Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/loopover-miner/lib/repo-clone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,13 @@ async function ensureRepoClonedUnlocked(
}
const cloned = await runGit(["clone", cloneUrl, repoPath], cloneBaseDir, timeoutMs);
if (!cloned.ok) return { ok: false, repoPath, error: cloned.stderr || "git_clone_failed" };
// A plain `git clone` creates exactly one local branch (origin's default HEAD). When baseBranch is anything
// else, `git worktree add -b <attempt> <path> <baseBranch>` in the consumer can't rev-parse a bare
// `<baseBranch>` name, so the first attempt fails with `invalid reference` until a later run's fetch+reset
// path DWIM-creates it. Check it out now (same call + error shape as the existing-clone path below); a
// fresh clone is already at origin's tip, so no fetch/reset is needed here.
const checkedOut = await runGit(["checkout", baseBranch], repoPath, timeoutMs);
if (!checkedOut.ok) return { ok: false, repoPath, error: checkedOut.stderr || "git_checkout_failed" };
return { ok: true, repoPath };
}

Expand Down
54 changes: 52 additions & 2 deletions test/unit/miner-repo-clone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,52 @@ describe("ensureRepoCloned (#5132)", () => {
expect(result.error).toBe("git_clone_failed");
});

it("REGRESSION (#9682): checks out baseBranch on the FIRST-USE clone path, after the clone (injected runGit)", async () => {
// Fresh cache dir + injected runGit -> existsSync(repoPath) is false -> the first-use clone branch runs.
const root = tempRoot("loopover-miner-repo-clone-freshcheckout-");
const cloneBaseDir = join(root, "cache");
const calls: string[][] = [];
const runGit = async (args: string[]) => {
calls.push(args);
return { ok: true, stdout: "", stderr: "" };
};
const result = await ensureRepoCloned("acme/widgets", { cloneBaseDir, remoteUrl: "unused", baseBranch: "develop", runGit });
expect(result.ok).toBe(true);
// Before the fix the first-use path returned right after `clone`, never checking out `develop`.
expect(calls[0]?.[0]).toBe("clone");
expect(calls.some((args) => args[0] === "checkout" && args[1] === "develop")).toBe(true);
});

it("REGRESSION (#9682): a failing checkout on the first-use clone path returns git_checkout_failed (injected runGit)", async () => {
const root = tempRoot("loopover-miner-repo-clone-freshcheckoutfail-");
const cloneBaseDir = join(root, "cache");
const runGit = async (args: string[]) => (args[0] === "checkout" ? { ok: false, stdout: "", stderr: "" } : { ok: true, stdout: "", stderr: "" });
const result = await ensureRepoCloned("acme/widgets", { cloneBaseDir, remoteUrl: "unused", baseBranch: "develop", runGit });
expect(result.ok).toBe(false);
expect(result.error).toBe("git_checkout_failed");

// And git's own stderr is surfaced verbatim when present, matching the existing-clone checkout branch.
const runGitWithStderr = async (args: string[]) => (args[0] === "checkout" ? { ok: false, stdout: "", stderr: "detached HEAD boom" } : { ok: true, stdout: "", stderr: "" });
const withStderr = await ensureRepoCloned("acme/widgets", { cloneBaseDir: join(tempRoot("loopover-miner-repo-clone-freshcheckoutfail2-"), "cache"), remoteUrl: "unused", baseBranch: "develop", runGit: runGitWithStderr });
expect(withStderr.error).toBe("detached HEAD boom");
});

it("REGRESSION (#9682): the first-use clone path does NOT fetch or hard-reset (a fresh clone is already at origin's tip)", async () => {
const root = tempRoot("loopover-miner-repo-clone-freshnofetch-");
const cloneBaseDir = join(root, "cache");
const calls: string[][] = [];
const runGit = async (args: string[]) => {
calls.push(args);
return { ok: true, stdout: "", stderr: "" };
};
const result = await ensureRepoCloned("acme/widgets", { cloneBaseDir, remoteUrl: "unused", baseBranch: "develop", runGit });
expect(result.ok).toBe(true);
expect(calls.some((args) => args[0] === "fetch")).toBe(false);
expect(calls.some((args) => args[0] === "reset")).toBe(false);
// Exactly the clone + checkout, in that order.
expect(calls.map((args) => args[0])).toEqual(["clone", "checkout"]);
});

it("rejects a dash-prefixed baseBranch before invoking git (#5923)", async () => {
// Real origin init + a real clone before the injected-runGit assertion. See the first test in this
// block for why this needs an explicit timeout.
Expand Down Expand Up @@ -259,8 +305,12 @@ describe("ensureRepoCloned per-repo concurrency guard (#6762)", () => {
const [firstResult, secondResult] = await Promise.all([first, second]);
expect(firstResult.ok).toBe(true);
expect(secondResult.ok).toBe(true);
// Strict, non-overlapping ordering: first runs start->end fully before second starts.
expect(events).toEqual(["start:clone", "end:clone", "start:clone", "end:clone"]);
// Strict, non-overlapping ordering: the first call runs its full fresh-clone git sequence (clone then the
// #9682 baseBranch checkout) start->end before the second call starts any git op of its own.
expect(events).toEqual([
"start:clone", "end:clone", "start:checkout", "end:checkout",
"start:clone", "end:clone", "start:checkout", "end:checkout",
]);
});

it("does NOT serialize across DIFFERENT repos -- they run in parallel", async () => {
Expand Down
Loading