| 1 | //! Extraction of jj change ids from raw Git commit objects. | |
| 2 | //! | |
| 3 | //! This is the single most format-sensitive function in Dogfood. Everything in | |
| 4 | //! the change index keys off its output, so it is deliberately strict: it | |
| 5 | //! either returns an id it is certain about, or `None`. | |
| 6 | //! | |
| 7 | //! The wire format is documented, with the observations that established it, in | |
| 8 | //! `docs/change-id-format.md`. The short version: | |
| 9 | //! | |
| 10 | //! - The header key is `change-id`, lowercase and hyphenated. | |
| 11 | //! - The value is 32 characters in jj's reverse-hex alphabet (`k`..=`z`) — the | |
| 12 | //! same string the CLI prints. It is **not** hex. Do not decode it. | |
| 13 | //! - Its position among the headers is not fixed. It appears before `gpgsig` on | |
| 14 | //! signed commits and after the multi-line `jj:conflict-labels` header on | |
| 15 | //! conflicted ones. | |
| 16 | //! | |
| 17 | //! That last point is why this is a real header parser rather than a substring | |
| 18 | //! search: a `gpgsig` signature body is base64, and a continuation line inside | |
| 19 | //! it can begin with any byte sequence at all, including `change-id `. | |
| 20 | ||
| 21 | /// A jj change id, in the canonical reverse-hex letter encoding jj stores and | |
| 22 | /// displays. | |
| 23 | /// | |
| 24 | /// Stored and compared as the letter string. Constructing one guarantees it is | |
| 25 | /// well-formed, so downstream code never has to re-validate. | |
| 26 | #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] | |
| 27 | pub struct ChangeId(String); | |
| 28 | ||
| 29 | /// Length of a jj change id: 16 bytes rendered as 32 reverse-hex characters. | |
| 30 | const CHANGE_ID_LEN: usize = 32; | |
| 31 | ||
| 32 | /// jj's reverse-hex alphabet. `0123456789abcdef` maps to `zyxwvutsrqponmlk`, | |
| 33 | /// so every character of a well-formed change id falls in `k..=z`. | |
| 34 | const fn is_reverse_hex(b: u8) -> bool { | |
| 35 | b >= b'k' && b <= b'z' | |
| 36 | } | |
| 37 | ||
| 38 | impl ChangeId { | |
| 39 | /// Parse a change id from its canonical letter encoding. | |
| 40 | /// | |
| 41 | /// Returns `None` unless the input is exactly 32 characters drawn from | |
| 42 | /// jj's reverse-hex alphabet. Being strict here is what keeps malformed | |
| 43 | /// ids out of the index, where they would be indistinguishable from real | |
| 44 | /// ones forever. | |
| 45 | pub fn parse(s: &str) -> Option<Self> { | |
| 46 | let b = s.as_bytes(); | |
| 47 | if b.len() != CHANGE_ID_LEN || !b.iter().copied().all(is_reverse_hex) { | |
| 48 | return None; | |
| 49 | } | |
| 50 | Some(ChangeId(s.to_owned())) | |
| 51 | } | |
| 52 | ||
| 53 | pub fn as_str(&self) -> &str { | |
| 54 | &self.0 | |
| 55 | } | |
| 56 | ||
| 57 | /// The abbreviated form shown in the UI, matching how the CLI abbreviates. | |
| 58 | /// | |
| 59 | /// This is presentation only. The shortest *unique* prefix within a repo is | |
| 60 | /// a database question, not a property of the id, and is resolved by the | |
| 61 | /// change lookup query rather than here. | |
| 62 | pub fn short(&self) -> &str { | |
| 63 | &self.0[..12.min(self.0.len())] | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | impl std::fmt::Display for ChangeId { | |
| 68 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 69 | f.write_str(&self.0) | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | /// One header of a Git commit object, with continuation lines folded in. | |
| 74 | struct Header<'a> { | |
| 75 | key: &'a [u8], | |
| 76 | /// Raw value. For multi-line headers this is the first line only; callers | |
| 77 | /// that need the folded value use [`Headers::folded`]. Every header | |
| 78 | /// Dogfood cares about is single-line, so this avoids allocating for the | |
| 79 | /// large `gpgsig` values that make up most of a signed commit. | |
| 80 | value: &'a [u8], | |
| 81 | } | |
| 82 | ||
| 83 | /// Iterator over the header block of a raw commit object. | |
| 84 | /// | |
| 85 | /// Stops at the first empty line, which per the Git object format terminates | |
| 86 | /// the headers and begins the message. Lines beginning with a single space are | |
| 87 | /// continuations of the preceding header and are skipped rather than being | |
| 88 | /// mistaken for new headers — the hazard this parser exists to avoid. | |
| 89 | struct Headers<'a> { | |
| 90 | rest: &'a [u8], | |
| 91 | done: bool, | |
| 92 | } | |
| 93 | ||
| 94 | impl<'a> Headers<'a> { | |
| 95 | fn new(raw: &'a [u8]) -> Self { | |
| 96 | Headers { rest: raw, done: false } | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | impl<'a> Iterator for Headers<'a> { | |
| 101 | type Item = Header<'a>; | |
| 102 | ||
| 103 | fn next(&mut self) -> Option<Header<'a>> { | |
| 104 | loop { | |
| 105 | if self.done || self.rest.is_empty() { | |
| 106 | return None; | |
| 107 | } | |
| 108 | ||
| 109 | // Split off one line. | |
| 110 | let (line, tail) = match memchr(b'\n', self.rest) { | |
| 111 | Some(i) => (&self.rest[..i], &self.rest[i + 1..]), | |
| 112 | // No trailing newline: this is the last line of the object. | |
| 113 | None => (self.rest, &self.rest[self.rest.len()..]), | |
| 114 | }; | |
| 115 | self.rest = tail; | |
| 116 | ||
| 117 | // An empty line ends the header block; the message follows. | |
| 118 | if line.is_empty() { | |
| 119 | self.done = true; | |
| 120 | return None; | |
| 121 | } | |
| 122 | ||
| 123 | // A leading space marks a continuation of the previous header's | |
| 124 | // value (gpgsig, jj:conflict-labels). Skip it: it is never the | |
| 125 | // start of a new header, no matter what bytes it contains. | |
| 126 | if line[0] == b' ' { | |
| 127 | continue; | |
| 128 | } | |
| 129 | ||
| 130 | // `key SP value`. A header with no space is malformed; skip it | |
| 131 | // rather than aborting, so one odd header cannot cost us the | |
| 132 | // change id. | |
| 133 | return match memchr(b' ', line) { | |
| 134 | Some(i) => Some(Header { key: &line[..i], value: &line[i + 1..] }), | |
| 135 | None => continue, | |
| 136 | }; | |
| 137 | } | |
| 138 | } | |
| 139 | } | |
| 140 | ||
| 141 | /// Minimal byte search, to avoid pulling in `memchr` for two call sites. | |
| 142 | fn memchr(needle: u8, haystack: &[u8]) -> Option<usize> { | |
| 143 | haystack.iter().position(|&b| b == needle) | |
| 144 | } | |
| 145 | ||
| 146 | /// Extract the jj change id from a raw git commit object. | |
| 147 | /// | |
| 148 | /// Returns `None` for commits authored by plain git, which carry no such | |
| 149 | /// header, and for commits whose `change-id` value is not a well-formed id. | |
| 150 | /// Callers treat `None` as "this commit needs a synthetic identity" (spec §4). | |
| 151 | /// | |
| 152 | /// `raw_commit` is the object payload — the bytes `git cat-file commit` prints, | |
| 153 | /// without the `commit <len>\0` prefix that precedes them in the object store. | |
| 154 | pub fn extract_change_id(raw_commit: &[u8]) -> Option<ChangeId> { | |
| 155 | for h in Headers::new(raw_commit) { | |
| 156 | if h.key == b"change-id" { | |
| 157 | // Trailing `\r` would mean a mangled object; `from_utf8` on the | |
| 158 | // trimmed value rejects anything non-ASCII anyway, and `parse` | |
| 159 | // rejects anything outside the alphabet. | |
| 160 | return std::str::from_utf8(h.value).ok().and_then(ChangeId::parse); | |
| 161 | } | |
| 162 | } | |
| 163 | None | |
| 164 | } | |
| 165 | ||
| 166 | /// Conflict information carried in a jj commit's `jj:trees` header. | |
| 167 | /// | |
| 168 | /// jj stores a conflicted state as an alternating list of tree ids: | |
| 169 | /// `side_0, base_0, side_1, base_1, side_2, …` — always one more side than | |
| 170 | /// base. See `docs/change-id-format.md` §6. | |
| 171 | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 172 | pub struct ConflictTrees { | |
| 173 | pub sides: Vec<String>, | |
| 174 | pub bases: Vec<String>, | |
| 175 | } | |
| 176 | ||
| 177 | /// Extract conflict tree structure from a raw commit object. | |
| 178 | /// | |
| 179 | /// Returns `None` for unconflicted commits. Detection uses the `jj:trees` | |
| 180 | /// header rather than looking for `.jjconflict-*` entries in the tree, because | |
| 181 | /// the header is authoritative and requires no object loads. | |
| 182 | pub fn extract_conflict_trees(raw_commit: &[u8]) -> Option<ConflictTrees> { | |
| 183 | let value = Headers::new(raw_commit).find(|h| h.key == b"jj:trees")?.value; | |
| 184 | let ids: Vec<String> = std::str::from_utf8(value) | |
| 185 | .ok()? | |
| 186 | .split_ascii_whitespace() | |
| 187 | .map(str::to_owned) | |
| 188 | .collect(); | |
| 189 | ||
| 190 | // Must be a non-empty odd count: sides = bases + 1. Anything else is not a | |
| 191 | // shape we understand, and guessing at it would produce a wrong conflict | |
| 192 | // view rather than an honest failure. | |
| 193 | if ids.is_empty() || ids.len() % 2 == 0 { | |
| 194 | return None; | |
| 195 | } | |
| 196 | ||
| 197 | let mut sides = Vec::with_capacity(ids.len() / 2 + 1); | |
| 198 | let mut bases = Vec::with_capacity(ids.len() / 2); | |
| 199 | for (i, id) in ids.into_iter().enumerate() { | |
| 200 | if i % 2 == 0 { | |
| 201 | sides.push(id); | |
| 202 | } else { | |
| 203 | bases.push(id); | |
| 204 | } | |
| 205 | } | |
| 206 | Some(ConflictTrees { sides, bases }) | |
| 207 | } | |
| 208 | ||
| 209 | /// Tree entry names jj uses for conflict storage. These are implementation | |
| 210 | /// detail of the storage format and must never appear in a user-facing file | |
| 211 | /// listing. | |
| 212 | pub fn is_conflict_artifact(name: &str) -> bool { | |
| 213 | name.starts_with(".jjconflict-") || name == "JJ-CONFLICT-README" | |
| 214 | } | |
| 215 | ||
| 216 | #[cfg(test)] | |
| 217 | mod tests { | |
| 218 | use super::*; | |
| 219 | ||
| 220 | /// A jj commit exactly as observed from `git cat-file commit`. | |
| 221 | const JJ_SIMPLE: &[u8] = b"\ | |
| 222 | tree 2e81171448eb9f2ee3821e3d447aa6b2fe3ddba1 | |
| 223 | author Fixture Author <fixture@dogfood.sh> 1785482369 +0000 | |
| 224 | committer Fixture Author <fixture@dogfood.sh> 1785482369 +0000 | |
| 225 | change-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu | |
| 226 | ||
| 227 | first change | |
| 228 | "; | |
| 229 | ||
| 230 | const PLAIN_GIT: &[u8] = b"\ | |
| 231 | tree 4997ca7a42e3ad9b729fbad3acd44fbabd07b6bd | |
| 232 | author Plain Git <pg@dogfood.sh> 1785482410 +0000 | |
| 233 | committer Plain Git <pg@dogfood.sh> 1785482410 +0000 | |
| 234 | ||
| 235 | plain git commit | |
| 236 | "; | |
| 237 | ||
| 238 | const JJ_MERGE: &[u8] = b"\ | |
| 239 | tree 6349276c93aa7281b3ea997c1ed9eee4fca779ca | |
| 240 | parent 0e6a26973ecf5056359d2b82f39ad9901675bd7f | |
| 241 | parent f4017227300e3da7b8b05c461bc1bc24047707c8 | |
| 242 | author Fixture Author <fixture@dogfood.sh> 1785482416 +0000 | |
| 243 | committer Fixture Author <fixture@dogfood.sh> 1785482416 +0000 | |
| 244 | change-id vokuuvrqvyoksvpnxznztskrpwnstmqt | |
| 245 | ||
| 246 | merge of left and right | |
| 247 | "; | |
| 248 | ||
| 249 | /// Signed commit: `change-id` precedes a multi-line `gpgsig`. | |
| 250 | const JJ_SIGNED: &[u8] = b"\ | |
| 251 | tree ec67420ed747b72ce94854190b4c59deff01b9db | |
| 252 | author Fixture Author <fixture@dogfood.sh> 1785482470 +0000 | |
| 253 | committer Fixture Author <fixture@dogfood.sh> 1785482470 +0000 | |
| 254 | change-id rwsosmnkorymmtvvzwkuuzzqqmuonwnn | |
| 255 | gpgsig -----BEGIN SSH SIGNATURE----- | |
| 256 | U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgpDlDGOvAOfzKu8EtSuCO+q7OMl | |
| 257 | P4iz0WatcsQcBg98kAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5 | |
| 258 | AAAAQLPQ/lWPdf2zmi261EFYVcLDTOT1CpY8cEWRpjQXTPAkOOsVexrIzZypFgM4yTtgyU | |
| 259 | pwfjmHzZ3D9uEeK1H3Ygg= | |
| 260 | -----END SSH SIGNATURE----- | |
| 261 | ||
| 262 | signed via jj | |
| 263 | "; | |
| 264 | ||
| 265 | /// Conflicted commit: `change-id` comes AFTER a multi-line `jj:` header. | |
| 266 | const JJ_CONFLICTED: &[u8] = b"\ | |
| 267 | tree 125bc2eed773e5eacbb11544e9966dab850ed4f9 | |
| 268 | parent 79976290ff8d8ad397f41d423a469b26d225f157 | |
| 269 | parent 47129393c3d17b7031ff88faa27ffe1b8180c225 | |
| 270 | author Fixture Author <fixture@dogfood.sh> 1785497562 +0000 | |
| 271 | committer Fixture Author <fixture@dogfood.sh> 1785497562 +0000 | |
| 272 | jj:conflict-labels qtmrpxym 79976290 \"side A\" | |
| 273 | qmksvpxn 691a639f \"conflict base\" | |
| 274 | syuzyywq 47129393 \"side B\" | |
| 275 | jj:trees ada380c77b2f1923458d0b93cbfee42f297a58e9 892cad2b4d1f22ae36e2107a6a187a5dc227f965 14a356d6227b63a22f94c18817912d5e37b77c21 | |
| 276 | change-id psxoyvqkymplnoqklzrotrqopkxqkynz | |
| 277 | ||
| 278 | conflicted merge | |
| 279 | "; | |
| 280 | ||
| 281 | #[test] | |
| 282 | fn extracts_from_simple_jj_commit() { | |
| 283 | let id = extract_change_id(JJ_SIMPLE).expect("jj commit must yield an id"); | |
| 284 | assert_eq!(id.as_str(), "qstvwxmpkvovsosxxmrnxpqmuuyllsqu"); | |
| 285 | } | |
| 286 | ||
| 287 | #[test] | |
| 288 | fn plain_git_commit_has_no_change_id() { | |
| 289 | assert_eq!(extract_change_id(PLAIN_GIT), None); | |
| 290 | } | |
| 291 | ||
| 292 | #[test] | |
| 293 | fn extracts_from_merge_commit() { | |
| 294 | let id = extract_change_id(JJ_MERGE).unwrap(); | |
| 295 | assert_eq!(id.as_str(), "vokuuvrqvyoksvpnxznztskrpwnstmqt"); | |
| 296 | } | |
| 297 | ||
| 298 | #[test] | |
| 299 | fn extracts_when_followed_by_multiline_signature() { | |
| 300 | let id = extract_change_id(JJ_SIGNED).unwrap(); | |
| 301 | assert_eq!(id.as_str(), "rwsosmnkorymmtvvzwkuuzzqqmuonwnn"); | |
| 302 | } | |
| 303 | ||
| 304 | #[test] | |
| 305 | fn extracts_when_preceded_by_multiline_jj_header() { | |
| 306 | // The case a naive "first N lines" parser gets wrong. | |
| 307 | let id = extract_change_id(JJ_CONFLICTED).unwrap(); | |
| 308 | assert_eq!(id.as_str(), "psxoyvqkymplnoqklzrotrqopkxqkynz"); | |
| 309 | } | |
| 310 | ||
| 311 | #[test] | |
| 312 | fn continuation_lines_are_never_read_as_headers() { | |
| 313 | // The specific attack/corruption shape this parser exists to prevent: a | |
| 314 | // signature body whose base64 happens to start a line with | |
| 315 | // "change-id ". It must be ignored as a continuation, and the real | |
| 316 | // header below it must win. | |
| 317 | let raw = b"\ | |
| 318 | tree 0000000000000000000000000000000000000000 | |
| 319 | author A <a@b.c> 1 +0000 | |
| 320 | committer A <a@b.c> 1 +0000 | |
| 321 | gpgsig -----BEGIN SSH SIGNATURE----- | |
| 322 | change-id kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk | |
| 323 | -----END SSH SIGNATURE----- | |
| 324 | change-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu | |
| 325 | ||
| 326 | msg | |
| 327 | "; | |
| 328 | let id = extract_change_id(raw).unwrap(); | |
| 329 | assert_eq!( | |
| 330 | id.as_str(), | |
| 331 | "qstvwxmpkvovsosxxmrnxpqmuuyllsqu", | |
| 332 | "a continuation line must not be parsed as a change-id header" | |
| 333 | ); | |
| 334 | } | |
| 335 | ||
| 336 | #[test] | |
| 337 | fn body_text_after_headers_is_not_scanned() { | |
| 338 | // A commit message that mentions a change-id header must not be | |
| 339 | // mistaken for one. The header block ends at the first blank line. | |
| 340 | let raw = b"\ | |
| 341 | tree 0000000000000000000000000000000000000000 | |
| 342 | author A <a@b.c> 1 +0000 | |
| 343 | committer A <a@b.c> 1 +0000 | |
| 344 | ||
| 345 | see also: | |
| 346 | change-id kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk | |
| 347 | "; | |
| 348 | assert_eq!(extract_change_id(raw), None); | |
| 349 | } | |
| 350 | ||
| 351 | #[test] | |
| 352 | fn rejects_malformed_values() { | |
| 353 | let cases: &[(&str, &[u8])] = &[ | |
| 354 | ("too short", b"tree x\nchange-id qstvwxmp\n\nm\n"), | |
| 355 | ( | |
| 356 | "too long", | |
| 357 | b"tree x\nchange-id qstvwxmpkvovsosxxmrnxpqmuuyllsquz\n\nm\n", | |
| 358 | ), | |
| 359 | ( | |
| 360 | "hex, not reverse-hex", | |
| 361 | b"tree x\nchange-id 0123456789abcdef0123456789abcdef\n\nm\n", | |
| 362 | ), | |
| 363 | ("empty", b"tree x\nchange-id \n\nm\n"), | |
| 364 | ( | |
| 365 | "uppercase", | |
| 366 | b"tree x\nchange-id QSTVWXMPKVOVSOSXXMRNXPQMUUYLLSQU\n\nm\n", | |
| 367 | ), | |
| 368 | ]; | |
| 369 | for (name, raw) in cases { | |
| 370 | assert_eq!(extract_change_id(raw), None, "should reject: {name}"); | |
| 371 | } | |
| 372 | } | |
| 373 | ||
| 374 | #[test] | |
| 375 | fn header_key_match_is_exact() { | |
| 376 | // `jj:change-id`, `Change-Id` (Gerrit's, in the message trailer) and | |
| 377 | // similar must not be picked up. | |
| 378 | let raw = b"\ | |
| 379 | tree 0000000000000000000000000000000000000000 | |
| 380 | author A <a@b.c> 1 +0000 | |
| 381 | Change-Id qstvwxmpkvovsosxxmrnxpqmuuyllsqu | |
| 382 | jj:change-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu | |
| 383 | ||
| 384 | m | |
| 385 | "; | |
| 386 | assert_eq!(extract_change_id(raw), None); | |
| 387 | } | |
| 388 | ||
| 389 | #[test] | |
| 390 | fn tolerates_unknown_extra_headers() { | |
| 391 | let raw = b"\ | |
| 392 | tree 0000000000000000000000000000000000000000 | |
| 393 | author A <a@b.c> 1 +0000 | |
| 394 | committer A <a@b.c> 1 +0000 | |
| 395 | mergetag something | |
| 396 | encoding ISO-8859-1 | |
| 397 | some-future-jj-header whatever | |
| 398 | change-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu | |
| 399 | ||
| 400 | m | |
| 401 | "; | |
| 402 | assert_eq!( | |
| 403 | extract_change_id(raw).unwrap().as_str(), | |
| 404 | "qstvwxmpkvovsosxxmrnxpqmuuyllsqu" | |
| 405 | ); | |
| 406 | } | |
| 407 | ||
| 408 | #[test] | |
| 409 | fn handles_truncated_object_without_panicking() { | |
| 410 | // Robustness: the indexer must never panic on a malformed object. | |
| 411 | for n in 0..JJ_SIMPLE.len() { | |
| 412 | let _ = extract_change_id(&JJ_SIMPLE[..n]); | |
| 413 | } | |
| 414 | } | |
| 415 | ||
| 416 | #[test] | |
| 417 | fn root_change_id_is_all_z() { | |
| 418 | // jj renders the root change as 32 'z' (= all-zero bytes). It is a | |
| 419 | // well-formed id and must parse, so the indexer can recognise and skip | |
| 420 | // it rather than treating it as a user change. | |
| 421 | let id = ChangeId::parse("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").unwrap(); | |
| 422 | assert_eq!(id.short(), "zzzzzzzzzzzz"); | |
| 423 | } | |
| 424 | ||
| 425 | #[test] | |
| 426 | fn parses_conflict_trees() { | |
| 427 | let c = extract_conflict_trees(JJ_CONFLICTED).expect("conflicted commit"); | |
| 428 | assert_eq!( | |
| 429 | c.sides, | |
| 430 | vec![ | |
| 431 | "ada380c77b2f1923458d0b93cbfee42f297a58e9".to_string(), | |
| 432 | "14a356d6227b63a22f94c18817912d5e37b77c21".to_string(), | |
| 433 | ] | |
| 434 | ); | |
| 435 | assert_eq!( | |
| 436 | c.bases, | |
| 437 | vec!["892cad2b4d1f22ae36e2107a6a187a5dc227f965".to_string()] | |
| 438 | ); | |
| 439 | // The invariant jj's conflict representation guarantees. | |
| 440 | assert_eq!(c.sides.len(), c.bases.len() + 1); | |
| 441 | } | |
| 442 | ||
| 443 | #[test] | |
| 444 | fn unconflicted_commits_have_no_conflict_trees() { | |
| 445 | assert_eq!(extract_conflict_trees(JJ_SIMPLE), None); | |
| 446 | assert_eq!(extract_conflict_trees(PLAIN_GIT), None); | |
| 447 | assert_eq!(extract_conflict_trees(JJ_SIGNED), None); | |
| 448 | } | |
| 449 | ||
| 450 | #[test] | |
| 451 | fn rejects_even_length_tree_lists() { | |
| 452 | // sides == bases + 1 always, so an even count is a shape we do not | |
| 453 | // understand and must not guess at. | |
| 454 | let raw = b"tree x\njj:trees aaaa bbbb\nchange-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu\n\nm\n"; | |
| 455 | assert_eq!(extract_conflict_trees(raw), None); | |
| 456 | } | |
| 457 | ||
| 458 | #[test] | |
| 459 | fn filters_conflict_artifacts_from_listings() { | |
| 460 | assert!(is_conflict_artifact(".jjconflict-side-0")); | |
| 461 | assert!(is_conflict_artifact(".jjconflict-base-0")); | |
| 462 | assert!(is_conflict_artifact("JJ-CONFLICT-README")); | |
| 463 | assert!(!is_conflict_artifact("src")); | |
| 464 | assert!(!is_conflict_artifact("c.txt")); | |
| 465 | // Not a jj artifact: a user file that merely starts similarly. | |
| 466 | assert!(!is_conflict_artifact(".jjconflict")); | |
| 467 | } | |
| 468 | } |
468 lines · Rust