use gix::bstr::ByteSlice;
use gix::prelude::ObjectIdExt;
use crate::db::Store;
use crate::error::{Error, Result};
use crate::tree::format::parse_path_parts;
use crate::types::{
Target, TargetType, ValueType, LIST_VALUE_DIR, SET_VALUE_DIR, STRING_VALUE_BLOB, TOMBSTONE_ROOT,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommitChange {
pub op: char,
pub target_type: String,
pub target_value: String,
pub key: String,
}
pub fn parse_commit_changes(message: &str) -> Option<Vec<CommitChange>> {
if !is_serialize_commit_message(message) {
return None;
}
if commit_changes_omitted(message) {
return None;
}
let body_start = message.find("\n\n")?;
let body = &message[body_start + 2..];
let mut changes = Vec::new();
for line in body.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.splitn(3, '\t').collect();
if parts.len() != 3 {
continue;
}
let op = parts[0].chars().next()?;
let target_label = parts[1];
let key = parts[2].to_string();
let (target_type, target_value) = if target_label == "project" {
("project".to_string(), String::new())
} else if let Some((t, v)) = target_label.split_once(':') {
(t.to_string(), v.to_string())
} else {
continue;
};
changes.push(CommitChange {
op,
target_type,
target_value,
key,
});
}
Some(changes)
}
#[must_use]
pub fn commit_changes_omitted(message: &str) -> bool {
if !is_serialize_commit_message(message) {
return false;
}
let Some(body_start) = message.find("\n\n") else {
return false;
};
let body = &message[body_start + 2..];
body.contains("changes-omitted: true")
}
fn is_serialize_commit_message(message: &str) -> bool {
message.starts_with("git-meta: serialize") || message.starts_with("gmeta: serialize")
}
pub fn insert_promisor_entries(
repo: &gix::Repository,
store: &Store,
tip_oid: gix::ObjectId,
old_tip: Option<gix::ObjectId>,
) -> Result<usize> {
let mut walk = repo.rev_walk(Some(tip_oid));
if let Some(old) = old_tip {
walk = walk.with_boundary(Some(old));
}
let iter = walk
.all()
.map_err(|e| Error::Other(format!("rev_walk failed: {e}")))?;
let mut count = 0;
let mut is_tip = true;
for info_result in iter {
let info = info_result.map_err(|e| Error::Other(format!("rev_walk iter: {e}")))?;
let oid = info.id;
if is_tip {
is_tip = false;
continue;
}
if old_tip.is_some() && Some(oid) == old_tip {
break;
}
let commit_obj = oid
.attach(repo)
.object()
.map_err(|e| Error::Other(format!("{e}")))?;
let commit = commit_obj.into_commit();
let message = commit.message_raw_sloppy().to_str_lossy().to_string();
match parse_commit_changes(&message) {
Some(changes) => {
for change in &changes {
if change.op == 'D' {
continue;
}
let target_type = change.target_type.parse::<TargetType>()?;
let target = if target_type == TargetType::Project {
Target::project()
} else {
Target::from_parts(target_type, Some(change.target_value.clone()))
};
if store.insert_promised(&target, &change.key, &ValueType::String)? {
count += 1;
}
}
}
None => {
let decoded = commit.decode().map_err(|e| Error::Other(format!("{e}")))?;
if decoded.parents().count() == 0 || commit_changes_omitted(&message) {
let tree_id = commit
.tree_id()
.map_err(|e| Error::Other(format!("{e}")))?
.detach();
count += insert_promised_tree_keys(repo, store, tree_id)?;
}
}
}
}
Ok(count)
}
fn insert_promised_tree_keys(
repo: &gix::Repository,
store: &Store,
tree_id: gix::ObjectId,
) -> Result<usize> {
let keys = extract_keys_from_tree(repo, tree_id)?;
let mut count = 0;
for (target_type_str, target_value, key) in &keys {
let target_type = target_type_str.parse::<TargetType>()?;
let target = if target_type == TargetType::Project {
Target::project()
} else {
Target::from_parts(target_type, Some(target_value.clone()))
};
if store.insert_promised(&target, key, &ValueType::String)? {
count += 1;
}
}
Ok(count)
}
pub fn extract_keys_from_tree(
repo: &gix::Repository,
tree_id: gix::ObjectId,
) -> Result<Vec<(String, String, String)>> {
let mut keys = Vec::new();
let mut paths = Vec::new();
collect_blob_paths(repo, tree_id, String::new(), &mut paths)?;
for path in &paths {
if let Some(parsed) = parse_tree_path(path) {
keys.push(parsed);
}
}
keys.sort();
keys.dedup();
Ok(keys)
}
fn collect_blob_paths(
repo: &gix::Repository,
tree_id: gix::ObjectId,
prefix: String,
paths: &mut Vec<String>,
) -> Result<()> {
let tree = tree_id
.attach(repo)
.object()
.map_err(|e| Error::Other(format!("{e}")))?
.into_tree();
for entry_result in tree.iter() {
let entry = entry_result.map_err(|e| Error::Other(format!("{e}")))?;
let name = entry.filename().to_str_lossy().to_string();
let full_path = if prefix.is_empty() {
name.clone()
} else {
format!("{prefix}{name}")
};
if entry.mode().is_blob() {
paths.push(full_path);
} else if entry.mode().is_tree() {
collect_blob_paths(repo, entry.object_id(), format!("{full_path}/"), paths)?;
}
}
Ok(())
}
fn parse_tree_path(path: &str) -> Option<(String, String, String)> {
let parts: Vec<&str> = path.split('/').collect();
if parts.len() < 2 {
return None;
}
if parts.contains(&TOMBSTONE_ROOT) {
return None;
}
let value_type_marker = if parts.contains(&STRING_VALUE_BLOB) {
STRING_VALUE_BLOB
} else if parts.contains(&LIST_VALUE_DIR) {
LIST_VALUE_DIR
} else if parts.contains(&SET_VALUE_DIR) {
SET_VALUE_DIR
} else {
return None;
};
let (target_type, target_value, key_parts) = parse_path_parts(&parts).ok()?;
let marker_pos = key_parts.iter().position(|&p| p == value_type_marker)?;
if marker_pos == 0 {
return None;
}
let key = key_parts[..marker_pos].join(":");
Some((target_type.as_str().to_string(), target_value, key))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_parse_commit_changes_normal() {
let msg = "git-meta: serialize (3 changes)\n\n\
A\tcommit:abc123\tagent:model\n\
M\tproject\tmeta:prune:since\n\
D\tbranch:main\treview:status";
let changes = parse_commit_changes(msg).unwrap();
assert_eq!(changes.len(), 3);
assert_eq!(changes[0].op, 'A');
assert_eq!(changes[0].target_type, "commit");
assert_eq!(changes[0].target_value, "abc123");
assert_eq!(changes[0].key, "agent:model");
assert_eq!(changes[2].op, 'D');
}
#[test]
fn test_parse_commit_changes_legacy_gmeta_prefix() {
let msg = "gmeta: serialize (1 changes)\n\nA\tproject\told_key";
let changes = parse_commit_changes(msg).unwrap();
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].key, "old_key");
}
#[test]
fn test_parse_commit_changes_non_git_meta() {
assert_eq!(parse_commit_changes("fix: some bug"), None);
}
#[test]
fn test_parse_commit_changes_omitted() {
let msg = "git-meta: serialize (5000 changes)\n\nchanges-omitted: true\ncount: 5000";
assert_eq!(parse_commit_changes(msg), None);
}
#[test]
fn test_parse_commit_changes_no_body() {
let msg = "git-meta: serialize (0 changes)";
assert_eq!(parse_commit_changes(msg), None);
}
#[test]
fn test_parse_tree_path_commit() {
let path = "commit/ab/abc123def456/agent/model/__value";
let result = parse_tree_path(path).unwrap();
assert_eq!(
result,
("commit".into(), "abc123def456".into(), "agent:model".into())
);
}
#[test]
fn test_parse_tree_path_project() {
let path = "project/testing/__value";
let result = parse_tree_path(path).unwrap();
assert_eq!(result, ("project".into(), String::new(), "testing".into()));
}
#[test]
fn test_parse_tree_path_tombstone_ignored() {
let path = "commit/ab/abc123/__tombstones/key/__deleted";
assert_eq!(parse_tree_path(path), None);
}
#[test]
fn test_parse_tree_path_list() {
let path = "commit/ab/abc123/tags/__list/12345-abcde";
let result = parse_tree_path(path).unwrap();
assert_eq!(result, ("commit".into(), "abc123".into(), "tags".into()));
}
#[test]
fn test_parse_tree_path_branch() {
let path = "branch/ab/feature-x/review/status/__value";
let result = parse_tree_path(path).unwrap();
assert_eq!(
result,
("branch".into(), "feature-x".into(), "review:status".into())
);
}
#[test]
fn test_parse_tree_path_branch_with_slash() {
let path = "branch/a6/alex/trails-multi-pr-a57e52c3/review/status/__value";
let result = parse_tree_path(path).unwrap();
assert_eq!(
result,
(
"branch".into(),
"alex/trails-multi-pr-a57e52c3".into(),
"review:status".into()
)
);
}
#[test]
fn test_parse_tree_path_project_nested_key() {
let path = "project/meta/prune/since/__value";
let result = parse_tree_path(path).unwrap();
assert_eq!(
result,
("project".into(), String::new(), "meta:prune:since".into())
);
}
}