1use std::collections::hash_map::DefaultHasher;
21use std::collections::HashSet;
22use std::fmt;
23use std::fs;
24use std::hash::{Hash, Hasher};
25use std::path::{Path, PathBuf};
26use std::time::SystemTime;
27
28use thiserror::Error;
29
30use crate::config::ConfigSet;
31use crate::error::{Error, Result};
32use crate::objects::{
33 extra_header_values, insert_header_before_signature, read_commit_subject_line,
34 remove_header_lines, serialize_commit, CommitData, HashAlgo, ObjectId, ObjectKind,
35};
36use crate::pack::SingleLocalPackReader;
37use crate::parallel::{try_par_map_with_local, ParallelOp};
38use crate::repo::Repository;
39use crate::repo_caches::RepoCaches;
40use crate::rev_list::{all_ref_commit_tips_for_walk, walk_closure_ordered, CommitGraph};
41use crate::rev_parse_error::{AmbiguousChangeIdHint, RevParseError};
42
43const REVERSE_HEX_LEN: usize = 32;
44const CHANGE_ID_KEY: &[u8] = b"change-id";
45const LEGACY_CHANGE_ID_KEY: &[u8] = b"gitbutler-change-id";
46const PARALLEL_STORED_CHANGE_ID_SCAN_MIN: usize = 8192;
48const CHANGE_ID_PROBE_MARK: &str = "grit-change-id-probe";
49const PROBE_PRESENT: &[u8] = b"present\n";
50const PROBE_ABSENT: &[u8] = b"absent\n";
51
52#[derive(Debug, Clone, PartialEq, Eq, Hash)]
54pub struct ChangeId(String);
55
56#[derive(Debug, Error, PartialEq, Eq)]
58pub enum ChangeIdError {
59 #[error("change id reverse-hex must be 32 characters")]
61 WrongLength,
62 #[error("invalid character in change id reverse-hex")]
64 InvalidCharacter,
65 #[error("failed to read random bytes for change id: {0}")]
67 RandomFailed(getrandom::Error),
68 #[error("sha-1 hash collision while deriving change id")]
70 HashCollision,
71}
72
73pub trait ChangeIdGenerator: Send + Sync {
75 fn random_bytes(&self) -> std::result::Result<[u8; 16], ChangeIdError>;
81}
82
83#[derive(Debug, Clone, Copy, Default)]
85pub struct OsChangeIdGenerator;
86
87impl ChangeIdGenerator for OsChangeIdGenerator {
88 fn random_bytes(&self) -> std::result::Result<[u8; 16], ChangeIdError> {
89 let mut buf = [0u8; 16];
90 getrandom::getrandom(&mut buf).map_err(ChangeIdError::RandomFailed)?;
91 Ok(buf)
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct ChangeIdMatch {
98 pub oid: ObjectId,
100 pub change_id: ChangeId,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct ResolvedChangeId {
107 pub id: ChangeId,
109 pub stored: bool,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct ChangeIdPolicy {
116 enabled: bool,
117 testing: ChangeIdTestingMode,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
121enum ChangeIdTestingMode {
122 Normal,
123 ContentHash,
124 Verbatim(String),
125}
126
127impl ChangeId {
128 #[must_use]
130 pub fn from_bytes(bytes: &[u8; 16]) -> Self {
131 let mut s = String::with_capacity(REVERSE_HEX_LEN);
132 for byte in bytes {
133 push_reverse_hex_nibble(&mut s, byte >> 4);
134 push_reverse_hex_nibble(&mut s, byte & 0x0f);
135 }
136 Self(s)
137 }
138
139 pub fn generate(generator: &dyn ChangeIdGenerator) -> std::result::Result<Self, ChangeIdError> {
145 let bytes = generator.random_bytes()?;
146 Ok(Self::from_bytes(&bytes))
147 }
148
149 pub fn parse_reverse_hex(s: &str) -> std::result::Result<Self, ChangeIdError> {
155 if s.len() != REVERSE_HEX_LEN {
156 return Err(ChangeIdError::WrongLength);
157 }
158 if !s.bytes().all(is_reverse_hex_byte) {
159 return Err(ChangeIdError::InvalidCharacter);
160 }
161 Ok(Self(s.to_owned()))
162 }
163
164 #[must_use]
166 pub fn is_reverse_hex(&self) -> bool {
167 self.0.len() == REVERSE_HEX_LEN && self.0.bytes().all(is_reverse_hex_byte)
168 }
169
170 #[must_use]
172 pub fn as_str(&self) -> &str {
173 &self.0
174 }
175
176 #[must_use]
178 pub fn short(&self, n: usize) -> &str {
179 if n == 0 {
180 return "";
181 }
182 let end = self
183 .0
184 .char_indices()
185 .nth(n)
186 .map_or(self.0.len(), |(byte_idx, _)| byte_idx);
187 &self.0[..end]
188 }
189
190 #[must_use]
192 pub fn derived_from_commit_id(oid: &ObjectId) -> Self {
193 let raw = oid.as_bytes();
194 let slice = &raw[4..20];
195 let mut transformed = [0u8; 16];
196 for (i, &b) in slice.iter().rev().enumerate() {
197 transformed[i] = b.reverse_bits();
198 }
199 Self::from_bytes(&transformed)
200 }
201
202 #[must_use]
203 fn from_opaque(value: String) -> Self {
204 Self(value)
205 }
206}
207
208impl fmt::Display for ChangeId {
209 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210 f.write_str(&self.0)
211 }
212}
213
214#[cfg(feature = "serde-models")]
215impl serde::Serialize for ChangeId {
216 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
217 where
218 S: serde::Serializer,
219 {
220 serializer.serialize_str(&self.0)
221 }
222}
223
224impl ChangeIdPolicy {
225 #[must_use]
227 pub fn from_config(config: &ConfigSet) -> Self {
228 let enabled = config
229 .get_bool("grit.changeIds")
230 .and_then(|r| r.ok())
231 .unwrap_or(true);
232 let testing = config
233 .get("grit.testing.changeId")
234 .map(|s| s.trim().to_owned())
235 .filter(|s| !s.is_empty())
236 .map(|value| {
237 if value.eq_ignore_ascii_case("content-hash") {
238 ChangeIdTestingMode::ContentHash
239 } else {
240 ChangeIdTestingMode::Verbatim(value)
241 }
242 })
243 .unwrap_or(ChangeIdTestingMode::Normal);
244 Self { enabled, testing }
245 }
246
247 #[must_use]
249 pub fn enabled(&self) -> bool {
250 self.enabled
251 }
252
253 pub fn id_for_new_commit(
259 &self,
260 data: &CommitData,
261 algo: HashAlgo,
262 generator: &dyn ChangeIdGenerator,
263 ) -> std::result::Result<Option<ChangeId>, ChangeIdError> {
264 if !self.enabled {
265 return Ok(None);
266 }
267 match &self.testing {
268 ChangeIdTestingMode::Normal => Ok(Some(ChangeId::generate(generator)?)),
269 ChangeIdTestingMode::ContentHash => {
270 let mut stripped = data.clone();
271 strip_change_id_headers(&mut stripped.extra_headers);
272 let bytes = serialize_commit(&stripped);
273 let digest = algo.digest(&bytes).map_err(|e| match e {
274 crate::error::Error::HashCollision { .. } => ChangeIdError::HashCollision,
275 other => {
276 panic!("unexpected error deriving content-hash change id: {other}")
277 }
278 })?;
279 let raw = digest.as_bytes();
280 let mut chunk = [0u8; 16];
281 chunk.copy_from_slice(&raw[4..20]);
282 Ok(Some(ChangeId::from_bytes(&chunk)))
283 }
284 ChangeIdTestingMode::Verbatim(value) => Ok(Some(ChangeId::from_opaque(value.clone()))),
285 }
286 }
287}
288
289#[must_use]
291pub fn change_id(data: &CommitData) -> Option<ChangeId> {
292 let mut legacy = None;
293 for_each_extra_header(&data.extra_headers, |key, value| {
294 if key == CHANGE_ID_KEY {
295 return Some(ChangeId::from_opaque(
296 String::from_utf8_lossy(value).into_owned(),
297 ));
298 }
299 if key == LEGACY_CHANGE_ID_KEY && legacy.is_none() {
300 legacy = Some(ChangeId::from_opaque(
301 String::from_utf8_lossy(value).into_owned(),
302 ));
303 }
304 None
305 })
306 .or(legacy)
307}
308
309pub fn set_change_id(data: &mut CommitData, id: &ChangeId) {
315 let line = format!("change-id {}\n", id.as_str());
316 let had_canonical = !extra_header_values(&data.extra_headers, "change-id").is_empty();
317 data.extra_headers =
318 insert_header_before_signature(&data.extra_headers, line.as_bytes(), |key| {
319 if key == CHANGE_ID_KEY {
320 return true;
321 }
322 key == LEGACY_CHANGE_ID_KEY && !had_canonical
324 });
325}
326
327#[must_use]
329pub fn resolved_change_id(oid: &ObjectId, data: &CommitData) -> ResolvedChangeId {
330 if let Some(id) = change_id(data) {
331 ResolvedChangeId { id, stored: true }
332 } else {
333 ResolvedChangeId {
334 id: ChangeId::derived_from_commit_id(oid),
335 stored: false,
336 }
337 }
338}
339
340pub(crate) fn logical_change_id_for_commit(
347 repo: &Repository,
348 oid: ObjectId,
349) -> Result<Option<ChangeId>> {
350 let obj = repo.odb().read(&oid)?;
351 let data = crate::objects::parse_commit(&obj.data)?;
352 if let Some(id) = change_id(&data) {
353 return Ok(Some(id));
354 }
355 let config = repo.config()?;
356 Ok(ChangeIdPolicy::from_config(&config)
357 .enabled()
358 .then(|| ChangeId::derived_from_commit_id(&oid)))
359}
360
361#[must_use]
363pub fn is_change_id_spec_token(spec: &str) -> bool {
364 let len = spec.len();
365 (4..=32).contains(&len) && spec.bytes().all(is_reverse_hex_byte)
366}
367
368pub fn find_commits_by_change_id_prefix(
375 repo: &Repository,
376 prefix: &str,
377) -> Result<Vec<ChangeIdMatch>> {
378 if !is_change_id_spec_token(prefix) {
379 return Err(Error::InvalidRef(format!(
380 "invalid change id revision token '{prefix}'"
381 )));
382 }
383 let tips = all_ref_commit_tips_for_walk(repo)?;
384 let mut graph = CommitGraph::with_commit_graph(repo, false, true);
385 let (_, order) = walk_closure_ordered(&mut graph, &tips)?;
386
387 let mut derived_hits = Vec::new();
388 let mut stored_scan = Vec::new();
389 for oid in &order {
390 if ChangeId::derived_from_commit_id(oid)
391 .as_str()
392 .starts_with(prefix)
393 {
394 derived_hits.push(*oid);
395 } else {
396 stored_scan.push(*oid);
397 }
398 }
399
400 let objects_dir = repo.odb().objects_dir();
401 let scan_stored_headers = stored_change_id_headers_likely(repo, objects_dir)?;
402 repo.odb().with_pack_read_context(|| {
403 let mut matches = Vec::new();
404 let mut seen = HashSet::new();
405 let mut pack_reader: Option<SingleLocalPackReader> = None;
406
407 for oid in derived_hits {
408 let derived = ChangeId::derived_from_commit_id(&oid);
409 let resolved =
410 match stored_change_id_from_commit_oid(repo, objects_dir, &mut pack_reader, oid)? {
411 Some(stored) => stored,
412 None => derived,
413 };
414 if resolved.as_str().starts_with(prefix) && seen.insert(oid) {
415 matches.push(ChangeIdMatch {
416 oid,
417 change_id: resolved,
418 });
419 }
420 }
421
422 if scan_stored_headers {
423 let stored_matches =
424 find_stored_change_id_prefix_matches(repo, objects_dir, &stored_scan, prefix)?;
425 for m in stored_matches {
426 if seen.insert(m.oid) {
427 matches.push(m);
428 }
429 }
430 }
431
432 matches.sort_by_key(|m| m.oid.to_hex());
433 Ok(matches)
434 })
435}
436
437pub(crate) fn invalidate_stored_change_id_header_probe(caches: &RepoCaches, objects_dir: &Path) {
439 if let Ok(mut guard) = caches.stored_change_id_header_likely().lock() {
440 *guard = None;
441 }
442 let path = change_id_probe_marker_path(objects_dir);
443 let _ = fs::remove_file(path);
444}
445
446fn stored_change_id_headers_likely(repo: &Repository, objects_dir: &Path) -> Result<bool> {
447 let fingerprint = change_id_probe_odb_fingerprint(objects_dir)?;
448 let cache = repo.caches().stored_change_id_header_likely();
449 if let Some((cached_fp, likely)) = *cache.lock().unwrap_or_else(|e| e.into_inner()) {
450 if cached_fp == fingerprint {
451 return Ok(likely);
452 }
453 }
454 let likely = probe_objects_for_change_id_headers(objects_dir, fingerprint)?;
455 *cache.lock().unwrap_or_else(|e| e.into_inner()) = Some((fingerprint, likely));
456 Ok(likely)
457}
458
459fn probe_objects_for_change_id_headers(objects_dir: &Path, fingerprint: u64) -> Result<bool> {
460 match read_change_id_probe_marker(objects_dir) {
461 Some(ChangeIdProbeMarker::Present) => return Ok(true),
462 Some(ChangeIdProbeMarker::Absent(marker_fp)) if marker_fp == fingerprint => {
463 return Ok(false);
464 }
465 Some(ChangeIdProbeMarker::LegacyAbsent) => {
466 let _ = fs::remove_file(change_id_probe_marker_path(objects_dir));
467 }
468 Some(ChangeIdProbeMarker::Absent(_)) | None => {}
469 }
470 if objects_dir_has_loose_objects(objects_dir) {
471 write_change_id_probe_marker(objects_dir, true, fingerprint)?;
472 return Ok(true);
473 }
474 let pack_dir = objects_dir.join("pack");
475 if pack_dir.is_dir() {
476 for entry in fs::read_dir(&pack_dir).map_err(Error::Io)? {
477 let entry = entry.map_err(Error::Io)?;
478 let path = entry.path();
479 if path.extension().is_none_or(|ext| ext != "pack") {
480 continue;
481 }
482 let data = fs::read(&path).map_err(Error::Io)?;
483 if object_bytes_may_contain_change_id_header(&data) {
484 write_change_id_probe_marker(objects_dir, true, fingerprint)?;
485 return Ok(true);
486 }
487 }
488 }
489 write_change_id_probe_marker(objects_dir, false, fingerprint)?;
490 Ok(false)
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq)]
494enum ChangeIdProbeMarker {
495 Present,
496 Absent(u64),
497 LegacyAbsent,
498}
499
500fn change_id_probe_odb_fingerprint(objects_dir: &Path) -> Result<u64> {
502 let mut hasher = DefaultHasher::new();
503 hash_loose_object_layout(objects_dir, &mut hasher)?;
504 let pack_dir = objects_dir.join("pack");
505 hash_path_metadata(&pack_dir, &mut hasher);
506 if pack_dir.is_dir() {
507 let mut pack_files = Vec::new();
508 for entry in fs::read_dir(&pack_dir).map_err(Error::Io)? {
509 let entry = entry.map_err(Error::Io)?;
510 let path = entry.path();
511 if path.extension().is_none_or(|ext| ext != "pack") {
512 continue;
513 }
514 let meta = fs::metadata(&path).map_err(Error::Io)?;
515 let name = path
516 .file_name()
517 .and_then(|n| n.to_str())
518 .unwrap_or("")
519 .to_owned();
520 pack_files.push((name, meta.len(), system_time_key(meta.modified().ok())));
521 }
522 pack_files.sort_unstable();
523 for file in pack_files {
524 file.hash(&mut hasher);
525 }
526 }
527 Ok(hasher.finish())
528}
529
530fn hash_loose_object_layout(objects_dir: &Path, hasher: &mut DefaultHasher) -> Result<()> {
531 let Ok(entries) = fs::read_dir(objects_dir) else {
532 return Ok(());
533 };
534 let mut loose = Vec::new();
535 for entry in entries.flatten() {
536 let name = entry.file_name();
537 let name = name.to_string_lossy();
538 if name.len() != 2 || !name.bytes().all(|b| b.is_ascii_hexdigit()) {
539 continue;
540 }
541 let Ok(hex_entries) = fs::read_dir(entry.path()) else {
542 continue;
543 };
544 for hex_entry in hex_entries.flatten() {
545 let path = hex_entry.path();
546 if !path.is_file() {
547 continue;
548 }
549 let meta = fs::metadata(&path).map_err(Error::Io)?;
550 let leaf = path
551 .file_name()
552 .and_then(|n| n.to_str())
553 .unwrap_or("")
554 .to_owned();
555 loose.push((
556 format!("{name}{leaf}"),
557 meta.len(),
558 system_time_key(meta.modified().ok()),
559 ));
560 }
561 }
562 loose.sort_unstable();
563 for item in loose {
564 item.hash(hasher);
565 }
566 Ok(())
567}
568
569fn hash_path_metadata(path: &Path, hasher: &mut DefaultHasher) {
570 if let Ok(meta) = fs::metadata(path) {
571 meta.len().hash(hasher);
572 system_time_key(meta.modified().ok()).hash(hasher);
573 }
574}
575
576fn system_time_key(time: Option<SystemTime>) -> (u64, u32) {
577 time.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
578 .map(|d| (d.as_secs(), d.subsec_nanos()))
579 .unwrap_or((0, 0))
580}
581
582fn change_id_probe_marker_path(objects_dir: &Path) -> PathBuf {
583 objects_dir.join("info").join(CHANGE_ID_PROBE_MARK)
584}
585
586fn read_change_id_probe_marker(objects_dir: &Path) -> Option<ChangeIdProbeMarker> {
587 let path = change_id_probe_marker_path(objects_dir);
588 let data = fs::read(path).ok()?;
589 if data.starts_with(PROBE_PRESENT) {
590 return Some(ChangeIdProbeMarker::Present);
591 }
592 if data.starts_with(b"absent ") {
593 let rest = data.strip_prefix(b"absent ")?;
594 let line = rest.split(|&b| b == b'\n').next()?;
595 let hex = std::str::from_utf8(line).ok()?.trim();
596 let fp = u64::from_str_radix(hex, 16).ok()?;
597 return Some(ChangeIdProbeMarker::Absent(fp));
598 }
599 if data.starts_with(PROBE_ABSENT) {
600 return Some(ChangeIdProbeMarker::LegacyAbsent);
601 }
602 None
603}
604
605fn write_change_id_probe_marker(objects_dir: &Path, present: bool, fingerprint: u64) -> Result<()> {
606 let path = change_id_probe_marker_path(objects_dir);
607 if let Some(parent) = path.parent() {
608 fs::create_dir_all(parent).map_err(Error::Io)?;
609 }
610 let payload = if present {
611 PROBE_PRESENT.to_vec()
612 } else {
613 format!("absent {fingerprint:016x}\n").into_bytes()
614 };
615 fs::write(path, payload).map_err(Error::Io)
616}
617
618fn objects_dir_has_loose_objects(objects_dir: &Path) -> bool {
619 let Ok(entries) = fs::read_dir(objects_dir) else {
620 return false;
621 };
622 for entry in entries.flatten() {
623 let name = entry.file_name();
624 let name = name.to_string_lossy();
625 if name.len() != 2 || !name.bytes().all(|b| b.is_ascii_hexdigit()) {
626 continue;
627 }
628 let Ok(hex_entries) = fs::read_dir(entry.path()) else {
629 continue;
630 };
631 if hex_entries.flatten().any(|e| e.path().is_file()) {
632 return true;
633 }
634 }
635 false
636}
637
638fn object_bytes_may_contain_change_id_header(data: &[u8]) -> bool {
639 header_key_present(data, CHANGE_ID_KEY) || header_key_present(data, LEGACY_CHANGE_ID_KEY)
640}
641
642fn find_stored_change_id_prefix_matches(
643 repo: &Repository,
644 objects_dir: &Path,
645 oids: &[ObjectId],
646 prefix: &str,
647) -> Result<Vec<ChangeIdMatch>> {
648 if oids.is_empty() {
649 return Ok(Vec::new());
650 }
651 let config = repo.config()?;
652 let mut par_opts = repo
653 .thread_policy()
654 .resolve(ParallelOp::DiffContent, config.as_ref());
655 par_opts.min_items = PARALLEL_STORED_CHANGE_ID_SCAN_MIN;
656 let total_bytes = oids.len() * crate::pack::COMMIT_HEADER_READ_PREFIX;
657 let scan_results = try_par_map_with_local(
658 oids,
659 par_opts,
660 total_bytes,
661 || ChangeIdHeaderReader { pack_reader: None },
662 |reader, oid| stored_change_id_match_for_oid(repo, objects_dir, reader, oid, prefix),
663 )
664 .map_err(|e| match e {
665 crate::parallel::ParallelHashError::Task(err) => err,
666 })?;
667 Ok(scan_results.into_iter().flatten().collect())
668}
669
670struct ChangeIdHeaderReader {
671 pack_reader: Option<SingleLocalPackReader>,
672}
673
674fn stored_change_id_match_for_oid(
675 repo: &Repository,
676 objects_dir: &Path,
677 reader: &mut ChangeIdHeaderReader,
678 oid: &ObjectId,
679 prefix: &str,
680) -> Result<Option<ChangeIdMatch>> {
681 let Some(stored) =
682 stored_change_id_from_commit_oid(repo, objects_dir, &mut reader.pack_reader, *oid)?
683 else {
684 return Ok(None);
685 };
686 if stored.as_str().starts_with(prefix) {
687 Ok(Some(ChangeIdMatch {
688 oid: *oid,
689 change_id: stored,
690 }))
691 } else {
692 Ok(None)
693 }
694}
695
696fn stored_change_id_from_commit_oid(
697 repo: &Repository,
698 objects_dir: &Path,
699 pack_reader: &mut Option<SingleLocalPackReader>,
700 oid: ObjectId,
701) -> Result<Option<ChangeId>> {
702 let Some(header) = read_commit_header_prefix(repo, objects_dir, pack_reader, oid)? else {
703 return Ok(None);
704 };
705 Ok(change_id_from_commit_object_header(&header))
706}
707
708fn read_commit_header_prefix(
709 repo: &Repository,
710 objects_dir: &Path,
711 pack_reader: &mut Option<SingleLocalPackReader>,
712 oid: ObjectId,
713) -> Result<Option<Vec<u8>>> {
714 for _ in 0..3 {
715 if pack_reader.is_none() {
716 *pack_reader = SingleLocalPackReader::open_allow_loose_for_commits(objects_dir, &oid)?;
717 }
718 let Some(reader) = pack_reader.as_mut() else {
719 break;
720 };
721 reader.clear_commit_header_prefix();
722 if let Some(prefix) = reader.read_commit_author_prefix_unordered(&oid)? {
723 return Ok(Some(prefix.to_vec()));
724 }
725 if !reader.contains_oid(&oid)? {
726 reader.clear_commit_header_prefix();
727 *pack_reader = None;
728 continue;
729 }
730 reader.clear_commit_header_prefix();
731 break;
732 }
733 let Ok(obj) = repo.read_replaced(&oid) else {
734 return Ok(None);
735 };
736 if obj.kind != ObjectKind::Commit {
737 return Ok(None);
738 }
739 Ok(Some(commit_header_bytes(&obj.data).to_vec()))
740}
741
742fn commit_header_bytes(data: &[u8]) -> &[u8] {
743 let mut pos = 0usize;
744 while pos < data.len() {
745 let line_start = pos;
746 let mut line_end = pos;
747 while line_end < data.len() && data[line_end] != b'\n' {
748 line_end += 1;
749 }
750 if line_start == line_end {
751 return &data[..line_start];
752 }
753 pos = line_end.saturating_add(1);
754 }
755 data
756}
757
758fn header_prefix_may_contain_change_id(header: &[u8]) -> bool {
759 header_key_present(header, CHANGE_ID_KEY) || header_key_present(header, LEGACY_CHANGE_ID_KEY)
760}
761
762#[must_use]
764fn change_id_from_commit_object_header(data: &[u8]) -> Option<ChangeId> {
765 if !header_prefix_may_contain_change_id(data) {
766 return None;
767 }
768 let header = commit_header_bytes(data);
769 let mut legacy = None;
770 let mut pos = 0usize;
771 while pos < header.len() {
772 let line_start = pos;
773 let mut line_end = pos;
774 while line_end < header.len() && header[line_end] != b'\n' {
775 line_end += 1;
776 }
777 let line = &header[line_start..line_end];
778 let after_nl = (line_end + 1).min(header.len());
779 if line.is_empty() {
780 break;
781 }
782 if line.first() == Some(&b' ') {
783 pos = after_nl;
784 continue;
785 }
786 let key_end = line.iter().position(|&b| b == b' ').unwrap_or(line.len());
787 let key = &line[..key_end];
788 let value = line.get(key_end + 1..).unwrap_or_default();
789 if key == CHANGE_ID_KEY {
790 return Some(ChangeId::from_opaque(
791 String::from_utf8_lossy(value).into_owned(),
792 ));
793 }
794 if key == LEGACY_CHANGE_ID_KEY && legacy.is_none() {
795 legacy = Some(ChangeId::from_opaque(
796 String::from_utf8_lossy(value).into_owned(),
797 ));
798 }
799 pos = after_nl;
800 while pos < header.len() {
801 let ls = pos;
802 let mut le = pos;
803 while le < header.len() && header[le] != b'\n' {
804 le += 1;
805 }
806 let an = (le + 1).min(header.len());
807 if header.get(ls) == Some(&b' ') {
808 pos = an;
809 } else {
810 break;
811 }
812 }
813 }
814 legacy
815}
816
817pub fn resolve_commit_by_change_id_prefix(repo: &Repository, prefix: &str) -> Result<ObjectId> {
824 let matches = find_commits_by_change_id_prefix(repo, prefix)?;
825 match matches.len() {
826 0 => Err(Error::ObjectNotFound(prefix.to_owned())),
827 1 => Ok(matches[0].oid),
828 _ => Err(RevParseError::AmbiguousChangeId {
829 prefix: prefix.to_owned(),
830 candidates: ambiguous_change_id_hints(repo, &matches)?,
831 }
832 .into()),
833 }
834}
835
836fn ambiguous_change_id_hints(
837 repo: &Repository,
838 matches: &[ChangeIdMatch],
839) -> Result<Vec<AmbiguousChangeIdHint>> {
840 let mut out = Vec::with_capacity(matches.len());
841 for m in matches {
842 let subject = repo
843 .read_replaced(&m.oid)
844 .ok()
845 .and_then(|obj| read_commit_subject_line(&obj.data).ok())
846 .unwrap_or_else(|| "(no subject)".to_owned());
847 out.push(AmbiguousChangeIdHint {
848 oid_hex: m.oid.to_hex(),
849 change_id: m.change_id.as_str().to_owned(),
850 subject,
851 });
852 }
853 Ok(out)
854}
855
856fn header_key_present(data: &[u8], key: &[u8]) -> bool {
857 if data.starts_with(key) {
858 return true;
859 }
860 data.windows(key.len() + 1)
861 .any(|w| w[0] == b'\n' && &w[1..] == key)
862}
863
864fn push_reverse_hex_nibble(out: &mut String, nibble: u8) {
865 debug_assert!(nibble < 16);
866 out.push(char::from(b'z' - nibble));
867}
868
869fn is_reverse_hex_byte(b: u8) -> bool {
870 (b'k'..=b'z').contains(&b)
871}
872
873fn strip_change_id_headers(extra: &mut Vec<u8>) {
874 *extra = remove_header_lines(extra, |key| {
875 key == CHANGE_ID_KEY || key == LEGACY_CHANGE_ID_KEY
876 });
877}
878
879fn for_each_extra_header(
880 extra: &[u8],
881 mut f: impl FnMut(&[u8], &[u8]) -> Option<ChangeId>,
882) -> Option<ChangeId> {
883 let mut pos = 0usize;
884 while pos < extra.len() {
885 let line_start = pos;
886 let mut line_end = pos;
887 while line_end < extra.len() && extra[line_end] != b'\n' {
888 line_end += 1;
889 }
890 let after_nl = (line_end + 1).min(extra.len());
891 let line = &extra[line_start..line_end];
892 if line.first() == Some(&b' ') {
893 pos = after_nl;
894 continue;
895 }
896 let key_end = line.iter().position(|&b| b == b' ').unwrap_or(line.len());
897 let key = &line[..key_end];
898 let value = line.get(key_end + 1..).unwrap_or_default();
899 if let Some(id) = f(key, value) {
900 return Some(id);
901 }
902 pos = after_nl;
903 while pos < extra.len() {
904 let ls = pos;
905 let mut le = pos;
906 while le < extra.len() && extra[le] != b'\n' {
907 le += 1;
908 }
909 let an = (le + 1).min(extra.len());
910 if extra.get(ls) == Some(&b' ') {
911 pos = an;
912 } else {
913 break;
914 }
915 }
916 }
917 None
918}
919
920#[cfg(test)]
921mod tests {
922 use std::sync::Arc;
923
924 use super::*;
925 use crate::config::environment::RepositoryOptions;
926 use crate::repo::{InitOptions, Repository};
927
928 struct FixedGenerator([u8; 16]);
929
930 impl ChangeIdGenerator for FixedGenerator {
931 fn random_bytes(&self) -> std::result::Result<[u8; 16], ChangeIdError> {
932 Ok(self.0)
933 }
934 }
935
936 #[test]
937 fn reverse_hex_roundtrip_and_alphabet() {
938 assert_eq!(
939 ChangeId::from_bytes(&[0x00; 16]).as_str(),
940 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
941 );
942 assert_eq!(
943 ChangeId::from_bytes(&[0xff; 16]).as_str(),
944 "kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk"
945 );
946 let mut one = [0u8; 16];
947 one[0] = 0xc0;
948 assert_eq!(&ChangeId::from_bytes(&one).as_str()[..2], "nz");
949 let bytes = [
950 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34,
951 0x12, 0x34,
952 ];
953 let id = ChangeId::from_bytes(&bytes);
954 assert!(id.is_reverse_hex());
955 assert_eq!(ChangeId::parse_reverse_hex(id.as_str()).expect("parse"), id);
956 }
957
958 #[test]
959 fn parse_rejects_bad_len_and_chars() {
960 assert_eq!(
961 ChangeId::parse_reverse_hex("tooshort"),
962 Err(ChangeIdError::WrongLength)
963 );
964 let mut s = "k".repeat(32);
965 s.replace_range(0..1, "j");
966 assert_eq!(
967 ChangeId::parse_reverse_hex(&s),
968 Err(ChangeIdError::InvalidCharacter)
969 );
970 }
971
972 #[test]
973 fn derived_matches_jujutsu_vectors() {
974 let cases = [
975 (
976 "0123456789abcdef0123456789abcdef01234567",
977 "ltpxnvrzksowmuqyltpxnvrzksowmuqy",
978 ),
979 (
980 "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
981 "xzqxssmkyyvqtoykmxpsztzuqmstmwvx",
982 ),
983 (
984 "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
985 "rqnpwyxsvwyoupllsuqvmymxnxtorovm",
986 ),
987 ];
988 for (hex, want) in cases {
989 let oid = ObjectId::from_hex(hex).expect("oid");
990 assert_eq!(ChangeId::derived_from_commit_id(&oid).as_str(), want);
991 }
992 }
993
994 #[test]
995 fn reads_change_id_before_legacy_header() {
996 let mut data = CommitData {
997 tree: ObjectId::from_hex("4b825dc642cb6eb9a060e54bf8d69288fbee4904").unwrap(),
998 parents: vec![],
999 author: String::new(),
1000 committer: String::new(),
1001 author_raw: Vec::new(),
1002 committer_raw: Vec::new(),
1003 encoding: None,
1004 message: String::new(),
1005 raw_message: None,
1006 extra_headers: b"gitbutler-change-id legacy\nchange-id preferred\n".to_vec(),
1007 };
1008 assert_eq!(
1009 change_id(&data).map(|id| id.as_str().to_owned()),
1010 Some("preferred".to_owned())
1011 );
1012 data.extra_headers = b"gitbutler-change-id only-legacy\n".to_vec();
1013 assert_eq!(
1014 change_id(&data).map(|id| id.as_str().to_owned()),
1015 Some("only-legacy".to_owned())
1016 );
1017 }
1018
1019 #[test]
1020 fn set_replaces_existing_and_inserts_before_gpgsig() {
1021 let mut data = CommitData {
1022 tree: ObjectId::from_hex("4b825dc642cb6eb9a060e54bf8d69288fbee4904").unwrap(),
1023 parents: vec![],
1024 author: String::new(),
1025 committer: String::new(),
1026 author_raw: Vec::new(),
1027 committer_raw: Vec::new(),
1028 encoding: None,
1029 message: String::new(),
1030 raw_message: None,
1031 extra_headers: b"change-id old\nencoding UTF-8\n".to_vec(),
1032 };
1033 let new_id = ChangeId::from_opaque("new-id".to_owned());
1034 set_change_id(&mut data, &new_id);
1035 let raw = String::from_utf8_lossy(&data.extra_headers);
1036 assert!(!raw.contains("change-id old"));
1037 assert!(raw.contains("change-id new-id"));
1038
1039 data.extra_headers = b"encoding UTF-8\n".to_vec();
1040 set_change_id(&mut data, &new_id);
1041 assert!(String::from_utf8_lossy(&data.extra_headers).contains("change-id new-id"));
1042
1043 data.extra_headers =
1044 b"change-id old\ngpgsig -----BEGIN SSH SIGNATURE-----\n sig\n -----END SSH SIGNATURE-----\n"
1045 .to_vec();
1046 set_change_id(&mut data, &new_id);
1047 let raw = String::from_utf8_lossy(&data.extra_headers);
1048 assert!(raw.find("change-id new-id").unwrap() < raw.find("gpgsig").unwrap());
1049
1050 data.extra_headers = b"gitbutler-change-id legacy-only\nencoding UTF-8\n".to_vec();
1051 set_change_id(&mut data, &new_id);
1052 let raw = String::from_utf8_lossy(&data.extra_headers);
1053 assert!(!raw.contains("gitbutler-change-id"));
1054 assert!(!raw.contains("legacy-only"));
1055 assert_eq!(raw.matches("change-id new-id").count(), 1);
1056
1057 data.extra_headers =
1058 b"change-id canonical\ngitbutler-change-id legacy\nencoding UTF-8\n".to_vec();
1059 set_change_id(&mut data, &new_id);
1060 let raw = String::from_utf8_lossy(&data.extra_headers);
1061 assert!(raw.contains("gitbutler-change-id legacy"));
1062 assert!(raw.contains("change-id new-id"));
1063 assert!(!raw.contains("change-id canonical"));
1064
1065 data.extra_headers = b"change-id jj-style-id\ngitbutler-change-id legacy-should-stay\nx-custom custom-value\n"
1066 .to_vec();
1067 set_change_id(&mut data, &ChangeId::from_opaque("jj-style-id".to_owned()));
1068 let raw = String::from_utf8_lossy(&data.extra_headers);
1069 assert!(raw.contains("change-id jj-style-id"));
1070 assert!(raw.contains("gitbutler-change-id legacy-should-stay"));
1071 assert!(raw.contains("x-custom custom-value"));
1072 }
1073
1074 #[test]
1075 fn short_respects_utf8_char_boundaries_for_verbatim_ids() {
1076 let mut cfg = ConfigSet::new();
1077 cfg.add_command_override("grit.testing.changeId", "é")
1078 .expect("override");
1079 let policy = ChangeIdPolicy::from_config(&cfg);
1080 let data = CommitData {
1081 tree: ObjectId::from_hex("4b825dc642cb6eb9a060e54bf8d69288fbee4904").unwrap(),
1082 parents: vec![],
1083 author: String::new(),
1084 committer: String::new(),
1085 author_raw: Vec::new(),
1086 committer_raw: Vec::new(),
1087 encoding: None,
1088 message: String::new(),
1089 raw_message: None,
1090 extra_headers: Vec::new(),
1091 };
1092 let id = policy
1093 .id_for_new_commit(&data, HashAlgo::Sha1, &FixedGenerator([0; 16]))
1094 .expect("ok")
1095 .expect("id");
1096 assert_eq!(id.as_str(), "é");
1097 assert_eq!(id.short(1), "é");
1098 assert_eq!(id.short(0), "");
1099 }
1100
1101 #[test]
1102 fn policy_disabled_and_testing_modes() {
1103 let mut cfg = ConfigSet::new();
1104 cfg.add_command_override("grit.changeIds", "false")
1105 .expect("override");
1106 let policy = ChangeIdPolicy::from_config(&cfg);
1107 assert!(!policy.enabled());
1108 let data = CommitData {
1109 tree: ObjectId::from_hex("4b825dc642cb6eb9a060e54bf8d69288fbee4904").unwrap(),
1110 parents: vec![],
1111 author: "a".into(),
1112 committer: "c".into(),
1113 author_raw: Vec::new(),
1114 committer_raw: Vec::new(),
1115 encoding: None,
1116 message: "m".into(),
1117 raw_message: None,
1118 extra_headers: Vec::new(),
1119 };
1120 let gen = FixedGenerator([1; 16]);
1121 assert!(policy
1122 .id_for_new_commit(&data, HashAlgo::Sha1, &gen)
1123 .expect("ok")
1124 .is_none());
1125
1126 cfg.add_command_override("grit.changeIds", "true")
1127 .expect("override");
1128 cfg.add_command_override("grit.testing.changeId", "content-hash")
1129 .expect("override");
1130 let policy = ChangeIdPolicy::from_config(&cfg);
1131 let id1 = policy
1132 .id_for_new_commit(&data, HashAlgo::Sha1, &gen)
1133 .expect("ok")
1134 .expect("some");
1135 let id2 = policy
1136 .id_for_new_commit(&data, HashAlgo::Sha1, &gen)
1137 .expect("ok")
1138 .expect("some");
1139 assert_eq!(id1, id2);
1140
1141 cfg.add_command_override("grit.testing.changeId", "fixed-test-value")
1142 .expect("override");
1143 let policy = ChangeIdPolicy::from_config(&cfg);
1144 let id = policy
1145 .id_for_new_commit(&data, HashAlgo::Sha1, &gen)
1146 .expect("ok")
1147 .expect("some");
1148 assert_eq!(id.as_str(), "fixed-test-value");
1149 }
1150
1151 #[test]
1152 fn generator_is_injectable() {
1153 let gen = FixedGenerator([0xab; 16]);
1154 let id = ChangeId::generate(&gen).expect("generate");
1155 assert_eq!(id, ChangeId::from_bytes(&[0xab; 16]));
1156 }
1157
1158 #[test]
1159 fn repository_exposes_injected_change_id_generator() -> crate::error::Result<()> {
1160 let gen: Arc<dyn ChangeIdGenerator> = Arc::new(FixedGenerator([0xcd; 16]));
1161 let dir = tempfile::tempdir().map_err(crate::error::Error::Io)?;
1162 let init = Repository::init(dir.path(), &InitOptions::default())?;
1163 let opts = RepositoryOptions::empty()
1164 .with_change_id_generator(Arc::clone(&gen))
1165 .with_work_tree(dir.path());
1166 let repo = Repository::open(init.git_dir(), &opts)?;
1167 let id = ChangeId::generate(repo.change_id_generator().as_ref()).expect("generate");
1168 assert_eq!(id, ChangeId::from_bytes(&[0xcd; 16]));
1169 Ok(())
1170 }
1171
1172 #[test]
1173 fn resolved_change_id_stored_vs_derived() {
1174 let oid = ObjectId::from_hex("0123456789abcdef0123456789abcdef01234567").unwrap();
1175 let mut data = CommitData {
1176 tree: ObjectId::from_hex("4b825dc642cb6eb9a060e54bf8d69288fbee4904").unwrap(),
1177 parents: vec![],
1178 author: String::new(),
1179 committer: String::new(),
1180 author_raw: Vec::new(),
1181 committer_raw: Vec::new(),
1182 encoding: None,
1183 message: String::new(),
1184 raw_message: None,
1185 extra_headers: b"change-id stored\n".to_vec(),
1186 };
1187 let resolved = resolved_change_id(&oid, &data);
1188 assert!(resolved.stored);
1189 assert_eq!(resolved.id.as_str(), "stored");
1190 data.extra_headers.clear();
1191 let resolved = resolved_change_id(&oid, &data);
1192 assert!(!resolved.stored);
1193 assert_eq!(
1194 resolved.id.as_str(),
1195 ChangeId::derived_from_commit_id(&oid).as_str()
1196 );
1197 }
1198
1199 #[test]
1200 fn public_api_surface_smoke() {
1201 let _ = CHANGE_ID_KEY;
1202 let _ = LEGACY_CHANGE_ID_KEY;
1203 let id = ChangeId::from_bytes(&[0; 16]);
1204 let _ = id.short(4);
1205 let _ = format!("{id}");
1206 let policy = ChangeIdPolicy::from_config(&ConfigSet::new());
1207 let _ = policy.enabled();
1208 let _ = OsChangeIdGenerator;
1209 let _ = ResolvedChangeId {
1210 id: id.clone(),
1211 stored: false,
1212 };
1213 let _ = ChangeIdError::WrongLength;
1214 let data = CommitData {
1215 tree: ObjectId::from_hex("4b825dc642cb6eb9a060e54bf8d69288fbee4904").unwrap(),
1216 parents: vec![],
1217 author: String::new(),
1218 committer: String::new(),
1219 author_raw: Vec::new(),
1220 committer_raw: Vec::new(),
1221 encoding: None,
1222 message: String::new(),
1223 raw_message: None,
1224 extra_headers: Vec::new(),
1225 };
1226 let _ = change_id(&data);
1227 set_change_id(&mut { data.clone() }, &id);
1228 let oid = ObjectId::from_hex("0123456789abcdef0123456789abcdef01234567").unwrap();
1229 let _ = resolved_change_id(&oid, &data);
1230 }
1231}