1use std::io::{BufRead, BufReader};
19use std::path::{Path, PathBuf};
20use std::process::{Command, Stdio};
21
22use anyhow::{Context, Result};
23pub use tokmd_types::CommitIntentKind;
24
25fn git_cmd() -> Command {
31 let mut cmd = Command::new("git");
32 cmd.env_remove("GIT_DIR").env_remove("GIT_WORK_TREE");
33 cmd
34}
35
36#[derive(Debug, Clone)]
37pub struct GitCommit {
38 pub timestamp: i64,
39 pub author: String,
40 pub hash: Option<String>,
41 pub subject: String,
42 pub files: Vec<String>,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47pub enum GitRangeMode {
48 #[default]
50 TwoDot,
51 ThreeDot,
53}
54
55impl GitRangeMode {
56 pub fn format(&self, base: &str, head: &str) -> String {
58 match self {
59 GitRangeMode::TwoDot => format!("{}..{}", base, head),
60 GitRangeMode::ThreeDot => format!("{}...{}", base, head),
61 }
62 }
63}
64
65pub fn git_available() -> bool {
66 git_cmd()
67 .arg("--version")
68 .stdout(Stdio::null())
69 .stderr(Stdio::null())
70 .status()
71 .map(|s| s.success())
72 .unwrap_or(false)
73}
74
75pub fn repo_root(path: &Path) -> Option<PathBuf> {
76 let output = git_cmd()
77 .arg("-C")
78 .arg(path)
79 .arg("rev-parse")
80 .arg("--show-toplevel")
81 .output()
82 .ok()?;
83 if !output.status.success() {
84 return None;
85 }
86 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
87 if root.is_empty() {
88 None
89 } else {
90 Some(PathBuf::from(root))
91 }
92}
93
94pub fn collect_history(
95 repo_root: &Path,
96 max_commits: Option<usize>,
97 max_commit_files: Option<usize>,
98) -> Result<Vec<GitCommit>> {
99 let mut child = git_cmd()
100 .arg("-C")
101 .arg(repo_root)
102 .arg("log")
103 .arg("--name-only")
104 .arg("--pretty=format:%ct|%ae|%H|%s")
105 .stdout(Stdio::piped())
106 .stderr(Stdio::null())
107 .spawn()
108 .context("Failed to spawn git log")?;
109
110 let stdout = child.stdout.take().context("Missing git log stdout")?;
111 let reader = BufReader::new(stdout);
112
113 let mut commits: Vec<GitCommit> = Vec::new();
114 let mut current: Option<GitCommit> = None;
115
116 for line in reader.lines() {
117 let line = line?;
118 if line.trim().is_empty() {
119 if let Some(commit) = current.take() {
120 commits.push(commit);
121 if max_commits.is_some_and(|limit| commits.len() >= limit) {
122 break;
123 }
124 }
125 continue;
126 }
127
128 if current.is_none() {
129 let mut parts = line.splitn(4, '|');
130 let ts = parts.next().unwrap_or("0").parse::<i64>().unwrap_or(0);
131 let author = parts.next().unwrap_or("").to_string();
132 let hash_str = parts.next().unwrap_or("").to_string();
133 let subject = parts.next().unwrap_or("").to_string();
134 let hash = if hash_str.is_empty() {
135 None
136 } else {
137 Some(hash_str)
138 };
139 current = Some(GitCommit {
140 timestamp: ts,
141 author,
142 hash,
143 subject,
144 files: Vec::new(),
145 });
146 continue;
147 }
148
149 if let Some(commit) = current.as_mut()
150 && max_commit_files
151 .map(|limit| commit.files.len() < limit)
152 .unwrap_or(true)
153 {
154 commit.files.push(line.trim().to_string());
155 }
156 }
157
158 if let Some(commit) = current.take() {
159 commits.push(commit);
160 }
161
162 let status = child.wait()?;
163 if !status.success() {
164 return Err(anyhow::anyhow!("git log failed"));
165 }
166
167 Ok(commits)
168}
169
170pub fn get_added_lines(
172 repo_root: &Path,
173 base: &str,
174 head: &str,
175 range_mode: GitRangeMode,
176) -> Result<std::collections::BTreeMap<PathBuf, std::collections::BTreeSet<usize>>> {
177 let range = range_mode.format(base, head);
178 let output = git_cmd()
179 .arg("-C")
180 .arg(repo_root)
181 .args(["diff", "--unified=0", &range])
182 .output()
183 .context("Failed to run git diff")?;
184
185 if !output.status.success() {
186 let stderr = String::from_utf8_lossy(&output.stderr);
187 return Err(anyhow::anyhow!("git diff failed: {}", stderr.trim()));
188 }
189
190 let stdout = String::from_utf8_lossy(&output.stdout);
191 let mut result: std::collections::BTreeMap<PathBuf, std::collections::BTreeSet<usize>> =
192 std::collections::BTreeMap::new();
193 let mut current_file: Option<PathBuf> = None;
194
195 for line in stdout.lines() {
196 if let Some(file_path) = line.strip_prefix("+++ b/") {
197 current_file = Some(PathBuf::from(file_path));
198 continue;
199 }
200
201 if line.starts_with("@@") {
202 let Some(file) = current_file.as_ref() else {
203 continue;
204 };
205
206 let parts: Vec<&str> = line.split_whitespace().collect();
209 if parts.len() < 3 {
210 continue;
211 }
212
213 let new_range = parts[2]; let range_str = new_range.strip_prefix('+').unwrap_or(new_range);
215 let range_parts: Vec<&str> = range_str.split(',').collect();
216
217 let start: usize = range_parts[0].parse().unwrap_or(0);
218 let count: usize = if range_parts.len() > 1 {
219 range_parts[1].parse().unwrap_or(1)
220 } else {
221 1
222 };
223
224 if count > 0 && start > 0 {
225 let set = result.entry(file.clone()).or_default();
226 for i in 0..count {
227 set.insert(start + i);
228 }
229 }
230 }
231 }
232
233 Ok(result)
234}
235
236pub fn rev_exists(repo_root: &Path, rev: &str) -> bool {
238 git_cmd()
239 .arg("-C")
240 .arg(repo_root)
241 .args(["rev-parse", "--verify", "--quiet"])
242 .arg(format!("{rev}^{{commit}}"))
243 .stdout(Stdio::null())
244 .stderr(Stdio::null())
245 .status()
246 .map(|s| s.success())
247 .unwrap_or(false)
248}
249
250pub fn resolve_base_ref(repo_root: &Path, requested: &str) -> Option<String> {
261 if rev_exists(repo_root, requested) {
263 return Some(requested.to_string());
264 }
265
266 if requested != "main" {
269 return None;
270 }
271
272 if let Ok(env_ref) = std::env::var("TOKMD_GIT_BASE_REF")
274 && !env_ref.is_empty()
275 && rev_exists(repo_root, &env_ref)
276 {
277 return Some(env_ref);
278 }
279
280 if let Ok(gh_base) = std::env::var("GITHUB_BASE_REF")
282 && !gh_base.is_empty()
283 {
284 let candidate = format!("origin/{gh_base}");
285 if rev_exists(repo_root, &candidate) {
286 return Some(candidate);
287 }
288 }
289
290 static FALLBACKS: &[&str] = &[
292 "origin/HEAD",
293 "origin/main",
294 "main",
295 "origin/master",
296 "master",
297 ];
298
299 for candidate in FALLBACKS {
300 if rev_exists(repo_root, candidate) {
301 return Some((*candidate).to_string());
302 }
303 }
304
305 None
306}
307
308pub fn classify_intent(subject: &str) -> CommitIntentKind {
318 let trimmed = subject.trim();
319 if trimmed.is_empty() {
320 return CommitIntentKind::Other;
321 }
322
323 if trimmed.starts_with("Revert \"") || trimmed.starts_with("revert:") {
325 return CommitIntentKind::Revert;
326 }
327
328 if let Some(kind) = parse_conventional_prefix(trimmed) {
330 return kind;
331 }
332
333 keyword_heuristic(trimmed)
335}
336
337fn parse_conventional_prefix(subject: &str) -> Option<CommitIntentKind> {
339 let colon_pos = subject.find(':')?;
340 let prefix = &subject[..colon_pos];
341
342 let prefix = if let Some(paren_pos) = prefix.find('(') {
344 &prefix[..paren_pos]
345 } else {
346 prefix
347 };
348 let prefix = prefix.trim_end_matches('!');
349
350 match prefix.to_ascii_lowercase().as_str() {
351 "feat" | "feature" => Some(CommitIntentKind::Feat),
352 "fix" | "bugfix" | "hotfix" => Some(CommitIntentKind::Fix),
353 "refactor" => Some(CommitIntentKind::Refactor),
354 "docs" | "doc" => Some(CommitIntentKind::Docs),
355 "test" | "tests" => Some(CommitIntentKind::Test),
356 "chore" => Some(CommitIntentKind::Chore),
357 "ci" => Some(CommitIntentKind::Ci),
358 "build" => Some(CommitIntentKind::Build),
359 "perf" => Some(CommitIntentKind::Perf),
360 "style" => Some(CommitIntentKind::Style),
361 "revert" => Some(CommitIntentKind::Revert),
362 _ => None,
363 }
364}
365
366fn keyword_heuristic(subject: &str) -> CommitIntentKind {
368 let lower = subject.to_ascii_lowercase();
369
370 if contains_word(&lower, "revert") {
372 CommitIntentKind::Revert
373 } else if contains_word(&lower, "fix")
374 || contains_word(&lower, "bug")
375 || contains_word(&lower, "patch")
376 || contains_word(&lower, "hotfix")
377 {
378 CommitIntentKind::Fix
379 } else if contains_word(&lower, "feat")
380 || contains_word(&lower, "feature")
381 || lower.starts_with("add ")
382 || lower.starts_with("implement ")
383 || lower.starts_with("introduce ")
384 {
385 CommitIntentKind::Feat
386 } else if contains_word(&lower, "refactor") || contains_word(&lower, "restructure") {
387 CommitIntentKind::Refactor
388 } else if contains_word(&lower, "doc") || contains_word(&lower, "readme") {
389 CommitIntentKind::Docs
390 } else if contains_word(&lower, "test") {
391 CommitIntentKind::Test
392 } else if contains_word(&lower, "perf")
393 || contains_word(&lower, "performance")
394 || contains_word(&lower, "optimize")
395 {
396 CommitIntentKind::Perf
397 } else if contains_word(&lower, "style")
398 || contains_word(&lower, "format")
399 || contains_word(&lower, "lint")
400 {
401 CommitIntentKind::Style
402 } else if contains_word(&lower, "ci") || contains_word(&lower, "pipeline") {
403 CommitIntentKind::Ci
404 } else if contains_word(&lower, "build") || contains_word(&lower, "deps") {
405 CommitIntentKind::Build
406 } else if contains_word(&lower, "chore") || contains_word(&lower, "cleanup") {
407 CommitIntentKind::Chore
408 } else {
409 CommitIntentKind::Other
410 }
411}
412
413fn contains_word(haystack: &str, word: &str) -> bool {
415 for (idx, _) in haystack.match_indices(word) {
416 let before_ok = idx == 0 || !haystack.as_bytes()[idx - 1].is_ascii_alphanumeric();
417 let after_idx = idx + word.len();
418 let after_ok =
419 after_idx >= haystack.len() || !haystack.as_bytes()[after_idx].is_ascii_alphanumeric();
420 if before_ok && after_ok {
421 return true;
422 }
423 }
424 false
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 fn test_git(dir: &Path) -> Command {
432 let mut cmd = git_cmd();
433 cmd.arg("-C").arg(dir);
434 cmd
435 }
436
437 #[test]
438 fn git_range_two_dot_format() {
439 assert_eq!(GitRangeMode::TwoDot.format("main", "HEAD"), "main..HEAD");
440 }
441
442 #[test]
443 fn git_range_three_dot_format() {
444 assert_eq!(GitRangeMode::ThreeDot.format("main", "HEAD"), "main...HEAD");
445 }
446
447 #[test]
448 fn git_range_default_is_two_dot() {
449 assert_eq!(GitRangeMode::default(), GitRangeMode::TwoDot);
450 }
451
452 #[test]
453 fn rev_exists_finds_head_in_repo() {
454 if !git_available() {
455 return;
456 }
457 let dir = tempfile::tempdir().unwrap();
458
459 test_git(dir.path()).arg("init").output().unwrap();
461 test_git(dir.path())
462 .args(["config", "user.email", "test@test.com"])
463 .output()
464 .unwrap();
465 test_git(dir.path())
466 .args(["config", "user.name", "Test"])
467 .output()
468 .unwrap();
469 std::fs::write(dir.path().join("f.txt"), "hello").unwrap();
470 test_git(dir.path()).args(["add", "."]).output().unwrap();
471 test_git(dir.path())
472 .args(["commit", "-m", "init"])
473 .output()
474 .unwrap();
475
476 assert!(rev_exists(dir.path(), "HEAD"));
477 assert!(!rev_exists(dir.path(), "nonexistent-branch-abc123"));
478 }
479
480 #[test]
481 fn resolve_base_ref_returns_requested_when_valid() {
482 if !git_available() {
483 return;
484 }
485 let dir = tempfile::tempdir().unwrap();
486
487 test_git(dir.path())
488 .args(["init", "-b", "main"])
489 .output()
490 .unwrap();
491 test_git(dir.path())
492 .args(["config", "user.email", "test@test.com"])
493 .output()
494 .unwrap();
495 test_git(dir.path())
496 .args(["config", "user.name", "Test"])
497 .output()
498 .unwrap();
499 std::fs::write(dir.path().join("f.txt"), "hello").unwrap();
500 test_git(dir.path()).args(["add", "."]).output().unwrap();
501 test_git(dir.path())
502 .args(["commit", "-m", "init"])
503 .output()
504 .unwrap();
505
506 assert_eq!(
507 resolve_base_ref(dir.path(), "main"),
508 Some("main".to_string())
509 );
510 }
511
512 #[test]
513 fn resolve_base_ref_returns_none_when_nothing_resolves() {
514 if !git_available() {
515 return;
516 }
517 let dir = tempfile::tempdir().unwrap();
518
519 test_git(dir.path())
521 .args(["init", "-b", "trunk"])
522 .output()
523 .unwrap();
524
525 assert_eq!(resolve_base_ref(dir.path(), "nonexistent"), None);
527 }
528}