diff --git a/crates/ide-vcs/src/cli.rs b/crates/ide-vcs/src/cli.rs index 0f865cea7..3a73f2704 100644 --- a/crates/ide-vcs/src/cli.rs +++ b/crates/ide-vcs/src/cli.rs @@ -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 { + 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 { + 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 { 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| { 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>>| { thread.join().map_err(|_| IdeError::internal("git output reader panicked"))?.map_err(IdeError::from) }; diff --git a/crates/ide-vcs/src/history.rs b/crates/ide-vcs/src/history.rs index 37990aeae..73bc78085 100644 --- a/crates/ide-vcs/src/history.rs +++ b/crates/ide-vcs/src/history.rs @@ -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::() > 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::>(), 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, + stdin: Option<&str>, + cancel: &CancellationToken, +) -> IdeResult { + 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 = + let mut opts: Vec = ["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)>> { - 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)>> { + 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> = 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 = 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) diff --git a/crates/ide-vcs/tests/log_projection.rs b/crates/ide-vcs/tests/log_projection.rs index 9015f793e..822d89569 100644 --- a/crates/ide-vcs/tests/log_projection.rs +++ b/crates/ide-vcs/tests/log_projection.rs @@ -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 {} +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 `^^@` 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 = + (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); +}