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};
10
11use crate::error::PawError;
12use crate::git::{assume_unchanged, exclude_from_git};
13
14/// Start marker prefix used for detection (ignores trailing comment text).
15const START_MARKER_PREFIX: &str = "<!-- git-paw:start";
16
17/// Full start marker line.
18const START_MARKER: &str = "<!-- git-paw:start — managed by git-paw, do not edit manually -->";
19
20/// End marker line.
21const END_MARKER: &str = "<!-- git-paw:end -->";
22
23/// Marker that identifies git-paw-managed git hook content.
24///
25/// When a project already has a `post-commit` or `pre-push` hook, git-paw
26/// chains its content after the existing hook, wrapped in marker lines so
27/// subsequent installs don't duplicate the block.
28const HOOK_START_MARKER: &str = "# >>> git-paw managed hook >>>";
29const HOOK_END_MARKER: &str = "# <<< git-paw managed hook <<<";
30
31/// Returns `true` if `content` contains a git-paw section start marker.
32pub fn has_git_paw_section(content: &str) -> bool {
33    content
34        .lines()
35        .any(|line| line.starts_with(START_MARKER_PREFIX))
36}
37
38/// Replaces the git-paw section (start marker through end marker, inclusive)
39/// with `new_section`. If the end marker is missing, replaces from the start
40/// marker to EOF.
41pub fn replace_git_paw_section(content: &str, new_section: &str) -> String {
42    let lines: Vec<&str> = content.lines().collect();
43
44    let Some(start_idx) = lines
45        .iter()
46        .position(|l| l.starts_with(START_MARKER_PREFIX))
47    else {
48        return content.to_string();
49    };
50
51    let end_idx = lines[start_idx..]
52        .iter()
53        .position(|l| l.contains(END_MARKER))
54        .map(|rel| start_idx + rel);
55
56    let mut result = String::new();
57
58    // Content before the start marker
59    for line in &lines[..start_idx] {
60        result.push_str(line);
61        result.push('\n');
62    }
63
64    // The new section
65    result.push_str(new_section);
66
67    // Content after the end marker (if it exists)
68    if let Some(end) = end_idx
69        && end + 1 < lines.len()
70    {
71        for line in &lines[end + 1..] {
72            result.push_str(line);
73            result.push('\n');
74        }
75    }
76
77    // Preserve trailing newline behavior of original if we replaced to EOF
78    if end_idx.is_none() && content.ends_with('\n') && !result.ends_with('\n') {
79        result.push('\n');
80    }
81
82    result
83}
84
85/// Injects `section` into `content`: appends if no git-paw section exists,
86/// replaces the existing one if present.
87pub fn inject_into_content(content: &str, section: &str) -> String {
88    if content.is_empty() {
89        return section.to_string();
90    }
91
92    if has_git_paw_section(content) {
93        return replace_git_paw_section(content, section);
94    }
95
96    // Append with proper spacing
97    let mut result = content.to_string();
98    if !result.ends_with('\n') {
99        result.push('\n');
100    }
101    result.push('\n');
102    result.push_str(section);
103    result
104}
105
106/// Reads a file (or treats a missing file as empty), injects `section`,
107/// and writes the result back.
108/// Per-worktree assignment context passed by the session launch flow.
109pub struct WorktreeAssignment {
110    /// The branch this worktree is checked out on.
111    pub branch: String,
112    /// The CLI name (e.g. "claude", "cursor") running in this worktree.
113    pub cli: String,
114    /// Optional spec content to embed in the assignment section.
115    pub spec_content: Option<String>,
116    /// Optional list of files this worktree owns.
117    pub owned_files: Option<Vec<String>>,
118    /// Optional rendered skill content to inject into the assignment section.
119    pub skill_content: Option<String>,
120    /// Optional inter-agent rules block (file ownership, never-push, proactive
121    /// status publishing, cherry-pick) injected by the supervisor. When `None`,
122    /// the generated section omits the `## Inter-Agent Rules` subsection
123    /// entirely so non-supervisor sessions are byte-identical to pre-supervisor
124    /// output.
125    pub inter_agent_rules: Option<String>,
126}
127
128/// Builds the standard inter-agent rules block that the supervisor injects
129/// into every coding agent's `AGENTS.md`.
130///
131/// `branches` is the list of all peer branches in the session — used to make
132/// the file-ownership constraint explicit ("don't touch files owned by ...").
133pub fn build_inter_agent_rules(branches: &[&str]) -> String {
134    let mut peers = String::new();
135    for (i, b) in branches.iter().enumerate() {
136        if i > 0 {
137            peers.push_str(", ");
138        }
139        peers.push('`');
140        peers.push_str(b);
141        peers.push('`');
142    }
143
144    let mut out = String::new();
145    out.push_str("These rules apply to every agent in this supervisor session. ");
146    out.push_str("Violating them blocks the supervisor's verification step.\n\n");
147    out.push_str("- **File ownership is exclusive.** You MUST NOT edit files owned by ");
148    out.push_str("other agents. Peers in this session: ");
149    out.push_str(&peers);
150    out.push_str(". Stay inside your declared file ownership list.\n");
151    out.push_str("- **Commit, never push.** You MUST commit to your worktree branch and ");
152    out.push_str("MUST NOT `git push` to any remote. The supervisor merges branches.\n");
153    out.push_str("- **Status publishing is automatic.** git-paw watches your worktree and ");
154    out.push_str("publishes `agent.status` with `modified_files` for you whenever your git ");
155    out.push_str("status changes. A `post-commit` hook publishes `agent.artifact` on each ");
156    out.push_str("commit. You do not need to curl these yourself.\n");
157    out.push_str("- **Watch peer status.** Poll `/messages/{{BRANCH_ID}}` to see peer ");
158    out.push_str("`agent.artifact` messages so you detect conflicts before the supervisor does.\n");
159    out.push_str("- **Cherry-pick peer artifacts.** When you are blocked on a peer, publish ");
160    out.push_str("`agent.blocked` and cherry-pick their commit when their artifact arrives ");
161    out.push_str("in your inbox. Do not wait for the supervisor to merge.\n");
162    out.push_str("- **Match spec field names exactly.** When implementing a spec, use the ");
163    out.push_str("exact field, function, and message names from the spec — do not rename ");
164    out.push_str("them. The supervisor's spec audit will reject mismatched names.\n");
165    out
166}
167
168/// Generates a marker-delimited assignment section for a worktree's AGENTS.md.
169pub fn generate_worktree_section(assignment: &WorktreeAssignment) -> String {
170    let mut section = String::new();
171    section.push_str(START_MARKER);
172    section.push('\n');
173    section.push('\n');
174    section.push_str("## git-paw Session Assignment\n");
175    section.push('\n');
176    let _ = writeln!(section, "- **Branch:** `{}`", assignment.branch);
177    let _ = writeln!(section, "- **CLI:** {}", assignment.cli);
178
179    if let Some(ref spec) = assignment.spec_content {
180        section.push('\n');
181        section.push_str("### Spec\n");
182        section.push('\n');
183        section.push_str(spec);
184        if !spec.ends_with('\n') {
185            section.push('\n');
186        }
187    }
188
189    if let Some(ref files) = assignment.owned_files {
190        section.push('\n');
191        section.push_str("### File Ownership\n");
192        section.push('\n');
193        for file in files {
194            let _ = writeln!(section, "- `{file}`");
195        }
196    }
197
198    if let Some(ref skill) = assignment.skill_content {
199        section.push('\n');
200        section.push_str(skill);
201        if !skill.ends_with('\n') {
202            section.push('\n');
203        }
204    }
205
206    if let Some(ref rules) = assignment.inter_agent_rules {
207        section.push('\n');
208        section.push_str("## Inter-Agent Rules\n");
209        section.push('\n');
210        section.push_str(rules);
211        if !rules.ends_with('\n') {
212            section.push('\n');
213        }
214    }
215
216    section.push('\n');
217    section.push_str(END_MARKER);
218    section.push('\n');
219    section
220}
221
222/// Reads the root repo's AGENTS.md, injects the worktree assignment section,
223/// writes the result to the worktree root, and protects it from being committed.
224///
225/// Uses two layers of protection:
226/// 1. `.git/info/exclude` — hides AGENTS.md from `git status`
227/// 2. `git update-index --assume-unchanged` — prevents `git add -A` from staging it
228///
229/// The second layer is critical for AI agents that run `git add -A` or
230/// `git add .` to commit their work — without it, the injected session
231/// content would be committed to the branch.
232pub fn setup_worktree_agents_md(
233    repo_root: &Path,
234    worktree_root: &Path,
235    assignment: &WorktreeAssignment,
236) -> Result<(), PawError> {
237    let root_agents = repo_root.join("AGENTS.md");
238    let root_content = match fs::read_to_string(&root_agents) {
239        Ok(c) => c,
240        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
241        Err(e) => {
242            return Err(PawError::AgentsMdError(format!(
243                "failed to read '{}': {e}",
244                root_agents.display()
245            )));
246        }
247    };
248
249    let section = generate_worktree_section(assignment);
250    let output = inject_into_content(&root_content, &section);
251
252    let worktree_agents = worktree_root.join("AGENTS.md");
253    fs::write(&worktree_agents, &output).map_err(|e| {
254        PawError::AgentsMdError(format!(
255            "failed to write '{}': {e}",
256            worktree_agents.display()
257        ))
258    })?;
259
260    exclude_from_git(worktree_root, "AGENTS.md")?;
261
262    // Belt-and-suspenders: mark the file as assume-unchanged so `git add -A`
263    // doesn't stage it. This only works when AGENTS.md is already tracked in
264    // the index (which it is for worktrees of repos that have a tracked
265    // AGENTS.md). For repos without a tracked AGENTS.md, exclude_from_git
266    // above is the primary protection.
267    let _ = assume_unchanged(worktree_root, "AGENTS.md");
268
269    Ok(())
270}
271
272/// Returns the path to the agent marker file for a given worktree.
273pub fn get_agent_marker_path(worktree: &Path) -> Result<PathBuf, PawError> {
274    let linked_git_dir = git_rev_parse_path(worktree, "--git-dir")?;
275    Ok(linked_git_dir.join("paw-agent-id"))
276}
277
278/// Builds the agent marker file content with optional extended fields.
279///
280/// Basic format (always included):
281/// ```text
282/// PAW_AGENT_ID=<agent_id>
283/// PAW_BROKER_URL=<broker_url>
284/// ```
285///
286/// Extended format (optional fields):
287/// ```text
288/// PAW_SUPERVISOR_PID=<pid>
289/// PAW_LAST_VERIFIED_COMMIT=<commit_hash>
290/// PAW_SESSION_NAME=<session_name>
291/// PAW_TIMESTAMP=<iso_timestamp>
292/// ```
293pub fn build_agent_marker(
294    broker_url: &str,
295    agent_id: &str,
296    supervisor_pid: Option<u32>,
297    last_verified_commit: Option<&str>,
298    session_name: Option<&str>,
299) -> String {
300    let mut marker = format!("PAW_AGENT_ID={agent_id}\nPAW_BROKER_URL={broker_url}\n");
301
302    // Add optional extended fields
303    if let Some(pid) = supervisor_pid {
304        let _ = writeln!(marker, "PAW_SUPERVISOR_PID={pid}");
305    }
306    if let Some(commit) = last_verified_commit {
307        let _ = writeln!(marker, "PAW_LAST_VERIFIED_COMMIT={commit}");
308    }
309    if let Some(session) = session_name {
310        let _ = writeln!(marker, "PAW_SESSION_NAME={session}");
311    }
312
313    // Always add timestamp for debugging/tracing
314    let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
315    let _ = writeln!(marker, "PAW_TIMESTAMP={timestamp}");
316
317    marker
318}
319
320/// Updates an existing agent marker file with additional fields.
321///
322/// This allows adding supervisor-specific information after the initial marker creation.
323///
324/// # Panics
325///
326/// Panics if the marker file contains malformed content that cannot be processed by
327/// the regex replacement logic.
328pub fn update_agent_marker(
329    marker_path: &Path,
330    supervisor_pid: Option<u32>,
331    last_verified_commit: Option<&str>,
332) -> Result<(), PawError> {
333    let content = fs::read_to_string(marker_path)
334        .map_err(|e| PawError::AgentsMdError(format!("failed to read marker file: {e}")))?;
335
336    let mut updated = content;
337
338    // Update supervisor PID if provided
339    if let Some(pid) = supervisor_pid {
340        if updated.contains("PAW_SUPERVISOR_PID=") {
341            // Replace existing PID
342            updated = regex::Regex::new(r"PAW_SUPERVISOR_PID=\d+")
343                .unwrap()
344                .replace(&updated, &format!("PAW_SUPERVISOR_PID={pid}"))
345                .to_string();
346        } else {
347            // Add new PID line
348            let _ = write!(updated, "\nPAW_SUPERVISOR_PID={pid}");
349        }
350    }
351
352    // Update last verified commit if provided
353    if let Some(commit) = last_verified_commit {
354        if updated.contains("PAW_LAST_VERIFIED_COMMIT=") {
355            // Replace existing commit
356            updated = regex::Regex::new(r"PAW_LAST_VERIFIED_COMMIT=[^\n]+")
357                .unwrap()
358                .replace(&updated, &format!("PAW_LAST_VERIFIED_COMMIT={commit}"))
359                .to_string();
360        } else {
361            // Add new commit line
362            let _ = write!(updated, "\nPAW_LAST_VERIFIED_COMMIT={commit}");
363        }
364    }
365
366    fs::write(marker_path, updated)
367        .map_err(|e| PawError::AgentsMdError(format!("failed to update marker file: {e}")))?;
368
369    Ok(())
370}
371
372/// Builds the dispatcher `post-commit` hook installed at the main repo's
373/// `.git/hooks/post-commit`.
374///
375/// Linked git worktrees share the main repo's `hooks/` directory (git-worktree
376/// does not use per-worktree hook directories unless `extensions.worktreeConfig`
377/// is enabled, which is an intrusive repo-wide setting). Instead we install a
378/// single dispatcher and store per-worktree `agent_id` and `broker_url` in
379/// `$GIT_DIR/paw-agent-id` — `$GIT_DIR` is set by git to the correct
380/// per-worktree gitdir when the hook runs, so the dispatcher reads the right
381/// file for whichever worktree just committed.
382fn build_post_commit_dispatcher_hook() -> String {
383    format!(
384        "#!/bin/sh\n\
385         {HOOK_START_MARKER}\n\
386         # Dispatcher: reads per-worktree $GIT_DIR/paw-agent-id and publishes\n\
387         # agent.artifact to the git-paw broker.\n\
388         if [ -n \"$GIT_DIR\" ] && [ -f \"$GIT_DIR/paw-agent-id\" ]; then\n\
389             . \"$GIT_DIR/paw-agent-id\"\n\
390             FILES=$(git diff HEAD~1 --name-only 2>/dev/null | awk '{{printf \"%s\\\"%s\\\"\", (NR>1?\",\":\"\"), $0}}')\n\
391             curl -s -X POST \"$PAW_BROKER_URL/publish\" \\\n\
392                 -H 'Content-Type: application/json' \\\n\
393                 -d \"{{\\\"type\\\":\\\"agent.artifact\\\",\\\"agent_id\\\":\\\"$PAW_AGENT_ID\\\",\\\"payload\\\":{{\\\"status\\\":\\\"committed\\\",\\\"exports\\\":[],\\\"modified_files\\\":[$FILES]}}}}\" \\\n\
394                 >/dev/null 2>&1 || true\n\
395         fi\n\
396         {HOOK_END_MARKER}\n"
397    )
398}
399
400fn build_pre_push_hook() -> String {
401    // Only reject when the calling worktree is an agent worktree — i.e.
402    // a `paw-agent-id` marker exists in this worktree's gitdir. The hook
403    // installs into the common gitdir (shared with the main repo and all
404    // linked worktrees), so without this gate the hook would also block
405    // legitimate pushes from the main repo. Mirror the post-commit
406    // dispatcher's gate at line 388 so behaviour is consistent.
407    format!(
408        "#!/bin/sh\n\
409         {HOOK_START_MARKER}\n\
410         if [ -n \"$GIT_DIR\" ] && [ -f \"$GIT_DIR/paw-agent-id\" ]; then\n\
411         echo 'error: git-paw agents must not push. The supervisor handles merges.' >&2\n\
412         exit 1\n\
413         fi\n\
414         {HOOK_END_MARKER}\n"
415    )
416}
417
418/// Chains `new_body` onto `existing`, preserving the existing content.
419///
420/// If `existing` already contains a complete git-paw marker block, it is
421/// replaced. If only the start marker is present (corrupted/truncated block),
422/// the existing content is preserved verbatim and `new_body` is appended —
423/// never silently discarded — so the user's shebang and original logic stay
424/// intact. Otherwise `new_body` is appended after the existing content.
425fn chain_hook(existing: &str, new_body: &str) -> String {
426    // Complete marker block — replace it. If only the start marker is
427    // present (corrupted/truncated block), fall through to the append path
428    // so the user's shebang and original logic are preserved instead of
429    // being silently discarded.
430    if let Some(start) = existing.find(HOOK_START_MARKER)
431        && let Some(end_rel) = existing[start..].find(HOOK_END_MARKER)
432    {
433        let end = start + end_rel + HOOK_END_MARKER.len();
434        let mut out = String::with_capacity(existing.len() + new_body.len());
435        out.push_str(&existing[..start]);
436        // Strip the shebang from the new body when chaining onto an existing
437        // hook — the existing file already has one.
438        let stripped = new_body.strip_prefix("#!/bin/sh\n").unwrap_or(new_body);
439        out.push_str(stripped);
440        out.push_str(&existing[end..]);
441        return out;
442    }
443    let mut out = existing.trim_end().to_string();
444    if !out.is_empty() {
445        out.push('\n');
446    }
447    let stripped = if out.is_empty() {
448        new_body.to_string()
449    } else {
450        new_body
451            .strip_prefix("#!/bin/sh\n")
452            .unwrap_or(new_body)
453            .to_string()
454    };
455    out.push_str(&stripped);
456    out
457}
458
459fn write_hook_file(hook_path: &Path, new_body: &str) -> Result<(), PawError> {
460    let existing = match fs::read_to_string(hook_path) {
461        Ok(c) => c,
462        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
463        Err(e) => {
464            return Err(PawError::AgentsMdError(format!(
465                "failed to read '{}': {e}",
466                hook_path.display()
467            )));
468        }
469    };
470
471    let content = if existing.is_empty() {
472        new_body.to_string()
473    } else {
474        chain_hook(&existing, new_body)
475    };
476
477    if let Some(parent) = hook_path.parent() {
478        fs::create_dir_all(parent).map_err(|e| {
479            PawError::AgentsMdError(format!("failed to create '{}': {e}", parent.display()))
480        })?;
481    }
482
483    fs::write(hook_path, content.as_bytes()).map_err(|e| {
484        PawError::AgentsMdError(format!("failed to write '{}': {e}", hook_path.display()))
485    })?;
486
487    #[cfg(unix)]
488    {
489        use std::os::unix::fs::PermissionsExt;
490        let mut perms = fs::metadata(hook_path)
491            .map_err(|e| {
492                PawError::AgentsMdError(format!("failed to stat '{}': {e}", hook_path.display()))
493            })?
494            .permissions();
495        perms.set_mode(0o755);
496        fs::set_permissions(hook_path, perms).map_err(|e| {
497            PawError::AgentsMdError(format!("failed to chmod '{}': {e}", hook_path.display()))
498        })?;
499    }
500
501    Ok(())
502}
503
504/// Resolves a path from `git rev-parse <flag>` inside `worktree`.
505///
506/// Returns the absolute, trimmed path. The output of `git rev-parse` may be
507/// relative to the worktree, so we canonicalise it against the worktree root
508/// when it is not already absolute.
509fn git_rev_parse_path(worktree: &Path, flag: &str) -> Result<PathBuf, PawError> {
510    let output = std::process::Command::new("git")
511        .current_dir(worktree)
512        .args(["rev-parse", flag])
513        .output()
514        .map_err(|e| PawError::AgentsMdError(format!("failed to run git rev-parse {flag}: {e}")))?;
515    if !output.status.success() {
516        let stderr = String::from_utf8_lossy(&output.stderr);
517        return Err(PawError::AgentsMdError(format!(
518            "git rev-parse {flag} failed in '{}': {stderr}",
519            worktree.display()
520        )));
521    }
522    let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
523    let path = PathBuf::from(&raw);
524    if path.is_absolute() {
525        Ok(path)
526    } else {
527        Ok(worktree.join(path))
528    }
529}
530
531/// Installs git-paw's `post-commit` dispatcher and `pre-push` block hook.
532///
533/// Linked git worktrees share the main repository's `.git/hooks/` directory
534/// (unless `extensions.worktreeConfig` is enabled, which is intrusive). This
535/// function therefore:
536///
537/// 1. Resolves the **common** git dir via `git rev-parse --git-common-dir` and
538///    installs the dispatcher hooks at `<common>/hooks/post-commit` and
539///    `<common>/hooks/pre-push` (chained onto any existing hooks).
540/// 2. Resolves the **linked** git dir via `git rev-parse --git-dir` and writes
541///    a per-worktree marker file at `<linked>/paw-agent-id` containing the
542///    `PAW_AGENT_ID` and `PAW_BROKER_URL` values the dispatcher will source.
543///
544/// The dispatcher hook reads `$GIT_DIR/paw-agent-id` at commit time — git sets
545/// `GIT_DIR` to the correct per-worktree gitdir, so each worktree publishes
546/// under its own agent id.
547pub fn install_git_hooks(
548    worktree: &Path,
549    broker_url: &str,
550    agent_id: &str,
551) -> Result<(), PawError> {
552    let common_git_dir = git_rev_parse_path(worktree, "--git-common-dir")?;
553    let linked_git_dir = git_rev_parse_path(worktree, "--git-dir")?;
554    let hooks_dir = common_git_dir.join("hooks");
555
556    write_hook_file(
557        &hooks_dir.join("post-commit"),
558        &build_post_commit_dispatcher_hook(),
559    )?;
560    write_hook_file(&hooks_dir.join("pre-push"), &build_pre_push_hook())?;
561
562    let marker_path = linked_git_dir.join("paw-agent-id");
563    if let Some(parent) = marker_path.parent() {
564        fs::create_dir_all(parent).map_err(|e| {
565            PawError::AgentsMdError(format!("failed to create '{}': {e}", parent.display()))
566        })?;
567    }
568    fs::write(
569        &marker_path,
570        build_agent_marker(broker_url, agent_id, None, None, None),
571    )
572    .map_err(|e| {
573        PawError::AgentsMdError(format!("failed to write '{}': {e}", marker_path.display()))
574    })?;
575
576    Ok(())
577}
578
579pub fn inject_section_into_file(path: &Path, section: &str) -> Result<(), PawError> {
580    let content = match fs::read_to_string(path) {
581        Ok(c) => c,
582        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
583        Err(e) => {
584            return Err(PawError::AgentsMdError(format!(
585                "failed to read '{}': {e}",
586                path.display()
587            )));
588        }
589    };
590
591    let output = inject_into_content(&content, section);
592
593    fs::write(path, &output)
594        .map_err(|e| PawError::AgentsMdError(format!("failed to write '{}': {e}", path.display())))
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    /// Test helper: generates a sample marker-delimited section for testing injection logic.
602    fn sample_section() -> String {
603        format!("{START_MARKER}\n## git-paw test section\n{END_MARKER}\n")
604    }
605
606    // -----------------------------------------------------------------------
607    // has_git_paw_section
608    // -----------------------------------------------------------------------
609
610    #[test]
611    fn has_section_returns_true_when_marker_present() {
612        let content = "# My Project\n\n<!-- git-paw:start — managed by git-paw, do not edit manually -->\nstuff\n<!-- git-paw:end -->\n";
613        assert!(has_git_paw_section(content));
614    }
615
616    #[test]
617    fn has_section_returns_false_without_marker() {
618        let content = "# My Project\n\nSome instructions.\n";
619        assert!(!has_git_paw_section(content));
620    }
621
622    #[test]
623    fn has_section_returns_false_for_empty() {
624        assert!(!has_git_paw_section(""));
625    }
626
627    // -----------------------------------------------------------------------
628    // generate_git_paw_section
629    // -----------------------------------------------------------------------
630
631    #[test]
632    fn generated_section_has_markers() {
633        let section = sample_section();
634        assert!(section.starts_with(START_MARKER));
635        assert!(section.contains(END_MARKER));
636    }
637
638    #[test]
639    fn sample_section_contains_git_paw_reference() {
640        let section = sample_section();
641        assert!(section.contains("git-paw"));
642    }
643
644    // -----------------------------------------------------------------------
645    // replace_git_paw_section
646    // -----------------------------------------------------------------------
647
648    #[test]
649    fn replace_with_both_markers_preserves_surrounding() {
650        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";
651        let new_section = "<!-- git-paw:start — managed by git-paw, do not edit manually -->\nnew content\n<!-- git-paw:end -->\n";
652        let result = replace_git_paw_section(content, new_section);
653        assert!(result.contains("# Title"));
654        assert!(result.contains("new content"));
655        assert!(!result.contains("old content"));
656        assert!(result.contains("## Footer"));
657    }
658
659    #[test]
660    fn replace_with_missing_end_marker_replaces_to_eof() {
661        let content = "# Title\n\n<!-- git-paw:start — managed by git-paw, do not edit manually -->\nold content that never ends\n";
662        let new_section = "<!-- git-paw:start — managed by git-paw, do not edit manually -->\nfixed\n<!-- git-paw:end -->\n";
663        let result = replace_git_paw_section(content, new_section);
664        assert!(result.contains("# Title"));
665        assert!(result.contains("fixed"));
666        assert!(!result.contains("old content"));
667    }
668
669    // -----------------------------------------------------------------------
670    // inject_into_content
671    // -----------------------------------------------------------------------
672
673    #[test]
674    fn inject_appends_when_no_existing_section() {
675        let content = "# My Project\n\nSome info.\n";
676        let section = sample_section();
677        let result = inject_into_content(content, &section);
678        assert!(result.starts_with("# My Project"));
679        assert!(result.contains(START_MARKER));
680    }
681
682    #[test]
683    fn inject_replaces_existing_section() {
684        let old_section = format!("{START_MARKER}\nold\n{END_MARKER}\n");
685        let content = format!("# Title\n\n{old_section}\n## Footer\n");
686        let new_section = format!("{START_MARKER}\nnew\n{END_MARKER}\n");
687        let result = inject_into_content(&content, &new_section);
688        assert!(result.contains("new"));
689        assert!(!result.contains("old"));
690        assert!(result.contains("## Footer"));
691    }
692
693    #[test]
694    fn inject_into_empty_content_returns_section_only() {
695        let section = sample_section();
696        let result = inject_into_content("", &section);
697        assert_eq!(result, section);
698    }
699
700    // -----------------------------------------------------------------------
701    // Spacing tests
702    // -----------------------------------------------------------------------
703
704    #[test]
705    fn spacing_with_trailing_newline() {
706        let content = "# Title\n";
707        let section = "<!-- git-paw:start -->\n<!-- git-paw:end -->\n";
708        let result = inject_into_content(content, section);
709        // Should have blank line separator: "# Title\n\n<!-- git-paw..."
710        assert!(result.contains("# Title\n\n<!-- git-paw:start"));
711    }
712
713    #[test]
714    fn spacing_without_trailing_newline() {
715        let content = "# Title";
716        let section = "<!-- git-paw:start -->\n<!-- git-paw:end -->\n";
717        let result = inject_into_content(content, section);
718        // Should add newline + blank line: "# Title\n\n<!-- git-paw..."
719        assert!(result.contains("# Title\n\n<!-- git-paw:start"));
720    }
721
722    // -----------------------------------------------------------------------
723    // File I/O tests
724    // -----------------------------------------------------------------------
725
726    #[test]
727    fn file_inject_appends_to_existing() {
728        let dir = tempfile::tempdir().unwrap();
729        let path = dir.path().join("AGENTS.md");
730        fs::write(&path, "# Existing\n").unwrap();
731
732        let section = sample_section();
733        inject_section_into_file(&path, &section).unwrap();
734
735        let result = fs::read_to_string(&path).unwrap();
736        assert!(result.contains("# Existing"));
737        assert!(result.contains(START_MARKER));
738    }
739
740    #[test]
741    fn file_inject_replaces_existing_section() {
742        let dir = tempfile::tempdir().unwrap();
743        let path = dir.path().join("AGENTS.md");
744        let initial = format!("# Title\n\n{START_MARKER}\nold\n{END_MARKER}\n");
745        fs::write(&path, &initial).unwrap();
746
747        let new_section = sample_section();
748        inject_section_into_file(&path, &new_section).unwrap();
749
750        let result = fs::read_to_string(&path).unwrap();
751        assert!(result.contains("# Title"));
752        assert!(!result.contains("\nold\n"));
753        assert!(result.contains("git-paw test section"));
754    }
755
756    #[test]
757    fn file_inject_creates_missing_file() {
758        let dir = tempfile::tempdir().unwrap();
759        let path = dir.path().join("AGENTS.md");
760        assert!(!path.exists());
761
762        let section = sample_section();
763        inject_section_into_file(&path, &section).unwrap();
764
765        let result = fs::read_to_string(&path).unwrap();
766        assert!(result.contains(START_MARKER));
767    }
768
769    #[test]
770    fn file_inject_readonly_returns_error() {
771        use std::os::unix::fs::PermissionsExt;
772
773        let dir = tempfile::tempdir().unwrap();
774        let path = dir.path().join("AGENTS.md");
775        fs::write(&path, "content").unwrap();
776        fs::set_permissions(&path, fs::Permissions::from_mode(0o444)).unwrap();
777
778        let section = sample_section();
779        let result = inject_section_into_file(&path, &section);
780        assert!(result.is_err());
781        let err = result.unwrap_err();
782        let msg = err.to_string();
783        assert!(msg.contains("AGENTS.md error"), "got: {msg}");
784        assert!(
785            msg.contains("AGENTS.md"),
786            "should mention file path, got: {msg}"
787        );
788
789        // Cleanup: restore permissions so tempdir can be removed
790        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
791    }
792
793    // -----------------------------------------------------------------------
794    // generate_worktree_section
795    // -----------------------------------------------------------------------
796
797    fn make_assignment(spec: Option<&str>, files: Option<Vec<&str>>) -> WorktreeAssignment {
798        WorktreeAssignment {
799            branch: "feat/foo".to_string(),
800            cli: "claude".to_string(),
801            spec_content: spec.map(ToString::to_string),
802            owned_files: files.map(|v| v.into_iter().map(ToString::to_string).collect()),
803            skill_content: None,
804            inter_agent_rules: None,
805        }
806    }
807
808    fn make_assignment_with_skill(
809        spec: Option<&str>,
810        files: Option<Vec<&str>>,
811        skill: Option<&str>,
812    ) -> WorktreeAssignment {
813        WorktreeAssignment {
814            branch: "feat/foo".to_string(),
815            cli: "claude".to_string(),
816            spec_content: spec.map(ToString::to_string),
817            owned_files: files.map(|v| v.into_iter().map(ToString::to_string).collect()),
818            skill_content: skill.map(ToString::to_string),
819            inter_agent_rules: None,
820        }
821    }
822
823    #[test]
824    fn worktree_section_all_fields() {
825        let assignment = make_assignment(
826            Some("Implement the widget.\n"),
827            Some(vec!["src/widget.rs", "tests/widget.rs"]),
828        );
829        let section = generate_worktree_section(&assignment);
830        assert!(section.starts_with(START_MARKER));
831        assert!(section.contains(END_MARKER));
832        assert!(section.contains("`feat/foo`"));
833        assert!(section.contains("claude"));
834        assert!(section.contains("### Spec"));
835        assert!(section.contains("Implement the widget."));
836        assert!(section.contains("### File Ownership"));
837        assert!(section.contains("`src/widget.rs`"));
838        assert!(section.contains("`tests/widget.rs`"));
839    }
840
841    #[test]
842    fn worktree_section_no_spec() {
843        let assignment = make_assignment(None, Some(vec!["src/main.rs"]));
844        let section = generate_worktree_section(&assignment);
845        assert!(section.contains("`feat/foo`"));
846        assert!(!section.contains("### Spec"));
847        assert!(section.contains("### File Ownership"));
848    }
849
850    #[test]
851    fn worktree_section_no_files() {
852        let assignment = make_assignment(Some("Do the thing.\n"), None);
853        let section = generate_worktree_section(&assignment);
854        assert!(section.contains("### Spec"));
855        assert!(!section.contains("### File Ownership"));
856    }
857
858    #[test]
859    fn worktree_section_minimal() {
860        let assignment = make_assignment(None, None);
861        let section = generate_worktree_section(&assignment);
862        assert!(section.starts_with(START_MARKER));
863        assert!(section.contains(END_MARKER));
864        assert!(section.contains("`feat/foo`"));
865        assert!(section.contains("claude"));
866        assert!(!section.contains("### Spec"));
867        assert!(!section.contains("### File Ownership"));
868    }
869
870    // -----------------------------------------------------------------------
871    // setup_worktree_agents_md
872    // -----------------------------------------------------------------------
873
874    /// Creates a real git repo in a tempdir (git init + initial commit).
875    ///
876    /// Resolves the absolute path to `git` once to avoid ENOENT races
877    /// under heavy parallel test load on macOS.
878    fn init_git_repo(dir: &Path) {
879        use std::process::Command;
880        let git = which::which("git").expect("git must be on PATH");
881        Command::new(&git)
882            .current_dir(dir)
883            .args(["init"])
884            .output()
885            .expect("git init");
886        Command::new(&git)
887            .current_dir(dir)
888            .args(["config", "user.email", "test@test.com"])
889            .output()
890            .expect("git config email");
891        Command::new(&git)
892            .current_dir(dir)
893            .args(["config", "user.name", "Test"])
894            .output()
895            .expect("git config name");
896        // Create and commit a file so HEAD exists
897        fs::write(dir.join("README.md"), "# test\n").unwrap();
898        Command::new(&git)
899            .current_dir(dir)
900            .args(["add", "README.md"])
901            .output()
902            .expect("git add");
903        Command::new(&git)
904            .current_dir(dir)
905            .args(["commit", "-m", "init"])
906            .output()
907            .expect("git commit");
908    }
909
910    #[test]
911    fn setup_worktree_root_exists() {
912        let repo = tempfile::tempdir().unwrap();
913        let wt = tempfile::tempdir().unwrap();
914        init_git_repo(wt.path());
915        fs::write(repo.path().join("AGENTS.md"), "# Project Rules\n").unwrap();
916
917        // Track AGENTS.md in the worktree's git index so assume-unchanged works
918        fs::write(wt.path().join("AGENTS.md"), "# placeholder\n").unwrap();
919        std::process::Command::new("git")
920            .current_dir(wt.path())
921            .args(["add", "AGENTS.md"])
922            .output()
923            .expect("git add AGENTS.md");
924        std::process::Command::new("git")
925            .current_dir(wt.path())
926            .args(["commit", "-m", "add agents"])
927            .output()
928            .expect("git commit");
929
930        let assignment = make_assignment(None, None);
931        setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
932
933        let result = fs::read_to_string(wt.path().join("AGENTS.md")).unwrap();
934        assert!(result.contains("# Project Rules"));
935        assert!(result.contains("`feat/foo`"));
936        assert!(result.contains(START_MARKER));
937
938        // Verify AGENTS.md is hidden from git status (assume-unchanged)
939        let status = std::process::Command::new("git")
940            .current_dir(wt.path())
941            .args(["status", "--porcelain"])
942            .output()
943            .expect("git status");
944        let status_output = String::from_utf8_lossy(&status.stdout);
945        assert!(
946            !status_output.contains("AGENTS.md"),
947            "AGENTS.md should not appear in git status, got: {status_output}"
948        );
949    }
950
951    #[test]
952    fn setup_worktree_root_missing() {
953        let repo = tempfile::tempdir().unwrap();
954        let wt = tempfile::tempdir().unwrap();
955        init_git_repo(wt.path());
956
957        let assignment = make_assignment(None, None);
958        setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
959
960        let result = fs::read_to_string(wt.path().join("AGENTS.md")).unwrap();
961        assert!(!result.contains("# Project Rules"));
962        assert!(result.contains("`feat/foo`"));
963    }
964
965    #[test]
966    fn setup_worktree_replaces_root_section() {
967        let repo = tempfile::tempdir().unwrap();
968        let wt = tempfile::tempdir().unwrap();
969        init_git_repo(wt.path());
970        let root_content =
971            format!("# Rules\n\n{START_MARKER}\nold root section\n{END_MARKER}\n\n## Footer\n");
972        fs::write(repo.path().join("AGENTS.md"), &root_content).unwrap();
973
974        let assignment = make_assignment(None, None);
975        setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
976
977        let result = fs::read_to_string(wt.path().join("AGENTS.md")).unwrap();
978        assert!(result.contains("# Rules"));
979        assert!(result.contains("## Footer"));
980        assert!(!result.contains("old root section"));
981        assert!(result.contains("`feat/foo`"));
982        assert_eq!(
983            result.matches(START_MARKER_PREFIX).count(),
984            1,
985            "should have exactly one git-paw section"
986        );
987    }
988
989    // -----------------------------------------------------------------------
990    // setup_worktree_agents_md — write failure
991    // -----------------------------------------------------------------------
992
993    #[test]
994    fn setup_worktree_write_failure_returns_agents_md_error() {
995        use std::os::unix::fs::PermissionsExt;
996
997        let repo = tempfile::tempdir().unwrap();
998        let wt = tempfile::tempdir().unwrap();
999        init_git_repo(wt.path());
1000
1001        // Make the worktree root read-only so AGENTS.md cannot be written
1002        fs::set_permissions(wt.path(), fs::Permissions::from_mode(0o555)).unwrap();
1003
1004        let assignment = make_assignment(None, None);
1005        let result = setup_worktree_agents_md(repo.path(), wt.path(), &assignment);
1006
1007        // Restore permissions so tempdir cleanup can succeed
1008        fs::set_permissions(wt.path(), fs::Permissions::from_mode(0o755)).unwrap();
1009
1010        assert!(result.is_err(), "should fail when worktree is read-only");
1011        let err = result.unwrap_err();
1012        let msg = err.to_string();
1013        assert!(
1014            msg.contains("AGENTS.md error"),
1015            "should return AgentsMdError, got: {msg}"
1016        );
1017    }
1018
1019    // -----------------------------------------------------------------------
1020    // exclude_from_git
1021    // -----------------------------------------------------------------------
1022
1023    #[test]
1024    fn exclude_creates_file_when_missing() {
1025        let wt = tempfile::tempdir().unwrap();
1026        fs::create_dir_all(wt.path().join(".git/info")).unwrap();
1027
1028        exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1029
1030        let content = fs::read_to_string(wt.path().join(".git/info/exclude")).unwrap();
1031        assert!(content.contains("AGENTS.md"));
1032    }
1033
1034    #[test]
1035    fn exclude_appends_when_not_present() {
1036        let wt = tempfile::tempdir().unwrap();
1037        let info = wt.path().join(".git/info");
1038        fs::create_dir_all(&info).unwrap();
1039        fs::write(info.join("exclude"), "*.log\n").unwrap();
1040
1041        exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1042
1043        let content = fs::read_to_string(info.join("exclude")).unwrap();
1044        assert!(content.contains("*.log"));
1045        assert!(content.contains("AGENTS.md"));
1046    }
1047
1048    #[test]
1049    fn exclude_no_duplicate() {
1050        let wt = tempfile::tempdir().unwrap();
1051        let info = wt.path().join(".git/info");
1052        fs::create_dir_all(&info).unwrap();
1053        fs::write(info.join("exclude"), "AGENTS.md\n").unwrap();
1054
1055        exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1056
1057        let content = fs::read_to_string(info.join("exclude")).unwrap();
1058        assert_eq!(content.matches("AGENTS.md").count(), 1);
1059    }
1060
1061    #[test]
1062    fn exclude_creates_info_dir() {
1063        let wt = tempfile::tempdir().unwrap();
1064        fs::create_dir_all(wt.path().join(".git")).unwrap();
1065        assert!(!wt.path().join(".git/info").exists());
1066
1067        exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1068
1069        assert!(wt.path().join(".git/info/exclude").exists());
1070        let content = fs::read_to_string(wt.path().join(".git/info/exclude")).unwrap();
1071        assert!(content.contains("AGENTS.md"));
1072    }
1073
1074    // -----------------------------------------------------------------------
1075    // generate_worktree_section — skill_content
1076    // -----------------------------------------------------------------------
1077
1078    #[test]
1079    fn worktree_section_all_fields_with_skill() {
1080        let assignment = make_assignment_with_skill(
1081            Some("Implement the widget.\n"),
1082            Some(vec!["src/widget.rs", "tests/widget.rs"]),
1083            Some("## Coordination\nUse the broker at http://127.0.0.1:9119 as feat-foo.\n"),
1084        );
1085        let section = generate_worktree_section(&assignment);
1086        assert!(section.starts_with(START_MARKER));
1087        assert!(section.contains(END_MARKER));
1088        assert!(section.contains("`feat/foo`"));
1089        assert!(section.contains("claude"));
1090        assert!(section.contains("### Spec"));
1091        assert!(section.contains("Implement the widget."));
1092        assert!(section.contains("### File Ownership"));
1093        assert!(section.contains("`src/widget.rs`"));
1094        assert!(section.contains("## Coordination"));
1095        // Skill content appears after file ownership and before end marker
1096        let ownership_pos = section.find("### File Ownership").unwrap();
1097        let skill_pos = section.find("## Coordination").unwrap();
1098        let end_pos = section.find(END_MARKER).unwrap();
1099        assert!(
1100            ownership_pos < skill_pos,
1101            "skill must come after file ownership"
1102        );
1103        assert!(skill_pos < end_pos, "skill must come before end marker");
1104    }
1105
1106    #[test]
1107    fn worktree_section_skill_without_spec_or_files() {
1108        let assignment = make_assignment_with_skill(
1109            None,
1110            None,
1111            Some("## Coordination\nBroker instructions here.\n"),
1112        );
1113        let section = generate_worktree_section(&assignment);
1114        assert!(section.contains("`feat/foo`"));
1115        assert!(section.contains("claude"));
1116        assert!(!section.contains("### Spec"));
1117        assert!(!section.contains("### File Ownership"));
1118        assert!(section.contains("## Coordination"));
1119        // Skill content appears after assignment and before end marker
1120        let assignment_pos = section.find("**CLI:**").unwrap();
1121        let skill_pos = section.find("## Coordination").unwrap();
1122        let end_pos = section.find(END_MARKER).unwrap();
1123        assert!(
1124            assignment_pos < skill_pos,
1125            "skill must come after assignment"
1126        );
1127        assert!(skill_pos < end_pos, "skill must come before end marker");
1128    }
1129
1130    #[test]
1131    fn worktree_section_none_skill_matches_v020() {
1132        // With skill_content = None, output must be identical to make_assignment (no skill)
1133        let with_none =
1134            make_assignment_with_skill(Some("Do the thing.\n"), Some(vec!["src/main.rs"]), None);
1135        let without = make_assignment(Some("Do the thing.\n"), Some(vec!["src/main.rs"]));
1136        assert_eq!(
1137            generate_worktree_section(&with_none),
1138            generate_worktree_section(&without),
1139            "skill_content = None must produce identical output to v0.2.0"
1140        );
1141    }
1142
1143    #[test]
1144    fn worktree_section_skill_contains_slugified_branch() {
1145        let assignment = WorktreeAssignment {
1146            branch: "feat/http-broker".to_string(),
1147            cli: "claude".to_string(),
1148            spec_content: None,
1149            owned_files: None,
1150            skill_content: Some(
1151                "Agent ID: feat-http-broker\nURL: http://127.0.0.1:9119\n".to_string(),
1152            ),
1153            inter_agent_rules: None,
1154        };
1155        let section = generate_worktree_section(&assignment);
1156        assert!(
1157            section.contains("feat-http-broker"),
1158            "should contain slugified branch"
1159        );
1160        assert!(
1161            !section.contains("{{BRANCH_ID}}"),
1162            "should not contain literal template placeholder"
1163        );
1164    }
1165
1166    #[test]
1167    fn worktree_section_skill_preserves_broker_url_placeholder() {
1168        let assignment = make_assignment_with_skill(
1169            None,
1170            None,
1171            Some("Connect to http://127.0.0.1:9119/messages\n"),
1172        );
1173        let section = generate_worktree_section(&assignment);
1174        assert!(
1175            section.contains("http://127.0.0.1:9119"),
1176            "broker URL must be present"
1177        );
1178    }
1179
1180    // -----------------------------------------------------------------------
1181    // generate_worktree_section — inter_agent_rules
1182    // -----------------------------------------------------------------------
1183
1184    #[test]
1185    fn worktree_section_with_inter_agent_rules() {
1186        let mut assignment = make_assignment(Some("Do the widget.\n"), Some(vec!["src/widget.rs"]));
1187        assignment.inter_agent_rules = Some("Stay in your lane.\nNever push.\n".to_string());
1188        let section = generate_worktree_section(&assignment);
1189        assert!(section.contains("## Inter-Agent Rules"));
1190        assert!(section.contains("Stay in your lane."));
1191        // Rules section appears before end marker
1192        let rules_pos = section.find("## Inter-Agent Rules").unwrap();
1193        let end_pos = section.find(END_MARKER).unwrap();
1194        assert!(rules_pos < end_pos, "rules must come before end marker");
1195    }
1196
1197    #[test]
1198    fn worktree_section_without_inter_agent_rules_has_no_section() {
1199        let assignment = make_assignment(Some("Do the widget.\n"), Some(vec!["src/widget.rs"]));
1200        let section = generate_worktree_section(&assignment);
1201        assert!(!section.contains("## Inter-Agent Rules"));
1202    }
1203
1204    #[test]
1205    fn worktree_section_inter_agent_rules_none_matches_pre_change() {
1206        // When inter_agent_rules is None, output must equal the pre-change baseline.
1207        let baseline = make_assignment(Some("Do.\n"), Some(vec!["src/main.rs"]));
1208        let with_none = WorktreeAssignment {
1209            branch: baseline.branch.clone(),
1210            cli: baseline.cli.clone(),
1211            spec_content: baseline.spec_content.clone(),
1212            owned_files: baseline.owned_files.clone(),
1213            skill_content: None,
1214            inter_agent_rules: None,
1215        };
1216        assert_eq!(
1217            generate_worktree_section(&baseline),
1218            generate_worktree_section(&with_none),
1219        );
1220    }
1221
1222    // -----------------------------------------------------------------------
1223    // build_inter_agent_rules
1224    // -----------------------------------------------------------------------
1225
1226    #[test]
1227    fn build_inter_agent_rules_contains_file_ownership() {
1228        let rules = build_inter_agent_rules(&["feat/a", "feat/b"]);
1229        assert!(rules.contains("File ownership"));
1230        assert!(rules.contains("`feat/a`"));
1231        assert!(rules.contains("`feat/b`"));
1232    }
1233
1234    #[test]
1235    fn build_inter_agent_rules_contains_never_push() {
1236        let rules = build_inter_agent_rules(&["feat/a"]);
1237        assert!(rules.contains("MUST NOT `git push`"));
1238    }
1239
1240    #[test]
1241    fn build_inter_agent_rules_notes_automatic_status() {
1242        let rules = build_inter_agent_rules(&["feat/a"]);
1243        assert!(rules.contains("Status publishing is automatic"));
1244        assert!(rules.contains("post-commit"));
1245    }
1246
1247    #[test]
1248    fn build_inter_agent_rules_contains_match_spec() {
1249        let rules = build_inter_agent_rules(&["feat/a"]);
1250        assert!(
1251            rules
1252                .to_lowercase()
1253                .contains("match spec field names exactly")
1254        );
1255    }
1256
1257    #[test]
1258    fn build_inter_agent_rules_contains_cherry_pick_reference() {
1259        let rules = build_inter_agent_rules(&["feat/a"]);
1260        assert!(rules.to_lowercase().contains("cherry-pick"));
1261    }
1262
1263    // -----------------------------------------------------------------------
1264    // Embedded coordination skill — proactive publishing + cherry-pick
1265    // -----------------------------------------------------------------------
1266
1267    #[test]
1268    fn embedded_coordination_contains_cherry_pick() {
1269        let content = include_str!("../assets/agent-skills/coordination.md");
1270        assert!(content.contains("git cherry-pick"));
1271    }
1272
1273    #[test]
1274    fn embedded_coordination_documents_automatic_status() {
1275        let content = include_str!("../assets/agent-skills/coordination.md");
1276        let lower = content.to_lowercase();
1277        assert!(lower.contains("automatic"));
1278        assert!(lower.contains("post-commit"));
1279    }
1280
1281    #[test]
1282    fn embedded_coordination_does_not_require_manual_status_publish() {
1283        let content = include_str!("../assets/agent-skills/coordination.md");
1284        assert!(!content.contains("MUST publish `agent.status`"));
1285        assert!(!content.contains("You MUST publish `agent.status`"));
1286    }
1287
1288    #[test]
1289    fn embedded_coordination_still_contains_optin_operations() {
1290        let content = include_str!("../assets/agent-skills/coordination.md");
1291        assert!(content.contains("agent.blocked"));
1292        assert!(content.contains("agent.artifact"));
1293        assert!(content.contains("{{GIT_PAW_BROKER_URL}}/messages/{{BRANCH_ID}}"));
1294    }
1295
1296    #[test]
1297    fn embedded_coordination_requires_no_push() {
1298        let content = include_str!("../assets/agent-skills/coordination.md");
1299        assert!(content.contains("MUST NOT push"));
1300    }
1301
1302    // -----------------------------------------------------------------------
1303    // Git hook installation
1304    // -----------------------------------------------------------------------
1305
1306    #[test]
1307    fn post_commit_dispatcher_hook_reads_marker_and_publishes() {
1308        let script = build_post_commit_dispatcher_hook();
1309        assert!(script.contains("$GIT_DIR/paw-agent-id"));
1310        assert!(script.contains(". \"$GIT_DIR/paw-agent-id\""));
1311        assert!(script.contains("$PAW_BROKER_URL/publish"));
1312        assert!(script.contains("$PAW_AGENT_ID"));
1313        assert!(script.contains("agent.artifact"));
1314        assert!(script.contains("|| true"));
1315    }
1316
1317    #[test]
1318    fn agent_marker_is_shell_sourceable() {
1319        let marker = build_agent_marker("http://127.0.0.1:9119", "feat-x", None, None, None);
1320        assert!(marker.contains("PAW_AGENT_ID=feat-x"));
1321        assert!(marker.contains("PAW_BROKER_URL=http://127.0.0.1:9119"));
1322    }
1323
1324    #[test]
1325    fn pre_push_hook_only_rejects_agent_worktrees() {
1326        let script = build_pre_push_hook();
1327        // The reject path must still be there so agent worktrees can't push.
1328        assert!(script.contains("exit 1"));
1329        assert!(script.contains("must not push"));
1330        // But it MUST be gated on the agent marker so the main repo and
1331        // non-agent worktrees can still push freely.
1332        assert!(
1333            script.contains("paw-agent-id"),
1334            "pre-push hook must gate the reject on $GIT_DIR/paw-agent-id; \
1335             without the gate, every push from this gitdir is blocked, \
1336             including legitimate pushes from the main repo"
1337        );
1338    }
1339
1340    #[test]
1341    fn chain_hook_replaces_existing_git_paw_block() {
1342        let existing = format!(
1343            "#!/bin/sh\n\
1344             # user hook\n\
1345             echo hi\n\
1346             {HOOK_START_MARKER}\n\
1347             old git-paw content\n\
1348             {HOOK_END_MARKER}\n"
1349        );
1350        let new_body = format!(
1351            "#!/bin/sh\n\
1352             {HOOK_START_MARKER}\n\
1353             new git-paw content\n\
1354             {HOOK_END_MARKER}\n"
1355        );
1356        let chained = chain_hook(&existing, &new_body);
1357        assert!(chained.contains("# user hook"));
1358        assert!(chained.contains("echo hi"));
1359        assert!(chained.contains("new git-paw content"));
1360        assert!(!chained.contains("old git-paw content"));
1361    }
1362
1363    #[test]
1364    fn chain_hook_appends_after_existing_content() {
1365        let existing = "#!/bin/sh\necho existing\n";
1366        let new_body = format!(
1367            "#!/bin/sh\n\
1368             {HOOK_START_MARKER}\n\
1369             new block\n\
1370             {HOOK_END_MARKER}\n"
1371        );
1372        let chained = chain_hook(existing, &new_body);
1373        assert!(chained.starts_with("#!/bin/sh\necho existing"));
1374        assert!(chained.contains("new block"));
1375        // The new shebang should be stripped when chaining.
1376        assert_eq!(chained.matches("#!/bin/sh").count(), 1);
1377    }
1378
1379    #[test]
1380    fn chain_hook_preserves_content_when_end_marker_missing() {
1381        // Corrupted/truncated hook: start marker present, end marker missing.
1382        // The user's shebang and original logic must be preserved verbatim
1383        // and the new git-paw block appended.
1384        let existing = format!(
1385            "#!/bin/sh\n\
1386             # important user logic\n\
1387             echo do_not_lose_me\n\
1388             {HOOK_START_MARKER}\n\
1389             leftover but no end marker\n"
1390        );
1391        let new_body = format!(
1392            "#!/bin/sh\n\
1393             {HOOK_START_MARKER}\n\
1394             new git-paw content\n\
1395             {HOOK_END_MARKER}\n"
1396        );
1397        let chained = chain_hook(&existing, &new_body);
1398        // All original lines must survive.
1399        assert!(chained.contains("#!/bin/sh"));
1400        assert!(chained.contains("# important user logic"));
1401        assert!(chained.contains("echo do_not_lose_me"));
1402        assert!(chained.contains("leftover but no end marker"));
1403        // The new block must be appended.
1404        assert!(chained.contains("new git-paw content"));
1405        assert!(chained.contains(HOOK_END_MARKER));
1406        // The new shebang should be stripped (only the existing one remains).
1407        assert_eq!(chained.matches("#!/bin/sh").count(), 1);
1408    }
1409
1410    #[test]
1411    #[serial_test::serial]
1412    fn install_git_hooks_writes_dispatcher_to_common_git_dir() {
1413        let tmp = tempfile::tempdir().unwrap();
1414        let worktree = tmp.path();
1415        init_git_repo(worktree);
1416
1417        install_git_hooks(worktree, "http://127.0.0.1:9119", "feat-x").unwrap();
1418
1419        let post_commit = worktree.join(".git").join("hooks").join("post-commit");
1420        let pre_push = worktree.join(".git").join("hooks").join("pre-push");
1421        let marker = worktree.join(".git").join("paw-agent-id");
1422
1423        assert!(post_commit.exists(), "post-commit should exist");
1424        assert!(pre_push.exists(), "pre-push should exist");
1425        assert!(marker.exists(), "paw-agent-id marker should exist");
1426
1427        let pc = fs::read_to_string(&post_commit).unwrap();
1428        assert!(pc.contains("$GIT_DIR/paw-agent-id"));
1429        assert!(pc.contains("agent.artifact"));
1430
1431        let marker_body = fs::read_to_string(&marker).unwrap();
1432        assert!(marker_body.contains("PAW_AGENT_ID=feat-x"));
1433        assert!(marker_body.contains("PAW_BROKER_URL=http://127.0.0.1:9119"));
1434
1435        #[cfg(unix)]
1436        {
1437            use std::os::unix::fs::PermissionsExt;
1438            let mode = fs::metadata(&post_commit).unwrap().permissions().mode();
1439            assert_eq!(mode & 0o111, 0o111, "post-commit must be executable");
1440        }
1441    }
1442
1443    #[test]
1444    #[serial_test::serial]
1445    fn install_git_hooks_preserves_existing_dispatcher_body() {
1446        let tmp = tempfile::tempdir().unwrap();
1447        let worktree = tmp.path();
1448        init_git_repo(worktree);
1449        let hook_path = worktree.join(".git").join("hooks").join("post-commit");
1450        fs::write(&hook_path, "#!/bin/sh\necho user hook\n").unwrap();
1451
1452        install_git_hooks(worktree, "http://127.0.0.1:9119", "feat-x").unwrap();
1453
1454        let body = fs::read_to_string(&hook_path).unwrap();
1455        assert!(body.contains("echo user hook"));
1456        assert!(body.contains("agent.artifact"));
1457    }
1458
1459    #[test]
1460    #[serial_test::serial]
1461    fn install_git_hooks_writes_linked_marker_for_linked_worktree() {
1462        let tmp = tempfile::tempdir().unwrap();
1463        let main_repo = tmp.path().join("main");
1464        fs::create_dir_all(&main_repo).unwrap();
1465        init_git_repo(&main_repo);
1466
1467        // Create an empty commit so we can add a worktree.
1468        std::process::Command::new("git")
1469            .args(["commit", "--allow-empty", "-m", "root", "-q"])
1470            .current_dir(&main_repo)
1471            .output()
1472            .unwrap();
1473
1474        // Add a linked worktree.
1475        let linked_path = tmp.path().join("linked");
1476        std::process::Command::new("git")
1477            .args([
1478                "worktree",
1479                "add",
1480                "-b",
1481                "feat-x",
1482                linked_path.to_str().unwrap(),
1483            ])
1484            .current_dir(&main_repo)
1485            .output()
1486            .unwrap();
1487
1488        install_git_hooks(&linked_path, "http://127.0.0.1:9119", "feat-x").unwrap();
1489
1490        // Dispatcher lives in main .git/hooks/
1491        let post_commit = main_repo.join(".git").join("hooks").join("post-commit");
1492        assert!(
1493            post_commit.exists(),
1494            "dispatcher must land in main .git/hooks/"
1495        );
1496        // Per-worktree marker lives in main .git/worktrees/linked/
1497        let marker = main_repo
1498            .join(".git")
1499            .join("worktrees")
1500            .join("linked")
1501            .join("paw-agent-id");
1502        assert!(
1503            marker.exists(),
1504            "marker must land in linked worktree gitdir"
1505        );
1506        let body = fs::read_to_string(&marker).unwrap();
1507        assert!(body.contains("PAW_AGENT_ID=feat-x"));
1508    }
1509
1510    // -----------------------------------------------------------------------
1511    // Enhanced Agent Marker Tests
1512    // -----------------------------------------------------------------------
1513
1514    #[test]
1515    fn build_agent_marker_basic_format() {
1516        let marker = build_agent_marker("http://127.0.0.1:9119", "feat-test", None, None, None);
1517
1518        assert!(marker.contains("PAW_AGENT_ID=feat-test"));
1519        assert!(marker.contains("PAW_BROKER_URL=http://127.0.0.1:9119"));
1520        assert!(marker.contains("PAW_TIMESTAMP="));
1521        // Should not contain optional fields
1522        assert!(!marker.contains("PAW_SUPERVISOR_PID"));
1523        assert!(!marker.contains("PAW_LAST_VERIFIED_COMMIT"));
1524        assert!(!marker.contains("PAW_SESSION_NAME"));
1525    }
1526
1527    #[test]
1528    fn build_agent_marker_with_all_extended_fields() {
1529        let marker = build_agent_marker(
1530            "http://localhost:9119",
1531            "feat-errors",
1532            Some(12345),
1533            Some("abc123def456"),
1534            Some("paw-test-session"),
1535        );
1536
1537        assert!(marker.contains("PAW_AGENT_ID=feat-errors"));
1538        assert!(marker.contains("PAW_BROKER_URL=http://localhost:9119"));
1539        assert!(marker.contains("PAW_SUPERVISOR_PID=12345"));
1540        assert!(marker.contains("PAW_LAST_VERIFIED_COMMIT=abc123def456"));
1541        assert!(marker.contains("PAW_SESSION_NAME=paw-test-session"));
1542        assert!(marker.contains("PAW_TIMESTAMP="));
1543    }
1544
1545    #[test]
1546    fn build_agent_marker_partial_extended_fields() {
1547        let marker =
1548            build_agent_marker("http://localhost:9119", "fix-cycle", Some(999), None, None);
1549
1550        assert!(marker.contains("PAW_SUPERVISOR_PID=999"));
1551        assert!(!marker.contains("PAW_LAST_VERIFIED_COMMIT"));
1552        assert!(!marker.contains("PAW_SESSION_NAME"));
1553    }
1554
1555    #[test]
1556    fn update_agent_marker_adds_missing_fields() {
1557        let tmp = tempfile::tempdir().unwrap();
1558        let marker_path = tmp.path().join("test-marker");
1559
1560        // Create initial marker
1561        let initial = "PAW_AGENT_ID=test\nPAW_BROKER_URL=http://localhost:9119\nPAW_TIMESTAMP=2026-01-01T00:00:00Z\n";
1562        fs::write(&marker_path, initial).unwrap();
1563
1564        // Update with supervisor PID
1565        update_agent_marker(&marker_path, Some(54321), None).unwrap();
1566
1567        let updated = fs::read_to_string(&marker_path).unwrap();
1568        assert!(updated.contains("PAW_AGENT_ID=test"));
1569        assert!(updated.contains("PAW_SUPERVISOR_PID=54321"));
1570    }
1571
1572    #[test]
1573    fn update_agent_marker_replaces_existing_fields() {
1574        let tmp = tempfile::tempdir().unwrap();
1575        let marker_path = tmp.path().join("test-marker");
1576
1577        // Create initial marker with old commit
1578        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";
1579        fs::write(&marker_path, initial).unwrap();
1580
1581        // Update with new commit
1582        update_agent_marker(&marker_path, None, Some("new456")).unwrap();
1583
1584        let updated = fs::read_to_string(&marker_path).unwrap();
1585        assert!(updated.contains("PAW_AGENT_ID=test"));
1586        assert!(updated.contains("PAW_LAST_VERIFIED_COMMIT=new456"));
1587        assert!(!updated.contains("PAW_LAST_VERIFIED_COMMIT=old123"));
1588    }
1589
1590    #[test]
1591    fn get_agent_marker_path_returns_correct_path() {
1592        let tmp = tempfile::tempdir().unwrap();
1593        let worktree = tmp.path();
1594        init_git_repo(worktree);
1595
1596        let marker_path = get_agent_marker_path(worktree).unwrap();
1597        assert!(marker_path.ends_with(".git/paw-agent-id"));
1598    }
1599}