#3 m1/gitx

mergedpassopened
details
branches
master ← m1/gitx
updated
id
96daee79-4a27-4d5b-82a8-e8c2f1a90670

Patch

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

Comments (0)

No comments yet.

Reviews (0)

No reviews yet.

all pulls