use std::collections::{BTreeMap, BTreeSet};
use std::io::{Read, Write};
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use crate::repo_scope::{is_supported, Tree};
type R<T> = Result<T, Box<dyn std::error::Error>>;
const GIT_DEADLINE: Duration = Duration::from_secs(120);
fn bounded(dir: &Path, args: &[&str], input: Option<Vec<u8>>, limit: Duration) -> R<Vec<u8>> {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(dir)
.env("GIT_NO_LAZY_FETCH", "1")
.env("GIT_TERMINAL_PROMPT", "0");
let (status, stdout, stderr) =
run_bounded(cmd, &format!("git {}", args.join(" ")), input, limit)?;
if !status.success() {
return Err(format!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&stderr).trim()
)
.into());
}
Ok(stdout)
}
pub(crate) fn run_bounded(
mut cmd: Command,
what: &str,
input: Option<Vec<u8>>,
limit: Duration,
) -> R<(std::process::ExitStatus, Vec<u8>, Vec<u8>)> {
let mut child = cmd
.stdin(if input.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("could not run {what}: {e}"))?;
if let (Some(bytes), Some(mut stdin)) = (input, child.stdin.take()) {
std::thread::spawn(move || {
let _ = stdin.write_all(&bytes);
});
}
let drain = |pipe: Option<Box<dyn Read + Send>>| {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let mut buf = Vec::new();
if let Some(mut p) = pipe {
let _ = p.read_to_end(&mut buf);
}
let _ = tx.send(buf);
});
rx
};
let out = drain(
child
.stdout
.take()
.map(|p| Box::new(p) as Box<dyn Read + Send>),
);
let err = drain(
child
.stderr
.take()
.map(|p| Box::new(p) as Box<dyn Read + Send>),
);
let deadline = Instant::now() + limit;
let too_slow = || -> Box<dyn std::error::Error> {
format!(
"{what} did not finish within {}s — weave stopped waiting rather than hang",
limit.as_secs()
)
.into()
};
let status = loop {
if let Some(status) = child.try_wait()? {
break status;
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Err(too_slow());
}
std::thread::sleep(Duration::from_millis(5));
};
let remaining = || deadline.saturating_duration_since(Instant::now());
let stdout = out.recv_timeout(remaining()).map_err(|_| too_slow())?;
let stderr = err.recv_timeout(remaining()).map_err(|_| too_slow())?;
Ok((status, stdout, stderr))
}
pub(crate) fn git(dir: &Path, args: &[&str]) -> R<String> {
git_input(dir, args, None)
}
pub(crate) fn git_input(dir: &Path, args: &[&str], input: Option<Vec<u8>>) -> R<String> {
let out = bounded(dir, args, input, GIT_DEADLINE)?;
Ok(String::from_utf8_lossy(&out).to_string())
}
#[derive(Debug, Default)]
pub struct Stage {
pub tree: Tree,
pub unreadable: BTreeMap<String, String>,
pub irregular: BTreeSet<String>,
}
fn read_paths_at_rev(dir: &Path, rev: &str, paths: &[String]) -> R<Stage> {
let mut stage = Stage::default();
for (path, entry) in entries_at_rev(dir, rev, paths)? {
match entry.body {
Body::Text(text) => {
stage.tree.insert(path, text);
}
Body::Binary => {}
Body::Irregular => {
stage.irregular.insert(path);
}
Body::Unreadable(why) => {
stage.unreadable.insert(path, why);
}
}
}
Ok(stage)
}
#[derive(Debug, Clone)]
pub(crate) struct Entry {
pub mode: String,
pub oid: String,
pub body: Body,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Body {
Text(String),
Binary,
Irregular,
Unreadable(String),
}
pub(crate) fn entries_at_rev(
dir: &Path,
rev: &str,
paths: &[String],
) -> R<BTreeMap<String, Entry>> {
let mut out = BTreeMap::new();
if paths.is_empty() {
return Ok(out);
}
let wanted: BTreeSet<&str> = paths.iter().map(String::as_str).collect();
let listing = bounded(
dir,
&["ls-tree", "-r", "-z", "--full-tree", rev],
None,
GIT_DEADLINE,
)?;
let mut blobs: Vec<(String, String, String)> = Vec::new(); for entry in listing.split(|&b| b == 0).filter(|e| !e.is_empty()) {
let entry = String::from_utf8_lossy(entry);
let Some((meta, path)) = entry.split_once('\t') else {
continue;
};
if !wanted.contains(path) {
continue;
}
let mut meta = meta.split(' ');
let (mode, oid) = (meta.next().unwrap_or(""), meta.nth(1).unwrap_or(""));
if mode == "100644" || mode == "100755" {
blobs.push((path.to_string(), mode.to_string(), oid.to_string()));
} else {
out.insert(
path.to_string(),
Entry {
mode: mode.to_string(),
oid: oid.to_string(),
body: Body::Irregular,
},
);
}
}
let oids: Vec<&str> = blobs.iter().map(|(_, _, oid)| oid.as_str()).collect();
let mut found = cat_blobs(dir, &oids)?;
let missing: Vec<&str> = oids
.iter()
.copied()
.filter(|oid| !found.contains_key(*oid))
.collect();
let mut why_missing = "the blob is not in the local object store".to_string();
if !missing.is_empty() {
match prefetch(dir, &missing) {
Ok(()) => found.extend(cat_blobs(dir, &missing)?),
Err(e) => why_missing = e.to_string(),
}
}
for (path, mode, oid) in blobs {
let body = match found.get(&oid) {
Some(Some(text)) => Body::Text(text.clone()),
Some(None) => Body::Binary,
None => Body::Unreadable(format!("{rev}: {why_missing}")),
};
out.insert(path, Entry { mode, oid, body });
}
Ok(out)
}
fn cat_blobs(dir: &Path, oids: &[&str]) -> R<BTreeMap<String, Option<String>>> {
let mut out = BTreeMap::new();
if oids.is_empty() {
return Ok(out);
}
let mut request = String::new();
for oid in oids {
request.push_str(oid);
request.push('\n');
}
let bytes = bounded(
dir,
&["cat-file", "--batch"],
Some(request.into_bytes()),
GIT_DEADLINE,
)?;
let mut pos = 0usize;
while pos < bytes.len() {
let Some(nl) = bytes[pos..].iter().position(|&b| b == b'\n') else {
break;
};
let header = String::from_utf8_lossy(&bytes[pos..pos + nl]).to_string();
pos += nl + 1;
let fields: Vec<&str> = header.split(' ').collect();
let [oid, _kind, size] = fields[..] else {
continue; };
let size: usize = size.parse()?;
if pos + size > bytes.len() {
return Err("git cat-file --batch reply was truncated".into());
}
let text = std::str::from_utf8(&bytes[pos..pos + size])
.ok()
.map(str::to_string);
out.insert(oid.to_string(), text);
pos += size + 1; }
Ok(out)
}
fn promisor_remote(dir: &Path) -> Option<String> {
let flagged = git(dir, &["config", "--get-regexp", r"^remote\..*\.promisor$"]).ok();
flagged
.iter()
.flat_map(|s| s.lines())
.filter_map(|l| l.split_once(' '))
.filter(|(_, v)| v.trim() == "true")
.find_map(|(k, _)| {
k.strip_prefix("remote.")?
.strip_suffix(".promisor")
.map(str::to_string)
})
.or_else(|| {
git(dir, &["config", "--get", "extensions.partialClone"])
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
})
}
fn prefetch(dir: &Path, oids: &[&str]) -> R<()> {
let remote = promisor_remote(dir)
.ok_or("the blob is not in the local object store and this is not a partial clone")?;
let mut request = String::new();
for oid in oids {
request.push_str(oid);
request.push('\n');
}
bounded(
dir,
&[
"-c",
"fetch.negotiationAlgorithm=noop",
"fetch",
&remote,
"--no-tags",
"--no-write-fetch-head",
"--recurse-submodules=no",
"--filter=blob:none",
"--stdin",
],
Some(request.into_bytes()),
GIT_DEADLINE,
)
.map_err(|e| {
format!(
"{} blob(s) are not local and could not be fetched: {e}",
oids.len()
)
})?;
Ok(())
}
pub(crate) fn rev_exists(dir: &Path, rev: &str) -> bool {
git(dir, &["rev-parse", "--verify", "--quiet", rev]).is_ok()
}
const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
fn replay_base(dir: &Path, theirs_rev: &str) -> R<String> {
let parent = format!("{theirs_rev}^");
if rev_exists(dir, &parent) {
Ok(git(dir, &["rev-parse", &parent])?.trim().to_string())
} else {
Ok(EMPTY_TREE.to_string())
}
}
pub(crate) fn resolve_revs(
dir: &Path,
base: Option<&str>,
ours: Option<&str>,
theirs: Option<&str>,
) -> R<(String, String, String)> {
if !rev_exists(dir, "HEAD") {
return Err(
"not a git repository (or no commits yet) — use the directory mode instead".into(),
);
}
let ours = ours.unwrap_or("HEAD").to_string();
let theirs = match theirs {
Some(t) => t.to_string(),
None => [
"MERGE_HEAD",
"REBASE_HEAD",
"CHERRY_PICK_HEAD",
"REVERT_HEAD",
]
.into_iter()
.find(|r| rev_exists(dir, r))
.map(str::to_string)
.ok_or(
"no --theirs given and no merge, rebase, cherry-pick or revert in progress \
(no MERGE_HEAD / REBASE_HEAD / CHERRY_PICK_HEAD / REVERT_HEAD)",
)?,
};
let base = match base {
Some(b) => b.to_string(),
None if theirs == "REBASE_HEAD" || theirs == "CHERRY_PICK_HEAD" => {
replay_base(dir, &theirs)?
}
None => git(dir, &["merge-base", &ours, &theirs])?
.trim()
.to_string(),
};
Ok((base, ours, theirs))
}
pub fn trees(
dir: &Path,
base: Option<&str>,
ours: Option<&str>,
theirs: Option<&str>,
) -> R<(Tree, Tree, Tree)> {
let (base, ours, theirs) = resolve_revs(dir, base, ours, theirs)?;
Ok((
read_rev_tree(dir, &base)?,
read_rev_tree(dir, &ours)?,
read_rev_tree(dir, &theirs)?,
))
}
pub struct MergeScope {
pub base: Tree,
pub ours: Tree,
pub theirs: Tree,
pub work: Tree,
pub subjects: Vec<String>,
pub unreadable: BTreeMap<String, String>,
pub irregular: BTreeSet<String>,
pub scope: String,
}
pub fn merge_scope(dir: &Path) -> R<Option<MergeScope>> {
if !rev_exists(dir, "HEAD") {
return Ok(None);
}
let (ours_rev, theirs_rev, moment, use_replay_base) = if rev_exists(dir, "MERGE_HEAD") {
(
"HEAD".to_string(),
"MERGE_HEAD".to_string(),
"merge in progress",
false,
)
} else if let Some(op_head) = ["REBASE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD"]
.into_iter()
.find(|r| rev_exists(dir, r))
{
let moment = match op_head {
"REBASE_HEAD" => "rebase in progress",
"CHERRY_PICK_HEAD" => "cherry-pick in progress",
_ => "revert in progress",
};
(
"HEAD".to_string(),
op_head.to_string(),
moment,
op_head != "REVERT_HEAD",
)
} else if rev_exists(dir, "HEAD^2") {
(
"HEAD^1".to_string(),
"HEAD^2".to_string(),
"merge just committed",
false,
)
} else {
return Ok(None);
};
let base_rev = if use_replay_base {
replay_base(dir, &theirs_rev)?
} else {
git(dir, &["merge-base", &ours_rev, &theirs_rev])?
.trim()
.to_string()
};
let mut subjects: BTreeSet<String> = BTreeSet::new();
for side in [&ours_rev, &theirs_rev] {
let list = git(
dir,
&["diff", "--name-only", "--no-renames", "-z", &base_rev, side],
)?;
subjects.extend(
list.split('\0')
.filter(|p| !p.is_empty() && is_supported(p))
.map(str::to_string),
);
}
if let Ok(list) = git(
dir,
&[
"diff",
"--name-only",
"--no-renames",
"--diff-filter=U",
"-z",
],
) {
subjects.extend(
list.split('\0')
.filter(|p| !p.is_empty() && is_supported(p))
.map(str::to_string),
);
}
let subjects: Vec<String> = subjects.into_iter().collect();
let stages = [
read_paths_at_rev(dir, &base_rev, &subjects)?,
read_paths_at_rev(dir, &ours_rev, &subjects)?,
read_paths_at_rev(dir, &theirs_rev, &subjects)?,
];
let work = read_worktree(dir)?;
let scope = format!(
"working tree vs the three merge stages of {ours_rev} × {theirs_rev} \
(base {}, {moment}) — {} file(s) either side changed",
&base_rev[..base_rev.len().min(8)],
subjects.len()
);
let mut unreadable: BTreeMap<String, String> = BTreeMap::new();
let mut irregular: BTreeSet<String> = BTreeSet::new();
for stage in &stages {
for (path, why) in &stage.unreadable {
unreadable
.entry(path.clone())
.or_insert_with(|| why.clone());
}
irregular.extend(stage.irregular.iter().cloned());
}
let subjects = subjects
.into_iter()
.filter(|p| !irregular.contains(p) && !unreadable.contains_key(p))
.collect();
irregular.retain(|p| !unreadable.contains_key(p));
let [base, ours, theirs] = stages.map(|s| s.tree);
Ok(Some(MergeScope {
base,
ours,
theirs,
work,
subjects,
unreadable,
irregular,
scope,
}))
}
pub(crate) fn read_worktree(dir: &Path) -> R<Tree> {
let listing = git(dir, &["ls-files", "-z"])?;
let mut tree = Tree::new();
for rel in listing.split('\0').filter(|p| !p.is_empty()) {
if !is_supported(rel) {
continue;
}
let path = dir.join(rel);
if !std::fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_file()) {
continue;
}
if let Ok(content) = std::fs::read_to_string(path) {
tree.insert(rel.to_string(), content);
}
}
Ok(tree)
}
pub fn file_stages(dir: &Path, path: &str) -> R<(String, String, String)> {
let stage = |n: u8| git(dir, &["show", &format!(":{n}:{path}")]).ok();
if let (Some(b), Some(o), Some(t)) = (stage(1), stage(2), stage(3)) {
return Ok((b, o, t));
}
let scope = merge_scope(dir)?.ok_or(
"no merge, rebase, cherry-pick or revert in progress and HEAD is not a merge \
commit — there is no three-way context to explain this file against",
)?;
let get = |t: &Tree| t.get(path).cloned().unwrap_or_default();
if !scope.ours.contains_key(path) && !scope.theirs.contains_key(path) {
return Err(format!("`{path}` is in neither side of this merge").into());
}
Ok((get(&scope.base), get(&scope.ours), get(&scope.theirs)))
}
pub(crate) fn read_rev_tree(dir: &Path, rev: &str) -> R<Tree> {
let listing = git(
dir,
&["ls-tree", "-r", "--name-only", "-z", "--full-tree", rev],
)?;
let paths: Vec<String> = listing
.split('\0')
.filter(|p| !p.is_empty() && is_supported(p))
.map(str::to_string)
.collect();
Ok(read_paths_at_rev(dir, rev, &paths)?.tree)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::{AtomicU32, Ordering};
static FIXTURE_COUNTER: AtomicU32 = AtomicU32::new(0);
fn git_ok(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.expect("run git");
assert!(
out.status.success(),
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
String::from_utf8_lossy(&out.stdout).to_string()
}
fn git_fails(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(dir)
.status()
.expect("run git");
assert!(
!status.success(),
"git {} unexpectedly succeeded",
args.join(" ")
);
}
fn git_fixture(name: &str) -> PathBuf {
let n = FIXTURE_COUNTER.fetch_add(1, Ordering::Relaxed);
let root = std::env::temp_dir().join(format!(
"weave-gitscan-test-{}-{}-{}",
std::process::id(),
name,
n
));
fs::create_dir_all(&root).expect("create fixture dir");
git_ok(&root, &["init", "-q"]);
git_ok(&root, &["checkout", "-b", "main"]);
root
}
#[test]
fn batch_read_of_large_blobs_does_not_deadlock() {
let root = git_fixture("cat-file-large");
let deep = "a_directory_name_long_enough_to_fatten_every_request/and_one_more_level";
fs::create_dir_all(root.join(deep)).expect("mkdir");
let mut paths = Vec::new();
for i in 0..3000 {
let name = format!("{deep}/module_number_{i:05}.py");
fs::write(root.join(&name), format!("value_{i} = {i}\n").repeat(8)).expect("write");
paths.push(name);
}
paths.push("absent.py".to_string());
git_ok(&root, &["add", "."]);
git_ok(&root, &["commit", "-m", "big"]);
let (tx, rx) = std::sync::mpsc::channel();
let dir = root.clone();
let asked = paths.clone();
std::thread::spawn(move || {
let _ = tx.send(
read_paths_at_rev(&dir, "HEAD", &asked)
.map(|s| s.tree)
.map_err(|e| e.to_string()),
);
});
let tree = rx
.recv_timeout(std::time::Duration::from_secs(60))
.expect("read_paths_at_rev hung on a large batch")
.expect("read_paths_at_rev failed");
assert_eq!(
tree.len(),
3000,
"every present path read, the absent one skipped"
);
assert_eq!(
tree[&format!("{deep}/module_number_02999.py")],
"value_2999 = 2999\n".repeat(8)
);
}
#[test]
fn merge_scope_during_rebase_conflict() {
let root = git_fixture("rebase-conflict");
let base_content = "def a():\n return 1\n\ndef keep():\n return 'stable'\n";
fs::write(root.join("m.py"), base_content).expect("write base");
git_ok(&root, &["add", "m.py"]);
git_ok(&root, &["commit", "-m", "initial"]);
git_ok(&root, &["checkout", "-b", "feature"]);
let feature_content = base_content.replace("return 1", "return 2");
fs::write(root.join("m.py"), &feature_content).expect("write feature");
git_ok(&root, &["add", "m.py"]);
git_ok(&root, &["commit", "-m", "feature"]);
let replayed_commit = git_ok(&root, &["rev-parse", "HEAD"]).trim().to_string();
git_ok(&root, &["checkout", "main"]);
let main_content = base_content.replace("return 1", "return 3");
fs::write(root.join("m.py"), &main_content).expect("write main");
git_ok(&root, &["add", "m.py"]);
git_ok(&root, &["commit", "-m", "main"]);
git_ok(&root, &["checkout", "feature"]);
git_fails(&root, &["rebase", "main"]);
let scope = merge_scope(&root)
.expect("merge_scope should not error")
.expect("merge_scope should recognize mid-rebase state");
assert!(
scope.scope.contains("rebase in progress"),
"scope should name the rebase moment: {}",
scope.scope
);
let rebase_head = git_ok(&root, &["rev-parse", "REBASE_HEAD"])
.trim()
.to_string();
assert_eq!(
rebase_head, replayed_commit,
"REBASE_HEAD should be the commit being replayed"
);
let theirs_rev = git_ok(&root, &["rev-parse", "REBASE_HEAD"])
.trim()
.to_string();
assert_eq!(
scope.theirs.get("m.py").map(String::as_str),
Some(feature_content.as_str()),
"theirs stage should come from REBASE_HEAD"
);
assert!(
scope.subjects.iter().any(|p| p == "m.py"),
"subjects should include the conflicted file: {:?}",
scope.subjects
);
let head_rev = git_ok(&root, &["rev-parse", "HEAD"]).trim().to_string();
assert_ne!(head_rev, replayed_commit);
assert_eq!(
scope.ours.get("m.py").map(String::as_str),
Some(main_content.as_str()),
"ours stage should come from HEAD"
);
assert_eq!(theirs_rev, replayed_commit);
}
fn partial_clone(name: &str, files: &[(&str, &str)]) -> (PathBuf, PathBuf) {
let src = git_fixture(name);
git_ok(&src, &["config", "uploadpack.allowFilter", "true"]);
git_ok(&src, &["config", "uploadpack.allowAnySHA1InWant", "true"]);
for (path, text) in files {
if let Some(dir) = Path::new(path).parent() {
fs::create_dir_all(src.join(dir)).expect("mkdir");
}
fs::write(src.join(path), text).expect("write");
}
git_ok(&src, &["add", "."]);
git_ok(
&src,
&[
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-qm",
"c",
],
);
let clone = src.with_extension("partial.git");
let url = format!("file://{}", src.display());
let parent = clone.parent().expect("parent");
git_ok(
parent,
&[
"clone",
"-q",
"--bare",
"--filter=blob:none",
&url,
clone.to_str().expect("utf8"),
],
);
(src, clone)
}
fn within<T: Send + 'static>(secs: u64, f: impl FnOnce() -> T + Send + 'static) -> T {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(f());
});
rx.recv_timeout(std::time::Duration::from_secs(secs))
.expect("hung past its deadline")
}
#[test]
fn a_partial_clone_fetches_missing_stage_blobs_in_one_batch() {
let files: Vec<(String, String)> = (0..40)
.map(|i| {
(
format!("pkg/m{i}.py"),
format!("def f{i}():\n return {i}\n"),
)
})
.collect();
let refs: Vec<(&str, &str)> = files
.iter()
.map(|(p, t)| (p.as_str(), t.as_str()))
.collect();
let (_src, clone) = partial_clone("partial-fetch", &refs);
let paths: Vec<String> = files.iter().map(|(p, _)| p.clone()).collect();
let packs = |dir: &Path| {
fs::read_dir(dir.join("objects/pack"))
.expect("pack dir")
.filter(|e| {
e.as_ref()
.is_ok_and(|e| e.path().extension().is_some_and(|x| x == "pack"))
})
.count()
};
assert_eq!(packs(&clone), 1, "the clone's own pack: trees, no blobs");
let dir = clone.clone();
let stage = within(60, move || {
read_paths_at_rev(&dir, "HEAD", &paths).map_err(|e| e.to_string())
})
.expect("read");
assert!(stage.unreadable.is_empty(), "{:?}", stage.unreadable);
assert_eq!(stage.tree.len(), 40);
assert_eq!(stage.tree["pkg/m7.py"], "def f7():\n return 7\n");
assert_eq!(packs(&clone), 2, "all forty blobs arrived in one fetch");
}
#[test]
fn an_unfetchable_blob_is_unreadable_not_absent_and_not_a_hang() {
let (_src, clone) = partial_clone("partial-dead", &[("a.py", "def a():\n return 1\n")]);
git_ok(
&clone,
&["remote", "set-url", "origin", "/nonexistent/weave-remote"],
);
let asked = vec!["a.py".to_string(), "never_existed.py".to_string()];
let stage = within(60, move || {
read_paths_at_rev(&clone, "HEAD", &asked).map_err(|e| e.to_string())
})
.expect("a dead remote is a per-path answer, not an error");
assert!(stage.tree.is_empty());
assert!(
stage.unreadable.contains_key("a.py"),
"{:?}",
stage.unreadable
);
assert!(
!stage.unreadable.contains_key("never_existed.py"),
"absent is not unreadable"
);
}
#[cfg(unix)]
#[test]
fn a_symlink_is_irregular_not_source() {
let root = git_fixture("symlink");
fs::write(root.join("real.py"), "x = 1\n").expect("write");
std::os::unix::fs::symlink("real.py", root.join("link.py")).expect("symlink");
git_ok(&root, &["add", "."]);
git_ok(
&root,
&[
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-qm",
"c",
],
);
let stage =
read_paths_at_rev(&root, "HEAD", &["real.py".into(), "link.py".into()]).expect("read");
assert_eq!(stage.tree.keys().collect::<Vec<_>>(), vec!["real.py"]);
assert!(stage.irregular.contains("link.py"));
let work = read_worktree(&root).expect("worktree");
assert!(
!work.contains_key("link.py"),
"a symlink is never read through"
);
}
#[test]
fn a_git_that_never_answers_is_an_error_not_a_wait() {
let root = git_fixture("slow");
let started = std::time::Instant::now();
let err = within(30, move || {
bounded(
&root,
&["-c", "alias.stall=!sleep 20", "stall"],
None,
std::time::Duration::from_secs(1),
)
.map_err(|e| e.to_string())
})
.expect_err("a stalled git must not succeed");
assert!(err.contains("did not finish within 1s"), "{err}");
assert!(started.elapsed() < std::time::Duration::from_secs(10));
}
}