Skip to main content

gitkraft_core/features/branches/
ops.rs

1//! Branch operations — list, create, delete, checkout, and merge branches.
2
3use anyhow::{bail, Context, Result};
4use git2::{BranchType as Git2BranchType, Repository};
5use tracing::debug;
6
7use super::types::{BranchInfo, BranchType};
8
9/// List all branches (local and remote) in the repository.
10pub 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
47/// Create a new local branch at HEAD with the given name.
48///
49/// Returns the newly created [`BranchInfo`].
50pub 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
73/// Delete a local branch by name.
74///
75/// Refuses to delete the currently checked-out branch.
76pub 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
92/// Checkout an existing local branch by name.
93///
94/// Sets HEAD to the branch reference and updates the working directory.
95pub fn checkout_branch(repo: &Repository, name: &str) -> Result<()> {
96    let refname = format!("refs/heads/{name}");
97
98    // Make sure the branch actually exists
99    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
112/// Merge a source branch into the current HEAD branch.
113///
114/// If the merge can be fast-forwarded, it does so. If it results in a normal
115/// merge (no conflicts), an automatic merge commit is created. If there are
116/// conflicts, an error is returned and the repository is left in a merging
117/// state so the user can resolve conflicts manually.
118pub fn merge_branch(repo: &Repository, source_branch: &str) -> Result<()> {
119    // Look up the source branch reference and its annotated commit.
120    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    // Perform merge analysis.
134    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        // Fast-forward: just move the current branch reference.
146        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        // Perform the actual merge (writes conflicts to index if any).
158        repo.merge(&[&annotated_commit], None, None)
159            .context("merge failed")?;
160
161        // Check for conflicts.
162        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        // No conflicts — create the merge commit automatically.
170        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        // Clean up merge state.
202        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
212/// Helper: get the short name of the branch HEAD points to.
213fn 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
222// ── subprocess helper ─────────────────────────────────────────────────────────
223
224fn 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
237// ── new public functions ──────────────────────────────────────────────────────
238
239/// Rename a local branch.
240pub 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
251/// Create a new local branch pointing at a specific commit OID.
252pub 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
263/// Push a local branch to a remote using `git push`.
264///
265/// Uses the system `git` binary so that the user's configured credential
266/// helpers (SSH agent, git-credential-manager, etc.) are respected.
267pub fn push_branch(workdir: &std::path::Path, branch: &str, remote: &str) -> Result<()> {
268    run_git(workdir, &["push", remote, branch])
269}
270
271/// Delete a remote branch using `git push <remote> --delete <branch>`.
272///
273/// `full_name` is the remote-tracking branch name (e.g. `origin/feature-x`).
274/// The function extracts the remote and branch parts automatically.
275pub 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
282/// Checkout a remote branch by creating a local tracking branch.
283///
284/// `full_name` is the remote-tracking branch name (e.g. `origin/feature-x`).
285/// Creates a local branch named `feature-x` that tracks `origin/feature-x`.
286pub 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
293/// Pull the current branch from a remote with `--rebase`.
294pub fn pull_rebase(workdir: &std::path::Path, remote: &str) -> Result<()> {
295    run_git(workdir, &["pull", "--rebase", remote])
296}
297
298/// Rebase the current HEAD onto `target` (branch name or OID).
299pub fn rebase_onto(workdir: &std::path::Path, target: &str) -> Result<()> {
300    run_git(workdir, &["rebase", target])
301}
302
303/// Create a lightweight Git tag pointing at the given OID.
304pub 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
315/// Create an annotated Git tag with a tagger signature and message pointing at the given OID.
316pub 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}