heddle-cli 0.4.0

An AI-native version control system
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
// SPDX-License-Identifier: Apache-2.0
//! End-to-end coverage for the `heddle visibility` verb family and the
//! Invariant-A (immutable-at-capture) default-tier binding (heddle#317).
//!
//! The conformance test (spike #266 §5.4) is the load-bearing one: a state
//! captured under a restrictive repo default must keep that tier even after
//! the default later drifts more-open. Resolution happens once, at capture —
//! never recomputed at serve time — so a config drift cannot retroactively
//! expose an already-captured state.
//!
//! These tests drive the CLI binary as a subprocess so they exercise the full
//! args → handler → repo → sidecar stack rather than poking at internals.

use std::{fs, path::Path};

use serde_json::Value;
use tempfile::TempDir;

use super::heddle;

/// Overwrite `.heddle/config.toml` with a minimal-but-valid config that pins
/// `[review.discussion] default_visibility` to `tier_toml`. Every other field
/// falls back to its serde default, so this is enough to drive capture-time
/// resolution deterministically. `tier_toml` is the raw TOML value for the
/// tier (e.g. `"\"Public\""` or `"{ Restricted = { scope_label = \"embargo\" } }"`).
fn set_repo_default_visibility(repo: &Path, tier_toml: &str) {
    let config_path = repo.join(".heddle/config.toml");
    let contents = format!(
        "[repository]\nversion = 1\n\n[review.discussion]\ndefault_visibility = {tier_toml}\n"
    );
    fs::write(&config_path, contents).expect("write repo config");
}

/// `heddle init` then capture one state; return the temp dir and the captured
/// state's change id (full).
fn init_and_capture(label: &str) -> (TempDir, String) {
    let temp = TempDir::new().unwrap();
    heddle(&["init"], Some(temp.path())).unwrap();
    fs::write(temp.path().join("note.txt"), label.as_bytes()).unwrap();
    (temp, String::new())
}

fn capture_state(temp: &Path, message: &str) -> String {
    heddle(&["capture", "-m", message], Some(temp)).expect("capture");
    let raw = heddle(&["--output", "json", "log", "--limit", "1"], Some(temp)).unwrap();
    let value: Value = serde_json::from_str(&raw).unwrap();
    value["states"][0]["change_id"]
        .as_str()
        .expect("log --output json should expose change_id")
        .to_string()
}

/// The change id of the current HEAD state (newest in the log).
fn latest_state(temp: &Path) -> String {
    let raw = heddle(&["--output", "json", "log", "--limit", "1"], Some(temp)).unwrap();
    let value: Value = serde_json::from_str(&raw).unwrap();
    value["states"][0]["change_id"]
        .as_str()
        .expect("log --output json should expose change_id")
        .to_string()
}

fn show_json(temp: &Path, state: &str) -> Value {
    let raw = heddle(
        &["--output", "json", "visibility", "show", state],
        Some(temp),
    )
    .expect("visibility show");
    serde_json::from_str(&raw).expect("visibility show output should be JSON")
}

#[test]
fn invariant_a_captured_tier_unchanged_when_default_drifts_public() {
    // Capture under a restrictive default…
    let (temp, _) = init_and_capture("secret");
    set_repo_default_visibility(
        temp.path(),
        "{ Restricted = { scope_label = \"embargo\" } }",
    );
    let state = capture_state(temp.path(), "captured under embargo");

    let before = show_json(temp.path(), &state);
    assert_eq!(before["output_kind"], "visibility_show");
    assert_eq!(
        before["tier"], "restricted",
        "state captured under a restricted default must resolve restricted: {before}"
    );
    assert_eq!(before["label"], "embargo");
    assert_eq!(before["effective_public"], false);

    // …drift the default wide open to public…
    set_repo_default_visibility(temp.path(), "\"Public\"");

    // …and the already-captured state's effective tier is unchanged. The
    // tier was bound at capture; serve-time never re-resolves from config.
    let after = show_json(temp.path(), &state);
    assert_eq!(
        after["tier"], "restricted",
        "drifting the default to public must NOT retroactively expose a captured state: {after}"
    );
    assert_eq!(after["label"], "embargo");
    assert_eq!(after["effective_public"], false);
}

#[test]
fn capture_still_applies_default_visibility() {
    // Capture funnels through the snapshot chokepoint where default visibility
    // is now bound; a non-public default must still stamp the captured state.
    let (temp, _) = init_and_capture("secret");
    set_repo_default_visibility(temp.path(), "\"Internal\"");
    let state = capture_state(temp.path(), "captured internal");

    let show = show_json(temp.path(), &state);
    assert_eq!(
        show["tier"], "internal",
        "capture must inherit the configured non-public default: {show}"
    );
    assert_eq!(show["effective_public"], false);
}

#[test]
fn cherry_pick_state_gets_default_visibility() {
    // PR #529 P1: the default-visibility binding was only on the `capture`
    // call site, so cherry-pick created a state left public even under a
    // non-public default. Now it funnels through the snapshot chokepoint.
    let temp = TempDir::new().unwrap();
    heddle(&["init"], Some(temp.path())).unwrap();

    fs::write(temp.path().join("note.txt"), b"base").unwrap();
    heddle(&["capture", "-m", "first"], Some(temp.path())).expect("capture first");
    let first = latest_state(temp.path());

    fs::write(temp.path().join("note.txt"), b"modified").unwrap();
    heddle(&["capture", "-m", "second"], Some(temp.path())).expect("capture second");

    // Pin a restrictive default, then cherry-pick the earlier state. The new
    // snapshot it commits must inherit the restricted tier — not stay public.
    set_repo_default_visibility(
        temp.path(),
        "{ Restricted = { scope_label = \"embargo\" } }",
    );
    heddle(&["cherry-pick", &first], Some(temp.path())).expect("cherry-pick");

    let new_state = latest_state(temp.path());
    let show = show_json(temp.path(), &new_state);
    assert_eq!(
        show["tier"], "restricted",
        "cherry-picked state must inherit the restricted default via the chokepoint: {show}"
    );
    assert_eq!(show["effective_public"], false);
}

#[test]
fn revert_state_gets_default_visibility() {
    // Sibling of the cherry-pick leak: a revert creates a new state too, and
    // must inherit the configured non-public default through the chokepoint.
    let temp = TempDir::new().unwrap();
    heddle(&["init"], Some(temp.path())).unwrap();

    fs::write(temp.path().join("note.txt"), b"base").unwrap();
    heddle(&["capture", "-m", "first"], Some(temp.path())).expect("capture first");

    fs::write(temp.path().join("note.txt"), b"modified").unwrap();
    heddle(&["capture", "-m", "second"], Some(temp.path())).expect("capture second");
    let second = latest_state(temp.path());

    set_repo_default_visibility(temp.path(), "\"Internal\"");
    heddle(&["revert", &second], Some(temp.path())).expect("revert");

    let new_state = latest_state(temp.path());
    let show = show_json(temp.path(), &new_state);
    assert_eq!(
        show["tier"], "internal",
        "reverted state must inherit the internal default via the chokepoint: {show}"
    );
    assert_eq!(show["effective_public"], false);
}

#[test]
fn undo_after_capture_with_nonpublic_default_reverts_snapshot_and_visibility_in_one_undo() {
    // PR #529 P1 regression: the automatic capture-time default-visibility
    // binding used to be its own trailing oplog batch, so the FIRST `undo` after
    // a capture restored only the sidecar and left the snapshot in place — undo
    // took two presses, and the automatic binding polluted undo history. Folding
    // the binding into the snapshot's own batch makes ONE `undo` revert the
    // snapshot AND its auto-applied default tier together.
    let temp = TempDir::new().unwrap();
    heddle(&["init"], Some(temp.path())).unwrap();

    fs::write(temp.path().join("note.txt"), b"base").unwrap();
    heddle(&["capture", "-m", "first"], Some(temp.path())).expect("capture first");
    let first = latest_state(temp.path());

    set_repo_default_visibility(temp.path(), "\"Internal\"");
    fs::write(temp.path().join("note.txt"), b"secret").unwrap();
    heddle(&["capture", "-m", "second"], Some(temp.path())).expect("capture second");
    let second = latest_state(temp.path());
    assert_ne!(first, second, "second capture is a distinct state");
    assert_eq!(
        show_json(temp.path(), &second)["tier"],
        "internal",
        "second capture inherits the non-public default"
    );

    // ONE undo must revert BOTH the snapshot (HEAD back to `first`) AND the
    // auto-applied visibility (the captured state reads public-by-absence again).
    heddle(&["undo"], Some(temp.path())).expect("undo capture");

    assert_eq!(
        latest_state(temp.path()),
        first,
        "one undo must revert the snapshot itself — HEAD back to the pre-capture state, \
         not just the sidecar"
    );
    let after = show_json(temp.path(), &second);
    assert_eq!(
        after["effective_public"], true,
        "the same single undo must also revert the auto-applied default visibility: {after}"
    );
    assert_eq!(after["tier"], "public");
}

#[test]
fn explicit_visibility_set_remains_its_own_undoable_batch() {
    // r2 preserved: an explicit `heddle visibility set` is a SEPARATE undoable
    // batch from the capture — only the AUTOMATIC capture-time default binding
    // folds into the snapshot batch. One undo of the set reverts the tier and
    // leaves the captured snapshot in place as HEAD.
    let (temp, _) = init_and_capture("ordinary"); // public default ⇒ capture has no auto binding
    let state = capture_state(temp.path(), "ordinary capture");
    assert_eq!(
        show_json(temp.path(), &state)["effective_public"],
        true,
        "capture under the public default writes no visibility record"
    );

    heddle(
        &["visibility", "set", &state, "--tier", "internal"],
        Some(temp.path()),
    )
    .expect("visibility set");
    assert_eq!(show_json(temp.path(), &state)["tier"], "internal");

    // One undo reverts ONLY the explicit set; the snapshot stays HEAD (proving
    // the set is its own batch, distinct from the capture).
    heddle(&["undo"], Some(temp.path())).expect("undo set");
    assert_eq!(
        latest_state(temp.path()),
        state,
        "undo of the explicit set must NOT revert the capture — the set is its own batch"
    );
    let after = show_json(temp.path(), &state);
    assert_eq!(
        after["effective_public"], true,
        "the explicit set is reverted by its own undo: {after}"
    );
    assert_eq!(after["tier"], "public");
}

#[test]
fn public_default_capture_stays_record_free() {
    // The common case: with the default (public) tier, capture writes no
    // visibility record — absence ≡ public.
    let (temp, _) = init_and_capture("ordinary");
    let state = capture_state(temp.path(), "ordinary capture");

    let show = show_json(temp.path(), &state);
    assert_eq!(show["tier"], "public");
    assert_eq!(show["effective_public"], true);
}

#[test]
fn visibility_set_then_show_reports_tier() {
    let (temp, _) = init_and_capture("ordinary");
    let state = capture_state(temp.path(), "ordinary capture");

    let raw = heddle(
        &[
            "--output",
            "json",
            "visibility",
            "set",
            &state,
            "--tier",
            "internal",
        ],
        Some(temp.path()),
    )
    .expect("visibility set");
    let set: Value = serde_json::from_str(&raw).unwrap();
    assert_eq!(set["output_kind"], "visibility_set");
    assert_eq!(set["tier"], "internal");

    let show = show_json(temp.path(), &state);
    assert_eq!(show["tier"], "internal");
    assert_eq!(show["effective_public"], false);
}

#[test]
fn visibility_promote_supersedes_to_less_restrictive() {
    let (temp, _) = init_and_capture("ordinary");
    let state = capture_state(temp.path(), "ordinary capture");

    heddle(
        &[
            "visibility",
            "set",
            &state,
            "--tier",
            "restricted",
            "--label",
            "embargo",
        ],
        Some(temp.path()),
    )
    .expect("visibility set restricted");

    let raw = heddle(
        &[
            "--output",
            "json",
            "visibility",
            "promote",
            &state,
            "--tier",
            "internal",
        ],
        Some(temp.path()),
    )
    .expect("visibility promote");
    let promote: Value = serde_json::from_str(&raw).unwrap();
    assert_eq!(promote["output_kind"], "visibility_promote");
    assert_eq!(promote["tier"], "internal");

    let show = show_json(temp.path(), &state);
    assert_eq!(
        show["tier"], "internal",
        "promotion should be the effective tier"
    );
}

#[test]
fn undo_visibility_set_restores_prior_sidecar() {
    // PR #529 P1: undoing a standalone `visibility set` must revert the
    // sidecar, not just mark the oplog entry undone. A state that was
    // public-by-absence before the set must read public again after undo, then
    // re-tiered after redo.
    let (temp, _) = init_and_capture("ordinary");
    let state = capture_state(temp.path(), "ordinary capture");
    assert_eq!(
        show_json(temp.path(), &state)["effective_public"],
        true,
        "state starts public-by-absence"
    );

    heddle(
        &["visibility", "set", &state, "--tier", "internal"],
        Some(temp.path()),
    )
    .expect("visibility set");
    assert_eq!(show_json(temp.path(), &state)["tier"], "internal");

    heddle(&["undo"], Some(temp.path())).expect("undo visibility set");
    let after_undo = show_json(temp.path(), &state);
    assert_eq!(
        after_undo["effective_public"], true,
        "undo must restore public-by-absence (sidecar removed): {after_undo}"
    );
    assert_eq!(after_undo["tier"], "public");

    heddle(&["undo", "--redo"], Some(temp.path())).expect("redo visibility set");
    let after_redo = show_json(temp.path(), &state);
    assert_eq!(
        after_redo["tier"], "internal",
        "redo must reapply the set tier: {after_redo}"
    );
    assert_eq!(after_redo["effective_public"], false);
}

#[test]
fn undo_visibility_set_restores_previous_nonpublic() {
    // When a state already carries a non-public tier, undoing a later `set`
    // must restore the PREVIOUS non-public tier, not drop to public-by-absence.
    let (temp, _) = init_and_capture("ordinary");
    let state = capture_state(temp.path(), "ordinary capture");

    heddle(
        &[
            "visibility",
            "set",
            &state,
            "--tier",
            "team-scoped",
            "--label",
            "infra",
        ],
        Some(temp.path()),
    )
    .expect("set team-scoped");
    assert_eq!(show_json(temp.path(), &state)["tier"], "team_scoped");

    heddle(
        &["visibility", "set", &state, "--tier", "internal"],
        Some(temp.path()),
    )
    .expect("set internal");
    assert_eq!(show_json(temp.path(), &state)["tier"], "internal");

    heddle(&["undo"], Some(temp.path())).expect("undo second set");
    let after = show_json(temp.path(), &state);
    assert_eq!(
        after["tier"], "team_scoped",
        "undo must restore the previous non-public tier, not absence: {after}"
    );
    assert_eq!(after["effective_public"], false);
    assert_eq!(after["label"], "infra");
}

#[test]
fn undo_visibility_promote_reverts_tier() {
    // A promote appends a superseding record; undo must drop back to the
    // pre-promote effective tier.
    let (temp, _) = init_and_capture("ordinary");
    let state = capture_state(temp.path(), "ordinary capture");

    heddle(
        &[
            "visibility",
            "set",
            &state,
            "--tier",
            "restricted",
            "--label",
            "embargo",
        ],
        Some(temp.path()),
    )
    .expect("set restricted");
    assert_eq!(show_json(temp.path(), &state)["tier"], "restricted");

    heddle(
        &["visibility", "promote", &state, "--tier", "internal"],
        Some(temp.path()),
    )
    .expect("promote to internal");
    assert_eq!(show_json(temp.path(), &state)["tier"], "internal");

    heddle(&["undo"], Some(temp.path())).expect("undo promote");
    let after_undo = show_json(temp.path(), &state);
    assert_eq!(
        after_undo["tier"], "restricted",
        "undo of promote must revert to the pre-promote tier: {after_undo}"
    );
    assert_eq!(after_undo["label"], "embargo");

    heddle(&["undo", "--redo"], Some(temp.path())).expect("redo promote");
    assert_eq!(
        show_json(temp.path(), &state)["tier"],
        "internal",
        "redo of promote must re-apply the promoted tier"
    );
}

#[test]
fn visibility_list_enumerates_tiered_states() {
    let (temp, _) = init_and_capture("ordinary");
    let state = capture_state(temp.path(), "ordinary capture");
    heddle(
        &["visibility", "set", &state, "--tier", "internal"],
        Some(temp.path()),
    )
    .expect("visibility set");

    let raw = heddle(
        &["--output", "json", "visibility", "list"],
        Some(temp.path()),
    )
    .expect("visibility list");
    let listing: Value = serde_json::from_str(&raw).unwrap();
    assert_eq!(listing["output_kind"], "visibility_list");
    assert_eq!(listing["count"], 1);
    assert_eq!(listing["states"][0]["tier"], "internal");
}