fix(vcs): keep Show History working past Windows' command-line limit (#1808) (#1831)

File history passed one ^<sha>^@ exclusion per created file, and path-filtered
log passed every selected path, as git arguments. A large module blew past
Windows' ~32K command line (os error 206). Pass them through git log --stdin.

Following renames only makes sense for a file: on a directory each created file
cut the history short, leaving a single commit. Directories now use plain path
filtering.
This commit is contained in:
Jaw authored and GitHub committed 2026-11-07 00:06:25 +08:00
1 parent 9c5372c7ab
commit 632a98987f
3 files changed
+143 -16

No files matched your search

+41 -1
View File
@@ -361,14 +361,48 @@ pub(crate) fn run_cancellable(root: &Path, args: &[&str], cancel: &CancellationT
/// Cancellable capture preserving exit codes for commands with documented non-error states.
pub(crate) fn run_cancellable_raw(root: &Path, args: &[&str], cancel: &CancellationToken) -> IdeResult<Output> {
run_cancellable_raw_stdin(root, args, None, cancel)
}
/// Like `run_cancellable`, feeding `stdin` to git. Revisions and paths passed through
/// `--stdin` are not bound by the command-line length limit (Windows: ~32K characters).
pub(crate) fn run_cancellable_stdin(
root: &Path,
args: &[&str],
stdin: &[u8],
cancel: &CancellationToken,
) -> IdeResult<Output> {
let out = run_cancellable_raw_stdin(root, args, Some(stdin), cancel)?;
if out.code == Some(0) { Ok(out) } else { Err(failure(args, &out.stderr, &out.stdout, out.code)) }
}
fn run_cancellable_raw_stdin(
root: &Path,
args: &[&str],
stdin: Option<&[u8]>,
cancel: &CancellationToken,
) -> IdeResult<Output> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut cmd = command(root)?;
cmd.args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
cmd.args(args)
.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
.stdout(Stdio::piped())
.stderr(Stdio::piped());
own_group(&mut cmd);
let mut session = CommandSession::start(&mut cmd);
let mut child = cmd.spawn().map_err(spawn_error)?;
let writer = match (stdin, child.stdin.take()) {
(Some(data), Some(mut pipe)) => {
let data = data.to_vec();
Some(std::thread::spawn(move || {
// A broken pipe just means git stopped reading early; its exit status tells the story.
let _ = pipe.write_all(&data);
}))
}
_ => None,
};
let capture = |mut pipe: Box<dyn Read + Send>| {
std::thread::spawn(move || {
let mut bytes = Vec::new();
@@ -382,6 +416,9 @@ pub(crate) fn run_cancellable_raw(root: &Path, args: &[&str], cancel: &Cancellat
terminate_tree(&mut child, KILL_GRACE);
let _ = stdout.join();
let _ = stderr.join();
if let Some(w) = writer {
let _ = w.join();
}
return Err(IdeError::cancelled());
}
if let Some(status) = child.try_wait()? {
@@ -389,6 +426,9 @@ pub(crate) fn run_cancellable_raw(root: &Path, args: &[&str], cancel: &Cancellat
}
std::thread::sleep(Duration::from_millis(20));
};
if let Some(w) = writer {
let _ = w.join();
}
let join = |thread: std::thread::JoinHandle<std::io::Result<Vec<u8>>>| {
thread.join().map_err(|_| IdeError::internal("git output reader panicked"))?.map_err(IdeError::from)
};
+51 -15
View File
@@ -80,12 +80,18 @@ fn git_select(
args.push("--first-parent".into());
}
args.extend(filter.iter().cloned());
// Past this many bytes of paths the command line risks Windows' ~32K limit.
let stdin = (paths.iter().map(|p| p.len() + 12).sum::<usize>() > 8 * 1024).then(|| paths_stdin(paths));
if stdin.is_some() {
args.push("--stdin".into());
}
args.push("--end-of-options".into());
args.extend(scope.split_whitespace().map(str::to_string));
args.push("--".into());
args.extend(paths.iter().map(|p| format!(":(literal){p}")));
let output =
crate::cli::run_cancellable(r.root(), &args.iter().map(String::as_str).collect::<Vec<_>>(), cancel)?;
if stdin.is_none() {
args.push("--".into());
args.extend(paths.iter().map(|p| format!(":(literal){p}")));
}
let output = run_log(r, args, stdin.as_deref(), cancel)?;
for line in output.stdout_str().lines() {
ids.insert(ObjectId::from_hex(line.as_bytes()).map_err(gx)?);
}
@@ -93,6 +99,28 @@ fn git_select(
Ok(ids)
}
/// Stdin for `git log --stdin` that limits the walk to `paths`, however many there are.
fn paths_stdin(paths: &[String]) -> String {
let mut text = String::from("--\n");
for p in paths {
text.push_str(&format!(":(literal){p}\n"));
}
text
}
fn run_log(
r: &Repo,
args: Vec<String>,
stdin: Option<&str>,
cancel: &CancellationToken,
) -> IdeResult<crate::cli::Output> {
let argv: Vec<&str> = args.iter().map(String::as_str).collect();
match stdin {
Some(text) => crate::cli::run_cancellable_stdin(r.root(), &argv, text.as_bytes(), cancel),
None => crate::cli::run_cancellable(r.root(), &argv, cancel),
}
}
/// The commits of a file's history and the path it had in each. The commits that changed
/// the path, merges included (`--full-history --simplify-merges`); where it appears, a
/// rename from any parent continues the history under the old path from that parent.
@@ -114,21 +142,27 @@ fn file_history(
if !started.insert((revs.clone(), path.clone())) {
continue;
}
let mut args: Vec<String> =
let mut opts: Vec<String> =
["log", "--format=%x00%H", "--name-status", "-z", "--full-history", "--simplify-merges", "--no-follow"]
.map(String::from)
.into();
if first_parent {
args.push("--first-parent".into());
opts.push("--first-parent".into());
}
args.push("--end-of-options".into());
args.extend(revs);
let pathspec = ["--".to_string(), format!(":(literal){path}")];
let run = |args: &[String]| -> IdeResult<Vec<(ObjectId, Option<char>)>> {
let argv: Vec<&str> = args.iter().chain(&pathspec).map(String::as_str).collect();
Ok(parse_name_status_log(&crate::cli::run_cancellable(r.root(), &argv, cancel)?.stdout_str()))
// `excluded` can name a revision per created file, so it goes through stdin.
let run = |excluded: &[String]| -> IdeResult<Vec<(ObjectId, Option<char>)>> {
let mut args = opts.clone();
if !excluded.is_empty() {
args.push("--stdin".into());
}
args.push("--end-of-options".into());
args.extend(revs.iter().cloned());
args.extend(pathspec.iter().cloned());
let stdin = (!excluded.is_empty()).then(|| excluded.join("\n") + "\n");
Ok(parse_name_status_log(&run_log(r, args, stdin.as_deref(), cancel)?.stdout_str()))
};
let records = run(&args)?;
let records = run(&[])?;
// Where the file appears its history ends: what is behind that is another file of
// the same name (a rename continues the history below). The commits of the history
// are those not behind a creation; the statuses come from the full listing (with
@@ -137,8 +171,8 @@ fn file_history(
let kept: Option<HashSet<ObjectId>> = if created.is_empty() {
None
} else {
args.extend(created.iter().map(|id| format!("^{id}^@")));
Some(run(&args)?.into_iter().map(|(id, _)| id).collect())
let excluded: Vec<String> = created.iter().map(|id| format!("^{id}^@")).collect();
Some(run(&excluded)?.into_iter().map(|(id, _)| id).collect())
};
let mut created = Vec::new();
for (id, first_status) in records {
@@ -354,7 +388,9 @@ fn log_indexed(
let mut history_paths = HashMap::new();
let path_ids = if paths.is_empty() {
None
} else if q.follow && paths.len() == 1 {
} else if q.follow && paths.len() == 1 && !r.root().join(&paths[0]).is_dir() {
// Following renames is for a file: a directory gains files all the time, and each
// creation would cut its history short.
let (ids, at) = file_history(r, &scopes, &paths[0], q.first_parent, cancel)?;
history_paths = at;
Some(ids)
+51
View File
@@ -649,3 +649,54 @@ fn file_history_through_a_cyclic_rename() {
pairs(&[("b to a", "a.txt"), ("Modified b", "b.txt"), ("a to b", "b.txt"), ("Created a", "a.txt")])
);
}
/// One commit per file under `dir/`, made with fast-import so hundreds stay quick.
fn commit_files(repo_dir: &Path, count: usize, name: impl Fn(usize) -> String) {
let mut stream = String::new();
for i in 0..count {
let path = name(i);
stream.push_str(&format!(
"commit refs/heads/main\ncommitter T <[email protected]> {} +0000\ndata 3\nc{}\n",
1_700_000_000 + i,
i % 10
));
stream.push_str(&format!("M 100644 inline {path}\ndata 1\nx\n\n"));
}
let mut child = Command::new("git")
.current_dir(repo_dir)
.args(["fast-import", "--quiet"])
.stdin(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(stream.as_bytes()).unwrap();
assert!(child.wait().unwrap().success());
git(repo_dir, &["symbolic-ref", "HEAD", "refs/heads/main"]);
git(repo_dir, &["reset", "-q", "--hard"]);
}
#[test]
fn followed_directory_history_survives_hundreds_of_created_files() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
// Every created file adds a `^<sha>^@` exclusion: ~800 of them passed as arguments exceed
// Windows' command-line limit (os error 206).
commit_files(dir, 800, |i| format!("module/src/main/java/com/example/generated/Class{i:04}.java"));
let repo = Repo::discover(dir).unwrap().unwrap();
let log = repo.log(&LogQuery { path: Some("module".into()), follow: true, ..Default::default() }).unwrap();
assert_eq!(log.len(), 800);
}
#[test]
fn many_selected_paths_filter_history_without_a_huge_command_line() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
commit_files(dir, 600, |i| format!("module/src/main/java/com/example/generated/Class{i:04}.java"));
let repo = Repo::discover(dir).unwrap().unwrap();
let paths: Vec<String> =
(0..500).map(|i| format!("module/src/main/java/com/example/generated/Class{i:04}.java")).collect();
let paths = paths.into_iter().map(Into::into).collect();
let log = repo.log(&LogQuery { paths, text: Some("c3".into()), ..Default::default() }).unwrap();
assert_eq!(log.len(), 50);
}