diff --git a/README.md b/README.md index 0e6388a..a9762e5 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,9 @@ Completion files are generated **during build process** in `target/*/build/*/out ## configuration -Currently `ahab` respects three environment variables. +Currently `ahab` respects the following environment variables. - `COMPOSE_FILE`: control which docker-compose file is used - defaults to `docker/local/docker-compose.yaml` - `AHAB_DJANGO_CONTAINER`: control which compose service is used for sending django commands - defaults to `django` - `AHAB_POSTGRES_CONTAINER`: control which compose service is used for sending postgres commands - defaults to `db` +- `AHAB_LINK_ROOT`: root of the out-of-repo store `ahab link` moves paths into - defaults to `${XDG_DATA_HOME:-$HOME/.local/share}/ahab` diff --git a/src/cli/ahab.rs b/src/cli/ahab.rs index 29bc0f3..d5d0388 100644 --- a/src/cli/ahab.rs +++ b/src/cli/ahab.rs @@ -1,4 +1,4 @@ -use super::{Django, DockerCompose, Postgres}; +use super::{Django, DockerCompose, Link, Postgres}; use clap::{Parser, Subcommand}; /// A program for interacting with various dockerized applications. @@ -28,4 +28,10 @@ pub enum Commands { #[command(subcommand)] command: Postgres, }, + + /// Out-of-repo store related subcommands + Link { + #[command(subcommand)] + command: Link, + }, } diff --git a/src/cli/link.rs b/src/cli/link.rs new file mode 100644 index 0000000..d777a08 --- /dev/null +++ b/src/cli/link.rs @@ -0,0 +1,31 @@ +use std::path::PathBuf; + +use clap::Subcommand; + +#[derive(Subcommand, Debug)] +pub enum Link { + /// Move untracked paths into the store and symlink them back + Add { + /// Untracked or ignored paths inside the repository + #[arg(required = true)] + paths: Vec, + + /// Link to a store path that already exists + #[arg(long)] + force: bool, + }, + + /// List untracked paths a sandbox would still see + Check { + /// Limit the listing to these paths + paths: Vec, + + /// Print ` ` for scripts, as `git status --porcelain` does + #[arg(long)] + porcelain: bool, + + /// Terminate porcelain entries with NUL + #[arg(short = 'z')] + null: bool, + }, +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index ec7423a..016985a 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,9 +1,11 @@ mod ahab; mod django; mod docker_compose; +mod link; mod postgres; pub use ahab::{Ahab, Commands}; pub use django::Django; pub use docker_compose::DockerCompose; +pub use link::Link; pub use postgres::Postgres; diff --git a/src/main.rs b/src/main.rs index 05a3559..82782ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,5 +40,13 @@ fn main() -> Result<()> { cli::Postgres::Dump { path } => scripts::postgres::dump(&db_container, &path), } } + cli::Commands::Link { command } => match command { + cli::Link::Add { paths, force } => scripts::link::add(&paths, force), + cli::Link::Check { + paths, + porcelain, + null, + } => scripts::link::check(&paths, porcelain, null), + }, } } diff --git a/src/scripts/link.rs b/src/scripts/link.rs new file mode 100644 index 0000000..fa78bf5 --- /dev/null +++ b/src/scripts/link.rs @@ -0,0 +1,761 @@ +use std::cell::Cell; +use std::env; +use std::ffi::OsString; +use std::fs; +use std::os::unix::fs::symlink; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{anyhow, Context, Result}; + +const BACKUP_SUFFIX: &str = ".ahab-bak"; +const LOCAL_NAMESPACE: &str = "_local"; + +// move untracked paths out of the repo and symlink them back +pub fn add(paths: &[PathBuf], force: bool) -> Result<()> { + let repo = Repo::discover()?; + let report = Report::new(&repo); + + if let [path] = paths { + return link_one(&repo, path, force, &report); + } + + let mut failed = 0; + for path in paths { + if let Err(e) = link_one(&repo, path, force, &report) { + eprintln!("error: {e:#}"); + failed += 1; + } + } + + if failed > 0 { + return Err(anyhow!("{failed} of {} paths failed", paths.len())); + } + Ok(()) +} + +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) { + println!("store: {}", self.store.display()); + } + + println!("\t{:<11}{}", format!("{verb}:"), path.display()); + } +} + +fn warn(msg: impl std::fmt::Display) { + eprintln!("warning: {msg}"); +} + +// list untracked paths not in the store, i.e. what a sandbox can still read +pub fn check(paths: &[PathBuf], porcelain: bool, null: bool) -> Result<()> { + let repo = Repo::discover()?; + let pathspecs = relative_pathspecs(&repo, paths)?; + + let mut exposed = Vec::new(); + // git lists untracked and ignored separately + for (mark, ignored) in [(UNTRACKED, false), (IGNORED, true)] { + for entry in list_others(&repo, ignored, &pathspecs)? { + let rel = PathBuf::from(entry.trim_end_matches('/')); + + // --directory collapses a wholly untracked dir into `dir/` + if entry.ends_with('/') { + exposed.extend(walk(&repo, &rel, mark)?.1); + } else { + exposed.extend(classify(&repo, &rel, mark)?); + } + } + } + + exposed.sort_by(|a, b| a.name.cmp(&b.name)); + + if porcelain || null { + print_porcelain(&exposed, null); + } else { + print_listing(&repo, &exposed); + } + + Ok(()) +} + +fn print_porcelain(exposed: &[Exposed], null: bool) { + let end = if null { '\0' } else { '\n' }; + + for item in exposed { + match &item.dest { + Some(dest) => print!("{} {} -> {}{end}", item.code(), item.name, dest.display()), + None => print!("{} {}{end}", item.code(), item.name), + } + } +} + +fn print_listing(repo: &Repo, exposed: &[Exposed]) { + println!("store: {}", repo.store.display()); + + if exposed.is_empty() { + println!("nothing outside the store, a sandbox would see tracked files only"); + return; + } + + let sections = [ + ( + "Untracked paths a sandbox can read:", + " (use \"ahab link add ...\" to move them into the store)", + Section::Content(UNTRACKED), + ), + ( + "Ignored paths a sandbox can read:", + " (use \"ahab link add ...\" to move them into the store)", + Section::Content(IGNORED), + ), + ( + "Symlinks leading outside the store:", + " (their contents are not in the repository either way)", + Section::Elsewhere, + ), + ]; + + for (heading, hint, section) in sections { + let mut items = exposed.iter().filter(|item| section.holds(item)).peekable(); + if items.peek().is_none() { + continue; + } + + println!("\n{heading}\n{hint}"); + for item in items { + match &item.dest { + Some(dest) => println!("\t{} -> {}", item.name, dest.display()), + None => println!("\t{}", item.name), + } + } + } +} + +enum Section { + Content(char), + Elsewhere, +} + +impl Section { + fn holds(&self, item: &Exposed) -> bool { + match self { + Self::Content(mark) => item.dest.is_none() && item.mark == *mark, + Self::Elsewhere => item.dest.is_some(), + } + } +} + +// status codes as `git status --porcelain` spells them +const UNTRACKED: char = '?'; +const IGNORED: char = '!'; +const ELSEWHERE: char = '>'; + +struct Exposed { + mark: char, + name: String, + dest: Option, +} + +impl Exposed { + fn content(mark: char, name: String) -> Self { + Self { + mark, + name, + dest: None, + } + } + + fn code(&self) -> String { + let second = if self.dest.is_some() { + ELSEWHERE + } else { + self.mark + }; + format!("{}{second}", self.mark) + } +} + +fn classify(repo: &Repo, rel: &Path, mark: char) -> Result> { + let src = repo.root.join(rel); + let name = rel.display().to_string(); + + let Some(meta) = symlink_metadata_opt(&src)? else { + return Ok(None); + }; + if !meta.is_symlink() { + return Ok(Some(Exposed::content(mark, name))); + } + + let dest = fs::read_link(&src).with_context(|| format!("reading symlink {}", src.display()))?; + if dest == repo.store.join(rel) { + return Ok(None); + } + + Ok(Some(Exposed { + mark, + name, + dest: Some(dest), + })) +} + +fn walk(repo: &Repo, rel: &Path, mark: char) -> Result<(usize, Vec)> { + let dir = repo.root.join(rel); + let mut handled = 0; + let mut exposed = Vec::new(); + + for entry in fs::read_dir(&dir).with_context(|| format!("reading {}", dir.display()))? { + let entry = entry?; + let child = rel.join(entry.file_name()); + + if entry.file_type()?.is_dir() { + let (below, inside) = walk(repo, &child, mark)?; + handled += below; + exposed.extend(inside); + continue; + } + + match classify(repo, &child, mark)? { + Some(item) => exposed.push(item), + None => handled += 1, + } + } + + // nothing below is in the store, so collapse to one line + if handled == 0 && !exposed.is_empty() { + let name = format!("{}/", rel.display()); + return Ok((0, vec![Exposed::content(mark, name)])); + } + + Ok((handled, exposed)) +} + +fn relative_pathspecs(repo: &Repo, paths: &[PathBuf]) -> Result> { + let mut specs = Vec::with_capacity(paths.len()); + + for path in paths { + let abs = resolve(path)?; + let rel = abs.strip_prefix(&repo.root).map_err(|_| { + anyhow!( + "{} is outside the repository {}", + abs.display(), + repo.root.display() + ) + })?; + + if rel.as_os_str().is_empty() { + return Ok(Vec::new()); + } + specs.push(rel.to_path_buf()); + } + + Ok(specs) +} + +fn list_others(repo: &Repo, ignored: bool, pathspecs: &[PathBuf]) -> Result> { + let mut cmd = Command::new("git"); + cmd.arg("-C").arg(&repo.root).args([ + "ls-files", + "-z", + "--others", + "--exclude-standard", + "--directory", + "--no-empty-directory", + ]); + if ignored { + cmd.arg("--ignored"); + } + cmd.arg("--").args(pathspecs); + + let out = cmd.output().context("running git ls-files")?; + if !out.status.success() { + return Err(anyhow!( + "git ls-files failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); + } + + Ok(String::from_utf8(out.stdout)? + .split('\0') + .filter(|p| !p.is_empty()) + .map(String::from) + .collect()) +} + +struct Repo { + root: PathBuf, + store: PathBuf, +} + +impl Repo { + fn discover() -> Result { + let root = git_root()?; + let store = store_root()?.join(repo_components(&root)?); + Ok(Self { root, store }) + } + + fn relative(&self, src: &Path) -> Result { + let rel = src.strip_prefix(&self.root).map_err(|_| { + anyhow!( + "{} is outside the repository {}", + src.display(), + self.root.display() + ) + })?; + + // empty means the whole repo + if rel.as_os_str().is_empty() { + return Err(anyhow!( + "refusing to externalize the repository root itself" + )); + } + if rel.starts_with(".git") { + return Err(anyhow!("refusing to externalize anything under .git")); + } + + Ok(rel.to_path_buf()) + } +} + +fn link_one(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 + if target.starts_with(&repo.root) { + return Err(anyhow!( + "target {} is inside the repository; point AHAB_LINK_ROOT elsewhere", + target.display() + )); + } + + if tracked(repo, &rel)? { + return Err(anyhow!( + "{} is tracked by git; only untracked or ignored paths can be externalized", + rel.display() + )); + } + if !ignored(repo, &rel) { + warn(format!("{} is not gitignored", 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 = fs::read_link(&src) + .with_context(|| format!("reading symlink {}", src.display()))?; + + 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 { + if !src.exists() { + warn(format!( + "{} is a broken symlink to {}", + rel.display(), + dest.display() + )); + } + move_out(&src, &target)?; + place_link(&src, &target)?; + report.line("moved", &rel); + return Ok(()); + } + + if !force { + return Err(needs_force(&target)); + } + + 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() + )); + } + + fs::rename(&src, &backup) + .with_context(|| format!("renaming {} to {}", src.display(), backup.display()))?; + report.line("saved", &suffixed(&rel, BACKUP_SUFFIX)); + + place_link(&src, &target)?; + report.line("linked", &rel); + Ok(()) + } + + Some(_) => { + move_out(&src, &target)?; + place_link(&src, &target)?; + report.line("moved", &rel); + Ok(()) + } + + None if target_taken => { + if !force { + return Err(needs_force(&target)); + } + place_link(&src, &target)?; + report.line("linked", &rel); + Ok(()) + } + + None => Err(anyhow!( + "{} does not exist and the store has no {}", + rel.display(), + target.display() + )), + } +} + +fn needs_force(target: &Path) -> anyhow::Error { + anyhow!( + "{} already exists; pass --force to link to it", + target.display() + ) +} + +fn resolve(path: &Path) -> Result { + let abs = std::path::absolute(path) + .with_context(|| format!("resolving absolute path of {}", path.display()))?; + + // the parent must exist so symlinked components resolve like git's toplevel + let Some(name) = abs.file_name().map(OsString::from) else { + return fs::canonicalize(&abs).with_context(|| format!("resolving {}", abs.display())); + }; + let parent = abs.parent().unwrap_or(Path::new("/")); + + let parent = fs::canonicalize(parent) + .with_context(|| format!("resolving directory {}", parent.display()))?; + Ok(parent.join(name)) +} + +fn git_root() -> Result { + let out = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .output() + .context("running git")?; + + if !out.status.success() { + return Err(anyhow!("not inside a git repository")); + } + + let root = String::from_utf8(out.stdout)?.trim().to_string(); + if root.is_empty() { + return Err(anyhow!("git reported an empty repository root")); + } + + fs::canonicalize(&root).with_context(|| format!("resolving {root}")) +} + +fn repo_components(root: &Path) -> Result { + if let Some(url) = git_origin_url() { + if let Some(components) = components_from_remote(&url) { + return Ok(components); + } + eprintln!("could not parse git remote `{url}`, falling back to the checkout name"); + } + + let name = root + .file_name() + .ok_or_else(|| anyhow!("cannot derive a store path for {}", root.display()))?; + Ok(Path::new(LOCAL_NAMESPACE).join(sanitize(&name.to_string_lossy()))) +} + +fn git_origin_url() -> Option { + let out = Command::new("git") + .args(["config", "--get", "remote.origin.url"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + + let url = String::from_utf8(out.stdout).ok()?.trim().to_string(); + (!url.is_empty()).then_some(url) +} + +fn components_from_remote(url: &str) -> Option { + let url = url.trim(); + let url = url.strip_suffix(".git").unwrap_or(url); + + // `scheme://[user@]host[:port]/path`, or scp-like `[user@]host:path` + let (authority, path) = match url.split_once("://") { + Some((_, after)) => after.split_once('/')?, + None => url.split_once(':')?, + }; + + let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h); + let host = host.split_once(':').map_or(host, |(h, _)| h); + // a local remote has no host to key on + if host.is_empty() { + return None; + } + + let mut components = PathBuf::from(sanitize(&host.to_lowercase())); + let mut depth = 0; + for part in path.split('/').filter(|p| !p.is_empty()) { + components.push(sanitize(part)); + depth += 1; + } + (depth > 0).then_some(components) +} + +fn sanitize(s: &str) -> String { + let out: String = s + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_') { + c + } else { + '_' + } + }) + .collect(); + + // `.` and `..` are legal characters but not legal components + if out.chars().all(|c| c == '.') { + return "_".repeat(out.len()); + } + out +} + +fn store_root() -> Result { + if let Some(root) = non_empty_var("AHAB_LINK_ROOT") { + return Ok(PathBuf::from(root)); + } + if let Some(xdg) = non_empty_var("XDG_DATA_HOME") { + return Ok(PathBuf::from(xdg).join("ahab")); + } + + let home = non_empty_var("HOME") + .ok_or_else(|| anyhow!("none of AHAB_LINK_ROOT, XDG_DATA_HOME or HOME is set"))?; + Ok(PathBuf::from(home).join(".local/share/ahab")) +} + +fn non_empty_var(name: &str) -> Option { + env::var_os(name).filter(|v| !v.is_empty()) +} + +fn tracked(repo: &Repo, rel: &Path) -> Result { + let out = Command::new("git") + .arg("-C") + .arg(&repo.root) + .args(["ls-files", "--cached", "--"]) + .arg(rel) + .output() + .context("running git ls-files")?; + + if !out.status.success() { + return Err(anyhow!( + "git ls-files failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); + } + Ok(!out.stdout.is_empty()) +} + +fn ignored(repo: &Repo, rel: &Path) -> bool { + Command::new("git") + .arg("-C") + .arg(&repo.root) + .args(["check-ignore", "-q", "--"]) + .arg(rel) + .status() + .is_ok_and(|s| s.success()) +} + +fn symlink_metadata_opt(path: &Path) -> Result> { + // symlink_metadata does not follow the link, so a symlink shows as one + match fs::symlink_metadata(path) { + Ok(meta) => Ok(Some(meta)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e).with_context(|| format!("inspecting {}", path.display())), + } +} + +fn move_out(src: &Path, target: &Path) -> Result<()> { + ensure_parent(target)?; + + // rename cannot cross filesystems, and the store often is another one + match fs::rename(src, target) { + Ok(()) => Ok(()), + Err(rename_err) => match copy_recursive(src, target) { + Ok(()) => remove_recursive(src), + Err(copy_err) => Err(copy_err).with_context(|| { + format!( + "moving {} to {} (rename failed: {rename_err})", + src.display(), + target.display() + ) + }), + }, + } +} + +fn copy_recursive(src: &Path, target: &Path) -> Result<()> { + let meta = + fs::symlink_metadata(src).with_context(|| format!("inspecting {}", src.display()))?; + + if meta.is_dir() { + fs::create_dir_all(target) + .with_context(|| format!("creating directory {}", target.display()))?; + for entry in + fs::read_dir(src).with_context(|| format!("reading directory {}", src.display()))? + { + let entry = entry?; + copy_recursive(&entry.path(), &target.join(entry.file_name()))?; + } + return Ok(()); + } + + if meta.is_symlink() { + let dest = fs::read_link(src)?; + return symlink(&dest, target) + .with_context(|| format!("creating symlink {}", target.display())); + } + + fs::copy(src, target) + .with_context(|| format!("copying {} to {}", src.display(), target.display()))?; + Ok(()) +} + +fn remove_recursive(path: &Path) -> Result<()> { + let meta = + fs::symlink_metadata(path).with_context(|| format!("inspecting {}", path.display()))?; + + if meta.is_dir() { + fs::remove_dir_all(path) + } else { + fs::remove_file(path) + } + .with_context(|| format!("removing {}", path.display())) +} + +fn ensure_parent(target: &Path) -> Result<()> { + if let Some(parent) = target.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent) + .with_context(|| format!("creating directory {}", parent.display()))?; + } + } + Ok(()) +} + +fn place_link(link_path: &Path, target: &Path) -> Result<()> { + // symlink under a temp name and rename over the path: the rename is atomic + let tmp = suffixed(link_path, ".ahab-tmp"); + let _ = fs::remove_file(&tmp); + + symlink(target, &tmp) + .with_context(|| format!("creating symlink {} -> {}", tmp.display(), target.display()))?; + fs::rename(&tmp, link_path) + .with_context(|| format!("replacing {} with a symlink", link_path.display()))?; + + Ok(()) +} + +fn suffixed(path: &Path, suffix: &str) -> PathBuf { + let mut out = path.as_os_str().to_owned(); + out.push(suffix); + PathBuf::from(out) +} + +#[cfg(test)] +mod tests { + use super::{components_from_remote, sanitize}; + use std::path::PathBuf; + + #[test] + fn parses_every_spelling_of_a_remote() { + let cases = [ + ( + "git@git.aflabs.org:urnik/afurnik.git", + "git.aflabs.org/urnik/afurnik", + ), + ( + "https://git.aflabs.org/urnik/afurnik.git", + "git.aflabs.org/urnik/afurnik", + ), + ( + "https://git.aflabs.org/urnik/afurnik", + "git.aflabs.org/urnik/afurnik", + ), + ( + "https://git.aflabs.org/urnik/afurnik/", + "git.aflabs.org/urnik/afurnik", + ), + ( + "ssh://git@git.aflabs.org:22/urnik/afurnik.git", + "git.aflabs.org/urnik/afurnik", + ), + ( + "git@GIT.Aflabs.org:urnik/AFurnik.git", + "git.aflabs.org/urnik/AFurnik", + ), + ( + "git@git.aflabs.org:urnik/internal/afurnik.git", + "git.aflabs.org/urnik/internal/afurnik", + ), + ]; + + for (url, want) in cases { + assert_eq!( + components_from_remote(url), + Some(PathBuf::from(want)), + "url: {url}" + ); + } + } + + #[test] + fn rejects_remotes_without_a_host() { + assert_eq!(components_from_remote("not-a-url"), None); + assert_eq!(components_from_remote("/srv/git/afurnik.git"), None); + assert_eq!(components_from_remote("file:///srv/git/afurnik.git"), None); + assert_eq!(components_from_remote("https://git.aflabs.org/"), None); + } + + #[test] + fn sanitize_never_yields_a_traversal() { + assert_eq!(sanitize(".."), "__"); + assert_eq!(sanitize("."), "_"); + assert_eq!(sanitize("a/b"), "a_b"); + assert_eq!(sanitize(".env"), ".env"); + } +} diff --git a/src/scripts/mod.rs b/src/scripts/mod.rs index d9e926b..e977d24 100644 --- a/src/scripts/mod.rs +++ b/src/scripts/mod.rs @@ -1,4 +1,5 @@ pub mod django; pub mod docker; pub mod docker_compose; +pub mod link; pub mod postgres;