features_cli/
git_helper.rs

1use anyhow::{Context, Result};
2use git2::Repository;
3use std::path::Path;
4
5use crate::models::Change;
6
7pub fn get_commits_for_path(repo_path: &Path, feature_path: &str) -> Result<Vec<Change>> {
8    let repo = Repository::discover(repo_path).with_context(|| {
9        format!(
10            "failed to discover git repository at `{}`",
11            repo_path.display()
12        )
13    })?;
14
15    // Convert the feature path to be relative to the repository root
16    let repo_workdir = repo
17        .workdir()
18        .context("repository has no working directory")?;
19    let feature_abs_path = std::fs::canonicalize(feature_path)
20        .with_context(|| format!("failed to canonicalize path `{}`", feature_path))?;
21
22    let relative_path = feature_abs_path
23        .strip_prefix(repo_workdir)
24        .with_context(|| format!("path `{}` is not within repository", feature_path))?;
25
26    let relative_path_str = relative_path.to_string_lossy();
27
28    let mut revwalk = repo.revwalk()?;
29    revwalk.push_head()?;
30    revwalk.set_sorting(git2::Sort::TIME)?;
31
32    let mut changes = Vec::new();
33
34    for oid in revwalk {
35        let oid = oid?;
36        let commit = repo.find_commit(oid)?;
37
38        // Check if this commit affects the feature path
39        if commit_affects_path(&repo, &commit, &relative_path_str)? {
40            let author = commit.author();
41            let message = commit.message().unwrap_or("").to_string();
42
43            // Split message into title and description
44            let lines: Vec<&str> = message.lines().collect();
45            let title = lines.first().unwrap_or(&"").to_string();
46            let description = if lines.len() > 1 {
47                lines[1..].join("\n").trim().to_string()
48            } else {
49                String::new()
50            };
51
52            changes.push(Change {
53                title,
54                author_name: author.name().unwrap_or("Unknown").to_string(),
55                author_email: author.email().unwrap_or("").to_string(),
56                description,
57                date: format_timestamp(commit.time()),
58                hash: format!("{}", oid),
59            });
60        }
61    }
62
63    Ok(changes)
64}
65
66fn commit_affects_path(repo: &Repository, commit: &git2::Commit, path: &str) -> Result<bool> {
67    // For the first commit (no parents), check if any files in the path exist in the tree
68    if commit.parent_count() == 0 {
69        let tree = commit.tree()?;
70        return Ok(tree_contains_path(&tree, path));
71    }
72
73    // For commits with parents, check the diff
74    let tree = commit.tree()?;
75    let parent = commit.parent(0)?;
76    let parent_tree = parent.tree()?;
77
78    let diff = repo.diff_tree_to_tree(Some(&parent_tree), Some(&tree), None)?;
79
80    let mut affects_path = false;
81    diff.foreach(
82        &mut |delta, _| {
83            if let Some(path_str) = delta.new_file().path()
84                && path_str.starts_with(path)
85            {
86                affects_path = true;
87            }
88            if let Some(path_str) = delta.old_file().path()
89                && path_str.starts_with(path)
90            {
91                affects_path = true;
92            }
93            true
94        },
95        None,
96        None,
97        None,
98    )?;
99
100    Ok(affects_path)
101}
102
103fn tree_contains_path(tree: &git2::Tree, path: &str) -> bool {
104    tree.get_path(Path::new(path)).is_ok()
105}
106
107fn format_timestamp(time: git2::Time) -> String {
108    let timestamp = time.seconds();
109    let datetime = chrono::DateTime::from_timestamp(timestamp, 0)
110        .unwrap_or_else(|| chrono::DateTime::from_timestamp(0, 0).unwrap());
111    datetime.format("%Y-%m-%d %H:%M:%S").to_string()
112}