diff --git a/src/commands/suggest.ts b/src/commands/suggest.ts index 769a05a..0cc7140 100644 --- a/src/commands/suggest.ts +++ b/src/commands/suggest.ts @@ -147,7 +147,7 @@ async function acceptAndCommit(selected: Suggestion, config: Config, diff: strin if (auto) { try { const result = commit(selected.message, selected.body); - console.log(pc.green(result.trim())); + console.log(pc.green(result.summary)); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; outro(pc.red(`Commit failed: ${msg}`)); @@ -208,7 +208,7 @@ async function acceptAndCommit(selected: Suggestion, config: Config, diff: strin try { const result = commit(finalMessage, finalBody); - console.log(pc.green(result.trim())); + console.log(pc.green(result.summary)); await appendEntry({ timestamp: new Date().toISOString(), diff --git a/src/git/diff.ts b/src/git/diff.ts index cef64af..efa00c4 100644 --- a/src/git/diff.ts +++ b/src/git/diff.ts @@ -1,7 +1,7 @@ import { execSync, spawnSync } from 'node:child_process'; import { writeFileSync, unlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, normalize } from 'node:path'; export interface DiffResult { diff: string; @@ -9,6 +9,14 @@ export interface DiffResult { staged: boolean; } +export interface CommitResult { + hash: string; + summary: string; + output: string; +} + +const GIT_DIFF_MAX_BUFFER = 100 * 1024 * 1024; + export function checkGitRepo(): void { try { execSync('git rev-parse --git-dir', { encoding: 'utf-8', stdio: 'pipe' }); @@ -18,8 +26,24 @@ export function checkGitRepo(): void { } } +export function hasCommits(): boolean { + try { + const count = execSync('git rev-list --count HEAD', { + encoding: 'utf-8', + stdio: 'pipe', + }).trim(); + + return Number.parseInt(count, 10) > 0; + } catch { + return false; + } +} + export function getStagedDiff(): DiffResult { - const diff = execSync('git diff --cached', { encoding: 'utf-8' }); + const diff = execSync('git diff --cached', { + encoding: 'utf-8', + maxBuffer: GIT_DIFF_MAX_BUFFER, + }); return { diff: diff.trim(), hasChanges: diff.trim().length > 0, @@ -28,7 +52,10 @@ export function getStagedDiff(): DiffResult { } export function getUnstagedDiff(): DiffResult { - const diff = execSync('git diff', { encoding: 'utf-8' }); + const diff = execSync('git diff', { + encoding: 'utf-8', + maxBuffer: GIT_DIFF_MAX_BUFFER, + }); return { diff: diff.trim(), hasChanges: diff.trim().length > 0, @@ -36,8 +63,18 @@ export function getUnstagedDiff(): DiffResult { }; } +function parseCommitOutput(output: string): CommitResult { + const summary = output.trim().split('\n').find(Boolean) ?? ''; + const match = summary.match(/^\[(?:.+\s)?([a-f0-9]{7,})\]\s+(.+)$/i); -export function commit(message: string, body?: string): string { + return { + hash: match?.[1] ?? '', + summary: match?.[2] ?? summary, + output, + }; +} + +export function commit(message: string, body?: string): CommitResult { const fullMessage = body ? `${message}\n\n${body}` : message; const tmpFile = join(tmpdir(), `commit-echo-msg-${process.pid}-${Date.now()}.txt`); try { @@ -51,12 +88,30 @@ export function commit(message: string, body?: string): string { const detail = [result.stderr, result.stdout].filter(Boolean).join('\n').trim(); throw new Error(detail || `git commit exited with code ${result.status}`); } - return result.stdout; + return parseCommitOutput(result.stdout); } finally { - try { unlinkSync(tmpFile); } catch {} + try { + unlinkSync(tmpFile); + } catch {} } } export function getRepoRoot(): string { - return execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim(); + return normalize(execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim()); +} + +export function getBranchName(): string { + try { + return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim(); + } catch { + return 'unknown'; + } +} + +export function getLastCommitMessage(): string { + try { + return execSync('git log -1 --format=%s', { encoding: 'utf-8', stdio: 'pipe' }).trim(); + } catch { + return ''; + } } diff --git a/tests/git-diff.test.mjs b/tests/git-diff.test.mjs new file mode 100644 index 0000000..0c8cbdc --- /dev/null +++ b/tests/git-diff.test.mjs @@ -0,0 +1,352 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + checkGitRepo, + commit, + getBranchName, + getLastCommitMessage, + getRepoRoot, + getStagedDiff, + getUnstagedDiff, + hasCommits, +} from "../dist/git/diff.js"; + +function createTempDir() { + return realpathSync.native(mkdtempSync(join(tmpdir(), "commit-echo-git-diff-test-"))); +} + +function git(args, cwd) { + return execFileSync("git", args, { + cwd, + encoding: "utf-8", + stdio: "pipe", + }); +} + +function createLargeDiffText() { + return "large diff line\n".repeat(90_000); +} + +function initRepo() { + const repoDir = createTempDir(); + + git(["init"], repoDir); + git(["config", "core.fsmonitor", "false"], repoDir); + git(["config", "user.name", "Test User"], repoDir); + git(["config", "user.email", "test@example.com"], repoDir); + + return repoDir; +} + +function withCwd(dir, fn) { + const previousCwd = process.cwd(); + + try { + process.chdir(dir); + return fn(); + } finally { + process.chdir(previousCwd); + } +} + +test("checkGitRepo returns successfully inside a git repo", () => { + const repoDir = initRepo(); + + try { + withCwd(repoDir, () => { + assert.doesNotThrow(() => checkGitRepo()); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test("checkGitRepo throws outside a git repo", () => { + const dir = createTempDir(); + + try { + withCwd(dir, () => { + assert.throws(() => checkGitRepo(), /git repository/i); + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("hasCommits returns false in an empty git repo", () => { + const repoDir = initRepo(); + + try { + withCwd(repoDir, () => { + assert.equal(hasCommits(), false); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test("hasCommits returns true after the first commit", () => { + const repoDir = initRepo(); + + try { + git(["commit", "--allow-empty", "-m", "initial commit"], repoDir); + + withCwd(repoDir, () => { + assert.equal(hasCommits(), true); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test("getStagedDiff returns diff when changes are staged", () => { + const repoDir = initRepo(); + + try { + writeFileSync(join(repoDir, "file.txt"), "hello\n", "utf-8"); + git(["add", "file.txt"], repoDir); + + withCwd(repoDir, () => { + const result = getStagedDiff(); + + assert.equal(result.hasChanges, true); + assert.equal(result.staged, true); + assert.ok(result.diff.includes("diff --git")); + assert.ok(result.diff.includes("+hello")); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test("getStagedDiff handles diffs larger than the default execSync buffer", () => { + const repoDir = initRepo(); + + try { + writeFileSync(join(repoDir, "large.txt"), createLargeDiffText(), "utf-8"); + git(["add", "large.txt"], repoDir); + + withCwd(repoDir, () => { + const result = getStagedDiff(); + + assert.equal(result.hasChanges, true); + assert.equal(result.staged, true); + assert.ok(result.diff.length > 1024 * 1024); + assert.ok(result.diff.includes("large diff line")); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test("getStagedDiff returns empty diff when no changes are staged", () => { + const repoDir = initRepo(); + + try { + withCwd(repoDir, () => { + const result = getStagedDiff(); + + assert.equal(result.diff, ""); + assert.equal(result.hasChanges, false); + assert.equal(result.staged, true); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test("getUnstagedDiff returns diff for unstaged changes", () => { + const repoDir = initRepo(); + + try { + writeFileSync(join(repoDir, "file.txt"), "hello\n", "utf-8"); + git(["add", "file.txt"], repoDir); + git(["commit", "-m", "initial commit"], repoDir); + + writeFileSync(join(repoDir, "file.txt"), "hello\nworld\n", "utf-8"); + + withCwd(repoDir, () => { + const result = getUnstagedDiff(); + + assert.equal(result.hasChanges, true); + assert.equal(result.staged, false); + assert.ok(result.diff.includes("diff --git")); + assert.ok(result.diff.includes("+world")); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test("getUnstagedDiff handles diffs larger than the default execSync buffer", () => { + const repoDir = initRepo(); + + try { + writeFileSync(join(repoDir, "large.txt"), "initial\n", "utf-8"); + git(["add", "large.txt"], repoDir); + git(["commit", "-m", "initial commit"], repoDir); + + writeFileSync(join(repoDir, "large.txt"), createLargeDiffText(), "utf-8"); + + withCwd(repoDir, () => { + const result = getUnstagedDiff(); + + assert.equal(result.hasChanges, true); + assert.equal(result.staged, false); + assert.ok(result.diff.length > 1024 * 1024); + assert.ok(result.diff.includes("large diff line")); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test("commit commits staged changes and returns output with the commit hash", () => { + const repoDir = initRepo(); + + try { + writeFileSync(join(repoDir, "file.txt"), "hello\n", "utf-8"); + git(["add", "file.txt"], repoDir); + + withCwd(repoDir, () => { + const result = commit("test: add file"); + + assert.match(result.hash, /^[a-f0-9]{7,}$/); + assert.equal(result.summary, "test: add file"); + assert.match(result.output, /\[[^\]]*[a-f0-9]{7,}\] test: add file/); + }); + + const hash = git(["rev-parse", "HEAD"], repoDir).trim(); + const log = git(["log", "--oneline", "-1"], repoDir); + + assert.match(hash, /^[a-f0-9]{40}$/); + assert.ok(log.includes("test: add file")); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test("commit parses detached HEAD commit output", () => { + const repoDir = initRepo(); + + try { + writeFileSync(join(repoDir, "file.txt"), "hello\n", "utf-8"); + git(["add", "file.txt"], repoDir); + git(["commit", "-m", "initial commit"], repoDir); + git(["checkout", "--detach"], repoDir); + + writeFileSync(join(repoDir, "file.txt"), "hello\ndetached\n", "utf-8"); + git(["add", "file.txt"], repoDir); + + withCwd(repoDir, () => { + const result = commit("test: detached commit"); + + assert.match(result.hash, /^[a-f0-9]{7,}$/); + assert.equal(result.summary, "test: detached commit"); + assert.match(result.output, /\[(?:detached HEAD|\(HEAD detached at [^)]+\)) [a-f0-9]{7,}\] test: detached commit/); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test("getRepoRoot returns the absolute path of the repository root", () => { + const repoDir = initRepo(); + const nestedDir = join(repoDir, "src", "nested"); + + try { + mkdirSync(nestedDir, { recursive: true }); + + assert.equal(existsSync(nestedDir), true); + + withCwd(nestedDir, () => { + assert.equal(getRepoRoot(), repoDir); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test("getBranchName returns the current branch name", () => { + const repoDir = initRepo(); + + try { + git(["commit", "--allow-empty", "-m", "initial commit"], repoDir); + const branchName = git(["rev-parse", "--abbrev-ref", "HEAD"], repoDir).trim(); + + withCwd(repoDir, () => { + assert.equal(getBranchName(), branchName); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test("getBranchName returns unknown when git command fails", () => { + const dir = createTempDir(); + + try { + withCwd(dir, () => { + assert.equal(getBranchName(), "unknown"); + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("getLastCommitMessage returns the last commit message in a repo with commits", () => { + const repoDir = initRepo(); + + try { + git(["commit", "--allow-empty", "-m", "initial commit"], repoDir); + git(["commit", "--allow-empty", "-m", "second commit"], repoDir); + + withCwd(repoDir, () => { + assert.equal(getLastCommitMessage(), "second commit"); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test("getLastCommitMessage returns empty string when there are no commits or in a non-git repo", () => { + const repoDir = initRepo(); + const nonGitDir = createTempDir(); + + try { + withCwd(repoDir, () => { + assert.equal(getLastCommitMessage(), ""); + }); + + withCwd(nonGitDir, () => { + assert.equal(getLastCommitMessage(), ""); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + rmSync(nonGitDir, { recursive: true, force: true }); + } +}); + +test("commit throws error when nothing is staged", () => { + const repoDir = initRepo(); + + try { + withCwd(repoDir, () => { + assert.throws(() => commit("feat: no changes"), /nothing to commit/i); + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +});