1use std::{
4 borrow::Cow,
5 collections::BTreeMap,
6 ffi::OsStr,
7 fmt, fs,
8 path::{Path, PathBuf},
9};
10
11use bstr::{BStr, BString, ByteSlice};
12use gix_hash::ObjectId;
13
14use crate::Result;
15
16#[cfg(not(feature = "repo-snapshot"))]
17mod git;
18#[cfg(feature = "repo-snapshot")]
19mod gix;
20
21#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct State {
24 pub head: Head,
26 pub config: BString,
28 pub references: Vec<Reference>,
30 pub commits: Vec<Commit>,
34 pub index: Vec<IndexEntry>,
36 pub index_tree: Option<ObjectId>,
38 pub worktree: Vec<WorktreeEntry>,
40 normalization_root: PathBuf,
41 show_object_ids: bool,
42}
43
44#[derive(Clone, Debug, Eq, PartialEq)]
46pub enum Head {
47 Unborn(BString),
49 Symbolic {
51 name: BString,
53 id: ObjectId,
55 },
56 Detached(ObjectId),
58}
59
60#[derive(Clone, Debug, Eq, PartialEq)]
62pub struct Reference {
63 pub name: BString,
65 pub target: ReferenceTarget,
67}
68
69#[derive(Clone, Debug, Eq, PartialEq)]
71pub enum ReferenceTarget {
72 Symbolic(BString),
74 Object(ObjectId),
76}
77
78#[derive(Clone, Debug, Eq, PartialEq)]
80pub struct Commit {
81 pub id: ObjectId,
83 pub data: Vec<u8>,
85}
86
87#[derive(Clone, Debug, Eq, PartialEq)]
89pub struct IndexEntry {
90 pub mode: u32,
92 pub id: ObjectId,
94 pub stage: u8,
96 pub path: BString,
98}
99
100#[derive(Clone, Debug, Eq, PartialEq)]
102pub struct WorktreeEntry {
103 pub path: PathBuf,
105 pub kind: WorktreeEntryKind,
107 pub unix_mode: Option<u32>,
109}
110
111#[derive(Clone, Debug, Eq, PartialEq)]
113pub enum WorktreeEntryKind {
114 Directory,
116 File(Vec<u8>),
118 Symlink(PathBuf),
120}
121
122pub fn snapshot(path: impl AsRef<Path>) -> Result<State> {
128 #[cfg(feature = "repo-snapshot")]
129 let mut state = gix::snapshot(path.as_ref())?;
130 #[cfg(not(feature = "repo-snapshot"))]
131 let mut state = git::snapshot(path.as_ref())?;
132 state.config = normalize_config_paths(state.config.as_bstr(), &state.normalization_root)?;
133 state.config = remove_generated_config(state.config.as_bstr())?;
134 state.show_object_ids = true;
135 Ok(state)
136}
137
138pub fn snapshot_portable(path: impl AsRef<Path>) -> Result<State> {
145 let mut state = snapshot(path)?;
146 for entry in &mut state.worktree {
147 entry.unix_mode = None;
148 }
149 state.config = normalize_config_indentation(state.config);
150 state.show_object_ids = false;
151 Ok(state)
152}
153
154#[cfg(feature = "repo-snapshot")]
155fn normalize_config_paths(input: &BStr, root: &Path) -> Result<BString> {
156 let mut config = gix_config::File::try_from(input)?;
157 for (section_name, value_name) in [
158 ("core", "worktree"),
159 ("remote", "url"),
160 ("remote", "pushurl"),
161 ("submodule", "url"),
162 ("include", "path"),
163 ("includeIf", "path"),
164 ] {
165 let subsections: std::collections::BTreeSet<_> = config
166 .sections_and_ids_by_name(section_name)
167 .into_iter()
168 .flatten()
169 .map(|(section, _)| section.header().subsection_name().map(ToOwned::to_owned))
170 .collect();
171 for subsection in subsections {
172 if let Ok(mut values) =
173 config.raw_values_mut_by(section_name, subsection.as_ref().map(|name| name.as_bstr()), value_name)
174 {
175 let normalized: Vec<_> = values
176 .get()?
177 .into_iter()
178 .map(|value| normalize_config_path(value.as_bstr(), root))
179 .collect();
180 for (index, value) in normalized.into_iter().enumerate() {
181 values.set_at(index, value)?;
182 }
183 }
184 }
185 }
186 Ok(config.into())
187}
188
189fn normalize_config_path(value: &BStr, root: &Path) -> BString {
190 let path = gix_path::from_bstr(value).into_owned();
191 let relative = if path.is_absolute() {
192 path.strip_prefix(root)
193 .map(Path::to_owned)
194 .ok()
195 .or_else(|| {
196 let root = root.canonicalize().ok()?;
197 path.canonicalize().ok()?.strip_prefix(root).map(Path::to_owned).ok()
198 })
199 .or_else(|| relative_to_repository_sibling(&path, root))
200 } else {
201 if value.contains_str("://")
202 || value
203 .find_byte(b':')
204 .is_some_and(|colon| !value[..colon].contains(&b'/'))
205 {
206 return "<normalized>".into();
207 }
208 Some(path)
209 };
210 let Some(relative) = relative else {
211 return "<normalized>".into();
212 };
213 let relative = portable_path(&relative);
214 if relative.is_empty() {
215 return "<normalized>".into();
216 }
217 let mut out = b"<normalized>/".to_vec();
218 out.extend_from_slice(&relative);
219 out.into()
220}
221
222fn relative_to_repository_sibling(path: &Path, git_dir: &Path) -> Option<PathBuf> {
223 let repository = if git_dir.file_name() == Some(OsStr::new(".git")) {
224 git_dir.parent()?
225 } else {
226 git_dir
227 };
228 relative_to_repository_sibling_inner(path, repository).or_else(|| {
229 let path = comparable_realpath(path)?;
230 let repository = comparable_realpath(repository)?;
231 relative_to_repository_sibling_inner(&path, &repository)
232 })
233}
234
235fn relative_to_repository_sibling_inner(path: &Path, repository: &Path) -> Option<PathBuf> {
236 path.strip_prefix(repository.parent()?).ok()?;
237 relative_path(repository, path)
238}
239
240fn comparable_realpath(path: &Path) -> Option<PathBuf> {
241 let realpath = gix_path::realpath(path).ok()?;
242 #[cfg(windows)]
243 {
244 Some(realpath.canonicalize().unwrap_or(realpath))
247 }
248 #[cfg(not(windows))]
249 {
250 Some(realpath)
251 }
252}
253
254fn relative_path(from: &Path, to: &Path) -> Option<PathBuf> {
255 let from: Vec<_> = from.components().collect();
256 let to: Vec<_> = to.components().collect();
257 let common = from.iter().zip(&to).take_while(|(left, right)| left == right).count();
258 if common == 0 {
259 return None;
260 }
261 let mut out = PathBuf::new();
262 for _ in &from[common..] {
263 out.push("..");
264 }
265 for component in &to[common..] {
266 out.push(component.as_os_str());
267 }
268 Some(out)
269}
270
271#[cfg(not(feature = "repo-snapshot"))]
272fn normalize_config_paths(input: &BStr, root: &Path) -> Result<BString> {
273 let mut out = Vec::with_capacity(input.len());
274 let mut section = b"".as_slice();
275 for line in input.lines_with_terminator() {
276 if let Some(name) = config_section_name(line) {
277 section = name;
278 out.extend_from_slice(line);
279 continue;
280 }
281 let Some((key, value_start, value_end)) = config_key_and_value(line) else {
282 out.extend_from_slice(line);
283 continue;
284 };
285 if is_location_key(section, key) {
286 out.extend_from_slice(&line[..value_start]);
287 out.extend_from_slice(&normalize_config_path(line[value_start..value_end].as_bstr(), root));
288 out.extend_from_slice(&line[value_end..]);
289 } else {
290 out.extend_from_slice(line);
291 }
292 }
293 Ok(out.into())
294}
295
296#[cfg(not(feature = "repo-snapshot"))]
297fn remove_generated_config(input: &BStr) -> Result<BString> {
298 let mut out = Vec::with_capacity(input.len());
299 let mut header = None;
300 let mut body = Vec::new();
301 for line in input.lines_with_terminator() {
302 if config_section_name(line).is_some() {
303 if let Some(previous) = header.replace(line) {
304 write_portable_config_section(previous, &body, &mut out);
305 body.clear();
306 }
307 } else if header.is_some() {
308 body.push(line);
309 } else {
310 out.extend_from_slice(line);
311 }
312 }
313 if let Some(header) = header {
314 write_portable_config_section(header, &body, &mut out);
315 }
316 Ok(out.into())
317}
318
319#[cfg(not(feature = "repo-snapshot"))]
320fn write_portable_config_section(header: &[u8], body: &[&[u8]], out: &mut Vec<u8>) {
321 let section = config_section_name(header).expect("caller provides a section header");
322 let retained: Vec<_> = body
323 .iter()
324 .copied()
325 .filter(|line| match config_key_and_value(line) {
326 Some((key, _, _)) => !is_generated_config_key(section, key),
327 None => true,
328 })
329 .collect();
330 let has_values = retained.iter().any(|line| config_key_and_value(line).is_some());
331 if !has_values && (section.eq_ignore_ascii_case(b"core") || section.eq_ignore_ascii_case(b"extensions")) {
332 return;
333 }
334 out.extend_from_slice(header);
335 for line in retained {
336 out.extend_from_slice(line);
337 }
338}
339
340#[cfg(not(feature = "repo-snapshot"))]
341fn config_section_name(line: &[u8]) -> Option<&[u8]> {
342 let start = line.iter().take_while(|byte| byte.is_ascii_whitespace()).count();
343 let body = line[start..].strip_prefix(b"[")?.split(|byte| *byte == b']').next()?;
344 body.split(|byte| byte.is_ascii_whitespace() || *byte == b'\"').next()
345}
346
347#[cfg(not(feature = "repo-snapshot"))]
348fn config_key_and_value(line: &[u8]) -> Option<(&[u8], usize, usize)> {
349 let mut start = 0;
350 while line.get(start).is_some_and(u8::is_ascii_whitespace) {
351 start += 1;
352 }
353 if matches!(line.get(start), None | Some(b'#' | b';' | b'[')) {
354 return None;
355 }
356 let key_end = line[start..]
357 .iter()
358 .position(|byte| byte.is_ascii_whitespace() || *byte == b'=')?
359 + start;
360 let mut value_start = key_end;
361 while line.get(value_start).is_some_and(u8::is_ascii_whitespace) {
362 value_start += 1;
363 }
364 if line.get(value_start) == Some(&b'=') {
365 value_start += 1;
366 while line.get(value_start).is_some_and(u8::is_ascii_whitespace) {
367 value_start += 1;
368 }
369 }
370 let mut value_end = line.len();
371 while matches!(line.get(value_end.wrapping_sub(1)), Some(b'\n' | b'\r')) {
372 value_end -= 1;
373 }
374 Some((&line[start..key_end], value_start, value_end))
375}
376
377#[cfg(not(feature = "repo-snapshot"))]
378fn is_location_key(section: &[u8], key: &[u8]) -> bool {
379 matches_location(section, key, b"core", b"worktree")
380 || matches_location(section, key, b"remote", b"url")
381 || matches_location(section, key, b"remote", b"pushurl")
382 || matches_location(section, key, b"submodule", b"url")
383 || matches_location(section, key, b"include", b"path")
384 || matches_location(section, key, b"includeIf", b"path")
385}
386
387#[cfg(not(feature = "repo-snapshot"))]
388fn matches_location(section: &[u8], key: &[u8], expected_section: &[u8], expected_key: &[u8]) -> bool {
389 section.eq_ignore_ascii_case(expected_section) && key.eq_ignore_ascii_case(expected_key)
390}
391
392#[cfg(not(feature = "repo-snapshot"))]
393fn is_generated_config_key(section: &[u8], key: &[u8]) -> bool {
394 if section.eq_ignore_ascii_case(b"core") {
395 [
396 b"repositoryformatversion".as_slice(),
397 b"filemode",
398 b"logallrefupdates",
399 b"ignorecase",
400 b"precomposeunicode",
401 b"symlinks",
402 ]
403 .iter()
404 .any(|name| key.eq_ignore_ascii_case(name))
405 } else if section.eq_ignore_ascii_case(b"extensions") {
406 [b"objectformat".as_slice(), b"compatobjectformat"]
407 .iter()
408 .any(|name| key.eq_ignore_ascii_case(name))
409 } else {
410 false
411 }
412}
413
414#[cfg(feature = "repo-snapshot")]
415fn remove_generated_config(input: &BStr) -> Result<BString> {
416 let mut config = gix_config::File::try_from(input)?;
417 for (section_name, value_names) in [
418 (
419 "core",
420 &[
421 "repositoryformatversion",
422 "filemode",
423 "logallrefupdates",
424 "ignorecase",
425 "precomposeunicode",
426 "symlinks",
427 ][..],
428 ),
429 ("extensions", &["objectformat", "compatobjectformat"][..]),
430 ] {
431 let section_ids: Vec<_> = config
432 .sections_and_ids_by_name(section_name)
433 .into_iter()
434 .flatten()
435 .map(|(_, id)| id)
436 .collect();
437 for id in section_ids {
438 let mut section = config.section_mut_by_id(id).expect("ID came from this config");
439 for value_name in value_names {
440 while section.remove(value_name).is_some() {}
441 }
442 if section.num_values() == 0 {
443 config.remove_section_by_id(id);
444 }
445 }
446 }
447
448 Ok(config.into())
449}
450
451fn normalize_config_indentation(config: BString) -> BString {
452 let mut out = Vec::with_capacity(config.len());
453 let mut in_indentation = true;
454 for byte in config.iter().copied() {
455 match byte {
456 b'\t' if in_indentation => out.extend_from_slice(b" "),
457 b'\n' => {
458 out.push(byte);
459 in_indentation = true;
460 }
461 b' ' if in_indentation => out.push(byte),
462 _ => {
463 out.push(byte);
464 in_indentation = false;
465 }
466 }
467 }
468 out.into()
469}
470
471struct Aliases {
478 by_id: BTreeMap<ObjectId, String>,
479 commits: Vec<ObjectId>,
480}
481
482impl Aliases {
483 fn new(state: &State) -> Self {
484 let parents: BTreeMap<_, Vec<_>> = state
485 .commits
486 .iter()
487 .map(|commit| {
488 let parents = commit
489 .data
490 .lines()
491 .filter_map(|line| line.strip_prefix(b"parent "))
492 .filter_map(|hex| ObjectId::from_hex(hex).ok())
493 .collect();
494 (commit.id, parents)
495 })
496 .collect();
497 let mut commits: Vec<_> = parents.keys().copied().collect();
498 let mut depths = BTreeMap::new();
499 commits.sort_by_key(|id| (commit_depth(*id, &parents, &mut depths), *id));
500
501 let mut by_id = BTreeMap::new();
502 for (index, id) in commits.iter().enumerate() {
503 by_id.insert(*id, format!("C{index}"));
504 }
505 let trees = state
506 .commits
507 .iter()
508 .flat_map(|commit| commit.data.lines())
509 .filter_map(|line| line.strip_prefix(b"tree "))
510 .filter_map(|hex| ObjectId::from_hex(hex).ok())
511 .chain(state.index_tree);
512 let mut tree_index = 0;
513 for id in trees {
514 by_id.entry(id).or_insert_with(|| {
515 let alias = format!("T{tree_index}");
516 tree_index += 1;
517 alias
518 });
519 }
520 let mut blob_index = 0;
521 let mut gitlink_index = 0;
522 for entry in &state.index {
523 by_id.entry(entry.id).or_insert_with(|| {
524 if entry.mode == 0o160000 {
525 let alias = format!("S{gitlink_index}");
526 gitlink_index += 1;
527 alias
528 } else {
529 let alias = format!("B{blob_index}");
530 blob_index += 1;
531 alias
532 }
533 });
534 }
535
536 let mut other_index = 0;
537 let mut insert_other = |id| {
538 by_id.entry(id).or_insert_with(|| {
539 let alias = format!("O{other_index}");
540 other_index += 1;
541 alias
542 });
543 };
544 match &state.head {
545 Head::Symbolic { id, .. } | Head::Detached(id) => insert_other(*id),
546 Head::Unborn(_) => {}
547 }
548 for reference in &state.references {
549 if let ReferenceTarget::Object(id) = &reference.target {
550 insert_other(*id);
551 }
552 }
553 for commit in &state.commits {
554 for id in commit
555 .data
556 .lines()
557 .filter_map(|line| line.strip_prefix(b"parent "))
558 .filter_map(|hex| ObjectId::from_hex(hex).ok())
559 {
560 insert_other(id);
561 }
562 }
563 Self { by_id, commits }
564 }
565
566 fn id(&self, id: ObjectId) -> String {
567 self.by_id.get(&id).cloned().unwrap_or_else(|| id.to_string())
568 }
569
570 fn head(&self, head: &Head) -> String {
571 match head {
572 Head::Unborn(name) => format!("unborn {}", name.as_bstr()),
573 Head::Symbolic { name, id } => format!("{} -> {}", name.as_bstr(), self.id(*id)),
574 Head::Detached(id) => format!("detached {}", self.id(*id)),
575 }
576 }
577}
578
579impl fmt::Display for State {
580 fn fmt(&self, out: &mut fmt::Formatter<'_>) -> fmt::Result {
581 let aliases = Aliases::new(self);
582 let State {
583 head,
584 config,
585 references,
586 commits,
587 index,
588 index_tree,
589 worktree,
590 normalization_root: _,
591 show_object_ids,
592 } = self;
593 writeln!(out, "HEAD {}", aliases.head(head))?;
594 write!(out, "\n[config]\n{}", config.as_bstr())?;
595 if !config.ends_with_str("\n") {
596 writeln!(out)?;
597 }
598 writeln!(out, "\n[refs]")?;
599 for reference in references {
600 let target = match &reference.target {
601 ReferenceTarget::Symbolic(name) => format!("-> {}", name.as_bstr()),
602 ReferenceTarget::Object(id) => aliases.id(*id),
603 };
604 writeln!(out, "{} = {target}", reference.name.as_bstr())?;
605 }
606
607 writeln!(out, "\n[commits]")?;
608 for commit in aliases.commits.iter().map(|id| {
609 commits
610 .iter()
611 .find(|commit| commit.id == *id)
612 .expect("aliases only contain captured commits")
613 }) {
614 writeln!(out, "{}", aliases.id(commit.id))?;
615 for line in commit.data.lines() {
616 if line.is_empty() {
617 writeln!(out)?;
618 } else if let Some((name, value)) = line.split_once_str(b" ")
619 && matches!(name, b"tree" | b"parent")
620 && let Ok(id) = ObjectId::from_hex(value)
621 {
622 writeln!(out, " {} {}", name.as_bstr(), aliases.id(id))?;
623 } else {
624 writeln!(out, " {}", line.as_bstr())?;
625 }
626 }
627 writeln!(out)?;
628 }
629
630 writeln!(out, "[index]")?;
631 match index_tree {
632 Some(id) => writeln!(out, "tree = {}", aliases.id(*id))?,
633 None => writeln!(out, "tree = conflicted")?,
634 }
635 for entry in index {
636 writeln!(
637 out,
638 "{:06o} {} stage={} {:?}",
639 entry.mode,
640 aliases.id(entry.id),
641 entry.stage,
642 entry.path.as_bstr()
643 )?;
644 }
645
646 writeln!(out, "\n[worktree]")?;
647 for entry in worktree {
648 let mode = entry.unix_mode.map_or_else(|| "-".into(), |mode| format!("{mode:06o}"));
649 match &entry.kind {
650 WorktreeEntryKind::Directory => writeln!(out, "{mode} dir {:?}", portable_path(&entry.path))?,
651 WorktreeEntryKind::File(data) => writeln!(
652 out,
653 "{mode} file {:?} = {:?}",
654 portable_path(&entry.path),
655 data.as_bstr()
656 )?,
657 WorktreeEntryKind::Symlink(target) => writeln!(
658 out,
659 "{mode} link {:?} -> {:?}",
660 portable_path(&entry.path),
661 portable_path(target)
662 )?,
663 }
664 }
665
666 if *show_object_ids {
667 writeln!(out, "\n[objects]")?;
668 for (id, alias) in &aliases.by_id {
669 writeln!(out, "{alias} = {id}")?;
670 }
671 }
672 Ok(())
673 }
674}
675
676fn commit_depth(
682 id: ObjectId,
683 parents: &BTreeMap<ObjectId, Vec<ObjectId>>,
684 cache: &mut BTreeMap<ObjectId, usize>,
685) -> usize {
686 if let Some(depth) = cache.get(&id) {
687 return *depth;
688 }
689 let depth = parents
690 .get(&id)
691 .into_iter()
692 .flatten()
693 .map(|parent| commit_depth(*parent, parents, cache) + 1)
694 .max()
695 .unwrap_or_default();
696 cache.insert(id, depth);
697 depth
698}
699
700fn portable_path(path: &Path) -> Cow<'_, BStr> {
701 gix_path::to_unix_separators_on_windows(gix_path::into_bstr(path))
702}
703
704fn worktree(root: Option<&Path>) -> Result<Vec<WorktreeEntry>> {
705 let Some(root) = root else {
706 return Ok(Vec::new());
707 };
708 let mut out = Vec::new();
709 visit_worktree(root, root, &mut out)?;
710 out.sort_by(|a, b| a.path.cmp(&b.path));
711 Ok(out)
712}
713
714fn visit_worktree(root: &Path, directory: &Path, out: &mut Vec<WorktreeEntry>) -> Result<()> {
715 let mut entries: Vec<_> = fs::read_dir(directory)?.collect::<std::io::Result<_>>()?;
716 entries.sort_by_key(fs::DirEntry::file_name);
717 for entry in entries {
718 if entry.file_name() == OsStr::new(".git") {
719 continue;
720 }
721 let path = entry.path();
722 let metadata = fs::symlink_metadata(&path)?;
723 let relative = path.strip_prefix(root)?.to_owned();
724 let kind = if metadata.file_type().is_symlink() {
725 WorktreeEntryKind::Symlink(fs::read_link(&path)?)
726 } else if metadata.is_dir() {
727 WorktreeEntryKind::Directory
728 } else {
729 WorktreeEntryKind::File(fs::read(&path)?)
730 };
731 out.push(WorktreeEntry {
732 path: relative,
733 kind,
734 unix_mode: unix_mode(&metadata),
735 });
736 if metadata.is_dir() {
737 visit_worktree(root, &path, out)?;
738 }
739 }
740 Ok(())
741}
742
743#[cfg(unix)]
744fn unix_mode(metadata: &fs::Metadata) -> Option<u32> {
745 use std::os::unix::fs::PermissionsExt;
746 Some(metadata.permissions().mode())
747}
748
749#[cfg(not(unix))]
750fn unix_mode(_metadata: &fs::Metadata) -> Option<u32> {
751 None
752}