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