Skip to main content

edict/commands/
sync.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use clap::Args;
6use sha2::{Digest, Sha256};
7
8use crate::config::Config;
9use crate::error::ExitError;
10use crate::subprocess::{Tool, run_command};
11use crate::template::{TemplateContext, update_managed_section};
12
13#[derive(Debug, Args)]
14pub struct SyncArgs {
15    /// Project root directory
16    #[arg(long)]
17    pub project_root: Option<PathBuf>,
18    /// Check mode: exit non-zero if anything is stale, without making changes
19    #[arg(long)]
20    pub check: bool,
21    /// Disable auto-commit (default: enabled)
22    #[arg(long)]
23    pub no_commit: bool,
24}
25
26/// Embedded workflow docs
27pub(crate) const WORKFLOW_DOCS: &[(&str, &str)] = &[
28    ("triage.md", include_str!("../templates/docs/triage.md")),
29    ("start.md", include_str!("../templates/docs/start.md")),
30    ("update.md", include_str!("../templates/docs/update.md")),
31    ("finish.md", include_str!("../templates/docs/finish.md")),
32    (
33        "worker-loop.md",
34        include_str!("../templates/docs/worker-loop.md"),
35    ),
36    ("planning.md", include_str!("../templates/docs/planning.md")),
37    ("scout.md", include_str!("../templates/docs/scout.md")),
38    ("proposal.md", include_str!("../templates/docs/proposal.md")),
39    (
40        "review-request.md",
41        include_str!("../templates/docs/review-request.md"),
42    ),
43    (
44        "review-response.md",
45        include_str!("../templates/docs/review-response.md"),
46    ),
47    (
48        "review-loop.md",
49        include_str!("../templates/docs/review-loop.md"),
50    ),
51    (
52        "merge-check.md",
53        include_str!("../templates/docs/merge-check.md"),
54    ),
55    (
56        "preflight.md",
57        include_str!("../templates/docs/preflight.md"),
58    ),
59    (
60        "cross-channel.md",
61        include_str!("../templates/docs/cross-channel.md"),
62    ),
63    (
64        "report-issue.md",
65        include_str!("../templates/docs/report-issue.md"),
66    ),
67    ("groom.md", include_str!("../templates/docs/groom.md")),
68    ("mission.md", include_str!("../templates/docs/mission.md")),
69    (
70        "coordination.md",
71        include_str!("../templates/docs/coordination.md"),
72    ),
73];
74
75/// Embedded design docs
76pub(crate) const DESIGN_DOCS: &[(&str, &str)] = &[(
77    "cli-conventions.md",
78    include_str!("../templates/design/cli-conventions.md"),
79)];
80
81/// Embedded reviewer prompts
82pub(crate) const REVIEWER_PROMPTS: &[(&str, &str)] = &[
83    (
84        "reviewer.md",
85        include_str!("../templates/reviewer.md.jinja"),
86    ),
87    (
88        "reviewer-security.md",
89        include_str!("../templates/reviewer-security.md.jinja"),
90    ),
91];
92
93impl SyncArgs {
94    pub fn execute(&self) -> Result<()> {
95        let project_root = self
96            .project_root
97            .clone()
98            .unwrap_or_else(|| std::env::current_dir().expect("Failed to get current dir"));
99
100        // Detect maw v2 bare repo
101        if crate::config::find_config(&project_root.join("ws/default")).is_some() {
102            return self.handle_bare_repo(&project_root);
103        }
104
105        // Check for agents dir — accept new (.agents/edict/) or legacy (.agents/botbox/)
106        let agents_dir_edict = project_root.join(".agents/edict");
107        let agents_dir_legacy = project_root.join(".agents/botbox");
108        if !agents_dir_edict.exists() && !agents_dir_legacy.exists() {
109            return Err(ExitError::Other(
110                "No .agents/edict/ found. Run `edict init` first.".to_string(),
111            )
112            .into());
113        }
114
115        // Load config (.edict.toml preferred, legacy names as fallback)
116        let config_path = crate::config::find_config(&project_root).ok_or_else(|| {
117            ExitError::Config("No .edict.toml or .botbox.toml found".to_string())
118        })?;
119        let config = Config::load(&config_path)
120            .with_context(|| format!("Failed to parse {}", config_path.display()))?;
121
122        // Migrate .botbox.json -> .edict.toml if needed (JSON is oldest legacy)
123        let json_path = project_root.join(crate::config::CONFIG_JSON);
124        let toml_path = project_root.join(crate::config::CONFIG_TOML);
125        if json_path.exists() && !toml_path.exists() {
126            let json_content = fs::read_to_string(&json_path)?;
127            match crate::config::json_to_toml(&json_content) {
128                Ok(toml_content) => {
129                    fs::write(&toml_path, &toml_content)?;
130                    fs::remove_file(&json_path)?;
131                    println!("Migrated .botbox.json -> .edict.toml");
132                }
133                Err(e) => {
134                    tracing::warn!("failed to migrate .botbox.json to .edict.toml: {e}");
135                }
136            }
137        }
138
139        // Migrate .botbox.toml -> .edict.toml (botbox era → edict era)
140        let legacy_toml_path = project_root.join(crate::config::CONFIG_TOML_LEGACY);
141        if legacy_toml_path.exists() && !toml_path.exists() {
142            match fs::rename(&legacy_toml_path, &toml_path) {
143                Ok(()) => println!("Migrated .botbox.toml -> .edict.toml"),
144                Err(e) => tracing::warn!("failed to rename .botbox.toml to .edict.toml: {e}"),
145            }
146        }
147
148        // Migrate .agents/botbox/ -> .agents/edict/ (botbox era → edict era)
149        if agents_dir_legacy.exists() && !agents_dir_edict.exists() {
150            match fs::rename(&agents_dir_legacy, &agents_dir_edict) {
151                Ok(()) => println!("Migrated .agents/botbox/ -> .agents/edict/"),
152                Err(e) => tracing::warn!("failed to rename .agents/botbox/ to .agents/edict/: {e}"),
153            }
154        }
155
156        // Resolved agents dir (after any migration above)
157        let agents_dir = if agents_dir_edict.exists() {
158            agents_dir_edict
159        } else {
160            agents_dir_legacy
161        };
162
163        // Check staleness for each component
164        let docs_stale = self.check_docs_staleness(&agents_dir)?;
165        let managed_stale = self.check_managed_section_staleness(&project_root, &config)?;
166        let prompts_stale = self.check_prompts_staleness(&agents_dir)?;
167        let design_docs_stale = self.check_design_docs_staleness(&agents_dir)?;
168
169        let any_stale =
170            docs_stale || managed_stale || prompts_stale || design_docs_stale;
171
172        if self.check {
173            if any_stale {
174                let mut parts = Vec::new();
175                if docs_stale {
176                    parts.push("workflow docs");
177                }
178                if managed_stale {
179                    parts.push("AGENTS.md managed section");
180                }
181                if prompts_stale {
182                    parts.push("reviewer prompts");
183                }
184                if design_docs_stale {
185                    parts.push("design docs");
186                }
187                tracing::warn!(components = %parts.join(", "), "stale components detected");
188                return Err(ExitError::new(1, "Project is out of sync".to_string()).into());
189            } else {
190                println!("All components up to date");
191                return Ok(());
192            }
193        }
194
195        // Clean up per-repo hooks (now managed globally)
196        self.cleanup_per_repo_hooks(&project_root)?;
197
198        // Perform updates
199        let mut changed_files = Vec::new();
200
201        if docs_stale {
202            self.sync_workflow_docs(&agents_dir)?;
203            changed_files.push(".agents/edict/*.md");
204            println!("Updated workflow docs");
205        }
206
207        if managed_stale {
208            self.sync_managed_section(&project_root, &config)?;
209            changed_files.push("AGENTS.md");
210            println!("Updated AGENTS.md managed section");
211        }
212
213        if prompts_stale {
214            self.sync_prompts(&agents_dir)?;
215            changed_files.push(".agents/edict/prompts/*.md");
216            println!("Updated reviewer prompts");
217        }
218
219        if design_docs_stale {
220            self.sync_design_docs(&agents_dir)?;
221            changed_files.push(".agents/edict/design/*.md");
222            println!("Updated design docs");
223        }
224
225        // Clean up legacy JS artifacts (scripts, shell hooks)
226        self.cleanup_legacy_artifacts(&agents_dir, &mut changed_files);
227
228        // Migrate bus hooks from bun .mjs to edict run
229        migrate_bus_hooks(&config);
230
231        // Migrate bus hooks from botbox: descriptions to edict: descriptions
232        migrate_botbox_bus_hooks_to_edict(&config, &project_root);
233
234        // Fix hook --cwd for maw v2 (ws/default → repo root)
235        migrate_hook_cwd(&config, &project_root);
236
237        // Migrate router hook claim from agent://{name}-router → agent://{name}-dev
238        migrate_router_hook_claim(&config, &project_root);
239
240        // Migrate botty → vessel (config key + bus hooks)
241        if !self.check {
242            migrate_vessel_hooks(&config, &project_root, &config_path);
243        }
244
245        // Migrate beads → bones (config, data, tooling files)
246        if !self.check {
247            migrate_beads_to_bones(&project_root, &config_path)?;
248        }
249
250        // Auto-commit if changes were made
251        if !changed_files.is_empty() && !self.no_commit {
252            self.auto_commit(&project_root, &changed_files)?;
253        }
254
255        println!("Sync complete");
256        Ok(())
257    }
258
259    fn handle_bare_repo(&self, project_root: &Path) -> Result<()> {
260        // Canonicalize project_root to prevent path traversal
261        let project_root = project_root
262            .canonicalize()
263            .context("canonicalizing project root")?;
264
265        // Validate this is actually an edict project
266        if crate::config::find_config(&project_root).is_none()
267            && crate::config::find_config(&project_root.join("ws/default")).is_none()
268        {
269            anyhow::bail!(
270                "not an edict project: no .edict.toml or .botbox.toml found in {}",
271                project_root.display()
272            );
273        }
274
275        let mut args = vec!["exec", "default", "--", "edict", "sync"];
276        if self.check {
277            args.push("--check");
278        }
279        if self.no_commit {
280            args.push("--no-commit");
281        }
282
283        run_command("maw", &args, Some(&project_root))?;
284
285        // Clean up stale legacy config files at bare repo root.
286        //
287        // After migration runs inside ws/default/, the bare root may still have stale
288        // .botbox.json or .botbox.toml files. Agents resolving config from the project root
289        // would find these before the authoritative ws/default/.edict.toml.
290        //
291        // Only remove when ws/default has a config, ensuring the authoritative config is in place.
292        let ws_has_config = crate::config::find_config(&project_root.join("ws/default")).is_some();
293        for stale_name in &[crate::config::CONFIG_JSON, crate::config::CONFIG_TOML_LEGACY] {
294            let stale_path = project_root.join(stale_name);
295            if stale_path.exists() && ws_has_config {
296                if self.check {
297                    tracing::warn!("stale {stale_name} at bare repo root (will be removed on sync)");
298                    return Err(
299                        ExitError::new(1, format!("Stale {stale_name} at bare repo root")).into(),
300                    );
301                } else {
302                    match fs::remove_file(&stale_path) {
303                        Ok(()) => println!(
304                            "Removed stale {stale_name} from bare repo root \
305                             (authoritative config lives in ws/default/)"
306                        ),
307                        Err(e) => {
308                            tracing::warn!("failed to remove stale {stale_name} at bare root: {e}")
309                        }
310                    }
311                }
312            }
313        }
314
315        // Create stubs at bare root
316        let stub_agents = project_root.join("AGENTS.md");
317        let stub_content = "**Do not edit the root AGENTS.md for memories or instructions. Use the AGENTS.md in ws/default/.**\n@ws/default/AGENTS.md\n";
318
319        if !stub_agents.exists() {
320            fs::write(&stub_agents, stub_content)?;
321            println!("Created bare-root AGENTS.md stub");
322        }
323
324        // Symlink .claude directory — use atomic approach to avoid TOCTOU
325        let root_claude_dir = project_root.join(".claude");
326        let ws_claude_dir = project_root.join("ws/default/.claude");
327
328        if ws_claude_dir.exists() {
329            // Check if already a correct symlink
330            let needs_symlink = match fs::read_link(&root_claude_dir) {
331                Ok(target) => target != Path::new("ws/default/.claude"),
332                Err(_) => true,
333            };
334
335            if needs_symlink {
336                // Use atomic rename pattern: create temp symlink, then rename over target
337                let tmp_link = project_root.join(".claude.tmp");
338                let _ = fs::remove_file(&tmp_link); // clean up any stale temp
339                #[cfg(unix)]
340                std::os::unix::fs::symlink("ws/default/.claude", &tmp_link)?;
341                #[cfg(windows)]
342                std::os::windows::fs::symlink_dir("ws/default/.claude", &tmp_link)?;
343
344                // Atomic rename (on same filesystem)
345                if let Err(e) = fs::rename(&tmp_link, &root_claude_dir) {
346                    let _ = fs::remove_file(&tmp_link);
347                    return Err(e).context("creating .claude symlink");
348                }
349                println!("Symlinked .claude → ws/default/.claude");
350            }
351        }
352
353        // Symlink .pi directory
354        let root_pi_dir = project_root.join(".pi");
355        let ws_pi_dir = project_root.join("ws/default/.pi");
356
357        if ws_pi_dir.exists() {
358            let needs_symlink = match fs::read_link(&root_pi_dir) {
359                Ok(target) => target != Path::new("ws/default/.pi"),
360                Err(_) => true,
361            };
362
363            if needs_symlink {
364                let tmp_link = project_root.join(".pi.tmp");
365                let _ = fs::remove_file(&tmp_link);
366                #[cfg(unix)]
367                std::os::unix::fs::symlink("ws/default/.pi", &tmp_link)?;
368                #[cfg(windows)]
369                std::os::windows::fs::symlink_dir("ws/default/.pi", &tmp_link)?;
370
371                if let Err(e) = fs::rename(&tmp_link, &root_pi_dir) {
372                    let _ = fs::remove_file(&tmp_link);
373                    return Err(e).context("creating .pi symlink");
374                }
375                println!("Symlinked .pi → ws/default/.pi");
376            }
377        }
378
379        Ok(())
380    }
381
382    /// Remove legacy JS-era artifacts that are no longer needed.
383    /// The Rust rewrite builds loops into the binary, so .mjs scripts and
384    /// shell hook wrappers are dead weight.
385    fn cleanup_legacy_artifacts(&self, agents_dir: &Path, changed_files: &mut Vec<&str>) {
386        // Remove .agents/botbox/scripts/ (JS loop scripts)
387        let scripts_dir = agents_dir.join("scripts");
388        if scripts_dir.is_dir() {
389            if self.check {
390                tracing::warn!("legacy scripts/ directory exists (will be removed on sync)");
391            } else {
392                match fs::remove_dir_all(&scripts_dir) {
393                    Ok(_) => {
394                        println!("Removed legacy scripts/ directory");
395                        changed_files.push(".agents/botbox/scripts/");
396                    }
397                    Err(e) => tracing::warn!("failed to remove legacy scripts/: {e}"),
398                }
399            }
400        }
401
402        // Remove .agents/botbox/hooks/ (shell hook scripts — now built into botbox binary)
403        let hooks_dir = agents_dir.join("hooks");
404        if hooks_dir.is_dir() {
405            if self.check {
406                tracing::warn!("legacy hooks/ directory exists (will be removed on sync)");
407            } else {
408                match fs::remove_dir_all(&hooks_dir) {
409                    Ok(_) => {
410                        println!("Removed legacy hooks/ directory");
411                        changed_files.push(".agents/botbox/hooks/");
412                    }
413                    Err(e) => tracing::warn!("failed to remove legacy hooks/: {e}"),
414                }
415            }
416        }
417
418        // Remove stale version markers from JS era
419        for marker in &[".scripts-version", ".hooks-version"] {
420            let path = agents_dir.join(marker);
421            if path.exists() && !self.check {
422                let _ = fs::remove_file(&path);
423            }
424        }
425    }
426
427    fn check_docs_staleness(&self, agents_dir: &Path) -> Result<bool> {
428        let version_file = agents_dir.join(".version");
429        let current = compute_docs_version();
430
431        if !version_file.exists() {
432            return Ok(true);
433        }
434
435        let installed = fs::read_to_string(&version_file)?.trim().to_string();
436        Ok(installed != current)
437    }
438
439    fn check_managed_section_staleness(
440        &self,
441        project_root: &Path,
442        config: &Config,
443    ) -> Result<bool> {
444        let agents_md = project_root.join("AGENTS.md");
445        if !agents_md.exists() {
446            return Ok(false); // No AGENTS.md to update
447        }
448
449        let content = fs::read_to_string(&agents_md)?;
450        let ctx = TemplateContext::from_config(config);
451        let updated = update_managed_section(&content, &ctx)?;
452
453        Ok(content != updated)
454    }
455
456    fn check_prompts_staleness(&self, agents_dir: &Path) -> Result<bool> {
457        let version_file = agents_dir.join("prompts/.prompts-version");
458        let current = compute_prompts_version();
459
460        if !version_file.exists() {
461            return Ok(true);
462        }
463
464        let installed = fs::read_to_string(&version_file)?.trim().to_string();
465        Ok(installed != current)
466    }
467
468    /// Clean up per-repo hooks that are now managed globally.
469    /// Removes botbox hooks from per-repo .claude/settings.json and .pi/extensions/.
470    fn cleanup_per_repo_hooks(&self, project_root: &Path) -> Result<()> {
471        if self.check {
472            return Ok(());
473        }
474
475        // Clean up per-repo .claude/settings.json botbox hooks
476        let settings_path = project_root.join(".claude/settings.json");
477        if settings_path.exists() {
478            let content = fs::read_to_string(&settings_path)?;
479            if let Ok(mut settings) = serde_json::from_str::<serde_json::Value>(&content) {
480                let mut changed = false;
481                if let Some(hooks) = settings.get_mut("hooks").and_then(|h| h.as_object_mut()) {
482                    for (_event, entries) in hooks.iter_mut() {
483                        if let Some(arr) = entries.as_array_mut() {
484                            let before = arr.len();
485                            arr.retain(|entry| {
486                                !entry["hooks"]
487                                    .as_array()
488                                    .is_some_and(|hooks| {
489                                        hooks.iter().any(|h| {
490                                            let cmd = &h["command"];
491                                            if let Some(s) = cmd.as_str() {
492                                                s.contains("botbox hooks run")
493                                            } else if let Some(a) = cmd.as_array() {
494                                                a.len() >= 3
495                                                    && a[0].as_str() == Some("botbox")
496                                                    && a[1].as_str() == Some("hooks")
497                                                    && a[2].as_str() == Some("run")
498                                            } else {
499                                                false
500                                            }
501                                        })
502                                    })
503                            });
504                            if arr.len() != before {
505                                changed = true;
506                            }
507                        }
508                    }
509                    // Remove empty event arrays
510                    hooks.retain(|_, v| {
511                        v.as_array().map(|a| !a.is_empty()).unwrap_or(true)
512                    });
513                }
514
515                if changed {
516                    // Remove hooks key entirely if empty
517                    if settings
518                        .get("hooks")
519                        .and_then(|h| h.as_object())
520                        .is_some_and(|h| h.is_empty())
521                    {
522                        settings.as_object_mut().unwrap().remove("hooks");
523                    }
524
525                    // Only write back if there's other content; delete if empty
526                    if settings.as_object().is_some_and(|o| o.is_empty()) {
527                        fs::remove_file(&settings_path)?;
528                        // Also remove .claude dir if empty
529                        let claude_dir = project_root.join(".claude");
530                        if claude_dir.exists() && fs::read_dir(&claude_dir)?.next().is_none() {
531                            fs::remove_dir(&claude_dir)?;
532                        }
533                    } else {
534                        fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
535                    }
536                    println!("Cleaned up per-repo botbox hooks from .claude/settings.json (now managed globally via `botbox hooks install`)");
537                }
538            }
539        }
540
541        // Clean up per-repo Pi extension
542        let pi_ext = project_root.join(".pi/extensions/botbox-hooks.ts");
543        if pi_ext.exists() {
544            fs::remove_file(&pi_ext)?;
545            // Clean up empty dirs
546            let pi_ext_dir = project_root.join(".pi/extensions");
547            if pi_ext_dir.exists() && fs::read_dir(&pi_ext_dir)?.next().is_none() {
548                fs::remove_dir(&pi_ext_dir)?;
549            }
550            let pi_dir = project_root.join(".pi");
551            if pi_dir.exists() && fs::read_dir(&pi_dir)?.next().is_none() {
552                fs::remove_dir(&pi_dir)?;
553            }
554            println!("Cleaned up per-repo Pi extension (now managed globally via `botbox hooks install`)");
555        }
556
557        Ok(())
558    }
559
560    fn check_design_docs_staleness(&self, agents_dir: &Path) -> Result<bool> {
561        let version_file = agents_dir.join("design/.design-docs-version");
562        let current = compute_design_docs_version();
563
564        if !version_file.exists() {
565            return Ok(true);
566        }
567
568        let installed = fs::read_to_string(&version_file)?.trim().to_string();
569        Ok(installed != current)
570    }
571
572    fn sync_workflow_docs(&self, agents_dir: &Path) -> Result<()> {
573        for (name, content) in WORKFLOW_DOCS {
574            let path = agents_dir.join(name);
575            fs::write(&path, content)
576                .with_context(|| format!("Failed to write {}", path.display()))?;
577        }
578
579        let version = compute_docs_version();
580        fs::write(agents_dir.join(".version"), version)?;
581
582        Ok(())
583    }
584
585    fn sync_managed_section(&self, project_root: &Path, config: &Config) -> Result<()> {
586        let agents_md = project_root.join("AGENTS.md");
587        if !agents_md.exists() {
588            return Ok(()); // Skip if no AGENTS.md
589        }
590
591        let content = fs::read_to_string(&agents_md)?;
592        let ctx = TemplateContext::from_config(config);
593        let updated = update_managed_section(&content, &ctx)?;
594
595        fs::write(&agents_md, updated)?;
596        Ok(())
597    }
598
599    fn sync_prompts(&self, agents_dir: &Path) -> Result<()> {
600        let prompts_dir = agents_dir.join("prompts");
601        fs::create_dir_all(&prompts_dir)?;
602
603        for (name, content) in REVIEWER_PROMPTS {
604            let path = prompts_dir.join(name);
605            fs::write(&path, content)
606                .with_context(|| format!("Failed to write {}", path.display()))?;
607        }
608
609        let version = compute_prompts_version();
610        fs::write(prompts_dir.join(".prompts-version"), version)?;
611
612        Ok(())
613    }
614
615    // sync_hooks removed — hooks are now installed globally via `botbox hooks install`
616
617    fn sync_design_docs(&self, agents_dir: &Path) -> Result<()> {
618        let design_dir = agents_dir.join("design");
619        fs::create_dir_all(&design_dir)?;
620
621        for (name, content) in DESIGN_DOCS {
622            let path = design_dir.join(name);
623            fs::write(&path, content)
624                .with_context(|| format!("Failed to write {}", path.display()))?;
625        }
626
627        let version = compute_design_docs_version();
628        fs::write(design_dir.join(".design-docs-version"), version)?;
629
630        Ok(())
631    }
632
633    fn auto_commit(&self, project_root: &Path, changed_files: &[&str]) -> Result<()> {
634        // Detect VCS: prefer jj if available, fall back to git
635        let vcs = detect_vcs(project_root);
636        if vcs == Vcs::None {
637            return Ok(()); // No VCS found, skip commit
638        }
639
640        // All paths that botbox sync may touch — git add is a no-op for unchanged files
641        let managed_paths: &[&str] = &[
642            ".agents/botbox/",
643            "AGENTS.md",
644            ".critignore",
645            ".botbox.toml",
646            ".botbox.json",
647            ".gitignore",
648        ];
649
650        // Build a human-readable summary from the caller's changed_files list
651        let files_str: String = changed_files
652            .join(", ")
653            .chars()
654            .filter(|c| !c.is_control())
655            .collect();
656        let message = format!("chore: edict sync (updated {})", files_str);
657
658        match vcs {
659            Vcs::Jj => {
660                run_command("jj", &["describe", "-m", &message], Some(project_root))?;
661                // Finalize: create new empty commit and advance main bookmark
662                run_command("jj", &["new", "-m", ""], Some(project_root))?;
663                run_command(
664                    "jj",
665                    &["bookmark", "set", "main", "-r", "@-"],
666                    Some(project_root),
667                )?;
668            }
669            Vcs::Git => {
670                // Stage managed paths that exist — git add errors on missing pathspecs
671                let existing: Vec<&str> = managed_paths
672                    .iter()
673                    .copied()
674                    .filter(|p| project_root.join(p).exists())
675                    .collect();
676                if existing.is_empty() {
677                    return Ok(());
678                }
679                let mut args = vec!["add", "--"];
680                args.extend_from_slice(&existing);
681                run_command("git", &args, Some(project_root))?;
682
683                // Only commit if there are staged changes
684                let status = run_command(
685                    "git",
686                    &["diff", "--cached", "--quiet"],
687                    Some(project_root),
688                );
689                if status.is_err() {
690                    // diff --cached --quiet exits 1 when there are staged changes
691                    run_command("git", &["commit", "-m", &message], Some(project_root))?;
692                }
693            }
694            Vcs::None => unreachable!(),
695        }
696
697        Ok(())
698    }
699}
700
701/// Migrate bus hooks from `botbox:` descriptions to `edict:` descriptions.
702///
703/// Finds hooks with `botbox:{name}:responder` or `botbox:{name}:reviewer-*` descriptions,
704/// removes them, and re-registers with `edict:` prefix and `edict run` commands.
705/// Called during `edict sync` on projects that were previously set up with `botbox`.
706fn migrate_botbox_bus_hooks_to_edict(config: &Config, project_root: &Path) {
707    let output = match Tool::new("bus")
708        .args(&["hooks", "list", "--format", "json"])
709        .run()
710    {
711        Ok(o) if o.success() => o,
712        _ => return,
713    };
714
715    let parsed: serde_json::Value = match serde_json::from_str(&output.stdout) {
716        Ok(v) => v,
717        Err(_) => return,
718    };
719
720    let hooks = match parsed.get("hooks").and_then(|h| h.as_array()) {
721        Some(h) => h,
722        None => return,
723    };
724
725    let name = &config.project.name;
726
727    // Resolve the correct cwd (bare root or project root)
728    let bare_root = if project_root.ends_with("ws/default") {
729        project_root
730            .parent()
731            .and_then(Path::parent)
732            .filter(|r| r.join(".manifold").exists())
733    } else if project_root.join(".manifold").exists() {
734        Some(project_root)
735    } else {
736        None
737    };
738    let root_str = bare_root
739        .map(|r| r.display().to_string())
740        .unwrap_or_else(|| project_root.display().to_string());
741
742    for hook in hooks {
743        let desc = hook
744            .get("description")
745            .and_then(|d| d.as_str())
746            .unwrap_or("");
747
748        // Only process botbox-era hooks for this project
749        if !desc.starts_with(&format!("botbox:{name}:")) {
750            continue;
751        }
752
753        let id = match hook.get("id").and_then(|i| i.as_str()) {
754            Some(id) => id,
755            None => continue,
756        };
757
758        // Remove old botbox hook
759        if Tool::new("bus")
760            .args(&["hooks", "remove", id])
761            .run()
762            .is_err()
763        {
764            tracing::warn!(hook_id = %id, "failed to remove botbox-era hook during edict migration");
765            continue;
766        }
767
768        let agent = config.default_agent();
769        if desc.ends_with(":responder") {
770            let responder_ml = config
771                .agents
772                .responder
773                .as_ref()
774                .and_then(|r| r.memory_limit.as_deref());
775            super::init::register_router_hook(&root_str, &root_str, name, &agent, responder_ml);
776            println!("  Migrated hook {desc} → edict:{name}:responder");
777        } else if let Some(role) = desc.strip_prefix(&format!("botbox:{name}:reviewer-")) {
778            let reviewer_agent = format!("{name}-{role}");
779            let reviewer_ml = config
780                .agents
781                .reviewer
782                .as_ref()
783                .and_then(|r| r.memory_limit.as_deref());
784            super::init::register_reviewer_hook(
785                &root_str,
786                &root_str,
787                name,
788                &agent,
789                &reviewer_agent,
790                reviewer_ml,
791            );
792            println!("  Migrated hook {desc} → edict:{name}:reviewer-{role}");
793        }
794    }
795}
796
797/// Migrate bus hooks from legacy formats to current `edict run` commands with descriptions.
798///
799/// Lists all hooks for this project's channel, identifies legacy hooks
800/// (bun-based, old naming, missing descriptions), removes them, and
801/// re-registers via `ensure_bus_hook` with proper descriptions for
802/// future idempotent management.
803fn migrate_bus_hooks(config: &Config) {
804    let output = match Tool::new("bus")
805        .args(&["hooks", "list", "--format", "json"])
806        .run()
807    {
808        Ok(o) if o.success() => o,
809        _ => return, // bus not available, skip silently
810    };
811
812    let parsed: serde_json::Value = match serde_json::from_str(&output.stdout) {
813        Ok(v) => v,
814        Err(_) => return,
815    };
816
817    let hooks = match parsed.get("hooks").and_then(|h| h.as_array()) {
818        Some(h) => h,
819        None => return,
820    };
821
822    let name = &config.project.name;
823    let agent = config.default_agent();
824    let env_inherit = "BOTBUS_CHANNEL,BOTBUS_MESSAGE_ID,BOTBUS_HOOK_ID,SSH_AUTH_SOCK,OTEL_EXPORTER_OTLP_ENDPOINT,TRACEPARENT";
825
826    for hook in hooks {
827        let id = match hook.get("id").and_then(|i| i.as_str()) {
828            Some(id) => id.to_string(),
829            None => continue,
830        };
831
832        let channel = hook.get("channel").and_then(|c| c.as_str()).unwrap_or("");
833
834        // Only migrate hooks for this project's channel
835        if channel != name {
836            continue;
837        }
838
839        // Skip hooks that already have an edict: or botbox: description (already migrated by
840        // migrate_bus_hooks or migrate_botbox_bus_hooks_to_edict respectively)
841        let existing_desc = hook
842            .get("description")
843            .and_then(|d| d.as_str())
844            .unwrap_or("");
845        if existing_desc.starts_with("edict:") || existing_desc.starts_with("botbox:") {
846            continue;
847        }
848
849        let cmd = hook.get("command").and_then(|c| c.as_array());
850        let cmd = match cmd {
851            Some(c) => c,
852            None => continue,
853        };
854
855        let cmd_strs: Vec<&str> = cmd.iter().filter_map(|v| v.as_str()).collect();
856
857        // Determine what kind of hook this is
858        let is_router = cmd_strs.iter().any(|s| {
859            s.contains("responder") || s.contains("respond.mjs") || s.contains("router.mjs")
860        });
861        let is_reviewer = cmd_strs
862            .iter()
863            .any(|s| s.contains("reviewer-loop") || s.contains("reviewer-loop.mjs"));
864
865        if !is_router && !is_reviewer {
866            continue;
867        }
868
869        let spawn_cwd = cmd_strs
870            .windows(2)
871            .find(|w| w[0] == "--cwd")
872            .map(|w| w[1])
873            .unwrap_or(".");
874
875        // Remove old hook (ensure_bus_hook handles dedup by description,
876        // but these legacy hooks have no description so we remove manually)
877        let remove = Tool::new("bus").args(&["hooks", "remove", &id]).run();
878
879        if remove.is_err() || !remove.as_ref().unwrap().success() {
880            tracing::warn!(hook_id = %id, "failed to remove legacy hook");
881            continue;
882        }
883
884        if is_router {
885            let claim_uri = format!("agent://{name}-dev");
886            let spawn_name = format!("{name}-responder");
887            let description = format!("edict:{name}:responder");
888            let responder_ml = config
889                .agents
890                .responder
891                .as_ref()
892                .and_then(|r| r.memory_limit.as_deref());
893
894            let mut router_args: Vec<&str> = vec![
895                "--agent",
896                &agent,
897                "--channel",
898                name,
899                "--claim",
900                &claim_uri,
901                "--claim-owner",
902                &agent,
903                "--cwd",
904                spawn_cwd,
905                "--ttl",
906                "600",
907                "--",
908                "vessel",
909                "spawn",
910                "--env-inherit",
911                env_inherit,
912            ];
913            if let Some(limit) = responder_ml {
914                router_args.push("--memory-limit");
915                router_args.push(limit);
916            }
917            router_args.extend_from_slice(&[
918                "--name",
919                &spawn_name,
920                "--cwd",
921                spawn_cwd,
922                "--",
923                "edict",
924                "run",
925                "responder",
926            ]);
927
928            match crate::subprocess::ensure_bus_hook(&description, &router_args) {
929                Ok(_) => println!("  Migrated router hook {id} → edict run responder"),
930                Err(e) => tracing::warn!("failed to re-register router hook: {e}"),
931            }
932        } else if is_reviewer {
933            let reviewer_agent = hook
934                .get("condition")
935                .and_then(|c| c.get("agent"))
936                .and_then(|a| a.as_str())
937                .unwrap_or("")
938                .to_string();
939
940            if reviewer_agent.is_empty() {
941                tracing::warn!(hook_id = %id, "could not determine reviewer agent for hook");
942                continue;
943            }
944
945            let role = reviewer_agent
946                .strip_prefix(&format!("{name}-"))
947                .unwrap_or(&reviewer_agent);
948            let claim_uri = format!("agent://{reviewer_agent}");
949            let description = format!("edict:{name}:reviewer-{role}");
950            let reviewer_ml = config
951                .agents
952                .reviewer
953                .as_ref()
954                .and_then(|r| r.memory_limit.as_deref());
955
956            let mut reviewer_args: Vec<&str> = vec![
957                "--agent",
958                &agent,
959                "--channel",
960                name,
961                "--mention",
962                &reviewer_agent,
963                "--claim",
964                &claim_uri,
965                "--claim-owner",
966                &reviewer_agent,
967                "--ttl",
968                "600",
969                "--priority",
970                "1",
971                "--cwd",
972                spawn_cwd,
973                "--",
974                "vessel",
975                "spawn",
976                "--env-inherit",
977                env_inherit,
978            ];
979            if let Some(limit) = reviewer_ml {
980                reviewer_args.push("--memory-limit");
981                reviewer_args.push(limit);
982            }
983            reviewer_args.extend_from_slice(&[
984                "--name",
985                &reviewer_agent,
986                "--cwd",
987                spawn_cwd,
988                "--",
989                "edict",
990                "run",
991                "reviewer-loop",
992                "--agent",
993                &reviewer_agent,
994            ]);
995
996            match crate::subprocess::ensure_bus_hook(&description, &reviewer_args) {
997                Ok(_) => println!(
998                    "  Migrated reviewer hook {id} → edict run reviewer-loop --agent {reviewer_agent}"
999                ),
1000                Err(e) => tracing::warn!(agent = %reviewer_agent, "failed to re-register reviewer hook: {e}"),
1001            }
1002        }
1003    }
1004}
1005
1006/// Fix hook --cwd for maw v2 bare repos.
1007///
1008/// Earlier versions of `detect_hook_paths` checked for `.jj` to identify bare repos,
1009/// which broke after the migration to Git+manifold. This re-registers hooks that have
1010/// `--cwd .../ws/default` with `--cwd .../` (the repo root) instead.
1011fn migrate_hook_cwd(config: &Config, project_root: &Path) {
1012    // Detect maw v2: project_root may be ws/default/ (inner sync) or the bare root
1013    let bare_root = if project_root.ends_with("ws/default") {
1014        project_root.parent().and_then(Path::parent)
1015    } else if project_root.join(".manifold").exists() {
1016        Some(project_root)
1017    } else {
1018        None
1019    };
1020
1021    let bare_root = match bare_root {
1022        Some(r) if r.join(".manifold").exists() => r,
1023        _ => return,
1024    };
1025
1026    let ws_default_str = bare_root
1027        .join("ws")
1028        .join("default")
1029        .display()
1030        .to_string();
1031    let root_str = bare_root.display().to_string();
1032
1033    let output = match Tool::new("bus")
1034        .args(&["hooks", "list", "--format", "json"])
1035        .run()
1036    {
1037        Ok(o) if o.success() => o,
1038        _ => return,
1039    };
1040
1041    let parsed: serde_json::Value = match serde_json::from_str(&output.stdout) {
1042        Ok(v) => v,
1043        Err(_) => return,
1044    };
1045
1046    let hooks = match parsed.get("hooks").and_then(|h| h.as_array()) {
1047        Some(h) => h,
1048        None => return,
1049    };
1050
1051    let name = &config.project.name;
1052    let agent = config.default_agent();
1053    let reviewers: Vec<String> = config
1054        .review
1055        .reviewers
1056        .iter()
1057        .map(|r| format!("{name}-{r}"))
1058        .collect();
1059
1060    for hook in hooks {
1061        let desc = hook
1062            .get("description")
1063            .and_then(|d| d.as_str())
1064            .unwrap_or("");
1065        // Accept both current and legacy description prefixes
1066        let is_ours = desc.starts_with(&format!("edict:{name}:"))
1067            || desc.starts_with(&format!("botbox:{name}:"));
1068        if !is_ours {
1069            continue;
1070        }
1071
1072        let cmd = match hook.get("command").and_then(|c| c.as_array()) {
1073            Some(c) => c,
1074            None => continue,
1075        };
1076        let cmd_strs: Vec<&str> = cmd.iter().filter_map(|v| v.as_str()).collect();
1077
1078        // Check if any --cwd arg still points to ws/default
1079        let has_stale_cwd = cmd_strs
1080            .windows(2)
1081            .any(|w| w[0] == "--cwd" && w[1] == ws_default_str);
1082        if !has_stale_cwd {
1083            continue;
1084        }
1085
1086        // Re-register with the correct cwd via the init helpers
1087        let id = match hook.get("id").and_then(|i| i.as_str()) {
1088            Some(id) => id,
1089            None => continue,
1090        };
1091
1092        // Remove old hook first
1093        if Tool::new("bus")
1094            .args(&["hooks", "remove", id])
1095            .run()
1096            .is_err()
1097        {
1098            continue;
1099        }
1100
1101        let is_router = desc.ends_with(":responder");
1102        if is_router {
1103            let responder_ml = config
1104                .agents
1105                .responder
1106                .as_ref()
1107                .and_then(|r| r.memory_limit.as_deref());
1108            super::init::register_router_hook(&root_str, &root_str, name, &agent, responder_ml);
1109            println!("  Fixed hook --cwd: {desc} → repo root");
1110        } else {
1111            let reviewer_ml = config
1112                .agents
1113                .reviewer
1114                .as_ref()
1115                .and_then(|r| r.memory_limit.as_deref());
1116            // Find which reviewer this is for
1117            for reviewer in &reviewers {
1118                if desc.contains(&reviewer.replace(&format!("{name}-"), "")) {
1119                    super::init::register_reviewer_hook(
1120                        &root_str, &root_str, name, &agent, reviewer, reviewer_ml,
1121                    );
1122                    println!("  Fixed hook --cwd: {desc} → repo root");
1123                    break;
1124                }
1125            }
1126        }
1127    }
1128}
1129
1130/// Migrate router hook claim pattern from `agent://{name}-router` to `agent://{name}-dev`
1131/// and spawn name from `{name}-router` to `{name}-responder`.
1132///
1133/// Earlier versions used a vestigial `-router` claim that nobody actually staked.
1134/// The new pattern uses `-dev` which matches the responder's own agent claim,
1135/// preventing re-trigger while processing.
1136fn migrate_router_hook_claim(config: &Config, project_root: &Path) {
1137    let output = match Tool::new("bus")
1138        .args(&["hooks", "list", "--format", "json"])
1139        .run()
1140    {
1141        Ok(o) if o.success() => o,
1142        _ => return,
1143    };
1144
1145    let parsed: serde_json::Value = match serde_json::from_str(&output.stdout) {
1146        Ok(v) => v,
1147        Err(_) => return,
1148    };
1149
1150    let hooks = match parsed.get("hooks").and_then(|h| h.as_array()) {
1151        Some(h) => h,
1152        None => return,
1153    };
1154
1155    let name = &config.project.name;
1156    let old_claim = format!("agent://{name}-router");
1157
1158    for hook in hooks {
1159        let desc = hook
1160            .get("description")
1161            .and_then(|d| d.as_str())
1162            .unwrap_or("");
1163        if desc != format!("edict:{name}:responder") && desc != format!("botbox:{name}:responder") {
1164            continue;
1165        }
1166
1167        // Check if the hook still uses the old claim pattern
1168        let claim = hook
1169            .get("condition")
1170            .and_then(|c| c.get("pattern"))
1171            .and_then(|p| p.as_str())
1172            .unwrap_or("");
1173        if claim != old_claim {
1174            continue;
1175        }
1176
1177        let id = match hook.get("id").and_then(|i| i.as_str()) {
1178            Some(id) => id,
1179            None => continue,
1180        };
1181
1182        // Remove old hook and re-register with new claim pattern
1183        if Tool::new("bus")
1184            .args(&["hooks", "remove", id])
1185            .run()
1186            .is_err()
1187        {
1188            continue;
1189        }
1190
1191        let agent = config.default_agent();
1192        // Resolve hook paths the same way migrate_hook_cwd does
1193        let bare_root = if project_root.ends_with("ws/default") {
1194            project_root
1195                .parent()
1196                .and_then(Path::parent)
1197                .filter(|r| r.join(".manifold").exists())
1198        } else if project_root.join(".manifold").exists() {
1199            Some(project_root)
1200        } else {
1201            None
1202        };
1203        let root_str = bare_root
1204            .map(|r| r.display().to_string())
1205            .unwrap_or_else(|| project_root.display().to_string());
1206        let responder_ml = config
1207            .agents
1208            .responder
1209            .as_ref()
1210            .and_then(|r| r.memory_limit.as_deref());
1211        super::init::register_router_hook(&root_str, &root_str, name, &agent, responder_ml);
1212        println!("  Migrated router hook claim: agent://{name}-router → agent://{name}-dev");
1213    }
1214}
1215
1216/// Migrate botty → vessel: update config key on disk and re-register bus hooks.
1217///
1218/// Idempotent — skips steps already done.
1219fn migrate_vessel_hooks(config: &Config, project_root: &Path, config_path: &Path) {
1220    // 1. Update config TOML on disk: botty = true → vessel = true
1221    if let Ok(content) = fs::read_to_string(config_path) {
1222        if content.contains("botty = ") {
1223            let updated = content.replace("botty = ", "vessel = ");
1224            if let Err(e) = fs::write(config_path, updated) {
1225                tracing::warn!("failed to update config botty→vessel: {e}");
1226            } else {
1227                println!("Migrated config: tools.botty → tools.vessel");
1228            }
1229        }
1230    }
1231
1232    // 2. Re-register edict hooks that still call `botty spawn` with `vessel spawn`.
1233    //    ensure_bus_hook deduplicates by description, so calling register_*_hook
1234    //    will remove the old hook and re-add it with the updated command.
1235    let output = match Tool::new("bus")
1236        .args(&["hooks", "list", "--format", "json"])
1237        .run()
1238    {
1239        Ok(o) if o.success() => o,
1240        _ => return,
1241    };
1242
1243    let parsed: serde_json::Value = match serde_json::from_str(&output.stdout) {
1244        Ok(v) => v,
1245        Err(_) => return,
1246    };
1247
1248    let hooks = match parsed.get("hooks").and_then(|h| h.as_array()) {
1249        Some(h) => h.to_vec(),
1250        None => return,
1251    };
1252
1253    let name = &config.project.name;
1254
1255    // Resolve root path (same logic as other hook migrations)
1256    let bare_root = if project_root.ends_with("ws/default") {
1257        project_root
1258            .parent()
1259            .and_then(Path::parent)
1260            .filter(|r| r.join(".manifold").exists())
1261    } else if project_root.join(".manifold").exists() {
1262        Some(project_root)
1263    } else {
1264        None
1265    };
1266    let root_str = bare_root
1267        .map(|r| r.display().to_string())
1268        .unwrap_or_else(|| project_root.display().to_string());
1269    let agent = config.default_agent();
1270
1271    for hook in &hooks {
1272        // Only migrate hooks whose command array contains "botty"
1273        let uses_botty = hook
1274            .get("command")
1275            .and_then(|c| c.as_array())
1276            .map(|arr| arr.iter().any(|v| v.as_str() == Some("botty")))
1277            .unwrap_or(false);
1278        if !uses_botty {
1279            continue;
1280        }
1281
1282        let desc = hook
1283            .get("description")
1284            .and_then(|d| d.as_str())
1285            .unwrap_or("");
1286
1287        if desc == format!("edict:{name}:responder") {
1288            let ml = config
1289                .agents
1290                .responder
1291                .as_ref()
1292                .and_then(|r| r.memory_limit.as_deref());
1293            super::init::register_router_hook(&root_str, &root_str, name, &agent, ml);
1294            println!("  Migrated router hook: vessel spawn (was botty)");
1295        } else if let Some(role) = desc
1296            .strip_prefix(&format!("edict:{name}:reviewer-"))
1297            .filter(|r| !r.is_empty())
1298        {
1299            let reviewer_agent = format!("{name}-{role}");
1300            let ml = config
1301                .agents
1302                .reviewer
1303                .as_ref()
1304                .and_then(|r| r.memory_limit.as_deref());
1305            super::init::register_reviewer_hook(&root_str, &root_str, name, &agent, &reviewer_agent, ml);
1306            println!("  Migrated reviewer hook {role}: vessel spawn (was botty)");
1307        }
1308    }
1309}
1310
1311/// Migrate beads → bones: config key, data directory, .maw.toml, .critignore, .gitignore.
1312///
1313/// This is idempotent — checks each step before acting.
1314fn migrate_beads_to_bones(project_root: &Path, config_path: &Path) -> Result<()> {
1315    let beads_dir = project_root.join(".beads");
1316    let bones_dir = project_root.join(".bones");
1317
1318    // 1. If config has `tools.beads` (in TOML), rename to `tools.bones`
1319    //    The serde alias handles deserialization, but we want the file itself updated.
1320    if config_path.exists() {
1321        let content = fs::read_to_string(config_path)?;
1322        if content.contains("beads") && !content.contains("bones") {
1323            let updated = content.replace("beads = ", "bones = ");
1324            fs::write(config_path, updated)?;
1325            println!("Migrated config: tools.beads → tools.bones");
1326        }
1327    }
1328
1329    // 2. If .beads/ exists and .bones/ doesn't → run `bn init` + migrate data
1330    if beads_dir.exists() && !bones_dir.exists() {
1331        let beads_db = beads_dir.join("beads.db");
1332        // Initialize bones first
1333        match run_command("bn", &["init"], Some(project_root)) {
1334            Ok(_) => println!("Initialized bones"),
1335            Err(e) => tracing::warn!("bn init failed: {e}"),
1336        }
1337        // Migrate data if beads.db exists
1338        if beads_db.exists() {
1339            let db_path = beads_db.to_string_lossy().to_string();
1340            match run_command(
1341                "bn",
1342                &["data", "migrate-from-beads", "--beads-db", &db_path],
1343                Some(project_root),
1344            ) {
1345                Ok(_) => println!("Migrated beads data to bones"),
1346                Err(e) => tracing::warn!("beads data migration failed: {e}"),
1347            }
1348        }
1349    }
1350
1351    // 3. Update .maw.toml: remove .beads/** entry (set auto_resolve_from_main to empty)
1352    let maw_toml = project_root.join(".maw.toml");
1353    if maw_toml.exists() {
1354        let content = fs::read_to_string(&maw_toml)?;
1355        if content.contains(".beads/") {
1356            // Remove the .beads/** line and set to empty array if it was the only entry
1357            let updated = content
1358                .lines()
1359                .map(|line| {
1360                    if line.contains(".beads/") {
1361                        // Skip this line
1362                        None
1363                    } else {
1364                        Some(line)
1365                    }
1366                })
1367                .flatten()
1368                .collect::<Vec<_>>()
1369                .join("\n");
1370            // If the array is now effectively empty, replace with empty
1371            let updated = updated.replace(
1372                "auto_resolve_from_main = [\n]",
1373                "auto_resolve_from_main = []",
1374            );
1375            fs::write(&maw_toml, format!("{updated}\n"))?;
1376            println!("Updated .maw.toml: removed .beads/** entry");
1377        }
1378    }
1379
1380    // 4. Update .critignore: remove .beads/ line (bones handles its own critignore)
1381    let critignore = project_root.join(".critignore");
1382    if critignore.exists() {
1383        let content = fs::read_to_string(&critignore)?;
1384        if content.contains(".beads/") {
1385            let updated: String = content
1386                .lines()
1387                .filter(|line| line.trim() != ".beads/")
1388                .collect::<Vec<_>>()
1389                .join("\n");
1390            let updated = if content.ends_with('\n') {
1391                format!("{updated}\n")
1392            } else {
1393                updated
1394            };
1395            fs::write(&critignore, updated)?;
1396            println!("Updated .critignore: removed .beads/ entry");
1397        }
1398    }
1399
1400    // 5. Update .gitignore: remove .bv/ line (bones is tracked, not ignored)
1401    let gitignore = project_root.join(".gitignore");
1402    if gitignore.exists() {
1403        let content = fs::read_to_string(&gitignore)?;
1404        if content.contains(".bv/") {
1405            let updated: String = content
1406                .lines()
1407                .filter(|line| line.trim() != ".bv/")
1408                .collect::<Vec<_>>()
1409                .join("\n");
1410            // Preserve trailing newline if original had one
1411            let updated = if content.ends_with('\n') {
1412                format!("{updated}\n")
1413            } else {
1414                updated
1415            };
1416            fs::write(&gitignore, updated)?;
1417            println!("Updated .gitignore: removed .bv/ entry");
1418        }
1419    }
1420
1421    Ok(())
1422}
1423
1424/// Version control system detected in a project.
1425#[derive(Debug, PartialEq, Eq)]
1426enum Vcs {
1427    Jj,
1428    Git,
1429    None,
1430}
1431
1432/// Detect which VCS manages this project root.
1433/// Prefers jj if found (searches ancestors for `.jj/`), falls back to git
1434/// (`.git` file or directory at `project_root` or ancestors).
1435fn detect_vcs(project_root: &Path) -> Vcs {
1436    if find_jj_root(project_root).is_some() {
1437        return Vcs::Jj;
1438    }
1439    // Check for .git file (worktree/maw) or .git directory (regular repo)
1440    if project_root
1441        .ancestors()
1442        .any(|p| p.join(".git").exists())
1443    {
1444        return Vcs::Git;
1445    }
1446    Vcs::None
1447}
1448
1449/// Search up the directory tree for a .jj directory (like jj itself does).
1450/// Returns the repo root if found, or None if not a jj repo.
1451fn find_jj_root(from: &Path) -> Option<PathBuf> {
1452    from.ancestors()
1453        .find(|p| p.join(".jj").is_dir())
1454        .map(|p| p.to_path_buf())
1455}
1456
1457/// Compute SHA-256 hash of all workflow docs
1458fn compute_docs_version() -> String {
1459    let mut hasher = Sha256::new();
1460    for (name, content) in WORKFLOW_DOCS {
1461        hasher.update(name.as_bytes());
1462        hasher.update(content.as_bytes());
1463    }
1464    format!("{:x}", hasher.finalize())[..32].to_string()
1465}
1466
1467/// Compute SHA-256 hash of all reviewer prompts
1468fn compute_prompts_version() -> String {
1469    let mut hasher = Sha256::new();
1470    for (name, content) in REVIEWER_PROMPTS {
1471        hasher.update(name.as_bytes());
1472        hasher.update(content.as_bytes());
1473    }
1474    format!("{:x}", hasher.finalize())[..32].to_string()
1475}
1476
1477/// Compute SHA-256 hash of all design docs
1478fn compute_design_docs_version() -> String {
1479    let mut hasher = Sha256::new();
1480    for (name, content) in DESIGN_DOCS {
1481        hasher.update(name.as_bytes());
1482        hasher.update(content.as_bytes());
1483    }
1484    format!("{:x}", hasher.finalize())[..32].to_string()
1485}
1486
1487#[cfg(test)]
1488mod tests {
1489    use super::*;
1490
1491    #[test]
1492    fn test_find_jj_root_direct() {
1493        let dir = tempfile::tempdir().unwrap();
1494        let jj = dir.path().join(".jj");
1495        fs::create_dir(&jj).unwrap();
1496        // Should find .jj right at `from`
1497        assert_eq!(find_jj_root(dir.path()), Some(dir.path().to_path_buf()));
1498    }
1499
1500    #[test]
1501    fn test_find_jj_root_ancestor() {
1502        let dir = tempfile::tempdir().unwrap();
1503        let jj = dir.path().join(".jj");
1504        fs::create_dir(&jj).unwrap();
1505        let ws = dir.path().join("ws/default");
1506        fs::create_dir_all(&ws).unwrap();
1507        // Should find .jj at the ancestor
1508        assert_eq!(find_jj_root(&ws), Some(dir.path().to_path_buf()));
1509    }
1510
1511    #[test]
1512    fn test_find_jj_root_missing() {
1513        let dir = tempfile::tempdir().unwrap();
1514        // No .jj anywhere
1515        assert_eq!(find_jj_root(dir.path()), None);
1516    }
1517
1518    #[test]
1519    fn test_version_hashes() {
1520        let docs_ver = compute_docs_version();
1521        assert_eq!(docs_ver.len(), 32);
1522        assert!(docs_ver.chars().all(|c| c.is_ascii_hexdigit()));
1523
1524        let prompts_ver = compute_prompts_version();
1525        assert_eq!(prompts_ver.len(), 32);
1526        assert!(prompts_ver.chars().all(|c| c.is_ascii_hexdigit()));
1527
1528        let design_ver = compute_design_docs_version();
1529        assert_eq!(design_ver.len(), 32);
1530        assert!(design_ver.chars().all(|c| c.is_ascii_hexdigit()));
1531    }
1532
1533    #[test]
1534    fn test_workflow_docs_embedded() {
1535        assert!(!WORKFLOW_DOCS.is_empty());
1536        for (name, content) in WORKFLOW_DOCS {
1537            assert!(!name.is_empty());
1538            assert!(!content.is_empty());
1539        }
1540    }
1541
1542    #[test]
1543    fn test_design_docs_embedded() {
1544        assert!(!DESIGN_DOCS.is_empty());
1545        for (name, content) in DESIGN_DOCS {
1546            assert!(!name.is_empty());
1547            assert!(!content.is_empty());
1548        }
1549    }
1550
1551    #[test]
1552    fn test_reviewer_prompts_embedded() {
1553        assert_eq!(REVIEWER_PROMPTS.len(), 2);
1554        assert!(REVIEWER_PROMPTS.iter().any(|(n, _)| *n == "reviewer.md"));
1555        assert!(
1556            REVIEWER_PROMPTS
1557                .iter()
1558                .any(|(n, _)| *n == "reviewer-security.md")
1559        );
1560    }
1561}