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({