#3 m1/gitx
Patch
src/gitx.rs+798 −0--- /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--- 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--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.