UNPKG

gitnifty

Version:

A robust, promise-based Git utility for Node.js

621 lines (620 loc) 22.6 kB
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"]); } }