1use 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 #[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 name: String,
94 path: PathBuf,
96 },
97 #[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 #[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: String,
117 },
118 #[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 #[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 #[arg(long)]
154 vcs: Option<String>,
155 #[command(subcommand)]
156 command: WorktreeCommands,
157 },
158 #[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 project: String,
174 name: String,
176 #[arg(long)]
178 vcs: Option<String>,
179 },
180 #[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 #[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_or_pair: String,
215 pair: Option<String>,
217 },
218 #[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: Option<String>,
233 },
234 #[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_or_key: String,
248 key: Option<String>,
250 },
251 #[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 #[arg(long)]
268 json: bool,
269 path: PathBuf,
271 },
272}
273
274#[derive(clap::Subcommand)]
275enum WorktreeCommands {
276 #[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 name_or_project: String,
295 name: Option<String>,
297 },
298 #[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 project: Option<String>,
312 },
313 #[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 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 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 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
633fn 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
655fn 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
811fn 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 eprintln!("worktree '{name}' already exists for {project_name}, reusing");
849 } else {
850 if worktree_path.exists() {
852 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 let mut seen_repo_paths = std::collections::HashSet::new();
888
889 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 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 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 if !found_any {
953 if let Some(filter) = project_filter {
954 if !config.projects.contains_key(filter) {
955 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 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 let mut seen_repo_paths = std::collections::HashSet::new();
1010
1011 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 let cwd = std::env::current_dir()?;
1038 if let Some((repo_config, repo_root)) = config::RepoConfig::discover(&cwd)? {
1039 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 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}