Skip to main content

git_paw/
init.rs

1//! Project initialization.
2//!
3//! Implements `git paw init` — creates `.git-paw/` directory, generates
4//! default config, and manages `.gitignore`.
5
6use 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
17/// Gitignore entries managed by init.
18const GITIGNORE_ENTRIES: &[&str] = &[".git-paw/logs/", ".git-paw/session-summary.md"];
19
20/// Bundled supervisor-sweep helper script, embedded at compile time and
21/// written to `<repo>/.git-paw/scripts/sweep.sh` by [`run_init`].
22const SWEEP_SCRIPT: &str = include_str!("../assets/scripts/sweep.sh");
23
24/// Runs the `git paw init` command.
25///
26/// Creates `.git-paw/` directory structure, generates a default config,
27/// installs the bundled `sweep.sh` supervisor helper at
28/// `<repo>/.git-paw/scripts/sweep.sh` (executable mode `0o755` on Unix),
29/// and manages `.gitignore`. The script is overwritten on every invocation
30/// so re-running `git paw init` picks up updates that ship with new
31/// versions of the binary. Idempotent for the other side effects —
32/// running twice produces identical results for the directory tree,
33/// `config.toml`, and `.gitignore`.
34pub fn run_init() -> Result<(), PawError> {
35    let cwd = std::env::current_dir()
36        .map_err(|e| PawError::InitError(format!("cannot read current directory: {e}")))?;
37    let repo_root = git::validate_repo(&cwd)?;
38
39    let paw_dir = repo_root.join(".git-paw");
40    let logs_dir = paw_dir.join("logs");
41    let scripts_dir = paw_dir.join("scripts");
42    let config_path = paw_dir.join("config.toml");
43
44    // 1. Create .git-paw/ directory
45    let created_dir = create_dir_if_missing(&paw_dir)?;
46    if created_dir {
47        println!("  Created .git-paw/");
48    }
49
50    // 2. Create .git-paw/logs/ directory
51    let created_logs = create_dir_if_missing(&logs_dir)?;
52    if created_logs {
53        println!("  Created .git-paw/logs/");
54    }
55
56    // 3. Create .git-paw/scripts/ directory and install sweep.sh.
57    let created_scripts = create_dir_if_missing(&scripts_dir)?;
58    if created_scripts {
59        println!("  Created .git-paw/scripts/");
60    }
61    let sweep_path = scripts_dir.join("sweep.sh");
62    let sweep_existed = sweep_path.exists();
63    install_sweep_script(&sweep_path)?;
64    if sweep_existed {
65        println!("  Updated .git-paw/scripts/sweep.sh");
66    } else {
67        println!("  Created .git-paw/scripts/sweep.sh");
68    }
69
70    // 4. Generate or migrate config. For a fresh config, prompt for supervisor
71    //    preferences and auto-detect `.specify/` to pre-fill `[specs]`. For an
72    //    existing config without a [supervisor] section, append one (prompting
73    //    if stdin is interactive). Init never mutates existing sections — only
74    //    appends missing ones.
75    let (created_config, migrated_config) = if config_path.exists() {
76        let migrated = migrate_existing_config(&config_path)?;
77        (false, migrated)
78    } else {
79        let supervisor_section = prompt_supervisor_section()?;
80        let specs_section = detect_speckit_section(&repo_root);
81        write_config_if_missing(
82            &config_path,
83            Some(&supervisor_section),
84            specs_section.as_deref(),
85        )?;
86        (true, false)
87    };
88    if created_config {
89        println!("  Created .git-paw/config.toml");
90    } else if migrated_config {
91        println!("  Updated .git-paw/config.toml (added missing sections)");
92    }
93
94    // 5. Manage .gitignore
95    let updated_gitignore = ensure_gitignore_entry(&repo_root)?;
96    if updated_gitignore {
97        println!("  Updated .gitignore");
98    }
99
100    if !created_dir && !created_logs && !created_config && !migrated_config && !updated_gitignore {
101        println!("Already initialized. Nothing to do.");
102    } else {
103        println!("Initialized git-paw.");
104    }
105
106    Ok(())
107}
108
109/// Writes the bundled supervisor-sweep helper to `path` and marks it
110/// executable. Overwrites any existing file at `path` (the script is treated
111/// as binary-managed content — users with local edits SHALL back the file up
112/// before re-running `git paw init`).
113fn install_sweep_script(path: &Path) -> Result<(), PawError> {
114    fs::write(path, SWEEP_SCRIPT)
115        .map_err(|e| PawError::InitError(format!("failed to write '{}': {e}", path.display())))?;
116
117    #[cfg(unix)]
118    {
119        use std::os::unix::fs::PermissionsExt;
120        let mut perms = fs::metadata(path)
121            .map_err(|e| PawError::InitError(format!("failed to stat '{}': {e}", path.display())))?
122            .permissions();
123        perms.set_mode(0o755);
124        fs::set_permissions(path, perms).map_err(|e| {
125            PawError::InitError(format!(
126                "failed to set executable bit on '{}': {e}",
127                path.display()
128            ))
129        })?;
130    }
131
132    Ok(())
133}
134
135/// Creates a directory if it doesn't exist. Returns `true` if created.
136fn create_dir_if_missing(path: &Path) -> Result<bool, PawError> {
137    if path.is_dir() {
138        return Ok(false);
139    }
140    fs::create_dir_all(path)
141        .map_err(|e| PawError::InitError(format!("failed to create '{}': {e}", path.display())))?;
142    Ok(true)
143}
144
145/// Appends any missing sections to an existing `config.toml`. Returns `true`
146/// if the file was modified. Does not touch any existing field — this is the
147/// safe upgrade path for new config sections added across versions.
148fn migrate_existing_config(path: &Path) -> Result<bool, PawError> {
149    let existing = fs::read_to_string(path)
150        .map_err(|e| PawError::InitError(format!("failed to read config: {e}")))?;
151
152    let mut appended = String::new();
153
154    // [supervisor] — the only section currently managed by migration. We
155    // detect presence with a simple line-based scan rather than parsing TOML
156    // so we don't lose comments or reorder fields on round-trip.
157    if !has_section(&existing, "supervisor") {
158        let section = prompt_supervisor_section()?;
159        appended.push_str(&section);
160    }
161
162    if appended.is_empty() {
163        return Ok(false);
164    }
165
166    let mut new_content = existing;
167    if !new_content.ends_with('\n') {
168        new_content.push('\n');
169    }
170    new_content.push_str(&appended);
171
172    fs::write(path, new_content)
173        .map_err(|e| PawError::InitError(format!("failed to write config: {e}")))?;
174    Ok(true)
175}
176
177/// Returns `true` if a non-commented `[section]` header exists in `content`.
178fn has_section(content: &str, section: &str) -> bool {
179    let header = format!("[{section}]");
180    content.lines().any(|line| {
181        let trimmed = line.trim_start();
182        !trimmed.starts_with('#') && trimmed.trim_end() == header
183    })
184}
185
186/// Writes the default config if the file doesn't already exist. Returns `true` if written.
187///
188/// If `supervisor_section` is `Some`, it is appended to the generated config so
189/// the user's init-time choice is persisted.
190///
191/// If `specs_section` is `Some`, it is appended to the generated config. This
192/// is how Spec Kit auto-detection persists `[specs] type = "speckit"` at init
193/// time.
194fn write_config_if_missing(
195    path: &Path,
196    supervisor_section: Option<&str>,
197    specs_section: Option<&str>,
198) -> Result<bool, PawError> {
199    if path.exists() {
200        return Ok(false);
201    }
202    let mut content = config::generate_default_config();
203    if let Some(section) = supervisor_section {
204        content.push_str(section);
205    }
206    if let Some(section) = specs_section {
207        content.push_str(section);
208    }
209    fs::write(path, content)
210        .map_err(|e| PawError::InitError(format!("failed to write config: {e}")))?;
211    Ok(true)
212}
213
214/// Returns a TOML `[specs]` section for `speckit` if `.specify/specs/` is
215/// present at `repo_root`, otherwise `None`. The generated section locks the
216/// choice in the config so future runs do not depend on auto-detection.
217fn detect_speckit_section(repo_root: &Path) -> Option<String> {
218    let specify = repo_root.join(".specify");
219    if !specify.is_dir() || !specify.join("specs").is_dir() {
220        return None;
221    }
222    Some(
223        "\n[specs]\n\
224         type = \"speckit\"\n\
225         dir = \".specify/specs\"\n"
226            .to_string(),
227    )
228}
229
230/// Prompts the user for their supervisor preferences and returns a TOML
231/// `[supervisor]` section to append to the generated config.
232///
233/// If the user declines, an explicit `enabled = false` section is returned so
234/// that future `git paw start` calls do not re-prompt.
235fn prompt_supervisor_section() -> Result<String, PawError> {
236    // In non-interactive contexts (CI, tests, piped stdin) fall back to an
237    // explicit opt-out so init remains scriptable.
238    if !std::io::stdin().is_terminal() {
239        return Ok("\n[supervisor]\nenabled = false\n".to_string());
240    }
241
242    let enabled = Confirm::new()
243        .with_prompt("Enable supervisor mode by default?")
244        .default(false)
245        .interact()
246        .map_err(|e| PawError::InitError(format!("prompt failed: {e}")))?;
247
248    if !enabled {
249        return Ok("\n[supervisor]\nenabled = false\n".to_string());
250    }
251
252    let test_command: String = Input::new()
253        .with_prompt("Test command to run after each agent completes (e.g. 'just check', leave empty to skip)")
254        .allow_empty(true)
255        .interact_text()
256        .map_err(|e| PawError::InitError(format!("prompt failed: {e}")))?;
257
258    let mut section = String::from("\n[supervisor]\nenabled = true\n");
259    let trimmed = test_command.trim();
260    if !trimmed.is_empty() {
261        let escaped = trimmed.replace('\\', "\\\\").replace('"', "\\\"");
262        writeln!(section, "test_command = \"{escaped}\"")
263            .map_err(|e| PawError::InitError(format!("format supervisor section: {e}")))?;
264    }
265    Ok(section)
266}
267
268/// Ensures `.gitignore` contains all managed entries. Returns `true` if modified.
269fn ensure_gitignore_entry(repo_root: &Path) -> Result<bool, PawError> {
270    let gitignore_path = repo_root.join(".gitignore");
271
272    let existing = match fs::read_to_string(&gitignore_path) {
273        Ok(content) => content,
274        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
275        Err(e) => {
276            return Err(PawError::InitError(format!(
277                "failed to read .gitignore: {e}"
278            )));
279        }
280    };
281
282    let existing_lines: std::collections::HashSet<&str> = existing.lines().map(str::trim).collect();
283    let missing: Vec<&&str> = GITIGNORE_ENTRIES
284        .iter()
285        .filter(|e| !existing_lines.contains(**e))
286        .collect();
287
288    if missing.is_empty() {
289        return Ok(false);
290    }
291
292    let mut content = existing;
293    if !content.is_empty() && !content.ends_with('\n') {
294        content.push('\n');
295    }
296    for entry in missing {
297        content.push_str(entry);
298        content.push('\n');
299    }
300
301    fs::write(&gitignore_path, content)
302        .map_err(|e| PawError::InitError(format!("failed to write .gitignore: {e}")))?;
303
304    Ok(true)
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use tempfile::TempDir;
311
312    fn setup_repo() -> TempDir {
313        let dir = TempDir::new().unwrap();
314        // Create a minimal .git dir so validate_repo-like checks work
315        fs::create_dir(dir.path().join(".git")).unwrap();
316        dir
317    }
318
319    // --- create_dir_if_missing ---
320
321    #[test]
322    fn creates_directory_when_missing() {
323        let dir = TempDir::new().unwrap();
324        let target = dir.path().join("new-dir");
325        assert!(create_dir_if_missing(&target).unwrap());
326        assert!(target.is_dir());
327    }
328
329    #[test]
330    fn skips_existing_directory() {
331        let dir = TempDir::new().unwrap();
332        let target = dir.path().join("existing");
333        fs::create_dir(&target).unwrap();
334        assert!(!create_dir_if_missing(&target).unwrap());
335    }
336
337    // --- write_config_if_missing ---
338
339    #[test]
340    fn writes_config_when_missing() {
341        let dir = TempDir::new().unwrap();
342        let config_path = dir.path().join("config.toml");
343        assert!(write_config_if_missing(&config_path, None, None).unwrap());
344        let content = fs::read_to_string(&config_path).unwrap();
345        assert!(content.contains("default_cli"));
346    }
347
348    #[test]
349    fn skips_existing_config() {
350        let dir = TempDir::new().unwrap();
351        let config_path = dir.path().join("config.toml");
352        fs::write(&config_path, "existing").unwrap();
353        assert!(!write_config_if_missing(&config_path, None, None).unwrap());
354        assert_eq!(fs::read_to_string(&config_path).unwrap(), "existing");
355    }
356
357    #[test]
358    fn appends_supervisor_section_when_provided() {
359        let dir = TempDir::new().unwrap();
360        let config_path = dir.path().join("config.toml");
361        let section = "\n[supervisor]\nenabled = true\ntest_command = \"just check\"\n";
362        assert!(write_config_if_missing(&config_path, Some(section), None).unwrap());
363
364        let content = fs::read_to_string(&config_path).unwrap();
365        let parsed: crate::config::PawConfig = toml::from_str(&content).unwrap();
366        let supervisor = parsed.supervisor.unwrap();
367        assert!(supervisor.enabled);
368        assert_eq!(supervisor.test_command.as_deref(), Some("just check"));
369    }
370
371    #[test]
372    fn detect_speckit_section_returns_some_when_specify_present() {
373        let dir = setup_repo();
374        fs::create_dir_all(dir.path().join(".specify").join("specs")).unwrap();
375        let section = detect_speckit_section(dir.path()).expect("section");
376        assert!(section.contains("[specs]"));
377        assert!(section.contains("type = \"speckit\""));
378        assert!(section.contains("dir = \".specify/specs\""));
379    }
380
381    #[test]
382    fn detect_speckit_section_none_when_specify_missing() {
383        let dir = setup_repo();
384        assert!(detect_speckit_section(dir.path()).is_none());
385    }
386
387    #[test]
388    fn detect_speckit_section_none_when_specify_lacks_specs_subdir() {
389        let dir = setup_repo();
390        fs::create_dir_all(dir.path().join(".specify").join("memory")).unwrap();
391        assert!(detect_speckit_section(dir.path()).is_none());
392    }
393
394    #[test]
395    fn write_config_appends_specs_section_when_provided() {
396        let dir = TempDir::new().unwrap();
397        let config_path = dir.path().join("config.toml");
398        let specs_section = "\n[specs]\ntype = \"speckit\"\ndir = \".specify/specs\"\n";
399        assert!(write_config_if_missing(&config_path, None, Some(specs_section)).unwrap());
400
401        let content = fs::read_to_string(&config_path).unwrap();
402        let parsed: crate::config::PawConfig = toml::from_str(&content).unwrap();
403        let specs = parsed.specs.expect("specs section parsed");
404        assert_eq!(specs.spec_type.as_deref(), Some("speckit"));
405        assert_eq!(specs.dir.as_deref(), Some(".specify/specs"));
406    }
407
408    #[test]
409    fn appends_disabled_supervisor_section() {
410        let dir = TempDir::new().unwrap();
411        let config_path = dir.path().join("config.toml");
412        let section = "\n[supervisor]\nenabled = false\n";
413        assert!(write_config_if_missing(&config_path, Some(section), None).unwrap());
414
415        let content = fs::read_to_string(&config_path).unwrap();
416        let parsed: crate::config::PawConfig = toml::from_str(&content).unwrap();
417        let supervisor = parsed.supervisor.unwrap();
418        assert!(!supervisor.enabled);
419    }
420
421    // --- ensure_gitignore_entry ---
422
423    #[test]
424    fn creates_gitignore_with_entry() {
425        let dir = setup_repo();
426        assert!(ensure_gitignore_entry(dir.path()).unwrap());
427        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
428        for entry in GITIGNORE_ENTRIES {
429            assert!(content.contains(entry), "missing {entry}");
430        }
431    }
432
433    #[test]
434    fn appends_to_existing_gitignore() {
435        let dir = setup_repo();
436        fs::write(dir.path().join(".gitignore"), "node_modules/\n").unwrap();
437        assert!(ensure_gitignore_entry(dir.path()).unwrap());
438        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
439        assert!(content.contains("node_modules/"));
440        for entry in GITIGNORE_ENTRIES {
441            assert!(content.contains(entry), "missing {entry}");
442        }
443    }
444
445    #[test]
446    fn appends_newline_if_missing() {
447        let dir = setup_repo();
448        fs::write(dir.path().join(".gitignore"), "node_modules/").unwrap();
449        assert!(ensure_gitignore_entry(dir.path()).unwrap());
450        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
451        assert!(content.contains("node_modules/\n"));
452        for entry in GITIGNORE_ENTRIES {
453            assert!(content.contains(entry), "missing {entry}");
454        }
455    }
456
457    #[test]
458    fn skips_when_all_entries_already_present() {
459        let dir = setup_repo();
460        let mut lines = String::from("node_modules/\n");
461        for entry in GITIGNORE_ENTRIES {
462            lines.push_str(entry);
463            lines.push('\n');
464        }
465        fs::write(dir.path().join(".gitignore"), lines).unwrap();
466        assert!(!ensure_gitignore_entry(dir.path()).unwrap());
467    }
468
469    #[test]
470    fn session_summary_added_alongside_logs() {
471        let dir = setup_repo();
472        fs::write(dir.path().join(".gitignore"), ".git-paw/logs/\n").unwrap();
473        assert!(ensure_gitignore_entry(dir.path()).unwrap());
474        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
475        assert!(content.contains(".git-paw/session-summary.md"));
476        assert_eq!(content.matches(".git-paw/logs/").count(), 1);
477    }
478
479    // --- migrate_existing_config ---
480
481    #[test]
482    fn has_section_detects_active_header() {
483        assert!(has_section("[supervisor]\nenabled = true\n", "supervisor"));
484        assert!(!has_section("# [supervisor]\n", "supervisor"));
485        assert!(!has_section("[broker]\n", "supervisor"));
486    }
487
488    /// Migration does not touch existing sections. A config already containing
489    /// `[supervisor]` plus a custom `[broker]` port must round-trip with both
490    /// sections and the custom port intact.
491    #[test]
492    fn migrate_preserves_existing_supervisor_and_custom_broker_port() {
493        let dir = TempDir::new().unwrap();
494        let config_path = dir.path().join("config.toml");
495        let initial = r#"[broker]
496enabled = true
497port = 12345
498
499[supervisor]
500enabled = true
501cli = "echo"
502"#;
503        fs::write(&config_path, initial).unwrap();
504
505        let modified = migrate_existing_config(&config_path).unwrap();
506        assert!(
507            !modified,
508            "migrate must be a no-op when [supervisor] already exists"
509        );
510
511        let after = fs::read_to_string(&config_path).unwrap();
512        assert!(
513            after.contains("port = 12345"),
514            "custom broker port must be preserved verbatim; got:\n{after}"
515        );
516        assert!(
517            after.contains("[supervisor]"),
518            "supervisor header must be preserved; got:\n{after}"
519        );
520        assert!(
521            after.contains("cli = \"echo\""),
522            "supervisor cli must be preserved; got:\n{after}"
523        );
524
525        // The TOML must still parse to a config with the expected fields.
526        let parsed: crate::config::PawConfig = toml::from_str(&after).unwrap();
527        let supervisor = parsed.supervisor.expect("supervisor present");
528        assert!(supervisor.enabled);
529        assert_eq!(supervisor.cli.as_deref(), Some("echo"));
530        assert_eq!(parsed.broker.port, 12345);
531    }
532
533    /// When `[supervisor]` is missing, migrate appends a section. Stdin in
534    /// tests is non-interactive, so the appended section is the explicit
535    /// opt-out (`enabled = false`). The pre-existing `[broker]` section and
536    /// its custom port must remain untouched.
537    #[test]
538    fn migrate_appends_supervisor_section_when_missing_and_keeps_broker_port() {
539        let dir = TempDir::new().unwrap();
540        let config_path = dir.path().join("config.toml");
541        let initial = "[broker]\nenabled = true\nport = 9119\n";
542        fs::write(&config_path, initial).unwrap();
543
544        let modified = migrate_existing_config(&config_path).unwrap();
545        assert!(
546            modified,
547            "migrate must report that the file was modified when appending"
548        );
549
550        let after = fs::read_to_string(&config_path).unwrap();
551        // Original section preserved.
552        assert!(
553            after.contains("port = 9119"),
554            "broker port must survive migration; got:\n{after}"
555        );
556        // Section appended.
557        assert!(
558            after.contains("[supervisor]"),
559            "supervisor section must be appended; got:\n{after}"
560        );
561
562        let parsed: crate::config::PawConfig = toml::from_str(&after).unwrap();
563        let supervisor = parsed.supervisor.expect("supervisor present");
564        assert!(
565            !supervisor.enabled,
566            "non-interactive migrate should opt out by default"
567        );
568        assert_eq!(parsed.broker.port, 9119);
569    }
570
571    /// Running migrate twice must produce identical content — the second run
572    /// has nothing to do.
573    #[test]
574    fn migrate_existing_config_is_idempotent() {
575        let dir = TempDir::new().unwrap();
576        let config_path = dir.path().join("config.toml");
577        fs::write(&config_path, "[broker]\nenabled = true\nport = 9119\n").unwrap();
578
579        migrate_existing_config(&config_path).unwrap();
580        let first = fs::read_to_string(&config_path).unwrap();
581        let modified = migrate_existing_config(&config_path).unwrap();
582        let second = fs::read_to_string(&config_path).unwrap();
583
584        assert!(!modified, "second migrate must be a no-op");
585        assert_eq!(first, second);
586    }
587
588    /// Bug F (v0-5-0-audit-cleanup §9d) — a config with an UNCOMMENTED
589    /// `[supervisor]` block must survive migrate without growing a
590    /// duplicate header. `has_section` is comment-aware: it only
591    /// matches active headers, so the uncommented user block is
592    /// detected and no stanza is appended. The file MUST still parse as
593    /// valid TOML afterwards (no `duplicate key` error).
594    #[test]
595    fn migrate_against_uncommented_supervisor_does_not_create_duplicate() {
596        let dir = TempDir::new().unwrap();
597        let config_path = dir.path().join("config.toml");
598        let initial = r#"# user-authored config
599branch_prefix = "feat/"
600
601[supervisor]
602enabled = true
603cli = "claude-oss"
604test_command = "just check"
605"#;
606        fs::write(&config_path, initial).unwrap();
607
608        let modified = migrate_existing_config(&config_path).unwrap();
609        assert!(
610            !modified,
611            "migrate must be a no-op when an uncommented [supervisor] block already exists"
612        );
613
614        let after = fs::read_to_string(&config_path).unwrap();
615        let header_count = after.lines().filter(|l| l.trim() == "[supervisor]").count();
616        assert_eq!(
617            header_count, 1,
618            "exactly one [supervisor] header must exist; found {header_count} in:\n{after}"
619        );
620
621        // Crucially, the file must parse without a duplicate-key error.
622        let parsed: crate::config::PawConfig = toml::from_str(&after).expect(
623            "config with uncommented [supervisor] must parse cleanly after migrate (no duplicate key)",
624        );
625        let supervisor = parsed.supervisor.expect("supervisor present");
626        assert!(supervisor.enabled);
627        assert_eq!(supervisor.cli.as_deref(), Some("claude-oss"));
628        assert_eq!(supervisor.test_command.as_deref(), Some("just check"));
629    }
630
631    /// 9d.7 sibling — when a user writes `branch_prefix = "feat/"` only
632    /// (no sections), running migrate appends the disabled
633    /// `[supervisor]` opt-out and preserves the user's `branch_prefix`.
634    /// The file parses as valid TOML.
635    ///
636    /// NOTE: the wider variant of 9d.7 (also appending commented
637    /// stanzas for `[broker]`, `[dashboard]`, etc.) is intentionally
638    /// deferred — it is a feature addition (richer migration), not a
639    /// bug fix. The current scope of `migrate_existing_config` is
640    /// limited to the `[supervisor]` section per the existing tests in
641    /// this module.
642    #[test]
643    fn migrate_against_branch_prefix_only_preserves_user_field() {
644        let dir = TempDir::new().unwrap();
645        let config_path = dir.path().join("config.toml");
646        fs::write(&config_path, "branch_prefix = \"feat/\"\n").unwrap();
647
648        let modified = migrate_existing_config(&config_path).unwrap();
649        assert!(
650            modified,
651            "migrate must append the missing [supervisor] section"
652        );
653
654        let after = fs::read_to_string(&config_path).unwrap();
655        assert!(
656            after.contains("branch_prefix = \"feat/\""),
657            "user branch_prefix must be preserved verbatim; got:\n{after}"
658        );
659        assert!(
660            after.contains("[supervisor]"),
661            "supervisor section must be appended; got:\n{after}"
662        );
663
664        // Most importantly: the result parses as valid TOML.
665        let parsed: crate::config::PawConfig = toml::from_str(&after)
666            .expect("config with branch_prefix + appended supervisor must parse cleanly");
667        assert_eq!(parsed.branch_prefix.as_deref(), Some("feat/"));
668    }
669
670    // --- Idempotency ---
671
672    #[test]
673    fn idempotent_gitignore() {
674        let dir = setup_repo();
675        ensure_gitignore_entry(dir.path()).unwrap();
676        let first = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
677        ensure_gitignore_entry(dir.path()).unwrap();
678        let second = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
679        assert_eq!(first, second);
680    }
681}