| ... | ... | @@ -76,6 +76,33 @@ function parsePorcelainPath(line: string): string | null { |
| 76 | 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 | 106 | function commandLabel(args: readonly string[]): string { |
| 80 | 107 | return `git ${args.join(" ")}`; |
| 81 | 108 | } |
| ... | ... | @@ -264,7 +291,12 @@ const makeGitCore = Effect.gen(function* () { |
| 264 | 291 | const branchRecency = yield* executeGit( |
| 265 | 292 | "GitCore.readBranchRecency", |
| 266 | 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 | 301 | timeoutMs: 15_000, |
| 270 | 302 | allowNonZeroExit: true, |
| ... | ... | @@ -564,7 +596,7 @@ const makeGitCore = Effect.gen(function* () { |
| 564 | 596 | const branchRecencyPromise = readBranchRecency(input.cwd).pipe( |
| 565 | 597 | Effect.catch(() => Effect.succeed(new Map<string, number>())), |
| 566 | 598 | ); |
| 567 | | const result = yield* executeGit( |
| 599 | const localBranchResult = yield* executeGit( |
| 568 | 600 | "GitCore.listBranches.branchNoColor", |
| 569 | 601 | input.cwd, |
| 570 | 602 | ["branch", "--no-color"], |
| ... | ... | @@ -574,8 +606,8 @@ const makeGitCore = Effect.gen(function* () { |
| 574 | 606 | }, |
| 575 | 607 | ); |
| 576 | 608 | |
| 577 | | if (result.code !== 0) { |
| 578 | | const stderr = result.stderr.trim(); |
| 609 | if (localBranchResult.code !== 0) { |
| 610 | const stderr = localBranchResult.stderr.trim(); |
| 579 | 611 | if (stderr.toLowerCase().includes("not a git repository")) { |
| 580 | 612 | return { branches: [], isRepo: false }; |
| 581 | 613 | } |
| ... | ... | @@ -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 | 624 | executeGit( |
| 593 | 625 | "GitCore.listBranches.defaultRef", |
| ... | ... | @@ -607,6 +639,15 @@ const makeGitCore = Effect.gen(function* () { |
| 607 | 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 | 651 | branchRecencyPromise, |
| 611 | 652 | ], |
| 612 | 653 | { concurrency: "unbounded" }, |
| ... | ... | @@ -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 | 681 | .split("\n") |
| 641 | | .map((line) => line.trim()) |
| 642 | | .filter((line) => line.length > 0) |
| 643 | | .map((line) => { |
| 644 | | const name = line.replace(/^[*+]\s+/, ""); |
| 645 | | return { |
| 646 | | name, |
| 647 | | current: line.startsWith("* "), |
| 648 | | isDefault: name === defaultBranch, |
| 649 | | worktreePath: worktreeMap.get(name) ?? null, |
| 650 | | }; |
| 651 | | }) |
| 682 | .map(parseBranchLine) |
| 683 | .filter((branch): branch is { name: string; current: boolean } => branch !== null) |
| 684 | .map((branch) => ({ |
| 685 | name: branch.name, |
| 686 | current: branch.current, |
| 687 | isRemote: false, |
| 688 | isDefault: branch.name === defaultBranch, |
| 689 | worktreePath: worktreeMap.get(branch.name) ?? null, |
| 690 | })) |
| 652 | 691 | .toSorted((a, b) => { |
| 653 | 692 | const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; |
| 654 | 693 | const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; |
| ... | ... | @@ -660,6 +699,29 @@ const makeGitCore = Effect.gen(function* () { |
| 660 | 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 | 725 | return { branches, isRepo: true }; |
| 664 | 726 | }); |
| 665 | 727 | |
| ... | ... | @@ -719,7 +781,63 @@ const makeGitCore = Effect.gen(function* () { |
| 719 | 781 | |
| 720 | 782 | const checkoutBranch: GitCoreShape["checkoutBranch"] = (input) => |
| 721 | 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 | 841 | timeoutMs: 10_000, |
| 724 | 842 | fallbackErrorMessage: "git checkout failed", |
| 725 | 843 | }); |