OXYGENHUB
◆ push-notes oxygen / tests / cli_json.rs
28.1 KiB · Rust · last changed oxy show --stat: the files a change touched, with lines added and removed 17 days ago source blame history raw
1
//! Every `--json` output of the CLI parses as JSON and carries the fields
2
//! the VS Code extension relies on (oxygen-vscode/docs/spec.md section 4).
3
4
use serde_json::Value;
5
use std::path::Path;
6
use std::process::Command;
7
8
fn oxy(dir: &Path, home: &Path, args: &[&str]) -> (i32, Vec<u8>, String) {
9
    let out = Command::new(env!("CARGO_BIN_EXE_oxy"))
10
        .arg("--plain")
11
        .args(args)
12
        .current_dir(dir)
13
        .env("OXY_HOME", home)
14
        .env("NO_COLOR", "1")
15
        .output()
16
        .unwrap();
17
    (
18
        out.status.code().unwrap_or(-1),
19
        out.stdout,
20
        String::from_utf8_lossy(&out.stderr).to_string(),
21
    )
22
}
23
24
fn json(dir: &Path, home: &Path, args: &[&str]) -> Value {
25
    let (code, out, err) = oxy(dir, home, args);
26
    assert!(
27
        code == 0 || code == 2 || code == 1,
28
        "oxy {args:?} exited {code}: {err}"
29
    );
30
    serde_json::from_slice(&out).unwrap_or_else(|e| {
31
        panic!(
32
            "oxy {args:?} is not JSON: {e}\n{}",
33
            String::from_utf8_lossy(&out)
34
        )
35
    })
36
}
37
38
fn write(dir: &Path, rel: &str, content: &str) {
39
    let p = dir.join(rel);
40
    std::fs::create_dir_all(p.parent().unwrap()).unwrap();
41
    std::fs::write(p, content).unwrap();
42
}
43
44
#[test]
45
fn machine_interface_round_trips() {
46
    let tmp = tempfile::tempdir().unwrap();
47
    let home = tmp.path().join("home");
48
    std::fs::create_dir_all(&home).unwrap();
49
    let origin = tmp.path().join("origin");
50
    assert_eq!(
51
        oxy(tmp.path(), &home, &["init", origin.to_str().unwrap()]).0,
52
        0
53
    );
54
    let repo = tmp.path().join("repo");
55
    assert_eq!(
56
        oxy(
57
            tmp.path(),
58
            &home,
59
            &["clone", origin.to_str().unwrap(), repo.to_str().unwrap()]
60
        )
61
        .0,
62
        0
63
    );
64
    let r = repo.as_path();
65
    assert_eq!(
66
        oxy(
67
            r,
68
            &home,
69
            &[
70
                "identity",
71
                "create",
72
                "--name",
73
                "Ada",
74
                "--email",
75
                "[email protected]"
76
            ]
77
        )
78
        .0,
79
        0
80
    );
81
    assert_eq!(
82
        oxy(r, &home, &["config", "--user", "core.sign", "change"]).0,
83
        0
84
    );
85
86
    let v = json(r, &home, &["version", "--json"]);
87
    assert_eq!(v["protocol"], 1);
88
    let root = json(r, &home, &["root", "--json"]);
89
    assert_eq!(root["bookmark"], "main");
90
    assert!(root["root"].as_str().unwrap().ends_with("repo"));
91
92
    write(r, "a.txt", "1\n2\n3\n");
93
    write(r, "b.txt", "b\n");
94
    let st = json(r, &home, &["status", "--json"]);
95
    assert_eq!(st["paths"].as_array().unwrap().len(), 2);
96
    assert_eq!(oxy(r, &home, &["commit", "-m", "base"]).0, 0);
97
    write(r, "a.txt", "0\n1\n2\n3\n");
98
    write(r, "b.txt", "b\nbb\n");
99
    write(r, "c.txt", "c\n");
100
101
    // diff and hunks agree on the working copy.
102
    let d = json(r, &home, &["diff", "--json"]);
103
    let files = d["files"].as_array().unwrap();
104
    assert_eq!(files.len(), 3, "{d}");
105
    let a = files.iter().find(|f| f["path"] == "a.txt").unwrap();
106
    assert_eq!(a["kind"], "modified");
107
    assert_eq!(a["hunks"][0]["base_line"], 0);
108
    assert_eq!(a["hunks"][0]["added"][0], "0\n");
109
    let c = files.iter().find(|f| f["path"] == "c.txt").unwrap();
110
    assert_eq!(c["kind"], "added");
111
    let hunks = json(r, &home, &["hunks", "--json"]);
112
    let hunks = hunks.as_array().unwrap();
113
    assert!(hunks.len() >= 3, "{hunks:?}");
114
    let b_hunk = hunks.iter().find(|h| h["path"] == "b.txt").unwrap()["index"]
115
        .as_u64()
116
        .unwrap();
117
    // Commit only b's hunk; discard c by path; then discard a's remaining hunk.
118
    assert_eq!(
119
        oxy(
120
            r,
121
            &home,
122
            &["commit", "-m", "b only", "--hunks", &b_hunk.to_string()]
123
        )
124
        .0,
125
        0
126
    );
127
    assert_eq!(oxy(r, &home, &["discard", "c.txt"]).0, 0);
128
    assert!(!r.join("c.txt").exists());
129
    let hunks = json(r, &home, &["hunks", "--json"]);
130
    let a_hunk = hunks
131
        .as_array()
132
        .unwrap()
133
        .iter()
134
        .find(|h| h["path"] == "a.txt")
135
        .unwrap()["index"]
136
        .as_u64()
137
        .unwrap();
138
    assert_eq!(
139
        oxy(r, &home, &["discard", "--hunks", &a_hunk.to_string()]).0,
140
        0
141
    );
142
    assert_eq!(
143
        std::fs::read_to_string(r.join("a.txt")).unwrap(),
144
        "1\n2\n3\n"
145
    );
146
    let st = json(r, &home, &["status", "--json"]);
147
    assert_eq!(st["clean"], true, "{st}");
148
149
    // cat serves file content at states.
150
    let (code, out, _) = oxy(r, &home, &["cat", "b.txt"]);
151
    assert_eq!((code, out.as_slice()), (0, b"b\nbb\n".as_slice()));
152
    let (code, out, _) = oxy(r, &home, &["cat", "b.txt", "-r", "main"]);
153
    assert_eq!((code, out.as_slice()), (0, b"b\nbb\n".as_slice()));
154
155
    // log, show, verify, op log, bookmarks, remotes, sync.
156
    let log = json(r, &home, &["log", "--json"]);
157
    let changes = log["changes"].as_array().unwrap();
158
    assert_eq!(changes.len(), 3, "working copy plus two changes: {log}");
159
    let head = changes.iter().find(|c| c["message"] == "b only").unwrap();
160
    assert_eq!(head["signature"]["status"], "signed");
161
    assert_eq!(head["author"], "Ada");
162
    let hash = head["hash"].as_str().unwrap().to_string();
163
    let by_path = json(r, &home, &["log", "--json", "--path", "b.txt"]);
164
    assert_eq!(by_path["changes"].as_array().unwrap().len(), 2, "{by_path}");
165
    let show = json(r, &home, &["show", "--json", &format!("#{}", &hash[..12])]);
166
    assert_eq!(show["files"][0]["path"], "b.txt");
167
    // oxygen#119: lines added and removed per file, and --stat.
168
    assert!(show["files"][0]["added"].as_u64().unwrap() >= 1, "{show}");
169
    let (code, out, _) = oxy(r, &home, &["show", "--stat", &format!("#{}", &hash[..12])]);
170
    let out = String::from_utf8_lossy(&out);
171
    assert!(
172
        code == 0 && out.contains("b.txt") && out.contains("1 file changed"),
173
        "{out}"
174
    );
175
    assert!(!out.contains("@@"), "the files, not the diff: {out}");
176
    let verify = json(r, &home, &["verify", "--json"]);
177
    assert!(verify
178
        .as_array()
179
        .unwrap()
180
        .iter()
181
        .all(|v| v["status"] == "signed"));
182
    let ops = json(r, &home, &["op", "log", "--json"]);
183
    assert!(ops[0]["current"] == true && ops.as_array().unwrap().len() > 3);
184
    assert_eq!(oxy(r, &home, &["bookmark", "create", "feature"]).0, 0);
185
    let bms = json(r, &home, &["bookmark", "list", "--json"]);
186
    assert_eq!(
187
        bms.as_array()
188
            .unwrap()
189
            .iter()
190
            .map(|b| b["name"].as_str().unwrap())
191
            .collect::<Vec<_>>(),
192
        vec!["feature", "main"]
193
    );
194
    let remotes = json(r, &home, &["remote", "list", "--json"]);
195
    assert_eq!(remotes[0]["name"], "origin");
196
    let push = json(r, &home, &["push", "--json"]);
197
    assert_eq!(push[0]["ok"], true, "{push}");
198
    let state = push[0]["state"].as_str().unwrap();
199
    assert!(
200
        state.len() == 64 && state.chars().all(|c| c.is_ascii_hexdigit()),
201
        "the state the push moved the bookmark to: {push}"
202
    );
203
    let fetch = json(r, &home, &["fetch", "--json"]);
204
    assert_eq!(fetch["remote"], "origin");
205
    let pull = json(r, &home, &["pull", "--json"]);
206
    assert_eq!(pull["changed"], false);
207
    assert_eq!(json(r, &home, &["resolve", "--json"])["conflicted"], false);
208
    assert!(json(r, &home, &["submodule", "status", "--json"])
209
        .as_array()
210
        .unwrap()
211
        .is_empty());
212
    assert!(json(r, &home, &["bisect", "status", "--json"]).is_null());
213
    assert!(json(r, &home, &["policy", "show", "--json"]).is_null());
214
    assert_eq!(oxy(r, &home, &["policy", "init"]).0, 0);
215
    let policy = json(r, &home, &["policy", "show", "--json"]);
216
    assert_eq!(policy["roots"][0]["name"], "Ada");
217
    // Keyless (oxygenhub#241): a hub's certified subject may sign too.
218
    let hub =
219
        oxy_core::identity::SecretKey::generate().identity("oxygenhub.io", "[email protected]", 1);
220
    let hub_file = r.join("hub.identity");
221
    std::fs::write(&hub_file, hub.encode()).unwrap();
222
    let grant = [
223
        "policy",
224
        "grant-subject",
225
        "repo:ada/demo:bookmark:main",
226
        "--issuer",
227
        hub_file.to_str().unwrap(),
228
        "--scope",
229
        "refs",
230
    ];
231
    assert_eq!(oxy(r, &home, &grant).0, 0);
232
    let policy = json(r, &home, &["policy", "show", "--json"]);
233
    let subjects = &policy["rules"][0]["subjects"][0];
234
    assert_eq!(
235
        (
236
            subjects["subject"].as_str(),
237
            subjects["issuer"]["name"].as_str(),
238
            subjects["scope"].as_str()
239
        ),
240
        (
241
            Some("repo:ada/demo:bookmark:main"),
242
            Some("oxygenhub.io"),
243
            Some("refs")
244
        )
245
    );
246
    std::fs::remove_file(&hub_file).unwrap();
247
    assert_eq!(
248
        oxy(r, &home, &["tag", "create", "v1", "-m", "first tag"]).0,
249
        0
250
    );
251
    let tags = json(r, &home, &["tag", "list", "--json"]);
252
    assert_eq!(
253
        (tags[0]["name"].as_str(), tags[0]["message"].as_str()),
254
        (Some("v1"), Some("first tag"))
255
    );
256
    let ws = json(r, &home, &["workspace", "list", "--json"]);
257
    assert_eq!(
258
        (ws.as_array().unwrap().len(), ws[0]["current"].as_bool()),
259
        (1, Some(true))
260
    );
261
}
262
263
/// `oxy commit -F`, `oxy describe` naming and tags as changes (feedback
264
/// 4 and 6).
265
#[test]
266
fn commit_from_a_file_and_describe_for_builds() {
267
    let tmp = tempfile::tempdir().unwrap();
268
    let home = tmp.path().join("home");
269
    std::fs::create_dir_all(&home).unwrap();
270
    let dir = tmp.path().join("r");
271
    std::fs::create_dir_all(&dir).unwrap();
272
    assert_eq!(oxy(&dir, &home, &["init"]).0, 0);
273
    write(&dir, "a.txt", "a\n");
274
    write(
275
        tmp.path(),
276
        "msg.txt",
277
        "Subject from a file\n\nA body\nof two lines.\n",
278
    );
279
    let msg = tmp.path().join("msg.txt");
280
    let (code, _, err) = oxy(&dir, &home, &["commit", "-F", msg.to_str().unwrap()]);
281
    assert_eq!(code, 0, "{err}");
282
    let log = json(&dir, &home, &["log", "--json", "-n", "2"]);
283
    assert_eq!(
284
        log["changes"][1]["message"],
285
        "Subject from a file\n\nA body\nof two lines."
286
    );
287
    assert_ne!(oxy(&dir, &home, &["commit"]).0, 0, "a message is required");
288
289
    // Untagged: bookmark and change; a tag names it; later work counts.
290
    let d = json(&dir, &home, &["describe", "--json"]);
291
    assert_eq!(
292
        (d["tag"].clone(), d["dirty"].clone()),
293
        (Value::Null, Value::Bool(false))
294
    );
295
    let change = d["change"].as_str().unwrap().to_string();
296
    assert_eq!(d["describe"], format!("main-{}", &change[..8]));
297
    assert_eq!(
298
        oxy(&dir, &home, &["tag", "create", "v0.1.0", "-m", "first"]).0,
299
        0
300
    );
301
    let (_, out, _) = oxy(&dir, &home, &["describe"]);
302
    assert_eq!(String::from_utf8_lossy(&out).trim(), "v0.1.0");
303
    let (code, out, err) = oxy(&dir, &home, &["show", "v0.1.0"]);
304
    assert_eq!(code, 0, "a tag names its change: {err}");
305
    assert!(String::from_utf8_lossy(&out).contains("Subject from a file"));
306
    write(&dir, "b.txt", "b\n");
307
    let d = json(&dir, &home, &["describe", "--json"]);
308
    assert_eq!(d["describe"], "v0.1.0-dirty");
309
    assert_eq!(oxy(&dir, &home, &["commit", "-m", "b"]).0, 0);
310
    write(&dir, "c.txt", "c\n");
311
    assert_eq!(oxy(&dir, &home, &["commit", "-m", "c"]).0, 0);
312
    let d = json(&dir, &home, &["describe", "--json"]);
313
    let change = d["change"].as_str().unwrap();
314
    assert_eq!(d["distance"], 2);
315
    assert_eq!(d["describe"], format!("v0.1.0-2-{}", &change[..8]));
316
}
317
318
/// `oxy log -L START,END:PATH`: the history of a range of lines.
319
#[test]
320
fn the_history_of_a_function() {
321
    let tmp = tempfile::tempdir().unwrap();
322
    let home = tmp.path().join("home");
323
    std::fs::create_dir_all(&home).unwrap();
324
    let dir = tmp.path().join("r");
325
    std::fs::create_dir_all(&dir).unwrap();
326
    assert_eq!(oxy(&dir, &home, &["init"]).0, 0);
327
    write(&dir, "f.rs", "use x;\nfn a() {\n  one();\n}\n");
328
    assert_eq!(oxy(&dir, &home, &["commit", "-m", "add f"]).0, 0);
329
    write(&dir, "f.rs", "use y;\nfn a() {\n  one();\n}\n");
330
    assert_eq!(oxy(&dir, &home, &["commit", "-m", "import"]).0, 0);
331
    write(&dir, "f.rs", "use y;\nfn a() {\n  two();\n}\n");
332
    assert_eq!(oxy(&dir, &home, &["commit", "-m", "body"]).0, 0);
333
    let v = json(&dir, &home, &["log", "--json", "-L", "2,4:f.rs"]);
334
    let messages: Vec<&str> = v["changes"]
335
        .as_array()
336
        .unwrap()
337
        .iter()
338
        .map(|c| c["message"].as_str().unwrap())
339
        .collect();
340
    assert_eq!(messages, ["body", "add f"], "not the import");
341
    let (code, _, err) = oxy(&dir, &home, &["log", "-L", "2:f.rs"]);
342
    assert_ne!(code, 0);
343
    assert!(err.contains("START,END:PATH"), "{err}");
344
}
345
346
#[test]
347
fn receipts_verify_import_list_show_and_export() {
348
    use oxy_core::hash::{ObjectRef, ObjectType};
349
    use oxy_core::identity::SecretKey;
350
    use oxy_core::objects::StateObject;
351
    use oxy_core::receipt::{Receipt, SignedReceipt};
352
    let tmp = tempfile::tempdir().unwrap();
353
    let home = tmp.path().join("home");
354
    std::fs::create_dir_all(&home).unwrap();
355
    let r = tmp.path().join("repo");
356
    assert_eq!(oxy(tmp.path(), &home, &["init", r.to_str().unwrap()]).0, 0);
357
    write(&r, "a.txt", "a\n");
358
    assert_eq!(oxy(&r, &home, &["commit", "-m", "A"]).0, 0);
359
360
    let repo = oxy_core::repo::Repo::open(&r).unwrap();
361
    let heads = repo.bookmark_heads().unwrap();
362
    let state = oxy_core::hash::hash_object(ObjectType::State, &StateObject::new(heads).encode());
363
    let receipt = Receipt {
364
        job_name: "test".into(),
365
        pipeline_hash: ObjectRef([1; 32]),
366
        scope_tree_hash: ObjectRef([2; 32]),
367
        inputs: vec![],
368
        environment: ObjectRef([3; 32]),
369
        outputs: vec![],
370
        exit_code: 0,
371
        duration_ms: 2500,
372
    };
373
    let sk = SecretKey::generate();
374
    let runner = sk.identity("earth-1", "runner@earth", 1);
375
    let sr = SignedReceipt::sign(receipt, repo.id().unwrap(), state, "demo#7", &runner, &sk);
376
    let file = tmp.path().join("r.receipt");
377
    std::fs::write(&file, sr.encode()).unwrap();
378
    let f = file.to_str().unwrap();
379
380
    let v = json(&r, &home, &["receipt", "verify", f, "--json"]);
381
    assert_eq!(
382
        (v["valid"].as_bool(), v["job"].as_str(), v["run"].as_str()),
383
        (Some(true), Some("test"), Some("demo#7"))
384
    );
385
    assert!(v["attest"].is_null());
386
387
    assert!(json(&r, &home, &["receipt", "list", "--json"])
388
        .as_array()
389
        .unwrap()
390
        .is_empty());
391
    assert_eq!(oxy(&r, &home, &["receipt", "import", f]).0, 0);
392
    let list = json(&r, &home, &["receipt", "list", "--json"]);
393
    let list = list.as_array().unwrap();
394
    assert_eq!(list.len(), 1);
395
    assert_eq!(list[0]["runner_name"], "earth-1");
396
    assert_eq!(list[0]["state"], state.to_hex());
397
    let attest = list[0]["attest"].as_str().unwrap().to_string();
398
    let shown = json(&r, &home, &["receipt", "show", &attest[..10], "--json"]);
399
    assert_eq!(shown["duration_ms"], 2500);
400
401
    let out = tmp.path().join("out.receipt");
402
    assert_eq!(
403
        oxy(
404
            &r,
405
            &home,
406
            &["receipt", "export", &attest[..10], out.to_str().unwrap()]
407
        )
408
        .0,
409
        0
410
    );
411
    assert_eq!(std::fs::read(&out).unwrap(), sr.encode());
412
413
    // SLSA provenance is signed with the exporter's key: none yet, so it is
414
    // refused and says what to do.
415
    let prov = tmp.path().join("prov.json");
416
    let p = prov.to_str().unwrap();
417
    let slsa = ["receipt", "export", &attest[..10], p, "--format", "slsa"];
418
    let (code, _, err) = oxy(&r, &home, &slsa);
419
    assert_ne!(code, 0);
420
    assert!(err.contains("oxy identity create"), "{err}");
421
    let ident = [
422
        "identity",
423
        "create",
424
        "--name",
425
        "Ada",
426
        "--email",
427
        "[email protected]",
428
    ];
429
    assert_eq!(oxy(&r, &home, &ident).0, 0);
430
    assert_eq!(oxy(&r, &home, &slsa).0, 0);
431
    let env: serde_json::Value = serde_json::from_slice(&std::fs::read(&prov).unwrap()).unwrap();
432
    assert_eq!(env["payloadType"], oxy_core::dsse::IN_TOTO);
433
    let payload = oxy_core::dsse::base64_decode(env["payload"].as_str().unwrap()).unwrap();
434
    let st: serde_json::Value = serde_json::from_slice(&payload).unwrap();
435
    assert_eq!(st["predicateType"], "https://slsa.dev/provenance/v1");
436
    assert_eq!(st["subject"][0]["digest"]["oxygen-state"], state.to_hex());
437
    let run = &st["predicate"]["runDetails"];
438
    assert_eq!(run["metadata"]["invocationId"], "demo#7");
439
    let kept = oxy_core::dsse::base64_decode(run["byproducts"][0]["content"].as_str().unwrap());
440
    assert_eq!(
441
        kept.unwrap(),
442
        sr.encode(),
443
        "the runner's receipt rides along"
444
    );
445
    // It verifies with the exporter's key and its PEM holds that key.
446
    let repo = oxy_core::repo::Repo::open(&r)
447
        .unwrap()
448
        .with_home(home.clone());
449
    let key = repo.signing_key().unwrap();
450
    assert_eq!(env["signatures"][0]["keyid"], key.to_hex());
451
    let me = repo.identity(&key).unwrap();
452
    let sig = oxy_core::dsse::base64_decode(env["signatures"][0]["sig"].as_str().unwrap()).unwrap();
453
    assert!(oxy_core::dsse::verify(&me, oxy_core::dsse::IN_TOTO, &payload, &sig).is_ok());
454
    assert!(oxy_core::dsse::verify(&runner, oxy_core::dsse::IN_TOTO, &payload, &sig).is_err());
455
    let pem = tmp.path().join("key.pem");
456
    let pem_args = ["identity", "export", "--pem", pem.to_str().unwrap()];
457
    assert_eq!(oxy(&r, &home, &pem_args).0, 0);
458
    assert_eq!(
459
        std::fs::read_to_string(&pem).unwrap(),
460
        oxy_core::dsse::public_pem(&me)
461
    );
462
463
    // A receipt whose result was changed after signing does not verify.
464
    let mut forged = sr.clone();
465
    forged.receipt.exit_code = 3;
466
    std::fs::write(&file, forged.encode()).unwrap();
467
    let (code, out, _) = oxy(&r, &home, &["receipt", "verify", f]);
468
    assert_eq!(code, 1);
469
    assert!(String::from_utf8_lossy(&out).contains("different receipt"));
470
    let (code, _, err) = oxy(&r, &home, &["receipt", "import", f]);
471
    assert_ne!(code, 0);
472
    assert!(err.contains("does not verify"), "{err}");
473
}
474
475
/// Metalgear, 2026-09-29 (oxygen#37): a bookmark created at one of three
476
/// independent heads held only that change and what it depends on, and
477
/// switching to it silently wrote a much older tree.
478
#[test]
479
fn a_bookmark_at_one_head_of_several_is_refused_unless_asked_for() {
480
    let tmp = tempfile::tempdir().unwrap();
481
    let home = tmp.path().join("home");
482
    std::fs::create_dir_all(&home).unwrap();
483
    let r = tmp.path().join("repo");
484
    assert_eq!(oxy(tmp.path(), &home, &["init", r.to_str().unwrap()]).0, 0);
485
    for f in ["a", "b", "c"] {
486
        write(&r, &format!("{f}.txt"), &format!("{f}\n"));
487
        assert_eq!(oxy(&r, &home, &["commit", "-m", f]).0, 0);
488
    }
489
    let bms = json(&r, &home, &["bookmark", "list", "--json"]);
490
    assert_eq!(
491
        bms[0]["heads"].as_array().unwrap().len(),
492
        3,
493
        "independent: {bms}"
494
    );
495
    let log = json(&r, &home, &["log", "--json"]);
496
    let hash = |m: &str| {
497
        let c = log["changes"]
498
            .as_array()
499
            .unwrap()
500
            .iter()
501
            .find(|c| c["message"] == m)
502
            .unwrap();
503
        format!("#{}", &c["hash"].as_str().unwrap()[..12])
504
    };
505
    let b = hash("b");
506
507
    let (code, _, err) = oxy(&r, &home, &["bookmark", "create", "x", &b]);
508
    assert_ne!(code, 0);
509
    assert!(
510
        err.contains("would lack 1 change") && err.contains("--upto") && err.contains("--only"),
511
        "{err}"
512
    );
513
514
    assert_eq!(
515
        oxy(&r, &home, &["bookmark", "create", "upto", &b, "--upto"]).0,
516
        0
517
    );
518
    assert_eq!(
519
        oxy(&r, &home, &["bookmark", "create", "only", &b, "--only"]).0,
520
        0
521
    );
522
    let bms = json(&r, &home, &["bookmark", "list", "--json"]);
523
    let heads = |n: &str| {
524
        bms.as_array()
525
            .unwrap()
526
            .iter()
527
            .find(|x| x["name"] == n)
528
            .unwrap()["heads"]
529
            .as_array()
530
            .unwrap()
531
            .len()
532
    };
533
    assert_eq!(heads("upto"), 2, "a and b: {bms}");
534
    assert_eq!(heads("only"), 1);
535
536
    // Switching to the smaller state says what it leaves out.
537
    let (code, out, err) = oxy(&r, &home, &["bookmark", "switch", "only"]);
538
    assert_eq!(code, 0, "{err}");
539
    let out = String::from_utf8_lossy(&out);
540
    assert!(out.contains("only lacks 2 changes that main has"), "{out}");
541
    assert!(!r.join("a.txt").exists() && r.join("b.txt").exists());
542
543
    // Moving a bookmark asks the same (metalgear, oxygen#42): main at b
544
    // alone would lose a, which it holds and was recorded before b.
545
    let (code, _, err) = oxy(&r, &home, &["bookmark", "set", "main", &b]);
546
    assert_ne!(code, 0, "b is a head main holds, not a move forward");
547
    assert!(
548
        err.contains("would lose 1 change") && err.contains("--upto"),
549
        "{err}"
550
    );
551
    assert_eq!(
552
        oxy(&r, &home, &["bookmark", "set", "main", &b, "--upto"]).0,
553
        0
554
    );
555
    let bms = json(&r, &home, &["bookmark", "list", "--json"]);
556
    let main = bms
557
        .as_array()
558
        .unwrap()
559
        .iter()
560
        .find(|x| x["name"] == "main")
561
        .unwrap();
562
    assert_eq!(main["heads"].as_array().unwrap().len(), 2, "a and b: {bms}");
563
564
    // A fast-forward (oxygen#78): y holds a and b; b2 is built on b, its
565
    // newest head. Moving y to b2 keeps a, which b2 does not depend on.
566
    assert_eq!(
567
        oxy(&r, &home, &["bookmark", "switch", "main", "--discard"]).0,
568
        0
569
    );
570
    assert_eq!(oxy(&r, &home, &["bookmark", "create", "y"]).0, 0);
571
    write(&r, "b.txt", "b2\n");
572
    assert_eq!(oxy(&r, &home, &["commit", "-m", "b2"]).0, 0);
573
    let log = json(&r, &home, &["log", "--json"]);
574
    let b2 = log["changes"]
575
        .as_array()
576
        .unwrap()
577
        .iter()
578
        .find(|c| c["message"] == "b2")
579
        .unwrap()["hash"]
580
        .as_str()
581
        .unwrap()[..12]
582
        .to_string();
583
    let (code, out, err) = oxy(&r, &home, &["bookmark", "set", "y", &format!("#{b2}")]);
584
    assert_eq!(code, 0, "{err}");
585
    assert!(
586
        String::from_utf8_lossy(&out).contains("builds on y's newest head"),
587
        "says why"
588
    );
589
    let bms = json(&r, &home, &["bookmark", "list", "--json"]);
590
    let y = bms
591
        .as_array()
592
        .unwrap()
593
        .iter()
594
        .find(|x| x["name"] == "y")
595
        .unwrap();
596
    assert_eq!(y["heads"].as_array().unwrap().len(), 2, "a and b2: {bms}");
597
}
598
599
/// oxygen#29: a pull on a feature bookmark said only "Already up to date"
600
/// while main was behind. Since oxygen#48 it updates main too, and says so.
601
#[test]
602
fn pull_names_its_bookmark_and_the_others_it_moved() {
603
    let tmp = tempfile::tempdir().unwrap();
604
    let home = tmp.path().join("home");
605
    std::fs::create_dir_all(&home).unwrap();
606
    let origin = tmp.path().join("origin");
607
    assert_eq!(
608
        oxy(tmp.path(), &home, &["init", origin.to_str().unwrap()]).0,
609
        0
610
    );
611
    let (a, b) = (tmp.path().join("a"), tmp.path().join("b"));
612
    for d in [&a, &b] {
613
        assert_eq!(
614
            oxy(
615
                tmp.path(),
616
                &home,
617
                &["clone", origin.to_str().unwrap(), d.to_str().unwrap()]
618
            )
619
            .0,
620
            0
621
        );
622
    }
623
    write(&a, "one.txt", "one\n");
624
    assert_eq!(oxy(&a, &home, &["commit", "-m", "one"]).0, 0);
625
    assert_eq!(oxy(&a, &home, &["push"]).0, 0);
626
    assert_eq!(oxy(&b, &home, &["pull"]).0, 0);
627
    assert_eq!(oxy(&b, &home, &["bookmark", "create", "feat"]).0, 0);
628
    assert_eq!(oxy(&b, &home, &["bookmark", "switch", "feat"]).0, 0);
629
    assert_eq!(oxy(&b, &home, &["push", "-b", "feat"]).0, 0);
630
    write(&a, "two.txt", "two\n");
631
    assert_eq!(oxy(&a, &home, &["commit", "-m", "two"]).0, 0);
632
    assert_eq!(oxy(&a, &home, &["push"]).0, 0);
633
634
    let (code, out, err) = oxy(&b, &home, &["pull"]);
635
    assert_eq!(code, 0, "{err}");
636
    let out = String::from_utf8_lossy(&out);
637
    assert!(out.contains("feat is up to date with origin"), "{out}");
638
    assert!(out.contains("Also updated main"), "{out}");
639
    assert!(!out.contains("behind"), "{out}");
640
}
641
642
/// oxygen#35: a clean status is answered from the manifest, loading no
643
/// history; any change, a mode change included, falls back to a snapshot.
644
#[test]
645
fn a_clean_status_needs_no_history_and_misses_no_change() {
646
    use std::os::unix::fs::PermissionsExt;
647
    let tmp = tempfile::tempdir().unwrap();
648
    let home = tmp.path().join("home");
649
    std::fs::create_dir_all(&home).unwrap();
650
    let r = tmp.path().join("repo");
651
    assert_eq!(oxy(tmp.path(), &home, &["init", r.to_str().unwrap()]).0, 0);
652
    let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30);
653
    for f in ["a.txt", "b.txt"] {
654
        write(&r, f, &format!("{f}\n"));
655
        std::fs::File::options()
656
            .write(true)
657
            .open(r.join(f))
658
            .unwrap()
659
            .set_modified(old)
660
            .unwrap();
661
    }
662
    assert_eq!(oxy(&r, &home, &["commit", "-m", "two"]).0, 0);
663
    let status = |r: &Path| {
664
        let out = Command::new(env!("CARGO_BIN_EXE_oxy"))
665
            .args(["--plain", "status"])
666
            .current_dir(r)
667
            .env("OXY_HOME", &home)
668
            .env("NO_COLOR", "1")
669
            .env("OXY_TIMINGS", "1")
670
            .output()
671
            .unwrap();
672
        (
673
            String::from_utf8_lossy(&out.stdout).to_string(),
674
            String::from_utf8_lossy(&out.stderr).to_string(),
675
        )
676
    };
677
    let (out, err) = status(&r);
678
    assert!(out.contains("working copy clean"), "{out}");
679
    assert!(!err.contains("snapshot"), "no snapshot was needed: {err}");
680
681
    // A mode change alters neither size nor time.
682
    std::fs::set_permissions(r.join("a.txt"), std::fs::Permissions::from_mode(0o755)).unwrap();
683
    let (out, err) = status(&r);
684
    assert!(out.contains("a.txt"), "{out}");
685
    assert!(err.contains("snapshot"), "{err}");
686
    std::fs::set_permissions(r.join("a.txt"), std::fs::Permissions::from_mode(0o644)).unwrap();
687
688
    // A new file.
689
    write(&r, "c.txt", "c\n");
690
    let (out, _) = status(&r);
691
    assert!(out.contains("c.txt"), "{out}");
692
}
693
694
#[test]
695
fn a_nested_git_repository_is_named_not_silently_skipped() {
696
    let tmp = tempfile::tempdir().unwrap();
697
    let home = tmp.path().join("home");
698
    std::fs::create_dir_all(&home).unwrap();
699
    let r = tmp.path().join("repo");
700
    std::fs::create_dir_all(&r).unwrap();
701
    assert_eq!(oxy(&r, &home, &["init", "."]).0, 0);
702
    write(&r, "a.txt", "a\n");
703
    write(&r, "rust-core/src/lib.rs", "pub fn f() {}\n");
704
    write(&r, "rust-core/.git/HEAD", "ref: refs/heads/main\n");
705
    let st = json(&r, &home, &["status", "--json"]);
706
    assert_eq!(st["untracked_nested"], serde_json::json!(["rust-core"]));
707
    let (code, out, err) = oxy(&r, &home, &["commit", "-m", "first"]);
708
    assert_eq!(code, 0, "{err}");
709
    let out = String::from_utf8_lossy(&out);
710
    assert!(
711
        out.contains("rust-core/") && out.contains("nested Git repository"),
712
        "{out}"
713
    );
714
    // The clean status says so too.
715
    let (_, out, _) = oxy(&r, &home, &["status"]);
716
    let out = String::from_utf8_lossy(&out);
717
    assert!(
718
        out.contains("working copy clean") && out.contains("rust-core/"),
719
        "{out}"
720
    );
721
}
722
723
/// oxygen#50: the log is a graph. Two changes that build on the same one
724
/// take two lanes that join above it; each change shows its ID, author,
725
/// time and hash on one line and its subject on the next.
726
#[test]
727
fn the_log_draws_lanes_that_join() {
728
    let tmp = tempfile::tempdir().unwrap();
729
    let home = tmp.path().join("home");
730
    std::fs::create_dir_all(&home).unwrap();
731
    let repo = tmp.path().join("r");
732
    assert_eq!(
733
        oxy(tmp.path(), &home, &["init", repo.to_str().unwrap()]).0,
734
        0
735
    );
736
    write(&repo, "a.txt", "a\n");
737
    write(&repo, "b.txt", "b\n");
738
    assert_eq!(oxy(&repo, &home, &["commit", "-m", "both files"]).0, 0);
739
    write(&repo, "a.txt", "a\na2\n");
740
    assert_eq!(oxy(&repo, &home, &["commit", "-m", "edit a"]).0, 0);
741
    write(&repo, "b.txt", "b\nb2\n");
742
    assert_eq!(oxy(&repo, &home, &["commit", "-m", "edit b"]).0, 0);
743
744
    let (code, out, err) = oxy(&repo, &home, &["log"]);
745
    assert_eq!(code, 0, "{err}");
746
    let out = String::from_utf8_lossy(&out);
747
    let lines: Vec<&str> = out.lines().collect();
748
    // The working copy, then "edit b", "edit a" beside it, then their base.
749
    assert!(lines[0].starts_with('@'), "{out}");
750
    assert!(
751
        lines
752
            .iter()
753
            .any(|l| l.starts_with("|-'") && l.contains("edit a")),
754
        "{out}"
755
    );
756
    assert!(
757
        lines
758
            .iter()
759
            .any(|l| l.starts_with("| *") || l.starts_with("| o")),
760
        "{out}"
761
    );
762
    let base = lines.iter().position(|l| l.contains("both files")).unwrap();
763
    assert!(
764
        lines[base - 1].contains('#'),
765
        "the header line carries the hash: {out}"
766
    );
767
}