| 1 | //! `GitStore` against the real fixture corpus. |
| 2 | //! |
| 3 | //! Fixtures are copied into the store's own sharded layout, so these exercise |
| 4 | //! the same code path a pushed repository takes — including `repo_path` |
| 5 | //! sharding, not just the Git plumbing. |
| 6 | //! |
| 7 | //! Skipped with a warning when the corpus is absent; `DF_REQUIRE_FIXTURES=1` |
| 8 | //! turns that into a failure so CI cannot silently skip everything. |
| 9 | |
| 10 | use std::path::{Path, PathBuf}; |
| 11 | |
| 12 | use df_store::{EntryKind, GitStore, RepoId, RepoStore, RevId, StoreError}; |
| 13 | use uuid::Uuid; |
| 14 | |
| 15 | struct Fixture { |
| 16 | _tmp: tempfile::TempDir, |
| 17 | store: GitStore, |
| 18 | id: RepoId, |
| 19 | } |
| 20 | |
| 21 | fn corpus() -> PathBuf { |
| 22 | Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/repos") |
| 23 | } |
| 24 | |
| 25 | /// Copy a fixture repository into a fresh store and return a handle to it. |
| 26 | fn load(case: &str) -> Option<Fixture> { |
| 27 | let src = corpus().join(format!("{case}.git")); |
| 28 | if !src.is_dir() { |
| 29 | if std::env::var_os("DF_REQUIRE_FIXTURES").is_some() { |
| 30 | panic!("DF_REQUIRE_FIXTURES is set but {case}.git is missing — run fixtures/gen.sh"); |
| 31 | } |
| 32 | eprintln!("warning: fixture {case}.git absent, test skipped"); |
| 33 | return None; |
| 34 | } |
| 35 | |
| 36 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 37 | let store = GitStore::new(tmp.path()); |
| 38 | let id = RepoId(Uuid::now_v7()); |
| 39 | |
| 40 | let dest = store.repo_path(id); |
| 41 | std::fs::create_dir_all(dest.parent().unwrap()).expect("shard dir"); |
| 42 | |
| 43 | let status = std::process::Command::new("cp") |
| 44 | .arg("-r") |
| 45 | .arg(&src) |
| 46 | .arg(&dest) |
| 47 | .status() |
| 48 | .expect("cp"); |
| 49 | assert!(status.success(), "copying fixture {case} failed"); |
| 50 | |
| 51 | Some(Fixture { _tmp: tmp, store, id }) |
| 52 | } |
| 53 | |
| 54 | async fn head(f: &Fixture) -> RevId { |
| 55 | f.store |
| 56 | .resolve(f.id, "main") |
| 57 | .await |
| 58 | .expect("resolving main") |
| 59 | } |
| 60 | |
| 61 | #[tokio::test] |
| 62 | async fn creates_and_deletes_a_repository() { |
| 63 | let tmp = tempfile::tempdir().unwrap(); |
| 64 | let store = GitStore::new(tmp.path()); |
| 65 | let id = RepoId(Uuid::now_v7()); |
| 66 | |
| 67 | assert!(!store.exists(id).await); |
| 68 | store.create(id, "main").await.expect("create"); |
| 69 | assert!(store.exists(id).await); |
| 70 | assert!(store.is_empty(id).await.expect("is_empty"), "a fresh repo has no refs"); |
| 71 | |
| 72 | // HEAD must point at the configured default bookmark, or a fresh clone |
| 73 | // checks out nothing. |
| 74 | let head = std::fs::read_to_string(store.repo_path(id).join("HEAD")).unwrap(); |
| 75 | assert_eq!(head.trim(), "ref: refs/heads/main"); |
| 76 | |
| 77 | store.delete(id).await.expect("delete"); |
| 78 | assert!(!store.exists(id).await); |
| 79 | } |
| 80 | |
| 81 | #[tokio::test] |
| 82 | async fn operations_on_a_missing_repo_report_no_such_repo() { |
| 83 | let tmp = tempfile::tempdir().unwrap(); |
| 84 | let store = GitStore::new(tmp.path()); |
| 85 | let id = RepoId(Uuid::now_v7()); |
| 86 | |
| 87 | assert!(matches!( |
| 88 | store.bookmarks(id).await, |
| 89 | Err(StoreError::NoSuchRepo) |
| 90 | )); |
| 91 | } |
| 92 | |
| 93 | #[tokio::test] |
| 94 | async fn lists_bookmarks() { |
| 95 | let Some(f) = load("stack") else { return }; |
| 96 | let marks = f.store.bookmarks(f.id).await.expect("bookmarks"); |
| 97 | let names: Vec<&str> = marks.iter().map(|b| b.name.as_str()).collect(); |
| 98 | assert!(names.contains(&"main"), "got {names:?}"); |
| 99 | assert!(names.contains(&"top"), "got {names:?}"); |
| 100 | } |
| 101 | |
| 102 | #[tokio::test] |
| 103 | async fn lists_a_tree_and_reads_a_blob() { |
| 104 | let Some(f) = load("basic") else { return }; |
| 105 | let rev = head(&f).await; |
| 106 | |
| 107 | let entries = f |
| 108 | .store |
| 109 | .list_tree(f.id, &rev, Path::new("")) |
| 110 | .await |
| 111 | .expect("list_tree"); |
| 112 | assert!(!entries.is_empty()); |
| 113 | |
| 114 | let file = entries |
| 115 | .iter() |
| 116 | .find(|e| e.kind == EntryKind::File) |
| 117 | .expect("at least one file"); |
| 118 | |
| 119 | let blob = f |
| 120 | .store |
| 121 | .read_blob(f.id, &rev, Path::new(&file.path)) |
| 122 | .await |
| 123 | .expect("read_blob"); |
| 124 | assert!(blob.text().is_some(), "fixture files are text"); |
| 125 | assert_eq!(blob.size, blob.content.len() as u64); |
| 126 | } |
| 127 | |
| 128 | /// `basic` is two commits, each adding one file — "add a" then "add b" — so a |
| 129 | /// correct per-entry walk must resolve them to *different* commits rather |
| 130 | /// than both landing on the tip, which is the failure mode a naive |
| 131 | /// "just report the directory's last commit for everything" implementation |
| 132 | /// would have. |
| 133 | #[tokio::test] |
| 134 | async fn last_commits_in_dir_resolves_each_entry_to_the_commit_that_touched_it() { |
| 135 | let Some(f) = load("basic") else { return }; |
| 136 | let rev = head(&f).await; |
| 137 | |
| 138 | let entries = f.store.list_tree(f.id, &rev, Path::new("")).await.unwrap(); |
| 139 | let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect(); |
| 140 | assert!(names.contains(&"a.txt".to_string())); |
| 141 | assert!(names.contains(&"b.txt".to_string())); |
| 142 | |
| 143 | let history = f |
| 144 | .store |
| 145 | .last_commits_in_dir(f.id, &rev, Path::new(""), &names) |
| 146 | .await |
| 147 | .expect("last_commits_in_dir"); |
| 148 | |
| 149 | let a = history.get("a.txt").expect("a.txt has history"); |
| 150 | let b = history.get("b.txt").expect("b.txt has history"); |
| 151 | |
| 152 | assert_eq!(a.summary(), "add a"); |
| 153 | assert_eq!(b.summary(), "add b"); |
| 154 | assert_ne!( |
| 155 | a.rev, b.rev, |
| 156 | "each file must resolve to the commit that actually touched it, not both to the tip" |
| 157 | ); |
| 158 | } |
| 159 | |
| 160 | #[tokio::test] |
| 161 | async fn last_commits_in_dir_reports_nothing_for_an_unknown_entry() { |
| 162 | let Some(f) = load("basic") else { return }; |
| 163 | let rev = head(&f).await; |
| 164 | |
| 165 | let history = f |
| 166 | .store |
| 167 | .last_commits_in_dir(f.id, &rev, Path::new(""), &["nonexistent.txt".to_string()]) |
| 168 | .await |
| 169 | .expect("last_commits_in_dir"); |
| 170 | |
| 171 | assert!(history.is_empty()); |
| 172 | } |
| 173 | |
| 174 | #[tokio::test] |
| 175 | async fn directories_sort_before_files() { |
| 176 | let Some(f) = load("hostile") else { return }; |
| 177 | let rev = f.store.resolve(f.id, "main").await.expect("resolve"); |
| 178 | let entries = f.store.list_tree(f.id, &rev, Path::new("")).await.unwrap(); |
| 179 | |
| 180 | let first_file = entries.iter().position(|e| !e.is_dir()); |
| 181 | let last_dir = entries.iter().rposition(|e| e.is_dir()); |
| 182 | if let (Some(ff), Some(ld)) = (first_file, last_dir) { |
| 183 | assert!(ld < ff, "directories must sort before files: {entries:#?}"); |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | // ─── path safety (spec §9) ─────────────────────────────────────────────────── |
| 188 | |
| 189 | #[tokio::test] |
| 190 | async fn traversal_out_of_the_repo_is_refused() { |
| 191 | let Some(f) = load("basic") else { return }; |
| 192 | let rev = head(&f).await; |
| 193 | |
| 194 | for bad in ["../../../etc/passwd", "/etc/passwd", "a/../../../etc/passwd"] { |
| 195 | let r = f.store.read_blob(f.id, &rev, Path::new(bad)).await; |
| 196 | assert!( |
| 197 | matches!(r, Err(StoreError::Path(_))), |
| 198 | "path {bad:?} must be refused, got {r:?}" |
| 199 | ); |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | #[tokio::test] |
| 204 | async fn a_symlink_escaping_the_repo_serves_its_target_text_not_the_file() { |
| 205 | // "A repository can contain a symlink to /etc/passwd and a naive blob |
| 206 | // handler will happily serve it" (spec §9). We must return the *link text*, |
| 207 | // never the contents of the file it points at. |
| 208 | let Some(f) = load("hostile") else { return }; |
| 209 | let rev = f.store.resolve(f.id, "main").await.expect("resolve"); |
| 210 | |
| 211 | let entries = f.store.list_tree(f.id, &rev, Path::new("")).await.unwrap(); |
| 212 | let links: Vec<_> = entries |
| 213 | .iter() |
| 214 | .filter(|e| e.kind == EntryKind::Symlink) |
| 215 | .collect(); |
| 216 | assert!(!links.is_empty(), "the hostile fixture must contain symlinks"); |
| 217 | |
| 218 | for link in links { |
| 219 | let blob = f |
| 220 | .store |
| 221 | .read_blob(f.id, &rev, Path::new(&link.path)) |
| 222 | .await |
| 223 | .expect("reading a symlink must succeed, returning its target text"); |
| 224 | |
| 225 | let text = String::from_utf8_lossy(&blob.content); |
| 226 | // The blob holds the *target path*, which is short. If we had followed |
| 227 | // the link we would be holding /etc/passwd or /etc/shadow. |
| 228 | assert!( |
| 229 | text == "/etc/passwd" || text == "../../../../etc/shadow", |
| 230 | "symlink {} yielded unexpected content {text:?}", |
| 231 | link.path |
| 232 | ); |
| 233 | assert!( |
| 234 | !text.contains("root:"), |
| 235 | "SECURITY: symlink was followed off-repo — served {text:?}" |
| 236 | ); |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | #[tokio::test] |
| 241 | async fn reading_a_directory_as_a_blob_is_refused() { |
| 242 | let Some(f) = load("hostile") else { return }; |
| 243 | let rev = f.store.resolve(f.id, "main").await.unwrap(); |
| 244 | let r = f.store.read_blob(f.id, &rev, Path::new("a")).await; |
| 245 | assert!(matches!(r, Err(StoreError::IsDirectory)), "got {r:?}"); |
| 246 | } |
| 247 | |
| 248 | #[tokio::test] |
| 249 | async fn a_missing_path_is_reported_as_missing() { |
| 250 | let Some(f) = load("basic") else { return }; |
| 251 | let rev = head(&f).await; |
| 252 | let r = f |
| 253 | .store |
| 254 | .read_blob(f.id, &rev, Path::new("does/not/exist.txt")) |
| 255 | .await; |
| 256 | assert!(matches!(r, Err(StoreError::NoSuchPath)), "got {r:?}"); |
| 257 | } |
| 258 | |
| 259 | // ─── jj semantics ──────────────────────────────────────────────────────────── |
| 260 | |
| 261 | #[tokio::test] |
| 262 | async fn revisions_carry_the_jj_change_id() { |
| 263 | let Some(f) = load("basic") else { return }; |
| 264 | let rev = head(&f).await; |
| 265 | let r = f.store.revision(f.id, &rev).await.expect("revision"); |
| 266 | |
| 267 | let change = r.change_id.expect("a jj commit must expose its change id"); |
| 268 | assert_eq!(change.len(), 32); |
| 269 | assert!(change.bytes().all(|b| (b'k'..=b'z').contains(&b))); |
| 270 | assert!(!r.conflicted); |
| 271 | } |
| 272 | |
| 273 | #[tokio::test] |
| 274 | async fn plain_git_revisions_have_no_change_id() { |
| 275 | let Some(f) = load("plain-git") else { return }; |
| 276 | let rev = f.store.resolve(f.id, "main").await.expect("resolve"); |
| 277 | let r = f.store.revision(f.id, &rev).await.expect("revision"); |
| 278 | assert_eq!(r.change_id, None); |
| 279 | } |
| 280 | |
| 281 | #[tokio::test] |
| 282 | async fn conflicted_revisions_expose_their_sides_and_hide_the_artifacts() { |
| 283 | let Some(f) = load("conflict") else { return }; |
| 284 | let rev = f.store.resolve(f.id, "main").await.expect("resolve"); |
| 285 | |
| 286 | let r = f.store.revision(f.id, &rev).await.expect("revision"); |
| 287 | assert!(r.conflicted, "the fixture head is a conflicted merge"); |
| 288 | assert_eq!( |
| 289 | r.conflict_sides.len(), |
| 290 | r.conflict_bases.len() + 1, |
| 291 | "sides == bases + 1" |
| 292 | ); |
| 293 | |
| 294 | // The .jjconflict-* trees are storage detail and must not surface. |
| 295 | let entries = f.store.list_tree(f.id, &rev, Path::new("")).await.unwrap(); |
| 296 | for e in &entries { |
| 297 | assert!( |
| 298 | !e.name.starts_with(".jjconflict-") && e.name != "JJ-CONFLICT-README", |
| 299 | "conflict artefact leaked into the listing: {}", |
| 300 | e.name |
| 301 | ); |
| 302 | } |
| 303 | assert!( |
| 304 | entries.iter().any(|e| e.name == "c.txt"), |
| 305 | "the real file must still be listed: {entries:#?}" |
| 306 | ); |
| 307 | } |
| 308 | |
| 309 | #[tokio::test] |
| 310 | async fn log_walks_history_and_respects_its_limit() { |
| 311 | let Some(f) = load("stack") else { return }; |
| 312 | let rev = f.store.resolve(f.id, "top").await.expect("resolve top"); |
| 313 | |
| 314 | let all = f.store.log(f.id, &rev, 100).await.expect("log"); |
| 315 | assert!(all.len() >= 5, "stack fixture has a base plus four changes"); |
| 316 | |
| 317 | let two = f.store.log(f.id, &rev, 2).await.expect("log"); |
| 318 | assert_eq!(two.len(), 2); |
| 319 | assert_eq!(two[0].rev, rev, "log starts at the requested revision"); |
| 320 | } |
| 321 | |
| 322 | #[tokio::test] |
| 323 | async fn ancestry_and_merge_base() { |
| 324 | let Some(f) = load("stack") else { return }; |
| 325 | let bottom = f.store.resolve(f.id, "main").await.unwrap(); |
| 326 | let top = f.store.resolve(f.id, "top").await.unwrap(); |
| 327 | |
| 328 | assert!(f.store.is_ancestor(f.id, &bottom, &top).await.unwrap()); |
| 329 | assert!( |
| 330 | !f.store.is_ancestor(f.id, &top, &bottom).await.unwrap(), |
| 331 | "ancestry is directional" |
| 332 | ); |
| 333 | assert!( |
| 334 | f.store.is_ancestor(f.id, &top, &top).await.unwrap(), |
| 335 | "a revision is its own ancestor" |
| 336 | ); |
| 337 | |
| 338 | let base = f.store.merge_base(f.id, &bottom, &top).await.unwrap(); |
| 339 | assert_eq!(base, Some(bottom)); |
| 340 | } |
| 341 | |
| 342 | #[tokio::test] |
| 343 | async fn diff_reports_changed_files_with_line_counts() { |
| 344 | let Some(f) = load("whitespace") else { return }; |
| 345 | let before = f.store.resolve(f.id, "main").await.unwrap(); |
| 346 | let after = f.store.resolve(f.id, "reindented").await.unwrap(); |
| 347 | |
| 348 | let d = f |
| 349 | .store |
| 350 | .diff(f.id, &before, &after, Default::default()) |
| 351 | .await |
| 352 | .expect("diff"); |
| 353 | |
| 354 | assert_eq!(d.files.len(), 1, "one file changed: {:#?}", d.files); |
| 355 | assert_eq!(d.files[0].path, "m.rs"); |
| 356 | assert!(d.total_additions > 0 && d.total_deletions > 0); |
| 357 | assert!(!d.truncated); |
| 358 | } |
| 359 | |
| 360 | /// The tree walk reports the directories it descends through as well as the |
| 361 | /// files inside them. A directory is not a changed file: it has no content, so |
| 362 | /// it reached the renderer as a hunkless "binary file" and padded the file |
| 363 | /// count of every change that touched a new subdirectory. |
| 364 | #[tokio::test] |
| 365 | async fn a_diff_lists_files_and_not_the_directories_holding_them() { |
| 366 | for case in ["basic", "stack", "renames", "mixed", "hostile"] { |
| 367 | let Some(f) = load(case) else { continue }; |
| 368 | let rev = head(&f).await; |
| 369 | let d = f |
| 370 | .store |
| 371 | .diff_from_parent(f.id, &rev, Default::default()) |
| 372 | .await |
| 373 | .expect("diff"); |
| 374 | |
| 375 | let paths: Vec<&str> = d.files.iter().map(|x| x.path.as_str()).collect(); |
| 376 | for p in &paths { |
| 377 | assert!( |
| 378 | !paths.iter().any(|q| q.starts_with(&format!("{p}/"))), |
| 379 | "{case}: {p:?} is a directory, not a changed file: {paths:#?}" |
| 380 | ); |
| 381 | } |
| 382 | } |
| 383 | } |
| 384 | |
| 385 | #[tokio::test] |
| 386 | async fn diff_of_a_revision_against_itself_is_empty() { |
| 387 | let Some(f) = load("basic") else { return }; |
| 388 | let rev = head(&f).await; |
| 389 | let d = f |
| 390 | .store |
| 391 | .diff(f.id, &rev, &rev, Default::default()) |
| 392 | .await |
| 393 | .expect("diff"); |
| 394 | assert!(d.files.is_empty()); |
| 395 | assert_eq!((d.total_additions, d.total_deletions), (0, 0)); |
| 396 | } |
| 397 | |
| 398 | #[tokio::test] |
| 399 | async fn an_unknown_revision_is_reported_not_panicked() { |
| 400 | let Some(f) = load("basic") else { return }; |
| 401 | let bogus = RevId::from_stored("0000000000000000000000000000000000000000"); |
| 402 | assert!(matches!( |
| 403 | f.store.revision(f.id, &bogus).await, |
| 404 | Err(StoreError::NoSuchRevision) |
| 405 | )); |
| 406 | assert!(matches!( |
| 407 | f.store.resolve(f.id, "no-such-bookmark").await, |
| 408 | Err(StoreError::NoSuchRevision) |
| 409 | )); |
| 410 | } |
| 411 | |
| 412 | #[tokio::test] |
| 413 | async fn revision_specs_containing_control_characters_are_refused() { |
| 414 | let Some(f) = load("basic") else { return }; |
| 415 | for bad in ["main\n", "main\0", "\x1b[31m"] { |
| 416 | assert!( |
| 417 | matches!(f.store.resolve(f.id, bad).await, Err(StoreError::NoSuchRevision)), |
| 418 | "spec {bad:?} must be refused" |
| 419 | ); |
| 420 | } |
| 421 | } |
| 422 | |
| 423 | #[tokio::test] |
| 424 | async fn size_bytes_reports_something_plausible() { |
| 425 | let Some(f) = load("basic") else { return }; |
| 426 | let n = f.store.size_bytes(f.id).await.expect("size"); |
| 427 | assert!(n > 0, "a populated repository occupies disk"); |
| 428 | } |
| 429 | |
| 430 | // ─── conflicts (spec §4, M4) ───────────────────────────────────────────────── |
| 431 | |
| 432 | /// Read a manifest value for a fixture case. |
| 433 | fn manifest(case: &str, key: &str) -> Option<String> { |
| 434 | let raw = std::fs::read_to_string(corpus().join("manifest.json")).ok()?; |
| 435 | let v: serde_json::Value = serde_json::from_str(&raw).ok()?; |
| 436 | v.get("cases")?.get(case)?.get(key)?.as_str().map(str::to_owned) |
| 437 | } |
| 438 | |
| 439 | #[tokio::test] |
| 440 | async fn a_conflicted_revision_reports_its_sides() { |
| 441 | let Some(f) = load("conflict") else { return }; |
| 442 | let Some(rev) = manifest("conflict", "conflicted_commit") else { |
| 443 | panic!("the conflict fixture must record its conflicted commit"); |
| 444 | }; |
| 445 | let rev = RevId::from_stored(rev); |
| 446 | |
| 447 | let revision = f.store.revision(f.id, &rev).await.expect("revision"); |
| 448 | assert!(revision.conflicted, "the fixture's commit is supposed to be conflicted"); |
| 449 | |
| 450 | let files = f.store.conflicts(f.id, &rev).await.expect("conflicts"); |
| 451 | assert!(!files.is_empty(), "a conflicted revision must report at least one file"); |
| 452 | |
| 453 | for file in &files { |
| 454 | // jj's representation is side/base/side…, so there is always an odd |
| 455 | // number of columns and at least one base to compare against. |
| 456 | assert!( |
| 457 | file.sides.len() >= 3, |
| 458 | "{}: expected at least side/base/side, got {}", |
| 459 | file.path, |
| 460 | file.sides.len() |
| 461 | ); |
| 462 | assert!( |
| 463 | file.sides.iter().any(|(l, _)| l.is_base()), |
| 464 | "{}: a conflict without a base cannot be read by a reviewer", |
| 465 | file.path |
| 466 | ); |
| 467 | // jj's own bookkeeping must never surface as a conflicted file. |
| 468 | assert!( |
| 469 | !file.path.contains(".jjconflict"), |
| 470 | "storage detail leaked into the conflict view: {}", |
| 471 | file.path |
| 472 | ); |
| 473 | } |
| 474 | } |
| 475 | |
| 476 | #[tokio::test] |
| 477 | async fn an_unconflicted_revision_has_no_conflicts_rather_than_an_error() { |
| 478 | let Some(f) = load("basic") else { return }; |
| 479 | let head = head(&f).await; |
| 480 | let files = f.store.conflicts(f.id, &head).await.expect("conflicts"); |
| 481 | assert!(files.is_empty()); |
| 482 | } |
| 483 | |
| 484 | // ─── word-level diffing (spec §8) ──────────────────────────────────────────── |
| 485 | |
| 486 | #[tokio::test] |
| 487 | async fn changed_lines_carry_word_level_spans() { |
| 488 | let Some(f) = load("basic") else { return }; |
| 489 | let head = head(&f).await; |
| 490 | |
| 491 | let diff = f |
| 492 | .store |
| 493 | .diff_from_parent(f.id, &head, df_store::DiffOpts::default()) |
| 494 | .await |
| 495 | .expect("diff"); |
| 496 | |
| 497 | let mut saw_span = false; |
| 498 | for file in &diff.files { |
| 499 | for hunk in &file.hunks { |
| 500 | for line in &hunk.lines { |
| 501 | // The invariant every renderer relies on: spans reconstruct the |
| 502 | // line exactly, so a view can render spans and never content. |
| 503 | let rebuilt: String = line.spans.iter().map(|s| s.text.as_str()).collect(); |
| 504 | assert_eq!( |
| 505 | rebuilt, line.content, |
| 506 | "spans must reconstruct the line exactly" |
| 507 | ); |
| 508 | |
| 509 | if matches!(line.kind, df_store::DiffLineKind::Context) { |
| 510 | assert!( |
| 511 | line.spans.iter().all(|s| !s.emphasis), |
| 512 | "a context line has nothing to emphasise" |
| 513 | ); |
| 514 | } |
| 515 | if line.spans.iter().any(|s| s.emphasis) { |
| 516 | saw_span = true; |
| 517 | } |
| 518 | } |
| 519 | } |
| 520 | } |
| 521 | |
| 522 | // Not asserted unconditionally: whether any line in the fixture is a |
| 523 | // *modification* rather than a pure add depends on the fixture. The |
| 524 | // reconstruction invariant above is the one that must always hold. |
| 525 | if !saw_span { |
| 526 | eprintln!("note: no intra-line changes in this fixture's head diff"); |
| 527 | } |
| 528 | } |
| 529 | |
| 530 | // ─── push validation (spec §4, §9) ─────────────────────────────────────────── |
| 531 | |
| 532 | /// A repository without this hook accepts anything: no ref-name allowlist and |
| 533 | /// no protected bookmarks. Git only runs `hooks/pre-receive`, and only if it is |
| 534 | /// executable — a hook that is present but not `+x` is silently skipped, which |
| 535 | /// is the same failure with a more confusing cause. |
| 536 | #[tokio::test] |
| 537 | async fn receive_validation_installs_an_executable_pre_receive_hook() { |
| 538 | let tmp = tempfile::tempdir().unwrap(); |
| 539 | let store = GitStore::new(tmp.path()); |
| 540 | let id = RepoId(Uuid::now_v7()); |
| 541 | store.create(id, "main").await.expect("create"); |
| 542 | |
| 543 | store |
| 544 | .configure_receive_validation(id, "/usr/local/bin/dogfood-hook") |
| 545 | .await |
| 546 | .expect("installing the hook"); |
| 547 | |
| 548 | let hook = store.repo_path(id).join("hooks").join("pre-receive"); |
| 549 | assert!(hook.is_file(), "git only runs hooks/pre-receive"); |
| 550 | |
| 551 | #[cfg(unix)] |
| 552 | { |
| 553 | use std::os::unix::fs::PermissionsExt; |
| 554 | let mode = std::fs::metadata(&hook).unwrap().permissions().mode(); |
| 555 | assert!( |
| 556 | mode & 0o111 != 0, |
| 557 | "a non-executable hook is skipped silently: mode {mode:o}" |
| 558 | ); |
| 559 | } |
| 560 | |
| 561 | let body = std::fs::read_to_string(&hook).unwrap(); |
| 562 | assert!( |
| 563 | body.contains("/usr/local/bin/dogfood-hook"), |
| 564 | "the hook must exec the compiled binary, not implement checks in shell: {body}" |
| 565 | ); |
| 566 | assert!( |
| 567 | body.contains(&id.0.to_string()), |
| 568 | "the binary needs the repository id and git does not pass one: {body}" |
| 569 | ); |
| 570 | } |
| 571 | |
| 572 | /// Installing twice must be a no-op, because it runs on every boot. |
| 573 | #[tokio::test] |
| 574 | async fn receive_validation_is_idempotent() { |
| 575 | let tmp = tempfile::tempdir().unwrap(); |
| 576 | let store = GitStore::new(tmp.path()); |
| 577 | let id = RepoId(Uuid::now_v7()); |
| 578 | store.create(id, "main").await.expect("create"); |
| 579 | |
| 580 | store.configure_receive_validation(id, "/hook").await.unwrap(); |
| 581 | let first = std::fs::read_to_string(store.repo_path(id).join("hooks/pre-receive")).unwrap(); |
| 582 | |
| 583 | store.configure_receive_validation(id, "/hook").await.unwrap(); |
| 584 | let second = std::fs::read_to_string(store.repo_path(id).join("hooks/pre-receive")).unwrap(); |
| 585 | |
| 586 | assert_eq!(first, second); |
| 587 | } |
| 588 | |
| 589 | // ─── in-browser editing ────────────────────────────────────────────────────── |
| 590 | |
| 591 | async fn tip(f: &Fixture) -> RevId { |
| 592 | f.store.resolve(f.id, "main").await.expect("resolving main") |
| 593 | } |
| 594 | |
| 595 | #[tokio::test] |
| 596 | async fn committing_an_edited_file_advances_the_bookmark() { |
| 597 | let Some(f) = load("basic") else { return }; |
| 598 | let before = tip(&f).await; |
| 599 | |
| 600 | let author = df_store::Signature { |
| 601 | name: "Alice".into(), |
| 602 | email: "alice@example.com".into(), |
| 603 | when: chrono::Utc::now(), |
| 604 | }; |
| 605 | |
| 606 | // Pick a file that exists, so this exercises replacement rather than |
| 607 | // creation. |
| 608 | let entries = f |
| 609 | .store |
| 610 | .list_tree(f.id, &before, Path::new("")) |
| 611 | .await |
| 612 | .expect("tree"); |
| 613 | let target = entries |
| 614 | .iter() |
| 615 | .find(|e| !e.is_dir()) |
| 616 | .expect("the fixture has a file") |
| 617 | .path |
| 618 | .clone(); |
| 619 | |
| 620 | let outcome = f |
| 621 | .store |
| 622 | .commit_file( |
| 623 | f.id, |
| 624 | "main", |
| 625 | &before, |
| 626 | &target, |
| 627 | b"edited by the web editor\n".to_vec(), |
| 628 | "edit: change a file", |
| 629 | &author, |
| 630 | ) |
| 631 | .await |
| 632 | .expect("commit_file"); |
| 633 | |
| 634 | let rev = match outcome { |
| 635 | df_store::EditOutcome::Committed { rev } => rev, |
| 636 | other => panic!("expected a commit, got {other:?}"), |
| 637 | }; |
| 638 | |
| 639 | assert_ne!(rev, before, "a new commit must be written"); |
| 640 | assert_eq!(tip(&f).await, rev, "the bookmark must point at it"); |
| 641 | |
| 642 | // The content is what we wrote… |
| 643 | let blob = f |
| 644 | .store |
| 645 | .read_blob(f.id, &rev, Path::new(&target)) |
| 646 | .await |
| 647 | .expect("read back"); |
| 648 | assert_eq!(blob.text(), Some("edited by the web editor\n")); |
| 649 | |
| 650 | // …and the parent is the old tip, so this is a fast-forward and the |
| 651 | // protected-bookmark rule is satisfied by construction. |
| 652 | let revision = f.store.revision(f.id, &rev).await.expect("revision"); |
| 653 | assert_eq!(revision.parents, vec![before]); |
| 654 | |
| 655 | // A web edit is a plain-git commit: no invented jj change id (spec §4). |
| 656 | assert_eq!(revision.change_id, None); |
| 657 | } |
| 658 | |
| 659 | #[tokio::test] |
| 660 | async fn committing_an_unchanged_file_writes_nothing() { |
| 661 | let Some(f) = load("basic") else { return }; |
| 662 | let before = tip(&f).await; |
| 663 | |
| 664 | let entries = f.store.list_tree(f.id, &before, Path::new("")).await.unwrap(); |
| 665 | let target = entries.iter().find(|e| !e.is_dir()).unwrap().path.clone(); |
| 666 | let existing = f |
| 667 | .store |
| 668 | .read_blob(f.id, &before, Path::new(&target)) |
| 669 | .await |
| 670 | .unwrap() |
| 671 | .content; |
| 672 | |
| 673 | let author = df_store::Signature { |
| 674 | name: "A".into(), |
| 675 | email: "a@b.c".into(), |
| 676 | when: chrono::Utc::now(), |
| 677 | }; |
| 678 | |
| 679 | let outcome = f |
| 680 | .store |
| 681 | .commit_file(f.id, "main", &before, &target, existing, "noop", &author) |
| 682 | .await |
| 683 | .expect("commit_file"); |
| 684 | |
| 685 | assert_eq!(outcome, df_store::EditOutcome::Unchanged); |
| 686 | assert_eq!(tip(&f).await, before, "the bookmark must not move"); |
| 687 | } |
| 688 | |
| 689 | /// The concurrent-edit case. Without this the second writer silently discards |
| 690 | /// the first one's commit. |
| 691 | #[tokio::test] |
| 692 | async fn an_edit_against_a_stale_revision_is_refused() { |
| 693 | let Some(f) = load("basic") else { return }; |
| 694 | let original = tip(&f).await; |
| 695 | |
| 696 | let author = df_store::Signature { |
| 697 | name: "A".into(), |
| 698 | email: "a@b.c".into(), |
| 699 | when: chrono::Utc::now(), |
| 700 | }; |
| 701 | |
| 702 | // Somebody else lands a commit first. |
| 703 | f.store |
| 704 | .commit_file( |
| 705 | f.id, |
| 706 | "main", |
| 707 | &original, |
| 708 | "shared.txt", |
| 709 | b"first writer\n".to_vec(), |
| 710 | "first", |
| 711 | &author, |
| 712 | ) |
| 713 | .await |
| 714 | .expect("first edit"); |
| 715 | |
| 716 | let moved = tip(&f).await; |
| 717 | assert_ne!(moved, original); |
| 718 | |
| 719 | // The second writer composed their edit against the original revision. |
| 720 | let outcome = f |
| 721 | .store |
| 722 | .commit_file( |
| 723 | f.id, |
| 724 | "main", |
| 725 | &original, |
| 726 | "shared.txt", |
| 727 | b"second writer\n".to_vec(), |
| 728 | "second", |
| 729 | &author, |
| 730 | ) |
| 731 | .await |
| 732 | .expect("commit_file"); |
| 733 | |
| 734 | match outcome { |
| 735 | df_store::EditOutcome::Stale { current } => assert_eq!(current, moved), |
| 736 | other => panic!("a stale edit must be refused, got {other:?}"), |
| 737 | } |
| 738 | |
| 739 | assert_eq!(tip(&f).await, moved, "nothing may be written"); |
| 740 | assert_eq!( |
| 741 | f.store |
| 742 | .read_blob(f.id, &moved, Path::new("shared.txt")) |
| 743 | .await |
| 744 | .unwrap() |
| 745 | .text(), |
| 746 | Some("first writer\n"), |
| 747 | "the first writer's content must survive" |
| 748 | ); |
| 749 | } |
| 750 | |
| 751 | #[tokio::test] |
| 752 | async fn the_editor_write_path_refuses_traversal_and_oversized_content() { |
| 753 | let Some(f) = load("basic") else { return }; |
| 754 | let before = tip(&f).await; |
| 755 | let author = df_store::Signature { |
| 756 | name: "A".into(), |
| 757 | email: "a@b.c".into(), |
| 758 | when: chrono::Utc::now(), |
| 759 | }; |
| 760 | |
| 761 | for path in ["../escape.txt", "/etc/passwd", "a/../../b.txt", ""] { |
| 762 | let e = f |
| 763 | .store |
| 764 | .commit_file(f.id, "main", &before, path, b"x".to_vec(), "m", &author) |
| 765 | .await; |
| 766 | assert!(e.is_err(), "{path} was accepted by the write path"); |
| 767 | } |
| 768 | |
| 769 | let huge = vec![b'a'; 2 * 1024 * 1024]; |
| 770 | assert!(matches!( |
| 771 | f.store |
| 772 | .commit_file(f.id, "main", &before, "big.txt", huge, "m", &author) |
| 773 | .await, |
| 774 | Err(StoreError::TooLarge { .. }) |
| 775 | )); |
| 776 | |
| 777 | assert_eq!(tip(&f).await, before, "no refused edit may move the bookmark"); |
| 778 | } |
778 lines · Rust