Skip to main content

grit_lib/
change_id.rs

1//! Change identifiers stored in commit headers.
2//!
3//! Grit records a stable [`ChangeId`] in the `change-id` header (and reads the
4//! legacy `gitbutler-change-id` name). Identifiers can be randomly generated,
5//! derived from a commit id (Jujutsu-compatible), or controlled via
6//! [`ChangeIdPolicy`] and repository config.
7//!
8//! Library guide: [change ids](https://grit-scm.com/docs/library/change-ids)
9//!
10//! ```rust
11//! use grit_lib::change_id::{ChangeId, ChangeIdPolicy};
12//! # fn main() -> Result<(), grit_lib::change_id::ChangeIdError> {
13//! let id = ChangeId::parse_reverse_hex("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz")?;
14//! assert_eq!(id.as_str(), "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz");
15//! let _policy = ChangeIdPolicy::from_config(&grit_lib::config::ConfigSet::new());
16//! # Ok(())
17//! # }
18//! ```
19
20use 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";
46/// Minimum reachable commits before parallel stored-header scans (see `group_by` author walks).
47const 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/// A change identifier as stored in a commit header (reverse-hex or opaque text).
53#[derive(Debug, Clone, PartialEq, Eq, Hash)]
54pub struct ChangeId(String);
55
56/// Failure modes for change-id parsing and generation.
57#[derive(Debug, Error, PartialEq, Eq)]
58pub enum ChangeIdError {
59    /// Reverse-hex form must be exactly 32 characters.
60    #[error("change id reverse-hex must be 32 characters")]
61    WrongLength,
62    /// A character is outside the reverse-hex alphabet (`k`–`z`).
63    #[error("invalid character in change id reverse-hex")]
64    InvalidCharacter,
65    /// OS randomness failed.
66    #[error("failed to read random bytes for change id: {0}")]
67    RandomFailed(getrandom::Error),
68    /// SHA-1 collision detection rejected commit bytes while deriving a content hash.
69    #[error("sha-1 hash collision while deriving change id")]
70    HashCollision,
71}
72
73/// Injectable source of random change-id bytes.
74pub trait ChangeIdGenerator: Send + Sync {
75    /// Return 16 fresh random bytes for [`ChangeId::from_bytes`].
76    ///
77    /// # Errors
78    ///
79    /// Returns [`ChangeIdError::RandomFailed`] when entropy is unavailable.
80    fn random_bytes(&self) -> std::result::Result<[u8; 16], ChangeIdError>;
81}
82
83/// Default generator backed by the OS CSPRNG (`getrandom`).
84#[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/// A commit whose resolved change id matched a prefix lookup.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct ChangeIdMatch {
98    /// Commit object id.
99    pub oid: ObjectId,
100    /// Stored or derived change id for that commit.
101    pub change_id: ChangeId,
102}
103
104/// Resolved change id plus whether it came from a stored header.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct ResolvedChangeId {
107    /// The effective change id.
108    pub id: ChangeId,
109    /// `true` when read from `change-id` or `gitbutler-change-id`.
110    pub stored: bool,
111}
112
113/// Repository and testing knobs for assigning change ids to new commits.
114#[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    /// Encode 16 raw bytes as GitButler reverse-hex (32 characters).
129    #[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    /// Generate a random id using `generator`.
140    ///
141    /// # Errors
142    ///
143    /// Propagates [`ChangeIdError::RandomFailed`] from the generator.
144    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    /// Parse strict reverse-hex form (32 chars, alphabet `k`–`z`).
150    ///
151    /// # Errors
152    ///
153    /// Returns [`ChangeIdError::WrongLength`] or [`ChangeIdError::InvalidCharacter`].
154    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    /// Whether the stored value is valid reverse-hex.
165    #[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    /// Full header value.
171    #[must_use]
172    pub fn as_str(&self) -> &str {
173        &self.0
174    }
175
176    /// Prefix of at most `n` Unicode scalar values for display.
177    #[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    /// Jujutsu/GitButler deterministic id from a commit object id.
191    #[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    /// Load `grit.changeIds` (default true) and `grit.testing.changeId`.
226    #[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    /// Whether this policy assigns an id to newly written commits.
248    #[must_use]
249    pub fn enabled(&self) -> bool {
250        self.enabled
251    }
252
253    /// Compute the change id for a new commit, if policy allows.
254    ///
255    /// # Errors
256    ///
257    /// Propagates random generation failures.
258    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/// Read `change-id`, falling back to `gitbutler-change-id`.
290#[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
309/// Set or replace the `change-id` header (before any signature block).
310///
311/// When a canonical `change-id` header already exists, a coexisting legacy
312/// `gitbutler-change-id` line is left intact (history rewrite preserves both).
313/// Legacy-only commits are migrated by replacing the legacy header.
314pub 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            // Legacy-only commits migrate to `change-id`; keep legacy when both are present.
323            key == LEGACY_CHANGE_ID_KEY && !had_canonical
324        });
325}
326
327/// Stored header when present, otherwise [`ChangeId::derived_from_commit_id`].
328#[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
340/// The change id `oid` reports: its stored header, or, when the repository's
341/// [`ChangeIdPolicy`] is enabled, the id derived from its commit id.
342///
343/// # Errors
344///
345/// Propagates object read, commit parse, and config load failures.
346pub(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/// Whether `spec` can name a change id (4–32 chars, alphabet `k`–`z`).
362#[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
368/// Walk commits reachable from all refs and `HEAD`, returning those whose resolved
369/// change id starts with `prefix`.
370///
371/// # Errors
372///
373/// Propagates ref listing, graph walk, and object read failures.
374pub 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
437/// Drop cached and persisted change-id header probe state after the ODB changes.
438pub(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
500/// Cheap ODB layout stamp for validating persisted/in-memory negative probe caches.
501fn 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/// Parse `change-id` / legacy header values from commit object bytes up to the message blank line.
763#[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
817/// Resolve a change-id token (full id or unique prefix) to a commit oid.
818///
819/// # Errors
820///
821/// Returns [`Error::ObjectNotFound`] when nothing matches,
822/// [`Error::RevParse`] with [`RevParseError::AmbiguousChangeId`] when several commits match.
823pub 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}