Jump to…
snowinitial commitqoxwzsukwmkx1mo
1//! Integration tests against the real fixture corpus.
2//!
3//! The unit tests in `change_id.rs` run against byte literals transcribed from
4//! real commits, which proves the parser handles those shapes but not that the
5//! shapes are still what `jj` produces. These tests read the actual bare
6//! repositories built by `fixtures/gen.sh`, so a jj format change fails here.
7//!
8//! Objects are read via `git cat-file`, not a Git library, because `df-index`
9//! must not depend on one (spec §3 rule 1).
10//!
11//! Skipped with a warning if the corpus has not been generated, so a plain
12//! `cargo test` on a fresh checkout does not fail confusingly. CI runs
13//! `fixtures/gen.sh` first, and `corpus_is_present` fails when `DF_REQUIRE_FIXTURES`
14//! is set so a CI misconfiguration cannot silently skip all of this.
15
16use std::collections::HashSet;
17use std::path::{Path, PathBuf};
18use std::process::Command;
19
20use df_index::{extract_change_id, extract_conflict_trees};
21
22fn corpus_root() -> PathBuf {
23 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/repos")
24}
25
26fn repo(name: &str) -> Option<PathBuf> {
27 let p = corpus_root().join(format!("{name}.git"));
28 p.is_dir().then_some(p)
29}
30
31/// Run a git command against a bare repo, returning stdout as raw bytes.
32fn git(repo: &Path, args: &[&str]) -> Vec<u8> {
33 let out = Command::new("git")
34 .arg("--git-dir")
35 .arg(repo)
36 .args(args)
37 .output()
38 .expect("git must be on PATH to run corpus tests");
39 assert!(
40 out.status.success(),
41 "git {args:?} failed in {}: {}",
42 repo.display(),
43 String::from_utf8_lossy(&out.stderr)
44 );
45 out.stdout
46}
47
48/// Every commit object in the repo, as raw bytes.
49fn all_commits(repo: &Path) -> Vec<(String, Vec<u8>)> {
50 let listing = git(repo, &["cat-file", "--batch-all-objects", "--batch-check=%(objectname) %(objecttype)"]);
51 String::from_utf8_lossy(&listing)
52 .lines()
53 .filter_map(|l| {
54 let (oid, kind) = l.split_once(' ')?;
55 (kind == "commit").then(|| oid.to_owned())
56 })
57 .map(|oid| {
58 let raw = git(repo, &["cat-file", "commit", &oid]);
59 (oid, raw)
60 })
61 .collect()
62}
63
64#[test]
65fn corpus_is_present() {
66 if repo("basic").is_none() {
67 let required = std::env::var_os("DF_REQUIRE_FIXTURES").is_some();
68 assert!(
69 !required,
70 "DF_REQUIRE_FIXTURES is set but the corpus is missing — run fixtures/gen.sh"
71 );
72 eprintln!("warning: fixture corpus absent, corpus tests skipped; run fixtures/gen.sh");
73 }
74}
75
76#[test]
77fn every_jj_commit_yields_a_change_id() {
78 let Some(r) = repo("basic") else { return };
79 let commits = all_commits(&r);
80 assert!(!commits.is_empty(), "basic.git must contain commits");
81 for (oid, raw) in commits {
82 assert!(
83 extract_change_id(&raw).is_some(),
84 "jj-authored commit {oid} in basic.git yielded no change id"
85 );
86 }
87}
88
89#[test]
90fn no_plain_git_commit_yields_a_change_id() {
91 let Some(r) = repo("plain-git") else { return };
92 let commits = all_commits(&r);
93 assert!(!commits.is_empty(), "plain-git.git must contain commits");
94 for (oid, raw) in commits {
95 assert_eq!(
96 extract_change_id(&raw),
97 None,
98 "plain-git commit {oid} must not yield a change id"
99 );
100 }
101}
102
103/// The property the entire product rests on (spec §4): rewriting a change
104/// produces a new commit but preserves the change id.
105#[test]
106fn change_id_is_stable_across_five_rewrites() {
107 let Some(r) = repo("rewritten") else { return };
108 let commits = all_commits(&r);
109
110 assert_eq!(
111 commits.len(),
112 5,
113 "rewritten.git should retain all five revisions as objects"
114 );
115
116 let ids: HashSet<String> = commits
117 .iter()
118 .map(|(oid, raw)| {
119 extract_change_id(raw)
120 .unwrap_or_else(|| panic!("commit {oid} has no change id"))
121 .as_str()
122 .to_owned()
123 })
124 .collect();
125
126 assert_eq!(
127 ids.len(),
128 1,
129 "all five revisions must share one change id, got {ids:?}"
130 );
131
132 let oids: HashSet<&String> = commits.iter().map(|(o, _)| o).collect();
133 assert_eq!(oids.len(), 5, "the five revisions must be distinct commits");
134}
135
136#[test]
137fn merge_commits_carry_a_change_id() {
138 let Some(r) = repo("merge") else { return };
139 let merges: Vec<_> = all_commits(&r)
140 .into_iter()
141 .filter(|(_, raw)| {
142 // Count `parent ` headers in the header block.
143 raw.split(|&b| b == b'\n')
144 .take_while(|l| !l.is_empty())
145 .filter(|l| l.starts_with(b"parent "))
146 .count()
147 > 1
148 })
149 .collect();
150
151 assert!(!merges.is_empty(), "merge.git must contain a merge commit");
152 for (oid, raw) in merges {
153 assert!(
154 extract_change_id(&raw).is_some(),
155 "merge commit {oid} yielded no change id"
156 );
157 }
158}
159
160/// The parser hazard: `change-id` sits next to a multi-line `gpgsig` whose
161/// base64 body could otherwise be misread as headers.
162#[test]
163fn signed_commits_yield_a_change_id() {
164 let Some(r) = repo("signed") else { return };
165 let signed: Vec<_> = all_commits(&r)
166 .into_iter()
167 .filter(|(_, raw)| {
168 raw.split(|&b| b == b'\n')
169 .take_while(|l| !l.is_empty())
170 .any(|l| l.starts_with(b"gpgsig "))
171 })
172 .collect();
173
174 assert!(!signed.is_empty(), "signed.git must contain a signed commit");
175 for (oid, raw) in signed {
176 assert!(
177 extract_change_id(&raw).is_some(),
178 "signed commit {oid} yielded no change id"
179 );
180 }
181}
182
183/// Conflicted commits put `change-id` *after* the multi-line
184/// `jj:conflict-labels` header — the layout a naive parser gets wrong.
185#[test]
186fn conflicted_commits_yield_both_change_id_and_conflict_trees() {
187 let Some(r) = repo("conflict") else { return };
188 let conflicted: Vec<_> = all_commits(&r)
189 .into_iter()
190 .filter(|(_, raw)| extract_conflict_trees(raw).is_some())
191 .collect();
192
193 assert!(
194 !conflicted.is_empty(),
195 "conflict.git must contain a conflicted commit"
196 );
197
198 for (oid, raw) in conflicted {
199 assert!(
200 extract_change_id(&raw).is_some(),
201 "conflicted commit {oid} yielded no change id despite preceding jj: headers"
202 );
203 let c = extract_conflict_trees(&raw).unwrap();
204 assert_eq!(
205 c.sides.len(),
206 c.bases.len() + 1,
207 "conflict {oid} violates the sides == bases + 1 invariant"
208 );
209 // Every referenced tree must actually exist in the repo.
210 for t in c.sides.iter().chain(c.bases.iter()) {
211 let kind = git(&r, &["cat-file", "-t", t]);
212 assert_eq!(
213 String::from_utf8_lossy(&kind).trim(),
214 "tree",
215 "conflict side/base {t} is not a tree"
216 );
217 }
218 }
219}
220
221/// Unconflicted commits must not be misdetected as conflicted — otherwise every
222/// change in the product renders a conflict banner.
223#[test]
224fn unconflicted_repos_report_no_conflicts() {
225 for name in ["basic", "stack", "merge", "signed", "plain-git"] {
226 let Some(r) = repo(name) else { continue };
227 for (oid, raw) in all_commits(&r) {
228 assert_eq!(
229 extract_conflict_trees(&raw),
230 None,
231 "commit {oid} in {name}.git falsely reports a conflict"
232 );
233 }
234 }
235}
236
237/// A repo migrated to jj: the indexer must produce real ids for the jj commits
238/// and fall back to synthetic identity for the plain-git ones, in one history.
239#[test]
240fn mixed_history_splits_into_real_and_synthetic() {
241 let Some(r) = repo("mixed") else { return };
242 let commits = all_commits(&r);
243 let with = commits.iter().filter(|(_, c)| extract_change_id(c).is_some()).count();
244 let without = commits.len() - with;
245
246 assert!(with > 0, "mixed.git must contain at least one jj commit");
247 assert!(
248 without > 0,
249 "mixed.git must contain at least one plain-git commit"
250 );
251}
252
253/// Change ids read from the corpus must all be well-formed under our own
254/// validation — this is what catches an alphabet or width change in jj.
255#[test]
256fn all_corpus_change_ids_are_well_formed() {
257 let mut seen = 0usize;
258 for name in ["basic", "rewritten", "stack", "merge", "conflict", "signed", "mixed"] {
259 let Some(r) = repo(name) else { continue };
260 for (_, raw) in all_commits(&r) {
261 if let Some(id) = extract_change_id(&raw) {
262 let s = id.as_str();
263 assert_eq!(s.len(), 32, "change id {s} has unexpected width");
264 assert!(
265 s.bytes().all(|b| (b'k'..=b'z').contains(&b)),
266 "change id {s} leaves the reverse-hex alphabet"
267 );
268 seen += 1;
269 }
270 }
271 }
272 assert!(seen > 0, "corpus yielded no change ids at all");
273}

273 lines · Rust