From c96211a28b3e5f371cafdae56eb2e96ea75a1e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Mon, 21 Sep 2026 13:02:26 +0000 Subject: [PATCH] feat: one store directory per checkout --- src/commands/link/store.rs | 34 +++++++++++++++++++-- tests/link_store.rs | 22 ++++++++++++++ tests/support/mod.rs | 61 ++++++++++++++++++++++++++++---------- 3 files changed, 99 insertions(+), 18 deletions(-) diff --git a/src/commands/link/store.rs b/src/commands/link/store.rs index 7be74c8..6cbf78a 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); @@ -302,11 +319,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 +426,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/tests/link_store.rs b/tests/link_store.rs index 95c36b2..50e5c6a 100644 --- a/tests/link_store.rs +++ b/tests/link_store.rs @@ -260,3 +260,25 @@ 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"); +} 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) +}