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 }