Skip to main content

gitkraft_core/features/commits/
ops.rs

1//! Commit operations — list, create, and inspect commits.
2
3use anyhow::{Context, Result};
4use git2::Repository;
5
6use super::types::CommitInfo;
7
8/// Walk the history from HEAD and return up to `max_count` commits.
9///
10/// Commits are sorted topologically and by time (newest first).
11pub fn list_commits(repo: &Repository, max_count: usize) -> Result<Vec<CommitInfo>> {
12    let mut revwalk = repo.revwalk().context("failed to create revwalk")?;
13    revwalk
14        .push_head()
15        .context("failed to push HEAD to revwalk")?;
16    revwalk
17        .set_sorting(git2::Sort::TIME | git2::Sort::TOPOLOGICAL)
18        .context("failed to set revwalk sorting")?;
19
20    let mut commits = Vec::with_capacity(max_count.min(256));
21    for oid_result in revwalk {
22        if commits.len() >= max_count {
23            break;
24        }
25        let oid = oid_result.context("revwalk iteration error")?;
26        let commit = repo
27            .find_commit(oid)
28            .with_context(|| format!("failed to find commit {oid}"))?;
29        commits.push(CommitInfo::from_git2_commit(&commit));
30    }
31
32    Ok(commits)
33}
34
35/// Commit the currently staged (index) changes with the given message.
36///
37/// Uses the repository's default signature (`user.name` / `user.email`).
38/// Returns the newly created [`CommitInfo`].
39pub fn create_commit(repo: &Repository, message: &str) -> Result<CommitInfo> {
40    let sig = repo.signature().context(
41        "failed to obtain default signature — set user.name and user.email in git config",
42    )?;
43
44    let mut index = repo.index().context("failed to read index")?;
45    let tree_oid = index
46        .write_tree()
47        .context("failed to write index to tree — are there staged changes?")?;
48    let tree = repo
49        .find_tree(tree_oid)
50        .context("failed to find tree written from index")?;
51
52    // Collect parent commits (HEAD if it exists).
53    let parent_commit;
54    let parents: Vec<&git2::Commit<'_>> = if let Ok(head_ref) = repo.head() {
55        let head_oid = head_ref
56            .target()
57            .context("HEAD is not a direct reference")?;
58        parent_commit = repo
59            .find_commit(head_oid)
60            .context("failed to find HEAD commit")?;
61        vec![&parent_commit]
62    } else {
63        // Initial commit — no parents.
64        vec![]
65    };
66
67    let oid = repo
68        .commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
69        .context("failed to create commit")?;
70
71    let commit = repo
72        .find_commit(oid)
73        .context("failed to look up newly created commit")?;
74
75    Ok(CommitInfo::from_git2_commit(&commit))
76}
77
78/// Retrieve the full [`CommitInfo`] for a commit identified by its hex OID string.
79pub fn get_commit_details(repo: &Repository, oid_str: &str) -> Result<CommitInfo> {
80    let oid =
81        git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
82    let commit = repo
83        .find_commit(oid)
84        .with_context(|| format!("commit {oid_str} not found"))?;
85
86    Ok(CommitInfo::from_git2_commit(&commit))
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use tempfile::TempDir;
93
94    /// Helper: create a repo with one commit so HEAD exists.
95    fn setup_repo_with_commit() -> (TempDir, Repository) {
96        let dir = TempDir::new().unwrap();
97        let repo = Repository::init(dir.path()).unwrap();
98
99        // Configure signature.
100        let mut config = repo.config().unwrap();
101        config.set_str("user.name", "Test User").unwrap();
102        config.set_str("user.email", "test@example.com").unwrap();
103
104        // Create a file, stage it, and commit.
105        let file_path = dir.path().join("hello.txt");
106        std::fs::write(&file_path, "hello world\n").unwrap();
107        {
108            let mut index = repo.index().unwrap();
109            index.add_path(std::path::Path::new("hello.txt")).unwrap();
110            index.write().unwrap();
111
112            let tree_oid = index.write_tree().unwrap();
113            let tree = repo.find_tree(tree_oid).unwrap();
114            let sig = repo.signature().unwrap();
115            repo.commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[])
116                .unwrap();
117        }
118
119        (dir, repo)
120    }
121
122    #[test]
123    fn list_commits_returns_initial_commit() {
124        let (_dir, repo) = setup_repo_with_commit();
125        let commits = list_commits(&repo, 10).unwrap();
126        assert_eq!(commits.len(), 1);
127        assert_eq!(commits[0].summary, "initial commit");
128        assert!(!commits[0].oid.is_empty());
129        assert_eq!(commits[0].short_oid.len(), 7);
130        assert!(commits[0].parent_ids.is_empty());
131    }
132
133    #[test]
134    fn create_commit_works() {
135        let (dir, repo) = setup_repo_with_commit();
136
137        // Make a change, stage it, then commit.
138        std::fs::write(dir.path().join("hello.txt"), "updated\n").unwrap();
139        let mut index = repo.index().unwrap();
140        index.add_path(std::path::Path::new("hello.txt")).unwrap();
141        index.write().unwrap();
142
143        let info = create_commit(&repo, "second commit").unwrap();
144        assert_eq!(info.summary, "second commit");
145        assert_eq!(info.parent_ids.len(), 1);
146    }
147
148    #[test]
149    fn get_commit_details_works() {
150        let (_dir, repo) = setup_repo_with_commit();
151        let commits = list_commits(&repo, 1).unwrap();
152        let oid_str = &commits[0].oid;
153        let detail = get_commit_details(&repo, oid_str).unwrap();
154        assert_eq!(detail.oid, *oid_str);
155        assert_eq!(detail.summary, "initial commit");
156    }
157
158    #[test]
159    fn get_commit_details_bad_oid() {
160        let (_dir, repo) = setup_repo_with_commit();
161        let result = get_commit_details(&repo, "not-a-valid-oid");
162        assert!(result.is_err());
163    }
164
165    #[test]
166    fn list_commits_respects_max_count() {
167        let (dir, repo) = setup_repo_with_commit();
168
169        // Add a second commit.
170        std::fs::write(dir.path().join("second.txt"), "two\n").unwrap();
171        let mut index = repo.index().unwrap();
172        index.add_path(std::path::Path::new("second.txt")).unwrap();
173        index.write().unwrap();
174        create_commit(&repo, "second commit").unwrap();
175
176        let one = list_commits(&repo, 1).unwrap();
177        assert_eq!(one.len(), 1);
178        assert_eq!(one[0].summary, "second commit");
179
180        let both = list_commits(&repo, 100).unwrap();
181        assert_eq!(both.len(), 2);
182    }
183}