Skip to main content

lean_ctx/core/import/
git_history.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The project's local git history → knowledge facts (K1).
3//!
4//! Reads `git log` of the project (no merges, newest first, at most
5//! `limit` commits) and keeps three kinds of commit, each as one fact with
6//! the commit as its anchor (`git:<full sha>`) and the commit time as
7//! `valid_from`:
8//!
9//! - a **revert** → `dead_end`: what was tried and taken back;
10//! - a **fix** (`fix:`/`fix(…):`/`hotfix`, "Fix …") → `experience`: what broke
11//!   and was repaired;
12//! - a **decision** (the message decides, chooses, switches, replaces,
13//!   migrates or drops) → `decision`.
14//!
15//! Everything else (features, chores, version bumps) is no fact. Each
16//! commit MESSAGE is admitted as a whole for a store before anything is
17//! taken from it — a message the policy or the gateway withholds or
18//! restricts yields nothing and is counted — and each fact built from it is
19//! admitted again (`super::push_repo_fact`). No file content, no diff and
20//! no author identity is read.
21//!
22//! On a machine connected to Hive, `lean-ctx import git` queues the facts it
23//! added or changed as import drafts, each sourced `import:git:<sha>`
24//! (the Hive connector's knowledge import drafts).
25
26use 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
34/// Commits read by default and at most.
35pub const DEFAULT_LIMIT: usize = 500;
36pub const MAX_LIMIT: usize = 5000;
37/// The `git log` output is read up to this many bytes; a log above it is
38/// cut at the last complete commit.
39const MAX_LOG_BYTES: usize = 16 * 1024 * 1024;
40const FIELD: char = '\u{1f}';
41const RECORD: char = '\u{1e}';
42
43/// One commit as `git log` reports it.
44#[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/// What a commit is to the knowledge store.
53#[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/// Classify one commit; `None` for everything that is no fact.
87#[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
109/// `fix(parser): handle x` → `handle x`; a plain subject stays as it is.
110fn 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
125/// A git trailer (`Co-Authored-By: …`, `Signed-off-by: …`): one token, a
126/// colon, a value.
127fn 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
133/// Whether `line` names a person: an email address anywhere in it.
134fn 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/// The body without its trailers (`Co-Authored-By`, `Signed-off-by`, …) and
144/// without any line that names a person by email: identities never reach
145/// admission, a fact or the store.
146#[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
156/// The first non-empty body line that is neither a trailer nor a revert note.
157fn 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/// The statement a fact makes of `commit`.
164#[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/// Parse the output of `git log --format=%H%x1f%cI%x1f%s%x1f%b%x1e`.
188#[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
212/// `git log` of the repository at `root`; `None` when git is missing or
213/// `root` is no repository.
214fn 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        // Cut at the last complete commit.
245        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/// Mine the git history of `project_root` (at most `limit` commits, capped
255/// at [`MAX_LIMIT`]) into facts. Nothing is stored here.
256#[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        // The whole message first: a marker anywhere in it decides for every
273        // fact taken from it (G01).
274        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;