1use std::path::{Path, PathBuf};
17use std::process::Command;
18
19use serde_json::{Value, json};
20
21use super::context::Ctx;
22use super::observe::{self, Api, Runner};
23use crate::detect::Forge;
24use crate::diagnostic::{Diagnostic, Reason};
25use crate::error::RkError;
26use crate::snapshot::{FORGE_DIR, REFS_DIR, Snapshot};
27
28pub const DESTRUCTIVE: [&str; 7] = [
30 "default-branch",
31 "single-trunk",
32 "merge-cleanup",
33 "protect-trunk",
34 "protect-tags",
35 "protect-release-lines",
36 "auto-merge",
37];
38
39#[must_use]
41pub fn destructive(step: &str) -> bool {
42 DESTRUCTIVE.contains(&step)
43}
44
45#[must_use]
47pub fn reads(ctx: &Ctx, step: &str) -> Vec<String> {
48 let Some(forge) = ctx.forge else {
49 return Vec::new();
50 };
51 let trunk = ctx.trunk();
52 match forge {
53 Forge::Github => {
54 let repo = &ctx.repo;
55 match step {
56 "default-branch" | "merge-cleanup" | "auto-merge" => vec![format!("repos/{repo}")],
57 "protect-trunk" => vec![
59 format!("repos/{repo}"),
60 format!("repos/{repo}/rulesets"),
61 format!("repos/{repo}/branches/{trunk}/protection"),
62 ],
63 "protect-tags" | "protect-release-lines" => vec![format!("repos/{repo}/rulesets")],
64 "single-trunk" => candidates(ctx)
65 .iter()
66 .map(|branch| format!("repos/{repo}/git/ref/heads/{branch}"))
67 .collect(),
68 _ => Vec::new(),
69 }
70 }
71 Forge::Gitlab => {
72 let project = ctx.repo.replace('/', "%2F");
73 match step {
74 "default-branch" | "merge-cleanup" | "auto-merge" => {
75 vec![format!("projects/{project}")]
76 }
77 "protect-trunk" => vec![
79 format!("projects/{project}"),
80 format!("projects/{project}/protected_branches"),
81 ],
82 "protect-release-lines" => {
83 vec![format!("projects/{project}/protected_branches")]
84 }
85 "protect-tags" => vec![format!("projects/{project}/protected_tags")],
86 "single-trunk" => candidates(ctx)
87 .iter()
88 .map(|branch| format!("projects/{project}/repository/branches/{branch}"))
89 .collect(),
90 _ => Vec::new(),
91 }
92 }
93 }
94}
95
96fn replaced_rulesets<'c>(ctx: &'c Ctx, step: &str) -> Vec<&'c str> {
100 match (ctx.forge, step) {
101 (Some(Forge::Github), "protect-trunk") => vec![ctx.safety_ruleset(), ctx.trunk_ruleset()],
102 (Some(Forge::Github), "protect-tags") => vec![ctx.tag_ruleset()],
103 (Some(Forge::Github), "protect-release-lines") => vec![ctx.lines_ruleset()],
104 _ => Vec::new(),
105 }
106}
107
108fn cannot_read(step: &str, path: &str, reason: &str) -> RkError {
110 RkError::refusal(
111 Diagnostic::new(
112 Reason::ObservationIncomplete,
113 format!(
114 "{step} changes forge state this run cannot read first ({path}: {reason}), so it did not run"
115 ),
116 )
117 .expected("the state the step changes, read and saved before it changes")
118 .action("rerun where the forge CLI can read this project's settings")
119 .target_state("unchanged")
120 .step(step),
121 )
122}
123
124fn candidates(ctx: &Ctx) -> Vec<String> {
127 ctx.retired_branches()
128 .iter()
129 .filter(|branch| branch.as_str() != ctx.trunk())
130 .cloned()
131 .collect()
132}
133
134pub fn save(
143 ctx: &Ctx,
144 step: &str,
145 snapshot: &Snapshot,
146 run: &mut Runner,
147) -> Result<PathBuf, RkError> {
148 let mut answers = serde_json::Map::new();
149 let mut branches = Vec::new();
150 for path in reads(ctx, step) {
151 let answer = match observe::api_get(ctx, run, &path)? {
152 Api::Ok(body) => body,
153 Api::Missing => Value::Null,
154 Api::Failed(reason) => return Err(cannot_read(step, &path, &reason)),
155 };
156 if step == "single-trunk"
157 && let Some(commit) = answer
158 .pointer("/object/sha")
159 .or_else(|| answer.pointer("/commit/id"))
160 .and_then(Value::as_str)
161 {
162 let name = path.rsplit('/').next().unwrap_or_default().to_owned();
163 branches.push((name, commit.to_owned()));
164 }
165 answers.insert(path, answer);
166 }
167 let listing = format!("repos/{}/rulesets", ctx.repo);
168 for name in replaced_rulesets(ctx, step) {
169 let Some(id) = answers
172 .get(&listing)
173 .and_then(Value::as_array)
174 .and_then(|list| list.iter().find(|ruleset| ruleset["name"] == name))
175 .and_then(|ruleset| ruleset["id"].as_u64())
176 else {
177 continue;
178 };
179 let path = format!("{listing}/{id}");
180 let answer = match observe::api_get(ctx, run, &path)? {
181 Api::Ok(body) => body,
182 Api::Missing => {
185 return Err(cannot_read(
186 step,
187 &path,
188 "the listed ruleset reads as missing",
189 ));
190 }
191 Api::Failed(reason) => return Err(cannot_read(step, &path, &reason)),
192 };
193 answers.insert(path, answer);
194 }
195 let mut bundled = Vec::new();
196 for (name, commit) in &branches {
197 if let Some(bundle) = bundle(ctx, snapshot, name, commit)? {
198 bundled.push(bundle.display().to_string());
199 }
200 }
201 let document = json!({
202 "step": step,
203 "taken_at": crate::applog::now_utc(),
204 "reads": answers,
205 "branches": branches
206 .iter()
207 .map(|(name, commit)| json!({"name": name, "commit": commit}))
208 .collect::<Vec<_>>(),
209 "bundles": bundled,
210 });
211 let bytes = serde_json::to_vec_pretty(&document).map_err(anyhow::Error::from)?;
212 snapshot.save(&Path::new(FORGE_DIR).join(format!("{step}.json")), &bytes)
213}
214
215fn bundle(
220 ctx: &Ctx,
221 snapshot: &Snapshot,
222 branch: &str,
223 commit: &str,
224) -> Result<Option<PathBuf>, RkError> {
225 let held = git(ctx)
226 .args(["cat-file", "-e", &format!("{commit}^{{commit}}")])
227 .status()
228 .is_ok_and(|status| status.success());
229 if !held {
230 return Ok(None);
231 }
232 let scratch = std::env::temp_dir().join(format!(
233 "rk-bundle-{}-{}",
234 std::process::id(),
235 branch.replace('/', "-")
236 ));
237 let _ = std::fs::remove_dir_all(&scratch);
238 std::fs::create_dir_all(&scratch)?;
239 let outcome = bundle_in(ctx, &scratch, snapshot, branch, commit);
240 let _ = std::fs::remove_dir_all(&scratch);
241 outcome
242}
243
244fn bundle_in(
246 ctx: &Ctx,
247 scratch: &Path,
248 snapshot: &Snapshot,
249 branch: &str,
250 commit: &str,
251) -> Result<Option<PathBuf>, RkError> {
252 let clone = scratch.join("objects.git");
253 let file = scratch.join("branch.bundle");
254 let cloned = git(ctx)
258 .args(["clone", "--quiet", "--bare", "--shared", "."])
259 .arg(&clone)
260 .status()
261 .is_ok_and(|status| status.success());
262 let named = cloned
263 && bare(&clone)
264 .args(["update-ref", &format!("refs/heads/{branch}"), commit])
265 .status()
266 .is_ok_and(|status| status.success());
267 let bundled = named
268 && bare(&clone)
269 .args(["bundle", "create", "--quiet"])
270 .arg(&file)
271 .arg(format!("refs/heads/{branch}"))
272 .status()
273 .is_ok_and(|status| status.success());
274 if !bundled {
275 return Ok(None);
276 }
277 let bytes = std::fs::read(&file)?;
278 snapshot
279 .save(
280 &Path::new(REFS_DIR).join(format!("{branch}.bundle")),
281 &bytes,
282 )
283 .map(Some)
284}
285
286fn bare(clone: &Path) -> Command {
288 let mut command = Command::new(crate::probes::git_bin());
289 for var in crate::maintenance::GIT_HOOK_VARS {
290 command.env_remove(var);
291 }
292 command.arg("-C").arg(clone);
293 command
294}
295
296fn git(ctx: &Ctx) -> Command {
298 let mut command = Command::new(crate::probes::git_bin());
299 for var in crate::maintenance::GIT_HOOK_VARS {
300 command.env_remove(var);
301 }
302 command.arg("-C").arg(ctx.target.as_std_path());
303 command
304}
305
306#[cfg(test)]
307mod tests {
308 use super::{DESTRUCTIVE, destructive};
309
310 #[test]
311 fn the_destructive_steps_are_named_once() {
312 for step in DESTRUCTIVE {
313 assert!(destructive(step));
314 }
315 for step in ["package-check", "install-bot", "protections-check"] {
316 assert!(!destructive(step), "{step}");
317 }
318 }
319}