1use std::fmt::Write;
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::sync::LazyLock;
11
12use crate::error::PawError;
13use crate::git::{exclude_from_git, no_assume_unchanged};
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 const SIDECAR_REL_PATH: &str = ".git-paw/AGENTS.local.md";
245
246pub fn setup_worktree_agents_md(
270 repo_root: &Path,
271 worktree_root: &Path,
272 assignment: &WorktreeAssignment,
273) -> Result<(), PawError> {
274 let root_agents = repo_root.join("AGENTS.md");
275 let root_content = match fs::read_to_string(&root_agents) {
276 Ok(c) => c,
277 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
278 Err(e) => {
279 return Err(PawError::AgentsMdError(format!(
280 "failed to read '{}': {e}",
281 root_agents.display()
282 )));
283 }
284 };
285
286 let section = generate_worktree_section(assignment);
287 let output = inject_into_content(&root_content, §ion);
288
289 let sidecar = worktree_root.join(SIDECAR_REL_PATH);
292 if let Some(parent) = sidecar.parent() {
293 fs::create_dir_all(parent).map_err(|e| {
294 PawError::AgentsMdError(format!("failed to create '{}': {e}", parent.display()))
295 })?;
296 }
297 fs::write(&sidecar, &output).map_err(|e| {
298 PawError::AgentsMdError(format!("failed to write '{}': {e}", sidecar.display()))
299 })?;
300
301 let _ = no_assume_unchanged(worktree_root, "AGENTS.md");
306
307 exclude_from_git(worktree_root, SIDECAR_REL_PATH)?;
312
313 Ok(())
314}
315
316pub fn get_agent_marker_path(worktree: &Path) -> Result<PathBuf, PawError> {
318 let linked_git_dir = git_rev_parse_path(worktree, "--git-dir")?;
319 Ok(linked_git_dir.join("paw-agent-id"))
320}
321
322pub fn build_agent_marker(
338 broker_url: &str,
339 agent_id: &str,
340 supervisor_pid: Option<u32>,
341 last_verified_commit: Option<&str>,
342 session_name: Option<&str>,
343) -> String {
344 let mut marker = format!("PAW_AGENT_ID={agent_id}\nPAW_BROKER_URL={broker_url}\n");
345
346 if let Some(pid) = supervisor_pid {
348 let _ = writeln!(marker, "PAW_SUPERVISOR_PID={pid}");
349 }
350 if let Some(commit) = last_verified_commit {
351 let _ = writeln!(marker, "PAW_LAST_VERIFIED_COMMIT={commit}");
352 }
353 if let Some(session) = session_name {
354 let _ = writeln!(marker, "PAW_SESSION_NAME={session}");
355 }
356
357 let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
359 let _ = writeln!(marker, "PAW_TIMESTAMP={timestamp}");
360
361 marker
362}
363
364pub fn update_agent_marker(
368 marker_path: &Path,
369 supervisor_pid: Option<u32>,
370 last_verified_commit: Option<&str>,
371) -> Result<(), PawError> {
372 let content = fs::read_to_string(marker_path)
373 .map_err(|e| PawError::AgentsMdError(format!("failed to read marker file: {e}")))?;
374
375 let mut updated = content;
376
377 if let Some(pid) = supervisor_pid {
379 if updated.contains("PAW_SUPERVISOR_PID=") {
380 updated = SUPERVISOR_PID_REGEX
382 .replace(&updated, &format!("PAW_SUPERVISOR_PID={pid}"))
383 .to_string();
384 } else {
385 let _ = write!(updated, "\nPAW_SUPERVISOR_PID={pid}");
387 }
388 }
389
390 if let Some(commit) = last_verified_commit {
392 if updated.contains("PAW_LAST_VERIFIED_COMMIT=") {
393 updated = LAST_VERIFIED_COMMIT_REGEX
395 .replace(&updated, &format!("PAW_LAST_VERIFIED_COMMIT={commit}"))
396 .to_string();
397 } else {
398 let _ = write!(updated, "\nPAW_LAST_VERIFIED_COMMIT={commit}");
400 }
401 }
402
403 fs::write(marker_path, updated)
404 .map_err(|e| PawError::AgentsMdError(format!("failed to update marker file: {e}")))?;
405
406 Ok(())
407}
408
409fn build_post_commit_dispatcher_hook() -> String {
420 format!(
421 "#!/bin/sh\n\
422 {HOOK_START_MARKER}\n\
423 # Dispatcher: reads the per-worktree paw-agent-id marker and publishes\n\
424 # agent.artifact to the git-paw broker. Resolve the gitdir via\n\
425 # rev-parse with a GIT_DIR fallback (git does not always export it).\n\
426 PAW_GD=\"${{GIT_DIR:-$(git rev-parse --git-dir 2>/dev/null)}}\"\n\
427 if [ -n \"$PAW_GD\" ] && [ -f \"$PAW_GD/paw-agent-id\" ]; then\n\
428 . \"$PAW_GD/paw-agent-id\"\n\
429 FILES=$(git diff HEAD~1 --name-only 2>/dev/null | awk '{{printf \"%s\\\"%s\\\"\", (NR>1?\",\":\"\"), $0}}')\n\
430 curl -s -X POST \"$PAW_BROKER_URL/publish\" \\\n\
431 -H 'Content-Type: application/json' \\\n\
432 -d \"{{\\\"type\\\":\\\"agent.artifact\\\",\\\"agent_id\\\":\\\"$PAW_AGENT_ID\\\",\\\"payload\\\":{{\\\"status\\\":\\\"committed\\\",\\\"exports\\\":[],\\\"modified_files\\\":[$FILES]}}}}\" \\\n\
433 >/dev/null 2>&1 || true\n\
434 # Branch-mismatch detection (detection without enforcement — fires\n\
435 # regardless of PAW_STRICT_BRANCH_GUARD; the pre-commit hook owns\n\
436 # blocking). Publishes agent.feedback + an agent.learning record\n\
437 # (category permission_pattern) identifying the contamination.\n\
438 if [ -n \"$PAW_EXPECTED_BRANCH\" ]; then\n\
439 PAW_CUR=$(git symbolic-ref --short HEAD 2>/dev/null)\n\
440 if [ -n \"$PAW_CUR\" ] && [ \"$PAW_CUR\" != \"$PAW_EXPECTED_BRANCH\" ]; then\n\
441 PAW_SHA=$(git rev-parse HEAD 2>/dev/null)\n\
442 curl -s -X POST \"$PAW_BROKER_URL/publish\" \\\n\
443 -H 'Content-Type: application/json' \\\n\
444 -d \"{{\\\"type\\\":\\\"agent.feedback\\\",\\\"agent_id\\\":\\\"$PAW_AGENT_ID\\\",\\\"payload\\\":{{\\\"from\\\":\\\"branch-guard\\\",\\\"errors\\\":[\\\"commit $PAW_SHA advanced '$PAW_CUR' but this worktree is for '$PAW_EXPECTED_BRANCH'; cherry-pick onto '$PAW_EXPECTED_BRANCH' and reset '$PAW_CUR'\\\"]}}}}\" \\\n\
445 >/dev/null 2>&1 || true\n\
446 curl -s -X POST \"$PAW_BROKER_URL/publish\" \\\n\
447 -H 'Content-Type: application/json' \\\n\
448 -d \"{{\\\"type\\\":\\\"agent.learning\\\",\\\"agent_id\\\":\\\"$PAW_AGENT_ID\\\",\\\"payload\\\":{{\\\"category\\\":\\\"permission_pattern\\\",\\\"body\\\":\\\"cross-worktree contamination: commit $PAW_SHA landed on '$PAW_CUR' instead of expected '$PAW_EXPECTED_BRANCH'\\\"}}}}\" \\\n\
449 >/dev/null 2>&1 || true\n\
450 fi\n\
451 fi\n\
452 fi\n\
453 {HOOK_END_MARKER}\n"
454 )
455}
456
457fn build_pre_push_hook() -> String {
458 format!(
465 "#!/bin/sh\n\
466 {HOOK_START_MARKER}\n\
467 if [ -n \"$GIT_DIR\" ] && [ -f \"$GIT_DIR/paw-agent-id\" ]; then\n\
468 echo 'error: git-paw agents must not push. The supervisor handles merges.' >&2\n\
469 exit 1\n\
470 fi\n\
471 {HOOK_END_MARKER}\n"
472 )
473}
474
475fn build_pre_commit_branch_guard_hook() -> String {
487 format!(
488 "#!/bin/sh\n\
489 {HOOK_START_MARKER}\n\
490 # Branch guard: refuse a commit that would advance a branch other than\n\
491 # the one this worktree was created for (cross-worktree contamination).\n\
492 # git does not reliably export GIT_DIR to pre-commit, so resolve the\n\
493 # per-worktree gitdir via rev-parse with a GIT_DIR fallback.\n\
494 PAW_GD=\"${{GIT_DIR:-$(git rev-parse --git-dir 2>/dev/null)}}\"\n\
495 if [ -n \"$PAW_GD\" ] && [ -f \"$PAW_GD/paw-agent-id\" ]; then\n\
496 . \"$PAW_GD/paw-agent-id\"\n\
497 if [ -n \"$PAW_EXPECTED_BRANCH\" ] && [ \"$PAW_STRICT_BRANCH_GUARD\" != \"false\" ]; then\n\
498 PAW_CUR=$(git symbolic-ref --short HEAD 2>/dev/null)\n\
499 if [ -n \"$PAW_CUR\" ] && [ \"$PAW_CUR\" != \"$PAW_EXPECTED_BRANCH\" ]; then\n\
500 echo \"error: git-paw branch guard refused this commit\" >&2\n\
501 echo \" HEAD is on '$PAW_CUR' but this worktree is for '$PAW_EXPECTED_BRANCH'.\" >&2\n\
502 echo \" The commit would advance the wrong branch. Switch back to '$PAW_EXPECTED_BRANCH'\" >&2\n\
503 echo \" (or set [supervisor] strict_branch_guard = false to override).\" >&2\n\
504 exit 1\n\
505 fi\n\
506 fi\n\
507 fi\n\
508 {HOOK_END_MARKER}\n"
509 )
510}
511
512fn chain_hook(existing: &str, new_body: &str) -> String {
520 if let Some(start) = existing.find(HOOK_START_MARKER)
525 && let Some(end_rel) = existing[start..].find(HOOK_END_MARKER)
526 {
527 let end = start + end_rel + HOOK_END_MARKER.len();
528 let mut out = String::with_capacity(existing.len() + new_body.len());
529 out.push_str(&existing[..start]);
530 let stripped = new_body.strip_prefix("#!/bin/sh\n").unwrap_or(new_body);
533 out.push_str(stripped);
534 out.push_str(&existing[end..]);
535 return out;
536 }
537 let mut out = existing.trim_end().to_string();
538 if !out.is_empty() {
539 out.push('\n');
540 }
541 let stripped = if out.is_empty() {
542 new_body.to_string()
543 } else {
544 new_body
545 .strip_prefix("#!/bin/sh\n")
546 .unwrap_or(new_body)
547 .to_string()
548 };
549 out.push_str(&stripped);
550 out
551}
552
553fn write_hook_file(hook_path: &Path, new_body: &str) -> Result<(), PawError> {
554 let existing = match fs::read_to_string(hook_path) {
555 Ok(c) => c,
556 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
557 Err(e) => {
558 return Err(PawError::AgentsMdError(format!(
559 "failed to read '{}': {e}",
560 hook_path.display()
561 )));
562 }
563 };
564
565 let content = if existing.is_empty() {
566 new_body.to_string()
567 } else {
568 chain_hook(&existing, new_body)
569 };
570
571 if let Some(parent) = hook_path.parent() {
572 fs::create_dir_all(parent).map_err(|e| {
573 PawError::AgentsMdError(format!("failed to create '{}': {e}", parent.display()))
574 })?;
575 }
576
577 fs::write(hook_path, content.as_bytes()).map_err(|e| {
578 PawError::AgentsMdError(format!("failed to write '{}': {e}", hook_path.display()))
579 })?;
580
581 #[cfg(unix)]
582 {
583 use std::os::unix::fs::PermissionsExt;
584 let mut perms = fs::metadata(hook_path)
585 .map_err(|e| {
586 PawError::AgentsMdError(format!("failed to stat '{}': {e}", hook_path.display()))
587 })?
588 .permissions();
589 perms.set_mode(0o755);
590 fs::set_permissions(hook_path, perms).map_err(|e| {
591 PawError::AgentsMdError(format!("failed to chmod '{}': {e}", hook_path.display()))
592 })?;
593 }
594
595 Ok(())
596}
597
598fn git_rev_parse_path(worktree: &Path, flag: &str) -> Result<PathBuf, PawError> {
604 let output = std::process::Command::new("git")
605 .current_dir(worktree)
606 .args(["rev-parse", flag])
607 .output()
608 .map_err(|e| PawError::AgentsMdError(format!("failed to run git rev-parse {flag}: {e}")))?;
609 if !output.status.success() {
610 let stderr = String::from_utf8_lossy(&output.stderr);
611 return Err(PawError::AgentsMdError(format!(
612 "git rev-parse {flag} failed in '{}': {stderr}",
613 worktree.display()
614 )));
615 }
616 let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
617 let path = PathBuf::from(&raw);
618 if path.is_absolute() {
619 Ok(path)
620 } else {
621 Ok(worktree.join(path))
622 }
623}
624
625pub fn install_git_hooks(
642 worktree: &Path,
643 broker_url: &str,
644 agent_id: &str,
645 expected_branch: &str,
646 strict_branch_guard: bool,
647) -> Result<(), PawError> {
648 let common_git_dir = git_rev_parse_path(worktree, "--git-common-dir")?;
649 let linked_git_dir = git_rev_parse_path(worktree, "--git-dir")?;
650 let hooks_dir = common_git_dir.join("hooks");
651
652 write_hook_file(
653 &hooks_dir.join("post-commit"),
654 &build_post_commit_dispatcher_hook(),
655 )?;
656 write_hook_file(&hooks_dir.join("pre-push"), &build_pre_push_hook())?;
657 write_hook_file(
658 &hooks_dir.join("pre-commit"),
659 &build_pre_commit_branch_guard_hook(),
660 )?;
661
662 let marker_path = linked_git_dir.join("paw-agent-id");
663 if let Some(parent) = marker_path.parent() {
664 fs::create_dir_all(parent).map_err(|e| {
665 PawError::AgentsMdError(format!("failed to create '{}': {e}", parent.display()))
666 })?;
667 }
668 let mut marker = build_agent_marker(broker_url, agent_id, None, None, None);
673 let _ = writeln!(marker, "PAW_EXPECTED_BRANCH={expected_branch}");
674 let _ = writeln!(
675 marker,
676 "PAW_STRICT_BRANCH_GUARD={}",
677 if strict_branch_guard { "true" } else { "false" }
678 );
679 fs::write(&marker_path, marker).map_err(|e| {
680 PawError::AgentsMdError(format!("failed to write '{}': {e}", marker_path.display()))
681 })?;
682
683 Ok(())
684}
685
686pub fn inject_section_into_file(path: &Path, section: &str) -> Result<(), PawError> {
687 let content = match fs::read_to_string(path) {
688 Ok(c) => c,
689 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
690 Err(e) => {
691 return Err(PawError::AgentsMdError(format!(
692 "failed to read '{}': {e}",
693 path.display()
694 )));
695 }
696 };
697
698 let output = inject_into_content(&content, section);
699
700 fs::write(path, &output)
701 .map_err(|e| PawError::AgentsMdError(format!("failed to write '{}': {e}", path.display())))
702}
703
704pub fn remove_git_paw_section(content: &str) -> String {
715 let lines: Vec<&str> = content.lines().collect();
716
717 let Some(start_idx) = lines
718 .iter()
719 .position(|l| l.starts_with(START_MARKER_PREFIX))
720 else {
721 return content.to_string();
723 };
724
725 let end_idx = lines[start_idx..]
726 .iter()
727 .position(|l| l.contains(END_MARKER))
728 .map(|rel| start_idx + rel);
729
730 let delete_start = start_idx;
732 let delete_end_exclusive = end_idx.map_or(lines.len(), |e| e + 1);
733
734 let mut delete_end = delete_end_exclusive;
740 let mut adjusted_start = delete_start;
741 if delete_end < lines.len() && lines[delete_end].is_empty() {
742 delete_end += 1;
743 } else if adjusted_start > 0 && lines[adjusted_start - 1].is_empty() {
744 adjusted_start -= 1;
745 }
746 let delete_start = adjusted_start;
747
748 let mut result = String::new();
749 for line in &lines[..delete_start] {
750 result.push_str(line);
751 result.push('\n');
752 }
753 for line in &lines[delete_end..] {
754 result.push_str(line);
755 result.push('\n');
756 }
757
758 if content.ends_with('\n') && !result.ends_with('\n') && !result.is_empty() {
761 result.push('\n');
762 }
763
764 if !content.ends_with('\n') && result.ends_with('\n') {
767 result.pop();
768 }
769
770 result
771}
772
773pub fn remove_session_boot_block(repo_root: &Path) -> Result<(), PawError> {
782 let agents_md = repo_root.join("AGENTS.md");
783 let content = match fs::read_to_string(&agents_md) {
784 Ok(c) => c,
785 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
786 Err(e) => {
787 return Err(PawError::AgentsMdError(format!(
788 "failed to read '{}': {e}",
789 agents_md.display()
790 )));
791 }
792 };
793
794 let new_content = remove_git_paw_section(&content);
795 if new_content == content {
796 return Ok(());
798 }
799
800 fs::write(&agents_md, &new_content).map_err(|e| {
801 PawError::AgentsMdError(format!("failed to write '{}': {e}", agents_md.display()))
802 })
803}
804
805#[cfg(test)]
806mod tests {
807 use super::*;
808
809 fn sample_section() -> String {
811 format!("{START_MARKER}\n## git-paw test section\n{END_MARKER}\n")
812 }
813
814 #[test]
819 fn has_section_returns_true_when_marker_present() {
820 let content = "# My Project\n\n<!-- git-paw:start — managed by git-paw, do not edit manually -->\nstuff\n<!-- git-paw:end -->\n";
821 assert!(has_git_paw_section(content));
822 }
823
824 #[test]
825 fn has_section_returns_false_without_marker() {
826 let content = "# My Project\n\nSome instructions.\n";
827 assert!(!has_git_paw_section(content));
828 }
829
830 #[test]
831 fn has_section_returns_false_for_empty() {
832 assert!(!has_git_paw_section(""));
833 }
834
835 #[test]
840 fn generated_section_has_markers() {
841 let section = sample_section();
842 assert!(section.starts_with(START_MARKER));
843 assert!(section.contains(END_MARKER));
844 }
845
846 #[test]
847 fn sample_section_contains_git_paw_reference() {
848 let section = sample_section();
849 assert!(section.contains("git-paw"));
850 }
851
852 #[test]
857 fn replace_with_both_markers_preserves_surrounding() {
858 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";
859 let new_section = "<!-- git-paw:start — managed by git-paw, do not edit manually -->\nnew content\n<!-- git-paw:end -->\n";
860 let result = replace_git_paw_section(content, new_section);
861 assert!(result.contains("# Title"));
862 assert!(result.contains("new content"));
863 assert!(!result.contains("old content"));
864 assert!(result.contains("## Footer"));
865 }
866
867 #[test]
868 fn replace_with_missing_end_marker_replaces_to_eof() {
869 let content = "# Title\n\n<!-- git-paw:start — managed by git-paw, do not edit manually -->\nold content that never ends\n";
870 let new_section = "<!-- git-paw:start — managed by git-paw, do not edit manually -->\nfixed\n<!-- git-paw:end -->\n";
871 let result = replace_git_paw_section(content, new_section);
872 assert!(result.contains("# Title"));
873 assert!(result.contains("fixed"));
874 assert!(!result.contains("old content"));
875 }
876
877 #[test]
882 fn inject_appends_when_no_existing_section() {
883 let content = "# My Project\n\nSome info.\n";
884 let section = sample_section();
885 let result = inject_into_content(content, §ion);
886 assert!(result.starts_with("# My Project"));
887 assert!(result.contains(START_MARKER));
888 }
889
890 #[test]
891 fn inject_replaces_existing_section() {
892 let old_section = format!("{START_MARKER}\nold\n{END_MARKER}\n");
893 let content = format!("# Title\n\n{old_section}\n## Footer\n");
894 let new_section = format!("{START_MARKER}\nnew\n{END_MARKER}\n");
895 let result = inject_into_content(&content, &new_section);
896 assert!(result.contains("new"));
897 assert!(!result.contains("old"));
898 assert!(result.contains("## Footer"));
899 }
900
901 #[test]
902 fn inject_into_empty_content_returns_section_only() {
903 let section = sample_section();
904 let result = inject_into_content("", §ion);
905 assert_eq!(result, section);
906 }
907
908 #[test]
913 fn spacing_with_trailing_newline() {
914 let content = "# Title\n";
915 let section = "<!-- git-paw:start -->\n<!-- git-paw:end -->\n";
916 let result = inject_into_content(content, section);
917 assert!(result.contains("# Title\n\n<!-- git-paw:start"));
919 }
920
921 #[test]
922 fn spacing_without_trailing_newline() {
923 let content = "# Title";
924 let section = "<!-- git-paw:start -->\n<!-- git-paw:end -->\n";
925 let result = inject_into_content(content, section);
926 assert!(result.contains("# Title\n\n<!-- git-paw:start"));
928 }
929
930 #[test]
935 fn file_inject_appends_to_existing() {
936 let dir = tempfile::tempdir().unwrap();
937 let path = dir.path().join("AGENTS.md");
938 fs::write(&path, "# Existing\n").unwrap();
939
940 let section = sample_section();
941 inject_section_into_file(&path, §ion).unwrap();
942
943 let result = fs::read_to_string(&path).unwrap();
944 assert!(result.contains("# Existing"));
945 assert!(result.contains(START_MARKER));
946 }
947
948 #[test]
949 fn file_inject_replaces_existing_section() {
950 let dir = tempfile::tempdir().unwrap();
951 let path = dir.path().join("AGENTS.md");
952 let initial = format!("# Title\n\n{START_MARKER}\nold\n{END_MARKER}\n");
953 fs::write(&path, &initial).unwrap();
954
955 let new_section = sample_section();
956 inject_section_into_file(&path, &new_section).unwrap();
957
958 let result = fs::read_to_string(&path).unwrap();
959 assert!(result.contains("# Title"));
960 assert!(!result.contains("\nold\n"));
961 assert!(result.contains("git-paw test section"));
962 }
963
964 #[test]
965 fn file_inject_creates_missing_file() {
966 let dir = tempfile::tempdir().unwrap();
967 let path = dir.path().join("AGENTS.md");
968 assert!(!path.exists());
969
970 let section = sample_section();
971 inject_section_into_file(&path, §ion).unwrap();
972
973 let result = fs::read_to_string(&path).unwrap();
974 assert!(result.contains(START_MARKER));
975 }
976
977 #[test]
978 fn file_inject_readonly_returns_error() {
979 use std::os::unix::fs::PermissionsExt;
980
981 let dir = tempfile::tempdir().unwrap();
982 let path = dir.path().join("AGENTS.md");
983 fs::write(&path, "content").unwrap();
984 fs::set_permissions(&path, fs::Permissions::from_mode(0o444)).unwrap();
985
986 let section = sample_section();
987 let result = inject_section_into_file(&path, §ion);
988 assert!(result.is_err());
989 let err = result.unwrap_err();
990 let msg = err.to_string();
991 assert!(msg.contains("AGENTS.md error"), "got: {msg}");
992 assert!(
993 msg.contains("AGENTS.md"),
994 "should mention file path, got: {msg}"
995 );
996
997 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
999 }
1000
1001 fn make_assignment(spec: Option<&str>, files: Option<Vec<&str>>) -> WorktreeAssignment {
1006 WorktreeAssignment {
1007 branch: "feat/foo".to_string(),
1008 cli: "claude".to_string(),
1009 spec_content: spec.map(ToString::to_string),
1010 owned_files: files.map(|v| v.into_iter().map(ToString::to_string).collect()),
1011 skill_content: None,
1012 inter_agent_rules: None,
1013 }
1014 }
1015
1016 fn make_assignment_with_skill(
1017 spec: Option<&str>,
1018 files: Option<Vec<&str>>,
1019 skill: Option<&str>,
1020 ) -> WorktreeAssignment {
1021 WorktreeAssignment {
1022 branch: "feat/foo".to_string(),
1023 cli: "claude".to_string(),
1024 spec_content: spec.map(ToString::to_string),
1025 owned_files: files.map(|v| v.into_iter().map(ToString::to_string).collect()),
1026 skill_content: skill.map(ToString::to_string),
1027 inter_agent_rules: None,
1028 }
1029 }
1030
1031 #[test]
1032 fn worktree_section_all_fields() {
1033 let assignment = make_assignment(
1034 Some("Implement the widget.\n"),
1035 Some(vec!["src/widget.rs", "tests/widget.rs"]),
1036 );
1037 let section = generate_worktree_section(&assignment);
1038 assert!(section.starts_with(START_MARKER));
1039 assert!(section.contains(END_MARKER));
1040 assert!(section.contains("`feat/foo`"));
1041 assert!(section.contains("claude"));
1042 assert!(section.contains("### Spec"));
1043 assert!(section.contains("Implement the widget."));
1044 assert!(section.contains("### File Ownership"));
1045 assert!(section.contains("`src/widget.rs`"));
1046 assert!(section.contains("`tests/widget.rs`"));
1047 }
1048
1049 #[test]
1050 fn worktree_section_no_spec() {
1051 let assignment = make_assignment(None, Some(vec!["src/main.rs"]));
1052 let section = generate_worktree_section(&assignment);
1053 assert!(section.contains("`feat/foo`"));
1054 assert!(!section.contains("### Spec"));
1055 assert!(section.contains("### File Ownership"));
1056 }
1057
1058 #[test]
1059 fn worktree_section_no_files() {
1060 let assignment = make_assignment(Some("Do the thing.\n"), None);
1061 let section = generate_worktree_section(&assignment);
1062 assert!(section.contains("### Spec"));
1063 assert!(!section.contains("### File Ownership"));
1064 }
1065
1066 #[test]
1067 fn worktree_section_minimal() {
1068 let assignment = make_assignment(None, None);
1069 let section = generate_worktree_section(&assignment);
1070 assert!(section.starts_with(START_MARKER));
1071 assert!(section.contains(END_MARKER));
1072 assert!(section.contains("`feat/foo`"));
1073 assert!(section.contains("claude"));
1074 assert!(!section.contains("### Spec"));
1075 assert!(!section.contains("### File Ownership"));
1076 }
1077
1078 fn init_git_repo(dir: &Path) {
1087 use std::process::Command;
1088 let git = which::which("git").expect("git must be on PATH");
1089 Command::new(&git)
1090 .current_dir(dir)
1091 .args(["init"])
1092 .output()
1093 .expect("git init");
1094 Command::new(&git)
1095 .current_dir(dir)
1096 .args(["config", "user.email", "test@test.com"])
1097 .output()
1098 .expect("git config email");
1099 Command::new(&git)
1100 .current_dir(dir)
1101 .args(["config", "user.name", "Test"])
1102 .output()
1103 .expect("git config name");
1104 fs::write(dir.join("README.md"), "# test\n").unwrap();
1106 Command::new(&git)
1107 .current_dir(dir)
1108 .args(["add", "README.md"])
1109 .output()
1110 .expect("git add");
1111 Command::new(&git)
1112 .current_dir(dir)
1113 .args(["commit", "-m", "init"])
1114 .output()
1115 .expect("git commit");
1116 }
1117
1118 fn sidecar_path(wt: &Path) -> PathBuf {
1120 wt.join(SIDECAR_REL_PATH)
1121 }
1122
1123 fn agents_md_ls_files_flag(wt: &Path) -> char {
1127 let out = std::process::Command::new("git")
1128 .current_dir(wt)
1129 .args(["ls-files", "-v", "AGENTS.md"])
1130 .output()
1131 .expect("git ls-files -v");
1132 let stdout = String::from_utf8_lossy(&out.stdout);
1133 stdout
1134 .lines()
1135 .next()
1136 .and_then(|l| l.chars().next())
1137 .unwrap_or('?')
1138 }
1139
1140 fn commit_tracked_agents_md(wt: &Path, body: &str) {
1143 fs::write(wt.join("AGENTS.md"), body).unwrap();
1144 std::process::Command::new("git")
1145 .current_dir(wt)
1146 .args(["add", "AGENTS.md"])
1147 .output()
1148 .expect("git add AGENTS.md");
1149 std::process::Command::new("git")
1150 .current_dir(wt)
1151 .args(["commit", "-m", "add agents"])
1152 .output()
1153 .expect("git commit");
1154 }
1155
1156 #[test]
1157 fn setup_worktree_root_exists() {
1158 let repo = tempfile::tempdir().unwrap();
1159 let wt = tempfile::tempdir().unwrap();
1160 init_git_repo(wt.path());
1161 fs::write(repo.path().join("AGENTS.md"), "# Project Rules\n").unwrap();
1162 commit_tracked_agents_md(wt.path(), "# placeholder\n");
1163
1164 let assignment = make_assignment(None, None);
1165 setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
1166
1167 let sidecar = fs::read_to_string(sidecar_path(wt.path())).unwrap();
1169 assert!(sidecar.contains("# Project Rules"));
1170 assert!(sidecar.contains("`feat/foo`"));
1171 assert!(sidecar.contains(START_MARKER));
1172
1173 assert_eq!(
1175 agents_md_ls_files_flag(wt.path()),
1176 'H',
1177 "tracked AGENTS.md must not carry the assume-unchanged bit"
1178 );
1179
1180 fs::write(wt.path().join("AGENTS.md"), "# placeholder\n\nhand edit\n").unwrap();
1182 let status = std::process::Command::new("git")
1183 .current_dir(wt.path())
1184 .args(["status", "--porcelain"])
1185 .output()
1186 .expect("git status");
1187 let status_output = String::from_utf8_lossy(&status.stdout);
1188 assert!(
1189 status_output.contains("AGENTS.md"),
1190 "a hand edit to AGENTS.md must appear in git status, got: {status_output}"
1191 );
1192 }
1193
1194 #[test]
1195 fn setup_worktree_hand_edit_stages_and_commits() {
1196 let repo = tempfile::tempdir().unwrap();
1199 let wt = tempfile::tempdir().unwrap();
1200 init_git_repo(wt.path());
1201 fs::write(repo.path().join("AGENTS.md"), "# Project Rules\n").unwrap();
1202 commit_tracked_agents_md(wt.path(), "# Project Rules\n");
1203
1204 let assignment = make_assignment(None, None);
1205 setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
1206
1207 fs::write(
1209 wt.path().join("AGENTS.md"),
1210 "# Project Rules\n\n- approved dependency: rmcp\n",
1211 )
1212 .unwrap();
1213
1214 std::process::Command::new("git")
1215 .current_dir(wt.path())
1216 .args(["add", "-A"])
1217 .output()
1218 .expect("git add -A");
1219 let commit = std::process::Command::new("git")
1220 .current_dir(wt.path())
1221 .args(["commit", "-m", "edit agents"])
1222 .output()
1223 .expect("git commit");
1224 assert!(commit.status.success(), "commit should succeed");
1225
1226 let show = std::process::Command::new("git")
1228 .current_dir(wt.path())
1229 .args(["show", "--stat", "HEAD"])
1230 .output()
1231 .expect("git show");
1232 assert!(String::from_utf8_lossy(&show.stdout).contains("AGENTS.md"));
1233 let status = std::process::Command::new("git")
1234 .current_dir(wt.path())
1235 .args(["status", "--porcelain", "AGENTS.md"])
1236 .output()
1237 .expect("git status");
1238 assert!(
1239 String::from_utf8_lossy(&status.stdout).trim().is_empty(),
1240 "AGENTS.md should be clean after committing the edit"
1241 );
1242 }
1243
1244 #[test]
1245 fn setup_worktree_managed_block_in_sidecar_combined_view() {
1246 let repo = tempfile::tempdir().unwrap();
1249 let wt = tempfile::tempdir().unwrap();
1250 init_git_repo(wt.path());
1251 fs::write(repo.path().join("AGENTS.md"), "# Project Rules\n").unwrap();
1252
1253 let assignment = make_assignment(None, None);
1254 setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
1255
1256 let sidecar = fs::read_to_string(sidecar_path(wt.path())).unwrap();
1257 assert!(
1258 sidecar.contains(START_MARKER),
1259 "sidecar must carry the block"
1260 );
1261 let root_idx = sidecar
1263 .find("# Project Rules")
1264 .expect("root content present");
1265 let block_idx = sidecar.find(START_MARKER).expect("block present");
1266 assert!(
1267 root_idx < block_idx,
1268 "root content must precede the managed block in the combined view"
1269 );
1270 }
1271
1272 #[test]
1273 fn setup_worktree_tracked_agents_md_untouched_and_not_excluded() {
1274 let repo = tempfile::tempdir().unwrap();
1277 let wt = tempfile::tempdir().unwrap();
1278 init_git_repo(wt.path());
1279 fs::write(repo.path().join("AGENTS.md"), "# Project Rules\n").unwrap();
1280 commit_tracked_agents_md(wt.path(), "# Project Rules\n");
1281
1282 let assignment = make_assignment(None, None);
1283 setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
1284
1285 let tracked = fs::read_to_string(wt.path().join("AGENTS.md")).unwrap();
1286 assert!(
1287 !tracked.contains(START_MARKER_PREFIX),
1288 "git-paw must not write its managed block into the tracked AGENTS.md"
1289 );
1290
1291 let exclude = fs::read_to_string(wt.path().join(".git/info/exclude")).unwrap_or_default();
1292 assert!(
1293 !exclude.lines().any(|l| l.trim() == "AGENTS.md"),
1294 "AGENTS.md must NOT be added to .git/info/exclude, got: {exclude}"
1295 );
1296 }
1297
1298 #[test]
1299 fn setup_worktree_sidecar_in_ignore_set() {
1300 let repo = tempfile::tempdir().unwrap();
1302 let wt = tempfile::tempdir().unwrap();
1303 init_git_repo(wt.path());
1304
1305 let assignment = make_assignment(None, None);
1306 setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
1307
1308 let exclude = fs::read_to_string(wt.path().join(".git/info/exclude")).unwrap();
1309 assert!(
1310 exclude.lines().any(|l| l.trim() == SIDECAR_REL_PATH),
1311 "sidecar path must be in the worktree ignore set, got: {exclude}"
1312 );
1313 }
1314
1315 #[test]
1316 fn setup_worktree_clears_stale_assume_unchanged() {
1317 let repo = tempfile::tempdir().unwrap();
1319 let wt = tempfile::tempdir().unwrap();
1320 init_git_repo(wt.path());
1321 fs::write(repo.path().join("AGENTS.md"), "# Project Rules\n").unwrap();
1322 commit_tracked_agents_md(wt.path(), "# placeholder\n");
1323
1324 std::process::Command::new("git")
1326 .current_dir(wt.path())
1327 .args(["update-index", "--assume-unchanged", "AGENTS.md"])
1328 .output()
1329 .expect("git update-index --assume-unchanged");
1330 assert_eq!(
1331 agents_md_ls_files_flag(wt.path()),
1332 'h',
1333 "precondition: the stale assume-unchanged bit is set"
1334 );
1335
1336 let assignment = make_assignment(None, None);
1337 setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
1338
1339 assert_eq!(
1340 agents_md_ls_files_flag(wt.path()),
1341 'H',
1342 "setup must clear the stale assume-unchanged bit"
1343 );
1344 fs::write(wt.path().join("AGENTS.md"), "# placeholder\n\nedited\n").unwrap();
1346 let status = std::process::Command::new("git")
1347 .current_dir(wt.path())
1348 .args(["status", "--porcelain"])
1349 .output()
1350 .expect("git status");
1351 assert!(
1352 String::from_utf8_lossy(&status.stdout).contains("AGENTS.md"),
1353 "after clearing the bit, a hand edit must appear in git status"
1354 );
1355 }
1356
1357 #[test]
1358 fn setup_worktree_root_missing() {
1359 let repo = tempfile::tempdir().unwrap();
1361 let wt = tempfile::tempdir().unwrap();
1362 init_git_repo(wt.path());
1363
1364 let assignment = make_assignment(None, None);
1365 setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
1366
1367 let sidecar = fs::read_to_string(sidecar_path(wt.path())).unwrap();
1368 assert!(!sidecar.contains("# Project Rules"));
1369 assert!(sidecar.contains("`feat/foo`"));
1370 }
1371
1372 #[test]
1373 fn setup_worktree_replaces_root_section() {
1374 let repo = tempfile::tempdir().unwrap();
1376 let wt = tempfile::tempdir().unwrap();
1377 init_git_repo(wt.path());
1378 let root_content =
1379 format!("# Rules\n\n{START_MARKER}\nold root section\n{END_MARKER}\n\n## Footer\n");
1380 fs::write(repo.path().join("AGENTS.md"), &root_content).unwrap();
1381
1382 let assignment = make_assignment(None, None);
1383 setup_worktree_agents_md(repo.path(), wt.path(), &assignment).unwrap();
1384
1385 let sidecar = fs::read_to_string(sidecar_path(wt.path())).unwrap();
1386 assert!(sidecar.contains("# Rules"));
1387 assert!(sidecar.contains("## Footer"));
1388 assert!(!sidecar.contains("old root section"));
1389 assert!(sidecar.contains("`feat/foo`"));
1390 assert_eq!(
1391 sidecar.matches(START_MARKER_PREFIX).count(),
1392 1,
1393 "should have exactly one git-paw section"
1394 );
1395 }
1396
1397 #[test]
1402 fn setup_worktree_write_failure_returns_agents_md_error() {
1403 use std::os::unix::fs::PermissionsExt;
1404
1405 let repo = tempfile::tempdir().unwrap();
1406 let wt = tempfile::tempdir().unwrap();
1407 init_git_repo(wt.path());
1408
1409 fs::set_permissions(wt.path(), fs::Permissions::from_mode(0o555)).unwrap();
1411
1412 let assignment = make_assignment(None, None);
1413 let result = setup_worktree_agents_md(repo.path(), wt.path(), &assignment);
1414
1415 fs::set_permissions(wt.path(), fs::Permissions::from_mode(0o755)).unwrap();
1417
1418 assert!(result.is_err(), "should fail when worktree is read-only");
1419 let err = result.unwrap_err();
1420 let msg = err.to_string();
1421 assert!(
1422 msg.contains("AGENTS.md error"),
1423 "should return AgentsMdError, got: {msg}"
1424 );
1425 }
1426
1427 #[test]
1432 fn exclude_creates_file_when_missing() {
1433 let wt = tempfile::tempdir().unwrap();
1434 fs::create_dir_all(wt.path().join(".git/info")).unwrap();
1435
1436 exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1437
1438 let content = fs::read_to_string(wt.path().join(".git/info/exclude")).unwrap();
1439 assert!(content.contains("AGENTS.md"));
1440 }
1441
1442 #[test]
1443 fn exclude_appends_when_not_present() {
1444 let wt = tempfile::tempdir().unwrap();
1445 let info = wt.path().join(".git/info");
1446 fs::create_dir_all(&info).unwrap();
1447 fs::write(info.join("exclude"), "*.log\n").unwrap();
1448
1449 exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1450
1451 let content = fs::read_to_string(info.join("exclude")).unwrap();
1452 assert!(content.contains("*.log"));
1453 assert!(content.contains("AGENTS.md"));
1454 }
1455
1456 #[test]
1457 fn exclude_no_duplicate() {
1458 let wt = tempfile::tempdir().unwrap();
1459 let info = wt.path().join(".git/info");
1460 fs::create_dir_all(&info).unwrap();
1461 fs::write(info.join("exclude"), "AGENTS.md\n").unwrap();
1462
1463 exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1464
1465 let content = fs::read_to_string(info.join("exclude")).unwrap();
1466 assert_eq!(content.matches("AGENTS.md").count(), 1);
1467 }
1468
1469 #[test]
1470 fn exclude_creates_info_dir() {
1471 let wt = tempfile::tempdir().unwrap();
1472 fs::create_dir_all(wt.path().join(".git")).unwrap();
1473 assert!(!wt.path().join(".git/info").exists());
1474
1475 exclude_from_git(wt.path(), "AGENTS.md").unwrap();
1476
1477 assert!(wt.path().join(".git/info/exclude").exists());
1478 let content = fs::read_to_string(wt.path().join(".git/info/exclude")).unwrap();
1479 assert!(content.contains("AGENTS.md"));
1480 }
1481
1482 #[test]
1487 fn worktree_section_all_fields_with_skill() {
1488 let assignment = make_assignment_with_skill(
1489 Some("Implement the widget.\n"),
1490 Some(vec!["src/widget.rs", "tests/widget.rs"]),
1491 Some("## Coordination\nUse the broker at http://127.0.0.1:9119 as feat-foo.\n"),
1492 );
1493 let section = generate_worktree_section(&assignment);
1494 assert!(section.starts_with(START_MARKER));
1495 assert!(section.contains(END_MARKER));
1496 assert!(section.contains("`feat/foo`"));
1497 assert!(section.contains("claude"));
1498 assert!(section.contains("### Spec"));
1499 assert!(section.contains("Implement the widget."));
1500 assert!(section.contains("### File Ownership"));
1501 assert!(section.contains("`src/widget.rs`"));
1502 assert!(section.contains("## Coordination"));
1503 let ownership_pos = section.find("### File Ownership").unwrap();
1505 let skill_pos = section.find("## Coordination").unwrap();
1506 let end_pos = section.find(END_MARKER).unwrap();
1507 assert!(
1508 ownership_pos < skill_pos,
1509 "skill must come after file ownership"
1510 );
1511 assert!(skill_pos < end_pos, "skill must come before end marker");
1512 }
1513
1514 #[test]
1515 fn worktree_section_skill_without_spec_or_files() {
1516 let assignment = make_assignment_with_skill(
1517 None,
1518 None,
1519 Some("## Coordination\nBroker instructions here.\n"),
1520 );
1521 let section = generate_worktree_section(&assignment);
1522 assert!(section.contains("`feat/foo`"));
1523 assert!(section.contains("claude"));
1524 assert!(!section.contains("### Spec"));
1525 assert!(!section.contains("### File Ownership"));
1526 assert!(section.contains("## Coordination"));
1527 let assignment_pos = section.find("**CLI:**").unwrap();
1529 let skill_pos = section.find("## Coordination").unwrap();
1530 let end_pos = section.find(END_MARKER).unwrap();
1531 assert!(
1532 assignment_pos < skill_pos,
1533 "skill must come after assignment"
1534 );
1535 assert!(skill_pos < end_pos, "skill must come before end marker");
1536 }
1537
1538 #[test]
1539 fn worktree_section_none_skill_matches_v020() {
1540 let with_none =
1542 make_assignment_with_skill(Some("Do the thing.\n"), Some(vec!["src/main.rs"]), None);
1543 let without = make_assignment(Some("Do the thing.\n"), Some(vec!["src/main.rs"]));
1544 assert_eq!(
1545 generate_worktree_section(&with_none),
1546 generate_worktree_section(&without),
1547 "skill_content = None must produce identical output to v0.2.0"
1548 );
1549 }
1550
1551 #[test]
1552 fn worktree_section_skill_contains_slugified_branch() {
1553 let assignment = WorktreeAssignment {
1554 branch: "feat/http-broker".to_string(),
1555 cli: "claude".to_string(),
1556 spec_content: None,
1557 owned_files: None,
1558 skill_content: Some(
1559 "Agent ID: feat-http-broker\nURL: http://127.0.0.1:9119\n".to_string(),
1560 ),
1561 inter_agent_rules: None,
1562 };
1563 let section = generate_worktree_section(&assignment);
1564 assert!(
1565 section.contains("feat-http-broker"),
1566 "should contain slugified branch"
1567 );
1568 assert!(
1569 !section.contains("{{BRANCH_ID}}"),
1570 "should not contain literal template placeholder"
1571 );
1572 }
1573
1574 #[test]
1575 fn worktree_section_skill_preserves_broker_url_placeholder() {
1576 let assignment = make_assignment_with_skill(
1577 None,
1578 None,
1579 Some("Connect to http://127.0.0.1:9119/messages\n"),
1580 );
1581 let section = generate_worktree_section(&assignment);
1582 assert!(
1583 section.contains("http://127.0.0.1:9119"),
1584 "broker URL must be present"
1585 );
1586 }
1587
1588 #[test]
1593 fn worktree_section_with_inter_agent_rules() {
1594 let mut assignment = make_assignment(Some("Do the widget.\n"), Some(vec!["src/widget.rs"]));
1595 assignment.inter_agent_rules = Some("Stay in your lane.\nNever push.\n".to_string());
1596 let section = generate_worktree_section(&assignment);
1597 assert!(section.contains("## Inter-Agent Rules"));
1598 assert!(section.contains("Stay in your lane."));
1599 let rules_pos = section.find("## Inter-Agent Rules").unwrap();
1601 let end_pos = section.find(END_MARKER).unwrap();
1602 assert!(rules_pos < end_pos, "rules must come before end marker");
1603 }
1604
1605 #[test]
1606 fn worktree_section_without_inter_agent_rules_has_no_section() {
1607 let assignment = make_assignment(Some("Do the widget.\n"), Some(vec!["src/widget.rs"]));
1608 let section = generate_worktree_section(&assignment);
1609 assert!(!section.contains("## Inter-Agent Rules"));
1610 }
1611
1612 #[test]
1613 fn worktree_section_inter_agent_rules_none_matches_pre_change() {
1614 let baseline = make_assignment(Some("Do.\n"), Some(vec!["src/main.rs"]));
1616 let with_none = WorktreeAssignment {
1617 branch: baseline.branch.clone(),
1618 cli: baseline.cli.clone(),
1619 spec_content: baseline.spec_content.clone(),
1620 owned_files: baseline.owned_files.clone(),
1621 skill_content: None,
1622 inter_agent_rules: None,
1623 };
1624 assert_eq!(
1625 generate_worktree_section(&baseline),
1626 generate_worktree_section(&with_none),
1627 );
1628 }
1629
1630 #[test]
1635 fn build_inter_agent_rules_contains_file_ownership() {
1636 let rules = build_inter_agent_rules(&["feat/a", "feat/b"]);
1637 assert!(rules.contains("File ownership"));
1638 assert!(rules.contains("`feat/a`"));
1639 assert!(rules.contains("`feat/b`"));
1640 }
1641
1642 #[test]
1643 fn build_inter_agent_rules_contains_never_push() {
1644 let rules = build_inter_agent_rules(&["feat/a"]);
1645 assert!(rules.contains("MUST NOT `git push`"));
1646 }
1647
1648 #[test]
1649 fn build_inter_agent_rules_notes_automatic_status() {
1650 let rules = build_inter_agent_rules(&["feat/a"]);
1651 assert!(rules.contains("Status publishing is automatic"));
1652 assert!(rules.contains("post-commit"));
1653 }
1654
1655 #[test]
1656 fn build_inter_agent_rules_contains_match_spec() {
1657 let rules = build_inter_agent_rules(&["feat/a"]);
1658 assert!(
1659 rules
1660 .to_lowercase()
1661 .contains("match spec field names exactly")
1662 );
1663 }
1664
1665 #[test]
1666 fn build_inter_agent_rules_contains_cherry_pick_reference() {
1667 let rules = build_inter_agent_rules(&["feat/a"]);
1668 assert!(rules.to_lowercase().contains("cherry-pick"));
1669 }
1670
1671 #[test]
1676 fn embedded_coordination_contains_cherry_pick() {
1677 let content = include_str!("../assets/agent-skills/coordination.md");
1678 assert!(content.contains("git cherry-pick"));
1679 }
1680
1681 #[test]
1682 fn embedded_coordination_documents_automatic_status() {
1683 let content = include_str!("../assets/agent-skills/coordination.md");
1684 let lower = content.to_lowercase();
1685 assert!(lower.contains("automatic"));
1686 assert!(lower.contains("post-commit"));
1687 }
1688
1689 #[test]
1690 fn embedded_coordination_does_not_require_manual_status_publish() {
1691 let content = include_str!("../assets/agent-skills/coordination.md");
1692 assert!(!content.contains("MUST publish `agent.status`"));
1693 assert!(!content.contains("You MUST publish `agent.status`"));
1694 }
1695
1696 #[test]
1697 fn embedded_coordination_still_contains_optin_operations() {
1698 let content = include_str!("../assets/agent-skills/coordination.md");
1699 assert!(content.contains("agent.blocked"));
1700 assert!(content.contains("agent.artifact"));
1701 assert!(content.contains("{{GIT_PAW_BROKER_URL}}/messages/{{BRANCH_ID}}"));
1702 }
1703
1704 #[test]
1705 fn embedded_coordination_requires_no_push() {
1706 let content = include_str!("../assets/agent-skills/coordination.md");
1707 assert!(content.contains("MUST NOT push"));
1708 }
1709
1710 #[test]
1715 fn pre_commit_guard_hook_blocks_on_branch_mismatch() {
1716 let script = build_pre_commit_branch_guard_hook();
1717 assert!(script.contains("git rev-parse --git-dir"));
1721 assert!(script.contains("PAW_EXPECTED_BRANCH"));
1722 assert!(script.contains("PAW_STRICT_BRANCH_GUARD"));
1723 assert!(script.contains("git symbolic-ref --short HEAD"));
1724 assert!(script.contains("exit 1"));
1725 }
1726
1727 #[test]
1728 fn post_commit_dispatcher_detects_branch_mismatch() {
1729 let script = build_post_commit_dispatcher_hook();
1730 assert!(script.contains("agent.feedback"));
1733 assert!(script.contains("agent.learning"));
1734 assert!(script.contains("permission_pattern"));
1735 assert!(script.contains("PAW_EXPECTED_BRANCH"));
1736 }
1737
1738 #[test]
1739 fn post_commit_dispatcher_hook_reads_marker_and_publishes() {
1740 let script = build_post_commit_dispatcher_hook();
1741 assert!(script.contains("$PAW_GD/paw-agent-id"));
1742 assert!(script.contains(". \"$PAW_GD/paw-agent-id\""));
1743 assert!(script.contains("$PAW_BROKER_URL/publish"));
1744 assert!(script.contains("$PAW_AGENT_ID"));
1745 assert!(script.contains("agent.artifact"));
1746 assert!(script.contains("|| true"));
1747 }
1748
1749 #[test]
1750 fn agent_marker_is_shell_sourceable() {
1751 let marker = build_agent_marker("http://127.0.0.1:9119", "feat-x", None, None, None);
1752 assert!(marker.contains("PAW_AGENT_ID=feat-x"));
1753 assert!(marker.contains("PAW_BROKER_URL=http://127.0.0.1:9119"));
1754 }
1755
1756 #[test]
1757 fn pre_push_hook_only_rejects_agent_worktrees() {
1758 let script = build_pre_push_hook();
1759 assert!(script.contains("exit 1"));
1761 assert!(script.contains("must not push"));
1762 assert!(
1765 script.contains("paw-agent-id"),
1766 "pre-push hook must gate the reject on $GIT_DIR/paw-agent-id; \
1767 without the gate, every push from this gitdir is blocked, \
1768 including legitimate pushes from the main repo"
1769 );
1770 }
1771
1772 #[test]
1773 fn chain_hook_replaces_existing_git_paw_block() {
1774 let existing = format!(
1775 "#!/bin/sh\n\
1776 # user hook\n\
1777 echo hi\n\
1778 {HOOK_START_MARKER}\n\
1779 old git-paw content\n\
1780 {HOOK_END_MARKER}\n"
1781 );
1782 let new_body = format!(
1783 "#!/bin/sh\n\
1784 {HOOK_START_MARKER}\n\
1785 new git-paw content\n\
1786 {HOOK_END_MARKER}\n"
1787 );
1788 let chained = chain_hook(&existing, &new_body);
1789 assert!(chained.contains("# user hook"));
1790 assert!(chained.contains("echo hi"));
1791 assert!(chained.contains("new git-paw content"));
1792 assert!(!chained.contains("old git-paw content"));
1793 }
1794
1795 #[test]
1796 fn chain_hook_appends_after_existing_content() {
1797 let existing = "#!/bin/sh\necho existing\n";
1798 let new_body = format!(
1799 "#!/bin/sh\n\
1800 {HOOK_START_MARKER}\n\
1801 new block\n\
1802 {HOOK_END_MARKER}\n"
1803 );
1804 let chained = chain_hook(existing, &new_body);
1805 assert!(chained.starts_with("#!/bin/sh\necho existing"));
1806 assert!(chained.contains("new block"));
1807 assert_eq!(chained.matches("#!/bin/sh").count(), 1);
1809 }
1810
1811 #[test]
1812 fn chain_hook_preserves_content_when_end_marker_missing() {
1813 let existing = format!(
1817 "#!/bin/sh\n\
1818 # important user logic\n\
1819 echo do_not_lose_me\n\
1820 {HOOK_START_MARKER}\n\
1821 leftover but no end marker\n"
1822 );
1823 let new_body = format!(
1824 "#!/bin/sh\n\
1825 {HOOK_START_MARKER}\n\
1826 new git-paw content\n\
1827 {HOOK_END_MARKER}\n"
1828 );
1829 let chained = chain_hook(&existing, &new_body);
1830 assert!(chained.contains("#!/bin/sh"));
1832 assert!(chained.contains("# important user logic"));
1833 assert!(chained.contains("echo do_not_lose_me"));
1834 assert!(chained.contains("leftover but no end marker"));
1835 assert!(chained.contains("new git-paw content"));
1837 assert!(chained.contains(HOOK_END_MARKER));
1838 assert_eq!(chained.matches("#!/bin/sh").count(), 1);
1840 }
1841
1842 #[test]
1843 #[serial_test::serial]
1844 fn install_git_hooks_writes_dispatcher_to_common_git_dir() {
1845 let tmp = tempfile::tempdir().unwrap();
1846 let worktree = tmp.path();
1847 init_git_repo(worktree);
1848
1849 install_git_hooks(worktree, "http://127.0.0.1:9119", "feat-x", "feat/x", true).unwrap();
1850
1851 let post_commit = worktree.join(".git").join("hooks").join("post-commit");
1852 let pre_push = worktree.join(".git").join("hooks").join("pre-push");
1853 let marker = worktree.join(".git").join("paw-agent-id");
1854
1855 assert!(post_commit.exists(), "post-commit should exist");
1856 assert!(pre_push.exists(), "pre-push should exist");
1857 assert!(marker.exists(), "paw-agent-id marker should exist");
1858
1859 let pc = fs::read_to_string(&post_commit).unwrap();
1860 assert!(pc.contains("$PAW_GD/paw-agent-id"));
1861 assert!(pc.contains("agent.artifact"));
1862
1863 let pre_commit = worktree.join(".git").join("hooks").join("pre-commit");
1865 assert!(pre_commit.exists(), "pre-commit guard should exist");
1866 let prc = fs::read_to_string(&pre_commit).unwrap();
1867 assert!(prc.contains("branch guard"));
1868 assert!(prc.contains("PAW_EXPECTED_BRANCH"));
1869
1870 let marker_body = fs::read_to_string(&marker).unwrap();
1871 assert!(marker_body.contains("PAW_AGENT_ID=feat-x"));
1872 assert!(marker_body.contains("PAW_BROKER_URL=http://127.0.0.1:9119"));
1873 assert!(marker_body.contains("PAW_EXPECTED_BRANCH=feat/x"));
1874 assert!(marker_body.contains("PAW_STRICT_BRANCH_GUARD=true"));
1875
1876 #[cfg(unix)]
1877 {
1878 use std::os::unix::fs::PermissionsExt;
1879 let mode = fs::metadata(&post_commit).unwrap().permissions().mode();
1880 assert_eq!(mode & 0o111, 0o111, "post-commit must be executable");
1881 }
1882 }
1883
1884 #[test]
1885 #[serial_test::serial]
1886 fn install_git_hooks_preserves_existing_dispatcher_body() {
1887 let tmp = tempfile::tempdir().unwrap();
1888 let worktree = tmp.path();
1889 init_git_repo(worktree);
1890 let hook_path = worktree.join(".git").join("hooks").join("post-commit");
1891 fs::write(&hook_path, "#!/bin/sh\necho user hook\n").unwrap();
1892
1893 install_git_hooks(worktree, "http://127.0.0.1:9119", "feat-x", "feat/x", true).unwrap();
1894
1895 let body = fs::read_to_string(&hook_path).unwrap();
1896 assert!(body.contains("echo user hook"));
1897 assert!(body.contains("agent.artifact"));
1898 }
1899
1900 #[test]
1901 #[serial_test::serial]
1902 fn install_git_hooks_writes_linked_marker_for_linked_worktree() {
1903 let tmp = tempfile::tempdir().unwrap();
1904 let main_repo = tmp.path().join("main");
1905 fs::create_dir_all(&main_repo).unwrap();
1906 init_git_repo(&main_repo);
1907
1908 std::process::Command::new("git")
1910 .args(["commit", "--allow-empty", "-m", "root", "-q"])
1911 .current_dir(&main_repo)
1912 .output()
1913 .unwrap();
1914
1915 let linked_path = tmp.path().join("linked");
1917 std::process::Command::new("git")
1918 .args([
1919 "worktree",
1920 "add",
1921 "-b",
1922 "feat-x",
1923 linked_path.to_str().unwrap(),
1924 ])
1925 .current_dir(&main_repo)
1926 .output()
1927 .unwrap();
1928
1929 install_git_hooks(
1930 &linked_path,
1931 "http://127.0.0.1:9119",
1932 "feat-x",
1933 "feat/x",
1934 true,
1935 )
1936 .unwrap();
1937
1938 let post_commit = main_repo.join(".git").join("hooks").join("post-commit");
1940 assert!(
1941 post_commit.exists(),
1942 "dispatcher must land in main .git/hooks/"
1943 );
1944 let marker = main_repo
1946 .join(".git")
1947 .join("worktrees")
1948 .join("linked")
1949 .join("paw-agent-id");
1950 assert!(
1951 marker.exists(),
1952 "marker must land in linked worktree gitdir"
1953 );
1954 let body = fs::read_to_string(&marker).unwrap();
1955 assert!(body.contains("PAW_AGENT_ID=feat-x"));
1956 }
1957
1958 #[test]
1963 fn build_agent_marker_basic_format() {
1964 let marker = build_agent_marker("http://127.0.0.1:9119", "feat-test", None, None, None);
1965
1966 assert!(marker.contains("PAW_AGENT_ID=feat-test"));
1967 assert!(marker.contains("PAW_BROKER_URL=http://127.0.0.1:9119"));
1968 assert!(marker.contains("PAW_TIMESTAMP="));
1969 assert!(!marker.contains("PAW_SUPERVISOR_PID"));
1971 assert!(!marker.contains("PAW_LAST_VERIFIED_COMMIT"));
1972 assert!(!marker.contains("PAW_SESSION_NAME"));
1973 }
1974
1975 #[test]
1976 fn build_agent_marker_with_all_extended_fields() {
1977 let marker = build_agent_marker(
1978 "http://localhost:9119",
1979 "feat-errors",
1980 Some(12345),
1981 Some("abc123def456"),
1982 Some("paw-test-session"),
1983 );
1984
1985 assert!(marker.contains("PAW_AGENT_ID=feat-errors"));
1986 assert!(marker.contains("PAW_BROKER_URL=http://localhost:9119"));
1987 assert!(marker.contains("PAW_SUPERVISOR_PID=12345"));
1988 assert!(marker.contains("PAW_LAST_VERIFIED_COMMIT=abc123def456"));
1989 assert!(marker.contains("PAW_SESSION_NAME=paw-test-session"));
1990 assert!(marker.contains("PAW_TIMESTAMP="));
1991 }
1992
1993 #[test]
1994 fn build_agent_marker_partial_extended_fields() {
1995 let marker =
1996 build_agent_marker("http://localhost:9119", "fix-cycle", Some(999), None, None);
1997
1998 assert!(marker.contains("PAW_SUPERVISOR_PID=999"));
1999 assert!(!marker.contains("PAW_LAST_VERIFIED_COMMIT"));
2000 assert!(!marker.contains("PAW_SESSION_NAME"));
2001 }
2002
2003 #[test]
2004 fn update_agent_marker_adds_missing_fields() {
2005 let tmp = tempfile::tempdir().unwrap();
2006 let marker_path = tmp.path().join("test-marker");
2007
2008 let initial = "PAW_AGENT_ID=test\nPAW_BROKER_URL=http://localhost:9119\nPAW_TIMESTAMP=2026-01-01T00:00:00Z\n";
2010 fs::write(&marker_path, initial).unwrap();
2011
2012 update_agent_marker(&marker_path, Some(54321), None).unwrap();
2014
2015 let updated = fs::read_to_string(&marker_path).unwrap();
2016 assert!(updated.contains("PAW_AGENT_ID=test"));
2017 assert!(updated.contains("PAW_SUPERVISOR_PID=54321"));
2018 }
2019
2020 #[test]
2021 fn update_agent_marker_replaces_existing_fields() {
2022 let tmp = tempfile::tempdir().unwrap();
2023 let marker_path = tmp.path().join("test-marker");
2024
2025 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";
2027 fs::write(&marker_path, initial).unwrap();
2028
2029 update_agent_marker(&marker_path, None, Some("new456")).unwrap();
2031
2032 let updated = fs::read_to_string(&marker_path).unwrap();
2033 assert!(updated.contains("PAW_AGENT_ID=test"));
2034 assert!(updated.contains("PAW_LAST_VERIFIED_COMMIT=new456"));
2035 assert!(!updated.contains("PAW_LAST_VERIFIED_COMMIT=old123"));
2036 }
2037
2038 #[test]
2039 fn update_agent_marker_reuses_lazy_regex_across_calls() {
2040 let tmp = tempfile::tempdir().unwrap();
2045 let marker_path = tmp.path().join("test-marker");
2046
2047 let initial = "PAW_AGENT_ID=test\nPAW_BROKER_URL=http://localhost:9119\nPAW_SUPERVISOR_PID=111\nPAW_LAST_VERIFIED_COMMIT=abc\n";
2048 fs::write(&marker_path, initial).unwrap();
2049
2050 update_agent_marker(&marker_path, Some(222), Some("def")).unwrap();
2051 update_agent_marker(&marker_path, Some(333), Some("ghi")).unwrap();
2052
2053 let updated = fs::read_to_string(&marker_path).unwrap();
2054 assert!(updated.contains("PAW_SUPERVISOR_PID=333"));
2055 assert!(updated.contains("PAW_LAST_VERIFIED_COMMIT=ghi"));
2056 assert!(!updated.contains("PAW_SUPERVISOR_PID=111"));
2057 assert!(!updated.contains("PAW_SUPERVISOR_PID=222"));
2058 assert!(!updated.contains("PAW_LAST_VERIFIED_COMMIT=abc"));
2059 assert!(!updated.contains("PAW_LAST_VERIFIED_COMMIT=def"));
2060 }
2061
2062 #[test]
2063 fn get_agent_marker_path_returns_correct_path() {
2064 let tmp = tempfile::tempdir().unwrap();
2065 let worktree = tmp.path();
2066 init_git_repo(worktree);
2067
2068 let marker_path = get_agent_marker_path(worktree).unwrap();
2069 assert!(marker_path.ends_with(".git/paw-agent-id"));
2070 }
2071
2072 #[test]
2077 fn remove_session_boot_block_strips_marked_block() {
2078 let tmp = tempfile::tempdir().unwrap();
2079 let repo_root = tmp.path();
2080 let agents_md = repo_root.join("AGENTS.md");
2081
2082 let header = "# Project AGENTS";
2083 let footer = "## Footer\n";
2084 let original = format!(
2085 "{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}"
2086 );
2087 fs::write(&agents_md, &original).unwrap();
2088
2089 remove_session_boot_block(repo_root).unwrap();
2090
2091 let after = fs::read_to_string(&agents_md).unwrap();
2092 let expected = format!("{header}\n\n{footer}");
2093 assert_eq!(
2094 after, expected,
2095 "after removal the file must match HEADER + blank + FOOTER byte-for-byte; got:\n{after:?}",
2096 );
2097 assert!(
2098 !after.contains("git-paw:start"),
2099 "no git-paw:start marker may remain after removal",
2100 );
2101 }
2102
2103 #[test]
2104 fn remove_session_boot_block_no_marker_is_noop() {
2105 let tmp = tempfile::tempdir().unwrap();
2106 let repo_root = tmp.path();
2107 let agents_md = repo_root.join("AGENTS.md");
2108
2109 let original = "# Project AGENTS\n\nNo boot block here.\n";
2110 fs::write(&agents_md, original).unwrap();
2111
2112 remove_session_boot_block(repo_root).unwrap();
2113
2114 let after = fs::read_to_string(&agents_md).unwrap();
2115 assert_eq!(
2116 after, original,
2117 "files without a boot-block marker must be preserved byte-for-byte",
2118 );
2119 }
2120
2121 #[test]
2122 fn remove_session_boot_block_missing_agents_md_is_noop() {
2123 let tmp = tempfile::tempdir().unwrap();
2126 remove_session_boot_block(tmp.path()).unwrap();
2127 assert!(
2128 !tmp.path().join("AGENTS.md").exists(),
2129 "remove_session_boot_block must not create AGENTS.md when none exists",
2130 );
2131 }
2132
2133 #[test]
2134 fn remove_session_boot_block_preserves_no_trailing_newline() {
2135 let tmp = tempfile::tempdir().unwrap();
2138 let repo_root = tmp.path();
2139 let agents_md = repo_root.join("AGENTS.md");
2140
2141 let original = "# Header no newline";
2142 fs::write(&agents_md, original).unwrap();
2143
2144 remove_session_boot_block(repo_root).unwrap();
2145
2146 let after = fs::read_to_string(&agents_md).unwrap();
2147 assert_eq!(
2148 after, original,
2149 "file without trailing newline must be preserved exactly"
2150 );
2151 }
2152}