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 = 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> { 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, } 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() ) }