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