| ... | @@ -76,6 +76,33 @@ function parsePorcelainPath(line: string): string | null { | ... | @@ -76,6 +76,33 @@ function parsePorcelainPath(line: string): string | null { |
| 76 | return filePath.length > 0 ? filePath : null; | 76 | return filePath.length > 0 ? filePath : null; |
| 77 | } | 77 | } |
| 78 | | 78 | |
| | 79 | function parseBranchLine(line: string): { name: string; current: boolean } | null { |
| | 80 | const trimmed = line.trim(); |
| | 81 | if (trimmed.length === 0) return null; |
| | 82 | |
| | 83 | const name = trimmed.replace(/^[*+]\s+/, ""); |
| | 84 | // Exclude symbolic refs like: "origin/HEAD -> origin/main". |
| | 85 | if (name.includes(" -> ")) return null; |
| | 86 | |
| | 87 | return { |
| | 88 | name, |
| | 89 | current: trimmed.startsWith("* "), |
| | 90 | }; |
| | 91 | } |
| | 92 | |
| | 93 | function parseRemoteTrackingCandidate( |
| | 94 | branchName: string, |
| | 95 | ): { remoteRef: string; localBranch: string } | null { |
| | 96 | const separatorIndex = branchName.indexOf("/"); |
| | 97 | if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) return null; |
| | 98 | const localBranch = branchName.slice(separatorIndex + 1).trim(); |
| | 99 | if (localBranch.length === 0) return null; |
| | 100 | return { |
| | 101 | remoteRef: branchName.trim(), |
| | 102 | localBranch, |
| | 103 | }; |
| | 104 | } |
| | 105 | |
| 79 | function commandLabel(args: readonly string[]): string { | 106 | function commandLabel(args: readonly string[]): string { |
| 80 | return `git ${args.join(" ")}`; | 107 | return `git ${args.join(" ")}`; |
| 81 | } | 108 | } |
| ... | @@ -264,7 +291,12 @@ const makeGitCore = Effect.gen(function* () { | ... | @@ -264,7 +291,12 @@ const makeGitCore = Effect.gen(function* () { |
| 264 | const branchRecency = yield* executeGit( | 291 | const branchRecency = yield* executeGit( |
| 265 | "GitCore.readBranchRecency", | 292 | "GitCore.readBranchRecency", |
| 266 | cwd, | 293 | cwd, |
| 267 | ["for-each-ref", "--format=%(refname:short)%09%(committerdate:unix)", "refs/heads"], | 294 | [ |
| | 295 | "for-each-ref", |
| | 296 | "--format=%(refname:short)%09%(committerdate:unix)", |
| | 297 | "refs/heads", |
| | 298 | "refs/remotes", |
| | 299 | ], |
| 268 | { | 300 | { |
| 269 | timeoutMs: 15_000, | 301 | timeoutMs: 15_000, |
| 270 | allowNonZeroExit: true, | 302 | allowNonZeroExit: true, |
| ... | @@ -564,7 +596,7 @@ const makeGitCore = Effect.gen(function* () { | ... | @@ -564,7 +596,7 @@ const makeGitCore = Effect.gen(function* () { |
| 564 | const branchRecencyPromise = readBranchRecency(input.cwd).pipe( | 596 | const branchRecencyPromise = readBranchRecency(input.cwd).pipe( |
| 565 | Effect.catch(() => Effect.succeed(new Map<string, number>())), | 597 | Effect.catch(() => Effect.succeed(new Map<string, number>())), |
| 566 | ); | 598 | ); |
| 567 | const result = yield* executeGit( | 599 | const localBranchResult = yield* executeGit( |
| 568 | "GitCore.listBranches.branchNoColor", | 600 | "GitCore.listBranches.branchNoColor", |
| 569 | input.cwd, | 601 | input.cwd, |
| 570 | ["branch", "--no-color"], | 602 | ["branch", "--no-color"], |
| ... | @@ -574,8 +606,8 @@ const makeGitCore = Effect.gen(function* () { | ... | @@ -574,8 +606,8 @@ const makeGitCore = Effect.gen(function* () { |
| 574 | }, | 606 | }, |
| 575 | ); | 607 | ); |
| 576 | | 608 | |
| 577 | if (result.code !== 0) { | 609 | if (localBranchResult.code !== 0) { |
| 578 | const stderr = result.stderr.trim(); | 610 | const stderr = localBranchResult.stderr.trim(); |
| 579 | if (stderr.toLowerCase().includes("not a git repository")) { | 611 | if (stderr.toLowerCase().includes("not a git repository")) { |
| 580 | return { branches: [], isRepo: false }; | 612 | return { branches: [], isRepo: false }; |
| 581 | } | 613 | } |
| ... | @@ -587,7 +619,7 @@ const makeGitCore = Effect.gen(function* () { | ... | @@ -587,7 +619,7 @@ const makeGitCore = Effect.gen(function* () { |
| 587 | ); | 619 | ); |
| 588 | } | 620 | } |
| 589 | | 621 | |
| 590 | const [defaultRef, worktreeList, branchLastCommit] = yield* Effect.all( | 622 | const [defaultRef, worktreeList, remoteBranchResult, branchLastCommit] = yield* Effect.all( |
| 591 | [ | 623 | [ |
| 592 | executeGit( | 624 | executeGit( |
| 593 | "GitCore.listBranches.defaultRef", | 625 | "GitCore.listBranches.defaultRef", |
| ... | @@ -607,6 +639,15 @@ const makeGitCore = Effect.gen(function* () { | ... | @@ -607,6 +639,15 @@ const makeGitCore = Effect.gen(function* () { |
| 607 | allowNonZeroExit: true, | 639 | allowNonZeroExit: true, |
| 608 | }, | 640 | }, |
| 609 | ), | 641 | ), |
| | 642 | executeGit( |
| | 643 | "GitCore.listBranches.remoteBranches", |
| | 644 | input.cwd, |
| | 645 | ["branch", "--no-color", "--remotes"], |
| | 646 | { |
| | 647 | timeoutMs: 10_000, |
| | 648 | allowNonZeroExit: true, |
| | 649 | }, |
| | 650 | ), |
| 610 | branchRecencyPromise, | 651 | branchRecencyPromise, |
| 611 | ], | 652 | ], |
| 612 | { concurrency: "unbounded" }, | 653 | { concurrency: "unbounded" }, |
| ... | @@ -636,19 +677,17 @@ const makeGitCore = Effect.gen(function* () { | ... | @@ -636,19 +677,17 @@ const makeGitCore = Effect.gen(function* () { |
| 636 | } | 677 | } |
| 637 | } | 678 | } |
| 638 | | 679 | |
| 639 | const branches = result.stdout | 680 | const localBranches = localBranchResult.stdout |
| 640 | .split("\n") | 681 | .split("\n") |
| 641 | .map((line) => line.trim()) | 682 | .map(parseBranchLine) |
| 642 | .filter((line) => line.length > 0) | 683 | .filter((branch): branch is { name: string; current: boolean } => branch !== null) |
| 643 | .map((line) => { | 684 | .map((branch) => ({ |
| 644 | const name = line.replace(/^[*+]\s+/, ""); | 685 | name: branch.name, |
| 645 | return { | 686 | current: branch.current, |
| 646 | name, | 687 | isRemote: false, |
| 647 | current: line.startsWith("* "), | 688 | isDefault: branch.name === defaultBranch, |
| 648 | isDefault: name === defaultBranch, | 689 | worktreePath: worktreeMap.get(branch.name) ?? null, |
| 649 | worktreePath: worktreeMap.get(name) ?? null, | 690 | })) |
| 650 | }; | | |
| 651 | }) | | |
| 652 | .toSorted((a, b) => { | 691 | .toSorted((a, b) => { |
| 653 | const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; | 692 | const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; |
| 654 | const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; | 693 | const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; |
| ... | @@ -660,6 +699,29 @@ const makeGitCore = Effect.gen(function* () { | ... | @@ -660,6 +699,29 @@ const makeGitCore = Effect.gen(function* () { |
| 660 | return a.name.localeCompare(b.name); | 699 | return a.name.localeCompare(b.name); |
| 661 | }); | 700 | }); |
| 662 | | 701 | |
| | 702 | const remoteBranches = |
| | 703 | remoteBranchResult.code === 0 |
| | 704 | ? remoteBranchResult.stdout |
| | 705 | .split("\n") |
| | 706 | .map(parseBranchLine) |
| | 707 | .filter((branch): branch is { name: string; current: boolean } => branch !== null) |
| | 708 | .map((branch) => ({ |
| | 709 | name: branch.name, |
| | 710 | current: false, |
| | 711 | isRemote: true, |
| | 712 | isDefault: false, |
| | 713 | worktreePath: null, |
| | 714 | })) |
| | 715 | .toSorted((a, b) => { |
| | 716 | const aLastCommit = branchLastCommit.get(a.name) ?? 0; |
| | 717 | const bLastCommit = branchLastCommit.get(b.name) ?? 0; |
| | 718 | if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; |
| | 719 | return a.name.localeCompare(b.name); |
| | 720 | }) |
| | 721 | : []; |
| | 722 | |
| | 723 | const branches = [...localBranches, ...remoteBranches]; |
| | 724 | |
| 663 | return { branches, isRepo: true }; | 725 | return { branches, isRepo: true }; |
| 664 | }); | 726 | }); |
| 665 | | 727 | |
| ... | @@ -719,7 +781,63 @@ const makeGitCore = Effect.gen(function* () { | ... | @@ -719,7 +781,63 @@ const makeGitCore = Effect.gen(function* () { |
| 719 | | 781 | |
| 720 | const checkoutBranch: GitCoreShape["checkoutBranch"] = (input) => | 782 | const checkoutBranch: GitCoreShape["checkoutBranch"] = (input) => |
| 721 | Effect.gen(function* () { | 783 | Effect.gen(function* () { |
| 722 | yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, ["checkout", input.branch], { | 784 | const trackingCandidate = parseRemoteTrackingCandidate(input.branch); |
| | 785 | if (!trackingCandidate) { |
| | 786 | yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, ["checkout", input.branch], { |
| | 787 | timeoutMs: 10_000, |
| | 788 | fallbackErrorMessage: "git checkout failed", |
| | 789 | }); |
| | 790 | |
| | 791 | // Refresh upstream refs in the background so checkout remains responsive. |
| | 792 | yield* Effect.sync(() => { |
| | 793 | void Effect.runPromise( |
| | 794 | refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.catch(() => Effect.void)), |
| | 795 | ); |
| | 796 | }); |
| | 797 | return; |
| | 798 | } |
| | 799 | |
| | 800 | const [localInputExists, remoteExists, localTrackingExists] = yield* Effect.all( |
| | 801 | [ |
| | 802 | executeGit( |
| | 803 | "GitCore.checkoutBranch.localInputExists", |
| | 804 | input.cwd, |
| | 805 | ["show-ref", "--verify", "--quiet", `refs/heads/${input.branch}`], |
| | 806 | { |
| | 807 | timeoutMs: 5_000, |
| | 808 | allowNonZeroExit: true, |
| | 809 | }, |
| | 810 | ).pipe(Effect.map((result) => result.code === 0)), |
| | 811 | executeGit( |
| | 812 | "GitCore.checkoutBranch.remoteExists", |
| | 813 | input.cwd, |
| | 814 | ["show-ref", "--verify", "--quiet", `refs/remotes/${trackingCandidate.remoteRef}`], |
| | 815 | { |
| | 816 | timeoutMs: 5_000, |
| | 817 | allowNonZeroExit: true, |
| | 818 | }, |
| | 819 | ).pipe(Effect.map((result) => result.code === 0)), |
| | 820 | executeGit( |
| | 821 | "GitCore.checkoutBranch.localTrackingExists", |
| | 822 | input.cwd, |
| | 823 | ["show-ref", "--verify", "--quiet", `refs/heads/${trackingCandidate.localBranch}`], |
| | 824 | { |
| | 825 | timeoutMs: 5_000, |
| | 826 | allowNonZeroExit: true, |
| | 827 | }, |
| | 828 | ).pipe(Effect.map((result) => result.code === 0)), |
| | 829 | ], |
| | 830 | { concurrency: "unbounded" }, |
| | 831 | ); |
| | 832 | |
| | 833 | const checkoutArgs = |
| | 834 | localInputExists |
| | 835 | ? ["checkout", input.branch] |
| | 836 | : remoteExists && !localTrackingExists |
| | 837 | ? ["checkout", "--track", trackingCandidate.remoteRef] |
| | 838 | : ["checkout", localTrackingExists ? trackingCandidate.localBranch : input.branch]; |
| | 839 | |
| | 840 | yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, { |
| 723 | timeoutMs: 10_000, | 841 | timeoutMs: 10_000, |
| 724 | fallbackErrorMessage: "git checkout failed", | 842 | fallbackErrorMessage: "git checkout failed", |
| 725 | }); | 843 | }); |