gitvan
Version:
Autonomic Git-native development automation platform with AI-powered workflows
618 lines (526 loc) • 18.3 kB
JavaScript
// src/composables/git.mjs
// GitVan v2 — useGit() - 80/20 Implementation
// - POSIX-first. No external deps. ESM.
// - Deterministic env: TZ=UTC, LANG=C.
// - UnJS context-aware (unctx). Captures context once to avoid loss after await.
// - Happy path only. No retries. No shell string interpolation.
// - 80/20 commands: Only the essential Git operations that GitVan actually uses.
import { execFile } from "node:child_process";
import path from "node:path";
import { useGitVan, tryUseGitVan } from "../core/context.mjs";
async function runGit(args, { cwd, env, maxBuffer = 12 * 1024 * 1024 } = {}) {
try {
const { stdout } = await execFile("git", args, {
cwd,
env,
maxBuffer,
});
return typeof stdout === "string" ? stdout.trim() : "";
} catch (error) {
// Handle specific cases for empty repositories
const command = `git ${args.join(" ")}`;
const errorMsg = error.message || "";
const stderr = error.stderr || "";
const fullError = `${errorMsg} ${stderr}`;
// Handle empty repository cases for rev-list commands
if (
args[0] === "rev-list" &&
(fullError.includes("ambiguous argument") ||
fullError.includes("unknown revision") ||
fullError.includes("not in the working tree") ||
fullError.includes("fatal: ambiguous argument"))
) {
return ""; // Return empty string for empty repo
}
// Re-throw with more context for other errors
const newError = new Error(`Command failed: ${command}\n${error.message}`);
newError.originalError = error;
newError.command = command;
newError.args = args;
newError.stderr = error.stderr;
throw newError;
}
}
async function runGitVoid(args, opts) {
await runGit(args, opts);
}
function toArr(x) {
return Array.isArray(x) ? x : [x];
}
export function useGit() {
// Get context from unctx - this must be called synchronously
let ctx;
try {
ctx = useGitVan();
} catch {
ctx = tryUseGitVan?.() || null;
}
// Resolve working directory
const cwd = (ctx && ctx.cwd) || process.cwd();
// Set up deterministic environment with UTC timezone and C locale
// Context env should not override TZ and LANG for determinism
const env = {
...process.env,
...(ctx && ctx.env ? ctx.env : {}),
TZ: "UTC", // Always override to UTC for determinism
LANG: "C", // Always override to C locale for determinism
};
const base = { cwd, env };
return {
// Context properties (exposed for testing)
cwd: base.cwd,
env: base.env,
// ---------- Repo info ----------
async branch() {
return runGit(["rev-parse", "--abbrev-ref", "HEAD"], base);
},
async head() {
return runGit(["rev-parse", "HEAD"], base);
},
async headSha() {
return runGit(["rev-parse", "HEAD"], base);
},
async repoRoot() {
return runGit(["rev-parse", "--show-toplevel"], base);
},
async worktreeGitDir() {
return runGit(["rev-parse", "--git-dir"], base);
},
nowISO() {
// Use context-provided time if available, otherwise fall back to env or current time
if (ctx && typeof ctx.now === "function") {
return ctx.now();
}
const forced = process.env.GITVAN_NOW;
return forced || new Date().toISOString();
},
// ---------- Read-only helpers ----------
async log(format = "%h%x09%s", extra = []) {
const extraArgs =
typeof extra === "string"
? extra.split(/\s+/).filter(Boolean)
: toArr(extra);
return runGit(["log", `--pretty=${format}`, ...extraArgs], base);
},
async logSinceLastTag(format = "%h%x09%s") {
try {
return runGit(["log", `--pretty=${format}`, "--oneline", "HEAD"], base);
} catch {
// If no tags exist, return empty
return "";
}
},
async statusPorcelain() {
return runGit(["status", "--porcelain"], base);
},
async isAncestor(a, b = "HEAD") {
try {
await runGitVoid(["merge-base", "--is-ancestor", a, b], base);
return true;
} catch {
return false;
}
},
async mergeBase(a, b) {
return runGit(["merge-base", a, b], base);
},
async revList(args = ["--max-count=50", "HEAD"]) {
const argArray = toArr(args);
// Ensure we always have a commit reference
if (argArray.length === 1 && argArray[0].startsWith("--")) {
argArray.push("HEAD");
}
return runGit(["rev-list", ...argArray], base);
},
// ---------- Write helpers (happy path) ----------
async add(paths) {
const list = toArr(paths).filter(Boolean);
if (list.length === 0) return;
await runGitVoid(["add", "--", ...list], base);
},
async writeFile(filePath, content) {
const { writeFile } = await import("node:fs/promises");
const fullPath = path.isAbsolute(filePath)
? filePath
: path.join(base.cwd, filePath);
await writeFile(fullPath, content, "utf8");
return fullPath;
},
async commit(message, opts = {}) {
const args = ["commit", "-m", message];
if (opts.sign) args.push("-S");
await runGitVoid(args, base);
},
async tag(name, msg, opts = {}) {
const args = ["tag"];
if (opts.sign) args.push("-s");
if (msg) args.push("-m", msg);
args.push(name);
await runGitVoid(args, base);
},
// ---------- Notes (receipts) ----------
async noteAdd(ref, message, sha = "HEAD") {
// git will create the notes ref if needed
await runGitVoid(
["notes", `--ref=${ref}`, "add", "-f", "-m", message, sha],
base
);
},
async noteAppend(ref, message, sha = "HEAD") {
await runGitVoid(
["notes", `--ref=${ref}`, "append", "-m", message, sha],
base
);
},
async noteShow(ref, sha = "HEAD") {
return runGit(["notes", `--ref=${ref}`, "show", sha], base);
},
// ---------- Atomic ref create (locks) ----------
// Uses stdin protocol to atomically create a ref if absent.
async updateRefCreate(ref, valueSha) {
// Check if ref exists first
try {
await runGitVoid(["show-ref", "--verify", "--quiet", ref], base);
// Ref exists, return false to indicate failure
return false;
} catch {
// Ref doesn't exist, try to create it
try {
await runGitVoid(["update-ref", ref, valueSha], base);
return true;
} catch (error) {
// If creation failed due to race condition, check if it exists now
try {
await runGitVoid(["show-ref", "--verify", "--quiet", ref], base);
return false; // Someone else created it
} catch {
throw error; // Real error, re-throw
}
}
}
},
// ---------- Plumbing ----------
async hashObject(filePath, { write = false } = {}) {
const abs = path.isAbsolute(filePath)
? filePath
: path.join(base.cwd, filePath);
const args = ["hash-object"];
if (write) args.push("-w");
args.push("--", abs);
return runGit(args, base);
},
async writeTree() {
return runGit(["write-tree"], base);
},
async catFilePretty(sha) {
try {
return runGit(["cat-file", "-p", sha], base);
} catch (error) {
// Handle common error cases gracefully
if (error.message.includes("Not a valid object name")) {
throw new Error(`Object ${sha} not found`);
}
throw error;
}
},
// ---------- Utility methods ----------
async isClean() {
const status = await this.statusPorcelain();
return status.trim() === "";
},
async hasUncommittedChanges() {
const status = await this.statusPorcelain();
return status.trim() !== "";
},
async getCurrentBranch() {
try {
return await this.branch();
} catch (error) {
// Handle detached HEAD state
if (error.message.includes("detached HEAD")) {
return "HEAD";
}
throw error;
}
},
async getCommitCount(branch = "HEAD") {
try {
const result = await runGit(["rev-list", "--count", branch], base);
return parseInt(result, 10) || 0;
} catch {
return 0;
}
},
// ---------- Info methods ----------
async info() {
try {
const [head, branch, worktree] = await Promise.all([
this.head(),
this.getCurrentBranch(),
this.repoRoot(),
]);
return {
head,
branch,
worktree,
isClean: await this.isClean(),
hasUncommittedChanges: await this.hasUncommittedChanges(),
};
} catch (error) {
throw new Error(`Failed to get git info: ${error.message}`);
}
},
// ---------- Ref methods ----------
async listRefs(pattern = "") {
try {
const args = ["for-each-ref", "--format=%(refname)"];
if (pattern) {
args.push(pattern);
}
const output = await runGit(args, base);
return output.split("\n").filter((line) => line.trim());
} catch (error) {
return [];
}
},
async getRef(ref) {
try {
const output = await runGit(["show-ref", "--verify", ref], base);
return output.trim();
} catch (error) {
return null;
}
},
// ---------- Worktree methods ----------
async listWorktrees() {
try {
const output = await runGit(["worktree", "list", "--porcelain"], base);
const worktrees = [];
let current = {};
for (const line of output.split("\n")) {
if (line.startsWith("worktree ")) {
if (current.path) worktrees.push(current);
current = { path: line.substring(9) };
} else if (line.startsWith("HEAD ")) {
current.head = line.substring(5);
} else if (line.startsWith("branch ")) {
current.branch = line.substring(7).replace("refs/heads/", "");
} else if (line.startsWith("detached")) {
current.detached = true;
}
}
if (current.path) worktrees.push(current);
// Mark main worktree
const mainPath = await this.repoRoot();
return worktrees.map((wt) => ({
...wt,
isMain: wt.path === mainPath,
}));
} catch {
// Fallback to single worktree
const worktree = await this.repoRoot();
const head = await this.head();
const branch = await this.getCurrentBranch();
return [
{
path: worktree,
head: head,
branch: branch,
isMain: true,
},
];
}
},
// ---------- Diff operations ----------
async diff(options = {}) {
const args = ["diff"];
// Handle different diff types
if (options.cached) args.push("--cached");
if (options.staged) args.push("--cached");
if (options.nameOnly) args.push("--name-only");
if (options.nameStatus) args.push("--name-status");
if (options.stat) args.push("--stat");
if (options.shortstat) args.push("--shortstat");
if (options.numstat) args.push("--numstat");
// Handle commit ranges
if (options.from && options.to) {
args.push(`${options.from}..${options.to}`);
} else if (options.from) {
args.push(options.from);
}
// Handle specific files
if (options.files && options.files.length > 0) {
args.push("--", ...toArr(options.files));
}
return runGit(args, base);
},
// ---------- Remote operations ----------
async fetch(remote = "origin", refspec = "", options = {}) {
const args = ["fetch"];
if (options.prune) args.push("--prune");
if (options.tags) args.push("--tags");
if (options.all) args.push("--all");
if (options.depth) args.push(`--depth=${options.depth}`);
if (remote) args.push(remote);
if (refspec) args.push(refspec);
await runGitVoid(args, base);
},
async push(remote = "origin", ref = "HEAD", options = {}) {
const args = ["push"];
if (options.force) args.push("--force");
if (options.setUpstream) args.push("--set-upstream");
if (options.tags) args.push("--tags");
if (options.delete) args.push("--delete");
if (options.dryRun) args.push("--dry-run");
if (remote) args.push(remote);
if (ref) args.push(ref);
await runGitVoid(args, base);
},
async pull(remote = "origin", branch = "", options = {}) {
const args = ["pull"];
if (options.rebase) args.push("--rebase");
if (options.ff) args.push("--ff-only");
if (options.noff) args.push("--no-ff");
if (options.squash) args.push("--squash");
if (remote) args.push(remote);
if (branch) args.push(branch);
await runGitVoid(args, base);
},
// ---------- Branch operations ----------
async branchList(options = {}) {
const args = ["branch"];
if (options.all) args.push("-a");
if (options.remote) args.push("-r");
if (options.merged) args.push("--merged");
if (options.noMerged) args.push("--no-merged");
if (options.verbose) args.push("-v");
const output = await runGit(args, base);
return output
.split("\n")
.filter((line) => line.trim())
.map((line) => line.replace(/^\*?\s*/, "").trim());
},
async branchCreate(name, startPoint = "HEAD", options = {}) {
const args = ["branch"];
if (options.force) args.push("-f");
if (options.track) args.push("--track");
if (options.noTrack) args.push("--no-track");
args.push(name);
if (startPoint !== "HEAD") args.push(startPoint);
await runGitVoid(args, base);
},
async branchDelete(name, options = {}) {
const args = ["branch"];
if (options.force) args.push("-D");
else args.push("-d");
args.push(name);
await runGitVoid(args, base);
},
// ---------- Checkout/Switch operations ----------
async checkout(ref, options = {}) {
const args = ["checkout"];
if (options.force) args.push("-f");
if (options.create) args.push("-b");
if (options.track) args.push("--track");
if (options.detach) args.push("--detach");
if (ref) args.push(ref);
await runGitVoid(args, base);
},
async switch(branch, options = {}) {
const args = ["switch"];
if (options.create) args.push("-c");
if (options.force) args.push("-f");
if (options.detach) args.push("--detach");
if (options.track) args.push("--track");
if (options.noTrack) args.push("--no-track");
if (branch) args.push(branch);
await runGitVoid(args, base);
},
// ---------- Merge operations ----------
async merge(ref, options = {}) {
const args = ["merge"];
if (options.noff) args.push("--no-ff");
if (options.ff) args.push("--ff-only");
if (options.squash) args.push("--squash");
if (options.noCommit) args.push("--no-commit");
if (options.message) args.push("-m", options.message);
if (ref) args.push(ref);
await runGitVoid(args, base);
},
// ---------- Rebase operations ----------
async rebase(onto = "origin/main", options = {}) {
const args = ["rebase"];
if (options.interactive) args.push("-i");
if (options.continue) args.push("--continue");
if (options.abort) args.push("--abort");
if (options.skip) args.push("--skip");
if (options.autosquash) args.push("--autosquash");
if (options.noAutosquash) args.push("--no-autosquash");
if (onto) args.push(onto);
await runGitVoid(args, base);
},
// ---------- Reset operations ----------
async reset(mode = "mixed", ref = "HEAD", options = {}) {
const args = ["reset"];
// Reset modes: soft, mixed, hard, merge, keep
if (mode) args.push(`--${mode}`);
if (options.paths && options.paths.length > 0) {
args.push("--", ...toArr(options.paths));
} else if (ref) {
args.push(ref);
}
await runGitVoid(args, base);
},
// ---------- Stash operations ----------
async stashSave(message = "", options = {}) {
const args = ["stash"];
if (options.push) args.push("push");
else args.push("save");
if (message) args.push("-m", message);
if (options.includeUntracked) args.push("-u");
if (options.keepIndex) args.push("--keep-index");
await runGitVoid(args, base);
},
async stashList() {
const output = await runGit(["stash", "list"], base);
return output.split("\n").filter((line) => line.trim());
},
async stashApply(stash = "stash@{0}", options = {}) {
const args = ["stash"];
if (options.pop) args.push("pop");
else args.push("apply");
if (stash) args.push(stash);
await runGitVoid(args, base);
},
async stashDrop(stash = "stash@{0}") {
await runGitVoid(["stash", "drop", stash], base);
},
// ---------- Cherry-pick operations ----------
async cherryPick(commit, options = {}) {
const args = ["cherry-pick"];
if (options.continue) args.push("--continue");
if (options.abort) args.push("--abort");
if (options.skip) args.push("--skip");
if (options.noCommit) args.push("--no-commit");
if (options.edit) args.push("--edit");
if (commit) args.push(commit);
await runGitVoid(args, base);
},
// ---------- Revert operations ----------
async revert(commit, options = {}) {
const args = ["revert"];
if (options.noCommit) args.push("--no-commit");
if (options.edit) args.push("--edit");
if (options.mainline) args.push("-m", options.mainline);
if (commit) args.push(commit);
await runGitVoid(args, base);
},
// ---------- Generic runner (escape hatch) ----------
async run(args) {
return runGit(toArr(args), base);
},
async runVoid(args) {
await runGitVoid(toArr(args), base);
},
};
}