diff --git a/.changeset/fix-pr-branch-refspec.md b/.changeset/fix-pr-branch-refspec.md new file mode 100644 index 00000000000..abc1e27f9d2 --- /dev/null +++ b/.changeset/fix-pr-branch-refspec.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix Agent Manager PR and base-branch worktrees when a repository uses a restrictive Git fetch refspec. diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts index a28e157a4ea..7973adc05de 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -839,6 +839,8 @@ export class WorktreeManager { private async refreshBase(branch: string, requested?: string): Promise { const remote = requested ?? (await this.resolveRemote()) if (!remote) return + validateGitRef(remote, "remote") + validateGitRef(branch, "branch") const key = `${this.root}:${remote}:${branch}` const cached = WorktreeManager.fetchCache.get(key) if (cached && Date.now() - cached < WorktreeManager.FETCH_CACHE_TTL) return @@ -849,7 +851,7 @@ export class WorktreeManager { const env = nonInteractiveEnv() await simpleGit(this.root, { unsafe: { allowUnsafeSshCommand: isKiloOwnedSshCommand(env) } }) .env(env) - .fetch(remote, branch, { "--quiet": null, "--no-tags": null }) + .raw(["fetch", "--quiet", "--no-tags", remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`]) WorktreeManager.fetchCache.set(key, Date.now()) } @@ -1107,10 +1109,17 @@ export class WorktreeManager { if (!remotes.some((r) => r.name === forkOwner)) { await this.git.addRemote(forkOwner, `https://github.com/${forkOwner}/${parsed.repo}.git`) } - await this.gitExec(["fetch", forkOwner, info.headRefName]) + await this.gitExec([ + "fetch", + "--quiet", + "--no-tags", + forkOwner, + `+refs/heads/${info.headRefName}:refs/remotes/${forkOwner}/${info.headRefName}`, + ]) } else { validateGitRef(info.headRefName, "branch name") - const ok = await this.gitTry(["fetch", "origin", info.headRefName]) + const ref = `+refs/heads/${info.headRefName}:refs/remotes/origin/${info.headRefName}` + const ok = await this.gitTry(["fetch", "--quiet", "--no-tags", "origin", ref]) if (!ok) { await this.gitExec([ "fetch", @@ -1118,6 +1127,14 @@ export class WorktreeManager { `+refs/pull/${parsed.number}/head:refs/remotes/origin/${info.headRefName}`, ]) } + if (!(await this.gitTry(["show-ref", "--verify", "--quiet", `refs/heads/${info.headRefName}`]))) { + const start = `refs/remotes/origin/${info.headRefName}` + await this.gitExec(["branch", info.headRefName, start]) + if (ok) { + await this.gitExec(["config", `branch.${info.headRefName}.remote`, "origin"]) + await this.gitExec(["config", `branch.${info.headRefName}.merge`, `refs/heads/${info.headRefName}`]) + } + } } } diff --git a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts index 67174fb21a8..69ff01da58a 100644 --- a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts @@ -11,6 +11,7 @@ import { versionedName, } from "../../src/agent-manager/branch-name" import { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager" +import type { PRInfo } from "../../src/agent-manager/git-import" import simpleGit from "simple-git" // Each test gets its own temp directory -- no shared state, safe to run in parallel. @@ -1138,6 +1139,92 @@ describe("WorktreeManager.createWorktree advanced", () => { const devParams = await git.log(["-1"]) expect(headParams.latest?.hash).toBe(devParams.latest?.hash) }) + + it("creates from a base branch excluded by the remote fetch refspec", async () => { + const { clone } = await createTempRepoWithOrigin() + const git = simpleGit(clone) + await git.checkoutLocalBranch("topic") + await fs.writeFile(path.join(clone, "topic.txt"), "topic") + await git.add(".") + await git.commit("topic commit") + await git.push("origin", "topic") + await git.checkout("main") + + await git.raw(["config", "remote.origin.fetch", "+refs/heads/main:refs/remotes/origin/main"]) + await git.raw(["update-ref", "-d", "refs/remotes/origin/topic"]) + + const result = await createManager(clone).createWorktree({ baseBranch: "topic", prompt: "from topic" }) + const remoteHead = (await git.revparse(["refs/remotes/origin/topic"])).trim() + const worktreeHead = (await simpleGit(result.path).revparse(["HEAD"])).trim() + + expect(worktreeHead).toBe(remoteHead) + expect(result.parentBranch).toBe("topic") + }) + + it("creates from a same-repository PR branch excluded by the remote fetch refspec", async () => { + const { clone } = await createTempRepoWithOrigin() + const git = simpleGit(clone) + await git.checkoutLocalBranch("topic") + await fs.writeFile(path.join(clone, "topic.txt"), "topic") + await git.add(".") + await git.commit("topic commit") + await git.push("origin", "topic") + await git.checkout("main") + await git.raw(["config", "remote.origin.fetch", "+refs/heads/main:refs/remotes/origin/main"]) + await git.raw(["update-ref", "-d", "refs/remotes/origin/topic"]) + await git.branch(["-D", "topic"]) + + const manager = createManager(clone) + const internal = manager as unknown as { + fetchPRInfo: (parsed: { owner: string; repo: string; number: number }) => Promise + } + internal.fetchPRInfo = async () => ({ + headRefName: "topic", + isCrossRepository: false, + title: "Topic PR", + }) + + const result = await manager.createFromPR("https://github.com/org/repo/pull/1") + const remoteHead = (await git.revparse(["refs/remotes/origin/topic"])).trim() + const worktreeHead = (await simpleGit(result.path).revparse(["HEAD"])).trim() + + expect(worktreeHead).toBe(remoteHead) + expect(result.parentBranch).toBe("topic") + }) + + it("does not track a deleted PR source branch when using the pull ref fallback", async () => { + const { bare, clone } = await createTempRepoWithOrigin() + const git = simpleGit(clone) + await git.checkoutLocalBranch("topic") + await fs.writeFile(path.join(clone, "topic.txt"), "topic") + await git.add(".") + await git.commit("topic commit") + await git.push("origin", "topic") + const head = (await git.revparse(["topic"])).trim() + await git.checkout("main") + await git.raw(["config", "remote.origin.fetch", "+refs/heads/main:refs/remotes/origin/main"]) + await git.raw(["update-ref", "-d", "refs/remotes/origin/topic"]) + gitExec(["git", "--git-dir", bare, "update-ref", "refs/pull/1/head", head]) + gitExec(["git", "--git-dir", bare, "update-ref", "-d", "refs/heads/topic"]) + await git.branch(["-D", "topic"]) + + const manager = createManager(clone) + const internal = manager as unknown as { + fetchPRInfo: (parsed: { owner: string; repo: string; number: number }) => Promise + } + internal.fetchPRInfo = async () => ({ + headRefName: "topic", + isCrossRepository: false, + title: "Topic PR", + }) + + const result = await manager.createFromPR("https://github.com/org/repo/pull/1") + const upstream = await git.raw(["config", "--get", "branch.topic.remote"]).catch(() => "") + const worktreeHead = (await simpleGit(result.path).revparse(["HEAD"])).trim() + + expect(worktreeHead).toBe(head) + expect(upstream.trim()).toBe("") + }) }) // ---------------------------------------------------------------------------