UNPKG

git-intent

Version:

Git workflow tool for intentional commits — define your commit intentions first for clearer, more atomic changes.

732 lines (713 loc) 26.5 kB
#!/usr/bin/env node "use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); // src/index.ts var import_commander10 = require("commander"); // src/utils/storage.ts var import_node_path3 = __toESM(require("path"), 1); var import_fs_extra3 = __toESM(require("fs-extra"), 1); // src/utils/generateId.ts var import_nanoid = require("nanoid"); function generateId(size) { return (0, import_nanoid.nanoid)(size); } // src/utils/get-package-info.ts var import_node_path = __toESM(require("path"), 1); var import_fs_extra = __toESM(require("fs-extra"), 1); function getPackageInfo() { const possiblePaths = [ import_node_path.default.resolve(__dirname, "../../package.json"), import_node_path.default.resolve(__dirname, "../package.json"), import_node_path.default.resolve(process.cwd(), "package.json") ]; for (const packageJsonPath of possiblePaths) { try { if (import_fs_extra.default.existsSync(packageJsonPath)) { const packageJson = import_fs_extra.default.readJSONSync(packageJsonPath); if (packageJson.name === "git-intent") { return { version: packageJson.version || "0.0.0", description: packageJson.description || "Git Intent CLI" }; } } } catch (error) { } } return { version: "0.0.0", description: "Git Intent CLI" }; } // src/utils/git.ts var import_node_child_process = require("child_process"); var import_node_path2 = __toESM(require("path"), 1); var import_fs_extra2 = __toESM(require("fs-extra"), 1); var import_simple_git = require("simple-git"); function execGit(args, options = {}) { const { input, cwd } = options; const result = (0, import_node_child_process.spawnSync)("git", args, { input: input ? Buffer.from(input) : void 0, cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }); if (result.status !== 0) { throw new Error(`Git command failed: git ${args.join(" ")} ${result.stderr}`); } return result.stdout ? result.stdout.trim() : ""; } var createGit = (cwd) => (0, import_simple_git.simpleGit)(cwd); async function findGitRoot(startDir = process.cwd()) { const dir = import_node_path2.default.resolve(startDir); const gitDir = import_node_path2.default.join(dir, ".git"); if (import_fs_extra2.default.existsSync(gitDir) && import_fs_extra2.default.statSync(gitDir).isDirectory()) { return dir; } const parentDir = import_node_path2.default.dirname(dir); if (parentDir === dir) { throw new Error("Not a git repository (or any of the parent directories)"); } return findGitRoot(parentDir); } async function checkIsRepo(cwd) { try { const git = createGit(cwd); await git.checkIsRepo(); return await findGitRoot(cwd); } catch { try { return await findGitRoot(cwd); } catch (error) { throw new Error("Not a git repository (or any of the parent directories)"); } } } async function getCurrentBranch(cwd) { const git = createGit(cwd); const branch = await git.revparse(["--abbrev-ref", "HEAD"]); return branch.trim(); } async function createCommit(message, cwd) { const git = createGit(cwd); const result = await git.commit(message); return result.commit; } async function hashObject(content, cwd) { return execGit(["hash-object", "-w", "--stdin"], { input: content, cwd }); } async function createTree(treeContent, cwd) { if (!treeContent || treeContent.trim() === "") { throw new Error("Invalid tree content: tree content cannot be empty"); } return execGit(["mktree"], { input: treeContent, cwd }); } async function createCommitTree(treeHash, message, cwd) { if (!treeHash || treeHash.trim() === "") { throw new Error("Invalid tree hash: tree hash cannot be empty"); } try { const result = execGit(["commit-tree", treeHash, "-m", message], { cwd }); if (!result || result.trim() === "") { throw new Error(`Failed to create commit tree from hash: ${treeHash}`); } return result; } catch (error) { console.error("Error creating commit tree:", error); throw error; } } async function updateRef(refName, commitHash, cwd) { if (!commitHash || commitHash.trim() === "") { throw new Error(`Invalid commit hash: commit hash cannot be empty for ref ${refName}`); } const git = createGit(cwd); await git.raw(["update-ref", refName, commitHash]); } async function deleteRef(refName, cwd) { const git = createGit(cwd); await git.raw(["update-ref", "-d", refName]); } async function checkRefExists(refName, cwd) { const git = createGit(cwd); try { await git.raw(["show-ref", "--verify", refName]); return true; } catch { return false; } } var git_default = createGit(); // src/utils/storage.ts var GitIntentionalCommitStorage = class _GitIntentionalCommitStorage { static instance; REFS_PREFIX = "refs/intentional-commits"; storageFilename; GIT_DIR = ".git"; gitRoot; constructor() { this.storageFilename = process.env.VITEST ? "test_intents.json" : "intents.json"; } static getInstance() { if (!_GitIntentionalCommitStorage.instance) { _GitIntentionalCommitStorage.instance = new _GitIntentionalCommitStorage(); } return _GitIntentionalCommitStorage.instance; } setGitRoot(root) { this.gitRoot = root; } async getGitRoot() { if (this.gitRoot) return this.gitRoot; return process.cwd(); } async getCommitsDir() { const root = await this.getGitRoot(); return import_node_path3.default.join(root, this.GIT_DIR, "intentional-commits"); } async getCommitsFile() { const commitsDir = await this.getCommitsDir(); return import_node_path3.default.join(commitsDir, "commits.json"); } getInitialData() { return { version: getPackageInfo().version, commits: [] }; } async ensureCommitsDir() { const commitsDir = await this.getCommitsDir(); const commitsFile = await this.getCommitsFile(); await import_fs_extra3.default.ensureDir(commitsDir); try { await import_fs_extra3.default.access(commitsFile); } catch { await import_fs_extra3.default.writeJSON(commitsFile, this.getInitialData()); } } migrateData(data) { return data; } async loadCommits() { const root = await this.getGitRoot(); await checkIsRepo(root); await this.ensureCommitsDir(); try { const result = await git_default.cwd(root).show(`${this.REFS_PREFIX}/commits:${this.storageFilename}`); const data = this.migrateData(JSON.parse(result)); return data.commits; } catch { const commitsFile = await this.getCommitsFile(); const data = this.migrateData(await import_fs_extra3.default.readJSON(commitsFile)); return data.commits; } } async saveCommitsData(data) { const root = await this.getGitRoot(); const commitsFile = await this.getCommitsFile(); const content = JSON.stringify(data, null, 2); const hash = await hashObject(content, root); const treeContent = `100644 blob ${hash} ${this.storageFilename} `; const treeHash = await createTree(treeContent, root); const commitHash = await createCommitTree(treeHash, "Update intent commits", root); await updateRef(`${this.REFS_PREFIX}/commits`, commitHash, root); await import_fs_extra3.default.writeJSON(commitsFile, data, { spaces: 2 }); } async saveCommits(commits) { const data = { version: getPackageInfo().version, commits }; await this.saveCommitsData(data); } async addCommit(commit2) { const currentCommits = await this.loadCommits(); const newCommitId = generateId(8); const newCommit = { ...commit2, id: newCommitId }; const data = { version: getPackageInfo().version, commits: [...currentCommits, newCommit] }; await this.saveCommitsData(data); return newCommitId; } async updateCommitMessage(id, message) { const currentCommits = await this.loadCommits(); const existingCommit = currentCommits.find((c) => c.id === id); if (!existingCommit) { throw new Error("Commit not found"); } const data = { version: getPackageInfo().version, commits: currentCommits.map((c) => c.id === id ? { ...existingCommit, message } : c) }; await this.saveCommitsData(data); } async deleteCommit(id) { const currentCommits = await this.loadCommits(); const newCommits = currentCommits.filter((c) => c.id !== id); await this.saveCommits(newCommits); } async clearCommits() { const root = await this.getGitRoot(); const commitsFile = await this.getCommitsFile(); await import_fs_extra3.default.remove(commitsFile); await deleteRef(`${this.REFS_PREFIX}/commits`, root); } async initializeRefs() { const root = await this.getGitRoot(); await checkIsRepo(root); const refExists = await checkRefExists(`${this.REFS_PREFIX}/commits`, root); if (!refExists) { const initialData = this.getInitialData(); const content = JSON.stringify(initialData, null, 2); const hash = await hashObject(content, root); const treeContent = `100644 blob ${hash} ${this.storageFilename} `; const treeHash = await createTree(treeContent, root); const commitHash = await createCommitTree(treeHash, "Initialize intent commits", root); if (!commitHash || commitHash.trim() === "") { throw new Error("Failed to create commit: commit hash is empty"); } await updateRef(`${this.REFS_PREFIX}/commits`, commitHash, root); } } }; var storage = GitIntentionalCommitStorage.getInstance(); // src/commands/list.ts var import_chalk = __toESM(require("chalk"), 1); var import_commander = require("commander"); var list = new import_commander.Command().command("list").description("List all intentional commits").action(async () => { const commits = await storage.loadCommits(); if (commits.length === 0) { console.log("No intents found"); return; } console.log("\nCreated:"); const createdCommits = commits.filter((commit2) => commit2.status === "created"); for (const commit2 of createdCommits) { console.log(import_chalk.default.white(` [${commit2.id}] ${commit2.message}`)); } console.log("\nIn Progress:"); const inProgressCommits = commits.filter((commit2) => commit2.status === "in_progress"); for (const commit2 of inProgressCommits) { console.log(import_chalk.default.blue(` [${commit2.id}] ${commit2.message}`)); } }); var list_default = list; // src/commands/start.ts var import_chalk2 = __toESM(require("chalk"), 1); var import_commander2 = require("commander"); var import_prompts = __toESM(require("prompts"), 1); var start = new import_commander2.Command().command("start").argument("[id]", "Intent ID").description("Start working on a planned intent").action(async (id) => { const commits = await storage.loadCommits(); let selectedId = id; if (!selectedId) { const createdCommits = commits.filter((c) => c.status === "created"); if (createdCommits.length === 0) { console.log("No created intents found"); return; } const response = await (0, import_prompts.default)({ type: "select", name: "id", message: "Select an intent to start:", choices: createdCommits.map((c) => ({ title: `${c.message} (${c.id})`, value: c.id })) }); selectedId = response.id; } if (!selectedId) { console.error("No intent selected"); return; } const targetCommit = commits.find((c) => c.id === selectedId); if (!targetCommit) { console.error("Intent not found"); return; } if (targetCommit.status !== "created") { console.error("Intent is not in created status"); return; } const currentBranch = await getCurrentBranch(); targetCommit.status = "in_progress"; targetCommit.metadata.startedAt = (/* @__PURE__ */ new Date()).toISOString(); targetCommit.metadata.branch = currentBranch; await storage.saveCommits(commits); console.log(import_chalk2.default.green("\u2713 Started working on:")); console.log(`ID: ${import_chalk2.default.blue(targetCommit.id)}`); console.log(`Message: ${targetCommit.message}`); }); var start_default = start; // src/commands/show.ts var import_chalk3 = __toESM(require("chalk"), 1); var import_commander3 = require("commander"); var show = new import_commander3.Command().command("show").description("Show current intention").action(async () => { const commits = await storage.loadCommits(); const currentCommit = commits.find((c) => c.status === "in_progress"); if (!currentCommit) { console.log("No active intention"); return; } console.log(import_chalk3.default.blue("Current intention:")); console.log(`ID: ${import_chalk3.default.dim(currentCommit.id)}`); console.log(`Message: ${currentCommit.message}`); console.log(`Status: ${import_chalk3.default.yellow(currentCommit.status)}`); console.log(`Created at: ${new Date(currentCommit.metadata.createdAt).toLocaleString()}`); }); var show_default = show; // src/commands/commit.ts var import_chalk4 = __toESM(require("chalk"), 1); var import_commander4 = require("commander"); var commit = new import_commander4.Command().command("commit").description("Complete current intention and commit").option("-m, --message <message>", "Additional commit message").action(async (options) => { const commits = await storage.loadCommits(); const currentCommit = commits.find((c) => c.status === "in_progress"); if (!currentCommit) { console.log("No active intention"); return; } const message = options.message ? `${currentCommit.message} ${options.message}` : currentCommit.message; await createCommit(message); await storage.deleteCommit(currentCommit.id); console.log(import_chalk4.default.green("\u2713 Intention completed and committed")); }); var commit_default = commit; // src/commands/drop.ts var import_chalk5 = __toESM(require("chalk"), 1); var import_commander5 = require("commander"); var import_prompts2 = __toESM(require("prompts"), 1); var drop = new import_commander5.Command().command("drop").description("Drop a planned intent").argument("[id]", "Intent ID").option("-a, --all", "Drop all created intents").action(async (id, options) => { const commits = await storage.loadCommits(); const createdCommits = commits.filter((c) => c.status === "created"); if (options.all) { await storage.saveCommits([]); console.log(import_chalk5.default.green("\u2713 All created intents removed")); return; } let selectedId = id; if (!selectedId) { if (createdCommits.length === 0) { console.log("No created intents found. Nothing to remove."); return; } const response = await (0, import_prompts2.default)({ type: "select", name: "id", message: "Select an intent to remove:", choices: createdCommits.map((c) => ({ title: `${c.message} (${c.id})`, value: c.id })) }); selectedId = response.id; } if (!selectedId) { console.error("No intent selected"); return; } const targetCommit = commits.find((c) => c.id === selectedId); if (!targetCommit) { console.error("Intent not found"); return; } if (targetCommit.status !== "created") { console.error("Can only remove intents in created status"); return; } const updatedCommits = commits.filter((c) => c.id !== selectedId); await storage.saveCommits(updatedCommits); console.log(import_chalk5.default.green("\u2713 Intent removed:")); console.log(`ID: ${import_chalk5.default.blue(targetCommit.id)}`); console.log(`Message: ${targetCommit.message}`); }); var drop_default = drop; // src/commands/cancel.ts var import_chalk6 = __toESM(require("chalk"), 1); var import_commander6 = require("commander"); var import_prompts3 = __toESM(require("prompts"), 1); var cancel = new import_commander6.Command().command("cancel").description("Cancel current intention").action(async () => { const commits = await storage.loadCommits(); const currentCommit = commits.find((c) => c.status === "in_progress"); if (!currentCommit) { console.log("No active intention"); return; } const { action } = await (0, import_prompts3.default)({ type: "select", name: "action", message: "What would you like to do with the intent?", choices: [ { title: "Reset to created status", value: "reset" }, { title: "Delete the intent", value: "delete" } ] }); if (!action) { return; } let updatedCommits; if (action === "reset") { updatedCommits = commits.map( (c) => c.id === currentCommit.id ? { ...c, status: "created", metadata: { ...c.metadata, startedAt: void 0 } } : c ); console.log(import_chalk6.default.green("\u2713 Intent reset to created status:")); } else { updatedCommits = commits.filter((c) => c.id !== currentCommit.id); console.log(import_chalk6.default.green("\u2713 Intent deleted:")); } await storage.saveCommits(updatedCommits); console.log(`ID: ${import_chalk6.default.blue(currentCommit.id)}`); console.log(`Message: ${currentCommit.message}`); console.log("\nNote: Your staged changes are preserved."); }); var cancel_default = cancel; // src/commands/reset.ts var import_commander7 = require("commander"); var import_prompts4 = __toESM(require("prompts"), 1); var reset = new import_commander7.Command().command("reset").description("Reset all intents").action(async () => { const response = await (0, import_prompts4.default)({ type: "confirm", name: "reset", message: "Are you sure you want to reset all intents?", initial: false }); if (!response.reset) { return; } await storage.clearCommits(); console.log("All intents reset"); }); var reset_default = reset; // src/commands/divide.ts var import_chalk7 = __toESM(require("chalk"), 1); var import_commander8 = require("commander"); var import_external_editor = __toESM(require("external-editor"), 1); var import_prompts5 = __toESM(require("prompts"), 1); var divide = new import_commander8.Command().command("divide").description("Divide an intent into smaller parts").action(async () => { const commits = await storage.loadCommits(); if (commits.length === 0) { console.log("No intents found to divide"); return; } const response = await (0, import_prompts5.default)({ type: "select", name: "id", message: "Select an intent to divide:", choices: commits.map((c) => ({ title: `[${c.status === "created" ? "Created" : "In Progress"}] ${c.message.split("\n")[0]} (${c.id})`, value: c.id })), onState: (state) => { if (state.aborted) { process.nextTick(() => { process.exit(0); }); } } }); const selectedId = response.id; if (!selectedId) { console.log("No intent selected"); return; } const targetCommit = commits.find((c) => c.id === selectedId); if (!targetCommit) { console.error("Intent not found"); return; } console.log(import_chalk7.default.blue("\nOriginal commit:"), targetCommit.message); const tasks = []; console.log(import_chalk7.default.yellow("\nDividing commit into two tasks.")); console.log( import_chalk7.default.dim( "Tip: Enter a title directly for a simple task, or leave it empty to open an editor for a detailed commit message." ) ); console.log(import_chalk7.default.blue("\nFirst task:")); const { taskTitle: firstTaskTitle } = await (0, import_prompts5.default)({ type: "text", name: "taskTitle", message: "Task 1 title:", onState: (state) => { if (state.aborted) { process.nextTick(() => { process.exit(0); }); } } }); if (firstTaskTitle && firstTaskTitle.trim() !== "") { tasks.push(firstTaskTitle.trim()); } else { console.log(import_chalk7.default.dim("Opening editor for commit message. Save and close the editor when done.")); let initialText = targetCommit.message; initialText = `# Enter commit message for the first task # Lines starting with # will be ignored ${initialText}`; const message = import_external_editor.default.edit(initialText, { postfix: ".git-intent-divide" }); const fullMessage = message.split("\n").filter((line) => !line.trim().startsWith("#")).join("\n").trim(); if (!fullMessage) { console.log("No message provided for the first task. Operation cancelled."); return; } tasks.push(fullMessage); } console.log(import_chalk7.default.blue("\nSecond task:")); const { taskTitle: secondTaskTitle } = await (0, import_prompts5.default)({ type: "text", name: "taskTitle", message: "Task 2 title:", onState: (state) => { if (state.aborted) { process.nextTick(() => { process.exit(0); }); } } }); if (secondTaskTitle && secondTaskTitle.trim() !== "") { tasks.push(secondTaskTitle.trim()); } else { console.log(import_chalk7.default.dim("Opening editor for commit message. Save and close the editor when done.")); const initialText = "# Enter commit message for the second task\n# Lines starting with # will be ignored\n"; const message = import_external_editor.default.edit(initialText, { postfix: ".git-intent-divide" }); const fullMessage = message.split("\n").filter((line) => !line.trim().startsWith("#")).join("\n").trim(); if (!fullMessage) { console.log("No message provided for the second task. Operation cancelled."); return; } tasks.push(fullMessage); } console.log(import_chalk7.default.blue("\nTasks to create:")); tasks.forEach((task, index) => { const title = task.split("\n")[0]; console.log(`${index + 1}. ${title}`); if (task.includes("\n")) { const preview = task.split("\n").slice(1).join(" ").trim(); const shortPreview = preview.length > 60 ? `${preview.substring(0, 57)}...` : preview; if (shortPreview) { console.log(` ${import_chalk7.default.dim(shortPreview)}`); } } }); const { confirmed } = await (0, import_prompts5.default)({ type: "confirm", name: "confirmed", message: "Do you want to divide the commit with these tasks?", initial: true, onState: (state) => { if (state.aborted) { process.nextTick(() => { process.exit(0); }); } } }); if (!confirmed) { console.log("Operation cancelled."); return; } const { shouldRemoveOriginal } = await (0, import_prompts5.default)({ type: "confirm", name: "shouldRemoveOriginal", message: "Do you want to remove the original commit?", initial: false, onState: (state) => { if (state.aborted) { process.nextTick(() => { process.exit(0); }); } } }); const newCommitIds = []; for (const task of tasks) { const newCommitId = await storage.addCommit({ message: task, status: "created", metadata: { createdAt: (/* @__PURE__ */ new Date()).toISOString() } }); newCommitIds.push(newCommitId); } if (shouldRemoveOriginal) { await storage.deleteCommit(targetCommit.id); } console.log(import_chalk7.default.green("\u2713 Successfully divided the commit:")); for (let i = 0; i < tasks.length; i++) { const title = tasks[i].split("\n")[0]; console.log(`${i + 1}. ${import_chalk7.default.blue(title)} (ID: ${newCommitIds[i]})`); } if (!shouldRemoveOriginal) { console.log(import_chalk7.default.yellow("\nOriginal commit was kept:")); const originalTitle = targetCommit.message.split("\n")[0]; console.log(`${import_chalk7.default.blue(originalTitle)} (ID: ${targetCommit.id})`); } }); var divide_default = divide; // src/commands/add.ts var import_chalk8 = __toESM(require("chalk"), 1); var import_commander9 = require("commander"); var import_external_editor2 = __toESM(require("external-editor"), 1); var add = new import_commander9.Command().command("add").description("Add a new intentional commit before starting work").argument("[message]", "Intent message").action(async (message) => { let commitMessage = message; if (!commitMessage) { const text = import_external_editor2.default.edit("", { postfix: ".git-intent" }); commitMessage = text.trim(); } if (!commitMessage) { console.error("Commit message is required"); process.exit(1); } const newCommitId = await storage.addCommit({ message: commitMessage, status: "created", metadata: { createdAt: (/* @__PURE__ */ new Date()).toISOString() } }); console.log(import_chalk8.default.green("\u2713 Intent created:")); console.log(`ID: ${import_chalk8.default.blue(newCommitId)}`); console.log(`Message: ${commitMessage}`); }); var add_default = add; // src/index.ts (async () => { await storage.initializeRefs(); const { version, description } = getPackageInfo(); import_commander10.program.name("git-intent").description(description).version(version).addCommand(add_default).addCommand(list_default).addCommand(start_default).addCommand(show_default).addCommand(commit_default).addCommand(cancel_default).addCommand(reset_default).addCommand(divide_default).addCommand(drop_default); import_commander10.program.parse(); })();