merge: multiple checkouts

This commit is contained in:
2026-09-21 15:31:30 +02:00
9 changed files with 334 additions and 46 deletions

View File

@@ -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 # `<code> <path>`, 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/<host>/<owner>/<repo>/`, derived
from the Git `origin` remote, or `_local/<checkout>` 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/<host>/<owner>/<repo>/<dir>-<hash>/`.
The repository part is derived from the Git `origin` remote, or
`_local/<checkout>` 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 <path>` 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
`<repo>/` 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
```

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

@@ -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<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) {
@@ -179,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);
@@ -203,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)?);
}
}
@@ -211,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)?;
@@ -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(())
}

View File

@@ -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<Option<Exposed>> {
}
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,

View File

@@ -109,7 +109,24 @@ fn git_root(ctx: &Ctx) -> Result<PathBuf> {
)))?)
}
// 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<PathBuf> {
Ok(project_components(ctx, root)?.join(checkout_name(root)))
}
// `<dir>-<hash>`: 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<PathBuf> {
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<std::fs::Metada
}
}
// where a link into the store points, if it is one: absolute, under the base.
// the directory need not be this checkout's own, since a moved checkout or an
// older layout still holds the file
pub(super) fn held(repo: &Repo, link: &Path, dest: &Path) -> Option<PathBuf> {
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

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

View File

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

View File

@@ -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");
}
// `<dir>-<hash>` 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<S: AsRef<OsStr>>(&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)
}