1use std::fmt::Write as _;
7use std::fs;
8use std::io::IsTerminal;
9use std::path::Path;
10
11use dialoguer::{Confirm, Input};
12
13use crate::config;
14use crate::error::PawError;
15use crate::git;
16
17const GITIGNORE_ENTRIES: &[&str] = &[
21 ".git-paw/logs/",
22 ".git-paw/tmp/",
23 ".git-paw/worktrees/",
24 ".git-paw/session-summary.md",
25 ".git-paw/session-learnings.md",
26];
27
28const SWEEP_SCRIPT: &str = include_str!("../assets/scripts/sweep.sh");
31
32const BROKER_SCRIPT: &str = include_str!("../assets/scripts/broker.sh");
38
39pub fn run_init() -> Result<(), PawError> {
50 let cwd = std::env::current_dir()
51 .map_err(|e| PawError::InitError(format!("cannot read current directory: {e}")))?;
52 let repo_root = git::validate_repo(&cwd)?;
53
54 let paw_dir = repo_root.join(".git-paw");
55 let logs_dir = paw_dir.join("logs");
56 let tmp_dir = paw_dir.join("tmp");
57 let scripts_dir = paw_dir.join("scripts");
58 let config_path = paw_dir.join("config.toml");
59
60 let created_dir = create_dir_if_missing(&paw_dir)?;
62 if created_dir {
63 println!(" Created .git-paw/");
64 }
65
66 let created_logs = create_dir_if_missing(&logs_dir)?;
68 if created_logs {
69 println!(" Created .git-paw/logs/");
70 }
71
72 let created_tmp = create_dir_if_missing(&tmp_dir)?;
75 if created_tmp {
76 println!(" Created .git-paw/tmp/");
77 }
78
79 let created_scripts = create_dir_if_missing(&scripts_dir)?;
82 if created_scripts {
83 println!(" Created .git-paw/scripts/");
84 }
85 let sweep_path = scripts_dir.join("sweep.sh");
86 let sweep_existed = sweep_path.exists();
87 install_script(&sweep_path, SWEEP_SCRIPT)?;
88 if sweep_existed {
89 println!(" Updated .git-paw/scripts/sweep.sh");
90 } else {
91 println!(" Created .git-paw/scripts/sweep.sh");
92 }
93 let broker_path = scripts_dir.join("broker.sh");
94 let broker_existed = broker_path.exists();
95 install_script(&broker_path, BROKER_SCRIPT)?;
96 if broker_existed {
97 println!(" Updated .git-paw/scripts/broker.sh");
98 } else {
99 println!(" Created .git-paw/scripts/broker.sh");
100 }
101
102 let (created_config, migrated_config) = if config_path.exists() {
108 let migrated = migrate_existing_config(&config_path)?;
109 (false, migrated)
110 } else {
111 let supervisor_section = prompt_supervisor_section()?;
112 let specs_section = detect_speckit_section(&repo_root);
113 write_config_if_missing(
114 &config_path,
115 Some(&supervisor_section),
116 specs_section.as_deref(),
117 )?;
118 (true, false)
119 };
120 if created_config {
121 println!(" Created .git-paw/config.toml");
122 } else if migrated_config {
123 println!(" Updated .git-paw/config.toml (added missing sections)");
124 }
125
126 let updated_gitignore = ensure_gitignore_entry(&repo_root)?;
128 if updated_gitignore {
129 println!(" Updated .gitignore");
130 }
131
132 if !created_dir
133 && !created_logs
134 && !created_tmp
135 && !created_config
136 && !migrated_config
137 && !updated_gitignore
138 {
139 println!("Already initialized. Nothing to do.");
140 } else {
141 println!("Initialized git-paw.");
142 }
143
144 Ok(())
145}
146
147fn install_script(path: &Path, content: &str) -> Result<(), PawError> {
153 fs::write(path, content)
154 .map_err(|e| PawError::InitError(format!("failed to write '{}': {e}", path.display())))?;
155
156 #[cfg(unix)]
157 {
158 use std::os::unix::fs::PermissionsExt;
159 let mut perms = fs::metadata(path)
160 .map_err(|e| PawError::InitError(format!("failed to stat '{}': {e}", path.display())))?
161 .permissions();
162 perms.set_mode(0o755);
163 fs::set_permissions(path, perms).map_err(|e| {
164 PawError::InitError(format!(
165 "failed to set executable bit on '{}': {e}",
166 path.display()
167 ))
168 })?;
169 }
170
171 Ok(())
172}
173
174fn create_dir_if_missing(path: &Path) -> Result<bool, PawError> {
176 if path.is_dir() {
177 return Ok(false);
178 }
179 fs::create_dir_all(path)
180 .map_err(|e| PawError::InitError(format!("failed to create '{}': {e}", path.display())))?;
181 Ok(true)
182}
183
184fn migrate_existing_config(path: &Path) -> Result<bool, PawError> {
188 let existing = fs::read_to_string(path)
189 .map_err(|e| PawError::InitError(format!("failed to read config: {e}")))?;
190
191 let mut appended = String::new();
192
193 if !has_section(&existing, "supervisor") {
197 let section = prompt_supervisor_section()?;
198 appended.push_str(§ion);
199 }
200
201 if appended.is_empty() {
202 return Ok(false);
203 }
204
205 let mut new_content = existing;
206 if !new_content.ends_with('\n') {
207 new_content.push('\n');
208 }
209 new_content.push_str(&appended);
210
211 fs::write(path, new_content)
212 .map_err(|e| PawError::InitError(format!("failed to write config: {e}")))?;
213 Ok(true)
214}
215
216fn has_section(content: &str, section: &str) -> bool {
218 let header = format!("[{section}]");
219 content.lines().any(|line| {
220 let trimmed = line.trim_start();
221 !trimmed.starts_with('#') && trimmed.trim_end() == header
222 })
223}
224
225fn write_config_if_missing(
234 path: &Path,
235 supervisor_section: Option<&str>,
236 specs_section: Option<&str>,
237) -> Result<bool, PawError> {
238 if path.exists() {
239 return Ok(false);
240 }
241 let mut content = config::generate_default_config();
242 if let Some(section) = supervisor_section {
243 content.push_str(section);
244 }
245 if let Some(section) = specs_section {
246 content.push_str(section);
247 }
248 fs::write(path, content)
249 .map_err(|e| PawError::InitError(format!("failed to write config: {e}")))?;
250 Ok(true)
251}
252
253fn detect_speckit_section(repo_root: &Path) -> Option<String> {
257 let specify = repo_root.join(".specify");
258 if !specify.is_dir() || !specify.join("specs").is_dir() {
259 return None;
260 }
261 Some(
262 "\n[specs]\n\
263 type = \"speckit\"\n\
264 dir = \".specify/specs\"\n"
265 .to_string(),
266 )
267}
268
269fn prompt_supervisor_section() -> Result<String, PawError> {
275 if !std::io::stdin().is_terminal() {
278 return Ok("\n[supervisor]\nenabled = false\n".to_string());
279 }
280
281 let enabled = Confirm::new()
282 .with_prompt("Enable supervisor mode by default?")
283 .default(false)
284 .interact()
285 .map_err(|e| PawError::InitError(format!("prompt failed: {e}")))?;
286
287 if !enabled {
288 return Ok("\n[supervisor]\nenabled = false\n".to_string());
289 }
290
291 let test_command: String = Input::new()
292 .with_prompt("Test command to run after each agent completes (e.g. 'just check', leave empty to skip)")
293 .allow_empty(true)
294 .interact_text()
295 .map_err(|e| PawError::InitError(format!("prompt failed: {e}")))?;
296
297 let mut section = String::from("\n[supervisor]\nenabled = true\n");
298 let trimmed = test_command.trim();
299 if !trimmed.is_empty() {
300 let escaped = trimmed.replace('\\', "\\\\").replace('"', "\\\"");
301 writeln!(section, "test_command = \"{escaped}\"")
302 .map_err(|e| PawError::InitError(format!("format supervisor section: {e}")))?;
303 }
304 Ok(section)
305}
306
307fn ensure_gitignore_entry(repo_root: &Path) -> Result<bool, PawError> {
309 let gitignore_path = repo_root.join(".gitignore");
310
311 let existing = match fs::read_to_string(&gitignore_path) {
312 Ok(content) => content,
313 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
314 Err(e) => {
315 return Err(PawError::InitError(format!(
316 "failed to read .gitignore: {e}"
317 )));
318 }
319 };
320
321 let existing_lines: std::collections::HashSet<&str> = existing.lines().map(str::trim).collect();
322 let missing: Vec<&&str> = GITIGNORE_ENTRIES
323 .iter()
324 .filter(|e| !existing_lines.contains(**e))
325 .collect();
326
327 if missing.is_empty() {
328 return Ok(false);
329 }
330
331 let mut content = existing;
332 if !content.is_empty() && !content.ends_with('\n') {
333 content.push('\n');
334 }
335 for entry in missing {
336 content.push_str(entry);
337 content.push('\n');
338 }
339
340 fs::write(&gitignore_path, content)
341 .map_err(|e| PawError::InitError(format!("failed to write .gitignore: {e}")))?;
342
343 Ok(true)
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use tempfile::TempDir;
350
351 fn setup_repo() -> TempDir {
352 let dir = TempDir::new().unwrap();
353 fs::create_dir(dir.path().join(".git")).unwrap();
355 dir
356 }
357
358 #[test]
361 fn creates_directory_when_missing() {
362 let dir = TempDir::new().unwrap();
363 let target = dir.path().join("new-dir");
364 assert!(create_dir_if_missing(&target).unwrap());
365 assert!(target.is_dir());
366 }
367
368 #[test]
369 fn skips_existing_directory() {
370 let dir = TempDir::new().unwrap();
371 let target = dir.path().join("existing");
372 fs::create_dir(&target).unwrap();
373 assert!(!create_dir_if_missing(&target).unwrap());
374 }
375
376 #[test]
379 fn writes_config_when_missing() {
380 let dir = TempDir::new().unwrap();
381 let config_path = dir.path().join("config.toml");
382 assert!(write_config_if_missing(&config_path, None, None).unwrap());
383 let content = fs::read_to_string(&config_path).unwrap();
384 assert!(content.contains("default_cli"));
385 }
386
387 #[test]
388 fn skips_existing_config() {
389 let dir = TempDir::new().unwrap();
390 let config_path = dir.path().join("config.toml");
391 fs::write(&config_path, "existing").unwrap();
392 assert!(!write_config_if_missing(&config_path, None, None).unwrap());
393 assert_eq!(fs::read_to_string(&config_path).unwrap(), "existing");
394 }
395
396 #[test]
397 fn appends_supervisor_section_when_provided() {
398 let dir = TempDir::new().unwrap();
399 let config_path = dir.path().join("config.toml");
400 let section = "\n[supervisor]\nenabled = true\ntest_command = \"just check\"\n";
401 assert!(write_config_if_missing(&config_path, Some(section), None).unwrap());
402
403 let content = fs::read_to_string(&config_path).unwrap();
404 let parsed: crate::config::PawConfig = toml::from_str(&content).unwrap();
405 let supervisor = parsed.supervisor.unwrap();
406 assert!(supervisor.enabled);
407 assert_eq!(supervisor.test_command.as_deref(), Some("just check"));
408 }
409
410 #[test]
411 fn detect_speckit_section_returns_some_when_specify_present() {
412 let dir = setup_repo();
413 fs::create_dir_all(dir.path().join(".specify").join("specs")).unwrap();
414 let section = detect_speckit_section(dir.path()).expect("section");
415 assert!(section.contains("[specs]"));
416 assert!(section.contains("type = \"speckit\""));
417 assert!(section.contains("dir = \".specify/specs\""));
418 }
419
420 #[test]
421 fn detect_speckit_section_none_when_specify_missing() {
422 let dir = setup_repo();
423 assert!(detect_speckit_section(dir.path()).is_none());
424 }
425
426 #[test]
427 fn detect_speckit_section_none_when_specify_lacks_specs_subdir() {
428 let dir = setup_repo();
429 fs::create_dir_all(dir.path().join(".specify").join("memory")).unwrap();
430 assert!(detect_speckit_section(dir.path()).is_none());
431 }
432
433 #[test]
434 fn write_config_appends_specs_section_when_provided() {
435 let dir = TempDir::new().unwrap();
436 let config_path = dir.path().join("config.toml");
437 let specs_section = "\n[specs]\ntype = \"speckit\"\ndir = \".specify/specs\"\n";
438 assert!(write_config_if_missing(&config_path, None, Some(specs_section)).unwrap());
439
440 let content = fs::read_to_string(&config_path).unwrap();
441 let parsed: crate::config::PawConfig = toml::from_str(&content).unwrap();
442 let specs = parsed.specs.expect("specs section parsed");
443 assert_eq!(specs.spec_type.as_deref(), Some("speckit"));
444 assert_eq!(specs.dir.as_deref(), Some(".specify/specs"));
445 }
446
447 #[test]
448 fn appends_disabled_supervisor_section() {
449 let dir = TempDir::new().unwrap();
450 let config_path = dir.path().join("config.toml");
451 let section = "\n[supervisor]\nenabled = false\n";
452 assert!(write_config_if_missing(&config_path, Some(section), None).unwrap());
453
454 let content = fs::read_to_string(&config_path).unwrap();
455 let parsed: crate::config::PawConfig = toml::from_str(&content).unwrap();
456 let supervisor = parsed.supervisor.unwrap();
457 assert!(!supervisor.enabled);
458 }
459
460 #[test]
463 fn creates_gitignore_with_entry() {
464 let dir = setup_repo();
465 assert!(ensure_gitignore_entry(dir.path()).unwrap());
466 let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
467 for entry in GITIGNORE_ENTRIES {
468 assert!(content.contains(entry), "missing {entry}");
469 }
470 }
471
472 #[test]
473 fn appends_to_existing_gitignore() {
474 let dir = setup_repo();
475 fs::write(dir.path().join(".gitignore"), "node_modules/\n").unwrap();
476 assert!(ensure_gitignore_entry(dir.path()).unwrap());
477 let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
478 assert!(content.contains("node_modules/"));
479 for entry in GITIGNORE_ENTRIES {
480 assert!(content.contains(entry), "missing {entry}");
481 }
482 }
483
484 #[test]
485 fn appends_newline_if_missing() {
486 let dir = setup_repo();
487 fs::write(dir.path().join(".gitignore"), "node_modules/").unwrap();
488 assert!(ensure_gitignore_entry(dir.path()).unwrap());
489 let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
490 assert!(content.contains("node_modules/\n"));
491 for entry in GITIGNORE_ENTRIES {
492 assert!(content.contains(entry), "missing {entry}");
493 }
494 }
495
496 #[test]
497 fn skips_when_all_entries_already_present() {
498 let dir = setup_repo();
499 let mut lines = String::from("node_modules/\n");
500 for entry in GITIGNORE_ENTRIES {
501 lines.push_str(entry);
502 lines.push('\n');
503 }
504 fs::write(dir.path().join(".gitignore"), lines).unwrap();
505 assert!(!ensure_gitignore_entry(dir.path()).unwrap());
506 }
507
508 #[test]
509 fn session_summary_added_alongside_logs() {
510 let dir = setup_repo();
511 fs::write(dir.path().join(".gitignore"), ".git-paw/logs/\n").unwrap();
512 assert!(ensure_gitignore_entry(dir.path()).unwrap());
513 let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
514 assert!(content.contains(".git-paw/session-summary.md"));
515 assert_eq!(content.matches(".git-paw/logs/").count(), 1);
516 }
517
518 #[test]
519 fn session_learnings_added_to_gitignore() {
520 let dir = setup_repo();
524 fs::write(dir.path().join(".gitignore"), ".git-paw/logs/\n").unwrap();
525 assert!(ensure_gitignore_entry(dir.path()).unwrap());
526 let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
527 assert!(
528 content.contains(".git-paw/session-learnings.md"),
529 "init must gitignore the per-session .git-paw/session-learnings.md output"
530 );
531 }
532
533 #[test]
534 fn repo_local_tmp_added_to_gitignore_and_not_duplicated() {
535 let dir = setup_repo();
538 fs::write(dir.path().join(".gitignore"), ".git-paw/logs/\n").unwrap();
540 assert!(ensure_gitignore_entry(dir.path()).unwrap());
541 let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
542 assert!(
543 content.contains(".git-paw/tmp/"),
544 "init must gitignore the repo-local .git-paw/tmp/ scratch dir"
545 );
546 assert!(!ensure_gitignore_entry(dir.path()).unwrap());
548 let content2 = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
549 assert_eq!(
550 content2.matches(".git-paw/tmp/").count(),
551 1,
552 ".git-paw/tmp/ must appear exactly once after repeated init"
553 );
554 }
555
556 #[test]
557 fn worktrees_dir_added_to_gitignore_and_not_duplicated() {
558 let dir = setup_repo();
561 fs::write(dir.path().join(".gitignore"), ".git-paw/logs/\n").unwrap();
563 assert!(ensure_gitignore_entry(dir.path()).unwrap());
564 let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
565 assert!(
566 content.contains(".git-paw/worktrees/"),
567 "init must gitignore the in-repo .git-paw/worktrees/ dir"
568 );
569 assert!(!ensure_gitignore_entry(dir.path()).unwrap());
571 let content2 = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
572 assert_eq!(
573 content2.matches(".git-paw/worktrees/").count(),
574 1,
575 ".git-paw/worktrees/ must appear exactly once after repeated init"
576 );
577 }
578
579 #[test]
582 fn has_section_detects_active_header() {
583 assert!(has_section("[supervisor]\nenabled = true\n", "supervisor"));
584 assert!(!has_section("# [supervisor]\n", "supervisor"));
585 assert!(!has_section("[broker]\n", "supervisor"));
586 }
587
588 #[test]
592 fn migrate_preserves_existing_supervisor_and_custom_broker_port() {
593 let dir = TempDir::new().unwrap();
594 let config_path = dir.path().join("config.toml");
595 let initial = r#"[broker]
596enabled = true
597port = 12345
598
599[supervisor]
600enabled = true
601cli = "echo"
602"#;
603 fs::write(&config_path, initial).unwrap();
604
605 let modified = migrate_existing_config(&config_path).unwrap();
606 assert!(
607 !modified,
608 "migrate must be a no-op when [supervisor] already exists"
609 );
610
611 let after = fs::read_to_string(&config_path).unwrap();
612 assert!(
613 after.contains("port = 12345"),
614 "custom broker port must be preserved verbatim; got:\n{after}"
615 );
616 assert!(
617 after.contains("[supervisor]"),
618 "supervisor header must be preserved; got:\n{after}"
619 );
620 assert!(
621 after.contains("cli = \"echo\""),
622 "supervisor cli must be preserved; got:\n{after}"
623 );
624
625 let parsed: crate::config::PawConfig = toml::from_str(&after).unwrap();
627 let supervisor = parsed.supervisor.expect("supervisor present");
628 assert!(supervisor.enabled);
629 assert_eq!(supervisor.cli.as_deref(), Some("echo"));
630 assert_eq!(parsed.broker.port, 12345);
631 }
632
633 #[test]
638 fn migrate_appends_supervisor_section_when_missing_and_keeps_broker_port() {
639 let dir = TempDir::new().unwrap();
640 let config_path = dir.path().join("config.toml");
641 let initial = "[broker]\nenabled = true\nport = 9119\n";
642 fs::write(&config_path, initial).unwrap();
643
644 let modified = migrate_existing_config(&config_path).unwrap();
645 assert!(
646 modified,
647 "migrate must report that the file was modified when appending"
648 );
649
650 let after = fs::read_to_string(&config_path).unwrap();
651 assert!(
653 after.contains("port = 9119"),
654 "broker port must survive migration; got:\n{after}"
655 );
656 assert!(
658 after.contains("[supervisor]"),
659 "supervisor section must be appended; got:\n{after}"
660 );
661
662 let parsed: crate::config::PawConfig = toml::from_str(&after).unwrap();
663 let supervisor = parsed.supervisor.expect("supervisor present");
664 assert!(
665 !supervisor.enabled,
666 "non-interactive migrate should opt out by default"
667 );
668 assert_eq!(parsed.broker.port, 9119);
669 }
670
671 #[test]
674 fn migrate_existing_config_is_idempotent() {
675 let dir = TempDir::new().unwrap();
676 let config_path = dir.path().join("config.toml");
677 fs::write(&config_path, "[broker]\nenabled = true\nport = 9119\n").unwrap();
678
679 migrate_existing_config(&config_path).unwrap();
680 let first = fs::read_to_string(&config_path).unwrap();
681 let modified = migrate_existing_config(&config_path).unwrap();
682 let second = fs::read_to_string(&config_path).unwrap();
683
684 assert!(!modified, "second migrate must be a no-op");
685 assert_eq!(first, second);
686 }
687
688 #[test]
695 fn migrate_against_uncommented_supervisor_does_not_create_duplicate() {
696 let dir = TempDir::new().unwrap();
697 let config_path = dir.path().join("config.toml");
698 let initial = r#"# user-authored config
699branch_prefix = "feat/"
700
701[supervisor]
702enabled = true
703cli = "claude-oss"
704test_command = "just check"
705"#;
706 fs::write(&config_path, initial).unwrap();
707
708 let modified = migrate_existing_config(&config_path).unwrap();
709 assert!(
710 !modified,
711 "migrate must be a no-op when an uncommented [supervisor] block already exists"
712 );
713
714 let after = fs::read_to_string(&config_path).unwrap();
715 let header_count = after.lines().filter(|l| l.trim() == "[supervisor]").count();
716 assert_eq!(
717 header_count, 1,
718 "exactly one [supervisor] header must exist; found {header_count} in:\n{after}"
719 );
720
721 let parsed: crate::config::PawConfig = toml::from_str(&after).expect(
723 "config with uncommented [supervisor] must parse cleanly after migrate (no duplicate key)",
724 );
725 let supervisor = parsed.supervisor.expect("supervisor present");
726 assert!(supervisor.enabled);
727 assert_eq!(supervisor.cli.as_deref(), Some("claude-oss"));
728 assert_eq!(supervisor.test_command.as_deref(), Some("just check"));
729 }
730
731 #[test]
743 fn migrate_against_branch_prefix_only_preserves_user_field() {
744 let dir = TempDir::new().unwrap();
745 let config_path = dir.path().join("config.toml");
746 fs::write(&config_path, "branch_prefix = \"feat/\"\n").unwrap();
747
748 let modified = migrate_existing_config(&config_path).unwrap();
749 assert!(
750 modified,
751 "migrate must append the missing [supervisor] section"
752 );
753
754 let after = fs::read_to_string(&config_path).unwrap();
755 assert!(
756 after.contains("branch_prefix = \"feat/\""),
757 "user branch_prefix must be preserved verbatim; got:\n{after}"
758 );
759 assert!(
760 after.contains("[supervisor]"),
761 "supervisor section must be appended; got:\n{after}"
762 );
763
764 let parsed: crate::config::PawConfig = toml::from_str(&after)
766 .expect("config with branch_prefix + appended supervisor must parse cleanly");
767 assert_eq!(parsed.branch_prefix.as_deref(), Some("feat/"));
768 }
769
770 #[test]
773 fn idempotent_gitignore() {
774 let dir = setup_repo();
775 ensure_gitignore_entry(dir.path()).unwrap();
776 let first = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
777 ensure_gitignore_entry(dir.path()).unwrap();
778 let second = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
779 assert_eq!(first, second);
780 }
781}