feat: migrate out of the flat store layout
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user