| 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