473 lines
16 KiB
Rust
473 lines
16 KiB
Rust
use fs_err as fs;
|
|
use std::env;
|
|
use std::ffi::{OsStr, OsString};
|
|
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
|
use std::path::{Component, Path, PathBuf};
|
|
|
|
use anyhow::{Context, Result, anyhow};
|
|
|
|
use crate::cmd::{CheckIgnore, Cmd, ConfigGet, LsFiles, RevParse};
|
|
use crate::ctx::Ctx;
|
|
use crate::output::note;
|
|
|
|
// a checkout with no remote to name it after
|
|
const LOCAL_NAMESPACE: &str = "_local";
|
|
|
|
pub(super) struct Repo {
|
|
pub(super) root: PathBuf,
|
|
pub(super) store: PathBuf,
|
|
// the configured store root, above the per-repository directories
|
|
pub(super) base: PathBuf,
|
|
}
|
|
|
|
impl Repo {
|
|
pub(super) fn discover(ctx: &Ctx, store: Option<&Path>) -> Result<Self> {
|
|
let root = git_root(ctx)?;
|
|
let base = normalized(match store {
|
|
Some(store) => store.to_path_buf(),
|
|
None => store_root()?,
|
|
})?;
|
|
|
|
Ok(Self {
|
|
store: base.join(repo_components(ctx, &root)?),
|
|
root,
|
|
base,
|
|
})
|
|
}
|
|
pub(super) fn relative(&self, src: &Path) -> Result<PathBuf> {
|
|
let rel = src.strip_prefix(&self.root).map_err(|_| {
|
|
anyhow!(
|
|
"{} is outside the repository {}",
|
|
src.display(),
|
|
self.root.display()
|
|
)
|
|
})?;
|
|
|
|
// empty means the whole repo
|
|
if rel.as_os_str().is_empty() {
|
|
return Err(anyhow!(
|
|
"refusing to externalize the repository root itself"
|
|
));
|
|
}
|
|
if rel.starts_with(".git") {
|
|
return Err(anyhow!("refusing to externalize anything under .git"));
|
|
}
|
|
|
|
Ok(rel.to_path_buf())
|
|
}
|
|
}
|
|
|
|
pub(super) fn resolve(path: &Path) -> Result<PathBuf> {
|
|
let abs = std::path::absolute(path)
|
|
.with_context(|| format!("resolving absolute path of {}", path.display()))?;
|
|
|
|
// the parent must exist so symlinked components resolve like git's toplevel
|
|
let Some(name) = abs.file_name().map(OsString::from) else {
|
|
return Ok(fs::canonicalize(&abs)?);
|
|
};
|
|
let parent = fs::canonicalize(abs.parent().unwrap_or(Path::new("/")))?;
|
|
Ok(parent.join(name))
|
|
}
|
|
|
|
// absolute, with `.` and `..` folded out. the store path is written into every
|
|
// symlink `add` creates, where a relative one would resolve from the link's own
|
|
// directory rather than the working one, and it is compared against the
|
|
// repository root, which a `..` would slip past
|
|
fn normalized(path: PathBuf) -> Result<PathBuf> {
|
|
let absolute = std::path::absolute(&path)
|
|
.with_context(|| format!("resolving absolute path of {}", path.display()))?;
|
|
|
|
let mut out = PathBuf::new();
|
|
for part in absolute.components() {
|
|
match part {
|
|
Component::CurDir => {}
|
|
Component::ParentDir => {
|
|
out.pop();
|
|
}
|
|
part => out.push(part),
|
|
}
|
|
}
|
|
|
|
Ok(out)
|
|
}
|
|
|
|
fn git_root(ctx: &Ctx) -> Result<PathBuf> {
|
|
// bytes, since the checkout can live at a path that is not utf-8, and with
|
|
// the cause kept: git not being installed and the directory not being a
|
|
// repository are different problems with the same one-line answer otherwise
|
|
let root = RevParse
|
|
.capture_bytes(ctx)
|
|
.context("asking git for the repository root")?;
|
|
|
|
let root = root.strip_suffix(b"\n").unwrap_or(&root);
|
|
if root.is_empty() {
|
|
return Err(anyhow!("git reported an empty repository root"));
|
|
}
|
|
|
|
Ok(fs::canonicalize(PathBuf::from(OsString::from_vec(
|
|
root.to_vec(),
|
|
)))?)
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
note!(
|
|
ctx,
|
|
"could not parse git remote `{url}`, falling back to the checkout name"
|
|
);
|
|
}
|
|
|
|
let name = root
|
|
.file_name()
|
|
.ok_or_else(|| anyhow!("cannot derive a store path for {}", root.display()))?;
|
|
Ok(Path::new(LOCAL_NAMESPACE).join(sanitize_name(name)))
|
|
}
|
|
|
|
fn git_origin_url(ctx: &Ctx) -> Option<String> {
|
|
let url = ConfigGet {
|
|
key: "remote.origin.url",
|
|
}
|
|
.capture(ctx)
|
|
.ok()?;
|
|
|
|
let url = url.trim().to_string();
|
|
(!url.is_empty()).then_some(url)
|
|
}
|
|
|
|
fn components_from_remote(url: &str) -> Option<PathBuf> {
|
|
let url = url.trim();
|
|
let url = url.strip_suffix(".git").unwrap_or(url);
|
|
|
|
// `scheme://[user@]host[:port]/path`, or scp-like `[user@]host:path`
|
|
let (authority, path) = match url.split_once("://") {
|
|
Some((_, after)) => after.split_once('/')?,
|
|
None => url.split_once(':')?,
|
|
};
|
|
|
|
let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
|
|
let host = host.split_once(':').map_or(host, |(h, _)| h);
|
|
// a local remote has no host to key on
|
|
if host.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let mut components = PathBuf::from(sanitize(&host.to_lowercase()));
|
|
let mut depth = 0;
|
|
for part in path.split('/').filter(|p| !p.is_empty()) {
|
|
components.push(sanitize(part));
|
|
depth += 1;
|
|
}
|
|
(depth > 0).then_some(components)
|
|
}
|
|
|
|
fn cleaned(s: &str) -> String {
|
|
let out: String = s
|
|
.chars()
|
|
.map(|c| {
|
|
if c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_') {
|
|
c
|
|
} else {
|
|
'_'
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
// `.` and `..` are legal characters but not legal components
|
|
match out.chars().all(|c| c == '.') {
|
|
true => "_".repeat(out.len()),
|
|
false => out,
|
|
}
|
|
}
|
|
|
|
fn sanitize(s: &str) -> String {
|
|
let out = cleaned(s);
|
|
|
|
// every replaced character maps to the same `_`, so `my~api` and `my:api`
|
|
// would otherwise share one directory with the plain `my_api`. a component
|
|
// that came through untouched keeps its name, so the common remote keeps
|
|
// the store path it already has
|
|
match out == s {
|
|
true => out,
|
|
false => format!("{out}-{}", fingerprint(s.as_bytes())),
|
|
}
|
|
}
|
|
|
|
// a checkout name is bytes like any other path. a lossy rendering turns every
|
|
// byte it cannot read into the same replacement character, so sanitize would
|
|
// see two different names as one and fingerprint them identically: the bytes
|
|
// themselves are what has to be fingerprinted
|
|
fn sanitize_name(name: &OsStr) -> String {
|
|
match name.to_str() {
|
|
Some(text) => sanitize(text),
|
|
None => format!(
|
|
"{}-{}",
|
|
cleaned(&name.to_string_lossy()),
|
|
fingerprint(name.as_bytes())
|
|
),
|
|
}
|
|
}
|
|
|
|
// fnv-1a: the store path has to stay put across rust releases, which the hashers
|
|
// in std explicitly do not promise
|
|
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)
|
|
}
|
|
|
|
fn store_root() -> Result<PathBuf> {
|
|
if let Some(xdg) = non_empty_var("XDG_DATA_HOME") {
|
|
return Ok(PathBuf::from(xdg).join("ahab"));
|
|
}
|
|
|
|
let home = non_empty_var("HOME")
|
|
.filter(|v| !v.is_empty())
|
|
.ok_or_else(|| anyhow!("neither XDG_DATA_HOME nor HOME is set"))?;
|
|
|
|
Ok(PathBuf::from(home).join(".local/share/ahab"))
|
|
}
|
|
fn non_empty_var(name: &str) -> Option<OsString> {
|
|
env::var_os(name).filter(|v| !v.is_empty())
|
|
}
|
|
|
|
pub(super) fn tracked(ctx: &Ctx, repo: &Repo, rel: &Path) -> Result<bool> {
|
|
// bytes: the listing echoes back the path asked about, which is not obliged
|
|
// to be utf-8, and failing to read it would refuse the path for the wrong
|
|
// reason rather than answering whether git tracks it
|
|
let listed = LsFiles::tracked(&repo.root)
|
|
.limited_to(&[rel])
|
|
.capture_bytes(ctx)?;
|
|
|
|
Ok(!listed.is_empty())
|
|
}
|
|
|
|
// check-ignore says 0 for ignored and 1 for not; anything else means it could
|
|
// not answer at all, which is not the same as "not ignored". git cannot even be
|
|
// asked about a path whose name looks like pathspec magic, since it rejects the
|
|
// magic rather than the name, and `:(literal)` is not accepted by this command
|
|
pub(super) fn ignored(ctx: &Ctx, repo: &Repo, rel: &Path) -> Result<bool> {
|
|
let status = CheckIgnore::new(&repo.root, rel).probe_status(ctx)?;
|
|
|
|
match status.code() {
|
|
Some(0) => Ok(true),
|
|
Some(1) => Ok(false),
|
|
_ => Err(anyhow!("`git check-ignore` exited with {status}")),
|
|
}
|
|
}
|
|
|
|
pub(super) fn symlink_metadata_opt(path: &Path) -> Result<Option<std::fs::Metadata>> {
|
|
// symlink_metadata does not follow the link, so a symlink shows as one
|
|
match fs::symlink_metadata(path) {
|
|
Ok(meta) => Ok(Some(meta)),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
|
Err(e) => Err(e.into()),
|
|
}
|
|
}
|
|
|
|
// 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
|
|
Inside,
|
|
// out of it, at this path
|
|
Outside(PathBuf),
|
|
// nowhere: a broken link, or too many hops for the kernel to follow
|
|
Dangling,
|
|
}
|
|
|
|
// comparing a link's target against an expected path only says what it claims;
|
|
// this says where following it really arrives, which is what decides whether a
|
|
// path is held by the store or merely points at something that is not
|
|
pub(super) fn leads(path: &Path, root: &Path) -> Leads {
|
|
let Ok(end) = fs::canonicalize(path) else {
|
|
return Leads::Dangling;
|
|
};
|
|
|
|
// the root can be reached through a symlink of its own, so resolve it too
|
|
// rather than comparing a resolved path against an unresolved prefix
|
|
let root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
|
|
|
match end.starts_with(&root) {
|
|
true => Leads::Inside,
|
|
false => Leads::Outside(end),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
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::{Path, PathBuf};
|
|
|
|
#[test]
|
|
fn parses_every_spelling_of_a_remote() {
|
|
let cases = [
|
|
(
|
|
"git@git.aflabs.org:urnik/afurnik.git",
|
|
"git.aflabs.org/urnik/afurnik",
|
|
),
|
|
(
|
|
"https://git.aflabs.org/urnik/afurnik.git",
|
|
"git.aflabs.org/urnik/afurnik",
|
|
),
|
|
(
|
|
"https://git.aflabs.org/urnik/afurnik",
|
|
"git.aflabs.org/urnik/afurnik",
|
|
),
|
|
(
|
|
"https://git.aflabs.org/urnik/afurnik/",
|
|
"git.aflabs.org/urnik/afurnik",
|
|
),
|
|
(
|
|
"ssh://git@git.aflabs.org:22/urnik/afurnik.git",
|
|
"git.aflabs.org/urnik/afurnik",
|
|
),
|
|
(
|
|
"git@GIT.Aflabs.org:urnik/AFurnik.git",
|
|
"git.aflabs.org/urnik/AFurnik",
|
|
),
|
|
(
|
|
"git@git.aflabs.org:urnik/internal/afurnik.git",
|
|
"git.aflabs.org/urnik/internal/afurnik",
|
|
),
|
|
];
|
|
|
|
for (url, want) in cases {
|
|
assert_eq!(
|
|
components_from_remote(url),
|
|
Some(PathBuf::from(want)),
|
|
"url: {url}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_remotes_without_a_host() {
|
|
assert_eq!(components_from_remote("not-a-url"), None);
|
|
assert_eq!(components_from_remote("/srv/git/afurnik.git"), None);
|
|
assert_eq!(components_from_remote("file:///srv/git/afurnik.git"), None);
|
|
assert_eq!(components_from_remote("https://git.aflabs.org/"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn sanitize_never_yields_a_traversal() {
|
|
for name in ["..", ".", "a/b", "../..", "a/../b"] {
|
|
let out = sanitize(name);
|
|
|
|
assert!(!out.contains('/'), "{name} -> {out}");
|
|
assert!(out != "." && out != "..", "{name} -> {out}");
|
|
}
|
|
|
|
// a name that needed no replacing keeps the store path it already has
|
|
assert_eq!(sanitize(".env"), ".env");
|
|
assert_eq!(sanitize("afurnik"), "afurnik");
|
|
assert_eq!(sanitize("git.aflabs.org"), "git.aflabs.org");
|
|
}
|
|
|
|
#[test]
|
|
fn sanitize_keeps_names_apart_that_replacing_would_collapse() {
|
|
// every disallowed character maps to `_`, so without the fingerprint
|
|
// these would all share one store directory with a plain `my_api`
|
|
let names = ["my~api", "my:api", "my api", "my/api", "my%api"];
|
|
|
|
for name in names {
|
|
assert_ne!(sanitize(name), sanitize("my_api"), "name: {name}");
|
|
}
|
|
|
|
let distinct: HashSet<String> = names.iter().map(|name| sanitize(name)).collect();
|
|
assert_eq!(distinct.len(), names.len(), "{distinct:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_fingerprint_does_not_drift_with_the_toolchain() {
|
|
// std's hashers make no such promise, and a moved store loses the files
|
|
assert_eq!(fingerprint(b""), "84222325");
|
|
assert_eq!(fingerprint(b"my~api"), fingerprint(b"my~api"));
|
|
assert_ne!(fingerprint(b"my~api"), fingerprint(b"my:api"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_checkout_name_that_is_not_utf_8_keeps_its_own_directory() {
|
|
// both render to the same replacement character, so a fingerprint taken
|
|
// of the rendering rather than the bytes cannot tell them apart
|
|
let one = OsStr::from_bytes(b"proj\xe9");
|
|
let two = OsStr::from_bytes(b"proj\xff");
|
|
|
|
assert_ne!(sanitize_name(one), sanitize_name(two));
|
|
// and a name that is utf-8 is keyed exactly as before
|
|
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
|
|
// repository root, so it needs exactly one spelling
|
|
let cases = [
|
|
("/a/b/../c", "/a/c"),
|
|
("/a/./b", "/a/b"),
|
|
("/a/b/../../c", "/c"),
|
|
("/../..", "/"),
|
|
];
|
|
|
|
for (from, want) in cases {
|
|
assert_eq!(
|
|
normalized(PathBuf::from(from)).unwrap(),
|
|
PathBuf::from(want),
|
|
"from: {from}"
|
|
);
|
|
}
|
|
}
|
|
}
|