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}
110
111/// Generates a marker-delimited assignment section for a worktree's AGENTS.md.
112pub fn generate_worktree_section(assignment: &WorktreeAssignment) -> String {
113    let mut section = String::new();
114    section.push_str(START_MARKER);
115    section.push('\n');
116    section.push('\n');
117    section.push_str("## git-paw Session Assignment\n");
118    section.push('\n');
119    let _ = writeln!(section, "- **Branch:** `{}`", assignment.branch);
120    let _ = writeln!(section, "- **CLI:** {}", assignment.cli);
121
122    if let Some(ref spec) = assignment.spec_content {
123        section.push('\n');
124        section.push_str("### Spec\n");
125        section.push('\n');
126        section.push_str(spec);
127        if !spec.ends_with('\n') {
128            section.push('\n');
129        }
130    }
131
132    if let Some(ref files) = assignment.owned_files {
133        section.push('\n');
134        section.push_str("### File Ownership\n");
135        section.push('\n');
136        for file in files {
137            let _ = writeln!(section, "- `{file}`");
138        }
139    }
140
141    section.push('\n');
142    section.push_str(END_MARKER);
143    section.push('\n');
144    section
145}
146
147/// Reads the root repo's AGENTS.md, injects the worktree assignment section,
148/// writes the result to the worktree root, and excludes it from git.
149pub fn setup_worktree_agents_md(
150    repo_root: &Path,
151    worktree_root: &Path,
152    assignment: &WorktreeAssignment,
153) -> Result<(), PawError> {
154    let root_agents = repo_root.join("AGENTS.md");
155    let root_content = match fs::read_to_string(&root_agents) {
156        Ok(c) => c,
157        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
158        Err(e) => {
159            return Err(PawError::AgentsMdError(format!(
160                "failed to read '{}': {e}",
161                root_agents.display()
162            )));
163        }
164    };
165
166    let section = generate_worktree_section(assignment);
167    let output = inject_into_content(&root_content, &section);
168
169    let worktree_agents = worktree_root.join("AGENTS.md");
170    fs::write(&worktree_agents, &output).map_err(|e| {
171        PawError::AgentsMdError(format!(
172            "failed to write '{}': {e}",
173            worktree_agents.display()
174        ))
175    })?;
176
177    exclude_from_git(worktree_root, "AGENTS.md")
178}
179
180/// Resolves the actual `.git` directory for a worktree.
181///
182/// In regular repos, `.git` is a directory. In worktrees created by
183/// `git worktree add`, `.git` is a file containing `gitdir: <path>`.
184fn resolve_git_dir(worktree_root: &Path) -> Result<PathBuf, PawError> {
185    let dot_git = worktree_root.join(".git");
186    if dot_git.is_dir() {
187        return Ok(dot_git);
188    }
189    // Worktree: .git is a file with "gitdir: <path>"
190    if dot_git.is_file() {
191        let content = fs::read_to_string(&dot_git).map_err(|e| {
192            PawError::AgentsMdError(format!("failed to read '{}': {e}", dot_git.display()))
193        })?;
194        if let Some(gitdir) = content.trim().strip_prefix("gitdir: ") {
195            let path = Path::new(gitdir);
196            if path.is_absolute() {
197                return Ok(path.to_path_buf());
198            }
199            return Ok(worktree_root.join(path));
200        }
201    }
202    // Fallback: treat as regular .git directory
203    Ok(dot_git)
204}
205
206/// Adds `filename` to the worktree's `.git/info/exclude` if not already present.
207pub fn exclude_from_git(worktree_root: &Path, filename: &str) -> Result<(), PawError> {
208    let git_dir = resolve_git_dir(worktree_root)?;
209    let git_info = git_dir.join("info");
210    if !git_info.exists() {
211        fs::create_dir_all(&git_info).map_err(|e| {
212            PawError::AgentsMdError(format!("failed to create '{}': {e}", git_info.display()))
213        })?;
214    }
215
216    let exclude_path = git_info.join("exclude");
217    let content = match fs::read_to_string(&exclude_path) {
218        Ok(c) => c,
219        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
220        Err(e) => {
221            return Err(PawError::AgentsMdError(format!(
222                "failed to read '{}': {e}",
223                exclude_path.display()
224            )));
225        }
226    };
227
228    if content.lines().any(|line| line.trim() == filename) {
229        return Ok(());
230    }
231
232    let mut new_content = content;
233    if !new_content.is_empty() && !new_content.ends_with('\n') {
234        new_content.push('\n');
235    }
236    new_content.push_str(filename);
237    new_content.push('\n');
238
239    fs::write(&exclude_path, &new_content).map_err(|e| {
240        PawError::AgentsMdError(format!("failed to write '{}': {e}", exclude_path.display()))
241    })
242}
243
244pub fn inject_section_into_file(path: &Path, section: &str) -> Result<(), PawError> {
245    let content = match fs::read_to_string(path) {
246        Ok(c) => c,
247        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
248        Err(e) => {
249            return Err(PawError::AgentsMdError(format!(
250                "failed to read '{}': {e}",
251                path.display()
252            )));
253        }
254    };
255
256    let output = inject_into_content(&content, section);
257
258    fs::write(path, &output)
259        .map_err(|e| PawError::AgentsMdError(format!("failed to write '{}': {e}", path.display())))
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    /// Test helper: generates a sample marker-delimited section for testing injection logic.
267    fn sample_section() -> String {
268        format!("{START_MARKER}\n## git-paw test section\n{END_MARKER}\n")
269    }
270
271    // -----------------------------------------------------------------------
272    // has_git_paw_section
273    // -----------------------------------------------------------------------
274
275    #[test]
276    fn has_section_returns_true_when_marker_present() {
277        let content = "# My Project\n\n<!-- git-paw:start — managed by git-paw, do not edit manually -->\nstuff\n<!-- git-paw:end -->\n";
278        assert!(has_git_paw_section(content));
279    }
280
281    #[test]
282    fn has_section_returns_false_without_marker() {
283        let content = "# My Project\n\nSome instructions.\n";
284        assert!(!has_git_paw_section(content));
285    }
286
287    #[test]
288    fn has_section_returns_false_for_empty() {
289        assert!(!has_git_paw_section(""));
290    }
291
292    // -----------------------------------------------------------------------
293    // generate_git_paw_section
294    // -----------------------------------------------------------------------
295
296    #[test]
297    fn generated_section_has_markers() {
298        let section = sample_section();
299        assert!(section.starts_with(START_MARKER));
300        assert!(section.contains(END_MARKER));
301    }
302
303    #[test]
304    fn sample_section_contains_git_paw_reference() {
305        let section = sample_section();
306        assert!(section.contains("git-paw"));
307    }
308
309    // -----------------------------------------------------------------------
310    // replace_git_paw_section
311    // -----------------------------------------------------------------------
312
313    #[test]
314    fn replace_with_both_markers_preserves_surrounding() {
315        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";
316        let new_section = "<!-- git-paw:start — managed by git-paw, do not edit manually -->\nnew content\n<!-- git-paw:end -->\n";
317        let result = replace_git_paw_section(content, new_section);
318        assert!(result.contains("# Title"));
319        assert!(result.contains("new content"));
320        assert!(!result.contains("old content"));
321        assert!(result.contains("## Footer"));
322    }
323
324    #[test]
325    fn replace_with_missing_end_marker_replaces_to_eof() {
326        let content = "# Title\n\n<!-- git-paw:start — managed by git-paw, do not edit manually -->\nold content that never ends\n";
327        let new_section = "<!-- git-paw:start — managed by git-paw, do not edit manually -->\nfixed\n<!-- git-paw:end -->\n";
328        let result = replace_git_paw_section(content, new_section);
329        assert!(result.contains("# Title"));
330        assert!(result.contains("fixed"));
331        assert!(!result.contains("old content"));
332    }
333
334    // -----------------------------------------------------------------------
335    // inject_into_content
336    // -----------------------------------------------------------------------
337
338    #[test]
339    fn inject_appends_when_no_existing_section() {
340        let content = "# My Project\n\nSome info.\n";
341        let section = sample_section();
342        let result = inject_into_content(content, &section);
343        assert!(result.starts_with("# My Project"));
344        assert!(result.contains(START_MARKER));
345    }
346
347    #[test]
348    fn inject_replaces_existing_section() {
349        let old_section = format!("{START_MARKER}\nold\n{END_MARKER}\n");
350        let content = format!("# Title\n\n{old_section}\n## Footer\n");
351        let new_section = format!("{START_MARKER}\nnew\n{END_MARKER}\n");
352        let result = inject_into_content(&content, &new_section);
353        assert!(result.contains("new"));
354        assert!(!result.contains("old"));
355        assert!(result.contains("## Footer"));
356    }
357
358    #[test]
359    fn inject_into_empty_content_returns_section_only() {
360        let section = sample_section();
361        let result = inject_into_content("", &section);
362        assert_eq!(result, section);
363    }
364
365    // -----------------------------------------------------------------------
366    // Spacing tests
367    // -----------------------------------------------------------------------
368
369    #[test]
370    fn spacing_with_trailing_newline() {
371        let content = "# Title\n";
372        let section = "<!-- git-paw:start -->\n<!-- git-paw:end -->\n";
373        let result = inject_into_content(content, section);
374        // Should have blank line separator: "# Title\n\n<!-- git-paw..."
375        assert!(result.contains("# Title\n\n<!-- git-paw:start"));
376    }
377
378    #[test]
379    fn spacing_without_trailing_newline() {
380        let content = "# Title";
381        let section = "<!-- git-paw:start -->\n<!-- git-paw:end -->\n";
382        let result = inject_into_content(content, section);
383        // Should add newline + blank line: "# Title\n\n<!-- git-paw..."
384        assert!(result.contains("# Title\n\n<!-- git-paw:start"));
385    }
386
387    // -----------------------------------------------------------------------
388    // File I/O tests
389    // -----------------------------------------------------------------------
390
391    #[test]
392    fn file_inject_appends_to_existing() {
393        let dir = tempfile::tempdir().unwrap();
394        let path = dir.path().join("AGENTS.md");
395        fs::write(&path, "# Existing\n").unwrap();
396
397        let section = sample_section();
398        inject_section_into_file(&path, &section).unwrap();
399
400        let result = fs::read_to_string(&path).unwrap();
401        assert!(result.contains("# Existing"));
402        assert!(result.contains(START_MARKER));
403    }
404
405    #[test]
406    fn file_inject_replaces_existing_section() {
407        let dir = tempfile::tempdir().unwrap();
408        let path = dir.path().join("AGENTS.md");
409        let initial = format!("# Title\n\n{START_MARKER}\nold\n{END_MARKER}\n");
410        fs::write(&path, &initial).unwrap();
411
412        let new_section = sample_section();
413        inject_section_into_file(&path, &new_section).unwrap();
414
415        let result = fs::read_to_string(&path).unwrap();
416        assert!(result.contains("# Title"));
417        assert!(!result.contains("\nold\n"));
418        assert!(result.contains("git-paw test section"));
419    }
420
421    #[test]
422    fn file_inject_creates_missing_file() {
423        let dir = tempfile::tempdir().unwrap();
424        let path = dir.path().join("AGENTS.md");
425        assert!(!path.exists());
426
427        let section = sample_section();
428        inject_section_into_file(&path, &section).unwrap();
429
430        let result = fs::read_to_string(&path).unwrap();
431        assert!(result.contains(START_MARKER));
432    }
433
434    #[test]
435    fn file_inject_readonly_returns_error() {
436        use std::os::unix::fs::PermissionsExt;
437
438        let dir = tempfile::tempdir().unwrap();
439        let path = dir.path().join("AGENTS.md");
440        fs::write(&path, "content").unwrap();
441        fs::set_permissions(&path, fs::Permissions::from_mode(0o444)).unwrap();
442
443        let section = sample_section();
444        let result = inject_section_into_file(&path, &section);
445        assert!(result.is_err());
446        let err = result.unwrap_err();
447        let msg = err.to_string();
448        assert!(msg.contains("AGENTS.md error"), "got: {msg}");
449        assert!(
450            msg.contains("AGENTS.md"),
451            "should mention file path, got: {msg}"
452        );
453
454        // Cleanup: restore permissions so tempdir can be removed
455        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
456    }
457
458    // -----------------------------------------------------------------------
459    // generate_worktree_section
460    // -----------------------------------------------------------------------
461
462    fn make_assignment(spec: Option<&str>, files: Option<Vec<&str>>) -> WorktreeAssignment {
463        WorktreeAssignment {
464            branch: "feat/foo".to_string(),
465            cli: "claude".to_string(),
466            spec_content: spec.map(ToString::to_string),
467            owned_files: files.map(|v| v.into_iter().map(ToString::to_string).collect()),
468        }
469    }
470
471    #[test]
472    fn worktree_section_all_fields() {
473        let assignment = make_assignment(
474            Some("Implement the widget.\n"),
475            Some(vec!["src/widget.rs", "tests/widget.rs"]),
476        );
477        let section = generate_worktree_section(&assignment);
478        assert!(section.starts_with(START_MARKER));
479        assert!(section.contains(END_MARKER));
480        assert!(section.contains("`feat/foo`"));
481        assert!(section.contains("claude"));
482        assert!(section.contains("### Spec"));
483        assert!(section.contains("Implement the widget."));
484        assert!(section.contains("### File Ownership"));
485        assert!(section.contains("`src/widget.rs`"));
486        assert!(section.contains("`tests/widget.rs`"));
487    }
488
489    #[test]
490    fn worktree_section_no_spec() {
491        let assignment = make_assignment(None, Some(vec!["src/main.rs"]));
492        let section = generate_worktree_section(&assignment);
493        assert!(section.contains("`feat/foo`"));
494        assert!(!section.contains("### Spec"));
495        assert!(section.contains("### File Ownership"));
496    }
497
498    #[test]
499    fn worktree_section_no_files() {
500        let assignment = make_assignment(Some("Do the thing.\n"), None);
501        let section = generate_worktree_section(&assignment);
502        assert!(section.contains("### Spec"));
503        assert!(!section.contains("### File Ownership"));
504    }
505
506    #[test]
507    fn worktree_section_minimal() {
508        let assignment = make_assignment(None, None);
509        let section = generate_worktree_section(&assignment);
510        assert!(section.starts_with(START_MARKER));
511        assert!(section.contains(END_MARKER));
512        assert!(section.contains("`feat/foo`"));
513        assert!(section.contains("claude"));
514        assert!(!section.contains("### Spec"));
515        assert!(!section.contains("### File Ownership"));
516    }
517
518    // -----------------------------------------------------------------------
519    // setup_worktree_agents_md
520    // -----------------------------------------------------------------------
521
522    #[test]
523    fn setup_worktree_root_exists() {
524        let repo = tempfile::tempdir().unwrap();
525        let wt = tempfile::tempdir().unwrap();
526        fs::write(repo.path().join("AGENTS.md"), "# Project Rules\n").unwrap();
527        // Create .git/info so exclude_from_git works
528        fs::create_dir_all(wt.path().join(".git/info")).unwrap();
529
530        let assignment = make_assignment(None, None);
531        setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
532
533        let result = fs::read_to_string(wt.path().join("AGENTS.md")).unwrap();
534        assert!(result.contains("# Project Rules"));
535        assert!(result.contains("`feat/foo`"));
536        assert!(result.contains(START_MARKER));
537    }
538
539    #[test]
540    fn setup_worktree_root_missing() {
541        let repo = tempfile::tempdir().unwrap();
542        let wt = tempfile::tempdir().unwrap();
543        fs::create_dir_all(wt.path().join(".git/info")).unwrap();
544
545        let assignment = make_assignment(None, None);
546        setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
547
548        let result = fs::read_to_string(wt.path().join("AGENTS.md")).unwrap();
549        assert!(!result.contains("# Project Rules"));
550        assert!(result.contains("`feat/foo`"));
551    }
552
553    #[test]
554    fn setup_worktree_replaces_root_section() {
555        let repo = tempfile::tempdir().unwrap();
556        let wt = tempfile::tempdir().unwrap();
557        let root_content =
558            format!("# Rules\n\n{START_MARKER}\nold root section\n{END_MARKER}\n\n## Footer\n");
559        fs::write(repo.path().join("AGENTS.md"), &root_content).unwrap();
560        fs::create_dir_all(wt.path().join(".git/info")).unwrap();
561
562        let assignment = make_assignment(None, None);
563        setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
564
565        let result = fs::read_to_string(wt.path().join("AGENTS.md")).unwrap();
566        assert!(result.contains("# Rules"));
567        assert!(result.contains("## Footer"));
568        assert!(!result.contains("old root section"));
569        assert!(result.contains("`feat/foo`"));
570        // Only one start marker
571        assert_eq!(
572            result.matches(START_MARKER_PREFIX).count(),
573            1,
574            "should have exactly one git-paw section"
575        );
576    }
577
578    // -----------------------------------------------------------------------
579    // setup_worktree_agents_md — write failure (Gap #13)
580    // -----------------------------------------------------------------------
581
582    #[test]
583    fn setup_worktree_write_failure_returns_agents_md_error() {
584        use std::os::unix::fs::PermissionsExt;
585
586        let repo = tempfile::tempdir().unwrap();
587        let wt = tempfile::tempdir().unwrap();
588        fs::create_dir_all(wt.path().join(".git/info")).unwrap();
589
590        // Make the worktree root read-only so AGENTS.md cannot be written
591        fs::set_permissions(wt.path(), fs::Permissions::from_mode(0o555)).unwrap();
592
593        let assignment = make_assignment(None, None);
594        let result = setup_worktree_agents_md(repo.path(), wt.path(), &assignment);
595
596        // Restore permissions so tempdir cleanup can succeed
597        fs::set_permissions(wt.path(), fs::Permissions::from_mode(0o755)).unwrap();
598
599        assert!(result.is_err(), "should fail when worktree is read-only");
600        let err = result.unwrap_err();
601        let msg = err.to_string();
602        assert!(
603            msg.contains("AGENTS.md error"),
604            "should return AgentsMdError, got: {msg}"
605        );
606    }
607
608    // -----------------------------------------------------------------------
609    // exclude_from_git
610    // -----------------------------------------------------------------------
611
612    #[test]
613    fn exclude_creates_file_when_missing() {
614        let wt = tempfile::tempdir().unwrap();
615        fs::create_dir_all(wt.path().join(".git/info")).unwrap();
616
617        exclude_from_git(wt.path(), "AGENTS.md").unwrap();
618
619        let content = fs::read_to_string(wt.path().join(".git/info/exclude")).unwrap();
620        assert!(content.contains("AGENTS.md"));
621    }
622
623    #[test]
624    fn exclude_appends_when_not_present() {
625        let wt = tempfile::tempdir().unwrap();
626        let info = wt.path().join(".git/info");
627        fs::create_dir_all(&info).unwrap();
628        fs::write(info.join("exclude"), "*.log\n").unwrap();
629
630        exclude_from_git(wt.path(), "AGENTS.md").unwrap();
631
632        let content = fs::read_to_string(info.join("exclude")).unwrap();
633        assert!(content.contains("*.log"));
634        assert!(content.contains("AGENTS.md"));
635    }
636
637    #[test]
638    fn exclude_no_duplicate() {
639        let wt = tempfile::tempdir().unwrap();
640        let info = wt.path().join(".git/info");
641        fs::create_dir_all(&info).unwrap();
642        fs::write(info.join("exclude"), "AGENTS.md\n").unwrap();
643
644        exclude_from_git(wt.path(), "AGENTS.md").unwrap();
645
646        let content = fs::read_to_string(info.join("exclude")).unwrap();
647        assert_eq!(content.matches("AGENTS.md").count(), 1);
648    }
649
650    #[test]
651    fn exclude_creates_info_dir() {
652        let wt = tempfile::tempdir().unwrap();
653        fs::create_dir_all(wt.path().join(".git")).unwrap();
654        assert!(!wt.path().join(".git/info").exists());
655
656        exclude_from_git(wt.path(), "AGENTS.md").unwrap();
657
658        assert!(wt.path().join(".git/info/exclude").exists());
659        let content = fs::read_to_string(wt.path().join(".git/info/exclude")).unwrap();
660        assert!(content.contains("AGENTS.md"));
661    }
662}