feat: one store directory per checkout

This commit is contained in:
2026-09-21 13:02:26 +00:00
parent 9adcfed28e
commit c96211a28b
3 changed files with 99 additions and 18 deletions

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> { 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(url) = git_origin_url(ctx) {
if let Some(components) = components_from_remote(&url) { if let Some(components) = components_from_remote(&url) {
return Ok(components); return Ok(components);
@@ -302,11 +319,13 @@ pub(super) fn leads(path: &Path, root: &Path) -> Leads {
#[cfg(test)] #[cfg(test)]
mod tests { 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::collections::HashSet;
use std::ffi::OsStr; use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt; use std::os::unix::ffi::OsStrExt;
use std::path::PathBuf; use std::path::{Path, PathBuf};
#[test] #[test]
fn parses_every_spelling_of_a_remote() { fn parses_every_spelling_of_a_remote() {
@@ -407,6 +426,17 @@ mod tests {
assert_eq!(sanitize_name(OsStr::new("afurnik")), "afurnik"); 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] #[test]
fn a_store_root_is_absolute_with_the_dots_folded_out() { fn a_store_root_is_absolute_with_the_dots_folded_out() {
// it is written into every symlink add creates, and compared against the // it is written into every symlink add creates, and compared against the

View File

@@ -260,3 +260,25 @@ fn distinct_remotes_do_not_share_one_store_directory() {
// the ordinary remote keeps the path it already had // the ordinary remote keeps the path it already had
assert!(plain.contains("/a/my_api"), "{plain}"); 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");
}

View File

@@ -7,6 +7,7 @@
#![allow(dead_code)] #![allow(dead_code)]
use std::ffi::OsStr; use std::ffi::OsStr;
use std::fmt::Write as _; use std::fmt::Write as _;
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Output}; use std::process::{Command, Output};
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
@@ -15,8 +16,12 @@ use std::{env, fs};
// unique per case even with the suite running in parallel // unique per case even with the suite running in parallel
static NEXT: AtomicU32 = AtomicU32::new(0); static NEXT: AtomicU32 = AtomicU32::new(0);
pub const REMOTE: &str = "https://git.example.org/acme/proj.git";
pub struct Case { pub struct Case {
root: PathBuf, root: PathBuf,
// the data home every checkout of a case shares, as one user's would
xdg: PathBuf,
pub repo: PathBuf, pub repo: PathBuf,
pub store: PathBuf, pub store: PathBuf,
pub outside: PathBuf, pub outside: PathBuf,
@@ -32,29 +37,41 @@ impl Case {
)); ));
let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&root);
let case = Self { Self::checkout(root.clone(), root.join("xdg"))
repo: root.join("repo"), }
store: root
.join("xdg/ahab/git.example.org/acme/proj")
.to_path_buf(),
outside: root.join("outside"),
root,
};
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"); 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(&["init", "-q", "."]);
case.git(&["config", "user.email", "test@example.org"]); case.git(&["config", "user.email", "test@example.org"]);
case.git(&["config", "user.name", "test"]); case.git(&["config", "user.name", "test"]);
case.git(&["config", "commit.gpgsign", "false"]); case.git(&["config", "commit.gpgsign", "false"]);
case.git(&[ case.git(&["remote", "add", "origin", REMOTE]);
"remote",
"add",
"origin",
"https://git.example.org/acme/proj.git",
]);
case.write("tracked.txt", b"tracked\n"); case.write("tracked.txt", b"tracked\n");
case.git(&["add", "tracked.txt"]); case.git(&["add", "tracked.txt"]);
case.git(&["commit", "-qm", "init"]); case.git(&["commit", "-qm", "init"]);
@@ -84,7 +101,7 @@ impl Case {
pub fn ahab<S: AsRef<OsStr>>(&self, args: &[S]) -> Run { pub fn ahab<S: AsRef<OsStr>>(&self, args: &[S]) -> Run {
let out = Command::new(env!("CARGO_BIN_EXE_ahab")) let out = Command::new(env!("CARGO_BIN_EXE_ahab"))
.current_dir(&self.repo) .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 // the git commands ahab runs must not read the developer's own config
.env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null") .env("GIT_CONFIG_SYSTEM", "/dev/null")
@@ -205,3 +222,15 @@ impl Run {
out 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)
}