From a91db7ccbd99e426fdbc26f3d73837e8efaf9561 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Thu, 7 May 2026 15:08:29 -0500 Subject: [PATCH] Add Crow-Session attribution trailer to commits (#246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire setup.sh to write 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 session UUID is a stable handle back to crow session metadata via `crow get-session `. A new top-level attributionTrailers flag on AppConfig (default true) toggles the behavior, exposed in Settings → Automation. setup.sh adds the local settings file to the per-worktree git exclude list so it never gets committed accidentally. Closes #246 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Sources/CrowCore/Models/AppConfig.swift | 11 +++- .../Tests/CrowCoreTests/AppConfigTests.swift | 23 +++++++ .../CrowUI/AutomationSettingsView.swift | 11 ++++ .../CrowUI/Sources/CrowUI/SettingsView.swift | 1 + skills/crow-workspace/SKILL.md | 6 ++ skills/crow-workspace/setup.sh | 62 +++++++++++++++++++ 6 files changed, 112 insertions(+), 2 deletions(-) diff --git a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift index f448c41..77164a6 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift @@ -17,6 +17,10 @@ public struct AppConfig: Codable, Sendable, Equatable { /// Opt into the tmux backend (#198). Surfaced in Settings → Experimental. /// Read once at app launch; takes effect on next relaunch. public var experimentalTmuxBackend: Bool + /// When true, `setup.sh` writes a per-worktree `.claude/settings.local.json` + /// that overrides Claude Code's `attribution.commit` to include the crow + /// session UUID alongside the standard `Co-Authored-By: Claude` trailer. + public var attributionTrailers: Bool public init( workspaces: [WorkspaceInfo] = [], @@ -27,7 +31,8 @@ public struct AppConfig: Codable, Sendable, Equatable { managerAutoPermissionMode: Bool = true, telemetry: TelemetryConfig = TelemetryConfig(), autoRespond: AutoRespondSettings = AutoRespondSettings(), - experimentalTmuxBackend: Bool = false + experimentalTmuxBackend: Bool = false, + attributionTrailers: Bool = true ) { self.workspaces = workspaces self.defaults = defaults @@ -38,6 +43,7 @@ public struct AppConfig: Codable, Sendable, Equatable { self.telemetry = telemetry self.autoRespond = autoRespond self.experimentalTmuxBackend = experimentalTmuxBackend + self.attributionTrailers = attributionTrailers } public init(from decoder: Decoder) throws { @@ -51,10 +57,11 @@ public struct AppConfig: Codable, Sendable, Equatable { telemetry = try container.decodeIfPresent(TelemetryConfig.self, forKey: .telemetry) ?? TelemetryConfig() autoRespond = try container.decodeIfPresent(AutoRespondSettings.self, forKey: .autoRespond) ?? AutoRespondSettings() experimentalTmuxBackend = try container.decodeIfPresent(Bool.self, forKey: .experimentalTmuxBackend) ?? false + attributionTrailers = try container.decodeIfPresent(Bool.self, forKey: .attributionTrailers) ?? true } private enum CodingKeys: String, CodingKey { - case workspaces, defaults, notifications, sidebar, remoteControlEnabled, managerAutoPermissionMode, telemetry, autoRespond, experimentalTmuxBackend + case workspaces, defaults, notifications, sidebar, remoteControlEnabled, managerAutoPermissionMode, telemetry, autoRespond, experimentalTmuxBackend, attributionTrailers } } diff --git a/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift index 6636f1c..1415983 100644 --- a/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift +++ b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift @@ -45,6 +45,7 @@ import Testing #expect(config.remoteControlEnabled == false) #expect(config.managerAutoPermissionMode == true) #expect(config.experimentalTmuxBackend == false) + #expect(config.attributionTrailers == true) } @Test func appConfigRemoteControlRoundTrip() throws { @@ -246,6 +247,28 @@ import Testing #expect(decoded2.experimentalTmuxBackend == false) } +@Test func appConfigAttributionTrailersRoundTrip() throws { + var config = AppConfig() + config.attributionTrailers = false + + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(AppConfig.self, from: data) + #expect(decoded.attributionTrailers == false) + + config.attributionTrailers = true + let data2 = try JSONEncoder().encode(config) + let decoded2 = try JSONDecoder().decode(AppConfig.self, from: data2) + #expect(decoded2.attributionTrailers == true) +} + +@Test func appConfigAttributionTrailersDefaultsTrueWhenKeyMissing() throws { + // Legacy configs without the key opt in by default — matches the behavior + // users see when they install the feature without touching settings. + let json = #"{"workspaces": [], "remoteControlEnabled": false}"#.data(using: .utf8)! + let config = try JSONDecoder().decode(AppConfig.self, from: json) + #expect(config.attributionTrailers == true) +} + @Test func ignoreReviewLabelsRoundTrip() throws { let config = AppConfig( defaults: ConfigDefaults(ignoreReviewLabels: ["dependencies", "renovate", "automated"]) diff --git a/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift b/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift index 7929d53..5f12548 100644 --- a/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift +++ b/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift @@ -9,6 +9,7 @@ public struct AutomationSettingsView: View { @Binding var remoteControlEnabled: Bool @Binding var managerAutoPermissionMode: Bool @Binding var autoRespond: AutoRespondSettings + @Binding var attributionTrailers: Bool var onSave: (() -> Void)? @State private var excludeReviewReposText: String @@ -20,12 +21,14 @@ public struct AutomationSettingsView: View { remoteControlEnabled: Binding, managerAutoPermissionMode: Binding, autoRespond: Binding, + attributionTrailers: Binding, onSave: (() -> Void)? = nil ) { self._defaults = defaults self._remoteControlEnabled = remoteControlEnabled self._managerAutoPermissionMode = managerAutoPermissionMode self._autoRespond = autoRespond + self._attributionTrailers = attributionTrailers self.onSave = onSave self._excludeReviewReposText = State(initialValue: defaults.wrappedValue.excludeReviewRepos.joined(separator: ", ")) self._ignoreReviewLabelsText = State(initialValue: defaults.wrappedValue.ignoreReviewLabels.joined(separator: ", ")) @@ -96,6 +99,14 @@ public struct AutomationSettingsView: View { .foregroundStyle(.secondary) } + Section("Attribution") { + Toggle("Add Crow-Session trailer to commits", isOn: $attributionTrailers) + .onChange(of: attributionTrailers) { _, _ in onSave?() } + Text("Writes a per-worktree .claude/settings.local.json that overrides Claude Code's commit attribution to include a Crow-Session: trailer alongside Co-Authored-By: Claude. Applies to new worktrees only.") + .font(.caption) + .foregroundStyle(.secondary) + } + autoRespondSection } .formStyle(.grouped) diff --git a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift index ad3e76f..2789620 100644 --- a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift @@ -43,6 +43,7 @@ public struct SettingsView: View { remoteControlEnabled: $config.remoteControlEnabled, managerAutoPermissionMode: $config.managerAutoPermissionMode, autoRespond: $config.autoRespond, + attributionTrailers: $config.attributionTrailers, onSave: { save() } ) .tabItem { Label("Automation", systemImage: "bolt.fill") } diff --git a/skills/crow-workspace/SKILL.md b/skills/crow-workspace/SKILL.md index d31a8cb..163e1ab 100644 --- a/skills/crow-workspace/SKILL.md +++ b/skills/crow-workspace/SKILL.md @@ -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). + +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. + ## Multi-Workspace Discovery ### Step 1: Enumerate Workspaces diff --git a/skills/crow-workspace/setup.sh b/skills/crow-workspace/setup.sh index c4a2a49..61bf7f4 100755 --- a/skills/crow-workspace/setup.sh +++ b/skills/crow-workspace/setup.sh @@ -73,6 +73,20 @@ is_remote_control_enabled() { | grep -qE '"remoteControlEnabled"[[:space:]]*:[[:space:]]*true' } +# Read the attributionTrailers flag from {devRoot}/.claude/config.json. +# Defaults to 0 (on) when the file is missing, malformed, or the key is +# absent — matches AppConfig's decodeIfPresent default. Returns 1 only +# when the key is explicitly set to false. +is_attribution_trailers_enabled() { + local config_path="$DEV_ROOT/.claude/config.json" + [[ -f "$config_path" ]] || return 0 + if tr -d '\n' < "$config_path" \ + | grep -qE '"attributionTrailers"[[:space:]]*:[[:space:]]*false'; then + return 1 + fi + return 0 +} + die() { local step="$1" msg="$2" local partial="" @@ -308,6 +322,53 @@ create_session() { fi } +# ─── Per-Worktree Settings (attribution trailer) ───────────────────────────── + +# Write 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. Runs for every +# worktree (primary and secondary) regardless of --skip-launch, so any worktree +# the user later opens with Claude Code picks up the override. +write_settings_local() { + if ! is_attribution_trailers_enabled; then + log "Attribution trailers disabled via config; skipping settings.local.json" + return + fi + + if [[ -z "$SESSION_ID" ]]; then + log "Warning: SESSION_ID not set, skipping settings.local.json" + return + fi + + local settings_dir="$WORKTREE_PATH/.claude" + local settings_path="$settings_dir/settings.local.json" + mkdir -p "$settings_dir" + + # The newlines inside the "commit" string are literal \n escapes in JSON; + # the heredoc passes them through to the file as the two-character sequence. + cat > "$settings_path" <\\nCrow-Session: $SESSION_ID" + } +} +EOF + log "Wrote attribution settings to $settings_path" + + # Belt-and-suspenders: add the file to the per-worktree git exclude so it + # is never accidentally committed even if the repo's .gitignore does not + # already cover .claude/settings.local.json. For worktrees, this lives at + # .git/worktrees//info/exclude — `git rev-parse --git-path` resolves it. + local exclude_file + exclude_file="$(git -C "$WORKTREE_PATH" rev-parse --git-path info/exclude 2>/dev/null)" || return 0 + [[ -n "$exclude_file" ]] || return 0 + mkdir -p "$(dirname "$exclude_file")" + touch "$exclude_file" + if ! grep -qxF '.claude/settings.local.json' "$exclude_file" 2>/dev/null; then + printf '\n# Added by crow setup.sh\n.claude/settings.local.json\n' >> "$exclude_file" + fi +} + # ─── GitHub Housekeeping (best-effort) ─────────────────────────────────────── github_ops() { @@ -527,6 +588,7 @@ main() { setup_worktree create_session + write_settings_local github_ops write_prompt launch_claude