src/gitx.rs 46.9 KiB 1323 lines Raw
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, Write};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 metadata `git` subprocesses.18 pub const GIT_TIMEOUT: Duration = Duration::from_secs(10);19 20 /// Timeout for the dumb-export step (a full clone + repack).21 ///22 /// Deliberately separate from [`GIT_TIMEOUT`]: the export reproduces a23 /// whole history, so sharing the 10s metadata budget would fail builds24 /// whose bundle already took seconds (f0rge #58).25 pub const EXPORT_TIMEOUT: Duration = Duration::from_secs(120);26 27 /// Default commit count for [`log_n`].28 pub const DEFAULT_LOG_N: usize = 100;29 30 /// Max diff lines before truncation (marker appended).31 pub const DIFF_MAX_LINES: usize = 2000;32 33 /// Marker appended when [`diff`] truncates output.34 pub const DIFF_TRUNCATED_MARKER: &str = "[hu13: diff truncated at 2000 lines]";35 36 /// Marker used when binary content is detected without git's own note.37 pub const BINARY_NOTE: &str = "[hu13: binary content omitted]";38 39 /// Typed errors for all gitx operations. Never panics.40 #[derive(Debug)]41 pub enum GitError {42     /// Repo path or output dir could not be used / created.43     Io(String),44     /// `git` binary could not be spawned.45     Spawn(String),46     /// `git` did not finish within [`GIT_TIMEOUT`].47     Timeout(String),48     /// `git` exited non-zero.49     Failed {50         cmd: String,51         status: String,52         stderr: String,53     },54     /// Requested branch does not exist (names what was tried).55     BranchNotFound { tried: Vec<String> },56     /// A rev / sha / path argument was rejected by git.57     NotFound(String),58 }59 60 impl std::fmt::Display for GitError {61     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {62         match self {63             Self::Io(msg) => write!(f, "hu13 gitx: io error: {msg}"),64             Self::Spawn(msg) => write!(f, "hu13 gitx: failed to spawn git: {msg}"),65             Self::Timeout(msg) => write!(f, "hu13 gitx: git timed out after 10s: {msg}"),66             Self::Failed {67                 cmd,68                 status,69                 stderr,70             } => {71                 write!(f, "hu13 gitx: `{cmd}` failed ({status}): {stderr}")72             }73             Self::BranchNotFound { tried } => {74                 write!(75                     f,76                     "hu13 gitx: branch not found (tried: {}); pass --branch or set HU13_BRANCH",77                     tried.join(", ")78                 )79             }80             Self::NotFound(msg) => write!(f, "hu13 gitx: not found: {msg}"),81         }82     }83 }84 85 impl std::error::Error for GitError {}86 87 /// One commit row from [`log_n`].88 #[derive(Debug, Clone, PartialEq, Eq)]89 pub struct Commit {90     /// Full 40-hex sha.91     pub sha: String,92     /// First line of the commit message.93     pub subject: String,94     /// Author name.95     pub author: String,96     /// Author date (`--date=iso`).97     pub date: String,98 }99 100 /// One tree entry from [`ls_tree`].101 #[derive(Debug, Clone, PartialEq, Eq)]102 pub struct TreeEntry {103     /// Lossy display name (for HTML after `esc()`).104     pub name: String,105     /// Exact filename bytes (preserved for non-UTF8 paths).106     pub name_bytes: Vec<u8>,107     /// `true` for directories (type `tree`), `false` otherwise.108     pub is_dir: bool,109     /// `true` for submodule entries (mode `160000`): no blob exists, so110     /// they must never get a raw copy or a file page (f0rge #68).111     pub is_gitlink: bool,112 }113 114 /// Exact bytes of `branch:path` from [`show`].115 #[derive(Debug, Clone, PartialEq, Eq)]116 pub struct Blob {117     /// Exact file bytes (byte-exact from `git show`).118     pub bytes: Vec<u8>,119     /// Lossy text of `bytes` for display (via `String::from_utf8_lossy`).120     pub display: String,121     /// Exact path bytes requested.122     pub path_bytes: Vec<u8>,123     /// Lossy display form of the path.124     pub path_display: String,125 }126 127 /// Output paths from [`snapshot`].128 #[derive(Debug, Clone, PartialEq, Eq)]129 pub struct Snapshot {130     /// Path to the created bundle (`<out>/repo.bundle`).131     pub bundle: PathBuf,132     /// Path to the created tarball (`<out>/repo.tar.gz`).133     pub tarball: PathBuf,134 }135 136 // ---------------------------------------------------------------------------137 // subprocess core138 // ---------------------------------------------------------------------------139 140 struct GitOutput {141     status_ok: bool,142     status_str: String,143     stdout: Vec<u8>,144     stderr: Vec<u8>,145 }146 147 fn cmd_string(repo: &Path, args: &[&OsStr]) -> String {148     let mut s = String::from("git -C ");149     s.push_str(&repo.to_string_lossy());150     for a in args {151         s.push(' ');152         s.push_str(&a.to_string_lossy());153     }154     s155 }156 157 fn run_git(repo: &Path, args: &[&OsStr]) -> Result<GitOutput, GitError> {158     run_git_opt(Some(repo), args, GIT_TIMEOUT, cmd_string(repo, args))159 }160 161 /// Shared runner for `git` subprocesses with a timeout.162 ///163 /// `repo` is `None` for commands that must not pass `-C` (the dumb-export164 /// clone/update-server-info pair); `display` is the command label used in165 /// errors. Paths stay `OsStr` end to end (f0rge #58): no lossy UTF-8166 /// round-trip.167 fn run_git_opt(168     repo: Option<&Path>,169     args: &[&OsStr],170     timeout: Duration,171     display: String,172 ) -> Result<GitOutput, GitError> {173     let mut cmd = Command::new("git");174     if let Some(repo) = repo {175         cmd.arg("-C").arg(repo);176     }177     cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());178     let mut child = cmd.spawn().map_err(|e| GitError::Spawn(e.to_string()))?;179 180     let stdout_handle = child.stdout.take();181     let stderr_handle = child.stderr.take();182 183     let (tx_out, rx_out) = mpsc::channel::<Vec<u8>>();184     let (tx_err, rx_err) = mpsc::channel::<Vec<u8>>();185 186     if let Some(mut out) = stdout_handle {187         let tx = tx_out;188         thread::spawn(move || {189             let mut buf = Vec::new();190             let _ = out.read_to_end(&mut buf);191             let _ = tx.send(buf);192         });193     } else {194         let _ = tx_out.send(Vec::new());195     }196     if let Some(mut err) = stderr_handle {197         let tx = tx_err;198         thread::spawn(move || {199             let mut buf = Vec::new();200             let _ = err.read_to_end(&mut buf);201             let _ = tx.send(buf);202         });203     } else {204         let _ = tx_err.send(Vec::new());205     }206 207     let start = Instant::now();208     loop {209         match child.try_wait() {210             Err(e) => {211                 return Err(GitError::Failed {212                     cmd: display.clone(),213                     status: "wait-error".to_string(),214                     stderr: e.to_string(),215                 })216             }217             Ok(Some(status)) => {218                 let stdout = rx_out219                     .recv_timeout(Duration::from_secs(5))220                     .unwrap_or_default();221                 let stderr = rx_err222                     .recv_timeout(Duration::from_secs(5))223                     .unwrap_or_default();224                 return Ok(GitOutput {225                     status_ok: status.success(),226                     status_str: status.to_string(),227                     stdout,228                     stderr,229                 });230             }231             Ok(None) => {232                 if start.elapsed() >= timeout {233                     let _ = child.kill();234                     let _ = child.wait();235                     return Err(GitError::Timeout(display.clone()));236                 }237                 thread::sleep(Duration::from_millis(10));238             }239         }240     }241 }242 243 fn run_git_stdin(244     repo: &Path,245     args: &[&OsStr],246     stdin_bytes: Vec<u8>,247 ) -> Result<GitOutput, GitError> {248     use std::io::Write;249     let mut cmd = Command::new("git");250     cmd.arg("-C")251         .arg(repo)252         .args(args)253         .stdin(Stdio::piped())254         .stdout(Stdio::piped())255         .stderr(Stdio::piped());256     let mut child = cmd.spawn().map_err(|e| GitError::Spawn(e.to_string()))?;257 258     if let Some(mut stdin) = child.stdin.take() {259         let (tx_in, rx_in) = mpsc::channel::<Result<(), String>>();260         thread::spawn(move || {261             let r = stdin.write_all(&stdin_bytes).map_err(|e| e.to_string());262             // Close stdin so git sees EOF even on partial writes.263             drop(stdin);264             let _ = tx_in.send(r);265         });266         // Wait briefly for the writer; a failure surfaces via git's exit.267         let _ = rx_in.recv_timeout(Duration::from_secs(5));268     }269 270     let stdout_handle = child.stdout.take();271     let stderr_handle = child.stderr.take();272 273     let (tx_out, rx_out) = mpsc::channel::<Vec<u8>>();274     let (tx_err, rx_err) = mpsc::channel::<Vec<u8>>();275 276     if let Some(mut out) = stdout_handle {277         let tx = tx_out;278         thread::spawn(move || {279             let mut buf = Vec::new();280             let _ = out.read_to_end(&mut buf);281             let _ = tx.send(buf);282         });283     } else {284         let _ = tx_out.send(Vec::new());285     }286     if let Some(mut err) = stderr_handle {287         let tx = tx_err;288         thread::spawn(move || {289             let mut buf = Vec::new();290             let _ = err.read_to_end(&mut buf);291             let _ = tx.send(buf);292         });293     } else {294         let _ = tx_err.send(Vec::new());295     }296 297     let start = Instant::now();298     loop {299         match child.try_wait() {300             Err(e) => {301                 return Err(GitError::Failed {302                     cmd: cmd_string(repo, args),303                     status: "wait-error".to_string(),304                     stderr: e.to_string(),305                 })306             }307             Ok(Some(status)) => {308                 let stdout = rx_out309                     .recv_timeout(Duration::from_secs(5))310                     .unwrap_or_default();311                 let stderr = rx_err312                     .recv_timeout(Duration::from_secs(5))313                     .unwrap_or_default();314                 return Ok(GitOutput {315                     status_ok: status.success(),316                     status_str: status.to_string(),317                     stdout,318                     stderr,319                 });320             }321             Ok(None) => {322                 if start.elapsed() >= GIT_TIMEOUT {323                     let _ = child.kill();324                     let _ = child.wait();325                     return Err(GitError::Timeout(cmd_string(repo, args)));326                 }327                 thread::sleep(Duration::from_millis(10));328             }329         }330     }331 }332 333 fn run_git_checked(repo: &Path, args: &[&OsStr]) -> Result<Vec<u8>, GitError> {334     let out = run_git(repo, args)?;335     if out.status_ok {336         Ok(out.stdout)337     } else {338         Err(GitError::Failed {339             cmd: cmd_string(repo, args),340             status: out.status_str,341             stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(),342         })343     }344 }345 346 fn branch_exists(repo: &Path, name: &str) -> Result<bool, GitError> {347     let refname = format!("refs/heads/{name}");348     let a_verify: &OsStr = OsStr::new("--verify");349     let a_quiet: &OsStr = OsStr::new("--quiet");350     let a_rev: &OsStr = OsStr::new("rev-parse");351     let os_ref = OsString::from(refname);352     let args: Vec<&OsStr> = vec![a_rev, a_verify, a_quiet, os_ref.as_os_str()];353     let out = run_git(repo, &args)?;354     Ok(out.status_ok)355 }356 357 // ---------------------------------------------------------------------------358 // public API359 // ---------------------------------------------------------------------------360 361 /// Resolve which branch to render.362 ///363 /// Precedence: explicit `flag` > `$HU13_BRANCH` > `main` then `master`.364 /// Returns the first existing branch; otherwise a loud [`GitError::BranchNotFound`]365 /// naming every name that was tried.366 pub fn resolve_branch(repo: &Path, flag: Option<&str>) -> Result<String, GitError> {367     let flag = flag.map(str::trim).filter(|s| !s.is_empty());368     if let Some(name) = flag {369         if branch_exists(repo, name)? {370             return Ok(name.to_string());371         }372         return Err(GitError::BranchNotFound {373             tried: vec![name.to_string()],374         });375     }376     if let Ok(env) = std::env::var("HU13_BRANCH") {377         let name = env.trim().to_string();378         if !name.is_empty() {379             if branch_exists(repo, &name)? {380                 return Ok(name);381             }382             return Err(GitError::BranchNotFound { tried: vec![name] });383         }384     }385     for candidate in ["main", "master"] {386         if branch_exists(repo, candidate)? {387             return Ok(candidate.to_string());388         }389     }390     Err(GitError::BranchNotFound {391         tried: vec!["main".to_string(), "master".to_string()],392     })393 }394 395 /// Short hash for `rev` (`git rev-parse --short <rev>`).396 pub fn rev_parse_short(repo: &Path, rev: &str) -> Result<String, GitError> {397     let a_rev: &OsStr = OsStr::new("rev-parse");398     let a_short: &OsStr = OsStr::new("--short");399     let os_rev = OsString::from(rev);400     let args: Vec<&OsStr> = vec![a_rev, a_short, os_rev.as_os_str()];401     let bytes = run_git_checked(repo, &args).map_err(|e| match e {402         GitError::Failed {403             cmd,404             status,405             stderr,406         } => GitError::Failed {407             cmd,408             status,409             stderr: format!("rev `{rev}` not found: {stderr}"),410         },411         other => other,412     })?;413     let s = String::from_utf8_lossy(&bytes).trim().to_string();414     if s.is_empty() {415         return Err(GitError::NotFound(format!("rev `{rev}` not found")));416     }417     Ok(s)418 }419 420 /// One path's last-commit summary for file tables (m6 code-gitea,421 /// f0rge #33): subject line + committer timestamp (unix, `%ct`).422 #[derive(Debug, Clone, PartialEq, Eq)]423 pub struct PathCommit {424     /// First line of the last commit touching the path (`%s`).425     pub subject: String,426     /// Committer date as unix seconds (`%ct`).427     pub committer_ts: i64,428 }429 430 /// Last commit touching `path` on `branch` (`git log -1`).431 ///432 /// Returns `None` when the path has no history (empty output). `path`433 /// is repo-relative (`a/b.txt` or `a/b` for dirs); empty means the434 /// branch head itself is not queried here (callers skip the root).435 /// Subject is a single `%s` line (no newlines); callers must still436 /// escape it for HTML and truncate it for display.437 pub fn last_commit_for_path(438     repo: &Path,439     branch: &str,440     path: &str,441 ) -> Result<Option<PathCommit>, GitError> {442     let a_log: &OsStr = OsStr::new("log");443     let a_one: &OsStr = OsStr::new("-1");444     let a_fmt: &OsStr = OsStr::new("--format=%s\x1f%ct");445     let os_branch = OsString::from(branch);446     let os_sep: &OsStr = OsStr::new("--");447     let os_path = OsString::from(path);448     let args: Vec<&OsStr> = vec![449         a_log,450         a_one,451         a_fmt,452         os_branch.as_os_str(),453         os_sep,454         os_path.as_os_str(),455     ];456     let out = run_git(repo, &args)?;457     if !out.status_ok {458         let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();459         return Err(GitError::Failed {460             cmd: cmd_string(repo, &args),461             status: out.status_str,462             stderr,463         });464     }465     let text = String::from_utf8_lossy(&out.stdout).trim().to_string();466     if text.is_empty() {467         return Ok(None);468     }469     let first_line = text.lines().next().unwrap_or("");470     let mut parts = first_line.splitn(2, '\x1f');471     let subject = parts.next().unwrap_or("").to_string();472     let ts: i64 = parts.next().unwrap_or("0").trim().parse().unwrap_or(0);473     Ok(Some(PathCommit {474         subject,475         committer_ts: ts,476     }))477 }478 479 /// Newest-first log for `branch`, up to `n` commits.480 ///481 /// Returns `(sha, subject, author, date)` rows as [`Commit`].482 pub fn log_n(repo: &Path, branch: &str, n: usize) -> Result<Vec<Commit>, GitError> {483     let format = "--format=%H\x1f%s\x1f%an\x1f%ad\x1e";484     let a_log: &OsStr = OsStr::new("log");485     let os_branch = OsString::from(branch);486     let os_n = OsString::from(format!("-n{n}"));487     let os_fmt = OsString::from(format);488     let os_date = OsString::from("--date=iso");489     let args: Vec<&OsStr> = vec![490         a_log,491         os_branch.as_os_str(),492         os_n.as_os_str(),493         os_fmt.as_os_str(),494         os_date.as_os_str(),495     ];496     let bytes = run_git_checked(repo, &args)?;497     let text = String::from_utf8_lossy(&bytes);498     let mut out = Vec::new();499     for record in text.split('\x1e') {500         let record = record.trim_matches(|c| c == '\n' || c == '\r');501         if record.trim().is_empty() {502             continue;503         }504         // Strip a single leading newline left by the previous record's terminator.505         let record = record.strip_prefix('\n').unwrap_or(record);506         if record.is_empty() {507             continue;508         }509         let mut parts = record.splitn(4, '\x1f');510         let (Some(sha), Some(subject), Some(author), Some(date)) =511             (parts.next(), parts.next(), parts.next(), parts.next())512         else {513             continue;514         };515         out.push(Commit {516             sha: sha.trim().to_string(),517             subject: subject.to_string(),518             author: author.to_string(),519             date: date.trim().to_string(),520         });521         if out.len() >= n {522             break;523         }524     }525     Ok(out)526 }527 528 /// Full commit message body for `sha` (`git show -s --format=%B <sha>`).529 ///530 /// Lossy UTF-8; callers must still escape it for HTML.531 pub fn message(repo: &Path, sha: &str) -> Result<String, GitError> {532     let a_show: &OsStr = OsStr::new("show");533     let a_silent: &OsStr = OsStr::new("-s");534     let a_fmt: &OsStr = OsStr::new("--format=%B");535     let os_sha = OsString::from(sha);536     let args: Vec<&OsStr> = vec![a_show, a_silent, a_fmt, os_sha.as_os_str()];537     let out = run_git(repo, &args)?;538     if !out.status_ok {539         let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();540         return Err(GitError::NotFound(format!(541             "commit `{sha}` not found: {stderr}"542         )));543     }544     Ok(String::from_utf8_lossy(&out.stdout).into_owned())545 }546 547 /// Directories-first listing of `branch:path` (`path` empty = root).548 ///549 /// Within each group (dirs, then files) entries are sorted by exact550 /// filename bytes. Non-UTF8 names keep exact bytes in `name_bytes` with a551 /// lossy `name` for display.552 pub fn ls_tree(repo: &Path, branch: &str, path: &str) -> Result<Vec<TreeEntry>, GitError> {553     let target: String = if path.is_empty() {554         branch.to_string()555     } else {556         format!("{branch}:{}", path.trim_matches('/'))557     };558     let a_tree: &OsStr = OsStr::new("ls-tree");559     let a_z: &OsStr = OsStr::new("-z");560     let os_target = OsString::from(target.clone());561     let args: Vec<&OsStr> = vec![a_tree, a_z, os_target.as_os_str()];562     let bytes = run_git_checked(repo, &args).map_err(|e| match e {563         GitError::Failed {564             cmd,565             status,566             stderr,567         } => GitError::NotFound(format!(568             "{cmd} failed ({status}): {stderr}; tree `{target}` not found"569         )),570         other => other,571     })?;572 573     let mut entries = Vec::new();574     for record in bytes.split(|b| *b == 0) {575         if record.is_empty() {576             continue;577         }578         let Some(tab) = record.iter().position(|b| *b == b'\t') else {579             continue;580         };581         let meta = &record[..tab];582         let name_bytes = record[tab + 1..].to_vec();583         // meta: "<mode> <type> <sha>"584         let meta_str = String::from_utf8_lossy(meta);585         let mut it = meta_str.split_whitespace();586         let mode = it.next().unwrap_or("");587         let typ = it.next().unwrap_or("");588         entries.push(TreeEntry {589             name: String::from_utf8_lossy(&name_bytes).into_owned(),590             name_bytes,591             is_dir: typ == "tree",592             is_gitlink: mode == "160000",593         });594     }595     entries.sort_by(|a, b| {596         b.is_dir597             .cmp(&a.is_dir)598             .then_with(|| a.name_bytes.cmp(&b.name_bytes))599     });600     Ok(entries)601 }602 603 /// Subset of repo-relative `paths` excluded by gitignore rules.604 ///605 /// Runs `git check-ignore --no-index --stdin -z` (NUL-separated, so606 /// spaces/newlines in names survive). `--no-index` matters: by607 /// default git hides tracked files from exclude checks, but hu13608 /// lists tracked files via [`ls_tree`], so a force-added609 /// gitignored path must still be filtered from language math.610 /// Untracked/ignored junk never reaches [`ls_tree`] at all.611 ///612 /// Returns the ignored subset (empty when nothing matches). Exit613 /// status 1 with empty output ("no matches") is success, not an614 /// error. Other git failures surface as [`GitError`]; callers615 /// building language math should degrade to "no ignores" rather616 /// than fail the build.617 ///618 /// NUL bytes inside a path would corrupt the `-z` framing, so such619 /// paths are skipped on input (they never match output either).620 pub fn ignored_paths(621     repo: &Path,622     paths: &[String],623 ) -> Result<std::collections::HashSet<String>, GitError> {624     use std::collections::HashSet;625     let clean: Vec<&str> = paths626         .iter()627         .map(String::as_str)628         .filter(|p| !p.is_empty() && !p.contains('\0'))629         .collect();630     if clean.is_empty() {631         return Ok(HashSet::new());632     }633     let mut stdin_bytes = Vec::new();634     for p in &clean {635         stdin_bytes.extend_from_slice(p.as_bytes());636         stdin_bytes.push(0);637     }638     let a_check: &OsStr = OsStr::new("check-ignore");639     let a_no_index: &OsStr = OsStr::new("--no-index");640     let a_stdin: &OsStr = OsStr::new("--stdin");641     let a_z: &OsStr = OsStr::new("-z");642     let args: Vec<&OsStr> = vec![a_check, a_no_index, a_stdin, a_z];643     let out = run_git_stdin(repo, &args, stdin_bytes)?;644     if out.stdout.is_empty() {645         // Exit 0 (all tracked, none ignored) or 1 (no matches): same result.646         return Ok(HashSet::new());647     }648     if !out.status_ok {649         let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();650         return Err(GitError::Failed {651             cmd: cmd_string(repo, &args),652             status: out.status_str,653             stderr,654         });655     }656     let mut set = HashSet::new();657     for chunk in out.stdout.split(|b| *b == 0) {658         if chunk.is_empty() {659             continue;660         }661         set.insert(String::from_utf8_lossy(chunk).into_owned());662     }663     Ok(set)664 }665 666 /// Byte-exact file content for `branch:path`.667 ///668 /// `path` is a repo-relative path; non-UTF8 paths work because it is669 /// passed as an `OsStr` (`Path`) straight through to git. The returned670 /// [`Blob`] keeps exact `bytes` plus a lossy `display` string, and echoes671 /// the exact `path_bytes` plus lossy `path_display`.672 pub fn show(repo: &Path, branch: &str, path: &Path) -> Result<Blob, GitError> {673     let path_os = path.as_os_str();674     let mut spec = OsString::from(branch);675     spec.push(":");676     spec.push(path_os);677 678     let a_show: &OsStr = OsStr::new("show");679     let args: Vec<&OsStr> = vec![a_show, spec.as_os_str()];680     let out = run_git(repo, &args)?;681     if !out.status_ok {682         let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();683         return Err(GitError::NotFound(format!(684             "`{branch}:{}` not found: {stderr}",685             path.to_string_lossy()686         )));687     }688     let path_bytes = path_os.as_encoded_bytes().to_vec();689     Ok(Blob {690         display: String::from_utf8_lossy(&out.stdout).into_owned(),691         bytes: out.stdout,692         path_display: path.to_string_lossy().into_owned(),693         path_bytes,694     })695 }696 697 /// Size in bytes of `branch:path` without reading it (`git cat-file -s`).698 ///699 /// Callers use this to avoid loading very large blobs into memory700 /// (f0rge #66): a 400 MB blob used to abort the process in the701 /// allocator.702 pub fn blob_size(repo: &Path, branch: &str, path: &Path) -> Result<u64, GitError> {703     let mut spec = OsString::from(branch);704     spec.push(":");705     spec.push(path.as_os_str());706     let a_cat: &OsStr = OsStr::new("cat-file");707     let a_size: &OsStr = OsStr::new("-s");708     let args: Vec<&OsStr> = vec![a_cat, a_size, spec.as_os_str()];709     let out = run_git(repo, &args)?;710     if !out.status_ok {711         let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();712         return Err(GitError::NotFound(format!(713             "`{branch}:{}` not found: {stderr}",714             path.to_string_lossy()715         )));716     }717     String::from_utf8_lossy(&out.stdout)718         .trim()719         .parse::<u64>()720         .map_err(|e| GitError::Failed {721             cmd: cmd_string(repo, &args),722             status: "parse".to_string(),723             stderr: format!("cat-file -s: {e}"),724         })725 }726 727 /// Stream `branch:path` to `dest` without buffering it in the process.728 ///729 /// Used for blobs above the in-memory cap (f0rge #66). The destination730 /// is removed again if git fails or the timeout fires, so a partial731 /// file never survives a failed build.732 pub fn show_to_file(repo: &Path, branch: &str, path: &Path, dest: &Path) -> Result<(), GitError> {733     let mut spec = OsString::from(branch);734     spec.push(":");735     spec.push(path.as_os_str());736     let a_show: &OsStr = OsStr::new("show");737     let args: Vec<&OsStr> = vec![a_show, spec.as_os_str()];738 739     let mut cmd = Command::new("git");740     cmd.arg("-C")741         .arg(repo)742         .args(&args)743         .stdout(Stdio::piped())744         .stderr(Stdio::piped());745     let mut child = cmd.spawn().map_err(|e| GitError::Spawn(e.to_string()))?;746     let stdout = child.stdout.take();747     let stderr = child.stderr.take();748 749     let (tx_err, rx_err) = mpsc::channel::<Vec<u8>>();750     if let Some(mut err) = stderr {751         thread::spawn(move || {752             let mut buf = Vec::new();753             let _ = err.read_to_end(&mut buf);754             let _ = tx_err.send(buf);755         });756     } else {757         let _ = tx_err.send(Vec::new());758     }759 760     let dest_owned = dest.to_path_buf();761     let (tx_copy, rx_copy) = mpsc::channel::<std::io::Result<u64>>();762     if let Some(mut out) = stdout {763         thread::spawn(move || {764             let file = match std::fs::File::create(&dest_owned) {765                 Ok(f) => f,766                 Err(e) => {767                     let _ = tx_copy.send(Err(e));768                     return;769                 }770             };771             let mut w = std::io::BufWriter::new(file);772             let res = std::io::copy(&mut out, &mut w).and_then(|n| w.flush().map(|()| n));773             let _ = tx_copy.send(res);774         });775     } else {776         let _ = tx_copy.send(Err(std::io::Error::other("git show produced no stdout")));777     }778 779     let start = Instant::now();780     let status = loop {781         match child.try_wait() {782             Err(e) => {783                 let _ = std::fs::remove_file(dest);784                 return Err(GitError::Failed {785                     cmd: cmd_string(repo, &args),786                     status: "wait-error".to_string(),787                     stderr: e.to_string(),788                 });789             }790             Ok(Some(status)) => break status,791             Ok(None) => {792                 if start.elapsed() > GIT_TIMEOUT {793                     let _ = child.kill();794                     let _ = child.wait();795                     let _ = std::fs::remove_file(dest);796                     return Err(GitError::Timeout(cmd_string(repo, &args)));797                 }798                 thread::sleep(Duration::from_millis(10));799             }800         }801     };802     let written = rx_copy803         .recv_timeout(Duration::from_secs(5))804         .unwrap_or_else(|_| Err(std::io::Error::other("copy thread hung")));805     let stderr = rx_err806         .recv_timeout(Duration::from_secs(5))807         .unwrap_or_default();808     if !status.success() || written.is_err() {809         let _ = std::fs::remove_file(dest);810         return Err(GitError::Failed {811             cmd: cmd_string(repo, &args),812             status: status.to_string(),813             stderr: String::from_utf8_lossy(&stderr).trim().to_string(),814         });815     }816     Ok(())817 }818 819 /// Unified diff text for one commit `sha`.820 ///821 /// Binary diffs keep git's `Binary files ... differ` note (or gain a822 /// `[hu13: binary content omitted]` note if git emits raw NULs). Output823 /// past [`DIFF_MAX_LINES`] lines is truncated with [`DIFF_TRUNCATED_MARKER`].824 pub fn diff(repo: &Path, sha: &str) -> Result<String, GitError> {825     let a_show: &OsStr = OsStr::new("show");826     let a_pretty: &OsStr = OsStr::new("--pretty=medium");827     let a_patch: &OsStr = OsStr::new("--patch");828     let a_noext: &OsStr = OsStr::new("--no-ext-diff");829     let os_sha = OsString::from(sha);830     let args: Vec<&OsStr> = vec![831         a_show,832         a_pretty,833         a_patch,834         a_noext,835         os_sha.as_os_str(),836         OsStr::new("--"),837     ];838     let out = run_git(repo, &args)?;839     if !out.status_ok {840         let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();841         return Err(GitError::NotFound(format!(842             "commit `{sha}` not found: {stderr}"843         )));844     }845     let mut text = String::from_utf8_lossy(&out.stdout).into_owned();846     let has_binary_note = text.contains("Binary files");847     if !has_binary_note && out.stdout.contains(&0) {848         text.push('\n');849         text.push_str(BINARY_NOTE);850         text.push('\n');851     }852     let lines: Vec<&str> = text.lines().collect();853     if lines.len() > DIFF_MAX_LINES {854         let mut truncated = lines[..DIFF_MAX_LINES].join("\n");855         truncated.push('\n');856         truncated.push_str(DIFF_TRUNCATED_MARKER);857         truncated.push('\n');858         return Ok(truncated);859     }860     Ok(text)861 }862 863 /// Snapshot `branch` into `out`:864 ///865 /// * `out/repo.bundle` via `git bundle create repo.bundle <branch>`866 /// * `out/repo.tar.gz` via `git archive`867 ///868 /// `out` is created if missing. Returns both paths.869 pub fn snapshot(repo: &Path, branch: &str, out: &Path) -> Result<Snapshot, GitError> {870     std::fs::create_dir_all(out)871         .map_err(|e| GitError::Io(format!("create out dir {}: {e}", out.to_string_lossy())))?;872     let bundle = out.join("repo.bundle");873     let tarball = out.join("repo.tar.gz");874 875     let os_branch = OsString::from(branch);876     let a_bundle: &OsStr = OsStr::new("bundle");877     let a_create: &OsStr = OsStr::new("create");878     let args: Vec<&OsStr> = vec![879         a_bundle,880         a_create,881         bundle.as_os_str(),882         os_branch.as_os_str(),883     ];884     let bytes = run_git(repo, &args);885     let bytes = bytes?;886     if !bytes.status_ok {887         let stderr = String::from_utf8_lossy(&bytes.stderr).trim().to_string();888         return Err(GitError::Failed {889             cmd: cmd_string(repo, &args),890             status: bytes.status_str,891             stderr,892         });893     }894 895     let os_branch2 = OsString::from(branch);896     let a_archive: &OsStr = OsStr::new("archive");897     let a_fmt: &OsStr = OsStr::new("--format=tar.gz");898     // `git archive -o<path>`: build the flag as an OsString (no lossy899     // UTF-8 round-trip for non-UTF-8 output paths).900     let mut a_out = OsString::from("-o");901     a_out.push(tarball.as_os_str());902     let args2: Vec<&OsStr> = vec![a_archive, a_fmt, a_out.as_os_str(), os_branch2.as_os_str()];903     let out2 = run_git(repo, &args2)?;904     if !out2.status_ok {905         let stderr = String::from_utf8_lossy(&out2.stderr).trim().to_string();906         return Err(GitError::Failed {907             cmd: cmd_string(repo, &args2),908             status: out2.status_str,909             stderr,910         });911     }912     Ok(Snapshot { bundle, tarball })913 }914 915 /// Export a dumb-HTTP clone dir into `out/repo.git` (issues #54/#55/#56/#57).916 ///917 /// The source is the **bundle** written by [`snapshot`], not the live918 /// repo: bundle transport negotiates reachable objects only, so the919 /// export is exactly the snapshot — one pack, no loose objects, no920 /// unreachable/dangling history leaking out of `refs` (f0rge #55/#56).921 /// A local-path clone (`git clone <repo>`) uses local transport instead922 /// and copies the whole object DB, including unreachable commits.923 ///924 /// After cloning we drop `remote.origin` (it held the build machine's925 /// absolute path, f0rge #57), remove the sample hooks (file-count noise),926 /// and run `update-server-info` so plain static servers927 /// (Pages/nginx/`hu13 serve`) can serve `git clone <origin>/repo.git`928 /// with zero dynamic smart HTTP.929 ///930 /// Failures are loud (build fails, never a silent missing dir), and an931 /// existing `out/repo.git` is removed first so stale objects never survive.932 pub fn export_dumb_repo(bundle: &Path, branch: &str, out: &Path) -> Result<PathBuf, GitError> {933     std::fs::create_dir_all(out)934         .map_err(|e| GitError::Io(format!("create out dir {}: {e}", out.to_string_lossy())))?;935     let dest = out.join("repo.git");936     if dest.exists() {937         std::fs::remove_dir_all(&dest)938             .map_err(|e| GitError::Io(format!("clean {}: {e}", dest.to_string_lossy())))?;939     }940 941     // Prefer a relative `.git`-free repo: clone with `--no-hardlinks` is942     // irrelevant for bundles, but `--quiet` keeps build output clean.943     let a_clone: &OsStr = OsStr::new("clone");944     let a_bare: &OsStr = OsStr::new("--bare");945     let a_quiet: &OsStr = OsStr::new("--quiet");946     let a_single: &OsStr = OsStr::new("--single-branch");947     let a_branch: &OsStr = OsStr::new("--branch");948     let a_sep: &OsStr = OsStr::new("--");949     let args: Vec<&OsStr> = vec![950         a_clone,951         a_bare,952         a_quiet,953         a_single,954         a_branch,955         OsStr::new(branch),956         a_sep,957         bundle.as_os_str(),958         dest.as_os_str(),959     ];960     let display =961         format!("git clone --bare --single-branch --branch {branch} <repo.bundle> repo.git");962     let out1 = run_git_opt(None, &args, EXPORT_TIMEOUT, display.clone())?;963     if !out1.status_ok {964         return Err(GitError::Failed {965             cmd: display,966             status: out1.status_str,967             stderr: String::from_utf8_lossy(&out1.stderr).trim().to_string(),968         });969     }970 971     // `remote.origin.url` would publish the local filesystem path.972     // Build `--git-dir=<path>` as an OsString: no UTF-8 round-trip.973     let mut git_dir_arg = OsString::from("--git-dir=");974     git_dir_arg.push(dest.as_os_str());975     let a_remote: &OsStr = OsStr::new("remote");976     let a_remove: &OsStr = OsStr::new("remove");977     let a_origin: &OsStr = OsStr::new("origin");978     let args_remote: Vec<&OsStr> = vec![git_dir_arg.as_os_str(), a_remote, a_remove, a_origin];979     let display_remote = "git --git-dir=<dest> remote remove origin".to_string();980     let out2 = run_git_opt(None, &args_remote, GIT_TIMEOUT, display_remote.clone())?;981     if !out2.status_ok {982         return Err(GitError::Failed {983             cmd: display_remote,984             status: out2.status_str,985             stderr: String::from_utf8_lossy(&out2.stderr).trim().to_string(),986         });987     }988 989     // Sample hooks are noise for a mirror; removing them also keeps the990     // dumb-HTTP file count down for the Pages 20k-file cap.991     let hooks = dest.join("hooks");992     if let Ok(rd) = std::fs::read_dir(&hooks) {993         for ent in rd.flatten() {994             let path = ent.path();995             if path.extension().is_some_and(|e| e == "sample") {996                 let _ = std::fs::remove_file(path);997             }998         }999     }1000 1001     let a_usi: &OsStr = OsStr::new("update-server-info");1002     let args2: Vec<&OsStr> = vec![git_dir_arg.as_os_str(), a_usi];1003     let display_usi = "git --git-dir=<dest> update-server-info".to_string();1004     let out3 = run_git_opt(None, &args2, EXPORT_TIMEOUT, display_usi.clone())?;1005     if !out3.status_ok {1006         return Err(GitError::Failed {1007             cmd: display_usi,1008             status: out3.status_str,1009             stderr: String::from_utf8_lossy(&out3.stderr).trim().to_string(),1010         });1011     }1012     Ok(dest)1013 }1014 1015 // ---------------------------------------------------------------------------1016 // tests (std-only fixture repos)1017 // ---------------------------------------------------------------------------1018 1019 #[cfg(test)]1020 mod tests {1021     use super::*;1022     use std::process::Command;1023     use std::sync::atomic::{AtomicU64, Ordering};1024 1025     static COUNTER: AtomicU64 = AtomicU64::new(0);1026 1027     fn mktemp(prefix: &str) -> PathBuf {1028         let id = COUNTER.fetch_add(1, Ordering::SeqCst);1029         let dir = std::env::temp_dir().join(format!(1030             "hu13-{prefix}-{}-{}-{}",1031             std::process::id(),1032             id,1033             std::time::SystemTime::now()1034                 .duration_since(std::time::UNIX_EPOCH)1035                 .map(|d| d.as_nanos())1036                 .unwrap_or(id as u128)1037         ));1038         std::fs::create_dir_all(&dir).expect("mktemp");1039         dir1040     }1041 1042     fn git(repo: &Path, args: &[&str]) {1043         let st = Command::new("git")1044             .arg("-C")1045             .arg(repo)1046             .args(args)1047             .status()1048             .expect("spawn git");1049         assert!(st.success(), "git {args:?} failed in {}", repo.display());1050     }1051 1052     fn git_env(repo: &Path, args: &[&str], k: &str, v: &str) {1053         let st = Command::new("git")1054             .arg("-C")1055             .arg(repo)1056             .args(args)1057             .env(k, v)1058             .status()1059             .expect("spawn git");1060         assert!(st.success(), "git {args:?} failed in {}", repo.display());1061     }1062 1063     /// Build a fixture repo with 3 commits: text, binary + dir, non-ASCII name.1064     /// Returns (repo_dir, branch).1065     fn fixture_repo() -> (PathBuf, String) {1066         let dir = mktemp("fixture");1067         git(&dir, &["init", "-b", "main"]);1068         git(&dir, &["config", "user.name", "hu13-test"]);1069         git(&dir, &["config", "user.email", "[email protected]"]);1070         git(&dir, &["config", "commit.gpgsign", "false"]);1071 1072         // Commit 1: text files + a directory (for dirs-first ordering).1073         std::fs::write(dir.join("zfile.txt"), "line one\n").unwrap();1074         std::fs::write(dir.join("afile.txt"), "alpha\n").unwrap();1075         std::fs::create_dir_all(dir.join("adir")).unwrap();1076         std::fs::write(dir.join("adir").join("inner.txt"), "inner\n").unwrap();1077         git(&dir, &["add", "-A"]);1078         git_env(1079             &dir,1080             &["commit", "-m", "first commit"],1081             "GIT_AUTHOR_DATE",1082             "2024-01-01T00:00:00+00:00",1083         );1084         // fix committer date too via env on same command is not enough; set both:1085         // (rely on default ordering by topology, not date, so fine)1086 1087         // Commit 2: binary file + modify zfile (gives diff hunk later).1088         let bin: Vec<u8> = (0u8..=255).collect();1089         std::fs::write(dir.join("bin.dat"), &bin).unwrap();1090         std::fs::write(dir.join("zfile.txt"), "line one\nline two added\n").unwrap();1091         git(&dir, &["add", "-A"]);1092         git(&dir, &["commit", "-m", "second commit with binary"]);1093 1094         // Commit 3: non-ASCII filename.1095         std::fs::write(dir.join("café.txt"), "unicode name\n").unwrap();1096         git(&dir, &["add", "-A"]);1097         git(&dir, &["commit", "-m", "third commit café"]);1098 1099         (dir, "main".to_string())1100     }1101 1102     #[test]1103     fn log_order_newest_first() {1104         let (repo, branch) = fixture_repo();1105         let log = log_n(&repo, &branch, 100).expect("log");1106         assert_eq!(log.len(), 3);1107         assert_eq!(log[0].subject, "third commit café");1108         assert_eq!(log[1].subject, "second commit with binary");1109         assert_eq!(log[2].subject, "first commit");1110         for c in &log {1111             assert_eq!(c.sha.len(), 40);1112             assert!(!c.author.is_empty());1113             assert!(!c.date.is_empty());1114         }1115     }1116 1117     #[test]1118     fn ls_tree_dirs_first_sorted() {1119         let (repo, branch) = fixture_repo();1120         let entries = ls_tree(&repo, &branch, "").expect("ls-tree");1121         let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();1122         // adir (dir) must come before all files.1123         let dir_pos = names.iter().position(|n| n == "adir").expect("adir");1124         for f in ["afile.txt", "zfile.txt", "bin.dat", "café.txt"] {1125             let p = names.iter().position(|n| n == f).expect(f);1126             assert!(dir_pos < p, "dir should sort before {f}: {names:?}");1127         }1128         assert!(entries.iter().find(|e| e.name == "adir").unwrap().is_dir);1129         assert!(1130             !entries1131                 .iter()1132                 .find(|e| e.name == "afile.txt")1133                 .unwrap()1134                 .is_dir1135         );1136         // files sorted by bytes within group1137         let files: Vec<&TreeEntry> = entries.iter().filter(|e| !e.is_dir).collect();1138         let mut sorted = files.clone();1139         sorted.sort_by(|a, b| a.name_bytes.cmp(&b.name_bytes));1140         assert_eq!(files, sorted);1141     }1142 1143     #[test]1144     fn show_is_byte_exact() {1145         let (repo, branch) = fixture_repo();1146         for f in ["zfile.txt", "bin.dat", "café.txt", "adir/inner.txt"] {1147             let blob = show(&repo, &branch, Path::new(f)).expect(f);1148             // Compare against raw `git show` bytes.1149             let out = Command::new("git")1150                 .arg("-C")1151                 .arg(&repo)1152                 .arg("show")1153                 .arg(format!("{branch}:{f}"))1154                 .output()1155                 .expect("git show");1156             assert!(out.status.success());1157             assert_eq!(blob.bytes, out.stdout, "byte-exact for {f}");1158             assert_eq!(blob.display, String::from_utf8_lossy(&blob.bytes));1159             assert_eq!(blob.path_display, f);1160         }1161     }1162 1163     #[test]1164     fn show_non_utf8_path() {1165         let (repo, branch) = fixture_repo();1166         // Create a non-UTF8 filename (Unix only).1167         #[cfg(unix)]1168         {1169             use std::ffi::OsStr;1170             use std::os::unix::ffi::OsStrExt;1171             let raw = b"weird-\xff-name.txt";1172             let p = Path::new(OsStr::from_bytes(raw));1173             std::fs::write(repo.join(p), b"raw bytes content\n").unwrap();1174             git(&repo, &["add", "-A"]);1175             git(&repo, &["commit", "-m", "non-utf8 filename"]);1176             let blob = show(&repo, &branch, p).expect("show non-utf8");1177             assert_eq!(blob.bytes, b"raw bytes content\n");1178             assert_eq!(blob.path_bytes, raw);1179             assert_eq!(blob.path_display, String::from_utf8_lossy(raw).into_owned());1180         }1181     }1182 1183     #[test]1184     fn message_returns_full_body() {1185         let (repo, branch) = fixture_repo();1186         let log = log_n(&repo, &branch, 100).expect("log");1187         let msg = message(&repo, &log[1].sha).expect("message");1188         assert!(1189             msg.contains("second commit with binary"),1190             "full body: {msg}"1191         );1192         assert!(message(&repo, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").is_err());1193     }1194 1195     #[test]1196     fn diff_contains_hunk() {1197         let (repo, branch) = fixture_repo();1198         let log = log_n(&repo, &branch, 100).expect("log");1199         // Middle commit touched zfile.txt adding "line two added".1200         let sha = &log[1].sha;1201         let d = diff(&repo, sha).expect("diff");1202         assert!(1203             d.contains("line two added"),1204             "diff should contain hunk: {d}"1205         );1206         assert!(d.contains("@@") || d.contains("diff --git"));1207     }1208 1209     #[test]1210     fn diff_truncates_long_output() {1211         let (repo, branch) = fixture_repo();1212         // Add a commit with >2000 diff lines.1213         let mut big = String::new();1214         for i in 0..2500 {1215             big.push_str(&format!("line {i}\n"));1216         }1217         std::fs::write(repo.join("big.txt"), &big).unwrap();1218         git(&repo, &["add", "-A"]);1219         git(&repo, &["commit", "-m", "big commit"]);1220         let log = log_n(&repo, &branch, 1).expect("log");1221         let d = diff(&repo, &log[0].sha).expect("diff");1222         assert!(d.contains(DIFF_TRUNCATED_MARKER));1223         assert!(d.lines().count() <= DIFF_MAX_LINES + 2);1224     }1225 1226     #[test]1227     fn resolve_branch_flag_env_fallback() {1228         let (repo, _) = fixture_repo();1229         assert_eq!(resolve_branch(&repo, Some("main")).unwrap(), "main");1230         assert!(resolve_branch(&repo, Some("nope")).is_err());1231         unsafe { std::env::set_var("HU13_BRANCH", "main") };1232         assert_eq!(resolve_branch(&repo, None).unwrap(), "main");1233         unsafe { std::env::remove_var("HU13_BRANCH") };1234         assert_eq!(resolve_branch(&repo, None).unwrap(), "main");1235         let err = resolve_branch(&repo, Some("missing"))1236             .unwrap_err()1237             .to_string();1238         assert!(err.contains("missing"));1239     }1240 1241     #[test]1242     fn rev_parse_short_matches_git() {1243         let (repo, branch) = fixture_repo();1244         let short = rev_parse_short(&repo, &branch).expect("rev-parse");1245         assert!(!short.is_empty());1246         let log = log_n(&repo, &branch, 1).expect("log");1247         assert!(log[0].sha.starts_with(&short));1248     }1249 1250     #[test]1251     fn snapshot_bundle_clones_and_tarball_extracts() {1252         let (repo, branch) = fixture_repo();1253         let out = mktemp("snapshot-out");1254         let snap = snapshot(&repo, &branch, &out).expect("snapshot");1255         assert!(snap.bundle.exists());1256         assert!(snap.tarball.exists());1257 1258         // Bundle clones.1259         let clone_dir = mktemp("clone");1260         let dest = clone_dir.join("cloned");1261         let st = Command::new("git")1262             .arg("clone")1263             .arg("-b")1264             .arg(&branch)1265             .arg(&snap.bundle)1266             .arg(&dest)1267             .status()1268             .expect("git clone");1269         assert!(st.success(), "git clone of bundle failed");1270         assert!(dest.join("zfile.txt").exists());1271         assert!(dest.join("bin.dat").exists());1272 1273         // Tarball extracts.1274         let extract = mktemp("extract");1275         let st = Command::new("tar")1276             .arg("-xzf")1277             .arg(&snap.tarball)1278             .arg("-C")1279             .arg(&extract)1280             .status()1281             .expect("tar extract");1282         assert!(st.success(), "tar extract failed");1283         assert!(extract.join("zfile.txt").exists());1284         assert!(extract.join("bin.dat").exists());1285     }1286 1287     #[test]1288     fn ignored_paths_needs_no_index_for_tracked() {1289         // hu13 lists tracked files, so force-added gitignored paths1290         // must still be reported (plain check-ignore hides tracked).1291         let dir = mktemp("ignored");1292         git(&dir, &["init", "-b", "main"]);1293         git(&dir, &["config", "user.name", "hu13-test"]);1294         git(&dir, &["config", "user.email", "[email protected]"]);1295         git(&dir, &["config", "commit.gpgsign", "false"]);1296         std::fs::write(dir.join(".gitignore"), "ignored/\n*.log\n").unwrap();1297         std::fs::create_dir_all(dir.join("ignored")).unwrap();1298         std::fs::write(dir.join("ignored").join("secret.rs"), "fn s() {}\n").unwrap();1299         std::fs::write(dir.join("debug.log"), "log\n").unwrap();1300         std::fs::write(dir.join("src.rs"), "fn a() {}\n").unwrap();1301         git(&dir, &["add", "-A"]);1302         git(&dir, &["commit", "-m", "base"]);1303         // Force-add the ignored paths so they are tracked like hu13 sees.1304         git(&dir, &["add", "-f", "ignored/secret.rs", "debug.log"]);1305         git(&dir, &["commit", "-m", "forced"]);1306         let paths = vec![1307             "src.rs".to_string(),1308             "ignored/secret.rs".to_string(),1309             "debug.log".to_string(),1310             ".gitignore".to_string(),1311         ];1312         let ignored = ignored_paths(&dir, &paths).expect("ignored_paths");1313         assert!(1314             ignored.contains("ignored/secret.rs"),1315             "force-added dir match, got {ignored:?}"1316         );1317         assert!(ignored.contains("debug.log"), "glob match, got {ignored:?}");1318         assert!(!ignored.contains("src.rs"), "code kept, got {ignored:?}");1319         assert!(!ignored.contains(".gitignore"), "gitignore kept");1320         // Empty input never touches git.1321         assert!(ignored_paths(&dir, &[]).expect("empty").is_empty());1322     }1323 }