feat(diff): add branch scope to context panel diff view
Show every change on the current branch relative to its base in the Changed/Staged/Last turn dropdown. The base comes from the branch's reflog record or an explicit per-branch user choice (persisted), never a main/master guess; when git has no record the user picks a base once from a searchable branch list. - server: GET /api/git/branch-base (reflog-derived base), GET /api/git/range-files (name-status -z with rename/copy destination paths and -C copy detection) - shared UI: optional getBranchBase/getGitRangeFiles runtime APIs with boundary parsing; persisted per-branch overrides keyed by runtime+directory+branch - DiffView: branch scope with confirmed-unavailability coercion of persisted tabs (detached HEAD, default-branch checkout, metadata settled without a default), range-invalidated diff cache guarded against stale completions, bounded branch-metadata retry, read-only diff actions in branch scope; hidden in VS Code - helper module branchDiffScope.ts with tests for coercion, availability, race conditions, and retry exhaustion
This commit is contained in:
@@ -2654,6 +2654,71 @@ export async function getRangeDiff(directory, { base, head, path: filePath, cont
|
||||
return diff;
|
||||
}
|
||||
|
||||
const BRANCH_CREATION_SOURCE_RE = /^branch: Created from (.+)$/;
|
||||
|
||||
/**
|
||||
* Parse a branch reflog (`git reflog show --format=%gs <branch>`) and return the
|
||||
* ref the branch was created from, when that source is itself a named ref.
|
||||
*
|
||||
* Returns null when the branch was created from `HEAD@{...}` or a raw commit
|
||||
* (detached start): the original branch name is not recorded anywhere in that
|
||||
* case, and guessing a base from commit topology would be a heuristic, not an
|
||||
* answer. Callers should ask the user to pick a base instead.
|
||||
*/
|
||||
export function parseBranchCreationSource(reflogText) {
|
||||
const lines = String(reflogText || '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
// Reflog lists newest entries first; the creation entry is the oldest one.
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const match = lines[index].match(BRANCH_CREATION_SOURCE_RE);
|
||||
if (!match) continue;
|
||||
const source = match[1].trim();
|
||||
if (!source || /^HEAD@/.test(source) || /^[0-9a-f]{7,40}$/i.test(source)) {
|
||||
return null;
|
||||
}
|
||||
return source;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the branch the given branch was created from, from its reflog.
|
||||
* Returns { base: null } when git has no authoritative record (clone, detached
|
||||
* start, reflog expired) — callers must not fall back to main/master.
|
||||
*/
|
||||
export async function getBranchBase(directory, branch) {
|
||||
const branchName = String(branch || '').trim();
|
||||
if (!branchName) {
|
||||
throw new Error('branch is required');
|
||||
}
|
||||
|
||||
const { git } = await createRepositoryGitContext(directory);
|
||||
|
||||
let reflog = '';
|
||||
try {
|
||||
reflog = await git.raw(['reflog', 'show', '--format=%gs', branchName]);
|
||||
} catch {
|
||||
return { base: null };
|
||||
}
|
||||
|
||||
const source = parseBranchCreationSource(reflog);
|
||||
if (!source || source === branchName) {
|
||||
return { base: null };
|
||||
}
|
||||
|
||||
const resolves = await git
|
||||
.raw(['rev-parse', '--verify', '--quiet', source])
|
||||
.then((value) => Boolean(String(value || '').trim()))
|
||||
.catch(() => false);
|
||||
if (!resolves) {
|
||||
return { base: null };
|
||||
}
|
||||
|
||||
return { base: source };
|
||||
}
|
||||
|
||||
export async function getRangeFiles(directory, { base, head } = {}) {
|
||||
const { git } = await createRepositoryGitContext(directory);
|
||||
const baseRef = typeof base === 'string' ? base.trim() : '';
|
||||
@@ -2673,11 +2738,26 @@ export async function getRangeFiles(directory, { base, head } = {}) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const raw = await git.raw(['diff', '--name-only', `${resolvedBase}...${headRef}`]);
|
||||
return String(raw || '')
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
// `-C` (copy detection among changed files only, so cheap) makes copies
|
||||
// surface as C entries instead of plain additions; rename detection is on
|
||||
// by default.
|
||||
const raw = await git.raw(['diff', '--name-status', '-z', '-C', `${resolvedBase}...${headRef}`]);
|
||||
// -z format: STATUS\0PATH\0[ORIG\0] repeated. For rename/copy entries
|
||||
// (`R100`, `C75`) the first path token is the ORIGINAL path and the second
|
||||
// is the DESTINATION — the diff (and the UI) must address the destination.
|
||||
const tokens = String(raw || '').split('\0');
|
||||
const files = [];
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const status = (tokens[index] || '').trim();
|
||||
if (!status) continue;
|
||||
const isRenameOrCopy = status.startsWith('R') || status.startsWith('C');
|
||||
const path = isRenameOrCopy ? (tokens[index + 2] || '').trim() : (tokens[index + 1] || '').trim();
|
||||
index += isRenameOrCopy ? 2 : 1;
|
||||
if (path) {
|
||||
files.push({ path, status: status.charAt(0) });
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif'];
|
||||
|
||||
Reference in New Issue
Block a user