diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 1ebb3aaf7b1..4d79c7c5d8d 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -536,6 +536,7 @@ export function ReviewSheet(props: ReviewSheetProps) { const actions: MenuAction[] = [ sectionAction(sectionMenu.workingTree, "Working tree"), sectionAction(sectionMenu.branchChanges, "Branch changes"), + sectionAction(sectionMenu.sinceFork, "Since fork"), sectionAction(sectionMenu.latestTurn, "Latest turn"), ]; @@ -731,6 +732,17 @@ export function ReviewSheet(props: ReviewSheetProps) { > Branch changes + { + if (sectionMenu.sinceFork) { + selectSection(sectionMenu.sinceFork.id); + } + }} + > + Since fork + { const turn27 = section("turn:27", "turn"); const workingTree = section("git:working-tree", "working-tree"); const branchChanges = section("git:branch-range", "branch-range"); + const sinceFork = section("git:since-fork", "since-fork"); - expect(buildReviewSectionMenu([turn28, turn27, workingTree, branchChanges])).toEqual({ - workingTree, - branchChanges, - latestTurn: turn28, - turns: [turn28, turn27], - }); + expect(buildReviewSectionMenu([turn28, turn27, workingTree, branchChanges, sinceFork])).toEqual( + { + workingTree, + branchChanges, + sinceFork, + latestTurn: turn28, + turns: [turn28, turn27], + }, + ); }); it("keeps unavailable scopes empty while data loads", () => { expect(buildReviewSectionMenu([])).toEqual({ workingTree: null, branchChanges: null, + sinceFork: null, latestTurn: null, turns: [], }); diff --git a/apps/mobile/src/features/review/review-section-menu.ts b/apps/mobile/src/features/review/review-section-menu.ts index 87d10266529..f3a3a5fe2d5 100644 --- a/apps/mobile/src/features/review/review-section-menu.ts +++ b/apps/mobile/src/features/review/review-section-menu.ts @@ -3,6 +3,7 @@ import type { ReviewSectionItem } from "./reviewModel"; export interface ReviewSectionMenu { readonly workingTree: ReviewSectionItem | null; readonly branchChanges: ReviewSectionItem | null; + readonly sinceFork: ReviewSectionItem | null; readonly latestTurn: ReviewSectionItem | null; readonly turns: ReadonlyArray; } @@ -15,6 +16,7 @@ export function buildReviewSectionMenu( return { workingTree: sections.find((section) => section.kind === "working-tree") ?? null, branchChanges: sections.find((section) => section.kind === "branch-range") ?? null, + sinceFork: sections.find((section) => section.kind === "since-fork") ?? null, latestTurn: turns[0] ?? null, turns, }; diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index 9459d41872d..8aa886d5e91 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -5,7 +5,7 @@ import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; import * as Order from "effect/Order"; -export type ReviewSectionKind = "turn" | "working-tree" | "branch-range"; +export type ReviewSectionKind = "turn" | "working-tree" | "branch-range" | "since-fork"; const DIRTY_WORKTREE_SECTION_ID = "git:working-tree"; const DIRTY_WORKTREE_TITLE = "Dirty worktree"; @@ -160,7 +160,9 @@ function gitSubtitle(section: ReviewDiffPreviewSource): string | null { return DIRTY_WORKTREE_SUBTITLE; } if (section.baseRef) { - return `${section.baseRef} ... ${section.headRef ?? "HEAD"}`; + return section.kind === "since-fork" + ? `${section.baseRef} ... worktree` + : `${section.baseRef} ... ${section.headRef ?? "HEAD"}`; } return "Base branch unavailable"; } diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 24d53cd4846..7282e4c825e 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -803,6 +803,49 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ); }), ); + + it.effect("covers committed and uncommitted work in the since-fork preview", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["checkout", "-b", "feature/since-fork"]); + yield* writeTextFile(cwd, "committed.txt", "committed\n"); + yield* git(cwd, ["add", "committed.txt"]); + yield* git(cwd, ["commit", "-m", "add committed file"]); + yield* writeTextFile(cwd, "README.md", "# dirty tracked edit\n"); + yield* writeTextFile(cwd, "untracked.txt", "untracked\n"); + + const preview = yield* driver.getReviewDiffPreview({ cwd, baseRef: initialBranch }); + const sinceFork = preview.sources.find((source) => source.kind === "since-fork"); + const branchRange = preview.sources.find((source) => source.kind === "branch-range"); + + assert.isDefined(sinceFork); + assert.include(sinceFork.diff, "committed.txt"); + assert.include(sinceFork.diff, "README.md"); + assert.include(sinceFork.diff, "untracked.txt"); + assert.strictEqual(sinceFork.baseRef, initialBranch); + + // The committed-only view stays narrow so both scopes remain distinguishable. + assert.include(branchRange?.diff ?? "", "committed.txt"); + assert.notInclude(branchRange?.diff ?? "", "untracked.txt"); + }), + ); + + it.effect("keeps the since-fork preview empty when no base branch resolves", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile(cwd, "README.md", "# dirty\n"); + + const preview = yield* driver.getReviewDiffPreview({ cwd, baseRef: "does-not-exist" }); + const sinceFork = preview.sources.find((source) => source.kind === "since-fork"); + + assert.isDefined(sinceFork); + assert.strictEqual(sinceFork.diff, ""); + }), + ); }); describe("repository status", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index e44dc048634..e61a82e8f2e 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2136,6 +2136,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); + const EMPTY_GIT_RESULT = { + exitCode: 0, + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + const getReviewDiffPreview = Effect.fn("getReviewDiffPreview")(function* ( input: ReviewDiffPreviewInput, ) { @@ -2175,15 +2183,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, appendTruncationMarker: true, }, - ).pipe( - Effect.orElseSucceed(() => ({ - exitCode: 0, - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - })), - ); + ).pipe(Effect.orElseSucceed(() => EMPTY_GIT_RESULT)); const dirtyUntracked = yield* readUntrackedReviewDiffs(input.cwd).pipe( Effect.orElseSucceed(() => ({ diff: "", truncated: false })), ); @@ -2210,17 +2210,51 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, appendTruncationMarker: true, }, - ).pipe( - Effect.orElseSucceed(() => ({ - exitCode: 0, - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - })), - ) + ).pipe(Effect.orElseSucceed(() => EMPTY_GIT_RESULT)) : null; const baseDiff = baseResult?.stdout ?? ""; + + // `git diff A...B` has no working-tree form, so resolve the fork point explicitly and diff it + // against the worktree to cover committed and uncommitted work in a single patch. + const mergeBaseResult = + baseRef && branch + ? yield* executeGit( + "GitVcsDriver.getReviewDiffPreview.mergeBase", + input.cwd, + ["merge-base", baseRef, "HEAD"], + { allowNonZeroExit: true }, + ).pipe(Effect.orElseSucceed(() => ({ ...EMPTY_GIT_RESULT, exitCode: 1 }))) + : null; + const mergeBaseSha = + mergeBaseResult?.exitCode === 0 && mergeBaseResult.stdout.trim().length > 0 + ? mergeBaseResult.stdout.trim() + : null; + const sinceForkResult = mergeBaseSha + ? yield* executeGit( + "GitVcsDriver.getReviewDiffPreview.sinceFork", + input.cwd, + [ + "diff", + "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--minimal", + ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), + mergeBaseSha, + "--", + ], + { + maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ).pipe(Effect.orElseSucceed(() => EMPTY_GIT_RESULT)) + : null; + const sinceForkDiff = sinceForkResult + ? [sinceForkResult.stdout.trimEnd(), dirtyUntracked.diff.trimEnd()] + .filter((diff) => diff.length > 0) + .join("\n") + : ""; const hashDiff = (diff: string) => crypto.digest("SHA-256", new TextEncoder().encode(diff)).pipe( Effect.map(Encoding.encodeHex), @@ -2235,9 +2269,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ), ); - const [dirtyDiffHash, baseDiffHash] = yield* Effect.all([ + const [dirtyDiffHash, baseDiffHash, sinceForkDiffHash] = yield* Effect.all([ hashDiff(dirtyDiff), hashDiff(baseDiff), + hashDiff(sinceForkDiff), ]); const sources: ReviewDiffPreviewSource[] = [ @@ -2261,6 +2296,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* diffHash: baseDiffHash, truncated: baseResult?.stdoutTruncated ?? false, }, + { + id: "since-fork", + kind: "since-fork", + title: baseRef ? `All changes since ${baseRef}` : "All changes since base branch", + baseRef, + headRef: branch ?? "HEAD", + diff: sinceForkDiff, + diffHash: sinceForkDiffHash, + truncated: (sinceForkResult?.stdoutTruncated ?? false) || dirtyUntracked.truncated, + }, ]; return { diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index d10cb39f0e3..34f07115d18 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -25,7 +25,11 @@ import { type DraftId } from "../composerDraftStore"; import { openDiffFilePrimaryAction } from "../diffFileActions"; import { useCheckpointDiff } from "~/lib/checkpointDiffState"; import { cn } from "~/lib/utils"; -import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore"; +import { + type DiffPanelGitScope, + selectThreadDiffPanelSelection, + useDiffPanelStore, +} from "../diffPanelStore"; import { useTheme } from "../hooks/useTheme"; import { buildFileDiffRenderKey, @@ -83,6 +87,18 @@ interface CollapsedDiffFilesState { const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet = new Set(); +const GIT_SCOPE_LABELS: Record = { + unstaged: "Working tree", + branch: "Branch changes", + "since-fork": "Since fork", +}; + +const GIT_SCOPE_SOURCE_KINDS: Record = { + unstaged: "working-tree", + branch: "branch-range", + "since-fork": "since-fork", +}; + const DIFF_PANEL_UNSAFE_CSS = ` [data-diffs-header], [data-diff], @@ -272,8 +288,15 @@ export default function DiffPanel({ }, [diffSelection, orderedTurnDiffSummaries, routeThreadRef]); const selectedTurnId = diffSelection.kind === "turn" ? diffSelection.turnId : null; - const selectedGitScope = diffSelection.kind === "unstaged" ? "unstaged" : "branch"; - const selectedBaseRef = diffSelection.kind === "branch" ? diffSelection.baseRef : null; + const selectedGitScope: DiffPanelGitScope = + diffSelection.kind === "unstaged" || diffSelection.kind === "since-fork" + ? diffSelection.kind + : "branch"; + const selectedBaseRef = + diffSelection.kind === "branch" || diffSelection.kind === "since-fork" + ? diffSelection.baseRef + : null; + const isBaseRelativeScope = selectedGitScope === "branch" || selectedGitScope === "since-fork"; const selectedFilePath = diffSelection.kind === "turn" ? diffSelection.filePath : null; const selectedFileRevealRequestId = diffSelection.kind === "turn" ? diffSelection.revealRequestId : 0; @@ -288,9 +311,7 @@ export default function DiffPanel({ const latestTurn = orderedTurnDiffSummaries[0]; const selectedScopeLabel = selectedTurnId === null - ? selectedGitScope === "unstaged" - ? "Working tree" - : "Branch changes" + ? GIT_SCOPE_LABELS[selectedGitScope] : selectedTurn?.turnId === latestTurn?.turnId ? "Latest turn" : `Turn ${selectedCheckpointTurnCount ?? "?"}`; @@ -304,9 +325,7 @@ export default function DiffPanel({ : EMPTY_COLLAPSED_DIFF_FILE_KEYS; const reviewSectionTitle = selectedTurn ? `Turn ${selectedCheckpointTurnCount ?? "?"}` - : selectedGitScope === "unstaged" - ? "Working tree" - : "Branch changes"; + : GIT_SCOPE_LABELS[selectedGitScope]; const selectedCheckpointRange = useMemo( () => typeof selectedCheckpointTurnCount === "number" @@ -361,13 +380,10 @@ export default function DiffPanel({ ? fallbackBranchDiffPreview : primaryBranchDiffPreview; const selectedGitSource = branchDiffPreview.data?.sources.find( - (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"), + (source) => source.kind === GIT_SCOPE_SOURCE_KINDS[selectedGitScope], ); const localBranchRefs = useEnvironmentQuery( - selectedTurnId === null && - selectedGitScope === "branch" && - activeThread && - branchDiffPreview.data?.cwd + selectedTurnId === null && isBaseRelativeScope && activeThread && branchDiffPreview.data?.cwd ? vcsEnvironment.listRefs({ environmentId: activeThread.environmentId, input: { @@ -381,10 +397,7 @@ export default function DiffPanel({ : null, ); const remoteBranchRefs = useEnvironmentQuery( - selectedTurnId === null && - selectedGitScope === "branch" && - activeThread && - branchDiffPreview.data?.cwd + selectedTurnId === null && isBaseRelativeScope && activeThread && branchDiffPreview.data?.cwd ? vcsEnvironment.listRefs({ environmentId: activeThread.environmentId, input: { @@ -521,7 +534,7 @@ export default function DiffPanel({ if (!routeThreadRef) return; useDiffPanelStore.getState().selectTurn(routeThreadRef, turnId); }; - const selectGitScope = (scope: "branch" | "unstaged") => { + const selectGitScope = (scope: DiffPanelGitScope) => { if (!routeThreadRef) return; useDiffPanelStore.getState().selectGitScope(routeThreadRef, scope); }; @@ -562,6 +575,16 @@ export default function DiffPanel({ > Branch changes + selectGitScope("since-fork")} + > + Since fork + - {selectedTurnId === null && selectedGitScope === "branch" && selectedGitSource?.baseRef && ( + {selectedTurnId === null && isBaseRelativeScope && selectedGitSource?.baseRef && (
) : ( diff --git a/apps/web/src/diffPanelStore.test.ts b/apps/web/src/diffPanelStore.test.ts index 607b7c8d580..d01642a2d46 100644 --- a/apps/web/src/diffPanelStore.test.ts +++ b/apps/web/src/diffPanelStore.test.ts @@ -64,6 +64,25 @@ describe("diffPanelStore", () => { ).toEqual({ kind: "branch", baseRef: "origin/main" }); }); + it("carries the base ref between the two base-relative scopes", () => { + useDiffPanelStore.getState().selectBranchBaseRef(THREAD_REF, "origin/main"); + useDiffPanelStore.getState().selectGitScope(THREAD_REF, "since-fork"); + + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ kind: "since-fork", baseRef: "origin/main" }); + + useDiffPanelStore.getState().selectBranchBaseRef(THREAD_REF, "origin/develop"); + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ kind: "since-fork", baseRef: "origin/develop" }); + + useDiffPanelStore.getState().selectGitScope(THREAD_REF, "branch"); + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ kind: "branch", baseRef: "origin/develop" }); + }); + it("reconciles a missing turn selection to the latest available turn", () => { const missingTurnId = TurnId.make("turn-missing"); const latestTurnId = TurnId.make("turn-latest"); diff --git a/apps/web/src/diffPanelStore.ts b/apps/web/src/diffPanelStore.ts index 56b5ad23fec..6c0bc738e48 100644 --- a/apps/web/src/diffPanelStore.ts +++ b/apps/web/src/diffPanelStore.ts @@ -5,8 +5,11 @@ import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; +export type DiffPanelGitScope = "branch" | "since-fork" | "unstaged"; + export type DiffPanelSelection = | { kind: "branch"; baseRef: string | null } + | { kind: "since-fork"; baseRef: string | null } | { kind: "unstaged" } | { kind: "turn"; turnId: TurnId; filePath: string | null; revealRequestId: number }; @@ -16,7 +19,7 @@ const DEFAULT_WORKING_TREE_SELECTION: DiffPanelSelection = { kind: "unstaged" }; interface DiffPanelStoreState { byThreadKey: Record; branchBaseRefByThreadKey: Record; - selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void; + selectGitScope: (ref: ScopedThreadRef, scope: DiffPanelGitScope) => void; selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void; selectTurn: (ref: ScopedThreadRef, turnId: TurnId, filePath?: string) => void; reconcileTurnSelection: (ref: ScopedThreadRef, availableTurnIds: ReadonlyArray) => void; @@ -28,6 +31,13 @@ function normalizeBaseRef(baseRef: string | null): string | null { return normalized ? normalized : null; } +/** Both base-relative scopes share one remembered comparison target per thread. */ +function isBaseRefSelection( + selection: DiffPanelSelection | undefined, +): selection is Extract { + return selection?.kind === "branch" || selection?.kind === "since-fork"; +} + export const useDiffPanelStore = create()( persist( (set) => ({ @@ -37,32 +47,34 @@ export const useDiffPanelStore = create()( set((state) => { const threadKey = scopedThreadKey(ref); const previous = state.byThreadKey[threadKey]; - const previousBaseRef = - previous?.kind === "branch" - ? previous.baseRef - : (state.branchBaseRefByThreadKey[threadKey] ?? null); + const previousBaseRef = isBaseRefSelection(previous) + ? previous.baseRef + : (state.branchBaseRefByThreadKey[threadKey] ?? null); return { byThreadKey: { ...state.byThreadKey, [threadKey]: - scope === "branch" - ? { kind: "branch", baseRef: previousBaseRef } - : { kind: "unstaged" }, + scope === "unstaged" + ? { kind: "unstaged" } + : { kind: scope, baseRef: previousBaseRef }, }, - branchBaseRefByThreadKey: - previous?.kind === "branch" - ? { ...state.branchBaseRefByThreadKey, [threadKey]: previous.baseRef } - : state.branchBaseRefByThreadKey, + branchBaseRefByThreadKey: isBaseRefSelection(previous) + ? { ...state.branchBaseRefByThreadKey, [threadKey]: previous.baseRef } + : state.branchBaseRefByThreadKey, }; }), selectBranchBaseRef: (ref, baseRef) => set((state) => { const threadKey = scopedThreadKey(ref); const normalizedBaseRef = normalizeBaseRef(baseRef); + const previous = state.byThreadKey[threadKey]; return { byThreadKey: { ...state.byThreadKey, - [threadKey]: { kind: "branch", baseRef: normalizedBaseRef }, + [threadKey]: { + kind: previous?.kind === "since-fork" ? "since-fork" : "branch", + baseRef: normalizedBaseRef, + }, }, branchBaseRefByThreadKey: { ...state.branchBaseRefByThreadKey, diff --git a/packages/contracts/src/review.ts b/packages/contracts/src/review.ts index a6b879a0c7f..b1a138c0695 100644 --- a/packages/contracts/src/review.ts +++ b/packages/contracts/src/review.ts @@ -10,7 +10,11 @@ export const ReviewDiffPreviewInput = Schema.Struct({ }); export type ReviewDiffPreviewInput = typeof ReviewDiffPreviewInput.Type; -export const ReviewDiffPreviewSourceKind = Schema.Literals(["working-tree", "branch-range"]); +export const ReviewDiffPreviewSourceKind = Schema.Literals([ + "working-tree", + "branch-range", + "since-fork", +]); export type ReviewDiffPreviewSourceKind = typeof ReviewDiffPreviewSourceKind.Type; export const ReviewDiffPreviewSource = Schema.Struct({