diff --git a/README.md b/README.md index 18098ac..0dbcf00 100644 --- a/README.md +++ b/README.md @@ -89,17 +89,19 @@ ahab link list # what the store holds for this repository ahab link check # what a sandbox can still read ahab link check --porcelain # ` `, for scripts ahab link check --exit-code # exit 4 when anything is outside the store +ahab link migrate # move out of the layout ahab 0.5 used ``` Restoring is the inverse of adding: the file comes back to where it was and the -store keeps nothing. A second checkout linking the same path is left with a -dangling symlink, there being only ever one stored copy. +store keeps nothing. Every checkout has a store directory of its own, so a +second clone of the same repository links and restores its files without +touching the first one's. Every stored path is in one of three states: ``` $ ahab link list -store: /home/you/.local/share/ahab/git.aflabs.org/urnik/afurnik +store: /home/you/.local/share/ahab/git.aflabs.org/urnik/afurnik/afurnik-5f1c8e3a linked: .env shadowed: config.local.py missing: secrets/token @@ -124,11 +126,26 @@ entries end with a NUL, and the two paths of a symlink leading elsewhere are separated by one as well, the way `git status -z` reports a rename. The store lives under -`${XDG_DATA_HOME:-$HOME/.local/share}/ahab////`, derived -from the Git `origin` remote, or `_local/` when there is no remote to -name it after. Its directories are created `0700`. A remote or checkout name -that needed characters replacing carries a short fingerprint of the original, -so two of them cannot share a directory. +`${XDG_DATA_HOME:-$HOME/.local/share}/ahab////-/`. +The repository part is derived from the Git `origin` remote, or +`_local/` when there is no remote to name it after; the last part is +the checkout's directory name and a short hash of its full path, so two clones +of one repository never share a directory. Its directories are created `0700`. +A remote or checkout name that needed characters replacing carries a short +fingerprint of the original, so two of them cannot share a directory either. + +Moving a checkout changes its hash. The symlinks still resolve, `check` still +counts them as in the store and `restore ` still brings them back, so the +way over is `restore` followed by `add`; `list` and `restore --all` only look +in the new directory. + +### Upgrading from 0.5 + +Before 0.6 the store had no per-checkout part, so existing links point at +`/` directly. They keep working. `list` and `status` report them as +`legacy`, and `ahab link migrate` moves them into the checkout's own directory +and repoints the links. Only what the current checkout links is moved, so +another clone's files in the same directory are left where they are. ## What ahab makes of your project @@ -145,7 +162,7 @@ postgres: db (postgres:18-alpine) container: 189d4d395d62 user: myproject database: myproject_db -store: /home/you/.local/share/ahab/git.aflabs.org/urnik/afurnik +store: /home/you/.local/share/ahab/git.aflabs.org/urnik/afurnik/afurnik-5f1c8e3a linked: 2 `ahab link check` lists what a sandbox can still read ``` 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 2e20f49..fe4d402 100644 --- a/src/commands/link.rs +++ b/src/commands/link.rs @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow, bail}; -use self::store::{Leads, Repo, ignored, leads, resolve, symlink_metadata_opt, tracked}; +use self::store::{Leads, Repo, held, ignored, leads, resolve, symlink_metadata_opt, tracked}; use crate::cli::link as cli; use crate::ctx::Ctx; use crate::fsops::suffixed; @@ -104,7 +104,6 @@ pub fn restore(ctx: &Ctx, args: &cli::Restore) -> Result<()> { fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<()> { let src = resolve(path)?; let rel = repo.relative(&src)?; - let stored = repo.store.join(&rel); let Some(meta) = symlink_metadata_opt(&src)? else { bail!("{} does not exist", rel.display()); @@ -116,14 +115,16 @@ fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<( ); } + // followed to wherever under the base it points, so a checkout that moved + // or a store laid out by an older ahab can still take its files back let dest = read_link(&src)?; - if dest != stored { + let Some(stored) = held(repo, &src, &dest) else { bail!( - "{} points at {}, which is not where the store keeps it", + "{} points at {}, which is not in the store", rel.display(), dest.display() ); - } + }; if symlink_metadata_opt(&stored)?.is_none() { bail!("{} is missing from the store", rel.display()); } @@ -169,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) { @@ -179,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); @@ -203,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)?); } } @@ -211,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)?; @@ -221,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(()); } @@ -247,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/link/check.rs b/src/commands/link/check.rs index b0f36f9..2e8b58f 100644 --- a/src/commands/link/check.rs +++ b/src/commands/link/check.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; -use super::store::{Leads, Repo, leads, resolve, symlink_metadata_opt}; +use super::store::{Leads, Repo, held, leads, resolve, symlink_metadata_opt}; use crate::cli::link as cli; use crate::cmd::{Cmd, LsFiles}; use crate::ctx::Ctx; @@ -179,10 +179,10 @@ fn classify(repo: &Repo, rel: &Path, mark: char) -> Result> { } let dest = read_link(&src)?; - if dest == repo.store.join(rel) { + if held(repo, &src, &dest).is_some() { // it names the store, but what the store holds there can be a symlink of // its own leading straight back out, which is not being held at all - return Ok(match leads(&src, &repo.store) { + return Ok(match leads(&src, &repo.base) { Leads::Inside | Leads::Dangling => None, Leads::Outside(end) => Some(Exposed { mark, diff --git a/src/commands/link/store.rs b/src/commands/link/store.rs index 7be74c8..ff6007e 100644 --- a/src/commands/link/store.rs +++ b/src/commands/link/store.rs @@ -109,7 +109,24 @@ fn git_root(ctx: &Ctx) -> Result { )))?) } +// the remote names the project and the checkout names its clone, so two clones +// never share a directory fn repo_components(ctx: &Ctx, root: &Path) -> Result { + Ok(project_components(ctx, root)?.join(checkout_name(root))) +} + +// `-`: the directory for a human, the hash of the canonical path +// for two checkouts in directories of one name +fn checkout_name(root: &Path) -> String { + let hash = fingerprint(root.as_os_str().as_bytes()); + + match root.file_name() { + Some(dir) => format!("{}-{hash}", cleaned(&dir.to_string_lossy())), + None => hash, + } +} + +fn project_components(ctx: &Ctx, root: &Path) -> Result { if let Some(url) = git_origin_url(ctx) { if let Some(components) = components_from_remote(&url) { return Ok(components); @@ -272,6 +289,19 @@ pub(super) fn symlink_metadata_opt(path: &Path) -> Result Option { + let dest = match dest.is_absolute() { + true => dest.to_path_buf(), + false => link.parent()?.join(dest), + }; + let dest = normalized(dest).ok()?; + + dest.starts_with(&repo.base).then_some(dest) +} + // where a chain of symlinks actually ends up pub(super) enum Leads { // somewhere under the directory it was supposed to stay in @@ -302,11 +332,13 @@ pub(super) fn leads(path: &Path, root: &Path) -> Leads { #[cfg(test)] mod tests { - use super::{components_from_remote, fingerprint, normalized, sanitize, sanitize_name}; + use super::{ + checkout_name, components_from_remote, fingerprint, normalized, sanitize, sanitize_name, + }; use std::collections::HashSet; use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; - use std::path::PathBuf; + use std::path::{Path, PathBuf}; #[test] fn parses_every_spelling_of_a_remote() { @@ -407,6 +439,17 @@ mod tests { assert_eq!(sanitize_name(OsStr::new("afurnik")), "afurnik"); } + #[test] + fn a_checkout_is_named_by_its_directory_and_its_path() { + let one = checkout_name(Path::new("/home/a/afurnik")); + let two = checkout_name(Path::new("/home/b/afurnik")); + + assert!(one.starts_with("afurnik-"), "{one}"); + assert_ne!(one, two); + // the root itself has no directory to be named after + assert_eq!(checkout_name(Path::new("/")), fingerprint(b"/")); + } + #[test] fn a_store_root_is_absolute_with_the_dots_folded_out() { // it is written into every symlink add creates, and compared against the 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 95c36b2..5059707 100644 --- a/tests/link_store.rs +++ b/tests/link_store.rs @@ -260,3 +260,90 @@ fn distinct_remotes_do_not_share_one_store_directory() { // the ordinary remote keeps the path it already had assert!(plain.contains("/a/my_api"), "{plain}"); } + +#[test] +fn two_checkouts_of_one_remote_keep_their_own_files() { + let case = Case::new("clones"); + let second = case.second_checkout(); + case.write(".env", b"FIRST\n"); + second.write(".env", b"SECOND\n"); + + case.ahab(&["link", "add", ".env"]).ok(); + // no collision, and no --force needed + second.ahab(&["link", "add", ".env"]).ok(); + + assert_ne!(case.store, second.store); + assert_eq!(fs::read(case.path(".env")).unwrap(), b"FIRST\n"); + assert_eq!(fs::read(second.path(".env")).unwrap(), b"SECOND\n"); + + // one restoring leaves the other linked + case.ahab(&["link", "restore", "--all"]).ok(); + assert!(second.is_symlink(".env")); + assert_eq!(fs::read(second.path(".env")).unwrap(), b"SECOND\n"); + second.ahab(&["link", "list"]).ok().says("linked: .env"); +} + +#[test] +fn a_link_into_an_older_store_layout_still_checks_clean_and_restores() { + let case = Case::new("legacy"); + // the flat layout, before the store was keyed by checkout + let old = case.store.parent().unwrap().join(".env"); + fs::create_dir_all(old.parent().unwrap()).unwrap(); + fs::write(&old, b"SECRET=1\n").unwrap(); + case.link(&case.path(".env"), &old); + case.gitignore(".env\n"); + + case.ahab(&["link", "check", "--exit-code"]).ok(); + case.ahab(&["link", "restore", ".env"]).ok(); + + assert!(!case.is_symlink(".env")); + assert_eq!(fs::read(case.path(".env")).unwrap(), b"SECRET=1\n"); + assert!(!old.exists()); + + // a link that leaves the store altogether is still refused + case.link(&case.path("key"), &case.outside.join("key")); + case.ahab(&["link", "restore", "key"]) + .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"); +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 5a66cea..40b2100 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -7,6 +7,7 @@ #![allow(dead_code)] use std::ffi::OsStr; use std::fmt::Write as _; +use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use std::sync::atomic::{AtomicU32, Ordering}; @@ -15,8 +16,12 @@ use std::{env, fs}; // unique per case even with the suite running in parallel static NEXT: AtomicU32 = AtomicU32::new(0); +pub const REMOTE: &str = "https://git.example.org/acme/proj.git"; + pub struct Case { root: PathBuf, + // the data home every checkout of a case shares, as one user's would + xdg: PathBuf, pub repo: PathBuf, pub store: PathBuf, pub outside: PathBuf, @@ -32,29 +37,41 @@ impl Case { )); let _ = fs::remove_dir_all(&root); - let case = Self { - repo: root.join("repo"), - store: root - .join("xdg/ahab/git.example.org/acme/proj") - .to_path_buf(), - outside: root.join("outside"), - root, - }; + Self::checkout(root.clone(), root.join("xdg")) + } - for dir in [&case.repo, &case.outside] { + // a second clone of the same remote, sharing the first one's data home + pub fn second_checkout(&self) -> Self { + Self::checkout(self.root.join("second"), self.xdg.clone()) + } + + fn checkout(root: PathBuf, xdg: PathBuf) -> Self { + let repo = root.join("repo"); + let outside = root.join("outside"); + for dir in [&repo, &outside] { fs::create_dir_all(dir).expect("creating the case directories"); } + // `-` of the canonical checkout, as ahab spells it + let canonical = fs::canonicalize(&repo).expect("resolving the repository"); + let store = xdg.join("ahab/git.example.org/acme/proj").join(format!( + "repo-{}", + fingerprint(canonical.as_os_str().as_bytes()) + )); + + let case = Self { + root, + xdg, + repo, + store, + outside, + }; + case.git(&["init", "-q", "."]); case.git(&["config", "user.email", "test@example.org"]); case.git(&["config", "user.name", "test"]); case.git(&["config", "commit.gpgsign", "false"]); - case.git(&[ - "remote", - "add", - "origin", - "https://git.example.org/acme/proj.git", - ]); + case.git(&["remote", "add", "origin", REMOTE]); case.write("tracked.txt", b"tracked\n"); case.git(&["add", "tracked.txt"]); case.git(&["commit", "-qm", "init"]); @@ -84,7 +101,7 @@ impl Case { pub fn ahab>(&self, args: &[S]) -> Run { let out = Command::new(env!("CARGO_BIN_EXE_ahab")) .current_dir(&self.repo) - .env("XDG_DATA_HOME", self.root.join("xdg")) + .env("XDG_DATA_HOME", &self.xdg) // the git commands ahab runs must not read the developer's own config .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_CONFIG_SYSTEM", "/dev/null") @@ -205,3 +222,15 @@ impl Run { out } } + +// fnv-1a, as the store spells a checkout +pub fn fingerprint(bytes: &[u8]) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100_0000_01b3); + } + + format!("{:08x}", hash as u32) +}