feat(ui): unify change comparisons and compact message actions
Branch comparisons could retain an old base or omit local edits, while Changes and walkthrough selected their sources independently. Share branch and commit selectors across both panels, honor exact refs, include local branch edits, and support first-parent commit diffs with the latest 50 commits. Compact message metadata and move touch actions into a shared sheet. Validated with workspace type-check, lint and build, focused Git and UI tests, and maintainer testing in the app.
This commit is contained in:
@@ -26,8 +26,10 @@ The following functions are exported and used by the web server:
|
||||
### Status and Diff Operations
|
||||
- `getStatus(directory)`: Get comprehensive Git status including current branch, tracking, ahead/behind, file changes, diff stats, merge/rebase state.
|
||||
- `getDiff(directory, { path, staged, contextLines })`: Get diff output for files or entire working tree. Untracked symbolic links are represented as link entries without following their targets.
|
||||
- `getRangeDiff(directory, { base, head, path, contextLines })`: Get diff between two refs. Uses three-dot `base...head` semantics, so work merged into `head` from `base` is excluded and only the branch's own changes are returned. Prefers `origin/<base>` when that remote-tracking ref exists, so a stale local base branch does not resurface already-merged commits. Exposed as `GET /api/git/range-diff` (`path` optional; omit it for the whole range).
|
||||
- `getRangeFiles(directory, { base, head })`: Get list of changed files between two refs.
|
||||
- `getRangeDiff(directory, { base, head, path, contextLines, includeWorkingTree })`: Compare the merge base of the exact selected refs with `head`. With `includeWorkingTree: true`, compare with the checked-out branch's current files instead, including committed, staged, unstaged, and untracked work in one net diff. This mode rejects a head that is not the checked-out branch. Exposed as `GET /api/git/range-diff`; omit `path` for the whole comparison.
|
||||
- `getRangeFiles(directory, { base, head, includeWorkingTree })`: List changed paths using the same comparison as `getRangeDiff`. A successful empty list means the final files match the merge base, even if staging and working-tree changes cancel each other out.
|
||||
- Both range operations honor refs literally. A local `main` is never replaced with `origin/main`, and an unavailable ref fails rather than choosing a different remote. The UI picker sends qualified refs to distinguish local and remote branches with matching display names.
|
||||
- Working-tree comparisons use the real index read-only. When untracked paths exist, a temporary copy of the index receives intent-to-add entries so Git computes additions, deletions, recreations, and renames together. Current contents come from the working tree, symlinks remain links, ignored files stay excluded, and temporary files are removed on success or failure.
|
||||
- `getFileDiff(directory, { path, staged })`: Get original and modified file contents for a single file (handles images as data URLs and symbolic links as their link-target text).
|
||||
- `listUntrackedPaths(directory)`: List individual untracked file paths honoring ignore rules. Much cheaper than `getStatus` when that is all a caller needs. Deliberately not `--directory`: collapsed directory entries end in a slash and are rejected by the per-file diff helpers, so a caller would silently lose every file inside a new directory.
|
||||
- `getUntrackedDiffs(directory, filePaths, { concurrency, contextLines })`: Diffs for untracked files against an empty tree. Resolves the repository context once instead of per file (`getDiff` re-resolves every call, costing an extra `rev-parse` each time) and bounds how many diff processes run at once. Returns one entry per input path in order; unreadable paths yield `''` rather than failing the batch.
|
||||
@@ -38,6 +40,7 @@ The following functions are exported and used by the web server:
|
||||
- `applyHunk(directory, filePath, options)`: Apply a single-hunk patch via `git apply`. `options.action` is `stage` (`git apply --cached`), `unstage` (`git apply --cached --reverse`), or `discard` (`git apply --reverse` in the working tree). The patch is written to a temp file; a `--check` runs first so a stale hunk fails with a clear "refresh and try again" error instead of a partial mutation. The patch target path must match the requested file.
|
||||
|
||||
### Branch Operations
|
||||
- `getBranchBase(directory, branch)`: Read a named creation source from reflog. After a rebase, the creation source is no longer a current parent record, so return `null` and let the user choose a base. Explicit per-runtime, directory, and branch choices in the shared UI outrank detection.
|
||||
- `getBranches(directory)`: Get list of local and remote branches (filtered to active remote branches).
|
||||
- `getUnpushedBranchCounts(directory, branchNames)`: Count commits ahead of each locally known upstream for up to five supplied local branches. This reads local refs only and omits branches without an upstream.
|
||||
- `createBranch(directory, branchName, options)`: Create and checkout a new branch.
|
||||
@@ -71,7 +74,8 @@ bootstrap, tracking is left unset rather than writing `branch.*.remote` /
|
||||
|
||||
### Log Operations
|
||||
- `getLog(directory, options)`: Get commit history with stats (supports maxCount, from, to, file filters).
|
||||
- `getCommitFiles(directory, commitHash)`: Get file changes for a specific commit.
|
||||
- `getCommitFiles(directory, commitHash)`: Get file changes for a specific commit relative to its first parent, or the empty tree for a root commit. NUL-delimited paths preserve whitespace; renamed files return their destination in `path` and source in `previousPath`.
|
||||
- `getCommitDiff(directory, { hash, path, previousPath, contextLines })`: Get the same commit's patch, with optional file filtering and context depth. `previousPath` keeps a rename's old and new paths in the per-file patch. Reads committed objects only, never the working tree. Exposed as `GET /api/git/commit-diff`; an unavailable hash fails rather than returning an empty diff.
|
||||
- `getCommitFileDiff(directory, hash, filePath, isBinary)`: Get before/after content for a specific file in a commit. Returns `{ original, modified, isBinary }`. Runs `git show <hash>^:<path>` and `git show <hash>:<path>` in parallel; returns empty strings on failure (added/deleted/root-commit edge cases).
|
||||
|
||||
### Merge and Rebase Operations
|
||||
@@ -130,6 +134,7 @@ The following functions are internal helpers used by exported functions:
|
||||
|
||||
### Runtime availability of range diffs
|
||||
- `GET /api/git/range-diff` is served by the OpenChamber web server, so it is available to web, desktop, and mobile clients. The shared `GitAPI.getGitRangeDiff` is therefore optional: web supplies the HTTP implementation, and VS Code does not implement it because the extension host serves Git through its own bridge rather than these routes. Features built on range diffs (currently the AI diff walkthrough) are not offered in VS Code.
|
||||
- Commit comparison uses the same server boundary through optional `GitAPI.getGitCommitDiff`. The shared Changes toolbar and walkthrough expose it on their existing desktop/tablet surfaces. The phone-specific Changes surface and the VS Code Git bridge keep their existing modes; Commit mode is not offered there. The HTTP operation is available to web, Electron, hosted mobile, and Capacitor clients.
|
||||
|
||||
### Staged and unstaged change handling
|
||||
- `status.files` exposes both `index` and `working_dir` codes. Shared UI uses these as separate scopes: staged rows are derived from non-empty `index` statuses, while unstaged rows are derived from `working_dir` statuses and untracked files.
|
||||
|
||||
@@ -7,13 +7,13 @@ export function registerGitRoutes(app) {
|
||||
return gitLibraries;
|
||||
};
|
||||
|
||||
const resolveDirectoryQuery = (value) => {
|
||||
const resolveDirectoryQuery = (value, preserveWhitespace = false) => {
|
||||
const raw = Array.isArray(value) ? value[0] : value;
|
||||
if (typeof raw !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
return trimmed || null;
|
||||
const normalized = preserveWhitespace ? raw : raw.trim();
|
||||
return normalized || null;
|
||||
};
|
||||
|
||||
const extractGitErrorText = (error) => {
|
||||
@@ -417,6 +417,7 @@ export function registerGitRoutes(app) {
|
||||
const diff = await getRangeDiff(directory, {
|
||||
base,
|
||||
head,
|
||||
includeWorkingTree: req.query.includeWorkingTree === 'true',
|
||||
path: pathParam,
|
||||
contextLines: Number.isFinite(context) ? context : 3,
|
||||
});
|
||||
@@ -463,7 +464,7 @@ export function registerGitRoutes(app) {
|
||||
return res.status(400).json({ error: 'base and head parameters are required' });
|
||||
}
|
||||
|
||||
const files = await getRangeFiles(directory, { base, head });
|
||||
const files = await getRangeFiles(directory, { base, head, includeWorkingTree: req.query.includeWorkingTree === 'true' });
|
||||
res.json({ files });
|
||||
} catch (error) {
|
||||
console.error('Failed to get git range files:', error);
|
||||
@@ -1300,6 +1301,25 @@ export function registerGitRoutes(app) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/git/commit-diff', async (req, res) => {
|
||||
const { getCommitDiff } = await getGitLibraries();
|
||||
try {
|
||||
const directory = resolveDirectoryQuery(req.query.directory);
|
||||
const hash = resolveDirectoryQuery(req.query.hash);
|
||||
if (!directory || !hash) return res.status(400).json({ error: 'directory and hash are required' });
|
||||
const context = Number(req.query.context ?? 3);
|
||||
const diff = await getCommitDiff(directory, {
|
||||
hash,
|
||||
path: resolveDirectoryQuery(req.query.path, true) ?? undefined,
|
||||
previousPath: resolveDirectoryQuery(req.query.previousPath, true) ?? undefined,
|
||||
contextLines: Number.isFinite(context) ? context : 3,
|
||||
});
|
||||
res.json({ diff });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message || 'Failed to get commit diff' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/git/commit-file-diff', async (req, res) => {
|
||||
const { getCommitFileDiff } = await getGitLibraries();
|
||||
try {
|
||||
|
||||
@@ -2628,7 +2628,51 @@ async function assertRangeRefsResolve(git, refs) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRangeDiff(directory, { base, head, path: filePath, contextLines = 3 } = {}) {
|
||||
// A private index lets git include untracked paths in the same tree comparison
|
||||
// as tracked files, including a staged deletion recreated at the same path.
|
||||
// Intent-to-add records only their existence; diff reads current file contents.
|
||||
async function runWorkingTreeRangeDiff(context, baseRef, headRef, args, paths = []) {
|
||||
const { git, repoRoot } = context;
|
||||
const readHead = async () => {
|
||||
const commit = (await git.raw(['rev-parse', '--verify', 'HEAD'])).trim();
|
||||
const ref = (await git.raw(['symbolic-ref', '--quiet', 'HEAD'])).trim();
|
||||
return `${commit}\n${ref}`;
|
||||
};
|
||||
const startingHead = await readHead();
|
||||
const [headCommit, currentRef] = startingHead.split('\n');
|
||||
const requestedRef = (await git.raw(['rev-parse', '--verify', '--symbolic-full-name', '--end-of-options', headRef])).trim();
|
||||
if (requestedRef !== currentRef) {
|
||||
throw new Error('Working-tree comparisons require the checked-out branch. Refresh and try again.');
|
||||
}
|
||||
const mergeBase = (await git.raw(['merge-base', baseRef, headCommit])).trim();
|
||||
const readDiff = async (comparisonGit) => {
|
||||
const diff = await comparisonGit.raw([...args, mergeBase, '--', ...paths]);
|
||||
if (await readHead() !== startingHead) {
|
||||
throw new Error('The checked-out branch changed during comparison. Refresh and try again.');
|
||||
}
|
||||
return diff;
|
||||
};
|
||||
const untracked = await git.raw(['ls-files', '--others', '--exclude-standard', '-z', '--', ...paths]);
|
||||
if (!untracked) return readDiff(git);
|
||||
|
||||
const temporaryDirectory = await fsp.mkdtemp(path.join(os.tmpdir(), 'openchamber-branch-diff-'));
|
||||
try {
|
||||
const indexPath = (await git.raw(['rev-parse', '--git-path', 'index'])).trim();
|
||||
const temporaryIndex = path.join(temporaryDirectory, 'index');
|
||||
await fsp.copyFile(path.resolve(repoRoot, indexPath), temporaryIndex);
|
||||
const pathspecFile = path.join(temporaryDirectory, 'paths');
|
||||
await fsp.writeFile(pathspecFile, untracked);
|
||||
const comparisonGit = await createGit(repoRoot);
|
||||
comparisonGit.env('GIT_INDEX_FILE', temporaryIndex);
|
||||
comparisonGit.env('GIT_LITERAL_PATHSPECS', '1');
|
||||
await comparisonGit.raw(['add', '--intent-to-add', '--pathspec-from-file=' + pathspecFile, '--pathspec-file-nul']);
|
||||
return await readDiff(comparisonGit);
|
||||
} finally {
|
||||
await fsp.rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRangeDiff(directory, { base, head, path: filePath, contextLines = 3, includeWorkingTree = false } = {}) {
|
||||
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
|
||||
const baseRef = typeof base === 'string' ? base.trim() : '';
|
||||
const headRef = typeof head === 'string' ? head.trim() : '';
|
||||
@@ -2636,51 +2680,39 @@ export async function getRangeDiff(directory, { base, head, path: filePath, cont
|
||||
throw new Error('base and head are required');
|
||||
}
|
||||
|
||||
// Prefer remote-tracking base ref so merged commits don't reappear
|
||||
// when local base branch is stale (common when user stays on feature branch).
|
||||
let resolvedBase = baseRef;
|
||||
const originCandidate = `refs/remotes/origin/${baseRef}`;
|
||||
try {
|
||||
const verified = await git.raw(['rev-parse', '--verify', originCandidate]);
|
||||
if (verified && verified.trim()) {
|
||||
resolvedBase = `origin/${baseRef}`;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Not every repository has an `origin`. When the base names a branch that
|
||||
// exists only on another remote, a bare name does not resolve — git looks in
|
||||
// refs/heads, not across remotes — and the diff fails with "ambiguous
|
||||
// argument". Fall back to whichever remote actually carries it.
|
||||
if (resolvedBase === baseRef && !/[*?[\]^~:\\]/.test(baseRef)) {
|
||||
const resolvesLocally = await git
|
||||
.raw(['rev-parse', '--verify', `refs/heads/${baseRef}`])
|
||||
.then((value) => Boolean(String(value || '').trim()))
|
||||
.catch(() => false);
|
||||
|
||||
if (!resolvesLocally) {
|
||||
const remoteMatch = await git
|
||||
.raw(['for-each-ref', '--count=1', '--format=%(refname:short)', `refs/remotes/*/${baseRef}`])
|
||||
.then((value) => String(value || '').trim())
|
||||
.catch(() => '');
|
||||
if (remoteMatch) {
|
||||
resolvedBase = remoteMatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await assertRangeRefsResolve(git, [resolvedBase, headRef]);
|
||||
await assertRangeRefsResolve(git, [baseRef, headRef]);
|
||||
|
||||
const args = ['diff', '--no-color'];
|
||||
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
|
||||
args.push(`-U${Math.max(0, contextLines)}`);
|
||||
}
|
||||
args.push(`${resolvedBase}...${headRef}`);
|
||||
const paths = [];
|
||||
if (filePath) {
|
||||
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
||||
args.push('--', fileContext.repoPath);
|
||||
try {
|
||||
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
||||
paths.push(fileContext.repoPath);
|
||||
} catch (error) {
|
||||
if (error.message !== 'Invalid file path') throw error;
|
||||
// A committed deletion is absent from HEAD, the index, and the working
|
||||
// tree. It is still a valid range path when it exists at the merge base.
|
||||
const mergeBase = (await git.raw(['merge-base', baseRef, headRef])).trim();
|
||||
for (const root of new Set([repoRoot, directoryPath])) {
|
||||
const target = path.resolve(root, filePath);
|
||||
if (!isInsideOrSameDirectory(repoRoot, target)) continue;
|
||||
const repoPath = toGitPath(path.relative(repoRoot, target));
|
||||
const exists = await git.raw(['cat-file', '-e', `${mergeBase}:${repoPath}`]).then(() => true).catch(() => false);
|
||||
if (exists) {
|
||||
paths.push(repoPath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (paths.length === 0) throw error;
|
||||
}
|
||||
}
|
||||
if (includeWorkingTree) {
|
||||
return runWorkingTreeRangeDiff({ git, repoRoot }, baseRef, headRef, args, paths);
|
||||
}
|
||||
args.push(`${baseRef}...${headRef}`, '--', ...paths);
|
||||
const diff = await git.raw(args);
|
||||
return diff;
|
||||
}
|
||||
@@ -2702,6 +2734,9 @@ export function parseBranchCreationSource(reflogText) {
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
// Rebase records its destination as a commit, not a parent branch. The
|
||||
// creation ref is no longer evidence of the current base after restacking.
|
||||
if (lines.some((line) => /^rebase(?:\s|\()/.test(line))) return null;
|
||||
// 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);
|
||||
@@ -2753,31 +2788,23 @@ export async function getBranchBase(directory, branch) {
|
||||
return { base: source };
|
||||
}
|
||||
|
||||
export async function getRangeFiles(directory, { base, head } = {}) {
|
||||
const { git } = await createRepositoryGitContext(directory);
|
||||
export async function getRangeFiles(directory, { base, head, includeWorkingTree = false } = {}) {
|
||||
const { git, repoRoot } = await createRepositoryGitContext(directory);
|
||||
const baseRef = typeof base === 'string' ? base.trim() : '';
|
||||
const headRef = typeof head === 'string' ? head.trim() : '';
|
||||
if (!baseRef || !headRef) {
|
||||
throw new Error('base and head are required');
|
||||
}
|
||||
|
||||
let resolvedBase = baseRef;
|
||||
const originCandidate = `refs/remotes/origin/${baseRef}`;
|
||||
try {
|
||||
const verified = await git.raw(['rev-parse', '--verify', originCandidate]);
|
||||
if (verified && verified.trim()) {
|
||||
resolvedBase = `origin/${baseRef}`;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
await assertRangeRefsResolve(git, [resolvedBase, headRef]);
|
||||
await assertRangeRefsResolve(git, [baseRef, headRef]);
|
||||
|
||||
// `-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}`]);
|
||||
const args = ['diff', '--name-status', '-z', '-C'];
|
||||
const raw = includeWorkingTree
|
||||
? await runWorkingTreeRangeDiff({ git, repoRoot }, baseRef, headRef, args)
|
||||
: await git.raw([...args, `${baseRef}...${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.
|
||||
@@ -2787,7 +2814,7 @@ export async function getRangeFiles(directory, { base, head } = {}) {
|
||||
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();
|
||||
const path = isRenameOrCopy ? (tokens[index + 2] || '') : (tokens[index + 1] || '');
|
||||
index += isRenameOrCopy ? 2 : 1;
|
||||
if (path) {
|
||||
files.push({ path, status: status.charAt(0) });
|
||||
@@ -2833,27 +2860,6 @@ const parseIsBinaryFromNumstat = (raw) => {
|
||||
return added === '-' || deleted === '-';
|
||||
};
|
||||
|
||||
const extractGitStatusPath = (status, pathPart) => {
|
||||
if ((status === 'R' || status === 'C') && pathPart.includes('\t')) {
|
||||
return pathPart.split('\t').pop() || pathPart;
|
||||
}
|
||||
return pathPart;
|
||||
};
|
||||
|
||||
const extractGitNumstatDestinationPath = (filePath) => {
|
||||
if (!filePath.includes(' => ')) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const braceMatch = filePath.match(/^(.*)\{([^{}]*)\s=>\s([^{}]*)\}(.*)$/);
|
||||
if (braceMatch) {
|
||||
const [, prefix, , destination, suffix] = braceMatch;
|
||||
return `${prefix}${destination}${suffix}`.replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
return filePath.split(' => ').pop()?.trim() || filePath;
|
||||
};
|
||||
|
||||
const looksBinaryBySniff = async (absolutePath) => {
|
||||
try {
|
||||
const handle = await fsp.open(absolutePath, 'r');
|
||||
@@ -4679,12 +4685,11 @@ export async function getLog(directory, options = {}) {
|
||||
};
|
||||
const resolvedFrom = await resolveBaseRefForLog(options.from, checkRef);
|
||||
|
||||
const baseLog = await git.log({
|
||||
maxCount,
|
||||
from: resolvedFrom,
|
||||
to: options.to,
|
||||
file: filePath
|
||||
});
|
||||
// simple-git's `to` alone means HEAD..to, which is empty for the current
|
||||
// branch. A single requested ref means its reachable history instead.
|
||||
const baseLog = options.to && !resolvedFrom
|
||||
? await git.log([`--max-count=${maxCount}`, options.to, ...(filePath ? ['--', filePath] : [])])
|
||||
: await git.log({ maxCount, from: resolvedFrom, to: options.to, file: filePath });
|
||||
|
||||
const logArgs = [
|
||||
'log',
|
||||
@@ -4928,85 +4933,61 @@ export async function canonicalizeWorktreeState(directory) {
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveCommitHash(git, hash) {
|
||||
if (!/^[0-9a-f]{7,64}$/i.test(hash)) throw new Error('A commit hash is required');
|
||||
return (await git.raw(['rev-parse', '--verify', '--end-of-options', `${hash}^{commit}`])).trim();
|
||||
}
|
||||
|
||||
const commitShowArgs = (hash) => ['show', '--format=', '--root', '--diff-merges=first-parent', '--find-renames', hash];
|
||||
|
||||
export async function getCommitDiff(directory, { hash, path: filePath, previousPath, contextLines = 3 } = {}) {
|
||||
const { git } = await createRepositoryGitContext(directory);
|
||||
const commit = await resolveCommitHash(git, hash);
|
||||
const paths = [filePath, previousPath].filter(Boolean).map((value) => `:(literal)${value}`);
|
||||
return git.raw([
|
||||
...commitShowArgs(commit), '--no-color', '--no-ext-diff', `-U${Math.max(0, contextLines)}`,
|
||||
'--', ...paths,
|
||||
]);
|
||||
}
|
||||
|
||||
export async function getCommitFiles(directory, commitHash) {
|
||||
const { git } = await createRepositoryGitContext(directory);
|
||||
|
||||
try {
|
||||
|
||||
const numstatRaw = await git.raw([
|
||||
'show',
|
||||
'--numstat',
|
||||
'--format=',
|
||||
commitHash
|
||||
]);
|
||||
|
||||
const files = [];
|
||||
const lines = numstatRaw.trim().split('\n').filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split('\t');
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const [insertionsRaw, deletionsRaw, ...pathParts] = parts;
|
||||
const filePath = pathParts.join('\t');
|
||||
if (!filePath) continue;
|
||||
|
||||
const insertions = insertionsRaw === '-' ? 0 : parseInt(insertionsRaw, 10) || 0;
|
||||
const deletions = deletionsRaw === '-' ? 0 : parseInt(deletionsRaw, 10) || 0;
|
||||
const isBinary = insertionsRaw === '-' && deletionsRaw === '-';
|
||||
|
||||
let changeType = 'M';
|
||||
let displayPath = filePath;
|
||||
|
||||
if (filePath.includes(' => ')) {
|
||||
changeType = 'R';
|
||||
|
||||
const match = filePath.match(/(?:\{[^}]*\s=>\s[^}]*\}|.*\s=>\s.*)/);
|
||||
if (match) {
|
||||
displayPath = filePath;
|
||||
}
|
||||
}
|
||||
|
||||
files.push({
|
||||
path: displayPath,
|
||||
insertions,
|
||||
deletions,
|
||||
isBinary,
|
||||
changeType
|
||||
});
|
||||
const hash = await resolveCommitHash(git, commitHash);
|
||||
const [numstat, nameStatus] = await Promise.all([
|
||||
git.raw([...commitShowArgs(hash), '--numstat', '-z', '--']),
|
||||
git.raw([...commitShowArgs(hash), '--name-status', '-z', '--']),
|
||||
]);
|
||||
const stats = new Map();
|
||||
const tokens = numstat.split('\0');
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const match = /^(\d+|-)\t(\d+|-)\t([\s\S]*)$/.exec(tokens[index]);
|
||||
if (!match) continue;
|
||||
let destination = match[3];
|
||||
if (!destination) {
|
||||
destination = tokens[index + 2];
|
||||
index += 2;
|
||||
}
|
||||
|
||||
const nameStatusRaw = await git.raw([
|
||||
'show',
|
||||
'--name-status',
|
||||
'--format=',
|
||||
commitHash
|
||||
]).catch(() => '');
|
||||
|
||||
const statusMap = new Map();
|
||||
const statusLines = nameStatusRaw.trim().split('\n').filter(Boolean);
|
||||
for (const line of statusLines) {
|
||||
const match = line.match(/^([AMDRC])\d*\t(.+)$/);
|
||||
if (match) {
|
||||
const [, status, pathPart] = match;
|
||||
statusMap.set(extractGitStatusPath(status, pathPart), status);
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const basePath = extractGitNumstatDestinationPath(file.path);
|
||||
|
||||
const status = statusMap.get(basePath) || statusMap.get(file.path);
|
||||
if (status) {
|
||||
file.changeType = status;
|
||||
}
|
||||
}
|
||||
|
||||
return { files };
|
||||
} catch (error) {
|
||||
console.error('Failed to get commit files:', error);
|
||||
throw error;
|
||||
stats.set(destination, {
|
||||
insertions: Number.parseInt(match[1], 10) || 0,
|
||||
deletions: Number.parseInt(match[2], 10) || 0,
|
||||
isBinary: match[1] === '-',
|
||||
});
|
||||
}
|
||||
const files = [];
|
||||
const names = nameStatus.split('\0');
|
||||
for (let index = 0; index < names.length; index += 1) {
|
||||
const changeType = names[index].charAt(0);
|
||||
if (!changeType) continue;
|
||||
const renamed = changeType === 'R' || changeType === 'C';
|
||||
const previousPath = renamed ? names[++index] : undefined;
|
||||
const filePath = names[++index];
|
||||
const fileStats = stats.get(filePath);
|
||||
if (!filePath || !fileStats) throw new Error('Incomplete commit file statistics');
|
||||
const entry = { path: filePath, ...fileStats, changeType };
|
||||
if (previousPath) entry.previousPath = previousPath;
|
||||
files.push(entry);
|
||||
}
|
||||
return { files };
|
||||
}
|
||||
|
||||
export async function renameBranch(directory, oldName, newName) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import simpleGit from 'simple-git';
|
||||
import { loadSourceSections, parseSource, sourceKey } from '../walkthrough/sources.js';
|
||||
import { registerGitRoutes } from './routes.js';
|
||||
|
||||
import {
|
||||
checkoutBranch,
|
||||
@@ -14,6 +16,10 @@ import {
|
||||
getBranches,
|
||||
getUnpushedBranchCounts,
|
||||
getRangeDiff,
|
||||
getBranchBase,
|
||||
getCommitDiff,
|
||||
getCommitFiles,
|
||||
getLog,
|
||||
getStatus,
|
||||
getWorktrees,
|
||||
isGitRepository,
|
||||
@@ -1704,18 +1710,249 @@ describe.runIf(canRunGit())('getUnpushedBranchCounts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(canRunGit())('commit comparisons', () => {
|
||||
it('shows only the selected commit and gives walkthrough the identical patch', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), 'selected version\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
runGit(repository, ['commit', '-m', 'selected']);
|
||||
const hash = runGit(repository, ['rev-parse', 'HEAD']).trim();
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), 'later version\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
runGit(repository, ['commit', '-m', 'later']);
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), 'uncommitted version\n');
|
||||
const patch = await getCommitDiff(repository, { hash, path: 'README.md' });
|
||||
expect(patch).toContain('+selected version');
|
||||
expect(patch).not.toContain('later version');
|
||||
expect(patch).not.toContain('uncommitted version');
|
||||
expect((await getCommitFiles(repository, hash)).files).toEqual([
|
||||
{ path: 'README.md', insertions: 1, deletions: 1, isBinary: false, changeType: 'M' },
|
||||
]);
|
||||
const source = parseSource({ kind: 'commit', hash });
|
||||
expect(sourceKey(source)).toBe(`commit:${hash}`);
|
||||
expect((await loadSourceSections(repository, source)).sections).toEqual([{ scope: 'commit', patch }]);
|
||||
const routes = new Map();
|
||||
registerGitRoutes({
|
||||
get: (url, handler) => routes.set(url, handler), post() {}, put() {}, delete() {},
|
||||
});
|
||||
let response;
|
||||
await routes.get('/api/git/commit-diff')(
|
||||
{ query: { directory: repository, hash, path: 'README.md' } },
|
||||
{ json: (body) => { response = body; }, status: (code) => { throw new Error(`Unexpected status ${code}`); } },
|
||||
);
|
||||
expect(response).toEqual({ diff: patch });
|
||||
});
|
||||
|
||||
it('handles root and empty commits and rejects invalid hashes', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
const root = runGit(repository, ['rev-parse', 'HEAD']).trim();
|
||||
expect(await getCommitDiff(repository, { hash: root })).toContain('+# Test');
|
||||
expect((await getCommitFiles(repository, root)).files[0].changeType).toBe('A');
|
||||
runGit(repository, ['commit', '--allow-empty', '-m', 'empty']);
|
||||
const empty = runGit(repository, ['rev-parse', 'HEAD']).trim();
|
||||
expect(await getCommitDiff(repository, { hash: empty })).toBe('');
|
||||
expect(await getCommitFiles(repository, empty)).toEqual({ files: [] });
|
||||
expect(() => parseSource({ kind: 'commit', hash: 'HEAD' })).toThrow();
|
||||
expect(() => parseSource({ kind: 'commit', hash: [root] })).toThrow();
|
||||
await expect(getCommitDiff(repository, { hash: 'HEAD' })).rejects.toThrow();
|
||||
await expect(getCommitFiles(repository, '0'.repeat(40))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('keeps rename paths and original contents together, including whitespace in names', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
const destination = ' new\nname.md';
|
||||
runGit(repository, ['mv', 'README.md', destination]);
|
||||
runGit(repository, ['commit', '-m', 'rename']);
|
||||
const hash = runGit(repository, ['rev-parse', 'HEAD']).trim();
|
||||
const { files } = await getCommitFiles(repository, hash);
|
||||
expect(files).toEqual([{ path: destination, previousPath: 'README.md', changeType: 'R', insertions: 0, deletions: 0, isBinary: false }]);
|
||||
const patch = await getCommitDiff(repository, { hash, path: destination, previousPath: files[0].previousPath });
|
||||
expect(patch).toContain('rename from README.md');
|
||||
expect(patch).toContain('similarity index 100%');
|
||||
});
|
||||
|
||||
it('compares a merge commit against its first parent', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
runGit(repository, ['checkout', '-b', 'side']);
|
||||
fs.writeFileSync(path.join(repository, 'side.txt'), 'side\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
runGit(repository, ['commit', '-m', 'side']);
|
||||
runGit(repository, ['checkout', 'next']);
|
||||
fs.writeFileSync(path.join(repository, 'main.txt'), 'main\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
runGit(repository, ['commit', '-m', 'main']);
|
||||
runGit(repository, ['merge', '--no-ff', 'side', '-m', 'merge']);
|
||||
const hash = runGit(repository, ['rev-parse', 'HEAD']).trim();
|
||||
expect((await getCommitFiles(repository, hash)).files.map((file) => file.path)).toEqual(['side.txt']);
|
||||
const patch = await getCommitDiff(repository, { hash });
|
||||
expect(patch).toContain('+side');
|
||||
expect(patch).not.toContain('main.txt');
|
||||
});
|
||||
|
||||
it('limits current-branch history to 50 commits without including another branch', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
runGit(repository, ['checkout', '-b', 'other']);
|
||||
runGit(repository, ['commit', '--allow-empty', '-m', 'other branch only']);
|
||||
runGit(repository, ['checkout', 'next']);
|
||||
for (let index = 0; index < 51; index += 1) runGit(repository, ['commit', '--allow-empty', '-m', `current ${index}`]);
|
||||
const history = await getLog(repository, { maxCount: 50, to: 'refs/heads/next' });
|
||||
expect(history.all).toHaveLength(50);
|
||||
expect(history.all[0].message).toBe('current 50');
|
||||
expect(history.all.some((commit) => commit.message === 'other branch only')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(canRunGit())('getRangeDiff', () => {
|
||||
it('resolves a base that exists only on a remote other than origin', async () => {
|
||||
it('loads a committed deletion that no longer exists in HEAD or the working tree', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
runGit(repository, ['rm', 'README.md']);
|
||||
runGit(repository, ['commit', '-m', 'delete file']);
|
||||
const diff = await getRangeDiff(repository, { base: 'origin/react', head: 'next', path: 'README.md', includeWorkingTree: true });
|
||||
expect(diff).toContain('deleted file mode');
|
||||
expect(diff).toContain('-# Test');
|
||||
});
|
||||
|
||||
it('carries the working-tree option through the actual HTTP route handlers', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
fs.writeFileSync(path.join(repository, 'local.txt'), 'current local work\n');
|
||||
const routes = new Map();
|
||||
registerGitRoutes({
|
||||
get: (url, handler) => routes.set(url, handler),
|
||||
post() {},
|
||||
put() {},
|
||||
delete() {},
|
||||
});
|
||||
const query = { directory: repository, base: 'origin/react', head: 'next', includeWorkingTree: 'true' };
|
||||
for (const endpoint of ['range-diff', 'range-files']) {
|
||||
let status = 200;
|
||||
let body;
|
||||
const response = {
|
||||
status(value) { status = value; return this; },
|
||||
json(value) { body = value; },
|
||||
};
|
||||
await routes.get(`/api/git/${endpoint}`)({ query }, response);
|
||||
expect(status).toBe(200);
|
||||
if (endpoint === 'range-diff') expect(body.diff).toContain('+current local work');
|
||||
else expect(body.files).toEqual([{ path: 'local.txt', status: 'A' }]);
|
||||
}
|
||||
});
|
||||
|
||||
it('asks for a new base after restacking and compares against the selected parent', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
runGit(repository, ['checkout', '-b', 'child', 'origin/react']);
|
||||
fs.writeFileSync(path.join(repository, 'child.txt'), 'child\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
runGit(repository, ['commit', '-m', 'child']);
|
||||
expect(await getBranchBase(repository, 'child')).toEqual({ base: 'origin/react' });
|
||||
runGit(repository, ['checkout', '-b', 'parent', 'origin/react']);
|
||||
fs.writeFileSync(path.join(repository, 'parent.txt'), 'parent\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
runGit(repository, ['commit', '-m', 'parent']);
|
||||
runGit(repository, ['checkout', 'child']);
|
||||
runGit(repository, ['rebase', 'parent']);
|
||||
expect(await getBranchBase(repository, 'child')).toEqual({ base: null });
|
||||
fs.writeFileSync(path.join(repository, 'child.txt'), 'current child\n');
|
||||
const options = { base: 'refs/heads/parent', head: 'child', includeWorkingTree: true };
|
||||
expect(await getRangeFiles(repository, options)).toEqual([{ path: 'child.txt', status: 'A' }]);
|
||||
const diff = await getRangeDiff(repository, options);
|
||||
expect(diff).toContain('+current child');
|
||||
expect(diff).not.toContain('parent.txt');
|
||||
});
|
||||
|
||||
it('combines committed, staged, unstaged and untracked work without changing the real index', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), '# Committed\n');
|
||||
runGit(repository, ['add', 'README.md']);
|
||||
runGit(repository, ['commit', '-m', 'branch change']);
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), '# Staged\n');
|
||||
fs.writeFileSync(path.join(repository, 'staged.txt'), 'staged only\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), '# Current\n');
|
||||
fs.writeFileSync(path.join(repository, 'untracked.txt'), 'new local file\n');
|
||||
fs.writeFileSync(path.join(repository, ' leading space.txt'), 'space path\n');
|
||||
const indexBefore = fs.readFileSync(path.join(repository, '.git/index'));
|
||||
const options = { base: 'origin/react', head: 'next', includeWorkingTree: true };
|
||||
|
||||
const diff = await getRangeDiff(repository, options);
|
||||
expect(diff).toContain('-# Test');
|
||||
expect(diff).toContain('+# Current');
|
||||
expect(diff).not.toContain('+# Staged');
|
||||
expect(diff).not.toContain('+# Committed');
|
||||
expect(diff).toContain('+new local file');
|
||||
expect(diff).toContain('+staged only');
|
||||
expect(await getRangeFiles(repository, options)).toEqual(expect.arrayContaining([
|
||||
{ path: 'README.md', status: 'M' },
|
||||
{ path: 'staged.txt', status: 'A' },
|
||||
{ path: 'untracked.txt', status: 'A' },
|
||||
{ path: ' leading space.txt', status: 'A' },
|
||||
]));
|
||||
const { sections } = await loadSourceSections(repository, { kind: 'branch', baseRef: options.base, headRef: options.head });
|
||||
expect(sections).toEqual([{ scope: 'branch', patch: diff }]);
|
||||
expect(fs.readFileSync(path.join(repository, '.git/index'))).toEqual(indexBefore);
|
||||
|
||||
const committed = await getRangeDiff(repository, { base: options.base, head: options.head });
|
||||
expect(committed).toContain('+# Committed');
|
||||
expect(committed).not.toContain('+new local file');
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), '# Latest\n');
|
||||
expect(await getRangeDiff(repository, { ...options, path: 'README.md' })).toContain('+# Latest');
|
||||
});
|
||||
|
||||
it('reports the final file after a staged deletion is recreated, and omits undone branch changes', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
runGit(repository, ['rm', 'README.md']);
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), '# Recreated\n');
|
||||
const options = { base: 'origin/react', head: 'next', includeWorkingTree: true };
|
||||
expect(await getRangeFiles(repository, options)).toEqual([{ path: 'README.md', status: 'M' }]);
|
||||
const diff = await getRangeDiff(repository, options);
|
||||
expect(diff).toContain('-# Test');
|
||||
expect(diff).toContain('+# Recreated');
|
||||
expect(diff.match(/diff --git/g)).toHaveLength(1);
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), '# Test\n');
|
||||
expect(await getRangeFiles(repository, options)).toEqual([]);
|
||||
expect(await getRangeDiff(repository, options)).toBe('');
|
||||
});
|
||||
|
||||
it('keeps local and remote bases distinct and rejects a different checked-out branch', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
runGit(repository, ['branch', 'react']);
|
||||
fs.writeFileSync(path.join(repository, 'parent.txt'), 'parent work\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
runGit(repository, ['commit', '-m', 'parent work']);
|
||||
runGit(repository, ['branch', '-f', 'react', 'HEAD']);
|
||||
fs.writeFileSync(path.join(repository, 'child.txt'), 'child work\n');
|
||||
const options = { head: 'next', includeWorkingTree: true };
|
||||
const local = await getRangeDiff(repository, { ...options, base: 'react' });
|
||||
const remote = await getRangeDiff(repository, { ...options, base: 'origin/react' });
|
||||
expect(local).not.toContain('parent.txt');
|
||||
expect(remote).toContain('parent.txt');
|
||||
expect(local).toContain('child.txt');
|
||||
expect(await getRangeFiles(repository, { ...options, base: 'react' })).toEqual([{ path: 'child.txt', status: 'A' }]);
|
||||
runGit(repository, ['checkout', 'react']);
|
||||
await expect(getRangeDiff(repository, { ...options, base: 'origin/react' })).rejects.toThrow(/checked-out branch/);
|
||||
});
|
||||
|
||||
it('includes untracked symlinks as links without reading their targets', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
const outside = path.join(createTempDir(), 'outside.txt');
|
||||
fs.writeFileSync(outside, 'must not be in a diff\n');
|
||||
fs.symlinkSync(outside, path.join(repository, 'link.txt'));
|
||||
const diff = await getRangeDiff(repository, { base: 'origin/react', head: 'next', includeWorkingTree: true });
|
||||
expect(diff).toContain('new file mode 120000');
|
||||
expect(diff).toContain(outside);
|
||||
expect(diff).not.toContain('must not be in a diff');
|
||||
});
|
||||
|
||||
it('uses an explicitly selected base on a remote other than origin', async () => {
|
||||
const { repository } = createRepositoryWithRemote({ remoteName: 'upstream', defaultBranch: 'react' });
|
||||
// Only refs/remotes/upstream/react carries the base — git cannot resolve the
|
||||
// bare name, so an unqualified `react...next` fails with "ambiguous argument".
|
||||
// The selected remote ref must work without a local branch of that name.
|
||||
fs.writeFileSync(path.join(repository, 'feature.txt'), 'work\n');
|
||||
runGit(repository, ['add', 'feature.txt']);
|
||||
runGit(repository, ['commit', '-m', 'feature']);
|
||||
|
||||
const diff = await getRangeDiff(repository, { base: 'react', head: 'next' });
|
||||
const diff = await getRangeDiff(repository, { base: 'upstream/react', head: 'next' });
|
||||
|
||||
expect(diff).toContain('feature.txt');
|
||||
await expect(getRangeDiff(repository, { base: 'react', head: 'next' })).rejects.toThrow(/is not available locally/);
|
||||
});
|
||||
|
||||
it('names an unfetched remote-only ref instead of failing with git\'s ambiguous argument (#2735)', async () => {
|
||||
@@ -1728,6 +1965,9 @@ describe.runIf(canRunGit())('getRangeDiff', () => {
|
||||
});
|
||||
|
||||
describe('parseBranchCreationSource', () => {
|
||||
it('does not reuse the creation base after a rebase', () => {
|
||||
expect(parseBranchCreationSource('rebase (finish): refs/heads/feature onto abc123\nbranch: Created from main')).toBeNull();
|
||||
});
|
||||
it('returns the source ref from the oldest creation entry', () => {
|
||||
// Reflog lists newest entries first; creation is the last line.
|
||||
const reflog = [
|
||||
@@ -1774,7 +2014,7 @@ describe.runIf(canRunGit())('getRangeFiles', () => {
|
||||
runGit(repository, ['add', 'added.txt', 'README.md']);
|
||||
runGit(repository, ['commit', '-m', 'changes']);
|
||||
|
||||
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
|
||||
const files = await getRangeFiles(repository, { base: 'origin/react', head: 'next' });
|
||||
|
||||
expect(files).toEqual(expect.arrayContaining([
|
||||
{ path: 'added.txt', status: 'A' },
|
||||
@@ -1796,7 +2036,7 @@ describe.runIf(canRunGit())('getRangeFiles', () => {
|
||||
runGit(repository, ['add', '-A']);
|
||||
runGit(repository, ['commit', '-m', 'rename']);
|
||||
|
||||
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
|
||||
const files = await getRangeFiles(repository, { base: 'origin/react', head: 'next' });
|
||||
|
||||
const renameEntry = files.find((file) => file.status === 'R');
|
||||
expect(renameEntry).toBeDefined();
|
||||
@@ -1818,7 +2058,7 @@ describe.runIf(canRunGit())('getRangeFiles', () => {
|
||||
runGit(repository, ['add', '-A']);
|
||||
runGit(repository, ['commit', '-m', 'copy']);
|
||||
|
||||
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
|
||||
const files = await getRangeFiles(repository, { base: 'origin/react', head: 'next' });
|
||||
|
||||
const copyEntry = files.find((file) => file.status === 'C');
|
||||
expect(copyEntry).toBeDefined();
|
||||
|
||||
@@ -52,20 +52,39 @@ written against staged code never silently re-anchors onto an unstaged edit.
|
||||
| Kind | Sections | Notes |
|
||||
|---|---|---|
|
||||
| `working-tree` (`all` \| `staged` \| `working`) | `staged`, `working` | Untracked files are fetched individually because `git diff` omits them |
|
||||
| `branch` | `branch` | `getRangeDiff` uses three-dot `base...head`, so work merged in from the base branch is excluded |
|
||||
| `pr` | `pr:<number>` | GitHub returns the merge-base diff, matching the branch semantics |
|
||||
| `branch` | `branch` | `getRangeDiff` with `includeWorkingTree: true` compares the selected merge base with current files, including committed and local work in one net diff |
|
||||
| `commit` | `commit` | `getCommitDiff` compares the full selected commit hash with its first parent; root commits compare with an empty tree |
|
||||
| `pr` | `pr:<number>` | GitHub's committed pull-request diff, without local working-tree changes |
|
||||
|
||||
For the current-branch source, the UI takes the base from the default branch of
|
||||
the current branch's tracking remote (`defaultBranches` in the branches
|
||||
response), and only then falls back to the conventional names. It does not offer
|
||||
the source at all when the chosen base exists neither locally nor on a remote —
|
||||
a repository whose default is neither `main`, `master` nor `develop` used to be
|
||||
handed `main...<head>`, which git rejects outright.
|
||||
Changes and walkthrough resolve the current branch's base through
|
||||
`packages/ui/src/hooks/useBranchComparisonBase.ts`. An explicit choice in Changes
|
||||
outranks reflog detection. Both toolbars use
|
||||
`packages/ui/src/components/views/git/BranchComparisonSelector.tsx` to select or
|
||||
change the base directly. Walkthrough allows selecting Branch before a base is
|
||||
known and waits for a valid choice before loading or generating. Opening
|
||||
walkthrough from Changes carries the selected base and head; later selections
|
||||
in either toolbar update both comparisons.
|
||||
|
||||
A base that exists only on a remote still works: `getRangeDiff` prefers
|
||||
`origin/<base>` when it exists, and otherwise resolves the base through whichever
|
||||
remote carries it, because a bare branch name git cannot find in `refs/heads`
|
||||
fails the same way.
|
||||
Commit mode uses the shared `CommitComparisonSelector` in both toolbars. It
|
||||
lists the latest 50 commits reachable from the checked-out branch, with subject,
|
||||
author, date, and short hash. Opening the picker refreshes that list; selecting a
|
||||
commit changes the comparison, not the checkout. Changes hands the selected full
|
||||
hash to walkthrough. The server accepts full object IDs for commit sources and
|
||||
keys their cache entries and generation jobs as `commit:<hash>`, so reviews of
|
||||
different commits cannot overwrite each other. Existing source keys keep their
|
||||
format. Commit reads have no working-tree freshness dependency, and selecting a
|
||||
commit never starts model generation.
|
||||
|
||||
The Git module owns exact-ref and working-tree comparison semantics. Local and
|
||||
remote bases remain distinct, and a checkout during a branch review requires
|
||||
the source to be resolved for the new branch rather than including another
|
||||
branch's local files.
|
||||
|
||||
Successful status refreshes invalidate the visible branch comparison even when
|
||||
file names and insertion/deletion counts stay the same. Walkthrough refreshes
|
||||
its current hunk index while visible; regeneration remains user-initiated. The
|
||||
content-addressed cache continues to reuse an old review only when its hunks
|
||||
match, and otherwise reports stale anchors and uncovered current hunks.
|
||||
|
||||
The panel offers the current branch's pull request on its own: it registers with
|
||||
the shared GitHub PR status store (`useGitHubPrStatusStore`) rather than waiting
|
||||
|
||||
@@ -86,7 +86,9 @@ export function buildPrompt({ digest, fileCount, hunkCount, source, previousWalk
|
||||
? `Uncommitted local changes (${source.scope === 'all' ? 'staged and unstaged' : source.scope}).`
|
||||
: source.kind === 'branch'
|
||||
? `All work on branch "${source.headRef}" that is not in "${source.baseRef}". Changes merged in from ${source.baseRef} are already excluded.`
|
||||
: `Pull request #${source.number}.`;
|
||||
: source.kind === 'commit'
|
||||
? `Only the changes introduced by commit ${source.hash}, relative to its first parent (or the empty tree for a root commit).`
|
||||
: `Pull request #${source.number}.`;
|
||||
|
||||
const prompt = `Reviewing: ${sourceLine}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getDiff, getRangeDiff, getUntrackedDiffs, listUntrackedPaths } from '../git/service.js';
|
||||
import { getDiff, getRangeDiff, getCommitDiff, getUntrackedDiffs, listUntrackedPaths } from '../git/service.js';
|
||||
|
||||
// A walkthrough source resolves to one or more diff *sections*. A section is a
|
||||
// patch plus the scope its hunk ids live in; keeping staged and working-tree
|
||||
@@ -48,6 +48,18 @@ export function parseSource(raw) {
|
||||
return { kind: 'pr', number };
|
||||
}
|
||||
|
||||
if (raw.kind === 'commit') {
|
||||
// Sources are content-addressed: accept a full object id, never a moving ref.
|
||||
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(raw.hash)) {
|
||||
throw new WalkthroughSourceError('commit sources require a full commit hash');
|
||||
}
|
||||
try {
|
||||
return { kind: 'commit', hash: raw.hash.toLowerCase() };
|
||||
} catch {
|
||||
throw new WalkthroughSourceError('commit sources require a full commit hash');
|
||||
}
|
||||
}
|
||||
|
||||
throw new WalkthroughSourceError(`Unknown source kind "${String(raw.kind)}"`);
|
||||
}
|
||||
|
||||
@@ -58,6 +70,7 @@ export function parseSource(raw) {
|
||||
export function sourceKey(source) {
|
||||
if (source.kind === 'working-tree') return `working-tree:${source.scope}`;
|
||||
if (source.kind === 'branch') return `branch:${source.baseRef}...${source.headRef}`;
|
||||
if (source.kind === 'commit') return `commit:${source.hash}`;
|
||||
return `pr:${source.number}`;
|
||||
}
|
||||
|
||||
@@ -97,13 +110,21 @@ export async function loadSourceSections(directory, source, { getPullRequestDiff
|
||||
}
|
||||
|
||||
if (source.kind === 'branch') {
|
||||
const patch = await getRangeDiff(directory, { base: source.baseRef, head: source.headRef });
|
||||
const patch = await getRangeDiff(directory, { base: source.baseRef, head: source.headRef, includeWorkingTree: true });
|
||||
return {
|
||||
sections: patch && patch.trim() ? [{ scope: 'branch', patch }] : [],
|
||||
meta: { baseRef: source.baseRef, headRef: source.headRef },
|
||||
};
|
||||
}
|
||||
|
||||
if (source.kind === 'commit') {
|
||||
const patch = await getCommitDiff(directory, { hash: source.hash });
|
||||
return {
|
||||
sections: patch.trim() ? [{ scope: 'commit', patch }] : [],
|
||||
meta: { hash: source.hash },
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof getPullRequestDiff !== 'function') {
|
||||
throw new WalkthroughSourceError('Pull request diffs are unavailable', 500);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ export const createWebGitAPI = (): GitAPI => ({
|
||||
return gitApiHttp.getGitLog(directory, options);
|
||||
},
|
||||
getCommitFiles: gitApiHttp.getCommitFiles,
|
||||
getGitCommitDiff: gitApiHttp.getGitCommitDiff,
|
||||
getCurrentGitIdentity: gitApiHttp.getCurrentGitIdentity,
|
||||
hasLocalIdentity: gitApiHttp.hasLocalIdentity,
|
||||
setGitIdentity: gitApiHttp.setGitIdentity,
|
||||
|
||||
Reference in New Issue
Block a user