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:
Bohdan Triapitsyn
2026-08-22 01:10:19 +03:00
parent 9f7d839fc6
commit 0b01f5ae2d
24 changed files with 1582 additions and 38 deletions
+43
View File
@@ -428,6 +428,49 @@ export function registerGitRoutes(app) {
}
});
app.get('/api/git/branch-base', async (req, res) => {
const { getBranchBase } = await getGitLibraries();
try {
const directory = resolveDirectoryQuery(req.query.directory);
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const branch = resolveDirectoryQuery(req.query.branch);
if (!branch) {
return res.status(400).json({ error: 'branch parameter is required' });
}
const result = await getBranchBase(directory, branch);
res.json(result);
} catch (error) {
console.error('Failed to get branch base:', error);
res.status(500).json({ error: error.message || 'Failed to get branch base' });
}
});
app.get('/api/git/range-files', async (req, res) => {
const { getRangeFiles } = await getGitLibraries();
try {
const directory = resolveDirectoryQuery(req.query.directory);
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const base = resolveDirectoryQuery(req.query.base);
const head = resolveDirectoryQuery(req.query.head);
if (!base || !head) {
return res.status(400).json({ error: 'base and head parameters are required' });
}
const files = await getRangeFiles(directory, { base, head });
res.json({ files });
} catch (error) {
console.error('Failed to get git range files:', error);
res.status(500).json({ error: error.message || 'Failed to get git range files' });
}
});
app.post('/api/git/revert', async (req, res) => {
const { revertFile } = await getGitLibraries();
try {
+85 -5
View File
@@ -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'];
@@ -28,6 +28,8 @@ import {
getDiff,
getFileDiff,
validateWorktreeCreate,
parseBranchCreationSource,
getRangeFiles,
} from './service.js';
// ---------------------------------------------------------------------------
@@ -1336,3 +1338,94 @@ describe.runIf(canRunGit())('getRangeDiff', () => {
expect(diff).toContain('feature.txt');
});
});
describe('parseBranchCreationSource', () => {
it('returns the source ref from the oldest creation entry', () => {
// Reflog lists newest entries first; creation is the last line.
const reflog = [
'commit: abc123',
'branch: Created from origin/main',
].join('\n');
expect(parseBranchCreationSource(reflog)).toBe('origin/main');
});
it('returns null when the branch was created from a detached HEAD pointer', () => {
const reflog = 'branch: Created from HEAD@{0}';
expect(parseBranchCreationSource(reflog)).toBeNull();
});
it('returns null when the branch was created from a raw commit', () => {
const reflog = 'branch: Created from 9a3b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b';
expect(parseBranchCreationSource(reflog)).toBeNull();
});
it('returns null when there is no creation entry', () => {
const reflog = ['commit: abc123', 'reset: moving to HEAD'].join('\n');
expect(parseBranchCreationSource(reflog)).toBeNull();
});
it('returns null for empty input', () => {
expect(parseBranchCreationSource('')).toBeNull();
expect(parseBranchCreationSource(undefined)).toBeNull();
});
});
describe.runIf(canRunGit())('getRangeFiles', () => {
it('returns added and modified paths with their status letters', async () => {
const { repository } = createRepositoryWithRemote();
fs.writeFileSync(path.join(repository, 'added.txt'), 'new\n');
fs.writeFileSync(path.join(repository, 'README.md'), '# Test\nchanged\n');
runGit(repository, ['add', 'added.txt', 'README.md']);
runGit(repository, ['commit', '-m', 'changes']);
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
expect(files).toEqual(expect.arrayContaining([
{ path: 'added.txt', status: 'A' },
{ path: 'README.md', status: 'M' },
]));
});
it('reports the destination path for renamed files, including spaces', async () => {
const { repository } = createRepositoryWithRemote();
// The original file must exist in the base: rename detection pairs a
// deletion against an addition relative to base, not within the branch.
fs.writeFileSync(path.join(repository, 'old name with spaces.md'), '# Test\n');
runGit(repository, ['add', 'old name with spaces.md']);
runGit(repository, ['commit', '-m', 'add file to rename']);
runGit(repository, ['push', 'origin', 'HEAD:react']);
// Spaces in filenames exercise the -z token split: a newline split would
// mangle these paths long before status letters matter.
fs.renameSync(path.join(repository, 'old name with spaces.md'), path.join(repository, 'new name with spaces.md'));
runGit(repository, ['add', '-A']);
runGit(repository, ['commit', '-m', 'rename']);
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
const renameEntry = files.find((file) => file.status === 'R');
expect(renameEntry).toBeDefined();
expect(renameEntry.path).toBe('new name with spaces.md');
expect(files.some((file) => file.path === 'old name with spaces.md')).toBe(false);
});
it('reports the destination path for copied files', async () => {
const { repository } = createRepositoryWithRemote();
// The source must exist in the base. Copy detection needs the repository's
// own `diff.renames=copies` setting on top of the service's -C flag; the
// parser must survive whatever C entries git emits.
runGit(repository, ['config', 'diff.renames', 'copies']);
fs.writeFileSync(path.join(repository, 'copied source.md'), '# Copy me\n');
runGit(repository, ['add', 'copied source.md']);
runGit(repository, ['commit', '-m', 'add source']);
runGit(repository, ['push', 'origin', 'HEAD:react']);
fs.copyFileSync(path.join(repository, 'copied source.md'), path.join(repository, 'copied destination.md'));
runGit(repository, ['add', '-A']);
runGit(repository, ['commit', '-m', 'copy']);
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
const copyEntry = files.find((file) => file.status === 'C');
expect(copyEntry).toBeDefined();
expect(copyEntry.path).toBe('copied destination.md');
});
});