| author | |
| committer | |
| log | d1034d62b28e19c19240085566a39a92c5f9b466 |
| tree | 2de62c712d716ad204fdd3e695376c314f11169b |
| parent | 8283b481a34f5cc6f984f290e7cb93cc35b4dc56 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 126 | id: "sections", |
| 127 | 127 | inline: true, |
| 128 | 128 | 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"), | |
| 131 | 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 | 62 | { |
| 63 | 63 | id: "working-tree", |
| 64 | 64 | kind: "working-tree", |
| 65 | title: "Dirty worktree", | |
| 65 | title: "Uncommitted", | |
| 66 | 66 | baseRef: "HEAD", |
| 67 | 67 | headRef: null, |
| 68 | 68 | diff: "diff --git a/a.ts b/a.ts", |
| ... | ... | @@ -72,7 +72,7 @@ describe("buildReviewSectionItems", () => { |
| 72 | 72 | { |
| 73 | 73 | id: "branch-range", |
| 74 | 74 | kind: "branch-range", |
| 75 | title: "Against main", | |
| 75 | title: "Changes vs main", | |
| 76 | 76 | baseRef: "main", |
| 77 | 77 | headRef: "feature", |
| 78 | 78 | diff: "diff --git a/a.ts b/a.ts", |
| ... | ... | @@ -105,10 +105,28 @@ describe("buildReviewSectionItems", () => { |
| 105 | 105 | isLoading: false, |
| 106 | 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 | }); |
| 110 | 110 | |
| 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 | 130 | const items = buildReviewSectionItems({ |
| 113 | 131 | checkpoints: [], |
| 114 | 132 | gitSections: [], |
| ... | ... | @@ -119,15 +137,14 @@ describe("buildReviewSectionItems", () => { |
| 119 | 137 | |
| 120 | 138 | expect(items).toEqual([ |
| 121 | 139 | 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", | |
| 126 | 143 | diff: null, |
| 127 | 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 | }); |
| 133 | 150 |
apps/mobile/src/features/review/reviewModel.ts+13-11| ... | ... | @@ -9,9 +9,9 @@ import * as Order from "effect/Order"; |
| 9 | 9 | |
| 10 | 10 | export type ReviewSectionKind = "turn" | "working-tree" | "branch-range"; |
| 11 | 11 | |
| 12 | const DIRTY_WORKTREE_SECTION_ID = "git:working-tree"; | |
| 13 | const DIRTY_WORKTREE_TITLE = "Dirty worktree"; | |
| 14 | const DIRTY_WORKTREE_SUBTITLE = "Tracked, staged, and untracked worktree changes"; | |
| 12 | const CHANGES_SECTION_ID = "git:branch-range"; | |
| 13 | const CHANGES_TITLE = "Changes"; | |
| 14 | const UNCOMMITTED_SUBTITLE = "Staged, unstaged, and untracked files"; | |
| 15 | 15 | |
| 16 | 16 | export interface ReviewSectionItem { |
| 17 | 17 | readonly id: string; |
| ... | ... | @@ -124,7 +124,7 @@ const readyCheckpointOrder = Order.make<ThreadCheckpointSummary>( |
| 124 | 124 | |
| 125 | 125 | function gitSubtitle(section: ReviewDiffPreviewSource): string | null { |
| 126 | 126 | if (section.kind === "working-tree") { |
| 127 | return DIRTY_WORKTREE_SUBTITLE; | |
| 127 | return UNCOMMITTED_SUBTITLE; | |
| 128 | 128 | } |
| 129 | 129 | if (section.baseRef) { |
| 130 | 130 | return `${section.baseRef} ... ${section.headRef ?? "HEAD"}`; |
| ... | ... | @@ -443,15 +443,16 @@ export function buildReviewSectionItems(input: { |
| 443 | 443 | truncated: section.truncated, |
| 444 | 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 | 448 | const visibleGitItems = |
| 448 | input.loadingGitSections && !hasDirtyWorktreeItem | |
| 449 | input.loadingGitSections && !hasChangesItem | |
| 449 | 450 | ? [ |
| 450 | 451 | { |
| 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, | |
| 455 | 456 | diff: null, |
| 456 | 457 | isLoading: true, |
| 457 | 458 | } satisfies ReviewSectionItem, |
| ... | ... | @@ -462,10 +463,11 @@ export function buildReviewSectionItems(input: { |
| 462 | 463 | return [...turnItems, ...visibleGitItems]; |
| 463 | 464 | } |
| 464 | 465 | |
| 466 | /** Prefers Changes, then the first section (a turn when the project is not a git repo). */ | |
| 465 | 467 | export function getDefaultReviewSectionId( |
| 466 | 468 | sections: ReadonlyArray<ReviewSectionItem>, |
| 467 | 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 | } |
| 470 | 472 | |
| 471 | 473 | export function buildReviewParsedDiff( |
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+1-1| ... | ... | @@ -282,7 +282,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 282 | 282 | <SheetListRow |
| 283 | 283 | icon="text.bubble" |
| 284 | 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 | 286 | disabled={busy || !isRepo} |
| 287 | 287 | onPress={() => { |
| 288 | 288 | const params = { environmentId, threadId }; |
apps/server/src/git/GitManager.ts+4-3| ... | ... | @@ -1000,7 +1000,7 @@ export const make = Effect.gen(function* () { |
| 1000 | 1000 | const canonicalizeExistingPath = (value: string) => |
| 1001 | 1001 | fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value)); |
| 1002 | 1002 | const normalizeStatusCacheKey = canonicalizeExistingPath; |
| 1003 | const nonRepositoryStatusDetails = { | |
| 1003 | const nonRepositoryStatusDetails: GitVcsDriver.GitStatusDetails = { | |
| 1004 | 1004 | isRepo: false, |
| 1005 | 1005 | hasOriginRemote: false, |
| 1006 | 1006 | isDefaultBranch: false, |
| ... | ... | @@ -1012,10 +1012,10 @@ export const make = Effect.gen(function* () { |
| 1012 | 1012 | aheadCount: 0, |
| 1013 | 1013 | behindCount: 0, |
| 1014 | 1014 | aheadOfDefaultCount: 0, |
| 1015 | } satisfies GitVcsDriver.GitStatusDetails; | |
| 1015 | }; | |
| 1016 | 1016 | const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { |
| 1017 | 1017 | const details = yield* gitCore |
| 1018 | .statusDetailsLocal(cwd, { includeDivergence: false }) | |
| 1018 | .statusDetailsLocal(cwd, { includeDivergence: false, includeBranchChanges: true }) | |
| 1019 | 1019 | .pipe( |
| 1020 | 1020 | Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)), |
| 1021 | 1021 | ); |
| ... | ... | @@ -1031,6 +1031,7 @@ export const make = Effect.gen(function* () { |
| 1031 | 1031 | refName: details.branch, |
| 1032 | 1032 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, |
| 1033 | 1033 | workingTree: details.workingTree, |
| 1034 | ...(details.branchChanges ? { branchChanges: details.branchChanges } : {}), | |
| 1034 | 1035 | } satisfies VcsStatusLocalResult; |
| 1035 | 1036 | }); |
| 1036 | 1037 | const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, { |
apps/server/src/vcs/GitVcsDriver.ts+3| ... | ... | @@ -76,6 +76,7 @@ export interface GitStatusDetails { |
| 76 | 76 | upstreamRef: string | null; |
| 77 | 77 | hasWorkingTreeChanges: boolean; |
| 78 | 78 | workingTree: VcsStatusResult["workingTree"]; |
| 79 | branchChanges?: VcsStatusResult["branchChanges"]; | |
| 79 | 80 | hasUpstream: boolean; |
| 80 | 81 | aheadCount: number; |
| 81 | 82 | behindCount: number; |
| ... | ... | @@ -85,6 +86,8 @@ export interface GitStatusDetails { |
| 85 | 86 | export interface GitLocalStatusOptions { |
| 86 | 87 | /** Skip revision walks and return zero divergence counts for local-only consumers. */ |
| 87 | 88 | readonly includeDivergence?: boolean; |
| 89 | /** Also read the diff panel's Changes totals. Failures leave them out. */ | |
| 90 | readonly includeBranchChanges?: boolean; | |
| 88 | 91 | } |
| 89 | 92 | |
| 90 | 93 | export interface GitRemoteStatusDetails { |
apps/server/src/vcs/GitVcsDriverCore.test.ts+179-6| ... | ... | @@ -26,6 +26,7 @@ import { |
| 26 | 26 | GitCommandError, |
| 27 | 27 | ReviewDiffPreviewInput, |
| 28 | 28 | type ReviewDiffFileContentsInput, |
| 29 | type ReviewDiffPreviewResult, | |
| 29 | 30 | type WorktreeSubmodules, |
| 30 | 31 | } from "@t3tools/contracts"; |
| 31 | 32 | import * as ServerConfig from "../config.ts"; |
| ... | ... | @@ -1128,6 +1129,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 1128 | 1129 | Effect.gen(function* () { |
| 1129 | 1130 | const cwd = yield* makeTmpDir(); |
| 1130 | 1131 | const { initialBranch } = yield* initRepoWithCommit(cwd); |
| 1132 | const mergeBase = yield* git(cwd, ["rev-parse", "HEAD"]); | |
| 1131 | 1133 | yield* writeTextFile(cwd, "untracked.txt", "untracked content\n"); |
| 1132 | 1134 | const paths = Array.from({ length: 5000 }, (_, index) => `${"a".repeat(220)}-${index}.txt`); |
| 1133 | 1135 | const stats = paths.map((path) => `1\t0\t${path}\0`).join(""); |
| ... | ... | @@ -1138,10 +1140,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 1138 | 1140 | const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 1139 | 1141 | const spawner = ChildProcessSpawner.make((command) => { |
| 1140 | 1142 | 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)) { | |
| 1145 | 1144 | return Effect.succeed(makeSuccessfulHandle(stats)); |
| 1146 | 1145 | } |
| 1147 | 1146 | if (command.args.includes("ls-files") && command.args.includes("--others")) { |
| ... | ... | @@ -1566,6 +1565,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 1566 | 1565 | |
| 1567 | 1566 | assert.deepStrictEqual(branch.files, [ |
| 1568 | 1567 | { path: "a-large.txt", previousPath: null, additions: 4000, deletions: 0 }, |
| 1568 | { path: "untracked.txt", previousPath: null, additions: 4000, deletions: 0 }, | |
| 1569 | 1569 | { path: "z-last.txt", previousPath: null, additions: 1, deletions: 0 }, |
| 1570 | 1570 | ]); |
| 1571 | 1571 | assert.deepStrictEqual(dirty.files, [ |
| ... | ... | @@ -1740,7 +1740,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 1740 | 1740 | }), |
| 1741 | 1741 | ); |
| 1742 | 1742 | |
| 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 | 1744 | Effect.gen(function* () { |
| 1745 | 1745 | const cwd = yield* makeTmpDir(); |
| 1746 | 1746 | const { initialBranch } = yield* initRepoWithCommit(cwd); |
| ... | ... | @@ -1749,6 +1749,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 1749 | 1749 | yield* writeTextFile(cwd, "README.md", "# branch change\nunchanged context\n"); |
| 1750 | 1750 | yield* git(cwd, ["add", "README.md"]); |
| 1751 | 1751 | yield* git(cwd, ["commit", "-m", "change readme"]); |
| 1752 | yield* writeTextFile(cwd, "README.md", "# dirty change\nunchanged context\n"); | |
| 1752 | 1753 | |
| 1753 | 1754 | const contents = yield* driver.getReviewDiffFileContents( |
| 1754 | 1755 | makeReviewDiffFileContentsInput(cwd, { |
| ... | ... | @@ -1759,12 +1760,184 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 1759 | 1760 | ); |
| 1760 | 1761 | |
| 1761 | 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 | }); |
| 1766 | 1915 | |
| 1767 | 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 | 1941 | it.effect("reports non-repository directories without failing", () => |
| 1769 | 1942 | Effect.gen(function* () { |
| 1770 | 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 | 62 | // prefixes. A repository or global diff.noprefix or diff.mnemonicPrefix would |
| 63 | 63 | // otherwise leak into the patch and leave every parsed file unnamed. |
| 64 | 64 | export 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. | |
| 66 | const 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 | ]; | |
| 65 | 75 | const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); |
| 66 | 76 | const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); |
| 67 | 77 | |
| ... | ... | @@ -1518,9 +1528,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1518 | 1528 | return remoteName; |
| 1519 | 1529 | }); |
| 1520 | 1530 | |
| 1531 | // `allowRemoteOfCurrent` lets the review diff compare the default branch with its remote copy. | |
| 1521 | 1532 | const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* ( |
| 1522 | 1533 | cwd: string, |
| 1523 | 1534 | refName: string, |
| 1535 | options?: { readonly allowRemoteOfCurrent?: boolean }, | |
| 1524 | 1536 | ) { |
| 1525 | 1537 | const configuredBaseBranch = yield* runGitStdout( |
| 1526 | 1538 | "GitVcsDriver.resolveBaseBranchForNoUpstream.config", |
| ... | ... | @@ -1552,7 +1564,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1552 | 1564 | : remotePrefix && candidate.startsWith(remotePrefix) |
| 1553 | 1565 | ? candidate.slice(remotePrefix.length) |
| 1554 | 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 | 1582 | continue; |
| 1557 | 1583 | } |
| 1558 | 1584 | |
| ... | ... | @@ -1923,6 +1949,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1923 | 1949 | } |
| 1924 | 1950 | files.sort((a, b) => a.path.localeCompare(b.path)); |
| 1925 | 1951 | |
| 1952 | const branchChanges = options?.includeBranchChanges | |
| 1953 | ? yield* readBranchChangeTotals(repositoryPaths?.worktreeRoot ?? cwd, refName).pipe( | |
| 1954 | Effect.orElseSucceed(() => undefined), | |
| 1955 | ) | |
| 1956 | : undefined; | |
| 1957 | ||
| 1926 | 1958 | return { |
| 1927 | 1959 | isRepo: true, |
| 1928 | 1960 | hasOriginRemote: hasPrimaryRemote, |
| ... | ... | @@ -1935,6 +1967,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1935 | 1967 | insertions, |
| 1936 | 1968 | deletions, |
| 1937 | 1969 | }, |
| 1970 | ...(branchChanges ? { branchChanges } : {}), | |
| 1938 | 1971 | hasUpstream: upstreamRef !== null, |
| 1939 | 1972 | aheadCount, |
| 1940 | 1973 | behindCount, |
| ... | ... | @@ -2413,6 +2446,145 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 2413 | 2446 | return env; |
| 2414 | 2447 | }); |
| 2415 | 2448 | |
| 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 | 2588 | const getReviewDiffPreview = Effect.fn("getReviewDiffPreview")(function* ( |
| 2417 | 2589 | input: ReviewDiffPreviewInput, |
| 2418 | 2590 | ) { |
| ... | ... | @@ -2439,21 +2611,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 2439 | 2611 | } |
| 2440 | 2612 | |
| 2441 | 2613 | 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); | |
| 2448 | 2620 | |
| 2449 | 2621 | 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, | |
| 2457 | 2623 | ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), |
| 2458 | 2624 | ]; |
| 2459 | 2625 | const readStats = Effect.fn("GitVcsDriver.getReviewDiffPreview.stat")(function* ( |
| ... | ... | @@ -2469,12 +2635,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 2469 | 2635 | ); |
| 2470 | 2636 | if (result.exitCode === 0) return { ref, files: parseReviewNumstat(result.stdout) }; |
| 2471 | 2637 | 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); | |
| 2478 | 2639 | const stdout = yield* runGitStdoutWithOptions( |
| 2479 | 2640 | "GitVcsDriver.getReviewDiffPreview.unbornStat", |
| 2480 | 2641 | cwd, |
| ... | ... | @@ -2491,6 +2652,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 2491 | 2652 | exitCode: result.exitCode, |
| 2492 | 2653 | }); |
| 2493 | 2654 | }); |
| 2655 | // One commit argument diffs that commit against the working tree. | |
| 2494 | 2656 | const readTrackedDiff = Effect.fn("GitVcsDriver.getReviewDiffPreview.tracked")(function* ( |
| 2495 | 2657 | ref: string | null, |
| 2496 | 2658 | env?: NodeJS.ProcessEnv, |
| ... | ... | @@ -2506,54 +2668,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 2506 | 2668 | ); |
| 2507 | 2669 | return { ...patch, files: stat.files }; |
| 2508 | 2670 | }); |
| 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; | |
| 2545 | 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 | 2692 | const dirtyFiles = dirtyTrackedResult.files; |
| 2558 | 2693 | const baseFiles = baseResult.files; |
| 2559 | 2694 | const dirtyDiff = dirtyTrackedResult.stdout; |
| ... | ... | @@ -2574,14 +2709,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 2574 | 2709 | ); |
| 2575 | 2710 | const [dirtyDiffHash, baseDiffHash] = yield* Effect.all([ |
| 2576 | 2711 | hashDiff(dirtyDiff, dirtyFiles ?? []), |
| 2577 | hashDiff(baseDiff, baseFiles), | |
| 2712 | hashDiff(baseDiff, baseFiles ?? []), | |
| 2578 | 2713 | ]); |
| 2579 | 2714 | |
| 2580 | 2715 | const sources: ReviewDiffPreviewSource[] = [ |
| 2581 | 2716 | { |
| 2582 | 2717 | id: "working-tree", |
| 2583 | 2718 | kind: "working-tree", |
| 2584 | title: "Dirty worktree", | |
| 2719 | title: "Uncommitted", | |
| 2585 | 2720 | baseRef: "HEAD", |
| 2586 | 2721 | headRef: null, |
| 2587 | 2722 | diff: dirtyDiff, |
| ... | ... | @@ -2592,11 +2727,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 2592 | 2727 | { |
| 2593 | 2728 | id: "branch-range", |
| 2594 | 2729 | 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", | |
| 2598 | 2734 | diff: baseDiff, |
| 2599 | files: baseFiles, | |
| 2735 | ...(baseFiles === undefined ? {} : { files: baseFiles }), | |
| 2600 | 2736 | diffHash: baseDiffHash, |
| 2601 | 2737 | truncated: baseResult.stdoutTruncated, |
| 2602 | 2738 | }, |
| ... | ... | @@ -2712,54 +2848,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 2712 | 2848 | return new TextDecoder("utf-8").decode(bytes); |
| 2713 | 2849 | }); |
| 2714 | 2850 | |
| 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 | 2853 | const getReviewDiffFileContents = Effect.fn("getReviewDiffFileContents")(function* ( |
| 2716 | 2854 | input: ReviewDiffFileContentsInput, |
| 2717 | 2855 | ) { |
| 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", | |
| 2749 | 2858 | input.cwd, |
| 2750 | ["merge-base", input.baseRef, input.headRef], | |
| 2859 | ["rev-parse", "--show-toplevel"], | |
| 2751 | 2860 | ).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."); | |
| 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 | 2868 | const [oldContents, newContents] = yield* Effect.all( |
| 2756 | 2869 | [ |
| 2757 | 2870 | input.changeType === "new" |
| 2758 | 2871 | ? Effect.succeed("") |
| 2759 | : readReviewFileAtRevision(input, mergeBase, input.oldPath), | |
| 2872 | : readReviewFileAtRevision(input, oldRevision, input.oldPath), | |
| 2760 | 2873 | input.changeType === "deleted" |
| 2761 | 2874 | ? Effect.succeed("") |
| 2762 | : readReviewFileAtRevision(input, input.headRef, input.newPath), | |
| 2875 | : readWorkingTreeReviewFile(input, repositoryRoot), | |
| 2763 | 2876 | ], |
| 2764 | 2877 | { concurrency: 2 }, |
| 2765 | 2878 | ); |
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+95-5| ... | ... | @@ -78,6 +78,8 @@ function makeTestLayer(state: { |
| 78 | 78 | remoteInvalidationCalls: number; |
| 79 | 79 | remoteStatusRefreshUpstreamValues?: Array<boolean | undefined>; |
| 80 | 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 | 84 | return VcsStatusBroadcaster.layer.pipe( |
| 83 | 85 | Layer.provideMerge(NodeServices.layer), |
| ... | ... | @@ -90,11 +92,15 @@ function makeTestLayer(state: { |
| 90 | 92 | return state.currentLocalStatus; |
| 91 | 93 | }), |
| 92 | 94 | 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 | ), | |
| 98 | 104 | invalidateLocalStatus: () => |
| 99 | 105 | Effect.sync(() => { |
| 100 | 106 | state.localInvalidationCalls += 1; |
| ... | ... | @@ -847,6 +853,90 @@ describe("VcsStatusBroadcaster", () => { |
| 847 | 853 | }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer()))); |
| 848 | 854 | }); |
| 849 | 855 | |
| 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 | 940 | it("backs off remote refresh failures exponentially and honors larger configured intervals", () => { |
| 851 | 941 | assert.equal( |
| 852 | 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 | 462 | if (options?.refreshUpstream !== false) { |
| 463 | 463 | yield* workflow.invalidateRemoteStatus(cwd); |
| 464 | 464 | } |
| 465 | const previousRemote = (yield* getCachedStatus(cwd))?.remote?.value; | |
| 465 | 466 | const remote = yield* workflow.remoteStatus({ cwd }, options); |
| 466 | 467 | const pulled = yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]); |
| 467 | 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 | 481 | return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); |
| 469 | 482 | }), |
| 470 | 483 | ); |
| ... | ... | @@ -480,10 +493,9 @@ export const make = Effect.gen(function* () { |
| 480 | 493 | cwd, |
| 481 | 494 | Effect.gen(function* () { |
| 482 | 495 | 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 }); | |
| 487 | 499 | const pulled = yield* maybeAutoPull(cwd, remote, [rawCwd]); |
| 488 | 500 | if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote); |
| 489 | 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 | 2222 | useLayoutEffect(() => { |
| 2223 | 2223 | const explicitThreadRef = explicitDiffOpenRef.current; |
| 2224 | 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 | 2226 | // A timeline click instead opens the specific turn/file the user requested. |
| 2227 | 2227 | if (diffOpen && activeThreadRef && explicitThreadRef !== activeThreadRef) { |
| 2228 | useDiffPanelStore.getState().selectGitScope(activeThreadRef, "unstaged"); | |
| 2228 | useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); | |
| 2229 | 2229 | } |
| 2230 | 2230 | }, [activeThreadRef, diffOpen]); |
| 2231 | 2231 | const rightPanelState = useRightPanelStore((state) => |
| ... | ... | @@ -5023,7 +5023,7 @@ export default function ChatView(props: ChatViewProps) { |
| 5023 | 5023 | ); |
| 5024 | 5024 | const addDiffSurface = useCallback(() => { |
| 5025 | 5025 | if (!activeThreadRef || !isServerThread || !isGitRepo) return; |
| 5026 | useDiffPanelStore.getState().selectGitScope(activeThreadRef, "unstaged"); | |
| 5026 | useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); | |
| 5027 | 5027 | useRightPanelStore.getState().open(activeThreadRef, "diff"); |
| 5028 | 5028 | onDiffPanelOpen?.(); |
| 5029 | 5029 | }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]); |
| ... | ... | @@ -5378,7 +5378,7 @@ export default function ChatView(props: ChatViewProps) { |
| 5378 | 5378 | if (!panels.openProactive(activeThreadRef, { id: "diff", kind: "diff" }, userActionRevision)) { |
| 5379 | 5379 | return; |
| 5380 | 5380 | } |
| 5381 | useDiffPanelStore.getState().selectGitScope(activeThreadRef, "unstaged"); | |
| 5381 | useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); | |
| 5382 | 5382 | onDiffPanelOpen?.(); |
| 5383 | 5383 | }, [ |
| 5384 | 5384 | turnDiffSummaries, |
apps/web/src/components/DiffPanel.tsx+10-10| ... | ... | @@ -233,8 +233,8 @@ export default function DiffPanel({ |
| 233 | 233 | const selectedScopeLabel = |
| 234 | 234 | selectedRunId === null |
| 235 | 235 | ? selectedGitScope === "unstaged" |
| 236 | ? "Working tree" | |
| 237 | : "Branch changes" | |
| 236 | ? "Uncommitted" | |
| 237 | : "Changes" | |
| 238 | 238 | : selectedTurn?.runId === latestTurn?.runId |
| 239 | 239 | ? "Latest turn" |
| 240 | 240 | : `Turn ${selectedCheckpointTurnCount ?? "?"}`; |
| ... | ... | @@ -246,8 +246,8 @@ export default function DiffPanel({ |
| 246 | 246 | const reviewSectionTitle = selectedTurn |
| 247 | 247 | ? `Turn ${selectedCheckpointTurnCount ?? "?"}` |
| 248 | 248 | : selectedGitScope === "unstaged" |
| 249 | ? "Working tree" | |
| 250 | : "Branch changes"; | |
| 249 | ? "Uncommitted" | |
| 250 | : "Changes"; | |
| 251 | 251 | const selectedCheckpointRange = useMemo( |
| 252 | 252 | () => |
| 253 | 253 | typeof selectedCheckpointTurnCount === "number" |
| ... | ... | @@ -676,11 +676,11 @@ export default function DiffPanel({ |
| 676 | 676 | </DropdownMenuTrigger> |
| 677 | 677 | <DropdownMenuContent align="start"> |
| 678 | 678 | <DropdownMenuRadioGroup value={selectedScopeValue} onValueChange={selectScopeValue}> |
| 679 | <DropdownMenuRadioItem value="unstaged" closeOnClick> | |
| 680 | <span>Working tree</span> | |
| 681 | </DropdownMenuRadioItem> | |
| 682 | 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 | 684 | </DropdownMenuRadioItem> |
| 685 | 685 | <DropdownMenuRadioItem value="latest" closeOnClick> |
| 686 | 686 | <span>Latest turn</span> |
| ... | ... | @@ -1004,8 +1004,8 @@ export default function DiffPanel({ |
| 1004 | 1004 | selectedTurn |
| 1005 | 1005 | ? "Loading checkpoint diff..." |
| 1006 | 1006 | : selectedGitScope === "unstaged" |
| 1007 | ? "Loading working tree diff..." | |
| 1008 | : "Loading branch diff..." | |
| 1007 | ? "Loading uncommitted changes..." | |
| 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 | 1192 | const isRepo = gitStatus?.isRepo ?? true; |
| 1193 | 1193 | const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; |
| 1194 | 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; | |
| 1195 | 1197 | |
| 1196 | 1198 | const allFiles = gitStatusForActions?.workingTree.files ?? []; |
| 1197 | 1199 | const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); |
| ... | ... | @@ -1936,12 +1938,8 @@ export default function GitActionsControl({ |
| 1936 | 1938 | <FileDiffIcon className={THREAD_DETAILS_PANEL_ICON_CLASS} aria-hidden /> |
| 1937 | 1939 | <span className="flex-1 text-left">Changes</span> |
| 1938 | 1940 | <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> | |
| 1945 | 1943 | </span> |
| 1946 | 1944 | </ThreadDetailsControl> |
| 1947 | 1945 | ) : null} |
apps/web/src/components/settings/SettingsPanels.tsx+1-1| ... | ... | @@ -2673,7 +2673,7 @@ export function GeneralSettingsPanel() { |
| 2673 | 2673 | |
| 2674 | 2674 | <SettingsRow |
| 2675 | 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 | 2677 | resetAction={ |
| 2678 | 2678 | settings.proactivePanelsEnabled !== DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled ? ( |
| 2679 | 2679 | <SettingResetButton |
apps/web/src/diffPanelStore.test.ts+17-4| ... | ... | @@ -14,18 +14,31 @@ describe("diffPanelStore", () => { |
| 14 | 14 | }), |
| 15 | 15 | ); |
| 16 | 16 | |
| 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 | 18 | expect( |
| 19 | 19 | selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), |
| 20 | ).toEqual({ kind: "unstaged" }); | |
| 20 | ).toEqual({ kind: "branch", baseRef: null }); | |
| 21 | 21 | }); |
| 22 | 22 | |
| 23 | it("defaults to working tree changes before a thread is selected", () => { | |
| 23 | it("defaults to Changes before a thread is selected", () => { | |
| 24 | 24 | expect(selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, null)).toEqual({ |
| 25 | kind: "unstaged", | |
| 25 | kind: "branch", | |
| 26 | baseRef: null, | |
| 26 | 27 | }); |
| 27 | 28 | }); |
| 28 | 29 | |
| 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 | 42 | it("preserves an explicit branch selection", () => { |
| 30 | 43 | useDiffPanelStore.getState().selectGitScope(THREAD_REF, "branch"); |
| 31 | 44 |
apps/web/src/diffPanelStore.ts+2-1| ... | ... | @@ -10,7 +10,8 @@ export type DiffPanelSelection = |
| 10 | 10 | | { kind: "unstaged" } |
| 11 | 11 | | { kind: "turn"; turnId: RunId; filePath: string | null; revealRequestId: number }; |
| 12 | 12 | |
| 13 | const DEFAULT_SELECTION: DiffPanelSelection = { kind: "unstaged" }; | |
| 13 | // "branch" is the Changes view: everything this checkout changed since its base. | |
| 14 | const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null }; | |
| 14 | 15 | |
| 15 | 16 | interface DiffPanelStoreState { |
| 16 | 17 | byThreadKey: Record<string, DiffPanelSelection>; |
packages/contracts/src/git.ts+11| ... | ... | @@ -235,6 +235,17 @@ const VcsStatusLocalShape = { |
| 235 | 235 | insertions: NonNegativeInt, |
| 236 | 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 | }; |
| 239 | 250 | |
| 240 | 251 | const VcsStatusRemoteShape = { |
packages/shared/src/git.ts+1| ... | ... | @@ -373,6 +373,7 @@ function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult { |
| 373 | 373 | refName: status.refName, |
| 374 | 374 | hasWorkingTreeChanges: status.hasWorkingTreeChanges, |
| 375 | 375 | workingTree: status.workingTree, |
| 376 | ...(status.branchChanges ? { branchChanges: status.branchChanges } : {}), | |
| 376 | 377 | }; |
| 377 | 378 | } |
| 378 | 379 |