Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fix-pr-branch-refspec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Fix Agent Manager PR and base-branch worktrees when a repository uses a restrictive Git fetch refspec.
23 changes: 20 additions & 3 deletions packages/kilo-vscode/src/agent-manager/WorktreeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,8 @@ export class WorktreeManager {
private async refreshBase(branch: string, requested?: string): Promise<void> {
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
Expand All @@ -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())
}

Expand Down Expand Up @@ -1107,17 +1109,32 @@ 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",
"origin",
`+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}`])
}
}
}
}

Expand Down
87 changes: 87 additions & 0 deletions packages/kilo-vscode/tests/unit/worktree-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<PRInfo>
}
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<PRInfo>
}
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("")
})
})

// ---------------------------------------------------------------------------
Expand Down
Loading