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::forge;
9use crate::github::{self, BranchLink, IssueState, IssueStatus, LinkSource, PrState, PrStatus};
10use crate::gitmoji;
11use crate::history::{self, OpEntry};
12use crate::hooks;
13use crate::issue_templates;
14use crate::json_api;
15use crate::labels::{self, LabelDiff};
16use crate::lifecycle::{self, HookContext, HookPhase, HookSkips};
17use crate::milestones::{self, MilestoneDiff};
18use crate::multiplexer::{
19 build_tmux_command, build_zellij_command, detect_tmux, detect_zellij, Multiplexer, SpawnMode,
20};
21use crate::naming::{BranchSpec, WorktreeName};
22use crate::pr_templates::{self, PrTemplateContext};
23use crate::presets;
24use crate::review;
25use crate::sync::{self, SyncAction, SyncReport, SyncStrategy};
26use crate::trust::{self, TrustLedger, TrustMode, TrustOutcome};
27use crate::workspace;
28use crate::worktree;
29use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
30use clap_complete::{generate, Shell};
31use git2::Repository;
32use std::io;
33use std::path::{Path, PathBuf};
34
35#[derive(Debug, Parser)]
36#[command(name = "gwm", version, about = "git worktree manager (TUI + CLI)")]
37pub struct Cli {
38 #[arg(long, global = true)]
46 pub allow_bootstrap: bool,
47
48 #[arg(long, global = true, conflicts_with = "allow_bootstrap")]
53 pub deny_bootstrap: bool,
54
55 #[arg(long, global = true, value_name = "DIR")]
65 pub workspace: Option<PathBuf>,
66
67 #[command(subcommand)]
68 pub command: Option<Command>,
69}
70
71#[derive(Debug, Clone, clap::Subcommand)]
73pub enum AgentsAction {
74 Attach {
78 pattern: String,
80 session_id: String,
82 },
83 Detach {
86 pattern: String,
88 session_id: Option<String>,
90 },
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
94pub enum AgentsFormat {
95 Table,
97 Json,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
103pub enum ListFormat {
104 Table,
106 Names,
108 Json,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
117pub enum OutputFormat {
118 Text,
120 Json,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
125pub enum InitShell {
126 Bash,
127 Zsh,
128 Fish,
129 Powershell,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
134pub enum LinkTarget {
135 Issue,
137 Pr,
139}
140
141#[derive(Debug, Subcommand)]
142pub enum Command {
143 Init {
145 #[arg(long, value_name = "NAME")]
149 preset: Option<String>,
150 #[arg(long)]
153 list_presets: bool,
154 #[arg(long)]
157 show: bool,
158 },
159 List {
161 #[arg(long, value_enum, default_value_t = ListFormat::Table)]
163 format: ListFormat,
164 #[arg(long)]
169 detect_pr: bool,
170 },
171 Agents {
176 #[command(subcommand)]
177 action: Option<AgentsAction>,
178 #[arg(long, value_enum, default_value_t = AgentsFormat::Table)]
180 format: AgentsFormat,
181 },
182 Create {
184 #[arg(required_unless_present = "name", conflicts_with = "name")]
186 branch_type: Option<String>,
187 #[arg(required_unless_present = "name", conflicts_with = "name")]
189 issue: Option<String>,
190 #[arg(required_unless_present = "name", conflicts_with = "name")]
192 desc: Option<String>,
193 #[arg(long, value_name = "NAME", conflicts_with_all = ["branch_type", "issue", "desc"])]
203 name: Option<String>,
204 #[arg(long)]
206 no_bootstrap: bool,
207 #[arg(long)]
212 reuse_branch: bool,
213 #[arg(long, value_name = "PHASES")]
215 skip_hooks: Option<String>,
216 #[arg(long, value_name = "NAME")]
221 repo: Option<String>,
222 },
223 Pr {
238 #[arg(long)]
241 render: bool,
242 #[arg(long, conflicts_with = "render")]
245 draft: bool,
246 #[arg(long, value_name = "REF")]
249 base: Option<String>,
250 },
251 Review {
266 #[arg()]
268 number: u64,
269 #[arg(long, value_name = "BRANCH")]
273 name: Option<String>,
274 #[arg(long)]
278 bootstrap: bool,
279 #[arg(long, value_name = "PHASES")]
281 skip_hooks: Option<String>,
282 },
283 New {
285 #[arg()]
287 branch_type: String,
288 #[arg()]
290 desc: String,
291 #[arg(long)]
293 no_bootstrap: bool,
294 #[arg(long)]
296 reuse_branch: bool,
297 #[arg(long, value_name = "PHASES")]
299 skip_hooks: Option<String>,
300 },
301 Remove {
303 pattern: String,
304 #[arg(long)]
306 delete_branch: bool,
307 #[arg(long)]
313 dry_run: bool,
314 #[arg(long)]
316 force: bool,
317 #[arg(long, value_name = "PHASES")]
319 skip_hooks: Option<String>,
320 },
321 #[command(visible_alias = "cd")]
326 Path {
327 pattern: String,
328 #[arg(long, value_enum, default_value_t = OutputFormat::Text)]
331 format: OutputFormat,
332 },
333 Bootstrap {
335 target: Option<String>,
337 #[arg(long, value_name = "PHASES")]
339 skip_hooks: Option<String>,
340 },
341 Sync {
354 pattern: Option<String>,
356 #[arg(long)]
358 merge: bool,
359 },
360 Prune {
362 #[arg(long)]
367 dry_run: bool,
368 },
369 Doctor {
374 #[arg(long, value_enum, default_value_t = OutputFormat::Text)]
379 format: OutputFormat,
380 },
381 Daemon {
393 #[arg(long, value_name = "PATH")]
401 socket: Option<PathBuf>,
402 #[arg(long, value_name = "MS", default_value_t = 1000, value_parser = clap::value_parser!(u64).range(1..))]
408 poll_ms: u64,
409 },
410 Statusline {
425 #[arg(long, value_name = "PATH")]
431 socket: Option<PathBuf>,
432 #[arg(long)]
435 watch: bool,
436 },
437 Types {
444 #[arg(long)]
448 gitmoji: bool,
449 },
450 CommitPrefix {
462 #[arg(long)]
465 branch: Option<String>,
466 #[arg(long)]
469 unicode: bool,
470 },
471 Hooks {
478 #[command(subcommand)]
479 action: HooksAction,
480 },
481 Completions {
487 #[arg(value_enum)]
489 shell: Shell,
490 },
491 ShellInit {
498 #[arg(value_enum)]
500 shell: InitShell,
501 },
502 #[command(visible_alias = "s")]
513 Switch,
514 Tmux {
522 pattern: String,
524 #[arg(short = 'p', long = "split")]
526 split: bool,
527 },
528 Zellij {
534 pattern: String,
536 #[arg(short = 'p', long = "split")]
538 split: bool,
539 },
540 Link {
549 #[arg(value_enum)]
551 target: LinkTarget,
552 number: u64,
554 #[arg(long)]
556 worktree: Option<String>,
557 },
558 Unlink {
564 #[arg(value_enum)]
566 target: LinkTarget,
567 #[arg(long)]
569 worktree: Option<String>,
570 },
571 Open {
577 #[arg(value_enum)]
579 target: LinkTarget,
580 #[arg(long)]
582 worktree: Option<String>,
583 #[arg(long)]
585 print_url: bool,
586 },
587 Status {
593 #[arg(long)]
595 worktree: Option<String>,
596 #[arg(long)]
598 json: bool,
599 },
600 Labels {
607 #[command(subcommand)]
608 action: LabelsAction,
609 },
610 Milestones {
618 #[command(subcommand)]
619 action: MilestonesAction,
620 },
621 Trust {
632 #[command(subcommand)]
633 action: TrustAction,
634 },
635 Aliases {
644 #[command(subcommand)]
645 action: AliasesAction,
646 },
647 Config {
649 #[command(subcommand)]
650 action: ConfigAction,
651 },
652 History {
661 #[arg(long, default_value_t = 20)]
663 limit: usize,
664 #[arg(long)]
667 all: bool,
668 },
669 Undo {
678 #[arg(long)]
681 bootstrap: bool,
682 },
683 Tui {
691 #[command(subcommand)]
692 action: TuiAction,
693 },
694 Theme {
700 #[command(subcommand)]
701 action: ThemeAction,
702 },
703 Exec {
712 #[arg(value_name = "SLUG")]
715 slugs: Vec<String>,
716 #[arg(long, value_name = "NAME")]
720 profile: Option<String>,
721 #[arg(long, value_name = "N")]
726 jobs: Option<u32>,
727 #[arg(last = true, allow_hyphen_values = true, value_name = "CMD")]
731 command: Vec<String>,
732 },
733 Clean {
745 #[arg(value_name = "SLUG")]
747 slugs: Vec<String>,
748 #[arg(long, value_name = "NAME")]
753 profile: Option<String>,
754 #[arg(long)]
756 yes: bool,
757 },
758}
759
760#[derive(Debug, Subcommand)]
762pub enum ThemeAction {
763 List,
765 Show {
767 name: String,
769 },
770}
771
772#[derive(Debug, Subcommand)]
774pub enum TuiAction {
775 Keys,
792}
793
794#[derive(Debug, Subcommand)]
799pub enum AliasesAction {
800 List,
809}
810
811#[derive(Debug, Subcommand)]
813pub enum ConfigAction {
814 Get {
816 key: String,
818 },
819 Set {
821 key: String,
823 value: Option<String>,
825 },
826 Unset {
828 key: String,
830 },
831 List {
833 #[arg(long)]
835 prefix: Option<String>,
836 },
837 Validate,
839 Path,
841 Edit,
843}
844
845#[derive(Debug, Subcommand)]
850pub enum HooksAction {
851 Install {
856 #[arg(value_enum)]
858 hook: HookKind,
859 #[arg(long)]
863 force: bool,
864 },
865}
866
867#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
873pub enum HookKind {
874 CommitMsg,
877}
878
879#[derive(Debug, Subcommand)]
883pub enum LabelsAction {
884 List,
891 Push {
902 #[arg(long)]
906 dry_run: bool,
907 #[arg(long)]
910 prune: bool,
911 #[arg(long)]
914 random_colors: bool,
915 },
916}
917
918#[derive(Debug, Subcommand)]
922pub enum TrustAction {
923 Add,
936 List,
943 Revoke {
948 origin: String,
952 },
953 Show,
961}
962
963#[derive(Debug, Subcommand)]
967pub enum MilestonesAction {
968 List,
975 Push {
984 #[arg(long)]
988 dry_run: bool,
989 #[arg(long)]
992 prune: bool,
993 },
994}
995
996pub fn run(cli: Cli) -> Result<()> {
997 let mode = trust::resolve_mode(cli.allow_bootstrap, cli.deny_bootstrap);
1005
1006 let Some(cmd) = cli.command else {
1011 if let Some(root) = cli.workspace {
1014 return crate::tui::run_workspace(&root, mode);
1015 }
1016 if let Some(root) = autodetect_workspace_prompt()? {
1019 return crate::tui::run_workspace(&root, mode);
1020 }
1021 return crate::tui::run(mode);
1022 };
1023
1024 if cli.workspace.is_some()
1030 && !matches!(
1031 cmd,
1032 Command::List { .. } | Command::Create { .. } | Command::Exec { .. } | Command::Clean { .. }
1033 )
1034 {
1035 return Err(GwmError::WorkspaceUnsupportedCommand);
1036 }
1037
1038 match cmd {
1039 Command::Init {
1040 preset,
1041 list_presets,
1042 show,
1043 } => cmd_init(preset, list_presets, show),
1044 Command::List { format, detect_pr } => match cli.workspace {
1045 Some(root) => cmd_list_workspace(&root, format, detect_pr),
1046 None => cmd_list(format, detect_pr),
1047 },
1048 Command::Create {
1049 branch_type,
1050 issue,
1051 desc,
1052 name,
1053 no_bootstrap,
1054 reuse_branch,
1055 skip_hooks,
1056 repo,
1057 } => {
1058 let start = match &cli.workspace {
1059 Some(root) => Some(resolve_workspace_create_repo(root, repo)?),
1060 None => None,
1061 };
1062 cmd_create(
1063 branch_type,
1064 issue,
1065 desc,
1066 name,
1067 no_bootstrap,
1068 reuse_branch,
1069 skip_hooks,
1070 mode,
1071 start.as_deref(),
1072 )
1073 }
1074 Command::New {
1075 branch_type,
1076 desc,
1077 no_bootstrap,
1078 reuse_branch,
1079 skip_hooks,
1080 } => cmd_new(branch_type, desc, no_bootstrap, reuse_branch, skip_hooks, mode),
1081 Command::Pr { render, draft, base } => cmd_pr(render, draft, base),
1082 Command::Review {
1083 number,
1084 name,
1085 bootstrap,
1086 skip_hooks,
1087 } => cmd_review(number, name, bootstrap, skip_hooks, mode),
1088 Command::Remove {
1089 pattern,
1090 delete_branch,
1091 dry_run,
1092 force,
1093 skip_hooks,
1094 } => cmd_remove(pattern, delete_branch, dry_run, force, skip_hooks, mode),
1095 Command::Path { pattern, format } => cmd_path(pattern, format),
1096 Command::Bootstrap { target, skip_hooks } => cmd_bootstrap(target, skip_hooks, mode),
1097 Command::Sync { pattern, merge } => cmd_sync(pattern, merge),
1098 Command::Prune { dry_run } => cmd_prune(dry_run),
1099 Command::Agents { action, format } => cmd_agents(action, format),
1100 Command::Doctor { format } => cmd_doctor(format),
1101 Command::Daemon { socket, poll_ms } => cmd_daemon(socket, poll_ms),
1102 Command::Statusline { socket, watch } => cmd_statusline(socket, watch),
1103 Command::Types { gitmoji } => cmd_types(gitmoji),
1104 Command::CommitPrefix { branch, unicode } => cmd_commit_prefix(branch, unicode),
1105 Command::Hooks { action } => cmd_hooks(action),
1106 Command::Completions { shell } => cmd_completions(shell),
1107 Command::ShellInit { shell } => cmd_shell_init(shell),
1108 Command::Switch => cmd_switch(),
1109 Command::Tmux { pattern, split } => cmd_multiplexer(Multiplexer::Tmux, pattern, split),
1110 Command::Zellij { pattern, split } => cmd_multiplexer(Multiplexer::Zellij, pattern, split),
1111 Command::Link {
1112 target,
1113 number,
1114 worktree,
1115 } => cmd_link(target, number, worktree),
1116 Command::Unlink { target, worktree } => cmd_unlink(target, worktree),
1117 Command::Open {
1118 target,
1119 worktree,
1120 print_url,
1121 } => cmd_open(target, worktree, print_url),
1122 Command::Status { worktree, json } => cmd_status(worktree, json),
1123 Command::Labels { action } => cmd_labels(action),
1124 Command::Milestones { action } => cmd_milestones(action),
1125 Command::Trust { action } => cmd_trust(action),
1126 Command::Aliases { action } => cmd_aliases(action),
1127 Command::Config { action } => cmd_config(action),
1128 Command::History { limit, all } => cmd_history(limit, all),
1129 Command::Undo { bootstrap } => cmd_undo(bootstrap, mode),
1130 Command::Tui { action } => cmd_tui(action),
1131 Command::Theme { action } => cmd_theme(action),
1132 Command::Exec {
1133 slugs,
1134 profile,
1135 jobs,
1136 command,
1137 } => match cli.workspace {
1138 Some(root) => cmd_exec_workspace(&root, slugs, profile, jobs, command),
1139 None => cmd_exec(slugs, profile, jobs, command),
1140 },
1141 Command::Clean { slugs, profile, yes } => match cli.workspace {
1142 Some(root) => cmd_clean_workspace(&root, slugs, profile, yes),
1143 None => cmd_clean(slugs, profile, yes),
1144 },
1145 }
1146}
1147
1148fn resolve_targets(repo: &Repository, slugs: &[String]) -> Result<Vec<worktree::WorktreeInfo>> {
1154 if slugs.is_empty() {
1155 Ok(worktree::list(repo)?.into_iter().filter(|w| !w.is_main).collect())
1156 } else {
1157 slugs.iter().map(|s| worktree::find_fuzzy(repo, s)).collect()
1158 }
1159}
1160
1161fn cmd_exec(slugs: Vec<String>, profile: Option<String>, jobs: Option<u32>, command: Vec<String>) -> Result<()> {
1165 let repo = worktree::discover_repo(None)?;
1166 let (argv, job_count) = exec_plan(&repo, profile.as_deref(), jobs, &command)?;
1167 let targets = resolve_targets(&repo, &slugs)?;
1168 if targets.is_empty() {
1169 println!("no worktrees to run in");
1170 return Ok(());
1171 }
1172 let outcomes = exec_run(&targets, &argv, job_count, None)?;
1173 print_exec_rollup_and_exit(&outcomes)
1174}
1175
1176fn cmd_exec_workspace(
1184 root: &Path,
1185 slugs: Vec<String>,
1186 profile: Option<String>,
1187 jobs: Option<u32>,
1188 command: Vec<String>,
1189) -> Result<()> {
1190 let opened = open_workspace_repos(root)?;
1191 let repos: Vec<&Repository> = opened.iter().map(|(_, r)| r).collect();
1192 let targets_per_repo = resolve_workspace_targets(&repos, &slugs)?;
1195 let mut plans: Vec<(&str, &Vec<worktree::WorktreeInfo>, Vec<String>, usize)> = Vec::new();
1200 for ((name, repo), targets) in opened.iter().zip(&targets_per_repo) {
1201 if targets.is_empty() {
1202 continue;
1203 }
1204 let (argv, job_count) = exec_plan(repo, profile.as_deref(), jobs, &command)?;
1205 plans.push((name, targets, argv, job_count));
1206 }
1207
1208 if plans.is_empty() {
1209 let opened_repos = opened.iter().map(|(_, r)| r);
1214 if let Some(err) = first_exec_plan_error(opened_repos, profile.as_deref(), jobs, &command) {
1215 return Err(err);
1216 }
1217 println!("no worktrees to run in");
1218 return Ok(());
1219 }
1220
1221 let mut all = Vec::new();
1223 for (name, targets, argv, job_count) in &plans {
1224 println!("\n══ {}", name);
1225 all.extend(exec_run(targets, argv, *job_count, Some(name))?);
1226 }
1227 print_exec_rollup_and_exit(&all)
1228}
1229
1230fn first_exec_plan_error<'a>(
1235 repos: impl Iterator<Item = &'a Repository>,
1236 profile: Option<&str>,
1237 jobs: Option<u32>,
1238 command: &[String],
1239) -> Option<GwmError> {
1240 let mut last = None;
1241 for repo in repos {
1242 match exec_plan(repo, profile, jobs, command) {
1243 Ok(_) => return None,
1244 Err(e) => last = Some(e),
1245 }
1246 }
1247 last
1248}
1249
1250fn open_workspace_repos(root: &Path) -> Result<Vec<(String, Repository)>> {
1254 for entry in std::fs::read_dir(root)? {
1264 let path = entry?.path();
1265 if path.is_dir() && path.join(".git").try_exists()? && Repository::open(&path).is_err() {
1266 let name = path
1267 .file_name()
1268 .map(|n| n.to_string_lossy().to_string())
1269 .unwrap_or_default();
1270 return Err(GwmError::Other(format!(
1271 "workspace: child repo `{name}` has a `.git` but cannot be opened (corrupt or unreadable)"
1272 )));
1273 }
1274 }
1275
1276 let ws = workspace::discover(root)?;
1277 if ws.is_empty() {
1278 return Err(GwmError::EmptyWorkspace {
1279 root: root.display().to_string(),
1280 });
1281 }
1282 ws.repos
1283 .iter()
1284 .map(|r| {
1285 Repository::open(&r.path)
1286 .map(|repo| (r.name.clone(), repo))
1287 .map_err(|e| GwmError::Other(format!("workspace: cannot open repo `{}`: {e}", r.name)))
1288 })
1289 .collect()
1290}
1291
1292fn exec_plan(
1297 repo: &Repository,
1298 profile: Option<&str>,
1299 jobs: Option<u32>,
1300 command: &[String],
1301) -> Result<(Vec<String>, usize)> {
1302 let exec_cfg = if profile.is_some() {
1309 let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?;
1310 Config::load_exec_config(workdir)?
1311 } else if jobs.is_none() {
1312 crate::config::ExecConfig {
1313 jobs: Config::load_exec_jobs_default(repo.workdir())?,
1314 ..Default::default()
1315 }
1316 } else {
1317 crate::config::ExecConfig::default()
1318 };
1319 let argv = exec::resolve_exec_command(profile, command, &exec_cfg)?;
1320 let job_count = exec::resolve_jobs(jobs, profile, &exec_cfg);
1321 Ok((argv, job_count))
1322}
1323
1324fn exec_run(
1330 targets: &[worktree::WorktreeInfo],
1331 argv: &[String],
1332 job_count: usize,
1333 tag: Option<&str>,
1334) -> Result<Vec<exec::ExecOutcome>> {
1335 let (program, args) = argv
1338 .split_first()
1339 .ok_or_else(|| GwmError::Other("exec: no command resolved".into()))?;
1340 let args = args.to_vec();
1341 let display = |name: &str| match tag {
1342 Some(t) => format!("{t}/{name}"),
1343 None => name.to_string(),
1344 };
1345
1346 let mut outcomes = Vec::with_capacity(targets.len());
1347 if job_count <= 1 {
1348 for w in targets {
1350 println!("\n━━ {} ({})", w.name, w.path.display());
1351 let status = exec::exec_in_dir(&w.path, program, &args);
1352 outcomes.push(exec::ExecOutcome {
1353 name: display(&w.name),
1354 status,
1355 });
1356 }
1357 } else {
1358 use std::io::Write;
1364 let items: Vec<(String, std::path::PathBuf)> = targets.iter().map(|w| (w.name.clone(), w.path.clone())).collect();
1365 let results = exec::run_in_dirs_parallel(job_count, &items, program, &args);
1366 let stdout = std::io::stdout();
1367 let mut lock = stdout.lock();
1368 for ((name, path), (outcome, output)) in items.iter().zip(results) {
1369 let _ = writeln!(lock, "\n━━ {} ({})", name, path.display());
1372 let _ = lock.write_all(&output);
1373 outcomes.push(exec::ExecOutcome {
1374 name: display(name),
1375 status: outcome.status,
1376 });
1377 }
1378 let _ = lock.flush();
1379 }
1380 Ok(outcomes)
1381}
1382
1383fn print_exec_rollup_and_exit(outcomes: &[exec::ExecOutcome]) -> Result<()> {
1387 println!("\nrollup:");
1388 for o in outcomes {
1389 println!(" {}", exec::format_outcome(o));
1390 }
1391 let code = exec::rollup_exit_code(outcomes);
1392 if code != 0 {
1393 std::process::exit(code);
1394 }
1395 Ok(())
1396}
1397
1398fn resolve_workspace_targets(repos: &[&Repository], slugs: &[String]) -> Result<Vec<Vec<worktree::WorktreeInfo>>> {
1408 if slugs.is_empty() {
1409 return repos
1414 .iter()
1415 .map(|repo| Ok(worktree::list(repo)?.into_iter().filter(|w| !w.is_main).collect()))
1416 .collect();
1417 }
1418
1419 let mut per_repo: Vec<Vec<worktree::WorktreeInfo>> = (0..repos.len()).map(|_| Vec::new()).collect();
1420 let mut matched = vec![false; slugs.len()];
1421 for (ri, repo) in repos.iter().enumerate() {
1422 for (si, slug) in slugs.iter().enumerate() {
1423 match worktree::find_fuzzy(repo, slug) {
1424 Ok(wt) => {
1425 per_repo[ri].push(wt);
1426 matched[si] = true;
1427 }
1428 Err(GwmError::WorktreeNotFound(_)) => {}
1430 Err(e) => return Err(e),
1432 }
1433 }
1434 }
1435 if let Some(si) = matched.iter().position(|m| !m) {
1436 return Err(GwmError::WorktreeNotFound(format!(
1437 "{} (no worktree matches it in any workspace repo)",
1438 slugs[si]
1439 )));
1440 }
1441 Ok(per_repo)
1442}
1443
1444fn cmd_clean(slugs: Vec<String>, profile: Option<String>, yes: bool) -> Result<()> {
1449 let repo = worktree::discover_repo(None)?;
1450 let targets = resolve_targets(&repo, &slugs)?;
1451 let (reclaims, skipped) = clean_scan_repo(&repo, &targets, profile.as_deref(), None)?;
1454 if targets.is_empty() {
1455 println!("no worktrees to clean");
1456 return Ok(());
1457 }
1458 clean_finish(&reclaims, &skipped, yes)
1459}
1460
1461fn cmd_clean_workspace(root: &Path, slugs: Vec<String>, profile: Option<String>, yes: bool) -> Result<()> {
1469 let opened = open_workspace_repos(root)?;
1470 let repos: Vec<&Repository> = opened.iter().map(|(_, r)| r).collect();
1471 let targets_per_repo = resolve_workspace_targets(&repos, &slugs)?;
1472
1473 let mut reclaims: Vec<clean::WorktreeReclaim> = Vec::new();
1479 let mut skipped: Vec<(String, String)> = Vec::new();
1480 let mut participated = false;
1481 for ((name, repo), targets) in opened.iter().zip(&targets_per_repo) {
1482 if targets.is_empty() {
1483 continue;
1484 }
1485 participated = true;
1486 let (mut rec, mut skip) = clean_scan_repo(repo, targets, profile.as_deref(), Some(name))?;
1487 reclaims.append(&mut rec);
1488 skipped.append(&mut skip);
1489 }
1490
1491 if !participated {
1492 let mut last_err = None;
1496 let mut valid = false;
1497 for (_, repo) in &opened {
1498 match clean_scan_repo(repo, &[], profile.as_deref(), None) {
1499 Ok(_) => {
1500 valid = true;
1501 break;
1502 }
1503 Err(e) => last_err = Some(e),
1504 }
1505 }
1506 if !valid {
1507 return Err(last_err.unwrap_or_else(|| GwmError::EmptyWorkspace {
1515 root: root.display().to_string(),
1516 }));
1517 }
1518 }
1519 clean_finish(&reclaims, &skipped, yes)
1520}
1521
1522type CleanScan = (Vec<clean::WorktreeReclaim>, Vec<(String, String)>);
1525
1526fn clean_scan_repo(
1533 repo: &Repository,
1534 targets: &[worktree::WorktreeInfo],
1535 profile: Option<&str>,
1536 tag: Option<&str>,
1537) -> Result<CleanScan> {
1538 let clean_cfg = Config::load_clean_config(repo.workdir())?;
1542 let patterns = clean::resolve_clean_dirs(profile, &clean_cfg)?;
1543
1544 let display = |name: &str| match tag {
1545 Some(t) => format!("{t}/{name}"),
1546 None => name.to_string(),
1547 };
1548
1549 let mut reclaims = Vec::with_capacity(targets.len());
1556 let mut skipped = Vec::new();
1557 for w in targets {
1558 let (mut reclaim, skips) = clean::scan_worktree_safe(&w.name, &w.path, &patterns);
1559 reclaim.name = display(&w.name);
1560 for rel in skips {
1561 skipped.push((display(&w.name), rel));
1562 }
1563 reclaims.push(reclaim);
1564 }
1565 Ok((reclaims, skipped))
1566}
1567
1568fn clean_finish(reclaims: &[clean::WorktreeReclaim], skipped: &[(String, String)], yes: bool) -> Result<()> {
1573 print!("{}", clean::format_report(reclaims));
1574 for (name, rel) in skipped {
1575 println!("skipped {}/{}: not git-ignored, or holds tracked files", name, rel);
1576 }
1577
1578 let grand: u64 = reclaims.iter().map(|r| r.total_bytes).sum();
1579 if grand == 0 {
1580 println!("nothing to reclaim");
1581 return Ok(());
1582 }
1583 if !yes {
1584 println!("re-run with --yes to delete the listed artifacts");
1585 return Ok(());
1586 }
1587
1588 let mut freed = 0u64;
1589 let mut failures = 0usize;
1590 for r in reclaims {
1591 match clean::delete_reclaim(r) {
1592 Ok(b) => freed = freed.saturating_add(b),
1593 Err(e) => {
1594 eprintln!("failed to reclaim {}: {e}", r.name);
1595 failures += 1;
1596 }
1597 }
1598 }
1599 println!("reclaimed {}", clean::human_size(freed));
1600 if failures > 0 {
1601 std::process::exit(1);
1602 }
1603 Ok(())
1604}
1605
1606fn autodetect_workspace_prompt() -> Result<Option<PathBuf>> {
1614 use std::io::{IsTerminal, Write};
1615
1616 let cwd = std::env::current_dir()?;
1617 let Some(ws) = workspace::autodetect(&cwd) else {
1618 return Ok(None);
1619 };
1620 if !io::stdin().is_terminal() {
1621 return Ok(None);
1622 }
1623 eprint!(
1624 "No git repo here. Open {} as a workspace ({} repos)? [Y/n] ",
1625 cwd.display(),
1626 ws.repos.len()
1627 );
1628 io::stderr().flush().ok();
1629 let mut answer = String::new();
1630 io::stdin().read_line(&mut answer)?;
1631 let a = answer.trim().to_ascii_lowercase();
1632 if a.is_empty() || a == "y" || a == "yes" {
1633 Ok(Some(ws.root))
1634 } else {
1635 Ok(None)
1636 }
1637}
1638
1639fn cmd_tui(action: TuiAction) -> Result<()> {
1640 match action {
1641 TuiAction::Keys => cmd_tui_keys(),
1642 }
1643}
1644
1645fn cmd_theme(action: ThemeAction) -> Result<()> {
1646 match action {
1647 ThemeAction::List => cmd_theme_list(),
1648 ThemeAction::Show { name } => cmd_theme_show(&name),
1649 }
1650}
1651
1652fn cmd_theme_list() -> Result<()> {
1658 for name in crate::tui::theme::preset_names() {
1659 println!("{}", name);
1660 }
1661 Ok(())
1662}
1663
1664fn cmd_theme_show(name: &str) -> Result<()> {
1675 use crate::tui::theme::Theme;
1676 use ratatui::style::Color;
1677
1678 let theme = Theme::preset(name).ok_or_else(|| {
1679 let known = crate::tui::theme::preset_names().join(", ");
1680 GwmError::Other(format!("theme show: unknown preset {:?} (known: {})", name, known))
1681 })?;
1682 let color_str = |c: Color| -> String {
1683 match c {
1684 Color::Rgb(r, g, b) => format!("\"#{:02x}{:02x}{:02x}\"", r, g, b),
1685 Color::Reset => "\"reset\"".to_string(),
1686 Color::Black => "\"black\"".to_string(),
1687 Color::Red => "\"red\"".to_string(),
1688 Color::Green => "\"green\"".to_string(),
1689 Color::Yellow => "\"yellow\"".to_string(),
1690 Color::Blue => "\"blue\"".to_string(),
1691 Color::Magenta => "\"magenta\"".to_string(),
1692 Color::Cyan => "\"cyan\"".to_string(),
1693 Color::Gray => "\"gray\"".to_string(),
1694 Color::DarkGray => "\"dark_gray\"".to_string(),
1695 Color::LightRed => "\"bright_red\"".to_string(),
1696 Color::LightGreen => "\"bright_green\"".to_string(),
1697 Color::LightYellow => "\"bright_yellow\"".to_string(),
1698 Color::LightBlue => "\"bright_blue\"".to_string(),
1699 Color::LightMagenta => "\"bright_magenta\"".to_string(),
1700 Color::LightCyan => "\"bright_cyan\"".to_string(),
1701 Color::White => "\"white\"".to_string(),
1702 Color::Indexed(n) => format!("\"{}\"", n),
1706 }
1707 };
1708 println!("[theme]");
1709 println!("preset = {:?}", name);
1710 println!("focus = {}", color_str(theme.focus));
1711 println!("accent = {}", color_str(theme.accent));
1712 println!("branch = {}", color_str(theme.branch));
1713 println!("clean = {}", color_str(theme.clean));
1714 println!("dirty = {}", color_str(theme.dirty));
1715 println!("main = {}", color_str(theme.main));
1716 println!("locked = {}", color_str(theme.locked));
1717 println!("prunable = {}", color_str(theme.prunable));
1718 println!("muted = {}", color_str(theme.muted));
1719 println!("selection_bg = {}", color_str(theme.selection_bg));
1720 println!("name = {}", color_str(theme.name));
1721 println!("path = {}", color_str(theme.path));
1722 println!("staged = {}", color_str(theme.staged));
1723 println!("modified = {}", color_str(theme.modified));
1724 println!("untracked = {}", color_str(theme.untracked));
1725 Ok(())
1726}
1727
1728fn cmd_tui_keys() -> Result<()> {
1738 use crate::tui::keymap::{Keymap, Source};
1739 use crate::tui::modal_keymap::{KeyContext, ModalKeymap};
1740
1741 let (keymap, modal) = match worktree::discover_repo(None) {
1748 Ok(repo) => match repo.workdir() {
1749 Some(workdir) => {
1750 let cfg = Config::load_for_repo(workdir)?;
1751 (cfg.tui.keys.resolved_keymap()?, cfg.tui.keys.resolved_modal_keymap()?)
1752 }
1753 None => (Keymap::defaults(), ModalKeymap::defaults()),
1754 },
1755 Err(_) => (Keymap::defaults(), ModalKeymap::defaults()),
1756 };
1757
1758 let rows = keymap.list();
1759 let action_w = rows
1762 .iter()
1763 .map(|b| b.action.slug().len())
1764 .max()
1765 .unwrap_or(0)
1766 .max("action".len());
1767 let keys_w = rows
1768 .iter()
1769 .map(|b| {
1770 b.chords
1771 .iter()
1772 .map(|c| c.iter().map(|k| k.to_string()).collect::<Vec<_>>().join(" "))
1773 .collect::<Vec<_>>()
1774 .join(", ")
1775 .len()
1776 })
1777 .max()
1778 .unwrap_or(0)
1779 .max("keys".len());
1780
1781 println!("{:<aw$} {:<kw$} source", "action", "keys", aw = action_w, kw = keys_w);
1782 for binding in rows {
1783 let keys = binding
1784 .chords
1785 .iter()
1786 .map(|c| c.iter().map(|k| k.to_string()).collect::<Vec<_>>().join(" "))
1787 .collect::<Vec<_>>()
1788 .join(", ");
1789 let source = match binding.source {
1790 Source::Default => "default",
1791 Source::UserConfig => ".gwm.toml",
1792 };
1793 println!(
1794 "{:<aw$} {:<kw$} {}",
1795 binding.action.slug(),
1796 keys,
1797 source,
1798 aw = action_w,
1799 kw = keys_w
1800 );
1801 }
1802
1803 let fmt_keys = |keys: &[crate::tui::keymap::KeyStroke]| -> String {
1807 keys.iter().map(|k| k.to_string()).collect::<Vec<_>>().join(", ")
1808 };
1809 for ctx in KeyContext::all() {
1810 let bindings = modal.bindings_for(*ctx);
1811 if bindings.is_empty() {
1812 continue;
1813 }
1814 println!("\n[tui.keys.modal.{}]", ctx.config_path());
1815 let verb_w = bindings
1816 .iter()
1817 .map(|b| b.action.verb().len())
1818 .max()
1819 .unwrap_or(0)
1820 .max("verb".len());
1821 let keys_w = bindings
1822 .iter()
1823 .map(|b| fmt_keys(&b.keys).len())
1824 .max()
1825 .unwrap_or(0)
1826 .max("keys".len());
1827 println!("{:<vw$} {:<kw$} source", "verb", "keys", vw = verb_w, kw = keys_w);
1828 for binding in bindings {
1829 let source = match binding.source {
1830 Source::Default => "default",
1831 Source::UserConfig => ".gwm.toml",
1832 };
1833 println!(
1834 "{:<vw$} {:<kw$} {}",
1835 binding.action.verb(),
1836 fmt_keys(&binding.keys),
1837 source,
1838 vw = verb_w,
1839 kw = keys_w
1840 );
1841 }
1842 }
1843 Ok(())
1844}
1845
1846fn cmd_init(preset: Option<String>, list_presets: bool, show: bool) -> Result<()> {
1847 if list_presets {
1850 let name_w = presets::all().iter().map(|p| p.name.len()).max().unwrap_or(0);
1851 for p in presets::all() {
1852 let aliases = if p.aliases.is_empty() {
1853 String::new()
1854 } else {
1855 format!(" (alias: {})", p.aliases.join(", "))
1856 };
1857 println!(" {:<w$} {}{}", p.name, p.description, aliases, w = name_w);
1858 }
1859 return Ok(());
1860 }
1861
1862 let name = preset.as_deref().unwrap_or("generic");
1864 let resolved = presets::lookup(name).ok_or_else(|| {
1865 GwmError::Config(format!(
1866 "unknown preset {name:?} — run `gwm init --list-presets` to see the built-ins"
1867 ))
1868 })?;
1869
1870 if show {
1872 print!("{}", resolved.body);
1873 return Ok(());
1874 }
1875
1876 let repo = worktree::discover_repo(None)?;
1877 let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?;
1878 let path = Config::write_preset(workdir, resolved.body)?;
1879 println!("wrote {} (preset: {})", path.display(), resolved.name);
1880 Ok(())
1881}
1882
1883fn cmd_agents(action: Option<AgentsAction>, format: AgentsFormat) -> Result<()> {
1887 let repo = worktree::discover_repo(None)?;
1888 let trees = worktree::list(&repo)?;
1889
1890 match action {
1891 None => {
1892 let mut rows: Vec<json_api::JsonWorktree> = trees.iter().map(json_api::JsonWorktree::from).collect();
1893 let pins = json_api::agent_pins_for_rows(&repo, &trees);
1894 let reals: Vec<PathBuf> = trees.iter().map(|w| w.path.clone()).collect();
1895 if format == AgentsFormat::Json {
1899 json_api::attach_agents(&mut rows, &reals, &pins);
1900 println!("{}", serde_json::to_string_pretty(&rows)?);
1901 return Ok(());
1902 }
1903 let pool = json_api::attach_agents_with_pool(&mut rows, &reals, &pins);
1904 let pinned_pairs: std::collections::BTreeSet<(&str, &str)> =
1908 pins.iter().map(|(path, sid)| (path.as_str(), sid.as_str())).collect();
1909 let now_epoch = std::time::SystemTime::now()
1910 .duration_since(std::time::SystemTime::UNIX_EPOCH)
1911 .map(|d| d.as_secs())
1912 .unwrap_or(0);
1913 let mut any = false;
1914 for (row, tree) in rows.iter().zip(&trees) {
1915 let Some(agents) = &row.agents else {
1916 continue;
1917 };
1918 any = true;
1919 let row_key = crate::agent_sessions::path_display_key(&tree.path);
1920 println!("{}", row.name);
1921 for s in &agents.sessions {
1922 let pin_mark = if pinned_pairs.contains(&(row_key.as_str(), s.id.as_str())) {
1923 " pinned"
1924 } else {
1925 ""
1926 };
1927 let ago = worktree::format_relative_duration(std::time::Duration::from_secs(
1930 now_epoch.saturating_sub(s.last_activity),
1931 ));
1932 let name_part = s.name.as_deref().map(|n| format!(" {n}")).unwrap_or_default();
1933 println!(
1934 " {:<9} {:<7} {:>4} ago {}{}{}",
1935 s.kind, s.freshness, ago, s.id, name_part, pin_mark
1936 );
1937 }
1938 }
1939 let shown: std::collections::BTreeSet<&str> = rows
1943 .iter()
1944 .filter_map(|r| r.agents.as_ref())
1945 .flat_map(|a| a.sessions.iter().map(|s| s.id.as_str()))
1946 .collect();
1947 let mut unmatched: Vec<&crate::agent_sessions::AgentSession> =
1948 pool.iter().filter(|s| !shown.contains(s.id.as_str())).collect();
1949 unmatched.sort_by_key(|s| (s.ended, std::cmp::Reverse(s.last_activity)));
1950 if !unmatched.is_empty() {
1951 any = true;
1952 let now_sys = std::time::SystemTime::now();
1953 println!("unmatched");
1954 for s in &unmatched {
1955 let word = match crate::agent_sessions::Freshness::classify(s.last_activity, s.ended, now_sys) {
1956 crate::agent_sessions::Freshness::Active => "active",
1957 crate::agent_sessions::Freshness::Idle => "idle",
1958 };
1959 let ago = worktree::format_relative_duration(now_sys.duration_since(s.last_activity).unwrap_or_default());
1960 let name_part = s.name.as_deref().map(|n| format!(" {n}")).unwrap_or_default();
1961 println!(
1962 " {:<9} {:<7} {:>4} ago {}{}",
1963 s.kind.display(),
1964 word,
1965 ago,
1966 s.id,
1967 name_part
1968 );
1969 }
1970 }
1971 if !any {
1972 println!("no agent session found");
1973 }
1974 Ok(())
1975 }
1976 Some(AgentsAction::Attach { pattern, session_id }) => {
1977 let target = resolve_agents_worktree(&trees, &pattern)?;
1978 let Some(branch) = github::pinnable_branch(target.branch.as_deref()).map(str::to_string) else {
1979 return Err(GwmError::Config(format!(
1980 "worktree '{}' has no branch (detached HEAD) — a pin lives in branch config",
1981 target.name
1982 )));
1983 };
1984 let home = crate::agent_sessions::agents_home()
1987 .ok_or_else(|| GwmError::Config("no home directory to scan for agent sessions".into()))?;
1988 let key = crate::agent_sessions::path_display_key(&target.path);
1989 let keyed: Vec<(String, PathBuf)> = trees
1990 .iter()
1991 .map(|w| (crate::agent_sessions::path_display_key(&w.path), w.path.clone()))
1992 .collect();
1993 let probe = [(key.clone(), session_id.clone())];
1994 let map = crate::agent_sessions::detect_all(&home, &keyed, &probe, std::time::SystemTime::now());
1995 let found = map
1996 .get(&key)
1997 .is_some_and(|a| a.sessions.iter().any(|s| s.id == session_id));
1998 if !found {
1999 return Err(GwmError::Config(format!(
2000 "no agent session with id '{session_id}' — run `gwm agents` to see the detected ids"
2001 )));
2002 }
2003 github::add_agent_pin(&repo, &branch, &session_id)?;
2004 println!("pinned {session_id} to {}", target.name);
2005 Ok(())
2006 }
2007 Some(AgentsAction::Detach { pattern, session_id }) => {
2008 let target = resolve_agents_worktree(&trees, &pattern)?;
2009 let Some(branch) = github::pinnable_branch(target.branch.as_deref()).map(str::to_string) else {
2010 return Err(GwmError::Config(format!(
2011 "worktree '{}' has no branch (detached HEAD) — nothing to detach",
2012 target.name
2013 )));
2014 };
2015 match session_id {
2016 Some(sid) => {
2017 if !github::remove_agent_pin(&repo, &branch, &sid)? {
2018 return Err(GwmError::Config(format!(
2019 "no pin '{sid}' on {} — run `gwm agents` to see the pinned ids",
2020 target.name
2021 )));
2022 }
2023 println!("detached {sid} from {}", target.name);
2024 }
2025 None => {
2026 github::clear_agent_pins(&repo, &branch)?;
2027 println!("detached every agent pin from {}", target.name);
2028 }
2029 }
2030 Ok(())
2031 }
2032 }
2033}
2034
2035fn resolve_agents_worktree(trees: &[worktree::WorktreeInfo], pattern: &str) -> Result<worktree::WorktreeInfo> {
2041 if pattern == "." {
2042 let cwd = std::env::current_dir()?;
2043 let cwd = cwd.canonicalize().unwrap_or(cwd);
2044 return trees
2045 .iter()
2046 .filter(|w| {
2047 let wp = w.path.canonicalize().unwrap_or_else(|_| w.path.clone());
2048 cwd.starts_with(&wp)
2049 })
2050 .max_by_key(|w| w.path.as_os_str().len())
2051 .cloned()
2052 .ok_or_else(|| GwmError::WorktreeNotFound(".".into()));
2053 }
2054 let exact: Vec<&worktree::WorktreeInfo> = trees.iter().filter(|w| w.name == pattern).collect();
2055 match exact.as_slice() {
2056 [one] => {
2057 if let Some(by_id) = trees.iter().find(|w| w.id == pattern && w.id != one.id) {
2060 return Err(GwmError::Other(format!(
2061 "'{}' is ambiguous: the display name of '{}' and the id of '{}'; target one by its unique id",
2062 pattern, one.id, by_id.id
2063 )));
2064 }
2065 return Ok((*one).clone());
2066 }
2067 [] => {
2068 if let Some(by_id) = trees.iter().find(|w| w.id == pattern) {
2069 return Ok(by_id.clone());
2070 }
2071 }
2072 many => {
2073 let ids = many.iter().map(|w| w.id.as_str()).collect::<Vec<_>>().join(", ");
2074 return Err(GwmError::Other(format!(
2075 "name '{pattern}' is ambiguous ({} worktrees share it); target one by id: {ids}",
2076 many.len()
2077 )));
2078 }
2079 }
2080 let pat = pattern.to_lowercase();
2081 let matches: Vec<&worktree::WorktreeInfo> = trees.iter().filter(|w| w.name.to_lowercase().contains(&pat)).collect();
2082 match matches.as_slice() {
2083 [one] => Ok((*one).clone()),
2084 [] => Err(GwmError::WorktreeNotFound(pattern.into())),
2085 many => Err(GwmError::Config(format!(
2086 "pattern '{pattern}' is ambiguous: {}",
2087 many.iter().map(|w| w.name.as_str()).collect::<Vec<_>>().join(", ")
2088 ))),
2089 }
2090}
2091
2092fn cmd_list(format: ListFormat, detect_pr: bool) -> Result<()> {
2093 let repo = worktree::discover_repo(None)?;
2094 let trees = worktree::list(&repo)?;
2095
2096 if format == ListFormat::Names {
2097 for w in trees.iter().filter(|w| !w.is_main) {
2101 println!("{}", w.name);
2102 }
2103 return Ok(());
2104 }
2105
2106 let detected_prs: Vec<Option<Option<u64>>> = if detect_pr {
2121 let config = Config::load_for_repo(repo.workdir().unwrap_or_else(|| repo.path()))?;
2127 match forge::resolve(&repo, &config).ok() {
2128 None => vec![None; trees.len()],
2129 Some(forge) => trees
2130 .iter()
2131 .map(|w| {
2132 w.branch.as_deref().map(|branch| {
2133 github::read_link_with_pr_detection(&repo, branch, forge.as_ref())
2134 .ok()
2135 .and_then(|l| l.pr)
2136 })
2137 })
2138 .collect(),
2139 }
2140 } else {
2141 Vec::new()
2142 };
2143
2144 if format == ListFormat::Json {
2145 let mut dto: Vec<json_api::JsonWorktree> = trees.iter().map(json_api::JsonWorktree::from).collect();
2149 let pins = json_api::agent_pins_for_rows(&repo, &trees);
2152 let reals: Vec<PathBuf> = trees.iter().map(|w| w.path.clone()).collect();
2153 json_api::attach_agents(&mut dto, &reals, &pins);
2154 if detect_pr {
2155 for (d, outcome) in dto.iter_mut().zip(&detected_prs) {
2159 if let Some(pr) = outcome {
2160 d.pr = *pr;
2161 }
2162 }
2163 }
2164 println!("{}", serde_json::to_string_pretty(&dto)?);
2165 return Ok(());
2166 }
2167
2168 let name_w = trees.iter().map(|w| w.name.len()).max().unwrap_or(4).clamp(4, 40);
2170 let branch_w = trees
2171 .iter()
2172 .map(|w| w.branch.as_deref().unwrap_or("-").len())
2173 .max()
2174 .unwrap_or(6)
2175 .clamp(6, 40);
2176 let status_w = 14;
2177 let pr_w = 6;
2178 let agent_w = 8;
2182 let agent_cells: Vec<String> = {
2183 let mut cells = vec!["-".to_string(); trees.len()];
2184 if let Some(home) = crate::agent_sessions::agents_home() {
2185 let keyed: Vec<(String, PathBuf)> = trees
2186 .iter()
2187 .map(|w| (crate::agent_sessions::path_display_key(&w.path), w.path.clone()))
2188 .collect();
2189 let pins: Vec<(String, String)> = trees
2190 .iter()
2191 .flat_map(|w| {
2192 let pins = github::pinnable_branch(w.branch.as_deref())
2193 .map(|b| github::agent_pins(&repo, b).unwrap_or_default())
2194 .unwrap_or_default();
2195 let path = crate::agent_sessions::path_display_key(&w.path);
2196 pins.into_iter().map(move |sid| (path.clone(), sid))
2197 })
2198 .collect();
2199 let map = crate::agent_sessions::detect_all(&home, &keyed, &pins, std::time::SystemTime::now());
2200 for (i, w) in trees.iter().enumerate() {
2201 if let Some(top) = map
2202 .get(&crate::agent_sessions::path_display_key(&w.path))
2203 .and_then(|a| a.top())
2204 {
2205 cells[i] = top.kind.display().to_string();
2206 }
2207 }
2208 }
2209 cells
2210 };
2211 let show_agent = agent_cells.iter().any(|c| c != "-");
2215 let agent_col = |cell: &str| {
2216 if show_agent {
2217 format!("{cell:<agent_w$} ")
2218 } else {
2219 String::new()
2220 }
2221 };
2222
2223 if detect_pr {
2224 println!(
2225 " {:<nw$} {:<bw$} {:<sw$} {:<pw$} {}PATH",
2226 "NAME",
2227 "BRANCH",
2228 "STATUS",
2229 "PR",
2230 agent_col("AGENT"),
2231 nw = name_w,
2232 bw = branch_w,
2233 sw = status_w,
2234 pw = pr_w,
2235 );
2236 } else {
2237 println!(
2238 " {:<nw$} {:<bw$} {:<sw$} {}PATH",
2239 "NAME",
2240 "BRANCH",
2241 "STATUS",
2242 agent_col("AGENT"),
2243 nw = name_w,
2244 bw = branch_w,
2245 sw = status_w,
2246 );
2247 }
2248 for (i, w) in trees.iter().enumerate() {
2249 let mark = if w.is_main { "*" } else { " " };
2250 let branch = w.branch.clone().unwrap_or_else(|| "-".into());
2251 let status = format_status_text(w);
2252 if detect_pr {
2253 let pr = detected_prs.get(i).copied().flatten().flatten();
2256 let pr_cell = pr.map(|n| format!("#{n}")).unwrap_or_else(|| "-".into());
2257 println!(
2258 "{} {:<nw$} {:<bw$} {:<sw$} {:<pw$} {}{}",
2259 mark,
2260 w.name,
2261 branch,
2262 status,
2263 pr_cell,
2264 agent_col(&agent_cells[i]),
2265 w.path.display(),
2266 nw = name_w,
2267 bw = branch_w,
2268 sw = status_w,
2269 pw = pr_w,
2270 );
2271 } else {
2272 println!(
2273 "{} {:<nw$} {:<bw$} {:<sw$} {}{}",
2274 mark,
2275 w.name,
2276 branch,
2277 status,
2278 agent_col(&agent_cells[i]),
2279 w.path.display(),
2280 nw = name_w,
2281 bw = branch_w,
2282 sw = status_w,
2283 );
2284 }
2285 }
2286 Ok(())
2287}
2288
2289fn cmd_list_workspace(root: &Path, format: ListFormat, detect_pr: bool) -> Result<()> {
2296 let ws = workspace::discover(root)?;
2297 if ws.is_empty() {
2298 return Err(GwmError::EmptyWorkspace {
2299 root: root.display().to_string(),
2300 });
2301 }
2302 let rows = workspace::merge_worktrees(&ws)?;
2303
2304 if format == ListFormat::Names {
2305 for row in &rows {
2306 println!("{}/{}", row.repo_name, row.info.name);
2307 }
2308 return Ok(());
2309 }
2310
2311 let detected_prs: Vec<Option<Option<u64>>> = if detect_pr {
2318 rows
2319 .iter()
2320 .map(|row| {
2321 let repo = Repository::open(&row.repo_path).ok()?;
2322 let branch = row.info.branch.as_deref()?;
2323 let config = Config::load_for_repo(&row.repo_path).ok()?;
2332 let forge = forge::resolve(&repo, &config).ok()?;
2333 Some(
2334 github::read_link_with_pr_detection(&repo, branch, forge.as_ref())
2335 .ok()
2336 .and_then(|l| l.pr),
2337 )
2338 })
2339 .collect()
2340 } else {
2341 Vec::new()
2342 };
2343
2344 let agent_pins: Vec<(String, String)> = rows
2349 .iter()
2350 .filter_map(|row| {
2351 let repo = Repository::open(&row.repo_path).ok()?;
2352 let branch = github::pinnable_branch(row.info.branch.as_deref())?;
2353 let pins = github::agent_pins(&repo, branch).ok()?;
2354 let path = crate::agent_sessions::path_display_key(&row.info.path);
2355 Some(pins.into_iter().map(move |sid| (path.clone(), sid)).collect::<Vec<_>>())
2356 })
2357 .flatten()
2358 .collect();
2359
2360 if format == ListFormat::Json {
2361 #[derive(serde::Serialize)]
2364 struct WorkspaceJsonWorktree<'a> {
2365 repo: &'a str,
2366 #[serde(flatten)]
2367 worktree: json_api::JsonWorktree,
2368 }
2369 let mut worktree_rows: Vec<json_api::JsonWorktree> = rows
2370 .iter()
2371 .enumerate()
2372 .map(|(i, row)| {
2373 let mut worktree = json_api::JsonWorktree::from(&row.info);
2374 if let Some(pr) = detected_prs.get(i).copied().flatten() {
2378 worktree.pr = pr;
2379 }
2380 worktree
2381 })
2382 .collect();
2383 let reals: Vec<PathBuf> = rows.iter().map(|r| r.info.path.clone()).collect();
2386 json_api::attach_agents(&mut worktree_rows, &reals, &agent_pins);
2387 let dto: Vec<WorkspaceJsonWorktree> = rows
2388 .iter()
2389 .zip(worktree_rows)
2390 .map(|(row, worktree)| WorkspaceJsonWorktree {
2391 repo: &row.repo_name,
2392 worktree,
2393 })
2394 .collect();
2395 println!("{}", serde_json::to_string_pretty(&dto)?);
2396 return Ok(());
2397 }
2398
2399 let repo_w = rows.iter().map(|r| r.repo_name.len()).max().unwrap_or(4).clamp(4, 30);
2400 let agent_w = 8;
2404 let agent_cells: Vec<String> = {
2405 let mut cells = vec!["-".to_string(); rows.len()];
2406 if let Some(home) = crate::agent_sessions::agents_home() {
2407 let keyed: Vec<(String, PathBuf)> = rows
2408 .iter()
2409 .map(|r| {
2410 (
2411 crate::agent_sessions::path_display_key(&r.info.path),
2412 r.info.path.clone(),
2413 )
2414 })
2415 .collect();
2416 let map = crate::agent_sessions::detect_all(&home, &keyed, &agent_pins, std::time::SystemTime::now());
2417 for (i, r) in rows.iter().enumerate() {
2418 if let Some(top) = map
2419 .get(&crate::agent_sessions::path_display_key(&r.info.path))
2420 .and_then(|a| a.top())
2421 {
2422 cells[i] = top.kind.display().to_string();
2423 }
2424 }
2425 }
2426 cells
2427 };
2428 let show_agent = agent_cells.iter().any(|c| c != "-");
2430 let agent_col = |cell: &str| {
2431 if show_agent {
2432 format!("{cell:<agent_w$} ")
2433 } else {
2434 String::new()
2435 }
2436 };
2437 let name_w = rows.iter().map(|r| r.info.name.len()).max().unwrap_or(4).clamp(4, 40);
2438 let branch_w = rows
2439 .iter()
2440 .map(|r| r.info.branch.as_deref().unwrap_or("-").len())
2441 .max()
2442 .unwrap_or(6)
2443 .clamp(6, 40);
2444 let status_w = 14;
2445 let pr_w = 6;
2446
2447 if detect_pr {
2448 println!(
2449 " {:<rw$} {:<nw$} {:<bw$} {:<sw$} {:<pw$} {}PATH",
2450 "REPO",
2451 "NAME",
2452 "BRANCH",
2453 "STATUS",
2454 "PR",
2455 agent_col("AGENT"),
2456 rw = repo_w,
2457 nw = name_w,
2458 bw = branch_w,
2459 sw = status_w,
2460 pw = pr_w,
2461 );
2462 } else {
2463 println!(
2464 " {:<rw$} {:<nw$} {:<bw$} {:<sw$} {}PATH",
2465 "REPO",
2466 "NAME",
2467 "BRANCH",
2468 "STATUS",
2469 agent_col("AGENT"),
2470 rw = repo_w,
2471 nw = name_w,
2472 bw = branch_w,
2473 sw = status_w,
2474 );
2475 }
2476 for (i, row) in rows.iter().enumerate() {
2477 let w = &row.info;
2478 let mark = if w.is_main { "*" } else { " " };
2479 let branch = w.branch.clone().unwrap_or_else(|| "-".into());
2480 let status = format_status_text(w);
2481 if detect_pr {
2482 let pr = detected_prs.get(i).copied().flatten().flatten();
2484 let pr_cell = pr.map(|n| format!("#{n}")).unwrap_or_else(|| "-".into());
2485 println!(
2486 "{} {:<rw$} {:<nw$} {:<bw$} {:<sw$} {:<pw$} {}{}",
2487 mark,
2488 row.repo_name,
2489 w.name,
2490 branch,
2491 status,
2492 pr_cell,
2493 agent_col(&agent_cells[i]),
2494 w.path.display(),
2495 rw = repo_w,
2496 nw = name_w,
2497 bw = branch_w,
2498 sw = status_w,
2499 pw = pr_w,
2500 );
2501 } else {
2502 println!(
2503 "{} {:<rw$} {:<nw$} {:<bw$} {:<sw$} {}{}",
2504 mark,
2505 row.repo_name,
2506 w.name,
2507 branch,
2508 status,
2509 agent_col(&agent_cells[i]),
2510 w.path.display(),
2511 rw = repo_w,
2512 nw = name_w,
2513 bw = branch_w,
2514 sw = status_w,
2515 );
2516 }
2517 }
2518 Ok(())
2519}
2520
2521fn format_status_text(w: &worktree::WorktreeInfo) -> String {
2522 if w.is_prunable {
2523 return "prunable".into();
2524 }
2525 if w.is_locked {
2526 return "locked".into();
2527 }
2528 let s = &w.status;
2529 if s.unknown {
2530 return "unknown".into();
2531 }
2532 let mut parts: Vec<String> = Vec::new();
2533 if s.is_dirty {
2534 parts.push("● dirty".into());
2535 }
2536 if s.has_upstream {
2537 if s.ahead > 0 {
2538 parts.push(format!("↑{}", s.ahead));
2539 }
2540 if s.behind > 0 {
2541 parts.push(format!("↓{}", s.behind));
2542 }
2543 if !s.is_dirty && s.synced() {
2544 parts.push("✓ synced".into());
2545 }
2546 } else if !s.is_dirty {
2547 parts.push("clean".into());
2548 }
2549 parts.join(" ")
2550}
2551
2552pub struct RepoContext {
2558 pub repo: Repository,
2559 pub workdir: PathBuf,
2560 pub config: Config,
2561}
2562
2563pub fn repo_context(start: Option<&Path>) -> Result<RepoContext> {
2573 let repo = worktree::discover_repo(start)?;
2574 let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
2575 let config = Config::load_for_repo(&workdir)?;
2576 Ok(RepoContext { repo, workdir, config })
2577}
2578
2579pub fn repo_context_lenient(start: Option<&Path>) -> Result<RepoContext> {
2585 let repo = worktree::discover_repo(start)?;
2586 let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
2587 let config = Config::load_for_repo(&workdir).unwrap_or_default();
2588 Ok(RepoContext { repo, workdir, config })
2589}
2590
2591fn resolve_workspace_create_repo(root: &Path, repo: Option<String>) -> Result<PathBuf> {
2596 let ws = workspace::discover(root)?;
2597 if ws.is_empty() {
2598 return Err(GwmError::EmptyWorkspace {
2599 root: root.display().to_string(),
2600 });
2601 }
2602 let available = ws.repos.iter().map(|r| r.name.as_str()).collect::<Vec<_>>().join(", ");
2603 let name = repo.ok_or_else(|| GwmError::WorkspaceRepoRequired {
2604 available: available.clone(),
2605 })?;
2606 ws.repos
2607 .iter()
2608 .find(|r| r.name == name)
2609 .map(|r| r.path.clone())
2610 .ok_or(GwmError::WorkspaceRepoNotFound { name, available })
2611}
2612
2613#[allow(clippy::too_many_arguments)]
2619fn cmd_create(
2620 branch_type: Option<String>,
2621 issue: Option<String>,
2622 desc: Option<String>,
2623 name: Option<String>,
2624 no_bootstrap: bool,
2625 reuse_branch: bool,
2626 skip_hooks: Option<String>,
2627 trust_mode: TrustMode,
2628 start: Option<&Path>,
2629) -> Result<()> {
2630 let RepoContext { repo, workdir, config } = repo_context(start)?;
2631 let repo_name = worktree::repo_name(&repo);
2632
2633 let wt_name = match name {
2638 Some(name) => WorktreeName::freeform(&name)?,
2639 None => {
2640 let resolved_types = config.resolved_branch_types();
2641 let (branch_type, issue, desc) = match (branch_type, issue, desc) {
2642 (Some(t), Some(i), Some(d)) => (t, i, d),
2643 _ => {
2644 return Err(GwmError::Other(
2645 "`gwm create` needs <TYPE> <ISSUE> <DESC> or --name".into(),
2646 ))
2647 }
2648 };
2649 WorktreeName::Structured(BranchSpec::new_with_types(
2650 branch_type,
2651 issue,
2652 desc,
2653 &resolved_types.types,
2654 )?)
2655 }
2656 };
2657
2658 let branch = wt_name.branch_name(&config.worktree, &repo_name)?;
2659 let dirname = wt_name.worktree_dirname(&config.worktree, &repo_name)?;
2660 let target = wt_name.worktree_path(&config.worktree, &repo_name, &workdir)?;
2661 let skips = HookSkips::parse(skip_hooks.as_deref())?;
2662
2663 let create_hooks_present = !config.hooks.pre_create.is_empty()
2667 || !config.hooks.post_create.is_empty()
2668 || (!no_bootstrap && !config.bootstrap.command.is_empty());
2669 if !no_bootstrap || create_hooks_present {
2670 trust_or_prompt(&workdir, Some(&repo), trust_mode)?;
2671 }
2672
2673 let pre_ctx = match &wt_name {
2681 WorktreeName::Structured(spec) => HookContext::for_create(&repo, &workdir, &workdir, &target, &branch, spec),
2682 WorktreeName::Freeform(_) => HookContext::for_worktree(&repo, &workdir, &workdir, &target, Some(&branch)),
2683 };
2684 let report = lifecycle::run_phase(&config, HookPhase::PreCreate, &pre_ctx, &skips, false)?;
2685 print_lifecycle_report(&report);
2686
2687 println!("creating worktree:");
2688 println!(" branch : {}", branch);
2689 println!(" dir : {}", dirname);
2690 println!(" path : {}", target.display());
2691
2692 let created = worktree::add(&repo, &dirname, &target, &branch, reuse_branch)?;
2693 println!("✓ worktree created at {}", created.display());
2694
2695 let post_ctx = pre_ctx.with_cwd(&created);
2696
2697 if no_bootstrap {
2698 println!("(skipped bootstrap)");
2699 } else {
2700 let report = lifecycle::run_phase(&config, HookPhase::PreBootstrap, &post_ctx, &skips, false)?;
2701 print_lifecycle_report(&report);
2702
2703 let ctx = BootstrapCtx {
2704 main_repo: &workdir,
2705 worktree: &created,
2706 config: &config,
2707 };
2708 let report = bootstrap::run_core(&ctx)?;
2709 print_report(&report);
2710
2711 let report = lifecycle::run_phase(&config, HookPhase::PostBootstrap, &post_ctx, &skips, false)?;
2712 print_lifecycle_report(&report);
2713 }
2714
2715 if config.hooks.has_any() && !config.bootstrap.command.is_empty() {
2716 eprintln!("warning: [[bootstrap.command]] is deprecated as a post_create hook when [hooks.*] is present");
2717 }
2718 let report = lifecycle::run_phase(&config, HookPhase::PostCreate, &post_ctx, &skips, !no_bootstrap)?;
2719 print_lifecycle_report(&report);
2720 Ok(())
2721}
2722
2723fn cmd_review(
2731 number: u64,
2732 name: Option<String>,
2733 bootstrap: bool,
2734 skip_hooks: Option<String>,
2735 trust_mode: TrustMode,
2736) -> Result<()> {
2737 let RepoContext { repo, workdir, config } = repo_context(None)?;
2738 let repo_name = worktree::repo_name(&repo);
2739 let forge = forge::resolve(&repo, &config)?;
2740
2741 println!("resolving {} #{number} on {} …", forge.pr_noun(), forge.slug());
2742 let head = forge.fetch_pr_head(number)?;
2743 let slug = review::head_slug(&head.head_ref_name);
2744
2745 let branch = name
2746 .clone()
2747 .unwrap_or_else(|| review::review_branch_name(number, &head.author, &slug));
2748 let dirname = match &name {
2749 Some(n) => review::dirname_from_branch(n),
2750 None => review::review_dirname(number, &head.author, &slug),
2751 };
2752 let base = crate::config::expand_placeholders(
2757 &config.worktree.base,
2758 &repo_name,
2759 Some("review"),
2760 Some(&number.to_string()),
2761 Some(&slug),
2762 Some(&workdir),
2763 )?;
2764 let target = PathBuf::from(base).join(&dirname);
2765 let skips = HookSkips::parse(skip_hooks.as_deref())?;
2766
2767 let spec = BranchSpec {
2772 type_: "review".to_string(),
2773 issue: number.to_string(),
2774 desc: slug.clone(),
2775 };
2776 let pre_ctx = HookContext::for_create(&repo, &workdir, &workdir, &target, &branch, &spec);
2777
2778 if bootstrap {
2782 trust_or_prompt(&workdir, Some(&repo), trust_mode)?;
2783 let report = lifecycle::run_phase(&config, HookPhase::PreCreate, &pre_ctx, &skips, false)?;
2784 print_lifecycle_report(&report);
2785 }
2786
2787 println!("creating review worktree:");
2788 println!(
2789 " PR : #{number} by {} ({} → {})",
2790 head.author, head.head_ref_name, head.base_ref_name
2791 );
2792 println!(" branch : {branch}");
2793 println!(" dir : {dirname}");
2794 println!(" path : {}", target.display());
2795
2796 let base_ref = (!head.base_ref_name.is_empty()).then(|| {
2809 let refspec = format!("+refs/heads/{0}:refs/remotes/origin/{0}", head.base_ref_name);
2810 let _ = worktree::run_git_logged(&workdir, &["fetch", "origin", &refspec]);
2811 format!("origin/{}", head.base_ref_name)
2812 });
2813 let head_ref = forge.pr_head_refspec(number);
2814 let rspec = review::ReviewSpec {
2815 number,
2816 head_ref: &head_ref,
2817 branch: &branch,
2818 dirname: &dirname,
2819 target: &target,
2820 base_ref: base_ref.as_deref(),
2821 };
2822 let created = review::materialize(&repo, &workdir, &rspec)?;
2823 println!("✓ review worktree created at {}", created.display());
2824 println!("✓ linked to {} #{number}", forge.pr_noun());
2825
2826 let post_ctx = pre_ctx.with_cwd(&created);
2827 match review::run_post_setup(&config, &post_ctx, &workdir, &created, &skips, bootstrap)? {
2828 Some(reports) => {
2829 print_lifecycle_report(&reports.pre_bootstrap);
2830 print_report(&reports.bootstrap);
2831 print_lifecycle_report(&reports.post_bootstrap);
2832 if config.hooks.has_any() && !config.bootstrap.command.is_empty() {
2833 eprintln!("warning: [[bootstrap.command]] is deprecated as a post_create hook when [hooks.*] is present");
2834 }
2835 print_lifecycle_report(&reports.post_create);
2836 }
2837 None => {
2838 println!("(skipped bootstrap + hooks — pass --bootstrap to run setup against the PR's code)");
2839 }
2840 }
2841 Ok(())
2842}
2843
2844fn cmd_new(
2845 branch_type: String,
2846 desc: String,
2847 no_bootstrap: bool,
2848 reuse_branch: bool,
2849 skip_hooks: Option<String>,
2850 trust_mode: TrustMode,
2851) -> Result<()> {
2852 let RepoContext { repo, config, .. } = repo_context(None)?;
2853 let repo_name = worktree::repo_name(&repo);
2854 let resolved_types = config.resolved_branch_types();
2855 let spec = BranchSpec::new_with_types(branch_type.clone(), "0", desc, &resolved_types.types)?;
2856 let draft = issue_templates::render_issue_draft(&repo, &config, &spec.type_, &spec.desc)?;
2857 let forge = forge::resolve_or_default(&repo, &config)?;
2858 let created = forge.create_issue(&forge::IssueCreateRequest {
2859 title: &draft.title,
2860 body_file: draft.body_file.path(),
2861 labels: &draft.labels,
2862 })?;
2863
2864 let label_summary = if draft.labels.is_empty() {
2865 String::new()
2866 } else {
2867 format!(" (labels: {})", draft.labels.join(", "))
2868 };
2869 println!("✓ created issue #{} {}{}", created.number, draft.title, label_summary);
2870 let issue = created.number.to_string();
2871 let branch = BranchSpec::new_with_types(
2872 spec.type_.clone(),
2873 issue.clone(),
2874 spec.desc.clone(),
2875 &resolved_types.types,
2876 )?
2877 .branch_name(&config.worktree, &repo_name)?;
2878 println!(" {}", created.url);
2879 println!("creating linked worktree for {}", branch);
2880
2881 cmd_create(
2882 Some(spec.type_),
2883 Some(issue),
2884 Some(spec.desc),
2885 None,
2888 no_bootstrap,
2889 reuse_branch,
2890 skip_hooks,
2891 trust_mode,
2892 None,
2893 )
2894}
2895
2896const PR_FILES_CHANGED_MAX_LINES: usize = 30;
2902
2903fn cmd_pr(render_only: bool, draft: bool, base_override: Option<String>) -> Result<()> {
2904 let RepoContext { repo, workdir, config } = repo_context(None)?;
2905 let head_name = current_branch_at(None)?;
2909 let branch_spec = crate::naming::BranchParser::from_config(&config, &worktree::repo_name(&repo)).parse(&head_name);
2912
2913 let branch_type = branch_spec
2919 .as_ref()
2920 .map(|s| s.type_.clone())
2921 .filter(|t| !t.is_empty())
2922 .unwrap_or_else(|| "chore".into());
2923 let issue = branch_spec.as_ref().map(|s| s.issue.clone()).unwrap_or_default();
2924 let desc = branch_spec.as_ref().map(|s| s.desc.clone()).unwrap_or_default();
2925
2926 let base = base_override
2927 .or_else(|| worktree::resolve_trunk(&repo, &config.doctor.trunks))
2928 .unwrap_or_else(|| "main".into());
2929
2930 let commits = worktree::git_log_subject_between(&workdir, &base, &head_name)
2931 .inspect_err(|e| {
2932 eprintln!(
2933 "note: could not collect `{{commits}}` from `git log {}..{}`: {} (placeholder will be empty)",
2934 base, head_name, e
2935 );
2936 })
2937 .unwrap_or_default();
2938 let files_changed = worktree::git_diff_stat_between(&workdir, &base, &head_name, PR_FILES_CHANGED_MAX_LINES)
2939 .inspect_err(|e| {
2940 eprintln!(
2941 "note: could not collect `{{files_changed}}` from `git diff --stat {}..{}`: {} (placeholder will be empty)",
2942 base, head_name, e
2943 );
2944 })
2945 .unwrap_or_default();
2946 let repo_slug = forge::repo_slug(&repo).unwrap_or_default();
2951
2952 let ctx = PrTemplateContext {
2953 branch_type: branch_type.clone(),
2954 issue,
2955 desc,
2956 base: base.clone(),
2957 head: head_name.clone(),
2958 commits,
2959 files_changed,
2960 repo: repo_slug.clone(),
2961 };
2962 let body = pr_templates::render_pr_body(&config.pr_template, &workdir, &ctx)?;
2963
2964 if render_only {
2965 print!("{}", body);
2966 if !body.ends_with('\n') {
2967 println!();
2968 }
2969 return Ok(());
2970 }
2971
2972 let mut body_file = tempfile::NamedTempFile::new()?;
2973 use std::io::Write;
2974 body_file.write_all(body.as_bytes())?;
2975 body_file.flush()?;
2976
2977 let title = pr_title(&ctx);
2978 let forge = forge::resolve_or_default(&repo, &config)?;
2979 let created = forge.create_pr(&forge::PrCreateRequest {
2980 title: &title,
2981 body_file: body_file.path(),
2982 head: &head_name,
2983 base: Some(base.as_str()),
2984 draft,
2985 })?;
2986 println!("✓ created {} #{}", forge.pr_noun(), created.number);
2987 println!(" {}", created.url);
2988 if let Err(e) = github::link_pr(&repo, &head_name, created.number) {
2989 eprintln!("note: could not record gwm-pr config for {}: {}", head_name, e);
2992 }
2993 Ok(())
2994}
2995
2996fn pr_title(ctx: &PrTemplateContext) -> String {
2997 if let Some(first) = ctx.commits.lines().next() {
3001 let trimmed = first.trim_start_matches("- ").trim();
3002 if !trimmed.is_empty() {
3003 return trimmed.to_string();
3004 }
3005 }
3006 if !ctx.desc.is_empty() {
3007 return format!("{}: {}", ctx.branch_type, ctx.desc);
3008 }
3009 format!("update {}", ctx.head)
3010}
3011
3012pub fn format_remove_plan(name: &str, path: &Path, branch: Option<&str>, delete_branch: bool) -> String {
3024 use std::fmt::Write;
3025 let mut out = String::new();
3026 let _ = writeln!(out, "would remove:");
3027 let _ = writeln!(out, " name: {}", name);
3028 let _ = writeln!(out, " path: {}", path.display());
3029 match (branch, delete_branch) {
3030 (Some(b), true) => {
3031 let _ = writeln!(out, " branch: {} (would be deleted)", b);
3032 }
3033 (Some(b), false) => {
3034 let _ = writeln!(out, " branch: {}", b);
3035 }
3036 (None, true) => {
3037 let _ = writeln!(out, " branch: - (no branch to delete)");
3043 }
3044 (None, false) => {
3045 let _ = writeln!(out, " branch: -");
3046 }
3047 }
3048 out
3049}
3050
3051pub fn format_prune_plan(entries: &[worktree::PrunableEntry]) -> String {
3063 use std::fmt::Write;
3064 let mut out = String::new();
3065 if entries.is_empty() {
3066 let _ = writeln!(out, "0 worktree(s) to prune");
3067 return out;
3068 }
3069 let name_w = entries.iter().map(|e| e.name.chars().count()).max().unwrap_or(4);
3075 let path_w = entries
3076 .iter()
3077 .map(|e| e.path.display().to_string().chars().count())
3078 .max()
3079 .unwrap_or(4);
3080 let _ = writeln!(out, "would prune {} worktree(s):", entries.len());
3081 for entry in entries {
3082 let _ = writeln!(
3083 out,
3084 " {:<nw$} {:<pw$} ({})",
3085 entry.name,
3086 entry.path.display(),
3087 entry.reason,
3088 nw = name_w,
3089 pw = path_w,
3090 );
3091 }
3092 out
3093}
3094
3095fn cmd_remove(
3096 pattern: String,
3097 delete_branch: bool,
3098 dry_run: bool,
3099 force: bool,
3100 skip_hooks: Option<String>,
3101 trust_mode: TrustMode,
3102) -> Result<()> {
3103 let repo = worktree::discover_repo(None)?;
3104 let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
3105 let found = worktree::find_fuzzy(&repo, &pattern)?;
3106 if dry_run {
3107 worktree::remove_dry_run(&repo, &found.id)?;
3115 print!(
3116 "{}",
3117 format_remove_plan(&found.name, &found.path, found.branch.as_deref(), delete_branch)
3118 );
3119 return Ok(());
3120 }
3121
3122 let config = Config::load_for_repo(&workdir)?;
3123 let mut skips = HookSkips::parse(skip_hooks.as_deref())?;
3124 if force {
3125 skips = skips.with(HookPhase::PreRemove).with(HookPhase::PostRemove);
3126 }
3127 if config.hooks.has_any() {
3128 trust_or_prompt(&workdir, Some(&repo), trust_mode)?;
3129 }
3130 let pre_ctx = HookContext::for_worktree(&repo, &workdir, &found.path, &found.path, found.branch.as_deref());
3131 let report = lifecycle::run_phase(&config, HookPhase::PreRemove, &pre_ctx, &skips, false)?;
3132 print_lifecycle_report(&report);
3133
3134 let branch_oid = found.branch.as_deref().and_then(|b| {
3143 repo
3144 .find_branch(b, git2::BranchType::Local)
3145 .ok()
3146 .and_then(|br| br.into_reference().target())
3147 .map(|o| o.to_string())
3148 });
3149 let repo_root = repo
3150 .workdir()
3151 .map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()))
3152 .unwrap_or_default();
3153 let entry = OpEntry {
3154 ts: chrono::Utc::now(),
3155 kind: crate::history::OpKind::Remove,
3156 worktree: found.name.clone(),
3157 branch: found.branch.clone(),
3158 branch_oid,
3159 path: found.path.clone(),
3160 deleted_branch: delete_branch,
3161 repo_root,
3162 undone: false,
3163 };
3164 if let Err(e) = history::record(entry) {
3165 eprintln!(
3166 "warning: failed to record undo journal entry: {} (continuing with the remove anyway)",
3167 e
3168 );
3169 }
3170
3171 worktree::remove(&repo, &found.id, delete_branch)?;
3172 println!("✓ removed {} ({})", found.name, found.path.display());
3173 if delete_branch {
3174 if let Some(b) = &found.branch {
3175 println!(" branch {} deleted", b);
3176 }
3177 }
3178 let post_ctx = pre_ctx.with_cwd(&workdir);
3179 let report = lifecycle::run_phase(&config, HookPhase::PostRemove, &post_ctx, &skips, false)?;
3180 print_lifecycle_report(&report);
3181 Ok(())
3182}
3183
3184fn cmd_path(pattern: String, format: OutputFormat) -> Result<()> {
3185 let repo = worktree::discover_repo(None)?;
3186 let found = worktree::find_fuzzy(&repo, &pattern)?;
3187 match format {
3188 OutputFormat::Text => println!("{}", found.path.display()),
3189 OutputFormat::Json => {
3190 let dto = json_api::JsonPath::from(&found);
3191 println!("{}", serde_json::to_string_pretty(&dto)?);
3192 }
3193 }
3194 Ok(())
3195}
3196
3197fn cmd_bootstrap(target: Option<String>, skip_hooks: Option<String>, trust_mode: TrustMode) -> Result<()> {
3198 let RepoContext { repo, workdir, config } = repo_context(None)?;
3199
3200 let mut worktree_branch: Option<String> = None;
3201 let worktree_path: PathBuf = match target {
3202 Some(t) => {
3203 let p = PathBuf::from(&t);
3204 if p.is_dir() {
3205 p
3206 } else {
3207 let found = worktree::find_fuzzy(&repo, &t)?;
3208 worktree_branch = found.branch.clone();
3209 found.path
3210 }
3211 }
3212 None => std::env::current_dir()?,
3213 };
3214 if worktree_branch.is_none() {
3220 worktree_branch = current_branch_at(Some(&worktree_path)).ok();
3221 }
3222 let skips = HookSkips::parse(skip_hooks.as_deref())?;
3223
3224 trust_or_prompt(&workdir, Some(&repo), trust_mode)?;
3225
3226 let hook_ctx = HookContext::for_worktree(
3227 &repo,
3228 &workdir,
3229 &worktree_path,
3230 &worktree_path,
3231 worktree_branch.as_deref(),
3232 );
3233 let report = lifecycle::run_phase(&config, HookPhase::PreBootstrap, &hook_ctx, &skips, false)?;
3234 print_lifecycle_report(&report);
3235
3236 let ctx = BootstrapCtx {
3237 main_repo: &workdir,
3238 worktree: &worktree_path,
3239 config: &config,
3240 };
3241 let report = bootstrap::run_core(&ctx)?;
3242 print_report(&report);
3243 let report = lifecycle::run_phase(&config, HookPhase::PostBootstrap, &hook_ctx, &skips, false)?;
3244 print_lifecycle_report(&report);
3245 Ok(())
3246}
3247
3248fn cmd_sync(pattern: Option<String>, merge: bool) -> Result<()> {
3249 let (target_path, name) = match pattern {
3257 Some(p) => {
3258 let repo = worktree::discover_repo(None)?;
3259 let found = worktree::find_fuzzy(&repo, &p)?;
3260 (found.path, found.name)
3261 }
3262 None => {
3263 let cwd = std::env::current_dir()?;
3264 let repo = Repository::discover(&cwd).map_err(|_| GwmError::NotInGitRepo)?;
3265 let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
3266 let name = workdir
3267 .file_name()
3268 .map(|n| n.to_string_lossy().into_owned())
3269 .unwrap_or_else(|| "worktree".into());
3270 (workdir, name)
3271 }
3272 };
3273
3274 let strategy = if merge {
3275 SyncStrategy::Merge
3276 } else {
3277 SyncStrategy::Rebase
3278 };
3279 let report = sync::sync(&target_path, strategy)?;
3280 print!("{}", format_sync_report(&name, &report));
3281 Ok(())
3282}
3283
3284pub fn format_sync_report(name: &str, report: &SyncReport) -> String {
3289 match report.action {
3290 SyncAction::UpToDate => {
3291 format!("✓ {} already up to date with {}\n", name, report.upstream)
3292 }
3293 SyncAction::Integrated => {
3294 let verb = match report.strategy {
3295 SyncStrategy::Rebase => "rebased",
3296 SyncStrategy::Merge => "merged",
3297 };
3298 let plural = if report.behind_before == 1 { "" } else { "s" };
3299 format!(
3300 "✓ {} {} {} commit{} from {}\n",
3301 name, verb, report.behind_before, plural, report.upstream
3302 )
3303 }
3304 }
3305}
3306
3307fn cmd_prune(dry_run: bool) -> Result<()> {
3308 let repo = worktree::discover_repo(None)?;
3309 if dry_run {
3310 let plan = worktree::prunable_worktrees(&repo)?;
3315 print!("{}", format_prune_plan(&plan));
3316 return Ok(());
3317 }
3318 let n = worktree::prune(&repo)?;
3319 println!("pruned {} stale worktree(s)", n);
3320 Ok(())
3321}
3322
3323fn cmd_doctor(format: OutputFormat) -> Result<()> {
3324 let RepoContext { repo, workdir, config } = repo_context_lenient(None)?;
3325
3326 let global = crate::config::global_config_path();
3330 let ctx = DoctorCtx {
3331 repo_workdir: &workdir,
3332 repo: &repo,
3333 config: &config,
3334 global_config_path: global.as_deref(),
3335 };
3336 let report = doctor::run(&ctx)?;
3337 match format {
3338 OutputFormat::Text => print_doctor_report(&report),
3339 OutputFormat::Json => {
3340 let dto = json_api::JsonDoctorReport::from(&report);
3341 println!("{}", serde_json::to_string_pretty(&dto)?);
3342 }
3343 }
3344
3345 let code = report.exit_code();
3349 if code != 0 {
3350 std::process::exit(code);
3351 }
3352 Ok(())
3353}
3354
3355#[cfg(all(any(unix, windows), feature = "daemon"))]
3361fn cmd_daemon(socket: Option<PathBuf>, poll_ms: u64) -> Result<()> {
3362 use std::sync::atomic::AtomicBool;
3363 use std::sync::Arc;
3364
3365 let repo = worktree::discover_repo(None)?;
3366 let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
3367 let (socket, manage_socket_dir) = match socket {
3371 Some(s) => (s, false),
3372 None => crate::daemon::default_socket(),
3373 };
3374 let mut opts = crate::daemon::ServeOptions::new(socket, workdir, std::time::Duration::from_millis(poll_ms));
3375 opts.manage_socket_dir = manage_socket_dir;
3376 crate::daemon::serve(&opts, Arc::new(AtomicBool::new(false)))
3380}
3381
3382#[cfg(not(all(any(unix, windows), feature = "daemon")))]
3383fn cmd_daemon(socket: Option<PathBuf>, poll_ms: u64) -> Result<()> {
3384 let _ = (socket, poll_ms);
3385 Err(GwmError::Other(
3386 "daemon mode is unavailable in this build (requires the `daemon` feature on a supported platform)".into(),
3387 ))
3388}
3389
3390fn print_statusline(worktrees: &[crate::json_api::JsonWorktree], cwd: &Path) {
3395 use std::io::Write;
3396 let active = crate::statusline::active_index_with(worktrees, cwd, |p| {
3400 std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
3401 });
3402 println!("{}", crate::statusline::render(worktrees, active));
3403 let _ = io::stdout().flush();
3404}
3405
3406#[cfg(all(any(unix, windows), feature = "daemon"))]
3407fn cmd_statusline(socket: Option<PathBuf>, watch: bool) -> Result<()> {
3408 let socket = socket.unwrap_or_else(crate::daemon::socket_path);
3409 let cwd = std::env::current_dir().unwrap_or_default();
3412
3413 if watch {
3414 crate::statusline::watch(
3420 |cb| crate::daemon::client::subscribe(&socket, cb),
3421 |worktrees| print_statusline(worktrees, &cwd),
3422 );
3423 return Ok(());
3424 }
3425
3426 match crate::daemon::client::list_once(&socket) {
3427 Ok(worktrees) => print_statusline(&worktrees, &cwd),
3428 Err(_) => print_statusline(&[], &cwd),
3430 }
3431 Ok(())
3432}
3433
3434#[cfg(not(all(any(unix, windows), feature = "daemon")))]
3435fn cmd_statusline(socket: Option<PathBuf>, watch: bool) -> Result<()> {
3436 let _ = (socket, watch);
3443 let cwd = std::env::current_dir().unwrap_or_default();
3444 print_statusline(&[], &cwd);
3445 Ok(())
3446}
3447
3448fn print_doctor_report(report: &doctor::DoctorReport) {
3449 let clean = crate::naming::sanitise_block_for_terminal;
3462 let indented = |text: &str, first: &str| {
3463 let cleaned = clean(text);
3464 let mut lines = cleaned.split('\n');
3465 let mut out = format!("{}{}", first, lines.next().unwrap_or_default());
3466 for line in lines {
3467 out.push_str("\n ");
3468 out.push_str(line);
3469 }
3470 out
3471 };
3472 for c in &report.checks {
3473 let sigil = match c.status {
3474 CheckStatus::Ok => "✓",
3475 CheckStatus::Warning => "!",
3476 CheckStatus::Failed => "✗",
3477 };
3478 println!("{} {}", sigil, crate::naming::sanitise_for_terminal(&c.name));
3481 if !c.detail.is_empty() {
3482 println!("{}", indented(&c.detail, " "));
3483 }
3484 if let Some(hint) = &c.fix_hint {
3485 println!("{}", indented(hint, " → "));
3486 }
3487 }
3488}
3489
3490fn cmd_types(gitmoji_flag: bool) -> Result<()> {
3491 let workdir = match worktree::discover_repo(None) {
3500 Ok(repo) => repo.workdir().map(|w| w.to_path_buf()),
3501 Err(_) => None,
3502 };
3503 let resolved = match &workdir {
3504 Some(w) => Config::load_for_repo(w)?.resolved_branch_types(),
3505 None => Config::default().resolved_branch_types(),
3506 };
3507
3508 let gitmoji_map = if gitmoji_flag {
3512 Some(gitmoji::load(workdir.as_deref())?)
3513 } else {
3514 None
3515 };
3516
3517 let width = resolved.types.iter().map(|t| t.name.len()).max().unwrap_or(0).max(8);
3520 let clean = crate::naming::sanitise_for_terminal;
3530 let sc_width = match &gitmoji_map {
3531 Some(map) => map.iter().map(|(_, sc)| clean(sc).len()).max().unwrap_or(0).max(10),
3532 None => 0,
3533 };
3534
3535 for t in &resolved.types {
3536 match &gitmoji_map {
3537 Some(map) => {
3538 let shortcode = clean(map.get(&t.name).unwrap_or(":question:"));
3542 let unicode = gitmoji::shortcode_to_unicode(&shortcode);
3543 println!(
3544 " {:<width$} {} {:<sw$} {}",
3545 t.name,
3546 unicode,
3547 shortcode,
3548 clean(&t.description),
3549 width = width,
3550 sw = sc_width,
3551 );
3552 }
3553 None => {
3554 println!(" {:<width$} {}", t.name, clean(&t.description), width = width);
3555 }
3556 }
3557 }
3558 println!();
3559 println!("(source: {})", resolved.source.label());
3560 Ok(())
3561}
3562
3563fn cmd_commit_prefix(branch_override: Option<String>, unicode: bool) -> Result<()> {
3568 let (workdir, repo_name, branch_name) = match branch_override {
3587 Some(name) => {
3588 let repo = worktree::discover_repo(None).ok();
3593 let workdir = repo.as_ref().and_then(|r| r.workdir().map(|w| w.to_path_buf()));
3594 (workdir, repo.as_ref().map(worktree::repo_name), name)
3595 }
3596 None => {
3597 let repo = worktree::discover_repo(None)?;
3598 let wd = repo.workdir().map(|w| w.to_path_buf());
3599 let name = current_branch_at(None)?;
3606 (wd, Some(worktree::repo_name(&repo)), name)
3607 }
3608 };
3609
3610 let config = workdir.as_deref().and_then(|wd| Config::load_for_repo(wd).ok());
3616 let parser = match (config.as_ref(), repo_name.as_deref()) {
3617 (Some(cfg), Some(repo)) => crate::naming::BranchParser::from_config(cfg, repo),
3618 _ => crate::naming::BranchParser::builtin().clone(),
3619 };
3620 let pattern = crate::naming::sanitise_for_terminal(
3625 &config
3626 .as_ref()
3627 .map(|c| c.worktree.branch_pattern.clone())
3628 .unwrap_or_else(crate::config::default_branch_pattern),
3629 );
3630
3631 let spec = parser.parse(&branch_name).ok_or_else(|| {
3637 GwmError::Other(format!(
3638 "branch '{}' does not match this repo's branch pattern `{}`, so it carries no branch type to \
3639 read — a commit prefix is derived from one, and a free-form branch has none. Pass --branch \
3640 <name written by the pattern>, or write the prefix by hand",
3641 branch_name, pattern
3642 ))
3643 })?;
3644
3645 if spec.type_.is_empty() || spec.issue.is_empty() {
3652 let want = match (spec.type_.is_empty(), spec.issue.is_empty()) {
3653 (true, true) => "`{type}` and `{issue}`",
3657 (true, false) => "`{type}`",
3658 _ => "`{issue}`",
3659 };
3660 return Err(GwmError::Other(format!(
3661 "this repo's branch pattern `{}` carries no {}, so branch '{}' has none to read — a commit \
3662 prefix is built from the branch type and issue number. Add {} to worktree.branch_pattern, \
3663 or write the prefix by hand",
3664 pattern, want, branch_name, want
3665 )));
3666 }
3667
3668 let map = gitmoji::load(workdir.as_deref())?;
3669 let prefix = gitmoji::resolve_prefix(&map, &spec, unicode);
3670 println!("{}", crate::naming::sanitise_for_terminal(&prefix));
3675 Ok(())
3676}
3677
3678fn cmd_hooks(action: HooksAction) -> Result<()> {
3683 match action {
3684 HooksAction::Install { hook, force } => cmd_hooks_install(hook, force),
3685 }
3686}
3687
3688fn cmd_hooks_install(hook: HookKind, force: bool) -> Result<()> {
3689 let repo = worktree::discover_repo(None)?;
3690 let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
3691 match hook {
3692 HookKind::CommitMsg => {
3693 let path = hooks::install_commit_msg(&workdir, force)?;
3694 println!("✓ installed {}", path.display());
3695 println!(" (auto-prepends gitmoji+type prefix when missing)");
3696 }
3697 }
3698 Ok(())
3699}
3700
3701fn cmd_completions(shell: Shell) -> Result<()> {
3702 let mut cmd = Cli::command();
3703 let name = cmd.get_name().to_string();
3704 generate(shell, &mut cmd, name, &mut io::stdout());
3705 Ok(())
3706}
3707
3708fn cmd_shell_init(shell: InitShell) -> Result<()> {
3709 print!("{}", shell_init_script(shell));
3710 Ok(())
3711}
3712
3713fn cmd_switch() -> Result<()> {
3722 let _ = worktree::discover_repo(None)?;
3726 match crate::tui::run_picker()? {
3727 Some(path) => {
3728 println!("{}", path.display());
3729 Ok(())
3730 }
3731 None => std::process::exit(1),
3732 }
3733}
3734
3735fn cmd_multiplexer(mux: Multiplexer, pattern: String, split: bool) -> Result<()> {
3749 let repo = worktree::discover_repo(None)?;
3750
3751 let env_name = match mux {
3752 Multiplexer::Tmux => "TMUX",
3753 Multiplexer::Zellij => "ZELLIJ",
3754 };
3755 let env_value = std::env::var(env_name).ok();
3756 let running = match mux {
3757 Multiplexer::Tmux => detect_tmux(env_value),
3758 Multiplexer::Zellij => detect_zellij(env_value),
3759 };
3760 if !running {
3761 return Err(GwmError::Other(format!(
3765 "{0} session not running (${1} unset) — run `gwm {0} <pattern>` from inside a {0} session",
3766 mux.binary(),
3767 env_name,
3768 )));
3769 }
3770
3771 let found = worktree::find_fuzzy(&repo, &pattern)?;
3772 let mode = if split { SpawnMode::Split } else { SpawnMode::Window };
3773 let argv = match mux {
3774 Multiplexer::Tmux => build_tmux_command(&found.name, &found.path, mode),
3775 Multiplexer::Zellij => build_zellij_command(&found.name, &found.path, mode),
3776 };
3777 spawn_multiplexer(mux, &argv)
3778}
3779
3780fn spawn_multiplexer(mux: Multiplexer, argv: &[String]) -> Result<()> {
3785 let (bin, rest) = argv.split_first().ok_or_else(|| {
3786 GwmError::Other(format!(
3787 "empty argv for {} spawn (build_*_command returned [])",
3788 mux.binary()
3789 ))
3790 })?;
3791 let status = std::process::Command::new(bin)
3795 .args(rest)
3796 .status()
3797 .map_err(|e| GwmError::CommandFailed(format!("could not spawn {}: {}", bin, e)))?;
3798 if !status.success() {
3799 return Err(GwmError::CommandFailed(format!(
3800 "{} exited with status {:?}",
3801 bin,
3802 status.code()
3803 )));
3804 }
3805 Ok(())
3806}
3807
3808fn resolve_target_repo(worktree: Option<String>) -> Result<(Repository, String, PathBuf)> {
3817 let path: PathBuf = match worktree {
3818 Some(pat) => {
3819 let p = PathBuf::from(&pat);
3821 if p.is_dir() {
3822 p
3823 } else {
3824 let main = worktree::discover_repo(None)?;
3825 worktree::find_fuzzy(&main, &pat)?.path
3826 }
3827 }
3828 None => std::env::current_dir()?,
3829 };
3830 let repo = Repository::discover(&path).map_err(|_| GwmError::NotInGitRepo)?;
3831 let branch = current_branch(&repo)?;
3832 Ok((repo, branch, path))
3833}
3834
3835fn current_branch_at(start: Option<&Path>) -> Result<String> {
3857 let from = match start {
3858 Some(p) => p.to_path_buf(),
3859 None => std::env::current_dir()?,
3860 };
3861 let repo = Repository::discover(&from).map_err(|_| GwmError::NotInGitRepo)?;
3862 current_branch(&repo)
3863}
3864
3865fn current_branch(repo: &Repository) -> Result<String> {
3866 let head = repo.head().map_err(|_| GwmError::UnbornHead {
3867 reason: "HEAD is unborn or detached".into(),
3868 })?;
3869 head
3870 .shorthand()
3871 .ok()
3872 .map(|s| s.to_string())
3873 .ok_or_else(|| GwmError::UnbornHead {
3874 reason: "HEAD has no shorthand (detached?)".into(),
3875 })
3876}
3877
3878fn cmd_link(target: LinkTarget, number: u64, worktree: Option<String>) -> Result<()> {
3879 let (repo, branch, _path) = resolve_target_repo(worktree)?;
3880 let config = Config::load_for_repo(repo.workdir().unwrap_or_else(|| repo.path()))?;
3883 forge::reconcile_links(&repo, &config);
3884 match target {
3885 LinkTarget::Issue => {
3886 github::link_issue(&repo, &branch, number)?;
3887 println!("✓ linked issue #{} to branch {}", number, branch);
3888 }
3889 LinkTarget::Pr => {
3890 github::link_pr(&repo, &branch, number)?;
3891 println!("✓ linked PR #{} to branch {}", number, branch);
3892 }
3893 }
3894 Ok(())
3895}
3896
3897fn cmd_unlink(target: LinkTarget, worktree: Option<String>) -> Result<()> {
3898 let (repo, branch, _path) = resolve_target_repo(worktree)?;
3899 match target {
3900 LinkTarget::Issue => {
3901 github::unlink_issue(&repo, &branch)?;
3902 println!("✓ unlinked issue on branch {}", branch);
3903 }
3904 LinkTarget::Pr => {
3905 github::unlink_pr(&repo, &branch)?;
3906 println!("✓ unlinked PR on branch {}", branch);
3907 }
3908 }
3909 Ok(())
3910}
3911
3912fn cmd_open(target: LinkTarget, worktree: Option<String>, print_url: bool) -> Result<()> {
3913 let (repo, branch, _path) = resolve_target_repo(worktree)?;
3914 let config = Config::load_for_repo(repo.workdir().unwrap_or_else(|| repo.path()))?;
3919 let forge = forge::resolve(&repo, &config)?;
3920 let link = github::read_link(&repo, &branch)?;
3921
3922 let url = match target {
3923 LinkTarget::Issue => {
3924 let n = link.issue.ok_or_else(|| GwmError::LinkMissing {
3925 kind: LinkKind::Issue,
3926 branch: branch.clone(),
3927 })?;
3928 forge.issue_url_confirmed(n)
3929 }
3930 LinkTarget::Pr => {
3931 let n = link.pr.ok_or_else(|| GwmError::LinkMissing {
3932 kind: LinkKind::Pr,
3933 branch: branch.clone(),
3934 })?;
3935 forge.pr_url_confirmed(n)
3936 }
3937 };
3938
3939 if print_url {
3940 println!("{}", url);
3941 return Ok(());
3942 }
3943 spawn_opener(&url)
3944}
3945
3946fn spawn_opener(url: &str) -> Result<()> {
3947 let opener = if cfg!(target_os = "macos") {
3948 "open"
3949 } else if cfg!(target_os = "windows") {
3950 "explorer"
3951 } else {
3952 "xdg-open"
3953 };
3954 let status = std::process::Command::new(opener)
3955 .arg(url)
3956 .status()
3957 .map_err(|e| GwmError::CommandFailed(format!("could not spawn {}: {}", opener, e)))?;
3958 if !status.success() {
3959 return Err(GwmError::CommandFailed(format!(
3960 "{} exited with status {:?}",
3961 opener,
3962 status.code()
3963 )));
3964 }
3965 Ok(())
3966}
3967
3968fn cmd_status(worktree: Option<String>, json: bool) -> Result<()> {
3969 let (repo, branch, _path) = resolve_target_repo(worktree)?;
3970
3971 let config = Config::load_for_repo(repo.workdir().unwrap_or_else(|| repo.path()))?;
3974 let forge = forge::resolve(&repo, &config).ok();
3975 let slug = forge.as_ref().map(|f| f.slug().to_string());
3976 let link = match forge.as_ref() {
3980 Some(f) => github::read_link_with_pr_detection(&repo, &branch, f.as_ref())?,
3981 None => github::read_link(&repo, &branch)?,
3982 };
3983 let (issue_status, pr_status) = fetch_link_status(&repo, &branch, &link, forge.as_deref());
3984
3985 if json {
3986 println!(
3987 "{}",
3988 build_status_json(&branch, slug.as_deref(), &link, &issue_status, &pr_status)
3989 );
3990 } else {
3991 let pr_noun = forge.as_ref().map_or("PR", |f| f.pr_noun());
3992 print_status_human(&branch, slug.as_deref(), &link, &issue_status, &pr_status, pr_noun);
3993 }
3994 Ok(())
3995}
3996
3997fn fetch_link_status(
3998 repo: &Repository,
3999 branch: &str,
4000 link: &BranchLink,
4001 forge: Option<&dyn forge::Forge>,
4002) -> (Option<IssueStatus>, Option<PrStatus>) {
4003 let Some(forge) = forge else {
4004 return (None, None);
4005 };
4006 let issue = link.issue.and_then(|n| forge.fetch_issue(n).ok());
4008 let pr = link.pr.and_then(|n| forge.fetch_pr(n).ok());
4009 if let Some(issue) = &issue {
4010 let _ = github::persist_issue_title(repo, branch, &issue.title);
4011 let _ = github::persist_issue_state(repo, branch, issue.state);
4012 }
4013 if let Some(pr) = &pr {
4014 let _ = match link.pr_source {
4015 LinkSource::Detected => github::persist_detected_pr_title(repo, branch, &pr.title)
4016 .and_then(|()| github::persist_detected_pr_state(repo, branch, pr.state)),
4017 LinkSource::Explicit => github::persist_pr_title(repo, branch, &pr.title)
4018 .and_then(|()| github::persist_pr_state(repo, branch, pr.state)),
4019 LinkSource::BranchName | LinkSource::None => Ok(()),
4020 };
4021 }
4022 (issue, pr)
4023}
4024
4025fn issue_state_str(s: IssueState) -> &'static str {
4026 match s {
4027 IssueState::Open => "open",
4028 IssueState::Closed => "closed",
4029 }
4030}
4031
4032fn pr_state_str(s: PrState) -> &'static str {
4033 match s {
4034 PrState::Open => "open",
4035 PrState::Draft => "draft",
4036 PrState::Closed => "closed",
4037 PrState::Merged => "merged",
4038 }
4039}
4040
4041fn link_source_str(s: LinkSource) -> &'static str {
4042 match s {
4043 LinkSource::None => "none",
4044 LinkSource::BranchName => "branch-name",
4045 LinkSource::Explicit => "explicit",
4046 LinkSource::Detected => "detected",
4047 }
4048}
4049
4050fn print_status_human(
4051 branch: &str,
4052 slug: Option<&str>,
4053 link: &BranchLink,
4054 issue: &Option<IssueStatus>,
4055 pr: &Option<PrStatus>,
4056 pr_noun: &str,
4060) {
4061 println!("branch: {}", branch);
4062 if let Some(s) = slug {
4063 println!("repo: {}", s);
4064 }
4065 println!("link: {}", link.summary(pr_noun));
4066
4067 if let Some(n) = link.issue {
4068 print!("issue: #{}", n);
4069 match issue {
4070 Some(s) => println!(" [{}] {}", issue_state_str(s.state), s.title),
4071 None => println!(" (status unavailable)"),
4072 }
4073 }
4074 if let Some(n) = link.pr {
4075 print!("pr: #{}", n);
4076 match pr {
4077 Some(s) => {
4078 let checks = if s.checks_total > 0 {
4079 format!(" · checks {}/{}", s.checks_passed, s.checks_total)
4080 } else {
4081 String::new()
4082 };
4083 println!(" [{}]{} {}", pr_state_str(s.state), checks, s.title);
4084 }
4085 None => println!(" (status unavailable)"),
4086 }
4087 }
4088}
4089
4090pub fn build_status_json(
4096 branch: &str,
4097 slug: Option<&str>,
4098 link: &BranchLink,
4099 issue: &Option<IssueStatus>,
4100 pr: &Option<PrStatus>,
4101) -> serde_json::Value {
4102 let mut obj = serde_json::Map::new();
4103 obj.insert("branch".into(), serde_json::Value::String(branch.into()));
4104 if let Some(s) = slug {
4105 obj.insert("repo".into(), serde_json::Value::String(s.into()));
4106 }
4107 obj.insert(
4108 "issue".into(),
4109 match link.issue {
4110 Some(n) => {
4111 let mut o = serde_json::Map::new();
4112 o.insert("number".into(), serde_json::Value::Number(n.into()));
4113 o.insert(
4114 "source".into(),
4115 serde_json::Value::String(link_source_str(link.issue_source).into()),
4116 );
4117 if let Some(s) = issue {
4118 o.insert(
4119 "state".into(),
4120 serde_json::Value::String(issue_state_str(s.state).into()),
4121 );
4122 o.insert("title".into(), serde_json::Value::String(s.title.clone()));
4123 o.insert(
4124 "labels".into(),
4125 serde_json::Value::Array(s.labels.iter().map(|l| serde_json::Value::String(l.clone())).collect()),
4126 );
4127 o.insert("url".into(), serde_json::Value::String(s.url.clone()));
4128 }
4129 serde_json::Value::Object(o)
4130 }
4131 None => serde_json::Value::Null,
4132 },
4133 );
4134 obj.insert(
4135 "pr".into(),
4136 match link.pr {
4137 Some(n) => {
4138 let mut o = serde_json::Map::new();
4139 o.insert("number".into(), serde_json::Value::Number(n.into()));
4140 o.insert(
4141 "source".into(),
4142 serde_json::Value::String(link_source_str(link.pr_source).into()),
4143 );
4144 if let Some(s) = pr {
4145 o.insert("state".into(), serde_json::Value::String(pr_state_str(s.state).into()));
4146 o.insert("title".into(), serde_json::Value::String(s.title.clone()));
4147 o.insert(
4148 "checks_passed".into(),
4149 serde_json::Value::Number(s.checks_passed.into()),
4150 );
4151 o.insert("checks_total".into(), serde_json::Value::Number(s.checks_total.into()));
4152 o.insert("url".into(), serde_json::Value::String(s.url.clone()));
4153 }
4154 serde_json::Value::Object(o)
4155 }
4156 None => serde_json::Value::Null,
4157 },
4158 );
4159 serde_json::Value::Object(obj)
4160}
4161
4162fn cmd_labels(action: LabelsAction) -> Result<()> {
4165 match action {
4166 LabelsAction::List => cmd_labels_list(),
4167 LabelsAction::Push {
4168 dry_run,
4169 prune,
4170 random_colors,
4171 } => cmd_labels_push(dry_run, prune, random_colors),
4172 }
4173}
4174
4175fn cmd_labels_list() -> Result<()> {
4176 let config = load_labels_config()?;
4177 if config.labels.is_empty() {
4178 println!("0 labels declared in .gwm.toml — nothing to push.");
4179 return Ok(());
4180 }
4181 let declared = labels::resolve_labels(&config.labels, false)?;
4185 let forge = labels_forge(&config)?;
4186 let remote = forge.fetch_remote_labels()?;
4187 let diff = labels::diff_labels(&declared, &remote);
4188 print_labels_diff(forge.slug(), &declared, &diff);
4189 Ok(())
4190}
4191
4192fn cmd_labels_push(dry_run: bool, prune: bool, random_colors: bool) -> Result<()> {
4193 let config = load_labels_config()?;
4194 if config.labels.is_empty() {
4195 println!("0 labels declared in .gwm.toml — nothing to push.");
4196 return Ok(());
4197 }
4198 let declared = labels::resolve_labels(&config.labels, random_colors)?;
4199 let forge = labels_forge(&config)?;
4200 let remote = forge.fetch_remote_labels()?;
4201 let diff = labels::diff_labels(&declared, &remote);
4202 let (n_create, n_update, n_match, n_extra) = diff.counts();
4203
4204 if prune {
4209 for remote_label in &diff.extra_on_remote {
4210 labels::validate_label_name(&remote_label.name).map_err(|e| {
4211 let inner = match e {
4212 GwmError::Config(msg) => msg,
4213 other => other.to_string(),
4214 };
4215 GwmError::Config(format!("labels (remote): {inner} — refusing to prune"))
4216 })?;
4217 }
4218 }
4219
4220 if dry_run {
4221 print_labels_diff(forge.slug(), &declared, &diff);
4222 let pruned = if prune { n_extra } else { 0 };
4223 println!(
4224 "{}",
4225 labels::diff_dry_run_line(n_create, n_update, n_match, n_extra, pruned)
4226 );
4227 return Ok(());
4228 }
4229
4230 for spec in &diff.to_create {
4231 forge.create_label(spec)?;
4232 println!("✓ created {}", spec.name);
4233 }
4234 for upd in &diff.to_update {
4235 forge.update_label(&upd.spec)?;
4236 println!("✓ updated {}", upd.spec.name);
4237 }
4238 if prune {
4239 for remote_label in &diff.extra_on_remote {
4240 forge.delete_label(&remote_label.name)?;
4241 println!("✗ pruned {}", remote_label.name);
4242 }
4243 } else if !diff.extra_on_remote.is_empty() {
4244 println!(
4245 "{} label(s) on remote not in config — pass --prune to delete",
4246 diff.extra_on_remote.len()
4247 );
4248 }
4249 println!("{} label(s) untouched", n_match);
4250 Ok(())
4251}
4252
4253fn load_labels_config() -> Result<Config> {
4257 Ok(repo_context(None)?.config)
4258}
4259
4260fn labels_forge(config: &Config) -> Result<std::sync::Arc<dyn forge::Forge>> {
4269 let repo = worktree::discover_repo(None)?;
4270 forge::resolve(&repo, config)
4271}
4272
4273fn print_labels_diff(slug: &str, declared: &[labels::LabelSpec], diff: &LabelDiff) {
4274 for line in labels_diff_lines(slug, declared, diff) {
4275 println!("{}", line);
4276 }
4277}
4278
4279pub fn labels_diff_lines(slug: &str, declared: &[labels::LabelSpec], diff: &LabelDiff) -> Vec<String> {
4288 let clean = crate::naming::sanitise_for_terminal;
4289 let (n_create, n_update, n_match, n_extra) = diff.counts();
4290 let mut lines = vec![format!(
4291 "declared in .gwm.toml: {} labels — diff against {}:",
4292 declared.len(),
4293 clean(slug)
4294 )];
4295 for spec in &diff.to_create {
4296 lines.push(format!(
4297 " + {:<20} (will create, color #{})",
4298 clean(&spec.name),
4299 clean(&spec.color)
4300 ));
4301 }
4302 for upd in &diff.to_update {
4303 let detail = match (&upd.previous_color, &upd.previous_description) {
4304 (Some(old), _) => format!("color #{} → #{}", old, upd.spec.color),
4305 (None, Some(_)) => "description changed".into(),
4306 _ => "diff".into(),
4307 };
4308 lines.push(format!(" ~ {:<20} ({})", clean(&upd.spec.name), clean(&detail)));
4309 }
4310 for spec in &diff.matching {
4311 lines.push(format!(" = {:<20} (match)", clean(&spec.name)));
4312 }
4313 for remote in &diff.extra_on_remote {
4314 lines.push(format!(" - {:<20} (on remote, not in config)", clean(&remote.name)));
4315 }
4316 lines.push(labels::diff_summary_line(n_create, n_update, n_match, n_extra));
4317 lines
4318}
4319
4320fn cmd_milestones(action: MilestonesAction) -> Result<()> {
4323 match action {
4324 MilestonesAction::List => cmd_milestones_list(),
4325 MilestonesAction::Push { dry_run, prune } => cmd_milestones_push(dry_run, prune),
4326 }
4327}
4328
4329fn cmd_milestones_list() -> Result<()> {
4330 let config = load_milestones_config()?;
4331 if config.milestones.is_empty() {
4332 println!("0 milestones declared in .gwm.toml — nothing to push.");
4333 return Ok(());
4334 }
4335 let declared = milestones::resolve_milestones(&config.milestones)?;
4339 let forge = labels_forge(&config)?;
4340 let remote = forge.fetch_remote_milestones()?;
4341 let diff = milestones::diff_milestones(&declared, &remote);
4342 print_milestones_diff(forge.slug(), &declared, &diff);
4343 Ok(())
4344}
4345
4346fn cmd_milestones_push(dry_run: bool, prune: bool) -> Result<()> {
4347 let config = load_milestones_config()?;
4348 if config.milestones.is_empty() {
4349 println!("0 milestones declared in .gwm.toml — nothing to push.");
4350 return Ok(());
4351 }
4352 let declared = milestones::resolve_milestones(&config.milestones)?;
4353 let forge = labels_forge(&config)?;
4354 let remote = forge.fetch_remote_milestones()?;
4355 let diff = milestones::diff_milestones(&declared, &remote);
4356 let (n_create, n_update, n_match, n_extra) = diff.counts();
4357
4358 for spec in &diff.to_create {
4362 forge.validate_milestone(spec)?;
4363 }
4364 for upd in &diff.to_update {
4365 forge.validate_milestone(&upd.spec)?;
4366 }
4367
4368 if dry_run {
4369 print_milestones_diff(forge.slug(), &declared, &diff);
4370 let pruned = if prune { n_extra } else { 0 };
4371 println!(
4372 "{}",
4373 labels::diff_dry_run_line(n_create, n_update, n_match, n_extra, pruned)
4374 );
4375 return Ok(());
4376 }
4377
4378 for spec in &diff.to_create {
4379 forge.create_milestone(spec)?;
4380 println!("✓ created {}", spec.title);
4381 }
4382 for upd in &diff.to_update {
4383 forge.update_milestone(upd.number, &upd.spec)?;
4384 println!("✓ updated {}", upd.spec.title);
4385 }
4386 if prune {
4387 for remote_milestone in &diff.extra_on_remote {
4388 forge.delete_milestone(remote_milestone.number)?;
4389 println!("✗ pruned {}", remote_milestone.title);
4390 }
4391 } else if !diff.extra_on_remote.is_empty() {
4392 println!(
4393 "{} milestone(s) on remote not in config — pass --prune to delete",
4394 diff.extra_on_remote.len()
4395 );
4396 }
4397 println!("{} milestone(s) untouched", n_match);
4398 Ok(())
4399}
4400
4401fn load_milestones_config() -> Result<Config> {
4405 Ok(repo_context(None)?.config)
4406}
4407
4408fn print_milestones_diff(slug: &str, declared: &[milestones::MilestoneSpec], diff: &MilestoneDiff) {
4409 for line in milestones_diff_lines(slug, declared, diff) {
4410 println!("{}", line);
4411 }
4412}
4413
4414pub fn milestones_diff_lines(slug: &str, declared: &[milestones::MilestoneSpec], diff: &MilestoneDiff) -> Vec<String> {
4423 let clean = crate::naming::sanitise_for_terminal;
4424 let (n_create, n_update, n_match, n_extra) = diff.counts();
4425 let mut lines = vec![format!(
4426 "declared in .gwm.toml: {} milestones — diff against {}:",
4427 declared.len(),
4428 clean(slug)
4429 )];
4430 for spec in &diff.to_create {
4431 let due = spec.due_on.as_deref().unwrap_or("no due date");
4432 lines.push(format!(
4433 " + {:<20} (will create, state {}, due {})",
4434 clean(&spec.title),
4435 spec.state.as_str(),
4436 clean(due)
4437 ));
4438 }
4439 for upd in &diff.to_update {
4440 let detail = match (&upd.previous_due_on, &upd.previous_state, &upd.previous_description) {
4441 (Some(old_due), _, _) => format!("due {} → {}", old_due, upd.spec.due_on.as_deref().unwrap_or("cleared")),
4442 (None, Some(old_state), _) => format!("state {} → {}", old_state.as_str(), upd.spec.state.as_str()),
4443 (None, None, Some(_)) => "description changed".into(),
4444 _ => "diff".into(),
4445 };
4446 lines.push(format!(" ~ {:<20} ({})", clean(&upd.spec.title), clean(&detail)));
4447 }
4448 for spec in &diff.matching {
4449 lines.push(format!(" = {:<20} (match)", clean(&spec.title)));
4450 }
4451 for remote in &diff.extra_on_remote {
4452 lines.push(format!(
4453 " - {:<20} (#{} on remote, not in config)",
4454 clean(&remote.title),
4455 remote.number
4456 ));
4457 }
4458 lines.push(labels::diff_summary_line(n_create, n_update, n_match, n_extra));
4459 lines
4460}
4461
4462fn cmd_trust(action: TrustAction) -> Result<()> {
4465 match action {
4466 TrustAction::Add => cmd_trust_add(),
4467 TrustAction::List => cmd_trust_list(),
4468 TrustAction::Revoke { origin } => cmd_trust_revoke(origin),
4469 TrustAction::Show => cmd_trust_show(),
4470 }
4471}
4472
4473fn cmd_trust_list() -> Result<()> {
4474 let path = trust::default_ledger_path()?;
4475 let ledger = TrustLedger::load(&path)?;
4476 if ledger.entries.is_empty() {
4477 println!("0 entries in trust ledger ({}).", path.display());
4478 return Ok(());
4479 }
4480 println!("trust ledger: {}", path.display());
4481 println!(
4482 " {} entr{} recorded:",
4483 ledger.entries.len(),
4484 if ledger.entries.len() == 1 { "y" } else { "ies" }
4485 );
4486 let clean = crate::naming::sanitise_for_terminal;
4492 let origin_w = ledger
4493 .entries
4494 .iter()
4495 .map(|e| clean(&e.origin).len())
4496 .max()
4497 .unwrap_or(6)
4498 .clamp(6, 60);
4499 for e in &ledger.entries {
4500 let short_sha: String = e.config_sha.chars().take(12).collect();
4506 println!(
4507 " {:<ow$} {} trusted_at {} by {}",
4508 clean(&e.origin),
4509 clean(&short_sha),
4510 e.trusted_at.to_rfc3339(),
4511 clean(&e.trusted_by),
4512 ow = origin_w,
4513 );
4514 }
4515 Ok(())
4516}
4517
4518fn cmd_trust_revoke(origin: String) -> Result<()> {
4519 let path = trust::default_ledger_path()?;
4520 let mut ledger = TrustLedger::load(&path)?;
4521 let removed = ledger.revoke(&origin);
4522 let shown = crate::naming::sanitise_for_terminal(&origin);
4525 if removed == 0 {
4526 println!("0 entries matched origin {} (nothing to revoke).", shown);
4527 return Ok(());
4528 }
4529 ledger.save(&path)?;
4530 println!(
4531 "✓ revoked {} entr{} for {}",
4532 removed,
4533 if removed == 1 { "y" } else { "ies" },
4534 shown
4535 );
4536 Ok(())
4537}
4538
4539fn cmd_trust_add() -> Result<()> {
4540 let cwd = std::env::current_dir()?;
4541 let repo = Repository::discover(&cwd).map_err(|_| GwmError::NotInGitRepo)?;
4542 let workdir = repo.workdir().unwrap_or_else(|| repo.path()).to_path_buf();
4543 let key = trust::origin_key_for_repo(&repo, &workdir);
4545 match trust::record_config(&workdir, &key, &trust::current_actor())? {
4546 Some(sha) => {
4547 let short: String = sha.chars().take(12).collect();
4548 println!(
4550 "✓ trusted {} (.gwm.toml {})",
4551 crate::naming::sanitise_for_terminal(&key),
4552 short
4553 );
4554 Ok(())
4555 }
4556 None => Err(GwmError::Other(format!(
4557 "no .gwm.toml in {} — there is nothing to trust here",
4558 workdir.display()
4559 ))),
4560 }
4561}
4562
4563fn cmd_trust_show() -> Result<()> {
4564 let path = trust::default_ledger_path()?;
4565 println!("ledger path: {}", path.display());
4566 match std::fs::read_to_string(&path) {
4567 Ok(body) => {
4568 let body = crate::naming::sanitise_block_for_terminal(&body);
4572 println!("---");
4573 print!("{}", body);
4574 if !body.ends_with('\n') {
4575 println!();
4576 }
4577 }
4578 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
4579 println!("(file does not exist yet — nothing has been trusted on this machine)");
4580 }
4581 Err(e) => return Err(e.into()),
4582 }
4583 Ok(())
4584}
4585
4586fn trust_or_prompt(workdir: &Path, repo: Option<&Repository>, mode: TrustMode) -> Result<()> {
4603 let origin_key = match repo {
4604 Some(r) => trust::origin_key_for_repo(r, workdir),
4605 None => trust::resolve_origin_key(None, workdir),
4606 };
4607
4608 match trust::evaluate(workdir, &origin_key, mode)? {
4609 TrustOutcome::Proceed => Ok(()),
4610 TrustOutcome::Refuse { message } => Err(GwmError::Other(message)),
4611 TrustOutcome::Prompt {
4612 cfg_path,
4613 body,
4614 sha,
4615 origin,
4616 mut ledger,
4617 ledger_path,
4618 } => {
4619 use std::io::IsTerminal;
4623 if !std::io::stdin().is_terminal() {
4624 return Err(GwmError::Other(format!(
4625 ".gwm.toml at {} is not in the trust ledger and stdin is not interactive — \
4626 pass --allow-bootstrap (or set GWM_ALLOW_BOOTSTRAP=1) to bypass, \
4627 or run interactively to approve",
4628 cfg_path.display()
4629 )));
4630 }
4631
4632 let granted = prompt_user(&cfg_path, &body, &origin, &sha)?;
4633 if !granted {
4634 return Err(GwmError::Other(format!(
4635 "trust prompt declined for {} — aborting bootstrap",
4636 cfg_path.display()
4637 )));
4638 }
4639
4640 ledger.record(&origin, &sha, &trust::current_actor());
4641 ledger.save(&ledger_path)?;
4642 println!(
4643 "✓ recorded trust for {} in {}",
4644 crate::naming::sanitise_for_terminal(&origin),
4645 ledger_path.display()
4646 );
4647 Ok(())
4648 }
4649 }
4650}
4651
4652fn prompt_user(cfg_path: &Path, bytes: &[u8], origin: &str, sha: &str) -> Result<bool> {
4661 use std::io::{BufRead, Write};
4662
4663 let body = String::from_utf8_lossy(bytes);
4664 let parsed: Option<Config> = toml::from_str(&body).ok();
4665 let stdin = std::io::stdin();
4666 let mut stdout = std::io::stdout();
4667
4668 println!();
4669 println!("gwm: this repo's .gwm.toml has not been trusted yet.");
4670 println!(" path : {}", cfg_path.display());
4671 println!(" origin : {}", crate::naming::sanitise_for_terminal(origin));
4675 println!(" hash : {}", sha);
4676 if let Some(cfg) = parsed.as_ref() {
4677 print_bootstrap_summary(cfg);
4678 } else {
4679 println!(" (could not parse .gwm.toml for summary — see raw via `show` below)");
4680 }
4681 println!();
4682
4683 loop {
4684 print!("Trust this .gwm.toml? [y/N/show]: ");
4685 stdout.flush().ok();
4686 let mut line = String::new();
4687 let n = stdin.lock().read_line(&mut line)?;
4688 if n == 0 {
4689 return Ok(false);
4691 }
4692 match line.trim().to_ascii_lowercase().as_str() {
4693 "y" | "yes" => return Ok(true),
4694 "n" | "no" | "" => return Ok(false),
4695 "show" | "s" => {
4696 let body = crate::naming::sanitise_block_for_terminal(&body);
4701 println!("---");
4702 print!("{}", body);
4703 if !body.ends_with('\n') {
4704 println!();
4705 }
4706 println!("---");
4707 }
4708 other => {
4709 println!(
4710 "unrecognised answer '{}': answer y, N, or show",
4711 crate::naming::sanitise_for_terminal(other)
4712 );
4713 }
4714 }
4715 }
4716}
4717
4718fn print_bootstrap_summary(cfg: &Config) {
4719 for line in bootstrap_summary_lines(cfg) {
4720 println!("{}", line);
4721 }
4722}
4723
4724pub fn bootstrap_summary_lines(cfg: &Config) -> Vec<String> {
4733 let bs = &cfg.bootstrap;
4734 if bs.copy.is_empty() && bs.command.is_empty() && bs.guard.is_empty() && bs.no_symlink.is_empty() {
4735 return vec![" bootstrap surface: (empty, no copies/commands/guards/no_symlinks declared)".to_string()];
4736 }
4737 let clean = crate::naming::sanitise_for_terminal;
4743 let mut lines = vec![" bootstrap surface:".to_string()];
4744 for c in &bs.copy {
4745 lines.push(format!(" - copy {} → {}", clean(&c.from), clean(&c.to)));
4746 }
4747 for g in &bs.guard {
4748 lines.push(format!(
4749 " - guard {} (on_match={}, deny={} pattern(s))",
4750 clean(&g.name),
4751 g.on_match,
4752 g.deny_patterns.len()
4753 ));
4754 }
4755 for ns in &bs.no_symlink {
4756 lines.push(format!(" - no-symlink {}", clean(&ns.path)));
4757 }
4758 for c in &bs.command {
4759 lines.push(format!(" - run {} ({})", clean(&c.name), clean(&c.run)));
4760 }
4761 lines
4762}
4763
4764fn cmd_aliases(action: AliasesAction) -> Result<()> {
4767 match action {
4768 AliasesAction::List => cmd_aliases_list(),
4769 }
4770}
4771
4772fn cmd_config(action: ConfigAction) -> Result<()> {
4773 match action {
4774 ConfigAction::Get { key } => config_cli::get(&key),
4775 ConfigAction::Set { key, value } => config_cli::set(&key, value.as_deref()),
4776 ConfigAction::Unset { key } => config_cli::unset(&key),
4777 ConfigAction::List { prefix } => config_cli::list(prefix.as_deref()),
4778 ConfigAction::Validate => config_cli::validate(),
4779 ConfigAction::Path => config_cli::path(),
4780 ConfigAction::Edit => config_cli::edit(),
4781 }
4782}
4783
4784fn cmd_aliases_list() -> Result<()> {
4802 let repo_workdir: Option<PathBuf> = crate::worktree::discover_repo(None)
4808 .ok()
4809 .and_then(|r| r.workdir().map(|w| w.to_path_buf()));
4810
4811 let resolved = crate::aliases::load(repo_workdir.as_deref(), None)?;
4812
4813 let clean = crate::naming::sanitise_for_terminal;
4819
4820 println!("built-in:");
4822 if resolved.built_in.is_empty() {
4823 println!(" (none)");
4824 } else {
4825 let width = resolved.built_in.iter().map(|e| e.name.len()).max().unwrap_or(2).max(2);
4826 for e in &resolved.built_in {
4827 println!(" {:<width$} → {}", clean(e.name), clean(e.expansion), width = width);
4828 }
4829 }
4830
4831 println!("repo (.gwm.toml):");
4833 if repo_workdir.is_none() {
4834 println!(" (not inside a git repository — repo aliases are read from <repo>/.gwm.toml)");
4835 } else if resolved.repo.is_empty() {
4836 println!(" (none declared)");
4837 } else {
4838 let width = resolved.repo.keys().map(|k| clean(k).len()).max().unwrap_or(2).max(2);
4839 for (name, expansion) in &resolved.repo {
4840 println!(" {:<width$} → {}", clean(name), clean(expansion), width = width);
4841 }
4842 }
4843
4844 println!("user (~/.config/gwm/aliases.toml):");
4850 if resolved.user.is_empty() {
4851 println!(" (none declared)");
4852 } else {
4853 let width = resolved.user.keys().map(|k| clean(k).len()).max().unwrap_or(2).max(2);
4854 for (name, expansion) in &resolved.user {
4855 let shadowed = resolved.repo.contains_key(name);
4861 let suffix = if shadowed { " (shadowed by repo)" } else { "" };
4862 println!(
4863 " {:<width$} → {}{}",
4864 clean(name),
4865 clean(expansion),
4866 suffix,
4867 width = width
4868 );
4869 }
4870 }
4871
4872 Ok(())
4873}
4874
4875pub fn shell_init_script(shell: InitShell) -> &'static str {
4876 match shell {
4877 InitShell::Bash | InitShell::Zsh => POSIX_SHELL_INIT,
4878 InitShell::Fish => FISH_SHELL_INIT,
4879 InitShell::Powershell => POWERSHELL_SHELL_INIT,
4880 }
4881}
4882
4883const POSIX_SHELL_INIT: &str = r#"# gwm shell helper — wraps `gwm cd` / `gwm switch` so the parent shell can cd.
4884# Install: eval "$(gwm shell-init bash)" # or zsh
4885#
4886# Two paths:
4887# gcd <pattern> # fuzzy resolve via `gwm cd <pattern>`, then cd
4888# gcd # no arg → opens the interactive picker via `gwm switch`, then cd
4889#
4890# Note: the `function name { ... }` form (zsh/bash-extended) is used instead
4891# of the parenthesised POSIX form so the parser does not error out with
4892# `defining function based on alias 'gcd'` when zsh already has a `gcd`
4893# alias (e.g. oh-my-zsh's `gcd=git checkout`). The `unalias` after the
4894# definition is what makes the function reachable at call time, since zsh
4895# still resolves the alias first when both exist.
4896function gcd {
4897 local target
4898 if [ "$#" -eq 0 ]; then
4899 # No arg → open the interactive picker. `gwm switch` exits non-zero on
4900 # cancel, in which case `gcd` must NOT attempt the `cd` (would land in $HOME).
4901 target="$(command gwm switch)" || return $?
4902 else
4903 target="$(command gwm cd "$@")" || return $?
4904 fi
4905 cd "$target" || return $?
4906}
4907unalias gcd 2>/dev/null || true
4908"#;
4909
4910const FISH_SHELL_INIT: &str = r#"# gwm shell helper — wraps `gwm cd` / `gwm switch` so the parent shell can cd.
4911# Install: gwm shell-init fish | source # then persist in ~/.config/fish/config.fish
4912#
4913# Two paths:
4914# gcd <pattern> # fuzzy resolve via `gwm cd <pattern>`, then cd
4915# gcd # no arg → opens the interactive picker via `gwm switch`, then cd
4916function gcd --description 'cd into a gwm worktree (no arg = interactive picker)'
4917 set -l target
4918 if test (count $argv) -eq 0
4919 # No arg → open the interactive picker; cancel exits non-zero, in which
4920 # case we must NOT attempt the cd (would land in $HOME).
4921 set target (command gwm switch)
4922 or return $status
4923 else
4924 set target (command gwm cd $argv)
4925 or return $status
4926 end
4927 # `--` stops option parsing, "$target" prevents wildcard expansion on
4928 # paths containing `[`, `]`, or `*`.
4929 cd -- "$target"
4930end
4931"#;
4932
4933const POWERSHELL_SHELL_INIT: &str = r#"# gwm shell helper — wraps `gwm cd` / `gwm switch` so the parent shell can cd.
4934# Install: Invoke-Expression (& gwm shell-init powershell | Out-String)
4935#
4936# Two paths:
4937# gcd <pattern> # fuzzy resolve via `gwm cd <pattern>`, then Set-Location
4938# gcd # no arg → opens the interactive picker via `gwm switch`, then Set-Location
4939#
4940# Note: this clears any prior `gcd` alias so the function takes effect.
4941Remove-Alias -Name gcd -Force -ErrorAction SilentlyContinue
4942function gcd {
4943 param([string]$Pattern)
4944 if ([string]::IsNullOrEmpty($Pattern)) {
4945 # No arg → open the interactive picker. The binary exits non-zero on
4946 # cancel; bail out before attempting Set-Location so we don't land in $HOME.
4947 $target = & gwm switch
4948 } else {
4949 $target = & gwm cd $Pattern
4950 }
4951 if ($LASTEXITCODE -ne 0) { return }
4952 Set-Location $target
4953}
4954"#;
4955
4956fn print_report(report: &bootstrap::BootstrapReport) {
4957 if report.steps.is_empty() {
4958 return;
4959 }
4960 println!();
4961 println!("bootstrap report:");
4962 for s in &report.steps {
4963 let sigil = s.status.sigil();
4964 println!(" {} {}", sigil, s.label);
4965 if !s.detail.is_empty() {
4966 for line in s.detail.lines() {
4967 println!(" {}", line);
4968 }
4969 }
4970 }
4971}
4972
4973fn print_lifecycle_report(report: &bootstrap::BootstrapReport) {
4974 if report.steps.is_empty() {
4975 return;
4976 }
4977 lifecycle::print_report(report);
4978}
4979
4980fn current_repo_root() -> Result<PathBuf> {
4990 let repo = worktree::discover_repo(None)?;
4991 let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
4992 Ok(std::fs::canonicalize(&workdir).unwrap_or(workdir))
4993}
4994
4995pub fn format_history_row(entry: &OpEntry, now: chrono::DateTime<chrono::Utc>) -> String {
5000 let delta = (now - entry.ts).to_std().unwrap_or(std::time::Duration::from_secs(0));
5001 let ago = worktree::format_relative_duration(delta);
5002 let suffix = if entry.undone { " (undone)" } else { "" };
5003 format!("{:<5} {:<7} {}{}", ago, entry.kind.as_str(), entry.worktree, suffix)
5004}
5005
5006fn cmd_history(limit: usize, all: bool) -> Result<()> {
5007 let path = history::default_journal_path()?;
5008 let journal = history::Journal::load(&path)?;
5009
5010 let root: Option<PathBuf> = if all { None } else { Some(current_repo_root()?) };
5016 let mut rows: Vec<&OpEntry> = match &root {
5017 Some(r) => journal.entries_for_repo(r).collect(),
5018 None => journal.entries().iter().collect(),
5019 };
5020
5021 if rows.is_empty() {
5026 println!("no operations recorded");
5027 return Ok(());
5028 }
5029 if limit == 0 {
5030 return Ok(());
5031 }
5032
5033 rows.sort_by_key(|e| std::cmp::Reverse(e.ts));
5035 rows.truncate(limit);
5036
5037 let now = chrono::Utc::now();
5038 for entry in rows {
5039 println!("{}", format_history_row(entry, now));
5040 }
5041 Ok(())
5042}
5043
5044fn cmd_undo(run_bootstrap: bool, trust_mode: TrustMode) -> Result<()> {
5045 let path = history::default_journal_path()?;
5046 let mut journal = history::Journal::load(&path)?;
5047 let root = current_repo_root()?;
5048
5049 let Some(entry) = journal.pop_last_for_repo(&root) else {
5050 return Err(GwmError::Other(format!(
5051 "nothing to undo for {} — the journal is empty for this repo",
5052 root.display()
5053 )));
5054 };
5055
5056 let repo = worktree::discover_repo(None)?;
5057
5058 if run_bootstrap {
5068 let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
5069 trust_or_prompt(&workdir, Some(&repo), trust_mode)?;
5070 }
5071
5072 if let (Some(branch_name), Some(oid_hex)) = (&entry.branch, &entry.branch_oid) {
5078 let oid = git2::Oid::from_str(oid_hex)
5079 .map_err(|e| GwmError::Other(format!("journal entry has invalid branch_oid '{}': {}", oid_hex, e)))?;
5080 if repo.find_branch(branch_name, git2::BranchType::Local).is_err() {
5081 repo
5082 .reference(
5083 &format!("refs/heads/{}", branch_name),
5084 oid,
5085 false,
5086 "gwm undo: resurrect branch",
5087 )
5088 .map_err(|e| {
5089 GwmError::Other(format!(
5090 "failed to recreate branch {} at {}: {}",
5091 branch_name, oid_hex, e
5092 ))
5093 })?;
5094 println!(
5095 "✓ recreated branch {} at {}",
5096 branch_name,
5097 &oid_hex[..oid_hex.len().min(8)]
5098 );
5099 } else {
5100 println!("· branch {} already exists — skipping resurrection", branch_name);
5101 }
5102 }
5103
5104 let branch_name = entry.branch.as_deref().ok_or_else(|| {
5120 GwmError::Other(format!(
5121 "cannot undo remove of detached-HEAD worktree {} — only branch-attached worktrees are supported today",
5122 entry.worktree
5123 ))
5124 })?;
5125 worktree::add(&repo, &entry.worktree, &entry.path, branch_name, true)?;
5128 println!("✓ re-added worktree at {}", entry.path.display());
5129
5130 journal.save(&path)?;
5134
5135 if run_bootstrap {
5138 let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
5139 let config = Config::load_for_repo(&workdir)?;
5140 let ctx = BootstrapCtx {
5141 main_repo: &workdir,
5142 worktree: &entry.path,
5143 config: &config,
5144 };
5145 let report = bootstrap::run(&ctx)?;
5146 print_report(&report);
5147 } else {
5148 println!("(skipped re-bootstrap; pass --bootstrap to run it)");
5149 }
5150
5151 Ok(())
5152}