authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-02 22:52:42-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-02 22:52:42-07:00
logd1034d62b28e19c19240085566a39a92c5f9b466
tree2de62c712d716ad204fdd3e695376c314f11169b
parent8283b481a34f5cc6f984f290e7cb93cc35b4dc56
signature Signed by PGP key B5690EEEBB952194

fix(web): diff panel opens on all branch changes, not just uncommitted (#15005)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

18 files changed, 611 insertions(+), 176 deletions(-)

apps/mobile/src/features/review/ReviewSheet.tsx+2-2
...@@ -126,8 +126,8 @@ function ReviewHeader(...@@ -126,8 +126,8 @@ function ReviewHeader(
126 id: "sections",126 id: "sections",
127 inline: true,127 inline: true,
128 items: [128 items: [
129 sectionAction(props.sectionMenu.workingTree, "Working tree"),129 sectionAction(props.sectionMenu.branchChanges, "Changes"),
130 sectionAction(props.sectionMenu.branchChanges, "Branch changes"),130 sectionAction(props.sectionMenu.workingTree, "Uncommitted"),
131 sectionAction(props.sectionMenu.latestTurn, "Latest turn"),131 sectionAction(props.sectionMenu.latestTurn, "Latest turn"),
132 ],132 ],
133 },133 },
apps/mobile/src/features/review/reviewModel.test.ts+26-9
...@@ -62,7 +62,7 @@ describe("buildReviewSectionItems", () => {...@@ -62,7 +62,7 @@ describe("buildReviewSectionItems", () => {
62 {62 {
63 id: "working-tree",63 id: "working-tree",
64 kind: "working-tree",64 kind: "working-tree",
65 title: "Dirty worktree",65 title: "Uncommitted",
66 baseRef: "HEAD",66 baseRef: "HEAD",
67 headRef: null,67 headRef: null,
68 diff: "diff --git a/a.ts b/a.ts",68 diff: "diff --git a/a.ts b/a.ts",
...@@ -72,7 +72,7 @@ describe("buildReviewSectionItems", () => {...@@ -72,7 +72,7 @@ describe("buildReviewSectionItems", () => {
72 {72 {
73 id: "branch-range",73 id: "branch-range",
74 kind: "branch-range",74 kind: "branch-range",
75 title: "Against main",75 title: "Changes vs main",
76 baseRef: "main",76 baseRef: "main",
77 headRef: "feature",77 headRef: "feature",
78 diff: "diff --git a/a.ts b/a.ts",78 diff: "diff --git a/a.ts b/a.ts",
...@@ -105,10 +105,28 @@ describe("buildReviewSectionItems", () => {...@@ -105,10 +105,28 @@ describe("buildReviewSectionItems", () => {
105 isLoading: false,105 isLoading: false,
106 diff: expect.stringContaining("loaded.ts"),106 diff: expect.stringContaining("loaded.ts"),
107 });107 });
108 expect(getDefaultReviewSectionId(items)).toBe("turn:2");108 expect(getDefaultReviewSectionId(items)).toBe("git:branch-range");
109 });109 });
110110
111 it("shows dirty worktree while git preview is loading", () => {111 it("falls back to the first turn without git sections", () => {
112 const items = buildReviewSectionItems({
113 checkpoints: [
114 makeCheckpoint({
115 runId: RunId.make("run-1"),
116 checkpointTurnCount: 1,
117 completedAt: "2026-04-01T00:00:00.000Z",
118 }),
119 ],
120 gitSections: [],
121 turnDiffById: {},
122 loadingTurnIds: {},
123 loadingGitSections: false,
124 });
125
126 expect(getDefaultReviewSectionId(items)).toBe("turn:1");
127 });
128
129 it("holds the Changes place while git preview is loading", () => {
112 const items = buildReviewSectionItems({130 const items = buildReviewSectionItems({
113 checkpoints: [],131 checkpoints: [],
114 gitSections: [],132 gitSections: [],
...@@ -119,15 +137,14 @@ describe("buildReviewSectionItems", () => {...@@ -119,15 +137,14 @@ describe("buildReviewSectionItems", () => {
119137
120 expect(items).toEqual([138 expect(items).toEqual([
121 expect.objectContaining({139 expect.objectContaining({
122 id: "git:working-tree",140 id: "git:branch-range",
123 kind: "working-tree",141 kind: "branch-range",
124 title: "Dirty worktree",142 title: "Changes",
125 subtitle: "Tracked, staged, and untracked worktree changes",
126 diff: null,143 diff: null,
127 isLoading: true,144 isLoading: true,
128 }),145 }),
129 ]);146 ]);
130 expect(getDefaultReviewSectionId(items)).toBe("git:working-tree");147 expect(getDefaultReviewSectionId(items)).toBe("git:branch-range");
131 });148 });
132});149});
133150
apps/mobile/src/features/review/reviewModel.ts+13-11
...@@ -9,9 +9,9 @@ import * as Order from "effect/Order";...@@ -9,9 +9,9 @@ import * as Order from "effect/Order";
99
10export type ReviewSectionKind = "turn" | "working-tree" | "branch-range";10export type ReviewSectionKind = "turn" | "working-tree" | "branch-range";
1111
12const DIRTY_WORKTREE_SECTION_ID = "git:working-tree";12const CHANGES_SECTION_ID = "git:branch-range";
13const DIRTY_WORKTREE_TITLE = "Dirty worktree";13const CHANGES_TITLE = "Changes";
14const DIRTY_WORKTREE_SUBTITLE = "Tracked, staged, and untracked worktree changes";14const UNCOMMITTED_SUBTITLE = "Staged, unstaged, and untracked files";
1515
16export interface ReviewSectionItem {16export interface ReviewSectionItem {
17 readonly id: string;17 readonly id: string;
...@@ -124,7 +124,7 @@ const readyCheckpointOrder = Order.make<ThreadCheckpointSummary>(...@@ -124,7 +124,7 @@ const readyCheckpointOrder = Order.make<ThreadCheckpointSummary>(
124124
125function gitSubtitle(section: ReviewDiffPreviewSource): string | null {125function gitSubtitle(section: ReviewDiffPreviewSource): string | null {
126 if (section.kind === "working-tree") {126 if (section.kind === "working-tree") {
127 return DIRTY_WORKTREE_SUBTITLE;127 return UNCOMMITTED_SUBTITLE;
128 }128 }
129 if (section.baseRef) {129 if (section.baseRef) {
130 return `${section.baseRef} ... ${section.headRef ?? "HEAD"}`;130 return `${section.baseRef} ... ${section.headRef ?? "HEAD"}`;
...@@ -443,15 +443,16 @@ export function buildReviewSectionItems(input: {...@@ -443,15 +443,16 @@ export function buildReviewSectionItems(input: {
443 truncated: section.truncated,443 truncated: section.truncated,
444 isLoading: false,444 isLoading: false,
445 }));445 }));
446 const hasDirtyWorktreeItem = gitItems.some((item) => item.id === DIRTY_WORKTREE_SECTION_ID);446 // Changes is the default section, so it holds the place while git sources load.
447 const hasChangesItem = gitItems.some((item) => item.id === CHANGES_SECTION_ID);
447 const visibleGitItems =448 const visibleGitItems =
448 input.loadingGitSections && !hasDirtyWorktreeItem449 input.loadingGitSections && !hasChangesItem
449 ? [450 ? [
450 {451 {
451 id: DIRTY_WORKTREE_SECTION_ID,452 id: CHANGES_SECTION_ID,
452 kind: "working-tree",453 kind: "branch-range",
453 title: DIRTY_WORKTREE_TITLE,454 title: CHANGES_TITLE,
454 subtitle: DIRTY_WORKTREE_SUBTITLE,455 subtitle: null,
455 diff: null,456 diff: null,
456 isLoading: true,457 isLoading: true,
457 } satisfies ReviewSectionItem,458 } satisfies ReviewSectionItem,
...@@ -462,10 +463,11 @@ export function buildReviewSectionItems(input: {...@@ -462,10 +463,11 @@ export function buildReviewSectionItems(input: {
462 return [...turnItems, ...visibleGitItems];463 return [...turnItems, ...visibleGitItems];
463}464}
464465
466/** Prefers Changes, then the first section (a turn when the project is not a git repo). */
465export function getDefaultReviewSectionId(467export function getDefaultReviewSectionId(
466 sections: ReadonlyArray<ReviewSectionItem>,468 sections: ReadonlyArray<ReviewSectionItem>,
467): string | null {469): string | null {
468 return sections[0]?.id ?? null;470 return (sections.find((section) => section.id === CHANGES_SECTION_ID) ?? sections[0])?.id ?? null;
469}471}
470472
471export function buildReviewParsedDiff(473export function buildReviewParsedDiff(
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+1-1
...@@ -282,7 +282,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {...@@ -282,7 +282,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
282 <SheetListRow282 <SheetListRow
283 icon="text.bubble"283 icon="text.bubble"
284 title="Review changes"284 title="Review changes"
285 subtitle="Inspect turn diffs, worktree changes, and base branch diff"285 subtitle="Inspect changes, uncommitted edits, and turn diffs"
286 disabled={busy || !isRepo}286 disabled={busy || !isRepo}
287 onPress={() => {287 onPress={() => {
288 const params = { environmentId, threadId };288 const params = { environmentId, threadId };
apps/server/src/git/GitManager.ts+4-3
...@@ -1000,7 +1000,7 @@ export const make = Effect.gen(function* () {...@@ -1000,7 +1000,7 @@ export const make = Effect.gen(function* () {
1000 const canonicalizeExistingPath = (value: string) =>1000 const canonicalizeExistingPath = (value: string) =>
1001 fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value));1001 fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value));
1002 const normalizeStatusCacheKey = canonicalizeExistingPath;1002 const normalizeStatusCacheKey = canonicalizeExistingPath;
1003 const nonRepositoryStatusDetails = {1003 const nonRepositoryStatusDetails: GitVcsDriver.GitStatusDetails = {
1004 isRepo: false,1004 isRepo: false,
1005 hasOriginRemote: false,1005 hasOriginRemote: false,
1006 isDefaultBranch: false,1006 isDefaultBranch: false,
...@@ -1012,10 +1012,10 @@ export const make = Effect.gen(function* () {...@@ -1012,10 +1012,10 @@ export const make = Effect.gen(function* () {
1012 aheadCount: 0,1012 aheadCount: 0,
1013 behindCount: 0,1013 behindCount: 0,
1014 aheadOfDefaultCount: 0,1014 aheadOfDefaultCount: 0,
1015 } satisfies GitVcsDriver.GitStatusDetails;1015 };
1016 const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) {1016 const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) {
1017 const details = yield* gitCore1017 const details = yield* gitCore
1018 .statusDetailsLocal(cwd, { includeDivergence: false })1018 .statusDetailsLocal(cwd, { includeDivergence: false, includeBranchChanges: true })
1019 .pipe(1019 .pipe(
1020 Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)),1020 Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)),
1021 );1021 );
...@@ -1031,6 +1031,7 @@ export const make = Effect.gen(function* () {...@@ -1031,6 +1031,7 @@ export const make = Effect.gen(function* () {
1031 refName: details.branch,1031 refName: details.branch,
1032 hasWorkingTreeChanges: details.hasWorkingTreeChanges,1032 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
1033 workingTree: details.workingTree,1033 workingTree: details.workingTree,
1034 ...(details.branchChanges ? { branchChanges: details.branchChanges } : {}),
1034 } satisfies VcsStatusLocalResult;1035 } satisfies VcsStatusLocalResult;
1035 });1036 });
1036 const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, {1037 const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, {
apps/server/src/vcs/GitVcsDriver.ts+3
...@@ -76,6 +76,7 @@ export interface GitStatusDetails {...@@ -76,6 +76,7 @@ export interface GitStatusDetails {
76 upstreamRef: string | null;76 upstreamRef: string | null;
77 hasWorkingTreeChanges: boolean;77 hasWorkingTreeChanges: boolean;
78 workingTree: VcsStatusResult["workingTree"];78 workingTree: VcsStatusResult["workingTree"];
79 branchChanges?: VcsStatusResult["branchChanges"];
79 hasUpstream: boolean;80 hasUpstream: boolean;
80 aheadCount: number;81 aheadCount: number;
81 behindCount: number;82 behindCount: number;
...@@ -85,6 +86,8 @@ export interface GitStatusDetails {...@@ -85,6 +86,8 @@ export interface GitStatusDetails {
85export interface GitLocalStatusOptions {86export interface GitLocalStatusOptions {
86 /** Skip revision walks and return zero divergence counts for local-only consumers. */87 /** Skip revision walks and return zero divergence counts for local-only consumers. */
87 readonly includeDivergence?: boolean;88 readonly includeDivergence?: boolean;
89 /** Also read the diff panel's Changes totals. Failures leave them out. */
90 readonly includeBranchChanges?: boolean;
88}91}
8992
90export interface GitRemoteStatusDetails {93export interface GitRemoteStatusDetails {
apps/server/src/vcs/GitVcsDriverCore.test.ts+179-6
...@@ -26,6 +26,7 @@ import {...@@ -26,6 +26,7 @@ import {
26 GitCommandError,26 GitCommandError,
27 ReviewDiffPreviewInput,27 ReviewDiffPreviewInput,
28 type ReviewDiffFileContentsInput,28 type ReviewDiffFileContentsInput,
29 type ReviewDiffPreviewResult,
29 type WorktreeSubmodules,30 type WorktreeSubmodules,
30} from "@t3tools/contracts";31} from "@t3tools/contracts";
31import * as ServerConfig from "../config.ts";32import * as ServerConfig from "../config.ts";
...@@ -1128,6 +1129,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -1128,6 +1129,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
1128 Effect.gen(function* () {1129 Effect.gen(function* () {
1129 const cwd = yield* makeTmpDir();1130 const cwd = yield* makeTmpDir();
1130 const { initialBranch } = yield* initRepoWithCommit(cwd);1131 const { initialBranch } = yield* initRepoWithCommit(cwd);
1132 const mergeBase = yield* git(cwd, ["rev-parse", "HEAD"]);
1131 yield* writeTextFile(cwd, "untracked.txt", "untracked content\n");1133 yield* writeTextFile(cwd, "untracked.txt", "untracked content\n");
1132 const paths = Array.from({ length: 5000 }, (_, index) => `${"a".repeat(220)}-${index}.txt`);1134 const paths = Array.from({ length: 5000 }, (_, index) => `${"a".repeat(220)}-${index}.txt`);
1133 const stats = paths.map((path) => `1\t0\t${path}\0`).join("");1135 const stats = paths.map((path) => `1\t0\t${path}\0`).join("");
...@@ -1138,10 +1140,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -1138,10 +1140,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
1138 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;1140 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
1139 const spawner = ChildProcessSpawner.make((command) => {1141 const spawner = ChildProcessSpawner.make((command) => {
1140 if (ChildProcess.isStandardCommand(command)) {1142 if (ChildProcess.isStandardCommand(command)) {
1141 if (1143 if (command.args.includes("--numstat") && command.args.includes(mergeBase)) {
1142 command.args.includes("--numstat") &&
1143 command.args.includes(`${initialBranch}...HEAD`)
1144 ) {
1145 return Effect.succeed(makeSuccessfulHandle(stats));1144 return Effect.succeed(makeSuccessfulHandle(stats));
1146 }1145 }
1147 if (command.args.includes("ls-files") && command.args.includes("--others")) {1146 if (command.args.includes("ls-files") && command.args.includes("--others")) {
...@@ -1566,6 +1565,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -1566,6 +1565,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
15661565
1567 assert.deepStrictEqual(branch.files, [1566 assert.deepStrictEqual(branch.files, [
1568 { path: "a-large.txt", previousPath: null, additions: 4000, deletions: 0 },1567 { path: "a-large.txt", previousPath: null, additions: 4000, deletions: 0 },
1568 { path: "untracked.txt", previousPath: null, additions: 4000, deletions: 0 },
1569 { path: "z-last.txt", previousPath: null, additions: 1, deletions: 0 },1569 { path: "z-last.txt", previousPath: null, additions: 1, deletions: 0 },
1570 ]);1570 ]);
1571 assert.deepStrictEqual(dirty.files, [1571 assert.deepStrictEqual(dirty.files, [
...@@ -1740,7 +1740,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -1740,7 +1740,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
1740 }),1740 }),
1741 );1741 );
17421742
1743 it.effect("loads merge-base and head contents for branch diff expansion", () =>1743 it.effect("loads merge-base and disk contents for Changes expansion", () =>
1744 Effect.gen(function* () {1744 Effect.gen(function* () {
1745 const cwd = yield* makeTmpDir();1745 const cwd = yield* makeTmpDir();
1746 const { initialBranch } = yield* initRepoWithCommit(cwd);1746 const { initialBranch } = yield* initRepoWithCommit(cwd);
...@@ -1749,6 +1749,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -1749,6 +1749,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
1749 yield* writeTextFile(cwd, "README.md", "# branch change\nunchanged context\n");1749 yield* writeTextFile(cwd, "README.md", "# branch change\nunchanged context\n");
1750 yield* git(cwd, ["add", "README.md"]);1750 yield* git(cwd, ["add", "README.md"]);
1751 yield* git(cwd, ["commit", "-m", "change readme"]);1751 yield* git(cwd, ["commit", "-m", "change readme"]);
1752 yield* writeTextFile(cwd, "README.md", "# dirty change\nunchanged context\n");
17521753
1753 const contents = yield* driver.getReviewDiffFileContents(1754 const contents = yield* driver.getReviewDiffFileContents(
1754 makeReviewDiffFileContentsInput(cwd, {1755 makeReviewDiffFileContentsInput(cwd, {
...@@ -1759,12 +1760,184 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -1759,12 +1760,184 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
1759 );1760 );
17601761
1761 assert.strictEqual(contents.oldContents, "# test\n");1762 assert.strictEqual(contents.oldContents, "# test\n");
1762 assert.strictEqual(contents.newContents, "# branch change\nunchanged context\n");1763 assert.strictEqual(contents.newContents, "# dirty change\nunchanged context\n");
1764 }),
1765 );
1766
1767 it.effect("Changes combines commits, uncommitted edits, and untracked files", () =>
1768 Effect.gen(function* () {
1769 const cwd = yield* makeTmpDir();
1770 const { initialBranch } = yield* initRepoWithCommit(cwd);
1771 const driver = yield* GitVcsDriver.GitVcsDriver;
1772 const sourceFiles = (
1773 preview: ReviewDiffPreviewResult,
1774 kind: "working-tree" | "branch-range",
1775 ) => preview.sources.find((source) => source.kind === kind)!.files;
1776 yield* git(cwd, ["checkout", "-b", "feature/combined"]);
1777 yield* writeTextFile(cwd, "README.md", "# test\ncommitted\n");
1778 yield* git(cwd, ["commit", "-am", "commit edit"]);
1779
1780 // A fully committed branch has no uncommitted work, but Changes still shows it.
1781 const clean = yield* driver.getReviewDiffPreview({ cwd, baseRef: initialBranch });
1782 assert.deepStrictEqual(sourceFiles(clean, "working-tree"), []);
1783 assert.deepStrictEqual(sourceFiles(clean, "branch-range"), [
1784 { path: "README.md", previousPath: null, additions: 1, deletions: 0 },
1785 ]);
1786
1787 yield* writeTextFile(cwd, "README.md", "# test\ncommitted\ndirty\n");
1788 yield* writeTextFile(cwd, "new.txt", "new\n");
1789 const dirty = yield* driver.getReviewDiffPreview({ cwd, baseRef: initialBranch });
1790 const changes = dirty.sources.find((source) => source.kind === "branch-range")!;
1791 assert.deepStrictEqual(changes.files, [
1792 { path: "README.md", previousPath: null, additions: 2, deletions: 0 },
1793 { path: "new.txt", previousPath: null, additions: 1, deletions: 0 },
1794 ]);
1795 assert.strictEqual(changes.diff.match(/^diff --git a\/README\.md /gm)?.length, 1);
1796 assert.include(changes.diff, "+dirty");
1797 assert.deepStrictEqual(sourceFiles(dirty, "working-tree"), [
1798 { path: "README.md", previousPath: null, additions: 1, deletions: 0 },
1799 { path: "new.txt", previousPath: null, additions: 1, deletions: 0 },
1800 ]);
1801 }),
1802 );
1803
1804 it.effect("Changes on the default branch compares with its remote copy", () =>
1805 Effect.gen(function* () {
1806 const cwd = yield* makeTmpDir();
1807 const remote = yield* makeTmpDir();
1808 yield* initRepoWithCommit(cwd);
1809 const driver = yield* GitVcsDriver.GitVcsDriver;
1810 yield* git(cwd, ["branch", "-M", "main"]);
1811 yield* writeTextFile(cwd, "unpushed.txt", "unpushed\n");
1812 yield* git(cwd, ["add", "unpushed.txt"]);
1813 yield* git(cwd, ["commit", "-m", "unpushed"]);
1814
1815 // Without a remote there is no base, so Changes equals Uncommitted.
1816 const local = yield* driver.getReviewDiffPreview({ cwd });
1817 const localChanges = local.sources.find((source) => source.kind === "branch-range")!;
1818 assert.isNull(localChanges.baseRef);
1819 assert.deepStrictEqual(localChanges.files, []);
1820
1821 yield* git(remote, ["init", "--bare"]);
1822 yield* git(cwd, ["remote", "add", "origin", remote]);
1823 yield* git(cwd, ["push", "origin", "HEAD~1:refs/heads/main"]);
1824 yield* git(cwd, ["fetch", "origin"]);
1825 const preview = yield* driver.getReviewDiffPreview({ cwd });
1826 const changes = preview.sources.find((source) => source.kind === "branch-range")!;
1827 assert.strictEqual(changes.baseRef, "origin/main");
1828 assert.deepStrictEqual(changes.files, [
1829 { path: "unpushed.txt", previousPath: null, additions: 1, deletions: 0 },
1830 ]);
1831 }),
1832 );
1833
1834 it.effect("Changes accepts an explicit base on a detached HEAD and rejects a bad one", () =>
1835 Effect.gen(function* () {
1836 const cwd = yield* makeTmpDir();
1837 const { initialBranch } = yield* initRepoWithCommit(cwd);
1838 const driver = yield* GitVcsDriver.GitVcsDriver;
1839 yield* git(cwd, ["checkout", "--detach"]);
1840 yield* writeTextFile(cwd, "detached.txt", "detached\n");
1841 yield* git(cwd, ["add", "detached.txt"]);
1842 yield* git(cwd, ["commit", "-m", "detached work"]);
1843
1844 const explicit = yield* driver.getReviewDiffPreview({ cwd, baseRef: initialBranch });
1845 assert.deepStrictEqual(
1846 explicit.sources.find((source) => source.kind === "branch-range")!.files,
1847 [{ path: "detached.txt", previousPath: null, additions: 1, deletions: 0 }],
1848 );
1849 const implicit = yield* driver.getReviewDiffPreview({ cwd });
1850 assert.deepStrictEqual(
1851 implicit.sources.find((source) => source.kind === "branch-range")!.files,
1852 [],
1853 );
1854 const error = yield* driver
1855 .getReviewDiffPreview({ cwd, baseRef: "missing-base" })
1856 .pipe(Effect.flip);
1857 assert.strictEqual(error.operation, "GitVcsDriver.resolveReviewMergeBase");
1858 }),
1859 );
1860
1861 it.effect("Changes compares with the empty tree before the first commit", () =>
1862 Effect.gen(function* () {
1863 const upstream = yield* makeTmpDir();
1864 yield* initRepoWithCommit(upstream);
1865 yield* git(upstream, ["branch", "-M", "main"]);
1866 const cwd = yield* makeTmpDir();
1867 const driver = yield* GitVcsDriver.GitVcsDriver;
1868 // An unborn main whose remote copy exists must not fail on merge-base.
1869 yield* git(cwd, ["init", "-b", "main"]);
1870 yield* git(cwd, ["remote", "add", "origin", upstream]);
1871 yield* git(cwd, ["fetch", "origin"]);
1872 yield* writeTextFile(cwd, "new.txt", "one\ntwo\n");
1873
1874 const preview = yield* driver.getReviewDiffPreview({ cwd });
1875 const changes = preview.sources.find((source) => source.kind === "branch-range")!;
1876 assert.isNull(changes.baseRef);
1877 assert.deepStrictEqual(changes.files, [
1878 { path: "new.txt", previousPath: null, additions: 2, deletions: 0 },
1879 ]);
1880 const status = yield* driver.statusDetailsLocal(cwd, { includeBranchChanges: true });
1881 assert.deepStrictEqual(status.branchChanges, {
1882 baseRef: null,
1883 insertions: 2,
1884 deletions: 0,
1885 });
1886 }),
1887 );
1888
1889 it.effect("Changes does not show newer base commits as deletions", () =>
1890 Effect.gen(function* () {
1891 const cwd = yield* makeTmpDir();
1892 const { initialBranch } = yield* initRepoWithCommit(cwd);
1893 const driver = yield* GitVcsDriver.GitVcsDriver;
1894 yield* git(cwd, ["checkout", "-b", "feature/rebased"]);
1895 yield* writeTextFile(cwd, "feature.txt", "feature\n");
1896 yield* git(cwd, ["add", "feature.txt"]);
1897 yield* git(cwd, ["commit", "-m", "feature work"]);
1898 yield* git(cwd, ["checkout", initialBranch]);
1899 yield* writeTextFile(cwd, "upstream.txt", "upstream\n");
1900 yield* git(cwd, ["add", "upstream.txt"]);
1901 yield* git(cwd, ["commit", "-m", "base moves"]);
1902 yield* git(cwd, ["checkout", "feature/rebased"]);
1903
1904 for (const rebase of [false, true]) {
1905 if (rebase) yield* git(cwd, ["rebase", initialBranch]);
1906 const preview = yield* driver.getReviewDiffPreview({ cwd, baseRef: initialBranch });
1907 assert.deepStrictEqual(
1908 preview.sources.find((source) => source.kind === "branch-range")!.files,
1909 [{ path: "feature.txt", previousPath: null, additions: 1, deletions: 0 }],
1910 );
1911 }
1763 }),1912 }),
1764 );1913 );
1765 });1914 });
17661915
1767 describe("repository status", () => {1916 describe("repository status", () => {
1917 it.effect("reads Changes totals with untracked files when requested", () =>
1918 Effect.gen(function* () {
1919 const cwd = yield* makeTmpDir();
1920 yield* initRepoWithCommit(cwd);
1921 const driver = yield* GitVcsDriver.GitVcsDriver;
1922 const pathService = yield* Path.Path;
1923 yield* git(cwd, ["branch", "-M", "main"]);
1924 yield* git(cwd, ["checkout", "-b", "feature/totals"]);
1925 yield* writeTextFile(cwd, "README.md", "# test\ncommitted\n");
1926 yield* git(cwd, ["commit", "-am", "commit edit"]);
1927 yield* writeTextFile(cwd, "nested/untracked.txt", "one\ntwo\n");
1928
1929 const status = yield* driver.statusDetailsLocal(pathService.join(cwd, "nested"), {
1930 includeBranchChanges: true,
1931 });
1932 assert.deepStrictEqual(status.branchChanges, {
1933 baseRef: "main",
1934 insertions: 3,
1935 deletions: 0,
1936 });
1937 assert.isUndefined((yield* driver.statusDetailsLocal(cwd)).branchChanges);
1938 }),
1939 );
1940
1768 it.effect("reports non-repository directories without failing", () =>1941 it.effect("reports non-repository directories without failing", () =>
1769 Effect.gen(function* () {1942 Effect.gen(function* () {
1770 const cwd = yield* makeTmpDir();1943 const cwd = yield* makeTmpDir();
apps/server/src/vcs/GitVcsDriverCore.ts+222-109
...@@ -62,6 +62,16 @@ const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024;...@@ -62,6 +62,16 @@ const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024;
62// prefixes. A repository or global diff.noprefix or diff.mnemonicPrefix would62// prefixes. A repository or global diff.noprefix or diff.mnemonicPrefix would
63// otherwise leak into the patch and leave every parsed file unnamed.63// otherwise leak into the patch and leave every parsed file unnamed.
64export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const;64export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const;
65// Shared by review previews and status totals, so the Changes row matches the Changes view.
66const REVIEW_DIFF_ARGS = [
67 "diff",
68 "--find-renames",
69 "--no-color",
70 "--no-ext-diff",
71 "--no-textconv",
72 "--minimal",
73 ...PATCH_RENDER_PREFIX_ARGS,
74];
65const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);75const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
66const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);76const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
6777
...@@ -1518,9 +1528,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1518,9 +1528,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1518 return remoteName;1528 return remoteName;
1519 });1529 });
15201530
1531 // `allowRemoteOfCurrent` lets the review diff compare the default branch with its remote copy.
1521 const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* (1532 const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* (
1522 cwd: string,1533 cwd: string,
1523 refName: string,1534 refName: string,
1535 options?: { readonly allowRemoteOfCurrent?: boolean },
1524 ) {1536 ) {
1525 const configuredBaseBranch = yield* runGitStdout(1537 const configuredBaseBranch = yield* runGitStdout(
1526 "GitVcsDriver.resolveBaseBranchForNoUpstream.config",1538 "GitVcsDriver.resolveBaseBranchForNoUpstream.config",
...@@ -1552,7 +1564,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1552,7 +1564,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1552 : remotePrefix && candidate.startsWith(remotePrefix)1564 : remotePrefix && candidate.startsWith(remotePrefix)
1553 ? candidate.slice(remotePrefix.length)1565 ? candidate.slice(remotePrefix.length)
1554 : candidate;1566 : candidate;
1555 if (normalizedCandidate.length === 0 || normalizedCandidate === refName) {1567 if (normalizedCandidate.length === 0) {
1568 continue;
1569 }
1570 if (normalizedCandidate === refName) {
1571 if (
1572 options?.allowRemoteOfCurrent &&
1573 primaryRemoteName &&
1574 (yield* remoteBranchExists({
1575 cwd,
1576 remoteName: primaryRemoteName,
1577 refName: normalizedCandidate,
1578 }))
1579 ) {
1580 return `${primaryRemoteName}/${normalizedCandidate}`;
1581 }
1556 continue;1582 continue;
1557 }1583 }
15581584
...@@ -1923,6 +1949,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1923,6 +1949,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1923 }1949 }
1924 files.sort((a, b) => a.path.localeCompare(b.path));1950 files.sort((a, b) => a.path.localeCompare(b.path));
19251951
1952 const branchChanges = options?.includeBranchChanges
1953 ? yield* readBranchChangeTotals(repositoryPaths?.worktreeRoot ?? cwd, refName).pipe(
1954 Effect.orElseSucceed(() => undefined),
1955 )
1956 : undefined;
1957
1926 return {1958 return {
1927 isRepo: true,1959 isRepo: true,
1928 hasOriginRemote: hasPrimaryRemote,1960 hasOriginRemote: hasPrimaryRemote,
...@@ -1935,6 +1967,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1935,6 +1967,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1935 insertions,1967 insertions,
1936 deletions,1968 deletions,
1937 },1969 },
1970 ...(branchChanges ? { branchChanges } : {}),
1938 hasUpstream: upstreamRef !== null,1971 hasUpstream: upstreamRef !== null,
1939 aheadCount,1972 aheadCount,
1940 behindCount,1973 behindCount,
...@@ -2413,6 +2446,145 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2413,6 +2446,145 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2413 return env;2446 return env;
2414 });2447 });
24152448
2449 // Before the first commit, review diffs compare with the empty tree instead of HEAD.
2450 const readEmptyTreeHash = Effect.fn("readEmptyTreeHash")(function* (cwd: string) {
2451 const stdout = yield* runGitStdout("GitVcsDriver.review.emptyTree", cwd, [
2452 "hash-object",
2453 "-t",
2454 "tree",
2455 (yield* HostProcessPlatform) === "win32" ? "NUL" : "/dev/null",
2456 ]);
2457 return stdout.trim();
2458 });
2459
2460 // Lists untracked files and adds them to a temporary index, so a diff against any commit
2461 // shows them as new. Returns null when the list is too big to read. Needs a Scope.
2462 const prepareUntrackedReviewIndex = Effect.fn("prepareUntrackedReviewIndex")(function* (
2463 cwd: string,
2464 pathArgs: ReadonlyArray<string>,
2465 onlyPath?: string,
2466 ) {
2467 const untracked = yield* executeGit(
2468 "GitVcsDriver.review.listUntracked",
2469 cwd,
2470 ["ls-files", "--others", "--exclude-standard", "-z", "--", ...pathArgs],
2471 { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES },
2472 ).pipe(
2473 Effect.catchIf(
2474 (error) => error.outputLength === undefined,
2475 () => Effect.succeed(null),
2476 ),
2477 );
2478 if (untracked === null) return null;
2479 const paths = splitNullSeparatedGitStdoutPaths(untracked).filter(
2480 (candidate) => onlyPath === undefined || candidate === onlyPath,
2481 );
2482 if (paths.length === 0) return { env: undefined };
2483 const env = yield* prepareReviewIndex(cwd, paths).pipe(
2484 Effect.catchTags({
2485 PlatformError: (cause) =>
2486 Effect.fail(
2487 new GitCommandError({
2488 operation: "GitVcsDriver.prepareReviewIndex",
2489 cwd,
2490 command: "git diff",
2491 detail: "Could not prepare the review index.",
2492 cause,
2493 }),
2494 ),
2495 }),
2496 );
2497 return { env };
2498 });
2499
2500 // The diff panel's Changes view compares the working tree with merge-base(base, HEAD).
2501 // With no usable base it compares with HEAD, so Changes equals Uncommitted.
2502 const resolveReviewMergeBase = Effect.fn("resolveReviewMergeBase")(function* (
2503 cwd: string,
2504 branch: string | null,
2505 explicitBaseRef?: string,
2506 ) {
2507 const baseRef =
2508 explicitBaseRef ??
2509 (branch
2510 ? yield* resolveBaseBranchForNoUpstream(cwd, branch, { allowRemoteOfCurrent: true }).pipe(
2511 Effect.orElseSucceed(() => null),
2512 )
2513 : null);
2514 if (baseRef === null) return { baseRef, mergeBase: "HEAD" };
2515 const args = ["merge-base", baseRef, "HEAD"];
2516 const result = yield* executeGit("GitVcsDriver.resolveReviewMergeBase", cwd, args, {
2517 allowNonZeroExit: true,
2518 });
2519 const mergeBase = result.stdout.trim();
2520 if (result.exitCode !== 0 || mergeBase.length === 0) {
2521 // Before the first commit there is nothing to compare with, so Changes equals Uncommitted.
2522 const head = yield* executeGit(
2523 "GitVcsDriver.resolveReviewMergeBase.head",
2524 cwd,
2525 ["rev-parse", "--verify", "--quiet", "HEAD"],
2526 { allowNonZeroExit: true },
2527 );
2528 if (explicitBaseRef === undefined && head.exitCode !== 0) {
2529 return { baseRef: null, mergeBase: "HEAD" };
2530 }
2531 return yield* new GitCommandError({
2532 ...gitCommandContext({ operation: "GitVcsDriver.resolveReviewMergeBase", cwd, args }),
2533 detail: `Could not find a common commit between '${baseRef}' and HEAD.`,
2534 exitCode: result.exitCode,
2535 });
2536 }
2537 return { baseRef, mergeBase };
2538 });
2539
2540 // Totals for the thread panel's Changes row. Same base and untracked files as the Changes view.
2541 const readBranchChangeTotals = Effect.fn("readBranchChangeTotals")(function* (
2542 cwd: string,
2543 branch: string | null,
2544 ) {
2545 const { baseRef, mergeBase } = yield* resolveReviewMergeBase(cwd, branch);
2546 const untracked = yield* prepareUntrackedReviewIndex(cwd, []);
2547 if (untracked === null) {
2548 return yield* new GitCommandError({
2549 operation: "GitVcsDriver.readBranchChangeTotals",
2550 command: "git ls-files",
2551 cwd,
2552 detail: "Too many untracked files to count.",
2553 });
2554 }
2555 const readNumstat = (ref: string) =>
2556 executeGit(
2557 "GitVcsDriver.readBranchChangeTotals",
2558 cwd,
2559 [...REVIEW_DIFF_ARGS, "--numstat", "-z", ref, "--"],
2560 {
2561 allowNonZeroExit: true,
2562 maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES,
2563 env: untracked.env,
2564 },
2565 );
2566 let result = yield* readNumstat(mergeBase);
2567 if (result.exitCode !== 0 && mergeBase === "HEAD" && isUnbornHeadStderr(result.stderr)) {
2568 result = yield* readNumstat(yield* readEmptyTreeHash(cwd));
2569 }
2570 if (result.exitCode !== 0) {
2571 return yield* new GitCommandError({
2572 operation: "GitVcsDriver.readBranchChangeTotals",
2573 command: "git diff --numstat",
2574 cwd,
2575 detail: "Could not read Changes totals.",
2576 exitCode: result.exitCode,
2577 });
2578 }
2579 let insertions = 0;
2580 let deletions = 0;
2581 for (const file of parseReviewNumstat(result.stdout)) {
2582 insertions += file.additions;
2583 deletions += file.deletions;
2584 }
2585 return { baseRef, insertions, deletions };
2586 }, Effect.scoped);
2587
2416 const getReviewDiffPreview = Effect.fn("getReviewDiffPreview")(function* (2588 const getReviewDiffPreview = Effect.fn("getReviewDiffPreview")(function* (
2417 input: ReviewDiffPreviewInput,2589 input: ReviewDiffPreviewInput,
2418 ) {2590 ) {
...@@ -2439,21 +2611,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2439,21 +2611,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2439 }2611 }
24402612
2441 const cwd = repository.worktreeRoot;2613 const cwd = repository.worktreeRoot;
2442 const branch = repository.currentBranch;2614 // A per-file request only reads its own source.
2443 const baseRef =2615 const dirtyRef = input.file?.sourceKind === "branch-range" ? null : "HEAD";
2444 input.baseRef ??2616 const review =
2445 (branch2617 input.file?.sourceKind === "working-tree"
2446 ? yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe(Effect.orElseSucceed(() => null))2618 ? { baseRef: input.baseRef ?? null, mergeBase: null }
2447 : null);2619 : yield* resolveReviewMergeBase(cwd, repository.currentBranch, input.baseRef);
24482620
2449 const diffArgs = [2621 const diffArgs = [
2450 "diff",2622 ...REVIEW_DIFF_ARGS,
2451 "--find-renames",
2452 "--no-color",
2453 "--no-ext-diff",
2454 "--no-textconv",
2455 "--minimal",
2456 ...PATCH_RENDER_PREFIX_ARGS,
2457 ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []),2623 ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []),
2458 ];2624 ];
2459 const readStats = Effect.fn("GitVcsDriver.getReviewDiffPreview.stat")(function* (2625 const readStats = Effect.fn("GitVcsDriver.getReviewDiffPreview.stat")(function* (
...@@ -2469,12 +2635,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2469,12 +2635,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2469 );2635 );
2470 if (result.exitCode === 0) return { ref, files: parseReviewNumstat(result.stdout) };2636 if (result.exitCode === 0) return { ref, files: parseReviewNumstat(result.stdout) };
2471 if (ref === "HEAD" && isUnbornHeadStderr(result.stderr)) {2637 if (ref === "HEAD" && isUnbornHeadStderr(result.stderr)) {
2472 const emptyTree = (yield* runGitStdout("GitVcsDriver.getReviewDiffPreview.emptyTree", cwd, [2638 const emptyTree = yield* readEmptyTreeHash(cwd);
2473 "hash-object",
2474 "-t",
2475 "tree",
2476 (yield* HostProcessPlatform) === "win32" ? "NUL" : "/dev/null",
2477 ])).trim();
2478 const stdout = yield* runGitStdoutWithOptions(2639 const stdout = yield* runGitStdoutWithOptions(
2479 "GitVcsDriver.getReviewDiffPreview.unbornStat",2640 "GitVcsDriver.getReviewDiffPreview.unbornStat",
2480 cwd,2641 cwd,
...@@ -2491,6 +2652,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2491,6 +2652,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2491 exitCode: result.exitCode,2652 exitCode: result.exitCode,
2492 });2653 });
2493 });2654 });
2655 // One commit argument diffs that commit against the working tree.
2494 const readTrackedDiff = Effect.fn("GitVcsDriver.getReviewDiffPreview.tracked")(function* (2656 const readTrackedDiff = Effect.fn("GitVcsDriver.getReviewDiffPreview.tracked")(function* (
2495 ref: string | null,2657 ref: string | null,
2496 env?: NodeJS.ProcessEnv,2658 env?: NodeJS.ProcessEnv,
...@@ -2506,54 +2668,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2506,54 +2668,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2506 );2668 );
2507 return { ...patch, files: stat.files };2669 return { ...patch, files: stat.files };
2508 });2670 });
2509 const readDirty = Effect.gen(function* () {2671 const [dirtyTrackedResult, baseResult] = yield* Effect.gen(function* () {
2510 if (input.file?.sourceKind === "branch-range") return yield* readTrackedDiff(null);2672 const untracked = yield* prepareUntrackedReviewIndex(cwd, pathArgs, input.file?.path);
2511 const untracked = yield* executeGit(2673 // With no base both sources diff HEAD, so read it once.
2512 "GitVcsDriver.review.listUntracked",2674 const [dirty, base] =
2513 cwd,2675 review.mergeBase === dirtyRef
2514 ["ls-files", "--others", "--exclude-standard", "-z", "--", ...pathArgs],2676 ? yield* readTrackedDiff(dirtyRef, untracked?.env).pipe(
2515 { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES },2677 Effect.map((result) => [result, result] as const),
2516 ).pipe(2678 )
2517 Effect.catchIf(2679 : yield* Effect.all(
2518 (error) => error.outputLength === undefined,2680 [
2519 () => Effect.succeed(null),2681 readTrackedDiff(dirtyRef, untracked?.env),
2520 ),2682 readTrackedDiff(review.mergeBase, untracked?.env),
2521 );2683 ],
2522 if (untracked === null) {2684 { concurrency: 2 },
2523 const tracked = yield* readTrackedDiff("HEAD");2685 );
2524 return { ...tracked, files: undefined, stdoutTruncated: true };2686 if (untracked !== null) return [dirty, base] as const;
2525 }2687 // Too many untracked files to list: show tracked changes and mark totals incomplete.
2526 const paths = splitNullSeparatedGitStdoutPaths(untracked).filter(2688 const incomplete = (ref: string | null, result: typeof dirty) =>
2527 (candidate) => !input.file || candidate === input.file.path,2689 ref === null ? result : { ...result, files: undefined, stdoutTruncated: true };
2528 );2690 return [incomplete(dirtyRef, dirty), incomplete(review.mergeBase, base)] as const;
2529 if (paths.length === 0) return yield* readTrackedDiff("HEAD");
2530 const env = yield* prepareReviewIndex(cwd, paths).pipe(
2531 Effect.catchTags({
2532 PlatformError: (cause) =>
2533 Effect.fail(
2534 new GitCommandError({
2535 operation: "GitVcsDriver.prepareReviewIndex",
2536 cwd,
2537 command: "git diff",
2538 detail: "Could not prepare the review index.",
2539 cause,
2540 }),
2541 ),
2542 }),
2543 );
2544 return yield* readTrackedDiff("HEAD", env);
2545 }).pipe(Effect.scoped);2691 }).pipe(Effect.scoped);
2546 const [dirtyTrackedResult, baseResult] = yield* Effect.all(
2547 [
2548 readDirty,
2549 readTrackedDiff(
2550 baseRef && branch && input.file?.sourceKind !== "working-tree"
2551 ? `${baseRef}...HEAD`
2552 : null,
2553 ),
2554 ],
2555 { concurrency: 2 },
2556 );
2557 const dirtyFiles = dirtyTrackedResult.files;2692 const dirtyFiles = dirtyTrackedResult.files;
2558 const baseFiles = baseResult.files;2693 const baseFiles = baseResult.files;
2559 const dirtyDiff = dirtyTrackedResult.stdout;2694 const dirtyDiff = dirtyTrackedResult.stdout;
...@@ -2574,14 +2709,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2574,14 +2709,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2574 );2709 );
2575 const [dirtyDiffHash, baseDiffHash] = yield* Effect.all([2710 const [dirtyDiffHash, baseDiffHash] = yield* Effect.all([
2576 hashDiff(dirtyDiff, dirtyFiles ?? []),2711 hashDiff(dirtyDiff, dirtyFiles ?? []),
2577 hashDiff(baseDiff, baseFiles),2712 hashDiff(baseDiff, baseFiles ?? []),
2578 ]);2713 ]);
25792714
2580 const sources: ReviewDiffPreviewSource[] = [2715 const sources: ReviewDiffPreviewSource[] = [
2581 {2716 {
2582 id: "working-tree",2717 id: "working-tree",
2583 kind: "working-tree",2718 kind: "working-tree",
2584 title: "Dirty worktree",2719 title: "Uncommitted",
2585 baseRef: "HEAD",2720 baseRef: "HEAD",
2586 headRef: null,2721 headRef: null,
2587 diff: dirtyDiff,2722 diff: dirtyDiff,
...@@ -2592,11 +2727,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2592,11 +2727,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2592 {2727 {
2593 id: "branch-range",2728 id: "branch-range",
2594 kind: "branch-range",2729 kind: "branch-range",
2595 title: baseRef ? `Against ${baseRef}` : "Against base branch",2730 title: review.baseRef ? `Changes vs ${review.baseRef}` : "Changes",
2596 baseRef,2731 baseRef: review.baseRef,
2597 headRef: branch ?? "HEAD",2732 // For display only. The new side is the working tree.
2733 headRef: repository.currentBranch ?? "HEAD",
2598 diff: baseDiff,2734 diff: baseDiff,
2599 files: baseFiles,2735 ...(baseFiles === undefined ? {} : { files: baseFiles }),
2600 diffHash: baseDiffHash,2736 diffHash: baseDiffHash,
2601 truncated: baseResult.stdoutTruncated,2737 truncated: baseResult.stdoutTruncated,
2602 },2738 },
...@@ -2712,54 +2848,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2712,54 +2848,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2712 return new TextDecoder("utf-8").decode(bytes);2848 return new TextDecoder("utf-8").decode(bytes);
2713 });2849 });
27142850
2851 // Both views compare a commit with the working tree: HEAD for Uncommitted,
2852 // merge-base(base, HEAD) for Changes. The new side always comes from disk.
2715 const getReviewDiffFileContents = Effect.fn("getReviewDiffFileContents")(function* (2853 const getReviewDiffFileContents = Effect.fn("getReviewDiffFileContents")(function* (
2716 input: ReviewDiffFileContentsInput,2854 input: ReviewDiffFileContentsInput,
2717 ) {2855 ) {
2718 if (input.sourceKind === "working-tree") {2856 const repositoryRoot = yield* runGitStdout(
2719 const repositoryRoot = yield* runGitStdout(2857 "GitVcsDriver.getReviewDiffFileContents.repositoryRoot",
2720 "GitVcsDriver.getReviewDiffFileContents.repositoryRoot",
2721 input.cwd,
2722 ["rev-parse", "--show-toplevel"],
2723 ).pipe(Effect.map((value) => value.trim()));
2724 if (repositoryRoot.length === 0) {
2725 return yield* reviewDiffFileError(input, "Could not resolve the Git repository root.");
2726 }
2727 const [oldContents, newContents] = yield* Effect.all(
2728 [
2729 input.changeType === "new"
2730 ? Effect.succeed("")
2731 : readReviewFileAtRevision(input, input.baseRef ?? "HEAD", input.oldPath),
2732 input.changeType === "deleted"
2733 ? Effect.succeed("")
2734 : readWorkingTreeReviewFile(input, repositoryRoot),
2735 ],
2736 { concurrency: 2 },
2737 );
2738 return { oldContents, newContents };
2739 }
2740
2741 if (!input.baseRef || !input.headRef) {
2742 return yield* reviewDiffFileError(
2743 input,
2744 "Branch diff file expansion requires both base and head refs.",
2745 );
2746 }
2747 const mergeBase = yield* runGitStdout(
2748 "GitVcsDriver.getReviewDiffFileContents.mergeBase",
2749 input.cwd,2858 input.cwd,
2750 ["merge-base", input.baseRef, input.headRef],2859 ["rev-parse", "--show-toplevel"],
2751 ).pipe(Effect.map((value) => value.trim()));2860 ).pipe(Effect.map((value) => value.trim()));
2752 if (mergeBase.length === 0) {2861 if (repositoryRoot.length === 0) {
2753 return yield* reviewDiffFileError(input, "Could not resolve the branch comparison base.");2862 return yield* reviewDiffFileError(input, "Could not resolve the Git repository root.");
2754 }2863 }
2864 const oldRevision =
2865 input.sourceKind === "working-tree"
2866 ? (input.baseRef ?? "HEAD")
2867 : (yield* resolveReviewMergeBase(input.cwd, null, input.baseRef ?? undefined)).mergeBase;
2755 const [oldContents, newContents] = yield* Effect.all(2868 const [oldContents, newContents] = yield* Effect.all(
2756 [2869 [
2757 input.changeType === "new"2870 input.changeType === "new"
2758 ? Effect.succeed("")2871 ? Effect.succeed("")
2759 : readReviewFileAtRevision(input, mergeBase, input.oldPath),2872 : readReviewFileAtRevision(input, oldRevision, input.oldPath),
2760 input.changeType === "deleted"2873 input.changeType === "deleted"
2761 ? Effect.succeed("")2874 ? Effect.succeed("")
2762 : readReviewFileAtRevision(input, input.headRef, input.newPath),2875 : readWorkingTreeReviewFile(input, repositoryRoot),
2763 ],2876 ],
2764 { concurrency: 2 },2877 { concurrency: 2 },
2765 );2878 );
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+95-5
...@@ -78,6 +78,8 @@ function makeTestLayer(state: {...@@ -78,6 +78,8 @@ function makeTestLayer(state: {
78 remoteInvalidationCalls: number;78 remoteInvalidationCalls: number;
79 remoteStatusRefreshUpstreamValues?: Array<boolean | undefined>;79 remoteStatusRefreshUpstreamValues?: Array<boolean | undefined>;
80 backgroundWorkEnabled?: boolean;80 backgroundWorkEnabled?: boolean;
81 /** Runs before each remote status read, e.g. to hold a fetch open. */
82 beforeRemoteStatus?: Effect.Effect<void>;
81}) {83}) {
82 return VcsStatusBroadcaster.layer.pipe(84 return VcsStatusBroadcaster.layer.pipe(
83 Layer.provideMerge(NodeServices.layer),85 Layer.provideMerge(NodeServices.layer),
...@@ -90,11 +92,15 @@ function makeTestLayer(state: {...@@ -90,11 +92,15 @@ function makeTestLayer(state: {
90 return state.currentLocalStatus;92 return state.currentLocalStatus;
91 }),93 }),
92 remoteStatus: (_input, options) =>94 remoteStatus: (_input, options) =>
93 Effect.sync(() => {95 Effect.suspend(() => state.beforeRemoteStatus ?? Effect.void).pipe(
94 state.remoteStatusCalls += 1;96 Effect.andThen(
95 state.remoteStatusRefreshUpstreamValues?.push(options?.refreshUpstream);97 Effect.sync(() => {
96 return state.currentRemoteStatus;98 state.remoteStatusCalls += 1;
97 }),99 state.remoteStatusRefreshUpstreamValues?.push(options?.refreshUpstream);
100 return state.currentRemoteStatus;
101 }),
102 ),
103 ),
98 invalidateLocalStatus: () =>104 invalidateLocalStatus: () =>
99 Effect.sync(() => {105 Effect.sync(() => {
100 state.localInvalidationCalls += 1;106 state.localInvalidationCalls += 1;
...@@ -847,6 +853,90 @@ describe("VcsStatusBroadcaster", () => {...@@ -847,6 +853,90 @@ describe("VcsStatusBroadcaster", () => {
847 }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer())));853 }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer())));
848 });854 });
849855
856 // A push from a terminal moves ahead; a PR merged on the host moves ahead-of-default.
857 it.effect.each([
858 ["a push", { ...baseRemoteStatus, aheadCount: 1, aheadOfDefaultCount: 0 }],
859 ["a merged pull request", { ...baseRemoteStatus, aheadOfDefaultCount: 2 }],
860 ] as const)("re-reads local status when a fetch reflects %s", ([, initialRemote]) => {
861 const state = {
862 currentLocalStatus: baseLocalStatus,
863 currentRemoteStatus: initialRemote as VcsStatusRemoteResult | null,
864 localStatusCalls: 0,
865 remoteStatusCalls: 0,
866 localInvalidationCalls: 0,
867 remoteInvalidationCalls: 0,
868 };
869
870 return Effect.gen(function* () {
871 const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
872 yield* broadcaster.getStatus({ cwd: "/repo" });
873 const scope = yield* Scope.make();
874 const snapshotDeferred = yield* Deferred.make<VcsStatusStreamEvent>();
875 const localUpdatedDeferred = yield* Deferred.make<VcsStatusStreamEvent>();
876 yield* Stream.runForEach(
877 broadcaster.streamStatus(
878 { cwd: "/repo" },
879 { automaticRemoteRefreshInterval: Effect.succeed(Duration.minutes(1)) },
880 ),
881 (event) =>
882 event._tag === "snapshot"
883 ? Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore)
884 : event._tag === "localUpdated"
885 ? Deferred.succeed(localUpdatedDeferred, event).pipe(Effect.ignore)
886 : Effect.void,
887 ).pipe(Effect.forkIn(scope));
888 yield* Deferred.await(snapshotDeferred);
889
890 // The next fetch moves the base, so the Changes totals shrink.
891 const pushedLocal: VcsStatusLocalResult = {
892 ...baseLocalStatus,
893 branchChanges: { baseRef: "origin/main", insertions: 0, deletions: 0 },
894 };
895 state.currentLocalStatus = pushedLocal;
896 state.currentRemoteStatus = { ...baseRemoteStatus, aheadOfDefaultCount: 0 };
897 yield* TestClock.adjust(Duration.minutes(1));
898
899 assert.deepStrictEqual(yield* Deferred.await(localUpdatedDeferred), {
900 _tag: "localUpdated",
901 local: pushedLocal,
902 } satisfies VcsStatusStreamEvent);
903 yield* Scope.close(scope, Exit.void);
904 }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer())));
905 });
906
907 it.effect("an explicit refresh reads local totals after the fetch", () => {
908 const state: Parameters<typeof makeTestLayer>[0] = {
909 currentLocalStatus: baseLocalStatus,
910 currentRemoteStatus: baseRemoteStatus,
911 localStatusCalls: 0,
912 remoteStatusCalls: 0,
913 localInvalidationCalls: 0,
914 remoteInvalidationCalls: 0,
915 };
916
917 return Effect.gen(function* () {
918 const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
919 const fetchStarted = yield* Deferred.make<void>();
920 const finishFetch = yield* Deferred.make<void>();
921 state.beforeRemoteStatus = Deferred.succeed(fetchStarted, undefined).pipe(
922 Effect.andThen(Deferred.await(finishFetch)),
923 );
924 const refresh = yield* broadcaster.refreshStatus("/repo").pipe(Effect.forkScoped);
925 yield* Deferred.await(fetchStarted);
926
927 // The fetch moves the base while it runs, so totals read before it ends would be stale.
928 const fetchedLocal: VcsStatusLocalResult = {
929 ...baseLocalStatus,
930 branchChanges: { baseRef: "origin/main", insertions: 0, deletions: 0 },
931 };
932 state.currentLocalStatus = fetchedLocal;
933 yield* Deferred.succeed(finishFetch, undefined);
934
935 const status = yield* Fiber.join(refresh);
936 assert.deepStrictEqual(status.branchChanges, fetchedLocal.branchChanges);
937 }).pipe(Effect.provide(makeTestLayer(state)));
938 });
939
850 it("backs off remote refresh failures exponentially and honors larger configured intervals", () => {940 it("backs off remote refresh failures exponentially and honors larger configured intervals", () => {
851 assert.equal(941 assert.equal(
852 Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(1, Duration.seconds(1))),942 Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(1, Duration.seconds(1))),
apps/server/src/vcs/VcsStatusBroadcaster.ts+16-4
...@@ -462,9 +462,22 @@ export const make = Effect.gen(function* () {...@@ -462,9 +462,22 @@ export const make = Effect.gen(function* () {
462 if (options?.refreshUpstream !== false) {462 if (options?.refreshUpstream !== false) {
463 yield* workflow.invalidateRemoteStatus(cwd);463 yield* workflow.invalidateRemoteStatus(cwd);
464 }464 }
465 const previousRemote = (yield* getCachedStatus(cwd))?.remote?.value;
465 const remote = yield* workflow.remoteStatus({ cwd }, options);466 const remote = yield* workflow.remoteStatus({ cwd }, options);
466 const pulled = yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]);467 const pulled = yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]);
467 if (pulled !== null) return pulled.remote;468 if (pulled !== null) return pulled.remote;
469 // Local status holds the Changes totals, which compare against remote refs. A fetch can
470 // move them with no local trigger (a push from a terminal, a PR merged on the host), so
471 // re-read local status on the first fetch and whenever divergence moves.
472 if (
473 remote &&
474 (!previousRemote ||
475 previousRemote.aheadCount !== remote.aheadCount ||
476 previousRemote.behindCount !== remote.behindCount ||
477 previousRemote.aheadOfDefaultCount !== remote.aheadOfDefaultCount)
478 ) {
479 yield* refreshLocalStatusCore(cwd);
480 }
468 return yield* updateCachedRemoteStatus(cwd, remote, { publish: true });481 return yield* updateCachedRemoteStatus(cwd, remote, { publish: true });
469 }),482 }),
470 );483 );
...@@ -480,10 +493,9 @@ export const make = Effect.gen(function* () {...@@ -480,10 +493,9 @@ export const make = Effect.gen(function* () {
480 cwd,493 cwd,
481 Effect.gen(function* () {494 Effect.gen(function* () {
482 yield* workflow.invalidateStatus(cwd);495 yield* workflow.invalidateStatus(cwd);
483 const [local, remote] = yield* Effect.all(496 // Local after remote: the fetch can move the base that the Changes totals compare with.
484 [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })],497 const remote = yield* workflow.remoteStatus({ cwd });
485 { concurrency: "unbounded" },498 const local = yield* workflow.localStatus({ cwd });
486 );
487 const pulled = yield* maybeAutoPull(cwd, remote, [rawCwd]);499 const pulled = yield* maybeAutoPull(cwd, remote, [rawCwd]);
488 if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote);500 if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote);
489 return yield* updateCachedStatus(cwd, local, remote, { publish: true });501 return yield* updateCachedStatus(cwd, local, remote, { publish: true });
apps/web/src/components/ChatView.tsx+4-4
...@@ -2222,10 +2222,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -2222,10 +2222,10 @@ export default function ChatView(props: ChatViewProps) {
2222 useLayoutEffect(() => {2222 useLayoutEffect(() => {
2223 const explicitThreadRef = explicitDiffOpenRef.current;2223 const explicitThreadRef = explicitDiffOpenRef.current;
2224 explicitDiffOpenRef.current = null;2224 explicitDiffOpenRef.current = null;
2225 // Generic openings always show the checkout, including tab fallbacks and thread changes.2225 // Generic openings always show Changes, including tab fallbacks and thread changes.
2226 // A timeline click instead opens the specific turn/file the user requested.2226 // A timeline click instead opens the specific turn/file the user requested.
2227 if (diffOpen && activeThreadRef && explicitThreadRef !== activeThreadRef) {2227 if (diffOpen && activeThreadRef && explicitThreadRef !== activeThreadRef) {
2228 useDiffPanelStore.getState().selectGitScope(activeThreadRef, "unstaged");2228 useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch");
2229 }2229 }
2230 }, [activeThreadRef, diffOpen]);2230 }, [activeThreadRef, diffOpen]);
2231 const rightPanelState = useRightPanelStore((state) =>2231 const rightPanelState = useRightPanelStore((state) =>
...@@ -5023,7 +5023,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -5023,7 +5023,7 @@ export default function ChatView(props: ChatViewProps) {
5023 );5023 );
5024 const addDiffSurface = useCallback(() => {5024 const addDiffSurface = useCallback(() => {
5025 if (!activeThreadRef || !isServerThread || !isGitRepo) return;5025 if (!activeThreadRef || !isServerThread || !isGitRepo) return;
5026 useDiffPanelStore.getState().selectGitScope(activeThreadRef, "unstaged");5026 useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch");
5027 useRightPanelStore.getState().open(activeThreadRef, "diff");5027 useRightPanelStore.getState().open(activeThreadRef, "diff");
5028 onDiffPanelOpen?.();5028 onDiffPanelOpen?.();
5029 }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]);5029 }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]);
...@@ -5378,7 +5378,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -5378,7 +5378,7 @@ export default function ChatView(props: ChatViewProps) {
5378 if (!panels.openProactive(activeThreadRef, { id: "diff", kind: "diff" }, userActionRevision)) {5378 if (!panels.openProactive(activeThreadRef, { id: "diff", kind: "diff" }, userActionRevision)) {
5379 return;5379 return;
5380 }5380 }
5381 useDiffPanelStore.getState().selectGitScope(activeThreadRef, "unstaged");5381 useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch");
5382 onDiffPanelOpen?.();5382 onDiffPanelOpen?.();
5383 }, [5383 }, [
5384 turnDiffSummaries,5384 turnDiffSummaries,
apps/web/src/components/DiffPanel.tsx+10-10
...@@ -233,8 +233,8 @@ export default function DiffPanel({...@@ -233,8 +233,8 @@ export default function DiffPanel({
233 const selectedScopeLabel =233 const selectedScopeLabel =
234 selectedRunId === null234 selectedRunId === null
235 ? selectedGitScope === "unstaged"235 ? selectedGitScope === "unstaged"
236 ? "Working tree"236 ? "Uncommitted"
237 : "Branch changes"237 : "Changes"
238 : selectedTurn?.runId === latestTurn?.runId238 : selectedTurn?.runId === latestTurn?.runId
239 ? "Latest turn"239 ? "Latest turn"
240 : `Turn ${selectedCheckpointTurnCount ?? "?"}`;240 : `Turn ${selectedCheckpointTurnCount ?? "?"}`;
...@@ -246,8 +246,8 @@ export default function DiffPanel({...@@ -246,8 +246,8 @@ export default function DiffPanel({
246 const reviewSectionTitle = selectedTurn246 const reviewSectionTitle = selectedTurn
247 ? `Turn ${selectedCheckpointTurnCount ?? "?"}`247 ? `Turn ${selectedCheckpointTurnCount ?? "?"}`
248 : selectedGitScope === "unstaged"248 : selectedGitScope === "unstaged"
249 ? "Working tree"249 ? "Uncommitted"
250 : "Branch changes";250 : "Changes";
251 const selectedCheckpointRange = useMemo(251 const selectedCheckpointRange = useMemo(
252 () =>252 () =>
253 typeof selectedCheckpointTurnCount === "number"253 typeof selectedCheckpointTurnCount === "number"
...@@ -676,11 +676,11 @@ export default function DiffPanel({...@@ -676,11 +676,11 @@ export default function DiffPanel({
676 </DropdownMenuTrigger>676 </DropdownMenuTrigger>
677 <DropdownMenuContent align="start">677 <DropdownMenuContent align="start">
678 <DropdownMenuRadioGroup value={selectedScopeValue} onValueChange={selectScopeValue}>678 <DropdownMenuRadioGroup value={selectedScopeValue} onValueChange={selectScopeValue}>
679 <DropdownMenuRadioItem value="unstaged" closeOnClick>
680 <span>Working tree</span>
681 </DropdownMenuRadioItem>
682 <DropdownMenuRadioItem value="branch" closeOnClick>679 <DropdownMenuRadioItem value="branch" closeOnClick>
683 <span>Branch changes</span>680 <span>Changes</span>
681 </DropdownMenuRadioItem>
682 <DropdownMenuRadioItem value="unstaged" closeOnClick>
683 <span>Uncommitted</span>
684 </DropdownMenuRadioItem>684 </DropdownMenuRadioItem>
685 <DropdownMenuRadioItem value="latest" closeOnClick>685 <DropdownMenuRadioItem value="latest" closeOnClick>
686 <span>Latest turn</span>686 <span>Latest turn</span>
...@@ -1004,8 +1004,8 @@ export default function DiffPanel({...@@ -1004,8 +1004,8 @@ export default function DiffPanel({
1004 selectedTurn1004 selectedTurn
1005 ? "Loading checkpoint diff..."1005 ? "Loading checkpoint diff..."
1006 : selectedGitScope === "unstaged"1006 : selectedGitScope === "unstaged"
1007 ? "Loading working tree diff..."1007 ? "Loading uncommitted changes..."
1008 : "Loading branch diff..."1008 : "Loading changes..."
1009 }1009 }
1010 />1010 />
1011 ) : (1011 ) : (
apps/web/src/components/GitActionsControl.tsx+4-6
...@@ -1192,6 +1192,8 @@ export default function GitActionsControl({...@@ -1192,6 +1192,8 @@ export default function GitActionsControl({
1192 const isRepo = gitStatus?.isRepo ?? true;1192 const isRepo = gitStatus?.isRepo ?? true;
1193 const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false;1193 const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false;
1194 const gitStatusForActions = gitStatus;1194 const gitStatusForActions = gitStatus;
1195 // Matches the diff panel's Changes view. Older servers only report uncommitted totals.
1196 const changesTotals = gitStatusForActions?.branchChanges ?? gitStatusForActions?.workingTree;
11951197
1196 const allFiles = gitStatusForActions?.workingTree.files ?? [];1198 const allFiles = gitStatusForActions?.workingTree.files ?? [];
1197 const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path));1199 const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path));
...@@ -1936,12 +1938,8 @@ export default function GitActionsControl({...@@ -1936,12 +1938,8 @@ export default function GitActionsControl({
1936 <FileDiffIcon className={THREAD_DETAILS_PANEL_ICON_CLASS} aria-hidden />1938 <FileDiffIcon className={THREAD_DETAILS_PANEL_ICON_CLASS} aria-hidden />
1937 <span className="flex-1 text-left">Changes</span>1939 <span className="flex-1 text-left">Changes</span>
1938 <span className="flex items-center gap-1 font-mono text-2xs tabular-nums">1940 <span className="flex items-center gap-1 font-mono text-2xs tabular-nums">
1939 <span className="text-success">1941 <span className="text-success">+{changesTotals?.insertions ?? 0}</span>
1940 +{gitStatusForActions?.workingTree.insertions ?? 0}1942 <span className="text-destructive">-{changesTotals?.deletions ?? 0}</span>
1941 </span>
1942 <span className="text-destructive">
1943 -{gitStatusForActions?.workingTree.deletions ?? 0}
1944 </span>
1945 </span>1943 </span>
1946 </ThreadDetailsControl>1944 </ThreadDetailsControl>
1947 ) : null}1945 ) : null}
apps/web/src/components/settings/SettingsPanels.tsx+1-1
...@@ -2673,7 +2673,7 @@ export function GeneralSettingsPanel() {...@@ -2673,7 +2673,7 @@ export function GeneralSettingsPanel() {
26732673
2674 <SettingsRow2674 <SettingsRow
2675 {...searchableSetting("proactive-panels")}2675 {...searchableSetting("proactive-panels")}
2676 description="Open linked pull requests first. Otherwise, open the working tree diff for changes to at least 3 files or 50 lines."2676 description="Open linked pull requests first. Otherwise, open Changes for edits to at least 3 files or 50 lines."
2677 resetAction={2677 resetAction={
2678 settings.proactivePanelsEnabled !== DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled ? (2678 settings.proactivePanelsEnabled !== DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled ? (
2679 <SettingResetButton2679 <SettingResetButton
apps/web/src/diffPanelStore.test.ts+17-4
...@@ -14,18 +14,31 @@ describe("diffPanelStore", () => {...@@ -14,18 +14,31 @@ describe("diffPanelStore", () => {
14 }),14 }),
15 );15 );
1616
17 it("defaults each thread to working tree changes without requiring git status", () => {17 it("defaults each thread to Changes without requiring git status", () => {
18 expect(18 expect(
19 selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF),19 selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF),
20 ).toEqual({ kind: "unstaged" });20 ).toEqual({ kind: "branch", baseRef: null });
21 });21 });
2222
23 it("defaults to working tree changes before a thread is selected", () => {23 it("defaults to Changes before a thread is selected", () => {
24 expect(selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, null)).toEqual({24 expect(selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, null)).toEqual({
25 kind: "unstaged",25 kind: "branch",
26 baseRef: null,
26 });27 });
27 });28 });
2829
30 it("keeps a custom base when a generic open selects Changes again", () => {
31 const store = useDiffPanelStore.getState();
32 store.selectBranchBaseRef(THREAD_REF, "origin/release");
33 store.selectGitScope(THREAD_REF, "branch");
34 store.selectTurn(THREAD_REF, RunId.make("turn-1"));
35 store.selectGitScope(THREAD_REF, "branch");
36
37 expect(
38 selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF),
39 ).toEqual({ kind: "branch", baseRef: "origin/release" });
40 });
41
29 it("preserves an explicit branch selection", () => {42 it("preserves an explicit branch selection", () => {
30 useDiffPanelStore.getState().selectGitScope(THREAD_REF, "branch");43 useDiffPanelStore.getState().selectGitScope(THREAD_REF, "branch");
3144
apps/web/src/diffPanelStore.ts+2-1
...@@ -10,7 +10,8 @@ export type DiffPanelSelection =...@@ -10,7 +10,8 @@ export type DiffPanelSelection =
10 | { kind: "unstaged" }10 | { kind: "unstaged" }
11 | { kind: "turn"; turnId: RunId; filePath: string | null; revealRequestId: number };11 | { kind: "turn"; turnId: RunId; filePath: string | null; revealRequestId: number };
1212
13const DEFAULT_SELECTION: DiffPanelSelection = { kind: "unstaged" };13// "branch" is the Changes view: everything this checkout changed since its base.
14const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null };
1415
15interface DiffPanelStoreState {16interface DiffPanelStoreState {
16 byThreadKey: Record<string, DiffPanelSelection>;17 byThreadKey: Record<string, DiffPanelSelection>;
packages/contracts/src/git.ts+11
...@@ -235,6 +235,17 @@ const VcsStatusLocalShape = {...@@ -235,6 +235,17 @@ const VcsStatusLocalShape = {
235 insertions: NonNegativeInt,235 insertions: NonNegativeInt,
236 deletions: NonNegativeInt,236 deletions: NonNegativeInt,
237 }),237 }),
238 /**
239 * Totals for the diff panel's Changes view: merge-base with the base branch to the
240 * working tree, untracked files included. Absent on older servers.
241 */
242 branchChanges: Schema.optional(
243 Schema.Struct({
244 baseRef: Schema.NullOr(TrimmedNonEmptyStringSchema),
245 insertions: NonNegativeInt,
246 deletions: NonNegativeInt,
247 }),
248 ),
238};249};
239250
240const VcsStatusRemoteShape = {251const VcsStatusRemoteShape = {
packages/shared/src/git.ts+1
...@@ -373,6 +373,7 @@ function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult {...@@ -373,6 +373,7 @@ function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult {
373 refName: status.refName,373 refName: status.refName,
374 hasWorkingTreeChanges: status.hasWorkingTreeChanges,374 hasWorkingTreeChanges: status.hasWorkingTreeChanges,
375 workingTree: status.workingTree,375 workingTree: status.workingTree,
376 ...(status.branchChanges ? { branchChanges: status.branchChanges } : {}),
376 };377 };
377}378}
378379