1use std::fmt::Write;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use crate::error::PawError;
12use crate::git::{assume_unchanged, exclude_from_git};
13
14const START_MARKER_PREFIX: &str = "<!-- git-paw:start";
16
17const START_MARKER: &str = "<!-- git-paw:start — managed by git-paw, do not edit manually -->";
19
20const END_MARKER: &str = "<!-- git-paw:end -->";
22
23const HOOK_START_MARKER: &str = "# >>> git-paw managed hook >>>";
29const HOOK_END_MARKER: &str = "# <<< git-paw managed hook <<<";
30
31pub fn has_git_paw_section(content: &str) -> bool {
33 content
34 .lines()
35 .any(|line| line.starts_with(START_MARKER_PREFIX))
36}
37
38pub fn replace_git_paw_section(content: &str, new_section: &str) -> String {
42 let lines: Vec<&str> = content.lines().collect();
43
44 let Some(start_idx) = lines
45 .iter()
46 .position(|l| l.starts_with(START_MARKER_PREFIX))
47 else {
48 return content.to_string();
49 };
50
51 let end_idx = lines[start_idx..]
52 .iter()
53 .position(|l| l.contains(END_MARKER))
54 .map(|rel| start_idx + rel);
55
56 let mut result = String::new();
57
58 for line in &lines[..start_idx] {
60 result.push_str(line);
61 result.push('\n');
62 }
63
64 result.push_str(new_section);
66
67 if let Some(end) = end_idx
69 && end + 1 < lines.len()
70 {
71 for line in &lines[end + 1..] {
72 result.push_str(line);
73 result.push('\n');
74 }
75 }
76
77 if end_idx.is_none() && content.ends_with('\n') && !result.ends_with('\n') {
79 result.push('\n');
80 }
81
82 result
83}
84
85pub fn inject_into_content(content: &str, section: &str) -> String {
88 if content.is_empty() {
89 return section.to_string();
90 }
91
92 if has_git_paw_section(content) {
93 return replace_git_paw_section(content, section);
94 }
95
96 let mut result = content.to_string();
98 if !result.ends_with('\n') {
99 result.push('\n');
100 }
101 result.push('\n');
102 result.push_str(section);
103 result
104}
105
106pub struct WorktreeAssignment {
110 pub branch: String,
112 pub cli: String,
114 pub spec_content: Option<String>,
116 pub owned_files: Option<Vec<String>>,
118 pub skill_content: Option<String>,
120 pub inter_agent_rules: Option<String>,
126}
127
128pub fn build_inter_agent_rules(branches: &[&str]) -> String {
134 let mut peers = String::new();
135 for (i, b) in branches.iter().enumerate() {
136 if i > 0 {
137 peers.push_str(", ");
138 }
139 peers.push('`');
140 peers.push_str(b);
141 peers.push('`');
142 }
143
144 let mut out = String::new();
145 out.push_str("These rules apply to every agent in this supervisor session. ");
146 out.push_str("Violating them blocks the supervisor's verification step.\n\n");
147 out.push_str("- **File ownership is exclusive.** You MUST NOT edit files owned by ");
148 out.push_str("other agents. Peers in this session: ");
149 out.push_str(&peers);
150 out.push_str(". Stay inside your declared file ownership list.\n");
151 out.push_str("- **Commit, never push.** You MUST commit to your worktree branch and ");
152 out.push_str("MUST NOT `git push` to any remote. The supervisor merges branches.\n");
153 out.push_str("- **Status publishing is automatic.** git-paw watches your worktree and ");
154 out.push_str("publishes `agent.status` with `modified_files` for you whenever your git ");
155 out.push_str("status changes. A `post-commit` hook publishes `agent.artifact` on each ");
156 out.push_str("commit. You do not need to curl these yourself.\n");
157 out.push_str("- **Watch peer status.** Poll `/messages/{{BRANCH_ID}}` to see peer ");
158 out.push_str("`agent.artifact` messages so you detect conflicts before the supervisor does.\n");
159 out.push_str("- **Cherry-pick peer artifacts.** When you are blocked on a peer, publish ");
160 out.push_str("`agent.blocked` and cherry-pick their commit when their artifact arrives ");
161 out.push_str("in your inbox. Do not wait for the supervisor to merge.\n");
162 out.push_str("- **Match spec field names exactly.** When implementing a spec, use the ");
163 out.push_str("exact field, function, and message names from the spec — do not rename ");
164 out.push_str("them. The supervisor's spec audit will reject mismatched names.\n");
165 out
166}
167
168pub fn generate_worktree_section(assignment: &WorktreeAssignment) -> String {
170 let mut section = String::new();
171 section.push_str(START_MARKER);
172 section.push('\n');
173 section.push('\n');
174 section.push_str("## git-paw Session Assignment\n");
175 section.push('\n');
176 let _ = writeln!(section, "- **Branch:** `{}`", assignment.branch);
177 let _ = writeln!(section, "- **CLI:** {}", assignment.cli);
178
179 if let Some(ref spec) = assignment.spec_content {
180 section.push('\n');
181 section.push_str("### Spec\n");
182 section.push('\n');
183 section.push_str(spec);
184 if !spec.ends_with('\n') {
185 section.push('\n');
186 }
187 }
188
189 if let Some(ref files) = assignment.owned_files {
190 section.push('\n');
191 section.push_str("### File Ownership\n");
192 section.push('\n');
193 for file in files {
194 let _ = writeln!(section, "- `{file}`");
195 }
196 }
197
198 if let Some(ref skill) = assignment.skill_content {
199 section.push('\n');
200 section.push_str(skill);
201 if !skill.ends_with('\n') {
202 section.push('\n');
203 }
204 }
205
206 if let Some(ref rules) = assignment.inter_agent_rules {
207 section.push('\n');
208 section.push_str("## Inter-Agent Rules\n");
209 section.push('\n');
210 section.push_str(rules);
211 if !rules.ends_with('\n') {
212 section.push('\n');
213 }
214 }
215
216 section.push('\n');
217 section.push_str(END_MARKER);
218 section.push('\n');
219 section
220}
221
222pub fn setup_worktree_agents_md(
233 repo_root: &Path,
234 worktree_root: &Path,
235 assignment: &WorktreeAssignment,
236) -> Result<(), PawError> {
237 let root_agents = repo_root.join("AGENTS.md");
238 let root_content = match fs::read_to_string(&root_agents) {
239 Ok(c) => c,
240 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
241 Err(e) => {
242 return Err(PawError::AgentsMdError(format!(
243 "failed to read '{}': {e}",
244 root_agents.display()
245 )));
246 }
247 };
248
249 let section = generate_worktree_section(assignment);
250 let output = inject_into_content(&root_content, §ion);
251
252 let worktree_agents = worktree_root.join("AGENTS.md");
253 fs::write(&worktree_agents, &output).map_err(|e| {
254 PawError::AgentsMdError(format!(
255 "failed to write '{}': {e}",
256 worktree_agents.display()
257 ))
258 })?;
259
260 exclude_from_git(worktree_root, "AGENTS.md")?;
261
262 let _ = assume_unchanged(worktree_root, "AGENTS.md");
268
269 Ok(())
270}
271
272pub fn get_agent_marker_path(worktree: &Path) -> Result<PathBuf, PawError> {
274 let linked_git_dir = git_rev_parse_path(worktree, "--git-dir")?;
275 Ok(linked_git_dir.join("paw-agent-id"))
276}
277
278pub fn build_agent_marker(
294 broker_url: &str,
295 agent_id: &str,
296 supervisor_pid: Option<u32>,
297 last_verified_commit: Option<&str>,
298 session_name: Option<&str>,
299) -> String {
300 let mut marker = format!("PAW_AGENT_ID={agent_id}\nPAW_BROKER_URL={broker_url}\n");
301
302 if let Some(pid) = supervisor_pid {
304 let _ = writeln!(marker, "PAW_SUPERVISOR_PID={pid}");
305 }
306 if let Some(commit) = last_verified_commit {
307 let _ = writeln!(marker, "PAW_LAST_VERIFIED_COMMIT={commit}");
308 }
309 if let Some(session) = session_name {
310 let _ = writeln!(marker, "PAW_SESSION_NAME={session}");
311 }
312
313 let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
315 let _ = writeln!(marker, "PAW_TIMESTAMP={timestamp}");
316
317 marker
318}
319
320pub fn update_agent_marker(
329 marker_path: &Path,
330 supervisor_pid: Option<u32>,
331 last_verified_commit: Option<&str>,
332) -> Result<(), PawError> {
333 let content = fs::read_to_string(marker_path)
334 .map_err(|e| PawError::AgentsMdError(format!("failed to read marker file: {e}")))?;
335
336 let mut updated = content;
337
338 if let Some(pid) = supervisor_pid {
340 if updated.contains("PAW_SUPERVISOR_PID=") {
341 updated = regex::Regex::new(r"PAW_SUPERVISOR_PID=\d+")
343 .unwrap()
344 .replace(&updated, &format!("PAW_SUPERVISOR_PID={pid}"))
345 .to_string();
346 } else {
347 let _ = write!(updated, "\nPAW_SUPERVISOR_PID={pid}");
349 }
350 }
351
352 if let Some(commit) = last_verified_commit {
354 if updated.contains("PAW_LAST_VERIFIED_COMMIT=") {
355 updated = regex::Regex::new(r"PAW_LAST_VERIFIED_COMMIT=[^\n]+")
357 .unwrap()
358 .replace(&updated, &format!("PAW_LAST_VERIFIED_COMMIT={commit}"))
359 .to_string();
360 } else {
361 let _ = write!(updated, "\nPAW_LAST_VERIFIED_COMMIT={commit}");
363 }
364 }
365
366 fs::write(marker_path, updated)
367 .map_err(|e| PawError::AgentsMdError(format!("failed to update marker file: {e}")))?;
368
369 Ok(())
370}
371
372fn build_post_commit_dispatcher_hook() -> String {
383 format!(
384 "#!/bin/sh\n\
385 {HOOK_START_MARKER}\n\
386 # Dispatcher: reads per-worktree $GIT_DIR/paw-agent-id and publishes\n\
387 # agent.artifact to the git-paw broker.\n\
388 if [ -n \"$GIT_DIR\" ] && [ -f \"$GIT_DIR/paw-agent-id\" ]; then\n\
389 . \"$GIT_DIR/paw-agent-id\"\n\
390 FILES=$(git diff HEAD~1 --name-only 2>/dev/null | awk '{{printf \"%s\\\"%s\\\"\", (NR>1?\",\":\"\"), $0}}')\n\
391 curl -s -X POST \"$PAW_BROKER_URL/publish\" \\\n\
392 -H 'Content-Type: application/json' \\\n\
393 -d \"{{\\\"type\\\":\\\"agent.artifact\\\",\\\"agent_id\\\":\\\"$PAW_AGENT_ID\\\",\\\"payload\\\":{{\\\"status\\\":\\\"committed\\\",\\\"exports\\\":[],\\\"modified_files\\\":[$FILES]}}}}\" \\\n\
394 >/dev/null 2>&1 || true\n\
395 fi\n\
396 {HOOK_END_MARKER}\n"
397 )
398}
399
400fn build_pre_push_hook() -> String {
401 format!(
408 "#!/bin/sh\n\
409 {HOOK_START_MARKER}\n\
410 if [ -n \"$GIT_DIR\" ] && [ -f \"$GIT_DIR/paw-agent-id\" ]; then\n\
411 echo 'error: git-paw agents must not push. The supervisor handles merges.' >&2\n\
412 exit 1\n\
413 fi\n\
414 {HOOK_END_MARKER}\n"
415 )
416}
417
418fn chain_hook(existing: &str, new_body: &str) -> String {
426 if let Some(start) = existing.find(HOOK_START_MARKER)
431 && let Some(end_rel) = existing[start..].find(HOOK_END_MARKER)
432 {
433 let end = start + end_rel + HOOK_END_MARKER.len();
434 let mut out = String::with_capacity(existing.len() + new_body.len());
435 out.push_str(&existing[..start]);
436 let stripped = new_body.strip_prefix("#!/bin/sh\n").unwrap_or(new_body);
439 out.push_str(stripped);
440 out.push_str(&existing[end..]);
441 return out;
442 }
443 let mut out = existing.trim_end().to_string();
444 if !out.is_empty() {
445 out.push('\n');
446 }
447 let stripped = if out.is_empty() {
448 new_body.to_string()
449 } else {
450 new_body
451 .strip_prefix("#!/bin/sh\n")
452 .unwrap_or(new_body)
453 .to_string()
454 };
455 out.push_str(&stripped);
456 out
457}
458
459fn write_hook_file(hook_path: &Path, new_body: &str) -> Result<(), PawError> {
460 let existing = match fs::read_to_string(hook_path) {
461 Ok(c) => c,
462 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
463 Err(e) => {
464 return Err(PawError::AgentsMdError(format!(
465 "failed to read '{}': {e}",
466 hook_path.display()
467 )));
468 }
469 };
470
471 let content = if existing.is_empty() {
472 new_body.to_string()
473 } else {
474 chain_hook(&existing, new_body)
475 };
476
477 if let Some(parent) = hook_path.parent() {
478 fs::create_dir_all(parent).map_err(|e| {
479 PawError::AgentsMdError(format!("failed to create '{}': {e}", parent.display()))
480 })?;
481 }
482
483 fs::write(hook_path, content.as_bytes()).map_err(|e| {
484 PawError::AgentsMdError(format!("failed to write '{}': {e}", hook_path.display()))
485 })?;
486
487 #[cfg(unix)]
488 {
489 use std::os::unix::fs::PermissionsExt;
490 let mut perms = fs::metadata(hook_path)
491 .map_err(|e| {
492 PawError::AgentsMdError(format!("failed to stat '{}': {e}", hook_path.display()))
493 })?
494 .permissions();
495 perms.set_mode(0o755);
496 fs::set_permissions(hook_path, perms).map_err(|e| {
497 PawError::AgentsMdError(format!("failed to chmod '{}': {e}", hook_path.display()))
498 })?;
499 }
500
501 Ok(())
502}
503
504fn git_rev_parse_path(worktree: &Path, flag: &str) -> Result<PathBuf, PawError> {
510 let output = std::process::Command::new("git")
511 .current_dir(worktree)
512 .args(["rev-parse", flag])
513 .output()
514 .map_err(|e| PawError::AgentsMdError(format!("failed to run git rev-parse {flag}: {e}")))?;
515 if !output.status.success() {
516 let stderr = String::from_utf8_lossy(&output.stderr);
517 return Err(PawError::AgentsMdError(format!(
518 "git rev-parse {flag} failed in '{}': {stderr}",
519 worktree.display()
520 )));
521 }
522 let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
523 let path = PathBuf::from(&raw);
524 if path.is_absolute() {
525 Ok(path)
526 } else {
527 Ok(worktree.join(path))
528 }
529}
530
531pub fn install_git_hooks(
548 worktree: &Path,
549 broker_url: &str,
550 agent_id: &str,
551) -> Result<(), PawError> {
552 let common_git_dir = git_rev_parse_path(worktree, "--git-common-dir")?;
553 let linked_git_dir = git_rev_parse_path(worktree, "--git-dir")?;
554 let hooks_dir = common_git_dir.join("hooks");
555
556 write_hook_file(
557 &hooks_dir.join("post-commit"),
558 &build_post_commit_dispatcher_hook(),
559 )?;
560 write_hook_file(&hooks_dir.join("pre-push"), &build_pre_push_hook())?;
561
562 let marker_path = linked_git_dir.join("paw-agent-id");
563 if let Some(parent) = marker_path.parent() {
564 fs::create_dir_all(parent).map_err(|e| {
565 PawError::AgentsMdError(format!("failed to create '{}': {e}", parent.display()))
566 })?;
567 }
568 fs::write(
569 &marker_path,
570 build_agent_marker(broker_url, agent_id, None, None, None),
571 )
572 .map_err(|e| {
573 PawError::AgentsMdError(format!("failed to write '{}': {e}", marker_path.display()))
574 })?;
575
576 Ok(())
577}
578
579pub fn inject_section_into_file(path: &Path, section: &str) -> Result<(), PawError> {
580 let content = match fs::read_to_string(path) {
581 Ok(c) => c,
582 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
583 Err(e) => {
584 return Err(PawError::AgentsMdError(format!(
585 "failed to read '{}': {e}",
586 path.display()
587 )));
588 }
589 };
590
591 let output = inject_into_content(&content, section);
592
593 fs::write(path, &output)
594 .map_err(|e| PawError::AgentsMdError(format!("failed to write '{}': {e}", path.display())))
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600
601 fn sample_section() -> String {
603 format!("{START_MARKER}\n## git-paw test section\n{END_MARKER}\n")
604 }
605
606 #[test]
611 fn has_section_returns_true_when_marker_present() {
612 let content = "# My Project\n\n<!-- git-paw:start — managed by git-paw, do not edit manually -->\nstuff\n<!-- git-paw:end -->\n";
613 assert!(has_git_paw_section(content));
614 }
615
616 #[test]
617 fn has_section_returns_false_without_marker() {
618 let content = "# My Project\n\nSome instructions.\n";
619 assert!(!has_git_paw_section(content));
620 }
621
622 #[test]
623 fn has_section_returns_false_for_empty() {
624 assert!(!has_git_paw_section(""));
625 }
626
627 #[test]
632 fn generated_section_has_markers() {
633 let section = sample_section();
634 assert!(section.starts_with(START_MARKER));
635 assert!(section.contains(END_MARKER));
636 }
637
638 #[test]
639 fn sample_section_contains_git_paw_reference() {
640 let section = sample_section();
641 assert!(section.contains("git-paw"));
642 }
643
644 #[test]
649 fn replace_with_both_markers_preserves_surrounding() {
650 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";
651 let new_section = "<!-- git-paw:start — managed by git-paw, do not edit manually -->\nnew content\n<!-- git-paw:end -->\n";
652 let result = replace_git_paw_section(content, new_section);
653 assert!(result.contains("# Title"));
654 assert!(result.contains("new content"));
655 assert!(!result.contains("old content"));
656 assert!(result.contains("## Footer"));
657 }
658
659 #[test]
660 fn replace_with_missing_end_marker_replaces_to_eof() {
661 let content = "# Title\n\n<!-- git-paw:start — managed by git-paw, do not edit manually -->\nold content that never ends\n";
662 let new_section = "<!-- git-paw:start — managed by git-paw, do not edit manually -->\nfixed\n<!-- git-paw:end -->\n";
663 let result = replace_git_paw_section(content, new_section);
664 assert!(result.contains("# Title"));
665 assert!(result.contains("fixed"));
666 assert!(!result.contains("old content"));
667 }
668
669 #[test]
674 fn inject_appends_when_no_existing_section() {
675 let content = "# My Project\n\nSome info.\n";
676 let section = sample_section();
677 let result = inject_into_content(content, §ion);
678 assert!(result.starts_with("# My Project"));
679 assert!(result.contains(START_MARKER));
680 }
681
682 #[test]
683 fn inject_replaces_existing_section() {
684 let old_section = format!("{START_MARKER}\nold\n{END_MARKER}\n");
685 let content = format!("# Title\n\n{old_section}\n## Footer\n");
686 let new_section = format!("{START_MARKER}\nnew\n{END_MARKER}\n");
687 let result = inject_into_content(&content, &new_section);
688 assert!(result.contains("new"));
689 assert!(!result.contains("old"));
690 assert!(result.contains("## Footer"));
691 }
692
693 #[test]
694 fn inject_into_empty_content_returns_section_only() {
695 let section = sample_section();
696 let result = inject_into_content("", §ion);
697 assert_eq!(result, section);
698 }
699
700 #[test]
705 fn spacing_with_trailing_newline() {
706 let content = "# Title\n";
707 let section = "<!-- git-paw:start -->\n<!-- git-paw:end -->\n";
708 let result = inject_into_content(content, section);
709 assert!(result.contains("# Title\n\n<!-- git-paw:start"));
711 }
712
713 #[test]
714 fn spacing_without_trailing_newline() {
715 let content = "# Title";
716 let section = "<!-- git-paw:start -->\n<!-- git-paw:end -->\n";
717 let result = inject_into_content(content, section);
718 assert!(result.contains("# Title\n\n<!-- git-paw:start"));
720 }
721
722 #[test]
727 fn file_inject_appends_to_existing() {
728 let dir = tempfile::tempdir().unwrap();
729 let path = dir.path().join("AGENTS.md");
730 fs::write(&path, "# Existing\n").unwrap();
731
732 let section = sample_section();
733 inject_section_into_file(&path, §ion).unwrap();
734
735 let result = fs::read_to_string(&path).unwrap();
736 assert!(result.contains("# Existing"));
737 assert!(result.contains(START_MARKER));
738 }
739
740 #[test]
741 fn file_inject_replaces_existing_section() {
742 let dir = tempfile::tempdir().unwrap();
743 let path = dir.path().join("AGENTS.md");
744 let initial = format!("# Title\n\n{START_MARKER}\nold\n{END_MARKER}\n");
745 fs::write(&path, &initial).unwrap();
746
747 let new_section = sample_section();
748 inject_section_into_file(&path, &new_section).unwrap();
749
750 let result = fs::read_to_string(&path).unwrap();
751 assert!(result.contains("# Title"));
752 assert!(!result.contains("\nold\n"));
753 assert!(result.contains("git-paw test section"));
754 }
755
756 #[test]
757 fn file_inject_creates_missing_file() {
758 let dir = tempfile::tempdir().unwrap();
759 let path = dir.path().join("AGENTS.md");
760 assert!(!path.exists());
761
762 let section = sample_section();
763 inject_section_into_file(&path, §ion).unwrap();
764
765 let result = fs::read_to_string(&path).unwrap();
766 assert!(result.contains(START_MARKER));
767 }
768
769 #[test]
770 fn file_inject_readonly_returns_error() {
771 use std::os::unix::fs::PermissionsExt;
772
773 let dir = tempfile::tempdir().unwrap();
774 let path = dir.path().join("AGENTS.md");
775 fs::write(&path, "content").unwrap();
776 fs::set_permissions(&path, fs::Permissions::from_mode(0o444)).unwrap();
777
778 let section = sample_section();
779 let result = inject_section_into_file(&path, §ion);
780 assert!(result.is_err());
781 let err = result.unwrap_err();
782 let msg = err.to_string();
783 assert!(msg.contains("AGENTS.md error"), "got: {msg}");
784 assert!(
785 msg.contains("AGENTS.md"),
786 "should mention file path, got: {msg}"
787 );
788
789 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
791 }
792
793 fn make_assignment(spec: Option<&str>, files: Option<Vec<&str>>) -> WorktreeAssignment {
798 WorktreeAssignment {
799 branch: "feat/foo".to_string(),
800 cli: "claude".to_string(),
801 spec_content: spec.map(ToString::to_string),
802 owned_files: files.map(|v| v.into_iter().map(ToString::to_string).collect()),
803 skill_content: None,
804 inter_agent_rules: None,
805 }
806 }
807
808 fn make_assignment_with_skill(
809 spec: Option<&str>,
810 files: Option<Vec<&str>>,
811 skill: Option<&str>,
812 ) -> WorktreeAssignment {
813 WorktreeAssignment {
814 branch: "feat/foo".to_string(),
815 cli: "claude".to_string(),
816 spec_content: spec.map(ToString::to_string),
817 owned_files: files.map(|v| v.into_iter().map(ToString::to_string).collect()),
818 skill_content: skill.map(ToString::to_string),
819 inter_agent_rules: None,
820 }
821 }
822
823 #[test]
824 fn worktree_section_all_fields() {
825 let assignment = make_assignment(
826 Some("Implement the widget.\n"),
827 Some(vec!["src/widget.rs", "tests/widget.rs"]),
828 );
829 let section = generate_worktree_section(&assignment);
830 assert!(section.starts_with(START_MARKER));
831 assert!(section.contains(END_MARKER));
832 assert!(section.contains("`feat/foo`"));
833 assert!(section.contains("claude"));
834 assert!(section.contains("### Spec"));
835 assert!(section.contains("Implement the widget."));
836 assert!(section.contains("### File Ownership"));
837 assert!(section.contains("`src/widget.rs`"));
838 assert!(section.contains("`tests/widget.rs`"));
839 }
840
841 #[test]
842 fn worktree_section_no_spec() {
843 let assignment = make_assignment(None, Some(vec!["src/main.rs"]));
844 let section = generate_worktree_section(&assignment);
845 assert!(section.contains("`feat/foo`"));
846 assert!(!section.contains("### Spec"));
847 assert!(section.contains("### File Ownership"));
848 }
849
850 #[test]
851 fn worktree_section_no_files() {
852 let assignment = make_assignment(Some("Do the thing.\n"), None);
853 let section = generate_worktree_section(&assignment);
854 assert!(section.contains("### Spec"));
855 assert!(!section.contains("### File Ownership"));
856 }
857
858 #[test]
859 fn worktree_section_minimal() {
860 let assignment = make_assignment(None, None);
861 let section = generate_worktree_section(&assignment);
862 assert!(section.starts_with(START_MARKER));
863 assert!(section.contains(END_MARKER));
864 assert!(section.contains("`feat/foo`"));
865 assert!(section.contains("claude"));
866 assert!(!section.contains("### Spec"));
867 assert!(!section.contains("### File Ownership"));
868 }
869
870 fn init_git_repo(dir: &Path) {
879 use std::process::Command;
880 let git = which::which("git").expect("git must be on PATH");
881 Command::new(&git)
882 .current_dir(dir)
883 .args(["init"])
884 .output()
885 .expect("git init");
886 Command::new(&git)
887 .current_dir(dir)
888 .args(["config", "user.email", "test@test.com"])
889 .output()
890 .expect("git config email");
891 Command::new(&git)
892 .current_dir(dir)
893 .args(["config", "user.name", "Test"])
894 .output()
895 .expect("git config name");
896 fs::write(dir.join("README.md"), "# test\n").unwrap();
898 Command::new(&git)
899 .current_dir(dir)
900 .args(["add", "README.md"])
901 .output()
902 .expect("git add");
903 Command::new(&git)
904 .current_dir(dir)
905 .args(["commit", "-m", "init"])
906 .output()
907 .expect("git commit");
908 }
909
910 #[test]
911 fn setup_worktree_root_exists() {
912 let repo = tempfile::tempdir().unwrap();
913 let wt = tempfile::tempdir().unwrap();
914 init_git_repo(wt.path());
915 fs::write(repo.path().join("AGENTS.md"), "# Project Rules\n").unwrap();
916
917 fs::write(wt.path().join("AGENTS.md"), "# placeholder\n").unwrap();
919 std::process::Command::new("git")
920 .current_dir(wt.path())
921 .args(["add", "AGENTS.md"])
922 .output()
923 .expect("git add AGENTS.md");
924 std::process::Command::new("git")
925 .current_dir(wt.path())
926 .args(["commit", "-m", "add agents"])
927 .output()
928 .expect("git commit");
929
930 let assignment = make_assignment(None, None);
931 setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
932
933 let result = fs::read_to_string(wt.path().join("AGENTS.md")).unwrap();
934 assert!(result.contains("# Project Rules"));
935 assert!(result.contains("`feat/foo`"));
936 assert!(result.contains(START_MARKER));
937
938 let status = std::process::Command::new("git")
940 .current_dir(wt.path())
941 .args(["status", "--porcelain"])
942 .output()
943 .expect("git status");
944 let status_output = String::from_utf8_lossy(&status.stdout);
945 assert!(
946 !status_output.contains("AGENTS.md"),
947 "AGENTS.md should not appear in git status, got: {status_output}"
948 );
949 }
950
951 #[test]
952 fn setup_worktree_root_missing() {
953 let repo = tempfile::tempdir().unwrap();
954 let wt = tempfile::tempdir().unwrap();
955 init_git_repo(wt.path());
956
957 let assignment = make_assignment(None, None);
958 setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
959
960 let result = fs::read_to_string(wt.path().join("AGENTS.md")).unwrap();
961 assert!(!result.contains("# Project Rules"));
962 assert!(result.contains("`feat/foo`"));
963 }
964
965 #[test]
966 fn setup_worktree_replaces_root_section() {
967 let repo = tempfile::tempdir().unwrap();
968 let wt = tempfile::tempdir().unwrap();
969 init_git_repo(wt.path());
970 let root_content =
971 format!("# Rules\n\n{START_MARKER}\nold root section\n{END_MARKER}\n\n## Footer\n");
972 fs::write(repo.path().join("AGENTS.md"), &root_content).unwrap();
973
974 let assignment = make_assignment(None, None);
975 setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
976
977 let result = fs::read_to_string(wt.path().join("AGENTS.md")).unwrap();
978 assert!(result.contains("# Rules"));
979 assert!(result.contains("## Footer"));
980 assert!(!result.contains("old root section"));
981 assert!(result.contains("`feat/foo`"));
982 assert_eq!(
983 result.matches(START_MARKER_PREFIX).count(),
984 1,
985 "should have exactly one git-paw section"
986 );
987 }
988
989 #[test]
994 fn setup_worktree_write_failure_returns_agents_md_error() {
995 use std::os::unix::fs::PermissionsExt;
996
997 let repo = tempfile::tempdir().unwrap();
998 let wt = tempfile::tempdir().unwrap();
999 init_git_repo(wt.path());
1000
1001 fs::set_permissions(wt.path(), fs::Permissions::from_mode(0o555)).unwrap();
1003
1004 let assignment = make_assignment(None, None);
1005 let result = setup_worktree_agents_md(repo.path(), wt.path(), &assignment);
1006
1007 fs::set_permissions(wt.path(), fs::Permissions::from_mode(0o755)).unwrap();
1009
1010 assert!(result.is_err(), "should fail when worktree is read-only");
1011 let err = result.unwrap_err();
1012 let msg = err.to_string();
1013 assert!(
1014 msg.contains("AGENTS.md error"),
1015 "should return AgentsMdError, got: {msg}"
1016 );
1017 }
1018
1019 #[test]
1024 fn exclude_creates_file_when_missing() {
1025 let wt = tempfile::tempdir().unwrap();
1026 fs::create_dir_all(wt.path().join(".git/info")).unwrap();
1027
1028 exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1029
1030 let content = fs::read_to_string(wt.path().join(".git/info/exclude")).unwrap();
1031 assert!(content.contains("AGENTS.md"));
1032 }
1033
1034 #[test]
1035 fn exclude_appends_when_not_present() {
1036 let wt = tempfile::tempdir().unwrap();
1037 let info = wt.path().join(".git/info");
1038 fs::create_dir_all(&info).unwrap();
1039 fs::write(info.join("exclude"), "*.log\n").unwrap();
1040
1041 exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1042
1043 let content = fs::read_to_string(info.join("exclude")).unwrap();
1044 assert!(content.contains("*.log"));
1045 assert!(content.contains("AGENTS.md"));
1046 }
1047
1048 #[test]
1049 fn exclude_no_duplicate() {
1050 let wt = tempfile::tempdir().unwrap();
1051 let info = wt.path().join(".git/info");
1052 fs::create_dir_all(&info).unwrap();
1053 fs::write(info.join("exclude"), "AGENTS.md\n").unwrap();
1054
1055 exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1056
1057 let content = fs::read_to_string(info.join("exclude")).unwrap();
1058 assert_eq!(content.matches("AGENTS.md").count(), 1);
1059 }
1060
1061 #[test]
1062 fn exclude_creates_info_dir() {
1063 let wt = tempfile::tempdir().unwrap();
1064 fs::create_dir_all(wt.path().join(".git")).unwrap();
1065 assert!(!wt.path().join(".git/info").exists());
1066
1067 exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1068
1069 assert!(wt.path().join(".git/info/exclude").exists());
1070 let content = fs::read_to_string(wt.path().join(".git/info/exclude")).unwrap();
1071 assert!(content.contains("AGENTS.md"));
1072 }
1073
1074 #[test]
1079 fn worktree_section_all_fields_with_skill() {
1080 let assignment = make_assignment_with_skill(
1081 Some("Implement the widget.\n"),
1082 Some(vec!["src/widget.rs", "tests/widget.rs"]),
1083 Some("## Coordination\nUse the broker at http://127.0.0.1:9119 as feat-foo.\n"),
1084 );
1085 let section = generate_worktree_section(&assignment);
1086 assert!(section.starts_with(START_MARKER));
1087 assert!(section.contains(END_MARKER));
1088 assert!(section.contains("`feat/foo`"));
1089 assert!(section.contains("claude"));
1090 assert!(section.contains("### Spec"));
1091 assert!(section.contains("Implement the widget."));
1092 assert!(section.contains("### File Ownership"));
1093 assert!(section.contains("`src/widget.rs`"));
1094 assert!(section.contains("## Coordination"));
1095 let ownership_pos = section.find("### File Ownership").unwrap();
1097 let skill_pos = section.find("## Coordination").unwrap();
1098 let end_pos = section.find(END_MARKER).unwrap();
1099 assert!(
1100 ownership_pos < skill_pos,
1101 "skill must come after file ownership"
1102 );
1103 assert!(skill_pos < end_pos, "skill must come before end marker");
1104 }
1105
1106 #[test]
1107 fn worktree_section_skill_without_spec_or_files() {
1108 let assignment = make_assignment_with_skill(
1109 None,
1110 None,
1111 Some("## Coordination\nBroker instructions here.\n"),
1112 );
1113 let section = generate_worktree_section(&assignment);
1114 assert!(section.contains("`feat/foo`"));
1115 assert!(section.contains("claude"));
1116 assert!(!section.contains("### Spec"));
1117 assert!(!section.contains("### File Ownership"));
1118 assert!(section.contains("## Coordination"));
1119 let assignment_pos = section.find("**CLI:**").unwrap();
1121 let skill_pos = section.find("## Coordination").unwrap();
1122 let end_pos = section.find(END_MARKER).unwrap();
1123 assert!(
1124 assignment_pos < skill_pos,
1125 "skill must come after assignment"
1126 );
1127 assert!(skill_pos < end_pos, "skill must come before end marker");
1128 }
1129
1130 #[test]
1131 fn worktree_section_none_skill_matches_v020() {
1132 let with_none =
1134 make_assignment_with_skill(Some("Do the thing.\n"), Some(vec!["src/main.rs"]), None);
1135 let without = make_assignment(Some("Do the thing.\n"), Some(vec!["src/main.rs"]));
1136 assert_eq!(
1137 generate_worktree_section(&with_none),
1138 generate_worktree_section(&without),
1139 "skill_content = None must produce identical output to v0.2.0"
1140 );
1141 }
1142
1143 #[test]
1144 fn worktree_section_skill_contains_slugified_branch() {
1145 let assignment = WorktreeAssignment {
1146 branch: "feat/http-broker".to_string(),
1147 cli: "claude".to_string(),
1148 spec_content: None,
1149 owned_files: None,
1150 skill_content: Some(
1151 "Agent ID: feat-http-broker\nURL: http://127.0.0.1:9119\n".to_string(),
1152 ),
1153 inter_agent_rules: None,
1154 };
1155 let section = generate_worktree_section(&assignment);
1156 assert!(
1157 section.contains("feat-http-broker"),
1158 "should contain slugified branch"
1159 );
1160 assert!(
1161 !section.contains("{{BRANCH_ID}}"),
1162 "should not contain literal template placeholder"
1163 );
1164 }
1165
1166 #[test]
1167 fn worktree_section_skill_preserves_broker_url_placeholder() {
1168 let assignment = make_assignment_with_skill(
1169 None,
1170 None,
1171 Some("Connect to http://127.0.0.1:9119/messages\n"),
1172 );
1173 let section = generate_worktree_section(&assignment);
1174 assert!(
1175 section.contains("http://127.0.0.1:9119"),
1176 "broker URL must be present"
1177 );
1178 }
1179
1180 #[test]
1185 fn worktree_section_with_inter_agent_rules() {
1186 let mut assignment = make_assignment(Some("Do the widget.\n"), Some(vec!["src/widget.rs"]));
1187 assignment.inter_agent_rules = Some("Stay in your lane.\nNever push.\n".to_string());
1188 let section = generate_worktree_section(&assignment);
1189 assert!(section.contains("## Inter-Agent Rules"));
1190 assert!(section.contains("Stay in your lane."));
1191 let rules_pos = section.find("## Inter-Agent Rules").unwrap();
1193 let end_pos = section.find(END_MARKER).unwrap();
1194 assert!(rules_pos < end_pos, "rules must come before end marker");
1195 }
1196
1197 #[test]
1198 fn worktree_section_without_inter_agent_rules_has_no_section() {
1199 let assignment = make_assignment(Some("Do the widget.\n"), Some(vec!["src/widget.rs"]));
1200 let section = generate_worktree_section(&assignment);
1201 assert!(!section.contains("## Inter-Agent Rules"));
1202 }
1203
1204 #[test]
1205 fn worktree_section_inter_agent_rules_none_matches_pre_change() {
1206 let baseline = make_assignment(Some("Do.\n"), Some(vec!["src/main.rs"]));
1208 let with_none = WorktreeAssignment {
1209 branch: baseline.branch.clone(),
1210 cli: baseline.cli.clone(),
1211 spec_content: baseline.spec_content.clone(),
1212 owned_files: baseline.owned_files.clone(),
1213 skill_content: None,
1214 inter_agent_rules: None,
1215 };
1216 assert_eq!(
1217 generate_worktree_section(&baseline),
1218 generate_worktree_section(&with_none),
1219 );
1220 }
1221
1222 #[test]
1227 fn build_inter_agent_rules_contains_file_ownership() {
1228 let rules = build_inter_agent_rules(&["feat/a", "feat/b"]);
1229 assert!(rules.contains("File ownership"));
1230 assert!(rules.contains("`feat/a`"));
1231 assert!(rules.contains("`feat/b`"));
1232 }
1233
1234 #[test]
1235 fn build_inter_agent_rules_contains_never_push() {
1236 let rules = build_inter_agent_rules(&["feat/a"]);
1237 assert!(rules.contains("MUST NOT `git push`"));
1238 }
1239
1240 #[test]
1241 fn build_inter_agent_rules_notes_automatic_status() {
1242 let rules = build_inter_agent_rules(&["feat/a"]);
1243 assert!(rules.contains("Status publishing is automatic"));
1244 assert!(rules.contains("post-commit"));
1245 }
1246
1247 #[test]
1248 fn build_inter_agent_rules_contains_match_spec() {
1249 let rules = build_inter_agent_rules(&["feat/a"]);
1250 assert!(
1251 rules
1252 .to_lowercase()
1253 .contains("match spec field names exactly")
1254 );
1255 }
1256
1257 #[test]
1258 fn build_inter_agent_rules_contains_cherry_pick_reference() {
1259 let rules = build_inter_agent_rules(&["feat/a"]);
1260 assert!(rules.to_lowercase().contains("cherry-pick"));
1261 }
1262
1263 #[test]
1268 fn embedded_coordination_contains_cherry_pick() {
1269 let content = include_str!("../assets/agent-skills/coordination.md");
1270 assert!(content.contains("git cherry-pick"));
1271 }
1272
1273 #[test]
1274 fn embedded_coordination_documents_automatic_status() {
1275 let content = include_str!("../assets/agent-skills/coordination.md");
1276 let lower = content.to_lowercase();
1277 assert!(lower.contains("automatic"));
1278 assert!(lower.contains("post-commit"));
1279 }
1280
1281 #[test]
1282 fn embedded_coordination_does_not_require_manual_status_publish() {
1283 let content = include_str!("../assets/agent-skills/coordination.md");
1284 assert!(!content.contains("MUST publish `agent.status`"));
1285 assert!(!content.contains("You MUST publish `agent.status`"));
1286 }
1287
1288 #[test]
1289 fn embedded_coordination_still_contains_optin_operations() {
1290 let content = include_str!("../assets/agent-skills/coordination.md");
1291 assert!(content.contains("agent.blocked"));
1292 assert!(content.contains("agent.artifact"));
1293 assert!(content.contains("{{GIT_PAW_BROKER_URL}}/messages/{{BRANCH_ID}}"));
1294 }
1295
1296 #[test]
1297 fn embedded_coordination_requires_no_push() {
1298 let content = include_str!("../assets/agent-skills/coordination.md");
1299 assert!(content.contains("MUST NOT push"));
1300 }
1301
1302 #[test]
1307 fn post_commit_dispatcher_hook_reads_marker_and_publishes() {
1308 let script = build_post_commit_dispatcher_hook();
1309 assert!(script.contains("$GIT_DIR/paw-agent-id"));
1310 assert!(script.contains(". \"$GIT_DIR/paw-agent-id\""));
1311 assert!(script.contains("$PAW_BROKER_URL/publish"));
1312 assert!(script.contains("$PAW_AGENT_ID"));
1313 assert!(script.contains("agent.artifact"));
1314 assert!(script.contains("|| true"));
1315 }
1316
1317 #[test]
1318 fn agent_marker_is_shell_sourceable() {
1319 let marker = build_agent_marker("http://127.0.0.1:9119", "feat-x", None, None, None);
1320 assert!(marker.contains("PAW_AGENT_ID=feat-x"));
1321 assert!(marker.contains("PAW_BROKER_URL=http://127.0.0.1:9119"));
1322 }
1323
1324 #[test]
1325 fn pre_push_hook_only_rejects_agent_worktrees() {
1326 let script = build_pre_push_hook();
1327 assert!(script.contains("exit 1"));
1329 assert!(script.contains("must not push"));
1330 assert!(
1333 script.contains("paw-agent-id"),
1334 "pre-push hook must gate the reject on $GIT_DIR/paw-agent-id; \
1335 without the gate, every push from this gitdir is blocked, \
1336 including legitimate pushes from the main repo"
1337 );
1338 }
1339
1340 #[test]
1341 fn chain_hook_replaces_existing_git_paw_block() {
1342 let existing = format!(
1343 "#!/bin/sh\n\
1344 # user hook\n\
1345 echo hi\n\
1346 {HOOK_START_MARKER}\n\
1347 old git-paw content\n\
1348 {HOOK_END_MARKER}\n"
1349 );
1350 let new_body = format!(
1351 "#!/bin/sh\n\
1352 {HOOK_START_MARKER}\n\
1353 new git-paw content\n\
1354 {HOOK_END_MARKER}\n"
1355 );
1356 let chained = chain_hook(&existing, &new_body);
1357 assert!(chained.contains("# user hook"));
1358 assert!(chained.contains("echo hi"));
1359 assert!(chained.contains("new git-paw content"));
1360 assert!(!chained.contains("old git-paw content"));
1361 }
1362
1363 #[test]
1364 fn chain_hook_appends_after_existing_content() {
1365 let existing = "#!/bin/sh\necho existing\n";
1366 let new_body = format!(
1367 "#!/bin/sh\n\
1368 {HOOK_START_MARKER}\n\
1369 new block\n\
1370 {HOOK_END_MARKER}\n"
1371 );
1372 let chained = chain_hook(existing, &new_body);
1373 assert!(chained.starts_with("#!/bin/sh\necho existing"));
1374 assert!(chained.contains("new block"));
1375 assert_eq!(chained.matches("#!/bin/sh").count(), 1);
1377 }
1378
1379 #[test]
1380 fn chain_hook_preserves_content_when_end_marker_missing() {
1381 let existing = format!(
1385 "#!/bin/sh\n\
1386 # important user logic\n\
1387 echo do_not_lose_me\n\
1388 {HOOK_START_MARKER}\n\
1389 leftover but no end marker\n"
1390 );
1391 let new_body = format!(
1392 "#!/bin/sh\n\
1393 {HOOK_START_MARKER}\n\
1394 new git-paw content\n\
1395 {HOOK_END_MARKER}\n"
1396 );
1397 let chained = chain_hook(&existing, &new_body);
1398 assert!(chained.contains("#!/bin/sh"));
1400 assert!(chained.contains("# important user logic"));
1401 assert!(chained.contains("echo do_not_lose_me"));
1402 assert!(chained.contains("leftover but no end marker"));
1403 assert!(chained.contains("new git-paw content"));
1405 assert!(chained.contains(HOOK_END_MARKER));
1406 assert_eq!(chained.matches("#!/bin/sh").count(), 1);
1408 }
1409
1410 #[test]
1411 #[serial_test::serial]
1412 fn install_git_hooks_writes_dispatcher_to_common_git_dir() {
1413 let tmp = tempfile::tempdir().unwrap();
1414 let worktree = tmp.path();
1415 init_git_repo(worktree);
1416
1417 install_git_hooks(worktree, "http://127.0.0.1:9119", "feat-x").unwrap();
1418
1419 let post_commit = worktree.join(".git").join("hooks").join("post-commit");
1420 let pre_push = worktree.join(".git").join("hooks").join("pre-push");
1421 let marker = worktree.join(".git").join("paw-agent-id");
1422
1423 assert!(post_commit.exists(), "post-commit should exist");
1424 assert!(pre_push.exists(), "pre-push should exist");
1425 assert!(marker.exists(), "paw-agent-id marker should exist");
1426
1427 let pc = fs::read_to_string(&post_commit).unwrap();
1428 assert!(pc.contains("$GIT_DIR/paw-agent-id"));
1429 assert!(pc.contains("agent.artifact"));
1430
1431 let marker_body = fs::read_to_string(&marker).unwrap();
1432 assert!(marker_body.contains("PAW_AGENT_ID=feat-x"));
1433 assert!(marker_body.contains("PAW_BROKER_URL=http://127.0.0.1:9119"));
1434
1435 #[cfg(unix)]
1436 {
1437 use std::os::unix::fs::PermissionsExt;
1438 let mode = fs::metadata(&post_commit).unwrap().permissions().mode();
1439 assert_eq!(mode & 0o111, 0o111, "post-commit must be executable");
1440 }
1441 }
1442
1443 #[test]
1444 #[serial_test::serial]
1445 fn install_git_hooks_preserves_existing_dispatcher_body() {
1446 let tmp = tempfile::tempdir().unwrap();
1447 let worktree = tmp.path();
1448 init_git_repo(worktree);
1449 let hook_path = worktree.join(".git").join("hooks").join("post-commit");
1450 fs::write(&hook_path, "#!/bin/sh\necho user hook\n").unwrap();
1451
1452 install_git_hooks(worktree, "http://127.0.0.1:9119", "feat-x").unwrap();
1453
1454 let body = fs::read_to_string(&hook_path).unwrap();
1455 assert!(body.contains("echo user hook"));
1456 assert!(body.contains("agent.artifact"));
1457 }
1458
1459 #[test]
1460 #[serial_test::serial]
1461 fn install_git_hooks_writes_linked_marker_for_linked_worktree() {
1462 let tmp = tempfile::tempdir().unwrap();
1463 let main_repo = tmp.path().join("main");
1464 fs::create_dir_all(&main_repo).unwrap();
1465 init_git_repo(&main_repo);
1466
1467 std::process::Command::new("git")
1469 .args(["commit", "--allow-empty", "-m", "root", "-q"])
1470 .current_dir(&main_repo)
1471 .output()
1472 .unwrap();
1473
1474 let linked_path = tmp.path().join("linked");
1476 std::process::Command::new("git")
1477 .args([
1478 "worktree",
1479 "add",
1480 "-b",
1481 "feat-x",
1482 linked_path.to_str().unwrap(),
1483 ])
1484 .current_dir(&main_repo)
1485 .output()
1486 .unwrap();
1487
1488 install_git_hooks(&linked_path, "http://127.0.0.1:9119", "feat-x").unwrap();
1489
1490 let post_commit = main_repo.join(".git").join("hooks").join("post-commit");
1492 assert!(
1493 post_commit.exists(),
1494 "dispatcher must land in main .git/hooks/"
1495 );
1496 let marker = main_repo
1498 .join(".git")
1499 .join("worktrees")
1500 .join("linked")
1501 .join("paw-agent-id");
1502 assert!(
1503 marker.exists(),
1504 "marker must land in linked worktree gitdir"
1505 );
1506 let body = fs::read_to_string(&marker).unwrap();
1507 assert!(body.contains("PAW_AGENT_ID=feat-x"));
1508 }
1509
1510 #[test]
1515 fn build_agent_marker_basic_format() {
1516 let marker = build_agent_marker("http://127.0.0.1:9119", "feat-test", None, None, None);
1517
1518 assert!(marker.contains("PAW_AGENT_ID=feat-test"));
1519 assert!(marker.contains("PAW_BROKER_URL=http://127.0.0.1:9119"));
1520 assert!(marker.contains("PAW_TIMESTAMP="));
1521 assert!(!marker.contains("PAW_SUPERVISOR_PID"));
1523 assert!(!marker.contains("PAW_LAST_VERIFIED_COMMIT"));
1524 assert!(!marker.contains("PAW_SESSION_NAME"));
1525 }
1526
1527 #[test]
1528 fn build_agent_marker_with_all_extended_fields() {
1529 let marker = build_agent_marker(
1530 "http://localhost:9119",
1531 "feat-errors",
1532 Some(12345),
1533 Some("abc123def456"),
1534 Some("paw-test-session"),
1535 );
1536
1537 assert!(marker.contains("PAW_AGENT_ID=feat-errors"));
1538 assert!(marker.contains("PAW_BROKER_URL=http://localhost:9119"));
1539 assert!(marker.contains("PAW_SUPERVISOR_PID=12345"));
1540 assert!(marker.contains("PAW_LAST_VERIFIED_COMMIT=abc123def456"));
1541 assert!(marker.contains("PAW_SESSION_NAME=paw-test-session"));
1542 assert!(marker.contains("PAW_TIMESTAMP="));
1543 }
1544
1545 #[test]
1546 fn build_agent_marker_partial_extended_fields() {
1547 let marker =
1548 build_agent_marker("http://localhost:9119", "fix-cycle", Some(999), None, None);
1549
1550 assert!(marker.contains("PAW_SUPERVISOR_PID=999"));
1551 assert!(!marker.contains("PAW_LAST_VERIFIED_COMMIT"));
1552 assert!(!marker.contains("PAW_SESSION_NAME"));
1553 }
1554
1555 #[test]
1556 fn update_agent_marker_adds_missing_fields() {
1557 let tmp = tempfile::tempdir().unwrap();
1558 let marker_path = tmp.path().join("test-marker");
1559
1560 let initial = "PAW_AGENT_ID=test\nPAW_BROKER_URL=http://localhost:9119\nPAW_TIMESTAMP=2026-01-01T00:00:00Z\n";
1562 fs::write(&marker_path, initial).unwrap();
1563
1564 update_agent_marker(&marker_path, Some(54321), None).unwrap();
1566
1567 let updated = fs::read_to_string(&marker_path).unwrap();
1568 assert!(updated.contains("PAW_AGENT_ID=test"));
1569 assert!(updated.contains("PAW_SUPERVISOR_PID=54321"));
1570 }
1571
1572 #[test]
1573 fn update_agent_marker_replaces_existing_fields() {
1574 let tmp = tempfile::tempdir().unwrap();
1575 let marker_path = tmp.path().join("test-marker");
1576
1577 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";
1579 fs::write(&marker_path, initial).unwrap();
1580
1581 update_agent_marker(&marker_path, None, Some("new456")).unwrap();
1583
1584 let updated = fs::read_to_string(&marker_path).unwrap();
1585 assert!(updated.contains("PAW_AGENT_ID=test"));
1586 assert!(updated.contains("PAW_LAST_VERIFIED_COMMIT=new456"));
1587 assert!(!updated.contains("PAW_LAST_VERIFIED_COMMIT=old123"));
1588 }
1589
1590 #[test]
1591 fn get_agent_marker_path_returns_correct_path() {
1592 let tmp = tempfile::tempdir().unwrap();
1593 let worktree = tmp.path();
1594 init_git_repo(worktree);
1595
1596 let marker_path = get_agent_marker_path(worktree).unwrap();
1597 assert!(marker_path.ends_with(".git/paw-agent-id"));
1598 }
1599}