Shallow clone: clone --depth, checkpoint and shallow endpoints, boundary-aware import and negotiation, oxy deepen; log from state metadata (spec 0.6g) 2863456a0f6d
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-26 22:34 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
2863456a0f6d96a6b04f7892b73d0665ff71ad94d14e2824726a9224f679537a- Depends on
- 096f447ccda0 135212de5370 252e8cb23fbc 2b5ee371060e 5b9c6ad45f6e 7421309f5372 750251b21a96 83e0677b07fe 982864068a59 b3c5a59a875a ba160f60219e c3069537d7c5 fce44ae9ad29 ff0af1ee299d
- Depended on by
- 569802ebae0e 80b069facc69 f3811eb20bea 7bd805ffb418 d36ba2f79f95 767ac6cd0545 2bf22305bc48 14edbaaf5291 4d6c9c1b358c 598009a75f6c 69816d688300 6f8aeb996558 afaba5f1306d d42369abf180 75167672ebcd e02bf0cd0533 8f10a07a1dbe 21b2aec279ff 94084087a876 28fc23d7706a 132b884b9a8d
- 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
- 50 atoms, 0 tree operations, 13 files
Browse the tree at this change
modified crates/oxy-core/src/protocol.rs +28 −0
| … | 93 unchanged lines |
||
| 94 | 94 | /// Individual objects by reference (spec §16.1 `objects`); a blob comes |
|
| 95 | 95 | /// with its chunks. |
|
| 96 | 96 | fn objects(&self, refs: &[ObjectRef]) -> Result<Vec<(ObjectType, Vec<u8>)>>; |
|
| 97 | + | /// A checkpoint of exactly `heads` (spec §16.1 `checkpoint`): the |
|
| 98 | + | /// checkpoint object, its payload blob, and the blob's chunks. |
|
| 99 | + | fn checkpoint(&self, heads: &[ChangeHash]) -> Result<Bundle>; |
|
| 100 | + | /// The changes within `depth` dependency steps of `heads` (spec §17). |
|
| 101 | + | fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>>; |
|
| 97 | 102 | /// Push changes, bookmark updates, and optionally a newer policy. The |
|
| 98 | 103 | /// policy is applied first; a result named `policy` reports it. |
|
| 99 | 104 | fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>>; |
|
| … | 28 unchanged lines |
||
| 128 | 133 | fn objects(&self, refs: &[ObjectRef]) -> Result<Vec<(ObjectType, Vec<u8>)>> { |
|
| 129 | 134 | self.repo.objects_for(refs) |
|
| 130 | 135 | } |
|
| 136 | + | fn checkpoint(&self, heads: &[ChangeHash]) -> Result<Bundle> { |
|
| 137 | + | self.repo.checkpoint_bundle_for(heads) |
|
| 138 | + | } |
|
| 139 | + | fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>> { |
|
| 140 | + | self.repo.shallow_changes(heads, depth) |
|
| 141 | + | } |
|
| 131 | 142 | fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>> { |
|
| 132 | 143 | self.repo.receive_push(bundle, updates, policy) |
|
| 133 | 144 | } |
|
| … | 90 unchanged lines |
||
| 224 | 235 | fn objects(&self, refs: &[ObjectRef]) -> Result<Vec<(ObjectType, Vec<u8>)>> { |
|
| 225 | 236 | Ok(wire::decode_bundle(&self.call(true, "objects", Some(&wire::encode_hashes(refs)))?)?.objects) |
|
| 226 | 237 | } |
|
| 238 | + | fn checkpoint(&self, heads: &[ChangeHash]) -> Result<Bundle> { |
|
| 239 | + | wire::decode_bundle(&self.call(true, "checkpoint", Some(&wire::encode_hashes(heads)))?) |
|
| 240 | + | } |
|
| 241 | + | fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>> { |
|
| 242 | + | wire::decode_hashes(&self.call(true, "shallow", Some(&wire::encode_shallow(heads, depth)))?) |
|
| 243 | + | } |
|
| 227 | 244 | fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>> { |
|
| 228 | 245 | wire::decode_push_response(&self.call(true, "push", Some(&wire::encode_push(&bundle, &updates, policy.as_ref())))?) |
|
| 229 | 246 | } |
|
| … | 204 unchanged lines |
||
| 434 | 451 | Ok((hashes_from(field(m, 0, "changes")?)?, max)) |
|
| 435 | 452 | } |
|
| 436 | 453 | ||
| 454 | + | /// `shallow` request: `{0: [heads], 1: depth}`. |
|
| 455 | + | pub fn encode_shallow(hs: &[ChangeHash], depth: u32) -> Vec<u8> { |
|
| 456 | + | cbor::encode(&Value::map([(0, hashes_value(hs)), (1, Value::Uint(depth as u64))])) |
|
| 457 | + | } |
|
| 458 | + | pub fn decode_shallow(bytes: &[u8]) -> Result<(Vec<ChangeHash>, u32)> { |
|
| 459 | + | let v = cbor::decode(bytes).map_err(ObjectError::from)?; |
|
| 460 | + | let m = map(&v, "shallow")?; |
|
| 461 | + | Ok((hashes_from(field(m, 0, "heads")?)?, o::u32_(field(m, 1, "depth")?, "depth")?)) |
|
| 462 | + | } |
|
| 463 | + | ||
| 437 | 464 | pub fn encode_hashes(hs: &[ChangeHash]) -> Vec<u8> { |
|
| 438 | 465 | cbor::encode(&Value::map([(0, hashes_value(hs))])) |
|
| 439 | 466 | } |
|
| … | 119 unchanged lines |
||
| 559 | 586 | assert_eq!(wire::decode_hashes(&wire::encode_hashes(&[c.hash()])).unwrap(), vec![c.hash()]); |
|
| 560 | 587 | assert_eq!(wire::decode_fetch(&wire::encode_fetch(&[c.hash()], Some(4096))).unwrap(), (vec![c.hash()], Some(4096))); |
|
| 561 | 588 | assert_eq!(wire::decode_fetch(&wire::encode_fetch(&[], None)).unwrap(), (vec![], None)); |
|
| 589 | + | assert_eq!(wire::decode_shallow(&wire::encode_shallow(&[c.hash()], 3)).unwrap(), (vec![c.hash()], 3)); |
|
| 562 | 590 | // An old-style fetch request without the filter field still decodes. |
|
| 563 | 591 | assert_eq!(wire::decode_fetch(&wire::encode_hashes(&[c.hash()])).unwrap(), (vec![c.hash()], None)); |
|
| 564 | 592 | ||
| … | 13 unchanged lines |
modified crates/oxy-core/src/repo/checkpoint.rs +39 −4
| … | 65 unchanged lines |
||
| 66 | 66 | Ok((r, state.len(), bytes)) |
|
| 67 | 67 | } |
|
| 68 | 68 | ||
| 69 | + | /// Point `bookmark` at an already stored checkpoint (a shallow clone's base). |
|
| 70 | + | pub(crate) fn install_checkpoint(&self, bookmark: &str, r: &ObjectRef) -> Result<()> { |
|
| 71 | + | Checkpoint::decode(&self.store.get_typed(r, ObjectType::Checkpoint)?)?; |
|
| 72 | + | fs::create_dir_all(self.oxy.join("checkpoints"))?; |
|
| 73 | + | write_atomic(&self.checkpoint_pointer(bookmark), r.to_hex().as_bytes()) |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | /// A bundle holding a checkpoint of exactly `heads` with its payload |
|
| 77 | + | /// (spec §16.1 `checkpoint`); the current bookmark checkpoint is reused |
|
| 78 | + | /// when it matches, otherwise one is made and, when `heads` are the |
|
| 79 | + | /// bookmark's, kept. |
|
| 80 | + | pub fn checkpoint_bundle_for(&self, heads: &[ChangeHash]) -> Result<Bundle> { |
|
| 81 | + | let wanted = StateObject::new(heads.to_vec()).heads; |
|
| 82 | + | let bookmark = self.config.bookmark.clone(); |
|
| 83 | + | let current = self.bookmark_heads()?; |
|
| 84 | + | let r = match self.latest_checkpoint(&bookmark)? { |
|
| 85 | + | Some((r, cp)) if cp.heads == wanted => r, |
|
| 86 | + | _ => { |
|
| 87 | + | let state = self.load_state(&wanted)?; |
|
| 88 | + | if current == wanted { |
|
| 89 | + | self.write_checkpoint(&bookmark, &state, &wanted)?.0 |
|
| 90 | + | } else { |
|
| 91 | + | let payload = state.checkpoint_payload(); |
|
| 92 | + | let blob = self.store_blob(&payload)?; |
|
| 93 | + | let cp = Checkpoint { heads: wanted.clone(), count: state.len() as u64, payload: blob }; |
|
| 94 | + | self.store.put(ObjectType::Checkpoint, &cp.encode())? |
|
| 95 | + | } |
|
| 96 | + | } |
|
| 97 | + | }; |
|
| 98 | + | let cp = Checkpoint::decode(&self.store.get_typed(&r, ObjectType::Checkpoint)?)?; |
|
| 99 | + | let mut bundle = Bundle::default(); |
|
| 100 | + | for c in Blob::decode(&self.store.get_typed(&cp.payload, ObjectType::Blob)?)?.chunks { |
|
| 101 | + | bundle.objects.push(self.store.get(&c)?); |
|
| 102 | + | } |
|
| 103 | + | bundle.objects.push(self.store.get(&cp.payload)?); |
|
| 104 | + | bundle.objects.push((ObjectType::Checkpoint, cp.encode())); |
|
| 105 | + | Ok(bundle) |
|
| 106 | + | } |
|
| 107 | + | ||
| 69 | 108 | /// Load the state of `heads` using a checkpoint when one applies. |
|
| 70 | 109 | pub(crate) fn load_state_checkpointed(&self, heads: &[ChangeHash]) -> Result<Option<State>> { |
|
| 71 | 110 | let mut bookmarks: Vec<String> = vec![self.config.bookmark.clone()]; |
|
| … | 7 unchanged lines |
||
| 79 | 118 | } |
|
| 80 | 119 | for b in bookmarks { |
|
| 81 | 120 | let Some((_, cp)) = self.latest_checkpoint(&b)? else { continue }; |
|
| 82 | - | // Cheap pre-check: every checkpoint head must be stored. |
|
| 83 | - | if cp.heads.iter().any(|h| !self.store.has(h)) { |
|
| 84 | - | continue; |
|
| 85 | - | } |
|
| 86 | 121 | let payload = self.read_blob(&cp.payload)?; |
|
| 87 | 122 | let base = State::from_checkpoint_payload(&payload)?; |
|
| 88 | 123 | let cp_heads: BTreeSet<ChangeHash> = cp.heads.iter().copied().collect(); |
|
| … | 31 unchanged lines |
modified crates/oxy-core/src/repo/gc.rs +6 −1
| … | 89 unchanged lines |
||
| 90 | 90 | if !keep.insert(h) { |
|
| 91 | 91 | continue; |
|
| 92 | 92 | } |
|
| 93 | - | let c = self.store.get_change(&h)?; |
|
| 93 | + | // Beyond the shallow boundary there is nothing to keep. |
|
| 94 | + | let c = match self.store.get_change(&h) { |
|
| 95 | + | Ok(c) => c, |
|
| 96 | + | Err(StoreError::NotFound(_)) => continue, |
|
| 97 | + | Err(e) => return Err(e.into()), |
|
| 98 | + | }; |
|
| 94 | 99 | stack.extend(c.deps.iter().copied()); |
|
| 95 | 100 | if !c.author.is_zero() { |
|
| 96 | 101 | keep.insert(c.author); |
|
| … | 27 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +4 −3
| … | 86 unchanged lines |
||
| 87 | 87 | ||
| 88 | 88 | pub struct LogEntry { |
|
| 89 | 89 | pub hash: ChangeHash, |
|
| 90 | - | pub change: Change, |
|
| 90 | + | /// Metadata from the state; the full change may be absent in a shallow repository. |
|
| 91 | + | pub change: crate::state::ChangeMeta, |
|
| 91 | 92 | pub is_working_copy: bool, |
|
| 92 | 93 | pub is_head: bool, |
|
| 93 | 94 | } |
|
| … | 417 unchanged lines |
||
| 511 | 512 | let state = self.load_state(&heads)?; |
|
| 512 | 513 | let head_set: BTreeSet<ChangeHash> = self.bookmark_heads()?.into_iter().collect(); |
|
| 513 | 514 | let mut entries: Vec<LogEntry> = Vec::new(); |
|
| 514 | - | for (h, _) in state.changes() { |
|
| 515 | - | entries.push(LogEntry { hash: *h, change: self.store.get_change(h)?, is_working_copy: *h == wc_hash, is_head: head_set.contains(h) }); |
|
| 515 | + | for (h, meta) in state.changes() { |
|
| 516 | + | entries.push(LogEntry { hash: *h, change: meta.clone(), is_working_copy: *h == wc_hash, is_head: head_set.contains(h) }); |
|
| 516 | 517 | } |
|
| 517 | 518 | entries.sort_by(|a, b| b.is_working_copy.cmp(&a.is_working_copy).then(b.change.hlc.cmp(&a.change.hlc)).then(b.hash.cmp(&a.hash))); |
|
| 518 | 519 | Ok(entries) |
|
| … | 81 unchanged lines |
modified crates/oxy-core/src/repo/sync.rs +168 −3
| … | 71 unchanged lines |
||
| 72 | 72 | Ok(seen) |
|
| 73 | 73 | } |
|
| 74 | 74 | ||
| 75 | + | /// The changes within `depth` dependency steps of `heads` (spec §17), |
|
| 76 | + | /// nearest first; depth 1 is the heads themselves. |
|
| 77 | + | pub fn shallow_changes(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>> { |
|
| 78 | + | let mut out = Vec::new(); |
|
| 79 | + | let mut seen: BTreeSet<ChangeHash> = BTreeSet::new(); |
|
| 80 | + | let mut layer: Vec<ChangeHash> = heads.to_vec(); |
|
| 81 | + | for _ in 0..depth.max(1) { |
|
| 82 | + | let mut next = Vec::new(); |
|
| 83 | + | for h in layer { |
|
| 84 | + | if !seen.insert(h) || !self.store.has(&h) { |
|
| 85 | + | continue; |
|
| 86 | + | } |
|
| 87 | + | let c = self.store.get_change(&h)?; |
|
| 88 | + | next.extend(c.deps.iter().copied()); |
|
| 89 | + | out.push(h); |
|
| 90 | + | } |
|
| 91 | + | if next.is_empty() { |
|
| 92 | + | break; |
|
| 93 | + | } |
|
| 94 | + | layer = next; |
|
| 95 | + | } |
|
| 96 | + | out.reverse(); |
|
| 97 | + | Ok(out) |
|
| 98 | + | } |
|
| 99 | + | ||
| 100 | + | fn shallow_path(&self) -> PathBuf { |
|
| 101 | + | self.oxy.join("shallow") |
|
| 102 | + | } |
|
| 103 | + | ||
| 104 | + | /// The shallow boundary: dependencies this repository does not hold (spec §17). |
|
| 105 | + | pub fn boundary(&self) -> BTreeSet<ChangeHash> { |
|
| 106 | + | fs::read_to_string(self.shallow_path()).map(|t| t.lines().filter_map(ObjectRef::from_hex).collect()).unwrap_or_default() |
|
| 107 | + | } |
|
| 108 | + | ||
| 109 | + | pub fn is_shallow(&self) -> bool { |
|
| 110 | + | !self.boundary().is_empty() |
|
| 111 | + | } |
|
| 112 | + | ||
| 113 | + | fn write_boundary(&self, boundary: &BTreeSet<ChangeHash>) -> Result<()> { |
|
| 114 | + | if boundary.is_empty() { |
|
| 115 | + | let _ = fs::remove_file(self.shallow_path()); |
|
| 116 | + | return Ok(()); |
|
| 117 | + | } |
|
| 118 | + | let text: String = boundary.iter().map(|h| format!("{}\n", h.to_hex())).collect(); |
|
| 119 | + | write_atomic(&self.shallow_path(), text.as_bytes()) |
|
| 120 | + | } |
|
| 121 | + | ||
| 122 | + | /// Recompute the boundary from what is stored: every dependency of a |
|
| 123 | + | /// stored change that is not itself stored. |
|
| 124 | + | fn refresh_boundary(&self, imported: &[ChangeHash]) -> Result<()> { |
|
| 125 | + | let mut boundary = self.boundary(); |
|
| 126 | + | boundary.retain(|h| !self.store.has(h)); |
|
| 127 | + | for h in imported { |
|
| 128 | + | for d in self.store.get_change(h)?.deps { |
|
| 129 | + | if !self.store.has(&d) { |
|
| 130 | + | boundary.insert(d); |
|
| 131 | + | } |
|
| 132 | + | } |
|
| 133 | + | } |
|
| 134 | + | self.write_boundary(&boundary) |
|
| 135 | + | } |
|
| 136 | + | ||
| 137 | + | /// Fetch `depth` more levels of history below the shallow boundary. |
|
| 138 | + | pub fn deepen(&self, remote_name: &str, depth: u32) -> Result<usize> { |
|
| 139 | + | let boundary = self.boundary(); |
|
| 140 | + | if boundary.is_empty() { |
|
| 141 | + | return Ok(0); |
|
| 142 | + | } |
|
| 143 | + | let remote = self.open_named_remote(remote_name)?; |
|
| 144 | + | let wanted = remote.shallow(&boundary.iter().copied().collect::<Vec<_>>(), depth)?; |
|
| 145 | + | let bundle = remote.fetch(&wanted, self.config.max_blob)?; |
|
| 146 | + | let imported = self.import_bundle_shallow(&bundle, false)?; |
|
| 147 | + | if self.config.max_blob.is_some() { |
|
| 148 | + | self.register_missing_blobs(&imported, remote_name)?; |
|
| 149 | + | } |
|
| 150 | + | self.refresh_boundary(&imported)?; |
|
| 151 | + | Ok(imported.len()) |
|
| 152 | + | } |
|
| 153 | + | ||
| 75 | 154 | /// `closure(want) \ closure(have)`, dependencies first (spec §16.2). |
|
| 76 | 155 | pub fn negotiate(&self, have: &[ChangeHash], want: &[ChangeHash]) -> Result<Vec<ChangeHash>> { |
|
| 77 | 156 | let have = self.closure_in_store(have)?; |
|
| 157 | + | // In a shallow repository the walk stops at the boundary, whose |
|
| 158 | + | // changes the remote is assumed to hold. |
|
| 159 | + | let boundary = self.boundary(); |
|
| 78 | 160 | let mut out = Vec::new(); |
|
| 79 | 161 | let mut done: BTreeSet<ChangeHash> = have.clone(); |
|
| 162 | + | done.extend(boundary.iter().copied()); |
|
| 80 | 163 | // Iterative post-order DFS. |
|
| 81 | 164 | let mut stack: Vec<(ChangeHash, bool)> = want.iter().rev().map(|h| (*h, false)).collect(); |
|
| 82 | 165 | while let Some((h, expanded)) = stack.pop() { |
|
| … | 101 unchanged lines |
||
| 184 | 267 | /// Store a bundle's contents. With `strict`, every change must |
|
| 185 | 268 | /// authenticate (signature or seal by its author or a delegate). |
|
| 186 | 269 | pub fn import_bundle(&self, bundle: &Bundle, strict: bool) -> Result<Vec<ChangeHash>> { |
|
| 270 | + | self.import_bundle_inner(bundle, strict, false) |
|
| 271 | + | } |
|
| 272 | + | ||
| 273 | + | /// Like `import_bundle`, accepting changes whose dependencies are absent |
|
| 274 | + | /// (they join the shallow boundary). |
|
| 275 | + | pub fn import_bundle_shallow(&self, bundle: &Bundle, strict: bool) -> Result<Vec<ChangeHash>> { |
|
| 276 | + | self.import_bundle_inner(bundle, strict, true) |
|
| 277 | + | } |
|
| 278 | + | ||
| 279 | + | fn import_bundle_inner(&self, bundle: &Bundle, strict: bool, shallow: bool) -> Result<Vec<ChangeHash>> { |
|
| 187 | 280 | for (ty, body) in &bundle.objects { |
|
| 188 | 281 | match ty { |
|
| 189 | - | ObjectType::Chunk | ObjectType::Blob | ObjectType::Identity | ObjectType::State => { |
|
| 282 | + | ObjectType::Chunk | ObjectType::Blob | ObjectType::Identity | ObjectType::State | ObjectType::Checkpoint => { |
|
| 190 | 283 | self.store.put(*ty, body)?; |
|
| 191 | 284 | } |
|
| 192 | 285 | other => return fail(format!("bundle may not carry {} objects", other.name())), |
|
| … | 11 unchanged lines |
||
| 204 | 297 | } |
|
| 205 | 298 | } |
|
| 206 | 299 | let incoming: BTreeSet<ChangeHash> = bundle.changes.iter().map(|(c, _)| c.hash()).collect(); |
|
| 300 | + | let boundary = self.boundary(); |
|
| 207 | 301 | let mut imported = Vec::new(); |
|
| 208 | 302 | for (c, sigs) in &bundle.changes { |
|
| 209 | 303 | let h = c.hash(); |
|
| 210 | 304 | for d in &c.deps { |
|
| 211 | - | if !self.store.has(d) && !incoming.contains(d) { |
|
| 305 | + | if !self.store.has(d) && !incoming.contains(d) && !shallow && !boundary.contains(d) { |
|
| 212 | 306 | return fail(format!("change {} depends on {}, which is neither stored nor in the bundle", h.short(12), d.short(12))); |
|
| 213 | 307 | } |
|
| 214 | 308 | } |
|
| … | 352 unchanged lines |
||
| 567 | 661 | Ok(FetchReport { refs: report, unverified }) |
|
| 568 | 662 | } |
|
| 569 | 663 | ||
| 664 | + | /// Shallow fetch of the remote's bookmark: its checkpoint as our base, |
|
| 665 | + | /// the changes within `depth` steps of its heads, and the boundary below. |
|
| 666 | + | pub fn fetch_shallow(&self, remote_name: &str, depth: u32) -> Result<usize> { |
|
| 667 | + | let remote = self.open_named_remote(remote_name)?; |
|
| 668 | + | if remote.repo_id()? != self.id()? { |
|
| 669 | + | return fail(format!("remote {remote_name:?} is a different repository (repository IDs differ)")); |
|
| 670 | + | } |
|
| 671 | + | let resp = remote.refs()?; |
|
| 672 | + | let bookmark = self.config.bookmark.clone(); |
|
| 673 | + | if let Some(sp) = &resp.policy { |
|
| 674 | + | let local = self.current_policy()?.map(|(_, sp)| sp.policy); |
|
| 675 | + | self.check_policy(sp, local.as_ref()).map_err(|e| RepoError::Msg(format!("policy from {remote_name}: {e}")))?; |
|
| 676 | + | self.set_policy(sp, &format!("oxy clone --depth (policy serial {})", sp.policy.serial))?; |
|
| 677 | + | self.record_policy_serial(remote_name, sp.policy.serial)?; |
|
| 678 | + | } |
|
| 679 | + | let Some(r) = resp.refs.into_iter().find(|r| r.name == bookmark) else { |
|
| 680 | + | return fail(format!("{remote_name} has no bookmark {bookmark:?}")); |
|
| 681 | + | }; |
|
| 682 | + | if r.heads.is_empty() { |
|
| 683 | + | return Ok(0); |
|
| 684 | + | } |
|
| 685 | + | // Base: the remote's checkpoint of exactly these heads. |
|
| 686 | + | let cp_bundle = remote.checkpoint(&r.heads)?; |
|
| 687 | + | let mut cp_ref = None; |
|
| 688 | + | for (ty, body) in &cp_bundle.objects { |
|
| 689 | + | let put = self.store.put(*ty, body)?; |
|
| 690 | + | if *ty == ObjectType::Checkpoint { |
|
| 691 | + | cp_ref = Some(put); |
|
| 692 | + | } |
|
| 693 | + | } |
|
| 694 | + | let Some(cp_ref) = cp_ref else { return fail(format!("{remote_name} sent no checkpoint")) }; |
|
| 695 | + | self.install_checkpoint(&bookmark, &cp_ref)?; |
|
| 696 | + | // History window. |
|
| 697 | + | let wanted = remote.shallow(&r.heads, depth)?; |
|
| 698 | + | let bundle = remote.fetch(&wanted, self.config.max_blob)?; |
|
| 699 | + | let imported = self.import_bundle_shallow(&bundle, false)?; |
|
| 700 | + | if self.config.max_blob.is_some() { |
|
| 701 | + | self.register_missing_blobs(&imported, remote_name)?; |
|
| 702 | + | } |
|
| 703 | + | self.refresh_boundary(&imported)?; |
|
| 704 | + | if let Some(sa) = &r.attestation { |
|
| 705 | + | let ar = self.store.put_attest(&sa.attest)?; |
|
| 706 | + | for sig in &sa.sigs { |
|
| 707 | + | self.store.put_signature(&ar, sig)?; |
|
| 708 | + | } |
|
| 709 | + | if let AttestBody::Ref { serial, .. } = sa.attest.body { |
|
| 710 | + | self.record_serial(remote_name, &r.name, serial)?; |
|
| 711 | + | } |
|
| 712 | + | } |
|
| 713 | + | let state = self.store.put_state(&StateObject::new(r.heads.clone()))?; |
|
| 714 | + | let (op_ref, op) = self.head_op()?; |
|
| 715 | + | let mut view = op.view.clone(); |
|
| 716 | + | view.remotes.entry(remote_name.to_string()).or_default().insert(r.name.clone(), state); |
|
| 717 | + | self.record_op(&[op_ref], &format!("oxy fetch {remote_name} --depth {depth}"), view)?; |
|
| 718 | + | Ok(imported.len()) |
|
| 719 | + | } |
|
| 720 | + | ||
| 570 | 721 | /// Fetch, then take the union of the local bookmark's state with the |
|
| 571 | 722 | /// remote's: no merge change, conflicts become reified (spec §9.4). |
|
| 572 | 723 | pub fn pull(&self, remote_name: &str) -> Result<PullReport> { |
|
| … | 137 unchanged lines |
||
| 710 | 861 | /// read, and materializing only `sparse` paths (which implies lazy blobs |
|
| 711 | 862 | /// unless a size is given, so out-of-scope binaries are never fetched). |
|
| 712 | 863 | pub fn clone_with(url: &str, dest: &Path, max_blob: Option<u64>, sparse: &[String]) -> Result<Repo> { |
|
| 864 | + | Self::clone_full(url, dest, max_blob, sparse, None) |
|
| 865 | + | } |
|
| 866 | + | ||
| 867 | + | /// Shallow clone (spec §17): a checkpoint of the bookmark's heads plus the |
|
| 868 | + | /// changes within `depth` steps of them; the rest is the boundary. |
|
| 869 | + | pub fn clone_shallow(url: &str, dest: &Path, depth: u32, max_blob: Option<u64>, sparse: &[String]) -> Result<Repo> { |
|
| 870 | + | Self::clone_full(url, dest, max_blob, sparse, Some(depth)) |
|
| 871 | + | } |
|
| 872 | + | ||
| 873 | + | fn clone_full(url: &str, dest: &Path, max_blob: Option<u64>, sparse: &[String], depth: Option<u32>) -> Result<Repo> { |
|
| 713 | 874 | let options = RemoteOptions { |
|
| 714 | 875 | token: std::env::var("OXY_TOKEN").ok(), |
|
| 715 | 876 | ca: std::env::var("OXY_CA").ok().map(PathBuf::from), |
|
| … | 11 unchanged lines |
||
| 727 | 888 | repo.config.set("filter.paths", &sparse.join(","))?; |
|
| 728 | 889 | } |
|
| 729 | 890 | repo.save_config()?; |
|
| 730 | - | repo.fetch("origin")?; |
|
| 891 | + | if let Some(depth) = depth { |
|
| 892 | + | repo.fetch_shallow("origin", depth)?; |
|
| 893 | + | } else { |
|
| 894 | + | repo.fetch("origin")?; |
|
| 895 | + | } |
|
| 731 | 896 | let (op_ref, op) = repo.head_op()?; |
|
| 732 | 897 | let bookmark = repo.config.bookmark.clone(); |
|
| 733 | 898 | if let Some(state) = op.view.remotes.get("origin").and_then(|b| b.get(&bookmark)).copied() { |
|
| … | 9 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +53 −0
| … | 1261 unchanged lines |
||
| 1262 | 1262 | assert!(full.files.contains_key("README.md")); |
|
| 1263 | 1263 | let _ = h1; |
|
| 1264 | 1264 | } |
|
| 1265 | + | ||
| 1266 | + | #[test] |
|
| 1267 | + | fn shallow_clone_and_deepen() { |
|
| 1268 | + | hermetic(); |
|
| 1269 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 1270 | + | let root = dir.path(); |
|
| 1271 | + | let origin_dir = root.join("origin"); |
|
| 1272 | + | let origin = Repo::init(&origin_dir).unwrap().with_home(root.join("home")); |
|
| 1273 | + | let mut hashes = Vec::new(); |
|
| 1274 | + | for i in 0..8 { |
|
| 1275 | + | write(&origin_dir, "a.txt", format!("{}\n", (0..=i).map(|k| k.to_string()).collect::<Vec<_>>().join("\n")).as_bytes()); |
|
| 1276 | + | hashes.push(origin.commit(&format!("c{i}"), &[]).unwrap()); |
|
| 1277 | + | } |
|
| 1278 | + | let url = origin_dir.to_string_lossy().to_string(); |
|
| 1279 | + | let a = Repo::clone_shallow(&url, &root.join("a"), 3, None, &[]).unwrap().with_home(root.join("home")); |
|
| 1280 | + | assert!(a.is_shallow()); |
|
| 1281 | + | assert_eq!(fs::read_to_string(root.join("a/a.txt")).unwrap(), "0\n1\n2\n3\n4\n5\n6\n7\n"); |
|
| 1282 | + | assert!(a.store().has(&hashes[7]) && a.store().has(&hashes[5]), "three levels present"); |
|
| 1283 | + | assert!(!a.store().has(&hashes[4]), "beyond the depth is absent"); |
|
| 1284 | + | assert_eq!(a.boundary(), [hashes[4]].into_iter().collect()); |
|
| 1285 | + | assert_eq!(a.log().unwrap().len(), 9, "the log shows every change from the checkpoint's metadata"); |
|
| 1286 | + | assert!(!a.snapshot("status").unwrap().changed); |
|
| 1287 | + | assert_eq!(a.quick_status().unwrap().heads, 1); |
|
| 1288 | + | ||
| 1289 | + | // Work continues on top of the shallow history and pushes back. |
|
| 1290 | + | write(&root.join("a"), "a.txt", b"0\n1\n2\n3\n4\n5\n6\n7\n8\n"); |
|
| 1291 | + | let h_new = a.commit("c8", &[]).unwrap(); |
|
| 1292 | + | assert!(a.push("origin", false).unwrap()[0].ok); |
|
| 1293 | + | assert_eq!(origin.bookmark_heads().unwrap(), vec![h_new]); |
|
| 1294 | + | // Fetching new work from origin into the shallow clone works too. |
|
| 1295 | + | write(&origin_dir, "b.txt", b"b\n"); |
|
| 1296 | + | origin.pull("origin").err(); |
|
| 1297 | + | let (_, wc) = origin.working_copy().unwrap(); |
|
| 1298 | + | let _ = wc; |
|
| 1299 | + | origin.checkout(&origin.bookmark_heads().unwrap(), true, "co").unwrap(); |
|
| 1300 | + | write(&origin_dir, "b.txt", b"b\n"); |
|
| 1301 | + | let h_b = origin.commit("b", &[]).unwrap(); |
|
| 1302 | + | let pr = a.pull("origin").unwrap(); |
|
| 1303 | + | assert!(pr.changed); |
|
| 1304 | + | assert!(a.store().has(&h_b)); |
|
| 1305 | + | assert!(root.join("a/b.txt").exists()); |
|
| 1306 | + | ||
| 1307 | + | // Deepening fetches older history until the boundary is gone. |
|
| 1308 | + | // Every commit also depends on the change that created the file, so two |
|
| 1309 | + | // levels below the boundary hold h4, h3, and the root. |
|
| 1310 | + | assert!(a.deepen("origin", 2).unwrap() >= 2); |
|
| 1311 | + | assert!(a.store().has(&hashes[3])); |
|
| 1312 | + | assert!(a.is_shallow()); |
|
| 1313 | + | a.deepen("origin", 100).unwrap(); |
|
| 1314 | + | assert!(!a.is_shallow(), "boundary reached the root"); |
|
| 1315 | + | assert!(a.store().has(&hashes[0])); |
|
| 1316 | + | assert_eq!(a.load_state_full(&a.bookmark_heads().unwrap()).unwrap().len(), 10); |
|
| 1317 | + | } |
modified crates/oxy-server/src/lib.rs +22 −0
| … | 136 unchanged lines |
||
| 137 | 137 | .await |
|
| 138 | 138 | } |
|
| 139 | 139 | ||
| 140 | + | async fn checkpoint(State(app): State<Shared>, Path(repo): Path<String>, body: Bytes) -> Result<Response, Failure> { |
|
| 141 | + | blocking(move || { |
|
| 142 | + | let r = app.open(&repo)?; |
|
| 143 | + | let heads = wire::decode_hashes(&body)?; |
|
| 144 | + | let lock = app.lock_for(&repo); |
|
| 145 | + | let _guard = lock.lock().unwrap(); |
|
| 146 | + | Ok(ok(wire::encode_bundle(&r.checkpoint_bundle_for(&heads)?))) |
|
| 147 | + | }) |
|
| 148 | + | .await |
|
| 149 | + | } |
|
| 150 | + | ||
| 151 | + | async fn shallow(State(app): State<Shared>, Path(repo): Path<String>, body: Bytes) -> Result<Response, Failure> { |
|
| 152 | + | blocking(move || { |
|
| 153 | + | let r = app.open(&repo)?; |
|
| 154 | + | let (heads, depth) = wire::decode_shallow(&body)?; |
|
| 155 | + | Ok(ok(wire::encode_hashes(&r.shallow_changes(&heads, depth)?))) |
|
| 156 | + | }) |
|
| 157 | + | .await |
|
| 158 | + | } |
|
| 159 | + | ||
| 140 | 160 | async fn objects(State(app): State<Shared>, Path(repo): Path<String>, body: Bytes) -> Result<Response, Failure> { |
|
| 141 | 161 | blocking(move || { |
|
| 142 | 162 | let r = app.open(&repo)?; |
|
| … | 24 unchanged lines |
||
| 167 | 187 | .route("/{repo}/oxy/v1/negotiate", post(negotiate)) |
|
| 168 | 188 | .route("/{repo}/oxy/v1/fetch", post(fetch)) |
|
| 169 | 189 | .route("/{repo}/oxy/v1/objects", post(objects)) |
|
| 190 | + | .route("/{repo}/oxy/v1/checkpoint", post(checkpoint)) |
|
| 191 | + | .route("/{repo}/oxy/v1/shallow", post(shallow)) |
|
| 170 | 192 | .route("/{repo}/oxy/v1/push", post(push)) |
|
| 171 | 193 | .with_state(app) |
|
| 172 | 194 | } |
|
| … | 25 unchanged lines |
modified docs/spec.md +8 −4
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | | | | |
|
| 4 | 4 | |---|---| |
|
| 5 | - | | **Status** | Draft 0.6f | |
|
| 5 | + | | **Status** | Draft 0.6g | |
|
| 6 | 6 | | **Format version** | 1 | |
|
| 7 | 7 | | **Date** | 2026-09-26 | |
|
| 8 | 8 | | **Audience** | Implementers, security reviewers, and contributors | |
|
| … | 683 unchanged lines |
||
| 692 | 692 | | `negotiate` request | `{0: [have], 1: [want]}`; response `{0: [hashes]}` in dependency order | |
|
| 693 | 693 | | `fetch` request | `{0: [hashes], 1: max blob size or null}`; response a `Bundle` without blobs above the size | |
|
| 694 | 694 | | `objects` request | `{0: [refs]}`; response a `Bundle` carrying only objects, a blob accompanied by its chunks | |
|
| 695 | + | | `checkpoint` request | `{0: [heads]}`; response a `Bundle` of objects: the checkpoint of exactly those heads, its payload blob, and the blob's chunks | |
|
| 696 | + | | `shallow` request | `{0: [heads], 1: depth}`; response `{0: [hashes]}`, the changes within `depth` steps of the heads, dependencies first | |
|
| 695 | 697 | | `push` request | `{0: Bundle, 1: [{0: name, 1: old state, 2: new state, 3: force, 4: signed attestation or null}], 2: signed policy or null}`; response `{0: [{0: name, 1: ok, 2: reason}]}`, where a result named `policy` reports the policy update | |
|
| 696 | 698 | ||
| 697 | - | The HTTP content type is `application/oxy-cbor`. The `checkpoint` endpoint of §16.1 is not yet specified in this form. |
|
| 699 | + | The HTTP content type is `application/oxy-cbor`. |
|
| 698 | 700 | ||
| 699 | 701 | ### 16.4 Push validation |
|
| 700 | 702 | ||
| … | 12 unchanged lines |
||
| 713 | 715 | ||
| 714 | 716 | - **Sparse** repositories hold the full change graph but only materialize the listed paths: the working copy contains nothing else, files outside the filter are neither reported nor removed, and changes touching other paths are stored and applied to the graph. A sparse clone sets `max-blob` to zero unless a size is given, so blobs outside the filter are fetched only if the filter later widens (§13.5). `oxy sparse PATH..` changes the filter of a clean working copy and re-checks it out. |
|
| 715 | 717 | - **Lazy blobs** are fetched via `objects` on first read. `oxy status` and `oxy commit` MUST NOT need them. |
|
| 716 | - | - **Shallow** repositories store a *shallow boundary*: a set of ChangeHashes whose dependencies are known to be absent. A shallow repository MUST hold a checkpoint at the boundary so that graphs can be reconstructed. Deps into the boundary are accepted as well-formed. Deepening fetches more history and moves the boundary. |
|
| 718 | + | - **Shallow** repositories hold a checkpoint of their bookmark's heads (obtained with the `checkpoint` message) and the changes within `depth` dependency steps of those heads (the `shallow` message lists them). The *shallow boundary* is the set of dependencies of stored changes that are not stored (`.oxy/shallow`). Dependencies into the boundary are accepted as well-formed, negotiation stops at the boundary (the remote is assumed to hold it), and the log shows every change from the checkpoint's metadata. `oxy deepen N` fetches `N` more levels below the boundary; when nothing remains the repository is no longer shallow. |
|
| 717 | 719 | - Thin clients (§22) always use `depth = 0`: a checkpoint plus zero history. |
|
| 718 | 720 | ||
| 719 | 721 | Servers MUST honour `filter` on `negotiate`, `fetch`, and `checkpoint`. |
|
| … | 120 unchanged lines |
||
| 840 | 842 | ||
| 841 | 843 | --- |
|
| 842 | 844 | ||
| 843 | - | *Changelog.* Draft 0.6f (2026-09-27): sparse checkout semantics (§17). |
|
| 845 | + | *Changelog.* Draft 0.6g (2026-09-27): shallow repositories as checkpoint plus history window, the `checkpoint` and `shallow` messages, deepening (§16.3, §17). |
|
| 846 | + | ||
| 847 | + | Draft 0.6f (2026-09-27): sparse checkout semantics (§17). |
|
| 844 | 848 | ||
| 845 | 849 | Draft 0.6e (2026-09-27): lazy blobs: the `fetch` filter, the `objects` message, and the absent-blob record (§13.5, §16.3). |
|
| 846 | 850 | ||
| … | 25 unchanged lines |
modified src/cli/format.rs +10 −1
| … | 3 unchanged lines |
||
| 4 | 4 | use oxy_core::hash::ChangeHash; |
|
| 5 | 5 | use oxy_core::objects::Change; |
|
| 6 | 6 | use oxy_core::repo::{Repo, SignatureStatus}; |
|
| 7 | + | use oxy_core::state::ChangeMeta; |
|
| 7 | 8 | ||
| 8 | 9 | pub fn short_id(c: &Change) -> String { |
|
| 9 | 10 | c.change_id.short(8) |
|
| 10 | 11 | } |
|
| 11 | 12 | ||
| 13 | + | pub fn short_meta_id(c: &ChangeMeta) -> String { |
|
| 14 | + | c.change_id.short(8) |
|
| 15 | + | } |
|
| 16 | + | ||
| 12 | 17 | /// `n head` / `n heads`. |
|
| 13 | 18 | pub fn plural(n: usize, word: &str) -> String { |
|
| 14 | 19 | format!("{n} {word}{}", if n == 1 { "" } else { "s" }) |
|
| … | 30 unchanged lines |
||
| 45 | 50 | } |
|
| 46 | 51 | ||
| 47 | 52 | pub fn deps_line(c: &Change) -> String { |
|
| 48 | - | c.deps.iter().map(|d| format!("#{}", d.short(12))).collect::<Vec<_>>().join(" ") |
|
| 53 | + | deps_of(&c.deps) |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | pub fn deps_of(deps: &[oxy_core::hash::ChangeHash]) -> String { |
|
| 57 | + | deps.iter().map(|d| format!("#{}", d.short(12))).collect::<Vec<_>>().join(" ") |
|
| 49 | 58 | } |
|
| 50 | 59 | ||
| 51 | 60 | #[cfg(test)] |
|
| … | 11 unchanged lines |
modified src/cli/identity.rs +4 −1
| … | 50 unchanged lines |
||
| 51 | 51 | }; |
|
| 52 | 52 | let mut bad = 0; |
|
| 53 | 53 | for h in hashes { |
|
| 54 | - | let c = repo.store().get_change(&h)?; |
|
| 54 | + | let Ok(c) = repo.store().get_change(&h) else { |
|
| 55 | + | println!("{} {}", ui::hash(format!("#{}", h.short(12))), ui::dim("not present (shallow)")); |
|
| 56 | + | continue; |
|
| 57 | + | }; |
|
| 55 | 58 | let line = signature_line(&repo, &h)?; |
|
| 56 | 59 | let styled = if line.starts_with("INVALID") { |
|
| 57 | 60 | bad += 1; |
|
| … | 97 unchanged lines |
modified src/cli/mod.rs +12 −1
| … | 34 unchanged lines |
||
| 35 | 35 | /// Materialize only these paths (repeatable); implies lazy blobs |
|
| 36 | 36 | #[arg(long = "sparse")] |
|
| 37 | 37 | sparse: Vec<String>, |
|
| 38 | + | /// Shallow clone: fetch only this many levels of history below the heads |
|
| 39 | + | #[arg(long)] |
|
| 40 | + | depth: Option<u32>, |
|
| 38 | 41 | }, |
|
| 42 | + | /// Fetch more history below a shallow clone's boundary |
|
| 43 | + | Deepen { |
|
| 44 | + | #[arg(default_value_t = 10)] |
|
| 45 | + | depth: u32, |
|
| 46 | + | #[arg(long, default_value = "origin")] |
|
| 47 | + | remote: String, |
|
| 48 | + | }, |
|
| 39 | 49 | /// Show or set the sparse filter (paths to materialize); re-checks out the bookmark |
|
| 40 | 50 | Sparse { paths: Vec<String> }, |
|
| 41 | 51 | /// Show the working copy: changed paths and conflicts |
|
| … | 202 unchanged lines |
||
| 244 | 254 | Cmd::Gc { now } => workspace::gc(now), |
|
| 245 | 255 | Cmd::Checkpoint => workspace::checkpoint(), |
|
| 246 | 256 | Cmd::Op { cmd: OpCmd::Log } => workspace::op_log(), |
|
| 247 | - | Cmd::Clone { url, dest, max_blob, sparse } => sync::clone(&url, dest, max_blob.as_deref(), &sparse), |
|
| 257 | + | Cmd::Clone { url, dest, max_blob, sparse, depth } => sync::clone(&url, dest, max_blob.as_deref(), &sparse, depth), |
|
| 258 | + | Cmd::Deepen { depth, remote } => sync::deepen(&remote, depth), |
|
| 248 | 259 | Cmd::Sparse { paths } => workspace::sparse(&paths), |
|
| 249 | 260 | Cmd::Remote { cmd } => match cmd { |
|
| 250 | 261 | RemoteCmd::Add { name, url } => sync::remote_add(&name, &url), |
|
| … | 24 unchanged lines |
modified src/cli/sync.rs +17 −2
| … | 6 unchanged lines |
||
| 7 | 7 | use oxy_core::repo::Repo; |
|
| 8 | 8 | use std::path::PathBuf; |
|
| 9 | 9 | ||
| 10 | - | pub fn clone(url: &str, dest: Option<PathBuf>, max_blob: Option<&str>, sparse: &[String]) -> Result<i32> { |
|
| 10 | + | pub fn clone(url: &str, dest: Option<PathBuf>, max_blob: Option<&str>, sparse: &[String], depth: Option<u32>) -> Result<i32> { |
|
| 11 | 11 | let dest = dest.unwrap_or_else(|| { |
|
| 12 | 12 | let base = url.trim_end_matches('/').rsplit('/').next().unwrap_or("repo"); |
|
| 13 | 13 | PathBuf::from(base) |
|
| 14 | 14 | }); |
|
| 15 | 15 | let max_blob = max_blob.map(oxy_core::repo::parse_size).transpose()?; |
|
| 16 | 16 | let sp = ui::spinner(&format!("Cloning {url}…")); |
|
| 17 | - | let repo = Repo::clone_with(url, &dest, max_blob, sparse)?; |
|
| 17 | + | let repo = match depth { |
|
| 18 | + | Some(d) => Repo::clone_shallow(url, &dest, d, max_blob, sparse)?, |
|
| 19 | + | None => Repo::clone_with(url, &dest, max_blob, sparse)?, |
|
| 20 | + | }; |
|
| 18 | 21 | let heads = repo.bookmark_heads()?; |
|
| 19 | 22 | sp.done(&format!("{} into {} ({} on {}: {})", ui::ok("Cloned"), ui::path(repo.root().display()), url, ui::id(&repo.config.bookmark), plural(heads.len(), "head"))); |
|
| 20 | 23 | Ok(0) |
|
| … | 143 unchanged lines |
||
| 164 | 167 | ); |
|
| 165 | 168 | Ok(0) |
|
| 166 | 169 | } |
|
| 170 | + | ||
| 171 | + | pub fn deepen(remote: &str, depth: u32) -> Result<i32> { |
|
| 172 | + | let repo = open()?; |
|
| 173 | + | if !repo.is_shallow() { |
|
| 174 | + | println!("{}", ui::dim("This repository is not shallow; nothing to deepen.")); |
|
| 175 | + | return Ok(0); |
|
| 176 | + | } |
|
| 177 | + | let sp = ui::spinner(&format!("Fetching {depth} more levels from {remote}…")); |
|
| 178 | + | let n = repo.deepen(remote, depth)?; |
|
| 179 | + | sp.done(&format!("{} {}; {}", ui::ok("Deepened by"), plural(n, "change"), if repo.is_shallow() { "still shallow".to_string() } else { ui::ok("history is now complete") })); |
|
| 180 | + | Ok(0) |
|
| 181 | + | } |
modified src/cli/workspace.rs +3 −3
| 1 | 1 | //! Commands that act on the local repository and working copy. |
|
| 2 | 2 | ||
| 3 | - | use super::format::{deps_line, plural, short_id, signature_line, when}; |
|
| 3 | + | use super::format::{deps_line, deps_of, plural, short_id, short_meta_id, signature_line, when}; |
|
| 4 | 4 | use super::open; |
|
| 5 | 5 | use super::ui; |
|
| 6 | 6 | use anyhow::Result; |
|
| … | 85 unchanged lines |
||
| 92 | 92 | flags.push("head"); |
|
| 93 | 93 | } |
|
| 94 | 94 | let flags = if flags.is_empty() { String::new() } else { ui::warn(format!(" ({})", flags.join(", "))) }; |
|
| 95 | - | println!("{} {}{}", ui::id(short_id(c)), ui::hash(format!("#{}", e.hash.short(12))), flags); |
|
| 95 | + | println!("{} {}{}", ui::id(short_meta_id(c)), ui::hash(format!("#{}", e.hash.short(12))), flags); |
|
| 96 | 96 | println!(" {}", ui::dim(when(c.hlc.wall_ms))); |
|
| 97 | 97 | if !c.deps.is_empty() { |
|
| 98 | - | println!(" {}", ui::dim(format!("deps: {}", deps_line(c)))); |
|
| 98 | + | println!(" {}", ui::dim(format!("deps: {}", deps_of(&c.deps)))); |
|
| 99 | 99 | } |
|
| 100 | 100 | let msg = if c.message.is_empty() { "(no description)" } else { &c.message }; |
|
| 101 | 101 | for line in msg.lines() { |
|
| … | 202 unchanged lines |