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:
commit
dfc8cdf516
|
|
@ -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()", () => {
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue