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
+351 -31
View File
@@ -3,9 +3,12 @@ import React from 'react';
import { useUIStore } from '@/stores/useUIStore';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore';
import { useGitBaseBranchStore, gitBaseBranchEntryKey } from '@/stores/useGitBaseBranchStore';
import { coerceDiffScope, branchRangeKey, isBranchScopeAvailable, isBranchScopeDefinitelyUnavailable, useRangeKeyedCache, useBoundedDirectoryRetry } from './branchDiffScope';
import { getBranchBase, getGitRangeDiff, getGitRangeFiles } from '@/lib/gitApi';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { cn } from '@/lib/utils';
import type { GitStatus } from '@/lib/api/types';
import type { GitStatus, GitRangeFileEntry } from '@/lib/api/types';
import {
DropdownMenu,
DropdownMenuContent,
@@ -79,7 +82,7 @@ type DiffData = {
fileDiff?: FileDiffMetadata;
contextMode?: DiffContextMode;
};
type DiffScope = 'all' | 'staged' | 'working' | 'turn';
type DiffScope = 'all' | 'staged' | 'working' | 'turn' | 'branch';
type TurnSnapshotDiff = {
file?: string;
@@ -91,6 +94,17 @@ type TurnSnapshotDiff = {
deletions?: number;
};
/** Reservation slot for a branch range diff while its fetch is in flight. */
const EMPTY_BRANCH_DIFF_PLACEHOLDER: DiffData = {
original: '',
modified: '',
isBinary: false,
contextMode: 'patch',
};
/** Bounded retries for branch metadata in the context diff panel (see effect). */
const BRANCH_METADATA_MAX_ATTEMPTS = 3;
const BinaryDiffPlaceholder = React.memo(() => {
const { t } = useI18n();
return (
@@ -230,11 +244,13 @@ const formatDiffTotals = (
};
interface ChangeScopeSelectorProps {
scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>;
scope: Extract<DiffScope, 'working' | 'staged' | 'turn' | 'branch'>;
workingCount: number;
stagedCount: number;
turnCount: number;
onScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>) => void;
branchCount: number | null;
showBranchOption: boolean;
onScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn' | 'branch'>) => void;
}
const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
@@ -242,16 +258,20 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
workingCount,
stagedCount,
turnCount,
branchCount,
showBranchOption,
onScopeChange,
}) => {
const { t } = useI18n();
const [open, setOpen] = React.useState(false);
const currentCount = scope === 'staged' ? stagedCount : scope === 'turn' ? turnCount : workingCount;
const currentCount = scope === 'staged' ? stagedCount : scope === 'turn' ? turnCount : scope === 'branch' ? (branchCount ?? 0) : workingCount;
const currentLabel = scope === 'staged'
? t('diffView.scope.staged')
: scope === 'turn'
? t('diffView.scope.lastTurn')
: t('diffView.scope.changed');
: scope === 'branch'
? t('diffView.scope.branch')
: t('diffView.scope.changed');
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
@@ -271,7 +291,7 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
<DropdownMenuRadioGroup
value={scope}
onValueChange={(value) => {
if (value === 'working' || value === 'staged' || value === 'turn') {
if (value === 'working' || value === 'staged' || value === 'turn' || value === 'branch') {
onScopeChange?.(value);
setOpen(false);
}
@@ -295,6 +315,14 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
<span className="typography-meta text-muted-foreground">{turnCount}</span>
</span>
</DropdownMenuRadioItem>
{showBranchOption ? (
<DropdownMenuRadioItem value="branch">
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
<span>{t('diffView.scope.branch')}</span>
<span className="typography-meta text-muted-foreground">{branchCount ?? '…'}</span>
</span>
</DropdownMenuRadioItem>
) : null}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
@@ -574,6 +602,8 @@ interface MultiFileDiffEntryProps {
staged?: boolean;
loadFullFiles?: boolean;
initialDiffData?: DiffData | null;
/** Hide stage/unstage/revert actions (read-only scopes like branch diffs). */
readOnlyActions?: boolean;
}
const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
@@ -593,6 +623,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
staged = false,
loadFullFiles = false,
initialDiffData = null,
readOnlyActions = false,
}) => {
const { t } = useI18n();
const { git } = useRuntimeAPIs();
@@ -922,13 +953,15 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
/>
<div className="pointer-events-none absolute bottom-3 right-3 z-20">
<div className="pointer-events-auto">
<FileDiffActions
filePath={file.path}
staged={staged}
busyAction={fileAction}
disabled={fileAction !== null}
onAction={handleFileAction}
/>
{!readOnlyActions ? (
<FileDiffActions
filePath={file.path}
staged={staged}
busyAction={fileAction}
disabled={fileAction !== null}
onAction={handleFileAction}
/>
) : null}
</div>
</div>
</>
@@ -945,7 +978,7 @@ interface DiffViewProps {
pinSelectedFileHeaderToTopOnNavigate?: boolean;
showOpenInEditorAction?: boolean;
diffScope?: DiffScope;
onDiffScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>) => void;
onDiffScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn' | 'branch'>) => void;
targetFilePath?: string | null;
/** Render diff content flush with the container edges (no outer padding). */
flushContent?: boolean;
@@ -974,6 +1007,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
const ensureStatus = useGitStore((state) => state.ensureStatus);
const fetchStatus = useGitStore((state) => state.fetchStatus);
const fetchBranches = useGitStore((state) => state.fetchBranches);
const clearDiffCache = useGitStore((state) => state.clearDiffCache);
const setDiff = useGitStore((state) => state.setDiff);
const [displayFile, setDisplayFile] = React.useState<string | null>(null);
@@ -1083,7 +1117,213 @@ export const DiffView: React.FC<DiffViewProps> = ({
return map;
}, [lastTurnDiffs]);
const workingFileCount = React.useMemo(() => {
if (!status?.files) return 0;
return status.files.filter(isWorkingStatusFile).length;
}, [status]);
const stagedFileCount = React.useMemo(() => {
if (!status?.files) return 0;
return status.files.filter(isStagedStatusFile).length;
}, [status]);
const turnFileCount = lastTurnDiffs.length;
// ----- Branch scope (all changes on this branch vs its base) -----
const currentBranch = status?.current ?? null;
const branches = useGitStore((state) => (effectiveDirectory ? state.directories.get(effectiveDirectory)?.branches ?? null : null));
const isLoadingBranches = useGitStore((state) => (effectiveDirectory ? state.directories.get(effectiveDirectory)?.isLoadingBranches ?? false : false));
// The Branch scope needs defaultBranches metadata that nothing else loads
// when only the context diff panel is open (GitView and the composer fetch
// it, and their absence must not hide the option), so load it here. A
// failed fetch leaves `branches` null and the loading flag settles back to
// false; the bounded retry below re-issues it a few times per directory and
// reports exhaustion so a dead repository neither loops forever nor spins
// the Branch scope on base resolution.
const startBranchMetadataFetch = React.useCallback(() => {
if (effectiveDirectory) {
void fetchBranches(effectiveDirectory, git);
}
}, [effectiveDirectory, fetchBranches, git]);
const branchMetadataExhausted = useBoundedDirectoryRetry(
effectiveDirectory ?? null,
isGitRepo !== false,
isLoadingBranches,
Boolean(branches),
startBranchMetadataFetch,
BRANCH_METADATA_MAX_ATTEMPTS
);
const repositoryDefaultBranch = React.useMemo(() => {
const trackingRemote = status?.tracking?.trim().split('/')[0];
return (trackingRemote && branches?.defaultBranches?.[trackingRemote])
?? branches?.defaultBranches?.origin
?? null;
}, [branches, status?.tracking]);
// Offered only while the default branch is known and the current branch is
// not it (an unknown default must not flash the option on a guess), and
// only outside VS Code (the extension has no context diff panel).
const showBranchOption = !isVSCodeRuntime() && isBranchScopeAvailable(currentBranch, repositoryDefaultBranch);
// Coercion acts only on CONFIRMED unavailability: the runtime has no branch
// scope at all, a settled status has no branch (detached HEAD), the default
// branch is known and we are on it, or metadata retries were exhausted.
// While status/metadata are still loading a persisted branch scope must
// survive instead of being rewritten to working on the first render.
// `status !== null` is the settled test: before the first status request
// even starts, status is null with loading still false, and that must not
// read as "settled without a branch".
const isBranchStatusResolved = status !== null;
const branchScopeDefinitelyUnavailable = isVSCodeRuntime()
|| branchMetadataExhausted
|| isBranchScopeDefinitelyUnavailable(
currentBranch,
repositoryDefaultBranch,
isBranchStatusResolved,
branches !== null
);
const setBaseOverride = useGitBaseBranchStore((state) => state.setOverride);
// Subscribe to the overrides map directly: `getOverride` reads `get()`
// imperatively, so a memo over it never recomputes when the store changes
// and a freshly picked base would be invisible until an unrelated rerender.
// The key includes the current branch: a base picked for one feature branch
// is not an answer for another branch of the same repository.
const baseOverride = useGitBaseBranchStore(
React.useCallback(
(state) => (effectiveDirectory && currentBranch
? state.overrides[gitBaseBranchEntryKey(effectiveDirectory, currentBranch)] ?? null
: null),
[currentBranch, effectiveDirectory]
)
);
const [detectedBranchBase, setDetectedBranchBase] = React.useState<string | null>(null);
const [isBranchBaseResolved, setIsBranchBaseResolved] = React.useState(false);
const [basePickerSearch, setBasePickerSearch] = React.useState('');
// A context tab persists its scope across branch checkouts and runtime
// switches. When the Branch scope is CONFIRMED unavailable (checked out the
// known default branch, VS Code runtime), fall back to Working instead of
// rendering the base-resolution spinner forever. Persist the coercion so
// the tab and the selector agree. Note it keys off confirmed
// unavailability, not off `showBranchOption`: while metadata loads the
// option is hidden but a persisted branch scope must not be rewritten.
React.useEffect(() => {
const coercedScope = coerceDiffScope(activeDiffScope, !branchScopeDefinitelyUnavailable);
if (coercedScope !== activeDiffScope) {
setActiveDiffScope(coercedScope);
// The only coercion is 'branch' -> 'working', so the persisted
// value always fits the callback domain.
if (coercedScope === 'working') {
onDiffScopeChange?.('working');
}
}
}, [activeDiffScope, branchScopeDefinitelyUnavailable, onDiffScopeChange]);
React.useEffect(() => {
if (!showBranchOption || !effectiveDirectory || !currentBranch) {
setDetectedBranchBase(null);
setIsBranchBaseResolved(false);
return;
}
let cancelled = false;
setIsBranchBaseResolved(false);
getBranchBase(effectiveDirectory, currentBranch)
.then((result) => {
if (!cancelled) setDetectedBranchBase(result.base);
})
.catch(() => {
if (!cancelled) setDetectedBranchBase(null);
})
.finally(() => {
if (!cancelled) setIsBranchBaseResolved(true);
});
return () => {
cancelled = true;
};
}, [currentBranch, effectiveDirectory, showBranchOption]);
// Explicit user choice outranks the detected source; both are real answers
// from git or the user — never a main/master guess.
const branchBase = baseOverride ?? detectedBranchBase;
const [branchFiles, setBranchFiles] = React.useState<GitRangeFileEntry[] | null>(null);
const [branchFilesError, setBranchFilesError] = React.useState<string | null>(null);
// Shared by the scope/base effect and the error-state Retry button; the
// fetch id discards completions from a superseded run (base or head
// changed, or an earlier retry is still in flight).
const branchFilesFetchIdRef = React.useRef(0);
const reloadBranchFiles = React.useCallback(() => {
if (!effectiveDirectory || !currentBranch || !branchBase) return;
const fetchId = branchFilesFetchIdRef.current + 1;
branchFilesFetchIdRef.current = fetchId;
setBranchFiles(null);
setBranchFilesError(null);
getGitRangeFiles(effectiveDirectory, { base: branchBase, head: currentBranch })
.then((files) => {
if (branchFilesFetchIdRef.current === fetchId) setBranchFiles(files);
})
.catch((error) => {
if (branchFilesFetchIdRef.current === fetchId) {
setBranchFilesError(error instanceof Error ? error.message : t('diffView.branch.loadError'));
}
});
}, [branchBase, currentBranch, effectiveDirectory, t]);
React.useEffect(() => {
if (activeDiffScope === 'branch') {
reloadBranchFiles();
}
}, [activeDiffScope, reloadBranchFiles]);
// Range diffs are fetched per expanded file: unlike working/staged diffs
// there is no per-file cache channel, so patch data lives in a range-keyed
// local cache. Stale completions from a previous range cannot write into
// the new range's cache (see useRangeKeyedCache).
const branchDiffRangeKey = activeDiffScope === 'branch' && effectiveDirectory && currentBranch && branchBase
? branchRangeKey(effectiveDirectory, branchBase, currentBranch)
: null;
const branchDiffPathsKey = React.useMemo(
() => (activeDiffScope === 'branch' ? Array.from(expandedFiles).sort().join('\0') : ''),
[activeDiffScope, expandedFiles]
);
const fetchBranchDiffEntry = React.useCallback(
(filePath: string) => {
if (!effectiveDirectory || !branchBase || !currentBranch) {
return Promise.reject(new Error('branch range is unavailable'));
}
return getGitRangeDiff(effectiveDirectory, { base: branchBase, head: currentBranch, path: filePath })
.then((response) => createTextDiffDataFromPatch(filePath, response.diff, 'patch'));
},
[branchBase, currentBranch, effectiveDirectory]
);
const branchDiffData = useRangeKeyedCache<DiffData>(
branchDiffRangeKey,
branchDiffPathsKey,
branchDiffRangeKey ? fetchBranchDiffEntry : null,
EMPTY_BRANCH_DIFF_PLACEHOLDER
);
const branchFileCount = branchFiles?.length ?? null;
const changedFiles: FileEntry[] = React.useMemo(() => {
if (activeDiffScope === 'branch') {
return (branchFiles ?? [])
.map((file) => ({
path: file.path,
index: '',
working_dir: file.status,
insertions: 0,
deletions: 0,
isNew: file.status === 'A',
}))
.sort((a, b) => a.path.localeCompare(b.path));
}
if (activeDiffScope === 'turn') {
return lastTurnDiffs
.map((diff) => ({
@@ -1115,19 +1355,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
isNew: isNewStatusFile(file),
}))
.sort((a, b) => a.path.localeCompare(b.path));
}, [activeDiffScope, lastTurnDiffs, status]);
const workingFileCount = React.useMemo(() => {
if (!status?.files) return 0;
return status.files.filter(isWorkingStatusFile).length;
}, [status]);
const stagedFileCount = React.useMemo(() => {
if (!status?.files) return 0;
return status.files.filter(isStagedStatusFile).length;
}, [status]);
const turnFileCount = lastTurnDiffs.length;
}, [activeDiffScope, branchFiles, lastTurnDiffs, status]);
const changedFilePathsKey = React.useMemo(
() => changedFiles.map((file) => file.path).join('\0'),
@@ -1670,7 +1898,14 @@ export const DiffView: React.FC<DiffViewProps> = ({
}}
staged={getFileStaged(file.path)}
loadFullFiles={loadFullFiles}
initialDiffData={activeDiffScope === 'turn' ? lastTurnDiffData.get(file.path) ?? null : null}
readOnlyActions={activeDiffScope === 'branch'}
initialDiffData={
activeDiffScope === 'turn'
? lastTurnDiffData.get(file.path) ?? null
: activeDiffScope === 'branch'
? branchDiffData.get(file.path) ?? null
: null
}
/>
))}
</div>
@@ -1707,10 +1942,93 @@ export const DiffView: React.FC<DiffViewProps> = ({
);
}
if (activeDiffScope === 'branch') {
if (!isBranchBaseResolved) {
return (
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
{t('diffView.branch.resolvingBase')}
</div>
);
}
if (!branchBase) {
const searchTerm = basePickerSearch.trim().toLowerCase();
const candidateBranches = (branches?.all ?? [])
.map((name: string) => name.replace(/^remotes\//, ''))
.filter((name: string) => name !== currentBranch && !name.endsWith(`/${currentBranch}`))
.filter((name: string) => !searchTerm || name.toLowerCase().includes(searchTerm))
.sort();
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<Icon name="git-branch" className="size-6 text-muted-foreground" />
<div className="typography-ui-label font-semibold text-foreground">{t('diffView.branch.noBaseTitle')}</div>
<div className="max-w-sm typography-micro text-muted-foreground">{t('diffView.branch.noBaseDescription')}</div>
<input
type="text"
value={basePickerSearch}
onChange={(event) => setBasePickerSearch(event.target.value)}
placeholder={t('gitView.branch.searchPlaceholder')}
aria-label={t('gitView.branch.searchPlaceholder')}
className="w-full max-w-sm rounded-md border border-border/60 bg-[var(--surface-elevated)] px-2.5 py-1.5 typography-meta text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
/>
<ScrollableOverlay outerClassName="max-h-48 w-full max-w-sm min-h-0" className="px-1 py-1">
{candidateBranches.length === 0 ? (
<div className="px-2 py-3 typography-meta text-muted-foreground">
{t('gitView.branch.empty')}
</div>
) : (
<div className="flex flex-col gap-0.5">
{candidateBranches.map((branch: string) => (
<button
key={branch}
type="button"
onClick={() => effectiveDirectory && currentBranch && setBaseOverride(effectiveDirectory, currentBranch, branch)}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
>
<Icon name="git-branch" className="size-3.5 text-primary" />
<span className="truncate typography-ui-label text-foreground" title={branch}>{branch}</span>
</button>
))}
</div>
)}
</ScrollableOverlay>
</div>
);
}
if (branchFilesError) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<div className="typography-ui-label font-semibold text-foreground">{t('diffView.branch.loadError')}</div>
<div className="max-w-sm typography-micro text-muted-foreground">{branchFilesError}</div>
<Button
variant="outline"
size="sm"
onClick={() => reloadBranchFiles()}
>
{t('diffView.actions.retry')}
</Button>
</div>
);
}
if (branchFiles === null) {
return (
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
{t('diffView.branch.loadingFiles')}
</div>
);
}
}
if (changedFiles.length === 0) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{activeDiffScope === 'turn' ? t('diffView.state.noLastTurnChanges') : t('diffView.state.cleanWorkingTree')}
{activeDiffScope === 'turn' ? t('diffView.state.noLastTurnChanges')
: activeDiffScope === 'branch' && branchBase ? t('diffView.branch.empty', { base: branchBase })
: t('diffView.state.cleanWorkingTree')}
</div>
);
}
@@ -1722,12 +2040,14 @@ export const DiffView: React.FC<DiffViewProps> = ({
<div className="flex h-full flex-col overflow-hidden bg-background">
<div className="@container/diff-toolbar flex min-w-0 items-center gap-2 px-3 py-2 bg-background">
{!isMobile && (
activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' ? (
activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' || activeDiffScope === 'branch' ? (
<ChangeScopeSelector
scope={activeDiffScope}
workingCount={workingFileCount}
stagedCount={stagedFileCount}
turnCount={turnFileCount}
branchCount={branchFileCount}
showBranchOption={showBranchOption}
onScopeChange={(scope) => {
setActiveDiffScope(scope);
onDiffScopeChange?.(scope);
@@ -0,0 +1,503 @@
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { describe, expect, test } from 'bun:test';
import {
branchRangeKey,
coerceDiffScope,
isBranchScopeAvailable,
isBranchScopeDefinitelyUnavailable,
useRangeKeyedCache,
useBoundedDirectoryRetry,
} from './branchDiffScope';
describe('coerceDiffScope', () => {
test('keeps the branch scope while it is offered', () => {
expect(coerceDiffScope('branch', true)).toBe('branch');
});
test('falls back to working when the branch scope disappears', () => {
// Covers a persisted context tab after checking out the default branch
// or switching to a runtime without the branch scope: the tab must land
// on a renderable scope instead of a permanent spinner.
expect(coerceDiffScope('branch', false)).toBe('working');
});
test('leaves every other scope untouched regardless of availability', () => {
for (const scope of ['working', 'staged', 'turn', 'all'] as const) {
expect(coerceDiffScope(scope, false)).toBe(scope);
expect(coerceDiffScope(scope, true)).toBe(scope);
}
});
});
describe('isBranchScopeAvailable', () => {
test('available when the default branch is known and different', () => {
expect(isBranchScopeAvailable('feature-a', 'main')).toBe(true);
});
test('unavailable on the default branch itself', () => {
expect(isBranchScopeAvailable('main', 'main')).toBe(false);
});
test('unavailable while the default branch is unknown', () => {
// Branch metadata loads asynchronously; an unknown default must not
// flash the Branch option on the guess that the branch differs from it.
expect(isBranchScopeAvailable('feature-a', null)).toBe(false);
});
test('unavailable without a current branch', () => {
expect(isBranchScopeAvailable(null, 'main')).toBe(false);
expect(isBranchScopeAvailable(null, null)).toBe(false);
});
});
describe('isBranchScopeDefinitelyUnavailable', () => {
test('unknown metadata is not confirmed unavailability', () => {
// While branch metadata loads the option stays hidden, but this is
// "unknown", not "confirmed gone" — coercion must not act on it.
expect(isBranchScopeDefinitelyUnavailable('feature-a', null, true, false)).toBe(false);
expect(isBranchScopeDefinitelyUnavailable('feature-a', 'main', true, false)).toBe(false);
});
test('unresolved status means the branch is unknown, not gone', () => {
// During the first status load a null currentBranch is "not loaded
// yet"; coercing on it would discard a persisted branch scope before
// the answer arrives.
expect(isBranchScopeDefinitelyUnavailable(null, 'main', false, false)).toBe(false);
expect(isBranchScopeDefinitelyUnavailable(null, null, false, true)).toBe(false);
});
test('detached HEAD after a settled status is confirmed unavailability', () => {
// Status finished (or failed) without a branch: the Branch scope is
// impossible, so a persisted branch scope must coerce away instead of
// spinning on base resolution forever.
expect(isBranchScopeDefinitelyUnavailable(null, 'main', true, false)).toBe(true);
expect(isBranchScopeDefinitelyUnavailable(null, null, true, true)).toBe(true);
});
test('metadata settled without a default branch is confirmed unavailability', () => {
// `getBranches` can succeed while git/remote never reported a default
// branch: retries will not change that, the option stays hidden, and a
// persisted branch scope must coerce instead of spinning on base
// resolution forever.
expect(isBranchScopeDefinitelyUnavailable('feature-a', null, true, true)).toBe(true);
expect(isBranchScopeAvailable('feature-a', null)).toBe(false);
expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable('feature-a', null, true, true))).toBe('working');
});
test('confirmed when the default branch is known and we are on it', () => {
expect(isBranchScopeDefinitelyUnavailable('main', 'main', true, true)).toBe(true);
expect(isBranchScopeDefinitelyUnavailable('feature-a', 'main', true, true)).toBe(false);
});
test('a persisted branch scope survives loading metadata and is coerced once the answer arrives', () => {
// The scenario: a context tab persisted scope='branch' and the panel
// reopens while branch metadata is still loading (null default).
// First render — option hidden, but NOT coerced away:
expect(isBranchScopeAvailable('feature-a', null)).toBe(false);
expect(isBranchScopeDefinitelyUnavailable('feature-a', null, true, false)).toBe(false);
expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable('feature-a', null, true, false))).toBe('branch');
// Metadata arrives and confirms a feature branch — still available:
expect(isBranchScopeAvailable('feature-a', 'main')).toBe(true);
expect(isBranchScopeDefinitelyUnavailable('feature-a', 'main', true, true)).toBe(false);
expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable('feature-a', 'main', true, true))).toBe('branch');
// User checks out the default branch — now confirmed, coerce:
expect(isBranchScopeDefinitelyUnavailable('main', 'main', true, true)).toBe(true);
expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable('main', 'main', true, true))).toBe('working');
});
test('a persisted branch scope coerces after detached HEAD once status settles', () => {
// Status still loading with a persisted branch scope — keep it:
expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable(null, 'main', false, false))).toBe('branch');
// Status settles on detached HEAD — coerce:
expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable(null, 'main', true, false))).toBe('working');
});
test('first render before the status request starts does not read as settled detached HEAD', () => {
// Sequence of a fresh mount with a persisted branch scope:
// 1. status===null, loading===false (request has not started yet),
// 2. loading===true,
// 3. settled status object with current===null (true detached HEAD).
// Only step 3 may coerce; steps 1-2 are "unknown" and keep the scope.
expect(isBranchScopeDefinitelyUnavailable(null, null, false, false)).toBe(false);
expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable(null, null, false, false))).toBe('branch');
expect(isBranchScopeDefinitelyUnavailable(null, null, false, true)).toBe(false);
expect(isBranchScopeDefinitelyUnavailable(null, null, true, false)).toBe(true);
expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable(null, null, true, false))).toBe('working');
});
});
describe('branchRangeKey', () => {
test('distinguishes bases, heads, and directories for the same path', () => {
// The same file path can carry different diff content per range; a cache
// keyed by path alone would leak a previous branch's patch.
const keys = [
branchRangeKey('/repo', 'main', 'feature-a'),
branchRangeKey('/repo', 'develop', 'feature-a'),
branchRangeKey('/repo', 'main', 'feature-b'),
branchRangeKey('/other', 'main', 'feature-a'),
];
expect(new Set(keys).size).toBe(4);
});
});
// ---------------------------------------------------------------------------
// useRangeKeyedCache
// ---------------------------------------------------------------------------
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((next, decline) => {
resolve = next;
reject = decline;
});
return { promise, resolve, reject };
};
const installMinimalDom = () => {
const descriptors = new Map<string, PropertyDescriptor | undefined>();
const setGlobal = (name: string, value: unknown) => {
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
};
class ElementStub {}
/** Minimal Document surface createRoot touches in these tests. */
type DocumentStub = {
nodeType: 9;
defaultView: typeof globalThis;
activeElement: Element | null;
addEventListener: (type: string, listener: () => void) => void;
removeEventListener: (type: string, listener: () => void) => void;
documentElement: typeof container;
body: typeof container;
};
const container = {
nodeType: 1,
tagName: 'DIV',
nodeName: 'DIV',
namespaceURI: 'http://www.w3.org/1999/xhtml',
ownerDocument: null as DocumentStub | null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
};
const documentStub: DocumentStub = {
nodeType: 9,
defaultView: globalThis,
activeElement: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
documentElement: container,
body: container,
};
container.ownerDocument = documentStub;
setGlobal('document', documentStub);
setGlobal('window', globalThis);
setGlobal('location', { search: '', protocol: 'http:', hostname: 'localhost' });
setGlobal('Element', ElementStub);
setGlobal('HTMLElement', ElementStub);
setGlobal('HTMLIFrameElement', ElementStub);
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
setGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0));
setGlobal('cancelAnimationFrame', (id: ReturnType<typeof setTimeout>) => clearTimeout(id));
return {
// SAFETY: the container stub implements the Element surface createRoot
// touches (nodeType/tagName/listeners); the real Element type is not
// constructible without a DOM implementation, so the gap goes through
// unknown deliberately.
container: container as unknown as Element,
restore: () => {
for (const [name, descriptor] of descriptors) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
},
};
};
describe('useRangeKeyedCache', () => {
test('a stale completion from the previous range cannot write into the new range', async () => {
const dom = installMinimalDom();
const root: Root = createRoot(dom.container);
// One shared path so the same key would be overwritten if the guard
// was missing.
const pathsKey = 'src/shared.ts';
const rangeA = '["/repo","main","feature-a"]';
const rangeB = '["/repo","develop","feature-b"]';
const fetchA = deferred<string>();
const fetchB = deferred<string>();
type CapturedEntries = { entries: ReadonlyMap<string, string> | null };
const captured: CapturedEntries = { entries: null };
let currentFetcher: (path: string) => Promise<string> = () => fetchA.promise;
const Harness = () => {
captured.entries = useRangeKeyedCache<string>(
rangeKey,
pathsKey,
currentFetcher,
'placeholder'
);
return null;
};
let rangeKey: string = rangeA;
try {
await act(async () => root.render(React.createElement(Harness)));
expect(captured.entries?.get(pathsKey)).toBe('placeholder');
// Switch the range while A's fetch is still in flight.
rangeKey = rangeB;
currentFetcher = () => fetchB.promise;
await act(async () => root.render(React.createElement(Harness)));
expect(captured.entries?.get(pathsKey)).toBe('placeholder');
// B completes first: its value must land.
await act(async () => {
fetchB.resolve('diff-from-develop');
await Promise.resolve();
});
expect(captured.entries?.get(pathsKey)).toBe('diff-from-develop');
// A completes last: the stale result must be discarded, not written
// over range B's entry.
await act(async () => {
fetchA.resolve('diff-from-main');
await Promise.resolve();
});
expect(captured.entries?.get(pathsKey)).toBe('diff-from-develop');
} finally {
await act(async () => root.unmount());
dom.restore();
}
});
test('a stale rejection from the previous range cannot delete the new range entry', async () => {
const dom = installMinimalDom();
const root: Root = createRoot(dom.container);
const pathsKey = 'src/shared.ts';
const rangeA = '["/repo","main","feature-a"]';
const rangeB = '["/repo","develop","feature-b"]';
const fetchA = deferred<string>();
const fetchB = deferred<string>();
type CapturedEntries = { entries: ReadonlyMap<string, string> | null };
const captured: CapturedEntries = { entries: null };
let currentFetcher: (path: string) => Promise<string> = () => fetchA.promise;
let rangeKey: string = rangeA;
const Harness = () => {
captured.entries = useRangeKeyedCache<string>(rangeKey, pathsKey, currentFetcher, 'placeholder');
return null;
};
try {
await act(async () => root.render(React.createElement(Harness)));
rangeKey = rangeB;
currentFetcher = () => fetchB.promise;
await act(async () => root.render(React.createElement(Harness)));
await act(async () => {
fetchB.resolve('diff-from-develop');
await Promise.resolve();
});
expect(captured.entries?.get(pathsKey)).toBe('diff-from-develop');
// The old range's fetch fails after the switch: it must not delete
// the new range's completed entry.
await act(async () => {
fetchA.reject(new Error('stale failure'));
await Promise.resolve();
});
expect(captured.entries?.get(pathsKey)).toBe('diff-from-develop');
} finally {
await act(async () => root.unmount());
dom.restore();
}
});
test('releases reservations for paths that never completed so a later run retries them', async () => {
const dom = installMinimalDom();
const root: Root = createRoot(dom.container);
const pathsKey = 'src/first.ts';
const stuck = deferred<string>();
type CapturedEntries = { entries: ReadonlyMap<string, string> | null };
const captured: CapturedEntries = { entries: null };
let currentPathsKey = pathsKey;
const fetched: string[] = [];
const Harness = () => {
captured.entries = useRangeKeyedCache<string>(
'range',
currentPathsKey,
(path) => {
fetched.push(path);
return currentPathsKey === pathsKey ? stuck.promise : Promise.resolve(`resolved-${path}`);
},
'placeholder'
);
return null;
};
try {
await act(async () => root.render(React.createElement(Harness)));
expect(fetched).toEqual(['src/first.ts']);
expect(captured.entries?.get('src/first.ts')).toBe('placeholder');
// Expand a different set of paths; the stuck reservation for
// src/first.ts is released, and a later run fetches it again.
currentPathsKey = 'src/first.ts\u0000src/second.ts';
await act(async () => root.render(React.createElement(Harness)));
expect(fetched).toEqual(['src/first.ts', 'src/first.ts', 'src/second.ts']);
expect(captured.entries?.get('src/first.ts')).toBe('resolved-src/first.ts');
expect(captured.entries?.get('src/second.ts')).toBe('resolved-src/second.ts');
} finally {
await act(async () => root.unmount());
dom.restore();
}
});
});
describe('useBoundedDirectoryRetry', () => {
test('starts once and reports no exhaustion on success', async () => {
const dom = installMinimalDom();
const root: Root = createRoot(dom.container);
const started: string[] = [];
let hasResult = false;
let latestExhausted: boolean | null = null;
const Harness = () => {
latestExhausted = useBoundedDirectoryRetry(
'/repo', true, false, hasResult,
() => { started.push('/repo'); }, 3
);
return null;
};
try {
await act(async () => root.render(React.createElement(Harness)));
expect(started).toEqual(['/repo']);
expect(latestExhausted).toBe(false);
// Result arrives: no further starts, no exhaustion.
hasResult = true;
await act(async () => root.render(React.createElement(Harness)));
expect(started).toEqual(['/repo']);
expect(latestExhausted).toBe(false);
} finally {
await act(async () => root.unmount());
dom.restore();
}
});
test('retries bounded times on failure, then reports exhaustion without looping', async () => {
const dom = installMinimalDom();
const root: Root = createRoot(dom.container);
const started: string[] = [];
let inFlight = false;
let latestExhausted: boolean | null = null;
const Harness = () => {
latestExhausted = useBoundedDirectoryRetry(
'/repo', true, inFlight, false,
() => { started.push('/repo'); }, 3
);
return null;
};
try {
// Each attempt is one in-flight transition: the start flips the
// caller's flag up, the failed request settles it back down.
for (let attempt = 1; attempt <= 3; attempt += 1) {
inFlight = false;
await act(async () => root.render(React.createElement(Harness)));
expect(started).toHaveLength(attempt);
inFlight = true;
await act(async () => root.render(React.createElement(Harness)));
}
expect(latestExhausted).toBe(false);
// Fourth transition: attempts exhausted, no more starts.
inFlight = false;
await act(async () => root.render(React.createElement(Harness)));
await act(async () => root.render(React.createElement(Harness)));
expect(started).toHaveLength(3);
expect(latestExhausted).toBe(true);
} finally {
await act(async () => root.unmount());
dom.restore();
}
});
test('exhaustion does not leak into the next directory on the first render', async () => {
const dom = installMinimalDom();
const root: Root = createRoot(dom.container);
const started: string[] = [];
let directory: string = '/repo-a';
let inFlight = false;
let hasResult = false;
let latestExhausted: boolean | null = null;
const Harness = () => {
latestExhausted = useBoundedDirectoryRetry(
directory, true, inFlight, hasResult,
() => { started.push(directory); }, 2
);
return null;
};
try {
// Burn through both retries for /repo-a until exhausted.
for (let attempt = 1; attempt <= 2; attempt += 1) {
inFlight = false;
await act(async () => root.render(React.createElement(Harness)));
inFlight = true;
await act(async () => root.render(React.createElement(Harness)));
}
inFlight = false;
await act(async () => root.render(React.createElement(Harness)));
expect(latestExhausted).toBe(true);
// Switch to another directory (a new tab with a persisted branch
// scope): exhaustion must reset in the SAME render, before any
// effect could rewrite the scope, and retries restart for it.
directory = '/repo-b';
await act(async () => root.render(React.createElement(Harness)));
expect(latestExhausted).toBe(false);
expect(started).toEqual(['/repo-a', '/repo-a', '/repo-b']);
hasResult = true;
await act(async () => root.render(React.createElement(Harness)));
expect(latestExhausted).toBe(false);
} finally {
await act(async () => root.unmount());
dom.restore();
}
});
test('an in-flight request suppresses duplicate starts from another consumer', async () => {
const dom = installMinimalDom();
const root: Root = createRoot(dom.container);
const started: string[] = [];
const Harness = () => {
useBoundedDirectoryRetry(
'/repo', true, true, false,
() => { started.push('/repo'); }, 3
);
return null;
};
try {
await act(async () => root.render(React.createElement(Harness)));
await act(async () => root.render(React.createElement(Harness)));
expect(started).toEqual([]);
} finally {
await act(async () => root.unmount());
dom.restore();
}
});
});
@@ -0,0 +1,211 @@
import React from 'react';
/**
* Pure helpers backing the "Branch" diff scope in DiffView. Extracted so the
* coercion, availability, and range-cache invalidation contracts are testable
* without mounting the full diff surface.
*/
/**
* The "Branch" scope only exists while the repository's default branch is
* known and the current branch differs from it (the caller decides runtime
* availability). An unknown default must NOT show the option: the scope is
* "this branch is not the default", which cannot be established, and offering
* it on a guess flashes the option while branch metadata is still loading.
*/
export const isBranchScopeAvailable = (
currentBranch: string | null,
repositoryDefaultBranch: string | null
): boolean => (
Boolean(currentBranch)
&& repositoryDefaultBranch !== null
&& currentBranch !== repositoryDefaultBranch
);
/**
* Confirmed unavailability of the Branch scope, as opposed to "not (yet)
* known". Coercion of a persisted branch scope must wait for this: while
* metadata is loading the default branch is unknown, the option stays hidden,
* but rewriting the persisted scope to working on that first render would
* discard the user's choice the moment metadata arrives and confirms the
* branch differs from the default.
*
* - `isBranchStatusResolved` distinguishes "no branch yet because the first
* status load has not settled" (unknown — keep the persisted scope) from
* "status finished and there is no branch" (detached HEAD / failed load —
* the Branch scope is impossible and the scope must coerce away).
* - `isBranchMetadataLoaded` + a null default means the branch list settled
* WITHOUT a resolvable default branch (git/remote never reported one): the
* Branch scope is impossible in a different way, and must coerce too,
* otherwise the persisted scope spins on base resolution forever.
*/
export const isBranchScopeDefinitelyUnavailable = (
currentBranch: string | null,
repositoryDefaultBranch: string | null,
isBranchStatusResolved: boolean,
isBranchMetadataLoaded: boolean
): boolean => {
if (!isBranchStatusResolved) return false;
if (currentBranch === null) return true;
if (isBranchMetadataLoaded && repositoryDefaultBranch === null) return true;
return repositoryDefaultBranch !== null && currentBranch === repositoryDefaultBranch;
};
/**
* A context tab persists its scope across branch checkouts and runtime
* switches. When the Branch scope stops being offered (checked out the
* default branch, VS Code runtime), fall back to a always-available one instead
* of rendering the base-resolution spinner forever.
*/
export const coerceDiffScope = <T extends string>(
scope: T,
branchScopeAvailable: boolean
): T | 'working' => (scope === 'branch' && !branchScopeAvailable ? 'working' : scope);
/**
* Identity of one `base...head` range in one repository. Range-cache entries
* are only valid within a single range: the same file path can carry different
* content under a different base or head, so a cache keyed by path alone leaks
* stale patches across branch and base switches.
*/
export const branchRangeKey = (directory: string, base: string, head: string): string =>
JSON.stringify([directory, base, head]);
/**
* Bounded per-directory retry for a request whose failure leaves no result and
* no signal beyond the in-flight flag settling back to false.
*
* - State carries its directory: after a directory switch the derived
* attempts/exhausted values reset IMMEDIATELY on the first render of the new
* directory (no reset effect, so no one-render window where a stale
* `exhausted: true` from the previous directory leaks into decisions).
* - Retries stop after `maxAttempts` and report exhaustion instead of looping
* forever against a dead target.
* - An in-flight request (possibly started by another mounted consumer of the
* same directory) suppresses duplicate starts.
*/
export const useBoundedDirectoryRetry = (
directory: string | null,
isEnabled: boolean,
isRequestInFlight: boolean,
hasResult: boolean,
startRequest: () => void,
maxAttempts: number
): boolean => {
// Attempts live in a ref and the effect's deps deliberately exclude them:
// a retry may only be triggered by an EXTERNAL transition (the in-flight
// flag settling back to false, a directory switch, a result appearing), never
// by the attempt counter itself — otherwise one start cascades into all
// remaining attempts in a single commit.
const attemptsRef = React.useRef<{ directory: string; attempts: number }>({ directory: '', attempts: 0 });
const [exhaustedState, setExhaustedState] = React.useState<{ directory: string; exhausted: boolean }>(
() => ({ directory: '', exhausted: false })
);
// The starter is read through a ref so an inline arrow from the caller
// cannot restart the effect in a render loop.
const startRequestRef = React.useRef(startRequest);
startRequestRef.current = startRequest;
// A different directory's (or the initial empty) exhaustion state reads as
// not exhausted; this derivation is the instant-reset guarantee above.
const exhausted = Boolean(directory) && exhaustedState.directory === directory && exhaustedState.exhausted;
React.useEffect(() => {
if (!directory || !isEnabled || hasResult || isRequestInFlight) {
return;
}
const attempts = attemptsRef.current.directory === directory ? attemptsRef.current.attempts : 0;
if (attempts >= maxAttempts) {
if (!(exhaustedState.directory === directory && exhaustedState.exhausted)) {
setExhaustedState({ directory, exhausted: true });
}
return;
}
attemptsRef.current = { directory, attempts: attempts + 1 };
startRequestRef.current();
}, [directory, exhaustedState, hasResult, isEnabled, isRequestInFlight, maxAttempts]);
return exhausted;
};
/**
* Per-path cache of lazily fetched values, valid within a single range.
*
* - Changing `rangeKey` clears every entry (new base/head/directory = new
* content for the same paths).
* - Each expanded path is reserved with `placeholder` before its fetch starts,
* so a re-run does not issue a duplicate request.
* - Completions from a previous run can never write into the new range's
* cache: every run is cancelled in its cleanup, and its callbacks ignore
* results after cancellation. This covers the stale-completion case where an
* old `fetchEntry` promise resolves (or rejects) after the range switched.
* - Reservations that never completed are released on cleanup so a later run
* retries those paths instead of showing the placeholder forever.
*/
export const useRangeKeyedCache = <T>(
rangeKey: string | null,
pathsKey: string,
fetchEntry: ((path: string) => Promise<T>) | null,
placeholder: T
): ReadonlyMap<string, T> => {
const [entries, setEntries] = React.useState<Map<string, T>>(() => new Map());
const entriesRef = React.useRef(entries);
entriesRef.current = entries;
// The fetcher is read through a ref so a caller passing an inline arrow (a
// new function every render) cannot restart the fetch effect in a loop.
const fetchEntryRef = React.useRef(fetchEntry);
fetchEntryRef.current = fetchEntry;
const writeEntry = React.useCallback((path: string, value: T | null) => {
const next = new Map(entriesRef.current);
if (value === null) {
if (!next.delete(path)) return;
} else {
next.set(path, value);
}
entriesRef.current = next;
setEntries(next);
}, []);
React.useEffect(() => {
if (!rangeKey) return;
entriesRef.current = new Map();
setEntries(entriesRef.current);
}, [rangeKey]);
React.useEffect(() => {
const fetcher = fetchEntryRef.current;
if (!rangeKey || !fetcher || !pathsKey) {
return;
}
let cancelled = false;
const pendingReservations = new Set<string>();
for (const path of pathsKey.split('\0')) {
if (entriesRef.current.has(path)) continue;
pendingReservations.add(path);
writeEntry(path, placeholder);
fetcher(path)
.then((value) => {
if (cancelled) return;
pendingReservations.delete(path);
writeEntry(path, value);
})
.catch(() => {
if (cancelled) return;
// Release the reservation so a later run can retry this path.
pendingReservations.delete(path);
writeEntry(path, null);
});
}
return () => {
cancelled = true;
for (const path of pendingReservations) {
writeEntry(path, null);
}
};
}, [pathsKey, placeholder, rangeKey, writeEntry]);
return entries;
};
+18
View File
@@ -157,6 +157,22 @@ export interface GetGitRangeDiffOptions {
contextLines?: number;
}
export interface GetGitRangeFilesOptions {
base: string;
head: string;
}
/** One changed file in a `base...head` range, with its change letter (A/M/D/R/C). */
export interface GitRangeFileEntry {
path: string;
status: string;
}
export interface GitBranchBaseResponse {
/** Null when git has no authoritative record of where the branch started. */
base: string | null;
}
export interface GitFileDiffResponse {
original: string;
modified: string;
@@ -466,6 +482,8 @@ export interface GitAPI {
getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse>;
getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse>;
getGitRangeDiff?(directory: string, options: GetGitRangeDiffOptions): Promise<GitDiffResponse>;
getGitRangeFiles?(directory: string, options: GetGitRangeFilesOptions): Promise<GitRangeFileEntry[]>;
getBranchBase?(directory: string, branch: string): Promise<GitBranchBaseResponse>;
revertGitFile(directory: string, filePath: string, options?: { scope?: 'all' | 'working' }): Promise<void>;
stageGitFile(directory: string, filePath: string): Promise<void>;
stageGitFiles?(directory: string, filePaths: string[]): Promise<void>;
+18
View File
@@ -119,6 +119,24 @@ export async function getGitRangeDiff(
return gitHttp.getGitRangeDiff(directory, options);
}
export async function getGitRangeFiles(
directory: string,
options: import('./api/types').GetGitRangeFilesOptions
): Promise<import('./api/types').GitRangeFileEntry[]> {
const runtime = getRuntimeGit();
if (runtime?.getGitRangeFiles) return runtime.getGitRangeFiles(directory, options);
return gitHttp.getGitRangeFiles(directory, options);
}
export async function getBranchBase(
directory: string,
branch: string
): Promise<import('./api/types').GitBranchBaseResponse> {
const runtime = getRuntimeGit();
if (runtime?.getBranchBase) return runtime.getBranchBase(directory, branch);
return gitHttp.getBranchBase(directory, branch);
}
export async function revertGitFile(
directory: string,
filePath: string,
+46
View File
@@ -3,6 +3,7 @@ import type {
GitDiffResponse,
GetGitDiffOptions,
GetGitRangeDiffOptions,
GetGitRangeFilesOptions,
GitFileDiffResponse,
GetGitFileDiffOptions,
GitBranch,
@@ -248,6 +249,51 @@ export async function getGitRangeDiff(
return response.json();
}
export async function getGitRangeFiles(
directory: string,
options: GetGitRangeFilesOptions
): Promise<import('./api/types').GitRangeFileEntry[]> {
const { base, head } = options;
if (!base || !head) {
throw new Error('base and head are required to fetch git range files');
}
const response = await runtimeFetch(
buildUrl(`${API_BASE}/range-files`, directory, { base, head })
);
if (!response.ok) {
throw new Error(`Failed to get git range files: ${response.statusText}`);
}
const payload = (await response.json()) as { files?: unknown };
if (!Array.isArray(payload.files)) return [];
return payload.files.filter((entry): entry is import('./api/types').GitRangeFileEntry => {
if (!entry || typeof entry !== 'object') return false;
const candidate = entry as { path?: unknown; status?: unknown };
return typeof candidate.path === 'string' && typeof candidate.status === 'string';
});
}
export async function getBranchBase(
directory: string,
branch: string
): Promise<import('./api/types').GitBranchBaseResponse> {
if (!branch) {
throw new Error('branch is required to get branch base');
}
const response = await runtimeFetch(
buildUrl(`${API_BASE}/branch-base`, directory, { branch })
);
if (!response.ok) {
throw new Error(`Failed to get branch base: ${response.statusText}`);
}
return response.json();
}
export async function getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse> {
const { path, staged } = options;
if (!path) {
+7
View File
@@ -1360,6 +1360,13 @@ export const dict = {
'diffView.scope.changed': 'Geändert',
'diffView.scope.staged': 'Staged',
'diffView.scope.lastTurn': 'Letzter Zug',
'diffView.scope.branch': 'Branch',
'diffView.branch.resolvingBase': 'Basis-Branch wird ermittelt...',
'diffView.branch.noBaseTitle': 'Kein Basis-Branch',
'diffView.branch.noBaseDescription': 'Git enthält keinen Eintrag, wo dieser Branch entstanden ist. Wähle einen Basis-Branch für den Vergleich.',
'diffView.branch.loadError': 'Branch-Änderungen konnten nicht geladen werden',
'diffView.branch.loadingFiles': 'Branch-Änderungen werden geladen...',
'diffView.branch.empty': 'Keine Änderungen in diesem Branch gegenüber {base}',
'diffView.scope.selectorAria': 'Änderungsmodus auswählen',
'diffView.actions.retry': 'Erneut versuchen',
'diffView.actions.renderAnyway': 'Trotzdem rendern',
+7
View File
@@ -1517,6 +1517,13 @@ export const dict = {
'diffView.scope.changed': 'Changed',
'diffView.scope.staged': 'Staged',
'diffView.scope.lastTurn': 'Last turn',
'diffView.scope.branch': 'Branch',
'diffView.branch.resolvingBase': 'Detecting base branch...',
'diffView.branch.noBaseTitle': 'No base branch',
'diffView.branch.noBaseDescription': 'Git has no record of where this branch started. Choose a base branch to compare against.',
'diffView.branch.loadError': 'Failed to load branch changes',
'diffView.branch.loadingFiles': 'Loading branch changes...',
'diffView.branch.empty': 'No changes on this branch relative to {base}',
'diffView.scope.selectorAria': 'Select change mode',
'diffView.actions.retry': 'Retry',
'diffView.actions.renderAnyway': 'Render anyway',
+7
View File
@@ -1483,6 +1483,13 @@ export const dict: Record<I18nKey, string> = {
"diffView.scope.changed": "Cambiados",
"diffView.scope.staged": "Staged",
"diffView.scope.lastTurn": "Último turno",
"diffView.scope.branch": "Rama",
"diffView.branch.resolvingBase": "Detectando rama base...",
"diffView.branch.noBaseTitle": "Sin rama base",
"diffView.branch.noBaseDescription": "Git no tiene registro de dónde surgió esta rama. Elige una rama base para comparar.",
"diffView.branch.loadError": "No se pudieron cargar los cambios de la rama",
"diffView.branch.loadingFiles": "Cargando cambios de la rama...",
"diffView.branch.empty": "No hay cambios en esta rama respecto a {base}",
"diffView.scope.selectorAria": "Seleccionar modo de cambios",
"diffView.actions.retry": "Volver a intentar",
"diffView.actions.renderAnyway": "Renderizar de todos modos",
+7
View File
@@ -1282,6 +1282,13 @@ export const dict = {
"diffView.scope.changed": "Modifiés",
"diffView.scope.staged": "Staged",
"diffView.scope.lastTurn": "Dernier tour",
"diffView.scope.branch": "Branche",
"diffView.branch.resolvingBase": "Détection de la branche de base...",
"diffView.branch.noBaseTitle": "Aucune branche de base",
"diffView.branch.noBaseDescription": "Git ne conserve aucune trace de la branche dorigine de cette branche. Choisissez une branche de base pour la comparaison.",
"diffView.branch.loadError": "Échec du chargement des modifications de la branche",
"diffView.branch.loadingFiles": "Chargement des modifications de la branche...",
"diffView.branch.empty": "Aucune modification sur cette branche par rapport à {base}",
"diffView.scope.selectorAria": "Sélectionner le mode de changements",
'diffView.actions.retry': 'Réessayer',
'diffView.actions.renderAnyway': 'Afficher quand même',
+7
View File
@@ -1513,6 +1513,13 @@ export const dict: Record<I18nKey, string> = {
'diffView.scope.changed': '変更済み',
'diffView.scope.staged': 'ステージ済み',
'diffView.scope.lastTurn': '最後のターン',
'diffView.scope.branch': 'ブランチ',
'diffView.branch.resolvingBase': 'ベースブランチを検出中...',
'diffView.branch.noBaseTitle': 'ベースブランチがありません',
'diffView.branch.noBaseDescription': 'このブランチがどこから作られたかの記録がGitにありません。比較するベースブランチを選択してください。',
'diffView.branch.loadError': 'ブランチの変更を読み込めませんでした',
'diffView.branch.loadingFiles': 'ブランチの変更を読み込み中...',
'diffView.branch.empty': 'このブランチには{base}に対する変更はありません',
'diffView.scope.selectorAria': '変更モードを選択',
'diffView.actions.retry': '再試行',
'diffView.actions.renderAnyway': 'とにかくレンダリング',
+7
View File
@@ -1519,6 +1519,13 @@ export const dict: Record<I18nKey, string> = {
"diffView.scope.changed": "Changed",
"diffView.scope.staged": "Staged",
"diffView.scope.lastTurn": "마지막 턴",
"diffView.scope.branch": "브랜치",
"diffView.branch.resolvingBase": "베이스 브랜치 감지 중...",
"diffView.branch.noBaseTitle": "베이스 브랜치 없음",
"diffView.branch.noBaseDescription": "이 브랜치가 어디서 시작되었는지 Git에 기록이 없습니다. 비교할 베이스 브랜치를 선택하세요.",
"diffView.branch.loadError": "브랜치 변경 사항을 불러오지 못했습니다",
"diffView.branch.loadingFiles": "브랜치 변경 사항 불러오는 중...",
"diffView.branch.empty": "이 브랜치에는 {base}에 대한 변경 사항이 없습니다",
"diffView.scope.selectorAria": "변경 모드 선택",
'diffView.actions.retry': '다시 시도',
'diffView.actions.renderAnyway': '그래도 렌더링',
+7
View File
@@ -1795,6 +1795,13 @@ export const dict: Record<I18nKey, string> = {
"diffView.scope.changed": "Zmienione",
"diffView.scope.staged": "Staged",
"diffView.scope.lastTurn": "Ostatnia tura",
"diffView.scope.branch": "Gałąź",
"diffView.branch.resolvingBase": "Wykrywanie gałęzi bazowej...",
"diffView.branch.noBaseTitle": "Brak gałęzi bazowej",
"diffView.branch.noBaseDescription": "Git nie zapisuje, od której gałęzi ta gałąź powstała. Wybierz gałąź bazową do porównania.",
"diffView.branch.loadError": "Nie udało się wczytać zmian gałęzi",
"diffView.branch.loadingFiles": "Wczytywanie zmian gałęzi...",
"diffView.branch.empty": "Brak zmian w tej gałęzi względem {base}",
"diffView.scope.selectorAria": "Wybierz tryb zmian",
'diffView.summary.changedFilesSingle': 'Zmieniono {count} plik',
'directoryExplorerDialog.actions.addProject': 'Dodaj projekt',
@@ -1483,6 +1483,13 @@ export const dict: Record<I18nKey, string> = {
"diffView.scope.changed": "Alteradas",
"diffView.scope.staged": "Staged",
"diffView.scope.lastTurn": "Último turno",
"diffView.scope.branch": "Branch",
"diffView.branch.resolvingBase": "Detectando branch base...",
"diffView.branch.noBaseTitle": "Sem branch base",
"diffView.branch.noBaseDescription": "O Git não tem registro de onde este branch começou. Escolha um branch base para comparar.",
"diffView.branch.loadError": "Falha ao carregar as alterações do branch",
"diffView.branch.loadingFiles": "Carregando alterações do branch...",
"diffView.branch.empty": "Nenhuma alteração neste branch em relação a {base}",
"diffView.scope.selectorAria": "Selecionar modo de alterações",
"diffView.actions.retry": "Tentar novamente",
"diffView.actions.renderAnyway": "Renderizar mesmo assim",
+7
View File
@@ -1483,6 +1483,13 @@ export const dict: Record<I18nKey, string> = {
"diffView.scope.changed": "Змінені",
"diffView.scope.staged": "Індексовані",
"diffView.scope.lastTurn": "Останній хід",
"diffView.scope.branch": "Гілка",
"diffView.branch.resolvingBase": "Визначаємо базову гілку...",
"diffView.branch.noBaseTitle": "Немає базової гілки",
"diffView.branch.noBaseDescription": "Git не зберігає, від якої гілки почалася ця гілка. Виберіть базову гілку для порівняння.",
"diffView.branch.loadError": "Не вдалося завантажити зміни гілки",
"diffView.branch.loadingFiles": "Завантаження змін гілки...",
"diffView.branch.empty": "Немає змін у цій гілці відносно {base}",
"diffView.scope.selectorAria": "Вибрати режим змін",
"diffView.actions.retry": "Повторити спробу",
"diffView.actions.renderAnyway": "Все одно відрендерити",
@@ -1483,6 +1483,13 @@ export const dict: Record<I18nKey, string> = {
"diffView.scope.changed": "已更改",
"diffView.scope.staged": "已暂存",
"diffView.scope.lastTurn": "上一轮",
"diffView.scope.branch": "分支",
"diffView.branch.resolvingBase": "正在检测基础分支...",
"diffView.branch.noBaseTitle": "没有基础分支",
"diffView.branch.noBaseDescription": "Git 中没有记录此分支的起点。请选择一个基础分支进行比较。",
"diffView.branch.loadError": "加载分支更改失败",
"diffView.branch.loadingFiles": "正在加载分支更改...",
"diffView.branch.empty": "此分支相对于 {base} 没有更改",
"diffView.scope.selectorAria": "选择更改模式",
'diffView.actions.retry': '重试',
'diffView.actions.renderAnyway': '仍然渲染',
@@ -1493,6 +1493,13 @@ export const dict: Record<I18nKey, string> = {
"diffView.scope.changed": "已變更",
"diffView.scope.staged": "已暫存",
"diffView.scope.lastTurn": "上一輪",
"diffView.scope.branch": "分支",
"diffView.branch.resolvingBase": "正在偵測基礎分支...",
"diffView.branch.noBaseTitle": "沒有基礎分支",
"diffView.branch.noBaseDescription": "Git 中沒有記錄此分支的起點。請選擇基礎分支進行比較。",
"diffView.branch.loadError": "載入分支變更失敗",
"diffView.branch.loadingFiles": "正在載入分支變更...",
"diffView.branch.empty": "此分支相對於 {base} 沒有變更",
"diffView.scope.selectorAria": "選擇變更模式",
'diffView.actions.retry': '重試',
'diffView.actions.renderAnyway': '仍然渲染',
@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, mock, test } from "bun:test"
let runtimeKey = "runtime-a"
mock.module("@/lib/runtime-switch", () => ({ getRuntimeKey: () => runtimeKey }))
const { gitBaseBranchEntryKey, useGitBaseBranchStore } = await import("./useGitBaseBranchStore")
describe("git base branch overrides", () => {
beforeEach(() => {
runtimeKey = "runtime-a"
useGitBaseBranchStore.setState({ overrides: {} })
})
test("keys the same repository per branch and runtime", () => {
const featureA = gitBaseBranchEntryKey("/repo", "feature-a")
const featureB = gitBaseBranchEntryKey("/repo", "feature-b")
runtimeKey = "runtime-b"
const featureARemote = gitBaseBranchEntryKey("/repo", "feature-a")
expect(new Set([featureA, featureB, featureARemote]).size).toBe(3)
})
test("a base picked for one branch does not apply to another branch", () => {
const store = useGitBaseBranchStore.getState()
store.setOverride("/repo", "feature-a", "main")
expect(store.getOverride("/repo", "feature-a")).toBe("main")
// feature-b must fall back to its own detection, not feature-a's choice.
expect(store.getOverride("/repo", "feature-b")).toBeNull()
})
test("different branches of one repository keep independent bases", () => {
const store = useGitBaseBranchStore.getState()
store.setOverride("/repo", "feature-a", "main")
store.setOverride("/repo", "feature-b", "develop")
expect(store.getOverride("/repo", "feature-a")).toBe("main")
expect(store.getOverride("/repo", "feature-b")).toBe("develop")
})
test("clearOverride removes only the targeted branch's choice", () => {
const store = useGitBaseBranchStore.getState()
store.setOverride("/repo", "feature-a", "main")
store.setOverride("/repo", "feature-b", "develop")
store.clearOverride("/repo", "feature-a")
expect(store.getOverride("/repo", "feature-a")).toBeNull()
expect(store.getOverride("/repo", "feature-b")).toBe("develop")
})
test("rejects empty directory, branch, or base", () => {
const store = useGitBaseBranchStore.getState()
store.setOverride("", "feature-a", "main")
store.setOverride("/repo", "", "main")
store.setOverride("/repo", "feature-a", "")
store.clearOverride("", "feature-a")
expect(useGitBaseBranchStore.getState().overrides).toEqual({})
expect(store.getOverride("", "feature-a")).toBeNull()
expect(store.getOverride("/repo", "")).toBeNull()
})
})
@@ -0,0 +1,71 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
const GIT_BASE_BRANCH_STORAGE_KEY = 'openchamber.git-base-branch';
const MAX_BASE_BRANCH_ENTRIES = 100;
/**
* Build the persisted override key for one branch of one repository.
*
* The branch is part of the identity on purpose: a base picked for one feature
* branch is not an answer for a different branch of the same repository, and a
* directory-only key would silently shadow reflog detection after checkout.
* Keys include the runtime identity so a remote runtime's paths never shadow
* local ones.
*/
export const gitBaseBranchEntryKey = (directory: string, branch: string): string =>
JSON.stringify([getRuntimeKey(), directory, branch]);
type GitBaseBranchState = {
overrides: Record<string, string>;
getOverride: (directory: string, branch: string) => string | null;
setOverride: (directory: string, branch: string, base: string) => void;
clearOverride: (directory: string, branch: string) => void;
};
/**
* Explicit per-branch base choices for the "Branch" diff scope.
*
* Git does not record a parent branch for every branch (clones, detached
* starts). When no authoritative source exists, the user picks a base once and
* the choice is remembered for that branch.
*/
export const useGitBaseBranchStore = create<GitBaseBranchState>()(
persist(
(set, get) => ({
overrides: {},
getOverride: (directory, branch) => {
if (!directory || !branch) return null;
return get().overrides[gitBaseBranchEntryKey(directory, branch)] ?? null;
},
setOverride: (directory, branch, base) => {
if (!directory || !branch || !base) return;
set((state) => {
const key = gitBaseBranchEntryKey(directory, branch);
const entries = Object.entries({ ...state.overrides, [key]: base });
while (entries.length > MAX_BASE_BRANCH_ENTRIES) {
entries.shift();
}
return { overrides: Object.fromEntries(entries) };
});
},
clearOverride: (directory, branch) => {
if (!directory || !branch) return;
set((state) => {
const key = gitBaseBranchEntryKey(directory, branch);
if (!(key in state.overrides)) return state;
const next = { ...state.overrides };
delete next[key];
return { overrides: next };
});
},
}),
{
name: GIT_BASE_BRANCH_STORAGE_KEY,
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({ overrides: state.overrides }),
}
)
);
+2 -2
View File
@@ -20,7 +20,7 @@ import { isVSCodeRuntime } from '@/lib/desktop';
export type WorkspaceSurface = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context' | 'diagram';
/** @deprecated Use WorkspaceSurface. */
export type MainTab = WorkspaceSurface;
export type PendingDiffScope = 'working' | 'staged' | 'turn';
export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch';
export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'notes' | 'terminal';
export type MermaidRenderingMode = 'svg' | 'ascii';
export type UserMessageRenderingMode = 'markdown' | 'plain';
@@ -205,7 +205,7 @@ const normalizeContextTabLabel = (value: string | null | undefined): string | nu
};
const normalizePendingDiffScope = (value: unknown): PendingDiffScope | null => {
return value === 'working' || value === 'staged' || value === 'turn' ? value : null;
return value === 'working' || value === 'staged' || value === 'turn' || value === 'branch' ? value : null;
};
const buildDefaultContextPanelTabDedupeKey = (mode: ContextPanelMode, targetPath: string | null): string => {
+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');
});
});
+2
View File
@@ -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,