feat: migrate out of the flat store layout

This commit is contained in:
2026-09-21 13:06:54 +00:00
parent 22d735d52c
commit a6a4b033a7
5 changed files with 163 additions and 11 deletions

View File

@@ -19,6 +19,15 @@ pub enum Link {
/// List untracked paths a sandbox would still see /// List untracked paths a sandbox would still see
Check(Check), 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)] #[derive(Args, Debug)]

View File

@@ -170,6 +170,12 @@ enum Stored {
// the store mirrors the repository layout, so walking it finds every path this // the store mirrors the repository layout, so walking it finds every path this
// repository has put there without asking git anything // repository has put there without asking git anything
fn stored_paths(repo: &Repo, dir: &Path) -> Result<Vec<(PathBuf, Stored)>> { fn stored_paths(repo: &Repo, dir: &Path) -> Result<Vec<(PathBuf, Stored)>> {
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<Vec<(PathBuf, Stored)>> {
let mut found = Vec::new(); let mut found = Vec::new();
let entries = match read_dir(dir) { let entries = match read_dir(dir) {
@@ -180,11 +186,11 @@ fn stored_paths(repo: &Repo, dir: &Path) -> Result<Vec<(PathBuf, Stored)>> {
for entry in entries { for entry in entries {
let stored = entry?.path(); let stored = entry?.path();
let rel = stored.strip_prefix(&repo.store).with_context(|| { let rel = stored.strip_prefix(mirror).with_context(|| {
format!( format!(
"{} is not under the store {}", "{} is not under the store {}",
stored.display(), stored.display(),
repo.store.display() mirror.display()
) )
})?; })?;
let src = repo.root.join(rel); let src = repo.root.join(rel);
@@ -204,7 +210,7 @@ fn stored_paths(repo: &Repo, dir: &Path) -> Result<Vec<(PathBuf, Stored)>> {
if state == Stored::Linked || !holds_dir { if state == Stored::Linked || !holds_dir {
found.push((src, state)); found.push((src, state));
} else { } 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<Vec<(PathBuf, Stored)>> {
Ok(found) 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<Vec<PathBuf>> {
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 // 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<Summary> {
let repo = Repo::discover(ctx, store)?; let repo = Repo::discover(ctx, store)?;
let stored = stored_paths(&repo, &repo.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) .filter(|(_, state)| *state == Stored::Linked)
.count(); .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 // list what the store holds for this repository, the inverse of check
pub fn list(ctx: &Ctx, args: &cli::List) -> Result<()> { pub fn list(ctx: &Ctx, args: &cli::List) -> Result<()> {
let repo = Repo::discover(ctx, args.store.root())?; let repo = Repo::discover(ctx, args.store.root())?;
let stored = stored_paths(&repo, &repo.store)?; let stored = stored_paths(&repo, &repo.store)?;
let legacy = legacy_paths(&repo)?;
line!("store: {}", repo.store.display()); line!("store: {}", repo.store.display());
if stored.is_empty() { if stored.is_empty() && legacy.is_empty() {
line!("nothing in the store for this repository"); line!("nothing in the store for this repository");
return Ok(()); return Ok(());
} }
@@ -248,6 +285,67 @@ pub fn list(ctx: &Ctx, args: &cli::List) -> Result<()> {
entry(verb, rel); 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(()) Ok(())
} }

View File

@@ -32,12 +32,15 @@ pub fn status(ctx: &Ctx, store: Option<&Path>) -> Result<()> {
fn stored(ctx: &Ctx, store: Option<&Path>) { fn stored(ctx: &Ctx, store: Option<&Path>) {
match link::stored_summary(ctx, store) { match link::stored_summary(ctx, store) {
Err(e) => line!("store: {e:#}"), Err(e) => line!("store: {e:#}"),
Ok((store, linked, other)) => { Ok(summary) => {
line!("store: {}", store.display()); line!("store: {}", summary.store.display());
line!("\tlinked: {linked}"); line!("\tlinked: {}", summary.linked);
if other > 0 { if summary.other > 0 {
line!("\tnot linked: {other} (see `ahab link list`)"); 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"); line!("\t`ahab link check` lists what a sandbox can still read");

View File

@@ -94,6 +94,7 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result<ExitCode> {
cli::Link::Add(args) => commands::link::add(ctx, &args).map(|()| done), cli::Link::Add(args) => commands::link::add(ctx, &args).map(|()| done),
cli::Link::List(args) => commands::link::list(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::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 // the one command with something to say through its exit code
cli::Link::Check(args) => match commands::link::check(ctx, &args)? && args.exit_code { cli::Link::Check(args) => match commands::link::check(ctx, &args)? && args.exit_code {
true => Ok(ExitCode::from(FINDINGS)), true => Ok(ExitCode::from(FINDINGS)),

View File

@@ -306,3 +306,44 @@ fn a_link_into_an_older_store_layout_still_checks_clean_and_restores() {
.failed() .failed()
.says("not in the store"); .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");
}