| 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,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 | it.effect("parses separate branch names when column.ui is always enabled", () => | 419 | it.effect("parses separate branch names when column.ui is always enabled", () => |
| 390 | Effect.gen(function* () { | 420 | Effect.gen(function* () { |
| 391 | const tmp = yield* makeTmpDir(); | 421 | const tmp = yield* makeTmpDir(); |
| ... | @@ -531,6 +561,41 @@ it.layer(TestLayer)("git integration", (it) => { | ... | @@ -531,6 +561,41 @@ it.layer(TestLayer)("git integration", (it) => { |
| 531 | expect(remoteBranch?.remoteName).toBe(remoteName); | 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 | // ── checkoutGitBranch ── | 601 | // ── checkoutGitBranch ── |
| ... | @@ -737,6 +802,9 @@ it.layer(TestLayer)("git integration", (it) => { | ... | @@ -737,6 +802,9 @@ it.layer(TestLayer)("git integration", (it) => { |
| 737 | ) { | 802 | ) { |
| 738 | return ok(); | 803 | return ok(); |
| 739 | } | 804 | } |
| 805 | if (input.operation === "GitCore.statusDetails.defaultRef") { | ||
| 806 | return ok("refs/remotes/origin/main\n"); | ||
| 807 | } | ||
| 740 | return Effect.fail( | 808 | return Effect.fail( |
| 741 | new GitCommandError({ | 809 | new GitCommandError({ |
| 742 | operation: input.operation, | 810 | operation: input.operation, |
| ... | @@ -800,6 +868,9 @@ it.layer(TestLayer)("git integration", (it) => { | ... | @@ -800,6 +868,9 @@ it.layer(TestLayer)("git integration", (it) => { |
| 800 | ) { | 868 | ) { |
| 801 | return ok(); | 869 | return ok(); |
| 802 | } | 870 | } |
| 871 | if (input.operation === "GitCore.statusDetails.defaultRef") { | ||
| 872 | return ok("refs/remotes/origin/main\n"); | ||
| 873 | } | ||
| 803 | return Effect.fail( | 874 | return Effect.fail( |
| 804 | new GitCommandError({ | 875 | new GitCommandError({ |
| 805 | operation: input.operation, | 876 | operation: input.operation, |
apps/server/src/git/Layers/GitCore.ts+112-11| ... | @@ -18,7 +18,8 @@ import { | ... | @@ -18,7 +18,8 @@ import { |
| 18 | } from "effect"; | 18 | } from "effect"; |
| 19 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 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 | import { | 23 | import { |
| 23 | GitCore, | 24 | GitCore, |
| 24 | type ExecuteGitProgress, | 25 | type ExecuteGitProgress, |
| ... | @@ -49,6 +50,7 @@ const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); | ... | @@ -49,6 +50,7 @@ const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); |
| 49 | const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); | 50 | const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); |
| 50 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; | 51 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; |
| 51 | const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; | 52 | const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; |
| 53 | const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; | ||
| 52 | 54 | ||
| 53 | type TraceTailState = { | 55 | type TraceTailState = { |
| 54 | processedChars: number; | 56 | processedChars: number; |
| ... | @@ -183,6 +185,40 @@ function parseBranchLine(line: string): { name: string; current: boolean } | nul | ... | @@ -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 | function sanitizeRemoteName(value: string): string { | 222 | function sanitizeRemoteName(value: string): string { |
| 187 | const sanitized = value | 223 | const sanitized = value |
| 188 | .trim() | 224 | .trim() |
| ... | @@ -1101,15 +1137,52 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { | ... | @@ -1101,15 +1137,52 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 1101 | const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { | 1137 | const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { |
| 1102 | yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true })); | 1138 | yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true })); |
| 1103 | 1139 | ||
| 1104 | const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all( | 1140 | const statusResult = yield* executeGit( |
| 1105 | [ | 1141 | "GitCore.statusDetails.status", |
| 1106 | runGitStdout("GitCore.statusDetails.status", cwd, ["status", "--porcelain=2", "--branch"]), | 1142 | cwd, |
| 1107 | runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), | 1143 | ["status", "--porcelain=2", "--branch"], |
| 1108 | runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, ["diff", "--cached", "--numstat"]), | 1144 | { |
| 1109 | ], | 1145 | allowNonZeroExit: true, |
| 1110 | { concurrency: "unbounded" }, | 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 | let branch: string | null = null; | 1186 | let branch: string | null = null; |
| 1114 | let upstreamRef: string | null = null; | 1187 | let upstreamRef: string | null = null; |
| 1115 | let aheadCount = 0; | 1188 | let aheadCount = 0; |
| ... | @@ -1176,6 +1249,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { | ... | @@ -1176,6 +1249,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 1176 | files.sort((a, b) => a.path.localeCompare(b.path)); | 1249 | files.sort((a, b) => a.path.localeCompare(b.path)); |
| 1177 | 1250 | ||
| 1178 | return { | 1251 | return { |
| 1252 | isRepo: true, | ||
| 1253 | hasOriginRemote, | ||
| 1254 | isDefaultBranch: | ||
| 1255 | branch !== null && | ||
| 1256 | (branch === defaultBranch || | ||
| 1257 | (defaultBranch === null && (branch === "main" || branch === "master"))), | ||
| 1179 | branch, | 1258 | branch, |
| 1180 | upstreamRef, | 1259 | upstreamRef, |
| 1181 | hasWorkingTreeChanges, | 1260 | hasWorkingTreeChanges, |
| ... | @@ -1193,6 +1272,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { | ... | @@ -1193,6 +1272,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 1193 | const status: GitCoreShape["status"] = (input) => | 1272 | const status: GitCoreShape["status"] = (input) => |
| 1194 | statusDetails(input.cwd).pipe( | 1273 | statusDetails(input.cwd).pipe( |
| 1195 | Effect.map((details) => ({ | 1274 | Effect.map((details) => ({ |
| 1275 | isRepo: details.isRepo, | ||
| 1276 | hasOriginRemote: details.hasOriginRemote, | ||
| 1277 | isDefaultBranch: details.isDefaultBranch, | ||
| 1196 | branch: details.branch, | 1278 | branch: details.branch, |
| 1197 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, | 1279 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, |
| 1198 | workingTree: details.workingTree, | 1280 | workingTree: details.workingTree, |
| ... | @@ -1571,7 +1653,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { | ... | @@ -1571,7 +1653,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 1571 | if (localBranchResult.code !== 0) { | 1653 | if (localBranchResult.code !== 0) { |
| 1572 | const stderr = localBranchResult.stderr.trim(); | 1654 | const stderr = localBranchResult.stderr.trim(); |
| 1573 | if (stderr.toLowerCase().includes("not a git repository")) { | 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 | return yield* createGitCommandError( | 1664 | return yield* createGitCommandError( |
| 1577 | "GitCore.listBranches", | 1665 | "GitCore.listBranches", |
| ... | @@ -1735,9 +1823,22 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { | ... | @@ -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 | const createWorktree: GitCoreShape["createWorktree"] = Effect.fn("createWorktree")( | 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,6 +660,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 660 | }); | 660 | }); |
| 661 | 661 | ||
| 662 | const status = yield* manager.status({ cwd: repoDir }); | 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 | expect(status.branch).toBe("feature/status-open-pr"); | 666 | expect(status.branch).toBe("feature/status-open-pr"); |
| 664 | expect(status.pr).toEqual({ | 667 | expect(status.pr).toEqual({ |
| 665 | number: 13, | 668 | number: 13, |
| ... | @@ -672,6 +675,32 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -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 | it.effect("status briefly caches repeated lookups for the same cwd", () => | 704 | it.effect("status briefly caches repeated lookups for the same cwd", () => |
| 676 | Effect.gen(function* () { | 705 | Effect.gen(function* () { |
| 677 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | 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,6 +5,7 @@ import { Cache, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Ref } f |
| 5 | import { | 5 | import { |
| 6 | GitActionProgressEvent, | 6 | GitActionProgressEvent, |
| 7 | GitActionProgressPhase, | 7 | GitActionProgressPhase, |
| 8 | GitCommandError, | ||
| 8 | GitRunStackedActionResult, | 9 | GitRunStackedActionResult, |
| 9 | GitStackedAction, | 10 | GitStackedAction, |
| 10 | ModelSelection, | 11 | ModelSelection, |
| ... | @@ -22,7 +23,7 @@ import { | ... | @@ -22,7 +23,7 @@ import { |
| 22 | type GitManagerShape, | 23 | type GitManagerShape, |
| 23 | type GitRunStackedActionOptions, | 24 | type GitRunStackedActionOptions, |
| 24 | } from "../Services/GitManager.ts"; | 25 | } from "../Services/GitManager.ts"; |
| 25 | import { GitCore } from "../Services/GitCore.ts"; | 26 | import { GitCore, GitStatusDetails } from "../Services/GitCore.ts"; |
| 26 | import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts"; | 27 | import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts"; |
| 27 | import { TextGeneration } from "../Services/TextGeneration.ts"; | 28 | import { TextGeneration } from "../Services/TextGeneration.ts"; |
| 28 | import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts"; | 29 | import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts"; |
| ... | @@ -39,6 +40,10 @@ type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "act | ... | @@ -39,6 +40,10 @@ type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "act |
| 39 | type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>; | 40 | type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>; |
| 40 | type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect<void, never>; | 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 | interface OpenPrInfo { | 47 | interface OpenPrInfo { |
| 43 | number: number; | 48 | number: number; |
| 44 | title: string; | 49 | title: string; |
| ... | @@ -689,10 +694,25 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { | ... | @@ -689,10 +694,25 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { |
| 689 | const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; | 694 | const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; |
| 690 | const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd); | 695 | const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd); |
| 691 | const readStatus = Effect.fn("readStatus")(function* (cwd: string) { | 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 | const pr = | 714 | const pr = |
| 695 | details.branch !== null | 715 | details.isRepo && details.branch !== null |
| 696 | ? yield* findLatestPr(cwd, { | 716 | ? yield* findLatestPr(cwd, { |
| 697 | branch: details.branch, | 717 | branch: details.branch, |
| 698 | upstreamRef: details.upstreamRef, | 718 | upstreamRef: details.upstreamRef, |
| ... | @@ -703,6 +723,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { | ... | @@ -703,6 +723,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { |
| 703 | : null; | 723 | : null; |
| 704 | 724 | ||
| 705 | return { | 725 | return { |
| 726 | isRepo: details.isRepo, | ||
| 727 | hasOriginRemote: details.hasOriginRemote, | ||
| 728 | isDefaultBranch: details.isDefaultBranch, | ||
| 706 | branch: details.branch, | 729 | branch: details.branch, |
| 707 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, | 730 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, |
| 708 | workingTree: details.workingTree, | 731 | workingTree: details.workingTree, |
| ... | @@ -909,12 +932,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { | ... | @@ -909,12 +932,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { |
| 909 | return parsed[0] ?? null; | 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 | const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( | 935 | const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( |
| 919 | cwd: string, | 936 | cwd: string, |
| 920 | result: Pick<GitRunStackedActionResult, "action" | "branch" | "commit" | "push" | "pr">, | 937 | result: Pick<GitRunStackedActionResult, "action" | "branch" | "commit" | "push" | "pr">, |
| ... | @@ -936,11 +953,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { | ... | @@ -936,11 +953,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { |
| 936 | upstreamRef: finalStatus.upstreamRef, | 953 | upstreamRef: finalStatus.upstreamRef, |
| 937 | hasUpstream: finalStatus.hasUpstream, | 954 | hasUpstream: finalStatus.hasUpstream, |
| 938 | }; | 955 | }; |
| 939 | currentBranchIsDefault = yield* isDefaultBranch(cwd, finalStatus.branch).pipe( | 956 | currentBranchIsDefault = finalStatus.isDefaultBranch; |
| 940 | Effect.catch(() => | ||
| 941 | Effect.succeed(finalStatus.branch === "main" || finalStatus.branch === "master"), | ||
| 942 | ), | ||
| 943 | ); | ||
| 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,6 +822,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 822 | gitManager: { | 822 | gitManager: { |
| 823 | status: () => | 823 | status: () => |
| 824 | Effect.succeed({ | 824 | Effect.succeed({ |
| 825 | isRepo: true, | ||
| 826 | hasOriginRemote: true, | ||
| 827 | isDefaultBranch: true, | ||
| 825 | branch: "main", | 828 | branch: "main", |
| 826 | hasWorkingTreeChanges: false, | 829 | hasWorkingTreeChanges: false, |
| 827 | workingTree: { files: [], insertions: 0, deletions: 0 }, | 830 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| ... | @@ -922,6 +925,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -922,6 +925,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 922 | ], | 925 | ], |
| 923 | isRepo: true, | 926 | isRepo: true, |
| 924 | hasOriginRemote: true, | 927 | hasOriginRemote: true, |
| 928 | nextCursor: null, | ||
| 929 | totalCount: 1, | ||
| 925 | }), | 930 | }), |
| 926 | createWorktree: () => | 931 | createWorktree: () => |
| 927 | Effect.succeed({ | 932 | Effect.succeed({ |
apps/web/src/components/BranchToolbar.logic.ts+4-52| ... | @@ -1,5 +1,9 @@ | ... | @@ -1,5 +1,9 @@ |
| 1 | import type { GitBranch } from "@t3tools/contracts"; | 1 | import type { GitBranch } from "@t3tools/contracts"; |
| 2 | import { Schema } from "effect"; | 2 | import { Schema } from "effect"; |
| 3 | export { | ||
| 4 | dedupeRemoteBranchesWithLocalMatches, | ||
| 5 | deriveLocalBranchNameFromRemoteRef, | ||
| 6 | } from "@t3tools/shared/git"; | ||
| 3 | 7 | ||
| 4 | export const EnvMode = Schema.Literals(["local", "worktree"]); | 8 | export const EnvMode = Schema.Literals(["local", "worktree"]); |
| 5 | export type EnvMode = typeof EnvMode.Type; | 9 | export type EnvMode = typeof EnvMode.Type; |
| ... | @@ -43,58 +47,6 @@ export function resolveBranchToolbarValue(input: { | ... | @@ -43,58 +47,6 @@ export function resolveBranchToolbarValue(input: { |
| 43 | return currentGitBranch ?? activeThreadBranch; | 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 | export function resolveBranchSelectionTarget(input: { | 50 | export function resolveBranchSelectionTarget(input: { |
| 99 | activeProjectCwd: string; | 51 | activeProjectCwd: string; |
| 100 | activeWorktreePath: string | null; | 52 | activeWorktreePath: string | null; |
apps/web/src/components/BranchToolbarBranchSelector.tsx+86-10| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import type { GitBranch } from "@t3tools/contracts"; | 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 | import { useVirtualizer } from "@tanstack/react-virtual"; | 3 | import { useVirtualizer } from "@tanstack/react-virtual"; |
| 4 | import { ChevronDownIcon } from "lucide-react"; | 4 | import { ChevronDownIcon } from "lucide-react"; |
| 5 | import { | 5 | import { |
| ... | @@ -15,7 +15,7 @@ import { | ... | @@ -15,7 +15,7 @@ import { |
| 15 | } from "react"; | 15 | } from "react"; |
| 16 | 16 | ||
| 17 | import { | 17 | import { |
| 18 | gitBranchesQueryOptions, | 18 | gitBranchSearchInfiniteQueryOptions, |
| 19 | gitQueryKeys, | 19 | gitQueryKeys, |
| 20 | gitStatusQueryOptions, | 20 | gitStatusQueryOptions, |
| 21 | invalidateGitQueries, | 21 | invalidateGitQueries, |
| ... | @@ -23,7 +23,6 @@ import { | ... | @@ -23,7 +23,6 @@ import { |
| 23 | import { readNativeApi } from "../nativeApi"; | 23 | import { readNativeApi } from "../nativeApi"; |
| 24 | import { parsePullRequestReference } from "../pullRequestReference"; | 24 | import { parsePullRequestReference } from "../pullRequestReference"; |
| 25 | import { | 25 | import { |
| 26 | dedupeRemoteBranchesWithLocalMatches, | ||
| 27 | deriveLocalBranchNameFromRemoteRef, | 26 | deriveLocalBranchNameFromRemoteRef, |
| 28 | EnvMode, | 27 | EnvMode, |
| 29 | resolveBranchSelectionTarget, | 28 | resolveBranchSelectionTarget, |
| ... | @@ -38,6 +37,7 @@ import { | ... | @@ -38,6 +37,7 @@ import { |
| 38 | ComboboxItem, | 37 | ComboboxItem, |
| 39 | ComboboxList, | 38 | ComboboxList, |
| 40 | ComboboxPopup, | 39 | ComboboxPopup, |
| 40 | ComboboxStatus, | ||
| 41 | ComboboxTrigger, | 41 | ComboboxTrigger, |
| 42 | } from "./ui/combobox"; | 42 | } from "./ui/combobox"; |
| 43 | import { toastManager } from "./ui/toast"; | 43 | import { toastManager } from "./ui/toast"; |
| ... | @@ -89,11 +89,33 @@ export function BranchToolbarBranchSelector({ | ... | @@ -89,11 +89,33 @@ export function BranchToolbarBranchSelector({ |
| 89 | const [branchQuery, setBranchQuery] = useState(""); | 89 | const [branchQuery, setBranchQuery] = useState(""); |
| 90 | const deferredBranchQuery = useDeferredValue(branchQuery); | 90 | const deferredBranchQuery = useDeferredValue(branchQuery); |
| 91 | 91 | ||
| 92 | const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); | ||
| 93 | const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd)); | 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 | const branches = useMemo( | 116 | const branches = useMemo( |
| 95 | () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []), | 117 | () => branchesSearchData?.pages.flatMap((page) => page.branches) ?? [], |
| 96 | [branchesQuery.data?.branches], | 118 | [branchesSearchData?.pages], |
| 97 | ); | 119 | ); |
| 98 | const currentGitBranch = | 120 | const currentGitBranch = |
| 99 | branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null; | 121 | branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null; |
| ... | @@ -108,8 +130,6 @@ export function BranchToolbarBranchSelector({ | ... | @@ -108,8 +130,6 @@ export function BranchToolbarBranchSelector({ |
| 108 | () => new Map(branches.map((branch) => [branch.name, branch] as const)), | 130 | () => new Map(branches.map((branch) => [branch.name, branch] as const)), |
| 109 | [branches], | 131 | [branches], |
| 110 | ); | 132 | ); |
| 111 | const trimmedBranchQuery = branchQuery.trim(); | ||
| 112 | const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); | ||
| 113 | const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase(); | 133 | const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase(); |
| 114 | const prReference = parsePullRequestReference(trimmedBranchQuery); | 134 | const prReference = parsePullRequestReference(trimmedBranchQuery); |
| 115 | const isSelectingWorktreeBase = | 135 | const isSelectingWorktreeBase = |
| ... | @@ -156,6 +176,14 @@ export function BranchToolbarBranchSelector({ | ... | @@ -156,6 +176,14 @@ export function BranchToolbarBranchSelector({ |
| 156 | ); | 176 | ); |
| 157 | const [isBranchActionPending, startBranchActionTransition] = useTransition(); | 177 | const [isBranchActionPending, startBranchActionTransition] = useTransition(); |
| 158 | const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; | 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 | const runBranchAction = (action: () => Promise<void>) => { | 188 | const runBranchAction = (action: () => Promise<void>) => { |
| 161 | startBranchActionTransition(async () => { | 189 | startBranchActionTransition(async () => { |
| ... | @@ -213,7 +241,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -213,7 +241,7 @@ export function BranchToolbarBranchSelector({ |
| 213 | 241 | ||
| 214 | let nextBranchName = selectedBranchName; | 242 | let nextBranchName = selectedBranchName; |
| 215 | if (branch.isRemote) { | 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 | if (status?.branch) { | 245 | if (status?.branch) { |
| 218 | nextBranchName = status.branch; | 246 | nextBranchName = status.branch; |
| 219 | } | 247 | } |
| ... | @@ -295,6 +323,24 @@ export function BranchToolbarBranchSelector({ | ... | @@ -295,6 +323,24 @@ export function BranchToolbarBranchSelector({ |
| 295 | ); | 323 | ); |
| 296 | 324 | ||
| 297 | const branchListScrollElementRef = useRef<HTMLDivElement | null>(null); | 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 | const branchListVirtualizer = useVirtualizer({ | 344 | const branchListVirtualizer = useVirtualizer({ |
| 299 | count: filteredBranchPickerItems.length, | 345 | count: filteredBranchPickerItems.length, |
| 300 | estimateSize: (index) => | 346 | estimateSize: (index) => |
| ... | @@ -331,6 +377,35 @@ export function BranchToolbarBranchSelector({ | ... | @@ -331,6 +377,35 @@ export function BranchToolbarBranchSelector({ |
| 331 | shouldVirtualizeBranchList, | 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 | const triggerLabel = getBranchTriggerLabel({ | 409 | const triggerLabel = getBranchTriggerLabel({ |
| 335 | activeWorktreePath, | 410 | activeWorktreePath, |
| 336 | effectiveEnvMode, | 411 | effectiveEnvMode, |
| ... | @@ -425,7 +500,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -425,7 +500,7 @@ export function BranchToolbarBranchSelector({ |
| 425 | <ComboboxTrigger | 500 | <ComboboxTrigger |
| 426 | render={<Button variant="ghost" size="xs" />} | 501 | render={<Button variant="ghost" size="xs" />} |
| 427 | className="text-muted-foreground/70 hover:text-foreground/80" | 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 | <span className="max-w-[240px] truncate">{triggerLabel}</span> | 505 | <span className="max-w-[240px] truncate">{triggerLabel}</span> |
| 431 | <ChevronDownIcon /> | 506 | <ChevronDownIcon /> |
| ... | @@ -468,6 +543,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -468,6 +543,7 @@ export function BranchToolbarBranchSelector({ |
| 468 | filteredBranchPickerItems.map((itemValue, index) => renderPickerItem(itemValue, index)) | 543 | filteredBranchPickerItems.map((itemValue, index) => renderPickerItem(itemValue, index)) |
| 469 | )} | 544 | )} |
| 470 | </ComboboxList> | 545 | </ComboboxList> |
| 546 | {branchStatusText ? <ComboboxStatus>{branchStatusText}</ComboboxStatus> : null} | ||
| 471 | </ComboboxPopup> | 547 | </ComboboxPopup> |
| 472 | </Combobox> | 548 | </Combobox> |
| 473 | ); | 549 | ); |
apps/web/src/components/ChatView.browser.tsx+5| ... | @@ -632,6 +632,8 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { | ... | @@ -632,6 +632,8 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { |
| 632 | return { | 632 | return { |
| 633 | isRepo: true, | 633 | isRepo: true, |
| 634 | hasOriginRemote: true, | 634 | hasOriginRemote: true, |
| 635 | nextCursor: null, | ||
| 636 | totalCount: 1, | ||
| 635 | branches: [ | 637 | branches: [ |
| 636 | { | 638 | { |
| 637 | name: "main", | 639 | name: "main", |
| ... | @@ -644,6 +646,9 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { | ... | @@ -644,6 +646,9 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { |
| 644 | } | 646 | } |
| 645 | if (tag === WS_METHODS.gitStatus) { | 647 | if (tag === WS_METHODS.gitStatus) { |
| 646 | return { | 648 | return { |
| 649 | isRepo: true, | ||
| 650 | hasOriginRemote: true, | ||
| 651 | isDefaultBranch: true, | ||
| 647 | branch: "main", | 652 | branch: "main", |
| 648 | hasWorkingTreeChanges: false, | 653 | hasWorkingTreeChanges: false, |
| 649 | workingTree: { | 654 | workingTree: { |
apps/web/src/components/ChatView.tsx+3-3| ... | @@ -26,7 +26,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr | ... | @@ -26,7 +26,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr |
| 26 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | 26 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 27 | import { useDebouncedValue } from "@tanstack/react-pacer"; | 27 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 28 | import { useNavigate, useSearch } from "@tanstack/react-router"; | 28 | import { useNavigate, useSearch } from "@tanstack/react-router"; |
| 29 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; | 29 | import { gitCreateWorktreeMutationOptions, gitStatusQueryOptions } from "~/lib/gitReactQuery"; |
| 30 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; | 30 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 31 | import { isElectron } from "../env"; | 31 | import { isElectron } from "../env"; |
| 32 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | 32 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| ... | @@ -1201,7 +1201,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1201,7 +1201,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1201 | (debouncerState) => ({ isPending: debouncerState.isPending }), | 1201 | (debouncerState) => ({ isPending: debouncerState.isPending }), |
| 1202 | ); | 1202 | ); |
| 1203 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; | 1203 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; |
| 1204 | const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); | 1204 | const gitStatusQuery = useQuery(gitStatusQueryOptions(gitCwd)); |
| 1205 | const keybindings = useServerKeybindings(); | 1205 | const keybindings = useServerKeybindings(); |
| 1206 | const availableEditors = useServerAvailableEditors(); | 1206 | const availableEditors = useServerAvailableEditors(); |
| 1207 | const modelOptionsByProvider = useMemo( | 1207 | const modelOptionsByProvider = useMemo( |
| ... | @@ -1338,7 +1338,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1338,7 +1338,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1338 | }); | 1338 | }); |
| 1339 | }, [activeProjectCwd, activeThreadWorktreePath]); | 1339 | }, [activeProjectCwd, activeThreadWorktreePath]); |
| 1340 | // Default true while loading to avoid toolbar flicker. | 1340 | // Default true while loading to avoid toolbar flicker. |
| 1341 | const isGitRepo = branchesQuery.data?.isRepo ?? true; | 1341 | const isGitRepo = gitStatusQuery.data?.isRepo ?? true; |
| 1342 | const terminalShortcutLabelOptions = useMemo( | 1342 | const terminalShortcutLabelOptions = useMemo( |
| 1343 | () => ({ | 1343 | () => ({ |
| 1344 | context: { | 1344 | context: { |
apps/web/src/components/DiffPanel.tsx+3-3| ... | @@ -19,7 +19,7 @@ import { | ... | @@ -19,7 +19,7 @@ import { |
| 19 | useState, | 19 | useState, |
| 20 | } from "react"; | 20 | } from "react"; |
| 21 | import { openInPreferredEditor } from "../editorPreferences"; | 21 | import { openInPreferredEditor } from "../editorPreferences"; |
| 22 | import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; | 22 | import { gitStatusQueryOptions } from "~/lib/gitReactQuery"; |
| 23 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; | 23 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| 24 | import { cn } from "~/lib/utils"; | 24 | import { cn } from "~/lib/utils"; |
| 25 | import { readNativeApi } from "../nativeApi"; | 25 | import { readNativeApi } from "../nativeApi"; |
| ... | @@ -189,8 +189,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | ... | @@ -189,8 +189,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 189 | activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, | 189 | activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, |
| 190 | ); | 190 | ); |
| 191 | const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; | 191 | const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; |
| 192 | const gitBranchesQuery = useQuery(gitBranchesQueryOptions(activeCwd ?? null)); | 192 | const gitStatusQuery = useQuery(gitStatusQueryOptions(activeCwd ?? null)); |
| 193 | const isGitRepo = gitBranchesQuery.data?.isRepo ?? true; | 193 | const isGitRepo = gitStatusQuery.data?.isRepo ?? true; |
| 194 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = | 194 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 195 | useTurnDiffSummaries(activeThread); | 195 | useTurnDiffSummaries(activeThread); |
| 196 | const orderedTurnDiffSummaries = useMemo( | 196 | const orderedTurnDiffSummaries = useMemo( |
apps/web/src/components/GitActionsControl.logic.test.ts+3| ... | @@ -13,6 +13,9 @@ import { | ... | @@ -13,6 +13,9 @@ import { |
| 13 | 13 | ||
| 14 | function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult { | 14 | function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult { |
| 15 | return { | 15 | return { |
| 16 | isRepo: true, | ||
| 17 | hasOriginRemote: true, | ||
| 18 | isDefaultBranch: false, | ||
| 16 | branch: "feature/test", | 19 | branch: "feature/test", |
| 17 | hasWorkingTreeChanges: false, | 20 | hasWorkingTreeChanges: false, |
| 18 | workingTree: { | 21 | workingTree: { |
apps/web/src/components/GitActionsControl.tsx+5-26| ... | @@ -41,14 +41,12 @@ import { Textarea } from "~/components/ui/textarea"; | ... | @@ -41,14 +41,12 @@ import { Textarea } from "~/components/ui/textarea"; |
| 41 | import { toastManager, type ThreadToastData } from "~/components/ui/toast"; | 41 | import { toastManager, type ThreadToastData } from "~/components/ui/toast"; |
| 42 | import { openInPreferredEditor } from "~/editorPreferences"; | 42 | import { openInPreferredEditor } from "~/editorPreferences"; |
| 43 | import { | 43 | import { |
| 44 | gitBranchesQueryOptions, | ||
| 45 | gitInitMutationOptions, | 44 | gitInitMutationOptions, |
| 46 | gitMutationKeys, | 45 | gitMutationKeys, |
| 47 | gitPullMutationOptions, | 46 | gitPullMutationOptions, |
| 48 | gitRunStackedActionMutationOptions, | 47 | gitRunStackedActionMutationOptions, |
| 49 | gitStatusQueryOptions, | 48 | gitStatusQueryOptions, |
| 50 | invalidateGitStatusQuery, | 49 | invalidateGitStatusQuery, |
| 51 | invalidateGitQueries, | ||
| 52 | } from "~/lib/gitReactQuery"; | 50 | } from "~/lib/gitReactQuery"; |
| 53 | import { newCommandId, randomUUID } from "~/lib/utils"; | 51 | import { newCommandId, randomUUID } from "~/lib/utils"; |
| 54 | import { resolvePathLinkTarget } from "~/terminal-links"; | 52 | import { resolvePathLinkTarget } from "~/terminal-links"; |
| ... | @@ -278,21 +276,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -278,21 +276,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 278 | ); | 276 | ); |
| 279 | 277 | ||
| 280 | const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); | 278 | const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); |
| 281 | |||
| 282 | const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd)); | ||
| 283 | // Default to true while loading so we don't flash init controls. | 279 | // Default to true while loading so we don't flash init controls. |
| 284 | const isRepo = branchList?.isRepo ?? true; | 280 | const isRepo = gitStatus?.isRepo ?? true; |
| 285 | const hasOriginRemote = branchList?.hasOriginRemote ?? false; | 281 | const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; |
| 286 | const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null; | 282 | const gitStatusForActions = gitStatus; |
| 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; | ||
| 296 | 283 | ||
| 297 | const allFiles = gitStatusForActions?.workingTree.files ?? []; | 284 | const allFiles = gitStatusForActions?.workingTree.files ?? []; |
| 298 | const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); | 285 | const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); |
| ... | @@ -336,11 +323,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -336,11 +323,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 336 | ]); | 323 | ]); |
| 337 | 324 | ||
| 338 | const isDefaultBranch = useMemo(() => { | 325 | const isDefaultBranch = useMemo(() => { |
| 339 | const branchName = gitStatusForActions?.branch; | 326 | return gitStatusForActions?.isDefaultBranch ?? false; |
| 340 | if (!branchName) return false; | 327 | }, [gitStatusForActions?.isDefaultBranch]); |
| 341 | const current = branchList?.branches.find((branch) => branch.name === branchName); | ||
| 342 | return current?.isDefault ?? (branchName === "main" || branchName === "master"); | ||
| 343 | }, [branchList?.branches, gitStatusForActions?.branch]); | ||
| 344 | 328 | ||
| 345 | const gitActionMenuItems = useMemo( | 329 | const gitActionMenuItems = useMemo( |
| 346 | () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasOriginRemote), | 330 | () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasOriginRemote), |
| ... | @@ -881,11 +865,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -881,11 +865,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 881 | Behind upstream. Pull/rebase first. | 865 | Behind upstream. Pull/rebase first. |
| 882 | </p> | 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 | {gitStatusError && ( | 868 | {gitStatusError && ( |
| 890 | <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError.message}</p> | 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,11 +157,16 @@ function resolveWsRpc(tag: string): unknown { |
| 157 | return { | 157 | return { |
| 158 | isRepo: true, | 158 | isRepo: true, |
| 159 | hasOriginRemote: true, | 159 | hasOriginRemote: true, |
| 160 | nextCursor: null, | ||
| 161 | totalCount: 1, | ||
| 160 | branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }], | 162 | branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }], |
| 161 | }; | 163 | }; |
| 162 | } | 164 | } |
| 163 | if (tag === WS_METHODS.gitStatus) { | 165 | if (tag === WS_METHODS.gitStatus) { |
| 164 | return { | 166 | return { |
| 167 | isRepo: true, | ||
| 168 | hasOriginRemote: true, | ||
| 169 | isDefaultBranch: true, | ||
| 165 | branch: "main", | 170 | branch: "main", |
| 166 | hasWorkingTreeChanges: false, | 171 | hasWorkingTreeChanges: false, |
| 167 | workingTree: { files: [], insertions: 0, deletions: 0 }, | 172 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
apps/web/src/lib/gitReactQuery.test.ts+43-9| ... | @@ -9,8 +9,11 @@ vi.mock("../wsRpcClient", () => ({ | ... | @@ -9,8 +9,11 @@ vi.mock("../wsRpcClient", () => ({ |
| 9 | getWsRpcClient: vi.fn(), | 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 | import { | 15 | import { |
| 13 | gitBranchesQueryOptions, | 16 | gitBranchSearchInfiniteQueryOptions, |
| 14 | gitMutationKeys, | 17 | gitMutationKeys, |
| 15 | gitQueryKeys, | 18 | gitQueryKeys, |
| 16 | gitPreparePullRequestThreadMutationOptions, | 19 | gitPreparePullRequestThreadMutationOptions, |
| ... | @@ -21,6 +24,19 @@ import { | ... | @@ -21,6 +24,19 @@ import { |
| 21 | invalidateGitQueries, | 24 | invalidateGitQueries, |
| 22 | } from "./gitReactQuery"; | 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 | describe("gitMutationKeys", () => { | 40 | describe("gitMutationKeys", () => { |
| 25 | it("scopes stacked action keys by cwd", () => { | 41 | it("scopes stacked action keys by cwd", () => { |
| 26 | expect(gitMutationKeys.runStackedAction("/repo/a")).not.toEqual( | 42 | expect(gitMutationKeys.runStackedAction("/repo/a")).not.toEqual( |
| ... | @@ -69,9 +85,21 @@ describe("invalidateGitQueries", () => { | ... | @@ -69,9 +85,21 @@ describe("invalidateGitQueries", () => { |
| 69 | const queryClient = new QueryClient(); | 85 | const queryClient = new QueryClient(); |
| 70 | 86 | ||
| 71 | queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" }); | 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 | queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" }); | 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 | await invalidateGitQueries(queryClient, { cwd: "/repo/a" }); | 104 | await invalidateGitQueries(queryClient, { cwd: "/repo/a" }); |
| 77 | 105 | ||
| ... | @@ -79,13 +107,23 @@ describe("invalidateGitQueries", () => { | ... | @@ -79,13 +107,23 @@ describe("invalidateGitQueries", () => { |
| 79 | queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated, | 107 | queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated, |
| 80 | ).toBe(true); | 108 | ).toBe(true); |
| 81 | expect( | 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 | ).toBe(true); | 116 | ).toBe(true); |
| 84 | expect( | 117 | expect( |
| 85 | queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated, | 118 | queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated, |
| 86 | ).toBe(false); | 119 | ).toBe(false); |
| 87 | expect( | 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 | ).toBe(false); | 127 | ).toBe(false); |
| 90 | }); | 128 | }); |
| 91 | }); | 129 | }); |
| ... | @@ -95,7 +133,6 @@ describe("invalidateGitStatusQuery", () => { | ... | @@ -95,7 +133,6 @@ describe("invalidateGitStatusQuery", () => { |
| 95 | const queryClient = new QueryClient(); | 133 | const queryClient = new QueryClient(); |
| 96 | 134 | ||
| 97 | queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" }); | 135 | queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" }); |
| 98 | queryClient.setQueryData(gitQueryKeys.branches("/repo/a"), { ok: "a-branches" }); | ||
| 99 | queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" }); | 136 | queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" }); |
| 100 | 137 | ||
| 101 | await invalidateGitStatusQuery(queryClient, "/repo/a"); | 138 | await invalidateGitStatusQuery(queryClient, "/repo/a"); |
| ... | @@ -103,9 +140,6 @@ describe("invalidateGitStatusQuery", () => { | ... | @@ -103,9 +140,6 @@ describe("invalidateGitStatusQuery", () => { |
| 103 | expect( | 140 | expect( |
| 104 | queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated, | 141 | queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated, |
| 105 | ).toBe(true); | 142 | ).toBe(true); |
| 106 | expect( | ||
| 107 | queryClient.getQueryState(gitBranchesQueryOptions("/repo/a").queryKey)?.isInvalidated, | ||
| 108 | ).toBe(false); | ||
| 109 | expect( | 143 | expect( |
| 110 | queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated, | 144 | queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated, |
| 111 | ).toBe(false); | 145 | ).toBe(false); |
apps/web/src/lib/gitReactQuery.ts+29-8| ... | @@ -1,5 +1,10 @@ | ... | @@ -1,5 +1,10 @@ |
| 1 | import { type GitActionProgressEvent, type GitStackedAction } from "@t3tools/contracts"; | 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 | import { ensureNativeApi } from "../nativeApi"; | 8 | import { ensureNativeApi } from "../nativeApi"; |
| 4 | import { getWsRpcClient } from "../wsRpcClient"; | 9 | import { getWsRpcClient } from "../wsRpcClient"; |
| 5 | 10 | ||
| ... | @@ -7,11 +12,14 @@ const GIT_STATUS_STALE_TIME_MS = 5_000; | ... | @@ -7,11 +12,14 @@ const GIT_STATUS_STALE_TIME_MS = 5_000; |
| 7 | const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000; | 12 | const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000; |
| 8 | const GIT_BRANCHES_STALE_TIME_MS = 15_000; | 13 | const GIT_BRANCHES_STALE_TIME_MS = 15_000; |
| 9 | const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000; | 14 | const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000; |
| 15 | const GIT_BRANCHES_PAGE_SIZE = 100; | ||
| 10 | 16 | ||
| 11 | export const gitQueryKeys = { | 17 | export const gitQueryKeys = { |
| 12 | all: ["git"] as const, | 18 | all: ["git"] as const, |
| 13 | status: (cwd: string | null) => ["git", "status", cwd] as const, | 19 | status: (cwd: string | null) => ["git", "status", cwd] as const, |
| 14 | branches: (cwd: string | null) => ["git", "branches", cwd] as const, | 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 | export const gitMutationKeys = { | 25 | export const gitMutationKeys = { |
| ... | @@ -59,15 +67,28 @@ export function gitStatusQueryOptions(cwd: string | null) { | ... | @@ -59,15 +67,28 @@ export function gitStatusQueryOptions(cwd: string | null) { |
| 59 | }); | 67 | }); |
| 60 | } | 68 | } |
| 61 | 69 | ||
| 62 | export function gitBranchesQueryOptions(cwd: string | null) { | 70 | export function gitBranchSearchInfiniteQueryOptions(input: { |
| 63 | return queryOptions({ | 71 | cwd: string | null; |
| 64 | queryKey: gitQueryKeys.branches(cwd), | 72 | query: string; |
| 65 | queryFn: async () => { | 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 | const api = ensureNativeApi(); | 81 | const api = ensureNativeApi(); |
| 67 | if (!cwd) throw new Error("Git branches are unavailable."); | 82 | if (!input.cwd) throw new Error("Git branches are unavailable."); |
| 68 | return api.git.listBranches({ cwd }); | 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 | staleTime: GIT_BRANCHES_STALE_TIME_MS, | 92 | staleTime: GIT_BRANCHES_STALE_TIME_MS, |
| 72 | refetchOnWindowFocus: true, | 93 | refetchOnWindowFocus: true, |
| 73 | refetchOnReconnect: true, | 94 | refetchOnReconnect: true, |
packages/contracts/src/git.ts+12-1| ... | @@ -2,6 +2,7 @@ import { Schema } from "effect"; | ... | @@ -2,6 +2,7 @@ import { Schema } from "effect"; |
| 2 | import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; | 2 | import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; |
| 3 | 3 | ||
| 4 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; | 4 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; |
| 5 | const GIT_LIST_BRANCHES_MAX_LIMIT = 200; | ||
| 5 | 6 | ||
| 6 | // Domain Types | 7 | // Domain Types |
| 7 | 8 | ||
| ... | @@ -120,6 +121,11 @@ export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; | ... | @@ -120,6 +121,11 @@ export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; |
| 120 | 121 | ||
| 121 | export const GitListBranchesInput = Schema.Struct({ | 122 | export const GitListBranchesInput = Schema.Struct({ |
| 122 | cwd: TrimmedNonEmptyStringSchema, | 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 | export type GitListBranchesInput = typeof GitListBranchesInput.Type; | 130 | export type GitListBranchesInput = typeof GitListBranchesInput.Type; |
| 125 | 131 | ||
| ... | @@ -180,7 +186,10 @@ const GitStatusPr = Schema.Struct({ | ... | @@ -180,7 +186,10 @@ const GitStatusPr = Schema.Struct({ |
| 180 | }); | 186 | }); |
| 181 | 187 | ||
| 182 | export const GitStatusResult = Schema.Struct({ | 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 | hasWorkingTreeChanges: Schema.Boolean, | 193 | hasWorkingTreeChanges: Schema.Boolean, |
| 185 | workingTree: Schema.Struct({ | 194 | workingTree: Schema.Struct({ |
| 186 | files: Schema.Array( | 195 | files: Schema.Array( |
| ... | @@ -204,6 +213,8 @@ export const GitListBranchesResult = Schema.Struct({ | ... | @@ -204,6 +213,8 @@ export const GitListBranchesResult = Schema.Struct({ |
| 204 | branches: Schema.Array(GitBranch), | 213 | branches: Schema.Array(GitBranch), |
| 205 | isRepo: Schema.Boolean, | 214 | isRepo: Schema.Boolean, |
| 206 | hasOriginRemote: Schema.Boolean, | 215 | hasOriginRemote: Schema.Boolean, |
| 216 | nextCursor: NonNegativeInt.pipe(Schema.NullOr), | ||
| 217 | totalCount: NonNegativeInt, | ||
| 207 | }); | 218 | }); |
| 208 | export type GitListBranchesResult = typeof GitListBranchesResult.Type; | 219 | export type GitListBranchesResult = typeof GitListBranchesResult.Type; |
| 209 | 220 |
packages/shared/src/git.ts+60| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import type { GitBranch } from "@t3tools/contracts"; | ||
| 2 | |||
| 1 | /** | 3 | /** |
| 2 | * Sanitize an arbitrary string into a valid, lowercase git branch fragment. | 4 | * Sanitize an arbitrary string into a valid, lowercase git branch fragment. |
| 3 | * Strips quotes, collapses separators, limits to 64 chars. | 5 | * Strips quotes, collapses separators, limits to 64 chars. |
| ... | @@ -59,3 +61,61 @@ export function resolveAutoFeatureBranchName( | ... | @@ -59,3 +61,61 @@ export function resolveAutoFeatureBranchName( |
| 59 | 61 | ||
| 60 | return `${resolvedBase}-${suffix}`; | 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,8 +4,6 @@ |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "scripts": { | 5 | "scripts": { |
| 6 | "prepare": "effect-language-service patch", | 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 | "typecheck": "tsc --noEmit", | 7 | "typecheck": "tsc --noEmit", |
| 10 | "test": "vitest run" | 8 | "test": "vitest run" |
| 11 | }, | 9 | }, |