gitnifty
Version:
A robust, promise-based Git utility for Node.js
621 lines (620 loc) • 22.6 kB
JavaScript
import { exec } from "node:child_process";
// ***** Git Class *****
/**
* A smart, user-friendly Git utility class for Node.js CLIs.
*
* `Git` provides a high-level interface for interacting with Git repositories using simple, intuitive commands.
* Designed for use in GitNifty, it wraps common Git operations like checking repository status, retrieving user info,
* detecting upstream branches, and more — all with clean automation and helpful defaults.
*
* This class is built to make version control effortless for developers who want precision and productivity,
* without dealing with complex Git shell commands directly.
*
* @example
* ```ts
* import { Git } from "./gitnifty";
*
* const git = new Git({ cwd: "/path/to/repo" });
* const username = await git.getUserName();
* const branch = await git.getCurrentBranchName();
* const isClean = await git.isWorkingDirClean();
* ```
*
* @remarks
* - Built for Node.js CLI tools like GitNifty.
* - Uses `child_process.exec` under the hood.
* - Handles common Git tasks with automation-friendly methods.
* - Falls back gracefully on errors, e.g., `hasUpstreamBranch()` or `isWorkingDirClean()` return `false` instead of throwing.
*
* @see {@link GitOptions} - Options for configuring the Git class.
* @see {@link https://git-scm.com/docs | Git Official Documentation}
*/
export class Git {
/**
* Creates an instance of the Git class.
*
* @param options - Configuration options for the Git instance.
*
* @example
* ```ts
* const git = new Git({ cwd: "/path/to/repo" });
* ```
*/
constructor(options) {
/**
* Executes a Git command and handles errors gracefully.
*
* @private
*
* @template T - The expected return type of the command.
*
* @param cmd - The Git command to execute.
*
* @returns A promise that resolves to `true` if the command succeeds, `false` otherwise.
*/
this.tryCommand = async (cmd) => {
try {
await cmd();
return true;
}
catch {
return false;
}
};
/**
* Retrieves the default branch name of the repository (e.g., `main` or `master`).
*
* Falls back to "main" if the default branch cannot be determined.
*
* @returns A promise that resolves with the default branch name.
*
* @throws Throws an error if the command fails (e.g., not a Git repository).
*
* @example
* ```ts
* const git = new Git({ cwd: "/path/to/repo" });
* const defaultBranch = await git.getDefaultBranchName();
* console.log(defaultBranch); // "main" or "master"
* ```
*/
this.getDefaultBranchName = async () => {
const branches = await this.runCommand("git branch -r");
const defaultBranch = branches
.split("\n")
.find((branch) => branch.includes("origin/HEAD"));
return defaultBranch
? defaultBranch.replace("origin/HEAD -> origin/", "").trim()
: "main";
};
this.cwd = options.cwd || process.cwd();
}
/**
* Runs a Git command in the specified working directory.
*
* @private
*
* @param cmd - The Git command to execute.
*
* @returns A promise that resolves with the command's stdout.
*
* @throws Throws an error with the command and stderr if execution fails.
*/
runCommand(cmd) {
return new Promise((resolve, reject) => {
exec(cmd, { cwd: this.cwd }, (error, stdout, stderr) => {
if (error) {
reject(new Error(`Error executing command: ${cmd}\n${stderr}`));
}
else {
resolve(stdout.trim());
}
});
});
}
/**
* Normalizes a value into an array.
*
* @example
* toArray("--tags") // ["--tags"]
* toArray(["--tags", "--always"]) // ["--tags", "--always"]
*
* @param input - A single value or an array of values.
* @returns The input wrapped in an array, if not already.
*/
toArray(input) {
return Array.isArray(input) ? input : [input];
}
/**
* Retrieves the configured Git user name.
*
* @returns A promise that resolves with the user's name.
*
* @throws Throws an error if the command fails (e.g., Git not installed or user not configured).
*
* @example
* ```ts
* const git = new Git({ cwd: "/path/to/repo" });
* const username = await git.getUserName();
* console.log(username); // "John Doe"
* ```
*/
getUserName() {
return this.runCommand("git config user.name");
}
/**
* Sets the global Git user name.
*
* This command configures the `user.name` value in the global Git configuration.
* It wraps `git config --global user.name "<name>"`.
*
* @param name - The Git user name to set. If it includes spaces, it will be quoted automatically.
*
* @returns A promise that resolves when the name has been successfully set.
*
* @example
* ```ts
* await git.setUserName("John Doe");
* const name = await git.getUserName();
* console.log(name); // "John Doe"
* ```
*/
setUserName(name) {
return this.runCommand(`git config --global user.name "${name}" `);
}
/**
* Retrieves the configured Git user email.
*
* @returns A promise that resolves with the user's email.
*
* @throws Throws an error if the command fails (e.g., Git not installed or email not configured).
*
* @example
* ```ts
* const git = new Git({ cwd: "/path/to/repo" });
* const email = await git.getUserEmail();
* console.log(email); // "john.doe@example.com"
* ```
*/
getUserEmail() {
return this.runCommand("git config user.email");
}
/**
* Sets the global Git user email.
*
* This command configures the `user.email` value in the global Git configuration.
* It wraps `git config --global user.email "<email>"`.
*
* @param email - The Git user email address to set.
*
* @returns A promise that resolves when the email has been successfully set.
*
* @example
* ```ts
* await git.setUserEmail("john.doe@example.com");
* const email = await git.getUserEmail();
* console.log(email); // "john.doe@example.com"
* ```
*/
setUserEmail(email) {
return this.runCommand(`git config --global user.email "${email}"`);
}
/**
* Checks if there are no **unstaged** changes in the working directory.
*
* @returns A promise that resolves to `true` if there are no unstaged changes, otherwise `false`.
*
* @example
* ```ts
* const git = new Git({ cwd: "/repo" });
* const clean = await git.hasNoUnstagedChanges();
* console.log(clean); // true if working directory has no unstaged changes
* ```
*/
hasNoUnstagedChanges() {
return this.tryCommand(() => this.runCommand("git diff --quiet"));
}
/**
* Checks if there are no **staged but uncommitted** changes.
*
* @returns A promise that resolves to `true` if there are no staged changes, otherwise `false`.
*
* @example
* ```ts
* const git = new Git({ cwd: "/repo" });
* const clean = await git.hasNoStagedChanges();
* console.log(clean); // true if nothing is staged for commit
* ```
*/
hasNoStagedChanges() {
return this.tryCommand(() => this.runCommand("git diff --cached --quiet"));
}
/**
* Checks if the working directory is completely clean i.e., no staged or unstaged changes.
*
* @returns A promise that resolves to `true` if the working directory is fully clean, otherwise `false`.
*
* @example
* ```ts
* const git = new Git({ cwd: "/repo" });
* const isClean = await git.isWorkingDirClean();
* console.log(isClean); // true if no changes, false if dirty
* ```
*
* @see {@link hasNoUnstagedChanges} To check if working directory has unstaged changes.
* @see {@link hasNoStagedChanges} To check if working directory has staged but uncommitted changes.
*/
async isWorkingDirClean() {
const unstagedClean = await this.hasNoUnstagedChanges();
const stagedClean = await this.hasNoStagedChanges();
return unstagedClean && stagedClean;
}
/**
* Checks if the current branch has an upstream branch configured.
*
* @returns A promise that resolves to `true` if an upstream branch is set, `false` otherwise.
*
* @example
* ```ts
* const git = new Git({ cwd: "/path/to/repo" });
* const hasUpstream = await git.hasUpstreamBranch();
* console.log(hasUpstream); // true (if upstream is set), false (if not)
* ```
*/
hasUpstreamBranch() {
return this.tryCommand(() => this.runCommand("git rev-parse --abbrev-ref --symbolic-full-name @{u}"));
}
/**
* Retrieves the name of the current branch.
*
* @returns A promise that resolves with the current branch name.
*
* @throws Throws an error if the command fails (e.g., not a Git repository).
*
* @example
* ```ts
* const git = new Git({ cwd: "/path/to/repo" });
* const branch = await git.getCurrentBranchName();
* console.log(branch); // "main"
* ```
*/
getCurrentBranchName() {
return this.runCommand("git rev-parse --abbrev-ref HEAD");
}
/**
* Stages one or more files or directories for the next commit.
*
* This method wraps `git add` to prepare specified files or directories for commit.
* You can provide a single path or an array of paths. By default, it stages all changes.
*
* @param path - The file(s) or directory path(s) to stage.
* Use `"."` to stage all changes. If an array is provided, all listed paths will be staged.
*
* @returns A promise that resolves with the command's stdout if successful.
*
* @throws Throws an error with stderr if the command fails (e.g., invalid path).
*
* @example
* ```ts
* const git = new Git({ cwd: "/repo" });
* await git.add("README.md"); // stages a single file
* await git.add(["src/", "docs/"]); // stages multiple directories
* await git.add(); // stages everything (default ".")
* ```
*/
add(path = ".") {
const normalizedPath = Array.isArray(path) ? path.join(" ") : path;
return this.runCommand(`git add ${normalizedPath} `);
}
/**
* Resets the current HEAD to the specified commit hash, with an optional behavior flag.
*
* This method wraps `git reset <hash> <flag>` and moves the current branch pointer to the given commit.
* It does not modify the working directory or the index unless additional flags (e.g., `--hard`, `--soft`) are added manually.
*
* @param hashValue - The target commit hash to reset to.
* This must be a valid Git commit SHA (full or abbreviated).
*
* @param flag - One or more Git reset flags.
* Examples: `"--soft"`, `"--hard"`.
*
*
* @returns A promise that resolves with the command's stdout if the reset succeeds.
*
* @throws Throws an error if the command fails (e.g., invalid hash or detached HEAD issues).
*
* @example
* ```ts
* const git = new Git({ cwd: "/repo" });
* await git.reset("abc1234"); // Moves HEAD to commit abc1234
* await git.reset("abc1234", "--hard"); // Hard reset to commit
* ```
*/
reset(hashValue, flag) {
const parts = ["git reset", flag, hashValue].filter(Boolean);
return this.runCommand(parts.join(" "));
}
/**
* Restores working tree files from the index or a specified source.
*
* This method wraps `git restore` to unstage files or restore their contents
* from the index (staged) or from a specific commit/branch.
*
* @param target - One or more file paths to restore.
* Use `"."` to restore all tracked files. When using an array, all paths will be included.
*
* @param flag - An optional Git restore flag like `--staged` or `--source=<commit>`.
* Use this to restore from a specific source or unstage changes.
*
* @returns A promise that resolves with the command's output on success.
*
* @throws Throws an error if the command fails (e.g., invalid path or ref).
*
* @example
* ```ts
* const git = new Git({ cwd: "/repo" });
* await git.restore("README.md"); // Restore file from index
* await git.restore(".", "--staged"); // Unstage all changes
* await git.restore(["src/", "docs/"], "--source=HEAD~1"); // Restore from previous commit
* ```
*
* @see {@link https://git-scm.com/docs/git-restore | git restore - Official Git Docs}
*/
restore(target = ".", flag) {
const normalizedTarget = Array.isArray(target) ? target.join(" ") : target;
const flagPart = flag ? ` ${flag}` : "";
return this.runCommand(`git restore${flagPart} ${normalizedTarget}`);
}
/**
* Commits staged changes to the repository with a custom message and optional flags.
*
* This method wraps `git commit -m "<message>"` with support for additional commit flags.
* It safely escapes double quotes in the commit message to avoid shell issues.
*
* @param message - The commit message to use. Will be wrapped in quotes and escaped.
* @param flags - Optional list of commit flags to customize the commit behavior.
* Each flag must be a valid `CommitFlag` value.
*
* @returns A promise that resolves with the command's stdout if the commit succeeds.
*
* @throws Throws an error if the commit fails (e.g., nothing staged, invalid flags).
*
* @example
* ```ts
* const git = new Git({ cwd: "/repo" });
* await git.commit("feat: add login API");
* await git.commit("fix: typo", ["--amend", "--no-edit"]);
* ```
*
* @see {@link CommitFlag} for supported commit flags
* @see {@link https://git-scm.com/docs/git-commit Git Commit Docs}
*/
async commit(message, flags) {
const flagsPart = flags
? Array.isArray(flags)
? ` ${flags.join(" ")}`
: ` ${flags}`
: "";
await this.runCommand(`git commit -m "${message.replace(/"/g, '\\"')}"${flagsPart}`);
return this;
}
/**
* Initializes a new Git repository in the working directory.
*
* @returns A promise that resolves to the Git instance.
*
* @example
* ```ts
* await git.init();
* await git.clone("https://github.com/repo.git");
* ```
*
* @see {@link https://git-scm.com/docs/git-init Git Init Docs}
*/
async init() {
await this.runCommand("git init");
return this;
}
/**
* Clones a Git repository into the current directory or specified folder.
*
* @param url - The Git repository URL to clone.
* @param dir - Optional directory to clone into.
*
* @returns A promise that resolves to the Git instance.
*
* @example
* ```ts
* await git.clone("https://github.com/user/repo.git", "my-folder");
* ```
*
* @see {@link https://git-scm.com/docs/git-clone Git Clone Docs}
*/
async clone(url, dir = "") {
await this.runCommand(`git clone ${url} ${dir}`);
return this;
}
/**
* Pushes changes to the specified remote and optionally to a specific branch.
*
* @param remote The remote name to push to. Defaults to `"origin"`.
* @param branch The branch name to push. If not provided, pushes all matching branches.
* @param flags Optional push flags to customize behavior. Can be a single flag or array.
*
* @returns A promise that resolves with the result of the Git command.
*
* @example
* ```ts
* git.push(); // git push origin
* git.push("origin", "main", "--force"); // git push --force origin main
* git.push("origin", "dev", ["--tags", "--set-upstream"]); // git push --tags --set-upstream origin dev
* ```
*
* @see {@link https://git-scm.com/docs/git-push Git Push Docs}
*/
async push(remote = "origin", branch = "", flags = []) {
let finalRemote = "origin";
let finalBranch = "";
let finalFlags = [];
if (Array.isArray(remote)) {
finalFlags = remote;
}
else {
finalRemote = remote;
if (Array.isArray(branch)) {
finalFlags = branch;
}
else {
finalBranch = branch;
finalFlags = Array.isArray(flags) ? flags : [flags].filter(Boolean);
}
}
const flagStr = finalFlags.length ? `${finalFlags.join(" ")} ` : "";
const branchRef = finalBranch
? `${finalRemote} ${finalBranch}`
: finalRemote;
await this.runCommand(`git push ${flagStr}${branchRef}`);
return this;
}
/**
* Creates, deletes, or lists Git tags with optional flags.
*
* @param value The tag name (or value depending on flags).
* @param flags Optional tag flags like `--annotate`, `--delete`, etc.
*
* @returns A promise that resolves with the result of the Git command.
*
* @example
* ```ts
* git.tag("v1.0.0"); // git tag v1.0.0
* git.tag("v1.0.0", "--delete"); // git tag --delete v1.0.0
* git.tag("v1.0.0", ["--annotate", "--force"]); // git tag --annotate --force v1.0.0
* ```
*
* @see {@link https://git-scm.com/docs/git-tag Git Tag Docs}
*/
tag(value, flags) {
const flagStr = flags
? Array.isArray(flags)
? `${flags.join(" ")} `
: `${flags} `
: "";
return this.runCommand(`git tag ${flagStr}${value}`);
}
/**
* Merges the specified branch into the current branch.
*
* @param branchName The name of the branch to merge.
* @param flags Optional merge flags like `--no-ff`, `--squash`, etc.
*
* @returns A promise that resolves with the result of the Git command.
*
* @example
* ```ts
* git.merge("feature-branch"); // git merge feature-branch
* git.merge("hotfix", ["--squash", "--no-commit"]); // git merge --squash --no-commit hotfix
* ```
*
* @see {@link https://git-scm.com/docs/git-merge Git Merge Docs}
*/
merge(branchName, flags) {
const flagStr = flags
? Array.isArray(flags)
? `${flags.join(" ")} `
: `${flags} `
: "";
return this.runCommand(`git merge ${flagStr}${branchName}`);
}
/**
* Switches to the given branch or commit, optionally creating it.
*
* @param target The branch, tag, or commit hash to checkout.
* @param flags Optional checkout flags like `-b`, `--orphan`, etc.
*
* @returns A promise that resolves with the result of the Git command.
*
* @example
* ```ts
* git.checkout("main"); // git checkout main
* git.checkout("new-branch", "-b"); // git checkout -b new-branch
* ```
*
* @see {@link https://git-scm.com/docs/git-checkout Git Checkout Docs}
*/
async checkout(target, flags) {
const flagStr = flags
? Array.isArray(flags)
? `${flags.join(" ")} `
: `${flags} `
: "";
await this.runCommand(`git checkout ${flagStr}${target}`);
return this;
}
/**
* Creates, deletes, renames, or lists branches.
*
* @param name Optional branch name. Required for some flags.
* @param flags Optional branch flags like `-d`, `-m`, `--list`, etc.
*
* @returns A promise that resolves with the result of the Git command.
*
* @example
* ```ts
* git.branch(); // git branch
* git.branch("feature-x"); // git branch feature-x
* git.branch("old-branch", ["-d"]); // git branch -d old-branch
* git.branch(undefined, "--show-current"); // git branch --show-current
* ```
*
* @see {@link https://git-scm.com/docs/git-branch Git Branch Docs}
*/
branch(name, flags = []) {
const flagArr = Array.isArray(flags) ? flags : flags ? [flags] : [];
const parts = ["git branch", ...flagArr, name].filter(Boolean);
return this.runCommand(parts.join(" "));
}
/**
* Runs `git describe` to generate a human-readable identifier for a commit.
*
* This wraps the `git describe` command and returns a string such as
* `v1.2.3-2-gabcdef` based on the most recent tag and commit information.
*
* @param flags - One or more optional `git describe` flags to customize the output.
* Can be a single flag or an array of flags.
*
* @param ref - Optional Git reference to describe (e.g., a branch, tag, or commit hash).
* Defaults to `HEAD` if not provided.
*
* @returns A promise that resolves with the `git describe` output.
*
* @example
* ```ts
* await git.describe("--tags");
* await git.describe(["--tags", "--long"], "main");
* await git.describe(["--dirty=*", "--abbrev=10"], "HEAD~2");
* ```
*
* @see {@link https://git-scm.com/docs/git-describe Git Describe Docs}
*/
describe(flags, ref) {
const args = [...this.toArray(flags), ref].filter(Boolean);
return this.runCommand(`git describe ${args.join(" ")}`);
}
/**
* Retrieves the latest reachable Git tag (e.g., `v1.2.3`) without commit metadata.
*
* This uses `git describe --tags --abbrev=0` to return only the most recent tag name,
* ignoring additional suffixes like commit counts or hashes.
*
* @returns A promise that resolves with the latest tag as a string.
*
* @example
* ```ts
* await git.getLatestTag(); // "v1.2.3"
* ```
*
* @see {@link Git.describe}
*/
getLatestTag() {
return this.describe(["--tags", "--abbrev=0"]);
}
}