1use 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 #[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 #[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 #[arg(long)]
206 check: bool,
207 },
208 #[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: clap_complete::Shell,
222 },
223}
224
225#[derive(clap::Subcommand)]
226enum EnvCommands {
227 #[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_or_pair: String,
244 pair: Option<String>,
246 },
247 #[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: Option<String>,
262 },
263 #[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_or_key: String,
277 key: Option<String>,
279 },
280 #[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 #[arg(long)]
297 json: bool,
298 path: PathBuf,
300 },
301}
302
303#[derive(clap::Subcommand)]
304enum WorktreeCommands {
305 #[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 name_or_project: String,
324 name: Option<String>,
326 },
327 #[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 project: Option<String>,
341 },
342 #[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 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 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 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
672fn 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
694fn 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
850fn 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 eprintln!("worktree '{name}' already exists for {project_name}, reusing");
888 } else {
889 if worktree_path.exists() {
891 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 let mut seen_repo_paths = std::collections::HashSet::new();
928
929 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 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 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 if !found_any {
994 if let Some(filter) = project_filter {
995 if !config.projects.contains_key(filter) {
996 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 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 let mut seen_repo_paths = std::collections::HashSet::new();
1051
1052 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 let cwd = std::env::current_dir()?;
1079 if let Some((repo_config, repo_root)) = config::RepoConfig::discover(&cwd)? {
1080 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 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}