mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
744 lines
27 KiB
TypeScript
744 lines
27 KiB
TypeScript
import { describe, it, expect } from "bun:test"
|
|
import * as fs from "fs/promises"
|
|
import * as os from "os"
|
|
import * as nodePath from "path"
|
|
import { GitOps } from "../../src/agent-manager/GitOps"
|
|
import { Semaphore } from "../../src/agent-manager/semaphore"
|
|
|
|
function ops(handler: (args: string[], cwd: string) => Promise<string>, semaphore?: Semaphore): GitOps {
|
|
return new GitOps({ log: () => undefined, runGit: handler, semaphore })
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|
|
|
|
function runGit(cwd: string, args: string[]): string {
|
|
const result = Bun.spawnSync({
|
|
cmd: ["git", ...args],
|
|
cwd,
|
|
stderr: "pipe",
|
|
stdout: "pipe",
|
|
env: {
|
|
...process.env,
|
|
GIT_TERMINAL_PROMPT: "0",
|
|
},
|
|
})
|
|
if (result.exitCode !== 0) {
|
|
throw new Error(Buffer.from(result.stderr).toString("utf8") || Buffer.from(result.stdout).toString("utf8"))
|
|
}
|
|
return Buffer.from(result.stdout).toString("utf8").trim()
|
|
}
|
|
|
|
async function withRepo(run: (cwd: string) => Promise<void>): Promise<void> {
|
|
const cwd = await fs.mkdtemp(nodePath.join(os.tmpdir(), "kilo-gitops-test-"))
|
|
try {
|
|
runGit(cwd, ["init"])
|
|
await run(cwd)
|
|
} finally {
|
|
await fs.rm(cwd, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
describe("GitOps", () => {
|
|
it("uses the configured Git executable for raw commands", async () => {
|
|
await withRepo(async (cwd) => {
|
|
let calls = 0
|
|
const git = new GitOps({
|
|
log: () => undefined,
|
|
binary: async () => {
|
|
calls++
|
|
return "git"
|
|
},
|
|
})
|
|
|
|
expect(await fs.realpath(await git.root(cwd))).toBe(await fs.realpath(cwd))
|
|
expect(calls).toBe(1)
|
|
})
|
|
})
|
|
|
|
it("uses an explicit Git executable path with spaces", async () => {
|
|
await withRepo(async (cwd) => {
|
|
const real = Bun.which("git")
|
|
if (!real) throw new Error("Git is required for this test")
|
|
|
|
const dir = await fs.mkdtemp(nodePath.join(os.tmpdir(), "kilo-gitops executable-"))
|
|
const binary = process.platform === "win32" ? real : nodePath.join(dir, "git")
|
|
try {
|
|
if (process.platform !== "win32") await fs.symlink(real, binary)
|
|
|
|
const git = new GitOps({ log: () => undefined, binary })
|
|
expect(git.path).toBe(binary)
|
|
expect(await fs.realpath(await git.root(cwd))).toBe(await fs.realpath(cwd))
|
|
} finally {
|
|
await fs.rm(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|
|
|
|
it("does not hold a semaphore slot while resolving Git", async () => {
|
|
const semaphore = new Semaphore(1)
|
|
let resolve!: (value: string) => void
|
|
const binary = new Promise<string>((done) => {
|
|
resolve = done
|
|
})
|
|
const git = new GitOps({ log: () => undefined, semaphore, binary: () => binary })
|
|
const pending = git.currentBranch("/repo")
|
|
let entered = false
|
|
|
|
await semaphore.run(async () => {
|
|
entered = true
|
|
})
|
|
resolve("git")
|
|
await pending
|
|
|
|
expect(entered).toBe(true)
|
|
})
|
|
|
|
it("stops waiting for Git resolution when disposed", async () => {
|
|
const git = new GitOps({
|
|
log: () => undefined,
|
|
binary: () => new Promise(() => undefined),
|
|
})
|
|
const pending = git.currentBranch("/repo")
|
|
|
|
git.dispose()
|
|
|
|
expect(await pending).toBe("")
|
|
})
|
|
|
|
it("retries executable resolution after a transient failure", async () => {
|
|
await withRepo(async (cwd) => {
|
|
let calls = 0
|
|
const git = new GitOps({
|
|
log: () => undefined,
|
|
binary: async () => {
|
|
calls++
|
|
if (calls === 1) throw new Error("transient resolution failure")
|
|
return "git"
|
|
},
|
|
})
|
|
|
|
expect(await git.root(cwd)).toBeUndefined()
|
|
expect(await fs.realpath(await git.root(cwd))).toBe(await fs.realpath(cwd))
|
|
expect(calls).toBe(2)
|
|
})
|
|
})
|
|
|
|
describe("currentBranch", () => {
|
|
it("returns the current branch name", async () => {
|
|
const git = ops(async (args) => {
|
|
if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "HEAD") return "feature"
|
|
return ""
|
|
})
|
|
expect(await git.currentBranch("/repo")).toBe("feature")
|
|
})
|
|
|
|
it("returns empty string on error", async () => {
|
|
const git = ops(async () => {
|
|
throw new Error("not a git repo")
|
|
})
|
|
expect(await git.currentBranch("/repo")).toBe("")
|
|
})
|
|
})
|
|
|
|
describe("root", () => {
|
|
it("resolves the nearest enclosing repository", async () => {
|
|
const git = ops(async (args) => {
|
|
if (args[0] === "rev-parse" && args[1] === "--show-toplevel") return "/workspace/frontend"
|
|
return ""
|
|
})
|
|
expect(await git.root("/workspace/frontend/src")).toBe("/workspace/frontend")
|
|
})
|
|
|
|
it("returns undefined outside a repository", async () => {
|
|
const git = ops(async () => {
|
|
throw new Error("not a git repo")
|
|
})
|
|
expect(await git.root("/workspace")).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe("resolveRemote", () => {
|
|
it("uses upstream remote when upstream is configured", async () => {
|
|
const git = ops(async (args) => {
|
|
if (args[0] === "rev-parse" && args[3] === "@{upstream}") return "upstream/main"
|
|
return ""
|
|
})
|
|
expect(await git.resolveRemote("/repo", "feature")).toBe("upstream")
|
|
})
|
|
|
|
it("uses branch config remote when no upstream", async () => {
|
|
const git = ops(async (args) => {
|
|
if (args[0] === "rev-parse" && args[3] === "@{upstream}") throw new Error("no upstream")
|
|
if (args[0] === "config" && args[1] === "branch.feature.remote") return "myfork"
|
|
return ""
|
|
})
|
|
expect(await git.resolveRemote("/repo", "feature")).toBe("myfork")
|
|
})
|
|
|
|
it("resolves branch from HEAD when no branch arg provided", async () => {
|
|
const git = ops(async (args) => {
|
|
if (args[0] === "rev-parse" && args[3] === "@{upstream}") throw new Error("no upstream")
|
|
if (args[0] === "branch") return "feature"
|
|
if (args[0] === "config" && args[1] === "branch.feature.remote") return "myfork"
|
|
return ""
|
|
})
|
|
expect(await git.resolveRemote("/repo")).toBe("myfork")
|
|
})
|
|
|
|
it("falls back to origin when nothing is configured", async () => {
|
|
const git = ops(async (args) => {
|
|
if (args[0] === "rev-parse") throw new Error("no upstream")
|
|
if (args[0] === "branch") return "feature"
|
|
if (args[0] === "config") throw new Error("no config")
|
|
return ""
|
|
})
|
|
expect(await git.resolveRemote("/repo", "feature")).toBe("origin")
|
|
})
|
|
})
|
|
|
|
describe("resolveTrackingBranch", () => {
|
|
it("returns configured upstream", async () => {
|
|
const git = ops(async (args) => {
|
|
if (args[0] === "rev-parse" && args[2] === "@{upstream}") return "origin/feature"
|
|
return ""
|
|
})
|
|
expect(await git.resolveTrackingBranch("/repo", "feature")).toBe("origin/feature")
|
|
})
|
|
|
|
it("falls back to <remote>/<branch> when no upstream", async () => {
|
|
const git = ops(async (args) => {
|
|
// resolveTrackingBranch: no upstream
|
|
if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "@{upstream}")
|
|
throw new Error("no upstream")
|
|
// resolveRemote: no upstream, config says "myfork"
|
|
if (args[0] === "rev-parse" && args[3] === "@{upstream}") throw new Error("no upstream")
|
|
if (args[0] === "config" && args[1] === "branch.feature.remote") return "myfork"
|
|
// verify myfork/feature exists
|
|
if (args[0] === "rev-parse" && args[1] === "--verify" && args[2] === "myfork/feature") return "abc123"
|
|
return ""
|
|
})
|
|
expect(await git.resolveTrackingBranch("/repo", "feature")).toBe("myfork/feature")
|
|
})
|
|
|
|
it("falls back to origin/<branch> when no branch config", async () => {
|
|
const git = ops(async (args) => {
|
|
if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "@{upstream}")
|
|
throw new Error("no upstream")
|
|
if (args[0] === "rev-parse" && args[3] === "@{upstream}") throw new Error("no upstream")
|
|
if (args[0] === "config") throw new Error("no config")
|
|
if (args[0] === "branch") return "feature"
|
|
if (args[0] === "rev-parse" && args[1] === "--verify" && args[2] === "origin/feature") return "abc123"
|
|
return ""
|
|
})
|
|
expect(await git.resolveTrackingBranch("/repo", "feature")).toBe("origin/feature")
|
|
})
|
|
|
|
it("returns undefined when no upstream and no remote ref", async () => {
|
|
const git = ops(async (args) => {
|
|
if (args[0] === "rev-parse") throw new Error("no ref")
|
|
if (args[0] === "config") throw new Error("no config")
|
|
if (args[0] === "branch") return ""
|
|
return ""
|
|
})
|
|
expect(await git.resolveTrackingBranch("/repo", "feature")).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe("resolveDefaultBranch", () => {
|
|
it("uses the remote's advertised HEAD instead of stale local metadata", async () => {
|
|
const commands: string[][] = []
|
|
const git = ops(async (args) => {
|
|
commands.push(args)
|
|
// resolveRemote: upstream is configured
|
|
if (args[0] === "rev-parse" && args[3] === "@{upstream}") return "upstream/main"
|
|
if (args[0] === "ls-remote") return "ref: refs/heads/develop\tHEAD\nabc123\tHEAD"
|
|
if (args[0] === "symbolic-ref" && args[2] === "refs/remotes/upstream/HEAD") return "upstream/master"
|
|
return ""
|
|
})
|
|
expect(await git.resolveDefaultBranch("/repo", "feature")).toBe("upstream/develop")
|
|
expect(commands.some((args) => args[0] === "symbolic-ref")).toBe(false)
|
|
})
|
|
|
|
it("falls back to local origin/HEAD when the remote is unavailable", async () => {
|
|
const git = ops(async (args) => {
|
|
if (args[0] === "rev-parse" && args[3] === "@{upstream}") throw new Error("no upstream")
|
|
if (args[0] === "config") throw new Error("no config")
|
|
if (args[0] === "branch") return "feature"
|
|
if (args[0] === "ls-remote") throw new Error("offline")
|
|
if (args[0] === "symbolic-ref" && args[2] === "refs/remotes/origin/HEAD") return "origin/main"
|
|
return ""
|
|
})
|
|
expect(await git.resolveDefaultBranch("/repo", "feature")).toBe("origin/main")
|
|
})
|
|
|
|
it("keeps master when the remote still advertises master", async () => {
|
|
const git = ops(async (args) => {
|
|
if (args[0] === "rev-parse") throw new Error("no upstream")
|
|
if (args[0] === "config") throw new Error("no config")
|
|
if (args[0] === "branch") return "feature"
|
|
if (args[0] === "ls-remote") return "ref: refs/heads/master\tHEAD\nabc123\tHEAD"
|
|
return ""
|
|
})
|
|
|
|
expect(await git.resolveDefaultBranch("/repo", "feature")).toBe("origin/master")
|
|
})
|
|
|
|
it("caches the advertised remote HEAD", async () => {
|
|
let calls = 0
|
|
const git = ops(async (args) => {
|
|
if (args[0] === "rev-parse") throw new Error("no upstream")
|
|
if (args[0] === "config") throw new Error("no config")
|
|
if (args[0] === "branch") return "feature"
|
|
if (args[0] === "ls-remote") {
|
|
calls++
|
|
return "ref: refs/heads/main\tHEAD\nabc123\tHEAD"
|
|
}
|
|
return ""
|
|
})
|
|
|
|
expect(await git.resolveDefaultBranch("/repo", "feature")).toBe("origin/main")
|
|
expect(await git.resolveDefaultBranch("/repo", "feature")).toBe("origin/main")
|
|
expect(calls).toBe(1)
|
|
})
|
|
|
|
it("returns undefined when <remote>/HEAD is not set", async () => {
|
|
const git = ops(async (args) => {
|
|
if (args[0] === "rev-parse") throw new Error("no upstream")
|
|
if (args[0] === "config") throw new Error("no config")
|
|
if (args[0] === "branch") return ""
|
|
if (args[0] === "ls-remote") throw new Error("no remote")
|
|
if (args[0] === "symbolic-ref") throw new Error("no symbolic ref")
|
|
return ""
|
|
})
|
|
expect(await git.resolveDefaultBranch("/repo")).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe("hasRemoteRef", () => {
|
|
it("returns true when ref exists", async () => {
|
|
const git = ops(async () => "abc123")
|
|
expect(await git.hasRemoteRef("/repo", "origin/main")).toBe(true)
|
|
})
|
|
|
|
it("returns false when ref does not exist", async () => {
|
|
const git = ops(async () => {
|
|
throw new Error("no ref")
|
|
})
|
|
expect(await git.hasRemoteRef("/repo", "origin/nonexistent")).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("aheadBehind", () => {
|
|
it("counts commits ahead and behind using the provided ref", async () => {
|
|
const git = ops(async (args) => {
|
|
if (args[0] === "rev-list" && args[1] === "--left-right") return "1\t3"
|
|
return ""
|
|
})
|
|
expect(await git.aheadBehind("/repo", "origin/main")).toEqual({ ahead: 3, behind: 1 })
|
|
})
|
|
|
|
it("does not fetch from remote", async () => {
|
|
const commands: string[][] = []
|
|
const git = ops(async (args) => {
|
|
commands.push(args)
|
|
if (args[0] === "rev-list" && args[1] === "--left-right") return "0\t4"
|
|
return ""
|
|
})
|
|
await git.aheadBehind("/repo", "myfork/main")
|
|
const fetches = commands.filter((c) => c[0] === "fetch")
|
|
expect(fetches.length).toBe(0)
|
|
})
|
|
|
|
it("returns zeros when rev-list fails", async () => {
|
|
const git = ops(async (args) => {
|
|
if (args[0] === "rev-list") throw new Error("fatal")
|
|
return ""
|
|
})
|
|
expect(await git.aheadBehind("/repo", "origin/main")).toEqual({ ahead: 0, behind: 0 })
|
|
})
|
|
|
|
it("uses the ref directly without double-prefixing", async () => {
|
|
const refs: string[] = []
|
|
const git = ops(async (args) => {
|
|
if (args[0] === "rev-list" && args[1] === "--left-right") {
|
|
refs.push(args[3]!)
|
|
return "0\t1"
|
|
}
|
|
return ""
|
|
})
|
|
const result = await git.aheadBehind("/repo", "origin/main")
|
|
expect(result).toEqual({ ahead: 1, behind: 0 })
|
|
expect(refs[0]).toBe("origin/main...HEAD")
|
|
})
|
|
})
|
|
|
|
describe("buildWorktreePatch", () => {
|
|
it("includes tracked and untracked changes", async () => {
|
|
await withRepo(async (cwd) => {
|
|
const git = new GitOps({ log: () => undefined })
|
|
await fs.writeFile(nodePath.join(cwd, "a.txt"), "one\n", "utf8")
|
|
await fs.writeFile(nodePath.join(cwd, "b.txt"), "one\n", "utf8")
|
|
runGit(cwd, ["add", "-A"])
|
|
runGit(cwd, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"])
|
|
|
|
await fs.writeFile(nodePath.join(cwd, "a.txt"), "two\n", "utf8")
|
|
await fs.writeFile(nodePath.join(cwd, "b.txt"), "two\n", "utf8")
|
|
await fs.writeFile(nodePath.join(cwd, "c.txt"), "new\n", "utf8")
|
|
|
|
const branch = runGit(cwd, ["branch", "--show-current"]) || "HEAD"
|
|
const patch = await git.buildWorktreePatch(cwd, branch)
|
|
|
|
expect(patch).toContain("a/a.txt")
|
|
expect(patch).toContain("a/b.txt")
|
|
expect(patch).toContain("a/c.txt")
|
|
})
|
|
})
|
|
|
|
it("limits patch to selected files", async () => {
|
|
await withRepo(async (cwd) => {
|
|
const git = new GitOps({ log: () => undefined })
|
|
await fs.writeFile(nodePath.join(cwd, "a.txt"), "one\n", "utf8")
|
|
await fs.writeFile(nodePath.join(cwd, "b.txt"), "one\n", "utf8")
|
|
runGit(cwd, ["add", "-A"])
|
|
runGit(cwd, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"])
|
|
|
|
await fs.writeFile(nodePath.join(cwd, "a.txt"), "two\n", "utf8")
|
|
await fs.writeFile(nodePath.join(cwd, "b.txt"), "two\n", "utf8")
|
|
await fs.writeFile(nodePath.join(cwd, "c.txt"), "new\n", "utf8")
|
|
|
|
const branch = runGit(cwd, ["branch", "--show-current"]) || "HEAD"
|
|
const patch = await git.buildWorktreePatch(cwd, branch, ["a.txt", "c.txt"])
|
|
|
|
expect(patch).toContain("a/a.txt")
|
|
expect(patch).toContain("a/c.txt")
|
|
expect(patch).not.toContain("a/b.txt")
|
|
})
|
|
})
|
|
|
|
it("filters absolute paths and .. traversal from selectedFiles", async () => {
|
|
await withRepo(async (cwd) => {
|
|
const git = new GitOps({ log: () => undefined })
|
|
await fs.writeFile(nodePath.join(cwd, "a.txt"), "one\n", "utf8")
|
|
await fs.writeFile(nodePath.join(cwd, "b.txt"), "one\n", "utf8")
|
|
runGit(cwd, ["add", "-A"])
|
|
runGit(cwd, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"])
|
|
|
|
await fs.writeFile(nodePath.join(cwd, "a.txt"), "two\n", "utf8")
|
|
await fs.writeFile(nodePath.join(cwd, "b.txt"), "two\n", "utf8")
|
|
|
|
const branch = runGit(cwd, ["branch", "--show-current"]) || "HEAD"
|
|
const patch = await git.buildWorktreePatch(cwd, branch, ["a.txt", "/etc/passwd", "../../../secret", "", " "])
|
|
|
|
expect(patch).toContain("a/a.txt")
|
|
expect(patch).not.toContain("b.txt")
|
|
expect(patch).not.toContain("passwd")
|
|
expect(patch).not.toContain("secret")
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("checkApplyPatch", () => {
|
|
it("returns ok for a clean patch", async () => {
|
|
await withRepo(async (cwd) => {
|
|
const git = new GitOps({ log: () => undefined })
|
|
await fs.writeFile(nodePath.join(cwd, "a.txt"), "one\n", "utf8")
|
|
runGit(cwd, ["add", "-A"])
|
|
runGit(cwd, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"])
|
|
|
|
await fs.writeFile(nodePath.join(cwd, "a.txt"), "two\n", "utf8")
|
|
const branch = runGit(cwd, ["branch", "--show-current"]) || "HEAD"
|
|
const patch = await git.buildWorktreePatch(cwd, branch)
|
|
|
|
// Reset the file so the patch can apply cleanly to the original state
|
|
runGit(cwd, ["checkout", "--", "a.txt"])
|
|
const result = await git.checkApplyPatch(cwd, patch)
|
|
expect(result.ok).toBe(true)
|
|
})
|
|
})
|
|
|
|
it("returns not-ok for an empty patch", async () => {
|
|
const git = new GitOps({ log: () => undefined })
|
|
const result = await git.checkApplyPatch("/tmp", "")
|
|
expect(result.ok).toBe(true)
|
|
expect(result.message).toBe("No changes to apply")
|
|
})
|
|
|
|
it("reports conflicts when patch context does not match", async () => {
|
|
await withRepo(async (cwd) => {
|
|
const git = new GitOps({ log: () => undefined })
|
|
// File content that does NOT match the patch context
|
|
await fs.writeFile(nodePath.join(cwd, "a.txt"), "completely different content\n", "utf8")
|
|
runGit(cwd, ["add", "-A"])
|
|
runGit(cwd, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"])
|
|
|
|
// Craft a patch whose context lines don't exist in the file
|
|
// and has no full-index blob SHAs, so --3way can't recover
|
|
const patch = [
|
|
"diff --git a/a.txt b/a.txt",
|
|
"--- a/a.txt",
|
|
"+++ b/a.txt",
|
|
"@@ -1,3 +1,3 @@",
|
|
" line1",
|
|
"-line2",
|
|
"+patched",
|
|
" line3",
|
|
"",
|
|
].join("\n")
|
|
|
|
const result = await git.checkApplyPatch(cwd, patch)
|
|
expect(result.ok).toBe(false)
|
|
expect(result.conflicts.length).toBeGreaterThan(0)
|
|
expect(result.message).toBeTruthy()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("applyPatch", () => {
|
|
it("applies changes to the working tree", async () => {
|
|
await withRepo(async (cwd) => {
|
|
const git = new GitOps({ log: () => undefined })
|
|
await fs.writeFile(nodePath.join(cwd, "a.txt"), "one\n", "utf8")
|
|
runGit(cwd, ["add", "-A"])
|
|
runGit(cwd, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"])
|
|
|
|
await fs.writeFile(nodePath.join(cwd, "a.txt"), "two\n", "utf8")
|
|
const branch = runGit(cwd, ["branch", "--show-current"]) || "HEAD"
|
|
const patch = await git.buildWorktreePatch(cwd, branch)
|
|
|
|
runGit(cwd, ["checkout", "--", "a.txt"])
|
|
const result = await git.applyPatch(cwd, patch)
|
|
expect(result.ok).toBe(true)
|
|
|
|
const content = await fs.readFile(nodePath.join(cwd, "a.txt"), "utf8")
|
|
expect(content).toBe("two\n")
|
|
})
|
|
})
|
|
|
|
it("returns empty patch as success", async () => {
|
|
const git = new GitOps({ log: () => undefined })
|
|
const result = await git.applyPatch("/tmp", "")
|
|
expect(result.ok).toBe(true)
|
|
expect(result.message).toBe("No changes to apply")
|
|
})
|
|
|
|
it("returns conflicts when applying a conflicting patch", async () => {
|
|
await withRepo(async (cwd) => {
|
|
const git = new GitOps({ log: () => undefined })
|
|
await fs.writeFile(nodePath.join(cwd, "a.txt"), "line1\nline2\nline3\n", "utf8")
|
|
runGit(cwd, ["add", "-A"])
|
|
runGit(cwd, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"])
|
|
|
|
await fs.writeFile(nodePath.join(cwd, "a.txt"), "line1\npatched\nline3\n", "utf8")
|
|
const branch = runGit(cwd, ["branch", "--show-current"]) || "HEAD"
|
|
const patch = await git.buildWorktreePatch(cwd, branch)
|
|
|
|
await fs.writeFile(nodePath.join(cwd, "a.txt"), "line1\ndifferent\nline3\n", "utf8")
|
|
runGit(cwd, ["add", "-A"])
|
|
runGit(cwd, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "diverge"])
|
|
|
|
const result = await git.applyPatch(cwd, patch)
|
|
expect(result.ok).toBe(false)
|
|
expect(result.conflicts.length).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("workingTreeStats", () => {
|
|
it("parses numstat for tracked changes", async () => {
|
|
const git = ops(async (args) => {
|
|
if (args[0] === "diff") return "3\t1\tsrc/a.ts\n0\t5\tsrc/b.ts"
|
|
if (args[0] === "ls-files") return ""
|
|
return ""
|
|
})
|
|
const stats = await git.workingTreeStats("/repo")
|
|
expect(stats).toEqual({ files: 2, additions: 3, deletions: 6 })
|
|
})
|
|
|
|
it("treats binary numstat entries as zero additions and deletions", async () => {
|
|
const git = ops(async (args) => {
|
|
if (args[0] === "diff") return "2\t1\ttext.ts\n-\t-\timage.png"
|
|
if (args[0] === "ls-files") return ""
|
|
return ""
|
|
})
|
|
const stats = await git.workingTreeStats("/repo")
|
|
expect(stats).toEqual({ files: 2, additions: 2, deletions: 1 })
|
|
})
|
|
|
|
it("returns zeros for a clean working tree", async () => {
|
|
const git = ops(async (args) => {
|
|
if (args[0] === "diff") return ""
|
|
if (args[0] === "ls-files") return ""
|
|
return ""
|
|
})
|
|
const stats = await git.workingTreeStats("/repo")
|
|
expect(stats).toEqual({ files: 0, additions: 0, deletions: 0 })
|
|
})
|
|
|
|
it("returns zeros when git commands fail", async () => {
|
|
const git = ops(async () => {
|
|
throw new Error("not a git repo")
|
|
})
|
|
const stats = await git.workingTreeStats("/repo")
|
|
expect(stats).toEqual({ files: 0, additions: 0, deletions: 0 })
|
|
})
|
|
|
|
it("counts untracked file lines as additions", async () => {
|
|
await withRepo(async (cwd) => {
|
|
const git = new GitOps({ log: () => undefined })
|
|
await fs.writeFile(nodePath.join(cwd, "init.txt"), "x\n", "utf8")
|
|
runGit(cwd, ["add", "-A"])
|
|
runGit(cwd, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"])
|
|
|
|
await fs.writeFile(nodePath.join(cwd, "new.txt"), "a\nb\nc", "utf8")
|
|
|
|
const stats = await git.workingTreeStats(cwd)
|
|
expect(stats.files).toBe(1)
|
|
expect(stats.additions).toBe(3)
|
|
})
|
|
})
|
|
|
|
it("skips untracked files larger than 1MB", async () => {
|
|
await withRepo(async (cwd) => {
|
|
const git = new GitOps({ log: () => undefined })
|
|
await fs.writeFile(nodePath.join(cwd, "init.txt"), "x\n", "utf8")
|
|
runGit(cwd, ["add", "-A"])
|
|
runGit(cwd, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"])
|
|
|
|
await fs.writeFile(nodePath.join(cwd, "huge.bin"), Buffer.alloc(1_000_001, 0x41))
|
|
await fs.writeFile(nodePath.join(cwd, "small.txt"), "hello", "utf8")
|
|
|
|
const stats = await git.workingTreeStats(cwd)
|
|
expect(stats.files).toBe(2)
|
|
// small.txt: "hello".split("\n").length = 1, huge.bin: skipped (0)
|
|
expect(stats.additions).toBe(1)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("dispose", () => {
|
|
it("aborts in-flight runGit calls quickly", async () => {
|
|
let resolved = false
|
|
const git = new GitOps({
|
|
log: () => undefined,
|
|
runGit: async () => {
|
|
await sleep(5000)
|
|
resolved = true
|
|
return "should not reach"
|
|
},
|
|
})
|
|
|
|
const start = Date.now()
|
|
const pending = git.currentBranch("/repo")
|
|
git.dispose()
|
|
await pending
|
|
const elapsed = Date.now() - start
|
|
expect(elapsed).toBeLessThan(500)
|
|
expect(resolved).toBe(false)
|
|
})
|
|
|
|
it("causes subsequent runGit calls to fail immediately", async () => {
|
|
let called = false
|
|
const git = new GitOps({
|
|
log: () => undefined,
|
|
runGit: async () => {
|
|
called = true
|
|
return "ok"
|
|
},
|
|
})
|
|
git.dispose()
|
|
|
|
// currentBranch swallows errors — should return "" without calling runGit
|
|
const result = await git.currentBranch("/repo")
|
|
expect(result).toBe("")
|
|
expect(called).toBe(false)
|
|
})
|
|
|
|
it("reports disposed state", () => {
|
|
const git = ops(async () => "ok")
|
|
expect(git.disposed).toBe(false)
|
|
git.dispose()
|
|
expect(git.disposed).toBe(true)
|
|
})
|
|
|
|
it("kills in-flight exec (spawn) processes", async () => {
|
|
await withRepo(async (cwd) => {
|
|
const git = new GitOps({ log: () => undefined })
|
|
await fs.writeFile(nodePath.join(cwd, "a.txt"), "one\n", "utf8")
|
|
runGit(cwd, ["add", "-A"])
|
|
runGit(cwd, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"])
|
|
await fs.writeFile(nodePath.join(cwd, "a.txt"), "two\n", "utf8")
|
|
|
|
const branch = runGit(cwd, ["branch", "--show-current"]) || "HEAD"
|
|
const pending = git.buildWorktreePatch(cwd, branch)
|
|
// Give spawn a moment to start, then dispose
|
|
await sleep(10)
|
|
git.dispose()
|
|
|
|
// Should either reject or return (but process should be killed)
|
|
try {
|
|
await pending
|
|
} catch {
|
|
// expected — aborted
|
|
}
|
|
expect(git.disposed).toBe(true)
|
|
})
|
|
})
|
|
|
|
it("kills an in-flight exec when its request signal aborts", async () => {
|
|
await withRepo(async (cwd) => {
|
|
const git = new GitOps({ log: () => undefined, binary: async () => process.execPath })
|
|
const ctl = new AbortController()
|
|
const pending = git.execGit(["-e", "setTimeout(() => {}, 5000)"], cwd, { signal: ctl.signal })
|
|
await sleep(25)
|
|
ctl.abort()
|
|
|
|
const result = await pending
|
|
expect(result.code).not.toBe(0)
|
|
git.dispose()
|
|
})
|
|
})
|
|
|
|
it("is safe to call multiple times", () => {
|
|
const git = ops(async () => "ok")
|
|
git.dispose()
|
|
git.dispose()
|
|
expect(git.disposed).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe("semaphore integration", () => {
|
|
it("limits concurrent raw() calls", async () => {
|
|
let running = 0
|
|
let peak = 0
|
|
const sem = new Semaphore(2)
|
|
const git = ops(async () => {
|
|
running++
|
|
peak = Math.max(peak, running)
|
|
await sleep(10)
|
|
running--
|
|
return "ok"
|
|
}, sem)
|
|
|
|
await Promise.all(Array.from({ length: 6 }, () => git.currentBranch("/repo")))
|
|
expect(peak).toBe(2)
|
|
})
|
|
|
|
it("works without a semaphore (no gating)", async () => {
|
|
let running = 0
|
|
let peak = 0
|
|
const git = ops(async () => {
|
|
running++
|
|
peak = Math.max(peak, running)
|
|
await sleep(10)
|
|
running--
|
|
return "ok"
|
|
})
|
|
|
|
await Promise.all(Array.from({ length: 4 }, () => git.currentBranch("/repo")))
|
|
expect(peak).toBe(4)
|
|
})
|
|
})
|
|
})
|