diff --git a/scripts/committer b/scripts/committer index 741e62bb2f2..e11a20d8624 100755 --- a/scripts/committer +++ b/scripts/committer @@ -39,7 +39,47 @@ if [ "$#" -eq 0 ]; then usage fi -files=("$@") +path_exists_or_tracked() { + local candidate=$1 + [ -e "$candidate" ] || git ls-files --error-unmatch -- "$candidate" >/dev/null 2>&1 +} + +append_normalized_file_arg() { + local raw=$1 + + if path_exists_or_tracked "$raw"; then + files+=("$raw") + return + fi + + if [[ "$raw" == *$'\n'* || "$raw" == *$'\r'* ]]; then + local normalized=${raw//$'\r'/} + while IFS= read -r line; do + if [[ "$line" == *[![:space:]]* ]]; then + files+=("$line") + fi + done <<< "$normalized" + return + fi + + if [[ "$raw" == *[[:space:]]* ]]; then + local split_paths=() + # Intentional IFS split for callers that pass a single shell-expanded path blob. + # shellcheck disable=SC2206 + split_paths=($raw) + if [ "${#split_paths[@]}" -gt 1 ]; then + files+=("${split_paths[@]}") + return + fi + fi + + files+=("$raw") +} + +files=() +for raw_arg in "$@"; do + append_normalized_file_arg "$raw_arg" +done # Disallow "." because it stages the entire repository and defeats the helper's safety guardrails. for file in "${files[@]}"; do @@ -129,11 +169,9 @@ run_git_with_lock_retry() { } for file in "${files[@]}"; do - if [ ! -e "$file" ]; then - if ! git ls-files --error-unmatch -- "$file" >/dev/null 2>&1; then - printf 'Error: file not found: %s\n' "$file" >&2 - exit 1 - fi + if ! path_exists_or_tracked "$file"; then + printf 'Error: file not found: %s\n' "$file" >&2 + exit 1 fi done diff --git a/test/scripts/committer.test.ts b/test/scripts/committer.test.ts new file mode 100644 index 00000000000..623cd2e09e6 --- /dev/null +++ b/test/scripts/committer.test.ts @@ -0,0 +1,89 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const scriptPath = path.join(process.cwd(), "scripts", "committer"); +const tempRepos: string[] = []; + +function run(cwd: string, command: string, args: string[]) { + return execFileSync(command, args, { + cwd, + encoding: "utf8", + }).trim(); +} + +function git(cwd: string, ...args: string[]) { + return run(cwd, "git", args); +} + +function createRepo() { + const repo = mkdtempSync(path.join(tmpdir(), "committer-test-")); + tempRepos.push(repo); + + git(repo, "init", "-q"); + git(repo, "config", "user.email", "test@example.com"); + git(repo, "config", "user.name", "Test User"); + writeFileSync(path.join(repo, "seed.txt"), "seed\n"); + git(repo, "add", "seed.txt"); + git(repo, "commit", "-qm", "seed"); + + return repo; +} + +function writeRepoFile(repo: string, relativePath: string, contents: string) { + const fullPath = path.join(repo, relativePath); + mkdirSync(path.dirname(fullPath), { recursive: true }); + writeFileSync(fullPath, contents); +} + +function commitWithHelper(repo: string, commitMessage: string, ...args: string[]) { + return run(repo, "bash", [scriptPath, commitMessage, ...args]); +} + +function committedPaths(repo: string) { + const output = git(repo, "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"); + return output.split("\n").filter(Boolean).toSorted(); +} + +afterEach(() => { + while (tempRepos.length > 0) { + const repo = tempRepos.pop(); + if (repo) { + rmSync(repo, { force: true, recursive: true }); + } + } +}); + +describe("scripts/committer", () => { + it("keeps plain argv paths working", () => { + const repo = createRepo(); + writeRepoFile(repo, "alpha.txt", "alpha\n"); + writeRepoFile(repo, "nested/file with spaces.txt", "beta\n"); + + commitWithHelper(repo, "test: plain argv", "alpha.txt", "nested/file with spaces.txt"); + + expect(committedPaths(repo)).toEqual(["alpha.txt", "nested/file with spaces.txt"]); + }); + + it("accepts a single space-delimited path blob", () => { + const repo = createRepo(); + writeRepoFile(repo, "alpha.txt", "alpha\n"); + writeRepoFile(repo, "beta.txt", "beta\n"); + + commitWithHelper(repo, "test: space blob", "alpha.txt beta.txt"); + + expect(committedPaths(repo)).toEqual(["alpha.txt", "beta.txt"]); + }); + + it("accepts a single newline-delimited path blob", () => { + const repo = createRepo(); + writeRepoFile(repo, "alpha.txt", "alpha\n"); + writeRepoFile(repo, "nested/file with spaces.txt", "beta\n"); + + commitWithHelper(repo, "test: newline blob", "alpha.txt\nnested/file with spaces.txt"); + + expect(committedPaths(repo)).toEqual(["alpha.txt", "nested/file with spaces.txt"]); + }); +});