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(
126126 id: "sections",
127127 inline: true,
128128 items: [
129 sectionAction(props.sectionMenu.workingTree, "Working tree"),
130 sectionAction(props.sectionMenu.branchChanges, "Branch changes"),
129 sectionAction(props.sectionMenu.branchChanges, "Changes"),
130 sectionAction(props.sectionMenu.workingTree, "Uncommitted"),
131131 sectionAction(props.sectionMenu.latestTurn, "Latest turn"),
132132 ],
133133 },
apps/mobile/src/features/review/reviewModel.test.ts+26-9
......@@ -62,7 +62,7 @@ describe("buildReviewSectionItems", () => {
6262 {
6363 id: "working-tree",
6464 kind: "working-tree",
65 title: "Dirty worktree",
65 title: "Uncommitted",
6666 baseRef: "HEAD",
6767 headRef: null,
6868 diff: "diff --git a/a.ts b/a.ts",
......@@ -72,7 +72,7 @@ describe("buildReviewSectionItems", () => {
7272 {
7373 id: "branch-range",
7474 kind: "branch-range",
75 title: "Against main",
75 title: "Changes vs main",
7676 baseRef: "main",
7777 headRef: "feature",
7878 diff: "diff --git a/a.ts b/a.ts",
......@@ -105,10 +105,28 @@ describe("buildReviewSectionItems", () => {
105105 isLoading: false,
106106 diff: expect.stringContaining("loaded.ts"),
107107 });
108 expect(getDefaultReviewSectionId(items)).toBe("turn:2");
108 expect(getDefaultReviewSectionId(items)).toBe("git:branch-range");
109109 });
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", () => {
112130 const items = buildReviewSectionItems({
113131 checkpoints: [],
114132 gitSections: [],
......@@ -119,15 +137,14 @@ describe("buildReviewSectionItems", () => {
119137
120138 expect(items).toEqual([
121139 expect.objectContaining({
122 id: "git:working-tree",
123 kind: "working-tree",
124 title: "Dirty worktree",
125 subtitle: "Tracked, staged, and untracked worktree changes",
140 id: "git:branch-range",
141 kind: "branch-range",
142 title: "Changes",
126143 diff: null,
127144 isLoading: true,
128145 }),
129146 ]);
130 expect(getDefaultReviewSectionId(items)).toBe("git:working-tree");
147 expect(getDefaultReviewSectionId(items)).toBe("git:branch-range");
131148 });
132149});
133150
apps/mobile/src/features/review/reviewModel.ts+13-11
......@@ -9,9 +9,9 @@ import * as Order from "effect/Order";
99
1010export type ReviewSectionKind = "turn" | "working-tree" | "branch-range";
1111
12const DIRTY_WORKTREE_SECTION_ID = "git:working-tree";
13const DIRTY_WORKTREE_TITLE = "Dirty worktree";
14const DIRTY_WORKTREE_SUBTITLE = "Tracked, staged, and untracked worktree changes";
12const CHANGES_SECTION_ID = "git:branch-range";
13const CHANGES_TITLE = "Changes";
14const UNCOMMITTED_SUBTITLE = "Staged, unstaged, and untracked files";
1515
1616export interface ReviewSectionItem {
1717 readonly id: string;
......@@ -124,7 +124,7 @@ const readyCheckpointOrder = Order.make<ThreadCheckpointSummary>(
124124
125125function gitSubtitle(section: ReviewDiffPreviewSource): string | null {
126126 if (section.kind === "working-tree") {
127 return DIRTY_WORKTREE_SUBTITLE;
127 return UNCOMMITTED_SUBTITLE;
128128 }
129129 if (section.baseRef) {
130130 return `${section.baseRef} ... ${section.headRef ?? "HEAD"}`;
......@@ -443,15 +443,16 @@ export function buildReviewSectionItems(input: {
443443 truncated: section.truncated,
444444 isLoading: false,
445445 }));
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);
447448 const visibleGitItems =
448 input.loadingGitSections && !hasDirtyWorktreeItem
449 input.loadingGitSections && !hasChangesItem
449450 ? [
450451 {
451 id: DIRTY_WORKTREE_SECTION_ID,
452 kind: "working-tree",
453 title: DIRTY_WORKTREE_TITLE,
454 subtitle: DIRTY_WORKTREE_SUBTITLE,
452 id: CHANGES_SECTION_ID,
453 kind: "branch-range",
454 title: CHANGES_TITLE,
455 subtitle: null,
455456 diff: null,
456457 isLoading: true,
457458 } satisfies ReviewSectionItem,
......@@ -462,10 +463,11 @@ export function buildReviewSectionItems(input: {
462463 return [...turnItems, ...visibleGitItems];
463464}
464465
466/** Prefers Changes, then the first section (a turn when the project is not a git repo). */
465467export function getDefaultReviewSectionId(
466468 sections: ReadonlyArray<ReviewSectionItem>,
467469): string | null {
468 return sections[0]?.id ?? null;
470 return (sections.find((section) => section.id === CHANGES_SECTION_ID) ?? sections[0])?.id ?? null;
469471}
470472
471473export function buildReviewParsedDiff(
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+1-1
......@@ -282,7 +282,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
282282 <SheetListRow
283283 icon="text.bubble"
284284 title="Review changes"
285 subtitle="Inspect turn diffs, worktree changes, and base branch diff"
285 subtitle="Inspect changes, uncommitted edits, and turn diffs"
286286 disabled={busy || !isRepo}
287287 onPress={() => {
288288 const params = { environmentId, threadId };
apps/server/src/git/GitManager.ts+4-3
......@@ -1000,7 +1000,7 @@ export const make = Effect.gen(function* () {
10001000 const canonicalizeExistingPath = (value: string) =>
10011001 fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value));
10021002 const normalizeStatusCacheKey = canonicalizeExistingPath;
1003 const nonRepositoryStatusDetails = {
1003 const nonRepositoryStatusDetails: GitVcsDriver.GitStatusDetails = {
10041004 isRepo: false,
10051005 hasOriginRemote: false,
10061006 isDefaultBranch: false,
......@@ -1012,10 +1012,10 @@ export const make = Effect.gen(function* () {
10121012 aheadCount: 0,
10131013 behindCount: 0,
10141014 aheadOfDefaultCount: 0,
1015 } satisfies GitVcsDriver.GitStatusDetails;
1015 };
10161016 const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) {
10171017 const details = yield* gitCore
1018 .statusDetailsLocal(cwd, { includeDivergence: false })
1018 .statusDetailsLocal(cwd, { includeDivergence: false, includeBranchChanges: true })
10191019 .pipe(
10201020 Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)),
10211021 );
......@@ -1031,6 +1031,7 @@ export const make = Effect.gen(function* () {
10311031 refName: details.branch,
10321032 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
10331033 workingTree: details.workingTree,
1034 ...(details.branchChanges ? { branchChanges: details.branchChanges } : {}),
10341035 } satisfies VcsStatusLocalResult;
10351036 });
10361037 const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, {
apps/server/src/vcs/GitVcsDriver.ts+3
......@@ -76,6 +76,7 @@ export interface GitStatusDetails {
7676 upstreamRef: string | null;
7777 hasWorkingTreeChanges: boolean;
7878 workingTree: VcsStatusResult["workingTree"];
79 branchChanges?: VcsStatusResult["branchChanges"];
7980 hasUpstream: boolean;
8081 aheadCount: number;
8182 behindCount: number;
......@@ -85,6 +86,8 @@ export interface GitStatusDetails {
8586export interface GitLocalStatusOptions {
8687 /** Skip revision walks and return zero divergence counts for local-only consumers. */
8788 readonly includeDivergence?: boolean;
89 /** Also read the diff panel's Changes totals. Failures leave them out. */
90 readonly includeBranchChanges?: boolean;
8891}
8992
9093export interface GitRemoteStatusDetails {
apps/server/src/vcs/GitVcsDriverCore.test.ts+179-6
......@@ -26,6 +26,7 @@ import {
2626 GitCommandError,
2727 ReviewDiffPreviewInput,
2828 type ReviewDiffFileContentsInput,
29 type ReviewDiffPreviewResult,
2930 type WorktreeSubmodules,
3031} from "@t3tools/contracts";
3132import * as ServerConfig from "../config.ts";
......@@ -1128,6 +1129,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
11281129 Effect.gen(function* () {
11291130 const cwd = yield* makeTmpDir();
11301131 const { initialBranch } = yield* initRepoWithCommit(cwd);
1132 const mergeBase = yield* git(cwd, ["rev-parse", "HEAD"]);
11311133 yield* writeTextFile(cwd, "untracked.txt", "untracked content\n");
11321134 const paths = Array.from({ length: 5000 }, (_, index) => `${"a".repeat(220)}-${index}.txt`);
11331135 const stats = paths.map((path) => `1\t0\t${path}\0`).join("");
......@@ -1138,10 +1140,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
11381140 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
11391141 const spawner = ChildProcessSpawner.make((command) => {
11401142 if (ChildProcess.isStandardCommand(command)) {
1141 if (
1142 command.args.includes("--numstat") &&
1143 command.args.includes(`${initialBranch}...HEAD`)
1144 ) {
1143 if (command.args.includes("--numstat") && command.args.includes(mergeBase)) {
11451144 return Effect.succeed(makeSuccessfulHandle(stats));
11461145 }
11471146 if (command.args.includes("ls-files") && command.args.includes("--others")) {
......@@ -1566,6 +1565,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
15661565
15671566 assert.deepStrictEqual(branch.files, [
15681567 { path: "a-large.txt", previousPath: null, additions: 4000, deletions: 0 },
1568 { path: "untracked.txt", previousPath: null, additions: 4000, deletions: 0 },
15691569 { path: "z-last.txt", previousPath: null, additions: 1, deletions: 0 },
15701570 ]);
15711571 assert.deepStrictEqual(dirty.files, [
......@@ -1740,7 +1740,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
17401740 }),
17411741 );
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", () =>
17441744 Effect.gen(function* () {
17451745 const cwd = yield* makeTmpDir();
17461746 const { initialBranch } = yield* initRepoWithCommit(cwd);
......@@ -1749,6 +1749,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
17491749 yield* writeTextFile(cwd, "README.md", "# branch change\nunchanged context\n");
17501750 yield* git(cwd, ["add", "README.md"]);
17511751 yield* git(cwd, ["commit", "-m", "change readme"]);
1752 yield* writeTextFile(cwd, "README.md", "# dirty change\nunchanged context\n");
17521753
17531754 const contents = yield* driver.getReviewDiffFileContents(
17541755 makeReviewDiffFileContentsInput(cwd, {
......@@ -1759,12 +1760,184 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
17591760 );
17601761
17611762 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 }
17631912 }),
17641913 );
17651914 });
17661915
17671916 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
17681941 it.effect("reports non-repository directories without failing", () =>
17691942 Effect.gen(function* () {
17701943 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;
6262// prefixes. A repository or global diff.noprefix or diff.mnemonicPrefix would
6363// otherwise leak into the patch and leave every parsed file unnamed.
6464export 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];
6575const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
6676const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
6777
......@@ -1518,9 +1528,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
15181528 return remoteName;
15191529 });
15201530
1531 // `allowRemoteOfCurrent` lets the review diff compare the default branch with its remote copy.
15211532 const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* (
15221533 cwd: string,
15231534 refName: string,
1535 options?: { readonly allowRemoteOfCurrent?: boolean },
15241536 ) {
15251537 const configuredBaseBranch = yield* runGitStdout(
15261538 "GitVcsDriver.resolveBaseBranchForNoUpstream.config",
......@@ -1552,7 +1564,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
15521564 : remotePrefix && candidate.startsWith(remotePrefix)
15531565 ? candidate.slice(remotePrefix.length)
15541566 : 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 }
15561582 continue;
15571583 }
15581584
......@@ -1923,6 +1949,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
19231949 }
19241950 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
19261958 return {
19271959 isRepo: true,
19281960 hasOriginRemote: hasPrimaryRemote,
......@@ -1935,6 +1967,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
19351967 insertions,
19361968 deletions,
19371969 },
1970 ...(branchChanges ? { branchChanges } : {}),
19381971 hasUpstream: upstreamRef !== null,
19391972 aheadCount,
19401973 behindCount,
......@@ -2413,6 +2446,145 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
24132446 return env;
24142447 });
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
24162588 const getReviewDiffPreview = Effect.fn("getReviewDiffPreview")(function* (
24172589 input: ReviewDiffPreviewInput,
24182590 ) {
......@@ -2439,21 +2611,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
24392611 }
24402612
24412613 const cwd = repository.worktreeRoot;
2442 const branch = repository.currentBranch;
2443 const baseRef =
2444 input.baseRef ??
2445 (branch
2446 ? yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe(Effect.orElseSucceed(() => null))
2447 : null);
2614 // A per-file request only reads its own source.
2615 const dirtyRef = input.file?.sourceKind === "branch-range" ? null : "HEAD";
2616 const review =
2617 input.file?.sourceKind === "working-tree"
2618 ? { baseRef: input.baseRef ?? null, mergeBase: null }
2619 : yield* resolveReviewMergeBase(cwd, repository.currentBranch, input.baseRef);
24482620
24492621 const diffArgs = [
2450 "diff",
2451 "--find-renames",
2452 "--no-color",
2453 "--no-ext-diff",
2454 "--no-textconv",
2455 "--minimal",
2456 ...PATCH_RENDER_PREFIX_ARGS,
2622 ...REVIEW_DIFF_ARGS,
24572623 ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []),
24582624 ];
24592625 const readStats = Effect.fn("GitVcsDriver.getReviewDiffPreview.stat")(function* (
......@@ -2469,12 +2635,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
24692635 );
24702636 if (result.exitCode === 0) return { ref, files: parseReviewNumstat(result.stdout) };
24712637 if (ref === "HEAD" && isUnbornHeadStderr(result.stderr)) {
2472 const emptyTree = (yield* runGitStdout("GitVcsDriver.getReviewDiffPreview.emptyTree", cwd, [
2473 "hash-object",
2474 "-t",
2475 "tree",
2476 (yield* HostProcessPlatform) === "win32" ? "NUL" : "/dev/null",
2477 ])).trim();
2638 const emptyTree = yield* readEmptyTreeHash(cwd);
24782639 const stdout = yield* runGitStdoutWithOptions(
24792640 "GitVcsDriver.getReviewDiffPreview.unbornStat",
24802641 cwd,
......@@ -2491,6 +2652,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
24912652 exitCode: result.exitCode,
24922653 });
24932654 });
2655 // One commit argument diffs that commit against the working tree.
24942656 const readTrackedDiff = Effect.fn("GitVcsDriver.getReviewDiffPreview.tracked")(function* (
24952657 ref: string | null,
24962658 env?: NodeJS.ProcessEnv,
......@@ -2506,54 +2668,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
25062668 );
25072669 return { ...patch, files: stat.files };
25082670 });
2509 const readDirty = Effect.gen(function* () {
2510 if (input.file?.sourceKind === "branch-range") return yield* readTrackedDiff(null);
2511 const untracked = yield* executeGit(
2512 "GitVcsDriver.review.listUntracked",
2513 cwd,
2514 ["ls-files", "--others", "--exclude-standard", "-z", "--", ...pathArgs],
2515 { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES },
2516 ).pipe(
2517 Effect.catchIf(
2518 (error) => error.outputLength === undefined,
2519 () => Effect.succeed(null),
2520 ),
2521 );
2522 if (untracked === null) {
2523 const tracked = yield* readTrackedDiff("HEAD");
2524 return { ...tracked, files: undefined, stdoutTruncated: true };
2525 }
2526 const paths = splitNullSeparatedGitStdoutPaths(untracked).filter(
2527 (candidate) => !input.file || candidate === input.file.path,
2528 );
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);
2671 const [dirtyTrackedResult, baseResult] = yield* Effect.gen(function* () {
2672 const untracked = yield* prepareUntrackedReviewIndex(cwd, pathArgs, input.file?.path);
2673 // With no base both sources diff HEAD, so read it once.
2674 const [dirty, base] =
2675 review.mergeBase === dirtyRef
2676 ? yield* readTrackedDiff(dirtyRef, untracked?.env).pipe(
2677 Effect.map((result) => [result, result] as const),
2678 )
2679 : yield* Effect.all(
2680 [
2681 readTrackedDiff(dirtyRef, untracked?.env),
2682 readTrackedDiff(review.mergeBase, untracked?.env),
2683 ],
2684 { concurrency: 2 },
2685 );
2686 if (untracked !== null) return [dirty, base] as const;
2687 // Too many untracked files to list: show tracked changes and mark totals incomplete.
2688 const incomplete = (ref: string | null, result: typeof dirty) =>
2689 ref === null ? result : { ...result, files: undefined, stdoutTruncated: true };
2690 return [incomplete(dirtyRef, dirty), incomplete(review.mergeBase, base)] as const;
25452691 }).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 );
25572692 const dirtyFiles = dirtyTrackedResult.files;
25582693 const baseFiles = baseResult.files;
25592694 const dirtyDiff = dirtyTrackedResult.stdout;
......@@ -2574,14 +2709,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
25742709 );
25752710 const [dirtyDiffHash, baseDiffHash] = yield* Effect.all([
25762711 hashDiff(dirtyDiff, dirtyFiles ?? []),
2577 hashDiff(baseDiff, baseFiles),
2712 hashDiff(baseDiff, baseFiles ?? []),
25782713 ]);
25792714
25802715 const sources: ReviewDiffPreviewSource[] = [
25812716 {
25822717 id: "working-tree",
25832718 kind: "working-tree",
2584 title: "Dirty worktree",
2719 title: "Uncommitted",
25852720 baseRef: "HEAD",
25862721 headRef: null,
25872722 diff: dirtyDiff,
......@@ -2592,11 +2727,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
25922727 {
25932728 id: "branch-range",
25942729 kind: "branch-range",
2595 title: baseRef ? `Against ${baseRef}` : "Against base branch",
2596 baseRef,
2597 headRef: branch ?? "HEAD",
2730 title: review.baseRef ? `Changes vs ${review.baseRef}` : "Changes",
2731 baseRef: review.baseRef,
2732 // For display only. The new side is the working tree.
2733 headRef: repository.currentBranch ?? "HEAD",
25982734 diff: baseDiff,
2599 files: baseFiles,
2735 ...(baseFiles === undefined ? {} : { files: baseFiles }),
26002736 diffHash: baseDiffHash,
26012737 truncated: baseResult.stdoutTruncated,
26022738 },
......@@ -2712,54 +2848,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
27122848 return new TextDecoder("utf-8").decode(bytes);
27132849 });
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.
27152853 const getReviewDiffFileContents = Effect.fn("getReviewDiffFileContents")(function* (
27162854 input: ReviewDiffFileContentsInput,
27172855 ) {
2718 if (input.sourceKind === "working-tree") {
2719 const repositoryRoot = yield* runGitStdout(
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",
2856 const repositoryRoot = yield* runGitStdout(
2857 "GitVcsDriver.getReviewDiffFileContents.repositoryRoot",
27492858 input.cwd,
2750 ["merge-base", input.baseRef, input.headRef],
2859 ["rev-parse", "--show-toplevel"],
27512860 ).pipe(Effect.map((value) => value.trim()));
2752 if (mergeBase.length === 0) {
2753 return yield* reviewDiffFileError(input, "Could not resolve the branch comparison base.");
2861 if (repositoryRoot.length === 0) {
2862 return yield* reviewDiffFileError(input, "Could not resolve the Git repository root.");
27542863 }
2864 const oldRevision =
2865 input.sourceKind === "working-tree"
2866 ? (input.baseRef ?? "HEAD")
2867 : (yield* resolveReviewMergeBase(input.cwd, null, input.baseRef ?? undefined)).mergeBase;
27552868 const [oldContents, newContents] = yield* Effect.all(
27562869 [
27572870 input.changeType === "new"
27582871 ? Effect.succeed("")
2759 : readReviewFileAtRevision(input, mergeBase, input.oldPath),
2872 : readReviewFileAtRevision(input, oldRevision, input.oldPath),
27602873 input.changeType === "deleted"
27612874 ? Effect.succeed("")
2762 : readReviewFileAtRevision(input, input.headRef, input.newPath),
2875 : readWorkingTreeReviewFile(input, repositoryRoot),
27632876 ],
27642877 { concurrency: 2 },
27652878 );
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+95-5
......@@ -78,6 +78,8 @@ function makeTestLayer(state: {
7878 remoteInvalidationCalls: number;
7979 remoteStatusRefreshUpstreamValues?: Array<boolean | undefined>;
8080 backgroundWorkEnabled?: boolean;
81 /** Runs before each remote status read, e.g. to hold a fetch open. */
82 beforeRemoteStatus?: Effect.Effect<void>;
8183}) {
8284 return VcsStatusBroadcaster.layer.pipe(
8385 Layer.provideMerge(NodeServices.layer),
......@@ -90,11 +92,15 @@ function makeTestLayer(state: {
9092 return state.currentLocalStatus;
9193 }),
9294 remoteStatus: (_input, options) =>
93 Effect.sync(() => {
94 state.remoteStatusCalls += 1;
95 state.remoteStatusRefreshUpstreamValues?.push(options?.refreshUpstream);
96 return state.currentRemoteStatus;
97 }),
95 Effect.suspend(() => state.beforeRemoteStatus ?? Effect.void).pipe(
96 Effect.andThen(
97 Effect.sync(() => {
98 state.remoteStatusCalls += 1;
99 state.remoteStatusRefreshUpstreamValues?.push(options?.refreshUpstream);
100 return state.currentRemoteStatus;
101 }),
102 ),
103 ),
98104 invalidateLocalStatus: () =>
99105 Effect.sync(() => {
100106 state.localInvalidationCalls += 1;
......@@ -847,6 +853,90 @@ describe("VcsStatusBroadcaster", () => {
847853 }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer())));
848854 });
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
850940 it("backs off remote refresh failures exponentially and honors larger configured intervals", () => {
851941 assert.equal(
852942 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* () {
462462 if (options?.refreshUpstream !== false) {
463463 yield* workflow.invalidateRemoteStatus(cwd);
464464 }
465 const previousRemote = (yield* getCachedStatus(cwd))?.remote?.value;
465466 const remote = yield* workflow.remoteStatus({ cwd }, options);
466467 const pulled = yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]);
467468 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 }
468481 return yield* updateCachedRemoteStatus(cwd, remote, { publish: true });
469482 }),
470483 );
......@@ -480,10 +493,9 @@ export const make = Effect.gen(function* () {
480493 cwd,
481494 Effect.gen(function* () {
482495 yield* workflow.invalidateStatus(cwd);
483 const [local, remote] = yield* Effect.all(
484 [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })],
485 { concurrency: "unbounded" },
486 );
496 // Local after remote: the fetch can move the base that the Changes totals compare with.
497 const remote = yield* workflow.remoteStatus({ cwd });
498 const local = yield* workflow.localStatus({ cwd });
487499 const pulled = yield* maybeAutoPull(cwd, remote, [rawCwd]);
488500 if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote);
489501 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) {
22222222 useLayoutEffect(() => {
22232223 const explicitThreadRef = explicitDiffOpenRef.current;
22242224 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.
22262226 // A timeline click instead opens the specific turn/file the user requested.
22272227 if (diffOpen && activeThreadRef && explicitThreadRef !== activeThreadRef) {
2228 useDiffPanelStore.getState().selectGitScope(activeThreadRef, "unstaged");
2228 useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch");
22292229 }
22302230 }, [activeThreadRef, diffOpen]);
22312231 const rightPanelState = useRightPanelStore((state) =>
......@@ -5023,7 +5023,7 @@ export default function ChatView(props: ChatViewProps) {
50235023 );
50245024 const addDiffSurface = useCallback(() => {
50255025 if (!activeThreadRef || !isServerThread || !isGitRepo) return;
5026 useDiffPanelStore.getState().selectGitScope(activeThreadRef, "unstaged");
5026 useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch");
50275027 useRightPanelStore.getState().open(activeThreadRef, "diff");
50285028 onDiffPanelOpen?.();
50295029 }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]);
......@@ -5378,7 +5378,7 @@ export default function ChatView(props: ChatViewProps) {
53785378 if (!panels.openProactive(activeThreadRef, { id: "diff", kind: "diff" }, userActionRevision)) {
53795379 return;
53805380 }
5381 useDiffPanelStore.getState().selectGitScope(activeThreadRef, "unstaged");
5381 useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch");
53825382 onDiffPanelOpen?.();
53835383 }, [
53845384 turnDiffSummaries,
apps/web/src/components/DiffPanel.tsx+10-10
......@@ -233,8 +233,8 @@ export default function DiffPanel({
233233 const selectedScopeLabel =
234234 selectedRunId === null
235235 ? selectedGitScope === "unstaged"
236 ? "Working tree"
237 : "Branch changes"
236 ? "Uncommitted"
237 : "Changes"
238238 : selectedTurn?.runId === latestTurn?.runId
239239 ? "Latest turn"
240240 : `Turn ${selectedCheckpointTurnCount ?? "?"}`;
......@@ -246,8 +246,8 @@ export default function DiffPanel({
246246 const reviewSectionTitle = selectedTurn
247247 ? `Turn ${selectedCheckpointTurnCount ?? "?"}`
248248 : selectedGitScope === "unstaged"
249 ? "Working tree"
250 : "Branch changes";
249 ? "Uncommitted"
250 : "Changes";
251251 const selectedCheckpointRange = useMemo(
252252 () =>
253253 typeof selectedCheckpointTurnCount === "number"
......@@ -676,11 +676,11 @@ export default function DiffPanel({
676676 </DropdownMenuTrigger>
677677 <DropdownMenuContent align="start">
678678 <DropdownMenuRadioGroup value={selectedScopeValue} onValueChange={selectScopeValue}>
679 <DropdownMenuRadioItem value="unstaged" closeOnClick>
680 <span>Working tree</span>
681 </DropdownMenuRadioItem>
682679 <DropdownMenuRadioItem value="branch" closeOnClick>
683 <span>Branch changes</span>
680 <span>Changes</span>
681 </DropdownMenuRadioItem>
682 <DropdownMenuRadioItem value="unstaged" closeOnClick>
683 <span>Uncommitted</span>
684684 </DropdownMenuRadioItem>
685685 <DropdownMenuRadioItem value="latest" closeOnClick>
686686 <span>Latest turn</span>
......@@ -1004,8 +1004,8 @@ export default function DiffPanel({
10041004 selectedTurn
10051005 ? "Loading checkpoint diff..."
10061006 : selectedGitScope === "unstaged"
1007 ? "Loading working tree diff..."
1008 : "Loading branch diff..."
1007 ? "Loading uncommitted changes..."
1008 : "Loading changes..."
10091009 }
10101010 />
10111011 ) : (
apps/web/src/components/GitActionsControl.tsx+4-6
......@@ -1192,6 +1192,8 @@ export default function GitActionsControl({
11921192 const isRepo = gitStatus?.isRepo ?? true;
11931193 const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false;
11941194 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
11961198 const allFiles = gitStatusForActions?.workingTree.files ?? [];
11971199 const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path));
......@@ -1936,12 +1938,8 @@ export default function GitActionsControl({
19361938 <FileDiffIcon className={THREAD_DETAILS_PANEL_ICON_CLASS} aria-hidden />
19371939 <span className="flex-1 text-left">Changes</span>
19381940 <span className="flex items-center gap-1 font-mono text-2xs tabular-nums">
1939 <span className="text-success">
1940 +{gitStatusForActions?.workingTree.insertions ?? 0}
1941 </span>
1942 <span className="text-destructive">
1943 -{gitStatusForActions?.workingTree.deletions ?? 0}
1944 </span>
1941 <span className="text-success">+{changesTotals?.insertions ?? 0}</span>
1942 <span className="text-destructive">-{changesTotals?.deletions ?? 0}</span>
19451943 </span>
19461944 </ThreadDetailsControl>
19471945 ) : null}
apps/web/src/components/settings/SettingsPanels.tsx+1-1
......@@ -2673,7 +2673,7 @@ export function GeneralSettingsPanel() {
26732673
26742674 <SettingsRow
26752675 {...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."
26772677 resetAction={
26782678 settings.proactivePanelsEnabled !== DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled ? (
26792679 <SettingResetButton
apps/web/src/diffPanelStore.test.ts+17-4
......@@ -14,18 +14,31 @@ describe("diffPanelStore", () => {
1414 }),
1515 );
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", () => {
1818 expect(
1919 selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF),
20 ).toEqual({ kind: "unstaged" });
20 ).toEqual({ kind: "branch", baseRef: null });
2121 });
2222
23 it("defaults to working tree changes before a thread is selected", () => {
23 it("defaults to Changes before a thread is selected", () => {
2424 expect(selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, null)).toEqual({
25 kind: "unstaged",
25 kind: "branch",
26 baseRef: null,
2627 });
2728 });
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
2942 it("preserves an explicit branch selection", () => {
3043 useDiffPanelStore.getState().selectGitScope(THREAD_REF, "branch");
3144
apps/web/src/diffPanelStore.ts+2-1
......@@ -10,7 +10,8 @@ export type DiffPanelSelection =
1010 | { kind: "unstaged" }
1111 | { 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
1516interface DiffPanelStoreState {
1617 byThreadKey: Record<string, DiffPanelSelection>;
packages/contracts/src/git.ts+11
......@@ -235,6 +235,17 @@ const VcsStatusLocalShape = {
235235 insertions: NonNegativeInt,
236236 deletions: NonNegativeInt,
237237 }),
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 ),
238249};
239250
240251const VcsStatusRemoteShape = {
packages/shared/src/git.ts+1
......@@ -373,6 +373,7 @@ function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult {
373373 refName: status.refName,
374374 hasWorkingTreeChanges: status.hasWorkingTreeChanges,
375375 workingTree: status.workingTree,
376 ...(status.branchChanges ? { branchChanges: status.branchChanges } : {}),
376377 };
377378}
378379