1 From 9be37d764617b0cc0b700dd07b35dafc8ea15ef0 Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 05:04:16 +01004 Subject: [PATCH] m1/gitx: git reader module + snapshot (16 tests)5 6 ---7 src/gitx.rs | 798 ++++++++++++++++++++++++++++++++++++++++++++++++++++8 src/main.rs | 1 +9 2 files changed, 799 insertions(+)10 create mode 100644 src/gitx.rs11 12 diff --git a/src/gitx.rs b/src/gitx.rs13 new file mode 10064414 index 0000000..10d087415 --- /dev/null16 +++ b/src/gitx.rs17 @@ -0,0 +1,798 @@18 +//! Git reader + snapshot helpers for hu13.19 +//!20 +//! All git access goes through `git -C <repo>` subprocesses with a 10s21 +//! timeout. Errors are typed ([`GitError`]); nothing here panics on22 +//! untrusted input or git failures.23 +24 +#![allow(dead_code)]25 +26 +use std::ffi::{OsStr, OsString};27 +use std::io::Read;28 +use std::path::{Path, PathBuf};29 +use std::process::{Command, Stdio};30 +use std::sync::mpsc;31 +use std::thread;32 +use std::time::{Duration, Instant};33 +34 +/// Timeout for every `git` subprocess.35 +pub const GIT_TIMEOUT: Duration = Duration::from_secs(10);36 +37 +/// Default commit count for [`log_n`].38 +pub const DEFAULT_LOG_N: usize = 100;39 +40 +/// Max diff lines before truncation (marker appended).41 +pub const DIFF_MAX_LINES: usize = 2000;42 +43 +/// Marker appended when [`diff`] truncates output.44 +pub const DIFF_TRUNCATED_MARKER: &str = "[hu13: diff truncated at 2000 lines]";45 +46 +/// Marker used when binary content is detected without git's own note.47 +pub const BINARY_NOTE: &str = "[hu13: binary content omitted]";48 +49 +/// Typed errors for all gitx operations. Never panics.50 +#[derive(Debug)]51 +pub enum GitError {52 + /// Repo path or output dir could not be used / created.53 + Io(String),54 + /// `git` binary could not be spawned.55 + Spawn(String),56 + /// `git` did not finish within [`GIT_TIMEOUT`].57 + Timeout(String),58 + /// `git` exited non-zero.59 + Failed {60 + cmd: String,61 + status: String,62 + stderr: String,63 + },64 + /// Requested branch does not exist (names what was tried).65 + BranchNotFound { tried: Vec<String> },66 + /// A rev / sha / path argument was rejected by git.67 + NotFound(String),68 +}69 +70 +impl std::fmt::Display for GitError {71 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {72 + match self {73 + Self::Io(msg) => write!(f, "hu13 gitx: io error: {msg}"),74 + Self::Spawn(msg) => write!(f, "hu13 gitx: failed to spawn git: {msg}"),75 + Self::Timeout(msg) => write!(f, "hu13 gitx: git timed out after 10s: {msg}"),76 + Self::Failed {77 + cmd,78 + status,79 + stderr,80 + } => {81 + write!(f, "hu13 gitx: `{cmd}` failed ({status}): {stderr}")82 + }83 + Self::BranchNotFound { tried } => {84 + write!(85 + f,86 + "hu13 gitx: branch not found (tried: {}); pass --branch or set HU13_BRANCH",87 + tried.join(", ")88 + )89 + }90 + Self::NotFound(msg) => write!(f, "hu13 gitx: not found: {msg}"),91 + }92 + }93 +}94 +95 +impl std::error::Error for GitError {}96 +97 +/// One commit row from [`log_n`].98 +#[derive(Debug, Clone, PartialEq, Eq)]99 +pub struct Commit {100 + /// Full 40-hex sha.101 + pub sha: String,102 + /// First line of the commit message.103 + pub subject: String,104 + /// Author name.105 + pub author: String,106 + /// Author date (`--date=iso`).107 + pub date: String,108 +}109 +110 +/// One tree entry from [`ls_tree`].111 +#[derive(Debug, Clone, PartialEq, Eq)]112 +pub struct TreeEntry {113 + /// Lossy display name (for HTML after `esc()`).114 + pub name: String,115 + /// Exact filename bytes (preserved for non-UTF8 paths).116 + pub name_bytes: Vec<u8>,117 + /// `true` for directories (type `tree`), `false` otherwise.118 + pub is_dir: bool,119 +}120 +121 +/// Exact bytes of `branch:path` from [`show`].122 +#[derive(Debug, Clone, PartialEq, Eq)]123 +pub struct Blob {124 + /// Exact file bytes (byte-exact from `git show`).125 + pub bytes: Vec<u8>,126 + /// Lossy text of `bytes` for display (via `String::from_utf8_lossy`).127 + pub display: String,128 + /// Exact path bytes requested.129 + pub path_bytes: Vec<u8>,130 + /// Lossy display form of the path.131 + pub path_display: String,132 +}133 +134 +/// Output paths from [`snapshot`].135 +#[derive(Debug, Clone, PartialEq, Eq)]136 +pub struct Snapshot {137 + /// Path to the created bundle (`<out>/repo.bundle`).138 + pub bundle: PathBuf,139 + /// Path to the created tarball (`<out>/repo.tar.gz`).140 + pub tarball: PathBuf,141 +}142 +143 +// ---------------------------------------------------------------------------144 +// subprocess core145 +// ---------------------------------------------------------------------------146 +147 +struct GitOutput {148 + status_ok: bool,149 + status_str: String,150 + stdout: Vec<u8>,151 + stderr: Vec<u8>,152 +}153 +154 +fn cmd_string(repo: &Path, args: &[&OsStr]) -> String {155 + let mut s = String::from("git -C ");156 + s.push_str(&repo.to_string_lossy());157 + for a in args {158 + s.push(' ');159 + s.push_str(&a.to_string_lossy());160 + }161 + s162 +}163 +164 +fn run_git(repo: &Path, args: &[&OsStr]) -> Result<GitOutput, GitError> {165 + let mut cmd = Command::new("git");166 + cmd.arg("-C")167 + .arg(repo)168 + .args(args)169 + .stdout(Stdio::piped())170 + .stderr(Stdio::piped());171 + let mut child = cmd.spawn().map_err(|e| GitError::Spawn(e.to_string()))?;172 +173 + let stdout_handle = child.stdout.take();174 + let stderr_handle = child.stderr.take();175 +176 + let (tx_out, rx_out) = mpsc::channel::<Vec<u8>>();177 + let (tx_err, rx_err) = mpsc::channel::<Vec<u8>>();178 +179 + if let Some(mut out) = stdout_handle {180 + let tx = tx_out;181 + thread::spawn(move || {182 + let mut buf = Vec::new();183 + let _ = out.read_to_end(&mut buf);184 + let _ = tx.send(buf);185 + });186 + } else {187 + let _ = tx_out.send(Vec::new());188 + }189 + if let Some(mut err) = stderr_handle {190 + let tx = tx_err;191 + thread::spawn(move || {192 + let mut buf = Vec::new();193 + let _ = err.read_to_end(&mut buf);194 + let _ = tx.send(buf);195 + });196 + } else {197 + let _ = tx_err.send(Vec::new());198 + }199 +200 + let start = Instant::now();201 + loop {202 + match child.try_wait() {203 + Err(e) => {204 + return Err(GitError::Failed {205 + cmd: cmd_string(repo, args),206 + status: "wait-error".to_string(),207 + stderr: e.to_string(),208 + })209 + }210 + Ok(Some(status)) => {211 + let stdout = rx_out212 + .recv_timeout(Duration::from_secs(5))213 + .unwrap_or_default();214 + let stderr = rx_err215 + .recv_timeout(Duration::from_secs(5))216 + .unwrap_or_default();217 + return Ok(GitOutput {218 + status_ok: status.success(),219 + status_str: status.to_string(),220 + stdout,221 + stderr,222 + });223 + }224 + Ok(None) => {225 + if start.elapsed() >= GIT_TIMEOUT {226 + let _ = child.kill();227 + let _ = child.wait();228 + return Err(GitError::Timeout(cmd_string(repo, args)));229 + }230 + thread::sleep(Duration::from_millis(10));231 + }232 + }233 + }234 +}235 +236 +fn run_git_checked(repo: &Path, args: &[&OsStr]) -> Result<Vec<u8>, GitError> {237 + let out = run_git(repo, args)?;238 + if out.status_ok {239 + Ok(out.stdout)240 + } else {241 + Err(GitError::Failed {242 + cmd: cmd_string(repo, args),243 + status: out.status_str,244 + stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(),245 + })246 + }247 +}248 +249 +fn branch_exists(repo: &Path, name: &str) -> Result<bool, GitError> {250 + let refname = format!("refs/heads/{name}");251 + let a_verify: &OsStr = OsStr::new("--verify");252 + let a_quiet: &OsStr = OsStr::new("--quiet");253 + let a_rev: &OsStr = OsStr::new("rev-parse");254 + let os_ref = OsString::from(refname);255 + let args: Vec<&OsStr> = vec![a_rev, a_verify, a_quiet, os_ref.as_os_str()];256 + let out = run_git(repo, &args)?;257 + Ok(out.status_ok)258 +}259 +260 +// ---------------------------------------------------------------------------261 +// public API262 +// ---------------------------------------------------------------------------263 +264 +/// Resolve which branch to render.265 +///266 +/// Precedence: explicit `flag` > `$HU13_BRANCH` > `main` then `master`.267 +/// Returns the first existing branch; otherwise a loud [`GitError::BranchNotFound`]268 +/// naming every name that was tried.269 +pub fn resolve_branch(repo: &Path, flag: Option<&str>) -> Result<String, GitError> {270 + let flag = flag.map(str::trim).filter(|s| !s.is_empty());271 + if let Some(name) = flag {272 + if branch_exists(repo, name)? {273 + return Ok(name.to_string());274 + }275 + return Err(GitError::BranchNotFound {276 + tried: vec![name.to_string()],277 + });278 + }279 + if let Ok(env) = std::env::var("HU13_BRANCH") {280 + let name = env.trim().to_string();281 + if !name.is_empty() {282 + if branch_exists(repo, &name)? {283 + return Ok(name);284 + }285 + return Err(GitError::BranchNotFound { tried: vec![name] });286 + }287 + }288 + for candidate in ["main", "master"] {289 + if branch_exists(repo, candidate)? {290 + return Ok(candidate.to_string());291 + }292 + }293 + Err(GitError::BranchNotFound {294 + tried: vec!["main".to_string(), "master".to_string()],295 + })296 +}297 +298 +/// Short hash for `rev` (`git rev-parse --short <rev>`).299 +pub fn rev_parse_short(repo: &Path, rev: &str) -> Result<String, GitError> {300 + let a_rev: &OsStr = OsStr::new("rev-parse");301 + let a_short: &OsStr = OsStr::new("--short");302 + let os_rev = OsString::from(rev);303 + let args: Vec<&OsStr> = vec![a_rev, a_short, os_rev.as_os_str()];304 + let bytes = run_git_checked(repo, &args).map_err(|e| match e {305 + GitError::Failed {306 + cmd,307 + status,308 + stderr,309 + } => GitError::Failed {310 + cmd,311 + status,312 + stderr: format!("rev `{rev}` not found: {stderr}"),313 + },314 + other => other,315 + })?;316 + let s = String::from_utf8_lossy(&bytes).trim().to_string();317 + if s.is_empty() {318 + return Err(GitError::NotFound(format!("rev `{rev}` not found")));319 + }320 + Ok(s)321 +}322 +323 +/// Newest-first log for `branch`, up to `n` commits.324 +///325 +/// Returns `(sha, subject, author, date)` rows as [`Commit`].326 +pub fn log_n(repo: &Path, branch: &str, n: usize) -> Result<Vec<Commit>, GitError> {327 + let format = "--format=%H\x1f%s\x1f%an\x1f%ad\x1e";328 + let a_log: &OsStr = OsStr::new("log");329 + let os_branch = OsString::from(branch);330 + let os_n = OsString::from(format!("-n{n}"));331 + let os_fmt = OsString::from(format);332 + let os_date = OsString::from("--date=iso");333 + let args: Vec<&OsStr> = vec![334 + a_log,335 + os_branch.as_os_str(),336 + os_n.as_os_str(),337 + os_fmt.as_os_str(),338 + os_date.as_os_str(),339 + ];340 + let bytes = run_git_checked(repo, &args)?;341 + let text = String::from_utf8_lossy(&bytes);342 + let mut out = Vec::new();343 + for record in text.split('\x1e') {344 + let record = record.trim_matches(|c| c == '\n' || c == '\r');345 + if record.trim().is_empty() {346 + continue;347 + }348 + // Strip a single leading newline left by the previous record's terminator.349 + let record = record.strip_prefix('\n').unwrap_or(record);350 + if record.is_empty() {351 + continue;352 + }353 + let mut parts = record.splitn(4, '\x1f');354 + let (Some(sha), Some(subject), Some(author), Some(date)) =355 + (parts.next(), parts.next(), parts.next(), parts.next())356 + else {357 + continue;358 + };359 + out.push(Commit {360 + sha: sha.trim().to_string(),361 + subject: subject.to_string(),362 + author: author.to_string(),363 + date: date.trim().to_string(),364 + });365 + if out.len() >= n {366 + break;367 + }368 + }369 + Ok(out)370 +}371 +372 +/// Directories-first listing of `branch:path` (`path` empty = root).373 +///374 +/// Within each group (dirs, then files) entries are sorted by exact375 +/// filename bytes. Non-UTF8 names keep exact bytes in `name_bytes` with a376 +/// lossy `name` for display.377 +pub fn ls_tree(repo: &Path, branch: &str, path: &str) -> Result<Vec<TreeEntry>, GitError> {378 + let target: String = if path.is_empty() {379 + branch.to_string()380 + } else {381 + format!("{branch}:{}", path.trim_matches('/'))382 + };383 + let a_tree: &OsStr = OsStr::new("ls-tree");384 + let a_z: &OsStr = OsStr::new("-z");385 + let os_target = OsString::from(target.clone());386 + let args: Vec<&OsStr> = vec![a_tree, a_z, os_target.as_os_str()];387 + let bytes = run_git_checked(repo, &args).map_err(|e| match e {388 + GitError::Failed {389 + cmd,390 + status,391 + stderr,392 + } => GitError::NotFound(format!(393 + "{cmd} failed ({status}): {stderr}; tree `{target}` not found"394 + )),395 + other => other,396 + })?;397 +398 + let mut entries = Vec::new();399 + for record in bytes.split(|b| *b == 0) {400 + if record.is_empty() {401 + continue;402 + }403 + let Some(tab) = record.iter().position(|b| *b == b'\t') else {404 + continue;405 + };406 + let meta = &record[..tab];407 + let name_bytes = record[tab + 1..].to_vec();408 + // meta: "<mode> <type> <sha>"409 + let meta_str = String::from_utf8_lossy(meta);410 + let mut it = meta_str.split_whitespace();411 + let _mode = it.next().unwrap_or("");412 + let typ = it.next().unwrap_or("");413 + entries.push(TreeEntry {414 + name: String::from_utf8_lossy(&name_bytes).into_owned(),415 + name_bytes,416 + is_dir: typ == "tree",417 + });418 + }419 + entries.sort_by(|a, b| {420 + b.is_dir421 + .cmp(&a.is_dir)422 + .then_with(|| a.name_bytes.cmp(&b.name_bytes))423 + });424 + Ok(entries)425 +}426 +427 +/// Byte-exact file content for `branch:path`.428 +///429 +/// `path` is a repo-relative path; non-UTF8 paths work because it is430 +/// passed as an `OsStr` (`Path`) straight through to git. The returned431 +/// [`Blob`] keeps exact `bytes` plus a lossy `display` string, and echoes432 +/// the exact `path_bytes` plus lossy `path_display`.433 +pub fn show(repo: &Path, branch: &str, path: &Path) -> Result<Blob, GitError> {434 + let path_os = path.as_os_str();435 + let mut spec = OsString::from(branch);436 + spec.push(":");437 + spec.push(path_os);438 +439 + let a_show: &OsStr = OsStr::new("show");440 + let args: Vec<&OsStr> = vec![a_show, spec.as_os_str()];441 + let out = run_git(repo, &args)?;442 + if !out.status_ok {443 + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();444 + return Err(GitError::NotFound(format!(445 + "`{branch}:{}` not found: {stderr}",446 + path.to_string_lossy()447 + )));448 + }449 + let path_bytes = path_os.as_encoded_bytes().to_vec();450 + Ok(Blob {451 + display: String::from_utf8_lossy(&out.stdout).into_owned(),452 + bytes: out.stdout,453 + path_display: path.to_string_lossy().into_owned(),454 + path_bytes,455 + })456 +}457 +458 +/// Unified diff text for one commit `sha`.459 +///460 +/// Binary diffs keep git's `Binary files ... differ` note (or gain a461 +/// `[hu13: binary content omitted]` note if git emits raw NULs). Output462 +/// past [`DIFF_MAX_LINES`] lines is truncated with [`DIFF_TRUNCATED_MARKER`].463 +pub fn diff(repo: &Path, sha: &str) -> Result<String, GitError> {464 + let a_show: &OsStr = OsStr::new("show");465 + let a_pretty: &OsStr = OsStr::new("--pretty=medium");466 + let a_patch: &OsStr = OsStr::new("--patch");467 + let a_noext: &OsStr = OsStr::new("--no-ext-diff");468 + let os_sha = OsString::from(sha);469 + let args: Vec<&OsStr> = vec![470 + a_show,471 + a_pretty,472 + a_patch,473 + a_noext,474 + os_sha.as_os_str(),475 + OsStr::new("--"),476 + ];477 + let out = run_git(repo, &args)?;478 + if !out.status_ok {479 + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();480 + return Err(GitError::NotFound(format!(481 + "commit `{sha}` not found: {stderr}"482 + )));483 + }484 + let mut text = String::from_utf8_lossy(&out.stdout).into_owned();485 + let has_binary_note = text.contains("Binary files");486 + if !has_binary_note && out.stdout.contains(&0) {487 + text.push('\n');488 + text.push_str(BINARY_NOTE);489 + text.push('\n');490 + }491 + let lines: Vec<&str> = text.lines().collect();492 + if lines.len() > DIFF_MAX_LINES {493 + let mut truncated = lines[..DIFF_MAX_LINES].join("\n");494 + truncated.push('\n');495 + truncated.push_str(DIFF_TRUNCATED_MARKER);496 + truncated.push('\n');497 + return Ok(truncated);498 + }499 + Ok(text)500 +}501 +502 +/// Snapshot `branch` into `out`:503 +///504 +/// * `out/repo.bundle` via `git bundle create repo.bundle <branch>`505 +/// * `out/repo.tar.gz` via `git archive`506 +///507 +/// `out` is created if missing. Returns both paths.508 +pub fn snapshot(repo: &Path, branch: &str, out: &Path) -> Result<Snapshot, GitError> {509 + std::fs::create_dir_all(out)510 + .map_err(|e| GitError::Io(format!("create out dir {}: {e}", out.to_string_lossy())))?;511 + let bundle = out.join("repo.bundle");512 + let tarball = out.join("repo.tar.gz");513 +514 + let bundle_str = bundle.to_string_lossy().into_owned();515 + let os_bundle = OsString::from(&bundle_str);516 + let os_branch = OsString::from(branch);517 + let a_bundle: &OsStr = OsStr::new("bundle");518 + let a_create: &OsStr = OsStr::new("create");519 + let args: Vec<&OsStr> = vec![520 + a_bundle,521 + a_create,522 + os_bundle.as_os_str(),523 + os_branch.as_os_str(),524 + ];525 + let bytes = run_git(repo, &args);526 + let bytes = bytes?;527 + if !bytes.status_ok {528 + let stderr = String::from_utf8_lossy(&bytes.stderr).trim().to_string();529 + return Err(GitError::Failed {530 + cmd: cmd_string(repo, &args),531 + status: bytes.status_str,532 + stderr,533 + });534 + }535 +536 + let tar_str = tarball.to_string_lossy().into_owned();537 + let os_tar = OsString::from(&tar_str);538 + let os_branch2 = OsString::from(branch);539 + let a_archive: &OsStr = OsStr::new("archive");540 + let a_fmt: &OsStr = OsStr::new("--format=tar.gz");541 + let a_out = OsString::from(format!("-o{tar_str}"));542 + let args2: Vec<&OsStr> = vec![a_archive, a_fmt, a_out.as_os_str(), os_branch2.as_os_str()];543 + let _ = os_tar;544 + let out2 = run_git(repo, &args2)?;545 + if !out2.status_ok {546 + let stderr = String::from_utf8_lossy(&out2.stderr).trim().to_string();547 + return Err(GitError::Failed {548 + cmd: cmd_string(repo, &args2),549 + status: out2.status_str,550 + stderr,551 + });552 + }553 + Ok(Snapshot { bundle, tarball })554 +}555 +556 +// ---------------------------------------------------------------------------557 +// tests (std-only fixture repos)558 +// ---------------------------------------------------------------------------559 +560 +#[cfg(test)]561 +mod tests {562 + use super::*;563 + use std::process::Command;564 + use std::sync::atomic::{AtomicU64, Ordering};565 +566 + static COUNTER: AtomicU64 = AtomicU64::new(0);567 +568 + fn mktemp(prefix: &str) -> PathBuf {569 + let id = COUNTER.fetch_add(1, Ordering::SeqCst);570 + let dir = std::env::temp_dir().join(format!(571 + "hu13-{prefix}-{}-{}-{}",572 + std::process::id(),573 + id,574 + std::time::SystemTime::now()575 + .duration_since(std::time::UNIX_EPOCH)576 + .map(|d| d.as_nanos())577 + .unwrap_or(id as u128)578 + ));579 + std::fs::create_dir_all(&dir).expect("mktemp");580 + dir581 + }582 +583 + fn git(repo: &Path, args: &[&str]) {584 + let st = Command::new("git")585 + .arg("-C")586 + .arg(repo)587 + .args(args)588 + .status()589 + .expect("spawn git");590 + assert!(st.success(), "git {args:?} failed in {}", repo.display());591 + }592 +593 + fn git_env(repo: &Path, args: &[&str], k: &str, v: &str) {594 + let st = Command::new("git")595 + .arg("-C")596 + .arg(repo)597 + .args(args)598 + .env(k, v)599 + .status()600 + .expect("spawn git");601 + assert!(st.success(), "git {args:?} failed in {}", repo.display());602 + }603 +604 + /// Build a fixture repo with 3 commits: text, binary + dir, non-ASCII name.605 + /// Returns (repo_dir, branch).606 + fn fixture_repo() -> (PathBuf, String) {607 + let dir = mktemp("fixture");608 + git(&dir, &["init", "-b", "main"]);609 + git(&dir, &["config", "user.name", "hu13-test"]);610 + git(&dir, &["config", "user.email", "[email protected]"]);611 + git(&dir, &["config", "commit.gpgsign", "false"]);612 +613 + // Commit 1: text files + a directory (for dirs-first ordering).614 + std::fs::write(dir.join("zfile.txt"), "line one\n").unwrap();615 + std::fs::write(dir.join("afile.txt"), "alpha\n").unwrap();616 + std::fs::create_dir_all(dir.join("adir")).unwrap();617 + std::fs::write(dir.join("adir").join("inner.txt"), "inner\n").unwrap();618 + git(&dir, &["add", "-A"]);619 + git_env(620 + &dir,621 + &["commit", "-m", "first commit"],622 + "GIT_AUTHOR_DATE",623 + "2024-01-01T00:00:00+00:00",624 + );625 + // fix committer date too via env on same command is not enough; set both:626 + // (rely on default ordering by topology, not date, so fine)627 +628 + // Commit 2: binary file + modify zfile (gives diff hunk later).629 + let bin: Vec<u8> = (0u8..=255).collect();630 + std::fs::write(dir.join("bin.dat"), &bin).unwrap();631 + std::fs::write(dir.join("zfile.txt"), "line one\nline two added\n").unwrap();632 + git(&dir, &["add", "-A"]);633 + git(&dir, &["commit", "-m", "second commit with binary"]);634 +635 + // Commit 3: non-ASCII filename.636 + std::fs::write(dir.join("café.txt"), "unicode name\n").unwrap();637 + git(&dir, &["add", "-A"]);638 + git(&dir, &["commit", "-m", "third commit café"]);639 +640 + (dir, "main".to_string())641 + }642 +643 + #[test]644 + fn log_order_newest_first() {645 + let (repo, branch) = fixture_repo();646 + let log = log_n(&repo, &branch, 100).expect("log");647 + assert_eq!(log.len(), 3);648 + assert_eq!(log[0].subject, "third commit café");649 + assert_eq!(log[1].subject, "second commit with binary");650 + assert_eq!(log[2].subject, "first commit");651 + for c in &log {652 + assert_eq!(c.sha.len(), 40);653 + assert!(!c.author.is_empty());654 + assert!(!c.date.is_empty());655 + }656 + }657 +658 + #[test]659 + fn ls_tree_dirs_first_sorted() {660 + let (repo, branch) = fixture_repo();661 + let entries = ls_tree(&repo, &branch, "").expect("ls-tree");662 + let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();663 + // adir (dir) must come before all files.664 + let dir_pos = names.iter().position(|n| n == "adir").expect("adir");665 + for f in ["afile.txt", "zfile.txt", "bin.dat", "café.txt"] {666 + let p = names.iter().position(|n| n == f).expect(f);667 + assert!(dir_pos < p, "dir should sort before {f}: {names:?}");668 + }669 + assert!(entries.iter().find(|e| e.name == "adir").unwrap().is_dir);670 + assert!(671 + !entries672 + .iter()673 + .find(|e| e.name == "afile.txt")674 + .unwrap()675 + .is_dir676 + );677 + // files sorted by bytes within group678 + let files: Vec<&TreeEntry> = entries.iter().filter(|e| !e.is_dir).collect();679 + let mut sorted = files.clone();680 + sorted.sort_by(|a, b| a.name_bytes.cmp(&b.name_bytes));681 + assert_eq!(files, sorted);682 + }683 +684 + #[test]685 + fn show_is_byte_exact() {686 + let (repo, branch) = fixture_repo();687 + for f in ["zfile.txt", "bin.dat", "café.txt", "adir/inner.txt"] {688 + let blob = show(&repo, &branch, Path::new(f)).expect(f);689 + // Compare against raw `git show` bytes.690 + let out = Command::new("git")691 + .arg("-C")692 + .arg(&repo)693 + .arg("show")694 + .arg(format!("{branch}:{f}"))695 + .output()696 + .expect("git show");697 + assert!(out.status.success());698 + assert_eq!(blob.bytes, out.stdout, "byte-exact for {f}");699 + assert_eq!(blob.display, String::from_utf8_lossy(&blob.bytes));700 + assert_eq!(blob.path_display, f);701 + }702 + }703 +704 + #[test]705 + fn show_non_utf8_path() {706 + let (repo, branch) = fixture_repo();707 + // Create a non-UTF8 filename (Unix only).708 + #[cfg(unix)]709 + {710 + use std::ffi::OsStr;711 + use std::os::unix::ffi::OsStrExt;712 + let raw = b"weird-\xff-name.txt";713 + let p = Path::new(OsStr::from_bytes(raw));714 + std::fs::write(repo.join(p), b"raw bytes content\n").unwrap();715 + git(&repo, &["add", "-A"]);716 + git(&repo, &["commit", "-m", "non-utf8 filename"]);717 + let blob = show(&repo, &branch, p).expect("show non-utf8");718 + assert_eq!(blob.bytes, b"raw bytes content\n");719 + assert_eq!(blob.path_bytes, raw);720 + assert_eq!(blob.path_display, String::from_utf8_lossy(raw).into_owned());721 + }722 + }723 +724 + #[test]725 + fn diff_contains_hunk() {726 + let (repo, branch) = fixture_repo();727 + let log = log_n(&repo, &branch, 100).expect("log");728 + // Middle commit touched zfile.txt adding "line two added".729 + let sha = &log[1].sha;730 + let d = diff(&repo, sha).expect("diff");731 + assert!(732 + d.contains("line two added"),733 + "diff should contain hunk: {d}"734 + );735 + assert!(d.contains("@@") || d.contains("diff --git"));736 + }737 +738 + #[test]739 + fn diff_truncates_long_output() {740 + let (repo, branch) = fixture_repo();741 + // Add a commit with >2000 diff lines.742 + let mut big = String::new();743 + for i in 0..2500 {744 + big.push_str(&format!("line {i}\n"));745 + }746 + std::fs::write(repo.join("big.txt"), &big).unwrap();747 + git(&repo, &["add", "-A"]);748 + git(&repo, &["commit", "-m", "big commit"]);749 + let log = log_n(&repo, &branch, 1).expect("log");750 + let d = diff(&repo, &log[0].sha).expect("diff");751 + assert!(d.contains(DIFF_TRUNCATED_MARKER));752 + assert!(d.lines().count() <= DIFF_MAX_LINES + 2);753 + }754 +755 + #[test]756 + fn resolve_branch_flag_env_fallback() {757 + let (repo, _) = fixture_repo();758 + assert_eq!(resolve_branch(&repo, Some("main")).unwrap(), "main");759 + assert!(resolve_branch(&repo, Some("nope")).is_err());760 + unsafe { std::env::set_var("HU13_BRANCH", "main") };761 + assert_eq!(resolve_branch(&repo, None).unwrap(), "main");762 + unsafe { std::env::remove_var("HU13_BRANCH") };763 + assert_eq!(resolve_branch(&repo, None).unwrap(), "main");764 + let err = resolve_branch(&repo, Some("missing"))765 + .unwrap_err()766 + .to_string();767 + assert!(err.contains("missing"));768 + }769 +770 + #[test]771 + fn rev_parse_short_matches_git() {772 + let (repo, branch) = fixture_repo();773 + let short = rev_parse_short(&repo, &branch).expect("rev-parse");774 + assert!(!short.is_empty());775 + let log = log_n(&repo, &branch, 1).expect("log");776 + assert!(log[0].sha.starts_with(&short));777 + }778 +779 + #[test]780 + fn snapshot_bundle_clones_and_tarball_extracts() {781 + let (repo, branch) = fixture_repo();782 + let out = mktemp("snapshot-out");783 + let snap = snapshot(&repo, &branch, &out).expect("snapshot");784 + assert!(snap.bundle.exists());785 + assert!(snap.tarball.exists());786 +787 + // Bundle clones.788 + let clone_dir = mktemp("clone");789 + let dest = clone_dir.join("cloned");790 + let st = Command::new("git")791 + .arg("clone")792 + .arg("-b")793 + .arg(&branch)794 + .arg(&snap.bundle)795 + .arg(&dest)796 + .status()797 + .expect("git clone");798 + assert!(st.success(), "git clone of bundle failed");799 + assert!(dest.join("zfile.txt").exists());800 + assert!(dest.join("bin.dat").exists());801 +802 + // Tarball extracts.803 + let extract = mktemp("extract");804 + let st = Command::new("tar")805 + .arg("-xzf")806 + .arg(&snap.tarball)807 + .arg("-C")808 + .arg(&extract)809 + .status()810 + .expect("tar extract");811 + assert!(st.success(), "tar extract failed");812 + assert!(extract.join("zfile.txt").exists());813 + assert!(extract.join("bin.dat").exists());814 + }815 +}816 diff --git a/src/main.rs b/src/main.rs817 index ace5b0b..100cafe 100644818 --- a/src/main.rs819 +++ b/src/main.rs820 @@ -1,3 +1,4 @@821 +mod gitx;822 mod layout;823 824 const VERSION: &str = env!("CARGO_PKG_VERSION");825 -- 826 2.55.0827