1use std::collections::HashSet;
27use std::path::Path;
28use std::process::{Command, Stdio};
29
30use chrono::{DateTime, Utc};
31
32use super::{ImportResult, ImportSource, RepoFact, SourceRefusal, push_repo_fact};
33
34pub const DEFAULT_LIMIT: usize = 500;
36pub const MAX_LIMIT: usize = 5000;
37const MAX_LOG_BYTES: usize = 16 * 1024 * 1024;
40const FIELD: char = '\u{1f}';
41const RECORD: char = '\u{1e}';
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Commit {
46 pub sha: String,
47 pub time: Option<DateTime<Utc>>,
48 pub subject: String,
49 pub body: String,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum Kind {
55 Revert,
56 Fix,
57 Decision,
58}
59
60impl Kind {
61 #[must_use]
62 pub const fn category(self) -> &'static str {
63 match self {
64 Self::Revert => "dead_end",
65 Self::Fix => "experience",
66 Self::Decision => "decision",
67 }
68 }
69}
70
71const DECISION_WORDS: [&str; 12] = [
72 "decide",
73 "decided",
74 "decision",
75 "adr",
76 "switch to",
77 "switched to",
78 "replace ",
79 "instead of",
80 "migrate to",
81 "migrated to",
82 "drop support",
83 "deprecate",
84];
85
86#[must_use]
88pub fn classify(commit: &Commit) -> Option<Kind> {
89 let subject = commit.subject.trim();
90 let lower = subject.to_lowercase();
91 if lower.starts_with("revert ") || commit.body.contains("This reverts commit ") {
92 return Some(Kind::Revert);
93 }
94 let conventional_fix = ["fix", "bugfix", "hotfix"].iter().any(|kind| {
95 lower.strip_prefix(kind).is_some_and(|rest| {
96 rest.starts_with(':') || rest.starts_with('(') || rest.starts_with("!:")
97 })
98 });
99 if conventional_fix || lower.starts_with("fix ") || lower.starts_with("fixed ") {
100 return Some(Kind::Fix);
101 }
102 let message = format!("{lower}\n{}", commit.body.to_lowercase());
103 if DECISION_WORDS.iter().any(|word| message.contains(word)) {
104 return Some(Kind::Decision);
105 }
106 None
107}
108
109fn without_prefix(subject: &str) -> &str {
111 match subject.split_once(": ") {
112 Some((head, rest))
113 if !head.is_empty()
114 && head.len() <= 40
115 && head
116 .chars()
117 .all(|c| c.is_alphanumeric() || "()!/-_.,".contains(c)) =>
118 {
119 rest
120 }
121 _ => subject,
122 }
123}
124
125fn is_trailer(line: &str) -> bool {
128 line.split_once(": ").is_some_and(|(key, _)| {
129 !key.is_empty() && key.len() <= 40 && key.chars().all(|c| c.is_alphanumeric() || c == '-')
130 })
131}
132
133fn names_a_person(line: &str) -> bool {
135 line.split_whitespace().any(|word| {
136 let word = word.trim_matches(|c: char| "<>()[],;\"'".contains(c));
137 word.split_once('@').is_some_and(|(user, host)| {
138 !user.is_empty() && host.contains('.') && !host.starts_with('.') && !host.ends_with('.')
139 })
140 })
141}
142
143#[must_use]
147pub fn without_identities(body: &str) -> String {
148 body.lines()
149 .filter(|line| !is_trailer(line.trim()) && !names_a_person(line))
150 .collect::<Vec<_>>()
151 .join("\n")
152 .trim()
153 .to_owned()
154}
155
156fn reason(body: &str) -> Option<&str> {
158 body.lines().map(str::trim).find(|line| {
159 !line.is_empty() && !line.starts_with("This reverts commit ") && !is_trailer(line)
160 })
161}
162
163#[must_use]
165pub fn statement(commit: &Commit, kind: Kind) -> String {
166 let short = &commit.sha[..commit.sha.len().min(12)];
167 let subject = commit.subject.trim();
168 let mut text = match kind {
169 Kind::Revert => {
170 let reverted = subject
171 .strip_prefix("Revert \"")
172 .and_then(|rest| rest.strip_suffix('"'))
173 .unwrap_or(subject);
174 format!("Tried and reverted: {}", without_prefix(reverted))
175 }
176 Kind::Fix => format!("Fixed: {}", without_prefix(subject)),
177 Kind::Decision => format!("Decided: {}", without_prefix(subject)),
178 };
179 if let Some(reason) = reason(&commit.body) {
180 text.push_str(" — ");
181 text.push_str(reason);
182 }
183 text.push_str(&format!(" (commit {short})"));
184 text
185}
186
187#[must_use]
189pub fn parse_log(log: &str) -> Vec<Commit> {
190 log.split(RECORD)
191 .filter_map(|record| {
192 let record = record.trim_start_matches('\n');
193 let mut fields = record.splitn(4, FIELD);
194 let sha = fields.next()?.trim();
195 if sha.len() < 7 || !sha.chars().all(|c| c.is_ascii_hexdigit()) {
196 return None;
197 }
198 let time = fields
199 .next()
200 .and_then(|time| DateTime::parse_from_rfc3339(time.trim()).ok())
201 .map(|time| time.with_timezone(&Utc));
202 Some(Commit {
203 sha: sha.to_owned(),
204 time,
205 subject: fields.next().unwrap_or_default().trim().to_owned(),
206 body: fields.next().unwrap_or_default().trim().to_owned(),
207 })
208 })
209 .collect()
210}
211
212fn read_log(root: &Path, limit: usize) -> Option<String> {
215 use std::io::Read;
216 let mut child = Command::new("git")
217 .arg("-C")
218 .arg(root)
219 .args([
220 "-c",
221 "core.quotepath=off",
222 "log",
223 "--no-merges",
224 "--no-color",
225 &format!("--max-count={limit}"),
226 "--format=%H%x1f%cI%x1f%s%x1f%b%x1e",
227 ])
228 .stdin(Stdio::null())
229 .stdout(Stdio::piped())
230 .stderr(Stdio::null())
231 .spawn()
232 .ok()?;
233 let mut bytes = Vec::new();
234 child
235 .stdout
236 .take()?
237 .take(MAX_LOG_BYTES as u64)
238 .read_to_end(&mut bytes)
239 .ok()?;
240 let _ = child.kill();
241 let status = child.wait().ok()?;
242 let text = String::from_utf8_lossy(&bytes).into_owned();
243 if bytes.len() >= MAX_LOG_BYTES {
244 return Some(
246 text.rsplit_once(RECORD)
247 .map(|(head, _)| head.to_owned())
248 .unwrap_or_default(),
249 );
250 }
251 status.success().then_some(text)
252}
253
254#[must_use]
257pub fn import(project_root: &Path, limit: usize) -> ImportResult {
258 let mut result = ImportResult::default();
259 let Some(log) = read_log(project_root, limit.clamp(1, MAX_LIMIT)) else {
260 result.refuse(SourceRefusal::Unreadable);
261 return result;
262 };
263 let gateway = crate::core::context_admission::carrier::gateway_snapshot();
264 let rules = Some(gateway.admission_policy());
265 let mut seen = HashSet::new();
266 for mut commit in parse_log(&log) {
267 result.sessions_found += 1;
268 commit.body = without_identities(&commit.body);
269 let Some(kind) = classify(&commit) else {
270 continue;
271 };
272 let message = format!("{}\n\n{}", commit.subject, commit.body);
275 if crate::core::context_admission::carrier::admit_for_store_under(&message, rules)
276 .ok()
277 .is_none_or(|admitted| admitted != message)
278 {
279 result.facts_skipped += 1;
280 continue;
281 }
282 let short = commit.sha[..commit.sha.len().min(12)].to_owned();
283 push_repo_fact(
284 &mut result,
285 &mut seen,
286 ImportSource::Git,
287 &format!("git:{short}"),
288 rules,
289 RepoFact {
290 category: kind.category(),
291 key: format!("git:{}", commit.sha),
292 value: statement(&commit, kind),
293 confidence: 0.6,
294 validity: None,
295 valid_from: commit.time,
296 anchors: Vec::new(),
297 },
298 );
299 }
300 result.facts_extracted = result.facts.len();
301 result
302}
303
304#[cfg(test)]
305#[path = "git_history_tests.rs"]
306mod tests;