From a06f873f17a021b03f293e5c1d86a3db6bc73f34 Mon Sep 17 00:00:00 2001 From: Ryan Daigle Date: Mon, 26 Jan 2026 14:16:45 -0500 Subject: [PATCH 1/2] Add baseline command for migration cleanup Adds the `migrate baseline ` command which marks a specific version as the baseline, allowing old migration files to be deleted while preserving history. Also adds `--baseline` and `--keep` flags to the `up` command. - Add src/baseline.rs for baseline file management - Add src/commands/baseline.rs for CLI command - Update status and up commands to respect baselines - Update documentation in README.md and CLAUDE.md - Bump version to 0.4.0 (new feature) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 7 + Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 20 ++- src/baseline.rs | 349 +++++++++++++++++++++++++++++++++++++++ src/commands/baseline.rs | 98 +++++++++++ src/commands/mod.rs | 1 + src/commands/status.rs | 63 ++++++- src/commands/up.rs | 53 +++++- src/lib.rs | 1 + src/main.rs | 49 +++++- src/state.rs | 52 +++++- 12 files changed, 673 insertions(+), 24 deletions(-) create mode 100644 src/baseline.rs create mode 100644 src/commands/baseline.rs diff --git a/CLAUDE.md b/CLAUDE.md index 0e77d51..028a013 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,9 +8,14 @@ Generic file migration tool for applying ordered transformations to a project di migrate status # Show applied/pending migrations migrate up # Apply all pending migrations migrate up --dry-run # Preview without applying +migrate up --baseline # Apply and create baseline at final version +migrate up --baseline --keep # Apply and baseline without deleting files migrate create # Create new bash migration migrate create --template ts # Create TypeScript migration migrate create --list-templates # List available templates +migrate baseline # Create baseline at specific version +migrate baseline --dry-run # Preview baseline changes +migrate baseline --keep # Baseline without deleting files ``` ## Options @@ -68,11 +73,13 @@ await fs.writeFile(`${projectRoot}/config.json`, '{}'); - `src/state.rs` - History tracking (`.history` file) - `src/version.rs` - Base36 version generation and parsing - `src/templates.rs` - Embedded migration templates +- `src/baseline.rs` - Baseline management (`.baseline` file) - `src/commands/` - CLI command implementations - `mod.rs` - Command module exports - `status.rs` - Status command (shows version summary) - `up.rs` - Up command - `create.rs` - Create command (generates time-based version) + - `baseline.rs` - Baseline command (marks versions as applied) - `templates/` - Template source files (bash.sh, typescript.ts, python.py, node.js, ruby.rb) ## Development diff --git a/Cargo.lock b/Cargo.lock index 1f03a9e..ac1d16b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,7 +285,7 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "migrate" -version = "0.3.2" +version = "0.4.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 15a526e..7a9fa50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "migrate" -version = "0.3.2" +version = "0.4.0" edition = "2021" description = "Generic file migration tool for applying ordered transformations to a project directory" license = "MIT" diff --git a/README.md b/README.md index 89e1670..8b76844 100644 --- a/README.md +++ b/README.md @@ -140,17 +140,21 @@ Migrations run in order by their version prefix (e.g., `1fb2g-`) and are tracked | `migrate status` | Show applied and pending migrations | | `migrate up` | Apply all pending migrations | | `migrate create ` | Create a new migration file | +| `migrate baseline `| Mark a version as the baseline | ### Options -| Option | Description | Default | -| -------------------------- | ------------------------------------- | ------------ | -| `-r, --root ` | Project root directory | `.` | -| `-m, --migrations ` | Migrations directory | `migrations` | -| `--dry-run` | Preview changes (up only) | `false` | -| `-t, --template ` | Template to use (create only) | `bash` | -| `-d, --description ` | Migration description (create only) | - | -| `--list-templates` | List available templates (create only)| - | +| Option | Description | Default | +| -------------------------- | ------------------------------------------------ | ------------ | +| `-r, --root ` | Project root directory | `.` | +| `-m, --migrations ` | Migrations directory | `migrations` | +| `--dry-run` | Preview changes (up, baseline) | `false` | +| `--baseline` | Create baseline after applying (up only) | `false` | +| `--keep` | Keep migration files when baselining (up, baseline) | `false` | +| `-t, --template ` | Template to use (create only) | `bash` | +| `-d, --description ` | Migration description (create only) | - | +| `-s, --summary ` | Baseline summary (baseline only) | - | +| `--list-templates` | List available templates (create only) | - | ## Available Templates diff --git a/src/baseline.rs b/src/baseline.rs new file mode 100644 index 0000000..cc55d1e --- /dev/null +++ b/src/baseline.rs @@ -0,0 +1,349 @@ +use anyhow::{bail, Context, Result}; +use chrono::{DateTime, Utc}; +use std::fs; +use std::path::Path; + +const BASELINE_FILE: &str = ".baseline"; + +/// A baseline assertion: migrations with version <= this are no longer required as files +#[derive(Debug, Clone)] +pub struct Baseline { + /// Version string (e.g., "1fb2g") + pub version: String, + /// When the baseline was created + pub created: DateTime, + /// Optional description of what migrations are included + pub summary: Option, +} + +/// Read the baseline file if it exists. +pub fn read_baseline(migrations_dir: &Path) -> Result> { + let baseline_path = migrations_dir.join(BASELINE_FILE); + + if !baseline_path.exists() { + return Ok(None); + } + + let content = fs::read_to_string(&baseline_path) + .with_context(|| format!("Failed to read baseline file: {}", baseline_path.display()))?; + + parse_baseline(&content).map(Some) +} + +/// Write the baseline file. +pub fn write_baseline(migrations_dir: &Path, baseline: &Baseline) -> Result<()> { + let baseline_path = migrations_dir.join(BASELINE_FILE); + + let mut content = format!( + "version: {}\ncreated: {}\n", + baseline.version, + baseline.created.to_rfc3339() + ); + + if let Some(summary) = &baseline.summary { + content.push_str("summary: |\n"); + for line in summary.lines() { + content.push_str(" "); + content.push_str(line); + content.push('\n'); + } + } + + fs::write(&baseline_path, content) + .with_context(|| format!("Failed to write baseline file: {}", baseline_path.display()))?; + + Ok(()) +} + +/// Parse baseline file content into a Baseline struct. +fn parse_baseline(content: &str) -> Result { + let mut version: Option = None; + let mut created: Option> = None; + let mut summary: Option = None; + let mut in_summary = false; + let mut summary_lines: Vec = Vec::new(); + + for line in content.lines() { + if in_summary { + // Summary lines are indented with 2 spaces + if let Some(stripped) = line.strip_prefix(" ") { + summary_lines.push(stripped.to_string()); + continue; + } else if line.starts_with(' ') || line.is_empty() { + // Still in summary block + if line.is_empty() { + summary_lines.push(String::new()); + } else { + summary_lines.push(line.trim_start().to_string()); + } + continue; + } else { + // End of summary block + in_summary = false; + summary = Some(summary_lines.join("\n").trim_end().to_string()); + summary_lines.clear(); + } + } + + if let Some(stripped) = line.strip_prefix("version:") { + version = Some(stripped.trim().to_string()); + } else if let Some(stripped) = line.strip_prefix("created:") { + let timestamp_str = stripped.trim(); + created = Some( + DateTime::parse_from_rfc3339(timestamp_str) + .with_context(|| format!("Invalid timestamp in baseline: {}", timestamp_str))? + .with_timezone(&Utc), + ); + } else if let Some(stripped) = line.strip_prefix("summary:") { + let rest = stripped.trim(); + if rest == "|" { + // Multi-line summary + in_summary = true; + } else if !rest.is_empty() { + // Single-line summary + summary = Some(rest.to_string()); + } + } + } + + // Handle summary at end of file + if in_summary && !summary_lines.is_empty() { + summary = Some(summary_lines.join("\n").trim_end().to_string()); + } + + let version = version.context("Baseline file missing 'version' field")?; + let created = created.context("Baseline file missing 'created' field")?; + + Ok(Baseline { + version, + created, + summary, + }) +} + +/// Compare two version strings. Returns true if v1 <= v2. +pub fn version_lte(v1: &str, v2: &str) -> bool { + v1 <= v2 +} + +/// Delete migration files at or before the baseline version. +/// Returns the list of deleted file paths. +pub fn delete_baselined_migrations( + baseline_version: &str, + available: &[crate::Migration], +) -> Result> { + let mut deleted = Vec::new(); + + for migration in available { + if version_lte(&migration.version, baseline_version) && migration.file_path.exists() { + fs::remove_file(&migration.file_path).with_context(|| { + format!( + "Failed to delete migration file: {}", + migration.file_path.display() + ) + })?; + deleted.push(migration.file_path.display().to_string()); + } + } + + Ok(deleted) +} + +/// Validate that a baseline can be created at the given version. +/// Returns an error if validation fails. +pub fn validate_baseline( + version: &str, + available: &[crate::Migration], + applied: &[crate::AppliedMigration], + existing_baseline: Option<&Baseline>, +) -> Result<()> { + // Check if the version matches any migration + let matching_migration = available.iter().find(|m| m.version == version); + if matching_migration.is_none() { + bail!("No migration found with version '{}'", version); + } + + // Cannot move baseline backward + if let Some(existing) = existing_baseline { + if version < existing.version.as_str() { + bail!( + "Cannot move baseline backward from '{}' to '{}'", + existing.version, + version + ); + } + } + + // All migrations at or before the version must be in history + let applied_ids: std::collections::HashSet<&str> = + applied.iter().map(|a| a.id.as_str()).collect(); + + for migration in available { + if version_lte(&migration.version, version) && !applied_ids.contains(migration.id.as_str()) + { + bail!( + "Cannot baseline: migration '{}' has not been applied", + migration.id + ); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{AppliedMigration, Migration}; + use std::path::PathBuf; + + #[test] + fn test_parse_baseline_simple() { + let content = "version: 1fb2g\ncreated: 2024-06-15T14:30:00Z\n"; + let baseline = parse_baseline(content).unwrap(); + assert_eq!(baseline.version, "1fb2g"); + assert!(baseline.summary.is_none()); + } + + #[test] + fn test_parse_baseline_with_summary() { + let content = r#"version: 1fb2g +created: 2024-06-15T14:30:00Z +summary: | + Initial project setup + TypeScript config +"#; + let baseline = parse_baseline(content).unwrap(); + assert_eq!(baseline.version, "1fb2g"); + assert_eq!( + baseline.summary, + Some("Initial project setup\nTypeScript config".to_string()) + ); + } + + #[test] + fn test_parse_baseline_single_line_summary() { + let content = "version: 1fb2g\ncreated: 2024-06-15T14:30:00Z\nsummary: Initial setup\n"; + let baseline = parse_baseline(content).unwrap(); + assert_eq!(baseline.version, "1fb2g"); + assert_eq!(baseline.summary, Some("Initial setup".to_string())); + } + + #[test] + fn test_version_lte() { + assert!(version_lte("1f700", "1f700")); + assert!(version_lte("1f700", "1f710")); + assert!(!version_lte("1f710", "1f700")); + assert!(version_lte("00000", "zzzzz")); + } + + #[test] + fn test_validate_baseline_no_matching_migration() { + let available = vec![Migration { + id: "1f700-first".to_string(), + version: "1f700".to_string(), + file_path: PathBuf::from("1f700-first.sh"), + }]; + let applied = vec![]; + + let result = validate_baseline("1f800", &available, &applied, None); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("No migration found")); + } + + #[test] + fn test_validate_baseline_unapplied_migration() { + let available = vec![ + Migration { + id: "1f700-first".to_string(), + version: "1f700".to_string(), + file_path: PathBuf::from("1f700-first.sh"), + }, + Migration { + id: "1f710-second".to_string(), + version: "1f710".to_string(), + file_path: PathBuf::from("1f710-second.sh"), + }, + ]; + let applied = vec![AppliedMigration { + id: "1f710-second".to_string(), + applied_at: Utc::now(), + }]; + + // Try to baseline at 1f710, but 1f700 hasn't been applied + let result = validate_baseline("1f710", &available, &applied, None); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("has not been applied")); + } + + #[test] + fn test_validate_baseline_backward_movement() { + let available = vec![ + Migration { + id: "1f700-first".to_string(), + version: "1f700".to_string(), + file_path: PathBuf::from("1f700-first.sh"), + }, + Migration { + id: "1f710-second".to_string(), + version: "1f710".to_string(), + file_path: PathBuf::from("1f710-second.sh"), + }, + ]; + let applied = vec![ + AppliedMigration { + id: "1f700-first".to_string(), + applied_at: Utc::now(), + }, + AppliedMigration { + id: "1f710-second".to_string(), + applied_at: Utc::now(), + }, + ]; + + let existing = Baseline { + version: "1f710".to_string(), + created: Utc::now(), + summary: None, + }; + + let result = validate_baseline("1f700", &available, &applied, Some(&existing)); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("backward")); + } + + #[test] + fn test_validate_baseline_success() { + let available = vec![ + Migration { + id: "1f700-first".to_string(), + version: "1f700".to_string(), + file_path: PathBuf::from("1f700-first.sh"), + }, + Migration { + id: "1f710-second".to_string(), + version: "1f710".to_string(), + file_path: PathBuf::from("1f710-second.sh"), + }, + ]; + let applied = vec![ + AppliedMigration { + id: "1f700-first".to_string(), + applied_at: Utc::now(), + }, + AppliedMigration { + id: "1f710-second".to_string(), + applied_at: Utc::now(), + }, + ]; + + let result = validate_baseline("1f710", &available, &applied, None); + assert!(result.is_ok()); + } +} diff --git a/src/commands/baseline.rs b/src/commands/baseline.rs new file mode 100644 index 0000000..83398e4 --- /dev/null +++ b/src/commands/baseline.rs @@ -0,0 +1,98 @@ +use anyhow::Result; +use chrono::Utc; +use std::path::Path; + +use crate::baseline::{ + delete_baselined_migrations, read_baseline, validate_baseline, write_baseline, Baseline, +}; + +use crate::loader::discover_migrations; +use crate::state::read_history; + +/// Create a baseline at the specified version +pub fn run( + project_root: &Path, + migrations_dir: &Path, + version: &str, + summary: Option<&str>, + dry_run: bool, + keep: bool, +) -> Result<()> { + let migrations_path = if migrations_dir.is_absolute() { + migrations_dir.to_path_buf() + } else { + project_root.join(migrations_dir) + }; + + if !migrations_path.exists() { + println!( + "No migrations directory found at: {}", + migrations_path.display() + ); + return Ok(()); + } + + let available = discover_migrations(&migrations_path)?; + let applied = read_history(&migrations_path)?; + let existing_baseline = read_baseline(&migrations_path)?; + + // Validate the baseline + validate_baseline(version, &available, &applied, existing_baseline.as_ref())?; + + // Find migrations that would be deleted + let to_delete: Vec<_> = available + .iter() + .filter(|m| m.version.as_str() <= version) + .collect(); + + if dry_run { + println!("Dry run - no changes will be made"); + println!(); + } + + println!( + "Creating baseline at version '{}'{}", + version, + if dry_run { " (dry run)" } else { "" } + ); + println!(); + + if !to_delete.is_empty() && !keep { + println!( + "{} migration file(s) to delete:", + if dry_run { "Would delete" } else { "Deleting" } + ); + for migration in &to_delete { + println!(" - {}", migration.id); + } + println!(); + } else if keep { + println!("Keeping migration files (--keep flag)"); + println!(); + } + + if dry_run { + return Ok(()); + } + + // Create the baseline + let baseline = Baseline { + version: version.to_string(), + created: Utc::now(), + summary: summary.map(|s| s.to_string()), + }; + + write_baseline(&migrations_path, &baseline)?; + println!("Created .baseline file"); + + // Delete old migration files unless --keep was specified + if !keep && !to_delete.is_empty() { + let deleted = delete_baselined_migrations(version, &available)?; + println!("Deleted {} migration file(s)", deleted.len()); + } + + println!(); + println!("Baseline created successfully at version '{}'", version); + + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index c5f864f..4a5456b 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod baseline; pub mod create; pub mod status; pub mod up; diff --git a/src/commands/status.rs b/src/commands/status.rs index baf9c50..9822dcd 100644 --- a/src/commands/status.rs +++ b/src/commands/status.rs @@ -1,6 +1,7 @@ use anyhow::Result; use std::path::Path; +use crate::baseline::read_baseline; use crate::loader::discover_migrations; use crate::state::{get_current_version, get_pending, get_target_version, read_history}; @@ -22,9 +23,10 @@ pub fn run(project_root: &Path, migrations_dir: &Path) -> Result<()> { let available = discover_migrations(&migrations_path)?; let applied = read_history(&migrations_path)?; - let pending = get_pending(&available, &applied); + let baseline = read_baseline(&migrations_path)?; + let pending = get_pending(&available, &applied, baseline.as_ref()); - if available.is_empty() { + if available.is_empty() && baseline.is_none() { println!("No migrations found in: {}", migrations_path.display()); return Ok(()); } @@ -36,8 +38,27 @@ pub fn run(project_root: &Path, migrations_dir: &Path) -> Result<()> { println!("================"); println!(); + // Show baseline info if present + if let Some(ref b) = baseline { + println!("Baseline: {} ({})", b.version, b.created.format("%Y-%m-%d")); + if let Some(ref summary) = b.summary { + for line in summary.lines() { + println!(" {}", line); + } + } + println!(); + } + // Show version summary line match (¤t_version, &target_version) { + (None, Some(target)) if baseline.is_some() => { + println!( + "Version: {} -> {} ({} pending)", + baseline.as_ref().unwrap().version, + target, + pending.len() + ); + } (None, Some(target)) => { println!("Version: (none) -> {} ({} pending)", target, pending.len()); } @@ -52,6 +73,12 @@ pub fn run(project_root: &Path, migrations_dir: &Path) -> Result<()> { pending.len() ); } + (None, None) if baseline.is_some() => { + println!( + "Version: {} (up to date, baselined)", + baseline.as_ref().unwrap().version + ); + } _ => {} } println!(); @@ -60,11 +87,24 @@ pub fn run(project_root: &Path, migrations_dir: &Path) -> Result<()> { if !applied.is_empty() { println!("Applied ({}):", applied.len()); for migration in &applied { - println!( - " + {} {}", - migration.id, - migration.applied_at.format("%Y-%m-%d %H:%M:%S") - ); + // Check if this migration is at or before baseline + let is_baselined = baseline + .as_ref() + .is_some_and(|b| extract_version(&migration.id) <= Some(b.version.clone())); + + if is_baselined { + println!( + " + {} {} (baseline)", + migration.id, + migration.applied_at.format("%Y-%m-%d %H:%M:%S") + ); + } else { + println!( + " + {} {}", + migration.id, + migration.applied_at.format("%Y-%m-%d %H:%M:%S") + ); + } } println!(); } @@ -79,3 +119,12 @@ pub fn run(project_root: &Path, migrations_dir: &Path) -> Result<()> { Ok(()) } + +/// Extract version from a migration ID (e.g., "1f72f-init" -> "1f72f") +fn extract_version(id: &str) -> Option { + if id.len() >= 5 && id.chars().nth(5) == Some('-') { + Some(id[..5].to_string()) + } else { + None + } +} diff --git a/src/commands/up.rs b/src/commands/up.rs index 1fc8825..7811c90 100644 --- a/src/commands/up.rs +++ b/src/commands/up.rs @@ -2,13 +2,20 @@ use anyhow::Result; use chrono::Utc; use std::path::Path; +use crate::baseline::{delete_baselined_migrations, read_baseline, write_baseline, Baseline}; use crate::executor::execute; use crate::loader::discover_migrations; use crate::state::{append_history, get_pending, read_history}; use crate::ExecutionContext; /// Apply all pending migrations -pub fn run(project_root: &Path, migrations_dir: &Path, dry_run: bool) -> Result<()> { +pub fn run( + project_root: &Path, + migrations_dir: &Path, + dry_run: bool, + create_baseline: bool, + keep: bool, +) -> Result<()> { let project_root = if project_root.is_absolute() { project_root.to_path_buf() } else { @@ -31,7 +38,8 @@ pub fn run(project_root: &Path, migrations_dir: &Path, dry_run: bool) -> Result< let available = discover_migrations(&migrations_path)?; let applied = read_history(&migrations_path)?; - let pending = get_pending(&available, &applied); + let baseline = read_baseline(&migrations_path)?; + let pending = get_pending(&available, &applied, baseline.as_ref()); if pending.is_empty() { println!("No pending migrations."); @@ -45,11 +53,14 @@ pub fn run(project_root: &Path, migrations_dir: &Path, dry_run: bool) -> Result< ); println!(); - for migration in pending { + let mut last_applied_version: Option = None; + + for migration in &pending { println!("→ {}", migration.id); if dry_run { println!(" (dry run - skipped)"); + last_applied_version = Some(migration.version.clone()); continue; } @@ -65,6 +76,7 @@ pub fn run(project_root: &Path, migrations_dir: &Path, dry_run: bool) -> Result< if result.success { let applied_at = Utc::now(); append_history(&migrations_path, &migration.id, applied_at)?; + last_applied_version = Some(migration.version.clone()); println!(" ✓ completed"); } else { println!(" ✗ failed (exit code {})", result.exit_code); @@ -82,5 +94,40 @@ pub fn run(project_root: &Path, migrations_dir: &Path, dry_run: bool) -> Result< println!(); println!("All migrations applied successfully."); + // Handle --baseline flag + if create_baseline { + if let Some(version) = last_applied_version { + println!(); + if dry_run { + println!("Would create baseline at version '{}'", version); + if !keep { + let to_delete: Vec<_> = available + .iter() + .filter(|m| m.version.as_str() <= version.as_str()) + .collect(); + if !to_delete.is_empty() { + println!("Would delete {} migration file(s)", to_delete.len()); + } + } + } else { + let new_baseline = Baseline { + version: version.clone(), + created: Utc::now(), + summary: None, + }; + + write_baseline(&migrations_path, &new_baseline)?; + println!("Created baseline at version '{}'", version); + + if !keep { + let deleted = delete_baselined_migrations(&version, &available)?; + if !deleted.is_empty() { + println!("Deleted {} migration file(s)", deleted.len()); + } + } + } + } + } + Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index 9538cc5..f637875 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod baseline; pub mod commands; pub mod executor; pub mod loader; diff --git a/src/main.rs b/src/main.rs index ddad890..0e20a10 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,14 @@ enum Commands { /// Preview without applying #[arg(long)] dry_run: bool, + + /// Create baseline at final version after applying (deletes old migration files) + #[arg(long)] + baseline: bool, + + /// Keep migration files when using --baseline (don't delete) + #[arg(long)] + keep: bool, }, /// Create a new migration @@ -48,6 +56,24 @@ enum Commands { #[arg(long)] list_templates: bool, }, + + /// Create a baseline at a specific version (removes old migration files) + Baseline { + /// Version to baseline at (e.g., "1fb2g") + version: String, + + /// Summary description for the baseline + #[arg(short = 's', long)] + summary: Option, + + /// Preview without making changes + #[arg(long)] + dry_run: bool, + + /// Keep migration files (don't delete) + #[arg(long)] + keep: bool, + }, } fn main() -> Result<()> { @@ -57,8 +83,12 @@ fn main() -> Result<()> { Commands::Status => { commands::status::run(&cli.root, &cli.migrations)?; } - Commands::Up { dry_run } => { - commands::up::run(&cli.root, &cli.migrations, dry_run)?; + Commands::Up { + dry_run, + baseline, + keep, + } => { + commands::up::run(&cli.root, &cli.migrations, dry_run, baseline, keep)?; } Commands::Create { name, @@ -75,6 +105,21 @@ fn main() -> Result<()> { list_templates, )?; } + Commands::Baseline { + version, + summary, + dry_run, + keep, + } => { + commands::baseline::run( + &cli.root, + &cli.migrations, + &version, + summary.as_deref(), + dry_run, + keep, + )?; + } } Ok(()) diff --git a/src/state.rs b/src/state.rs index f1a0860..e1ee5a9 100644 --- a/src/state.rs +++ b/src/state.rs @@ -4,6 +4,7 @@ use std::fs::{self, OpenOptions}; use std::io::{BufRead, BufReader, Write}; use std::path::Path; +use crate::baseline::Baseline; use crate::{AppliedMigration, Migration}; const HISTORY_FILE: &str = ".history"; @@ -64,16 +65,30 @@ pub fn append_history(migrations_dir: &Path, id: &str, applied_at: DateTime } /// Get pending migrations (available but not yet applied). +/// If a baseline is provided, skip migrations at or before the baseline version. pub fn get_pending<'a>( available: &'a [Migration], applied: &[AppliedMigration], + baseline: Option<&Baseline>, ) -> Vec<&'a Migration> { let applied_ids: std::collections::HashSet<&str> = applied.iter().map(|a| a.id.as_str()).collect(); available .iter() - .filter(|m| !applied_ids.contains(m.id.as_str())) + .filter(|m| { + // Already applied + if applied_ids.contains(m.id.as_str()) { + return false; + } + // Covered by baseline (only skip if not in history) + if let Some(b) = baseline { + if m.version.as_str() <= b.version.as_str() { + return false; + } + } + true + }) .collect() } @@ -129,12 +144,45 @@ mod tests { applied_at: Utc::now(), }]; - let pending = get_pending(&available, &applied); + let pending = get_pending(&available, &applied, None); assert_eq!(pending.len(), 2); assert_eq!(pending[0].id, "1f710-second"); assert_eq!(pending[1].id, "1f720-third"); } + #[test] + fn test_get_pending_with_baseline() { + let available = vec![ + Migration { + id: "1f700-first".to_string(), + version: "1f700".to_string(), + file_path: "1f700-first.sh".into(), + }, + Migration { + id: "1f710-second".to_string(), + version: "1f710".to_string(), + file_path: "1f710-second.sh".into(), + }, + Migration { + id: "1f720-third".to_string(), + version: "1f720".to_string(), + file_path: "1f720-third.sh".into(), + }, + ]; + + // No applied migrations, but baseline at 1f710 + let applied: Vec = vec![]; + let baseline = Baseline { + version: "1f710".to_string(), + created: Utc::now(), + summary: None, + }; + + let pending = get_pending(&available, &applied, Some(&baseline)); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].id, "1f720-third"); + } + #[test] fn test_get_current_version() { let available = vec![ From d329a792c27b981950ba5446ba69343a09c4f198 Mon Sep 17 00:00:00 2001 From: Ryan Daigle Date: Mon, 26 Jan 2026 14:21:54 -0500 Subject: [PATCH 2/2] Restructure README with migration lifecycle guide Reorganize documentation to follow the natural migration workflow: creating, writing, applying, checking status, and baselining. Add practical guidance on when to use each feature and what happens under the hood. Co-Authored-By: Claude Opus 4.5 --- README.md | 172 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 101 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 8b76844..fae5c3c 100644 --- a/README.md +++ b/README.md @@ -41,56 +41,47 @@ cargo install migrate cargo install --git https://github.com/glideapps/migrate ``` -## Usage - -### Check migration status +## Quick Start ```bash +# Check what migrations exist and their status migrate status -``` - -### Apply pending migrations -```bash +# Apply all pending migrations migrate up -``` - -### Preview changes without applying -```bash +# Preview what would happen without making changes migrate up --dry-run ``` -### Create a new migration - -```bash -# Create a bash migration (default) -migrate create add-prettier +## Migration Lifecycle -# Create a TypeScript migration -migrate create add-config --template ts +### 1. Creating Migrations -# Create with description -migrate create add-prettier -d "Add Prettier configuration" +Create a new migration with `migrate create`: -# List available templates -migrate create --list-templates +```bash +migrate create add-prettier # Bash script (default) +migrate create setup-config --template ts # TypeScript +migrate create init-db --template python # Python ``` -## Writing Migrations +This generates a timestamped file like `1fb2g-add-prettier.sh` in your `migrations/` directory. The 5-character prefix ensures migrations run in chronological order. + +**Available templates:** `bash`, `ts`, `python`, `node`, `ruby` -Migration files use the format `XXXXX-name.{sh,ts,py,...}` where `XXXXX` is a 5-character base36 version automatically generated by `migrate create`. The version encodes the creation timestamp, ensuring migrations sort chronologically. +### 2. Writing Migrations Migrations are executable files that receive context via environment variables: -```bash -MIGRATE_PROJECT_ROOT=/path/to/project # Absolute path to project root -MIGRATE_MIGRATIONS_DIR=/path/to/migrations # Where migration files live -MIGRATE_ID=1fb2g-initial-setup # Current migration ID (includes version) -MIGRATE_DRY_RUN=true|false # Whether this is a dry run -``` +| Variable | Description | +|----------|-------------| +| `MIGRATE_PROJECT_ROOT` | Absolute path to project root | +| `MIGRATE_MIGRATIONS_DIR` | Where migration files live | +| `MIGRATE_ID` | Current migration ID (e.g., `1fb2g-add-prettier`) | +| `MIGRATE_DRY_RUN` | `true` if running in preview mode | -### Bash example +**Bash example:** ```bash #!/usr/bin/env bash @@ -109,7 +100,7 @@ cat > tsconfig.json << 'EOF' EOF ``` -### TypeScript example +**TypeScript example:** ```typescript #!/usr/bin/env -S npx tsx @@ -120,65 +111,104 @@ import * as path from 'path'; const projectRoot = process.env.MIGRATE_PROJECT_ROOT!; -const config = { - version: 1, - features: ['auth', 'api'] -}; - await fs.writeFile( path.join(projectRoot, 'config.json'), - JSON.stringify(config, null, 2) + JSON.stringify({ version: 1 }, null, 2) ); ``` -Migrations run in order by their version prefix (e.g., `1fb2g-`) and are tracked in a `.history` file. +### 3. Applying Migrations + +Run `migrate up` to apply all pending migrations in order. Each successful migration is recorded in `.history`, so it won't run again. + +```bash +migrate up # Apply all pending +migrate up --dry-run # Preview without applying +``` + +If a migration fails, execution stops immediately. Fix the issue and re-run `migrate up`—already-applied migrations are skipped. + +### 4. Checking Status + +Use `migrate status` to see what's been applied and what's pending: + +``` +Version: 1fb2g → 1fc3h (2 pending) + +Applied (3): + ✓ 1fa1f-init-project + ✓ 1fa2g-add-typescript + ✓ 1fb2g-setup-eslint -## CLI Reference +Pending (2): + • 1fc2h-add-prettier + • 1fc3h-configure-ci +``` + +### 5. Baselining (Cleaning Up Old Migrations) -| Command | Description | -| --------------------------- | ----------------------------------- | -| `migrate status` | Show applied and pending migrations | -| `migrate up` | Apply all pending migrations | -| `migrate create ` | Create a new migration file | -| `migrate baseline `| Mark a version as the baseline | +Over time, your `migrations/` directory accumulates files. Once migrations have been applied everywhere (all environments, all team members), you can **baseline** to clean up. -### Options +Baselining marks a version as the "starting point"—migrations at or before that version are considered complete and can be deleted. -| Option | Description | Default | -| -------------------------- | ------------------------------------------------ | ------------ | -| `-r, --root ` | Project root directory | `.` | -| `-m, --migrations ` | Migrations directory | `migrations` | -| `--dry-run` | Preview changes (up, baseline) | `false` | -| `--baseline` | Create baseline after applying (up only) | `false` | -| `--keep` | Keep migration files when baselining (up, baseline) | `false` | -| `-t, --template ` | Template to use (create only) | `bash` | -| `-d, --description ` | Migration description (create only) | - | -| `-s, --summary ` | Baseline summary (baseline only) | - | -| `--list-templates` | List available templates (create only) | - | +```bash +# Mark version 1fb2g as baseline and delete old migration files +migrate baseline 1fb2g -## Available Templates +# Preview what would be deleted without making changes +migrate baseline 1fb2g --dry-run -- `bash` - Shell script (`.sh`) -- `ts` - TypeScript via tsx (`.ts`) -- `python` - Python 3 (`.py`) -- `node` - Node.js (`.js`) -- `ruby` - Ruby (`.rb`) +# Create baseline but keep the files (just update .baseline) +migrate baseline 1fb2g --keep +``` + +You can also baseline immediately after applying migrations: + +```bash +migrate up --baseline # Apply and baseline at final version +migrate up --baseline --keep # Apply and baseline without deleting files +``` + +**When to baseline:** +- All environments have applied the migrations +- All team members have pulled and applied +- You want to reduce clutter in the migrations directory + +**What baselining does:** +- Creates/updates `.baseline` file with the baseline version +- Optionally deletes migration files at or before that version +- Future `migrate up` skips migrations covered by the baseline + +## Directory Structure + +``` +your-project/ +├── migrations/ +│ ├── .history # Tracks applied migrations (auto-generated) +│ ├── .baseline # Baseline marker (optional, from baselining) +│ ├── 1fc2h-add-prettier.sh +│ └── 1fc3h-configure-ci.ts +└── ... +``` + +## Global Options + +These options work with all commands: + +| Option | Description | Default | +|--------|-------------|---------| +| `-r, --root ` | Project root directory | `.` | +| `-m, --migrations ` | Migrations directory | `migrations` | ## Development ```bash -# Clone and setup -git clone +git clone https://github.com/glideapps/migrate cd migrate ./scripts/setup # Enable git hooks, fetch deps, build, test -# Common commands cargo build # Build debug binary cargo nextest run # Run tests cargo fmt # Format code cargo clippy # Lint -cargo run -- status # Run CLI locally - -# Build release -cargo build --release ```