// // Thin git wrapper. Nova cannot read a repository's state directly, so branch, // remote, and checkout information all come from the git binary. // const { exec, output } = require("./exec.js"); const log = require("./log.js"); const GIT = "/usr/bin/env"; function git(dir, args) { return exec(GIT, ["git", ...args], { cwd: dir }); } function gitOutput(dir, args) { return output(GIT, ["git", ...args], { cwd: dir }); } async function isRepository(dir) { const result = await gitOutput(dir, ["rev-parse", "--is-inside-work-tree"]); return result === "true"; } async function repositoryRoot(dir) { return await gitOutput(dir, ["rev-parse", "--show-toplevel"]); } async function currentBranch(dir) { const branch = await gitOutput(dir, ["rev-parse", "--abbrev-ref", "HEAD"]); // A detached HEAD reports "HEAD", which is not a branch anyone can filter by. return branch && branch !== "HEAD" ? branch : null; } async function headSha(dir) { return await gitOutput(dir, ["rev-parse", "HEAD"]); } /** Returns [{ name, url }] for every configured remote. */ async function remotes(dir) { const text = await gitOutput(dir, ["config", "--get-regexp", "^remote\\..*\\.url$"]); if (!text) return []; const found = []; for (const line of text.split("\n")) { const match = line.trim().match(/^remote\.(.+)\.url\s+(.+)$/); if (match) found.push({ name: match[1], url: match[2] }); } return found; } /** Prefers "origin" when present; otherwise the first remote defined. */ async function primaryRemote(dir) { const list = await remotes(dir); if (!list.length) return null; return list.find((remote) => remote.name === "origin") || list[0]; } async function hasLocalBranch(dir, branch) { const result = await git(dir, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]); return result.status === 0; } async function isDirty(dir) { const text = await gitOutput(dir, ["status", "--porcelain"]); return Boolean(text && text.length); } /** * Fetches the remote and switches to `branch`, creating a tracking branch when * it only exists on the remote. Resolves to an error string, or null on success. */ async function checkoutBranch(dir, branch, remoteName = "origin") { if (await isDirty(dir)) { return "The working tree has uncommitted changes."; } const fetched = await git(dir, ["fetch", remoteName, branch]); if (fetched.status !== 0) { log.debug("fetch failed", fetched.stderr); } if (await hasLocalBranch(dir, branch)) { const result = await git(dir, ["checkout", branch]); return result.status === 0 ? null : result.stderr.trim() || "Checkout failed."; } const result = await git(dir, [ "checkout", "-b", branch, "--track", `${remoteName}/${branch}`, ]); return result.status === 0 ? null : result.stderr.trim() || "Checkout failed."; } /** * Finds git repositories at `root` and one level below it, which covers both a * single-project workspace and the common "folder of repos" layout. */ async function discoverRepositories(root) { if (!root) return []; const found = []; if (await isRepository(root)) { const top = (await repositoryRoot(root)) || root; found.push(top); } let entries = []; try { entries = nova.fs.listdir(root); } catch (error) { log.debug("listdir failed", root, String(error)); } for (const entry of entries) { if (entry.startsWith(".")) continue; const child = nova.path.join(root, entry); const stats = nova.fs.stat(child); if (!stats || !stats.isDirectory()) continue; if (!nova.fs.access(nova.path.join(child, ".git"), nova.fs.F_OK)) continue; if (!found.includes(child)) found.push(child); } return found; } exports.git = git; exports.isRepository = isRepository; exports.repositoryRoot = repositoryRoot; exports.currentBranch = currentBranch; exports.headSha = headSha; exports.remotes = remotes; exports.primaryRemote = primaryRemote; exports.checkoutBranch = checkoutBranch; exports.isDirty = isDirty; exports.discoverRepositories = discoverRepositories;