Skip to main content

git_paw/
agents.rs

1//! AGENTS.md generation and injection.
2//!
3//! Provides marker-based section injection into `AGENTS.md` files.
4//! Core logic uses pure `&str → String` functions for testability,
5//! with a thin I/O wrapper for file operations.
6
7use std::fmt::Write;
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::sync::LazyLock;
11
12use crate::error::PawError;
13use crate::git::{exclude_from_git, no_assume_unchanged};
14
15/// Matches `PAW_SUPERVISOR_PID=<digits>` lines inside the agent marker file.
16///
17/// Compiled once on first use via `LazyLock`. The `expect` is allowed by the
18/// project's panic-surface rules because the pattern is a static literal and
19/// the failure is unreachable at runtime.
20static SUPERVISOR_PID_REGEX: LazyLock<regex::Regex> =
21    LazyLock::new(|| regex::Regex::new(r"PAW_SUPERVISOR_PID=\d+").expect("static regex compiles"));
22
23/// Matches `PAW_LAST_VERIFIED_COMMIT=<value>` lines inside the agent marker file.
24static LAST_VERIFIED_COMMIT_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
25    regex::Regex::new(r"PAW_LAST_VERIFIED_COMMIT=[^\n]+").expect("static regex compiles")
26});
27
28/// Start marker prefix used for detection (ignores trailing comment text).
29const START_MARKER_PREFIX: &str = "<!-- git-paw:start";
30
31/// Full start marker line.
32const START_MARKER: &str = "<!-- git-paw:start — managed by git-paw, do not edit manually -->";
33
34/// End marker line.
35const END_MARKER: &str = "<!-- git-paw:end -->";
36
37/// Marker that identifies git-paw-managed git hook content.
38///
39/// When a project already has a `post-commit` or `pre-push` hook, git-paw
40/// chains its content after the existing hook, wrapped in marker lines so
41/// subsequent installs don't duplicate the block.
42const HOOK_START_MARKER: &str = "# >>> git-paw managed hook >>>";
43const HOOK_END_MARKER: &str = "# <<< git-paw managed hook <<<";
44
45/// Returns `true` if `content` contains a git-paw section start marker.
46pub fn has_git_paw_section(content: &str) -> bool {
47    content
48        .lines()
49        .any(|line| line.starts_with(START_MARKER_PREFIX))
50}
51
52/// Replaces the git-paw section (start marker through end marker, inclusive)
53/// with `new_section`. If the end marker is missing, replaces from the start
54/// marker to EOF.
55pub fn replace_git_paw_section(content: &str, new_section: &str) -> String {
56    let lines: Vec<&str> = content.lines().collect();
57
58    let Some(start_idx) = lines
59        .iter()
60        .position(|l| l.starts_with(START_MARKER_PREFIX))
61    else {
62        return content.to_string();
63    };
64
65    let end_idx = lines[start_idx..]
66        .iter()
67        .position(|l| l.contains(END_MARKER))
68        .map(|rel| start_idx + rel);
69
70    let mut result = String::new();
71
72    // Content before the start marker
73    for line in &lines[..start_idx] {
74        result.push_str(line);
75        result.push('\n');
76    }
77
78    // The new section
79    result.push_str(new_section);
80
81    // Content after the end marker (if it exists)
82    if let Some(end) = end_idx
83        && end + 1 < lines.len()
84    {
85        for line in &lines[end + 1..] {
86            result.push_str(line);
87            result.push('\n');
88        }
89    }
90
91    // Preserve trailing newline behavior of original if we replaced to EOF
92    if end_idx.is_none() && content.ends_with('\n') && !result.ends_with('\n') {
93        result.push('\n');
94    }
95
96    result
97}
98
99/// Injects `section` into `content`: appends if no git-paw section exists,
100/// replaces the existing one if present.
101pub fn inject_into_content(content: &str, section: &str) -> String {
102    if content.is_empty() {
103        return section.to_string();
104    }
105
106    if has_git_paw_section(content) {
107        return replace_git_paw_section(content, section);
108    }
109
110    // Append with proper spacing
111    let mut result = content.to_string();
112    if !result.ends_with('\n') {
113        result.push('\n');
114    }
115    result.push('\n');
116    result.push_str(section);
117    result
118}
119
120/// Reads a file (or treats a missing file as empty), injects `section`,
121/// and writes the result back.
122/// Per-worktree assignment context passed by the session launch flow.
123pub struct WorktreeAssignment {
124    /// The branch this worktree is checked out on.
125    pub branch: String,
126    /// The CLI name (e.g. "claude", "cursor") running in this worktree.
127    pub cli: String,
128    /// Optional spec content to embed in the assignment section.
129    pub spec_content: Option<String>,
130    /// Optional list of files this worktree owns.
131    pub owned_files: Option<Vec<String>>,
132    /// Optional rendered skill content to inject into the assignment section.
133    pub skill_content: Option<String>,
134    /// Optional inter-agent rules block (file ownership, never-push, proactive
135    /// status publishing, cherry-pick) injected by the supervisor. When `None`,
136    /// the generated section omits the `## Inter-Agent Rules` subsection
137    /// entirely so non-supervisor sessions are byte-identical to pre-supervisor
138    /// output.
139    pub inter_agent_rules: Option<String>,
140}
141
142/// Builds the standard inter-agent rules block that the supervisor injects
143/// into every coding agent's `AGENTS.md`.
144///
145/// `branches` is the list of all peer branches in the session — used to make
146/// the file-ownership constraint explicit ("don't touch files owned by ...").
147pub fn build_inter_agent_rules(branches: &[&str]) -> String {
148    let mut peers = String::new();
149    for (i, b) in branches.iter().enumerate() {
150        if i > 0 {
151            peers.push_str(", ");
152        }
153        peers.push('`');
154        peers.push_str(b);
155        peers.push('`');
156    }
157
158    let mut out = String::new();
159    out.push_str("These rules apply to every agent in this supervisor session. ");
160    out.push_str("Violating them blocks the supervisor's verification step.\n\n");
161    out.push_str("- **File ownership is exclusive.** You MUST NOT edit files owned by ");
162    out.push_str("other agents. Peers in this session: ");
163    out.push_str(&peers);
164    out.push_str(". Stay inside your declared file ownership list.\n");
165    out.push_str("- **Commit, never push.** You MUST commit to your worktree branch and ");
166    out.push_str("MUST NOT `git push` to any remote. The supervisor merges branches.\n");
167    out.push_str("- **Status publishing is automatic.** git-paw watches your worktree and ");
168    out.push_str("publishes `agent.status` with `modified_files` for you whenever your git ");
169    out.push_str("status changes. A `post-commit` hook publishes `agent.artifact` on each ");
170    out.push_str("commit. You do not need to curl these yourself.\n");
171    out.push_str("- **Watch peer status.** Poll `/messages/{{BRANCH_ID}}` to see peer ");
172    out.push_str("`agent.artifact` messages so you detect conflicts before the supervisor does.\n");
173    out.push_str("- **Cherry-pick peer artifacts.** When you are blocked on a peer, publish ");
174    out.push_str("`agent.blocked` and cherry-pick their commit when their artifact arrives ");
175    out.push_str("in your inbox. Do not wait for the supervisor to merge.\n");
176    out.push_str("- **Match spec field names exactly.** When implementing a spec, use the ");
177    out.push_str("exact field, function, and message names from the spec — do not rename ");
178    out.push_str("them. The supervisor's spec audit will reject mismatched names.\n");
179    out
180}
181
182/// Generates a marker-delimited assignment section for a worktree's AGENTS.md.
183pub fn generate_worktree_section(assignment: &WorktreeAssignment) -> String {
184    let mut section = String::new();
185    section.push_str(START_MARKER);
186    section.push('\n');
187    section.push('\n');
188    section.push_str("## git-paw Session Assignment\n");
189    section.push('\n');
190    let _ = writeln!(section, "- **Branch:** `{}`", assignment.branch);
191    let _ = writeln!(section, "- **CLI:** {}", assignment.cli);
192
193    if let Some(ref spec) = assignment.spec_content {
194        section.push('\n');
195        section.push_str("### Spec\n");
196        section.push('\n');
197        section.push_str(spec);
198        if !spec.ends_with('\n') {
199            section.push('\n');
200        }
201    }
202
203    if let Some(ref files) = assignment.owned_files {
204        section.push('\n');
205        section.push_str("### File Ownership\n");
206        section.push('\n');
207        for file in files {
208            let _ = writeln!(section, "- `{file}`");
209        }
210    }
211
212    if let Some(ref skill) = assignment.skill_content {
213        section.push('\n');
214        section.push_str(skill);
215        if !skill.ends_with('\n') {
216            section.push('\n');
217        }
218    }
219
220    if let Some(ref rules) = assignment.inter_agent_rules {
221        section.push('\n');
222        section.push_str("## Inter-Agent Rules\n");
223        section.push('\n');
224        section.push_str(rules);
225        if !rules.ends_with('\n') {
226            section.push('\n');
227        }
228    }
229
230    section.push('\n');
231    section.push_str(END_MARKER);
232    section.push('\n');
233    section
234}
235
236/// Worktree-relative path of the gitignored **sidecar** instruction file that
237/// carries git-paw's managed block.
238///
239/// The combined view — the project's `AGENTS.md` content followed by the
240/// per-worktree assignment section — is written here, never to the tracked
241/// `AGENTS.md`. `.git-paw/` is already gitignored and used for session
242/// learnings, logs, and helper scripts, so the ephemeral injection lives
243/// alongside them and is never committed.
244pub const SIDECAR_REL_PATH: &str = ".git-paw/AGENTS.local.md";
245
246/// Reads the root repo's `AGENTS.md`, injects the worktree assignment section,
247/// and writes the combined view to a gitignored **sidecar** instruction file
248/// ([`SIDECAR_REL_PATH`]) in the worktree — never the tracked `AGENTS.md`.
249///
250/// The sidecar keeps the ephemeral per-session injection out of version
251/// control entirely, so the tracked `AGENTS.md` stays a normal committable
252/// file: a hand edit to it shows in `git status` and stages via `git add -A`.
253/// This resolves the v0.7.0 footgun (finding F10), where a file-level
254/// `git update-index --assume-unchanged AGENTS.md` bit silently hid *every*
255/// edit to the file — including legitimate ones — and blocked agents from
256/// committing real `AGENTS.md` changes.
257///
258/// The launched CLI is pointed at the sidecar's combined view via its boot
259/// prompt (see `build_task_prompt`), since the supported CLIs only auto-load
260/// the worktree-root `AGENTS.md`.
261///
262/// Two steps make the upgrade self-healing for worktrees set up by an older
263/// git-paw version:
264/// 1. Any stale `assume-unchanged` bit on the tracked `AGENTS.md` is cleared
265///    (`git update-index --no-assume-unchanged AGENTS.md`) so the file becomes
266///    committable again.
267/// 2. The sidecar path is added to the worktree ignore set, so the injection
268///    is never accidentally committed.
269pub fn setup_worktree_agents_md(
270    repo_root: &Path,
271    worktree_root: &Path,
272    assignment: &WorktreeAssignment,
273) -> Result<(), PawError> {
274    let root_agents = repo_root.join("AGENTS.md");
275    let root_content = match fs::read_to_string(&root_agents) {
276        Ok(c) => c,
277        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
278        Err(e) => {
279            return Err(PawError::AgentsMdError(format!(
280                "failed to read '{}': {e}",
281                root_agents.display()
282            )));
283        }
284    };
285
286    let section = generate_worktree_section(assignment);
287    let output = inject_into_content(&root_content, &section);
288
289    // Write the combined view to the gitignored sidecar, NOT the tracked
290    // AGENTS.md. Create the `.git-paw/` parent directory if it is absent.
291    let sidecar = worktree_root.join(SIDECAR_REL_PATH);
292    if let Some(parent) = sidecar.parent() {
293        fs::create_dir_all(parent).map_err(|e| {
294            PawError::AgentsMdError(format!("failed to create '{}': {e}", parent.display()))
295        })?;
296    }
297    fs::write(&sidecar, &output).map_err(|e| {
298        PawError::AgentsMdError(format!("failed to write '{}': {e}", sidecar.display()))
299    })?;
300
301    // Self-healing: clear any stale assume-unchanged bit a prior git-paw
302    // version set on the tracked AGENTS.md so it is committable again. The
303    // tracked AGENTS.md is otherwise left untouched — git-paw no longer
304    // injects into it, hides it from `git status`, or excludes it.
305    let _ = no_assume_unchanged(worktree_root, "AGENTS.md");
306
307    // Keep the ephemeral sidecar out of git. `.git-paw/AGENTS.local.md` is
308    // gitignored at the repo level too, but adding the explicit worktree-level
309    // entry is idempotent and pins the guarantee even for repos whose
310    // `.gitignore` predates this file.
311    exclude_from_git(worktree_root, SIDECAR_REL_PATH)?;
312
313    Ok(())
314}
315
316/// Returns the path to the agent marker file for a given worktree.
317pub fn get_agent_marker_path(worktree: &Path) -> Result<PathBuf, PawError> {
318    let linked_git_dir = git_rev_parse_path(worktree, "--git-dir")?;
319    Ok(linked_git_dir.join("paw-agent-id"))
320}
321
322/// Builds the agent marker file content with optional extended fields.
323///
324/// Basic format (always included):
325/// ```text
326/// PAW_AGENT_ID=<agent_id>
327/// PAW_BROKER_URL=<broker_url>
328/// ```
329///
330/// Extended format (optional fields):
331/// ```text
332/// PAW_SUPERVISOR_PID=<pid>
333/// PAW_LAST_VERIFIED_COMMIT=<commit_hash>
334/// PAW_SESSION_NAME=<session_name>
335/// PAW_TIMESTAMP=<iso_timestamp>
336/// ```
337pub fn build_agent_marker(
338    broker_url: &str,
339    agent_id: &str,
340    supervisor_pid: Option<u32>,
341    last_verified_commit: Option<&str>,
342    session_name: Option<&str>,
343) -> String {
344    let mut marker = format!("PAW_AGENT_ID={agent_id}\nPAW_BROKER_URL={broker_url}\n");
345
346    // Add optional extended fields
347    if let Some(pid) = supervisor_pid {
348        let _ = writeln!(marker, "PAW_SUPERVISOR_PID={pid}");
349    }
350    if let Some(commit) = last_verified_commit {
351        let _ = writeln!(marker, "PAW_LAST_VERIFIED_COMMIT={commit}");
352    }
353    if let Some(session) = session_name {
354        let _ = writeln!(marker, "PAW_SESSION_NAME={session}");
355    }
356
357    // Always add timestamp for debugging/tracing
358    let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
359    let _ = writeln!(marker, "PAW_TIMESTAMP={timestamp}");
360
361    marker
362}
363
364/// Updates an existing agent marker file with additional fields.
365///
366/// This allows adding supervisor-specific information after the initial marker creation.
367pub fn update_agent_marker(
368    marker_path: &Path,
369    supervisor_pid: Option<u32>,
370    last_verified_commit: Option<&str>,
371) -> Result<(), PawError> {
372    let content = fs::read_to_string(marker_path)
373        .map_err(|e| PawError::AgentsMdError(format!("failed to read marker file: {e}")))?;
374
375    let mut updated = content;
376
377    // Update supervisor PID if provided
378    if let Some(pid) = supervisor_pid {
379        if updated.contains("PAW_SUPERVISOR_PID=") {
380            // Replace existing PID
381            updated = SUPERVISOR_PID_REGEX
382                .replace(&updated, &format!("PAW_SUPERVISOR_PID={pid}"))
383                .to_string();
384        } else {
385            // Add new PID line
386            let _ = write!(updated, "\nPAW_SUPERVISOR_PID={pid}");
387        }
388    }
389
390    // Update last verified commit if provided
391    if let Some(commit) = last_verified_commit {
392        if updated.contains("PAW_LAST_VERIFIED_COMMIT=") {
393            // Replace existing commit
394            updated = LAST_VERIFIED_COMMIT_REGEX
395                .replace(&updated, &format!("PAW_LAST_VERIFIED_COMMIT={commit}"))
396                .to_string();
397        } else {
398            // Add new commit line
399            let _ = write!(updated, "\nPAW_LAST_VERIFIED_COMMIT={commit}");
400        }
401    }
402
403    fs::write(marker_path, updated)
404        .map_err(|e| PawError::AgentsMdError(format!("failed to update marker file: {e}")))?;
405
406    Ok(())
407}
408
409/// Builds the dispatcher `post-commit` hook installed at the main repo's
410/// `.git/hooks/post-commit`.
411///
412/// Linked git worktrees share the main repo's `hooks/` directory (git-worktree
413/// does not use per-worktree hook directories unless `extensions.worktreeConfig`
414/// is enabled, which is an intrusive repo-wide setting). Instead we install a
415/// single dispatcher and store per-worktree `agent_id` and `broker_url` in
416/// `$GIT_DIR/paw-agent-id` — `$GIT_DIR` is set by git to the correct
417/// per-worktree gitdir when the hook runs, so the dispatcher reads the right
418/// file for whichever worktree just committed.
419fn build_post_commit_dispatcher_hook() -> String {
420    format!(
421        "#!/bin/sh\n\
422         {HOOK_START_MARKER}\n\
423         # Dispatcher: reads the per-worktree paw-agent-id marker and publishes\n\
424         # agent.artifact to the git-paw broker. Resolve the gitdir via\n\
425         # rev-parse with a GIT_DIR fallback (git does not always export it).\n\
426         PAW_GD=\"${{GIT_DIR:-$(git rev-parse --git-dir 2>/dev/null)}}\"\n\
427         if [ -n \"$PAW_GD\" ] && [ -f \"$PAW_GD/paw-agent-id\" ]; then\n\
428             . \"$PAW_GD/paw-agent-id\"\n\
429             FILES=$(git diff HEAD~1 --name-only 2>/dev/null | awk '{{printf \"%s\\\"%s\\\"\", (NR>1?\",\":\"\"), $0}}')\n\
430             curl -s -X POST \"$PAW_BROKER_URL/publish\" \\\n\
431                 -H 'Content-Type: application/json' \\\n\
432                 -d \"{{\\\"type\\\":\\\"agent.artifact\\\",\\\"agent_id\\\":\\\"$PAW_AGENT_ID\\\",\\\"payload\\\":{{\\\"status\\\":\\\"committed\\\",\\\"exports\\\":[],\\\"modified_files\\\":[$FILES]}}}}\" \\\n\
433                 >/dev/null 2>&1 || true\n\
434             # Branch-mismatch detection (detection without enforcement — fires\n\
435             # regardless of PAW_STRICT_BRANCH_GUARD; the pre-commit hook owns\n\
436             # blocking). Publishes agent.feedback + an agent.learning record\n\
437             # (category permission_pattern) identifying the contamination.\n\
438             if [ -n \"$PAW_EXPECTED_BRANCH\" ]; then\n\
439                 PAW_CUR=$(git symbolic-ref --short HEAD 2>/dev/null)\n\
440                 if [ -n \"$PAW_CUR\" ] && [ \"$PAW_CUR\" != \"$PAW_EXPECTED_BRANCH\" ]; then\n\
441                     PAW_SHA=$(git rev-parse HEAD 2>/dev/null)\n\
442                     curl -s -X POST \"$PAW_BROKER_URL/publish\" \\\n\
443                         -H 'Content-Type: application/json' \\\n\
444                         -d \"{{\\\"type\\\":\\\"agent.feedback\\\",\\\"agent_id\\\":\\\"$PAW_AGENT_ID\\\",\\\"payload\\\":{{\\\"from\\\":\\\"branch-guard\\\",\\\"errors\\\":[\\\"commit $PAW_SHA advanced '$PAW_CUR' but this worktree is for '$PAW_EXPECTED_BRANCH'; cherry-pick onto '$PAW_EXPECTED_BRANCH' and reset '$PAW_CUR'\\\"]}}}}\" \\\n\
445                         >/dev/null 2>&1 || true\n\
446                     curl -s -X POST \"$PAW_BROKER_URL/publish\" \\\n\
447                         -H 'Content-Type: application/json' \\\n\
448                         -d \"{{\\\"type\\\":\\\"agent.learning\\\",\\\"agent_id\\\":\\\"$PAW_AGENT_ID\\\",\\\"payload\\\":{{\\\"category\\\":\\\"permission_pattern\\\",\\\"body\\\":\\\"cross-worktree contamination: commit $PAW_SHA landed on '$PAW_CUR' instead of expected '$PAW_EXPECTED_BRANCH'\\\"}}}}\" \\\n\
449                         >/dev/null 2>&1 || true\n\
450                 fi\n\
451             fi\n\
452         fi\n\
453         {HOOK_END_MARKER}\n"
454    )
455}
456
457fn build_pre_push_hook() -> String {
458    // Only reject when the calling worktree is an agent worktree — i.e.
459    // a `paw-agent-id` marker exists in this worktree's gitdir. The hook
460    // installs into the common gitdir (shared with the main repo and all
461    // linked worktrees), so without this gate the hook would also block
462    // legitimate pushes from the main repo. Mirror the post-commit
463    // dispatcher's gate at line 388 so behaviour is consistent.
464    format!(
465        "#!/bin/sh\n\
466         {HOOK_START_MARKER}\n\
467         if [ -n \"$GIT_DIR\" ] && [ -f \"$GIT_DIR/paw-agent-id\" ]; then\n\
468         echo 'error: git-paw agents must not push. The supervisor handles merges.' >&2\n\
469         exit 1\n\
470         fi\n\
471         {HOOK_END_MARKER}\n"
472    )
473}
474
475/// Builds the `pre-commit` branch-guard hook.
476///
477/// Sources the per-worktree `$GIT_DIR/paw-agent-id` marker and, when
478/// `PAW_STRICT_BRANCH_GUARD` is not `false`, refuses a commit whose
479/// `git symbolic-ref --short HEAD` differs from `PAW_EXPECTED_BRANCH` — the
480/// branch the worktree was created for. This blocks cross-worktree
481/// contamination, where a commit advances the wrong branch because linked
482/// worktrees share `.git/refs`. The opt-out (`strict_branch_guard = false`)
483/// turns enforcement off while leaving the post-commit detection in place.
484/// Gated on the marker's presence so non-agent checkouts (the main repo)
485/// committing through the shared hooks dir are never blocked.
486fn build_pre_commit_branch_guard_hook() -> String {
487    format!(
488        "#!/bin/sh\n\
489         {HOOK_START_MARKER}\n\
490         # Branch guard: refuse a commit that would advance a branch other than\n\
491         # the one this worktree was created for (cross-worktree contamination).\n\
492         # git does not reliably export GIT_DIR to pre-commit, so resolve the\n\
493         # per-worktree gitdir via rev-parse with a GIT_DIR fallback.\n\
494         PAW_GD=\"${{GIT_DIR:-$(git rev-parse --git-dir 2>/dev/null)}}\"\n\
495         if [ -n \"$PAW_GD\" ] && [ -f \"$PAW_GD/paw-agent-id\" ]; then\n\
496             . \"$PAW_GD/paw-agent-id\"\n\
497             if [ -n \"$PAW_EXPECTED_BRANCH\" ] && [ \"$PAW_STRICT_BRANCH_GUARD\" != \"false\" ]; then\n\
498                 PAW_CUR=$(git symbolic-ref --short HEAD 2>/dev/null)\n\
499                 if [ -n \"$PAW_CUR\" ] && [ \"$PAW_CUR\" != \"$PAW_EXPECTED_BRANCH\" ]; then\n\
500                     echo \"error: git-paw branch guard refused this commit\" >&2\n\
501                     echo \"  HEAD is on '$PAW_CUR' but this worktree is for '$PAW_EXPECTED_BRANCH'.\" >&2\n\
502                     echo \"  The commit would advance the wrong branch. Switch back to '$PAW_EXPECTED_BRANCH'\" >&2\n\
503                     echo \"  (or set [supervisor] strict_branch_guard = false to override).\" >&2\n\
504                     exit 1\n\
505                 fi\n\
506             fi\n\
507         fi\n\
508         {HOOK_END_MARKER}\n"
509    )
510}
511
512/// Chains `new_body` onto `existing`, preserving the existing content.
513///
514/// If `existing` already contains a complete git-paw marker block, it is
515/// replaced. If only the start marker is present (corrupted/truncated block),
516/// the existing content is preserved verbatim and `new_body` is appended —
517/// never silently discarded — so the user's shebang and original logic stay
518/// intact. Otherwise `new_body` is appended after the existing content.
519fn chain_hook(existing: &str, new_body: &str) -> String {
520    // Complete marker block — replace it. If only the start marker is
521    // present (corrupted/truncated block), fall through to the append path
522    // so the user's shebang and original logic are preserved instead of
523    // being silently discarded.
524    if let Some(start) = existing.find(HOOK_START_MARKER)
525        && let Some(end_rel) = existing[start..].find(HOOK_END_MARKER)
526    {
527        let end = start + end_rel + HOOK_END_MARKER.len();
528        let mut out = String::with_capacity(existing.len() + new_body.len());
529        out.push_str(&existing[..start]);
530        // Strip the shebang from the new body when chaining onto an existing
531        // hook — the existing file already has one.
532        let stripped = new_body.strip_prefix("#!/bin/sh\n").unwrap_or(new_body);
533        out.push_str(stripped);
534        out.push_str(&existing[end..]);
535        return out;
536    }
537    let mut out = existing.trim_end().to_string();
538    if !out.is_empty() {
539        out.push('\n');
540    }
541    let stripped = if out.is_empty() {
542        new_body.to_string()
543    } else {
544        new_body
545            .strip_prefix("#!/bin/sh\n")
546            .unwrap_or(new_body)
547            .to_string()
548    };
549    out.push_str(&stripped);
550    out
551}
552
553fn write_hook_file(hook_path: &Path, new_body: &str) -> Result<(), PawError> {
554    let existing = match fs::read_to_string(hook_path) {
555        Ok(c) => c,
556        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
557        Err(e) => {
558            return Err(PawError::AgentsMdError(format!(
559                "failed to read '{}': {e}",
560                hook_path.display()
561            )));
562        }
563    };
564
565    let content = if existing.is_empty() {
566        new_body.to_string()
567    } else {
568        chain_hook(&existing, new_body)
569    };
570
571    if let Some(parent) = hook_path.parent() {
572        fs::create_dir_all(parent).map_err(|e| {
573            PawError::AgentsMdError(format!("failed to create '{}': {e}", parent.display()))
574        })?;
575    }
576
577    fs::write(hook_path, content.as_bytes()).map_err(|e| {
578        PawError::AgentsMdError(format!("failed to write '{}': {e}", hook_path.display()))
579    })?;
580
581    #[cfg(unix)]
582    {
583        use std::os::unix::fs::PermissionsExt;
584        let mut perms = fs::metadata(hook_path)
585            .map_err(|e| {
586                PawError::AgentsMdError(format!("failed to stat '{}': {e}", hook_path.display()))
587            })?
588            .permissions();
589        perms.set_mode(0o755);
590        fs::set_permissions(hook_path, perms).map_err(|e| {
591            PawError::AgentsMdError(format!("failed to chmod '{}': {e}", hook_path.display()))
592        })?;
593    }
594
595    Ok(())
596}
597
598/// Resolves a path from `git rev-parse <flag>` inside `worktree`.
599///
600/// Returns the absolute, trimmed path. The output of `git rev-parse` may be
601/// relative to the worktree, so we canonicalise it against the worktree root
602/// when it is not already absolute.
603fn git_rev_parse_path(worktree: &Path, flag: &str) -> Result<PathBuf, PawError> {
604    let output = std::process::Command::new("git")
605        .current_dir(worktree)
606        .args(["rev-parse", flag])
607        .output()
608        .map_err(|e| PawError::AgentsMdError(format!("failed to run git rev-parse {flag}: {e}")))?;
609    if !output.status.success() {
610        let stderr = String::from_utf8_lossy(&output.stderr);
611        return Err(PawError::AgentsMdError(format!(
612            "git rev-parse {flag} failed in '{}': {stderr}",
613            worktree.display()
614        )));
615    }
616    let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
617    let path = PathBuf::from(&raw);
618    if path.is_absolute() {
619        Ok(path)
620    } else {
621        Ok(worktree.join(path))
622    }
623}
624
625/// Installs git-paw's `post-commit` dispatcher and `pre-push` block hook.
626///
627/// Linked git worktrees share the main repository's `.git/hooks/` directory
628/// (unless `extensions.worktreeConfig` is enabled, which is intrusive). This
629/// function therefore:
630///
631/// 1. Resolves the **common** git dir via `git rev-parse --git-common-dir` and
632///    installs the dispatcher hooks at `<common>/hooks/post-commit` and
633///    `<common>/hooks/pre-push` (chained onto any existing hooks).
634/// 2. Resolves the **linked** git dir via `git rev-parse --git-dir` and writes
635///    a per-worktree marker file at `<linked>/paw-agent-id` containing the
636///    `PAW_AGENT_ID` and `PAW_BROKER_URL` values the dispatcher will source.
637///
638/// The dispatcher hook reads `$GIT_DIR/paw-agent-id` at commit time — git sets
639/// `GIT_DIR` to the correct per-worktree gitdir, so each worktree publishes
640/// under its own agent id.
641pub fn install_git_hooks(
642    worktree: &Path,
643    broker_url: &str,
644    agent_id: &str,
645    expected_branch: &str,
646    strict_branch_guard: bool,
647) -> Result<(), PawError> {
648    let common_git_dir = git_rev_parse_path(worktree, "--git-common-dir")?;
649    let linked_git_dir = git_rev_parse_path(worktree, "--git-dir")?;
650    let hooks_dir = common_git_dir.join("hooks");
651
652    write_hook_file(
653        &hooks_dir.join("post-commit"),
654        &build_post_commit_dispatcher_hook(),
655    )?;
656    write_hook_file(&hooks_dir.join("pre-push"), &build_pre_push_hook())?;
657    write_hook_file(
658        &hooks_dir.join("pre-commit"),
659        &build_pre_commit_branch_guard_hook(),
660    )?;
661
662    let marker_path = linked_git_dir.join("paw-agent-id");
663    if let Some(parent) = marker_path.parent() {
664        fs::create_dir_all(parent).map_err(|e| {
665            PawError::AgentsMdError(format!("failed to create '{}': {e}", parent.display()))
666        })?;
667    }
668    // The branch-guard fields are appended to the marker the hooks source.
669    // `PAW_EXPECTED_BRANCH` is the branch this worktree was created for;
670    // `PAW_STRICT_BRANCH_GUARD` controls whether the pre-commit hook *blocks*
671    // (vs. detection-only via post-commit).
672    let mut marker = build_agent_marker(broker_url, agent_id, None, None, None);
673    let _ = writeln!(marker, "PAW_EXPECTED_BRANCH={expected_branch}");
674    let _ = writeln!(
675        marker,
676        "PAW_STRICT_BRANCH_GUARD={}",
677        if strict_branch_guard { "true" } else { "false" }
678    );
679    fs::write(&marker_path, marker).map_err(|e| {
680        PawError::AgentsMdError(format!("failed to write '{}': {e}", marker_path.display()))
681    })?;
682
683    Ok(())
684}
685
686pub fn inject_section_into_file(path: &Path, section: &str) -> Result<(), PawError> {
687    let content = match fs::read_to_string(path) {
688        Ok(c) => c,
689        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
690        Err(e) => {
691            return Err(PawError::AgentsMdError(format!(
692                "failed to read '{}': {e}",
693                path.display()
694            )));
695        }
696    };
697
698    let output = inject_into_content(&content, section);
699
700    fs::write(path, &output)
701        .map_err(|e| PawError::AgentsMdError(format!("failed to write '{}': {e}", path.display())))
702}
703
704/// Removes the git-paw managed block (start marker through end marker,
705/// inclusive — plus any blank lines immediately adjacent) from `content`.
706///
707/// If `content` has no start marker, returns `content` unchanged. This makes
708/// the helper safe to call unconditionally during teardown.
709///
710/// Adjacency rule: the helper consumes ONE leading blank line and ONE trailing
711/// blank line that surround the block, restoring the file to its
712/// pre-injection shape (`inject_into_content` inserts a leading blank line
713/// when appending to a non-empty file).
714pub fn remove_git_paw_section(content: &str) -> String {
715    let lines: Vec<&str> = content.lines().collect();
716
717    let Some(start_idx) = lines
718        .iter()
719        .position(|l| l.starts_with(START_MARKER_PREFIX))
720    else {
721        // No marker — preserve content byte-for-byte.
722        return content.to_string();
723    };
724
725    let end_idx = lines[start_idx..]
726        .iter()
727        .position(|l| l.contains(END_MARKER))
728        .map(|rel| start_idx + rel);
729
730    // Compute the range to delete: [delete_start, delete_end_exclusive).
731    let delete_start = start_idx;
732    let delete_end_exclusive = end_idx.map_or(lines.len(), |e| e + 1);
733
734    // Consume at most ONE adjacent blank line to avoid collapsing the
735    // surrounding paragraph spacing. Prefer the trailing blank because
736    // `inject_into_content` inserts a leading blank when appending the
737    // block, so the trailing blank is more likely to be vestigial.
738    // If only a leading blank exists, fall back to that.
739    let mut delete_end = delete_end_exclusive;
740    let mut adjusted_start = delete_start;
741    if delete_end < lines.len() && lines[delete_end].is_empty() {
742        delete_end += 1;
743    } else if adjusted_start > 0 && lines[adjusted_start - 1].is_empty() {
744        adjusted_start -= 1;
745    }
746    let delete_start = adjusted_start;
747
748    let mut result = String::new();
749    for line in &lines[..delete_start] {
750        result.push_str(line);
751        result.push('\n');
752    }
753    for line in &lines[delete_end..] {
754        result.push_str(line);
755        result.push('\n');
756    }
757
758    // Preserve trailing-newline behaviour of the original file when the
759    // result is not already terminated with one.
760    if content.ends_with('\n') && !result.ends_with('\n') && !result.is_empty() {
761        result.push('\n');
762    }
763
764    // If the original file lacked a trailing newline AND the result
765    // gained one from our line-by-line reconstruction, trim it back.
766    if !content.ends_with('\n') && result.ends_with('\n') {
767        result.pop();
768    }
769
770    result
771}
772
773/// Reads `path` (treating a missing file as empty), removes any
774/// git-paw managed block, and writes the result back. Idempotent: a file
775/// with no markers is a no-op and the original content is preserved
776/// byte-for-byte.
777///
778/// v0-5-0-audit-cleanup Bug E — `cmd_stop` and `cmd_purge` invoke this
779/// against the repo-root `AGENTS.md` after teardown so the supervisor-
780/// pane boot block does not accumulate across sessions.
781pub fn remove_session_boot_block(repo_root: &Path) -> Result<(), PawError> {
782    let agents_md = repo_root.join("AGENTS.md");
783    let content = match fs::read_to_string(&agents_md) {
784        Ok(c) => c,
785        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
786        Err(e) => {
787            return Err(PawError::AgentsMdError(format!(
788                "failed to read '{}': {e}",
789                agents_md.display()
790            )));
791        }
792    };
793
794    let new_content = remove_git_paw_section(&content);
795    if new_content == content {
796        // No marker block — nothing to write.
797        return Ok(());
798    }
799
800    fs::write(&agents_md, &new_content).map_err(|e| {
801        PawError::AgentsMdError(format!("failed to write '{}': {e}", agents_md.display()))
802    })
803}
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808
809    /// Test helper: generates a sample marker-delimited section for testing injection logic.
810    fn sample_section() -> String {
811        format!("{START_MARKER}\n## git-paw test section\n{END_MARKER}\n")
812    }
813
814    // -----------------------------------------------------------------------
815    // has_git_paw_section
816    // -----------------------------------------------------------------------
817
818    #[test]
819    fn has_section_returns_true_when_marker_present() {
820        let content = "# My Project\n\n<!-- git-paw:start — managed by git-paw, do not edit manually -->\nstuff\n<!-- git-paw:end -->\n";
821        assert!(has_git_paw_section(content));
822    }
823
824    #[test]
825    fn has_section_returns_false_without_marker() {
826        let content = "# My Project\n\nSome instructions.\n";
827        assert!(!has_git_paw_section(content));
828    }
829
830    #[test]
831    fn has_section_returns_false_for_empty() {
832        assert!(!has_git_paw_section(""));
833    }
834
835    // -----------------------------------------------------------------------
836    // generate_git_paw_section
837    // -----------------------------------------------------------------------
838
839    #[test]
840    fn generated_section_has_markers() {
841        let section = sample_section();
842        assert!(section.starts_with(START_MARKER));
843        assert!(section.contains(END_MARKER));
844    }
845
846    #[test]
847    fn sample_section_contains_git_paw_reference() {
848        let section = sample_section();
849        assert!(section.contains("git-paw"));
850    }
851
852    // -----------------------------------------------------------------------
853    // replace_git_paw_section
854    // -----------------------------------------------------------------------
855
856    #[test]
857    fn replace_with_both_markers_preserves_surrounding() {
858        let content = "# Title\n\n<!-- git-paw:start — managed by git-paw, do not edit manually -->\nold content\n<!-- git-paw:end -->\n\n## Footer\n";
859        let new_section = "<!-- git-paw:start — managed by git-paw, do not edit manually -->\nnew content\n<!-- git-paw:end -->\n";
860        let result = replace_git_paw_section(content, new_section);
861        assert!(result.contains("# Title"));
862        assert!(result.contains("new content"));
863        assert!(!result.contains("old content"));
864        assert!(result.contains("## Footer"));
865    }
866
867    #[test]
868    fn replace_with_missing_end_marker_replaces_to_eof() {
869        let content = "# Title\n\n<!-- git-paw:start — managed by git-paw, do not edit manually -->\nold content that never ends\n";
870        let new_section = "<!-- git-paw:start — managed by git-paw, do not edit manually -->\nfixed\n<!-- git-paw:end -->\n";
871        let result = replace_git_paw_section(content, new_section);
872        assert!(result.contains("# Title"));
873        assert!(result.contains("fixed"));
874        assert!(!result.contains("old content"));
875    }
876
877    // -----------------------------------------------------------------------
878    // inject_into_content
879    // -----------------------------------------------------------------------
880
881    #[test]
882    fn inject_appends_when_no_existing_section() {
883        let content = "# My Project\n\nSome info.\n";
884        let section = sample_section();
885        let result = inject_into_content(content, &section);
886        assert!(result.starts_with("# My Project"));
887        assert!(result.contains(START_MARKER));
888    }
889
890    #[test]
891    fn inject_replaces_existing_section() {
892        let old_section = format!("{START_MARKER}\nold\n{END_MARKER}\n");
893        let content = format!("# Title\n\n{old_section}\n## Footer\n");
894        let new_section = format!("{START_MARKER}\nnew\n{END_MARKER}\n");
895        let result = inject_into_content(&content, &new_section);
896        assert!(result.contains("new"));
897        assert!(!result.contains("old"));
898        assert!(result.contains("## Footer"));
899    }
900
901    #[test]
902    fn inject_into_empty_content_returns_section_only() {
903        let section = sample_section();
904        let result = inject_into_content("", &section);
905        assert_eq!(result, section);
906    }
907
908    // -----------------------------------------------------------------------
909    // Spacing tests
910    // -----------------------------------------------------------------------
911
912    #[test]
913    fn spacing_with_trailing_newline() {
914        let content = "# Title\n";
915        let section = "<!-- git-paw:start -->\n<!-- git-paw:end -->\n";
916        let result = inject_into_content(content, section);
917        // Should have blank line separator: "# Title\n\n<!-- git-paw..."
918        assert!(result.contains("# Title\n\n<!-- git-paw:start"));
919    }
920
921    #[test]
922    fn spacing_without_trailing_newline() {
923        let content = "# Title";
924        let section = "<!-- git-paw:start -->\n<!-- git-paw:end -->\n";
925        let result = inject_into_content(content, section);
926        // Should add newline + blank line: "# Title\n\n<!-- git-paw..."
927        assert!(result.contains("# Title\n\n<!-- git-paw:start"));
928    }
929
930    // -----------------------------------------------------------------------
931    // File I/O tests
932    // -----------------------------------------------------------------------
933
934    #[test]
935    fn file_inject_appends_to_existing() {
936        let dir = tempfile::tempdir().unwrap();
937        let path = dir.path().join("AGENTS.md");
938        fs::write(&path, "# Existing\n").unwrap();
939
940        let section = sample_section();
941        inject_section_into_file(&path, &section).unwrap();
942
943        let result = fs::read_to_string(&path).unwrap();
944        assert!(result.contains("# Existing"));
945        assert!(result.contains(START_MARKER));
946    }
947
948    #[test]
949    fn file_inject_replaces_existing_section() {
950        let dir = tempfile::tempdir().unwrap();
951        let path = dir.path().join("AGENTS.md");
952        let initial = format!("# Title\n\n{START_MARKER}\nold\n{END_MARKER}\n");
953        fs::write(&path, &initial).unwrap();
954
955        let new_section = sample_section();
956        inject_section_into_file(&path, &new_section).unwrap();
957
958        let result = fs::read_to_string(&path).unwrap();
959        assert!(result.contains("# Title"));
960        assert!(!result.contains("\nold\n"));
961        assert!(result.contains("git-paw test section"));
962    }
963
964    #[test]
965    fn file_inject_creates_missing_file() {
966        let dir = tempfile::tempdir().unwrap();
967        let path = dir.path().join("AGENTS.md");
968        assert!(!path.exists());
969
970        let section = sample_section();
971        inject_section_into_file(&path, &section).unwrap();
972
973        let result = fs::read_to_string(&path).unwrap();
974        assert!(result.contains(START_MARKER));
975    }
976
977    #[test]
978    fn file_inject_readonly_returns_error() {
979        use std::os::unix::fs::PermissionsExt;
980
981        let dir = tempfile::tempdir().unwrap();
982        let path = dir.path().join("AGENTS.md");
983        fs::write(&path, "content").unwrap();
984        fs::set_permissions(&path, fs::Permissions::from_mode(0o444)).unwrap();
985
986        let section = sample_section();
987        let result = inject_section_into_file(&path, &section);
988        assert!(result.is_err());
989        let err = result.unwrap_err();
990        let msg = err.to_string();
991        assert!(msg.contains("AGENTS.md error"), "got: {msg}");
992        assert!(
993            msg.contains("AGENTS.md"),
994            "should mention file path, got: {msg}"
995        );
996
997        // Cleanup: restore permissions so tempdir can be removed
998        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
999    }
1000
1001    // -----------------------------------------------------------------------
1002    // generate_worktree_section
1003    // -----------------------------------------------------------------------
1004
1005    fn make_assignment(spec: Option<&str>, files: Option<Vec<&str>>) -> WorktreeAssignment {
1006        WorktreeAssignment {
1007            branch: "feat/foo".to_string(),
1008            cli: "claude".to_string(),
1009            spec_content: spec.map(ToString::to_string),
1010            owned_files: files.map(|v| v.into_iter().map(ToString::to_string).collect()),
1011            skill_content: None,
1012            inter_agent_rules: None,
1013        }
1014    }
1015
1016    fn make_assignment_with_skill(
1017        spec: Option<&str>,
1018        files: Option<Vec<&str>>,
1019        skill: Option<&str>,
1020    ) -> WorktreeAssignment {
1021        WorktreeAssignment {
1022            branch: "feat/foo".to_string(),
1023            cli: "claude".to_string(),
1024            spec_content: spec.map(ToString::to_string),
1025            owned_files: files.map(|v| v.into_iter().map(ToString::to_string).collect()),
1026            skill_content: skill.map(ToString::to_string),
1027            inter_agent_rules: None,
1028        }
1029    }
1030
1031    #[test]
1032    fn worktree_section_all_fields() {
1033        let assignment = make_assignment(
1034            Some("Implement the widget.\n"),
1035            Some(vec!["src/widget.rs", "tests/widget.rs"]),
1036        );
1037        let section = generate_worktree_section(&assignment);
1038        assert!(section.starts_with(START_MARKER));
1039        assert!(section.contains(END_MARKER));
1040        assert!(section.contains("`feat/foo`"));
1041        assert!(section.contains("claude"));
1042        assert!(section.contains("### Spec"));
1043        assert!(section.contains("Implement the widget."));
1044        assert!(section.contains("### File Ownership"));
1045        assert!(section.contains("`src/widget.rs`"));
1046        assert!(section.contains("`tests/widget.rs`"));
1047    }
1048
1049    #[test]
1050    fn worktree_section_no_spec() {
1051        let assignment = make_assignment(None, Some(vec!["src/main.rs"]));
1052        let section = generate_worktree_section(&assignment);
1053        assert!(section.contains("`feat/foo`"));
1054        assert!(!section.contains("### Spec"));
1055        assert!(section.contains("### File Ownership"));
1056    }
1057
1058    #[test]
1059    fn worktree_section_no_files() {
1060        let assignment = make_assignment(Some("Do the thing.\n"), None);
1061        let section = generate_worktree_section(&assignment);
1062        assert!(section.contains("### Spec"));
1063        assert!(!section.contains("### File Ownership"));
1064    }
1065
1066    #[test]
1067    fn worktree_section_minimal() {
1068        let assignment = make_assignment(None, None);
1069        let section = generate_worktree_section(&assignment);
1070        assert!(section.starts_with(START_MARKER));
1071        assert!(section.contains(END_MARKER));
1072        assert!(section.contains("`feat/foo`"));
1073        assert!(section.contains("claude"));
1074        assert!(!section.contains("### Spec"));
1075        assert!(!section.contains("### File Ownership"));
1076    }
1077
1078    // -----------------------------------------------------------------------
1079    // setup_worktree_agents_md
1080    // -----------------------------------------------------------------------
1081
1082    /// Creates a real git repo in a tempdir (git init + initial commit).
1083    ///
1084    /// Resolves the absolute path to `git` once to avoid ENOENT races
1085    /// under heavy parallel test load on macOS.
1086    fn init_git_repo(dir: &Path) {
1087        use std::process::Command;
1088        let git = which::which("git").expect("git must be on PATH");
1089        Command::new(&git)
1090            .current_dir(dir)
1091            .args(["init"])
1092            .output()
1093            .expect("git init");
1094        Command::new(&git)
1095            .current_dir(dir)
1096            .args(["config", "user.email", "test@test.com"])
1097            .output()
1098            .expect("git config email");
1099        Command::new(&git)
1100            .current_dir(dir)
1101            .args(["config", "user.name", "Test"])
1102            .output()
1103            .expect("git config name");
1104        // Create and commit a file so HEAD exists
1105        fs::write(dir.join("README.md"), "# test\n").unwrap();
1106        Command::new(&git)
1107            .current_dir(dir)
1108            .args(["add", "README.md"])
1109            .output()
1110            .expect("git add");
1111        Command::new(&git)
1112            .current_dir(dir)
1113            .args(["commit", "-m", "init"])
1114            .output()
1115            .expect("git commit");
1116    }
1117
1118    /// Absolute path of the gitignored sidecar inside a worktree.
1119    fn sidecar_path(wt: &Path) -> PathBuf {
1120        wt.join(SIDECAR_REL_PATH)
1121    }
1122
1123    /// Reads the `git ls-files -v` flag character for `AGENTS.md`. A lowercase
1124    /// flag (e.g. `h`) means the assume-unchanged bit is set; uppercase `H`
1125    /// means a normal tracked file.
1126    fn agents_md_ls_files_flag(wt: &Path) -> char {
1127        let out = std::process::Command::new("git")
1128            .current_dir(wt)
1129            .args(["ls-files", "-v", "AGENTS.md"])
1130            .output()
1131            .expect("git ls-files -v");
1132        let stdout = String::from_utf8_lossy(&out.stdout);
1133        stdout
1134            .lines()
1135            .next()
1136            .and_then(|l| l.chars().next())
1137            .unwrap_or('?')
1138    }
1139
1140    /// Tracks `AGENTS.md` in the worktree index with `body` and commits it, so
1141    /// assume-unchanged / commit semantics apply to a real tracked file.
1142    fn commit_tracked_agents_md(wt: &Path, body: &str) {
1143        fs::write(wt.join("AGENTS.md"), body).unwrap();
1144        std::process::Command::new("git")
1145            .current_dir(wt)
1146            .args(["add", "AGENTS.md"])
1147            .output()
1148            .expect("git add AGENTS.md");
1149        std::process::Command::new("git")
1150            .current_dir(wt)
1151            .args(["commit", "-m", "add agents"])
1152            .output()
1153            .expect("git commit");
1154    }
1155
1156    #[test]
1157    fn setup_worktree_root_exists() {
1158        let repo = tempfile::tempdir().unwrap();
1159        let wt = tempfile::tempdir().unwrap();
1160        init_git_repo(wt.path());
1161        fs::write(repo.path().join("AGENTS.md"), "# Project Rules\n").unwrap();
1162        commit_tracked_agents_md(wt.path(), "# placeholder\n");
1163
1164        let assignment = make_assignment(None, None);
1165        setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
1166
1167        // The combined view lands in the gitignored sidecar, not AGENTS.md.
1168        let sidecar = fs::read_to_string(sidecar_path(wt.path())).unwrap();
1169        assert!(sidecar.contains("# Project Rules"));
1170        assert!(sidecar.contains("`feat/foo`"));
1171        assert!(sidecar.contains(START_MARKER));
1172
1173        // 5.1: the tracked AGENTS.md is NOT marked assume-unchanged.
1174        assert_eq!(
1175            agents_md_ls_files_flag(wt.path()),
1176            'H',
1177            "tracked AGENTS.md must not carry the assume-unchanged bit"
1178        );
1179
1180        // 5.1: a hand edit to the tracked AGENTS.md appears in git status.
1181        fs::write(wt.path().join("AGENTS.md"), "# placeholder\n\nhand edit\n").unwrap();
1182        let status = std::process::Command::new("git")
1183            .current_dir(wt.path())
1184            .args(["status", "--porcelain"])
1185            .output()
1186            .expect("git status");
1187        let status_output = String::from_utf8_lossy(&status.stdout);
1188        assert!(
1189            status_output.contains("AGENTS.md"),
1190            "a hand edit to AGENTS.md must appear in git status, got: {status_output}"
1191        );
1192    }
1193
1194    #[test]
1195    fn setup_worktree_hand_edit_stages_and_commits() {
1196        // 5.2: a hand edit to the tracked AGENTS.md stages via `git add -A`
1197        // and commits — the v0.7.0 footgun (blocked commit) is gone.
1198        let repo = tempfile::tempdir().unwrap();
1199        let wt = tempfile::tempdir().unwrap();
1200        init_git_repo(wt.path());
1201        fs::write(repo.path().join("AGENTS.md"), "# Project Rules\n").unwrap();
1202        commit_tracked_agents_md(wt.path(), "# Project Rules\n");
1203
1204        let assignment = make_assignment(None, None);
1205        setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
1206
1207        // Legitimate edit an agent might make (e.g. adding a dependency row).
1208        fs::write(
1209            wt.path().join("AGENTS.md"),
1210            "# Project Rules\n\n- approved dependency: rmcp\n",
1211        )
1212        .unwrap();
1213
1214        std::process::Command::new("git")
1215            .current_dir(wt.path())
1216            .args(["add", "-A"])
1217            .output()
1218            .expect("git add -A");
1219        let commit = std::process::Command::new("git")
1220            .current_dir(wt.path())
1221            .args(["commit", "-m", "edit agents"])
1222            .output()
1223            .expect("git commit");
1224        assert!(commit.status.success(), "commit should succeed");
1225
1226        // The committed tip contains the edit and the working tree is clean.
1227        let show = std::process::Command::new("git")
1228            .current_dir(wt.path())
1229            .args(["show", "--stat", "HEAD"])
1230            .output()
1231            .expect("git show");
1232        assert!(String::from_utf8_lossy(&show.stdout).contains("AGENTS.md"));
1233        let status = std::process::Command::new("git")
1234            .current_dir(wt.path())
1235            .args(["status", "--porcelain", "AGENTS.md"])
1236            .output()
1237            .expect("git status");
1238        assert!(
1239            String::from_utf8_lossy(&status.stdout).trim().is_empty(),
1240            "AGENTS.md should be clean after committing the edit"
1241        );
1242    }
1243
1244    #[test]
1245    fn setup_worktree_managed_block_in_sidecar_combined_view() {
1246        // 5.3: the managed `<!-- git-paw:start -->` block is present in the
1247        // sidecar and the sidecar is the combined view (root + block).
1248        let repo = tempfile::tempdir().unwrap();
1249        let wt = tempfile::tempdir().unwrap();
1250        init_git_repo(wt.path());
1251        fs::write(repo.path().join("AGENTS.md"), "# Project Rules\n").unwrap();
1252
1253        let assignment = make_assignment(None, None);
1254        setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
1255
1256        let sidecar = fs::read_to_string(sidecar_path(wt.path())).unwrap();
1257        assert!(
1258            sidecar.contains(START_MARKER),
1259            "sidecar must carry the block"
1260        );
1261        // Combined = root content first, then the managed block.
1262        let root_idx = sidecar
1263            .find("# Project Rules")
1264            .expect("root content present");
1265        let block_idx = sidecar.find(START_MARKER).expect("block present");
1266        assert!(
1267            root_idx < block_idx,
1268            "root content must precede the managed block in the combined view"
1269        );
1270    }
1271
1272    #[test]
1273    fn setup_worktree_tracked_agents_md_untouched_and_not_excluded() {
1274        // 5.4: git-paw writes no block into the tracked AGENTS.md, and does
1275        // not add AGENTS.md to the worktree's `.git/info/exclude`.
1276        let repo = tempfile::tempdir().unwrap();
1277        let wt = tempfile::tempdir().unwrap();
1278        init_git_repo(wt.path());
1279        fs::write(repo.path().join("AGENTS.md"), "# Project Rules\n").unwrap();
1280        commit_tracked_agents_md(wt.path(), "# Project Rules\n");
1281
1282        let assignment = make_assignment(None, None);
1283        setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
1284
1285        let tracked = fs::read_to_string(wt.path().join("AGENTS.md")).unwrap();
1286        assert!(
1287            !tracked.contains(START_MARKER_PREFIX),
1288            "git-paw must not write its managed block into the tracked AGENTS.md"
1289        );
1290
1291        let exclude = fs::read_to_string(wt.path().join(".git/info/exclude")).unwrap_or_default();
1292        assert!(
1293            !exclude.lines().any(|l| l.trim() == "AGENTS.md"),
1294            "AGENTS.md must NOT be added to .git/info/exclude, got: {exclude}"
1295        );
1296    }
1297
1298    #[test]
1299    fn setup_worktree_sidecar_in_ignore_set() {
1300        // 5.5: the sidecar path IS in the worktree ignore set.
1301        let repo = tempfile::tempdir().unwrap();
1302        let wt = tempfile::tempdir().unwrap();
1303        init_git_repo(wt.path());
1304
1305        let assignment = make_assignment(None, None);
1306        setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
1307
1308        let exclude = fs::read_to_string(wt.path().join(".git/info/exclude")).unwrap();
1309        assert!(
1310            exclude.lines().any(|l| l.trim() == SIDECAR_REL_PATH),
1311            "sidecar path must be in the worktree ignore set, got: {exclude}"
1312        );
1313    }
1314
1315    #[test]
1316    fn setup_worktree_clears_stale_assume_unchanged() {
1317        // 5.6: a stale assume-unchanged bit set before setup is cleared.
1318        let repo = tempfile::tempdir().unwrap();
1319        let wt = tempfile::tempdir().unwrap();
1320        init_git_repo(wt.path());
1321        fs::write(repo.path().join("AGENTS.md"), "# Project Rules\n").unwrap();
1322        commit_tracked_agents_md(wt.path(), "# placeholder\n");
1323
1324        // Simulate an older git-paw version having hidden the file.
1325        std::process::Command::new("git")
1326            .current_dir(wt.path())
1327            .args(["update-index", "--assume-unchanged", "AGENTS.md"])
1328            .output()
1329            .expect("git update-index --assume-unchanged");
1330        assert_eq!(
1331            agents_md_ls_files_flag(wt.path()),
1332            'h',
1333            "precondition: the stale assume-unchanged bit is set"
1334        );
1335
1336        let assignment = make_assignment(None, None);
1337        setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
1338
1339        assert_eq!(
1340            agents_md_ls_files_flag(wt.path()),
1341            'H',
1342            "setup must clear the stale assume-unchanged bit"
1343        );
1344        // And a hand edit now surfaces in git status.
1345        fs::write(wt.path().join("AGENTS.md"), "# placeholder\n\nedited\n").unwrap();
1346        let status = std::process::Command::new("git")
1347            .current_dir(wt.path())
1348            .args(["status", "--porcelain"])
1349            .output()
1350            .expect("git status");
1351        assert!(
1352            String::from_utf8_lossy(&status.stdout).contains("AGENTS.md"),
1353            "after clearing the bit, a hand edit must appear in git status"
1354        );
1355    }
1356
1357    #[test]
1358    fn setup_worktree_root_missing() {
1359        // 5.7: read the sidecar, not the worktree AGENTS.md.
1360        let repo = tempfile::tempdir().unwrap();
1361        let wt = tempfile::tempdir().unwrap();
1362        init_git_repo(wt.path());
1363
1364        let assignment = make_assignment(None, None);
1365        setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
1366
1367        let sidecar = fs::read_to_string(sidecar_path(wt.path())).unwrap();
1368        assert!(!sidecar.contains("# Project Rules"));
1369        assert!(sidecar.contains("`feat/foo`"));
1370    }
1371
1372    #[test]
1373    fn setup_worktree_replaces_root_section() {
1374        // 5.7: read the sidecar, not the worktree AGENTS.md.
1375        let repo = tempfile::tempdir().unwrap();
1376        let wt = tempfile::tempdir().unwrap();
1377        init_git_repo(wt.path());
1378        let root_content =
1379            format!("# Rules\n\n{START_MARKER}\nold root section\n{END_MARKER}\n\n## Footer\n");
1380        fs::write(repo.path().join("AGENTS.md"), &root_content).unwrap();
1381
1382        let assignment = make_assignment(None, None);
1383        setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
1384
1385        let sidecar = fs::read_to_string(sidecar_path(wt.path())).unwrap();
1386        assert!(sidecar.contains("# Rules"));
1387        assert!(sidecar.contains("## Footer"));
1388        assert!(!sidecar.contains("old root section"));
1389        assert!(sidecar.contains("`feat/foo`"));
1390        assert_eq!(
1391            sidecar.matches(START_MARKER_PREFIX).count(),
1392            1,
1393            "should have exactly one git-paw section"
1394        );
1395    }
1396
1397    // -----------------------------------------------------------------------
1398    // setup_worktree_agents_md — write failure
1399    // -----------------------------------------------------------------------
1400
1401    #[test]
1402    fn setup_worktree_write_failure_returns_agents_md_error() {
1403        use std::os::unix::fs::PermissionsExt;
1404
1405        let repo = tempfile::tempdir().unwrap();
1406        let wt = tempfile::tempdir().unwrap();
1407        init_git_repo(wt.path());
1408
1409        // Make the worktree root read-only so AGENTS.md cannot be written
1410        fs::set_permissions(wt.path(), fs::Permissions::from_mode(0o555)).unwrap();
1411
1412        let assignment = make_assignment(None, None);
1413        let result = setup_worktree_agents_md(repo.path(), wt.path(), &assignment);
1414
1415        // Restore permissions so tempdir cleanup can succeed
1416        fs::set_permissions(wt.path(), fs::Permissions::from_mode(0o755)).unwrap();
1417
1418        assert!(result.is_err(), "should fail when worktree is read-only");
1419        let err = result.unwrap_err();
1420        let msg = err.to_string();
1421        assert!(
1422            msg.contains("AGENTS.md error"),
1423            "should return AgentsMdError, got: {msg}"
1424        );
1425    }
1426
1427    // -----------------------------------------------------------------------
1428    // exclude_from_git
1429    // -----------------------------------------------------------------------
1430
1431    #[test]
1432    fn exclude_creates_file_when_missing() {
1433        let wt = tempfile::tempdir().unwrap();
1434        fs::create_dir_all(wt.path().join(".git/info")).unwrap();
1435
1436        exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1437
1438        let content = fs::read_to_string(wt.path().join(".git/info/exclude")).unwrap();
1439        assert!(content.contains("AGENTS.md"));
1440    }
1441
1442    #[test]
1443    fn exclude_appends_when_not_present() {
1444        let wt = tempfile::tempdir().unwrap();
1445        let info = wt.path().join(".git/info");
1446        fs::create_dir_all(&info).unwrap();
1447        fs::write(info.join("exclude"), "*.log\n").unwrap();
1448
1449        exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1450
1451        let content = fs::read_to_string(info.join("exclude")).unwrap();
1452        assert!(content.contains("*.log"));
1453        assert!(content.contains("AGENTS.md"));
1454    }
1455
1456    #[test]
1457    fn exclude_no_duplicate() {
1458        let wt = tempfile::tempdir().unwrap();
1459        let info = wt.path().join(".git/info");
1460        fs::create_dir_all(&info).unwrap();
1461        fs::write(info.join("exclude"), "AGENTS.md\n").unwrap();
1462
1463        exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1464
1465        let content = fs::read_to_string(info.join("exclude")).unwrap();
1466        assert_eq!(content.matches("AGENTS.md").count(), 1);
1467    }
1468
1469    #[test]
1470    fn exclude_creates_info_dir() {
1471        let wt = tempfile::tempdir().unwrap();
1472        fs::create_dir_all(wt.path().join(".git")).unwrap();
1473        assert!(!wt.path().join(".git/info").exists());
1474
1475        exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1476
1477        assert!(wt.path().join(".git/info/exclude").exists());
1478        let content = fs::read_to_string(wt.path().join(".git/info/exclude")).unwrap();
1479        assert!(content.contains("AGENTS.md"));
1480    }
1481
1482    // -----------------------------------------------------------------------
1483    // generate_worktree_section — skill_content
1484    // -----------------------------------------------------------------------
1485
1486    #[test]
1487    fn worktree_section_all_fields_with_skill() {
1488        let assignment = make_assignment_with_skill(
1489            Some("Implement the widget.\n"),
1490            Some(vec!["src/widget.rs", "tests/widget.rs"]),
1491            Some("## Coordination\nUse the broker at http://127.0.0.1:9119 as feat-foo.\n"),
1492        );
1493        let section = generate_worktree_section(&assignment);
1494        assert!(section.starts_with(START_MARKER));
1495        assert!(section.contains(END_MARKER));
1496        assert!(section.contains("`feat/foo`"));
1497        assert!(section.contains("claude"));
1498        assert!(section.contains("### Spec"));
1499        assert!(section.contains("Implement the widget."));
1500        assert!(section.contains("### File Ownership"));
1501        assert!(section.contains("`src/widget.rs`"));
1502        assert!(section.contains("## Coordination"));
1503        // Skill content appears after file ownership and before end marker
1504        let ownership_pos = section.find("### File Ownership").unwrap();
1505        let skill_pos = section.find("## Coordination").unwrap();
1506        let end_pos = section.find(END_MARKER).unwrap();
1507        assert!(
1508            ownership_pos < skill_pos,
1509            "skill must come after file ownership"
1510        );
1511        assert!(skill_pos < end_pos, "skill must come before end marker");
1512    }
1513
1514    #[test]
1515    fn worktree_section_skill_without_spec_or_files() {
1516        let assignment = make_assignment_with_skill(
1517            None,
1518            None,
1519            Some("## Coordination\nBroker instructions here.\n"),
1520        );
1521        let section = generate_worktree_section(&assignment);
1522        assert!(section.contains("`feat/foo`"));
1523        assert!(section.contains("claude"));
1524        assert!(!section.contains("### Spec"));
1525        assert!(!section.contains("### File Ownership"));
1526        assert!(section.contains("## Coordination"));
1527        // Skill content appears after assignment and before end marker
1528        let assignment_pos = section.find("**CLI:**").unwrap();
1529        let skill_pos = section.find("## Coordination").unwrap();
1530        let end_pos = section.find(END_MARKER).unwrap();
1531        assert!(
1532            assignment_pos < skill_pos,
1533            "skill must come after assignment"
1534        );
1535        assert!(skill_pos < end_pos, "skill must come before end marker");
1536    }
1537
1538    #[test]
1539    fn worktree_section_none_skill_matches_v020() {
1540        // With skill_content = None, output must be identical to make_assignment (no skill)
1541        let with_none =
1542            make_assignment_with_skill(Some("Do the thing.\n"), Some(vec!["src/main.rs"]), None);
1543        let without = make_assignment(Some("Do the thing.\n"), Some(vec!["src/main.rs"]));
1544        assert_eq!(
1545            generate_worktree_section(&with_none),
1546            generate_worktree_section(&without),
1547            "skill_content = None must produce identical output to v0.2.0"
1548        );
1549    }
1550
1551    #[test]
1552    fn worktree_section_skill_contains_slugified_branch() {
1553        let assignment = WorktreeAssignment {
1554            branch: "feat/http-broker".to_string(),
1555            cli: "claude".to_string(),
1556            spec_content: None,
1557            owned_files: None,
1558            skill_content: Some(
1559                "Agent ID: feat-http-broker\nURL: http://127.0.0.1:9119\n".to_string(),
1560            ),
1561            inter_agent_rules: None,
1562        };
1563        let section = generate_worktree_section(&assignment);
1564        assert!(
1565            section.contains("feat-http-broker"),
1566            "should contain slugified branch"
1567        );
1568        assert!(
1569            !section.contains("{{BRANCH_ID}}"),
1570            "should not contain literal template placeholder"
1571        );
1572    }
1573
1574    #[test]
1575    fn worktree_section_skill_preserves_broker_url_placeholder() {
1576        let assignment = make_assignment_with_skill(
1577            None,
1578            None,
1579            Some("Connect to http://127.0.0.1:9119/messages\n"),
1580        );
1581        let section = generate_worktree_section(&assignment);
1582        assert!(
1583            section.contains("http://127.0.0.1:9119"),
1584            "broker URL must be present"
1585        );
1586    }
1587
1588    // -----------------------------------------------------------------------
1589    // generate_worktree_section — inter_agent_rules
1590    // -----------------------------------------------------------------------
1591
1592    #[test]
1593    fn worktree_section_with_inter_agent_rules() {
1594        let mut assignment = make_assignment(Some("Do the widget.\n"), Some(vec!["src/widget.rs"]));
1595        assignment.inter_agent_rules = Some("Stay in your lane.\nNever push.\n".to_string());
1596        let section = generate_worktree_section(&assignment);
1597        assert!(section.contains("## Inter-Agent Rules"));
1598        assert!(section.contains("Stay in your lane."));
1599        // Rules section appears before end marker
1600        let rules_pos = section.find("## Inter-Agent Rules").unwrap();
1601        let end_pos = section.find(END_MARKER).unwrap();
1602        assert!(rules_pos < end_pos, "rules must come before end marker");
1603    }
1604
1605    #[test]
1606    fn worktree_section_without_inter_agent_rules_has_no_section() {
1607        let assignment = make_assignment(Some("Do the widget.\n"), Some(vec!["src/widget.rs"]));
1608        let section = generate_worktree_section(&assignment);
1609        assert!(!section.contains("## Inter-Agent Rules"));
1610    }
1611
1612    #[test]
1613    fn worktree_section_inter_agent_rules_none_matches_pre_change() {
1614        // When inter_agent_rules is None, output must equal the pre-change baseline.
1615        let baseline = make_assignment(Some("Do.\n"), Some(vec!["src/main.rs"]));
1616        let with_none = WorktreeAssignment {
1617            branch: baseline.branch.clone(),
1618            cli: baseline.cli.clone(),
1619            spec_content: baseline.spec_content.clone(),
1620            owned_files: baseline.owned_files.clone(),
1621            skill_content: None,
1622            inter_agent_rules: None,
1623        };
1624        assert_eq!(
1625            generate_worktree_section(&baseline),
1626            generate_worktree_section(&with_none),
1627        );
1628    }
1629
1630    // -----------------------------------------------------------------------
1631    // build_inter_agent_rules
1632    // -----------------------------------------------------------------------
1633
1634    #[test]
1635    fn build_inter_agent_rules_contains_file_ownership() {
1636        let rules = build_inter_agent_rules(&["feat/a", "feat/b"]);
1637        assert!(rules.contains("File ownership"));
1638        assert!(rules.contains("`feat/a`"));
1639        assert!(rules.contains("`feat/b`"));
1640    }
1641
1642    #[test]
1643    fn build_inter_agent_rules_contains_never_push() {
1644        let rules = build_inter_agent_rules(&["feat/a"]);
1645        assert!(rules.contains("MUST NOT `git push`"));
1646    }
1647
1648    #[test]
1649    fn build_inter_agent_rules_notes_automatic_status() {
1650        let rules = build_inter_agent_rules(&["feat/a"]);
1651        assert!(rules.contains("Status publishing is automatic"));
1652        assert!(rules.contains("post-commit"));
1653    }
1654
1655    #[test]
1656    fn build_inter_agent_rules_contains_match_spec() {
1657        let rules = build_inter_agent_rules(&["feat/a"]);
1658        assert!(
1659            rules
1660                .to_lowercase()
1661                .contains("match spec field names exactly")
1662        );
1663    }
1664
1665    #[test]
1666    fn build_inter_agent_rules_contains_cherry_pick_reference() {
1667        let rules = build_inter_agent_rules(&["feat/a"]);
1668        assert!(rules.to_lowercase().contains("cherry-pick"));
1669    }
1670
1671    // -----------------------------------------------------------------------
1672    // Embedded coordination skill — proactive publishing + cherry-pick
1673    // -----------------------------------------------------------------------
1674
1675    #[test]
1676    fn embedded_coordination_contains_cherry_pick() {
1677        let content = include_str!("../assets/agent-skills/coordination.md");
1678        assert!(content.contains("git cherry-pick"));
1679    }
1680
1681    #[test]
1682    fn embedded_coordination_documents_automatic_status() {
1683        let content = include_str!("../assets/agent-skills/coordination.md");
1684        let lower = content.to_lowercase();
1685        assert!(lower.contains("automatic"));
1686        assert!(lower.contains("post-commit"));
1687    }
1688
1689    #[test]
1690    fn embedded_coordination_does_not_require_manual_status_publish() {
1691        let content = include_str!("../assets/agent-skills/coordination.md");
1692        assert!(!content.contains("MUST publish `agent.status`"));
1693        assert!(!content.contains("You MUST publish `agent.status`"));
1694    }
1695
1696    #[test]
1697    fn embedded_coordination_still_contains_optin_operations() {
1698        let content = include_str!("../assets/agent-skills/coordination.md");
1699        assert!(content.contains("agent.blocked"));
1700        assert!(content.contains("agent.artifact"));
1701        assert!(content.contains("{{GIT_PAW_BROKER_URL}}/messages/{{BRANCH_ID}}"));
1702    }
1703
1704    #[test]
1705    fn embedded_coordination_requires_no_push() {
1706        let content = include_str!("../assets/agent-skills/coordination.md");
1707        assert!(content.contains("MUST NOT push"));
1708    }
1709
1710    // -----------------------------------------------------------------------
1711    // Git hook installation
1712    // -----------------------------------------------------------------------
1713
1714    #[test]
1715    fn pre_commit_guard_hook_blocks_on_branch_mismatch() {
1716        let script = build_pre_commit_branch_guard_hook();
1717        // Resolves the gitdir robustly (git does not export GIT_DIR to
1718        // pre-commit), gates on the marker, compares HEAD vs expected branch,
1719        // honours the strict opt-out, and exits non-zero on mismatch.
1720        assert!(script.contains("git rev-parse --git-dir"));
1721        assert!(script.contains("PAW_EXPECTED_BRANCH"));
1722        assert!(script.contains("PAW_STRICT_BRANCH_GUARD"));
1723        assert!(script.contains("git symbolic-ref --short HEAD"));
1724        assert!(script.contains("exit 1"));
1725    }
1726
1727    #[test]
1728    fn post_commit_dispatcher_detects_branch_mismatch() {
1729        let script = build_post_commit_dispatcher_hook();
1730        // Detection (without enforcement) publishes both feedback and a
1731        // permission_pattern learning when HEAD differs from the expected branch.
1732        assert!(script.contains("agent.feedback"));
1733        assert!(script.contains("agent.learning"));
1734        assert!(script.contains("permission_pattern"));
1735        assert!(script.contains("PAW_EXPECTED_BRANCH"));
1736    }
1737
1738    #[test]
1739    fn post_commit_dispatcher_hook_reads_marker_and_publishes() {
1740        let script = build_post_commit_dispatcher_hook();
1741        assert!(script.contains("$PAW_GD/paw-agent-id"));
1742        assert!(script.contains(". \"$PAW_GD/paw-agent-id\""));
1743        assert!(script.contains("$PAW_BROKER_URL/publish"));
1744        assert!(script.contains("$PAW_AGENT_ID"));
1745        assert!(script.contains("agent.artifact"));
1746        assert!(script.contains("|| true"));
1747    }
1748
1749    #[test]
1750    fn agent_marker_is_shell_sourceable() {
1751        let marker = build_agent_marker("http://127.0.0.1:9119", "feat-x", None, None, None);
1752        assert!(marker.contains("PAW_AGENT_ID=feat-x"));
1753        assert!(marker.contains("PAW_BROKER_URL=http://127.0.0.1:9119"));
1754    }
1755
1756    #[test]
1757    fn pre_push_hook_only_rejects_agent_worktrees() {
1758        let script = build_pre_push_hook();
1759        // The reject path must still be there so agent worktrees can't push.
1760        assert!(script.contains("exit 1"));
1761        assert!(script.contains("must not push"));
1762        // But it MUST be gated on the agent marker so the main repo and
1763        // non-agent worktrees can still push freely.
1764        assert!(
1765            script.contains("paw-agent-id"),
1766            "pre-push hook must gate the reject on $GIT_DIR/paw-agent-id; \
1767             without the gate, every push from this gitdir is blocked, \
1768             including legitimate pushes from the main repo"
1769        );
1770    }
1771
1772    #[test]
1773    fn chain_hook_replaces_existing_git_paw_block() {
1774        let existing = format!(
1775            "#!/bin/sh\n\
1776             # user hook\n\
1777             echo hi\n\
1778             {HOOK_START_MARKER}\n\
1779             old git-paw content\n\
1780             {HOOK_END_MARKER}\n"
1781        );
1782        let new_body = format!(
1783            "#!/bin/sh\n\
1784             {HOOK_START_MARKER}\n\
1785             new git-paw content\n\
1786             {HOOK_END_MARKER}\n"
1787        );
1788        let chained = chain_hook(&existing, &new_body);
1789        assert!(chained.contains("# user hook"));
1790        assert!(chained.contains("echo hi"));
1791        assert!(chained.contains("new git-paw content"));
1792        assert!(!chained.contains("old git-paw content"));
1793    }
1794
1795    #[test]
1796    fn chain_hook_appends_after_existing_content() {
1797        let existing = "#!/bin/sh\necho existing\n";
1798        let new_body = format!(
1799            "#!/bin/sh\n\
1800             {HOOK_START_MARKER}\n\
1801             new block\n\
1802             {HOOK_END_MARKER}\n"
1803        );
1804        let chained = chain_hook(existing, &new_body);
1805        assert!(chained.starts_with("#!/bin/sh\necho existing"));
1806        assert!(chained.contains("new block"));
1807        // The new shebang should be stripped when chaining.
1808        assert_eq!(chained.matches("#!/bin/sh").count(), 1);
1809    }
1810
1811    #[test]
1812    fn chain_hook_preserves_content_when_end_marker_missing() {
1813        // Corrupted/truncated hook: start marker present, end marker missing.
1814        // The user's shebang and original logic must be preserved verbatim
1815        // and the new git-paw block appended.
1816        let existing = format!(
1817            "#!/bin/sh\n\
1818             # important user logic\n\
1819             echo do_not_lose_me\n\
1820             {HOOK_START_MARKER}\n\
1821             leftover but no end marker\n"
1822        );
1823        let new_body = format!(
1824            "#!/bin/sh\n\
1825             {HOOK_START_MARKER}\n\
1826             new git-paw content\n\
1827             {HOOK_END_MARKER}\n"
1828        );
1829        let chained = chain_hook(&existing, &new_body);
1830        // All original lines must survive.
1831        assert!(chained.contains("#!/bin/sh"));
1832        assert!(chained.contains("# important user logic"));
1833        assert!(chained.contains("echo do_not_lose_me"));
1834        assert!(chained.contains("leftover but no end marker"));
1835        // The new block must be appended.
1836        assert!(chained.contains("new git-paw content"));
1837        assert!(chained.contains(HOOK_END_MARKER));
1838        // The new shebang should be stripped (only the existing one remains).
1839        assert_eq!(chained.matches("#!/bin/sh").count(), 1);
1840    }
1841
1842    #[test]
1843    #[serial_test::serial]
1844    fn install_git_hooks_writes_dispatcher_to_common_git_dir() {
1845        let tmp = tempfile::tempdir().unwrap();
1846        let worktree = tmp.path();
1847        init_git_repo(worktree);
1848
1849        install_git_hooks(worktree, "http://127.0.0.1:9119", "feat-x", "feat/x", true).unwrap();
1850
1851        let post_commit = worktree.join(".git").join("hooks").join("post-commit");
1852        let pre_push = worktree.join(".git").join("hooks").join("pre-push");
1853        let marker = worktree.join(".git").join("paw-agent-id");
1854
1855        assert!(post_commit.exists(), "post-commit should exist");
1856        assert!(pre_push.exists(), "pre-push should exist");
1857        assert!(marker.exists(), "paw-agent-id marker should exist");
1858
1859        let pc = fs::read_to_string(&post_commit).unwrap();
1860        assert!(pc.contains("$PAW_GD/paw-agent-id"));
1861        assert!(pc.contains("agent.artifact"));
1862
1863        // pre-commit branch guard installed alongside the dispatcher.
1864        let pre_commit = worktree.join(".git").join("hooks").join("pre-commit");
1865        assert!(pre_commit.exists(), "pre-commit guard should exist");
1866        let prc = fs::read_to_string(&pre_commit).unwrap();
1867        assert!(prc.contains("branch guard"));
1868        assert!(prc.contains("PAW_EXPECTED_BRANCH"));
1869
1870        let marker_body = fs::read_to_string(&marker).unwrap();
1871        assert!(marker_body.contains("PAW_AGENT_ID=feat-x"));
1872        assert!(marker_body.contains("PAW_BROKER_URL=http://127.0.0.1:9119"));
1873        assert!(marker_body.contains("PAW_EXPECTED_BRANCH=feat/x"));
1874        assert!(marker_body.contains("PAW_STRICT_BRANCH_GUARD=true"));
1875
1876        #[cfg(unix)]
1877        {
1878            use std::os::unix::fs::PermissionsExt;
1879            let mode = fs::metadata(&post_commit).unwrap().permissions().mode();
1880            assert_eq!(mode & 0o111, 0o111, "post-commit must be executable");
1881        }
1882    }
1883
1884    #[test]
1885    #[serial_test::serial]
1886    fn install_git_hooks_preserves_existing_dispatcher_body() {
1887        let tmp = tempfile::tempdir().unwrap();
1888        let worktree = tmp.path();
1889        init_git_repo(worktree);
1890        let hook_path = worktree.join(".git").join("hooks").join("post-commit");
1891        fs::write(&hook_path, "#!/bin/sh\necho user hook\n").unwrap();
1892
1893        install_git_hooks(worktree, "http://127.0.0.1:9119", "feat-x", "feat/x", true).unwrap();
1894
1895        let body = fs::read_to_string(&hook_path).unwrap();
1896        assert!(body.contains("echo user hook"));
1897        assert!(body.contains("agent.artifact"));
1898    }
1899
1900    #[test]
1901    #[serial_test::serial]
1902    fn install_git_hooks_writes_linked_marker_for_linked_worktree() {
1903        let tmp = tempfile::tempdir().unwrap();
1904        let main_repo = tmp.path().join("main");
1905        fs::create_dir_all(&main_repo).unwrap();
1906        init_git_repo(&main_repo);
1907
1908        // Create an empty commit so we can add a worktree.
1909        std::process::Command::new("git")
1910            .args(["commit", "--allow-empty", "-m", "root", "-q"])
1911            .current_dir(&main_repo)
1912            .output()
1913            .unwrap();
1914
1915        // Add a linked worktree.
1916        let linked_path = tmp.path().join("linked");
1917        std::process::Command::new("git")
1918            .args([
1919                "worktree",
1920                "add",
1921                "-b",
1922                "feat-x",
1923                linked_path.to_str().unwrap(),
1924            ])
1925            .current_dir(&main_repo)
1926            .output()
1927            .unwrap();
1928
1929        install_git_hooks(
1930            &linked_path,
1931            "http://127.0.0.1:9119",
1932            "feat-x",
1933            "feat/x",
1934            true,
1935        )
1936        .unwrap();
1937
1938        // Dispatcher lives in main .git/hooks/
1939        let post_commit = main_repo.join(".git").join("hooks").join("post-commit");
1940        assert!(
1941            post_commit.exists(),
1942            "dispatcher must land in main .git/hooks/"
1943        );
1944        // Per-worktree marker lives in main .git/worktrees/linked/
1945        let marker = main_repo
1946            .join(".git")
1947            .join("worktrees")
1948            .join("linked")
1949            .join("paw-agent-id");
1950        assert!(
1951            marker.exists(),
1952            "marker must land in linked worktree gitdir"
1953        );
1954        let body = fs::read_to_string(&marker).unwrap();
1955        assert!(body.contains("PAW_AGENT_ID=feat-x"));
1956    }
1957
1958    // -----------------------------------------------------------------------
1959    // Enhanced Agent Marker Tests
1960    // -----------------------------------------------------------------------
1961
1962    #[test]
1963    fn build_agent_marker_basic_format() {
1964        let marker = build_agent_marker("http://127.0.0.1:9119", "feat-test", None, None, None);
1965
1966        assert!(marker.contains("PAW_AGENT_ID=feat-test"));
1967        assert!(marker.contains("PAW_BROKER_URL=http://127.0.0.1:9119"));
1968        assert!(marker.contains("PAW_TIMESTAMP="));
1969        // Should not contain optional fields
1970        assert!(!marker.contains("PAW_SUPERVISOR_PID"));
1971        assert!(!marker.contains("PAW_LAST_VERIFIED_COMMIT"));
1972        assert!(!marker.contains("PAW_SESSION_NAME"));
1973    }
1974
1975    #[test]
1976    fn build_agent_marker_with_all_extended_fields() {
1977        let marker = build_agent_marker(
1978            "http://localhost:9119",
1979            "feat-errors",
1980            Some(12345),
1981            Some("abc123def456"),
1982            Some("paw-test-session"),
1983        );
1984
1985        assert!(marker.contains("PAW_AGENT_ID=feat-errors"));
1986        assert!(marker.contains("PAW_BROKER_URL=http://localhost:9119"));
1987        assert!(marker.contains("PAW_SUPERVISOR_PID=12345"));
1988        assert!(marker.contains("PAW_LAST_VERIFIED_COMMIT=abc123def456"));
1989        assert!(marker.contains("PAW_SESSION_NAME=paw-test-session"));
1990        assert!(marker.contains("PAW_TIMESTAMP="));
1991    }
1992
1993    #[test]
1994    fn build_agent_marker_partial_extended_fields() {
1995        let marker =
1996            build_agent_marker("http://localhost:9119", "fix-cycle", Some(999), None, None);
1997
1998        assert!(marker.contains("PAW_SUPERVISOR_PID=999"));
1999        assert!(!marker.contains("PAW_LAST_VERIFIED_COMMIT"));
2000        assert!(!marker.contains("PAW_SESSION_NAME"));
2001    }
2002
2003    #[test]
2004    fn update_agent_marker_adds_missing_fields() {
2005        let tmp = tempfile::tempdir().unwrap();
2006        let marker_path = tmp.path().join("test-marker");
2007
2008        // Create initial marker
2009        let initial = "PAW_AGENT_ID=test\nPAW_BROKER_URL=http://localhost:9119\nPAW_TIMESTAMP=2026-01-01T00:00:00Z\n";
2010        fs::write(&marker_path, initial).unwrap();
2011
2012        // Update with supervisor PID
2013        update_agent_marker(&marker_path, Some(54321), None).unwrap();
2014
2015        let updated = fs::read_to_string(&marker_path).unwrap();
2016        assert!(updated.contains("PAW_AGENT_ID=test"));
2017        assert!(updated.contains("PAW_SUPERVISOR_PID=54321"));
2018    }
2019
2020    #[test]
2021    fn update_agent_marker_replaces_existing_fields() {
2022        let tmp = tempfile::tempdir().unwrap();
2023        let marker_path = tmp.path().join("test-marker");
2024
2025        // Create initial marker with old commit
2026        let initial = "PAW_AGENT_ID=test\nPAW_BROKER_URL=http://localhost:9119\nPAW_LAST_VERIFIED_COMMIT=old123\nPAW_TIMESTAMP=2026-01-01T00:00:00Z\n";
2027        fs::write(&marker_path, initial).unwrap();
2028
2029        // Update with new commit
2030        update_agent_marker(&marker_path, None, Some("new456")).unwrap();
2031
2032        let updated = fs::read_to_string(&marker_path).unwrap();
2033        assert!(updated.contains("PAW_AGENT_ID=test"));
2034        assert!(updated.contains("PAW_LAST_VERIFIED_COMMIT=new456"));
2035        assert!(!updated.contains("PAW_LAST_VERIFIED_COMMIT=old123"));
2036    }
2037
2038    #[test]
2039    fn update_agent_marker_reuses_lazy_regex_across_calls() {
2040        // Smoke test for the LazyLock<Regex> hoisting: invoking
2041        // `update_agent_marker` twice in succession must not panic and the
2042        // second call must replace the first call's substituted value.
2043        // Reaching this point at all proves both regexes initialised cleanly.
2044        let tmp = tempfile::tempdir().unwrap();
2045        let marker_path = tmp.path().join("test-marker");
2046
2047        let initial = "PAW_AGENT_ID=test\nPAW_BROKER_URL=http://localhost:9119\nPAW_SUPERVISOR_PID=111\nPAW_LAST_VERIFIED_COMMIT=abc\n";
2048        fs::write(&marker_path, initial).unwrap();
2049
2050        update_agent_marker(&marker_path, Some(222), Some("def")).unwrap();
2051        update_agent_marker(&marker_path, Some(333), Some("ghi")).unwrap();
2052
2053        let updated = fs::read_to_string(&marker_path).unwrap();
2054        assert!(updated.contains("PAW_SUPERVISOR_PID=333"));
2055        assert!(updated.contains("PAW_LAST_VERIFIED_COMMIT=ghi"));
2056        assert!(!updated.contains("PAW_SUPERVISOR_PID=111"));
2057        assert!(!updated.contains("PAW_SUPERVISOR_PID=222"));
2058        assert!(!updated.contains("PAW_LAST_VERIFIED_COMMIT=abc"));
2059        assert!(!updated.contains("PAW_LAST_VERIFIED_COMMIT=def"));
2060    }
2061
2062    #[test]
2063    fn get_agent_marker_path_returns_correct_path() {
2064        let tmp = tempfile::tempdir().unwrap();
2065        let worktree = tmp.path();
2066        init_git_repo(worktree);
2067
2068        let marker_path = get_agent_marker_path(worktree).unwrap();
2069        assert!(marker_path.ends_with(".git/paw-agent-id"));
2070    }
2071
2072    // v0-5-0-audit-cleanup §9c (Bug E) — remove_session_boot_block must
2073    // strip a marker-delimited block from AGENTS.md byte-for-byte and
2074    // remain a no-op for files without markers.
2075
2076    #[test]
2077    fn remove_session_boot_block_strips_marked_block() {
2078        let tmp = tempfile::tempdir().unwrap();
2079        let repo_root = tmp.path();
2080        let agents_md = repo_root.join("AGENTS.md");
2081
2082        let header = "# Project AGENTS";
2083        let footer = "## Footer\n";
2084        let original = format!(
2085            "{header}\n\n<!-- git-paw:start — managed by git-paw, do not edit manually -->\n## boot block\nsome content\n<!-- git-paw:end -->\n\n{footer}"
2086        );
2087        fs::write(&agents_md, &original).unwrap();
2088
2089        remove_session_boot_block(repo_root).unwrap();
2090
2091        let after = fs::read_to_string(&agents_md).unwrap();
2092        let expected = format!("{header}\n\n{footer}");
2093        assert_eq!(
2094            after, expected,
2095            "after removal the file must match HEADER + blank + FOOTER byte-for-byte; got:\n{after:?}",
2096        );
2097        assert!(
2098            !after.contains("git-paw:start"),
2099            "no git-paw:start marker may remain after removal",
2100        );
2101    }
2102
2103    #[test]
2104    fn remove_session_boot_block_no_marker_is_noop() {
2105        let tmp = tempfile::tempdir().unwrap();
2106        let repo_root = tmp.path();
2107        let agents_md = repo_root.join("AGENTS.md");
2108
2109        let original = "# Project AGENTS\n\nNo boot block here.\n";
2110        fs::write(&agents_md, original).unwrap();
2111
2112        remove_session_boot_block(repo_root).unwrap();
2113
2114        let after = fs::read_to_string(&agents_md).unwrap();
2115        assert_eq!(
2116            after, original,
2117            "files without a boot-block marker must be preserved byte-for-byte",
2118        );
2119    }
2120
2121    #[test]
2122    fn remove_session_boot_block_missing_agents_md_is_noop() {
2123        // The helper SHALL be idempotent — calling it against a repo
2124        // root that has no AGENTS.md at all is not an error.
2125        let tmp = tempfile::tempdir().unwrap();
2126        remove_session_boot_block(tmp.path()).unwrap();
2127        assert!(
2128            !tmp.path().join("AGENTS.md").exists(),
2129            "remove_session_boot_block must not create AGENTS.md when none exists",
2130        );
2131    }
2132
2133    #[test]
2134    fn remove_session_boot_block_preserves_no_trailing_newline() {
2135        // If the original file lacks a trailing newline, the helper
2136        // must preserve that shape.
2137        let tmp = tempfile::tempdir().unwrap();
2138        let repo_root = tmp.path();
2139        let agents_md = repo_root.join("AGENTS.md");
2140
2141        let original = "# Header no newline";
2142        fs::write(&agents_md, original).unwrap();
2143
2144        remove_session_boot_block(repo_root).unwrap();
2145
2146        let after = fs::read_to_string(&agents_md).unwrap();
2147        assert_eq!(
2148            after, original,
2149            "file without trailing newline must be preserved exactly"
2150        );
2151    }
2152}