1use std::fmt::Write;
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::sync::LazyLock;
11
12use crate::error::PawError;
13use crate::git::{assume_unchanged, exclude_from_git};
14
15static SUPERVISOR_PID_REGEX: LazyLock<regex::Regex> =
21 LazyLock::new(|| regex::Regex::new(r"PAW_SUPERVISOR_PID=\d+").expect("static regex compiles"));
22
23static LAST_VERIFIED_COMMIT_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
25 regex::Regex::new(r"PAW_LAST_VERIFIED_COMMIT=[^\n]+").expect("static regex compiles")
26});
27
28const START_MARKER_PREFIX: &str = "<!-- git-paw:start";
30
31const START_MARKER: &str = "<!-- git-paw:start — managed by git-paw, do not edit manually -->";
33
34const END_MARKER: &str = "<!-- git-paw:end -->";
36
37const HOOK_START_MARKER: &str = "# >>> git-paw managed hook >>>";
43const HOOK_END_MARKER: &str = "# <<< git-paw managed hook <<<";
44
45pub fn has_git_paw_section(content: &str) -> bool {
47 content
48 .lines()
49 .any(|line| line.starts_with(START_MARKER_PREFIX))
50}
51
52pub fn replace_git_paw_section(content: &str, new_section: &str) -> String {
56 let lines: Vec<&str> = content.lines().collect();
57
58 let Some(start_idx) = lines
59 .iter()
60 .position(|l| l.starts_with(START_MARKER_PREFIX))
61 else {
62 return content.to_string();
63 };
64
65 let end_idx = lines[start_idx..]
66 .iter()
67 .position(|l| l.contains(END_MARKER))
68 .map(|rel| start_idx + rel);
69
70 let mut result = String::new();
71
72 for line in &lines[..start_idx] {
74 result.push_str(line);
75 result.push('\n');
76 }
77
78 result.push_str(new_section);
80
81 if let Some(end) = end_idx
83 && end + 1 < lines.len()
84 {
85 for line in &lines[end + 1..] {
86 result.push_str(line);
87 result.push('\n');
88 }
89 }
90
91 if end_idx.is_none() && content.ends_with('\n') && !result.ends_with('\n') {
93 result.push('\n');
94 }
95
96 result
97}
98
99pub fn inject_into_content(content: &str, section: &str) -> String {
102 if content.is_empty() {
103 return section.to_string();
104 }
105
106 if has_git_paw_section(content) {
107 return replace_git_paw_section(content, section);
108 }
109
110 let mut result = content.to_string();
112 if !result.ends_with('\n') {
113 result.push('\n');
114 }
115 result.push('\n');
116 result.push_str(section);
117 result
118}
119
120pub struct WorktreeAssignment {
124 pub branch: String,
126 pub cli: String,
128 pub spec_content: Option<String>,
130 pub owned_files: Option<Vec<String>>,
132 pub skill_content: Option<String>,
134 pub inter_agent_rules: Option<String>,
140}
141
142pub fn build_inter_agent_rules(branches: &[&str]) -> String {
148 let mut peers = String::new();
149 for (i, b) in branches.iter().enumerate() {
150 if i > 0 {
151 peers.push_str(", ");
152 }
153 peers.push('`');
154 peers.push_str(b);
155 peers.push('`');
156 }
157
158 let mut out = String::new();
159 out.push_str("These rules apply to every agent in this supervisor session. ");
160 out.push_str("Violating them blocks the supervisor's verification step.\n\n");
161 out.push_str("- **File ownership is exclusive.** You MUST NOT edit files owned by ");
162 out.push_str("other agents. Peers in this session: ");
163 out.push_str(&peers);
164 out.push_str(". Stay inside your declared file ownership list.\n");
165 out.push_str("- **Commit, never push.** You MUST commit to your worktree branch and ");
166 out.push_str("MUST NOT `git push` to any remote. The supervisor merges branches.\n");
167 out.push_str("- **Status publishing is automatic.** git-paw watches your worktree and ");
168 out.push_str("publishes `agent.status` with `modified_files` for you whenever your git ");
169 out.push_str("status changes. A `post-commit` hook publishes `agent.artifact` on each ");
170 out.push_str("commit. You do not need to curl these yourself.\n");
171 out.push_str("- **Watch peer status.** Poll `/messages/{{BRANCH_ID}}` to see peer ");
172 out.push_str("`agent.artifact` messages so you detect conflicts before the supervisor does.\n");
173 out.push_str("- **Cherry-pick peer artifacts.** When you are blocked on a peer, publish ");
174 out.push_str("`agent.blocked` and cherry-pick their commit when their artifact arrives ");
175 out.push_str("in your inbox. Do not wait for the supervisor to merge.\n");
176 out.push_str("- **Match spec field names exactly.** When implementing a spec, use the ");
177 out.push_str("exact field, function, and message names from the spec — do not rename ");
178 out.push_str("them. The supervisor's spec audit will reject mismatched names.\n");
179 out
180}
181
182pub fn generate_worktree_section(assignment: &WorktreeAssignment) -> String {
184 let mut section = String::new();
185 section.push_str(START_MARKER);
186 section.push('\n');
187 section.push('\n');
188 section.push_str("## git-paw Session Assignment\n");
189 section.push('\n');
190 let _ = writeln!(section, "- **Branch:** `{}`", assignment.branch);
191 let _ = writeln!(section, "- **CLI:** {}", assignment.cli);
192
193 if let Some(ref spec) = assignment.spec_content {
194 section.push('\n');
195 section.push_str("### Spec\n");
196 section.push('\n');
197 section.push_str(spec);
198 if !spec.ends_with('\n') {
199 section.push('\n');
200 }
201 }
202
203 if let Some(ref files) = assignment.owned_files {
204 section.push('\n');
205 section.push_str("### File Ownership\n");
206 section.push('\n');
207 for file in files {
208 let _ = writeln!(section, "- `{file}`");
209 }
210 }
211
212 if let Some(ref skill) = assignment.skill_content {
213 section.push('\n');
214 section.push_str(skill);
215 if !skill.ends_with('\n') {
216 section.push('\n');
217 }
218 }
219
220 if let Some(ref rules) = assignment.inter_agent_rules {
221 section.push('\n');
222 section.push_str("## Inter-Agent Rules\n");
223 section.push('\n');
224 section.push_str(rules);
225 if !rules.ends_with('\n') {
226 section.push('\n');
227 }
228 }
229
230 section.push('\n');
231 section.push_str(END_MARKER);
232 section.push('\n');
233 section
234}
235
236pub fn setup_worktree_agents_md(
247 repo_root: &Path,
248 worktree_root: &Path,
249 assignment: &WorktreeAssignment,
250) -> Result<(), PawError> {
251 let root_agents = repo_root.join("AGENTS.md");
252 let root_content = match fs::read_to_string(&root_agents) {
253 Ok(c) => c,
254 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
255 Err(e) => {
256 return Err(PawError::AgentsMdError(format!(
257 "failed to read '{}': {e}",
258 root_agents.display()
259 )));
260 }
261 };
262
263 let section = generate_worktree_section(assignment);
264 let output = inject_into_content(&root_content, §ion);
265
266 let worktree_agents = worktree_root.join("AGENTS.md");
267 fs::write(&worktree_agents, &output).map_err(|e| {
268 PawError::AgentsMdError(format!(
269 "failed to write '{}': {e}",
270 worktree_agents.display()
271 ))
272 })?;
273
274 exclude_from_git(worktree_root, "AGENTS.md")?;
275
276 let _ = assume_unchanged(worktree_root, "AGENTS.md");
282
283 Ok(())
284}
285
286pub fn get_agent_marker_path(worktree: &Path) -> Result<PathBuf, PawError> {
288 let linked_git_dir = git_rev_parse_path(worktree, "--git-dir")?;
289 Ok(linked_git_dir.join("paw-agent-id"))
290}
291
292pub fn build_agent_marker(
308 broker_url: &str,
309 agent_id: &str,
310 supervisor_pid: Option<u32>,
311 last_verified_commit: Option<&str>,
312 session_name: Option<&str>,
313) -> String {
314 let mut marker = format!("PAW_AGENT_ID={agent_id}\nPAW_BROKER_URL={broker_url}\n");
315
316 if let Some(pid) = supervisor_pid {
318 let _ = writeln!(marker, "PAW_SUPERVISOR_PID={pid}");
319 }
320 if let Some(commit) = last_verified_commit {
321 let _ = writeln!(marker, "PAW_LAST_VERIFIED_COMMIT={commit}");
322 }
323 if let Some(session) = session_name {
324 let _ = writeln!(marker, "PAW_SESSION_NAME={session}");
325 }
326
327 let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
329 let _ = writeln!(marker, "PAW_TIMESTAMP={timestamp}");
330
331 marker
332}
333
334pub fn update_agent_marker(
338 marker_path: &Path,
339 supervisor_pid: Option<u32>,
340 last_verified_commit: Option<&str>,
341) -> Result<(), PawError> {
342 let content = fs::read_to_string(marker_path)
343 .map_err(|e| PawError::AgentsMdError(format!("failed to read marker file: {e}")))?;
344
345 let mut updated = content;
346
347 if let Some(pid) = supervisor_pid {
349 if updated.contains("PAW_SUPERVISOR_PID=") {
350 updated = SUPERVISOR_PID_REGEX
352 .replace(&updated, &format!("PAW_SUPERVISOR_PID={pid}"))
353 .to_string();
354 } else {
355 let _ = write!(updated, "\nPAW_SUPERVISOR_PID={pid}");
357 }
358 }
359
360 if let Some(commit) = last_verified_commit {
362 if updated.contains("PAW_LAST_VERIFIED_COMMIT=") {
363 updated = LAST_VERIFIED_COMMIT_REGEX
365 .replace(&updated, &format!("PAW_LAST_VERIFIED_COMMIT={commit}"))
366 .to_string();
367 } else {
368 let _ = write!(updated, "\nPAW_LAST_VERIFIED_COMMIT={commit}");
370 }
371 }
372
373 fs::write(marker_path, updated)
374 .map_err(|e| PawError::AgentsMdError(format!("failed to update marker file: {e}")))?;
375
376 Ok(())
377}
378
379fn build_post_commit_dispatcher_hook() -> String {
390 format!(
391 "#!/bin/sh\n\
392 {HOOK_START_MARKER}\n\
393 # Dispatcher: reads per-worktree $GIT_DIR/paw-agent-id and publishes\n\
394 # agent.artifact to the git-paw broker.\n\
395 if [ -n \"$GIT_DIR\" ] && [ -f \"$GIT_DIR/paw-agent-id\" ]; then\n\
396 . \"$GIT_DIR/paw-agent-id\"\n\
397 FILES=$(git diff HEAD~1 --name-only 2>/dev/null | awk '{{printf \"%s\\\"%s\\\"\", (NR>1?\",\":\"\"), $0}}')\n\
398 curl -s -X POST \"$PAW_BROKER_URL/publish\" \\\n\
399 -H 'Content-Type: application/json' \\\n\
400 -d \"{{\\\"type\\\":\\\"agent.artifact\\\",\\\"agent_id\\\":\\\"$PAW_AGENT_ID\\\",\\\"payload\\\":{{\\\"status\\\":\\\"committed\\\",\\\"exports\\\":[],\\\"modified_files\\\":[$FILES]}}}}\" \\\n\
401 >/dev/null 2>&1 || true\n\
402 fi\n\
403 {HOOK_END_MARKER}\n"
404 )
405}
406
407fn build_pre_push_hook() -> String {
408 format!(
415 "#!/bin/sh\n\
416 {HOOK_START_MARKER}\n\
417 if [ -n \"$GIT_DIR\" ] && [ -f \"$GIT_DIR/paw-agent-id\" ]; then\n\
418 echo 'error: git-paw agents must not push. The supervisor handles merges.' >&2\n\
419 exit 1\n\
420 fi\n\
421 {HOOK_END_MARKER}\n"
422 )
423}
424
425fn chain_hook(existing: &str, new_body: &str) -> String {
433 if let Some(start) = existing.find(HOOK_START_MARKER)
438 && let Some(end_rel) = existing[start..].find(HOOK_END_MARKER)
439 {
440 let end = start + end_rel + HOOK_END_MARKER.len();
441 let mut out = String::with_capacity(existing.len() + new_body.len());
442 out.push_str(&existing[..start]);
443 let stripped = new_body.strip_prefix("#!/bin/sh\n").unwrap_or(new_body);
446 out.push_str(stripped);
447 out.push_str(&existing[end..]);
448 return out;
449 }
450 let mut out = existing.trim_end().to_string();
451 if !out.is_empty() {
452 out.push('\n');
453 }
454 let stripped = if out.is_empty() {
455 new_body.to_string()
456 } else {
457 new_body
458 .strip_prefix("#!/bin/sh\n")
459 .unwrap_or(new_body)
460 .to_string()
461 };
462 out.push_str(&stripped);
463 out
464}
465
466fn write_hook_file(hook_path: &Path, new_body: &str) -> Result<(), PawError> {
467 let existing = match fs::read_to_string(hook_path) {
468 Ok(c) => c,
469 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
470 Err(e) => {
471 return Err(PawError::AgentsMdError(format!(
472 "failed to read '{}': {e}",
473 hook_path.display()
474 )));
475 }
476 };
477
478 let content = if existing.is_empty() {
479 new_body.to_string()
480 } else {
481 chain_hook(&existing, new_body)
482 };
483
484 if let Some(parent) = hook_path.parent() {
485 fs::create_dir_all(parent).map_err(|e| {
486 PawError::AgentsMdError(format!("failed to create '{}': {e}", parent.display()))
487 })?;
488 }
489
490 fs::write(hook_path, content.as_bytes()).map_err(|e| {
491 PawError::AgentsMdError(format!("failed to write '{}': {e}", hook_path.display()))
492 })?;
493
494 #[cfg(unix)]
495 {
496 use std::os::unix::fs::PermissionsExt;
497 let mut perms = fs::metadata(hook_path)
498 .map_err(|e| {
499 PawError::AgentsMdError(format!("failed to stat '{}': {e}", hook_path.display()))
500 })?
501 .permissions();
502 perms.set_mode(0o755);
503 fs::set_permissions(hook_path, perms).map_err(|e| {
504 PawError::AgentsMdError(format!("failed to chmod '{}': {e}", hook_path.display()))
505 })?;
506 }
507
508 Ok(())
509}
510
511fn git_rev_parse_path(worktree: &Path, flag: &str) -> Result<PathBuf, PawError> {
517 let output = std::process::Command::new("git")
518 .current_dir(worktree)
519 .args(["rev-parse", flag])
520 .output()
521 .map_err(|e| PawError::AgentsMdError(format!("failed to run git rev-parse {flag}: {e}")))?;
522 if !output.status.success() {
523 let stderr = String::from_utf8_lossy(&output.stderr);
524 return Err(PawError::AgentsMdError(format!(
525 "git rev-parse {flag} failed in '{}': {stderr}",
526 worktree.display()
527 )));
528 }
529 let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
530 let path = PathBuf::from(&raw);
531 if path.is_absolute() {
532 Ok(path)
533 } else {
534 Ok(worktree.join(path))
535 }
536}
537
538pub fn install_git_hooks(
555 worktree: &Path,
556 broker_url: &str,
557 agent_id: &str,
558) -> Result<(), PawError> {
559 let common_git_dir = git_rev_parse_path(worktree, "--git-common-dir")?;
560 let linked_git_dir = git_rev_parse_path(worktree, "--git-dir")?;
561 let hooks_dir = common_git_dir.join("hooks");
562
563 write_hook_file(
564 &hooks_dir.join("post-commit"),
565 &build_post_commit_dispatcher_hook(),
566 )?;
567 write_hook_file(&hooks_dir.join("pre-push"), &build_pre_push_hook())?;
568
569 let marker_path = linked_git_dir.join("paw-agent-id");
570 if let Some(parent) = marker_path.parent() {
571 fs::create_dir_all(parent).map_err(|e| {
572 PawError::AgentsMdError(format!("failed to create '{}': {e}", parent.display()))
573 })?;
574 }
575 fs::write(
576 &marker_path,
577 build_agent_marker(broker_url, agent_id, None, None, None),
578 )
579 .map_err(|e| {
580 PawError::AgentsMdError(format!("failed to write '{}': {e}", marker_path.display()))
581 })?;
582
583 Ok(())
584}
585
586pub fn inject_section_into_file(path: &Path, section: &str) -> Result<(), PawError> {
587 let content = match fs::read_to_string(path) {
588 Ok(c) => c,
589 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
590 Err(e) => {
591 return Err(PawError::AgentsMdError(format!(
592 "failed to read '{}': {e}",
593 path.display()
594 )));
595 }
596 };
597
598 let output = inject_into_content(&content, section);
599
600 fs::write(path, &output)
601 .map_err(|e| PawError::AgentsMdError(format!("failed to write '{}': {e}", path.display())))
602}
603
604pub fn remove_git_paw_section(content: &str) -> String {
615 let lines: Vec<&str> = content.lines().collect();
616
617 let Some(start_idx) = lines
618 .iter()
619 .position(|l| l.starts_with(START_MARKER_PREFIX))
620 else {
621 return content.to_string();
623 };
624
625 let end_idx = lines[start_idx..]
626 .iter()
627 .position(|l| l.contains(END_MARKER))
628 .map(|rel| start_idx + rel);
629
630 let delete_start = start_idx;
632 let delete_end_exclusive = end_idx.map_or(lines.len(), |e| e + 1);
633
634 let mut delete_end = delete_end_exclusive;
640 let mut adjusted_start = delete_start;
641 if delete_end < lines.len() && lines[delete_end].is_empty() {
642 delete_end += 1;
643 } else if adjusted_start > 0 && lines[adjusted_start - 1].is_empty() {
644 adjusted_start -= 1;
645 }
646 let delete_start = adjusted_start;
647
648 let mut result = String::new();
649 for line in &lines[..delete_start] {
650 result.push_str(line);
651 result.push('\n');
652 }
653 for line in &lines[delete_end..] {
654 result.push_str(line);
655 result.push('\n');
656 }
657
658 if content.ends_with('\n') && !result.ends_with('\n') && !result.is_empty() {
661 result.push('\n');
662 }
663
664 if !content.ends_with('\n') && result.ends_with('\n') {
667 result.pop();
668 }
669
670 result
671}
672
673pub fn remove_session_boot_block(repo_root: &Path) -> Result<(), PawError> {
682 let agents_md = repo_root.join("AGENTS.md");
683 let content = match fs::read_to_string(&agents_md) {
684 Ok(c) => c,
685 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
686 Err(e) => {
687 return Err(PawError::AgentsMdError(format!(
688 "failed to read '{}': {e}",
689 agents_md.display()
690 )));
691 }
692 };
693
694 let new_content = remove_git_paw_section(&content);
695 if new_content == content {
696 return Ok(());
698 }
699
700 fs::write(&agents_md, &new_content).map_err(|e| {
701 PawError::AgentsMdError(format!("failed to write '{}': {e}", agents_md.display()))
702 })
703}
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708
709 fn sample_section() -> String {
711 format!("{START_MARKER}\n## git-paw test section\n{END_MARKER}\n")
712 }
713
714 #[test]
719 fn has_section_returns_true_when_marker_present() {
720 let content = "# My Project\n\n<!-- git-paw:start — managed by git-paw, do not edit manually -->\nstuff\n<!-- git-paw:end -->\n";
721 assert!(has_git_paw_section(content));
722 }
723
724 #[test]
725 fn has_section_returns_false_without_marker() {
726 let content = "# My Project\n\nSome instructions.\n";
727 assert!(!has_git_paw_section(content));
728 }
729
730 #[test]
731 fn has_section_returns_false_for_empty() {
732 assert!(!has_git_paw_section(""));
733 }
734
735 #[test]
740 fn generated_section_has_markers() {
741 let section = sample_section();
742 assert!(section.starts_with(START_MARKER));
743 assert!(section.contains(END_MARKER));
744 }
745
746 #[test]
747 fn sample_section_contains_git_paw_reference() {
748 let section = sample_section();
749 assert!(section.contains("git-paw"));
750 }
751
752 #[test]
757 fn replace_with_both_markers_preserves_surrounding() {
758 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";
759 let new_section = "<!-- git-paw:start — managed by git-paw, do not edit manually -->\nnew content\n<!-- git-paw:end -->\n";
760 let result = replace_git_paw_section(content, new_section);
761 assert!(result.contains("# Title"));
762 assert!(result.contains("new content"));
763 assert!(!result.contains("old content"));
764 assert!(result.contains("## Footer"));
765 }
766
767 #[test]
768 fn replace_with_missing_end_marker_replaces_to_eof() {
769 let content = "# Title\n\n<!-- git-paw:start — managed by git-paw, do not edit manually -->\nold content that never ends\n";
770 let new_section = "<!-- git-paw:start — managed by git-paw, do not edit manually -->\nfixed\n<!-- git-paw:end -->\n";
771 let result = replace_git_paw_section(content, new_section);
772 assert!(result.contains("# Title"));
773 assert!(result.contains("fixed"));
774 assert!(!result.contains("old content"));
775 }
776
777 #[test]
782 fn inject_appends_when_no_existing_section() {
783 let content = "# My Project\n\nSome info.\n";
784 let section = sample_section();
785 let result = inject_into_content(content, §ion);
786 assert!(result.starts_with("# My Project"));
787 assert!(result.contains(START_MARKER));
788 }
789
790 #[test]
791 fn inject_replaces_existing_section() {
792 let old_section = format!("{START_MARKER}\nold\n{END_MARKER}\n");
793 let content = format!("# Title\n\n{old_section}\n## Footer\n");
794 let new_section = format!("{START_MARKER}\nnew\n{END_MARKER}\n");
795 let result = inject_into_content(&content, &new_section);
796 assert!(result.contains("new"));
797 assert!(!result.contains("old"));
798 assert!(result.contains("## Footer"));
799 }
800
801 #[test]
802 fn inject_into_empty_content_returns_section_only() {
803 let section = sample_section();
804 let result = inject_into_content("", §ion);
805 assert_eq!(result, section);
806 }
807
808 #[test]
813 fn spacing_with_trailing_newline() {
814 let content = "# Title\n";
815 let section = "<!-- git-paw:start -->\n<!-- git-paw:end -->\n";
816 let result = inject_into_content(content, section);
817 assert!(result.contains("# Title\n\n<!-- git-paw:start"));
819 }
820
821 #[test]
822 fn spacing_without_trailing_newline() {
823 let content = "# Title";
824 let section = "<!-- git-paw:start -->\n<!-- git-paw:end -->\n";
825 let result = inject_into_content(content, section);
826 assert!(result.contains("# Title\n\n<!-- git-paw:start"));
828 }
829
830 #[test]
835 fn file_inject_appends_to_existing() {
836 let dir = tempfile::tempdir().unwrap();
837 let path = dir.path().join("AGENTS.md");
838 fs::write(&path, "# Existing\n").unwrap();
839
840 let section = sample_section();
841 inject_section_into_file(&path, §ion).unwrap();
842
843 let result = fs::read_to_string(&path).unwrap();
844 assert!(result.contains("# Existing"));
845 assert!(result.contains(START_MARKER));
846 }
847
848 #[test]
849 fn file_inject_replaces_existing_section() {
850 let dir = tempfile::tempdir().unwrap();
851 let path = dir.path().join("AGENTS.md");
852 let initial = format!("# Title\n\n{START_MARKER}\nold\n{END_MARKER}\n");
853 fs::write(&path, &initial).unwrap();
854
855 let new_section = sample_section();
856 inject_section_into_file(&path, &new_section).unwrap();
857
858 let result = fs::read_to_string(&path).unwrap();
859 assert!(result.contains("# Title"));
860 assert!(!result.contains("\nold\n"));
861 assert!(result.contains("git-paw test section"));
862 }
863
864 #[test]
865 fn file_inject_creates_missing_file() {
866 let dir = tempfile::tempdir().unwrap();
867 let path = dir.path().join("AGENTS.md");
868 assert!(!path.exists());
869
870 let section = sample_section();
871 inject_section_into_file(&path, §ion).unwrap();
872
873 let result = fs::read_to_string(&path).unwrap();
874 assert!(result.contains(START_MARKER));
875 }
876
877 #[test]
878 fn file_inject_readonly_returns_error() {
879 use std::os::unix::fs::PermissionsExt;
880
881 let dir = tempfile::tempdir().unwrap();
882 let path = dir.path().join("AGENTS.md");
883 fs::write(&path, "content").unwrap();
884 fs::set_permissions(&path, fs::Permissions::from_mode(0o444)).unwrap();
885
886 let section = sample_section();
887 let result = inject_section_into_file(&path, §ion);
888 assert!(result.is_err());
889 let err = result.unwrap_err();
890 let msg = err.to_string();
891 assert!(msg.contains("AGENTS.md error"), "got: {msg}");
892 assert!(
893 msg.contains("AGENTS.md"),
894 "should mention file path, got: {msg}"
895 );
896
897 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
899 }
900
901 fn make_assignment(spec: Option<&str>, files: Option<Vec<&str>>) -> WorktreeAssignment {
906 WorktreeAssignment {
907 branch: "feat/foo".to_string(),
908 cli: "claude".to_string(),
909 spec_content: spec.map(ToString::to_string),
910 owned_files: files.map(|v| v.into_iter().map(ToString::to_string).collect()),
911 skill_content: None,
912 inter_agent_rules: None,
913 }
914 }
915
916 fn make_assignment_with_skill(
917 spec: Option<&str>,
918 files: Option<Vec<&str>>,
919 skill: Option<&str>,
920 ) -> WorktreeAssignment {
921 WorktreeAssignment {
922 branch: "feat/foo".to_string(),
923 cli: "claude".to_string(),
924 spec_content: spec.map(ToString::to_string),
925 owned_files: files.map(|v| v.into_iter().map(ToString::to_string).collect()),
926 skill_content: skill.map(ToString::to_string),
927 inter_agent_rules: None,
928 }
929 }
930
931 #[test]
932 fn worktree_section_all_fields() {
933 let assignment = make_assignment(
934 Some("Implement the widget.\n"),
935 Some(vec!["src/widget.rs", "tests/widget.rs"]),
936 );
937 let section = generate_worktree_section(&assignment);
938 assert!(section.starts_with(START_MARKER));
939 assert!(section.contains(END_MARKER));
940 assert!(section.contains("`feat/foo`"));
941 assert!(section.contains("claude"));
942 assert!(section.contains("### Spec"));
943 assert!(section.contains("Implement the widget."));
944 assert!(section.contains("### File Ownership"));
945 assert!(section.contains("`src/widget.rs`"));
946 assert!(section.contains("`tests/widget.rs`"));
947 }
948
949 #[test]
950 fn worktree_section_no_spec() {
951 let assignment = make_assignment(None, Some(vec!["src/main.rs"]));
952 let section = generate_worktree_section(&assignment);
953 assert!(section.contains("`feat/foo`"));
954 assert!(!section.contains("### Spec"));
955 assert!(section.contains("### File Ownership"));
956 }
957
958 #[test]
959 fn worktree_section_no_files() {
960 let assignment = make_assignment(Some("Do the thing.\n"), None);
961 let section = generate_worktree_section(&assignment);
962 assert!(section.contains("### Spec"));
963 assert!(!section.contains("### File Ownership"));
964 }
965
966 #[test]
967 fn worktree_section_minimal() {
968 let assignment = make_assignment(None, None);
969 let section = generate_worktree_section(&assignment);
970 assert!(section.starts_with(START_MARKER));
971 assert!(section.contains(END_MARKER));
972 assert!(section.contains("`feat/foo`"));
973 assert!(section.contains("claude"));
974 assert!(!section.contains("### Spec"));
975 assert!(!section.contains("### File Ownership"));
976 }
977
978 fn init_git_repo(dir: &Path) {
987 use std::process::Command;
988 let git = which::which("git").expect("git must be on PATH");
989 Command::new(&git)
990 .current_dir(dir)
991 .args(["init"])
992 .output()
993 .expect("git init");
994 Command::new(&git)
995 .current_dir(dir)
996 .args(["config", "user.email", "test@test.com"])
997 .output()
998 .expect("git config email");
999 Command::new(&git)
1000 .current_dir(dir)
1001 .args(["config", "user.name", "Test"])
1002 .output()
1003 .expect("git config name");
1004 fs::write(dir.join("README.md"), "# test\n").unwrap();
1006 Command::new(&git)
1007 .current_dir(dir)
1008 .args(["add", "README.md"])
1009 .output()
1010 .expect("git add");
1011 Command::new(&git)
1012 .current_dir(dir)
1013 .args(["commit", "-m", "init"])
1014 .output()
1015 .expect("git commit");
1016 }
1017
1018 #[test]
1019 fn setup_worktree_root_exists() {
1020 let repo = tempfile::tempdir().unwrap();
1021 let wt = tempfile::tempdir().unwrap();
1022 init_git_repo(wt.path());
1023 fs::write(repo.path().join("AGENTS.md"), "# Project Rules\n").unwrap();
1024
1025 fs::write(wt.path().join("AGENTS.md"), "# placeholder\n").unwrap();
1027 std::process::Command::new("git")
1028 .current_dir(wt.path())
1029 .args(["add", "AGENTS.md"])
1030 .output()
1031 .expect("git add AGENTS.md");
1032 std::process::Command::new("git")
1033 .current_dir(wt.path())
1034 .args(["commit", "-m", "add agents"])
1035 .output()
1036 .expect("git commit");
1037
1038 let assignment = make_assignment(None, None);
1039 setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
1040
1041 let result = fs::read_to_string(wt.path().join("AGENTS.md")).unwrap();
1042 assert!(result.contains("# Project Rules"));
1043 assert!(result.contains("`feat/foo`"));
1044 assert!(result.contains(START_MARKER));
1045
1046 let status = std::process::Command::new("git")
1048 .current_dir(wt.path())
1049 .args(["status", "--porcelain"])
1050 .output()
1051 .expect("git status");
1052 let status_output = String::from_utf8_lossy(&status.stdout);
1053 assert!(
1054 !status_output.contains("AGENTS.md"),
1055 "AGENTS.md should not appear in git status, got: {status_output}"
1056 );
1057 }
1058
1059 #[test]
1060 fn setup_worktree_root_missing() {
1061 let repo = tempfile::tempdir().unwrap();
1062 let wt = tempfile::tempdir().unwrap();
1063 init_git_repo(wt.path());
1064
1065 let assignment = make_assignment(None, None);
1066 setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
1067
1068 let result = fs::read_to_string(wt.path().join("AGENTS.md")).unwrap();
1069 assert!(!result.contains("# Project Rules"));
1070 assert!(result.contains("`feat/foo`"));
1071 }
1072
1073 #[test]
1074 fn setup_worktree_replaces_root_section() {
1075 let repo = tempfile::tempdir().unwrap();
1076 let wt = tempfile::tempdir().unwrap();
1077 init_git_repo(wt.path());
1078 let root_content =
1079 format!("# Rules\n\n{START_MARKER}\nold root section\n{END_MARKER}\n\n## Footer\n");
1080 fs::write(repo.path().join("AGENTS.md"), &root_content).unwrap();
1081
1082 let assignment = make_assignment(None, None);
1083 setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
1084
1085 let result = fs::read_to_string(wt.path().join("AGENTS.md")).unwrap();
1086 assert!(result.contains("# Rules"));
1087 assert!(result.contains("## Footer"));
1088 assert!(!result.contains("old root section"));
1089 assert!(result.contains("`feat/foo`"));
1090 assert_eq!(
1091 result.matches(START_MARKER_PREFIX).count(),
1092 1,
1093 "should have exactly one git-paw section"
1094 );
1095 }
1096
1097 #[test]
1102 fn setup_worktree_write_failure_returns_agents_md_error() {
1103 use std::os::unix::fs::PermissionsExt;
1104
1105 let repo = tempfile::tempdir().unwrap();
1106 let wt = tempfile::tempdir().unwrap();
1107 init_git_repo(wt.path());
1108
1109 fs::set_permissions(wt.path(), fs::Permissions::from_mode(0o555)).unwrap();
1111
1112 let assignment = make_assignment(None, None);
1113 let result = setup_worktree_agents_md(repo.path(), wt.path(), &assignment);
1114
1115 fs::set_permissions(wt.path(), fs::Permissions::from_mode(0o755)).unwrap();
1117
1118 assert!(result.is_err(), "should fail when worktree is read-only");
1119 let err = result.unwrap_err();
1120 let msg = err.to_string();
1121 assert!(
1122 msg.contains("AGENTS.md error"),
1123 "should return AgentsMdError, got: {msg}"
1124 );
1125 }
1126
1127 #[test]
1132 fn exclude_creates_file_when_missing() {
1133 let wt = tempfile::tempdir().unwrap();
1134 fs::create_dir_all(wt.path().join(".git/info")).unwrap();
1135
1136 exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1137
1138 let content = fs::read_to_string(wt.path().join(".git/info/exclude")).unwrap();
1139 assert!(content.contains("AGENTS.md"));
1140 }
1141
1142 #[test]
1143 fn exclude_appends_when_not_present() {
1144 let wt = tempfile::tempdir().unwrap();
1145 let info = wt.path().join(".git/info");
1146 fs::create_dir_all(&info).unwrap();
1147 fs::write(info.join("exclude"), "*.log\n").unwrap();
1148
1149 exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1150
1151 let content = fs::read_to_string(info.join("exclude")).unwrap();
1152 assert!(content.contains("*.log"));
1153 assert!(content.contains("AGENTS.md"));
1154 }
1155
1156 #[test]
1157 fn exclude_no_duplicate() {
1158 let wt = tempfile::tempdir().unwrap();
1159 let info = wt.path().join(".git/info");
1160 fs::create_dir_all(&info).unwrap();
1161 fs::write(info.join("exclude"), "AGENTS.md\n").unwrap();
1162
1163 exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1164
1165 let content = fs::read_to_string(info.join("exclude")).unwrap();
1166 assert_eq!(content.matches("AGENTS.md").count(), 1);
1167 }
1168
1169 #[test]
1170 fn exclude_creates_info_dir() {
1171 let wt = tempfile::tempdir().unwrap();
1172 fs::create_dir_all(wt.path().join(".git")).unwrap();
1173 assert!(!wt.path().join(".git/info").exists());
1174
1175 exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1176
1177 assert!(wt.path().join(".git/info/exclude").exists());
1178 let content = fs::read_to_string(wt.path().join(".git/info/exclude")).unwrap();
1179 assert!(content.contains("AGENTS.md"));
1180 }
1181
1182 #[test]
1187 fn worktree_section_all_fields_with_skill() {
1188 let assignment = make_assignment_with_skill(
1189 Some("Implement the widget.\n"),
1190 Some(vec!["src/widget.rs", "tests/widget.rs"]),
1191 Some("## Coordination\nUse the broker at http://127.0.0.1:9119 as feat-foo.\n"),
1192 );
1193 let section = generate_worktree_section(&assignment);
1194 assert!(section.starts_with(START_MARKER));
1195 assert!(section.contains(END_MARKER));
1196 assert!(section.contains("`feat/foo`"));
1197 assert!(section.contains("claude"));
1198 assert!(section.contains("### Spec"));
1199 assert!(section.contains("Implement the widget."));
1200 assert!(section.contains("### File Ownership"));
1201 assert!(section.contains("`src/widget.rs`"));
1202 assert!(section.contains("## Coordination"));
1203 let ownership_pos = section.find("### File Ownership").unwrap();
1205 let skill_pos = section.find("## Coordination").unwrap();
1206 let end_pos = section.find(END_MARKER).unwrap();
1207 assert!(
1208 ownership_pos < skill_pos,
1209 "skill must come after file ownership"
1210 );
1211 assert!(skill_pos < end_pos, "skill must come before end marker");
1212 }
1213
1214 #[test]
1215 fn worktree_section_skill_without_spec_or_files() {
1216 let assignment = make_assignment_with_skill(
1217 None,
1218 None,
1219 Some("## Coordination\nBroker instructions here.\n"),
1220 );
1221 let section = generate_worktree_section(&assignment);
1222 assert!(section.contains("`feat/foo`"));
1223 assert!(section.contains("claude"));
1224 assert!(!section.contains("### Spec"));
1225 assert!(!section.contains("### File Ownership"));
1226 assert!(section.contains("## Coordination"));
1227 let assignment_pos = section.find("**CLI:**").unwrap();
1229 let skill_pos = section.find("## Coordination").unwrap();
1230 let end_pos = section.find(END_MARKER).unwrap();
1231 assert!(
1232 assignment_pos < skill_pos,
1233 "skill must come after assignment"
1234 );
1235 assert!(skill_pos < end_pos, "skill must come before end marker");
1236 }
1237
1238 #[test]
1239 fn worktree_section_none_skill_matches_v020() {
1240 let with_none =
1242 make_assignment_with_skill(Some("Do the thing.\n"), Some(vec!["src/main.rs"]), None);
1243 let without = make_assignment(Some("Do the thing.\n"), Some(vec!["src/main.rs"]));
1244 assert_eq!(
1245 generate_worktree_section(&with_none),
1246 generate_worktree_section(&without),
1247 "skill_content = None must produce identical output to v0.2.0"
1248 );
1249 }
1250
1251 #[test]
1252 fn worktree_section_skill_contains_slugified_branch() {
1253 let assignment = WorktreeAssignment {
1254 branch: "feat/http-broker".to_string(),
1255 cli: "claude".to_string(),
1256 spec_content: None,
1257 owned_files: None,
1258 skill_content: Some(
1259 "Agent ID: feat-http-broker\nURL: http://127.0.0.1:9119\n".to_string(),
1260 ),
1261 inter_agent_rules: None,
1262 };
1263 let section = generate_worktree_section(&assignment);
1264 assert!(
1265 section.contains("feat-http-broker"),
1266 "should contain slugified branch"
1267 );
1268 assert!(
1269 !section.contains("{{BRANCH_ID}}"),
1270 "should not contain literal template placeholder"
1271 );
1272 }
1273
1274 #[test]
1275 fn worktree_section_skill_preserves_broker_url_placeholder() {
1276 let assignment = make_assignment_with_skill(
1277 None,
1278 None,
1279 Some("Connect to http://127.0.0.1:9119/messages\n"),
1280 );
1281 let section = generate_worktree_section(&assignment);
1282 assert!(
1283 section.contains("http://127.0.0.1:9119"),
1284 "broker URL must be present"
1285 );
1286 }
1287
1288 #[test]
1293 fn worktree_section_with_inter_agent_rules() {
1294 let mut assignment = make_assignment(Some("Do the widget.\n"), Some(vec!["src/widget.rs"]));
1295 assignment.inter_agent_rules = Some("Stay in your lane.\nNever push.\n".to_string());
1296 let section = generate_worktree_section(&assignment);
1297 assert!(section.contains("## Inter-Agent Rules"));
1298 assert!(section.contains("Stay in your lane."));
1299 let rules_pos = section.find("## Inter-Agent Rules").unwrap();
1301 let end_pos = section.find(END_MARKER).unwrap();
1302 assert!(rules_pos < end_pos, "rules must come before end marker");
1303 }
1304
1305 #[test]
1306 fn worktree_section_without_inter_agent_rules_has_no_section() {
1307 let assignment = make_assignment(Some("Do the widget.\n"), Some(vec!["src/widget.rs"]));
1308 let section = generate_worktree_section(&assignment);
1309 assert!(!section.contains("## Inter-Agent Rules"));
1310 }
1311
1312 #[test]
1313 fn worktree_section_inter_agent_rules_none_matches_pre_change() {
1314 let baseline = make_assignment(Some("Do.\n"), Some(vec!["src/main.rs"]));
1316 let with_none = WorktreeAssignment {
1317 branch: baseline.branch.clone(),
1318 cli: baseline.cli.clone(),
1319 spec_content: baseline.spec_content.clone(),
1320 owned_files: baseline.owned_files.clone(),
1321 skill_content: None,
1322 inter_agent_rules: None,
1323 };
1324 assert_eq!(
1325 generate_worktree_section(&baseline),
1326 generate_worktree_section(&with_none),
1327 );
1328 }
1329
1330 #[test]
1335 fn build_inter_agent_rules_contains_file_ownership() {
1336 let rules = build_inter_agent_rules(&["feat/a", "feat/b"]);
1337 assert!(rules.contains("File ownership"));
1338 assert!(rules.contains("`feat/a`"));
1339 assert!(rules.contains("`feat/b`"));
1340 }
1341
1342 #[test]
1343 fn build_inter_agent_rules_contains_never_push() {
1344 let rules = build_inter_agent_rules(&["feat/a"]);
1345 assert!(rules.contains("MUST NOT `git push`"));
1346 }
1347
1348 #[test]
1349 fn build_inter_agent_rules_notes_automatic_status() {
1350 let rules = build_inter_agent_rules(&["feat/a"]);
1351 assert!(rules.contains("Status publishing is automatic"));
1352 assert!(rules.contains("post-commit"));
1353 }
1354
1355 #[test]
1356 fn build_inter_agent_rules_contains_match_spec() {
1357 let rules = build_inter_agent_rules(&["feat/a"]);
1358 assert!(
1359 rules
1360 .to_lowercase()
1361 .contains("match spec field names exactly")
1362 );
1363 }
1364
1365 #[test]
1366 fn build_inter_agent_rules_contains_cherry_pick_reference() {
1367 let rules = build_inter_agent_rules(&["feat/a"]);
1368 assert!(rules.to_lowercase().contains("cherry-pick"));
1369 }
1370
1371 #[test]
1376 fn embedded_coordination_contains_cherry_pick() {
1377 let content = include_str!("../assets/agent-skills/coordination.md");
1378 assert!(content.contains("git cherry-pick"));
1379 }
1380
1381 #[test]
1382 fn embedded_coordination_documents_automatic_status() {
1383 let content = include_str!("../assets/agent-skills/coordination.md");
1384 let lower = content.to_lowercase();
1385 assert!(lower.contains("automatic"));
1386 assert!(lower.contains("post-commit"));
1387 }
1388
1389 #[test]
1390 fn embedded_coordination_does_not_require_manual_status_publish() {
1391 let content = include_str!("../assets/agent-skills/coordination.md");
1392 assert!(!content.contains("MUST publish `agent.status`"));
1393 assert!(!content.contains("You MUST publish `agent.status`"));
1394 }
1395
1396 #[test]
1397 fn embedded_coordination_still_contains_optin_operations() {
1398 let content = include_str!("../assets/agent-skills/coordination.md");
1399 assert!(content.contains("agent.blocked"));
1400 assert!(content.contains("agent.artifact"));
1401 assert!(content.contains("{{GIT_PAW_BROKER_URL}}/messages/{{BRANCH_ID}}"));
1402 }
1403
1404 #[test]
1405 fn embedded_coordination_requires_no_push() {
1406 let content = include_str!("../assets/agent-skills/coordination.md");
1407 assert!(content.contains("MUST NOT push"));
1408 }
1409
1410 #[test]
1415 fn post_commit_dispatcher_hook_reads_marker_and_publishes() {
1416 let script = build_post_commit_dispatcher_hook();
1417 assert!(script.contains("$GIT_DIR/paw-agent-id"));
1418 assert!(script.contains(". \"$GIT_DIR/paw-agent-id\""));
1419 assert!(script.contains("$PAW_BROKER_URL/publish"));
1420 assert!(script.contains("$PAW_AGENT_ID"));
1421 assert!(script.contains("agent.artifact"));
1422 assert!(script.contains("|| true"));
1423 }
1424
1425 #[test]
1426 fn agent_marker_is_shell_sourceable() {
1427 let marker = build_agent_marker("http://127.0.0.1:9119", "feat-x", None, None, None);
1428 assert!(marker.contains("PAW_AGENT_ID=feat-x"));
1429 assert!(marker.contains("PAW_BROKER_URL=http://127.0.0.1:9119"));
1430 }
1431
1432 #[test]
1433 fn pre_push_hook_only_rejects_agent_worktrees() {
1434 let script = build_pre_push_hook();
1435 assert!(script.contains("exit 1"));
1437 assert!(script.contains("must not push"));
1438 assert!(
1441 script.contains("paw-agent-id"),
1442 "pre-push hook must gate the reject on $GIT_DIR/paw-agent-id; \
1443 without the gate, every push from this gitdir is blocked, \
1444 including legitimate pushes from the main repo"
1445 );
1446 }
1447
1448 #[test]
1449 fn chain_hook_replaces_existing_git_paw_block() {
1450 let existing = format!(
1451 "#!/bin/sh\n\
1452 # user hook\n\
1453 echo hi\n\
1454 {HOOK_START_MARKER}\n\
1455 old git-paw content\n\
1456 {HOOK_END_MARKER}\n"
1457 );
1458 let new_body = format!(
1459 "#!/bin/sh\n\
1460 {HOOK_START_MARKER}\n\
1461 new git-paw content\n\
1462 {HOOK_END_MARKER}\n"
1463 );
1464 let chained = chain_hook(&existing, &new_body);
1465 assert!(chained.contains("# user hook"));
1466 assert!(chained.contains("echo hi"));
1467 assert!(chained.contains("new git-paw content"));
1468 assert!(!chained.contains("old git-paw content"));
1469 }
1470
1471 #[test]
1472 fn chain_hook_appends_after_existing_content() {
1473 let existing = "#!/bin/sh\necho existing\n";
1474 let new_body = format!(
1475 "#!/bin/sh\n\
1476 {HOOK_START_MARKER}\n\
1477 new block\n\
1478 {HOOK_END_MARKER}\n"
1479 );
1480 let chained = chain_hook(existing, &new_body);
1481 assert!(chained.starts_with("#!/bin/sh\necho existing"));
1482 assert!(chained.contains("new block"));
1483 assert_eq!(chained.matches("#!/bin/sh").count(), 1);
1485 }
1486
1487 #[test]
1488 fn chain_hook_preserves_content_when_end_marker_missing() {
1489 let existing = format!(
1493 "#!/bin/sh\n\
1494 # important user logic\n\
1495 echo do_not_lose_me\n\
1496 {HOOK_START_MARKER}\n\
1497 leftover but no end marker\n"
1498 );
1499 let new_body = format!(
1500 "#!/bin/sh\n\
1501 {HOOK_START_MARKER}\n\
1502 new git-paw content\n\
1503 {HOOK_END_MARKER}\n"
1504 );
1505 let chained = chain_hook(&existing, &new_body);
1506 assert!(chained.contains("#!/bin/sh"));
1508 assert!(chained.contains("# important user logic"));
1509 assert!(chained.contains("echo do_not_lose_me"));
1510 assert!(chained.contains("leftover but no end marker"));
1511 assert!(chained.contains("new git-paw content"));
1513 assert!(chained.contains(HOOK_END_MARKER));
1514 assert_eq!(chained.matches("#!/bin/sh").count(), 1);
1516 }
1517
1518 #[test]
1519 #[serial_test::serial]
1520 fn install_git_hooks_writes_dispatcher_to_common_git_dir() {
1521 let tmp = tempfile::tempdir().unwrap();
1522 let worktree = tmp.path();
1523 init_git_repo(worktree);
1524
1525 install_git_hooks(worktree, "http://127.0.0.1:9119", "feat-x").unwrap();
1526
1527 let post_commit = worktree.join(".git").join("hooks").join("post-commit");
1528 let pre_push = worktree.join(".git").join("hooks").join("pre-push");
1529 let marker = worktree.join(".git").join("paw-agent-id");
1530
1531 assert!(post_commit.exists(), "post-commit should exist");
1532 assert!(pre_push.exists(), "pre-push should exist");
1533 assert!(marker.exists(), "paw-agent-id marker should exist");
1534
1535 let pc = fs::read_to_string(&post_commit).unwrap();
1536 assert!(pc.contains("$GIT_DIR/paw-agent-id"));
1537 assert!(pc.contains("agent.artifact"));
1538
1539 let marker_body = fs::read_to_string(&marker).unwrap();
1540 assert!(marker_body.contains("PAW_AGENT_ID=feat-x"));
1541 assert!(marker_body.contains("PAW_BROKER_URL=http://127.0.0.1:9119"));
1542
1543 #[cfg(unix)]
1544 {
1545 use std::os::unix::fs::PermissionsExt;
1546 let mode = fs::metadata(&post_commit).unwrap().permissions().mode();
1547 assert_eq!(mode & 0o111, 0o111, "post-commit must be executable");
1548 }
1549 }
1550
1551 #[test]
1552 #[serial_test::serial]
1553 fn install_git_hooks_preserves_existing_dispatcher_body() {
1554 let tmp = tempfile::tempdir().unwrap();
1555 let worktree = tmp.path();
1556 init_git_repo(worktree);
1557 let hook_path = worktree.join(".git").join("hooks").join("post-commit");
1558 fs::write(&hook_path, "#!/bin/sh\necho user hook\n").unwrap();
1559
1560 install_git_hooks(worktree, "http://127.0.0.1:9119", "feat-x").unwrap();
1561
1562 let body = fs::read_to_string(&hook_path).unwrap();
1563 assert!(body.contains("echo user hook"));
1564 assert!(body.contains("agent.artifact"));
1565 }
1566
1567 #[test]
1568 #[serial_test::serial]
1569 fn install_git_hooks_writes_linked_marker_for_linked_worktree() {
1570 let tmp = tempfile::tempdir().unwrap();
1571 let main_repo = tmp.path().join("main");
1572 fs::create_dir_all(&main_repo).unwrap();
1573 init_git_repo(&main_repo);
1574
1575 std::process::Command::new("git")
1577 .args(["commit", "--allow-empty", "-m", "root", "-q"])
1578 .current_dir(&main_repo)
1579 .output()
1580 .unwrap();
1581
1582 let linked_path = tmp.path().join("linked");
1584 std::process::Command::new("git")
1585 .args([
1586 "worktree",
1587 "add",
1588 "-b",
1589 "feat-x",
1590 linked_path.to_str().unwrap(),
1591 ])
1592 .current_dir(&main_repo)
1593 .output()
1594 .unwrap();
1595
1596 install_git_hooks(&linked_path, "http://127.0.0.1:9119", "feat-x").unwrap();
1597
1598 let post_commit = main_repo.join(".git").join("hooks").join("post-commit");
1600 assert!(
1601 post_commit.exists(),
1602 "dispatcher must land in main .git/hooks/"
1603 );
1604 let marker = main_repo
1606 .join(".git")
1607 .join("worktrees")
1608 .join("linked")
1609 .join("paw-agent-id");
1610 assert!(
1611 marker.exists(),
1612 "marker must land in linked worktree gitdir"
1613 );
1614 let body = fs::read_to_string(&marker).unwrap();
1615 assert!(body.contains("PAW_AGENT_ID=feat-x"));
1616 }
1617
1618 #[test]
1623 fn build_agent_marker_basic_format() {
1624 let marker = build_agent_marker("http://127.0.0.1:9119", "feat-test", None, None, None);
1625
1626 assert!(marker.contains("PAW_AGENT_ID=feat-test"));
1627 assert!(marker.contains("PAW_BROKER_URL=http://127.0.0.1:9119"));
1628 assert!(marker.contains("PAW_TIMESTAMP="));
1629 assert!(!marker.contains("PAW_SUPERVISOR_PID"));
1631 assert!(!marker.contains("PAW_LAST_VERIFIED_COMMIT"));
1632 assert!(!marker.contains("PAW_SESSION_NAME"));
1633 }
1634
1635 #[test]
1636 fn build_agent_marker_with_all_extended_fields() {
1637 let marker = build_agent_marker(
1638 "http://localhost:9119",
1639 "feat-errors",
1640 Some(12345),
1641 Some("abc123def456"),
1642 Some("paw-test-session"),
1643 );
1644
1645 assert!(marker.contains("PAW_AGENT_ID=feat-errors"));
1646 assert!(marker.contains("PAW_BROKER_URL=http://localhost:9119"));
1647 assert!(marker.contains("PAW_SUPERVISOR_PID=12345"));
1648 assert!(marker.contains("PAW_LAST_VERIFIED_COMMIT=abc123def456"));
1649 assert!(marker.contains("PAW_SESSION_NAME=paw-test-session"));
1650 assert!(marker.contains("PAW_TIMESTAMP="));
1651 }
1652
1653 #[test]
1654 fn build_agent_marker_partial_extended_fields() {
1655 let marker =
1656 build_agent_marker("http://localhost:9119", "fix-cycle", Some(999), None, None);
1657
1658 assert!(marker.contains("PAW_SUPERVISOR_PID=999"));
1659 assert!(!marker.contains("PAW_LAST_VERIFIED_COMMIT"));
1660 assert!(!marker.contains("PAW_SESSION_NAME"));
1661 }
1662
1663 #[test]
1664 fn update_agent_marker_adds_missing_fields() {
1665 let tmp = tempfile::tempdir().unwrap();
1666 let marker_path = tmp.path().join("test-marker");
1667
1668 let initial = "PAW_AGENT_ID=test\nPAW_BROKER_URL=http://localhost:9119\nPAW_TIMESTAMP=2026-01-01T00:00:00Z\n";
1670 fs::write(&marker_path, initial).unwrap();
1671
1672 update_agent_marker(&marker_path, Some(54321), None).unwrap();
1674
1675 let updated = fs::read_to_string(&marker_path).unwrap();
1676 assert!(updated.contains("PAW_AGENT_ID=test"));
1677 assert!(updated.contains("PAW_SUPERVISOR_PID=54321"));
1678 }
1679
1680 #[test]
1681 fn update_agent_marker_replaces_existing_fields() {
1682 let tmp = tempfile::tempdir().unwrap();
1683 let marker_path = tmp.path().join("test-marker");
1684
1685 let initial = "PAW_AGENT_ID=test\nPAW_BROKER_URL=http://localhost:9119\nPAW_LAST_VERIFIED_COMMIT=old123\nPAW_TIMESTAMP=2026-01-01T00:00:00Z\n";
1687 fs::write(&marker_path, initial).unwrap();
1688
1689 update_agent_marker(&marker_path, None, Some("new456")).unwrap();
1691
1692 let updated = fs::read_to_string(&marker_path).unwrap();
1693 assert!(updated.contains("PAW_AGENT_ID=test"));
1694 assert!(updated.contains("PAW_LAST_VERIFIED_COMMIT=new456"));
1695 assert!(!updated.contains("PAW_LAST_VERIFIED_COMMIT=old123"));
1696 }
1697
1698 #[test]
1699 fn update_agent_marker_reuses_lazy_regex_across_calls() {
1700 let tmp = tempfile::tempdir().unwrap();
1705 let marker_path = tmp.path().join("test-marker");
1706
1707 let initial = "PAW_AGENT_ID=test\nPAW_BROKER_URL=http://localhost:9119\nPAW_SUPERVISOR_PID=111\nPAW_LAST_VERIFIED_COMMIT=abc\n";
1708 fs::write(&marker_path, initial).unwrap();
1709
1710 update_agent_marker(&marker_path, Some(222), Some("def")).unwrap();
1711 update_agent_marker(&marker_path, Some(333), Some("ghi")).unwrap();
1712
1713 let updated = fs::read_to_string(&marker_path).unwrap();
1714 assert!(updated.contains("PAW_SUPERVISOR_PID=333"));
1715 assert!(updated.contains("PAW_LAST_VERIFIED_COMMIT=ghi"));
1716 assert!(!updated.contains("PAW_SUPERVISOR_PID=111"));
1717 assert!(!updated.contains("PAW_SUPERVISOR_PID=222"));
1718 assert!(!updated.contains("PAW_LAST_VERIFIED_COMMIT=abc"));
1719 assert!(!updated.contains("PAW_LAST_VERIFIED_COMMIT=def"));
1720 }
1721
1722 #[test]
1723 fn get_agent_marker_path_returns_correct_path() {
1724 let tmp = tempfile::tempdir().unwrap();
1725 let worktree = tmp.path();
1726 init_git_repo(worktree);
1727
1728 let marker_path = get_agent_marker_path(worktree).unwrap();
1729 assert!(marker_path.ends_with(".git/paw-agent-id"));
1730 }
1731
1732 #[test]
1737 fn remove_session_boot_block_strips_marked_block() {
1738 let tmp = tempfile::tempdir().unwrap();
1739 let repo_root = tmp.path();
1740 let agents_md = repo_root.join("AGENTS.md");
1741
1742 let header = "# Project AGENTS";
1743 let footer = "## Footer\n";
1744 let original = format!(
1745 "{header}\n\n<!-- git-paw:start — managed by git-paw, do not edit manually -->\n## boot block\nsome content\n<!-- git-paw:end -->\n\n{footer}"
1746 );
1747 fs::write(&agents_md, &original).unwrap();
1748
1749 remove_session_boot_block(repo_root).unwrap();
1750
1751 let after = fs::read_to_string(&agents_md).unwrap();
1752 let expected = format!("{header}\n\n{footer}");
1753 assert_eq!(
1754 after, expected,
1755 "after removal the file must match HEADER + blank + FOOTER byte-for-byte; got:\n{after:?}",
1756 );
1757 assert!(
1758 !after.contains("git-paw:start"),
1759 "no git-paw:start marker may remain after removal",
1760 );
1761 }
1762
1763 #[test]
1764 fn remove_session_boot_block_no_marker_is_noop() {
1765 let tmp = tempfile::tempdir().unwrap();
1766 let repo_root = tmp.path();
1767 let agents_md = repo_root.join("AGENTS.md");
1768
1769 let original = "# Project AGENTS\n\nNo boot block here.\n";
1770 fs::write(&agents_md, original).unwrap();
1771
1772 remove_session_boot_block(repo_root).unwrap();
1773
1774 let after = fs::read_to_string(&agents_md).unwrap();
1775 assert_eq!(
1776 after, original,
1777 "files without a boot-block marker must be preserved byte-for-byte",
1778 );
1779 }
1780
1781 #[test]
1782 fn remove_session_boot_block_missing_agents_md_is_noop() {
1783 let tmp = tempfile::tempdir().unwrap();
1786 remove_session_boot_block(tmp.path()).unwrap();
1787 assert!(
1788 !tmp.path().join("AGENTS.md").exists(),
1789 "remove_session_boot_block must not create AGENTS.md when none exists",
1790 );
1791 }
1792
1793 #[test]
1794 fn remove_session_boot_block_preserves_no_trailing_newline() {
1795 let tmp = tempfile::tempdir().unwrap();
1798 let repo_root = tmp.path();
1799 let agents_md = repo_root.join("AGENTS.md");
1800
1801 let original = "# Header no newline";
1802 fs::write(&agents_md, original).unwrap();
1803
1804 remove_session_boot_block(repo_root).unwrap();
1805
1806 let after = fs::read_to_string(&agents_md).unwrap();
1807 assert_eq!(
1808 after, original,
1809 "file without trailing newline must be preserved exactly"
1810 );
1811 }
1812}