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
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)]

View File

@@ -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<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 entries = match read_dir(dir) {
@@ -180,11 +186,11 @@ fn stored_paths(repo: &Repo, dir: &Path) -> Result<Vec<(PathBuf, Stored)>> {
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<Vec<(PathBuf, Stored)>> {
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<Vec<(PathBuf, Stored)>> {
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
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 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(())
}

View File

@@ -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");

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::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)),