use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::process::Command;
use chrono::{Duration, NaiveDateTime, Utc};
use super::DEFAULT_MIGRATIONS_DIR;
const VERSION_FMT: &str = "%Y%m%d%H%M%S";
const VERSION_LEN: usize = 14;
fn version_prefix(dir_name: &str) -> Option<String> {
let prefix = dir_name.split('_').next()?;
(prefix.len() == VERSION_LEN && prefix.bytes().all(|b| b.is_ascii_digit()))
.then(|| prefix.to_string())
}
fn parse_version(version: &str) -> Option<NaiveDateTime> {
NaiveDateTime::parse_from_str(version, VERSION_FMT).ok()
}
fn format_version(dt: NaiveDateTime) -> String {
dt.format(VERSION_FMT).to_string()
}
fn local_migration_entries(dir: &Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut names: Vec<String> = entries
.flatten()
.filter(|entry| entry.path().is_dir())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| version_prefix(name).is_some())
.collect();
names.sort();
names
}
fn git_refs(patterns: &[&str]) -> Vec<String> {
let Ok(output) = Command::new("git")
.args(["for-each-ref", "--format=%(refname)"])
.args(patterns)
.output()
else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::to_owned)
.filter(|r| !r.is_empty())
.collect()
}
fn remote_branch_heads() -> Option<BTreeMap<String, String>> {
let output = Command::new("git")
.args(["ls-remote", "--heads", "origin"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| {
let (sha, refname) = line.split_once('\t')?;
let name = refname.strip_prefix("refs/heads/")?;
Some((name.to_owned(), sha.to_owned()))
})
.collect(),
)
}
fn local_origin_branch_heads() -> BTreeMap<String, String> {
let Ok(output) = Command::new("git")
.args([
"for-each-ref",
"--format=%(objectname) %(refname)",
"refs/remotes/origin",
])
.output()
else {
return BTreeMap::new();
};
if !output.status.success() {
return BTreeMap::new();
}
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| {
let (sha, refname) = line.split_once(' ')?;
let name = refname.strip_prefix("refs/remotes/origin/")?;
(name != "HEAD").then(|| (name.to_owned(), sha.to_owned()))
})
.collect()
}
fn repo_root_prefix() -> String {
let Ok(output) = Command::new("git")
.args(["rev-parse", "--show-prefix"])
.output()
else {
return String::new();
};
if !output.status.success() {
return String::new();
}
String::from_utf8_lossy(&output.stdout).trim().to_owned()
}
fn migration_dirs_at_ref(git_ref: &str, migrations_dir_name: &str) -> Vec<String> {
let root_relative_dir = format!("{}{migrations_dir_name}", repo_root_prefix());
let Ok(output) = Command::new("git")
.args([
"ls-tree",
"-d",
"--full-tree",
"--name-only",
&format!("{git_ref}:{root_relative_dir}"),
])
.output()
else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::to_owned)
.filter(|d| !d.is_empty())
.collect()
}
fn framework_migration_names() -> Vec<String> {
use autumn_web::reexports::diesel::migration::{Migration, MigrationSource};
use autumn_web::reexports::diesel::pg::Pg;
if matches!(
std::env::var("DATABASE_URL")
.ok()
.as_deref()
.and_then(autumn_web::config::DatabaseBackend::detect),
Some(autumn_web::config::DatabaseBackend::Sqlite)
) {
return Vec::new();
}
let migrations: Vec<Box<dyn Migration<Pg>>> = autumn_web::migrate::FRAMEWORK_MIGRATIONS
.migrations()
.unwrap_or_default();
migrations.iter().map(|m| m.name().to_string()).collect()
}
fn resolve_free_version(taken: &BTreeSet<String>, now: NaiveDateTime) -> Result<String, String> {
let max_taken_str = taken.iter().max().cloned();
let mut version = now;
if let Some(max_taken_str) = &max_taken_str
&& max_taken_str.as_str() > format_version(now).as_str()
{
let Some(max_taken) = parse_version(max_taken_str) else {
return Err(format!(
"the latest existing migration version is {max_taken_str}, which is not a \
valid calendar timestamp -- Diesel only requires 14 digits, not a real date, \
but this generator needs to do date arithmetic on the latest one to guarantee \
the next version sorts after it.\n\
Fix migrations/{max_taken_str}_* to a real UTC timestamp (or rename it if it \
was a typo) before generating another migration.",
));
};
let horizon = version + Duration::days(7);
if max_taken > horizon {
return Err(format!(
"the latest existing migration version is {}, which is more than a week ahead \
of this machine's UTC clock ({}).\n\
Either the clock is wrong, or a migration carries a mistyped version.\n\
Refusing to guess: generating a version before it would make Diesel run this \
migration first, and generating one after it would push every future \
migration behind a bogus date.\n\
Look for migrations/{}_* here or on another branch.",
format_version(max_taken),
format_version(now),
format_version(max_taken),
));
}
version = max_taken + Duration::seconds(1);
eprintln!(
"note: this machine's UTC clock ({}) is behind the latest existing migration ({}); \
using {} so this one still sorts -- and therefore runs -- after it",
format_version(now),
format_version(max_taken),
format_version(version),
);
}
let mut bumped = 0u32;
while taken.contains(&format_version(version)) {
version += Duration::seconds(1);
bumped += 1;
if bumped > 120 {
return Err(format!(
"no free version in the two minutes after {}; that is not a clock problem -- \
check migrations/ for a block of hand-written consecutive stamps",
format_version(version),
));
}
}
if bumped > 0 {
eprintln!("note: the starting second was already claimed; moved forward {bumped}s");
}
Ok(format_version(version))
}
fn validate_migration_name(name: &str) -> Result<(), String> {
let starts_right = name.chars().next().is_some_and(|c| c.is_ascii_lowercase());
let chars_ok = name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
let no_double_underscore = !name.contains("__");
let no_edge_underscore = !name.starts_with('_') && !name.ends_with('_');
if !name.is_empty() && starts_right && chars_ok && no_double_underscore && no_edge_underscore {
Ok(())
} else {
Err(format!(
"'{name}' is not a snake_case name starting with a letter (lowercase letters, \
digits and single underscores)"
))
}
}
struct NewMigrationLock {
path: std::path::PathBuf,
}
impl NewMigrationLock {
fn acquire(migrations_dir: &Path) -> Result<Self, String> {
let path = migrations_dir.join(".autumn-migrate-new.lock");
for attempt in 0..50 {
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(_) => return Ok(Self { path }),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
if attempt == 0 {
eprintln!(
"note: another `autumn migrate new` looks to be in progress \
({}); waiting for it to finish\u{2026}",
path.display()
);
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
Err(e) => {
return Err(format!("cannot create {}: {e}", path.display()));
}
}
}
Err(format!(
"{} still exists after 5s -- if no `autumn migrate new` is actually running, \
remove it and retry",
path.display()
))
}
}
impl Drop for NewMigrationLock {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
fn run_new_locked(migrations_dir: &Path, name: &str) -> Result<std::path::PathBuf, String> {
let mut taken: BTreeSet<String> = local_migration_entries(migrations_dir)
.iter()
.filter_map(|d| version_prefix(d))
.collect();
for git_ref in git_refs(&["refs/heads", "refs/remotes"]) {
for dir_name in migration_dirs_at_ref(&git_ref, DEFAULT_MIGRATIONS_DIR) {
if let Some(v) = version_prefix(&dir_name) {
taken.insert(v);
}
}
}
for fw_name in framework_migration_names() {
if let Some(v) = version_prefix(&fw_name) {
taken.insert(v);
}
}
let version = resolve_free_version(&taken, Utc::now().naive_utc())?;
let dir_name = format!("{version}_{name}");
let dir = migrations_dir.join(&dir_name);
if dir.exists() {
return Err(format!("{} already exists", dir.display()));
}
std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
let up_sql = format!(
"-- {name}\n\
--\n\
-- WHY: state the problem this migration solves, not what the SQL does. The DDL\n\
-- below already says what it does; what a reader six months from now cannot\n\
-- recover is why it was worth a schema change.\n\
--\n\
-- LOCKING: if this touches a large or hot table, say which lock each statement\n\
-- takes and for how long. `ALTER TABLE ... ADD CONSTRAINT ... CHECK` and `ADD\n\
-- COLUMN ... NOT NULL` without a default both scan the whole table under ACCESS\n\
-- EXCLUSIVE; on a chokepoint table that is an outage. Splitting into `NOT VALID`\n\
-- plus a separate `VALIDATE CONSTRAINT` migration avoids it.\n"
);
let down_sql = format!(
"-- Reverse of {dir_name}.\n\
--\n\
-- If the reverse cannot preserve data, say so HERE and make the loss an explicit\n\
-- operator act rather than a silent DELETE.\n"
);
std::fs::write(dir.join("up.sql"), up_sql)
.map_err(|e| format!("cannot write {}: {e}", dir.join("up.sql").display()))?;
std::fs::write(dir.join("down.sql"), down_sql)
.map_err(|e| format!("cannot write {}: {e}", dir.join("down.sql").display()))?;
Ok(dir)
}
pub fn run_new(name: &str) {
if let Err(e) = validate_migration_name(name) {
eprintln!("error: {e}");
std::process::exit(2);
}
let migrations_dir = Path::new(DEFAULT_MIGRATIONS_DIR);
if !migrations_dir.exists() {
eprintln!(
"\u{2717} Migrations directory not found: {DEFAULT_MIGRATIONS_DIR}/\n Create it \
with `diesel setup`."
);
std::process::exit(1);
}
let lock = match NewMigrationLock::acquire(migrations_dir) {
Ok(lock) => lock,
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
};
let result = run_new_locked(migrations_dir, name);
drop(lock);
match result {
Ok(dir) => println!("{}", dir.display()),
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
}
fn resolve_default_branch_ref() -> Option<String> {
if let Some(branch) = remote_head_branch() {
return Some(format!("refs/remotes/origin/{branch}"));
}
if let Ok(output) = Command::new("git")
.args(["symbolic-ref", "-q", "refs/remotes/origin/HEAD"])
.output()
&& output.status.success()
{
let r = String::from_utf8_lossy(&output.stdout).trim().to_owned();
if !r.is_empty() {
return Some(r);
}
}
["main", "master", "trunk", "trunk-dev"]
.into_iter()
.map(|name| format!("refs/remotes/origin/{name}"))
.find(|r| {
Command::new("git")
.args(["show-ref", "--verify", "--quiet", r])
.status()
.is_ok_and(|s| s.success())
})
}
fn remote_head_branch() -> Option<String> {
let output = Command::new("git")
.args(["ls-remote", "--symref", "origin", "HEAD"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout)
.lines()
.find_map(|line| {
let rest = line.strip_prefix("ref: ")?;
let refname = rest.split('\t').next()?;
refname.strip_prefix("refs/heads/").map(str::to_owned)
})
}
pub fn run_check_collisions() {
let migrations_dir = Path::new(DEFAULT_MIGRATIONS_DIR);
if !migrations_dir.exists() {
println!("OK: no {DEFAULT_MIGRATIONS_DIR}/ directory -- nothing to check");
return;
}
let mut claimants: BTreeMap<String, Vec<String>> = BTreeMap::new();
let working_tree = local_migration_entries(migrations_dir);
for dir_name in &working_tree {
claimants
.entry(dir_name.clone())
.or_default()
.push("working tree".to_owned());
}
let remote_heads = remote_branch_heads();
let local_heads = local_origin_branch_heads();
let is_ref_current = |branch_name: &str| -> bool {
remote_heads.as_ref().is_some_and(|remote_heads| {
remote_heads
.get(branch_name)
.is_some_and(|remote_sha| local_heads.get(branch_name) == Some(remote_sha))
})
};
let default_ref = resolve_default_branch_ref();
let mut default_branch_entries: BTreeSet<String> = BTreeSet::new();
let default_verified = default_ref.as_ref().is_some_and(|default_ref| {
let is_current = default_ref
.strip_prefix("refs/remotes/origin/")
.is_some_and(is_ref_current);
if is_current {
for dir_name in migration_dirs_at_ref(default_ref, DEFAULT_MIGRATIONS_DIR) {
default_branch_entries.insert(dir_name.clone());
claimants
.entry(dir_name)
.or_default()
.push(default_ref.clone());
}
}
is_current
});
let remote_refs = git_refs(&["refs/remotes/origin"]);
let refs_seen = remote_refs.len();
for git_ref in &remote_refs {
if Some(git_ref) == default_ref.as_ref() {
continue; }
if let Some(branch_name) = git_ref.strip_prefix("refs/remotes/origin/")
&& !is_ref_current(branch_name)
{
continue;
}
for dir_name in migration_dirs_at_ref(git_ref, DEFAULT_MIGRATIONS_DIR) {
claimants.entry(dir_name).or_default().push(git_ref.clone());
}
}
let framework_names: BTreeSet<String> = framework_migration_names().into_iter().collect();
for name in &framework_names {
claimants
.entry(name.clone())
.or_default()
.push("autumn framework migrations".to_owned());
}
let full_branch_coverage = remote_heads.as_ref().is_some_and(|remote_heads| {
!remote_heads.is_empty()
&& remote_heads
.iter()
.all(|(name, sha)| local_heads.get(name) == Some(sha))
});
if !full_branch_coverage {
eprintln!(
"\nWARNING: this clone does not have every branch's refs available, so ONLY the\n\
\x20 working tree and this CLI's compiled-in framework migrations were\n\
\x20 checked -- the cross-branch half of this check did not run (or only\n\
\x20 ran against a narrowed subset of branches).\n\n\
\x20 In CI this means the checkout is shallow or single-branch: set\n\
\x20 `fetch-depth: 0` on actions/checkout (and drop any `ref:`/sparse\n\
\x20 narrowing), or fetch every branch explicitly:\n\n\
\x20 git fetch origin '+refs/heads/*:refs/remotes/origin/*'\n\n\
\x20 Locally, `git fetch origin` is enough.\n"
);
}
let mut by_version: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for dir_name in claimants.keys() {
if let Some(v) = version_prefix(dir_name) {
by_version.entry(v).or_default().insert(dir_name.clone());
}
}
let working_tree_only: BTreeSet<&String> = working_tree
.iter()
.filter(|d| !default_branch_entries.contains(*d))
.collect();
let mut mine: Vec<(String, BTreeSet<String>)> = Vec::new();
let mut others = 0usize;
for (version, names) in &by_version {
if names.len() < 2 {
continue;
}
let has_framework_claimant = names.iter().any(|n| framework_names.contains(n));
let purely_local = names.iter().all(|n| working_tree.contains(n));
let has_working_tree_only_member = names.iter().any(|n| working_tree_only.contains(n));
let attributable_via_working_tree =
(default_verified || purely_local) && has_working_tree_only_member;
let attributable_via_framework =
has_framework_claimant && default_verified && has_working_tree_only_member;
if attributable_via_framework || attributable_via_working_tree {
mine.push((version.clone(), names.clone()));
} else {
others += 1;
}
}
if !mine.is_empty() {
eprintln!("\nFAIL: migration version claimed by more than one directory:");
for (version, names) in &mine {
eprintln!("\n version {version}:");
for name in names {
let sources = claimants
.get(name)
.map(|s| s.join(", "))
.unwrap_or_default();
eprintln!(" migrations/{name} ({sources})");
}
}
eprintln!(
"\nDiesel records applied migrations BY VERSION, so on a fresh database only ONE\n\
directory per version ever runs -- the other is skipped forever, silently. A\n\
skipped migration is not merely absent: a constraint it was supposed to add will\n\
appear to exist in review and not exist in the database.\n\n\
Fix: renumber YOUR migration (the one not yet on the default branch) to a free\n\
version.\n\n\
\x20 autumn migrate new <name> # picks a free version\n\
\x20 git mv migrations/<old> migrations/<new>_<same_name>\n\n\
Keep the descriptive suffix, and if your migration is one half of an ordered\n\
pair (ADD ... NOT VALID / VALIDATE), keep the pair in order."
);
std::process::exit(1);
}
if others > 0 {
eprintln!(
"note: {others} version collision(s) exist between OTHER branches and do not \
involve this one -- they will fail on their own pushes."
);
}
if full_branch_coverage {
println!(
"OK: no migration this checkout introduces collides with the default branch or \
with any of {refs_seen} remote ref(s)"
);
} else {
println!(
"OK: every migration version in the working tree is unique (cross-branch check \
SKIPPED -- see the warning above)"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dt(s: &str) -> NaiveDateTime {
parse_version(s).unwrap()
}
#[test]
fn version_prefix_accepts_14_digit_prefix() {
assert_eq!(
version_prefix("20260812000000_add_widgets"),
Some("20260812000000".to_owned())
);
}
#[test]
fn version_prefix_rejects_non_migration_entries() {
assert_eq!(version_prefix("README.md"), None);
assert_eq!(version_prefix("2026081200000_short"), None);
assert_eq!(version_prefix("not_a_timestamp_at_all"), None);
}
#[test]
fn validate_migration_name_accepts_snake_case() {
assert!(validate_migration_name("add_widget_archived_at").is_ok());
}
#[test]
fn validate_migration_name_rejects_leading_digit() {
assert!(validate_migration_name("1add_widget").is_err());
}
#[test]
fn validate_migration_name_rejects_pascal_case() {
assert!(validate_migration_name("AddWidgetArchivedAt").is_err());
}
#[test]
fn validate_migration_name_rejects_double_underscore() {
assert!(validate_migration_name("add__widget").is_err());
}
#[test]
fn resolve_free_version_uses_now_when_nothing_is_taken() {
let now = dt("20260812120000");
assert_eq!(
resolve_free_version(&BTreeSet::new(), now).unwrap(),
"20260812120000"
);
}
#[test]
fn resolve_free_version_bumps_past_an_exact_second_collision() {
let mut taken = BTreeSet::new();
taken.insert("20260812120000".to_owned());
let now = dt("20260812120000");
assert_eq!(resolve_free_version(&taken, now).unwrap(), "20260812120001");
}
#[test]
fn resolve_free_version_skips_a_run_of_taken_seconds() {
let mut taken = BTreeSet::new();
for s in ["20260812120000", "20260812120001", "20260812120002"] {
taken.insert(s.to_owned());
}
let now = dt("20260812120000");
assert_eq!(resolve_free_version(&taken, now).unwrap(), "20260812120003");
}
#[test]
fn resolve_free_version_sorts_after_a_future_taken_version_within_the_clock_skew_horizon() {
let mut taken = BTreeSet::new();
taken.insert("20260812120500".to_owned());
let now = dt("20260812120000"); assert_eq!(resolve_free_version(&taken, now).unwrap(), "20260812120501");
}
#[test]
fn resolve_free_version_refuses_to_guess_past_a_week_ahead() {
let mut taken = BTreeSet::new();
taken.insert("20270101000000".to_owned()); let now = dt("20260812120000");
let err = resolve_free_version(&taken, now).unwrap_err();
assert!(err.contains("more than a week ahead"), "{err}");
}
#[test]
fn resolve_free_version_rejects_a_calendar_invalid_max_taken_version() {
let mut taken = BTreeSet::new();
taken.insert("20261301000000".to_owned());
let now = dt("20260812120000");
let err = resolve_free_version(&taken, now).unwrap_err();
assert!(err.contains("20261301000000"), "{err}");
assert!(err.contains("not a valid calendar timestamp"), "{err}");
}
#[test]
fn resolve_free_version_ignores_a_calendar_invalid_version_that_is_not_the_max() {
let mut taken = BTreeSet::new();
taken.insert("20260812120000".to_owned());
taken.insert("00000013000000".to_owned()); let now = dt("20260812120000");
assert_eq!(resolve_free_version(&taken, now).unwrap(), "20260812120001");
}
#[test]
fn resolve_free_version_is_never_before_the_max_taken_version() {
let mut taken = BTreeSet::new();
taken.insert("20260812120000".to_owned());
taken.insert("20260812130000".to_owned());
let now = dt("20260810000000"); let version = resolve_free_version(&taken, now).unwrap();
assert!(version.as_str() > "20260812130000", "{version}");
}
}