OXYGENHUB
# c8d9059e4e70 oxygen / crates / oxy-core / src / repo / tests.rs
383.5 KiB · Rust · last changed Pushing a new bookmark sends only what the remote lacks (closes #142) 10 days ago source blame history raw
1
//! Tests for the repository layer.
2
3
use super::*;
4
use crate::protocol::SignedPolicy;
5
use crate::repo::{BisectStep, Selection};
6
7
/// Point `$OXY_HOME` at a throwaway directory once per test process so no
8
/// test reads or writes the developer's real user configuration or keys.
9
fn hermetic() {
10
    static ONCE: std::sync::Once = std::sync::Once::new();
11
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
12
    ONCE.call_once(|| {
13
        let d = tempfile::tempdir().unwrap();
14
        std::env::set_var("OXY_HOME", d.path());
15
        DIR.set(d).ok();
16
    });
17
}
18
19
fn write(root: &Path, rel: &str, content: &[u8]) {
20
    let p = root.join(rel);
21
    fs::create_dir_all(p.parent().unwrap()).unwrap();
22
    fs::write(p, content).unwrap();
23
}
24
25
fn statuses(s: &SnapshotResult) -> Vec<(String, ChangeStatus)> {
26
    s.statuses
27
        .iter()
28
        .map(|p| (p.path.clone(), p.status))
29
        .collect()
30
}
31
32
#[test]
33
fn init_snapshot_commit_log_undo() {
34
    hermetic();
35
    let dir = tempfile::tempdir().unwrap();
36
    let root = dir.path();
37
    let repo = Repo::init(root).unwrap();
38
    assert!(Repo::init(root).is_err());
39
    let ops = repo.op_log().unwrap();
40
    assert_eq!(ops.len(), 1);
41
    assert_eq!(ops[0].1.command, "oxy init");
42
43
    // Empty working copy: snapshot records no operation (spec §12.3).
44
    let s = repo.snapshot("oxy status").unwrap();
45
    assert!(!s.changed);
46
    assert_eq!(repo.op_log().unwrap().len(), 1);
47
48
    write(root, "src/main.rs", b"fn main() {}\n");
49
    write(root, "README.md", b"# Hello\n");
50
    write(root, ".oxyignore", b"/target\n");
51
    write(root, "target/junk.o", b"\0\0\0");
52
    let s = repo.snapshot("oxy status").unwrap();
53
    assert!(s.changed);
54
    assert_eq!(
55
        statuses(&s),
56
        vec![
57
            (".oxyignore".into(), ChangeStatus::Added),
58
            ("README.md".into(), ChangeStatus::Added),
59
            ("src/main.rs".into(), ChangeStatus::Added),
60
        ]
61
    );
62
    assert_eq!(repo.op_log().unwrap().len(), 2);
63
    // Snapshotting again with nothing changed records no op.
64
    let s2 = repo.snapshot("oxy status").unwrap();
65
    assert!(!s2.changed);
66
    assert_eq!(s2.wc_hash, s.wc_hash);
67
    assert_eq!(repo.op_log().unwrap().len(), 2);
68
69
    let h1 = repo.commit("Initial import", &[]).unwrap();
70
    let c1 = repo.store().get_change(&h1).unwrap();
71
    assert_eq!(c1.kind, ChangeKind::Normal);
72
    assert!(c1.deps.is_empty(), "first change has nothing to depend on");
73
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]);
74
    let (wc_hash, wc) = repo.working_copy().unwrap();
75
    assert_eq!(wc.deps, vec![h1]);
76
    assert!(wc.edits.is_empty());
77
    assert!(repo.commit("nothing", &[]).is_err());
78
79
    // Modify one file, remove another, add a binary file.
80
    write(
81
        root,
82
        "src/main.rs",
83
        b"fn main() {\n    println!(\"hi\");\n}\n",
84
    );
85
    fs::remove_file(root.join("README.md")).unwrap();
86
    write(root, "logo.png", b"\x89PNG\0\0\x01");
87
    let s = repo.snapshot("oxy status").unwrap();
88
    assert_eq!(
89
        statuses(&s),
90
        vec![
91
            ("README.md".into(), ChangeStatus::Removed),
92
            ("logo.png".into(), ChangeStatus::Added),
93
            ("src/main.rs".into(), ChangeStatus::Modified),
94
        ]
95
    );
96
    assert_ne!(s.wc_hash, wc_hash);
97
    // The rewritten working copy keeps its ChangeId and depends on h1.
98
    assert_eq!(s.wc.change_id, wc.change_id);
99
    assert!(s.wc.deps.contains(&h1));
100
101
    let h2 = repo.commit("Second", &[]).unwrap();
102
    let c2 = repo.store().get_change(&h2).unwrap();
103
    assert_eq!(
104
        c2.deps,
105
        vec![h1],
106
        "everything it touches was introduced by h1"
107
    );
108
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]);
109
110
    // The materialized head state matches the working copy.
111
    let st = repo.load_state(&[h2]).unwrap();
112
    let snap = st.materialize();
113
    assert!(!snap.is_conflicted());
114
    assert_eq!(
115
        snap.files.keys().cloned().collect::<Vec<_>>(),
116
        [".oxyignore", "logo.png", "src", "src/main.rs"]
117
    );
118
    assert!(
119
        matches!(&snap.files["src/main.rs"].content, FileContent::Text { bytes, .. } if bytes == b"fn main() {\n    println!(\"hi\");\n}\n")
120
    );
121
    assert!(snap.files["logo.png"].mode.opaque);
122
    if let FileContent::Opaque { blobs } = &snap.files["logo.png"].content {
123
        assert_eq!(repo.read_blob(&blobs[0]).unwrap(), b"\x89PNG\0\0\x01");
124
    } else {
125
        panic!("logo should be opaque");
126
    }
127
128
    // Log: newest first, working copy included.
129
    let log = repo.log().unwrap();
130
    assert_eq!(log.len(), 3);
131
    assert!(log[0].is_working_copy);
132
    assert_eq!(log[1].hash, h2);
133
    assert!(log[1].is_head);
134
    assert_eq!(log[2].hash, h1);
135
    let d = repo.describe_change(&h2).unwrap();
136
    assert!(d.contains("D README.md"), "{d}");
137
    assert!(d.contains("+    println!(\"hi\");"), "{d}");
138
    assert!(d.contains("-fn main() {}"), "{d}");
139
140
    // Resolution by prefix.
141
    assert_eq!(repo.resolve_change(&c2.change_id.short(6)).unwrap(), h2);
142
    assert_eq!(
143
        repo.resolve_change(&format!("#{}", h1.short(8))).unwrap(),
144
        h1
145
    );
146
    assert!(repo.resolve_change("zzzz").is_err());
147
148
    // Undo the commit: bookmark and working copy go back.
149
    repo.undo().unwrap();
150
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]);
151
    let s = repo.snapshot("oxy status").unwrap();
152
    assert!(
153
        !s.changed,
154
        "files on disk still match the pre-commit working copy"
155
    );
156
    assert_eq!(s.statuses.len(), 3);
157
}
158
159
/// Make `main` conflicted: two commits on the same base that edit the same line.
160
fn conflicted_repo() -> (tempfile::TempDir, Repo, ChangeHash, ChangeHash, ChangeHash) {
161
    let dir = tempfile::tempdir().unwrap();
162
    let root = dir.path();
163
    let repo = Repo::init(root).unwrap();
164
    write(root, "f.txt", b"a\nb\nc\n");
165
    let h1 = repo.commit("base", &[]).unwrap();
166
    write(root, "f.txt", b"a\nB\nc\n");
167
    let h2 = repo.commit("upper", &[]).unwrap();
168
    repo.checkout(&[h1], false, "checkout").unwrap();
169
    write(root, "f.txt", b"a\nX\nc\n");
170
    let h3 = repo.commit("ex", &[]).unwrap();
171
    (dir, repo, h1, h2, h3)
172
}
173
174
#[test]
175
fn concurrent_edits_conflict_and_can_be_resolved() {
176
    hermetic();
177
    let (dir, repo, h1, h2, h3) = conflicted_repo();
178
    let root = dir.path();
179
    let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect();
180
    assert_eq!(heads, [h2, h3].into_iter().collect());
181
    let st = repo.load_state(&[h2, h3]).unwrap();
182
    assert!(st.materialize().is_conflicted());
183
184
    // Checking out main writes markers.
185
    let r = repo
186
        .checkout(&repo.bookmark_heads().unwrap(), false, "checkout main")
187
        .unwrap();
188
    assert!(r.conflicted);
189
    let marked = fs::read_to_string(root.join("f.txt")).unwrap();
190
    assert!(
191
        marked.starts_with("a\n<<<<<<< oxy conflict 1 of 1"),
192
        "{marked}"
193
    );
194
    assert!(
195
        marked.contains("B\n") && marked.contains("X\n") && marked.ends_with("end\nc\n"),
196
        "{marked}"
197
    );
198
    assert!(has_conflict_markers(marked.as_bytes()));
199
    // Untouched markers: clean working copy; commit has nothing to do.
200
    let s = repo.snapshot("status").unwrap();
201
    assert!(s.statuses.is_empty());
202
    assert!(!s.resolves);
203
204
    // Partially edited file that still has markers cannot be committed.
205
    write(root, "f.txt", marked.replace("B\n", "BB\n").as_bytes());
206
    let s = repo.snapshot("status").unwrap();
207
    assert_eq!(
208
        statuses(&s),
209
        vec![("f.txt".into(), ChangeStatus::ConflictEdited)]
210
    );
211
    assert!(repo.commit("no", &[]).is_err());
212
213
    // Resolve by keeping one side and adding a line elsewhere.
214
    write(root, "f.txt", b"a\nX\nc\nd\n");
215
    let s = repo.snapshot("status").unwrap();
216
    assert_eq!(statuses(&s), vec![("f.txt".into(), ChangeStatus::Resolved)]);
217
    assert!(s.resolves);
218
    let h4 = repo.commit("resolve", &[]).unwrap();
219
    let c4 = repo.store().get_change(&h4).unwrap();
220
    assert_eq!(c4.kind, ChangeKind::Resolution);
221
    let mut want = vec![h1, h2, h3];
222
    want.sort();
223
    assert_eq!(
224
        c4.deps, want,
225
        "a resolution depends on exactly the contributors and what it references"
226
    );
227
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h4]);
228
    let snap = repo.load_state(&[h4]).unwrap().materialize();
229
    assert!(!snap.is_conflicted());
230
    assert!(
231
        matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nc\nd\n")
232
    );
233
    assert!(!repo.snapshot("status").unwrap().changed);
234
    // A peer holding only h2 and h3 and receiving h4 sees the same result,
235
    // whatever order the changes arrive in.
236
    let mut st = State::new();
237
    for h in [h4, h3, h1, h2] {
238
        st.add_unchecked(repo.store().get_change(&h).unwrap());
239
    }
240
    st.validate().unwrap();
241
    assert!(
242
        matches!(&st.materialize().files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nc\nd\n")
243
    );
244
}
245
246
/// oxygen#118 (Metal Gear): a resolution commits by paths, leaving other
247
/// work uncommitted, so long as it takes every file it resolves; and
248
/// `amend PATHS` folds only those paths into the head.
249
#[test]
250
fn resolutions_and_amends_take_paths() {
251
    hermetic();
252
    let (dir, repo, _h1, _h2, _h3) = conflicted_repo();
253
    let root = dir.path();
254
    repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main")
255
        .unwrap();
256
    // The conflict resolved, and other work beside it.
257
    write(root, "f.txt", b"a\nX\nc\n");
258
    write(root, "notes.txt", b"pending\n");
259
    let s = repo.snapshot("status").unwrap();
260
    assert!(s.resolves);
261
    let err = repo
262
        .commit_selection_partial("resolve", &[], &Selection::Paths(vec!["notes.txt".into()]))
263
        .unwrap_err()
264
        .to_string();
265
    assert!(
266
        err.contains("takes every file it resolves") && err.contains("f.txt"),
267
        "{err}"
268
    );
269
    let (h4, partial) = repo
270
        .commit_selection_partial("resolve", &[], &Selection::Paths(vec!["f.txt".into()]))
271
        .unwrap();
272
    assert!(partial);
273
    assert_eq!(
274
        repo.store().get_change(&h4).unwrap().kind,
275
        ChangeKind::Resolution
276
    );
277
    assert!(!repo
278
        .load_state(&[h4])
279
        .unwrap()
280
        .materialize()
281
        .is_conflicted());
282
    let s = repo.snapshot("status").unwrap();
283
    assert_eq!(
284
        statuses(&s),
285
        vec![("notes.txt".into(), ChangeStatus::Added)],
286
        "the other work waits"
287
    );
288
289
    // amend PATHS: only those paths join the head; it keeps its ChangeId.
290
    // On the resolution, so the bookmark has one head to amend.
291
    let h5 = repo.commit("notes", &[h4]).unwrap();
292
    let id5 = repo.store().get_change(&h5).unwrap().change_id;
293
    write(root, "notes.txt", b"pending\nmore\n");
294
    write(root, "other.txt", b"later\n");
295
    let h6 = repo
296
        .amend_paths(Some("notes, amended"), &["notes.txt".into()])
297
        .unwrap();
298
    let c6 = repo.store().get_change(&h6).unwrap();
299
    assert_eq!((c6.change_id, c6.message.as_str()), (id5, "notes, amended"));
300
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h6]);
301
    let snap6 = repo.load_state(&[h6]).unwrap().materialize();
302
    assert!(
303
        matches!(&snap6.files["notes.txt"].content, FileContent::Text { bytes, .. } if bytes == b"pending\nmore\n")
304
    );
305
    assert!(
306
        !snap6.files.contains_key("other.txt"),
307
        "other.txt stays out"
308
    );
309
    let s = repo.snapshot("status").unwrap();
310
    assert_eq!(
311
        statuses(&s),
312
        vec![("other.txt".into(), ChangeStatus::Added)]
313
    );
314
}
315
316
#[test]
317
fn resolution_may_keep_both_sides_or_neither() {
318
    hermetic();
319
    let (dir, repo, _h1, _h2, _h3) = conflicted_repo();
320
    let root = dir.path();
321
    repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main")
322
        .unwrap();
323
    // Keep both sides in a chosen order.
324
    write(root, "f.txt", b"a\nX\nB\nc\n");
325
    let h = repo.commit("both", &[]).unwrap();
326
    let snap = repo.load_state(&[h]).unwrap().materialize();
327
    assert!(!snap.is_conflicted());
328
    assert!(
329
        matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nB\nc\n")
330
    );
331
    // Undo and instead drop the whole region.
332
    repo.undo().unwrap();
333
    write(root, "f.txt", b"a\nc\n");
334
    let h = repo.commit("neither", &[]).unwrap();
335
    let snap = repo.load_state(&[h]).unwrap().materialize();
336
    assert!(!snap.is_conflicted());
337
    assert!(
338
        matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nc\n")
339
    );
340
}
341
342
#[test]
343
fn name_conflict_resolved_by_removing_a_candidate() {
344
    hermetic();
345
    let dir = tempfile::tempdir().unwrap();
346
    let root = dir.path();
347
    let repo = Repo::init(root).unwrap();
348
    write(root, "keep.txt", b"k\n");
349
    let h1 = repo.commit("base", &[]).unwrap();
350
    write(root, "new.txt", b"one\n");
351
    repo.commit("one", &[]).unwrap();
352
    repo.checkout(&[h1], false, "checkout").unwrap();
353
    write(root, "new.txt", b"two\n");
354
    repo.commit("two", &[]).unwrap();
355
    let r = repo
356
        .checkout(&repo.bookmark_heads().unwrap(), false, "checkout main")
357
        .unwrap();
358
    assert!(r.conflicted);
359
    assert!(
360
        root.join("new.txt.oxy-conflict-1").exists()
361
            && root.join("new.txt.oxy-conflict-2").exists()
362
    );
363
    assert!(!root.join("new.txt").exists());
364
    // Candidate numbering follows change-hash order, so pick by content:
365
    // keep the "two" candidate under the real name and drop the other.
366
    let (keep, drop) = if fs::read(root.join("new.txt.oxy-conflict-1")).unwrap() == b"two\n" {
367
        (1, 2)
368
    } else {
369
        (2, 1)
370
    };
371
    fs::remove_file(root.join(format!("new.txt.oxy-conflict-{drop}"))).unwrap();
372
    fs::rename(
373
        root.join(format!("new.txt.oxy-conflict-{keep}")),
374
        root.join("new.txt"),
375
    )
376
    .unwrap();
377
    let h = repo.commit("pick two", &[]).unwrap();
378
    assert_eq!(
379
        repo.store().get_change(&h).unwrap().kind,
380
        ChangeKind::Resolution
381
    );
382
    // The resolution does not touch keep.txt, so it does not depend on h1;
383
    // the bookmark keeps both as heads and their union is the state.
384
    let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect();
385
    assert_eq!(heads, [h1, h].into_iter().collect());
386
    let snap = repo
387
        .load_state(&repo.bookmark_heads().unwrap())
388
        .unwrap()
389
        .materialize();
390
    assert!(!snap.is_conflicted(), "{:?}", snap.tree.conflicts);
391
    assert_eq!(
392
        snap.files.keys().cloned().collect::<Vec<_>>(),
393
        ["keep.txt", "new.txt"]
394
    );
395
    assert!(
396
        matches!(&snap.files["new.txt"].content, FileContent::Text { bytes, .. } if bytes == b"two\n")
397
    );
398
}
399
400
#[test]
401
fn identities_and_signed_commits() {
402
    hermetic();
403
    let dir = tempfile::tempdir().unwrap();
404
    let root = dir.path();
405
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
406
    write(root, "a.txt", b"a\n");
407
    let h0 = repo.commit("unsigned", &[]).unwrap();
408
    assert_eq!(repo.verify_change(&h0).unwrap(), SignatureStatus::Unsigned);
409
    assert_eq!(
410
        repo.store().get_change(&h0).unwrap().author,
411
        ObjectRef::ZERO
412
    );
413
414
    let (key, id) = repo
415
        .create_identity("Ada", "[email protected]", IdentityScope::Repo)
416
        .unwrap();
417
    assert_eq!(id.hash(), key);
418
    assert_eq!(repo.config.author_key, Some(key));
419
    // Config survives reopen; the secret key is found again.
420
    let reopened = Repo::open(root).unwrap().with_home(root.join("home"));
421
    assert_eq!(reopened.config.author_key, Some(key));
422
    assert_eq!(reopened.secret_key().unwrap().unwrap().0, key);
423
424
    // Default core.sign = seal: commits carry the author but no signature.
425
    write(root, "a.txt", b"a\nb\n");
426
    let h1 = repo.commit("authored", &[]).unwrap();
427
    assert_eq!(repo.store().get_change(&h1).unwrap().author, key);
428
    assert_eq!(repo.verify_change(&h1).unwrap(), SignatureStatus::Unsigned);
429
    // Signing later works too.
430
    repo.sign_change(&h1).unwrap();
431
    assert!(
432
        matches!(repo.verify_change(&h1).unwrap(), SignatureStatus::Valid { key: k, .. } if k == key)
433
    );
434
435
    repo.config.set("core.sign", "change").unwrap();
436
    repo.save_config().unwrap();
437
    write(root, "a.txt", b"a\nb\nc\n");
438
    let h2 = repo.commit("signed", &[]).unwrap();
439
    assert_eq!(
440
        repo.verify_change(&h2).unwrap(),
441
        SignatureStatus::Valid {
442
            key,
443
            name: "Ada".into(),
444
            email: "[email protected]".into(),
445
            sealed: false,
446
            revoked: false
447
        }
448
    );
449
450
    // A signature by a key that is not the author or a delegate is rejected
451
    // when the change names an author; an anonymous change (made before any
452
    // identity, author zero) is adopted by whoever signs it (spec §14.4).
453
    let other = SecretKey::generate();
454
    let other_id = other.identity("Mallory", "[email protected]", 0);
455
    let other_key = repo.store().put_identity(&other_id).unwrap();
456
    write(root, "a.txt", b"a\nb\nc\nd\n");
457
    repo.config.set("core.sign", "seal").unwrap();
458
    let h3 = repo.commit("ada's, unsigned", &[]).unwrap();
459
    let forged = other.sign(Domain::Change, &other_key, &h3);
460
    repo.store().put_signature(&h3, &forged).unwrap();
461
    assert!(
462
        matches!(repo.verify_change(&h3).unwrap(), SignatureStatus::Invalid(m) if m.contains("neither the author"))
463
    );
464
    let adopted = other.sign(Domain::Change, &other_key, &h0);
465
    repo.store().put_signature(&h0, &adopted).unwrap();
466
    assert!(
467
        matches!(repo.verify_change(&h0).unwrap(), SignatureStatus::Valid { name, .. } if name == "Mallory")
468
    );
469
470
    // Tampering with the stored signature invalidates it.
471
    let mut sig = repo.store().signatures(&h2).unwrap().remove(0);
472
    sig.ed[0] ^= 1;
473
    repo.store().put_signature(&h2, &sig).unwrap();
474
    assert!(matches!(
475
        repo.verify_change(&h2).unwrap(),
476
        SignatureStatus::Invalid(_)
477
    ));
478
479
    // Without the secret key, signing at commit fails cleanly.
480
    let no_keys = Repo::open(root).unwrap().with_home(root.join("nowhere"));
481
    write(root, "a.txt", b"z\n");
482
    assert!(no_keys.commit("cannot sign", &[]).is_err());
483
}
484
485
#[test]
486
fn checkout_moves_the_working_copy() {
487
    hermetic();
488
    let dir = tempfile::tempdir().unwrap();
489
    let root = dir.path();
490
    let repo = Repo::init(root).unwrap();
491
    write(root, "a.txt", b"one\n");
492
    write(root, "sub/deep/b.txt", b"b\n");
493
    let h1 = repo.commit("first", &[]).unwrap();
494
    write(root, "a.txt", b"two\n");
495
    fs::remove_file(root.join("sub/deep/b.txt")).unwrap();
496
    write(root, "bin.dat", &[0, 1, 2, 3]);
497
    let h2 = repo.commit("second", &[]).unwrap();
498
499
    let r = repo.checkout(&[h1], false, "oxy checkout").unwrap();
500
    assert_eq!(
501
        r,
502
        CheckoutReport {
503
            written: 2,
504
            removed: 1,
505
            removed_paths: vec!["bin.dat".into()],
506
            conflicted: false,
507
            notes: vec![],
508
            lacks: 1,
509
        }
510
    );
511
    assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\n");
512
    assert_eq!(fs::read(root.join("sub/deep/b.txt")).unwrap(), b"b\n");
513
    assert!(!root.join("bin.dat").exists());
514
    let (_, wc) = repo.working_copy().unwrap();
515
    assert_eq!(wc.deps, vec![h1]);
516
    assert!(
517
        !repo.snapshot("status").unwrap().changed,
518
        "checkout leaves a clean working copy"
519
    );
520
    // The bookmark still points at h2.
521
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]);
522
523
    // Dirty working copy refuses without --discard.
524
    write(root, "a.txt", b"dirty\n");
525
    assert!(repo.checkout(&[h2], false, "oxy checkout").is_err());
526
    let r = repo
527
        .checkout(&[h2], true, "oxy checkout --discard")
528
        .unwrap();
529
    assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"two\n");
530
    assert_eq!(fs::read(root.join("bin.dat")).unwrap(), [0, 1, 2, 3]);
531
    assert!(!root.join("sub").exists(), "empty directories are pruned");
532
    assert_eq!(r.removed, 1);
533
    assert!(!repo.snapshot("status").unwrap().changed);
534
535
    // Refuses to write through a symlinked directory.
536
    #[cfg(unix)]
537
    {
538
        fs::remove_dir_all(root.join("sub")).ok();
539
        std::os::unix::fs::symlink("/tmp", root.join("sub")).unwrap();
540
        assert!(repo.checkout(&[h1], true, "oxy checkout").is_err());
541
    }
542
}
543
544
#[test]
545
fn large_files_are_chunked_and_read_back() {
546
    hermetic();
547
    let dir = tempfile::tempdir().unwrap();
548
    let repo = Repo::init(dir.path()).unwrap();
549
    let mut big = vec![0u8; 700 * 1024];
550
    for (i, b) in big.iter_mut().enumerate() {
551
        *b = (i as u32).wrapping_mul(2654435761).to_le_bytes()[0];
552
    }
553
    let r = repo.store_blob(&big).unwrap();
554
    let blob = Blob::decode(&repo.store().get_typed(&r, ObjectType::Blob).unwrap()).unwrap();
555
    assert!(blob.chunks.len() >= 3, "{}", blob.chunks.len());
556
    assert_eq!(repo.read_blob(&r).unwrap(), big);
557
    assert_eq!(Repo::blob_ref(&big), r);
558
}
559
560
#[test]
561
fn clone_push_pull_with_seals_and_attestations() {
562
    hermetic();
563
    let dir = tempfile::tempdir().unwrap();
564
    let root = dir.path();
565
    let origin_path = root.join("origin");
566
    let origin = Repo::init(&origin_path).unwrap();
567
    let origin_url = origin_path.to_string_lossy().to_string();
568
569
    // A clones, creates an identity (default core.sign = seal), commits, pushes.
570
    let mut a = Repo::clone(&origin_url, &root.join("a"))
571
        .unwrap()
572
        .with_home(root.join("home-a"));
573
    assert_eq!(a.id().unwrap(), origin.id().unwrap());
574
    let (key_a, _) = a
575
        .create_identity("Ada", "[email protected]", IdentityScope::Repo)
576
        .unwrap();
577
    write(&root.join("a"), "f.txt", b"a\n");
578
    let h1 = a.commit("first", &[]).unwrap();
579
    let results = a.push("origin", false).unwrap();
580
    origin.refresh().unwrap();
581
    assert!(results[0].ok, "{}", results[0].reason);
582
    assert_eq!(origin.bookmark_heads().unwrap(), vec![h1]);
583
    // The change was sealed at push and the reference attested.
584
    assert!(
585
        matches!(origin.authenticate_change(&h1).unwrap(), SignatureStatus::Valid { key, sealed: true, .. } if key == key_a)
586
    );
587
    assert_eq!(
588
        origin.verify_change(&h1).unwrap(),
589
        SignatureStatus::Unsigned
590
    );
591
    let serial_of = |att: &SignedAttest| match att.attest.body {
592
        AttestBody::Ref { serial, .. } => serial,
593
        _ => unreachable!(),
594
    };
595
    let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap();
596
    // A bookmark new to the remote starts at the clock (past any deleted
597
    // namesake's serial); later updates count up from there.
598
    let first = serial_of(&att);
599
    assert!(first > 1_000_000_000_000, "{first}");
600
    let again = a.push("origin", false).unwrap();
601
    origin.refresh().unwrap();
602
    assert!(again[0].ok && again[0].reason.contains("up to date"));
603
    let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap();
604
    assert_eq!(serial_of(&att), first, "a no-op push does not attest again");
605
606
    // B clones and sees the file and the seal.
607
    let b = Repo::clone(&origin_url, &root.join("b")).unwrap();
608
    assert_eq!(fs::read(root.join("b/f.txt")).unwrap(), b"a\n");
609
    assert_eq!(b.bookmark_heads().unwrap(), vec![h1]);
610
    assert!(matches!(
611
        b.authenticate_change(&h1).unwrap(),
612
        SignatureStatus::Valid { sealed: true, .. }
613
    ));
614
615
    // B (no identity) commits and pushes unsigned; the unverified origin accepts.
616
    write(&root.join("b"), "f.txt", b"a\nb\n");
617
    let h2 = b.commit("second", &[]).unwrap();
618
    assert!(b.push("origin", false).unwrap()[0].ok);
619
    origin.refresh().unwrap();
620
    assert_eq!(origin.bookmark_heads().unwrap(), vec![h2]);
621
622
    // A commits an independent change; push is refused until A pulls.
623
    write(&root.join("a"), "g.txt", b"g\n");
624
    let h3 = a.commit("third", &[]).unwrap();
625
    assert!(a.push("origin", false).is_err());
626
    let pr = a.pull("origin").unwrap();
627
    assert!(pr.changed && pr.checked_out);
628
    let mut want = vec![h2, h3];
629
    want.sort();
630
    assert_eq!(pr.heads, want);
631
    assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"a\nb\n");
632
    assert_eq!(fs::read(root.join("a/g.txt")).unwrap(), b"g\n");
633
    let results = a.push("origin", false).unwrap();
634
    origin.refresh().unwrap();
635
    assert!(results[0].ok, "{}", results[0].reason);
636
    let mut origin_heads = origin.bookmark_heads().unwrap();
637
    origin_heads.sort();
638
    assert_eq!(origin_heads, want);
639
    let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap();
640
    assert_eq!(serial_of(&att), first + 1);
641
642
    // B pulls and ends up identical.
643
    let pr = b.pull("origin").unwrap();
644
    assert_eq!(pr.heads, want);
645
    assert_eq!(fs::read(root.join("b/g.txt")).unwrap(), b"g\n");
646
    assert!(!b.snapshot("status").unwrap().changed);
647
648
    // Concurrent edits of the same line: the union is a reified conflict.
649
    write(&root.join("a"), "f.txt", b"a\nA\n");
650
    a.commit("a-edit", &[]).unwrap();
651
    a.push("origin", false).unwrap();
652
    origin.refresh().unwrap();
653
    write(&root.join("b"), "f.txt", b"a\nB\n");
654
    b.commit("b-edit", &[]).unwrap();
655
    let pr = b.pull("origin").unwrap();
656
    assert!(pr.changed && pr.checked_out);
657
    let text = fs::read_to_string(root.join("b/f.txt")).unwrap();
658
    assert!(text.contains("<<<<<<< oxy conflict"), "{text}");
659
    write(&root.join("b"), "f.txt", b"a\nAB\n");
660
    let hr = b.commit("resolve", &[]).unwrap();
661
    assert_eq!(
662
        b.store().get_change(&hr).unwrap().kind,
663
        ChangeKind::Resolution
664
    );
665
    assert!(b.push("origin", false).unwrap()[0].ok);
666
    origin.refresh().unwrap();
667
    a.pull("origin").unwrap();
668
    assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"a\nAB\n");
669
    // The resolution depends on the conflicting edits, but nothing depends
670
    // on "third" (it only added g.txt), so it stays a head beside it.
671
    let mut want = vec![hr, h3];
672
    want.sort();
673
    assert_eq!(a.bookmark_heads().unwrap(), want);
674
    assert!(!a.load_state(&want).unwrap().materialize().is_conflicted());
675
}
676
677
#[test]
678
fn receive_push_rejects_bad_updates() {
679
    hermetic();
680
    let dir = tempfile::tempdir().unwrap();
681
    let root = dir.path();
682
    let origin = Repo::init(&root.join("origin")).unwrap();
683
    let url = root.join("origin").to_string_lossy().to_string();
684
    let mut a = Repo::clone(&url, &root.join("a"))
685
        .unwrap()
686
        .with_home(root.join("home"));
687
    let (key, _) = a
688
        .create_identity("Ada", "a@b", IdentityScope::Repo)
689
        .unwrap();
690
    write(&root.join("a"), "f.txt", b"x\n");
691
    let h1 = a.commit("one", &[]).unwrap();
692
    assert!(a.push("origin", false).unwrap()[0].ok);
693
    let empty = origin.store().put_state(&StateObject::new(vec![])).unwrap();
694
    let s1 = origin
695
        .store()
696
        .put_state(&StateObject::new(vec![h1]))
697
        .unwrap();
698
699
    // Missing head.
700
    let ghost = origin
701
        .store()
702
        .put_state(&StateObject::new(vec![ObjectRef([9; 32])]))
703
        .unwrap();
704
    let r = origin
705
        .receive_push(
706
            Bundle::default(),
707
            vec![RefUpdate {
708
                name: "main".into(),
709
                old_state: s1,
710
                new_state: ghost,
711
                force: false,
712
                attestation: None,
713
            }],
714
            None,
715
            vec![],
716
        )
717
        .unwrap();
718
    assert!(
719
        !r[0].ok && r[0].reason.contains("missing"),
720
        "{}",
721
        r[0].reason
722
    );
723
724
    // Stale old_state.
725
    let r = origin
726
        .receive_push(
727
            Bundle::default(),
728
            vec![RefUpdate {
729
                name: "main".into(),
730
                old_state: empty,
731
                new_state: s1,
732
                force: false,
733
                attestation: None,
734
            }],
735
            None,
736
            vec![],
737
        )
738
        .unwrap();
739
    assert!(!r[0].ok && r[0].reason.contains("stale"), "{}", r[0].reason);
740
741
    // Non-fast-forward without force is refused; with force it passes.
742
    let r = origin
743
        .receive_push(
744
            Bundle::default(),
745
            vec![RefUpdate {
746
                name: "main".into(),
747
                old_state: s1,
748
                new_state: empty,
749
                force: false,
750
                attestation: None,
751
            }],
752
            None,
753
            vec![],
754
        )
755
        .unwrap();
756
    assert!(
757
        !r[0].ok && r[0].reason.contains("fast-forward"),
758
        "{}",
759
        r[0].reason
760
    );
761
    let r = origin
762
        .receive_push(
763
            Bundle::default(),
764
            vec![RefUpdate {
765
                name: "main".into(),
766
                old_state: s1,
767
                new_state: empty,
768
                force: true,
769
                attestation: None,
770
            }],
771
            None,
772
            vec![],
773
        )
774
        .unwrap();
775
    assert!(r[0].ok);
776
    assert_eq!(origin.bookmark_heads().unwrap(), vec![]);
777
778
    // Attestation with a stale serial is a rollback and is refused.
779
    let sa = a
780
        .sign_attest(&Attest {
781
            repo: a.id().unwrap(),
782
            hlc: Hlc::now(),
783
            body: AttestBody::Ref {
784
                name: "main".into(),
785
                old_state: empty,
786
                new_state: s1,
787
                serial: 1,
788
            },
789
        })
790
        .unwrap();
791
    let r = origin
792
        .receive_push(
793
            Bundle::default(),
794
            vec![RefUpdate {
795
                name: "main".into(),
796
                old_state: empty,
797
                new_state: s1,
798
                force: false,
799
                attestation: Some(sa.clone()),
800
            }],
801
            None,
802
            vec![],
803
        )
804
        .unwrap();
805
    assert!(
806
        !r[0].ok && r[0].reason.contains("serial"),
807
        "{}",
808
        r[0].reason
809
    );
810
    // An attestation for a different update is refused too.
811
    let sa2 = a
812
        .sign_attest(&Attest {
813
            repo: a.id().unwrap(),
814
            hlc: Hlc::now(),
815
            body: AttestBody::Ref {
816
                name: "main".into(),
817
                old_state: s1,
818
                new_state: empty,
819
                serial: 5,
820
            },
821
        })
822
        .unwrap();
823
    let r = origin
824
        .receive_push(
825
            Bundle::default(),
826
            vec![RefUpdate {
827
                name: "main".into(),
828
                old_state: empty,
829
                new_state: s1,
830
                force: false,
831
                attestation: Some(sa2),
832
            }],
833
            None,
834
            vec![],
835
        )
836
        .unwrap();
837
    assert!(
838
        !r[0].ok && r[0].reason.contains("describe"),
839
        "{}",
840
        r[0].reason
841
    );
842
    let _ = key;
843
}
844
845
#[test]
846
fn user_config_supplies_defaults_and_clones_sign() {
847
    hermetic();
848
    let dir = tempfile::tempdir().unwrap();
849
    let root = dir.path();
850
    let home = root.join("home");
851
    let origin = Repo::init(&root.join("origin")).unwrap();
852
    let url = root.join("origin").to_string_lossy().to_string();
853
854
    // Identity at user scope: the repository file stays free of author keys.
855
    let mut a = Repo::init(&root.join("a")).unwrap().with_home(&home);
856
    let (key, _) = a
857
        .create_identity("Ada", "[email protected]", IdentityScope::User)
858
        .unwrap();
859
    assert!(!a.config.repo_layer().contains_key("author.key"));
860
    assert_eq!(a.config.author_key, Some(key));
861
    assert!(home.join("config.toml").exists());
862
    a.set_user_config("core.sign", "change").unwrap();
863
    assert!(a.config.sign_changes);
864
865
    // Repository settings override user settings and are the only ones saved.
866
    a.config.set("core.sign", "seal").unwrap();
867
    a.save_config().unwrap();
868
    let again = Repo::open(&root.join("a")).unwrap().with_home(&home);
869
    assert!(!again.config.sign_changes);
870
    assert_eq!(again.config.author_key, Some(key));
871
    assert_eq!(
872
        read_config_file(&root.join("a/.oxy/config"))
873
            .unwrap()
874
            .get("core.sign")
875
            .map(String::as_str),
876
        Some("seal")
877
    );
878
879
    // A fresh clone made by this user signs with the user-level identity,
880
    // even though the clone's store has never seen the identity object.
881
    let b = Repo::clone(&url, &root.join("b")).unwrap().with_home(&home);
882
    assert!(b.config.sign_changes);
883
    write(&root.join("b"), "f.txt", b"f\n");
884
    let h = b.commit("signed from a clone", &[]).unwrap();
885
    assert!(
886
        matches!(b.verify_change(&h).unwrap(), SignatureStatus::Valid { key: k, .. } if k == key)
887
    );
888
    assert!(b.push("origin", false).unwrap()[0].ok);
889
    assert!(matches!(
890
        origin.verify_change(&h).unwrap(),
891
        SignatureStatus::Valid { .. }
892
    ));
893
}
894
895
#[test]
896
fn format1_repositories_are_migrated_on_open() {
897
    hermetic();
898
    use crate::oplog::{Format1Op, Format1View};
899
    let dir = tempfile::tempdir().unwrap();
900
    let root = dir.path();
901
    let repo = Repo::init(root).unwrap();
902
    write(root, "a.txt", b"a\n");
903
    let h1 = repo.commit("one", &[]).unwrap();
904
    let (wc_hash, _) = repo.working_copy().unwrap();
905
    // Rewrite the op log the way format 1 stored it: two ops, head lists.
906
    let mut v1 = Format1View::default();
907
    v1.bookmarks.insert("main".into(), vec![]);
908
    v1.workspaces.insert(repo.workspace(), wc_hash);
909
    let init = Format1Op {
910
        parents: vec![],
911
        hlc: Hlc::default(),
912
        command: "oxy init".into(),
913
        view: v1.clone(),
914
    };
915
    let init_ref = repo.store().put(ObjectType::Op, &init.encode()).unwrap();
916
    v1.bookmarks.insert("main".into(), vec![h1]);
917
    let commit = Format1Op {
918
        parents: vec![init_ref],
919
        hlc: Hlc::default(),
920
        command: "oxy commit".into(),
921
        view: v1,
922
    };
923
    let commit_ref = repo.store().put(ObjectType::Op, &commit.encode()).unwrap();
924
    write_atomic(&root.join(".oxy/ops/head"), commit_ref.to_hex().as_bytes()).unwrap();
925
    write_atomic(&root.join(".oxy/format"), FORMAT1_LINE.as_bytes()).unwrap();
926
    drop(repo);
927
928
    let repo = Repo::open(root).unwrap();
929
    assert_eq!(
930
        fs::read_to_string(root.join(".oxy/format")).unwrap(),
931
        FORMAT_LINE
932
    );
933
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]);
934
    let ops = repo.op_log().unwrap();
935
    assert_eq!(
936
        ops.iter()
937
            .map(|(_, o)| o.command.as_str())
938
            .collect::<Vec<_>>(),
939
        ["oxy commit", "oxy init"]
940
    );
941
    assert_eq!(repo.heads_of(&ops[1].1.view, "main").unwrap(), vec![]);
942
    assert!(!repo.snapshot("status").unwrap().changed);
943
    // Reopening again is a no-op.
944
    assert_eq!(Repo::open(root).unwrap().op_log().unwrap().len(), 2);
945
}
946
947
#[test]
948
fn undo_skips_snapshot_operations() {
949
    hermetic();
950
    let dir = tempfile::tempdir().unwrap();
951
    let root = dir.path();
952
    let repo = Repo::init(root).unwrap();
953
    write(root, "a.txt", b"a\n");
954
    let h1 = repo.commit("a", &[]).unwrap();
955
    // A later command snapshots new edits on top of the commit.
956
    write(root, "b.txt", b"b\n");
957
    let s = repo.snapshot("oxy status").unwrap();
958
    assert!(s.changed);
959
    assert!(repo.head_op().unwrap().1.is_snapshot());
960
    assert_eq!(repo.last_command().unwrap().command, "oxy commit -m \"a\"");
961
    // Undo must undo the commit, not the snapshot.
962
    repo.undo().unwrap();
963
    assert_eq!(repo.bookmark_heads().unwrap(), vec![]);
964
    let s = repo.snapshot("oxy status").unwrap();
965
    let paths: Vec<_> = s.statuses.iter().map(|p| p.path.as_str()).collect();
966
    assert_eq!(
967
        paths,
968
        ["a.txt", "b.txt"],
969
        "both files are back in the working copy"
970
    );
971
    assert!(repo.store().get_change(&h1).unwrap().deps.is_empty());
972
    // Only snapshots remain above `init`, so there is nothing left to undo.
973
    assert_eq!(repo.last_command().unwrap().command, "oxy init");
974
    assert!(repo.undo().is_err());
975
}
976
977
#[test]
978
fn independent_changes_commute_and_make_multiple_heads() {
979
    hermetic();
980
    let dir = tempfile::tempdir().unwrap();
981
    let root = dir.path();
982
    let repo = Repo::init(root).unwrap();
983
    write(root, "a.txt", b"a\n");
984
    let h1 = repo.commit("a", &[]).unwrap();
985
    write(root, "b.txt", b"b\n");
986
    let h2 = repo.commit("b", &[]).unwrap();
987
    let c2 = repo.store().get_change(&h2).unwrap();
988
    assert!(c2.deps.is_empty(), "b.txt touches nothing from h1");
989
    let mut heads = repo.bookmark_heads().unwrap();
990
    heads.sort();
991
    let mut want = vec![h1, h2];
992
    want.sort();
993
    assert_eq!(heads, want);
994
    // Editing a.txt depends on h1 only.
995
    write(root, "a.txt", b"a\na2\n");
996
    let h3 = repo.commit("a2", &[]).unwrap();
997
    assert_eq!(repo.store().get_change(&h3).unwrap().deps, vec![h1]);
998
    let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect();
999
    assert_eq!(heads, [h2, h3].into_iter().collect());
1000
    // Explicit dependency.
1001
    write(root, "c.txt", b"c\n");
1002
    let h4 = repo.commit("c", &[h2]).unwrap();
1003
    assert_eq!(repo.store().get_change(&h4).unwrap().deps, vec![h2]);
1004
}
1005
1006
#[test]
1007
fn mode_change_and_text_binary_switch() {
1008
    hermetic();
1009
    let dir = tempfile::tempdir().unwrap();
1010
    let root = dir.path();
1011
    let repo = Repo::init(root).unwrap();
1012
    write(root, "run.sh", b"#!/bin/sh\n");
1013
    repo.commit("add script", &[]).unwrap();
1014
    #[cfg(unix)]
1015
    {
1016
        use std::os::unix::fs::PermissionsExt;
1017
        fs::set_permissions(root.join("run.sh"), fs::Permissions::from_mode(0o755)).unwrap();
1018
        let s = repo.snapshot("status").unwrap();
1019
        assert_eq!(
1020
            statuses(&s),
1021
            vec![("run.sh".into(), ChangeStatus::ModeChanged)]
1022
        );
1023
        let h = repo.commit("exec", &[]).unwrap();
1024
        let snap = repo.load_state(&[h]).unwrap().materialize();
1025
        assert!(snap.files["run.sh"].mode.exec);
1026
    }
1027
    // Text → binary → text.
1028
    write(root, "run.sh", b"\0binary");
1029
    let h = repo.commit("binary", &[]).unwrap();
1030
    let snap = repo.load_state(&[h]).unwrap().materialize();
1031
    assert!(snap.files["run.sh"].mode.opaque);
1032
    write(root, "run.sh", b"text again\n");
1033
    let h = repo.commit("text", &[]).unwrap();
1034
    let snap = repo.load_state(&[h]).unwrap().materialize();
1035
    assert!(!snap.files["run.sh"].mode.opaque);
1036
    assert!(
1037
        matches!(&snap.files["run.sh"].content, FileContent::Text { bytes, .. } if bytes == b"text again\n")
1038
    );
1039
    assert!(!snap.is_conflicted());
1040
}
1041
1042
/// oxygen#87: a binary file given new contents and the executable bit in
1043
/// one commit kept the contents and lost the bit.
1044
#[cfg(unix)]
1045
#[test]
1046
fn a_binary_file_changed_and_made_executable_at_once_keeps_both() {
1047
    use std::os::unix::fs::PermissionsExt;
1048
    hermetic();
1049
    let dir = tempfile::tempdir().unwrap();
1050
    let root = dir.path();
1051
    let repo = Repo::init(root).unwrap();
1052
    write(root, "tool.exe", b"MZ\0one");
1053
    repo.commit("add", &[]).unwrap();
1054
    write(root, "tool.exe", b"MZ\0two");
1055
    fs::set_permissions(root.join("tool.exe"), fs::Permissions::from_mode(0o755)).unwrap();
1056
    let h = repo.commit("update and +x", &[]).unwrap();
1057
    let snap = repo.load_state(&[h]).unwrap().materialize();
1058
    let f = &snap.files["tool.exe"];
1059
    assert!(f.mode.opaque && f.mode.exec, "{:?}", f.mode);
1060
    assert!(
1061
        repo.snapshot("status").unwrap().statuses.is_empty(),
1062
        "nothing left to commit"
1063
    );
1064
}
1065
1066
/// Keyless step 4 (oxygenhub#241): an agent signing with a short-lived
1067
/// key whose certificate an issuer gave pushes when the policy grants its
1068
/// subject, and a fetch accepts what it attested; an expired certificate,
1069
/// or a subject the policy does not grant, may not move the bookmark.
1070
#[test]
1071
fn a_certified_key_pushes_under_a_subject_grant() {
1072
    use crate::cert::{Certificate, SignedCertificate};
1073
    use crate::identity::Scope;
1074
    hermetic();
1075
    let dir = tempfile::tempdir().unwrap();
1076
    let root = dir.path();
1077
    let _origin = Repo::init(&root.join("origin")).unwrap();
1078
    let url = root.join("origin").to_string_lossy().to_string();
1079
    let mut a = Repo::clone(&url, &root.join("a"))
1080
        .unwrap()
1081
        .with_home(root.join("home-a"));
1082
    a.create_identity("Ada", "[email protected]", IdentityScope::User)
1083
        .unwrap();
1084
    write(&root.join("a"), "a.txt", b"a\n");
1085
    a.commit("a", &[]).unwrap();
1086
    a.policy_init().unwrap();
1087
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
1088
    let hub_sk = SecretKey::generate();
1089
    let hub = hub_sk.identity("hub", "[email protected]", 1);
1090
    a.policy_grant_subject(
1091
        "agent:claude@ada",
1092
        hub.clone(),
1093
        Scope::CHANGES | Scope::REFS,
1094
        None,
1095
    )
1096
    .unwrap();
1097
    assert!(a
1098
        .push("origin", false)
1099
        .unwrap()
1100
        .iter()
1101
        .any(|r| r.name == "policy" && r.ok));
1102
1103
    let now = Hlc::now().wall_ms;
1104
    // An agent repository whose key the hub certified for `subject`, from
1105
    // `from` to `to`.
1106
    let agent = |name: &str, subject: &str, from: u64, to: u64| {
1107
        let mut r = Repo::clone(&url, &root.join(name))
1108
            .unwrap()
1109
            .with_home(root.join(format!("home-{name}")));
1110
        let (_, id) = r
1111
            .create_identity(name, "[email protected]", IdentityScope::User)
1112
            .unwrap();
1113
        let cert = SignedCertificate::sign(
1114
            Certificate {
1115
                key: id,
1116
                issuer: hub.hash(),
1117
                subject: subject.into(),
1118
                claims: vec![],
1119
                scope: Scope::CHANGES | Scope::REFS,
1120
                not_before: from,
1121
                expires: to,
1122
                grant: None,
1123
            },
1124
            &hub,
1125
            &hub_sk,
1126
        );
1127
        r.store().put_identity(&hub).unwrap();
1128
        r.store().put_certificate(&cert).unwrap();
1129
        write(&root.join(name), &format!("{name}.txt"), b"agent work\n");
1130
        r.commit("work", &[]).unwrap();
1131
        r
1132
    };
1133
1134
    let claude = agent("claude", "agent:claude@ada", now - 60_000, now + 600_000);
1135
    let r = claude.push("origin", false).unwrap();
1136
    assert!(r.iter().all(|x| x.ok), "{r:?}");
1137
    // Ada's fetch accepts the bookmark the certified key attested.
1138
    a.pull("origin").unwrap();
1139
    assert!(a.bookmark_heads().unwrap().iter().any(|h| a
1140
        .store()
1141
        .get_change(h)
1142
        .map(|c| c.message == "work")
1143
        .unwrap_or(false)));
1144
1145
    // Step 4c: a host with an acceptor key records when it accepted the
1146
    // certified update and serves it; a fetch checks the certificate at
1147
    // that time only when the certificate's issuer signed it for exactly
1148
    // this attestation, else at the attestation's own time.
1149
    let host = Repo::open(&root.join("origin"))
1150
        .unwrap()
1151
        .with_acceptor(hub.clone(), hub_sk.clone());
1152
    let (attest_ref, sa) = host.latest_ref_attest("main").unwrap().unwrap();
1153
    host.record_acceptance(&attest_ref).unwrap();
1154
    let served = host.remote_refs().unwrap();
1155
    let acc = served
1156
        .refs
1157
        .iter()
1158
        .find(|r| r.name == "main")
1159
        .unwrap()
1160
        .accepted
1161
        .clone()
1162
        .expect("served with the refs");
1163
    let signer = sa.sigs[0].key;
1164
    assert_eq!(
1165
        a.certified_time(&signer, &sa, Some(&acc)),
1166
        acc.attest.hlc.wall_ms
1167
    );
1168
    assert_eq!(a.certified_time(&signer, &sa, None), sa.attest.hlc.wall_ms);
1169
    let stranger_sk = SecretKey::generate();
1170
    let stranger = stranger_sk.identity("stranger", "s@x", 1);
1171
    let mut forged = acc.clone();
1172
    forged.sigs = vec![stranger_sk.sign(Domain::Attest, &stranger.hash(), &acc.attest.hash())];
1173
    assert_eq!(
1174
        a.certified_time(&signer, &sa, Some(&forged)),
1175
        sa.attest.hlc.wall_ms,
1176
        "not the issuer's"
1177
    );
1178
    let mut elsewhere = acc.clone();
1179
    elsewhere.attest.body = AttestBody::Accepted {
1180
        attest: ObjectRef([7; 32]),
1181
    };
1182
    elsewhere.sigs = vec![hub_sk.sign(Domain::Attest, &hub.hash(), &elsewhere.attest.hash())];
1183
    assert_eq!(
1184
        a.certified_time(&signer, &sa, Some(&elsewhere)),
1185
        sa.attest.hlc.wall_ms,
1186
        "another attestation's"
1187
    );
1188
    a.pull("origin").unwrap();
1189
    // gc keeps the acceptance with the attestation.
1190
    host.gc(true).unwrap();
1191
    assert!(host.acceptance_of(&attest_ref).unwrap().is_some());
1192
1193
    let late = agent("late", "agent:claude@ada", now - 1_200_000, now - 600_000);
1194
    let r = late.push("origin", false).unwrap();
1195
    assert!(
1196
        !r[0].ok && r[0].reason.contains("may not update"),
1197
        "an expired certificate: {r:?}"
1198
    );
1199
    let other = agent("other", "agent:mallory@ada", now - 60_000, now + 600_000);
1200
    let r = other.push("origin", false).unwrap();
1201
    assert!(
1202
        !r[0].ok && r[0].reason.contains("may not update"),
1203
        "a subject not granted: {r:?}"
1204
    );
1205
}
1206
1207
/// A tag made under one short-lived key and pushed under another: its
1208
/// signer travels with the push (identity and certificate), so a receiver
1209
/// that has never seen that key accepts it. A tag-only push by a keyless
1210
/// agent was refused "unknown key" (found releasing OxygenFlow 0.2.0).
1211
#[test]
1212
fn a_tag_signed_by_an_earlier_short_lived_key_is_pushed() {
1213
    use crate::cert::{Certificate, SignedCertificate};
1214
    use crate::identity::Scope;
1215
    hermetic();
1216
    let dir = tempfile::tempdir().unwrap();
1217
    let root = dir.path();
1218
    let _origin = Repo::init(&root.join("origin")).unwrap();
1219
    let url = root.join("origin").to_string_lossy().to_string();
1220
    let mut a = Repo::clone(&url, &root.join("a"))
1221
        .unwrap()
1222
        .with_home(root.join("home-a"));
1223
    a.create_identity("Ada", "[email protected]", IdentityScope::User)
1224
        .unwrap();
1225
    write(&root.join("a"), "a.txt", b"a\n");
1226
    a.commit("a", &[]).unwrap();
1227
    a.policy_init().unwrap();
1228
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
1229
    let hub_sk = SecretKey::generate();
1230
    let hub = hub_sk.identity("hub", "[email protected]", 1);
1231
    a.policy_grant_subject(
1232
        "agent:claude@ada",
1233
        hub.clone(),
1234
        Scope::CHANGES | Scope::REFS,
1235
        Some("*"),
1236
    )
1237
    .unwrap();
1238
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
1239
1240
    let now = Hlc::now().wall_ms;
1241
    let mut agent = Repo::clone(&url, &root.join("agent"))
1242
        .unwrap()
1243
        .with_home(root.join("home-agent"));
1244
    agent.store().put_identity(&hub).unwrap();
1245
    let certify = |agent: &Repo, id| {
1246
        let cert = SignedCertificate::sign(
1247
            Certificate {
1248
                key: id,
1249
                issuer: hub.hash(),
1250
                subject: "agent:claude@ada".into(),
1251
                claims: vec![],
1252
                scope: Scope::CHANGES | Scope::REFS,
1253
                not_before: now - 60_000,
1254
                expires: now + 600_000,
1255
                grant: None,
1256
            },
1257
            &hub,
1258
            &hub_sk,
1259
        );
1260
        agent.store().put_certificate(&cert).unwrap();
1261
    };
1262
    // The tag, under the first short-lived key.
1263
    let (first, first_id) = agent
1264
        .create_identity("claude", "[email protected]", IdentityScope::User)
1265
        .unwrap();
1266
    certify(&agent, first_id);
1267
    let heads = agent.bookmark_heads().unwrap();
1268
    agent.tag_create("v1", &heads, "first").unwrap();
1269
    // The push, under a second one (a later process's ten-minute key).
1270
    let (second, second_id) = agent
1271
        .create_identity("claude", "[email protected]", IdentityScope::User)
1272
        .unwrap();
1273
    assert_ne!(first, second);
1274
    certify(&agent, second_id);
1275
    agent
1276
        .set_user_config("author.signer", &second.to_hex())
1277
        .unwrap();
1278
    let host = Repo::open(&root.join("origin")).unwrap();
1279
    assert!(
1280
        host.store().get_identity(&first).is_err(),
1281
        "the host has never seen the tag's key"
1282
    );
1283
    let r = agent.push("origin", false).unwrap();
1284
    assert!(r.iter().all(|x| x.ok), "{r:?}");
1285
    let host = Repo::open(&root.join("origin")).unwrap();
1286
    assert!(
1287
        host.tag_refs().unwrap().iter().any(|t| t.name == "v1"),
1288
        "the tag arrived"
1289
    );
1290
}
1291
1292
/// oxygen#129: a short-lived key's tag is checked on a fetch at a time its
1293
/// signer did not choose, its issuer's acceptance, never at the time it
1294
/// claims nor at the fetch's (when the ten-minute certificate has long
1295
/// expired). Without an acceptance it is left out and said so; with one
1296
/// inside the certificate's window it is fetched however much later, and
1297
/// a host it is pushed on to keeps and serves the acceptance.
1298
#[test]
1299
fn a_short_lived_key_s_tag_is_fetched_at_its_issuer_s_acceptance() {
1300
    use crate::cert::{Certificate, SignedCertificate};
1301
    use crate::identity::Scope;
1302
    hermetic();
1303
    let dir = tempfile::tempdir().unwrap();
1304
    let root = dir.path();
1305
    let _origin = Repo::init(&root.join("origin")).unwrap();
1306
    let url = root.join("origin").to_string_lossy().to_string();
1307
    let mut a = Repo::clone(&url, &root.join("a"))
1308
        .unwrap()
1309
        .with_home(root.join("home-a"));
1310
    a.create_identity("Ada", "[email protected]", IdentityScope::User)
1311
        .unwrap();
1312
    write(&root.join("a"), "a.txt", b"a\n");
1313
    a.commit("a", &[]).unwrap();
1314
    a.policy_init().unwrap();
1315
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
1316
    let hub_sk = SecretKey::generate();
1317
    let hub = hub_sk.identity("hub", "[email protected]", 1);
1318
    a.policy_grant_subject(
1319
        "agent:claude@ada",
1320
        hub.clone(),
1321
        Scope::CHANGES | Scope::REFS,
1322
        Some("*"),
1323
    )
1324
    .unwrap();
1325
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
1326
1327
    let now = Hlc::now().wall_ms;
1328
    // An agent whose key the hub certified from `from` to `to`.
1329
    let agent = |name: &str, from: u64, to: u64| {
1330
        let mut r = Repo::clone(&url, &root.join(name))
1331
            .unwrap()
1332
            .with_home(root.join(format!("home-{name}")));
1333
        r.store().put_identity(&hub).unwrap();
1334
        let (_, id) = r
1335
            .create_identity(name, "[email protected]", IdentityScope::User)
1336
            .unwrap();
1337
        let cert = SignedCertificate::sign(
1338
            Certificate {
1339
                key: id,
1340
                issuer: hub.hash(),
1341
                subject: "agent:claude@ada".into(),
1342
                claims: vec![],
1343
                scope: Scope::CHANGES | Scope::REFS,
1344
                not_before: from,
1345
                expires: to,
1346
                grant: None,
1347
            },
1348
            &hub,
1349
            &hub_sk,
1350
        );
1351
        r.store().put_certificate(&cert).unwrap();
1352
        r
1353
    };
1354
1355
    // A tag by a key certified now, pushed to a host that records no
1356
    // acceptance (a path on disk): it lands, but a fetch has no time to
1357
    // check its certificate at, so it is left out and the fetch goes on.
1358
    let claude = agent("claude", now - 60_000, now + 600_000);
1359
    let heads = claude.bookmark_heads().unwrap();
1360
    claude.tag_create("v1", &heads, "one").unwrap();
1361
    let r = claude.push("origin", false).unwrap();
1362
    assert!(r.iter().all(|x| x.ok), "{r:?}");
1363
    let b = Repo::clone(&url, &root.join("b"))
1364
        .unwrap()
1365
        .with_home(root.join("home-b"));
1366
    let rep = b.fetch("origin").unwrap();
1367
    assert!(
1368
        rep.skipped
1369
            .iter()
1370
            .any(|(n, why)| n == "tags/v1" && why.contains("no time to check its certificate at")),
1371
        "{rep:?}"
1372
    );
1373
    assert_eq!(b.tag_heads("v1").unwrap(), None);
1374
1375
    // The host's acceptance, signed by the certificate's issuer, served
1376
    // with the tag: fetched.
1377
    let host = Repo::open(&root.join("origin"))
1378
        .unwrap()
1379
        .with_acceptor(hub.clone(), hub_sk.clone());
1380
    let (v1, _) = host.tag_attest("v1").unwrap().unwrap();
1381
    host.record_acceptance(&v1).unwrap();
1382
    let served = host.tag_refs().unwrap();
1383
    assert!(served
1384
        .iter()
1385
        .any(|t| t.name == "v1" && t.accepted.is_some()));
1386
    let rep = b.fetch("origin").unwrap();
1387
    assert!(rep.skipped.is_empty(), "{rep:?}");
1388
    assert_eq!(b.tag_heads("v1").unwrap(), Some(heads.clone()));
1389
    // gc keeps the acceptance with the tag.
1390
    host.gc(true).unwrap();
1391
    assert!(host.acceptance_of(&v1).unwrap().is_some());
1392
1393
    // Long after: a key whose certificate expired an hour ago may not
1394
    // create a tag now.
1395
    let late = agent("late", now - 7_200_000, now - 3_600_000);
1396
    late.tag_create("v2", &heads, "two").unwrap();
1397
    let r = late.push("origin", false).unwrap();
1398
    assert!(
1399
        r.iter()
1400
            .any(|x| x.name == "tags/v2" && !x.ok && x.reason.contains("may not create")),
1401
        "{r:?}"
1402
    );
1403
    // With its issuer's acceptance from inside the window (as a hub gave
1404
    // it then, passed on to another host), it is taken at that time: by
1405
    // the host it is pushed to, which keeps it, and by a fresh clone.
1406
    let (v2, _) = late.tag_attest("v2").unwrap().unwrap();
1407
    let acc = Attest {
1408
        repo: late.id().unwrap(),
1409
        hlc: Hlc {
1410
            wall_ms: now - 5_400_000,
1411
            logical: 0,
1412
        },
1413
        body: AttestBody::Accepted { attest: v2 },
1414
    };
1415
    let sig = hub_sk.sign(Domain::Attest, &hub.hash(), &acc.hash());
1416
    // One by any other key gives no time.
1417
    let stranger_sk = SecretKey::generate();
1418
    let stranger = stranger_sk.identity("stranger", "[email protected]", 1);
1419
    let forged = stranger_sk.sign(Domain::Attest, &stranger.hash(), &acc.hash());
1420
    late.keep_acceptance(
1421
        &v2,
1422
        &SignedAttest {
1423
            attest: acc.clone(),
1424
            sigs: vec![forged],
1425
        },
1426
    )
1427
    .unwrap();
1428
    let r = late.push("origin", false).unwrap();
1429
    assert!(
1430
        r.iter().any(|x| x.name == "tags/v2" && !x.ok),
1431
        "not the issuer's: {r:?}"
1432
    );
1433
    let theirs = SignedAttest {
1434
        attest: acc,
1435
        sigs: vec![sig],
1436
    };
1437
    // Kept beside the stranger's: the issuer's is the one sent.
1438
    late.keep_acceptance(&v2, &theirs).unwrap();
1439
    let r = late.push("origin", false).unwrap();
1440
    // (Its re-attestation of main is refused: an expired key moves no
1441
    // bookmark.)
1442
    assert!(r.iter().any(|x| x.name == "tags/v2" && x.ok), "{r:?}");
1443
    let host = Repo::open(&root.join("origin")).unwrap();
1444
    let (hv2, _) = host.tag_attest("v2").unwrap().unwrap();
1445
    assert_eq!(hv2, v2);
1446
    assert!(host
1447
        .store()
1448
        .index_get_pub("accepted", &v2)
1449
        .unwrap()
1450
        .contains(&theirs.attest.hash()));
1451
    let c = Repo::clone(&url, &root.join("c"))
1452
        .unwrap()
1453
        .with_home(root.join("home-c"));
1454
    assert_eq!(c.tag_heads("v2").unwrap(), Some(heads.clone()));
1455
    assert_eq!(c.tag_heads("v1").unwrap(), Some(heads));
1456
}
1457
1458
/// oxygenhub#266: a short-lived key its issuer certified moves a bookmark
1459
/// and signs no change (a keyless agent naming a bookmark). A third
1460
/// party's clone gets the key's identity, its certificate and the
1461
/// issuer's identity with the fetch, so the policy's subject grant
1462
/// authorizes the move and the signature verifies.
1463
#[test]
1464
fn a_bookmark_moved_by_a_certified_key_alone_is_fetched() {
1465
    use crate::cert::{Certificate, SignedCertificate};
1466
    use crate::identity::Scope;
1467
    hermetic();
1468
    let dir = tempfile::tempdir().unwrap();
1469
    let root = dir.path();
1470
    let _origin = Repo::init(&root.join("origin")).unwrap();
1471
    let url = root.join("origin").to_string_lossy().to_string();
1472
    let mut a = Repo::clone(&url, &root.join("a"))
1473
        .unwrap()
1474
        .with_home(root.join("home-a"));
1475
    a.create_identity("Ada", "[email protected]", IdentityScope::User)
1476
        .unwrap();
1477
    write(&root.join("a"), "a.txt", b"a\n");
1478
    a.commit("a", &[]).unwrap();
1479
    a.policy_init().unwrap();
1480
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
1481
    let hub_sk = SecretKey::generate();
1482
    let hub = hub_sk.identity("hub", "[email protected]", 1);
1483
    a.policy_grant_subject(
1484
        "agent:claude@ada",
1485
        hub.clone(),
1486
        Scope::CHANGES | Scope::REFS,
1487
        None,
1488
    )
1489
    .unwrap();
1490
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
1491
1492
    let now = Hlc::now().wall_ms;
1493
    let mut agent = Repo::clone(&url, &root.join("agent"))
1494
        .unwrap()
1495
        .with_home(root.join("home-agent"));
1496
    let (key, id) = agent
1497
        .create_identity("claude", "[email protected]", IdentityScope::User)
1498
        .unwrap();
1499
    let cert = SignedCertificate::sign(
1500
        Certificate {
1501
            key: id,
1502
            issuer: hub.hash(),
1503
            subject: "agent:claude@ada".into(),
1504
            claims: vec![],
1505
            scope: Scope::CHANGES | Scope::REFS,
1506
            not_before: now - 60_000,
1507
            expires: now + 600_000,
1508
            grant: None,
1509
        },
1510
        &hub,
1511
        &hub_sk,
1512
    );
1513
    agent.store().put_identity(&hub).unwrap();
1514
    agent.store().put_certificate(&cert).unwrap();
1515
    let heads = agent.bookmark_heads().unwrap();
1516
    agent.bookmark_create("agent-work", Some(&heads)).unwrap();
1517
    let r = agent.push_bookmark("origin", "agent-work", false).unwrap();
1518
    assert!(r.iter().all(|x| x.ok), "{r:?}");
1519
    let host = Repo::open(&root.join("origin")).unwrap();
1520
    let (_, sa) = host.latest_ref_attest("agent-work").unwrap().unwrap();
1521
    assert_eq!(sa.sigs[0].key, key, "the certified key alone moved it");
1522
1523
    // A fetch naming no change brings the certificate and both identities.
1524
    let bundle = host.bundle_raw_for(&[], None).unwrap();
1525
    assert!(bundle.changes.is_empty() && bundle.raw_changes.is_empty());
1526
    assert_eq!(bundle.certificates.len(), 1);
1527
    for k in [key, hub.hash()] {
1528
        assert!(bundle
1529
            .objects
1530
            .iter()
1531
            .any(|(t, b)| *t == ObjectType::Identity && hash_object(*t, b) == k));
1532
    }
1533
1534
    let c = Repo::clone(&url, &root.join("c")).unwrap();
1535
    let tracked = c.view().unwrap().remotes["origin"].clone();
1536
    assert_eq!(
1537
        tracked.get("agent-work"),
1538
        host.view().unwrap().bookmarks.get("agent-work")
1539
    );
1540
    assert!(c.store().certificate_of(&key).unwrap().is_some());
1541
}
1542
1543
/// Trusted time (oxygenhub#240, spec §14.8): a fetched attestation by a
1544
/// short-lived key is judged at its integrated time, the earliest time a
1545
/// pinned witness cosigned a checkpoint holding it. A certificate valid
1546
/// then but expired now passes; a witness this user does not trust gives
1547
/// no time (the earlier rule, said so); a backdated attestation is noted;
1548
/// an answer without the new fields is judged as before.
1549
#[test]
1550
fn a_fetched_attestation_is_judged_at_its_integrated_time() {
1551
    use super::transparency::LogCheck;
1552
    use crate::cert::{Certificate, SignedCertificate};
1553
    use crate::identity::Scope;
1554
    use crate::protocol::{Integration, Logged, RefsResponse, RemoteRef};
1555
    use crate::tlog::{self, Checkpoint, Entry, Hash, Integrated, Note};
1556
    hermetic();
1557
    let dir = tempfile::tempdir().unwrap();
1558
    let root = dir.path();
1559
    let mut a = Repo::init(&root.join("a"))
1560
        .unwrap()
1561
        .with_home(root.join("home-a"));
1562
    a.create_identity("Ada", "[email protected]", IdentityScope::User)
1563
        .unwrap();
1564
    write(&root.join("a"), "a.txt", b"a\n");
1565
    a.commit("a", &[]).unwrap();
1566
    a.policy_init().unwrap();
1567
    let issuer_sk = SecretKey::generate();
1568
    let issuer = issuer_sk.identity("hub", "[email protected]", 1);
1569
    a.policy_grant_subject(
1570
        "agent:claude@ada",
1571
        issuer.clone(),
1572
        Scope::CHANGES | Scope::REFS,
1573
        None,
1574
    )
1575
    .unwrap();
1576
    let p = a.current_policy().unwrap().unwrap().1.policy;
1577
1578
    // An agent key the issuer certified for ten minutes two hours ago.
1579
    let now = Hlc::now().wall_ms;
1580
    let t0 = now - 2 * 3_600_000;
1581
    let agent_sk = SecretKey::generate();
1582
    let agent = agent_sk.identity("claude", "[email protected]", t0);
1583
    let cert = SignedCertificate::sign(
1584
        Certificate {
1585
            key: agent.clone(),
1586
            issuer: issuer.hash(),
1587
            subject: "agent:claude@ada".into(),
1588
            claims: vec![],
1589
            scope: Scope::CHANGES | Scope::REFS,
1590
            not_before: t0,
1591
            expires: t0 + 600_000,
1592
            grant: None,
1593
        },
1594
        &issuer,
1595
        &issuer_sk,
1596
    );
1597
    a.store().put_identity(&agent).unwrap();
1598
    a.store().put_certificate(&cert).unwrap();
1599
    let signer = agent.hash();
1600
    let attested = |claimed: u64| {
1601
        let attest = Attest {
1602
            repo: a.id().unwrap(),
1603
            hlc: Hlc {
1604
                wall_ms: claimed,
1605
                logical: 0,
1606
            },
1607
            body: AttestBody::Ref {
1608
                name: "main".into(),
1609
                old_state: ObjectRef([1; 32]),
1610
                new_state: ObjectRef([2; 32]),
1611
                serial: 1,
1612
            },
1613
        };
1614
        let sig = agent_sk.sign(Domain::Attest, &signer, &attest.hash());
1615
        SignedAttest {
1616
            attest,
1617
            sigs: vec![sig],
1618
        }
1619
    };
1620
1621
    // The hub's log: eight entries, the attestation's at 3, the latest
1622
    // checkpoint of 8, and one of 4 that witnesses cosigned.
1623
    let log_sk = SecretKey::generate();
1624
    let log_key = log_sk.identity("hub.example", "[email protected]", 1);
1625
    let origin = "hub.example/log";
1626
    let hub = "https://hub.example";
1627
    let url = "https://hub.example/ada/a";
1628
    let w_sk = SecretKey::generate();
1629
    let w = w_sk.identity("w", "[email protected]", 1);
1630
    let stranger_sk = SecretKey::generate();
1631
    let stranger = stranger_sk.identity("s", "[email protected]", 1);
1632
    let sa = attested(t0 + 300_000);
1633
    let entry_of = |i: u64| {
1634
        let (kind, object) = if i == 3 {
1635
            ("ref", sa.attest.hash())
1636
        } else {
1637
            ("seal", ObjectRef([i as u8; 32]))
1638
        };
1639
        Entry {
1640
            kind: kind.into(),
1641
            repo: sa.attest.repo,
1642
            object,
1643
            signers: vec![signer],
1644
            received: t0 + 300_000,
1645
        }
1646
        .encode()
1647
    };
1648
    let mut stored: Vec<Hash> = Vec::new();
1649
    for i in 0..8 {
1650
        let add = tlog::stored_hashes_for_leaf(i, tlog::leaf_hash(&entry_of(i)), &stored).unwrap();
1651
        stored.extend(add);
1652
    }
1653
    let note_of = |n: u64, cosigners: &[(&str, &Identity, &SecretKey, u64)]| {
1654
        let mut note = Checkpoint {
1655
            origin: origin.into(),
1656
            size: n,
1657
            root: tlog::tree_hash(n, &stored).unwrap(),
1658
            extensions: vec![],
1659
        }
1660
        .sign(&log_key, &log_sk)
1661
        .unwrap();
1662
        for (name, id, sk, t) in cosigners {
1663
            note.sigs
1664
                .extend(tlog::cosign(&note.text, name, id, sk, *t).unwrap());
1665
        }
1666
        note.encode()
1667
    };
1668
    let latest = note_of(8, &[]);
1669
    let witnessed_at = (t0 + 360_000) / 1000;
1670
    let cosigned = note_of(
1671
        4,
1672
        &[
1673
            ("w.example", &w, &w_sk, witnessed_at),
1674
            ("s.example", &stranger, &stranger_sk, 1),
1675
        ],
1676
    );
1677
    let answer = |integration: Vec<Integration>, witnesses: Vec<(String, Vec<u8>)>| RefsResponse {
1678
        refs: vec![RemoteRef {
1679
            name: "main".into(),
1680
            state: ObjectRef([2; 32]),
1681
            heads: vec![],
1682
            attestation: Some(sa.clone()),
1683
            last_serial: None,
1684
            accepted: None,
1685
            logged: Some(Logged {
1686
                index: 3,
1687
                entry: entry_of(3),
1688
                proof: tlog::prove_inclusion(8, 3, &stored).unwrap(),
1689
            }),
1690
            integration,
1691
        }],
1692
        log: Some(latest.clone()),
1693
        witnesses,
1694
        ..RefsResponse::default()
1695
    };
1696
    let proof4 = tlog::prove_inclusion(4, 3, &stored).unwrap();
1697
    let served = vec![Integration {
1698
        note: cosigned.clone(),
1699
        proof: proof4.clone(),
1700
    }];
1701
    let listed = vec![("w.example".to_string(), w.encode())];
1702
    // This user saw the hub's log before (pinned, so nothing is fetched).
1703
    a.remember_log(hub, &log_key, &Note::parse(&latest).unwrap())
1704
        .unwrap();
1705
1706
    // The witness is pinned on first sight, silently; the stranger's
1707
    // earlier cosignature counts for nothing.
1708
    let resp = answer(served.clone(), listed.clone());
1709
    let lc = a.check_log(url, None, &resp).unwrap();
1710
    assert!(lc.notes.is_empty(), "{:?}", lc.notes);
1711
    let t = lc.time_of("main").unwrap();
1712
    assert_eq!((t.time, t.witness.as_str()), (witnessed_at, "w.example"));
1713
    // The certificate expired an hour and fifty minutes ago, but covered
1714
    // the integrated time: accepted, and nothing to say.
1715
    assert!(cert.cert.expires < now);
1716
    let mut notes = Vec::new();
1717
    a.authorize_in_time(&p, "origin", &resp.refs[0], &sa, &signer, &lc, &mut notes)
1718
        .unwrap();
1719
    assert!(notes.is_empty(), "{notes:?}");
1720
1721
    // Only the stranger cosigned: no witnessed time, so the earlier rule
1722
    // (the attestation's own time, inside the window), said so.
1723
    let lonely = vec![Integration {
1724
        note: note_of(4, &[("s.example", &stranger, &stranger_sk, 1)]),
1725
        proof: proof4.clone(),
1726
    }];
1727
    let resp = answer(lonely, vec![]);
1728
    let lc = a.check_log(url, None, &resp).unwrap();
1729
    assert!(lc
1730
        .time_of("main")
1731
        .unwrap_err()
1732
        .contains("no witness this user trusts"));
1733
    let mut notes = Vec::new();
1734
    a.authorize_in_time(&p, "origin", &resp.refs[0], &sa, &signer, &lc, &mut notes)
1735
        .unwrap();
1736
    assert!(
1737
        notes.len() == 1 && notes[0].contains("not at a witnessed time"),
1738
        "{notes:?}"
1739
    );
1740
1741
    // A witness listed later under the pinned name with another key is
1742
    // pinned with a note, as a new log key is.
1743
    let resp = answer(
1744
        served.clone(),
1745
        vec![("w.example".into(), stranger.encode())],
1746
    );
1747
    let lc = a.check_log(url, None, &resp).unwrap();
1748
    assert!(
1749
        lc.notes.iter().any(|n| n.contains("another key")),
1750
        "{:?}",
1751
        lc.notes
1752
    );
1753
    assert!(
1754
        lc.time_of("main").is_err(),
1755
        "w's cosignature is by its old key"
1756
    );
1757
    a.check_log(url, None, &answer(served.clone(), listed.clone()))
1758
        .unwrap();
1759
1760
    // An old peer's answer: no log, no witnesses, no integration. Judged
1761
    // as before, silently.
1762
    let mut old = answer(vec![], vec![]);
1763
    old.log = None;
1764
    old.refs[0].logged = None;
1765
    let lc = a.check_log(url, None, &old).unwrap();
1766
    assert!(!lc.kept && lc.notes.is_empty());
1767
    let mut notes = Vec::new();
1768
    a.authorize_in_time(&p, "origin", &old.refs[0], &sa, &signer, &lc, &mut notes)
1769
        .unwrap();
1770
    assert!(notes.is_empty(), "{notes:?}");
1771
1772
    // Judged at given integrated times.
1773
    let at = |secs_after_t0: u64| {
1774
        let mut times = std::collections::HashMap::new();
1775
        times.insert(
1776
            "main".to_string(),
1777
            Ok(Integrated {
1778
                time: (t0 + secs_after_t0 * 1000) / 1000,
1779
                witness: "w.example".into(),
1780
                size: 4,
1781
            }),
1782
        );
1783
        LogCheck {
1784
            kept: true,
1785
            times,
1786
            ..LogCheck::default()
1787
        }
1788
    };
1789
    let judged = |sa: &SignedAttest, lc: &LogCheck| {
1790
        let mut notes = Vec::new();
1791
        a.authorize_in_time(&p, "origin", &old.refs[0], sa, &signer, lc, &mut notes)
1792
            .map(|_| notes)
1793
    };
1794
    // Claimed inside the window, first witnessed an hour later, after
1795
    // the certificate expired: backdated, or held back. Accepted by the
1796
    // earlier rule while logs are new, with both said.
1797
    let notes = judged(&sa, &at(3600)).unwrap();
1798
    assert!(
1799
        notes
1800
            .iter()
1801
            .any(|n| n.contains("had lapsed when witness w.example"))
1802
            && notes
1803
                .iter()
1804
                .any(|n| n.contains("55 minutes before witness")),
1805
        "{notes:?}"
1806
    );
1807
    // Claimed after the certificate expired, but witnessed inside its
1808
    // window: the clock the signer did not choose decides. The gap is
1809
    // still noted.
1810
    let fast = attested(now);
1811
    let notes = judged(&fast, &at(400)).unwrap();
1812
    assert!(
1813
        notes.len() == 1 && notes[0].contains("after witness w.example saw it"),
1814
        "{notes:?}"
1815
    );
1816
    // Claimed and witnessed after it expired: refused, as before.
1817
    assert!(judged(&fast, &at(3600)).is_err());
1818
    // A gap within ten minutes is nothing to note.
1819
    assert!(judged(&sa, &at(500)).unwrap().is_empty());
1820
}
1821
1822
/// Keyless step 5 (oxygenhub#241): an agent's fresh key, certified for a
1823
/// subject the author granted, seals changes in the author's name; with
1824
/// no grant, a stranger's, or one that had ended, it is neither the
1825
/// author nor a delegate.
1826
/// oxygen#135: an owner renews or rotates with every delegation, and a
1827
/// repository may name the newest key as the author; the agent's grant by
1828
/// the earlier key still counts, but not one that key made after.
1829
#[test]
1830
fn a_grant_outlives_its_owner_s_rotation() {
1831
    use crate::cert::{Certificate, Grant, SignedCertificate, SignedGrant};
1832
    use crate::identity::Scope;
1833
    hermetic();
1834
    let dir = tempfile::tempdir().unwrap();
1835
    let root = dir.path();
1836
    let mut a = Repo::init(&root.join("a"))
1837
        .unwrap()
1838
        .with_home(root.join("home-a"));
1839
    let (old, _) = a
1840
        .create_identity("Ada", "[email protected]", IdentityScope::User)
1841
        .unwrap();
1842
    let (_, old_sk) = a.secret_key().unwrap().unwrap();
1843
    let old_id = a.identity(&old).unwrap();
1844
    let hub_sk = SecretKey::generate();
1845
    let hub = hub_sk.identity("hub", "[email protected]", 1);
1846
    let now = Hlc::now().wall_ms;
1847
    let grant = |from: u64| {
1848
        SignedGrant::sign(
1849
            Grant {
1850
                grantor: old,
1851
                subject: "agent:claude@ada".into(),
1852
                issuer: hub.hash(),
1853
                scope: Scope::CHANGES | Scope::SEAL,
1854
                not_before: from,
1855
                expires: now + 86_400_000,
1856
            },
1857
            &old_id,
1858
            &old_sk,
1859
        )
1860
    };
1861
    let before = grant(now - 60_000);
1862
    let after = grant(now + 60_000);
1863
    let rot = a.renew_identity(None).unwrap();
1864
    assert_ne!(rot.new, old);
1865
    let agent = |name: &str, g: SignedGrant| {
1866
        let mut r = Repo::init(&root.join(name))
1867
            .unwrap()
1868
            .with_home(root.join(format!("home-{name}")));
1869
        let (key, id) = r
1870
            .create_identity(name, "[email protected]", IdentityScope::User)
1871
            .unwrap();
1872
        for i in [&old_id, &rot.identity, &hub] {
1873
            r.store().put_identity(i).unwrap();
1874
        }
1875
        r.set_user_config("author.key", &rot.new.to_hex()).unwrap();
1876
        r.set_user_config("author.signer", &key.to_hex()).unwrap();
1877
        let cert = SignedCertificate::sign(
1878
            Certificate {
1879
                key: id,
1880
                issuer: hub.hash(),
1881
                subject: "agent:claude@ada".into(),
1882
                claims: vec![],
1883
                scope: Scope::CHANGES | Scope::REFS | Scope::SEAL,
1884
                not_before: now - 60_000,
1885
                expires: now + 600_000,
1886
                grant: Some(g),
1887
            },
1888
            &hub,
1889
            &hub_sk,
1890
        );
1891
        r.store().put_certificate(&cert).unwrap();
1892
        write(&root.join(name), "work.txt", b"agent work\n");
1893
        let h = r.commit("work", &[]).unwrap();
1894
        assert_eq!(r.store().get_change_meta(&h).unwrap().author, rot.new);
1895
        r.seal(&[h]).unwrap();
1896
        (r, h)
1897
    };
1898
    let (r, h) = agent("kept", before);
1899
    assert!(
1900
        matches!(
1901
            r.authenticate_change(&h).unwrap(),
1902
            SignatureStatus::Valid { .. }
1903
        ),
1904
        "{:?}",
1905
        r.authenticate_change(&h).unwrap()
1906
    );
1907
    let (r, h) = agent("late", after);
1908
    assert!(
1909
        matches!(r.authenticate_change(&h).unwrap(), SignatureStatus::Invalid(e) if e.contains("neither")),
1910
        "a grant the old key made after the rotation: {:?}",
1911
        r.authenticate_change(&h).unwrap()
1912
    );
1913
}
1914
1915
#[test]
1916
fn a_certified_key_acts_for_its_author_under_a_grant() {
1917
    use crate::cert::{Certificate, Grant, SignedCertificate, SignedGrant};
1918
    use crate::identity::{Identity, Scope};
1919
    hermetic();
1920
    let dir = tempfile::tempdir().unwrap();
1921
    let root = dir.path();
1922
    let _origin = Repo::init(&root.join("origin")).unwrap();
1923
    let url = root.join("origin").to_string_lossy().to_string();
1924
    let mut a = Repo::clone(&url, &root.join("a"))
1925
        .unwrap()
1926
        .with_home(root.join("home-a"));
1927
    let (ada, _) = a
1928
        .create_identity("Ada", "[email protected]", IdentityScope::User)
1929
        .unwrap();
1930
    write(&root.join("a"), "a.txt", b"a\n");
1931
    a.commit("a", &[]).unwrap();
1932
    a.policy_init().unwrap();
1933
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
1934
    let hub_sk = SecretKey::generate();
1935
    let hub = hub_sk.identity("hub", "[email protected]", 1);
1936
    a.policy_grant_subject(
1937
        "agent:claude@ada",
1938
        hub.clone(),
1939
        Scope::CHANGES | Scope::REFS,
1940
        None,
1941
    )
1942
    .unwrap();
1943
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
1944
    let (_, ada_sk) = a.secret_key().unwrap().unwrap();
1945
    let ada_id = a.identity(&ada).unwrap();
1946
1947
    let now = Hlc::now().wall_ms;
1948
    let grant = |by: &Identity, sk: &SecretKey, from: u64, to: u64| {
1949
        SignedGrant::sign(
1950
            Grant {
1951
                grantor: by.hash(),
1952
                subject: "agent:claude@ada".into(),
1953
                issuer: hub.hash(),
1954
                scope: Scope::CHANGES | Scope::SEAL,
1955
                not_before: from,
1956
                expires: to,
1957
            },
1958
            by,
1959
            sk,
1960
        )
1961
    };
1962
    // Ada's work as an agent runs it: Ada the author, a fresh key the hub
1963
    // certified (with `g` inside) the signer.
1964
    let agent = |name: &str, g: Option<SignedGrant>| {
1965
        let mut r = Repo::clone(&url, &root.join(name))
1966
            .unwrap()
1967
            .with_home(root.join(format!("home-{name}")));
1968
        let (key, id) = r
1969
            .create_identity(name, "[email protected]", IdentityScope::User)
1970
            .unwrap();
1971
        r.set_user_config("author.key", &ada.to_hex()).unwrap();
1972
        r.set_user_config("author.signer", &key.to_hex()).unwrap();
1973
        let cert = SignedCertificate::sign(
1974
            Certificate {
1975
                key: id,
1976
                issuer: hub.hash(),
1977
                subject: "agent:claude@ada".into(),
1978
                claims: vec![],
1979
                scope: Scope::CHANGES | Scope::REFS | Scope::SEAL,
1980
                not_before: now - 60_000,
1981
                expires: now + 600_000,
1982
                grant: g,
1983
            },
1984
            &hub,
1985
            &hub_sk,
1986
        );
1987
        r.store().put_identity(&hub).unwrap();
1988
        r.store().put_certificate(&cert).unwrap();
1989
        write(&root.join(name), &format!("{name}.txt"), b"agent work\n");
1990
        let h = r.commit("work", &[]).unwrap();
1991
        assert_eq!(r.store().get_change_meta(&h).unwrap().author, ada);
1992
        (r, key, h)
1993
    };
1994
1995
    let (claude, key, h) = agent(
1996
        "claude",
1997
        Some(grant(&ada_id, &ada_sk, now - 60_000, now + 86_400_000)),
1998
    );
1999
    let r = claude.push("origin", false).unwrap();
2000
    assert!(r.iter().all(|x| x.ok), "{r:?}");
2001
    let host = Repo::open(&root.join("origin")).unwrap();
2002
    assert!(
2003
        matches!(
2004
            host.authenticate_change(&h).unwrap(),
2005
            SignatureStatus::Valid { key: k, sealed: true, name, .. } if k == key && name == "agent:claude@ada"
2006
        ),
2007
        "named by its certificate's subject"
2008
    );
2009
    a.pull("origin").unwrap();
2010
    assert!(matches!(
2011
        a.authenticate_change(&h).unwrap(),
2012
        SignatureStatus::Valid { key: k, .. } if k == key
2013
    ));
2014
2015
    let (none, _, _) = agent("none", None);
2016
    let r = none.push("origin", false).unwrap();
2017
    assert!(r.iter().all(|x| !x.ok), "no grant: {r:?}");
2018
    let mallory_sk = SecretKey::generate();
2019
    let mallory = mallory_sk.identity("Mallory", "[email protected]", 1);
2020
    let (stranger, _, _) = agent(
2021
        "stranger",
2022
        Some(grant(&mallory, &mallory_sk, now - 60_000, now + 600_000)),
2023
    );
2024
    let r = stranger.push("origin", false).unwrap();
2025
    assert!(r.iter().all(|x| !x.ok), "a stranger's grant: {r:?}");
2026
    let (ended, _, eh) = agent(
2027
        "ended",
2028
        Some(grant(&ada_id, &ada_sk, now - 7_200_000, now - 3_600_000)),
2029
    );
2030
    let r = ended.push("origin", false).unwrap();
2031
    assert!(r.iter().all(|x| !x.ok), "an ended grant: {r:?}");
2032
    assert!(matches!(
2033
        ended.authenticate_change(&eh).unwrap(),
2034
        SignatureStatus::Invalid(e) if e.contains("grant did not cover")
2035
    ));
2036
}
2037
2038
#[test]
2039
fn trust_policy_governs_pushes_and_fetches() {
2040
    hermetic();
2041
    let dir = tempfile::tempdir().unwrap();
2042
    let root = dir.path();
2043
    let origin = Repo::init(&root.join("origin")).unwrap();
2044
    let url = root.join("origin").to_string_lossy().to_string();
2045
2046
    // A is the root. Before the policy, an unsigned push from B is fine.
2047
    let mut a = Repo::clone(&url, &root.join("a"))
2048
        .unwrap()
2049
        .with_home(root.join("home-a"));
2050
    let (key_a, _) = a
2051
        .create_identity("Ada", "[email protected]", IdentityScope::User)
2052
        .unwrap();
2053
    let mut b = Repo::clone(&url, &root.join("b"))
2054
        .unwrap()
2055
        .with_home(root.join("home-b"));
2056
    write(&root.join("b"), "b.txt", b"b\n");
2057
    b.commit("unsigned before policy", &[]).unwrap();
2058
    assert!(b.push("origin", false).unwrap()[0].ok);
2059
    origin.refresh().unwrap();
2060
2061
    // A creates the policy and pushes it; origin now enforces it.
2062
    write(&root.join("a"), "a.txt", b"a\n");
2063
    a.commit("a", &[]).unwrap();
2064
    let sp = a.policy_init().unwrap();
2065
    assert_eq!(sp.policy.serial, 1);
2066
    a.pull("origin").unwrap();
2067
    let results = a.push("origin", false).unwrap();
2068
    origin.refresh().unwrap();
2069
    assert!(results.iter().all(|r| r.ok), "{results:?}");
2070
    assert_eq!(origin.current_policy().unwrap().unwrap().1.policy.serial, 1);
2071
2072
    // B pulls the policy. Without an identity B cannot push at all.
2073
    b.pull("origin").unwrap();
2074
    assert!(
2075
        b.current_policy().unwrap().is_some(),
2076
        "the policy arrived with the fetch"
2077
    );
2078
    assert!(b.push("origin", false).is_err());
2079
    // With an identity the policy does not list, the push is rejected by origin.
2080
    let (key_b, id_b) = b
2081
        .create_identity("Bob", "[email protected]", IdentityScope::User)
2082
        .unwrap();
2083
    write(&root.join("b"), "b.txt", b"b2\n");
2084
    let h_b2 = b.commit("b2 as Bob", &[]).unwrap();
2085
    assert_eq!(b.store().get_change(&h_b2).unwrap().author, key_b);
2086
    let r = b.push("origin", false).unwrap();
2087
    origin.refresh().unwrap();
2088
    assert!(
2089
        !r[0].ok && r[0].reason.contains("may not update"),
2090
        "{:?}",
2091
        r
2092
    );
2093
2094
    // A grants B (identity handed over as a file) and publishes the policy.
2095
    let sp = a.policy_grant(id_b.clone(), None).unwrap();
2096
    assert_eq!(sp.policy.serial, 2);
2097
    let r = a.push("origin", false).unwrap();
2098
    origin.refresh().unwrap();
2099
    assert!(r.iter().any(|x| x.name == "policy" && x.ok), "{r:?}");
2100
    // B must fetch the new policy before pushing.
2101
    assert!(b
2102
        .push("origin", false)
2103
        .unwrap_or_else(|e| vec![RefResult {
2104
            name: "main".into(),
2105
            ok: false,
2106
            reason: e.to_string()
2107
        }])
2108
        .iter()
2109
        .all(|x| !x.ok));
2110
    b.pull("origin").unwrap();
2111
    assert_eq!(b.current_policy().unwrap().unwrap().1.policy.serial, 2);
2112
    let r = b.push("origin", false).unwrap();
2113
    origin.refresh().unwrap();
2114
    assert!(r[0].ok, "{:?}", r);
2115
    // Origin verified B's change under the policy.
2116
    let heads = origin.bookmark_heads().unwrap();
2117
    for h in &heads {
2118
        let c = origin.store().get_change(h).unwrap();
2119
        if c.author == key_b {
2120
            origin
2121
                .change_allowed(
2122
                    h,
2123
                    &origin.current_policy().unwrap().unwrap().1.policy,
2124
                    "main",
2125
                )
2126
                .unwrap();
2127
        }
2128
    }
2129
2130
    // A policy signed by a non-root (B) is refused, as is a replayed serial.
2131
    let mut forged = b.current_policy().unwrap().unwrap().1.policy.clone();
2132
    forged.serial = 3;
2133
    forged.roots = vec![key_b];
2134
    let fh = forged.hash();
2135
    let (_, sk_b) = b.secret_key().unwrap().unwrap();
2136
    let forged = SignedPolicy {
2137
        policy: forged,
2138
        sigs: vec![sk_b.sign(Domain::Policy, &key_b, &fh)],
2139
    };
2140
    let r = origin
2141
        .receive_push(Bundle::default(), vec![], Some(forged), vec![])
2142
        .unwrap();
2143
    assert!(!r[0].ok && r[0].reason.contains("root"), "{:?}", r);
2144
    let stale = a.current_policy().unwrap().unwrap().1;
2145
    let mut old = stale.clone();
2146
    old.policy.serial = 1;
2147
    let r = origin
2148
        .receive_push(Bundle::default(), vec![], Some(old), vec![])
2149
        .unwrap();
2150
    assert!(!r[0].ok && r[0].reason.contains("serial"), "{:?}", r);
2151
2152
    // A revokes B; after B pulls the policy, B's pushes are refused again.
2153
    a.pull("origin").unwrap();
2154
    a.policy_revoke(&key_b).unwrap();
2155
    let r = a.push("origin", false).unwrap();
2156
    origin.refresh().unwrap();
2157
    assert!(r.iter().any(|x| x.name == "policy" && x.ok), "{r:?}");
2158
    b.pull("origin").unwrap();
2159
    write(&root.join("b"), "b.txt", b"b3\n");
2160
    b.commit("b3", &[]).unwrap();
2161
    let r = b.push("origin", false).unwrap();
2162
    origin.refresh().unwrap();
2163
    assert!(!r[0].ok, "{:?}", r);
2164
2165
    // A clone by a third party receives and verifies everything.
2166
    let c = Repo::clone(&url, &root.join("c"))
2167
        .unwrap()
2168
        .with_home(root.join("home-c"));
2169
    assert_eq!(c.current_policy().unwrap().unwrap().1.policy.serial, 3);
2170
    assert!(c
2171
        .current_policy()
2172
        .unwrap()
2173
        .unwrap()
2174
        .1
2175
        .policy
2176
        .roots
2177
        .contains(&key_a));
2178
    assert!(!c.snapshot("status").unwrap().changed);
2179
}
2180
2181
/// oxygen#66: a gc says what it is doing, phase by phase.
2182
#[test]
2183
fn gc_reports_its_phases() {
2184
    hermetic();
2185
    let dir = tempfile::tempdir().unwrap();
2186
    let repo = Repo::init(dir.path()).unwrap();
2187
    for i in 0..3 {
2188
        write(
2189
            dir.path(),
2190
            &format!("f{i}.txt"),
2191
            format!("{i}\n").as_bytes(),
2192
        );
2193
        repo.commit(&format!("c{i}"), &[]).unwrap();
2194
    }
2195
    let mut phases = Vec::new();
2196
    repo.gc_with(true, &mut |p| phases.push(p.to_string()))
2197
        .unwrap();
2198
    assert_eq!(phases[0], "finding what is reachable", "{phases:?}");
2199
    assert!(
2200
        phases.iter().any(|p| p.starts_with("pruning")),
2201
        "{phases:?}"
2202
    );
2203
    assert!(
2204
        phases.iter().any(|p| p.starts_with("packing 0 of ")),
2205
        "{phases:?}"
2206
    );
2207
    assert!(
2208
        phases
2209
            .iter()
2210
            .any(|p| p.starts_with("writing the index of ")),
2211
        "{phases:?}"
2212
    );
2213
}
2214
2215
#[test]
2216
fn gc_packs_objects_and_history_still_works() {
2217
    hermetic();
2218
    let dir = tempfile::tempdir().unwrap();
2219
    let root = dir.path();
2220
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
2221
    repo.create_identity("Ada", "a@b", IdentityScope::User)
2222
        .unwrap();
2223
    repo.set_user_config("core.sign", "change").unwrap();
2224
    write(root, "a.txt", b"one\n");
2225
    let h1 = repo.commit("one", &[]).unwrap();
2226
    write(root, "a.txt", b"two\n");
2227
    write(root, "big.bin", &vec![0u8; 100_000]);
2228
    let h2 = repo.commit("two", &[]).unwrap();
2229
    // Several snapshots leave superseded working-copy versions behind.
2230
    for i in 0..5 {
2231
        write(root, "a.txt", format!("two\n{i}\n").as_bytes());
2232
        repo.snapshot("status").unwrap();
2233
    }
2234
    write(root, "a.txt", b"two\n");
2235
    repo.snapshot("status").unwrap();
2236
    let before = repo.store().list().unwrap();
2237
    assert!(repo.store().loose_count().unwrap() > 0);
2238
2239
    // With the default retention every operation's view is a root: nothing prunes.
2240
    let r = repo.gc(true).unwrap();
2241
    assert_eq!(r.packed.objects, before.len());
2242
    assert_eq!(r.pruned.objects, 0);
2243
    assert_eq!(repo.store().loose_count().unwrap(), 0);
2244
    assert_eq!(repo.store().list().unwrap(), before);
2245
2246
    // With zero retention only the current view is a root: superseded
2247
    // working copies go, everything reachable stays.
2248
    repo.config.set("core.op-retention", "0").unwrap();
2249
    repo.save_config().unwrap();
2250
    let mut repo = Repo::open(root).unwrap().with_home(root.join("home"));
2251
    let r = repo.gc(true).unwrap();
2252
    assert!(r.pruned.objects >= 5, "{r:?}");
2253
    let after = repo.store().list().unwrap();
2254
    assert!(after.len() < before.len());
2255
    assert!(after.contains(&h1) && after.contains(&h2));
2256
    for (_, op) in repo.op_log().unwrap() {
2257
        assert!(!op.command.is_empty(), "operation objects are all kept");
2258
    }
2259
    // Everything still works from the pruned, packed store.
2260
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]);
2261
    assert!(matches!(
2262
        repo.verify_change(&h2).unwrap(),
2263
        SignatureStatus::Valid { .. }
2264
    ));
2265
    assert_eq!(repo.log().unwrap().len(), 3);
2266
    repo.checkout(&[h1], false, "checkout").unwrap();
2267
    assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\n");
2268
    repo.checkout(&[h2], false, "checkout").unwrap();
2269
    assert_eq!(fs::read(root.join("big.bin")).unwrap().len(), 100_000);
2270
    assert!(!repo.snapshot("status").unwrap().changed);
2271
    // Grace period defers pruning of young garbage.
2272
    write(root, "a.txt", b"three\n");
2273
    repo.commit("three", &[]).unwrap();
2274
    let r = repo.gc(false).unwrap();
2275
    assert_eq!(r.pruned.objects, 0);
2276
    assert!(r.pruned.deferred > 0);
2277
    let r = repo.gc(true).unwrap();
2278
    assert!(r.pruned.objects > 0);
2279
    assert_eq!(repo.log().unwrap().len(), 4);
2280
    // Restoring the default retention makes the old views roots again; the
2281
    // working copies they name were pruned, which gc tolerates.
2282
    repo.config.set("core.op-retention", "90").unwrap();
2283
    repo.save_config().unwrap();
2284
    let repo = Repo::open(root).unwrap().with_home(root.join("home"));
2285
    let r = repo.gc(false).unwrap();
2286
    assert_eq!(r.pruned.objects, 0);
2287
    assert_eq!(repo.log().unwrap().len(), 4);
2288
}
2289
2290
#[test]
2291
fn describe_amend_abandon_heads() {
2292
    hermetic();
2293
    let dir = tempfile::tempdir().unwrap();
2294
    let root = dir.path();
2295
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
2296
    repo.create_identity("Ada", "a@b", IdentityScope::User)
2297
        .unwrap();
2298
    repo.set_user_config("core.sign", "change").unwrap();
2299
    write(root, "a.txt", b"one\n");
2300
    let h1 = repo.commit("first", &[]).unwrap();
2301
    write(root, "a.txt", b"one\ntwo\n");
2302
    let h2 = repo.commit("secnod", &[]).unwrap();
2303
    let id2 = repo.store().get_change(&h2).unwrap().change_id;
2304
2305
    // describe: same ChangeId, new hash, bookmark and working copy follow.
2306
    let h2b = repo.describe(&h2, "second").unwrap();
2307
    assert_ne!(h2b, h2);
2308
    let c = repo.store().get_change(&h2b).unwrap();
2309
    assert_eq!(
2310
        (c.change_id, c.message.as_str(), c.deps.clone()),
2311
        (id2, "second", vec![h1])
2312
    );
2313
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h2b]);
2314
    assert!(matches!(
2315
        repo.verify_change(&h2b).unwrap(),
2316
        SignatureStatus::Valid { .. }
2317
    ));
2318
    let s = repo.snapshot("status").unwrap();
2319
    assert!(s.statuses.is_empty(), "{:?}", s.statuses);
2320
    assert_eq!(
2321
        repo.resolve_change(&id2.short(8)).unwrap(),
2322
        h2b,
2323
        "the old object is no longer reachable from the log"
2324
    );
2325
    // Describing a non-head rewrites its dependents (h2b → h2c') and the working copy.
2326
    let h1b = repo.describe(&h1, "first, described").unwrap();
2327
    assert_ne!(h1b, h1);
2328
    let heads = repo.bookmark_heads().unwrap();
2329
    assert_eq!(heads.len(), 1);
2330
    let top = repo.store().get_change(&heads[0]).unwrap();
2331
    assert_eq!((top.change_id, top.deps.clone()), (id2, vec![h1b]));
2332
    assert!(
2333
        matches!(
2334
            repo.verify_change(&heads[0]).unwrap(),
2335
            SignatureStatus::Valid { .. }
2336
        ),
2337
        "rewritten own change is re-signed"
2338
    );
2339
    let (_, wc) = repo.working_copy().unwrap();
2340
    assert_eq!(
2341
        wc.deps,
2342
        vec![heads[0]],
2343
        "the working copy follows the rewrite"
2344
    );
2345
    assert!(!repo.snapshot("status").unwrap().changed);
2346
    let snap = repo.load_state(&heads).unwrap().materialize();
2347
    assert!(
2348
        matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\n")
2349
    );
2350
2351
    // amend: fold an edit into the head; the result is one change from h1.
2352
    write(root, "a.txt", b"one\ntwo\nthree\n");
2353
    write(root, "b.txt", b"b\n");
2354
    let h2c = repo.amend(None).unwrap();
2355
    let c = repo.store().get_change(&h2c).unwrap();
2356
    assert_eq!(c.change_id, id2);
2357
    assert_eq!(c.message, "second");
2358
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h2c]);
2359
    assert_eq!(
2360
        repo.log().unwrap().len(),
2361
        3,
2362
        "working copy, amended head, first"
2363
    );
2364
    let snap = repo.load_state(&[h2c]).unwrap().materialize();
2365
    assert!(
2366
        matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\nthree\n")
2367
    );
2368
    assert!(snap.files.contains_key("b.txt"));
2369
    assert!(!repo.snapshot("status").unwrap().changed);
2370
    assert!(
2371
        repo.amend(None).is_err(),
2372
        "clean working copy and no message"
2373
    );
2374
    let h2d = repo.amend(Some("second, amended")).unwrap();
2375
    assert_eq!(
2376
        repo.store().get_change(&h2d).unwrap().message,
2377
        "second, amended"
2378
    );
2379
2380
    // abandon: the head goes, its content stays as uncommitted work.
2381
    repo.abandon(&h2d).unwrap();
2382
    assert_eq!(
2383
        repo.bookmark_heads().unwrap(),
2384
        vec![h1b],
2385
        "the described first change is the head again"
2386
    );
2387
    let s = repo.snapshot("status").unwrap();
2388
    let paths: Vec<_> = s
2389
        .statuses
2390
        .iter()
2391
        .map(|p| (p.path.as_str(), p.status))
2392
        .collect();
2393
    assert_eq!(
2394
        paths,
2395
        vec![
2396
            ("a.txt", ChangeStatus::Modified),
2397
            ("b.txt", ChangeStatus::Added)
2398
        ]
2399
    );
2400
    assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\ntwo\nthree\n");
2401
    let h3 = repo.commit("recommitted", &[]).unwrap();
2402
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h3]);
2403
    // undo brings the abandoned head back.
2404
    repo.undo().unwrap();
2405
    repo.undo().unwrap();
2406
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h2d]);
2407
2408
    // Abandoning a non-head whose dependent references its atoms reifies
2409
    // the references (here: a.txt vanishes with it, so the dependent's edit
2410
    // of a.txt is dropped with a note) instead of refusing; undo restores it.
2411
    let base = repo.bookmark_heads().unwrap()[0];
2412
    let base_change = repo.store().get_change(&base).unwrap();
2413
    let h1_now = base_change.deps[0];
2414
    let notes = repo.abandon(&h1_now).unwrap();
2415
    assert!(
2416
        !notes.is_empty(),
2417
        "the dependent's edit of a.txt was reified"
2418
    );
2419
    let heads = repo.bookmark_heads().unwrap();
2420
    let after = repo.load_state(&heads).unwrap().materialize();
2421
    assert!(
2422
        !after.files.contains_key("a.txt") && after.files.contains_key("b.txt"),
2423
        "{:?}",
2424
        after.files.keys()
2425
    );
2426
    repo.undo().unwrap();
2427
    assert_eq!(repo.bookmark_heads().unwrap(), vec![base]);
2428
    write(root, "c.txt", b"c\n");
2429
    let hc = repo.commit("c", &[]).unwrap();
2430
    write(root, "d.txt", b"d\n");
2431
    let hd = repo.commit("d", &[hc]).unwrap();
2432
    assert_eq!(repo.store().get_change(&hd).unwrap().deps, vec![hc]);
2433
    repo.abandon(&hc).unwrap();
2434
    let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect();
2435
    assert!(!heads.contains(&hc));
2436
    let d_now = heads
2437
        .iter()
2438
        .find(|h| repo.store().get_change(h).unwrap().message == "d")
2439
        .copied()
2440
        .unwrap();
2441
    assert!(
2442
        repo.store().get_change(&d_now).unwrap().deps.is_empty(),
2443
        "d inherited c's (empty) dependencies"
2444
    );
2445
    let s = repo.snapshot("status").unwrap();
2446
    assert_eq!(
2447
        s.statuses
2448
            .iter()
2449
            .map(|p| p.path.as_str())
2450
            .collect::<Vec<_>>(),
2451
        vec!["c.txt"],
2452
        "c's content is now uncommitted work"
2453
    );
2454
}
2455
2456
#[test]
2457
fn git_export_mirrors_history() {
2458
    hermetic();
2459
    if std::process::Command::new("git")
2460
        .arg("--version")
2461
        .output()
2462
        .is_err()
2463
    {
2464
        eprintln!("git not installed; skipping");
2465
        return;
2466
    }
2467
    let dir = tempfile::tempdir().unwrap();
2468
    let root = dir.path();
2469
    let mut repo = Repo::init(&root.join("r"))
2470
        .unwrap()
2471
        .with_home(root.join("home"));
2472
    repo.create_identity("Ada Lovelace", "[email protected]", IdentityScope::User)
2473
        .unwrap();
2474
    let r = root.join("r");
2475
    write(&r, "README.md", b"hello\n");
2476
    write(&r, "src/main.rs", b"fn main() {}\n");
2477
    let h1 = repo.commit("first", &[]).unwrap();
2478
    write(&r, "src/main.rs", b"fn main() { run(); }\n");
2479
    write(&r, "logo.png", b"\x89PNG\0\0");
2480
    let h2 = repo.commit("second", &[]).unwrap();
2481
2482
    let git_dir = root.join("mirror.git");
2483
    let rep = repo.git_export(&git_dir).unwrap();
2484
    assert_eq!((rep.exported, rep.skipped, rep.heads), (2, 0, 1));
2485
    let git = |args: &[&str]| -> String {
2486
        let out = std::process::Command::new("git")
2487
            .arg("-C")
2488
            .arg(&git_dir)
2489
            .args(args)
2490
            .output()
2491
            .unwrap();
2492
        assert!(
2493
            out.status.success(),
2494
            "{}",
2495
            String::from_utf8_lossy(&out.stderr)
2496
        );
2497
        String::from_utf8_lossy(&out.stdout).trim().to_string()
2498
    };
2499
    assert_eq!(git(&["log", "--format=%s", "main"]), "second\nfirst");
2500
    assert_eq!(git(&["show", "main:src/main.rs"]), "fn main() { run(); }");
2501
    assert_eq!(git(&["show", "main:README.md"]), "hello");
2502
    let body = git(&["log", "-1", "--format=%B", "main"]);
2503
    assert!(
2504
        body.contains(&format!("Oxy-Hash: {}", h2.to_hex())),
2505
        "{body}"
2506
    );
2507
    assert!(body.contains("Change-Id: "), "{body}");
2508
    assert_eq!(
2509
        git(&["log", "-1", "--format=%an <%ae>", "main"]),
2510
        "Ada Lovelace <[email protected]>"
2511
    );
2512
    assert_eq!(
2513
        git(&["ls-tree", "--name-only", "main"]),
2514
        "README.md\nlogo.png\nsrc"
2515
    );
2516
2517
    // Re-export is incremental.
2518
    let rep = repo.git_export(&git_dir).unwrap();
2519
    assert_eq!((rep.exported, rep.skipped), (0, 2));
2520
2521
    // An independent change makes two heads; the next state is one more
2522
    // commit on the linear branch whose tree is the union.
2523
    repo.checkout(&[h1], false, "checkout").unwrap();
2524
    write(&r, "other.txt", b"o\n");
2525
    let h3 = repo.commit("third", &[]).unwrap();
2526
    let rep = repo.git_export(&git_dir).unwrap();
2527
    assert_eq!((rep.exported, rep.heads), (1, 2));
2528
    assert_eq!(git(&["log", "--format=%s", "main"]), "third\nsecond\nfirst");
2529
    let parents = git(&["rev-list", "--parents", "-1", "main"]);
2530
    assert_eq!(parents.split(' ').count(), 2, "one parent: {parents}");
2531
    assert_eq!(git(&["show", "main:other.txt"]), "o");
2532
    assert_eq!(git(&["show", "main:src/main.rs"]), "fn main() { run(); }");
2533
    assert_eq!(
2534
        git(&["ls-tree", "--name-only", "main"]),
2535
        "README.md\nlogo.png\nother.txt\nsrc"
2536
    );
2537
    let body = git(&["log", "-1", "--format=%B", "main"]);
2538
    assert!(
2539
        body.contains(&format!("Oxy-Hash: {}", h3.to_hex())) && body.contains("Oxy-State: "),
2540
        "{body}"
2541
    );
2542
    // A rewrite is a new state whose one new change is the rewritten one.
2543
    repo.describe(&h3, "third, described").unwrap();
2544
    let rep = repo.git_export(&git_dir).unwrap();
2545
    assert_eq!(rep.exported, 1);
2546
    assert_eq!(
2547
        git(&["log", "-1", "--format=%s", "main"]),
2548
        "third, described"
2549
    );
2550
    assert_eq!(git(&["log", "--format=%s", "main"]).lines().count(), 4);
2551
    let _ = h2;
2552
}
2553
2554
#[test]
2555
fn git_import_reproduces_trees() {
2556
    git_import_reproduces_trees_with(None);
2557
}
2558
2559
/// The same import with every merge rendered in batches of one file
2560
/// (oxygen#25): what a merge onto a base of thousands of files takes.
2561
#[test]
2562
fn git_import_reproduces_trees_in_batches() {
2563
    git_import_reproduces_trees_with(Some(1));
2564
}
2565
2566
fn git_import_reproduces_trees_with(batch: Option<usize>) {
2567
    crate::repo::git::set_merge_batch_for_tests(batch);
2568
    hermetic();
2569
    if std::process::Command::new("git")
2570
        .arg("--version")
2571
        .output()
2572
        .is_err()
2573
    {
2574
        eprintln!("git not installed; skipping");
2575
        return;
2576
    }
2577
    let dir = tempfile::tempdir().unwrap();
2578
    let root = dir.path();
2579
    let g = root.join("src.git");
2580
    fs::create_dir_all(&g).unwrap();
2581
    let git = |args: &[&str]| -> String {
2582
        let out = std::process::Command::new("git")
2583
            .arg("-C")
2584
            .arg(&g)
2585
            .args([
2586
                "-c",
2587
                "user.name=Grace",
2588
                "-c",
2589
                "[email protected]",
2590
                "-c",
2591
                "commit.gpgsign=false",
2592
            ])
2593
            .args(args)
2594
            .output()
2595
            .unwrap();
2596
        assert!(
2597
            out.status.success(),
2598
            "git {args:?}: {}",
2599
            String::from_utf8_lossy(&out.stderr)
2600
        );
2601
        String::from_utf8_lossy(&out.stdout).trim().to_string()
2602
    };
2603
    git(&["init", "-q", "-b", "main"]);
2604
    write(&g, "README.md", b"# Hi\n");
2605
    write(&g, "src/lib.rs", b"pub fn a() {}\n");
2606
    git(&["add", "."]);
2607
    git(&["commit", "-q", "-m", "initial"]);
2608
    write(&g, "src/lib.rs", b"pub fn a() {}\npub fn b() {}\n");
2609
    write(&g, "run.sh", b"#!/bin/sh\n");
2610
    #[cfg(unix)]
2611
    {
2612
        use std::os::unix::fs::PermissionsExt;
2613
        fs::set_permissions(g.join("run.sh"), fs::Permissions::from_mode(0o755)).unwrap();
2614
    }
2615
    write(&g, "logo.png", b"\x89PNG\0\0\x01");
2616
    git(&["add", "."]);
2617
    git(&[
2618
        "commit",
2619
        "-q",
2620
        "-m",
2621
        "second\n\nChange-Id: 0123456789abcdef0123456789abcdef",
2622
    ]);
2623
    // A branch that edits README, merged back with a conflict-free merge.
2624
    git(&["checkout", "-q", "-b", "topic", "HEAD~1"]);
2625
    write(&g, "README.md", b"# Hi\n\nMore.\n");
2626
    git(&["commit", "-q", "-am", "readme"]);
2627
    git(&["checkout", "-q", "main"]);
2628
    git(&["merge", "-q", "--no-ff", "-m", "merge topic", "topic"]);
2629
    // A branch from the initial commit inserting at the same place as
2630
    // "second": concurrent in Oxygen too, so the merge commit is a resolution.
2631
    let initial = git(&["rev-list", "--max-parents=0", "HEAD"]);
2632
    git(&["checkout", "-q", "-b", "clash", &initial]);
2633
    write(&g, "src/lib.rs", b"pub fn a() {}\npub fn c() {}\n");
2634
    git(&["commit", "-q", "-am", "clash"]);
2635
    git(&["checkout", "-q", "main"]);
2636
    let _ = std::process::Command::new("git")
2637
        .arg("-C")
2638
        .arg(&g)
2639
        .args(["merge", "-q", "clash"])
2640
        .output();
2641
    write(
2642
        &g,
2643
        "src/lib.rs",
2644
        b"pub fn a() {}\npub fn b() {}\npub fn c() {}\n",
2645
    );
2646
    git(&["add", "."]);
2647
    git(&["commit", "-q", "-m", "resolve clash"]);
2648
    git(&["rm", "-q", "logo.png"]);
2649
    git(&["commit", "-q", "-m", "drop logo"]);
2650
    // A rename with a small edit is imported as a move of the same node.
2651
    git(&["mv", "src/lib.rs", "src/lib2.rs"]);
2652
    write(
2653
        &g,
2654
        "src/lib2.rs",
2655
        b"pub fn a() {}\npub fn b() {}\npub fn c() {}\npub fn d() {}\n",
2656
    );
2657
    git(&["add", "."]);
2658
    git(&["commit", "-q", "-m", "rename lib"]);
2659
    // A branch that edits run.sh, merged keeping main's tree (`-s ours`):
2660
    // run.sh is the same as in the first parent's tree, but the merged
2661
    // change touched it, so the importer must compare it, not skip it.
2662
    git(&["checkout", "-q", "-b", "discarded"]);
2663
    write(&g, "run.sh", b"#!/bin/sh\necho discarded\n");
2664
    git(&["commit", "-q", "-am", "discarded edit"]);
2665
    git(&["checkout", "-q", "main"]);
2666
    git(&[
2667
        "merge",
2668
        "-q",
2669
        "-s",
2670
        "ours",
2671
        "-m",
2672
        "merge discarded, keeping ours",
2673
        "discarded",
2674
    ]);
2675
    let expected_count: usize = git(&["rev-list", "--count", "main"]).parse().unwrap();
2676
2677
    let dest = root.join("oxy");
2678
    let repo = Repo::init(&dest).unwrap().with_home(root.join("home"));
2679
    let rep = repo.import_git(&g, "main").unwrap();
2680
    assert_eq!(rep.commits, expected_count);
2681
    assert_eq!(rep.merges, 3);
2682
    assert_eq!(
2683
        fs::read(dest.join("run.sh")).unwrap(),
2684
        b"#!/bin/sh\n",
2685
        "the ours merge kept main's run.sh"
2686
    );
2687
    let v = repo.verify_git_import(&g, "main").unwrap();
2688
    assert_eq!(v.commits, expected_count, "every commit's tree reproduced");
2689
    assert!(v.mismatches.is_empty(), "{:?}", v.mismatches);
2690
    // The working copy equals the Git tree at main.
2691
    assert_eq!(
2692
        fs::read(dest.join("src/lib2.rs")).unwrap(),
2693
        b"pub fn a() {}\npub fn b() {}\npub fn c() {}\npub fn d() {}\n"
2694
    );
2695
    assert!(!dest.join("src/lib.rs").exists());
2696
    assert_eq!(
2697
        fs::read(dest.join("README.md")).unwrap(),
2698
        b"# Hi\n\nMore.\n"
2699
    );
2700
    assert!(!dest.join("logo.png").exists());
2701
    #[cfg(unix)]
2702
    {
2703
        use std::os::unix::fs::PermissionsExt;
2704
        assert!(
2705
            fs::metadata(dest.join("run.sh"))
2706
                .unwrap()
2707
                .permissions()
2708
                .mode()
2709
                & 0o111
2710
                != 0
2711
        );
2712
    }
2713
    assert!(
2714
        !repo.snapshot("status").unwrap().changed,
2715
        "clean after import"
2716
    );
2717
    let heads = repo.bookmark_heads().unwrap();
2718
    let st = repo.load_state(&heads).unwrap();
2719
    assert!(!st.materialize().is_conflicted());
2720
    assert_eq!(st.len(), expected_count);
2721
    let tip = repo
2722
        .log()
2723
        .unwrap()
2724
        .into_iter()
2725
        .find(|e| e.change.message.starts_with("rename lib"))
2726
        .unwrap();
2727
    let c = repo.store().get_change(&tip.hash).unwrap();
2728
    assert!(
2729
        c.tree_ops
2730
            .iter()
2731
            .any(|op| matches!(op, TreeOp::Move { name, .. } if name == "lib2.rs")),
2732
        "{:?}",
2733
        c.tree_ops
2734
    );
2735
    assert!(!c
2736
        .tree_ops
2737
        .iter()
2738
        .any(|op| matches!(op, TreeOp::AddNode { .. })));
2739
    // Messages carry Git trailers; the Change-Id trailer was honoured.
2740
    let log = repo.log().unwrap();
2741
    let second = log
2742
        .iter()
2743
        .find(|e| e.change.message.starts_with("second"))
2744
        .unwrap();
2745
    assert_eq!(
2746
        second.change.change_id,
2747
        ChangeId::from_hex("0123456789abcdef0123456789abcdef").unwrap()
2748
    );
2749
    assert!(second
2750
        .change
2751
        .message
2752
        .contains("Git-Author: Grace <[email protected]>"));
2753
    let resolve = log
2754
        .iter()
2755
        .find(|e| e.change.message.starts_with("resolve clash"))
2756
        .unwrap();
2757
    assert_eq!(
2758
        resolve.change.kind,
2759
        ChangeKind::Resolution,
2760
        "a conflicting Git merge becomes a resolution"
2761
    );
2762
    // Re-importing into another fresh repository gives the same ChangeIds.
2763
    let repo2 = Repo::init(&root.join("oxy2"))
2764
        .unwrap()
2765
        .with_home(root.join("home"));
2766
    repo2.import_git(&g, "main").unwrap();
2767
    // New nodes' identifiers derive from the commit (spec §23), so the
2768
    // re-import is not just the same ChangeIds but the same changes.
2769
    assert_eq!(
2770
        repo2.bookmark_heads().unwrap(),
2771
        repo.bookmark_heads().unwrap()
2772
    );
2773
    let ids: BTreeSet<ChangeId> = repo
2774
        .log()
2775
        .unwrap()
2776
        .iter()
2777
        .filter(|e| !e.is_working_copy)
2778
        .map(|e| e.change.change_id)
2779
        .collect();
2780
    let ids2: BTreeSet<ChangeId> = repo2
2781
        .log()
2782
        .unwrap()
2783
        .iter()
2784
        .filter(|e| !e.is_working_copy)
2785
        .map(|e| e.change.change_id)
2786
        .collect();
2787
    assert_eq!(ids, ids2);
2788
    // Round trip back out to Git reproduces the tip tree.
2789
    let mirror = root.join("mirror.git");
2790
    repo.git_export(&mirror).unwrap();
2791
    let out = std::process::Command::new("git")
2792
        .arg("-C")
2793
        .arg(&mirror)
2794
        .args(["show", "main:src/lib2.rs"])
2795
        .output()
2796
        .unwrap();
2797
    assert_eq!(
2798
        String::from_utf8_lossy(&out.stdout),
2799
        "pub fn a() {}\npub fn b() {}\npub fn c() {}\npub fn d() {}\n"
2800
    );
2801
    let out = std::process::Command::new("git")
2802
        .arg("-C")
2803
        .arg(&mirror)
2804
        .args(["show", "main:src/lib.rs"])
2805
        .output()
2806
        .unwrap();
2807
    assert!(
2808
        !out.status.success(),
2809
        "the old path is gone from the export"
2810
    );
2811
}
2812
2813
#[test]
2814
fn checkpoints_accelerate_loading_and_stay_correct() {
2815
    hermetic();
2816
    let dir = tempfile::tempdir().unwrap();
2817
    let root = dir.path();
2818
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
2819
    repo.config.set("core.checkpoint-interval", "5").unwrap();
2820
    repo.save_config().unwrap();
2821
    let repo = Repo::open(root).unwrap().with_home(root.join("home"));
2822
    let mut hashes = Vec::new();
2823
    for i in 0..12 {
2824
        write(
2825
            root,
2826
            "a.txt",
2827
            format!(
2828
                "{}\n",
2829
                (0..=i)
2830
                    .map(|k| k.to_string())
2831
                    .collect::<Vec<_>>()
2832
                    .join("\n")
2833
            )
2834
            .as_bytes(),
2835
        );
2836
        write(root, &format!("f{i}.txt"), b"x\n");
2837
        hashes.push(repo.commit(&format!("c{i}"), &[]).unwrap());
2838
    }
2839
    let (_, cp) = repo
2840
        .latest_checkpoint("main")
2841
        .unwrap()
2842
        .expect("automatic checkpoint");
2843
    assert!(cp.count >= 5 && cp.count <= 12, "{}", cp.count);
2844
    // Loading through the checkpoint equals loading everything.
2845
    let heads = repo.bookmark_heads().unwrap();
2846
    let fast = repo.load_state(&heads).unwrap();
2847
    let full = repo.load_state_full(&heads).unwrap();
2848
    assert_eq!(fast.len(), full.len());
2849
    assert_eq!(fast.heads(), full.heads());
2850
    let (a, b) = (fast.materialize(), full.materialize());
2851
    assert_eq!(
2852
        a.files.keys().collect::<Vec<_>>(),
2853
        b.files.keys().collect::<Vec<_>>()
2854
    );
2855
    for (p, f) in &a.files {
2856
        assert_eq!(f.content, b.files[p].content, "{p}");
2857
    }
2858
    assert!(repo.load_state_checkpointed(&heads).unwrap().is_some());
2859
    // A head set that does not contain the checkpoint's heads falls back.
2860
    assert!(repo
2861
        .load_state_checkpointed(&[hashes[0]])
2862
        .unwrap()
2863
        .is_none());
2864
    assert_eq!(repo.load_state(&[hashes[0]]).unwrap().len(), 1);
2865
    // Everything else keeps working on top of the checkpoint.
2866
    assert!(!repo.snapshot("status").unwrap().changed);
2867
    let h = repo.describe(&hashes[3], "c3 described").unwrap();
2868
    assert_eq!(repo.store().get_change(&h).unwrap().message, "c3 described");
2869
    let (r, n, bytes) = repo.checkpoint().unwrap();
2870
    assert!(n >= 12 && bytes > 0);
2871
    assert_eq!(repo.latest_checkpoint("main").unwrap().unwrap().0, r);
2872
    write(root, "a.txt", b"after\n");
2873
    let h_after = repo.commit("after", &[]).unwrap();
2874
    let st = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap();
2875
    assert!(st.contains(&h_after));
2876
    assert!(
2877
        matches!(&st.materialize().files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"after\n")
2878
    );
2879
    assert_eq!(repo.log().unwrap().len(), 14);
2880
    // Checkpoints survive gc.
2881
    repo.gc(true).unwrap();
2882
    assert!(repo.latest_checkpoint("main").unwrap().is_some());
2883
    assert!(repo
2884
        .load_state_checkpointed(&repo.bookmark_heads().unwrap())
2885
        .unwrap()
2886
        .is_some());
2887
}
2888
2889
#[test]
2890
fn quick_status_for_prompts() {
2891
    hermetic();
2892
    let dir = tempfile::tempdir().unwrap();
2893
    let root = dir.path();
2894
    let origin = Repo::init(&root.join("origin")).unwrap();
2895
    let url = root.join("origin").to_string_lossy().to_string();
2896
    let a = Repo::clone(&url, &root.join("a"))
2897
        .unwrap()
2898
        .with_home(root.join("home"));
2899
    let ops_before = a.op_log().unwrap().len();
2900
    let info = a.quick_status().unwrap();
2901
    assert_eq!(
2902
        (
2903
            info.bookmark.as_str(),
2904
            info.heads,
2905
            info.dirty,
2906
            info.conflicted,
2907
            info.ahead,
2908
            info.behind
2909
        ),
2910
        ("main", 0, false, false, Some(0), Some(0))
2911
    );
2912
    write(&root.join("a"), "f.txt", b"f\n");
2913
    assert!(a.quick_status().unwrap().dirty, "a new file is dirty");
2914
    assert_eq!(
2915
        a.op_log().unwrap().len(),
2916
        ops_before,
2917
        "quick_status records nothing"
2918
    );
2919
    // A snapshotted but uncommitted change is still dirty.
2920
    a.snapshot("status").unwrap();
2921
    assert!(a.quick_status().unwrap().dirty);
2922
    a.commit("f", &[]).unwrap();
2923
    let info = a.quick_status().unwrap();
2924
    assert!(!info.dirty, "clean right after a commit");
2925
    assert_eq!((info.ahead, info.behind), (Some(1), Some(0)));
2926
    assert_eq!(
2927
        info.format("%b %c%d%x%A%D"),
2928
        format!("main {}+1", info.change_id)
2929
    );
2930
    assert_eq!(info.format("[%h heads] %a/%B %%"), "[1 heads] 1/0 %");
2931
    // Touching a file without changing it is not dirty; changing content of
2932
    // the same size is.
2933
    let p = root.join("a/f.txt");
2934
    let content = fs::read(&p).unwrap();
2935
    fs::write(&p, &content).unwrap();
2936
    assert!(!a.quick_status().unwrap().dirty);
2937
    fs::write(&p, b"g\n").unwrap();
2938
    assert!(a.quick_status().unwrap().dirty);
2939
    fs::write(&p, b"f\n").unwrap();
2940
    // Push, then a second clone commits and pushes: we are behind.
2941
    assert!(a.push("origin", false).unwrap()[0].ok);
2942
    assert_eq!(a.quick_status().unwrap().ahead, Some(0));
2943
    let b = Repo::clone(&url, &root.join("b"))
2944
        .unwrap()
2945
        .with_home(root.join("home-b"));
2946
    write(&root.join("b"), "g.txt", b"g\n");
2947
    b.commit("g", &[]).unwrap();
2948
    b.push("origin", false).unwrap();
2949
    a.fetch("origin").unwrap();
2950
    let info = a.quick_status().unwrap();
2951
    assert_eq!((info.ahead, info.behind), (Some(0), Some(1)));
2952
    assert_eq!(info.format("%A%D"), "-1");
2953
    let _ = origin;
2954
}
2955
2956
#[test]
2957
fn lazy_blobs_are_fetched_on_first_read() {
2958
    hermetic();
2959
    let dir = tempfile::tempdir().unwrap();
2960
    let root = dir.path();
2961
    let origin_dir = root.join("origin");
2962
    let origin = Repo::init(&origin_dir)
2963
        .unwrap()
2964
        .with_home(root.join("home"));
2965
    let url = origin_dir.to_string_lossy().to_string();
2966
    let big: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect(); // has NUL bytes: opaque
2967
    write(&origin_dir, "big.bin", &big);
2968
    write(&origin_dir, "small.bin", &[0u8, 1, 2]);
2969
    write(&origin_dir, "t.txt", b"t\n");
2970
    let h = origin.commit("blobs", &[]).unwrap();
2971
    let big_ref = Repo::blob_ref(&big);
2972
2973
    // Fetch only (no checkout) with a filter: the big blob stays absent.
2974
    let a = Repo::init_with_id(&root.join("a"), origin.id().unwrap())
2975
        .unwrap()
2976
        .with_home(root.join("home"));
2977
    let a = {
2978
        let mut a = a;
2979
        a.config.set("remote.origin.url", &url).unwrap();
2980
        a.config.set("filter.max-blob", "100K").unwrap();
2981
        a.save_config().unwrap();
2982
        a
2983
    };
2984
    assert_eq!(a.config.max_blob, Some(100 * 1024));
2985
    a.fetch("origin").unwrap();
2986
    assert!(a.store().has(&h));
2987
    assert!(!a.store().has(&big_ref), "withheld");
2988
    assert!(
2989
        a.store().has(&Repo::blob_ref(&[0u8, 1, 2])),
2990
        "small blob came along"
2991
    );
2992
    assert_eq!(a.lazy_remote(&big_ref).as_deref(), Some("origin"));
2993
2994
    // status and commit do not need it: adopt the state, edit a text file, commit.
2995
    let (op_ref, op) = a.head_op().unwrap();
2996
    let mut view = op.view.clone();
2997
    view.bookmarks.insert(
2998
        "main".into(),
2999
        a.store().put_state(&StateObject::new(vec![h])).unwrap(),
3000
    );
3001
    a.record_op(&[op_ref], "adopt", view).unwrap();
3002
    // Checkout reads the blob lazily, which fetches it.
3003
    a.checkout(&[h], true, "checkout").unwrap();
3004
    assert!(a.store().has(&big_ref), "fetched on first read");
3005
    assert_eq!(fs::read(root.join("a/big.bin")).unwrap(), big);
3006
    assert!(a.lazy_remote(&big_ref).is_none(), "no longer registered");
3007
    write(&root.join("a"), "t.txt", b"t2\n");
3008
    a.commit("text edit", &[]).unwrap();
3009
3010
    // clone --max-blob records the filter; the checkout fetches lazily.
3011
    let b = Repo::clone_with(&url, &root.join("b"), Some(1024), &[]).unwrap();
3012
    assert_eq!(b.config.max_blob, Some(1024));
3013
    assert_eq!(fs::read(root.join("b/big.bin")).unwrap(), big);
3014
    assert_eq!(parse_size("2M").unwrap(), 2 << 20);
3015
    assert!(parse_size("lots").is_err());
3016
}
3017
3018
#[test]
3019
fn sparse_checkout_materializes_only_chosen_paths() {
3020
    hermetic();
3021
    let dir = tempfile::tempdir().unwrap();
3022
    let root = dir.path();
3023
    let origin_dir = root.join("origin");
3024
    let origin = Repo::init(&origin_dir)
3025
        .unwrap()
3026
        .with_home(root.join("home"));
3027
    write(&origin_dir, "src/main.rs", b"fn main() {}\n");
3028
    write(&origin_dir, "src/lib.rs", b"pub fn a() {}\n");
3029
    write(&origin_dir, "docs/guide.md", b"# guide\n");
3030
    let logo: Vec<u8> = (0..50_000u32).map(|i| (i % 7) as u8).collect();
3031
    write(&origin_dir, "docs/logo.png", &logo);
3032
    write(&origin_dir, "README.md", b"# top\n");
3033
    let h1 = origin.commit("tree", &[]).unwrap();
3034
    let url = origin_dir.to_string_lossy().to_string();
3035
3036
    let a = Repo::clone_with(&url, &root.join("a"), None, &["src".into()])
3037
        .unwrap()
3038
        .with_home(root.join("home"));
3039
    assert_eq!(a.config.sparse_paths, vec!["src"]);
3040
    assert_eq!(a.config.max_blob, Some(0), "sparse implies lazy blobs");
3041
    assert!(root.join("a/src/main.rs").exists());
3042
    assert!(!root.join("a/docs").exists() && !root.join("a/README.md").exists());
3043
    assert!(
3044
        !a.store().has(&Repo::blob_ref(&logo)),
3045
        "out-of-scope blob never fetched"
3046
    );
3047
    assert!(
3048
        !a.snapshot("status").unwrap().changed,
3049
        "out-of-scope files are not removals"
3050
    );
3051
    assert!(!a.quick_status().unwrap().dirty);
3052
3053
    // Edits in scope commit normally and keep the rest of the tree intact.
3054
    write(
3055
        &root.join("a"),
3056
        "src/lib.rs",
3057
        b"pub fn a() {}\npub fn b() {}\n",
3058
    );
3059
    let h2 = a.commit("edit lib", &[]).unwrap();
3060
    let st = a.load_state(&[h2]).unwrap().materialize();
3061
    assert!(
3062
        st.files.contains_key("docs/guide.md"),
3063
        "the state still has everything"
3064
    );
3065
    assert!(a.push("origin", false).unwrap()[0].ok);
3066
    origin.refresh().unwrap();
3067
    // Files created outside the scope are ignored by status.
3068
    write(&root.join("a"), "docs/new.md", b"ignored\n");
3069
    assert!(a.snapshot("status").unwrap().statuses.is_empty());
3070
3071
    // Widening the filter checks out the rest; narrowing leaves stray files alone.
3072
    let mut a = a;
3073
    a.config.set("filter.paths", "src,docs").unwrap();
3074
    a.save_config().unwrap();
3075
    let r = a
3076
        .checkout(&a.bookmark_heads().unwrap(), true, "widen")
3077
        .unwrap();
3078
    assert!(r.written >= 2);
3079
    assert_eq!(
3080
        fs::read(root.join("a/docs/logo.png")).unwrap(),
3081
        logo,
3082
        "now fetched lazily"
3083
    );
3084
    assert!(!root.join("a/README.md").exists());
3085
    // Origin sees the change made under a sparse checkout with the full tree intact.
3086
    origin.pull("origin").err();
3087
    let full = origin
3088
        .load_state(&origin.bookmark_heads().unwrap())
3089
        .unwrap()
3090
        .materialize();
3091
    assert!(
3092
        matches!(&full.files["src/lib.rs"].content, FileContent::Text { bytes, .. } if bytes == b"pub fn a() {}\npub fn b() {}\n")
3093
    );
3094
    assert!(full.files.contains_key("README.md"));
3095
    let _ = h1;
3096
}
3097
3098
/// #137: a depth-0 clone takes no change, so the blobs of the state's
3099
/// binary files came with nothing and the checkout failed ("object … not
3100
/// found"); they are fetched with the checkpoint, or left lazy under
3101
/// `--max-blob`.
3102
#[test]
3103
fn a_depth_0_clone_brings_its_binary_files() {
3104
    hermetic();
3105
    let dir = tempfile::tempdir().unwrap();
3106
    let root = dir.path();
3107
    let origin_dir = root.join("origin");
3108
    let origin = Repo::init(&origin_dir)
3109
        .unwrap()
3110
        .with_home(root.join("home"));
3111
    let png: Vec<u8> = (0..4000u32)
3112
        .map(|i| (i * 7 % 251) as u8)
3113
        .chain([0, 0xff])
3114
        .collect();
3115
    // The logo comes with a.txt's first line, and the head edits that line,
3116
    // so the change that set the logo is below the head, not a head itself.
3117
    write(&origin_dir, "logo.png", &png);
3118
    write(&origin_dir, "a.txt", b"first\n");
3119
    origin.commit("logo", &[]).unwrap();
3120
    write(&origin_dir, "a.txt", b"later\n");
3121
    origin.commit("later", &[]).unwrap();
3122
    assert_eq!(origin.bookmark_heads().unwrap().len(), 1);
3123
    let url = origin_dir.to_string_lossy().to_string();
3124
    Repo::clone_shallow(&url, &root.join("a"), 0, None, &[]).unwrap();
3125
    assert_eq!(fs::read(root.join("a/logo.png")).unwrap(), png);
3126
    let lazy = Repo::clone_shallow(&url, &root.join("b"), 0, Some(0), &[]).unwrap();
3127
    assert_eq!(fs::read(root.join("b/a.txt")).unwrap(), b"later\n");
3128
    let blob = {
3129
        let heads = lazy.bookmark_heads().unwrap();
3130
        let st = lazy.load_state_history(&heads).unwrap();
3131
        st.opaque_nodes()
3132
            .iter()
3133
            .flat_map(|n| st.live_blobs(n))
3134
            .next()
3135
            .unwrap()
3136
    };
3137
    assert_eq!(lazy.read_blob(&blob).unwrap(), png, "fetched on read");
3138
}
3139
3140
#[test]
3141
fn shallow_clone_and_deepen() {
3142
    hermetic();
3143
    let dir = tempfile::tempdir().unwrap();
3144
    let root = dir.path();
3145
    let origin_dir = root.join("origin");
3146
    let origin = Repo::init(&origin_dir)
3147
        .unwrap()
3148
        .with_home(root.join("home"));
3149
    let mut hashes = Vec::new();
3150
    for i in 0..8 {
3151
        write(
3152
            &origin_dir,
3153
            "a.txt",
3154
            format!(
3155
                "{}\n",
3156
                (0..=i)
3157
                    .map(|k| k.to_string())
3158
                    .collect::<Vec<_>>()
3159
                    .join("\n")
3160
            )
3161
            .as_bytes(),
3162
        );
3163
        hashes.push(origin.commit(&format!("c{i}"), &[]).unwrap());
3164
    }
3165
    let url = origin_dir.to_string_lossy().to_string();
3166
    let a = Repo::clone_shallow(&url, &root.join("a"), 3, None, &[])
3167
        .unwrap()
3168
        .with_home(root.join("home"));
3169
    assert!(a.is_shallow());
3170
    assert_eq!(
3171
        fs::read_to_string(root.join("a/a.txt")).unwrap(),
3172
        "0\n1\n2\n3\n4\n5\n6\n7\n"
3173
    );
3174
    assert!(
3175
        a.store().has(&hashes[7]) && a.store().has(&hashes[5]),
3176
        "three levels present"
3177
    );
3178
    assert!(!a.store().has(&hashes[4]), "beyond the depth is absent");
3179
    assert_eq!(a.boundary(), [hashes[4]].into_iter().collect());
3180
    assert_eq!(
3181
        a.log().unwrap().len(),
3182
        9,
3183
        "the log shows every change from the checkpoint's metadata"
3184
    );
3185
    assert!(!a.snapshot("status").unwrap().changed);
3186
    assert_eq!(a.quick_status().unwrap().heads, 1);
3187
3188
    // Work continues on top of the shallow history and pushes back.
3189
    write(&root.join("a"), "a.txt", b"0\n1\n2\n3\n4\n5\n6\n7\n8\n");
3190
    let h_new = a.commit("c8", &[]).unwrap();
3191
    assert!(a.push("origin", false).unwrap()[0].ok);
3192
    origin.refresh().unwrap();
3193
    assert_eq!(origin.bookmark_heads().unwrap(), vec![h_new]);
3194
    // Fetching new work from origin into the shallow clone works too.
3195
    write(&origin_dir, "b.txt", b"b\n");
3196
    origin.pull("origin").err();
3197
    let (_, wc) = origin.working_copy().unwrap();
3198
    let _ = wc;
3199
    origin
3200
        .checkout(&origin.bookmark_heads().unwrap(), true, "co")
3201
        .unwrap();
3202
    write(&origin_dir, "b.txt", b"b\n");
3203
    let h_b = origin.commit("b", &[]).unwrap();
3204
    let pr = a.pull("origin").unwrap();
3205
    assert!(pr.changed);
3206
    assert!(a.store().has(&h_b));
3207
    assert!(root.join("a/b.txt").exists());
3208
3209
    // Deepening fetches older history until the boundary is gone.
3210
    // Every commit also depends on the change that created the file, so two
3211
    // levels below the boundary hold h4, h3, and the root.
3212
    assert!(a.deepen("origin", 2).unwrap() >= 2);
3213
    assert!(a.store().has(&hashes[3]));
3214
    assert!(a.is_shallow());
3215
    a.deepen("origin", 100).unwrap();
3216
    assert!(!a.is_shallow(), "boundary reached the root");
3217
    assert!(a.store().has(&hashes[0]));
3218
    assert_eq!(
3219
        a.load_state_full(&a.bookmark_heads().unwrap())
3220
            .unwrap()
3221
            .len(),
3222
        10
3223
    );
3224
}
3225
3226
#[test]
3227
fn tags_are_immutable_named_states_and_travel() {
3228
    hermetic();
3229
    let dir = tempfile::tempdir().unwrap();
3230
    let root = dir.path();
3231
    let origin = Repo::init(&root.join("origin")).unwrap();
3232
    let url = root.join("origin").to_string_lossy().to_string();
3233
    let mut a = Repo::clone(&url, &root.join("a"))
3234
        .unwrap()
3235
        .with_home(root.join("home-a"));
3236
    let (key_a, _) = a
3237
        .create_identity("Ada", "[email protected]", IdentityScope::User)
3238
        .unwrap();
3239
    write(&root.join("a"), "f.txt", b"1\n");
3240
    let h1 = a.commit("one", &[]).unwrap();
3241
    write(&root.join("a"), "f.txt", b"1\n2\n");
3242
    let h2 = a.commit("two", &[]).unwrap();
3243
3244
    let t = a.tag_create("v1", &[h1], "first release").unwrap();
3245
    assert_eq!(
3246
        (t.heads.clone(), t.signer.as_deref()),
3247
        (vec![h1], Some("Ada"))
3248
    );
3249
    assert!(a.tag_create("v1", &[h2], "").is_err(), "immutable");
3250
    assert!(a.tag_create("bad/name", &[h2], "").is_err());
3251
    assert_eq!(a.tag_heads("v1").unwrap(), Some(vec![h1]));
3252
    assert_eq!(a.resolve_heads("v1").unwrap(), vec![h1]);
3253
    assert_eq!(a.resolve_heads("main").unwrap(), vec![h2]);
3254
    let (_, sa) = a.tag_attest("v1").unwrap().unwrap();
3255
    assert_eq!(sa.sigs[0].key, key_a);
3256
    // Without a policy every client is sent every tag, whatever features
3257
    // it declares (oxygen#131).
3258
    let mut refs = a.remote_refs().unwrap();
3259
    a.refs_for_client(&mut refs, None);
3260
    assert_eq!(refs.tags.len(), 1);
3261
    // Checkout by tag.
3262
    a.checkout(&a.resolve_heads("v1").unwrap(), false, "co")
3263
        .unwrap();
3264
    assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"1\n");
3265
    a.checkout(&a.bookmark_heads().unwrap(), false, "co")
3266
        .unwrap();
3267
3268
    // Push carries the tag; a second attestation for the name is refused.
3269
    let r = a.push("origin", false).unwrap();
3270
    origin.refresh().unwrap();
3271
    assert!(r.iter().any(|x| x.name == "tags/v1" && x.ok), "{r:?}");
3272
    assert_eq!(origin.tag_heads("v1").unwrap(), Some(vec![h1]));
3273
    let mut forged = a
3274
        .tag_refs()
3275
        .unwrap()
3276
        .into_iter()
3277
        .find(|t| t.name == "v1")
3278
        .unwrap();
3279
    forged.state = origin
3280
        .store()
3281
        .put_state(&StateObject::new(vec![h2]))
3282
        .unwrap();
3283
    forged.heads = vec![h2];
3284
    forged.attestation = None;
3285
    let r = origin
3286
        .receive_push(Bundle::default(), vec![], None, vec![forged])
3287
        .unwrap();
3288
    assert!(!r[0].ok && r[0].reason.contains("immutable"), "{r:?}");
3289
3290
    // A clone gets the tag; a later tag arrives on fetch.
3291
    let b = Repo::clone(&url, &root.join("b"))
3292
        .unwrap()
3293
        .with_home(root.join("home-b"));
3294
    assert_eq!(b.tag_heads("v1").unwrap(), Some(vec![h1]));
3295
    assert!(b.tags().unwrap()[0].signer.as_deref() == Some("Ada"));
3296
    a.tag_create("v2", &[h2], "second").unwrap();
3297
    a.push("origin", false).unwrap();
3298
    origin.refresh().unwrap();
3299
    let rep = b.fetch("origin").unwrap();
3300
    assert_eq!(rep.new_tags, 1);
3301
    assert_eq!(b.tag_heads("v2").unwrap(), Some(vec![h2]));
3302
3303
    // Under a policy, an unattested tag is refused and an attested one by the root passes.
3304
    a.policy_init().unwrap();
3305
    a.push("origin", false).unwrap();
3306
    origin.refresh().unwrap();
3307
    let unattested = crate::protocol::TagRef::new(
3308
        "v3".into(),
3309
        origin
3310
            .store()
3311
            .put_state(&StateObject::new(vec![h2]))
3312
            .unwrap(),
3313
        vec![h2],
3314
        None,
3315
    );
3316
    let r = origin
3317
        .receive_push(Bundle::default(), vec![], None, vec![unattested])
3318
        .unwrap();
3319
    assert!(!r[0].ok && r[0].reason.contains("attestation"), "{r:?}");
3320
    a.tag_create("v3", &[h2], "").unwrap();
3321
    let r = a.push("origin", false).unwrap();
3322
    origin.refresh().unwrap();
3323
    assert!(r.iter().any(|x| x.name == "tags/v3" && x.ok), "{r:?}");
3324
    assert!(a.gc(true).is_ok());
3325
    assert_eq!(a.tags().unwrap().len(), 3, "tags survive gc");
3326
3327
    // After a rotation the policy names only the new key, yet a fresh clone
3328
    // still takes the tags the old key made before it; the old key makes
3329
    // nothing new.
3330
    let rot = a.rotate_identity().unwrap();
3331
    let r = a.push("origin", false).unwrap();
3332
    origin.refresh().unwrap();
3333
    assert!(r.iter().all(|x| x.ok), "{r:?}");
3334
    let c = Repo::clone(&url, &root.join("c"))
3335
        .unwrap()
3336
        .with_home(root.join("home-c"));
3337
    assert_eq!(c.tag_heads("v3").unwrap(), Some(vec![h2]));
3338
    let (_, sp) = c.current_policy().unwrap().unwrap();
3339
    assert!(!sp.policy.authorizes(&key_a, "tags/v9"));
3340
    let made = c.identity(&rot.new).unwrap().created;
3341
    assert!(c.predecessor_authorized(&sp.policy, &key_a, "tags/v9", made - 1));
3342
    assert!(!c.predecessor_authorized(&sp.policy, &key_a, "tags/v9", made));
3343
    assert!(!c.predecessor_authorized(&sp.policy, &rot.new, "tags/v9", 0));
3344
}
3345
3346
#[test]
3347
fn multiple_workspaces_share_a_repository() {
3348
    hermetic();
3349
    let dir = tempfile::tempdir().unwrap();
3350
    let root = dir.path();
3351
    let main_dir = root.join("main");
3352
    let repo = Repo::init(&main_dir).unwrap().with_home(root.join("home"));
3353
    write(&main_dir, "a.txt", b"a\n");
3354
    let h1 = repo.commit("a", &[]).unwrap();
3355
    assert_eq!(repo.workspaces().unwrap().len(), 1);
3356
3357
    // A second workspace elsewhere, checked out on the bookmark.
3358
    let ws_dir = root.join("ws2");
3359
    let ops = repo.op_log().unwrap().len();
3360
    let ws = repo.workspace_add(&ws_dir, None).unwrap();
3361
    assert_eq!(
3362
        ws.op_log().unwrap().len(),
3363
        ops + 2,
3364
        "the add and its checkout, and no snapshot finding the tree deleted (oxygen#137)"
3365
    );
3366
    assert!(ws_dir.join(".oxy").is_file());
3367
    assert_eq!(fs::read(ws_dir.join("a.txt")).unwrap(), b"a\n");
3368
    assert_ne!(ws.workspace(), repo.workspace());
3369
    let list = repo.workspaces().unwrap();
3370
    assert_eq!(list.len(), 2);
3371
    assert!(list
3372
        .iter()
3373
        .any(|w| w.id == ws.workspace() && w.path == ws_dir.canonicalize().unwrap() && !w.current));
3374
    assert!(!ws.snapshot("status").unwrap().changed);
3375
3376
    // Work in the second workspace: its own working copy, shared bookmark.
3377
    write(&ws_dir, "b.txt", b"b\n");
3378
    let s = ws.snapshot("status").unwrap();
3379
    assert_eq!(s.statuses.len(), 1);
3380
    assert!(
3381
        !repo.snapshot("status").unwrap().changed,
3382
        "the first workspace is untouched"
3383
    );
3384
    let h2 = ws.commit("b from ws2", &[]).unwrap();
3385
    repo.refresh().unwrap();
3386
    let mut heads = repo.bookmark_heads().unwrap();
3387
    heads.sort();
3388
    let mut want = vec![h1, h2];
3389
    want.sort();
3390
    assert_eq!(heads, want, "both workspaces see the bookmark move");
3391
    assert!(!main_dir.join("b.txt").exists());
3392
    repo.checkout(&repo.bookmark_heads().unwrap(), false, "co")
3393
        .unwrap();
3394
    assert!(main_dir.join("b.txt").exists());
3395
3396
    // Reopening from inside the second workspace finds the shared repository.
3397
    let reopened = Repo::open(&ws_dir).unwrap();
3398
    assert_eq!(reopened.workspace(), ws.workspace());
3399
    assert_eq!(reopened.id().unwrap(), repo.id().unwrap());
3400
    assert_eq!(reopened.quick_status().unwrap().heads, 2);
3401
3402
    // Workspaces cannot nest; forget drops one from the view but keeps files.
3403
    assert!(repo.workspace_add(&main_dir.join("inner"), None).is_err());
3404
    assert!(repo.workspace_forget(&repo.workspace()).is_err());
3405
    repo.workspace_forget(&ws.workspace()).unwrap();
3406
    assert_eq!(repo.workspaces().unwrap().len(), 1);
3407
    assert!(ws_dir.join("b.txt").exists());
3408
    assert!(
3409
        ws.snapshot("status").is_err(),
3410
        "a forgotten workspace is no longer in the view"
3411
    );
3412
3413
    // The working-copy lock: re-entrant in a thread; a lock whose process
3414
    // is gone is taken over, and a live process's is not, however old.
3415
    let lock = repo.lock_snapshot().unwrap();
3416
    let again = repo.lock_working_copy().unwrap();
3417
    drop(again);
3418
    drop(lock);
3419
    let lock_path = main_dir
3420
        .join(".oxy/workspaces")
3421
        .join(repo.workspace().to_hex())
3422
        .join("lock");
3423
    let old = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
3424
    let age = |p: &Path| fs::File::open(p).unwrap().set_modified(old).unwrap();
3425
    fs::write(&lock_path, std::process::id().to_string()).unwrap();
3426
    age(&lock_path);
3427
    assert!(
3428
        !super::workspace::lock_is_stale(&lock_path),
3429
        "a live process keeps its lock"
3430
    );
3431
    fs::write(&lock_path, "4294967295").unwrap();
3432
    age(&lock_path);
3433
    assert!(repo.lock_snapshot().is_ok(), "stale lock taken over");
3434
}
3435
3436
#[test]
3437
fn attributes_binary_eol_nocompress_lockable() {
3438
    hermetic();
3439
    let dir = tempfile::tempdir().unwrap();
3440
    let root = dir.path();
3441
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
3442
    write(
3443
        root,
3444
        ".oxyattributes",
3445
        b"*.dat binary nocompress\n*.txt eol=crlf\nassets/** lockable\n",
3446
    );
3447
    write(root, "plain.dat", b"no nul bytes here\n");
3448
    write(root, "notes.txt", b"one\r\ntwo\r\n");
3449
    write(root, "assets/model.bin", b"\0model");
3450
    let h = repo.commit("attrs", &[]).unwrap();
3451
    let snap = repo.load_state(&[h]).unwrap().materialize();
3452
    assert!(
3453
        snap.files["plain.dat"].mode.opaque,
3454
        "binary attribute forces opaque without NUL bytes"
3455
    );
3456
    assert!(
3457
        matches!(&snap.files["notes.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\n"),
3458
        "stored with LF"
3459
    );
3460
    assert!(
3461
        !repo.snapshot("status").unwrap().changed,
3462
        "CRLF on disk equals LF in the state"
3463
    );
3464
    // nocompress chunks pack raw.
3465
    let r = repo.gc(true).unwrap();
3466
    assert!(r.packed.objects > 0);
3467
    // Checkout writes CRLF for eol=crlf and makes lockable files read-only.
3468
    fs::remove_file(root.join("notes.txt")).unwrap();
3469
    repo.checkout(&[h], true, "co").unwrap();
3470
    assert_eq!(fs::read(root.join("notes.txt")).unwrap(), b"one\r\ntwo\r\n");
3471
    assert!(fs::metadata(root.join("assets/model.bin"))
3472
        .unwrap()
3473
        .permissions()
3474
        .readonly());
3475
    assert!(repo.lock_path("plain.dat").is_err(), "not lockable");
3476
    repo.lock_path("assets/model.bin").unwrap();
3477
    assert!(!fs::metadata(root.join("assets/model.bin"))
3478
        .unwrap()
3479
        .permissions()
3480
        .readonly());
3481
    assert!(repo.is_locked("assets/model.bin"));
3482
    write(root, "assets/model.bin", b"\0model2");
3483
    repo.commit("edit model", &[]).unwrap();
3484
    repo.unlock_path("assets/model.bin").unwrap();
3485
    assert!(fs::metadata(root.join("assets/model.bin"))
3486
        .unwrap()
3487
        .permissions()
3488
        .readonly());
3489
    // A later checkout can still overwrite a read-only lockable file.
3490
    repo.checkout(&[h], true, "co").unwrap();
3491
    assert_eq!(fs::read(root.join("assets/model.bin")).unwrap(), b"\0model");
3492
}
3493
3494
#[cfg(unix)]
3495
#[test]
3496
fn hooks_can_veto_observe_and_add_dependencies() {
3497
    use std::os::unix::fs::PermissionsExt;
3498
    hermetic();
3499
    let dir = tempfile::tempdir().unwrap();
3500
    let root = dir.path();
3501
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
3502
    let hooks = root.join(".oxy/hooks");
3503
    fs::create_dir_all(&hooks).unwrap();
3504
    let hook = |name: &str, body: &str| {
3505
        let p = hooks.join(name);
3506
        fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
3507
        fs::set_permissions(&p, fs::Permissions::from_mode(0o755)).unwrap();
3508
    };
3509
    hook("pre-commit", "cat > \"$OXY_ROOT/.last-pre-commit\"; grep -q WIP \"$OXY_ROOT/.last-pre-commit\" && { echo 'no WIP commits' >&2; exit 1; }; exit 0");
3510
    hook("post-commit", "cat > \"$OXY_ROOT/.last-post-commit\"");
3511
    hook("post-checkout", "cat > \"$OXY_ROOT/.last-post-checkout\"");
3512
    hook("pre-push", "echo pushing blocked >&2; exit 3");
3513
    write(root, ".oxyignore", b".last-*\n");
3514
    write(root, "a.txt", b"a\n");
3515
    let err = repo.commit("WIP: nope", &[]).unwrap_err().to_string();
3516
    assert!(err.contains("pre-commit refused: no WIP commits"), "{err}");
3517
    let h1 = repo.commit("first", &[]).unwrap();
3518
    let pre = fs::read_to_string(root.join(".last-pre-commit")).unwrap();
3519
    assert!(
3520
        pre.contains("\"event\":\"pre-commit\"")
3521
            && pre.contains("\"path\":\"a.txt\"")
3522
            && pre.contains("\"message\":\"first\""),
3523
        "{pre}"
3524
    );
3525
    let post = fs::read_to_string(root.join(".last-post-commit")).unwrap();
3526
    assert!(post.contains(&h1.to_hex()), "{post}");
3527
    repo.checkout(&[h1], false, "co").unwrap();
3528
    assert!(fs::read_to_string(root.join(".last-post-checkout"))
3529
        .unwrap()
3530
        .contains("post-checkout"));
3531
    repo.config.set("remote.origin.url", "/nowhere").unwrap();
3532
    repo.save_config().unwrap();
3533
    let err = repo.push("origin", false).unwrap_err().to_string();
3534
    assert!(err.contains("pre-push refused: pushing blocked"), "{err}");
3535
3536
    // semantic-deps: only when enabled; adds an explicit dependency.
3537
    write(root, "b.txt", b"b\n");
3538
    hook(
3539
        "semantic-deps",
3540
        &format!(
3541
            "cat > \"$OXY_ROOT/.last-semantic\"; echo '{{\"deps\": [\"{}\"]}}'",
3542
            h1.to_hex()
3543
        ),
3544
    );
3545
    let h2 = repo.commit("b without hook", &[]).unwrap();
3546
    assert!(
3547
        repo.store().get_change(&h2).unwrap().deps.is_empty(),
3548
        "hook disabled by default"
3549
    );
3550
    repo.config.set("hooks.semantic-deps", "true").unwrap();
3551
    repo.save_config().unwrap();
3552
    write(root, "c.txt", b"c\n");
3553
    let h3 = repo.commit("c with hook", &[]).unwrap();
3554
    assert_eq!(repo.store().get_change(&h3).unwrap().deps, vec![h1]);
3555
    let sem = fs::read_to_string(root.join(".last-semantic")).unwrap();
3556
    assert!(
3557
        sem.contains("\"touched_paths\":[\"c.txt\"]") && sem.contains("candidate_deps"),
3558
        "{sem}"
3559
    );
3560
    assert_eq!(json_str("a\"b\\c\n"), "\"a\\\"b\\\\c\\n\"");
3561
}
3562
3563
#[test]
3564
fn amend_into_older_change_and_split() {
3565
    hermetic();
3566
    let dir = tempfile::tempdir().unwrap();
3567
    let root = dir.path();
3568
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
3569
    repo.create_identity("Ada", "a@b", IdentityScope::User)
3570
        .unwrap();
3571
    repo.set_user_config("core.sign", "change").unwrap();
3572
    write(root, "a.txt", b"one\ntwo\n");
3573
    let h0 = repo.commit("base", &[]).unwrap();
3574
    write(root, "a.txt", b"one\nTWO\n");
3575
    let h1 = repo.commit("edit two", &[]).unwrap();
3576
    write(root, "a.txt", b"one\nTWO\nthree\n");
3577
    let h2 = repo.commit("add three", &[]).unwrap();
3578
    let id1 = repo.store().get_change(&h1).unwrap().change_id;
3579
3580
    // Fixing the line h1 introduced folds into h1; h2 is rewritten on top.
3581
    write(root, "a.txt", b"one\nTWO!\nthree\n");
3582
    let h1b = repo.amend_into(&h1, None).unwrap();
3583
    assert_ne!(h1b, h1);
3584
    let c1b = repo.store().get_change(&h1b).unwrap();
3585
    assert_eq!(c1b.change_id, id1);
3586
    assert_eq!(c1b.deps, vec![h0]);
3587
    assert!(matches!(
3588
        repo.verify_change(&h1b).unwrap(),
3589
        SignatureStatus::Valid { .. }
3590
    ));
3591
    let heads = repo.bookmark_heads().unwrap();
3592
    assert_eq!(heads.len(), 1);
3593
    let top = repo.store().get_change(&heads[0]).unwrap();
3594
    assert_eq!(top.message, "add three");
3595
    assert_eq!(
3596
        top.deps.iter().copied().collect::<BTreeSet<_>>(),
3597
        [h0, h1b].into_iter().collect(),
3598
        "depends on the file's creator and the rewritten line"
3599
    );
3600
    let snap = repo.load_state(&heads).unwrap().materialize();
3601
    assert!(
3602
        matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\nTWO!\nthree\n")
3603
    );
3604
    assert!(
3605
        !repo.snapshot("status").unwrap().changed,
3606
        "the working copy is clean after folding"
3607
    );
3608
    // Only h1b's own state shows the fix without "three".
3609
    let only = repo.load_state(&[h1b]).unwrap().materialize();
3610
    assert!(
3611
        matches!(&only.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\nTWO!\n")
3612
    );
3613
    assert_eq!(repo.log().unwrap().len(), 4);
3614
3615
    // An edit to a line introduced by h2 cannot fold into h1.
3616
    write(root, "a.txt", b"one\nTWO!\nthree!\n");
3617
    let err = repo.amend_into(&h1b, None).unwrap_err().to_string();
3618
    assert!(err.contains("introduced after"), "{err}");
3619
    assert!(
3620
        repo.snapshot("status").unwrap().statuses.len() == 1,
3621
        "nothing was changed by the refusal"
3622
    );
3623
    write(root, "a.txt", b"one\nTWO!\nthree\n");
3624
3625
    // Split the head that touches two files.
3626
    write(root, "b.txt", b"b\n");
3627
    write(root, "c.txt", b"c\n");
3628
    let h3 = repo.commit("b and c", &[]).unwrap();
3629
    let id3 = repo.store().get_change(&h3).unwrap().change_id;
3630
    let (p1, p2) = repo.split(&h3, &["b.txt".into()]).unwrap();
3631
    let c_1 = repo.store().get_change(&p1).unwrap();
3632
    let c_2 = repo.store().get_change(&p2).unwrap();
3633
    assert!(c_1.message.contains("part 1: b.txt"));
3634
    assert_eq!(c_2.change_id, id3);
3635
    let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect();
3636
    assert!(
3637
        heads.contains(&p1) && heads.contains(&p2),
3638
        "independent parts are both heads"
3639
    );
3640
    let snap = repo
3641
        .load_state(&repo.bookmark_heads().unwrap())
3642
        .unwrap()
3643
        .materialize();
3644
    assert!(snap.files.contains_key("b.txt") && snap.files.contains_key("c.txt"));
3645
    let only1 = repo.load_state(&[p1]).unwrap().materialize();
3646
    assert!(only1.files.contains_key("b.txt") && !only1.files.contains_key("c.txt"));
3647
    assert!(!repo.snapshot("status").unwrap().changed);
3648
    assert!(repo.split(&p2, &["nope.txt".into()]).is_err());
3649
    let _ = h2;
3650
}
3651
3652
#[test]
3653
fn identity_lifecycle() {
3654
    hermetic();
3655
    let dir = tempfile::tempdir().unwrap();
3656
    let root = dir.path();
3657
    let origin = Repo::init(&root.join("origin")).unwrap();
3658
    let url = root.join("origin").to_string_lossy().to_string();
3659
    let mut a = Repo::clone(&url, &root.join("a"))
3660
        .unwrap()
3661
        .with_home(root.join("home-a"));
3662
    a.set_user_config("core.sign", "change").unwrap();
3663
    let (key_a, _) = a
3664
        .create_identity("Ada", "[email protected]", IdentityScope::User)
3665
        .unwrap();
3666
    write(&root.join("a"), "a.txt", b"a\n");
3667
    let h1 = a.commit("one", &[]).unwrap();
3668
    assert!(a.policy_init().unwrap().applied);
3669
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
3670
    origin.refresh().unwrap();
3671
3672
    // Expiry: an identity that had expired when the change was made does not authenticate it.
3673
    let mut e = Repo::init(&root.join("e"))
3674
        .unwrap()
3675
        .with_home(root.join("home-e"));
3676
    e.set_user_config("core.sign", "change").unwrap();
3677
    e.create_identity_with(
3678
        "Eve",
3679
        "[email protected]",
3680
        IdentityScope::User,
3681
        Some(Hlc::now().wall_ms - 1),
3682
    )
3683
    .unwrap();
3684
    write(&root.join("e"), "e.txt", b"e\n");
3685
    let he = e.commit("late", &[]).unwrap();
3686
    assert!(
3687
        matches!(e.verify_change(&he).unwrap(), SignatureStatus::Invalid(m) if m.contains("expired"))
3688
    );
3689
    // Renewal re-issues the identity (same keys, new KeyId) with a later expiry.
3690
    let r = e
3691
        .renew_identity(Some(Hlc::now().wall_ms + 86_400_000))
3692
        .unwrap();
3693
    assert_eq!(e.identity(&r.new).unwrap().previous, Some(r.old));
3694
    write(&root.join("e"), "e.txt", b"e2\n");
3695
    let he2 = e.commit("in time", &[]).unwrap();
3696
    assert!(
3697
        matches!(e.verify_change(&he2).unwrap(), SignatureStatus::Valid { key, .. } if key == r.new)
3698
    );
3699
3700
    // Rotation: a fresh key; the old change still verifies; the policy follows.
3701
    let r = a.rotate_identity().unwrap();
3702
    assert_eq!(r.old, key_a);
3703
    assert_eq!(a.config.author_key, Some(r.new));
3704
    assert_eq!(a.identity(&r.new).unwrap().previous, Some(key_a));
3705
    assert!(r.policy.as_ref().unwrap().applied);
3706
    let (_, sp) = a.current_policy().unwrap().unwrap();
3707
    assert_eq!(
3708
        (sp.policy.serial, sp.policy.roots.clone()),
3709
        (2, vec![r.new])
3710
    );
3711
    assert!(
3712
        !sp.policy.revoked.contains(&key_a) && sp.policy.authorizes(&r.new, "main"),
3713
        "superseded, not revoked"
3714
    );
3715
    assert!(sp.policy.succeeds(&r.new, &key_a) && !sp.policy.succeeds(&key_a, &r.new));
3716
    assert!(
3717
        matches!(a.verify_change(&h1).unwrap(), SignatureStatus::Valid { key, revoked: false, .. } if key == key_a)
3718
    );
3719
    write(&root.join("a"), "a.txt", b"a2\n");
3720
    let h2 = a.commit("two", &[]).unwrap();
3721
    assert!(
3722
        matches!(a.verify_change(&h2).unwrap(), SignatureStatus::Valid { key, revoked: false, .. } if key == r.new)
3723
    );
3724
    // Origin accepts the rotated policy (signed by the old root) and the new key's change.
3725
    let res = a.push("origin", false).unwrap();
3726
    origin.refresh().unwrap();
3727
    assert!(res.iter().all(|x| x.ok), "{res:?}");
3728
    assert_eq!(origin.current_policy().unwrap().unwrap().1.policy.serial, 2);
3729
    let key_a = r.new;
3730
3731
    // Delegation: Bob may sign on Ada's behalf. Delegates live in the
3732
    // identity object, so Ada's KeyId changes while her keys stay the same.
3733
    let mut b = Repo::clone(&url, &root.join("b"))
3734
        .unwrap()
3735
        .with_home(root.join("home-b"));
3736
    let (key_b, id_b) = b
3737
        .create_identity("Bob", "[email protected]", IdentityScope::User)
3738
        .unwrap();
3739
    let r = a.delegate_add(id_b.clone(), Scope::ALL, None).unwrap();
3740
    assert_eq!(r.old, key_a);
3741
    let key_a2 = r.new;
3742
    assert_eq!(
3743
        a.identity(&key_a2).unwrap().delegates,
3744
        vec![Delegate::full(key_b)]
3745
    );
3746
    assert_eq!(
3747
        a.identity(&key_a2).unwrap().ed25519_pk,
3748
        a.identity(&key_a).unwrap().ed25519_pk
3749
    );
3750
    assert!(
3751
        a.delegate_add(id_b.clone(), Scope::ALL, None).is_err(),
3752
        "already a delegate"
3753
    );
3754
    assert!(a.push("origin", false).unwrap().iter().all(|x| x.ok));
3755
    origin.refresh().unwrap();
3756
    write(&root.join("a"), "a.txt", b"a3\n");
3757
    let h3 = a.commit("three", &[]).unwrap();
3758
    assert!(
3759
        matches!(a.verify_change(&h3).unwrap(), SignatureStatus::Valid { key, .. } if key == key_a2),
3760
        "the same secret signs under the new KeyId"
3761
    );
3762
    // A change authored by Ada but signed only by Bob verifies through the delegation.
3763
    a.set_user_config("core.sign", "seal").unwrap();
3764
    write(&root.join("a"), "a.txt", b"a4\n");
3765
    let h4 = a.commit("four", &[]).unwrap();
3766
    assert_eq!(a.verify_change(&h4).unwrap(), SignatureStatus::Unsigned);
3767
    let (_, sk_b) = b.secret_key().unwrap().unwrap();
3768
    a.store()
3769
        .put_signature(&h4, &sk_b.sign(Domain::Change, &key_b, &h4))
3770
        .unwrap();
3771
    assert!(
3772
        matches!(a.verify_change(&h4).unwrap(), SignatureStatus::Valid { key, .. } if key == key_b)
3773
    );
3774
    a.set_user_config("core.sign", "change").unwrap();
3775
    // Withdrawing the delegation re-issues the identity again.
3776
    let r = a.delegate_remove(&key_b).unwrap();
3777
    assert!(a.identity(&r.new).unwrap().delegates.is_empty());
3778
    assert!(a.delegate_remove(&key_b).is_err());
3779
    let key_a3 = r.new;
3780
    let res = a.push("origin", false).unwrap();
3781
    origin.refresh().unwrap();
3782
    assert!(res.iter().all(|x| x.ok), "{res:?}");
3783
3784
    // Multi-signer policy: Bob becomes a root and the threshold rises to 2,
3785
    // both applied under the old threshold of 1. A grant then needs Bob too.
3786
    assert!(a.policy_root_add(id_b.clone()).unwrap().applied);
3787
    assert!(a.policy_root_add(id_b.clone()).is_err());
3788
    assert!(a.policy_set_threshold(3).is_err());
3789
    assert!(a.policy_set_threshold(2).unwrap().applied);
3790
    assert!(a.push("origin", false).unwrap().iter().all(|x| x.ok));
3791
    origin.refresh().unwrap();
3792
    b.pull("origin").unwrap();
3793
    assert_eq!(
3794
        b.current_policy().unwrap().unwrap().1.policy.root_threshold,
3795
        2
3796
    );
3797
    let mut c = Repo::init(&root.join("c"))
3798
        .unwrap()
3799
        .with_home(root.join("home-c"));
3800
    let (key_c, id_c) = c
3801
        .create_identity("Cy", "[email protected]", IdentityScope::User)
3802
        .unwrap();
3803
    let serial_before = a.current_policy().unwrap().unwrap().1.policy.serial;
3804
    let proposal = a.policy_grant(id_c, None).unwrap();
3805
    assert!(!proposal.applied && proposal.sigs.len() == 1);
3806
    assert_eq!(proposal.policy.serial, serial_before + 1);
3807
    assert_eq!(
3808
        a.current_policy().unwrap().unwrap().1.policy.serial,
3809
        serial_before,
3810
        "a proposal changes nothing yet"
3811
    );
3812
    // Bob signs the proposal, handed over as a file, and it applies for him.
3813
    let bytes = proposal.signed().encode();
3814
    let signed = b
3815
        .policy_sign(SignedPolicy::decode(&bytes).unwrap())
3816
        .unwrap();
3817
    assert!(signed.applied && signed.sigs.len() == 2);
3818
    assert!(b
3819
        .current_policy()
3820
        .unwrap()
3821
        .unwrap()
3822
        .1
3823
        .policy
3824
        .authorizes(&key_c, "main"));
3825
    assert!(
3826
        b.policy_sign(signed.signed()).is_err(),
3827
        "a stale proposal is refused"
3828
    );
3829
    // Bob publishes it and Ada picks it up.
3830
    assert!(b
3831
        .push("origin", false)
3832
        .unwrap()
3833
        .iter()
3834
        .any(|x| x.name == "policy" && x.ok));
3835
    origin.refresh().unwrap();
3836
    a.pull("origin").unwrap();
3837
    assert_eq!(
3838
        a.current_policy().unwrap().unwrap().1.policy.serial,
3839
        serial_before + 1
3840
    );
3841
    // A root cannot revoke itself; revoking Cy needs both roots but is recorded locally at once.
3842
    assert!(a.policy_revoke(&key_a3).is_err());
3843
    let u = a
3844
        .revoke_identity(&key_c, "left the project")
3845
        .unwrap()
3846
        .unwrap();
3847
    assert!(!u.applied);
3848
    assert!(a.is_revoked(&key_c).unwrap());
3849
    assert!(!b.is_revoked(&key_c).unwrap());
3850
    let u = b.policy_sign(u.signed()).unwrap();
3851
    assert!(u.applied && u.policy.is_revoked(&key_c));
3852
    assert!(b.is_revoked(&key_c).unwrap());
3853
    // Bob can revoke Ada's root key, leaving himself as the only root (threshold 2 would be unmeetable).
3854
    assert!(
3855
        b.policy_revoke(&key_a3).is_err(),
3856
        "would leave one root for a threshold of 2"
3857
    );
3858
    // Revoking one's own key without a policy root works locally: a plain marker plus attestation.
3859
    let key_c_own = c.config.author_key.unwrap();
3860
    assert!(c
3861
        .revoke_identity(&key_c_own, "lost laptop")
3862
        .unwrap()
3863
        .is_none());
3864
    assert!(c.is_revoked(&key_c_own).unwrap());
3865
    assert_eq!(key_c_own, key_c);
3866
}
3867
3868
#[test]
3869
fn renames_are_detected_and_tracked_as_moves() {
3870
    hermetic();
3871
    let dir = tempfile::tempdir().unwrap();
3872
    let root = dir.path();
3873
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
3874
    write(
3875
        root,
3876
        "src/old.rs",
3877
        b"fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\n",
3878
    );
3879
    write(root, "img.bin", b"\x89PNG\0\0\x01\x02");
3880
    write(root, "tiny.txt", b"x\n");
3881
    let h1 = repo.commit("base", &[]).unwrap();
3882
    let node_old = repo.load_state(&[h1]).unwrap().materialize().files["src/old.rs"].node;
3883
    let node_img = repo.load_state(&[h1]).unwrap().materialize().files["img.bin"].node;
3884
3885
    // Rename with a small edit, a binary moved unchanged, and a rewrite
3886
    // that shares nothing with the removed file.
3887
    fs::remove_file(root.join("src/old.rs")).unwrap();
3888
    write(
3889
        root,
3890
        "lib/new.rs",
3891
        b"fn a() {}\nfn b() {}\nfn c() {}\nfn e() {}\n",
3892
    );
3893
    fs::remove_file(root.join("img.bin")).unwrap();
3894
    write(root, "assets/img.bin", b"\x89PNG\0\0\x01\x02");
3895
    fs::remove_file(root.join("tiny.txt")).unwrap();
3896
    write(root, "other.txt", b"completely different\n");
3897
    let snap = repo.snapshot("status").unwrap();
3898
    let statuses: Vec<(&str, ChangeStatus)> = snap
3899
        .statuses
3900
        .iter()
3901
        .map(|s| (s.path.as_str(), s.status))
3902
        .collect();
3903
    assert_eq!(
3904
        statuses,
3905
        vec![
3906
            ("assets/img.bin", ChangeStatus::Renamed),
3907
            ("lib/new.rs", ChangeStatus::Renamed),
3908
            ("other.txt", ChangeStatus::Added),
3909
            ("src", ChangeStatus::Removed),
3910
            ("tiny.txt", ChangeStatus::Removed)
3911
        ]
3912
    );
3913
    let h2 = repo.commit("move things", &[]).unwrap();
3914
    let c = repo.store().get_change(&h2).unwrap();
3915
    let moves: Vec<_> = c
3916
        .tree_ops
3917
        .iter()
3918
        .filter(|op| matches!(op, TreeOp::Move { .. }))
3919
        .collect();
3920
    assert_eq!(moves.len(), 2, "{:?}", c.tree_ops);
3921
    assert!(c.tree_ops.iter().any(
3922
        |op| matches!(op, TreeOp::Move { node, name, .. } if *node == node_old && name == "new.rs")
3923
    ));
3924
    assert!(c.tree_ops.iter().any(
3925
        |op| matches!(op, TreeOp::Move { node, name, .. } if *node == node_img && name == "img.bin")
3926
    ));
3927
    let snap = repo.load_state(&[h2]).unwrap().materialize();
3928
    assert_eq!(
3929
        snap.files["lib/new.rs"].node, node_old,
3930
        "the node survives the rename"
3931
    );
3932
    assert!(
3933
        matches!(&snap.files["lib/new.rs"].content, FileContent::Text { bytes, .. } if bytes == b"fn a() {}\nfn b() {}\nfn c() {}\nfn e() {}\n")
3934
    );
3935
    assert!(!snap.files.contains_key("src/old.rs") && !snap.files.contains_key("src"));
3936
    assert!(render_change(
3937
        &c,
3938
        &repo.load_state(&[h1]).unwrap(),
3939
        &repo.load_state(&[h1]).unwrap().materialize()
3940
    )
3941
    .contains("R "));
3942
    // A fresh checkout reproduces the tree.
3943
    let other = tempfile::tempdir().unwrap();
3944
    let clone = Repo::clone(&root.to_string_lossy(), other.path()).unwrap();
3945
    assert_eq!(
3946
        fs::read(other.path().join("lib/new.rs")).unwrap(),
3947
        b"fn a() {}\nfn b() {}\nfn c() {}\nfn e() {}\n"
3948
    );
3949
    assert!(!other.path().join("src").exists());
3950
    assert!(!clone.snapshot("status").unwrap().changed);
3951
    // Similarity is line based, byte weighted.
3952
    assert_eq!(similarity(b"a\nb\n", b"a\nb\n"), 100);
3953
    assert_eq!(similarity(b"a\nb\nc\nd\n", b"a\nb\nx\ny\n"), 50);
3954
    assert_eq!(similarity(b"hello\n", b"world\n"), 0);
3955
    assert_eq!(similarity(b"", b"x\n"), 0);
3956
}
3957
3958
#[test]
3959
fn git_fetch_and_push_round_trip() {
3960
    hermetic();
3961
    if std::process::Command::new("git")
3962
        .arg("--version")
3963
        .output()
3964
        .is_err()
3965
    {
3966
        eprintln!("git not installed; skipping");
3967
        return;
3968
    }
3969
    let dir = tempfile::tempdir().unwrap();
3970
    let root = dir.path();
3971
    let git_in = |d: &Path, args: &[&str]| -> String {
3972
        let out = std::process::Command::new("git")
3973
            .arg("-C")
3974
            .arg(d)
3975
            .args([
3976
                "-c",
3977
                "user.name=Grace",
3978
                "-c",
3979
                "[email protected]",
3980
                "-c",
3981
                "commit.gpgsign=false",
3982
            ])
3983
            .args(args)
3984
            .output()
3985
            .unwrap();
3986
        assert!(
3987
            out.status.success(),
3988
            "git {args:?}: {}",
3989
            String::from_utf8_lossy(&out.stderr)
3990
        );
3991
        String::from_utf8_lossy(&out.stdout).trim().to_string()
3992
    };
3993
    // A bare "remote" and a work tree that pushes to it.
3994
    let remote = root.join("remote.git");
3995
    fs::create_dir_all(&remote).unwrap();
3996
    git_in(&remote, &["init", "-q", "--bare", "-b", "main"]);
3997
    let work = root.join("work");
3998
    git_in(
3999
        root,
4000
        &[
4001
            "clone",
4002
            "-q",
4003
            remote.to_str().unwrap(),
4004
            work.to_str().unwrap(),
4005
        ],
4006
    );
4007
    git_in(&work, &["checkout", "-q", "-b", "main"]);
4008
    write(&work, "README.md", b"# Project\n");
4009
    write(&work, "src/lib.rs", b"pub fn a() {}\n");
4010
    git_in(&work, &["add", "."]);
4011
    git_in(&work, &["commit", "-q", "-m", "initial"]);
4012
    write(&work, "src/lib.rs", b"pub fn a() {}\npub fn b() {}\n");
4013
    git_in(&work, &["commit", "-q", "-am", "second"]);
4014
    git_in(&work, &["push", "-q", "origin", "main"]);
4015
    let tip1 = git_in(&work, &["rev-parse", "HEAD"]);
4016
4017
    // Fetch into a fresh oxy repository.
4018
    let o = root.join("oxy");
4019
    let mut repo = Repo::init(&o).unwrap().with_home(root.join("home"));
4020
    repo.create_identity("Ada", "[email protected]", IdentityScope::User)
4021
        .unwrap();
4022
    let url = remote.to_string_lossy().to_string();
4023
    let r = repo.git_fetch(&url, "main").unwrap();
4024
    assert_eq!((r.commits, r.heads), (2, 1));
4025
    assert_eq!(
4026
        fs::read(o.join("src/lib.rs")).unwrap(),
4027
        b"pub fn a() {}\npub fn b() {}\n"
4028
    );
4029
    assert!(!repo.snapshot("status").unwrap().changed);
4030
    assert_eq!(
4031
        repo.git_fetch(&url, "main").unwrap().commits,
4032
        0,
4033
        "nothing new"
4034
    );
4035
4036
    // An oxy change, pushed back: one commit on top of the Git tip.
4037
    write(
4038
        &o,
4039
        "src/lib.rs",
4040
        b"pub fn a() {}\npub fn b() {}\npub fn c() {}\n",
4041
    );
4042
    write(&o, "NOTES.md", b"from oxy\n");
4043
    let h_oxy = repo.commit("add c and notes", &[]).unwrap();
4044
    let r = repo.git_push(&url, "main").unwrap();
4045
    assert_eq!(r.exported, 1, "{r:?}");
4046
    git_in(&work, &["pull", "-q", "--ff-only", "origin", "main"]);
4047
    assert_eq!(
4048
        git_in(&work, &["log", "--format=%s", "main"]),
4049
        "add c and notes\nsecond\ninitial"
4050
    );
4051
    assert_eq!(git_in(&work, &["rev-parse", "HEAD~1"]), tip1);
4052
    assert_eq!(fs::read(work.join("NOTES.md")).unwrap(), b"from oxy\n");
4053
    assert_eq!(git_in(&work, &["log", "-1", "--format=%an", "main"]), "Ada");
4054
    assert!(git_in(&work, &["log", "-1", "--format=%B", "main"])
4055
        .contains(&format!("Oxy-Hash: {}", h_oxy.to_hex())));
4056
    assert_eq!(
4057
        repo.git_push(&url, "main").unwrap().exported,
4058
        0,
4059
        "already pushed"
4060
    );
4061
4062
    // Git moves on; pushing without fetching is refused, fetching brings
4063
    // the commit in and the working copy follows.
4064
    write(&work, "README.md", b"# Project\n\nMore.\n");
4065
    git_in(&work, &["commit", "-q", "-am", "readme from git"]);
4066
    git_in(&work, &["push", "-q", "origin", "main"]);
4067
    let tip2 = git_in(&work, &["rev-parse", "HEAD"]);
4068
    assert!(repo
4069
        .git_push(&url, "main")
4070
        .unwrap_err()
4071
        .to_string()
4072
        .contains("fetch"));
4073
    let r = repo.git_fetch(&url, "main").unwrap();
4074
    assert_eq!(
4075
        (r.commits, r.heads),
4076
        (1, 2),
4077
        "the Git change and the oxy change commute"
4078
    );
4079
    assert_eq!(
4080
        fs::read(o.join("README.md")).unwrap(),
4081
        b"# Project\n\nMore.\n"
4082
    );
4083
    assert_eq!(fs::read(o.join("NOTES.md")).unwrap(), b"from oxy\n");
4084
    assert!(!repo.snapshot("status").unwrap().changed);
4085
    // Another oxy change pushes on top of Git's newest commit.
4086
    write(&o, "NOTES.md", b"from oxy\nagain\n");
4087
    repo.commit("notes again", &[]).unwrap();
4088
    assert_eq!(repo.git_push(&url, "main").unwrap().exported, 1);
4089
    git_in(&work, &["pull", "-q", "--ff-only", "origin", "main"]);
4090
    assert_eq!(git_in(&work, &["rev-parse", "HEAD~1"]), tip2);
4091
    assert_eq!(
4092
        fs::read(work.join("NOTES.md")).unwrap(),
4093
        b"from oxy\nagain\n"
4094
    );
4095
    assert_eq!(
4096
        git_in(&work, &["log", "--format=%s", "main"])
4097
            .lines()
4098
            .count(),
4099
        5
4100
    );
4101
}
4102
4103
/// Benchmark, not a gate: `cargo test --release -p oxy-core --lib bench_one_gibibyte -- --ignored --nocapture`.
4104
/// Commits, stores, clones, and reads back a 1 GiB incompressible file.
4105
#[test]
4106
#[ignore]
4107
fn bench_one_gibibyte_file() {
4108
    hermetic();
4109
    // /tmp is often a small tmpfs; `OXY_BENCH_DIR` picks a roomier disk.
4110
    let dir = match std::env::var_os("OXY_BENCH_DIR") {
4111
        Some(d) => tempfile::tempdir_in(d).unwrap(),
4112
        None => tempfile::tempdir().unwrap(),
4113
    };
4114
    let root = dir.path();
4115
    let repo = Repo::init(&root.join("r"))
4116
        .unwrap()
4117
        .with_home(root.join("home"));
4118
    let size = 1usize << 30;
4119
    let mut data = vec![0u8; size];
4120
    let mut x = 0x9e3779b97f4a7c15u64;
4121
    for chunk in data.chunks_mut(8) {
4122
        x ^= x << 13;
4123
        x ^= x >> 7;
4124
        x ^= x << 17;
4125
        let b = x.to_le_bytes();
4126
        chunk.copy_from_slice(&b[..chunk.len()]);
4127
    }
4128
    let t = std::time::Instant::now();
4129
    write(&root.join("r"), "big.bin", &data);
4130
    eprintln!("write 1 GiB to disk: {:.2?}", t.elapsed());
4131
    let t = std::time::Instant::now();
4132
    let h = repo.commit("big", &[]).unwrap();
4133
    eprintln!("commit (chunk + hash + store): {:.2?}", t.elapsed());
4134
    let t = std::time::Instant::now();
4135
    assert!(!repo.snapshot("status").unwrap().changed);
4136
    eprintln!("status on a clean 1 GiB working copy: {:.2?}", t.elapsed());
4137
    let t = std::time::Instant::now();
4138
    let objects = repo.store().list().unwrap().len();
4139
    eprintln!("objects stored: {objects} ({:.2?} to list)", t.elapsed());
4140
    let t = std::time::Instant::now();
4141
    let g = repo.gc(true).unwrap();
4142
    eprintln!("gc (pack): {:.2?} {g:?}", t.elapsed());
4143
    let t = std::time::Instant::now();
4144
    let clone = Repo::clone(&root.join("r").to_string_lossy(), &root.join("c")).unwrap();
4145
    eprintln!("clone (fetch + checkout): {:.2?}", t.elapsed());
4146
    let t = std::time::Instant::now();
4147
    let back = fs::read(root.join("c/big.bin")).unwrap();
4148
    assert_eq!(blake3::hash(&back), blake3::hash(&data));
4149
    eprintln!("read back and verify: {:.2?}", t.elapsed());
4150
    assert_eq!(clone.bookmark_heads().unwrap(), vec![h]);
4151
    let store_bytes: u64 = walkdir_size(&root.join("r/.oxy/objects"));
4152
    eprintln!(
4153
        "store size: {:.1} MiB for 1 GiB of data",
4154
        store_bytes as f64 / (1024.0 * 1024.0)
4155
    );
4156
}
4157
4158
fn walkdir_size(p: &Path) -> u64 {
4159
    let mut total = 0;
4160
    if let Ok(rd) = fs::read_dir(p) {
4161
        for e in rd.flatten() {
4162
            let path = e.path();
4163
            if path.is_dir() {
4164
                total += walkdir_size(&path);
4165
            } else if let Ok(m) = fs::metadata(&path) {
4166
                total += m.len();
4167
            }
4168
        }
4169
    }
4170
    total
4171
}
4172
4173
/// An opaque file whose size and mtime match the manifest is not read on
4174
/// the next snapshot: its blob comes from the manifest's stat cache.
4175
#[cfg(unix)]
4176
#[test]
4177
fn stat_cache_skips_reading_unchanged_opaque_files() {
4178
    use std::os::unix::fs::PermissionsExt;
4179
    hermetic();
4180
    let dir = tempfile::tempdir().unwrap();
4181
    let root = dir.path();
4182
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
4183
    let mut big = vec![0u8; 300 * 1024];
4184
    for (i, b) in big.iter_mut().enumerate() {
4185
        *b = (i as u32).wrapping_mul(2654435761).to_le_bytes()[1];
4186
    }
4187
    big[0] = 0;
4188
    write(root, "big.bin", &big);
4189
    write(root, "a.txt", b"text\n");
4190
    let h1 = repo.commit("base", &[]).unwrap();
4191
    let (_, _, m) = repo.read_manifest().unwrap().unwrap();
4192
    assert!(
4193
        m.entries["big.bin"].blob.is_some(),
4194
        "opaque files record their blob"
4195
    );
4196
    assert!(m.entries["a.txt"].blob.is_none());
4197
    let blob = m.entries["big.bin"].blob.unwrap();
4198
    assert!(repo.store().has(&blob));
4199
4200
    // Files modified within two seconds of the manifest are racy and still
4201
    // read; age the file so the cache applies, then snapshot to record it.
4202
    let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30);
4203
    fs::File::options()
4204
        .write(true)
4205
        .open(root.join("big.bin"))
4206
        .unwrap()
4207
        .set_modified(old)
4208
        .unwrap();
4209
    assert!(!repo.snapshot("status").unwrap().changed);
4210
    // Now the file cannot be read at all; the snapshot succeeds without it.
4211
    fs::set_permissions(root.join("big.bin"), fs::Permissions::from_mode(0o000)).unwrap();
4212
    let snap = repo.snapshot("status").unwrap();
4213
    assert!(
4214
        !snap.changed && snap.statuses.is_empty(),
4215
        "{:?}",
4216
        snap.statuses
4217
    );
4218
    fs::set_permissions(root.join("big.bin"), fs::Permissions::from_mode(0o644)).unwrap();
4219
    // A real change (new mtime, new content) is seen and committed correctly.
4220
    let mut big2 = big.clone();
4221
    big2[10] ^= 0xff;
4222
    write(root, "big.bin", &big2);
4223
    let snap = repo.snapshot("status").unwrap();
4224
    assert_eq!(
4225
        snap.statuses
4226
            .iter()
4227
            .map(|s| (s.path.as_str(), s.status))
4228
            .collect::<Vec<_>>(),
4229
        vec![("big.bin", ChangeStatus::Modified)]
4230
    );
4231
    let h2 = repo.commit("edit", &[]).unwrap();
4232
    let files = repo.load_state(&[h2]).unwrap().materialize().files;
4233
    assert_eq!(
4234
        files["big.bin"].content,
4235
        FileContent::Opaque {
4236
            blobs: vec![Repo::blob_ref(&big2)]
4237
        }
4238
    );
4239
    // Checkout writes a manifest with blobs too, so the next status is cached.
4240
    repo.checkout(&[h1], false, "checkout").unwrap();
4241
    let (_, _, m) = repo.read_manifest().unwrap().unwrap();
4242
    assert_eq!(m.entries["big.bin"].blob, Some(Repo::blob_ref(&big)));
4243
    assert!(!repo.snapshot("status").unwrap().changed);
4244
}
4245
4246
#[test]
4247
fn status_json_lists_conflicts_with_contributors() {
4248
    hermetic();
4249
    let dir = tempfile::tempdir().unwrap();
4250
    let root = dir.path();
4251
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
4252
    write(root, "a.txt", b"one\n");
4253
    let h1 = repo.commit("base", &[]).unwrap();
4254
    write(root, "a.txt", b"one\nleft\n");
4255
    let ha = repo.commit("left", &[]).unwrap();
4256
    repo.checkout(&[h1], true, "checkout").unwrap();
4257
    write(root, "a.txt", b"one\nright\n");
4258
    let hb = repo.commit("right", &[]).unwrap();
4259
    repo.checkout(&[ha, hb], true, "checkout").unwrap();
4260
    let snap = repo.snapshot("status").unwrap();
4261
    assert!(snap.base_snapshot.is_conflicted());
4262
    let json = repo.status_json(&snap).unwrap();
4263
    let ida = repo.store().get_change(&ha).unwrap().change_id.to_hex();
4264
    let idb = repo.store().get_change(&hb).unwrap().change_id.to_hex();
4265
    assert!(json.contains("\"conflicted\":true"), "{json}");
4266
    assert!(
4267
        json.contains("\"kind\":\"text\",\"path\":\"a.txt\""),
4268
        "{json}"
4269
    );
4270
    assert!(json.contains(&ida) && json.contains(&idb), "{json}");
4271
    assert!(json.contains("\"resolved\":false"), "{json}");
4272
    assert!(json.starts_with('{') && json.ends_with('}'));
4273
    // Resolve and the report says so; a clean copy has no conflicts.
4274
    write(root, "a.txt", b"one\nleft\nright\n");
4275
    let snap = repo.snapshot("status").unwrap();
4276
    let json = repo.status_json(&snap).unwrap();
4277
    assert!(json.contains("\"resolved\":true"), "{json}");
4278
    repo.commit("resolve", &[]).unwrap();
4279
    let snap = repo.snapshot("status").unwrap();
4280
    let json = repo.status_json(&snap).unwrap();
4281
    assert!(
4282
        json.contains("\"clean\":true,\"conflicted\":false,\"paths\":[],\"conflicts\":[]"),
4283
        "{json}"
4284
    );
4285
}
4286
4287
#[test]
4288
fn ignore_rules_do_not_untrack_files() {
4289
    hermetic();
4290
    let dir = tempfile::tempdir().unwrap();
4291
    let root = dir.path();
4292
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
4293
    write(root, "src.c", b"int x;\n");
4294
    write(root, "build/out.o", b"\0obj");
4295
    write(root, "sub/keep.log", b"keep\n");
4296
    write(root, "sub/junk.log", b"junk\n");
4297
    write(root, ".oxyignore", b"/build\n*.log\n!sub/keep.log\n");
4298
    let snap = repo.snapshot("status").unwrap();
4299
    let added: Vec<&str> = snap.statuses.iter().map(|s| s.path.as_str()).collect();
4300
    assert_eq!(
4301
        added,
4302
        vec![".oxyignore", "src.c", "sub/keep.log"],
4303
        "anchored, glob, and negated rules"
4304
    );
4305
    let h1 = repo.commit("base", &[]).unwrap();
4306
    // Ignoring a tracked file changes nothing about it.
4307
    write(root, ".oxyignore", b"/build\n*.log\n!sub/keep.log\nsrc.c\n");
4308
    let snap = repo.snapshot("status").unwrap();
4309
    assert_eq!(
4310
        snap.statuses
4311
            .iter()
4312
            .map(|s| s.path.as_str())
4313
            .collect::<Vec<_>>(),
4314
        vec![".oxyignore"]
4315
    );
4316
    let h2 = repo.commit("ignore src.c", &[]).unwrap();
4317
    assert!(repo
4318
        .load_state(&[h2])
4319
        .unwrap()
4320
        .materialize()
4321
        .files
4322
        .contains_key("src.c"));
4323
    // Its edits are still seen, by status and by the prompt.
4324
    assert_eq!(repo.quick_dirty().unwrap(), Some(false));
4325
    write(root, "src.c", b"int x, y;\n");
4326
    assert_eq!(repo.quick_dirty().unwrap(), Some(true));
4327
    let snap = repo.snapshot("status").unwrap();
4328
    assert_eq!(
4329
        snap.statuses
4330
            .iter()
4331
            .map(|s| (s.path.as_str(), s.status))
4332
            .collect::<Vec<_>>(),
4333
        vec![("src.c", ChangeStatus::Modified)]
4334
    );
4335
    let h3 = repo.commit("edit src.c", &[]).unwrap();
4336
    assert!(
4337
        matches!(&repo.load_state(&[h3]).unwrap().materialize().files["src.c"].content, FileContent::Text { bytes, .. } if bytes == b"int x, y;\n")
4338
    );
4339
    // Deleting it on disk removes it, ignored or not; a checkout brings it back.
4340
    fs::remove_file(root.join("src.c")).unwrap();
4341
    let snap = repo.snapshot("status").unwrap();
4342
    assert_eq!(
4343
        snap.statuses
4344
            .iter()
4345
            .map(|s| (s.path.as_str(), s.status))
4346
            .collect::<Vec<_>>(),
4347
        vec![("src.c", ChangeStatus::Removed)]
4348
    );
4349
    repo.checkout(&[h3], true, "checkout").unwrap();
4350
    assert_eq!(fs::read(root.join("src.c")).unwrap(), b"int x, y;\n");
4351
    assert!(!repo.snapshot("status").unwrap().changed);
4352
    let _ = h1;
4353
}
4354
4355
#[test]
4356
fn split_works_on_non_heads_by_node() {
4357
    hermetic();
4358
    let dir = tempfile::tempdir().unwrap();
4359
    let root = dir.path();
4360
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
4361
    write(root, "a.txt", b"one\n");
4362
    write(root, "junk/x.lock", b"lock\n");
4363
    let h1 = repo.commit("work plus junk", &[]).unwrap();
4364
    write(root, "a.txt", b"one\ntwo\n");
4365
    let h2 = repo.commit("more work", &[]).unwrap();
4366
    assert_eq!(repo.store().get_change(&h2).unwrap().deps, vec![h1]);
4367
    let id1 = repo.store().get_change(&h1).unwrap().change_id;
4368
    // h1 is not a head: split it by node.
4369
    let (hj, h1b) = repo.split(&h1, &["junk".into()]).unwrap();
4370
    assert_ne!(h1b, h1);
4371
    assert_eq!(
4372
        repo.store().get_change(&h1b).unwrap().change_id,
4373
        id1,
4374
        "the rest keeps the ChangeId"
4375
    );
4376
    let junk = repo.store().get_change(&hj).unwrap();
4377
    assert!(junk.tree_ops.iter().all(
4378
        |op| matches!(op, TreeOp::AddNode { name, .. } if name == "junk" || name == "x.lock")
4379
    ));
4380
    let heads = repo.bookmark_heads().unwrap();
4381
    assert!(
4382
        heads.contains(&hj),
4383
        "the split-off part is a head of its own"
4384
    );
4385
    let snap = repo.load_state(&heads).unwrap().materialize();
4386
    assert!(
4387
        matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\n"),
4388
        "the dependent was rewritten onto the new hash"
4389
    );
4390
    assert!(snap.files.contains_key("junk/x.lock"));
4391
    let h2b = heads
4392
        .iter()
4393
        .find(|h| repo.store().get_change(h).unwrap().message == "more work")
4394
        .copied()
4395
        .unwrap();
4396
    assert_eq!(repo.store().get_change(&h2b).unwrap().deps, vec![h1b]);
4397
    assert!(!repo.snapshot("status").unwrap().changed);
4398
    // Abandoning the split-off head removes the junk from history.
4399
    repo.abandon(&hj).unwrap();
4400
    let heads = repo.bookmark_heads().unwrap();
4401
    let snap = repo.load_state(&heads).unwrap().materialize();
4402
    assert!(!snap.files.contains_key("junk/x.lock") && !snap.files.contains_key("junk"));
4403
    assert!(snap.files.contains_key("a.txt"));
4404
    // A dependent that touches the split-off paths blocks the split.
4405
    write(root, "junk/y.lock", b"y\n");
4406
    let hb = repo.commit("junk base", &[]).unwrap();
4407
    write(root, "junk/y.lock", b"yy\n");
4408
    write(root, "b.txt", b"b\n");
4409
    repo.commit("touches junk", &[]).unwrap();
4410
    let err = repo.split(&hb, &["junk".into()]).unwrap_err().to_string();
4411
    assert!(err.contains("depends on the split-off paths"), "{err}");
4412
}
4413
4414
#[test]
4415
fn bookmarks_switch_merge_and_push() {
4416
    hermetic();
4417
    let dir = tempfile::tempdir().unwrap();
4418
    let root = dir.path();
4419
    let origin = Repo::init(&root.join("origin")).unwrap();
4420
    let url = root.join("origin").to_string_lossy().to_string();
4421
    let mut repo = Repo::clone(&url, &root.join("r"))
4422
        .unwrap()
4423
        .with_home(root.join("home"));
4424
    let r = root.join("r");
4425
    write(&r, "a.txt", b"a\n");
4426
    let h_a = repo.commit("a", &[]).unwrap();
4427
    assert!(repo.push("origin", false).unwrap()[0].ok);
4428
    origin.refresh().unwrap();
4429
4430
    // A bookmark at the working copy's base, then work on it.
4431
    assert!(repo.bookmark_create("bad name..", None).is_err());
4432
    assert!(repo.bookmark_create("-x", None).is_err());
4433
    assert_eq!(repo.bookmark_create("feature", None).unwrap(), vec![h_a]);
4434
    assert!(repo.bookmark_create("feature", None).is_err(), "exists");
4435
    repo.bookmark_switch("feature", false).unwrap();
4436
    assert_eq!(repo.config.bookmark, "feature");
4437
    write(&r, "a.txt", b"a\nb\n");
4438
    write(&r, "b.txt", b"b\n");
4439
    let h_b = repo.commit("b on feature", &[]).unwrap();
4440
    assert_eq!(
4441
        repo.bookmark_heads().unwrap(),
4442
        vec![h_b],
4443
        "b edits a.txt, so it depends on a"
4444
    );
4445
    assert_eq!(
4446
        repo.resolve_heads("main").unwrap(),
4447
        vec![h_a],
4448
        "main is untouched"
4449
    );
4450
    assert_eq!(
4451
        repo.resolve_change("feature").unwrap(),
4452
        h_b,
4453
        "a one-head bookmark names its change"
4454
    );
4455
    // Reopening keeps the workspace's bookmark.
4456
    let reopened = Repo::open(&r).unwrap().with_home(root.join("home"));
4457
    assert_eq!(reopened.config.bookmark, "feature");
4458
    // Back to main: b.txt goes away; work there.
4459
    repo.bookmark_switch("main", false).unwrap();
4460
    assert!(!r.join("b.txt").exists());
4461
    assert_eq!(fs::read(r.join("a.txt")).unwrap(), b"a\n");
4462
    write(&r, "c.txt", b"c\n");
4463
    let h_c = repo.commit("c on main", &[]).unwrap();
4464
    let list = repo.bookmarks().unwrap();
4465
    assert_eq!(
4466
        list.iter()
4467
            .map(|b| (b.name.as_str(), b.current))
4468
            .collect::<Vec<_>>(),
4469
        vec![("feature", false), ("main", true)]
4470
    );
4471
    assert_eq!(
4472
        list.iter().find(|b| b.name == "main").unwrap().ahead,
4473
        Some(1),
4474
        "c is not on origin yet"
4475
    );
4476
    // Merge feature into main: the union, both heads, no merge change.
4477
    let m = repo.merge("feature").unwrap();
4478
    assert!(m.changed && m.checked_out);
4479
    let mut want = vec![h_b, h_c];
4480
    want.sort();
4481
    assert_eq!(m.heads, want);
4482
    assert_eq!(fs::read(r.join("b.txt")).unwrap(), b"b\n");
4483
    assert!(!repo.merge("feature").unwrap().changed, "already merged");
4484
    assert!(repo.merge("main").is_err());
4485
    // The other bookmark can be pushed by name and appears at origin.
4486
    assert!(repo.push_bookmark("origin", "feature", false).unwrap()[0].ok);
4487
    origin.refresh().unwrap();
4488
    assert_eq!(origin.resolve_heads("feature").unwrap(), vec![h_b]);
4489
    assert!(repo.push("origin", false).unwrap()[0].ok);
4490
    origin.refresh().unwrap();
4491
    assert_eq!(
4492
        repo.bookmarks()
4493
            .unwrap()
4494
            .iter()
4495
            .find(|b| b.name == "main")
4496
            .unwrap()
4497
            .ahead,
4498
        Some(0)
4499
    );
4500
    // A second workspace inherits the current bookmark and can switch independently.
4501
    let ws = repo.workspace_add(&root.join("ws"), None).unwrap();
4502
    assert_eq!(ws.config.bookmark, "main");
4503
    let mut ws = ws;
4504
    ws.bookmark_switch("feature", false).unwrap();
4505
    assert_eq!(
4506
        Repo::open(&r).unwrap().config.bookmark,
4507
        "main",
4508
        "the first workspace is unaffected"
4509
    );
4510
    assert!(
4511
        repo.bookmark_delete("feature").is_err(),
4512
        "current bookmark of another workspace"
4513
    );
4514
    ws.bookmark_switch("main", false).unwrap();
4515
    // Set and delete.
4516
    assert_eq!(repo.bookmark_set("feature", &[h_a]).unwrap(), vec![h_a]);
4517
    assert!(repo.bookmark_delete("main").is_err(), "current here");
4518
    repo.bookmark_delete("feature").unwrap();
4519
    assert!(repo.resolve_heads("feature").is_err());
4520
    assert!(repo.bookmark_set("feature", &[h_a]).is_err());
4521
    assert!(
4522
        repo.checkout(&[h_a], false, "checkout").is_ok(),
4523
        "changes of a deleted bookmark remain"
4524
    );
4525
}
4526
4527
#[test]
4528
fn oxygen_subrepos_pin_follow_and_update() {
4529
    hermetic();
4530
    let dir = tempfile::tempdir().unwrap();
4531
    let root = dir.path();
4532
    // A library repository with two commits, and a bare-ish origin for it.
4533
    let lib_origin = Repo::init(&root.join("lib-origin")).unwrap();
4534
    let lib_url = root.join("lib-origin").to_string_lossy().to_string();
4535
    let lib = Repo::clone(&lib_url, &root.join("lib"))
4536
        .unwrap()
4537
        .with_home(root.join("home"));
4538
    write(&root.join("lib"), "lib.txt", b"v1\n");
4539
    let l1 = lib.commit("lib v1", &[]).unwrap();
4540
    assert!(lib.push("origin", false).unwrap()[0].ok);
4541
    let _ = lib_origin;
4542
4543
    // The application (a clone of its own origin) adds it as a subrepo.
4544
    let app_origin = Repo::init(&root.join("app-origin")).unwrap();
4545
    let app_url = root.join("app-origin").to_string_lossy().to_string();
4546
    let _ = app_origin;
4547
    let app_dir = root.join("app");
4548
    let app = Repo::clone(&app_url, &app_dir)
4549
        .unwrap()
4550
        .with_home(root.join("home"));
4551
    write(&app_dir, "main.txt", b"app\n");
4552
    let pin = app.submodule_add(&lib_url, "libs/lib", None).unwrap();
4553
    assert!(matches!(&pin, crate::subrepo::Pin::Oxy { heads, .. } if heads == &vec![l1]));
4554
    assert!(app_dir.join(".oxymodules").exists() && app_dir.join("libs/lib/.oxy").is_dir());
4555
    let snap = app.snapshot("status").unwrap();
4556
    let paths: Vec<&str> = snap.statuses.iter().map(|s| s.path.as_str()).collect();
4557
    assert_eq!(
4558
        paths,
4559
        vec![".oxymodules", "libs/lib", "main.txt"],
4560
        "the nested files are not walked"
4561
    );
4562
    let a1 = app.commit("app with lib", &[]).unwrap();
4563
    let files = app.load_state(&[a1]).unwrap().materialize().files;
4564
    assert_eq!(files["libs/lib"].kind, NodeKind::Subrepo);
4565
    assert!(!files.contains_key("libs/lib/lib.txt"));
4566
    assert!(!app.snapshot("status").unwrap().changed);
4567
    let subs = app.subrepos().unwrap();
4568
    assert_eq!(subs.len(), 1);
4569
    assert_eq!(subs[0].actual.as_ref(), Some(&subs[0].pin));
4570
4571
    // Advance the nested repository: the parent sees a modified pin.
4572
    let nested = Repo::open(&app_dir.join("libs/lib"))
4573
        .unwrap()
4574
        .with_home(root.join("home"));
4575
    write(&app_dir.join("libs/lib"), "lib.txt", b"v2\n");
4576
    let l2 = nested.commit("lib v2", &[]).unwrap();
4577
    assert!(nested.push("origin", false).unwrap()[0].ok);
4578
    let snap = app.snapshot("status").unwrap();
4579
    assert_eq!(
4580
        snap.statuses
4581
            .iter()
4582
            .map(|s| (s.path.as_str(), s.status))
4583
            .collect::<Vec<_>>(),
4584
        vec![("libs/lib", ChangeStatus::Modified)]
4585
    );
4586
    let a2 = app.commit("bump lib", &[]).unwrap();
4587
    // Checking out the older app state brings the nested working copy back
4588
    // to the older pin, but never moves its bookmark backwards (oxygen#38).
4589
    let r = app.checkout(&[a1], false, "checkout").unwrap();
4590
    assert!(
4591
        r.notes.len() == 1 && r.notes[0].contains("keeps 1 newer change"),
4592
        "{:?}",
4593
        r.notes
4594
    );
4595
    assert_eq!(fs::read(app_dir.join("libs/lib/lib.txt")).unwrap(), b"v1\n");
4596
    let nested = Repo::open(&app_dir.join("libs/lib")).unwrap();
4597
    assert_eq!(nested.bookmark_heads().unwrap(), vec![l2]);
4598
    assert_eq!(nested.working_copy().unwrap().1.deps, vec![l1]);
4599
    assert!(!app.snapshot("status").unwrap().changed);
4600
    // A dirty nested repository is left alone and reported.
4601
    write(&app_dir.join("libs/lib"), "scratch.txt", b"wip\n");
4602
    let r = app.checkout(&[a2], false, "checkout").unwrap();
4603
    assert!(
4604
        r.notes.iter().any(|n| n.contains("uncommitted")),
4605
        "{:?}",
4606
        r.notes
4607
    );
4608
    assert_eq!(fs::read(app_dir.join("libs/lib/lib.txt")).unwrap(), b"v1\n");
4609
    fs::remove_file(app_dir.join("libs/lib/scratch.txt")).unwrap();
4610
    let r = app.submodule_update(false, &[]).unwrap();
4611
    assert_eq!(r, vec![("libs/lib".to_string(), SubrepoSync::Updated)]);
4612
    assert_eq!(fs::read(app_dir.join("libs/lib/lib.txt")).unwrap(), b"v2\n");
4613
    let _ = l2;
4614
4615
    // A fresh clone of the app has the pin but no nested repository until update --init.
4616
    assert!(app.push("origin", false).unwrap()[0].ok);
4617
    let clone_dir = root.join("app-clone");
4618
    let clone = Repo::clone(&app_url, &clone_dir)
4619
        .unwrap()
4620
        .with_home(root.join("home"));
4621
    assert!(!clone_dir.join("libs/lib/.oxy").exists());
4622
    assert_eq!(
4623
        clone.submodule_update(false, &[]).unwrap(),
4624
        vec![("libs/lib".to_string(), SubrepoSync::Missing)]
4625
    );
4626
    assert_eq!(
4627
        clone.submodule_update(true, &[]).unwrap(),
4628
        vec![("libs/lib".to_string(), SubrepoSync::Updated)]
4629
    );
4630
    assert_eq!(
4631
        fs::read(clone_dir.join("libs/lib/lib.txt")).unwrap(),
4632
        b"v2\n"
4633
    );
4634
    assert!(!clone.snapshot("status").unwrap().changed);
4635
    // Removing the subrepo leaves the directory and drops the node.
4636
    clone.submodule_remove("libs/lib").unwrap();
4637
    let snap = clone.snapshot("status").unwrap();
4638
    assert!(
4639
        snap.statuses
4640
            .iter()
4641
            .any(|s| s.path == "libs/lib" && s.status == ChangeStatus::Removed),
4642
        "{:?}",
4643
        snap.statuses
4644
    );
4645
    let c = clone.commit("drop lib", &[]).unwrap();
4646
    assert!(!clone
4647
        .load_state(&[c])
4648
        .unwrap()
4649
        .materialize()
4650
        .files
4651
        .contains_key("libs/lib"));
4652
    assert!(clone_dir.join("libs/lib/lib.txt").exists());
4653
    assert!(
4654
        !clone.snapshot("status").unwrap().changed,
4655
        "an unregistered nested repository is ignored"
4656
    );
4657
}
4658
4659
#[test]
4660
fn git_submodules_import_and_export_as_gitlinks() {
4661
    hermetic();
4662
    if std::process::Command::new("git")
4663
        .arg("--version")
4664
        .output()
4665
        .is_err()
4666
    {
4667
        eprintln!("git not installed; skipping");
4668
        return;
4669
    }
4670
    let dir = tempfile::tempdir().unwrap();
4671
    let root = dir.path();
4672
    let git_in = |d: &Path, args: &[&str]| -> String {
4673
        let out = std::process::Command::new("git")
4674
            .arg("-C")
4675
            .arg(d)
4676
            .args([
4677
                "-c",
4678
                "user.name=Grace",
4679
                "-c",
4680
                "[email protected]",
4681
                "-c",
4682
                "commit.gpgsign=false",
4683
                "-c",
4684
                "protocol.file.allow=always",
4685
            ])
4686
            .args(args)
4687
            .output()
4688
            .unwrap();
4689
        assert!(
4690
            out.status.success(),
4691
            "git {args:?}: {}",
4692
            String::from_utf8_lossy(&out.stderr)
4693
        );
4694
        String::from_utf8_lossy(&out.stdout).trim().to_string()
4695
    };
4696
    let sub = root.join("sub");
4697
    fs::create_dir_all(&sub).unwrap();
4698
    git_in(&sub, &["init", "-q", "-b", "main"]);
4699
    write(&sub, "s.txt", b"sub\n");
4700
    git_in(&sub, &["add", "."]);
4701
    git_in(&sub, &["commit", "-q", "-m", "sub initial"]);
4702
    let sub_sha = git_in(&sub, &["rev-parse", "HEAD"]);
4703
    let g = root.join("main.git");
4704
    fs::create_dir_all(&g).unwrap();
4705
    git_in(&g, &["init", "-q", "-b", "main"]);
4706
    write(&g, "README.md", b"# app\n");
4707
    git_in(&g, &["add", "."]);
4708
    git_in(&g, &["commit", "-q", "-m", "initial"]);
4709
    git_in(
4710
        &g,
4711
        &[
4712
            "submodule",
4713
            "add",
4714
            "-q",
4715
            sub.to_str().unwrap(),
4716
            "vendor/sub",
4717
        ],
4718
    );
4719
    git_in(&g, &["commit", "-q", "-m", "add submodule"]);
4720
4721
    let dest = root.join("oxy");
4722
    let repo = Repo::init(&dest).unwrap().with_home(root.join("home"));
4723
    let rep = repo.import_git(&g, "main").unwrap();
4724
    assert_eq!((rep.commits, rep.submodules), (2, 1));
4725
    let files = repo
4726
        .load_state(&repo.bookmark_heads().unwrap())
4727
        .unwrap()
4728
        .materialize()
4729
        .files;
4730
    assert_eq!(files["vendor/sub"].kind, NodeKind::Subrepo);
4731
    let FileContent::Opaque { blobs } = &files["vendor/sub"].content else {
4732
        panic!()
4733
    };
4734
    assert_eq!(
4735
        crate::subrepo::Pin::decode(&repo.read_blob(&blobs[0]).unwrap()).unwrap(),
4736
        crate::subrepo::Pin::Git {
4737
            sha: sub_sha.clone(),
4738
            branch: "HEAD".into()
4739
        }
4740
    );
4741
    let modules =
4742
        crate::subrepo::Modules::parse(&fs::read_to_string(dest.join(".oxymodules")).unwrap());
4743
    assert_eq!(modules.entries["vendor/sub"].url, sub.to_string_lossy());
4744
    assert!(modules.entries["vendor/sub"].git);
4745
    assert!(
4746
        files.contains_key(".gitmodules"),
4747
        "the Git file is kept for round trips"
4748
    );
4749
    assert!(
4750
        !dest.join("vendor/sub/.git").exists(),
4751
        "not cloned until update --init"
4752
    );
4753
    assert!(!repo.snapshot("status").unwrap().changed);
4754
    // update --init clones it at the pinned commit; status is unchanged.
4755
    assert_eq!(
4756
        repo.submodule_update(true, &[]).unwrap(),
4757
        vec![("vendor/sub".to_string(), SubrepoSync::Updated)]
4758
    );
4759
    assert_eq!(
4760
        git_in(&dest.join("vendor/sub"), &["rev-parse", "HEAD"]),
4761
        sub_sha
4762
    );
4763
    assert!(!repo.snapshot("status").unwrap().changed);
4764
    // Export writes the gitlink and .gitmodules back.
4765
    let mirror = root.join("mirror.git");
4766
    let rep = repo.git_export(&mirror).unwrap();
4767
    assert_eq!(rep.skipped_subrepos, 0);
4768
    let ls = git_in(&mirror, &["ls-tree", "main", "vendor/sub"]);
4769
    assert!(
4770
        ls.starts_with("160000 commit ") && ls.contains(&sub_sha),
4771
        "{ls}"
4772
    );
4773
    assert_eq!(
4774
        git_in(&mirror, &["ls-tree", "--name-only", "main"]),
4775
        ".gitmodules\nREADME.md\nvendor"
4776
    );
4777
    let orig_tree = git_in(&g, &["rev-parse", "main^{tree}"]);
4778
    assert_eq!(
4779
        git_in(&mirror, &["rev-parse", "main^{tree}"]),
4780
        orig_tree,
4781
        "tree round trip"
4782
    );
4783
}
4784
4785
#[test]
4786
fn partial_commits_by_path_and_by_hunk() {
4787
    hermetic();
4788
    let dir = tempfile::tempdir().unwrap();
4789
    let root = dir.path();
4790
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
4791
    write(root, "a.txt", b"1\n2\n3\n");
4792
    write(root, "b.txt", b"b\n");
4793
    let h0 = repo.commit("base", &[]).unwrap();
4794
    write(root, "a.txt", b"0\n1\n2\n3\n4\n");
4795
    write(root, "b.txt", b"b\nbb\n");
4796
    write(root, "c.txt", b"c\n");
4797
    // By path: only a.txt goes in; the rest stays on disk as uncommitted work.
4798
    let h1 = repo
4799
        .commit_selection("a only", &[], &Selection::Paths(vec!["a.txt".into()]))
4800
        .unwrap();
4801
    let c1 = repo.store().get_change(&h1).unwrap();
4802
    assert!(
4803
        c1.tree_ops.is_empty() && c1.edits.iter().all(|e| e.node() == c1.edits[0].node()),
4804
        "{:?}",
4805
        c1.tree_ops
4806
    );
4807
    assert_eq!(c1.deps, vec![h0]);
4808
    let files = repo.load_state(&[h1]).unwrap().materialize().files;
4809
    assert!(
4810
        matches!(&files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"0\n1\n2\n3\n4\n")
4811
    );
4812
    assert!(matches!(&files["b.txt"].content, FileContent::Text { bytes, .. } if bytes == b"b\n"));
4813
    assert!(!files.contains_key("c.txt"));
4814
    let snap = repo.snapshot("status").unwrap();
4815
    assert_eq!(
4816
        snap.statuses
4817
            .iter()
4818
            .map(|s| (s.path.as_str(), s.status))
4819
            .collect::<Vec<_>>(),
4820
        vec![
4821
            ("b.txt", ChangeStatus::Modified),
4822
            ("c.txt", ChangeStatus::Added)
4823
        ]
4824
    );
4825
    assert_eq!(
4826
        fs::read(root.join("b.txt")).unwrap(),
4827
        b"b\nbb\n",
4828
        "disk untouched"
4829
    );
4830
    // By hunk: pick the b.txt insert; c.txt's addition is left.
4831
    let (_, hunks) = repo.hunks().unwrap();
4832
    let b_hunk = hunks
4833
        .iter()
4834
        .find(|h| h.path == "b.txt" && h.text.contains("+bb"))
4835
        .unwrap()
4836
        .index;
4837
    let h2 = repo
4838
        .commit_selection("b only", &[], &Selection::Hunks(vec![b_hunk]))
4839
        .unwrap();
4840
    let files = repo.load_state(&[h2]).unwrap().materialize().files;
4841
    assert!(
4842
        matches!(&files["b.txt"].content, FileContent::Text { bytes, .. } if bytes == b"b\nbb\n")
4843
    );
4844
    assert!(!files.contains_key("c.txt"));
4845
    let snap = repo.snapshot("status").unwrap();
4846
    assert_eq!(
4847
        snap.statuses
4848
            .iter()
4849
            .map(|s| s.path.as_str())
4850
            .collect::<Vec<_>>(),
4851
        vec!["c.txt"]
4852
    );
4853
    // Choosing only the content hunk of a file in a new directory pulls in
4854
    // the directory and file additions it needs.
4855
    write(root, "d/e.txt", b"e\n");
4856
    let (_, hunks) = repo.hunks().unwrap();
4857
    let e_insert = hunks
4858
        .iter()
4859
        .find(|h| h.path == "d/e.txt" && h.text.contains("+e"))
4860
        .unwrap()
4861
        .index;
4862
    let h3 = repo
4863
        .commit_selection("e only", &[], &Selection::Hunks(vec![e_insert]))
4864
        .unwrap();
4865
    let c3 = repo.store().get_change(&h3).unwrap();
4866
    assert_eq!(c3.tree_ops.len(), 2, "{:?}", c3.tree_ops);
4867
    let files = repo.load_state(&[h3]).unwrap().materialize().files;
4868
    assert!(files.contains_key("d/e.txt") && !files.contains_key("c.txt"));
4869
    // Choosing a new file's addition hunk brings its content along.
4870
    write(root, "f/g.txt", b"g\n");
4871
    let (_, hunks) = repo.hunks().unwrap();
4872
    let g_add = hunks
4873
        .iter()
4874
        .find(|h| h.path == "f/g.txt" && h.text.starts_with("A "))
4875
        .unwrap()
4876
        .index;
4877
    let h4 = repo
4878
        .commit_selection("g by addition", &[], &Selection::Hunks(vec![g_add]))
4879
        .unwrap();
4880
    let files = repo.load_state(&[h4]).unwrap().materialize().files;
4881
    assert!(
4882
        matches!(&files["f/g.txt"].content, FileContent::Text { bytes, .. } if bytes == b"g\n")
4883
    );
4884
    assert!(!repo
4885
        .snapshot("status")
4886
        .unwrap()
4887
        .statuses
4888
        .iter()
4889
        .any(|s| s.path == "f/g.txt"));
4890
    // Everything left commits normally; the working copy is then clean.
4891
    repo.commit("the rest", &[]).unwrap();
4892
    assert!(!repo.snapshot("status").unwrap().changed);
4893
    let files = repo
4894
        .load_state(&repo.bookmark_heads().unwrap())
4895
        .unwrap()
4896
        .materialize()
4897
        .files;
4898
    assert!(files.contains_key("c.txt"));
4899
}
4900
4901
#[test]
4902
fn blame_attributes_lines_to_introducing_changes_across_renames() {
4903
    hermetic();
4904
    let dir = tempfile::tempdir().unwrap();
4905
    let root = dir.path();
4906
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
4907
    repo.create_identity("Ada", "[email protected]", IdentityScope::User)
4908
        .unwrap();
4909
    write(root, "a.txt", b"one\n");
4910
    let h1 = repo.commit("first", &[]).unwrap();
4911
    write(root, "a.txt", b"one\ntwo\n");
4912
    let h2 = repo.commit("second", &[]).unwrap();
4913
    let b = repo.blame("a.txt").unwrap();
4914
    assert_eq!(
4915
        b.iter()
4916
            .map(|l| (l.line, l.hash, l.text.as_slice()))
4917
            .collect::<Vec<_>>(),
4918
        vec![(1, h1, b"one\n".as_slice()), (2, h2, b"two\n".as_slice())]
4919
    );
4920
    assert!(b.iter().all(|l| l.author == "Ada"));
4921
    assert_eq!(
4922
        b[0].change_id,
4923
        repo.store().get_change(&h1).unwrap().change_id
4924
    );
4925
    fs::rename(root.join("a.txt"), root.join("b.txt")).unwrap();
4926
    repo.commit("rename", &[]).unwrap();
4927
    let b = repo.blame("b.txt").unwrap();
4928
    assert_eq!(
4929
        b.iter().map(|l| l.hash).collect::<Vec<_>>(),
4930
        vec![h1, h2],
4931
        "the node survives the rename"
4932
    );
4933
    assert!(repo.blame("a.txt").is_err());
4934
}
4935
4936
#[test]
4937
fn bisect_finds_the_first_bad_change() {
4938
    hermetic();
4939
    let dir = tempfile::tempdir().unwrap();
4940
    let root = dir.path();
4941
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
4942
    let mut content = String::new();
4943
    let mut hashes = Vec::new();
4944
    for i in 1..=8 {
4945
        content.push_str(&format!("line {i}{}\n", if i == 5 { " bug" } else { "" }));
4946
        write(root, "f.txt", content.as_bytes());
4947
        hashes.push(repo.commit(&format!("step {i}"), &[]).unwrap());
4948
    }
4949
    // Each step appends after the previous line, so the chain is linear.
4950
    assert_eq!(repo.bookmark_heads().unwrap(), vec![hashes[7]]);
4951
    repo.bisect_start().unwrap();
4952
    assert!(matches!(
4953
        repo.bisect_mark(Some(true), &[hashes[0]]).unwrap(),
4954
        BisectStep::NeedMarks { good: 1, bad: 0 }
4955
    ));
4956
    let step = repo.bisect_mark(Some(false), &[hashes[7]]).unwrap();
4957
    assert!(
4958
        matches!(step, BisectStep::Testing { remaining: 7, .. }),
4959
        "{step:?}"
4960
    );
4961
    // Automated: bad when the bug line is present.
4962
    let step = repo
4963
        .bisect_run(&["sh".into(), "-c".into(), "! grep -q bug f.txt".into()])
4964
        .unwrap();
4965
    assert_eq!(step, BisectStep::Found { culprit: hashes[4] });
4966
    assert_eq!(
4967
        repo.bisect_status().unwrap(),
4968
        Some(BisectStep::Found { culprit: hashes[4] })
4969
    );
4970
    repo.bisect_reset().unwrap();
4971
    assert_eq!(fs::read_to_string(root.join("f.txt")).unwrap(), content);
4972
    assert!(repo.bisect_status().unwrap().is_none());
4973
    assert!(!repo.snapshot("status").unwrap().changed);
4974
    // Manual marks and a skip.
4975
    repo.bisect_start().unwrap();
4976
    repo.bisect_mark(Some(true), &[hashes[0]]).unwrap();
4977
    let mut step = repo.bisect_mark(Some(false), &[hashes[7]]).unwrap();
4978
    let mut skipped_once = false;
4979
    let mut guard = 0;
4980
    while let BisectStep::Testing { .. } = step {
4981
        guard += 1;
4982
        assert!(guard < 20);
4983
        let text = fs::read_to_string(root.join("f.txt")).unwrap();
4984
        if !skipped_once && text.contains("line 6") {
4985
            // An untestable state away from the boundary: the search routes around it.
4986
            skipped_once = true;
4987
            step = repo.bisect_mark(None, &[]).unwrap();
4988
            continue;
4989
        }
4990
        step = repo.bisect_mark(Some(!text.contains("bug")), &[]).unwrap();
4991
    }
4992
    assert!(skipped_once);
4993
    assert_eq!(step, BisectStep::Found { culprit: hashes[4] });
4994
    repo.bisect_reset().unwrap();
4995
    // Skipping the culprit itself leaves it among the candidates reported.
4996
    repo.bisect_start().unwrap();
4997
    repo.bisect_mark(Some(true), &[hashes[0]]).unwrap();
4998
    let mut step = repo.bisect_mark(Some(false), &[hashes[7]]).unwrap();
4999
    let mut guard = 0;
5000
    while let BisectStep::Testing { heads, .. } = &step {
5001
        guard += 1;
5002
        assert!(guard < 20);
5003
        let text = fs::read_to_string(root.join("f.txt")).unwrap();
5004
        let is_culprit_state =
5005
            heads.contains(&hashes[4]) && !heads.iter().any(|h| hashes[5..].contains(h));
5006
        step = if is_culprit_state {
5007
            repo.bisect_mark(None, &[]).unwrap()
5008
        } else {
5009
            repo.bisect_mark(Some(!text.contains("bug")), &[]).unwrap()
5010
        };
5011
    }
5012
    match step {
5013
        BisectStep::Inconclusive { candidates } => assert!(
5014
            candidates.contains(&hashes[4]) && candidates.len() <= 2,
5015
            "{candidates:?}"
5016
        ),
5017
        other => panic!("{other:?}"),
5018
    }
5019
    repo.bisect_reset().unwrap();
5020
}
5021
5022
#[test]
5023
fn squash_folds_a_change_into_its_dependency() {
5024
    hermetic();
5025
    let dir = tempfile::tempdir().unwrap();
5026
    let root = dir.path();
5027
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
5028
    write(root, "a.txt", b"1\n");
5029
    let a = repo.commit("A", &[]).unwrap();
5030
    write(root, "a.txt", b"1\n2\n");
5031
    let c = repo.commit("C", &[]).unwrap();
5032
    write(root, "a.txt", b"1\n2\n3\n");
5033
    let e = repo.commit("E", &[]).unwrap();
5034
    write(root, "b.txt", b"b\n");
5035
    let b = repo.commit("B", &[]).unwrap();
5036
    let id_a = repo.store().get_change(&a).unwrap().change_id;
5037
    let id_e = repo.store().get_change(&e).unwrap().change_id;
5038
    // E depends on C which A lacks: refused, naming it.
5039
    let err = repo.squash(&e, &a).unwrap_err().to_string();
5040
    assert!(err.contains(&c.short(12)), "{err}");
5041
    // C into A: A' has both, C is gone, E follows and still reads the same.
5042
    let a2 = repo.squash(&c, &a).unwrap();
5043
    let a2c = repo.store().get_change(&a2).unwrap();
5044
    assert_eq!(a2c.change_id, id_a);
5045
    assert!(a2c.deps.is_empty());
5046
    let heads = repo.bookmark_heads().unwrap();
5047
    let st = repo.load_state(&heads).unwrap();
5048
    assert!(!st.contains(&c) && !st.contains(&a) && !st.contains(&e));
5049
    let e2 = st
5050
        .changes()
5051
        .find(|(_, m)| m.change_id == id_e)
5052
        .map(|(h, _)| *h)
5053
        .unwrap();
5054
    assert_eq!(
5055
        repo.store().get_change(&e2).unwrap().deps,
5056
        vec![a2],
5057
        "E now depends on A' alone"
5058
    );
5059
    let files = st.materialize().files;
5060
    assert!(
5061
        matches!(&files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"1\n2\n3\n")
5062
    );
5063
    assert!(files.contains_key("b.txt"));
5064
    assert!(st.contains(&b));
5065
    assert!(!repo.snapshot("status").unwrap().changed);
5066
    assert_eq!(repo.log().unwrap().len(), 4, "working copy, B, E', A'");
5067
    // Undo restores the three original changes.
5068
    repo.undo().unwrap();
5069
    repo.undo().unwrap();
5070
    let st = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap();
5071
    assert!(st.contains(&c) && st.contains(&a) && st.contains(&e));
5072
}
5073
5074
#[test]
5075
fn rebase_re_expresses_a_change_against_another_base() {
5076
    hermetic();
5077
    let dir = tempfile::tempdir().unwrap();
5078
    let root = dir.path();
5079
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
5080
    write(root, "a.txt", b"1\n2\n");
5081
    let a = repo.commit("A", &[]).unwrap();
5082
    write(root, "a.txt", b"1\n2\n3\n");
5083
    let c = repo.commit("C: append 3", &[]).unwrap();
5084
    write(root, "a.txt", b"1\n2\n3\n4\n");
5085
    let e = repo.commit("E: append 4", &[]).unwrap();
5086
    // A different base: A plus a change that prepends a line.
5087
    repo.checkout(&[a], true, "checkout").unwrap();
5088
    write(root, "a.txt", b"0\n1\n2\n");
5089
    let d = repo.commit("D: prepend 0", &[]).unwrap();
5090
    let id_c = repo.store().get_change(&c).unwrap().change_id;
5091
    let id_e = repo.store().get_change(&e).unwrap().change_id;
5092
    // Rebase C onto D: its effect (append 3) re-derived against D's text.
5093
    let c2 = repo.rebase(&c, &[d]).unwrap();
5094
    let c2c = repo.store().get_change(&c2).unwrap();
5095
    assert_eq!(c2c.change_id, id_c);
5096
    assert!(c2c.deps.contains(&d), "{:?}", c2c.deps);
5097
    let heads = repo.bookmark_heads().unwrap();
5098
    let st = repo.load_state(&heads).unwrap();
5099
    assert!(!st.contains(&c) && st.contains(&d));
5100
    let e2 = st
5101
        .changes()
5102
        .find(|(_, m)| m.change_id == id_e)
5103
        .map(|(h, _)| *h)
5104
        .unwrap();
5105
    let e2deps = repo.store().get_change(&e2).unwrap().deps;
5106
    assert!(
5107
        e2deps.contains(&c2) && !e2deps.contains(&c) && e2deps.contains(&a),
5108
        "E follows C by content and still depends on A, which introduced the file: {e2deps:?}"
5109
    );
5110
    let files = st.materialize().files;
5111
    assert!(
5112
        matches!(&files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"0\n1\n2\n3\n4\n"),
5113
        "{:?}",
5114
        files["a.txt"].content
5115
    );
5116
    assert!(!files["a.txt"].is_conflicted());
5117
    // Rebasing onto a state that already contains the change is refused.
5118
    assert!(repo.rebase(&c2, &[c2]).is_err());
5119
    let err = repo.rebase(&e2, &[d]).unwrap_err().to_string();
5120
    assert!(
5121
        err.contains("lacks dependencies"),
5122
        "E needs C, which D's state does not have: {err}"
5123
    );
5124
}
5125
5126
#[test]
5127
fn unsigned_tags_keep_their_message() {
5128
    hermetic();
5129
    let dir = tempfile::tempdir().unwrap();
5130
    let root = dir.path();
5131
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
5132
    write(root, "a.txt", b"a\n");
5133
    repo.commit("a", &[]).unwrap();
5134
    assert!(repo.secret_key().unwrap().is_none());
5135
    repo.tag_create("v1", &repo.bookmark_heads().unwrap(), "first release")
5136
        .unwrap();
5137
    let tags = repo.tags().unwrap();
5138
    assert_eq!(
5139
        (
5140
            tags[0].name.as_str(),
5141
            tags[0].message.as_str(),
5142
            tags[0].signer.as_deref()
5143
        ),
5144
        ("v1", "first release", None)
5145
    );
5146
    assert!(
5147
        repo.tag_refs().unwrap()[0].attestation.is_none(),
5148
        "an unsigned attestation is not advertised"
5149
    );
5150
}
5151
5152
#[test]
5153
fn fsck_passes_a_sound_repository_and_names_each_fault() {
5154
    // Files on disk are what this tests: tampering with them, and the
5155
    // directory store's crash points.
5156
    if std::env::var_os("OXY_STORE_BACKEND").is_some() {
5157
        return;
5158
    }
5159
    hermetic();
5160
    let dir = tempfile::tempdir().unwrap();
5161
    let root = dir.path();
5162
    let origin_path = root.join("origin");
5163
    let origin = Repo::init(&origin_path).unwrap();
5164
    let mut a = Repo::clone(&origin_path.to_string_lossy(), &root.join("a"))
5165
        .unwrap()
5166
        .with_home(root.join("home-a"));
5167
    a.create_identity("Ada", "[email protected]", IdentityScope::Repo)
5168
        .unwrap();
5169
    write(&root.join("a"), "f.txt", b"one\n");
5170
    write(&root.join("a"), "bin.dat", b"\0\x01\x02binary");
5171
    let h1 = a.commit("first", &[]).unwrap();
5172
    write(&root.join("a"), "f.txt", b"one\ntwo\n");
5173
    let h2 = a.commit("second", &[]).unwrap();
5174
    a.tag_create("v1", &[h2], "first release").unwrap();
5175
    a.checkpoint().unwrap();
5176
    assert!(a.push("origin", false).unwrap()[0].ok);
5177
    origin.refresh().unwrap();
5178
5179
    // Sound: signed changes, a seal, a tag, a checkpoint, a ref attestation.
5180
    for repo in [&a, &origin] {
5181
        let rep = repo.fsck().unwrap();
5182
        assert_eq!(rep.problems, vec![], "{rep:?}");
5183
        // The two commits, plus the working copy's change in `a`.
5184
        assert!(rep.changes >= 2 && rep.signed >= 2, "{rep:?}");
5185
        assert!(rep.ops > 1 && rep.objects > 5);
5186
    }
5187
    assert!(origin.fsck().unwrap().attestations >= 1);
5188
5189
    // A flipped byte in a stored object is corruption, named by hash.
5190
    let c1 = a.store().path_for(&h1);
5191
    let pristine = fs::read(&c1).unwrap();
5192
    let mut bytes = pristine.clone();
5193
    *bytes.last_mut().unwrap() ^= 1;
5194
    fs::write(&c1, &bytes).unwrap();
5195
    let rep = a.fsck().unwrap();
5196
    assert!(rep.errors() >= 1);
5197
    assert!(
5198
        rep.problems
5199
            .iter()
5200
            .any(|p| p.object == Some(h1) && p.what.contains("unreadable")),
5201
        "{rep:?}"
5202
    );
5203
    fs::write(&c1, &pristine).unwrap();
5204
    assert_eq!(a.fsck().unwrap().errors(), 0);
5205
5206
    // A missing change is named with what needed it.
5207
    fs::remove_file(&c1).unwrap();
5208
    let rep = a.fsck().unwrap();
5209
    assert!(
5210
        rep.problems.iter().any(|p| p.severity == Severity::Error
5211
            && p.object == Some(h1)
5212
            && p.what.contains("change missing, needed by change")),
5213
        "{rep:?}"
5214
    );
5215
    fs::write(&c1, &pristine).unwrap();
5216
5217
    // A missing blob is an error in a full clone.
5218
    let c2 = a.store().get_change(&h1).unwrap();
5219
    let blob = c2
5220
        .edits
5221
        .iter()
5222
        .find_map(|e| match e {
5223
            Edit::Replace { new: Some(b), .. } => Some(*b),
5224
            _ => None,
5225
        })
5226
        .expect("the binary file is a blob");
5227
    let bp = a.store().path_for(&blob);
5228
    let blob_bytes = fs::read(&bp).unwrap();
5229
    fs::remove_file(&bp).unwrap();
5230
    let rep = a.fsck().unwrap();
5231
    assert!(
5232
        rep.problems
5233
            .iter()
5234
            .any(|p| p.object == Some(blob) && p.what.contains("blob missing")),
5235
        "{rep:?}"
5236
    );
5237
    fs::write(&bp, &blob_bytes).unwrap();
5238
5239
    // A broken op log head is reported, not a crash.
5240
    let head = root.join("a/.oxy/ops/head");
5241
    let saved = fs::read(&head).unwrap();
5242
    fs::write(&head, b"not a hash").unwrap();
5243
    let rep = a.fsck().unwrap();
5244
    assert!(
5245
        rep.problems.iter().any(|p| p.what.contains("ops/head")),
5246
        "{rep:?}"
5247
    );
5248
    fs::write(&head, saved).unwrap();
5249
    assert_eq!(a.fsck().unwrap().problems, vec![]);
5250
}
5251
5252
/// Copy a directory tree (for restoring a pristine repository).
5253
fn copy_tree(from: &Path, to: &Path) {
5254
    fs::create_dir_all(to).unwrap();
5255
    for e in fs::read_dir(from).unwrap() {
5256
        let e = e.unwrap();
5257
        let target = to.join(e.file_name());
5258
        if e.file_type().unwrap().is_dir() {
5259
            copy_tree(&e.path(), &target);
5260
        } else {
5261
            fs::copy(e.path(), &target).unwrap();
5262
        }
5263
    }
5264
}
5265
5266
/// Spec §13.6: stop `commit` and `push` at every durable step, as a crash
5267
/// would, and check that both repositories still open, pass `fsck`, and
5268
/// accept the same operation again.
5269
#[test]
5270
fn crash_at_every_step_of_commit_and_push_leaves_sound_repositories() {
5271
    // Files on disk are what this tests: tampering with them, and the
5272
    // directory store's crash points.
5273
    if std::env::var_os("OXY_STORE_BACKEND").is_some() {
5274
        return;
5275
    }
5276
    use crate::durable::failpoint;
5277
    hermetic();
5278
    let dir = tempfile::tempdir().unwrap();
5279
    let pristine = dir.path().join("pristine");
5280
    let origin_path = pristine.join("origin");
5281
    Repo::init(&origin_path).unwrap();
5282
    let mut a = Repo::clone(&origin_path.to_string_lossy(), &pristine.join("a"))
5283
        .unwrap()
5284
        .with_home(dir.path().join("home"));
5285
    a.create_identity("Ada", "[email protected]", IdentityScope::Repo)
5286
        .unwrap();
5287
    write(&pristine.join("a"), "f.txt", b"one\n");
5288
    a.commit("first", &[]).unwrap();
5289
    assert!(a.push("origin", false).unwrap()[0].ok);
5290
    drop(a);
5291
5292
    type Op = fn(&Repo) -> Result<()>;
5293
    let edit_and_commit: Op = |r| {
5294
        write(r.root(), "f.txt", b"one\ntwo\n");
5295
        write(r.root(), "big.bin", &vec![7u8; 300_000]);
5296
        r.commit("second", &[]).map(|_| ())
5297
    };
5298
    let push: Op = |r| {
5299
        let res = r.push("origin", false)?;
5300
        if res.iter().all(|x| x.ok) {
5301
            Ok(())
5302
        } else {
5303
            Err(RepoError::Msg(format!("{res:?}")))
5304
        }
5305
    };
5306
5307
    for (name, prepare, op) in [
5308
        ("commit", None, edit_and_commit),
5309
        ("push", Some(edit_and_commit), push),
5310
    ] {
5311
        let mut steps = 0;
5312
        for n in 1.. {
5313
            let run = dir.path().join(format!("{name}-{n}"));
5314
            copy_tree(&pristine, &run);
5315
            // Clones record their remote by path; point it at this copy.
5316
            let open = |p: &Path| {
5317
                let mut r = Repo::open(p).unwrap().with_home(dir.path().join("home"));
5318
                r.config
5319
                    .set("remote.origin.url", &run.join("origin").to_string_lossy())
5320
                    .unwrap();
5321
                r.save_config().unwrap();
5322
                r
5323
            };
5324
            let a = open(&run.join("a"));
5325
            if let Some(prep) = prepare {
5326
                prep(&a).unwrap();
5327
            }
5328
            failpoint::crash_after(n);
5329
            let outcome = op(&a);
5330
            let crashed = failpoint::disarm();
5331
            drop(a);
5332
            if !crashed {
5333
                outcome.unwrap_or_else(|e| panic!("{name} failed without a crash: {e}"));
5334
                steps = n - 1;
5335
                break;
5336
            }
5337
            assert!(
5338
                outcome.is_err(),
5339
                "{name}: a crash at step {n} was swallowed"
5340
            );
5341
            for repo in ["a", "origin"] {
5342
                let r = Repo::open(&run.join(repo)).unwrap_or_else(|e| {
5343
                    panic!("{name} crash at step {n}: {repo} no longer opens: {e}")
5344
                });
5345
                let rep = r.fsck().unwrap();
5346
                assert_eq!(
5347
                    rep.errors(),
5348
                    0,
5349
                    "{name} crash at step {n}: {repo} fails fsck: {:?}",
5350
                    rep.problems
5351
                );
5352
            }
5353
            // The interrupted operation can simply be run again.
5354
            let a = open(&run.join("a"));
5355
            // A crash after the commit point leaves the commit done.
5356
            let landed = a
5357
                .bookmark_heads()
5358
                .unwrap()
5359
                .iter()
5360
                .any(|h| a.store().get_change(h).unwrap().message == "second");
5361
            let again = match (name, landed) {
5362
                ("commit", true) => Ok(()),
5363
                ("commit", false) => a.commit("second", &[]).map(|_| ()),
5364
                _ => op(&a),
5365
            };
5366
            again.unwrap_or_else(|e| panic!("{name} crash at step {n}: retry failed: {e}"));
5367
            assert_eq!(a.fsck().unwrap().errors(), 0);
5368
            fs::remove_dir_all(&run).unwrap();
5369
        }
5370
        assert!(steps >= 3, "{name} has only {steps} durable steps?");
5371
    }
5372
}
5373
5374
/// Spec §12.1: commands in two workspaces of one repository, and pushes,
5375
/// running at the same time lose nothing: every commit ends up on the
5376
/// bookmark, locally and on the remote, and the repository passes fsck.
5377
#[test]
5378
fn concurrent_commits_in_two_workspaces_lose_nothing() {
5379
    hermetic();
5380
    let dir = tempfile::tempdir().unwrap();
5381
    let root = dir.path();
5382
    let origin = root.join("origin");
5383
    Repo::init_bare_unbound(&origin).unwrap();
5384
    let main = root.join("main");
5385
    let mut repo = Repo::init(&main).unwrap();
5386
    repo.config
5387
        .set("remote.origin.url", &origin.to_string_lossy())
5388
        .unwrap();
5389
    repo.save_config().unwrap();
5390
    write(&main, "seed.txt", b"seed\n");
5391
    repo.commit("seed", &[]).unwrap();
5392
    let second = root.join("second");
5393
    repo.workspace_add(&second, None).unwrap();
5394
    drop(repo);
5395
5396
    const EACH: usize = 15;
5397
    std::thread::scope(|s| {
5398
        for (tag, dir, push) in [("a", main.clone(), true), ("b", second.clone(), false)] {
5399
            s.spawn(move || {
5400
                let r = Repo::open(&dir).unwrap();
5401
                for i in 0..EACH {
5402
                    write(
5403
                        &dir,
5404
                        &format!("{tag}-{i}.txt"),
5405
                        format!("{tag} {i}\n").as_bytes(),
5406
                    );
5407
                    r.commit(&format!("{tag} {i}"), &[]).unwrap();
5408
                    if push && i % 5 == 4 {
5409
                        // A push may be refused while the other workspace moves the bookmark; nothing may be lost either way.
5410
                        let _ = r.push("origin", false);
5411
                    }
5412
                }
5413
            });
5414
        }
5415
    });
5416
5417
    let repo = Repo::open(&main).unwrap();
5418
    let heads = repo.bookmark_heads().unwrap();
5419
    let state = repo.load_state(&heads).unwrap();
5420
    let messages: BTreeSet<String> = state.changes().map(|(_, m)| m.message.clone()).collect();
5421
    for tag in ["a", "b"] {
5422
        for i in 0..EACH {
5423
            assert!(
5424
                messages.contains(&format!("{tag} {i}")),
5425
                "lost commit {tag} {i}; have {messages:?}"
5426
            );
5427
        }
5428
    }
5429
    let rep = repo.fsck().unwrap();
5430
    assert_eq!(rep.errors(), 0, "{:?}", rep.problems);
5431
    assert!(repo.push("origin", false).unwrap().iter().all(|r| r.ok));
5432
    let hosted = Repo::open(&origin).unwrap();
5433
    let hosted_heads = hosted.bookmark_heads().unwrap();
5434
    assert_eq!(
5435
        hosted.load_state(&hosted_heads).unwrap().len(),
5436
        state.len(),
5437
        "the remote has every commit"
5438
    );
5439
}
5440
5441
/// Two operations recorded from the same parent (what two processes do
5442
/// when they race) are merged: both bookmarks' heads survive, and a
5443
/// change made on one side only is kept.
5444
#[test]
5445
fn operations_from_the_same_parent_are_merged() {
5446
    hermetic();
5447
    let dir = tempfile::tempdir().unwrap();
5448
    let root = dir.path();
5449
    let repo = Repo::init(root).unwrap();
5450
    write(root, "a.txt", b"a\n");
5451
    let a = repo.commit("a", &[]).unwrap();
5452
    write(root, "b.txt", b"b\n");
5453
    let b = repo.commit("b", &[]).unwrap();
5454
    let (x, op) = repo.head_op().unwrap();
5455
    let state = |heads: Vec<ChangeHash>| repo.store().put_state(&StateObject::new(heads)).unwrap();
5456
5457
    // Side one moves main to just `a` and adds a bookmark; side two moves main to just `b`.
5458
    let mut one = op.view.clone();
5459
    one.bookmarks.insert("main".into(), state(vec![a]));
5460
    one.bookmarks.insert("topic".into(), state(vec![a]));
5461
    let mut two = op.view.clone();
5462
    two.bookmarks.insert("main".into(), state(vec![b]));
5463
    repo.record_op(&[x], "one", one).unwrap();
5464
    let merged = repo.record_op(&[x], "two", two).unwrap();
5465
5466
    let (head, op) = repo.head_op().unwrap();
5467
    assert_eq!(head, merged);
5468
    assert_eq!(op.parents.len(), 2, "a merge of the two operations");
5469
    assert!(op.command.contains("merged with a concurrent operation"));
5470
    let mut main = repo
5471
        .store()
5472
        .get_state(&op.view.bookmarks["main"])
5473
        .unwrap()
5474
        .heads;
5475
    main.sort();
5476
    let mut want = vec![a, b];
5477
    want.sort();
5478
    assert_eq!(main, want, "both sides' heads are kept");
5479
    assert!(
5480
        op.view.bookmarks.contains_key("topic"),
5481
        "a one-sided change survives"
5482
    );
5483
    assert_eq!(repo.fsck().unwrap().errors(), 0);
5484
}
5485
5486
#[test]
5487
fn failed_requests_come_with_a_next_step() {
5488
    use crate::protocol::hint_for;
5489
    assert!(hint_for("https://h/x/oxy/v1/push: HTTP 401: bad token")
5490
        .unwrap()
5491
        .contains("token"));
5492
    assert!(hint_for("…: HTTP 403: forbidden")
5493
        .unwrap()
5494
        .contains("repo:write"));
5495
    assert!(hint_for("…: HTTP 404: no such repository")
5496
        .unwrap()
5497
        .contains("owner and repository name"));
5498
    assert!(hint_for("invalid peer certificate: UnknownIssuer")
5499
        .unwrap()
5500
        .contains("oxyhub-ca.pem"));
5501
    assert!(hint_for("Connect error: Connection refused (os error 111)")
5502
        .unwrap()
5503
        .contains("running"));
5504
    assert!(hint_for("Dns Failed: failed to lookup address information")
5505
        .unwrap()
5506
        .contains("resolve"));
5507
    assert_eq!(hint_for("something else entirely"), None);
5508
}
5509
5510
#[test]
5511
fn doctor_names_problems_and_what_to_do() {
5512
    hermetic();
5513
    let dir = tempfile::tempdir().unwrap();
5514
    let root = dir.path();
5515
    let origin = root.join("origin");
5516
    Repo::init_bare_unbound(&origin).unwrap();
5517
    let mut repo = Repo::init(&root.join("r"))
5518
        .unwrap()
5519
        .with_home(root.join("home"));
5520
    let find = |r: &DoctorReport, area: &str, h: Health| {
5521
        r.checks
5522
            .iter()
5523
            .find(|c| c.area == area && c.health == h)
5524
            .cloned()
5525
    };
5526
5527
    let r = repo.doctor(true);
5528
    assert!(find(&r, "identity", Health::Warn)
5529
        .unwrap()
5530
        .hint
5531
        .unwrap()
5532
        .contains("oxy identity create"));
5533
    assert!(find(&r, "repository", Health::Ok).is_some());
5534
5535
    repo.create_identity("Ada", "[email protected]", IdentityScope::Repo)
5536
        .unwrap();
5537
    repo.config.set("author.name", "Ada").unwrap();
5538
    repo.config.set("author.email", "[email protected]").unwrap();
5539
    repo.config
5540
        .set("remote.origin.url", &origin.to_string_lossy())
5541
        .unwrap();
5542
    repo.config
5543
        .set("remote.down.url", "http://127.0.0.1:9/a/b")
5544
        .unwrap();
5545
    repo.config.set("remote.down.token", "t").unwrap();
5546
    repo.config
5547
        .set("remote.badca.url", "https://example.invalid/a/b")
5548
        .unwrap();
5549
    repo.config
5550
        .set(
5551
            "remote.badca.ca",
5552
            &root.join("missing.pem").to_string_lossy(),
5553
        )
5554
        .unwrap();
5555
    repo.save_config().unwrap();
5556
5557
    let r = repo.doctor(true);
5558
    assert!(find(&r, "identity", Health::Ok).is_some(), "{r:?}");
5559
    assert!(find(&r, "author", Health::Ok).is_some(), "{r:?}");
5560
    assert!(find(&r, "remote origin", Health::Ok)
5561
        .unwrap()
5562
        .message
5563
        .contains("first push binds it"));
5564
    let down = find(&r, "remote down", Health::Fail).unwrap();
5565
    assert!(
5566
        down.hint.as_deref().unwrap_or("").contains("running"),
5567
        "{down:?}"
5568
    );
5569
    let badca = find(&r, "remote badca", Health::Fail).unwrap();
5570
    assert!(
5571
        badca.hint.unwrap().contains("remote.badca.ca"),
5572
        "the remote's own name is in the hint"
5573
    );
5574
    assert_eq!(r.worst(), Health::Fail);
5575
5576
    // Offline, nothing is contacted.
5577
    let r = repo.doctor(false);
5578
    assert!(find(&r, "remote down", Health::Fail).is_none());
5579
    assert!(find(&r, "remote down", Health::Ok)
5580
        .unwrap()
5581
        .message
5582
        .contains("not contacted"));
5583
}
5584
5585
/// Spec §8.1: deleting a line depends on whoever inserted the lines right
5586
/// after it. Inserting after `b` and then deleting `b`, one commit after
5587
/// the other, must not leave a delete/insert conflict.
5588
#[test]
5589
fn deleting_a_line_after_an_insertion_below_it_is_not_a_conflict() {
5590
    hermetic();
5591
    let dir = tempfile::tempdir().unwrap();
5592
    let root = dir.path();
5593
    let repo = Repo::init(root).unwrap();
5594
    write(root, "f", b"a\nb\nc\n");
5595
    repo.commit("base", &[]).unwrap();
5596
    write(root, "f", b"a\nb\nx\nc\n");
5597
    let insert = repo.commit("insert x after b", &[]).unwrap();
5598
    write(root, "f", b"a\nx\nc\n");
5599
    let delete = repo.commit("delete b", &[]).unwrap();
5600
    assert!(
5601
        repo.store()
5602
            .get_change(&delete)
5603
            .unwrap()
5604
            .deps
5605
            .contains(&insert),
5606
        "the deletion depends on the insertion below the deleted line"
5607
    );
5608
    let heads = repo.bookmark_heads().unwrap();
5609
    assert_eq!(heads, vec![delete]);
5610
    let snap = repo.load_state(&heads).unwrap().materialize();
5611
    assert!(!snap.is_conflicted());
5612
    assert!(
5613
        matches!(&snap.files["f"].content, FileContent::Text { bytes, .. } if bytes == b"a\nx\nc\n")
5614
    );
5615
}
5616
5617
/// Spec §9.3: a binary file reverted to an earlier blob keeps it, and each
5618
/// replacement depends on the change that set the blob it replaces.
5619
#[test]
5620
fn a_binary_file_reverted_to_an_earlier_blob_keeps_it() {
5621
    hermetic();
5622
    let dir = tempfile::tempdir().unwrap();
5623
    let root = dir.path();
5624
    let repo = Repo::init(root).unwrap();
5625
    write(root, "bin", b"\0A");
5626
    let v1 = repo.commit("v1", &[]).unwrap();
5627
    write(root, "bin", b"\0B");
5628
    let v2 = repo.commit("v2", &[]).unwrap();
5629
    assert!(repo.store().get_change(&v2).unwrap().deps.contains(&v1));
5630
    write(root, "bin", b"\0A");
5631
    let v3 = repo.commit("back to A", &[]).unwrap();
5632
    assert!(repo.store().get_change(&v3).unwrap().deps.contains(&v2));
5633
    assert_eq!(repo.bookmark_heads().unwrap(), vec![v3]);
5634
    let state = repo.load_state(&[v3]).unwrap();
5635
    let snap = state.materialize();
5636
    let FileContent::Opaque { blobs } = &snap.files["bin"].content else {
5637
        panic!("opaque")
5638
    };
5639
    assert_eq!(blobs.len(), 1, "one live blob, not none and not two");
5640
    assert_eq!(repo.read_blob(&blobs[0]).unwrap(), b"\0A");
5641
}
5642
5643
/// Spec §23: a binary file changed on two branches and merged in Git to
5644
/// one side's version comes back as that version at the merge, and the
5645
/// import verifies commit by commit.
5646
#[test]
5647
fn git_import_resolves_a_binary_merged_in_git_and_verifies() {
5648
    hermetic();
5649
    if std::process::Command::new("git")
5650
        .arg("--version")
5651
        .output()
5652
        .is_err()
5653
    {
5654
        eprintln!("git not installed; skipping");
5655
        return;
5656
    }
5657
    let dir = tempfile::tempdir().unwrap();
5658
    let root = dir.path();
5659
    let g = root.join("src.git");
5660
    fs::create_dir_all(&g).unwrap();
5661
    let git = |args: &[&str]| -> String {
5662
        let out = std::process::Command::new("git")
5663
            .arg("-C")
5664
            .arg(&g)
5665
            .args([
5666
                "-c",
5667
                "user.name=Grace",
5668
                "-c",
5669
                "[email protected]",
5670
                "-c",
5671
                "commit.gpgsign=false",
5672
            ])
5673
            .args(args)
5674
            .output()
5675
            .unwrap();
5676
        assert!(
5677
            out.status.success(),
5678
            "git {args:?}: {}",
5679
            String::from_utf8_lossy(&out.stderr)
5680
        );
5681
        String::from_utf8_lossy(&out.stdout).trim().to_string()
5682
    };
5683
    git(&["init", "-q", "-b", "main"]);
5684
    write(&g, "logo.bin", b"\0base");
5685
    write(&g, "notes.txt", b"a\nb\nc\n");
5686
    git(&["add", "."]);
5687
    git(&["commit", "-qm", "base"]);
5688
    git(&["checkout", "-qb", "side"]);
5689
    write(&g, "logo.bin", b"\0side");
5690
    write(&g, "notes.txt", b"a\nb\nx\nc\n");
5691
    git(&["commit", "-qam", "side"]);
5692
    git(&["checkout", "-q", "main"]);
5693
    write(&g, "logo.bin", b"\0main");
5694
    git(&["commit", "-qam", "main"]);
5695
    // Git cannot merge the binary; the merge takes main's and keeps side's text.
5696
    let _ = std::process::Command::new("git")
5697
        .arg("-C")
5698
        .arg(&g)
5699
        .args([
5700
            "-c",
5701
            "user.name=G",
5702
            "-c",
5703
            "user.email=g@e",
5704
            "merge",
5705
            "-q",
5706
            "side",
5707
        ])
5708
        .output();
5709
    write(&g, "logo.bin", b"\0main");
5710
    git(&["add", "."]);
5711
    git(&["commit", "-qm", "merge side"]);
5712
    // Then a revert of the binary to its first version.
5713
    write(&g, "logo.bin", b"\0base");
5714
    git(&["commit", "-qam", "back to base"]);
5715
5716
    let repo = Repo::init(&root.join("o")).unwrap();
5717
    let rep = repo.import_git(&g, "main").unwrap();
5718
    assert_eq!(rep.merges, 1);
5719
    let v = repo.verify_git_import(&g, "main").unwrap();
5720
    assert!(v.mismatches.is_empty(), "{:?}", v.mismatches);
5721
    assert_eq!(v.commits, 5);
5722
    let snap = repo
5723
        .load_state(&repo.bookmark_heads().unwrap())
5724
        .unwrap()
5725
        .materialize();
5726
    assert!(!snap.is_conflicted());
5727
    let FileContent::Opaque { blobs } = &snap.files["logo.bin"].content else {
5728
        panic!("opaque")
5729
    };
5730
    assert_eq!(repo.read_blob(&blobs[0]).unwrap(), b"\0base");
5731
}
5732
5733
/// Spec §7.7 on a file system without executable bits or symlinks: the
5734
/// recorded bit is kept, and a symlink checked out as a plain file holding
5735
/// its target is still that symlink.
5736
#[cfg(unix)]
5737
#[test]
5738
fn windows_file_systems_keep_exec_bits_and_symlinks() {
5739
    use crate::portable::Platform;
5740
    use std::os::unix::fs::PermissionsExt;
5741
    hermetic();
5742
    let dir = tempfile::tempdir().unwrap();
5743
    let root = dir.path();
5744
    let repo = Repo::init(root).unwrap();
5745
    write(root, "run.sh", b"#!/bin/sh\n");
5746
    fs::set_permissions(root.join("run.sh"), fs::Permissions::from_mode(0o755)).unwrap();
5747
    std::os::unix::fs::symlink("run.sh", root.join("link")).unwrap();
5748
    repo.commit("script and link", &[]).unwrap();
5749
    drop(repo);
5750
5751
    // What checking out on Windows leaves: no exec bit, the link as a file.
5752
    fs::set_permissions(root.join("run.sh"), fs::Permissions::from_mode(0o644)).unwrap();
5753
    fs::remove_file(root.join("link")).unwrap();
5754
    write(root, "link", b"run.sh");
5755
    let win = Repo::open(root).unwrap().with_platform(Platform::windows());
5756
    let s = win.snapshot("oxy status").unwrap();
5757
    assert!(
5758
        s.statuses.is_empty(),
5759
        "nothing changed on Windows: {:?}",
5760
        statuses(&s)
5761
    );
5762
    // Editing the file is a content change, not a mode change; retargeting the link keeps it a link.
5763
    write(root, "run.sh", b"#!/bin/sh\necho hi\n");
5764
    write(root, "link", b"other");
5765
    let h = win.commit("edit on Windows", &[]).unwrap();
5766
    let snap = win.load_state(&[h]).unwrap().materialize();
5767
    assert!(snap.files["run.sh"].mode.exec, "the exec bit survives");
5768
    assert_eq!(snap.files["link"].kind, NodeKind::Symlink);
5769
    // The same working copy read as Linux sees the lost bit and the plain file.
5770
    let linux = Repo::open(root).unwrap();
5771
    assert!(!linux.snapshot("oxy status").unwrap().statuses.is_empty());
5772
}
5773
5774
/// Paths that collide on case-insensitive or Windows file systems are
5775
/// named at checkout there, and by doctor everywhere.
5776
#[test]
5777
fn paths_other_platforms_cannot_hold_are_reported() {
5778
    use crate::portable::Platform;
5779
    hermetic();
5780
    let dir = tempfile::tempdir().unwrap();
5781
    let root = dir.path();
5782
    let repo = Repo::init(root).unwrap();
5783
    write(root, "README.md", b"a\n");
5784
    write(root, "readme.md", b"b\n");
5785
    write(root, "src/aux.c", b"int x;\n");
5786
    let h = repo.commit("unportable", &[]).unwrap();
5787
    let r = repo.doctor(false);
5788
    let port = r.checks.iter().find(|c| c.area == "portability").unwrap();
5789
    assert_eq!(port.health, Health::Warn);
5790
    assert!(
5791
        port.message.contains("differ only in case") && port.message.contains("aux.c"),
5792
        "{}",
5793
        port.message
5794
    );
5795
    let mac = Repo::open(root).unwrap().with_platform(Platform::macos());
5796
    let rep = mac.checkout(&[h], true, "oxy checkout").unwrap();
5797
    assert!(
5798
        rep.notes
5799
            .iter()
5800
            .any(|n| n.contains("README.md and readme.md")),
5801
        "{:?}",
5802
        rep.notes
5803
    );
5804
    assert!(
5805
        !rep.notes.iter().any(|n| n.contains("aux.c")),
5806
        "macOS allows aux.c"
5807
    );
5808
}
5809
5810
#[test]
5811
fn history_and_blame_of_a_loaded_state_match_the_full_walk() {
5812
    hermetic();
5813
    let dir = tempfile::tempdir().unwrap();
5814
    let repo = Repo::init(dir.path()).unwrap();
5815
    write(dir.path(), "a.txt", b"one\n");
5816
    write(dir.path(), "other.txt", b"x\n");
5817
    write(dir.path(), "bin.dat", &[0u8, 1, 2, 0, 255]);
5818
    repo.snapshot("t").unwrap();
5819
    let c1 = repo.commit("first", &[]).unwrap();
5820
    write(dir.path(), "other.txt", b"y\n");
5821
    repo.snapshot("t").unwrap();
5822
    repo.commit("other only", &[]).unwrap();
5823
    write(dir.path(), "a.txt", b"one\ntwo\n");
5824
    repo.snapshot("t").unwrap();
5825
    let c3 = repo.commit("second line", &[]).unwrap();
5826
    write(dir.path(), "other.txt", b"z\n");
5827
    repo.snapshot("t").unwrap();
5828
    repo.commit("other again", &[]).unwrap();
5829
5830
    let heads = repo.bookmark_heads().unwrap();
5831
    let state = repo.load_state(&heads).unwrap();
5832
    let full: Vec<ChangeHash> = repo
5833
        .log_for_path_in("a.txt", &heads)
5834
        .unwrap()
5835
        .iter()
5836
        .map(|e| e.hash)
5837
        .collect();
5838
    assert_eq!(
5839
        full,
5840
        vec![c3, c1],
5841
        "newest first, only changes touching the path"
5842
    );
5843
    let newest: Vec<ChangeHash> = repo
5844
        .log_for_path_of(&state, "a.txt", Some(1))
5845
        .unwrap()
5846
        .iter()
5847
        .map(|e| e.hash)
5848
        .collect();
5849
    assert_eq!(newest, vec![c3], "stops at the first");
5850
    assert_eq!(
5851
        repo.log_for_path_of(&state, "a.txt", None).unwrap().len(),
5852
        2
5853
    );
5854
    assert!(repo
5855
        .log_for_path_of(&state, "missing.txt", Some(1))
5856
        .is_err());
5857
    assert_eq!(
5858
        repo.log_state(&heads).unwrap().len(),
5859
        repo.log_of(&state).len()
5860
    );
5861
5862
    let tree = state.tree().view();
5863
    let via_state = repo.blame_of(&state, &tree, "a.txt").unwrap();
5864
    let via_heads = repo.blame_in("a.txt", &heads).unwrap();
5865
    assert_eq!(
5866
        via_state
5867
            .iter()
5868
            .map(|l| (l.hash, l.text.clone()))
5869
            .collect::<Vec<_>>(),
5870
        via_heads
5871
            .iter()
5872
            .map(|l| (l.hash, l.text.clone()))
5873
            .collect::<Vec<_>>()
5874
    );
5875
    assert_eq!(
5876
        via_state.iter().map(|l| l.hash).collect::<Vec<_>>(),
5877
        vec![c1, c3]
5878
    );
5879
    assert!(repo.blame_of(&state, &tree, "missing.txt").is_err());
5880
}
5881
5882
/// A text file whose stat matches the manifest and whose recorded hash is
5883
/// the base's content is taken from the base without reading it; anything
5884
/// else is read, so no edit is missed (spec §12.3).
5885
#[cfg(unix)]
5886
#[test]
5887
fn stat_cache_takes_unchanged_text_from_the_base_and_misses_no_edit() {
5888
    use std::os::unix::fs::PermissionsExt;
5889
    hermetic();
5890
    let dir = tempfile::tempdir().unwrap();
5891
    let root = dir.path();
5892
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
5893
    write(root, "a.txt", b"one\ntwo\n");
5894
    write(root, "b.txt", b"bee\n");
5895
    repo.commit("base", &[]).unwrap();
5896
    let age = |p: &str| {
5897
        let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30);
5898
        fs::File::options()
5899
            .write(true)
5900
            .open(root.join(p))
5901
            .unwrap()
5902
            .set_modified(old)
5903
            .unwrap();
5904
    };
5905
    age("a.txt");
5906
    age("b.txt");
5907
    assert!(
5908
        !repo.snapshot("status").unwrap().changed,
5909
        "records the aged stat"
5910
    );
5911
5912
    // Clean and equal to the base: not read at all.
5913
    fs::set_permissions(root.join("a.txt"), fs::Permissions::from_mode(0o000)).unwrap();
5914
    let snap = repo.snapshot("status").unwrap();
5915
    assert!(
5916
        !snap.changed && snap.statuses.is_empty(),
5917
        "{:?}",
5918
        snap.statuses
5919
    );
5920
    fs::set_permissions(root.join("a.txt"), fs::Permissions::from_mode(0o644)).unwrap();
5921
5922
    // An uncommitted edit that is older than the manifest: stat-clean, but
5923
    // its hash is not the base's, so it is read and stays visible.
5924
    write(root, "b.txt", b"bee edited\n");
5925
    age("b.txt");
5926
    let first = repo.snapshot("status").unwrap();
5927
    assert_eq!(
5928
        statuses(&first),
5929
        vec![("b.txt".to_string(), ChangeStatus::Modified)]
5930
    );
5931
    let again = repo.snapshot("status").unwrap();
5932
    assert_eq!(
5933
        statuses(&again),
5934
        vec![("b.txt".to_string(), ChangeStatus::Modified)],
5935
        "still seen once stat-clean"
5936
    );
5937
    assert!(!again.changed, "and the working copy hashes the same");
5938
    fs::set_permissions(root.join("b.txt"), fs::Permissions::from_mode(0o000)).unwrap();
5939
    assert!(
5940
        repo.snapshot("status").is_err(),
5941
        "an edited file must be read, never taken from the base"
5942
    );
5943
    fs::set_permissions(root.join("b.txt"), fs::Permissions::from_mode(0o644)).unwrap();
5944
5945
    // A same-size edit made just now is racy and read.
5946
    write(root, "a.txt", b"one\nTWO\n");
5947
    assert_eq!(
5948
        statuses(&repo.snapshot("status").unwrap()),
5949
        vec![
5950
            ("a.txt".to_string(), ChangeStatus::Modified),
5951
            ("b.txt".to_string(), ChangeStatus::Modified)
5952
        ]
5953
    );
5954
    // Committing makes the edits the base: both are clean and served from it again.
5955
    repo.commit("edits", &[]).unwrap();
5956
    age("a.txt");
5957
    age("b.txt");
5958
    assert!(repo.snapshot("status").unwrap().statuses.is_empty());
5959
    fs::set_permissions(root.join("a.txt"), fs::Permissions::from_mode(0o000)).unwrap();
5960
    fs::set_permissions(root.join("b.txt"), fs::Permissions::from_mode(0o000)).unwrap();
5961
    let snap = repo.snapshot("status").unwrap();
5962
    assert!(!snap.changed && snap.statuses.is_empty());
5963
    fs::set_permissions(root.join("a.txt"), fs::Permissions::from_mode(0o644)).unwrap();
5964
    fs::set_permissions(root.join("b.txt"), fs::Permissions::from_mode(0o644)).unwrap();
5965
}
5966
5967
/// Version 2 checkpoints (spec §11): a large file's graph is a blob of its
5968
/// own, kept by gc, checked by fsck, shared by the next checkpoint when the
5969
/// file is unchanged, and loading through the checkpoint equals a full load.
5970
#[test]
5971
fn checkpoints_keep_large_graphs_as_blobs_of_their_own() {
5972
    hermetic();
5973
    let dir = tempfile::tempdir().unwrap();
5974
    let root = dir.path();
5975
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
5976
    let long: String = (0..300)
5977
        .map(|i| format!("line {i} of a long file\n"))
5978
        .collect();
5979
    write(root, "long.txt", long.as_bytes());
5980
    write(root, "short.txt", b"short\n");
5981
    repo.commit("base", &[]).unwrap();
5982
    let (r1, _, _) = repo.checkpoint().unwrap();
5983
    let cp1 = crate::objects::Checkpoint::decode(
5984
        &repo.store().get_typed(&r1, ObjectType::Checkpoint).unwrap(),
5985
    )
5986
    .unwrap();
5987
    let graphs1 = repo.checkpoint_graph_blobs(&cp1).unwrap();
5988
    assert_eq!(graphs1.len(), 1, "only the long file's graph is a blob");
5989
5990
    // gc keeps the graph blob; fsck finds the checkpoint sound.
5991
    repo.gc(true).unwrap();
5992
    assert!(repo.store().has(&graphs1[0]));
5993
    let report = repo.fsck().unwrap();
5994
    assert_eq!(report.errors(), 0, "{:?}", report.problems);
5995
5996
    // Loading through the checkpoint equals a full load.
5997
    write(root, "short.txt", b"short, edited\n");
5998
    repo.commit("edit the short file", &[]).unwrap();
5999
    let heads = repo.bookmark_heads().unwrap();
6000
    let fast = repo.load_state(&heads).unwrap();
6001
    let full = repo.load_state_full(&heads).unwrap();
6002
    assert_eq!(fast.checkpoint_payload(), full.checkpoint_payload());
6003
6004
    // The next checkpoint shares the unchanged long file's graph.
6005
    let (r2, _, _) = repo.checkpoint().unwrap();
6006
    let cp2 = crate::objects::Checkpoint::decode(
6007
        &repo.store().get_typed(&r2, ObjectType::Checkpoint).unwrap(),
6008
    )
6009
    .unwrap();
6010
    assert_eq!(repo.checkpoint_graph_blobs(&cp2).unwrap(), graphs1);
6011
6012
    // A shallow clone's bundle carries the graph blob with the checkpoint.
6013
    let bundle = repo.checkpoint_bundle_for(&heads).unwrap();
6014
    assert!(bundle.objects.iter().any(|(t, body)| *t == ObjectType::Blob
6015
        && crate::hash::hash_object(ObjectType::Blob, body) == graphs1[0]));
6016
6017
    // A version 1 checkpoint written by an older oxy still loads.
6018
    let state = repo.load_state_full(&heads).unwrap();
6019
    let old = repo.store_blob(&state.checkpoint_payload()).unwrap();
6020
    let cp_old = crate::objects::Checkpoint {
6021
        heads: cp2.heads.clone(),
6022
        count: cp2.count,
6023
        payload: old,
6024
    };
6025
    let r_old = repo
6026
        .store()
6027
        .put(ObjectType::Checkpoint, &cp_old.encode())
6028
        .unwrap();
6029
    repo.install_checkpoint("main", &r_old).unwrap();
6030
    assert_eq!(
6031
        repo.load_state(&heads).unwrap().checkpoint_payload(),
6032
        full.checkpoint_payload()
6033
    );
6034
    assert_eq!(repo.fsck().unwrap().errors(), 0);
6035
6036
    // A lost graph blob fails the load loudly rather than dropping the
6037
    // file, and fsck reports it (a fresh repository, objects still loose).
6038
    let dir2 = tempfile::tempdir().unwrap();
6039
    let root2 = dir2.path();
6040
    let repo2 = Repo::init(root2).unwrap().with_home(root2.join("home"));
6041
    write(root2, "long.txt", long.as_bytes());
6042
    repo2.commit("base", &[]).unwrap();
6043
    let (r, _, _) = repo2.checkpoint().unwrap();
6044
    let cp = crate::objects::Checkpoint::decode(
6045
        &repo2.store().get_typed(&r, ObjectType::Checkpoint).unwrap(),
6046
    )
6047
    .unwrap();
6048
    let graph = repo2.checkpoint_graph_blobs(&cp).unwrap()[0];
6049
    repo2.store().remove_loose_for_test(&graph).unwrap();
6050
    assert!(repo2.load_state(&repo2.bookmark_heads().unwrap()).is_err());
6051
    assert!(repo2.fsck().unwrap().errors() > 0, "fsck reports it");
6052
}
6053
6054
/// A new repository has not stored the user's identity yet (that happens
6055
/// at the first signed change); looking it up falls back to the key
6056
/// directory, and a file there that does not hash to the key is refused.
6057
#[test]
6058
fn identity_is_found_in_the_key_directory_for_a_new_repository() {
6059
    hermetic();
6060
    let dir = tempfile::tempdir().unwrap();
6061
    let home = dir.path().join("home");
6062
    let mut first = Repo::init(&dir.path().join("a"))
6063
        .unwrap()
6064
        .with_home(home.clone());
6065
    let (key, _) = first
6066
        .create_identity("Ada", "[email protected]", IdentityScope::User)
6067
        .unwrap();
6068
    let fresh = Repo::init(&dir.path().join("b"))
6069
        .unwrap()
6070
        .with_home(home.clone());
6071
    assert!(!fresh.store().has(&key), "nothing stored yet");
6072
    let id = fresh.identity(&key).unwrap();
6073
    assert_eq!((id.name.as_str(), id.hash()), ("Ada", key));
6074
    // A key file that is not this identity is an error, not a wrong answer.
6075
    let keys = fresh.keys_dir.clone();
6076
    let (other, _) = first
6077
        .create_identity("Bob", "[email protected]", IdentityScope::User)
6078
        .unwrap();
6079
    fs::copy(
6080
        keys.join(format!("{}.identity", other.to_hex())),
6081
        keys.join(format!("{}.identity", key.to_hex())),
6082
    )
6083
    .unwrap();
6084
    assert!(fresh
6085
        .identity(&key)
6086
        .unwrap_err()
6087
        .to_string()
6088
        .contains("does not hold identity"));
6089
}
6090
6091
/// Status snapshots (spec §12.3): a text file whose stat matches the
6092
/// manifest, and whose base graph key is the one the manifest recorded it
6093
/// equal to, is neither read nor rendered; any edit, and any change to the
6094
/// file's graph in the base, brings it back.
6095
#[cfg(unix)]
6096
#[test]
6097
fn status_snapshots_skip_files_unchanged_since_the_manifest() {
6098
    use std::os::unix::fs::PermissionsExt;
6099
    hermetic();
6100
    let dir = tempfile::tempdir().unwrap();
6101
    let root = dir.path();
6102
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
6103
    for (p, c) in [("a.txt", "one\n"), ("b.txt", "bee\n"), ("d/c.txt", "sea\n")] {
6104
        write(root, p, c.as_bytes());
6105
    }
6106
    repo.commit("base", &[]).unwrap();
6107
    let age = |p: &str| {
6108
        let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30);
6109
        fs::File::options()
6110
            .write(true)
6111
            .open(root.join(p))
6112
            .unwrap()
6113
            .set_modified(old)
6114
            .unwrap();
6115
    };
6116
    let lock = |p: &str, mode: u32| {
6117
        fs::set_permissions(root.join(p), fs::Permissions::from_mode(mode)).unwrap()
6118
    };
6119
    for p in ["a.txt", "b.txt", "d/c.txt"] {
6120
        age(p);
6121
    }
6122
    assert!(repo.snapshot("status").unwrap().statuses.is_empty());
6123
    let (_, _, m) = repo.read_manifest().unwrap().unwrap();
6124
    assert!(
6125
        m.entries.values().all(|e| e.graph.is_some()),
6126
        "keys recorded"
6127
    );
6128
6129
    // Unreadable files prove they are not read; the status snapshot does
6130
    // not render them either, and its base lacks their graphs.
6131
    lock("a.txt", 0o000);
6132
    lock("b.txt", 0o000);
6133
    let snap = repo.snapshot_status("status").unwrap();
6134
    assert!(!snap.changed && snap.statuses.is_empty());
6135
    assert_eq!(
6136
        snap.base_snapshot
6137
            .unrendered
6138
            .iter()
6139
            .cloned()
6140
            .collect::<Vec<_>>(),
6141
        vec!["a.txt", "b.txt", "d/c.txt"]
6142
    );
6143
    assert!(!snap.base.is_full());
6144
    assert!(!snap.base_snapshot.is_conflicted());
6145
    // A full snapshot does not read them either, but renders everything.
6146
    let full = repo.snapshot("status").unwrap();
6147
    assert!(full.statuses.is_empty() && full.base.is_full());
6148
    assert!(full.base_snapshot.unrendered.is_empty());
6149
    assert!(matches!(&full.base_snapshot.files["a.txt"].content,
6150
        FileContent::Text { bytes, .. } if bytes == b"one\n"));
6151
6152
    // An edit is seen; the file is rendered, the others still are not.
6153
    write(root, "d/c.txt", b"sea edited\n");
6154
    let snap = repo.snapshot_status("status").unwrap();
6155
    assert_eq!(
6156
        statuses(&snap),
6157
        vec![("d/c.txt".to_string(), ChangeStatus::Modified)]
6158
    );
6159
    assert!(!snap.base_snapshot.unrendered.contains("d/c.txt"));
6160
    // So is a mode change of an unread file.
6161
    lock("b.txt", 0o100);
6162
    assert_eq!(
6163
        statuses(&repo.snapshot_status("status").unwrap()),
6164
        vec![
6165
            ("b.txt".to_string(), ChangeStatus::ModeChanged),
6166
            ("d/c.txt".to_string(), ChangeStatus::Modified)
6167
        ]
6168
    );
6169
    lock("b.txt", 0o000);
6170
6171
    // Committing c.txt changes its graph in the base, not a.txt's: a.txt
6172
    // stays unread, c.txt is read once and then skipped too.
6173
    repo.commit_selection("edit c", &[], &Selection::Paths(vec!["d/c.txt".into()]))
6174
        .unwrap();
6175
    age("d/c.txt");
6176
    let snap = repo.snapshot_status("status").unwrap();
6177
    assert!(snap.statuses.is_empty(), "{:?}", snap.statuses);
6178
    assert!(snap.base_snapshot.unrendered.contains("a.txt"));
6179
    lock("d/c.txt", 0o000);
6180
    let snap = repo.snapshot_status("status").unwrap();
6181
    assert!(snap.statuses.is_empty());
6182
    assert!(snap.base_snapshot.unrendered.contains("d/c.txt"));
6183
    for p in ["a.txt", "b.txt", "d/c.txt"] {
6184
        lock(p, 0o644);
6185
    }
6186
}
6187
6188
/// Attributes decide how a file compares with the base, so a changed
6189
/// `.oxyattributes` makes every text file be compared again.
6190
#[cfg(unix)]
6191
#[test]
6192
fn a_changed_attributes_file_disables_skipping_unchanged_files() {
6193
    hermetic();
6194
    let dir = tempfile::tempdir().unwrap();
6195
    let root = dir.path();
6196
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
6197
    write(root, "a.txt", b"crlf\r\n");
6198
    repo.commit("base", &[]).unwrap();
6199
    let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30);
6200
    fs::File::options()
6201
        .write(true)
6202
        .open(root.join("a.txt"))
6203
        .unwrap()
6204
        .set_modified(old)
6205
        .unwrap();
6206
    assert!(repo.snapshot_status("status").unwrap().statuses.is_empty());
6207
    assert!(repo
6208
        .snapshot_status("status")
6209
        .unwrap()
6210
        .base_snapshot
6211
        .unrendered
6212
        .contains("a.txt"));
6213
    // With eol=lf, a.txt as stored no longer equals the base.
6214
    write(root, ".oxyattributes", b"*.txt eol=lf\n");
6215
    let snap = repo.snapshot_status("status").unwrap();
6216
    assert_eq!(
6217
        statuses(&snap),
6218
        vec![
6219
            (".oxyattributes".to_string(), ChangeStatus::Added),
6220
            ("a.txt".to_string(), ChangeStatus::Modified)
6221
        ]
6222
    );
6223
}
6224
6225
/// Bookmarks with `/` in their names: pushing records their
6226
/// attestations, and checkpoints are kept for them, beside `feature`
6227
/// itself.
6228
#[test]
6229
fn bookmarks_with_slashes_push_and_checkpoint() {
6230
    hermetic();
6231
    let dir = tempfile::tempdir().unwrap();
6232
    let root = dir.path();
6233
    let origin = Repo::init(&root.join("origin")).unwrap();
6234
    let url = root.join("origin").to_string_lossy().to_string();
6235
    let mut repo = Repo::clone(&url, &root.join("r"))
6236
        .unwrap()
6237
        .with_home(root.join("home"));
6238
    // With an identity, pushes carry reference attestations: the case
6239
    // that failed, since the attestation is a file named after the bookmark.
6240
    repo.create_identity("Ada", "[email protected]", IdentityScope::Repo)
6241
        .unwrap();
6242
    let r = root.join("r");
6243
    write(&r, "a.txt", b"a\n");
6244
    let h_a = repo.commit("a", &[]).unwrap();
6245
    assert!(repo.push("origin", false).unwrap()[0].ok);
6246
    origin.refresh().unwrap();
6247
    repo.bookmark_create("feature", None).unwrap();
6248
    repo.bookmark_create("feature/x", None).unwrap();
6249
    repo.bookmark_switch("feature/x", false).unwrap();
6250
    write(&r, "b.txt", b"b\n");
6251
    let h_b = repo.commit("b", &[]).unwrap();
6252
    for name in ["feature", "feature/x"] {
6253
        let res = repo.push_bookmark("origin", name, false).unwrap();
6254
        origin.refresh().unwrap();
6255
        assert!(res[0].ok, "{name}: {res:?}");
6256
    }
6257
    assert_eq!(origin.resolve_heads("feature").unwrap(), vec![h_a]);
6258
    assert_eq!(
6259
        origin.resolve_heads("feature/x").unwrap(),
6260
        repo.resolve_heads("feature/x").unwrap()
6261
    );
6262
    assert!(origin.resolve_heads("feature/x").unwrap().contains(&h_b));
6263
    assert!(origin.latest_ref_attest("feature/x").unwrap().is_some());
6264
    assert!(origin.latest_ref_attest("feature").unwrap().is_some());
6265
6266
    // A checkpoint of feature/x is found again when loading its state.
6267
    let (cp, _, _) = repo.checkpoint().unwrap();
6268
    assert_eq!(repo.latest_checkpoint("feature/x").unwrap().unwrap().0, cp);
6269
    assert!(repo.latest_checkpoint("feature").unwrap().is_none());
6270
    let heads = repo.resolve_heads("feature/x").unwrap();
6271
    assert!(repo.load_state_checkpointed(&heads).unwrap().is_some());
6272
}
6273
6274
/// Deleting a bookmark on a remote (spec §16.4): attested like any update,
6275
/// never the remote's own bookmark, and a bookmark created again later is
6276
/// accepted although the deletion's attestation stays the latest.
6277
#[test]
6278
fn bookmarks_are_deleted_on_a_remote() {
6279
    hermetic();
6280
    let dir = tempfile::tempdir().unwrap();
6281
    let root = dir.path();
6282
    let origin = Repo::init(&root.join("origin")).unwrap();
6283
    let url = root.join("origin").to_string_lossy().to_string();
6284
    let mut repo = Repo::clone(&url, &root.join("r"))
6285
        .unwrap()
6286
        .with_home(root.join("home"));
6287
    repo.create_identity("Ada", "[email protected]", IdentityScope::Repo)
6288
        .unwrap();
6289
    let r = root.join("r");
6290
    write(&r, "a.txt", b"a\n");
6291
    repo.commit("a", &[]).unwrap();
6292
    assert!(repo.push("origin", false).unwrap()[0].ok);
6293
    origin.refresh().unwrap();
6294
    repo.bookmark_create("feature/x", None).unwrap();
6295
    assert!(repo.push_bookmark("origin", "feature/x", false).unwrap()[0].ok);
6296
    origin.refresh().unwrap();
6297
    assert!(origin.resolve_heads("feature/x").is_ok());
6298
6299
    let res = repo.push_delete("origin", "feature/x").unwrap();
6300
    origin.refresh().unwrap();
6301
    assert!(res[0].ok, "{res:?}");
6302
    assert!(!origin.view().unwrap().bookmarks.contains_key("feature/x"));
6303
    let (_, att) = origin.latest_ref_attest("feature/x").unwrap().unwrap();
6304
    assert!(
6305
        matches!(att.attest.body, AttestBody::Ref { new_state, .. } if new_state.is_zero()),
6306
        "the deletion is attested"
6307
    );
6308
    assert!(
6309
        repo.view().unwrap().bookmarks.contains_key("feature/x"),
6310
        "the local bookmark stays"
6311
    );
6312
    assert!(!repo
6313
        .view()
6314
        .unwrap()
6315
        .remotes
6316
        .get("origin")
6317
        .is_some_and(|m| m.contains_key("feature/x")));
6318
6319
    // Pushing it again creates it again, past the deletion's serial.
6320
    let res = repo.push_bookmark("origin", "feature/x", false).unwrap();
6321
    origin.refresh().unwrap();
6322
    assert!(res[0].ok, "{res:?}");
6323
    assert!(origin.view().unwrap().bookmarks.contains_key("feature/x"));
6324
6325
    // Nothing to delete, and never the remote's own bookmark.
6326
    assert!(repo.push_delete("origin", "nope").is_err());
6327
    let res = repo.push_delete("origin", "main").unwrap();
6328
    origin.refresh().unwrap();
6329
    assert!(
6330
        !res[0].ok && res[0].reason.contains("default bookmark"),
6331
        "{res:?}"
6332
    );
6333
    assert!(origin.view().unwrap().bookmarks.contains_key("main"));
6334
}
6335
6336
/// `bookmark switch --carry` (spec §10.1): uncommitted work goes along
6337
/// when the target has the same versions of the files it touches, and is
6338
/// refused, untouched, when it has others.
6339
#[test]
6340
fn switching_bookmarks_carries_uncommitted_work() {
6341
    hermetic();
6342
    let dir = tempfile::tempdir().unwrap();
6343
    let root = dir.path();
6344
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
6345
    write(root, "a.txt", b"a\n");
6346
    write(root, "b.txt", b"b\n");
6347
    write(root, "gone.txt", b"gone\n");
6348
    repo.commit("base", &[]).unwrap();
6349
    repo.bookmark_create("feature", None).unwrap();
6350
    // Started on main, belongs on feature: edit, add, remove.
6351
    write(root, "a.txt", b"a\nmore\n");
6352
    write(root, "new.txt", b"new\n");
6353
    fs::remove_file(root.join("gone.txt")).unwrap();
6354
    let (_, carried) = repo.bookmark_switch_carry("feature").unwrap();
6355
    assert_eq!(carried, vec!["a.txt", "gone.txt", "new.txt"]);
6356
    assert_eq!(repo.config.bookmark, "feature");
6357
    assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"a\nmore\n");
6358
    assert_eq!(fs::read(root.join("new.txt")).unwrap(), b"new\n");
6359
    assert!(!root.join("gone.txt").exists());
6360
    let snap = repo.snapshot("status").unwrap();
6361
    assert_eq!(
6362
        statuses(&snap),
6363
        vec![
6364
            ("a.txt".to_string(), ChangeStatus::Modified),
6365
            ("gone.txt".to_string(), ChangeStatus::Removed),
6366
            ("new.txt".to_string(), ChangeStatus::Added)
6367
        ]
6368
    );
6369
    repo.commit("the work", &[]).unwrap();
6370
    assert!(repo.resolve_heads("main").unwrap().len() == 1);
6371
6372
    // main moves b.txt on; work on feature touching b.txt cannot follow.
6373
    repo.bookmark_switch("main", false).unwrap();
6374
    write(root, "b.txt", b"b on main\n");
6375
    repo.commit("b on main", &[]).unwrap();
6376
    repo.bookmark_switch("feature", false).unwrap();
6377
    write(root, "b.txt", b"b edited\n");
6378
    let err = repo.bookmark_switch_carry("main").unwrap_err().to_string();
6379
    assert!(err.contains("b.txt") && err.contains("--discard"), "{err}");
6380
    assert_eq!(repo.config.bookmark, "feature", "nothing happened");
6381
    assert_eq!(fs::read(root.join("b.txt")).unwrap(), b"b edited\n");
6382
}
6383
6384
/// Merging and checking out (feedback 3): a working copy without changes
6385
/// follows a merge whichever state it sat on; a refused checkout names
6386
/// what is in the way; `checkout --carry` takes the work along.
6387
#[test]
6388
fn merges_move_clean_working_copies_and_checkouts_say_what_blocks_them() {
6389
    hermetic();
6390
    let dir = tempfile::tempdir().unwrap();
6391
    let root = dir.path();
6392
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
6393
    write(root, "a.txt", b"a\n");
6394
    let h1 = repo.commit("a", &[]).unwrap();
6395
    write(root, "b.txt", b"b\n");
6396
    repo.commit("b", &[]).unwrap();
6397
    repo.bookmark_create("feature", None).unwrap();
6398
    repo.bookmark_switch("feature", false).unwrap();
6399
    write(root, "c.txt", b"c\n");
6400
    repo.commit("c on feature", &[]).unwrap();
6401
    repo.bookmark_switch("main", false).unwrap();
6402
    // The working copy sits on an older state than main's heads.
6403
    repo.checkout(&[h1], false, "checkout").unwrap();
6404
    assert!(!root.join("b.txt").exists());
6405
    let m = repo.merge("feature").unwrap();
6406
    assert!(m.checked_out, "a clean working copy follows the merge");
6407
    for f in ["a.txt", "b.txt", "c.txt"] {
6408
        assert!(root.join(f).exists(), "{f}");
6409
    }
6410
6411
    // A refusal says which paths are in the way and what to do.
6412
    fs::remove_file(root.join("b.txt")).unwrap();
6413
    write(root, "a.txt", b"a edited\n");
6414
    let err = repo
6415
        .checkout(&[h1], false, "checkout")
6416
        .unwrap_err()
6417
        .to_string();
6418
    assert!(
6419
        err.contains("modified a.txt") && err.contains("removed b.txt") && err.contains("--carry"),
6420
        "{err}"
6421
    );
6422
    // --carry takes them along: h1 has a.txt as the base has it, and the
6423
    // removed b.txt is already as h1 has it (none), so that is no conflict
6424
    // either (oxygen#100).
6425
    let (_, carried) = repo
6426
        .checkout_carry(&[h1], "the first change", "checkout")
6427
        .unwrap();
6428
    assert_eq!(carried, vec!["a.txt"]);
6429
    assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"a edited\n");
6430
    assert!(!root.join("b.txt").exists(), "h1 has no b.txt");
6431
}
6432
6433
/// Doctor and subrepos before the first commit (feedback 5 and 8): a
6434
/// user identity not stored in a new repository yet is fine while its
6435
/// secret key is there; an added subrepo is listed as not committed yet.
6436
#[test]
6437
fn a_new_repository_is_healthy_before_its_first_commit() {
6438
    hermetic();
6439
    let dir = tempfile::tempdir().unwrap();
6440
    let root = dir.path();
6441
    let home = root.join("home");
6442
    let mut first = Repo::init(&root.join("first"))
6443
        .unwrap()
6444
        .with_home(home.clone());
6445
    let (key, _) = first
6446
        .create_identity("Ada", "[email protected]", IdentityScope::User)
6447
        .unwrap();
6448
    let repo = Repo::init(&root.join("second"))
6449
        .unwrap()
6450
        .with_home(home.clone());
6451
    // Only the secret key: no identity object anywhere this repository sees.
6452
    let keys = repo.keys_dir.clone();
6453
    let _ = fs::remove_file(keys.join(format!("{}.identity", key.to_hex())));
6454
    assert!(keys.join(format!("{}.secret", key.to_hex())).is_file());
6455
    let r = repo.doctor(true);
6456
    let id = r.checks.iter().find(|c| c.area == "identity").unwrap();
6457
    assert_eq!(id.health, Health::Ok, "{}", id.message);
6458
    assert!(id.message.contains("first signed change"), "{}", id.message);
6459
6460
    // A subrepo added and not committed yet is listed, pinned where it is.
6461
    let lib = root.join("lib");
6462
    let l = Repo::init(&lib).unwrap().with_home(home.clone());
6463
    write(&lib, "x.txt", b"x\n");
6464
    l.commit("x", &[]).unwrap();
6465
    let second = root.join("second");
6466
    write(&second, "a.txt", b"a\n");
6467
    repo.submodule_add(&lib.to_string_lossy(), "deps/lib", None)
6468
        .unwrap();
6469
    let subs = repo.subrepos().unwrap();
6470
    assert_eq!(subs.len(), 1);
6471
    assert_eq!(
6472
        (subs[0].path.as_str(), subs[0].committed),
6473
        ("deps/lib", false)
6474
    );
6475
    assert_eq!(subs[0].actual.as_ref(), Some(&subs[0].pin));
6476
    repo.commit("add lib", &[]).unwrap();
6477
    let subs = repo.subrepos().unwrap();
6478
    assert!(subs[0].committed, "committed now");
6479
}
6480
6481
/// Commit, diff, hunks and discard use the partial snapshot: a file
6482
/// unchanged since the manifest is neither read nor rendered, and showing
6483
/// a change renders only what it edits, the same as from a full base.
6484
#[cfg(unix)]
6485
#[test]
6486
fn commits_and_diffs_leave_unchanged_files_alone() {
6487
    use std::os::unix::fs::PermissionsExt;
6488
    hermetic();
6489
    let dir = tempfile::tempdir().unwrap();
6490
    let root = dir.path();
6491
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
6492
    write(root, "big.txt", b"lots\nof\nlines\n");
6493
    write(root, "a.txt", b"one\ntwo\n");
6494
    repo.commit("base", &[]).unwrap();
6495
    let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30);
6496
    for f in ["big.txt", "a.txt"] {
6497
        fs::File::options()
6498
            .write(true)
6499
            .open(root.join(f))
6500
            .unwrap()
6501
            .set_modified(old)
6502
            .unwrap();
6503
    }
6504
    assert!(repo.snapshot_status("status").unwrap().statuses.is_empty());
6505
    // big.txt cannot be read at all from here on.
6506
    fs::set_permissions(root.join("big.txt"), fs::Permissions::from_mode(0o000)).unwrap();
6507
6508
    write(root, "a.txt", b"one\nTWO\nthree\n");
6509
    let diff = repo.diff_files(None).unwrap();
6510
    assert_eq!(
6511
        diff.iter().map(|f| f.path.as_str()).collect::<Vec<_>>(),
6512
        ["a.txt"]
6513
    );
6514
    let (_, hunks) = repo.hunks().unwrap();
6515
    assert!(!hunks.is_empty() && hunks.iter().all(|h| h.path == "a.txt"));
6516
    let h = repo.commit("edit a", &[]).unwrap();
6517
6518
    // Showing the change needs only a.txt's graph, and renders as a full base does.
6519
    let c = repo.store().get_change(&h).unwrap();
6520
    let full = repo.load_state(&c.deps).unwrap();
6521
    let want = render_change(&c, &full, &full.materialize());
6522
    assert_eq!(repo.describe_change(&h).unwrap(), want);
6523
    let (base, snap) = repo.change_base(&c).unwrap();
6524
    assert!(!base.is_full() && snap.unrendered.contains("big.txt"));
6525
6526
    // Discarding works the same way.
6527
    write(root, "a.txt", b"scratch\n");
6528
    assert_eq!(repo.discard(&["a.txt".into()]).unwrap(), 1);
6529
    assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\nTWO\nthree\n");
6530
    fs::set_permissions(root.join("big.txt"), fs::Permissions::from_mode(0o644)).unwrap();
6531
}
6532
6533
/// A replace conflict (spec §9.3) is settled by a change that writes one
6534
/// candidate, or new bytes, at the path when the caller names it; a plain
6535
/// snapshot of a checkout never settles it on its own.
6536
#[test]
6537
fn replace_conflicts_are_settled_when_asked() {
6538
    hermetic();
6539
    let dir = tempfile::tempdir().unwrap();
6540
    let root = dir.path();
6541
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
6542
    let blob = |n: u8| {
6543
        let mut v = vec![0u8; 64];
6544
        v[1] = n;
6545
        v
6546
    };
6547
    write(root, "img.bin", &blob(1));
6548
    repo.commit("add img", &[]).unwrap();
6549
    repo.bookmark_create("other", None).unwrap();
6550
    write(root, "img.bin", &blob(2));
6551
    repo.commit("img two", &[]).unwrap();
6552
    repo.bookmark_switch("other", false).unwrap();
6553
    write(root, "img.bin", &blob(3));
6554
    repo.commit("img three", &[]).unwrap();
6555
    repo.bookmark_switch("main", false).unwrap();
6556
    repo.merge("other").unwrap();
6557
    let heads = repo.bookmark_heads().unwrap();
6558
    let merged = repo.load_state(&heads).unwrap().materialize();
6559
    assert!(
6560
        matches!(&merged.files["img.bin"].content, FileContent::Opaque { blobs } if blobs.len() == 2),
6561
        "a replace conflict"
6562
    );
6563
    // A snapshot of the checkout records nothing.
6564
    assert!(repo
6565
        .snapshot("status")
6566
        .unwrap()
6567
        .statuses
6568
        .iter()
6569
        .all(|s| s.path != "img.bin"));
6570
6571
    // Keeping candidate 3 settles it.
6572
    let author = repo.author();
6573
    let edits = [FileEdit {
6574
        path: "img.bin".into(),
6575
        content: Some(blob(3)),
6576
    }];
6577
    let c = repo
6578
        .build_change(&heads, &edits, "keep three", author)
6579
        .unwrap();
6580
    assert_eq!(c.kind, ChangeKind::Resolution);
6581
    let mut st = repo.load_state(&heads).unwrap();
6582
    for d in &c.deps {
6583
        assert!(st.contains(d));
6584
    }
6585
    assert!(
6586
        heads.iter().all(|h| c.deps.contains(h)),
6587
        "depends on both contributors"
6588
    );
6589
    st.add(c).unwrap();
6590
    let after = st.materialize();
6591
    assert!(!after.is_conflicted());
6592
    assert_eq!(
6593
        after.files["img.bin"].content,
6594
        FileContent::Opaque {
6595
            blobs: vec![Repo::blob_ref(&blob(3))]
6596
        }
6597
    );
6598
6599
    // New bytes settle it too.
6600
    let edits = [FileEdit {
6601
        path: "img.bin".into(),
6602
        content: Some(blob(9)),
6603
    }];
6604
    let c = repo.build_change(&heads, &edits, "new", author).unwrap();
6605
    let mut st = repo.load_state(&heads).unwrap();
6606
    st.add(c).unwrap();
6607
    assert_eq!(
6608
        st.materialize().files["img.bin"].content,
6609
        FileContent::Opaque {
6610
            blobs: vec![Repo::blob_ref(&blob(9))]
6611
        }
6612
    );
6613
}
6614
6615
/// The history of a range of lines (spec §27): the changes that wrote
6616
/// or deleted its atoms, or deleted atoms from between them; edits outside
6617
/// the range and changes the range's lines survived are not in it.
6618
#[test]
6619
fn line_ranges_have_exact_histories() {
6620
    hermetic();
6621
    let dir = tempfile::tempdir().unwrap();
6622
    let root = dir.path();
6623
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
6624
    write(root, "f.rs", b"use x;\nfn a() {\n  one();\n  two();\n}\n");
6625
    let base = repo.commit("add f", &[]).unwrap();
6626
    write(root, "f.rs", b"use y;\nfn a() {\n  one();\n  two();\n}\n");
6627
    let outside = repo.commit("change the import", &[]).unwrap();
6628
    write(root, "f.rs", b"use y;\nfn a() {\n  ONE();\n  two();\n}\n");
6629
    let inside = repo.commit("edit a line of a", &[]).unwrap();
6630
    write(root, "f.rs", b"use y;\nfn a() {\n  ONE();\n}\n");
6631
    let deleted = repo.commit("delete a line of a", &[]).unwrap();
6632
    write(root, "f.rs", b"use y;\nfn a() {\n  ONE();\n}\nfn b() {}\n");
6633
    let after = repo.commit("add b after a", &[]).unwrap();
6634
    let heads = repo.bookmark_heads().unwrap();
6635
6636
    // Lines 2..4 are `fn a() {`, `ONE();`, `}` now.
6637
    let hist: Vec<ChangeHash> = repo
6638
        .line_history(&heads, "f.rs", 2, 4)
6639
        .unwrap()
6640
        .iter()
6641
        .map(|e| e.hash)
6642
        .collect();
6643
    for (h, what) in [
6644
        (base, "wrote it"),
6645
        (inside, "edited a line"),
6646
        (deleted, "deleted a line from inside"),
6647
    ] {
6648
        assert!(hist.contains(&h), "{what}");
6649
    }
6650
    for (h, what) in [(outside, "an edit above"), (after, "an addition below")] {
6651
        assert!(!hist.contains(&h), "{what}");
6652
    }
6653
    assert_eq!(hist[0], deleted, "newest first");
6654
    // One line: only what touched it.
6655
    let one: Vec<ChangeHash> = repo
6656
        .line_history(&heads, "f.rs", 1, 1)
6657
        .unwrap()
6658
        .iter()
6659
        .map(|e| e.hash)
6660
        .collect();
6661
    assert!(one.contains(&outside) && !one.contains(&inside));
6662
    assert!(repo.line_history(&heads, "f.rs", 3, 9).is_err());
6663
    assert!(repo.line_history(&heads, "f.rs", 0, 1).is_err());
6664
    assert!(repo.line_history(&heads, "nope.rs", 1, 1).is_err());
6665
}
6666
6667
#[test]
6668
fn receipts_are_kept_with_their_state_and_verified() {
6669
    use crate::receipt::{Receipt, SignedReceipt};
6670
    hermetic();
6671
    let dir = tempfile::tempdir().unwrap();
6672
    let root = dir.path();
6673
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
6674
    write(root, "a.txt", b"1\n");
6675
    repo.commit("A", &[]).unwrap();
6676
    let heads = repo.bookmark_heads().unwrap();
6677
    let state = repo
6678
        .store()
6679
        .put_state(&StateObject::new(heads.clone()))
6680
        .unwrap();
6681
    let out = repo.store_blob(b"built\n").unwrap();
6682
    let receipt = Receipt {
6683
        job_name: "build".into(),
6684
        pipeline_hash: ObjectRef([1; 32]),
6685
        scope_tree_hash: ObjectRef([2; 32]),
6686
        inputs: vec![],
6687
        environment: ObjectRef([3; 32]),
6688
        outputs: vec![out],
6689
        exit_code: 0,
6690
        duration_ms: 42,
6691
    };
6692
    let sk = SecretKey::generate();
6693
    let runner = sk.identity("earth-1", "runner@earth", 1);
6694
    let sr = SignedReceipt::sign(
6695
        receipt.clone(),
6696
        repo.id().unwrap(),
6697
        state,
6698
        "demo#1",
6699
        &runner,
6700
        &sk,
6701
    );
6702
6703
    let r = repo.add_receipt(&sr).unwrap();
6704
    let found = repo.receipts(&heads).unwrap();
6705
    assert_eq!(found.len(), 1);
6706
    assert_eq!(found[0].attest, r);
6707
    assert_eq!(found[0].signed, sr);
6708
    assert!(found[0].problem.is_none() && !found[0].revoked);
6709
    assert!(repo.receipts(&[ObjectRef([5; 32])]).unwrap().is_empty());
6710
6711
    // Another repository's receipt, or a forged one, is refused.
6712
    let elsewhere = SignedReceipt::sign(receipt.clone(), [0; 16], state, "x", &runner, &sk);
6713
    assert!(repo
6714
        .add_receipt(&elsewhere)
6715
        .unwrap_err()
6716
        .to_string()
6717
        .contains("another repository"));
6718
    let mut forged = sr.clone();
6719
    forged.receipt.exit_code = 1;
6720
    assert!(repo
6721
        .add_receipt(&forged)
6722
        .unwrap_err()
6723
        .to_string()
6724
        .contains("does not verify"));
6725
6726
    // A receipt signed by a short-lived key keeps its certificate and the
6727
    // issuer's identity, and reads back with them (oxygenhub#241).
6728
    let hub_sk = SecretKey::generate();
6729
    let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1);
6730
    let job_sk = SecretKey::generate();
6731
    let job = job_sk.identity("job 2 test", "[email protected]", 1);
6732
    let mut test_receipt = receipt.clone();
6733
    test_receipt.job_name = "test".into();
6734
    let plain = SignedReceipt::sign(
6735
        test_receipt,
6736
        repo.id().unwrap(),
6737
        state,
6738
        "demo#2",
6739
        &job,
6740
        &job_sk,
6741
    );
6742
    let now = plain.attest.hlc.wall_ms;
6743
    let cert = crate::cert::SignedCertificate::sign(
6744
        crate::cert::Certificate {
6745
            key: job.clone(),
6746
            issuer: hub.hash(),
6747
            subject: "repo:andy/demo:bookmark:main".into(),
6748
            claims: vec![],
6749
            scope: crate::identity::Scope::SEAL,
6750
            not_before: now - 1_000,
6751
            expires: now + 600_000,
6752
            grant: None,
6753
        },
6754
        &hub,
6755
        &hub_sk,
6756
    );
6757
    let certified = plain.with_certificate(cert, hub.clone());
6758
    repo.add_receipt(&certified).unwrap();
6759
    let found = repo.receipts(&heads).unwrap();
6760
    let got = found
6761
        .iter()
6762
        .find(|i| i.signed.receipt.job_name == "test")
6763
        .unwrap();
6764
    assert_eq!(got.signed, certified);
6765
    assert!(got.problem.is_none(), "{:?}", got.problem);
6766
6767
    // gc keeps the receipts, their runners, the certificate and its issuer,
6768
    // and the output while the state lives.
6769
    repo.gc(true).unwrap();
6770
    let found = repo.receipts(&heads).unwrap();
6771
    assert_eq!(found.len(), 2);
6772
    let got = found
6773
        .iter()
6774
        .find(|i| i.signed.receipt.job_name == "test")
6775
        .unwrap();
6776
    assert_eq!(got.signed, certified, "the certificate survives gc");
6777
    assert!(got.problem.is_none());
6778
    assert_eq!(repo.read_blob(&out).unwrap(), b"built\n");
6779
    assert!(repo.fsck().unwrap().problems.is_empty());
6780
}
6781
6782
#[test]
6783
fn approvals_are_kept_with_their_state_and_survive_gc() {
6784
    hermetic();
6785
    let dir = tempfile::tempdir().unwrap();
6786
    let root = dir.path();
6787
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
6788
    write(root, "a.txt", b"1\n");
6789
    repo.commit("A", &[]).unwrap();
6790
    let heads = repo.bookmark_heads().unwrap();
6791
    let state = repo.store().put_state(&StateObject::new(heads)).unwrap();
6792
    let plan = repo.store_blob(b"the reviewed plan\n").unwrap();
6793
    let sk = SecretKey::generate();
6794
    let andy = sk.identity("Andy", "[email protected]", 1);
6795
    let statement = crate::approval::Statement {
6796
        repo: repo.id().unwrap(),
6797
        run: "https://hub.example/andy/infra/flow/8".into(),
6798
        job: "apply".into(),
6799
        job_hash: ObjectRef([1; 32]),
6800
        state,
6801
        inputs: vec![plan],
6802
        environment: "prod".into(),
6803
        approve: true,
6804
    };
6805
    let sa = crate::approval::SignedApproval::sign(&statement, &andy, &sk);
6806
    let r = repo.add_approval(&sa).unwrap();
6807
    assert_eq!(repo.store().approvals_for(&state).unwrap(), vec![r]);
6808
    // Another repository's approval, or a changed one, is refused.
6809
    let elsewhere = crate::approval::SignedApproval::sign(
6810
        &crate::approval::Statement {
6811
            repo: [0; 16],
6812
            ..statement.clone()
6813
        },
6814
        &andy,
6815
        &sk,
6816
    );
6817
    assert!(repo
6818
        .add_approval(&elsewhere)
6819
        .unwrap_err()
6820
        .to_string()
6821
        .contains("another repository"));
6822
    let mut forged = sa.clone();
6823
    if let AttestBody::Approval { environment, .. } = &mut forged.attest.body {
6824
        *environment = "dev".into();
6825
    }
6826
    assert!(repo
6827
        .add_approval(&forged)
6828
        .unwrap_err()
6829
        .to_string()
6830
        .contains("does not verify"));
6831
    // gc keeps it, its signature and its signer while the state lives.
6832
    repo.gc(true).unwrap();
6833
    assert_eq!(repo.store().approvals_for(&state).unwrap(), vec![r]);
6834
    assert_eq!(repo.store().get_attest(&r).unwrap(), sa.attest);
6835
    assert_eq!(repo.store().signatures(&r).unwrap(), sa.sigs);
6836
    assert!(repo.fsck().unwrap().problems.is_empty());
6837
}
6838
6839
#[test]
6840
fn status_leaves_an_unchanged_manifest_alone() {
6841
    hermetic();
6842
    let dir = tempfile::tempdir().unwrap();
6843
    let root = dir.path();
6844
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
6845
    write(root, "a.txt", b"1\n");
6846
    write(root, "b.txt", b"2\n");
6847
    repo.commit("A", &[]).unwrap();
6848
    // Files last touched well outside the racy window.
6849
    let old = std::time::SystemTime::now() - std::time::Duration::from_secs(60);
6850
    for f in ["a.txt", "b.txt"] {
6851
        fs::File::options()
6852
            .write(true)
6853
            .open(root.join(f))
6854
            .unwrap()
6855
            .set_modified(old)
6856
            .unwrap();
6857
    }
6858
    repo.snapshot_status("status").unwrap();
6859
    let path = repo.manifest_path();
6860
    let first = fs::read(&path).unwrap();
6861
    repo.snapshot_status("status").unwrap();
6862
    assert_eq!(
6863
        fs::read(&path).unwrap(),
6864
        first,
6865
        "an unchanged tree does not rewrite the manifest"
6866
    );
6867
    // A change rewrites it.
6868
    write(root, "a.txt", b"1\n2\n");
6869
    repo.snapshot_status("status").unwrap();
6870
    assert_ne!(fs::read(&path).unwrap(), first);
6871
}
6872
6873
#[test]
6874
fn large_text_files_travel_and_survive_gc_as_text_blobs() {
6875
    hermetic();
6876
    let dir = tempfile::tempdir().unwrap();
6877
    let root = dir.path();
6878
    let origin_path = root.join("origin");
6879
    Repo::init(&origin_path).unwrap();
6880
    let origin_url = origin_path.to_string_lossy().to_string();
6881
    let a = Repo::clone(&origin_url, &root.join("a")).unwrap();
6882
    let big: String = (0..5000).map(|i| format!("line {i}\n")).collect();
6883
    write(&root.join("a"), "big.txt", big.as_bytes());
6884
    write(&root.join("a"), "small.txt", b"small\n");
6885
    let h1 = a.commit("large", &[]).unwrap();
6886
    let (_, body) = a.store().get(&h1).unwrap();
6887
    assert!(body.len() < 2048, "the change is {} bytes", body.len());
6888
    assert!(a.push("origin", false).unwrap()[0].ok);
6889
6890
    // A clone reads the text from the blobs that came with the change.
6891
    let b = Repo::clone(&origin_url, &root.join("b")).unwrap();
6892
    assert_eq!(fs::read(root.join("b/big.txt")).unwrap(), big.as_bytes());
6893
    // An edit in the middle of the stored insert is an ordinary change.
6894
    let edited = big.replace("line 2500\n", "line 2500, edited\n");
6895
    write(&root.join("b"), "big.txt", edited.as_bytes());
6896
    let h2 = b.commit("edit", &[]).unwrap();
6897
    assert!(b.store().get_change(&h2).unwrap().edits.len() <= 2);
6898
    assert!(b.push("origin", false).unwrap()[0].ok);
6899
    assert!(a.pull("origin").unwrap().checked_out);
6900
    assert_eq!(fs::read(root.join("a/big.txt")).unwrap(), edited.as_bytes());
6901
    assert_eq!(
6902
        a.fsck().unwrap().errors(),
6903
        0,
6904
        "{:?}",
6905
        a.fsck().unwrap().problems
6906
    );
6907
6908
    // With zero retention gc keeps the text blobs of every kept change.
6909
    let mut a = Repo::open(&root.join("a")).unwrap();
6910
    a.config.set("core.op-retention", "0").unwrap();
6911
    a.save_config().unwrap();
6912
    let a = Repo::open(&root.join("a")).unwrap();
6913
    a.gc(true).unwrap();
6914
    let a = Repo::open(&root.join("a")).unwrap();
6915
    assert_eq!(a.store().get_change(&h1).unwrap().message, "large");
6916
    assert_eq!(
6917
        a.fsck().unwrap().errors(),
6918
        0,
6919
        "{:?}",
6920
        a.fsck().unwrap().problems
6921
    );
6922
    // And a clone of the packed repository reads them from its packs.
6923
    let c = Repo::clone(&root.join("a").to_string_lossy(), &root.join("c")).unwrap();
6924
    assert_eq!(fs::read(root.join("c/big.txt")).unwrap(), edited.as_bytes());
6925
    drop(c);
6926
}
6927
6928
#[test]
6929
fn operations_that_both_replace_a_working_copy_are_not_merged() {
6930
    hermetic();
6931
    let dir = tempfile::tempdir().unwrap();
6932
    let root = dir.path();
6933
    let repo = Repo::init(root).unwrap();
6934
    write(root, "a.txt", b"a\n");
6935
    let a = repo.commit("a", &[]).unwrap();
6936
    write(root, "b.txt", b"b\n");
6937
    repo.commit("b", &[]).unwrap();
6938
    let (x, op) = repo.head_op().unwrap();
6939
    let ws = *op.view.workspaces.keys().next().unwrap();
6940
    let mut one = op.view.clone();
6941
    one.workspaces.insert(ws, a);
6942
    let mut two = op.view.clone();
6943
    two.workspaces.insert(ws, ObjectRef([7; 32]));
6944
    let first = repo.record_op(&[x], "one", one).unwrap();
6945
    let e = repo.record_op(&[x], "two", two).unwrap_err();
6946
    assert!(
6947
        e.to_string()
6948
            .contains("changed this working copy at the same time"),
6949
        "{e}"
6950
    );
6951
    assert_eq!(
6952
        repo.head_op().unwrap().0,
6953
        first,
6954
        "the second is not recorded"
6955
    );
6956
}
6957
6958
/// Metalgear, 2026-09-29 (oxygen#36): a status run while a bookmark switch
6959
/// rewrote the working copy recorded the half-written files as edits, and
6960
/// its view replaced the switch's.
6961
#[test]
6962
fn a_snapshot_during_a_switch_never_records_the_switch_as_edits() {
6963
    hermetic();
6964
    let dir = tempfile::tempdir().unwrap();
6965
    let root = dir.path().to_path_buf();
6966
    let mut repo = Repo::init(&root).unwrap();
6967
    write(&root, "keep.txt", b"keep\n");
6968
    repo.commit("base", &[]).unwrap();
6969
    repo.bookmark_create("small", None).unwrap();
6970
    for i in 0..300 {
6971
        write(
6972
            &root,
6973
            &format!("many/f{i}.txt"),
6974
            format!("file {i}\n").as_bytes(),
6975
        );
6976
    }
6977
    repo.commit("many", &[]).unwrap();
6978
    let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
6979
    let watcher = {
6980
        let (root, stop) = (root.clone(), stop.clone());
6981
        std::thread::spawn(move || {
6982
            let r = Repo::open(&root).unwrap();
6983
            let mut ran = 0;
6984
            while !stop.load(std::sync::atomic::Ordering::Relaxed) {
6985
                if r.snapshot_status("oxy status").is_ok() {
6986
                    ran += 1;
6987
                }
6988
            }
6989
            ran
6990
        })
6991
    };
6992
    for i in 0..20 {
6993
        let to = if i % 2 == 0 { "small" } else { "main" };
6994
        repo.bookmark_switch(to, false).unwrap();
6995
    }
6996
    stop.store(true, std::sync::atomic::Ordering::Relaxed);
6997
    assert!(watcher.join().unwrap() > 0, "the watcher snapshotted");
6998
    // Back on main, clean, with every file where main has it.
6999
    let repo = Repo::open(&root).unwrap();
7000
    let s = repo.snapshot_status("oxy status").unwrap();
7001
    assert!(s.statuses.is_empty(), "{:?}", statuses(&s));
7002
    assert!(s.wc.edits.is_empty() && s.wc.tree_ops.is_empty());
7003
    assert_eq!(s.wc.deps, repo.bookmark_heads().unwrap());
7004
    assert!(root.join("many/f299.txt").exists());
7005
}
7006
7007
/// Metalgear, 2026-09-29 (oxygen#38): a parent's `bookmark switch --carry`
7008
/// put a subrepo that was ahead of its pin back at the pin and moved the
7009
/// subrepo's own bookmark backwards.
7010
#[test]
7011
fn a_parent_switch_with_carry_leaves_a_subrepo_ahead_of_its_pin_alone() {
7012
    hermetic();
7013
    let dir = tempfile::tempdir().unwrap();
7014
    let root = dir.path();
7015
    Repo::init(&root.join("lib-origin")).unwrap();
7016
    let lib_url = root.join("lib-origin").to_string_lossy().to_string();
7017
    let lib = Repo::clone(&lib_url, &root.join("lib")).unwrap();
7018
    write(&root.join("lib"), "lib.txt", b"v1\n");
7019
    lib.commit("lib v1", &[]).unwrap();
7020
    assert!(lib.push("origin", false).unwrap()[0].ok);
7021
7022
    let app_dir = root.join("app");
7023
    let mut app = Repo::init(&app_dir).unwrap();
7024
    write(&app_dir, "main.txt", b"app\n");
7025
    app.submodule_add(&lib_url, "libs/lib", None).unwrap();
7026
    app.commit("app with lib", &[]).unwrap();
7027
    app.bookmark_create("feature", None).unwrap();
7028
7029
    // New work in the subrepo, not yet pinned by the parent.
7030
    let nested_dir = app_dir.join("libs/lib");
7031
    let nested = Repo::open(&nested_dir).unwrap();
7032
    write(&nested_dir, "new.txt", b"new\n");
7033
    let l2 = nested.commit("lib: new file", &[]).unwrap();
7034
    drop(nested);
7035
7036
    let (_, carried) = app.bookmark_switch_carry("feature").unwrap();
7037
    assert!(carried.contains(&"libs/lib".to_string()), "{carried:?}");
7038
    let nested = Repo::open(&nested_dir).unwrap();
7039
    assert!(
7040
        nested.bookmark_heads().unwrap().contains(&l2),
7041
        "its bookmark kept its work"
7042
    );
7043
    assert!(nested_dir.join("new.txt").exists(), "and its files");
7044
    let s = app.snapshot("status").unwrap();
7045
    assert_eq!(
7046
        statuses(&s),
7047
        vec![("libs/lib".to_string(), ChangeStatus::Modified)]
7048
    );
7049
}
7050
7051
/// oxygen#29 (metalgear, rstar-api): after a proposal landed on the hub,
7052
/// moving `main` to a change this clone had pushed on another bookmark,
7053
/// the first `pull` said "Already up to date" while `main` was behind.
7054
#[test]
7055
fn pull_takes_a_remote_move_to_changes_already_here() {
7056
    hermetic();
7057
    let dir = tempfile::tempdir().unwrap();
7058
    let root = dir.path();
7059
    let origin_path = root.join("origin");
7060
    let origin = Repo::init(&origin_path).unwrap();
7061
    let url = origin_path.to_string_lossy().to_string();
7062
    let mut b = Repo::clone(&url, &root.join("b")).unwrap();
7063
    write(&root.join("b"), "base.txt", b"base\n");
7064
    b.commit("base", &[]).unwrap();
7065
    assert!(b.push("origin", false).unwrap()[0].ok);
7066
    origin.refresh().unwrap();
7067
7068
    // A feature bookmark with one change, pushed.
7069
    b.bookmark_create("feat", None).unwrap();
7070
    b.bookmark_switch("feat", false).unwrap();
7071
    write(&root.join("b"), "x.txt", b"x\n");
7072
    let x = b.commit("x", &[]).unwrap();
7073
    assert!(b.push("origin", false).unwrap()[0].ok);
7074
    origin.refresh().unwrap();
7075
    b.bookmark_switch("main", false).unwrap();
7076
7077
    // The remote lands it: its main moves to the feature's state.
7078
    let feat = origin
7079
        .store()
7080
        .get_state(&origin.view().unwrap().bookmarks["feat"])
7081
        .unwrap()
7082
        .heads;
7083
    origin.bookmark_set("main", &feat).unwrap();
7084
7085
    let r = b.pull("origin").unwrap();
7086
    assert!(r.changed, "{r:?}");
7087
    assert!(r.heads.contains(&x) || b.load_state(&r.heads).unwrap().contains(&x));
7088
    let main = b
7089
        .bookmarks()
7090
        .unwrap()
7091
        .into_iter()
7092
        .find(|m| m.name == "main")
7093
        .unwrap();
7094
    assert_eq!(main.behind, Some(0), "{main:?}");
7095
}
7096
7097
/// oxygen#48: a remote's bookmarks are names here (`origin/NAME`); switching
7098
/// to one only the remote has makes a local bookmark from it; a pull moves
7099
/// every local bookmark the remote has too; a clone checks out the remote's
7100
/// default bookmark.
7101
#[test]
7102
fn remote_bookmarks_are_named_switched_to_pulled_and_cloned() {
7103
    hermetic();
7104
    let dir = tempfile::tempdir().unwrap();
7105
    let root = dir.path();
7106
    let origin_path = root.join("origin");
7107
    let mut origin = Repo::init(&origin_path).unwrap();
7108
    let url = origin_path.to_string_lossy().to_string();
7109
    let mut a = Repo::clone(&url, &root.join("a")).unwrap();
7110
    write(&root.join("a"), "base.txt", b"base\n");
7111
    a.commit("base", &[]).unwrap();
7112
    assert!(a.push("origin", false).unwrap()[0].ok);
7113
    a.bookmark_create("develop", None).unwrap();
7114
    a.bookmark_switch("develop", false).unwrap();
7115
    write(&root.join("a"), "base.txt", b"base\nd1\n");
7116
    let d1 = a.commit("d1", &[]).unwrap();
7117
    assert!(a.push("origin", false).unwrap()[0].ok);
7118
7119
    // A clone has main only; origin/develop is a name all the same.
7120
    let c_root = root.join("c");
7121
    let mut c = Repo::clone(&url, &c_root).unwrap();
7122
    assert_eq!(c.config.bookmark, "main");
7123
    assert!(!c.view().unwrap().bookmarks.contains_key("develop"));
7124
    assert_eq!(c.resolve_heads("origin/develop").unwrap(), vec![d1]);
7125
    assert_eq!(c.resolve_change("origin/develop").unwrap(), d1);
7126
    assert_eq!(c.remote_only_bookmarks().unwrap().len(), 1);
7127
7128
    // Switching makes the local bookmark from origin's.
7129
    c.bookmark_switch("develop", false).unwrap();
7130
    assert_eq!(c.config.bookmark, "develop");
7131
    assert_eq!(c.bookmark_heads().unwrap(), vec![d1]);
7132
    assert_eq!(fs::read(c_root.join("base.txt")).unwrap(), b"base\nd1\n");
7133
    assert!(c.remote_only_bookmarks().unwrap().is_empty());
7134
    assert!(c.bookmark_switch("nowhere", false).is_err());
7135
7136
    // Both bookmarks move on the remote; one pull takes both.
7137
    write(&root.join("a"), "base.txt", b"base\nd1\nd2\n");
7138
    let d2 = a.commit("d2", &[]).unwrap();
7139
    assert!(a.push("origin", false).unwrap()[0].ok);
7140
    a.bookmark_switch("main", false).unwrap();
7141
    write(&root.join("a"), "m.txt", b"m\n");
7142
    let m = a.commit("m", &[]).unwrap();
7143
    assert!(a.push("origin", false).unwrap()[0].ok);
7144
    let r = c.pull("origin").unwrap();
7145
    assert!(r.changed && r.checked_out, "{r:?}");
7146
    assert_eq!(r.heads, vec![d2]);
7147
    assert_eq!(r.others, vec!["main".to_string()]);
7148
    assert!(c.resolve_heads("main").unwrap().contains(&m));
7149
    assert_eq!(
7150
        fs::read(c_root.join("base.txt")).unwrap(),
7151
        b"base\nd1\nd2\n"
7152
    );
7153
    let again = c.pull("origin").unwrap();
7154
    assert!(!again.changed && again.others.is_empty(), "{again:?}");
7155
7156
    // A clone checks out the remote's default bookmark.
7157
    origin.config.set("workspace.bookmark", "develop").unwrap();
7158
    origin.save_config().unwrap();
7159
    let e = Repo::clone(&url, &root.join("e")).unwrap();
7160
    assert_eq!(e.config.bookmark, "develop");
7161
    assert_eq!(e.bookmark_heads().unwrap(), vec![d2]);
7162
    let reopened = Repo::open(&root.join("e")).unwrap();
7163
    assert_eq!(reopened.config.bookmark, "develop");
7164
    // REMOTE/NAME names the remote's bookmark to switch to.
7165
    let mut e = reopened;
7166
    e.bookmark_switch("origin/main", false).unwrap();
7167
    assert_eq!(e.config.bookmark, "main");
7168
    assert!(e.bookmark_heads().unwrap().contains(&m));
7169
}
7170
7171
/// oxygen#51, #52: --at-op reads an earlier view and refuses changes;
7172
/// op restore goes back to an operation, files included, and undo takes
7173
/// the restore back whole; op revert undoes one operation alone; each
7174
/// operation's changes are described from the views.
7175
#[test]
7176
fn operations_can_be_read_restored_and_reverted() {
7177
    hermetic();
7178
    let dir = tempfile::tempdir().unwrap();
7179
    let root = dir.path();
7180
    let repo = Repo::init(root).unwrap();
7181
    write(root, "a.txt", b"a\n");
7182
    let first = repo.commit("first", &[]).unwrap();
7183
    write(root, "a.txt", b"a\nb\n");
7184
    let second = repo.commit("second", &[]).unwrap();
7185
    let at_second = repo.head_op().unwrap().0.to_hex();
7186
    repo.bookmark_create("keep", None).unwrap();
7187
    let created = repo.head_op().unwrap().0.to_hex();
7188
    write(root, "n.txt", b"n\n");
7189
    let third = repo.commit("third", &[]).unwrap();
7190
7191
    // What each operation changed.
7192
    let ops = repo.op_log().unwrap();
7193
    let changes = repo.view_changes(&ops[1].1.view, &ops[0].1.view).unwrap();
7194
    assert!(
7195
        changes
7196
            .iter()
7197
            .any(|c| matches!(c, ViewChange::BookmarkMoved { name, .. } if name == "main")),
7198
        "{changes:?}"
7199
    );
7200
    assert!(changes
7201
        .iter()
7202
        .any(|c| matches!(c, ViewChange::WorkingCopy { .. })));
7203
7204
    // Reading at an earlier operation.
7205
    let old = Repo::open(root).unwrap().at_op(&at_second[..10]).unwrap();
7206
    assert_eq!(old.bookmark_heads().unwrap(), vec![second]);
7207
    assert!(!old.view().unwrap().bookmarks.contains_key("keep"));
7208
    write(root, "z.txt", b"z\n");
7209
    assert!(old.commit("refused", &[]).is_err(), "--at-op is read-only");
7210
    fs::remove_file(root.join("z.txt")).unwrap();
7211
    assert!(repo.resolve_op("zz").is_err());
7212
    assert_eq!(repo.resolve_op("@").unwrap().0, repo.head_op().unwrap().0);
7213
7214
    // Restore goes back, files and all; undo takes it back whole.
7215
    repo.op_restore(&at_second, false).unwrap();
7216
    assert_eq!(repo.bookmark_heads().unwrap(), vec![second]);
7217
    assert!(!root.join("n.txt").exists());
7218
    repo.undo().unwrap();
7219
    let mut heads = repo.bookmark_heads().unwrap();
7220
    heads.sort();
7221
    let mut want = vec![second, third];
7222
    want.sort();
7223
    assert_eq!(heads, want);
7224
    assert!(
7225
        root.join("n.txt").exists(),
7226
        "the files came back with the undo"
7227
    );
7228
7229
    // Revert the bookmark's creation alone: the commit after it stays.
7230
    let r = repo.op_revert(&created).unwrap();
7231
    assert_eq!(r.reverted, vec!["keep".to_string()]);
7232
    assert!(!repo.view().unwrap().bookmarks.contains_key("keep"));
7233
    assert!(repo
7234
        .load_state(&repo.bookmark_heads().unwrap())
7235
        .unwrap()
7236
        .contains(&third));
7237
    assert!(repo.op_revert(&created).is_err(), "nothing left to revert");
7238
    let _ = first;
7239
}
7240
7241
/// oxygen#41: with uncommitted work, a status after a snapshot is answered
7242
/// from the status cache while every file is as the snapshot left it, and
7243
/// from a snapshot again as soon as one is not.
7244
#[test]
7245
fn status_with_uncommitted_work_comes_from_the_cache_until_a_file_changes() {
7246
    hermetic();
7247
    let dir = tempfile::tempdir().unwrap();
7248
    let root = dir.path();
7249
    let repo = Repo::init(root).unwrap();
7250
    write(root, "a.txt", b"a\n");
7251
    write(root, "b.txt", b"b\n");
7252
    repo.commit("base", &[]).unwrap();
7253
    assert_eq!(repo.status_by_manifest().unwrap(), Some(vec![]), "clean");
7254
7255
    write(root, "a.txt", b"a\nmore\n");
7256
    assert_eq!(
7257
        repo.status_by_manifest().unwrap(),
7258
        None,
7259
        "no snapshot of the edit yet"
7260
    );
7261
    let snap = repo.snapshot_status("oxy status").unwrap();
7262
    assert_eq!(snap.statuses.len(), 1);
7263
    let cached = repo
7264
        .status_by_manifest()
7265
        .unwrap()
7266
        .expect("answered from the cache");
7267
    assert_eq!(cached.len(), 1);
7268
    assert_eq!(
7269
        (cached[0].path.as_str(), cached[0].status),
7270
        ("a.txt", ChangeStatus::Modified)
7271
    );
7272
7273
    // Any further change sends status back to a snapshot.
7274
    write(root, "a.txt", b"a\nmore\nand more\n");
7275
    assert_eq!(repo.status_by_manifest().unwrap(), None, "edited again");
7276
    repo.snapshot_status("oxy status").unwrap();
7277
    assert!(repo.status_by_manifest().unwrap().is_some());
7278
    write(root, "new.txt", b"n\n");
7279
    assert_eq!(repo.status_by_manifest().unwrap(), None, "a file added");
7280
    fs::remove_file(root.join("new.txt")).unwrap();
7281
    assert!(
7282
        repo.status_by_manifest().unwrap().is_some(),
7283
        "back as it was"
7284
    );
7285
    fs::remove_file(root.join("b.txt")).unwrap();
7286
    assert_eq!(repo.status_by_manifest().unwrap(), None, "a file removed");
7287
    write(root, "b.txt", b"b\n");
7288
7289
    // A commit leaves nothing uncommitted: clean again, no cache needed.
7290
    repo.snapshot_status("oxy status").unwrap();
7291
    repo.commit("more", &[]).unwrap();
7292
    assert_eq!(repo.status_by_manifest().unwrap(), Some(vec![]));
7293
}
7294
7295
/// oxygen#41: while the status cache holds, the working copy's diff is
7296
/// rendered from the stored working-copy change, and says exactly what a
7297
/// snapshot's diff says.
7298
#[test]
7299
fn the_working_copy_diff_from_the_cache_matches_a_snapshot() {
7300
    hermetic();
7301
    let dir = tempfile::tempdir().unwrap();
7302
    let root = dir.path();
7303
    let repo = Repo::init(root).unwrap();
7304
    write(root, "a.txt", b"one\ntwo\nthree\n");
7305
    write(root, "gone.txt", b"bye\n");
7306
    repo.commit("base", &[]).unwrap();
7307
    write(root, "a.txt", b"one\n2\nthree\nfour\n");
7308
    write(root, "new.txt", b"hello\n");
7309
    fs::remove_file(root.join("gone.txt")).unwrap();
7310
    let snap = repo.snapshot_status("oxy status").unwrap();
7311
    let slow = render_change(&snap.wc, &snap.base, &snap.base_snapshot);
7312
    assert!(
7313
        repo.status_by_manifest().unwrap().is_some(),
7314
        "the cache holds"
7315
    );
7316
    let (wc, base, snapshot) = repo.working_copy_with_base().unwrap();
7317
    assert_eq!(render_change(&wc, &base, &snapshot), slow);
7318
    assert_eq!(repo.diff_files(None).unwrap().len(), 3);
7319
}
7320
7321
/// oxygen#41: a manifest written from a walk keeps every directory's time;
7322
/// while none has moved, the check stats the tracked files without listing
7323
/// the tree, and a file added or removed anywhere moves its directory's
7324
/// time, so the tree is listed again.
7325
#[test]
7326
fn the_manifest_check_skips_listing_while_no_directory_changed() {
7327
    hermetic();
7328
    let dir = tempfile::tempdir().unwrap();
7329
    let root = dir.path();
7330
    let repo = Repo::init(root).unwrap();
7331
    write(root, "a.txt", b"a\n");
7332
    write(root, "sub/deep/b.txt", b"b\n");
7333
    repo.commit("base", &[]).unwrap();
7334
    // Out of the racy window: every file and directory a minute old.
7335
    let old = std::time::SystemTime::now() - std::time::Duration::from_secs(60);
7336
    for p in ["a.txt", "sub/deep/b.txt", "sub/deep", "sub", ""] {
7337
        fs::File::open(root.join(p))
7338
            .unwrap()
7339
            .set_modified(old)
7340
            .unwrap();
7341
    }
7342
    repo.snapshot_status("oxy status").unwrap();
7343
    repo.write_manifest(false, false).unwrap();
7344
    let m = repo.read_manifest().unwrap().unwrap().2;
7345
    assert!(m.dirs.iter().any(|(d, _)| d == "sub/deep"), "{:?}", m.dirs);
7346
    assert_eq!(
7347
        repo.disk_differs_by_dirs(&m),
7348
        Some(false),
7349
        "no listing needed"
7350
    );
7351
    assert_eq!(repo.status_by_manifest().unwrap(), Some(vec![]));
7352
7353
    // A same-size edit with a new time: found by its stat.
7354
    write(root, "sub/deep/b.txt", b"c\n");
7355
    assert_eq!(repo.disk_differs_by_dirs(&m), Some(true));
7356
    write(root, "sub/deep/b.txt", b"b\n");
7357
    fs::File::open(root.join("sub/deep/b.txt"))
7358
        .unwrap()
7359
        .set_modified(old)
7360
        .unwrap();
7361
    assert_eq!(repo.disk_differs_by_dirs(&m), Some(false));
7362
7363
    // A new file moves its directory's time: the tree must be listed.
7364
    write(root, "sub/deep/new.txt", b"n\n");
7365
    assert_eq!(repo.disk_differs_by_dirs(&m), None);
7366
    assert_eq!(repo.status_by_manifest().unwrap(), None);
7367
    fs::remove_file(root.join("sub/deep/new.txt")).unwrap();
7368
    // A removed one too.
7369
    fs::remove_file(root.join("a.txt")).unwrap();
7370
    assert_eq!(repo.disk_differs_by_dirs(&m), None);
7371
}
7372
7373
/// oxygen#41: a commit right after a status takes the snapshot the status
7374
/// left (the status cache holds) rather than walking again, and commits
7375
/// exactly what a snapshot would have.
7376
#[test]
7377
fn a_commit_after_a_status_uses_the_cached_snapshot() {
7378
    hermetic();
7379
    let dir = tempfile::tempdir().unwrap();
7380
    let root = dir.path();
7381
    let repo = Repo::init(root).unwrap();
7382
    write(root, "a.txt", b"a\nb\n");
7383
    write(root, ".oxyattributes", b"*.bin binary\n");
7384
    repo.commit("base", &[]).unwrap();
7385
    write(root, "a.txt", b"a\nB\n");
7386
    write(root, "new.txt", b"new\n");
7387
    let snap = repo.snapshot_status("oxy status").unwrap();
7388
    let cached = repo
7389
        .snapshot_from_cache()
7390
        .unwrap()
7391
        .expect("the cache holds");
7392
    assert_eq!(cached.wc_hash, snap.wc_hash);
7393
    assert_eq!(cached.statuses, snap.statuses);
7394
    assert!(
7395
        !cached.attributes.is_empty(),
7396
        "the .oxyattributes file was read"
7397
    );
7398
    let h = repo.commit("edit", &[]).unwrap();
7399
    let c = repo.store().get_change(&h).unwrap();
7400
    assert_eq!(
7401
        c.tree_ops.len() + c.edits.len(),
7402
        snap.wc.tree_ops.len() + snap.wc.edits.len()
7403
    );
7404
    assert_eq!(
7405
        repo.status_by_manifest().unwrap(),
7406
        Some(vec![]),
7407
        "clean after the commit"
7408
    );
7409
    assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"a\nB\n");
7410
}
7411
7412
/// oxygen#41: a clean check that verified racy entries restamps the
7413
/// manifest, so later checks can trust them, and a same-size rewrite
7414
/// straight afterwards is still found: entries within two seconds of the
7415
/// new time stay racy.
7416
#[test]
7417
fn a_verified_clean_check_restamps_the_manifest_safely() {
7418
    hermetic();
7419
    let dir = tempfile::tempdir().unwrap();
7420
    let root = dir.path();
7421
    let repo = Repo::init(root).unwrap();
7422
    write(root, "a.txt", b"one\n");
7423
    repo.commit("one", &[]).unwrap();
7424
    let before = repo.read_manifest().unwrap().unwrap().2.written_ns;
7425
    assert!(repo.clean_by_manifest().unwrap());
7426
    let after = repo.read_manifest().unwrap().unwrap().2.written_ns;
7427
    assert!(after > before, "restamped");
7428
    write(root, "a.txt", b"two\n");
7429
    assert!(
7430
        !repo.clean_by_manifest().unwrap(),
7431
        "a same-size rewrite is still seen"
7432
    );
7433
}
7434
7435
/// oxygen#35: a history-only state keeps no inserted text. It reads a
7436
/// stored insert's text blob only when it builds that file's graph, and
7437
/// builds the same graph a full load does.
7438
#[test]
7439
fn history_only_states_read_text_only_for_the_graphs_they_build() {
7440
    hermetic();
7441
    let dir = tempfile::tempdir().unwrap();
7442
    let root = dir.path();
7443
    let repo = Repo::init(root).unwrap();
7444
    let big: String = (0..3000).map(|i| format!("big line {i}\n")).collect();
7445
    let other: String = (0..3000).map(|i| format!("other line {i}\n")).collect();
7446
    write(root, "big.txt", big.as_bytes());
7447
    write(root, "other.txt", other.as_bytes());
7448
    let h = repo.commit("two large files", &[]).unwrap();
7449
    write(root, "big.txt", format!("{big}one more\n").as_bytes());
7450
    let h2 = repo.commit("append", &[]).unwrap();
7451
7452
    let full = repo.load_state_full(&[h2]).unwrap().materialize();
7453
    let text = |snap: &crate::state::Snapshot, p: &str| match &snap.files[p].content {
7454
        FileContent::Text { bytes, .. } => bytes.clone(),
7455
        other => panic!("{other:?}"),
7456
    };
7457
    // Built on demand, every graph renders as the full load's.
7458
    let mut hist = repo.load_state_history(&[h2]).unwrap();
7459
    let big_node = full.files["big.txt"].node;
7460
    assert!(hist.graph_key(&big_node).is_some(), "not built yet");
7461
    repo.load_all_graphs(&mut hist).unwrap();
7462
    let rendered = hist.materialize();
7463
    for p in ["big.txt", "other.txt"] {
7464
        assert_eq!(text(&rendered, p), text(&full, p), "{p}");
7465
    }
7466
7467
    // Without other.txt's text blob, history still loads and big.txt still
7468
    // builds: nothing read the other text.
7469
    let (_, texts) = repo.store().get_change_lazy(&h).unwrap();
7470
    assert_eq!(texts.len(), 2);
7471
    let content = |r: &ObjectRef| -> Vec<u8> {
7472
        let blob = crate::objects::Blob::decode(&repo.store().get(r).unwrap().1).unwrap();
7473
        blob.chunks
7474
            .iter()
7475
            .flat_map(|c| repo.store().get(c).unwrap().1)
7476
            .collect()
7477
    };
7478
    let other_text = texts
7479
        .iter()
7480
        .filter_map(|t| match t {
7481
            crate::objects::TextRef::Blob(r, _, _) => Some(*r),
7482
            _ => None,
7483
        })
7484
        .find(|r| content(r).windows(12).any(|w| w == b"other line 1"))
7485
        .unwrap();
7486
    repo.store().remove_loose_for_test(&other_text).unwrap();
7487
    let mut hist = repo.load_state_history(&[h2]).unwrap();
7488
    repo.load_graphs_of(&mut hist, &[big_node]).unwrap();
7489
    assert!(repo
7490
        .load_graphs_of(&mut hist, &[full.files["other.txt"].node])
7491
        .is_err());
7492
}
7493
7494
/// oxygen#35: the first status after a commit read and rendered every
7495
/// file (79 s and more than 14 GB on the Linux tree), because the
7496
/// manifest had no graph keys for files the commit added.
7497
#[test]
7498
fn a_commit_leaves_a_manifest_the_next_status_trusts() {
7499
    use std::os::unix::fs::PermissionsExt;
7500
    hermetic();
7501
    let dir = tempfile::tempdir().unwrap();
7502
    let root = dir.path();
7503
    let repo = Repo::init(root).unwrap();
7504
    let files = ["a.txt", "b.txt", "d/c.txt"];
7505
    for p in files {
7506
        write(root, p, format!("{p}\n").as_bytes());
7507
        let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30);
7508
        fs::File::options()
7509
            .write(true)
7510
            .open(root.join(p))
7511
            .unwrap()
7512
            .set_modified(old)
7513
            .unwrap();
7514
    }
7515
    repo.commit("added", &[]).unwrap();
7516
    let (_, _, m) = repo.read_manifest().unwrap().unwrap();
7517
    assert!(
7518
        m.entries.values().all(|e| e.graph.is_some()),
7519
        "keys recorded at commit"
7520
    );
7521
    // Unreadable files prove the next status reads none of them.
7522
    for p in files {
7523
        fs::set_permissions(root.join(p), fs::Permissions::from_mode(0o000)).unwrap();
7524
    }
7525
    let snap = repo.snapshot_status("status").unwrap();
7526
    for p in files {
7527
        fs::set_permissions(root.join(p), fs::Permissions::from_mode(0o644)).unwrap();
7528
    }
7529
    assert!(!snap.changed && snap.statuses.is_empty());
7530
    assert_eq!(snap.base_snapshot.unrendered.len(), files.len());
7531
}
7532
7533
/// oxygen#35: a checkout reads, renders and writes only the files whose
7534
/// graphs differ, and renders the rest a batch at a time.
7535
#[test]
7536
fn checkout_touches_only_what_differs_and_renders_in_batches() {
7537
    use std::os::unix::fs::PermissionsExt;
7538
    hermetic();
7539
    let dir = tempfile::tempdir().unwrap();
7540
    let root = dir.path();
7541
    let origin = root.join("origin");
7542
    let mut repo = Repo::init(&origin).unwrap();
7543
    // More files than one batch holds.
7544
    let n = 2100;
7545
    let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30);
7546
    for i in 0..n {
7547
        let p = format!("f/{i:04}.txt");
7548
        write(&origin, &p, format!("file {i}\n").as_bytes());
7549
        fs::File::options()
7550
            .write(true)
7551
            .open(origin.join(&p))
7552
            .unwrap()
7553
            .set_modified(old)
7554
            .unwrap();
7555
    }
7556
    repo.commit("many", &[]).unwrap();
7557
    repo.bookmark_create("feature", None).unwrap();
7558
    repo.bookmark_switch("feature", false).unwrap();
7559
    write(&origin, "f/0007.txt", b"changed\n");
7560
    repo.commit("one change", &[]).unwrap();
7561
7562
    // Switching back to main must not read the unchanged files.
7563
    for i in 0..n {
7564
        if i != 7 {
7565
            let p = origin.join(format!("f/{i:04}.txt"));
7566
            fs::set_permissions(&p, fs::Permissions::from_mode(0o000)).unwrap();
7567
        }
7568
    }
7569
    let r = repo.bookmark_switch("main", false).unwrap();
7570
    for i in 0..n {
7571
        let p = origin.join(format!("f/{i:04}.txt"));
7572
        fs::set_permissions(&p, fs::Permissions::from_mode(0o644)).unwrap();
7573
    }
7574
    assert_eq!((r.written, r.removed), (1, 0), "{r:?}");
7575
    assert_eq!(fs::read(origin.join("f/0007.txt")).unwrap(), b"file 7\n");
7576
    assert!(repo.snapshot_status("status").unwrap().statuses.is_empty());
7577
7578
    // A clone renders every file, across batches.
7579
    let c = root.join("clone");
7580
    Repo::clone(&origin.to_string_lossy(), &c).unwrap();
7581
    for i in [0, 2047, 2048, n - 1] {
7582
        assert_eq!(
7583
            fs::read(c.join(format!("f/{i:04}.txt"))).unwrap(),
7584
            format!("file {i}\n").as_bytes()
7585
        );
7586
    }
7587
    let cloned = Repo::open(&c).unwrap();
7588
    assert!(cloned
7589
        .snapshot_status("status")
7590
        .unwrap()
7591
        .statuses
7592
        .is_empty());
7593
}
7594
7595
/// A file written just before the manifest (inside the racy window) is
7596
/// checked by its hash: trusted when equal, caught when edited in place
7597
/// with the same size and time.
7598
#[test]
7599
fn a_racy_manifest_entry_is_checked_by_hash() {
7600
    hermetic();
7601
    let dir = tempfile::tempdir().unwrap();
7602
    let root = dir.path();
7603
    let repo = Repo::init(root).unwrap();
7604
    write(root, "a.txt", b"one\n");
7605
    repo.commit("fresh", &[]).unwrap();
7606
    assert!(repo.clean_by_manifest().unwrap(), "fresh but equal");
7607
    let t = fs::metadata(root.join("a.txt"))
7608
        .unwrap()
7609
        .modified()
7610
        .unwrap();
7611
    write(root, "a.txt", b"two\n");
7612
    fs::File::options()
7613
        .write(true)
7614
        .open(root.join("a.txt"))
7615
        .unwrap()
7616
        .set_modified(t)
7617
        .unwrap();
7618
    assert!(
7619
        !repo.clean_by_manifest().unwrap(),
7620
        "same size and time, other bytes"
7621
    );
7622
    assert_eq!(
7623
        statuses(&repo.snapshot_status("status").unwrap()),
7624
        vec![("a.txt".to_string(), ChangeStatus::Modified)]
7625
    );
7626
}
7627
7628
/// A fetch on one machine moves changes as stored, text blobs among the
7629
/// objects; a change whose text blob did not come is refused, not stored.
7630
#[test]
7631
fn a_raw_bundle_without_a_changes_text_is_refused() {
7632
    hermetic();
7633
    let dir = tempfile::tempdir().unwrap();
7634
    let root = dir.path();
7635
    let a = Repo::init(&root.join("a")).unwrap();
7636
    let big: String = (0..3000).map(|i| format!("line {i}\n")).collect();
7637
    write(&root.join("a"), "big.txt", big.as_bytes());
7638
    let h = a.commit("big", &[]).unwrap();
7639
    let bundle = a.bundle_raw_for(&[h], None).unwrap();
7640
    assert_eq!(bundle.raw_changes.len(), 1);
7641
    assert!(bundle.changes.is_empty());
7642
    let b = Repo::init(&root.join("b")).unwrap();
7643
    let mut without = bundle.clone();
7644
    without.objects.retain(|(ty, _)| *ty != ObjectType::Blob);
7645
    let e = b.import_bundle(&without, false).unwrap_err();
7646
    assert!(e.to_string().contains("came without its text blob"), "{e}");
7647
    assert!(!b.store().has(&h));
7648
    assert_eq!(b.import_bundle(&bundle, false).unwrap(), vec![h]);
7649
    assert_eq!(b.store().get_change(&h).unwrap().message, "big");
7650
}
7651
7652
/// oxygen#30 (vscode): a directory deleted in one line of history and made
7653
/// again was a second node for one Git path, so a file moved into it on
7654
/// another line, where the first node still stood, ended up in two places.
7655
/// An import now brings the directory back rather than make a new one.
7656
#[test]
7657
fn git_import_brings_a_directory_back_rather_than_making_a_second() {
7658
    hermetic();
7659
    if std::process::Command::new("git")
7660
        .arg("--version")
7661
        .output()
7662
        .is_err()
7663
    {
7664
        eprintln!("git not installed; skipping");
7665
        return;
7666
    }
7667
    let dir = tempfile::tempdir().unwrap();
7668
    let root = dir.path();
7669
    let g = root.join("src.git");
7670
    fs::create_dir_all(&g).unwrap();
7671
    let git = |args: &[&str]| -> String {
7672
        let out = std::process::Command::new("git")
7673
            .arg("-C")
7674
            .arg(&g)
7675
            .args([
7676
                "-c",
7677
                "user.name=Grace",
7678
                "-c",
7679
                "[email protected]",
7680
                "-c",
7681
                "commit.gpgsign=false",
7682
            ])
7683
            .args(args)
7684
            .output()
7685
            .unwrap();
7686
        assert!(
7687
            out.status.success(),
7688
            "git {args:?}: {}",
7689
            String::from_utf8_lossy(&out.stderr)
7690
        );
7691
        String::from_utf8_lossy(&out.stdout).trim().to_string()
7692
    };
7693
    git(&["init", "-q", "-b", "main"]);
7694
    write(&g, "d/a.txt", b"a\n");
7695
    write(&g, "c/x.txt", b"x\n");
7696
    git(&["add", "."]);
7697
    git(&["commit", "-qm", "base"]);
7698
    // One line: move x into d while d still stands.
7699
    git(&["checkout", "-qb", "side"]);
7700
    git(&["mv", "c/x.txt", "d/x.txt"]);
7701
    git(&["commit", "-qm", "x into d"]);
7702
    // The other: d goes, comes back, and x moves into it too.
7703
    git(&["checkout", "-q", "main"]);
7704
    git(&["rm", "-q", "d/a.txt"]);
7705
    git(&["commit", "-qm", "d goes"]);
7706
    write(&g, "d/b.txt", b"b\n");
7707
    git(&["add", "."]);
7708
    git(&["commit", "-qm", "d comes back"]);
7709
    git(&["mv", "c/x.txt", "d/x.txt"]);
7710
    git(&["commit", "-qm", "x into d again"]);
7711
    git(&["merge", "-q", "--no-edit", "side"]);
7712
7713
    let repo = Repo::init(&root.join("o")).unwrap();
7714
    repo.import_git(&g, "main").unwrap();
7715
    let v = repo.verify_git_import(&g, "main").unwrap();
7716
    assert!(v.mismatches.is_empty(), "{:?}", v.mismatches);
7717
    let st = repo
7718
        .load_state_history(&repo.bookmark_heads().unwrap())
7719
        .unwrap();
7720
    let view = st.tree().view();
7721
    assert!(view.conflicts.is_empty(), "{:?}", view.conflicts);
7722
    // The directory is the one the first commit made.
7723
    // Commits made in one second tie on time: find the first by its message.
7724
    let first = repo
7725
        .log()
7726
        .unwrap()
7727
        .into_iter()
7728
        .find(|e| e.change.message.starts_with("base"))
7729
        .unwrap();
7730
    let base = repo
7731
        .load_state_history(&[first.hash])
7732
        .unwrap()
7733
        .tree()
7734
        .view();
7735
    assert_eq!(view.entries["d"].node, base.entries["d"].node);
7736
}
7737
7738
/// oxygen#30, the rest: a placement left under a directory another line
7739
/// removed is out of sight, so no diff reaches it; the merge that settles
7740
/// where the file is retires it.
7741
#[test]
7742
fn git_import_merge_retires_a_placement_under_a_removed_directory() {
7743
    hermetic();
7744
    if std::process::Command::new("git")
7745
        .arg("--version")
7746
        .output()
7747
        .is_err()
7748
    {
7749
        eprintln!("git not installed; skipping");
7750
        return;
7751
    }
7752
    let dir = tempfile::tempdir().unwrap();
7753
    let root = dir.path();
7754
    let g = root.join("src.git");
7755
    fs::create_dir_all(&g).unwrap();
7756
    let run = |args: &[&str]| {
7757
        std::process::Command::new("git")
7758
            .arg("-C")
7759
            .arg(&g)
7760
            .args([
7761
                "-c",
7762
                "user.name=Grace",
7763
                "-c",
7764
                "[email protected]",
7765
                "-c",
7766
                "commit.gpgsign=false",
7767
            ])
7768
            .args(args)
7769
            .output()
7770
            .unwrap()
7771
    };
7772
    let git = |args: &[&str]| {
7773
        let out = run(args);
7774
        assert!(
7775
            out.status.success(),
7776
            "git {args:?}: {}",
7777
            String::from_utf8_lossy(&out.stderr)
7778
        );
7779
    };
7780
    git(&["init", "-q", "-b", "main"]);
7781
    write(&g, "c/x.txt", b"x\n");
7782
    write(&g, "keep.txt", b"k\n");
7783
    git(&["add", "."]);
7784
    git(&["commit", "-qm", "base"]);
7785
    // One line renames the file within c; the other moves it out of c,
7786
    // which goes.
7787
    git(&["checkout", "-qb", "side"]);
7788
    git(&["mv", "c/x.txt", "c/y.txt"]);
7789
    git(&["commit", "-qm", "rename within c"]);
7790
    git(&["checkout", "-q", "main"]);
7791
    fs::create_dir_all(g.join("n")).unwrap();
7792
    git(&["mv", "c/x.txt", "n/x.txt"]);
7793
    git(&["commit", "-qm", "move out of c"]);
7794
    // Git cannot merge the two renames; the merge keeps n/x.txt.
7795
    let _ = run(&["merge", "-q", "side"]);
7796
    let _ = run(&[
7797
        "rm",
7798
        "-q",
7799
        "--cached",
7800
        "--ignore-unmatch",
7801
        "c/y.txt",
7802
        "c/x.txt",
7803
    ]);
7804
    let _ = std::fs::remove_dir_all(g.join("c"));
7805
    write(&g, "n/x.txt", b"x\n");
7806
    git(&["add", "-A"]);
7807
    git(&["commit", "-qm", "merge: keep n/x.txt"]);
7808
7809
    let repo = Repo::init(&root.join("o")).unwrap();
7810
    repo.import_git(&g, "main").unwrap();
7811
    let v = repo.verify_git_import(&g, "main").unwrap();
7812
    assert!(v.mismatches.is_empty(), "{:?}", v.mismatches);
7813
    let st = repo
7814
        .load_state_history(&repo.bookmark_heads().unwrap())
7815
        .unwrap();
7816
    let view = st.tree().view();
7817
    assert!(view.conflicts.is_empty(), "{:?}", view.conflicts);
7818
    assert!(view.entries.contains_key("n/x.txt"));
7819
}
7820
7821
/// oxygen#30, orphans: a file added on one line into a directory the other
7822
/// line removed is placed where the tree cannot show it; Git's merge keeps
7823
/// the file, and the import's merge leaves exactly one of it.
7824
#[test]
7825
fn git_import_merge_leaves_no_orphan_behind() {
7826
    hermetic();
7827
    if std::process::Command::new("git")
7828
        .arg("--version")
7829
        .output()
7830
        .is_err()
7831
    {
7832
        eprintln!("git not installed; skipping");
7833
        return;
7834
    }
7835
    let dir = tempfile::tempdir().unwrap();
7836
    let root = dir.path();
7837
    let g = root.join("src.git");
7838
    fs::create_dir_all(&g).unwrap();
7839
    let git = |args: &[&str]| {
7840
        let out = std::process::Command::new("git")
7841
            .arg("-C")
7842
            .arg(&g)
7843
            .args([
7844
                "-c",
7845
                "user.name=Grace",
7846
                "-c",
7847
                "[email protected]",
7848
                "-c",
7849
                "commit.gpgsign=false",
7850
            ])
7851
            .args(args)
7852
            .output()
7853
            .unwrap();
7854
        assert!(
7855
            out.status.success(),
7856
            "git {args:?}: {}",
7857
            String::from_utf8_lossy(&out.stderr)
7858
        );
7859
    };
7860
    git(&["init", "-q", "-b", "main"]);
7861
    write(&g, "d/f.txt", b"f\n");
7862
    write(&g, "keep.txt", b"k\n");
7863
    git(&["add", "."]);
7864
    git(&["commit", "-qm", "base"]);
7865
    git(&["checkout", "-qb", "side"]);
7866
    write(&g, "d/new.txt", b"new\n");
7867
    git(&["add", "."]);
7868
    git(&["commit", "-qm", "add into d"]);
7869
    git(&["checkout", "-q", "main"]);
7870
    git(&["rm", "-q", "d/f.txt"]);
7871
    git(&["commit", "-qm", "d goes"]);
7872
    git(&["merge", "-q", "--no-edit", "side"]);
7873
7874
    let repo = Repo::init(&root.join("o")).unwrap();
7875
    repo.import_git(&g, "main").unwrap();
7876
    let v = repo.verify_git_import(&g, "main").unwrap();
7877
    assert!(v.mismatches.is_empty(), "{:?}", v.mismatches);
7878
    let st = repo
7879
        .load_state_history(&repo.bookmark_heads().unwrap())
7880
        .unwrap();
7881
    let view = st.tree().view();
7882
    assert!(view.conflicts.is_empty(), "{:?}", view.conflicts);
7883
    assert!(view.entries.contains_key("d/new.txt"));
7884
}
7885
7886
/// fsck checks a change's text as the blobs it is stored in, without
7887
/// decoding it: a missing text blob is an error named for the change.
7888
#[test]
7889
fn fsck_names_a_missing_text_blob() {
7890
    hermetic();
7891
    let dir = tempfile::tempdir().unwrap();
7892
    let root = dir.path();
7893
    let repo = Repo::init(root).unwrap();
7894
    let big: String = (0..3000).map(|i| format!("line {i}\n")).collect();
7895
    write(root, "big.txt", big.as_bytes());
7896
    let h = repo.commit("big", &[]).unwrap();
7897
    assert_eq!(repo.fsck().unwrap().errors(), 0);
7898
    let (_, texts) = repo.store().get_change_lazy(&h).unwrap();
7899
    let Some(crate::objects::TextRef::Blob(b, _, _)) = texts.first().copied() else {
7900
        panic!("a stored text")
7901
    };
7902
    repo.store().remove_loose_for_test(&b).unwrap();
7903
    let rep = repo.fsck().unwrap();
7904
    assert!(rep.errors() > 0);
7905
    assert!(
7906
        rep.problems.iter().any(|p| p.what.contains(&h.short(12))),
7907
        "{:?}",
7908
        rep.problems
7909
    );
7910
}
7911
7912
/// oxygen#32: a subrepo on the same hub as one of the parent's remotes is
7913
/// reached with that remote's token and CA (a private subrepo then clones
7914
/// for anyone who can read the parent); another hub gets none of them.
7915
#[test]
7916
fn a_subrepo_on_the_parents_hub_borrows_its_credentials() {
7917
    hermetic();
7918
    let dir = tempfile::tempdir().unwrap();
7919
    let mut repo = Repo::init(dir.path()).unwrap();
7920
    repo.config
7921
        .set(
7922
            "remote.origin.url",
7923
            "https://Hub.example:7343/doomsdayone/metalgear",
7924
        )
7925
        .unwrap();
7926
    repo.config.set("remote.origin.token", "secret").unwrap();
7927
    repo.config
7928
        .set("remote.origin.ca", "/tmp/hub-ca.pem")
7929
        .unwrap();
7930
    let same = repo.credentials_for("https://hub.example:7343/doomsdayone/rstar-api");
7931
    assert_eq!(same.token.as_deref(), Some("secret"));
7932
    assert_eq!(same.ca.as_deref(), Some(Path::new("/tmp/hub-ca.pem")));
7933
    for other in [
7934
        "https://elsewhere.example:7343/doomsdayone/rstar-api",
7935
        "https://hub.example/doomsdayone/rstar-api",
7936
        "http://hub.example:7343/doomsdayone/rstar-api",
7937
    ] {
7938
        let o = repo.credentials_for(other);
7939
        assert_ne!(o.token.as_deref(), Some("secret"), "{other}");
7940
    }
7941
}
7942
7943
/// oxygen#47: a remote with no token of its own uses the user's credential
7944
/// store for its hub, and a nested checkout finds its parent's remote
7945
/// token there instead of keeping a copy.
7946
#[test]
7947
fn remotes_use_the_credential_store_and_the_enclosing_checkout() {
7948
    hermetic();
7949
    let home = tempfile::tempdir().unwrap();
7950
    let dir = tempfile::tempdir().unwrap();
7951
    let mut parent = Repo::init(dir.path()).unwrap().with_home(home.path());
7952
    parent
7953
        .config
7954
        .set("remote.origin.url", "https://hub.example/o/parent")
7955
        .unwrap();
7956
    parent.save_config().unwrap();
7957
    assert_eq!(parent.remote_options("origin").token, None);
7958
    crate::credentials::Credentials::at(home.path())
7959
        .save("https://hub.example", "stored", Some("andy"), None)
7960
        .unwrap();
7961
    assert_eq!(
7962
        parent.remote_options("origin").token.as_deref(),
7963
        Some("stored")
7964
    );
7965
7966
    let empty_home = tempfile::tempdir().unwrap();
7967
    let nested = dir.path().join("deps").join("child");
7968
    let mut child = Repo::init(&nested).unwrap().with_home(empty_home.path());
7969
    child
7970
        .config
7971
        .set("remote.origin.url", "https://hub.example/o/child")
7972
        .unwrap();
7973
    child.save_config().unwrap();
7974
    assert_eq!(child.remote_options("origin").token, None);
7975
    parent
7976
        .config
7977
        .set("remote.origin.token", "checkout")
7978
        .unwrap();
7979
    parent.save_config().unwrap();
7980
    assert_eq!(
7981
        child.remote_options("origin").token.as_deref(),
7982
        Some("checkout")
7983
    );
7984
    // Another hub's checkout token is not borrowed.
7985
    child
7986
        .config
7987
        .set("remote.origin.url", "https://other.example/o/child")
7988
        .unwrap();
7989
    assert_eq!(child.remote_options("origin").token, None);
7990
}
7991
7992
#[test]
7993
fn the_metadata_cache_survives_a_torn_tail() {
7994
    hermetic();
7995
    let dir = tempfile::tempdir().unwrap();
7996
    let root = dir.path();
7997
    let repo = Repo::init(root).unwrap();
7998
    write(root, "a.txt", b"1\n");
7999
    let h = repo.commit("first", &[]).unwrap();
8000
    let meta = repo.store().get_change_meta(&h).unwrap();
8001
    let cache = repo.store().dir().join("metas");
8002
    let len = fs::metadata(&cache).unwrap().len();
8003
    assert!(len > 0, "a change read is cached");
8004
    // A torn record at the end: the cache reads up to it and cuts it off.
8005
    fs::OpenOptions::new()
8006
        .append(true)
8007
        .open(&cache)
8008
        .unwrap()
8009
        .write_all(&[7; 20])
8010
        .unwrap();
8011
    let again = Repo::open(root).unwrap();
8012
    assert_eq!(again.store().get_change_meta(&h).unwrap(), meta);
8013
    assert_eq!(fs::metadata(&cache).unwrap().len(), len);
8014
}
8015
8016
#[test]
8017
fn a_manifest_that_does_not_check_reads_as_none() {
8018
    hermetic();
8019
    let dir = tempfile::tempdir().unwrap();
8020
    let root = dir.path();
8021
    let repo = Repo::init(root).unwrap();
8022
    write(root, "a.txt", b"1\n");
8023
    repo.commit("first", &[]).unwrap();
8024
    let path = repo.manifest_path();
8025
    let mut bytes = fs::read(&path).unwrap();
8026
    assert!(Repo::open(root).unwrap().read_manifest().unwrap().is_some());
8027
    let last = bytes.len() - 1;
8028
    bytes[last] ^= 1;
8029
    fs::write(&path, &bytes).unwrap();
8030
    assert!(Repo::open(root).unwrap().read_manifest().unwrap().is_none());
8031
    // The next status reads the files and writes it again.
8032
    let again = Repo::open(root).unwrap();
8033
    assert!(!again.snapshot("status").unwrap().changed);
8034
    assert!(again.read_manifest().unwrap().is_some());
8035
}
8036
8037
#[test]
8038
fn a_commit_leaves_a_manifest_that_proves_the_working_copy_clean() {
8039
    hermetic();
8040
    let dir = tempfile::tempdir().unwrap();
8041
    let root = dir.path();
8042
    let repo = Repo::init(root).unwrap();
8043
    write(root, "a.txt", b"1\n");
8044
    write(root, "b.txt", b"2\n");
8045
    // b.txt outside the racy window, so snapshots take it as unchanged.
8046
    let old = std::time::SystemTime::now() - std::time::Duration::from_secs(60);
8047
    fs::File::options()
8048
        .write(true)
8049
        .open(root.join("b.txt"))
8050
        .unwrap()
8051
        .set_modified(old)
8052
        .unwrap();
8053
    repo.commit("first", &[]).unwrap();
8054
    assert!(repo.clean_by_manifest().unwrap(), "after the first commit");
8055
    repo.snapshot_status("status").unwrap();
8056
    write(root, "a.txt", b"1\n2\n");
8057
    repo.commit("second", &[]).unwrap();
8058
    assert!(
8059
        repo.clean_by_manifest().unwrap(),
8060
        "after a commit of one file"
8061
    );
8062
    // Edited, seen by a status, edited again, then committed: the status
8063
    // left the first edit's stat, which the commit must not keep.
8064
    write(root, "a.txt", b"1\n2\n3\n");
8065
    repo.snapshot_status("status").unwrap();
8066
    write(root, "a.txt", b"1\n2\n3\n4\n5\n");
8067
    repo.commit("third", &[]).unwrap();
8068
    assert!(
8069
        repo.clean_by_manifest().unwrap(),
8070
        "after an edit a status saw"
8071
    );
8072
}
8073
8074
#[test]
8075
fn a_conflicted_rebase_can_be_resolved_in_the_working_copy() {
8076
    hermetic();
8077
    let dir = tempfile::tempdir().unwrap();
8078
    let root = dir.path();
8079
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
8080
    write(root, "a.txt", b"1\n2\n");
8081
    let a = repo.commit("A", &[]).unwrap();
8082
    repo.bookmark_create("develop", Some(&[a])).unwrap();
8083
    write(root, "a.txt", b"1\nX\n");
8084
    let c = repo.commit("C: 2 to X", &[]).unwrap();
8085
    repo.bookmark_switch("develop", false).unwrap();
8086
    write(root, "a.txt", b"1\nY\n");
8087
    let d = repo.commit("D: 2 to Y", &[]).unwrap();
8088
    repo.bookmark_switch("main", false).unwrap();
8089
8090
    let err = repo.rebase(&c, &[d]).unwrap_err();
8091
    assert!(matches!(err, RepoError::Conflicted(_)), "{err}");
8092
    let r = repo.rebase_into_working_copy(&c, &[d]).unwrap();
8093
    assert!(r.changed && r.checked_out);
8094
    let snap = repo.snapshot_status("status").unwrap();
8095
    assert!(
8096
        snap.base_snapshot.is_conflicted(),
8097
        "the conflict is in the working copy"
8098
    );
8099
    let text = fs::read_to_string(root.join("a.txt")).unwrap();
8100
    assert!(text.contains('X') && text.contains('Y'), "{text}");
8101
8102
    write(root, "a.txt", b"1\nXY\n");
8103
    repo.commit("Resolve", &[]).unwrap();
8104
    let st = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap();
8105
    assert!(st.contains(&c) && st.contains(&d));
8106
    let files = st.materialize().files;
8107
    assert!(!files["a.txt"].is_conflicted());
8108
    assert!(
8109
        matches!(&files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"1\nXY\n")
8110
    );
8111
8112
    // A change the current bookmark does not hold is refused.
8113
    repo.bookmark_switch("develop", false).unwrap();
8114
    write(root, "b.txt", b"b\n");
8115
    let e = repo.commit("E", &[]).unwrap();
8116
    repo.bookmark_switch("main", false).unwrap();
8117
    let err = repo.rebase_into_working_copy(&e, &[a]).unwrap_err();
8118
    assert!(
8119
        err.to_string().contains("not on the current bookmark"),
8120
        "{err}"
8121
    );
8122
}
8123
8124
#[test]
8125
fn an_ignore_rule_drops_an_added_file_but_not_a_tracked_one() {
8126
    hermetic();
8127
    let dir = tempfile::tempdir().unwrap();
8128
    let root = dir.path();
8129
    let repo = Repo::init(root).unwrap();
8130
    write(root, "a.txt", b"a\n");
8131
    write(root, "kept.log", b"tracked\n");
8132
    repo.commit("first", &[]).unwrap();
8133
    write(root, "build.log", b"made\n");
8134
    let snap = repo.snapshot_status("status").unwrap();
8135
    assert!(snap.statuses.iter().any(|s| s.path == "build.log"));
8136
    // A rule written afterwards leaves the added file out again, while the
8137
    // committed one stays tracked.
8138
    write(root, ".gitignore", b"*.log\n");
8139
    let snap = repo.snapshot_status("status").unwrap();
8140
    let paths: Vec<&str> = snap.statuses.iter().map(|s| s.path.as_str()).collect();
8141
    assert_eq!(paths, vec![".gitignore"], "{paths:?}");
8142
    write(root, "kept.log", b"tracked, edited\n");
8143
    let snap = repo.snapshot_status("status").unwrap();
8144
    assert!(snap.statuses.iter().any(|s| s.path == "kept.log"));
8145
}
8146
8147
#[test]
8148
fn shed_graphs_come_back_the_same() {
8149
    hermetic();
8150
    let dir = tempfile::tempdir().unwrap();
8151
    let root = dir.path();
8152
    let repo = Repo::init(root).unwrap();
8153
    write(root, "a.txt", b"1\n2\n3\n");
8154
    write(root, "b.txt", b"x\n");
8155
    repo.commit("first", &[]).unwrap();
8156
    write(root, "a.txt", b"1\ntwo\n3\n4\n");
8157
    repo.commit("second", &[]).unwrap();
8158
    let heads = repo.bookmark_heads().unwrap();
8159
    let mut st = repo.load_state_history(&heads).unwrap();
8160
    let view = st.tree().view();
8161
    let nodes: Vec<NodeId> = ["a.txt", "b.txt"]
8162
        .iter()
8163
        .map(|p| view.entries[*p].node)
8164
        .collect();
8165
    repo.load_graphs_of(&mut st, &nodes).unwrap();
8166
    let before: Vec<Vec<u8>> = nodes.iter().map(|n| st.render_file(n).0).collect();
8167
    assert_eq!(st.built_count(), 2);
8168
    let shed = st.shed_graphs(|b| repo.store_blob(b)).unwrap();
8169
    assert_eq!((shed, st.built_count()), (2, 0));
8170
    // A later change applies on top of the shed history.
8171
    write(root, "a.txt", b"1\ntwo\n3\n4\n5\n");
8172
    let h3 = repo.commit("third", &[]).unwrap();
8173
    let (skeleton, texts) = repo.store().get_change_lazy(&h3).unwrap();
8174
    st.add_known(h3, skeleton, texts);
8175
    repo.load_graphs_of(&mut st, &nodes).unwrap();
8176
    let after: Vec<Vec<u8>> = nodes.iter().map(|n| st.render_file(n).0).collect();
8177
    assert_eq!(after[1], before[1]);
8178
    assert_eq!(after[0], b"1\ntwo\n3\n4\n5\n");
8179
}
8180
8181
#[test]
8182
fn many_edited_files_diff_in_parallel() {
8183
    // Enough edited files that the builder diffs them in parallel; the
8184
    // commit must hold exactly what is on disk, and a second snapshot
8185
    // must find nothing left to commit.
8186
    hermetic();
8187
    let dir = tempfile::tempdir().unwrap();
8188
    let root = dir.path();
8189
    let repo = Repo::init(root).unwrap();
8190
    let text = |i: usize, round: usize| {
8191
        let mut s = String::new();
8192
        for line in 0..50 {
8193
            if round > 0 && line % 7 == i % 7 {
8194
                s.push_str(&format!("edited {round} {i} {line}\n"));
8195
            } else {
8196
                s.push_str(&format!("file {i} line {line}\n"));
8197
            }
8198
        }
8199
        if round > 0 && i.is_multiple_of(3) {
8200
            s.push_str("appended\n");
8201
        }
8202
        s
8203
    };
8204
    for i in 0..40 {
8205
        write(root, &format!("d/f{i}.txt"), text(i, 0).as_bytes());
8206
    }
8207
    repo.commit("first", &[]).unwrap();
8208
    for i in 0..40 {
8209
        write(root, &format!("d/f{i}.txt"), text(i, 1).as_bytes());
8210
    }
8211
    let h = repo.commit("edit every file", &[]).unwrap();
8212
    let snap = repo.load_state(&[h]).unwrap().materialize();
8213
    for i in 0..40 {
8214
        let want = text(i, 1);
8215
        assert!(
8216
            matches!(&snap.files[&format!("d/f{i}.txt")].content, FileContent::Text { bytes, .. } if bytes == want.as_bytes()),
8217
            "d/f{i}.txt as committed"
8218
        );
8219
    }
8220
    assert!(
8221
        repo.commit("nothing", &[]).is_err(),
8222
        "nothing left to commit"
8223
    );
8224
}
8225
8226
/// oxygen#56: after a fetch, how a bookmark stands against its remote copy
8227
/// is known (fetch printed "up to date" while main was behind), a change
8228
/// only fetched resolves and says where it is, and a checkout whose only
8229
/// remote is not `origin` uses that one.
8230
#[test]
8231
fn a_fetched_change_shows_as_behind_and_resolves() {
8232
    hermetic();
8233
    let dir = tempfile::tempdir().unwrap();
8234
    let root = dir.path();
8235
    let origin = Repo::init(&root.join("origin")).unwrap();
8236
    drop(origin);
8237
    let url = root.join("origin").to_string_lossy().to_string();
8238
    let a = Repo::clone(&url, &root.join("a"))
8239
        .unwrap()
8240
        .with_home(root.join("home-a"));
8241
    write(&root.join("a"), "f.txt", b"one\n");
8242
    a.commit("one", &[]).unwrap();
8243
    a.push("origin", false).unwrap();
8244
    let b = Repo::clone(&url, &root.join("b"))
8245
        .unwrap()
8246
        .with_home(root.join("home-b"));
8247
    assert_eq!(b.tracking("origin", "main").unwrap(), Some((0, 0)));
8248
8249
    write(&root.join("a"), "f.txt", b"one\ntwo\n");
8250
    let h2 = a.commit("two", &[]).unwrap();
8251
    a.push("origin", false).unwrap();
8252
    b.fetch("origin").unwrap();
8253
    // Fetched, not pulled: one behind, the change found and placed.
8254
    assert_eq!(b.tracking("origin", "main").unwrap(), Some((0, 1)));
8255
    assert_eq!(b.tracking("origin", "nope").unwrap(), None);
8256
    assert_eq!(
8257
        b.resolve_change(&format!("#{}", &h2.to_hex()[..12]))
8258
            .unwrap(),
8259
        h2
8260
    );
8261
    assert_eq!(
8262
        b.remote_only_location(&h2).unwrap().as_deref(),
8263
        Some("origin/main")
8264
    );
8265
    // Fetching again changes nothing: still behind, which is what the
8266
    // CLI now says rather than "up to date".
8267
    b.fetch("origin").unwrap();
8268
    assert_eq!(b.tracking("origin", "main").unwrap(), Some((0, 1)));
8269
    b.pull("origin").unwrap();
8270
    assert_eq!(b.tracking("origin", "main").unwrap(), Some((0, 0)));
8271
    assert_eq!(b.remote_only_location(&h2).unwrap(), None);
8272
8273
    // One remote, not called origin: it is the default.
8274
    let mut c = Repo::init(&root.join("c")).unwrap();
8275
    assert_eq!(c.default_remote(), "origin");
8276
    c.config.set("remote.earth.url", &url).unwrap();
8277
    assert_eq!(c.default_remote(), "earth");
8278
    c.config.set("remote.origin.url", &url).unwrap();
8279
    assert_eq!(c.default_remote(), "origin");
8280
}
8281
8282
/// oxygen#57: a clone from a relative local path records the remote as an
8283
/// absolute path, so fetch still finds it from inside the clone.
8284
#[test]
8285
fn a_clone_from_a_relative_path_records_it_absolute() {
8286
    hermetic();
8287
    let dir = tempfile::tempdir().unwrap();
8288
    let root = dir.path();
8289
    Repo::init(&root.join("origin")).unwrap();
8290
    let rel = crate::protocol::stored_url("relative/repo");
8291
    assert!(std::path::Path::new(&rel).is_absolute(), "{rel}");
8292
    assert_eq!(
8293
        crate::protocol::stored_url("https://hub.example/o/n"),
8294
        "https://hub.example/o/n"
8295
    );
8296
    assert!(crate::protocol::stored_url("file://x").starts_with("file:///"));
8297
    assert!(crate::protocol::stored_url("dir://x").starts_with("dir:///"));
8298
    // A bucket is kept as written: it is not a path.
8299
    assert_eq!(
8300
        crate::protocol::stored_url("s3://bucket/prefix?profile=p"),
8301
        "s3://bucket/prefix?profile=p"
8302
    );
8303
    let url = root.join("origin").to_string_lossy().to_string();
8304
    let b = Repo::clone(&url, &root.join("b")).unwrap();
8305
    assert_eq!(b.config.remotes["origin"], url);
8306
    b.fetch("origin").unwrap();
8307
}
8308
8309
/// oxygen#60 and #61: a workspace added on a bookmark is on that bookmark,
8310
/// and gets its subrepos at their pins from this workspace's copies, with
8311
/// their remotes pointing where the subrepos come from.
8312
#[test]
8313
fn a_workspace_added_on_a_bookmark_is_on_it_with_its_subrepos() {
8314
    hermetic();
8315
    let dir = tempfile::tempdir().unwrap();
8316
    let root = dir.path();
8317
    Repo::init(&root.join("lib-origin")).unwrap();
8318
    let lib_url = root.join("lib-origin").to_string_lossy().to_string();
8319
    let lib = Repo::clone(&lib_url, &root.join("lib"))
8320
        .unwrap()
8321
        .with_home(root.join("home"));
8322
    write(&root.join("lib"), "lib.txt", b"v1\n");
8323
    let l1 = lib.commit("lib v1", &[]).unwrap();
8324
    assert!(lib.push("origin", false).unwrap()[0].ok);
8325
8326
    let app_dir = root.join("app");
8327
    let app = Repo::init(&app_dir).unwrap().with_home(root.join("home"));
8328
    write(&app_dir, "main.txt", b"app\n");
8329
    app.submodule_add(&lib_url, "libs/lib", None).unwrap();
8330
    app.commit("app with lib", &[]).unwrap();
8331
    // A second bookmark, and the main workspace on another.
8332
    app.bookmark_create("develop", None).unwrap();
8333
    app.bookmark_create("feature", None).unwrap();
8334
    let mut app = app;
8335
    app.bookmark_switch("feature", false).unwrap();
8336
    assert_eq!(app.config.bookmark, "feature");
8337
8338
    // The library's origin goes away: the subrepo must come from here.
8339
    std::fs::rename(root.join("lib-origin"), root.join("lib-origin-gone")).unwrap();
8340
    let heads = app.resolve_heads("develop").unwrap();
8341
    let ws = app
8342
        .workspace_add_on(&root.join("ws"), Some(&heads), Some("develop"))
8343
        .unwrap();
8344
    assert_eq!(
8345
        ws.config.bookmark, "develop",
8346
        "the new workspace is on develop"
8347
    );
8348
    assert_eq!(
8349
        app.config.bookmark, "feature",
8350
        "and this one stays on its own"
8351
    );
8352
    let sub = Repo::open(&root.join("ws/libs/lib")).unwrap();
8353
    assert_eq!(
8354
        sub.config.remotes["origin"], lib_url,
8355
        "its origin is the subrepo's URL"
8356
    );
8357
    assert_eq!(sub.bookmark_heads().unwrap(), vec![l1], "at its pin");
8358
    assert_eq!(
8359
        std::fs::read(root.join("ws/libs/lib/lib.txt")).unwrap(),
8360
        b"v1\n"
8361
    );
8362
}
8363
8364
/// Revsets (docs/revset_query_language.md): symbols, relations, set
8365
/// operators and the predicates, over a small graph with two heads and
8366
/// a rename.
8367
#[test]
8368
fn revsets_select_changes_by_relation_author_text_date_and_path() {
8369
    hermetic();
8370
    let dir = tempfile::tempdir().unwrap();
8371
    let root = dir.path();
8372
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
8373
    repo.create_identity("Ada", "[email protected]", IdentityScope::User)
8374
        .unwrap();
8375
    write(root, "src/a.rs", b"one\n");
8376
    let a = repo.commit("first: add a", &[]).unwrap();
8377
    write(root, "src/a.rs", b"one\ntwo\n");
8378
    let b = repo.commit("second: grow a", &[]).unwrap();
8379
    // A second head, independent of b: a new file on top of a.
8380
    repo.checkout(&[a], false, "checkout a").unwrap();
8381
    write(root, "docs/notes.md", b"notes\n");
8382
    let c = repo.commit("third: notes", &[]).unwrap();
8383
    // Back to both heads, then rename a.rs: the node keeps its identity.
8384
    repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main")
8385
        .unwrap();
8386
    fs::remove_file(root.join("src/a.rs")).unwrap();
8387
    write(root, "src/z.rs", b"one\ntwo\n");
8388
    let d = repo.commit("fourth: rename a to z", &[]).unwrap();
8389
    let wc = repo.working_copy().unwrap().0;
8390
    // A saved revset is checked when set.
8391
    repo.set_user_config("revset.mine", "author(me) & ::main")
8392
        .unwrap();
8393
    assert!(repo.set_user_config("revset.bad", "a &").is_err());
8394
    let set = |src: &str| -> Vec<ChangeHash> {
8395
        let mut v = repo.revset(src).unwrap().hashes;
8396
        v.sort();
8397
        v
8398
    };
8399
    let sorted = |hs: &[ChangeHash]| -> Vec<ChangeHash> {
8400
        let mut v = hs.to_vec();
8401
        v.sort();
8402
        v
8403
    };
8404
8405
    // Changes commute: a new file depends on nothing and the rename on the
8406
    // file's creation, so the graph is a, b->a, d->a, c, and the working
8407
    // copy on the three heads b, c, d.
8408
    let heads = sorted(&repo.bookmark_heads().unwrap());
8409
    let meta = |h: &ChangeHash| repo.store().get_change_meta(h).unwrap();
8410
    assert!(heads.contains(&d) && heads.contains(&c));
8411
    // Symbols and closures.
8412
    assert_eq!(set("main"), heads);
8413
    assert_eq!(set("::main"), sorted(&[a, b, c, d]));
8414
    assert_eq!(set("@"), vec![wc]);
8415
    assert_eq!(set("::@"), sorted(&[a, b, c, d, wc]));
8416
    assert_eq!(set(&format!("::#{}", b.short(12))), sorted(&[a, b]));
8417
    assert_eq!(set(&format!("#{}::", a.short(12))), sorted(&[a, b, d, wc]));
8418
    assert_eq!(
8419
        set(&format!("#{}::#{}", a.short(12), b.short(12))),
8420
        sorted(&[a, b])
8421
    );
8422
    // Ranges: what main has beyond b, and `..` forms.
8423
    assert_eq!(set(&format!("#{}..main", b.short(12))), sorted(&[c, d]));
8424
    assert_eq!(set(&format!("#{}..", c.short(12))), sorted(&[a, b, d, wc]));
8425
    assert_eq!(set("..main"), sorted(&[a, b, c, d]));
8426
    // Operators and precedence: ~ binds tighter than &, & than |.
8427
    assert_eq!(
8428
        set(&format!("::main ~ #{}", d.short(12))),
8429
        sorted(&[a, b, c])
8430
    );
8431
    assert_eq!(
8432
        set(&format!("::main & #{} | #{}", b.short(12), c.short(12))),
8433
        sorted(&[b, c])
8434
    );
8435
    assert_eq!(set("all() ~ ::main"), vec![wc]);
8436
    // Relations.
8437
    let mut head_deps: Vec<ChangeHash> = heads.iter().flat_map(|h| meta(h).deps).collect();
8438
    head_deps.sort();
8439
    head_deps.dedup();
8440
    assert_eq!(set("deps(main)"), head_deps);
8441
    let on_a: Vec<ChangeHash> = [b, c, d]
8442
        .into_iter()
8443
        .filter(|h| meta(h).deps.contains(&a))
8444
        .collect();
8445
    assert_eq!(set(&format!("dependents(#{})", a.short(12))), sorted(&on_a));
8446
    assert_eq!(set("heads(::main)"), heads);
8447
    assert_eq!(set("roots(::main)"), sorted(&[a, c]));
8448
    assert_eq!(
8449
        set(&format!(
8450
            "heads(#{} | #{} | #{})",
8451
            a.short(12),
8452
            b.short(12),
8453
            c.short(12)
8454
        )),
8455
        sorted(&[b, c])
8456
    );
8457
    assert_eq!(set("latest(::main, 1)"), vec![d]);
8458
    assert_eq!(set("kind(working-copy)"), vec![wc]);
8459
    assert_eq!(set("none()"), vec![]);
8460
    // Predicates: author by name, email, key prefix and `me`.
8461
    let key = repo.config.author_key.unwrap();
8462
    assert_eq!(set("author(ada) & ::main"), sorted(&[a, b, c, d]));
8463
    assert_eq!(set("author(\"[email protected]\") & ::main").len(), 4);
8464
    assert_eq!(set(&format!("author({}) & ::main", key.short(12))).len(), 4);
8465
    assert_eq!(set("author(me) & ::main").len(), 4);
8466
    assert_eq!(set("author(nobody)"), vec![]);
8467
    assert_eq!(set("description(notes)"), vec![c]);
8468
    assert_eq!(set("description(\"rename a\")"), vec![d]);
8469
    assert_eq!(set("date(-1h..) & ::main").len(), 4);
8470
    assert_eq!(set("date(..2000-01-01)"), vec![]);
8471
    // touches follows the node through the rename, in the current state.
8472
    assert_eq!(set("touches(\"src/z.rs\")"), sorted(&[a, b, d]));
8473
    assert_eq!(set("touches(\"src/**\") & ::main"), sorted(&[a, b, d]));
8474
    assert_eq!(set("touches(\"docs/*\")"), vec![c]);
8475
    assert_eq!(set("touches(\"nothing/*\")"), vec![]);
8476
    // Newest first in the result order.
8477
    assert_eq!(repo.revset("::main").unwrap().hashes[0], d);
8478
    // The saved revset, and errors that say what is wrong.
8479
    assert_eq!(set("mine()").len(), 4);
8480
    let e = repo.revset("nosuch()").unwrap_err().to_string();
8481
    assert!(e.contains("unknown function nosuch()"), "{e}");
8482
    let e = repo.revset("main &").unwrap_err().to_string();
8483
    assert!(e.contains("at character"), "{e}");
8484
    let e = repo.revset("zzzz").unwrap_err().to_string();
8485
    assert!(e.contains("no change matches"), "{e}");
8486
    let e = repo
8487
        .revset_with_budget("::main", std::time::Duration::ZERO)
8488
        .unwrap_err()
8489
        .to_string();
8490
    assert!(e.contains("budget"), "{e}");
8491
    // The log entries carry the bookmark's heads and the working copy.
8492
    let entries = repo.log_revset("::@").unwrap();
8493
    assert!(entries[0].is_working_copy && entries.iter().any(|e| e.is_head && e.hash == d));
8494
    // Trust predicates (§2.4): nothing is signed yet (core.sign = seal, no
8495
    // push); then a signed change, a tag, and a revocation.
8496
    assert_eq!(set("unsigned() & ::main"), sorted(&[a, b, c, d]));
8497
    assert_eq!(set("signed_by(me)"), vec![]);
8498
    repo.sign_change(&d).unwrap();
8499
    assert_eq!(set("signed_by(me)"), vec![d]);
8500
    assert_eq!(set("signed_by([email protected])"), vec![d]);
8501
    assert_eq!(set(&format!("signed_by({})", key.short(12))), vec![d]);
8502
    assert_eq!(set("unsigned() & ::main"), sorted(&[a, b, c]));
8503
    assert_eq!(set("delegated()"), vec![]);
8504
    assert_eq!(set("revoked()"), vec![]);
8505
    assert_eq!(set("attested(tag)"), vec![]);
8506
    repo.tag_create("v1", &[b], "first").unwrap();
8507
    assert_eq!(set("attested(tag)"), sorted(&[a, b]));
8508
    assert_eq!(set("attested(tag) & unsigned()"), sorted(&[a, b]));
8509
    assert_eq!(set("attested(receipt)"), vec![]);
8510
    assert_eq!(set("built(test)"), vec![]);
8511
    let e = repo.revset("attested(seal)").unwrap_err().to_string();
8512
    assert!(e.contains("tag, receipt or ref"), "{e}");
8513
    repo.revoke_identity(&key, "test").unwrap();
8514
    assert_eq!(set("revoked()"), vec![d]);
8515
    assert_eq!(set("signed_by(me) ~ revoked()"), vec![]);
8516
}
8517
8518
#[test]
8519
fn a_rotation_is_recorded_wherever_the_old_key_was_named() {
8520
    let dir = tempfile::tempdir().unwrap();
8521
    let root = dir.path();
8522
    let home = root.join("home");
8523
    // Ada's identity lives in the user file; repository `a` pins the same
8524
    // key in its own file, repository `b` names none.
8525
    let mut a = Repo::init(&root.join("a")).unwrap().with_home(&home);
8526
    let (key, _) = a
8527
        .create_identity("Ada", "[email protected]", IdentityScope::User)
8528
        .unwrap();
8529
    a.config.set("author.key", &key.to_hex()).unwrap();
8530
    a.save_config().unwrap();
8531
    let agent = SecretKey::generate().identity("Agent", "[email protected]", 1);
8532
    let r = a
8533
        .delegate_add(agent.clone(), crate::identity::Scope::CHANGES, None)
8534
        .unwrap();
8535
    assert_eq!(
8536
        read_config_file(&home.join("config.toml")).unwrap()["author.key"],
8537
        r.new.to_hex(),
8538
        "the user file follows"
8539
    );
8540
    assert_eq!(
8541
        read_config_file(&root.join("a/.oxy/config")).unwrap()["author.key"],
8542
        r.new.to_hex(),
8543
        "the pinning repository follows"
8544
    );
8545
    let b = Repo::init(&root.join("b")).unwrap().with_home(&home);
8546
    assert_eq!(
8547
        b.config.author_key,
8548
        Some(r.new),
8549
        "a checkout without a pin follows the user file"
8550
    );
8551
    // A repository-scoped identity leaves the user file alone.
8552
    let other_home = root.join("home2");
8553
    let mut c = Repo::init(&root.join("c")).unwrap().with_home(&other_home);
8554
    let (ck, _) = c
8555
        .create_identity("Cy", "[email protected]", IdentityScope::Repo)
8556
        .unwrap();
8557
    assert!(!other_home.join("config.toml").exists());
8558
    let r2 = c.rotate_identity().unwrap();
8559
    assert_ne!(r2.new, ck);
8560
    assert_eq!(
8561
        read_config_file(&root.join("c/.oxy/config")).unwrap()["author.key"],
8562
        r2.new.to_hex()
8563
    );
8564
    assert!(
8565
        !other_home.join("config.toml").exists(),
8566
        "a repository-scoped identity stays repository-scoped"
8567
    );
8568
    // Several delegates in one rotation; a repeat is refused.
8569
    let (d1, d2) = (
8570
        SecretKey::generate().identity("D1", "d1@x", 1),
8571
        SecretKey::generate().identity("D2", "d2@x", 1),
8572
    );
8573
    let r3 = c
8574
        .delegate_add_many(vec![d1.clone(), d2], crate::identity::Scope::CHANGES, None)
8575
        .unwrap();
8576
    assert_eq!(r3.identity.delegates.len(), 2);
8577
    assert!(c
8578
        .delegate_add(d1, crate::identity::Scope::CHANGES, None)
8579
        .is_err());
8580
}
8581
8582
/// Two unrelated histories that both add the same file, joined by two
8583
/// independent merges on two branches, then those branches merged: Git
8584
/// merges identical additions cleanly, and the import must too (#71,
8585
/// found in VS Code's history).
8586
#[test]
8587
fn git_import_joins_unrelated_histories_that_add_the_same_file() {
8588
    // Batches of one: every merge renders apart from the state, where a
8589
    // file whose graph the state had built came out empty (#71).
8590
    crate::repo::git::set_merge_batch_for_tests(Some(1));
8591
    hermetic();
8592
    if std::process::Command::new("git")
8593
        .arg("--version")
8594
        .output()
8595
        .is_err()
8596
    {
8597
        eprintln!("git not installed; skipping");
8598
        return;
8599
    }
8600
    let dir = tempfile::tempdir().unwrap();
8601
    let root = dir.path();
8602
    let g = root.join("src.git");
8603
    fs::create_dir_all(&g).unwrap();
8604
    let git = |args: &[&str]| -> String {
8605
        let out = std::process::Command::new("git")
8606
            .arg("-C")
8607
            .arg(&g)
8608
            .args([
8609
                "-c",
8610
                "user.name=Grace",
8611
                "-c",
8612
                "[email protected]",
8613
                "-c",
8614
                "commit.gpgsign=false",
8615
            ])
8616
            .args(args)
8617
            .output()
8618
            .unwrap();
8619
        assert!(
8620
            out.status.success(),
8621
            "git {args:?}: {}",
8622
            String::from_utf8_lossy(&out.stderr)
8623
        );
8624
        String::from_utf8_lossy(&out.stdout).trim().to_string()
8625
    };
8626
    let text = b"/* header */\nconst a = 1;\nconst b = 2;\n";
8627
    // History A: main.
8628
    git(&["init", "-q", "-b", "main"]);
8629
    write(&g, "gulpfile.js", text);
8630
    write(&g, "a.txt", b"a\n");
8631
    git(&["add", "."]);
8632
    git(&["commit", "-q", "-m", "A root"]);
8633
    // History B: an unrelated root adding the same file, identical.
8634
    git(&["checkout", "-q", "--orphan", "fork"]);
8635
    git(&["rm", "-q", "-r", "--cached", "."]);
8636
    for f in ["gulpfile.js", "a.txt"] {
8637
        let _ = fs::remove_file(g.join(f));
8638
    }
8639
    write(&g, "gulpfile.js", text);
8640
    write(&g, "b.txt", b"b\n");
8641
    git(&["add", "."]);
8642
    git(&["commit", "-q", "-m", "B root"]);
8643
    // Two branches from main, each joining B on its own.
8644
    git(&["checkout", "-q", "-b", "one", "main"]);
8645
    git(&[
8646
        "merge",
8647
        "-q",
8648
        "--no-ff",
8649
        "--allow-unrelated-histories",
8650
        "-m",
8651
        "join on one",
8652
        "fork",
8653
    ]);
8654
    git(&["checkout", "-q", "-b", "two", "main"]);
8655
    write(&g, "a.txt", b"a\ntwo\n");
8656
    git(&["commit", "-q", "-am", "work on two"]);
8657
    git(&[
8658
        "merge",
8659
        "-q",
8660
        "--no-ff",
8661
        "--allow-unrelated-histories",
8662
        "-m",
8663
        "join on two",
8664
        "fork",
8665
    ]);
8666
    // Then the two joins meet, and the file is reformatted after.
8667
    git(&["checkout", "-q", "main"]);
8668
    git(&["merge", "-q", "--no-ff", "-m", "merge one", "one"]);
8669
    git(&["merge", "-q", "--no-ff", "-m", "merge two", "two"]);
8670
    write(
8671
        &g,
8672
        "gulpfile.js",
8673
        b"/* header */\nconst a = 1;\nconst b = 3;\n",
8674
    );
8675
    git(&["commit", "-q", "-am", "after"]);
8676
8677
    let dest = root.join("dest");
8678
    let repo = Repo::init(&dest).unwrap().with_home(root.join("home"));
8679
    repo.import_git(&g, "main").unwrap();
8680
    let v = repo.verify_git_import(&g, "main").unwrap();
8681
    assert!(v.mismatches.is_empty(), "{:?}", v.mismatches);
8682
    assert_eq!(
8683
        fs::read(dest.join("gulpfile.js")).unwrap(),
8684
        b"/* header */\nconst a = 1;\nconst b = 3;\n"
8685
    );
8686
}
8687
8688
/// As above, but the two joins keep different copies (each branch had a
8689
/// different version when it joined), and later work edits each copy:
8690
/// the shape of VS Code's c67ef57cda90 (#71).
8691
#[test]
8692
fn git_import_joins_where_each_join_kept_a_different_copy() {
8693
    // Batches of one: every merge renders apart from the state, where a
8694
    // file whose graph the state had built came out empty (#71).
8695
    crate::repo::git::set_merge_batch_for_tests(Some(1));
8696
    hermetic();
8697
    if std::process::Command::new("git")
8698
        .arg("--version")
8699
        .output()
8700
        .is_err()
8701
    {
8702
        eprintln!("git not installed; skipping");
8703
        return;
8704
    }
8705
    let dir = tempfile::tempdir().unwrap();
8706
    let root = dir.path();
8707
    let g = root.join("src.git");
8708
    fs::create_dir_all(&g).unwrap();
8709
    let run = |args: &[&str]| {
8710
        std::process::Command::new("git")
8711
            .arg("-C")
8712
            .arg(&g)
8713
            .args([
8714
                "-c",
8715
                "user.name=Grace",
8716
                "-c",
8717
                "[email protected]",
8718
                "-c",
8719
                "commit.gpgsign=false",
8720
            ])
8721
            .args(args)
8722
            .output()
8723
            .unwrap()
8724
    };
8725
    let git = |args: &[&str]| -> String {
8726
        let out = run(args);
8727
        assert!(
8728
            out.status.success(),
8729
            "git {args:?}: {}",
8730
            String::from_utf8_lossy(&out.stderr)
8731
        );
8732
        String::from_utf8_lossy(&out.stdout).trim().to_string()
8733
    };
8734
    git(&["init", "-q", "-b", "main"]);
8735
    write(
8736
        &g,
8737
        "gulpfile.js",
8738
        b"/* header */\nconst a = 1;\nconst b = 2;\n",
8739
    );
8740
    git(&["add", "."]);
8741
    git(&["commit", "-q", "-m", "A root"]);
8742
    write(
8743
        &g,
8744
        "gulpfile.js",
8745
        b"/* header */\nconst a = 1;\n\nconst b = 2;\n",
8746
    );
8747
    git(&["commit", "-q", "-am", "whitespace"]);
8748
    git(&["checkout", "-q", "--orphan", "fork"]);
8749
    git(&["rm", "-q", "-r", "--cached", "."]);
8750
    let _ = fs::remove_file(g.join("gulpfile.js"));
8751
    write(
8752
        &g,
8753
        "gulpfile.js",
8754
        b"/* header */\nconst a = 1;\nconst b = 2;\nconst c = 3;\n",
8755
    );
8756
    git(&["add", "."]);
8757
    git(&["commit", "-q", "-m", "B root"]);
8758
    // Branch one joins B keeping main's copy; branch two keeps B's.
8759
    for (branch, keep) in [("one", "ours"), ("two", "theirs")] {
8760
        git(&["checkout", "-q", "-b", branch, "main"]);
8761
        let _ = run(&[
8762
            "merge",
8763
            "-q",
8764
            "--no-ff",
8765
            "--allow-unrelated-histories",
8766
            "-m",
8767
            "join",
8768
            "fork",
8769
        ]);
8770
        git(&["checkout", &format!("--{keep}"), "gulpfile.js"]);
8771
        git(&["add", "gulpfile.js"]);
8772
        git(&["commit", "-q", "-m", &format!("join on {branch}")]);
8773
    }
8774
    // Each branch then edits its copy.
8775
    git(&["checkout", "-q", "one"]);
8776
    write(
8777
        &g,
8778
        "gulpfile.js",
8779
        b"/* header */\nconst a = 1;\n\nconst b = 2;\n// tslint\n",
8780
    );
8781
    git(&["commit", "-q", "-am", "tslint on one"]);
8782
    git(&["checkout", "-q", "two"]);
8783
    write(
8784
        &g,
8785
        "gulpfile.js",
8786
        b"/* header */\nconst a = 1;\nconst b = 2;\nconst c = 3;\n// fmt\n",
8787
    );
8788
    write(&g, "extra.txt", b"x\n");
8789
    write(&g, "more.txt", b"m\n");
8790
    git(&["add", "."]);
8791
    git(&["commit", "-q", "-am", "fmt on two"]);
8792
    // They meet: resolved by hand.
8793
    git(&["checkout", "-q", "one"]);
8794
    let _ = run(&["merge", "-q", "--no-ff", "-m", "merge two", "two"]);
8795
    write(
8796
        &g,
8797
        "gulpfile.js",
8798
        b"/* header */\nconst a = 1;\nconst b = 2;\nconst c = 3;\n// tslint\n// fmt\n",
8799
    );
8800
    git(&["add", "gulpfile.js"]);
8801
    git(&["commit", "-q", "-m", "merge two into one"]);
8802
8803
    let dest = root.join("dest");
8804
    let repo = Repo::init(&dest).unwrap().with_home(root.join("home"));
8805
    repo.import_git(&g, "one").unwrap();
8806
    let v = repo.verify_git_import(&g, "one").unwrap();
8807
    assert!(v.mismatches.is_empty(), "{:?}", v.mismatches);
8808
}
8809
8810
/// oxygen#68: on a store that is not this disk's directory, the operation
8811
/// head is the store's reference record, moved by compare-and-swap. Two
8812
/// handles recording from the same head both get through, merged; a swap
8813
/// from a stale version is refused; a second repository cannot start on a
8814
/// store that already holds one.
8815
#[test]
8816
fn over_a_store_the_operation_head_moves_by_compare_and_swap() {
8817
    use crate::strata::{MemoryStore, Store as _};
8818
    let backend = std::sync::Arc::new(MemoryStore::new());
8819
    crate::store::TEST_BACKEND.with(|t| *t.borrow_mut() = Some(backend.clone()));
8820
    let dir = tempfile::tempdir().unwrap();
8821
    let a = Repo::init(dir.path()).unwrap();
8822
    assert!(
8823
        !dir.path().join(".oxy/ops/head").exists(),
8824
        "the head is not a file"
8825
    );
8826
    let (h0, op0) = a.head_op().unwrap();
8827
    let record = backend
8828
        .read_ref(HEAD_RECORD)
8829
        .unwrap()
8830
        .expect("the head is the store's record");
8831
    assert_eq!(String::from_utf8(record.0).unwrap(), h0.to_hex());
8832
8833
    let b = Repo::open(dir.path()).unwrap();
8834
    let state = a.store.put_state(&StateObject::new(vec![])).unwrap();
8835
    let mut va = op0.view.clone();
8836
    va.bookmarks.insert("from-a".into(), state);
8837
    let mut vb = op0.view.clone();
8838
    vb.bookmarks.insert("from-b".into(), state);
8839
    let rb = b.record_op(&[h0], "bookmark from-b", vb).unwrap();
8840
    assert_eq!(b.head_ref().unwrap(), rb);
8841
    // a recorded from h0 too: the head moved, so the two are merged.
8842
    let ra = a.record_op(&[h0], "bookmark from-a", va).unwrap();
8843
    let (head, op) = Repo::open(dir.path()).unwrap().head_op().unwrap();
8844
    assert_eq!(head, ra);
8845
    assert!(
8846
        op.command.ends_with("(merged with a concurrent operation)"),
8847
        "{}",
8848
        op.command
8849
    );
8850
    assert_eq!(op.parents, vec![rb, op.parents[1]]);
8851
    assert!(op.view.bookmarks.contains_key("from-a") && op.view.bookmarks.contains_key("from-b"));
8852
8853
    // A swap from a version that is no longer current moves nothing.
8854
    let (h, v) = a.read_head().unwrap();
8855
    assert!(b.swap_head(&rb, v.as_ref()).unwrap());
8856
    assert!(!a.swap_head(&h, v.as_ref()).unwrap(), "stale");
8857
    a.refresh().unwrap();
8858
    assert_eq!(a.head_ref().unwrap(), rb);
8859
8860
    let other = tempfile::tempdir().unwrap();
8861
    let err = Repo::init(other.path())
8862
        .err()
8863
        .expect("the store already holds a log")
8864
        .to_string();
8865
    assert!(
8866
        err.contains("already holds a repository's operation log"),
8867
        "{err}"
8868
    );
8869
    crate::store::TEST_BACKEND.with(|t| *t.borrow_mut() = None);
8870
}
8871
8872
/// oxygen#68: a repository kept on a store (`storage.url`) holds its
8873
/// objects and operation head there, with `.oxy/cache` in front: it works
8874
/// as one on disk does, reopens, and survives losing its cache.
8875
#[test]
8876
fn a_repository_runs_on_a_store_with_a_cache_in_front() {
8877
    hermetic();
8878
    if std::env::var_os("OXY_STORE_BACKEND").is_some() {
8879
        return;
8880
    }
8881
    let dir = tempfile::tempdir().unwrap();
8882
    let (root, bucket) = (dir.path().join("work"), dir.path().join("bucket"));
8883
    let url = format!("dir://{}", bucket.display());
8884
    let repo = Repo::init_on(&root, [7; 16], Some(&url)).unwrap();
8885
    assert_eq!(repo.config.storage_url.as_deref(), Some(url.as_str()));
8886
    write(&root, "a.txt", b"one\n");
8887
    let h1 = repo.commit("First", &[]).unwrap();
8888
    write(&root, "a.txt", b"one\ntwo\n");
8889
    let h2 = repo.commit("Second", &[]).unwrap();
8890
    assert!(
8891
        !root.join(".oxy/ops/head").exists(),
8892
        "the head is in the store"
8893
    );
8894
    assert!(bucket.join("ops/head").is_file());
8895
    assert!(root.join(".oxy/cache").is_dir());
8896
    drop(repo);
8897
8898
    // Reopened, and again with no cache at all: everything comes from the store.
8899
    for drop_cache in [false, true] {
8900
        if drop_cache {
8901
            std::fs::remove_dir_all(root.join(".oxy/cache")).unwrap();
8902
        }
8903
        let repo = Repo::open(&root).unwrap();
8904
        let log: Vec<String> = repo
8905
            .op_log()
8906
            .unwrap()
8907
            .into_iter()
8908
            .map(|(_, op)| op.command)
8909
            .collect();
8910
        assert!(log.len() >= 3, "{log:?}");
8911
        assert_eq!(repo.store().get_change(&h1).unwrap().message, "First");
8912
        let heads = repo
8913
            .store()
8914
            .get_state(&repo.view().unwrap().bookmarks["main"])
8915
            .unwrap()
8916
            .heads;
8917
        assert_eq!(heads, vec![h2]);
8918
        assert_eq!(
8919
            repo.load_state(&heads).unwrap().materialize().files.len(),
8920
            1
8921
        );
8922
    }
8923
8924
    // A second repository cannot start on the same store.
8925
    let err = Repo::init_on(&dir.path().join("other"), [8; 16], Some(&url))
8926
        .err()
8927
        .unwrap()
8928
        .to_string();
8929
    assert!(
8930
        err.contains("already holds a repository's operation log"),
8931
        "{err}"
8932
    );
8933
    let err = Repo::init_on(&dir.path().join("third"), [9; 16], Some("nope://x"))
8934
        .err()
8935
        .unwrap()
8936
        .to_string();
8937
    assert!(err.contains("cannot open that kind of store"), "{err}");
8938
}
8939
8940
/// oxygen#75: the prompt's quick check agrees with status in a repository
8941
/// with a subrepo: clean after a commit, dirty when the subrepo moves to
8942
/// another state, clean again once that pin is committed. (It compared the
8943
/// subrepo's directory as if it were a file, so it was always dirty.)
8944
#[test]
8945
fn the_quick_dirty_check_compares_a_subrepo_by_its_pin() {
8946
    hermetic();
8947
    let dir = tempfile::tempdir().unwrap();
8948
    let root = dir.path();
8949
    let _lib_origin = Repo::init(&root.join("lib-origin")).unwrap();
8950
    let lib_url = root.join("lib-origin").to_string_lossy().to_string();
8951
    let lib = Repo::clone(&lib_url, &root.join("lib"))
8952
        .unwrap()
8953
        .with_home(root.join("home"));
8954
    write(&root.join("lib"), "lib.txt", b"v1\n");
8955
    lib.commit("lib v1", &[]).unwrap();
8956
    assert!(lib.push("origin", false).unwrap()[0].ok);
8957
    let app_dir = root.join("app");
8958
    let app = Repo::init(&app_dir).unwrap().with_home(root.join("home"));
8959
    write(&app_dir, "main.txt", b"app\n");
8960
    app.submodule_add(&lib_url, "libs/lib", None).unwrap();
8961
    app.commit("app with lib", &[]).unwrap();
8962
    // Out of the racy window, so the stat-only path decides.
8963
    std::thread::sleep(std::time::Duration::from_millis(2100));
8964
    let reopen = || Repo::open(&app_dir).unwrap();
8965
    assert_eq!(
8966
        reopen().quick_dirty().unwrap(),
8967
        Some(false),
8968
        "clean, as status says"
8969
    );
8970
    let nested = Repo::open(&app_dir.join("libs/lib")).unwrap();
8971
    write(&app_dir.join("libs/lib"), "lib.txt", b"v2\n");
8972
    nested.commit("lib v2", &[]).unwrap();
8973
    assert_eq!(
8974
        reopen().quick_dirty().unwrap(),
8975
        Some(true),
8976
        "the subrepo moved"
8977
    );
8978
    reopen().commit("pin lib v2", &[]).unwrap();
8979
    std::thread::sleep(std::time::Duration::from_millis(2100));
8980
    assert_eq!(
8981
        reopen().quick_dirty().unwrap(),
8982
        Some(false),
8983
        "the new pin is committed"
8984
    );
8985
}
8986
8987
#[test]
8988
fn a_module_entry_that_was_never_added_is_reported_as_unpinned() {
8989
    // oxygen#72: a .oxymodules section with no subrepo behind it.
8990
    hermetic();
8991
    let dir = tempfile::tempdir().unwrap();
8992
    let root = dir.path();
8993
    let _lib_origin = Repo::init(&root.join("lib-origin")).unwrap();
8994
    let lib_url = root.join("lib-origin").to_string_lossy().to_string();
8995
    let lib = Repo::clone(&lib_url, &root.join("lib"))
8996
        .unwrap()
8997
        .with_home(root.join("home"));
8998
    write(&root.join("lib"), "lib.txt", b"v1\n");
8999
    lib.commit("lib v1", &[]).unwrap();
9000
    assert!(lib.push("origin", false).unwrap()[0].ok);
9001
    let app_dir = root.join("app");
9002
    let app = Repo::init(&app_dir).unwrap().with_home(root.join("home"));
9003
    write(
9004
        &app_dir,
9005
        ".oxymodules",
9006
        format!("[libs/lib]\nurl = {lib_url}\n").as_bytes(),
9007
    );
9008
    let unpinned = app.unpinned_modules().unwrap();
9009
    assert_eq!(unpinned.len(), 1);
9010
    assert_eq!(unpinned[0].0, "libs/lib");
9011
    assert_eq!(unpinned[0].1.url, lib_url);
9012
    assert!(app.submodule_update(true, &[]).unwrap().is_empty());
9013
    app.submodule_add(&lib_url, "libs/lib", None).unwrap();
9014
    assert!(app.unpinned_modules().unwrap().is_empty(), "added now");
9015
}
9016
9017
#[test]
9018
fn the_bookmark_revset_function_names_only_bookmarks() {
9019
    // oxygen#73: bookmark(NAME) is a bookmark's heads, never a tag or prefix.
9020
    hermetic();
9021
    let dir = tempfile::tempdir().unwrap();
9022
    let repo = Repo::init(dir.path()).unwrap();
9023
    write(dir.path(), "a.txt", b"a\n");
9024
    let a = repo.commit("A", &[]).unwrap();
9025
    write(dir.path(), "b.txt", b"b\n");
9026
    let b = repo.commit("B", &[]).unwrap();
9027
    let mut heads = repo.revset("bookmark(main)").unwrap().hashes;
9028
    heads.sort();
9029
    let mut want = repo.bookmark_heads().unwrap();
9030
    want.sort();
9031
    assert_eq!(heads, want);
9032
    assert!(heads.contains(&b));
9033
    assert_eq!(repo.revset("main").unwrap().hashes.len(), heads.len());
9034
    // A change prefix is a symbol, not a bookmark.
9035
    let prefix = repo.store().get_change_meta(&a).unwrap().change_id.short(8);
9036
    assert!(repo.revset(&prefix).is_ok());
9037
    let e = repo
9038
        .revset(&format!("bookmark({prefix})"))
9039
        .unwrap_err()
9040
        .to_string();
9041
    assert!(e.contains("no such bookmark"), "{e}");
9042
}
9043
9044
/// oxygen#140: a replace conflict is settled from the working copy, as
9045
/// `oxy resolve PATH` does: the path's content on disk becomes the
9046
/// resolution, and the commit that records it is a Resolution.
9047
#[test]
9048
fn a_replace_conflict_is_settled_from_the_working_copy() {
9049
    hermetic();
9050
    let dir = tempfile::tempdir().unwrap();
9051
    let root = dir.path();
9052
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
9053
    let blob = |n: u8| {
9054
        let mut v = vec![0u8; 64];
9055
        v[1] = n;
9056
        v
9057
    };
9058
    write(root, "img.bin", &blob(1));
9059
    write(root, "a.txt", b"a\n");
9060
    repo.commit("add img", &[]).unwrap();
9061
    repo.bookmark_create("other", None).unwrap();
9062
    write(root, "img.bin", &blob(2));
9063
    repo.commit("img two", &[]).unwrap();
9064
    repo.bookmark_switch("other", false).unwrap();
9065
    write(root, "img.bin", &blob(3));
9066
    repo.commit("img three", &[]).unwrap();
9067
    repo.bookmark_switch("main", false).unwrap();
9068
    repo.merge("other").unwrap();
9069
    // Without a choice, nothing the working copy holds settles it, and
9070
    // other work cannot be committed past it.
9071
    write(root, "img.bin", &blob(9));
9072
    write(root, "a.txt", b"a\nmore\n");
9073
    assert!(
9074
        repo.commit("edit a", &[]).is_err(),
9075
        "refused while conflicted"
9076
    );
9077
    let snap = repo.snapshot("oxy resolve").unwrap();
9078
    assert!(
9079
        repo.choose_resolutions(&snap.base_snapshot, &["a.txt".to_string()])
9080
            .is_err(),
9081
        "a.txt has no replace conflict"
9082
    );
9083
    repo.choose_resolutions(&snap.base_snapshot, &["img.bin".to_string()])
9084
        .unwrap();
9085
    let snap = repo.snapshot("oxy resolve").unwrap();
9086
    assert!(snap
9087
        .statuses
9088
        .iter()
9089
        .any(|s| s.path == "img.bin" && s.status == ChangeStatus::Resolved));
9090
    let c = repo.commit("settle img", &[]).unwrap();
9091
    assert_eq!(
9092
        repo.store().get_change(&c).unwrap().kind,
9093
        ChangeKind::Resolution
9094
    );
9095
    let after = repo
9096
        .load_state(&repo.bookmark_heads().unwrap())
9097
        .unwrap()
9098
        .materialize();
9099
    assert!(!after.is_conflicted());
9100
    assert_eq!(
9101
        after.files["img.bin"].content,
9102
        FileContent::Opaque {
9103
            blobs: vec![Repo::blob_ref(&blob(9))]
9104
        }
9105
    );
9106
    assert!(
9107
        matches!(&after.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nmore\n")
9108
    );
9109
    // Settled: the choice is forgotten, and the working copy is clean.
9110
    let snap = repo.snapshot("status").unwrap();
9111
    assert!(snap.statuses.is_empty(), "{:?}", snap.statuses);
9112
    assert!(repo.resolutions().unwrap().is_empty());
9113
}
9114
9115
/// oxygen#140: the same for a subrepo pin moved two ways at once.
9116
#[test]
9117
fn a_pin_conflict_is_settled_from_the_working_copy() {
9118
    hermetic();
9119
    let dir = tempfile::tempdir().unwrap();
9120
    let root = dir.path();
9121
    let _lib_origin = Repo::init(&root.join("lib-origin")).unwrap();
9122
    let lib_url = root.join("lib-origin").to_string_lossy().to_string();
9123
    let lib = Repo::clone(&lib_url, &root.join("lib"))
9124
        .unwrap()
9125
        .with_home(root.join("home"));
9126
    write(&root.join("lib"), "lib.txt", b"v1\n");
9127
    lib.commit("lib v1", &[]).unwrap();
9128
    assert!(lib.push("origin", false).unwrap()[0].ok);
9129
    let app_dir = root.join("app");
9130
    let mut app = Repo::init(&app_dir).unwrap().with_home(root.join("home"));
9131
    write(&app_dir, "main.txt", b"app\n");
9132
    app.submodule_add(&lib_url, "libs/lib", None).unwrap();
9133
    app.commit("pin v1", &[]).unwrap();
9134
    let nested = Repo::open(&app_dir.join("libs/lib"))
9135
        .unwrap()
9136
        .with_home(root.join("home"));
9137
    fn pin(app: &Repo, nested: &Repo, text: &[u8], message: &str) {
9138
        write(nested.root(), "lib.txt", text);
9139
        nested.commit(message, &[]).unwrap();
9140
        app.commit(&format!("pin {message}"), &[]).unwrap();
9141
    }
9142
    app.bookmark_create("other", None).unwrap();
9143
    pin(&app, &nested, b"v2\n", "v2");
9144
    app.bookmark_switch("other", false).unwrap();
9145
    pin(&app, &nested, b"v3\n", "v3");
9146
    app.bookmark_switch("main", false).unwrap();
9147
    app.merge("other").unwrap();
9148
    let merged = app
9149
        .load_state(&app.bookmark_heads().unwrap())
9150
        .unwrap()
9151
        .materialize();
9152
    assert!(
9153
        matches!(&merged.files["libs/lib"].content, FileContent::Opaque { blobs } if blobs.len() == 2),
9154
        "a pin conflict"
9155
    );
9156
    // The nested repository moves on once more; that pin is the choice.
9157
    write(&app_dir.join("libs/lib"), "lib.txt", b"v4\n");
9158
    nested.commit("v4", &[]).unwrap();
9159
    let snap = app.snapshot("oxy resolve").unwrap();
9160
    app.choose_resolutions(&snap.base_snapshot, &["libs/lib".to_string()])
9161
        .unwrap();
9162
    let c = app.commit("settle the pin", &[]).unwrap();
9163
    assert_eq!(
9164
        app.store().get_change(&c).unwrap().kind,
9165
        ChangeKind::Resolution
9166
    );
9167
    let after = app
9168
        .load_state(&app.bookmark_heads().unwrap())
9169
        .unwrap()
9170
        .materialize();
9171
    assert!(!after.is_conflicted());
9172
    assert!(app.snapshot("status").unwrap().statuses.is_empty());
9173
}
9174
9175
#[test]
9176
fn carrying_work_over_a_subrepo_already_at_the_target_pin_is_no_conflict() {
9177
    // oxygen#74: a stale state with .oxymodules but no pin, a newer one
9178
    // pinning the subrepo, and the nested repository on disk at that pin.
9179
    hermetic();
9180
    let dir = tempfile::tempdir().unwrap();
9181
    let root = dir.path();
9182
    let _lib_origin = Repo::init(&root.join("lib-origin")).unwrap();
9183
    let lib_url = root.join("lib-origin").to_string_lossy().to_string();
9184
    let lib = Repo::clone(&lib_url, &root.join("lib"))
9185
        .unwrap()
9186
        .with_home(root.join("home"));
9187
    write(&root.join("lib"), "lib.txt", b"v1\n");
9188
    lib.commit("lib v1", &[]).unwrap();
9189
    assert!(lib.push("origin", false).unwrap()[0].ok);
9190
    let app_dir = root.join("app");
9191
    let app = Repo::init(&app_dir).unwrap().with_home(root.join("home"));
9192
    write(&app_dir, "main.txt", b"app\n");
9193
    app.submodule_add(&lib_url, "libs/lib", None).unwrap();
9194
    // The stale state: registered in .oxymodules, never pinned.
9195
    fs::rename(app_dir.join("libs/lib"), root.join("aside")).unwrap();
9196
    app.commit("initial", &[]).unwrap();
9197
    let stale = app.bookmark_heads().unwrap();
9198
    fs::rename(root.join("aside"), app_dir.join("libs/lib")).unwrap();
9199
    app.commit("pin lib", &[]).unwrap();
9200
    let newer = app.bookmark_heads().unwrap();
9201
    app.checkout(&stale, false, "test").unwrap();
9202
    write(&app_dir, "notes.txt", b"mine\n");
9203
    let (_, carried) = app.checkout_carry(&newer, "main", "test").unwrap();
9204
    assert_eq!(carried, vec!["notes.txt".to_string()]);
9205
    assert!(app_dir.join("notes.txt").exists());
9206
}
9207
9208
/// oxygen#142: a new bookmark made from main sends what main lacks, not
9209
/// its whole history again.
9210
#[test]
9211
fn a_new_bookmark_sends_only_what_the_remote_lacks() {
9212
    hermetic();
9213
    let dir = tempfile::tempdir().unwrap();
9214
    let root = dir.path();
9215
    let _origin = Repo::init(&root.join("origin")).unwrap();
9216
    let url = root.join("origin").to_string_lossy().to_string();
9217
    let mut one = Repo::clone(&url, &root.join("one"))
9218
        .unwrap()
9219
        .with_home(root.join("home"));
9220
    for n in 0..5 {
9221
        write(&root.join("one"), "a.txt", format!("{n}\n").as_bytes());
9222
        one.commit(&format!("main {n}"), &[]).unwrap();
9223
    }
9224
    assert!(one.push("origin", false).unwrap()[0].ok);
9225
    one.bookmark_create("feature", None).unwrap();
9226
    one.bookmark_switch("feature", false).unwrap();
9227
    write(&root.join("one"), "b.txt", b"feature\n");
9228
    let added = one.commit("feature", &[]).unwrap();
9229
    let heads = one.bookmark_heads().unwrap();
9230
    let refs = one
9231
        .open_named_remote("origin")
9232
        .unwrap()
9233
        .refs()
9234
        .unwrap()
9235
        .refs;
9236
    assert!(
9237
        refs.iter().all(|r| r.name != "feature"),
9238
        "new to the remote"
9239
    );
9240
    let sent = one
9241
        .negotiate(&one.remote_holds(&refs, &[]), &heads)
9242
        .unwrap();
9243
    assert_eq!(sent, vec![added], "only the change main lacks");
9244
    assert!(one.push_bookmark("origin", "feature", false).unwrap()[0].ok);
9245
    let two = Repo::clone(&url, &root.join("two")).unwrap();
9246
    assert!(two.store().has(&added));
9247
}
9248
9249
#[test]
9250
fn a_bookmark_only_behind_its_remote_catches_up() {
9251
    // oxygen#74: switch takes the remote's state rather than a stale one.
9252
    hermetic();
9253
    let dir = tempfile::tempdir().unwrap();
9254
    let root = dir.path();
9255
    let _origin = Repo::init(&root.join("origin")).unwrap();
9256
    let url = root.join("origin").to_string_lossy().to_string();
9257
    let one = Repo::clone(&url, &root.join("one"))
9258
        .unwrap()
9259
        .with_home(root.join("home"));
9260
    write(&root.join("one"), "a.txt", b"a\n");
9261
    one.commit("A", &[]).unwrap();
9262
    assert!(one.push("origin", false).unwrap()[0].ok);
9263
    let two = Repo::clone(&url, &root.join("two"))
9264
        .unwrap()
9265
        .with_home(root.join("home"));
9266
    write(&root.join("two"), "b.txt", b"b\n");
9267
    two.commit("B", &[]).unwrap();
9268
    assert!(two.push("origin", false).unwrap()[0].ok);
9269
    assert_eq!(one.catch_up_bookmark("main").unwrap(), None, "not fetched");
9270
    one.fetch("origin").unwrap();
9271
    assert_eq!(one.catch_up_bookmark("main").unwrap(), Some(1));
9272
    assert_eq!(one.bookmarks().unwrap()[0].behind, Some(0));
9273
    // Local work the remote lacks: left alone.
9274
    write(&root.join("one"), "c.txt", b"c\n");
9275
    one.commit("C", &[]).unwrap();
9276
    write(&root.join("two"), "d.txt", b"d\n");
9277
    two.commit("D", &[]).unwrap();
9278
    assert!(two.push("origin", false).unwrap()[0].ok);
9279
    one.fetch("origin").unwrap();
9280
    assert_eq!(one.catch_up_bookmark("main").unwrap(), None);
9281
}
9282
9283
#[test]
9284
fn a_bookmark_is_compared_with_a_remote_that_has_it_when_there_is_no_origin() {
9285
    // oxygen#83: remotes earth and gcs, origin removed.
9286
    hermetic();
9287
    let dir = tempfile::tempdir().unwrap();
9288
    let root = dir.path();
9289
    let _up = Repo::init(&root.join("up")).unwrap();
9290
    let url = root.join("up").to_string_lossy().to_string();
9291
    let other = Repo::clone(&url, &root.join("other"))
9292
        .unwrap()
9293
        .with_home(root.join("home"));
9294
    write(&root.join("other"), "a.txt", b"a\n");
9295
    other.commit("a", &[]).unwrap();
9296
    assert!(other.push("origin", false).unwrap()[0].ok);
9297
    let mut one = Repo::clone(&url, &root.join("one"))
9298
        .unwrap()
9299
        .with_home(root.join("home"));
9300
    one.config.set("remote.earth.url", &url).unwrap();
9301
    one.config.set("remote.gcs.url", &url).unwrap();
9302
    one.config.set("remote.origin.url", "").unwrap();
9303
    one.save_config().unwrap();
9304
    write(&root.join("other"), "b.txt", b"b\n");
9305
    other.commit("b", &[]).unwrap();
9306
    assert!(other.push("origin", false).unwrap()[0].ok);
9307
    one.fetch("earth").unwrap();
9308
    let b = one
9309
        .bookmarks()
9310
        .unwrap()
9311
        .into_iter()
9312
        .find(|b| b.name == "main")
9313
        .unwrap();
9314
    assert_eq!(
9315
        (b.remote.as_deref(), b.behind),
9316
        (Some("earth"), Some(1)),
9317
        "a stale origin copy is not used"
9318
    );
9319
    assert_eq!(
9320
        one.default_remote(),
9321
        "origin",
9322
        "no default chosen among several"
9323
    );
9324
    let e = one.pull(&one.default_remote()).unwrap_err().to_string();
9325
    assert!(e.contains("earth, gcs") && e.contains("core.remote"), "{e}");
9326
    one.config.set("core.remote", "earth").unwrap();
9327
    assert_eq!(one.default_remote(), "earth");
9328
}
9329
9330
#[test]
9331
fn a_missing_author_identity_says_how_to_fix_it() {
9332
    // oxygen#82: an agent home's author.key naming an identity the
9333
    // repository does not hold.
9334
    hermetic();
9335
    let dir = tempfile::tempdir().unwrap();
9336
    let mut repo = Repo::init(dir.path()).unwrap();
9337
    let missing = KeyId::from_hex(&"ab".repeat(32)).unwrap();
9338
    repo.config.set("author.key", &missing.to_hex()).unwrap();
9339
    let e = repo.identity(&missing).unwrap_err().to_string();
9340
    assert!(
9341
        e.contains("this repository has no identity") && e.contains("oxy config author.key"),
9342
        "{e}"
9343
    );
9344
    assert!(e.contains("this repository's config"), "{e}");
9345
}
9346
9347
#[test]
9348
fn a_working_copy_behind_its_bookmark_is_flagged_and_pull_brings_it_up() {
9349
    // oxygen#89 (supermarioland, 2026-10-09): the bookmark moved on without
9350
    // the working copy, a pull with nothing new left it there, and status
9351
    // said "clean".
9352
    hermetic();
9353
    let dir = tempfile::tempdir().unwrap();
9354
    let root = dir.path();
9355
    let _up = Repo::init(&root.join("up")).unwrap();
9356
    let url = root.join("up").to_string_lossy().to_string();
9357
    let other = Repo::clone(&url, &root.join("other"))
9358
        .unwrap()
9359
        .with_home(root.join("home"));
9360
    write(&root.join("other"), "a.txt", b"a\n");
9361
    other.commit("a", &[]).unwrap();
9362
    assert!(other.push("origin", false).unwrap()[0].ok);
9363
    let one = Repo::clone(&url, &root.join("one"))
9364
        .unwrap()
9365
        .with_home(root.join("home"));
9366
    write(&root.join("other"), "b.txt", b"b\n");
9367
    other.commit("b", &[]).unwrap();
9368
    assert!(other.push("origin", false).unwrap()[0].ok);
9369
    one.fetch("origin").unwrap();
9370
    // The bookmark moves; the working copy does not.
9371
    let theirs = one.resolve_heads("origin/main").unwrap();
9372
    one.bookmark_set("main", &theirs).unwrap();
9373
    assert!(!root.join("one/b.txt").exists());
9374
    assert_eq!(one.working_copy_behind().unwrap(), Some(1));
9375
    let r = one.pull("origin").unwrap();
9376
    assert!(
9377
        !r.changed && r.checked_out,
9378
        "nothing new, but the working copy caught up"
9379
    );
9380
    assert!(root.join("one/b.txt").exists());
9381
    assert_eq!(one.working_copy_behind().unwrap(), None);
9382
}
9383
9384
#[test]
9385
fn touched_nodes_cache_answers_as_the_changes_do() {
9386
    hermetic();
9387
    let dir = tempfile::tempdir().unwrap();
9388
    let repo = Repo::init(dir.path()).unwrap();
9389
    write(dir.path(), "a.txt", b"one\n");
9390
    write(dir.path(), "b.txt", b"x\n");
9391
    repo.snapshot("t").unwrap();
9392
    let c1 = repo.commit("first", &[]).unwrap();
9393
    write(dir.path(), "b.txt", b"y\n");
9394
    repo.snapshot("t").unwrap();
9395
    repo.commit("b only", &[]).unwrap();
9396
    fs::rename(dir.path().join("a.txt"), dir.path().join("c.txt")).unwrap();
9397
    write(dir.path(), "c.txt", b"one\ntwo\n");
9398
    repo.snapshot("t").unwrap();
9399
    let c3 = repo.commit("rename and edit", &[]).unwrap();
9400
    // Uncommitted work, so the working copy touches a file too.
9401
    write(dir.path(), "c.txt", b"one\ntwo\nthree\n");
9402
    repo.snapshot("t").unwrap();
9403
9404
    let path = dir.path().join(".oxy/index/touched");
9405
    let hashes = |es: Vec<LogEntry>| es.iter().map(|e| e.hash).collect::<Vec<_>>();
9406
    let cold = hashes(repo.log_for_path("c.txt").unwrap());
9407
    assert!(path.exists(), "the first query writes the cache");
9408
    let warm = hashes(repo.log_for_path("c.txt").unwrap());
9409
    assert_eq!(cold, warm, "the cache answers as the changes do");
9410
    let (wc, _) = repo.working_copy().unwrap();
9411
    assert_eq!(
9412
        cold,
9413
        vec![wc, c3, c1],
9414
        "renames followed, the working copy included"
9415
    );
9416
9417
    // Working-copy changes are not kept: they are replaced by every snapshot.
9418
    let cached = super::touched::read_touched_for_test(&fs::read(&path).unwrap()).unwrap();
9419
    assert!(!cached.contains(&wc), "a working-copy change is not cached");
9420
    assert!(cached.contains(&c1) && cached.contains(&c3));
9421
9422
    // A damaged cache is ignored, not trusted.
9423
    let mut bytes = fs::read(&path).unwrap();
9424
    let last = bytes.len() - 40;
9425
    bytes[last] ^= 1;
9426
    fs::write(&path, &bytes).unwrap();
9427
    assert_eq!(hashes(repo.log_for_path("c.txt").unwrap()), cold);
9428
    let touches = repo.log_revset("touches(\"c.txt\") ~ @").unwrap();
9429
    assert_eq!(hashes(touches), vec![c3, c1]);
9430
}
9431
9432
/// oxygen#88 (vscode 9ab2056393c8): two lines of history that each move a
9433
/// file back where it was leave two placements at one place, which the
9434
/// tree shows as one file. Removing it, and then its directory, retires
9435
/// both, so no copy is left under a directory that is gone.
9436
#[test]
9437
fn git_import_removes_every_copy_of_a_file_at_one_place() {
9438
    hermetic();
9439
    if std::process::Command::new("git")
9440
        .arg("--version")
9441
        .output()
9442
        .is_err()
9443
    {
9444
        eprintln!("git not installed; skipping");
9445
        return;
9446
    }
9447
    let dir = tempfile::tempdir().unwrap();
9448
    let root = dir.path();
9449
    let g = root.join("src.git");
9450
    fs::create_dir_all(&g).unwrap();
9451
    let git = |args: &[&str]| {
9452
        let out = std::process::Command::new("git")
9453
            .arg("-C")
9454
            .arg(&g)
9455
            .args([
9456
                "-c",
9457
                "user.name=Grace",
9458
                "-c",
9459
                "[email protected]",
9460
                "-c",
9461
                "commit.gpgsign=false",
9462
            ])
9463
            .args(args)
9464
            .output()
9465
            .unwrap();
9466
        assert!(
9467
            out.status.success(),
9468
            "git {args:?}: {}",
9469
            String::from_utf8_lossy(&out.stderr)
9470
        );
9471
    };
9472
    git(&["init", "-q", "-b", "main"]);
9473
    write(&g, "d/list.txt", b"one\ntwo\nthree\nfour\n");
9474
    write(&g, "keep.txt", b"k\n");
9475
    git(&["add", "."]);
9476
    git(&["commit", "-qm", "base"]);
9477
    fs::create_dir_all(g.join("d/sub")).unwrap();
9478
    git(&["mv", "d/list.txt", "d/sub/list.txt"]);
9479
    git(&["commit", "-qm", "into sub"]);
9480
    git(&["checkout", "-qb", "side"]);
9481
    git(&["mv", "d/sub/list.txt", "d/list.txt"]);
9482
    git(&["commit", "-qm", "back, on side"]);
9483
    git(&["checkout", "-q", "main"]);
9484
    git(&["mv", "d/sub/list.txt", "d/list.txt"]);
9485
    git(&["commit", "-qm", "back, on main"]);
9486
    git(&["merge", "-q", "--no-edit", "side"]);
9487
    git(&["rm", "-rq", "d"]);
9488
    git(&["commit", "-qm", "d goes"]);
9489
9490
    let repo = Repo::init(&root.join("o")).unwrap();
9491
    repo.import_git(&g, "main").unwrap();
9492
    let v = repo.verify_git_import(&g, "main").unwrap();
9493
    assert!(v.mismatches.is_empty(), "{:?}", v.mismatches);
9494
    let st = repo
9495
        .load_state_history(&repo.bookmark_heads().unwrap())
9496
        .unwrap();
9497
    let view = st.tree().view();
9498
    assert!(view.conflicts.is_empty(), "{:?}", view.conflicts);
9499
    assert_eq!(view.entries.keys().collect::<Vec<_>>(), vec!["keep.txt"]);
9500
}
9501
9502
/// oxygen#94: a state index's file histories are read, and checked by
9503
/// their own hashes, only when a file is built. One found damaged is an
9504
/// error that says so, the index is removed, and the next read rebuilds it.
9505
#[test]
9506
fn a_damaged_file_history_in_the_state_index_is_rebuilt() {
9507
    hermetic();
9508
    let dir = tempfile::tempdir().unwrap();
9509
    let repo = Repo::init(dir.path()).unwrap();
9510
    for i in 0..6 {
9511
        write(
9512
            dir.path(),
9513
            &format!("f{i}.txt"),
9514
            format!("one {i}\n").as_bytes(),
9515
        );
9516
    }
9517
    repo.snapshot("t").unwrap();
9518
    repo.commit("first", &[]).unwrap();
9519
    for i in 0..6 {
9520
        write(
9521
            dir.path(),
9522
            &format!("f{i}.txt"),
9523
            format!("one {i}\ntwo\n").as_bytes(),
9524
        );
9525
    }
9526
    repo.snapshot("t").unwrap();
9527
    repo.commit("second", &[]).unwrap();
9528
    let want: Vec<Vec<u8>> = (0..6)
9529
        .map(|i| format!("one {i}\ntwo\n").into_bytes())
9530
        .collect();
9531
    // The index of the working copy's base, holding every file's history
9532
    // (a few changes behind, an index is replayed onto, not rewritten).
9533
    let (_, wc) = repo.working_copy().unwrap();
9534
    let base = repo.load_state_history(&wc.deps).unwrap();
9535
    repo.write_state_index(&wc.deps, &base);
9536
    for (i, w) in want.iter().enumerate() {
9537
        assert_eq!(&repo.file_at(&format!("f{i}.txt"), None).unwrap(), w);
9538
    }
9539
    let index = repo.state_indexes();
9540
    assert!(!index.is_empty(), "a state index was written");
9541
    // The histories are last in the file: damage the last of them.
9542
    let mut bytes = fs::read(&index[0]).unwrap();
9543
    let last = bytes.len() - 1;
9544
    bytes[last] ^= 0x40;
9545
    fs::write(&index[0], &bytes).unwrap();
9546
9547
    let reads: Vec<_> = (0..6)
9548
        .map(|i| repo.file_at(&format!("f{i}.txt"), None))
9549
        .collect();
9550
    let damaged: Vec<String> = reads
9551
        .iter()
9552
        .filter_map(|r| r.as_ref().err().map(|e| e.to_string()))
9553
        .collect();
9554
    assert_eq!(damaged.len(), 1, "{damaged:?}");
9555
    assert!(
9556
        damaged[0].contains("state index was damaged")
9557
            && damaged[0].contains("run the command again"),
9558
        "{}",
9559
        damaged[0]
9560
    );
9561
    // Every file reads right again, from an index rebuilt from history.
9562
    for (i, w) in want.iter().enumerate() {
9563
        assert_eq!(&repo.file_at(&format!("f{i}.txt"), None).unwrap(), w);
9564
    }
9565
}
9566
9567
/// oxygen#95: a push longer than `push.batch-bytes` goes in parts, each
9568
/// objects alone and in dependency order, and the last moves the bookmark.
9569
/// The remote ends with every change and the same state, and says how each
9570
/// part went.
9571
#[test]
9572
fn a_long_push_goes_in_parts() {
9573
    hermetic();
9574
    let dir = tempfile::tempdir().unwrap();
9575
    let root = dir.path();
9576
    let origin = root.join("origin");
9577
    Repo::init_bare_unbound(&origin).unwrap();
9578
    let main = root.join("main");
9579
    let mut repo = Repo::init(&main).unwrap();
9580
    repo.config
9581
        .set("remote.origin.url", &origin.to_string_lossy())
9582
        .unwrap();
9583
    // One byte: every change is a part of its own.
9584
    repo.config.set("push.batch-bytes", "1").unwrap();
9585
    repo.save_config().unwrap();
9586
    for i in 0..6 {
9587
        write(
9588
            &main,
9589
            &format!("f{i}.txt"),
9590
            format!("file {i}\n").as_bytes(),
9591
        );
9592
        repo.commit(&format!("c{i}"), &[]).unwrap();
9593
    }
9594
    let mut parts = Vec::new();
9595
    let results = repo
9596
        .push_bookmark_with("origin", "main", false, &mut |m| parts.push(m.to_string()))
9597
        .unwrap();
9598
    assert!(
9599
        results.iter().any(|r| r.ok && r.name == "main"),
9600
        "{results:?}"
9601
    );
9602
    assert_eq!(parts.len(), 6, "{parts:?}");
9603
    assert_eq!(parts[0], "part 1 of 6: 1 changes");
9604
9605
    let remote = Repo::open(&origin).unwrap();
9606
    let heads = repo.bookmark_heads().unwrap();
9607
    assert_eq!(remote.bookmark_heads().unwrap(), heads);
9608
    assert_eq!(
9609
        remote.log_state(&heads).unwrap().len(),
9610
        repo.log_state(&heads).unwrap().len()
9611
    );
9612
    // A push of objects alone reports how it went, as `objects`.
9613
    let got = remote
9614
        .receive_push(Bundle::default(), vec![], None, vec![])
9615
        .unwrap();
9616
    assert_eq!(got.len(), 1);
9617
    assert!(got[0].ok && got[0].name == OBJECTS_RESULT, "{got:?}");
9618
}
9619
9620
/// oxygen#133: one change whose blobs pass `push.batch-bytes` sends them
9621
/// first, in parts of objects alone, so no request is larger than about
9622
/// that (a proxy in front of a hub refuses one past 100 MB); the change
9623
/// and the bookmark follow, and a clone has every file.
9624
#[test]
9625
fn a_heavy_change_sends_its_blobs_in_parts() {
9626
    hermetic();
9627
    let dir = tempfile::tempdir().unwrap();
9628
    let root = dir.path();
9629
    let origin = root.join("origin");
9630
    Repo::init_bare_unbound(&origin).unwrap();
9631
    let main = root.join("main");
9632
    let mut repo = Repo::init(&main).unwrap();
9633
    repo.config
9634
        .set("remote.origin.url", &origin.to_string_lossy())
9635
        .unwrap();
9636
    repo.config.set("push.batch-bytes", "50000").unwrap();
9637
    repo.save_config().unwrap();
9638
    let mut x: u64 = 0x9e3779b97f4a7c15;
9639
    let mut noise = |n: usize| -> Vec<u8> {
9640
        (0..n)
9641
            .map(|_| {
9642
                x ^= x << 13;
9643
                x ^= x >> 7;
9644
                x ^= x << 17;
9645
                (x >> 24) as u8
9646
            })
9647
            .collect()
9648
    };
9649
    let files: Vec<Vec<u8>> = (0..4).map(|_| noise(40_000)).collect();
9650
    for (i, f) in files.iter().enumerate() {
9651
        write(&main, &format!("bin{i}.dat"), f);
9652
    }
9653
    repo.commit("four blobs", &[]).unwrap();
9654
    let mut parts = Vec::new();
9655
    let results = repo
9656
        .push_bookmark_with("origin", "main", false, &mut |m| parts.push(m.to_string()))
9657
        .unwrap();
9658
    assert!(
9659
        results.iter().any(|r| r.ok && r.name == "main"),
9660
        "{results:?}"
9661
    );
9662
    assert!(
9663
        parts.len() >= 3 && parts[0].starts_with("objects 1 of"),
9664
        "{parts:?}"
9665
    );
9666
    let back = Repo::clone(&origin.to_string_lossy(), &root.join("back")).unwrap();
9667
    for (i, f) in files.iter().enumerate() {
9668
        assert_eq!(&fs::read(root.join(format!("back/bin{i}.dat"))).unwrap(), f);
9669
    }
9670
    drop(back);
9671
}
9672
9673
/// oxygen#136: a bundle carries a replaced binary file's new content
9674
/// only. Its old content came with the change that added it, so removing
9675
/// or replacing a file sends no old blob again; the remote and a clone of
9676
/// it are whole.
9677
#[test]
9678
fn a_replacement_does_not_send_the_old_content_again() {
9679
    hermetic();
9680
    let dir = tempfile::tempdir().unwrap();
9681
    let root = dir.path();
9682
    let origin = root.join("origin");
9683
    Repo::init_bare_unbound(&origin).unwrap();
9684
    let main = root.join("main");
9685
    let mut repo = Repo::init(&main).unwrap();
9686
    repo.config
9687
        .set("remote.origin.url", &origin.to_string_lossy())
9688
        .unwrap();
9689
    repo.save_config().unwrap();
9690
    let blobs = |b: &Bundle| {
9691
        b.objects
9692
            .iter()
9693
            .filter(|(ty, _)| *ty == ObjectType::Blob)
9694
            .count()
9695
    };
9696
    write(&main, "bin.dat", b"\0\x01first binary");
9697
    write(&main, "gone.dat", b"\0\x02to be removed");
9698
    let added = repo.commit("add", &[]).unwrap();
9699
    assert!(repo.push("origin", false).unwrap()[0].ok);
9700
    write(&main, "bin.dat", b"\0\x01second binary");
9701
    fs::remove_file(main.join("gone.dat")).unwrap();
9702
    let changed = repo.commit("replace and remove", &[]).unwrap();
9703
    assert_eq!(blobs(&repo.bundle_for(&[changed]).unwrap()), 1);
9704
    assert_eq!(blobs(&repo.bundle_for(&[added, changed]).unwrap()), 3);
9705
    assert!(repo.push("origin", false).unwrap()[0].ok);
9706
    let origin = Repo::open(&origin).unwrap();
9707
    assert_eq!(origin.fsck().unwrap().problems, vec![]);
9708
    let back = Repo::clone(&origin.root().to_string_lossy(), &root.join("back")).unwrap();
9709
    assert_eq!(back.fsck().unwrap().problems, vec![]);
9710
    assert_eq!(
9711
        fs::read(root.join("back/bin.dat")).unwrap(),
9712
        b"\0\x01second binary"
9713
    );
9714
    assert!(!root.join("back/gone.dat").exists());
9715
}
9716
9717
/// oxygen#97: with `git.claim`, an import authors the importing identity's
9718
/// own commits (Git author email = its email, any case) with its key, and
9719
/// leaves anyone else's with the zero key; the history verifies as before.
9720
#[test]
9721
fn git_import_claims_your_own_commits() {
9722
    hermetic();
9723
    if std::process::Command::new("git")
9724
        .arg("--version")
9725
        .output()
9726
        .is_err()
9727
    {
9728
        eprintln!("git not installed; skipping");
9729
        return;
9730
    }
9731
    let dir = tempfile::tempdir().unwrap();
9732
    let root = dir.path();
9733
    let g = root.join("src.git");
9734
    fs::create_dir_all(&g).unwrap();
9735
    let git = |who: &str, args: &[&str]| {
9736
        let (name, email) = who.split_once(' ').unwrap();
9737
        let out = std::process::Command::new("git")
9738
            .arg("-C")
9739
            .arg(&g)
9740
            .args([
9741
                "-c",
9742
                &format!("user.name={name}"),
9743
                "-c",
9744
                &format!("user.email={email}"),
9745
                "-c",
9746
                "commit.gpgsign=false",
9747
            ])
9748
            .args(args)
9749
            .output()
9750
            .unwrap();
9751
        assert!(
9752
            out.status.success(),
9753
            "git {args:?}: {}",
9754
            String::from_utf8_lossy(&out.stderr)
9755
        );
9756
    };
9757
    git("Grace [email protected]", &["init", "-q", "-b", "main"]);
9758
    write(&g, "a.txt", b"a\n");
9759
    git("Grace [email protected]", &["add", "."]);
9760
    git("Grace [email protected]", &["commit", "-qm", "mine"]);
9761
    write(&g, "b.txt", b"b\n");
9762
    git("Ada [email protected]", &["add", "."]);
9763
    git("Ada [email protected]", &["commit", "-qm", "someone else's"]);
9764
9765
    let mut repo = Repo::init(&root.join("o")).unwrap();
9766
    let (key, _) = repo
9767
        .create_identity("Grace", "[email protected]", IdentityScope::Repo)
9768
        .unwrap();
9769
    repo.config.set("git.claim", "true").unwrap();
9770
    repo.save_config().unwrap();
9771
    let repo = Repo::open(&root.join("o")).unwrap();
9772
    repo.import_git(&g, "main").unwrap();
9773
    let v = repo.verify_git_import(&g, "main").unwrap();
9774
    assert!(v.mismatches.is_empty(), "{:?}", v.mismatches);
9775
    let heads = repo.bookmark_heads().unwrap();
9776
    let by_message: std::collections::HashMap<String, KeyId> = repo
9777
        .log_state(&heads)
9778
        .unwrap()
9779
        .into_iter()
9780
        .map(|e| {
9781
            (
9782
                e.change.message.lines().next().unwrap_or("").to_string(),
9783
                e.change.author,
9784
            )
9785
        })
9786
        .collect();
9787
    assert_eq!(by_message["mine"], key, "her own commit, any case of email");
9788
    assert_eq!(by_message["someone else's"], ObjectRef::ZERO, "not hers");
9789
}
9790
9791
/// oxygen#86: removing a remote removes all of it: every `remote.NAME.*`
9792
/// key (URL, token, CA), its tracking bookmarks, and nothing else.
9793
#[test]
9794
fn removing_a_remote_leaves_nothing_of_it() {
9795
    hermetic();
9796
    let dir = tempfile::tempdir().unwrap();
9797
    let origin = dir.path().join("origin");
9798
    Repo::init_bare_unbound(&origin).unwrap();
9799
    let main = dir.path().join("main");
9800
    let mut repo = Repo::init(&main).unwrap();
9801
    repo.config
9802
        .set("remote.origin.url", &origin.to_string_lossy())
9803
        .unwrap();
9804
    repo.config
9805
        .set("remote.origin.token", "secret-ish")
9806
        .unwrap();
9807
    repo.config
9808
        .set("remote.keep.url", &origin.to_string_lossy())
9809
        .unwrap();
9810
    repo.save_config().unwrap();
9811
    write(&main, "a.txt", b"a\n");
9812
    repo.commit("a", &[]).unwrap();
9813
    assert!(repo.push("origin", false).unwrap()[0].ok);
9814
    assert!(
9815
        repo.view().unwrap().remotes.contains_key("origin"),
9816
        "a tracking bookmark"
9817
    );
9818
9819
    repo.remove_remote("origin").unwrap();
9820
    let again = Repo::open(&main).unwrap();
9821
    assert!(
9822
        !again
9823
            .config
9824
            .repo_layer()
9825
            .keys()
9826
            .any(|k| k.starts_with("remote.origin.")),
9827
        "{:?}",
9828
        again.config.repo_layer()
9829
    );
9830
    assert!(!again.config.remotes.contains_key("origin"));
9831
    assert!(
9832
        !again.view().unwrap().remotes.contains_key("origin"),
9833
        "its tracking bookmarks go"
9834
    );
9835
    assert!(
9836
        again.config.remotes.contains_key("keep"),
9837
        "another remote stays"
9838
    );
9839
    let mut again = again;
9840
    assert!(
9841
        again.remove_remote("origin").is_err(),
9842
        "a second removal is refused"
9843
    );
9844
}
9845
9846
/// oxygenhub#126: a blob a host stored for itself (a job's artifact) is
9847
/// unreachable from history, so gc prunes it, unless the host names it:
9848
/// then it and its chunks stay, and it reads back whole.
9849
#[test]
9850
fn gc_keeps_the_blobs_a_host_names() {
9851
    hermetic();
9852
    let dir = tempfile::tempdir().unwrap();
9853
    let repo = Repo::init(dir.path()).unwrap();
9854
    write(dir.path(), "a.txt", b"a\n");
9855
    repo.commit("a", &[]).unwrap();
9856
    // Large enough to be several chunks.
9857
    let body: Vec<u8> = (0..400_000u32)
9858
        .flat_map(|i| (i.wrapping_mul(2654435761)).to_le_bytes())
9859
        .collect();
9860
    let kept = repo.store_blob(&body).unwrap();
9861
    let lost = repo.store_blob(b"an artifact nobody names").unwrap();
9862
    repo.gc_keeping(true, &[kept], &mut |_| {}).unwrap();
9863
    assert_eq!(repo.read_blob(&kept).unwrap(), body, "named: kept whole");
9864
    assert!(!repo.store().has(&lost), "not named: pruned");
9865
    repo.gc(true).unwrap();
9866
    assert!(!repo.store().has(&kept), "a plain gc prunes it");
9867
}
9868
9869
/// oxygen#99: undo in one workspace undoes that workspace's last command,
9870
/// never another workspace's, says what it undid, and goes further back
9871
/// when run again.
9872
#[test]
9873
fn undo_is_scoped_to_its_workspace() {
9874
    hermetic();
9875
    let dir = tempfile::tempdir().unwrap();
9876
    let root = dir.path().join("main");
9877
    let a = Repo::init(&root).unwrap();
9878
    write(&root, "base.txt", b"base\n");
9879
    let base = a.commit("base", &[]).unwrap();
9880
    a.bookmark_create("feature", None).unwrap();
9881
    let ws_dir = dir.path().join("ws");
9882
    let heads = a.resolve_heads("feature").unwrap();
9883
    let b = a
9884
        .workspace_add_on(&ws_dir, Some(&heads), Some("feature"))
9885
        .unwrap();
9886
9887
    // A commits on main, then B commits on feature: B's is the newest.
9888
    write(&root, "a.txt", b"a\n");
9889
    let ca = a.commit("from a", &[]).unwrap();
9890
    write(&ws_dir, "b.txt", b"b\n");
9891
    let cb = b.commit("from b", &[]).unwrap();
9892
9893
    // A's undo undoes A's commit and leaves B's.
9894
    let u = a.undo().unwrap();
9895
    assert_eq!(u.command, "oxy commit -m \"from a\"");
9896
    assert_eq!(u.others_kept, 1, "B's newer commit is left");
9897
    assert!(!u.changes.is_empty());
9898
    let view = a.view().unwrap();
9899
    // (Independent changes commute, so a bookmark's heads may hold base too.)
9900
    assert_eq!(a.heads_of(&view, "main").unwrap(), vec![base]);
9901
    assert!(
9902
        a.heads_of(&view, "feature").unwrap().contains(&cb),
9903
        "B's commit stands"
9904
    );
9905
    assert!(
9906
        a.store().get_change(&ca).is_ok(),
9907
        "the change itself is still in the store"
9908
    );
9909
    // A's working copy was clean, so its files followed; B's are untouched.
9910
    assert!(!root.join("a.txt").exists());
9911
    assert!(a.snapshot("oxy status").unwrap().statuses.is_empty());
9912
    assert!(ws_dir.join("b.txt").exists());
9913
9914
    // B's undo still finds B's own commit, under A's undo.
9915
    let u = b.undo().unwrap();
9916
    assert_eq!(u.command, "oxy commit -m \"from b\"");
9917
    assert!(!b
9918
        .heads_of(&b.view().unwrap(), "feature")
9919
        .unwrap()
9920
        .contains(&cb));
9921
    assert_eq!(
9922
        a.heads_of(&a.view().unwrap(), "main").unwrap(),
9923
        vec![base],
9924
        "A's undo stands"
9925
    );
9926
9927
    // A's next undo goes past the commit it already undid, to adding B's
9928
    // workspace: refused, since B has worked there since.
9929
    let e = a.undo().unwrap_err().to_string();
9930
    assert!(e.contains("the workspace at") && e.contains("ws"), "{e}");
9931
}
9932
9933
/// oxygen#99: two workspaces on one bookmark: an undo takes back only its
9934
/// own commit's head, and the other workspace's head stays.
9935
#[test]
9936
fn undo_on_a_shared_bookmark_keeps_the_other_workspaces_head() {
9937
    hermetic();
9938
    let dir = tempfile::tempdir().unwrap();
9939
    let root = dir.path().join("main");
9940
    let a = Repo::init(&root).unwrap();
9941
    write(&root, "base.txt", b"base\n");
9942
    a.commit("base", &[]).unwrap();
9943
    let ws_dir = dir.path().join("ws");
9944
    let b = a.workspace_add(&ws_dir, None).unwrap();
9945
    write(&root, "a.txt", b"a\n");
9946
    let ca = a.commit("from a", &[]).unwrap();
9947
    write(&ws_dir, "b.txt", b"b\n");
9948
    let cb = b.commit("from b", &[]).unwrap();
9949
    let u = a.undo().unwrap();
9950
    assert_eq!(u.command, "oxy commit -m \"from a\"");
9951
    let heads = a.heads_of(&a.view().unwrap(), "main").unwrap();
9952
    assert!(heads.contains(&cb) && !heads.contains(&ca), "{heads:?}");
9953
}
9954
9955
/// oxygen#99: an undo that would also take back what another workspace
9956
/// did since to the same bookmark is refused, naming it.
9957
#[test]
9958
fn undo_refuses_to_take_back_another_workspaces_work() {
9959
    hermetic();
9960
    let dir = tempfile::tempdir().unwrap();
9961
    let root = dir.path().join("main");
9962
    let a = Repo::init(&root).unwrap();
9963
    write(&root, "base.txt", b"base\n");
9964
    a.commit("base", &[]).unwrap();
9965
    let ws_dir = dir.path().join("ws");
9966
    let b = a.workspace_add(&ws_dir, None).unwrap();
9967
    // Both on main: A commits, then B builds on it, editing A's file.
9968
    write(&root, "a.txt", b"a\n");
9969
    a.commit("from a", &[]).unwrap();
9970
    b.refresh().unwrap();
9971
    let heads = b.resolve_heads("main").unwrap();
9972
    b.checkout(&heads, false, "oxy checkout").unwrap();
9973
    write(&ws_dir, "a.txt", b"a, then b\n");
9974
    b.commit("from b", &[]).unwrap();
9975
    let e = a.undo().unwrap_err().to_string();
9976
    assert!(
9977
        e.contains("bookmark main") && e.contains("builds on"),
9978
        "{e}"
9979
    );
9980
    assert!(e.contains("oxy op restore"), "{e}");
9981
}
9982
9983
/// oxygen#99: undoing a commit that was already pushed says so: the remote
9984
/// still has it.
9985
#[test]
9986
fn undo_says_when_the_commit_was_pushed() {
9987
    hermetic();
9988
    let dir = tempfile::tempdir().unwrap();
9989
    let root = dir.path();
9990
    Repo::init(&root.join("origin")).unwrap();
9991
    let url = root.join("origin").to_string_lossy().to_string();
9992
    let repo = Repo::clone(&url, &root.join("work"))
9993
        .unwrap()
9994
        .with_home(root.join("home"));
9995
    write(&root.join("work"), "a.txt", b"a\n");
9996
    repo.commit("a", &[]).unwrap();
9997
    assert!(repo.push("origin", false).unwrap()[0].ok);
9998
    // The push itself is passed over: the remote keeps what it was sent.
9999
    let u = repo.undo().unwrap();
10000
    assert_eq!(u.command, "oxy commit -m \"a\"");
10001
    assert_eq!(u.pushed, vec![("main".to_string(), "origin".to_string())]);
10002
}
10003
10004
/// oxygen#98: a new untracked file over snapshot.max-new-file-size is left
10005
/// out of the snapshot and named; tracked files and files already in the
10006
/// working copy are not.
10007
#[test]
10008
fn large_new_files_are_left_out_of_snapshots() {
10009
    hermetic();
10010
    let dir = tempfile::tempdir().unwrap();
10011
    let root = dir.path();
10012
    let mut repo = Repo::init(root).unwrap();
10013
    repo.config.max_new_file = 1024;
10014
    write(root, "small.txt", b"small\n");
10015
    write(root, "big.bin", &vec![0u8; 4096]);
10016
    let s = repo.snapshot("oxy status").unwrap();
10017
    let paths: Vec<_> = s.statuses.iter().map(|p| p.path.as_str()).collect();
10018
    assert_eq!(paths, ["small.txt"]);
10019
    assert_eq!(repo.skipped_large(), vec![("big.bin".to_string(), 4096)]);
10020
    repo.commit("small", &[]).unwrap();
10021
10022
    // A tracked file may grow past the limit.
10023
    write(root, "small.txt", &vec![b'x'; 4096]);
10024
    let s = repo.snapshot("oxy status").unwrap();
10025
    assert!(s.statuses.iter().any(|p| p.path == "small.txt"));
10026
    // With no limit the big file is taken in, and stays once it is.
10027
    repo.config.max_new_file = 0;
10028
    let s = repo.snapshot("oxy status").unwrap();
10029
    assert!(s.statuses.iter().any(|p| p.path == "big.bin"));
10030
    repo.config.max_new_file = 1024;
10031
    let s = repo.snapshot("oxy status").unwrap();
10032
    assert!(
10033
        s.statuses.iter().any(|p| p.path == "big.bin"),
10034
        "already in the working copy"
10035
    );
10036
    assert!(repo.skipped_large().is_empty());
10037
}
10038
10039
/// oxygen#98: a file only a past working-copy snapshot held is pruned once
10040
/// snapshots expire, and kept while they have not.
10041
#[test]
10042
fn gc_prunes_what_only_expired_snapshots_held() {
10043
    hermetic();
10044
    let dir = tempfile::tempdir().unwrap();
10045
    let root = dir.path();
10046
    let mut repo = Repo::init(root).unwrap();
10047
    repo.config.gc_grace_days = 0;
10048
    write(root, "a.txt", b"a\n");
10049
    repo.commit("a", &[]).unwrap();
10050
    let image: Vec<u8> = (0..200_000u32).map(|i| (i % 7) as u8).collect();
10051
    write(root, "image.tar", &image);
10052
    repo.snapshot("oxy status").unwrap();
10053
    std::fs::remove_file(root.join("image.tar")).unwrap();
10054
    repo.snapshot("oxy status").unwrap();
10055
    let blob = Repo::blob_ref(&image);
10056
    assert!(repo.store().has(&blob));
10057
    repo.gc(true).unwrap();
10058
    assert!(repo.store().has(&blob), "a recent snapshot keeps it");
10059
    repo.config.snapshot_retention_days = 0;
10060
    repo.gc(true).unwrap();
10061
    assert!(!repo.store().has(&blob), "an expired snapshot does not");
10062
    assert_eq!(
10063
        repo.file_at("a.txt", None).unwrap(),
10064
        b"a\n",
10065
        "committed files stay"
10066
    );
10067
}
10068
10069
/// oxygen#100: uncommitted work byte-for-byte what the bookmark switched to
10070
/// already has is no conflict: the switch goes through, nothing is lost,
10071
/// and other carried work still comes along.
10072
#[test]
10073
fn carrying_work_the_target_already_has_is_no_conflict() {
10074
    hermetic();
10075
    let dir = tempfile::tempdir().unwrap();
10076
    let root = dir.path();
10077
    let mut repo = Repo::init(root).unwrap();
10078
    write(root, "a.txt", b"one\n");
10079
    write(root, "b.txt", b"b\n");
10080
    repo.commit("one", &[]).unwrap();
10081
    repo.bookmark_create("landed", None).unwrap();
10082
    // On landed, a.txt becomes "two" (as a proposal landing would).
10083
    repo.bookmark_switch("landed", false).unwrap();
10084
    write(root, "a.txt", b"two\n");
10085
    repo.commit("two", &[]).unwrap();
10086
    repo.bookmark_switch("main", false).unwrap();
10087
    assert_eq!(std::fs::read(root.join("a.txt")).unwrap(), b"one\n");
10088
    // The same edit, uncommitted, on main; and another edit besides.
10089
    write(root, "a.txt", b"two\n");
10090
    write(root, "b.txt", b"b, edited\n");
10091
    let (_, carried) = repo.bookmark_switch_carry("landed").unwrap();
10092
    assert_eq!(
10093
        carried,
10094
        vec!["b.txt".to_string()],
10095
        "only the work landed lacks is carried"
10096
    );
10097
    assert_eq!(std::fs::read(root.join("a.txt")).unwrap(), b"two\n");
10098
    assert_eq!(std::fs::read(root.join("b.txt")).unwrap(), b"b, edited\n");
10099
    let s = repo.snapshot("oxy status").unwrap();
10100
    let paths: Vec<_> = s.statuses.iter().map(|p| p.path.as_str()).collect();
10101
    assert_eq!(paths, ["b.txt"]);
10102
10103
    // A real difference is still refused.
10104
    repo.bookmark_switch("main", true).unwrap();
10105
    write(root, "a.txt", b"three\n");
10106
    let e = repo
10107
        .bookmark_switch_carry("landed")
10108
        .unwrap_err()
10109
        .to_string();
10110
    assert!(e.contains("a.txt") && e.contains("--discard"), "{e}");
10111
}
10112
10113
/// oxygen#103: a full change hash as `--json` prints it, without `#`,
10114
/// resolves, so JSON output feeds back into commands.
10115
#[test]
10116
fn a_full_hash_resolves_without_its_hash_sign() {
10117
    hermetic();
10118
    let dir = tempfile::tempdir().unwrap();
10119
    let root = dir.path();
10120
    let repo = Repo::init(root).unwrap();
10121
    write(root, "a.txt", b"a\n");
10122
    let h = repo.commit("a", &[]).unwrap();
10123
    assert_eq!(repo.resolve_change(&h.to_hex()).unwrap(), h);
10124
    assert_eq!(repo.resolve_change(&format!("#{}", h.to_hex())).unwrap(), h);
10125
    assert!(repo
10126
        .resolve_change(&"0".repeat(64))
10127
        .unwrap_err()
10128
        .to_string()
10129
        .contains("no change matches"));
10130
}
10131
10132
#[test]
10133
fn a_pin_change_moves_only_the_pin() {
10134
    hermetic();
10135
    let dir = tempfile::tempdir().unwrap();
10136
    let root = dir.path();
10137
    let _lib_origin = Repo::init(&root.join("lib-origin")).unwrap();
10138
    let lib_url = root.join("lib-origin").to_string_lossy().to_string();
10139
    let lib = Repo::clone(&lib_url, &root.join("lib"))
10140
        .unwrap()
10141
        .with_home(root.join("home"));
10142
    write(&root.join("lib"), "lib.txt", b"v1\n");
10143
    lib.commit("lib v1", &[]).unwrap();
10144
    assert!(lib.push("origin", false).unwrap()[0].ok);
10145
    let _app_origin = Repo::init(&root.join("app-origin")).unwrap();
10146
    let app_dir = root.join("app");
10147
    let app = Repo::clone(&root.join("app-origin").to_string_lossy(), &app_dir)
10148
        .unwrap()
10149
        .with_home(root.join("home"));
10150
    write(&app_dir, "main.txt", b"app\n");
10151
    let pin1 = app.submodule_add(&lib_url, "libs/lib", None).unwrap();
10152
    let a1 = app.commit("app with lib", &[]).unwrap();
10153
    // The library moves on.
10154
    write(&root.join("lib"), "more.txt", b"v2\n");
10155
    let l2 = lib.commit("lib v2", &[]).unwrap();
10156
    let crate::subrepo::Pin::Oxy { repo, bookmark, .. } = pin1 else {
10157
        panic!("an Oxygen pin")
10158
    };
10159
    let pin2 = crate::subrepo::Pin::Oxy {
10160
        repo,
10161
        heads: vec![l2],
10162
        bookmark,
10163
    };
10164
    let author = app.store().get_change(&a1).unwrap().author;
10165
    let change = app
10166
        .build_pin_change(&[a1], "libs/lib", &pin2, "Move lib to v2", author)
10167
        .unwrap();
10168
    let mut state = app.load_state(&[a1]).unwrap();
10169
    state.add(change).unwrap();
10170
    let files = state.materialize().files;
10171
    assert_eq!(files["libs/lib"].kind, NodeKind::Subrepo);
10172
    let FileContent::Opaque { blobs } = &files["libs/lib"].content else {
10173
        panic!("a pin is opaque")
10174
    };
10175
    let read = crate::subrepo::Pin::decode(&app.read_blob(&blobs[0]).unwrap()).unwrap();
10176
    assert_eq!(read, pin2, "the new pin");
10177
    assert!(
10178
        matches!(&files["main.txt"].content, FileContent::Text { bytes, .. } if bytes.as_slice() == b"app\n"),
10179
        "nothing else changes"
10180
    );
10181
    // Only a subrepo's pin can be moved this way.
10182
    let e = app.build_pin_change(&[a1], "main.txt", &pin2, "x", author);
10183
    assert!(format!("{:?}", e.unwrap_err()).contains("not a subrepo"));
10184
    let e = app.build_pin_change(&[a1], "libs/none", &pin2, "x", author);
10185
    assert!(format!("{:?}", e.unwrap_err()).contains("does not exist"));
10186
}
10187
10188
/// oxygenhub#249: a handle reads the operation head once and keeps it,
10189
/// so one command or request never mixes two heads; another handle's
10190
/// operation shows only after `refresh`. Its own operations move it, and
10191
/// one recorded from the head it kept merges with what landed since, as
10192
/// any concurrent operation does.
10193
#[test]
10194
fn one_handle_keeps_one_head_until_it_refreshes() {
10195
    hermetic();
10196
    let dir = tempfile::tempdir().unwrap();
10197
    let root = dir.path();
10198
    let writer = Repo::init(root).unwrap();
10199
    write(root, "a.txt", b"one\n");
10200
    let h1 = writer.commit("one", &[]).unwrap();
10201
    let reader = Repo::open(root).unwrap();
10202
    let before = reader.view().unwrap();
10203
    assert_eq!(reader.bookmark_heads().unwrap(), vec![h1]);
10204
    write(root, "a.txt", b"two\n");
10205
    let h2 = writer.commit("two", &[]).unwrap();
10206
    assert_eq!(
10207
        writer.bookmark_heads().unwrap(),
10208
        vec![h2],
10209
        "its own operation"
10210
    );
10211
    assert_eq!(reader.view().unwrap(), before, "the head it read first");
10212
    assert_eq!(reader.bookmark_heads().unwrap(), vec![h1]);
10213
    reader.refresh().unwrap();
10214
    assert_eq!(reader.bookmark_heads().unwrap(), vec![h2]);
10215
10216
    // A handle recording from an older head merges with the newer one.
10217
    let stale = Repo::open(root).unwrap();
10218
    assert_eq!(stale.bookmark_heads().unwrap(), vec![h2]);
10219
    write(root, "a.txt", b"three\n");
10220
    let h3 = writer.commit("three", &[]).unwrap();
10221
    stale.bookmark_create("feature", Some(&[h1])).unwrap();
10222
    let view = stale.view().unwrap();
10223
    assert!(view.bookmarks.contains_key("feature"));
10224
    assert_eq!(
10225
        stale.bookmark_heads().unwrap(),
10226
        vec![h3],
10227
        "merged, not lost"
10228
    );
10229
    let fresh = Repo::open(root).unwrap();
10230
    assert_eq!(fresh.view().unwrap(), view);
10231
}
10232
10233
/// oxygenhub#249 over a store (a bucket's compare-and-swap): the same.
10234
#[test]
10235
fn over_a_store_one_handle_keeps_one_head() {
10236
    hermetic();
10237
    let backend = std::sync::Arc::new(crate::strata::MemoryStore::new());
10238
    crate::store::TEST_BACKEND.with(|t| *t.borrow_mut() = Some(backend.clone()));
10239
    let dir = tempfile::tempdir().unwrap();
10240
    let a = Repo::init(dir.path()).unwrap();
10241
    let (h0, _) = a.head_op().unwrap();
10242
    let b = Repo::open(dir.path()).unwrap();
10243
    assert_eq!(b.head_op().unwrap().0, h0);
10244
    let state = a.store.put_state(&StateObject::new(vec![])).unwrap();
10245
    let mut view = a.view().unwrap();
10246
    view.bookmarks.insert("x".into(), state);
10247
    let r = a.record_op(&[h0], "bookmark x", view).unwrap();
10248
    assert_eq!(a.head_op().unwrap().0, r);
10249
    assert_eq!(b.head_op().unwrap().0, h0, "b keeps its head");
10250
    b.refresh().unwrap();
10251
    assert_eq!(b.head_op().unwrap().0, r);
10252
    crate::store::TEST_BACKEND.with(|t| *t.borrow_mut() = None);
10253
}
10254
10255
/// oxygenhub#249: gc retires what it repacks rather than deleting it; a
10256
/// reader opened before reads on, then and after the next gc deletes the
10257
/// retired files once their hour is up. Younger ones are kept.
10258
#[test]
10259
fn gc_retires_and_a_later_gc_deletes_after_the_hour() {
10260
    hermetic();
10261
    let dir = tempfile::tempdir().unwrap();
10262
    let root = dir.path();
10263
    let repo = Repo::init(root).unwrap();
10264
    let mut heads = Vec::new();
10265
    for i in 0..4 {
10266
        write(root, &format!("f{i}.txt"), format!("{i}\n").as_bytes());
10267
        heads.push(repo.commit(&format!("c{i}"), &[]).unwrap());
10268
    }
10269
    let reader = Repo::open(root).unwrap();
10270
    assert_eq!(reader.log().unwrap().len(), 5);
10271
    let r = repo.gc(true).unwrap();
10272
    assert_eq!(r.retired, crate::store::RetiredReport::default());
10273
    assert_eq!(repo.store().loose_count().unwrap(), 0);
10274
    assert_eq!(reader.log().unwrap().len(), 5, "read on from what it had");
10275
    // Nothing is an hour old yet: a gc keeps it all.
10276
    let r = repo.gc(true).unwrap();
10277
    assert_eq!((r.retired.packs, r.retired.loose), (0, 0));
10278
    assert!(r.retired.kept > 0, "{r:?}");
10279
    write(root, "f9.txt", b"9\n");
10280
    heads.push(repo.commit("c9", &[]).unwrap());
10281
    crate::store::reader_tests::age_retired(
10282
        repo.store(),
10283
        crate::store::RETIRED_FOR + std::time::Duration::from_secs(60),
10284
    );
10285
    let r = repo.gc(true).unwrap();
10286
    assert!(r.retired.packs >= 1 && r.retired.loose > 0, "{r:?}");
10287
    assert_eq!(r.retired.kept, 0, "{r:?}");
10288
    let r = repo.gc(true).unwrap();
10289
    assert_eq!((r.retired.packs, r.retired.loose), (0, 0));
10290
    assert!(r.retired.kept > 0, "what the last gc retired is young");
10291
    // The reader opened before all of it still reads everything, from
10292
    // the packs it finds when what it loaded is gone.
10293
    assert_eq!(reader.log().unwrap().len(), 5);
10294
    for h in &heads[..4] {
10295
        reader.store().get_change(h).unwrap();
10296
    }
10297
    reader.refresh().unwrap();
10298
    assert_eq!(reader.log().unwrap().len(), 6);
10299
    let fresh = Repo::open(root).unwrap();
10300
    assert_eq!(
10301
        fresh.store().pack_count(),
10302
        1,
10303
        "retired packs are not loaded"
10304
    );
10305
}
10306
10307
/// oxygenhub#249: a repository opens when its store's pack listing names
10308
/// an index that is not there (deleted between the listing and the read).
10309
#[test]
10310
fn a_repository_opens_when_a_listed_index_is_gone() {
10311
    hermetic();
10312
    let backend = crate::store::reader_tests::racing(true);
10313
    let dyn_backend: std::sync::Arc<crate::store::Backend> = backend.clone();
10314
    crate::store::TEST_BACKEND.with(|t| *t.borrow_mut() = Some(dyn_backend));
10315
    let dir = tempfile::tempdir().unwrap();
10316
    let repo = Repo::init(dir.path()).unwrap();
10317
    write(dir.path(), "a.txt", b"a\n");
10318
    let h = repo.commit("a", &[]).unwrap();
10319
    repo.gc(true).unwrap();
10320
    let again = Repo::open(dir.path()).unwrap();
10321
    assert_eq!(again.store().pack_count(), 1);
10322
    assert_eq!(again.bookmark_heads().unwrap(), vec![h]);
10323
    // And when one is deleted between the listing and the read: a copy of
10324
    // the live pack, whose index goes as the store opens.
10325
    use crate::strata::Store as _;
10326
    let idx = backend
10327
        .list("pack-")
10328
        .unwrap()
10329
        .into_iter()
10330
        .map(|e| e.key)
10331
        .find(|k| k.ends_with(".idx") && k != "pack-0000.idx")
10332
        .unwrap();
10333
    let stem = idx.trim_end_matches(".idx");
10334
    for ext in ["pack", "idx"] {
10335
        let bytes = backend.get(&format!("{stem}.{ext}")).unwrap();
10336
        backend
10337
            .put_new(&format!("pack-copy.{ext}"), &bytes)
10338
            .unwrap();
10339
    }
10340
    *backend.doomed.lock().unwrap() = Some("pack-copy.idx".into());
10341
    let again = Repo::open(dir.path()).unwrap();
10342
    assert_eq!(again.store().pack_count(), 1);
10343
    assert_eq!(again.bookmark_heads().unwrap(), vec![h]);
10344
    assert!(!backend.has("pack-copy.idx").unwrap());
10345
    crate::store::TEST_BACKEND.with(|t| *t.borrow_mut() = None);
10346
}