authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-28 16:24:42-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-28 21:56:22-08:00
log3c66cdf2fe2541b96077a4c6cc48bf60617baf0b
treeac9be8226fb8a1aa8d58e989aaa6555ca8a45a9f
parentdb31c362135d7089766111eb3ed8e5a2f2cc0397

Support remote branch listing and tracking checkout flow

- Include remote refs in branch listings, ordered after local branches - Auto-track remote branches on checkout when no local branch exists - Update branch picker to label remote branches and store local branch name after checkout

5 files changed, 205 insertions(+), 25 deletions(-)

apps/server/src/git/Layers/GitCore.test.ts+44
......@@ -370,6 +370,50 @@ it.layer(TestLayer)("git integration", (it) => {
370370 expect(result.branches.every((b) => b.isDefault === false)).toBe(true);
371371 }),
372372 );
373
374 it.effect("lists local branches first and remote branches last", () =>
375 Effect.gen(function* () {
376 const remote = yield* makeTmpDir();
377 const tmp = yield* makeTmpDir();
378
379 yield* git(remote, ["init", "--bare"]);
380 yield* initRepoWithCommit(tmp);
381 const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find(
382 (branch) => branch.current,
383 )!.name;
384
385 yield* git(tmp, ["remote", "add", "origin", remote]);
386 yield* git(tmp, ["push", "-u", "origin", defaultBranch]);
387
388 yield* createGitBranch({ cwd: tmp, branch: "feature/local-only" });
389
390 const remoteOnlyBranch = "feature/remote-only";
391 yield* checkoutGitBranch({ cwd: tmp, branch: defaultBranch });
392 yield* git(tmp, ["checkout", "-b", remoteOnlyBranch]);
393 yield* git(tmp, ["push", "-u", "origin", remoteOnlyBranch]);
394 yield* git(tmp, ["checkout", defaultBranch]);
395 yield* git(tmp, ["branch", "-D", remoteOnlyBranch]);
396
397 const result = yield* listGitBranches({ cwd: tmp });
398 const firstRemoteIndex = result.branches.findIndex((branch) => branch.isRemote);
399
400 expect(firstRemoteIndex).toBeGreaterThan(0);
401 expect(result.branches.slice(0, firstRemoteIndex).every((branch) => !branch.isRemote)).toBe(
402 true,
403 );
404 expect(result.branches.slice(firstRemoteIndex).every((branch) => branch.isRemote)).toBe(
405 true,
406 );
407 expect(
408 result.branches.some((branch) => branch.name === "feature/local-only" && !branch.isRemote),
409 ).toBe(true);
410 expect(
411 result.branches.some(
412 (branch) => branch.name === "origin/feature/remote-only" && branch.isRemote,
413 ),
414 ).toBe(true);
415 }),
416 );
373417 });
374418
375419 // ── checkoutGitBranch ──
apps/server/src/git/Layers/GitCore.ts+136-18
......@@ -76,6 +76,33 @@ function parsePorcelainPath(line: string): string | null {
7676 return filePath.length > 0 ? filePath : null;
7777}
7878
79function 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
93function 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
79106function commandLabel(args: readonly string[]): string {
80107 return `git ${args.join(" ")}`;
81108}
......@@ -264,7 +291,12 @@ const makeGitCore = Effect.gen(function* () {
264291 const branchRecency = yield* executeGit(
265292 "GitCore.readBranchRecency",
266293 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 ],
268300 {
269301 timeoutMs: 15_000,
270302 allowNonZeroExit: true,
......@@ -564,7 +596,7 @@ const makeGitCore = Effect.gen(function* () {
564596 const branchRecencyPromise = readBranchRecency(input.cwd).pipe(
565597 Effect.catch(() => Effect.succeed(new Map<string, number>())),
566598 );
567 const result = yield* executeGit(
599 const localBranchResult = yield* executeGit(
568600 "GitCore.listBranches.branchNoColor",
569601 input.cwd,
570602 ["branch", "--no-color"],
......@@ -574,8 +606,8 @@ const makeGitCore = Effect.gen(function* () {
574606 },
575607 );
576608
577 if (result.code !== 0) {
578 const stderr = result.stderr.trim();
609 if (localBranchResult.code !== 0) {
610 const stderr = localBranchResult.stderr.trim();
579611 if (stderr.toLowerCase().includes("not a git repository")) {
580612 return { branches: [], isRepo: false };
581613 }
......@@ -587,7 +619,7 @@ const makeGitCore = Effect.gen(function* () {
587619 );
588620 }
589621
590 const [defaultRef, worktreeList, branchLastCommit] = yield* Effect.all(
622 const [defaultRef, worktreeList, remoteBranchResult, branchLastCommit] = yield* Effect.all(
591623 [
592624 executeGit(
593625 "GitCore.listBranches.defaultRef",
......@@ -607,6 +639,15 @@ const makeGitCore = Effect.gen(function* () {
607639 allowNonZeroExit: true,
608640 },
609641 ),
642 executeGit(
643 "GitCore.listBranches.remoteBranches",
644 input.cwd,
645 ["branch", "--no-color", "--remotes"],
646 {
647 timeoutMs: 10_000,
648 allowNonZeroExit: true,
649 },
650 ),
610651 branchRecencyPromise,
611652 ],
612653 { concurrency: "unbounded" },
......@@ -636,19 +677,17 @@ const makeGitCore = Effect.gen(function* () {
636677 }
637678 }
638679
639 const branches = result.stdout
680 const localBranches = localBranchResult.stdout
640681 .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 }))
652691 .toSorted((a, b) => {
653692 const aPriority = a.current ? 0 : a.isDefault ? 1 : 2;
654693 const bPriority = b.current ? 0 : b.isDefault ? 1 : 2;
......@@ -660,6 +699,29 @@ const makeGitCore = Effect.gen(function* () {
660699 return a.name.localeCompare(b.name);
661700 });
662701
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
663725 return { branches, isRepo: true };
664726 });
665727
......@@ -719,7 +781,63 @@ const makeGitCore = Effect.gen(function* () {
719781
720782 const checkoutBranch: GitCoreShape["checkoutBranch"] = (input) =>
721783 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, {
723841 timeoutMs: 10_000,
724842 fallbackErrorMessage: "git checkout failed",
725843 });
apps/server/src/git/Services/GitCore.ts+1-1
......@@ -101,7 +101,7 @@ export interface GitCoreShape {
101101 ) => Effect.Effect<string | null, GitCommandError>;
102102
103103 /**
104 * List local branches and branch metadata.
104 * List local + remote branches and branch metadata.
105105 */
106106 readonly listBranches: (
107107 input: GitListBranchesInput,
apps/web/src/components/BranchToolbar.tsx+23-6
......@@ -31,6 +31,14 @@ interface BranchToolbarProps {
3131 onComposerFocusRequest?: () => void;
3232}
3333
34function deriveLocalBranchNameFromRemoteRef(branchName: string): string {
35 const separatorIndex = branchName.indexOf("/");
36 if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) {
37 return branchName;
38 }
39 return branchName.slice(separatorIndex + 1);
40}
41
3442export default function BranchToolbar({
3543 threadId,
3644 envMode,
......@@ -174,10 +182,14 @@ export default function BranchToolbar({
174182 return;
175183 }
176184
185 const selectedBranchName = branch.isRemote
186 ? deriveLocalBranchNameFromRemoteRef(branch.name)
187 : branch.name;
188
177189 checkoutMutation.mutate(branch.name, {
178190 onSuccess: () => {
179191 setThreadError(null);
180 setThreadBranch(branch.name, activeWorktreePath);
192 setThreadBranch(selectedBranchName, activeWorktreePath);
181193 setIsBranchMenuOpen(false);
182194 onComposerFocusRequest?.();
183195 },
......@@ -283,6 +295,15 @@ export default function BranchToolbar({
283295
284296 const hasSecondaryWorktree =
285297 branch.worktreePath && branch.worktreePath !== activeProject.cwd;
298 const badge = branch.current
299 ? "current"
300 : hasSecondaryWorktree
301 ? "worktree"
302 : branch.isRemote
303 ? "remote"
304 : branch.isDefault
305 ? "default"
306 : null;
286307 return (
287308 <ComboboxItem
288309 hideIndicator
......@@ -295,11 +316,7 @@ export default function BranchToolbar({
295316 >
296317 <div className="flex w-full items-center justify-between gap-2">
297318 <span className="truncate">{itemValue}</span>
298 {(branch.current || branch.isDefault || hasSecondaryWorktree) && (
299 <span className="shrink-0 text-[10px] text-muted-foreground/45">
300 {branch.current ? "current" : hasSecondaryWorktree ? "worktree" : "default"}
301 </span>
302 )}
319 {badge && <span className="shrink-0 text-[10px] text-muted-foreground/45">{badge}</span>}
303320 </div>
304321 </ComboboxItem>
305322 );
packages/contracts/src/git.ts+1
......@@ -23,6 +23,7 @@ export type GitStatusPrState = typeof GitStatusPrState.Type;
2323
2424export const GitBranch = Schema.Struct({
2525 name: TrimmedNonEmptyStringSchema,
26 isRemote: Schema.optional(Schema.Boolean),
2627 current: Schema.Boolean,
2728 isDefault: Schema.Boolean,
2829 worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),