From 51ee17f822bab17d683715c47281bb63394dcac3 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 11 Apr 2026 13:11:23 -0700 Subject: [PATCH 1/4] Workspace creation --- .../WORKSPACE_CREATION_FALLBACK.md | 266 ++++++++++++++++++ .../utils/resolve-start-point.ts | 44 +++ .../workspace-creation/workspace-creation.ts | 17 +- .../test/resolve-start-point.test.ts | 85 ++++++ 4 files changed, 410 insertions(+), 2 deletions(-) create mode 100644 packages/host-service/WORKSPACE_CREATION_FALLBACK.md create mode 100644 packages/host-service/src/trpc/router/workspace-creation/utils/resolve-start-point.ts create mode 100644 packages/host-service/test/resolve-start-point.test.ts diff --git a/packages/host-service/WORKSPACE_CREATION_FALLBACK.md b/packages/host-service/WORKSPACE_CREATION_FALLBACK.md new file mode 100644 index 00000000000..75e2bd9a28e --- /dev/null +++ b/packages/host-service/WORKSPACE_CREATION_FALLBACK.md @@ -0,0 +1,266 @@ +# V2 Workspace Creation: Prefer `origin/main` with Fallback + +## Problem + +V2 workspace creation uses whatever `baseBranch` the UI provides, defaulting to `HEAD`: + +```ts +// workspace-creation.ts:381 +const baseBranch = input.composer.baseBranch || "HEAD"; +``` + +New workspaces often branch off a stale local `main` instead of the latest `origin/main`. The v1 path already prefers `origin/`, but v2 has no equivalent. + +--- + +## How Others Solve This + +### VS Code (Copilot worktree creation) + +**`chatSessionWorktreeServiceImpl.ts:79-92`** + +Resolves the branch's **upstream tracking ref** via `getBranch()`: + +```ts +if (isAgentSessionsWorkspace && baseBranch) { + const branchDetails = await gitService.getBranch(repo, baseBranch); + if (branchDetails?.upstream?.remote && branchDetails.upstream?.name) { + baseBranch = `${branchDetails.upstream.remote}/${branchDetails.upstream.name}`; + } +} +// Then: git worktree add -b --no-track +``` + +- Uses git's tracking config — works with non-`origin` remotes automatically +- No-op if tracking isn't configured (freshly cloned repos) +- No fetch before creation — relies on last background fetch +- Passes `--no-track` so the new branch doesn't inherit upstream tracking + +### T3Code (worktree creation) + +**`GitCore.ts:1896-1917`** + +No fallback in `createWorktree` itself — passes baseBranch straight through. But `resolveBaseBranchForNoUpstream` (line 1068) has a chain for other flows: + +``` +1. git config: branch..gh-merge-base +2. git symbolic-ref refs/remotes//HEAD (remote default branch) +3. Candidates ["main", "master"] — check local refs/heads/ then remote refs/remotes/ +``` + +- Has a **15-second cache-based upstream refresh** (`git fetch --quiet --no-tags`) for status checks (not worktree creation) +- Returns `origin/main` when only the remote branch exists +- Resolves primary remote dynamically (`origin` -> first remote -> error) + +### GitHub Desktop (branch creation) + +**`create-branch.ts:1-49`, `find-default-branch.ts:21-68`, `git/branch.ts:21-38`** + +Multi-layered approach with a `StartPoint` enum and explicit priority chain: + +**Default branch resolution** (`findDefaultBranch`): +``` +1. git symbolic-ref refs/remotes//HEAD (what remote considers default) +2. git config init.defaultBranch (local git config) +3. Hardcoded "main" +``` + +Then finds the best local representation in priority order: +``` +1. Local branch that TRACKS the remote default (e.g., local main tracking origin/main) +2. Local branch with same NAME as remote default (e.g., local main) +3. Remote tracking branch itself (e.g., origin/main) +``` + +**Branch creation itself:** +- `StartPoint.UpstreamDefaultBranch` -> uses `upstream/main`, passes `--no-track` +- `StartPoint.DefaultBranch` -> uses `main` (local branch name) +- `StartPoint.CurrentBranch` / `Head` -> uses current HEAD +- Fallback chain: `UpstreamDefaultBranch` -> `DefaultBranch` -> `CurrentBranch` -> `Head` + +**Freshness:** Background fetcher runs every ~1 hour (min 5 min). After each fetch, runs `git remote set-head -a ` to refresh the remote HEAD symref. No fetch at branch creation time. + +### Superset v1 + +**`workspace-init.ts:217-273`** + +`resolveLocalStartPoint`: +``` +1. origin/ (git rev-parse --verify --quiet) +2. locally +3. Scan common branches: main, master, develop, trunk (both origin/ and local) +``` + +- Fast: `rev-parse` is local I/O only (<5ms) +- No network calls + +--- + +## Comparison + +| | VS Code | T3Code | GitHub Desktop | Superset v1 | +|--|---------|--------|----------------|-------------| +| **Strategy** | Upstream tracking lookup | Config -> symbolic-ref -> candidates | Symbolic-ref -> config -> "main" + local/remote search | `origin/` prefix -> local -> scan | +| **Prefers remote ref?** | Yes (via upstream) | Yes (when only remote exists) | Prefers local that tracks remote | Yes (`origin/` first) | +| **Handles non-origin remotes?** | Yes (reads tracking config) | Yes (resolves primary remote) | Yes (contribution target remote) | No (hardcodes `origin/`) | +| **Default branch detection** | N/A (baseBranch always provided) | `symbolic-ref refs/remotes//HEAD` | `symbolic-ref` + `init.defaultBranch` + `"main"` | Hardcoded `"main"` | +| **Fetches before creation?** | No | No (separate 15s cache for status) | No (background hourly fetch) | No | +| **`--no-track`?** | Yes (always) | No | Only for upstream default branch | No | +| **Git ops at creation time** | 1 getBranch | 0 (resolution is separate) | 0 (pre-resolved) | 1-2 rev-parse | +| **Complexity** | Low | High (Effect services, caches) | Medium (enum + multi-layer resolution) | Low | + +--- + +## Proposed Approach + +Combine **Superset v1's simplicity** with **T3Code/GitHub Desktop's dynamic default branch detection**. + +### Resolution order + +Given `baseBranch` from UI: + +``` +If baseBranch provided (e.g., "develop"): + 1. origin/ — freshest remote-tracking ref + 2. locally — fallback if origin not fetched + 3. HEAD — ultimate fallback + +If baseBranch NOT provided: + 1. Resolve repo default via: git symbolic-ref refs/remotes/origin/HEAD --short + (strips "origin/" prefix to get e.g. "main") + Falls back to "main" if symbolic-ref fails + 2. Then same chain: origin/ -> -> HEAD +``` + +Each check uses `git rev-parse --verify --quiet` — local only, <5ms. + +**Why this over the alternatives:** +- **Over VS Code's approach**: Upstream tracking lookup is elegant but silently no-ops when tracking isn't configured. Direct `origin/` check is more reliable. +- **Over T3Code's approach**: `gh-merge-base` config and GitHub CLI API calls are too heavy for a hot path. +- **Over GitHub Desktop's approach**: Pre-resolved state + background fetcher is great for a long-running GUI app, but host-service is request-driven — we need to resolve at call time. +- **Over v1's common-branch scan**: Unnecessary when we can detect the actual default branch name via `symbolic-ref`. Scanning `master`/`develop`/`trunk` is a guess; `symbolic-ref` is authoritative. + +### New file: `utils/resolve-start-point.ts` + +```ts +import type { SimpleGit } from "simple-git"; + +export async function resolveStartPoint( + git: SimpleGit, + baseBranch: string | undefined, +): Promise<{ ref: string; resolvedFrom: string }> { + const branch = baseBranch?.trim() || (await resolveDefaultBranchName(git)); + + const originRef = `origin/${branch}`; + if (await refExists(git, originRef)) { + return { ref: originRef, resolvedFrom: `remote-tracking (${originRef})` }; + } + + if (await refExists(git, branch)) { + return { ref: branch, resolvedFrom: `local (${branch})` }; + } + + return { ref: "HEAD", resolvedFrom: `fallback (HEAD), "${branch}" not found` }; +} + +async function resolveDefaultBranchName(git: SimpleGit): Promise { + try { + const ref = await git.raw([ + "symbolic-ref", + "refs/remotes/origin/HEAD", + "--short", + ]); + return ref.trim().replace(/^origin\//, ""); + } catch { + return "main"; + } +} + +async function refExists(git: SimpleGit, ref: string): Promise { + try { + await git.raw(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]); + return true; + } catch { + return false; + } +} +``` + +### Change in `workspace-creation.ts` (lines 380-392) + +```diff + const git = await ctx.git(localProject.repoPath); +- const baseBranch = input.composer.baseBranch || "HEAD"; ++ const { ref: startPoint, resolvedFrom } = await resolveStartPoint( ++ git, ++ input.composer.baseBranch, ++ ); ++ console.log( ++ `[workspaceCreation.create] start point resolved: ${startPoint} (${resolvedFrom})`, ++ ); + + await git.raw([ + "worktree", "add", + "-b", branchName, + worktreePath, +- baseBranch, ++ startPoint, + ]); +``` + +--- + +### `--no-track` on worktree creation + +When branching from `origin/main`, git auto-sets tracking so `git push` targets `origin/main` — not what users want. We need to prevent this. + +- **VS Code**: `--no-track` flag +- **GitHub Desktop**: `--no-track` for upstream fork branches +- **Superset v1**: `^{commit}` suffix (dereferences to raw SHA, same effect) + +We'll use `--no-track` (more readable than `^{commit}`) and rely on `push.autoSetupRemote` for first-push tracking (already set by v1's worktree init). + +```diff + await git.raw([ + "worktree", "add", ++ "--no-track", + "-b", branchName, + worktreePath, + startPoint, + ]); +``` + +--- + +## Future consideration: periodic background fetch + +Host-service is long-running, so a T3Code/GitHub Desktop-style **background fetch** could keep `origin/*` refs fresh without adding latency to workspace creation. Options: + +- **Periodic fetch**: e.g., `git fetch --quiet --no-tags origin` every N minutes per repo (T3Code uses 15s for status, GitHub Desktop uses ~1hr) +- **Pre-scan on project load**: fetch once when a project is first resolved, then periodically thereafter +- **Cache with TTL**: track last-fetch time per repo, only fetch if stale (like T3Code's `StatusUpstreamRefreshCache`) + +This is heavier and introduces credential handling + concurrency concerns, so not included in the initial implementation. But it would make `origin/main` reliably fresh rather than depending on the user's last manual/IDE fetch. + +--- + +## Performance + +| | Git ops | Latency | +|--|---------|---------| +| Current | 0 | 0ms | +| New (baseBranch provided, happy path) | 1 rev-parse | ~3ms | +| New (baseBranch provided, worst case) | 2 rev-parse | ~8ms | +| New (no baseBranch, happy path) | 1 symbolic-ref + 1 rev-parse | ~6ms | +| New (no baseBranch, worst case) | 1 symbolic-ref + 2 rev-parse | ~11ms | +| `git worktree add` itself | — | 100-500ms | + +--- + +## Files + +| File | Action | +|------|--------| +| `src/trpc/router/workspace-creation/utils/resolve-start-point.ts` | Create | +| `src/trpc/router/workspace-creation/workspace-creation.ts` | Modify (lines 380-392) | +| `test/resolve-start-point.test.ts` | Create | diff --git a/packages/host-service/src/trpc/router/workspace-creation/utils/resolve-start-point.ts b/packages/host-service/src/trpc/router/workspace-creation/utils/resolve-start-point.ts new file mode 100644 index 00000000000..212e94fcc7b --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/utils/resolve-start-point.ts @@ -0,0 +1,44 @@ +import type { SimpleGit } from "simple-git"; + +export async function resolveStartPoint( + git: SimpleGit, + baseBranch: string | undefined, +): Promise<{ ref: string; resolvedFrom: string }> { + const branch = baseBranch?.trim() || (await resolveDefaultBranchName(git)); + + const originRef = `origin/${branch}`; + if (await refExists(git, originRef)) { + return { ref: originRef, resolvedFrom: `remote-tracking (${originRef})` }; + } + + if (await refExists(git, branch)) { + return { ref: branch, resolvedFrom: `local (${branch})` }; + } + + return { + ref: "HEAD", + resolvedFrom: `fallback (HEAD), "${branch}" not found`, + }; +} + +async function resolveDefaultBranchName(git: SimpleGit): Promise { + try { + const ref = await git.raw([ + "symbolic-ref", + "refs/remotes/origin/HEAD", + "--short", + ]); + return ref.trim().replace(/^origin\//, ""); + } catch { + return "main"; + } +} + +async function refExists(git: SimpleGit, ref: string): Promise { + try { + await git.raw(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]); + return true; + } catch { + return false; + } +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts index c6785f407fe..0c45cf52b68 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts @@ -8,6 +8,7 @@ import { z } from "zod"; import { projects, workspaces } from "../../../db/schema"; import type { HostServiceContext } from "../../../types"; import { protectedProcedure, router } from "../../index"; +import { resolveStartPoint } from "./utils/resolve-start-point"; import { deduplicateBranchName } from "./utils/sanitize-branch"; // ── In-memory create progress (polled by renderer) ────────────────── @@ -378,17 +379,29 @@ export const workspaceCreationRouter = router({ ); const git = await ctx.git(localProject.repoPath); - const baseBranch = input.composer.baseBranch || "HEAD"; + + // Resolve the best start point: prefer origin/ for freshest code, + // fall back to local branch, then HEAD. + const { ref: startPoint, resolvedFrom } = await resolveStartPoint( + git, + input.composer.baseBranch, + ); + console.log( + `[workspaceCreation.create] start point resolved: ${startPoint} (${resolvedFrom})`, + ); // Always create a new branch — never check out an existing one. // Checking out existing branches is a separate intent (e.g. createFromPr). + // --no-track prevents the new branch from tracking the remote ref + // (e.g. origin/main); push.autoSetupRemote handles first-push tracking. await git.raw([ "worktree", "add", + "--no-track", "-b", branchName, worktreePath, - baseBranch, + startPoint, ]); setProgress(input.pendingId, "registering"); diff --git a/packages/host-service/test/resolve-start-point.test.ts b/packages/host-service/test/resolve-start-point.test.ts new file mode 100644 index 00000000000..b2db121a0d0 --- /dev/null +++ b/packages/host-service/test/resolve-start-point.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, mock, test } from "bun:test"; +import { resolveStartPoint } from "../src/trpc/router/workspace-creation/utils/resolve-start-point"; + +function createMockGit(existingRefs: Set) { + return { + raw: mock(async (args: string[]) => { + // Handle rev-parse --verify --quiet ^{commit} + if (args[0] === "rev-parse" && args[1] === "--verify") { + const ref = args[3]?.replace("^{commit}", "") ?? ""; + if (existingRefs.has(ref)) return ""; + throw new Error(`fatal: Needed a single revision`); + } + // Handle symbolic-ref refs/remotes/origin/HEAD --short + if (args[0] === "symbolic-ref" && args[1] === "refs/remotes/origin/HEAD") { + if (existingRefs.has("__symbolic_ref__")) { + return existingRefs.has("__default_master__") + ? "origin/master" + : "origin/main"; + } + throw new Error("fatal: ref refs/remotes/origin/HEAD is not a symbolic ref"); + } + throw new Error(`Unexpected raw args: ${args.join(" ")}`); + }), + } as never; +} + +describe("resolveStartPoint", () => { + test("prefers origin/ when it exists", async () => { + const git = createMockGit(new Set(["origin/main", "main"])); + const result = await resolveStartPoint(git, "main"); + + expect(result.ref).toBe("origin/main"); + expect(result.resolvedFrom).toContain("remote-tracking"); + }); + + test("falls back to local branch when origin/ missing", async () => { + const git = createMockGit(new Set(["main"])); + const result = await resolveStartPoint(git, "main"); + + expect(result.ref).toBe("main"); + expect(result.resolvedFrom).toContain("local"); + }); + + test("falls back to HEAD when neither exists", async () => { + const git = createMockGit(new Set()); + const result = await resolveStartPoint(git, "main"); + + expect(result.ref).toBe("HEAD"); + expect(result.resolvedFrom).toContain("fallback"); + expect(result.resolvedFrom).toContain('"main" not found'); + }); + + test("works with explicit branch name", async () => { + const git = createMockGit(new Set(["origin/develop", "develop"])); + const result = await resolveStartPoint(git, "develop"); + + expect(result.ref).toBe("origin/develop"); + expect(result.resolvedFrom).toContain("origin/develop"); + }); + + test("resolves default branch via symbolic-ref when baseBranch not provided", async () => { + const git = createMockGit( + new Set(["__symbolic_ref__", "__default_master__", "origin/master", "master"]), + ); + const result = await resolveStartPoint(git, undefined); + + expect(result.ref).toBe("origin/master"); + expect(result.resolvedFrom).toContain("remote-tracking"); + }); + + test("defaults to 'main' when symbolic-ref fails and baseBranch not provided", async () => { + const git = createMockGit(new Set(["origin/main"])); + const result = await resolveStartPoint(git, undefined); + + expect(result.ref).toBe("origin/main"); + expect(result.resolvedFrom).toContain("remote-tracking"); + }); + + test("handles empty/whitespace baseBranch as undefined", async () => { + const git = createMockGit(new Set(["origin/main"])); + const result = await resolveStartPoint(git, " "); + + expect(result.ref).toBe("origin/main"); + }); +}); From fd4d7cf852ea106be69e48a73af8f7aa5015b5a4 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 11 Apr 2026 16:47:59 -0700 Subject: [PATCH 2/4] feat(host-service): add targeted fetch before worktree creation When resolveStartPoint resolves to an origin/* ref, fetch just that single branch before creating the worktree to ensure we branch from the latest remote state. Fails gracefully if offline or auth expired. Also updates the design doc with cross-product comparison (VS Code, T3Code, GitHub Desktop) and documents the fetch strategy. --- .../WORKSPACE_CREATION_FALLBACK.md | 85 ++++++++++++++++--- .../workspace-creation/workspace-creation.ts | 14 +++ 2 files changed, 85 insertions(+), 14 deletions(-) diff --git a/packages/host-service/WORKSPACE_CREATION_FALLBACK.md b/packages/host-service/WORKSPACE_CREATION_FALLBACK.md index 75e2bd9a28e..c8d452d1082 100644 --- a/packages/host-service/WORKSPACE_CREATION_FALLBACK.md +++ b/packages/host-service/WORKSPACE_CREATION_FALLBACK.md @@ -98,16 +98,16 @@ Then finds the best local representation in priority order: ## Comparison -| | VS Code | T3Code | GitHub Desktop | Superset v1 | -|--|---------|--------|----------------|-------------| -| **Strategy** | Upstream tracking lookup | Config -> symbolic-ref -> candidates | Symbolic-ref -> config -> "main" + local/remote search | `origin/` prefix -> local -> scan | -| **Prefers remote ref?** | Yes (via upstream) | Yes (when only remote exists) | Prefers local that tracks remote | Yes (`origin/` first) | -| **Handles non-origin remotes?** | Yes (reads tracking config) | Yes (resolves primary remote) | Yes (contribution target remote) | No (hardcodes `origin/`) | -| **Default branch detection** | N/A (baseBranch always provided) | `symbolic-ref refs/remotes//HEAD` | `symbolic-ref` + `init.defaultBranch` + `"main"` | Hardcoded `"main"` | -| **Fetches before creation?** | No | No (separate 15s cache for status) | No (background hourly fetch) | No | -| **`--no-track`?** | Yes (always) | No | Only for upstream default branch | No | -| **Git ops at creation time** | 1 getBranch | 0 (resolution is separate) | 0 (pre-resolved) | 1-2 rev-parse | -| **Complexity** | Low | High (Effect services, caches) | Medium (enum + multi-layer resolution) | Low | +| | VS Code | T3Code | GitHub Desktop | Superset v1 | **Superset v2 (this PR)** | +|--|---------|--------|----------------|-------------|--------------------------| +| **Strategy** | Upstream tracking lookup | Config -> symbolic-ref -> candidates | Symbolic-ref -> config -> "main" + local/remote search | `origin/` prefix -> local -> scan | `symbolic-ref` default + `origin/` -> local -> HEAD | +| **Prefers remote ref?** | Yes (via upstream) | Yes (when only remote exists) | Prefers local that tracks remote | Yes (`origin/` first) | Yes (`origin/` first) | +| **Handles non-origin remotes?** | Yes (reads tracking config) | Yes (resolves primary remote) | Yes (contribution target remote) | No (hardcodes `origin/`) | No (hardcodes `origin/`) | +| **Default branch detection** | N/A (baseBranch always provided) | `symbolic-ref refs/remotes//HEAD` | `symbolic-ref` + `init.defaultBranch` + `"main"` | Hardcoded `"main"` | `symbolic-ref refs/remotes/origin/HEAD` -> `"main"` | +| **Fetches before creation?** | No | No (separate 15s cache for status) | No (background hourly fetch) | No | **Yes — targeted single-ref fetch** | +| **`--no-track`?** | Yes (always) | No | Only for upstream default branch | No (`^{commit}` instead) | Yes (always) | +| **Git ops at creation time** | 1 getBranch | 0 (resolution is separate) | 0 (pre-resolved) | 1-2 rev-parse | 1 symbolic-ref + 1-2 rev-parse + 1 fetch | +| **Complexity** | Low | High (Effect services, caches) | Medium (enum + multi-layer resolution) | Low | Low | --- @@ -232,15 +232,72 @@ We'll use `--no-track` (more readable than `^{commit}`) and rely on `push.autoSe --- -## Future consideration: periodic background fetch +## Targeted fetch at create time -Host-service is long-running, so a T3Code/GitHub Desktop-style **background fetch** could keep `origin/*` refs fresh without adding latency to workspace creation. Options: +`resolveStartPoint` reads local `origin/*` refs, which are only as fresh as the last `git fetch`. Rather than fetching everything or guessing staleness, we **fetch only the single ref we resolved to, right before creating the worktree**. + +### Flow + +``` +resolveStartPoint(git, baseBranch) + -> resolves to e.g. "origin/develop" + +If resolved ref starts with "origin/": + -> extract branch name ("develop") + -> git fetch origin develop --quiet --no-tags + -> (refreshes only that one ref) + +git worktree add --no-track -b +``` + +If `resolveStartPoint` fell back to a local branch or HEAD, no fetch happens — there's nothing remote to refresh. + +### Why this approach + +- **Fetches only what we use**: `git fetch origin ` fetches a single ref + its objects. Fast (~100-300ms for a single branch) vs full `git fetch origin` (can be seconds on large repos). +- **No wasted work**: If the user picked a local branch or HEAD, zero network cost. +- **Right place, right time**: Freshness matters at worktree creation, not at branch listing. No renderer changes needed. +- **Credentials already handled**: `ctx.git(repoPath)` returns a credentialed simple-git instance via `GitCredentialProvider` — fetch just works. +- **Graceful failure**: If fetch fails (offline, auth expired), `resolveStartPoint` already resolved to the best available local ref. We log a warning and proceed. + +### Implementation in `workspace-creation.ts` + +After `resolveStartPoint`, before `git worktree add`: + +```ts +const { ref: startPoint, resolvedFrom } = await resolveStartPoint( + git, + input.composer.baseBranch, +); + +// If we resolved to a remote-tracking ref, fetch just that branch +// to ensure we're branching from the latest remote state. +if (startPoint.startsWith("origin/")) { + const remoteBranch = startPoint.replace(/^origin\//, ""); + try { + await git.fetch(["origin", remoteBranch, "--quiet", "--no-tags"]); + } catch (err) { + console.warn( + `[workspaceCreation.create] fetch origin ${remoteBranch} failed, proceeding with local ref:`, + err, + ); + } +} + +await git.raw([ + "worktree", "add", "--no-track", + "-b", branchName, worktreePath, startPoint, +]); +``` + +### Future: periodic background fetch + +Host-service is long-running, so a T3Code/GitHub Desktop-style **background fetch** could keep `origin/*` refs fresh without any per-request cost. Options: - **Periodic fetch**: e.g., `git fetch --quiet --no-tags origin` every N minutes per repo (T3Code uses 15s for status, GitHub Desktop uses ~1hr) -- **Pre-scan on project load**: fetch once when a project is first resolved, then periodically thereafter - **Cache with TTL**: track last-fetch time per repo, only fetch if stale (like T3Code's `StatusUpstreamRefreshCache`) -This is heavier and introduces credential handling + concurrency concerns, so not included in the initial implementation. But it would make `origin/main` reliably fresh rather than depending on the user's last manual/IDE fetch. +This would make branch listing fresh too (not just worktree creation) but requires more infrastructure (fetch scheduling, concurrency control). --- diff --git a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts index 0c45cf52b68..722bbf9beda 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts @@ -390,6 +390,20 @@ export const workspaceCreationRouter = router({ `[workspaceCreation.create] start point resolved: ${startPoint} (${resolvedFrom})`, ); + // If we resolved to a remote-tracking ref, fetch just that branch + // to ensure we're branching from the latest remote state. + if (startPoint.startsWith("origin/")) { + const remoteBranch = startPoint.replace(/^origin\//, ""); + try { + await git.fetch(["origin", remoteBranch, "--quiet", "--no-tags"]); + } catch (err) { + console.warn( + `[workspaceCreation.create] fetch origin ${remoteBranch} failed, proceeding with local ref:`, + err, + ); + } + } + // Always create a new branch — never check out an existing one. // Checking out existing branches is a separate intent (e.g. createFromPr). // --no-track prevents the new branch from tracking the remote ref From 02fd8a33796d7902c899249c7f4555890f531bac Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 11 Apr 2026 16:59:52 -0700 Subject: [PATCH 3/4] Lint --- .../test/resolve-start-point.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/host-service/test/resolve-start-point.test.ts b/packages/host-service/test/resolve-start-point.test.ts index b2db121a0d0..6218c544cac 100644 --- a/packages/host-service/test/resolve-start-point.test.ts +++ b/packages/host-service/test/resolve-start-point.test.ts @@ -11,13 +11,18 @@ function createMockGit(existingRefs: Set) { throw new Error(`fatal: Needed a single revision`); } // Handle symbolic-ref refs/remotes/origin/HEAD --short - if (args[0] === "symbolic-ref" && args[1] === "refs/remotes/origin/HEAD") { + if ( + args[0] === "symbolic-ref" && + args[1] === "refs/remotes/origin/HEAD" + ) { if (existingRefs.has("__symbolic_ref__")) { return existingRefs.has("__default_master__") ? "origin/master" : "origin/main"; } - throw new Error("fatal: ref refs/remotes/origin/HEAD is not a symbolic ref"); + throw new Error( + "fatal: ref refs/remotes/origin/HEAD is not a symbolic ref", + ); } throw new Error(`Unexpected raw args: ${args.join(" ")}`); }), @@ -60,7 +65,12 @@ describe("resolveStartPoint", () => { test("resolves default branch via symbolic-ref when baseBranch not provided", async () => { const git = createMockGit( - new Set(["__symbolic_ref__", "__default_master__", "origin/master", "master"]), + new Set([ + "__symbolic_ref__", + "__default_master__", + "origin/master", + "master", + ]), ); const result = await resolveStartPoint(git, undefined); From 3cc80ba302a08bc63af5ab5d4a0e8e87f90c2025 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 11 Apr 2026 17:01:40 -0700 Subject: [PATCH 4/4] test(host-service): co-locate resolve-start-point test + add HEAD fallback case Moves the test next to its source per AGENTS.md co-location convention and adds a missing test for the cold-start HEAD fallback path (no baseBranch provided, symbolic-ref fails, no default branch exists). --- .../utils}/resolve-start-point.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) rename packages/host-service/{test => src/trpc/router/workspace-creation/utils}/resolve-start-point.test.ts (88%) diff --git a/packages/host-service/test/resolve-start-point.test.ts b/packages/host-service/src/trpc/router/workspace-creation/utils/resolve-start-point.test.ts similarity index 88% rename from packages/host-service/test/resolve-start-point.test.ts rename to packages/host-service/src/trpc/router/workspace-creation/utils/resolve-start-point.test.ts index 6218c544cac..e2e7bed3a76 100644 --- a/packages/host-service/test/resolve-start-point.test.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/utils/resolve-start-point.test.ts @@ -1,5 +1,5 @@ import { describe, expect, mock, test } from "bun:test"; -import { resolveStartPoint } from "../src/trpc/router/workspace-creation/utils/resolve-start-point"; +import { resolveStartPoint } from "./resolve-start-point"; function createMockGit(existingRefs: Set) { return { @@ -86,6 +86,15 @@ describe("resolveStartPoint", () => { expect(result.resolvedFrom).toContain("remote-tracking"); }); + test("falls back to HEAD when symbolic-ref fails and no default branch exists", async () => { + const git = createMockGit(new Set()); + const result = await resolveStartPoint(git, undefined); + + expect(result.ref).toBe("HEAD"); + expect(result.resolvedFrom).toContain("fallback"); + expect(result.resolvedFrom).toContain('"main" not found'); + }); + test("handles empty/whitespace baseBranch as undefined", async () => { const git = createMockGit(new Set(["origin/main"])); const result = await resolveStartPoint(git, " ");