Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
131 changes: 120 additions & 11 deletions src/baseline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>> {
) -> Result<Vec<DeletedItem>> {
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,
});
}
}
}
}

Expand Down Expand Up @@ -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());
}
}
41 changes: 32 additions & 9 deletions src/commands/baseline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -54,16 +54,32 @@ pub fn run(
println!();

if !to_delete.is_empty() && !keep {
println!(
"{} migration file(s) to delete:",
if dry_run { "Would delete" } else { "Deleting" }
);
println!("{}:", 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!();
}

Expand All @@ -81,10 +97,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!();
Expand Down
30 changes: 26 additions & 4 deletions src/commands/up.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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 {
Expand All @@ -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());
}
}
}
Expand Down