From 0b01f5ae2d2beada53066afce5e633661808efff Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 21 Aug 2026 23:16:58 +0300 Subject: [PATCH] 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 --- packages/ui/src/components/views/DiffView.tsx | 382 +++++++++++-- .../components/views/branchDiffScope.test.ts | 503 ++++++++++++++++++ .../src/components/views/branchDiffScope.ts | 211 ++++++++ packages/ui/src/lib/api/types.ts | 18 + packages/ui/src/lib/gitApi.ts | 18 + packages/ui/src/lib/gitApiHttp.ts | 46 ++ packages/ui/src/lib/i18n/messages/de.ts | 7 + packages/ui/src/lib/i18n/messages/en.ts | 7 + packages/ui/src/lib/i18n/messages/es.ts | 7 + packages/ui/src/lib/i18n/messages/fr.ts | 7 + packages/ui/src/lib/i18n/messages/ja.ts | 7 + packages/ui/src/lib/i18n/messages/ko.ts | 7 + packages/ui/src/lib/i18n/messages/pl.ts | 7 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 7 + packages/ui/src/lib/i18n/messages/uk.ts | 7 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 7 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 7 + .../src/stores/useGitBaseBranchStore.test.ts | 62 +++ .../ui/src/stores/useGitBaseBranchStore.ts | 71 +++ packages/ui/src/stores/useUIStore.ts | 4 +- packages/web/server/lib/git/routes.js | 43 ++ packages/web/server/lib/git/service.js | 90 +++- packages/web/server/lib/git/service.test.js | 93 ++++ packages/web/src/api/git.ts | 2 + 24 files changed, 1582 insertions(+), 38 deletions(-) create mode 100644 packages/ui/src/components/views/branchDiffScope.test.ts create mode 100644 packages/ui/src/components/views/branchDiffScope.ts create mode 100644 packages/ui/src/stores/useGitBaseBranchStore.test.ts create mode 100644 packages/ui/src/stores/useGitBaseBranchStore.ts diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 0dfd659e..896208b7 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -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; + scope: Extract; workingCount: number; stagedCount: number; turnCount: number; - onScopeChange?: (scope: Extract) => void; + branchCount: number | null; + showBranchOption: boolean; + onScopeChange?: (scope: Extract) => void; } const ChangeScopeSelector = React.memo(({ @@ -242,16 +258,20 @@ const ChangeScopeSelector = React.memo(({ 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 ( @@ -271,7 +291,7 @@ const ChangeScopeSelector = React.memo(({ { - 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(({ {turnCount} + {showBranchOption ? ( + + + {t('diffView.scope.branch')} + {branchCount ?? '…'} + + + ) : null} @@ -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(({ @@ -593,6 +623,7 @@ const MultiFileDiffEntry = React.memo(({ staged = false, loadFullFiles = false, initialDiffData = null, + readOnlyActions = false, }) => { const { t } = useI18n(); const { git } = useRuntimeAPIs(); @@ -922,13 +953,15 @@ const MultiFileDiffEntry = React.memo(({ />
- + {!readOnlyActions ? ( + + ) : null}
@@ -945,7 +978,7 @@ interface DiffViewProps { pinSelectedFileHeaderToTopOnNavigate?: boolean; showOpenInEditorAction?: boolean; diffScope?: DiffScope; - onDiffScopeChange?: (scope: Extract) => void; + onDiffScopeChange?: (scope: Extract) => 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 = ({ 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(null); @@ -1083,7 +1117,213 @@ export const DiffView: React.FC = ({ 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(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(null); + const [branchFilesError, setBranchFilesError] = React.useState(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( + 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 = ({ 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 = ({ }} 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 + } /> ))} @@ -1707,10 +1942,93 @@ export const DiffView: React.FC = ({ ); } + if (activeDiffScope === 'branch') { + if (!isBranchBaseResolved) { + return ( +
+ + {t('diffView.branch.resolvingBase')} +
+ ); + } + + 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 ( +
+ +
{t('diffView.branch.noBaseTitle')}
+
{t('diffView.branch.noBaseDescription')}
+ 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)]" + /> + + {candidateBranches.length === 0 ? ( +
+ {t('gitView.branch.empty')} +
+ ) : ( +
+ {candidateBranches.map((branch: string) => ( + + ))} +
+ )} +
+
+ ); + } + + if (branchFilesError) { + return ( +
+
{t('diffView.branch.loadError')}
+
{branchFilesError}
+ +
+ ); + } + + if (branchFiles === null) { + return ( +
+ + {t('diffView.branch.loadingFiles')} +
+ ); + } + } + if (changedFiles.length === 0) { return (
- {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')}
); } @@ -1722,12 +2040,14 @@ export const DiffView: React.FC = ({
{!isMobile && ( - activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' ? ( + activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' || activeDiffScope === 'branch' ? ( { setActiveDiffScope(scope); onDiffScopeChange?.(scope); diff --git a/packages/ui/src/components/views/branchDiffScope.test.ts b/packages/ui/src/components/views/branchDiffScope.test.ts new file mode 100644 index 00000000..f365b858 --- /dev/null +++ b/packages/ui/src/components/views/branchDiffScope.test.ts @@ -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 = () => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((next, decline) => { + resolve = next; + reject = decline; + }); + return { promise, resolve, reject }; +}; + +const installMinimalDom = () => { + const descriptors = new Map(); + 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) => 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(); + const fetchB = deferred(); + type CapturedEntries = { entries: ReadonlyMap | null }; + const captured: CapturedEntries = { entries: null }; + + let currentFetcher: (path: string) => Promise = () => fetchA.promise; + + const Harness = () => { + captured.entries = useRangeKeyedCache( + 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(); + const fetchB = deferred(); + type CapturedEntries = { entries: ReadonlyMap | null }; + const captured: CapturedEntries = { entries: null }; + + let currentFetcher: (path: string) => Promise = () => fetchA.promise; + let rangeKey: string = rangeA; + + const Harness = () => { + captured.entries = useRangeKeyedCache(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(); + type CapturedEntries = { entries: ReadonlyMap | null }; + const captured: CapturedEntries = { entries: null }; + + let currentPathsKey = pathsKey; + const fetched: string[] = []; + + const Harness = () => { + captured.entries = useRangeKeyedCache( + '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(); + } + }); +}); diff --git a/packages/ui/src/components/views/branchDiffScope.ts b/packages/ui/src/components/views/branchDiffScope.ts new file mode 100644 index 00000000..19d50d64 --- /dev/null +++ b/packages/ui/src/components/views/branchDiffScope.ts @@ -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 = ( + 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 = ( + rangeKey: string | null, + pathsKey: string, + fetchEntry: ((path: string) => Promise) | null, + placeholder: T +): ReadonlyMap => { + const [entries, setEntries] = React.useState>(() => 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(); + + 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; +}; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 7c44cc62..fd555f3f 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -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; getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise; getGitRangeDiff?(directory: string, options: GetGitRangeDiffOptions): Promise; + getGitRangeFiles?(directory: string, options: GetGitRangeFilesOptions): Promise; + getBranchBase?(directory: string, branch: string): Promise; revertGitFile(directory: string, filePath: string, options?: { scope?: 'all' | 'working' }): Promise; stageGitFile(directory: string, filePath: string): Promise; stageGitFiles?(directory: string, filePaths: string[]): Promise; diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index 56203ebb..1192cb06 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -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 { + 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 { + const runtime = getRuntimeGit(); + if (runtime?.getBranchBase) return runtime.getBranchBase(directory, branch); + return gitHttp.getBranchBase(directory, branch); +} + export async function revertGitFile( directory: string, filePath: string, diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 24317b24..2c0c6603 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -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 { + 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 { + 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 { const { path, staged } = options; if (!path) { diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 9fcf7760..d957a7b9 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index e69452de..981f2b8f 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index acf65e36..394cc771 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1483,6 +1483,13 @@ export const dict: Record = { "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", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 94d915e4..1315c8f7 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -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 d’origine 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', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 0a2529c5..09882066 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1513,6 +1513,13 @@ export const dict: Record = { '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': 'とにかくレンダリング', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index fc164a8f..ef0eca76 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1519,6 +1519,13 @@ export const dict: Record = { "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': '그래도 렌더링', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index ba9bc45e..fbe1dc4f 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1795,6 +1795,13 @@ export const dict: Record = { "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', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index f13f1748..a467bc91 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1483,6 +1483,13 @@ export const dict: Record = { "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", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index a6867e50..b87b7876 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1483,6 +1483,13 @@ export const dict: Record = { "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": "Все одно відрендерити", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 71c92446..e7852359 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1483,6 +1483,13 @@ export const dict: Record = { "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': '仍然渲染', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 58c52dce..c8122b4c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1493,6 +1493,13 @@ export const dict: Record = { "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': '仍然渲染', diff --git a/packages/ui/src/stores/useGitBaseBranchStore.test.ts b/packages/ui/src/stores/useGitBaseBranchStore.test.ts new file mode 100644 index 00000000..3909d4e2 --- /dev/null +++ b/packages/ui/src/stores/useGitBaseBranchStore.test.ts @@ -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() + }) +}) diff --git a/packages/ui/src/stores/useGitBaseBranchStore.ts b/packages/ui/src/stores/useGitBaseBranchStore.ts new file mode 100644 index 00000000..0d3d993f --- /dev/null +++ b/packages/ui/src/stores/useGitBaseBranchStore.ts @@ -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; + 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()( + 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 }), + } + ) +); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 7c772122..8ed14467 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -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 => { diff --git a/packages/web/server/lib/git/routes.js b/packages/web/server/lib/git/routes.js index 3766fea2..0fe38cdf 100644 --- a/packages/web/server/lib/git/routes.js +++ b/packages/web/server/lib/git/routes.js @@ -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 { diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index e82d3774..d8e97b37 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -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 `) 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']; diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index 88baecc3..eb86c2ac 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -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'); + }); +}); diff --git a/packages/web/src/api/git.ts b/packages/web/src/api/git.ts index 80c48a46..d6bc9fd9 100644 --- a/packages/web/src/api/git.ts +++ b/packages/web/src/api/git.ts @@ -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,