Skip to main content

gwm/
lifecycle.rs

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    // Issue #417: the remove / bootstrap hook context rebuilds `{type}` /
121    // `{issue}` / `{desc}` by re-reading the branch, so it reads it with the
122    // pattern that wrote it. `for_create` does not come through here — it
123    // carries the original `BranchSpec` straight through.
124    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  // `run` becomes a shell script, so its placeholder values are escaped;
237  // `env` values are handed to `Command::env` and never see a shell, so
238  // escaping them would push literal quote characters into what the hook
239  // reads back. Same placeholders, deliberately different treatment.
240  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  // Exported before the step's own entries so an explicit `env` key wins.
249  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  // Record on the Command Logs transcript (issue #226): a lifecycle hook is
256  // an external command gwm ran. Log the placeholder-expanded script the
257  // user authored, not the `sh -c` wrapper.
258  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
268/// Value behind a `{token}`, or `None` when the token is not one of ours —
269/// an unknown `{…}` is left in the template untouched, as it always was.
270fn 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
284/// Substitute the hook placeholders in `template`, in **one pass**.
285///
286/// Single-pass is a correctness requirement, not a tidiness one. Chained
287/// `str::replace` calls re-scan what the previous call just wrote, so a
288/// value that itself contains a token — a branch really can be called
289/// `spike-{issue}` — gets rewritten from the inside. With `escape` on, that
290/// would splice quote characters into the middle of another value.
291///
292/// `escape` is set when the result becomes a shell script. Placeholder
293/// values are **data**: the branch name is whatever git says it is, and a
294/// branch can arrive from a colleague's push or a fork PR, so it must not be
295/// able to close the hook's command and open its own.
296fn 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      // Unbalanced `{` — nothing left to substitute, keep it verbatim.
304      out.push_str(tail);
305      return out;
306    };
307    let token = &tail[..=close];
308    match placeholder_value(token, ctx) {
309      // An empty value has nothing to inject, and quoting it would change
310      // arity rather than safety: `quote("")` is `''`, so `mycmd {issue}`
311      // would start passing an argument where every release up to 1.5.0
312      // passed none — on any branch that does not match the convention,
313      // since `{type}` / `{issue}` / `{desc}` are empty there. Letting it
314      // through keeps this change's blast radius to the vulnerability
315      // itself, which is what a security patch owes the people applying it.
316      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
326/// Expansion for a value that never reaches a shell (a `Command::env` entry).
327fn expand_placeholders(template: &str, ctx: &HookContext) -> String {
328  expand(template, ctx, false)
329}
330
331/// Expansion for a string that becomes `sh -c <script>`.
332fn expand_shell(template: &str, ctx: &HookContext) -> String {
333  expand(template, ctx, true)
334}
335
336/// The same context, exported as environment variables.
337///
338/// A hook that reads `"$GWM_BRANCH"` never has to think about escaping at
339/// all: shell parameter expansion does not re-parse the value it produces,
340/// so no substitution can start a second command. Quote it — the value is
341/// still subject to word splitting and pathname expansion when bare, and a
342/// branch may contain a tab, a newline or a `*`.
343fn 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    // `rsplit_once`, not `split_once` (Codex review #458): a GitLab slug
381    // can be `group/sub/proj`, where the namespace is everything before
382    // the LAST separator. Identical behaviour for GitHub's two-segment
383    // `owner/repo`.
384    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}