Skip to main content

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::{CommandFactory, Parser};
44
45mod config;
46mod error;
47mod vcs;
48
49use config::{AutoRegister, 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    /// Update grove to the latest release
195    #[command(
196        long_about = "Update grove to the latest GitHub release.\n\n\
197            Checks the latest release of coreyja-studio/grove on GitHub and, \
198            if a newer version is available, downloads the correct binary for \
199            your platform and replaces the current executable. Use --check to \
200            see the latest version without downloading.",
201        after_help = "Examples:\n  grove update\n  grove update --check"
202    )]
203    Update {
204        /// Only check for updates without installing
205        #[arg(long)]
206        check: bool,
207    },
208    /// Generate shell completion scripts
209    #[command(
210        long_about = "Generate shell completion scripts for grove.\n\n\
211            Output the completion script to stdout. Pipe it to the appropriate \
212            location for your shell to enable tab-completion of grove commands \
213            and arguments.",
214        after_help = "Examples:\n  \
215            grove completions bash > ~/.local/share/bash-completion/completions/grove\n  \
216            grove completions zsh > ~/.zfunc/_grove\n  \
217            grove completions fish > ~/.config/fish/completions/grove.fish"
218    )]
219    Completions {
220        /// Shell to generate completions for
221        shell: clap_complete::Shell,
222    },
223}
224
225#[derive(clap::Subcommand)]
226enum EnvCommands {
227    /// Set an environment variable
228    #[command(
229        long_about = "Set an environment variable for a project or worktree.\n\n\
230            The project can be specified explicitly or auto-detected from your \
231            current directory. Use the project/worktree syntax to set a variable \
232            on a specific worktree (overrides the project-level value).",
233        after_help = "Examples:\n  \
234            # Set for a project (explicit name):\n  \
235            grove env set myapp DATABASE_URL=postgres://localhost/myapp\n\n  \
236            # Set for current project (auto-detected from cwd):\n  \
237            grove env set SECRET_KEY=abc123\n\n  \
238            # Set for a specific worktree:\n  \
239            grove env set myapp/my-feature DATABASE_URL=postgres://localhost/myapp_feature"
240    )]
241    Set {
242        /// Project name or KEY=value pair (auto-detects project from cwd if KEY=value)
243        project_or_pair: String,
244        /// KEY=value pair (when first argument is a project name)
245        pair: Option<String>,
246    },
247    /// Show all environment variables
248    #[command(
249        long_about = "List all environment variables for a project or worktree.\n\n\
250            Shows variables from all sources (repo .grove/config.toml, project-level, \
251            and worktree-level) with labels indicating where each value comes from. \
252            Worktree-level values override project-level values, which override repo-level.\n\n\
253            If no project is specified, auto-detects from the current directory.",
254        after_help = "Examples:\n  \
255            grove env list myapp\n  \
256            grove env list myapp/my-feature\n  \
257            grove env list          # auto-detect from cwd"
258    )]
259    List {
260        /// Project name or project/worktree (auto-detects from cwd if omitted)
261        project: Option<String>,
262    },
263    /// Remove an environment variable
264    #[command(
265        long_about = "Remove an environment variable from a project or worktree.\n\n\
266            The project can be specified explicitly or auto-detected from the \
267            current directory. Use project/worktree syntax to remove a worktree-level \
268            override (the project-level value will then apply again).",
269        after_help = "Examples:\n  \
270            grove env unset myapp SECRET_KEY\n  \
271            grove env unset SECRET_KEY           # auto-detect project from cwd\n  \
272            grove env unset myapp/feat DATABASE_URL"
273    )]
274    Unset {
275        /// Project name or env var key (auto-detects project from cwd if only one arg)
276        project_or_key: String,
277        /// Env var key (when first argument is a project name)
278        key: Option<String>,
279    },
280    /// Output environment variables for the project containing a path
281    #[command(
282        long_about = "Export resolved environment variables for a given directory path.\n\n\
283            Looks up which project (and optionally worktree) owns the given path, \
284            merges all env var layers (repo, project, worktree), and outputs them. \
285            By default outputs in KEY=value format (one per line); with --json \
286            outputs a JSON object.\n\n\
287            This is primarily used by the mise plugin to inject variables into the \
288            shell, but can also be used for scripting.",
289        after_help = "Examples:\n  \
290            grove env export /home/user/code/myapp\n  \
291            grove env export --json /home/user/code/myapp/.worktrees/feat\n  \
292            eval \"$(grove env export /home/user/code/myapp)\""
293    )]
294    Export {
295        /// Output as a JSON object instead of KEY=value lines
296        #[arg(long)]
297        json: bool,
298        /// Directory path to resolve the project and worktree from
299        path: PathBuf,
300    },
301}
302
303#[derive(clap::Subcommand)]
304enum WorktreeCommands {
305    /// Create a new worktree
306    #[command(
307        long_about = "Create a new git worktree or jj workspace for a project.\n\n\
308            The worktree is created in the project's worktree base directory \
309            (defaults to <repo>/.worktrees/<name>). For git repos, a new branch \
310            with the worktree name is created. For jj repos, a new workspace is added.\n\n\
311            If the project has database configuration in .grove/config.toml, a \
312            dedicated database is created and DATABASE_URL is set automatically. \
313            Post-create hooks (if configured) run after creation.\n\n\
314            The project can be specified explicitly or auto-detected from cwd.",
315        after_help = "Examples:\n  \
316            # Explicit project:\n  \
317            grove worktree new myapp my-feature\n\n  \
318            # Auto-detect project from cwd:\n  \
319            grove worktree new my-feature"
320    )]
321    New {
322        /// Worktree name, or project name if second argument is provided
323        name_or_project: String,
324        /// Worktree name (when provided, first argument is the project name)
325        name: Option<String>,
326    },
327    /// List worktrees
328    #[command(
329        long_about = "List all worktrees across registered projects.\n\n\
330            Shows each worktree's full name (project-worktree), branch, and path, \
331            tab-separated. If a project is specified, only shows worktrees for \
332            that project. Also includes worktrees from auto-detected projects \
333            (via .grove/config.toml in cwd).",
334        after_help = "Examples:\n  \
335            grove worktree list\n  \
336            grove worktree list myapp"
337    )]
338    List {
339        /// Only show worktrees for this project
340        project: Option<String>,
341    },
342    /// Remove a worktree
343    #[command(
344        long_about = "Remove a git worktree or jj workspace.\n\n\
345            Accepts either the full name (e.g., \"myapp-my-feature\") or just the \
346            short worktree name (e.g., \"my-feature\") if it's unambiguous across \
347            all projects. If the project has database configuration, the worktree's \
348            database is dropped automatically.\n\n\
349            For git, this runs `git worktree remove`. For jj, this runs \
350            `jj workspace forget` and deletes the directory.",
351        after_help = "Examples:\n  \
352            grove worktree rm myapp-my-feature\n  \
353            grove worktree rm my-feature          # if unambiguous"
354    )]
355    Rm {
356        /// Worktree name (full \"project-name\" or short \"name\" if unambiguous)
357        name: String,
358    },
359}
360
361fn main() {
362    let cli = Cli::parse();
363
364    if let Err(e) = run(cli.command) {
365        eprintln!("error: {e}");
366        std::process::exit(1);
367    }
368}
369
370fn run(command: Commands) -> Result<()> {
371    match command {
372        Commands::Add { name, path } => cmd_add(&name, path),
373        Commands::List => cmd_list(),
374        Commands::Remove { name } => cmd_remove(&name),
375        Commands::Env { command } => match command {
376            EnvCommands::Set {
377                project_or_pair,
378                pair,
379            } => cmd_env_set(&project_or_pair, pair.as_deref()),
380            EnvCommands::List { project } => cmd_env_list(project.as_deref()),
381            EnvCommands::Unset {
382                project_or_key,
383                key,
384            } => cmd_env_unset(&project_or_key, key.as_deref()),
385            EnvCommands::Export { json, path } => cmd_env_export(path, json),
386        },
387        Commands::InitMise => cmd_init_mise(),
388        Commands::Update { check } => cmd_update(check),
389        Commands::Completions { shell } => {
390            clap_complete::generate(shell, &mut Cli::command(), "grove", &mut std::io::stdout());
391            Ok(())
392        }
393        Commands::Start { project, name, vcs } => {
394            let vcs_override = parse_vcs_override(vcs.as_deref())?;
395            cmd_start(&project, &name, vcs_override)
396        }
397        Commands::Worktree { vcs, command } => {
398            let vcs_override = parse_vcs_override(vcs.as_deref())?;
399            match command {
400                WorktreeCommands::New {
401                    name_or_project,
402                    name,
403                } => cmd_worktree_new(&name_or_project, name.as_deref(), vcs_override),
404                WorktreeCommands::List { project } => {
405                    cmd_worktree_list(project.as_deref(), vcs_override)
406                }
407                WorktreeCommands::Rm { name } => cmd_worktree_rm(&name, vcs_override),
408            }
409        }
410    }
411}
412
413fn cmd_add(name: &str, path: PathBuf) -> Result<()> {
414    Config::update(|config| config.add_project(name.to_string(), path))?;
415    println!("Added project '{name}'");
416    Ok(())
417}
418
419fn cmd_list() -> Result<()> {
420    let config = Config::load()?;
421    if config.projects.is_empty() {
422        println!("No projects registered");
423        return Ok(());
424    }
425    for (name, project) in &config.projects {
426        println!("{name}\t{}", project.path.display());
427    }
428    Ok(())
429}
430
431fn cmd_remove(name: &str) -> Result<()> {
432    Config::update(|config| config.remove_project(name))?;
433    println!("Removed project '{name}'");
434    Ok(())
435}
436
437fn cmd_env_set(project_or_pair: &str, pair: Option<&str>) -> Result<()> {
438    let (project_str, pair_str) = match pair {
439        Some(p) => (Some(project_or_pair), p),
440        None => (None, project_or_pair),
441    };
442
443    let mut config = Config::load()?;
444    // Resolve project once — either from explicit name or auto-detection.
445    // For the two-arg form, project_ref may include a worktree specifier (project/worktree).
446    let (project_ref, resolved) = if let Some(s) = project_str {
447        let pr = ProjectRef::parse(s)?;
448        let resolved =
449            config::resolve_project(&mut config, Some(&pr.project), AutoRegister::Enabled)?;
450        (pr, resolved)
451    } else {
452        let (name, project, repo_env) =
453            config::resolve_project(&mut config, None, AutoRegister::Enabled)?;
454        let pr = ProjectRef {
455            project: name.clone(),
456            worktree: None,
457        };
458        (pr, (name, project, repo_env))
459    };
460
461    let (key, value) = pair_str
462        .split_once('=')
463        .ok_or_else(|| Error::InvalidEnvFormat(pair_str.to_string()))?;
464
465    if let Some(wt_name) = &project_ref.worktree {
466        validate_worktree_exists(&resolved.1, &project_ref.project, wt_name)?;
467
468        let mut vars = EnvVars::load_worktree(&project_ref.project, wt_name)?;
469        vars.set(key.to_string(), value.to_string());
470        vars.save_worktree(&project_ref.project, wt_name)?;
471        println!("Set {key} for worktree '{}/{wt_name}'", project_ref.project);
472    } else {
473        let mut vars = EnvVars::load(&project_ref.project)?;
474        vars.set(key.to_string(), value.to_string());
475        vars.save(&project_ref.project)?;
476        println!("Set {key} for project '{}'", project_ref.project);
477    }
478
479    Ok(())
480}
481
482fn cmd_env_unset(project_or_key: &str, key: Option<&str>) -> Result<()> {
483    let (project_str, actual_key) = match key {
484        Some(k) => (Some(project_or_key), k),
485        None => (None, project_or_key),
486    };
487
488    let mut config = Config::load()?;
489    let project_ref = if let Some(s) = project_str {
490        ProjectRef::parse(s)?
491    } else {
492        let (name, _, _) = config::resolve_project(&mut config, None, AutoRegister::Enabled)?;
493        ProjectRef {
494            project: name,
495            worktree: None,
496        }
497    };
498
499    let (_, resolved_project, _) = config::resolve_project(
500        &mut config,
501        Some(&project_ref.project),
502        AutoRegister::Enabled,
503    )?;
504
505    if let Some(wt_name) = &project_ref.worktree {
506        validate_worktree_exists(&resolved_project, &project_ref.project, wt_name)?;
507
508        let mut vars = EnvVars::load_worktree(&project_ref.project, wt_name)?;
509        if vars.remove(actual_key) {
510            if vars.vars.is_empty() {
511                let path = config::worktree_env_path(&project_ref.project, wt_name)?;
512                if path.exists() {
513                    std::fs::remove_file(&path)?;
514                }
515            } else {
516                vars.save_worktree(&project_ref.project, wt_name)?;
517            }
518            println!(
519                "Unset {actual_key} for worktree '{}/{wt_name}'",
520                project_ref.project
521            );
522        } else {
523            println!(
524                "Key '{actual_key}' not found in worktree '{}/{wt_name}'",
525                project_ref.project
526            );
527        }
528    } else {
529        let mut vars = EnvVars::load(&project_ref.project)?;
530        if vars.remove(actual_key) {
531            if vars.vars.is_empty() {
532                let path = config::env_path(&project_ref.project)?;
533                if path.exists() {
534                    std::fs::remove_file(&path)?;
535                }
536            } else {
537                vars.save(&project_ref.project)?;
538            }
539            println!("Unset {actual_key} for project '{}'", project_ref.project);
540        } else {
541            println!(
542                "Key '{actual_key}' not found in project '{}'",
543                project_ref.project
544            );
545        }
546    }
547
548    Ok(())
549}
550
551fn cmd_env_list(project: Option<&str>) -> Result<()> {
552    let mut config = Config::load()?;
553
554    let project_ref = if let Some(s) = project {
555        ProjectRef::parse(s)?
556    } else {
557        let (name, _, _) = config::resolve_project(&mut config, None, AutoRegister::Disabled)?;
558        ProjectRef {
559            project: name,
560            worktree: None,
561        }
562    };
563
564    let (_, resolved_project, repo_env) = config::resolve_project(
565        &mut config,
566        Some(&project_ref.project),
567        AutoRegister::Disabled,
568    )?;
569
570    if let Some(wt_name) = &project_ref.worktree {
571        validate_worktree_exists(&resolved_project, &project_ref.project, wt_name)?;
572
573        let merged = config::load_merged_env(&project_ref.project, Some(wt_name), &repo_env)?;
574        if merged.is_empty() {
575            println!(
576                "No environment variables set for '{}/{wt_name}'",
577                project_ref.project
578            );
579            return Ok(());
580        }
581
582        let max_key_len = merged.iter().map(|v| v.key.len()).max().unwrap_or(0);
583        for var in &merged {
584            let source_label = match var.source {
585                config::EnvSource::Repo => "(from repo)",
586                config::EnvSource::Project => "(from project)",
587                config::EnvSource::Worktree => "(override)",
588            };
589            println!(
590                "{:width$} = {}  {}",
591                var.key,
592                var.value,
593                source_label,
594                width = max_key_len
595            );
596        }
597    } else {
598        let merged = config::load_merged_env(&project_ref.project, None, &repo_env)?;
599        if merged.is_empty() {
600            println!("No environment variables set for '{}'", project_ref.project);
601            return Ok(());
602        }
603
604        if repo_env.is_empty() {
605            // Backward compat: no repo env, use plain KEY=value format
606            for var in &merged {
607                println!("{}={}", var.key, var.value);
608            }
609        } else {
610            let max_key_len = merged.iter().map(|v| v.key.len()).max().unwrap_or(0);
611            for var in &merged {
612                let source_label = match var.source {
613                    config::EnvSource::Repo => "(from repo)",
614                    config::EnvSource::Project | config::EnvSource::Worktree => "(override)",
615                };
616                println!(
617                    "{:width$} = {}  {}",
618                    var.key,
619                    var.value,
620                    source_label,
621                    width = max_key_len
622                );
623            }
624        }
625    }
626
627    Ok(())
628}
629
630fn cmd_env_export(path: PathBuf, json: bool) -> Result<()> {
631    if json {
632        if !path.exists() {
633            println!("{{}}");
634            return Ok(());
635        }
636
637        let mut config = Config::load()?;
638        let Some((name, _project, worktree, repo_env)) =
639            config::resolve_project_for_path(&mut config, &path, AutoRegister::Disabled)?
640        else {
641            println!("{{}}");
642            return Ok(());
643        };
644
645        let merged = config::load_merged_env(&name, worktree.as_deref(), &repo_env)?;
646        let map: BTreeMap<String, String> = merged.into_iter().map(|v| (v.key, v.value)).collect();
647        let json_str = serde_json::to_string(&map)?;
648        println!("{json_str}");
649    } else {
650        let mut config = Config::load()?;
651        let (name, _project, worktree, repo_env) =
652            config::resolve_project_for_path(&mut config, &path, AutoRegister::Disabled)?
653                .ok_or(Error::NoProjectForPath(path))?;
654
655        let merged = config::load_merged_env(&name, worktree.as_deref(), &repo_env)?;
656        let output = config::export_merged_env(&merged);
657        if !output.is_empty() {
658            println!("{output}");
659        }
660    }
661    Ok(())
662}
663
664fn parse_vcs_override(vcs: Option<&str>) -> Result<Option<vcs::VcsOverride>> {
665    match vcs.map(str::to_lowercase).as_deref() {
666        None => Ok(None),
667        Some("git") => Ok(Some(vcs::VcsOverride::Git)),
668        Some(other) => Err(Error::InvalidVcsOverride(other.to_string())),
669    }
670}
671
672/// Validate that a worktree actually exists for a project.
673fn validate_worktree_exists(
674    project: &config::Project,
675    project_name: &str,
676    worktree_name: &str,
677) -> Result<()> {
678    let backend = vcs::detect_backend(&project.path, None)?;
679    let worktrees = backend.list_worktrees(&project.path, &project.worktree_base())?;
680    let exists = worktrees.iter().any(|wt| {
681        wt.path
682            .file_name()
683            .is_some_and(|n| n.to_string_lossy() == worktree_name)
684    });
685    if !exists {
686        return Err(Error::WorktreeEnvNotFound(
687            project_name.to_string(),
688            worktree_name.to_string(),
689        ));
690    }
691    Ok(())
692}
693
694/// Validate worktree name contains only alphanumeric, hyphens, and underscores.
695fn validate_worktree_name(name: &str) -> Result<()> {
696    if name.is_empty()
697        || !name
698            .chars()
699            .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
700    {
701        return Err(Error::InvalidWorktreeName(name.to_string()));
702    }
703    Ok(())
704}
705
706fn create_database(db_name: &str) -> Result<()> {
707    let output = std::process::Command::new("createdb")
708        .arg(db_name)
709        .output()?;
710
711    if !output.status.success() {
712        return Err(Error::DatabaseCreationFailed(
713            String::from_utf8_lossy(&output.stderr).to_string(),
714        ));
715    }
716
717    Ok(())
718}
719
720fn drop_database(db_name: &str) -> Result<()> {
721    let output = std::process::Command::new("dropdb")
722        .args(["--if-exists", db_name])
723        .output()?;
724
725    if !output.status.success() {
726        return Err(Error::DatabaseDropFailed(
727            String::from_utf8_lossy(&output.stderr).to_string(),
728        ));
729    }
730
731    Ok(())
732}
733
734fn run_setup_command(
735    command: &str,
736    worktree_path: &std::path::Path,
737    env_var_name: &str,
738    database_url: &str,
739) -> Result<()> {
740    let output = std::process::Command::new("sh")
741        .args(["-c", command])
742        .current_dir(worktree_path)
743        .env(env_var_name, database_url)
744        .output()?;
745
746    if !output.status.success() {
747        return Err(Error::SetupCommandFailed(
748            String::from_utf8_lossy(&output.stderr).to_string(),
749        ));
750    }
751
752    Ok(())
753}
754
755fn run_mise_trust(worktree_path: &std::path::Path) -> Result<()> {
756    let output = match std::process::Command::new("mise")
757        .arg("trust")
758        .current_dir(worktree_path)
759        .output()
760    {
761        Ok(output) => output,
762        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
763        Err(e) => return Err(e.into()),
764    };
765
766    if output.status.success() {
767        println!("Ran mise trust");
768    } else {
769        let stderr = String::from_utf8_lossy(&output.stderr);
770        eprintln!("Warning: mise trust failed: {stderr}");
771    }
772    Ok(())
773}
774
775fn run_post_create_hooks(hooks: &[String], worktree_path: &std::path::Path) -> Result<()> {
776    for cmd in hooks {
777        println!("Running hook: {cmd}");
778        let output = std::process::Command::new("sh")
779            .args(["-c", cmd])
780            .current_dir(worktree_path)
781            .output()?;
782
783        if !output.status.success() {
784            return Err(Error::HookFailed(
785                cmd.clone(),
786                String::from_utf8_lossy(&output.stderr).to_string(),
787            ));
788        }
789        println!("Hook completed: {cmd}");
790    }
791    Ok(())
792}
793
794fn create_worktree_with_hooks(
795    project_name: &str,
796    project: &config::Project,
797    worktree_name: &str,
798    vcs_override: Option<vcs::VcsOverride>,
799) -> Result<std::path::PathBuf> {
800    validate_worktree_name(worktree_name)?;
801
802    let worktree_base = project.worktree_base();
803    let worktree_path = worktree_base.join(worktree_name);
804
805    if worktree_path.exists() {
806        return Err(Error::WorktreePathExists(worktree_path));
807    }
808
809    if !worktree_base.exists() {
810        std::fs::create_dir_all(&worktree_base)?;
811    }
812
813    let backend = vcs::detect_backend(&project.path, vcs_override)?;
814    backend.create_worktree(&project.path, &worktree_path, worktree_name)?;
815
816    println!("Created worktree at {}", worktree_path.display());
817
818    if let Some(db_config) = &project.database {
819        let db_name = db_config.db_name(project_name, worktree_name);
820        println!("Creating database '{db_name}'...");
821        create_database(&db_name)?;
822        println!("Created database '{db_name}'");
823
824        let db_url = db_config.database_url(project_name, worktree_name);
825        let env_var = db_config.env_var_name();
826
827        let mut env_vars = EnvVars::load_worktree(project_name, worktree_name)?;
828        env_vars.set(env_var.to_string(), db_url.clone());
829        env_vars.save_worktree(project_name, worktree_name)?;
830        println!("Set {env_var} for worktree '{project_name}/{worktree_name}'");
831
832        if let Some(cmd) = &db_config.setup_command {
833            println!("Running setup command: {cmd}");
834            run_setup_command(cmd, &worktree_path, env_var, &db_url)?;
835            println!("Setup command completed");
836        }
837    }
838
839    run_mise_trust(&worktree_path)?;
840
841    if let Some(hooks) = &project.hooks {
842        if !hooks.post_create.is_empty() {
843            run_post_create_hooks(&hooks.post_create, &worktree_path)?;
844        }
845    }
846
847    Ok(worktree_path)
848}
849
850/// Opens `$EDITOR` pointed at the given path, if `$EDITOR` is set.
851///
852/// Uses `sh -c` to support editors with arguments (e.g., `EDITOR="code --wait"`).
853/// If `$EDITOR` is not set, returns `Ok(())` silently.
854fn open_editor(path: &std::path::Path) -> Result<()> {
855    if std::env::var_os("EDITOR").is_none() {
856        return Ok(());
857    }
858
859    let status = std::process::Command::new("sh")
860        .args(["-c", r#"$EDITOR "$@""#, "--", path.to_str().unwrap()])
861        .status()?;
862
863    if !status.success() {
864        let editor = std::env::var("EDITOR").unwrap_or_default();
865        let code = status
866            .code()
867            .map_or_else(|| "unknown".to_string(), |c| c.to_string());
868        return Err(Error::EditorFailed(editor, code));
869    }
870
871    Ok(())
872}
873
874fn cmd_start(project: &str, name: &str, vcs_override: Option<vcs::VcsOverride>) -> Result<()> {
875    validate_worktree_name(name)?;
876
877    let mut config = Config::load()?;
878    let (project_name, resolved_project, _repo_env) =
879        config::resolve_project(&mut config, Some(project), AutoRegister::Enabled)?;
880
881    let worktree_path = resolved_project.worktree_base().join(name);
882
883    if worktree_path.exists()
884        && (worktree_path.join(".git").exists() || worktree_path.join(".jj").exists())
885    {
886        // Valid existing worktree — reuse it
887        eprintln!("worktree '{name}' already exists for {project_name}, reusing");
888    } else {
889        // Either doesn't exist, or is an orphaned directory without .git/.jj
890        if worktree_path.exists() {
891            // Orphaned directory — clean up before recreating
892            std::fs::remove_dir_all(&worktree_path)?;
893        }
894        create_worktree_with_hooks(&project_name, &resolved_project, name, vcs_override)?;
895    }
896
897    open_editor(&worktree_path)?;
898    Ok(())
899}
900
901fn cmd_worktree_new(
902    name_or_project: &str,
903    name: Option<&str>,
904    vcs_override: Option<vcs::VcsOverride>,
905) -> Result<()> {
906    let (explicit_project, worktree_name) = match name {
907        Some(wt_name) => (Some(name_or_project), wt_name),
908        None => (None, name_or_project),
909    };
910
911    let mut config = Config::load()?;
912    let (project_name, project, _repo_env) =
913        config::resolve_project(&mut config, explicit_project, AutoRegister::Enabled)?;
914
915    create_worktree_with_hooks(&project_name, &project, worktree_name, vcs_override)?;
916    Ok(())
917}
918
919fn cmd_worktree_list(
920    project_filter: Option<&str>,
921    vcs_override: Option<vcs::VcsOverride>,
922) -> Result<()> {
923    let config = Config::load()?;
924
925    let mut found_any = false;
926    // Track seen repo paths (not names) to deduplicate when registered name ≠ effective_name()
927    let mut seen_repo_paths = std::collections::HashSet::new();
928
929    // 1. Iterate registered projects
930    for (project_name, project) in &config.projects {
931        if let Some(filter) = project_filter {
932            if project_name != filter {
933                continue;
934            }
935        }
936
937        if let Ok(canonical) = project.path.canonicalize() {
938            seen_repo_paths.insert(canonical);
939        }
940        let backend = vcs::detect_backend(&project.path, vcs_override)?;
941        let worktrees = backend.list_worktrees(&project.path, &project.worktree_base())?;
942        for wt in worktrees {
943            found_any = true;
944            let dir_name = wt
945                .path
946                .file_name()
947                .map(|s| s.to_string_lossy())
948                .unwrap_or_default();
949            let branch = wt.branch.as_deref().unwrap_or(match wt.vcs_kind {
950                vcs::VcsKind::Git => "(detached)",
951                vcs::VcsKind::Jj => "(jj workspace)",
952            });
953            println!("{project_name}-{dir_name}\t{branch}\t{}", wt.path.display());
954        }
955    }
956
957    // 2. Also include auto-detected project from cwd (if not already listed)
958    let cwd = std::env::current_dir()?;
959    let auto_detected = config::RepoConfig::discover(&cwd)?;
960    if let Some((ref repo_config, ref repo_root)) = auto_detected {
961        let name = repo_config.effective_name(repo_root);
962
963        let matches_filter = match project_filter {
964            Some(filter) => filter == name,
965            None => true,
966        };
967
968        // Deduplicate by repo path — covers the case where registered name ≠ effective_name()
969        if matches_filter && !seen_repo_paths.contains(repo_root) {
970            let user_proj = config.projects.get(&name);
971            let path = user_proj.map_or_else(|| repo_root.clone(), |p| p.path.clone());
972            let project = config::merge_project(Some(repo_config), user_proj, path);
973
974            let backend = vcs::detect_backend(&project.path, vcs_override)?;
975            let worktrees = backend.list_worktrees(&project.path, &project.worktree_base())?;
976            for wt in worktrees {
977                found_any = true;
978                let dir_name = wt
979                    .path
980                    .file_name()
981                    .map(|s| s.to_string_lossy())
982                    .unwrap_or_default();
983                let branch = wt.branch.as_deref().unwrap_or(match wt.vcs_kind {
984                    vcs::VcsKind::Git => "(detached)",
985                    vcs::VcsKind::Jj => "(jj workspace)",
986                });
987                println!("{name}-{dir_name}\t{branch}\t{}", wt.path.display());
988            }
989        }
990    }
991
992    // 3. If filter was provided and nothing found, check validity
993    if !found_any {
994        if let Some(filter) = project_filter {
995            if !config.projects.contains_key(filter) {
996                // Reuse the cached auto-detection result instead of re-running discover()
997                let auto_name = auto_detected.map(|(rc, root)| rc.effective_name(&root));
998                if auto_name.as_deref() != Some(filter) {
999                    return Err(Error::ProjectNotFound(filter.to_string()));
1000                }
1001            }
1002            println!("No worktrees found for project '{filter}'");
1003        } else {
1004            println!("No worktrees found");
1005        }
1006    }
1007
1008    Ok(())
1009}
1010
1011fn cleanup_and_remove_worktree(
1012    project_name: &str,
1013    project: &config::Project,
1014    wt: &vcs::WorktreeInfo,
1015    vcs_override: Option<vcs::VcsOverride>,
1016) -> Result<()> {
1017    // Extract worktree dir name before removal (directory may be gone after)
1018    let worktree_dir_name = wt
1019        .path
1020        .file_name()
1021        .map(|s| s.to_string_lossy().to_string())
1022        .unwrap_or_default();
1023
1024    if let Some(db_config) = &project.database {
1025        let db_name = db_config.db_name(project_name, &worktree_dir_name);
1026        println!("Dropping database '{db_name}'...");
1027        drop_database(&db_name)?;
1028        println!("Dropped database '{db_name}'");
1029    }
1030
1031    let backend = vcs::detect_backend(&project.path, vcs_override)?;
1032    backend.remove_worktree(&project.path, &wt.path, &worktree_dir_name)?;
1033    println!("Removed worktree at {}", wt.path.display());
1034
1035    if project.database.is_some() {
1036        let override_path = config::worktree_env_path(project_name, &worktree_dir_name)?;
1037        if override_path.exists() {
1038            std::fs::remove_file(&override_path)?;
1039        }
1040    }
1041
1042    Ok(())
1043}
1044
1045fn cmd_worktree_rm(name: &str, vcs_override: Option<vcs::VcsOverride>) -> Result<()> {
1046    let config = Config::load()?;
1047
1048    let mut matches: Vec<(String, config::Project, vcs::WorktreeInfo)> = Vec::new();
1049    // Track seen repo paths (not names) to deduplicate when registered name ≠ effective_name()
1050    let mut seen_repo_paths = std::collections::HashSet::new();
1051
1052    // 1. Search registered projects
1053    for (project_name, project) in &config.projects {
1054        if let Ok(canonical) = project.path.canonicalize() {
1055            seen_repo_paths.insert(canonical);
1056        }
1057        let backend = vcs::detect_backend(&project.path, vcs_override)?;
1058        let worktrees = backend.list_worktrees(&project.path, &project.worktree_base())?;
1059        for wt in worktrees {
1060            let dir_name = wt
1061                .path
1062                .file_name()
1063                .map(|s| s.to_string_lossy().to_string())
1064                .unwrap_or_default();
1065
1066            let full_name = format!("{project_name}-{dir_name}");
1067            if full_name == name {
1068                return cleanup_and_remove_worktree(project_name, project, &wt, vcs_override);
1069            }
1070
1071            if dir_name == name {
1072                matches.push((project_name.clone(), project.clone(), wt));
1073            }
1074        }
1075    }
1076
1077    // 2. Also search auto-detected project from cwd
1078    let cwd = std::env::current_dir()?;
1079    if let Some((repo_config, repo_root)) = config::RepoConfig::discover(&cwd)? {
1080        // Deduplicate by repo path — covers the case where registered name ≠ effective_name()
1081        if !seen_repo_paths.contains(&repo_root) {
1082            let proj_name = repo_config.effective_name(&repo_root);
1083            let user_proj = config.projects.get(&proj_name);
1084            let path = user_proj.map(|p| p.path.clone()).unwrap_or(repo_root);
1085            let project = config::merge_project(Some(&repo_config), user_proj, path);
1086            let backend = vcs::detect_backend(&project.path, vcs_override)?;
1087            let worktrees = backend.list_worktrees(&project.path, &project.worktree_base())?;
1088            for wt in worktrees {
1089                let dir_name = wt
1090                    .path
1091                    .file_name()
1092                    .map(|s| s.to_string_lossy().to_string())
1093                    .unwrap_or_default();
1094
1095                let full_name = format!("{proj_name}-{dir_name}");
1096                if full_name == name {
1097                    return cleanup_and_remove_worktree(&proj_name, &project, &wt, vcs_override);
1098                }
1099
1100                if dir_name == name {
1101                    matches.push((proj_name.clone(), project.clone(), wt));
1102                }
1103            }
1104        }
1105    }
1106
1107    // 3. Handle matches
1108    match matches.len() {
1109        0 => Err(Error::WorktreeNotFound(name.to_string())),
1110        1 => {
1111            let (project_name, project, wt) = matches.remove(0);
1112            cleanup_and_remove_worktree(&project_name, &project, &wt, vcs_override)
1113        }
1114        _ => {
1115            let candidates: Vec<String> = matches
1116                .iter()
1117                .map(|(proj, _, wt)| {
1118                    let dir_name = wt
1119                        .path
1120                        .file_name()
1121                        .map(|s| s.to_string_lossy().to_string())
1122                        .unwrap_or_default();
1123                    format!("{proj}-{dir_name}")
1124                })
1125                .collect();
1126            Err(Error::AmbiguousWorktreeName(
1127                name.to_string(),
1128                candidates.join(", "),
1129            ))
1130        }
1131    }
1132}
1133
1134fn mise_data_dir() -> Result<PathBuf> {
1135    if let Ok(dir) = std::env::var("MISE_DATA_DIR") {
1136        return Ok(PathBuf::from(dir));
1137    }
1138    if let Ok(dir) = std::env::var("XDG_DATA_HOME") {
1139        return Ok(PathBuf::from(dir).join("mise"));
1140    }
1141    dirs::home_dir()
1142        .map(|h| h.join(".local/share/mise"))
1143        .ok_or(Error::NoDataDir)
1144}
1145
1146fn cmd_init_mise() -> Result<()> {
1147    let plugin_dir = mise_data_dir()?.join("plugins/grove");
1148    let hooks_dir = plugin_dir.join("hooks");
1149
1150    std::fs::create_dir_all(&hooks_dir)?;
1151    std::fs::write(plugin_dir.join("metadata.lua"), MISE_METADATA_LUA)?;
1152    std::fs::write(hooks_dir.join("mise_env.lua"), MISE_ENV_LUA)?;
1153
1154    println!("Installed grove plugin to {}", plugin_dir.display());
1155    println!();
1156    println!("Add the following to ~/.config/mise/config.toml:");
1157    println!();
1158    println!("[env]");
1159    println!("_.grove = {{}}");
1160    println!();
1161    println!("If the file doesn't exist yet, create it first.");
1162
1163    Ok(())
1164}
1165
1166fn cmd_update(check_only: bool) -> Result<()> {
1167    let current = env!("CARGO_PKG_VERSION");
1168
1169    let updater = self_update::backends::github::Update::configure()
1170        .repo_owner("coreyja-studio")
1171        .repo_name("grove")
1172        .bin_name("grove")
1173        .current_version(current)
1174        .build()
1175        .map_err(|e| Error::Update(e.to_string()))?;
1176
1177    let latest = updater
1178        .get_latest_release()
1179        .map_err(|e| Error::Update(e.to_string()))?;
1180
1181    let latest_version = latest.version.strip_prefix('v').unwrap_or(&latest.version);
1182
1183    if check_only {
1184        println!("Current version: {current}");
1185        println!("Latest version:  {latest_version}");
1186        if latest_version == current {
1187            println!("Already up to date.");
1188        }
1189        return Ok(());
1190    }
1191
1192    if latest_version == current {
1193        println!("Already up to date (v{current}).");
1194        return Ok(());
1195    }
1196
1197    println!("Updating grove v{current} -> v{latest_version}...");
1198
1199    let status = updater.update().map_err(|e| Error::Update(e.to_string()))?;
1200
1201    let new_version = status
1202        .version()
1203        .strip_prefix('v')
1204        .unwrap_or(status.version());
1205    println!("Updated to v{new_version}.");
1206
1207    Ok(())
1208}