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::collections::{HashMap, HashSet};
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum HookPhase {
13  PreCreate,
14  PostCreate,
15  PreBootstrap,
16  PostBootstrap,
17  PreRemove,
18  PostRemove,
19}
20
21impl HookPhase {
22  pub fn as_str(self) -> &'static str {
23    match self {
24      Self::PreCreate => "pre_create",
25      Self::PostCreate => "post_create",
26      Self::PreBootstrap => "pre_bootstrap",
27      Self::PostBootstrap => "post_bootstrap",
28      Self::PreRemove => "pre_remove",
29      Self::PostRemove => "post_remove",
30    }
31  }
32
33  fn parse(value: &str) -> Option<Self> {
34    match value {
35      "pre_create" => Some(Self::PreCreate),
36      "post_create" => Some(Self::PostCreate),
37      "pre_bootstrap" => Some(Self::PreBootstrap),
38      "post_bootstrap" => Some(Self::PostBootstrap),
39      "pre_remove" => Some(Self::PreRemove),
40      "post_remove" => Some(Self::PostRemove),
41      _ => None,
42    }
43  }
44}
45
46#[derive(Debug, Clone, Default)]
47pub struct HookSkips {
48  phases: HashSet<HookPhase>,
49}
50
51impl HookSkips {
52  pub fn parse(raw: Option<&str>) -> Result<Self> {
53    let mut phases = HashSet::new();
54    let Some(raw) = raw else {
55      return Ok(Self { phases });
56    };
57    for part in raw.split(',').map(str::trim).filter(|p| !p.is_empty()) {
58      let phase = HookPhase::parse(part).ok_or_else(|| {
59        GwmError::Config(format!(
60          "unknown hook phase '{}' in --skip-hooks (expected one of pre_create,post_create,pre_bootstrap,post_bootstrap,pre_remove,post_remove)",
61          part
62        ))
63      })?;
64      phases.insert(phase);
65    }
66    Ok(Self { phases })
67  }
68
69  pub fn with(mut self, phase: HookPhase) -> Self {
70    self.phases.insert(phase);
71    self
72  }
73
74  fn contains(&self, phase: HookPhase) -> bool {
75    self.phases.contains(&phase)
76  }
77}
78
79#[derive(Debug, Clone)]
80pub struct HookContext {
81  pub main_repo: PathBuf,
82  pub cwd: PathBuf,
83  pub path: PathBuf,
84  pub branch: String,
85  pub branch_type: String,
86  pub issue: String,
87  pub desc: String,
88  pub user: String,
89  pub owner: String,
90  pub repo: String,
91}
92
93impl HookContext {
94  pub fn for_create(
95    repo: &Repository,
96    main_repo: &Path,
97    cwd: &Path,
98    path: &Path,
99    branch: &str,
100    spec: &BranchSpec,
101  ) -> Self {
102    let meta = RepoMeta::from_repo(repo);
103    Self {
104      main_repo: main_repo.to_path_buf(),
105      cwd: cwd.to_path_buf(),
106      path: path.to_path_buf(),
107      branch: branch.to_string(),
108      branch_type: spec.type_.clone(),
109      issue: spec.issue.clone(),
110      desc: spec.desc.clone(),
111      user: git_user(repo),
112      owner: meta.owner,
113      repo: meta.repo,
114    }
115  }
116
117  pub fn for_worktree(repo: &Repository, main_repo: &Path, cwd: &Path, path: &Path, branch: Option<&str>) -> Self {
118    let meta = RepoMeta::from_repo(repo);
119    let parsed = branch.and_then(crate::naming::parse_branch);
120    Self {
121      main_repo: main_repo.to_path_buf(),
122      cwd: cwd.to_path_buf(),
123      path: path.to_path_buf(),
124      branch: branch.unwrap_or_default().to_string(),
125      branch_type: parsed.as_ref().map(|s| s.type_.clone()).unwrap_or_default(),
126      issue: parsed.as_ref().map(|s| s.issue.clone()).unwrap_or_default(),
127      desc: parsed.as_ref().map(|s| s.desc.clone()).unwrap_or_default(),
128      user: git_user(repo),
129      owner: meta.owner,
130      repo: meta.repo,
131    }
132  }
133
134  pub fn with_cwd(&self, cwd: &Path) -> Self {
135    let mut next = self.clone();
136    next.cwd = cwd.to_path_buf();
137    next
138  }
139}
140
141#[derive(Debug)]
142pub struct HookAbort {
143  pub phase: HookPhase,
144  pub step: String,
145  pub detail: String,
146}
147
148pub fn run_phase(
149  config: &Config,
150  phase: HookPhase,
151  ctx: &HookContext,
152  skips: &HookSkips,
153  include_legacy_post_create: bool,
154) -> Result<BootstrapReport> {
155  let mut report = BootstrapReport { steps: Vec::new() };
156  if skips.contains(phase) {
157    report.steps.push(StepResult::skipped(
158      format!("[{}] hooks", phase.as_str()),
159      "skipped by --skip-hooks",
160    ));
161    return Ok(report);
162  }
163
164  let mut steps = steps_for(config, phase);
165  if phase == HookPhase::PostCreate && include_legacy_post_create {
166    steps.extend(config.bootstrap.command.iter().cloned().map(HookStep::from));
167  }
168
169  for step in steps {
170    let label = format!("[{}] {}", phase.as_str(), step.name);
171    if let Some(ref guard) = step.when {
172      if !bootstrap::evaluate_when(guard, &ctx.cwd) {
173        report
174          .steps
175          .push(StepResult::skipped(label, format!("when condition '{}' false", guard)));
176        continue;
177      }
178    }
179
180    match run_step(&step, ctx) {
181      Ok(output) => report
182        .steps
183        .push(StepResult::ok_with_detail(label, bootstrap::trailing_lines(&output, 3))),
184      Err(detail) => match step.on_fail {
185        HookOnFail::Abort => {
186          report.steps.push(StepResult::failed(label, detail.clone()));
187          print_report(&report);
188          return Err(GwmError::CommandFailed(format!(
189            "hook {} '{}' failed: {}",
190            phase.as_str(),
191            step.name,
192            detail
193          )));
194        }
195        HookOnFail::Warn => report.steps.push(StepResult::warning(label, detail)),
196        HookOnFail::Ignore => report
197          .steps
198          .push(StepResult::skipped(label, format!("ignored failure: {}", detail))),
199      },
200    }
201  }
202
203  Ok(report)
204}
205
206pub fn print_report(report: &BootstrapReport) {
207  for s in &report.steps {
208    let sigil = s.status.sigil();
209    println!("  {} {}", sigil, s.label);
210    if !s.detail.is_empty() {
211      for line in s.detail.lines() {
212        println!("      {}", line);
213      }
214    }
215  }
216}
217
218fn steps_for(config: &Config, phase: HookPhase) -> Vec<HookStep> {
219  match phase {
220    HookPhase::PreCreate => config.hooks.pre_create.clone(),
221    HookPhase::PostCreate => config.hooks.post_create.clone(),
222    HookPhase::PreBootstrap => config.hooks.pre_bootstrap.clone(),
223    HookPhase::PostBootstrap => config.hooks.post_bootstrap.clone(),
224    HookPhase::PreRemove => config.hooks.pre_remove.clone(),
225    HookPhase::PostRemove => config.hooks.post_remove.clone(),
226  }
227}
228
229fn run_step(step: &HookStep, ctx: &HookContext) -> std::result::Result<String, String> {
230  let run = expand_placeholders(&step.run, ctx);
231  let env = step
232    .env
233    .iter()
234    .map(|(key, value)| (key.clone(), expand_placeholders(value, ctx)))
235    .collect::<HashMap<_, _>>();
236  let mut cmd = Command::new("sh");
237  cmd.arg("-c").arg(&run).current_dir(&ctx.cwd);
238  for (key, value) in env {
239    cmd.env(key, value);
240  }
241  // Record on the Command Logs transcript (issue #226): a lifecycle hook is
242  // an external command gwm ran. Log the placeholder-expanded script the
243  // user authored, not the `sh -c` wrapper.
244  let out = crate::command_log::run_logged(&mut cmd, run.clone()).map_err(|e| format!("failed to spawn: {}", e))?;
245  let stdout = String::from_utf8_lossy(&out.stdout).to_string();
246  let stderr = String::from_utf8_lossy(&out.stderr).to_string();
247  let detail = if stdout.is_empty() { stderr } else { stdout };
248  if !out.status.success() {
249    return Err(format!("exited with {}\n{}", out.status, detail).trim().to_string());
250  }
251  Ok(detail)
252}
253
254fn expand_placeholders(template: &str, ctx: &HookContext) -> String {
255  template
256    .replace("{branch}", &ctx.branch)
257    .replace("{path}", &ctx.path.display().to_string())
258    .replace("{type}", &ctx.branch_type)
259    .replace("{issue}", &ctx.issue)
260    .replace("{desc}", &ctx.desc)
261    .replace("{user}", &ctx.user)
262    .replace("{owner}", &ctx.owner)
263    .replace("{repo}", &ctx.repo)
264}
265
266fn git_user(repo: &Repository) -> String {
267  repo
268    .config()
269    .ok()
270    .and_then(|cfg| cfg.get_string("user.name").ok())
271    .filter(|value| !value.trim().is_empty())
272    .or_else(|| std::env::var("USER").ok())
273    .unwrap_or_default()
274}
275
276struct RepoMeta {
277  owner: String,
278  repo: String,
279}
280
281impl RepoMeta {
282  fn from_repo(repo: &Repository) -> Self {
283    let repo_name = crate::worktree::repo_name(repo);
284    let Ok(slug) = github::repo_slug(repo) else {
285      return Self {
286        owner: String::new(),
287        repo: repo_name,
288      };
289    };
290    let Some((owner, name)) = slug.split_once('/') else {
291      return Self {
292        owner: String::new(),
293        repo: repo_name,
294      };
295    };
296    Self {
297      owner: owner.to_string(),
298      repo: name.to_string(),
299    }
300  }
301}