use std::collections::HashSet;
use std::env;
use std::fs;
use std::io::{ErrorKind, Read};
use std::path::Path;
use std::thread;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use time::{OffsetDateTime, macros::format_description};
use crate::cli::{Cli, Command, ConfigCommand, ScriptAddArgs, ScriptCommand};
use crate::config::{
Config, ScriptEntry, config_path, default_config_template, expand_path, worktree_path,
};
use crate::git;
use crate::output;
use crate::project::{require_repo_root, resolve_project};
pub fn run(cli: Cli) -> Result<()> {
run_command(cli.project.as_deref(), cli.command)
}
fn run_command(project: Option<&str>, command: Command) -> Result<()> {
match command {
Command::Init => init(project),
Command::Open { branch, from } => open_branch(project, branch.as_deref(), from.as_deref()),
Command::Close {
branch,
delete_branch,
} => close_branch(project, branch.as_deref(), delete_branch),
Command::Prune { merged } => prune(project, merged),
Command::Go { branch } => go(project, &branch),
Command::List => list(project),
Command::Config { command } => config(command),
Command::Script { command } => script(project, command),
Command::External(args) => {
let name = args
.first()
.context("external subcommand requires an argument")?;
let script_args = args.get(1..).unwrap_or(&[]).to_vec();
script_run(project, name, &script_args)
}
}
}
fn config(command: ConfigCommand) -> Result<()> {
match command {
ConfigCommand::Init => {
let path = config_path()?;
if path.exists() {
output::print_info(&format!("config file already exists at {}", path.display()));
return Ok(());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("failed to create config directory {}", parent.display())
})?;
}
fs::write(&path, default_config_template())
.with_context(|| format!("failed to write config at {}", path.display()))?;
output::print_success(&format!("created config at {}", path.display()));
Ok(())
}
ConfigCommand::Path => {
output::print_path(&config_path()?);
Ok(())
}
ConfigCommand::Show => {
let path = config_path()?;
match fs::read_to_string(&path) {
Ok(contents) => {
print!("{contents}");
Ok(())
}
Err(error) if error.kind() == ErrorKind::NotFound => {
output::print_info(&format!("config file not found at {}", path.display()));
output::print_info(
"it will be created automatically when a command saves configuration",
);
print!("{}", default_config_template());
Ok(())
}
Err(error) => Err(error)
.with_context(|| format!("failed to read config at {}", path.display())),
}
}
}
}
fn init(project: Option<&str>) -> Result<()> {
let cwd = env::current_dir().context("failed to read current directory")?;
let context = resolve_project(project, &cwd)?;
let repo_root = require_repo_root(&context)?;
let config = Config::load()?;
init_repo(repo_root, &context.alias, &config)
}
fn init_repo(repo_root: &Path, alias: &str, config: &Config) -> Result<()> {
if !git::has_origin(repo_root)? {
bail!(
"repository at {} does not have an origin remote",
repo_root.display()
)
}
let _ = git::origin_url(repo_root)?;
if !git::is_clean(repo_root)? {
bail!("repository has uncommitted changes; refusing to reset")
}
let branch = git::current_branch(repo_root)?;
git::fetch_origin(repo_root)?;
if git::remote_branch_exists(repo_root, &branch)? {
git::hard_reset_to_origin(repo_root, &branch)?;
}
for script in config.scripts_for_project(alias) {
if let Some(path) = &script.path {
let expanded = expand_path(path);
if !expanded.exists() {
bail!(
"configured script '{}' does not exist at {}",
script.name,
expanded.display()
)
}
git::run_script(repo_root, &expanded, &[])?;
} else if let Some(text) = &script.text {
git::run_script_text(repo_root, text, &[], &script.name)?;
}
}
Ok(())
}
fn go(project: Option<&str>, branch: &str) -> Result<()> {
let cwd = env::current_dir().context("failed to read current directory")?;
let context = resolve_project(project, &cwd)?;
if let Some(repo_root) = &context.repo_root {
let worktrees = git::list_worktrees(repo_root)?;
if let Some(worktree) = worktrees
.iter()
.find(|worktree| worktree.branch.as_deref() == Some(branch))
{
output::print_path(&worktree.path);
return Ok(());
}
}
let config = Config::load()?;
let target = worktree_path(&config, &context.alias, branch)?;
output::print_path(&target);
Ok(())
}
fn list(project: Option<&str>) -> Result<()> {
let cwd = env::current_dir().context("failed to read current directory")?;
let context = resolve_project(project, &cwd)?;
let repo_root = require_repo_root(&context)?;
let worktrees = git::list_worktrees(repo_root)?;
for worktree in worktrees {
let branch = worktree.branch.unwrap_or_else(|| "(detached)".to_owned());
output::print_list_entry(
if worktree.is_current { "*" } else { "-" },
&format!("{branch}\t{}", worktree.path.display()),
worktree.is_current,
);
}
Ok(())
}
fn script(project: Option<&str>, command: ScriptCommand) -> Result<()> {
match command {
ScriptCommand::Add(args) => script_add(project, &args),
ScriptCommand::List => script_list(project),
ScriptCommand::All => script_all(),
ScriptCommand::Run { name, args } => script_run(project, &name, &args),
ScriptCommand::Remove { name } => script_remove(project, &name),
}
}
fn script_add(project: Option<&str>, args: &ScriptAddArgs) -> Result<()> {
let cwd = env::current_dir().context("failed to read current directory")?;
let context = resolve_project(project, &cwd)?;
let mut config = Config::load()?;
let (path, text) = match (&args.path, &args.text) {
(Some(_), Some(_)) => {
bail!("cannot specify both a path and --text for a script")
}
(Some(path), None) => (Some(path.to_path_buf()), None),
(None, Some(text)) => {
let content = match text {
Some(s) => s.clone(),
None => {
let mut buffer = String::new();
std::io::stdin()
.read_to_string(&mut buffer)
.context("failed to read script text from stdin")?;
buffer
}
};
(None, Some(content))
}
(None, None) => bail!("must specify either a path or --text for a script"),
};
config.add_script(
&context.alias,
ScriptEntry {
name: args.name.clone(),
path,
text,
},
)?;
config.save()?;
output::print_success(&format!(
"added script '{}' for project '{}'",
args.name, context.alias
));
Ok(())
}
fn script_list(project: Option<&str>) -> Result<()> {
let cwd = env::current_dir().context("failed to read current directory")?;
let context = resolve_project(project, &cwd)?;
let config = Config::load()?;
let scripts = config.scripts_for_project(&context.alias);
if scripts.is_empty() {
output::print_info(&format!(
"no scripts configured for project '{}'",
context.alias
));
return Ok(());
}
for script in scripts {
let display = if let Some(path) = &script.path {
expand_path(path).display().to_string()
} else {
"(inline)".to_owned()
};
println!("{}\t{}", script.name, display);
}
Ok(())
}
fn script_all() -> Result<()> {
let config = Config::load()?;
let mut projects: Vec<_> = config.scripts.iter().collect();
projects.sort_by(|a, b| a.0.cmp(b.0));
if projects.is_empty() {
output::print_info("no scripts configured");
return Ok(());
}
for (index, (project, scripts)) in projects.iter().enumerate() {
if index > 0 {
println!();
}
output::print_section(&format!("[{project}]"));
for script in scripts.iter() {
let display = if let Some(path) = &script.path {
expand_path(path).display().to_string()
} else {
"(inline)".to_owned()
};
println!("{}\t{}", script.name, display);
}
}
Ok(())
}
fn script_run(project: Option<&str>, name: &str, args: &[String]) -> Result<()> {
let cwd = env::current_dir().context("failed to read current directory")?;
let context = resolve_project(project, &cwd)?;
let config = Config::load()?;
let script = config
.scripts_for_project(&context.alias)
.iter()
.find(|script| script.name == name)
.with_context(|| format!("script '{name}' not found for project '{}'", context.alias))?;
let run_dir = context.repo_root.as_deref().unwrap_or(cwd.as_path());
if let Some(path) = &script.path {
let script_path = expand_path(path);
if !script_path.exists() {
bail!(
"configured script '{}' does not exist at {}",
script.name,
script_path.display()
)
}
git::run_script(run_dir, &script_path, args)
} else if let Some(text) = &script.text {
git::run_script_text(run_dir, text, args, &script.name)
} else {
bail!("script '{}' has neither path nor text", script.name)
}
}
fn script_remove(project: Option<&str>, name: &str) -> Result<()> {
let cwd = env::current_dir().context("failed to read current directory")?;
let context = resolve_project(project, &cwd)?;
let mut config = Config::load()?;
config.remove_script(&context.alias, name)?;
config.save()?;
output::print_success(&format!(
"removed script '{name}' from project '{}'",
context.alias
));
Ok(())
}
fn open_branch(project: Option<&str>, branch: Option<&str>, from: Option<&str>) -> Result<()> {
let cwd = env::current_dir().context("failed to read current directory")?;
let context = resolve_project(project, &cwd)?;
let repo_root = require_repo_root(&context)?;
let config = Config::load()?;
let branch = match branch {
Some(branch) => branch.to_owned(),
None => derive_open_branch_name(repo_root, &config, &context.alias, from, &cwd)?,
};
let target = worktree_path(&config, &context.alias, &branch)?;
if !target.exists() {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).with_context(|| {
format!(
"failed to create worktree parent directory {}",
parent.display()
)
})?;
}
if let Some(from) = from {
let base = branch_base_ref(repo_root, from)?;
git::add_worktree_from(repo_root, &target, &branch, &base)?;
} else {
git::add_worktree(repo_root, &target, &branch)?;
}
}
let config = Config::load()?;
init_repo(&target, &context.alias, &config)?;
output::print_path(&target);
Ok(())
}
fn close_branch(project: Option<&str>, branch: Option<&str>, delete_branch: bool) -> Result<()> {
let cwd = env::current_dir().context("failed to read current directory")?;
let context = resolve_project(project, &cwd)?;
let repo_root = require_repo_root(&context)?;
let explicit_branch = branch.is_some();
let branch = match branch {
Some(branch) => branch.to_owned(),
None => git::current_branch(&cwd)?,
};
let canonical_cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.clone());
let worktrees = git::list_worktrees(repo_root)?;
let worktree = if explicit_branch {
worktrees
.iter()
.find(|wt| wt.branch.as_deref() == Some(branch.as_str()))
} else {
worktrees
.iter()
.find(|wt| canonical_cwd.starts_with(&wt.path))
.or_else(|| worktrees.iter().find(|wt| wt.branch.as_deref() == Some(branch.as_str())))
};
let worktree = worktree.with_context(|| {
if explicit_branch {
format!("no worktree found for branch '{branch}'")
} else {
"no worktree found for current directory".to_owned()
}
})?;
let main_repo = if delete_branch {
let git_dir = git::absolute_git_dir(repo_root)?;
Some(git_dir
.ancestors()
.find(|ancestor| ancestor.file_name().map_or(false, |name| name == ".git"))
.and_then(|dotgit| dotgit.parent())
.with_context(|| format!("failed to find main repository for {}", git_dir.display()))?
.to_path_buf())
} else {
None
};
git::remove_worktree(repo_root, &worktree.path)?;
output::print_success(&format!("removed worktree {}", worktree.path.display()));
if delete_branch {
let main_repo = main_repo.context("main repository was not resolved")?;
git::delete_local_branch(&main_repo, &branch)?;
output::print_success(&format!("deleted local branch '{branch}'"));
}
Ok(())
}
fn prune(project: Option<&str>, merged: bool) -> Result<()> {
let cwd = env::current_dir().context("failed to read current directory")?;
let context = resolve_project(project, &cwd)?;
let repo_root = require_repo_root(&context)?;
git::prune_worktrees(repo_root)?;
output::print_success("pruned stale worktree metadata");
if !merged {
return Ok(());
}
let checked_out_branches: HashSet<_> = git::list_worktrees(repo_root)?
.into_iter()
.filter_map(|worktree| worktree.branch)
.collect();
for branch in git::merged_local_branches(repo_root)? {
if checked_out_branches.contains(&branch) {
continue;
}
git::delete_local_branch(repo_root, &branch)?;
output::print_success(&format!("deleted local branch '{branch}'"));
}
Ok(())
}
fn derive_open_branch_name(
collision_root: &Path,
config: &Config,
alias: &str,
from: Option<&str>,
cwd: &Path,
) -> Result<String> {
let prefix = from.map(str::to_owned).unwrap_or(git::current_branch(cwd)?);
let current = prefix.trim_end_matches('-');
if current.is_empty() {
bail!("current branch name is invalid after trimming trailing dashes")
}
let _ = config.worktree_destination()?;
for _ in 0..1000 {
let timestamp = OffsetDateTime::now_utc()
.format(format_description!(
"[year repr:last_two][month][day]-[hour][minute][second]-[subsecond digits:9]"
))
.context("failed to format generated branch timestamp")?;
let branch = format!("{current}-{timestamp}");
if !branch_or_path_exists(collision_root, config, alias, &branch)? {
return Ok(branch);
}
thread::sleep(Duration::from_millis(1));
}
bail!("failed to generate a unique timestamped branch name")
}
fn branch_or_path_exists(
collision_root: &Path,
config: &Config,
alias: &str,
branch: &str,
) -> Result<bool> {
Ok(git::local_branch_exists(collision_root, branch)?
|| git::remote_branch_exists(collision_root, branch)?
|| worktree_path(config, alias, branch)?.exists())
}
fn branch_base_ref(repo_root: &Path, branch: &str) -> Result<String> {
if git::local_branch_exists(repo_root, branch)? {
Ok(branch.to_owned())
} else if git::remote_branch_exists(repo_root, branch)? {
Ok(format!("origin/{branch}"))
} else {
bail!("base branch '{branch}' not found")
}
}