476 lines
15 KiB
Rust
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()
|
|
)
|
|
}
|