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 pub skill_content: Option<String>,
111}
112
113pub 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
157pub 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, §ion);
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 let _ = assume_unchanged(worktree_root, "AGENTS.md");
203
204 Ok(())
205}
206
207pub 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
234fn 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 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 Ok(dot_git)
258}
259
260pub 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 fn sample_section() -> String {
322 format!("{START_MARKER}\n## git-paw test section\n{END_MARKER}\n")
323 }
324
325 #[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 #[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 #[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 #[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, §ion);
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("", §ion);
416 assert_eq!(result, section);
417 }
418
419 #[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 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 assert!(result.contains("# Title\n\n<!-- git-paw:start"));
439 }
440
441 #[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, §ion).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, §ion).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, §ion);
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 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
510 }
511
512 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 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 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 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 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 #[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 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 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 #[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 #[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 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 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 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}