use std::env;
use std::path::Path;
use clap::{Parser, Subcommand};
use crate::config::{self, Config};
use crate::repo;
use crate::search;
#[derive(Parser)]
#[command(
name = "gimme",
about = "The multi-repo manager for professional developers",
long_about = "The multi-repo manager for professional developers. Gimme helps you \
hop from project to project, branch to branch, and worktree to worktree. \
Pin your areas of ownership, set up multiple code folders, and alias partial \
searches for faster jumping.",
version,
args_conflicts_with_subcommands = true
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
pub repo: Option<String>,
}
#[derive(Subcommand)]
pub enum Command {
#[command(alias = "to")]
Jump {
repo: String,
},
#[command(alias = "ls")]
List {
query: Option<String>,
#[arg(short, long)]
branch: bool,
#[arg(long)]
merged: bool,
#[arg(long)]
no_merged: bool,
},
Pin {
target: Option<String>,
#[arg(short, long)]
branch: bool,
},
Unpin {
target: Option<String>,
#[arg(short, long)]
branch: bool,
},
Clean {
#[arg(short, long)]
branch: bool,
#[arg(long)]
all: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
force: bool,
#[arg(short, long)]
verbose: bool,
},
Config {
#[command(subcommand)]
action: Option<ConfigAction>,
},
Setup,
}
#[derive(Subcommand)]
pub enum ConfigAction {
Add {
#[command(subcommand)]
what: ConfigAddTarget,
},
#[command(alias = "rm", alias = "remove")]
Delete {
#[command(subcommand)]
what: ConfigDeleteTarget,
},
#[command(alias = "list")]
Ls {
#[command(subcommand)]
what: Option<ConfigLsTarget>,
},
}
#[derive(Subcommand)]
pub enum ConfigAddTarget {
Group {
path: String,
},
Alias {
short: String,
expanded: String,
},
Protected {
branch: String,
},
}
#[derive(Subcommand)]
pub enum ConfigDeleteTarget {
Group {
target: String,
},
Alias {
short: String,
},
Protected {
branch: String,
},
}
#[derive(Subcommand)]
pub enum ConfigLsTarget {
#[command(alias = "groups")]
Group,
#[command(alias = "repos")]
Repo,
#[command(alias = "branches")]
Branch,
#[command(alias = "aliases")]
Alias,
}
pub fn run(cli: Cli, config: &mut Config) {
match cli.command {
Some(Command::Jump { repo }) => jump(&repo, config),
Some(Command::List {
query,
branch,
merged,
no_merged,
}) => {
if branch {
list_branches(config, merged, no_merged);
} else {
list_repos(config, query.as_deref().unwrap_or(""));
}
}
Some(Command::Pin { target, branch }) => {
if branch {
pin_branch(config, target.as_deref());
} else {
pin_repo(config, target.as_deref());
}
}
Some(Command::Unpin { target, branch }) => {
if branch {
unpin_branch(config, target.as_deref());
} else {
unpin_repo(config, target.as_deref());
}
}
Some(Command::Clean {
branch,
all,
dry_run,
force,
verbose,
}) => {
if !branch {
eprintln!("Please specify -b flag to clean branches.");
eprintln!("Usage: gimme clean -b [--all] [--dry-run] [--force] [-v]");
return;
}
clean_branches(config, all, dry_run, force, verbose);
}
Some(Command::Config { action }) => run_config(config, action),
Some(Command::Setup) => crate::setup::run(),
None => match cli.repo {
Some(repo) => jump(&repo, config),
None => {
let _ = <Cli as clap::CommandFactory>::command().print_help();
eprintln!();
}
},
}
}
fn jump(query: &str, config: &Config) {
let resolved = config
.aliases
.get(query)
.map(|s| s.as_str())
.unwrap_or(query);
if let Ok(normalized) = crate::path::normalize(resolved) {
if Path::new(&normalized).is_dir() {
println!("cd://{normalized}");
return;
}
}
let opts = search::for_repo(config, resolved);
let mut found = search::repositories(&opts, config);
if found.is_empty() {
eprintln!("No repositories found for \"{query}\".");
return;
}
search::sort_by_pins(&mut found);
println!("cd://{}", found[0].path);
}
fn list_repos(config: &Config, query: &str) {
let opts = search::for_repo(config, query);
let all_repos = search::repositories(&opts, config);
let mut pinned: Vec<_> = all_repos.iter().filter(|r| r.pinned).collect();
if !pinned.is_empty() {
pinned.sort_by_key(|r| r.pin_index);
eprintln!("pinned repositories:");
for r in &pinned {
eprintln!("- {} ({})", r.name, r.current_branch);
}
eprintln!();
}
for folder in config::get_search_folders(config) {
eprintln!("{folder}/");
let folder_opts = search::in_folders(vec![folder], query);
let repos = search::repositories(&folder_opts, config);
for r in &repos {
if r.pinned {
eprintln!(" {} ({}) [pinned]", r.name, r.current_branch);
} else {
eprintln!(" {} ({})", r.name, r.current_branch);
}
}
}
}
fn list_branches(config: &Config, merged_only: bool, no_merged_only: bool) {
let cwd = match env::current_dir() {
Ok(d) => d.to_string_lossy().to_string(),
Err(e) => {
eprintln!("ERROR Could not determine current directory: {e}");
return;
}
};
let current_repo = match search::find_repo_for_path(&cwd, config) {
Some(r) => r,
None => {
eprintln!("Not in a git repository.");
return;
}
};
let global_pins = &config.pins.branches.global;
let repo_pins = config
.pins
.branches
.repositories
.get(¤t_repo.identifier)
.cloned()
.unwrap_or_default();
let branches = repo::list_branches(¤t_repo);
eprintln!("{}/", current_repo.name);
for branch in &branches {
let is_merged = repo::is_merged(¤t_repo, branch, global_pins);
if merged_only && !is_merged {
continue;
}
if no_merged_only && is_merged {
continue;
}
let prefix = if branch == ¤t_repo.current_branch {
"* "
} else {
" "
};
let pin_status = if global_pins.contains(branch) {
" [protected]"
} else if repo_pins.contains(branch) {
" [pinned]"
} else {
""
};
let mut indicators = Vec::new();
if is_merged && !global_pins.contains(branch) {
indicators.push("merged");
}
if repo::is_stale(¤t_repo, branch) {
indicators.push("stale");
}
if repo::has_worktree(¤t_repo, branch) && branch != ¤t_repo.current_branch {
indicators.push("worktree");
}
let status = if indicators.is_empty() {
String::new()
} else {
format!(" ({})", indicators.join(", "))
};
eprintln!("{prefix}{branch}{status}{pin_status}");
}
}
fn pin_repo(config: &mut Config, target: Option<&str>) {
let repo_path = match resolve_target_or_cwd(target) {
Some(p) => p,
None => return,
};
config::add_pinned_repo(config, &repo_path);
}
fn pin_branch(config: &mut Config, target: Option<&str>) {
let cwd = match env::current_dir() {
Ok(d) => d.to_string_lossy().to_string(),
Err(e) => {
eprintln!("ERROR Could not determine current directory: {e}");
return;
}
};
let current_repo = match search::find_repo_for_path(&cwd, config) {
Some(r) => r,
None => {
eprintln!("Not in a git repository.");
return;
}
};
let branch_name = target
.map(|s| s.to_string())
.unwrap_or_else(|| current_repo.current_branch.clone());
let branches = repo::list_branches(¤t_repo);
if !branches.contains(&branch_name) {
eprintln!("Branch \"{branch_name}\" not found.");
return;
}
if config::is_branch_globally_pinned(config, &branch_name) {
eprintln!("Branch \"{branch_name}\" is already globally protected.");
return;
}
config::add_repo_pinned_branch(config, ¤t_repo.identifier, &branch_name);
}
fn unpin_repo(config: &mut Config, target: Option<&str>) {
let repo_path = match resolve_target_or_cwd(target) {
Some(p) => p,
None => return,
};
config::delete_pinned_repo(config, &repo_path);
}
fn unpin_branch(config: &mut Config, target: Option<&str>) {
let cwd = match env::current_dir() {
Ok(d) => d.to_string_lossy().to_string(),
Err(e) => {
eprintln!("ERROR Could not determine current directory: {e}");
return;
}
};
let current_repo = match search::find_repo_for_path(&cwd, config) {
Some(r) => r,
None => {
eprintln!("Not in a git repository.");
return;
}
};
let branch_name = target
.map(|s| s.to_string())
.unwrap_or_else(|| current_repo.current_branch.clone());
if config::is_branch_globally_pinned(config, &branch_name) {
eprintln!(
"Branch \"{branch_name}\" is globally protected. \
Use 'gimme config' to modify global settings."
);
return;
}
if !config::is_branch_pinned_for_repo(config, ¤t_repo.identifier, &branch_name) {
eprintln!("Branch \"{branch_name}\" is not pinned for this repo.");
return;
}
config::delete_repo_pinned_branch(config, ¤t_repo.identifier, &branch_name);
}
fn resolve_target_or_cwd(target: Option<&str>) -> Option<String> {
match target {
Some(p) => Some(p.to_string()),
None => match env::current_dir() {
Ok(d) => Some(d.to_string_lossy().to_string()),
Err(e) => {
eprintln!("ERROR Could not determine current directory: {e}");
None
}
},
}
}
fn clean_branches(config: &Config, all: bool, dry_run: bool, force: bool, verbose: bool) {
let cwd = match env::current_dir() {
Ok(d) => d.to_string_lossy().to_string(),
Err(e) => {
eprintln!("ERROR Could not determine current directory: {e}");
return;
}
};
let current_repo = match search::find_repo_for_path(&cwd, config) {
Some(r) => r,
None => {
eprintln!("Not in a git repository.");
return;
}
};
let global_pins = &config.pins.branches.global;
let repo_pins = config
.pins
.branches
.repositories
.get(¤t_repo.identifier)
.cloned()
.unwrap_or_default();
let branches = repo::list_branches(¤t_repo);
let (to_delete, skipped) = partition_branches_for_clean(
&branches,
¤t_repo,
global_pins,
&repo_pins,
all,
force,
);
if dry_run {
print_dry_run(&to_delete, &skipped);
return;
}
let deleted_count = execute_branch_deletes(¤t_repo, &to_delete, verbose);
print_delete_summary(deleted_count);
if !skipped.is_empty() && verbose {
print_skipped_worktrees(&skipped);
}
}
fn partition_branches_for_clean(
branches: &[String],
current_repo: &repo::Repo,
global_pins: &[String],
repo_pins: &[String],
all: bool,
force: bool,
) -> (Vec<String>, Vec<String>) {
let mut to_delete = Vec::new();
let mut skipped = Vec::new();
for branch in branches {
if branch == ¤t_repo.current_branch {
continue;
}
if global_pins.contains(branch) {
continue;
}
if repo_pins.contains(branch) && !force {
continue;
}
if repo::has_worktree(current_repo, branch) {
skipped.push(branch.clone());
continue;
}
if !all && !repo::is_merged(current_repo, branch, global_pins) {
continue;
}
to_delete.push(branch.clone());
}
(to_delete, skipped)
}
fn execute_branch_deletes(current_repo: &repo::Repo, to_delete: &[String], verbose: bool) -> usize {
let mut count = 0;
for branch in to_delete {
match repo::delete_branch(current_repo, branch) {
Ok(()) => {
count += 1;
if verbose {
eprintln!("Deleted branch \"{branch}\".");
}
}
Err(e) => {
eprintln!("WARN Failed to delete branch \"{branch}\": {e}");
}
}
}
count
}
fn print_dry_run(to_delete: &[String], skipped: &[String]) {
if to_delete.is_empty() {
eprintln!("No branches to delete.");
} else {
eprintln!("Would delete {} branches:", to_delete.len());
for branch in to_delete {
eprintln!(" {branch}");
}
}
if !skipped.is_empty() {
eprintln!();
print_skipped_worktrees(skipped);
}
}
fn print_delete_summary(count: usize) {
match count {
0 => eprintln!("No branches deleted."),
1 => eprintln!("Deleted 1 branch."),
n => eprintln!("Deleted {n} branches."),
}
}
fn print_skipped_worktrees(skipped: &[String]) {
eprintln!("Skipped {} branches with active worktrees:", skipped.len());
for branch in skipped {
eprintln!(" {branch}");
}
}
fn run_config(config: &mut Config, action: Option<ConfigAction>) {
match action {
None => show_all_config(config),
Some(ConfigAction::Add { what }) => match what {
ConfigAddTarget::Group { path } => config::add_group(config, &path),
ConfigAddTarget::Alias { short, expanded } => {
config::add_alias(config, &short, &expanded);
}
ConfigAddTarget::Protected { branch } => {
config::add_global_pinned_branch(config, &branch);
}
},
Some(ConfigAction::Delete { what }) => match what {
ConfigDeleteTarget::Group { target } => {
if let Ok(idx) = target.parse::<usize>() {
config::delete_group_by_index(config, idx);
} else {
config::delete_group(config, &target);
}
}
ConfigDeleteTarget::Alias { short } => config::delete_alias(config, &short),
ConfigDeleteTarget::Protected { branch } => {
config::delete_global_pinned_branch(config, &branch);
}
},
Some(ConfigAction::Ls { what }) => match what {
None => show_all_config(config),
Some(ConfigLsTarget::Group) => show_groups(config),
Some(ConfigLsTarget::Repo) => show_pinned_repos(config),
Some(ConfigLsTarget::Branch) => show_pinned_branches(config),
Some(ConfigLsTarget::Alias) => show_aliases(config),
},
}
}
fn show_all_config(config: &Config) {
show_groups(config);
eprintln!();
show_pinned_repos(config);
eprintln!();
show_pinned_branches(config);
eprintln!();
show_aliases(config);
}
fn show_groups(config: &Config) {
let groups = config::get_search_folders(config);
eprintln!("Search Groups:");
if groups.is_empty() {
eprintln!(" (none configured)");
return;
}
for (i, group) in groups.iter().enumerate() {
eprintln!(" [{i}] {group}");
}
}
fn show_pinned_repos(config: &Config) {
let repos = config::get_pinned_repos(config);
eprintln!("Pinned Repositories:");
if repos.is_empty() {
eprintln!(" (none configured)");
return;
}
for (i, r) in repos.iter().enumerate() {
eprintln!(" [{i}] {r}");
}
}
fn show_pinned_branches(config: &Config) {
eprintln!("Global Protected Branches:");
if config.pins.branches.global.is_empty() {
eprintln!(" (none configured)");
} else {
for branch in &config.pins.branches.global {
eprintln!(" - {branch}");
}
}
if !config.pins.branches.repositories.is_empty() {
eprintln!();
eprintln!("Pinned Branches (per-repo):");
for (repo_id, branches) in &config.pins.branches.repositories {
eprintln!(" {repo_id}:");
for branch in branches {
eprintln!(" - {branch}");
}
}
}
}
fn show_aliases(config: &Config) {
eprintln!("Aliases:");
if config.aliases.is_empty() {
eprintln!(" (none configured)");
return;
}
for (short, expanded) in &config.aliases {
eprintln!(" {short} -> {expanded}");
}
}