//! Parser for jj change-ids embedded in git commit headers.
//!
//! jj ā„ 0.30 writes a `change-id` header into every commit it creates, which
//! survives amends and rebases. This module extracts and validates those IDs
//! from raw git commit objects.
use std::process::Command;
use tracing::info;
/// A valid jj change-id: exactly 32 lowercase reverse-hex characters (zāk).
///
/// The reverse-hex alphabet uses letters z, y, x, w, v, u, t, s, r, q, p, o,
/// n, m, l, k ā 16 characters total, representing values 0ā15 in reverse order.
const CHANGE_ID_LEN: usize = 32;
/// Validates whether a string is a well-formed jj change-id.
///
/// A valid change-id is exactly 32 characters long and uses only the
/// reverse-hex alphabet: z, y, x, w, v, u, t, s, r, q, p, o, n, m, l, k (16
/// letters from 'k' to 'z' inclusive).
fn is_valid_change_id(s: &str) -> bool {
if s.len() != CHANGE_ID_LEN {
return false;
}
s.chars().all(|c| matches!(c, 'k'..='z'))
}
/// Parses the raw commit headers (everything before the first blank line) and
/// returns the change-id if present and valid.
///
/// The headers are expected in the format git uses: one header per line, with
/// a blank line separating headers from the commit message.
fn parse_change_id_from_headers(headers: &str) -> Option<String> {
for line in headers.lines() {
if let Some(value) = line.strip_prefix("change-id ") {
let value = value.trim();
if is_valid_change_id(value) {
return Some(value.to_string());
}
info!(
change_id = value,
"change-id header present but malformed; treating as absent"
);
return None;
}
}
None
}
/// Reads the raw commit object for the given OID and extracts the change-id,
/// if one is present and valid.
///
/// Returns `None` if the commit has no change-id header, if the header is
/// malformed, or if the object is not a commit.
#[tracing::instrument(level = "debug", skip(bare), fields(bare = %bare.display()))]
pub async fn parse_change_id(bare: &std::path::Path, oid: &str) -> Option<String> {
let output = tokio::process::Command::new("git")
.arg("-C")
.arg(bare)
.arg("cat-file")
.arg("commit")
.arg(oid)
.output()
.await
.ok()?;
if !output.status.success() {
info!(oid, "git cat-file commit failed; no change-id extracted");
return None;
}
let raw = String::from_utf8_lossy(&output.stdout);
// Split at the first blank line: everything before it is headers.
let headers = raw.split("\n\n").next()?;
let change_id = parse_change_id_from_headers(headers);
if let Some(ref cid) = change_id {
info!(%cid, oid, "extracted change-id from commit");
}
change_id
}
/// Synchronous version for use in non-async contexts (e.g., tests).
pub fn parse_change_id_sync(bare: &std::path::Path, oid: &str) -> Option<String> {
let output = Command::new("git")
.arg("-C")
.arg(bare)
.arg("cat-file")
.arg("commit")
.arg(oid)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&output.stdout);
let headers = raw.split("\n\n").next()?;
parse_change_id_from_headers(headers)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_change_id_is_accepted() {
assert!(is_valid_change_id("mwyosuvoswmkponquzmrkrtkyqqwsorp"));
assert!(is_valid_change_id("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"));
assert!(is_valid_change_id("kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk"));
}
#[test]
fn wrong_length_is_rejected() {
assert!(!is_valid_change_id("mwyosuvoswmkponquzmrkrtkyqqwsor")); // 31 chars
assert!(!is_valid_change_id("mwyosuvoswmkponquzmrkrtkyqqwsorpp")); // 33 chars
assert!(!is_valid_change_id(""));
}
#[test]
fn invalid_characters_are_rejected() {
// Contains 'a' which is not in reverse-hex alphabet
assert!(!is_valid_change_id("mwyosuvoswmkponquzmrkrtkyqqwsora"));
// Contains uppercase
assert!(!is_valid_change_id("MWYOSUVOSWMKPONQUZMRKRTKYQQWSORP"));
// Contains digit
assert!(!is_valid_change_id("mwyosuvoswmkponquzmrkrtkyqqwsor1"));
}
#[test]
fn parses_present_change_id() {
let headers = "tree abc123\n\
parent def456\n\
author Test <test@example.com> 1234567890 +0000\n\
committer Test <test@example.com> 1234567890 +0000\n\
change-id mwyosuvoswmkponquzmrkrtkyqqwsorp\n";
assert_eq!(
parse_change_id_from_headers(headers),
Some("mwyosuvoswmkponquzmrkrtkyqqwsorp".to_string())
);
}
#[test]
fn absent_change_id_returns_none() {
let headers = "tree abc123\n\
parent def456\n\
author Test <test@example.com> 1234567890 +0000\n\
committer Test <test@example.com> 1234567890 +0000\n";
assert_eq!(parse_change_id_from_headers(headers), None);
}
#[test]
fn malformed_change_id_returns_none() {
let headers = "tree abc123\n\
change-id tooshort\n";
assert_eq!(parse_change_id_from_headers(headers), None);
}
#[test]
fn change_id_with_invalid_chars_returns_none() {
let headers = "tree abc123\n\
change-id M.WYOSUVOSWMKPONQUZMRKRTKYQQWSORP\n";
assert_eq!(parse_change_id_from_headers(headers), None);
}
}