refactor: name the modules after what they hold
This commit is contained in:
254
src/commands/link/store.rs
Normal file
254
src/commands/link/store.rs
Normal file
@@ -0,0 +1,254 @@
|
||||
use fs_err as fs;
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
use std::path::{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 = 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))
|
||||
}
|
||||
|
||||
fn git_root(ctx: &Ctx) -> Result<PathBuf> {
|
||||
let root = RevParse
|
||||
.capture(ctx)
|
||||
.map_err(|_| anyhow!("not inside a git repository"))?;
|
||||
|
||||
let root = root.trim().to_string();
|
||||
if root.is_empty() {
|
||||
return Err(anyhow!("git reported an empty repository root"));
|
||||
}
|
||||
|
||||
Ok(fs::canonicalize(&root)?)
|
||||
}
|
||||
|
||||
fn repo_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!("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.to_string_lossy())))
|
||||
}
|
||||
|
||||
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 sanitize(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
|
||||
if out.chars().all(|c| c == '.') {
|
||||
return "_".repeat(out.len());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
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> {
|
||||
let listed = LsFiles::tracked(&repo.root)
|
||||
.limited_to(&[rel])
|
||||
.capture(ctx)?;
|
||||
|
||||
Ok(!listed.is_empty())
|
||||
}
|
||||
|
||||
pub(super) fn ignored(ctx: &Ctx, repo: &Repo, rel: &Path) -> bool {
|
||||
CheckIgnore::new(&repo.root, rel)
|
||||
.quietly_succeeds(ctx)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{components_from_remote, sanitize};
|
||||
use std::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() {
|
||||
assert_eq!(sanitize(".."), "__");
|
||||
assert_eq!(sanitize("."), "_");
|
||||
assert_eq!(sanitize("a/b"), "a_b");
|
||||
assert_eq!(sanitize(".env"), ".env");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user