.forge/prs/96daee79-4a27-4d5b-82a8-e8c2f1a90670/patch.diff 28.7 KiB 827 lines Raw
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