First commit: 12 s and 2.1 GB on the Linux tree (refs #41) 5b4c51307b14
A commit's own snapshot no longer encodes the working-copy change (the commit encodes and stores its own); oxy commit prints the new change's id from its metadata instead of reading back the whole change; line ends take four bytes and exactly their room; a change's inline lines are encoded as their bytes rather than a CBOR value per line (same encoding, tested); stored texts go a batch of at most 32 MiB at a time. The first commit of the Linux tip: 25.2 s and 2.7 GiB to 12.4 s and 2.07 GiB (Git 30.8 s).
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-02 03:00 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
5b4c51307b14eee794c356ac0d086b4fb3f7e36170b9095817e52a889d455cb5- Depends on
- 01a47cd7f0c8 0f93f49ce059 17f94b13ae54 192fad1e46a2 40a40bb7b798 614541c3cebd 6f8aeb996558 75167672ebcd 957fee30826e 982864068a59 c07e0e7d2d85 cd4eb55e3b3d cdb5beba7f05 e7ddd552b619 e96a430c78f3 e993a086c13f fce44ae9ad29 ff0af1ee299d
- Depended on by
- 7505194116e7
- 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.10.0 v0.11.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
- 35 atoms, 0 tree operations, 8 files
Browse the tree at this change
modified Cargo.lock +1 −0
| … | 1271 unchanged lines |
||
| 1272 | 1272 | "hex", |
|
| 1273 | 1273 | "ignore", |
|
| 1274 | 1274 | "libc", |
|
| 1275 | + | "memchr", |
|
| 1275 | 1276 | "ml-dsa", |
|
| 1276 | 1277 | "proptest", |
|
| 1277 | 1278 | "rand 0.8.8", |
|
| … | 1284 unchanged lines |
modified crates/oxy-core/Cargo.toml +1 −0
| … | 19 unchanged lines |
||
| 20 | 20 | webpki-roots = "1" |
|
| 21 | 21 | zstd = "0.13" |
|
| 22 | 22 | globset = "0.4" |
|
| 23 | + | memchr = "2" |
|
| 23 | 24 | rayon = "1" |
|
| 24 | 25 | ||
| 25 | 26 | [target.'cfg(unix)'.dependencies] |
|
| … | 5 unchanged lines |
modified crates/oxy-core/src/cbor.rs +5 −0
| … | 13 unchanged lines |
||
| 14 | 14 | Map(BTreeMap<u64, Value>), |
|
| 15 | 15 | Bool(bool), |
|
| 16 | 16 | Null, |
|
| 17 | + | /// An item already in its canonical encoding, written out as it is. |
|
| 18 | + | /// Only built for encoding (never decoded): a large change's inline |
|
| 19 | + | /// lines then cost their bytes, not a value per line. |
|
| 20 | + | Encoded(Vec<u8>), |
|
| 17 | 21 | } |
|
| 18 | 22 | ||
| 19 | 23 | #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] |
|
| … | 98 unchanged lines |
||
| 118 | 122 | Value::Bool(false) => out.push(0xf4), |
|
| 119 | 123 | Value::Bool(true) => out.push(0xf5), |
|
| 120 | 124 | Value::Null => out.push(0xf6), |
|
| 125 | + | Value::Encoded(b) => out.extend_from_slice(b), |
|
| 121 | 126 | } |
|
| 122 | 127 | } |
|
| 123 | 128 | ||
| … | 477 unchanged lines |
modified crates/oxy-core/src/lines.rs +69 −19
| … | 10 unchanged lines |
||
| 11 | 11 | /// Shared with the file it was read from, when there was one. |
|
| 12 | 12 | buf: Arc<Vec<u8>>, |
|
| 13 | 13 | /// The end offset of each line in `buf`. |
|
| 14 | - | ends: Vec<u64>, |
|
| 14 | + | ends: Ends, |
|
| 15 | 15 | } |
|
| 16 | 16 | ||
| 17 | + | /// Line end offsets, four bytes each unless the buffer is over 4 GiB: a |
|
| 18 | + | /// first commit of the Linux tree holds 36 million of them at once. Which |
|
| 19 | + | /// form follows from the buffer's length alone, so equal lines compare |
|
| 20 | + | /// equal. |
|
| 21 | + | #[derive(PartialEq, Eq, Hash)] |
|
| 22 | + | enum Ends { |
|
| 23 | + | Small(Vec<u32>), |
|
| 24 | + | Large(Vec<u64>), |
|
| 25 | + | } |
|
| 26 | + | ||
| 27 | + | impl Default for Ends { |
|
| 28 | + | fn default() -> Self { |
|
| 29 | + | Ends::Small(Vec::new()) |
|
| 30 | + | } |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | impl Ends { |
|
| 34 | + | fn from_u64(ends: Vec<u64>, buf_len: usize) -> Ends { |
|
| 35 | + | if u32::try_from(buf_len).is_ok() { |
|
| 36 | + | Ends::Small(ends.into_iter().map(|e| e as u32).collect()) |
|
| 37 | + | } else { |
|
| 38 | + | Ends::Large(ends) |
|
| 39 | + | } |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | fn len(&self) -> usize { |
|
| 43 | + | match self { |
|
| 44 | + | Ends::Small(v) => v.len(), |
|
| 45 | + | Ends::Large(v) => v.len(), |
|
| 46 | + | } |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | fn is_empty(&self) -> bool { |
|
| 50 | + | self.len() == 0 |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | fn get(&self, i: usize) -> Option<usize> { |
|
| 54 | + | match self { |
|
| 55 | + | Ends::Small(v) => v.get(i).map(|&e| e as usize), |
|
| 56 | + | Ends::Large(v) => v.get(i).map(|&e| e as usize), |
|
| 57 | + | } |
|
| 58 | + | } |
|
| 59 | + | } |
|
| 60 | + | ||
| 17 | 61 | /// An insert's lines. Cloning is cheap: the buffer is shared. |
|
| 18 | 62 | #[derive(Clone, Default, PartialEq, Eq, Hash)] |
|
| 19 | 63 | pub struct Lines(Arc<Inner>); |
|
| … | 10 unchanged lines |
||
| 30 | 74 | /// shared rather than copied. |
|
| 31 | 75 | pub fn split_shared(buf: Arc<Vec<u8>>) -> Lines { |
|
| 32 | 76 | let content = &buf[..]; |
|
| 33 | - | let mut ends = Vec::new(); |
|
| 34 | - | for (i, &c) in content.iter().enumerate() { |
|
| 35 | - | if c == b'\n' { |
|
| 36 | - | ends.push(i as u64 + 1); |
|
| 37 | - | } |
|
| 38 | - | } |
|
| 39 | - | if ends.last().copied().unwrap_or(0) as usize != content.len() { |
|
| 40 | - | ends.push(content.len() as u64); |
|
| 41 | - | } |
|
| 77 | + | // Counted first, so the offsets take exactly their room. |
|
| 78 | + | let newlines = memchr::memchr_iter(b'\n', content).count(); |
|
| 79 | + | let unterminated = content.last().is_some_and(|&c| c != b'\n'); |
|
| 80 | + | let n = newlines + usize::from(unterminated); |
|
| 81 | + | let ends_at = memchr::memchr_iter(b'\n', content) |
|
| 82 | + | .map(|i| i + 1) |
|
| 83 | + | .chain(unterminated.then_some(content.len())); |
|
| 84 | + | let ends = if u32::try_from(content.len()).is_ok() { |
|
| 85 | + | let mut v = Vec::with_capacity(n); |
|
| 86 | + | v.extend(ends_at.map(|e| e as u32)); |
|
| 87 | + | Ends::Small(v) |
|
| 88 | + | } else { |
|
| 89 | + | let mut v = Vec::with_capacity(n); |
|
| 90 | + | v.extend(ends_at.map(|e| e as u64)); |
|
| 91 | + | Ends::Large(v) |
|
| 92 | + | }; |
|
| 42 | 93 | Lines(Arc::new(Inner { buf, ends })) |
|
| 43 | 94 | } |
|
| 44 | 95 | ||
| … | 7 unchanged lines |
||
| 52 | 103 | ||
| 53 | 104 | /// Line `i`, with its terminator. |
|
| 54 | 105 | pub fn get(&self, i: usize) -> Option<&[u8]> { |
|
| 55 | - | let end = *self.0.ends.get(i)? as usize; |
|
| 56 | - | let start = if i == 0 { |
|
| 57 | - | 0 |
|
| 58 | - | } else { |
|
| 59 | - | self.0.ends[i - 1] as usize |
|
| 60 | - | }; |
|
| 106 | + | let end = self.0.ends.get(i)?; |
|
| 107 | + | let start = if i == 0 { 0 } else { self.0.ends.get(i - 1)? }; |
|
| 61 | 108 | Some(&self.0.buf[start..end]) |
|
| 62 | 109 | } |
|
| 63 | 110 | ||
| … | 13 unchanged lines |
||
| 77 | 124 | pub fn is_split_form(&self) -> bool { |
|
| 78 | 125 | let buf = &self.0.buf; |
|
| 79 | 126 | let mut start = 0usize; |
|
| 80 | - | for (i, &end) in self.0.ends.iter().enumerate() { |
|
| 81 | - | let line = &buf[start..end as usize]; |
|
| 127 | + | for i in 0..self.0.ends.len() { |
|
| 128 | + | let end = self.0.ends.get(i).unwrap_or(start); |
|
| 129 | + | let line = &buf[start..end]; |
|
| 82 | 130 | if line.is_empty() { |
|
| 83 | 131 | return false; |
|
| 84 | 132 | } |
|
| … | 8 unchanged lines |
||
| 93 | 141 | if body.contains(&b'\n') { |
|
| 94 | 142 | return false; |
|
| 95 | 143 | } |
|
| 96 | - | start = end as usize; |
|
| 144 | + | start = end; |
|
| 97 | 145 | } |
|
| 98 | 146 | true |
|
| 99 | 147 | } |
|
| … | 23 unchanged lines |
||
| 123 | 171 | buf.extend_from_slice(l); |
|
| 124 | 172 | ends.push(buf.len() as u64); |
|
| 125 | 173 | } |
|
| 174 | + | let ends = Ends::from_u64(ends, buf.len()); |
|
| 126 | 175 | Lines(Arc::new(Inner { |
|
| 127 | 176 | buf: Arc::new(buf), |
|
| 128 | 177 | ends, |
|
| … | 8 unchanged lines |
||
| 137 | 186 | buf.extend_from_slice(&l); |
|
| 138 | 187 | ends.push(buf.len() as u64); |
|
| 139 | 188 | } |
|
| 189 | + | let ends = Ends::from_u64(ends, buf.len()); |
|
| 140 | 190 | Lines(Arc::new(Inner { |
|
| 141 | 191 | buf: Arc::new(buf), |
|
| 142 | 192 | ends, |
|
| … | 65 unchanged lines |
modified crates/oxy-core/src/objects.rs +63 −18
| … | 486 unchanged lines |
||
| 487 | 487 | ||
| 488 | 488 | /// The edit's value; `stored` is the text blob of an insert stored as |
|
| 489 | 489 | /// one, and its form (see [`Change::value_with`], which works them out). |
|
| 490 | - | fn to_value(&self, stored: Option<(ObjectRef, TextForm)>) -> Value { |
|
| 490 | + | /// The edit's value; `compact` writes an inline insert's lines as |
|
| 491 | + | /// their encoding rather than a value per line (same bytes encoded). |
|
| 492 | + | fn to_value(&self, stored: Option<(ObjectRef, TextForm)>, compact: bool) -> Value { |
|
| 491 | 493 | let nid = |n: &NodeId| Value::Bytes(n.0.to_vec()); |
|
| 492 | 494 | let opt = |o: &Option<ObjectRef>| match o { |
|
| 493 | 495 | Some(r) => Value::Bytes(r.0.to_vec()), |
|
| … | 37 unchanged lines |
||
| 531 | 533 | (3, before.to_value()), |
|
| 532 | 534 | ( |
|
| 533 | 535 | 4, |
|
| 534 | - | Value::Array(lines.iter().map(|l| Value::Bytes(l.to_vec())).collect()), |
|
| 536 | + | if compact { |
|
| 537 | + | Value::Encoded(cbor::encode_byte_strings(lines)) |
|
| 538 | + | } else { |
|
| 539 | + | Value::Array(lines.iter().map(|l| Value::Bytes(l.to_vec())).collect()) |
|
| 540 | + | }, |
|
| 535 | 541 | ), |
|
| 536 | 542 | ]), |
|
| 537 | 543 | (Edit::Delete { node, atoms }, _) => Value::map([ |
|
| … | 135 unchanged lines |
||
| 673 | 679 | } |
|
| 674 | 680 | ||
| 675 | 681 | pub fn to_value(&self) -> Value { |
|
| 676 | - | self.value_with(&mut |_| true) |
|
| 682 | + | self.value_with(&mut |_| true, false) |
|
| 677 | 683 | } |
|
| 678 | 684 | ||
| 679 | 685 | /// The text blob of each edit stored as one, `None` for the others; |
|
| … | 6 unchanged lines |
||
| 686 | 692 | let stored: Vec<usize> = (0..self.edits.len()) |
|
| 687 | 693 | .filter(|i| self.edits[*i].stored_text(self.version)) |
|
| 688 | 694 | .collect(); |
|
| 689 | - | for batch in stored.chunks(256) { |
|
| 695 | + | // At most 256 texts or 32 MiB of them a batch: some directories |
|
| 696 | + | // hold hundreds of generated files of megabytes each, and a batch |
|
| 697 | + | // is copied and compressed at once. |
|
| 698 | + | let mut batches: Vec<&[usize]> = Vec::new(); |
|
| 699 | + | let (mut start, mut bytes) = (0, 0usize); |
|
| 700 | + | for (k, i) in stored.iter().enumerate() { |
|
| 701 | + | if let Edit::Insert { lines, .. } = &self.edits[*i] { |
|
| 702 | + | bytes += lines.bytes().len(); |
|
| 703 | + | } |
|
| 704 | + | if k + 1 - start == 256 || bytes >= 32 << 20 { |
|
| 705 | + | batches.push(&stored[start..=k]); |
|
| 706 | + | (start, bytes) = (k + 1, 0); |
|
| 707 | + | } |
|
| 708 | + | } |
|
| 709 | + | if start < stored.len() { |
|
| 710 | + | batches.push(&stored[start..]); |
|
| 711 | + | } |
|
| 712 | + | for batch in batches { |
|
| 690 | 713 | let made: Vec<StoredText> = batch |
|
| 691 | 714 | .par_iter() |
|
| 692 | 715 | .map(|i| { |
|
| … | 22 unchanged lines |
||
| 715 | 738 | refs |
|
| 716 | 739 | } |
|
| 717 | 740 | ||
| 718 | - | fn value_with(&self, sink: &mut TextSink) -> Value { |
|
| 741 | + | /// The change's value, its stored texts handed to `sink`; `compact` |
|
| 742 | + | /// as [`Edit::to_value`] takes it, for a value that is only encoded. |
|
| 743 | + | fn value_with(&self, sink: &mut TextSink, compact: bool) -> Value { |
|
| 719 | 744 | Value::map([ |
|
| 720 | 745 | (0, Value::Uint(self.version)), |
|
| 721 | 746 | (1, Value::Bytes(self.change_id.0.to_vec())), |
|
| … | 19 unchanged lines |
||
| 741 | 766 | self.edits |
|
| 742 | 767 | .iter() |
|
| 743 | 768 | .zip(self.stored_texts(sink)) |
|
| 744 | - | .map(|(e, stored)| e.to_value(stored)) |
|
| 769 | + | .map(|(e, stored)| e.to_value(stored, compact)) |
|
| 745 | 770 | .collect(), |
|
| 746 | 771 | ), |
|
| 747 | 772 | ), |
|
| … | 7 unchanged lines |
||
| 755 | 780 | self.deps.windows(2).all(|w| w[0] < w[1]), |
|
| 756 | 781 | "deps must be normalized" |
|
| 757 | 782 | ); |
|
| 758 | - | cbor::encode(&self.to_value()) |
|
| 783 | + | cbor::encode(&self.value_with(&mut |_| true, true)) |
|
| 759 | 784 | } |
|
| 760 | 785 | ||
| 761 | 786 | /// The encoding and the text blobs its large inserts are stored as, |
|
| … | 4 unchanged lines |
||
| 766 | 791 | "deps must be normalized" |
|
| 767 | 792 | ); |
|
| 768 | 793 | let mut texts = Vec::new(); |
|
| 769 | - | let bytes = cbor::encode(&self.value_with(&mut |batch| { |
|
| 770 | - | texts.extend(batch); |
|
| 771 | - | true |
|
| 772 | - | })); |
|
| 794 | + | let bytes = cbor::encode(&self.value_with( |
|
| 795 | + | &mut |batch| { |
|
| 796 | + | texts.extend(batch); |
|
| 797 | + | true |
|
| 798 | + | }, |
|
| 799 | + | true, |
|
| 800 | + | )); |
|
| 773 | 801 | (bytes, texts) |
|
| 774 | 802 | } |
|
| 775 | 803 | ||
| … | 9 unchanged lines |
||
| 785 | 813 | "deps must be normalized" |
|
| 786 | 814 | ); |
|
| 787 | 815 | let mut failed = None; |
|
| 788 | - | let value = self.value_with(&mut |batch| match store(batch) { |
|
| 789 | - | Ok(()) => true, |
|
| 790 | - | Err(e) => { |
|
| 791 | - | failed = Some(e); |
|
| 792 | - | false |
|
| 793 | - | } |
|
| 794 | - | }); |
|
| 816 | + | let value = self.value_with( |
|
| 817 | + | &mut |batch| match store(batch) { |
|
| 818 | + | Ok(()) => true, |
|
| 819 | + | Err(e) => { |
|
| 820 | + | failed = Some(e); |
|
| 821 | + | false |
|
| 822 | + | } |
|
| 823 | + | }, |
|
| 824 | + | true, |
|
| 825 | + | ); |
|
| 795 | 826 | match failed { |
|
| 796 | 827 | Some(e) => Err(e), |
|
| 797 | 828 | None => Ok(cbor::encode(&value)), |
|
| … | 548 unchanged lines |
||
| 1346 | 1377 | let mut e = c.clone(); |
|
| 1347 | 1378 | e.message.push('!'); |
|
| 1348 | 1379 | assert_ne!(e.hash(), c.hash()); |
|
| 1380 | + | } |
|
| 1381 | + | ||
| 1382 | + | #[test] |
|
| 1383 | + | fn compact_encoding_is_the_plain_one() { |
|
| 1384 | + | // Inline lines written as their encoding give the same bytes, so |
|
| 1385 | + | // the same hash, as a value per line. |
|
| 1386 | + | let c = sample_change(); |
|
| 1387 | + | assert!(!c.has_stored_text(), "the sample's insert is inline"); |
|
| 1388 | + | let plain = cbor::encode(&c.to_value()); |
|
| 1389 | + | assert_eq!(c.encode(), plain); |
|
| 1390 | + | assert_eq!(c.encode_parts().0, plain); |
|
| 1391 | + | let streamed: Result<Vec<u8>, ()> = c.encode_streaming(|_| Ok(())); |
|
| 1392 | + | assert_eq!(streamed.unwrap(), plain); |
|
| 1393 | + | assert_eq!(Change::decode(&plain).unwrap(), c); |
|
| 1349 | 1394 | } |
|
| 1350 | 1395 | ||
| 1351 | 1396 | #[test] |
|
| … | 278 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +15 −5
| … | 44 unchanged lines |
||
| 45 | 45 | ||
| 46 | 46 | /// Result of snapshotting the working copy. |
|
| 47 | 47 | pub struct SnapshotResult { |
|
| 48 | + | /// The working-copy change's hash; for a commit's own snapshot, which |
|
| 49 | + | /// does not encode it, the hash it had before. |
|
| 48 | 50 | pub wc_hash: ChangeHash, |
|
| 49 | 51 | pub wc: Change, |
|
| 50 | 52 | /// The base state (closure of the working-copy change's dependencies). |
|
| … | 304 unchanged lines |
||
| 355 | 357 | if self.config.sign_changes && ours && self.secret_key()?.is_none() { |
|
| 356 | 358 | return fail("core.sign is \"change\" but no secret key is available for author.key"); |
|
| 357 | 359 | } |
|
| 360 | + | let storing = crate::timing::phase("store: the change"); |
|
| 358 | 361 | let hash = self.store.put_change(change)?; |
|
| 362 | + | drop(storing); |
|
| 359 | 363 | if self.config.sign_changes && ours { |
|
| 364 | + | let _t = crate::timing::phase("store: sign it"); |
|
| 360 | 365 | self.sign_change(&hash)?; |
|
| 361 | 366 | } |
|
| 362 | 367 | Ok(hash) |
|
| … | 213 unchanged lines |
||
| 576 | 581 | // Encoded once, its texts stored a batch at a time as they are |
|
| 577 | 582 | // made (a stored blob is not stored again); the change itself only |
|
| 578 | 583 | // when it changed. |
|
| 579 | - | let encoded = wc.encode_streaming(|batch| self.store.put_texts(&batch))?; |
|
| 580 | - | let new_hash = crate::hash::hash_object(crate::hash::ObjectType::Change, &encoded); |
|
| 581 | - | let changed = new_hash != old_hash; |
|
| 582 | - | // For a commit the change is neither stored nor recorded: the commit |
|
| 583 | - | // stores its own, built from this one's operations. |
|
| 584 | + | // For a commit the change is neither encoded, stored nor recorded: |
|
| 585 | + | // the commit stores its own, built from this one's operations, and |
|
| 586 | + | // its texts with it. The hash it would have is not needed. |
|
| 587 | + | let (new_hash, changed, encoded) = if for_commit { |
|
| 588 | + | (old_hash, true, Vec::new()) |
|
| 589 | + | } else { |
|
| 590 | + | let encoded = wc.encode_streaming(|batch| self.store.put_texts(&batch))?; |
|
| 591 | + | let new_hash = crate::hash::hash_object(crate::hash::ObjectType::Change, &encoded); |
|
| 592 | + | (new_hash, new_hash != old_hash, encoded) |
|
| 593 | + | }; |
|
| 584 | 594 | if changed && !for_commit { |
|
| 585 | 595 | self.store.put(crate::hash::ObjectType::Change, &encoded)?; |
|
| 586 | 596 | let mut view = op.view.clone(); |
|
| … | 2290 unchanged lines |
modified crates/oxy-core/src/store.rs +3 −0
| … | 1092 unchanged lines |
||
| 1093 | 1093 | /// Store a change and the text blobs (and their chunks) its large |
|
| 1094 | 1094 | /// inserts are stored as (spec §6.1). |
|
| 1095 | 1095 | pub fn put_change(&self, c: &Change) -> Result<ObjectRef> { |
|
| 1096 | + | let encoding = crate::timing::phase("store: encode the change and its texts"); |
|
| 1096 | 1097 | let bytes = c.encode_streaming(|batch| self.put_texts(&batch))?; |
|
| 1098 | + | drop(encoding); |
|
| 1099 | + | let _t = crate::timing::phase("store: put the encoding"); |
|
| 1097 | 1100 | self.put(ObjectType::Change, &bytes) |
|
| 1098 | 1101 | } |
|
| 1099 | 1102 | ||
| … | 947 unchanged lines |
modified src/cli/workspace.rs +3 −2
| … | 286 unchanged lines |
||
| 287 | 287 | }; |
|
| 288 | 288 | let sp = ui::spinner("Committing…"); |
|
| 289 | 289 | let h = repo.commit_selection(message, &deps, &selection)?; |
|
| 290 | - | let c = repo.store().get_change(&h)?; |
|
| 290 | + | // The metadata only: a first commit's change holds the whole tree. |
|
| 291 | + | let meta = repo.store().get_change_meta(&h)?; |
|
| 291 | 292 | sp.done(&format!( |
|
| 292 | 293 | "{} {} {} {}", |
|
| 293 | 294 | ui::done("Committed"), |
|
| 294 | - | ui::id(short_id(&c)), |
|
| 295 | + | ui::id(meta.change_id.short(8)), |
|
| 295 | 296 | ui::hash(format!("#{}", h.short(12))), |
|
| 296 | 297 | ui::subject(subject_of(message)) |
|
| 297 | 298 | )); |
|
| … | 2075 unchanged lines |