1use crate::bootstrap::{self, BootstrapCtx};
2use crate::clean;
3use crate::config::Config;
4use crate::config_cli;
5use crate::doctor::{self, CheckStatus, DoctorCtx};
6use crate::error::{GwmError, LinkKind, Result};
7use crate::exec;
8use crate::github::{self, BranchLink, IssueState, IssueStatus, LinkSource, PrState, PrStatus};
9use crate::gitmoji;
10use crate::history::{self, OpEntry};
11use crate::hooks;
12use crate::issue_templates;
13use crate::json_api;
14use crate::labels::{self, LabelDiff};
15use crate::lifecycle::{self, HookContext, HookPhase, HookSkips};
16use crate::milestones::{self, MilestoneDiff};
17use crate::multiplexer::{
18 build_tmux_command, build_zellij_command, detect_tmux, detect_zellij, Multiplexer, SpawnMode,
19};
20use crate::naming::{parse_branch, BranchSpec};
21use crate::pr_templates::{self, PrTemplateContext};
22use crate::presets;
23use crate::review;
24use crate::sync::{self, SyncAction, SyncReport, SyncStrategy};
25use crate::trust::{self, TrustLedger, TrustMode, TrustOutcome};
26use crate::workspace;
27use crate::worktree;
28use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
29use clap_complete::{generate, Shell};
30use git2::Repository;
31use std::io;
32use std::path::{Path, PathBuf};
33
34#[derive(Debug, Parser)]
35#[command(name = "gwm", version, about = "git worktree manager (TUI + CLI)")]
36pub struct Cli {
37 #[arg(long, global = true)]
45 pub allow_bootstrap: bool,
46
47 #[arg(long, global = true, conflicts_with = "allow_bootstrap")]
52 pub deny_bootstrap: bool,
53
54 #[arg(long, global = true, value_name = "DIR")]
64 pub workspace: Option<PathBuf>,
65
66 #[command(subcommand)]
67 pub command: Option<Command>,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
71pub enum ListFormat {
72 Table,
74 Names,
76 Json,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
85pub enum OutputFormat {
86 Text,
88 Json,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
93pub enum InitShell {
94 Bash,
95 Zsh,
96 Fish,
97 Powershell,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
102pub enum LinkTarget {
103 Issue,
105 Pr,
107}
108
109#[derive(Debug, Subcommand)]
110pub enum Command {
111 Init {
113 #[arg(long, value_name = "NAME")]
117 preset: Option<String>,
118 #[arg(long)]
121 list_presets: bool,
122 #[arg(long)]
125 show: bool,
126 },
127 List {
129 #[arg(long, value_enum, default_value_t = ListFormat::Table)]
131 format: ListFormat,
132 #[arg(long)]
137 detect_pr: bool,
138 },
139 Create {
141 #[arg()]
143 branch_type: String,
144 #[arg()]
146 issue: String,
147 #[arg()]
149 desc: String,
150 #[arg(long)]
152 no_bootstrap: bool,
153 #[arg(long)]
158 reuse_branch: bool,
159 #[arg(long, value_name = "PHASES")]
161 skip_hooks: Option<String>,
162 #[arg(long, value_name = "NAME")]
167 repo: Option<String>,
168 },
169 Pr {
184 #[arg(long)]
187 render: bool,
188 #[arg(long, conflicts_with = "render")]
191 draft: bool,
192 #[arg(long, value_name = "REF")]
195 base: Option<String>,
196 },
197 Review {
212 #[arg()]
214 number: u64,
215 #[arg(long, value_name = "BRANCH")]
219 name: Option<String>,
220 #[arg(long)]
224 bootstrap: bool,
225 #[arg(long, value_name = "PHASES")]
227 skip_hooks: Option<String>,
228 },
229 New {
231 #[arg()]
233 branch_type: String,
234 #[arg()]
236 desc: String,
237 #[arg(long)]
239 no_bootstrap: bool,
240 #[arg(long)]
242 reuse_branch: bool,
243 #[arg(long, value_name = "PHASES")]
245 skip_hooks: Option<String>,
246 },
247 Remove {
249 pattern: String,
250 #[arg(long)]
252 delete_branch: bool,
253 #[arg(long)]
259 dry_run: bool,
260 #[arg(long)]
262 force: bool,
263 #[arg(long, value_name = "PHASES")]
265 skip_hooks: Option<String>,
266 },
267 #[command(visible_alias = "cd")]
272 Path {
273 pattern: String,
274 #[arg(long, value_enum, default_value_t = OutputFormat::Text)]
277 format: OutputFormat,
278 },
279 Bootstrap {
281 target: Option<String>,
283 #[arg(long, value_name = "PHASES")]
285 skip_hooks: Option<String>,
286 },
287 Sync {
300 pattern: Option<String>,
302 #[arg(long)]
304 merge: bool,
305 },
306 Prune {
308 #[arg(long)]
313 dry_run: bool,
314 },
315 Doctor {
320 #[arg(long, value_enum, default_value_t = OutputFormat::Text)]
325 format: OutputFormat,
326 },
327 Daemon {
338 #[arg(long, value_name = "PATH")]
341 socket: Option<PathBuf>,
342 #[arg(long, value_name = "MS", default_value_t = 1000, value_parser = clap::value_parser!(u64).range(1..))]
348 poll_ms: u64,
349 },
350 Statusline {
364 #[arg(long, value_name = "PATH")]
367 socket: Option<PathBuf>,
368 #[arg(long)]
371 watch: bool,
372 },
373 Types {
380 #[arg(long)]
384 gitmoji: bool,
385 },
386 CommitPrefix {
398 #[arg(long)]
401 branch: Option<String>,
402 #[arg(long)]
405 unicode: bool,
406 },
407 Hooks {
414 #[command(subcommand)]
415 action: HooksAction,
416 },
417 Completions {
423 #[arg(value_enum)]
425 shell: Shell,
426 },
427 ShellInit {
434 #[arg(value_enum)]
436 shell: InitShell,
437 },
438 #[command(visible_alias = "s")]
449 Switch,
450 Tmux {
458 pattern: String,
460 #[arg(short = 'p', long = "split")]
462 split: bool,
463 },
464 Zellij {
470 pattern: String,
472 #[arg(short = 'p', long = "split")]
474 split: bool,
475 },
476 Link {
485 #[arg(value_enum)]
487 target: LinkTarget,
488 number: u64,
490 #[arg(long)]
492 worktree: Option<String>,
493 },
494 Unlink {
500 #[arg(value_enum)]
502 target: LinkTarget,
503 #[arg(long)]
505 worktree: Option<String>,
506 },
507 Open {
513 #[arg(value_enum)]
515 target: LinkTarget,
516 #[arg(long)]
518 worktree: Option<String>,
519 #[arg(long)]
521 print_url: bool,
522 },
523 Status {
529 #[arg(long)]
531 worktree: Option<String>,
532 #[arg(long)]
534 json: bool,
535 },
536 Labels {
543 #[command(subcommand)]
544 action: LabelsAction,
545 },
546 Milestones {
554 #[command(subcommand)]
555 action: MilestonesAction,
556 },
557 Trust {
568 #[command(subcommand)]
569 action: TrustAction,
570 },
571 Aliases {
580 #[command(subcommand)]
581 action: AliasesAction,
582 },
583 Config {
585 #[command(subcommand)]
586 action: ConfigAction,
587 },
588 History {
597 #[arg(long, default_value_t = 20)]
599 limit: usize,
600 #[arg(long)]
603 all: bool,
604 },
605 Undo {
614 #[arg(long)]
617 bootstrap: bool,
618 },
619 Tui {
627 #[command(subcommand)]
628 action: TuiAction,
629 },
630 Theme {
636 #[command(subcommand)]
637 action: ThemeAction,
638 },
639 Exec {
648 #[arg(value_name = "SLUG")]
651 slugs: Vec<String>,
652 #[arg(long, value_name = "NAME")]
656 profile: Option<String>,
657 #[arg(long, value_name = "N")]
662 jobs: Option<u32>,
663 #[arg(last = true, allow_hyphen_values = true, value_name = "CMD")]
667 command: Vec<String>,
668 },
669 Clean {
681 #[arg(value_name = "SLUG")]
683 slugs: Vec<String>,
684 #[arg(long, value_name = "NAME")]
689 profile: Option<String>,
690 #[arg(long)]
692 yes: bool,
693 },
694}
695
696#[derive(Debug, Subcommand)]
698pub enum ThemeAction {
699 List,
701 Show {
703 name: String,
705 },
706}
707
708#[derive(Debug, Subcommand)]
710pub enum TuiAction {
711 Keys,
728}
729
730#[derive(Debug, Subcommand)]
735pub enum AliasesAction {
736 List,
745}
746
747#[derive(Debug, Subcommand)]
749pub enum ConfigAction {
750 Get {
752 key: String,
754 },
755 Set {
757 key: String,
759 value: Option<String>,
761 },
762 Unset {
764 key: String,
766 },
767 List {
769 #[arg(long)]
771 prefix: Option<String>,
772 },
773 Validate,
775 Path,
777 Edit,
779}
780
781#[derive(Debug, Subcommand)]
786pub enum HooksAction {
787 Install {
792 #[arg(value_enum)]
794 hook: HookKind,
795 #[arg(long)]
799 force: bool,
800 },
801}
802
803#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
809pub enum HookKind {
810 CommitMsg,
813}
814
815#[derive(Debug, Subcommand)]
819pub enum LabelsAction {
820 List,
827 Push {
838 #[arg(long)]
842 dry_run: bool,
843 #[arg(long)]
846 prune: bool,
847 #[arg(long)]
850 random_colors: bool,
851 },
852}
853
854#[derive(Debug, Subcommand)]
858pub enum TrustAction {
859 List,
866 Revoke {
871 origin: String,
875 },
876 Show,
884}
885
886#[derive(Debug, Subcommand)]
890pub enum MilestonesAction {
891 List,
898 Push {
907 #[arg(long)]
911 dry_run: bool,
912 #[arg(long)]
915 prune: bool,
916 },
917}
918
919pub fn run(cli: Cli) -> Result<()> {
920 let mode = trust::resolve_mode(cli.allow_bootstrap, cli.deny_bootstrap);
928
929 let Some(cmd) = cli.command else {
934 if let Some(root) = cli.workspace {
937 return crate::tui::run_workspace(&root, mode);
938 }
939 if let Some(root) = autodetect_workspace_prompt()? {
942 return crate::tui::run_workspace(&root, mode);
943 }
944 return crate::tui::run(mode);
945 };
946
947 if cli.workspace.is_some()
953 && !matches!(
954 cmd,
955 Command::List { .. } | Command::Create { .. } | Command::Exec { .. } | Command::Clean { .. }
956 )
957 {
958 return Err(GwmError::WorkspaceUnsupportedCommand);
959 }
960
961 match cmd {
962 Command::Init {
963 preset,
964 list_presets,
965 show,
966 } => cmd_init(preset, list_presets, show),
967 Command::List { format, detect_pr } => match cli.workspace {
968 Some(root) => cmd_list_workspace(&root, format, detect_pr),
969 None => cmd_list(format, detect_pr),
970 },
971 Command::Create {
972 branch_type,
973 issue,
974 desc,
975 no_bootstrap,
976 reuse_branch,
977 skip_hooks,
978 repo,
979 } => {
980 let start = match &cli.workspace {
981 Some(root) => Some(resolve_workspace_create_repo(root, repo)?),
982 None => None,
983 };
984 cmd_create(
985 branch_type,
986 issue,
987 desc,
988 no_bootstrap,
989 reuse_branch,
990 skip_hooks,
991 mode,
992 start.as_deref(),
993 )
994 }
995 Command::New {
996 branch_type,
997 desc,
998 no_bootstrap,
999 reuse_branch,
1000 skip_hooks,
1001 } => cmd_new(branch_type, desc, no_bootstrap, reuse_branch, skip_hooks, mode),
1002 Command::Pr { render, draft, base } => cmd_pr(render, draft, base),
1003 Command::Review {
1004 number,
1005 name,
1006 bootstrap,
1007 skip_hooks,
1008 } => cmd_review(number, name, bootstrap, skip_hooks, mode),
1009 Command::Remove {
1010 pattern,
1011 delete_branch,
1012 dry_run,
1013 force,
1014 skip_hooks,
1015 } => cmd_remove(pattern, delete_branch, dry_run, force, skip_hooks, mode),
1016 Command::Path { pattern, format } => cmd_path(pattern, format),
1017 Command::Bootstrap { target, skip_hooks } => cmd_bootstrap(target, skip_hooks, mode),
1018 Command::Sync { pattern, merge } => cmd_sync(pattern, merge),
1019 Command::Prune { dry_run } => cmd_prune(dry_run),
1020 Command::Doctor { format } => cmd_doctor(format),
1021 Command::Daemon { socket, poll_ms } => cmd_daemon(socket, poll_ms),
1022 Command::Statusline { socket, watch } => cmd_statusline(socket, watch),
1023 Command::Types { gitmoji } => cmd_types(gitmoji),
1024 Command::CommitPrefix { branch, unicode } => cmd_commit_prefix(branch, unicode),
1025 Command::Hooks { action } => cmd_hooks(action),
1026 Command::Completions { shell } => cmd_completions(shell),
1027 Command::ShellInit { shell } => cmd_shell_init(shell),
1028 Command::Switch => cmd_switch(),
1029 Command::Tmux { pattern, split } => cmd_multiplexer(Multiplexer::Tmux, pattern, split),
1030 Command::Zellij { pattern, split } => cmd_multiplexer(Multiplexer::Zellij, pattern, split),
1031 Command::Link {
1032 target,
1033 number,
1034 worktree,
1035 } => cmd_link(target, number, worktree),
1036 Command::Unlink { target, worktree } => cmd_unlink(target, worktree),
1037 Command::Open {
1038 target,
1039 worktree,
1040 print_url,
1041 } => cmd_open(target, worktree, print_url),
1042 Command::Status { worktree, json } => cmd_status(worktree, json),
1043 Command::Labels { action } => cmd_labels(action),
1044 Command::Milestones { action } => cmd_milestones(action),
1045 Command::Trust { action } => cmd_trust(action),
1046 Command::Aliases { action } => cmd_aliases(action),
1047 Command::Config { action } => cmd_config(action),
1048 Command::History { limit, all } => cmd_history(limit, all),
1049 Command::Undo { bootstrap } => cmd_undo(bootstrap),
1050 Command::Tui { action } => cmd_tui(action),
1051 Command::Theme { action } => cmd_theme(action),
1052 Command::Exec {
1053 slugs,
1054 profile,
1055 jobs,
1056 command,
1057 } => match cli.workspace {
1058 Some(root) => cmd_exec_workspace(&root, slugs, profile, jobs, command),
1059 None => cmd_exec(slugs, profile, jobs, command),
1060 },
1061 Command::Clean { slugs, profile, yes } => match cli.workspace {
1062 Some(root) => cmd_clean_workspace(&root, slugs, profile, yes),
1063 None => cmd_clean(slugs, profile, yes),
1064 },
1065 }
1066}
1067
1068fn resolve_targets(repo: &Repository, slugs: &[String]) -> Result<Vec<worktree::WorktreeInfo>> {
1074 if slugs.is_empty() {
1075 Ok(worktree::list(repo)?.into_iter().filter(|w| !w.is_main).collect())
1076 } else {
1077 slugs.iter().map(|s| worktree::find_fuzzy(repo, s)).collect()
1078 }
1079}
1080
1081fn cmd_exec(slugs: Vec<String>, profile: Option<String>, jobs: Option<u32>, command: Vec<String>) -> Result<()> {
1085 let repo = worktree::discover_repo(None)?;
1086 let (argv, job_count) = exec_plan(&repo, profile.as_deref(), jobs, &command)?;
1087 let targets = resolve_targets(&repo, &slugs)?;
1088 if targets.is_empty() {
1089 println!("no worktrees to run in");
1090 return Ok(());
1091 }
1092 let outcomes = exec_run(&targets, &argv, job_count, None)?;
1093 print_exec_rollup_and_exit(&outcomes)
1094}
1095
1096fn cmd_exec_workspace(
1104 root: &Path,
1105 slugs: Vec<String>,
1106 profile: Option<String>,
1107 jobs: Option<u32>,
1108 command: Vec<String>,
1109) -> Result<()> {
1110 let opened = open_workspace_repos(root)?;
1111 let repos: Vec<&Repository> = opened.iter().map(|(_, r)| r).collect();
1112 let targets_per_repo = resolve_workspace_targets(&repos, &slugs)?;
1115 let mut plans: Vec<(&str, &Vec<worktree::WorktreeInfo>, Vec<String>, usize)> = Vec::new();
1120 for ((name, repo), targets) in opened.iter().zip(&targets_per_repo) {
1121 if targets.is_empty() {
1122 continue;
1123 }
1124 let (argv, job_count) = exec_plan(repo, profile.as_deref(), jobs, &command)?;
1125 plans.push((name, targets, argv, job_count));
1126 }
1127
1128 if plans.is_empty() {
1129 let opened_repos = opened.iter().map(|(_, r)| r);
1134 if let Some(err) = first_exec_plan_error(opened_repos, profile.as_deref(), jobs, &command) {
1135 return Err(err);
1136 }
1137 println!("no worktrees to run in");
1138 return Ok(());
1139 }
1140
1141 let mut all = Vec::new();
1143 for (name, targets, argv, job_count) in &plans {
1144 println!("\n══ {}", name);
1145 all.extend(exec_run(targets, argv, *job_count, Some(name))?);
1146 }
1147 print_exec_rollup_and_exit(&all)
1148}
1149
1150fn first_exec_plan_error<'a>(
1155 repos: impl Iterator<Item = &'a Repository>,
1156 profile: Option<&str>,
1157 jobs: Option<u32>,
1158 command: &[String],
1159) -> Option<GwmError> {
1160 let mut last = None;
1161 for repo in repos {
1162 match exec_plan(repo, profile, jobs, command) {
1163 Ok(_) => return None,
1164 Err(e) => last = Some(e),
1165 }
1166 }
1167 last
1168}
1169
1170fn open_workspace_repos(root: &Path) -> Result<Vec<(String, Repository)>> {
1174 for entry in std::fs::read_dir(root)? {
1184 let path = entry?.path();
1185 if path.is_dir() && path.join(".git").try_exists()? && Repository::open(&path).is_err() {
1186 let name = path
1187 .file_name()
1188 .map(|n| n.to_string_lossy().to_string())
1189 .unwrap_or_default();
1190 return Err(GwmError::Other(format!(
1191 "workspace: child repo `{name}` has a `.git` but cannot be opened (corrupt or unreadable)"
1192 )));
1193 }
1194 }
1195
1196 let ws = workspace::discover(root)?;
1197 if ws.is_empty() {
1198 return Err(GwmError::EmptyWorkspace {
1199 root: root.display().to_string(),
1200 });
1201 }
1202 ws.repos
1203 .iter()
1204 .map(|r| {
1205 Repository::open(&r.path)
1206 .map(|repo| (r.name.clone(), repo))
1207 .map_err(|e| GwmError::Other(format!("workspace: cannot open repo `{}`: {e}", r.name)))
1208 })
1209 .collect()
1210}
1211
1212fn exec_plan(
1217 repo: &Repository,
1218 profile: Option<&str>,
1219 jobs: Option<u32>,
1220 command: &[String],
1221) -> Result<(Vec<String>, usize)> {
1222 let exec_cfg = if profile.is_some() {
1229 let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?;
1230 Config::load_exec_config(workdir)?
1231 } else if jobs.is_none() {
1232 crate::config::ExecConfig {
1233 jobs: Config::load_exec_jobs_default(repo.workdir())?,
1234 ..Default::default()
1235 }
1236 } else {
1237 crate::config::ExecConfig::default()
1238 };
1239 let argv = exec::resolve_exec_command(profile, command, &exec_cfg)?;
1240 let job_count = exec::resolve_jobs(jobs, profile, &exec_cfg);
1241 Ok((argv, job_count))
1242}
1243
1244fn exec_run(
1250 targets: &[worktree::WorktreeInfo],
1251 argv: &[String],
1252 job_count: usize,
1253 tag: Option<&str>,
1254) -> Result<Vec<exec::ExecOutcome>> {
1255 let (program, args) = argv
1258 .split_first()
1259 .ok_or_else(|| GwmError::Other("exec: no command resolved".into()))?;
1260 let args = args.to_vec();
1261 let display = |name: &str| match tag {
1262 Some(t) => format!("{t}/{name}"),
1263 None => name.to_string(),
1264 };
1265
1266 let mut outcomes = Vec::with_capacity(targets.len());
1267 if job_count <= 1 {
1268 for w in targets {
1270 println!("\n━━ {} ({})", w.name, w.path.display());
1271 let status = exec::exec_in_dir(&w.path, program, &args);
1272 outcomes.push(exec::ExecOutcome {
1273 name: display(&w.name),
1274 status,
1275 });
1276 }
1277 } else {
1278 use std::io::Write;
1284 let items: Vec<(String, std::path::PathBuf)> = targets.iter().map(|w| (w.name.clone(), w.path.clone())).collect();
1285 let results = exec::run_in_dirs_parallel(job_count, &items, program, &args);
1286 let stdout = std::io::stdout();
1287 let mut lock = stdout.lock();
1288 for ((name, path), (outcome, output)) in items.iter().zip(results) {
1289 let _ = writeln!(lock, "\n━━ {} ({})", name, path.display());
1292 let _ = lock.write_all(&output);
1293 outcomes.push(exec::ExecOutcome {
1294 name: display(name),
1295 status: outcome.status,
1296 });
1297 }
1298 let _ = lock.flush();
1299 }
1300 Ok(outcomes)
1301}
1302
1303fn print_exec_rollup_and_exit(outcomes: &[exec::ExecOutcome]) -> Result<()> {
1307 println!("\nrollup:");
1308 for o in outcomes {
1309 println!(" {}", exec::format_outcome(o));
1310 }
1311 let code = exec::rollup_exit_code(outcomes);
1312 if code != 0 {
1313 std::process::exit(code);
1314 }
1315 Ok(())
1316}
1317
1318fn resolve_workspace_targets(repos: &[&Repository], slugs: &[String]) -> Result<Vec<Vec<worktree::WorktreeInfo>>> {
1328 if slugs.is_empty() {
1329 return repos
1334 .iter()
1335 .map(|repo| Ok(worktree::list(repo)?.into_iter().filter(|w| !w.is_main).collect()))
1336 .collect();
1337 }
1338
1339 let mut per_repo: Vec<Vec<worktree::WorktreeInfo>> = (0..repos.len()).map(|_| Vec::new()).collect();
1340 let mut matched = vec![false; slugs.len()];
1341 for (ri, repo) in repos.iter().enumerate() {
1342 for (si, slug) in slugs.iter().enumerate() {
1343 match worktree::find_fuzzy(repo, slug) {
1344 Ok(wt) => {
1345 per_repo[ri].push(wt);
1346 matched[si] = true;
1347 }
1348 Err(GwmError::WorktreeNotFound(_)) => {}
1350 Err(e) => return Err(e),
1352 }
1353 }
1354 }
1355 if let Some(si) = matched.iter().position(|m| !m) {
1356 return Err(GwmError::WorktreeNotFound(format!(
1357 "{} (no worktree matches it in any workspace repo)",
1358 slugs[si]
1359 )));
1360 }
1361 Ok(per_repo)
1362}
1363
1364fn cmd_clean(slugs: Vec<String>, profile: Option<String>, yes: bool) -> Result<()> {
1369 let repo = worktree::discover_repo(None)?;
1370 let targets = resolve_targets(&repo, &slugs)?;
1371 let (reclaims, skipped) = clean_scan_repo(&repo, &targets, profile.as_deref(), None)?;
1374 if targets.is_empty() {
1375 println!("no worktrees to clean");
1376 return Ok(());
1377 }
1378 clean_finish(&reclaims, &skipped, yes)
1379}
1380
1381fn cmd_clean_workspace(root: &Path, slugs: Vec<String>, profile: Option<String>, yes: bool) -> Result<()> {
1389 let opened = open_workspace_repos(root)?;
1390 let repos: Vec<&Repository> = opened.iter().map(|(_, r)| r).collect();
1391 let targets_per_repo = resolve_workspace_targets(&repos, &slugs)?;
1392
1393 let mut reclaims: Vec<clean::WorktreeReclaim> = Vec::new();
1399 let mut skipped: Vec<(String, String)> = Vec::new();
1400 let mut participated = false;
1401 for ((name, repo), targets) in opened.iter().zip(&targets_per_repo) {
1402 if targets.is_empty() {
1403 continue;
1404 }
1405 participated = true;
1406 let (mut rec, mut skip) = clean_scan_repo(repo, targets, profile.as_deref(), Some(name))?;
1407 reclaims.append(&mut rec);
1408 skipped.append(&mut skip);
1409 }
1410
1411 if !participated {
1412 let mut last_err = None;
1416 let mut valid = false;
1417 for (_, repo) in &opened {
1418 match clean_scan_repo(repo, &[], profile.as_deref(), None) {
1419 Ok(_) => {
1420 valid = true;
1421 break;
1422 }
1423 Err(e) => last_err = Some(e),
1424 }
1425 }
1426 if !valid {
1427 return Err(last_err.expect("open_workspace_repos guarantees a non-empty workspace"));
1428 }
1429 }
1430 clean_finish(&reclaims, &skipped, yes)
1431}
1432
1433type CleanScan = (Vec<clean::WorktreeReclaim>, Vec<(String, String)>);
1436
1437fn clean_scan_repo(
1444 repo: &Repository,
1445 targets: &[worktree::WorktreeInfo],
1446 profile: Option<&str>,
1447 tag: Option<&str>,
1448) -> Result<CleanScan> {
1449 let clean_cfg = Config::load_clean_config(repo.workdir())?;
1453 let patterns = clean::resolve_clean_dirs(profile, &clean_cfg)?;
1454
1455 let display = |name: &str| match tag {
1456 Some(t) => format!("{t}/{name}"),
1457 None => name.to_string(),
1458 };
1459
1460 let mut reclaims = Vec::with_capacity(targets.len());
1467 let mut skipped = Vec::new();
1468 for w in targets {
1469 let (mut reclaim, skips) = clean::scan_worktree_safe(&w.name, &w.path, &patterns);
1470 reclaim.name = display(&w.name);
1471 for rel in skips {
1472 skipped.push((display(&w.name), rel));
1473 }
1474 reclaims.push(reclaim);
1475 }
1476 Ok((reclaims, skipped))
1477}
1478
1479fn clean_finish(reclaims: &[clean::WorktreeReclaim], skipped: &[(String, String)], yes: bool) -> Result<()> {
1484 print!("{}", clean::format_report(reclaims));
1485 for (name, rel) in skipped {
1486 println!("skipped {}/{}: not git-ignored, or holds tracked files", name, rel);
1487 }
1488
1489 let grand: u64 = reclaims.iter().map(|r| r.total_bytes).sum();
1490 if grand == 0 {
1491 println!("nothing to reclaim");
1492 return Ok(());
1493 }
1494 if !yes {
1495 println!("re-run with --yes to delete the listed artifacts");
1496 return Ok(());
1497 }
1498
1499 let mut freed = 0u64;
1500 let mut failures = 0usize;
1501 for r in reclaims {
1502 match clean::delete_reclaim(r) {
1503 Ok(b) => freed = freed.saturating_add(b),
1504 Err(e) => {
1505 eprintln!("failed to reclaim {}: {e}", r.name);
1506 failures += 1;
1507 }
1508 }
1509 }
1510 println!("reclaimed {}", clean::human_size(freed));
1511 if failures > 0 {
1512 std::process::exit(1);
1513 }
1514 Ok(())
1515}
1516
1517fn autodetect_workspace_prompt() -> Result<Option<PathBuf>> {
1525 use std::io::{IsTerminal, Write};
1526
1527 let cwd = std::env::current_dir()?;
1528 let Some(ws) = workspace::autodetect(&cwd) else {
1529 return Ok(None);
1530 };
1531 if !io::stdin().is_terminal() {
1532 return Ok(None);
1533 }
1534 eprint!(
1535 "No git repo here. Open {} as a workspace ({} repos)? [Y/n] ",
1536 cwd.display(),
1537 ws.repos.len()
1538 );
1539 io::stderr().flush().ok();
1540 let mut answer = String::new();
1541 io::stdin().read_line(&mut answer)?;
1542 let a = answer.trim().to_ascii_lowercase();
1543 if a.is_empty() || a == "y" || a == "yes" {
1544 Ok(Some(ws.root))
1545 } else {
1546 Ok(None)
1547 }
1548}
1549
1550fn cmd_tui(action: TuiAction) -> Result<()> {
1551 match action {
1552 TuiAction::Keys => cmd_tui_keys(),
1553 }
1554}
1555
1556fn cmd_theme(action: ThemeAction) -> Result<()> {
1557 match action {
1558 ThemeAction::List => cmd_theme_list(),
1559 ThemeAction::Show { name } => cmd_theme_show(&name),
1560 }
1561}
1562
1563fn cmd_theme_list() -> Result<()> {
1569 for name in crate::tui::theme::preset_names() {
1570 println!("{}", name);
1571 }
1572 Ok(())
1573}
1574
1575fn cmd_theme_show(name: &str) -> Result<()> {
1586 use crate::tui::theme::Theme;
1587 use ratatui::style::Color;
1588
1589 let theme = Theme::preset(name).ok_or_else(|| {
1590 let known = crate::tui::theme::preset_names().join(", ");
1591 GwmError::Other(format!("theme show: unknown preset {:?} (known: {})", name, known))
1592 })?;
1593 let color_str = |c: Color| -> String {
1594 match c {
1595 Color::Rgb(r, g, b) => format!("\"#{:02x}{:02x}{:02x}\"", r, g, b),
1596 Color::Reset => "\"reset\"".to_string(),
1597 Color::Black => "\"black\"".to_string(),
1598 Color::Red => "\"red\"".to_string(),
1599 Color::Green => "\"green\"".to_string(),
1600 Color::Yellow => "\"yellow\"".to_string(),
1601 Color::Blue => "\"blue\"".to_string(),
1602 Color::Magenta => "\"magenta\"".to_string(),
1603 Color::Cyan => "\"cyan\"".to_string(),
1604 Color::Gray => "\"gray\"".to_string(),
1605 Color::DarkGray => "\"dark_gray\"".to_string(),
1606 Color::LightRed => "\"bright_red\"".to_string(),
1607 Color::LightGreen => "\"bright_green\"".to_string(),
1608 Color::LightYellow => "\"bright_yellow\"".to_string(),
1609 Color::LightBlue => "\"bright_blue\"".to_string(),
1610 Color::LightMagenta => "\"bright_magenta\"".to_string(),
1611 Color::LightCyan => "\"bright_cyan\"".to_string(),
1612 Color::White => "\"white\"".to_string(),
1613 Color::Indexed(n) => format!("\"{}\"", n),
1617 }
1618 };
1619 println!("[theme]");
1620 println!("preset = {:?}", name);
1621 println!("focus = {}", color_str(theme.focus));
1622 println!("accent = {}", color_str(theme.accent));
1623 println!("branch = {}", color_str(theme.branch));
1624 println!("clean = {}", color_str(theme.clean));
1625 println!("dirty = {}", color_str(theme.dirty));
1626 println!("main = {}", color_str(theme.main));
1627 println!("locked = {}", color_str(theme.locked));
1628 println!("prunable = {}", color_str(theme.prunable));
1629 println!("muted = {}", color_str(theme.muted));
1630 println!("selection_bg = {}", color_str(theme.selection_bg));
1631 println!("name = {}", color_str(theme.name));
1632 println!("path = {}", color_str(theme.path));
1633 println!("staged = {}", color_str(theme.staged));
1634 println!("modified = {}", color_str(theme.modified));
1635 println!("untracked = {}", color_str(theme.untracked));
1636 Ok(())
1637}
1638
1639fn cmd_tui_keys() -> Result<()> {
1649 use crate::tui::keymap::{Keymap, Source};
1650 use crate::tui::modal_keymap::{KeyContext, ModalKeymap};
1651
1652 let (keymap, modal) = match worktree::discover_repo(None) {
1659 Ok(repo) => match repo.workdir() {
1660 Some(workdir) => {
1661 let cfg = Config::load_for_repo(workdir)?;
1662 (cfg.tui.keys.resolved_keymap()?, cfg.tui.keys.resolved_modal_keymap()?)
1663 }
1664 None => (Keymap::defaults(), ModalKeymap::defaults()),
1665 },
1666 Err(_) => (Keymap::defaults(), ModalKeymap::defaults()),
1667 };
1668
1669 let rows = keymap.list();
1670 let action_w = rows
1673 .iter()
1674 .map(|b| b.action.slug().len())
1675 .max()
1676 .unwrap_or(0)
1677 .max("action".len());
1678 let keys_w = rows
1679 .iter()
1680 .map(|b| {
1681 b.chords
1682 .iter()
1683 .map(|c| c.iter().map(|k| k.to_string()).collect::<Vec<_>>().join(" "))
1684 .collect::<Vec<_>>()
1685 .join(", ")
1686 .len()
1687 })
1688 .max()
1689 .unwrap_or(0)
1690 .max("keys".len());
1691
1692 println!("{:<aw$} {:<kw$} source", "action", "keys", aw = action_w, kw = keys_w);
1693 for binding in rows {
1694 let keys = binding
1695 .chords
1696 .iter()
1697 .map(|c| c.iter().map(|k| k.to_string()).collect::<Vec<_>>().join(" "))
1698 .collect::<Vec<_>>()
1699 .join(", ");
1700 let source = match binding.source {
1701 Source::Default => "default",
1702 Source::UserConfig => ".gwm.toml",
1703 };
1704 println!(
1705 "{:<aw$} {:<kw$} {}",
1706 binding.action.slug(),
1707 keys,
1708 source,
1709 aw = action_w,
1710 kw = keys_w
1711 );
1712 }
1713
1714 let fmt_keys = |keys: &[crate::tui::keymap::KeyStroke]| -> String {
1718 keys.iter().map(|k| k.to_string()).collect::<Vec<_>>().join(", ")
1719 };
1720 for ctx in KeyContext::all() {
1721 let bindings = modal.bindings_for(*ctx);
1722 if bindings.is_empty() {
1723 continue;
1724 }
1725 println!("\n[tui.keys.modal.{}]", ctx.config_path());
1726 let verb_w = bindings
1727 .iter()
1728 .map(|b| b.action.verb().len())
1729 .max()
1730 .unwrap_or(0)
1731 .max("verb".len());
1732 let keys_w = bindings
1733 .iter()
1734 .map(|b| fmt_keys(&b.keys).len())
1735 .max()
1736 .unwrap_or(0)
1737 .max("keys".len());
1738 println!("{:<vw$} {:<kw$} source", "verb", "keys", vw = verb_w, kw = keys_w);
1739 for binding in bindings {
1740 let source = match binding.source {
1741 Source::Default => "default",
1742 Source::UserConfig => ".gwm.toml",
1743 };
1744 println!(
1745 "{:<vw$} {:<kw$} {}",
1746 binding.action.verb(),
1747 fmt_keys(&binding.keys),
1748 source,
1749 vw = verb_w,
1750 kw = keys_w
1751 );
1752 }
1753 }
1754 Ok(())
1755}
1756
1757fn cmd_init(preset: Option<String>, list_presets: bool, show: bool) -> Result<()> {
1758 if list_presets {
1761 let name_w = presets::all().iter().map(|p| p.name.len()).max().unwrap_or(0);
1762 for p in presets::all() {
1763 let aliases = if p.aliases.is_empty() {
1764 String::new()
1765 } else {
1766 format!(" (alias: {})", p.aliases.join(", "))
1767 };
1768 println!(" {:<w$} {}{}", p.name, p.description, aliases, w = name_w);
1769 }
1770 return Ok(());
1771 }
1772
1773 let name = preset.as_deref().unwrap_or("generic");
1775 let resolved = presets::lookup(name).ok_or_else(|| {
1776 GwmError::Config(format!(
1777 "unknown preset {name:?} — run `gwm init --list-presets` to see the built-ins"
1778 ))
1779 })?;
1780
1781 if show {
1783 print!("{}", resolved.body);
1784 return Ok(());
1785 }
1786
1787 let repo = worktree::discover_repo(None)?;
1788 let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?;
1789 let path = Config::write_preset(workdir, resolved.body)?;
1790 println!("wrote {} (preset: {})", path.display(), resolved.name);
1791 Ok(())
1792}
1793
1794fn cmd_list(format: ListFormat, detect_pr: bool) -> Result<()> {
1795 let repo = worktree::discover_repo(None)?;
1796 let trees = worktree::list(&repo)?;
1797
1798 if format == ListFormat::Names {
1799 for w in trees.iter().filter(|w| !w.is_main) {
1803 println!("{}", w.name);
1804 }
1805 return Ok(());
1806 }
1807
1808 let detected_prs: Vec<Option<Option<u64>>> = if detect_pr {
1823 match github::repo_slug(&repo).ok() {
1824 None => vec![None; trees.len()],
1825 Some(slug) => trees
1826 .iter()
1827 .map(|w| {
1828 w.branch.as_deref().map(|branch| {
1829 github::read_link_with_pr_detection(&repo, branch, &slug)
1830 .ok()
1831 .and_then(|l| l.pr)
1832 })
1833 })
1834 .collect(),
1835 }
1836 } else {
1837 Vec::new()
1838 };
1839
1840 if format == ListFormat::Json {
1841 let mut dto: Vec<json_api::JsonWorktree> = trees.iter().map(json_api::JsonWorktree::from).collect();
1845 if detect_pr {
1846 for (d, outcome) in dto.iter_mut().zip(&detected_prs) {
1850 if let Some(pr) = outcome {
1851 d.pr = *pr;
1852 }
1853 }
1854 }
1855 println!("{}", serde_json::to_string_pretty(&dto)?);
1856 return Ok(());
1857 }
1858
1859 let name_w = trees.iter().map(|w| w.name.len()).max().unwrap_or(4).clamp(4, 40);
1861 let branch_w = trees
1862 .iter()
1863 .map(|w| w.branch.as_deref().unwrap_or("-").len())
1864 .max()
1865 .unwrap_or(6)
1866 .clamp(6, 40);
1867 let status_w = 14;
1868 let pr_w = 6;
1869
1870 if detect_pr {
1871 println!(
1872 " {:<nw$} {:<bw$} {:<sw$} {:<pw$} PATH",
1873 "NAME",
1874 "BRANCH",
1875 "STATUS",
1876 "PR",
1877 nw = name_w,
1878 bw = branch_w,
1879 sw = status_w,
1880 pw = pr_w,
1881 );
1882 } else {
1883 println!(
1884 " {:<nw$} {:<bw$} {:<sw$} PATH",
1885 "NAME",
1886 "BRANCH",
1887 "STATUS",
1888 nw = name_w,
1889 bw = branch_w,
1890 sw = status_w,
1891 );
1892 }
1893 for (i, w) in trees.iter().enumerate() {
1894 let mark = if w.is_main { "*" } else { " " };
1895 let branch = w.branch.clone().unwrap_or_else(|| "-".into());
1896 let status = format_status_text(w);
1897 if detect_pr {
1898 let pr = detected_prs.get(i).copied().flatten().flatten();
1901 let pr_cell = pr.map(|n| format!("#{n}")).unwrap_or_else(|| "-".into());
1902 println!(
1903 "{} {:<nw$} {:<bw$} {:<sw$} {:<pw$} {}",
1904 mark,
1905 w.name,
1906 branch,
1907 status,
1908 pr_cell,
1909 w.path.display(),
1910 nw = name_w,
1911 bw = branch_w,
1912 sw = status_w,
1913 pw = pr_w,
1914 );
1915 } else {
1916 println!(
1917 "{} {:<nw$} {:<bw$} {:<sw$} {}",
1918 mark,
1919 w.name,
1920 branch,
1921 status,
1922 w.path.display(),
1923 nw = name_w,
1924 bw = branch_w,
1925 sw = status_w,
1926 );
1927 }
1928 }
1929 Ok(())
1930}
1931
1932fn cmd_list_workspace(root: &Path, format: ListFormat, detect_pr: bool) -> Result<()> {
1939 let ws = workspace::discover(root)?;
1940 if ws.is_empty() {
1941 return Err(GwmError::EmptyWorkspace {
1942 root: root.display().to_string(),
1943 });
1944 }
1945 let rows = workspace::merge_worktrees(&ws)?;
1946
1947 if format == ListFormat::Names {
1948 for row in &rows {
1949 println!("{}/{}", row.repo_name, row.info.name);
1950 }
1951 return Ok(());
1952 }
1953
1954 let detected_prs: Vec<Option<Option<u64>>> = if detect_pr {
1961 rows
1962 .iter()
1963 .map(|row| {
1964 let repo = Repository::open(&row.repo_path).ok()?;
1965 let branch = row.info.branch.as_deref()?;
1966 let slug = github::repo_slug(&repo).ok()?;
1967 Some(
1968 github::read_link_with_pr_detection(&repo, branch, &slug)
1969 .ok()
1970 .and_then(|l| l.pr),
1971 )
1972 })
1973 .collect()
1974 } else {
1975 Vec::new()
1976 };
1977
1978 if format == ListFormat::Json {
1979 #[derive(serde::Serialize)]
1982 struct WorkspaceJsonWorktree<'a> {
1983 repo: &'a str,
1984 #[serde(flatten)]
1985 worktree: json_api::JsonWorktree,
1986 }
1987 let dto: Vec<WorkspaceJsonWorktree> = rows
1988 .iter()
1989 .enumerate()
1990 .map(|(i, row)| {
1991 let mut worktree = json_api::JsonWorktree::from(&row.info);
1992 if let Some(pr) = detected_prs.get(i).copied().flatten() {
1996 worktree.pr = pr;
1997 }
1998 WorkspaceJsonWorktree {
1999 repo: &row.repo_name,
2000 worktree,
2001 }
2002 })
2003 .collect();
2004 println!("{}", serde_json::to_string_pretty(&dto)?);
2005 return Ok(());
2006 }
2007
2008 let repo_w = rows.iter().map(|r| r.repo_name.len()).max().unwrap_or(4).clamp(4, 30);
2009 let name_w = rows.iter().map(|r| r.info.name.len()).max().unwrap_or(4).clamp(4, 40);
2010 let branch_w = rows
2011 .iter()
2012 .map(|r| r.info.branch.as_deref().unwrap_or("-").len())
2013 .max()
2014 .unwrap_or(6)
2015 .clamp(6, 40);
2016 let status_w = 14;
2017 let pr_w = 6;
2018
2019 if detect_pr {
2020 println!(
2021 " {:<rw$} {:<nw$} {:<bw$} {:<sw$} {:<pw$} PATH",
2022 "REPO",
2023 "NAME",
2024 "BRANCH",
2025 "STATUS",
2026 "PR",
2027 rw = repo_w,
2028 nw = name_w,
2029 bw = branch_w,
2030 sw = status_w,
2031 pw = pr_w,
2032 );
2033 } else {
2034 println!(
2035 " {:<rw$} {:<nw$} {:<bw$} {:<sw$} PATH",
2036 "REPO",
2037 "NAME",
2038 "BRANCH",
2039 "STATUS",
2040 rw = repo_w,
2041 nw = name_w,
2042 bw = branch_w,
2043 sw = status_w,
2044 );
2045 }
2046 for (i, row) in rows.iter().enumerate() {
2047 let w = &row.info;
2048 let mark = if w.is_main { "*" } else { " " };
2049 let branch = w.branch.clone().unwrap_or_else(|| "-".into());
2050 let status = format_status_text(w);
2051 if detect_pr {
2052 let pr = detected_prs.get(i).copied().flatten().flatten();
2054 let pr_cell = pr.map(|n| format!("#{n}")).unwrap_or_else(|| "-".into());
2055 println!(
2056 "{} {:<rw$} {:<nw$} {:<bw$} {:<sw$} {:<pw$} {}",
2057 mark,
2058 row.repo_name,
2059 w.name,
2060 branch,
2061 status,
2062 pr_cell,
2063 w.path.display(),
2064 rw = repo_w,
2065 nw = name_w,
2066 bw = branch_w,
2067 sw = status_w,
2068 pw = pr_w,
2069 );
2070 } else {
2071 println!(
2072 "{} {:<rw$} {:<nw$} {:<bw$} {:<sw$} {}",
2073 mark,
2074 row.repo_name,
2075 w.name,
2076 branch,
2077 status,
2078 w.path.display(),
2079 rw = repo_w,
2080 nw = name_w,
2081 bw = branch_w,
2082 sw = status_w,
2083 );
2084 }
2085 }
2086 Ok(())
2087}
2088
2089fn format_status_text(w: &worktree::WorktreeInfo) -> String {
2090 if w.is_prunable {
2091 return "prunable".into();
2092 }
2093 if w.is_locked {
2094 return "locked".into();
2095 }
2096 let s = &w.status;
2097 if s.unknown {
2098 return "unknown".into();
2099 }
2100 let mut parts: Vec<String> = Vec::new();
2101 if s.is_dirty {
2102 parts.push("● dirty".into());
2103 }
2104 if s.has_upstream {
2105 if s.ahead > 0 {
2106 parts.push(format!("↑{}", s.ahead));
2107 }
2108 if s.behind > 0 {
2109 parts.push(format!("↓{}", s.behind));
2110 }
2111 if !s.is_dirty && s.synced() {
2112 parts.push("✓ synced".into());
2113 }
2114 } else if !s.is_dirty {
2115 parts.push("clean".into());
2116 }
2117 parts.join(" ")
2118}
2119
2120pub struct RepoContext {
2126 pub repo: Repository,
2127 pub workdir: PathBuf,
2128 pub config: Config,
2129}
2130
2131pub fn repo_context(start: Option<&Path>) -> Result<RepoContext> {
2141 let repo = worktree::discover_repo(start)?;
2142 let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
2143 let config = Config::load_for_repo(&workdir)?;
2144 Ok(RepoContext { repo, workdir, config })
2145}
2146
2147pub fn repo_context_lenient(start: Option<&Path>) -> Result<RepoContext> {
2153 let repo = worktree::discover_repo(start)?;
2154 let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
2155 let config = Config::load_for_repo(&workdir).unwrap_or_default();
2156 Ok(RepoContext { repo, workdir, config })
2157}
2158
2159fn resolve_workspace_create_repo(root: &Path, repo: Option<String>) -> Result<PathBuf> {
2164 let ws = workspace::discover(root)?;
2165 if ws.is_empty() {
2166 return Err(GwmError::EmptyWorkspace {
2167 root: root.display().to_string(),
2168 });
2169 }
2170 let available = ws.repos.iter().map(|r| r.name.as_str()).collect::<Vec<_>>().join(", ");
2171 let name = repo.ok_or_else(|| GwmError::WorkspaceRepoRequired {
2172 available: available.clone(),
2173 })?;
2174 ws.repos
2175 .iter()
2176 .find(|r| r.name == name)
2177 .map(|r| r.path.clone())
2178 .ok_or(GwmError::WorkspaceRepoNotFound { name, available })
2179}
2180
2181#[allow(clippy::too_many_arguments)]
2187fn cmd_create(
2188 branch_type: String,
2189 issue: String,
2190 desc: String,
2191 no_bootstrap: bool,
2192 reuse_branch: bool,
2193 skip_hooks: Option<String>,
2194 trust_mode: TrustMode,
2195 start: Option<&Path>,
2196) -> Result<()> {
2197 let RepoContext { repo, workdir, config } = repo_context(start)?;
2198 let repo_name = worktree::repo_name(&repo);
2199
2200 let resolved_types = config.resolved_branch_types();
2201 let spec = BranchSpec::new_with_types(branch_type, issue, desc, &resolved_types.types)?;
2202 let branch = spec.branch_name(&config.worktree, &repo_name)?;
2203 let dirname = spec.worktree_dirname(&config.worktree, &repo_name)?;
2204 let target = spec.worktree_path(&config.worktree, &repo_name, &workdir)?;
2205 let skips = HookSkips::parse(skip_hooks.as_deref())?;
2206
2207 let create_hooks_present = !config.hooks.pre_create.is_empty()
2211 || !config.hooks.post_create.is_empty()
2212 || (!no_bootstrap && !config.bootstrap.command.is_empty());
2213 if !no_bootstrap || create_hooks_present {
2214 trust_or_prompt(&workdir, Some(&repo), trust_mode)?;
2215 }
2216
2217 let pre_ctx = HookContext::for_create(&repo, &workdir, &workdir, &target, &branch, &spec);
2218 let report = lifecycle::run_phase(&config, HookPhase::PreCreate, &pre_ctx, &skips, false)?;
2219 print_lifecycle_report(&report);
2220
2221 println!("creating worktree:");
2222 println!(" branch : {}", branch);
2223 println!(" dir : {}", dirname);
2224 println!(" path : {}", target.display());
2225
2226 let created = worktree::add(&repo, &dirname, &target, &branch, reuse_branch)?;
2227 println!("✓ worktree created at {}", created.display());
2228
2229 let post_ctx = pre_ctx.with_cwd(&created);
2230
2231 if no_bootstrap {
2232 println!("(skipped bootstrap)");
2233 } else {
2234 let report = lifecycle::run_phase(&config, HookPhase::PreBootstrap, &post_ctx, &skips, false)?;
2235 print_lifecycle_report(&report);
2236
2237 let ctx = BootstrapCtx {
2238 main_repo: &workdir,
2239 worktree: &created,
2240 config: &config,
2241 };
2242 let report = bootstrap::run_core(&ctx)?;
2243 print_report(&report);
2244
2245 let report = lifecycle::run_phase(&config, HookPhase::PostBootstrap, &post_ctx, &skips, false)?;
2246 print_lifecycle_report(&report);
2247 }
2248
2249 if config.hooks.has_any() && !config.bootstrap.command.is_empty() {
2250 eprintln!("warning: [[bootstrap.command]] is deprecated as a post_create hook when [hooks.*] is present");
2251 }
2252 let report = lifecycle::run_phase(&config, HookPhase::PostCreate, &post_ctx, &skips, !no_bootstrap)?;
2253 print_lifecycle_report(&report);
2254 Ok(())
2255}
2256
2257fn cmd_review(
2265 number: u64,
2266 name: Option<String>,
2267 bootstrap: bool,
2268 skip_hooks: Option<String>,
2269 trust_mode: TrustMode,
2270) -> Result<()> {
2271 let RepoContext { repo, workdir, config } = repo_context(None)?;
2272 let repo_name = worktree::repo_name(&repo);
2273 let repo_slug = github::repo_slug(&repo)?;
2274
2275 println!("resolving PR #{number} on {repo_slug} …");
2276 let head = github::fetch_pr_head(&repo_slug, number)?;
2277 let slug = review::head_slug(&head.head_ref_name);
2278
2279 let branch = name
2280 .clone()
2281 .unwrap_or_else(|| review::review_branch_name(number, &head.author, &slug));
2282 let dirname = match &name {
2283 Some(n) => review::dirname_from_branch(n),
2284 None => review::review_dirname(number, &head.author, &slug),
2285 };
2286 let base = crate::config::expand_placeholders(
2291 &config.worktree.base,
2292 &repo_name,
2293 Some("review"),
2294 Some(&number.to_string()),
2295 Some(&slug),
2296 Some(&workdir),
2297 )?;
2298 let target = PathBuf::from(base).join(&dirname);
2299 let skips = HookSkips::parse(skip_hooks.as_deref())?;
2300
2301 let spec = BranchSpec {
2306 type_: "review".to_string(),
2307 issue: number.to_string(),
2308 desc: slug.clone(),
2309 };
2310 let pre_ctx = HookContext::for_create(&repo, &workdir, &workdir, &target, &branch, &spec);
2311
2312 if bootstrap {
2316 trust_or_prompt(&workdir, Some(&repo), trust_mode)?;
2317 let report = lifecycle::run_phase(&config, HookPhase::PreCreate, &pre_ctx, &skips, false)?;
2318 print_lifecycle_report(&report);
2319 }
2320
2321 println!("creating review worktree:");
2322 println!(
2323 " PR : #{number} by {} ({} → {})",
2324 head.author, head.head_ref_name, head.base_ref_name
2325 );
2326 println!(" branch : {branch}");
2327 println!(" dir : {dirname}");
2328 println!(" path : {}", target.display());
2329
2330 let base_ref = (!head.base_ref_name.is_empty()).then(|| {
2343 let refspec = format!("+refs/heads/{0}:refs/remotes/origin/{0}", head.base_ref_name);
2344 let _ = worktree::run_git_logged(&workdir, &["fetch", "origin", &refspec]);
2345 format!("origin/{}", head.base_ref_name)
2346 });
2347 let rspec = review::ReviewSpec {
2348 number,
2349 branch: &branch,
2350 dirname: &dirname,
2351 target: &target,
2352 base_ref: base_ref.as_deref(),
2353 };
2354 let created = review::materialize(&repo, &workdir, &rspec)?;
2355 println!("✓ review worktree created at {}", created.display());
2356 println!("✓ linked to PR #{number}");
2357
2358 let post_ctx = pre_ctx.with_cwd(&created);
2359 match review::run_post_setup(&config, &post_ctx, &workdir, &created, &skips, bootstrap)? {
2360 Some(reports) => {
2361 print_lifecycle_report(&reports.pre_bootstrap);
2362 print_report(&reports.bootstrap);
2363 print_lifecycle_report(&reports.post_bootstrap);
2364 if config.hooks.has_any() && !config.bootstrap.command.is_empty() {
2365 eprintln!("warning: [[bootstrap.command]] is deprecated as a post_create hook when [hooks.*] is present");
2366 }
2367 print_lifecycle_report(&reports.post_create);
2368 }
2369 None => {
2370 println!("(skipped bootstrap + hooks — pass --bootstrap to run setup against the PR's code)");
2371 }
2372 }
2373 Ok(())
2374}
2375
2376fn cmd_new(
2377 branch_type: String,
2378 desc: String,
2379 no_bootstrap: bool,
2380 reuse_branch: bool,
2381 skip_hooks: Option<String>,
2382 trust_mode: TrustMode,
2383) -> Result<()> {
2384 let RepoContext { repo, config, .. } = repo_context(None)?;
2385 let repo_name = worktree::repo_name(&repo);
2386 let resolved_types = config.resolved_branch_types();
2387 let spec = BranchSpec::new_with_types(branch_type.clone(), "0", desc, &resolved_types.types)?;
2388 let draft = issue_templates::render_issue_draft(&repo, &config, &spec.type_, &spec.desc)?;
2389 let slug = github::repo_slug(&repo).ok();
2390 let created = github::create_issue(&github::IssueCreateRequest {
2391 title: &draft.title,
2392 body_file: draft.body_file.path(),
2393 labels: &draft.labels,
2394 repo: slug.as_deref(),
2395 })?;
2396
2397 let label_summary = if draft.labels.is_empty() {
2398 String::new()
2399 } else {
2400 format!(" (labels: {})", draft.labels.join(", "))
2401 };
2402 println!("✓ created issue #{} {}{}", created.number, draft.title, label_summary);
2403 let issue = created.number.to_string();
2404 let branch = BranchSpec::new_with_types(
2405 spec.type_.clone(),
2406 issue.clone(),
2407 spec.desc.clone(),
2408 &resolved_types.types,
2409 )?
2410 .branch_name(&config.worktree, &repo_name)?;
2411 println!(" {}", created.url);
2412 println!("creating linked worktree for {}", branch);
2413
2414 cmd_create(
2415 spec.type_,
2416 issue,
2417 spec.desc,
2418 no_bootstrap,
2419 reuse_branch,
2420 skip_hooks,
2421 trust_mode,
2422 None,
2423 )
2424}
2425
2426const PR_FILES_CHANGED_MAX_LINES: usize = 30;
2432
2433fn cmd_pr(render_only: bool, draft: bool, base_override: Option<String>) -> Result<()> {
2434 let RepoContext { repo, workdir, config } = repo_context(None)?;
2435 let head_name = current_branch(&repo)?;
2436 let branch_spec = parse_branch(&head_name);
2437
2438 let branch_type = branch_spec
2439 .as_ref()
2440 .map(|s| s.type_.clone())
2441 .unwrap_or_else(|| "chore".into());
2442 let issue = branch_spec.as_ref().map(|s| s.issue.clone()).unwrap_or_default();
2443 let desc = branch_spec.as_ref().map(|s| s.desc.clone()).unwrap_or_default();
2444
2445 let base = base_override
2446 .or_else(|| worktree::resolve_trunk(&repo, &config.doctor.trunks))
2447 .unwrap_or_else(|| "main".into());
2448
2449 let commits = worktree::git_log_subject_between(&workdir, &base, &head_name)
2450 .inspect_err(|e| {
2451 eprintln!(
2452 "note: could not collect `{{commits}}` from `git log {}..{}`: {} (placeholder will be empty)",
2453 base, head_name, e
2454 );
2455 })
2456 .unwrap_or_default();
2457 let files_changed = worktree::git_diff_stat_between(&workdir, &base, &head_name, PR_FILES_CHANGED_MAX_LINES)
2458 .inspect_err(|e| {
2459 eprintln!(
2460 "note: could not collect `{{files_changed}}` from `git diff --stat {}..{}`: {} (placeholder will be empty)",
2461 base, head_name, e
2462 );
2463 })
2464 .unwrap_or_default();
2465 let repo_slug = github::repo_slug(&repo).unwrap_or_default();
2466
2467 let ctx = PrTemplateContext {
2468 branch_type: branch_type.clone(),
2469 issue,
2470 desc,
2471 base: base.clone(),
2472 head: head_name.clone(),
2473 commits,
2474 files_changed,
2475 repo: repo_slug.clone(),
2476 };
2477 let body = pr_templates::render_pr_body(&config.pr_template, &workdir, &ctx)?;
2478
2479 if render_only {
2480 print!("{}", body);
2481 if !body.ends_with('\n') {
2482 println!();
2483 }
2484 return Ok(());
2485 }
2486
2487 let mut body_file = tempfile::NamedTempFile::new()?;
2488 use std::io::Write;
2489 body_file.write_all(body.as_bytes())?;
2490 body_file.flush()?;
2491
2492 let title = pr_title(&ctx);
2493 let slug = if repo_slug.is_empty() {
2494 None
2495 } else {
2496 Some(repo_slug.as_str())
2497 };
2498 let created = github::create_pr(&github::PrCreateRequest {
2499 title: &title,
2500 body_file: body_file.path(),
2501 head: &head_name,
2502 base: Some(base.as_str()),
2503 draft,
2504 repo: slug,
2505 })?;
2506 println!("✓ created PR #{}", created.number);
2507 println!(" {}", created.url);
2508 if let Err(e) = github::link_pr(&repo, &head_name, created.number) {
2509 eprintln!("note: could not record gwm-pr config for {}: {}", head_name, e);
2512 }
2513 Ok(())
2514}
2515
2516fn pr_title(ctx: &PrTemplateContext) -> String {
2517 if let Some(first) = ctx.commits.lines().next() {
2521 let trimmed = first.trim_start_matches("- ").trim();
2522 if !trimmed.is_empty() {
2523 return trimmed.to_string();
2524 }
2525 }
2526 if !ctx.desc.is_empty() {
2527 return format!("{}: {}", ctx.branch_type, ctx.desc);
2528 }
2529 format!("update {}", ctx.head)
2530}
2531
2532pub fn format_remove_plan(name: &str, path: &Path, branch: Option<&str>, delete_branch: bool) -> String {
2544 use std::fmt::Write;
2545 let mut out = String::new();
2546 let _ = writeln!(out, "would remove:");
2547 let _ = writeln!(out, " name: {}", name);
2548 let _ = writeln!(out, " path: {}", path.display());
2549 match (branch, delete_branch) {
2550 (Some(b), true) => {
2551 let _ = writeln!(out, " branch: {} (would be deleted)", b);
2552 }
2553 (Some(b), false) => {
2554 let _ = writeln!(out, " branch: {}", b);
2555 }
2556 (None, true) => {
2557 let _ = writeln!(out, " branch: - (no branch to delete)");
2563 }
2564 (None, false) => {
2565 let _ = writeln!(out, " branch: -");
2566 }
2567 }
2568 out
2569}
2570
2571pub fn format_prune_plan(entries: &[worktree::PrunableEntry]) -> String {
2583 use std::fmt::Write;
2584 let mut out = String::new();
2585 if entries.is_empty() {
2586 let _ = writeln!(out, "0 worktree(s) to prune");
2587 return out;
2588 }
2589 let name_w = entries.iter().map(|e| e.name.chars().count()).max().unwrap_or(4);
2595 let path_w = entries
2596 .iter()
2597 .map(|e| e.path.display().to_string().chars().count())
2598 .max()
2599 .unwrap_or(4);
2600 let _ = writeln!(out, "would prune {} worktree(s):", entries.len());
2601 for entry in entries {
2602 let _ = writeln!(
2603 out,
2604 " {:<nw$} {:<pw$} ({})",
2605 entry.name,
2606 entry.path.display(),
2607 entry.reason,
2608 nw = name_w,
2609 pw = path_w,
2610 );
2611 }
2612 out
2613}
2614
2615fn cmd_remove(
2616 pattern: String,
2617 delete_branch: bool,
2618 dry_run: bool,
2619 force: bool,
2620 skip_hooks: Option<String>,
2621 trust_mode: TrustMode,
2622) -> Result<()> {
2623 let repo = worktree::discover_repo(None)?;
2624 let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
2625 let found = worktree::find_fuzzy(&repo, &pattern)?;
2626 if dry_run {
2627 worktree::remove_dry_run(&repo, &found.id)?;
2635 print!(
2636 "{}",
2637 format_remove_plan(&found.name, &found.path, found.branch.as_deref(), delete_branch)
2638 );
2639 return Ok(());
2640 }
2641
2642 let config = Config::load_for_repo(&workdir)?;
2643 let mut skips = HookSkips::parse(skip_hooks.as_deref())?;
2644 if force {
2645 skips = skips.with(HookPhase::PreRemove).with(HookPhase::PostRemove);
2646 }
2647 if config.hooks.has_any() {
2648 trust_or_prompt(&workdir, Some(&repo), trust_mode)?;
2649 }
2650 let pre_ctx = HookContext::for_worktree(&repo, &workdir, &found.path, &found.path, found.branch.as_deref());
2651 let report = lifecycle::run_phase(&config, HookPhase::PreRemove, &pre_ctx, &skips, false)?;
2652 print_lifecycle_report(&report);
2653
2654 let branch_oid = found.branch.as_deref().and_then(|b| {
2663 repo
2664 .find_branch(b, git2::BranchType::Local)
2665 .ok()
2666 .and_then(|br| br.into_reference().target())
2667 .map(|o| o.to_string())
2668 });
2669 let repo_root = repo
2670 .workdir()
2671 .map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()))
2672 .unwrap_or_default();
2673 let entry = OpEntry {
2674 ts: chrono::Utc::now(),
2675 kind: crate::history::OpKind::Remove,
2676 worktree: found.name.clone(),
2677 branch: found.branch.clone(),
2678 branch_oid,
2679 path: found.path.clone(),
2680 deleted_branch: delete_branch,
2681 repo_root,
2682 undone: false,
2683 };
2684 if let Err(e) = history::record(entry) {
2685 eprintln!(
2686 "warning: failed to record undo journal entry: {} (continuing with the remove anyway)",
2687 e
2688 );
2689 }
2690
2691 worktree::remove(&repo, &found.id, delete_branch)?;
2692 println!("✓ removed {} ({})", found.name, found.path.display());
2693 if delete_branch {
2694 if let Some(b) = &found.branch {
2695 println!(" branch {} deleted", b);
2696 }
2697 }
2698 let post_ctx = pre_ctx.with_cwd(&workdir);
2699 let report = lifecycle::run_phase(&config, HookPhase::PostRemove, &post_ctx, &skips, false)?;
2700 print_lifecycle_report(&report);
2701 Ok(())
2702}
2703
2704fn cmd_path(pattern: String, format: OutputFormat) -> Result<()> {
2705 let repo = worktree::discover_repo(None)?;
2706 let found = worktree::find_fuzzy(&repo, &pattern)?;
2707 match format {
2708 OutputFormat::Text => println!("{}", found.path.display()),
2709 OutputFormat::Json => {
2710 let dto = json_api::JsonPath::from(&found);
2711 println!("{}", serde_json::to_string_pretty(&dto)?);
2712 }
2713 }
2714 Ok(())
2715}
2716
2717fn cmd_bootstrap(target: Option<String>, skip_hooks: Option<String>, trust_mode: TrustMode) -> Result<()> {
2718 let RepoContext { repo, workdir, config } = repo_context(None)?;
2719
2720 let mut worktree_branch: Option<String> = None;
2721 let worktree_path: PathBuf = match target {
2722 Some(t) => {
2723 let p = PathBuf::from(&t);
2724 if p.is_dir() {
2725 p
2726 } else {
2727 let found = worktree::find_fuzzy(&repo, &t)?;
2728 worktree_branch = found.branch.clone();
2729 found.path
2730 }
2731 }
2732 None => std::env::current_dir()?,
2733 };
2734 let skips = HookSkips::parse(skip_hooks.as_deref())?;
2735
2736 trust_or_prompt(&workdir, Some(&repo), trust_mode)?;
2737
2738 let hook_ctx = HookContext::for_worktree(
2739 &repo,
2740 &workdir,
2741 &worktree_path,
2742 &worktree_path,
2743 worktree_branch.as_deref(),
2744 );
2745 let report = lifecycle::run_phase(&config, HookPhase::PreBootstrap, &hook_ctx, &skips, false)?;
2746 print_lifecycle_report(&report);
2747
2748 let ctx = BootstrapCtx {
2749 main_repo: &workdir,
2750 worktree: &worktree_path,
2751 config: &config,
2752 };
2753 let report = bootstrap::run_core(&ctx)?;
2754 print_report(&report);
2755 let report = lifecycle::run_phase(&config, HookPhase::PostBootstrap, &hook_ctx, &skips, false)?;
2756 print_lifecycle_report(&report);
2757 Ok(())
2758}
2759
2760fn cmd_sync(pattern: Option<String>, merge: bool) -> Result<()> {
2761 let (target_path, name) = match pattern {
2769 Some(p) => {
2770 let repo = worktree::discover_repo(None)?;
2771 let found = worktree::find_fuzzy(&repo, &p)?;
2772 (found.path, found.name)
2773 }
2774 None => {
2775 let cwd = std::env::current_dir()?;
2776 let repo = Repository::discover(&cwd).map_err(|_| GwmError::NotInGitRepo)?;
2777 let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
2778 let name = workdir
2779 .file_name()
2780 .map(|n| n.to_string_lossy().into_owned())
2781 .unwrap_or_else(|| "worktree".into());
2782 (workdir, name)
2783 }
2784 };
2785
2786 let strategy = if merge {
2787 SyncStrategy::Merge
2788 } else {
2789 SyncStrategy::Rebase
2790 };
2791 let report = sync::sync(&target_path, strategy)?;
2792 print!("{}", format_sync_report(&name, &report));
2793 Ok(())
2794}
2795
2796pub fn format_sync_report(name: &str, report: &SyncReport) -> String {
2801 match report.action {
2802 SyncAction::UpToDate => {
2803 format!("✓ {} already up to date with {}\n", name, report.upstream)
2804 }
2805 SyncAction::Integrated => {
2806 let verb = match report.strategy {
2807 SyncStrategy::Rebase => "rebased",
2808 SyncStrategy::Merge => "merged",
2809 };
2810 let plural = if report.behind_before == 1 { "" } else { "s" };
2811 format!(
2812 "✓ {} {} {} commit{} from {}\n",
2813 name, verb, report.behind_before, plural, report.upstream
2814 )
2815 }
2816 }
2817}
2818
2819fn cmd_prune(dry_run: bool) -> Result<()> {
2820 let repo = worktree::discover_repo(None)?;
2821 if dry_run {
2822 let plan = worktree::prunable_worktrees(&repo)?;
2827 print!("{}", format_prune_plan(&plan));
2828 return Ok(());
2829 }
2830 let n = worktree::prune(&repo)?;
2831 println!("pruned {} stale worktree(s)", n);
2832 Ok(())
2833}
2834
2835fn cmd_doctor(format: OutputFormat) -> Result<()> {
2836 let RepoContext { repo, workdir, config } = repo_context_lenient(None)?;
2837
2838 let global = crate::config::global_config_path();
2842 let ctx = DoctorCtx {
2843 repo_workdir: &workdir,
2844 repo: &repo,
2845 config: &config,
2846 global_config_path: global.as_deref(),
2847 };
2848 let report = doctor::run(&ctx)?;
2849 match format {
2850 OutputFormat::Text => print_doctor_report(&report),
2851 OutputFormat::Json => {
2852 let dto = json_api::JsonDoctorReport::from(&report);
2853 println!("{}", serde_json::to_string_pretty(&dto)?);
2854 }
2855 }
2856
2857 let code = report.exit_code();
2861 if code != 0 {
2862 std::process::exit(code);
2863 }
2864 Ok(())
2865}
2866
2867#[cfg(all(unix, feature = "daemon"))]
2872fn cmd_daemon(socket: Option<PathBuf>, poll_ms: u64) -> Result<()> {
2873 use std::sync::atomic::AtomicBool;
2874 use std::sync::Arc;
2875
2876 let repo = worktree::discover_repo(None)?;
2877 let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
2878 let socket = socket.unwrap_or_else(crate::daemon::socket_path);
2879 let opts = crate::daemon::ServeOptions {
2880 socket,
2881 repo_workdir: workdir,
2882 poll_interval: std::time::Duration::from_millis(poll_ms),
2883 };
2884 crate::daemon::serve(&opts, Arc::new(AtomicBool::new(false)))
2888}
2889
2890#[cfg(not(all(unix, feature = "daemon")))]
2891fn cmd_daemon(socket: Option<PathBuf>, poll_ms: u64) -> Result<()> {
2892 let _ = (socket, poll_ms);
2893 Err(GwmError::Other(
2894 "daemon mode is unavailable in this build (requires a Unix platform and the `daemon` feature)".into(),
2895 ))
2896}
2897
2898fn print_statusline(worktrees: &[crate::json_api::JsonWorktree], cwd: &Path) {
2903 use std::io::Write;
2904 let active = crate::statusline::active_index_with(worktrees, cwd, |p| {
2908 std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
2909 });
2910 println!("{}", crate::statusline::render(worktrees, active));
2911 let _ = io::stdout().flush();
2912}
2913
2914#[cfg(all(unix, feature = "daemon"))]
2915fn cmd_statusline(socket: Option<PathBuf>, watch: bool) -> Result<()> {
2916 let socket = socket.unwrap_or_else(crate::daemon::socket_path);
2917 let cwd = std::env::current_dir().unwrap_or_default();
2920
2921 if watch {
2922 crate::statusline::watch(
2928 |cb| crate::daemon::client::subscribe(&socket, cb),
2929 |worktrees| print_statusline(worktrees, &cwd),
2930 );
2931 return Ok(());
2932 }
2933
2934 match crate::daemon::client::list_once(&socket) {
2935 Ok(worktrees) => print_statusline(&worktrees, &cwd),
2936 Err(_) => print_statusline(&[], &cwd),
2938 }
2939 Ok(())
2940}
2941
2942#[cfg(not(all(unix, feature = "daemon")))]
2943fn cmd_statusline(socket: Option<PathBuf>, watch: bool) -> Result<()> {
2944 let _ = (socket, watch);
2947 let cwd = std::env::current_dir().unwrap_or_default();
2948 print_statusline(&[], &cwd);
2949 Ok(())
2950}
2951
2952fn print_doctor_report(report: &doctor::DoctorReport) {
2953 for c in &report.checks {
2954 let sigil = match c.status {
2955 CheckStatus::Ok => "✓",
2956 CheckStatus::Warning => "!",
2957 CheckStatus::Failed => "✗",
2958 };
2959 println!("{} {}", sigil, c.name);
2960 if !c.detail.is_empty() {
2961 println!(" {}", c.detail);
2962 }
2963 if let Some(hint) = &c.fix_hint {
2964 println!(" → {}", hint);
2965 }
2966 }
2967}
2968
2969fn cmd_types(gitmoji_flag: bool) -> Result<()> {
2970 let workdir = match worktree::discover_repo(None) {
2979 Ok(repo) => repo.workdir().map(|w| w.to_path_buf()),
2980 Err(_) => None,
2981 };
2982 let resolved = match &workdir {
2983 Some(w) => Config::load_for_repo(w)?.resolved_branch_types(),
2984 None => Config::default().resolved_branch_types(),
2985 };
2986
2987 let gitmoji_map = if gitmoji_flag {
2991 Some(gitmoji::load(workdir.as_deref())?)
2992 } else {
2993 None
2994 };
2995
2996 let width = resolved.types.iter().map(|t| t.name.len()).max().unwrap_or(0).max(8);
2999 let sc_width = match &gitmoji_map {
3003 Some(map) => map.iter().map(|(_, sc)| sc.len()).max().unwrap_or(0).max(10),
3004 None => 0,
3005 };
3006
3007 for t in &resolved.types {
3008 match &gitmoji_map {
3009 Some(map) => {
3010 let shortcode = map.get(&t.name).unwrap_or(":question:");
3014 let unicode = gitmoji::shortcode_to_unicode(shortcode);
3015 println!(
3016 " {:<width$} {} {:<sw$} {}",
3017 t.name,
3018 unicode,
3019 shortcode,
3020 t.description,
3021 width = width,
3022 sw = sc_width,
3023 );
3024 }
3025 None => {
3026 println!(" {:<width$} {}", t.name, t.description, width = width);
3027 }
3028 }
3029 }
3030 println!();
3031 println!("(source: {})", resolved.source.label());
3032 Ok(())
3033}
3034
3035fn cmd_commit_prefix(branch_override: Option<String>, unicode: bool) -> Result<()> {
3040 let (workdir, branch_name) = match branch_override {
3055 Some(name) => {
3056 let workdir = worktree::discover_repo(None)
3061 .ok()
3062 .and_then(|r| r.workdir().map(|w| w.to_path_buf()));
3063 (workdir, name)
3064 }
3065 None => {
3066 let repo = worktree::discover_repo(None)?;
3067 let wd = repo.workdir().map(|w| w.to_path_buf());
3068 let name = current_branch(&repo)?;
3069 (wd, name)
3070 }
3071 };
3072
3073 let spec = parse_branch(&branch_name).ok_or_else(|| {
3074 GwmError::Other(format!(
3075 "branch '{}' does not follow the gwm convention <type>/#<issue>-<slug> — \
3076 cannot derive a commit prefix from it",
3077 branch_name
3078 ))
3079 })?;
3080
3081 let map = gitmoji::load(workdir.as_deref())?;
3082 let prefix = gitmoji::resolve_prefix(&map, &spec, unicode);
3083 println!("{}", prefix);
3084 Ok(())
3085}
3086
3087fn cmd_hooks(action: HooksAction) -> Result<()> {
3092 match action {
3093 HooksAction::Install { hook, force } => cmd_hooks_install(hook, force),
3094 }
3095}
3096
3097fn cmd_hooks_install(hook: HookKind, force: bool) -> Result<()> {
3098 let repo = worktree::discover_repo(None)?;
3099 let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
3100 match hook {
3101 HookKind::CommitMsg => {
3102 let path = hooks::install_commit_msg(&workdir, force)?;
3103 println!("✓ installed {}", path.display());
3104 println!(" (auto-prepends gitmoji+type prefix when missing)");
3105 }
3106 }
3107 Ok(())
3108}
3109
3110fn cmd_completions(shell: Shell) -> Result<()> {
3111 let mut cmd = Cli::command();
3112 let name = cmd.get_name().to_string();
3113 generate(shell, &mut cmd, name, &mut io::stdout());
3114 Ok(())
3115}
3116
3117fn cmd_shell_init(shell: InitShell) -> Result<()> {
3118 print!("{}", shell_init_script(shell));
3119 Ok(())
3120}
3121
3122fn cmd_switch() -> Result<()> {
3131 let _ = worktree::discover_repo(None)?;
3135 match crate::tui::run_picker()? {
3136 Some(path) => {
3137 println!("{}", path.display());
3138 Ok(())
3139 }
3140 None => std::process::exit(1),
3141 }
3142}
3143
3144fn cmd_multiplexer(mux: Multiplexer, pattern: String, split: bool) -> Result<()> {
3158 let repo = worktree::discover_repo(None)?;
3159
3160 let env_name = match mux {
3161 Multiplexer::Tmux => "TMUX",
3162 Multiplexer::Zellij => "ZELLIJ",
3163 };
3164 let env_value = std::env::var(env_name).ok();
3165 let running = match mux {
3166 Multiplexer::Tmux => detect_tmux(env_value),
3167 Multiplexer::Zellij => detect_zellij(env_value),
3168 };
3169 if !running {
3170 return Err(GwmError::Other(format!(
3174 "{0} session not running (${1} unset) — run `gwm {0} <pattern>` from inside a {0} session",
3175 mux.binary(),
3176 env_name,
3177 )));
3178 }
3179
3180 let found = worktree::find_fuzzy(&repo, &pattern)?;
3181 let mode = if split { SpawnMode::Split } else { SpawnMode::Window };
3182 let argv = match mux {
3183 Multiplexer::Tmux => build_tmux_command(&found.name, &found.path, mode),
3184 Multiplexer::Zellij => build_zellij_command(&found.name, &found.path, mode),
3185 };
3186 spawn_multiplexer(mux, &argv)
3187}
3188
3189fn spawn_multiplexer(mux: Multiplexer, argv: &[String]) -> Result<()> {
3194 let (bin, rest) = argv.split_first().ok_or_else(|| {
3195 GwmError::Other(format!(
3196 "empty argv for {} spawn (build_*_command returned [])",
3197 mux.binary()
3198 ))
3199 })?;
3200 let status = std::process::Command::new(bin)
3204 .args(rest)
3205 .status()
3206 .map_err(|e| GwmError::CommandFailed(format!("could not spawn {}: {}", bin, e)))?;
3207 if !status.success() {
3208 return Err(GwmError::CommandFailed(format!(
3209 "{} exited with status {:?}",
3210 bin,
3211 status.code()
3212 )));
3213 }
3214 Ok(())
3215}
3216
3217fn resolve_target_repo(worktree: Option<String>) -> Result<(Repository, String, PathBuf)> {
3226 let path: PathBuf = match worktree {
3227 Some(pat) => {
3228 let p = PathBuf::from(&pat);
3230 if p.is_dir() {
3231 p
3232 } else {
3233 let main = worktree::discover_repo(None)?;
3234 worktree::find_fuzzy(&main, &pat)?.path
3235 }
3236 }
3237 None => std::env::current_dir()?,
3238 };
3239 let repo = Repository::discover(&path).map_err(|_| GwmError::NotInGitRepo)?;
3240 let branch = current_branch(&repo)?;
3241 Ok((repo, branch, path))
3242}
3243
3244fn current_branch(repo: &Repository) -> Result<String> {
3245 let head = repo.head().map_err(|_| GwmError::UnbornHead {
3246 reason: "HEAD is unborn or detached".into(),
3247 })?;
3248 head
3249 .shorthand()
3250 .ok()
3251 .map(|s| s.to_string())
3252 .ok_or_else(|| GwmError::UnbornHead {
3253 reason: "HEAD has no shorthand (detached?)".into(),
3254 })
3255}
3256
3257fn cmd_link(target: LinkTarget, number: u64, worktree: Option<String>) -> Result<()> {
3258 let (repo, branch, _path) = resolve_target_repo(worktree)?;
3259 match target {
3260 LinkTarget::Issue => {
3261 github::link_issue(&repo, &branch, number)?;
3262 println!("✓ linked issue #{} to branch {}", number, branch);
3263 }
3264 LinkTarget::Pr => {
3265 github::link_pr(&repo, &branch, number)?;
3266 println!("✓ linked PR #{} to branch {}", number, branch);
3267 }
3268 }
3269 Ok(())
3270}
3271
3272fn cmd_unlink(target: LinkTarget, worktree: Option<String>) -> Result<()> {
3273 let (repo, branch, _path) = resolve_target_repo(worktree)?;
3274 match target {
3275 LinkTarget::Issue => {
3276 github::unlink_issue(&repo, &branch)?;
3277 println!("✓ unlinked issue on branch {}", branch);
3278 }
3279 LinkTarget::Pr => {
3280 github::unlink_pr(&repo, &branch)?;
3281 println!("✓ unlinked PR on branch {}", branch);
3282 }
3283 }
3284 Ok(())
3285}
3286
3287fn cmd_open(target: LinkTarget, worktree: Option<String>, print_url: bool) -> Result<()> {
3288 let (repo, branch, _path) = resolve_target_repo(worktree)?;
3289 let link = github::read_link(&repo, &branch)?;
3290 let slug = github::repo_slug(&repo)?;
3291
3292 let url = match target {
3293 LinkTarget::Issue => {
3294 let n = link.issue.ok_or_else(|| GwmError::LinkMissing {
3295 kind: LinkKind::Issue,
3296 branch: branch.clone(),
3297 })?;
3298 github::issue_url(&slug, n)
3299 }
3300 LinkTarget::Pr => {
3301 let n = link.pr.ok_or_else(|| GwmError::LinkMissing {
3302 kind: LinkKind::Pr,
3303 branch: branch.clone(),
3304 })?;
3305 github::pr_url(&slug, n)
3306 }
3307 };
3308
3309 if print_url {
3310 println!("{}", url);
3311 return Ok(());
3312 }
3313 spawn_opener(&url)
3314}
3315
3316fn spawn_opener(url: &str) -> Result<()> {
3317 let opener = if cfg!(target_os = "macos") {
3318 "open"
3319 } else if cfg!(target_os = "windows") {
3320 "explorer"
3321 } else {
3322 "xdg-open"
3323 };
3324 let status = std::process::Command::new(opener)
3325 .arg(url)
3326 .status()
3327 .map_err(|e| GwmError::CommandFailed(format!("could not spawn {}: {}", opener, e)))?;
3328 if !status.success() {
3329 return Err(GwmError::CommandFailed(format!(
3330 "{} exited with status {:?}",
3331 opener,
3332 status.code()
3333 )));
3334 }
3335 Ok(())
3336}
3337
3338fn cmd_status(worktree: Option<String>, json: bool) -> Result<()> {
3339 let (repo, branch, _path) = resolve_target_repo(worktree)?;
3340
3341 let slug = github::repo_slug(&repo).ok();
3344 let link = match slug.as_deref() {
3348 Some(s) => github::read_link_with_pr_detection(&repo, &branch, s)?,
3349 None => github::read_link(&repo, &branch)?,
3350 };
3351 let (issue_status, pr_status) = fetch_link_status(&repo, &branch, &link, slug.as_deref());
3352
3353 if json {
3354 println!(
3355 "{}",
3356 build_status_json(&branch, slug.as_deref(), &link, &issue_status, &pr_status)
3357 );
3358 } else {
3359 print_status_human(&branch, slug.as_deref(), &link, &issue_status, &pr_status);
3360 }
3361 Ok(())
3362}
3363
3364fn fetch_link_status(
3365 repo: &Repository,
3366 branch: &str,
3367 link: &BranchLink,
3368 slug: Option<&str>,
3369) -> (Option<IssueStatus>, Option<PrStatus>) {
3370 let Some(slug) = slug else {
3371 return (None, None);
3372 };
3373 let issue = link.issue.and_then(|n| github::fetch_issue(slug, n).ok());
3375 let pr = link.pr.and_then(|n| github::fetch_pr(slug, n).ok());
3376 if let Some(issue) = &issue {
3377 let _ = github::persist_issue_title(repo, branch, &issue.title);
3378 let _ = github::persist_issue_state(repo, branch, issue.state);
3379 }
3380 if let Some(pr) = &pr {
3381 let _ = match link.pr_source {
3382 LinkSource::Detected => github::persist_detected_pr_title(repo, branch, &pr.title)
3383 .and_then(|()| github::persist_detected_pr_state(repo, branch, pr.state)),
3384 LinkSource::Explicit => github::persist_pr_title(repo, branch, &pr.title)
3385 .and_then(|()| github::persist_pr_state(repo, branch, pr.state)),
3386 LinkSource::BranchName | LinkSource::None => Ok(()),
3387 };
3388 }
3389 (issue, pr)
3390}
3391
3392fn issue_state_str(s: IssueState) -> &'static str {
3393 match s {
3394 IssueState::Open => "open",
3395 IssueState::Closed => "closed",
3396 }
3397}
3398
3399fn pr_state_str(s: PrState) -> &'static str {
3400 match s {
3401 PrState::Open => "open",
3402 PrState::Draft => "draft",
3403 PrState::Closed => "closed",
3404 PrState::Merged => "merged",
3405 }
3406}
3407
3408fn link_source_str(s: LinkSource) -> &'static str {
3409 match s {
3410 LinkSource::None => "none",
3411 LinkSource::BranchName => "branch-name",
3412 LinkSource::Explicit => "explicit",
3413 LinkSource::Detected => "detected",
3414 }
3415}
3416
3417fn print_status_human(
3418 branch: &str,
3419 slug: Option<&str>,
3420 link: &BranchLink,
3421 issue: &Option<IssueStatus>,
3422 pr: &Option<PrStatus>,
3423) {
3424 println!("branch: {}", branch);
3425 if let Some(s) = slug {
3426 println!("repo: {}", s);
3427 }
3428 println!("link: {}", link.summary());
3429
3430 if let Some(n) = link.issue {
3431 print!("issue: #{}", n);
3432 match issue {
3433 Some(s) => println!(" [{}] {}", issue_state_str(s.state), s.title),
3434 None => println!(" (status unavailable)"),
3435 }
3436 }
3437 if let Some(n) = link.pr {
3438 print!("pr: #{}", n);
3439 match pr {
3440 Some(s) => {
3441 let checks = if s.checks_total > 0 {
3442 format!(" · checks {}/{}", s.checks_passed, s.checks_total)
3443 } else {
3444 String::new()
3445 };
3446 println!(" [{}]{} {}", pr_state_str(s.state), checks, s.title);
3447 }
3448 None => println!(" (status unavailable)"),
3449 }
3450 }
3451}
3452
3453pub fn build_status_json(
3459 branch: &str,
3460 slug: Option<&str>,
3461 link: &BranchLink,
3462 issue: &Option<IssueStatus>,
3463 pr: &Option<PrStatus>,
3464) -> serde_json::Value {
3465 let mut obj = serde_json::Map::new();
3466 obj.insert("branch".into(), serde_json::Value::String(branch.into()));
3467 if let Some(s) = slug {
3468 obj.insert("repo".into(), serde_json::Value::String(s.into()));
3469 }
3470 obj.insert(
3471 "issue".into(),
3472 match link.issue {
3473 Some(n) => {
3474 let mut o = serde_json::Map::new();
3475 o.insert("number".into(), serde_json::Value::Number(n.into()));
3476 o.insert(
3477 "source".into(),
3478 serde_json::Value::String(link_source_str(link.issue_source).into()),
3479 );
3480 if let Some(s) = issue {
3481 o.insert(
3482 "state".into(),
3483 serde_json::Value::String(issue_state_str(s.state).into()),
3484 );
3485 o.insert("title".into(), serde_json::Value::String(s.title.clone()));
3486 o.insert(
3487 "labels".into(),
3488 serde_json::Value::Array(s.labels.iter().map(|l| serde_json::Value::String(l.clone())).collect()),
3489 );
3490 o.insert("url".into(), serde_json::Value::String(s.url.clone()));
3491 }
3492 serde_json::Value::Object(o)
3493 }
3494 None => serde_json::Value::Null,
3495 },
3496 );
3497 obj.insert(
3498 "pr".into(),
3499 match link.pr {
3500 Some(n) => {
3501 let mut o = serde_json::Map::new();
3502 o.insert("number".into(), serde_json::Value::Number(n.into()));
3503 o.insert(
3504 "source".into(),
3505 serde_json::Value::String(link_source_str(link.pr_source).into()),
3506 );
3507 if let Some(s) = pr {
3508 o.insert("state".into(), serde_json::Value::String(pr_state_str(s.state).into()));
3509 o.insert("title".into(), serde_json::Value::String(s.title.clone()));
3510 o.insert(
3511 "checks_passed".into(),
3512 serde_json::Value::Number(s.checks_passed.into()),
3513 );
3514 o.insert("checks_total".into(), serde_json::Value::Number(s.checks_total.into()));
3515 o.insert("url".into(), serde_json::Value::String(s.url.clone()));
3516 }
3517 serde_json::Value::Object(o)
3518 }
3519 None => serde_json::Value::Null,
3520 },
3521 );
3522 serde_json::Value::Object(obj)
3523}
3524
3525fn cmd_labels(action: LabelsAction) -> Result<()> {
3528 match action {
3529 LabelsAction::List => cmd_labels_list(),
3530 LabelsAction::Push {
3531 dry_run,
3532 prune,
3533 random_colors,
3534 } => cmd_labels_push(dry_run, prune, random_colors),
3535 }
3536}
3537
3538fn cmd_labels_list() -> Result<()> {
3539 let config = load_labels_config()?;
3540 if config.labels.is_empty() {
3541 println!("0 labels declared in .gwm.toml — nothing to push.");
3542 return Ok(());
3543 }
3544 let declared = labels::resolve_labels(&config.labels, false)?;
3548 let slug = labels_slug()?;
3549 let remote = github::fetch_remote_labels(&slug)?;
3550 let diff = labels::diff_labels(&declared, &remote);
3551 print_labels_diff(&slug, &declared, &diff);
3552 Ok(())
3553}
3554
3555fn cmd_labels_push(dry_run: bool, prune: bool, random_colors: bool) -> Result<()> {
3556 let config = load_labels_config()?;
3557 if config.labels.is_empty() {
3558 println!("0 labels declared in .gwm.toml — nothing to push.");
3559 return Ok(());
3560 }
3561 let declared = labels::resolve_labels(&config.labels, random_colors)?;
3562 let slug = labels_slug()?;
3563 let remote = github::fetch_remote_labels(&slug)?;
3564 let diff = labels::diff_labels(&declared, &remote);
3565 let (n_create, n_update, n_match, n_extra) = diff.counts();
3566
3567 if dry_run {
3568 print_labels_diff(&slug, &declared, &diff);
3569 let pruned = if prune { n_extra } else { 0 };
3570 println!(
3571 "{}",
3572 labels::diff_dry_run_line(n_create, n_update, n_match, n_extra, pruned)
3573 );
3574 return Ok(());
3575 }
3576
3577 for spec in &diff.to_create {
3578 github::push_label(&slug, spec)?;
3579 println!("✓ created {}", spec.name);
3580 }
3581 for upd in &diff.to_update {
3582 github::push_label(&slug, &upd.spec)?;
3583 println!("✓ updated {}", upd.spec.name);
3584 }
3585 if prune {
3586 for remote_label in &diff.extra_on_remote {
3587 github::delete_label(&slug, &remote_label.name)?;
3588 println!("✗ pruned {}", remote_label.name);
3589 }
3590 } else if !diff.extra_on_remote.is_empty() {
3591 println!(
3592 "{} label(s) on remote not in config — pass --prune to delete",
3593 diff.extra_on_remote.len()
3594 );
3595 }
3596 println!("{} label(s) untouched", n_match);
3597 Ok(())
3598}
3599
3600fn load_labels_config() -> Result<Config> {
3604 Ok(repo_context(None)?.config)
3605}
3606
3607fn labels_slug() -> Result<String> {
3612 let repo = worktree::discover_repo(None)?;
3613 github::repo_slug(&repo)
3614}
3615
3616fn print_labels_diff(slug: &str, declared: &[labels::LabelSpec], diff: &LabelDiff) {
3617 let (n_create, n_update, n_match, n_extra) = diff.counts();
3618 println!(
3619 "declared in .gwm.toml: {} labels — diff against {}:",
3620 declared.len(),
3621 slug
3622 );
3623 for spec in &diff.to_create {
3624 println!(" + {:<20} (will create — color #{})", spec.name, spec.color);
3625 }
3626 for upd in &diff.to_update {
3627 let detail = match (&upd.previous_color, &upd.previous_description) {
3628 (Some(old), _) => format!("color #{} → #{}", old, upd.spec.color),
3629 (None, Some(_)) => "description changed".into(),
3630 _ => "diff".into(),
3631 };
3632 println!(" ~ {:<20} ({})", upd.spec.name, detail);
3633 }
3634 for spec in &diff.matching {
3635 println!(" = {:<20} (match)", spec.name);
3636 }
3637 for remote in &diff.extra_on_remote {
3638 println!(" - {:<20} (on remote, not in config)", remote.name);
3639 }
3640 println!("{}", labels::diff_summary_line(n_create, n_update, n_match, n_extra));
3641}
3642
3643fn cmd_milestones(action: MilestonesAction) -> Result<()> {
3646 match action {
3647 MilestonesAction::List => cmd_milestones_list(),
3648 MilestonesAction::Push { dry_run, prune } => cmd_milestones_push(dry_run, prune),
3649 }
3650}
3651
3652fn cmd_milestones_list() -> Result<()> {
3653 let config = load_milestones_config()?;
3654 if config.milestones.is_empty() {
3655 println!("0 milestones declared in .gwm.toml — nothing to push.");
3656 return Ok(());
3657 }
3658 let declared = milestones::resolve_milestones(&config.milestones)?;
3662 let slug = milestones_slug()?;
3663 let remote = github::fetch_remote_milestones(&slug)?;
3664 let diff = milestones::diff_milestones(&declared, &remote);
3665 print_milestones_diff(&slug, &declared, &diff);
3666 Ok(())
3667}
3668
3669fn cmd_milestones_push(dry_run: bool, prune: bool) -> Result<()> {
3670 let config = load_milestones_config()?;
3671 if config.milestones.is_empty() {
3672 println!("0 milestones declared in .gwm.toml — nothing to push.");
3673 return Ok(());
3674 }
3675 let declared = milestones::resolve_milestones(&config.milestones)?;
3676 let slug = milestones_slug()?;
3677 let remote = github::fetch_remote_milestones(&slug)?;
3678 let diff = milestones::diff_milestones(&declared, &remote);
3679 let (n_create, n_update, n_match, n_extra) = diff.counts();
3680
3681 if dry_run {
3682 print_milestones_diff(&slug, &declared, &diff);
3683 let pruned = if prune { n_extra } else { 0 };
3684 println!(
3685 "{}",
3686 labels::diff_dry_run_line(n_create, n_update, n_match, n_extra, pruned)
3687 );
3688 return Ok(());
3689 }
3690
3691 for spec in &diff.to_create {
3692 github::create_milestone(&slug, spec)?;
3693 println!("✓ created {}", spec.title);
3694 }
3695 for upd in &diff.to_update {
3696 github::update_milestone(&slug, upd.number, &upd.spec)?;
3697 println!("✓ updated {}", upd.spec.title);
3698 }
3699 if prune {
3700 for remote_milestone in &diff.extra_on_remote {
3701 github::delete_milestone(&slug, remote_milestone.number)?;
3702 println!("✗ pruned {}", remote_milestone.title);
3703 }
3704 } else if !diff.extra_on_remote.is_empty() {
3705 println!(
3706 "{} milestone(s) on remote not in config — pass --prune to delete",
3707 diff.extra_on_remote.len()
3708 );
3709 }
3710 println!("{} milestone(s) untouched", n_match);
3711 Ok(())
3712}
3713
3714fn load_milestones_config() -> Result<Config> {
3718 Ok(repo_context(None)?.config)
3719}
3720
3721fn milestones_slug() -> Result<String> {
3726 let repo = worktree::discover_repo(None)?;
3727 github::repo_slug(&repo)
3728}
3729
3730fn print_milestones_diff(slug: &str, declared: &[milestones::MilestoneSpec], diff: &MilestoneDiff) {
3731 let (n_create, n_update, n_match, n_extra) = diff.counts();
3732 println!(
3733 "declared in .gwm.toml: {} milestones — diff against {}:",
3734 declared.len(),
3735 slug
3736 );
3737 for spec in &diff.to_create {
3738 let due = spec.due_on.as_deref().unwrap_or("no due date");
3739 println!(
3740 " + {:<20} (will create — state {}, due {})",
3741 spec.title,
3742 spec.state.as_str(),
3743 due
3744 );
3745 }
3746 for upd in &diff.to_update {
3747 let detail = match (&upd.previous_due_on, &upd.previous_state, &upd.previous_description) {
3748 (Some(old_due), _, _) => format!("due {} → {}", old_due, upd.spec.due_on.as_deref().unwrap_or("cleared")),
3749 (None, Some(old_state), _) => format!("state {} → {}", old_state.as_str(), upd.spec.state.as_str()),
3750 (None, None, Some(_)) => "description changed".into(),
3751 _ => "diff".into(),
3752 };
3753 println!(" ~ {:<20} ({})", upd.spec.title, detail);
3754 }
3755 for spec in &diff.matching {
3756 println!(" = {:<20} (match)", spec.title);
3757 }
3758 for remote in &diff.extra_on_remote {
3759 println!(" - {:<20} (#{} on remote, not in config)", remote.title, remote.number);
3760 }
3761 println!("{}", labels::diff_summary_line(n_create, n_update, n_match, n_extra));
3762}
3763
3764fn cmd_trust(action: TrustAction) -> Result<()> {
3767 match action {
3768 TrustAction::List => cmd_trust_list(),
3769 TrustAction::Revoke { origin } => cmd_trust_revoke(origin),
3770 TrustAction::Show => cmd_trust_show(),
3771 }
3772}
3773
3774fn cmd_trust_list() -> Result<()> {
3775 let path = trust::default_ledger_path()?;
3776 let ledger = TrustLedger::load(&path)?;
3777 if ledger.entries.is_empty() {
3778 println!("0 entries in trust ledger ({}).", path.display());
3779 return Ok(());
3780 }
3781 println!("trust ledger: {}", path.display());
3782 println!(
3783 " {} entr{} recorded:",
3784 ledger.entries.len(),
3785 if ledger.entries.len() == 1 { "y" } else { "ies" }
3786 );
3787 let origin_w = ledger
3788 .entries
3789 .iter()
3790 .map(|e| e.origin.len())
3791 .max()
3792 .unwrap_or(6)
3793 .clamp(6, 60);
3794 for e in &ledger.entries {
3795 let short_sha: String = e.config_sha.chars().take(12).collect();
3801 println!(
3802 " {:<ow$} {} trusted_at {} by {}",
3803 e.origin,
3804 short_sha,
3805 e.trusted_at.to_rfc3339(),
3806 e.trusted_by,
3807 ow = origin_w,
3808 );
3809 }
3810 Ok(())
3811}
3812
3813fn cmd_trust_revoke(origin: String) -> Result<()> {
3814 let path = trust::default_ledger_path()?;
3815 let mut ledger = TrustLedger::load(&path)?;
3816 let removed = ledger.revoke(&origin);
3817 if removed == 0 {
3818 println!("0 entries matched origin {} (nothing to revoke).", origin);
3819 return Ok(());
3820 }
3821 ledger.save(&path)?;
3822 println!(
3823 "✓ revoked {} entr{} for {}",
3824 removed,
3825 if removed == 1 { "y" } else { "ies" },
3826 origin
3827 );
3828 Ok(())
3829}
3830
3831fn cmd_trust_show() -> Result<()> {
3832 let path = trust::default_ledger_path()?;
3833 println!("ledger path: {}", path.display());
3834 match std::fs::read_to_string(&path) {
3835 Ok(body) => {
3836 println!("---");
3837 print!("{}", body);
3838 if !body.ends_with('\n') {
3839 println!();
3840 }
3841 }
3842 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
3843 println!("(file does not exist yet — nothing has been trusted on this machine)");
3844 }
3845 Err(e) => return Err(e.into()),
3846 }
3847 Ok(())
3848}
3849
3850fn trust_or_prompt(workdir: &Path, repo: Option<&Repository>, mode: TrustMode) -> Result<()> {
3867 let origin = origin_url_for_repo(repo);
3868 let origin_key = trust::resolve_origin_key(origin.as_deref(), workdir);
3869
3870 match trust::evaluate(workdir, &origin_key, mode)? {
3871 TrustOutcome::Proceed => Ok(()),
3872 TrustOutcome::Refuse { message } => Err(GwmError::Other(message)),
3873 TrustOutcome::Prompt {
3874 cfg_path,
3875 body,
3876 sha,
3877 origin,
3878 mut ledger,
3879 ledger_path,
3880 } => {
3881 use std::io::IsTerminal;
3885 if !std::io::stdin().is_terminal() {
3886 return Err(GwmError::Other(format!(
3887 ".gwm.toml at {} is not in the trust ledger and stdin is not interactive — \
3888 pass --allow-bootstrap (or set GWM_ALLOW_BOOTSTRAP=1) to bypass, \
3889 or run interactively to approve",
3890 cfg_path.display()
3891 )));
3892 }
3893
3894 let granted = prompt_user(&cfg_path, &body, &origin, &sha)?;
3895 if !granted {
3896 return Err(GwmError::Other(format!(
3897 "trust prompt declined for {} — aborting bootstrap",
3898 cfg_path.display()
3899 )));
3900 }
3901
3902 ledger.record(&origin, &sha, &trust::current_actor());
3903 ledger.save(&ledger_path)?;
3904 println!("✓ recorded trust for {} in {}", origin, ledger_path.display());
3905 Ok(())
3906 }
3907 }
3908}
3909
3910fn origin_url_for_repo(repo: Option<&Repository>) -> Option<String> {
3915 let r = repo?;
3916 let remote = r.find_remote("origin").ok()?;
3917 remote.url().ok().map(|s| s.to_string())
3918}
3919
3920fn prompt_user(cfg_path: &Path, bytes: &[u8], origin: &str, sha: &str) -> Result<bool> {
3925 use std::io::{BufRead, Write};
3926
3927 let body = String::from_utf8_lossy(bytes);
3928 let parsed: Option<Config> = toml::from_str(&body).ok();
3929 let stdin = std::io::stdin();
3930 let mut stdout = std::io::stdout();
3931
3932 println!();
3933 println!("gwm: this repo's .gwm.toml has not been trusted yet.");
3934 println!(" path : {}", cfg_path.display());
3935 println!(" origin : {}", origin);
3936 println!(" hash : {}", sha);
3937 if let Some(cfg) = parsed.as_ref() {
3938 print_bootstrap_summary(cfg);
3939 } else {
3940 println!(" (could not parse .gwm.toml for summary — see raw via `show` below)");
3941 }
3942 println!();
3943
3944 loop {
3945 print!("Trust this .gwm.toml? [y/N/show]: ");
3946 stdout.flush().ok();
3947 let mut line = String::new();
3948 let n = stdin.lock().read_line(&mut line)?;
3949 if n == 0 {
3950 return Ok(false);
3952 }
3953 match line.trim().to_ascii_lowercase().as_str() {
3954 "y" | "yes" => return Ok(true),
3955 "n" | "no" | "" => return Ok(false),
3956 "show" | "s" => {
3957 println!("---");
3958 print!("{}", body);
3959 if !body.ends_with('\n') {
3960 println!();
3961 }
3962 println!("---");
3963 }
3964 other => {
3965 println!("unrecognised answer '{}' — answer y, N, or show", other);
3966 }
3967 }
3968 }
3969}
3970
3971fn print_bootstrap_summary(cfg: &Config) {
3972 let bs = &cfg.bootstrap;
3973 if bs.copy.is_empty() && bs.command.is_empty() && bs.guard.is_empty() && bs.no_symlink.is_empty() {
3974 println!(" bootstrap surface: (empty — no copies/commands/guards/no_symlinks declared)");
3975 return;
3976 }
3977 println!(" bootstrap surface:");
3978 for c in &bs.copy {
3979 println!(" - copy {} → {}", c.from, c.to);
3980 }
3981 for g in &bs.guard {
3982 println!(
3983 " - guard {} (on_match={}, deny={} pattern(s))",
3984 g.name,
3985 g.on_match,
3986 g.deny_patterns.len()
3987 );
3988 }
3989 for ns in &bs.no_symlink {
3990 println!(" - no-symlink {}", ns.path);
3991 }
3992 for c in &bs.command {
3993 println!(" - run {} ({})", c.name, c.run);
3994 }
3995}
3996
3997fn cmd_aliases(action: AliasesAction) -> Result<()> {
4000 match action {
4001 AliasesAction::List => cmd_aliases_list(),
4002 }
4003}
4004
4005fn cmd_config(action: ConfigAction) -> Result<()> {
4006 match action {
4007 ConfigAction::Get { key } => config_cli::get(&key),
4008 ConfigAction::Set { key, value } => config_cli::set(&key, value.as_deref()),
4009 ConfigAction::Unset { key } => config_cli::unset(&key),
4010 ConfigAction::List { prefix } => config_cli::list(prefix.as_deref()),
4011 ConfigAction::Validate => config_cli::validate(),
4012 ConfigAction::Path => config_cli::path(),
4013 ConfigAction::Edit => config_cli::edit(),
4014 }
4015}
4016
4017fn cmd_aliases_list() -> Result<()> {
4035 let repo_workdir: Option<PathBuf> = crate::worktree::discover_repo(None)
4041 .ok()
4042 .and_then(|r| r.workdir().map(|w| w.to_path_buf()));
4043
4044 let resolved = crate::aliases::load(repo_workdir.as_deref(), None)?;
4045
4046 println!("built-in:");
4048 if resolved.built_in.is_empty() {
4049 println!(" (none)");
4050 } else {
4051 let width = resolved.built_in.iter().map(|e| e.name.len()).max().unwrap_or(2).max(2);
4052 for e in &resolved.built_in {
4053 println!(" {:<width$} → {}", e.name, e.expansion, width = width);
4054 }
4055 }
4056
4057 println!("repo (.gwm.toml):");
4059 if repo_workdir.is_none() {
4060 println!(" (not inside a git repository — repo aliases are read from <repo>/.gwm.toml)");
4061 } else if resolved.repo.is_empty() {
4062 println!(" (none declared)");
4063 } else {
4064 let width = resolved.repo.keys().map(|k| k.len()).max().unwrap_or(2).max(2);
4065 for (name, expansion) in &resolved.repo {
4066 println!(" {:<width$} → {}", name, expansion, width = width);
4067 }
4068 }
4069
4070 println!("user (~/.config/gwm/aliases.toml):");
4076 if resolved.user.is_empty() {
4077 println!(" (none declared)");
4078 } else {
4079 let width = resolved.user.keys().map(|k| k.len()).max().unwrap_or(2).max(2);
4080 for (name, expansion) in &resolved.user {
4081 let shadowed = resolved.repo.contains_key(name);
4084 let suffix = if shadowed { " (shadowed by repo)" } else { "" };
4085 println!(" {:<width$} → {}{}", name, expansion, suffix, width = width);
4086 }
4087 }
4088
4089 Ok(())
4090}
4091
4092pub fn shell_init_script(shell: InitShell) -> &'static str {
4093 match shell {
4094 InitShell::Bash | InitShell::Zsh => POSIX_SHELL_INIT,
4095 InitShell::Fish => FISH_SHELL_INIT,
4096 InitShell::Powershell => POWERSHELL_SHELL_INIT,
4097 }
4098}
4099
4100const POSIX_SHELL_INIT: &str = r#"# gwm shell helper — wraps `gwm cd` / `gwm switch` so the parent shell can cd.
4101# Install: eval "$(gwm shell-init bash)" # or zsh
4102#
4103# Two paths:
4104# gcd <pattern> # fuzzy resolve via `gwm cd <pattern>`, then cd
4105# gcd # no arg → opens the interactive picker via `gwm switch`, then cd
4106#
4107# Note: the `function name { ... }` form (zsh/bash-extended) is used instead
4108# of the parenthesised POSIX form so the parser does not error out with
4109# `defining function based on alias 'gcd'` when zsh already has a `gcd`
4110# alias (e.g. oh-my-zsh's `gcd=git checkout`). The `unalias` after the
4111# definition is what makes the function reachable at call time, since zsh
4112# still resolves the alias first when both exist.
4113function gcd {
4114 local target
4115 if [ "$#" -eq 0 ]; then
4116 # No arg → open the interactive picker. `gwm switch` exits non-zero on
4117 # cancel, in which case `gcd` must NOT attempt the `cd` (would land in $HOME).
4118 target="$(command gwm switch)" || return $?
4119 else
4120 target="$(command gwm cd "$@")" || return $?
4121 fi
4122 cd "$target" || return $?
4123}
4124unalias gcd 2>/dev/null || true
4125"#;
4126
4127const FISH_SHELL_INIT: &str = r#"# gwm shell helper — wraps `gwm cd` / `gwm switch` so the parent shell can cd.
4128# Install: gwm shell-init fish | source # then persist in ~/.config/fish/config.fish
4129#
4130# Two paths:
4131# gcd <pattern> # fuzzy resolve via `gwm cd <pattern>`, then cd
4132# gcd # no arg → opens the interactive picker via `gwm switch`, then cd
4133function gcd --description 'cd into a gwm worktree (no arg = interactive picker)'
4134 set -l target
4135 if test (count $argv) -eq 0
4136 # No arg → open the interactive picker; cancel exits non-zero, in which
4137 # case we must NOT attempt the cd (would land in $HOME).
4138 set target (command gwm switch)
4139 or return $status
4140 else
4141 set target (command gwm cd $argv)
4142 or return $status
4143 end
4144 # `--` stops option parsing, "$target" prevents wildcard expansion on
4145 # paths containing `[`, `]`, or `*`.
4146 cd -- "$target"
4147end
4148"#;
4149
4150const POWERSHELL_SHELL_INIT: &str = r#"# gwm shell helper — wraps `gwm cd` / `gwm switch` so the parent shell can cd.
4151# Install: Invoke-Expression (& gwm shell-init powershell | Out-String)
4152#
4153# Two paths:
4154# gcd <pattern> # fuzzy resolve via `gwm cd <pattern>`, then Set-Location
4155# gcd # no arg → opens the interactive picker via `gwm switch`, then Set-Location
4156#
4157# Note: this clears any prior `gcd` alias so the function takes effect.
4158Remove-Alias -Name gcd -Force -ErrorAction SilentlyContinue
4159function gcd {
4160 param([string]$Pattern)
4161 if ([string]::IsNullOrEmpty($Pattern)) {
4162 # No arg → open the interactive picker. The binary exits non-zero on
4163 # cancel; bail out before attempting Set-Location so we don't land in $HOME.
4164 $target = & gwm switch
4165 } else {
4166 $target = & gwm cd $Pattern
4167 }
4168 if ($LASTEXITCODE -ne 0) { return }
4169 Set-Location $target
4170}
4171"#;
4172
4173fn print_report(report: &bootstrap::BootstrapReport) {
4174 if report.steps.is_empty() {
4175 return;
4176 }
4177 println!();
4178 println!("bootstrap report:");
4179 for s in &report.steps {
4180 let sigil = s.status.sigil();
4181 println!(" {} {}", sigil, s.label);
4182 if !s.detail.is_empty() {
4183 for line in s.detail.lines() {
4184 println!(" {}", line);
4185 }
4186 }
4187 }
4188}
4189
4190fn print_lifecycle_report(report: &bootstrap::BootstrapReport) {
4191 if report.steps.is_empty() {
4192 return;
4193 }
4194 lifecycle::print_report(report);
4195}
4196
4197fn current_repo_root() -> Result<PathBuf> {
4207 let repo = worktree::discover_repo(None)?;
4208 let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
4209 Ok(std::fs::canonicalize(&workdir).unwrap_or(workdir))
4210}
4211
4212pub fn format_history_row(entry: &OpEntry, now: chrono::DateTime<chrono::Utc>) -> String {
4217 let delta = (now - entry.ts).to_std().unwrap_or(std::time::Duration::from_secs(0));
4218 let ago = worktree::format_relative_duration(delta);
4219 let suffix = if entry.undone { " (undone)" } else { "" };
4220 format!("{:<5} {:<7} {}{}", ago, entry.kind.as_str(), entry.worktree, suffix)
4221}
4222
4223fn cmd_history(limit: usize, all: bool) -> Result<()> {
4224 let path = history::default_journal_path()?;
4225 let journal = history::Journal::load(&path)?;
4226
4227 let root: Option<PathBuf> = if all { None } else { Some(current_repo_root()?) };
4233 let mut rows: Vec<&OpEntry> = match &root {
4234 Some(r) => journal.entries_for_repo(r).collect(),
4235 None => journal.entries().iter().collect(),
4236 };
4237
4238 if rows.is_empty() {
4243 println!("no operations recorded");
4244 return Ok(());
4245 }
4246 if limit == 0 {
4247 return Ok(());
4248 }
4249
4250 rows.sort_by_key(|e| std::cmp::Reverse(e.ts));
4252 rows.truncate(limit);
4253
4254 let now = chrono::Utc::now();
4255 for entry in rows {
4256 println!("{}", format_history_row(entry, now));
4257 }
4258 Ok(())
4259}
4260
4261fn cmd_undo(run_bootstrap: bool) -> Result<()> {
4262 let path = history::default_journal_path()?;
4263 let mut journal = history::Journal::load(&path)?;
4264 let root = current_repo_root()?;
4265
4266 let Some(entry) = journal.pop_last_for_repo(&root) else {
4267 return Err(GwmError::Other(format!(
4268 "nothing to undo for {} — the journal is empty for this repo",
4269 root.display()
4270 )));
4271 };
4272
4273 let repo = worktree::discover_repo(None)?;
4274
4275 if let (Some(branch_name), Some(oid_hex)) = (&entry.branch, &entry.branch_oid) {
4281 let oid = git2::Oid::from_str(oid_hex)
4282 .map_err(|e| GwmError::Other(format!("journal entry has invalid branch_oid '{}': {}", oid_hex, e)))?;
4283 if repo.find_branch(branch_name, git2::BranchType::Local).is_err() {
4284 repo
4285 .reference(
4286 &format!("refs/heads/{}", branch_name),
4287 oid,
4288 false,
4289 "gwm undo: resurrect branch",
4290 )
4291 .map_err(|e| {
4292 GwmError::Other(format!(
4293 "failed to recreate branch {} at {}: {}",
4294 branch_name, oid_hex, e
4295 ))
4296 })?;
4297 println!(
4298 "✓ recreated branch {} at {}",
4299 branch_name,
4300 &oid_hex[..oid_hex.len().min(8)]
4301 );
4302 } else {
4303 println!("· branch {} already exists — skipping resurrection", branch_name);
4304 }
4305 }
4306
4307 let branch_name = entry.branch.as_deref().ok_or_else(|| {
4323 GwmError::Other(format!(
4324 "cannot undo remove of detached-HEAD worktree {} — only branch-attached worktrees are supported today",
4325 entry.worktree
4326 ))
4327 })?;
4328 worktree::add(&repo, &entry.worktree, &entry.path, branch_name, true)?;
4331 println!("✓ re-added worktree at {}", entry.path.display());
4332
4333 journal.save(&path)?;
4337
4338 if run_bootstrap {
4340 let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
4341 let config = Config::load_for_repo(&workdir)?;
4342 let ctx = BootstrapCtx {
4343 main_repo: &workdir,
4344 worktree: &entry.path,
4345 config: &config,
4346 };
4347 let report = bootstrap::run(&ctx)?;
4348 print_report(&report);
4349 } else {
4350 println!("(skipped re-bootstrap; pass --bootstrap to run it)");
4351 }
4352
4353 Ok(())
4354}