From 3c66cdf2fe2541b96077a4c6cc48bf60617baf0b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Feb 2026 16:24:42 -0800 Subject: [PATCH] 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 --- apps/server/src/git/Layers/GitCore.test.ts | 44 ++++++ apps/server/src/git/Layers/GitCore.ts | 154 ++++++++++++++++++--- apps/server/src/git/Services/GitCore.ts | 2 +- apps/web/src/components/BranchToolbar.tsx | 29 +++- packages/contracts/src/git.ts | 1 + 5 files changed, 205 insertions(+), 25 deletions(-) diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 32f9942b920a471f6413abbc2a010fc8844cfb50..8845808ac1c33ba3879f5d9975dbe81cf28f966e 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -370,6 +370,50 @@ it.layer(TestLayer)("git integration", (it) => { expect(result.branches.every((b) => b.isDefault === false)).toBe(true); }), ); + + it.effect("lists local branches first and remote branches last", () => + Effect.gen(function* () { + const remote = yield* makeTmpDir(); + const tmp = yield* makeTmpDir(); + + yield* git(remote, ["init", "--bare"]); + yield* initRepoWithCommit(tmp); + const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + (branch) => branch.current, + )!.name; + + yield* git(tmp, ["remote", "add", "origin", remote]); + yield* git(tmp, ["push", "-u", "origin", defaultBranch]); + + yield* createGitBranch({ cwd: tmp, branch: "feature/local-only" }); + + const remoteOnlyBranch = "feature/remote-only"; + yield* checkoutGitBranch({ cwd: tmp, branch: defaultBranch }); + yield* git(tmp, ["checkout", "-b", remoteOnlyBranch]); + yield* git(tmp, ["push", "-u", "origin", remoteOnlyBranch]); + yield* git(tmp, ["checkout", defaultBranch]); + yield* git(tmp, ["branch", "-D", remoteOnlyBranch]); + + const result = yield* listGitBranches({ cwd: tmp }); + const firstRemoteIndex = result.branches.findIndex((branch) => branch.isRemote); + + expect(firstRemoteIndex).toBeGreaterThan(0); + expect(result.branches.slice(0, firstRemoteIndex).every((branch) => !branch.isRemote)).toBe( + true, + ); + expect(result.branches.slice(firstRemoteIndex).every((branch) => branch.isRemote)).toBe( + true, + ); + expect( + result.branches.some((branch) => branch.name === "feature/local-only" && !branch.isRemote), + ).toBe(true); + expect( + result.branches.some( + (branch) => branch.name === "origin/feature/remote-only" && branch.isRemote, + ), + ).toBe(true); + }), + ); }); // ── checkoutGitBranch ── diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index b5fb6b5074a94626f58daecbbd637eef45ad8a29..8226d7d17e9b4a335669d6a9399bf0ba3c199aac 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -76,6 +76,33 @@ function parsePorcelainPath(line: string): string | null { return filePath.length > 0 ? filePath : null; } +function parseBranchLine(line: string): { name: string; current: boolean } | null { + const trimmed = line.trim(); + if (trimmed.length === 0) return null; + + const name = trimmed.replace(/^[*+]\s+/, ""); + // Exclude symbolic refs like: "origin/HEAD -> origin/main". + if (name.includes(" -> ")) return null; + + return { + name, + current: trimmed.startsWith("* "), + }; +} + +function parseRemoteTrackingCandidate( + branchName: string, +): { remoteRef: string; localBranch: string } | null { + const separatorIndex = branchName.indexOf("/"); + if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) return null; + const localBranch = branchName.slice(separatorIndex + 1).trim(); + if (localBranch.length === 0) return null; + return { + remoteRef: branchName.trim(), + localBranch, + }; +} + function commandLabel(args: readonly string[]): string { return `git ${args.join(" ")}`; } @@ -264,7 +291,12 @@ const makeGitCore = Effect.gen(function* () { const branchRecency = yield* executeGit( "GitCore.readBranchRecency", cwd, - ["for-each-ref", "--format=%(refname:short)%09%(committerdate:unix)", "refs/heads"], + [ + "for-each-ref", + "--format=%(refname:short)%09%(committerdate:unix)", + "refs/heads", + "refs/remotes", + ], { timeoutMs: 15_000, allowNonZeroExit: true, @@ -564,7 +596,7 @@ const makeGitCore = Effect.gen(function* () { const branchRecencyPromise = readBranchRecency(input.cwd).pipe( Effect.catch(() => Effect.succeed(new Map())), ); - const result = yield* executeGit( + const localBranchResult = yield* executeGit( "GitCore.listBranches.branchNoColor", input.cwd, ["branch", "--no-color"], @@ -574,8 +606,8 @@ const makeGitCore = Effect.gen(function* () { }, ); - if (result.code !== 0) { - const stderr = result.stderr.trim(); + if (localBranchResult.code !== 0) { + const stderr = localBranchResult.stderr.trim(); if (stderr.toLowerCase().includes("not a git repository")) { return { branches: [], isRepo: false }; } @@ -587,7 +619,7 @@ const makeGitCore = Effect.gen(function* () { ); } - const [defaultRef, worktreeList, branchLastCommit] = yield* Effect.all( + const [defaultRef, worktreeList, remoteBranchResult, branchLastCommit] = yield* Effect.all( [ executeGit( "GitCore.listBranches.defaultRef", @@ -607,6 +639,15 @@ const makeGitCore = Effect.gen(function* () { allowNonZeroExit: true, }, ), + executeGit( + "GitCore.listBranches.remoteBranches", + input.cwd, + ["branch", "--no-color", "--remotes"], + { + timeoutMs: 10_000, + allowNonZeroExit: true, + }, + ), branchRecencyPromise, ], { concurrency: "unbounded" }, @@ -636,19 +677,17 @@ const makeGitCore = Effect.gen(function* () { } } - const branches = result.stdout + const localBranches = localBranchResult.stdout .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .map((line) => { - const name = line.replace(/^[*+]\s+/, ""); - return { - name, - current: line.startsWith("* "), - isDefault: name === defaultBranch, - worktreePath: worktreeMap.get(name) ?? null, - }; - }) + .map(parseBranchLine) + .filter((branch): branch is { name: string; current: boolean } => branch !== null) + .map((branch) => ({ + name: branch.name, + current: branch.current, + isRemote: false, + isDefault: branch.name === defaultBranch, + worktreePath: worktreeMap.get(branch.name) ?? null, + })) .toSorted((a, b) => { const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; @@ -660,6 +699,29 @@ const makeGitCore = Effect.gen(function* () { return a.name.localeCompare(b.name); }); + const remoteBranches = + remoteBranchResult.code === 0 + ? remoteBranchResult.stdout + .split("\n") + .map(parseBranchLine) + .filter((branch): branch is { name: string; current: boolean } => branch !== null) + .map((branch) => ({ + name: branch.name, + current: false, + isRemote: true, + isDefault: false, + worktreePath: null, + })) + .toSorted((a, b) => { + const aLastCommit = branchLastCommit.get(a.name) ?? 0; + const bLastCommit = branchLastCommit.get(b.name) ?? 0; + if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; + return a.name.localeCompare(b.name); + }) + : []; + + const branches = [...localBranches, ...remoteBranches]; + return { branches, isRepo: true }; }); @@ -719,7 +781,63 @@ const makeGitCore = Effect.gen(function* () { const checkoutBranch: GitCoreShape["checkoutBranch"] = (input) => Effect.gen(function* () { - yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, ["checkout", input.branch], { + const trackingCandidate = parseRemoteTrackingCandidate(input.branch); + if (!trackingCandidate) { + yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, ["checkout", input.branch], { + timeoutMs: 10_000, + fallbackErrorMessage: "git checkout failed", + }); + + // Refresh upstream refs in the background so checkout remains responsive. + yield* Effect.sync(() => { + void Effect.runPromise( + refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.catch(() => Effect.void)), + ); + }); + return; + } + + const [localInputExists, remoteExists, localTrackingExists] = yield* Effect.all( + [ + executeGit( + "GitCore.checkoutBranch.localInputExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/heads/${input.branch}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.code === 0)), + executeGit( + "GitCore.checkoutBranch.remoteExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/remotes/${trackingCandidate.remoteRef}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.code === 0)), + executeGit( + "GitCore.checkoutBranch.localTrackingExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/heads/${trackingCandidate.localBranch}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.code === 0)), + ], + { concurrency: "unbounded" }, + ); + + const checkoutArgs = + localInputExists + ? ["checkout", input.branch] + : remoteExists && !localTrackingExists + ? ["checkout", "--track", trackingCandidate.remoteRef] + : ["checkout", localTrackingExists ? trackingCandidate.localBranch : input.branch]; + + yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, { timeoutMs: 10_000, fallbackErrorMessage: "git checkout failed", }); diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index d2544de90921fbee628f5a79f59b743221692ae2..c0c08af739e462b8b9d0eac2dd378ffaca30888a 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -101,7 +101,7 @@ export interface GitCoreShape { ) => Effect.Effect; /** - * List local branches and branch metadata. + * List local + remote branches and branch metadata. */ readonly listBranches: ( input: GitListBranchesInput, diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 904ef96e28a24b89d268c22d139cdd54d8208245..2b40187ba55d7aa260502be2058fb2595178deff 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -31,6 +31,14 @@ interface BranchToolbarProps { onComposerFocusRequest?: () => void; } +function deriveLocalBranchNameFromRemoteRef(branchName: string): string { + const separatorIndex = branchName.indexOf("/"); + if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) { + return branchName; + } + return branchName.slice(separatorIndex + 1); +} + export default function BranchToolbar({ threadId, envMode, @@ -174,10 +182,14 @@ export default function BranchToolbar({ return; } + const selectedBranchName = branch.isRemote + ? deriveLocalBranchNameFromRemoteRef(branch.name) + : branch.name; + checkoutMutation.mutate(branch.name, { onSuccess: () => { setThreadError(null); - setThreadBranch(branch.name, activeWorktreePath); + setThreadBranch(selectedBranchName, activeWorktreePath); setIsBranchMenuOpen(false); onComposerFocusRequest?.(); }, @@ -283,6 +295,15 @@ export default function BranchToolbar({ const hasSecondaryWorktree = branch.worktreePath && branch.worktreePath !== activeProject.cwd; + const badge = branch.current + ? "current" + : hasSecondaryWorktree + ? "worktree" + : branch.isRemote + ? "remote" + : branch.isDefault + ? "default" + : null; return (
{itemValue} - {(branch.current || branch.isDefault || hasSecondaryWorktree) && ( - - {branch.current ? "current" : hasSecondaryWorktree ? "worktree" : "default"} - - )} + {badge && {badge}}
); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 22d8ae9c6b2af5bc2e4c73b46ffb9abc457ec861..8a2b00d86bf365700ea41a243ee54bb0f098ff8c 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -23,6 +23,7 @@ export type GitStatusPrState = typeof GitStatusPrState.Type; export const GitBranch = Schema.Struct({ name: TrimmedNonEmptyStringSchema, + isRemote: Schema.optional(Schema.Boolean), current: Schema.Boolean, isDefault: Schema.Boolean, worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), -- 2.54.0