Files
moreminimore-vibe/src/ipc/utils/git_utils.ts
Adeniji Adekunle James d3f3ac3ae1 Replace native Git with Dugite to support users without Git installed (#1760)
I moved all isomorphic-git usage into a single git_utils.ts file and
added Dugite as an alternative Git provider. The app now checks the
user’s settings and uses dugite when user enabled native git for all
isomorphic-git commands. This makes it easy to fully remove
isomorphic-git in the future by updating only git_utils.ts.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Adds Dugite-based native Git (bundled binary) and refactors all Git
calls to a unified git_utils API, replacing direct isomorphic-git usage
across the app.
> 
> - **Git Platform Abstraction**:
> - Introduces `dugite` and bundles Git via Electron Forge
(`extraResource`) with `LOCAL_GIT_DIRECTORY` setup in `src/main.ts`.
> - Adds `src/ipc/git_types.ts` and a comprehensive
`src/ipc/utils/git_utils.ts` wrapper supporting both Dugite (native) and
`isomorphic-git` (fallback): `commit`, `add`/`addAll`, `remove`, `init`,
`clone`, `push`, `setRemoteUrl`, `currentBranch`, `listBranches`,
`renameBranch`, `log`, `isIgnored`, `getCurrentCommitHash`,
`getGitUncommittedFiles`, `getFileAtCommit`, `checkout`,
`stageToRevert`.
> - **Refactors (switch to git_utils)**:
> - Replaces direct `isomorphic-git` imports in handlers and processors:
`app_handlers`, `chat_handlers`, `createFromTemplate`,
`github_handlers`, `import_handlers`, `portal_handlers`,
`version_handlers`, `response_processor`, `neon_timestamp_utils`,
`utils/codebase`.
> - Updates tests to mock `git_utils`
(`src/__tests__/chat_stream_handlers.test.ts`).
> - **Behavioral/Feature Updates**:
> - `createFromTemplate` uses `fetch` for GitHub API and `gitClone` for
cloning with cache validation.
> - GitHub integration uses `gitSetRemoteUrl`/`gitPush`/`gitClone`,
handling public vs token URLs and directory creation when native Git is
disabled.
> - Versioning, imports, app file edits, migrations now stage/commit via
`git_utils`.
> - **UI/Copy**:
>   - Updates Settings description for “Enable Native Git”.
> - **Config/Version**:
>   - Bumps version to `0.29.0-beta.1`; adds `dugite` dependency.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
ba098f7f25d85fc6330a41dc718fbfd43fff2d6c. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Will Chen <willchen90@gmail.com>
2025-12-09 19:01:25 -08:00

684 lines
18 KiB
TypeScript

import { getGitAuthor } from "./git_author";
import git from "isomorphic-git";
import http from "isomorphic-git/http/node";
import { exec } from "dugite";
import fs from "node:fs";
import { promises as fsPromises } from "node:fs";
import pathModule from "node:path";
import { readSettings } from "../../main/settings";
import log from "electron-log";
const logger = log.scope("git_utils");
import type {
GitBaseParams,
GitFileParams,
GitCheckoutParams,
GitBranchRenameParams,
GitCloneParams,
GitCommitParams,
GitLogParams,
GitFileAtCommitParams,
GitSetRemoteUrlParams,
GitStageToRevertParams,
GitInitParams,
GitPushParams,
GitCommit,
} from "../git_types";
/**
* Helper function that wraps exec and throws an error if the exit code is non-zero
*/
async function execOrThrow(
args: string[],
path: string,
errorMessage?: string,
): Promise<void> {
const result = await exec(args, path);
if (result.exitCode !== 0) {
const errorDetails = result.stderr.trim() || result.stdout.trim();
const error = errorMessage
? `${errorMessage}. ${errorDetails}`
: `Git command failed: ${args.join(" ")}. ${errorDetails}`;
throw new Error(error);
}
}
export async function getCurrentCommitHash({
path,
ref = "HEAD",
}: GitInitParams): Promise<string> {
const settings = readSettings();
if (settings.enableNativeGit) {
const result = await exec(["rev-parse", ref], path);
if (result.exitCode !== 0) {
throw new Error(
`Failed to resolve ref '${ref}': ${result.stderr.trim() || result.stdout.trim()}`,
);
}
return result.stdout.trim();
} else {
return await git.resolveRef({
fs,
dir: path,
ref,
});
}
}
export async function isGitStatusClean({
path,
}: {
path: string;
}): Promise<boolean> {
const settings = readSettings();
if (settings.enableNativeGit) {
const result = await exec(["status", "--porcelain"], path);
if (result.exitCode !== 0) {
throw new Error(`Failed to get status: ${result.stderr}`);
}
// If output is empty, working directory is clean (no changes)
const isClean = result.stdout.trim().length === 0;
return isClean;
} else {
const statusMatrix = await git.statusMatrix({ fs, dir: path });
return statusMatrix.every(
(row) => row[1] === 1 && row[2] === 1 && row[3] === 1,
);
}
}
export async function gitCommit({
path,
message,
amend,
}: GitCommitParams): Promise<string> {
const settings = readSettings();
if (settings.enableNativeGit) {
// Get author info to match isomorphic-git behavior
const author = await getGitAuthor();
// Perform the commit using dugite with --author flag
const args = [
"commit",
"-m",
message,
"--author",
`${author.name} <${author.email}>`,
];
if (amend) {
args.push("--amend");
}
await execOrThrow(args, path, "Failed to create commit");
// Get the new commit hash
const result = await exec(["rev-parse", "HEAD"], path);
if (result.exitCode !== 0) {
throw new Error(
`Failed to get commit hash: ${result.stderr.trim() || result.stdout.trim()}`,
);
}
return result.stdout.trim();
} else {
return git.commit({
fs: fs,
dir: path,
message,
author: await getGitAuthor(),
amend: amend,
});
}
}
export async function gitCheckout({
path,
ref,
}: GitCheckoutParams): Promise<void> {
const settings = readSettings();
if (settings.enableNativeGit) {
await execOrThrow(
["checkout", ref],
path,
`Failed to checkout ref '${ref}'`,
);
return;
} else {
return git.checkout({ fs, dir: path, ref });
}
}
export async function gitStageToRevert({
path,
targetOid,
}: GitStageToRevertParams): Promise<void> {
const settings = readSettings();
if (settings.enableNativeGit) {
// Get the current HEAD commit hash
const currentHeadResult = await exec(["rev-parse", "HEAD"], path);
if (currentHeadResult.exitCode !== 0) {
throw new Error(
`Failed to get current commit: ${currentHeadResult.stderr.trim() || currentHeadResult.stdout.trim()}`,
);
}
const currentCommit = currentHeadResult.stdout.trim();
// If we're already at the target commit, nothing to do
if (currentCommit === targetOid) {
return;
}
// Safety: refuse to run if the work-tree isn't clean.
const statusResult = await exec(["status", "--porcelain"], path);
if (statusResult.exitCode !== 0) {
throw new Error(
`Failed to get status: ${statusResult.stderr.trim() || statusResult.stdout.trim()}`,
);
}
if (statusResult.stdout.trim() !== "") {
throw new Error("Cannot revert: working tree has uncommitted changes.");
}
// Reset the working directory and index to match the target commit state
// This effectively undoes all changes since the target commit
await execOrThrow(
["reset", "--hard", targetOid],
path,
`Failed to reset to target commit '${targetOid}'`,
);
// Reset back to the original HEAD but keep the working directory as it is
// This stages all the changes needed to revert to the target state
await execOrThrow(
["reset", "--soft", currentCommit],
path,
"Failed to reset back to original HEAD",
);
} else {
// Get status matrix comparing the target commit (previousVersionId as HEAD) with current working directory
const matrix = await git.statusMatrix({
fs,
dir: path,
ref: targetOid,
});
// Process each file to revert to the state in previousVersionId
for (const [filepath, headStatus, workdirStatus] of matrix) {
const fullPath = pathModule.join(path, filepath);
// If file exists in HEAD (previous version)
if (headStatus === 1) {
// If file doesn't exist or has changed in working directory, restore it from the target commit
if (workdirStatus !== 1) {
const { blob } = await git.readBlob({
fs,
dir: path,
oid: targetOid,
filepath,
});
await fsPromises.mkdir(pathModule.dirname(fullPath), {
recursive: true,
});
await fsPromises.writeFile(fullPath, Buffer.from(blob));
}
}
// If file doesn't exist in HEAD but exists in working directory, delete it
else if (headStatus === 0 && workdirStatus !== 0) {
if (fs.existsSync(fullPath)) {
await fsPromises.unlink(fullPath);
await git.remove({
fs,
dir: path,
filepath: filepath,
});
}
}
}
// Stage all changes
await git.add({
fs,
dir: path,
filepath: ".",
});
}
}
export async function gitAddAll({ path }: GitBaseParams): Promise<void> {
const settings = readSettings();
if (settings.enableNativeGit) {
await execOrThrow(["add", "."], path, "Failed to stage all files");
return;
} else {
return git.add({ fs, dir: path, filepath: "." });
}
}
export async function gitAdd({ path, filepath }: GitFileParams): Promise<void> {
const settings = readSettings();
if (settings.enableNativeGit) {
await execOrThrow(
["add", "--", filepath],
path,
`Failed to stage file '${filepath}'`,
);
} else {
await git.add({
fs,
dir: path,
filepath,
});
}
}
export async function gitInit({
path,
ref = "main",
}: GitInitParams): Promise<void> {
const settings = readSettings();
if (settings.enableNativeGit) {
await execOrThrow(
["init", "-b", ref],
path,
`Failed to initialize git repository with branch '${ref}'`,
);
} else {
await git.init({
fs,
dir: path,
defaultBranch: ref,
});
}
}
export async function gitRemove({
path,
filepath,
}: GitFileParams): Promise<void> {
const settings = readSettings();
if (settings.enableNativeGit) {
await execOrThrow(
["rm", "-f", "--", filepath],
path,
`Failed to remove file '${filepath}'`,
);
} else {
await git.remove({
fs,
dir: path,
filepath,
});
}
}
export async function getGitUncommittedFiles({
path,
}: GitBaseParams): Promise<string[]> {
const settings = readSettings();
if (settings.enableNativeGit) {
const result = await exec(["status", "--porcelain"], path);
if (result.exitCode !== 0) {
throw new Error(
`Failed to get uncommitted files: ${result.stderr.trim() || result.stdout.trim()}`,
);
}
return result.stdout
.toString()
.split("\n")
.filter((line) => line.trim() !== "")
.map((line) => line.slice(3).trim());
} else {
const statusMatrix = await git.statusMatrix({ fs, dir: path });
return statusMatrix
.filter((row) => row[1] !== 1 || row[2] !== 1 || row[3] !== 1)
.map((row) => row[0]);
}
}
export async function getFileAtCommit({
path,
filePath,
commitHash,
}: GitFileAtCommitParams): Promise<string | null> {
const settings = readSettings();
if (settings.enableNativeGit) {
try {
const result = await exec(["show", `${commitHash}:${filePath}`], path);
if (result.exitCode !== 0) {
// File doesn't exist at this commit or other error
return null;
}
return result.stdout;
} catch (error: any) {
logger.error(
`Error getting file at commit ${commitHash}: ${error.message}`,
);
// File doesn't exist at this commit
return null;
}
} else {
try {
const { blob } = await git.readBlob({
fs,
dir: path,
oid: commitHash,
filepath: filePath,
});
return Buffer.from(blob).toString("utf-8");
} catch (error: any) {
logger.error(
`Error getting file at commit ${commitHash}: ${error.message}`,
);
// File doesn't exist at this commit
return null;
}
}
}
export async function gitListBranches({
path,
}: GitBaseParams): Promise<string[]> {
const settings = readSettings();
if (settings.enableNativeGit) {
const result = await exec(["branch", "--list"], path);
if (result.exitCode !== 0) {
throw new Error(result.stderr.toString());
}
// Parse output:
// e.g. "* main\n feature/login"
return result.stdout
.toString()
.split("\n")
.map((line) => line.replace("*", "").trim())
.filter((line) => line.length > 0);
} else {
return await git.listBranches({
fs,
dir: path,
});
}
}
export async function gitRenameBranch({
path,
oldBranch,
newBranch,
}: GitBranchRenameParams): Promise<void> {
const settings = readSettings();
if (settings.enableNativeGit) {
// git branch -m oldBranch newBranch
const result = await exec(["branch", "-m", oldBranch, newBranch], path);
if (result.exitCode !== 0) {
throw new Error(result.stderr.toString());
}
} else {
await git.renameBranch({
fs,
dir: path,
oldref: oldBranch,
ref: newBranch,
});
}
}
export async function gitClone({
path,
url,
accessToken,
singleBranch = true,
depth,
}: GitCloneParams): Promise<void> {
const settings = readSettings();
if (settings.enableNativeGit) {
// Dugite version (real Git)
// Build authenticated URL if accessToken is provided and URL doesn't already have auth
const finalUrl =
accessToken && !url.includes("@")
? url.replace("https://", `https://${accessToken}:x-oauth-basic@`)
: url;
const args = ["clone"];
if (depth && depth > 0) {
args.push("--depth", String(depth));
}
if (singleBranch) {
args.push("--single-branch");
}
args.push(finalUrl, path);
const result = await exec(args, ".");
if (result.exitCode !== 0) {
throw new Error(result.stderr.toString());
}
} else {
// isomorphic-git version
// Strip any embedded auth from URL since isomorphic-git uses onAuth
const cleanUrl = url.replace(/https:\/\/[^@]+@/, "https://");
await git.clone({
fs,
http,
dir: path,
url: cleanUrl,
onAuth: accessToken
? () => ({
username: accessToken,
password: "x-oauth-basic",
})
: undefined,
singleBranch,
depth: depth ?? undefined,
});
}
}
export async function gitSetRemoteUrl({
path,
remoteUrl,
}: GitSetRemoteUrlParams): Promise<void> {
const settings = readSettings();
if (settings.enableNativeGit) {
// Dugite version
try {
// Try to add the remote
const result = await exec(["remote", "add", "origin", remoteUrl], path);
// If remote already exists, update it instead
if (result.exitCode !== 0 && result.stderr.includes("already exists")) {
const updateResult = await exec(
["remote", "set-url", "origin", remoteUrl],
path,
);
if (updateResult.exitCode !== 0) {
throw new Error(`Failed to update remote: ${updateResult.stderr}`);
}
} else if (result.exitCode !== 0) {
// Handle other errors
throw new Error(`Failed to add remote: ${result.stderr}`);
}
} catch (error: any) {
logger.error("Error setting up remote:", error);
throw error; // or handle as needed
}
} else {
//isomorphic-git version
await git.setConfig({
fs,
dir: path,
path: "remote.origin.url",
value: remoteUrl,
});
}
}
export async function gitPush({
path,
branch,
accessToken,
force,
}: GitPushParams): Promise<void> {
const settings = readSettings();
if (settings.enableNativeGit) {
// Dugite version
try {
// Push using the configured origin remote (which already has auth in URL)
const args = ["push", "origin", `main:${branch}`];
if (force) {
args.push("--force");
}
const result = await exec(args, path);
if (result.exitCode !== 0) {
const errorMsg = result.stderr.toString() || result.stdout.toString();
throw new Error(`Git push failed: ${errorMsg}`);
}
} catch (error: any) {
logger.error("Error during git push:", error);
throw new Error(`Git push failed: ${error.message}`);
}
} else {
// isomorphic-git version
await git.push({
fs,
http,
dir: path,
remote: "origin",
ref: "main",
remoteRef: branch,
onAuth: () => ({
username: accessToken,
password: "x-oauth-basic",
}),
force: !!force,
});
}
}
export async function gitCurrentBranch({
path,
}: GitBaseParams): Promise<string | null> {
const settings = readSettings();
if (settings.enableNativeGit) {
// Dugite version
const result = await exec(["branch", "--show-current"], path);
if (result.exitCode !== 0) {
throw new Error(
`Failed to get current branch: ${result.stderr.trim() || result.stdout.trim()}`,
);
}
const branch = result.stdout.trim() || null;
return branch;
} else {
// isomorphic-git version returns string | undefined
const branch = await git.currentBranch({
fs,
dir: path,
fullname: false,
});
return branch ?? null;
}
}
export async function gitLog({
path,
depth = 100_000,
}: GitLogParams): Promise<GitCommit[]> {
const settings = readSettings();
if (settings.enableNativeGit) {
return await gitLogNative(path, depth);
} else {
// isomorphic-git fallback: this already returns the same structure
return await git.log({
fs,
dir: path,
depth,
});
}
}
export async function gitIsIgnored({
path,
filepath,
}: GitFileParams): Promise<boolean> {
const settings = readSettings();
if (settings.enableNativeGit) {
// Dugite version
// git check-ignore file
const result = await exec(["check-ignore", filepath], path);
// If exitCode == 0 → file is ignored
if (result.exitCode === 0) return true;
// If exitCode == 1 → not ignored
if (result.exitCode === 1) return false;
// Other exit codes are actual errors
throw new Error(result.stderr.toString());
} else {
// isomorphic-git version
return await git.isIgnored({
fs,
dir: path,
filepath,
});
}
}
export async function gitLogNative(
path: string,
depth = 100_000,
): Promise<GitCommit[]> {
// Use git log with custom format to get all data in a single process
// Format: %H = commit hash, %at = author timestamp (unix), %B = raw body (message)
// Using null byte as field separator and custom delimiter between commits
const logArgs = [
"log",
"--max-count",
String(depth),
"--format=%H%x00%at%x00%B%x00---END-COMMIT---",
"HEAD",
];
const logResult = await exec(logArgs, path);
if (logResult.exitCode !== 0) {
throw new Error(logResult.stderr.toString());
}
const output = logResult.stdout.toString().trim();
if (!output) {
return [];
}
// Split by commit delimiter (without newline since trim() removes trailing newline)
const commitChunks = output.split("\x00---END-COMMIT---").filter(Boolean);
const entries: GitCommit[] = [];
for (const chunk of commitChunks) {
// Split by null byte: [oid, timestamp, message]
const parts = chunk.split("\x00");
if (parts.length >= 3) {
const oid = parts[0].trim();
const timestamp = Number(parts[1]);
// Message is everything after the second null byte, may contain null bytes itself
const message = parts.slice(2).join("\x00");
entries.push({
oid,
commit: {
message: message,
author: {
timestamp: timestamp,
},
},
});
}
}
return entries;
}