OXYGENHUB

Spec Draft 0.8: subrepositories, squash and rebase, multiple bookmarks and merge, partial commits, blame and bisect; phase 2 plan 86fe29fd62ea

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 12:51 UTC
Signature
✔ signed by Andy McCall
Hash
86fe29fd62ea9ca0567f8fa4a7df65ca25281bafaff0c2e0147cf72bac52a850
Depends on
30ea9c1e70fa 50a046e439e9 5b1114a04905 86e682c242c6 96f7610fa666 ff0af1ee299d
Depended on by
69953b507188 e266b7d4065c 1a3b62032ed9 6f8aeb996558 83d61e803ccd e7ac6c2e84e4 61e3bce7ea5b c6aa5bd9d1c8 8b678b298fee 728da0f92161 957fee30826e 4dee277c327a fbda43e7d8ed
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
14 atoms, 0 tree operations, 2 files

Browse the tree at this change

modified docs/plan.md +13 −0
…
35 unchanged lines
36 36
- [x] Documentation: `docs/guide.md` (user guide by task) and `docs/protocol.md` (for other implementers, derived from spec §16 and the wire encodings). (Done; every command and flag in the guide was checked against `--help`.)
37 37
- [x] Spec review pass: resolve or re-word every "MAY ... until" allowance introduced during implementation, and close what can be closed in §27. (Done as Draft 0.7: no "MAY ... until" wording remained; `oxy status --json` implemented to meet §9.5; §12.4, §16.1 and §24 aligned with the implementation; new §26.1 status table; §27 records workspace conflicts and server locks as open.)
38 38
39 +
## Phase 2 (2026-09-27, Andy's request: the everyday tools Git has)
40 +
41 +
Rules as above. Spec Draft 0.8 defines each item; implement in this order, each with tests, a gate, and an oxy commit.
42 +
43 +
- [ ] Multiple bookmarks (spec §10.1): `oxy bookmark create|set|delete|list|switch`, per-workspace current bookmark, `oxy merge BOOKMARK`, `oxy checkout NAME`, bookmark names resolved before change prefixes; push/pull/log act on the current bookmark; `oxy push -b`.
44 +
- [ ] Subrepositories (spec §7.8, §23): node kind `subrepo`, pin encoding, `.oxymodules`, snapshot stops at nested repositories and reads their pin, checkout moves or leaves nested repositories, `oxy submodule add|update|status|remove`; Git import/export of gitlinks and `.gitmodules` (test with a mirror of tetriblox, never the live repository).
45 +
- [ ] Partial commits (spec §12.3): `oxy commit PATH..` and `oxy commit --interactive` (hunk = one edit or tree operation, dependent hunks pulled in), sharing the split machinery.
46 +
- [ ] Blame (spec §27): `oxy blame PATH [--json]` from atom keys, with author and date; renames followed.
47 +
- [ ] Bisect (spec §27): `oxy bisect start|good|bad|run|reset` over dependency-closed prefixes; state under `.oxy/bisect/`.
48 +
- [ ] Squash (spec §8.5): `oxy squash CHANGE --into DEST` generalising `amend --into`, with rewrite propagation.
49 +
- [ ] Rebase (spec §8.5): `oxy rebase CHANGE --onto HEADS..` re-deriving edits against a new base; refused on conflicted files.
50 +
- [ ] Guide, README and §26.1 updated for each; spec changelog per item.
51 +
39 52
## Log
40 53
41 54
- 2026-09-26 23:01 plan created.
…
18 unchanged lines
modified docs/spec.md +42 −7
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.7 |
5 +
| **Status** | Draft 0.8 |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
171 unchanged lines
180 180
|---|---|
181 181
| `TreeOp` | Map with key 0 = variant tag: 0 `AddNode`, 1 `RemoveNode`, 2 `Move`, 3 `SetMode`; key 1 = node id; `AddNode` adds 2 kind, 3 mode, 4 parent, 5 name; `RemoveNode` adds 2 placement ref; `Move` adds 2 old placement ref, 4 parent, 5 name; `SetMode` adds 2 old mode ref, 3 mode. |
182 182
| `Edit` | Map with key 0 = variant tag: 0 `Insert`, 1 `Delete`, 2 `Replace`; key 1 = node id; `Insert` adds 2 after, 3 before, 4 lines (array of byte strings) or 5 `lines_blob`; `Delete` adds 2 atom ranges; `Replace` adds 2 old (bytes or null), 3 new (bytes or null). |
183 -
| `NodeKind` | Unsigned integer: 0 file, 1 dir, 2 symlink. |
183 +
| `NodeKind` | Unsigned integer: 0 file, 1 dir, 2 symlink, 3 subrepo (§7.8). |
184 +
| Subrepo pin | The opaque content of a `subrepo` node: a CBOR map `{0: 1, 1: kind, 2: locator, 3: pin, 4: bookmark}` where `kind` is 0 for an Oxygen repository (`locator` = repository id bytes(16), `pin` = a state object's heads as `[ChangeHash]`) or 1 for a Git repository (`locator` = null, `pin` = commit sha as text); `bookmark` is the branch or bookmark name the pin was taken from. |
184 185
| `Mode` | Unsigned integer bit field: bit 0 executable, bit 1 opaque content mode. Directories carry 0. |
185 186
| `OpRef` (placement or mode reference) | `[bytes(32) change hash, uint index]`; the all-zero hash means this change. |
186 187
| `AtomRef` | `0` for `START`, `1` for `END`, otherwise `[bytes(32) change hash, uint index]` with the all-zero hash meaning this change. |
…
44 unchanged lines
231 232
232 233
### 7.1 Tree model
233 234
234 -
The repository tree is a set of nodes. Each node has a **NodeId**, a **kind** (`file`, `dir`, `symlink`), a **mode**, and zero or more **placements**. The mode of a file is its executable bit and its **content mode**, `text` or `opaque` (§7.5); a symlink's content is its target path and its content mode is always `opaque`; a directory has no mode. The root directory has the fixed NodeId of 16 zero bytes and no placement.
235 +
The repository tree is a set of nodes. Each node has a **NodeId**, a **kind** (`file`, `dir`, `symlink`, `subrepo`, §7.8), a **mode**, and zero or more **placements**. The mode of a file is its executable bit and its **content mode**, `text` or `opaque` (§7.5); a symlink's content is its target path and its content mode is always `opaque`; a directory has no mode. The root directory has the fixed NodeId of 16 zero bytes and no placement.
235 236
236 237
A placement is `(parent NodeId, name)`. Names are byte strings that MUST be valid UTF-8, MUST NOT be empty, `.`, or `..`, and MUST NOT contain `/` or NUL. Placements that a particular file system cannot represent distinctly, such as names differing only by case, are workspace conflicts (§9.7), not errors in the state.
237 238
…
55 unchanged lines
293 294
- `.oxyignore` and `.oxyattributes` files that are symlinks MUST be treated as absent, with a warning from `oxy status`.
294 295
- On file systems without symlink support, a symlink node is written as a plain file containing the target path, and `oxy status` reports it; snapshot MUST NOT convert such a file back into a content change.
295 296
297 +
### 7.8 Subrepositories
298 +
299 +
A **subrepo** node embeds another repository at a path, as Git's submodules do. Its content is a **pin**: which repository, and which state of it (§5.5, "Subrepo pin"). The pin is stored and versioned exactly like an opaque file, as a blob replaced by `Replace` edits, so two changes that move the same subrepo to different states produce an ordinary replace conflict (§9.3) that `oxy status` lists and a resolution settles. A subrepo has no placements of its own inside the parent beyond its one entry; its files belong to the nested repository and are never atoms of the parent.
300 +
301 +
Two pin kinds exist. An **Oxygen pin** names a repository id and a state (a sorted set of heads), and is verifiable: the nested repository's changes are fetched from its own remotes and checked under its own policy. A **Git pin** names a commit hash and is what `oxy git import` produces for a Git submodule (§23); it is checked out with Git and never converted silently.
302 +
303 +
The mapping from path to remote lives in `.oxymodules` at the repository root, a tracked file of `[path]` sections with `url` and optional `bookmark` (Oxygen) or `branch` (Git) keys, edited by `oxy submodule add|remove` and readable by hand. A pin without a `.oxymodules` entry is checked out only when the nested repository is already present.
304 +
305 +
In the working copy a subrepo is a directory holding its own `.oxy/` (or `.git/`). The snapshot MUST NOT descend into it; it reads the nested repository's current bookmark heads (or Git `HEAD`) as the pin, so committing the parent records whatever the nested repository is at, as in Git. `oxy checkout` of the parent MUST move an existing nested repository to the pinned state (`oxy checkout` of those heads, or `git checkout --detach <sha>`), fetching from the recorded remote when objects are missing, and MUST leave an absent nested repository absent until `oxy submodule update --init` clones it. A nested working copy with uncommitted changes is never overwritten: the checkout reports it and continues.
306 +
307 +
`oxy submodule add URL PATH [--bookmark NAME]` clones URL into PATH, records `.oxymodules`, and adds the node pinned at the clone's heads. `oxy submodule update [--init] [PATH..]` brings nested repositories to their pins. `oxy submodule status` lists each subrepo with its pin and whether the nested repository matches it. `oxy submodule remove PATH` removes the node and the `.oxymodules` entry; the directory is left in place.
308 +
296 309
## 8. Dependencies and commutation
297 310
298 311
### 8.1 Definition
…
20 unchanged lines
319 332
320 333
Rewriting a change `C` to `C'` (amend, rebase, split, squash) produces a new object with the same ChangeId. Amending an older change with the working copy's edits (`oxy amend --into C`) is allowed when every atom, placement, and node those edits reference exists in `C`'s closure; the edits are appended to `C` with references to `C` turned into self references, so `C`'s own atoms keep their indices and dependents are rewritten with an identity correspondence. Splitting a head (`oxy split C PATH..`) rebuilds it as a first change holding its effect on the named paths and a second holding the rest under the original ChangeId. Any change `D` with `hash(C) ∈ deps(D)` is orphaned relative to `C'`. `oxy` MUST automatically rewrite orphaned descendants by translating their AtomRefs and PlacementRefs from `C` to `C'` where a correspondence exists. Where none exists the reference is **reified by re-anchoring**: an insert anchored on a vanished atom is anchored instead on the nearest surviving atom of the same insert chain of `C` (the preceding one for `after`, the following one for `before`), or on that chain's own anchors when none survives, so that an order conflict (§9.1) arises exactly when `C'` placed other content there; a deletion of a vanished atom is dropped; an operation or edit on a node that vanished with `C` is dropped. Each adjustment MUST be reported to the user. Removing `C` altogether rewires each dependent to depend on `deps(C)` and reifies references into `C` the same way. Changes that do not depend on `C` are untouched, and a rewritten change by another author is stored without that author's signature, which only they can renew. Rewriting is recorded in the operation log (§12) and is undoable.
321 334
335 +
### 8.5 Folding and re-basing
336 +
337 +
Changes that commute never need re-basing to be combined, so `oxy rebase` and `oxy squash` are history-editing tools, not merge tools.
338 +
339 +
**Squash.** `oxy squash C --into D` folds change `C` into `D`, where `D` is a dependency of `C` and every atom, placement, and mode that `C` refers to in `D` is one `D` introduces or one both already know. The result `D'` carries `D`'s operations followed by `C`'s, with `C`'s references to `D` turned into self-references, keeps `D`'s ChangeId, and is signed afresh by its author; `C` is removed from the state and every change that depended on `C` or `D` is rewritten with the correspondence of §8.4. A `C` that depends on changes `D` does not depend on cannot be squashed into `D`; the command names them. `oxy amend --into` is the special case where `C` is the working copy.
340 +
341 +
**Rebase.** `oxy rebase C --onto HEADS` re-expresses `C` against a different base: the files `C` touches are materialized twice, from `closure(deps(C))` and from `closure(deps(C)) ∪ {C}`, the difference is re-derived as edits against the state `HEADS` names, and the new change keeps `C`'s ChangeId with dependencies computed from what it now references (plus `HEADS` as explicit dependencies). This is the move a Git user makes with `rebase --onto`: it changes what a change is said to build on. It is refused when `HEADS` conflicts in any file `C` touches, since the re-derivation would have no single text to diff against, and when the result would be empty. Dependents of `C` are rewritten per §8.4. Because commuting changes already coexist, the common reason to rebase is to drop an accidental dependency, for instance on a change that is to be abandoned.
342 +
322 343
## 9. Conflicts
323 344
324 345
### 9.1 Text conflicts
…
73 unchanged lines
398 419
State { version [0]: 1, heads [1]: [ChangeHash] }   // sorted, unique; may be empty
399 420
```
400 421
422 +
A repository holds any number of bookmarks. Each workspace has a **current bookmark** (`oxy bookmark switch NAME`, recorded per workspace) that its working copy sits on and that `commit`, `log`, `push`, and `pull` act on by default. `oxy bookmark create NAME [CHANGE..]` names a new state (default: the current working copy's base heads), `set NAME CHANGE..` moves one, `delete NAME` removes it (its changes stay until unreachable, §21), `list` shows all with their heads and the remote-tracking difference, and `oxy merge NAME` unions the named bookmark's heads into the current one, which is the whole of merging in this model: no merge change, conflicts reified (§9). `oxy checkout NAME` materializes a bookmark without switching to it. Bookmark names are resolved before change prefixes wherever a change is expected.
423 +
401 424
Every reference, attestation, view, and checkpoint therefore names a state by one ObjectRef however many heads it has. Multiple heads are normal in this model: every independent change is a head until something depends on it, so a busy bookmark may have dozens. That is not a conflict in the §9 sense; `oxy log` shows the heads as parallel, and a state object keeps the cost of naming them constant for everything that refers to the bookmark. The empty state, `State { heads: [] }`, is what a new bookmark points at.
402 425
403 426
### 10.2 Reference attestations (G2, G3)
…
57 unchanged lines
461 484
- The working-copy change is unsigned and never pushed while `kind = working-copy`.
462 485
- `oxy commit` (alias `oxy describe`) sets the message, sets `kind = normal`, and starts a fresh working-copy change on top of it. The change is signed immediately if `core.sign = "change"`; with the default `core.sign = "seal"` it is left unsigned locally and covered by a seal at push time (§14.4).
463 486
- `oxy new`, `oxy edit <change>`, and `oxy abandon` move which change the workspace edits.
464 -
- There is no staging area and no stash. Partial commits are made with `oxy split`.
487 +
- There is no staging area and no stash. A partial commit is a split of the working copy: `oxy commit PATH..` records only the named paths (and every edit of theirs) and leaves the rest as uncommitted work; `oxy commit --interactive` offers each hunk (one `Insert` or `Delete` edit on one file, or one tree operation) for inclusion, like `git add -p`; `oxy split` does the same to an existing change. A hunk that another selected hunk refers to is selected with it, so the committed change is always well-formed (§8.3).
465 488
466 489
### 12.4 Snapshot performance
467 490
…
316 unchanged lines
784 807
- **Import.** Each Git commit becomes one change whose edits are the diff between the union of its parents' states and its tree, so a merge commit becomes a resolution exactly when the parents conflict in the change model, and an ordinary change otherwise. The commit's author and hash are recorded as `Git-Author` and `Git-Commit` trailers; the author key is the zero KeyId, since nobody can sign on the original author's behalf. Submodule entries are skipped. Renames are detected on import exactly as for a snapshot (§7.1, similarity ≥ 50%), then tracked exactly. The Git commit's `Change-Id` trailer, if present and 128 bits, becomes the ChangeId; otherwise one is derived as BLAKE3-128 of the Git commit hash, so re-import is idempotent. Note that a change's own closure is what it depends on, not the Git snapshot: the imported bookmark's state equals the Git tree, but checking out an individual imported change yields only the files it and its dependencies touch.
785 808
- **Export.** Each state along a bookmark becomes a Git commit whose tree is the materialized state. The ChangeId is written as a `Change-Id:` trailer and the ChangeHash as `Oxy-Hash:`. Conflicted states are exported with §9.5 markers and an `Oxy-Conflicted: true` trailer. Signatures are not exported; Git commits MAY be additionally signed with the Ed25519 key via SSH signature format.
786 809
- **Fetch and push against a Git remote.** `oxy git fetch URL` keeps a bare mirror under `.oxy/git/` for the remote, fetches the branch into it, and imports the commits the bookmark does not have yet, seeding the import with the commits already known: an imported commit maps to its change, and a commit this repository exported maps to the bookmark state it was made from (so a Git commit on top of exported work is based on everything that work contained, including changes Git never saw). The bookmark moves to the union of its heads and the new changes, like a pull; the working copy follows when it was clean. A fetched state whose previous state was itself a Git tree is remembered as equal to the fetched tip. `oxy git push URL` refuses until the remote's tip is known, exports the bookmark's later states as commits chained onto that tip, and pushes the branch, so the push is always a fast-forward.
810 +
- **Submodules.** A Git gitlink entry (mode `160000`) imports as a `subrepo` node with a Git pin (§7.8) and the matching `.gitmodules` entry becomes a `.oxymodules` entry with `branch` set to the submodule's configured branch when there is one. Export writes a Git pin back as a gitlink and `.oxymodules` back as `.gitmodules`; an Oxygen pin has no Git form and is exported as a gitlink only when the nested repository has a Git mirror commit for that state (§23, fetch and push), otherwise as an empty directory with a warning.
787 811
- **Round trip.** Import followed by export MUST reproduce every tree hash. Export followed by import MUST reproduce every ChangeId.
788 812
789 813
## 24. Command-line interface
…
9 unchanged lines
799 823
oxy split CHANGE [PATH]..           oxy abandon CHANGE           oxy resolve
800 824
oxy checkout [CHANGE].. [--discard] oxy undo                     oxy op log
801 825
oxy tag create NAME [CHANGE].. [-m MSG]   oxy tag list
826 +
oxy bookmark create|set|delete|list|switch  oxy merge BOOKMARK
827 +
oxy commit [PATH].. [--interactive]         oxy squash CHANGE --into DEST   oxy rebase CHANGE --onto HEADS..
828 +
oxy submodule add|update|status|remove      oxy blame PATH               oxy bisect start|good|bad|run|reset
802 829
oxy workspace add|list|forget       oxy sparse [PATH]..          oxy deepen [N]
803 830
oxy fetch [REMOTE]                  oxy pull [REMOTE]            oxy push [REMOTE] [--force]
804 831
oxy remote add|remove|list          oxy serve                    oxy lock|unlock PATH
…
5 unchanged lines
810 837
811 838
Change arguments accept a ChangeId prefix, a ChangeHash prefix (with `#`), a bookmark or tag name, and `@` for the working-copy change. Ambiguous prefixes are an error.
812 839
813 -
Reserved for a later version, with these names: `oxy new`, `oxy edit`, `oxy squash`, `oxy rebase` (commuting changes need no rebase; the command would re-express a change against different dependencies), `oxy redo`, revsets (`log -r`), `oxy bookmark create|set|delete|list` (one bookmark per workspace today), and `oxy remote trust` (with the Noise transport, §15).
840 +
Reserved for a later version, with these names: `oxy new`, `oxy edit`, `oxy redo`, revsets (`log -r`), and `oxy remote trust` (with the Noise transport, §15).
814 841
815 842
## 25. Performance targets
816 843
…
47 unchanged lines
864 891
| Git interoperability (§23) | Implemented: import, export, fetch, push, rename detection |
865 892
| Conformance suite (§26) | Partial: encoding, signature, permutation, conflict, rewrite, protocol, chunking and Git tests exist as unit and integration tests in the crates; a standalone vector suite is not published yet |
866 893
867 -
## 27. Open questions
894 +
## 27. Inspection: blame and bisect
895 +
896 +
**Blame.** Every line of a file in a state is an atom identified by the change that introduced it (§7.3), so `oxy blame PATH` is a lookup, not a heuristic: for each line it prints the introducing change's ChangeId, author, and date, and `--json` gives the ChangeHash and atom index. A line inside a conflict region is attributed to the side that introduced it. Blame follows renames for free, since the node survives them, and it MUST cost no more than materializing the file.
897 +
898 +
**Bisect.** A regression is a change, and the candidates lie between a known-good state and a known-bad one: `candidates = closure(bad) \ closure(good)`. `oxy bisect start`, then `oxy bisect good CHANGE..` and `oxy bisect bad CHANGE..` (either may name a bookmark), orders the candidates so that every prefix is dependency-closed and checks out `good ∪ prefix` at the midpoint. Each further `good`/`bad` halves the remaining range, and the search ends by naming the first change whose inclusion makes the state bad; `oxy bisect run CMD` automates it with the command's exit status (0 good, 125 skip, anything else bad). `oxy bisect reset` restores the working copy from before the search. Because prefixes are closed under dependencies, every tested state is well-formed; a change that cannot be tested in isolation because it commutes with the culprit is simply ordered after it. Bisect state lives under `.oxy/bisect/` and is not versioned.
899 +
900 +
## 28. Open questions
868 901
869 902
1. **Encryption at rest.** Per-repository XChaCha20-Poly1305 with a key hierarchy is reserved for format version 2; object headers already leave room. Convergent encryption is rejected because it leaks equality.
870 903
2. **Word- or character-granularity atoms** for prose and minified files. Line atoms are fixed for v1.
…
3 unchanged lines
874 907
875 908
---
876 909
877 -
*Changelog.* Draft 0.7 (2026-09-27): review pass. `oxy status --json` shape (§9.5); stat crawl required and file-system monitor recommended (§12.4); capabilities aligned with the implementation (§16.1); §24 lists the implemented command line and names the reserved commands; new §26.1 reference implementation status; §27 records the open workspace-conflict and lock items.
910 +
*Changelog.* Draft 0.8 (2026-09-27): subrepositories with Oxygen and Git pins (§7.8, §23); squash and rebase defined as history edits (§8.5); multiple bookmarks per repository with a per-workspace current bookmark and `oxy merge` (§10.1); partial and interactive commits (§12.3); blame and bisect (§27); open questions moved to §28.
911 +
912 +
Draft 0.7 (2026-09-27): review pass. `oxy status --json` shape (§9.5); stat crawl required and file-system monitor recommended (§12.4); capabilities aligned with the implementation (§16.1); §24 lists the implemented command line and names the reserved commands; new §26.1 reference implementation status; §28 records the open workspace-conflict and lock items.
878 913
879 914
Draft 0.6s (2026-09-27): the manifest as a stat cache with a racy-stat window (§12.4).
880 915
…
53 unchanged lines