gitkraft_core/features/commits/
ops.rs1use anyhow::{Context, Result};
4use git2::Repository;
5use std::collections::HashMap;
6
7use super::types::{CommitInfo, RefKind, RefLabel};
8
9pub(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 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 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
89pub 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
106pub 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 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 info.message = String::new();
145 if let Some(refs) = ref_map.get(&oid) {
146 info.refs = refs.clone();
147 }
148 commits.push(info);
149 }
150
151 Ok(commits)
152}
153
154pub fn create_commit(repo: &Repository, message: &str) -> Result<CommitInfo> {
159 let sig = repo.signature().context(
160 "failed to obtain default signature — set user.name and user.email in git config",
161 )?;
162
163 let mut index = repo.index().context("failed to read index")?;
164 let tree_oid = index
165 .write_tree()
166 .context("failed to write index to tree — are there staged changes?")?;
167 let tree = repo
168 .find_tree(tree_oid)
169 .context("failed to find tree written from index")?;
170
171 let parent_commit;
173 let parents: Vec<&git2::Commit<'_>> = if let Ok(head_ref) = repo.head() {
174 let head_oid = head_ref
175 .target()
176 .context("HEAD is not a direct reference")?;
177 parent_commit = repo
178 .find_commit(head_oid)
179 .context("failed to find HEAD commit")?;
180 vec![&parent_commit]
181 } else {
182 vec![]
184 };
185
186 let oid = repo
187 .commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
188 .context("failed to create commit")?;
189
190 let commit = repo
191 .find_commit(oid)
192 .context("failed to look up newly created commit")?;
193
194 Ok(CommitInfo::from_git2_commit(&commit))
195}
196
197pub fn get_commit_details(repo: &Repository, oid_str: &str) -> Result<CommitInfo> {
199 let oid =
200 git2::Oid::from_str(oid_str).with_context(|| format!("invalid OID string: {oid_str}"))?;
201 let commit = repo
202 .find_commit(oid)
203 .with_context(|| format!("commit {oid_str} not found"))?;
204
205 Ok(CommitInfo::from_git2_commit(&commit))
206}
207
208#[cfg(test)]
209mod tests {
210 #[test]
211 fn cherry_pick_on_nonexistent_repo_returns_error() {
212 let result = super::cherry_pick_commit(std::path::Path::new("/nonexistent"), "abc1234");
213 assert!(result.is_err());
214 }
215
216 use super::*;
217 use tempfile::TempDir;
218
219 fn setup_repo_with_commit() -> (TempDir, Repository) {
221 let dir = TempDir::new().unwrap();
222 let repo = Repository::init(dir.path()).unwrap();
223
224 let mut config = repo.config().unwrap();
226 config.set_str("user.name", "Test User").unwrap();
227 config.set_str("user.email", "test@example.com").unwrap();
228
229 let file_path = dir.path().join("hello.txt");
231 std::fs::write(&file_path, "hello world\n").unwrap();
232 {
233 let mut index = repo.index().unwrap();
234 index.add_path(std::path::Path::new("hello.txt")).unwrap();
235 index.write().unwrap();
236
237 let tree_oid = index.write_tree().unwrap();
238 let tree = repo.find_tree(tree_oid).unwrap();
239 let sig = repo.signature().unwrap();
240 repo.commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[])
241 .unwrap();
242 }
243
244 (dir, repo)
245 }
246
247 #[test]
248 fn list_commits_returns_initial_commit() {
249 let (_dir, repo) = setup_repo_with_commit();
250 let commits = list_commits(&repo, 10).unwrap();
251 assert_eq!(commits.len(), 1);
252 assert_eq!(commits[0].summary, "initial commit");
253 assert!(!commits[0].oid.is_empty());
254 assert_eq!(commits[0].short_oid.len(), 7);
255 assert!(commits[0].parent_ids.is_empty());
256 }
257
258 #[test]
259 fn create_commit_works() {
260 let (dir, repo) = setup_repo_with_commit();
261
262 std::fs::write(dir.path().join("hello.txt"), "updated\n").unwrap();
264 let mut index = repo.index().unwrap();
265 index.add_path(std::path::Path::new("hello.txt")).unwrap();
266 index.write().unwrap();
267
268 let info = create_commit(&repo, "second commit").unwrap();
269 assert_eq!(info.summary, "second commit");
270 assert_eq!(info.parent_ids.len(), 1);
271 }
272
273 #[test]
274 fn get_commit_details_works() {
275 let (_dir, repo) = setup_repo_with_commit();
276 let commits = list_commits(&repo, 1).unwrap();
277 let oid_str = &commits[0].oid;
278 let detail = get_commit_details(&repo, oid_str).unwrap();
279 assert_eq!(detail.oid, *oid_str);
280 assert_eq!(detail.summary, "initial commit");
281 }
282
283 #[test]
284 fn get_commit_details_bad_oid() {
285 let (_dir, repo) = setup_repo_with_commit();
286 let result = get_commit_details(&repo, "not-a-valid-oid");
287 assert!(result.is_err());
288 }
289
290 #[test]
291 fn list_commits_respects_max_count() {
292 let (dir, repo) = setup_repo_with_commit();
293
294 std::fs::write(dir.path().join("second.txt"), "two\n").unwrap();
296 let mut index = repo.index().unwrap();
297 index.add_path(std::path::Path::new("second.txt")).unwrap();
298 index.write().unwrap();
299 create_commit(&repo, "second commit").unwrap();
300
301 let one = list_commits(&repo, 1).unwrap();
302 assert_eq!(one.len(), 1);
303 assert_eq!(one[0].summary, "second commit");
304
305 let both = list_commits(&repo, 100).unwrap();
306 assert_eq!(both.len(), 2);
307 }
308
309 #[test]
310 fn list_commits_attaches_head_ref_to_tip() {
311 let (_dir, repo) = setup_repo_with_commit();
312 let commits = list_commits(&repo, 10).unwrap();
313 assert!(!commits[0].refs.is_empty(), "tip commit should have refs");
315 assert!(
316 commits[0]
317 .refs
318 .iter()
319 .any(|r| r.kind == crate::features::commits::types::RefKind::Head),
320 "tip commit should have a Head ref"
321 );
322 }
323
324 #[test]
325 fn list_commits_non_tip_commits_have_no_refs() {
326 let (dir, repo) = setup_repo_with_commit();
327 std::fs::write(dir.path().join("second.txt"), "two\n").unwrap();
329 let mut index = repo.index().unwrap();
330 index.add_path(std::path::Path::new("second.txt")).unwrap();
331 index.write().unwrap();
332 create_commit(&repo, "second commit").unwrap();
333
334 let commits = list_commits(&repo, 100).unwrap();
335 assert_eq!(commits.len(), 2);
336 assert!(!commits[0].refs.is_empty());
338 assert!(
340 commits[1].refs.is_empty(),
341 "non-tip commits should have empty refs"
342 );
343 }
344
345 #[test]
346 fn build_ref_map_includes_tags() {
347 let (_dir, repo) = setup_repo_with_commit();
348 let head_oid = repo.head().unwrap().target().unwrap();
350 let head_commit = repo.find_commit(head_oid).unwrap();
351 repo.tag_lightweight("v1.0.0", head_commit.as_object(), false)
352 .unwrap();
353
354 let ref_map = build_ref_map(&repo);
355 let labels = ref_map.get(&head_oid).expect("HEAD should have refs");
356 assert!(
357 labels
358 .iter()
359 .any(|r| r.name == "v1.0.0"
360 && r.kind == crate::features::commits::types::RefKind::Tag),
361 "tag should appear in ref map"
362 );
363 }
364}