Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Packages/CrowCore/Sources/CrowCore/CrowAttribution.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ public enum CrowAttribution {
|----------|--------|
| Created (issues, PR descriptions, etc.) | `[🐦‍⬛ Created with Crow via <agent>](https://github.com/radiusmethod/crow)` |
| Reviewed | `[🐦‍⬛ Reviewed by Crow via <agent>](https://github.com/radiusmethod/crow)` |
| Committed (hand-authored commit message) | Trailer block at the end of the message: `Crow-Session: <session-uuid>` and `Co-Authored-By: Claude <noreply@anthropic.com>` 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. |

`<agent>` 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
Expand Down
1 change: 1 addition & 0 deletions Resources/crow-attribution-FOOTER.md.template
Original file line number Diff line number Diff line change
Expand Up @@ -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 <agent>](https://github.com/radiusmethod/crow)` |
| Reviewed | `[🐦‍⬛ Reviewed by Crow via <agent>](https://github.com/radiusmethod/crow)` |
| Committed (hand-authored commit message) | Trailer block at the end of the message: `Crow-Session: <session-uuid>` and `Co-Authored-By: Claude <noreply@anthropic.com>` 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. |

`<agent>` 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
Expand Down
6 changes: 6 additions & 0 deletions Resources/crow-workspace-SKILL.md.template
Original file line number Diff line number Diff line change
Expand Up @@ -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: <uuid>` trailer alongside the standard `Co-Authored-By: Claude` line. The trailer is a stable handle back to session metadata via `crow get-session <uuid>`. 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: <session-uuid>` and `Co-Authored-By: Claude <noreply@anthropic.com>` 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*<uuid>\s*$` (see `IssueTracker.crowSessionTrailerPattern`).

## Multi-Workspace Discovery

### Step 1: Enumerate Workspaces
Expand Down
154 changes: 154 additions & 0 deletions Resources/crow-workspace-setup.sh.template
Original file line number Diff line number Diff line change
Expand Up @@ -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: <uuid>` 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: <uuid>` 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 <noreply@anthropic.com>" \
"$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 <noreply@anthropic.com>" \
"$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() {
Expand Down Expand Up @@ -839,6 +992,7 @@ main() {
setup_worktree
create_session
write_settings_local
install_commit_hook
github_ops
write_prompt
launch_agent
Expand Down
95 changes: 95 additions & 0 deletions Tests/CrowTests/AttributionSkillTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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..<haystack.endIndex)
else { return nil }
return haystack[startRange.lowerBound..<endRange.upperBound]
}

/// The hook function body lives in ONE canonical place. Both setup.sh and
/// the bundled template carry a byte-identical copy. This test is the only
/// thing keeping them honest — if a future edit hits one and not the other,
/// new worktrees scaffolded from the template will silently lose the fix.
@Test func workspaceSetupAndTemplateHookBlocksAreByteIdentical() throws {
let live = try Self.liveSetupSh()
let bundled = try Self.bundledSetupTemplate()
guard let liveBlock = Self.hookBlock(live) else {
Issue.record("Could not locate install_commit_hook block in skills/crow-workspace/setup.sh — section header missing or renamed.")
return
}
guard let bundledBlock = Self.hookBlock(bundled) else {
Issue.record("Could not locate install_commit_hook block in Resources/crow-workspace-setup.sh.template — section header missing or renamed.")
return
}
#expect(liveBlock == bundledBlock,
"install_commit_hook + remove_commit_hook must stay byte-identical between skills/crow-workspace/setup.sh and Resources/crow-workspace-setup.sh.template — the hook body is the load-bearing fix for CROW-518 and a partial copy means new scaffolds will silently lose it.")
}

/// The hook is the safety net, but the worker prompt must still teach the
/// agent to include the trailers explicitly. Without this guidance, a single
/// careless `git commit -m "…"` against a worktree the hook somehow missed
/// (foreign git client, opt-out, racy install) breaks the auto-merge gate.
@Test func liveWorkspaceSkillTeachesTrailerRequirement() throws {
let body = try Self.liveWorkspaceSkill()
#expect(body.contains("Crow-Session: <session-uuid>"),
"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 <noreply@anthropic.com>"),
"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: <session-uuid>"),
"Resources/crow-workspace-SKILL.md.template must instruct workers to include the Crow-Session trailer.")
#expect(body.contains("Co-Authored-By: Claude <noreply@anthropic.com>"),
"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: <session-uuid>"),
"FOOTER.md Committed row must spell out the Crow-Session trailer literally.")
#expect(live.contains("Co-Authored-By: Claude <noreply@anthropic.com>"),
"FOOTER.md Committed row must spell out the Co-Authored-By: Claude trailer literally.")
}
}
1 change: 1 addition & 0 deletions skills/crow-attribution/FOOTER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <agent>](https://github.com/radiusmethod/crow)` |
| Reviewed | `[🐦‍⬛ Reviewed by Crow via <agent>](https://github.com/radiusmethod/crow)` |
| Committed (hand-authored commit message) | Trailer block at the end of the message: `Crow-Session: <session-uuid>` and `Co-Authored-By: Claude <noreply@anthropic.com>` 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. |

`<agent>` 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
Expand Down
2 changes: 2 additions & 0 deletions skills/crow-workspace/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <session-uuid>` and `Co-Authored-By: Claude <noreply@anthropic.com>` 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*<uuid>\s*$` (see `IssueTracker.crowSessionTrailerPattern`).

## Multi-Workspace Discovery

### Step 1: Enumerate Workspaces
Expand Down
Loading
Loading