OXYGENHUB

Name moved subrepo pins in status and commit; changelog on the WASM tier (closes #162) 232aabb5452d

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 3d63024a1a60
When
· 2026-11-04 01:01 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
232aabb5452d64b37861ebe61aaabacb012e4ae041f141bc0a51e00d4c8986a5
Depends on
135212de5370 284fe47d98b9 2b668d92b066 3604179679eb 75167672ebcd 8fd78eb52ad2 957fee30826e aded4110f0bd c6aa5bd9d1c8 d4dbab97e948 fce44ae9ad29
Depended on by
nothing yet
In bookmarks
main
In tags
v0.11.0
Edits
9 atoms, 2 tree operations, 5 files

Browse the tree at this change

added .flow/checks.wasm +0 −0

No line changes.

added tests/cli_subrepo.rs +94 −0
1 +
//! oxygen#162: a subrepo pin moved by work inside the subrepo is a change
2 +
//! the parent's next commit records; `oxy status` and `oxy commit` say so,
3 +
//! and say when a subrepo holds uncommitted edits of its own.
4 +
5 +
use std::path::Path;
6 +
use std::process::Command;
7 +
8 +
fn oxy(dir: &Path, home: &Path, args: &[&str]) -> (i32, 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 +
    let text = format!(
18 +
        "{}{}",
19 +
        String::from_utf8_lossy(&out.stdout),
20 +
        String::from_utf8_lossy(&out.stderr)
21 +
    );
22 +
    (out.status.code().unwrap_or(-1), text)
23 +
}
24 +
25 +
fn ok(dir: &Path, home: &Path, args: &[&str]) -> String {
26 +
    let (code, out) = oxy(dir, home, args);
27 +
    assert_eq!(code, 0, "oxy {args:?}: {out}");
28 +
    out
29 +
}
30 +
31 +
#[test]
32 +
fn moved_pins_and_edits_inside_subrepos_are_named() {
33 +
    let tmp = tempfile::tempdir().unwrap();
34 +
    let t = tmp.path();
35 +
    let home = t.join("home");
36 +
    std::fs::create_dir_all(&home).unwrap();
37 +
    ok(
38 +
        t,
39 +
        &home,
40 +
        &[
41 +
            "identity",
42 +
            "create",
43 +
            "--name",
44 +
            "Ada",
45 +
            "--email",
46 +
            "[email protected]",
47 +
        ],
48 +
    );
49 +
    // The subrepo's own repository, with one change.
50 +
    let lib = t.join("lib");
51 +
    ok(t, &home, &["init", lib.to_str().unwrap()]);
52 +
    std::fs::write(lib.join("lib.txt"), "one\n").unwrap();
53 +
    ok(&lib, &home, &["commit", "-m", "lib one"]);
54 +
    // The parent pins it.
55 +
    let app = t.join("app");
56 +
    ok(t, &home, &["init", app.to_str().unwrap()]);
57 +
    std::fs::write(app.join("app.txt"), "app\n").unwrap();
58 +
    ok(
59 +
        &app,
60 +
        &home,
61 +
        &["submodule", "add", lib.to_str().unwrap(), "deps/lib"],
62 +
    );
63 +
    ok(&app, &home, &["commit", "-m", "app with lib"]);
64 +
    let quiet = ok(&app, &home, &["status"]);
65 +
    assert!(!quiet.contains("Subrepo pins"), "{quiet}");
66 +
67 +
    // Edits inside the subrepo, not committed there: named, not a pin move.
68 +
    let inner = app.join("deps/lib");
69 +
    std::fs::write(inner.join("lib.txt"), "two\n").unwrap();
70 +
    let st = ok(&app, &home, &["status"]);
71 +
    assert!(
72 +
        st.contains("Subrepo pins") && st.contains("uncommitted edits inside it"),
73 +
        "{st}"
74 +
    );
75 +
    assert!(!st.contains("pin moved"), "{st}");
76 +
77 +
    // Committed inside: the pin has moved, and a bare commit says it records it.
78 +
    ok(&inner, &home, &["commit", "-m", "lib two"]);
79 +
    let st = ok(&app, &home, &["status"]);
80 +
    assert!(st.contains("deps/lib") && st.contains("pin moved"), "{st}");
81 +
    std::fs::write(app.join("app.txt"), "app 2\n").unwrap();
82 +
    let by_path = ok(&app, &home, &["commit", "-m", "app only", "app.txt"]);
83 +
    assert!(
84 +
        !by_path.contains("recorded subrepo"),
85 +
        "a commit by path leaves the pin out: {by_path}"
86 +
    );
87 +
    let bare = ok(&app, &home, &["commit", "-m", "everything"]);
88 +
    assert!(
89 +
        bare.contains("recorded subrepo deps/lib's pin moving") && bare.contains("a pin bump"),
90 +
        "{bare}"
91 +
    );
92 +
    let after = ok(&app, &home, &["status"]);
93 +
    assert!(!after.contains("Subrepo pins"), "recorded: {after}");
94 +
}
modified .flow/pipeline.kdl +17 −0
…
2 unchanged lines
3 3
// Earth; the jobs appear as checks on the change.
4 4
schema-version 1
5 5
6 +
// Cheap checks on the WASM tier (oxygenflow#96): OxygenFlow's checks module,
7 +
// copied from oxygenflow/.flow/checks.wasm (built there from
8 +
// crates/flow-checks; copy it again when it changes). No image and no KVM,
9 +
// so any runner with the WASM tier takes it: a phone, plex, windows-1.
10 +
sandbox "wasm" {
11 +
    runtime "wasm"
12 +
    memory-limit "64MiB"
13 +
}
14 +
15 +
// The changelog's shape, and a section for the version Cargo.toml says, so a
16 +
// release never finds its notes missing.
17 +
job "changelog" {
18 +
    sandbox "wasm"
19 +
    scope "CHANGELOG.md" "Cargo.toml" ".flow/checks.wasm"
20 +
    exec ".flow/checks.wasm" "changelog" "CHANGELOG.md" "Cargo.toml"
21 +
}
22 +
6 23
// The OxygenFlow build image: Rust with rustfmt and clippy, Node, and every
7 24
// crate of Cargo.lock vendored (oxygenflow/images/rust). Rebuild and re-pin
8 25
// it when Cargo.lock changes.
…
135 unchanged lines
modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Changed: the changelog is checked on the WASM tier.** A `changelog` job in the pipeline runs OxygenFlow's checks module on any runner with the WASM tier, failing when the changelog's sections are out of shape or Cargo.toml's version has none.
6 +
- **Changed: moved subrepo pins are named (#162).** A pin moved by work inside a subrepo is a pin bump the parent's next commit records, and it used to go in unsaid. `oxy status` now lists it under *Subrepo pins* (old → new), and a subrepo's own uncommitted edits, which the parent does not record; a bare `oxy commit` that records a moved pin says so and how to leave it out (`oxy commit PATHS`).
5 7
- **Added: `oxy submodule remove --delete` (#164).** It deletes the subrepo's directory too, read-only object files and all, and refuses while the subrepo has uncommitted edits (`--force` to delete them anyway); without it, removal says the directory is left and how to delete it. A directory moved by hand used to fail halfway, after the copy, on the read-only files.
6 8
- **Changed: an agent's commit says "awaiting seal", not "unsigned" (#165).** In a checkout that signs keyless under an agent grant, a change of its own author that the next push will seal shows `◌ awaiting seal` in `oxy log` and `oxy show`; "unsigned" is kept for changes nothing will sign.
7 9
- **Fixed: a stale `author.key` can no longer fork your identity (#163).** A checkout that pinned `author.key` to a key a rotation made elsewhere had already succeeded built a second successor of it on `oxy identity delegate add`, splitting the identity in two. `rotate`, `renew` and `delegate add`/`remove` now refuse to start from a key that already has a successor in your home, naming the newest key and the `oxy config` that points at it; their report says which key they started from and which config named it; and `oxy identity show` warns when the configured key is not the newest, and names the key where an identity forks.
…
317 unchanged lines
modified src/cli/workspace.rs +101 −0
…
243 unchanged lines
244 244
        );
245 245
        print_behind(&repo);
246 246
        print_statuses(&statuses);
247 +
        print_pin_moves(&pin_moves(&repo, None));
247 248
        print_untracked_nested(&repo, &repo.untracked_nested());
248 249
        print_skipped_large(&repo);
249 250
        return Ok(0);
…
25 unchanged lines
275 276
    );
276 277
    print_behind(&repo);
277 278
    print_statuses(&snap.statuses);
279 +
    print_pin_moves(&pin_moves(&repo, None));
278 280
    print_untracked_nested(&repo, &snap.untracked_nested);
279 281
    print_skipped_large(&repo);
280 282
    if !snap.base_snapshot.is_conflicted() {
…
140 unchanged lines
421 423
    } else {
422 424
        oxy_core::repo::Selection::Paths(paths.to_vec())
423 425
    };
426 +
    // Pins this commit records as moved, said after it (oxygen#162): a pin
427 +
    // moved by work inside a subrepo is a pin bump, easy to make unasked.
428 +
    let moves = match &selection {
429 +
        oxy_core::repo::Selection::All => pin_moves(&repo, None),
430 +
        oxy_core::repo::Selection::Paths(p) => pin_moves(&repo, Some(p)),
431 +
        oxy_core::repo::Selection::Hunks(_) => Vec::new(),
432 +
    };
424 433
    let sp = ui::spinner("Committing…");
425 434
    let (h, partial) = repo.commit_selection_partial(message, &deps, &selection)?;
426 435
    // The metadata only: a first commit's change holds the whole tree.
…
5 unchanged lines
432 441
        ui::hash(format!("#{}", h.short(12))),
433 442
        ui::subject(subject_of(message))
434 443
    ));
444 +
    for m in &moves {
445 +
        let line = match (&m.to, m.dirty) {
446 +
            (Some(to), _) => format!(
447 +
                "recorded subrepo {}'s pin moving {} {} {}: a pin bump. Meant only your own files? `oxy commit PATHS` leaves pins out",
448 +
                m.path,
449 +
                m.from,
450 +
                ui::glyph("→", "->"),
451 +
                to
452 +
            ),
453 +
            (None, true) => format!(
454 +
                "subrepo {} has uncommitted edits of its own: the pin names its last committed state; the edits stay in it",
455 +
                m.path
456 +
            ),
457 +
            (None, false) => continue,
458 +
        };
459 +
        println!("  {} {}", ui::caution(), ui::warn(line));
460 +
    }
435 461
    print_untracked_nested(&repo, &repo.untracked_nested());
436 462
    print_skipped_large(&repo);
437 463
    // Only a commit that took part of the working copy leaves anything.
…
12 unchanged lines
450 476
    }
451 477
    where_now(&repo);
452 478
    Ok(0)
479 +
}
480 +
481 +
/// A subrepo whose pin the next commit would move (`to`), or which has
482 +
/// uncommitted edits of its own (oxygen#162).
483 +
struct PinMove {
484 +
    path: String,
485 +
    from: String,
486 +
    to: Option<String>,
487 +
    dirty: bool,
488 +
}
489 +
490 +
/// The subrepos (among `paths`, when given) whose checkout is not at the
491 +
/// pin the base records, or holds uncommitted edits. Only a checkout with
492 +
/// subrepos (an `.oxymodules`) pays for the look.
493 +
fn pin_moves(repo: &oxy_core::repo::Repo, paths: Option<&[String]>) -> Vec<PinMove> {
494 +
    if !repo.root().join(".oxymodules").exists() {
495 +
        return Vec::new();
496 +
    }
497 +
    let Ok(subs) = repo.subrepos() else {
498 +
        return Vec::new();
499 +
    };
500 +
    let chosen = |p: &str| {
501 +
        paths.is_none_or(|ps| {
502 +
            ps.iter().any(|x| {
503 +
                let x = x.trim_end_matches('/');
504 +
                p == x || p.starts_with(&format!("{x}/")) || x.is_empty() || x == "."
505 +
            })
506 +
        })
507 +
    };
508 +
    subs.into_iter()
509 +
        .filter(|s| s.committed && chosen(&s.path))
510 +
        .filter_map(|s| {
511 +
            let to = s
512 +
                .actual
513 +
                .as_ref()
514 +
                .filter(|a| !a.same_state(&s.pin))
515 +
                .map(|a| a.summary());
516 +
            (to.is_some() || s.dirty).then(|| PinMove {
517 +
                from: s.pin.summary(),
518 +
                to,
519 +
                dirty: s.dirty,
520 +
                path: s.path,
521 +
            })
522 +
        })
523 +
        .collect()
524 +
}
525 +
526 +
/// `oxy status`'s word on subrepos: a moved pin is a change the next commit
527 +
/// records; edits inside a subrepo are not.
528 +
fn print_pin_moves(moves: &[PinMove]) {
529 +
    if moves.is_empty() {
530 +
        return;
531 +
    }
532 +
    println!("{}", ui::section("Subrepo pins"));
533 +
    for m in moves {
534 +
        if let Some(to) = &m.to {
535 +
            println!(
536 +
                "  {} {}  {} {} {}  {}",
537 +
                ui::warn(ui::glyph("↕", "<>")),
538 +
                ui::path(&m.path),
539 +
                ui::dim(&m.from),
540 +
                ui::dim(ui::glyph("→", "->")),
541 +
                to,
542 +
                ui::dim("pin moved: a bare `oxy commit` records it")
543 +
            );
544 +
        }
545 +
        if m.dirty {
546 +
            println!(
547 +
                "  {} {}  {}",
548 +
                ui::warn(ui::glyph("✎", "~")),
549 +
                ui::path(&m.path),
550 +
                ui::dim("uncommitted edits inside it: commit them there; the parent records only its pin")
551 +
            );
552 +
        }
553 +
    }
453 554
}
454 555
455 556
pub fn blame(path: &str, json: bool) -> Result<i32> {
…
2667 unchanged lines