Files
ahab/src/commands/link.rs

476 lines
15 KiB
Rust

mod check;
mod store;
pub use check::check;
use fs_err::{read_dir, read_link};
use std::cell::Cell;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use self::store::{Leads, Repo, ignored, leads, resolve, symlink_metadata_opt, tracked};
use crate::cli::link as cli;
use crate::ctx::Ctx;
use crate::fsops::suffixed;
use crate::output::{line, note, plural, warning};
const BACKUP_SUFFIX: &str = ".ahab-bak";
// where the link waits while the payload comes back out of the store
const RESTORING_SUFFIX: &str = ".ahab-restoring";
// one path's failure is its own; several are counted, so a run over a list says
// what happened to each and then how the run as a whole went. a single path has
// nothing to count, so its error is simply the command's
fn each(paths: &[PathBuf], mut act: impl FnMut(&Path) -> Result<()>) -> Result<()> {
if let [only] = paths {
return act(only);
}
let mut failed = 0;
for path in paths {
if let Err(e) = act(path) {
warning!("{e:#}");
failed += 1;
}
}
match failed {
0 => Ok(()),
failed => Err(anyhow!("{failed} of {} paths failed", paths.len())),
}
}
// move untracked paths out of the repo and symlink them back
pub fn add(ctx: &Ctx, args: &cli::Add) -> Result<()> {
let repo = Repo::discover(ctx, args.store.root())?;
let report = Report::new(&repo);
each(&args.paths, |path| {
link_one(ctx, &repo, path, args.force, &report)
})
}
// move paths in the store back into the repo, the inverse of add
pub fn restore(ctx: &Ctx, args: &cli::Restore) -> Result<()> {
let repo = Repo::discover(ctx, args.store.root())?;
let report = Report::new(&repo);
// clap requires one or the other and refuses both, so only the two real
// cases are left here
let stored = match args.all {
true => stored_paths(&repo, &repo.store)?,
false => args
.paths
.iter()
.cloned()
.map(|path| (path, Stored::Linked))
.collect(),
};
// only a linked path can be moved back; the store can hold orphans too
let linked: Vec<PathBuf> = stored
.iter()
.filter(|(_, state)| *state == Stored::Linked)
.map(|(path, _)| path.clone())
.collect();
let skipped = stored.len() - linked.len();
if linked.is_empty() {
match skipped {
0 => note!(ctx, "nothing in the store for this repository"),
n => note!(
ctx,
"nothing to restore: {n} path{} in the store {} not linked, see `ahab link list`",
plural(n),
if n == 1 { "is" } else { "are" }
),
}
return Ok(());
}
if skipped > 0 {
note!(
ctx,
"leaving {skipped} path{} that the store holds but nothing links to",
plural(skipped)
);
}
each(&linked, |path| restore_one(ctx, &repo, path, &report))
}
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());
};
if !meta.is_symlink() {
bail!(
"{} is not a symlink, so it is not in the store",
rel.display()
);
}
let dest = read_link(&src)?;
if dest != stored {
bail!(
"{} points at {}, which is not where the store keeps it",
rel.display(),
dest.display()
);
}
if symlink_metadata_opt(&stored)?.is_none() {
bail!("{} is missing from the store", rel.display());
}
// the link is moved aside rather than removed: if the payload cannot come
// back out of the store, the repository is left pointing at where it still is
let aside = suffixed(&src, RESTORING_SUFFIX);
if symlink_metadata_opt(&aside)?.is_some() {
bail!("{} is in the way; move it aside", aside.display());
}
ctx.fs().rename(&src, &aside)?;
if let Err(e) = ctx.fs().move_path(&stored, &src) {
ctx.fs().rename(&aside, &src).with_context(|| {
format!(
"could not put the link at {} back after failing to restore it",
src.display()
)
})?;
return Err(e);
}
ctx.fs().remove_file(&aside)?;
ctx.fs().prune_empty(stored.parent(), &repo.base);
report.line("restored", &rel);
Ok(())
}
// what the repository looks like from the store's side
#[derive(PartialEq)]
enum Stored {
// the symlink is there and points at the store, which is the whole point
Linked,
// nothing is at the path the store holds it for
Missing,
// something else took the path, so the stored copy is the one nobody reads
Taken,
}
// 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)>> {
let mut found = Vec::new();
let entries = match read_dir(dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(found),
Err(e) => return Err(e.into()),
};
for entry in entries {
let stored = entry?.path();
let rel = stored.strip_prefix(&repo.store).with_context(|| {
format!(
"{} is not under the store {}",
stored.display(),
repo.store.display()
)
})?;
let src = repo.root.join(rel);
let state = match symlink_metadata_opt(&src)? {
None => Stored::Missing,
Some(meta) if !meta.is_symlink() => Stored::Taken,
Some(_) if read_link(&src).is_ok_and(|dest| dest == stored) => Stored::Linked,
Some(_) => Stored::Taken,
};
// a symlink the store happens to hold is a leaf, never a directory to
// walk into: is_dir would follow it and list whatever it points at
let holds_dir = symlink_metadata_opt(&stored)?.is_some_and(|meta| meta.is_dir());
// a linked directory is one entry; otherwise the paths inside it are
if state == Stored::Linked || !holds_dir {
found.push((src, state));
} else {
found.extend(stored_paths(repo, &stored)?);
}
}
found.sort_by(|(a, _), (b, _)| a.cmp(b));
Ok(found)
}
// what the store holds, without the git questions check asks
pub fn stored_summary(ctx: &Ctx, store: Option<&Path>) -> Result<(PathBuf, usize, usize)> {
let repo = Repo::discover(ctx, store)?;
let stored = stored_paths(&repo, &repo.store)?;
let linked = stored
.iter()
.filter(|(_, state)| *state == Stored::Linked)
.count();
Ok((repo.store, linked, stored.len() - linked))
}
// 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)?;
line!("store: {}", repo.store.display());
if stored.is_empty() {
line!("nothing in the store for this repository");
return Ok(());
}
for (path, state) in stored {
let rel = path.strip_prefix(&repo.root).unwrap_or(&path);
let verb = match state {
Stored::Linked => "linked",
Stored::Missing => "missing",
Stored::Taken => "shadowed",
};
entry(verb, rel);
}
Ok(())
}
// one line of what the store did with a path, in the column width `status`
// prints too: the two used to set it separately and had to be kept in step
fn entry(verb: &str, path: &Path) {
line!("\t{:<11}{}", format!("{verb}:"), path.display());
}
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) {
line!("store: {}", self.store.display());
}
entry(verb, path);
}
}
fn link_one(ctx: &Ctx, 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. the
// base is resolved as well as compared: one symlinked into the checkout
// passes a prefix test while landing the file straight back inside it
if target.starts_with(&repo.root) || matches!(leads(&repo.base, &repo.root), Leads::Inside) {
return Err(anyhow!(
"the store at {} is inside the repository {}; point --store or \
AHAB_LINK_ROOT somewhere else",
repo.base.display(),
repo.root.display()
));
}
stays_in_store(repo, &rel)?;
// before anything is moved in, so the tree it lands in is never briefly
// readable by anyone else
ctx.fs().ensure_private_parent(&repo.base, &target)?;
if tracked(ctx, repo, &rel)? {
return Err(anyhow!(
"{} is tracked by git; only untracked or ignored paths can be externalized",
rel.display()
));
}
match ignored(ctx, repo, &rel) {
Ok(true) => {}
Ok(false) => warning!("{} is not gitignored", rel.display()),
// saying "not gitignored" here would be an answer git never gave
Err(e) => warning!(
"could not tell whether {} is gitignored: {e:#}",
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 = read_link(&src)?;
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 {
// moving the link moves the pointer and leaves the contents
// where they are, so the store would hold a way back out and
// check, seeing a link into the store, would call it clean
if let Leads::Outside(end) = leads(&src, &repo.root) {
return Err(anyhow!(
"{} is a symlink to {}, outside the repository; \
externalizing it would move the link and leave its \
contents there, so repoint or remove it instead",
rel.display(),
end.display()
));
}
if !src.exists() {
warning!(
"{} is a broken symlink to {}",
rel.display(),
dest.display()
);
}
move_and_link(ctx, &src, &target)?;
report.line("moved", &rel);
return Ok(());
}
if !force {
return Err(needs_force(&target));
}
ctx.fs().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()
));
}
ctx.fs().rename(&src, &backup)?;
report.line("saved", &suffixed(&rel, BACKUP_SUFFIX));
ctx.fs().place_link(&src, &target)?;
report.line("linked", &rel);
Ok(())
}
Some(_) => {
move_and_link(ctx, &src, &target)?;
report.line("moved", &rel);
Ok(())
}
None if target_taken => {
if !force {
return Err(needs_force(&target));
}
ctx.fs().place_link(&src, &target)?;
report.line("linked", &rel);
Ok(())
}
None => Err(anyhow!(
"{} does not exist and the store has no {}",
rel.display(),
target.display()
)),
}
}
// the two halves have to end up looking like one step: with the payload moved
// but no link placed, the store holds a path nothing points at and the working
// tree has lost it altogether, which is the one outcome worse than failing
fn move_and_link(ctx: &Ctx, src: &Path, target: &Path) -> Result<()> {
ctx.fs().move_path(src, target)?;
if let Err(e) = ctx.fs().place_link(src, target) {
ctx.fs().move_path(target, src).with_context(|| {
format!(
"could not put {} back after failing to link it to {}",
src.display(),
target.display()
)
})?;
return Err(e);
}
Ok(())
}
// creating the store directories follows any symlink already standing in them,
// so a store that holds one would take the move somewhere else entirely; only
// the components at or below the store are examined, since everything above it
// is outside by definition
fn stays_in_store(repo: &Repo, rel: &Path) -> Result<()> {
let mut path = repo.store.clone();
for part in rel.components() {
path.push(part);
match symlink_metadata_opt(&path)? {
// nothing here yet, so nothing below it can be followed either
None => return Ok(()),
Some(meta) if meta.is_symlink() => {
if let Leads::Outside(end) = leads(&path, &repo.store) {
return Err(anyhow!(
"the store holds {} as a symlink to {}, outside the store; \
refusing to write through it",
path.display(),
end.display()
));
}
}
Some(_) => {}
}
}
Ok(())
}
fn needs_force(target: &Path) -> anyhow::Error {
anyhow!(
"{} already exists; pass --force to link to it",
target.display()
)
}