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(
425 dir: &Path,
426 base: Option<&str>,
427 ours: Option<&str>,
428 theirs: Option<&str>,
429) -> R<(String, String, String)> {
430 if !rev_exists(dir, "HEAD") {
431 return Err(
432 "not a git repository (or no commits yet) — run inside a git repository with at \
433 least one commit"
434 .into(),
435 );
436 }
437 let mut ours_default = "HEAD";
438 let theirs = match theirs {
439 Some(t) => t.to_string(),
440 None => match [
441 "MERGE_HEAD",
442 "REBASE_HEAD",
443 "CHERRY_PICK_HEAD",
444 "REVERT_HEAD",
445 ]
446 .into_iter()
447 .find(|r| rev_exists(dir, r))
448 {
449 Some(r) => r.to_string(),
450 None if rev_exists(dir, "HEAD^2") => {
451 ours_default = "HEAD^1";
452 "HEAD^2".to_string()
453 }
454 None => {
455 return Err(
456 "no theirs revision given (`--theirs`, or `theirs` over MCP) and no \
457 merge, rebase, cherry-pick or revert in progress, and HEAD is not a merge \
458 commit"
459 .into(),
460 )
461 }
462 },
463 };
464 let ours = ours.unwrap_or(ours_default).to_string();
465 let base = match base {
466 Some(b) => b.to_string(),
467 None if theirs == "REBASE_HEAD" || theirs == "CHERRY_PICK_HEAD" => {
468 replay_base(dir, &theirs)?
469 }
470 None => git(dir, &["merge-base", &ours, &theirs])?
471 .trim()
472 .to_string(),
473 };
474 Ok((base, ours, theirs))
475}
476
477pub fn trees(
478 dir: &Path,
479 base: Option<&str>,
480 ours: Option<&str>,
481 theirs: Option<&str>,
482) -> R<(Tree, Tree, Tree)> {
483 let (base, ours, theirs) = resolve_revs(dir, base, ours, theirs)?;
484 Ok((
485 read_rev_tree(dir, &base)?,
486 read_rev_tree(dir, &ours)?,
487 read_rev_tree(dir, &theirs)?,
488 ))
489}
490
491pub struct MergeScope {
495 pub base: Tree,
496 pub ours: Tree,
497 pub theirs: Tree,
498 pub work: Tree,
500 pub subjects: Vec<String>,
502 pub unreadable: BTreeMap<String, String>,
506 pub irregular: BTreeSet<String>,
509 pub scope: String,
510}
511
512pub fn merge_scope(dir: &Path) -> R<Option<MergeScope>> {
535 if !rev_exists(dir, "HEAD") {
536 return Ok(None);
537 }
538 let (ours_rev, theirs_rev, moment, use_replay_base) = if rev_exists(dir, "MERGE_HEAD") {
539 (
540 "HEAD".to_string(),
541 "MERGE_HEAD".to_string(),
542 "merge in progress",
543 false,
544 )
545 } else if let Some(op_head) = ["REBASE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD"]
546 .into_iter()
547 .find(|r| rev_exists(dir, r))
548 {
549 let moment = match op_head {
556 "REBASE_HEAD" => "rebase in progress",
557 "CHERRY_PICK_HEAD" => "cherry-pick in progress",
558 _ => "revert in progress",
559 };
560 (
561 "HEAD".to_string(),
562 op_head.to_string(),
563 moment,
564 op_head != "REVERT_HEAD",
565 )
566 } else if rev_exists(dir, "HEAD^2") {
567 (
568 "HEAD^1".to_string(),
569 "HEAD^2".to_string(),
570 "merge just committed",
571 false,
572 )
573 } else {
574 return Ok(None);
575 };
576 let base_rev = if use_replay_base {
577 replay_base(dir, &theirs_rev)?
578 } else {
579 git(dir, &["merge-base", &ours_rev, &theirs_rev])?
580 .trim()
581 .to_string()
582 };
583
584 let mut subjects: BTreeSet<String> = BTreeSet::new();
601 for side in [&ours_rev, &theirs_rev] {
602 let list = git(
603 dir,
604 &["diff", "--name-only", "--no-renames", "-z", &base_rev, side],
605 )?;
606 subjects.extend(
607 list.split('\0')
608 .filter(|p| !p.is_empty() && is_supported(p))
609 .map(str::to_string),
610 );
611 }
612 if let Ok(list) = git(
615 dir,
616 &[
617 "diff",
618 "--name-only",
619 "--no-renames",
620 "--diff-filter=U",
621 "-z",
622 ],
623 ) {
624 subjects.extend(
625 list.split('\0')
626 .filter(|p| !p.is_empty() && is_supported(p))
627 .map(str::to_string),
628 );
629 }
630 let subjects: Vec<String> = subjects.into_iter().collect();
631
632 let stages = [
642 read_paths_at_rev(dir, &base_rev, &subjects)?,
643 read_paths_at_rev(dir, &ours_rev, &subjects)?,
644 read_paths_at_rev(dir, &theirs_rev, &subjects)?,
645 ];
646 let work = read_worktree(dir)?;
647 let scope = format!(
648 "working tree vs the three merge stages of {ours_rev} × {theirs_rev} \
649 (base {}, {moment}) — {} file(s) either side changed",
650 &base_rev[..base_rev.len().min(8)],
651 subjects.len()
652 );
653 let mut unreadable: BTreeMap<String, String> = BTreeMap::new();
656 let mut irregular: BTreeSet<String> = BTreeSet::new();
657 for stage in &stages {
658 for (path, why) in &stage.unreadable {
659 unreadable
660 .entry(path.clone())
661 .or_insert_with(|| why.clone());
662 }
663 irregular.extend(stage.irregular.iter().cloned());
664 }
665 let subjects = subjects
666 .into_iter()
667 .filter(|p| !irregular.contains(p) && !unreadable.contains_key(p))
668 .collect();
669 irregular.retain(|p| !unreadable.contains_key(p));
670 let [base, ours, theirs] = stages.map(|s| s.tree);
671 Ok(Some(MergeScope {
672 base,
673 ours,
674 theirs,
675 work,
676 subjects,
677 unreadable,
678 irregular,
679 scope,
680 }))
681}
682
683pub(crate) fn read_worktree(dir: &Path) -> R<Tree> {
688 let listing = git(dir, &["ls-files", "-z"])?;
689 let mut tree = Tree::new();
690 for rel in listing.split('\0').filter(|p| !p.is_empty()) {
691 if !is_supported(rel) {
692 continue;
693 }
694 let path = dir.join(rel);
695 if !std::fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_file()) {
696 continue;
697 }
698 if let Ok(content) = std::fs::read_to_string(path) {
699 tree.insert(rel.to_string(), content);
700 }
701 }
702 Ok(tree)
703}
704
705pub fn file_stages(dir: &Path, path: &str) -> R<(String, String, String)> {
714 let stage = |n: u8| git(dir, &["show", &format!(":{n}:{path}")]).ok();
715 if let (Some(b), Some(o), Some(t)) = (stage(1), stage(2), stage(3)) {
716 return Ok((b, o, t));
717 }
718 let scope = merge_scope(dir)?.ok_or(
719 "no merge, rebase, cherry-pick or revert in progress and HEAD is not a merge \
720 commit — there is no three-way context to explain this file against",
721 )?;
722 let get = |t: &Tree| t.get(path).cloned().unwrap_or_default();
723 if !scope.ours.contains_key(path) && !scope.theirs.contains_key(path) {
724 return Err(format!("`{path}` is in neither side of this merge").into());
725 }
726 Ok((get(&scope.base), get(&scope.ours), get(&scope.theirs)))
727}
728
729pub(crate) fn read_rev_tree(dir: &Path, rev: &str) -> R<Tree> {
733 let listing = git(
734 dir,
735 &["ls-tree", "-r", "--name-only", "-z", "--full-tree", rev],
736 )?;
737 let paths: Vec<String> = listing
738 .split('\0')
739 .filter(|p| !p.is_empty() && is_supported(p))
740 .map(str::to_string)
741 .collect();
742 Ok(read_paths_at_rev(dir, rev, &paths)?.tree)
745}
746
747#[cfg(test)]
748mod tests {
749 use super::*;
750 use std::fs;
751 use std::path::PathBuf;
752 use std::process::Command;
753 use std::sync::atomic::{AtomicU32, Ordering};
754
755 static FIXTURE_COUNTER: AtomicU32 = AtomicU32::new(0);
756
757 fn git_ok(dir: &Path, args: &[&str]) -> String {
758 let out = Command::new("git")
759 .args(args)
760 .current_dir(dir)
761 .output()
762 .expect("run git");
763 assert!(
764 out.status.success(),
765 "git {} failed: {}",
766 args.join(" "),
767 String::from_utf8_lossy(&out.stderr).trim()
768 );
769 String::from_utf8_lossy(&out.stdout).to_string()
770 }
771
772 fn git_fails(dir: &Path, args: &[&str]) {
773 let status = Command::new("git")
774 .args(args)
775 .current_dir(dir)
776 .status()
777 .expect("run git");
778 assert!(
779 !status.success(),
780 "git {} unexpectedly succeeded",
781 args.join(" ")
782 );
783 }
784
785 fn git_fixture(name: &str) -> PathBuf {
786 let n = FIXTURE_COUNTER.fetch_add(1, Ordering::Relaxed);
787 let root = std::env::temp_dir().join(format!(
788 "weave-gitscan-test-{}-{}-{}",
789 std::process::id(),
790 name,
791 n
792 ));
793 fs::create_dir_all(&root).expect("create fixture dir");
794 git_ok(&root, &["init", "-q"]);
795 git_ok(&root, &["checkout", "-b", "main"]);
796 root
797 }
798
799 #[test]
805 fn batch_read_of_large_blobs_does_not_deadlock() {
806 let root = git_fixture("cat-file-large");
810 let deep = "a_directory_name_long_enough_to_fatten_every_request/and_one_more_level";
811 fs::create_dir_all(root.join(deep)).expect("mkdir");
812 let mut paths = Vec::new();
813 for i in 0..3000 {
814 let name = format!("{deep}/module_number_{i:05}.py");
815 fs::write(root.join(&name), format!("value_{i} = {i}\n").repeat(8)).expect("write");
816 paths.push(name);
817 }
818 paths.push("absent.py".to_string());
819 git_ok(&root, &["add", "."]);
820 git_ok(&root, &["commit", "-m", "big"]);
821
822 let (tx, rx) = std::sync::mpsc::channel();
823 let dir = root.clone();
824 let asked = paths.clone();
825 std::thread::spawn(move || {
826 let _ = tx.send(
827 read_paths_at_rev(&dir, "HEAD", &asked)
828 .map(|s| s.tree)
829 .map_err(|e| e.to_string()),
830 );
831 });
832 let tree = rx
833 .recv_timeout(std::time::Duration::from_secs(60))
834 .expect("read_paths_at_rev hung on a large batch")
835 .expect("read_paths_at_rev failed");
836 assert_eq!(
837 tree.len(),
838 3000,
839 "every present path read, the absent one skipped"
840 );
841 assert_eq!(
842 tree[&format!("{deep}/module_number_02999.py")],
843 "value_2999 = 2999\n".repeat(8)
844 );
845 }
846
847 #[test]
850 fn merge_scope_during_rebase_conflict() {
851 let root = git_fixture("rebase-conflict");
852 let base_content = "def a():\n return 1\n\ndef keep():\n return 'stable'\n";
853
854 fs::write(root.join("m.py"), base_content).expect("write base");
855 git_ok(&root, &["add", "m.py"]);
856 git_ok(&root, &["commit", "-m", "initial"]);
857
858 git_ok(&root, &["checkout", "-b", "feature"]);
859 let feature_content = base_content.replace("return 1", "return 2");
860 fs::write(root.join("m.py"), &feature_content).expect("write feature");
861 git_ok(&root, &["add", "m.py"]);
862 git_ok(&root, &["commit", "-m", "feature"]);
863 let replayed_commit = git_ok(&root, &["rev-parse", "HEAD"]).trim().to_string();
864
865 git_ok(&root, &["checkout", "main"]);
866 let main_content = base_content.replace("return 1", "return 3");
867 fs::write(root.join("m.py"), &main_content).expect("write main");
868 git_ok(&root, &["add", "m.py"]);
869 git_ok(&root, &["commit", "-m", "main"]);
870
871 git_ok(&root, &["checkout", "feature"]);
872 git_fails(&root, &["rebase", "main"]);
873
874 let scope = merge_scope(&root)
875 .expect("merge_scope should not error")
876 .expect("merge_scope should recognize mid-rebase state");
877
878 assert!(
879 scope.scope.contains("rebase in progress"),
880 "scope should name the rebase moment: {}",
881 scope.scope
882 );
883
884 let rebase_head = git_ok(&root, &["rev-parse", "REBASE_HEAD"])
885 .trim()
886 .to_string();
887 assert_eq!(
888 rebase_head, replayed_commit,
889 "REBASE_HEAD should be the commit being replayed"
890 );
891
892 let theirs_rev = git_ok(&root, &["rev-parse", "REBASE_HEAD"])
893 .trim()
894 .to_string();
895 assert_eq!(
896 scope.theirs.get("m.py").map(String::as_str),
897 Some(feature_content.as_str()),
898 "theirs stage should come from REBASE_HEAD"
899 );
900 assert!(
901 scope.subjects.iter().any(|p| p == "m.py"),
902 "subjects should include the conflicted file: {:?}",
903 scope.subjects
904 );
905
906 let head_rev = git_ok(&root, &["rev-parse", "HEAD"]).trim().to_string();
908 assert_ne!(head_rev, replayed_commit);
909 assert_eq!(
910 scope.ours.get("m.py").map(String::as_str),
911 Some(main_content.as_str()),
912 "ours stage should come from HEAD"
913 );
914 assert_eq!(theirs_rev, replayed_commit);
915 }
916
917 fn partial_clone(name: &str, files: &[(&str, &str)]) -> (PathBuf, PathBuf) {
920 let src = git_fixture(name);
921 git_ok(&src, &["config", "uploadpack.allowFilter", "true"]);
922 git_ok(&src, &["config", "uploadpack.allowAnySHA1InWant", "true"]);
923 for (path, text) in files {
924 if let Some(dir) = Path::new(path).parent() {
925 fs::create_dir_all(src.join(dir)).expect("mkdir");
926 }
927 fs::write(src.join(path), text).expect("write");
928 }
929 git_ok(&src, &["add", "."]);
930 git_ok(
931 &src,
932 &[
933 "-c",
934 "user.name=t",
935 "-c",
936 "user.email=t@t",
937 "commit",
938 "-qm",
939 "c",
940 ],
941 );
942 let clone = src.with_extension("partial.git");
943 let url = format!("file://{}", src.display());
944 let parent = clone.parent().expect("parent");
945 git_ok(
946 parent,
947 &[
948 "clone",
949 "-q",
950 "--bare",
951 "--filter=blob:none",
952 &url,
953 clone.to_str().expect("utf8"),
954 ],
955 );
956 (src, clone)
957 }
958
959 fn within<T: Send + 'static>(secs: u64, f: impl FnOnce() -> T + Send + 'static) -> T {
962 let (tx, rx) = std::sync::mpsc::channel();
963 std::thread::spawn(move || {
964 let _ = tx.send(f());
965 });
966 rx.recv_timeout(std::time::Duration::from_secs(secs))
967 .expect("hung past its deadline")
968 }
969
970 #[test]
975 fn a_partial_clone_fetches_missing_stage_blobs_in_one_batch() {
976 let files: Vec<(String, String)> = (0..40)
977 .map(|i| {
978 (
979 format!("pkg/m{i}.py"),
980 format!("def f{i}():\n return {i}\n"),
981 )
982 })
983 .collect();
984 let refs: Vec<(&str, &str)> = files
985 .iter()
986 .map(|(p, t)| (p.as_str(), t.as_str()))
987 .collect();
988 let (_src, clone) = partial_clone("partial-fetch", &refs);
989 let paths: Vec<String> = files.iter().map(|(p, _)| p.clone()).collect();
990 let packs = |dir: &Path| {
991 fs::read_dir(dir.join("objects/pack"))
992 .expect("pack dir")
993 .filter(|e| {
994 e.as_ref()
995 .is_ok_and(|e| e.path().extension().is_some_and(|x| x == "pack"))
996 })
997 .count()
998 };
999 assert_eq!(packs(&clone), 1, "the clone's own pack: trees, no blobs");
1000 let dir = clone.clone();
1001 let stage = within(60, move || {
1002 read_paths_at_rev(&dir, "HEAD", &paths).map_err(|e| e.to_string())
1003 })
1004 .expect("read");
1005 assert!(stage.unreadable.is_empty(), "{:?}", stage.unreadable);
1006 assert_eq!(stage.tree.len(), 40);
1007 assert_eq!(stage.tree["pkg/m7.py"], "def f7():\n return 7\n");
1008 assert_eq!(packs(&clone), 2, "all forty blobs arrived in one fetch");
1010 }
1011
1012 #[test]
1016 fn an_unfetchable_blob_is_unreadable_not_absent_and_not_a_hang() {
1017 let (_src, clone) = partial_clone("partial-dead", &[("a.py", "def a():\n return 1\n")]);
1018 git_ok(
1019 &clone,
1020 &["remote", "set-url", "origin", "/nonexistent/weave-remote"],
1021 );
1022 let asked = vec!["a.py".to_string(), "never_existed.py".to_string()];
1023 let stage = within(60, move || {
1024 read_paths_at_rev(&clone, "HEAD", &asked).map_err(|e| e.to_string())
1025 })
1026 .expect("a dead remote is a per-path answer, not an error");
1027 assert!(stage.tree.is_empty());
1028 assert!(
1029 stage.unreadable.contains_key("a.py"),
1030 "{:?}",
1031 stage.unreadable
1032 );
1033 assert!(
1034 !stage.unreadable.contains_key("never_existed.py"),
1035 "absent is not unreadable"
1036 );
1037 }
1038
1039 #[cfg(unix)]
1042 #[test]
1043 fn a_symlink_is_irregular_not_source() {
1044 let root = git_fixture("symlink");
1045 fs::write(root.join("real.py"), "x = 1\n").expect("write");
1046 std::os::unix::fs::symlink("real.py", root.join("link.py")).expect("symlink");
1047 git_ok(&root, &["add", "."]);
1048 git_ok(
1049 &root,
1050 &[
1051 "-c",
1052 "user.name=t",
1053 "-c",
1054 "user.email=t@t",
1055 "commit",
1056 "-qm",
1057 "c",
1058 ],
1059 );
1060 let stage =
1061 read_paths_at_rev(&root, "HEAD", &["real.py".into(), "link.py".into()]).expect("read");
1062 assert_eq!(stage.tree.keys().collect::<Vec<_>>(), vec!["real.py"]);
1063 assert!(stage.irregular.contains("link.py"));
1064 let work = read_worktree(&root).expect("worktree");
1065 assert!(
1066 !work.contains_key("link.py"),
1067 "a symlink is never read through"
1068 );
1069 }
1070
1071 #[test]
1074 fn a_git_that_never_answers_is_an_error_not_a_wait() {
1075 let root = git_fixture("slow");
1076 let started = std::time::Instant::now();
1077 let err = within(30, move || {
1078 bounded(
1079 &root,
1080 &["-c", "alias.stall=!sleep 20", "stall"],
1081 None,
1082 std::time::Duration::from_secs(1),
1083 )
1084 .map_err(|e| e.to_string())
1085 })
1086 .expect_err("a stalled git must not succeed");
1087 assert!(err.contains("did not finish within 1s"), "{err}");
1088 assert!(started.elapsed() < std::time::Duration::from_secs(10));
1089 }
1090
1091 #[test]
1095 fn resolve_revs_defaults_to_a_committed_merge() {
1096 let root = git_fixture("committed-merge");
1097 let commit = |msg: &str| {
1098 git_ok(
1099 &root,
1100 &[
1101 "-c",
1102 "user.name=t",
1103 "-c",
1104 "user.email=t@t",
1105 "commit",
1106 "-qm",
1107 msg,
1108 ],
1109 );
1110 };
1111 fs::write(root.join("a.py"), "x = 1\n").expect("write");
1112 git_ok(&root, &["add", "."]);
1113 commit("base");
1114 git_ok(&root, &["checkout", "-qb", "side"]);
1115 fs::write(root.join("b.py"), "y = 1\n").expect("write");
1116 git_ok(&root, &["add", "."]);
1117 commit("side");
1118 git_ok(&root, &["checkout", "-q", "main"]);
1119 fs::write(root.join("c.py"), "z = 1\n").expect("write");
1120 git_ok(&root, &["add", "."]);
1121 commit("main");
1122 git_ok(
1123 &root,
1124 &[
1125 "-c",
1126 "user.name=t",
1127 "-c",
1128 "user.email=t@t",
1129 "merge",
1130 "-q",
1131 "--no-edit",
1132 "side",
1133 ],
1134 );
1135
1136 let (_, ours, theirs) = resolve_revs(&root, None, None, None).expect("committed merge");
1137 assert_eq!((ours.as_str(), theirs.as_str()), ("HEAD^1", "HEAD^2"));
1138 let _ = fs::remove_dir_all(&root);
1139 }
1140}