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 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}