OXYGENHUB

Git import: shared graphs, deterministic node ids; quieter status dfb23a3a8cfa

Found by the vscode import (P5c). A state's file graphs are shared
between clones (Arc, copied on edit), so keeping states for branch points
costs little, and the import tracks heads as changes arrive instead of
keeping every change for a final state: 5 000 vscode commits peak at
2.3 GB instead of 6.0 GB. New files' and directories' NodeIds derive from
the Git commit and path, so importing the same history twice gives the
same changes (spec §23, Draft 0.8p). OXY_IMPORT_KEEP sets how many states
the import keeps, for measuring.

Reported by Andy on metalgear. status no longer rewrites an unchanged
manifest whose stamp already trusts every entry, which a subrepo's
manifest turned into an editor refresh loop. oxy remote list --json
names each remote's CA file, for tools that talk to the same hub.

closes #
refs andymccall/oxygen-vscode#
refs andymccall/oxygenhub-vscode#
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 12:38 UTC
Signature
✔ signed by Andy McCall
Hash
dfb23a3a8cfa1fdf4f464003b8b19ed8fdfba9730896761927d7c1073f178aad
Depends on
0bdc3f91a39f 135212de5370 30ea9c1e70fa 55e5a16a2b9c 62794ecc7f74 6e723b81f5b9 75167672ebcd 79ab8528ff62 958fb27dda7c 982864068a59 afaba5f1306d b7f102fcbb17 c3069537d7c5 cc7a00565161 cdb5beba7f05 e8d38e47e9d7 f7510b63cceb fc1a466a1e66 fce44ae9ad29 ff0af1ee299d
Depended on by
3a4b3e018326 d6e0d5b8b339 36e51aa806c6 1966f5db15ee 23ff1288a5dd 40a40bb7b798 2306a4d24779 ab4d4e31b38d 57d8dc05c9d4 b493d993a9df a87c7b9d563a 86e1824f2d0b 6f8aeb996558
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
42 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified crates/oxy-core/src/repo/git.rs +21 −13
…
592 unchanged lines
593 593
        let mut report = GitImportReport::default();
594 594
        let mut known = self.git_known(git_dir)?;
595 595
        let old_heads = self.bookmark_heads()?;
596 -
        let mut new_changes: Vec<Change> = Vec::new();
596 +
        // The bookmark's heads as changes arrive: a change stops being a
597 +
        // head when another depends on it, so no full state of every
598 +
        // imported change is needed at the end.
599 +
        let mut heads_now: BTreeSet<ChangeHash> = old_heads.iter().copied().collect();
597 600
        // The state and Git tree of imported commits whose first-parent
598 601
        // children are still to come, so a child starts from its parent's
599 602
        // state instead of rebuilding it from history. The last child takes
600 -
        // it; earlier ones clone it. At most `MAX_KEPT` are kept (a later
603 +
        // it; earlier ones clone it. At most `max_kept` are kept (a later
601 604
        // child of an evicted one rebuilds).
602 -
        const MAX_KEPT: usize = 8;
605 +
        // OXY_IMPORT_KEEP overrides it, for measuring.
606 +
        let max_kept: usize = std::env::var("OXY_IMPORT_KEEP")
607 +
            .ok()
608 +
            .and_then(|v| v.parse().ok())
609 +
            .unwrap_or(8);
603 610
        let mut waiting: HashMap<&str, usize> = HashMap::new();
604 611
        for gc in &commits {
605 612
            if let Some(p1) = gc.parents.first() {
…
110 unchanged lines
716 723
                    Some(c) => c.clone(),
717 724
                    None => {
718 725
                        let c = cat.read(&sha)?;
719 -
                        if cache_bytes + c.len() > 256 * 1024 * 1024 {
726 +
                        if cache_bytes + c.len() > 64 * 1024 * 1024 {
720 727
                            blob_cache.clear();
721 728
                            cache_bytes = 0;
722 729
                        }
…
50 unchanged lines
773 780
                }
774 781
            }
775 782
            lap(3, &mut clock);
783 +
            // New nodes' identifiers derive from the commit, so importing the
784 +
            // same Git history twice gives the same changes (spec §23).
785 +
            let seed = *blake3::hash(format!("oxy git import\0{}", gc.sha).as_bytes()).as_bytes();
776 786
            let mut built =
777 -
                self.build_from_entries(&base, &base_snapshot, HashMap::new(), &entries)?;
787 +
                self.build_from_entries_seeded(&base, &base_snapshot, &entries, seed)?;
778 788
            lap(4, &mut clock);
779 789
            // A file or submodule with several live blobs (branches that
780 790
            // replaced it independently) is resolved to the commit's blob:
…
50 unchanged lines
831 841
            change.deps = deps.into_iter().collect();
832 842
            change.normalize();
833 843
            let hash = self.store.put_change(&change)?;
834 -
            new_changes.push(change.clone());
844 +
            for d in &change.deps {
845 +
                heads_now.remove(d);
846 +
            }
847 +
            heads_now.insert(hash);
835 848
            base.add_unchecked(change);
836 849
            known.imported.insert(gc.sha.clone(), hash);
837 850
            if waiting.get(gc.sha.as_str()).is_some_and(|n| *n > 0) {
838 851
                kept.insert(gc.sha.clone(), (base, tree));
839 852
                kept_order.push_back(gc.sha.clone());
840 -
                if kept_order.len() > MAX_KEPT {
853 +
                if kept_order.len() > max_kept {
841 854
                    if let Some(old) = kept_order.pop_front() {
842 855
                        kept.remove(&old);
843 856
                    }
…
22 unchanged lines
866 879
        if last.is_none() {
867 880
            return Ok(report);
868 881
        }
869 -
        let mut all = self.load_state(&old_heads)?;
870 -
        for c in new_changes {
871 -
            all.add_unchecked(c);
872 -
        }
873 -
        let mut heads = all.heads();
874 -
        heads.sort();
882 +
        let heads: Vec<ChangeHash> = heads_now.into_iter().collect();
875 883
        report.heads = heads.len();
876 884
        let (op_ref, op) = self.head_op()?;
877 885
        let mut view = op.view.clone();
…
389 unchanged lines
modified crates/oxy-core/src/repo/prompt.rs +28 −4
…
176 unchanged lines
177 177
}
178 178
179 179
impl Repo {
180 -
    fn manifest_path(&self) -> PathBuf {
180 +
    pub(crate) fn manifest_path(&self) -> PathBuf {
181 181
        self.oxy
182 182
            .join("workspaces")
183 183
            .join(self.workspace.to_hex())
…
21 unchanged lines
205 205
            .duration_since(UNIX_EPOCH)
206 206
            .map(|d| d.as_nanos())
207 207
            .unwrap_or(0);
208 -
        let mut text = format!(
209 -
            "# conflicted={}\n# uncommitted={}\n# written={written}\n",
208 +
        let head = format!(
209 +
            "# conflicted={}\n# uncommitted={}\n",
210 210
            conflicted as u8, uncommitted as u8
211 211
        );
212 +
        let mut body = String::new();
213 +
        let mut newest_ns: u128 = 0;
212 214
        for (path, e) in entries {
213 215
            let full = self.root.join(path);
214 216
            let (size, mtime_ns) = match fs::symlink_metadata(&full) {
…
5 unchanged lines
220 222
                None => blake3::hash(&e.content).to_hex().to_string(),
221 223
            };
222 224
            let blob = blobs.get(path).copied().or(e.blob);
223 -
            text.push_str(&manifest_line(
225 +
            newest_ns = newest_ns.max(mtime_ns);
226 +
            body.push_str(&manifest_line(
224 227
                path,
225 228
                size,
226 229
                mtime_ns,
…
3 unchanged lines
230 233
            ));
231 234
        }
232 235
        let p = self.manifest_path();
236 +
        // Leave an unchanged manifest alone when its stamp already trusts
237 +
        // every entry (none is within the racy window): rewriting it would
238 +
        // change nothing but the file, which editors watching the tree
239 +
        // (and subrepos inside it) take as a change.
240 +
        if let Ok(old) = fs::read_to_string(&p) {
241 +
            let old_written: Option<u128> = old.lines().find_map(|l| {
242 +
                l.strip_prefix("# written=")
243 +
                    .and_then(|v| v.trim().parse().ok())
244 +
            });
245 +
            let old_rest: String = old
246 +
                .lines()
247 +
                .filter(|l| !l.starts_with("# written="))
248 +
                .map(|l| format!("{l}\n"))
249 +
                .collect();
250 +
            if let Some(w) = old_written {
251 +
                if old_rest == format!("{head}{body}") && newest_ns + 2_000_000_000 < w {
252 +
                    return Ok(());
253 +
                }
254 +
            }
255 +
        }
256 +
        let text = format!("{head}# written={written}\n{body}");
233 257
        fs::create_dir_all(p.parent().unwrap())?;
234 258
        write_atomic(&p, text.as_bytes())
235 259
    }
…
224 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +71 −6
…
125 unchanged lines
126 126
        fs_entries: &BTreeMap<String, FsEntry>,
127 127
        resolve: &HashSet<String>,
128 128
    ) -> Result<BuiltChange> {
129 +
        self.build_from_entries_with(base, base_snapshot, reuse, fs_entries, resolve, None)
130 +
    }
131 +
132 +
    /// `build_from_entries` whose new nodes take identifiers derived from
133 +
    /// `seed` and their paths instead of random ones, so the same input
134 +
    /// always builds the same change (a Git import, spec §23).
135 +
    pub(crate) fn build_from_entries_seeded(
136 +
        &self,
137 +
        base: &State,
138 +
        base_snapshot: &Snapshot,
139 +
        fs_entries: &BTreeMap<String, FsEntry>,
140 +
        seed: [u8; 32],
141 +
    ) -> Result<BuiltChange> {
142 +
        self.build_from_entries_with(
143 +
            base,
144 +
            base_snapshot,
145 +
            HashMap::new(),
146 +
            fs_entries,
147 +
            &HashSet::new(),
148 +
            Some(seed),
149 +
        )
150 +
    }
151 +
152 +
    fn build_from_entries_with(
153 +
        &self,
154 +
        base: &State,
155 +
        base_snapshot: &Snapshot,
156 +
        reuse: HashMap<String, (NodeId, NodeKind)>,
157 +
        fs_entries: &BTreeMap<String, FsEntry>,
158 +
        resolve: &HashSet<String>,
159 +
        seed: Option<[u8; 32]>,
160 +
    ) -> Result<BuiltChange> {
129 161
        // Attributes come from the files being committed, so a new
130 162
        // `.oxyattributes` applies to the same change that adds it.
131 163
        let attrs = crate::attributes::Attributes::from_files(
…
4 unchanged lines
136 168
        );
137 169
        let mut b = ChangeBuilder::new(base, base_snapshot, reuse).with_attributes(attrs);
138 170
        b.resolve = resolve.clone();
171 +
        b.node_seed = seed;
139 172
        // Rename detection (spec §7.1, §23): a removed file and an added file
140 173
        // of the same kind whose contents are at least half alike become one
141 174
        // move of the same node, so identity survives across the rename.
…
1289 unchanged lines
1431 1464
}
1432 1465
1433 1466
/// Builds the tree ops and edits of a working-copy change against a base.
1467 +
/// `BLAKE3("oxy node 1" 0 seed kind path)`, truncated: a node identifier
1468 +
/// that the same seed and path always give (never the root's).
1469 +
fn seeded_node(seed: &[u8; 32], path: &str, kind: NodeKind) -> NodeId {
1470 +
    let mut h = blake3::Hasher::new();
1471 +
    h.update(b"oxy node 1\0");
1472 +
    h.update(seed);
1473 +
    h.update(&[match kind {
1474 +
        NodeKind::File => 1,
1475 +
        NodeKind::Dir => 2,
1476 +
        NodeKind::Symlink => 3,
1477 +
        NodeKind::Subrepo => 4,
1478 +
    }]);
1479 +
    h.update(path.as_bytes());
1480 +
    let mut id = [0u8; 16];
1481 +
    id.copy_from_slice(&h.finalize().as_bytes()[..16]);
1482 +
    if id == [0; 16] {
1483 +
        id[15] = 1;
1484 +
    }
1485 +
    NodeId(id)
1486 +
}
1487 +
1434 1488
struct ChangeBuilder<'a> {
1435 1489
    base: &'a State,
1436 1490
    base_snapshot: &'a Snapshot,
…
9 unchanged lines
1446 1500
    blobs: HashMap<String, ObjectRef>,
1447 1501
    /// Opaque files whose replace conflict the content settles.
1448 1502
    resolve: HashSet<String>,
1503 +
    /// When set, new nodes' identifiers derive from it and their paths.
1504 +
    node_seed: Option<[u8; 32]>,
1449 1505
}
1450 1506
1451 1507
impl<'a> ChangeBuilder<'a> {
…
22 unchanged lines
1474 1530
            attrs: crate::attributes::Attributes::default(),
1475 1531
            blobs: HashMap::new(),
1476 1532
            resolve: HashSet::new(),
1533 +
            node_seed: None,
1477 1534
        }
1478 1535
    }
1479 1536
…
40 unchanged lines
1520 1577
            crate::attributes::normalize_eol(content)
1521 1578
        } else {
1522 1579
            content.to_vec()
1580 +
        }
1581 +
    }
1582 +
1583 +
    /// The identifier of a node added at `path`: reused when asked, derived
1584 +
    /// from the seed when there is one, random otherwise.
1585 +
    fn new_node(&self, path: &str, kind: NodeKind) -> NodeId {
1586 +
        if let Some(n) = self.reused_node(path, kind) {
1587 +
            return n;
1523 1588
        }
1589 +
        match &self.node_seed {
1590 +
            Some(seed) => seeded_node(seed, path, kind),
1591 +
            None => NodeId::random(),
1592 +
        }
1524 1593
    }
1525 1594
1526 1595
    fn reused_node(&self, path: &str, kind: NodeKind) -> Option<NodeId> {
…
17 unchanged lines
1544 1613
            parent = match self.dir_nodes.get(&prefix) {
1545 1614
                Some(n) => *n,
1546 1615
                None => {
1547 -
                    let node = self
1548 -
                        .reused_node(&prefix, NodeKind::Dir)
1549 -
                        .unwrap_or_else(NodeId::random);
1616 +
                    let node = self.new_node(&prefix, NodeKind::Dir);
1550 1617
                    self.tree_ops.push(TreeOp::AddNode {
1551 1618
                        node,
1552 1619
                        kind: NodeKind::Dir,
…
12 unchanged lines
1565 1632
    fn add_path(&mut self, repo: &Repo, path: &str, e: &FsEntry) -> Result<()> {
1566 1633
        let (dir, name) = split_path(path);
1567 1634
        let parent = self.ensure_dir(dir);
1568 -
        let node = self
1569 -
            .reused_node(path, e.kind)
1570 -
            .unwrap_or_else(NodeId::random);
1635 +
        let node = self.new_node(path, e.kind);
1571 1636
        let opaque = self.opaque_for(path, e);
1572 1637
        let nocompress = self.attrs.lookup(path).nocompress;
1573 1638
        self.tree_ops.push(TreeOp::AddNode {
…
669 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +40 −0
…
1616 unchanged lines
1617 1617
        .unwrap()
1618 1618
        .with_home(root.join("home"));
1619 1619
    repo2.import_git(&g, "main").unwrap();
1620 +
    // New nodes' identifiers derive from the commit (spec §23), so the
1621 +
    // re-import is not just the same ChangeIds but the same changes.
1622 +
    assert_eq!(
1623 +
        repo2.bookmark_heads().unwrap(),
1624 +
        repo.bookmark_heads().unwrap()
1625 +
    );
1620 1626
    let ids: BTreeSet<ChangeId> = repo
1621 1627
        .log()
1622 1628
        .unwrap()
…
3841 unchanged lines
5464 5470
    assert_eq!(repo.receipts(&heads).unwrap().len(), 1);
5465 5471
    assert_eq!(repo.read_blob(&out).unwrap(), b"built\n");
5466 5472
    assert!(repo.fsck().unwrap().problems.is_empty());
5473 +
}
5474 +
5475 +
#[test]
5476 +
fn status_leaves_an_unchanged_manifest_alone() {
5477 +
    hermetic();
5478 +
    let dir = tempfile::tempdir().unwrap();
5479 +
    let root = dir.path();
5480 +
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
5481 +
    write(root, "a.txt", b"1\n");
5482 +
    write(root, "b.txt", b"2\n");
5483 +
    repo.commit("A", &[]).unwrap();
5484 +
    // Files last touched well outside the racy window.
5485 +
    let old = std::time::SystemTime::now() - std::time::Duration::from_secs(60);
5486 +
    for f in ["a.txt", "b.txt"] {
5487 +
        fs::File::options()
5488 +
            .write(true)
5489 +
            .open(root.join(f))
5490 +
            .unwrap()
5491 +
            .set_modified(old)
5492 +
            .unwrap();
5493 +
    }
5494 +
    repo.snapshot_status("status").unwrap();
5495 +
    let path = repo.manifest_path();
5496 +
    let first = fs::read(&path).unwrap();
5497 +
    repo.snapshot_status("status").unwrap();
5498 +
    assert_eq!(
5499 +
        fs::read(&path).unwrap(),
5500 +
        first,
5501 +
        "an unchanged tree does not rewrite the manifest"
5502 +
    );
5503 +
    // A change rewrites it.
5504 +
    write(root, "a.txt", b"1\n2\n");
5505 +
    repo.snapshot_status("status").unwrap();
5506 +
    assert_ne!(fs::read(&path).unwrap(), first);
5467 5507
}
modified crates/oxy-core/src/state.rs +14 −8
…
56 unchanged lines
57 57
pub struct State {
58 58
    changes: HashMap<ChangeHash, ChangeMeta>,
59 59
    tree: TreeGraph,
60 -
    files: HashMap<NodeId, FileGraph>,
60 +
    /// Shared between clones: a clone copies a graph only when an edit
61 +
    /// changes it (`Arc::make_mut`), so keeping several states that differ
62 +
    /// by a few changes costs little more than one.
63 +
    files: HashMap<NodeId, Arc<FileGraph>>,
61 64
    replaces: HashMap<NodeId, Vec<ReplaceRecord>>,
62 65
    /// Memoised transitive dependencies. A mutex, not a `RefCell`, so a
63 66
    /// loaded state can be shared across threads by a host that caches it.
…
186 unchanged lines
250 253
                node.short(12)
251 254
            );
252 255
        }
253 -
        self.files.get(node)
256 +
        self.files.get(node).map(|g| g.as_ref())
254 257
    }
255 258
256 259
    /// Operations over every file need every graph.
…
63 unchanged lines
320 323
                Ok((node, graph))
321 324
            })
322 325
            .collect::<Result<_, ObjectError>>()?;
323 -
        self.files.extend(built);
326 +
        self.files
327 +
            .extend(built.into_iter().map(|(n, g)| (n, Arc::new(g))));
324 328
        Ok(())
325 329
    }
326 330
…
118 unchanged lines
445 449
    pub fn checkpoint_file_graphs(&self) -> Vec<(NodeId, Vec<u8>)> {
446 450
        use rayon::prelude::*;
447 451
        self.require_full("a checkpoint");
448 -
        let mut nodes: Vec<(&NodeId, &FileGraph)> = self.files.iter().collect();
452 +
        let mut nodes: Vec<(&NodeId, &FileGraph)> =
453 +
            self.files.iter().map(|(n, g)| (n, g.as_ref())).collect();
449 454
        nodes.sort_by_key(|(n, _)| **n);
450 455
        nodes
451 456
            .into_par_iter()
…
152 unchanged lines
604 609
                Ok((*node, graph))
605 610
            })
606 611
            .collect::<Result<Vec<_>, ObjectError>>()?;
607 -
        st.files.extend(graphs);
612 +
        st.files
613 +
            .extend(graphs.into_iter().map(|(n, g)| (n, Arc::new(g))));
608 614
        Ok(st)
609 615
    }
610 616
…
130 unchanged lines
741 747
            }
742 748
            for (node, edits) in per_node {
743 749
                match self.files.get_mut(&node) {
744 -
                    Some(g) => apply_file_edits(g, h, edits),
750 +
                    Some(g) => apply_file_edits(Arc::make_mut(g), h, edits),
745 751
                    None => deferred.entry(node).or_default().edits.push((h, edits)),
746 752
                }
747 753
            }
…
11 unchanged lines
759 765
                    let idx = counters.entry(node).or_default();
760 766
                    let first = *idx;
761 767
                    *idx += lines.len() as u32;
762 -
                    self.files.entry(node).or_default().insert_owned(
768 +
                    Arc::make_mut(self.files.entry(node).or_default()).insert_owned(
763 769
                        h,
764 770
                        first,
765 771
                        vertex(after.resolve(h)),
…
2 unchanged lines
768 774
                    );
769 775
                }
770 776
                Edit::Delete { node, atoms } => {
771 -
                    let g = self.files.entry(node).or_default();
777 +
                    let g = Arc::make_mut(self.files.entry(node).or_default());
772 778
                    for r in atoms {
773 779
                        g.delete(h, &r.resolve(h));
774 780
                    }
…
1004 unchanged lines
modified docs/spec.md +3 −3
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.8o |
5 +
| **Status** | Draft 0.8p |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
844 unchanged lines
853 853
854 854
`oxy git import <repo>` and `oxy git export <repo>` provide a lossless-enough bridge; `oxy git fetch/push` operate against a Git remote directly.
855 855
856 -
- **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.
856 +
- **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. New files' and directories' NodeIds are derived too, as BLAKE3-128 of the commit hash, the node kind and the path, so importing the same history twice, anywhere, gives the same changes. 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.
857 857
- **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.
858 858
- **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.
859 859
- **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, keeps a tracked `.gitmodules` as is, and otherwise derives `.gitmodules` from `.oxymodules`; an Oxygen pin has no Git form and is omitted from the exported tree, which the export report counts.
…
112 unchanged lines
972 972
973 973
---
974 974
975 -
*Changelog.* Draft 0.8o (2026-09-29): execution receipts, the `receipt` object and attestation (§14.6). Draft 0.8n (2026-09-29): line history, `oxy log -L` (§27). Draft 0.8m (2026-09-29): resolving replace conflicts (§9.3). Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11).
975 +
*Changelog.* Draft 0.8p (2026-09-29): Git import derives new NodeIds from the commit, making imports deterministic (§23). Draft 0.8o (2026-09-29): execution receipts, the `receipt` object and attestation (§14.6). Draft 0.8n (2026-09-29): line history, `oxy log -L` (§27). Draft 0.8m (2026-09-29): resolving replace conflicts (§9.3). Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11).
976 976
977 977
Draft 0.8i (2026-09-28): portability rules for exec bits and path names (§7.7); found by verifying a real Git import commit by commit: deletions depend on the inserters of the lines right after them, and replacements on the change that set their old blob (§8.1); replace records are superseded causally, so a reverted binary keeps its blob (§9.3); merges resolve opaque conflicts to the commit's blob, and `oxy git verify` (§23). Draft 0.8h (2026-09-28): `oxy fsck` (§21.1); durability rules and the operation head as commit point (§13.6); the operation head moves by compare-and-swap and concurrent operations are merged (§12.1); `oxy doctor` (§21.2). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands.
978 978
…
61 unchanged lines
modified src/cli/sync.rs +6 −0
…
81 unchanged lines
82 82
            json::obj([
83 83
                ("name", json::s(name)),
84 84
                ("url", json::s(url)),
85 +
                // The CA file `remote.NAME.ca` names, for tools that talk to
86 +
                // the same hub (an editor's forge view, `oh`).
87 +
                (
88 +
                    "ca",
89 +
                    json::opt(repo.config.remote_cas.get(name), |c| json::s(c)),
90 +
                ),
85 91
                ("bookmarks", bms.unwrap_or_else(|| "[]".into())),
86 92
            ])
87 93
        });
…
619 unchanged lines