diff --git a/src/scripts/completions.rs b/src/commands/completions.rs similarity index 100% rename from src/scripts/completions.rs rename to src/commands/completions.rs diff --git a/src/scripts/django.rs b/src/commands/django.rs similarity index 87% rename from src/scripts/django.rs rename to src/commands/django.rs index 488e56c..704e200 100644 --- a/src/scripts/django.rs +++ b/src/commands/django.rs @@ -3,10 +3,10 @@ use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; use crate::cmd::{Bash, Cmd, Manage, Words}; -use crate::compose::Compose; use crate::ctx::Ctx; use crate::fsops::{create_dir, touch, write_new}; use crate::output::note; +use crate::project::Project; const DEBUG_TEMPLATE: &str = r#"from django.core.management.base import BaseCommand @@ -20,8 +20,7 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> { let app_name = app.to_string_lossy(); let app_dir = Path::new(&app); - let not_app_exists = !app_dir.is_dir(); - if not_app_exists { + if !app_dir.is_dir() { return Err(anyhow!("directory {app_name} does not exist")); } @@ -29,8 +28,7 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> { let management_dir = app_dir.join("management"); - let not_management_exists = !management_dir.exists(); - if not_management_exists { + if !management_dir.exists() { create_dir(ctx, &management_dir)?; touch(ctx, &management_dir.join("__init__.py"))?; @@ -39,8 +37,7 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> { let commands_dir = management_dir.join("commands"); - let not_commands_exists = !commands_dir.exists(); - if not_commands_exists { + if !commands_dir.exists() { create_dir(ctx, &commands_dir)?; touch(ctx, &commands_dir.join("__init__.py"))?; @@ -90,5 +87,5 @@ pub fn test(ctx: &Ctx) -> Result<()> { } fn service(ctx: &Ctx) -> Result { - Compose::resolve(ctx)?.django() + Project::resolve(ctx)?.django() } diff --git a/src/commands/link.rs b/src/commands/link.rs new file mode 100644 index 0000000..d2a91df --- /dev/null +++ b/src/commands/link.rs @@ -0,0 +1,284 @@ +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::{Result, anyhow, bail}; + +use self::store::{Repo, ignored, resolve, symlink_metadata_opt, tracked}; +use crate::ctx::Ctx; +use crate::fsops::{move_path, place_link, prune_empty, remove_file, rename, suffixed}; +use crate::output::{note, warning}; + +const BACKUP_SUFFIX: &str = ".ahab-bak"; + +// move untracked paths out of the repo and symlink them back +pub fn add(ctx: &Ctx, paths: &[PathBuf], force: bool, store: Option<&Path>) -> Result<()> { + let repo = Repo::discover(ctx, store)?; + let report = Report::new(&repo); + + if let [path] = paths { + return link_one(ctx, &repo, path, force, &report); + } + + let mut failed = 0; + for path in paths { + if let Err(e) = link_one(ctx, &repo, path, force, &report) { + note!("error: {e:#}"); + failed += 1; + } + } + + if failed > 0 { + return Err(anyhow!("{failed} of {} paths failed", paths.len())); + } + Ok(()) +} + +// move paths in the store back into the repo, the inverse of add +pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) -> Result<()> { + let repo = Repo::discover(ctx, store)?; + 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() { + note!("nothing in the store for this repository"); + return Ok(()); + } + + if let [path] = paths.as_slice() { + return restore_one(ctx, &repo, path, &report); + } + + let mut failed = 0; + for path in &paths { + if let Err(e) = restore_one(ctx, &repo, path, &report) { + note!("error: {e:#}"); + failed += 1; + } + } + + if failed > 0 { + return Err(anyhow!("{failed} of {} paths failed", paths.len())); + } + Ok(()) +} + +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()); + } + + remove_file(ctx, &src)?; + move_path(ctx, &stored, &src)?; + prune_empty(ctx, stored.parent(), &repo.base); + + 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 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) + .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()) + && 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) +} + +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()); + } +} + +// list untracked paths not in the store, i.e. what a sandbox can still read +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 + if target.starts_with(&repo.root) { + return Err(anyhow!( + "target {} is inside the repository; point AHAB_LINK_ROOT elsewhere", + target.display() + )); + } + + if tracked(ctx, repo, &rel)? { + return Err(anyhow!( + "{} is tracked by git; only untracked or ignored paths can be externalized", + rel.display() + )); + } + if !ignored(ctx, repo, &rel) { + warning!("{} 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 = 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 { + if !src.exists() { + warning!( + "{} is a broken symlink to {}", + rel.display(), + dest.display() + ); + } + move_path(ctx, &src, &target)?; + place_link(ctx, &src, &target)?; + report.line("moved", &rel); + return Ok(()); + } + + if !force { + return Err(needs_force(&target)); + } + + place_link(ctx, &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() + )); + } + + rename(ctx, &src, &backup)?; + report.line("saved", &suffixed(&rel, BACKUP_SUFFIX)); + + place_link(ctx, &src, &target)?; + report.line("linked", &rel); + Ok(()) + } + + Some(_) => { + move_path(ctx, &src, &target)?; + place_link(ctx, &src, &target)?; + report.line("moved", &rel); + Ok(()) + } + + None if target_taken => { + if !force { + return Err(needs_force(&target)); + } + place_link(ctx, &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() + ) +} diff --git a/src/commands/link/check.rs b/src/commands/link/check.rs new file mode 100644 index 0000000..637431d --- /dev/null +++ b/src/commands/link/check.rs @@ -0,0 +1,243 @@ +use fs_err::{read_dir, read_link}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use anyhow::{Result, anyhow}; + +use super::store::{Repo, resolve, symlink_metadata_opt}; +use crate::cmd::{Cmd, LsFiles}; +use crate::ctx::Ctx; + +pub fn check( + ctx: &Ctx, + paths: &[PathBuf], + porcelain: bool, + null: bool, + exit_code: bool, + store: Option<&Path>, +) -> Result { + let repo = Repo::discover(ctx, store)?; + 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(ctx, &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); + } + + // git's --exit-code convention: nothing to report is 0, anything is 1 + if exit_code && !exposed.is_empty() { + return Ok(ExitCode::FAILURE); + } + + Ok(ExitCode::SUCCESS) +} + +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 = read_link(&src)?; + 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 read_dir(&dir)? { + 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( + ctx: &Ctx, + repo: &Repo, + ignored: bool, + pathspecs: &[PathBuf], +) -> Result> { + let mut listing = LsFiles::untracked(&repo.root).limited_to(pathspecs); + if ignored { + listing = listing.ignored(); + } + + Ok(listing + .capture(ctx)? + .split('\0') + .filter(|p| !p.is_empty()) + .map(String::from) + .collect()) +} diff --git a/src/commands/link/store.rs b/src/commands/link/store.rs new file mode 100644 index 0000000..5683c22 --- /dev/null +++ b/src/commands/link/store.rs @@ -0,0 +1,254 @@ +use fs_err as fs; +use std::env; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow}; + +use crate::cmd::{CheckIgnore, Cmd, ConfigGet, LsFiles, RevParse}; +use crate::ctx::Ctx; +use crate::output::note; + +// a checkout with no remote to name it after +const LOCAL_NAMESPACE: &str = "_local"; + +pub(super) struct Repo { + pub(super) root: PathBuf, + pub(super) store: PathBuf, + // the configured store root, above the per-repository directories + pub(super) base: PathBuf, +} + +impl Repo { + pub(super) fn discover(ctx: &Ctx, store: Option<&Path>) -> Result { + let root = git_root(ctx)?; + let base = match store { + Some(store) => store.to_path_buf(), + None => store_root()?, + }; + + Ok(Self { + store: base.join(repo_components(ctx, &root)?), + root, + base, + }) + } + pub(super) 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()) + } +} + +pub(super) 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 Ok(fs::canonicalize(&abs)?); + }; + let parent = fs::canonicalize(abs.parent().unwrap_or(Path::new("/")))?; + Ok(parent.join(name)) +} + +fn git_root(ctx: &Ctx) -> Result { + let root = RevParse + .capture(ctx) + .map_err(|_| anyhow!("not inside a git repository"))?; + + let root = root.trim().to_string(); + if root.is_empty() { + return Err(anyhow!("git reported an empty repository root")); + } + + Ok(fs::canonicalize(&root)?) +} + +fn repo_components(ctx: &Ctx, root: &Path) -> Result { + if let Some(url) = git_origin_url(ctx) { + if let Some(components) = components_from_remote(&url) { + return Ok(components); + } + note!("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(ctx: &Ctx) -> Option { + let url = ConfigGet { + key: "remote.origin.url", + } + .capture(ctx) + .ok()?; + + let url = url.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(xdg) = non_empty_var("XDG_DATA_HOME") { + return Ok(PathBuf::from(xdg).join("ahab")); + } + + let home = non_empty_var("HOME") + .filter(|v| !v.is_empty()) + .ok_or_else(|| anyhow!("neither XDG_DATA_HOME nor 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()) +} + +pub(super) fn tracked(ctx: &Ctx, repo: &Repo, rel: &Path) -> Result { + let listed = LsFiles::tracked(&repo.root) + .limited_to(&[rel]) + .capture(ctx)?; + + Ok(!listed.is_empty()) +} + +pub(super) fn ignored(ctx: &Ctx, repo: &Repo, rel: &Path) -> bool { + CheckIgnore::new(&repo.root, rel) + .quietly_succeeds(ctx) + .unwrap_or(false) +} + +pub(super) 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.into()), + } +} + +#[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/commands/mod.rs similarity index 100% rename from src/scripts/mod.rs rename to src/commands/mod.rs diff --git a/src/scripts/postgres.rs b/src/commands/postgres.rs similarity index 60% rename from src/scripts/postgres.rs rename to src/commands/postgres.rs index 7e5bde4..3ef52cf 100644 --- a/src/scripts/postgres.rs +++ b/src/commands/postgres.rs @@ -1,129 +1,30 @@ -use anyhow::{Context, Result, anyhow, bail}; -use std::{ - fs::File, - io::{self, Read, Write}, - path::Path, - process::Stdio, - thread, - time::{Duration, Instant}, -}; +mod server; +mod shape; +use fs_err::File; +use std::io::{self, Write}; +use std::path::Path; +use std::process::Stdio; + +use anyhow::{Context, Result, bail}; + +use self::server::{Database, wait_until_ready, when_ready}; +use self::shape::{Dump, HEADER_LEN, Kind}; use crate::cli::Format; use crate::cmd::{ - Cmd, Cp, CreateDb, DropDb, Gunzip, Gzip, Head, PgDump, PgDumpAll, PgIsReady, PgRestore, Ps, - Psql, Rm, Start, Stop, Up, + Cmd, Cp, CreateDb, DropDb, Gunzip, Gzip, Head, PgDump, PgDumpAll, PgRestore, Rm, Start, Stop, + Up, }; -use crate::compose::Compose; use crate::ctx::Ctx; use crate::fsops::{remove_file, rename, suffixed}; use crate::output::note; -const CUSTOM_MAGIC: &[u8] = b"PGDMP"; -const TAR_MAGIC: &[u8] = b"toc.dat"; -const GZIP_MAGIC: &[u8] = b"\x1f\x8b"; -const CLUSTER_MARKER: &str = "PostgreSQL database cluster dump"; -const READY_TIMEOUT: Duration = Duration::from_secs(60); -const POLL_INTERVAL: Duration = Duration::from_secs(1); - -// wide enough for the cluster marker, which sits a few bytes into the file -const HEADER_LEN: usize = 512; - // unique per run: docker cp will not copy a directory over an existing path, and // quietly leaves whatever was there for pg_restore to read instead fn remote_dump() -> String { format!("/tmp/ahab-dump-{}", std::process::id()) } -struct Database { - service: String, - container: String, - user: String, - name: String, -} - -impl Database { - fn resolve(ctx: &Ctx) -> Result { - let compose = Compose::resolve(ctx)?; - let service = compose.postgres()?; - let (user, name) = compose.postgres_credentials(&service); - - let container = Ps::id_of(&service).capture(ctx)?.trim().to_string(); - - if container.is_empty() { - return Err(anyhow!("service {service} has no running container")); - } - - Ok(Self { - service, - container, - user, - name, - }) - } - - // what reads this shape of dump back in - fn restore_with(&self, kind: Kind) -> Box { - match kind { - Kind::Archive => Box::new(PgRestore::new(&self.user, &self.name)), - Kind::Sql => Box::new(Psql::new(&self.user, &self.name).atomic()), - Kind::Cluster => Box::new(Psql::new(&self.user, "postgres")), - } - } -} - -enum Dump { - Directory, - Gzip, - Header(Vec), -} - -impl Dump { - fn of(path: &Path) -> Result { - if path.is_dir() { - return Ok(Self::Directory); - } - - let header = read_header(path)?; - if header.starts_with(GZIP_MAGIC) { - return Ok(Self::Gzip); - } - - Ok(Self::Header(header)) - } -} - -// how the dump has to be fed back in: pg_restore for an archive, psql into the -// database for a single database dump, psql into postgres for a whole cluster -#[derive(Clone, Copy, PartialEq)] -enum Kind { - Archive, - Sql, - Cluster, -} - -impl Kind { - fn of(header: &[u8]) -> Self { - if header.starts_with(CUSTOM_MAGIC) || header.starts_with(TAR_MAGIC) { - return Self::Archive; - } - - if String::from_utf8_lossy(header).contains(CLUSTER_MARKER) { - return Self::Cluster; - } - - Self::Sql - } - - fn tool(&self) -> &'static str { - match self { - Self::Archive => "pg_restore", - Self::Sql | Self::Cluster => "psql", - } - } -} - -// pg_dumpall recreates roles the cluster already has, so this is the expected -// shape of a working restore rather than a problem fn is_existing_role_error(line: &str) -> bool { line.starts_with("ERROR:") && line.contains("role \"") && line.ends_with("already exists") } @@ -161,54 +62,6 @@ fn restore_cluster(ctx: &Ctx, db: &Database, restore: &dyn Cmd, file: &Path) -> Ok(()) } -fn read_header(path: &Path) -> Result> { - let mut file = File::open(path).with_context(|| format!("opening {}", path.display()))?; - let mut header = vec![0; HEADER_LEN]; - - let read = file - .read(&mut header) - .with_context(|| format!("reading {}", path.display()))?; - header.truncate(read); - - Ok(header) -} - -fn wait_until_ready(ctx: &Ctx, db: &Database) -> Result<()> { - if ctx.dry_run { - return Ok(()); - } - - let deadline = Instant::now() + READY_TIMEOUT; - - loop { - let ready = PgIsReady { - username: &db.user, - dbname: &db.name, - } - .in_container(&db.container) - .quietly_succeeds(ctx)?; - - if ready { - return Ok(()); - } - - if Instant::now() >= deadline { - bail!( - "{} did not accept connections within {} seconds", - db.service, - READY_TIMEOUT.as_secs() - ); - } - - thread::sleep(POLL_INTERVAL); - } -} - -fn when_ready(ctx: &Ctx, db: &Database, command: &dyn Cmd) -> Result<()> { - wait_until_ready(ctx, db)?; - command.run(ctx) -} - pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { let dump = Dump::of(file)?; let db = Database::resolve(ctx)?; @@ -265,7 +118,9 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { } }; - let tool = kind.tool(); + let restore = db.restore_with(kind); + // the name of what actually runs, so the message cannot drift from it + let tool = restore.argv().program().to_string(); note!("restoring database with {tool}"); when_ready( @@ -309,7 +164,6 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { bail!("{tool} failed, the database is left empty"); } } else { - let restore = db.restore_with(kind); let restore: Box = match dump { Dump::Gzip => Box::new(Gunzip.pipe(&*restore)), _ => restore, @@ -363,9 +217,7 @@ pub fn dump(ctx: &Ctx, file: &Path, format: Format, gzip: bool) -> Result<()> { let stdout = if ctx.dry_run { Stdio::null() } else { - Stdio::from( - File::create(&partial).with_context(|| format!("creating {}", partial.display()))?, - ) + Stdio::from(std::fs::File::from(File::create(&partial)?)) }; let dumping = dump_command(&db, format); @@ -424,7 +276,7 @@ fn dump_directory(ctx: &Ctx, db: &Database, target: &Path) -> Result<()> { #[cfg(test)] mod tests { - use super::{GZIP_MAGIC, Kind, is_existing_role_error}; + use super::is_existing_role_error; #[test] fn only_the_existing_role_complaint_is_expected() { @@ -441,35 +293,4 @@ mod tests { assert!(!is_existing_role_error(line), "{line}"); } } - - #[test] - fn an_archive_is_recognised_by_its_magic() { - assert!(matches!(Kind::of(b"PGDMP\x01\x0f"), Kind::Archive)); - assert!(matches!(Kind::of(b"toc.dat\x00\x00"), Kind::Archive)); - } - - #[test] - fn a_cluster_dump_is_recognised_by_its_header() { - let header = b"--\n-- PostgreSQL database cluster dump\n--\n\n\\restrict abc\n"; - assert!(matches!(Kind::of(header), Kind::Cluster)); - } - - #[test] - fn anything_else_is_a_single_database_dump() { - for header in [ - &b"--\n-- PostgreSQL database dump\n"[..], - &b"BEGIN;"[..], - &b""[..], - &b"PGD"[..], - &b"toc.da"[..], - ] { - assert!(matches!(Kind::of(header), Kind::Sql), "{header:?}"); - } - } - - #[test] - fn gzip_is_recognised_by_its_magic() { - assert!(b"\x1f\x8b\x08\x00rest".starts_with(GZIP_MAGIC)); - assert!(!b"PGDMP".starts_with(GZIP_MAGIC)); - } } diff --git a/src/commands/postgres/server.rs b/src/commands/postgres/server.rs new file mode 100644 index 0000000..3d1a47d --- /dev/null +++ b/src/commands/postgres/server.rs @@ -0,0 +1,85 @@ +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::{Result, anyhow, bail}; + +use super::shape::Kind; +use crate::cmd::{Cmd, PgIsReady, PgRestore, Ps, Psql}; +use crate::ctx::Ctx; +use crate::project::Project; + +const READY_TIMEOUT: Duration = Duration::from_secs(60); +const POLL_INTERVAL: Duration = Duration::from_secs(1); + +pub(super) struct Database { + pub(super) service: String, + pub(super) container: String, + pub(super) user: String, + pub(super) name: String, +} + +impl Database { + pub(super) fn resolve(ctx: &Ctx) -> Result { + let compose = Project::resolve(ctx)?; + let service = compose.postgres()?; + let (user, name) = compose.postgres_credentials(&service); + + let container = Ps::id_of(&service).capture(ctx)?.trim().to_string(); + + if container.is_empty() { + return Err(anyhow!("service {service} has no running container")); + } + + Ok(Self { + service, + container, + user, + name, + }) + } + + // what reads this shape of dump back in + pub(super) fn restore_with(&self, kind: Kind) -> Box { + match kind { + Kind::Archive => Box::new(PgRestore::new(&self.user, &self.name)), + Kind::Sql => Box::new(Psql::new(&self.user, &self.name).atomic()), + Kind::Cluster => Box::new(Psql::new(&self.user, "postgres")), + } + } +} + +pub(super) fn wait_until_ready(ctx: &Ctx, db: &Database) -> Result<()> { + if ctx.dry_run { + return Ok(()); + } + + let deadline = Instant::now() + READY_TIMEOUT; + + loop { + let ready = PgIsReady { + username: &db.user, + dbname: &db.name, + } + .in_container(&db.container) + .quietly_succeeds(ctx)?; + + if ready { + return Ok(()); + } + + if Instant::now() >= deadline { + bail!( + "{} did not accept connections within {} seconds", + db.service, + READY_TIMEOUT.as_secs() + ); + } + + thread::sleep(POLL_INTERVAL); + } +} + +pub(super) fn when_ready(ctx: &Ctx, db: &Database, command: &dyn Cmd) -> Result<()> { + wait_until_ready(ctx, db)?; + command.run(ctx) +} diff --git a/src/commands/postgres/shape.rs b/src/commands/postgres/shape.rs new file mode 100644 index 0000000..7508fc4 --- /dev/null +++ b/src/commands/postgres/shape.rs @@ -0,0 +1,104 @@ +use fs_err::File; +use std::io::Read; +use std::path::Path; + +use anyhow::Result; + +const CUSTOM_MAGIC: &[u8] = b"PGDMP"; +const TAR_MAGIC: &[u8] = b"toc.dat"; +const GZIP_MAGIC: &[u8] = b"\x1f\x8b"; +const CLUSTER_MARKER: &str = "PostgreSQL database cluster dump"; +// wide enough for the cluster marker, which sits a few bytes into the file +pub(super) const HEADER_LEN: usize = 512; + +pub(super) enum Dump { + Directory, + Gzip, + Header(Vec), +} + +impl Dump { + pub(super) fn of(path: &Path) -> Result { + if path.is_dir() { + return Ok(Self::Directory); + } + + let header = read_header(path)?; + if header.starts_with(GZIP_MAGIC) { + return Ok(Self::Gzip); + } + + Ok(Self::Header(header)) + } +} + +// how the dump has to be fed back in: pg_restore for an archive, psql into the +// database for a single database dump, psql into postgres for a whole cluster +#[derive(Clone, Copy, PartialEq)] +pub(super) enum Kind { + Archive, + Sql, + Cluster, +} + +impl Kind { + pub(super) fn of(header: &[u8]) -> Self { + if header.starts_with(CUSTOM_MAGIC) || header.starts_with(TAR_MAGIC) { + return Self::Archive; + } + + if String::from_utf8_lossy(header).contains(CLUSTER_MARKER) { + return Self::Cluster; + } + + Self::Sql + } +} + +// pg_dumpall recreates roles the cluster already has, so this is the expected + +fn read_header(path: &Path) -> Result> { + let mut file = File::open(path)?; + let mut header = vec![0; HEADER_LEN]; + + let read = file.read(&mut header)?; + header.truncate(read); + + Ok(header) +} + +#[cfg(test)] +mod tests { + use super::{GZIP_MAGIC, Kind}; + + #[test] + fn an_archive_is_recognised_by_its_magic() { + assert!(matches!(Kind::of(b"PGDMP\x01\x0f"), Kind::Archive)); + assert!(matches!(Kind::of(b"toc.dat\x00\x00"), Kind::Archive)); + } + + #[test] + fn a_cluster_dump_is_recognised_by_its_header() { + let header = b"--\n-- PostgreSQL database cluster dump\n--\n\n\\restrict abc\n"; + assert!(matches!(Kind::of(header), Kind::Cluster)); + } + + #[test] + fn anything_else_is_a_single_database_dump() { + for header in [ + &b"--\n-- PostgreSQL database dump\n"[..], + &b"BEGIN;"[..], + &b""[..], + &b"PGD"[..], + &b"toc.da"[..], + ] { + assert!(matches!(Kind::of(header), Kind::Sql), "{header:?}"); + } + } + + #[test] + fn gzip_is_recognised_by_its_magic() { + assert!(b"\x1f\x8b\x08\x00rest".starts_with(GZIP_MAGIC)); + assert!(!b"PGDMP".starts_with(GZIP_MAGIC)); + } +} diff --git a/src/main.rs b/src/main.rs index b6e8934..b40d113 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,11 +2,11 @@ use std::process::ExitCode; mod cli; mod cmd; -mod compose; +mod commands; mod ctx; mod fsops; mod output; -mod scripts; +mod project; use anyhow::Result; use clap::Parser; @@ -43,25 +43,25 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { match command { cli::Commands::Django { command } => { match command { - cli::Django::Bash => scripts::django::bash(ctx), - cli::Django::Run { rest } => scripts::django::run(ctx, &rest), + cli::Django::Bash => commands::django::bash(ctx), + cli::Django::Run { rest } => commands::django::run(ctx, &rest), cli::Django::MakeCommand { app, name } => { - scripts::django::make_command(ctx, &app, &name) + commands::django::make_command(ctx, &app, &name) } - cli::Django::Makemigrations => scripts::django::makemigrations(ctx), - cli::Django::Manage { rest } => scripts::django::manage(ctx, &rest), - cli::Django::Migrate { rest } => scripts::django::migrate(ctx, &rest), - cli::Django::Shell => scripts::django::shell(ctx), - cli::Django::Test => scripts::django::test(ctx), + cli::Django::Makemigrations => commands::django::makemigrations(ctx), + cli::Django::Manage { rest } => commands::django::manage(ctx, &rest), + cli::Django::Migrate { rest } => commands::django::migrate(ctx, &rest), + cli::Django::Shell => commands::django::shell(ctx), + cli::Django::Test => commands::django::test(ctx), }?; Ok(done) } cli::Commands::Postgres { command } => { match command { - cli::Postgres::Import { path } => scripts::postgres::import(ctx, &path), + cli::Postgres::Import { path } => commands::postgres::import(ctx, &path), cli::Postgres::Dump { path, format, gzip } => { - scripts::postgres::dump(ctx, &path, format, gzip) + commands::postgres::dump(ctx, &path, format, gzip) } }?; @@ -72,9 +72,9 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { paths, force, store, - } => scripts::link::add(ctx, &paths, force, store.root.as_deref()).map(|()| done), + } => commands::link::add(ctx, &paths, force, store.root.as_deref()).map(|()| done), cli::Link::Restore { paths, all, store } => { - scripts::link::restore(ctx, &paths, all, store.root.as_deref()).map(|()| done) + commands::link::restore(ctx, &paths, all, store.root.as_deref()).map(|()| done) } // the one command with something to say through its exit code cli::Link::Check { @@ -83,7 +83,7 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { null, exit_code, store, - } => scripts::link::check( + } => commands::link::check( ctx, &paths, porcelain, @@ -93,7 +93,7 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { ), }, cli::Commands::Completions { shell } => { - scripts::completions::completions(shell)?; + commands::completions::completions(shell)?; Ok(done) } diff --git a/src/compose.rs b/src/project.rs similarity index 97% rename from src/compose.rs rename to src/project.rs index 9e89f4d..296b3eb 100644 --- a/src/compose.rs +++ b/src/project.rs @@ -7,11 +7,11 @@ use crate::ctx::Ctx; const POSTGRES_IMAGES: [&str; 4] = ["postg", "timescale", "pgvector", "citus"]; const DJANGO_SETTINGS_MODULE: &str = "DJANGO_SETTINGS_MODULE"; -pub struct Compose { +pub struct Project { services: Value, } -impl Compose { +impl Project { pub fn resolve(ctx: &Ctx) -> Result { let json = Config.capture(ctx)?; let config: Value = serde_json::from_str(&json).context("parsing docker compose config")?; @@ -125,11 +125,11 @@ fn is_postgres_image(image: &str) -> bool { #[cfg(test)] mod tests { - use super::{Compose, is_postgres_image}; + use super::{Project, is_postgres_image}; use serde_json::json; - fn compose(services: serde_json::Value) -> Compose { - Compose { services } + fn compose(services: serde_json::Value) -> Project { + Project { services } } #[test] diff --git a/src/scripts/link.rs b/src/scripts/link.rs deleted file mode 100644 index 1ca306b..0000000 --- a/src/scripts/link.rs +++ /dev/null @@ -1,763 +0,0 @@ -use std::cell::Cell; -use std::env; -use std::ffi::OsString; -use std::fs; -use std::path::{Path, PathBuf}; - -use std::process::ExitCode; - -use anyhow::{Context, Result, anyhow, bail}; - -use crate::cmd::{CheckIgnore, Cmd, ConfigGet, LsFiles, RevParse}; -use crate::ctx::Ctx; -use crate::fsops::{move_path, place_link, prune_empty, remove_file, rename, suffixed}; -use crate::output::{note, warning}; - -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(ctx: &Ctx, paths: &[PathBuf], force: bool, store: Option<&Path>) -> Result<()> { - let repo = Repo::discover(ctx, store)?; - let report = Report::new(&repo); - - if let [path] = paths { - return link_one(ctx, &repo, path, force, &report); - } - - let mut failed = 0; - for path in paths { - if let Err(e) = link_one(ctx, &repo, path, force, &report) { - note!("error: {e:#}"); - failed += 1; - } - } - - if failed > 0 { - return Err(anyhow!("{failed} of {} paths failed", paths.len())); - } - Ok(()) -} - -// move paths in the store back into the repo, the inverse of add -pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) -> Result<()> { - let repo = Repo::discover(ctx, store)?; - 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() { - note!("nothing in the store for this repository"); - return Ok(()); - } - - if let [path] = paths.as_slice() { - return restore_one(ctx, &repo, path, &report); - } - - let mut failed = 0; - for path in &paths { - if let Err(e) = restore_one(ctx, &repo, path, &report) { - note!("error: {e:#}"); - failed += 1; - } - } - - if failed > 0 { - return Err(anyhow!("{failed} of {} paths failed", paths.len())); - } - Ok(()) -} - -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 = 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()); - } - - remove_file(ctx, &src)?; - move_path(ctx, &stored, &src)?; - prune_empty(ctx, stored.parent(), &repo.base); - - 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) -} - -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()); - } -} - -// list untracked paths not in the store, i.e. what a sandbox can still read -pub fn check( - ctx: &Ctx, - paths: &[PathBuf], - porcelain: bool, - null: bool, - exit_code: bool, - store: Option<&Path>, -) -> Result { - let repo = Repo::discover(ctx, store)?; - 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(ctx, &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); - } - - // git's --exit-code convention: nothing to report is 0, anything is 1 - if exit_code && !exposed.is_empty() { - return Ok(ExitCode::FAILURE); - } - - Ok(ExitCode::SUCCESS) -} - -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( - ctx: &Ctx, - repo: &Repo, - ignored: bool, - pathspecs: &[PathBuf], -) -> Result> { - let mut listing = LsFiles::untracked(&repo.root).limited_to(pathspecs); - if ignored { - listing = listing.ignored(); - } - - Ok(listing - .capture(ctx)? - .split('\0') - .filter(|p| !p.is_empty()) - .map(String::from) - .collect()) -} - -struct Repo { - root: PathBuf, - store: PathBuf, - // the configured store root, above the per-repository directories - base: PathBuf, -} - -impl Repo { - fn discover(ctx: &Ctx, store: Option<&Path>) -> Result { - let root = git_root(ctx)?; - let base = match store { - Some(store) => store.to_path_buf(), - None => store_root()?, - }; - - Ok(Self { - store: base.join(repo_components(ctx, &root)?), - root, - base, - }) - } - 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(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 - if target.starts_with(&repo.root) { - return Err(anyhow!( - "target {} is inside the repository; point AHAB_LINK_ROOT elsewhere", - target.display() - )); - } - - if tracked(ctx, repo, &rel)? { - return Err(anyhow!( - "{} is tracked by git; only untracked or ignored paths can be externalized", - rel.display() - )); - } - if !ignored(ctx, repo, &rel) { - warning!("{} 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() { - warning!( - "{} is a broken symlink to {}", - rel.display(), - dest.display() - ); - } - move_path(ctx, &src, &target)?; - place_link(ctx, &src, &target)?; - report.line("moved", &rel); - return Ok(()); - } - - if !force { - return Err(needs_force(&target)); - } - - place_link(ctx, &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() - )); - } - - rename(ctx, &src, &backup)?; - report.line("saved", &suffixed(&rel, BACKUP_SUFFIX)); - - place_link(ctx, &src, &target)?; - report.line("linked", &rel); - Ok(()) - } - - Some(_) => { - move_path(ctx, &src, &target)?; - place_link(ctx, &src, &target)?; - report.line("moved", &rel); - Ok(()) - } - - None if target_taken => { - if !force { - return Err(needs_force(&target)); - } - place_link(ctx, &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(ctx: &Ctx) -> Result { - let root = RevParse - .capture(ctx) - .map_err(|_| anyhow!("not inside a git repository"))?; - - let root = root.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(ctx: &Ctx, root: &Path) -> Result { - if let Some(url) = git_origin_url(ctx) { - if let Some(components) = components_from_remote(&url) { - return Ok(components); - } - note!("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(ctx: &Ctx) -> Option { - let url = ConfigGet { - key: "remote.origin.url", - } - .capture(ctx) - .ok()?; - - let url = url.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(xdg) = non_empty_var("XDG_DATA_HOME") { - return Ok(PathBuf::from(xdg).join("ahab")); - } - - let home = non_empty_var("HOME") - .filter(|v| !v.is_empty()) - .ok_or_else(|| anyhow!("neither XDG_DATA_HOME nor 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(ctx: &Ctx, repo: &Repo, rel: &Path) -> Result { - let listed = LsFiles::tracked(&repo.root) - .limited_to(&[rel]) - .capture(ctx)?; - - Ok(!listed.is_empty()) -} - -fn ignored(ctx: &Ctx, repo: &Repo, rel: &Path) -> bool { - CheckIgnore::new(&repo.root, rel) - .quietly_succeeds(ctx) - .unwrap_or(false) -} - -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())), - } -} - -#[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"); - } -}