From bbbca755be672af5d21927c4f820840da955a97d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Tue, 8 Sep 2026 11:57:45 +0000 Subject: [PATCH] refactor: run git through the same layer as everything else --- src/cmd/git.rs | 156 ++++++++++++++++++++++++++++++++++++++++++++ src/cmd/mod.rs | 2 + src/main.rs | 13 +++- src/scripts/link.rs | 130 +++++++++++++++--------------------- 4 files changed, 219 insertions(+), 82 deletions(-) create mode 100644 src/cmd/git.rs diff --git a/src/cmd/git.rs b/src/cmd/git.rs new file mode 100644 index 0000000..cf7d211 --- /dev/null +++ b/src/cmd/git.rs @@ -0,0 +1,156 @@ +use std::path::Path; + +use super::{Argv, Cmd}; + +// -C has no long form; it runs git as though from that directory +fn git(root: &Path) -> Argv { + Argv::new("git").arg("-C").arg(root.to_string_lossy()) +} + +// the root of the repository the working directory is in +pub struct RevParse; + +impl Cmd for RevParse { + fn argv(&self) -> Argv { + Argv::new("git").arg("rev-parse").arg("--show-toplevel") + } +} + +// the url a remote points at, if the repository has one +pub struct ConfigGet<'a> { + pub key: &'a str, +} + +impl Cmd for ConfigGet<'_> { + fn argv(&self) -> Argv { + Argv::new("git").arg("config").flag("--get", self.key) + } +} + +pub struct LsFiles<'a> { + root: &'a Path, + tracked: bool, + ignored: bool, + pathspecs: Vec, +} + +impl<'a> LsFiles<'a> { + // paths git would not restore: untracked ones, and whole directories rather + // than every file inside them + pub fn untracked(root: &'a Path) -> Self { + Self { + root, + tracked: false, + ignored: false, + pathspecs: Vec::new(), + } + } + + pub fn tracked(root: &'a Path) -> Self { + Self { + root, + tracked: true, + ignored: false, + pathspecs: Vec::new(), + } + } + + // the ignored paths instead of the merely untracked ones + pub fn ignored(mut self) -> Self { + self.ignored = true; + self + } + + pub fn limited_to>(mut self, pathspecs: &[P]) -> Self { + self.pathspecs = pathspecs + .iter() + .map(|path| path.as_ref().to_string_lossy().to_string()) + .collect(); + self + } +} + +impl Cmd for LsFiles<'_> { + fn argv(&self) -> Argv { + let mut argv = git(self.root).arg("ls-files"); + + if self.tracked { + argv = argv.arg("--cached"); + } else { + // -z has no long form; it separates the paths with NUL, which is the + // only separator a filename cannot contain + argv = argv + .arg("-z") + .arg("--others") + .arg("--exclude-standard") + .arg("--directory") + .arg("--no-empty-directory"); + } + + if self.ignored { + argv = argv.arg("--ignored"); + } + + argv.arg("--").args(&self.pathspecs) + } +} + +// whether a path is ignored, said through the exit code alone +pub struct CheckIgnore<'a> { + root: &'a Path, + path: &'a Path, +} + +impl<'a> CheckIgnore<'a> { + pub fn new(root: &'a Path, path: &'a Path) -> Self { + Self { root, path } + } +} + +impl Cmd for CheckIgnore<'_> { + fn argv(&self) -> Argv { + git(self.root) + .arg("check-ignore") + .arg("--quiet") + .arg("--") + .arg(self.path.to_string_lossy()) + } +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use super::{CheckIgnore, LsFiles}; + use crate::cmd::Cmd; + + #[test] + fn an_untracked_listing_asks_for_whole_directories() { + let argv = LsFiles::untracked(Path::new("/repo")) + .ignored() + .limited_to(&[PathBuf::from("a b")]) + .argv(); + + assert_eq!( + argv.quoted(), + "git -C /repo ls-files -z --others --exclude-standard --directory \ + --no-empty-directory --ignored -- 'a b'" + ); + } + + #[test] + fn the_tracked_listing_asks_git_only_about_the_paths_given() { + let argv = LsFiles::tracked(Path::new("/repo")) + .limited_to(&[PathBuf::from(".env")]) + .argv(); + + assert_eq!(argv.quoted(), "git -C /repo ls-files --cached -- .env"); + } + + #[test] + fn check_ignore_says_nothing_and_reports_through_its_status() { + let argv = CheckIgnore::new(Path::new("/repo"), Path::new(".env")).argv(); + + assert_eq!(argv.quoted(), "git -C /repo check-ignore --quiet -- .env"); + } +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 7cabfbe..1c13afa 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -2,6 +2,7 @@ mod argv; mod compose; mod django; mod docker; +mod git; mod postgres; mod shell; @@ -16,6 +17,7 @@ pub use argv::Argv; pub use compose::{Config, Ps, Run, Start, Stop, Up}; pub use django::{Bash, Manage, Words}; pub use docker::{Cp, Exec}; +pub use git::{CheckIgnore, ConfigGet, LsFiles, RevParse}; pub use postgres::{CreateDb, DropDb, PgDump, PgDumpAll, PgIsReady, PgRestore, Psql}; pub use shell::{Gunzip, Gzip, Head, Pipeline, Rm}; diff --git a/src/main.rs b/src/main.rs index 7d3aff6..4db2ecc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -67,9 +67,9 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { paths, force, store, - } => scripts::link::add(&paths, force, store.root.as_deref()).map(|()| done), + } => scripts::link::add(ctx, &paths, force, store.root.as_deref()).map(|()| done), cli::Link::Restore { paths, all, store } => { - scripts::link::restore(&paths, all, store.root.as_deref()).map(|()| done) + scripts::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 { @@ -78,7 +78,14 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { null, exit_code, store, - } => scripts::link::check(&paths, porcelain, null, exit_code, store.root.as_deref()), + } => scripts::link::check( + ctx, + &paths, + porcelain, + null, + exit_code, + store.root.as_deref(), + ), }, cli::Commands::Completions { shell } => { scripts::completions::completions(shell)?; diff --git a/src/scripts/link.rs b/src/scripts/link.rs index 90c780f..f1c402c 100644 --- a/src/scripts/link.rs +++ b/src/scripts/link.rs @@ -4,26 +4,29 @@ use std::ffi::OsString; use std::fs; use std::os::unix::fs::symlink; use std::path::{Path, PathBuf}; -use std::process::Command; + use std::process::ExitCode; use anyhow::{Context, Result, anyhow, bail}; +use crate::cmd::{CheckIgnore, Cmd, ConfigGet, LsFiles, RevParse}; +use crate::ctx::Ctx; + 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, store: Option<&Path>) -> Result<()> { - let repo = Repo::discover(store)?; +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(&repo, path, force, &report); + return link_one(ctx, &repo, path, force, &report); } let mut failed = 0; for path in paths { - if let Err(e) = link_one(&repo, path, force, &report) { + if let Err(e) = link_one(ctx, &repo, path, force, &report) { eprintln!("error: {e:#}"); failed += 1; } @@ -36,8 +39,8 @@ pub fn add(paths: &[PathBuf], force: bool, store: Option<&Path>) -> Result<()> { } // move paths in the store back into the repo, the inverse of add -pub fn restore(paths: &[PathBuf], all: bool, store: Option<&Path>) -> Result<()> { - let repo = Repo::discover(store)?; +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) { @@ -179,19 +182,20 @@ fn warn(msg: impl std::fmt::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(store)?; + 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(&repo, ignored, &pathspecs)? { + 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/` @@ -392,30 +396,19 @@ fn relative_pathspecs(repo: &Repo, paths: &[PathBuf]) -> Result> { 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", - ]); +fn list_others( + ctx: &Ctx, + repo: &Repo, + ignored: bool, + pathspecs: &[PathBuf], +) -> Result> { + let mut listing = LsFiles::untracked(&repo.root).limited_to(pathspecs); 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() - )); + listing = listing.ignored(); } - Ok(String::from_utf8(out.stdout)? + Ok(listing + .capture(ctx)? .split('\0') .filter(|p| !p.is_empty()) .map(String::from) @@ -430,15 +423,15 @@ struct Repo { } impl Repo { - fn discover(store: Option<&Path>) -> Result { - let root = git_root()?; + 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(&root)?), + store: base.join(repo_components(ctx, &root)?), root, base, }) @@ -466,7 +459,7 @@ impl Repo { } } -fn link_one(repo: &Repo, path: &Path, force: bool, report: &Report) -> Result<()> { +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); @@ -479,13 +472,13 @@ fn link_one(repo: &Repo, path: &Path, force: bool, report: &Report) -> Result<() )); } - if tracked(repo, &rel)? { + if tracked(ctx, repo, &rel)? { return Err(anyhow!( "{} is tracked by git; only untracked or ignored paths can be externalized", rel.display() )); } - if !ignored(repo, &rel) { + if !ignored(ctx, repo, &rel) { warn(format!("{} is not gitignored", rel.display())); } @@ -601,17 +594,12 @@ fn resolve(path: &Path) -> Result { Ok(parent.join(name)) } -fn git_root() -> Result { - let out = Command::new("git") - .args(["rev-parse", "--show-toplevel"]) - .output() - .context("running git")?; +fn git_root(ctx: &Ctx) -> Result { + let root = RevParse + .capture(ctx) + .map_err(|_| anyhow!("not inside a git repository"))?; - if !out.status.success() { - return Err(anyhow!("not inside a git repository")); - } - - let root = String::from_utf8(out.stdout)?.trim().to_string(); + let root = root.trim().to_string(); if root.is_empty() { return Err(anyhow!("git reported an empty repository root")); } @@ -619,8 +607,8 @@ fn git_root() -> Result { fs::canonicalize(&root).with_context(|| format!("resolving {root}")) } -fn repo_components(root: &Path) -> Result { - if let Some(url) = git_origin_url() { +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); } @@ -633,16 +621,14 @@ fn repo_components(root: &Path) -> Result { 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; +fn git_origin_url(ctx: &Ctx) -> Option { + let url = ConfigGet { + key: "remote.origin.url", } + .capture(ctx) + .ok()?; - let url = String::from_utf8(out.stdout).ok()?.trim().to_string(); + let url = url.trim().to_string(); (!url.is_empty()).then_some(url) } @@ -706,32 +692,18 @@ 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")?; +fn tracked(ctx: &Ctx, repo: &Repo, rel: &Path) -> Result { + let listed = LsFiles::tracked(&repo.root) + .limited_to(&[rel]) + .capture(ctx)?; - if !out.status.success() { - return Err(anyhow!( - "git ls-files failed: {}", - String::from_utf8_lossy(&out.stderr).trim() - )); - } - Ok(!out.stdout.is_empty()) + Ok(!listed.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 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> {