| 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 | |
| 16 | use std::collections::HashSet; |
| 17 | use std::path::{Path, PathBuf}; |
| 18 | use std::process::Command; |
| 19 | |
| 20 | use df_index::{extract_change_id, extract_conflict_trees}; |
| 21 | |
| 22 | fn corpus_root() -> PathBuf { |
| 23 | Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/repos") |
| 24 | } |
| 25 | |
| 26 | fn 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. |
| 32 | fn 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. |
| 49 | fn 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] |
| 65 | fn 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] |
| 77 | fn 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] |
| 90 | fn 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] |
| 106 | fn 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] |
| 137 | fn 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] |
| 163 | fn 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] |
| 186 | fn 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] |
| 224 | fn 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] |
| 240 | fn 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] |
| 256 | fn 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