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:
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ export const createWebGitAPI = (): GitAPI => ({
|
||||
getGitDiff: gitApiHttp.getGitDiff,
|
||||
getGitFileDiff: gitApiHttp.getGitFileDiff,
|
||||
getGitRangeDiff: gitApiHttp.getGitRangeDiff,
|
||||
getGitRangeFiles: gitApiHttp.getGitRangeFiles,
|
||||
getBranchBase: gitApiHttp.getBranchBase,
|
||||
revertGitFile: gitApiHttp.revertGitFile,
|
||||
stageGitFile: gitApiHttp.stageGitFile,
|
||||
stageGitFiles: gitApiHttp.stageGitFiles,
|
||||
|
||||
Reference in New Issue
Block a user