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