| author | |
| committer | |
| log | 915a0548c0c024d9a51da1041475120dde089c26 |
| tree | 8fa26fbb0e0e083eedebfbb550d09e2008abc62c |
| parent | 5ce5472bc389e178132df40f316014d714f3275d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>18 files changed, 502 insertions(+), 139 deletions(-)
apps/server/src/git/Layers/GitCore.test.ts+71| ... | ... | @@ -386,6 +386,36 @@ it.layer(TestLayer)("git integration", (it) => { |
| 386 | 386 | }), |
| 387 | 387 | ); |
| 388 | 388 | |
| 389 | it.effect("paginates branch results and returns paging metadata", () => | |
| 390 | Effect.gen(function* () { | |
| 391 | const tmp = yield* makeTmpDir(); | |
| 392 | const { initialBranch } = yield* initRepoWithCommit(tmp); | |
| 393 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-a" }); | |
| 394 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-b" }); | |
| 395 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-c" }); | |
| 396 | ||
| 397 | const firstPage = yield* (yield* GitCore).listBranches({ cwd: tmp, limit: 2 }); | |
| 398 | expect(firstPage.totalCount).toBe(4); | |
| 399 | expect(firstPage.nextCursor).toBe(2); | |
| 400 | expect(firstPage.branches.map((branch) => branch.name)).toEqual([ | |
| 401 | initialBranch, | |
| 402 | "feature-a", | |
| 403 | ]); | |
| 404 | ||
| 405 | const secondPage = yield* (yield* GitCore).listBranches({ | |
| 406 | cwd: tmp, | |
| 407 | cursor: firstPage.nextCursor ?? 0, | |
| 408 | limit: 2, | |
| 409 | }); | |
| 410 | expect(secondPage.totalCount).toBe(4); | |
| 411 | expect(secondPage.nextCursor).toBeNull(); | |
| 412 | expect(secondPage.branches.map((branch) => branch.name)).toEqual([ | |
| 413 | "feature-b", | |
| 414 | "feature-c", | |
| 415 | ]); | |
| 416 | }), | |
| 417 | ); | |
| 418 | ||
| 389 | 419 | it.effect("parses separate branch names when column.ui is always enabled", () => |
| 390 | 420 | Effect.gen(function* () { |
| 391 | 421 | const tmp = yield* makeTmpDir(); |
| ... | ... | @@ -531,6 +561,41 @@ it.layer(TestLayer)("git integration", (it) => { |
| 531 | 561 | expect(remoteBranch?.remoteName).toBe(remoteName); |
| 532 | 562 | }), |
| 533 | 563 | ); |
| 564 | ||
| 565 | it.effect( | |
| 566 | "filters branch queries before pagination and dedupes origin refs with local matches", | |
| 567 | () => | |
| 568 | Effect.gen(function* () { | |
| 569 | const remote = yield* makeTmpDir(); | |
| 570 | const tmp = yield* makeTmpDir(); | |
| 571 | ||
| 572 | yield* git(remote, ["init", "--bare"]); | |
| 573 | const { initialBranch } = yield* initRepoWithCommit(tmp); | |
| 574 | yield* git(tmp, ["remote", "add", "origin", remote]); | |
| 575 | yield* git(tmp, ["push", "-u", "origin", initialBranch]); | |
| 576 | ||
| 577 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/demo" }); | |
| 578 | yield* git(tmp, ["push", "-u", "origin", "feature/demo"]); | |
| 579 | ||
| 580 | yield* git(tmp, ["checkout", "-b", "feature/remote-only"]); | |
| 581 | yield* git(tmp, ["push", "-u", "origin", "feature/remote-only"]); | |
| 582 | yield* git(tmp, ["checkout", initialBranch]); | |
| 583 | yield* git(tmp, ["branch", "-D", "feature/remote-only"]); | |
| 584 | ||
| 585 | const result = yield* (yield* GitCore).listBranches({ | |
| 586 | cwd: tmp, | |
| 587 | query: "feature/", | |
| 588 | limit: 10, | |
| 589 | }); | |
| 590 | ||
| 591 | expect(result.totalCount).toBe(2); | |
| 592 | expect(result.nextCursor).toBeNull(); | |
| 593 | expect(result.branches.map((branch) => branch.name)).toEqual([ | |
| 594 | "feature/demo", | |
| 595 | "origin/feature/remote-only", | |
| 596 | ]); | |
| 597 | }), | |
| 598 | ); | |
| 534 | 599 | }); |
| 535 | 600 | |
| 536 | 601 | // ── checkoutGitBranch ── |
| ... | ... | @@ -737,6 +802,9 @@ it.layer(TestLayer)("git integration", (it) => { |
| 737 | 802 | ) { |
| 738 | 803 | return ok(); |
| 739 | 804 | } |
| 805 | if (input.operation === "GitCore.statusDetails.defaultRef") { | |
| 806 | return ok("refs/remotes/origin/main\n"); | |
| 807 | } | |
| 740 | 808 | return Effect.fail( |
| 741 | 809 | new GitCommandError({ |
| 742 | 810 | operation: input.operation, |
| ... | ... | @@ -800,6 +868,9 @@ it.layer(TestLayer)("git integration", (it) => { |
| 800 | 868 | ) { |
| 801 | 869 | return ok(); |
| 802 | 870 | } |
| 871 | if (input.operation === "GitCore.statusDetails.defaultRef") { | |
| 872 | return ok("refs/remotes/origin/main\n"); | |
| 873 | } | |
| 803 | 874 | return Effect.fail( |
| 804 | 875 | new GitCommandError({ |
| 805 | 876 | operation: input.operation, |
apps/server/src/git/Layers/GitCore.ts+112-11| ... | ... | @@ -18,7 +18,8 @@ import { |
| 18 | 18 | } from "effect"; |
| 19 | 19 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 20 | 20 | |
| 21 | import { GitCommandError } from "@t3tools/contracts"; | |
| 21 | import { GitCommandError, type GitBranch } from "@t3tools/contracts"; | |
| 22 | import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; | |
| 22 | 23 | import { |
| 23 | 24 | GitCore, |
| 24 | 25 | type ExecuteGitProgress, |
| ... | ... | @@ -49,6 +50,7 @@ const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); |
| 49 | 50 | const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); |
| 50 | 51 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; |
| 51 | 52 | const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; |
| 53 | const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; | |
| 52 | 54 | |
| 53 | 55 | type TraceTailState = { |
| 54 | 56 | processedChars: number; |
| ... | ... | @@ -183,6 +185,40 @@ function parseBranchLine(line: string): { name: string; current: boolean } | nul |
| 183 | 185 | }; |
| 184 | 186 | } |
| 185 | 187 | |
| 188 | function filterBranchesForListQuery( | |
| 189 | branches: ReadonlyArray<GitBranch>, | |
| 190 | query?: string, | |
| 191 | ): ReadonlyArray<GitBranch> { | |
| 192 | if (!query) { | |
| 193 | return branches; | |
| 194 | } | |
| 195 | ||
| 196 | const normalizedQuery = query.toLowerCase(); | |
| 197 | return branches.filter((branch) => branch.name.toLowerCase().includes(normalizedQuery)); | |
| 198 | } | |
| 199 | ||
| 200 | function paginateBranches(input: { | |
| 201 | branches: ReadonlyArray<GitBranch>; | |
| 202 | cursor?: number | undefined; | |
| 203 | limit?: number | undefined; | |
| 204 | }): { | |
| 205 | branches: ReadonlyArray<GitBranch>; | |
| 206 | nextCursor: number | null; | |
| 207 | totalCount: number; | |
| 208 | } { | |
| 209 | const cursor = input.cursor ?? 0; | |
| 210 | const limit = input.limit ?? GIT_LIST_BRANCHES_DEFAULT_LIMIT; | |
| 211 | const totalCount = input.branches.length; | |
| 212 | const branches = input.branches.slice(cursor, cursor + limit); | |
| 213 | const nextCursor = cursor + branches.length < totalCount ? cursor + branches.length : null; | |
| 214 | ||
| 215 | return { | |
| 216 | branches, | |
| 217 | nextCursor, | |
| 218 | totalCount, | |
| 219 | }; | |
| 220 | } | |
| 221 | ||
| 186 | 222 | function sanitizeRemoteName(value: string): string { |
| 187 | 223 | const sanitized = value |
| 188 | 224 | .trim() |
| ... | ... | @@ -1101,15 +1137,52 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 1101 | 1137 | const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { |
| 1102 | 1138 | yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true })); |
| 1103 | 1139 | |
| 1104 | const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all( | |
| 1105 | [ | |
| 1106 | runGitStdout("GitCore.statusDetails.status", cwd, ["status", "--porcelain=2", "--branch"]), | |
| 1107 | runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), | |
| 1108 | runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, ["diff", "--cached", "--numstat"]), | |
| 1109 | ], | |
| 1110 | { concurrency: "unbounded" }, | |
| 1140 | const statusResult = yield* executeGit( | |
| 1141 | "GitCore.statusDetails.status", | |
| 1142 | cwd, | |
| 1143 | ["status", "--porcelain=2", "--branch"], | |
| 1144 | { | |
| 1145 | allowNonZeroExit: true, | |
| 1146 | }, | |
| 1111 | 1147 | ); |
| 1112 | 1148 | |
| 1149 | if (statusResult.code !== 0) { | |
| 1150 | const stderr = statusResult.stderr.trim(); | |
| 1151 | return yield* createGitCommandError( | |
| 1152 | "GitCore.statusDetails.status", | |
| 1153 | cwd, | |
| 1154 | ["status", "--porcelain=2", "--branch"], | |
| 1155 | stderr || "git status failed", | |
| 1156 | ); | |
| 1157 | } | |
| 1158 | ||
| 1159 | const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasOriginRemote] = | |
| 1160 | yield* Effect.all( | |
| 1161 | [ | |
| 1162 | runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), | |
| 1163 | runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [ | |
| 1164 | "diff", | |
| 1165 | "--cached", | |
| 1166 | "--numstat", | |
| 1167 | ]), | |
| 1168 | executeGit( | |
| 1169 | "GitCore.statusDetails.defaultRef", | |
| 1170 | cwd, | |
| 1171 | ["symbolic-ref", "refs/remotes/origin/HEAD"], | |
| 1172 | { | |
| 1173 | allowNonZeroExit: true, | |
| 1174 | }, | |
| 1175 | ), | |
| 1176 | originRemoteExists(cwd).pipe(Effect.catch(() => Effect.succeed(false))), | |
| 1177 | ], | |
| 1178 | { concurrency: "unbounded" }, | |
| 1179 | ); | |
| 1180 | const statusStdout = statusResult.stdout; | |
| 1181 | const defaultBranch = | |
| 1182 | defaultRefResult.code === 0 | |
| 1183 | ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "") | |
| 1184 | : null; | |
| 1185 | ||
| 1113 | 1186 | let branch: string | null = null; |
| 1114 | 1187 | let upstreamRef: string | null = null; |
| 1115 | 1188 | let aheadCount = 0; |
| ... | ... | @@ -1176,6 +1249,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 1176 | 1249 | files.sort((a, b) => a.path.localeCompare(b.path)); |
| 1177 | 1250 | |
| 1178 | 1251 | return { |
| 1252 | isRepo: true, | |
| 1253 | hasOriginRemote, | |
| 1254 | isDefaultBranch: | |
| 1255 | branch !== null && | |
| 1256 | (branch === defaultBranch || | |
| 1257 | (defaultBranch === null && (branch === "main" || branch === "master"))), | |
| 1179 | 1258 | branch, |
| 1180 | 1259 | upstreamRef, |
| 1181 | 1260 | hasWorkingTreeChanges, |
| ... | ... | @@ -1193,6 +1272,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 1193 | 1272 | const status: GitCoreShape["status"] = (input) => |
| 1194 | 1273 | statusDetails(input.cwd).pipe( |
| 1195 | 1274 | Effect.map((details) => ({ |
| 1275 | isRepo: details.isRepo, | |
| 1276 | hasOriginRemote: details.hasOriginRemote, | |
| 1277 | isDefaultBranch: details.isDefaultBranch, | |
| 1196 | 1278 | branch: details.branch, |
| 1197 | 1279 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, |
| 1198 | 1280 | workingTree: details.workingTree, |
| ... | ... | @@ -1571,7 +1653,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 1571 | 1653 | if (localBranchResult.code !== 0) { |
| 1572 | 1654 | const stderr = localBranchResult.stderr.trim(); |
| 1573 | 1655 | if (stderr.toLowerCase().includes("not a git repository")) { |
| 1574 | return { branches: [], isRepo: false, hasOriginRemote: false }; | |
| 1656 | return { | |
| 1657 | branches: [], | |
| 1658 | isRepo: false, | |
| 1659 | hasOriginRemote: false, | |
| 1660 | nextCursor: null, | |
| 1661 | totalCount: 0, | |
| 1662 | }; | |
| 1575 | 1663 | } |
| 1576 | 1664 | return yield* createGitCommandError( |
| 1577 | 1665 | "GitCore.listBranches", |
| ... | ... | @@ -1735,9 +1823,22 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 1735 | 1823 | }) |
| 1736 | 1824 | : []; |
| 1737 | 1825 | |
| 1738 | const branches = [...localBranches, ...remoteBranches]; | |
| 1826 | const branches = paginateBranches({ | |
| 1827 | branches: filterBranchesForListQuery( | |
| 1828 | dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]), | |
| 1829 | input.query, | |
| 1830 | ), | |
| 1831 | cursor: input.cursor, | |
| 1832 | limit: input.limit, | |
| 1833 | }); | |
| 1739 | 1834 | |
| 1740 | return { branches, isRepo: true, hasOriginRemote: remoteNames.includes("origin") }; | |
| 1835 | return { | |
| 1836 | branches: [...branches.branches], | |
| 1837 | isRepo: true, | |
| 1838 | hasOriginRemote: remoteNames.includes("origin"), | |
| 1839 | nextCursor: branches.nextCursor, | |
| 1840 | totalCount: branches.totalCount, | |
| 1841 | }; | |
| 1741 | 1842 | }); |
| 1742 | 1843 | |
| 1743 | 1844 | const createWorktree: GitCoreShape["createWorktree"] = Effect.fn("createWorktree")( |
apps/server/src/git/Layers/GitManager.test.ts+29| ... | ... | @@ -660,6 +660,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 660 | 660 | }); |
| 661 | 661 | |
| 662 | 662 | const status = yield* manager.status({ cwd: repoDir }); |
| 663 | expect(status.isRepo).toBe(true); | |
| 664 | expect(status.hasOriginRemote).toBe(true); | |
| 665 | expect(status.isDefaultBranch).toBe(false); | |
| 663 | 666 | expect(status.branch).toBe("feature/status-open-pr"); |
| 664 | 667 | expect(status.pr).toEqual({ |
| 665 | 668 | number: 13, |
| ... | ... | @@ -672,6 +675,32 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 672 | 675 | }), |
| 673 | 676 | ); |
| 674 | 677 | |
| 678 | it.effect("status returns an explicit non-repo result for non-git directories", () => | |
| 679 | Effect.gen(function* () { | |
| 680 | const cwd = yield* makeTempDir("t3code-git-manager-non-repo-"); | |
| 681 | const { manager } = yield* makeManager(); | |
| 682 | ||
| 683 | const status = yield* manager.status({ cwd }); | |
| 684 | ||
| 685 | expect(status).toEqual({ | |
| 686 | isRepo: false, | |
| 687 | hasOriginRemote: false, | |
| 688 | isDefaultBranch: false, | |
| 689 | branch: null, | |
| 690 | hasWorkingTreeChanges: false, | |
| 691 | workingTree: { | |
| 692 | files: [], | |
| 693 | insertions: 0, | |
| 694 | deletions: 0, | |
| 695 | }, | |
| 696 | hasUpstream: false, | |
| 697 | aheadCount: 0, | |
| 698 | behindCount: 0, | |
| 699 | pr: null, | |
| 700 | }); | |
| 701 | }), | |
| 702 | ); | |
| 703 | ||
| 675 | 704 | it.effect("status briefly caches repeated lookups for the same cwd", () => |
| 676 | 705 | Effect.gen(function* () { |
| 677 | 706 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
apps/server/src/git/Layers/GitManager.ts+27-14| ... | ... | @@ -5,6 +5,7 @@ import { Cache, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Ref } f |
| 5 | 5 | import { |
| 6 | 6 | GitActionProgressEvent, |
| 7 | 7 | GitActionProgressPhase, |
| 8 | GitCommandError, | |
| 8 | 9 | GitRunStackedActionResult, |
| 9 | 10 | GitStackedAction, |
| 10 | 11 | ModelSelection, |
| ... | ... | @@ -22,7 +23,7 @@ import { |
| 22 | 23 | type GitManagerShape, |
| 23 | 24 | type GitRunStackedActionOptions, |
| 24 | 25 | } from "../Services/GitManager.ts"; |
| 25 | import { GitCore } from "../Services/GitCore.ts"; | |
| 26 | import { GitCore, GitStatusDetails } from "../Services/GitCore.ts"; | |
| 26 | 27 | import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts"; |
| 27 | 28 | import { TextGeneration } from "../Services/TextGeneration.ts"; |
| 28 | 29 | import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts"; |
| ... | ... | @@ -39,6 +40,10 @@ type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "act |
| 39 | 40 | type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>; |
| 40 | 41 | type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect<void, never>; |
| 41 | 42 | |
| 43 | function isNotGitRepositoryError(error: GitCommandError): boolean { | |
| 44 | return error.message.toLowerCase().includes("not a git repository"); | |
| 45 | } | |
| 46 | ||
| 42 | 47 | interface OpenPrInfo { |
| 43 | 48 | number: number; |
| 44 | 49 | title: string; |
| ... | ... | @@ -689,10 +694,25 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { |
| 689 | 694 | const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; |
| 690 | 695 | const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd); |
| 691 | 696 | const readStatus = Effect.fn("readStatus")(function* (cwd: string) { |
| 692 | const details = yield* gitCore.statusDetails(cwd); | |
| 697 | const details = yield* gitCore.statusDetails(cwd).pipe( | |
| 698 | Effect.catchIf(isNotGitRepositoryError, () => | |
| 699 | Effect.succeed({ | |
| 700 | isRepo: false, | |
| 701 | hasOriginRemote: false, | |
| 702 | isDefaultBranch: false, | |
| 703 | branch: null, | |
| 704 | upstreamRef: null, | |
| 705 | hasWorkingTreeChanges: false, | |
| 706 | workingTree: { files: [], insertions: 0, deletions: 0 }, | |
| 707 | hasUpstream: false, | |
| 708 | aheadCount: 0, | |
| 709 | behindCount: 0, | |
| 710 | } satisfies GitStatusDetails), | |
| 711 | ), | |
| 712 | ); | |
| 693 | 713 | |
| 694 | 714 | const pr = |
| 695 | details.branch !== null | |
| 715 | details.isRepo && details.branch !== null | |
| 696 | 716 | ? yield* findLatestPr(cwd, { |
| 697 | 717 | branch: details.branch, |
| 698 | 718 | upstreamRef: details.upstreamRef, |
| ... | ... | @@ -703,6 +723,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { |
| 703 | 723 | : null; |
| 704 | 724 | |
| 705 | 725 | return { |
| 726 | isRepo: details.isRepo, | |
| 727 | hasOriginRemote: details.hasOriginRemote, | |
| 728 | isDefaultBranch: details.isDefaultBranch, | |
| 706 | 729 | branch: details.branch, |
| 707 | 730 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, |
| 708 | 731 | workingTree: details.workingTree, |
| ... | ... | @@ -909,12 +932,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { |
| 909 | 932 | return parsed[0] ?? null; |
| 910 | 933 | }); |
| 911 | 934 | |
| 912 | const isDefaultBranch = Effect.fn("isDefaultBranch")(function* (cwd: string, branch: string) { | |
| 913 | const branches = yield* gitCore.listBranches({ cwd }); | |
| 914 | const currentBranch = branches.branches.find((candidate) => candidate.name === branch); | |
| 915 | return currentBranch?.isDefault ?? (branch === "main" || branch === "master"); | |
| 916 | }); | |
| 917 | ||
| 918 | 935 | const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( |
| 919 | 936 | cwd: string, |
| 920 | 937 | result: Pick<GitRunStackedActionResult, "action" | "branch" | "commit" | "push" | "pr">, |
| ... | ... | @@ -936,11 +953,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { |
| 936 | 953 | upstreamRef: finalStatus.upstreamRef, |
| 937 | 954 | hasUpstream: finalStatus.hasUpstream, |
| 938 | 955 | }; |
| 939 | currentBranchIsDefault = yield* isDefaultBranch(cwd, finalStatus.branch).pipe( | |
| 940 | Effect.catch(() => | |
| 941 | Effect.succeed(finalStatus.branch === "main" || finalStatus.branch === "master"), | |
| 942 | ), | |
| 943 | ); | |
| 956 | currentBranchIsDefault = finalStatus.isDefaultBranch; | |
| 944 | 957 | } |
| 945 | 958 | } |
| 946 | 959 |
apps/server/src/server.test.ts+5| ... | ... | @@ -822,6 +822,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 822 | 822 | gitManager: { |
| 823 | 823 | status: () => |
| 824 | 824 | Effect.succeed({ |
| 825 | isRepo: true, | |
| 826 | hasOriginRemote: true, | |
| 827 | isDefaultBranch: true, | |
| 825 | 828 | branch: "main", |
| 826 | 829 | hasWorkingTreeChanges: false, |
| 827 | 830 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| ... | ... | @@ -922,6 +925,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 922 | 925 | ], |
| 923 | 926 | isRepo: true, |
| 924 | 927 | hasOriginRemote: true, |
| 928 | nextCursor: null, | |
| 929 | totalCount: 1, | |
| 925 | 930 | }), |
| 926 | 931 | createWorktree: () => |
| 927 | 932 | Effect.succeed({ |
apps/web/src/components/BranchToolbar.logic.ts+4-52| ... | ... | @@ -1,5 +1,9 @@ |
| 1 | 1 | import type { GitBranch } from "@t3tools/contracts"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | export { | |
| 4 | dedupeRemoteBranchesWithLocalMatches, | |
| 5 | deriveLocalBranchNameFromRemoteRef, | |
| 6 | } from "@t3tools/shared/git"; | |
| 3 | 7 | |
| 4 | 8 | export const EnvMode = Schema.Literals(["local", "worktree"]); |
| 5 | 9 | export type EnvMode = typeof EnvMode.Type; |
| ... | ... | @@ -43,58 +47,6 @@ export function resolveBranchToolbarValue(input: { |
| 43 | 47 | return currentGitBranch ?? activeThreadBranch; |
| 44 | 48 | } |
| 45 | 49 | |
| 46 | export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { | |
| 47 | const firstSeparatorIndex = branchName.indexOf("/"); | |
| 48 | if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) { | |
| 49 | return branchName; | |
| 50 | } | |
| 51 | return branchName.slice(firstSeparatorIndex + 1); | |
| 52 | } | |
| 53 | ||
| 54 | function deriveLocalBranchNameCandidatesFromRemoteRef( | |
| 55 | branchName: string, | |
| 56 | remoteName?: string, | |
| 57 | ): ReadonlyArray<string> { | |
| 58 | const candidates = new Set<string>(); | |
| 59 | const firstSlashCandidate = deriveLocalBranchNameFromRemoteRef(branchName); | |
| 60 | if (firstSlashCandidate.length > 0) { | |
| 61 | candidates.add(firstSlashCandidate); | |
| 62 | } | |
| 63 | ||
| 64 | if (remoteName) { | |
| 65 | const remotePrefix = `${remoteName}/`; | |
| 66 | if (branchName.startsWith(remotePrefix) && branchName.length > remotePrefix.length) { | |
| 67 | candidates.add(branchName.slice(remotePrefix.length)); | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | return [...candidates]; | |
| 72 | } | |
| 73 | ||
| 74 | export function dedupeRemoteBranchesWithLocalMatches( | |
| 75 | branches: ReadonlyArray<GitBranch>, | |
| 76 | ): ReadonlyArray<GitBranch> { | |
| 77 | const localBranchNames = new Set( | |
| 78 | branches.filter((branch) => !branch.isRemote).map((branch) => branch.name), | |
| 79 | ); | |
| 80 | ||
| 81 | return branches.filter((branch) => { | |
| 82 | if (!branch.isRemote) { | |
| 83 | return true; | |
| 84 | } | |
| 85 | ||
| 86 | if (branch.remoteName !== "origin") { | |
| 87 | return true; | |
| 88 | } | |
| 89 | ||
| 90 | const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef( | |
| 91 | branch.name, | |
| 92 | branch.remoteName, | |
| 93 | ); | |
| 94 | return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate)); | |
| 95 | }); | |
| 96 | } | |
| 97 | ||
| 98 | 50 | export function resolveBranchSelectionTarget(input: { |
| 99 | 51 | activeProjectCwd: string; |
| 100 | 52 | activeWorktreePath: string | null; |
apps/web/src/components/BranchToolbarBranchSelector.tsx+86-10| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import type { GitBranch } from "@t3tools/contracts"; |
| 2 | import { useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 2 | import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 3 | 3 | import { useVirtualizer } from "@tanstack/react-virtual"; |
| 4 | 4 | import { ChevronDownIcon } from "lucide-react"; |
| 5 | 5 | import { |
| ... | ... | @@ -15,7 +15,7 @@ import { |
| 15 | 15 | } from "react"; |
| 16 | 16 | |
| 17 | 17 | import { |
| 18 | gitBranchesQueryOptions, | |
| 18 | gitBranchSearchInfiniteQueryOptions, | |
| 19 | 19 | gitQueryKeys, |
| 20 | 20 | gitStatusQueryOptions, |
| 21 | 21 | invalidateGitQueries, |
| ... | ... | @@ -23,7 +23,6 @@ import { |
| 23 | 23 | import { readNativeApi } from "../nativeApi"; |
| 24 | 24 | import { parsePullRequestReference } from "../pullRequestReference"; |
| 25 | 25 | import { |
| 26 | dedupeRemoteBranchesWithLocalMatches, | |
| 27 | 26 | deriveLocalBranchNameFromRemoteRef, |
| 28 | 27 | EnvMode, |
| 29 | 28 | resolveBranchSelectionTarget, |
| ... | ... | @@ -38,6 +37,7 @@ import { |
| 38 | 37 | ComboboxItem, |
| 39 | 38 | ComboboxList, |
| 40 | 39 | ComboboxPopup, |
| 40 | ComboboxStatus, | |
| 41 | 41 | ComboboxTrigger, |
| 42 | 42 | } from "./ui/combobox"; |
| 43 | 43 | import { toastManager } from "./ui/toast"; |
| ... | ... | @@ -89,11 +89,33 @@ export function BranchToolbarBranchSelector({ |
| 89 | 89 | const [branchQuery, setBranchQuery] = useState(""); |
| 90 | 90 | const deferredBranchQuery = useDeferredValue(branchQuery); |
| 91 | 91 | |
| 92 | const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); | |
| 93 | 92 | const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd)); |
| 93 | const trimmedBranchQuery = branchQuery.trim(); | |
| 94 | const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); | |
| 95 | ||
| 96 | useEffect(() => { | |
| 97 | if (!branchCwd) return; | |
| 98 | void queryClient.prefetchInfiniteQuery( | |
| 99 | gitBranchSearchInfiniteQueryOptions({ cwd: branchCwd, query: "" }), | |
| 100 | ); | |
| 101 | }, [branchCwd, queryClient]); | |
| 102 | ||
| 103 | const { | |
| 104 | data: branchesSearchData, | |
| 105 | fetchNextPage, | |
| 106 | hasNextPage, | |
| 107 | isFetchingNextPage, | |
| 108 | isPending: isBranchesSearchPending, | |
| 109 | } = useInfiniteQuery( | |
| 110 | gitBranchSearchInfiniteQueryOptions({ | |
| 111 | cwd: branchCwd, | |
| 112 | query: deferredTrimmedBranchQuery, | |
| 113 | enabled: isBranchMenuOpen, | |
| 114 | }), | |
| 115 | ); | |
| 94 | 116 | const branches = useMemo( |
| 95 | () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []), | |
| 96 | [branchesQuery.data?.branches], | |
| 117 | () => branchesSearchData?.pages.flatMap((page) => page.branches) ?? [], | |
| 118 | [branchesSearchData?.pages], | |
| 97 | 119 | ); |
| 98 | 120 | const currentGitBranch = |
| 99 | 121 | branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null; |
| ... | ... | @@ -108,8 +130,6 @@ export function BranchToolbarBranchSelector({ |
| 108 | 130 | () => new Map(branches.map((branch) => [branch.name, branch] as const)), |
| 109 | 131 | [branches], |
| 110 | 132 | ); |
| 111 | const trimmedBranchQuery = branchQuery.trim(); | |
| 112 | const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); | |
| 113 | 133 | const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase(); |
| 114 | 134 | const prReference = parsePullRequestReference(trimmedBranchQuery); |
| 115 | 135 | const isSelectingWorktreeBase = |
| ... | ... | @@ -156,6 +176,14 @@ export function BranchToolbarBranchSelector({ |
| 156 | 176 | ); |
| 157 | 177 | const [isBranchActionPending, startBranchActionTransition] = useTransition(); |
| 158 | 178 | const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; |
| 179 | const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0; | |
| 180 | const branchStatusText = isBranchesSearchPending | |
| 181 | ? "Loading branches..." | |
| 182 | : isFetchingNextPage | |
| 183 | ? "Loading more branches..." | |
| 184 | : hasNextPage | |
| 185 | ? `Showing ${branches.length} of ${totalBranchCount} branches` | |
| 186 | : null; | |
| 159 | 187 | |
| 160 | 188 | const runBranchAction = (action: () => Promise<void>) => { |
| 161 | 189 | startBranchActionTransition(async () => { |
| ... | ... | @@ -213,7 +241,7 @@ export function BranchToolbarBranchSelector({ |
| 213 | 241 | |
| 214 | 242 | let nextBranchName = selectedBranchName; |
| 215 | 243 | if (branch.isRemote) { |
| 216 | const status = await api.git.status({ cwd: branchCwd }).catch(() => null); | |
| 244 | const status = await api.git.status({ cwd: selectionTarget.checkoutCwd }).catch(() => null); | |
| 217 | 245 | if (status?.branch) { |
| 218 | 246 | nextBranchName = status.branch; |
| 219 | 247 | } |
| ... | ... | @@ -295,6 +323,24 @@ export function BranchToolbarBranchSelector({ |
| 295 | 323 | ); |
| 296 | 324 | |
| 297 | 325 | const branchListScrollElementRef = useRef<HTMLDivElement | null>(null); |
| 326 | const maybeFetchNextBranchPage = useCallback(() => { | |
| 327 | if (!isBranchMenuOpen || !hasNextPage || isFetchingNextPage) { | |
| 328 | return; | |
| 329 | } | |
| 330 | ||
| 331 | const scrollElement = branchListScrollElementRef.current; | |
| 332 | if (!scrollElement) { | |
| 333 | return; | |
| 334 | } | |
| 335 | ||
| 336 | const distanceFromBottom = | |
| 337 | scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight; | |
| 338 | if (distanceFromBottom > 96) { | |
| 339 | return; | |
| 340 | } | |
| 341 | ||
| 342 | void fetchNextPage().catch(() => undefined); | |
| 343 | }, [fetchNextPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]); | |
| 298 | 344 | const branchListVirtualizer = useVirtualizer({ |
| 299 | 345 | count: filteredBranchPickerItems.length, |
| 300 | 346 | estimateSize: (index) => |
| ... | ... | @@ -331,6 +377,35 @@ export function BranchToolbarBranchSelector({ |
| 331 | 377 | shouldVirtualizeBranchList, |
| 332 | 378 | ]); |
| 333 | 379 | |
| 380 | useEffect(() => { | |
| 381 | if (!isBranchMenuOpen) { | |
| 382 | return; | |
| 383 | } | |
| 384 | ||
| 385 | branchListScrollElementRef.current?.scrollTo({ top: 0 }); | |
| 386 | }, [deferredTrimmedBranchQuery, isBranchMenuOpen]); | |
| 387 | ||
| 388 | useEffect(() => { | |
| 389 | const scrollElement = branchListScrollElementRef.current; | |
| 390 | if (!scrollElement || !isBranchMenuOpen) { | |
| 391 | return; | |
| 392 | } | |
| 393 | ||
| 394 | const handleScroll = () => { | |
| 395 | maybeFetchNextBranchPage(); | |
| 396 | }; | |
| 397 | ||
| 398 | scrollElement.addEventListener("scroll", handleScroll, { passive: true }); | |
| 399 | handleScroll(); | |
| 400 | return () => { | |
| 401 | scrollElement.removeEventListener("scroll", handleScroll); | |
| 402 | }; | |
| 403 | }, [isBranchMenuOpen, maybeFetchNextBranchPage]); | |
| 404 | ||
| 405 | useEffect(() => { | |
| 406 | maybeFetchNextBranchPage(); | |
| 407 | }, [branches.length, maybeFetchNextBranchPage]); | |
| 408 | ||
| 334 | 409 | const triggerLabel = getBranchTriggerLabel({ |
| 335 | 410 | activeWorktreePath, |
| 336 | 411 | effectiveEnvMode, |
| ... | ... | @@ -425,7 +500,7 @@ export function BranchToolbarBranchSelector({ |
| 425 | 500 | <ComboboxTrigger |
| 426 | 501 | render={<Button variant="ghost" size="xs" />} |
| 427 | 502 | className="text-muted-foreground/70 hover:text-foreground/80" |
| 428 | disabled={(branchesQuery.isLoading && branches.length === 0) || isBranchActionPending} | |
| 503 | disabled={(isBranchesSearchPending && branches.length === 0) || isBranchActionPending} | |
| 429 | 504 | > |
| 430 | 505 | <span className="max-w-[240px] truncate">{triggerLabel}</span> |
| 431 | 506 | <ChevronDownIcon /> |
| ... | ... | @@ -468,6 +543,7 @@ export function BranchToolbarBranchSelector({ |
| 468 | 543 | filteredBranchPickerItems.map((itemValue, index) => renderPickerItem(itemValue, index)) |
| 469 | 544 | )} |
| 470 | 545 | </ComboboxList> |
| 546 | {branchStatusText ? <ComboboxStatus>{branchStatusText}</ComboboxStatus> : null} | |
| 471 | 547 | </ComboboxPopup> |
| 472 | 548 | </Combobox> |
| 473 | 549 | ); |
apps/web/src/components/ChatView.browser.tsx+5| ... | ... | @@ -632,6 +632,8 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { |
| 632 | 632 | return { |
| 633 | 633 | isRepo: true, |
| 634 | 634 | hasOriginRemote: true, |
| 635 | nextCursor: null, | |
| 636 | totalCount: 1, | |
| 635 | 637 | branches: [ |
| 636 | 638 | { |
| 637 | 639 | name: "main", |
| ... | ... | @@ -644,6 +646,9 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { |
| 644 | 646 | } |
| 645 | 647 | if (tag === WS_METHODS.gitStatus) { |
| 646 | 648 | return { |
| 649 | isRepo: true, | |
| 650 | hasOriginRemote: true, | |
| 651 | isDefaultBranch: true, | |
| 647 | 652 | branch: "main", |
| 648 | 653 | hasWorkingTreeChanges: false, |
| 649 | 654 | workingTree: { |
apps/web/src/components/ChatView.tsx+3-3| ... | ... | @@ -26,7 +26,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr |
| 26 | 26 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 27 | 27 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 28 | 28 | import { useNavigate, useSearch } from "@tanstack/react-router"; |
| 29 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; | |
| 29 | import { gitCreateWorktreeMutationOptions, gitStatusQueryOptions } from "~/lib/gitReactQuery"; | |
| 30 | 30 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 31 | 31 | import { isElectron } from "../env"; |
| 32 | 32 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| ... | ... | @@ -1201,7 +1201,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1201 | 1201 | (debouncerState) => ({ isPending: debouncerState.isPending }), |
| 1202 | 1202 | ); |
| 1203 | 1203 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; |
| 1204 | const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); | |
| 1204 | const gitStatusQuery = useQuery(gitStatusQueryOptions(gitCwd)); | |
| 1205 | 1205 | const keybindings = useServerKeybindings(); |
| 1206 | 1206 | const availableEditors = useServerAvailableEditors(); |
| 1207 | 1207 | const modelOptionsByProvider = useMemo( |
| ... | ... | @@ -1338,7 +1338,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1338 | 1338 | }); |
| 1339 | 1339 | }, [activeProjectCwd, activeThreadWorktreePath]); |
| 1340 | 1340 | // Default true while loading to avoid toolbar flicker. |
| 1341 | const isGitRepo = branchesQuery.data?.isRepo ?? true; | |
| 1341 | const isGitRepo = gitStatusQuery.data?.isRepo ?? true; | |
| 1342 | 1342 | const terminalShortcutLabelOptions = useMemo( |
| 1343 | 1343 | () => ({ |
| 1344 | 1344 | context: { |
apps/web/src/components/DiffPanel.tsx+3-3| ... | ... | @@ -19,7 +19,7 @@ import { |
| 19 | 19 | useState, |
| 20 | 20 | } from "react"; |
| 21 | 21 | import { openInPreferredEditor } from "../editorPreferences"; |
| 22 | import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; | |
| 22 | import { gitStatusQueryOptions } from "~/lib/gitReactQuery"; | |
| 23 | 23 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| 24 | 24 | import { cn } from "~/lib/utils"; |
| 25 | 25 | import { readNativeApi } from "../nativeApi"; |
| ... | ... | @@ -189,8 +189,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 189 | 189 | activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, |
| 190 | 190 | ); |
| 191 | 191 | const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; |
| 192 | const gitBranchesQuery = useQuery(gitBranchesQueryOptions(activeCwd ?? null)); | |
| 193 | const isGitRepo = gitBranchesQuery.data?.isRepo ?? true; | |
| 192 | const gitStatusQuery = useQuery(gitStatusQueryOptions(activeCwd ?? null)); | |
| 193 | const isGitRepo = gitStatusQuery.data?.isRepo ?? true; | |
| 194 | 194 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 195 | 195 | useTurnDiffSummaries(activeThread); |
| 196 | 196 | const orderedTurnDiffSummaries = useMemo( |
apps/web/src/components/GitActionsControl.logic.test.ts+3| ... | ... | @@ -13,6 +13,9 @@ import { |
| 13 | 13 | |
| 14 | 14 | function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult { |
| 15 | 15 | return { |
| 16 | isRepo: true, | |
| 17 | hasOriginRemote: true, | |
| 18 | isDefaultBranch: false, | |
| 16 | 19 | branch: "feature/test", |
| 17 | 20 | hasWorkingTreeChanges: false, |
| 18 | 21 | workingTree: { |
apps/web/src/components/GitActionsControl.tsx+5-26| ... | ... | @@ -41,14 +41,12 @@ import { Textarea } from "~/components/ui/textarea"; |
| 41 | 41 | import { toastManager, type ThreadToastData } from "~/components/ui/toast"; |
| 42 | 42 | import { openInPreferredEditor } from "~/editorPreferences"; |
| 43 | 43 | import { |
| 44 | gitBranchesQueryOptions, | |
| 45 | 44 | gitInitMutationOptions, |
| 46 | 45 | gitMutationKeys, |
| 47 | 46 | gitPullMutationOptions, |
| 48 | 47 | gitRunStackedActionMutationOptions, |
| 49 | 48 | gitStatusQueryOptions, |
| 50 | 49 | invalidateGitStatusQuery, |
| 51 | invalidateGitQueries, | |
| 52 | 50 | } from "~/lib/gitReactQuery"; |
| 53 | 51 | import { newCommandId, randomUUID } from "~/lib/utils"; |
| 54 | 52 | import { resolvePathLinkTarget } from "~/terminal-links"; |
| ... | ... | @@ -278,21 +276,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 278 | 276 | ); |
| 279 | 277 | |
| 280 | 278 | const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); |
| 281 | ||
| 282 | const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd)); | |
| 283 | 279 | // Default to true while loading so we don't flash init controls. |
| 284 | const isRepo = branchList?.isRepo ?? true; | |
| 285 | const hasOriginRemote = branchList?.hasOriginRemote ?? false; | |
| 286 | const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null; | |
| 287 | const isGitStatusOutOfSync = | |
| 288 | !!gitStatus?.branch && !!currentBranch && gitStatus.branch !== currentBranch; | |
| 289 | ||
| 290 | useEffect(() => { | |
| 291 | if (!isGitStatusOutOfSync) return; | |
| 292 | void invalidateGitQueries(queryClient, { cwd: gitCwd }); | |
| 293 | }, [gitCwd, isGitStatusOutOfSync, queryClient]); | |
| 294 | ||
| 295 | const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus; | |
| 280 | const isRepo = gitStatus?.isRepo ?? true; | |
| 281 | const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; | |
| 282 | const gitStatusForActions = gitStatus; | |
| 296 | 283 | |
| 297 | 284 | const allFiles = gitStatusForActions?.workingTree.files ?? []; |
| 298 | 285 | const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); |
| ... | ... | @@ -336,11 +323,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 336 | 323 | ]); |
| 337 | 324 | |
| 338 | 325 | const isDefaultBranch = useMemo(() => { |
| 339 | const branchName = gitStatusForActions?.branch; | |
| 340 | if (!branchName) return false; | |
| 341 | const current = branchList?.branches.find((branch) => branch.name === branchName); | |
| 342 | return current?.isDefault ?? (branchName === "main" || branchName === "master"); | |
| 343 | }, [branchList?.branches, gitStatusForActions?.branch]); | |
| 326 | return gitStatusForActions?.isDefaultBranch ?? false; | |
| 327 | }, [gitStatusForActions?.isDefaultBranch]); | |
| 344 | 328 | |
| 345 | 329 | const gitActionMenuItems = useMemo( |
| 346 | 330 | () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasOriginRemote), |
| ... | ... | @@ -881,11 +865,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 881 | 865 | Behind upstream. Pull/rebase first. |
| 882 | 866 | </p> |
| 883 | 867 | )} |
| 884 | {isGitStatusOutOfSync && ( | |
| 885 | <p className="px-2 py-1.5 text-xs text-muted-foreground"> | |
| 886 | Refreshing git status... | |
| 887 | </p> | |
| 888 | )} | |
| 889 | 868 | {gitStatusError && ( |
| 890 | 869 | <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError.message}</p> |
| 891 | 870 | )} |
apps/web/src/components/KeybindingsToast.browser.tsx+5| ... | ... | @@ -157,11 +157,16 @@ function resolveWsRpc(tag: string): unknown { |
| 157 | 157 | return { |
| 158 | 158 | isRepo: true, |
| 159 | 159 | hasOriginRemote: true, |
| 160 | nextCursor: null, | |
| 161 | totalCount: 1, | |
| 160 | 162 | branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }], |
| 161 | 163 | }; |
| 162 | 164 | } |
| 163 | 165 | if (tag === WS_METHODS.gitStatus) { |
| 164 | 166 | return { |
| 167 | isRepo: true, | |
| 168 | hasOriginRemote: true, | |
| 169 | isDefaultBranch: true, | |
| 165 | 170 | branch: "main", |
| 166 | 171 | hasWorkingTreeChanges: false, |
| 167 | 172 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
apps/web/src/lib/gitReactQuery.test.ts+43-9| ... | ... | @@ -9,8 +9,11 @@ vi.mock("../wsRpcClient", () => ({ |
| 9 | 9 | getWsRpcClient: vi.fn(), |
| 10 | 10 | })); |
| 11 | 11 | |
| 12 | import type { InfiniteData } from "@tanstack/react-query"; | |
| 13 | import type { GitListBranchesResult } from "@t3tools/contracts"; | |
| 14 | ||
| 12 | 15 | import { |
| 13 | gitBranchesQueryOptions, | |
| 16 | gitBranchSearchInfiniteQueryOptions, | |
| 14 | 17 | gitMutationKeys, |
| 15 | 18 | gitQueryKeys, |
| 16 | 19 | gitPreparePullRequestThreadMutationOptions, |
| ... | ... | @@ -21,6 +24,19 @@ import { |
| 21 | 24 | invalidateGitQueries, |
| 22 | 25 | } from "./gitReactQuery"; |
| 23 | 26 | |
| 27 | const BRANCH_QUERY_RESULT: GitListBranchesResult = { | |
| 28 | branches: [], | |
| 29 | isRepo: true, | |
| 30 | hasOriginRemote: true, | |
| 31 | nextCursor: null, | |
| 32 | totalCount: 0, | |
| 33 | }; | |
| 34 | ||
| 35 | const BRANCH_SEARCH_RESULT: InfiniteData<GitListBranchesResult, number> = { | |
| 36 | pages: [BRANCH_QUERY_RESULT], | |
| 37 | pageParams: [0], | |
| 38 | }; | |
| 39 | ||
| 24 | 40 | describe("gitMutationKeys", () => { |
| 25 | 41 | it("scopes stacked action keys by cwd", () => { |
| 26 | 42 | expect(gitMutationKeys.runStackedAction("/repo/a")).not.toEqual( |
| ... | ... | @@ -69,9 +85,21 @@ describe("invalidateGitQueries", () => { |
| 69 | 85 | const queryClient = new QueryClient(); |
| 70 | 86 | |
| 71 | 87 | queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" }); |
| 72 | queryClient.setQueryData(gitQueryKeys.branches("/repo/a"), { ok: "a-branches" }); | |
| 88 | queryClient.setQueryData( | |
| 89 | gitBranchSearchInfiniteQueryOptions({ | |
| 90 | cwd: "/repo/a", | |
| 91 | query: "feature", | |
| 92 | }).queryKey, | |
| 93 | BRANCH_SEARCH_RESULT, | |
| 94 | ); | |
| 73 | 95 | queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" }); |
| 74 | queryClient.setQueryData(gitQueryKeys.branches("/repo/b"), { ok: "b-branches" }); | |
| 96 | queryClient.setQueryData( | |
| 97 | gitBranchSearchInfiniteQueryOptions({ | |
| 98 | cwd: "/repo/b", | |
| 99 | query: "feature", | |
| 100 | }).queryKey, | |
| 101 | BRANCH_SEARCH_RESULT, | |
| 102 | ); | |
| 75 | 103 | |
| 76 | 104 | await invalidateGitQueries(queryClient, { cwd: "/repo/a" }); |
| 77 | 105 | |
| ... | ... | @@ -79,13 +107,23 @@ describe("invalidateGitQueries", () => { |
| 79 | 107 | queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated, |
| 80 | 108 | ).toBe(true); |
| 81 | 109 | expect( |
| 82 | queryClient.getQueryState(gitBranchesQueryOptions("/repo/a").queryKey)?.isInvalidated, | |
| 110 | queryClient.getQueryState( | |
| 111 | gitBranchSearchInfiniteQueryOptions({ | |
| 112 | cwd: "/repo/a", | |
| 113 | query: "feature", | |
| 114 | }).queryKey, | |
| 115 | )?.isInvalidated, | |
| 83 | 116 | ).toBe(true); |
| 84 | 117 | expect( |
| 85 | 118 | queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated, |
| 86 | 119 | ).toBe(false); |
| 87 | 120 | expect( |
| 88 | queryClient.getQueryState(gitBranchesQueryOptions("/repo/b").queryKey)?.isInvalidated, | |
| 121 | queryClient.getQueryState( | |
| 122 | gitBranchSearchInfiniteQueryOptions({ | |
| 123 | cwd: "/repo/b", | |
| 124 | query: "feature", | |
| 125 | }).queryKey, | |
| 126 | )?.isInvalidated, | |
| 89 | 127 | ).toBe(false); |
| 90 | 128 | }); |
| 91 | 129 | }); |
| ... | ... | @@ -95,7 +133,6 @@ describe("invalidateGitStatusQuery", () => { |
| 95 | 133 | const queryClient = new QueryClient(); |
| 96 | 134 | |
| 97 | 135 | queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" }); |
| 98 | queryClient.setQueryData(gitQueryKeys.branches("/repo/a"), { ok: "a-branches" }); | |
| 99 | 136 | queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" }); |
| 100 | 137 | |
| 101 | 138 | await invalidateGitStatusQuery(queryClient, "/repo/a"); |
| ... | ... | @@ -103,9 +140,6 @@ describe("invalidateGitStatusQuery", () => { |
| 103 | 140 | expect( |
| 104 | 141 | queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated, |
| 105 | 142 | ).toBe(true); |
| 106 | expect( | |
| 107 | queryClient.getQueryState(gitBranchesQueryOptions("/repo/a").queryKey)?.isInvalidated, | |
| 108 | ).toBe(false); | |
| 109 | 143 | expect( |
| 110 | 144 | queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated, |
| 111 | 145 | ).toBe(false); |
apps/web/src/lib/gitReactQuery.ts+29-8| ... | ... | @@ -1,5 +1,10 @@ |
| 1 | 1 | import { type GitActionProgressEvent, type GitStackedAction } from "@t3tools/contracts"; |
| 2 | import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; | |
| 2 | import { | |
| 3 | infiniteQueryOptions, | |
| 4 | mutationOptions, | |
| 5 | queryOptions, | |
| 6 | type QueryClient, | |
| 7 | } from "@tanstack/react-query"; | |
| 3 | 8 | import { ensureNativeApi } from "../nativeApi"; |
| 4 | 9 | import { getWsRpcClient } from "../wsRpcClient"; |
| 5 | 10 | |
| ... | ... | @@ -7,11 +12,14 @@ const GIT_STATUS_STALE_TIME_MS = 5_000; |
| 7 | 12 | const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000; |
| 8 | 13 | const GIT_BRANCHES_STALE_TIME_MS = 15_000; |
| 9 | 14 | const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000; |
| 15 | const GIT_BRANCHES_PAGE_SIZE = 100; | |
| 10 | 16 | |
| 11 | 17 | export const gitQueryKeys = { |
| 12 | 18 | all: ["git"] as const, |
| 13 | 19 | status: (cwd: string | null) => ["git", "status", cwd] as const, |
| 14 | 20 | branches: (cwd: string | null) => ["git", "branches", cwd] as const, |
| 21 | branchSearch: (cwd: string | null, query: string) => | |
| 22 | ["git", "branches", cwd, "search", query] as const, | |
| 15 | 23 | }; |
| 16 | 24 | |
| 17 | 25 | export const gitMutationKeys = { |
| ... | ... | @@ -59,15 +67,28 @@ export function gitStatusQueryOptions(cwd: string | null) { |
| 59 | 67 | }); |
| 60 | 68 | } |
| 61 | 69 | |
| 62 | export function gitBranchesQueryOptions(cwd: string | null) { | |
| 63 | return queryOptions({ | |
| 64 | queryKey: gitQueryKeys.branches(cwd), | |
| 65 | queryFn: async () => { | |
| 70 | export function gitBranchSearchInfiniteQueryOptions(input: { | |
| 71 | cwd: string | null; | |
| 72 | query: string; | |
| 73 | enabled?: boolean; | |
| 74 | }) { | |
| 75 | const normalizedQuery = input.query.trim(); | |
| 76 | ||
| 77 | return infiniteQueryOptions({ | |
| 78 | queryKey: gitQueryKeys.branchSearch(input.cwd, normalizedQuery), | |
| 79 | initialPageParam: 0, | |
| 80 | queryFn: async ({ pageParam }) => { | |
| 66 | 81 | const api = ensureNativeApi(); |
| 67 | if (!cwd) throw new Error("Git branches are unavailable."); | |
| 68 | return api.git.listBranches({ cwd }); | |
| 82 | if (!input.cwd) throw new Error("Git branches are unavailable."); | |
| 83 | return api.git.listBranches({ | |
| 84 | cwd: input.cwd, | |
| 85 | ...(normalizedQuery.length > 0 ? { query: normalizedQuery } : {}), | |
| 86 | cursor: pageParam, | |
| 87 | limit: GIT_BRANCHES_PAGE_SIZE, | |
| 88 | }); | |
| 69 | 89 | }, |
| 70 | enabled: cwd !== null, | |
| 90 | getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, | |
| 91 | enabled: input.cwd !== null && (input.enabled ?? true), | |
| 71 | 92 | staleTime: GIT_BRANCHES_STALE_TIME_MS, |
| 72 | 93 | refetchOnWindowFocus: true, |
| 73 | 94 | refetchOnReconnect: true, |
packages/contracts/src/git.ts+12-1| ... | ... | @@ -2,6 +2,7 @@ import { Schema } from "effect"; |
| 2 | 2 | import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; |
| 3 | 3 | |
| 4 | 4 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; |
| 5 | const GIT_LIST_BRANCHES_MAX_LIMIT = 200; | |
| 5 | 6 | |
| 6 | 7 | // Domain Types |
| 7 | 8 | |
| ... | ... | @@ -120,6 +121,11 @@ export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; |
| 120 | 121 | |
| 121 | 122 | export const GitListBranchesInput = Schema.Struct({ |
| 122 | 123 | cwd: TrimmedNonEmptyStringSchema, |
| 124 | query: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(256))), | |
| 125 | cursor: Schema.optional(NonNegativeInt), | |
| 126 | limit: Schema.optional( | |
| 127 | PositiveInt.check(Schema.isLessThanOrEqualTo(GIT_LIST_BRANCHES_MAX_LIMIT)), | |
| 128 | ), | |
| 123 | 129 | }); |
| 124 | 130 | export type GitListBranchesInput = typeof GitListBranchesInput.Type; |
| 125 | 131 | |
| ... | ... | @@ -180,7 +186,10 @@ const GitStatusPr = Schema.Struct({ |
| 180 | 186 | }); |
| 181 | 187 | |
| 182 | 188 | export const GitStatusResult = Schema.Struct({ |
| 183 | branch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), | |
| 189 | isRepo: Schema.Boolean, | |
| 190 | hasOriginRemote: Schema.Boolean, | |
| 191 | isDefaultBranch: Schema.Boolean, | |
| 192 | branch: Schema.NullOr(TrimmedNonEmptyStringSchema), | |
| 184 | 193 | hasWorkingTreeChanges: Schema.Boolean, |
| 185 | 194 | workingTree: Schema.Struct({ |
| 186 | 195 | files: Schema.Array( |
| ... | ... | @@ -204,6 +213,8 @@ export const GitListBranchesResult = Schema.Struct({ |
| 204 | 213 | branches: Schema.Array(GitBranch), |
| 205 | 214 | isRepo: Schema.Boolean, |
| 206 | 215 | hasOriginRemote: Schema.Boolean, |
| 216 | nextCursor: NonNegativeInt.pipe(Schema.NullOr), | |
| 217 | totalCount: NonNegativeInt, | |
| 207 | 218 | }); |
| 208 | 219 | export type GitListBranchesResult = typeof GitListBranchesResult.Type; |
| 209 | 220 |
packages/shared/src/git.ts+60| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import type { GitBranch } from "@t3tools/contracts"; | |
| 2 | ||
| 1 | 3 | /** |
| 2 | 4 | * Sanitize an arbitrary string into a valid, lowercase git branch fragment. |
| 3 | 5 | * Strips quotes, collapses separators, limits to 64 chars. |
| ... | ... | @@ -59,3 +61,61 @@ export function resolveAutoFeatureBranchName( |
| 59 | 61 | |
| 60 | 62 | return `${resolvedBase}-${suffix}`; |
| 61 | 63 | } |
| 64 | ||
| 65 | /** | |
| 66 | * Strip the remote prefix from a remote ref such as `origin/feature/demo`. | |
| 67 | */ | |
| 68 | export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { | |
| 69 | const firstSeparatorIndex = branchName.indexOf("/"); | |
| 70 | if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) { | |
| 71 | return branchName; | |
| 72 | } | |
| 73 | return branchName.slice(firstSeparatorIndex + 1); | |
| 74 | } | |
| 75 | ||
| 76 | function deriveLocalBranchNameCandidatesFromRemoteRef( | |
| 77 | branchName: string, | |
| 78 | remoteName?: string, | |
| 79 | ): ReadonlyArray<string> { | |
| 80 | const candidates = new Set<string>(); | |
| 81 | const firstSlashCandidate = deriveLocalBranchNameFromRemoteRef(branchName); | |
| 82 | if (firstSlashCandidate.length > 0) { | |
| 83 | candidates.add(firstSlashCandidate); | |
| 84 | } | |
| 85 | ||
| 86 | if (remoteName) { | |
| 87 | const remotePrefix = `${remoteName}/`; | |
| 88 | if (branchName.startsWith(remotePrefix) && branchName.length > remotePrefix.length) { | |
| 89 | candidates.add(branchName.slice(remotePrefix.length)); | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | return [...candidates]; | |
| 94 | } | |
| 95 | ||
| 96 | /** | |
| 97 | * Hide `origin/*` remote refs when a matching local branch already exists. | |
| 98 | */ | |
| 99 | export function dedupeRemoteBranchesWithLocalMatches( | |
| 100 | branches: ReadonlyArray<GitBranch>, | |
| 101 | ): ReadonlyArray<GitBranch> { | |
| 102 | const localBranchNames = new Set( | |
| 103 | branches.filter((branch) => !branch.isRemote).map((branch) => branch.name), | |
| 104 | ); | |
| 105 | ||
| 106 | return branches.filter((branch) => { | |
| 107 | if (!branch.isRemote) { | |
| 108 | return true; | |
| 109 | } | |
| 110 | ||
| 111 | if (branch.remoteName !== "origin") { | |
| 112 | return true; | |
| 113 | } | |
| 114 | ||
| 115 | const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef( | |
| 116 | branch.name, | |
| 117 | branch.remoteName, | |
| 118 | ); | |
| 119 | return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate)); | |
| 120 | }); | |
| 121 | } |
scripts/package.json-2| ... | ... | @@ -4,8 +4,6 @@ |
| 4 | 4 | "type": "module", |
| 5 | 5 | "scripts": { |
| 6 | 6 | "prepare": "effect-language-service patch", |
| 7 | "claude-fast-mode-probe": "bun run claude-fast-mode-probe.ts", | |
| 8 | "claude-haiku-thinking-probe": "bun run claude-haiku-thinking-probe.ts", | |
| 9 | 7 | "typecheck": "tsc --noEmit", |
| 10 | 8 | "test": "vitest run" |
| 11 | 9 | }, |