From 246615f70fc2f7a5a81b5806e0a53f9482b13b5f Mon Sep 17 00:00:00 2001 From: Ryan Daigle Date: Wed, 4 Feb 2026 10:04:44 -0500 Subject: [PATCH 1/2] Delete migration asset directories during baseline cleanup When baselining, also delete any companion directories named after the migration ID (e.g., 1fb2g-init/ for migration 1fb2g-init.sh). This prevents asset folders from accumulating when old migrations are cleaned up. The output clearly indicates which migrations have associated asset directories being deleted. Co-Authored-By: Claude Haiku 4.5 --- README.md | 16 ++++- src/baseline.rs | 131 +++++++++++++++++++++++++++++++++++---- src/commands/baseline.rs | 44 ++++++++++--- src/commands/up.rs | 30 +++++++-- 4 files changed, 198 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index c9640c0..0fb2142 100644 --- a/README.md +++ b/README.md @@ -176,9 +176,23 @@ migrate up --baseline --keep # Apply and baseline without deleting files **What baselining does:** - Records the baseline version in the `history` file -- Optionally deletes migration files at or before that version +- Deletes migration files at or before that version (unless `--keep`) +- Deletes any associated asset directories (directories named after the migration ID, e.g., `1fb2g-setup-eslint/`) - Future `migrate up` skips migrations covered by the baseline +**Asset directories:** Migrations can have companion directories for assets (templates, configs, data files). Name the directory after the migration ID: + +``` +migrations/ +├── 1fb2g-setup-eslint.sh # Migration script +├── 1fb2g-setup-eslint/ # Asset directory (deleted with migration) +│ ├── .eslintrc.json +│ └── .eslintignore +└── ... +``` + +When `1fb2g` is baselined, both the `.sh` file and the `1fb2g-setup-eslint/` directory are deleted. + ## Directory Structure ``` diff --git a/src/baseline.rs b/src/baseline.rs index 0a7f442..7e13ea4 100644 --- a/src/baseline.rs +++ b/src/baseline.rs @@ -9,23 +9,55 @@ 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. +/// Represents an item deleted during baseline cleanup +#[derive(Debug, Clone)] +pub struct DeletedItem { + pub path: String, + pub is_directory: bool, +} + +/// Delete migration files and associated asset directories at or before the baseline version. +/// Asset directories are identified by having the same prefix as the migration ID (e.g., "1f700-init/"). +/// Returns the list of deleted items (files and directories). pub fn delete_baselined_migrations( baseline_version: &str, available: &[Migration], -) -> Result> { +) -> 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()); + if version_lte(&migration.version, baseline_version) { + // Delete the migration file + if migration.file_path.exists() { + fs::remove_file(&migration.file_path).with_context(|| { + format!( + "Failed to delete migration file: {}", + migration.file_path.display() + ) + })?; + deleted.push(DeletedItem { + path: migration.file_path.display().to_string(), + is_directory: false, + }); + } + + // Delete associated asset directory if it exists + // The directory shares the migration ID as its name (e.g., "1f700-init/") + if let Some(parent) = migration.file_path.parent() { + let asset_dir = parent.join(&migration.id); + if asset_dir.exists() && asset_dir.is_dir() { + fs::remove_dir_all(&asset_dir).with_context(|| { + format!( + "Failed to delete migration asset directory: {}", + asset_dir.display() + ) + })?; + deleted.push(DeletedItem { + path: asset_dir.display().to_string(), + is_directory: true, + }); + } + } } } @@ -197,4 +229,81 @@ mod tests { let result = validate_baseline("1f710", &available, &applied, None); assert!(result.is_ok()); } + + #[test] + fn test_delete_baselined_migrations_with_asset_dirs() { + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let migrations_dir = temp_dir.path(); + + // Create migration file + let migration_file = migrations_dir.join("1f700-first.sh"); + fs::write(&migration_file, "#!/bin/bash\necho hello").unwrap(); + + // Create asset directory with files + let asset_dir = migrations_dir.join("1f700-first"); + fs::create_dir(&asset_dir).unwrap(); + fs::write(asset_dir.join("config.json"), "{}").unwrap(); + fs::write(asset_dir.join("template.txt"), "template").unwrap(); + + // Create a second migration without asset dir + let migration_file2 = migrations_dir.join("1f710-second.sh"); + fs::write(&migration_file2, "#!/bin/bash\necho world").unwrap(); + + let available = vec![ + Migration { + id: "1f700-first".to_string(), + version: "1f700".to_string(), + file_path: migration_file.clone(), + }, + Migration { + id: "1f710-second".to_string(), + version: "1f710".to_string(), + file_path: migration_file2.clone(), + }, + ]; + + // Delete migrations at or before 1f710 + let deleted = delete_baselined_migrations("1f710", &available).unwrap(); + + // Should delete both files and the asset directory + assert_eq!(deleted.len(), 3); // 2 files + 1 directory + + let files: Vec<_> = deleted.iter().filter(|d| !d.is_directory).collect(); + let dirs: Vec<_> = deleted.iter().filter(|d| d.is_directory).collect(); + + assert_eq!(files.len(), 2); + assert_eq!(dirs.len(), 1); + + // Verify files are gone + assert!(!migration_file.exists()); + assert!(!migration_file2.exists()); + assert!(!asset_dir.exists()); + } + + #[test] + fn test_delete_baselined_migrations_no_asset_dir() { + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let migrations_dir = temp_dir.path(); + + // Create migration file without asset directory + let migration_file = migrations_dir.join("1f700-first.sh"); + fs::write(&migration_file, "#!/bin/bash\necho hello").unwrap(); + + let available = vec![Migration { + id: "1f700-first".to_string(), + version: "1f700".to_string(), + file_path: migration_file.clone(), + }]; + + let deleted = delete_baselined_migrations("1f700", &available).unwrap(); + + // Should only delete the file + assert_eq!(deleted.len(), 1); + assert!(!deleted[0].is_directory); + assert!(!migration_file.exists()); + } } diff --git a/src/commands/baseline.rs b/src/commands/baseline.rs index bb5ebb4..1793c6a 100644 --- a/src/commands/baseline.rs +++ b/src/commands/baseline.rs @@ -2,7 +2,7 @@ use anyhow::Result; use chrono::Utc; use std::path::Path; -use crate::baseline::{delete_baselined_migrations, validate_baseline}; +use crate::baseline::{delete_baselined_migrations, validate_baseline, DeletedItem}; use crate::loader::discover_migrations; use crate::state::{append_baseline, read_history, Baseline}; @@ -55,15 +55,38 @@ pub fn run( if !to_delete.is_empty() && !keep { println!( - "{} migration file(s) to delete:", - if dry_run { "Would delete" } else { "Deleting" } + "{}:", + if dry_run { + "Would delete" + } else { + "Deleting" + } ); for migration in &to_delete { - println!(" - {}", migration.id); + let asset_dir_exists = migration + .file_path + .parent() + .map(|p| p.join(&migration.id).is_dir()) + .unwrap_or(false); + if asset_dir_exists { + println!(" - {} (file + {}/)", migration.id, migration.id); + } else { + println!(" - {}", migration.id); + } } println!(); } else if keep { - println!("Keeping migration files (--keep flag)"); + let has_any_asset_dir = to_delete.iter().any(|m| { + m.file_path + .parent() + .map(|p| p.join(&m.id).is_dir()) + .unwrap_or(false) + }); + if has_any_asset_dir { + println!("Keeping migration files and asset directories (--keep flag)"); + } else { + println!("Keeping migration files (--keep flag)"); + } println!(); } @@ -81,10 +104,17 @@ pub fn run( append_baseline(&migrations_path, &baseline)?; println!("Added baseline to history file"); - // Delete old migration files unless --keep was specified + // Delete old migration files and asset directories unless --keep was specified if !keep && !to_delete.is_empty() { let deleted = delete_baselined_migrations(version, &available)?; - println!("Deleted {} migration file(s)", deleted.len()); + let (files, dirs): (Vec<&DeletedItem>, Vec<&DeletedItem>) = + deleted.iter().partition(|d| !d.is_directory); + if !files.is_empty() { + println!("Deleted {} migration file(s)", files.len()); + } + if !dirs.is_empty() { + println!("Deleted {} asset directory(ies)", dirs.len()); + } } println!(); diff --git a/src/commands/up.rs b/src/commands/up.rs index 72929e3..465fade 100644 --- a/src/commands/up.rs +++ b/src/commands/up.rs @@ -2,7 +2,7 @@ use anyhow::Result; use chrono::Utc; use std::path::Path; -use crate::baseline::delete_baselined_migrations; +use crate::baseline::{delete_baselined_migrations, DeletedItem}; use crate::executor::execute; use crate::loader::discover_migrations; use crate::state::{append_baseline, append_history, get_pending, read_history, Baseline}; @@ -105,7 +105,24 @@ pub fn run( .filter(|m| m.version.as_str() <= version.as_str()) .collect(); if !to_delete.is_empty() { - println!("Would delete {} migration file(s)", to_delete.len()); + let asset_dir_count = to_delete + .iter() + .filter(|m| { + m.file_path + .parent() + .map(|p| p.join(&m.id).is_dir()) + .unwrap_or(false) + }) + .count(); + if asset_dir_count > 0 { + println!( + "Would delete {} migration file(s) and {} asset directory(ies)", + to_delete.len(), + asset_dir_count + ); + } else { + println!("Would delete {} migration file(s)", to_delete.len()); + } } } } else { @@ -120,8 +137,13 @@ pub fn run( if !keep { let deleted = delete_baselined_migrations(&version, &available)?; - if !deleted.is_empty() { - println!("Deleted {} migration file(s)", deleted.len()); + let (files, dirs): (Vec<&DeletedItem>, Vec<&DeletedItem>) = + deleted.iter().partition(|d| !d.is_directory); + if !files.is_empty() { + println!("Deleted {} migration file(s)", files.len()); + } + if !dirs.is_empty() { + println!("Deleted {} asset directory(ies)", dirs.len()); } } } From b97e509c4fd750fc2b6a210bd36c4303ab89a020 Mon Sep 17 00:00:00 2001 From: Ryan Daigle Date: Wed, 4 Feb 2026 10:06:03 -0500 Subject: [PATCH 2/2] Bump version to 0.5.0 and fix formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Version bump: 0.4.1 → 0.5.0 (minor for new asset directory deletion feature) - Fix cargo fmt issue in baseline.rs Co-Authored-By: Claude Opus 4.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/commands/baseline.rs | 9 +-------- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index de4e64c..ae13f32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,7 +285,7 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "migrate" -version = "0.4.1" +version = "0.5.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index fbe13a9..4d25a03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "migrate" -version = "0.4.1" +version = "0.5.0" edition = "2021" description = "Generic file migration tool for applying ordered transformations to a project directory" license = "MIT" diff --git a/src/commands/baseline.rs b/src/commands/baseline.rs index 1793c6a..3fd6d4a 100644 --- a/src/commands/baseline.rs +++ b/src/commands/baseline.rs @@ -54,14 +54,7 @@ pub fn run( println!(); if !to_delete.is_empty() && !keep { - println!( - "{}:", - if dry_run { - "Would delete" - } else { - "Deleting" - } - ); + println!("{}:", if dry_run { "Would delete" } else { "Deleting" }); for migration in &to_delete { let asset_dir_exists = migration .file_path