forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
22.2 KiB
1//! git CLI history: log, commit diff, last commits, archive (see docs/m2-interfaces.md).
2//! Every commit argument is a validated full hex OID; paths go after `--` as literal pathspecs.
3
4use std::collections::{HashMap, HashSet};
5use std::path::Path;
6use std::process::Stdio;
7
8use tokio::io::AsyncReadExt;
9
10use crate::browse::{self, CommitInfo};
11use crate::command::{GitCommand, GitEnv, ObjectSource};
12use crate::error::{Error, Result};
13
14/// Byte cap on any single git output stream read by this module.
15const OUTPUT_CAP: usize = 20 * 1024 * 1024;
16const EMPTY_TREE_SHA1: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
17const EMPTY_TREE_SHA256: &str = "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321";
18
19/// Record start (`%x1e`), field separator (`%x1f`), end of fields (`%x1d`).
20const REC: u8 = 0x1e;
21const FIELD: u8 = 0x1f;
22const END: u8 = 0x1d;
23/// Commit fields: oid, parents, author name/email/time, committer name/email/time, summary, message.
24const COMMIT_FORMAT: &str =
25 "--format=%x1e%H%x1f%P%x1f%an%x1f%ae%x1f%at%x1f%cn%x1f%ce%x1f%ct%x1f%s%x1f%B%x1d";
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ArchiveFormat {
29 TarGz,
30 Zip,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum DiffStatus {
35 Added,
36 Deleted,
37 Modified,
38 Renamed,
39 Copied,
40 TypeChanged,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum LineKind {
45 Context,
46 Add,
47 Del,
48 NoNewline,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct DiffLine {
53 pub kind: LineKind,
54 pub old_no: Option<u32>,
55 pub new_no: Option<u32>,
56 pub text: String,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct Hunk {
61 pub header: String,
62 pub lines: Vec<DiffLine>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct FileDiff {
67 pub old_path: Option<String>,
68 pub new_path: Option<String>,
69 pub status: DiffStatus,
70 pub is_binary: bool,
71 pub additions: u32,
72 pub deletions: u32,
73 pub hunks: Vec<Hunk>,
74 pub truncated: bool,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct DiffLimits {
79 pub max_files: usize,
80 pub max_lines_per_file: usize,
81 pub max_total_lines: usize,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct CommitDiff {
86 pub files: Vec<FileDiff>,
87 pub truncated_files: bool,
88}
89
90/// `git` with the forge-wide config overrides: `core.quotePath=false` through the
91/// environment (`GIT_CONFIG_COUNT`), so it applies before the subcommand as `-c` would.
92pub(crate) fn git(env: &GitEnv, subcommand: &'static str) -> GitCommand {
93 GitCommand::new(env, subcommand)
94 .env("GIT_CONFIG_COUNT", "1")
95 .env("GIT_CONFIG_KEY_0", "core.quotePath")
96 .env("GIT_CONFIG_VALUE_0", "false")
97}
98
99/// Accepts only a full SHA-1 (40) or SHA-256 (64) hex object id.
100pub(crate) fn validate_oid(oid: &str) -> Result<()> {
101 let len_ok = oid.len() == 40 || oid.len() == 64;
102 if len_ok && oid.bytes().all(|b| b.is_ascii_hexdigit()) {
103 Ok(())
104 } else {
105 Err(Error::InvalidInput(format!(
106 "not a full commit oid: {oid:?}"
107 )))
108 }
109}
110
111/// Pathspec that matches the given path literally (no glob or magic interpretation).
112fn literal_spec(path: &str) -> String {
113 format!(":(literal){path}")
114}
115
116/// Accepts `[A-Za-z0-9._-]+`, excluding `.` and `..` and a leading `-`.
117fn validate_prefix(prefix: &str) -> Result<()> {
118 let charset_ok = !prefix.is_empty()
119 && prefix
120 .bytes()
121 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-');
122 if !charset_ok || prefix == "." || prefix == ".." || prefix.starts_with('-') {
123 return Err(Error::InvalidInput(format!(
124 "invalid archive prefix: {prefix:?}"
125 )));
126 }
127 Ok(())
128}
129
130/// Feeds stdout chunks to `f` until EOF, until `f` returns false, or until
131/// [`OUTPUT_CAP`] bytes. Returns `true` only when EOF was reached. A non-zero exit
132/// after EOF becomes `Error::Failed` with stderr.
133async fn for_each_chunk(cmd: GitCommand, mut f: impl FnMut(&[u8]) -> bool) -> Result<bool> {
134 let mut command = cmd.into_tokio();
135 command.stdout(Stdio::piped()).stderr(Stdio::piped());
136 let mut child = command.spawn()?;
137 let mut stdout = child
138 .stdout
139 .take()
140 .ok_or_else(|| Error::Failed("git stdout missing".into()))?;
141 let stderr_task = child.stderr.take().map(|mut e| {
142 tokio::spawn(async move {
143 let mut v = Vec::new();
144 let _ = e.read_to_end(&mut v).await;
145 v
146 })
147 });
148
149 let mut chunk = vec![0u8; 64 * 1024];
150 let mut total = 0usize;
151 let mut eof = false;
152 loop {
153 let n = stdout.read(&mut chunk).await?;
154 if n == 0 {
155 eof = true;
156 break;
157 }
158 let take = n.min(OUTPUT_CAP - total);
159 let keep = f(&chunk[..take]);
160 total += take;
161 if !keep || take < n {
162 break;
163 }
164 }
165 if !eof {
166 let _ = child.start_kill();
167 }
168 drop(stdout);
169 let status = child.wait().await?;
170 let stderr = match stderr_task {
171 Some(t) => t.await.unwrap_or_default(),
172 None => Vec::new(),
173 };
174 if eof && !status.success() {
175 return Err(Error::Failed(format!(
176 "git exited with {status}: {}",
177 String::from_utf8_lossy(&stderr).trim()
178 )));
179 }
180 Ok(eof)
181}
182
183/// Reads the whole stdout up to [`OUTPUT_CAP`]. The flag is `false` when the cap cut it short.
184async fn read_capped(cmd: GitCommand) -> Result<(Vec<u8>, bool)> {
185 let mut buf = Vec::new();
186 let complete = for_each_chunk(cmd, |c| {
187 buf.extend_from_slice(c);
188 true
189 })
190 .await?;
191 Ok((buf, complete))
192}
193
194/// Parses one record (the bytes after a `%x1e` up to the next one). Returns the commit
195/// and the bytes after `%x1d` (file names when `--name-only` is used).
196fn parse_record(rec: &[u8]) -> Option<(CommitInfo, &[u8])> {
197 let end = rec.iter().position(|&b| b == END)?;
198 let fields = &rec[..end];
199 let rest = &rec[end + 1..];
200 let mut it = fields.splitn(10, |&b| b == FIELD);
201 let text = |p: Option<&[u8]>| String::from_utf8_lossy(p.unwrap_or_default()).into_owned();
202 let time = |p: Option<&[u8]>| text(p).parse::<i64>().unwrap_or(0);
203
204 let oid = text(it.next());
205 if oid.is_empty() {
206 return None;
207 }
208 let parents = text(it.next())
209 .split_whitespace()
210 .map(str::to_owned)
211 .collect();
212 let author_name = text(it.next());
213 let author_email = text(it.next());
214 let author_time = time(it.next());
215 let committer_name = text(it.next());
216 let committer_email = text(it.next());
217 let committer_time = time(it.next());
218 let summary = text(it.next());
219 let message = text(it.next()).trim_end_matches('\n').to_owned();
220 let info = CommitInfo {
221 oid,
222 parents,
223 author_name,
224 author_email,
225 author_time,
226 committer_name,
227 committer_email,
228 committer_time,
229 summary,
230 message,
231 };
232 Some((info, rest))
233}
234
235/// Commit history for `commit`, optionally limited to `path`, newest first.
236pub async fn log(
237 env: &GitEnv,
238 repo: &Path,
239 commit: &str,
240 path: Option<&str>,
241 skip: usize,
242 limit: usize,
243) -> Result<Vec<CommitInfo>> {
244 validate_oid(commit)?;
245 let mut cmd = git(env, "log")
246 .git_dir(repo)
247 .arg("-z")
248 .arg("--no-color")
249 .arg(COMMIT_FORMAT)
250 .user_opt("--skip", &skip.to_string())?
251 .user_opt("--max-count", &limit.to_string())?
252 .user_revs(&[commit])?;
253 if let Some(p) = path {
254 browse::validate_repo_path(p)?;
255 let spec = literal_spec(p);
256 cmd = cmd.paths(&[spec.as_str()]);
257 }
258 let (buf, _complete) = read_capped(cmd).await?;
259 Ok(buf
260 .split(|&b| b == REC)
261 .filter_map(parse_record)
262 .map(|(info, _)| info)
263 .collect())
264}
265
266/// Commits reachable from `new` but not from `old` (when `old` is given), oldest first. At most
267/// `max` of the newest commits are returned. Both ids must be full object ids; they are passed
268/// after `--end-of-options`, so no value can be read as an option.
269pub async fn new_commits(
270 env: &GitEnv,
271 repo: &Path,
272 old: Option<&str>,
273 new: &str,
274 max: usize,
275) -> Result<Vec<CommitInfo>> {
276 new_commits_with(env, repo, ObjectSource::default(), old, new, max).await
277}
278
279/// [`new_commits`], reading objects that are missing from `repo` from `src` as well.
280pub async fn new_commits_with(
281 env: &GitEnv,
282 repo: &Path,
283 src: ObjectSource<'_>,
284 old: Option<&str>,
285 new: &str,
286 max: usize,
287) -> Result<Vec<CommitInfo>> {
288 validate_oid(new)?;
289 let exclude = match old {
290 Some(old) => {
291 validate_oid(old)?;
292 Some(format!("^{old}"))
293 }
294 None => None,
295 };
296 let mut revs = vec![new];
297 if let Some(exclude) = exclude.as_deref() {
298 revs.push(exclude);
299 }
300 let cmd = git(env, "log")
301 .git_dir(repo)
302 .arg("-z")
303 .arg("--no-color")
304 .arg(COMMIT_FORMAT)
305 .user_opt("--max-count", &max.to_string())?
306 .objects(src)?
307 .user_revs(&revs)?;
308 let (buf, _complete) = read_capped(cmd).await?;
309 let mut commits: Vec<CommitInfo> = buf
310 .split(|&b| b == REC)
311 .filter_map(parse_record)
312 .map(|(info, _)| info)
313 .collect();
314 commits.reverse();
315 Ok(commits)
316}
317
318/// Which `diff-tree` output a command produces.
319#[derive(Clone, Copy)]
320enum DiffMode {
321 NameStatus,
322 Patch,
323}
324
325fn diff_tree_cmd(
326 env: &GitEnv,
327 repo: &Path,
328 src: ObjectSource<'_>,
329 base: &str,
330 oid: &str,
331 mode: DiffMode,
332) -> Result<GitCommand> {
333 let cmd = git(env, "diff-tree")
334 .git_dir(repo)
335 .arg("-r")
336 .arg("-M")
337 .arg("--no-commit-id")
338 .arg("--no-color")
339 .arg("--no-ext-diff")
340 .arg("--no-textconv");
341 let cmd = match mode {
342 DiffMode::NameStatus => cmd.arg("-z").arg("--name-status"),
343 DiffMode::Patch => cmd.arg("-p").arg("--full-index"),
344 };
345 cmd.objects(src)?.user_revs(&[base, oid])
346}
347
348pub(crate) fn empty_tree_for(oid: &str) -> &'static str {
349 if oid.len() == 64 {
350 EMPTY_TREE_SHA256
351 } else {
352 EMPTY_TREE_SHA1
353 }
354}
355
356/// First parent of `oid`, or `None` for a root commit.
357async fn first_parent(env: &GitEnv, repo: &Path, oid: &str) -> Result<Option<String>> {
358 let out = git(env, "rev-list")
359 .git_dir(repo)
360 .arg("--parents")
361 .arg("-n1")
362 .user_revs(&[oid])?
363 .output()
364 .await?;
365 let text = String::from_utf8_lossy(&out);
366 let mut tokens = text.split_whitespace();
367 let _oid = tokens.next();
368 match tokens.next() {
369 Some(p) => {
370 validate_oid(p)?;
371 Ok(Some(p.to_owned()))
372 }
373 None => Ok(None),
374 }
375}
376
377/// One entry of `--name-status -z` output.
378struct NameEntry {
379 status: DiffStatus,
380 old_path: Option<String>,
381 new_path: Option<String>,
382}
383
384fn status_of(code: u8) -> DiffStatus {
385 match code {
386 b'A' => DiffStatus::Added,
387 b'D' => DiffStatus::Deleted,
388 b'R' => DiffStatus::Renamed,
389 b'C' => DiffStatus::Copied,
390 b'T' => DiffStatus::TypeChanged,
391 _ => DiffStatus::Modified,
392 }
393}
394
395/// Parses NUL-separated `--name-status -z` output. A trailing partial record is dropped.
396fn parse_name_status(buf: &[u8]) -> Vec<NameEntry> {
397 let text = |p: &[u8]| String::from_utf8_lossy(p).into_owned();
398 let mut out = Vec::new();
399 let mut fields = buf.split(|&b| b == 0);
400 while let Some(code) = fields.next() {
401 let Some(&c) = code.first() else { continue };
402 if matches!(c, b'R' | b'C') {
403 let (Some(a), Some(b)) = (fields.next(), fields.next()) else {
404 break;
405 };
406 out.push(NameEntry {
407 status: status_of(c),
408 old_path: Some(text(a)),
409 new_path: Some(text(b)),
410 });
411 } else {
412 let Some(p) = fields.next() else { break };
413 let p = text(p);
414 let (old_path, new_path) = match c {
415 b'A' => (None, Some(p)),
416 b'D' => (Some(p), None),
417 _ => (Some(p.clone()), Some(p)),
418 };
419 out.push(NameEntry {
420 status: status_of(c),
421 old_path,
422 new_path,
423 });
424 }
425 }
426 out
427}
428
429/// Byte offsets where `diff --git` headers start at the beginning of a line.
430fn block_starts(text: &str) -> Vec<usize> {
431 text.match_indices("diff --git ")
432 .map(|(i, _)| i)
433 .filter(|&i| i == 0 || text.as_bytes()[i - 1] == b'\n')
434 .collect()
435}
436
437/// `@@ -a[,b] +c[,d] @@` to the old and new start line numbers.
438fn parse_hunk_start(line: &str) -> (u32, u32) {
439 let mut parts = line.split_whitespace().skip(1);
440 let start = |p: Option<&str>, sign: char| {
441 p.and_then(|p| p.strip_prefix(sign))
442 .and_then(|p| p.split(',').next())
443 .and_then(|n| n.parse::<u32>().ok())
444 .unwrap_or(1)
445 };
446 let old = start(parts.next(), '-');
447 let new = start(parts.next(), '+');
448 (old, new)
449}
450
451/// Totals and hunks of one file's section of the patch.
452#[derive(Default)]
453struct BlockOut {
454 is_binary: bool,
455 additions: u32,
456 deletions: u32,
457 hunks: Vec<Hunk>,
458 truncated: bool,
459}
460
461/// Parses one `diff --git` section. Lines beyond `limits.max_lines_per_file` or the
462/// shared `total_left` budget are dropped and mark the block truncated. Counts stay exact.
463fn parse_block(block: &str, limits: &DiffLimits, total_left: &mut usize) -> BlockOut {
464 let mut out = BlockOut::default();
465 let mut file_lines = 0usize;
466 let mut in_hunk = false;
467 let mut pending_header: Option<String> = None;
468 let (mut old_no, mut new_no) = (0u32, 0u32);
469
470 for line in block.lines() {
471 if !in_hunk {
472 if line.starts_with("@@") {
473 in_hunk = true;
474 (old_no, new_no) = parse_hunk_start(line);
475 pending_header = Some(line.to_owned());
476 } else if line.starts_with("Binary files ") || line == "GIT binary patch" {
477 out.is_binary = true;
478 }
479 continue;
480 }
481 if line.starts_with("@@") {
482 (old_no, new_no) = parse_hunk_start(line);
483 pending_header = Some(line.to_owned());
484 continue;
485 }
486
487 let (kind, text) = match line.as_bytes().first() {
488 Some(b'+') => (LineKind::Add, &line[1..]),
489 Some(b'-') => (LineKind::Del, &line[1..]),
490 Some(b' ') => (LineKind::Context, &line[1..]),
491 Some(b'\\') => (LineKind::NoNewline, line),
492 _ => (LineKind::Context, line),
493 };
494 let (dl_old, dl_new) = match kind {
495 LineKind::Context => (Some(old_no), Some(new_no)),
496 LineKind::Del => (Some(old_no), None),
497 LineKind::Add => (None, Some(new_no)),
498 LineKind::NoNewline => (None, None),
499 };
500 match kind {
501 LineKind::Add => {
502 out.additions += 1;
503 new_no += 1;
504 }
505 LineKind::Del => {
506 out.deletions += 1;
507 old_no += 1;
508 }
509 LineKind::Context => {
510 old_no += 1;
511 new_no += 1;
512 }
513 LineKind::NoNewline => {}
514 }
515
516 if file_lines >= limits.max_lines_per_file || *total_left == 0 {
517 out.truncated = true;
518 continue;
519 }
520 if let Some(h) = pending_header.take() {
521 out.hunks.push(Hunk {
522 header: h,
523 lines: Vec::new(),
524 });
525 }
526 if let Some(hunk) = out.hunks.last_mut() {
527 hunk.lines.push(DiffLine {
528 kind,
529 old_no: dl_old,
530 new_no: dl_new,
531 text: text.to_owned(),
532 });
533 }
534 file_lines += 1;
535 *total_left -= 1;
536 }
537 out
538}
539
540/// The diff of `oid` against its first parent (or the empty tree for a root commit).
541/// Uses `diff-tree -M` for names and statuses and a second `diff-tree -p` run for hunks,
542/// joined by position. Both streams are capped at [`OUTPUT_CAP`].
543pub async fn commit_diff(
544 env: &GitEnv,
545 repo: &Path,
546 oid: &str,
547 limits: DiffLimits,
548) -> Result<CommitDiff> {
549 validate_oid(oid)?;
550 let base = match first_parent(env, repo, oid).await? {
551 Some(p) => p,
552 None => empty_tree_for(oid).to_owned(),
553 };
554 diff_between(env, repo, ObjectSource::default(), &base, oid, limits).await
555}
556
557/// The diff from `base` (a commit or tree id) to `oid` (a commit id), with the same parsing and
558/// limits as [`commit_diff`]. Both ids must be full object ids.
559pub(crate) async fn diff_between(
560 env: &GitEnv,
561 repo: &Path,
562 src: ObjectSource<'_>,
563 base: &str,
564 oid: &str,
565 limits: DiffLimits,
566) -> Result<CommitDiff> {
567 validate_oid(base)?;
568 validate_oid(oid)?;
569 let (name_buf, names_complete) = read_capped(diff_tree_cmd(
570 env,
571 repo,
572 src,
573 base,
574 oid,
575 DiffMode::NameStatus,
576 )?)
577 .await?;
578 let mut entries = parse_name_status(&name_buf);
579 let mut truncated_files = false;
580 if !names_complete {
581 entries.pop();
582 truncated_files = true;
583 }
584 if entries.len() > limits.max_files {
585 entries.truncate(limits.max_files);
586 truncated_files = true;
587 }
588
589 let (patch_buf, patch_complete) =
590 read_capped(diff_tree_cmd(env, repo, src, base, oid, DiffMode::Patch)?).await?;
591 let patch = String::from_utf8_lossy(&patch_buf);
592 let starts = block_starts(&patch);
593 // A capped patch may end inside its last section, so that section is dropped.
594 let usable = if patch_complete {
595 starts.len()
596 } else {
597 starts.len().saturating_sub(1)
598 };
599
600 let mut total_left = limits.max_total_lines;
601 let mut files = Vec::with_capacity(entries.len());
602 for (i, entry) in entries.into_iter().enumerate() {
603 let block = if i < usable {
604 let end = starts.get(i + 1).copied().unwrap_or(patch.len());
605 Some(parse_block(
606 &patch[starts[i]..end],
607 &limits,
608 &mut total_left,
609 ))
610 } else {
611 None
612 };
613 let (out, missing) = match block {
614 Some(b) => (b, false),
615 None => (BlockOut::default(), true),
616 };
617 files.push(FileDiff {
618 old_path: entry.old_path,
619 new_path: entry.new_path,
620 status: entry.status,
621 is_binary: out.is_binary,
622 additions: out.additions,
623 deletions: out.deletions,
624 hunks: out.hunks,
625 truncated: missing || out.truncated,
626 });
627 }
628 Ok(CommitDiff {
629 files,
630 truncated_files,
631 })
632}
633
634/// The newest commit (walking back from `commit`) that touched each direct child of `dir`,
635/// keyed by child name. One `git log` walk, limited to `max_walk` commits, and it stops
636/// early once every name is found. `dir` is `""` for the repo root.
637pub async fn last_commits(
638 env: &GitEnv,
639 repo: &Path,
640 commit: &str,
641 dir: &str,
642 names: &[String],
643 max_walk: usize,
644) -> Result<HashMap<String, CommitInfo>> {
645 validate_oid(commit)?;
646 if !dir.is_empty() {
647 browse::validate_repo_path(dir)?;
648 }
649 let wanted: HashSet<&str> = names.iter().map(String::as_str).collect();
650 let mut found: HashMap<String, CommitInfo> = HashMap::new();
651 if wanted.is_empty() || max_walk == 0 {
652 return Ok(found);
653 }
654
655 let prefix = if dir.is_empty() {
656 String::new()
657 } else {
658 format!("{dir}/")
659 };
660 let mut cmd = git(env, "log")
661 .git_dir(repo)
662 .arg("-z")
663 .arg("--no-color")
664 .arg("--name-only")
665 .arg(COMMIT_FORMAT)
666 .user_opt("--max-count", &max_walk.to_string())?
667 .user_revs(&[commit])?;
668 if !dir.is_empty() {
669 let spec = literal_spec(dir);
670 cmd = cmd.paths(&[spec.as_str()]);
671 }
672
673 {
674 let mut handle = |rec: &[u8]| -> bool {
675 let Some((info, files)) = parse_record(rec) else {
676 return true;
677 };
678 // `-z --name-only` puts a NUL and/or newline between the format and the names.
679 let start = files
680 .iter()
681 .position(|&b| b != 0 && b != b'\n')
682 .unwrap_or(files.len());
683 let files = &files[start..];
684 for seg in files.split(|&b| b == 0).filter(|s| !s.is_empty()) {
685 let path = String::from_utf8_lossy(seg);
686 let rest: &str = if prefix.is_empty() {
687 &path
688 } else if let Some(r) = path.strip_prefix(prefix.as_str()) {
689 r
690 } else {
691 continue;
692 };
693 let child = rest.split('/').next().unwrap_or(rest);
694 if wanted.contains(child) && !found.contains_key(child) {
695 found.insert(child.to_owned(), info.clone());
696 }
697 }
698 found.len() < wanted.len()
699 };
700
701 let mut pending: Vec<u8> = Vec::new();
702 let mut started = false;
703 let mut stopped = false;
704 let eof = for_each_chunk(cmd, |chunk| {
705 for &b in chunk {
706 if b == REC {
707 if started && !handle(&pending) {
708 stopped = true;
709 return false;
710 }
711 pending.clear();
712 started = true;
713 } else if started {
714 pending.push(b);
715 }
716 }
717 true
718 })
719 .await?;
720 if eof && started && !stopped {
721 handle(&pending);
722 }
723 }
724 Ok(found)
725}
726
727/// `git archive` for `commit`, with every entry under `<prefix>/`. The caller streams stdout.
728pub fn archive_command(
729 env: &GitEnv,
730 repo: &Path,
731 commit: &str,
732 format: ArchiveFormat,
733 prefix: &str,
734) -> Result<GitCommand> {
735 validate_oid(commit)?;
736 validate_prefix(prefix)?;
737 let format_arg = match format {
738 ArchiveFormat::TarGz => "--format=tar.gz",
739 ArchiveFormat::Zip => "--format=zip",
740 };
741 git(env, "archive")
742 .git_dir(repo)
743 .arg(format_arg)
744 .user_opt("--prefix", &format!("{prefix}/"))?
745 .user_revs(&[commit])
746}