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