use crate::bootstrap::{self, BootstrapCtx};
use crate::clean;
use crate::config::Config;
use crate::config_cli;
use crate::doctor::{self, CheckStatus, DoctorCtx};
use crate::error::{GwmError, LinkKind, Result};
use crate::exec;
use crate::forge;
use crate::github::{self, BranchLink, IssueState, IssueStatus, LinkSource, PrState, PrStatus};
use crate::gitmoji;
use crate::history::{self, OpEntry};
use crate::hooks;
use crate::issue_templates;
use crate::json_api;
use crate::labels::{self, LabelDiff};
use crate::lifecycle::{self, HookContext, HookPhase, HookSkips};
use crate::milestones::{self, MilestoneDiff};
use crate::multiplexer::{
build_tmux_command, build_zellij_command, detect_tmux, detect_zellij, Multiplexer, SpawnMode,
};
use crate::naming::{BranchSpec, WorktreeName};
use crate::pr_templates::{self, PrTemplateContext};
use crate::presets;
use crate::review;
use crate::sync::{self, SyncAction, SyncReport, SyncStrategy};
use crate::trust::{self, TrustLedger, TrustMode, TrustOutcome};
use crate::workspace;
use crate::worktree;
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use clap_complete::{generate, Shell};
use git2::Repository;
use std::io;
use std::path::{Path, PathBuf};
#[derive(Debug, Parser)]
#[command(name = "gwm", version, about = "git worktree manager (TUI + CLI)")]
pub struct Cli {
#[arg(long, global = true)]
pub allow_bootstrap: bool,
#[arg(long, global = true, conflicts_with = "allow_bootstrap")]
pub deny_bootstrap: bool,
#[arg(long, global = true, value_name = "DIR")]
pub workspace: Option<PathBuf>,
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Debug, Clone, clap::Subcommand)]
pub enum AgentsAction {
Attach {
pattern: String,
session_id: String,
},
Detach {
pattern: String,
session_id: Option<String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum AgentsFormat {
Table,
Json,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ListFormat {
Table,
Names,
Json,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputFormat {
Text,
Json,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum InitShell {
Bash,
Zsh,
Fish,
Powershell,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum LinkTarget {
Issue,
Pr,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Init {
#[arg(long, value_name = "NAME")]
preset: Option<String>,
#[arg(long)]
list_presets: bool,
#[arg(long)]
show: bool,
},
List {
#[arg(long, value_enum, default_value_t = ListFormat::Table)]
format: ListFormat,
#[arg(long)]
detect_pr: bool,
},
Agents {
#[command(subcommand)]
action: Option<AgentsAction>,
#[arg(long, value_enum, default_value_t = AgentsFormat::Table)]
format: AgentsFormat,
},
Create {
#[arg(required_unless_present = "name", conflicts_with = "name")]
branch_type: Option<String>,
#[arg(required_unless_present = "name", conflicts_with = "name")]
issue: Option<String>,
#[arg(required_unless_present = "name", conflicts_with = "name")]
desc: Option<String>,
#[arg(long, value_name = "NAME", conflicts_with_all = ["branch_type", "issue", "desc"])]
name: Option<String>,
#[arg(long)]
no_bootstrap: bool,
#[arg(long)]
reuse_branch: bool,
#[arg(long, value_name = "PHASES")]
skip_hooks: Option<String>,
#[arg(long, value_name = "NAME")]
repo: Option<String>,
},
Pr {
#[arg(long)]
render: bool,
#[arg(long, conflicts_with = "render")]
draft: bool,
#[arg(long, value_name = "REF")]
base: Option<String>,
},
Review {
#[arg()]
number: u64,
#[arg(long, value_name = "BRANCH")]
name: Option<String>,
#[arg(long)]
bootstrap: bool,
#[arg(long, value_name = "PHASES")]
skip_hooks: Option<String>,
},
New {
#[arg()]
branch_type: String,
#[arg()]
desc: String,
#[arg(long)]
no_bootstrap: bool,
#[arg(long)]
reuse_branch: bool,
#[arg(long, value_name = "PHASES")]
skip_hooks: Option<String>,
},
Remove {
pattern: String,
#[arg(long)]
delete_branch: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
force: bool,
#[arg(long, value_name = "PHASES")]
skip_hooks: Option<String>,
},
#[command(visible_alias = "cd")]
Path {
pattern: String,
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
},
Bootstrap {
target: Option<String>,
#[arg(long, value_name = "PHASES")]
skip_hooks: Option<String>,
},
Sync {
pattern: Option<String>,
#[arg(long)]
merge: bool,
},
Prune {
#[arg(long)]
dry_run: bool,
},
Doctor {
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
},
Daemon {
#[arg(long, value_name = "PATH")]
socket: Option<PathBuf>,
#[arg(long, value_name = "MS", default_value_t = 1000, value_parser = clap::value_parser!(u64).range(1..))]
poll_ms: u64,
},
Statusline {
#[arg(long, value_name = "PATH")]
socket: Option<PathBuf>,
#[arg(long)]
watch: bool,
},
Types {
#[arg(long)]
gitmoji: bool,
},
CommitPrefix {
#[arg(long)]
branch: Option<String>,
#[arg(long)]
unicode: bool,
},
Hooks {
#[command(subcommand)]
action: HooksAction,
},
Completions {
#[arg(value_enum)]
shell: Shell,
},
ShellInit {
#[arg(value_enum)]
shell: InitShell,
},
#[command(visible_alias = "s")]
Switch,
Tmux {
pattern: String,
#[arg(short = 'p', long = "split")]
split: bool,
},
Zellij {
pattern: String,
#[arg(short = 'p', long = "split")]
split: bool,
},
Link {
#[arg(value_enum)]
target: LinkTarget,
number: u64,
#[arg(long)]
worktree: Option<String>,
},
Unlink {
#[arg(value_enum)]
target: LinkTarget,
#[arg(long)]
worktree: Option<String>,
},
Open {
#[arg(value_enum)]
target: LinkTarget,
#[arg(long)]
worktree: Option<String>,
#[arg(long)]
print_url: bool,
},
Status {
#[arg(long)]
worktree: Option<String>,
#[arg(long)]
json: bool,
},
Labels {
#[command(subcommand)]
action: LabelsAction,
},
Milestones {
#[command(subcommand)]
action: MilestonesAction,
},
Trust {
#[command(subcommand)]
action: TrustAction,
},
Aliases {
#[command(subcommand)]
action: AliasesAction,
},
Config {
#[command(subcommand)]
action: ConfigAction,
},
History {
#[arg(long, default_value_t = 20)]
limit: usize,
#[arg(long)]
all: bool,
},
Undo {
#[arg(long)]
bootstrap: bool,
},
Tui {
#[command(subcommand)]
action: TuiAction,
},
Theme {
#[command(subcommand)]
action: ThemeAction,
},
Exec {
#[arg(value_name = "SLUG")]
slugs: Vec<String>,
#[arg(long, value_name = "NAME")]
profile: Option<String>,
#[arg(long, value_name = "N")]
jobs: Option<u32>,
#[arg(last = true, allow_hyphen_values = true, value_name = "CMD")]
command: Vec<String>,
},
Clean {
#[arg(value_name = "SLUG")]
slugs: Vec<String>,
#[arg(long, value_name = "NAME")]
profile: Option<String>,
#[arg(long)]
yes: bool,
},
}
#[derive(Debug, Subcommand)]
pub enum ThemeAction {
List,
Show {
name: String,
},
}
#[derive(Debug, Subcommand)]
pub enum TuiAction {
Keys,
}
#[derive(Debug, Subcommand)]
pub enum AliasesAction {
List,
}
#[derive(Debug, Subcommand)]
pub enum ConfigAction {
Get {
key: String,
},
Set {
key: String,
value: Option<String>,
},
Unset {
key: String,
},
List {
#[arg(long)]
prefix: Option<String>,
},
Validate,
Path,
Edit,
}
#[derive(Debug, Subcommand)]
pub enum HooksAction {
Install {
#[arg(value_enum)]
hook: HookKind,
#[arg(long)]
force: bool,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum HookKind {
CommitMsg,
}
#[derive(Debug, Subcommand)]
pub enum LabelsAction {
List,
Push {
#[arg(long)]
dry_run: bool,
#[arg(long)]
prune: bool,
#[arg(long)]
random_colors: bool,
},
}
#[derive(Debug, Subcommand)]
pub enum TrustAction {
Add,
List,
Revoke {
origin: String,
},
Show,
}
#[derive(Debug, Subcommand)]
pub enum MilestonesAction {
List,
Push {
#[arg(long)]
dry_run: bool,
#[arg(long)]
prune: bool,
},
}
pub fn run(cli: Cli) -> Result<()> {
let mode = trust::resolve_mode(cli.allow_bootstrap, cli.deny_bootstrap);
let Some(cmd) = cli.command else {
if let Some(root) = cli.workspace {
return crate::tui::run_workspace(&root, mode);
}
if let Some(root) = autodetect_workspace_prompt()? {
return crate::tui::run_workspace(&root, mode);
}
return crate::tui::run(mode);
};
if cli.workspace.is_some()
&& !matches!(
cmd,
Command::List { .. } | Command::Create { .. } | Command::Exec { .. } | Command::Clean { .. }
)
{
return Err(GwmError::WorkspaceUnsupportedCommand);
}
match cmd {
Command::Init {
preset,
list_presets,
show,
} => cmd_init(preset, list_presets, show),
Command::List { format, detect_pr } => match cli.workspace {
Some(root) => cmd_list_workspace(&root, format, detect_pr),
None => cmd_list(format, detect_pr),
},
Command::Create {
branch_type,
issue,
desc,
name,
no_bootstrap,
reuse_branch,
skip_hooks,
repo,
} => {
let start = match &cli.workspace {
Some(root) => Some(resolve_workspace_create_repo(root, repo)?),
None => None,
};
cmd_create(
branch_type,
issue,
desc,
name,
no_bootstrap,
reuse_branch,
skip_hooks,
mode,
start.as_deref(),
)
}
Command::New {
branch_type,
desc,
no_bootstrap,
reuse_branch,
skip_hooks,
} => cmd_new(branch_type, desc, no_bootstrap, reuse_branch, skip_hooks, mode),
Command::Pr { render, draft, base } => cmd_pr(render, draft, base),
Command::Review {
number,
name,
bootstrap,
skip_hooks,
} => cmd_review(number, name, bootstrap, skip_hooks, mode),
Command::Remove {
pattern,
delete_branch,
dry_run,
force,
skip_hooks,
} => cmd_remove(pattern, delete_branch, dry_run, force, skip_hooks, mode),
Command::Path { pattern, format } => cmd_path(pattern, format),
Command::Bootstrap { target, skip_hooks } => cmd_bootstrap(target, skip_hooks, mode),
Command::Sync { pattern, merge } => cmd_sync(pattern, merge),
Command::Prune { dry_run } => cmd_prune(dry_run),
Command::Agents { action, format } => cmd_agents(action, format),
Command::Doctor { format } => cmd_doctor(format),
Command::Daemon { socket, poll_ms } => cmd_daemon(socket, poll_ms),
Command::Statusline { socket, watch } => cmd_statusline(socket, watch),
Command::Types { gitmoji } => cmd_types(gitmoji),
Command::CommitPrefix { branch, unicode } => cmd_commit_prefix(branch, unicode),
Command::Hooks { action } => cmd_hooks(action),
Command::Completions { shell } => cmd_completions(shell),
Command::ShellInit { shell } => cmd_shell_init(shell),
Command::Switch => cmd_switch(),
Command::Tmux { pattern, split } => cmd_multiplexer(Multiplexer::Tmux, pattern, split),
Command::Zellij { pattern, split } => cmd_multiplexer(Multiplexer::Zellij, pattern, split),
Command::Link {
target,
number,
worktree,
} => cmd_link(target, number, worktree),
Command::Unlink { target, worktree } => cmd_unlink(target, worktree),
Command::Open {
target,
worktree,
print_url,
} => cmd_open(target, worktree, print_url),
Command::Status { worktree, json } => cmd_status(worktree, json),
Command::Labels { action } => cmd_labels(action),
Command::Milestones { action } => cmd_milestones(action),
Command::Trust { action } => cmd_trust(action),
Command::Aliases { action } => cmd_aliases(action),
Command::Config { action } => cmd_config(action),
Command::History { limit, all } => cmd_history(limit, all),
Command::Undo { bootstrap } => cmd_undo(bootstrap, mode),
Command::Tui { action } => cmd_tui(action),
Command::Theme { action } => cmd_theme(action),
Command::Exec {
slugs,
profile,
jobs,
command,
} => match cli.workspace {
Some(root) => cmd_exec_workspace(&root, slugs, profile, jobs, command),
None => cmd_exec(slugs, profile, jobs, command),
},
Command::Clean { slugs, profile, yes } => match cli.workspace {
Some(root) => cmd_clean_workspace(&root, slugs, profile, yes),
None => cmd_clean(slugs, profile, yes),
},
}
}
fn resolve_targets(repo: &Repository, slugs: &[String]) -> Result<Vec<worktree::WorktreeInfo>> {
if slugs.is_empty() {
Ok(worktree::list(repo)?.into_iter().filter(|w| !w.is_main).collect())
} else {
slugs.iter().map(|s| worktree::find_fuzzy(repo, s)).collect()
}
}
fn cmd_exec(slugs: Vec<String>, profile: Option<String>, jobs: Option<u32>, command: Vec<String>) -> Result<()> {
let repo = worktree::discover_repo(None)?;
let (argv, job_count) = exec_plan(&repo, profile.as_deref(), jobs, &command)?;
let targets = resolve_targets(&repo, &slugs)?;
if targets.is_empty() {
println!("no worktrees to run in");
return Ok(());
}
let outcomes = exec_run(&targets, &argv, job_count, None)?;
print_exec_rollup_and_exit(&outcomes)
}
fn cmd_exec_workspace(
root: &Path,
slugs: Vec<String>,
profile: Option<String>,
jobs: Option<u32>,
command: Vec<String>,
) -> Result<()> {
let opened = open_workspace_repos(root)?;
let repos: Vec<&Repository> = opened.iter().map(|(_, r)| r).collect();
let targets_per_repo = resolve_workspace_targets(&repos, &slugs)?;
let mut plans: Vec<(&str, &Vec<worktree::WorktreeInfo>, Vec<String>, usize)> = Vec::new();
for ((name, repo), targets) in opened.iter().zip(&targets_per_repo) {
if targets.is_empty() {
continue;
}
let (argv, job_count) = exec_plan(repo, profile.as_deref(), jobs, &command)?;
plans.push((name, targets, argv, job_count));
}
if plans.is_empty() {
let opened_repos = opened.iter().map(|(_, r)| r);
if let Some(err) = first_exec_plan_error(opened_repos, profile.as_deref(), jobs, &command) {
return Err(err);
}
println!("no worktrees to run in");
return Ok(());
}
let mut all = Vec::new();
for (name, targets, argv, job_count) in &plans {
println!("\n══ {}", name);
all.extend(exec_run(targets, argv, *job_count, Some(name))?);
}
print_exec_rollup_and_exit(&all)
}
fn first_exec_plan_error<'a>(
repos: impl Iterator<Item = &'a Repository>,
profile: Option<&str>,
jobs: Option<u32>,
command: &[String],
) -> Option<GwmError> {
let mut last = None;
for repo in repos {
match exec_plan(repo, profile, jobs, command) {
Ok(_) => return None,
Err(e) => last = Some(e),
}
}
last
}
fn open_workspace_repos(root: &Path) -> Result<Vec<(String, Repository)>> {
for entry in std::fs::read_dir(root)? {
let path = entry?.path();
if path.is_dir() && path.join(".git").try_exists()? && Repository::open(&path).is_err() {
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
return Err(GwmError::Other(format!(
"workspace: child repo `{name}` has a `.git` but cannot be opened (corrupt or unreadable)"
)));
}
}
let ws = workspace::discover(root)?;
if ws.is_empty() {
return Err(GwmError::EmptyWorkspace {
root: root.display().to_string(),
});
}
ws.repos
.iter()
.map(|r| {
Repository::open(&r.path)
.map(|repo| (r.name.clone(), repo))
.map_err(|e| GwmError::Other(format!("workspace: cannot open repo `{}`: {e}", r.name)))
})
.collect()
}
fn exec_plan(
repo: &Repository,
profile: Option<&str>,
jobs: Option<u32>,
command: &[String],
) -> Result<(Vec<String>, usize)> {
let exec_cfg = if profile.is_some() {
let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?;
Config::load_exec_config(workdir)?
} else if jobs.is_none() {
crate::config::ExecConfig {
jobs: Config::load_exec_jobs_default(repo.workdir())?,
..Default::default()
}
} else {
crate::config::ExecConfig::default()
};
let argv = exec::resolve_exec_command(profile, command, &exec_cfg)?;
let job_count = exec::resolve_jobs(jobs, profile, &exec_cfg);
Ok((argv, job_count))
}
fn exec_run(
targets: &[worktree::WorktreeInfo],
argv: &[String],
job_count: usize,
tag: Option<&str>,
) -> Result<Vec<exec::ExecOutcome>> {
let (program, args) = argv
.split_first()
.ok_or_else(|| GwmError::Other("exec: no command resolved".into()))?;
let args = args.to_vec();
let display = |name: &str| match tag {
Some(t) => format!("{t}/{name}"),
None => name.to_string(),
};
let mut outcomes = Vec::with_capacity(targets.len());
if job_count <= 1 {
for w in targets {
println!("\n━━ {} ({})", w.name, w.path.display());
let status = exec::exec_in_dir(&w.path, program, &args);
outcomes.push(exec::ExecOutcome {
name: display(&w.name),
status,
});
}
} else {
use std::io::Write;
let items: Vec<(String, std::path::PathBuf)> = targets.iter().map(|w| (w.name.clone(), w.path.clone())).collect();
let results = exec::run_in_dirs_parallel(job_count, &items, program, &args);
let stdout = std::io::stdout();
let mut lock = stdout.lock();
for ((name, path), (outcome, output)) in items.iter().zip(results) {
let _ = writeln!(lock, "\n━━ {} ({})", name, path.display());
let _ = lock.write_all(&output);
outcomes.push(exec::ExecOutcome {
name: display(name),
status: outcome.status,
});
}
let _ = lock.flush();
}
Ok(outcomes)
}
fn print_exec_rollup_and_exit(outcomes: &[exec::ExecOutcome]) -> Result<()> {
println!("\nrollup:");
for o in outcomes {
println!(" {}", exec::format_outcome(o));
}
let code = exec::rollup_exit_code(outcomes);
if code != 0 {
std::process::exit(code);
}
Ok(())
}
fn resolve_workspace_targets(repos: &[&Repository], slugs: &[String]) -> Result<Vec<Vec<worktree::WorktreeInfo>>> {
if slugs.is_empty() {
return repos
.iter()
.map(|repo| Ok(worktree::list(repo)?.into_iter().filter(|w| !w.is_main).collect()))
.collect();
}
let mut per_repo: Vec<Vec<worktree::WorktreeInfo>> = (0..repos.len()).map(|_| Vec::new()).collect();
let mut matched = vec![false; slugs.len()];
for (ri, repo) in repos.iter().enumerate() {
for (si, slug) in slugs.iter().enumerate() {
match worktree::find_fuzzy(repo, slug) {
Ok(wt) => {
per_repo[ri].push(wt);
matched[si] = true;
}
Err(GwmError::WorktreeNotFound(_)) => {}
Err(e) => return Err(e),
}
}
}
if let Some(si) = matched.iter().position(|m| !m) {
return Err(GwmError::WorktreeNotFound(format!(
"{} (no worktree matches it in any workspace repo)",
slugs[si]
)));
}
Ok(per_repo)
}
fn cmd_clean(slugs: Vec<String>, profile: Option<String>, yes: bool) -> Result<()> {
let repo = worktree::discover_repo(None)?;
let targets = resolve_targets(&repo, &slugs)?;
let (reclaims, skipped) = clean_scan_repo(&repo, &targets, profile.as_deref(), None)?;
if targets.is_empty() {
println!("no worktrees to clean");
return Ok(());
}
clean_finish(&reclaims, &skipped, yes)
}
fn cmd_clean_workspace(root: &Path, slugs: Vec<String>, profile: Option<String>, yes: bool) -> Result<()> {
let opened = open_workspace_repos(root)?;
let repos: Vec<&Repository> = opened.iter().map(|(_, r)| r).collect();
let targets_per_repo = resolve_workspace_targets(&repos, &slugs)?;
let mut reclaims: Vec<clean::WorktreeReclaim> = Vec::new();
let mut skipped: Vec<(String, String)> = Vec::new();
let mut participated = false;
for ((name, repo), targets) in opened.iter().zip(&targets_per_repo) {
if targets.is_empty() {
continue;
}
participated = true;
let (mut rec, mut skip) = clean_scan_repo(repo, targets, profile.as_deref(), Some(name))?;
reclaims.append(&mut rec);
skipped.append(&mut skip);
}
if !participated {
let mut last_err = None;
let mut valid = false;
for (_, repo) in &opened {
match clean_scan_repo(repo, &[], profile.as_deref(), None) {
Ok(_) => {
valid = true;
break;
}
Err(e) => last_err = Some(e),
}
}
if !valid {
return Err(last_err.unwrap_or_else(|| GwmError::EmptyWorkspace {
root: root.display().to_string(),
}));
}
}
clean_finish(&reclaims, &skipped, yes)
}
type CleanScan = (Vec<clean::WorktreeReclaim>, Vec<(String, String)>);
fn clean_scan_repo(
repo: &Repository,
targets: &[worktree::WorktreeInfo],
profile: Option<&str>,
tag: Option<&str>,
) -> Result<CleanScan> {
let clean_cfg = Config::load_clean_config(repo.workdir())?;
let patterns = clean::resolve_clean_dirs(profile, &clean_cfg)?;
let display = |name: &str| match tag {
Some(t) => format!("{t}/{name}"),
None => name.to_string(),
};
let mut reclaims = Vec::with_capacity(targets.len());
let mut skipped = Vec::new();
for w in targets {
let (mut reclaim, skips) = clean::scan_worktree_safe(&w.name, &w.path, &patterns);
reclaim.name = display(&w.name);
for rel in skips {
skipped.push((display(&w.name), rel));
}
reclaims.push(reclaim);
}
Ok((reclaims, skipped))
}
fn clean_finish(reclaims: &[clean::WorktreeReclaim], skipped: &[(String, String)], yes: bool) -> Result<()> {
print!("{}", clean::format_report(reclaims));
for (name, rel) in skipped {
println!("skipped {}/{}: not git-ignored, or holds tracked files", name, rel);
}
let grand: u64 = reclaims.iter().map(|r| r.total_bytes).sum();
if grand == 0 {
println!("nothing to reclaim");
return Ok(());
}
if !yes {
println!("re-run with --yes to delete the listed artifacts");
return Ok(());
}
let mut freed = 0u64;
let mut failures = 0usize;
for r in reclaims {
match clean::delete_reclaim(r) {
Ok(b) => freed = freed.saturating_add(b),
Err(e) => {
eprintln!("failed to reclaim {}: {e}", r.name);
failures += 1;
}
}
}
println!("reclaimed {}", clean::human_size(freed));
if failures > 0 {
std::process::exit(1);
}
Ok(())
}
fn autodetect_workspace_prompt() -> Result<Option<PathBuf>> {
use std::io::{IsTerminal, Write};
let cwd = std::env::current_dir()?;
let Some(ws) = workspace::autodetect(&cwd) else {
return Ok(None);
};
if !io::stdin().is_terminal() {
return Ok(None);
}
eprint!(
"No git repo here. Open {} as a workspace ({} repos)? [Y/n] ",
cwd.display(),
ws.repos.len()
);
io::stderr().flush().ok();
let mut answer = String::new();
io::stdin().read_line(&mut answer)?;
let a = answer.trim().to_ascii_lowercase();
if a.is_empty() || a == "y" || a == "yes" {
Ok(Some(ws.root))
} else {
Ok(None)
}
}
fn cmd_tui(action: TuiAction) -> Result<()> {
match action {
TuiAction::Keys => cmd_tui_keys(),
}
}
fn cmd_theme(action: ThemeAction) -> Result<()> {
match action {
ThemeAction::List => cmd_theme_list(),
ThemeAction::Show { name } => cmd_theme_show(&name),
}
}
fn cmd_theme_list() -> Result<()> {
for name in crate::tui::theme::preset_names() {
println!("{}", name);
}
Ok(())
}
fn cmd_theme_show(name: &str) -> Result<()> {
use crate::tui::theme::Theme;
use ratatui::style::Color;
let theme = Theme::preset(name).ok_or_else(|| {
let known = crate::tui::theme::preset_names().join(", ");
GwmError::Other(format!("theme show: unknown preset {:?} (known: {})", name, known))
})?;
let color_str = |c: Color| -> String {
match c {
Color::Rgb(r, g, b) => format!("\"#{:02x}{:02x}{:02x}\"", r, g, b),
Color::Reset => "\"reset\"".to_string(),
Color::Black => "\"black\"".to_string(),
Color::Red => "\"red\"".to_string(),
Color::Green => "\"green\"".to_string(),
Color::Yellow => "\"yellow\"".to_string(),
Color::Blue => "\"blue\"".to_string(),
Color::Magenta => "\"magenta\"".to_string(),
Color::Cyan => "\"cyan\"".to_string(),
Color::Gray => "\"gray\"".to_string(),
Color::DarkGray => "\"dark_gray\"".to_string(),
Color::LightRed => "\"bright_red\"".to_string(),
Color::LightGreen => "\"bright_green\"".to_string(),
Color::LightYellow => "\"bright_yellow\"".to_string(),
Color::LightBlue => "\"bright_blue\"".to_string(),
Color::LightMagenta => "\"bright_magenta\"".to_string(),
Color::LightCyan => "\"bright_cyan\"".to_string(),
Color::White => "\"white\"".to_string(),
Color::Indexed(n) => format!("\"{}\"", n),
}
};
println!("[theme]");
println!("preset = {:?}", name);
println!("focus = {}", color_str(theme.focus));
println!("accent = {}", color_str(theme.accent));
println!("branch = {}", color_str(theme.branch));
println!("clean = {}", color_str(theme.clean));
println!("dirty = {}", color_str(theme.dirty));
println!("main = {}", color_str(theme.main));
println!("locked = {}", color_str(theme.locked));
println!("prunable = {}", color_str(theme.prunable));
println!("muted = {}", color_str(theme.muted));
println!("selection_bg = {}", color_str(theme.selection_bg));
println!("name = {}", color_str(theme.name));
println!("path = {}", color_str(theme.path));
println!("staged = {}", color_str(theme.staged));
println!("modified = {}", color_str(theme.modified));
println!("untracked = {}", color_str(theme.untracked));
Ok(())
}
fn cmd_tui_keys() -> Result<()> {
use crate::tui::keymap::{Keymap, Source};
use crate::tui::modal_keymap::{KeyContext, ModalKeymap};
let (keymap, modal) = match worktree::discover_repo(None) {
Ok(repo) => match repo.workdir() {
Some(workdir) => {
let cfg = Config::load_for_repo(workdir)?;
(cfg.tui.keys.resolved_keymap()?, cfg.tui.keys.resolved_modal_keymap()?)
}
None => (Keymap::defaults(), ModalKeymap::defaults()),
},
Err(_) => (Keymap::defaults(), ModalKeymap::defaults()),
};
let rows = keymap.list();
let action_w = rows
.iter()
.map(|b| b.action.slug().len())
.max()
.unwrap_or(0)
.max("action".len());
let keys_w = rows
.iter()
.map(|b| {
b.chords
.iter()
.map(|c| c.iter().map(|k| k.to_string()).collect::<Vec<_>>().join(" "))
.collect::<Vec<_>>()
.join(", ")
.len()
})
.max()
.unwrap_or(0)
.max("keys".len());
println!("{:<aw$} {:<kw$} source", "action", "keys", aw = action_w, kw = keys_w);
for binding in rows {
let keys = binding
.chords
.iter()
.map(|c| c.iter().map(|k| k.to_string()).collect::<Vec<_>>().join(" "))
.collect::<Vec<_>>()
.join(", ");
let source = match binding.source {
Source::Default => "default",
Source::UserConfig => ".gwm.toml",
};
println!(
"{:<aw$} {:<kw$} {}",
binding.action.slug(),
keys,
source,
aw = action_w,
kw = keys_w
);
}
let fmt_keys = |keys: &[crate::tui::keymap::KeyStroke]| -> String {
keys.iter().map(|k| k.to_string()).collect::<Vec<_>>().join(", ")
};
for ctx in KeyContext::all() {
let bindings = modal.bindings_for(*ctx);
if bindings.is_empty() {
continue;
}
println!("\n[tui.keys.modal.{}]", ctx.config_path());
let verb_w = bindings
.iter()
.map(|b| b.action.verb().len())
.max()
.unwrap_or(0)
.max("verb".len());
let keys_w = bindings
.iter()
.map(|b| fmt_keys(&b.keys).len())
.max()
.unwrap_or(0)
.max("keys".len());
println!("{:<vw$} {:<kw$} source", "verb", "keys", vw = verb_w, kw = keys_w);
for binding in bindings {
let source = match binding.source {
Source::Default => "default",
Source::UserConfig => ".gwm.toml",
};
println!(
"{:<vw$} {:<kw$} {}",
binding.action.verb(),
fmt_keys(&binding.keys),
source,
vw = verb_w,
kw = keys_w
);
}
}
Ok(())
}
fn cmd_init(preset: Option<String>, list_presets: bool, show: bool) -> Result<()> {
if list_presets {
let name_w = presets::all().iter().map(|p| p.name.len()).max().unwrap_or(0);
for p in presets::all() {
let aliases = if p.aliases.is_empty() {
String::new()
} else {
format!(" (alias: {})", p.aliases.join(", "))
};
println!(" {:<w$} {}{}", p.name, p.description, aliases, w = name_w);
}
return Ok(());
}
let name = preset.as_deref().unwrap_or("generic");
let resolved = presets::lookup(name).ok_or_else(|| {
GwmError::Config(format!(
"unknown preset {name:?} — run `gwm init --list-presets` to see the built-ins"
))
})?;
if show {
print!("{}", resolved.body);
return Ok(());
}
let repo = worktree::discover_repo(None)?;
let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?;
let path = Config::write_preset(workdir, resolved.body)?;
println!("wrote {} (preset: {})", path.display(), resolved.name);
Ok(())
}
fn cmd_agents(action: Option<AgentsAction>, format: AgentsFormat) -> Result<()> {
let repo = worktree::discover_repo(None)?;
let trees = worktree::list(&repo)?;
match action {
None => {
let mut rows: Vec<json_api::JsonWorktree> = trees.iter().map(json_api::JsonWorktree::from).collect();
let pins = json_api::agent_pins_for_rows(&repo, &trees);
let reals: Vec<PathBuf> = trees.iter().map(|w| w.path.clone()).collect();
if format == AgentsFormat::Json {
json_api::attach_agents(&mut rows, &reals, &pins);
println!("{}", serde_json::to_string_pretty(&rows)?);
return Ok(());
}
let pool = json_api::attach_agents_with_pool(&mut rows, &reals, &pins);
let pinned_pairs: std::collections::BTreeSet<(&str, &str)> =
pins.iter().map(|(path, sid)| (path.as_str(), sid.as_str())).collect();
let now_epoch = std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let mut any = false;
for (row, tree) in rows.iter().zip(&trees) {
let Some(agents) = &row.agents else {
continue;
};
any = true;
let row_key = crate::agent_sessions::path_display_key(&tree.path);
println!("{}", row.name);
for s in &agents.sessions {
let pin_mark = if pinned_pairs.contains(&(row_key.as_str(), s.id.as_str())) {
" pinned"
} else {
""
};
let ago = worktree::format_relative_duration(std::time::Duration::from_secs(
now_epoch.saturating_sub(s.last_activity),
));
let name_part = s.name.as_deref().map(|n| format!(" {n}")).unwrap_or_default();
println!(
" {:<9} {:<7} {:>4} ago {}{}{}",
s.kind, s.freshness, ago, s.id, name_part, pin_mark
);
}
}
let shown: std::collections::BTreeSet<&str> = rows
.iter()
.filter_map(|r| r.agents.as_ref())
.flat_map(|a| a.sessions.iter().map(|s| s.id.as_str()))
.collect();
let mut unmatched: Vec<&crate::agent_sessions::AgentSession> =
pool.iter().filter(|s| !shown.contains(s.id.as_str())).collect();
unmatched.sort_by_key(|s| (s.ended, std::cmp::Reverse(s.last_activity)));
if !unmatched.is_empty() {
any = true;
let now_sys = std::time::SystemTime::now();
println!("unmatched");
for s in &unmatched {
let word = match crate::agent_sessions::Freshness::classify(s.last_activity, s.ended, now_sys) {
crate::agent_sessions::Freshness::Active => "active",
crate::agent_sessions::Freshness::Idle => "idle",
};
let ago = worktree::format_relative_duration(now_sys.duration_since(s.last_activity).unwrap_or_default());
let name_part = s.name.as_deref().map(|n| format!(" {n}")).unwrap_or_default();
println!(
" {:<9} {:<7} {:>4} ago {}{}",
s.kind.display(),
word,
ago,
s.id,
name_part
);
}
}
if !any {
println!("no agent session found");
}
Ok(())
}
Some(AgentsAction::Attach { pattern, session_id }) => {
let target = resolve_agents_worktree(&trees, &pattern)?;
let Some(branch) = github::pinnable_branch(target.branch.as_deref()).map(str::to_string) else {
return Err(GwmError::Config(format!(
"worktree '{}' has no branch (detached HEAD) — a pin lives in branch config",
target.name
)));
};
let home = crate::agent_sessions::agents_home()
.ok_or_else(|| GwmError::Config("no home directory to scan for agent sessions".into()))?;
let key = crate::agent_sessions::path_display_key(&target.path);
let keyed: Vec<(String, PathBuf)> = trees
.iter()
.map(|w| (crate::agent_sessions::path_display_key(&w.path), w.path.clone()))
.collect();
let probe = [(key.clone(), session_id.clone())];
let map = crate::agent_sessions::detect_all(&home, &keyed, &probe, std::time::SystemTime::now());
let found = map
.get(&key)
.is_some_and(|a| a.sessions.iter().any(|s| s.id == session_id));
if !found {
return Err(GwmError::Config(format!(
"no agent session with id '{session_id}' — run `gwm agents` to see the detected ids"
)));
}
github::add_agent_pin(&repo, &branch, &session_id)?;
println!("pinned {session_id} to {}", target.name);
Ok(())
}
Some(AgentsAction::Detach { pattern, session_id }) => {
let target = resolve_agents_worktree(&trees, &pattern)?;
let Some(branch) = github::pinnable_branch(target.branch.as_deref()).map(str::to_string) else {
return Err(GwmError::Config(format!(
"worktree '{}' has no branch (detached HEAD) — nothing to detach",
target.name
)));
};
match session_id {
Some(sid) => {
if !github::remove_agent_pin(&repo, &branch, &sid)? {
return Err(GwmError::Config(format!(
"no pin '{sid}' on {} — run `gwm agents` to see the pinned ids",
target.name
)));
}
println!("detached {sid} from {}", target.name);
}
None => {
github::clear_agent_pins(&repo, &branch)?;
println!("detached every agent pin from {}", target.name);
}
}
Ok(())
}
}
}
fn resolve_agents_worktree(trees: &[worktree::WorktreeInfo], pattern: &str) -> Result<worktree::WorktreeInfo> {
if pattern == "." {
let cwd = std::env::current_dir()?;
let cwd = cwd.canonicalize().unwrap_or(cwd);
return trees
.iter()
.filter(|w| {
let wp = w.path.canonicalize().unwrap_or_else(|_| w.path.clone());
cwd.starts_with(&wp)
})
.max_by_key(|w| w.path.as_os_str().len())
.cloned()
.ok_or_else(|| GwmError::WorktreeNotFound(".".into()));
}
let exact: Vec<&worktree::WorktreeInfo> = trees.iter().filter(|w| w.name == pattern).collect();
match exact.as_slice() {
[one] => {
if let Some(by_id) = trees.iter().find(|w| w.id == pattern && w.id != one.id) {
return Err(GwmError::Other(format!(
"'{}' is ambiguous: the display name of '{}' and the id of '{}'; target one by its unique id",
pattern, one.id, by_id.id
)));
}
return Ok((*one).clone());
}
[] => {
if let Some(by_id) = trees.iter().find(|w| w.id == pattern) {
return Ok(by_id.clone());
}
}
many => {
let ids = many.iter().map(|w| w.id.as_str()).collect::<Vec<_>>().join(", ");
return Err(GwmError::Other(format!(
"name '{pattern}' is ambiguous ({} worktrees share it); target one by id: {ids}",
many.len()
)));
}
}
let pat = pattern.to_lowercase();
let matches: Vec<&worktree::WorktreeInfo> = trees.iter().filter(|w| w.name.to_lowercase().contains(&pat)).collect();
match matches.as_slice() {
[one] => Ok((*one).clone()),
[] => Err(GwmError::WorktreeNotFound(pattern.into())),
many => Err(GwmError::Config(format!(
"pattern '{pattern}' is ambiguous: {}",
many.iter().map(|w| w.name.as_str()).collect::<Vec<_>>().join(", ")
))),
}
}
fn cmd_list(format: ListFormat, detect_pr: bool) -> Result<()> {
let repo = worktree::discover_repo(None)?;
let trees = worktree::list(&repo)?;
if format == ListFormat::Names {
for w in trees.iter().filter(|w| !w.is_main) {
println!("{}", w.name);
}
return Ok(());
}
let detected_prs: Vec<Option<Option<u64>>> = if detect_pr {
let config = Config::load_for_repo(repo.workdir().unwrap_or_else(|| repo.path()))?;
match forge::resolve(&repo, &config).ok() {
None => vec![None; trees.len()],
Some(forge) => trees
.iter()
.map(|w| {
w.branch.as_deref().map(|branch| {
github::read_link_with_pr_detection(&repo, branch, forge.as_ref())
.ok()
.and_then(|l| l.pr)
})
})
.collect(),
}
} else {
Vec::new()
};
if format == ListFormat::Json {
let mut dto: Vec<json_api::JsonWorktree> = trees.iter().map(json_api::JsonWorktree::from).collect();
let pins = json_api::agent_pins_for_rows(&repo, &trees);
let reals: Vec<PathBuf> = trees.iter().map(|w| w.path.clone()).collect();
json_api::attach_agents(&mut dto, &reals, &pins);
if detect_pr {
for (d, outcome) in dto.iter_mut().zip(&detected_prs) {
if let Some(pr) = outcome {
d.pr = *pr;
}
}
}
println!("{}", serde_json::to_string_pretty(&dto)?);
return Ok(());
}
let name_w = trees.iter().map(|w| w.name.len()).max().unwrap_or(4).clamp(4, 40);
let branch_w = trees
.iter()
.map(|w| w.branch.as_deref().unwrap_or("-").len())
.max()
.unwrap_or(6)
.clamp(6, 40);
let status_w = 14;
let pr_w = 6;
let agent_w = 8;
let agent_cells: Vec<String> = {
let mut cells = vec!["-".to_string(); trees.len()];
if let Some(home) = crate::agent_sessions::agents_home() {
let keyed: Vec<(String, PathBuf)> = trees
.iter()
.map(|w| (crate::agent_sessions::path_display_key(&w.path), w.path.clone()))
.collect();
let pins: Vec<(String, String)> = trees
.iter()
.flat_map(|w| {
let pins = github::pinnable_branch(w.branch.as_deref())
.map(|b| github::agent_pins(&repo, b).unwrap_or_default())
.unwrap_or_default();
let path = crate::agent_sessions::path_display_key(&w.path);
pins.into_iter().map(move |sid| (path.clone(), sid))
})
.collect();
let map = crate::agent_sessions::detect_all(&home, &keyed, &pins, std::time::SystemTime::now());
for (i, w) in trees.iter().enumerate() {
if let Some(top) = map
.get(&crate::agent_sessions::path_display_key(&w.path))
.and_then(|a| a.top())
{
cells[i] = top.kind.display().to_string();
}
}
}
cells
};
let show_agent = agent_cells.iter().any(|c| c != "-");
let agent_col = |cell: &str| {
if show_agent {
format!("{cell:<agent_w$} ")
} else {
String::new()
}
};
if detect_pr {
println!(
" {:<nw$} {:<bw$} {:<sw$} {:<pw$} {}PATH",
"NAME",
"BRANCH",
"STATUS",
"PR",
agent_col("AGENT"),
nw = name_w,
bw = branch_w,
sw = status_w,
pw = pr_w,
);
} else {
println!(
" {:<nw$} {:<bw$} {:<sw$} {}PATH",
"NAME",
"BRANCH",
"STATUS",
agent_col("AGENT"),
nw = name_w,
bw = branch_w,
sw = status_w,
);
}
for (i, w) in trees.iter().enumerate() {
let mark = if w.is_main { "*" } else { " " };
let branch = w.branch.clone().unwrap_or_else(|| "-".into());
let status = format_status_text(w);
if detect_pr {
let pr = detected_prs.get(i).copied().flatten().flatten();
let pr_cell = pr.map(|n| format!("#{n}")).unwrap_or_else(|| "-".into());
println!(
"{} {:<nw$} {:<bw$} {:<sw$} {:<pw$} {}{}",
mark,
w.name,
branch,
status,
pr_cell,
agent_col(&agent_cells[i]),
w.path.display(),
nw = name_w,
bw = branch_w,
sw = status_w,
pw = pr_w,
);
} else {
println!(
"{} {:<nw$} {:<bw$} {:<sw$} {}{}",
mark,
w.name,
branch,
status,
agent_col(&agent_cells[i]),
w.path.display(),
nw = name_w,
bw = branch_w,
sw = status_w,
);
}
}
Ok(())
}
fn cmd_list_workspace(root: &Path, format: ListFormat, detect_pr: bool) -> Result<()> {
let ws = workspace::discover(root)?;
if ws.is_empty() {
return Err(GwmError::EmptyWorkspace {
root: root.display().to_string(),
});
}
let rows = workspace::merge_worktrees(&ws)?;
if format == ListFormat::Names {
for row in &rows {
println!("{}/{}", row.repo_name, row.info.name);
}
return Ok(());
}
let detected_prs: Vec<Option<Option<u64>>> = if detect_pr {
rows
.iter()
.map(|row| {
let repo = Repository::open(&row.repo_path).ok()?;
let branch = row.info.branch.as_deref()?;
let config = Config::load_for_repo(&row.repo_path).ok()?;
let forge = forge::resolve(&repo, &config).ok()?;
Some(
github::read_link_with_pr_detection(&repo, branch, forge.as_ref())
.ok()
.and_then(|l| l.pr),
)
})
.collect()
} else {
Vec::new()
};
let agent_pins: Vec<(String, String)> = rows
.iter()
.filter_map(|row| {
let repo = Repository::open(&row.repo_path).ok()?;
let branch = github::pinnable_branch(row.info.branch.as_deref())?;
let pins = github::agent_pins(&repo, branch).ok()?;
let path = crate::agent_sessions::path_display_key(&row.info.path);
Some(pins.into_iter().map(move |sid| (path.clone(), sid)).collect::<Vec<_>>())
})
.flatten()
.collect();
if format == ListFormat::Json {
#[derive(serde::Serialize)]
struct WorkspaceJsonWorktree<'a> {
repo: &'a str,
#[serde(flatten)]
worktree: json_api::JsonWorktree,
}
let mut worktree_rows: Vec<json_api::JsonWorktree> = rows
.iter()
.enumerate()
.map(|(i, row)| {
let mut worktree = json_api::JsonWorktree::from(&row.info);
if let Some(pr) = detected_prs.get(i).copied().flatten() {
worktree.pr = pr;
}
worktree
})
.collect();
let reals: Vec<PathBuf> = rows.iter().map(|r| r.info.path.clone()).collect();
json_api::attach_agents(&mut worktree_rows, &reals, &agent_pins);
let dto: Vec<WorkspaceJsonWorktree> = rows
.iter()
.zip(worktree_rows)
.map(|(row, worktree)| WorkspaceJsonWorktree {
repo: &row.repo_name,
worktree,
})
.collect();
println!("{}", serde_json::to_string_pretty(&dto)?);
return Ok(());
}
let repo_w = rows.iter().map(|r| r.repo_name.len()).max().unwrap_or(4).clamp(4, 30);
let agent_w = 8;
let agent_cells: Vec<String> = {
let mut cells = vec!["-".to_string(); rows.len()];
if let Some(home) = crate::agent_sessions::agents_home() {
let keyed: Vec<(String, PathBuf)> = rows
.iter()
.map(|r| {
(
crate::agent_sessions::path_display_key(&r.info.path),
r.info.path.clone(),
)
})
.collect();
let map = crate::agent_sessions::detect_all(&home, &keyed, &agent_pins, std::time::SystemTime::now());
for (i, r) in rows.iter().enumerate() {
if let Some(top) = map
.get(&crate::agent_sessions::path_display_key(&r.info.path))
.and_then(|a| a.top())
{
cells[i] = top.kind.display().to_string();
}
}
}
cells
};
let show_agent = agent_cells.iter().any(|c| c != "-");
let agent_col = |cell: &str| {
if show_agent {
format!("{cell:<agent_w$} ")
} else {
String::new()
}
};
let name_w = rows.iter().map(|r| r.info.name.len()).max().unwrap_or(4).clamp(4, 40);
let branch_w = rows
.iter()
.map(|r| r.info.branch.as_deref().unwrap_or("-").len())
.max()
.unwrap_or(6)
.clamp(6, 40);
let status_w = 14;
let pr_w = 6;
if detect_pr {
println!(
" {:<rw$} {:<nw$} {:<bw$} {:<sw$} {:<pw$} {}PATH",
"REPO",
"NAME",
"BRANCH",
"STATUS",
"PR",
agent_col("AGENT"),
rw = repo_w,
nw = name_w,
bw = branch_w,
sw = status_w,
pw = pr_w,
);
} else {
println!(
" {:<rw$} {:<nw$} {:<bw$} {:<sw$} {}PATH",
"REPO",
"NAME",
"BRANCH",
"STATUS",
agent_col("AGENT"),
rw = repo_w,
nw = name_w,
bw = branch_w,
sw = status_w,
);
}
for (i, row) in rows.iter().enumerate() {
let w = &row.info;
let mark = if w.is_main { "*" } else { " " };
let branch = w.branch.clone().unwrap_or_else(|| "-".into());
let status = format_status_text(w);
if detect_pr {
let pr = detected_prs.get(i).copied().flatten().flatten();
let pr_cell = pr.map(|n| format!("#{n}")).unwrap_or_else(|| "-".into());
println!(
"{} {:<rw$} {:<nw$} {:<bw$} {:<sw$} {:<pw$} {}{}",
mark,
row.repo_name,
w.name,
branch,
status,
pr_cell,
agent_col(&agent_cells[i]),
w.path.display(),
rw = repo_w,
nw = name_w,
bw = branch_w,
sw = status_w,
pw = pr_w,
);
} else {
println!(
"{} {:<rw$} {:<nw$} {:<bw$} {:<sw$} {}{}",
mark,
row.repo_name,
w.name,
branch,
status,
agent_col(&agent_cells[i]),
w.path.display(),
rw = repo_w,
nw = name_w,
bw = branch_w,
sw = status_w,
);
}
}
Ok(())
}
fn format_status_text(w: &worktree::WorktreeInfo) -> String {
if w.is_prunable {
return "prunable".into();
}
if w.is_locked {
return "locked".into();
}
let s = &w.status;
if s.unknown {
return "unknown".into();
}
let mut parts: Vec<String> = Vec::new();
if s.is_dirty {
parts.push("● dirty".into());
}
if s.has_upstream {
if s.ahead > 0 {
parts.push(format!("↑{}", s.ahead));
}
if s.behind > 0 {
parts.push(format!("↓{}", s.behind));
}
if !s.is_dirty && s.synced() {
parts.push("✓ synced".into());
}
} else if !s.is_dirty {
parts.push("clean".into());
}
parts.join(" ")
}
pub struct RepoContext {
pub repo: Repository,
pub workdir: PathBuf,
pub config: Config,
}
pub fn repo_context(start: Option<&Path>) -> Result<RepoContext> {
let repo = worktree::discover_repo(start)?;
let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
let config = Config::load_for_repo(&workdir)?;
Ok(RepoContext { repo, workdir, config })
}
pub fn repo_context_lenient(start: Option<&Path>) -> Result<RepoContext> {
let repo = worktree::discover_repo(start)?;
let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
let config = Config::load_for_repo(&workdir).unwrap_or_default();
Ok(RepoContext { repo, workdir, config })
}
fn resolve_workspace_create_repo(root: &Path, repo: Option<String>) -> Result<PathBuf> {
let ws = workspace::discover(root)?;
if ws.is_empty() {
return Err(GwmError::EmptyWorkspace {
root: root.display().to_string(),
});
}
let available = ws.repos.iter().map(|r| r.name.as_str()).collect::<Vec<_>>().join(", ");
let name = repo.ok_or_else(|| GwmError::WorkspaceRepoRequired {
available: available.clone(),
})?;
ws.repos
.iter()
.find(|r| r.name == name)
.map(|r| r.path.clone())
.ok_or(GwmError::WorkspaceRepoNotFound { name, available })
}
#[allow(clippy::too_many_arguments)]
fn cmd_create(
branch_type: Option<String>,
issue: Option<String>,
desc: Option<String>,
name: Option<String>,
no_bootstrap: bool,
reuse_branch: bool,
skip_hooks: Option<String>,
trust_mode: TrustMode,
start: Option<&Path>,
) -> Result<()> {
let RepoContext { repo, workdir, config } = repo_context(start)?;
let repo_name = worktree::repo_name(&repo);
let wt_name = match name {
Some(name) => WorktreeName::freeform(&name)?,
None => {
let resolved_types = config.resolved_branch_types();
let (branch_type, issue, desc) = match (branch_type, issue, desc) {
(Some(t), Some(i), Some(d)) => (t, i, d),
_ => {
return Err(GwmError::Other(
"`gwm create` needs <TYPE> <ISSUE> <DESC> or --name".into(),
))
}
};
WorktreeName::Structured(BranchSpec::new_with_types(
branch_type,
issue,
desc,
&resolved_types.types,
)?)
}
};
let branch = wt_name.branch_name(&config.worktree, &repo_name)?;
let dirname = wt_name.worktree_dirname(&config.worktree, &repo_name)?;
let target = wt_name.worktree_path(&config.worktree, &repo_name, &workdir)?;
let skips = HookSkips::parse(skip_hooks.as_deref())?;
let create_hooks_present = !config.hooks.pre_create.is_empty()
|| !config.hooks.post_create.is_empty()
|| (!no_bootstrap && !config.bootstrap.command.is_empty());
if !no_bootstrap || create_hooks_present {
trust_or_prompt(&workdir, Some(&repo), trust_mode)?;
}
let pre_ctx = match &wt_name {
WorktreeName::Structured(spec) => HookContext::for_create(&repo, &workdir, &workdir, &target, &branch, spec),
WorktreeName::Freeform(_) => HookContext::for_worktree(&repo, &workdir, &workdir, &target, Some(&branch)),
};
let report = lifecycle::run_phase(&config, HookPhase::PreCreate, &pre_ctx, &skips, false)?;
print_lifecycle_report(&report);
println!("creating worktree:");
println!(" branch : {}", branch);
println!(" dir : {}", dirname);
println!(" path : {}", target.display());
let created = worktree::add(&repo, &dirname, &target, &branch, reuse_branch)?;
println!("✓ worktree created at {}", created.display());
let post_ctx = pre_ctx.with_cwd(&created);
if no_bootstrap {
println!("(skipped bootstrap)");
} else {
let report = lifecycle::run_phase(&config, HookPhase::PreBootstrap, &post_ctx, &skips, false)?;
print_lifecycle_report(&report);
let ctx = BootstrapCtx {
main_repo: &workdir,
worktree: &created,
config: &config,
};
let report = bootstrap::run_core(&ctx)?;
print_report(&report);
let report = lifecycle::run_phase(&config, HookPhase::PostBootstrap, &post_ctx, &skips, false)?;
print_lifecycle_report(&report);
}
if config.hooks.has_any() && !config.bootstrap.command.is_empty() {
eprintln!("warning: [[bootstrap.command]] is deprecated as a post_create hook when [hooks.*] is present");
}
let report = lifecycle::run_phase(&config, HookPhase::PostCreate, &post_ctx, &skips, !no_bootstrap)?;
print_lifecycle_report(&report);
Ok(())
}
fn cmd_review(
number: u64,
name: Option<String>,
bootstrap: bool,
skip_hooks: Option<String>,
trust_mode: TrustMode,
) -> Result<()> {
let RepoContext { repo, workdir, config } = repo_context(None)?;
let repo_name = worktree::repo_name(&repo);
let forge = forge::resolve(&repo, &config)?;
println!("resolving {} #{number} on {} …", forge.pr_noun(), forge.slug());
let head = forge.fetch_pr_head(number)?;
let slug = review::head_slug(&head.head_ref_name);
let branch = name
.clone()
.unwrap_or_else(|| review::review_branch_name(number, &head.author, &slug));
let dirname = match &name {
Some(n) => review::dirname_from_branch(n),
None => review::review_dirname(number, &head.author, &slug),
};
let base = crate::config::expand_placeholders(
&config.worktree.base,
&repo_name,
Some("review"),
Some(&number.to_string()),
Some(&slug),
Some(&workdir),
)?;
let target = PathBuf::from(base).join(&dirname);
let skips = HookSkips::parse(skip_hooks.as_deref())?;
let spec = BranchSpec {
type_: "review".to_string(),
issue: number.to_string(),
desc: slug.clone(),
};
let pre_ctx = HookContext::for_create(&repo, &workdir, &workdir, &target, &branch, &spec);
if bootstrap {
trust_or_prompt(&workdir, Some(&repo), trust_mode)?;
let report = lifecycle::run_phase(&config, HookPhase::PreCreate, &pre_ctx, &skips, false)?;
print_lifecycle_report(&report);
}
println!("creating review worktree:");
println!(
" PR : #{number} by {} ({} → {})",
head.author, head.head_ref_name, head.base_ref_name
);
println!(" branch : {branch}");
println!(" dir : {dirname}");
println!(" path : {}", target.display());
let base_ref = (!head.base_ref_name.is_empty()).then(|| {
let refspec = format!("+refs/heads/{0}:refs/remotes/origin/{0}", head.base_ref_name);
let _ = worktree::run_git_logged(&workdir, &["fetch", "origin", &refspec]);
format!("origin/{}", head.base_ref_name)
});
let head_ref = forge.pr_head_refspec(number);
let rspec = review::ReviewSpec {
number,
head_ref: &head_ref,
branch: &branch,
dirname: &dirname,
target: &target,
base_ref: base_ref.as_deref(),
};
let created = review::materialize(&repo, &workdir, &rspec)?;
println!("✓ review worktree created at {}", created.display());
println!("✓ linked to {} #{number}", forge.pr_noun());
let post_ctx = pre_ctx.with_cwd(&created);
match review::run_post_setup(&config, &post_ctx, &workdir, &created, &skips, bootstrap)? {
Some(reports) => {
print_lifecycle_report(&reports.pre_bootstrap);
print_report(&reports.bootstrap);
print_lifecycle_report(&reports.post_bootstrap);
if config.hooks.has_any() && !config.bootstrap.command.is_empty() {
eprintln!("warning: [[bootstrap.command]] is deprecated as a post_create hook when [hooks.*] is present");
}
print_lifecycle_report(&reports.post_create);
}
None => {
println!("(skipped bootstrap + hooks — pass --bootstrap to run setup against the PR's code)");
}
}
Ok(())
}
fn cmd_new(
branch_type: String,
desc: String,
no_bootstrap: bool,
reuse_branch: bool,
skip_hooks: Option<String>,
trust_mode: TrustMode,
) -> Result<()> {
let RepoContext { repo, config, .. } = repo_context(None)?;
let repo_name = worktree::repo_name(&repo);
let resolved_types = config.resolved_branch_types();
let spec = BranchSpec::new_with_types(branch_type.clone(), "0", desc, &resolved_types.types)?;
let draft = issue_templates::render_issue_draft(&repo, &config, &spec.type_, &spec.desc)?;
let forge = forge::resolve_or_default(&repo, &config)?;
let created = forge.create_issue(&forge::IssueCreateRequest {
title: &draft.title,
body_file: draft.body_file.path(),
labels: &draft.labels,
})?;
let label_summary = if draft.labels.is_empty() {
String::new()
} else {
format!(" (labels: {})", draft.labels.join(", "))
};
println!("✓ created issue #{} {}{}", created.number, draft.title, label_summary);
let issue = created.number.to_string();
let branch = BranchSpec::new_with_types(
spec.type_.clone(),
issue.clone(),
spec.desc.clone(),
&resolved_types.types,
)?
.branch_name(&config.worktree, &repo_name)?;
println!(" {}", created.url);
println!("creating linked worktree for {}", branch);
cmd_create(
Some(spec.type_),
Some(issue),
Some(spec.desc),
None,
no_bootstrap,
reuse_branch,
skip_hooks,
trust_mode,
None,
)
}
const PR_FILES_CHANGED_MAX_LINES: usize = 30;
fn cmd_pr(render_only: bool, draft: bool, base_override: Option<String>) -> Result<()> {
let RepoContext { repo, workdir, config } = repo_context(None)?;
let head_name = current_branch_at(None)?;
let branch_spec = crate::naming::BranchParser::from_config(&config, &worktree::repo_name(&repo)).parse(&head_name);
let branch_type = branch_spec
.as_ref()
.map(|s| s.type_.clone())
.filter(|t| !t.is_empty())
.unwrap_or_else(|| "chore".into());
let issue = branch_spec.as_ref().map(|s| s.issue.clone()).unwrap_or_default();
let desc = branch_spec.as_ref().map(|s| s.desc.clone()).unwrap_or_default();
let base = base_override
.or_else(|| worktree::resolve_trunk(&repo, &config.doctor.trunks))
.unwrap_or_else(|| "main".into());
let commits = worktree::git_log_subject_between(&workdir, &base, &head_name)
.inspect_err(|e| {
eprintln!(
"note: could not collect `{{commits}}` from `git log {}..{}`: {} (placeholder will be empty)",
base, head_name, e
);
})
.unwrap_or_default();
let files_changed = worktree::git_diff_stat_between(&workdir, &base, &head_name, PR_FILES_CHANGED_MAX_LINES)
.inspect_err(|e| {
eprintln!(
"note: could not collect `{{files_changed}}` from `git diff --stat {}..{}`: {} (placeholder will be empty)",
base, head_name, e
);
})
.unwrap_or_default();
let repo_slug = forge::repo_slug(&repo).unwrap_or_default();
let ctx = PrTemplateContext {
branch_type: branch_type.clone(),
issue,
desc,
base: base.clone(),
head: head_name.clone(),
commits,
files_changed,
repo: repo_slug.clone(),
};
let body = pr_templates::render_pr_body(&config.pr_template, &workdir, &ctx)?;
if render_only {
print!("{}", body);
if !body.ends_with('\n') {
println!();
}
return Ok(());
}
let mut body_file = tempfile::NamedTempFile::new()?;
use std::io::Write;
body_file.write_all(body.as_bytes())?;
body_file.flush()?;
let title = pr_title(&ctx);
let forge = forge::resolve_or_default(&repo, &config)?;
let created = forge.create_pr(&forge::PrCreateRequest {
title: &title,
body_file: body_file.path(),
head: &head_name,
base: Some(base.as_str()),
draft,
})?;
println!("✓ created {} #{}", forge.pr_noun(), created.number);
println!(" {}", created.url);
if let Err(e) = github::link_pr(&repo, &head_name, created.number) {
eprintln!("note: could not record gwm-pr config for {}: {}", head_name, e);
}
Ok(())
}
fn pr_title(ctx: &PrTemplateContext) -> String {
if let Some(first) = ctx.commits.lines().next() {
let trimmed = first.trim_start_matches("- ").trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
if !ctx.desc.is_empty() {
return format!("{}: {}", ctx.branch_type, ctx.desc);
}
format!("update {}", ctx.head)
}
pub fn format_remove_plan(name: &str, path: &Path, branch: Option<&str>, delete_branch: bool) -> String {
use std::fmt::Write;
let mut out = String::new();
let _ = writeln!(out, "would remove:");
let _ = writeln!(out, " name: {}", name);
let _ = writeln!(out, " path: {}", path.display());
match (branch, delete_branch) {
(Some(b), true) => {
let _ = writeln!(out, " branch: {} (would be deleted)", b);
}
(Some(b), false) => {
let _ = writeln!(out, " branch: {}", b);
}
(None, true) => {
let _ = writeln!(out, " branch: - (no branch to delete)");
}
(None, false) => {
let _ = writeln!(out, " branch: -");
}
}
out
}
pub fn format_prune_plan(entries: &[worktree::PrunableEntry]) -> String {
use std::fmt::Write;
let mut out = String::new();
if entries.is_empty() {
let _ = writeln!(out, "0 worktree(s) to prune");
return out;
}
let name_w = entries.iter().map(|e| e.name.chars().count()).max().unwrap_or(4);
let path_w = entries
.iter()
.map(|e| e.path.display().to_string().chars().count())
.max()
.unwrap_or(4);
let _ = writeln!(out, "would prune {} worktree(s):", entries.len());
for entry in entries {
let _ = writeln!(
out,
" {:<nw$} {:<pw$} ({})",
entry.name,
entry.path.display(),
entry.reason,
nw = name_w,
pw = path_w,
);
}
out
}
fn cmd_remove(
pattern: String,
delete_branch: bool,
dry_run: bool,
force: bool,
skip_hooks: Option<String>,
trust_mode: TrustMode,
) -> Result<()> {
let repo = worktree::discover_repo(None)?;
let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
let found = worktree::find_fuzzy(&repo, &pattern)?;
if dry_run {
worktree::remove_dry_run(&repo, &found.id)?;
print!(
"{}",
format_remove_plan(&found.name, &found.path, found.branch.as_deref(), delete_branch)
);
return Ok(());
}
let config = Config::load_for_repo(&workdir)?;
let mut skips = HookSkips::parse(skip_hooks.as_deref())?;
if force {
skips = skips.with(HookPhase::PreRemove).with(HookPhase::PostRemove);
}
if config.hooks.has_any() {
trust_or_prompt(&workdir, Some(&repo), trust_mode)?;
}
let pre_ctx = HookContext::for_worktree(&repo, &workdir, &found.path, &found.path, found.branch.as_deref());
let report = lifecycle::run_phase(&config, HookPhase::PreRemove, &pre_ctx, &skips, false)?;
print_lifecycle_report(&report);
let branch_oid = found.branch.as_deref().and_then(|b| {
repo
.find_branch(b, git2::BranchType::Local)
.ok()
.and_then(|br| br.into_reference().target())
.map(|o| o.to_string())
});
let repo_root = repo
.workdir()
.map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()))
.unwrap_or_default();
let entry = OpEntry {
ts: chrono::Utc::now(),
kind: crate::history::OpKind::Remove,
worktree: found.name.clone(),
branch: found.branch.clone(),
branch_oid,
path: found.path.clone(),
deleted_branch: delete_branch,
repo_root,
undone: false,
};
if let Err(e) = history::record(entry) {
eprintln!(
"warning: failed to record undo journal entry: {} (continuing with the remove anyway)",
e
);
}
worktree::remove(&repo, &found.id, delete_branch)?;
println!("✓ removed {} ({})", found.name, found.path.display());
if delete_branch {
if let Some(b) = &found.branch {
println!(" branch {} deleted", b);
}
}
let post_ctx = pre_ctx.with_cwd(&workdir);
let report = lifecycle::run_phase(&config, HookPhase::PostRemove, &post_ctx, &skips, false)?;
print_lifecycle_report(&report);
Ok(())
}
fn cmd_path(pattern: String, format: OutputFormat) -> Result<()> {
let repo = worktree::discover_repo(None)?;
let found = worktree::find_fuzzy(&repo, &pattern)?;
match format {
OutputFormat::Text => println!("{}", found.path.display()),
OutputFormat::Json => {
let dto = json_api::JsonPath::from(&found);
println!("{}", serde_json::to_string_pretty(&dto)?);
}
}
Ok(())
}
fn cmd_bootstrap(target: Option<String>, skip_hooks: Option<String>, trust_mode: TrustMode) -> Result<()> {
let RepoContext { repo, workdir, config } = repo_context(None)?;
let mut worktree_branch: Option<String> = None;
let worktree_path: PathBuf = match target {
Some(t) => {
let p = PathBuf::from(&t);
if p.is_dir() {
p
} else {
let found = worktree::find_fuzzy(&repo, &t)?;
worktree_branch = found.branch.clone();
found.path
}
}
None => std::env::current_dir()?,
};
if worktree_branch.is_none() {
worktree_branch = current_branch_at(Some(&worktree_path)).ok();
}
let skips = HookSkips::parse(skip_hooks.as_deref())?;
trust_or_prompt(&workdir, Some(&repo), trust_mode)?;
let hook_ctx = HookContext::for_worktree(
&repo,
&workdir,
&worktree_path,
&worktree_path,
worktree_branch.as_deref(),
);
let report = lifecycle::run_phase(&config, HookPhase::PreBootstrap, &hook_ctx, &skips, false)?;
print_lifecycle_report(&report);
let ctx = BootstrapCtx {
main_repo: &workdir,
worktree: &worktree_path,
config: &config,
};
let report = bootstrap::run_core(&ctx)?;
print_report(&report);
let report = lifecycle::run_phase(&config, HookPhase::PostBootstrap, &hook_ctx, &skips, false)?;
print_lifecycle_report(&report);
Ok(())
}
fn cmd_sync(pattern: Option<String>, merge: bool) -> Result<()> {
let (target_path, name) = match pattern {
Some(p) => {
let repo = worktree::discover_repo(None)?;
let found = worktree::find_fuzzy(&repo, &p)?;
(found.path, found.name)
}
None => {
let cwd = std::env::current_dir()?;
let repo = Repository::discover(&cwd).map_err(|_| GwmError::NotInGitRepo)?;
let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
let name = workdir
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "worktree".into());
(workdir, name)
}
};
let strategy = if merge {
SyncStrategy::Merge
} else {
SyncStrategy::Rebase
};
let report = sync::sync(&target_path, strategy)?;
print!("{}", format_sync_report(&name, &report));
Ok(())
}
pub fn format_sync_report(name: &str, report: &SyncReport) -> String {
match report.action {
SyncAction::UpToDate => {
format!("✓ {} already up to date with {}\n", name, report.upstream)
}
SyncAction::Integrated => {
let verb = match report.strategy {
SyncStrategy::Rebase => "rebased",
SyncStrategy::Merge => "merged",
};
let plural = if report.behind_before == 1 { "" } else { "s" };
format!(
"✓ {} {} {} commit{} from {}\n",
name, verb, report.behind_before, plural, report.upstream
)
}
}
}
fn cmd_prune(dry_run: bool) -> Result<()> {
let repo = worktree::discover_repo(None)?;
if dry_run {
let plan = worktree::prunable_worktrees(&repo)?;
print!("{}", format_prune_plan(&plan));
return Ok(());
}
let n = worktree::prune(&repo)?;
println!("pruned {} stale worktree(s)", n);
Ok(())
}
fn cmd_doctor(format: OutputFormat) -> Result<()> {
let RepoContext { repo, workdir, config } = repo_context_lenient(None)?;
let global = crate::config::global_config_path();
let ctx = DoctorCtx {
repo_workdir: &workdir,
repo: &repo,
config: &config,
global_config_path: global.as_deref(),
};
let report = doctor::run(&ctx)?;
match format {
OutputFormat::Text => print_doctor_report(&report),
OutputFormat::Json => {
let dto = json_api::JsonDoctorReport::from(&report);
println!("{}", serde_json::to_string_pretty(&dto)?);
}
}
let code = report.exit_code();
if code != 0 {
std::process::exit(code);
}
Ok(())
}
#[cfg(all(any(unix, windows), feature = "daemon"))]
fn cmd_daemon(socket: Option<PathBuf>, poll_ms: u64) -> Result<()> {
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
let repo = worktree::discover_repo(None)?;
let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
let (socket, manage_socket_dir) = match socket {
Some(s) => (s, false),
None => crate::daemon::default_socket(),
};
let mut opts = crate::daemon::ServeOptions::new(socket, workdir, std::time::Duration::from_millis(poll_ms));
opts.manage_socket_dir = manage_socket_dir;
crate::daemon::serve(&opts, Arc::new(AtomicBool::new(false)))
}
#[cfg(not(all(any(unix, windows), feature = "daemon")))]
fn cmd_daemon(socket: Option<PathBuf>, poll_ms: u64) -> Result<()> {
let _ = (socket, poll_ms);
Err(GwmError::Other(
"daemon mode is unavailable in this build (requires the `daemon` feature on a supported platform)".into(),
))
}
fn print_statusline(worktrees: &[crate::json_api::JsonWorktree], cwd: &Path) {
use std::io::Write;
let active = crate::statusline::active_index_with(worktrees, cwd, |p| {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
});
println!("{}", crate::statusline::render(worktrees, active));
let _ = io::stdout().flush();
}
#[cfg(all(any(unix, windows), feature = "daemon"))]
fn cmd_statusline(socket: Option<PathBuf>, watch: bool) -> Result<()> {
let socket = socket.unwrap_or_else(crate::daemon::socket_path);
let cwd = std::env::current_dir().unwrap_or_default();
if watch {
crate::statusline::watch(
|cb| crate::daemon::client::subscribe(&socket, cb),
|worktrees| print_statusline(worktrees, &cwd),
);
return Ok(());
}
match crate::daemon::client::list_once(&socket) {
Ok(worktrees) => print_statusline(&worktrees, &cwd),
Err(_) => print_statusline(&[], &cwd),
}
Ok(())
}
#[cfg(not(all(any(unix, windows), feature = "daemon")))]
fn cmd_statusline(socket: Option<PathBuf>, watch: bool) -> Result<()> {
let _ = (socket, watch);
let cwd = std::env::current_dir().unwrap_or_default();
print_statusline(&[], &cwd);
Ok(())
}
fn print_doctor_report(report: &doctor::DoctorReport) {
let clean = crate::naming::sanitise_block_for_terminal;
let indented = |text: &str, first: &str| {
let cleaned = clean(text);
let mut lines = cleaned.split('\n');
let mut out = format!("{}{}", first, lines.next().unwrap_or_default());
for line in lines {
out.push_str("\n ");
out.push_str(line);
}
out
};
for c in &report.checks {
let sigil = match c.status {
CheckStatus::Ok => "✓",
CheckStatus::Warning => "!",
CheckStatus::Failed => "✗",
};
println!("{} {}", sigil, crate::naming::sanitise_for_terminal(&c.name));
if !c.detail.is_empty() {
println!("{}", indented(&c.detail, " "));
}
if let Some(hint) = &c.fix_hint {
println!("{}", indented(hint, " → "));
}
}
}
fn cmd_types(gitmoji_flag: bool) -> Result<()> {
let workdir = match worktree::discover_repo(None) {
Ok(repo) => repo.workdir().map(|w| w.to_path_buf()),
Err(_) => None,
};
let resolved = match &workdir {
Some(w) => Config::load_for_repo(w)?.resolved_branch_types(),
None => Config::default().resolved_branch_types(),
};
let gitmoji_map = if gitmoji_flag {
Some(gitmoji::load(workdir.as_deref())?)
} else {
None
};
let width = resolved.types.iter().map(|t| t.name.len()).max().unwrap_or(0).max(8);
let clean = crate::naming::sanitise_for_terminal;
let sc_width = match &gitmoji_map {
Some(map) => map.iter().map(|(_, sc)| clean(sc).len()).max().unwrap_or(0).max(10),
None => 0,
};
for t in &resolved.types {
match &gitmoji_map {
Some(map) => {
let shortcode = clean(map.get(&t.name).unwrap_or(":question:"));
let unicode = gitmoji::shortcode_to_unicode(&shortcode);
println!(
" {:<width$} {} {:<sw$} {}",
t.name,
unicode,
shortcode,
clean(&t.description),
width = width,
sw = sc_width,
);
}
None => {
println!(" {:<width$} {}", t.name, clean(&t.description), width = width);
}
}
}
println!();
println!("(source: {})", resolved.source.label());
Ok(())
}
fn cmd_commit_prefix(branch_override: Option<String>, unicode: bool) -> Result<()> {
let (workdir, repo_name, branch_name) = match branch_override {
Some(name) => {
let repo = worktree::discover_repo(None).ok();
let workdir = repo.as_ref().and_then(|r| r.workdir().map(|w| w.to_path_buf()));
(workdir, repo.as_ref().map(worktree::repo_name), name)
}
None => {
let repo = worktree::discover_repo(None)?;
let wd = repo.workdir().map(|w| w.to_path_buf());
let name = current_branch_at(None)?;
(wd, Some(worktree::repo_name(&repo)), name)
}
};
let config = workdir.as_deref().and_then(|wd| Config::load_for_repo(wd).ok());
let parser = match (config.as_ref(), repo_name.as_deref()) {
(Some(cfg), Some(repo)) => crate::naming::BranchParser::from_config(cfg, repo),
_ => crate::naming::BranchParser::builtin().clone(),
};
let pattern = crate::naming::sanitise_for_terminal(
&config
.as_ref()
.map(|c| c.worktree.branch_pattern.clone())
.unwrap_or_else(crate::config::default_branch_pattern),
);
let spec = parser.parse(&branch_name).ok_or_else(|| {
GwmError::Other(format!(
"branch '{}' does not match this repo's branch pattern `{}`, so it carries no branch type to \
read — a commit prefix is derived from one, and a free-form branch has none. Pass --branch \
<name written by the pattern>, or write the prefix by hand",
branch_name, pattern
))
})?;
if spec.type_.is_empty() || spec.issue.is_empty() {
let want = match (spec.type_.is_empty(), spec.issue.is_empty()) {
(true, true) => "`{type}` and `{issue}`",
(true, false) => "`{type}`",
_ => "`{issue}`",
};
return Err(GwmError::Other(format!(
"this repo's branch pattern `{}` carries no {}, so branch '{}' has none to read — a commit \
prefix is built from the branch type and issue number. Add {} to worktree.branch_pattern, \
or write the prefix by hand",
pattern, want, branch_name, want
)));
}
let map = gitmoji::load(workdir.as_deref())?;
let prefix = gitmoji::resolve_prefix(&map, &spec, unicode);
println!("{}", crate::naming::sanitise_for_terminal(&prefix));
Ok(())
}
fn cmd_hooks(action: HooksAction) -> Result<()> {
match action {
HooksAction::Install { hook, force } => cmd_hooks_install(hook, force),
}
}
fn cmd_hooks_install(hook: HookKind, force: bool) -> Result<()> {
let repo = worktree::discover_repo(None)?;
let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
match hook {
HookKind::CommitMsg => {
let path = hooks::install_commit_msg(&workdir, force)?;
println!("✓ installed {}", path.display());
println!(" (auto-prepends gitmoji+type prefix when missing)");
}
}
Ok(())
}
fn cmd_completions(shell: Shell) -> Result<()> {
let mut cmd = Cli::command();
let name = cmd.get_name().to_string();
generate(shell, &mut cmd, name, &mut io::stdout());
Ok(())
}
fn cmd_shell_init(shell: InitShell) -> Result<()> {
print!("{}", shell_init_script(shell));
Ok(())
}
fn cmd_switch() -> Result<()> {
let _ = worktree::discover_repo(None)?;
match crate::tui::run_picker()? {
Some(path) => {
println!("{}", path.display());
Ok(())
}
None => std::process::exit(1),
}
}
fn cmd_multiplexer(mux: Multiplexer, pattern: String, split: bool) -> Result<()> {
let repo = worktree::discover_repo(None)?;
let env_name = match mux {
Multiplexer::Tmux => "TMUX",
Multiplexer::Zellij => "ZELLIJ",
};
let env_value = std::env::var(env_name).ok();
let running = match mux {
Multiplexer::Tmux => detect_tmux(env_value),
Multiplexer::Zellij => detect_zellij(env_value),
};
if !running {
return Err(GwmError::Other(format!(
"{0} session not running (${1} unset) — run `gwm {0} <pattern>` from inside a {0} session",
mux.binary(),
env_name,
)));
}
let found = worktree::find_fuzzy(&repo, &pattern)?;
let mode = if split { SpawnMode::Split } else { SpawnMode::Window };
let argv = match mux {
Multiplexer::Tmux => build_tmux_command(&found.name, &found.path, mode),
Multiplexer::Zellij => build_zellij_command(&found.name, &found.path, mode),
};
spawn_multiplexer(mux, &argv)
}
fn spawn_multiplexer(mux: Multiplexer, argv: &[String]) -> Result<()> {
let (bin, rest) = argv.split_first().ok_or_else(|| {
GwmError::Other(format!(
"empty argv for {} spawn (build_*_command returned [])",
mux.binary()
))
})?;
let status = std::process::Command::new(bin)
.args(rest)
.status()
.map_err(|e| GwmError::CommandFailed(format!("could not spawn {}: {}", bin, e)))?;
if !status.success() {
return Err(GwmError::CommandFailed(format!(
"{} exited with status {:?}",
bin,
status.code()
)));
}
Ok(())
}
fn resolve_target_repo(worktree: Option<String>) -> Result<(Repository, String, PathBuf)> {
let path: PathBuf = match worktree {
Some(pat) => {
let p = PathBuf::from(&pat);
if p.is_dir() {
p
} else {
let main = worktree::discover_repo(None)?;
worktree::find_fuzzy(&main, &pat)?.path
}
}
None => std::env::current_dir()?,
};
let repo = Repository::discover(&path).map_err(|_| GwmError::NotInGitRepo)?;
let branch = current_branch(&repo)?;
Ok((repo, branch, path))
}
fn current_branch_at(start: Option<&Path>) -> Result<String> {
let from = match start {
Some(p) => p.to_path_buf(),
None => std::env::current_dir()?,
};
let repo = Repository::discover(&from).map_err(|_| GwmError::NotInGitRepo)?;
current_branch(&repo)
}
fn current_branch(repo: &Repository) -> Result<String> {
let head = repo.head().map_err(|_| GwmError::UnbornHead {
reason: "HEAD is unborn or detached".into(),
})?;
head
.shorthand()
.ok()
.map(|s| s.to_string())
.ok_or_else(|| GwmError::UnbornHead {
reason: "HEAD has no shorthand (detached?)".into(),
})
}
fn cmd_link(target: LinkTarget, number: u64, worktree: Option<String>) -> Result<()> {
let (repo, branch, _path) = resolve_target_repo(worktree)?;
let config = Config::load_for_repo(repo.workdir().unwrap_or_else(|| repo.path()))?;
forge::reconcile_links(&repo, &config);
match target {
LinkTarget::Issue => {
github::link_issue(&repo, &branch, number)?;
println!("✓ linked issue #{} to branch {}", number, branch);
}
LinkTarget::Pr => {
github::link_pr(&repo, &branch, number)?;
println!("✓ linked PR #{} to branch {}", number, branch);
}
}
Ok(())
}
fn cmd_unlink(target: LinkTarget, worktree: Option<String>) -> Result<()> {
let (repo, branch, _path) = resolve_target_repo(worktree)?;
match target {
LinkTarget::Issue => {
github::unlink_issue(&repo, &branch)?;
println!("✓ unlinked issue on branch {}", branch);
}
LinkTarget::Pr => {
github::unlink_pr(&repo, &branch)?;
println!("✓ unlinked PR on branch {}", branch);
}
}
Ok(())
}
fn cmd_open(target: LinkTarget, worktree: Option<String>, print_url: bool) -> Result<()> {
let (repo, branch, _path) = resolve_target_repo(worktree)?;
let config = Config::load_for_repo(repo.workdir().unwrap_or_else(|| repo.path()))?;
let forge = forge::resolve(&repo, &config)?;
let link = github::read_link(&repo, &branch)?;
let url = match target {
LinkTarget::Issue => {
let n = link.issue.ok_or_else(|| GwmError::LinkMissing {
kind: LinkKind::Issue,
branch: branch.clone(),
})?;
forge.issue_url_confirmed(n)
}
LinkTarget::Pr => {
let n = link.pr.ok_or_else(|| GwmError::LinkMissing {
kind: LinkKind::Pr,
branch: branch.clone(),
})?;
forge.pr_url_confirmed(n)
}
};
if print_url {
println!("{}", url);
return Ok(());
}
spawn_opener(&url)
}
fn spawn_opener(url: &str) -> Result<()> {
let opener = if cfg!(target_os = "macos") {
"open"
} else if cfg!(target_os = "windows") {
"explorer"
} else {
"xdg-open"
};
let status = std::process::Command::new(opener)
.arg(url)
.status()
.map_err(|e| GwmError::CommandFailed(format!("could not spawn {}: {}", opener, e)))?;
if !status.success() {
return Err(GwmError::CommandFailed(format!(
"{} exited with status {:?}",
opener,
status.code()
)));
}
Ok(())
}
fn cmd_status(worktree: Option<String>, json: bool) -> Result<()> {
let (repo, branch, _path) = resolve_target_repo(worktree)?;
let config = Config::load_for_repo(repo.workdir().unwrap_or_else(|| repo.path()))?;
let forge = forge::resolve(&repo, &config).ok();
let slug = forge.as_ref().map(|f| f.slug().to_string());
let link = match forge.as_ref() {
Some(f) => github::read_link_with_pr_detection(&repo, &branch, f.as_ref())?,
None => github::read_link(&repo, &branch)?,
};
let (issue_status, pr_status) = fetch_link_status(&repo, &branch, &link, forge.as_deref());
if json {
println!(
"{}",
build_status_json(&branch, slug.as_deref(), &link, &issue_status, &pr_status)
);
} else {
let pr_noun = forge.as_ref().map_or("PR", |f| f.pr_noun());
print_status_human(&branch, slug.as_deref(), &link, &issue_status, &pr_status, pr_noun);
}
Ok(())
}
fn fetch_link_status(
repo: &Repository,
branch: &str,
link: &BranchLink,
forge: Option<&dyn forge::Forge>,
) -> (Option<IssueStatus>, Option<PrStatus>) {
let Some(forge) = forge else {
return (None, None);
};
let issue = link.issue.and_then(|n| forge.fetch_issue(n).ok());
let pr = link.pr.and_then(|n| forge.fetch_pr(n).ok());
if let Some(issue) = &issue {
let _ = github::persist_issue_title(repo, branch, &issue.title);
let _ = github::persist_issue_state(repo, branch, issue.state);
}
if let Some(pr) = &pr {
let _ = match link.pr_source {
LinkSource::Detected => github::persist_detected_pr_title(repo, branch, &pr.title)
.and_then(|()| github::persist_detected_pr_state(repo, branch, pr.state)),
LinkSource::Explicit => github::persist_pr_title(repo, branch, &pr.title)
.and_then(|()| github::persist_pr_state(repo, branch, pr.state)),
LinkSource::BranchName | LinkSource::None => Ok(()),
};
}
(issue, pr)
}
fn issue_state_str(s: IssueState) -> &'static str {
match s {
IssueState::Open => "open",
IssueState::Closed => "closed",
}
}
fn pr_state_str(s: PrState) -> &'static str {
match s {
PrState::Open => "open",
PrState::Draft => "draft",
PrState::Closed => "closed",
PrState::Merged => "merged",
}
}
fn link_source_str(s: LinkSource) -> &'static str {
match s {
LinkSource::None => "none",
LinkSource::BranchName => "branch-name",
LinkSource::Explicit => "explicit",
LinkSource::Detected => "detected",
}
}
fn print_status_human(
branch: &str,
slug: Option<&str>,
link: &BranchLink,
issue: &Option<IssueStatus>,
pr: &Option<PrStatus>,
pr_noun: &str,
) {
println!("branch: {}", branch);
if let Some(s) = slug {
println!("repo: {}", s);
}
println!("link: {}", link.summary(pr_noun));
if let Some(n) = link.issue {
print!("issue: #{}", n);
match issue {
Some(s) => println!(" [{}] {}", issue_state_str(s.state), s.title),
None => println!(" (status unavailable)"),
}
}
if let Some(n) = link.pr {
print!("pr: #{}", n);
match pr {
Some(s) => {
let checks = if s.checks_total > 0 {
format!(" · checks {}/{}", s.checks_passed, s.checks_total)
} else {
String::new()
};
println!(" [{}]{} {}", pr_state_str(s.state), checks, s.title);
}
None => println!(" (status unavailable)"),
}
}
}
pub fn build_status_json(
branch: &str,
slug: Option<&str>,
link: &BranchLink,
issue: &Option<IssueStatus>,
pr: &Option<PrStatus>,
) -> serde_json::Value {
let mut obj = serde_json::Map::new();
obj.insert("branch".into(), serde_json::Value::String(branch.into()));
if let Some(s) = slug {
obj.insert("repo".into(), serde_json::Value::String(s.into()));
}
obj.insert(
"issue".into(),
match link.issue {
Some(n) => {
let mut o = serde_json::Map::new();
o.insert("number".into(), serde_json::Value::Number(n.into()));
o.insert(
"source".into(),
serde_json::Value::String(link_source_str(link.issue_source).into()),
);
if let Some(s) = issue {
o.insert(
"state".into(),
serde_json::Value::String(issue_state_str(s.state).into()),
);
o.insert("title".into(), serde_json::Value::String(s.title.clone()));
o.insert(
"labels".into(),
serde_json::Value::Array(s.labels.iter().map(|l| serde_json::Value::String(l.clone())).collect()),
);
o.insert("url".into(), serde_json::Value::String(s.url.clone()));
}
serde_json::Value::Object(o)
}
None => serde_json::Value::Null,
},
);
obj.insert(
"pr".into(),
match link.pr {
Some(n) => {
let mut o = serde_json::Map::new();
o.insert("number".into(), serde_json::Value::Number(n.into()));
o.insert(
"source".into(),
serde_json::Value::String(link_source_str(link.pr_source).into()),
);
if let Some(s) = pr {
o.insert("state".into(), serde_json::Value::String(pr_state_str(s.state).into()));
o.insert("title".into(), serde_json::Value::String(s.title.clone()));
o.insert(
"checks_passed".into(),
serde_json::Value::Number(s.checks_passed.into()),
);
o.insert("checks_total".into(), serde_json::Value::Number(s.checks_total.into()));
o.insert("url".into(), serde_json::Value::String(s.url.clone()));
}
serde_json::Value::Object(o)
}
None => serde_json::Value::Null,
},
);
serde_json::Value::Object(obj)
}
fn cmd_labels(action: LabelsAction) -> Result<()> {
match action {
LabelsAction::List => cmd_labels_list(),
LabelsAction::Push {
dry_run,
prune,
random_colors,
} => cmd_labels_push(dry_run, prune, random_colors),
}
}
fn cmd_labels_list() -> Result<()> {
let config = load_labels_config()?;
if config.labels.is_empty() {
println!("0 labels declared in .gwm.toml — nothing to push.");
return Ok(());
}
let declared = labels::resolve_labels(&config.labels, false)?;
let forge = labels_forge(&config)?;
let remote = forge.fetch_remote_labels()?;
let diff = labels::diff_labels(&declared, &remote);
print_labels_diff(forge.slug(), &declared, &diff);
Ok(())
}
fn cmd_labels_push(dry_run: bool, prune: bool, random_colors: bool) -> Result<()> {
let config = load_labels_config()?;
if config.labels.is_empty() {
println!("0 labels declared in .gwm.toml — nothing to push.");
return Ok(());
}
let declared = labels::resolve_labels(&config.labels, random_colors)?;
let forge = labels_forge(&config)?;
let remote = forge.fetch_remote_labels()?;
let diff = labels::diff_labels(&declared, &remote);
let (n_create, n_update, n_match, n_extra) = diff.counts();
if prune {
for remote_label in &diff.extra_on_remote {
labels::validate_label_name(&remote_label.name).map_err(|e| {
let inner = match e {
GwmError::Config(msg) => msg,
other => other.to_string(),
};
GwmError::Config(format!("labels (remote): {inner} — refusing to prune"))
})?;
}
}
if dry_run {
print_labels_diff(forge.slug(), &declared, &diff);
let pruned = if prune { n_extra } else { 0 };
println!(
"{}",
labels::diff_dry_run_line(n_create, n_update, n_match, n_extra, pruned)
);
return Ok(());
}
for spec in &diff.to_create {
forge.create_label(spec)?;
println!("✓ created {}", spec.name);
}
for upd in &diff.to_update {
forge.update_label(&upd.spec)?;
println!("✓ updated {}", upd.spec.name);
}
if prune {
for remote_label in &diff.extra_on_remote {
forge.delete_label(&remote_label.name)?;
println!("✗ pruned {}", remote_label.name);
}
} else if !diff.extra_on_remote.is_empty() {
println!(
"{} label(s) on remote not in config — pass --prune to delete",
diff.extra_on_remote.len()
);
}
println!("{} label(s) untouched", n_match);
Ok(())
}
fn load_labels_config() -> Result<Config> {
Ok(repo_context(None)?.config)
}
fn labels_forge(config: &Config) -> Result<std::sync::Arc<dyn forge::Forge>> {
let repo = worktree::discover_repo(None)?;
forge::resolve(&repo, config)
}
fn print_labels_diff(slug: &str, declared: &[labels::LabelSpec], diff: &LabelDiff) {
for line in labels_diff_lines(slug, declared, diff) {
println!("{}", line);
}
}
pub fn labels_diff_lines(slug: &str, declared: &[labels::LabelSpec], diff: &LabelDiff) -> Vec<String> {
let clean = crate::naming::sanitise_for_terminal;
let (n_create, n_update, n_match, n_extra) = diff.counts();
let mut lines = vec![format!(
"declared in .gwm.toml: {} labels — diff against {}:",
declared.len(),
clean(slug)
)];
for spec in &diff.to_create {
lines.push(format!(
" + {:<20} (will create, color #{})",
clean(&spec.name),
clean(&spec.color)
));
}
for upd in &diff.to_update {
let detail = match (&upd.previous_color, &upd.previous_description) {
(Some(old), _) => format!("color #{} → #{}", old, upd.spec.color),
(None, Some(_)) => "description changed".into(),
_ => "diff".into(),
};
lines.push(format!(" ~ {:<20} ({})", clean(&upd.spec.name), clean(&detail)));
}
for spec in &diff.matching {
lines.push(format!(" = {:<20} (match)", clean(&spec.name)));
}
for remote in &diff.extra_on_remote {
lines.push(format!(" - {:<20} (on remote, not in config)", clean(&remote.name)));
}
lines.push(labels::diff_summary_line(n_create, n_update, n_match, n_extra));
lines
}
fn cmd_milestones(action: MilestonesAction) -> Result<()> {
match action {
MilestonesAction::List => cmd_milestones_list(),
MilestonesAction::Push { dry_run, prune } => cmd_milestones_push(dry_run, prune),
}
}
fn cmd_milestones_list() -> Result<()> {
let config = load_milestones_config()?;
if config.milestones.is_empty() {
println!("0 milestones declared in .gwm.toml — nothing to push.");
return Ok(());
}
let declared = milestones::resolve_milestones(&config.milestones)?;
let forge = labels_forge(&config)?;
let remote = forge.fetch_remote_milestones()?;
let diff = milestones::diff_milestones(&declared, &remote);
print_milestones_diff(forge.slug(), &declared, &diff);
Ok(())
}
fn cmd_milestones_push(dry_run: bool, prune: bool) -> Result<()> {
let config = load_milestones_config()?;
if config.milestones.is_empty() {
println!("0 milestones declared in .gwm.toml — nothing to push.");
return Ok(());
}
let declared = milestones::resolve_milestones(&config.milestones)?;
let forge = labels_forge(&config)?;
let remote = forge.fetch_remote_milestones()?;
let diff = milestones::diff_milestones(&declared, &remote);
let (n_create, n_update, n_match, n_extra) = diff.counts();
for spec in &diff.to_create {
forge.validate_milestone(spec)?;
}
for upd in &diff.to_update {
forge.validate_milestone(&upd.spec)?;
}
if dry_run {
print_milestones_diff(forge.slug(), &declared, &diff);
let pruned = if prune { n_extra } else { 0 };
println!(
"{}",
labels::diff_dry_run_line(n_create, n_update, n_match, n_extra, pruned)
);
return Ok(());
}
for spec in &diff.to_create {
forge.create_milestone(spec)?;
println!("✓ created {}", spec.title);
}
for upd in &diff.to_update {
forge.update_milestone(upd.number, &upd.spec)?;
println!("✓ updated {}", upd.spec.title);
}
if prune {
for remote_milestone in &diff.extra_on_remote {
forge.delete_milestone(remote_milestone.number)?;
println!("✗ pruned {}", remote_milestone.title);
}
} else if !diff.extra_on_remote.is_empty() {
println!(
"{} milestone(s) on remote not in config — pass --prune to delete",
diff.extra_on_remote.len()
);
}
println!("{} milestone(s) untouched", n_match);
Ok(())
}
fn load_milestones_config() -> Result<Config> {
Ok(repo_context(None)?.config)
}
fn print_milestones_diff(slug: &str, declared: &[milestones::MilestoneSpec], diff: &MilestoneDiff) {
for line in milestones_diff_lines(slug, declared, diff) {
println!("{}", line);
}
}
pub fn milestones_diff_lines(slug: &str, declared: &[milestones::MilestoneSpec], diff: &MilestoneDiff) -> Vec<String> {
let clean = crate::naming::sanitise_for_terminal;
let (n_create, n_update, n_match, n_extra) = diff.counts();
let mut lines = vec![format!(
"declared in .gwm.toml: {} milestones — diff against {}:",
declared.len(),
clean(slug)
)];
for spec in &diff.to_create {
let due = spec.due_on.as_deref().unwrap_or("no due date");
lines.push(format!(
" + {:<20} (will create, state {}, due {})",
clean(&spec.title),
spec.state.as_str(),
clean(due)
));
}
for upd in &diff.to_update {
let detail = match (&upd.previous_due_on, &upd.previous_state, &upd.previous_description) {
(Some(old_due), _, _) => format!("due {} → {}", old_due, upd.spec.due_on.as_deref().unwrap_or("cleared")),
(None, Some(old_state), _) => format!("state {} → {}", old_state.as_str(), upd.spec.state.as_str()),
(None, None, Some(_)) => "description changed".into(),
_ => "diff".into(),
};
lines.push(format!(" ~ {:<20} ({})", clean(&upd.spec.title), clean(&detail)));
}
for spec in &diff.matching {
lines.push(format!(" = {:<20} (match)", clean(&spec.title)));
}
for remote in &diff.extra_on_remote {
lines.push(format!(
" - {:<20} (#{} on remote, not in config)",
clean(&remote.title),
remote.number
));
}
lines.push(labels::diff_summary_line(n_create, n_update, n_match, n_extra));
lines
}
fn cmd_trust(action: TrustAction) -> Result<()> {
match action {
TrustAction::Add => cmd_trust_add(),
TrustAction::List => cmd_trust_list(),
TrustAction::Revoke { origin } => cmd_trust_revoke(origin),
TrustAction::Show => cmd_trust_show(),
}
}
fn cmd_trust_list() -> Result<()> {
let path = trust::default_ledger_path()?;
let ledger = TrustLedger::load(&path)?;
if ledger.entries.is_empty() {
println!("0 entries in trust ledger ({}).", path.display());
return Ok(());
}
println!("trust ledger: {}", path.display());
println!(
" {} entr{} recorded:",
ledger.entries.len(),
if ledger.entries.len() == 1 { "y" } else { "ies" }
);
let clean = crate::naming::sanitise_for_terminal;
let origin_w = ledger
.entries
.iter()
.map(|e| clean(&e.origin).len())
.max()
.unwrap_or(6)
.clamp(6, 60);
for e in &ledger.entries {
let short_sha: String = e.config_sha.chars().take(12).collect();
println!(
" {:<ow$} {} trusted_at {} by {}",
clean(&e.origin),
clean(&short_sha),
e.trusted_at.to_rfc3339(),
clean(&e.trusted_by),
ow = origin_w,
);
}
Ok(())
}
fn cmd_trust_revoke(origin: String) -> Result<()> {
let path = trust::default_ledger_path()?;
let mut ledger = TrustLedger::load(&path)?;
let removed = ledger.revoke(&origin);
let shown = crate::naming::sanitise_for_terminal(&origin);
if removed == 0 {
println!("0 entries matched origin {} (nothing to revoke).", shown);
return Ok(());
}
ledger.save(&path)?;
println!(
"✓ revoked {} entr{} for {}",
removed,
if removed == 1 { "y" } else { "ies" },
shown
);
Ok(())
}
fn cmd_trust_add() -> Result<()> {
let cwd = std::env::current_dir()?;
let repo = Repository::discover(&cwd).map_err(|_| GwmError::NotInGitRepo)?;
let workdir = repo.workdir().unwrap_or_else(|| repo.path()).to_path_buf();
let key = trust::origin_key_for_repo(&repo, &workdir);
match trust::record_config(&workdir, &key, &trust::current_actor())? {
Some(sha) => {
let short: String = sha.chars().take(12).collect();
println!(
"✓ trusted {} (.gwm.toml {})",
crate::naming::sanitise_for_terminal(&key),
short
);
Ok(())
}
None => Err(GwmError::Other(format!(
"no .gwm.toml in {} — there is nothing to trust here",
workdir.display()
))),
}
}
fn cmd_trust_show() -> Result<()> {
let path = trust::default_ledger_path()?;
println!("ledger path: {}", path.display());
match std::fs::read_to_string(&path) {
Ok(body) => {
let body = crate::naming::sanitise_block_for_terminal(&body);
println!("---");
print!("{}", body);
if !body.ends_with('\n') {
println!();
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
println!("(file does not exist yet — nothing has been trusted on this machine)");
}
Err(e) => return Err(e.into()),
}
Ok(())
}
fn trust_or_prompt(workdir: &Path, repo: Option<&Repository>, mode: TrustMode) -> Result<()> {
let origin_key = match repo {
Some(r) => trust::origin_key_for_repo(r, workdir),
None => trust::resolve_origin_key(None, workdir),
};
match trust::evaluate(workdir, &origin_key, mode)? {
TrustOutcome::Proceed => Ok(()),
TrustOutcome::Refuse { message } => Err(GwmError::Other(message)),
TrustOutcome::Prompt {
cfg_path,
body,
sha,
origin,
mut ledger,
ledger_path,
} => {
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
return Err(GwmError::Other(format!(
".gwm.toml at {} is not in the trust ledger and stdin is not interactive — \
pass --allow-bootstrap (or set GWM_ALLOW_BOOTSTRAP=1) to bypass, \
or run interactively to approve",
cfg_path.display()
)));
}
let granted = prompt_user(&cfg_path, &body, &origin, &sha)?;
if !granted {
return Err(GwmError::Other(format!(
"trust prompt declined for {} — aborting bootstrap",
cfg_path.display()
)));
}
ledger.record(&origin, &sha, &trust::current_actor());
ledger.save(&ledger_path)?;
println!(
"✓ recorded trust for {} in {}",
crate::naming::sanitise_for_terminal(&origin),
ledger_path.display()
);
Ok(())
}
}
}
fn prompt_user(cfg_path: &Path, bytes: &[u8], origin: &str, sha: &str) -> Result<bool> {
use std::io::{BufRead, Write};
let body = String::from_utf8_lossy(bytes);
let parsed: Option<Config> = toml::from_str(&body).ok();
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
println!();
println!("gwm: this repo's .gwm.toml has not been trusted yet.");
println!(" path : {}", cfg_path.display());
println!(" origin : {}", crate::naming::sanitise_for_terminal(origin));
println!(" hash : {}", sha);
if let Some(cfg) = parsed.as_ref() {
print_bootstrap_summary(cfg);
} else {
println!(" (could not parse .gwm.toml for summary — see raw via `show` below)");
}
println!();
loop {
print!("Trust this .gwm.toml? [y/N/show]: ");
stdout.flush().ok();
let mut line = String::new();
let n = stdin.lock().read_line(&mut line)?;
if n == 0 {
return Ok(false);
}
match line.trim().to_ascii_lowercase().as_str() {
"y" | "yes" => return Ok(true),
"n" | "no" | "" => return Ok(false),
"show" | "s" => {
let body = crate::naming::sanitise_block_for_terminal(&body);
println!("---");
print!("{}", body);
if !body.ends_with('\n') {
println!();
}
println!("---");
}
other => {
println!(
"unrecognised answer '{}': answer y, N, or show",
crate::naming::sanitise_for_terminal(other)
);
}
}
}
}
fn print_bootstrap_summary(cfg: &Config) {
for line in bootstrap_summary_lines(cfg) {
println!("{}", line);
}
}
pub fn bootstrap_summary_lines(cfg: &Config) -> Vec<String> {
let bs = &cfg.bootstrap;
if bs.copy.is_empty() && bs.command.is_empty() && bs.guard.is_empty() && bs.no_symlink.is_empty() {
return vec![" bootstrap surface: (empty, no copies/commands/guards/no_symlinks declared)".to_string()];
}
let clean = crate::naming::sanitise_for_terminal;
let mut lines = vec![" bootstrap surface:".to_string()];
for c in &bs.copy {
lines.push(format!(" - copy {} → {}", clean(&c.from), clean(&c.to)));
}
for g in &bs.guard {
lines.push(format!(
" - guard {} (on_match={}, deny={} pattern(s))",
clean(&g.name),
g.on_match,
g.deny_patterns.len()
));
}
for ns in &bs.no_symlink {
lines.push(format!(" - no-symlink {}", clean(&ns.path)));
}
for c in &bs.command {
lines.push(format!(" - run {} ({})", clean(&c.name), clean(&c.run)));
}
lines
}
fn cmd_aliases(action: AliasesAction) -> Result<()> {
match action {
AliasesAction::List => cmd_aliases_list(),
}
}
fn cmd_config(action: ConfigAction) -> Result<()> {
match action {
ConfigAction::Get { key } => config_cli::get(&key),
ConfigAction::Set { key, value } => config_cli::set(&key, value.as_deref()),
ConfigAction::Unset { key } => config_cli::unset(&key),
ConfigAction::List { prefix } => config_cli::list(prefix.as_deref()),
ConfigAction::Validate => config_cli::validate(),
ConfigAction::Path => config_cli::path(),
ConfigAction::Edit => config_cli::edit(),
}
}
fn cmd_aliases_list() -> Result<()> {
let repo_workdir: Option<PathBuf> = crate::worktree::discover_repo(None)
.ok()
.and_then(|r| r.workdir().map(|w| w.to_path_buf()));
let resolved = crate::aliases::load(repo_workdir.as_deref(), None)?;
let clean = crate::naming::sanitise_for_terminal;
println!("built-in:");
if resolved.built_in.is_empty() {
println!(" (none)");
} else {
let width = resolved.built_in.iter().map(|e| e.name.len()).max().unwrap_or(2).max(2);
for e in &resolved.built_in {
println!(" {:<width$} → {}", clean(e.name), clean(e.expansion), width = width);
}
}
println!("repo (.gwm.toml):");
if repo_workdir.is_none() {
println!(" (not inside a git repository — repo aliases are read from <repo>/.gwm.toml)");
} else if resolved.repo.is_empty() {
println!(" (none declared)");
} else {
let width = resolved.repo.keys().map(|k| clean(k).len()).max().unwrap_or(2).max(2);
for (name, expansion) in &resolved.repo {
println!(" {:<width$} → {}", clean(name), clean(expansion), width = width);
}
}
println!("user (~/.config/gwm/aliases.toml):");
if resolved.user.is_empty() {
println!(" (none declared)");
} else {
let width = resolved.user.keys().map(|k| clean(k).len()).max().unwrap_or(2).max(2);
for (name, expansion) in &resolved.user {
let shadowed = resolved.repo.contains_key(name);
let suffix = if shadowed { " (shadowed by repo)" } else { "" };
println!(
" {:<width$} → {}{}",
clean(name),
clean(expansion),
suffix,
width = width
);
}
}
Ok(())
}
pub fn shell_init_script(shell: InitShell) -> &'static str {
match shell {
InitShell::Bash | InitShell::Zsh => POSIX_SHELL_INIT,
InitShell::Fish => FISH_SHELL_INIT,
InitShell::Powershell => POWERSHELL_SHELL_INIT,
}
}
const POSIX_SHELL_INIT: &str = r#"# gwm shell helper — wraps `gwm cd` / `gwm switch` so the parent shell can cd.
# Install: eval "$(gwm shell-init bash)" # or zsh
#
# Two paths:
# gcd <pattern> # fuzzy resolve via `gwm cd <pattern>`, then cd
# gcd # no arg → opens the interactive picker via `gwm switch`, then cd
#
# Note: the `function name { ... }` form (zsh/bash-extended) is used instead
# of the parenthesised POSIX form so the parser does not error out with
# `defining function based on alias 'gcd'` when zsh already has a `gcd`
# alias (e.g. oh-my-zsh's `gcd=git checkout`). The `unalias` after the
# definition is what makes the function reachable at call time, since zsh
# still resolves the alias first when both exist.
function gcd {
local target
if [ "$#" -eq 0 ]; then
# No arg → open the interactive picker. `gwm switch` exits non-zero on
# cancel, in which case `gcd` must NOT attempt the `cd` (would land in $HOME).
target="$(command gwm switch)" || return $?
else
target="$(command gwm cd "$@")" || return $?
fi
cd "$target" || return $?
}
unalias gcd 2>/dev/null || true
"#;
const FISH_SHELL_INIT: &str = r#"# gwm shell helper — wraps `gwm cd` / `gwm switch` so the parent shell can cd.
# Install: gwm shell-init fish | source # then persist in ~/.config/fish/config.fish
#
# Two paths:
# gcd <pattern> # fuzzy resolve via `gwm cd <pattern>`, then cd
# gcd # no arg → opens the interactive picker via `gwm switch`, then cd
function gcd --description 'cd into a gwm worktree (no arg = interactive picker)'
set -l target
if test (count $argv) -eq 0
# No arg → open the interactive picker; cancel exits non-zero, in which
# case we must NOT attempt the cd (would land in $HOME).
set target (command gwm switch)
or return $status
else
set target (command gwm cd $argv)
or return $status
end
# `--` stops option parsing, "$target" prevents wildcard expansion on
# paths containing `[`, `]`, or `*`.
cd -- "$target"
end
"#;
const POWERSHELL_SHELL_INIT: &str = r#"# gwm shell helper — wraps `gwm cd` / `gwm switch` so the parent shell can cd.
# Install: Invoke-Expression (& gwm shell-init powershell | Out-String)
#
# Two paths:
# gcd <pattern> # fuzzy resolve via `gwm cd <pattern>`, then Set-Location
# gcd # no arg → opens the interactive picker via `gwm switch`, then Set-Location
#
# Note: this clears any prior `gcd` alias so the function takes effect.
Remove-Alias -Name gcd -Force -ErrorAction SilentlyContinue
function gcd {
param([string]$Pattern)
if ([string]::IsNullOrEmpty($Pattern)) {
# No arg → open the interactive picker. The binary exits non-zero on
# cancel; bail out before attempting Set-Location so we don't land in $HOME.
$target = & gwm switch
} else {
$target = & gwm cd $Pattern
}
if ($LASTEXITCODE -ne 0) { return }
Set-Location $target
}
"#;
fn print_report(report: &bootstrap::BootstrapReport) {
if report.steps.is_empty() {
return;
}
println!();
println!("bootstrap report:");
for s in &report.steps {
let sigil = s.status.sigil();
println!(" {} {}", sigil, s.label);
if !s.detail.is_empty() {
for line in s.detail.lines() {
println!(" {}", line);
}
}
}
}
fn print_lifecycle_report(report: &bootstrap::BootstrapReport) {
if report.steps.is_empty() {
return;
}
lifecycle::print_report(report);
}
fn current_repo_root() -> Result<PathBuf> {
let repo = worktree::discover_repo(None)?;
let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
Ok(std::fs::canonicalize(&workdir).unwrap_or(workdir))
}
pub fn format_history_row(entry: &OpEntry, now: chrono::DateTime<chrono::Utc>) -> String {
let delta = (now - entry.ts).to_std().unwrap_or(std::time::Duration::from_secs(0));
let ago = worktree::format_relative_duration(delta);
let suffix = if entry.undone { " (undone)" } else { "" };
format!("{:<5} {:<7} {}{}", ago, entry.kind.as_str(), entry.worktree, suffix)
}
fn cmd_history(limit: usize, all: bool) -> Result<()> {
let path = history::default_journal_path()?;
let journal = history::Journal::load(&path)?;
let root: Option<PathBuf> = if all { None } else { Some(current_repo_root()?) };
let mut rows: Vec<&OpEntry> = match &root {
Some(r) => journal.entries_for_repo(r).collect(),
None => journal.entries().iter().collect(),
};
if rows.is_empty() {
println!("no operations recorded");
return Ok(());
}
if limit == 0 {
return Ok(());
}
rows.sort_by_key(|e| std::cmp::Reverse(e.ts));
rows.truncate(limit);
let now = chrono::Utc::now();
for entry in rows {
println!("{}", format_history_row(entry, now));
}
Ok(())
}
fn cmd_undo(run_bootstrap: bool, trust_mode: TrustMode) -> Result<()> {
let path = history::default_journal_path()?;
let mut journal = history::Journal::load(&path)?;
let root = current_repo_root()?;
let Some(entry) = journal.pop_last_for_repo(&root) else {
return Err(GwmError::Other(format!(
"nothing to undo for {} — the journal is empty for this repo",
root.display()
)));
};
let repo = worktree::discover_repo(None)?;
if run_bootstrap {
let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
trust_or_prompt(&workdir, Some(&repo), trust_mode)?;
}
if let (Some(branch_name), Some(oid_hex)) = (&entry.branch, &entry.branch_oid) {
let oid = git2::Oid::from_str(oid_hex)
.map_err(|e| GwmError::Other(format!("journal entry has invalid branch_oid '{}': {}", oid_hex, e)))?;
if repo.find_branch(branch_name, git2::BranchType::Local).is_err() {
repo
.reference(
&format!("refs/heads/{}", branch_name),
oid,
false,
"gwm undo: resurrect branch",
)
.map_err(|e| {
GwmError::Other(format!(
"failed to recreate branch {} at {}: {}",
branch_name, oid_hex, e
))
})?;
println!(
"✓ recreated branch {} at {}",
branch_name,
&oid_hex[..oid_hex.len().min(8)]
);
} else {
println!("· branch {} already exists — skipping resurrection", branch_name);
}
}
let branch_name = entry.branch.as_deref().ok_or_else(|| {
GwmError::Other(format!(
"cannot undo remove of detached-HEAD worktree {} — only branch-attached worktrees are supported today",
entry.worktree
))
})?;
worktree::add(&repo, &entry.worktree, &entry.path, branch_name, true)?;
println!("✓ re-added worktree at {}", entry.path.display());
journal.save(&path)?;
if run_bootstrap {
let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
let config = Config::load_for_repo(&workdir)?;
let ctx = BootstrapCtx {
main_repo: &workdir,
worktree: &entry.path,
config: &config,
};
let report = bootstrap::run(&ctx)?;
print_report(&report);
} else {
println!("(skipped re-bootstrap; pass --bootstrap to run it)");
}
Ok(())
}