1use std::collections::{BTreeMap, BTreeSet};
13use std::io::{Read, Write};
14use std::path::Path;
15use std::process::{Command, Stdio};
16use std::sync::mpsc;
17use std::time::{Duration, Instant};
18
19use crate::repo_scope::{is_supported, Tree};
20
21type R<T> = Result<T, Box<dyn std::error::Error>>;
22
23const GIT_DEADLINE: Duration = Duration::from_secs(120);
25
26fn bounded(dir: &Path, args: &[&str], input: Option<Vec<u8>>, limit: Duration) -> R<Vec<u8>> {
41 let mut cmd = Command::new("git");
42 cmd.args(args)
43 .current_dir(dir)
44 .env("GIT_NO_LAZY_FETCH", "1")
45 .env("GIT_TERMINAL_PROMPT", "0");
46 let (status, stdout, stderr) =
47 run_bounded(cmd, &format!("git {}", args.join(" ")), input, limit)?;
48 if !status.success() {
49 return Err(format!(
50 "git {} failed: {}",
51 args.join(" "),
52 String::from_utf8_lossy(&stderr).trim()
53 )
54 .into());
55 }
56 Ok(stdout)
57}
58
59pub(crate) fn run_bounded(
64 mut cmd: Command,
65 what: &str,
66 input: Option<Vec<u8>>,
67 limit: Duration,
68) -> R<(std::process::ExitStatus, Vec<u8>, Vec<u8>)> {
69 let mut child = cmd
70 .stdin(if input.is_some() {
71 Stdio::piped()
72 } else {
73 Stdio::null()
74 })
75 .stdout(Stdio::piped())
76 .stderr(Stdio::piped())
77 .spawn()
78 .map_err(|e| format!("could not run {what}: {e}"))?;
79 if let (Some(bytes), Some(mut stdin)) = (input, child.stdin.take()) {
80 std::thread::spawn(move || {
82 let _ = stdin.write_all(&bytes);
83 });
84 }
85 let drain = |pipe: Option<Box<dyn Read + Send>>| {
86 let (tx, rx) = mpsc::channel();
87 std::thread::spawn(move || {
88 let mut buf = Vec::new();
89 if let Some(mut p) = pipe {
90 let _ = p.read_to_end(&mut buf);
91 }
92 let _ = tx.send(buf);
93 });
94 rx
95 };
96 let out = drain(
97 child
98 .stdout
99 .take()
100 .map(|p| Box::new(p) as Box<dyn Read + Send>),
101 );
102 let err = drain(
103 child
104 .stderr
105 .take()
106 .map(|p| Box::new(p) as Box<dyn Read + Send>),
107 );
108
109 let deadline = Instant::now() + limit;
110 let too_slow = || -> Box<dyn std::error::Error> {
111 format!(
112 "{what} did not finish within {}s — weave stopped waiting rather than hang",
113 limit.as_secs()
114 )
115 .into()
116 };
117 let status = loop {
118 if let Some(status) = child.try_wait()? {
119 break status;
120 }
121 if Instant::now() >= deadline {
122 let _ = child.kill();
123 let _ = child.wait();
124 return Err(too_slow());
125 }
126 std::thread::sleep(Duration::from_millis(5));
127 };
128 let remaining = || deadline.saturating_duration_since(Instant::now());
131 let stdout = out.recv_timeout(remaining()).map_err(|_| too_slow())?;
132 let stderr = err.recv_timeout(remaining()).map_err(|_| too_slow())?;
133 Ok((status, stdout, stderr))
134}
135
136pub(crate) fn git(dir: &Path, args: &[&str]) -> R<String> {
137 git_input(dir, args, None)
138}
139
140pub(crate) fn git_input(dir: &Path, args: &[&str], input: Option<Vec<u8>>) -> R<String> {
142 let out = bounded(dir, args, input, GIT_DEADLINE)?;
143 Ok(String::from_utf8_lossy(&out).to_string())
144}
145
146#[derive(Debug, Default)]
148pub struct Stage {
149 pub tree: Tree,
151 pub unreadable: BTreeMap<String, String>,
157 pub irregular: BTreeSet<String>,
160}
161
162fn read_paths_at_rev(dir: &Path, rev: &str, paths: &[String]) -> R<Stage> {
177 let mut stage = Stage::default();
178 for (path, entry) in entries_at_rev(dir, rev, paths)? {
179 match entry.body {
180 Body::Text(text) => {
181 stage.tree.insert(path, text);
182 }
183 Body::Binary => {}
185 Body::Irregular => {
186 stage.irregular.insert(path);
187 }
188 Body::Unreadable(why) => {
189 stage.unreadable.insert(path, why);
190 }
191 }
192 }
193 Ok(stage)
194}
195
196#[derive(Debug, Clone)]
199pub(crate) struct Entry {
200 pub mode: String,
201 pub oid: String,
202 pub body: Body,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub(crate) enum Body {
207 Text(String),
209 Binary,
211 Irregular,
213 Unreadable(String),
215}
216
217pub(crate) fn entries_at_rev(
221 dir: &Path,
222 rev: &str,
223 paths: &[String],
224) -> R<BTreeMap<String, Entry>> {
225 let mut out = BTreeMap::new();
226 if paths.is_empty() {
227 return Ok(out);
228 }
229 let wanted: BTreeSet<&str> = paths.iter().map(String::as_str).collect();
230 let listing = bounded(
232 dir,
233 &["ls-tree", "-r", "-z", "--full-tree", rev],
234 None,
235 GIT_DEADLINE,
236 )?;
237 let mut blobs: Vec<(String, String, String)> = Vec::new(); for entry in listing.split(|&b| b == 0).filter(|e| !e.is_empty()) {
239 let entry = String::from_utf8_lossy(entry);
240 let Some((meta, path)) = entry.split_once('\t') else {
241 continue;
242 };
243 if !wanted.contains(path) {
244 continue;
245 }
246 let mut meta = meta.split(' ');
247 let (mode, oid) = (meta.next().unwrap_or(""), meta.nth(1).unwrap_or(""));
248 if mode == "100644" || mode == "100755" {
249 blobs.push((path.to_string(), mode.to_string(), oid.to_string()));
250 } else {
251 out.insert(
252 path.to_string(),
253 Entry {
254 mode: mode.to_string(),
255 oid: oid.to_string(),
256 body: Body::Irregular,
257 },
258 );
259 }
260 }
261
262 let oids: Vec<&str> = blobs.iter().map(|(_, _, oid)| oid.as_str()).collect();
263 let mut found = cat_blobs(dir, &oids)?;
264 let missing: Vec<&str> = oids
265 .iter()
266 .copied()
267 .filter(|oid| !found.contains_key(*oid))
268 .collect();
269 let mut why_missing = "the blob is not in the local object store".to_string();
270 if !missing.is_empty() {
271 match prefetch(dir, &missing) {
272 Ok(()) => found.extend(cat_blobs(dir, &missing)?),
273 Err(e) => why_missing = e.to_string(),
274 }
275 }
276 for (path, mode, oid) in blobs {
277 let body = match found.get(&oid) {
278 Some(Some(text)) => Body::Text(text.clone()),
279 Some(None) => Body::Binary,
280 None => Body::Unreadable(format!("{rev}: {why_missing}")),
281 };
282 out.insert(path, Entry { mode, oid, body });
283 }
284 Ok(out)
285}
286
287fn cat_blobs(dir: &Path, oids: &[&str]) -> R<BTreeMap<String, Option<String>>> {
290 let mut out = BTreeMap::new();
291 if oids.is_empty() {
292 return Ok(out);
293 }
294 let mut request = String::new();
295 for oid in oids {
296 request.push_str(oid);
297 request.push('\n');
298 }
299 let bytes = bounded(
300 dir,
301 &["cat-file", "--batch"],
302 Some(request.into_bytes()),
303 GIT_DEADLINE,
304 )?;
305 let mut pos = 0usize;
309 while pos < bytes.len() {
310 let Some(nl) = bytes[pos..].iter().position(|&b| b == b'\n') else {
311 break;
312 };
313 let header = String::from_utf8_lossy(&bytes[pos..pos + nl]).to_string();
314 pos += nl + 1;
315 let fields: Vec<&str> = header.split(' ').collect();
316 let [oid, _kind, size] = fields[..] else {
317 continue; };
319 let size: usize = size.parse()?;
320 if pos + size > bytes.len() {
321 return Err("git cat-file --batch reply was truncated".into());
322 }
323 let text = std::str::from_utf8(&bytes[pos..pos + size])
324 .ok()
325 .map(str::to_string);
326 out.insert(oid.to_string(), text);
327 pos += size + 1; }
329 Ok(out)
330}
331
332fn promisor_remote(dir: &Path) -> Option<String> {
335 let flagged = git(dir, &["config", "--get-regexp", r"^remote\..*\.promisor$"]).ok();
336 flagged
337 .iter()
338 .flat_map(|s| s.lines())
339 .filter_map(|l| l.split_once(' '))
340 .filter(|(_, v)| v.trim() == "true")
341 .find_map(|(k, _)| {
342 k.strip_prefix("remote.")?
343 .strip_suffix(".promisor")
344 .map(str::to_string)
345 })
346 .or_else(|| {
347 git(dir, &["config", "--get", "extensions.partialClone"])
348 .ok()
349 .map(|s| s.trim().to_string())
350 .filter(|s| !s.is_empty())
351 })
352}
353
354fn prefetch(dir: &Path, oids: &[&str]) -> R<()> {
360 let remote = promisor_remote(dir)
361 .ok_or("the blob is not in the local object store and this is not a partial clone")?;
362 let mut request = String::new();
363 for oid in oids {
364 request.push_str(oid);
365 request.push('\n');
366 }
367 bounded(
368 dir,
369 &[
370 "-c",
371 "fetch.negotiationAlgorithm=noop",
372 "fetch",
373 &remote,
374 "--no-tags",
375 "--no-write-fetch-head",
376 "--recurse-submodules=no",
377 "--filter=blob:none",
378 "--stdin",
379 ],
380 Some(request.into_bytes()),
381 GIT_DEADLINE,
382 )
383 .map_err(|e| {
384 format!(
385 "{} blob(s) are not local and could not be fetched: {e}",
386 oids.len()
387 )
388 })?;
389 Ok(())
390}
391
392pub(crate) fn rev_exists(dir: &Path, rev: &str) -> bool {
393 git(dir, &["rev-parse", "--verify", "--quiet", rev]).is_ok()
394}
395
396const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
398
399fn replay_base(dir: &Path, theirs_rev: &str) -> R<String> {
402 let parent = format!("{theirs_rev}^");
403 if rev_exists(dir, &parent) {
404 Ok(git(dir, &["rev-parse", &parent])?.trim().to_string())
405 } else {
406 Ok(EMPTY_TREE.to_string())
407 }
408}
409
410pub(crate) fn resolve_revs(
422 dir: &Path,
423 base: Option<&str>,
424 ours: Option<&str>,
425 theirs: Option<&str>,
426) -> R<(String, String, String)> {
427 if !rev_exists(dir, "HEAD") {
428 return Err(
429 "not a git repository (or no commits yet) — use the directory mode instead".into(),
430 );
431 }
432 let ours = ours.unwrap_or("HEAD").to_string();
433 let theirs = match theirs {
434 Some(t) => t.to_string(),
435 None => [
436 "MERGE_HEAD",
437 "REBASE_HEAD",
438 "CHERRY_PICK_HEAD",
439 "REVERT_HEAD",
440 ]
441 .into_iter()
442 .find(|r| rev_exists(dir, r))
443 .map(str::to_string)
444 .ok_or(
445 "no --theirs given and no merge, rebase, cherry-pick or revert in progress \
446 (no MERGE_HEAD / REBASE_HEAD / CHERRY_PICK_HEAD / REVERT_HEAD)",
447 )?,
448 };
449 let base = match base {
450 Some(b) => b.to_string(),
451 None if theirs == "REBASE_HEAD" || theirs == "CHERRY_PICK_HEAD" => {
452 replay_base(dir, &theirs)?
453 }
454 None => git(dir, &["merge-base", &ours, &theirs])?
455 .trim()
456 .to_string(),
457 };
458 Ok((base, ours, theirs))
459}
460
461pub fn trees(
462 dir: &Path,
463 base: Option<&str>,
464 ours: Option<&str>,
465 theirs: Option<&str>,
466) -> R<(Tree, Tree, Tree)> {
467 let (base, ours, theirs) = resolve_revs(dir, base, ours, theirs)?;
468 Ok((
469 read_rev_tree(dir, &base)?,
470 read_rev_tree(dir, &ours)?,
471 read_rev_tree(dir, &theirs)?,
472 ))
473}
474
475pub struct MergeScope {
479 pub base: Tree,
480 pub ours: Tree,
481 pub theirs: Tree,
482 pub work: Tree,
484 pub subjects: Vec<String>,
486 pub unreadable: BTreeMap<String, String>,
490 pub irregular: BTreeSet<String>,
493 pub scope: String,
494}
495
496pub fn merge_scope(dir: &Path) -> R<Option<MergeScope>> {
519 if !rev_exists(dir, "HEAD") {
520 return Ok(None);
521 }
522 let (ours_rev, theirs_rev, moment, use_replay_base) = if rev_exists(dir, "MERGE_HEAD") {
523 (
524 "HEAD".to_string(),
525 "MERGE_HEAD".to_string(),
526 "merge in progress",
527 false,
528 )
529 } else if let Some(op_head) = ["REBASE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD"]
530 .into_iter()
531 .find(|r| rev_exists(dir, r))
532 {
533 let moment = match op_head {
540 "REBASE_HEAD" => "rebase in progress",
541 "CHERRY_PICK_HEAD" => "cherry-pick in progress",
542 _ => "revert in progress",
543 };
544 (
545 "HEAD".to_string(),
546 op_head.to_string(),
547 moment,
548 op_head != "REVERT_HEAD",
549 )
550 } else if rev_exists(dir, "HEAD^2") {
551 (
552 "HEAD^1".to_string(),
553 "HEAD^2".to_string(),
554 "merge just committed",
555 false,
556 )
557 } else {
558 return Ok(None);
559 };
560 let base_rev = if use_replay_base {
561 replay_base(dir, &theirs_rev)?
562 } else {
563 git(dir, &["merge-base", &ours_rev, &theirs_rev])?
564 .trim()
565 .to_string()
566 };
567
568 let mut subjects: BTreeSet<String> = BTreeSet::new();
585 for side in [&ours_rev, &theirs_rev] {
586 let list = git(
587 dir,
588 &["diff", "--name-only", "--no-renames", "-z", &base_rev, side],
589 )?;
590 subjects.extend(
591 list.split('\0')
592 .filter(|p| !p.is_empty() && is_supported(p))
593 .map(str::to_string),
594 );
595 }
596 if let Ok(list) = git(
599 dir,
600 &[
601 "diff",
602 "--name-only",
603 "--no-renames",
604 "--diff-filter=U",
605 "-z",
606 ],
607 ) {
608 subjects.extend(
609 list.split('\0')
610 .filter(|p| !p.is_empty() && is_supported(p))
611 .map(str::to_string),
612 );
613 }
614 let subjects: Vec<String> = subjects.into_iter().collect();
615
616 let stages = [
626 read_paths_at_rev(dir, &base_rev, &subjects)?,
627 read_paths_at_rev(dir, &ours_rev, &subjects)?,
628 read_paths_at_rev(dir, &theirs_rev, &subjects)?,
629 ];
630 let work = read_worktree(dir)?;
631 let scope = format!(
632 "working tree vs the three merge stages of {ours_rev} × {theirs_rev} \
633 (base {}, {moment}) — {} file(s) either side changed",
634 &base_rev[..base_rev.len().min(8)],
635 subjects.len()
636 );
637 let mut unreadable: BTreeMap<String, String> = BTreeMap::new();
640 let mut irregular: BTreeSet<String> = BTreeSet::new();
641 for stage in &stages {
642 for (path, why) in &stage.unreadable {
643 unreadable
644 .entry(path.clone())
645 .or_insert_with(|| why.clone());
646 }
647 irregular.extend(stage.irregular.iter().cloned());
648 }
649 let subjects = subjects
650 .into_iter()
651 .filter(|p| !irregular.contains(p) && !unreadable.contains_key(p))
652 .collect();
653 irregular.retain(|p| !unreadable.contains_key(p));
654 let [base, ours, theirs] = stages.map(|s| s.tree);
655 Ok(Some(MergeScope {
656 base,
657 ours,
658 theirs,
659 work,
660 subjects,
661 unreadable,
662 irregular,
663 scope,
664 }))
665}
666
667pub(crate) fn read_worktree(dir: &Path) -> R<Tree> {
672 let listing = git(dir, &["ls-files", "-z"])?;
673 let mut tree = Tree::new();
674 for rel in listing.split('\0').filter(|p| !p.is_empty()) {
675 if !is_supported(rel) {
676 continue;
677 }
678 let path = dir.join(rel);
679 if !std::fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_file()) {
680 continue;
681 }
682 if let Ok(content) = std::fs::read_to_string(path) {
683 tree.insert(rel.to_string(), content);
684 }
685 }
686 Ok(tree)
687}
688
689pub fn file_stages(dir: &Path, path: &str) -> R<(String, String, String)> {
698 let stage = |n: u8| git(dir, &["show", &format!(":{n}:{path}")]).ok();
699 if let (Some(b), Some(o), Some(t)) = (stage(1), stage(2), stage(3)) {
700 return Ok((b, o, t));
701 }
702 let scope = merge_scope(dir)?.ok_or(
703 "no merge, rebase, cherry-pick or revert in progress and HEAD is not a merge \
704 commit — there is no three-way context to explain this file against",
705 )?;
706 let get = |t: &Tree| t.get(path).cloned().unwrap_or_default();
707 if !scope.ours.contains_key(path) && !scope.theirs.contains_key(path) {
708 return Err(format!("`{path}` is in neither side of this merge").into());
709 }
710 Ok((get(&scope.base), get(&scope.ours), get(&scope.theirs)))
711}
712
713pub(crate) fn read_rev_tree(dir: &Path, rev: &str) -> R<Tree> {
717 let listing = git(
718 dir,
719 &["ls-tree", "-r", "--name-only", "-z", "--full-tree", rev],
720 )?;
721 let paths: Vec<String> = listing
722 .split('\0')
723 .filter(|p| !p.is_empty() && is_supported(p))
724 .map(str::to_string)
725 .collect();
726 Ok(read_paths_at_rev(dir, rev, &paths)?.tree)
729}
730
731#[cfg(test)]
732mod tests {
733 use super::*;
734 use std::fs;
735 use std::path::PathBuf;
736 use std::process::Command;
737 use std::sync::atomic::{AtomicU32, Ordering};
738
739 static FIXTURE_COUNTER: AtomicU32 = AtomicU32::new(0);
740
741 fn git_ok(dir: &Path, args: &[&str]) -> String {
742 let out = Command::new("git")
743 .args(args)
744 .current_dir(dir)
745 .output()
746 .expect("run git");
747 assert!(
748 out.status.success(),
749 "git {} failed: {}",
750 args.join(" "),
751 String::from_utf8_lossy(&out.stderr).trim()
752 );
753 String::from_utf8_lossy(&out.stdout).to_string()
754 }
755
756 fn git_fails(dir: &Path, args: &[&str]) {
757 let status = Command::new("git")
758 .args(args)
759 .current_dir(dir)
760 .status()
761 .expect("run git");
762 assert!(
763 !status.success(),
764 "git {} unexpectedly succeeded",
765 args.join(" ")
766 );
767 }
768
769 fn git_fixture(name: &str) -> PathBuf {
770 let n = FIXTURE_COUNTER.fetch_add(1, Ordering::Relaxed);
771 let root = std::env::temp_dir().join(format!(
772 "weave-gitscan-test-{}-{}-{}",
773 std::process::id(),
774 name,
775 n
776 ));
777 fs::create_dir_all(&root).expect("create fixture dir");
778 git_ok(&root, &["init", "-q"]);
779 git_ok(&root, &["checkout", "-b", "main"]);
780 root
781 }
782
783 #[test]
789 fn batch_read_of_large_blobs_does_not_deadlock() {
790 let root = git_fixture("cat-file-large");
794 let deep = "a_directory_name_long_enough_to_fatten_every_request/and_one_more_level";
795 fs::create_dir_all(root.join(deep)).expect("mkdir");
796 let mut paths = Vec::new();
797 for i in 0..3000 {
798 let name = format!("{deep}/module_number_{i:05}.py");
799 fs::write(root.join(&name), format!("value_{i} = {i}\n").repeat(8)).expect("write");
800 paths.push(name);
801 }
802 paths.push("absent.py".to_string());
803 git_ok(&root, &["add", "."]);
804 git_ok(&root, &["commit", "-m", "big"]);
805
806 let (tx, rx) = std::sync::mpsc::channel();
807 let dir = root.clone();
808 let asked = paths.clone();
809 std::thread::spawn(move || {
810 let _ = tx.send(
811 read_paths_at_rev(&dir, "HEAD", &asked)
812 .map(|s| s.tree)
813 .map_err(|e| e.to_string()),
814 );
815 });
816 let tree = rx
817 .recv_timeout(std::time::Duration::from_secs(60))
818 .expect("read_paths_at_rev hung on a large batch")
819 .expect("read_paths_at_rev failed");
820 assert_eq!(
821 tree.len(),
822 3000,
823 "every present path read, the absent one skipped"
824 );
825 assert_eq!(
826 tree[&format!("{deep}/module_number_02999.py")],
827 "value_2999 = 2999\n".repeat(8)
828 );
829 }
830
831 #[test]
834 fn merge_scope_during_rebase_conflict() {
835 let root = git_fixture("rebase-conflict");
836 let base_content = "def a():\n return 1\n\ndef keep():\n return 'stable'\n";
837
838 fs::write(root.join("m.py"), base_content).expect("write base");
839 git_ok(&root, &["add", "m.py"]);
840 git_ok(&root, &["commit", "-m", "initial"]);
841
842 git_ok(&root, &["checkout", "-b", "feature"]);
843 let feature_content = base_content.replace("return 1", "return 2");
844 fs::write(root.join("m.py"), &feature_content).expect("write feature");
845 git_ok(&root, &["add", "m.py"]);
846 git_ok(&root, &["commit", "-m", "feature"]);
847 let replayed_commit = git_ok(&root, &["rev-parse", "HEAD"]).trim().to_string();
848
849 git_ok(&root, &["checkout", "main"]);
850 let main_content = base_content.replace("return 1", "return 3");
851 fs::write(root.join("m.py"), &main_content).expect("write main");
852 git_ok(&root, &["add", "m.py"]);
853 git_ok(&root, &["commit", "-m", "main"]);
854
855 git_ok(&root, &["checkout", "feature"]);
856 git_fails(&root, &["rebase", "main"]);
857
858 let scope = merge_scope(&root)
859 .expect("merge_scope should not error")
860 .expect("merge_scope should recognize mid-rebase state");
861
862 assert!(
863 scope.scope.contains("rebase in progress"),
864 "scope should name the rebase moment: {}",
865 scope.scope
866 );
867
868 let rebase_head = git_ok(&root, &["rev-parse", "REBASE_HEAD"])
869 .trim()
870 .to_string();
871 assert_eq!(
872 rebase_head, replayed_commit,
873 "REBASE_HEAD should be the commit being replayed"
874 );
875
876 let theirs_rev = git_ok(&root, &["rev-parse", "REBASE_HEAD"])
877 .trim()
878 .to_string();
879 assert_eq!(
880 scope.theirs.get("m.py").map(String::as_str),
881 Some(feature_content.as_str()),
882 "theirs stage should come from REBASE_HEAD"
883 );
884 assert!(
885 scope.subjects.iter().any(|p| p == "m.py"),
886 "subjects should include the conflicted file: {:?}",
887 scope.subjects
888 );
889
890 let head_rev = git_ok(&root, &["rev-parse", "HEAD"]).trim().to_string();
892 assert_ne!(head_rev, replayed_commit);
893 assert_eq!(
894 scope.ours.get("m.py").map(String::as_str),
895 Some(main_content.as_str()),
896 "ours stage should come from HEAD"
897 );
898 assert_eq!(theirs_rev, replayed_commit);
899 }
900
901 fn partial_clone(name: &str, files: &[(&str, &str)]) -> (PathBuf, PathBuf) {
904 let src = git_fixture(name);
905 git_ok(&src, &["config", "uploadpack.allowFilter", "true"]);
906 git_ok(&src, &["config", "uploadpack.allowAnySHA1InWant", "true"]);
907 for (path, text) in files {
908 if let Some(dir) = Path::new(path).parent() {
909 fs::create_dir_all(src.join(dir)).expect("mkdir");
910 }
911 fs::write(src.join(path), text).expect("write");
912 }
913 git_ok(&src, &["add", "."]);
914 git_ok(
915 &src,
916 &[
917 "-c",
918 "user.name=t",
919 "-c",
920 "user.email=t@t",
921 "commit",
922 "-qm",
923 "c",
924 ],
925 );
926 let clone = src.with_extension("partial.git");
927 let url = format!("file://{}", src.display());
928 let parent = clone.parent().expect("parent");
929 git_ok(
930 parent,
931 &[
932 "clone",
933 "-q",
934 "--bare",
935 "--filter=blob:none",
936 &url,
937 clone.to_str().expect("utf8"),
938 ],
939 );
940 (src, clone)
941 }
942
943 fn within<T: Send + 'static>(secs: u64, f: impl FnOnce() -> T + Send + 'static) -> T {
946 let (tx, rx) = std::sync::mpsc::channel();
947 std::thread::spawn(move || {
948 let _ = tx.send(f());
949 });
950 rx.recv_timeout(std::time::Duration::from_secs(secs))
951 .expect("hung past its deadline")
952 }
953
954 #[test]
959 fn a_partial_clone_fetches_missing_stage_blobs_in_one_batch() {
960 let files: Vec<(String, String)> = (0..40)
961 .map(|i| {
962 (
963 format!("pkg/m{i}.py"),
964 format!("def f{i}():\n return {i}\n"),
965 )
966 })
967 .collect();
968 let refs: Vec<(&str, &str)> = files
969 .iter()
970 .map(|(p, t)| (p.as_str(), t.as_str()))
971 .collect();
972 let (_src, clone) = partial_clone("partial-fetch", &refs);
973 let paths: Vec<String> = files.iter().map(|(p, _)| p.clone()).collect();
974 let packs = |dir: &Path| {
975 fs::read_dir(dir.join("objects/pack"))
976 .expect("pack dir")
977 .filter(|e| {
978 e.as_ref()
979 .is_ok_and(|e| e.path().extension().is_some_and(|x| x == "pack"))
980 })
981 .count()
982 };
983 assert_eq!(packs(&clone), 1, "the clone's own pack: trees, no blobs");
984 let dir = clone.clone();
985 let stage = within(60, move || {
986 read_paths_at_rev(&dir, "HEAD", &paths).map_err(|e| e.to_string())
987 })
988 .expect("read");
989 assert!(stage.unreadable.is_empty(), "{:?}", stage.unreadable);
990 assert_eq!(stage.tree.len(), 40);
991 assert_eq!(stage.tree["pkg/m7.py"], "def f7():\n return 7\n");
992 assert_eq!(packs(&clone), 2, "all forty blobs arrived in one fetch");
994 }
995
996 #[test]
1000 fn an_unfetchable_blob_is_unreadable_not_absent_and_not_a_hang() {
1001 let (_src, clone) = partial_clone("partial-dead", &[("a.py", "def a():\n return 1\n")]);
1002 git_ok(
1003 &clone,
1004 &["remote", "set-url", "origin", "/nonexistent/weave-remote"],
1005 );
1006 let asked = vec!["a.py".to_string(), "never_existed.py".to_string()];
1007 let stage = within(60, move || {
1008 read_paths_at_rev(&clone, "HEAD", &asked).map_err(|e| e.to_string())
1009 })
1010 .expect("a dead remote is a per-path answer, not an error");
1011 assert!(stage.tree.is_empty());
1012 assert!(
1013 stage.unreadable.contains_key("a.py"),
1014 "{:?}",
1015 stage.unreadable
1016 );
1017 assert!(
1018 !stage.unreadable.contains_key("never_existed.py"),
1019 "absent is not unreadable"
1020 );
1021 }
1022
1023 #[cfg(unix)]
1026 #[test]
1027 fn a_symlink_is_irregular_not_source() {
1028 let root = git_fixture("symlink");
1029 fs::write(root.join("real.py"), "x = 1\n").expect("write");
1030 std::os::unix::fs::symlink("real.py", root.join("link.py")).expect("symlink");
1031 git_ok(&root, &["add", "."]);
1032 git_ok(
1033 &root,
1034 &[
1035 "-c",
1036 "user.name=t",
1037 "-c",
1038 "user.email=t@t",
1039 "commit",
1040 "-qm",
1041 "c",
1042 ],
1043 );
1044 let stage =
1045 read_paths_at_rev(&root, "HEAD", &["real.py".into(), "link.py".into()]).expect("read");
1046 assert_eq!(stage.tree.keys().collect::<Vec<_>>(), vec!["real.py"]);
1047 assert!(stage.irregular.contains("link.py"));
1048 let work = read_worktree(&root).expect("worktree");
1049 assert!(
1050 !work.contains_key("link.py"),
1051 "a symlink is never read through"
1052 );
1053 }
1054
1055 #[test]
1058 fn a_git_that_never_answers_is_an_error_not_a_wait() {
1059 let root = git_fixture("slow");
1060 let started = std::time::Instant::now();
1061 let err = within(30, move || {
1062 bounded(
1063 &root,
1064 &["-c", "alias.stall=!sleep 20", "stall"],
1065 None,
1066 std::time::Duration::from_secs(1),
1067 )
1068 .map_err(|e| e.to_string())
1069 })
1070 .expect_err("a stalled git must not succeed");
1071 assert!(err.contains("did not finish within 1s"), "{err}");
1072 assert!(started.elapsed() < std::time::Duration::from_secs(10));
1073 }
1074}