From ffcf5ea8bb5faa5cc4cb87e7e55c63c8af20f175 Mon Sep 17 00:00:00 2001 From: Gautam Tayal Date: Sun, 29 Mar 2026 12:51:46 +0530 Subject: [PATCH 1/3] feat(workspace): enhance worktree creation with remote branch resolution --- .../src/__tests__/index.test.ts | 144 ++++++++++++++++-- .../plugins/workspace-worktree/src/index.ts | 87 ++++++++--- 2 files changed, 199 insertions(+), 32 deletions(-) diff --git a/packages/plugins/workspace-worktree/src/__tests__/index.test.ts b/packages/plugins/workspace-worktree/src/__tests__/index.test.ts index 2a81ca030..c9118f7fd 100644 --- a/packages/plugins/workspace-worktree/src/__tests__/index.test.ts +++ b/packages/plugins/workspace-worktree/src/__tests__/index.test.ts @@ -60,6 +60,15 @@ function mockGitError(message: string) { mockExecFileAsync.mockRejectedValueOnce(new Error(message)); } +function mockOriginRemote(fetchSucceeds = true) { + mockGitSuccess(""); // git remote get-url origin + if (fetchSucceeds) { + mockGitSuccess(""); // git fetch origin --quiet + } else { + mockGitError("Could not resolve host"); // git fetch origin --quiet + } +} + function makeProject(overrides?: Partial): ProjectConfig { return { name: "test-project", @@ -106,8 +115,8 @@ describe("create() factory", () => { it("uses ~/.worktrees as default base dir", async () => { const ws = create(); - // Mock: fetch, worktree add - mockGitSuccess(""); // fetch + mockOriginRemote(); + mockGitSuccess(""); // git rev-parse --verify --quiet origin/main mockGitSuccess(""); // worktree add const info = await ws.create(makeCreateConfig()); @@ -118,7 +127,8 @@ describe("create() factory", () => { it("uses custom worktreeDir from config", async () => { const ws = create({ worktreeDir: "/custom/worktrees" }); - mockGitSuccess(""); // fetch + mockOriginRemote(); + mockGitSuccess(""); // git rev-parse --verify --quiet origin/main mockGitSuccess(""); // worktree add const info = await ws.create(makeCreateConfig()); @@ -129,7 +139,8 @@ describe("create() factory", () => { it("expands tilde in custom worktreeDir", async () => { const ws = create({ worktreeDir: "~/custom-path" }); - mockGitSuccess(""); // fetch + mockOriginRemote(); + mockGitSuccess(""); // git rev-parse --verify --quiet origin/main mockGitSuccess(""); // worktree add const info = await ws.create(makeCreateConfig()); @@ -142,17 +153,30 @@ describe("workspace.create()", () => { it("calls git fetch and git worktree add with correct args", async () => { const ws = create(); - mockGitSuccess(""); // fetch + mockOriginRemote(); + mockGitSuccess(""); // git rev-parse --verify --quiet origin/main mockGitSuccess(""); // worktree add await ws.create(makeCreateConfig()); - // First call: git fetch origin --quiet + // First call: git remote get-url origin + expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["remote", "get-url", "origin"], { + cwd: "/repo/path", + }); + + // Second call: git fetch origin --quiet expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["fetch", "origin", "--quiet"], { cwd: "/repo/path", }); - // Second call: git worktree add -b + // Third call: git rev-parse --verify --quiet origin/main + expect(mockExecFileAsync).toHaveBeenCalledWith( + "git", + ["rev-parse", "--verify", "--quiet", "origin/main"], + { cwd: "/repo/path" }, + ); + + // Fourth call: git worktree add -b expect(mockExecFileAsync).toHaveBeenCalledWith( "git", [ @@ -170,7 +194,8 @@ describe("workspace.create()", () => { it("creates the project worktree directory", async () => { const ws = create(); - mockGitSuccess(""); // fetch + mockOriginRemote(); + mockGitSuccess(""); // git rev-parse --verify --quiet origin/main mockGitSuccess(""); // worktree add await ws.create(makeCreateConfig()); @@ -183,7 +208,8 @@ describe("workspace.create()", () => { it("continues when fetch fails (offline)", async () => { const ws = create(); - mockGitError("Could not resolve host"); // fetch fails + mockOriginRemote(false); + mockGitSuccess(""); // git rev-parse --verify --quiet origin/main mockGitSuccess(""); // worktree add succeeds const info = await ws.create(makeCreateConfig()); @@ -194,7 +220,8 @@ describe("workspace.create()", () => { it("handles branch already exists by adding worktree then checking out", async () => { const ws = create(); - mockGitSuccess(""); // fetch + mockOriginRemote(); + mockGitSuccess(""); // git rev-parse --verify --quiet origin/main mockGitError("already exists"); // worktree add -b fails mockGitSuccess(""); // worktree add (without -b) mockGitSuccess(""); // checkout @@ -219,7 +246,8 @@ describe("workspace.create()", () => { it("cleans up worktree on checkout failure", async () => { const ws = create(); - mockGitSuccess(""); // fetch + mockOriginRemote(); + mockGitSuccess(""); // git rev-parse --verify --quiet origin/main mockGitError("already exists"); // worktree add -b fails mockGitSuccess(""); // worktree add (without -b) mockGitError("checkout failed: conflict"); // checkout fails @@ -240,7 +268,8 @@ describe("workspace.create()", () => { it("still throws on checkout failure even if cleanup fails", async () => { const ws = create(); - mockGitSuccess(""); // fetch + mockOriginRemote(); + mockGitSuccess(""); // git rev-parse --verify --quiet origin/main mockGitError("already exists"); // worktree add -b fails mockGitSuccess(""); // worktree add (without -b) mockGitError("checkout failed"); // checkout fails @@ -254,7 +283,8 @@ describe("workspace.create()", () => { it("throws for non-already-exists worktree add errors", async () => { const ws = create(); - mockGitSuccess(""); // fetch + mockOriginRemote(); + mockGitSuccess(""); // git rev-parse --verify --quiet origin/main mockGitError("fatal: invalid reference"); // worktree add fails with other error await expect(ws.create(makeCreateConfig())).rejects.toThrow( @@ -297,7 +327,8 @@ describe("workspace.create()", () => { it("returns correct WorkspaceInfo", async () => { const ws = create(); - mockGitSuccess(""); // fetch + mockOriginRemote(); + mockGitSuccess(""); // git rev-parse --verify --quiet origin/main mockGitSuccess(""); // worktree add const info = await ws.create(makeCreateConfig()); @@ -313,7 +344,8 @@ describe("workspace.create()", () => { it("expands tilde in project path", async () => { const ws = create(); - mockGitSuccess(""); // fetch + mockOriginRemote(); + mockGitSuccess(""); // git rev-parse --verify --quiet origin/main mockGitSuccess(""); // worktree add await ws.create( @@ -327,6 +359,88 @@ describe("workspace.create()", () => { cwd: "/mock-home/my-repo", }); }); + + it("uses the local default branch when origin remote is missing", async () => { + const ws = create(); + + mockGitError("fatal: not a git repository"); // git remote get-url origin fails + mockGitSuccess(""); // worktree add + + await ws.create(makeCreateConfig()); + + expect(mockExecFileAsync).toHaveBeenCalledWith( + "git", + [ + "worktree", + "add", + "-b", + "feat/TEST-1", + "/mock-home/.worktrees/myproject/session-1", + "main", + ], + { cwd: "/repo/path" }, + ); + }); +}); + +describe("workspace.restore()", () => { + it("prefers origin branch refs when origin exists", async () => { + const ws = create(); + + mockGitSuccess(""); // git worktree prune + mockOriginRemote(); + mockGitError("fatal: invalid reference"); // git worktree add workspacePath cfg.branch fails + mockGitSuccess(""); // git rev-parse --verify --quiet origin/feat/TEST-1 + mockGitSuccess(""); // git worktree add -b cfg.branch workspacePath origin/feat/TEST-1 + + const info = await ws.restore!(makeCreateConfig(), "/mock-home/.worktrees/myproject/session-1"); + + expect(mockExecFileAsync).toHaveBeenCalledWith( + "git", + [ + "worktree", + "add", + "-b", + "feat/TEST-1", + "/mock-home/.worktrees/myproject/session-1", + "origin/feat/TEST-1", + ], + { cwd: "/repo/path" }, + ); + + expect(info.branch).toBe("feat/TEST-1"); + }); + + it("uses the local default branch when origin remote is missing", async () => { + const ws = create(); + + mockGitSuccess(""); // git worktree prune + mockGitError("fatal: not a git repository"); // git remote get-url origin fails + mockGitError("fatal: invalid reference"); // git worktree add workspacePath cfg.branch fails + mockGitSuccess(""); // git worktree add -b cfg.branch workspacePath main + + const info = await ws.restore!(makeCreateConfig(), "/mock-home/.worktrees/myproject/session-1"); + + expect(mockExecFileAsync).toHaveBeenCalledWith( + "git", + [ + "worktree", + "add", + "-b", + "feat/TEST-1", + "/mock-home/.worktrees/myproject/session-1", + "main", + ], + { cwd: "/repo/path" }, + ); + + expect(info).toEqual({ + path: "/mock-home/.worktrees/myproject/session-1", + branch: "feat/TEST-1", + sessionId: "session-1", + projectId: "myproject", + }); + }); }); describe("workspace.destroy()", () => { diff --git a/packages/plugins/workspace-worktree/src/index.ts b/packages/plugins/workspace-worktree/src/index.ts index 11b9b49e1..c7fabf8a2 100644 --- a/packages/plugins/workspace-worktree/src/index.ts +++ b/packages/plugins/workspace-worktree/src/index.ts @@ -29,6 +29,44 @@ async function git(cwd: string, ...args: string[]): Promise { return stdout.trimEnd(); } +async function hasOriginRemote(cwd: string): Promise { + try { + await git(cwd, "remote", "get-url", "origin"); + return true; + } catch { + return false; + } +} + +async function refExists(cwd: string, ref: string): Promise { + try { + await git(cwd, "rev-parse", "--verify", "--quiet", ref); + return true; + } catch { + return false; + } +} + +async function resolveBaseRef( + repoPath: string, + defaultBranch: string, + options?: { branch?: string; hasOrigin?: boolean }, +): Promise { + const hasOrigin = options?.hasOrigin ?? (await hasOriginRemote(repoPath)); + + if (hasOrigin) { + if (options?.branch) { + const remoteBranch = `origin/${options.branch}`; + if (await refExists(repoPath, remoteBranch)) return remoteBranch; + } + + const remoteDefaultBranch = `origin/${defaultBranch}`; + if (await refExists(repoPath, remoteDefaultBranch)) return remoteDefaultBranch; + } + + return defaultBranch; +} + /** Only allow safe characters in path segments to prevent directory traversal */ const SAFE_PATH_SEGMENT = /^[a-zA-Z0-9_-]+$/; @@ -64,14 +102,18 @@ export function create(config?: Record): Workspace { mkdirSync(projectWorktreeDir, { recursive: true }); - // Fetch latest from remote - try { - await git(repoPath, "fetch", "origin", "--quiet"); - } catch { - // Fetch may fail if offline — continue anyway + const hasOrigin = await hasOriginRemote(repoPath); + + // Fetch latest from remote when origin exists + if (hasOrigin) { + try { + await git(repoPath, "fetch", "origin", "--quiet"); + } catch { + // Fetch may fail if offline — continue anyway + } } - const baseRef = `origin/${cfg.project.defaultBranch}`; + const baseRef = await resolveBaseRef(repoPath, cfg.project.defaultBranch, { hasOrigin }); // Create worktree with a new branch try { @@ -217,24 +259,35 @@ export function create(config?: Record): Workspace { } // Fetch latest - try { - await git(repoPath, "fetch", "origin", "--quiet"); - } catch { - // May fail if offline + const hasOrigin = await hasOriginRemote(repoPath); + if (hasOrigin) { + try { + await git(repoPath, "fetch", "origin", "--quiet"); + } catch { + // May fail if offline + } } // Try to create worktree on the existing branch try { await git(repoPath, "worktree", "add", workspacePath, cfg.branch); } catch { - // Branch might not exist locally — try from origin - const remoteBranch = `origin/${cfg.branch}`; - try { - await git(repoPath, "worktree", "add", "-b", cfg.branch, workspacePath, remoteBranch); - } catch { - // Last resort: create from default branch - const baseRef = `origin/${cfg.project.defaultBranch}`; + const baseRef = await resolveBaseRef(repoPath, cfg.project.defaultBranch, { + branch: cfg.branch, + hasOrigin, + }); + + if (!baseRef.startsWith("origin/")) { + // No remote available — create from the local default branch await git(repoPath, "worktree", "add", "-b", cfg.branch, workspacePath, baseRef); + } else { + // Branch might not exist locally — try the remote ref first, then fall back + // to the local default branch if the remote ref is unavailable. + try { + await git(repoPath, "worktree", "add", "-b", cfg.branch, workspacePath, baseRef); + } catch { + await git(repoPath, "worktree", "add", "-b", cfg.branch, workspacePath, cfg.project.defaultBranch); + } } } From f113f8b80137183245872576e319361836ce009d Mon Sep 17 00:00:00 2001 From: Gautam Tayal Date: Sun, 29 Mar 2026 13:18:41 +0530 Subject: [PATCH 2/3] fix(workspace): improve error handling in worktree creation --- .../src/__tests__/index.test.ts | 48 +++++++++++++++++-- .../plugins/workspace-worktree/src/index.ts | 15 +++++- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/packages/plugins/workspace-worktree/src/__tests__/index.test.ts b/packages/plugins/workspace-worktree/src/__tests__/index.test.ts index c9118f7fd..36db87bff 100644 --- a/packages/plugins/workspace-worktree/src/__tests__/index.test.ts +++ b/packages/plugins/workspace-worktree/src/__tests__/index.test.ts @@ -217,6 +217,46 @@ describe("workspace.create()", () => { expect(info.path).toBe("/mock-home/.worktrees/myproject/session-1"); }); + it("uses refs/heads/ when origin is missing", async () => { + const ws = create(); + + mockGitError("fatal: not a git repository"); // git remote get-url origin fails + mockGitSuccess(""); // git rev-parse --verify --quiet refs/heads/main + mockGitSuccess(""); // worktree add succeeds + + await ws.create(makeCreateConfig()); + + expect(mockExecFileAsync).toHaveBeenCalledWith( + "git", + ["rev-parse", "--verify", "--quiet", "refs/heads/main"], + { cwd: "/repo/path" }, + ); + + expect(mockExecFileAsync).toHaveBeenCalledWith( + "git", + [ + "worktree", + "add", + "-b", + "feat/TEST-1", + "/mock-home/.worktrees/myproject/session-1", + "refs/heads/main", + ], + { cwd: "/repo/path" }, + ); + }); + + it("throws when neither origin nor the local default branch can be resolved", async () => { + const ws = create(); + + mockGitError("fatal: not a git repository"); // git remote get-url origin fails + mockGitError("fatal: invalid reference"); // git rev-parse --verify --quiet refs/heads/main + + await expect(ws.create(makeCreateConfig())).rejects.toThrow( + 'Unable to resolve base ref for default branch "main"', + ); + }); + it("handles branch already exists by adding worktree then checking out", async () => { const ws = create(); @@ -364,6 +404,7 @@ describe("workspace.create()", () => { const ws = create(); mockGitError("fatal: not a git repository"); // git remote get-url origin fails + mockGitSuccess(""); // git rev-parse --verify --quiet refs/heads/main mockGitSuccess(""); // worktree add await ws.create(makeCreateConfig()); @@ -376,7 +417,7 @@ describe("workspace.create()", () => { "-b", "feat/TEST-1", "/mock-home/.worktrees/myproject/session-1", - "main", + "refs/heads/main", ], { cwd: "/repo/path" }, ); @@ -417,7 +458,8 @@ describe("workspace.restore()", () => { mockGitSuccess(""); // git worktree prune mockGitError("fatal: not a git repository"); // git remote get-url origin fails mockGitError("fatal: invalid reference"); // git worktree add workspacePath cfg.branch fails - mockGitSuccess(""); // git worktree add -b cfg.branch workspacePath main + mockGitSuccess(""); // git rev-parse --verify --quiet refs/heads/main + mockGitSuccess(""); // git worktree add -b cfg.branch workspacePath refs/heads/main const info = await ws.restore!(makeCreateConfig(), "/mock-home/.worktrees/myproject/session-1"); @@ -429,7 +471,7 @@ describe("workspace.restore()", () => { "-b", "feat/TEST-1", "/mock-home/.worktrees/myproject/session-1", - "main", + "refs/heads/main", ], { cwd: "/repo/path" }, ); diff --git a/packages/plugins/workspace-worktree/src/index.ts b/packages/plugins/workspace-worktree/src/index.ts index c7fabf8a2..fca11f798 100644 --- a/packages/plugins/workspace-worktree/src/index.ts +++ b/packages/plugins/workspace-worktree/src/index.ts @@ -64,7 +64,10 @@ async function resolveBaseRef( if (await refExists(repoPath, remoteDefaultBranch)) return remoteDefaultBranch; } - return defaultBranch; + const localDefaultBranch = `refs/heads/${defaultBranch}`; + if (await refExists(repoPath, localDefaultBranch)) return localDefaultBranch; + + throw new Error(`Unable to resolve base ref for default branch "${defaultBranch}"`); } /** Only allow safe characters in path segments to prevent directory traversal */ @@ -286,7 +289,15 @@ export function create(config?: Record): Workspace { try { await git(repoPath, "worktree", "add", "-b", cfg.branch, workspacePath, baseRef); } catch { - await git(repoPath, "worktree", "add", "-b", cfg.branch, workspacePath, cfg.project.defaultBranch); + await git( + repoPath, + "worktree", + "add", + "-b", + cfg.branch, + workspacePath, + `refs/heads/${cfg.project.defaultBranch}`, + ); } } } From b83686b662302b8628c83b34134717f18b035c11 Mon Sep 17 00:00:00 2001 From: Gautam Tayal Date: Sun, 29 Mar 2026 14:16:54 +0530 Subject: [PATCH 3/3] test(workspace): add test for handling existing branch with local default-branch fallback --- .../src/__tests__/index.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/plugins/workspace-worktree/src/__tests__/index.test.ts b/packages/plugins/workspace-worktree/src/__tests__/index.test.ts index 36db87bff..e3fbb57ad 100644 --- a/packages/plugins/workspace-worktree/src/__tests__/index.test.ts +++ b/packages/plugins/workspace-worktree/src/__tests__/index.test.ts @@ -283,6 +283,30 @@ describe("workspace.create()", () => { expect(info.branch).toBe("feat/TEST-1"); }); + it("handles existing branch with local default-branch fallback when origin is missing", async () => { + const ws = create(); + + mockGitError("fatal: not a git repository"); // git remote get-url origin fails + mockGitSuccess(""); // git rev-parse --verify --quiet refs/heads/main + mockGitError("already exists"); // worktree add -b fails + mockGitSuccess(""); // worktree add without -b using refs/heads/main + mockGitSuccess(""); // checkout existing branch + + const info = await ws.create(makeCreateConfig()); + + expect(mockExecFileAsync).toHaveBeenCalledWith( + "git", + ["worktree", "add", "/mock-home/.worktrees/myproject/session-1", "refs/heads/main"], + { cwd: "/repo/path" }, + ); + + expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["checkout", "feat/TEST-1"], { + cwd: "/mock-home/.worktrees/myproject/session-1", + }); + + expect(info.branch).toBe("feat/TEST-1"); + }); + it("cleans up worktree on checkout failure", async () => { const ws = create();