Skip to main content

gen_cargo/
fleet_commit.rs

1//! `gen fleet-commit` — typed multi-repo commit + push primitive.
2//!
3//! Sibling of `fleet_sweep`. After `gen fleet-sweep --write` lands
4//! Cargo.build-spec.json sidecars into N repos, `gen fleet-commit`
5//! walks the same set + commits the typed sidecar to each repo's
6//! main branch.
7//!
8//! Algorithmic guarantees:
9//!   - Deterministic: same repo state → same commit content + message
10//!   - Side-effect-scoped: only stages `Cargo.build-spec.json`
11//!     (never `git add -A`); other dirty paths in the working tree
12//!     remain untouched.
13//!   - Idempotent: re-running on a repo with the spec already
14//!     committed reports SkippedAlreadyClean.
15//!   - Typed failure classification: every shell-level error maps
16//!     to a typed CommitOutcome variant; no opaque error strings
17//!     reach the operator.
18
19use 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/// One repo's commit outcome.
31#[derive(Clone, Debug, Serialize, Deserialize)]
32#[serde(tag = "status", rename_all = "kebab-case")]
33pub enum CommitOutcome {
34    /// Committed (and pushed if `push` was requested).
35    Committed {
36        commit_sha: String,
37        pushed: bool,
38        elapsed_ms: u64,
39    },
40    /// No spec file present; nothing to commit.
41    SkippedNoSidecar,
42    /// Spec is already at HEAD, working-tree-clean for this file.
43    SkippedAlreadyClean,
44    /// Not a git repo.
45    SkippedNotAGitRepo,
46    /// A typed structural failure.
47    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    /// git add failed (file inaccessible, etc.).
58    GitAddFailed,
59    /// git commit failed (commit hook rejected, signing failure).
60    GitCommitFailed,
61    /// git push failed (network, auth, branch-protection rule).
62    GitPushFailed,
63    /// git rev-parse / status / cat-file failed unexpectedly.
64    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
116/// Walk every immediate sub-directory of `root`; for each, commit
117/// `Cargo.build-spec.json` if it differs from HEAD.
118///
119/// `push = true` additionally pushes to `origin/<current-branch>`
120/// after each successful commit. `rebase_first` runs
121/// `git pull --rebase` before push to absorb upstream changes (sidecars
122/// land cleanly on top of CI auto-release commits).
123pub 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    // Determine whether sidecar differs from HEAD.
170    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    // git add Cargo.build-spec.json (only this file — never -A).
183    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    // git commit with the canonical deterministic message.
192    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    // Capture the new commit SHA.
202    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    // Optional rebase to absorb upstream commits before push.
222    if rebase_first {
223        if let Err(_detail) = run_git(repo, &["fetch", "origin"]) {
224            // fetch failure isn't fatal — push will surface a clearer error.
225        }
226        // pull --rebase tolerates the autostash + replay model.
227        let _ = run_git(repo, &["pull", "--rebase", "origin", "HEAD"]);
228    }
229
230    // git push origin HEAD.
231    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    /// File matches HEAD content; nothing to commit.
248    Clean,
249    /// File is untracked, modified, or staged.
250    UntrackedOrModified,
251}
252
253/// Inspect `Cargo.build-spec.json`'s state vs HEAD. Deterministic:
254/// returns Clean iff `git status --porcelain SIDECAR` is empty.
255fn 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
280/// Canonical commit message — deterministic per-spec rollout. Same
281/// across all repos so operators can grep the fleet by message.
282fn 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    /// init a bare repo with one initial commit so git push has a sense
319    /// of HEAD; useful for tests that need real-shape git state.
320    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        // Other dirty file that must NOT be committed.
441        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        // OTHER must still be untracked.
449        let status = run_git(&repo, &["status", "--porcelain"]).unwrap();
450        assert!(status.contains("?? OTHER"), "OTHER should be untracked: {status:?}");
451    }
452}