// Git CLI integration for project status, safe common actions, and file diffs. var fs = require("fs"); var path = require("path"); var { execFile, execFileSync } = require("child_process"); var { wrapSpawnAsUser } = require("./os-users"); var MAX_DIFF_BYTES = 2 * 1024 * 1024; var MAX_REMOTE_LABEL = 64; function gitEnvironment(osUserInfo) { var overrides = { GIT_TERMINAL_PROMPT: "0" }; if (osUserInfo && osUserInfo.home) overrides.HOME = osUserInfo.home; if (osUserInfo && osUserInfo.user) { overrides.USER = osUserInfo.user; overrides.LOGNAME = osUserInfo.user; } return Object.assign({}, process.env, overrides); } function runGitSync(cwd, args, options, osUserInfo) { var opts = Object.assign({ cwd: cwd, encoding: "utf8", timeout: 5000, maxBuffer: 8 * 1024 * 1024, stdio: "pipe", env: gitEnvironment(osUserInfo), }, options || {}); if (osUserInfo) { opts.uid = osUserInfo.uid; opts.gid = osUserInfo.gid; } var wrapped = wrapSpawnAsUser("git", args, opts); return execFileSync(wrapped.command, wrapped.args, wrapped.options); } function runGit(cwd, args, timeout, osUserInfo) { return new Promise(function (resolve, reject) { var opts = { cwd: cwd, encoding: "utf8", timeout: timeout || 30000, maxBuffer: 8 * 1024 * 1024, env: gitEnvironment(osUserInfo), }; if (osUserInfo) { opts.uid = osUserInfo.uid; opts.gid = osUserInfo.gid; } var wrapped = wrapSpawnAsUser("git", args, opts); execFile(wrapped.command, wrapped.args, wrapped.options, function (err, stdout, stderr) { if (err) { var detail = String(stderr || stdout || err.message || "Git command failed").trim(); err.userMessage = detail; reject(err); return; } resolve(String(stdout || stderr || "").trim()); }); }); } function parseBranchHeader(record, result) { var space = record.indexOf(" ", 2); if (space === -1) return; var key = record.slice(2, space); var value = record.slice(space + 1); if (key === "branch.oid") result.oid = value; else if (key === "branch.head") result.branch = value; else if (key === "branch.upstream") result.upstream = value; else if (key === "branch.ab") { var match = value.match(/^\+(\d+) -(\d+)$/); if (match) { result.ahead = parseInt(match[1], 10); result.behind = parseInt(match[2], 10); } } } function parseTrackedRecord(record, kind) { var pattern = kind === "2" ? /^2 ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$/ : /^1 ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$/; var match = record.match(pattern); if (!match) return null; var xy = match[1]; return { path: match[match.length - 1], originalPath: null, code: xy, staged: xy.charAt(0) !== ".", unstaged: xy.charAt(1) !== ".", untracked: false, conflicted: false, kind: kind === "2" ? "renamed" : "changed", }; } function parseUnmergedRecord(record) { var match = record.match(/^u ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$/); if (!match) return null; return { path: match[match.length - 1], originalPath: null, code: match[1], staged: true, unstaged: true, untracked: false, conflicted: true, kind: "conflicted", }; } function parsePorcelainV2(raw) { var result = { oid: null, branch: null, upstream: null, ahead: 0, behind: 0, files: [], }; var records = String(raw || "").split("\0"); for (var i = 0; i < records.length; i++) { var record = records[i]; if (!record) continue; if (record.indexOf("# ") === 0) { parseBranchHeader(record, result); continue; } if (record.indexOf("? ") === 0) { result.files.push({ path: record.slice(2), originalPath: null, code: "??", staged: false, unstaged: true, untracked: true, conflicted: false, kind: "untracked", }); continue; } if (record.indexOf("u ") === 0) { var conflict = parseUnmergedRecord(record); if (conflict) result.files.push(conflict); continue; } if (record.indexOf("1 ") === 0 || record.indexOf("2 ") === 0) { var kind = record.charAt(0); var file = parseTrackedRecord(record, kind); if (file && kind === "2" && i + 1 < records.length) { file.originalPath = records[++i] || null; } if (file) result.files.push(file); } } return result; } function normalizeGitPath(cwd, value) { return path.normalize(path.isAbsolute(value) ? value : path.resolve(cwd, value)); } // Whether this checkout is a linked worktree. The two directories have to be // compared through realpath: git answers --absolute-git-dir already resolved // but --git-common-dir relative to cwd, so on any platform where the working // directory sits behind a symlink (macOS /var -> /private/var, for one) a // plain string compare reports every ordinary repository as a worktree. function isLinkedWorktree(gitDir, commonDir) { if (!gitDir || !commonDir) return false; return realGitPath(gitDir) !== realGitPath(commonDir); } function realGitPath(value) { try { return fs.realpathSync(value); } catch (e) { return value; } } function parseWorktrees(raw) { var blocks = String(raw || "").trim().split(/\n\n+/); var result = []; for (var i = 0; i < blocks.length; i++) { if (!blocks[i]) continue; var lines = blocks[i].split("\n"); var item = { path: null, branch: null, head: null, bare: false, detached: false }; for (var j = 0; j < lines.length; j++) { var line = lines[j]; if (line.indexOf("worktree ") === 0) item.path = line.slice(9); else if (line.indexOf("HEAD ") === 0) item.head = line.slice(5); else if (line.indexOf("branch ") === 0) item.branch = line.slice(7).replace(/^refs\/heads\//, ""); else if (line === "bare") item.bare = true; else if (line === "detached") item.detached = true; } if (item.path) result.push(item); } return result; } function getStatus(cwd, osUserInfo) { try { var inside = runGitSync(cwd, ["rev-parse", "--is-inside-work-tree"], null, osUserInfo).trim(); if (inside !== "true") return { isRepository: false }; } catch (e) { return { isRepository: false }; } var root = runGitSync(cwd, ["rev-parse", "--show-toplevel"], null, osUserInfo).trim(); var raw = runGitSync(root, ["status", "--porcelain=v2", "--branch", "-z"], null, osUserInfo); var parsed = parsePorcelainV2(raw); var origin = null; var gitDir = null; var commonDir = null; var worktrees = []; try { origin = runGitSync(cwd, ["remote", "get-url", "origin"], null, osUserInfo).trim() || null; } catch (e) {} try { gitDir = normalizeGitPath(cwd, runGitSync(cwd, ["rev-parse", "--absolute-git-dir"], null, osUserInfo).trim()); } catch (e) {} try { commonDir = normalizeGitPath(cwd, runGitSync(cwd, ["rev-parse", "--git-common-dir"], null, osUserInfo).trim()); } catch (e) {} try { worktrees = parseWorktrees(runGitSync(cwd, ["worktree", "list", "--porcelain"], null, osUserInfo)); } catch (e) {} var normalizedRoot = path.normalize(root); var currentWorktree = null; for (var i = 0; i < worktrees.length; i++) { if (path.normalize(worktrees[i].path) === normalizedRoot) { currentWorktree = worktrees[i]; break; } } var detached = parsed.branch === "(detached)" || !!(currentWorktree && currentWorktree.detached); return { isRepository: true, name: path.basename(root), root: root, branch: detached ? null : parsed.branch, detached: detached, oid: parsed.oid === "(initial)" ? null : parsed.oid, upstream: parsed.upstream, ahead: parsed.ahead, behind: parsed.behind, origin: origin, gitDir: gitDir, commonDir: commonDir, isWorktree: isLinkedWorktree(gitDir, commonDir), mainWorktree: worktrees.length > 0 ? worktrees[0].path : root, worktrees: worktrees, files: parsed.files, dirty: parsed.files.length > 0, }; } // A bounded, credential-free remote label. The raw remote URL can carry an // embedded token, so the userinfo segment is dropped before the label is // built rather than after. function shortRemoteLabel(origin) { if (!origin) return null; var label = String(origin).trim() .replace(/^git@([^:]+):/, "$1/") .replace(/^[a-z][a-z0-9+.-]*:\/\//i, "") .replace(/^[^@/]*@/, "") .replace(/\.git$/, "") .replace(/\/+$/, ""); if (label.length > MAX_REMOTE_LABEL) label = label.slice(0, MAX_REMOTE_LABEL - 1) + "…"; return label || null; } function countChangedFiles(files) { var counts = { changed: files.length, staged: 0, unstaged: 0, untracked: 0, conflicted: 0 }; for (var i = 0; i < files.length; i++) { var file = files[i]; if (file.conflicted) counts.conflicted++; if (file.untracked) counts.untracked++; if (file.staged) counts.staged++; if (file.unstaged && !file.untracked) counts.unstaged++; } return counts; } // Cheap always-visible repository placard data. Deliberately narrower than // getStatus: three git invocations instead of six, no per-file payload, no // session attribution, and no filesystem path in the result. Identity is // derived here so the client never resolves or renders a repository path. function getSummary(cwd, osUserInfo) { var probe; try { probe = runGitSync(cwd, [ "rev-parse", "--is-inside-work-tree", "--show-toplevel", "--absolute-git-dir", "--git-common-dir", ], null, osUserInfo); } catch (e) { return { isRepository: false }; } var lines = String(probe || "").trim().split("\n"); if (String(lines[0] || "").trim() !== "true" || !lines[1]) return { isRepository: false }; var root = String(lines[1]).trim(); var gitDir = lines[2] ? normalizeGitPath(cwd, String(lines[2]).trim()) : null; var commonDir = lines[3] ? normalizeGitPath(cwd, String(lines[3]).trim()) : null; var parsed = parsePorcelainV2(runGitSync(root, ["status", "--porcelain=v2", "--branch", "-z"], null, osUserInfo)); var origin = null; try { origin = runGitSync(cwd, ["remote", "get-url", "origin"], null, osUserInfo).trim() || null; } catch (e) {} var detached = parsed.branch === "(detached)"; var counts = countChangedFiles(parsed.files); return { isRepository: true, name: path.basename(root), branch: detached ? null : parsed.branch, detached: detached, shortOid: parsed.oid && parsed.oid !== "(initial)" ? String(parsed.oid).slice(0, 8) : null, isWorktree: isLinkedWorktree(gitDir, commonDir), hasUpstream: !!parsed.upstream, ahead: parsed.ahead, behind: parsed.behind, changed: counts.changed, staged: counts.staged, unstaged: counts.unstaged, untracked: counts.untracked, conflicted: counts.conflicted, remote: shortRemoteLabel(origin), }; } function resolveChangedPaths(status, requestedPaths) { if (!Array.isArray(requestedPaths) || requestedPaths.length === 0 || requestedPaths.length > 200) { throw new Error("Select at least one changed file"); } var byPath = {}; for (var i = 0; i < status.files.length; i++) byPath[status.files[i].path] = status.files[i]; var result = []; var seen = {}; for (var j = 0; j < requestedPaths.length; j++) { var requested = requestedPaths[j]; var entry = typeof requested === "string" ? byPath[requested] : null; if (!entry) throw new Error("File is no longer changed: " + String(requested)); if (!seen[entry.path]) { result.push(entry.path); seen[entry.path] = true; } if (entry.originalPath && !seen[entry.originalPath]) { result.push(entry.originalPath); seen[entry.originalPath] = true; } } return result; } function runAction(cwd, body, osUserInfo) { var action = body && body.action; var status = getStatus(cwd, osUserInfo); if (!status.isRepository) return Promise.reject(new Error("This project is not a Git repository")); if (action === "stage" || action === "unstage") { var paths = resolveChangedPaths(status, body.paths); if (action === "stage") return runGit(status.root, ["add", "-A", "--"].concat(paths), null, osUserInfo); if (status.oid) return runGit(status.root, ["restore", "--staged", "--"].concat(paths), null, osUserInfo); return runGit(status.root, ["rm", "--cached", "-r", "--"].concat(paths), null, osUserInfo); } if (action === "stage_all") return runGit(status.root, ["add", "-A"], null, osUserInfo); if (action === "unstage_all") { if (status.oid) return runGit(status.root, ["reset"], null, osUserInfo); return runGit(status.root, ["rm", "--cached", "-r", "."], null, osUserInfo); } if (action === "pull") { if (!status.upstream) return Promise.reject(new Error("The current branch has no upstream")); return runGit(status.root, ["pull", "--ff-only"], 60000, osUserInfo); } if (action === "push") { if (status.upstream) return runGit(status.root, ["push"], 60000, osUserInfo); if (!status.origin || !status.branch) return Promise.reject(new Error("Set an origin and check out a branch before pushing")); return runGit(status.root, ["push", "--set-upstream", "origin", status.branch], 60000, osUserInfo); } return Promise.reject(new Error("Unsupported Git action")); } function bufferIsBinary(buffer) { var limit = Math.min(buffer.length, 8000); for (var i = 0; i < limit; i++) { if (buffer[i] === 0) return true; } return false; } function getHeadFile(cwd, filePath, osUserInfo) { try { return runGitSync(cwd, ["show", "HEAD:" + filePath], { encoding: null, maxBuffer: MAX_DIFF_BYTES }, osUserInfo); } catch (e) { return Buffer.alloc(0); } } function fileBufferResult(buffer) { var binary = bufferIsBinary(buffer); return { content: binary ? "" : buffer.toString("utf8"), binary: binary }; } function readWorkingTreeFile(cwd, filePath) { var root = path.resolve(cwd); var absolutePath = path.resolve(root, filePath); if (absolutePath !== root && absolutePath.indexOf(root + path.sep) !== 0) throw new Error("Invalid file path"); try { var stat = fs.lstatSync(absolutePath); if (stat.isSymbolicLink()) return fileBufferResult(Buffer.from(fs.readlinkSync(absolutePath), "utf8")); if (!stat.isFile()) return { content: "", binary: false }; if (stat.size > MAX_DIFF_BYTES) throw new Error("File is too large to preview"); return fileBufferResult(fs.readFileSync(absolutePath)); } catch (e) { if (e.code === "ENOENT") return { content: "", binary: false }; throw e; } } function getFileAtCommit(cwd, commit, filePath, osUserInfo) { if (!commit) return { content: "", binary: false }; try { var buffer = runGitSync(cwd, ["show", commit + ":" + filePath], { encoding: null, maxBuffer: MAX_DIFF_BYTES }, osUserInfo); return fileBufferResult(buffer); } catch (e) { return { content: "", binary: false }; } } function getFileDiff(cwd, requestedPath, osUserInfo) { var status = getStatus(cwd, osUserInfo); if (!status.isRepository) throw new Error("This project is not a Git repository"); var entry = null; for (var i = 0; i < status.files.length; i++) { if (status.files[i].path === requestedPath) { entry = status.files[i]; break; } } if (!entry) throw new Error("File is no longer changed"); var oldPath = entry.originalPath || entry.path; var oldBuffer = getHeadFile(status.root, oldPath, osUserInfo); var newBuffer = Buffer.alloc(0); var absolutePath = path.resolve(status.root, entry.path); var rootPrefix = path.resolve(status.root) + path.sep; if (absolutePath.indexOf(rootPrefix) !== 0) throw new Error("Invalid changed file path"); try { var stat = fs.lstatSync(absolutePath); if (stat.isSymbolicLink()) { newBuffer = Buffer.from(fs.readlinkSync(absolutePath), "utf8"); } else if (stat.isFile()) { if (stat.size > MAX_DIFF_BYTES) throw new Error("File is too large to preview"); newBuffer = fs.readFileSync(absolutePath); } } catch (e) { if (e.message === "File is too large to preview") throw e; } var binary = bufferIsBinary(oldBuffer) || bufferIsBinary(newBuffer); return { path: entry.path, oldPath: oldPath, oldContent: binary ? "" : oldBuffer.toString("utf8"), newContent: binary ? "" : newBuffer.toString("utf8"), binary: binary, }; } module.exports = { getFileAtCommit: getFileAtCommit, getFileDiff: getFileDiff, getStatus: getStatus, getSummary: getSummary, parsePorcelainV2: parsePorcelainV2, parseWorktrees: parseWorktrees, readWorkingTreeFile: readWorkingTreeFile, runGitSync: runGitSync, runGit: runGit, runAction: runAction, shortRemoteLabel: shortRemoteLabel, };