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;
12
13/// Start marker prefix used for detection (ignores trailing comment text).
14const START_MARKER_PREFIX: &str = "<!-- git-paw:start";
15
16/// Full start marker line.
17const START_MARKER: &str = "<!-- git-paw:start — managed by git-paw, do not edit manually -->";
18
19/// End marker line.
20const END_MARKER: &str = "<!-- git-paw:end -->";
21
22/// Returns `true` if `content` contains a git-paw section start marker.
23pub fn has_git_paw_section(content: &str) -> bool {
24    content
25        .lines()
26        .any(|line| line.starts_with(START_MARKER_PREFIX))
27}
28
29/// Replaces the git-paw section (start marker through end marker, inclusive)
30/// with `new_section`. If the end marker is missing, replaces from the start
31/// marker to EOF.
32pub fn replace_git_paw_section(content: &str, new_section: &str) -> String {
33    let lines: Vec<&str> = content.lines().collect();
34
35    let Some(start_idx) = lines
36        .iter()
37        .position(|l| l.starts_with(START_MARKER_PREFIX))
38    else {
39        return content.to_string();
40    };
41
42    let end_idx = lines[start_idx..]
43        .iter()
44        .position(|l| l.contains(END_MARKER))
45        .map(|rel| start_idx + rel);
46
47    let mut result = String::new();
48
49    // Content before the start marker
50    for line in &lines[..start_idx] {
51        result.push_str(line);
52        result.push('\n');
53    }
54
55    // The new section
56    result.push_str(new_section);
57
58    // Content after the end marker (if it exists)
59    if let Some(end) = end_idx
60        && end + 1 < lines.len()
61    {
62        for line in &lines[end + 1..] {
63            result.push_str(line);
64            result.push('\n');
65        }
66    }
67
68    // Preserve trailing newline behavior of original if we replaced to EOF
69    if end_idx.is_none() && content.ends_with('\n') && !result.ends_with('\n') {
70        result.push('\n');
71    }
72
73    result
74}
75
76/// Injects `section` into `content`: appends if no git-paw section exists,
77/// replaces the existing one if present.
78pub fn inject_into_content(content: &str, section: &str) -> String {
79    if content.is_empty() {
80        return section.to_string();
81    }
82
83    if has_git_paw_section(content) {
84        return replace_git_paw_section(content, section);
85    }
86
87    // Append with proper spacing
88    let mut result = content.to_string();
89    if !result.ends_with('\n') {
90        result.push('\n');
91    }
92    result.push('\n');
93    result.push_str(section);
94    result
95}
96
97/// Reads a file (or treats a missing file as empty), injects `section`,
98/// and writes the result back.
99/// Per-worktree assignment context passed by the session launch flow.
100pub struct WorktreeAssignment {
101    /// The branch this worktree is checked out on.
102    pub branch: String,
103    /// The CLI name (e.g. "claude", "cursor") running in this worktree.
104    pub cli: String,
105    /// Optional spec content to embed in the assignment section.
106    pub spec_content: Option<String>,
107    /// Optional list of files this worktree owns.
108    pub owned_files: Option<Vec<String>>,
109    /// Optional rendered skill content to inject into the assignment section.
110    pub skill_content: Option<String>,
111}
112
113/// Generates a marker-delimited assignment section for a worktree's AGENTS.md.
114pub fn generate_worktree_section(assignment: &WorktreeAssignment) -> String {
115    let mut section = String::new();
116    section.push_str(START_MARKER);
117    section.push('\n');
118    section.push('\n');
119    section.push_str("## git-paw Session Assignment\n");
120    section.push('\n');
121    let _ = writeln!(section, "- **Branch:** `{}`", assignment.branch);
122    let _ = writeln!(section, "- **CLI:** {}", assignment.cli);
123
124    if let Some(ref spec) = assignment.spec_content {
125        section.push('\n');
126        section.push_str("### Spec\n");
127        section.push('\n');
128        section.push_str(spec);
129        if !spec.ends_with('\n') {
130            section.push('\n');
131        }
132    }
133
134    if let Some(ref files) = assignment.owned_files {
135        section.push('\n');
136        section.push_str("### File Ownership\n");
137        section.push('\n');
138        for file in files {
139            let _ = writeln!(section, "- `{file}`");
140        }
141    }
142
143    if let Some(ref skill) = assignment.skill_content {
144        section.push('\n');
145        section.push_str(skill);
146        if !skill.ends_with('\n') {
147            section.push('\n');
148        }
149    }
150
151    section.push('\n');
152    section.push_str(END_MARKER);
153    section.push('\n');
154    section
155}
156
157/// Reads the root repo's AGENTS.md, injects the worktree assignment section,
158/// writes the result to the worktree root, and protects it from being committed.
159///
160/// Uses two layers of protection:
161/// 1. `.git/info/exclude` — hides AGENTS.md from `git status`
162/// 2. `git update-index --assume-unchanged` — prevents `git add -A` from staging it
163///
164/// The second layer is critical for AI agents that run `git add -A` or
165/// `git add .` to commit their work — without it, the injected session
166/// content would be committed to the branch.
167pub fn setup_worktree_agents_md(
168    repo_root: &Path,
169    worktree_root: &Path,
170    assignment: &WorktreeAssignment,
171) -> Result<(), PawError> {
172    let root_agents = repo_root.join("AGENTS.md");
173    let root_content = match fs::read_to_string(&root_agents) {
174        Ok(c) => c,
175        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
176        Err(e) => {
177            return Err(PawError::AgentsMdError(format!(
178                "failed to read '{}': {e}",
179                root_agents.display()
180            )));
181        }
182    };
183
184    let section = generate_worktree_section(assignment);
185    let output = inject_into_content(&root_content, &section);
186
187    let worktree_agents = worktree_root.join("AGENTS.md");
188    fs::write(&worktree_agents, &output).map_err(|e| {
189        PawError::AgentsMdError(format!(
190            "failed to write '{}': {e}",
191            worktree_agents.display()
192        ))
193    })?;
194
195    exclude_from_git(worktree_root, "AGENTS.md")?;
196
197    // Belt-and-suspenders: mark the file as assume-unchanged so `git add -A`
198    // doesn't stage it. This only works when AGENTS.md is already tracked in
199    // the index (which it is for worktrees of repos that have a tracked
200    // AGENTS.md). For repos without a tracked AGENTS.md, exclude_from_git
201    // above is the primary protection.
202    let _ = assume_unchanged(worktree_root, "AGENTS.md");
203
204    Ok(())
205}
206
207/// Marks a file as assume-unchanged in git's index.
208///
209/// This prevents `git add -A`, `git add .`, and `git commit -a` from
210/// staging changes to the file, even if its content has been modified.
211/// Used to protect injected session content from being committed by
212/// AI coding agents.
213pub fn assume_unchanged(worktree_root: &Path, filename: &str) -> Result<(), PawError> {
214    let output = std::process::Command::new("git")
215        .current_dir(worktree_root)
216        .args(["update-index", "--assume-unchanged", filename])
217        .output()
218        .map_err(|e| {
219            PawError::AgentsMdError(format!(
220                "failed to run git update-index --assume-unchanged: {e}"
221            ))
222        })?;
223
224    if !output.status.success() {
225        let stderr = String::from_utf8_lossy(&output.stderr);
226        return Err(PawError::AgentsMdError(format!(
227            "git update-index --assume-unchanged failed for '{filename}': {stderr}"
228        )));
229    }
230
231    Ok(())
232}
233
234/// Resolves the actual `.git` directory for a worktree.
235///
236/// In regular repos, `.git` is a directory. In worktrees created by
237/// `git worktree add`, `.git` is a file containing `gitdir: <path>`.
238fn resolve_git_dir(worktree_root: &Path) -> Result<PathBuf, PawError> {
239    let dot_git = worktree_root.join(".git");
240    if dot_git.is_dir() {
241        return Ok(dot_git);
242    }
243    // Worktree: .git is a file with "gitdir: <path>"
244    if dot_git.is_file() {
245        let content = fs::read_to_string(&dot_git).map_err(|e| {
246            PawError::AgentsMdError(format!("failed to read '{}': {e}", dot_git.display()))
247        })?;
248        if let Some(gitdir) = content.trim().strip_prefix("gitdir: ") {
249            let path = Path::new(gitdir);
250            if path.is_absolute() {
251                return Ok(path.to_path_buf());
252            }
253            return Ok(worktree_root.join(path));
254        }
255    }
256    // Fallback: treat as regular .git directory
257    Ok(dot_git)
258}
259
260/// Adds `filename` to the worktree's `.git/info/exclude` if not already present.
261pub fn exclude_from_git(worktree_root: &Path, filename: &str) -> Result<(), PawError> {
262    let git_dir = resolve_git_dir(worktree_root)?;
263    let git_info = git_dir.join("info");
264    if !git_info.exists() {
265        fs::create_dir_all(&git_info).map_err(|e| {
266            PawError::AgentsMdError(format!("failed to create '{}': {e}", git_info.display()))
267        })?;
268    }
269
270    let exclude_path = git_info.join("exclude");
271    let content = match fs::read_to_string(&exclude_path) {
272        Ok(c) => c,
273        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
274        Err(e) => {
275            return Err(PawError::AgentsMdError(format!(
276                "failed to read '{}': {e}",
277                exclude_path.display()
278            )));
279        }
280    };
281
282    if content.lines().any(|line| line.trim() == filename) {
283        return Ok(());
284    }
285
286    let mut new_content = content;
287    if !new_content.is_empty() && !new_content.ends_with('\n') {
288        new_content.push('\n');
289    }
290    new_content.push_str(filename);
291    new_content.push('\n');
292
293    fs::write(&exclude_path, &new_content).map_err(|e| {
294        PawError::AgentsMdError(format!("failed to write '{}': {e}", exclude_path.display()))
295    })
296}
297
298pub fn inject_section_into_file(path: &Path, section: &str) -> Result<(), PawError> {
299    let content = match fs::read_to_string(path) {
300        Ok(c) => c,
301        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
302        Err(e) => {
303            return Err(PawError::AgentsMdError(format!(
304                "failed to read '{}': {e}",
305                path.display()
306            )));
307        }
308    };
309
310    let output = inject_into_content(&content, section);
311
312    fs::write(path, &output)
313        .map_err(|e| PawError::AgentsMdError(format!("failed to write '{}': {e}", path.display())))
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    /// Test helper: generates a sample marker-delimited section for testing injection logic.
321    fn sample_section() -> String {
322        format!("{START_MARKER}\n## git-paw test section\n{END_MARKER}\n")
323    }
324
325    // -----------------------------------------------------------------------
326    // has_git_paw_section
327    // -----------------------------------------------------------------------
328
329    #[test]
330    fn has_section_returns_true_when_marker_present() {
331        let content = "# My Project\n\n<!-- git-paw:start — managed by git-paw, do not edit manually -->\nstuff\n<!-- git-paw:end -->\n";
332        assert!(has_git_paw_section(content));
333    }
334
335    #[test]
336    fn has_section_returns_false_without_marker() {
337        let content = "# My Project\n\nSome instructions.\n";
338        assert!(!has_git_paw_section(content));
339    }
340
341    #[test]
342    fn has_section_returns_false_for_empty() {
343        assert!(!has_git_paw_section(""));
344    }
345
346    // -----------------------------------------------------------------------
347    // generate_git_paw_section
348    // -----------------------------------------------------------------------
349
350    #[test]
351    fn generated_section_has_markers() {
352        let section = sample_section();
353        assert!(section.starts_with(START_MARKER));
354        assert!(section.contains(END_MARKER));
355    }
356
357    #[test]
358    fn sample_section_contains_git_paw_reference() {
359        let section = sample_section();
360        assert!(section.contains("git-paw"));
361    }
362
363    // -----------------------------------------------------------------------
364    // replace_git_paw_section
365    // -----------------------------------------------------------------------
366
367    #[test]
368    fn replace_with_both_markers_preserves_surrounding() {
369        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";
370        let new_section = "<!-- git-paw:start — managed by git-paw, do not edit manually -->\nnew content\n<!-- git-paw:end -->\n";
371        let result = replace_git_paw_section(content, new_section);
372        assert!(result.contains("# Title"));
373        assert!(result.contains("new content"));
374        assert!(!result.contains("old content"));
375        assert!(result.contains("## Footer"));
376    }
377
378    #[test]
379    fn replace_with_missing_end_marker_replaces_to_eof() {
380        let content = "# Title\n\n<!-- git-paw:start — managed by git-paw, do not edit manually -->\nold content that never ends\n";
381        let new_section = "<!-- git-paw:start — managed by git-paw, do not edit manually -->\nfixed\n<!-- git-paw:end -->\n";
382        let result = replace_git_paw_section(content, new_section);
383        assert!(result.contains("# Title"));
384        assert!(result.contains("fixed"));
385        assert!(!result.contains("old content"));
386    }
387
388    // -----------------------------------------------------------------------
389    // inject_into_content
390    // -----------------------------------------------------------------------
391
392    #[test]
393    fn inject_appends_when_no_existing_section() {
394        let content = "# My Project\n\nSome info.\n";
395        let section = sample_section();
396        let result = inject_into_content(content, &section);
397        assert!(result.starts_with("# My Project"));
398        assert!(result.contains(START_MARKER));
399    }
400
401    #[test]
402    fn inject_replaces_existing_section() {
403        let old_section = format!("{START_MARKER}\nold\n{END_MARKER}\n");
404        let content = format!("# Title\n\n{old_section}\n## Footer\n");
405        let new_section = format!("{START_MARKER}\nnew\n{END_MARKER}\n");
406        let result = inject_into_content(&content, &new_section);
407        assert!(result.contains("new"));
408        assert!(!result.contains("old"));
409        assert!(result.contains("## Footer"));
410    }
411
412    #[test]
413    fn inject_into_empty_content_returns_section_only() {
414        let section = sample_section();
415        let result = inject_into_content("", &section);
416        assert_eq!(result, section);
417    }
418
419    // -----------------------------------------------------------------------
420    // Spacing tests
421    // -----------------------------------------------------------------------
422
423    #[test]
424    fn spacing_with_trailing_newline() {
425        let content = "# Title\n";
426        let section = "<!-- git-paw:start -->\n<!-- git-paw:end -->\n";
427        let result = inject_into_content(content, section);
428        // Should have blank line separator: "# Title\n\n<!-- git-paw..."
429        assert!(result.contains("# Title\n\n<!-- git-paw:start"));
430    }
431
432    #[test]
433    fn spacing_without_trailing_newline() {
434        let content = "# Title";
435        let section = "<!-- git-paw:start -->\n<!-- git-paw:end -->\n";
436        let result = inject_into_content(content, section);
437        // Should add newline + blank line: "# Title\n\n<!-- git-paw..."
438        assert!(result.contains("# Title\n\n<!-- git-paw:start"));
439    }
440
441    // -----------------------------------------------------------------------
442    // File I/O tests
443    // -----------------------------------------------------------------------
444
445    #[test]
446    fn file_inject_appends_to_existing() {
447        let dir = tempfile::tempdir().unwrap();
448        let path = dir.path().join("AGENTS.md");
449        fs::write(&path, "# Existing\n").unwrap();
450
451        let section = sample_section();
452        inject_section_into_file(&path, &section).unwrap();
453
454        let result = fs::read_to_string(&path).unwrap();
455        assert!(result.contains("# Existing"));
456        assert!(result.contains(START_MARKER));
457    }
458
459    #[test]
460    fn file_inject_replaces_existing_section() {
461        let dir = tempfile::tempdir().unwrap();
462        let path = dir.path().join("AGENTS.md");
463        let initial = format!("# Title\n\n{START_MARKER}\nold\n{END_MARKER}\n");
464        fs::write(&path, &initial).unwrap();
465
466        let new_section = sample_section();
467        inject_section_into_file(&path, &new_section).unwrap();
468
469        let result = fs::read_to_string(&path).unwrap();
470        assert!(result.contains("# Title"));
471        assert!(!result.contains("\nold\n"));
472        assert!(result.contains("git-paw test section"));
473    }
474
475    #[test]
476    fn file_inject_creates_missing_file() {
477        let dir = tempfile::tempdir().unwrap();
478        let path = dir.path().join("AGENTS.md");
479        assert!(!path.exists());
480
481        let section = sample_section();
482        inject_section_into_file(&path, &section).unwrap();
483
484        let result = fs::read_to_string(&path).unwrap();
485        assert!(result.contains(START_MARKER));
486    }
487
488    #[test]
489    fn file_inject_readonly_returns_error() {
490        use std::os::unix::fs::PermissionsExt;
491
492        let dir = tempfile::tempdir().unwrap();
493        let path = dir.path().join("AGENTS.md");
494        fs::write(&path, "content").unwrap();
495        fs::set_permissions(&path, fs::Permissions::from_mode(0o444)).unwrap();
496
497        let section = sample_section();
498        let result = inject_section_into_file(&path, &section);
499        assert!(result.is_err());
500        let err = result.unwrap_err();
501        let msg = err.to_string();
502        assert!(msg.contains("AGENTS.md error"), "got: {msg}");
503        assert!(
504            msg.contains("AGENTS.md"),
505            "should mention file path, got: {msg}"
506        );
507
508        // Cleanup: restore permissions so tempdir can be removed
509        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
510    }
511
512    // -----------------------------------------------------------------------
513    // generate_worktree_section
514    // -----------------------------------------------------------------------
515
516    fn make_assignment(spec: Option<&str>, files: Option<Vec<&str>>) -> WorktreeAssignment {
517        WorktreeAssignment {
518            branch: "feat/foo".to_string(),
519            cli: "claude".to_string(),
520            spec_content: spec.map(ToString::to_string),
521            owned_files: files.map(|v| v.into_iter().map(ToString::to_string).collect()),
522            skill_content: None,
523        }
524    }
525
526    fn make_assignment_with_skill(
527        spec: Option<&str>,
528        files: Option<Vec<&str>>,
529        skill: Option<&str>,
530    ) -> WorktreeAssignment {
531        WorktreeAssignment {
532            branch: "feat/foo".to_string(),
533            cli: "claude".to_string(),
534            spec_content: spec.map(ToString::to_string),
535            owned_files: files.map(|v| v.into_iter().map(ToString::to_string).collect()),
536            skill_content: skill.map(ToString::to_string),
537        }
538    }
539
540    #[test]
541    fn worktree_section_all_fields() {
542        let assignment = make_assignment(
543            Some("Implement the widget.\n"),
544            Some(vec!["src/widget.rs", "tests/widget.rs"]),
545        );
546        let section = generate_worktree_section(&assignment);
547        assert!(section.starts_with(START_MARKER));
548        assert!(section.contains(END_MARKER));
549        assert!(section.contains("`feat/foo`"));
550        assert!(section.contains("claude"));
551        assert!(section.contains("### Spec"));
552        assert!(section.contains("Implement the widget."));
553        assert!(section.contains("### File Ownership"));
554        assert!(section.contains("`src/widget.rs`"));
555        assert!(section.contains("`tests/widget.rs`"));
556    }
557
558    #[test]
559    fn worktree_section_no_spec() {
560        let assignment = make_assignment(None, Some(vec!["src/main.rs"]));
561        let section = generate_worktree_section(&assignment);
562        assert!(section.contains("`feat/foo`"));
563        assert!(!section.contains("### Spec"));
564        assert!(section.contains("### File Ownership"));
565    }
566
567    #[test]
568    fn worktree_section_no_files() {
569        let assignment = make_assignment(Some("Do the thing.\n"), None);
570        let section = generate_worktree_section(&assignment);
571        assert!(section.contains("### Spec"));
572        assert!(!section.contains("### File Ownership"));
573    }
574
575    #[test]
576    fn worktree_section_minimal() {
577        let assignment = make_assignment(None, None);
578        let section = generate_worktree_section(&assignment);
579        assert!(section.starts_with(START_MARKER));
580        assert!(section.contains(END_MARKER));
581        assert!(section.contains("`feat/foo`"));
582        assert!(section.contains("claude"));
583        assert!(!section.contains("### Spec"));
584        assert!(!section.contains("### File Ownership"));
585    }
586
587    // -----------------------------------------------------------------------
588    // setup_worktree_agents_md
589    // -----------------------------------------------------------------------
590
591    /// Creates a real git repo in a tempdir (git init + initial commit).
592    ///
593    /// Resolves the absolute path to `git` once to avoid ENOENT races
594    /// under heavy parallel test load on macOS.
595    fn init_git_repo(dir: &Path) {
596        use std::process::Command;
597        let git = which::which("git").expect("git must be on PATH");
598        Command::new(&git)
599            .current_dir(dir)
600            .args(["init"])
601            .output()
602            .expect("git init");
603        Command::new(&git)
604            .current_dir(dir)
605            .args(["config", "user.email", "test@test.com"])
606            .output()
607            .expect("git config email");
608        Command::new(&git)
609            .current_dir(dir)
610            .args(["config", "user.name", "Test"])
611            .output()
612            .expect("git config name");
613        // Create and commit a file so HEAD exists
614        fs::write(dir.join("README.md"), "# test\n").unwrap();
615        Command::new(&git)
616            .current_dir(dir)
617            .args(["add", "README.md"])
618            .output()
619            .expect("git add");
620        Command::new(&git)
621            .current_dir(dir)
622            .args(["commit", "-m", "init"])
623            .output()
624            .expect("git commit");
625    }
626
627    #[test]
628    fn setup_worktree_root_exists() {
629        let repo = tempfile::tempdir().unwrap();
630        let wt = tempfile::tempdir().unwrap();
631        init_git_repo(wt.path());
632        fs::write(repo.path().join("AGENTS.md"), "# Project Rules\n").unwrap();
633
634        // Track AGENTS.md in the worktree's git index so assume-unchanged works
635        fs::write(wt.path().join("AGENTS.md"), "# placeholder\n").unwrap();
636        std::process::Command::new("git")
637            .current_dir(wt.path())
638            .args(["add", "AGENTS.md"])
639            .output()
640            .expect("git add AGENTS.md");
641        std::process::Command::new("git")
642            .current_dir(wt.path())
643            .args(["commit", "-m", "add agents"])
644            .output()
645            .expect("git commit");
646
647        let assignment = make_assignment(None, None);
648        setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
649
650        let result = fs::read_to_string(wt.path().join("AGENTS.md")).unwrap();
651        assert!(result.contains("# Project Rules"));
652        assert!(result.contains("`feat/foo`"));
653        assert!(result.contains(START_MARKER));
654
655        // Verify AGENTS.md is hidden from git status (assume-unchanged)
656        let status = std::process::Command::new("git")
657            .current_dir(wt.path())
658            .args(["status", "--porcelain"])
659            .output()
660            .expect("git status");
661        let status_output = String::from_utf8_lossy(&status.stdout);
662        assert!(
663            !status_output.contains("AGENTS.md"),
664            "AGENTS.md should not appear in git status, got: {status_output}"
665        );
666    }
667
668    #[test]
669    fn setup_worktree_root_missing() {
670        let repo = tempfile::tempdir().unwrap();
671        let wt = tempfile::tempdir().unwrap();
672        init_git_repo(wt.path());
673
674        let assignment = make_assignment(None, None);
675        setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
676
677        let result = fs::read_to_string(wt.path().join("AGENTS.md")).unwrap();
678        assert!(!result.contains("# Project Rules"));
679        assert!(result.contains("`feat/foo`"));
680    }
681
682    #[test]
683    fn setup_worktree_replaces_root_section() {
684        let repo = tempfile::tempdir().unwrap();
685        let wt = tempfile::tempdir().unwrap();
686        init_git_repo(wt.path());
687        let root_content =
688            format!("# Rules\n\n{START_MARKER}\nold root section\n{END_MARKER}\n\n## Footer\n");
689        fs::write(repo.path().join("AGENTS.md"), &root_content).unwrap();
690
691        let assignment = make_assignment(None, None);
692        setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
693
694        let result = fs::read_to_string(wt.path().join("AGENTS.md")).unwrap();
695        assert!(result.contains("# Rules"));
696        assert!(result.contains("## Footer"));
697        assert!(!result.contains("old root section"));
698        assert!(result.contains("`feat/foo`"));
699        assert_eq!(
700            result.matches(START_MARKER_PREFIX).count(),
701            1,
702            "should have exactly one git-paw section"
703        );
704    }
705
706    // -----------------------------------------------------------------------
707    // setup_worktree_agents_md — write failure
708    // -----------------------------------------------------------------------
709
710    #[test]
711    fn setup_worktree_write_failure_returns_agents_md_error() {
712        use std::os::unix::fs::PermissionsExt;
713
714        let repo = tempfile::tempdir().unwrap();
715        let wt = tempfile::tempdir().unwrap();
716        init_git_repo(wt.path());
717
718        // Make the worktree root read-only so AGENTS.md cannot be written
719        fs::set_permissions(wt.path(), fs::Permissions::from_mode(0o555)).unwrap();
720
721        let assignment = make_assignment(None, None);
722        let result = setup_worktree_agents_md(repo.path(), wt.path(), &assignment);
723
724        // Restore permissions so tempdir cleanup can succeed
725        fs::set_permissions(wt.path(), fs::Permissions::from_mode(0o755)).unwrap();
726
727        assert!(result.is_err(), "should fail when worktree is read-only");
728        let err = result.unwrap_err();
729        let msg = err.to_string();
730        assert!(
731            msg.contains("AGENTS.md error"),
732            "should return AgentsMdError, got: {msg}"
733        );
734    }
735
736    // -----------------------------------------------------------------------
737    // exclude_from_git
738    // -----------------------------------------------------------------------
739
740    #[test]
741    fn exclude_creates_file_when_missing() {
742        let wt = tempfile::tempdir().unwrap();
743        fs::create_dir_all(wt.path().join(".git/info")).unwrap();
744
745        exclude_from_git(wt.path(), "AGENTS.md").unwrap();
746
747        let content = fs::read_to_string(wt.path().join(".git/info/exclude")).unwrap();
748        assert!(content.contains("AGENTS.md"));
749    }
750
751    #[test]
752    fn exclude_appends_when_not_present() {
753        let wt = tempfile::tempdir().unwrap();
754        let info = wt.path().join(".git/info");
755        fs::create_dir_all(&info).unwrap();
756        fs::write(info.join("exclude"), "*.log\n").unwrap();
757
758        exclude_from_git(wt.path(), "AGENTS.md").unwrap();
759
760        let content = fs::read_to_string(info.join("exclude")).unwrap();
761        assert!(content.contains("*.log"));
762        assert!(content.contains("AGENTS.md"));
763    }
764
765    #[test]
766    fn exclude_no_duplicate() {
767        let wt = tempfile::tempdir().unwrap();
768        let info = wt.path().join(".git/info");
769        fs::create_dir_all(&info).unwrap();
770        fs::write(info.join("exclude"), "AGENTS.md\n").unwrap();
771
772        exclude_from_git(wt.path(), "AGENTS.md").unwrap();
773
774        let content = fs::read_to_string(info.join("exclude")).unwrap();
775        assert_eq!(content.matches("AGENTS.md").count(), 1);
776    }
777
778    #[test]
779    fn exclude_creates_info_dir() {
780        let wt = tempfile::tempdir().unwrap();
781        fs::create_dir_all(wt.path().join(".git")).unwrap();
782        assert!(!wt.path().join(".git/info").exists());
783
784        exclude_from_git(wt.path(), "AGENTS.md").unwrap();
785
786        assert!(wt.path().join(".git/info/exclude").exists());
787        let content = fs::read_to_string(wt.path().join(".git/info/exclude")).unwrap();
788        assert!(content.contains("AGENTS.md"));
789    }
790
791    // -----------------------------------------------------------------------
792    // generate_worktree_section — skill_content
793    // -----------------------------------------------------------------------
794
795    #[test]
796    fn worktree_section_all_fields_with_skill() {
797        let assignment = make_assignment_with_skill(
798            Some("Implement the widget.\n"),
799            Some(vec!["src/widget.rs", "tests/widget.rs"]),
800            Some("## Coordination\nUse the broker at ${GIT_PAW_BROKER_URL} as feat-foo.\n"),
801        );
802        let section = generate_worktree_section(&assignment);
803        assert!(section.starts_with(START_MARKER));
804        assert!(section.contains(END_MARKER));
805        assert!(section.contains("`feat/foo`"));
806        assert!(section.contains("claude"));
807        assert!(section.contains("### Spec"));
808        assert!(section.contains("Implement the widget."));
809        assert!(section.contains("### File Ownership"));
810        assert!(section.contains("`src/widget.rs`"));
811        assert!(section.contains("## Coordination"));
812        // Skill content appears after file ownership and before end marker
813        let ownership_pos = section.find("### File Ownership").unwrap();
814        let skill_pos = section.find("## Coordination").unwrap();
815        let end_pos = section.find(END_MARKER).unwrap();
816        assert!(
817            ownership_pos < skill_pos,
818            "skill must come after file ownership"
819        );
820        assert!(skill_pos < end_pos, "skill must come before end marker");
821    }
822
823    #[test]
824    fn worktree_section_skill_without_spec_or_files() {
825        let assignment = make_assignment_with_skill(
826            None,
827            None,
828            Some("## Coordination\nBroker instructions here.\n"),
829        );
830        let section = generate_worktree_section(&assignment);
831        assert!(section.contains("`feat/foo`"));
832        assert!(section.contains("claude"));
833        assert!(!section.contains("### Spec"));
834        assert!(!section.contains("### File Ownership"));
835        assert!(section.contains("## Coordination"));
836        // Skill content appears after assignment and before end marker
837        let assignment_pos = section.find("**CLI:**").unwrap();
838        let skill_pos = section.find("## Coordination").unwrap();
839        let end_pos = section.find(END_MARKER).unwrap();
840        assert!(
841            assignment_pos < skill_pos,
842            "skill must come after assignment"
843        );
844        assert!(skill_pos < end_pos, "skill must come before end marker");
845    }
846
847    #[test]
848    fn worktree_section_none_skill_matches_v020() {
849        // With skill_content = None, output must be identical to make_assignment (no skill)
850        let with_none =
851            make_assignment_with_skill(Some("Do the thing.\n"), Some(vec!["src/main.rs"]), None);
852        let without = make_assignment(Some("Do the thing.\n"), Some(vec!["src/main.rs"]));
853        assert_eq!(
854            generate_worktree_section(&with_none),
855            generate_worktree_section(&without),
856            "skill_content = None must produce identical output to v0.2.0"
857        );
858    }
859
860    #[test]
861    fn worktree_section_skill_contains_slugified_branch() {
862        let assignment = WorktreeAssignment {
863            branch: "feat/http-broker".to_string(),
864            cli: "claude".to_string(),
865            spec_content: None,
866            owned_files: None,
867            skill_content: Some(
868                "Agent ID: feat-http-broker\nURL: ${GIT_PAW_BROKER_URL}\n".to_string(),
869            ),
870        };
871        let section = generate_worktree_section(&assignment);
872        assert!(
873            section.contains("feat-http-broker"),
874            "should contain slugified branch"
875        );
876        assert!(
877            !section.contains("{{BRANCH_ID}}"),
878            "should not contain literal template placeholder"
879        );
880    }
881
882    #[test]
883    fn worktree_section_skill_preserves_broker_url_placeholder() {
884        let assignment = make_assignment_with_skill(
885            None,
886            None,
887            Some("Connect to ${GIT_PAW_BROKER_URL}/messages\n"),
888        );
889        let section = generate_worktree_section(&assignment);
890        assert!(
891            section.contains("${GIT_PAW_BROKER_URL}"),
892            "broker URL placeholder must be preserved as literal"
893        );
894    }
895}