Merge pull request #790 from gautamtayal1/fix/git-worktree-origin

fix (workspace): bugfix with git worktree creation for local repos
This commit is contained in:
Dhruv Sharma 2026-03-29 22:18:13 +05:30 committed by GitHub
commit dfc8cdf516
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 276 additions and 32 deletions

View File

@ -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>): 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 <branch> <path> <baseRef>
// 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 <branch> <path> <baseRef>
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());
@ -191,10 +217,51 @@ describe("workspace.create()", () => {
expect(info.path).toBe("/mock-home/.worktrees/myproject/session-1");
});
it("uses refs/heads/<defaultBranch> 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();
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
@ -216,10 +283,35 @@ 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();
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 +332,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 +347,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 +391,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 +408,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 +423,90 @@ 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(""); // git rev-parse --verify --quiet refs/heads/main
mockGitSuccess(""); // worktree add
await ws.create(makeCreateConfig());
expect(mockExecFileAsync).toHaveBeenCalledWith(
"git",
[
"worktree",
"add",
"-b",
"feat/TEST-1",
"/mock-home/.worktrees/myproject/session-1",
"refs/heads/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 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");
expect(mockExecFileAsync).toHaveBeenCalledWith(
"git",
[
"worktree",
"add",
"-b",
"feat/TEST-1",
"/mock-home/.worktrees/myproject/session-1",
"refs/heads/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()", () => {

View File

@ -29,6 +29,47 @@ async function git(cwd: string, ...args: string[]): Promise<string> {
return stdout.trimEnd();
}
async function hasOriginRemote(cwd: string): Promise<boolean> {
try {
await git(cwd, "remote", "get-url", "origin");
return true;
} catch {
return false;
}
}
async function refExists(cwd: string, ref: string): Promise<boolean> {
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<string> {
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;
}
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 */
const SAFE_PATH_SEGMENT = /^[a-zA-Z0-9_-]+$/;
@ -64,14 +105,18 @@ export function create(config?: Record<string, unknown>): 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 +262,43 @@ export function create(config?: Record<string, unknown>): 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,
`refs/heads/${cfg.project.defaultBranch}`,
);
}
}
}