use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Instant;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::error::CargoError;
const BUILD_SPEC: &str = "Cargo.build-spec.json";
const DELTA: &str = "Cargo.gen.lock";
const ALLOWED: [&str; 3] = [BUILD_SPEC, DELTA, ".gitignore"];
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "kebab-case")]
pub enum MigrateOutcome {
Migrated {
commit_sha: String,
pushed: bool,
build_spec_retired: bool,
elapsed_ms: u64,
},
AlreadyDeltaOnly,
SkippedNotRust,
SkippedNotAGitRepo,
SkippedDirty { detail: String },
SkippedLockMutated,
SkippedNoDelta,
Failed {
category: MigrateFailure,
detail: String,
elapsed_ms: u64,
},
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum MigrateFailure {
BuildFailed,
GitInspectionFailed,
GitStageFailed,
GitCommitFailed,
PushDiverged,
PushFailed,
VerifyMismatch,
}
#[derive(Clone, Copy, Debug)]
pub struct MigrateOpts {
pub push: bool,
pub bot_identity: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MigrateReport {
pub outcomes: IndexMap<String, MigrateOutcome>,
pub total_elapsed_ms: u64,
}
impl MigrateReport {
pub fn total(&self) -> usize {
self.outcomes.len()
}
pub fn migrated_count(&self) -> usize {
self.outcomes
.values()
.filter(|o| matches!(o, MigrateOutcome::Migrated { .. }))
.count()
}
pub fn pushed_count(&self) -> usize {
self.outcomes
.values()
.filter(|o| matches!(o, MigrateOutcome::Migrated { pushed: true, .. }))
.count()
}
pub fn failed_count(&self) -> usize {
self.outcomes
.values()
.filter(|o| matches!(o, MigrateOutcome::Failed { .. }))
.count()
}
pub fn skipped_count(&self) -> usize {
self.outcomes
.values()
.filter(|o| {
matches!(
o,
MigrateOutcome::SkippedNotRust
| MigrateOutcome::SkippedNotAGitRepo
| MigrateOutcome::SkippedDirty { .. }
| MigrateOutcome::SkippedLockMutated
| MigrateOutcome::SkippedNoDelta
| MigrateOutcome::AlreadyDeltaOnly
)
})
.count()
}
}
pub fn run(repos: &[PathBuf], opts: MigrateOpts) -> Result<MigrateReport, CargoError> {
let started = Instant::now();
let mut outcomes: IndexMap<String, MigrateOutcome> = IndexMap::new();
for repo in repos {
let name = repo
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| repo.display().to_string());
outcomes.insert(name, migrate_one(repo, opts));
}
Ok(MigrateReport {
outcomes,
total_elapsed_ms: started.elapsed().as_millis() as u64,
})
}
pub fn migrate_one(repo: &Path, opts: MigrateOpts) -> MigrateOutcome {
let started = Instant::now();
let ms = || started.elapsed().as_millis() as u64;
if !repo.join(".git").exists() {
return MigrateOutcome::SkippedNotAGitRepo;
}
if !(repo.join("Cargo.toml").exists() && repo.join("Cargo.lock").exists()) {
return MigrateOutcome::SkippedNotRust;
}
match blocking_dirty(repo) {
Err(detail) => {
return MigrateOutcome::Failed {
category: MigrateFailure::GitInspectionFailed,
detail,
elapsed_ms: ms(),
}
}
Ok(Some(detail)) => return MigrateOutcome::SkippedDirty { detail },
Ok(None) => {}
}
if opts.push {
if let Ok(branch) = current_branch(repo) {
let _ = run_git(repo, &["fetch", "--quiet", "origin", &branch]);
let _ = run_git(repo, &["merge", "--ff-only", &format!("origin/{branch}")]);
}
}
let lock_before = std::fs::read(repo.join("Cargo.lock")).ok();
if let Err(e) = crate::build_spec::generate_multi_target_and_write(repo) {
return MigrateOutcome::Failed {
category: MigrateFailure::BuildFailed,
detail: e.to_string(),
elapsed_ms: ms(),
};
}
let lock_after = std::fs::read(repo.join("Cargo.lock")).ok();
if lock_before != lock_after {
let _ = run_git(repo, &["checkout", "--", "Cargo.lock"]);
if build_spec_tracked(repo) {
let _ = run_git(repo, &["checkout", "--", BUILD_SPEC]);
}
let _ = remove_if_untracked(repo, DELTA);
return MigrateOutcome::SkippedLockMutated;
}
if !repo.join(DELTA).exists() {
if build_spec_tracked(repo) {
let _ = run_git(repo, &["checkout", "--", BUILD_SPEC]);
}
return MigrateOutcome::SkippedNoDelta;
}
let mut retired = false;
if build_spec_tracked(repo) {
if let Err(e) = run_git(repo, &["rm", "--quiet", "--cached", "--", BUILD_SPEC]) {
return MigrateOutcome::Failed {
category: MigrateFailure::GitStageFailed,
detail: e,
elapsed_ms: ms(),
};
}
retired = true;
}
if let Err(e) = ensure_gitignored(repo, BUILD_SPEC) {
return MigrateOutcome::Failed {
category: MigrateFailure::GitStageFailed,
detail: e,
elapsed_ms: ms(),
};
}
if let Err(e) = run_git(repo, &["add", "--", DELTA, ".gitignore"]) {
return MigrateOutcome::Failed {
category: MigrateFailure::GitStageFailed,
detail: e,
elapsed_ms: ms(),
};
}
if run_git(repo, &["diff", "--cached", "--quiet"]).is_ok() {
return MigrateOutcome::AlreadyDeltaOnly;
}
let msg = "spec: retire committed Cargo.build-spec.json — delta-only\n\n\
Drop the full build-spec from version control + gitignore it; the slim\n\
Cargo.gen.lock delta is the sole committed spec source. substrate's\n\
lockfile-builder reconstructs the build-spec in pure Nix from Cargo.lock\n\
+ the delta (delta > build-spec > IFD), so the big artifact is redundant\n\
operator-surface noise. Cargo.lock unchanged (gen build is read-only).\n\n\
Migrated by `gen fleet-migrate`.";
let mut commit_args: Vec<&str> = Vec::new();
if opts.bot_identity {
commit_args.extend_from_slice(&[
"-c",
"user.name=gen-spec-bot",
"-c",
"user.email=gen-spec-bot@pleme-io.invalid",
]);
}
commit_args.extend_from_slice(&["commit", "--quiet", "-m", msg]);
if let Err(e) = run_git(repo, &commit_args) {
return MigrateOutcome::Failed {
category: MigrateFailure::GitCommitFailed,
detail: e,
elapsed_ms: ms(),
};
}
let sha = run_git(repo, &["rev-parse", "HEAD"])
.map(|s| s.trim().to_string())
.unwrap_or_default();
let mut pushed = false;
if opts.push {
let branch = current_branch(repo).unwrap_or_else(|_| "main".to_string());
match run_git(repo, &["push", "origin", &branch]) {
Ok(_) => {}
Err(e) => {
let category = if e.contains("non-fast-forward") || e.contains("rejected") {
MigrateFailure::PushDiverged
} else {
MigrateFailure::PushFailed
};
return MigrateOutcome::Failed {
category,
detail: e,
elapsed_ms: ms(),
};
}
}
let remote = run_git(repo, &["ls-remote", "--heads", "origin", &branch])
.ok()
.and_then(|s| s.split_whitespace().next().map(str::to_string))
.unwrap_or_default();
if remote != sha {
return MigrateOutcome::Failed {
category: MigrateFailure::VerifyMismatch,
detail: format!("remote={remote} local={sha}"),
elapsed_ms: ms(),
};
}
pushed = true;
}
MigrateOutcome::Migrated {
commit_sha: sha,
pushed,
build_spec_retired: retired,
elapsed_ms: ms(),
}
}
fn blocking_dirty(repo: &Path) -> Result<Option<String>, String> {
let out = run_git(repo, &["status", "--porcelain"])?;
for line in out.lines() {
if line.len() < 4 {
continue;
}
let (status, rest) = line.split_at(2);
if status == "??" {
continue; }
let path = rest.trim();
let path = path.rsplit(" -> ").next().unwrap_or(path).trim();
if !ALLOWED.contains(&path) {
return Ok(Some(format!("{status} {path}")));
}
}
Ok(None)
}
fn build_spec_tracked(repo: &Path) -> bool {
run_git(repo, &["ls-files", "--error-unmatch", BUILD_SPEC]).is_ok()
}
fn current_branch(repo: &Path) -> Result<String, String> {
run_git(repo, &["rev-parse", "--abbrev-ref", "HEAD"]).map(|s| s.trim().to_string())
}
fn remove_if_untracked(repo: &Path, rel: &str) -> Result<(), String> {
if run_git(repo, &["ls-files", "--error-unmatch", rel]).is_err() {
let _ = std::fs::remove_file(repo.join(rel));
}
Ok(())
}
fn ensure_gitignored(repo: &Path, entry: &str) -> Result<(), String> {
let gi = repo.join(".gitignore");
let cur = std::fs::read_to_string(&gi).unwrap_or_default();
if cur.lines().any(|l| l.trim() == entry) {
return Ok(());
}
let mut new = cur;
if !new.is_empty() && !new.ends_with('\n') {
new.push('\n');
}
new.push_str(entry);
new.push('\n');
std::fs::write(&gi, new).map_err(|e| format!("write .gitignore: {e}"))
}
fn run_git(repo: &Path, args: &[&str]) -> Result<String, String> {
let output = Command::new("git")
.args(args)
.current_dir(repo)
.output()
.map_err(|e| format!("git {args:?}: spawn failed: {e}"))?;
if !output.status.success() {
return Err(format!(
"git {args:?} → exit {}: {}",
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_git_repo(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("genfm-{}-{}", std::process::id(), tag));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
run_git(&dir, &["init", "--quiet"]).unwrap();
run_git(&dir, &["config", "user.email", "t@example.com"]).unwrap();
run_git(&dir, &["config", "user.name", "t"]).unwrap();
dir
}
fn write(dir: &Path, rel: &str, body: &str) {
std::fs::write(dir.join(rel), body).unwrap();
}
#[test]
fn ensure_gitignored_appends_and_is_idempotent() {
let dir = temp_git_repo("gi");
ensure_gitignored(&dir, BUILD_SPEC).unwrap();
let after_first = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
assert!(after_first.lines().any(|l| l == BUILD_SPEC));
ensure_gitignored(&dir, BUILD_SPEC).unwrap();
let after_second = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
assert_eq!(after_first, after_second);
assert_eq!(
after_second.lines().filter(|l| *l == BUILD_SPEC).count(),
1
);
}
#[test]
fn ensure_gitignored_preserves_existing_entries() {
let dir = temp_git_repo("gi2");
write(&dir, ".gitignore", "/target\n");
ensure_gitignored(&dir, BUILD_SPEC).unwrap();
let gi = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
assert!(gi.contains("/target"));
assert!(gi.lines().any(|l| l == BUILD_SPEC));
}
#[test]
fn blocking_dirty_clean_tree_is_none() {
let dir = temp_git_repo("clean");
write(&dir, "foo.txt", "hi");
run_git(&dir, &["add", "."]).unwrap();
run_git(&dir, &["commit", "--quiet", "-m", "init"]).unwrap();
assert_eq!(blocking_dirty(&dir).unwrap(), None);
}
#[test]
fn blocking_dirty_flags_tracked_change_outside_allowlist() {
let dir = temp_git_repo("dirty");
write(&dir, "foo.txt", "hi");
run_git(&dir, &["add", "."]).unwrap();
run_git(&dir, &["commit", "--quiet", "-m", "init"]).unwrap();
write(&dir, "foo.txt", "modified"); let blocked = blocking_dirty(&dir).unwrap();
assert!(blocked.is_some(), "tracked non-allowed change must block");
assert!(blocked.unwrap().contains("foo.txt"));
}
#[test]
fn blocking_dirty_allows_migration_files_and_untracked() {
let dir = temp_git_repo("allow");
write(&dir, "foo.txt", "hi");
write(&dir, BUILD_SPEC, "{}");
run_git(&dir, &["add", "."]).unwrap();
run_git(&dir, &["commit", "--quiet", "-m", "init"]).unwrap();
write(&dir, BUILD_SPEC, "{\"v\":10}");
write(&dir, "scratch.tmp", "x");
assert_eq!(
blocking_dirty(&dir).unwrap(),
None,
"allowed-file change + untracked file must not block"
);
}
#[test]
fn build_spec_tracked_reflects_git_state() {
let dir = temp_git_repo("track");
write(&dir, BUILD_SPEC, "{}");
assert!(!build_spec_tracked(&dir), "untracked before add");
run_git(&dir, &["add", "."]).unwrap();
run_git(&dir, &["commit", "--quiet", "-m", "init"]).unwrap();
assert!(build_spec_tracked(&dir), "tracked after commit");
}
#[test]
fn migrate_one_non_rust_repo_skipped() {
let dir = temp_git_repo("notrust");
assert!(matches!(
migrate_one(&dir, MigrateOpts { push: false, bot_identity: false }),
MigrateOutcome::SkippedNotRust
));
}
#[test]
fn migrate_one_non_git_skipped() {
let dir = std::env::temp_dir().join(format!("genfm-{}-nogit", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
write(&dir, "Cargo.toml", "[package]");
write(&dir, "Cargo.lock", "");
assert!(matches!(
migrate_one(&dir, MigrateOpts { push: false, bot_identity: false }),
MigrateOutcome::SkippedNotAGitRepo
));
}
}