Files
openidea/crates/ide-vcs/tests/log_shape.rs
Shohruh Ismoilovandshohruh-genius 9f2e6b570f feat(vcs): correct Git log graph projection and expand history views (#1076)
Git Log could lose commits when filtering by several paths, draw wrong
ancestry after filtering or collapsing rows, and open the wrong
repository in multi-root views. The graph is now projected over the full
commit DAG and laid out in Bek order, so filtered and collapsed views keep
the real ancestry; each log view keeps its own filters and requests.

Commit metadata, messages and path changes are kept in a persistent index
(by object id, in the IDE's cache directory) with seekable paging, and the
log gains parent-aware merge comparisons, a lazy revision browser,
read-only logs of repositories outside the project (opened with command
execution disabled), commit bookmarks, configurable built-in columns and
batched commit details. The adapted IntelliJ algorithms are attributed in
THIRD_PARTY_LICENSES.md.

Closes #872

Co-authored-by: shohruh-genius <7658451+shohruh-genius@users.noreply.github.com>
2026-11-05 02:31:32 +08:00

237 lines
9.9 KiB
Rust

//! Git Log graph options (order, first parent, no merges, date range) and the Git menu
//! dialogs' operations (merge / rebase with options, remotes, worktrees).
use ide_vcs::*;
use std::path::Path;
use std::process::Command;
fn git(dir: &Path, args: &[&str], time: Option<i64>) -> String {
let mut cmd = Command::new("git");
cmd.current_dir(dir)
.args(["-c", "init.defaultBranch=main", "-c", "user.name=Test", "-c", "user.email=t@example.com"])
.args(["-c", "commit.gpgsign=false", "-c", "core.hooksPath=.git/hooks"])
.args(args)
.env("GIT_TERMINAL_PROMPT", "0");
if let Some(t) = time {
let date = format!("@{t} +0000");
cmd.env("GIT_AUTHOR_DATE", &date).env("GIT_COMMITTER_DATE", &date);
}
let out = cmd.output().expect("git runs");
assert!(out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn commit(dir: &Path, file: &str, msg: &str, time: i64) -> String {
std::fs::write(dir.join(file), msg).unwrap();
git(dir, &["add", "-A"], None);
git(dir, &["commit", "-q", "-m", msg], Some(time));
git(dir, &["rev-parse", "HEAD"], None)
}
struct History {
_tmp: tempfile::TempDir,
repo: Repo,
c1: String,
s1: String,
c2: String,
m: String,
c3: String,
}
/// c1 ← s1 (side) ; c1 ← c2 ← m (merges s1) ← c3, committed at 1000…5000 s.
fn history() -> History {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
git(&dir, &["init", "-q"], None);
let c1 = commit(&dir, "a.txt", "c1", 1000);
git(&dir, &["checkout", "-q", "-b", "side"], None);
let s1 = commit(&dir, "s.txt", "s1", 2000);
git(&dir, &["checkout", "-q", "main"], None);
let c2 = commit(&dir, "b.txt", "c2", 3000);
git(&dir, &["merge", "-q", "--no-ff", "-m", "merge side", "side"], Some(4000));
let m = git(&dir, &["rev-parse", "HEAD"], None);
let c3 = commit(&dir, "c.txt", "c3", 5000);
let repo = Repo::discover(&dir).unwrap().expect("repository");
History { _tmp: tmp, repo, c1, s1, c2, m, c3 }
}
fn log(h: &History, q: LogQuery) -> Vec<Commit> {
h.repo.log(&LogQuery { branches: vec!["main".into()], ..q }).unwrap()
}
fn ids(commits: &[Commit]) -> Vec<String> {
commits.iter().map(|c| c.id.clone()).collect()
}
fn parents_of(commits: &[Commit], id: &str) -> Vec<String> {
commits.iter().find(|c| c.id == id).unwrap().graph_parents.clone().unwrap()
}
#[test]
fn date_order_follows_commit_time_and_reports_it() {
let h = history();
let out = log(&h, LogQuery { order: LogOrder::Date, ..Default::default() });
assert_eq!(ids(&out), vec![h.c3.clone(), h.m.clone(), h.c2.clone(), h.s1.clone(), h.c1.clone()]);
assert_eq!(out[0].commit_time_ms, 5_000_000.0);
}
#[test]
fn incoming_order_groups_the_merged_branch_before_the_main_parent() {
let h = history();
let out = log(&h, LogQuery { order: LogOrder::Standard, ..Default::default() });
assert_eq!(ids(&out), vec![h.c3.clone(), h.m.clone(), h.s1.clone(), h.c2.clone(), h.c1.clone()]);
assert_eq!(out[1].parents, vec![h.c2.clone(), h.s1.clone()]);
}
#[test]
fn no_merges_links_children_to_the_merge_parents() {
let h = history();
let out = log(&h, LogQuery { no_merges: true, ..Default::default() });
assert!(!ids(&out).contains(&h.m));
assert_eq!(out.len(), 4);
assert_eq!(parents_of(&out, &h.c3), vec![h.c2.clone(), h.s1.clone()]);
}
#[test]
fn first_parent_skips_merged_branches() {
let h = history();
let out = log(&h, LogQuery { first_parent: true, ..Default::default() });
assert_eq!(ids(&out), vec![h.c3.clone(), h.m.clone(), h.c2.clone(), h.c1.clone()]);
assert_eq!(parents_of(&out, &h.m), vec![h.c2.clone()]);
assert_eq!(out.iter().find(|c| c.id == h.m).unwrap().parents, vec![h.c2.clone(), h.s1.clone()]);
}
#[test]
fn date_range_keeps_the_graph_connected() {
let h = history();
let since = log(&h, LogQuery { since_ms: Some(2_500_000.0), ..Default::default() });
assert_eq!(ids(&since), vec![h.c3.clone(), h.m.clone(), h.c2.clone()]);
// Older parents are not shown, so they are dropped.
assert_eq!(parents_of(&since, &h.m), vec![h.c2.clone()]);
assert!(parents_of(&since, &h.c2).is_empty());
let window = log(&h, LogQuery { since_ms: Some(1_500_000.0), until_ms: Some(4_500_000.0), ..Default::default() });
let mut got = ids(&window);
got.sort();
let mut want = vec![h.m.clone(), h.c2.clone(), h.s1.clone()];
want.sort();
assert_eq!(got, want);
// A commit newer than `until` is skipped and its child links past it.
let until = log(&h, LogQuery { until_ms: Some(3_500_000.0), order: LogOrder::Date, ..Default::default() });
assert_eq!(ids(&until), vec![h.c2.clone(), h.s1.clone(), h.c1.clone()]);
let combined = log(&h, LogQuery { until_ms: Some(4_500_000.0), no_merges: true, ..Default::default() });
assert!(!ids(&combined).contains(&h.m) && !ids(&combined).contains(&h.c3));
}
#[test]
fn merge_with_options_and_rebase_onto() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"], None);
// Repo operations spawn their own Git commands, so command-local `-c`
// options in the helper do not supply their committer identity.
git(dir, &["config", "user.name", "Test"], None);
git(dir, &["config", "user.email", "t@example.com"], None);
git(dir, &["config", "commit.gpgsign", "false"], None);
let base = commit(dir, "a.txt", "base", 1000);
git(dir, &["checkout", "-q", "-b", "feature"], None);
let f1 = commit(dir, "f.txt", "f1", 2000);
git(dir, &["checkout", "-q", "main"], None);
let repo = Repo::discover(dir).unwrap().unwrap();
let ff_only = MergeOptions { ff_only: true, squash: true, ..Default::default() };
assert!(repo.merge_with(&["feature".into()], &ff_only).is_err());
let no_ff = MergeOptions { no_ff: true, message: Some("Merge feature now".into()), ..Default::default() };
repo.merge_with(&["feature".into()], &no_ff).unwrap();
assert_eq!(git(dir, &["log", "-1", "--format=%P %s"], None), format!("{base} {f1} Merge feature now"));
git(dir, &["checkout", "-q", "-b", "topic", &base], None);
commit(dir, "t.txt", "topic", 3000);
repo.rebase_with("main", &RebaseOptions::default()).unwrap();
assert_eq!(git(dir, &["rev-list", "--count", "main..topic"], None), "1");
assert_eq!(git(dir, &["merge-base", "main", "topic"], None), git(dir, &["rev-parse", "main"], None));
}
#[test]
fn remotes_and_worktrees_are_managed() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("work");
std::fs::create_dir(&dir).unwrap();
git(&dir, &["init", "-q"], None);
commit(&dir, "a.txt", "base", 1000);
let repo = Repo::discover(&dir).unwrap().unwrap();
repo.edit_remote(&RemoteEdit::Add { name: "origin".into(), url: "https://example.com/a.git".into() }).unwrap();
repo.edit_remote(&RemoteEdit::Edit {
name: "origin".into(),
new_name: "upstream".into(),
url: "https://example.com/b.git".into(),
})
.unwrap();
assert_eq!(
repo.remotes().unwrap(),
vec![Remote { name: "upstream".into(), url: "https://example.com/b.git".into() }]
);
assert!(repo.edit_remote(&RemoteEdit::Add { name: "--bad".into(), url: "x".into() }).is_err());
repo.edit_remote(&RemoteEdit::Remove { name: "upstream".into() }).unwrap();
assert!(repo.remotes().unwrap().is_empty());
let wt = tmp.path().join("work-fix");
repo.add_worktree(&wt, "main", Some("fix")).unwrap();
let list = repo.worktrees().unwrap();
assert_eq!(list.len(), 2);
assert!(list[0].main && list[0].branch.as_deref() == Some("main"));
assert_eq!(list[1].branch.as_deref(), Some("fix"));
assert!(wt.join("a.txt").exists());
repo.remove_worktree(&wt, false).unwrap();
assert_eq!(repo.worktrees().unwrap().len(), 1);
}
#[test]
fn a_user_filter_links_shown_commits_past_hidden_ones() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"], None);
let by = |who: &str, file: &str, msg: &str, time: i64| {
std::fs::write(dir.join(file), msg).unwrap();
git(dir, &["add", "-A"], None);
let email = format!("user.email={who}@example.com");
git(dir, &["-c", &email, "commit", "-q", "-m", msg], Some(time));
git(dir, &["rev-parse", "HEAD"], None)
};
let m1 = by("me", "a", "m1", 1_000);
let o1 = by("other", "b", "o1", 2_000);
let m2 = by("me", "c", "m2", 3_000);
let m3 = by("me", "d", "m3", 4_000);
let _o2 = by("other", "e", "o2", 5_000);
let repo = Repo::discover(dir).unwrap().unwrap();
let log = repo.log(&LogQuery { author: Some("me@".into()), ..Default::default() }).unwrap();
let links: Vec<(&str, Vec<&str>, Vec<bool>)> = log
.iter()
.map(|c| {
(
c.summary.as_str(),
c.graph_parents.as_ref().unwrap().iter().map(String::as_str).collect(),
if c.graph_dashed.iter().any(|d| *d) { c.graph_dashed.clone() } else { Vec::new() },
)
})
.collect();
assert_eq!(
links,
[("m3", vec![m2.as_str()], vec![]), ("m2", vec![m1.as_str()], vec![true]), ("m1", vec![], vec![]),],
"m2's parent o1 is hidden: m2 links to m1, dashed"
);
assert!(!log.iter().any(|c| c.graph_parents.as_ref().unwrap().contains(&o1)));
assert_eq!(log[1].parents, vec![o1]);
// A page ends at m2: walking on still links it to m1.
let page = repo.log(&LogQuery { author: Some("me@".into()), limit: 2, ..Default::default() }).unwrap();
assert_eq!(page.len(), 2);
assert_eq!(page[1].graph_parents.as_deref().unwrap(), std::slice::from_ref(&m1));
let next = repo.log(&LogQuery { author: Some("me@".into()), skip: 2, limit: 2, ..Default::default() }).unwrap();
assert_eq!(next.iter().map(|c| c.id.clone()).collect::<Vec<_>>(), std::slice::from_ref(&m1));
let _ = m3;
}