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::{assume_unchanged, exclude_from_git};
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/// Reads the root repo's AGENTS.md, injects the worktree assignment section,
237/// writes the result to the worktree root, and protects it from being committed.
238///
239/// Uses two layers of protection:
240/// 1. `.git/info/exclude` — hides AGENTS.md from `git status`
241/// 2. `git update-index --assume-unchanged` — prevents `git add -A` from staging it
242///
243/// The second layer is critical for AI agents that run `git add -A` or
244/// `git add .` to commit their work — without it, the injected session
245/// content would be committed to the branch.
246pub fn setup_worktree_agents_md(
247    repo_root: &Path,
248    worktree_root: &Path,
249    assignment: &WorktreeAssignment,
250) -> Result<(), PawError> {
251    let root_agents = repo_root.join("AGENTS.md");
252    let root_content = match fs::read_to_string(&root_agents) {
253        Ok(c) => c,
254        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
255        Err(e) => {
256            return Err(PawError::AgentsMdError(format!(
257                "failed to read '{}': {e}",
258                root_agents.display()
259            )));
260        }
261    };
262
263    let section = generate_worktree_section(assignment);
264    let output = inject_into_content(&root_content, &section);
265
266    let worktree_agents = worktree_root.join("AGENTS.md");
267    fs::write(&worktree_agents, &output).map_err(|e| {
268        PawError::AgentsMdError(format!(
269            "failed to write '{}': {e}",
270            worktree_agents.display()
271        ))
272    })?;
273
274    exclude_from_git(worktree_root, "AGENTS.md")?;
275
276    // Belt-and-suspenders: mark the file as assume-unchanged so `git add -A`
277    // doesn't stage it. This only works when AGENTS.md is already tracked in
278    // the index (which it is for worktrees of repos that have a tracked
279    // AGENTS.md). For repos without a tracked AGENTS.md, exclude_from_git
280    // above is the primary protection.
281    let _ = assume_unchanged(worktree_root, "AGENTS.md");
282
283    Ok(())
284}
285
286/// Returns the path to the agent marker file for a given worktree.
287pub fn get_agent_marker_path(worktree: &Path) -> Result<PathBuf, PawError> {
288    let linked_git_dir = git_rev_parse_path(worktree, "--git-dir")?;
289    Ok(linked_git_dir.join("paw-agent-id"))
290}
291
292/// Builds the agent marker file content with optional extended fields.
293///
294/// Basic format (always included):
295/// ```text
296/// PAW_AGENT_ID=<agent_id>
297/// PAW_BROKER_URL=<broker_url>
298/// ```
299///
300/// Extended format (optional fields):
301/// ```text
302/// PAW_SUPERVISOR_PID=<pid>
303/// PAW_LAST_VERIFIED_COMMIT=<commit_hash>
304/// PAW_SESSION_NAME=<session_name>
305/// PAW_TIMESTAMP=<iso_timestamp>
306/// ```
307pub fn build_agent_marker(
308    broker_url: &str,
309    agent_id: &str,
310    supervisor_pid: Option<u32>,
311    last_verified_commit: Option<&str>,
312    session_name: Option<&str>,
313) -> String {
314    let mut marker = format!("PAW_AGENT_ID={agent_id}\nPAW_BROKER_URL={broker_url}\n");
315
316    // Add optional extended fields
317    if let Some(pid) = supervisor_pid {
318        let _ = writeln!(marker, "PAW_SUPERVISOR_PID={pid}");
319    }
320    if let Some(commit) = last_verified_commit {
321        let _ = writeln!(marker, "PAW_LAST_VERIFIED_COMMIT={commit}");
322    }
323    if let Some(session) = session_name {
324        let _ = writeln!(marker, "PAW_SESSION_NAME={session}");
325    }
326
327    // Always add timestamp for debugging/tracing
328    let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
329    let _ = writeln!(marker, "PAW_TIMESTAMP={timestamp}");
330
331    marker
332}
333
334/// Updates an existing agent marker file with additional fields.
335///
336/// This allows adding supervisor-specific information after the initial marker creation.
337pub fn update_agent_marker(
338    marker_path: &Path,
339    supervisor_pid: Option<u32>,
340    last_verified_commit: Option<&str>,
341) -> Result<(), PawError> {
342    let content = fs::read_to_string(marker_path)
343        .map_err(|e| PawError::AgentsMdError(format!("failed to read marker file: {e}")))?;
344
345    let mut updated = content;
346
347    // Update supervisor PID if provided
348    if let Some(pid) = supervisor_pid {
349        if updated.contains("PAW_SUPERVISOR_PID=") {
350            // Replace existing PID
351            updated = SUPERVISOR_PID_REGEX
352                .replace(&updated, &format!("PAW_SUPERVISOR_PID={pid}"))
353                .to_string();
354        } else {
355            // Add new PID line
356            let _ = write!(updated, "\nPAW_SUPERVISOR_PID={pid}");
357        }
358    }
359
360    // Update last verified commit if provided
361    if let Some(commit) = last_verified_commit {
362        if updated.contains("PAW_LAST_VERIFIED_COMMIT=") {
363            // Replace existing commit
364            updated = LAST_VERIFIED_COMMIT_REGEX
365                .replace(&updated, &format!("PAW_LAST_VERIFIED_COMMIT={commit}"))
366                .to_string();
367        } else {
368            // Add new commit line
369            let _ = write!(updated, "\nPAW_LAST_VERIFIED_COMMIT={commit}");
370        }
371    }
372
373    fs::write(marker_path, updated)
374        .map_err(|e| PawError::AgentsMdError(format!("failed to update marker file: {e}")))?;
375
376    Ok(())
377}
378
379/// Builds the dispatcher `post-commit` hook installed at the main repo's
380/// `.git/hooks/post-commit`.
381///
382/// Linked git worktrees share the main repo's `hooks/` directory (git-worktree
383/// does not use per-worktree hook directories unless `extensions.worktreeConfig`
384/// is enabled, which is an intrusive repo-wide setting). Instead we install a
385/// single dispatcher and store per-worktree `agent_id` and `broker_url` in
386/// `$GIT_DIR/paw-agent-id` — `$GIT_DIR` is set by git to the correct
387/// per-worktree gitdir when the hook runs, so the dispatcher reads the right
388/// file for whichever worktree just committed.
389fn build_post_commit_dispatcher_hook() -> String {
390    format!(
391        "#!/bin/sh\n\
392         {HOOK_START_MARKER}\n\
393         # Dispatcher: reads the per-worktree paw-agent-id marker and publishes\n\
394         # agent.artifact to the git-paw broker. Resolve the gitdir via\n\
395         # rev-parse with a GIT_DIR fallback (git does not always export it).\n\
396         PAW_GD=\"${{GIT_DIR:-$(git rev-parse --git-dir 2>/dev/null)}}\"\n\
397         if [ -n \"$PAW_GD\" ] && [ -f \"$PAW_GD/paw-agent-id\" ]; then\n\
398             . \"$PAW_GD/paw-agent-id\"\n\
399             FILES=$(git diff HEAD~1 --name-only 2>/dev/null | awk '{{printf \"%s\\\"%s\\\"\", (NR>1?\",\":\"\"), $0}}')\n\
400             curl -s -X POST \"$PAW_BROKER_URL/publish\" \\\n\
401                 -H 'Content-Type: application/json' \\\n\
402                 -d \"{{\\\"type\\\":\\\"agent.artifact\\\",\\\"agent_id\\\":\\\"$PAW_AGENT_ID\\\",\\\"payload\\\":{{\\\"status\\\":\\\"committed\\\",\\\"exports\\\":[],\\\"modified_files\\\":[$FILES]}}}}\" \\\n\
403                 >/dev/null 2>&1 || true\n\
404             # Branch-mismatch detection (detection without enforcement — fires\n\
405             # regardless of PAW_STRICT_BRANCH_GUARD; the pre-commit hook owns\n\
406             # blocking). Publishes agent.feedback + an agent.learning record\n\
407             # (category permission_pattern) identifying the contamination.\n\
408             if [ -n \"$PAW_EXPECTED_BRANCH\" ]; then\n\
409                 PAW_CUR=$(git symbolic-ref --short HEAD 2>/dev/null)\n\
410                 if [ -n \"$PAW_CUR\" ] && [ \"$PAW_CUR\" != \"$PAW_EXPECTED_BRANCH\" ]; then\n\
411                     PAW_SHA=$(git rev-parse HEAD 2>/dev/null)\n\
412                     curl -s -X POST \"$PAW_BROKER_URL/publish\" \\\n\
413                         -H 'Content-Type: application/json' \\\n\
414                         -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\
415                         >/dev/null 2>&1 || true\n\
416                     curl -s -X POST \"$PAW_BROKER_URL/publish\" \\\n\
417                         -H 'Content-Type: application/json' \\\n\
418                         -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\
419                         >/dev/null 2>&1 || true\n\
420                 fi\n\
421             fi\n\
422         fi\n\
423         {HOOK_END_MARKER}\n"
424    )
425}
426
427fn build_pre_push_hook() -> String {
428    // Only reject when the calling worktree is an agent worktree — i.e.
429    // a `paw-agent-id` marker exists in this worktree's gitdir. The hook
430    // installs into the common gitdir (shared with the main repo and all
431    // linked worktrees), so without this gate the hook would also block
432    // legitimate pushes from the main repo. Mirror the post-commit
433    // dispatcher's gate at line 388 so behaviour is consistent.
434    format!(
435        "#!/bin/sh\n\
436         {HOOK_START_MARKER}\n\
437         if [ -n \"$GIT_DIR\" ] && [ -f \"$GIT_DIR/paw-agent-id\" ]; then\n\
438         echo 'error: git-paw agents must not push. The supervisor handles merges.' >&2\n\
439         exit 1\n\
440         fi\n\
441         {HOOK_END_MARKER}\n"
442    )
443}
444
445/// Builds the `pre-commit` branch-guard hook.
446///
447/// Sources the per-worktree `$GIT_DIR/paw-agent-id` marker and, when
448/// `PAW_STRICT_BRANCH_GUARD` is not `false`, refuses a commit whose
449/// `git symbolic-ref --short HEAD` differs from `PAW_EXPECTED_BRANCH` — the
450/// branch the worktree was created for. This blocks cross-worktree
451/// contamination, where a commit advances the wrong branch because linked
452/// worktrees share `.git/refs`. The opt-out (`strict_branch_guard = false`)
453/// turns enforcement off while leaving the post-commit detection in place.
454/// Gated on the marker's presence so non-agent checkouts (the main repo)
455/// committing through the shared hooks dir are never blocked.
456fn build_pre_commit_branch_guard_hook() -> String {
457    format!(
458        "#!/bin/sh\n\
459         {HOOK_START_MARKER}\n\
460         # Branch guard: refuse a commit that would advance a branch other than\n\
461         # the one this worktree was created for (cross-worktree contamination).\n\
462         # git does not reliably export GIT_DIR to pre-commit, so resolve the\n\
463         # per-worktree gitdir via rev-parse with a GIT_DIR fallback.\n\
464         PAW_GD=\"${{GIT_DIR:-$(git rev-parse --git-dir 2>/dev/null)}}\"\n\
465         if [ -n \"$PAW_GD\" ] && [ -f \"$PAW_GD/paw-agent-id\" ]; then\n\
466             . \"$PAW_GD/paw-agent-id\"\n\
467             if [ -n \"$PAW_EXPECTED_BRANCH\" ] && [ \"$PAW_STRICT_BRANCH_GUARD\" != \"false\" ]; then\n\
468                 PAW_CUR=$(git symbolic-ref --short HEAD 2>/dev/null)\n\
469                 if [ -n \"$PAW_CUR\" ] && [ \"$PAW_CUR\" != \"$PAW_EXPECTED_BRANCH\" ]; then\n\
470                     echo \"error: git-paw branch guard refused this commit\" >&2\n\
471                     echo \"  HEAD is on '$PAW_CUR' but this worktree is for '$PAW_EXPECTED_BRANCH'.\" >&2\n\
472                     echo \"  The commit would advance the wrong branch. Switch back to '$PAW_EXPECTED_BRANCH'\" >&2\n\
473                     echo \"  (or set [supervisor] strict_branch_guard = false to override).\" >&2\n\
474                     exit 1\n\
475                 fi\n\
476             fi\n\
477         fi\n\
478         {HOOK_END_MARKER}\n"
479    )
480}
481
482/// Chains `new_body` onto `existing`, preserving the existing content.
483///
484/// If `existing` already contains a complete git-paw marker block, it is
485/// replaced. If only the start marker is present (corrupted/truncated block),
486/// the existing content is preserved verbatim and `new_body` is appended —
487/// never silently discarded — so the user's shebang and original logic stay
488/// intact. Otherwise `new_body` is appended after the existing content.
489fn chain_hook(existing: &str, new_body: &str) -> String {
490    // Complete marker block — replace it. If only the start marker is
491    // present (corrupted/truncated block), fall through to the append path
492    // so the user's shebang and original logic are preserved instead of
493    // being silently discarded.
494    if let Some(start) = existing.find(HOOK_START_MARKER)
495        && let Some(end_rel) = existing[start..].find(HOOK_END_MARKER)
496    {
497        let end = start + end_rel + HOOK_END_MARKER.len();
498        let mut out = String::with_capacity(existing.len() + new_body.len());
499        out.push_str(&existing[..start]);
500        // Strip the shebang from the new body when chaining onto an existing
501        // hook — the existing file already has one.
502        let stripped = new_body.strip_prefix("#!/bin/sh\n").unwrap_or(new_body);
503        out.push_str(stripped);
504        out.push_str(&existing[end..]);
505        return out;
506    }
507    let mut out = existing.trim_end().to_string();
508    if !out.is_empty() {
509        out.push('\n');
510    }
511    let stripped = if out.is_empty() {
512        new_body.to_string()
513    } else {
514        new_body
515            .strip_prefix("#!/bin/sh\n")
516            .unwrap_or(new_body)
517            .to_string()
518    };
519    out.push_str(&stripped);
520    out
521}
522
523fn write_hook_file(hook_path: &Path, new_body: &str) -> Result<(), PawError> {
524    let existing = match fs::read_to_string(hook_path) {
525        Ok(c) => c,
526        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
527        Err(e) => {
528            return Err(PawError::AgentsMdError(format!(
529                "failed to read '{}': {e}",
530                hook_path.display()
531            )));
532        }
533    };
534
535    let content = if existing.is_empty() {
536        new_body.to_string()
537    } else {
538        chain_hook(&existing, new_body)
539    };
540
541    if let Some(parent) = hook_path.parent() {
542        fs::create_dir_all(parent).map_err(|e| {
543            PawError::AgentsMdError(format!("failed to create '{}': {e}", parent.display()))
544        })?;
545    }
546
547    fs::write(hook_path, content.as_bytes()).map_err(|e| {
548        PawError::AgentsMdError(format!("failed to write '{}': {e}", hook_path.display()))
549    })?;
550
551    #[cfg(unix)]
552    {
553        use std::os::unix::fs::PermissionsExt;
554        let mut perms = fs::metadata(hook_path)
555            .map_err(|e| {
556                PawError::AgentsMdError(format!("failed to stat '{}': {e}", hook_path.display()))
557            })?
558            .permissions();
559        perms.set_mode(0o755);
560        fs::set_permissions(hook_path, perms).map_err(|e| {
561            PawError::AgentsMdError(format!("failed to chmod '{}': {e}", hook_path.display()))
562        })?;
563    }
564
565    Ok(())
566}
567
568/// Resolves a path from `git rev-parse <flag>` inside `worktree`.
569///
570/// Returns the absolute, trimmed path. The output of `git rev-parse` may be
571/// relative to the worktree, so we canonicalise it against the worktree root
572/// when it is not already absolute.
573fn git_rev_parse_path(worktree: &Path, flag: &str) -> Result<PathBuf, PawError> {
574    let output = std::process::Command::new("git")
575        .current_dir(worktree)
576        .args(["rev-parse", flag])
577        .output()
578        .map_err(|e| PawError::AgentsMdError(format!("failed to run git rev-parse {flag}: {e}")))?;
579    if !output.status.success() {
580        let stderr = String::from_utf8_lossy(&output.stderr);
581        return Err(PawError::AgentsMdError(format!(
582            "git rev-parse {flag} failed in '{}': {stderr}",
583            worktree.display()
584        )));
585    }
586    let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
587    let path = PathBuf::from(&raw);
588    if path.is_absolute() {
589        Ok(path)
590    } else {
591        Ok(worktree.join(path))
592    }
593}
594
595/// Installs git-paw's `post-commit` dispatcher and `pre-push` block hook.
596///
597/// Linked git worktrees share the main repository's `.git/hooks/` directory
598/// (unless `extensions.worktreeConfig` is enabled, which is intrusive). This
599/// function therefore:
600///
601/// 1. Resolves the **common** git dir via `git rev-parse --git-common-dir` and
602///    installs the dispatcher hooks at `<common>/hooks/post-commit` and
603///    `<common>/hooks/pre-push` (chained onto any existing hooks).
604/// 2. Resolves the **linked** git dir via `git rev-parse --git-dir` and writes
605///    a per-worktree marker file at `<linked>/paw-agent-id` containing the
606///    `PAW_AGENT_ID` and `PAW_BROKER_URL` values the dispatcher will source.
607///
608/// The dispatcher hook reads `$GIT_DIR/paw-agent-id` at commit time — git sets
609/// `GIT_DIR` to the correct per-worktree gitdir, so each worktree publishes
610/// under its own agent id.
611pub fn install_git_hooks(
612    worktree: &Path,
613    broker_url: &str,
614    agent_id: &str,
615    expected_branch: &str,
616    strict_branch_guard: bool,
617) -> Result<(), PawError> {
618    let common_git_dir = git_rev_parse_path(worktree, "--git-common-dir")?;
619    let linked_git_dir = git_rev_parse_path(worktree, "--git-dir")?;
620    let hooks_dir = common_git_dir.join("hooks");
621
622    write_hook_file(
623        &hooks_dir.join("post-commit"),
624        &build_post_commit_dispatcher_hook(),
625    )?;
626    write_hook_file(&hooks_dir.join("pre-push"), &build_pre_push_hook())?;
627    write_hook_file(
628        &hooks_dir.join("pre-commit"),
629        &build_pre_commit_branch_guard_hook(),
630    )?;
631
632    let marker_path = linked_git_dir.join("paw-agent-id");
633    if let Some(parent) = marker_path.parent() {
634        fs::create_dir_all(parent).map_err(|e| {
635            PawError::AgentsMdError(format!("failed to create '{}': {e}", parent.display()))
636        })?;
637    }
638    // The branch-guard fields are appended to the marker the hooks source.
639    // `PAW_EXPECTED_BRANCH` is the branch this worktree was created for;
640    // `PAW_STRICT_BRANCH_GUARD` controls whether the pre-commit hook *blocks*
641    // (vs. detection-only via post-commit).
642    let mut marker = build_agent_marker(broker_url, agent_id, None, None, None);
643    let _ = writeln!(marker, "PAW_EXPECTED_BRANCH={expected_branch}");
644    let _ = writeln!(
645        marker,
646        "PAW_STRICT_BRANCH_GUARD={}",
647        if strict_branch_guard { "true" } else { "false" }
648    );
649    fs::write(&marker_path, marker).map_err(|e| {
650        PawError::AgentsMdError(format!("failed to write '{}': {e}", marker_path.display()))
651    })?;
652
653    Ok(())
654}
655
656pub fn inject_section_into_file(path: &Path, section: &str) -> Result<(), PawError> {
657    let content = match fs::read_to_string(path) {
658        Ok(c) => c,
659        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
660        Err(e) => {
661            return Err(PawError::AgentsMdError(format!(
662                "failed to read '{}': {e}",
663                path.display()
664            )));
665        }
666    };
667
668    let output = inject_into_content(&content, section);
669
670    fs::write(path, &output)
671        .map_err(|e| PawError::AgentsMdError(format!("failed to write '{}': {e}", path.display())))
672}
673
674/// Removes the git-paw managed block (start marker through end marker,
675/// inclusive — plus any blank lines immediately adjacent) from `content`.
676///
677/// If `content` has no start marker, returns `content` unchanged. This makes
678/// the helper safe to call unconditionally during teardown.
679///
680/// Adjacency rule: the helper consumes ONE leading blank line and ONE trailing
681/// blank line that surround the block, restoring the file to its
682/// pre-injection shape (`inject_into_content` inserts a leading blank line
683/// when appending to a non-empty file).
684pub fn remove_git_paw_section(content: &str) -> String {
685    let lines: Vec<&str> = content.lines().collect();
686
687    let Some(start_idx) = lines
688        .iter()
689        .position(|l| l.starts_with(START_MARKER_PREFIX))
690    else {
691        // No marker — preserve content byte-for-byte.
692        return content.to_string();
693    };
694
695    let end_idx = lines[start_idx..]
696        .iter()
697        .position(|l| l.contains(END_MARKER))
698        .map(|rel| start_idx + rel);
699
700    // Compute the range to delete: [delete_start, delete_end_exclusive).
701    let delete_start = start_idx;
702    let delete_end_exclusive = end_idx.map_or(lines.len(), |e| e + 1);
703
704    // Consume at most ONE adjacent blank line to avoid collapsing the
705    // surrounding paragraph spacing. Prefer the trailing blank because
706    // `inject_into_content` inserts a leading blank when appending the
707    // block, so the trailing blank is more likely to be vestigial.
708    // If only a leading blank exists, fall back to that.
709    let mut delete_end = delete_end_exclusive;
710    let mut adjusted_start = delete_start;
711    if delete_end < lines.len() && lines[delete_end].is_empty() {
712        delete_end += 1;
713    } else if adjusted_start > 0 && lines[adjusted_start - 1].is_empty() {
714        adjusted_start -= 1;
715    }
716    let delete_start = adjusted_start;
717
718    let mut result = String::new();
719    for line in &lines[..delete_start] {
720        result.push_str(line);
721        result.push('\n');
722    }
723    for line in &lines[delete_end..] {
724        result.push_str(line);
725        result.push('\n');
726    }
727
728    // Preserve trailing-newline behaviour of the original file when the
729    // result is not already terminated with one.
730    if content.ends_with('\n') && !result.ends_with('\n') && !result.is_empty() {
731        result.push('\n');
732    }
733
734    // If the original file lacked a trailing newline AND the result
735    // gained one from our line-by-line reconstruction, trim it back.
736    if !content.ends_with('\n') && result.ends_with('\n') {
737        result.pop();
738    }
739
740    result
741}
742
743/// Reads `path` (treating a missing file as empty), removes any
744/// git-paw managed block, and writes the result back. Idempotent: a file
745/// with no markers is a no-op and the original content is preserved
746/// byte-for-byte.
747///
748/// v0-5-0-audit-cleanup Bug E — `cmd_stop` and `cmd_purge` invoke this
749/// against the repo-root `AGENTS.md` after teardown so the supervisor-
750/// pane boot block does not accumulate across sessions.
751pub fn remove_session_boot_block(repo_root: &Path) -> Result<(), PawError> {
752    let agents_md = repo_root.join("AGENTS.md");
753    let content = match fs::read_to_string(&agents_md) {
754        Ok(c) => c,
755        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
756        Err(e) => {
757            return Err(PawError::AgentsMdError(format!(
758                "failed to read '{}': {e}",
759                agents_md.display()
760            )));
761        }
762    };
763
764    let new_content = remove_git_paw_section(&content);
765    if new_content == content {
766        // No marker block — nothing to write.
767        return Ok(());
768    }
769
770    fs::write(&agents_md, &new_content).map_err(|e| {
771        PawError::AgentsMdError(format!("failed to write '{}': {e}", agents_md.display()))
772    })
773}
774
775#[cfg(test)]
776mod tests {
777    use super::*;
778
779    /// Test helper: generates a sample marker-delimited section for testing injection logic.
780    fn sample_section() -> String {
781        format!("{START_MARKER}\n## git-paw test section\n{END_MARKER}\n")
782    }
783
784    // -----------------------------------------------------------------------
785    // has_git_paw_section
786    // -----------------------------------------------------------------------
787
788    #[test]
789    fn has_section_returns_true_when_marker_present() {
790        let content = "# My Project\n\n<!-- git-paw:start — managed by git-paw, do not edit manually -->\nstuff\n<!-- git-paw:end -->\n";
791        assert!(has_git_paw_section(content));
792    }
793
794    #[test]
795    fn has_section_returns_false_without_marker() {
796        let content = "# My Project\n\nSome instructions.\n";
797        assert!(!has_git_paw_section(content));
798    }
799
800    #[test]
801    fn has_section_returns_false_for_empty() {
802        assert!(!has_git_paw_section(""));
803    }
804
805    // -----------------------------------------------------------------------
806    // generate_git_paw_section
807    // -----------------------------------------------------------------------
808
809    #[test]
810    fn generated_section_has_markers() {
811        let section = sample_section();
812        assert!(section.starts_with(START_MARKER));
813        assert!(section.contains(END_MARKER));
814    }
815
816    #[test]
817    fn sample_section_contains_git_paw_reference() {
818        let section = sample_section();
819        assert!(section.contains("git-paw"));
820    }
821
822    // -----------------------------------------------------------------------
823    // replace_git_paw_section
824    // -----------------------------------------------------------------------
825
826    #[test]
827    fn replace_with_both_markers_preserves_surrounding() {
828        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";
829        let new_section = "<!-- git-paw:start — managed by git-paw, do not edit manually -->\nnew content\n<!-- git-paw:end -->\n";
830        let result = replace_git_paw_section(content, new_section);
831        assert!(result.contains("# Title"));
832        assert!(result.contains("new content"));
833        assert!(!result.contains("old content"));
834        assert!(result.contains("## Footer"));
835    }
836
837    #[test]
838    fn replace_with_missing_end_marker_replaces_to_eof() {
839        let content = "# Title\n\n<!-- git-paw:start — managed by git-paw, do not edit manually -->\nold content that never ends\n";
840        let new_section = "<!-- git-paw:start — managed by git-paw, do not edit manually -->\nfixed\n<!-- git-paw:end -->\n";
841        let result = replace_git_paw_section(content, new_section);
842        assert!(result.contains("# Title"));
843        assert!(result.contains("fixed"));
844        assert!(!result.contains("old content"));
845    }
846
847    // -----------------------------------------------------------------------
848    // inject_into_content
849    // -----------------------------------------------------------------------
850
851    #[test]
852    fn inject_appends_when_no_existing_section() {
853        let content = "# My Project\n\nSome info.\n";
854        let section = sample_section();
855        let result = inject_into_content(content, &section);
856        assert!(result.starts_with("# My Project"));
857        assert!(result.contains(START_MARKER));
858    }
859
860    #[test]
861    fn inject_replaces_existing_section() {
862        let old_section = format!("{START_MARKER}\nold\n{END_MARKER}\n");
863        let content = format!("# Title\n\n{old_section}\n## Footer\n");
864        let new_section = format!("{START_MARKER}\nnew\n{END_MARKER}\n");
865        let result = inject_into_content(&content, &new_section);
866        assert!(result.contains("new"));
867        assert!(!result.contains("old"));
868        assert!(result.contains("## Footer"));
869    }
870
871    #[test]
872    fn inject_into_empty_content_returns_section_only() {
873        let section = sample_section();
874        let result = inject_into_content("", &section);
875        assert_eq!(result, section);
876    }
877
878    // -----------------------------------------------------------------------
879    // Spacing tests
880    // -----------------------------------------------------------------------
881
882    #[test]
883    fn spacing_with_trailing_newline() {
884        let content = "# Title\n";
885        let section = "<!-- git-paw:start -->\n<!-- git-paw:end -->\n";
886        let result = inject_into_content(content, section);
887        // Should have blank line separator: "# Title\n\n<!-- git-paw..."
888        assert!(result.contains("# Title\n\n<!-- git-paw:start"));
889    }
890
891    #[test]
892    fn spacing_without_trailing_newline() {
893        let content = "# Title";
894        let section = "<!-- git-paw:start -->\n<!-- git-paw:end -->\n";
895        let result = inject_into_content(content, section);
896        // Should add newline + blank line: "# Title\n\n<!-- git-paw..."
897        assert!(result.contains("# Title\n\n<!-- git-paw:start"));
898    }
899
900    // -----------------------------------------------------------------------
901    // File I/O tests
902    // -----------------------------------------------------------------------
903
904    #[test]
905    fn file_inject_appends_to_existing() {
906        let dir = tempfile::tempdir().unwrap();
907        let path = dir.path().join("AGENTS.md");
908        fs::write(&path, "# Existing\n").unwrap();
909
910        let section = sample_section();
911        inject_section_into_file(&path, &section).unwrap();
912
913        let result = fs::read_to_string(&path).unwrap();
914        assert!(result.contains("# Existing"));
915        assert!(result.contains(START_MARKER));
916    }
917
918    #[test]
919    fn file_inject_replaces_existing_section() {
920        let dir = tempfile::tempdir().unwrap();
921        let path = dir.path().join("AGENTS.md");
922        let initial = format!("# Title\n\n{START_MARKER}\nold\n{END_MARKER}\n");
923        fs::write(&path, &initial).unwrap();
924
925        let new_section = sample_section();
926        inject_section_into_file(&path, &new_section).unwrap();
927
928        let result = fs::read_to_string(&path).unwrap();
929        assert!(result.contains("# Title"));
930        assert!(!result.contains("\nold\n"));
931        assert!(result.contains("git-paw test section"));
932    }
933
934    #[test]
935    fn file_inject_creates_missing_file() {
936        let dir = tempfile::tempdir().unwrap();
937        let path = dir.path().join("AGENTS.md");
938        assert!(!path.exists());
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(START_MARKER));
945    }
946
947    #[test]
948    fn file_inject_readonly_returns_error() {
949        use std::os::unix::fs::PermissionsExt;
950
951        let dir = tempfile::tempdir().unwrap();
952        let path = dir.path().join("AGENTS.md");
953        fs::write(&path, "content").unwrap();
954        fs::set_permissions(&path, fs::Permissions::from_mode(0o444)).unwrap();
955
956        let section = sample_section();
957        let result = inject_section_into_file(&path, &section);
958        assert!(result.is_err());
959        let err = result.unwrap_err();
960        let msg = err.to_string();
961        assert!(msg.contains("AGENTS.md error"), "got: {msg}");
962        assert!(
963            msg.contains("AGENTS.md"),
964            "should mention file path, got: {msg}"
965        );
966
967        // Cleanup: restore permissions so tempdir can be removed
968        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
969    }
970
971    // -----------------------------------------------------------------------
972    // generate_worktree_section
973    // -----------------------------------------------------------------------
974
975    fn make_assignment(spec: Option<&str>, files: Option<Vec<&str>>) -> WorktreeAssignment {
976        WorktreeAssignment {
977            branch: "feat/foo".to_string(),
978            cli: "claude".to_string(),
979            spec_content: spec.map(ToString::to_string),
980            owned_files: files.map(|v| v.into_iter().map(ToString::to_string).collect()),
981            skill_content: None,
982            inter_agent_rules: None,
983        }
984    }
985
986    fn make_assignment_with_skill(
987        spec: Option<&str>,
988        files: Option<Vec<&str>>,
989        skill: Option<&str>,
990    ) -> WorktreeAssignment {
991        WorktreeAssignment {
992            branch: "feat/foo".to_string(),
993            cli: "claude".to_string(),
994            spec_content: spec.map(ToString::to_string),
995            owned_files: files.map(|v| v.into_iter().map(ToString::to_string).collect()),
996            skill_content: skill.map(ToString::to_string),
997            inter_agent_rules: None,
998        }
999    }
1000
1001    #[test]
1002    fn worktree_section_all_fields() {
1003        let assignment = make_assignment(
1004            Some("Implement the widget.\n"),
1005            Some(vec!["src/widget.rs", "tests/widget.rs"]),
1006        );
1007        let section = generate_worktree_section(&assignment);
1008        assert!(section.starts_with(START_MARKER));
1009        assert!(section.contains(END_MARKER));
1010        assert!(section.contains("`feat/foo`"));
1011        assert!(section.contains("claude"));
1012        assert!(section.contains("### Spec"));
1013        assert!(section.contains("Implement the widget."));
1014        assert!(section.contains("### File Ownership"));
1015        assert!(section.contains("`src/widget.rs`"));
1016        assert!(section.contains("`tests/widget.rs`"));
1017    }
1018
1019    #[test]
1020    fn worktree_section_no_spec() {
1021        let assignment = make_assignment(None, Some(vec!["src/main.rs"]));
1022        let section = generate_worktree_section(&assignment);
1023        assert!(section.contains("`feat/foo`"));
1024        assert!(!section.contains("### Spec"));
1025        assert!(section.contains("### File Ownership"));
1026    }
1027
1028    #[test]
1029    fn worktree_section_no_files() {
1030        let assignment = make_assignment(Some("Do the thing.\n"), None);
1031        let section = generate_worktree_section(&assignment);
1032        assert!(section.contains("### Spec"));
1033        assert!(!section.contains("### File Ownership"));
1034    }
1035
1036    #[test]
1037    fn worktree_section_minimal() {
1038        let assignment = make_assignment(None, None);
1039        let section = generate_worktree_section(&assignment);
1040        assert!(section.starts_with(START_MARKER));
1041        assert!(section.contains(END_MARKER));
1042        assert!(section.contains("`feat/foo`"));
1043        assert!(section.contains("claude"));
1044        assert!(!section.contains("### Spec"));
1045        assert!(!section.contains("### File Ownership"));
1046    }
1047
1048    // -----------------------------------------------------------------------
1049    // setup_worktree_agents_md
1050    // -----------------------------------------------------------------------
1051
1052    /// Creates a real git repo in a tempdir (git init + initial commit).
1053    ///
1054    /// Resolves the absolute path to `git` once to avoid ENOENT races
1055    /// under heavy parallel test load on macOS.
1056    fn init_git_repo(dir: &Path) {
1057        use std::process::Command;
1058        let git = which::which("git").expect("git must be on PATH");
1059        Command::new(&git)
1060            .current_dir(dir)
1061            .args(["init"])
1062            .output()
1063            .expect("git init");
1064        Command::new(&git)
1065            .current_dir(dir)
1066            .args(["config", "user.email", "test@test.com"])
1067            .output()
1068            .expect("git config email");
1069        Command::new(&git)
1070            .current_dir(dir)
1071            .args(["config", "user.name", "Test"])
1072            .output()
1073            .expect("git config name");
1074        // Create and commit a file so HEAD exists
1075        fs::write(dir.join("README.md"), "# test\n").unwrap();
1076        Command::new(&git)
1077            .current_dir(dir)
1078            .args(["add", "README.md"])
1079            .output()
1080            .expect("git add");
1081        Command::new(&git)
1082            .current_dir(dir)
1083            .args(["commit", "-m", "init"])
1084            .output()
1085            .expect("git commit");
1086    }
1087
1088    #[test]
1089    fn setup_worktree_root_exists() {
1090        let repo = tempfile::tempdir().unwrap();
1091        let wt = tempfile::tempdir().unwrap();
1092        init_git_repo(wt.path());
1093        fs::write(repo.path().join("AGENTS.md"), "# Project Rules\n").unwrap();
1094
1095        // Track AGENTS.md in the worktree's git index so assume-unchanged works
1096        fs::write(wt.path().join("AGENTS.md"), "# placeholder\n").unwrap();
1097        std::process::Command::new("git")
1098            .current_dir(wt.path())
1099            .args(["add", "AGENTS.md"])
1100            .output()
1101            .expect("git add AGENTS.md");
1102        std::process::Command::new("git")
1103            .current_dir(wt.path())
1104            .args(["commit", "-m", "add agents"])
1105            .output()
1106            .expect("git commit");
1107
1108        let assignment = make_assignment(None, None);
1109        setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
1110
1111        let result = fs::read_to_string(wt.path().join("AGENTS.md")).unwrap();
1112        assert!(result.contains("# Project Rules"));
1113        assert!(result.contains("`feat/foo`"));
1114        assert!(result.contains(START_MARKER));
1115
1116        // Verify AGENTS.md is hidden from git status (assume-unchanged)
1117        let status = std::process::Command::new("git")
1118            .current_dir(wt.path())
1119            .args(["status", "--porcelain"])
1120            .output()
1121            .expect("git status");
1122        let status_output = String::from_utf8_lossy(&status.stdout);
1123        assert!(
1124            !status_output.contains("AGENTS.md"),
1125            "AGENTS.md should not appear in git status, got: {status_output}"
1126        );
1127    }
1128
1129    #[test]
1130    fn setup_worktree_root_missing() {
1131        let repo = tempfile::tempdir().unwrap();
1132        let wt = tempfile::tempdir().unwrap();
1133        init_git_repo(wt.path());
1134
1135        let assignment = make_assignment(None, None);
1136        setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
1137
1138        let result = fs::read_to_string(wt.path().join("AGENTS.md")).unwrap();
1139        assert!(!result.contains("# Project Rules"));
1140        assert!(result.contains("`feat/foo`"));
1141    }
1142
1143    #[test]
1144    fn setup_worktree_replaces_root_section() {
1145        let repo = tempfile::tempdir().unwrap();
1146        let wt = tempfile::tempdir().unwrap();
1147        init_git_repo(wt.path());
1148        let root_content =
1149            format!("# Rules\n\n{START_MARKER}\nold root section\n{END_MARKER}\n\n## Footer\n");
1150        fs::write(repo.path().join("AGENTS.md"), &root_content).unwrap();
1151
1152        let assignment = make_assignment(None, None);
1153        setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
1154
1155        let result = fs::read_to_string(wt.path().join("AGENTS.md")).unwrap();
1156        assert!(result.contains("# Rules"));
1157        assert!(result.contains("## Footer"));
1158        assert!(!result.contains("old root section"));
1159        assert!(result.contains("`feat/foo`"));
1160        assert_eq!(
1161            result.matches(START_MARKER_PREFIX).count(),
1162            1,
1163            "should have exactly one git-paw section"
1164        );
1165    }
1166
1167    // -----------------------------------------------------------------------
1168    // setup_worktree_agents_md — write failure
1169    // -----------------------------------------------------------------------
1170
1171    #[test]
1172    fn setup_worktree_write_failure_returns_agents_md_error() {
1173        use std::os::unix::fs::PermissionsExt;
1174
1175        let repo = tempfile::tempdir().unwrap();
1176        let wt = tempfile::tempdir().unwrap();
1177        init_git_repo(wt.path());
1178
1179        // Make the worktree root read-only so AGENTS.md cannot be written
1180        fs::set_permissions(wt.path(), fs::Permissions::from_mode(0o555)).unwrap();
1181
1182        let assignment = make_assignment(None, None);
1183        let result = setup_worktree_agents_md(repo.path(), wt.path(), &assignment);
1184
1185        // Restore permissions so tempdir cleanup can succeed
1186        fs::set_permissions(wt.path(), fs::Permissions::from_mode(0o755)).unwrap();
1187
1188        assert!(result.is_err(), "should fail when worktree is read-only");
1189        let err = result.unwrap_err();
1190        let msg = err.to_string();
1191        assert!(
1192            msg.contains("AGENTS.md error"),
1193            "should return AgentsMdError, got: {msg}"
1194        );
1195    }
1196
1197    // -----------------------------------------------------------------------
1198    // exclude_from_git
1199    // -----------------------------------------------------------------------
1200
1201    #[test]
1202    fn exclude_creates_file_when_missing() {
1203        let wt = tempfile::tempdir().unwrap();
1204        fs::create_dir_all(wt.path().join(".git/info")).unwrap();
1205
1206        exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1207
1208        let content = fs::read_to_string(wt.path().join(".git/info/exclude")).unwrap();
1209        assert!(content.contains("AGENTS.md"));
1210    }
1211
1212    #[test]
1213    fn exclude_appends_when_not_present() {
1214        let wt = tempfile::tempdir().unwrap();
1215        let info = wt.path().join(".git/info");
1216        fs::create_dir_all(&info).unwrap();
1217        fs::write(info.join("exclude"), "*.log\n").unwrap();
1218
1219        exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1220
1221        let content = fs::read_to_string(info.join("exclude")).unwrap();
1222        assert!(content.contains("*.log"));
1223        assert!(content.contains("AGENTS.md"));
1224    }
1225
1226    #[test]
1227    fn exclude_no_duplicate() {
1228        let wt = tempfile::tempdir().unwrap();
1229        let info = wt.path().join(".git/info");
1230        fs::create_dir_all(&info).unwrap();
1231        fs::write(info.join("exclude"), "AGENTS.md\n").unwrap();
1232
1233        exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1234
1235        let content = fs::read_to_string(info.join("exclude")).unwrap();
1236        assert_eq!(content.matches("AGENTS.md").count(), 1);
1237    }
1238
1239    #[test]
1240    fn exclude_creates_info_dir() {
1241        let wt = tempfile::tempdir().unwrap();
1242        fs::create_dir_all(wt.path().join(".git")).unwrap();
1243        assert!(!wt.path().join(".git/info").exists());
1244
1245        exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1246
1247        assert!(wt.path().join(".git/info/exclude").exists());
1248        let content = fs::read_to_string(wt.path().join(".git/info/exclude")).unwrap();
1249        assert!(content.contains("AGENTS.md"));
1250    }
1251
1252    // -----------------------------------------------------------------------
1253    // generate_worktree_section — skill_content
1254    // -----------------------------------------------------------------------
1255
1256    #[test]
1257    fn worktree_section_all_fields_with_skill() {
1258        let assignment = make_assignment_with_skill(
1259            Some("Implement the widget.\n"),
1260            Some(vec!["src/widget.rs", "tests/widget.rs"]),
1261            Some("## Coordination\nUse the broker at http://127.0.0.1:9119 as feat-foo.\n"),
1262        );
1263        let section = generate_worktree_section(&assignment);
1264        assert!(section.starts_with(START_MARKER));
1265        assert!(section.contains(END_MARKER));
1266        assert!(section.contains("`feat/foo`"));
1267        assert!(section.contains("claude"));
1268        assert!(section.contains("### Spec"));
1269        assert!(section.contains("Implement the widget."));
1270        assert!(section.contains("### File Ownership"));
1271        assert!(section.contains("`src/widget.rs`"));
1272        assert!(section.contains("## Coordination"));
1273        // Skill content appears after file ownership and before end marker
1274        let ownership_pos = section.find("### File Ownership").unwrap();
1275        let skill_pos = section.find("## Coordination").unwrap();
1276        let end_pos = section.find(END_MARKER).unwrap();
1277        assert!(
1278            ownership_pos < skill_pos,
1279            "skill must come after file ownership"
1280        );
1281        assert!(skill_pos < end_pos, "skill must come before end marker");
1282    }
1283
1284    #[test]
1285    fn worktree_section_skill_without_spec_or_files() {
1286        let assignment = make_assignment_with_skill(
1287            None,
1288            None,
1289            Some("## Coordination\nBroker instructions here.\n"),
1290        );
1291        let section = generate_worktree_section(&assignment);
1292        assert!(section.contains("`feat/foo`"));
1293        assert!(section.contains("claude"));
1294        assert!(!section.contains("### Spec"));
1295        assert!(!section.contains("### File Ownership"));
1296        assert!(section.contains("## Coordination"));
1297        // Skill content appears after assignment and before end marker
1298        let assignment_pos = section.find("**CLI:**").unwrap();
1299        let skill_pos = section.find("## Coordination").unwrap();
1300        let end_pos = section.find(END_MARKER).unwrap();
1301        assert!(
1302            assignment_pos < skill_pos,
1303            "skill must come after assignment"
1304        );
1305        assert!(skill_pos < end_pos, "skill must come before end marker");
1306    }
1307
1308    #[test]
1309    fn worktree_section_none_skill_matches_v020() {
1310        // With skill_content = None, output must be identical to make_assignment (no skill)
1311        let with_none =
1312            make_assignment_with_skill(Some("Do the thing.\n"), Some(vec!["src/main.rs"]), None);
1313        let without = make_assignment(Some("Do the thing.\n"), Some(vec!["src/main.rs"]));
1314        assert_eq!(
1315            generate_worktree_section(&with_none),
1316            generate_worktree_section(&without),
1317            "skill_content = None must produce identical output to v0.2.0"
1318        );
1319    }
1320
1321    #[test]
1322    fn worktree_section_skill_contains_slugified_branch() {
1323        let assignment = WorktreeAssignment {
1324            branch: "feat/http-broker".to_string(),
1325            cli: "claude".to_string(),
1326            spec_content: None,
1327            owned_files: None,
1328            skill_content: Some(
1329                "Agent ID: feat-http-broker\nURL: http://127.0.0.1:9119\n".to_string(),
1330            ),
1331            inter_agent_rules: None,
1332        };
1333        let section = generate_worktree_section(&assignment);
1334        assert!(
1335            section.contains("feat-http-broker"),
1336            "should contain slugified branch"
1337        );
1338        assert!(
1339            !section.contains("{{BRANCH_ID}}"),
1340            "should not contain literal template placeholder"
1341        );
1342    }
1343
1344    #[test]
1345    fn worktree_section_skill_preserves_broker_url_placeholder() {
1346        let assignment = make_assignment_with_skill(
1347            None,
1348            None,
1349            Some("Connect to http://127.0.0.1:9119/messages\n"),
1350        );
1351        let section = generate_worktree_section(&assignment);
1352        assert!(
1353            section.contains("http://127.0.0.1:9119"),
1354            "broker URL must be present"
1355        );
1356    }
1357
1358    // -----------------------------------------------------------------------
1359    // generate_worktree_section — inter_agent_rules
1360    // -----------------------------------------------------------------------
1361
1362    #[test]
1363    fn worktree_section_with_inter_agent_rules() {
1364        let mut assignment = make_assignment(Some("Do the widget.\n"), Some(vec!["src/widget.rs"]));
1365        assignment.inter_agent_rules = Some("Stay in your lane.\nNever push.\n".to_string());
1366        let section = generate_worktree_section(&assignment);
1367        assert!(section.contains("## Inter-Agent Rules"));
1368        assert!(section.contains("Stay in your lane."));
1369        // Rules section appears before end marker
1370        let rules_pos = section.find("## Inter-Agent Rules").unwrap();
1371        let end_pos = section.find(END_MARKER).unwrap();
1372        assert!(rules_pos < end_pos, "rules must come before end marker");
1373    }
1374
1375    #[test]
1376    fn worktree_section_without_inter_agent_rules_has_no_section() {
1377        let assignment = make_assignment(Some("Do the widget.\n"), Some(vec!["src/widget.rs"]));
1378        let section = generate_worktree_section(&assignment);
1379        assert!(!section.contains("## Inter-Agent Rules"));
1380    }
1381
1382    #[test]
1383    fn worktree_section_inter_agent_rules_none_matches_pre_change() {
1384        // When inter_agent_rules is None, output must equal the pre-change baseline.
1385        let baseline = make_assignment(Some("Do.\n"), Some(vec!["src/main.rs"]));
1386        let with_none = WorktreeAssignment {
1387            branch: baseline.branch.clone(),
1388            cli: baseline.cli.clone(),
1389            spec_content: baseline.spec_content.clone(),
1390            owned_files: baseline.owned_files.clone(),
1391            skill_content: None,
1392            inter_agent_rules: None,
1393        };
1394        assert_eq!(
1395            generate_worktree_section(&baseline),
1396            generate_worktree_section(&with_none),
1397        );
1398    }
1399
1400    // -----------------------------------------------------------------------
1401    // build_inter_agent_rules
1402    // -----------------------------------------------------------------------
1403
1404    #[test]
1405    fn build_inter_agent_rules_contains_file_ownership() {
1406        let rules = build_inter_agent_rules(&["feat/a", "feat/b"]);
1407        assert!(rules.contains("File ownership"));
1408        assert!(rules.contains("`feat/a`"));
1409        assert!(rules.contains("`feat/b`"));
1410    }
1411
1412    #[test]
1413    fn build_inter_agent_rules_contains_never_push() {
1414        let rules = build_inter_agent_rules(&["feat/a"]);
1415        assert!(rules.contains("MUST NOT `git push`"));
1416    }
1417
1418    #[test]
1419    fn build_inter_agent_rules_notes_automatic_status() {
1420        let rules = build_inter_agent_rules(&["feat/a"]);
1421        assert!(rules.contains("Status publishing is automatic"));
1422        assert!(rules.contains("post-commit"));
1423    }
1424
1425    #[test]
1426    fn build_inter_agent_rules_contains_match_spec() {
1427        let rules = build_inter_agent_rules(&["feat/a"]);
1428        assert!(
1429            rules
1430                .to_lowercase()
1431                .contains("match spec field names exactly")
1432        );
1433    }
1434
1435    #[test]
1436    fn build_inter_agent_rules_contains_cherry_pick_reference() {
1437        let rules = build_inter_agent_rules(&["feat/a"]);
1438        assert!(rules.to_lowercase().contains("cherry-pick"));
1439    }
1440
1441    // -----------------------------------------------------------------------
1442    // Embedded coordination skill — proactive publishing + cherry-pick
1443    // -----------------------------------------------------------------------
1444
1445    #[test]
1446    fn embedded_coordination_contains_cherry_pick() {
1447        let content = include_str!("../assets/agent-skills/coordination.md");
1448        assert!(content.contains("git cherry-pick"));
1449    }
1450
1451    #[test]
1452    fn embedded_coordination_documents_automatic_status() {
1453        let content = include_str!("../assets/agent-skills/coordination.md");
1454        let lower = content.to_lowercase();
1455        assert!(lower.contains("automatic"));
1456        assert!(lower.contains("post-commit"));
1457    }
1458
1459    #[test]
1460    fn embedded_coordination_does_not_require_manual_status_publish() {
1461        let content = include_str!("../assets/agent-skills/coordination.md");
1462        assert!(!content.contains("MUST publish `agent.status`"));
1463        assert!(!content.contains("You MUST publish `agent.status`"));
1464    }
1465
1466    #[test]
1467    fn embedded_coordination_still_contains_optin_operations() {
1468        let content = include_str!("../assets/agent-skills/coordination.md");
1469        assert!(content.contains("agent.blocked"));
1470        assert!(content.contains("agent.artifact"));
1471        assert!(content.contains("{{GIT_PAW_BROKER_URL}}/messages/{{BRANCH_ID}}"));
1472    }
1473
1474    #[test]
1475    fn embedded_coordination_requires_no_push() {
1476        let content = include_str!("../assets/agent-skills/coordination.md");
1477        assert!(content.contains("MUST NOT push"));
1478    }
1479
1480    // -----------------------------------------------------------------------
1481    // Git hook installation
1482    // -----------------------------------------------------------------------
1483
1484    #[test]
1485    fn pre_commit_guard_hook_blocks_on_branch_mismatch() {
1486        let script = build_pre_commit_branch_guard_hook();
1487        // Resolves the gitdir robustly (git does not export GIT_DIR to
1488        // pre-commit), gates on the marker, compares HEAD vs expected branch,
1489        // honours the strict opt-out, and exits non-zero on mismatch.
1490        assert!(script.contains("git rev-parse --git-dir"));
1491        assert!(script.contains("PAW_EXPECTED_BRANCH"));
1492        assert!(script.contains("PAW_STRICT_BRANCH_GUARD"));
1493        assert!(script.contains("git symbolic-ref --short HEAD"));
1494        assert!(script.contains("exit 1"));
1495    }
1496
1497    #[test]
1498    fn post_commit_dispatcher_detects_branch_mismatch() {
1499        let script = build_post_commit_dispatcher_hook();
1500        // Detection (without enforcement) publishes both feedback and a
1501        // permission_pattern learning when HEAD differs from the expected branch.
1502        assert!(script.contains("agent.feedback"));
1503        assert!(script.contains("agent.learning"));
1504        assert!(script.contains("permission_pattern"));
1505        assert!(script.contains("PAW_EXPECTED_BRANCH"));
1506    }
1507
1508    #[test]
1509    fn post_commit_dispatcher_hook_reads_marker_and_publishes() {
1510        let script = build_post_commit_dispatcher_hook();
1511        assert!(script.contains("$PAW_GD/paw-agent-id"));
1512        assert!(script.contains(". \"$PAW_GD/paw-agent-id\""));
1513        assert!(script.contains("$PAW_BROKER_URL/publish"));
1514        assert!(script.contains("$PAW_AGENT_ID"));
1515        assert!(script.contains("agent.artifact"));
1516        assert!(script.contains("|| true"));
1517    }
1518
1519    #[test]
1520    fn agent_marker_is_shell_sourceable() {
1521        let marker = build_agent_marker("http://127.0.0.1:9119", "feat-x", None, None, None);
1522        assert!(marker.contains("PAW_AGENT_ID=feat-x"));
1523        assert!(marker.contains("PAW_BROKER_URL=http://127.0.0.1:9119"));
1524    }
1525
1526    #[test]
1527    fn pre_push_hook_only_rejects_agent_worktrees() {
1528        let script = build_pre_push_hook();
1529        // The reject path must still be there so agent worktrees can't push.
1530        assert!(script.contains("exit 1"));
1531        assert!(script.contains("must not push"));
1532        // But it MUST be gated on the agent marker so the main repo and
1533        // non-agent worktrees can still push freely.
1534        assert!(
1535            script.contains("paw-agent-id"),
1536            "pre-push hook must gate the reject on $GIT_DIR/paw-agent-id; \
1537             without the gate, every push from this gitdir is blocked, \
1538             including legitimate pushes from the main repo"
1539        );
1540    }
1541
1542    #[test]
1543    fn chain_hook_replaces_existing_git_paw_block() {
1544        let existing = format!(
1545            "#!/bin/sh\n\
1546             # user hook\n\
1547             echo hi\n\
1548             {HOOK_START_MARKER}\n\
1549             old git-paw content\n\
1550             {HOOK_END_MARKER}\n"
1551        );
1552        let new_body = format!(
1553            "#!/bin/sh\n\
1554             {HOOK_START_MARKER}\n\
1555             new git-paw content\n\
1556             {HOOK_END_MARKER}\n"
1557        );
1558        let chained = chain_hook(&existing, &new_body);
1559        assert!(chained.contains("# user hook"));
1560        assert!(chained.contains("echo hi"));
1561        assert!(chained.contains("new git-paw content"));
1562        assert!(!chained.contains("old git-paw content"));
1563    }
1564
1565    #[test]
1566    fn chain_hook_appends_after_existing_content() {
1567        let existing = "#!/bin/sh\necho existing\n";
1568        let new_body = format!(
1569            "#!/bin/sh\n\
1570             {HOOK_START_MARKER}\n\
1571             new block\n\
1572             {HOOK_END_MARKER}\n"
1573        );
1574        let chained = chain_hook(existing, &new_body);
1575        assert!(chained.starts_with("#!/bin/sh\necho existing"));
1576        assert!(chained.contains("new block"));
1577        // The new shebang should be stripped when chaining.
1578        assert_eq!(chained.matches("#!/bin/sh").count(), 1);
1579    }
1580
1581    #[test]
1582    fn chain_hook_preserves_content_when_end_marker_missing() {
1583        // Corrupted/truncated hook: start marker present, end marker missing.
1584        // The user's shebang and original logic must be preserved verbatim
1585        // and the new git-paw block appended.
1586        let existing = format!(
1587            "#!/bin/sh\n\
1588             # important user logic\n\
1589             echo do_not_lose_me\n\
1590             {HOOK_START_MARKER}\n\
1591             leftover but no end marker\n"
1592        );
1593        let new_body = format!(
1594            "#!/bin/sh\n\
1595             {HOOK_START_MARKER}\n\
1596             new git-paw content\n\
1597             {HOOK_END_MARKER}\n"
1598        );
1599        let chained = chain_hook(&existing, &new_body);
1600        // All original lines must survive.
1601        assert!(chained.contains("#!/bin/sh"));
1602        assert!(chained.contains("# important user logic"));
1603        assert!(chained.contains("echo do_not_lose_me"));
1604        assert!(chained.contains("leftover but no end marker"));
1605        // The new block must be appended.
1606        assert!(chained.contains("new git-paw content"));
1607        assert!(chained.contains(HOOK_END_MARKER));
1608        // The new shebang should be stripped (only the existing one remains).
1609        assert_eq!(chained.matches("#!/bin/sh").count(), 1);
1610    }
1611
1612    #[test]
1613    #[serial_test::serial]
1614    fn install_git_hooks_writes_dispatcher_to_common_git_dir() {
1615        let tmp = tempfile::tempdir().unwrap();
1616        let worktree = tmp.path();
1617        init_git_repo(worktree);
1618
1619        install_git_hooks(worktree, "http://127.0.0.1:9119", "feat-x", "feat/x", true).unwrap();
1620
1621        let post_commit = worktree.join(".git").join("hooks").join("post-commit");
1622        let pre_push = worktree.join(".git").join("hooks").join("pre-push");
1623        let marker = worktree.join(".git").join("paw-agent-id");
1624
1625        assert!(post_commit.exists(), "post-commit should exist");
1626        assert!(pre_push.exists(), "pre-push should exist");
1627        assert!(marker.exists(), "paw-agent-id marker should exist");
1628
1629        let pc = fs::read_to_string(&post_commit).unwrap();
1630        assert!(pc.contains("$PAW_GD/paw-agent-id"));
1631        assert!(pc.contains("agent.artifact"));
1632
1633        // pre-commit branch guard installed alongside the dispatcher.
1634        let pre_commit = worktree.join(".git").join("hooks").join("pre-commit");
1635        assert!(pre_commit.exists(), "pre-commit guard should exist");
1636        let prc = fs::read_to_string(&pre_commit).unwrap();
1637        assert!(prc.contains("branch guard"));
1638        assert!(prc.contains("PAW_EXPECTED_BRANCH"));
1639
1640        let marker_body = fs::read_to_string(&marker).unwrap();
1641        assert!(marker_body.contains("PAW_AGENT_ID=feat-x"));
1642        assert!(marker_body.contains("PAW_BROKER_URL=http://127.0.0.1:9119"));
1643        assert!(marker_body.contains("PAW_EXPECTED_BRANCH=feat/x"));
1644        assert!(marker_body.contains("PAW_STRICT_BRANCH_GUARD=true"));
1645
1646        #[cfg(unix)]
1647        {
1648            use std::os::unix::fs::PermissionsExt;
1649            let mode = fs::metadata(&post_commit).unwrap().permissions().mode();
1650            assert_eq!(mode & 0o111, 0o111, "post-commit must be executable");
1651        }
1652    }
1653
1654    #[test]
1655    #[serial_test::serial]
1656    fn install_git_hooks_preserves_existing_dispatcher_body() {
1657        let tmp = tempfile::tempdir().unwrap();
1658        let worktree = tmp.path();
1659        init_git_repo(worktree);
1660        let hook_path = worktree.join(".git").join("hooks").join("post-commit");
1661        fs::write(&hook_path, "#!/bin/sh\necho user hook\n").unwrap();
1662
1663        install_git_hooks(worktree, "http://127.0.0.1:9119", "feat-x", "feat/x", true).unwrap();
1664
1665        let body = fs::read_to_string(&hook_path).unwrap();
1666        assert!(body.contains("echo user hook"));
1667        assert!(body.contains("agent.artifact"));
1668    }
1669
1670    #[test]
1671    #[serial_test::serial]
1672    fn install_git_hooks_writes_linked_marker_for_linked_worktree() {
1673        let tmp = tempfile::tempdir().unwrap();
1674        let main_repo = tmp.path().join("main");
1675        fs::create_dir_all(&main_repo).unwrap();
1676        init_git_repo(&main_repo);
1677
1678        // Create an empty commit so we can add a worktree.
1679        std::process::Command::new("git")
1680            .args(["commit", "--allow-empty", "-m", "root", "-q"])
1681            .current_dir(&main_repo)
1682            .output()
1683            .unwrap();
1684
1685        // Add a linked worktree.
1686        let linked_path = tmp.path().join("linked");
1687        std::process::Command::new("git")
1688            .args([
1689                "worktree",
1690                "add",
1691                "-b",
1692                "feat-x",
1693                linked_path.to_str().unwrap(),
1694            ])
1695            .current_dir(&main_repo)
1696            .output()
1697            .unwrap();
1698
1699        install_git_hooks(
1700            &linked_path,
1701            "http://127.0.0.1:9119",
1702            "feat-x",
1703            "feat/x",
1704            true,
1705        )
1706        .unwrap();
1707
1708        // Dispatcher lives in main .git/hooks/
1709        let post_commit = main_repo.join(".git").join("hooks").join("post-commit");
1710        assert!(
1711            post_commit.exists(),
1712            "dispatcher must land in main .git/hooks/"
1713        );
1714        // Per-worktree marker lives in main .git/worktrees/linked/
1715        let marker = main_repo
1716            .join(".git")
1717            .join("worktrees")
1718            .join("linked")
1719            .join("paw-agent-id");
1720        assert!(
1721            marker.exists(),
1722            "marker must land in linked worktree gitdir"
1723        );
1724        let body = fs::read_to_string(&marker).unwrap();
1725        assert!(body.contains("PAW_AGENT_ID=feat-x"));
1726    }
1727
1728    // -----------------------------------------------------------------------
1729    // Enhanced Agent Marker Tests
1730    // -----------------------------------------------------------------------
1731
1732    #[test]
1733    fn build_agent_marker_basic_format() {
1734        let marker = build_agent_marker("http://127.0.0.1:9119", "feat-test", None, None, None);
1735
1736        assert!(marker.contains("PAW_AGENT_ID=feat-test"));
1737        assert!(marker.contains("PAW_BROKER_URL=http://127.0.0.1:9119"));
1738        assert!(marker.contains("PAW_TIMESTAMP="));
1739        // Should not contain optional fields
1740        assert!(!marker.contains("PAW_SUPERVISOR_PID"));
1741        assert!(!marker.contains("PAW_LAST_VERIFIED_COMMIT"));
1742        assert!(!marker.contains("PAW_SESSION_NAME"));
1743    }
1744
1745    #[test]
1746    fn build_agent_marker_with_all_extended_fields() {
1747        let marker = build_agent_marker(
1748            "http://localhost:9119",
1749            "feat-errors",
1750            Some(12345),
1751            Some("abc123def456"),
1752            Some("paw-test-session"),
1753        );
1754
1755        assert!(marker.contains("PAW_AGENT_ID=feat-errors"));
1756        assert!(marker.contains("PAW_BROKER_URL=http://localhost:9119"));
1757        assert!(marker.contains("PAW_SUPERVISOR_PID=12345"));
1758        assert!(marker.contains("PAW_LAST_VERIFIED_COMMIT=abc123def456"));
1759        assert!(marker.contains("PAW_SESSION_NAME=paw-test-session"));
1760        assert!(marker.contains("PAW_TIMESTAMP="));
1761    }
1762
1763    #[test]
1764    fn build_agent_marker_partial_extended_fields() {
1765        let marker =
1766            build_agent_marker("http://localhost:9119", "fix-cycle", Some(999), None, None);
1767
1768        assert!(marker.contains("PAW_SUPERVISOR_PID=999"));
1769        assert!(!marker.contains("PAW_LAST_VERIFIED_COMMIT"));
1770        assert!(!marker.contains("PAW_SESSION_NAME"));
1771    }
1772
1773    #[test]
1774    fn update_agent_marker_adds_missing_fields() {
1775        let tmp = tempfile::tempdir().unwrap();
1776        let marker_path = tmp.path().join("test-marker");
1777
1778        // Create initial marker
1779        let initial = "PAW_AGENT_ID=test\nPAW_BROKER_URL=http://localhost:9119\nPAW_TIMESTAMP=2026-01-01T00:00:00Z\n";
1780        fs::write(&marker_path, initial).unwrap();
1781
1782        // Update with supervisor PID
1783        update_agent_marker(&marker_path, Some(54321), None).unwrap();
1784
1785        let updated = fs::read_to_string(&marker_path).unwrap();
1786        assert!(updated.contains("PAW_AGENT_ID=test"));
1787        assert!(updated.contains("PAW_SUPERVISOR_PID=54321"));
1788    }
1789
1790    #[test]
1791    fn update_agent_marker_replaces_existing_fields() {
1792        let tmp = tempfile::tempdir().unwrap();
1793        let marker_path = tmp.path().join("test-marker");
1794
1795        // Create initial marker with old commit
1796        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";
1797        fs::write(&marker_path, initial).unwrap();
1798
1799        // Update with new commit
1800        update_agent_marker(&marker_path, None, Some("new456")).unwrap();
1801
1802        let updated = fs::read_to_string(&marker_path).unwrap();
1803        assert!(updated.contains("PAW_AGENT_ID=test"));
1804        assert!(updated.contains("PAW_LAST_VERIFIED_COMMIT=new456"));
1805        assert!(!updated.contains("PAW_LAST_VERIFIED_COMMIT=old123"));
1806    }
1807
1808    #[test]
1809    fn update_agent_marker_reuses_lazy_regex_across_calls() {
1810        // Smoke test for the LazyLock<Regex> hoisting: invoking
1811        // `update_agent_marker` twice in succession must not panic and the
1812        // second call must replace the first call's substituted value.
1813        // Reaching this point at all proves both regexes initialised cleanly.
1814        let tmp = tempfile::tempdir().unwrap();
1815        let marker_path = tmp.path().join("test-marker");
1816
1817        let initial = "PAW_AGENT_ID=test\nPAW_BROKER_URL=http://localhost:9119\nPAW_SUPERVISOR_PID=111\nPAW_LAST_VERIFIED_COMMIT=abc\n";
1818        fs::write(&marker_path, initial).unwrap();
1819
1820        update_agent_marker(&marker_path, Some(222), Some("def")).unwrap();
1821        update_agent_marker(&marker_path, Some(333), Some("ghi")).unwrap();
1822
1823        let updated = fs::read_to_string(&marker_path).unwrap();
1824        assert!(updated.contains("PAW_SUPERVISOR_PID=333"));
1825        assert!(updated.contains("PAW_LAST_VERIFIED_COMMIT=ghi"));
1826        assert!(!updated.contains("PAW_SUPERVISOR_PID=111"));
1827        assert!(!updated.contains("PAW_SUPERVISOR_PID=222"));
1828        assert!(!updated.contains("PAW_LAST_VERIFIED_COMMIT=abc"));
1829        assert!(!updated.contains("PAW_LAST_VERIFIED_COMMIT=def"));
1830    }
1831
1832    #[test]
1833    fn get_agent_marker_path_returns_correct_path() {
1834        let tmp = tempfile::tempdir().unwrap();
1835        let worktree = tmp.path();
1836        init_git_repo(worktree);
1837
1838        let marker_path = get_agent_marker_path(worktree).unwrap();
1839        assert!(marker_path.ends_with(".git/paw-agent-id"));
1840    }
1841
1842    // v0-5-0-audit-cleanup §9c (Bug E) — remove_session_boot_block must
1843    // strip a marker-delimited block from AGENTS.md byte-for-byte and
1844    // remain a no-op for files without markers.
1845
1846    #[test]
1847    fn remove_session_boot_block_strips_marked_block() {
1848        let tmp = tempfile::tempdir().unwrap();
1849        let repo_root = tmp.path();
1850        let agents_md = repo_root.join("AGENTS.md");
1851
1852        let header = "# Project AGENTS";
1853        let footer = "## Footer\n";
1854        let original = format!(
1855            "{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}"
1856        );
1857        fs::write(&agents_md, &original).unwrap();
1858
1859        remove_session_boot_block(repo_root).unwrap();
1860
1861        let after = fs::read_to_string(&agents_md).unwrap();
1862        let expected = format!("{header}\n\n{footer}");
1863        assert_eq!(
1864            after, expected,
1865            "after removal the file must match HEADER + blank + FOOTER byte-for-byte; got:\n{after:?}",
1866        );
1867        assert!(
1868            !after.contains("git-paw:start"),
1869            "no git-paw:start marker may remain after removal",
1870        );
1871    }
1872
1873    #[test]
1874    fn remove_session_boot_block_no_marker_is_noop() {
1875        let tmp = tempfile::tempdir().unwrap();
1876        let repo_root = tmp.path();
1877        let agents_md = repo_root.join("AGENTS.md");
1878
1879        let original = "# Project AGENTS\n\nNo boot block here.\n";
1880        fs::write(&agents_md, original).unwrap();
1881
1882        remove_session_boot_block(repo_root).unwrap();
1883
1884        let after = fs::read_to_string(&agents_md).unwrap();
1885        assert_eq!(
1886            after, original,
1887            "files without a boot-block marker must be preserved byte-for-byte",
1888        );
1889    }
1890
1891    #[test]
1892    fn remove_session_boot_block_missing_agents_md_is_noop() {
1893        // The helper SHALL be idempotent — calling it against a repo
1894        // root that has no AGENTS.md at all is not an error.
1895        let tmp = tempfile::tempdir().unwrap();
1896        remove_session_boot_block(tmp.path()).unwrap();
1897        assert!(
1898            !tmp.path().join("AGENTS.md").exists(),
1899            "remove_session_boot_block must not create AGENTS.md when none exists",
1900        );
1901    }
1902
1903    #[test]
1904    fn remove_session_boot_block_preserves_no_trailing_newline() {
1905        // If the original file lacks a trailing newline, the helper
1906        // must preserve that shape.
1907        let tmp = tempfile::tempdir().unwrap();
1908        let repo_root = tmp.path();
1909        let agents_md = repo_root.join("AGENTS.md");
1910
1911        let original = "# Header no newline";
1912        fs::write(&agents_md, original).unwrap();
1913
1914        remove_session_boot_block(repo_root).unwrap();
1915
1916        let after = fs::read_to_string(&agents_md).unwrap();
1917        assert_eq!(
1918            after, original,
1919            "file without trailing newline must be preserved exactly"
1920        );
1921    }
1922}