Skip to main content

ralph/git/
branch.rs

1//! Git branch helpers for resolving the current branch name.
2//!
3//! Responsibilities:
4//! - Determine the current branch name for the repository.
5//! - Fail fast on detached HEAD states to avoid ambiguous base branches.
6//! - Fast-forward the local base branch to `origin/<branch>` when required.
7//!
8//! Not handled here:
9//! - Branch creation or deletion (see `git/workspace.rs`).
10//! - Push operations (see `git/commit.rs`).
11//!
12//! Invariants/assumptions:
13//! - Caller expects a named branch (not detached HEAD).
14//! - Git is available and the repo root is valid.
15
16use crate::git::error::git_base_command;
17use anyhow::{Context, Result, bail};
18use std::path::Path;
19
20pub(crate) fn current_branch(repo_root: &Path) -> Result<String> {
21    let output = git_base_command(repo_root)
22        .arg("rev-parse")
23        .arg("--abbrev-ref")
24        .arg("HEAD")
25        .output()
26        .with_context(|| {
27            format!(
28                "run git rev-parse --abbrev-ref HEAD in {}",
29                repo_root.display()
30            )
31        })?;
32
33    if !output.status.success() {
34        let stderr = String::from_utf8_lossy(&output.stderr);
35        bail!(
36            "Failed to determine current branch: git rev-parse error: {}",
37            stderr.trim()
38        );
39    }
40
41    let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
42    if branch.is_empty() {
43        bail!("Failed to determine current branch: empty branch name.");
44    }
45
46    if branch == "HEAD" {
47        bail!("Parallel run requires a named branch (detached HEAD detected).");
48    }
49
50    Ok(branch)
51}
52
53#[allow(dead_code)]
54pub(crate) fn fast_forward_branch_to_origin(repo_root: &Path, branch: &str) -> Result<()> {
55    let branch = branch.trim();
56    if branch.is_empty() {
57        bail!("Cannot fast-forward: branch name is empty.");
58    }
59
60    let checkout_output = git_base_command(repo_root)
61        .args(["checkout", branch])
62        .output()
63        .with_context(|| format!("run git checkout {} in {}", branch, repo_root.display()))?;
64    if !checkout_output.status.success() {
65        let stderr = String::from_utf8_lossy(&checkout_output.stderr);
66        bail!(
67            "Failed to check out branch {} before fast-forward: {}",
68            branch,
69            stderr.trim()
70        );
71    }
72
73    let fetch_output = git_base_command(repo_root)
74        .args(["fetch", "origin", "--prune"])
75        .output()
76        .with_context(|| format!("run git fetch origin --prune in {}", repo_root.display()))?;
77    if !fetch_output.status.success() {
78        let stderr = String::from_utf8_lossy(&fetch_output.stderr);
79        bail!(
80            "Failed to fetch origin before fast-forwarding {}: {}",
81            branch,
82            stderr.trim()
83        );
84    }
85
86    let remote_ref = format!("origin/{}", branch);
87    let merge_output = git_base_command(repo_root)
88        .args(["merge", "--ff-only", &remote_ref])
89        .output()
90        .with_context(|| {
91            format!(
92                "run git merge --ff-only {} in {}",
93                remote_ref,
94                repo_root.display()
95            )
96        })?;
97    if !merge_output.status.success() {
98        let stderr = String::from_utf8_lossy(&merge_output.stderr);
99        bail!(
100            "Failed to fast-forward branch {} to {}: {}",
101            branch,
102            remote_ref,
103            stderr.trim()
104        );
105    }
106
107    Ok(())
108}
109
110#[cfg(test)]
111mod tests {
112    use super::{current_branch, fast_forward_branch_to_origin};
113    use crate::testsupport::git as git_test;
114    use anyhow::Result;
115    use tempfile::TempDir;
116
117    #[test]
118    fn current_branch_returns_branch_name() -> Result<()> {
119        let temp = TempDir::new()?;
120        git_test::init_repo(temp.path())?;
121        std::fs::write(temp.path().join("init.txt"), "init")?;
122        git_test::commit_all(temp.path(), "init")?;
123        let expected = git_test::git_output(temp.path(), &["rev-parse", "--abbrev-ref", "HEAD"])?;
124        let branch = current_branch(temp.path())?;
125        assert_eq!(branch, expected);
126        Ok(())
127    }
128
129    #[test]
130    fn current_branch_errors_on_detached_head() -> Result<()> {
131        let temp = TempDir::new()?;
132        git_test::init_repo(temp.path())?;
133        std::fs::write(temp.path().join("init.txt"), "init")?;
134        git_test::commit_all(temp.path(), "init")?;
135        git_test::git_run(temp.path(), &["checkout", "--detach", "HEAD"])?;
136        let err = current_branch(temp.path()).unwrap_err();
137        assert!(err.to_string().contains("detached HEAD"));
138        Ok(())
139    }
140
141    #[test]
142    fn fast_forward_branch_to_origin_updates_local_branch() -> Result<()> {
143        let temp = TempDir::new()?;
144        let remote = temp.path().join("remote.git");
145        std::fs::create_dir_all(&remote)?;
146        git_test::init_bare_repo(&remote)?;
147
148        let seed = temp.path().join("seed");
149        std::fs::create_dir_all(&seed)?;
150        git_test::init_repo(&seed)?;
151        std::fs::write(seed.join("seed.txt"), "v1")?;
152        git_test::commit_all(&seed, "seed init")?;
153        let branch = git_test::git_output(&seed, &["rev-parse", "--abbrev-ref", "HEAD"])?;
154        git_test::add_remote(&seed, "origin", &remote)?;
155        git_test::push_branch(&seed, &branch)?;
156        git_test::git_run(
157            &remote,
158            &["symbolic-ref", "HEAD", &format!("refs/heads/{}", branch)],
159        )?;
160
161        let local = temp.path().join("local");
162        git_test::clone_repo(&remote, &local)?;
163        git_test::configure_user(&local)?;
164
165        let upstream = temp.path().join("upstream");
166        git_test::clone_repo(&remote, &upstream)?;
167        git_test::configure_user(&upstream)?;
168        std::fs::write(upstream.join("seed.txt"), "v2")?;
169        git_test::commit_all(&upstream, "remote ahead")?;
170        git_test::push_branch(&upstream, &branch)?;
171
172        let old_head = git_test::git_output(&local, &["rev-parse", "HEAD"])?;
173        fast_forward_branch_to_origin(&local, &branch)?;
174        let new_head = git_test::git_output(&local, &["rev-parse", "HEAD"])?;
175        let remote_head =
176            git_test::git_output(&local, &["rev-parse", &format!("origin/{}", branch)])?;
177
178        assert_ne!(old_head, new_head);
179        assert_eq!(new_head, remote_head);
180        Ok(())
181    }
182
183    #[test]
184    fn fast_forward_branch_to_origin_errors_on_divergence() -> Result<()> {
185        let temp = TempDir::new()?;
186        let remote = temp.path().join("remote.git");
187        std::fs::create_dir_all(&remote)?;
188        git_test::init_bare_repo(&remote)?;
189
190        let seed = temp.path().join("seed");
191        std::fs::create_dir_all(&seed)?;
192        git_test::init_repo(&seed)?;
193        std::fs::write(seed.join("seed.txt"), "v1")?;
194        git_test::commit_all(&seed, "seed init")?;
195        let branch = git_test::git_output(&seed, &["rev-parse", "--abbrev-ref", "HEAD"])?;
196        git_test::add_remote(&seed, "origin", &remote)?;
197        git_test::push_branch(&seed, &branch)?;
198        git_test::git_run(
199            &remote,
200            &["symbolic-ref", "HEAD", &format!("refs/heads/{}", branch)],
201        )?;
202
203        let local = temp.path().join("local");
204        git_test::clone_repo(&remote, &local)?;
205        git_test::configure_user(&local)?;
206
207        let upstream = temp.path().join("upstream");
208        git_test::clone_repo(&remote, &upstream)?;
209        git_test::configure_user(&upstream)?;
210
211        std::fs::write(local.join("local.txt"), "local-only")?;
212        git_test::commit_all(&local, "local ahead")?;
213
214        std::fs::write(upstream.join("remote.txt"), "remote-only")?;
215        git_test::commit_all(&upstream, "remote ahead")?;
216        git_test::push_branch(&upstream, &branch)?;
217
218        let err = fast_forward_branch_to_origin(&local, &branch).unwrap_err();
219        assert!(
220            err.to_string().contains("fast-forward"),
221            "unexpected error: {err}"
222        );
223        Ok(())
224    }
225}