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
11 changes: 9 additions & 2 deletions Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [],
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
}
}

Expand Down
23 changes: 23 additions & 0 deletions Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"])
Expand Down
11 changes: 11 additions & 0 deletions Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,12 +21,14 @@ public struct AutomationSettingsView: View {
remoteControlEnabled: Binding<Bool>,
managerAutoPermissionMode: Binding<Bool>,
autoRespond: Binding<AutoRespondSettings>,
attributionTrailers: Binding<Bool>,
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: ", "))
Expand Down Expand Up @@ -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: <uuid> trailer alongside Co-Authored-By: Claude. Applies to new worktrees only.")
.font(.caption)
.foregroundStyle(.secondary)
}

autoRespondSection
}
.formStyle(.grouped)
Expand Down
1 change: 1 addition & 0 deletions Packages/CrowUI/Sources/CrowUI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") }
Expand Down
6 changes: 6 additions & 0 deletions skills/crow-workspace/SKILL.md
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).

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
Expand Down
62 changes: 62 additions & 0 deletions skills/crow-workspace/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
Expand Down Expand Up @@ -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: <uuid>` 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" <<EOF
{
"attribution": {
"commit": "🤖 Generated with Claude Code, orchestrated by Crow\\n\\nCo-Authored-By: Claude <noreply@anthropic.com>\\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/<name>/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() {
Expand Down Expand Up @@ -527,6 +588,7 @@ main() {

setup_worktree
create_session
write_settings_local
github_ops
write_prompt
launch_claude
Expand Down
Loading