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
@@ -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');
});
});