@jovan158/vantage
Version:
See and control what your coding agent does
137 lines (136 loc) • 6.22 kB
JavaScript
// Git plumbing for session isolation (CONCEPT.md problem ④).
//
// Isolation model: run the agent in a dedicated git worktree on a branch
// `vantage/<sessionId>` cut from HEAD, so the user's working tree is never
// touched. At the end we commit the agent's work to that branch, which gives a
// clean aggregated diff (base..HEAD) and a trivial merge-or-discard decision.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
export function git(cwd, args) {
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
}
export function gitSafe(cwd, args) {
try {
const stdout = execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
return { ok: true, stdout: stdout.trim(), stderr: "" };
}
catch (err) {
const e = err;
return {
ok: false,
stdout: String(e.stdout ?? "").trim(),
stderr: String(e.stderr ?? "").trim(),
};
}
}
export function isGitRepo(cwd) {
return gitSafe(cwd, ["rev-parse", "--is-inside-work-tree"]).stdout === "true";
}
export function headSha(cwd) {
return git(cwd, ["rev-parse", "HEAD"]);
}
export function isDirty(cwd) {
return git(cwd, ["status", "--porcelain"]).length > 0;
}
// Create branch `vantage/<sessionId>` at HEAD and a worktree checked out on it.
export function addSessionWorktree(cwd, sessionId) {
const baseSha = headSha(cwd);
const branch = `vantage/${sessionId}`;
const wtPath = path.join(cwd, ".vantage", "worktrees", sessionId);
git(cwd, ["worktree", "add", "-b", branch, wtPath, baseSha]);
return { path: wtPath, branch, baseSha };
}
// Commit whatever the agent changed in the worktree onto the isolation branch.
// Returns true if a commit was made, false if the tree was unchanged.
export function commitSessionWork(worktreeCwd, message) {
if (!isDirty(worktreeCwd))
return false;
git(worktreeCwd, ["add", "-A"]);
git(worktreeCwd, ["commit", "--no-verify", "-m", message]);
return true;
}
// Aggregated diff of the isolation branch against its base.
export function sessionDiff(worktreeCwd, baseSha) {
const numstat = git(worktreeCwd, ["diff", "--numstat", baseSha, "HEAD"]);
const files = [];
let added = 0;
let removed = 0;
for (const line of numstat.split("\n").filter(Boolean)) {
const [a, r, ...rest] = line.split("\t");
const filePath = rest.join("\t");
const ai = a === "-" ? -1 : Number(a);
const ri = r === "-" ? -1 : Number(r);
files.push({ path: filePath, added: ai, removed: ri });
if (ai > 0)
added += ai;
if (ri > 0)
removed += ri;
}
const patch = git(worktreeCwd, ["diff", baseSha, "HEAD"]);
return { files, added, removed, patch };
}
// Remove the worktree and (optionally) delete the isolation branch.
export function removeSessionWorktree(cwd, worktreePath, branch, deleteBranch = true) {
gitSafe(cwd, ["worktree", "remove", "--force", worktreePath]);
if (deleteBranch)
gitSafe(cwd, ["branch", "-D", branch]);
}
// ---------------------------------------------------------------------------
// Change tracking without isolation: what changed in the working tree while
// a session ran.
//
// A snapshot is the whole working tree — tracked and untracked files, minus
// .gitignore'd ones — written as a git tree object through a throwaway index.
// The user's index, branches and history are never touched; the tree objects
// are unreferenced and git's garbage collection removes them eventually.
// Hashing thousands of untracked files (an unignored node_modules, say)
// would stall the start and bloat the object store; past this, skip.
const MAX_UNTRACKED = 2000;
export function snapshotWorkingTree(cwd, timeoutMs = 30_000) {
const run = (args, env = process.env) => execFileSync("git", args, { cwd, env, encoding: "utf8", timeout: timeoutMs, stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024 }).trim();
let tmpIndex = null;
try {
// Vantage's own files (session logs, in <project>/.vantage) are not the
// session's changes. The project may be a subdirectory of the repo.
const notOurs = `:(exclude,top)${run(["rev-parse", "--show-prefix"])}.vantage`;
const untracked = run(["ls-files", "--others", "--exclude-standard", "-z", "--", ":/", notOurs]).split("\0").filter(Boolean).length;
if (untracked > MAX_UNTRACKED) {
return { ok: false, reason: `${untracked} untracked files (more than ${MAX_UNTRACKED}) — add them to .gitignore to get a change summary` };
}
// Start from a copy of the real index: git then only re-hashes files
// whose stat data changed, which keeps this fast in large repos.
tmpIndex = path.join(os.tmpdir(), `vantage-index-${process.pid}-${Math.random().toString(36).slice(2)}`);
const realIndex = path.resolve(cwd, run(["rev-parse", "--git-path", "index"]));
if (fs.existsSync(realIndex))
fs.copyFileSync(realIndex, tmpIndex);
const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
run(["add", "--all", "--", ":/", notOurs], env);
return { ok: true, tree: run(["write-tree"], env) };
}
catch (err) {
return { ok: false, reason: err.message.split("\n")[0] ?? "git failed" };
}
finally {
if (tmpIndex)
fs.rmSync(tmpIndex, { force: true });
}
}
export function treeDiff(cwd, fromTree, toTree) {
const files = [];
let added = 0;
let removed = 0;
const numstat = git(cwd, ["diff", "--numstat", fromTree, toTree]);
for (const line of numstat.split("\n").filter(Boolean)) {
const [a, r, ...rest] = line.split("\t");
const ai = a === "-" ? -1 : Number(a);
const ri = r === "-" ? -1 : Number(r);
files.push({ path: rest.join("\t"), added: ai, removed: ri });
if (ai > 0)
added += ai;
if (ri > 0)
removed += ri;
}
return { files, added, removed, patch: files.length ? git(cwd, ["diff", fromTree, toTree]) : "" };
}