grove/
main.rs

1//! # Grove
2//!
3//! A lightweight CLI for managing multiple git/jj repositories with per-project
4//! environment variables and centralized worktree management.
5//!
6//! ## Core Concepts
7//!
8//! - **Project Registry** — Track multiple repositories under short names.
9//!   Register with `grove add`, list with `grove list`.
10//! - **Environment Variables** — Layered env vars stored outside the repo.
11//!   Project-level defaults, worktree-level overrides, and repo-level
12//!   defaults in `.grove/config.toml`. Integrates with [mise](https://mise.jdx.dev/)
13//!   for automatic shell injection.
14//! - **Worktree Management** — Create and manage git worktrees (or jj workspaces)
15//!   across all projects from a single command. Worktrees get their own
16//!   database instances and env var overrides.
17//!
18//! ## Quick Start
19//!
20//! ```bash
21//! # Register a project
22//! grove add myapp /path/to/repo
23//!
24//! # Set environment variables
25//! grove env set myapp DATABASE_URL=postgres:///myapp_dev
26//!
27//! # Create a worktree and start working
28//! grove start myapp my-feature
29//!
30//! # List all worktrees across projects
31//! grove worktree list
32//! ```
33//!
34//! ## Modules
35//!
36//! - [`config`] — Configuration loading, project registry, env var management
37//! - [`vcs`] — VCS backend abstraction (git, jj)
38//! - [`error`] — Error types
39
40use std::collections::BTreeMap;
41use std::path::PathBuf;
42
43use clap::Parser;
44
45mod config;
46mod error;
47mod vcs;
48
49use config::{Config, EnvVars, ProjectRef};
50use error::{Error, Result};
51
52const MISE_METADATA_LUA: &str = include_str!("mise_plugin/metadata.lua");
53const MISE_ENV_LUA: &str = include_str!("mise_plugin/mise_env.lua");
54
55#[derive(Parser)]
56#[command(
57    name = "grove",
58    version,
59    about = "Manage a grove of git/jj repositories",
60    long_about = "Manage a grove of git/jj repositories.\n\n\
61        Grove tracks multiple git and jj repositories, manages isolated worktrees \
62        for each, and provides per-project and per-worktree environment variables. \
63        It integrates with mise to inject environment variables automatically.\n\n\
64        Projects can be registered explicitly with `grove add` or auto-detected \
65        from a .grove/config.toml file in the repository root. Worktrees are \
66        created in a configurable base directory and can have their own database \
67        instances and environment variable overrides.",
68    after_help = "Get started:\n  \
69        grove add myproject /path/to/repo\n  \
70        grove start myproject my-feature\n\n\
71        See `grove <command> --help` for detailed usage of each command."
72)]
73struct Cli {
74    #[command(subcommand)]
75    command: Commands,
76}
77
78#[derive(clap::Subcommand)]
79enum Commands {
80    /// Register an existing git repo
81    #[command(
82        long_about = "Register an existing git or jj repository with grove under a short name.\n\n\
83            The path must point to a directory containing a .git or .jj directory. \
84            The project name is used in all other grove commands to reference this \
85            repository. Once registered, you can create worktrees, manage env vars, \
86            and use `grove start` with this project.",
87        after_help = "Examples:\n  \
88            grove add myapp /home/user/code/myapp\n  \
89            grove add api ~/projects/api-server"
90    )]
91    Add {
92        /// Short name to identify this project in grove commands
93        name: String,
94        /// Path to the git or jj repository root (must contain .git or .jj)
95        path: PathBuf,
96    },
97    /// Show all registered projects
98    #[command(
99        long_about = "Show all projects registered with grove.\n\n\
100            Outputs each project's name and path, tab-separated. \
101            Projects are registered with `grove add` and stored in \
102            the grove config file (~/.config/grove/config.toml).",
103        after_help = "Examples:\n  grove list"
104    )]
105    List,
106    /// Unregister a project (doesn't delete files)
107    #[command(
108        long_about = "Unregister a project from grove.\n\n\
109            This removes the project from grove's config but does NOT delete \
110            the repository, worktrees, or any files on disk. To also clean up \
111            worktrees, remove them first with `grove worktree rm`.",
112        after_help = "Examples:\n  grove remove myapp"
113    )]
114    Remove {
115        /// Name of the project to unregister
116        name: String,
117    },
118    /// Manage environment variables
119    #[command(
120        long_about = "Manage per-project and per-worktree environment variables.\n\n\
121            Environment variables are stored in ~/.config/grove/envs/ and can be \
122            injected into your shell via the mise integration (`grove init-mise`). \
123            Variables set at the project level apply to all worktrees. Variables \
124            set at the worktree level override project-level values. Repo-level \
125            defaults can also be defined in .grove/config.toml under [env].\n\n\
126            When a project argument is omitted, grove auto-detects the project \
127            from your current working directory.",
128        after_help = "Examples:\n  \
129            grove env set myapp DATABASE_URL=postgres://localhost/myapp\n  \
130            grove env list myapp\n  \
131            grove env unset myapp DATABASE_URL"
132    )]
133    Env {
134        #[command(subcommand)]
135        command: EnvCommands,
136    },
137    /// Manage git/jj worktrees
138    #[command(
139        long_about = "Manage git worktrees and jj workspaces for registered projects.\n\n\
140            Grove creates worktrees in a configurable base directory (defaults to \
141            <repo>/.worktrees/). Each worktree gets its own branch (git) or workspace \
142            (jj). If the project has database configuration, a dedicated database is \
143            created per worktree and the DATABASE_URL is set automatically.\n\n\
144            The VCS backend is auto-detected: if the repo has a .jj directory, jj is \
145            used; otherwise git. Use --vcs git to force git mode for colocated repos.",
146        after_help = "Examples:\n  \
147            grove worktree new myapp my-feature\n  \
148            grove worktree list myapp\n  \
149            grove worktree rm myapp-my-feature"
150    )]
151    Worktree {
152        /// Force a specific VCS backend (e.g., "git") instead of auto-detection
153        #[arg(long)]
154        vcs: Option<String>,
155        #[command(subcommand)]
156        command: WorktreeCommands,
157    },
158    /// Create a worktree, run hooks, and open editor
159    #[command(
160        long_about = "Create a new worktree (or reuse an existing one), run lifecycle hooks, \
161            and open it in your $EDITOR.\n\n\
162            This is the high-level \"start working\" command. It combines worktree \
163            creation, database provisioning, mise trust, post-create hooks, and editor \
164            launch into a single step. If the worktree already exists, it skips creation \
165            and just opens the editor.\n\n\
166            Requires $EDITOR to be set for the editor launch (silently skipped if unset).",
167        after_help = "Examples:\n  \
168            grove start myapp my-feature\n  \
169            grove start myapp bugfix-123 --vcs git"
170    )]
171    Start {
172        /// Name of the registered project
173        project: String,
174        /// Name for the new worktree (alphanumeric, hyphens, underscores only)
175        name: String,
176        /// Force a specific VCS backend (e.g., "git") instead of auto-detection
177        #[arg(long)]
178        vcs: Option<String>,
179    },
180    /// Install grove plugin for mise
181    #[command(
182        long_about = "Install the grove plugin for mise so that grove-managed environment \
183            variables are automatically injected into your shell.\n\n\
184            This copies the plugin files to mise's plugin directory \
185            (~/.local/share/mise/plugins/grove/) and prints the config snippet \
186            you need to add to ~/.config/mise/config.toml.\n\n\
187            After running this command and adding the config, mise will automatically \
188            export grove environment variables when you cd into a project directory.",
189        after_help = "Examples:\n  grove init-mise\n\n\
190            After running, add to ~/.config/mise/config.toml:\n  \
191            [env]\n  _.grove = {}"
192    )]
193    InitMise,
194}
195
196#[derive(clap::Subcommand)]
197enum EnvCommands {
198    /// Set an environment variable
199    #[command(
200        long_about = "Set an environment variable for a project or worktree.\n\n\
201            The project can be specified explicitly or auto-detected from your \
202            current directory. Use the project/worktree syntax to set a variable \
203            on a specific worktree (overrides the project-level value).",
204        after_help = "Examples:\n  \
205            # Set for a project (explicit name):\n  \
206            grove env set myapp DATABASE_URL=postgres://localhost/myapp\n\n  \
207            # Set for current project (auto-detected from cwd):\n  \
208            grove env set SECRET_KEY=abc123\n\n  \
209            # Set for a specific worktree:\n  \
210            grove env set myapp/my-feature DATABASE_URL=postgres://localhost/myapp_feature"
211    )]
212    Set {
213        /// Project name or KEY=value pair (auto-detects project from cwd if KEY=value)
214        project_or_pair: String,
215        /// KEY=value pair (when first argument is a project name)
216        pair: Option<String>,
217    },
218    /// Show all environment variables
219    #[command(
220        long_about = "List all environment variables for a project or worktree.\n\n\
221            Shows variables from all sources (repo .grove/config.toml, project-level, \
222            and worktree-level) with labels indicating where each value comes from. \
223            Worktree-level values override project-level values, which override repo-level.\n\n\
224            If no project is specified, auto-detects from the current directory.",
225        after_help = "Examples:\n  \
226            grove env list myapp\n  \
227            grove env list myapp/my-feature\n  \
228            grove env list          # auto-detect from cwd"
229    )]
230    List {
231        /// Project name or project/worktree (auto-detects from cwd if omitted)
232        project: Option<String>,
233    },
234    /// Remove an environment variable
235    #[command(
236        long_about = "Remove an environment variable from a project or worktree.\n\n\
237            The project can be specified explicitly or auto-detected from the \
238            current directory. Use project/worktree syntax to remove a worktree-level \
239            override (the project-level value will then apply again).",
240        after_help = "Examples:\n  \
241            grove env unset myapp SECRET_KEY\n  \
242            grove env unset SECRET_KEY           # auto-detect project from cwd\n  \
243            grove env unset myapp/feat DATABASE_URL"
244    )]
245    Unset {
246        /// Project name or env var key (auto-detects project from cwd if only one arg)
247        project_or_key: String,
248        /// Env var key (when first argument is a project name)
249        key: Option<String>,
250    },
251    /// Output environment variables for the project containing a path
252    #[command(
253        long_about = "Export resolved environment variables for a given directory path.\n\n\
254            Looks up which project (and optionally worktree) owns the given path, \
255            merges all env var layers (repo, project, worktree), and outputs them. \
256            By default outputs in KEY=value format (one per line); with --json \
257            outputs a JSON object.\n\n\
258            This is primarily used by the mise plugin to inject variables into the \
259            shell, but can also be used for scripting.",
260        after_help = "Examples:\n  \
261            grove env export /home/user/code/myapp\n  \
262            grove env export --json /home/user/code/myapp/.worktrees/feat\n  \
263            eval \"$(grove env export /home/user/code/myapp)\""
264    )]
265    Export {
266        /// Output as a JSON object instead of KEY=value lines
267        #[arg(long)]
268        json: bool,
269        /// Directory path to resolve the project and worktree from
270        path: PathBuf,
271    },
272}
273
274#[derive(clap::Subcommand)]
275enum WorktreeCommands {
276    /// Create a new worktree
277    #[command(
278        long_about = "Create a new git worktree or jj workspace for a project.\n\n\
279            The worktree is created in the project's worktree base directory \
280            (defaults to <repo>/.worktrees/<name>). For git repos, a new branch \
281            with the worktree name is created. For jj repos, a new workspace is added.\n\n\
282            If the project has database configuration in .grove/config.toml, a \
283            dedicated database is created and DATABASE_URL is set automatically. \
284            Post-create hooks (if configured) run after creation.\n\n\
285            The project can be specified explicitly or auto-detected from cwd.",
286        after_help = "Examples:\n  \
287            # Explicit project:\n  \
288            grove worktree new myapp my-feature\n\n  \
289            # Auto-detect project from cwd:\n  \
290            grove worktree new my-feature"
291    )]
292    New {
293        /// Worktree name, or project name if second argument is provided
294        name_or_project: String,
295        /// Worktree name (when provided, first argument is the project name)
296        name: Option<String>,
297    },
298    /// List worktrees
299    #[command(
300        long_about = "List all worktrees across registered projects.\n\n\
301            Shows each worktree's full name (project-worktree), branch, and path, \
302            tab-separated. If a project is specified, only shows worktrees for \
303            that project. Also includes worktrees from auto-detected projects \
304            (via .grove/config.toml in cwd).",
305        after_help = "Examples:\n  \
306            grove worktree list\n  \
307            grove worktree list myapp"
308    )]
309    List {
310        /// Only show worktrees for this project
311        project: Option<String>,
312    },
313    /// Remove a worktree
314    #[command(
315        long_about = "Remove a git worktree or jj workspace.\n\n\
316            Accepts either the full name (e.g., \"myapp-my-feature\") or just the \
317            short worktree name (e.g., \"my-feature\") if it's unambiguous across \
318            all projects. If the project has database configuration, the worktree's \
319            database is dropped automatically.\n\n\
320            For git, this runs `git worktree remove`. For jj, this runs \
321            `jj workspace forget` and deletes the directory.",
322        after_help = "Examples:\n  \
323            grove worktree rm myapp-my-feature\n  \
324            grove worktree rm my-feature          # if unambiguous"
325    )]
326    Rm {
327        /// Worktree name (full \"project-name\" or short \"name\" if unambiguous)
328        name: String,
329    },
330}
331
332fn main() {
333    let cli = Cli::parse();
334
335    if let Err(e) = run(cli.command) {
336        eprintln!("error: {e}");
337        std::process::exit(1);
338    }
339}
340
341fn run(command: Commands) -> Result<()> {
342    match command {
343        Commands::Add { name, path } => cmd_add(&name, path),
344        Commands::List => cmd_list(),
345        Commands::Remove { name } => cmd_remove(&name),
346        Commands::Env { command } => match command {
347            EnvCommands::Set {
348                project_or_pair,
349                pair,
350            } => cmd_env_set(&project_or_pair, pair.as_deref()),
351            EnvCommands::List { project } => cmd_env_list(project.as_deref()),
352            EnvCommands::Unset {
353                project_or_key,
354                key,
355            } => cmd_env_unset(&project_or_key, key.as_deref()),
356            EnvCommands::Export { json, path } => cmd_env_export(path, json),
357        },
358        Commands::InitMise => cmd_init_mise(),
359        Commands::Start { project, name, vcs } => {
360            let vcs_override = parse_vcs_override(vcs.as_deref())?;
361            cmd_start(&project, &name, vcs_override)
362        }
363        Commands::Worktree { vcs, command } => {
364            let vcs_override = parse_vcs_override(vcs.as_deref())?;
365            match command {
366                WorktreeCommands::New {
367                    name_or_project,
368                    name,
369                } => cmd_worktree_new(&name_or_project, name.as_deref(), vcs_override),
370                WorktreeCommands::List { project } => {
371                    cmd_worktree_list(project.as_deref(), vcs_override)
372                }
373                WorktreeCommands::Rm { name } => cmd_worktree_rm(&name, vcs_override),
374            }
375        }
376    }
377}
378
379fn cmd_add(name: &str, path: PathBuf) -> Result<()> {
380    let mut config = Config::load()?;
381    config.add_project(name.to_string(), path)?;
382    config.save()?;
383    println!("Added project '{name}'");
384    Ok(())
385}
386
387fn cmd_list() -> Result<()> {
388    let config = Config::load()?;
389    if config.projects.is_empty() {
390        println!("No projects registered");
391        return Ok(());
392    }
393    for (name, project) in &config.projects {
394        println!("{name}\t{}", project.path.display());
395    }
396    Ok(())
397}
398
399fn cmd_remove(name: &str) -> Result<()> {
400    let mut config = Config::load()?;
401    config.remove_project(name)?;
402    config.save()?;
403    println!("Removed project '{name}'");
404    Ok(())
405}
406
407fn cmd_env_set(project_or_pair: &str, pair: Option<&str>) -> Result<()> {
408    let (project_str, pair_str) = match pair {
409        Some(p) => (Some(project_or_pair), p),
410        None => (None, project_or_pair),
411    };
412
413    let config = Config::load()?;
414    // Resolve project once — either from explicit name or auto-detection.
415    // For the two-arg form, project_ref may include a worktree specifier (project/worktree).
416    let (project_ref, resolved) = if let Some(s) = project_str {
417        let pr = ProjectRef::parse(s)?;
418        let resolved = config::resolve_project(&config, Some(&pr.project))?;
419        (pr, resolved)
420    } else {
421        let (name, project, repo_env) = config::resolve_project(&config, None)?;
422        let pr = ProjectRef {
423            project: name.clone(),
424            worktree: None,
425        };
426        (pr, (name, project, repo_env))
427    };
428
429    let (key, value) = pair_str
430        .split_once('=')
431        .ok_or_else(|| Error::InvalidEnvFormat(pair_str.to_string()))?;
432
433    if let Some(wt_name) = &project_ref.worktree {
434        validate_worktree_exists(&resolved.1, &project_ref.project, wt_name)?;
435
436        let mut vars = EnvVars::load_worktree(&project_ref.project, wt_name)?;
437        vars.set(key.to_string(), value.to_string());
438        vars.save_worktree(&project_ref.project, wt_name)?;
439        println!("Set {key} for worktree '{}/{wt_name}'", project_ref.project);
440    } else {
441        let mut vars = EnvVars::load(&project_ref.project)?;
442        vars.set(key.to_string(), value.to_string());
443        vars.save(&project_ref.project)?;
444        println!("Set {key} for project '{}'", project_ref.project);
445    }
446
447    Ok(())
448}
449
450fn cmd_env_unset(project_or_key: &str, key: Option<&str>) -> Result<()> {
451    let (project_str, actual_key) = match key {
452        Some(k) => (Some(project_or_key), k),
453        None => (None, project_or_key),
454    };
455
456    let config = Config::load()?;
457    let project_ref = if let Some(s) = project_str {
458        ProjectRef::parse(s)?
459    } else {
460        let (name, _, _) = config::resolve_project(&config, None)?;
461        ProjectRef {
462            project: name,
463            worktree: None,
464        }
465    };
466
467    let (_, resolved_project, _) = config::resolve_project(&config, Some(&project_ref.project))?;
468
469    if let Some(wt_name) = &project_ref.worktree {
470        validate_worktree_exists(&resolved_project, &project_ref.project, wt_name)?;
471
472        let mut vars = EnvVars::load_worktree(&project_ref.project, wt_name)?;
473        if vars.remove(actual_key) {
474            if vars.vars.is_empty() {
475                let path = config::worktree_env_path(&project_ref.project, wt_name)?;
476                if path.exists() {
477                    std::fs::remove_file(&path)?;
478                }
479            } else {
480                vars.save_worktree(&project_ref.project, wt_name)?;
481            }
482            println!(
483                "Unset {actual_key} for worktree '{}/{wt_name}'",
484                project_ref.project
485            );
486        } else {
487            println!(
488                "Key '{actual_key}' not found in worktree '{}/{wt_name}'",
489                project_ref.project
490            );
491        }
492    } else {
493        let mut vars = EnvVars::load(&project_ref.project)?;
494        if vars.remove(actual_key) {
495            if vars.vars.is_empty() {
496                let path = config::env_path(&project_ref.project)?;
497                if path.exists() {
498                    std::fs::remove_file(&path)?;
499                }
500            } else {
501                vars.save(&project_ref.project)?;
502            }
503            println!("Unset {actual_key} for project '{}'", project_ref.project);
504        } else {
505            println!(
506                "Key '{actual_key}' not found in project '{}'",
507                project_ref.project
508            );
509        }
510    }
511
512    Ok(())
513}
514
515fn cmd_env_list(project: Option<&str>) -> Result<()> {
516    let config = Config::load()?;
517
518    let project_ref = if let Some(s) = project {
519        ProjectRef::parse(s)?
520    } else {
521        let (name, _, _) = config::resolve_project(&config, None)?;
522        ProjectRef {
523            project: name,
524            worktree: None,
525        }
526    };
527
528    let (_, resolved_project, repo_env) =
529        config::resolve_project(&config, Some(&project_ref.project))?;
530
531    if let Some(wt_name) = &project_ref.worktree {
532        validate_worktree_exists(&resolved_project, &project_ref.project, wt_name)?;
533
534        let merged = config::load_merged_env(&project_ref.project, Some(wt_name), &repo_env)?;
535        if merged.is_empty() {
536            println!(
537                "No environment variables set for '{}/{wt_name}'",
538                project_ref.project
539            );
540            return Ok(());
541        }
542
543        let max_key_len = merged.iter().map(|v| v.key.len()).max().unwrap_or(0);
544        for var in &merged {
545            let source_label = match var.source {
546                config::EnvSource::Repo => "(from repo)",
547                config::EnvSource::Project => "(from project)",
548                config::EnvSource::Worktree => "(override)",
549            };
550            println!(
551                "{:width$} = {}  {}",
552                var.key,
553                var.value,
554                source_label,
555                width = max_key_len
556            );
557        }
558    } else {
559        let merged = config::load_merged_env(&project_ref.project, None, &repo_env)?;
560        if merged.is_empty() {
561            println!("No environment variables set for '{}'", project_ref.project);
562            return Ok(());
563        }
564
565        if repo_env.is_empty() {
566            // Backward compat: no repo env, use plain KEY=value format
567            for var in &merged {
568                println!("{}={}", var.key, var.value);
569            }
570        } else {
571            let max_key_len = merged.iter().map(|v| v.key.len()).max().unwrap_or(0);
572            for var in &merged {
573                let source_label = match var.source {
574                    config::EnvSource::Repo => "(from repo)",
575                    config::EnvSource::Project | config::EnvSource::Worktree => "(override)",
576                };
577                println!(
578                    "{:width$} = {}  {}",
579                    var.key,
580                    var.value,
581                    source_label,
582                    width = max_key_len
583                );
584            }
585        }
586    }
587
588    Ok(())
589}
590
591fn cmd_env_export(path: PathBuf, json: bool) -> Result<()> {
592    if json {
593        if !path.exists() {
594            println!("{{}}");
595            return Ok(());
596        }
597
598        let config = Config::load()?;
599        let Some((name, _project, worktree, repo_env)) =
600            config::resolve_project_for_path(&config, &path)?
601        else {
602            println!("{{}}");
603            return Ok(());
604        };
605
606        let merged = config::load_merged_env(&name, worktree.as_deref(), &repo_env)?;
607        let map: BTreeMap<String, String> = merged.into_iter().map(|v| (v.key, v.value)).collect();
608        let json_str = serde_json::to_string(&map)?;
609        println!("{json_str}");
610    } else {
611        let config = Config::load()?;
612        let (name, _project, worktree, repo_env) =
613            config::resolve_project_for_path(&config, &path)?
614                .ok_or(Error::NoProjectForPath(path))?;
615
616        let merged = config::load_merged_env(&name, worktree.as_deref(), &repo_env)?;
617        let output = config::export_merged_env(&merged);
618        if !output.is_empty() {
619            println!("{output}");
620        }
621    }
622    Ok(())
623}
624
625fn parse_vcs_override(vcs: Option<&str>) -> Result<Option<vcs::VcsOverride>> {
626    match vcs.map(str::to_lowercase).as_deref() {
627        None => Ok(None),
628        Some("git") => Ok(Some(vcs::VcsOverride::Git)),
629        Some(other) => Err(Error::InvalidVcsOverride(other.to_string())),
630    }
631}
632
633/// Validate that a worktree actually exists for a project.
634fn validate_worktree_exists(
635    project: &config::Project,
636    project_name: &str,
637    worktree_name: &str,
638) -> Result<()> {
639    let backend = vcs::detect_backend(&project.path, None)?;
640    let worktrees = backend.list_worktrees(&project.path, &project.worktree_base())?;
641    let exists = worktrees.iter().any(|wt| {
642        wt.path
643            .file_name()
644            .is_some_and(|n| n.to_string_lossy() == worktree_name)
645    });
646    if !exists {
647        return Err(Error::WorktreeEnvNotFound(
648            project_name.to_string(),
649            worktree_name.to_string(),
650        ));
651    }
652    Ok(())
653}
654
655/// Validate worktree name contains only alphanumeric, hyphens, and underscores.
656fn validate_worktree_name(name: &str) -> Result<()> {
657    if name.is_empty()
658        || !name
659            .chars()
660            .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
661    {
662        return Err(Error::InvalidWorktreeName(name.to_string()));
663    }
664    Ok(())
665}
666
667fn create_database(db_name: &str) -> Result<()> {
668    let output = std::process::Command::new("createdb")
669        .arg(db_name)
670        .output()?;
671
672    if !output.status.success() {
673        return Err(Error::DatabaseCreationFailed(
674            String::from_utf8_lossy(&output.stderr).to_string(),
675        ));
676    }
677
678    Ok(())
679}
680
681fn drop_database(db_name: &str) -> Result<()> {
682    let output = std::process::Command::new("dropdb")
683        .args(["--if-exists", db_name])
684        .output()?;
685
686    if !output.status.success() {
687        return Err(Error::DatabaseDropFailed(
688            String::from_utf8_lossy(&output.stderr).to_string(),
689        ));
690    }
691
692    Ok(())
693}
694
695fn run_setup_command(
696    command: &str,
697    worktree_path: &std::path::Path,
698    env_var_name: &str,
699    database_url: &str,
700) -> Result<()> {
701    let output = std::process::Command::new("sh")
702        .args(["-c", command])
703        .current_dir(worktree_path)
704        .env(env_var_name, database_url)
705        .output()?;
706
707    if !output.status.success() {
708        return Err(Error::SetupCommandFailed(
709            String::from_utf8_lossy(&output.stderr).to_string(),
710        ));
711    }
712
713    Ok(())
714}
715
716fn run_mise_trust(worktree_path: &std::path::Path) -> Result<()> {
717    let output = match std::process::Command::new("mise")
718        .arg("trust")
719        .current_dir(worktree_path)
720        .output()
721    {
722        Ok(output) => output,
723        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
724        Err(e) => return Err(e.into()),
725    };
726
727    if output.status.success() {
728        println!("Ran mise trust");
729    } else {
730        let stderr = String::from_utf8_lossy(&output.stderr);
731        eprintln!("Warning: mise trust failed: {stderr}");
732    }
733    Ok(())
734}
735
736fn run_post_create_hooks(hooks: &[String], worktree_path: &std::path::Path) -> Result<()> {
737    for cmd in hooks {
738        println!("Running hook: {cmd}");
739        let output = std::process::Command::new("sh")
740            .args(["-c", cmd])
741            .current_dir(worktree_path)
742            .output()?;
743
744        if !output.status.success() {
745            return Err(Error::HookFailed(
746                cmd.clone(),
747                String::from_utf8_lossy(&output.stderr).to_string(),
748            ));
749        }
750        println!("Hook completed: {cmd}");
751    }
752    Ok(())
753}
754
755fn create_worktree_with_hooks(
756    project_name: &str,
757    project: &config::Project,
758    worktree_name: &str,
759    vcs_override: Option<vcs::VcsOverride>,
760) -> Result<std::path::PathBuf> {
761    validate_worktree_name(worktree_name)?;
762
763    let worktree_base = project.worktree_base();
764    let worktree_path = worktree_base.join(worktree_name);
765
766    if worktree_path.exists() {
767        return Err(Error::WorktreePathExists(worktree_path));
768    }
769
770    if !worktree_base.exists() {
771        std::fs::create_dir_all(&worktree_base)?;
772    }
773
774    let backend = vcs::detect_backend(&project.path, vcs_override)?;
775    backend.create_worktree(&project.path, &worktree_path, worktree_name)?;
776
777    println!("Created worktree at {}", worktree_path.display());
778
779    if let Some(db_config) = &project.database {
780        let db_name = db_config.db_name(project_name, worktree_name);
781        println!("Creating database '{db_name}'...");
782        create_database(&db_name)?;
783        println!("Created database '{db_name}'");
784
785        let db_url = db_config.database_url(project_name, worktree_name);
786        let env_var = db_config.env_var_name();
787
788        let mut env_vars = EnvVars::load_worktree(project_name, worktree_name)?;
789        env_vars.set(env_var.to_string(), db_url.clone());
790        env_vars.save_worktree(project_name, worktree_name)?;
791        println!("Set {env_var} for worktree '{project_name}/{worktree_name}'");
792
793        if let Some(cmd) = &db_config.setup_command {
794            println!("Running setup command: {cmd}");
795            run_setup_command(cmd, &worktree_path, env_var, &db_url)?;
796            println!("Setup command completed");
797        }
798    }
799
800    run_mise_trust(&worktree_path)?;
801
802    if let Some(hooks) = &project.hooks {
803        if !hooks.post_create.is_empty() {
804            run_post_create_hooks(&hooks.post_create, &worktree_path)?;
805        }
806    }
807
808    Ok(worktree_path)
809}
810
811/// Opens `$EDITOR` pointed at the given path, if `$EDITOR` is set.
812///
813/// Uses `sh -c` to support editors with arguments (e.g., `EDITOR="code --wait"`).
814/// If `$EDITOR` is not set, returns `Ok(())` silently.
815fn open_editor(path: &std::path::Path) -> Result<()> {
816    if std::env::var_os("EDITOR").is_none() {
817        return Ok(());
818    }
819
820    let status = std::process::Command::new("sh")
821        .args(["-c", r#"$EDITOR "$@""#, "--", path.to_str().unwrap()])
822        .status()?;
823
824    if !status.success() {
825        let editor = std::env::var("EDITOR").unwrap_or_default();
826        let code = status
827            .code()
828            .map_or_else(|| "unknown".to_string(), |c| c.to_string());
829        return Err(Error::EditorFailed(editor, code));
830    }
831
832    Ok(())
833}
834
835fn cmd_start(project: &str, name: &str, vcs_override: Option<vcs::VcsOverride>) -> Result<()> {
836    validate_worktree_name(name)?;
837
838    let config = Config::load()?;
839    let (project_name, resolved_project, _repo_env) =
840        config::resolve_project(&config, Some(project))?;
841
842    let worktree_path = resolved_project.worktree_base().join(name);
843
844    if worktree_path.exists()
845        && (worktree_path.join(".git").exists() || worktree_path.join(".jj").exists())
846    {
847        // Valid existing worktree — reuse it
848        eprintln!("worktree '{name}' already exists for {project_name}, reusing");
849    } else {
850        // Either doesn't exist, or is an orphaned directory without .git/.jj
851        if worktree_path.exists() {
852            // Orphaned directory — clean up before recreating
853            std::fs::remove_dir_all(&worktree_path)?;
854        }
855        create_worktree_with_hooks(&project_name, &resolved_project, name, vcs_override)?;
856    }
857
858    open_editor(&worktree_path)?;
859    Ok(())
860}
861
862fn cmd_worktree_new(
863    name_or_project: &str,
864    name: Option<&str>,
865    vcs_override: Option<vcs::VcsOverride>,
866) -> Result<()> {
867    let (explicit_project, worktree_name) = match name {
868        Some(wt_name) => (Some(name_or_project), wt_name),
869        None => (None, name_or_project),
870    };
871
872    let config = Config::load()?;
873    let (project_name, project, _repo_env) = config::resolve_project(&config, explicit_project)?;
874
875    create_worktree_with_hooks(&project_name, &project, worktree_name, vcs_override)?;
876    Ok(())
877}
878
879fn cmd_worktree_list(
880    project_filter: Option<&str>,
881    vcs_override: Option<vcs::VcsOverride>,
882) -> Result<()> {
883    let config = Config::load()?;
884
885    let mut found_any = false;
886    // Track seen repo paths (not names) to deduplicate when registered name ≠ effective_name()
887    let mut seen_repo_paths = std::collections::HashSet::new();
888
889    // 1. Iterate registered projects
890    for (project_name, project) in &config.projects {
891        if let Some(filter) = project_filter {
892            if project_name != filter {
893                continue;
894            }
895        }
896
897        if let Ok(canonical) = project.path.canonicalize() {
898            seen_repo_paths.insert(canonical);
899        }
900        let backend = vcs::detect_backend(&project.path, vcs_override)?;
901        let worktrees = backend.list_worktrees(&project.path, &project.worktree_base())?;
902        for wt in worktrees {
903            found_any = true;
904            let dir_name = wt
905                .path
906                .file_name()
907                .map(|s| s.to_string_lossy())
908                .unwrap_or_default();
909            let branch = wt.branch.as_deref().unwrap_or(match wt.vcs_kind {
910                vcs::VcsKind::Git => "(detached)",
911                vcs::VcsKind::Jj => "(jj workspace)",
912            });
913            println!("{project_name}-{dir_name}\t{branch}\t{}", wt.path.display());
914        }
915    }
916
917    // 2. Also include auto-detected project from cwd (if not already listed)
918    let cwd = std::env::current_dir()?;
919    let auto_detected = config::RepoConfig::discover(&cwd)?;
920    if let Some((ref repo_config, ref repo_root)) = auto_detected {
921        let name = repo_config.effective_name(repo_root);
922
923        let matches_filter = match project_filter {
924            Some(filter) => filter == name,
925            None => true,
926        };
927
928        // Deduplicate by repo path — covers the case where registered name ≠ effective_name()
929        if matches_filter && !seen_repo_paths.contains(repo_root) {
930            let user_proj = config.projects.get(&name);
931            let path = user_proj.map_or_else(|| repo_root.clone(), |p| p.path.clone());
932            let project = config::merge_project(Some(repo_config), user_proj, path);
933            let backend = vcs::detect_backend(&project.path, vcs_override)?;
934            let worktrees = backend.list_worktrees(&project.path, &project.worktree_base())?;
935            for wt in worktrees {
936                found_any = true;
937                let dir_name = wt
938                    .path
939                    .file_name()
940                    .map(|s| s.to_string_lossy())
941                    .unwrap_or_default();
942                let branch = wt.branch.as_deref().unwrap_or(match wt.vcs_kind {
943                    vcs::VcsKind::Git => "(detached)",
944                    vcs::VcsKind::Jj => "(jj workspace)",
945                });
946                println!("{name}-{dir_name}\t{branch}\t{}", wt.path.display());
947            }
948        }
949    }
950
951    // 3. If filter was provided and nothing found, check validity
952    if !found_any {
953        if let Some(filter) = project_filter {
954            if !config.projects.contains_key(filter) {
955                // Reuse the cached auto-detection result instead of re-running discover()
956                let auto_name = auto_detected.map(|(rc, root)| rc.effective_name(&root));
957                if auto_name.as_deref() != Some(filter) {
958                    return Err(Error::ProjectNotFound(filter.to_string()));
959                }
960            }
961            println!("No worktrees found for project '{filter}'");
962        } else {
963            println!("No worktrees found");
964        }
965    }
966
967    Ok(())
968}
969
970fn cleanup_and_remove_worktree(
971    project_name: &str,
972    project: &config::Project,
973    wt: &vcs::WorktreeInfo,
974    vcs_override: Option<vcs::VcsOverride>,
975) -> Result<()> {
976    // Extract worktree dir name before removal (directory may be gone after)
977    let worktree_dir_name = wt
978        .path
979        .file_name()
980        .map(|s| s.to_string_lossy().to_string())
981        .unwrap_or_default();
982
983    if let Some(db_config) = &project.database {
984        let db_name = db_config.db_name(project_name, &worktree_dir_name);
985        println!("Dropping database '{db_name}'...");
986        drop_database(&db_name)?;
987        println!("Dropped database '{db_name}'");
988    }
989
990    let backend = vcs::detect_backend(&project.path, vcs_override)?;
991    backend.remove_worktree(&project.path, &wt.path, &worktree_dir_name)?;
992    println!("Removed worktree at {}", wt.path.display());
993
994    if project.database.is_some() {
995        let override_path = config::worktree_env_path(project_name, &worktree_dir_name)?;
996        if override_path.exists() {
997            std::fs::remove_file(&override_path)?;
998        }
999    }
1000
1001    Ok(())
1002}
1003
1004fn cmd_worktree_rm(name: &str, vcs_override: Option<vcs::VcsOverride>) -> Result<()> {
1005    let config = Config::load()?;
1006
1007    let mut matches: Vec<(String, config::Project, vcs::WorktreeInfo)> = Vec::new();
1008    // Track seen repo paths (not names) to deduplicate when registered name ≠ effective_name()
1009    let mut seen_repo_paths = std::collections::HashSet::new();
1010
1011    // 1. Search registered projects
1012    for (project_name, project) in &config.projects {
1013        if let Ok(canonical) = project.path.canonicalize() {
1014            seen_repo_paths.insert(canonical);
1015        }
1016        let backend = vcs::detect_backend(&project.path, vcs_override)?;
1017        let worktrees = backend.list_worktrees(&project.path, &project.worktree_base())?;
1018        for wt in worktrees {
1019            let dir_name = wt
1020                .path
1021                .file_name()
1022                .map(|s| s.to_string_lossy().to_string())
1023                .unwrap_or_default();
1024
1025            let full_name = format!("{project_name}-{dir_name}");
1026            if full_name == name {
1027                return cleanup_and_remove_worktree(project_name, project, &wt, vcs_override);
1028            }
1029
1030            if dir_name == name {
1031                matches.push((project_name.clone(), project.clone(), wt));
1032            }
1033        }
1034    }
1035
1036    // 2. Also search auto-detected project from cwd
1037    let cwd = std::env::current_dir()?;
1038    if let Some((repo_config, repo_root)) = config::RepoConfig::discover(&cwd)? {
1039        // Deduplicate by repo path — covers the case where registered name ≠ effective_name()
1040        if !seen_repo_paths.contains(&repo_root) {
1041            let proj_name = repo_config.effective_name(&repo_root);
1042            let user_proj = config.projects.get(&proj_name);
1043            let path = user_proj.map(|p| p.path.clone()).unwrap_or(repo_root);
1044            let project = config::merge_project(Some(&repo_config), user_proj, path);
1045            let backend = vcs::detect_backend(&project.path, vcs_override)?;
1046            let worktrees = backend.list_worktrees(&project.path, &project.worktree_base())?;
1047            for wt in worktrees {
1048                let dir_name = wt
1049                    .path
1050                    .file_name()
1051                    .map(|s| s.to_string_lossy().to_string())
1052                    .unwrap_or_default();
1053
1054                let full_name = format!("{proj_name}-{dir_name}");
1055                if full_name == name {
1056                    return cleanup_and_remove_worktree(&proj_name, &project, &wt, vcs_override);
1057                }
1058
1059                if dir_name == name {
1060                    matches.push((proj_name.clone(), project.clone(), wt));
1061                }
1062            }
1063        }
1064    }
1065
1066    // 3. Handle matches
1067    match matches.len() {
1068        0 => Err(Error::WorktreeNotFound(name.to_string())),
1069        1 => {
1070            let (project_name, project, wt) = matches.remove(0);
1071            cleanup_and_remove_worktree(&project_name, &project, &wt, vcs_override)
1072        }
1073        _ => {
1074            let candidates: Vec<String> = matches
1075                .iter()
1076                .map(|(proj, _, wt)| {
1077                    let dir_name = wt
1078                        .path
1079                        .file_name()
1080                        .map(|s| s.to_string_lossy().to_string())
1081                        .unwrap_or_default();
1082                    format!("{proj}-{dir_name}")
1083                })
1084                .collect();
1085            Err(Error::AmbiguousWorktreeName(
1086                name.to_string(),
1087                candidates.join(", "),
1088            ))
1089        }
1090    }
1091}
1092
1093fn mise_data_dir() -> Result<PathBuf> {
1094    if let Ok(dir) = std::env::var("MISE_DATA_DIR") {
1095        return Ok(PathBuf::from(dir));
1096    }
1097    if let Ok(dir) = std::env::var("XDG_DATA_HOME") {
1098        return Ok(PathBuf::from(dir).join("mise"));
1099    }
1100    dirs::home_dir()
1101        .map(|h| h.join(".local/share/mise"))
1102        .ok_or(Error::NoDataDir)
1103}
1104
1105fn cmd_init_mise() -> Result<()> {
1106    let plugin_dir = mise_data_dir()?.join("plugins/grove");
1107    let hooks_dir = plugin_dir.join("hooks");
1108
1109    std::fs::create_dir_all(&hooks_dir)?;
1110    std::fs::write(plugin_dir.join("metadata.lua"), MISE_METADATA_LUA)?;
1111    std::fs::write(hooks_dir.join("mise_env.lua"), MISE_ENV_LUA)?;
1112
1113    println!("Installed grove plugin to {}", plugin_dir.display());
1114    println!();
1115    println!("Add the following to ~/.config/mise/config.toml:");
1116    println!();
1117    println!("[env]");
1118    println!("_.grove = {{}}");
1119    println!();
1120    println!("If the file doesn't exist yet, create it first.");
1121
1122    Ok(())
1123}