From a6a4b033a7f102077151ba5b2fbd18759f061ead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Mon, 21 Sep 2026 13:06:54 +0000 Subject: [PATCH] feat: migrate out of the flat store layout --- src/cli/link.rs | 9 ++++ src/commands/link.rs | 110 ++++++++++++++++++++++++++++++++++++++--- src/commands/status.rs | 13 +++-- src/main.rs | 1 + tests/link_store.rs | 41 +++++++++++++++ 5 files changed, 163 insertions(+), 11 deletions(-) diff --git a/src/cli/link.rs b/src/cli/link.rs index 853ff06..5a3d48a 100644 --- a/src/cli/link.rs +++ b/src/cli/link.rs @@ -19,6 +19,15 @@ pub enum Link { /// List untracked paths a sandbox would still see Check(Check), + + /// Move this checkout's paths from the old store layout into its own directory + Migrate(Migrate), +} + +#[derive(Args, Debug)] +pub struct Migrate { + #[command(flatten)] + pub store: Store, } #[derive(Args, Debug)] diff --git a/src/commands/link.rs b/src/commands/link.rs index a20a518..fe4d402 100644 --- a/src/commands/link.rs +++ b/src/commands/link.rs @@ -170,6 +170,12 @@ enum Stored { // the store mirrors the repository layout, so walking it finds every path this // repository has put there without asking git anything fn stored_paths(repo: &Repo, dir: &Path) -> Result> { + mirrored_paths(repo, &repo.store, dir) +} + +// the paths under `dir` as the repository would link them, with `mirror` as +// the directory that stands for the repository root +fn mirrored_paths(repo: &Repo, mirror: &Path, dir: &Path) -> Result> { let mut found = Vec::new(); let entries = match read_dir(dir) { @@ -180,11 +186,11 @@ fn stored_paths(repo: &Repo, dir: &Path) -> Result> { for entry in entries { let stored = entry?.path(); - let rel = stored.strip_prefix(&repo.store).with_context(|| { + let rel = stored.strip_prefix(mirror).with_context(|| { format!( "{} is not under the store {}", stored.display(), - repo.store.display() + mirror.display() ) })?; let src = repo.root.join(rel); @@ -204,7 +210,7 @@ fn stored_paths(repo: &Repo, dir: &Path) -> Result> { if state == Stored::Linked || !holds_dir { found.push((src, state)); } else { - found.extend(stored_paths(repo, &stored)?); + found.extend(mirrored_paths(repo, mirror, &stored)?); } } @@ -212,8 +218,29 @@ fn stored_paths(repo: &Repo, dir: &Path) -> Result> { Ok(found) } +// what this repository links into the flat layout above its own directory, +// from before the store was keyed by checkout +fn legacy_paths(repo: &Repo) -> Result> { + let Some(legacy) = repo.store.parent() else { + return Ok(Vec::new()); + }; + + Ok(mirrored_paths(repo, legacy, legacy)? + .into_iter() + .filter(|(_, state)| *state == Stored::Linked) + .map(|(src, _)| src) + .collect()) +} + +pub struct Summary { + pub store: PathBuf, + pub linked: usize, + pub other: usize, + pub legacy: usize, +} + // what the store holds, without the git questions check asks -pub fn stored_summary(ctx: &Ctx, store: Option<&Path>) -> Result<(PathBuf, usize, usize)> { +pub fn stored_summary(ctx: &Ctx, store: Option<&Path>) -> Result { let repo = Repo::discover(ctx, store)?; let stored = stored_paths(&repo, &repo.store)?; @@ -222,17 +249,27 @@ pub fn stored_summary(ctx: &Ctx, store: Option<&Path>) -> Result<(PathBuf, usize .filter(|(_, state)| *state == Stored::Linked) .count(); - Ok((repo.store, linked, stored.len() - linked)) + let legacy = legacy_paths(&repo)?.len(); + + Ok(Summary { + store: repo.store, + linked, + other: stored.len() - linked, + legacy, + }) } +pub const MIGRATE_HINT: &str = "run `ahab link migrate` to move them into it"; + // list what the store holds for this repository, the inverse of check pub fn list(ctx: &Ctx, args: &cli::List) -> Result<()> { let repo = Repo::discover(ctx, args.store.root())?; let stored = stored_paths(&repo, &repo.store)?; + let legacy = legacy_paths(&repo)?; line!("store: {}", repo.store.display()); - if stored.is_empty() { + if stored.is_empty() && legacy.is_empty() { line!("nothing in the store for this repository"); return Ok(()); } @@ -248,6 +285,67 @@ pub fn list(ctx: &Ctx, args: &cli::List) -> Result<()> { entry(verb, rel); } + for path in &legacy { + entry("legacy", path.strip_prefix(&repo.root).unwrap_or(path)); + } + if !legacy.is_empty() { + line!( + "{} path{} in the old layout, {MIGRATE_HINT}", + legacy.len(), + plural(legacy.len()) + ); + } + + Ok(()) +} + +// move this checkout's paths from the old layout into its own directory +pub fn migrate(ctx: &Ctx, args: &cli::Migrate) -> Result<()> { + let repo = Repo::discover(ctx, args.store.root())?; + let report = Report::new(&repo); + let legacy = legacy_paths(&repo)?; + + if legacy.is_empty() { + note!(ctx, "nothing in the old layout for this repository"); + return Ok(()); + } + + each(&legacy, |src| migrate_one(ctx, &repo, src, &report)) +} + +fn migrate_one(ctx: &Ctx, repo: &Repo, src: &Path, report: &Report) -> Result<()> { + let rel = repo.relative(src)?; + let old = read_link(src)?; + let target = repo.store.join(&rel); + + if symlink_metadata_opt(&target)?.is_some() { + bail!( + "{} already exists; restore {} and add it again instead", + target.display(), + rel.display() + ); + } + + stays_in_store(repo, &rel)?; + ctx.fs().ensure_private_parent(&repo.base, &target)?; + ctx.fs().move_path(&old, &target)?; + + // the link still names the old place until it is repointed, so a failure + // here puts the payload back where it points + if let Err(e) = ctx.fs().place_link(src, &target) { + ctx.fs().move_path(&target, &old).with_context(|| { + format!( + "could not put {} back after failing to link {} to it", + old.display(), + rel.display() + ) + })?; + + return Err(e); + } + + ctx.fs().prune_empty(old.parent(), &repo.base); + report.line("migrated", &rel); Ok(()) } diff --git a/src/commands/status.rs b/src/commands/status.rs index 992f58d..7da63d4 100644 --- a/src/commands/status.rs +++ b/src/commands/status.rs @@ -32,12 +32,15 @@ pub fn status(ctx: &Ctx, store: Option<&Path>) -> Result<()> { fn stored(ctx: &Ctx, store: Option<&Path>) { match link::stored_summary(ctx, store) { Err(e) => line!("store: {e:#}"), - Ok((store, linked, other)) => { - line!("store: {}", store.display()); - line!("\tlinked: {linked}"); + Ok(summary) => { + line!("store: {}", summary.store.display()); + line!("\tlinked: {}", summary.linked); - if other > 0 { - line!("\tnot linked: {other} (see `ahab link list`)"); + if summary.other > 0 { + line!("\tnot linked: {} (see `ahab link list`)", summary.other); + } + if summary.legacy > 0 { + line!("\tlegacy: {} ({})", summary.legacy, link::MIGRATE_HINT); } line!("\t`ahab link check` lists what a sandbox can still read"); diff --git a/src/main.rs b/src/main.rs index 3fec578..98302af 100644 --- a/src/main.rs +++ b/src/main.rs @@ -94,6 +94,7 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { cli::Link::Add(args) => commands::link::add(ctx, &args).map(|()| done), cli::Link::List(args) => commands::link::list(ctx, &args).map(|()| done), cli::Link::Restore(args) => commands::link::restore(ctx, &args).map(|()| done), + cli::Link::Migrate(args) => commands::link::migrate(ctx, &args).map(|()| done), // the one command with something to say through its exit code cli::Link::Check(args) => match commands::link::check(ctx, &args)? && args.exit_code { true => Ok(ExitCode::from(FINDINGS)), diff --git a/tests/link_store.rs b/tests/link_store.rs index 960daf7..5059707 100644 --- a/tests/link_store.rs +++ b/tests/link_store.rs @@ -306,3 +306,44 @@ fn a_link_into_an_older_store_layout_still_checks_clean_and_restores() { .failed() .says("not in the store"); } + +#[test] +fn migrate_moves_only_this_checkouts_paths_out_of_the_old_layout() { + let case = Case::new("migrate"); + let old = case.store.parent().unwrap().to_path_buf(); + fs::create_dir_all(old.join("secrets")).unwrap(); + fs::write(old.join(".env"), b"SECRET=1\n").unwrap(); + fs::write(old.join("secrets/token"), b"tok\n").unwrap(); + // another checkout's file in the same flat layout, linked from nowhere here + fs::write(old.join("other.env"), b"OTHER\n").unwrap(); + case.link(&case.path(".env"), &old.join(".env")); + case.link(&case.path("secrets"), &old.join("secrets")); + case.gitignore(".env\nsecrets/\n"); + + case.ahab(&["link", "list"]) + .ok() + .says("legacy: .env") + .says("legacy: secrets") + .says("ahab link migrate"); + case.ahab(&["status"]).says("legacy: 2"); + + case.ahab(&["link", "migrate"]).ok().says("migrated: .env"); + + assert_eq!( + fs::read_link(case.path(".env")).unwrap(), + case.store.join(".env") + ); + assert_eq!(fs::read(case.path(".env")).unwrap(), b"SECRET=1\n"); + assert_eq!(fs::read(case.path("secrets/token")).unwrap(), b"tok\n"); + assert!(!old.join(".env").exists()); + assert!(!old.join("secrets").exists()); + assert_eq!(fs::read(old.join("other.env")).unwrap(), b"OTHER\n"); + + case.ahab(&["link", "list"]) + .ok() + .says("linked: .env") + .silent_about("legacy"); + case.ahab(&["link", "migrate"]) + .ok() + .says("nothing in the old layout"); +}