Skip to main content

tracevault_core/
gitai.rs

1use crate::attribution::{Attribution, FileAttribution, LineRange};
2use crate::diff::{DiffLineKind, FileDiff};
3
4/// A parsed git-ai authorship log.
5#[derive(Debug, Clone)]
6pub struct GitAiAuthorshipLog {
7    pub files: Vec<GitAiFileEntry>,
8    pub metadata: Option<serde_json::Value>,
9}
10
11/// Per-file entry from attestation section.
12#[derive(Debug, Clone)]
13pub struct GitAiFileEntry {
14    pub path: String,
15    /// AI-authored line ranges as (start, end) inclusive, 1-indexed.
16    pub ai_line_ranges: Vec<(u32, u32)>,
17}
18
19/// Parse a git-ai note (from `git notes --ref refs/notes/ai show <sha>`).
20///
21/// Format (v3.0.0):
22/// ```text
23/// path/to/file.rs
24///    session_id 94,102,107,120,122-123,128
25/// another/file.rs
26///    session_id 1-5,10
27/// ---
28/// {"schema_version":"authorship/3.0.0",...}
29/// ```
30/// Line specs are comma-separated numbers and ranges (no +/- prefix).
31pub fn parse_gitai_note(note: &str) -> Option<GitAiAuthorshipLog> {
32    let note = note.trim();
33    if note.is_empty() {
34        return None;
35    }
36
37    let separator_pos = note.find("\n---\n").or_else(|| note.find("\n---"))?;
38    let attestation = &note[..separator_pos];
39    let metadata_str = note[separator_pos..]
40        .trim_start_matches('\n')
41        .strip_prefix("---")?;
42    let metadata_str = metadata_str.trim();
43
44    let metadata: Option<serde_json::Value> = if metadata_str.is_empty() {
45        None
46    } else {
47        serde_json::from_str(metadata_str).ok()
48    };
49
50    let files = parse_attestation(attestation);
51    if files.is_empty() {
52        return None;
53    }
54
55    Some(GitAiAuthorshipLog { files, metadata })
56}
57
58fn parse_attestation(text: &str) -> Vec<GitAiFileEntry> {
59    let mut files: Vec<GitAiFileEntry> = Vec::new();
60    let mut current_path: Option<String> = None;
61    let mut current_ranges: Vec<(u32, u32)> = Vec::new();
62
63    for line in text.lines() {
64        if line.is_empty() {
65            continue;
66        }
67
68        // Indented lines are session entries (start with whitespace)
69        if line.starts_with(' ') || line.starts_with('\t') {
70            // Format: "  session_id 94,102,107,120,122-123,128"
71            let tokens: Vec<&str> = line.split_whitespace().collect();
72            // tokens[0] = session_id, tokens[1] = comma-separated line specs
73            if tokens.len() >= 2 {
74                for spec in tokens[1].split(',') {
75                    if let Some((start, end)) = parse_line_range(spec) {
76                        current_ranges.push((start, end));
77                    }
78                }
79            }
80        } else {
81            // Non-indented line = file path; flush previous file
82            if let Some(path) = current_path.take() {
83                files.push(GitAiFileEntry {
84                    path,
85                    ai_line_ranges: std::mem::take(&mut current_ranges),
86                });
87            }
88            current_path = Some(line.to_string());
89        }
90    }
91
92    // Flush last file
93    if let Some(path) = current_path {
94        files.push(GitAiFileEntry {
95            path,
96            ai_line_ranges: current_ranges,
97        });
98    }
99
100    files
101}
102
103/// Parse "1-10" as (1, 10) or "20" as (20, 20).
104fn parse_line_range(s: &str) -> Option<(u32, u32)> {
105    if let Some((start_str, end_str)) = s.split_once('-') {
106        let start = start_str.parse().ok()?;
107        let end = end_str.parse().ok()?;
108        Some((start, end))
109    } else {
110        let n = s.parse().ok()?;
111        Some((n, n))
112    }
113}
114
115/// Convert a git-ai authorship log to tracevault's Attribution format.
116/// `diff_files` provides the full diff so we can identify human-written lines
117/// (lines added in the diff but not listed in the git-ai note).
118pub fn gitai_to_attribution(log: &GitAiAuthorshipLog, diff_files: &[FileDiff]) -> Attribution {
119    use std::collections::{HashMap, HashSet};
120
121    // Build a lookup: file path -> set of AI-authored new line numbers
122    let ai_lines_by_file: HashMap<&str, HashSet<u32>> = log
123        .files
124        .iter()
125        .map(|entry| {
126            let mut lines = HashSet::new();
127            for &(start, end) in &entry.ai_line_ranges {
128                for n in start..=end {
129                    lines.insert(n);
130                }
131            }
132            (entry.path.as_str(), lines)
133        })
134        .collect();
135
136    let mut files: Vec<FileAttribution> = Vec::new();
137
138    for diff_file in diff_files {
139        // Collect all added line numbers and deleted count from the diff
140        let mut added_lines: Vec<u32> = Vec::new();
141        let mut deleted_count: u32 = 0;
142
143        for hunk in &diff_file.hunks {
144            for line in &hunk.lines {
145                match line.kind {
146                    DiffLineKind::Add => {
147                        if let Some(n) = line.new_line_number {
148                            added_lines.push(n);
149                        }
150                    }
151                    DiffLineKind::Delete => {
152                        deleted_count += 1;
153                    }
154                    DiffLineKind::Context => {}
155                }
156            }
157        }
158
159        let ai_set = ai_lines_by_file.get(diff_file.path.as_str());
160
161        // Partition added lines into AI vs human
162        let mut ai_line_nums: Vec<u32> = Vec::new();
163        let mut human_line_nums: Vec<u32> = Vec::new();
164
165        for n in &added_lines {
166            if ai_set.is_some_and(|s| s.contains(n)) {
167                ai_line_nums.push(*n);
168            } else {
169                human_line_nums.push(*n);
170            }
171        }
172
173        let ai_lines = collapse_to_ranges(&mut ai_line_nums);
174        let human_lines = collapse_to_ranges(&mut human_line_nums);
175
176        files.push(FileAttribution {
177            path: diff_file.path.clone(),
178            lines_added: added_lines.len() as u32,
179            lines_deleted: deleted_count,
180            ai_lines,
181            human_lines,
182            mixed_lines: vec![],
183        });
184    }
185
186    let summary = crate::attribution_engine::compute_attribution_summary(&files);
187
188    Attribution { files, summary }
189}
190
191/// Collapse a list of line numbers into contiguous `LineRange`s.
192fn collapse_to_ranges(nums: &mut [u32]) -> Vec<LineRange> {
193    if nums.is_empty() {
194        return vec![];
195    }
196    nums.sort_unstable();
197
198    let mut ranges = Vec::new();
199    let mut start = nums[0];
200    let mut end = nums[0];
201
202    for &n in &nums[1..] {
203        if n == end + 1 {
204            end = n;
205        } else {
206            ranges.push(LineRange { start, end });
207            start = n;
208            end = n;
209        }
210    }
211    ranges.push(LineRange { start, end });
212    ranges
213}