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 |