1use std::path::{Path, PathBuf};
20use std::process::Command;
21use std::time::Instant;
22
23use indexmap::IndexMap;
24use serde::{Deserialize, Serialize};
25
26use crate::error::CargoError;
27
28const SIDECAR: &str = "Cargo.build-spec.json";
29
30#[derive(Clone, Debug, Serialize, Deserialize)]
32#[serde(tag = "status", rename_all = "kebab-case")]
33pub enum CommitOutcome {
34 Committed {
36 commit_sha: String,
37 pushed: bool,
38 elapsed_ms: u64,
39 },
40 SkippedNoSidecar,
42 SkippedAlreadyClean,
44 SkippedNotAGitRepo,
46 Failed {
48 category: CommitFailureCategory,
49 detail: String,
50 elapsed_ms: u64,
51 },
52}
53
54#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
55#[serde(rename_all = "kebab-case")]
56pub enum CommitFailureCategory {
57 GitAddFailed,
59 GitCommitFailed,
61 GitPushFailed,
63 GitInspectionFailed,
65}
66
67#[derive(Clone, Debug, Serialize, Deserialize)]
68pub struct CommitReport {
69 pub root: PathBuf,
70 pub outcomes: IndexMap<String, CommitOutcome>,
71 pub total_elapsed_ms: u64,
72}
73
74impl CommitReport {
75 #[must_use]
76 pub fn total(&self) -> usize {
77 self.outcomes.len()
78 }
79 #[must_use]
80 pub fn committed_count(&self) -> usize {
81 self.outcomes
82 .values()
83 .filter(|o| matches!(o, CommitOutcome::Committed { .. }))
84 .count()
85 }
86 #[must_use]
87 pub fn pushed_count(&self) -> usize {
88 self.outcomes
89 .values()
90 .filter(|o| matches!(o, CommitOutcome::Committed { pushed: true, .. }))
91 .count()
92 }
93 #[must_use]
94 pub fn skipped_count(&self) -> usize {
95 self.outcomes
96 .values()
97 .filter(|o| {
98 matches!(
99 o,
100 CommitOutcome::SkippedNoSidecar
101 | CommitOutcome::SkippedAlreadyClean
102 | CommitOutcome::SkippedNotAGitRepo
103 )
104 })
105 .count()
106 }
107 #[must_use]
108 pub fn failed_count(&self) -> usize {
109 self.outcomes
110 .values()
111 .filter(|o| matches!(o, CommitOutcome::Failed { .. }))
112 .count()
113 }
114}
115
116pub fn run(
124 root: &Path,
125 push: bool,
126 rebase_first: bool,
127) -> Result<CommitReport, CargoError> {
128 let started = Instant::now();
129 let mut outcomes: IndexMap<String, CommitOutcome> = IndexMap::new();
130
131 let entries = std::fs::read_dir(root).map_err(|source| CargoError::Io {
132 path: root.to_path_buf(),
133 source,
134 })?;
135 let mut dirs: Vec<PathBuf> = entries
136 .filter_map(|e| e.ok().map(|e| e.path()))
137 .filter(|p| p.is_dir())
138 .collect();
139 dirs.sort();
140
141 for repo in dirs {
142 let name = repo
143 .file_name()
144 .map(|s| s.to_string_lossy().into_owned())
145 .unwrap_or_default();
146 if name.is_empty() || name.starts_with('.') {
147 continue;
148 }
149 let outcome = commit_one(&repo, push, rebase_first);
150 outcomes.insert(name, outcome);
151 }
152
153 Ok(CommitReport {
154 root: root.to_path_buf(),
155 outcomes,
156 total_elapsed_ms: started.elapsed().as_millis() as u64,
157 })
158}
159
160fn commit_one(repo: &Path, push: bool, rebase_first: bool) -> CommitOutcome {
161 let started = Instant::now();
162 if !repo.join(SIDECAR).exists() {
163 return CommitOutcome::SkippedNoSidecar;
164 }
165 if !repo.join(".git").exists() {
166 return CommitOutcome::SkippedNotAGitRepo;
167 }
168
169 match git_file_state(repo) {
171 Ok(GitFileState::Clean) => return CommitOutcome::SkippedAlreadyClean,
172 Ok(GitFileState::UntrackedOrModified) => {}
173 Err(detail) => {
174 return CommitOutcome::Failed {
175 category: CommitFailureCategory::GitInspectionFailed,
176 detail,
177 elapsed_ms: started.elapsed().as_millis() as u64,
178 };
179 }
180 }
181
182 if let Err(detail) = run_git(repo, &["add", SIDECAR]) {
184 return CommitOutcome::Failed {
185 category: CommitFailureCategory::GitAddFailed,
186 detail,
187 elapsed_ms: started.elapsed().as_millis() as u64,
188 };
189 }
190
191 let msg = canonical_commit_message();
193 if let Err(detail) = run_git(repo, &["commit", "-m", &msg]) {
194 return CommitOutcome::Failed {
195 category: CommitFailureCategory::GitCommitFailed,
196 detail,
197 elapsed_ms: started.elapsed().as_millis() as u64,
198 };
199 }
200
201 let commit_sha = match run_git(repo, &["rev-parse", "HEAD"]) {
203 Ok(s) => s.trim().to_string(),
204 Err(detail) => {
205 return CommitOutcome::Failed {
206 category: CommitFailureCategory::GitInspectionFailed,
207 detail,
208 elapsed_ms: started.elapsed().as_millis() as u64,
209 };
210 }
211 };
212
213 if !push {
214 return CommitOutcome::Committed {
215 commit_sha,
216 pushed: false,
217 elapsed_ms: started.elapsed().as_millis() as u64,
218 };
219 }
220
221 if rebase_first {
223 if let Err(_detail) = run_git(repo, &["fetch", "origin"]) {
224 }
226 let _ = run_git(repo, &["pull", "--rebase", "origin", "HEAD"]);
228 }
229
230 if let Err(detail) = run_git(repo, &["push", "origin", "HEAD"]) {
232 return CommitOutcome::Failed {
233 category: CommitFailureCategory::GitPushFailed,
234 detail,
235 elapsed_ms: started.elapsed().as_millis() as u64,
236 };
237 }
238
239 CommitOutcome::Committed {
240 commit_sha,
241 pushed: true,
242 elapsed_ms: started.elapsed().as_millis() as u64,
243 }
244}
245
246enum GitFileState {
247 Clean,
249 UntrackedOrModified,
251}
252
253fn git_file_state(repo: &Path) -> Result<GitFileState, String> {
256 let out = run_git(repo, &["status", "--porcelain", SIDECAR])?;
257 if out.trim().is_empty() {
258 Ok(GitFileState::Clean)
259 } else {
260 Ok(GitFileState::UntrackedOrModified)
261 }
262}
263
264fn run_git(repo: &Path, args: &[&str]) -> Result<String, String> {
265 let output = Command::new("git")
266 .args(args)
267 .current_dir(repo)
268 .output()
269 .map_err(|e| format!("git {args:?}: spawn failed: {e}"))?;
270 if !output.status.success() {
271 return Err(format!(
272 "git {args:?} → exit {}: {}",
273 output.status.code().unwrap_or(-1),
274 String::from_utf8_lossy(&output.stderr).trim()
275 ));
276 }
277 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
278}
279
280fn canonical_commit_message() -> String {
283 "add Cargo.build-spec.json — substrate lockfile-builder default-on input
284
285Typed sidecar produced by `gen lock-build`. Composes Cargo.toml +
286Cargo.lock + cargo metadata into one canonical JSON that substrate's
287pure-Nix lockfile-builder consumes directly. Eliminates the need
288for a generated Cargo.nix on the substrate default path.
289
290Regenerate with `gen build .` whenever Cargo.lock changes.
291
292 - gen ecosystem: github.com/pleme-io/gen
293 - substrate path: substrate.lib.build.rust.lockfile-builder
294 - rollout doc: pleme-io/gen/docs/PACKED-DEFAULTS.md
295"
296 .to_string()
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use std::fs;
303
304 fn tempdir() -> PathBuf {
305 use std::sync::atomic::{AtomicU64, Ordering};
306 static C: AtomicU64 = AtomicU64::new(0);
307 let n = C.fetch_add(1, Ordering::Relaxed);
308 let p = std::env::temp_dir().join(format!(
309 "gen-fleet-commit-test-{}-{}",
310 std::process::id(),
311 n
312 ));
313 let _ = fs::remove_dir_all(&p);
314 fs::create_dir_all(&p).unwrap();
315 p
316 }
317
318 fn init_repo(p: &Path) {
321 run_git(p, &["init", "-q"]).unwrap();
322 run_git(p, &["config", "user.email", "test@example.org"]).unwrap();
323 run_git(p, &["config", "user.name", "test"]).unwrap();
324 run_git(p, &["config", "commit.gpgsign", "false"]).unwrap();
325 fs::write(p.join("README"), "x").unwrap();
326 run_git(p, &["add", "README"]).unwrap();
327 run_git(p, &["commit", "-q", "-m", "init"]).unwrap();
328 }
329
330 #[test]
331 fn skipped_no_sidecar_when_file_absent() {
332 let root = tempdir();
333 let repo = root.join("empty");
334 fs::create_dir_all(&repo).unwrap();
335 let report = run(&root, false, false).unwrap();
336 assert!(matches!(
337 report.outcomes.get("empty"),
338 Some(CommitOutcome::SkippedNoSidecar)
339 ));
340 }
341
342 #[test]
343 fn skipped_not_a_git_repo_when_no_dot_git() {
344 let root = tempdir();
345 let repo = root.join("nogit");
346 fs::create_dir_all(&repo).unwrap();
347 fs::write(repo.join(SIDECAR), "{}").unwrap();
348 let report = run(&root, false, false).unwrap();
349 assert!(matches!(
350 report.outcomes.get("nogit"),
351 Some(CommitOutcome::SkippedNotAGitRepo)
352 ));
353 }
354
355 #[test]
356 fn untracked_sidecar_is_committed() {
357 let root = tempdir();
358 let repo = root.join("real");
359 fs::create_dir_all(&repo).unwrap();
360 init_repo(&repo);
361 fs::write(repo.join(SIDECAR), "{\"version\":1}\n").unwrap();
362 let report = run(&root, false, false).unwrap();
363 let outcome = report.outcomes.get("real").unwrap();
364 assert!(
365 matches!(outcome, CommitOutcome::Committed { pushed: false, .. }),
366 "expected Committed, got {outcome:?}"
367 );
368 }
369
370 #[test]
371 fn already_committed_is_skipped_clean() {
372 let root = tempdir();
373 let repo = root.join("done");
374 fs::create_dir_all(&repo).unwrap();
375 init_repo(&repo);
376 fs::write(repo.join(SIDECAR), "{\"version\":1}\n").unwrap();
377 run_git(&repo, &["add", SIDECAR]).unwrap();
378 run_git(&repo, &["commit", "-q", "-m", "spec"]).unwrap();
379 let report = run(&root, false, false).unwrap();
380 assert!(matches!(
381 report.outcomes.get("done"),
382 Some(CommitOutcome::SkippedAlreadyClean)
383 ));
384 }
385
386 #[test]
387 fn report_aggregators_count_correctly() {
388 let mut outcomes = IndexMap::new();
389 outcomes.insert(
390 "a".into(),
391 CommitOutcome::Committed {
392 commit_sha: "x".into(),
393 pushed: false,
394 elapsed_ms: 1,
395 },
396 );
397 outcomes.insert(
398 "b".into(),
399 CommitOutcome::Committed {
400 commit_sha: "y".into(),
401 pushed: true,
402 elapsed_ms: 1,
403 },
404 );
405 outcomes.insert("c".into(), CommitOutcome::SkippedAlreadyClean);
406 outcomes.insert(
407 "d".into(),
408 CommitOutcome::Failed {
409 category: CommitFailureCategory::GitPushFailed,
410 detail: "x".into(),
411 elapsed_ms: 1,
412 },
413 );
414 let report = CommitReport {
415 root: PathBuf::from("/x"),
416 outcomes,
417 total_elapsed_ms: 4,
418 };
419 assert_eq!(report.total(), 4);
420 assert_eq!(report.committed_count(), 2);
421 assert_eq!(report.pushed_count(), 1);
422 assert_eq!(report.skipped_count(), 1);
423 assert_eq!(report.failed_count(), 1);
424 }
425
426 #[test]
427 fn commit_message_is_deterministic() {
428 let a = canonical_commit_message();
429 let b = canonical_commit_message();
430 assert_eq!(a, b);
431 assert!(a.contains("substrate lockfile-builder default-on input"));
432 }
433
434 #[test]
435 fn other_dirty_files_are_not_staged() {
436 let root = tempdir();
437 let repo = root.join("dirty");
438 fs::create_dir_all(&repo).unwrap();
439 init_repo(&repo);
440 fs::write(repo.join("OTHER"), "leave-me-alone").unwrap();
442 fs::write(repo.join(SIDECAR), "{\"version\":1}\n").unwrap();
443 let report = run(&root, false, false).unwrap();
444 assert!(matches!(
445 report.outcomes.get("dirty"),
446 Some(CommitOutcome::Committed { .. })
447 ));
448 let status = run_git(&repo, &["status", "--porcelain"]).unwrap();
450 assert!(status.contains("?? OTHER"), "OTHER should be untracked: {status:?}");
451 }
452}