1use std::fmt::Write;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use crate::error::PawError;
12
13const START_MARKER_PREFIX: &str = "<!-- git-paw:start";
15
16const START_MARKER: &str = "<!-- git-paw:start — managed by git-paw, do not edit manually -->";
18
19const END_MARKER: &str = "<!-- git-paw:end -->";
21
22pub fn has_git_paw_section(content: &str) -> bool {
24 content
25 .lines()
26 .any(|line| line.starts_with(START_MARKER_PREFIX))
27}
28
29pub 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 for line in &lines[..start_idx] {
51 result.push_str(line);
52 result.push('\n');
53 }
54
55 result.push_str(new_section);
57
58 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 if end_idx.is_none() && content.ends_with('\n') && !result.ends_with('\n') {
70 result.push('\n');
71 }
72
73 result
74}
75
76pub 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 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
97pub struct WorktreeAssignment {
101 pub branch: String,
103 pub cli: String,
105 pub spec_content: Option<String>,
107 pub owned_files: Option<Vec<String>>,
109}
110
111pub 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
147pub 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, §ion);
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
180fn 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 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 Ok(dot_git)
204}
205
206pub 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 fn sample_section() -> String {
268 format!("{START_MARKER}\n## git-paw test section\n{END_MARKER}\n")
269 }
270
271 #[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 #[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 #[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 #[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, §ion);
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("", §ion);
362 assert_eq!(result, section);
363 }
364
365 #[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 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 assert!(result.contains("# Title\n\n<!-- git-paw:start"));
385 }
386
387 #[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, §ion).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, §ion).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, §ion);
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 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
456 }
457
458 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 #[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 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 assert_eq!(
572 result.matches(START_MARKER_PREFIX).count(),
573 1,
574 "should have exactly one git-paw section"
575 );
576 }
577
578 #[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 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 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 #[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}