Skip to main content

open_kioku_git/
lib.rs

1use open_kioku_errors::{OkError, Result};
2use std::collections::HashMap;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7#[derive(Debug, Clone, PartialEq)]
8pub struct CochangeRecord {
9    pub path: PathBuf,
10    pub cochanged_path: PathBuf,
11    pub commit_count: usize,
12    pub recency_weight: f32,
13    pub test_corun: bool,
14    pub commits: Vec<String>,
15}
16
17pub fn discover_root(start: impl AsRef<Path>) -> Result<PathBuf> {
18    let mut current = start.as_ref().canonicalize()?;
19    loop {
20        if current.join(".git").exists() || current.join("ok.toml").exists() {
21            return Ok(current);
22        }
23        if !current.pop() {
24            return Ok(start.as_ref().canonicalize()?);
25        }
26    }
27}
28
29pub fn branch(root: impl AsRef<Path>) -> Option<String> {
30    let head = fs::read_to_string(root.as_ref().join(".git/HEAD")).ok()?;
31    if let Some(value) = head.strip_prefix("ref: refs/heads/") {
32        return Some(value.trim().to_string());
33    }
34    None
35}
36
37pub fn commit(root: impl AsRef<Path>) -> Option<String> {
38    let head = fs::read_to_string(root.as_ref().join(".git/HEAD")).ok()?;
39    if !head.starts_with("ref: ") {
40        return Some(head.trim().to_string());
41    }
42    let reference = head.trim().strip_prefix("ref: ")?;
43    fs::read_to_string(root.as_ref().join(".git").join(reference))
44        .ok()
45        .map(|value| value.trim().to_string())
46}
47
48pub fn require_repo(root: impl AsRef<Path>) -> Result<PathBuf> {
49    let root = discover_root(root)?;
50    if !root.exists() {
51        return Err(OkError::Repository(format!(
52            "repository root does not exist: {}",
53            root.display()
54        )));
55    }
56    Ok(root)
57}
58
59pub fn cochange_records(
60    root: impl AsRef<Path>,
61    max_commits: usize,
62    max_files_per_commit: usize,
63) -> Result<Vec<CochangeRecord>> {
64    let root = root.as_ref();
65    if !root.join(".git").exists() || max_commits == 0 || max_files_per_commit < 2 {
66        return Ok(Vec::new());
67    }
68    let output = Command::new("git")
69        .arg("-C")
70        .arg(root)
71        .arg("log")
72        .arg(format!("--max-count={max_commits}"))
73        .arg("--name-only")
74        .arg("--pretty=format:commit:%H")
75        .output()
76        .map_err(|err| OkError::Repository(format!("git history scan failed: {err}")))?;
77    if !output.status.success() {
78        return Ok(Vec::new());
79    }
80    let stdout = String::from_utf8_lossy(&output.stdout);
81    let mut commits = Vec::new();
82    let mut current_sha: Option<String> = None;
83    let mut current_files = Vec::new();
84    for line in stdout.lines() {
85        if let Some(sha) = line.strip_prefix("commit:") {
86            push_commit(&mut commits, current_sha.take(), &mut current_files);
87            current_sha = Some(sha.trim().to_string());
88        } else {
89            let path = line.trim();
90            if is_history_path(path) {
91                current_files.push(PathBuf::from(path));
92            }
93        }
94    }
95    push_commit(&mut commits, current_sha, &mut current_files);
96
97    let mut pairs: HashMap<(PathBuf, PathBuf), CochangeRecord> = HashMap::new();
98    for (idx, (sha, mut files)) in commits.into_iter().enumerate() {
99        files.sort();
100        files.dedup();
101        if files.len() < 2 || files.len() > max_files_per_commit {
102            continue;
103        }
104        let recency_weight = 1.0 / (1.0 + idx as f32 / 25.0);
105        for left in &files {
106            for right in &files {
107                if left == right {
108                    continue;
109                }
110                let key = (left.clone(), right.clone());
111                let entry = pairs.entry(key).or_insert_with(|| CochangeRecord {
112                    path: left.clone(),
113                    cochanged_path: right.clone(),
114                    commit_count: 0,
115                    recency_weight: 0.0,
116                    test_corun: is_test_path(right),
117                    commits: Vec::new(),
118                });
119                entry.commit_count += 1;
120                entry.recency_weight += recency_weight;
121                entry.test_corun |= is_test_path(right);
122                if entry.commits.len() < 5 {
123                    entry.commits.push(sha.clone());
124                }
125            }
126        }
127    }
128    let mut records = pairs.into_values().collect::<Vec<_>>();
129    records.sort_by(|a, b| {
130        b.recency_weight
131            .partial_cmp(&a.recency_weight)
132            .unwrap_or(std::cmp::Ordering::Equal)
133            .then_with(|| b.commit_count.cmp(&a.commit_count))
134            .then_with(|| a.path.cmp(&b.path))
135            .then_with(|| a.cochanged_path.cmp(&b.cochanged_path))
136    });
137    Ok(records)
138}
139
140fn push_commit(
141    commits: &mut Vec<(String, Vec<PathBuf>)>,
142    sha: Option<String>,
143    files: &mut Vec<PathBuf>,
144) {
145    if let Some(sha) = sha {
146        commits.push((sha, std::mem::take(files)));
147    }
148}
149
150fn is_history_path(path: &str) -> bool {
151    !path.is_empty()
152        && !path.ends_with('/')
153        && !path.starts_with(".git/")
154        && !path.starts_with(".ok/")
155        && !path.contains("=>")
156}
157
158fn is_test_path(path: &Path) -> bool {
159    let value = path.to_string_lossy().to_ascii_lowercase();
160    value.contains("/test/")
161        || value.contains("/tests/")
162        || value.ends_with("_test.rs")
163        || value.ends_with("_test.go")
164        || value.ends_with(".test.ts")
165        || value.ends_with(".spec.ts")
166        || value.ends_with("test.java")
167        || value.ends_with("tests.java")
168}
169
170#[cfg(test)]
171mod tests {
172    use super::cochange_records;
173    use std::process::Command;
174
175    #[test]
176    fn cochange_records_apply_recency_and_test_corun() {
177        let dir = tempfile::tempdir().unwrap();
178        run(dir.path(), &["init"]);
179        run(dir.path(), &["config", "user.email", "test@example.com"]);
180        run(dir.path(), &["config", "user.name", "Test User"]);
181
182        write(dir.path(), "src/old.rs", "fn old() {}\n");
183        write(
184            dir.path(),
185            "tests/old_test.rs",
186            "#[test] fn old_test() {}\n",
187        );
188        run(dir.path(), &["add", "."]);
189        run(dir.path(), &["commit", "-m", "old pair"]);
190
191        write(dir.path(), "src/new.rs", "fn new() {}\n");
192        write(
193            dir.path(),
194            "tests/new_test.rs",
195            "#[test] fn new_test() {}\n",
196        );
197        run(dir.path(), &["add", "."]);
198        run(dir.path(), &["commit", "-m", "new pair"]);
199
200        let records = cochange_records(dir.path(), 20, 10).unwrap();
201        let new_pair = records
202            .iter()
203            .find(|record| {
204                record.path == std::path::Path::new("src/new.rs")
205                    && record.cochanged_path == std::path::Path::new("tests/new_test.rs")
206            })
207            .unwrap();
208        let old_pair = records
209            .iter()
210            .find(|record| {
211                record.path == std::path::Path::new("src/old.rs")
212                    && record.cochanged_path == std::path::Path::new("tests/old_test.rs")
213            })
214            .unwrap();
215
216        assert!(new_pair.test_corun);
217        assert!(new_pair.recency_weight > old_pair.recency_weight);
218        assert_eq!(new_pair.commit_count, 1);
219    }
220
221    fn write(root: &std::path::Path, path: &str, content: &str) {
222        let path = root.join(path);
223        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
224        std::fs::write(path, content).unwrap();
225    }
226
227    fn run(root: &std::path::Path, args: &[&str]) {
228        let status = Command::new("git")
229            .arg("-C")
230            .arg(root)
231            .args(args)
232            .status()
233            .unwrap();
234        assert!(status.success(), "git {args:?} failed");
235    }
236}