From a5c2bdfdef6754f0ac6ccb08f30a8869eca2b9d1 Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Mon, 15 Jun 2026 23:28:23 -0400 Subject: [PATCH] setup.sh: install prepare-commit-msg hook for Crow-Session trailer (#518) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-merge gate requires a `Crow-Session: ` trailer matching a known session on at least one PR commit. `setup.sh` writes `.claude/settings.local.json` with `attribution.commit` so Claude Code's built-in commit flow lands the trailer — but hand-rolled `git commit -m` / heredoc commits skip that flow and produce trailerless commits. RadiusMethod/corveil#1442 was the live proof: session 4CF06A61-… was active, settings.local.json was correct, and commit 0780a100 still shipped with no `Crow-Session:` line, so the gate refused auto-merge. This change closes the bypass with a per-worktree `prepare-commit-msg` hook that idempotently appends `Crow-Session:` and `Co-Authored-By:` when missing, plus matching worker-prompt guidance so the hook is a safety net rather than the only line of defense. Worktree-scoped via `extensions.worktreeConfig` + per-worktree `core.hooksPath` so it never pollutes sibling worktrees of the same repo. The hook body lives in one canonical heredoc copied verbatim into setup.sh and the bundled template; AttributionSkillTests guards against drift. Opt-out path (`attributionTrailers: false`) also affirmatively removes a pre-existing install. Tests: - skills/crow-workspace/setup_hook_test.sh — 27 cases covering append, idempotence, foreign-trailer preservation, empty body, comment-only body, merge/squash sources, missing session id, worktree isolation (commit in sibling worktree carries no trailer), real `git commit` in the configured worktree carries both trailers, opt-out removes hook + CROW_SESSION_ID. - Tests/CrowTests/AttributionSkillTests.swift — 4 new snapshots: byte-identical install_commit_hook between live and template, live SKILL.md teaches trailer requirement, template SKILL.md teaches trailer requirement, FOOTER carries the Committed row. - Manual #1442 repro: installed the hook in the corveil-1441 sibling worktree under the same 4CF06A61-… session id; `git commit --allow-empty -m "…"` produced a commit carrying both trailers, a second `--amend` left the message byte-identical, and the crow-518 worktree itself stayed hook-free (isolation across repos). Reverted the smoke commit (HEAD back at 0780a100) and uninstalled the hook from corveil-1441 before pushing. 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: DC5F9D9D-4F14-4711-97B2-733C8F489804 --- .../Sources/CrowCore/CrowAttribution.swift | 1 + Resources/crow-attribution-FOOTER.md.template | 1 + Resources/crow-workspace-SKILL.md.template | 6 + Resources/crow-workspace-setup.sh.template | 154 ++++++++++++ Tests/CrowTests/AttributionSkillTests.swift | 95 ++++++++ skills/crow-attribution/FOOTER.md | 1 + skills/crow-workspace/SKILL.md | 2 + skills/crow-workspace/setup.sh | 154 ++++++++++++ skills/crow-workspace/setup_hook_test.sh | 225 ++++++++++++++++++ 9 files changed, 639 insertions(+) create mode 100755 skills/crow-workspace/setup_hook_test.sh diff --git a/Packages/CrowCore/Sources/CrowCore/CrowAttribution.swift b/Packages/CrowCore/Sources/CrowCore/CrowAttribution.swift index d7fa57d7..6843686d 100644 --- a/Packages/CrowCore/Sources/CrowCore/CrowAttribution.swift +++ b/Packages/CrowCore/Sources/CrowCore/CrowAttribution.swift @@ -108,6 +108,7 @@ public enum CrowAttribution { |----------|--------| | Created (issues, PR descriptions, etc.) | `[🐦‍⬛ Created with Crow via ](https://github.com/radiusmethod/crow)` | | Reviewed | `[🐦‍⬛ Reviewed by Crow via ](https://github.com/radiusmethod/crow)` | + | Committed (hand-authored commit message) | Trailer block at the end of the message: `Crow-Session: ` and `Co-Authored-By: Claude ` on their own lines, separated from the body by a blank line. `setup.sh` installs a `prepare-commit-msg` hook (CROW-518) that idempotently fills them in if missing, but include them explicitly when writing the message — the hook is the safety net. | `` above is just a placeholder for *this document* — in the real footer lines your skill receives, the agent name is already filled in. Do not change the URL or diff --git a/Resources/crow-attribution-FOOTER.md.template b/Resources/crow-attribution-FOOTER.md.template index 4ac38f5c..7af56770 100644 --- a/Resources/crow-attribution-FOOTER.md.template +++ b/Resources/crow-attribution-FOOTER.md.template @@ -16,6 +16,7 @@ The link target is always `https://github.com/radiusmethod/crow` — never a for |----------|--------| | Created (issues, PR descriptions, etc.) | `[🐦‍⬛ Created with Crow via ](https://github.com/radiusmethod/crow)` | | Reviewed | `[🐦‍⬛ Reviewed by Crow via ](https://github.com/radiusmethod/crow)` | +| Committed (hand-authored commit message) | Trailer block at the end of the message: `Crow-Session: ` and `Co-Authored-By: Claude ` on their own lines, separated from the body by a blank line. `setup.sh` installs a `prepare-commit-msg` hook (CROW-518) that idempotently fills them in if missing, but include them explicitly when writing the message — the hook is the safety net. | `` above is just a placeholder for *this document* — in the real footer lines your skill receives, the agent name is already filled in. Do not change the URL or diff --git a/Resources/crow-workspace-SKILL.md.template b/Resources/crow-workspace-SKILL.md.template index 3a90f51f..353f9642 100644 --- a/Resources/crow-workspace-SKILL.md.template +++ b/Resources/crow-workspace-SKILL.md.template @@ -44,6 +44,12 @@ Configuration is at `{devRoot}/.claude/config.json` (managed by the Crow app). T } ``` +### Commit Attribution Trailers + +By default, `setup.sh` writes a per-worktree `.claude/settings.local.json` that overrides Claude Code's `attribution.commit` so commits include a `Crow-Session: ` trailer alongside the standard `Co-Authored-By: Claude` line. The trailer is a stable handle back to session metadata via `crow get-session `. To opt out globally, set `"attributionTrailers": false` at the top level of `{devRoot}/.claude/config.json` (also surfaced in Settings → Automation → Attribution). + +**When authoring commits by hand** (`git commit -m "…"`, heredoc, `git commit --amend`), include both `Crow-Session: ` and `Co-Authored-By: Claude ` as trailers at the end of the message. `attribution.commit` only fires for Claude Code's built-in commit flow; hand-rolled commits bypass it. `setup.sh` also installs a per-worktree `prepare-commit-msg` hook (CROW-518) that idempotently appends both trailers when missing — treat that hook as a safety net, not the primary path. Both trailers must be line-anchored at the end of the message; the `crow:merge` auto-merge gate parses `^Crow-Session:\s*\s*$` (see `IssueTracker.crowSessionTrailerPattern`). + ## Multi-Workspace Discovery ### Step 1: Enumerate Workspaces diff --git a/Resources/crow-workspace-setup.sh.template b/Resources/crow-workspace-setup.sh.template index e28ae73c..b8b488d1 100755 --- a/Resources/crow-workspace-setup.sh.template +++ b/Resources/crow-workspace-setup.sh.template @@ -480,6 +480,159 @@ EOF fi } +# ─── Per-Worktree prepare-commit-msg Hook (CROW-518) ───────────────────────── + +# Install a per-worktree `prepare-commit-msg` hook that idempotently appends +# `Crow-Session: ` and `Co-Authored-By: Claude` trailers to commit +# messages when missing. Closes the bypass where Claude Code's +# `attribution.commit` setting only fires for its own commit flow — hand-rolled +# `git commit -m`/heredoc commits skip it and produce trailerless commits, which +# defeats the `crow:merge` auto-merge gate (CROW-518). +# +# Worktree-scoped: enables `extensions.worktreeConfig` on the main repo +# (idempotent, one-time), sets per-worktree `core.hooksPath` to this worktree's +# gitdir hooks dir, and writes the session id to a `CROW_SESSION_ID` file under +# the same gitdir. Sibling worktrees of the same repo carry their own session +# id (or none) and are unaffected. +install_commit_hook() { + if ! is_attribution_trailers_enabled; then + log "Attribution trailers disabled via config; removing any prepare-commit-msg hook" + remove_commit_hook + return + fi + + if [[ -z "$SESSION_ID" ]]; then + log "Warning: SESSION_ID not set, skipping prepare-commit-msg hook" + return + fi + + if ! command -v git >/dev/null 2>&1; then + log "git not found; skipping prepare-commit-msg hook" + return + fi + + local worktree_gitdir hooks_dir session_id_file + worktree_gitdir=$(git -C "$WORKTREE_PATH" rev-parse --git-dir 2>/dev/null) || { + log "Warning: could not resolve worktree gitdir; skipping hook install" + return + } + # `git rev-parse --git-path hooks` resolves to $GIT_COMMON_DIR/hooks (shared + # across worktrees by default), which would pollute every sibling worktree. + # Compose the per-worktree path off --git-dir instead. + hooks_dir="$worktree_gitdir/hooks" + session_id_file=$(git -C "$WORKTREE_PATH" rev-parse --git-path CROW_SESSION_ID 2>/dev/null) || { + log "Warning: could not resolve CROW_SESSION_ID path; skipping hook install" + return + } + + # Enable per-worktree config on the main repo (idempotent). Required before + # `git config --worktree` writes to a per-worktree `config.worktree` file + # instead of falling back to the shared local config. + git -C "$WORKTREE_PATH" config --local extensions.worktreeConfig true \ + >/dev/null 2>&1 || log "Warning: failed to enable extensions.worktreeConfig" + + # Point this worktree at its own hooks dir using an absolute path so the + # resolution does not depend on the agent's cwd at commit time. + git -C "$WORKTREE_PATH" config --worktree core.hooksPath "$hooks_dir" \ + >/dev/null 2>&1 || log "Warning: failed to set per-worktree core.hooksPath" + + mkdir -p "$hooks_dir" + printf '%s\n' "$SESSION_ID" > "$session_id_file" + + local hook_path="$hooks_dir/prepare-commit-msg" + # The hook body is a verbatim heredoc — defined here so it lives in one + # place. Resources/crow-workspace-setup.sh.template carries a byte-identical + # copy; AttributionSkillTests guards against drift. + cat > "$hook_path" <<'CROW_HOOK_EOF' +#!/bin/sh +# Crow prepare-commit-msg hook (CROW-518). +# Idempotently appends `Crow-Session: ` and `Co-Authored-By: Claude` +# trailers to commit messages when missing. Resolves the session id from a +# CROW_SESSION_ID file in this worktree's gitdir, so sibling worktrees that +# don't carry one are no-ops. Never blocks a commit. +set -u + +COMMIT_MSG_FILE="${1:-}" +COMMIT_SOURCE="${2:-}" + +[ -n "$COMMIT_MSG_FILE" ] && [ -f "$COMMIT_MSG_FILE" ] || exit 0 + +# Merge / squash messages get crafted server-side later — leave them alone. +case "$COMMIT_SOURCE" in + merge|squash) exit 0 ;; +esac + +SESSION_ID_FILE="$(git rev-parse --git-path CROW_SESSION_ID 2>/dev/null)" || exit 0 +[ -f "$SESSION_ID_FILE" ] || exit 0 +SESSION_ID="$(tr -d '[:space:]' < "$SESSION_ID_FILE" 2>/dev/null)" +[ -n "$SESSION_ID" ] || exit 0 + +# Skip when the message body has no non-comment content (`git commit -m ""` +# or a `# …` template-only file). Without this, an empty commit attempt +# would grow a body and the resulting "git commit -m ''" no-op-fails path +# would surface a confusing-looking message. +if ! grep -vE '^[[:space:]]*#' "$COMMIT_MSG_FILE" 2>/dev/null \ + | grep -q '[^[:space:]]'; then + exit 0 +fi + +ADD_CROW=1 +# Skip the Crow-Session trailer when ANY Crow-Session line already exists — +# preserves a user-typed trailer even if its UUID differs from ours; the +# crow:merge gate only needs at least one matching known session UUID. +if grep -qE '^Crow-Session:[[:space:]]' "$COMMIT_MSG_FILE" 2>/dev/null; then + ADD_CROW=0 +fi + +ADD_COAUTH=1 +if grep -qE '^Co-Authored-By:[[:space:]]*Claude' "$COMMIT_MSG_FILE" 2>/dev/null; then + ADD_COAUTH=0 +fi + +if [ "$ADD_CROW" -eq 0 ] && [ "$ADD_COAUTH" -eq 0 ]; then + exit 0 +fi + +# Apply remaining additions in a single interpret-trailers call so the +# resulting block is blank-line-separated from the body and line-anchored +# (matches IssueTracker.crowSessionTrailerPattern with .anchorsMatchLines). +if [ "$ADD_CROW" -eq 1 ] && [ "$ADD_COAUTH" -eq 1 ]; then + git interpret-trailers --in-place \ + --trailer "Crow-Session: $SESSION_ID" \ + --trailer "Co-Authored-By: Claude " \ + "$COMMIT_MSG_FILE" 2>/dev/null || true +elif [ "$ADD_CROW" -eq 1 ]; then + git interpret-trailers --in-place \ + --trailer "Crow-Session: $SESSION_ID" \ + "$COMMIT_MSG_FILE" 2>/dev/null || true +elif [ "$ADD_COAUTH" -eq 1 ]; then + git interpret-trailers --in-place \ + --trailer "Co-Authored-By: Claude " \ + "$COMMIT_MSG_FILE" 2>/dev/null || true +fi + +exit 0 +CROW_HOOK_EOF + chmod +x "$hook_path" 2>/dev/null || true + log "Installed prepare-commit-msg hook at $hook_path" +} + +# Remove a previously installed prepare-commit-msg hook and its companion +# CROW_SESSION_ID file. Called when attributionTrailers flips to false so a +# stale install does not keep adding trailers. Leaves +# `extensions.worktreeConfig` and `core.hooksPath` alone — both are harmless +# when the hook file is gone. +remove_commit_hook() { + if ! command -v git >/dev/null 2>&1; then + return + fi + local worktree_gitdir hooks_dir session_id_file + worktree_gitdir=$(git -C "$WORKTREE_PATH" rev-parse --git-dir 2>/dev/null) || return + hooks_dir="$worktree_gitdir/hooks" + session_id_file=$(git -C "$WORKTREE_PATH" rev-parse --git-path CROW_SESSION_ID 2>/dev/null) || return + rm -f "$hooks_dir/prepare-commit-msg" "$session_id_file" 2>/dev/null || true +} + # ─── GitHub Housekeeping (best-effort) ─────────────────────────────────────── github_ops() { @@ -839,6 +992,7 @@ main() { setup_worktree create_session write_settings_local + install_commit_hook github_ops write_prompt launch_agent diff --git a/Tests/CrowTests/AttributionSkillTests.swift b/Tests/CrowTests/AttributionSkillTests.swift index 0537564e..77089915 100644 --- a/Tests/CrowTests/AttributionSkillTests.swift +++ b/Tests/CrowTests/AttributionSkillTests.swift @@ -250,4 +250,99 @@ struct AttributionSkillTests { #expect(!expanded.contains(Self.shellAgentExpression)) #expect(expanded.contains("Created with Crow via OpenAI Codex")) } + + // MARK: - CROW-518: Crow-Session trailer hardening + + private static func liveWorkspaceSkill() throws -> String { + let url = repoRoot().appendingPathComponent("skills/crow-workspace/SKILL.md") + return try String(contentsOf: url, encoding: .utf8) + } + + private static func bundledWorkspaceTemplate() throws -> String { + let url = repoRoot().appendingPathComponent("Resources/crow-workspace-SKILL.md.template") + return try String(contentsOf: url, encoding: .utf8) + } + + private static func liveSetupSh() throws -> String { + let url = repoRoot().appendingPathComponent("skills/crow-workspace/setup.sh") + return try String(contentsOf: url, encoding: .utf8) + } + + private static func bundledSetupTemplate() throws -> String { + let url = repoRoot() + .appendingPathComponent("Resources/crow-workspace-setup.sh.template") + return try String(contentsOf: url, encoding: .utf8) + } + + /// Extract the `install_commit_hook` / `remove_commit_hook` block bounded by + /// the section headers in setup.sh. The block is heredoc-defined and copied + /// verbatim into both the live script and the bundled template; this helper + /// pulls the same span out of either so the drift guard can compare bytes. + private static func hookBlock(_ haystack: String) -> Substring? { + let startMarker = "# ─── Per-Worktree prepare-commit-msg Hook (CROW-518) ─────────────────────────" + let endMarker = "# ─── GitHub Housekeeping (best-effort) ───────────────────────────────────────" + guard let startRange = haystack.range(of: startMarker), + let endRange = haystack.range(of: endMarker, range: startRange.upperBound.."), + "skills/crow-workspace/SKILL.md must instruct workers to include the Crow-Session trailer in hand-authored commits.") + #expect(body.contains("Co-Authored-By: Claude "), + "skills/crow-workspace/SKILL.md must instruct workers to include the Co-Authored-By: Claude trailer in hand-authored commits.") + #expect(body.contains("prepare-commit-msg"), + "skills/crow-workspace/SKILL.md must reference the prepare-commit-msg hook so workers know the safety net exists (CROW-518).") + } + + @Test func bundledWorkspaceTemplateTeachesTrailerRequirement() throws { + let body = try Self.bundledWorkspaceTemplate() + #expect(body.contains("Crow-Session: "), + "Resources/crow-workspace-SKILL.md.template must instruct workers to include the Crow-Session trailer.") + #expect(body.contains("Co-Authored-By: Claude "), + "Resources/crow-workspace-SKILL.md.template must instruct workers to include the Co-Authored-By: Claude trailer.") + #expect(body.contains("prepare-commit-msg"), + "Resources/crow-workspace-SKILL.md.template must reference the prepare-commit-msg hook (CROW-518).") + } + + /// The Committed footer row is the canonical worker-facing surface for the + /// trailer requirement. Guard live + bundled template + Swift constant in + /// one shot — `liveAttributionFooterAndBundledTemplateAreByteIdentical` and + /// `liveAttributionFooterMatchesSwiftConstant` already enforce equality + /// among the three, so this test only needs to assert the content lives in + /// one of them. + @Test func attributionFooterContainsCommittedRow() throws { + let live = try Self.liveAttributionFooter() + #expect(live.contains("| Committed"), + "FOOTER.md must carry a Committed row teaching the Crow-Session trailer requirement (CROW-518).") + #expect(live.contains("Crow-Session: "), + "FOOTER.md Committed row must spell out the Crow-Session trailer literally.") + #expect(live.contains("Co-Authored-By: Claude "), + "FOOTER.md Committed row must spell out the Co-Authored-By: Claude trailer literally.") + } } diff --git a/skills/crow-attribution/FOOTER.md b/skills/crow-attribution/FOOTER.md index 4ac38f5c..7af56770 100644 --- a/skills/crow-attribution/FOOTER.md +++ b/skills/crow-attribution/FOOTER.md @@ -16,6 +16,7 @@ The link target is always `https://github.com/radiusmethod/crow` — never a for |----------|--------| | Created (issues, PR descriptions, etc.) | `[🐦‍⬛ Created with Crow via ](https://github.com/radiusmethod/crow)` | | Reviewed | `[🐦‍⬛ Reviewed by Crow via ](https://github.com/radiusmethod/crow)` | +| Committed (hand-authored commit message) | Trailer block at the end of the message: `Crow-Session: ` and `Co-Authored-By: Claude ` on their own lines, separated from the body by a blank line. `setup.sh` installs a `prepare-commit-msg` hook (CROW-518) that idempotently fills them in if missing, but include them explicitly when writing the message — the hook is the safety net. | `` above is just a placeholder for *this document* — in the real footer lines your skill receives, the agent name is already filled in. Do not change the URL or diff --git a/skills/crow-workspace/SKILL.md b/skills/crow-workspace/SKILL.md index 5392327c..41dde853 100644 --- a/skills/crow-workspace/SKILL.md +++ b/skills/crow-workspace/SKILL.md @@ -52,6 +52,8 @@ By default, `setup.sh` writes a per-worktree `.claude/settings.local.json` that The worktree's settings.local.json is added to that worktree's per-worktree git exclude list, so it stays local even when the repo's tracked `.gitignore` does not already cover it. +**When authoring commits by hand** (`git commit -m "…"`, heredoc, `git commit --amend`), include both `Crow-Session: ` and `Co-Authored-By: Claude ` as trailers at the end of the message. `attribution.commit` only fires for Claude Code's built-in commit flow; hand-rolled commits bypass it. `setup.sh` also installs a per-worktree `prepare-commit-msg` hook (CROW-518) that idempotently appends both trailers when missing — treat that hook as a safety net, not the primary path. Both trailers must be line-anchored at the end of the message; the `crow:merge` auto-merge gate parses `^Crow-Session:\s*\s*$` (see `IssueTracker.crowSessionTrailerPattern`). + ## Multi-Workspace Discovery ### Step 1: Enumerate Workspaces diff --git a/skills/crow-workspace/setup.sh b/skills/crow-workspace/setup.sh index ff4a40e5..5e8509bd 100755 --- a/skills/crow-workspace/setup.sh +++ b/skills/crow-workspace/setup.sh @@ -593,6 +593,159 @@ Crow-Session: $SESSION_ID" fi } +# ─── Per-Worktree prepare-commit-msg Hook (CROW-518) ───────────────────────── + +# Install a per-worktree `prepare-commit-msg` hook that idempotently appends +# `Crow-Session: ` and `Co-Authored-By: Claude` trailers to commit +# messages when missing. Closes the bypass where Claude Code's +# `attribution.commit` setting only fires for its own commit flow — hand-rolled +# `git commit -m`/heredoc commits skip it and produce trailerless commits, which +# defeats the `crow:merge` auto-merge gate (CROW-518). +# +# Worktree-scoped: enables `extensions.worktreeConfig` on the main repo +# (idempotent, one-time), sets per-worktree `core.hooksPath` to this worktree's +# gitdir hooks dir, and writes the session id to a `CROW_SESSION_ID` file under +# the same gitdir. Sibling worktrees of the same repo carry their own session +# id (or none) and are unaffected. +install_commit_hook() { + if ! is_attribution_trailers_enabled; then + log "Attribution trailers disabled via config; removing any prepare-commit-msg hook" + remove_commit_hook + return + fi + + if [[ -z "$SESSION_ID" ]]; then + log "Warning: SESSION_ID not set, skipping prepare-commit-msg hook" + return + fi + + if ! command -v git >/dev/null 2>&1; then + log "git not found; skipping prepare-commit-msg hook" + return + fi + + local worktree_gitdir hooks_dir session_id_file + worktree_gitdir=$(git -C "$WORKTREE_PATH" rev-parse --git-dir 2>/dev/null) || { + log "Warning: could not resolve worktree gitdir; skipping hook install" + return + } + # `git rev-parse --git-path hooks` resolves to $GIT_COMMON_DIR/hooks (shared + # across worktrees by default), which would pollute every sibling worktree. + # Compose the per-worktree path off --git-dir instead. + hooks_dir="$worktree_gitdir/hooks" + session_id_file=$(git -C "$WORKTREE_PATH" rev-parse --git-path CROW_SESSION_ID 2>/dev/null) || { + log "Warning: could not resolve CROW_SESSION_ID path; skipping hook install" + return + } + + # Enable per-worktree config on the main repo (idempotent). Required before + # `git config --worktree` writes to a per-worktree `config.worktree` file + # instead of falling back to the shared local config. + git -C "$WORKTREE_PATH" config --local extensions.worktreeConfig true \ + >/dev/null 2>&1 || log "Warning: failed to enable extensions.worktreeConfig" + + # Point this worktree at its own hooks dir using an absolute path so the + # resolution does not depend on the agent's cwd at commit time. + git -C "$WORKTREE_PATH" config --worktree core.hooksPath "$hooks_dir" \ + >/dev/null 2>&1 || log "Warning: failed to set per-worktree core.hooksPath" + + mkdir -p "$hooks_dir" + printf '%s\n' "$SESSION_ID" > "$session_id_file" + + local hook_path="$hooks_dir/prepare-commit-msg" + # The hook body is a verbatim heredoc — defined here so it lives in one + # place. Resources/crow-workspace-setup.sh.template carries a byte-identical + # copy; AttributionSkillTests guards against drift. + cat > "$hook_path" <<'CROW_HOOK_EOF' +#!/bin/sh +# Crow prepare-commit-msg hook (CROW-518). +# Idempotently appends `Crow-Session: ` and `Co-Authored-By: Claude` +# trailers to commit messages when missing. Resolves the session id from a +# CROW_SESSION_ID file in this worktree's gitdir, so sibling worktrees that +# don't carry one are no-ops. Never blocks a commit. +set -u + +COMMIT_MSG_FILE="${1:-}" +COMMIT_SOURCE="${2:-}" + +[ -n "$COMMIT_MSG_FILE" ] && [ -f "$COMMIT_MSG_FILE" ] || exit 0 + +# Merge / squash messages get crafted server-side later — leave them alone. +case "$COMMIT_SOURCE" in + merge|squash) exit 0 ;; +esac + +SESSION_ID_FILE="$(git rev-parse --git-path CROW_SESSION_ID 2>/dev/null)" || exit 0 +[ -f "$SESSION_ID_FILE" ] || exit 0 +SESSION_ID="$(tr -d '[:space:]' < "$SESSION_ID_FILE" 2>/dev/null)" +[ -n "$SESSION_ID" ] || exit 0 + +# Skip when the message body has no non-comment content (`git commit -m ""` +# or a `# …` template-only file). Without this, an empty commit attempt +# would grow a body and the resulting "git commit -m ''" no-op-fails path +# would surface a confusing-looking message. +if ! grep -vE '^[[:space:]]*#' "$COMMIT_MSG_FILE" 2>/dev/null \ + | grep -q '[^[:space:]]'; then + exit 0 +fi + +ADD_CROW=1 +# Skip the Crow-Session trailer when ANY Crow-Session line already exists — +# preserves a user-typed trailer even if its UUID differs from ours; the +# crow:merge gate only needs at least one matching known session UUID. +if grep -qE '^Crow-Session:[[:space:]]' "$COMMIT_MSG_FILE" 2>/dev/null; then + ADD_CROW=0 +fi + +ADD_COAUTH=1 +if grep -qE '^Co-Authored-By:[[:space:]]*Claude' "$COMMIT_MSG_FILE" 2>/dev/null; then + ADD_COAUTH=0 +fi + +if [ "$ADD_CROW" -eq 0 ] && [ "$ADD_COAUTH" -eq 0 ]; then + exit 0 +fi + +# Apply remaining additions in a single interpret-trailers call so the +# resulting block is blank-line-separated from the body and line-anchored +# (matches IssueTracker.crowSessionTrailerPattern with .anchorsMatchLines). +if [ "$ADD_CROW" -eq 1 ] && [ "$ADD_COAUTH" -eq 1 ]; then + git interpret-trailers --in-place \ + --trailer "Crow-Session: $SESSION_ID" \ + --trailer "Co-Authored-By: Claude " \ + "$COMMIT_MSG_FILE" 2>/dev/null || true +elif [ "$ADD_CROW" -eq 1 ]; then + git interpret-trailers --in-place \ + --trailer "Crow-Session: $SESSION_ID" \ + "$COMMIT_MSG_FILE" 2>/dev/null || true +elif [ "$ADD_COAUTH" -eq 1 ]; then + git interpret-trailers --in-place \ + --trailer "Co-Authored-By: Claude " \ + "$COMMIT_MSG_FILE" 2>/dev/null || true +fi + +exit 0 +CROW_HOOK_EOF + chmod +x "$hook_path" 2>/dev/null || true + log "Installed prepare-commit-msg hook at $hook_path" +} + +# Remove a previously installed prepare-commit-msg hook and its companion +# CROW_SESSION_ID file. Called when attributionTrailers flips to false so a +# stale install does not keep adding trailers. Leaves +# `extensions.worktreeConfig` and `core.hooksPath` alone — both are harmless +# when the hook file is gone. +remove_commit_hook() { + if ! command -v git >/dev/null 2>&1; then + return + fi + local worktree_gitdir hooks_dir session_id_file + worktree_gitdir=$(git -C "$WORKTREE_PATH" rev-parse --git-dir 2>/dev/null) || return + hooks_dir="$worktree_gitdir/hooks" + session_id_file=$(git -C "$WORKTREE_PATH" rev-parse --git-path CROW_SESSION_ID 2>/dev/null) || return + rm -f "$hooks_dir/prepare-commit-msg" "$session_id_file" 2>/dev/null || true +} + # ─── GitHub Housekeeping (best-effort) ─────────────────────────────────────── github_ops() { @@ -962,6 +1115,7 @@ main() { setup_worktree create_session write_settings_local + install_commit_hook github_ops write_prompt launch_agent diff --git a/skills/crow-workspace/setup_hook_test.sh b/skills/crow-workspace/setup_hook_test.sh new file mode 100755 index 00000000..f1c389bb --- /dev/null +++ b/skills/crow-workspace/setup_hook_test.sh @@ -0,0 +1,225 @@ +#!/usr/bin/env bash +# Unit tests for the per-worktree prepare-commit-msg hook (CROW-518). +# +# Sources setup.sh (sourcing is side-effect free thanks to the BASH_SOURCE +# guard at the bottom) and exercises install_commit_hook / remove_commit_hook +# end-to-end against a throwaway git repo with two worktrees. The hook +# binary itself is also driven directly so each acceptance criterion in +# the ticket gets a dedicated row. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SETUP_SH="$SCRIPT_DIR/setup.sh" + +pass=0; fail=0 +check() { # check + if [[ "$2" == "$3" ]]; then + pass=$((pass+1)); echo " ok: $1" + else + fail=$((fail+1)); echo " FAIL: $1"; echo " expected: [$2]"; echo " actual: [$3]" + fi +} +contains() { # contains + if [[ "$2" == *"$3"* ]]; then + pass=$((pass+1)); echo " ok: $1" + else + fail=$((fail+1)); echo " FAIL: $1"; echo " [$2] does not contain [$3]" + fi +} +not_contains() { # not_contains + if [[ "$2" != *"$3"* ]]; then + pass=$((pass+1)); echo " ok: $1" + else + fail=$((fail+1)); echo " FAIL: $1"; echo " [$2] should NOT contain [$3]" + fi +} + +TMP=$(mktemp -d "${TMPDIR:-/tmp}/crow-hook-test.XXXXXX") +trap 'rm -rf "$TMP"' EXIT + +UUID="11112222-3333-4444-5555-666677778888" +OTHER_UUID="aaaa1111-bbbb-2222-cccc-3333dddd4444" + +# ─── Synthetic devRoot + repo with two worktrees ───────────────────────────── + +DEV_ROOT="$TMP/devroot" +mkdir -p "$DEV_ROOT/.claude" +echo '{}' > "$DEV_ROOT/.claude/config.json" + +MAIN_REPO="$TMP/repo" +mkdir -p "$MAIN_REPO" +git -C "$MAIN_REPO" init -q -b main +git -C "$MAIN_REPO" config user.email tester@example.invalid +git -C "$MAIN_REPO" config user.name "Tester" +echo "seed" > "$MAIN_REPO/README.md" +git -C "$MAIN_REPO" add README.md +git -C "$MAIN_REPO" commit -q -m "seed" + +WT_A="$TMP/wt-a" +WT_B="$TMP/wt-b" +git -C "$MAIN_REPO" worktree add -q "$WT_A" -b wt-a +git -C "$MAIN_REPO" worktree add -q "$WT_B" -b wt-b + +# Source the helpers (main is guarded). +# shellcheck disable=SC1090 +source "$SETUP_SH" +# Re-assign globals after sourcing (top-level resets them to ""). +DEV_ROOT="$TMP/devroot" + +# Helpers used across tests. +hook_path() { echo "$(git -C "$1" rev-parse --git-dir)/hooks/prepare-commit-msg"; } +session_file() { git -C "$1" rev-parse --git-path CROW_SESSION_ID; } + +# Drive the hook directly the way `git commit` would. +# Usage: run_hook [] +run_hook() { + ( cd "$1" && "$(hook_path "$1")" "$2" "${3:-}" ) +} + +# ─── Install: happy path under wt-a ────────────────────────────────────────── +echo "== install wt-a ==" +WORKTREE_PATH="$WT_A" +SESSION_ID="$UUID" +install_commit_hook + +check "hook installed in wt-a" "yes" \ + "$([[ -x "$(hook_path "$WT_A")" ]] && echo yes || echo no)" +check "CROW_SESSION_ID written in wt-a" "$UUID" \ + "$(tr -d '[:space:]' < "$(session_file "$WT_A")")" +check "core.hooksPath set per-worktree" "$(git -C "$WT_A" rev-parse --git-path hooks)" \ + "$(git -C "$WT_A" config --get core.hooksPath)" +check "extensions.worktreeConfig enabled on main" "true" \ + "$(git -C "$MAIN_REPO" config --get extensions.worktreeConfig)" + +# ─── Test A: body without trailers gets both appended ──────────────────────── +echo "== test A: append both trailers ==" +MSG_A="$TMP/msg-a" +printf 'feat: add thing\n\nbody text\n' > "$MSG_A" +run_hook "$WT_A" "$MSG_A" +actual_a="$(cat "$MSG_A")" +contains "test A: Crow-Session trailer appended" "$actual_a" "Crow-Session: $UUID" +contains "test A: Co-Authored-By trailer appended" "$actual_a" \ + "Co-Authored-By: Claude " +contains "test A: subject preserved" "$actual_a" "feat: add thing" +contains "test A: body preserved" "$actual_a" "body text" + +# ─── Test B: existing trailers → no duplication ────────────────────────────── +echo "== test B: idempotent on already-trailered message ==" +MSG_B="$TMP/msg-b" +cat > "$MSG_B" < +EOF +before_b="$(cat "$MSG_B")" +run_hook "$WT_A" "$MSG_B" +after_b="$(cat "$MSG_B")" +check "test B: message unchanged" "$before_b" "$after_b" +check "test B: exactly one Crow-Session line" "1" \ + "$(grep -cE '^Crow-Session:' "$MSG_B" | tr -d '[:space:]')" +check "test B: exactly one Co-Authored-By line" "1" \ + "$(grep -cE '^Co-Authored-By: Claude' "$MSG_B" | tr -d '[:space:]')" + +# ─── Test B2: existing Crow-Session w/ DIFFERENT uuid → preserved, not dupped ─ +echo "== test B2: foreign Crow-Session is preserved ==" +MSG_B2="$TMP/msg-b2" +cat > "$MSG_B2" <" + +# ─── Test D: empty message → no-op ─────────────────────────────────────────── +echo "== test D: empty + comment-only messages ==" +MSG_D_EMPTY="$TMP/msg-d-empty" +: > "$MSG_D_EMPTY" +run_hook "$WT_A" "$MSG_D_EMPTY" +check "test D: empty message stays empty" "" "$(cat "$MSG_D_EMPTY")" + +MSG_D_COMMENT="$TMP/msg-d-comment" +printf '# please enter the commit message\n# lines starting with # are ignored\n' > "$MSG_D_COMMENT" +before_d="$(cat "$MSG_D_COMMENT")" +run_hook "$WT_A" "$MSG_D_COMMENT" +check "test D: comment-only file unchanged" "$before_d" "$(cat "$MSG_D_COMMENT")" + +# ─── Test E: merge / squash sources → no-op ───────────────────────────────── +echo "== test E: merge/squash sources ==" +MSG_E_MERGE="$TMP/msg-e-merge" +printf 'Merge branch foo\n\nautomatic merge\n' > "$MSG_E_MERGE" +before_e_merge="$(cat "$MSG_E_MERGE")" +run_hook "$WT_A" "$MSG_E_MERGE" merge +check "test E: merge commit unchanged" "$before_e_merge" "$(cat "$MSG_E_MERGE")" + +MSG_E_SQUASH="$TMP/msg-e-squash" +printf 'Squashed\n\nbody\n' > "$MSG_E_SQUASH" +before_e_squash="$(cat "$MSG_E_SQUASH")" +run_hook "$WT_A" "$MSG_E_SQUASH" squash +check "test E: squash commit unchanged" "$before_e_squash" "$(cat "$MSG_E_SQUASH")" + +# ─── Test F: empty CROW_SESSION_ID → no-op ────────────────────────────────── +echo "== test F: empty CROW_SESSION_ID file ==" +: > "$(session_file "$WT_A")" +MSG_F="$TMP/msg-f" +printf 'subject\n\nbody\n' > "$MSG_F" +before_f="$(cat "$MSG_F")" +run_hook "$WT_A" "$MSG_F" +check "test F: empty session id leaves message alone" "$before_f" "$(cat "$MSG_F")" +# Restore the session id file for the rest of the suite. +printf '%s\n' "$UUID" > "$(session_file "$WT_A")" + +# ─── Test G: worktree isolation ───────────────────────────────────────────── +echo "== test G: sibling worktree is unaffected ==" +# wt-b never had install_commit_hook called → no per-worktree hooksPath, +# no CROW_SESSION_ID, no hook file. A real commit there must NOT carry +# Crow-Session. +echo "isolation seed" > "$WT_B/isolation" +git -C "$WT_B" add isolation +git -C "$WT_B" commit -q -m "isolation: no trailer please" +WT_B_MSG="$(git -C "$WT_B" log -1 --format='%B')" +not_contains "test G: wt-b commit has no Crow-Session trailer" "$WT_B_MSG" "Crow-Session" +not_contains "test G: wt-b commit has no Co-Authored-By trailer" "$WT_B_MSG" "Co-Authored-By: Claude" +# Sanity: wt-a's CROW_SESSION_ID file is NOT visible from wt-b's gitdir. +check "test G: wt-b gitdir has no CROW_SESSION_ID" "no" \ + "$([[ -f "$(session_file "$WT_B")" ]] && echo yes || echo no)" + +# ─── Test G2: wt-a commits via `git commit` DO carry trailers ─────────────── +echo "== test G2: real commit in wt-a wears both trailers ==" +echo "real seed" > "$WT_A/real" +git -C "$WT_A" add real +git -C "$WT_A" -c user.email=a@a.invalid -c user.name=A commit -q -m "real: just subject" +WT_A_MSG="$(git -C "$WT_A" log -1 --format='%B')" +contains "test G2: wt-a real commit has Crow-Session" "$WT_A_MSG" "Crow-Session: $UUID" +contains "test G2: wt-a real commit has Co-Authored-By" "$WT_A_MSG" \ + "Co-Authored-By: Claude " + +# ─── Test C: attributionTrailers:false → install removes pre-existing hook ── +echo "== test C: opt-out removes hook + CROW_SESSION_ID ==" +cat > "$DEV_ROOT/.claude/config.json" <<'JSON' +{"attributionTrailers": false} +JSON +# Confirm the hook is currently in place from earlier tests. +check "test C: hook present before opt-out call" "yes" \ + "$([[ -f "$(hook_path "$WT_A")" ]] && echo yes || echo no)" +install_commit_hook +check "test C: hook removed when opt-out" "no" \ + "$([[ -f "$(hook_path "$WT_A")" ]] && echo yes || echo no)" +check "test C: CROW_SESSION_ID removed when opt-out" "no" \ + "$([[ -f "$(session_file "$WT_A")" ]] && echo yes || echo no)" + +# Restore the enabled config so any later tests (or reruns) work. +echo '{}' > "$DEV_ROOT/.claude/config.json" + +# ─── Summary ──────────────────────────────────────────────────────────────── +echo +echo "── prepare-commit-msg hook tests: $pass passed, $fail failed ──" +[[ "$fail" -eq 0 ]]