feat: add link restore
This commit is contained in:
@@ -116,10 +116,16 @@ dangling symlink instead of the contents, while the host resolves it as before.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
ahab link add .env secrets/ # move out, leave symlinks behind
|
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 # what a sandbox can still read
|
||||||
ahab link check --porcelain # `<code> <path>`, for scripts
|
ahab link check --porcelain # `<code> <path>`, 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
|
The store lives under
|
||||||
`${XDG_DATA_HOME:-$HOME/.local/share}/ahab/<host>/<owner>/<repo>/`, derived
|
`${XDG_DATA_HOME:-$HOME/.local/share}/ahab/<host>/<owner>/<repo>/`, derived
|
||||||
from the git `origin` remote.
|
from the git `origin` remote.
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ pub enum Link {
|
|||||||
force: bool,
|
force: bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Move paths in the store back into the repository
|
||||||
|
Restore {
|
||||||
|
paths: Vec<PathBuf>,
|
||||||
|
|
||||||
|
/// Restore every path this repository has in the store
|
||||||
|
#[arg(long)]
|
||||||
|
all: bool,
|
||||||
|
},
|
||||||
|
|
||||||
/// List untracked paths a sandbox would still see
|
/// List untracked paths a sandbox would still see
|
||||||
Check {
|
Check {
|
||||||
/// Limit the listing to these paths
|
/// Limit the listing to these paths
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ fn main() -> Result<()> {
|
|||||||
},
|
},
|
||||||
cli::Commands::Link { command } => match command {
|
cli::Commands::Link { command } => match command {
|
||||||
cli::Link::Add { paths, force } => scripts::link::add(&paths, force),
|
cli::Link::Add { paths, force } => scripts::link::add(&paths, force),
|
||||||
|
cli::Link::Restore { paths, all } => scripts::link::restore(&paths, all),
|
||||||
cli::Link::Check {
|
cli::Link::Check {
|
||||||
paths,
|
paths,
|
||||||
porcelain,
|
porcelain,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::process;
|
use std::process;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
|
|
||||||
const BACKUP_SUFFIX: &str = ".ahab-bak";
|
const BACKUP_SUFFIX: &str = ".ahab-bak";
|
||||||
const LOCAL_NAMESPACE: &str = "_local";
|
const LOCAL_NAMESPACE: &str = "_local";
|
||||||
@@ -36,6 +36,121 @@ pub fn add(paths: &[PathBuf], force: bool) -> Result<()> {
|
|||||||
Ok(())
|
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<Vec<PathBuf>> {
|
||||||
|
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 {
|
struct Report {
|
||||||
store: PathBuf,
|
store: PathBuf,
|
||||||
named: Cell<bool>,
|
named: Cell<bool>,
|
||||||
@@ -390,7 +505,7 @@ fn link_one(repo: &Repo, path: &Path, force: bool, report: &Report) -> Result<()
|
|||||||
dest.display()
|
dest.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
move_out(&src, &target)?;
|
move_path(&src, &target)?;
|
||||||
place_link(&src, &target)?;
|
place_link(&src, &target)?;
|
||||||
report.line("moved", &rel);
|
report.line("moved", &rel);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -428,7 +543,7 @@ fn link_one(repo: &Repo, path: &Path, force: bool, report: &Report) -> Result<()
|
|||||||
}
|
}
|
||||||
|
|
||||||
Some(_) => {
|
Some(_) => {
|
||||||
move_out(&src, &target)?;
|
move_path(&src, &target)?;
|
||||||
place_link(&src, &target)?;
|
place_link(&src, &target)?;
|
||||||
report.line("moved", &rel);
|
report.line("moved", &rel);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -617,7 +732,7 @@ fn symlink_metadata_opt(path: &Path) -> Result<Option<fs::Metadata>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn move_out(src: &Path, target: &Path) -> Result<()> {
|
fn move_path(src: &Path, target: &Path) -> Result<()> {
|
||||||
ensure_parent(target)?;
|
ensure_parent(target)?;
|
||||||
|
|
||||||
// rename cannot cross filesystems, and the store often is another one
|
// rename cannot cross filesystems, and the store often is another one
|
||||||
|
|||||||
Reference in New Issue
Block a user