gitkraft_core/features/branches/
ops.rs1use anyhow::{bail, Context, Result};
4use git2::{BranchType as Git2BranchType, Repository};
5use tracing::debug;
6
7use super::types::{BranchInfo, BranchType};
8
9pub fn list_branches(repo: &Repository) -> Result<Vec<BranchInfo>> {
11 let mut branches = Vec::new();
12
13 let head_ref = repo.head().ok();
14 let head_name = head_ref
15 .as_ref()
16 .and_then(|r| r.shorthand().map(String::from));
17
18 for branch_result in repo.branches(None)? {
19 let (branch, bt) = branch_result?;
20
21 let name = branch.name()?.unwrap_or("<invalid utf-8>").to_string();
22
23 let branch_type = match bt {
24 Git2BranchType::Local => BranchType::Local,
25 Git2BranchType::Remote => BranchType::Remote,
26 };
27
28 let is_head = match branch_type {
29 BranchType::Local => head_name.as_deref() == Some(name.as_str()),
30 BranchType::Remote => false,
31 };
32
33 let target_oid = branch.get().target().map(|oid| oid.to_string());
34
35 branches.push(BranchInfo {
36 name,
37 branch_type,
38 is_head,
39 target_oid,
40 });
41 }
42
43 debug!("listed {} branches", branches.len());
44 Ok(branches)
45}
46
47pub fn create_branch(repo: &Repository, name: &str) -> Result<BranchInfo> {
51 let head_ref = repo
52 .head()
53 .context("HEAD not found — is this an empty repository?")?;
54 let commit = head_ref
55 .peel_to_commit()
56 .context("HEAD does not point to a commit")?;
57
58 let branch = repo
59 .branch(name, &commit, false)
60 .with_context(|| format!("failed to create branch '{name}'"))?;
61
62 let target_oid = branch.get().target().map(|oid| oid.to_string());
63
64 debug!(name, "created branch");
65 Ok(BranchInfo {
66 name: name.to_string(),
67 branch_type: BranchType::Local,
68 is_head: false,
69 target_oid,
70 })
71}
72
73pub fn delete_branch(repo: &Repository, name: &str) -> Result<()> {
77 let mut branch = repo
78 .find_branch(name, Git2BranchType::Local)
79 .with_context(|| format!("local branch '{name}' not found"))?;
80
81 if branch.is_head() {
82 bail!("cannot delete the currently checked-out branch '{name}'");
83 }
84
85 branch
86 .delete()
87 .with_context(|| format!("failed to delete branch '{name}'"))?;
88 debug!(name, "deleted branch");
89 Ok(())
90}
91
92pub fn checkout_branch(repo: &Repository, name: &str) -> Result<()> {
96 let refname = format!("refs/heads/{name}");
97
98 repo.find_branch(name, Git2BranchType::Local)
100 .with_context(|| format!("local branch '{name}' not found"))?;
101
102 repo.set_head(&refname)
103 .with_context(|| format!("failed to set HEAD to '{refname}'"))?;
104
105 repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))
106 .with_context(|| format!("failed to checkout branch '{name}'"))?;
107
108 debug!(name, "checked out branch");
109 Ok(())
110}
111
112pub fn merge_branch(repo: &Repository, source_branch: &str) -> Result<()> {
119 let branch = repo
121 .find_branch(source_branch, Git2BranchType::Local)
122 .with_context(|| format!("local branch '{source_branch}' not found"))?;
123
124 let source_ref = branch.get();
125 let source_oid = source_ref
126 .target()
127 .with_context(|| format!("branch '{source_branch}' has no target OID"))?;
128
129 let annotated_commit = repo
130 .find_annotated_commit(source_oid)
131 .context("failed to find annotated commit for source branch")?;
132
133 let (analysis, _preference) = repo
135 .merge_analysis(&[&annotated_commit])
136 .context("merge analysis failed")?;
137
138 if analysis.is_up_to_date() {
139 debug!(source_branch, "already up to date");
140 return Ok(());
141 }
142
143 if analysis.is_fast_forward() {
144 debug!(source_branch, "fast-forwarding");
145 let refname = format!("refs/heads/{}", head_branch_name(repo)?);
147 let msg = format!("Fast-forward merge of '{source_branch}'");
148 repo.reference(&refname, source_oid, true, &msg)?;
149 repo.set_head(&refname)?;
150 repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))?;
151 return Ok(());
152 }
153
154 if analysis.is_normal() {
155 debug!(source_branch, "performing normal merge");
156
157 repo.merge(&[&annotated_commit], None, None)
159 .context("merge failed")?;
160
161 let index = repo.index().context("failed to read index after merge")?;
163 if index.has_conflicts() {
164 bail!(
165 "merge of '{source_branch}' resulted in conflicts — resolve them and commit manually"
166 );
167 }
168
169 let sig = repo
171 .signature()
172 .or_else(|_| git2::Signature::now("GitKraft User", "user@gitkraft.local"))
173 .context("failed to obtain signature")?;
174
175 let mut index = repo.index().context("failed to read index")?;
176 let tree_oid = index.write_tree().context("failed to write merged tree")?;
177 let tree = repo
178 .find_tree(tree_oid)
179 .context("failed to find merged tree")?;
180
181 let head_commit = repo
182 .head()?
183 .peel_to_commit()
184 .context("HEAD does not point to a commit")?;
185
186 let source_commit = repo
187 .find_commit(source_oid)
188 .context("failed to find source commit")?;
189
190 let message = format!("Merge branch '{source_branch}'");
191 repo.commit(
192 Some("HEAD"),
193 &sig,
194 &sig,
195 &message,
196 &tree,
197 &[&head_commit, &source_commit],
198 )
199 .context("failed to create merge commit")?;
200
201 repo.cleanup_state()
203 .context("failed to clean up merge state")?;
204
205 debug!(source_branch, "merge commit created");
206 return Ok(());
207 }
208
209 bail!("merge analysis returned an unexpected result for branch '{source_branch}'");
210}
211
212fn head_branch_name(repo: &Repository) -> Result<String> {
214 let head = repo.head().context("HEAD not found")?;
215 let name = head
216 .shorthand()
217 .context("HEAD is not a symbolic reference (detached HEAD?)")?
218 .to_string();
219 Ok(name)
220}
221
222fn run_git(workdir: &std::path::Path, args: &[&str]) -> anyhow::Result<()> {
225 let output = std::process::Command::new("git")
226 .current_dir(workdir)
227 .args(args)
228 .output()
229 .context("failed to spawn git")?;
230 if !output.status.success() {
231 let stderr = String::from_utf8_lossy(&output.stderr);
232 anyhow::bail!("{}", stderr.trim());
233 }
234 Ok(())
235}
236
237pub fn rename_branch(repo: &Repository, old_name: &str, new_name: &str) -> Result<()> {
241 let mut branch = repo
242 .find_branch(old_name, Git2BranchType::Local)
243 .with_context(|| format!("branch '{old_name}' not found"))?;
244 branch
245 .rename(new_name, false)
246 .with_context(|| format!("failed to rename '{old_name}' → '{new_name}'"))?;
247 debug!(old_name, new_name, "renamed branch");
248 Ok(())
249}
250
251pub fn create_branch_at_commit(repo: &Repository, name: &str, oid_str: &str) -> Result<()> {
253 let oid = git2::Oid::from_str(oid_str).context("invalid OID")?;
254 let commit = repo
255 .find_commit(oid)
256 .with_context(|| format!("commit {oid_str} not found"))?;
257 repo.branch(name, &commit, false)
258 .with_context(|| format!("failed to create branch '{name}' at {oid_str}"))?;
259 debug!(name, oid_str, "created branch at commit");
260 Ok(())
261}
262
263pub fn push_branch(workdir: &std::path::Path, branch: &str, remote: &str) -> Result<()> {
268 run_git(workdir, &["push", remote, branch])
269}
270
271pub fn delete_remote_branch(workdir: &std::path::Path, full_name: &str) -> Result<()> {
276 let (remote, branch) = full_name.split_once('/').with_context(|| {
277 format!("invalid remote branch name '{full_name}' — expected 'remote/branch'")
278 })?;
279 run_git(workdir, &["push", remote, "--delete", branch])
280}
281
282pub fn checkout_remote_branch(workdir: &std::path::Path, full_name: &str) -> Result<()> {
287 let (_remote, branch) = full_name.split_once('/').with_context(|| {
288 format!("invalid remote branch name '{full_name}' — expected 'remote/branch'")
289 })?;
290 run_git(workdir, &["checkout", "-b", branch, "--track", full_name])
291}
292
293pub fn pull_rebase(workdir: &std::path::Path, remote: &str) -> Result<()> {
295 run_git(workdir, &["pull", "--rebase", remote])
296}
297
298pub fn rebase_onto(workdir: &std::path::Path, target: &str) -> Result<()> {
300 run_git(workdir, &["rebase", target])
301}
302
303pub fn create_tag(repo: &Repository, name: &str, oid_str: &str) -> Result<()> {
305 let oid = git2::Oid::from_str(oid_str).context("invalid OID")?;
306 let object = repo
307 .find_object(oid, None)
308 .with_context(|| format!("object {oid_str} not found"))?;
309 repo.tag_lightweight(name, &object, false)
310 .with_context(|| format!("failed to create lightweight tag '{name}'"))?;
311 debug!(name, oid_str, "created lightweight tag");
312 Ok(())
313}
314
315pub fn create_annotated_tag(
317 repo: &Repository,
318 name: &str,
319 message: &str,
320 oid_str: &str,
321) -> Result<()> {
322 let oid = git2::Oid::from_str(oid_str).context("invalid OID")?;
323 let object = repo
324 .find_object(oid, None)
325 .with_context(|| format!("object {oid_str} not found"))?;
326 let sig = repo
327 .signature()
328 .or_else(|_| git2::Signature::now("GitKraft User", "user@gitkraft.local"))
329 .context("failed to obtain signature")?;
330 repo.tag(name, &object, &sig, message, false)
331 .with_context(|| format!("failed to create annotated tag '{name}'"))?;
332 debug!(name, oid_str, "created annotated tag");
333 Ok(())
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use tempfile::TempDir;
340
341 fn setup_repo_with_commit() -> (TempDir, Repository) {
342 let dir = TempDir::new().unwrap();
343 let repo = Repository::init(dir.path()).unwrap();
344 let mut config = repo.config().unwrap();
345 config.set_str("user.name", "Test User").unwrap();
346 config.set_str("user.email", "test@example.com").unwrap();
347 std::fs::write(dir.path().join("file.txt"), "hello\n").unwrap();
348 let mut index = repo.index().unwrap();
349 index.add_path(std::path::Path::new("file.txt")).unwrap();
350 index.write().unwrap();
351 let tree_oid = index.write_tree().unwrap();
352 {
353 let tree = repo.find_tree(tree_oid).unwrap();
354 let sig = repo.signature().unwrap();
355 repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
356 .unwrap();
357 }
358 (dir, repo)
359 }
360
361 #[test]
362 fn list_branches_shows_main() {
363 let (_dir, repo) = setup_repo_with_commit();
364 let branches = list_branches(&repo).unwrap();
365 assert!(!branches.is_empty());
366 assert!(branches.iter().any(|b| b.is_head));
367 }
368
369 #[test]
370 fn create_and_delete_branch() {
371 let (_dir, repo) = setup_repo_with_commit();
372 let branch = create_branch(&repo, "feature-test").unwrap();
373 assert_eq!(branch.name, "feature-test");
374 assert!(!branch.is_head);
375
376 delete_branch(&repo, "feature-test").unwrap();
377 let branches = list_branches(&repo).unwrap();
378 assert!(!branches.iter().any(|b| b.name == "feature-test"));
379 }
380
381 #[test]
382 fn checkout_branch_switches_head() {
383 let (_dir, repo) = setup_repo_with_commit();
384 create_branch(&repo, "new-branch").unwrap();
385 checkout_branch(&repo, "new-branch").unwrap();
386 let branches = list_branches(&repo).unwrap();
387 let head = branches.iter().find(|b| b.is_head).unwrap();
388 assert_eq!(head.name, "new-branch");
389 }
390
391 #[test]
392 fn delete_head_branch_fails() {
393 let (_dir, repo) = setup_repo_with_commit();
394 let branches = list_branches(&repo).unwrap();
395 let head = branches.iter().find(|b| b.is_head).unwrap();
396 let result = delete_branch(&repo, &head.name);
397 assert!(result.is_err());
398 }
399}