import * as cp from "node:child_process"; import * as path from "node:path"; import { BRANCH_SNAPSHOT_FORMAT, BranchSnapshotEntry, parseAheadBehind, parseBranchSnapshot, parseOriginHeadTarget, parseRemoteRefNames, REMOTE_REFS_FORMAT, resolveCompareBranch, synthesizeRows } from "./branchCleanup"; import { getConfig } from "./config"; import { applyLineageRules, applyMergeRules, buildAncestryQueryArgs, buildLineageQueryArgs, buildMergeQueryArgs, FileHistoryRecordGroup, findLineageRename, LineageResult, normalizeFileHistoryPath, parseAncestryGraph, parseFileHistoryRecords, resolveFileHistoryEntries, spliceLineageContinuation } from "./fileHistory"; import { DEFAULT_GIT_PATH, resolveGitExecutable } from "./gitExecutable"; import { isSafeRemoteName, isValidRefName } from "./refValidation"; import { BranchCleanupAheadBehind, BranchCleanupResult, CheckoutBranchResult, CommitOrdering, FileHistoryEntry, GitCommandStatus, GitCommit, GitCommitDetails, GitCommitNode, GitFileChange, GitFileChangeType, GitRefData, GitResetMode, GitStash, GitUnsavedChanges, PushPreparation, PushTarget, RemoteBranchTarget, UNCOMMITTED_CHANGES_HASH, VALID_UNCOMMITTED_RESET_MODES, WorktreeCollection } from "./types"; import { evalPromises, getPathFromStr, isValidCommitHash } from "./utils"; import { parseWorktreeList } from "./worktree"; const eolRegex = /\r\n|\r|\n/g; const headRegex = /^\(HEAD detached (at|from) .+\)$/; const REGEX_META_CHARS = /[.*+?^${}()|[\]\\]/g; const gitLogSeparator = "XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb"; const NO_QUOTE_PATH_CONFIG = ["-c", "core.quotePath=false"]; const LOG_FORMAT_FIELD_COUNT = 6; const STASH_FORMAT_FIELD_COUNT = 7; const STASH_SHOW_INCLUDE_UNTRACKED_OPTION = "-u"; const DIFF_FILTER_AMDR_OPTION = "--diff-filter=AMDR"; const DIFF_FILTER_R_OPTION = "--diff-filter=R"; const DIFF_FIND_RENAMES_OPTION = "--find-renames"; const NULL_BYTE_OPTION = "-z"; const NULL_BYTE_SEPARATOR = "\0"; const NAME_STATUS_OPTION = "--name-status"; const NUMSTAT_OPTION = "--numstat"; const NO_WALK_SORTED_OPTION = "--no-walk=sorted"; const TAB_SEPARATOR = "\t"; const VALID_FILE_CHANGE_TYPES: ReadonlySet = new Set(["A", "M", "D", "R"]); const BRANCH_COMPARISON_MAX_PARALLEL = 3; const FILE_HISTORY_ANCESTRY_MIN_ENTRIES = 2; export const COMMIT_ORDER_FLAGS: Readonly> = { date: "--date-order", topo: "--topo-order", "author-date": "--author-date-order" }; /** * Commit details format field indices (used in gitCommitDetailsFormat and commitDetails parser). * Maps to: %H(hash), %P(parents), %an(author), %ae(email), dateType, %cn(committer), %ce(committerEmail) */ const COMMIT_DETAILS_FIELD = { HASH: 0, PARENTS: 1, AUTHOR: 2, EMAIL: 3, DATE: 4, COMMITTER: 5, COMMITTER_EMAIL: 6 } as const; function escapeRegExp(str: string): string { return str.replace(REGEX_META_CHARS, "\\$&"); } function splitNullSeparatedFields(stdout: string): string[] { return stdout.split(NULL_BYTE_SEPARATOR); } function appendAuthorArgs(args: string[], authors: string[]) { for (const author of authors) { args.push(`--author=${escapeRegExp(author)}`); } } function buildMergeArgs( ref: string, createNewCommit: boolean, squash: boolean, noCommit: boolean ): string[] { const args = ["merge", ref]; if (squash) { args.push("--squash"); } else if (createNewCommit) { args.push("--no-ff"); } if (noCommit) { args.push("--no-commit"); } return args; } const VALID_GIT_REFS = new Set([ "HEAD", "MERGE_HEAD", "ORIG_HEAD", "FETCH_HEAD", "CHERRY_PICK_HEAD" ]); function isValidGitRef(ref: string): boolean { return VALID_GIT_REFS.has(ref); } const INVALID_COMMIT_HASH_MESSAGE = "Invalid commit hash."; const INVALID_PARENT_INDEX_MESSAGE = "Invalid parent index."; const INVALID_REF_NAME_MESSAGE = "Invalid ref name."; const INVALID_REMOTE_NAME_MESSAGE = "Invalid remote name."; const GIT_QUERY_FAILED_MESSAGE = "Git command failed."; const REFS_HEADS_PREFIX = "refs/heads/"; const OPTION_ARG_PREFIX = "-"; const PUSH_REFSPEC_SEPARATOR = ":"; /** * Result of a read-only Git query that has to tell "the query ran and produced * this output" apart from "the query itself failed", so that a failure is never * mistaken for an empty result. */ type GitQueryResult = { kind: "ok"; stdout: string } | { kind: "error"; status: string }; type RemoteNamesResult = { kind: "ok"; remotes: string[] } | { kind: "error"; status: string }; function isRefNameSafe(refName: string): boolean { return !refName.startsWith(OPTION_ARG_PREFIX); } const VALID_RESET_MODES = new Set(["soft", "mixed", "hard"]); function parseGitLogCommits(stdout: string): GitCommit[] { const lines = stdout.split(eolRegex); const gitCommits: GitCommit[] = []; for (let i = 0; i < lines.length - 1; i++) { const line = lines[i].split(gitLogSeparator); if (line.length !== LOG_FORMAT_FIELD_COUNT) break; gitCommits.push({ hash: line[0], parentHashes: line[1].split(" ").filter(Boolean), author: line[2], email: line[3], date: parseInt(line[4], 10), message: line[5] }); } return gitCommits; } /** * Collect the HEAD of every detached worktree, deduplicated and restricted to * hashes that pass validation, so that only safe values reach the Git arguments. */ function collectDetachedHeads(worktrees: WorktreeCollection): string[] { const heads = new Set(); for (const worktree of worktrees.detached) { if (isValidCommitHash(worktree.head)) heads.add(worktree.head); } return [...heads]; } /** * A pinned commit normally goes after the loaded window, but when one of its * parents is already loaded it must precede that parent: the graph walks from a * child down to its parents and never terminates if a parent comes first. */ function findPinnedInsertIndex(commits: GitCommit[], pinnedCommit: GitCommit): number { const firstParentIndex = commits.findIndex((commit) => pinnedCommit.parentHashes.includes(commit.hash) ); return firstParentIndex === -1 ? commits.length : firstParentIndex; } /** * Order pinned commits so that a pinned parent is inserted before its pinned * child. Each insertion goes before the commit's earliest loaded parent, so a * child inserted while its parent is still pending would be left behind that * parent once the parent is inserted. History is acyclic, so a commit without * pending parents always exists; the fallback only guards malformed input. */ function orderPinnedParentsFirst(pinnedCommits: GitCommit[]): GitCommit[] { const ordered: GitCommit[] = []; const pending = [...pinnedCommits]; while (pending.length > 0) { const index = pending.findIndex( (commit) => !commit.parentHashes.some((parentHash) => pending.some((other) => other.hash === parentHash) ) ); ordered.push(...pending.splice(index === -1 ? 0 : index, 1)); } return ordered; } export class DataSource { private gitPath: string = DEFAULT_GIT_PATH; private gitLogFormat!: string; private gitStashFormat!: string; private gitCommitDetailsFormat!: string; constructor() { this.generateGitCommandFormats(); } public async registerGitPath(): Promise { this.gitPath = await resolveGitExecutable(getConfig().gitPath()); } public generateGitCommandFormats() { let dateType = getConfig().dateType() === "Author Date" ? "%at" : "%ct"; this.gitLogFormat = ["%H", "%P", "%an", "%ae", dateType, "%s"].join(gitLogSeparator); this.gitStashFormat = ["%H", "%P", "%gD", "%an", "%ae", dateType, "%s"].join(gitLogSeparator); const commitDetailsFields = ["%H", "%P", "%an", "%ae", dateType, "%cn", "%ce"]; this.gitCommitDetailsFormat = `${commitDetailsFields.join(gitLogSeparator)}%n%B`; } public getBranches(repo: string, showRemoteBranches: boolean) { return this.spawnGit<{ branches: string[]; head: string | null; error: boolean }>( ["branch", ...(showRemoteBranches ? ["-a"] : [])], repo, (stdout) => { let branchData: { branches: string[]; head: string | null; error: boolean } = { branches: [], head: null, error: false }; let lines = stdout.split(eolRegex); for (let i = 0; i < lines.length - 1; i++) { let name = lines[i].substring(2).split(" -> ")[0]; if (name.match(headRegex) !== null) continue; if (name.startsWith("remotes/")) continue; if (lines[i][0] === "*") { branchData.head = name; branchData.branches.unshift(name); } else { branchData.branches.push(name); } } return branchData; }, { branches: [], head: null, error: true } ); } public async getCommits( repo: string, branches: string[], maxCommits: number, showRemoteBranches: boolean, authors: string[], commitOrdering: CommitOrdering ) { const worktrees = await this.getWorktrees(repo); const [commits, refData, stashes, authorList, pinnedCommits] = await Promise.all([ this.getGitLog(repo, branches, maxCommits + 1, showRemoteBranches, authors, commitOrdering), this.getRefs(repo, showRemoteBranches), this.getStashes(repo), this.getAuthors(repo), this.getPinnedDetachedCommits(repo, collectDetachedHeads(worktrees), authors) ]); const moreCommitsAvailable = commits.length === maxCommits + 1; if (moreCommitsAvailable) commits.pop(); const knownHashes = new Set(commits.map((commit) => commit.hash)); const pendingPinnedCommits = pinnedCommits.filter((pinnedCommit) => { if (knownHashes.has(pinnedCommit.hash)) return false; knownHashes.add(pinnedCommit.hash); return true; }); for (const pinnedCommit of orderPinnedParentsFirst(pendingPinnedCommits)) { commits.splice(findPinnedInsertIndex(commits, pinnedCommit), 0, pinnedCommit); } if (refData.head !== null) { for (let i = 0; i < commits.length; i++) { if (refData.head === commits[i].hash) { const unsavedChanges = getConfig().showUncommittedChanges() ? await this.getGitUnsavedChanges(repo) : null; if (unsavedChanges !== null) { commits.unshift({ hash: UNCOMMITTED_CHANGES_HASH, parentHashes: [refData.head], author: UNCOMMITTED_CHANGES_HASH, email: "", date: Math.round(new Date().getTime() / 1000), message: `Uncommitted Changes (${unsavedChanges.changes})` }); } break; } } } const commitNodes: GitCommitNode[] = []; const commitLookup: { [hash: string]: number } = {}; for (let i = 0; i < commits.length; i++) { commitLookup[commits[i].hash] = i; commitNodes.push({ hash: commits[i].hash, parentHashes: commits[i].parentHashes, author: commits[i].author, email: commits[i].email, date: commits[i].date, message: commits[i].message, refs: [], stash: null }); } const toInsert: { index: number; data: GitStash }[] = []; for (let i = 0; i < stashes.length; i++) { if (typeof commitLookup[stashes[i].hash] === "number") { commitNodes[commitLookup[stashes[i].hash]].stash = { selector: stashes[i].selector, baseHash: stashes[i].baseHash, untrackedFilesHash: stashes[i].untrackedFilesHash }; } else if (typeof commitLookup[stashes[i].baseHash] === "number") { toInsert.push({ index: commitLookup[stashes[i].baseHash], data: stashes[i] }); } } toInsert.sort((a, b) => (a.index !== b.index ? a.index - b.index : b.data.date - a.data.date)); for (let i = toInsert.length - 1; i >= 0; i--) { const stash = toInsert[i].data; commitNodes.splice(toInsert[i].index, 0, { hash: stash.hash, parentHashes: [stash.baseHash], author: stash.author, email: stash.email, date: stash.date, message: stash.message, refs: [], stash: { selector: stash.selector, baseHash: stash.baseHash, untrackedFilesHash: stash.untrackedFilesHash } }); } for (let i = 0; i < commitNodes.length; i++) { commitLookup[commitNodes[i].hash] = i; } for (let i = 0; i < refData.refs.length; i++) { if (typeof commitLookup[refData.refs[i].hash] === "number") { commitNodes[commitLookup[refData.refs[i].hash]].refs.push(refData.refs[i]); } } return { commits: commitNodes, head: refData.head, moreCommitsAvailable: moreCommitsAvailable, authors: authorList, worktrees }; } public async commitDetails( repo: string, commitHash: string, hasParents: boolean, isStash: boolean ): Promise { if (!isValidCommitHash(commitHash)) { return null; } try { const nameStatusArgs = this.buildDiffArgs( isStash, hasParents, NAME_STATUS_OPTION, commitHash ); const numStatArgs = this.buildDiffArgs(isStash, hasParents, NUMSTAT_OPTION, commitHash); const isRootCommit = !hasParents && !isStash; const splitDiffOutput = (stdout: string): string[] => { const fields = splitNullSeparatedFields(stdout); if (isRootCommit) fields.shift(); return fields; }; const [details, nameStatusFields, numStatFields] = await Promise.all([ this.spawnGit( ["show", "--quiet", commitHash, `--format=${this.gitCommitDetailsFormat}`], repo, (stdout) => { let lines = stdout.split(eolRegex); let lastLine = lines.length - 1; while (lines.length > 0 && lines[lastLine] === "") lastLine--; let commitInfo = lines[0].split(gitLogSeparator); return { hash: commitInfo[COMMIT_DETAILS_FIELD.HASH], parents: commitInfo[COMMIT_DETAILS_FIELD.PARENTS].split(" ").filter(Boolean), author: commitInfo[COMMIT_DETAILS_FIELD.AUTHOR], email: commitInfo[COMMIT_DETAILS_FIELD.EMAIL], date: parseInt(commitInfo[COMMIT_DETAILS_FIELD.DATE], 10), committer: commitInfo[COMMIT_DETAILS_FIELD.COMMITTER], committerEmail: commitInfo[COMMIT_DETAILS_FIELD.COMMITTER_EMAIL] ?? "", body: lines.slice(1, lastLine + 1).join("\n"), fileChanges: [] }; }, null ), this.spawnGit(nameStatusArgs, repo, splitDiffOutput, []), this.spawnGit(numStatArgs, repo, splitDiffOutput, []) ]); if (details === null) return null; const fileLookup = this.parseNameStatusRecords(nameStatusFields, details.fileChanges); this.applyNumStatRecords(numStatFields, fileLookup, details.fileChanges); return details; } catch { return null; } } public async getUncommittedDetails(repo: string): Promise { try { const [nameStatus, numStat, untrackedFiles] = await Promise.all([ this.spawnGit( this.buildDiffStatArgs(NAME_STATUS_OPTION, "HEAD"), repo, splitNullSeparatedFields, [] ), this.spawnGit( this.buildDiffStatArgs(NUMSTAT_OPTION, "HEAD"), repo, splitNullSeparatedFields, [] ), this.listUntrackedFiles(repo) ]); const details: GitCommitDetails = { hash: UNCOMMITTED_CHANGES_HASH, parents: [], author: "", email: "", date: 0, committer: "", committerEmail: "", body: "", fileChanges: [] }; const fileLookup = this.parseNameStatusRecords(nameStatus, details.fileChanges); this.applyNumStatRecords(numStat, fileLookup, details.fileChanges); this.appendUntrackedFileChanges(untrackedFiles, fileLookup, details.fileChanges); return details; } catch { return null; } } public async getCommitComparison( repo: string, fromHash: string, toHash: string ): Promise { const isFromUncommitted = fromHash === UNCOMMITTED_CHANGES_HASH; const isToWorkingTree = isFromUncommitted || toHash === ""; const diffBaseHash = isFromUncommitted ? toHash : fromHash; if (diffBaseHash !== "" && !isValidCommitHash(diffBaseHash)) { return null; } if (!isToWorkingTree && !isValidCommitHash(toHash)) { return null; } try { const diffRefs = isToWorkingTree ? [diffBaseHash] : [diffBaseHash, toHash]; const gitCommands: Promise[] = [ this.spawnGit( this.buildDiffStatArgs(NAME_STATUS_OPTION, ...diffRefs), repo, splitNullSeparatedFields, [] ), this.spawnGit( this.buildDiffStatArgs(NUMSTAT_OPTION, ...diffRefs), repo, splitNullSeparatedFields, [] ) ]; if (isToWorkingTree) { gitCommands.push(this.listUntrackedFiles(repo)); } const [nameStatus, numStat, untrackedFiles = []] = await Promise.all(gitCommands); const fileChanges: GitFileChange[] = []; const fileLookup = this.parseNameStatusRecords(nameStatus, fileChanges); this.applyNumStatRecords(numStat, fileLookup, fileChanges); this.appendUntrackedFileChanges(untrackedFiles, fileLookup, fileChanges); return fileChanges; } catch { return null; } } public getCommitFile(repo: string, commitHash: string, filePath: string) { const baseHash = commitHash.replace(/\^$/, ""); if (!isValidCommitHash(baseHash) && !isValidGitRef(baseHash)) { return Promise.resolve(""); } if (filePath.split("/").includes("..")) { return Promise.resolve(""); } return this.spawnGit(["show", `${commitHash}:${filePath}`], repo, (stdout) => stdout, ""); } public resolveRefToHash(repo: string, ref: string): Promise { if (!isValidGitRef(ref)) { return Promise.resolve(null); } return this.spawnGit( ["rev-parse", ref], repo, (stdout) => stdout.split(eolRegex)[0] || null, null ); } public getNewPathOfRenamedFile( repo: string, commitHash: string, oldFilePath: string ): Promise { if (!isValidCommitHash(commitHash)) { return Promise.resolve(null); } if (oldFilePath.split("/").includes("..")) { return Promise.resolve(null); } return this.spawnGit( [ "diff", NAME_STATUS_OPTION, DIFF_FILTER_R_OPTION, DIFF_FIND_RENAMES_OPTION, NULL_BYTE_OPTION, commitHash, "HEAD" ], repo, (stdout) => { // Format: status\0oldPath\0newPath\0... const fields = stdout.split(NULL_BYTE_SEPARATOR); let cursor = 0; while (cursor < fields.length && fields[cursor] !== "") { const statusChar = (fields[cursor] ?? "")[0] ?? ""; if (!VALID_FILE_CHANGE_TYPES.has(statusChar)) break; if (statusChar === "R") { const oldPath = getPathFromStr(fields[cursor + 1] ?? ""); if (oldPath === oldFilePath) { return getPathFromStr(fields[cursor + 2] ?? ""); } cursor += 3; } else { cursor += 2; } } return null; }, null ); } public getRemoteUrl(repo: string) { return this.spawnGit( ["config", "--get", "remote.origin.url"], repo, (stdout) => stdout.split(eolRegex)[0], null ); } public isGitRepository(path: string) { return this.spawnGit(["rev-parse", "--git-dir"], path, () => true, false); } public async getRepositoryStateWatchPaths(repo: string): Promise { const [gitDir, gitCommonDir] = await Promise.all([ this.spawnGit( ["rev-parse", "--git-dir"], repo, (stdout) => stdout.trim() || null, null ), this.spawnGit( ["rev-parse", "--git-common-dir"], repo, (stdout) => stdout.trim() || null, null ) ]); const watchPaths = new Set(); for (const watchPath of [gitDir, gitCommonDir]) { if (watchPath === null || watchPath === "") continue; watchPaths.add( path.normalize(path.isAbsolute(watchPath) ? watchPath : path.resolve(repo, watchPath)) ); } return Array.from(watchPaths); } public addTag( repo: string, tagName: string, commitHash: string, lightweight: boolean, message: string ) { if (!isRefNameSafe(tagName)) { return Promise.resolve(INVALID_REF_NAME_MESSAGE); } let args = ["tag"]; if (lightweight) { args.push(tagName); } else { args.push("-a", tagName, "-m", message); } args.push(commitHash); return this.runGitCommandSpawn(args, repo); } public deleteTag(repo: string, tagName: string) { return this.runGitCommandSpawn(["tag", "-d", tagName], repo); } public pushTag(repo: string, tagName: string) { return this.runGitCommandSpawn(["push", "origin", tagName], repo); } public createBranch(repo: string, branchName: string, commitHash: string) { if (!isRefNameSafe(branchName)) { return Promise.resolve(INVALID_REF_NAME_MESSAGE); } if (!isValidCommitHash(commitHash)) { return Promise.resolve(INVALID_COMMIT_HASH_MESSAGE); } return this.runGitCommandSpawn(["branch", branchName, commitHash], repo); } public async checkoutBranch( repo: string, branchName: string, remoteBranch: RemoteBranchTarget | null ): Promise { if (!isValidRefName(branchName)) { return { kind: "invalidRef" }; } if (remoteBranch === null) { return { kind: "completed", status: await this.runGitCommandSpawn(["checkout", branchName], repo) }; } if ( typeof remoteBranch !== "object" || typeof remoteBranch.remoteName !== "string" || typeof remoteBranch.branchName !== "string" ) { return { kind: "invalidRef" }; } const remoteTrackingRef = `${remoteBranch.remoteName}/${remoteBranch.branchName}`; if ( !isSafeRemoteName(remoteBranch.remoteName) || !isValidRefName(remoteBranch.branchName) || !isValidRefName(remoteTrackingRef) ) { return { kind: "invalidRef" }; } const existingBranch = await this.runGitQuery(["branch", "--list", branchName], repo); if (existingBranch.kind === "error") { return { kind: "completed", status: existingBranch.status }; } if (existingBranch.stdout.trim() === "") { return { kind: "completed", status: await this.runGitCommandSpawn( ["checkout", "--track", "-b", branchName, remoteTrackingRef], repo ) }; } if (branchName !== remoteBranch.branchName) { return { kind: "branchExists" }; } const remotes = await this.queryRemoteNames(repo); if (remotes.kind === "error") { return { kind: "completed", status: remotes.status }; } if (!remotes.remotes.includes(remoteBranch.remoteName)) { return { kind: "remoteNotFound" }; } const checkoutStatus = await this.runGitCommandSpawn(["checkout", branchName], repo); if (checkoutStatus !== null) { return { kind: "completed", status: checkoutStatus }; } const pullStatus = await this.runGitCommandSpawn( ["pull", remoteBranch.remoteName, remoteBranch.branchName], repo ); return pullStatus === null ? { kind: "completed", status: null } : { kind: "pullFailed", status: pullStatus }; } public checkoutCommit(repo: string, commitHash: string) { if (!isValidCommitHash(commitHash)) { return Promise.resolve(INVALID_COMMIT_HASH_MESSAGE); } return this.runGitCommandSpawn(["checkout", commitHash], repo); } public deleteBranch(repo: string, branchName: string, forceDelete: boolean) { return this.runGitCommandSpawn( ["branch", "--delete", ...(forceDelete ? ["--force"] : []), branchName], repo ); } public async renameBranch( repo: string, oldName: string, newName: string, updateUpstream: boolean ) { if (!isRefNameSafe(newName)) { return INVALID_REF_NAME_MESSAGE; } const status = await this.runGitCommandSpawn(["branch", "-m", oldName, newName], repo); if (status !== null || !updateUpstream) { return status; } // If no remote is configured for this branch (e.g. a newly created branch that was never // pushed), set it to "origin" so that subsequent pushes work without --set-upstream. const remoteConfigured = await this.runGitCommandSpawn( ["config", "--get", `branch.${newName}.remote`], repo ); if (remoteConfigured !== null) { const setRemote = await this.runGitCommandSpawn( ["config", `branch.${newName}.remote`, "origin"], repo ); if (setRemote !== null) return setRemote; } return this.runGitCommandSpawn( ["config", `branch.${newName}.merge`, `refs/heads/${newName}`], repo ); } public mergeBranch( repo: string, branchName: string, createNewCommit: boolean, squash: boolean, noCommit: boolean ) { return this.runGitCommandSpawn( buildMergeArgs(branchName, createNewCommit, squash, noCommit), repo ); } public deleteRemoteBranch(repo: string, remoteName: string, branchName: string) { return this.runGitCommandSpawn(["push", remoteName, "--delete", branchName], repo); } public rebaseBranch(repo: string, branchName: string) { return this.runGitCommandSpawn(["rebase", branchName], repo); } public mergeCommit( repo: string, commitHash: string, createNewCommit: boolean, squash: boolean, noCommit: boolean ) { if (!isValidCommitHash(commitHash)) { return Promise.resolve(INVALID_COMMIT_HASH_MESSAGE); } return this.runGitCommandSpawn( buildMergeArgs(commitHash, createNewCommit, squash, noCommit), repo ); } public cherrypickCommit( repo: string, commitHash: string, parentIndex: number, recordOrigin: boolean, noCommit: boolean ) { if (!isValidCommitHash(commitHash)) { return Promise.resolve(INVALID_COMMIT_HASH_MESSAGE); } if (!Number.isInteger(parentIndex) || parentIndex < 0) { return Promise.resolve(INVALID_PARENT_INDEX_MESSAGE); } const args = ["cherry-pick", commitHash]; if (parentIndex > 0) { args.push("-m", String(parentIndex)); } if (recordOrigin) { args.push("-x"); } if (noCommit) { args.push("--no-commit"); } return this.runGitCommandSpawn(args, repo); } public revertCommit(repo: string, commitHash: string, parentIndex: number) { if (!isValidCommitHash(commitHash)) { return Promise.resolve(INVALID_COMMIT_HASH_MESSAGE); } if (!Number.isInteger(parentIndex) || parentIndex < 0) { return Promise.resolve(INVALID_PARENT_INDEX_MESSAGE); } return this.runGitCommandSpawn( ["revert", "--no-edit", commitHash, ...(parentIndex > 0 ? ["-m", String(parentIndex)] : [])], repo ); } public resetToCommit(repo: string, commitHash: string, resetMode: GitResetMode) { if (!isValidCommitHash(commitHash)) { return Promise.resolve(INVALID_COMMIT_HASH_MESSAGE); } if (!VALID_RESET_MODES.has(resetMode)) { return Promise.resolve("Invalid reset mode."); } return this.runGitCommandSpawn(["reset", `--${resetMode}`, commitHash], repo); } public applyStash(repo: string, selector: string, reinstateIndex: boolean) { const args = ["stash", "apply"]; if (reinstateIndex) args.push("--index"); args.push(selector); return this.runGitCommandSpawn(args, repo); } public popStash(repo: string, selector: string, reinstateIndex: boolean) { const args = ["stash", "pop"]; if (reinstateIndex) args.push("--index"); args.push(selector); return this.runGitCommandSpawn(args, repo); } public dropStash(repo: string, selector: string) { return this.runGitCommandSpawn(["stash", "drop", selector], repo); } public branchFromStash(repo: string, branchName: string, selector: string) { return this.runGitCommandSpawn(["stash", "branch", branchName, selector], repo); } public pushStash(repo: string, message: string, includeUntracked: boolean) { const args = ["stash", "push"]; if (message !== "") args.push("--message", message); if (includeUntracked) args.push("--include-untracked"); return this.runGitCommandSpawn(args, repo); } public resetUncommitted(repo: string, mode: string) { if (!VALID_UNCOMMITTED_RESET_MODES.has(mode)) { return Promise.resolve("Invalid reset mode."); } return this.runGitCommandSpawn(["reset", `--${mode}`, "HEAD"], repo); } public cleanUntrackedFiles(repo: string, directories: boolean) { const args = ["clean", "-f"]; if (directories) args.push("-d"); return this.runGitCommandSpawn(args, repo); } public pull(repo: string) { return this.runGitCommandSpawn(["pull"], repo); } public async preparePush(repo: string): Promise { const currentBranch = await this.runGitQuery(["branch", "--show-current"], repo); if (currentBranch.kind === "error") { return { kind: "error", status: currentBranch.status }; } // An empty current branch means a detached HEAD, which has no upstream to read. const branchName = currentBranch.stdout.split(eolRegex)[0] ?? ""; if (branchName !== "") { const target = await this.readUpstreamTarget(repo, branchName); if (target !== null) { return { kind: "upstream", target }; } } const remotes = await this.queryRemoteNames(repo); if (remotes.kind === "error") { return { kind: "error", status: remotes.status }; } return { kind: "selectRemote", remotes: remotes.remotes }; } public async getRemotes(repo: string): Promise { const remotes = await this.queryRemoteNames(repo); return remotes.kind === "ok" ? remotes.remotes : null; } public pushToUpstream(repo: string, target: PushTarget): Promise { if (!isSafeRemoteName(target.remoteName)) { return Promise.resolve(INVALID_REMOTE_NAME_MESSAGE); } if (!isValidRefName(target.localBranchName) || !isValidRefName(target.upstreamBranchName)) { return Promise.resolve(INVALID_REF_NAME_MESSAGE); } const refspec = [target.localBranchName, target.upstreamBranchName].join( PUSH_REFSPEC_SEPARATOR ); return this.runGitCommandSpawn(["push", target.remoteName, refspec], repo); } public pushWithUpstream(repo: string, remoteName: string): Promise { if (!isSafeRemoteName(remoteName)) { return Promise.resolve(INVALID_REMOTE_NAME_MESSAGE); } return this.runGitCommandSpawn(["push", "--set-upstream", remoteName, "HEAD"], repo); } private async readUpstreamTarget(repo: string, branchName: string): Promise { const [remote, merge] = await Promise.all([ this.runGitQuery(["config", "--get", `branch.${branchName}.remote`], repo), this.runGitQuery(["config", "--get", `branch.${branchName}.merge`], repo) ]); if (remote.kind === "error" || merge.kind === "error") { return null; } const remoteName = remote.stdout.split(eolRegex)[0] ?? ""; const mergeRef = merge.stdout.split(eolRegex)[0] ?? ""; if (!mergeRef.startsWith(REFS_HEADS_PREFIX)) { return null; } const upstreamBranch = mergeRef.substring(REFS_HEADS_PREFIX.length); if (!isSafeRemoteName(remoteName) || !isValidRefName(upstreamBranch)) { return null; } return { remoteName, localBranchName: branchName, upstreamBranchName: upstreamBranch }; } private async queryRemoteNames(repo: string): Promise { const result = await this.runGitQuery(["remote"], repo); if (result.kind === "error") { return { kind: "error", status: result.status }; } const remotes = result.stdout .split(eolRegex) .map((line) => line.trim()) .filter((line) => line !== "" && isSafeRemoteName(line)) .sort(); return { kind: "ok", remotes }; } public fetch(repo: string) { return this.runGitCommandSpawn(["fetch", "--all", "--prune"], repo); } public getWorktrees(repo: string) { return this.spawnGit( ["worktree", "list", "--porcelain"], repo, (stdout) => parseWorktreeList(stdout), { branches: {}, detached: [] } ); } public addWorktree(repo: string, path: string, branchName: string, commitHash?: string) { if (commitHash !== undefined) { return this.runGitCommandSpawn(["worktree", "add", "-b", branchName, path, commitHash], repo); } return this.runGitCommandSpawn(["worktree", "add", path, branchName], repo); } public removeWorktree(repo: string, worktreePath: string) { return this.runGitCommandSpawn(["worktree", "remove", worktreePath], repo); } /** * Collect the branch cleanup facts: a single refs/heads snapshot decides the * row identities and the comparison target, while worktrees, remote names * and remote refs are fetched independently so that each failed collection * only degrades its own fact (unknown / null) instead of the whole result. * Only the snapshot's failure is a whole-result error; an empty snapshot is * a successful empty state. */ public async getBranchCleanup( repo: string, requestedCompareBranch: string | null ): Promise { const snapshotResult = await this.runGitQuery( ["for-each-ref", `--format=${BRANCH_SNAPSHOT_FORMAT}`, "--sort=refname", "refs/heads"], repo ); if (snapshotResult.kind === "error") { return { kind: "error", status: snapshotResult.status }; } const entries = parseBranchSnapshot(snapshotResult.stdout); const [worktreeResult, remoteNamesResult, remoteRefsResult] = await Promise.all([ this.runGitQuery(["worktree", "list", "--porcelain"], repo), this.queryRemoteNames(repo), this.runGitQuery(["for-each-ref", `--format=${REMOTE_REFS_FORMAT}`, "refs/remotes"], repo) ]); const worktrees = worktreeResult.kind === "ok" ? parseWorktreeList(worktreeResult.stdout) : null; const remoteNames = remoteNamesResult.kind === "ok" ? remoteNamesResult.remotes : null; const remoteRefs = remoteRefsResult.kind === "ok" ? parseRemoteRefNames(remoteRefsResult.stdout) : null; const originHeadTarget = remoteRefsResult.kind === "ok" ? parseOriginHeadTarget(remoteRefsResult.stdout) : null; const compare = resolveCompareBranch(entries, originHeadTarget, requestedCompareBranch); const comparisons = compare !== null && compare.commitOid !== null ? await this.collectBranchComparisons(repo, compare.commitOid, entries) : new Map(); return { kind: "ok", compareBranch: compare === null ? null : compare.branchName, rows: synthesizeRows(entries, compare, comparisons, worktrees, remoteNames, remoteRefs) }; } /** * Resolve the history of one file from an anchor commit with the lineage * query (plus one continuation query per rename), then the merge and * ancestry queries. The merge query runs even when the lineage has no * entries, and the ancestry query only when a birth-merge candidate exists * among at least two entries. Any Git failure, malformed output or rule * violation yields null rather than a partial result. */ public async getFileHistory( repo: string, anchorHash: string, filePath: string ): Promise { const requestedPath = normalizeFileHistoryPath(filePath); if (!isValidCommitHash(anchorHash) || requestedPath === null) return null; const lineage = await this.queryFileHistoryLineage(repo, anchorHash, requestedPath); if (lineage === null) return null; const boundaryHash = lineage.boundary === null ? null : lineage.boundary.hash; const mergeGroups = await this.queryFileHistoryRecords( buildMergeQueryArgs(anchorHash, boundaryHash, lineage.historicalPaths), repo ); if (mergeGroups === null) return null; const lineageHashes = new Set(lineage.entries.map((e) => e.hash)); const merge = applyMergeRules(mergeGroups, lineage.historicalPaths, lineageHashes); if (merge === null) return null; const needsAncestry = merge.birthCandidateHashes.length > 0 && lineage.entries.length + merge.entries.length >= FILE_HISTORY_ANCESTRY_MIN_ENTRIES; const ancestry = needsAncestry ? await this.queryFileHistoryAncestry(repo, anchorHash, lineage) : null; if (needsAncestry && ancestry === null) return null; return resolveFileHistoryEntries({ requestedPath, lineage, merge, ancestry }); } /** * Run the lineage query, then for each rename it follows a lineage query on * the old path whose groups after the rename replace the earlier ones. * Since Git 2.56, `--follow` keeps one path per commit and a commit reached * from two history lines takes the path of the line walked last, so the * history before a rename can be dropped; the continuation query recovers * it on every Git version. Each rename lies further along the shared walk, * so the loop ends after at most two queries per rename. */ private async queryFileHistoryLineage( repo: string, anchorHash: string, requestedPath: string ): Promise { let groups = await this.queryFileHistoryRecords( buildLineageQueryArgs(anchorHash, requestedPath), repo ); let followedRenames = 0; while (groups !== null) { const lineage = applyLineageRules(groups, anchorHash, requestedPath); if (lineage === null) return null; const rename = findLineageRename(lineage, followedRenames); if (rename === null) return lineage; groups = await this.continueFileHistoryLineage(repo, anchorHash, groups, rename); followedRenames++; } return null; } /** * Query the lineage of the rename's old path from the anchor and splice it * after the rename commit. When a later commit renamed another file onto * the old path, `--follow` from the anchor tracks that file and never * reaches the rename, so the query is repeated from the rename commit, * whose ancestors only hold the renamed file at the old path. */ private async continueFileHistoryLineage( repo: string, anchorHash: string, groups: readonly FileHistoryRecordGroup[], rename: FileHistoryEntry ): Promise { for (const startHash of [anchorHash, rename.hash]) { const continuation = await this.queryFileHistoryRecords( buildLineageQueryArgs(startHash, rename.oldFilePath), repo ); if (continuation === null) return null; const spliced = spliceLineageContinuation(groups, rename, continuation); if (spliced !== null) return spliced; } return null; } private async queryFileHistoryRecords( args: string[], repo: string ): Promise { const result = await this.runGitQuery(args, repo); return result.kind === "error" ? null : parseFileHistoryRecords(result.stdout); } private async queryFileHistoryAncestry( repo: string, anchorHash: string, lineage: LineageResult ): Promise | null> { const boundaryParents = lineage.boundary === null ? [] : lineage.boundary.parentHashes; const result = await this.runGitQuery( buildAncestryQueryArgs(anchorHash, boundaryParents), repo ); return result.kind === "error" ? null : parseAncestryGraph(result.stdout); } /** * Run one rev-list comparison per snapshot entry that has a validated commit * OID, at most BRANCH_COMPARISON_MAX_PARALLEL at a time and in snapshot * order. Only Git-derived OIDs are put into the revspec, and a failed * rev-list degrades just that branch's entry to unknown. */ private async collectBranchComparisons( repo: string, compareOid: string, entries: readonly BranchSnapshotEntry[] ): Promise> { const comparable: { branchName: string; commitOid: string }[] = []; for (const entry of entries) { if (entry.commitOid !== null) { comparable.push({ branchName: entry.branchName, commitOid: entry.commitOid }); } } const results = await evalPromises(comparable, BRANCH_COMPARISON_MAX_PARALLEL, (branch) => this.runGitQuery( ["rev-list", "--left-right", "--count", `${compareOid}...${branch.commitOid}`], repo ) ); const comparisons = new Map(); for (let i = 0; i < comparable.length; i++) { const result = results[i]; comparisons.set( comparable[i].branchName, result.kind === "ok" ? parseAheadBehind(result.stdout) : { kind: "unknown" } ); } return comparisons; } private getRefs(repo: string, showRemoteBranches: boolean) { let args = ["show-ref"]; if (!showRemoteBranches) { args.push("--heads", "--tags"); } args.push("-d", "--head"); return this.spawnGit( args, repo, (stdout) => { let refData: GitRefData = { head: null, refs: [] }; let lines = stdout.split(eolRegex); for (let i = 0; i < lines.length - 1; i++) { let line = lines[i].split(" "); if (line.length < 2) continue; let hash = line.shift()!; let ref = line.join(" "); if (ref.startsWith("refs/heads/")) { refData.refs.push({ hash: hash, name: ref.substring(11), type: "head" }); } else if (ref.startsWith("refs/tags/")) { refData.refs.push({ hash: hash, name: ref.endsWith("^{}") ? ref.substring(10, ref.length - 3) : ref.substring(10), type: "tag" }); } else if (ref.startsWith("refs/remotes/")) { const name = ref.substring(13); if (name.endsWith("/HEAD")) continue; refData.refs.push({ hash: hash, name: name, type: "remote" }); } else if (ref === "HEAD") { refData.head = hash; } } return refData; }, { head: null, refs: [] } ); } private getGitLog( repo: string, branches: string[], num: number, showRemoteBranches: boolean, authors: string[], commitOrdering: CommitOrdering ) { const args = [ "log", `--max-count=${num}`, `--format=${this.gitLogFormat}`, COMMIT_ORDER_FLAGS[commitOrdering] ]; appendAuthorArgs(args, authors); if (branches.length > 0) { args.push(...branches); } else { args.push("--branches", "--tags"); if (showRemoteBranches) args.push("--remotes"); } return this.spawnGit(args, repo, parseGitLogCommits, []); } /** * Fetch the given detached worktree HEADs themselves, without walking their * history, so that a HEAD outside the normal commit window can still be shown. * The author filter is shared with the normal log, the branch filter is not. */ private getPinnedDetachedCommits(repo: string, heads: string[], authors: string[]) { if (heads.length === 0) return Promise.resolve([]); const args = ["log", NO_WALK_SORTED_OPTION, `--format=${this.gitLogFormat}`]; appendAuthorArgs(args, authors); args.push(...heads); return this.spawnGit(args, repo, parseGitLogCommits, []); } private getGitUnsavedChanges(repo: string) { return this.spawnGit( ["status", "-s", "--branch", "--untracked-files", "--porcelain"], repo, (stdout) => { let lines = stdout.split(eolRegex); return lines.length > 2 ? { branch: lines[0].substring(3).split("...")[0], changes: lines.length - 2 } : null; }, null ); } private getAuthors(repo: string) { return this.spawnGit( ["shortlog", "-s", "HEAD"], repo, (stdout) => { const lines = stdout.split(eolRegex); const authors: string[] = []; for (let i = 0; i < lines.length; i++) { const tabIndex = lines[i].indexOf("\t"); if (tabIndex === -1) continue; const name = lines[i].substring(tabIndex + 1); if (name !== "") { authors.push(name); } } return authors.sort(); }, [] ); } private getStashes(repo: string) { return this.spawnGit( ["reflog", `--format=${this.gitStashFormat}`, "refs/stash", "--"], repo, (stdout) => { const lines = stdout.split(eolRegex); const stashes: GitStash[] = []; for (let i = 0; i < lines.length - 1; i++) { const line = lines[i].split(gitLogSeparator); if (line.length !== STASH_FORMAT_FIELD_COUNT || line[1] === "") continue; const parentHashes = line[1].split(" "); stashes.push({ hash: line[0], baseHash: parentHashes[0], untrackedFilesHash: parentHashes.length === 3 ? parentHashes[2] : null, selector: line[2], author: line[3], email: line[4], date: parseInt(line[5], 10), message: line[6] }); } return stashes; }, [] ); } private buildDiffStatArgs(statOption: string, ...refs: string[]): string[] { return [ ...NO_QUOTE_PATH_CONFIG, "diff", statOption, DIFF_FIND_RENAMES_OPTION, DIFF_FILTER_AMDR_OPTION, NULL_BYTE_OPTION, ...refs ]; } private listUntrackedFiles(repo: string) { return this.spawnGit( [...NO_QUOTE_PATH_CONFIG, "ls-files", "--others", "--exclude-standard", NULL_BYTE_OPTION], repo, splitNullSeparatedFields, [] ); } /** * Parses NUL-separated name-status fields ("status\0path" pairs, or * "R\0old\0new" triples) into fileChanges, returning a new-path lookup used * to attach numstat counts and to skip untracked files already in the diff. */ private parseNameStatusRecords( nameStatusFields: string[], fileChanges: GitFileChange[] ): { [file: string]: number } { const fileLookup: { [file: string]: number } = {}; let ns = 0; while (ns < nameStatusFields.length && nameStatusFields[ns] !== "") { const statusChar = (nameStatusFields[ns] ?? "")[0] ?? ""; if (!VALID_FILE_CHANGE_TYPES.has(statusChar)) break; if (statusChar === "R") { const oldFilePath = getPathFromStr(nameStatusFields[ns + 1] ?? ""); const newFilePath = getPathFromStr(nameStatusFields[ns + 2] ?? ""); fileLookup[newFilePath] = fileChanges.length; fileChanges.push({ oldFilePath, newFilePath, type: statusChar as GitFileChangeType, additions: null, deletions: null }); ns += 3; } else { const filePath = getPathFromStr(nameStatusFields[ns + 1] ?? ""); fileLookup[filePath] = fileChanges.length; fileChanges.push({ oldFilePath: filePath, newFilePath: filePath, type: statusChar as GitFileChangeType, additions: null, deletions: null }); ns += 2; } } return fileLookup; } private appendUntrackedFileChanges( untrackedFiles: string[], fileLookup: { [file: string]: number }, fileChanges: GitFileChange[] ) { for (const filePath of untrackedFiles) { if (filePath === "" || typeof fileLookup[filePath] === "number") continue; fileChanges.push({ oldFilePath: filePath, newFilePath: filePath, type: "A", additions: null, deletions: null }); } } private buildDiffArgs( isStash: boolean, hasParents: boolean, statOption: string, commitHash: string ): string[] { if (isStash) { return [ "stash", "show", STASH_SHOW_INCLUDE_UNTRACKED_OPTION, statOption, DIFF_FIND_RENAMES_OPTION, DIFF_FILTER_AMDR_OPTION, NULL_BYTE_OPTION, commitHash ]; } if (hasParents) { return [ "diff", statOption, DIFF_FIND_RENAMES_OPTION, DIFF_FILTER_AMDR_OPTION, NULL_BYTE_OPTION, `${commitHash}^`, commitHash ]; } return [ "diff-tree", statOption, "-r", "--root", DIFF_FIND_RENAMES_OPTION, DIFF_FILTER_AMDR_OPTION, NULL_BYTE_OPTION, commitHash ]; } /** * Applies additions/deletions from NUL-separated numstat fields onto fileChanges. * Record forms: "add\tdel\tpath" (cursor +1), or rename "add\tdel\t" with the old/new * paths in the following two NUL fields (cursor +3). Only the first two TABs delimit * the numeric fields, so paths containing TABs are preserved in full. A malformed * record (fewer than two TABs) is skipped on its own; subsequent records are parsed. */ private applyNumStatRecords( numStatFields: string[], fileLookup: { [file: string]: number }, fileChanges: GitFileChange[] ) { let ni = 0; while (ni < numStatFields.length && numStatFields[ni] !== "") { const record = numStatFields[ni]; const firstTab = record.indexOf(TAB_SEPARATOR); const secondTab = firstTab === -1 ? -1 : record.indexOf(TAB_SEPARATOR, firstTab + 1); if (secondTab === -1) { ni += 1; continue; } const additions = parseInt(record.substring(0, firstTab), 10); const deletions = parseInt(record.substring(firstTab + 1, secondTab), 10); const rawPath = record.substring(secondTab + 1); let filePath: string; if (rawPath !== "") { filePath = getPathFromStr(rawPath); ni += 1; } else { filePath = getPathFromStr(numStatFields[ni + 2] ?? ""); ni += 3; } const idx = fileLookup[filePath]; if (typeof idx === "number" && fileChanges[idx] !== undefined) { fileChanges[idx].additions = Number.isNaN(additions) ? null : additions; fileChanges[idx].deletions = Number.isNaN(deletions) ? null : deletions; } } } private async runGitCommandSpawn(args: string[], repo: string): Promise { const result = await this.spawnGitProcess(args, repo); if (result.kind === "error") { return result.error.message.split(eolRegex).join("\n"); } if (result.code === 0) { return null; } const raw = result.stdout !== "" ? result.stdout : result.stderr !== "" ? result.stderr : ""; const lines = raw.split(eolRegex); if (lines[lines.length - 1] === "") lines.pop(); return lines.join("\n"); } private async runGitQuery(args: string[], repo: string): Promise { const result = await this.spawnGitProcess(args, repo); if (result.kind === "error") { return { kind: "error", status: result.error.message.split(eolRegex).join("\n") }; } if (result.code === 0) { return { kind: "ok", stdout: result.stdout }; } const lines = (result.stderr !== "" ? result.stderr : result.stdout).split(eolRegex); if (lines[lines.length - 1] === "") lines.pop(); const message = lines.join("\n"); return { kind: "error", status: message !== "" ? message : GIT_QUERY_FAILED_MESSAGE }; } private async spawnGit( args: string[], repo: string, successValue: { (stdout: string): T }, errorValue: T ): Promise { const result = await this.spawnGitProcess(args, repo); return result.kind === "exit" && result.code === 0 ? successValue(result.stdout) : errorValue; } private spawnGitProcess( args: string[], repo: string ): Promise< | { kind: "error"; error: Error } | { kind: "exit"; code: number | null; stdout: string; stderr: string } > { return new Promise((resolve) => { const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; let err = false; const cmd = cp.spawn(this.gitPath, args, { cwd: repo, env: { ...process.env, LC_ALL: "C" } }); cmd.stdout.on("data", (d: string | Buffer) => { stdoutChunks.push(Buffer.from(d)); }); cmd.stderr.on("data", (d: string | Buffer) => { stderrChunks.push(Buffer.from(d)); }); cmd.on("error", (e: Error) => { resolve({ kind: "error", error: e }); err = true; }); cmd.on("close", (code: number | null) => { if (err) return; resolve({ kind: "exit", code, stdout: Buffer.concat(stdoutChunks).toString(), stderr: Buffer.concat(stderrChunks).toString() }); }); }); } }