|
|
|
|
@@ -0,0 +1,761 @@
|
|
|
|
|
use std::cell::Cell;
|
|
|
|
|
use std::env;
|
|
|
|
|
use std::ffi::OsString;
|
|
|
|
|
use std::fs;
|
|
|
|
|
use std::os::unix::fs::symlink;
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
use std::process::Command;
|
|
|
|
|
|
|
|
|
|
use anyhow::{anyhow, Context, Result};
|
|
|
|
|
|
|
|
|
|
const BACKUP_SUFFIX: &str = ".ahab-bak";
|
|
|
|
|
const LOCAL_NAMESPACE: &str = "_local";
|
|
|
|
|
|
|
|
|
|
// move untracked paths out of the repo and symlink them back
|
|
|
|
|
pub fn add(paths: &[PathBuf], force: bool) -> Result<()> {
|
|
|
|
|
let repo = Repo::discover()?;
|
|
|
|
|
let report = Report::new(&repo);
|
|
|
|
|
|
|
|
|
|
if let [path] = paths {
|
|
|
|
|
return link_one(&repo, path, force, &report);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut failed = 0;
|
|
|
|
|
for path in paths {
|
|
|
|
|
if let Err(e) = link_one(&repo, path, force, &report) {
|
|
|
|
|
eprintln!("error: {e:#}");
|
|
|
|
|
failed += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if failed > 0 {
|
|
|
|
|
return Err(anyhow!("{failed} of {} paths failed", paths.len()));
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct Report {
|
|
|
|
|
store: PathBuf,
|
|
|
|
|
named: Cell<bool>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Report {
|
|
|
|
|
fn new(repo: &Repo) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
store: repo.store.clone(),
|
|
|
|
|
named: Cell::new(false),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn line(&self, verb: &str, path: &Path) {
|
|
|
|
|
// worth naming once per run
|
|
|
|
|
if !self.named.replace(true) {
|
|
|
|
|
println!("store: {}", self.store.display());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
println!("\t{:<11}{}", format!("{verb}:"), path.display());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn warn(msg: impl std::fmt::Display) {
|
|
|
|
|
eprintln!("warning: {msg}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// list untracked paths not in the store, i.e. what a sandbox can still read
|
|
|
|
|
pub fn check(paths: &[PathBuf], porcelain: bool, null: bool) -> Result<()> {
|
|
|
|
|
let repo = Repo::discover()?;
|
|
|
|
|
let pathspecs = relative_pathspecs(&repo, paths)?;
|
|
|
|
|
|
|
|
|
|
let mut exposed = Vec::new();
|
|
|
|
|
// git lists untracked and ignored separately
|
|
|
|
|
for (mark, ignored) in [(UNTRACKED, false), (IGNORED, true)] {
|
|
|
|
|
for entry in list_others(&repo, ignored, &pathspecs)? {
|
|
|
|
|
let rel = PathBuf::from(entry.trim_end_matches('/'));
|
|
|
|
|
|
|
|
|
|
// --directory collapses a wholly untracked dir into `dir/`
|
|
|
|
|
if entry.ends_with('/') {
|
|
|
|
|
exposed.extend(walk(&repo, &rel, mark)?.1);
|
|
|
|
|
} else {
|
|
|
|
|
exposed.extend(classify(&repo, &rel, mark)?);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
exposed.sort_by(|a, b| a.name.cmp(&b.name));
|
|
|
|
|
|
|
|
|
|
if porcelain || null {
|
|
|
|
|
print_porcelain(&exposed, null);
|
|
|
|
|
} else {
|
|
|
|
|
print_listing(&repo, &exposed);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn print_porcelain(exposed: &[Exposed], null: bool) {
|
|
|
|
|
let end = if null { '\0' } else { '\n' };
|
|
|
|
|
|
|
|
|
|
for item in exposed {
|
|
|
|
|
match &item.dest {
|
|
|
|
|
Some(dest) => print!("{} {} -> {}{end}", item.code(), item.name, dest.display()),
|
|
|
|
|
None => print!("{} {}{end}", item.code(), item.name),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn print_listing(repo: &Repo, exposed: &[Exposed]) {
|
|
|
|
|
println!("store: {}", repo.store.display());
|
|
|
|
|
|
|
|
|
|
if exposed.is_empty() {
|
|
|
|
|
println!("nothing outside the store, a sandbox would see tracked files only");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let sections = [
|
|
|
|
|
(
|
|
|
|
|
"Untracked paths a sandbox can read:",
|
|
|
|
|
" (use \"ahab link add <path>...\" to move them into the store)",
|
|
|
|
|
Section::Content(UNTRACKED),
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"Ignored paths a sandbox can read:",
|
|
|
|
|
" (use \"ahab link add <path>...\" to move them into the store)",
|
|
|
|
|
Section::Content(IGNORED),
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"Symlinks leading outside the store:",
|
|
|
|
|
" (their contents are not in the repository either way)",
|
|
|
|
|
Section::Elsewhere,
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (heading, hint, section) in sections {
|
|
|
|
|
let mut items = exposed.iter().filter(|item| section.holds(item)).peekable();
|
|
|
|
|
if items.peek().is_none() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
println!("\n{heading}\n{hint}");
|
|
|
|
|
for item in items {
|
|
|
|
|
match &item.dest {
|
|
|
|
|
Some(dest) => println!("\t{} -> {}", item.name, dest.display()),
|
|
|
|
|
None => println!("\t{}", item.name),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
enum Section {
|
|
|
|
|
Content(char),
|
|
|
|
|
Elsewhere,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Section {
|
|
|
|
|
fn holds(&self, item: &Exposed) -> bool {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Content(mark) => item.dest.is_none() && item.mark == *mark,
|
|
|
|
|
Self::Elsewhere => item.dest.is_some(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// status codes as `git status --porcelain` spells them
|
|
|
|
|
const UNTRACKED: char = '?';
|
|
|
|
|
const IGNORED: char = '!';
|
|
|
|
|
const ELSEWHERE: char = '>';
|
|
|
|
|
|
|
|
|
|
struct Exposed {
|
|
|
|
|
mark: char,
|
|
|
|
|
name: String,
|
|
|
|
|
dest: Option<PathBuf>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Exposed {
|
|
|
|
|
fn content(mark: char, name: String) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
mark,
|
|
|
|
|
name,
|
|
|
|
|
dest: None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn code(&self) -> String {
|
|
|
|
|
let second = if self.dest.is_some() {
|
|
|
|
|
ELSEWHERE
|
|
|
|
|
} else {
|
|
|
|
|
self.mark
|
|
|
|
|
};
|
|
|
|
|
format!("{}{second}", self.mark)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn classify(repo: &Repo, rel: &Path, mark: char) -> Result<Option<Exposed>> {
|
|
|
|
|
let src = repo.root.join(rel);
|
|
|
|
|
let name = rel.display().to_string();
|
|
|
|
|
|
|
|
|
|
let Some(meta) = symlink_metadata_opt(&src)? else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
if !meta.is_symlink() {
|
|
|
|
|
return Ok(Some(Exposed::content(mark, name)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let dest = fs::read_link(&src).with_context(|| format!("reading symlink {}", src.display()))?;
|
|
|
|
|
if dest == repo.store.join(rel) {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(Some(Exposed {
|
|
|
|
|
mark,
|
|
|
|
|
name,
|
|
|
|
|
dest: Some(dest),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn walk(repo: &Repo, rel: &Path, mark: char) -> Result<(usize, Vec<Exposed>)> {
|
|
|
|
|
let dir = repo.root.join(rel);
|
|
|
|
|
let mut handled = 0;
|
|
|
|
|
let mut exposed = Vec::new();
|
|
|
|
|
|
|
|
|
|
for entry in fs::read_dir(&dir).with_context(|| format!("reading {}", dir.display()))? {
|
|
|
|
|
let entry = entry?;
|
|
|
|
|
let child = rel.join(entry.file_name());
|
|
|
|
|
|
|
|
|
|
if entry.file_type()?.is_dir() {
|
|
|
|
|
let (below, inside) = walk(repo, &child, mark)?;
|
|
|
|
|
handled += below;
|
|
|
|
|
exposed.extend(inside);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
match classify(repo, &child, mark)? {
|
|
|
|
|
Some(item) => exposed.push(item),
|
|
|
|
|
None => handled += 1,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// nothing below is in the store, so collapse to one line
|
|
|
|
|
if handled == 0 && !exposed.is_empty() {
|
|
|
|
|
let name = format!("{}/", rel.display());
|
|
|
|
|
return Ok((0, vec![Exposed::content(mark, name)]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok((handled, exposed))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn relative_pathspecs(repo: &Repo, paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
|
|
|
|
|
let mut specs = Vec::with_capacity(paths.len());
|
|
|
|
|
|
|
|
|
|
for path in paths {
|
|
|
|
|
let abs = resolve(path)?;
|
|
|
|
|
let rel = abs.strip_prefix(&repo.root).map_err(|_| {
|
|
|
|
|
anyhow!(
|
|
|
|
|
"{} is outside the repository {}",
|
|
|
|
|
abs.display(),
|
|
|
|
|
repo.root.display()
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
if rel.as_os_str().is_empty() {
|
|
|
|
|
return Ok(Vec::new());
|
|
|
|
|
}
|
|
|
|
|
specs.push(rel.to_path_buf());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(specs)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn list_others(repo: &Repo, ignored: bool, pathspecs: &[PathBuf]) -> Result<Vec<String>> {
|
|
|
|
|
let mut cmd = Command::new("git");
|
|
|
|
|
cmd.arg("-C").arg(&repo.root).args([
|
|
|
|
|
"ls-files",
|
|
|
|
|
"-z",
|
|
|
|
|
"--others",
|
|
|
|
|
"--exclude-standard",
|
|
|
|
|
"--directory",
|
|
|
|
|
"--no-empty-directory",
|
|
|
|
|
]);
|
|
|
|
|
if ignored {
|
|
|
|
|
cmd.arg("--ignored");
|
|
|
|
|
}
|
|
|
|
|
cmd.arg("--").args(pathspecs);
|
|
|
|
|
|
|
|
|
|
let out = cmd.output().context("running git ls-files")?;
|
|
|
|
|
if !out.status.success() {
|
|
|
|
|
return Err(anyhow!(
|
|
|
|
|
"git ls-files failed: {}",
|
|
|
|
|
String::from_utf8_lossy(&out.stderr).trim()
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(String::from_utf8(out.stdout)?
|
|
|
|
|
.split('\0')
|
|
|
|
|
.filter(|p| !p.is_empty())
|
|
|
|
|
.map(String::from)
|
|
|
|
|
.collect())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct Repo {
|
|
|
|
|
root: PathBuf,
|
|
|
|
|
store: PathBuf,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Repo {
|
|
|
|
|
fn discover() -> Result<Self> {
|
|
|
|
|
let root = git_root()?;
|
|
|
|
|
let store = store_root()?.join(repo_components(&root)?);
|
|
|
|
|
Ok(Self { root, store })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn link_one(repo: &Repo, path: &Path, force: bool, report: &Report) -> Result<()> {
|
|
|
|
|
let src = resolve(path)?;
|
|
|
|
|
let rel = repo.relative(&src)?;
|
|
|
|
|
let target = repo.store.join(&rel);
|
|
|
|
|
|
|
|
|
|
// a target inside the repo would be readable from the sandbox anyway
|
|
|
|
|
if target.starts_with(&repo.root) {
|
|
|
|
|
return Err(anyhow!(
|
|
|
|
|
"target {} is inside the repository; point AHAB_LINK_ROOT elsewhere",
|
|
|
|
|
target.display()
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if tracked(repo, &rel)? {
|
|
|
|
|
return Err(anyhow!(
|
|
|
|
|
"{} is tracked by git; only untracked or ignored paths can be externalized",
|
|
|
|
|
rel.display()
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if !ignored(repo, &rel) {
|
|
|
|
|
warn(format!("{} is not gitignored", rel.display()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let src_meta = symlink_metadata_opt(&src)?;
|
|
|
|
|
let target_taken = symlink_metadata_opt(&target)?.is_some();
|
|
|
|
|
|
|
|
|
|
match src_meta {
|
|
|
|
|
Some(meta) if meta.is_symlink() => {
|
|
|
|
|
let dest = fs::read_link(&src)
|
|
|
|
|
.with_context(|| format!("reading symlink {}", src.display()))?;
|
|
|
|
|
|
|
|
|
|
if dest == target {
|
|
|
|
|
if !target_taken {
|
|
|
|
|
return Err(anyhow!(
|
|
|
|
|
"{} already points at {}, but nothing is there",
|
|
|
|
|
rel.display(),
|
|
|
|
|
target.display()
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
report.line("unchanged", &rel);
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// nothing in the store to adopt, so the symlink itself moves out
|
|
|
|
|
if !target_taken {
|
|
|
|
|
if !src.exists() {
|
|
|
|
|
warn(format!(
|
|
|
|
|
"{} is a broken symlink to {}",
|
|
|
|
|
rel.display(),
|
|
|
|
|
dest.display()
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
move_out(&src, &target)?;
|
|
|
|
|
place_link(&src, &target)?;
|
|
|
|
|
report.line("moved", &rel);
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !force {
|
|
|
|
|
return Err(needs_force(&target));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
place_link(&src, &target)?;
|
|
|
|
|
report.line("repointed", &rel);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Some(_) if target_taken => {
|
|
|
|
|
if !force {
|
|
|
|
|
return Err(needs_force(&target));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let backup = suffixed(&src, BACKUP_SUFFIX);
|
|
|
|
|
if symlink_metadata_opt(&backup)?.is_some() {
|
|
|
|
|
return Err(anyhow!(
|
|
|
|
|
"{} already exists; remove it before re-linking",
|
|
|
|
|
backup.display()
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fs::rename(&src, &backup)
|
|
|
|
|
.with_context(|| format!("renaming {} to {}", src.display(), backup.display()))?;
|
|
|
|
|
report.line("saved", &suffixed(&rel, BACKUP_SUFFIX));
|
|
|
|
|
|
|
|
|
|
place_link(&src, &target)?;
|
|
|
|
|
report.line("linked", &rel);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Some(_) => {
|
|
|
|
|
move_out(&src, &target)?;
|
|
|
|
|
place_link(&src, &target)?;
|
|
|
|
|
report.line("moved", &rel);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
None if target_taken => {
|
|
|
|
|
if !force {
|
|
|
|
|
return Err(needs_force(&target));
|
|
|
|
|
}
|
|
|
|
|
place_link(&src, &target)?;
|
|
|
|
|
report.line("linked", &rel);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
None => Err(anyhow!(
|
|
|
|
|
"{} does not exist and the store has no {}",
|
|
|
|
|
rel.display(),
|
|
|
|
|
target.display()
|
|
|
|
|
)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn needs_force(target: &Path) -> anyhow::Error {
|
|
|
|
|
anyhow!(
|
|
|
|
|
"{} already exists; pass --force to link to it",
|
|
|
|
|
target.display()
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 fs::canonicalize(&abs).with_context(|| format!("resolving {}", abs.display()));
|
|
|
|
|
};
|
|
|
|
|
let parent = abs.parent().unwrap_or(Path::new("/"));
|
|
|
|
|
|
|
|
|
|
let parent = fs::canonicalize(parent)
|
|
|
|
|
.with_context(|| format!("resolving directory {}", parent.display()))?;
|
|
|
|
|
Ok(parent.join(name))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn git_root() -> Result<PathBuf> {
|
|
|
|
|
let out = Command::new("git")
|
|
|
|
|
.args(["rev-parse", "--show-toplevel"])
|
|
|
|
|
.output()
|
|
|
|
|
.context("running git")?;
|
|
|
|
|
|
|
|
|
|
if !out.status.success() {
|
|
|
|
|
return Err(anyhow!("not inside a git repository"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let root = String::from_utf8(out.stdout)?.trim().to_string();
|
|
|
|
|
if root.is_empty() {
|
|
|
|
|
return Err(anyhow!("git reported an empty repository root"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fs::canonicalize(&root).with_context(|| format!("resolving {root}"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn repo_components(root: &Path) -> Result<PathBuf> {
|
|
|
|
|
if let Some(url) = git_origin_url() {
|
|
|
|
|
if let Some(components) = components_from_remote(&url) {
|
|
|
|
|
return Ok(components);
|
|
|
|
|
}
|
|
|
|
|
eprintln!("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() -> Option<String> {
|
|
|
|
|
let out = Command::new("git")
|
|
|
|
|
.args(["config", "--get", "remote.origin.url"])
|
|
|
|
|
.output()
|
|
|
|
|
.ok()?;
|
|
|
|
|
if !out.status.success() {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let url = String::from_utf8(out.stdout).ok()?.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(root) = non_empty_var("AHAB_LINK_ROOT") {
|
|
|
|
|
return Ok(PathBuf::from(root));
|
|
|
|
|
}
|
|
|
|
|
if let Some(xdg) = non_empty_var("XDG_DATA_HOME") {
|
|
|
|
|
return Ok(PathBuf::from(xdg).join("ahab"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let home = non_empty_var("HOME")
|
|
|
|
|
.ok_or_else(|| anyhow!("none of AHAB_LINK_ROOT, XDG_DATA_HOME or 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())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn tracked(repo: &Repo, rel: &Path) -> Result<bool> {
|
|
|
|
|
let out = Command::new("git")
|
|
|
|
|
.arg("-C")
|
|
|
|
|
.arg(&repo.root)
|
|
|
|
|
.args(["ls-files", "--cached", "--"])
|
|
|
|
|
.arg(rel)
|
|
|
|
|
.output()
|
|
|
|
|
.context("running git ls-files")?;
|
|
|
|
|
|
|
|
|
|
if !out.status.success() {
|
|
|
|
|
return Err(anyhow!(
|
|
|
|
|
"git ls-files failed: {}",
|
|
|
|
|
String::from_utf8_lossy(&out.stderr).trim()
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(!out.stdout.is_empty())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn ignored(repo: &Repo, rel: &Path) -> bool {
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.arg("-C")
|
|
|
|
|
.arg(&repo.root)
|
|
|
|
|
.args(["check-ignore", "-q", "--"])
|
|
|
|
|
.arg(rel)
|
|
|
|
|
.status()
|
|
|
|
|
.is_ok_and(|s| s.success())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn symlink_metadata_opt(path: &Path) -> Result<Option<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).with_context(|| format!("inspecting {}", path.display())),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn move_out(src: &Path, target: &Path) -> Result<()> {
|
|
|
|
|
ensure_parent(target)?;
|
|
|
|
|
|
|
|
|
|
// rename cannot cross filesystems, and the store often is another one
|
|
|
|
|
match fs::rename(src, target) {
|
|
|
|
|
Ok(()) => Ok(()),
|
|
|
|
|
Err(rename_err) => match copy_recursive(src, target) {
|
|
|
|
|
Ok(()) => remove_recursive(src),
|
|
|
|
|
Err(copy_err) => Err(copy_err).with_context(|| {
|
|
|
|
|
format!(
|
|
|
|
|
"moving {} to {} (rename failed: {rename_err})",
|
|
|
|
|
src.display(),
|
|
|
|
|
target.display()
|
|
|
|
|
)
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn copy_recursive(src: &Path, target: &Path) -> Result<()> {
|
|
|
|
|
let meta =
|
|
|
|
|
fs::symlink_metadata(src).with_context(|| format!("inspecting {}", src.display()))?;
|
|
|
|
|
|
|
|
|
|
if meta.is_dir() {
|
|
|
|
|
fs::create_dir_all(target)
|
|
|
|
|
.with_context(|| format!("creating directory {}", target.display()))?;
|
|
|
|
|
for entry in
|
|
|
|
|
fs::read_dir(src).with_context(|| format!("reading directory {}", src.display()))?
|
|
|
|
|
{
|
|
|
|
|
let entry = entry?;
|
|
|
|
|
copy_recursive(&entry.path(), &target.join(entry.file_name()))?;
|
|
|
|
|
}
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if meta.is_symlink() {
|
|
|
|
|
let dest = fs::read_link(src)?;
|
|
|
|
|
return symlink(&dest, target)
|
|
|
|
|
.with_context(|| format!("creating symlink {}", target.display()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fs::copy(src, target)
|
|
|
|
|
.with_context(|| format!("copying {} to {}", src.display(), target.display()))?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn remove_recursive(path: &Path) -> Result<()> {
|
|
|
|
|
let meta =
|
|
|
|
|
fs::symlink_metadata(path).with_context(|| format!("inspecting {}", path.display()))?;
|
|
|
|
|
|
|
|
|
|
if meta.is_dir() {
|
|
|
|
|
fs::remove_dir_all(path)
|
|
|
|
|
} else {
|
|
|
|
|
fs::remove_file(path)
|
|
|
|
|
}
|
|
|
|
|
.with_context(|| format!("removing {}", path.display()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn ensure_parent(target: &Path) -> Result<()> {
|
|
|
|
|
if let Some(parent) = target.parent() {
|
|
|
|
|
if !parent.as_os_str().is_empty() {
|
|
|
|
|
fs::create_dir_all(parent)
|
|
|
|
|
.with_context(|| format!("creating directory {}", parent.display()))?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn place_link(link_path: &Path, target: &Path) -> Result<()> {
|
|
|
|
|
// symlink under a temp name and rename over the path: the rename is atomic
|
|
|
|
|
let tmp = suffixed(link_path, ".ahab-tmp");
|
|
|
|
|
let _ = fs::remove_file(&tmp);
|
|
|
|
|
|
|
|
|
|
symlink(target, &tmp)
|
|
|
|
|
.with_context(|| format!("creating symlink {} -> {}", tmp.display(), target.display()))?;
|
|
|
|
|
fs::rename(&tmp, link_path)
|
|
|
|
|
.with_context(|| format!("replacing {} with a symlink", link_path.display()))?;
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn suffixed(path: &Path, suffix: &str) -> PathBuf {
|
|
|
|
|
let mut out = path.as_os_str().to_owned();
|
|
|
|
|
out.push(suffix);
|
|
|
|
|
PathBuf::from(out)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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");
|
|
|
|
|
}
|
|
|
|
|
}
|