From 9be37d764617b0cc0b700dd07b35dafc8ea15ef0 Mon Sep 17 00:00:00 2001 From: gl0w Date: Thu, 10 Sep 2026 05:04:16 +0100 Subject: [PATCH] m1/gitx: git reader module + snapshot (16 tests) --- src/gitx.rs | 798 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + 2 files changed, 799 insertions(+) create mode 100644 src/gitx.rs diff --git a/src/gitx.rs b/src/gitx.rs new file mode 100644 index 0000000..10d0874 --- /dev/null +++ b/src/gitx.rs @@ -0,0 +1,798 @@ +//! Git reader + snapshot helpers for hu13. +//! +//! All git access goes through `git -C ` subprocesses with a 10s +//! timeout. Errors are typed ([`GitError`]); nothing here panics on +//! untrusted input or git failures. + +#![allow(dead_code)] + +use std::ffi::{OsStr, OsString}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +/// Timeout for every `git` subprocess. +pub const GIT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Default commit count for [`log_n`]. +pub const DEFAULT_LOG_N: usize = 100; + +/// Max diff lines before truncation (marker appended). +pub const DIFF_MAX_LINES: usize = 2000; + +/// Marker appended when [`diff`] truncates output. +pub const DIFF_TRUNCATED_MARKER: &str = "[hu13: diff truncated at 2000 lines]"; + +/// Marker used when binary content is detected without git's own note. +pub const BINARY_NOTE: &str = "[hu13: binary content omitted]"; + +/// Typed errors for all gitx operations. Never panics. +#[derive(Debug)] +pub enum GitError { + /// Repo path or output dir could not be used / created. + Io(String), + /// `git` binary could not be spawned. + Spawn(String), + /// `git` did not finish within [`GIT_TIMEOUT`]. + Timeout(String), + /// `git` exited non-zero. + Failed { + cmd: String, + status: String, + stderr: String, + }, + /// Requested branch does not exist (names what was tried). + BranchNotFound { tried: Vec }, + /// A rev / sha / path argument was rejected by git. + NotFound(String), +} + +impl std::fmt::Display for GitError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(msg) => write!(f, "hu13 gitx: io error: {msg}"), + Self::Spawn(msg) => write!(f, "hu13 gitx: failed to spawn git: {msg}"), + Self::Timeout(msg) => write!(f, "hu13 gitx: git timed out after 10s: {msg}"), + Self::Failed { + cmd, + status, + stderr, + } => { + write!(f, "hu13 gitx: `{cmd}` failed ({status}): {stderr}") + } + Self::BranchNotFound { tried } => { + write!( + f, + "hu13 gitx: branch not found (tried: {}); pass --branch or set HU13_BRANCH", + tried.join(", ") + ) + } + Self::NotFound(msg) => write!(f, "hu13 gitx: not found: {msg}"), + } + } +} + +impl std::error::Error for GitError {} + +/// One commit row from [`log_n`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Commit { + /// Full 40-hex sha. + pub sha: String, + /// First line of the commit message. + pub subject: String, + /// Author name. + pub author: String, + /// Author date (`--date=iso`). + pub date: String, +} + +/// One tree entry from [`ls_tree`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TreeEntry { + /// Lossy display name (for HTML after `esc()`). + pub name: String, + /// Exact filename bytes (preserved for non-UTF8 paths). + pub name_bytes: Vec, + /// `true` for directories (type `tree`), `false` otherwise. + pub is_dir: bool, +} + +/// Exact bytes of `branch:path` from [`show`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Blob { + /// Exact file bytes (byte-exact from `git show`). + pub bytes: Vec, + /// Lossy text of `bytes` for display (via `String::from_utf8_lossy`). + pub display: String, + /// Exact path bytes requested. + pub path_bytes: Vec, + /// Lossy display form of the path. + pub path_display: String, +} + +/// Output paths from [`snapshot`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Snapshot { + /// Path to the created bundle (`/repo.bundle`). + pub bundle: PathBuf, + /// Path to the created tarball (`/repo.tar.gz`). + pub tarball: PathBuf, +} + +// --------------------------------------------------------------------------- +// subprocess core +// --------------------------------------------------------------------------- + +struct GitOutput { + status_ok: bool, + status_str: String, + stdout: Vec, + stderr: Vec, +} + +fn cmd_string(repo: &Path, args: &[&OsStr]) -> String { + let mut s = String::from("git -C "); + s.push_str(&repo.to_string_lossy()); + for a in args { + s.push(' '); + s.push_str(&a.to_string_lossy()); + } + s +} + +fn run_git(repo: &Path, args: &[&OsStr]) -> Result { + let mut cmd = Command::new("git"); + cmd.arg("-C") + .arg(repo) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = cmd.spawn().map_err(|e| GitError::Spawn(e.to_string()))?; + + let stdout_handle = child.stdout.take(); + let stderr_handle = child.stderr.take(); + + let (tx_out, rx_out) = mpsc::channel::>(); + let (tx_err, rx_err) = mpsc::channel::>(); + + if let Some(mut out) = stdout_handle { + let tx = tx_out; + thread::spawn(move || { + let mut buf = Vec::new(); + let _ = out.read_to_end(&mut buf); + let _ = tx.send(buf); + }); + } else { + let _ = tx_out.send(Vec::new()); + } + if let Some(mut err) = stderr_handle { + let tx = tx_err; + thread::spawn(move || { + let mut buf = Vec::new(); + let _ = err.read_to_end(&mut buf); + let _ = tx.send(buf); + }); + } else { + let _ = tx_err.send(Vec::new()); + } + + let start = Instant::now(); + loop { + match child.try_wait() { + Err(e) => { + return Err(GitError::Failed { + cmd: cmd_string(repo, args), + status: "wait-error".to_string(), + stderr: e.to_string(), + }) + } + Ok(Some(status)) => { + let stdout = rx_out + .recv_timeout(Duration::from_secs(5)) + .unwrap_or_default(); + let stderr = rx_err + .recv_timeout(Duration::from_secs(5)) + .unwrap_or_default(); + return Ok(GitOutput { + status_ok: status.success(), + status_str: status.to_string(), + stdout, + stderr, + }); + } + Ok(None) => { + if start.elapsed() >= GIT_TIMEOUT { + let _ = child.kill(); + let _ = child.wait(); + return Err(GitError::Timeout(cmd_string(repo, args))); + } + thread::sleep(Duration::from_millis(10)); + } + } + } +} + +fn run_git_checked(repo: &Path, args: &[&OsStr]) -> Result, GitError> { + let out = run_git(repo, args)?; + if out.status_ok { + Ok(out.stdout) + } else { + Err(GitError::Failed { + cmd: cmd_string(repo, args), + status: out.status_str, + stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(), + }) + } +} + +fn branch_exists(repo: &Path, name: &str) -> Result { + let refname = format!("refs/heads/{name}"); + let a_verify: &OsStr = OsStr::new("--verify"); + let a_quiet: &OsStr = OsStr::new("--quiet"); + let a_rev: &OsStr = OsStr::new("rev-parse"); + let os_ref = OsString::from(refname); + let args: Vec<&OsStr> = vec![a_rev, a_verify, a_quiet, os_ref.as_os_str()]; + let out = run_git(repo, &args)?; + Ok(out.status_ok) +} + +// --------------------------------------------------------------------------- +// public API +// --------------------------------------------------------------------------- + +/// Resolve which branch to render. +/// +/// Precedence: explicit `flag` > `$HU13_BRANCH` > `main` then `master`. +/// Returns the first existing branch; otherwise a loud [`GitError::BranchNotFound`] +/// naming every name that was tried. +pub fn resolve_branch(repo: &Path, flag: Option<&str>) -> Result { + let flag = flag.map(str::trim).filter(|s| !s.is_empty()); + if let Some(name) = flag { + if branch_exists(repo, name)? { + return Ok(name.to_string()); + } + return Err(GitError::BranchNotFound { + tried: vec![name.to_string()], + }); + } + if let Ok(env) = std::env::var("HU13_BRANCH") { + let name = env.trim().to_string(); + if !name.is_empty() { + if branch_exists(repo, &name)? { + return Ok(name); + } + return Err(GitError::BranchNotFound { tried: vec![name] }); + } + } + for candidate in ["main", "master"] { + if branch_exists(repo, candidate)? { + return Ok(candidate.to_string()); + } + } + Err(GitError::BranchNotFound { + tried: vec!["main".to_string(), "master".to_string()], + }) +} + +/// Short hash for `rev` (`git rev-parse --short `). +pub fn rev_parse_short(repo: &Path, rev: &str) -> Result { + let a_rev: &OsStr = OsStr::new("rev-parse"); + let a_short: &OsStr = OsStr::new("--short"); + let os_rev = OsString::from(rev); + let args: Vec<&OsStr> = vec![a_rev, a_short, os_rev.as_os_str()]; + let bytes = run_git_checked(repo, &args).map_err(|e| match e { + GitError::Failed { + cmd, + status, + stderr, + } => GitError::Failed { + cmd, + status, + stderr: format!("rev `{rev}` not found: {stderr}"), + }, + other => other, + })?; + let s = String::from_utf8_lossy(&bytes).trim().to_string(); + if s.is_empty() { + return Err(GitError::NotFound(format!("rev `{rev}` not found"))); + } + Ok(s) +} + +/// Newest-first log for `branch`, up to `n` commits. +/// +/// Returns `(sha, subject, author, date)` rows as [`Commit`]. +pub fn log_n(repo: &Path, branch: &str, n: usize) -> Result, GitError> { + let format = "--format=%H\x1f%s\x1f%an\x1f%ad\x1e"; + let a_log: &OsStr = OsStr::new("log"); + let os_branch = OsString::from(branch); + let os_n = OsString::from(format!("-n{n}")); + let os_fmt = OsString::from(format); + let os_date = OsString::from("--date=iso"); + let args: Vec<&OsStr> = vec![ + a_log, + os_branch.as_os_str(), + os_n.as_os_str(), + os_fmt.as_os_str(), + os_date.as_os_str(), + ]; + let bytes = run_git_checked(repo, &args)?; + let text = String::from_utf8_lossy(&bytes); + let mut out = Vec::new(); + for record in text.split('\x1e') { + let record = record.trim_matches(|c| c == '\n' || c == '\r'); + if record.trim().is_empty() { + continue; + } + // Strip a single leading newline left by the previous record's terminator. + let record = record.strip_prefix('\n').unwrap_or(record); + if record.is_empty() { + continue; + } + let mut parts = record.splitn(4, '\x1f'); + let (Some(sha), Some(subject), Some(author), Some(date)) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + continue; + }; + out.push(Commit { + sha: sha.trim().to_string(), + subject: subject.to_string(), + author: author.to_string(), + date: date.trim().to_string(), + }); + if out.len() >= n { + break; + } + } + Ok(out) +} + +/// Directories-first listing of `branch:path` (`path` empty = root). +/// +/// Within each group (dirs, then files) entries are sorted by exact +/// filename bytes. Non-UTF8 names keep exact bytes in `name_bytes` with a +/// lossy `name` for display. +pub fn ls_tree(repo: &Path, branch: &str, path: &str) -> Result, GitError> { + let target: String = if path.is_empty() { + branch.to_string() + } else { + format!("{branch}:{}", path.trim_matches('/')) + }; + let a_tree: &OsStr = OsStr::new("ls-tree"); + let a_z: &OsStr = OsStr::new("-z"); + let os_target = OsString::from(target.clone()); + let args: Vec<&OsStr> = vec![a_tree, a_z, os_target.as_os_str()]; + let bytes = run_git_checked(repo, &args).map_err(|e| match e { + GitError::Failed { + cmd, + status, + stderr, + } => GitError::NotFound(format!( + "{cmd} failed ({status}): {stderr}; tree `{target}` not found" + )), + other => other, + })?; + + let mut entries = Vec::new(); + for record in bytes.split(|b| *b == 0) { + if record.is_empty() { + continue; + } + let Some(tab) = record.iter().position(|b| *b == b'\t') else { + continue; + }; + let meta = &record[..tab]; + let name_bytes = record[tab + 1..].to_vec(); + // meta: " " + let meta_str = String::from_utf8_lossy(meta); + let mut it = meta_str.split_whitespace(); + let _mode = it.next().unwrap_or(""); + let typ = it.next().unwrap_or(""); + entries.push(TreeEntry { + name: String::from_utf8_lossy(&name_bytes).into_owned(), + name_bytes, + is_dir: typ == "tree", + }); + } + entries.sort_by(|a, b| { + b.is_dir + .cmp(&a.is_dir) + .then_with(|| a.name_bytes.cmp(&b.name_bytes)) + }); + Ok(entries) +} + +/// Byte-exact file content for `branch:path`. +/// +/// `path` is a repo-relative path; non-UTF8 paths work because it is +/// passed as an `OsStr` (`Path`) straight through to git. The returned +/// [`Blob`] keeps exact `bytes` plus a lossy `display` string, and echoes +/// the exact `path_bytes` plus lossy `path_display`. +pub fn show(repo: &Path, branch: &str, path: &Path) -> Result { + let path_os = path.as_os_str(); + let mut spec = OsString::from(branch); + spec.push(":"); + spec.push(path_os); + + let a_show: &OsStr = OsStr::new("show"); + let args: Vec<&OsStr> = vec![a_show, spec.as_os_str()]; + let out = run_git(repo, &args)?; + if !out.status_ok { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + return Err(GitError::NotFound(format!( + "`{branch}:{}` not found: {stderr}", + path.to_string_lossy() + ))); + } + let path_bytes = path_os.as_encoded_bytes().to_vec(); + Ok(Blob { + display: String::from_utf8_lossy(&out.stdout).into_owned(), + bytes: out.stdout, + path_display: path.to_string_lossy().into_owned(), + path_bytes, + }) +} + +/// Unified diff text for one commit `sha`. +/// +/// Binary diffs keep git's `Binary files ... differ` note (or gain a +/// `[hu13: binary content omitted]` note if git emits raw NULs). Output +/// past [`DIFF_MAX_LINES`] lines is truncated with [`DIFF_TRUNCATED_MARKER`]. +pub fn diff(repo: &Path, sha: &str) -> Result { + let a_show: &OsStr = OsStr::new("show"); + let a_pretty: &OsStr = OsStr::new("--pretty=medium"); + let a_patch: &OsStr = OsStr::new("--patch"); + let a_noext: &OsStr = OsStr::new("--no-ext-diff"); + let os_sha = OsString::from(sha); + let args: Vec<&OsStr> = vec![ + a_show, + a_pretty, + a_patch, + a_noext, + os_sha.as_os_str(), + OsStr::new("--"), + ]; + let out = run_git(repo, &args)?; + if !out.status_ok { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + return Err(GitError::NotFound(format!( + "commit `{sha}` not found: {stderr}" + ))); + } + let mut text = String::from_utf8_lossy(&out.stdout).into_owned(); + let has_binary_note = text.contains("Binary files"); + if !has_binary_note && out.stdout.contains(&0) { + text.push('\n'); + text.push_str(BINARY_NOTE); + text.push('\n'); + } + let lines: Vec<&str> = text.lines().collect(); + if lines.len() > DIFF_MAX_LINES { + let mut truncated = lines[..DIFF_MAX_LINES].join("\n"); + truncated.push('\n'); + truncated.push_str(DIFF_TRUNCATED_MARKER); + truncated.push('\n'); + return Ok(truncated); + } + Ok(text) +} + +/// Snapshot `branch` into `out`: +/// +/// * `out/repo.bundle` via `git bundle create repo.bundle ` +/// * `out/repo.tar.gz` via `git archive` +/// +/// `out` is created if missing. Returns both paths. +pub fn snapshot(repo: &Path, branch: &str, out: &Path) -> Result { + std::fs::create_dir_all(out) + .map_err(|e| GitError::Io(format!("create out dir {}: {e}", out.to_string_lossy())))?; + let bundle = out.join("repo.bundle"); + let tarball = out.join("repo.tar.gz"); + + let bundle_str = bundle.to_string_lossy().into_owned(); + let os_bundle = OsString::from(&bundle_str); + let os_branch = OsString::from(branch); + let a_bundle: &OsStr = OsStr::new("bundle"); + let a_create: &OsStr = OsStr::new("create"); + let args: Vec<&OsStr> = vec![ + a_bundle, + a_create, + os_bundle.as_os_str(), + os_branch.as_os_str(), + ]; + let bytes = run_git(repo, &args); + let bytes = bytes?; + if !bytes.status_ok { + let stderr = String::from_utf8_lossy(&bytes.stderr).trim().to_string(); + return Err(GitError::Failed { + cmd: cmd_string(repo, &args), + status: bytes.status_str, + stderr, + }); + } + + let tar_str = tarball.to_string_lossy().into_owned(); + let os_tar = OsString::from(&tar_str); + let os_branch2 = OsString::from(branch); + let a_archive: &OsStr = OsStr::new("archive"); + let a_fmt: &OsStr = OsStr::new("--format=tar.gz"); + let a_out = OsString::from(format!("-o{tar_str}")); + let args2: Vec<&OsStr> = vec![a_archive, a_fmt, a_out.as_os_str(), os_branch2.as_os_str()]; + let _ = os_tar; + let out2 = run_git(repo, &args2)?; + if !out2.status_ok { + let stderr = String::from_utf8_lossy(&out2.stderr).trim().to_string(); + return Err(GitError::Failed { + cmd: cmd_string(repo, &args2), + status: out2.status_str, + stderr, + }); + } + Ok(Snapshot { bundle, tarball }) +} + +// --------------------------------------------------------------------------- +// tests (std-only fixture repos) +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + use std::sync::atomic::{AtomicU64, Ordering}; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + + fn mktemp(prefix: &str) -> PathBuf { + let id = COUNTER.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!( + "hu13-{prefix}-{}-{}-{}", + std::process::id(), + id, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(id as u128) + )); + std::fs::create_dir_all(&dir).expect("mktemp"); + dir + } + + fn git(repo: &Path, args: &[&str]) { + let st = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .status() + .expect("spawn git"); + assert!(st.success(), "git {args:?} failed in {}", repo.display()); + } + + fn git_env(repo: &Path, args: &[&str], k: &str, v: &str) { + let st = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .env(k, v) + .status() + .expect("spawn git"); + assert!(st.success(), "git {args:?} failed in {}", repo.display()); + } + + /// Build a fixture repo with 3 commits: text, binary + dir, non-ASCII name. + /// Returns (repo_dir, branch). + fn fixture_repo() -> (PathBuf, String) { + let dir = mktemp("fixture"); + git(&dir, &["init", "-b", "main"]); + git(&dir, &["config", "user.name", "hu13-test"]); + git(&dir, &["config", "user.email", "hu13@test.local"]); + git(&dir, &["config", "commit.gpgsign", "false"]); + + // Commit 1: text files + a directory (for dirs-first ordering). + std::fs::write(dir.join("zfile.txt"), "line one\n").unwrap(); + std::fs::write(dir.join("afile.txt"), "alpha\n").unwrap(); + std::fs::create_dir_all(dir.join("adir")).unwrap(); + std::fs::write(dir.join("adir").join("inner.txt"), "inner\n").unwrap(); + git(&dir, &["add", "-A"]); + git_env( + &dir, + &["commit", "-m", "first commit"], + "GIT_AUTHOR_DATE", + "2024-01-01T00:00:00+00:00", + ); + // fix committer date too via env on same command is not enough; set both: + // (rely on default ordering by topology, not date, so fine) + + // Commit 2: binary file + modify zfile (gives diff hunk later). + let bin: Vec = (0u8..=255).collect(); + std::fs::write(dir.join("bin.dat"), &bin).unwrap(); + std::fs::write(dir.join("zfile.txt"), "line one\nline two added\n").unwrap(); + git(&dir, &["add", "-A"]); + git(&dir, &["commit", "-m", "second commit with binary"]); + + // Commit 3: non-ASCII filename. + std::fs::write(dir.join("café.txt"), "unicode name\n").unwrap(); + git(&dir, &["add", "-A"]); + git(&dir, &["commit", "-m", "third commit café"]); + + (dir, "main".to_string()) + } + + #[test] + fn log_order_newest_first() { + let (repo, branch) = fixture_repo(); + let log = log_n(&repo, &branch, 100).expect("log"); + assert_eq!(log.len(), 3); + assert_eq!(log[0].subject, "third commit café"); + assert_eq!(log[1].subject, "second commit with binary"); + assert_eq!(log[2].subject, "first commit"); + for c in &log { + assert_eq!(c.sha.len(), 40); + assert!(!c.author.is_empty()); + assert!(!c.date.is_empty()); + } + } + + #[test] + fn ls_tree_dirs_first_sorted() { + let (repo, branch) = fixture_repo(); + let entries = ls_tree(&repo, &branch, "").expect("ls-tree"); + let names: Vec = entries.iter().map(|e| e.name.clone()).collect(); + // adir (dir) must come before all files. + let dir_pos = names.iter().position(|n| n == "adir").expect("adir"); + for f in ["afile.txt", "zfile.txt", "bin.dat", "café.txt"] { + let p = names.iter().position(|n| n == f).expect(f); + assert!(dir_pos < p, "dir should sort before {f}: {names:?}"); + } + assert!(entries.iter().find(|e| e.name == "adir").unwrap().is_dir); + assert!( + !entries + .iter() + .find(|e| e.name == "afile.txt") + .unwrap() + .is_dir + ); + // files sorted by bytes within group + let files: Vec<&TreeEntry> = entries.iter().filter(|e| !e.is_dir).collect(); + let mut sorted = files.clone(); + sorted.sort_by(|a, b| a.name_bytes.cmp(&b.name_bytes)); + assert_eq!(files, sorted); + } + + #[test] + fn show_is_byte_exact() { + let (repo, branch) = fixture_repo(); + for f in ["zfile.txt", "bin.dat", "café.txt", "adir/inner.txt"] { + let blob = show(&repo, &branch, Path::new(f)).expect(f); + // Compare against raw `git show` bytes. + let out = Command::new("git") + .arg("-C") + .arg(&repo) + .arg("show") + .arg(format!("{branch}:{f}")) + .output() + .expect("git show"); + assert!(out.status.success()); + assert_eq!(blob.bytes, out.stdout, "byte-exact for {f}"); + assert_eq!(blob.display, String::from_utf8_lossy(&blob.bytes)); + assert_eq!(blob.path_display, f); + } + } + + #[test] + fn show_non_utf8_path() { + let (repo, branch) = fixture_repo(); + // Create a non-UTF8 filename (Unix only). + #[cfg(unix)] + { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + let raw = b"weird-\xff-name.txt"; + let p = Path::new(OsStr::from_bytes(raw)); + std::fs::write(repo.join(p), b"raw bytes content\n").unwrap(); + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-m", "non-utf8 filename"]); + let blob = show(&repo, &branch, p).expect("show non-utf8"); + assert_eq!(blob.bytes, b"raw bytes content\n"); + assert_eq!(blob.path_bytes, raw); + assert_eq!(blob.path_display, String::from_utf8_lossy(raw).into_owned()); + } + } + + #[test] + fn diff_contains_hunk() { + let (repo, branch) = fixture_repo(); + let log = log_n(&repo, &branch, 100).expect("log"); + // Middle commit touched zfile.txt adding "line two added". + let sha = &log[1].sha; + let d = diff(&repo, sha).expect("diff"); + assert!( + d.contains("line two added"), + "diff should contain hunk: {d}" + ); + assert!(d.contains("@@") || d.contains("diff --git")); + } + + #[test] + fn diff_truncates_long_output() { + let (repo, branch) = fixture_repo(); + // Add a commit with >2000 diff lines. + let mut big = String::new(); + for i in 0..2500 { + big.push_str(&format!("line {i}\n")); + } + std::fs::write(repo.join("big.txt"), &big).unwrap(); + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-m", "big commit"]); + let log = log_n(&repo, &branch, 1).expect("log"); + let d = diff(&repo, &log[0].sha).expect("diff"); + assert!(d.contains(DIFF_TRUNCATED_MARKER)); + assert!(d.lines().count() <= DIFF_MAX_LINES + 2); + } + + #[test] + fn resolve_branch_flag_env_fallback() { + let (repo, _) = fixture_repo(); + assert_eq!(resolve_branch(&repo, Some("main")).unwrap(), "main"); + assert!(resolve_branch(&repo, Some("nope")).is_err()); + unsafe { std::env::set_var("HU13_BRANCH", "main") }; + assert_eq!(resolve_branch(&repo, None).unwrap(), "main"); + unsafe { std::env::remove_var("HU13_BRANCH") }; + assert_eq!(resolve_branch(&repo, None).unwrap(), "main"); + let err = resolve_branch(&repo, Some("missing")) + .unwrap_err() + .to_string(); + assert!(err.contains("missing")); + } + + #[test] + fn rev_parse_short_matches_git() { + let (repo, branch) = fixture_repo(); + let short = rev_parse_short(&repo, &branch).expect("rev-parse"); + assert!(!short.is_empty()); + let log = log_n(&repo, &branch, 1).expect("log"); + assert!(log[0].sha.starts_with(&short)); + } + + #[test] + fn snapshot_bundle_clones_and_tarball_extracts() { + let (repo, branch) = fixture_repo(); + let out = mktemp("snapshot-out"); + let snap = snapshot(&repo, &branch, &out).expect("snapshot"); + assert!(snap.bundle.exists()); + assert!(snap.tarball.exists()); + + // Bundle clones. + let clone_dir = mktemp("clone"); + let dest = clone_dir.join("cloned"); + let st = Command::new("git") + .arg("clone") + .arg("-b") + .arg(&branch) + .arg(&snap.bundle) + .arg(&dest) + .status() + .expect("git clone"); + assert!(st.success(), "git clone of bundle failed"); + assert!(dest.join("zfile.txt").exists()); + assert!(dest.join("bin.dat").exists()); + + // Tarball extracts. + let extract = mktemp("extract"); + let st = Command::new("tar") + .arg("-xzf") + .arg(&snap.tarball) + .arg("-C") + .arg(&extract) + .status() + .expect("tar extract"); + assert!(st.success(), "tar extract failed"); + assert!(extract.join("zfile.txt").exists()); + assert!(extract.join("bin.dat").exists()); + } +} diff --git a/src/main.rs b/src/main.rs index ace5b0b..100cafe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod gitx; mod layout; const VERSION: &str = env!("CARGO_PKG_VERSION"); -- 2.55.0