use std::collections::HashSet;
use std::path::Path;
use std::process::{Command, Stdio};
use chrono::{DateTime, Utc};
use super::{ImportResult, ImportSource, RepoFact, SourceRefusal, push_repo_fact};
pub const DEFAULT_LIMIT: usize = 500;
pub const MAX_LIMIT: usize = 5000;
const MAX_LOG_BYTES: usize = 16 * 1024 * 1024;
const FIELD: char = '\u{1f}';
const RECORD: char = '\u{1e}';
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Commit {
pub sha: String,
pub time: Option<DateTime<Utc>>,
pub subject: String,
pub body: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Revert,
Fix,
Decision,
}
impl Kind {
#[must_use]
pub const fn category(self) -> &'static str {
match self {
Self::Revert => "dead_end",
Self::Fix => "experience",
Self::Decision => "decision",
}
}
}
const DECISION_WORDS: [&str; 12] = [
"decide",
"decided",
"decision",
"adr",
"switch to",
"switched to",
"replace ",
"instead of",
"migrate to",
"migrated to",
"drop support",
"deprecate",
];
#[must_use]
pub fn classify(commit: &Commit) -> Option<Kind> {
let subject = commit.subject.trim();
let lower = subject.to_lowercase();
if lower.starts_with("revert ") || commit.body.contains("This reverts commit ") {
return Some(Kind::Revert);
}
let conventional_fix = ["fix", "bugfix", "hotfix"].iter().any(|kind| {
lower.strip_prefix(kind).is_some_and(|rest| {
rest.starts_with(':') || rest.starts_with('(') || rest.starts_with("!:")
})
});
if conventional_fix || lower.starts_with("fix ") || lower.starts_with("fixed ") {
return Some(Kind::Fix);
}
let message = format!("{lower}\n{}", commit.body.to_lowercase());
if DECISION_WORDS.iter().any(|word| message.contains(word)) {
return Some(Kind::Decision);
}
None
}
fn without_prefix(subject: &str) -> &str {
match subject.split_once(": ") {
Some((head, rest))
if !head.is_empty()
&& head.len() <= 40
&& head
.chars()
.all(|c| c.is_alphanumeric() || "()!/-_.,".contains(c)) =>
{
rest
}
_ => subject,
}
}
fn is_trailer(line: &str) -> bool {
line.split_once(": ").is_some_and(|(key, _)| {
!key.is_empty() && key.len() <= 40 && key.chars().all(|c| c.is_alphanumeric() || c == '-')
})
}
fn names_a_person(line: &str) -> bool {
line.split_whitespace().any(|word| {
let word = word.trim_matches(|c: char| "<>()[],;\"'".contains(c));
word.split_once('@').is_some_and(|(user, host)| {
!user.is_empty() && host.contains('.') && !host.starts_with('.') && !host.ends_with('.')
})
})
}
#[must_use]
pub fn without_identities(body: &str) -> String {
body.lines()
.filter(|line| !is_trailer(line.trim()) && !names_a_person(line))
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_owned()
}
fn reason(body: &str) -> Option<&str> {
body.lines().map(str::trim).find(|line| {
!line.is_empty() && !line.starts_with("This reverts commit ") && !is_trailer(line)
})
}
#[must_use]
pub fn statement(commit: &Commit, kind: Kind) -> String {
let short = &commit.sha[..commit.sha.len().min(12)];
let subject = commit.subject.trim();
let mut text = match kind {
Kind::Revert => {
let reverted = subject
.strip_prefix("Revert \"")
.and_then(|rest| rest.strip_suffix('"'))
.unwrap_or(subject);
format!("Tried and reverted: {}", without_prefix(reverted))
}
Kind::Fix => format!("Fixed: {}", without_prefix(subject)),
Kind::Decision => format!("Decided: {}", without_prefix(subject)),
};
if let Some(reason) = reason(&commit.body) {
text.push_str(" — ");
text.push_str(reason);
}
text.push_str(&format!(" (commit {short})"));
text
}
#[must_use]
pub fn parse_log(log: &str) -> Vec<Commit> {
log.split(RECORD)
.filter_map(|record| {
let record = record.trim_start_matches('\n');
let mut fields = record.splitn(4, FIELD);
let sha = fields.next()?.trim();
if sha.len() < 7 || !sha.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let time = fields
.next()
.and_then(|time| DateTime::parse_from_rfc3339(time.trim()).ok())
.map(|time| time.with_timezone(&Utc));
Some(Commit {
sha: sha.to_owned(),
time,
subject: fields.next().unwrap_or_default().trim().to_owned(),
body: fields.next().unwrap_or_default().trim().to_owned(),
})
})
.collect()
}
fn read_log(root: &Path, limit: usize) -> Option<String> {
use std::io::Read;
let mut child = Command::new("git")
.arg("-C")
.arg(root)
.args([
"-c",
"core.quotepath=off",
"log",
"--no-merges",
"--no-color",
&format!("--max-count={limit}"),
"--format=%H%x1f%cI%x1f%s%x1f%b%x1e",
])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
let mut bytes = Vec::new();
child
.stdout
.take()?
.take(MAX_LOG_BYTES as u64)
.read_to_end(&mut bytes)
.ok()?;
let _ = child.kill();
let status = child.wait().ok()?;
let text = String::from_utf8_lossy(&bytes).into_owned();
if bytes.len() >= MAX_LOG_BYTES {
return Some(
text.rsplit_once(RECORD)
.map(|(head, _)| head.to_owned())
.unwrap_or_default(),
);
}
status.success().then_some(text)
}
#[must_use]
pub fn import(project_root: &Path, limit: usize) -> ImportResult {
let mut result = ImportResult::default();
let Some(log) = read_log(project_root, limit.clamp(1, MAX_LIMIT)) else {
result.refuse(SourceRefusal::Unreadable);
return result;
};
let gateway = crate::core::context_admission::carrier::gateway_snapshot();
let rules = Some(gateway.admission_policy());
let mut seen = HashSet::new();
for mut commit in parse_log(&log) {
result.sessions_found += 1;
commit.body = without_identities(&commit.body);
let Some(kind) = classify(&commit) else {
continue;
};
let message = format!("{}\n\n{}", commit.subject, commit.body);
if crate::core::context_admission::carrier::admit_for_store_under(&message, rules)
.ok()
.is_none_or(|admitted| admitted != message)
{
result.facts_skipped += 1;
continue;
}
let short = commit.sha[..commit.sha.len().min(12)].to_owned();
push_repo_fact(
&mut result,
&mut seen,
ImportSource::Git,
&format!("git:{short}"),
rules,
RepoFact {
category: kind.category(),
key: format!("git:{}", commit.sha),
value: statement(&commit, kind),
confidence: 0.6,
validity: None,
valid_from: commit.time,
anchors: Vec::new(),
},
);
}
result.facts_extracted = result.facts.len();
result
}
#[cfg(test)]
#[path = "git_history_tests.rs"]
mod tests;