diff --git a/git-hooks/pre-commit b/git-hooks/pre-commit index 88c73412bac..76fee645db1 100755 --- a/git-hooks/pre-commit +++ b/git-hooks/pre-commit @@ -16,6 +16,18 @@ if [[ ! -f "$FILTER_FILES" ]]; then exit 1 fi +GIT_DIR="$(git rev-parse --git-dir 2>/dev/null || true)" +if [[ -n "$GIT_DIR" ]] && \ + { [[ -f "$GIT_DIR/MERGE_HEAD" ]] || \ + [[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ]] || \ + [[ -f "$GIT_DIR/REVERT_HEAD" ]] || \ + [[ -f "$GIT_DIR/REBASE_HEAD" ]] || \ + [[ -d "$GIT_DIR/rebase-merge" ]] || \ + [[ -d "$GIT_DIR/rebase-apply" ]]; }; then + # Sequencer commits stage the operation result, not just the user's local edits. + exit 0 +fi + # Security: avoid option-injection from malicious file names (e.g. "--all", "--force"). # Robustness: NUL-delimited file list handles spaces/newlines safely. # Compatibility: use read loops instead of `mapfile` so this runs on macOS Bash 3.x. diff --git a/test/git-hooks-pre-commit.test.ts b/test/git-hooks-pre-commit.test.ts index 541a840fbc8..229ae8013f8 100644 --- a/test/git-hooks-pre-commit.test.ts +++ b/test/git-hooks-pre-commit.test.ts @@ -1,6 +1,6 @@ // Git hook tests validate pre-commit hook behavior and scripts. import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { cleanupTempDirs, makeTempRepoRoot } from "./helpers/temp-repo.js"; @@ -39,8 +39,8 @@ const runFailure = ( const failure = error as Error & { status?: number; stderr?: string; stdout?: string }; return { status: failure.status ?? 1, - stderr: String(failure.stderr ?? ""), - stdout: String(failure.stdout ?? ""), + stderr: failure.stderr ?? "", + stdout: failure.stdout ?? "", }; } throw error; @@ -83,6 +83,34 @@ function installPreCommitFixture(dir: string): string { return fakeBinDir; } +function installFormattingRecorder(dir: string): string { + const logPath = path.join(dir, "hook-tool.log"); + writeFileSync( + path.join(dir, "scripts", "pre-commit", "filter-staged-files.mjs"), + `const files = process.argv.slice(3).filter((arg) => arg !== "--"); +for (const file of files) { + if (file.endsWith(".ts")) { + process.stdout.write(file); + process.stdout.write("\0"); + } +} +`, + "utf8", + ); + writeFileSync( + path.join(dir, "scripts", "pre-commit", "run-node-tool.sh"), + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> ${JSON.stringify(logPath)} +`, + { + encoding: "utf8", + mode: 0o755, + }, + ); + return logPath; +} + function installRunNodeToolFixture(dir: string): void { mkdirSync(path.join(dir, "scripts", "pre-commit"), { recursive: true }); symlinkSync( @@ -101,6 +129,13 @@ function splitNonEmptyLines(output: string): string[] { return lines; } +function readFormatterLog(logPath: string): string[] { + if (!existsSync(logPath)) { + return []; + } + return splitNonEmptyLines(readFileSync(logPath, "utf8")); +} + afterEach(() => { cleanupTempDirs(tempDirs); }); @@ -128,6 +163,100 @@ describe("git-hooks/pre-commit (integration)", () => { expect(staged).toEqual(["--all"]); }); + it("skips formatting staged files while a merge commit is in progress", () => { + const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-merge-"); + run(dir, "git", ["init", "-q", "--initial-branch=main"]); + installPreCommitFixture(dir); + const logPath = installFormattingRecorder(dir); + + writeFileSync(path.join(dir, "changed.ts"), "export const value = 1;\n", "utf8"); + run(dir, "git", ["add", "--", "changed.ts"]); + run(dir, "git", [ + "-c", + "user.name=Test User", + "-c", + "user.email=test@example.invalid", + "commit", + "-q", + "-m", + "initial", + ]); + run(dir, "git", ["checkout", "-q", "-b", "side"]); + writeFileSync(path.join(dir, "changed.ts"), "export const value = 2;\n", "utf8"); + run(dir, "git", ["add", "--", "changed.ts"]); + run(dir, "git", [ + "-c", + "user.name=Test User", + "-c", + "user.email=test@example.invalid", + "commit", + "-q", + "-m", + "side change", + ]); + run(dir, "git", ["checkout", "-q", "main"]); + run(dir, "git", [ + "-c", + "user.name=Test User", + "-c", + "user.email=test@example.invalid", + "merge", + "--no-commit", + "--no-ff", + "side", + ]); + + expect(existsSync(path.join(dir, ".git", "MERGE_HEAD"))).toBe(true); + expect(run(dir, "git", ["diff", "--cached", "--name-only"])).toBe("changed.ts"); + + run(dir, "bash", ["git-hooks/pre-commit"]); + + expect(readFormatterLog(logPath)).toEqual([]); + }); + + it.each([ + ["cherry-pick", "CHERRY_PICK_HEAD", "file"], + ["revert", "REVERT_HEAD", "file"], + ["rebase head", "REBASE_HEAD", "file"], + ["merge rebase state", "rebase-merge", "dir"], + ["apply rebase state", "rebase-apply", "dir"], + ])("skips formatting staged files while %s metadata is present", (_label, gitPath, kind) => { + const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-sequencer-"); + run(dir, "git", ["init", "-q", "--initial-branch=main"]); + installPreCommitFixture(dir); + const logPath = installFormattingRecorder(dir); + + writeFileSync(path.join(dir, "changed.ts"), "export const value = 1;\n", "utf8"); + run(dir, "git", ["add", "--", "changed.ts"]); + + const metadataPath = path.join(dir, ".git", gitPath); + if (kind === "dir") { + mkdirSync(metadataPath, { recursive: true }); + } else { + writeFileSync(metadataPath, "sequencer state\n", "utf8"); + } + + run(dir, "bash", ["git-hooks/pre-commit"]); + + expect(readFormatterLog(logPath)).toEqual([]); + }); + + it("still formats staged files during a normal commit", () => { + const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-normal-"); + run(dir, "git", ["init", "-q", "--initial-branch=main"]); + installPreCommitFixture(dir); + const logPath = installFormattingRecorder(dir); + + writeFileSync(path.join(dir, "changed.ts"), "export const value = 1;\n", "utf8"); + run(dir, "git", ["add", "--", "changed.ts"]); + + run(dir, "bash", ["git-hooks/pre-commit"]); + + expect(readFormatterLog(logPath)).toEqual([ + "oxfmt --write --no-error-on-unmatched-pattern changed.ts", + ]); + }); + it("does not run the changed-scope check for non-doc staged changes", () => { const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-no-check-changed-"); run(dir, "git", ["init", "-q", "--initial-branch=main"]);