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 |