diff --git a/README.md b/README.md index 826fc5a..c9a693a 100644 --- a/README.md +++ b/README.md @@ -116,10 +116,16 @@ dangling symlink instead of the contents, while the host resolves it as before. ```bash ahab link add .env secrets/ # move out, leave symlinks behind +ahab link restore .env # move it back into the repository +ahab link restore --all # everything this repo has in the store ahab link check # what a sandbox can still read ahab link check --porcelain # ` `, for scripts ``` +`restore` is the inverse of `add`: the file moves out of the store and back to +where it was, and the store keeps nothing. A second checkout linking the same +path is left with a dangling symlink, since there is only ever one stored copy. + The store lives under `${XDG_DATA_HOME:-$HOME/.local/share}/ahab////`, derived from the git `origin` remote. diff --git a/src/cli/link.rs b/src/cli/link.rs index 58d29de..c39d511 100644 --- a/src/cli/link.rs +++ b/src/cli/link.rs @@ -15,6 +15,15 @@ pub enum Link { force: bool, }, + /// Move paths in the store back into the repository + Restore { + paths: Vec, + + /// Restore every path this repository has in the store + #[arg(long)] + all: bool, + }, + /// List untracked paths a sandbox would still see Check { /// Limit the listing to these paths diff --git a/src/main.rs b/src/main.rs index 3a020eb..892bdd9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,7 @@ fn main() -> Result<()> { }, cli::Commands::Link { command } => match command { cli::Link::Add { paths, force } => scripts::link::add(&paths, force), + cli::Link::Restore { paths, all } => scripts::link::restore(&paths, all), cli::Link::Check { paths, porcelain, diff --git a/src/scripts/link.rs b/src/scripts/link.rs index 83e2619..504da76 100644 --- a/src/scripts/link.rs +++ b/src/scripts/link.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; use std::process; use std::process::Command; -use anyhow::{Context, Result, anyhow}; +use anyhow::{Context, Result, anyhow, bail}; const BACKUP_SUFFIX: &str = ".ahab-bak"; const LOCAL_NAMESPACE: &str = "_local"; @@ -36,6 +36,121 @@ pub fn add(paths: &[PathBuf], force: bool) -> Result<()> { Ok(()) } +// move paths in the store back into the repo, the inverse of add +pub fn restore(paths: &[PathBuf], all: bool) -> Result<()> { + let repo = Repo::discover()?; + let report = Report::new(&repo); + + let paths = match (all, paths) { + (true, []) => linked_paths(&repo, &repo.store)?, + (true, _) => bail!("--all restores everything, so it takes no paths"), + (false, []) => bail!("name a path to restore, or pass --all"), + (false, paths) => paths.to_vec(), + }; + + if paths.is_empty() { + eprintln!("nothing in the store for this repository"); + return Ok(()); + } + + if let [path] = paths.as_slice() { + return restore_one(&repo, path, &report); + } + + let mut failed = 0; + for path in &paths { + if let Err(e) = restore_one(&repo, path, &report) { + eprintln!("error: {e:#}"); + failed += 1; + } + } + + if failed > 0 { + return Err(anyhow!("{failed} of {} paths failed", paths.len())); + } + Ok(()) +} + +fn restore_one(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 = fs::read_link(&src).with_context(|| format!("reading {}", src.display()))?; + 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()); + } + + fs::remove_file(&src).with_context(|| format!("removing {}", src.display()))?; + move_path(&stored, &src)?; + prune_empty(stored.parent(), &store_root()?); + + report.line("restored", &rel); + Ok(()) +} + +// the store mirrors the repository layout, so walking it finds every path this +// repository has linked without asking git anything +fn linked_paths(repo: &Repo, dir: &Path) -> Result> { + let mut found = Vec::new(); + + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(found), + Err(e) => return Err(e).with_context(|| format!("reading {}", dir.display())), + }; + + for entry in entries { + let stored = entry?.path(); + let rel = stored + .strip_prefix(&repo.store) + .expect("walked out of the store"); + let src = repo.root.join(rel); + + let linked = symlink_metadata_opt(&src)?.is_some_and(|meta| meta.is_symlink()) + && fs::read_link(&src).is_ok_and(|dest| dest == stored); + + if linked { + found.push(src); + } else if stored.is_dir() { + found.extend(linked_paths(repo, &stored)?); + } + } + + found.sort(); + Ok(found) +} + +// a restored path can leave the store holding nothing but empty directories +fn prune_empty(dir: Option<&Path>, stop: &Path) { + let mut dir = dir; + + while let Some(path) = dir { + if path == stop || !path.starts_with(stop) || fs::remove_dir(path).is_err() { + return; + } + + dir = path.parent(); + } +} + struct Report { store: PathBuf, named: Cell, @@ -390,7 +505,7 @@ fn link_one(repo: &Repo, path: &Path, force: bool, report: &Report) -> Result<() dest.display() )); } - move_out(&src, &target)?; + move_path(&src, &target)?; place_link(&src, &target)?; report.line("moved", &rel); return Ok(()); @@ -428,7 +543,7 @@ fn link_one(repo: &Repo, path: &Path, force: bool, report: &Report) -> Result<() } Some(_) => { - move_out(&src, &target)?; + move_path(&src, &target)?; place_link(&src, &target)?; report.line("moved", &rel); Ok(()) @@ -617,7 +732,7 @@ fn symlink_metadata_opt(path: &Path) -> Result> { } } -fn move_out(src: &Path, target: &Path) -> Result<()> { +fn move_path(src: &Path, target: &Path) -> Result<()> { ensure_parent(target)?; // rename cannot cross filesystems, and the store often is another one