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;
5use std::collections::HashMap;
6
7use super::types::{CommitInfo, RefKind, RefLabel};
8
9// ── ref map ───────────────────────────────────────────────────────────────
10
11/// Build a map of commit OID → ref labels for all refs in the repository.
12///
13/// Used by [`list_commits`] and [`crate::features::log::ops::get_log`] to
14/// attach branch/tag information to each [`CommitInfo`].
15pub(crate) fn build_ref_map(repo: &Repository) -> HashMap<git2::Oid, Vec<RefLabel>> {
16    let mut map: HashMap<git2::Oid, Vec<RefLabel>> = HashMap::new();
17
18    let head_branch: Option<String> = repo
19        .head()
20        .ok()
21        .filter(|h| h.is_branch())
22        .and_then(|h| h.shorthand().map(|s| s.to_string()));
23
24    if let Ok(refs) = repo.references() {
25        for rf in refs.flatten() {
26            let full_name = match rf.name() {
27                Some(n) => n.to_string(),
28                None => continue,
29            };
30            let oid = match rf.peel_to_commit() {
31                Ok(c) => c.id(),
32                Err(_) => continue,
33            };
34            let label = if let Some(branch) = full_name.strip_prefix("refs/heads/") {
35                let kind = if head_branch.as_deref() == Some(branch) {
36                    RefKind::Head
37                } else {
38                    RefKind::LocalBranch
39                };
40                RefLabel {
41                    name: branch.to_string(),
42                    kind,
43                }
44            } else if let Some(rb) = full_name.strip_prefix("refs/remotes/") {
45                if rb.ends_with("/HEAD") {
46                    continue;
47                }
48                RefLabel {
49                    name: rb.to_string(),
50                    kind: RefKind::RemoteBranch,
51                }
52            } else if let Some(tag) = full_name.strip_prefix("refs/tags/") {
53                RefLabel {
54                    name: tag.to_string(),
55                    kind: RefKind::Tag,
56                }
57            } else {
58                continue;
59            };
60            map.entry(oid).or_default().push(label);
61        }
62    }
63
64    // Detached HEAD: synthesise a label for the bare commit.
65    if head_branch.is_none() {
66        if let Ok(head) = repo.head() {
67            if let Ok(commit) = head.peel_to_commit() {
68                map.entry(commit.id()).or_default().push(RefLabel {
69                    name: "HEAD".to_string(),
70                    kind: RefKind::Head,
71                });
72            }
73        }
74    }
75
76    // Sort each bucket: Head first, LocalBranch, RemoteBranch, Tag.
77    for labels in map.values_mut() {
78        labels.sort_by_key(|r| match r.kind {
79            RefKind::Head => 0u8,
80            RefKind::LocalBranch => 1,
81            RefKind::RemoteBranch => 2,
82            RefKind::Tag => 3,
83        });
84    }
85
86    map
87}
88
89// ── cherry-pick ──────────────────────────────────────────────────────────
90pub fn cherry_pick_commit(workdir: &std::path::Path, oid_str: &str) -> anyhow::Result<()> {
91    let output = std::process::Command::new("git")
92        .args(["cherry-pick", oid_str])
93        .current_dir(workdir)
94        .output()
95        .context("failed to run git cherry-pick")?;
96    if output.status.success() {
97        Ok(())
98    } else {
99        Err(anyhow::anyhow!(
100            "cherry-pick failed: {}",
101            String::from_utf8_lossy(&output.stderr).trim()
102        ))
103    }
104}
105
106/// Walk the history from HEAD and return up to `max_count` commits.
107///
108/// Commits are sorted topologically and by time (newest first).
109/// Each commit's [`CommitInfo::refs`] is populated with any branch / tag /
110/// HEAD labels that point directly at it.
111pub fn list_commits(repo: &Repository, max_count: usize) -> Result<Vec<CommitInfo>> {
112    let ref_map = build_ref_map(repo);
113
114    let mut revwalk = repo.revwalk().context("failed to create revwalk")?;
115    // Push ALL refs so branches, tags, and remotes appear in the graph
116    // (not just commits reachable from HEAD).
117    revwalk
118        .push_head()
119        .context("failed to push HEAD to revwalk")?;
120    if let Ok(refs) = repo.references() {
121        for r in refs.flatten() {
122            if let Some(oid) = r.target() {
123                let _ = revwalk.push(oid);
124            }
125        }
126    }
127    revwalk
128        .set_sorting(git2::Sort::TIME | git2::Sort::TOPOLOGICAL)
129        .context("failed to set revwalk sorting")?;
130
131    let mut commits = Vec::with_capacity(max_count.min(256));
132    for oid_result in revwalk {
133        if commits.len() >= max_count {
134            break;
135        }
136        let oid = oid_result.context("revwalk iteration error")?;
137        let commit = repo
138            .find_commit(oid)
139            .with_context(|| format!("failed to find commit {oid}"))?;
140        let mut info = CommitInfo::from_git2_commit(&commit);
141        // Don't store full multi-line commit bodies in the batch list —
142        // only the summary (first line) is shown in the log view.
143        // The full message is re-loaded on demand via get_commit_details().
144        info.message = String::new();
145        // Author email is only needed for search (which reads from git2
146        // directly) — not displayed in the commit log list view.
147        info.author_email = String::new();
148        if let Some(refs) = ref_map.get(&oid) {
149            info.refs = refs.clone();
150        }
151        commits.push(info);
152    }
153
154    Ok(commits)
155}
156
157/// Commit the currently staged (index) changes with the given message.
158///
159/// Uses the repository's default signature (`user.name` / `user.email`).
160/// Returns the newly created [`CommitInfo`].
161pub fn create_commit(repo: &Repository, message: &str) -> Result<CommitInfo> {
162    let sig = repo.signature().context(
163        "failed to obtain default signature — set user.name and user.email in git config",
164    )?;
165
166    let mut index = repo.index().context("failed to read index")?;
167    let tree_oid = index
168        .write_tree()
169        .context("failed to write index to tree — are there staged changes?")?;
170    let tree = repo
171        .find_tree(tree_oid)
172        .context("failed to find tree written from index")?;
173
174    // Collect parent commits (HEAD if it exists).
175    let parent_commit;
176    let parents: Vec<&git2::Commit<'_>> = if let Ok(head_ref) = repo.head() {
177        let head_oid = head_ref
178            .target()
179            .context("HEAD is not a direct reference")?;
180        parent_commit = repo
181            .find_commit(head_oid)
182            .context("failed to find HEAD commit")?;
183        vec![&parent_commit]
184    } else {
185        // Initial commit — no parents.
186        vec![]
187    };
188
189    let oid = repo
190        .commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
191        .context("failed to create commit")?;
192
193    let commit = repo
194        .find_commit(oid)
195        .context("failed to look up newly created commit")?;
196
197    Ok(CommitInfo::from_git2_commit(&commit))
198}
199
200/// Retrieve the full [`CommitInfo`] for a commit identified by its hex OID string.
201pub fn get_commit_details(repo: &Repository, oid_str: &str) -> Result<CommitInfo> {
202    let oid =
203        git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
204    let commit = repo
205        .find_commit(oid)
206        .with_context(|| format!("commit {oid_str} not found"))?;
207
208    Ok(CommitInfo::from_git2_commit(&commit))
209}
210
211#[cfg(test)]
212mod tests {
213    #[test]
214    fn cherry_pick_on_nonexistent_repo_returns_error() {
215        let result = super::cherry_pick_commit(std::path::Path::new("/nonexistent"), "abc1234");
216        assert!(result.is_err());
217    }
218
219    use super::*;
220    use tempfile::TempDir;
221
222    /// Helper: create a repo with one commit so HEAD exists.
223    fn setup_repo_with_commit() -> (TempDir, Repository) {
224        let dir = TempDir::new().unwrap();
225        let repo = Repository::init(dir.path()).unwrap();
226
227        // Configure signature.
228        let mut config = repo.config().unwrap();
229        config.set_str("user.name", "Test User").unwrap();
230        config.set_str("user.email", "test@example.com").unwrap();
231
232        // Create a file, stage it, and commit.
233        let file_path = dir.path().join("hello.txt");
234        std::fs::write(&file_path, "hello world\n").unwrap();
235        {
236            let mut index = repo.index().unwrap();
237            index.add_path(std::path::Path::new("hello.txt")).unwrap();
238            index.write().unwrap();
239
240            let tree_oid = index.write_tree().unwrap();
241            let tree = repo.find_tree(tree_oid).unwrap();
242            let sig = repo.signature().unwrap();
243            repo.commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[])
244                .unwrap();
245        }
246
247        (dir, repo)
248    }
249
250    #[test]
251    fn list_commits_returns_initial_commit() {
252        let (_dir, repo) = setup_repo_with_commit();
253        let commits = list_commits(&repo, 10).unwrap();
254        assert_eq!(commits.len(), 1);
255        assert_eq!(commits[0].summary, "initial commit");
256        assert!(!commits[0].oid.is_empty());
257        assert_eq!(commits[0].short_oid().len(), 7);
258        assert!(commits[0].parent_ids.is_empty());
259    }
260
261    #[test]
262    fn create_commit_works() {
263        let (dir, repo) = setup_repo_with_commit();
264
265        // Make a change, stage it, then commit.
266        std::fs::write(dir.path().join("hello.txt"), "updated\n").unwrap();
267        let mut index = repo.index().unwrap();
268        index.add_path(std::path::Path::new("hello.txt")).unwrap();
269        index.write().unwrap();
270
271        let info = create_commit(&repo, "second commit").unwrap();
272        assert_eq!(info.summary, "second commit");
273        assert_eq!(info.parent_ids.len(), 1);
274    }
275
276    #[test]
277    fn get_commit_details_works() {
278        let (_dir, repo) = setup_repo_with_commit();
279        let commits = list_commits(&repo, 1).unwrap();
280        let oid_str = &commits[0].oid;
281        let detail = get_commit_details(&repo, oid_str).unwrap();
282        assert_eq!(detail.oid, *oid_str);
283        assert_eq!(detail.summary, "initial commit");
284    }
285
286    #[test]
287    fn get_commit_details_bad_oid() {
288        let (_dir, repo) = setup_repo_with_commit();
289        let result = get_commit_details(&repo, "not-a-valid-oid");
290        assert!(result.is_err());
291    }
292
293    #[test]
294    fn list_commits_respects_max_count() {
295        let (dir, repo) = setup_repo_with_commit();
296
297        // Add a second commit.
298        std::fs::write(dir.path().join("second.txt"), "two\n").unwrap();
299        let mut index = repo.index().unwrap();
300        index.add_path(std::path::Path::new("second.txt")).unwrap();
301        index.write().unwrap();
302        create_commit(&repo, "second commit").unwrap();
303
304        let one = list_commits(&repo, 1).unwrap();
305        assert_eq!(one.len(), 1);
306        assert_eq!(one[0].summary, "second commit");
307
308        let both = list_commits(&repo, 100).unwrap();
309        assert_eq!(both.len(), 2);
310    }
311
312    #[test]
313    fn list_commits_attaches_head_ref_to_tip() {
314        let (_dir, repo) = setup_repo_with_commit();
315        let commits = list_commits(&repo, 10).unwrap();
316        // The single commit should carry the HEAD branch label.
317        assert!(!commits[0].refs.is_empty(), "tip commit should have refs");
318        assert!(
319            commits[0]
320                .refs
321                .iter()
322                .any(|r| r.kind == crate::features::commits::types::RefKind::Head),
323            "tip commit should have a Head ref"
324        );
325    }
326
327    #[test]
328    fn list_commits_non_tip_commits_have_no_refs() {
329        let (dir, repo) = setup_repo_with_commit();
330        // Add a second commit — only the tip should have refs.
331        std::fs::write(dir.path().join("second.txt"), "two\n").unwrap();
332        let mut index = repo.index().unwrap();
333        index.add_path(std::path::Path::new("second.txt")).unwrap();
334        index.write().unwrap();
335        create_commit(&repo, "second commit").unwrap();
336
337        let commits = list_commits(&repo, 100).unwrap();
338        assert_eq!(commits.len(), 2);
339        // Tip (newest) should have refs
340        assert!(!commits[0].refs.is_empty());
341        // Parent (older) should have no refs
342        assert!(
343            commits[1].refs.is_empty(),
344            "non-tip commits should have empty refs"
345        );
346    }
347
348    #[test]
349    fn build_ref_map_includes_tags() {
350        let (_dir, repo) = setup_repo_with_commit();
351        // Create a lightweight tag on HEAD
352        let head_oid = repo.head().unwrap().target().unwrap();
353        let head_commit = repo.find_commit(head_oid).unwrap();
354        repo.tag_lightweight("v1.0.0", head_commit.as_object(), false)
355            .unwrap();
356
357        let ref_map = build_ref_map(&repo);
358        let labels = ref_map.get(&head_oid).expect("HEAD should have refs");
359        assert!(
360            labels
361                .iter()
362                .any(|r| r.name == "v1.0.0"
363                    && r.kind == crate::features::commits::types::RefKind::Tag),
364            "tag should appear in ref map"
365        );
366    }
367}