Files
kilocode/packages/kilo-vscode/tests/unit/git-import.test.ts
T
kirillk 91040bcff4 fix(agent-manager): report worktree problems accurately and recover from them
Agent Manager could tell a user "Git is not installed or not found in PATH"
while git was installed and working, show a worktree as having no changes when
the status check had actually failed, and keep polling worktrees whose folder
was long gone. One unresponsive worktree was enough to slow down status updates
for every other row.

Three causes, all of them the same mistake in different places: a failure was
reported as a fact about something else.

- A failed process launch reports ENOENT whether the program or the working
  directory is missing, and the code read that as "git is missing".
- A failed or timed-out status check returned zero counts, which is
  indistinguishable from a clean worktree.
- A timed-out GitHub CLI call was recorded as a success, which reset the
  backoff that was supposed to stop retrying it.

Nothing reconciled the three views of a worktree either (the row, git's own
registration, the folder on disk), so stale rows accumulated and were polled
forever, and a timed-out `gh pr view` was retried on every cycle.

Approach: name each state and never guess between them. A worktree is healthy,
restorable, gone, not-a-worktree, or unmeasurable, and each state gets the
message and the recovery action that actually applies. Startup reconciles the
three views and repairs metadata only; deleting files is always a user's
click. Repeated failures park one worktree instead of the whole panel, and
git/gh calls get budgets sized to the work they do.

Both clients implement the same states, wording, and recovery actions.
2026-09-14 13:42:40 -04:00

484 lines
16 KiB
TypeScript

import { describe, expect, it } from "bun:test"
import {
parsePRUrl,
localBranchName,
parseForEachRefOutput,
buildBranchList,
parseWorktreeList,
checkedOutBranchesFromWorktreeList,
classifyPRError,
classifyWorktreeError,
validateGitRef,
} from "../../src/agent-manager/git-import"
// ---------------------------------------------------------------------------
// parsePRUrl
// ---------------------------------------------------------------------------
describe("parsePRUrl", () => {
it("parses a standard GitHub PR URL", () => {
expect(parsePRUrl("https://github.com/Kilo-Org/kilocode/pull/6164")).toEqual({
owner: "Kilo-Org",
repo: "kilocode",
number: 6164,
})
})
it("handles URL without protocol", () => {
expect(parsePRUrl("github.com/owner/repo/pull/42")).toEqual({
owner: "owner",
repo: "repo",
number: 42,
})
})
it("handles trailing slashes", () => {
expect(parsePRUrl("https://github.com/o/r/pull/1///")).toEqual({
owner: "o",
repo: "r",
number: 1,
})
})
it("handles www.github.com", () => {
expect(parsePRUrl("https://www.github.com/o/r/pull/99")).toEqual({
owner: "o",
repo: "r",
number: 99,
})
})
it("returns null for non-GitHub URLs", () => {
expect(parsePRUrl("https://gitlab.com/owner/repo/pull/1")).toBeNull()
})
it("returns null for GitHub URLs without /pull/", () => {
expect(parsePRUrl("https://github.com/owner/repo/issues/1")).toBeNull()
})
it("returns null for empty string", () => {
expect(parsePRUrl("")).toBeNull()
})
it("returns null for garbage input", () => {
expect(parsePRUrl("not a url at all")).toBeNull()
})
it("returns null when PR number is missing", () => {
expect(parsePRUrl("https://github.com/owner/repo/pull/")).toBeNull()
})
it("strips whitespace", () => {
expect(parsePRUrl(" https://github.com/o/r/pull/5 ")).toEqual({
owner: "o",
repo: "r",
number: 5,
})
})
it("handles URL with extra path segments after PR number", () => {
const result = parsePRUrl("https://github.com/o/r/pull/123/files")
expect(result).toEqual({ owner: "o", repo: "r", number: 123 })
})
it("rejects lookalike domains", () => {
expect(parsePRUrl("https://evilgithub.com/owner/repo/pull/1")).toBeNull()
expect(parsePRUrl("https://notgithub.com/owner/repo/pull/1")).toBeNull()
})
})
// ---------------------------------------------------------------------------
// localBranchName
// ---------------------------------------------------------------------------
describe("localBranchName", () => {
it("returns headRefName for same-repo PR", () => {
expect(
localBranchName({
headRefName: "feat/cool",
isCrossRepository: false,
title: "test",
}),
).toBe("feat/cool")
})
it("prefixes with fork owner for cross-repo PR", () => {
expect(
localBranchName({
headRefName: "fix/bug",
headRepositoryOwner: { login: "Contributor" },
isCrossRepository: true,
title: "test",
}),
).toBe("contributor/fix/bug")
})
it("returns headRefName if cross-repo but no owner", () => {
expect(
localBranchName({
headRefName: "fix/bug",
isCrossRepository: true,
title: "test",
}),
).toBe("fix/bug")
})
})
// ---------------------------------------------------------------------------
// parseForEachRefOutput
// ---------------------------------------------------------------------------
describe("parseForEachRefOutput", () => {
it("parses local and remote branches with dates", () => {
const raw = [
"refs/heads/main\t2025-01-15T10:00:00+00:00",
"refs/heads/feat/a\t2025-01-14T09:00:00+00:00",
"refs/remotes/origin/main\t2025-01-15T10:00:00+00:00",
"refs/remotes/origin/feat/b\t2025-01-13T08:00:00+00:00",
].join("\n")
const { locals, remotes, dates } = parseForEachRefOutput(raw)
expect([...locals]).toEqual(["main", "feat/a"])
expect([...remotes]).toEqual(["main", "feat/b"])
expect(dates.get("main")).toBe("2025-01-15T10:00:00+00:00")
expect(dates.get("feat/a")).toBe("2025-01-14T09:00:00+00:00")
expect(dates.get("feat/b")).toBe("2025-01-13T08:00:00+00:00")
})
it("skips HEAD entries", () => {
const raw = "refs/remotes/origin/HEAD\t2025-01-01T00:00:00+00:00\nrefs/heads/main\t2025-01-01T00:00:00+00:00"
const { locals, remotes } = parseForEachRefOutput(raw)
expect(remotes.has("HEAD")).toBe(false)
expect(locals.has("main")).toBe(true)
})
it("skips empty lines", () => {
const raw = "\n\nrefs/heads/main\t2025-01-01T00:00:00+00:00\n\n"
const { locals } = parseForEachRefOutput(raw)
expect([...locals]).toEqual(["main"])
})
it("handles empty output", () => {
const { locals, remotes, dates } = parseForEachRefOutput("")
expect(locals.size).toBe(0)
expect(remotes.size).toBe(0)
expect(dates.size).toBe(0)
})
it("local branch takes priority for date when both exist", () => {
const raw = [
"refs/heads/main\t2025-02-01T00:00:00+00:00",
"refs/remotes/origin/main\t2025-01-01T00:00:00+00:00",
].join("\n")
const { dates } = parseForEachRefOutput(raw)
expect(dates.get("main")).toBe("2025-02-01T00:00:00+00:00")
})
})
// ---------------------------------------------------------------------------
// buildBranchList
// ---------------------------------------------------------------------------
describe("buildBranchList", () => {
it("merges local and remote into deduplicated sorted list", () => {
const locals = new Set(["main", "feat/a"])
const remotes = new Set(["main", "feat/b"])
const dates = new Map([
["main", "2025-01-15T00:00:00+00:00"],
["feat/a", "2025-01-14T00:00:00+00:00"],
["feat/b", "2025-01-13T00:00:00+00:00"],
])
const result = buildBranchList(locals, remotes, dates, "main")
expect(result[0].name).toBe("main")
expect(result[0].isDefault).toBe(true)
expect(result[0].isLocal).toBe(true)
expect(result[0].isRemote).toBe(true)
expect(result[1].name).toBe("feat/a")
expect(result[1].isLocal).toBe(true)
expect(result[1].isRemote).toBe(false)
expect(result[2].name).toBe("feat/b")
expect(result[2].isLocal).toBe(false)
expect(result[2].isRemote).toBe(true)
})
it("sorts default branch first", () => {
const locals = new Set(["z-branch", "main"])
const dates = new Map([
["z-branch", "2099-01-01T00:00:00+00:00"],
["main", "2020-01-01T00:00:00+00:00"],
])
const result = buildBranchList(locals, new Set(), dates, "main")
expect(result[0].name).toBe("main")
})
it("sorts by date descending after default", () => {
const locals = new Set(["old", "new", "mid"])
const dates = new Map([
["old", "2020-01-01T00:00:00+00:00"],
["new", "2025-01-01T00:00:00+00:00"],
["mid", "2023-01-01T00:00:00+00:00"],
])
const result = buildBranchList(locals, new Set(), dates, "none")
expect(result.map((b) => b.name)).toEqual(["new", "mid", "old"])
})
it("handles empty inputs", () => {
expect(buildBranchList(new Set(), new Set(), new Map(), "main")).toEqual([])
})
})
// ---------------------------------------------------------------------------
// parseWorktreeList
// ---------------------------------------------------------------------------
describe("parseWorktreeList", () => {
it("parses standard worktree entries", () => {
const raw = [
"worktree /home/user/repo",
"HEAD abc123",
"branch refs/heads/main",
"",
"worktree /home/user/repo/.worktrees/feat",
"HEAD def456",
"branch refs/heads/feat/cool",
"",
].join("\n")
const entries = parseWorktreeList(raw)
expect(entries).toHaveLength(2)
expect(entries[0]).toEqual({ path: "/home/user/repo", branch: "main", bare: false, detached: false })
expect(entries[1]).toEqual({
path: "/home/user/repo/.worktrees/feat",
branch: "feat/cool",
bare: false,
detached: false,
})
})
it("detects bare worktree", () => {
const raw = "worktree /home/user/repo\nHEAD abc\nbare\n\n"
const entries = parseWorktreeList(raw)
expect(entries[0].bare).toBe(true)
})
it("detects detached HEAD", () => {
const raw = "worktree /home/user/repo/.wt/fix\nHEAD abc123\ndetached\n\n"
const entries = parseWorktreeList(raw)
expect(entries[0].detached).toBe(true)
expect(entries[0].branch).toBe("(detached)")
})
it("handles empty output", () => {
expect(parseWorktreeList("")).toEqual([])
expect(parseWorktreeList("\n\n")).toEqual([])
})
it("handles missing branch line with unknown", () => {
const raw = "worktree /some/path\nHEAD abc\n\n"
const entries = parseWorktreeList(raw)
expect(entries[0].branch).toBe("unknown")
})
})
// ---------------------------------------------------------------------------
// checkedOutBranchesFromWorktreeList
// ---------------------------------------------------------------------------
describe("checkedOutBranchesFromWorktreeList", () => {
it("returns branches from non-bare non-detached entries", () => {
const raw = [
"worktree /repo",
"HEAD abc",
"branch refs/heads/main",
"",
"worktree /repo/.wt/feat",
"HEAD def",
"branch refs/heads/feat/x",
"",
"worktree /repo/.wt/detached",
"HEAD ghi",
"detached",
"",
].join("\n")
const branches = checkedOutBranchesFromWorktreeList(raw)
expect(branches.has("main")).toBe(true)
expect(branches.has("feat/x")).toBe(true)
expect(branches.has("(detached)")).toBe(false)
expect(branches.size).toBe(2)
})
it("excludes bare entries", () => {
const raw = "worktree /repo\nHEAD abc\nbare\n\n"
expect(checkedOutBranchesFromWorktreeList(raw).size).toBe(0)
})
it("handles empty input", () => {
expect(checkedOutBranchesFromWorktreeList("").size).toBe(0)
})
})
// ---------------------------------------------------------------------------
// classifyPRError
// ---------------------------------------------------------------------------
describe("classifyPRError", () => {
it("detects PR not found", () => {
expect(classifyPRError("GraphQL: Could not resolve to a PullRequest")).toBe("not_found")
expect(classifyPRError("not found")).toBe("not_found")
})
it("detects gh CLI missing", () => {
expect(classifyPRError("gh: command not found")).toBe("gh_missing")
expect(classifyPRError("spawn gh ENOENT")).toBe("gh_missing")
expect(classifyPRError("'gh' is not recognized as an internal command")).toBe("gh_missing")
})
it("detects gh auth issue", () => {
expect(classifyPRError("not logged into any github hosts")).toBe("gh_auth")
expect(classifyPRError("please run `gh auth login`")).toBe("gh_auth")
})
it("returns unknown for unrecognized errors", () => {
expect(classifyPRError("something went wrong")).toBe("unknown")
})
})
// ---------------------------------------------------------------------------
// validateGitRef
// ---------------------------------------------------------------------------
describe("validateGitRef", () => {
it("accepts simple branch names", () => {
expect(() => validateGitRef("main", "branch")).not.toThrow()
expect(() => validateGitRef("feat/cool", "branch")).not.toThrow()
expect(() => validateGitRef("fix-123", "branch")).not.toThrow()
expect(() => validateGitRef("v1.2.3", "branch")).not.toThrow()
})
it("accepts usernames with dots, hyphens, underscores", () => {
expect(() => validateGitRef("some-user", "owner")).not.toThrow()
expect(() => validateGitRef("user.name", "owner")).not.toThrow()
expect(() => validateGitRef("user_name", "owner")).not.toThrow()
})
it("rejects values starting with a dash (git flag injection)", () => {
expect(() => validateGitRef("--upload-pack=evil", "ref")).toThrow('Unsafe ref: "--upload-pack=evil"')
expect(() => validateGitRef("-b", "ref")).toThrow('Unsafe ref: "-b"')
})
it("rejects empty strings", () => {
expect(() => validateGitRef("", "ref")).toThrow('Unsafe ref: ""')
})
it("rejects values with spaces", () => {
expect(() => validateGitRef("bad name", "ref")).toThrow()
})
it("rejects values with shell metacharacters", () => {
expect(() => validateGitRef("$(whoami)", "ref")).toThrow()
expect(() => validateGitRef("foo;rm -rf /", "ref")).toThrow()
expect(() => validateGitRef("foo`id`", "ref")).toThrow()
expect(() => validateGitRef("foo|bar", "ref")).toThrow()
})
it("rejects values with newlines", () => {
expect(() => validateGitRef("foo\nbar", "ref")).toThrow()
})
it("rejects values containing .. (git ref traversal)", () => {
expect(() => validateGitRef("foo/../bar", "ref")).toThrow()
expect(() => validateGitRef("..hidden", "ref")).toThrow()
})
})
// ---------------------------------------------------------------------------
// classifyWorktreeError
// ---------------------------------------------------------------------------
describe("classifyWorktreeError", () => {
it("detects git not found from spawn ENOENT", () => {
expect(classifyWorktreeError("spawn git ENOENT")).toBe("git_not_found")
expect(
classifyWorktreeError(
"Error: spawn git ENOENT at ChildProcess._handle.onexit (node:internal/child_process:285:19)",
),
).toBe("git_not_found")
})
it("detects git not found from PATH message", () => {
expect(
classifyWorktreeError("Git is not installed or not found in PATH. Please install Git and restart VS Code."),
).toBe("git_not_found")
})
it("detects not a git repository", () => {
expect(
classifyWorktreeError(
"This folder is not a git repository. Initialize a repository or open a git project to use worktrees.",
),
).toBe("not_git_repo")
})
it("detects Git LFS missing", () => {
expect(
classifyWorktreeError(
"This repository uses Git LFS, but git-lfs was not found. Please install Git LFS to use this repository.",
),
).toBe("lfs_missing")
})
it("detects a repository with no commits", () => {
expect(
classifyWorktreeError("This repository has no commits yet. Create an initial commit before using worktrees."),
).toBe("no_commits")
})
it("returns undefined for unrecognized errors", () => {
expect(classifyWorktreeError('Branch "foo" already exists')).toBeUndefined()
expect(classifyWorktreeError("Failed to create worktree: fatal: unknown error")).toBeUndefined()
expect(classifyWorktreeError("something went wrong")).toBeUndefined()
})
// A failed spawn reports ENOENT whether the binary or the working directory is missing, so
// "install git" must never be inferred from the message alone.
it("blames the missing directory, not git, when the cwd is gone", () => {
expect(classifyWorktreeError("Error: spawn git ENOENT", { cwd: "/gone", exists: () => false })).toBe(
"worktree_missing",
)
})
it("still blames git when the cwd exists", () => {
expect(classifyWorktreeError("Error: spawn git ENOENT", { cwd: "/repo", exists: () => true })).toBe("git_not_found")
})
it("blames git when the version probe itself failed", () => {
expect(
classifyWorktreeError("some unrelated failure", { cwd: "/repo", exists: () => true, probeFailed: true }),
).toBe("git_not_found")
})
it("separates a broken worktree from a non-repo folder", () => {
expect(
classifyWorktreeError("fatal: not a git repository: /repo/.git/worktrees/hidden-sparrow", {
cwd: "/repo/.kilo/worktrees/hidden-sparrow",
exists: () => true,
}),
).toBe("worktree_unregistered")
expect(classifyWorktreeError("fatal: not a git repository", { cwd: "/repo", exists: () => true })).toBe(
"not_git_repo",
)
})
it("reports a timeout as a timeout", () => {
expect(classifyWorktreeError("Git command timed out after 15000ms", { cwd: "/repo", exists: () => true })).toBe(
"git_timeout",
)
})
})