merge: multiple checkouts
This commit is contained in:
35
README.md
35
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 # `<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
|
||||
```
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user