1use crate::bootstrap::{self, BootstrapReport, StepResult};
2use crate::config::{Config, HookOnFail, HookStep};
3use crate::error::{GwmError, Result};
4use crate::github;
5use crate::naming::BranchSpec;
6use git2::Repository;
7use std::borrow::Cow;
8use std::collections::{HashMap, HashSet};
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum HookPhase {
14 PreCreate,
15 PostCreate,
16 PreBootstrap,
17 PostBootstrap,
18 PreRemove,
19 PostRemove,
20}
21
22impl HookPhase {
23 pub fn as_str(self) -> &'static str {
24 match self {
25 Self::PreCreate => "pre_create",
26 Self::PostCreate => "post_create",
27 Self::PreBootstrap => "pre_bootstrap",
28 Self::PostBootstrap => "post_bootstrap",
29 Self::PreRemove => "pre_remove",
30 Self::PostRemove => "post_remove",
31 }
32 }
33
34 fn parse(value: &str) -> Option<Self> {
35 match value {
36 "pre_create" => Some(Self::PreCreate),
37 "post_create" => Some(Self::PostCreate),
38 "pre_bootstrap" => Some(Self::PreBootstrap),
39 "post_bootstrap" => Some(Self::PostBootstrap),
40 "pre_remove" => Some(Self::PreRemove),
41 "post_remove" => Some(Self::PostRemove),
42 _ => None,
43 }
44 }
45}
46
47#[derive(Debug, Clone, Default)]
48pub struct HookSkips {
49 phases: HashSet<HookPhase>,
50}
51
52impl HookSkips {
53 pub fn parse(raw: Option<&str>) -> Result<Self> {
54 let mut phases = HashSet::new();
55 let Some(raw) = raw else {
56 return Ok(Self { phases });
57 };
58 for part in raw.split(',').map(str::trim).filter(|p| !p.is_empty()) {
59 let phase = HookPhase::parse(part).ok_or_else(|| {
60 GwmError::Config(format!(
61 "unknown hook phase '{}' in --skip-hooks (expected one of pre_create,post_create,pre_bootstrap,post_bootstrap,pre_remove,post_remove)",
62 part
63 ))
64 })?;
65 phases.insert(phase);
66 }
67 Ok(Self { phases })
68 }
69
70 pub fn with(mut self, phase: HookPhase) -> Self {
71 self.phases.insert(phase);
72 self
73 }
74
75 fn contains(&self, phase: HookPhase) -> bool {
76 self.phases.contains(&phase)
77 }
78}
79
80#[derive(Debug, Clone)]
81pub struct HookContext {
82 pub main_repo: PathBuf,
83 pub cwd: PathBuf,
84 pub path: PathBuf,
85 pub branch: String,
86 pub branch_type: String,
87 pub issue: String,
88 pub desc: String,
89 pub user: String,
90 pub owner: String,
91 pub repo: String,
92}
93
94impl HookContext {
95 pub fn for_create(
96 repo: &Repository,
97 main_repo: &Path,
98 cwd: &Path,
99 path: &Path,
100 branch: &str,
101 spec: &BranchSpec,
102 ) -> Self {
103 let meta = RepoMeta::from_repo(repo);
104 Self {
105 main_repo: main_repo.to_path_buf(),
106 cwd: cwd.to_path_buf(),
107 path: path.to_path_buf(),
108 branch: branch.to_string(),
109 branch_type: spec.type_.clone(),
110 issue: spec.issue.clone(),
111 desc: spec.desc.clone(),
112 user: git_user(repo),
113 owner: meta.owner,
114 repo: meta.repo,
115 }
116 }
117
118 pub fn for_worktree(repo: &Repository, main_repo: &Path, cwd: &Path, path: &Path, branch: Option<&str>) -> Self {
119 let meta = RepoMeta::from_repo(repo);
120 let parser = crate::naming::BranchParser::for_repo(repo);
125 let parsed = branch.and_then(|b| parser.parse(b));
126 Self {
127 main_repo: main_repo.to_path_buf(),
128 cwd: cwd.to_path_buf(),
129 path: path.to_path_buf(),
130 branch: branch.unwrap_or_default().to_string(),
131 branch_type: parsed.as_ref().map(|s| s.type_.clone()).unwrap_or_default(),
132 issue: parsed.as_ref().map(|s| s.issue.clone()).unwrap_or_default(),
133 desc: parsed.as_ref().map(|s| s.desc.clone()).unwrap_or_default(),
134 user: git_user(repo),
135 owner: meta.owner,
136 repo: meta.repo,
137 }
138 }
139
140 pub fn with_cwd(&self, cwd: &Path) -> Self {
141 let mut next = self.clone();
142 next.cwd = cwd.to_path_buf();
143 next
144 }
145}
146
147#[derive(Debug)]
148pub struct HookAbort {
149 pub phase: HookPhase,
150 pub step: String,
151 pub detail: String,
152}
153
154pub fn run_phase(
155 config: &Config,
156 phase: HookPhase,
157 ctx: &HookContext,
158 skips: &HookSkips,
159 include_legacy_post_create: bool,
160) -> Result<BootstrapReport> {
161 let mut report = BootstrapReport { steps: Vec::new() };
162 if skips.contains(phase) {
163 report.steps.push(StepResult::skipped(
164 format!("[{}] hooks", phase.as_str()),
165 "skipped by --skip-hooks",
166 ));
167 return Ok(report);
168 }
169
170 let mut steps = steps_for(config, phase);
171 if phase == HookPhase::PostCreate && include_legacy_post_create {
172 steps.extend(config.bootstrap.command.iter().cloned().map(HookStep::from));
173 }
174
175 for step in steps {
176 let label = format!("[{}] {}", phase.as_str(), step.name);
177 if let Some(ref guard) = step.when {
178 if !bootstrap::evaluate_when(guard, &ctx.cwd) {
179 report
180 .steps
181 .push(StepResult::skipped(label, format!("when condition '{}' false", guard)));
182 continue;
183 }
184 }
185
186 match run_step(&step, ctx) {
187 Ok(output) => report
188 .steps
189 .push(StepResult::ok_with_detail(label, bootstrap::trailing_lines(&output, 3))),
190 Err(detail) => match step.on_fail {
191 HookOnFail::Abort => {
192 report.steps.push(StepResult::failed(label, detail.clone()));
193 print_report(&report);
194 return Err(GwmError::CommandFailed(format!(
195 "hook {} '{}' failed: {}",
196 phase.as_str(),
197 step.name,
198 detail
199 )));
200 }
201 HookOnFail::Warn => report.steps.push(StepResult::warning(label, detail)),
202 HookOnFail::Ignore => report
203 .steps
204 .push(StepResult::skipped(label, format!("ignored failure: {}", detail))),
205 },
206 }
207 }
208
209 Ok(report)
210}
211
212pub fn print_report(report: &BootstrapReport) {
213 for s in &report.steps {
214 let sigil = s.status.sigil();
215 println!(" {} {}", sigil, s.label);
216 if !s.detail.is_empty() {
217 for line in s.detail.lines() {
218 println!(" {}", line);
219 }
220 }
221 }
222}
223
224fn steps_for(config: &Config, phase: HookPhase) -> Vec<HookStep> {
225 match phase {
226 HookPhase::PreCreate => config.hooks.pre_create.clone(),
227 HookPhase::PostCreate => config.hooks.post_create.clone(),
228 HookPhase::PreBootstrap => config.hooks.pre_bootstrap.clone(),
229 HookPhase::PostBootstrap => config.hooks.post_bootstrap.clone(),
230 HookPhase::PreRemove => config.hooks.pre_remove.clone(),
231 HookPhase::PostRemove => config.hooks.post_remove.clone(),
232 }
233}
234
235fn run_step(step: &HookStep, ctx: &HookContext) -> std::result::Result<String, String> {
236 let run = expand_shell(&step.run, ctx);
241 let env = step
242 .env
243 .iter()
244 .map(|(key, value)| (key.clone(), expand_placeholders(value, ctx)))
245 .collect::<HashMap<_, _>>();
246 let mut cmd = Command::new("sh");
247 cmd.arg("-c").arg(&run).current_dir(&ctx.cwd);
248 for (key, value) in gwm_env(ctx) {
250 cmd.env(key, value);
251 }
252 for (key, value) in env {
253 cmd.env(key, value);
254 }
255 let out = crate::command_log::run_logged(&mut cmd, run.clone()).map_err(|e| format!("failed to spawn: {}", e))?;
259 let stdout = String::from_utf8_lossy(&out.stdout).to_string();
260 let stderr = String::from_utf8_lossy(&out.stderr).to_string();
261 let detail = if stdout.is_empty() { stderr } else { stdout };
262 if !out.status.success() {
263 return Err(format!("exited with {}\n{}", out.status, detail).trim().to_string());
264 }
265 Ok(detail)
266}
267
268fn placeholder_value<'a>(token: &str, ctx: &'a HookContext) -> Option<Cow<'a, str>> {
271 Some(match token {
272 "{branch}" => Cow::Borrowed(ctx.branch.as_str()),
273 "{path}" => Cow::Owned(ctx.path.display().to_string()),
274 "{type}" => Cow::Borrowed(ctx.branch_type.as_str()),
275 "{issue}" => Cow::Borrowed(ctx.issue.as_str()),
276 "{desc}" => Cow::Borrowed(ctx.desc.as_str()),
277 "{user}" => Cow::Borrowed(ctx.user.as_str()),
278 "{owner}" => Cow::Borrowed(ctx.owner.as_str()),
279 "{repo}" => Cow::Borrowed(ctx.repo.as_str()),
280 _ => return None,
281 })
282}
283
284fn expand(template: &str, ctx: &HookContext, escape: bool) -> String {
297 let mut out = String::with_capacity(template.len());
298 let mut rest = template;
299 while let Some(open) = rest.find('{') {
300 out.push_str(&rest[..open]);
301 let tail = &rest[open..];
302 let Some(close) = tail.find('}') else {
303 out.push_str(tail);
305 return out;
306 };
307 let token = &tail[..=close];
308 match placeholder_value(token, ctx) {
309 Some(value) if escape && !value.is_empty() => out.push_str(&shell_words::quote(&value)),
317 Some(value) => out.push_str(&value),
318 None => out.push_str(token),
319 }
320 rest = &tail[close + 1..];
321 }
322 out.push_str(rest);
323 out
324}
325
326fn expand_placeholders(template: &str, ctx: &HookContext) -> String {
328 expand(template, ctx, false)
329}
330
331fn expand_shell(template: &str, ctx: &HookContext) -> String {
333 expand(template, ctx, true)
334}
335
336fn gwm_env(ctx: &HookContext) -> [(&'static str, String); 8] {
344 [
345 ("GWM_BRANCH", ctx.branch.clone()),
346 ("GWM_PATH", ctx.path.display().to_string()),
347 ("GWM_TYPE", ctx.branch_type.clone()),
348 ("GWM_ISSUE", ctx.issue.clone()),
349 ("GWM_DESC", ctx.desc.clone()),
350 ("GWM_USER", ctx.user.clone()),
351 ("GWM_OWNER", ctx.owner.clone()),
352 ("GWM_REPO", ctx.repo.clone()),
353 ]
354}
355
356fn git_user(repo: &Repository) -> String {
357 repo
358 .config()
359 .ok()
360 .and_then(|cfg| cfg.get_string("user.name").ok())
361 .filter(|value| !value.trim().is_empty())
362 .or_else(|| std::env::var("USER").ok())
363 .unwrap_or_default()
364}
365
366struct RepoMeta {
367 owner: String,
368 repo: String,
369}
370
371impl RepoMeta {
372 fn from_repo(repo: &Repository) -> Self {
373 let repo_name = crate::worktree::repo_name(repo);
374 let Ok(slug) = github::repo_slug(repo) else {
375 return Self {
376 owner: String::new(),
377 repo: repo_name,
378 };
379 };
380 let Some((owner, name)) = slug.rsplit_once('/') else {
385 return Self {
386 owner: String::new(),
387 repo: repo_name,
388 };
389 };
390 Self {
391 owner: owner.to_string(),
392 repo: name.to_string(),
393 }
394 }
395}