Skip to main content

git_atomic/git/
branch.rs

1use crate::core::GitError;
2use gix::ObjectId;
3
4/// State of an atomic branch relative to the base commit.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum BranchState {
7    /// Branch does not exist yet.
8    Missing,
9    /// Branch tip equals the base commit.
10    Current,
11    /// Base is an ancestor of tip (safe to append).
12    FastForward { tip: ObjectId },
13    /// Neither is an ancestor of the other.
14    Diverged { tip: ObjectId },
15}
16
17/// Manages atomic branch resolution and state detection.
18pub struct BranchManager<'r> {
19    repo: &'r gix::Repository,
20    base_id: ObjectId,
21    branch_template: String,
22}
23
24impl<'r> BranchManager<'r> {
25    pub fn new(repo: &'r gix::Repository, base_id: ObjectId, branch_template: String) -> Self {
26        Self {
27            repo,
28            base_id,
29            branch_template,
30        }
31    }
32
33    /// Compute the full ref name for a component branch.
34    /// Uses the component's branch override if present, otherwise the template.
35    pub fn branch_ref_name(&self, component: &str, branch_override: Option<&str>) -> String {
36        let branch_name = match branch_override {
37            Some(name) => name.to_string(),
38            None => self.branch_template.replace("{component}", component),
39        };
40        format!("refs/heads/{branch_name}")
41    }
42
43    /// Determine the state of a branch relative to the base.
44    pub fn check_state(&self, ref_name: &str) -> Result<BranchState, GitError> {
45        let reference = self
46            .repo
47            .try_find_reference(ref_name)
48            .map_err(|e| GitError::Operation(format!("find reference {ref_name}: {e}")))?;
49
50        let reference = match reference {
51            Some(r) => r,
52            None => return Ok(BranchState::Missing),
53        };
54
55        let tip = reference
56            .into_fully_peeled_id()
57            .map_err(|e| GitError::Operation(format!("peel reference {ref_name}: {e}")))?
58            .detach();
59
60        if tip == self.base_id {
61            return Ok(BranchState::Current);
62        }
63
64        let merge_base = self
65            .repo
66            .merge_base(tip, self.base_id)
67            .map_err(|e| GitError::Operation(format!("merge_base: {e}")))?;
68
69        if merge_base == self.base_id {
70            Ok(BranchState::FastForward { tip })
71        } else {
72            Ok(BranchState::Diverged { tip })
73        }
74    }
75
76    /// Determine the parent commit for a new atomic commit on this branch.
77    /// Returns the appropriate parent ObjectId, or an error if diverged and not forced.
78    pub fn parent_for(&self, ref_name: &str, force: bool) -> Result<ObjectId, GitError> {
79        match self.check_state(ref_name)? {
80            BranchState::Missing | BranchState::Current => Ok(self.base_id),
81            BranchState::FastForward { tip } => Ok(tip),
82            BranchState::Diverged { .. } => {
83                if force {
84                    Ok(self.base_id)
85                } else {
86                    Err(GitError::Operation(format!(
87                        "branch {ref_name} has diverged from base; use --force to override"
88                    )))
89                }
90            }
91        }
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use std::path::Path;
99    use std::process::Command;
100
101    fn git(dir: &Path, args: &[&str]) -> String {
102        let out = Command::new("git")
103            .args(args)
104            .current_dir(dir)
105            .output()
106            .unwrap();
107        String::from_utf8_lossy(&out.stdout).trim().to_string()
108    }
109
110    fn init_repo(dir: &Path) {
111        git(dir, &["init", "-b", "main"]);
112        git(dir, &["config", "user.email", "test@test.com"]);
113        git(dir, &["config", "user.name", "Test"]);
114        std::fs::write(dir.join("file.txt"), "init").unwrap();
115        git(dir, &["add", "."]);
116        git(dir, &["commit", "-m", "initial"]);
117    }
118
119    #[test]
120    fn missing_branch() {
121        let dir = tempfile::tempdir().unwrap();
122        init_repo(dir.path());
123        let repo = crate::git::open_repo(dir.path()).unwrap();
124        let base = crate::git::resolve_commit(&repo, "HEAD").unwrap();
125        let mgr = BranchManager::new(&repo, base, "atomic/{component}".into());
126
127        let ref_name = mgr.branch_ref_name("frontend", None);
128        assert_eq!(ref_name, "refs/heads/atomic/frontend");
129        assert_eq!(mgr.check_state(&ref_name).unwrap(), BranchState::Missing);
130        assert_eq!(mgr.parent_for(&ref_name, false).unwrap(), base);
131    }
132
133    #[test]
134    fn current_branch() {
135        let dir = tempfile::tempdir().unwrap();
136        init_repo(dir.path());
137        git(dir.path(), &["branch", "atomic/test"]);
138        let repo = crate::git::open_repo(dir.path()).unwrap();
139        let base = crate::git::resolve_commit(&repo, "HEAD").unwrap();
140        let mgr = BranchManager::new(&repo, base, "atomic/{component}".into());
141
142        let state = mgr.check_state("refs/heads/atomic/test").unwrap();
143        assert_eq!(state, BranchState::Current);
144    }
145
146    #[test]
147    fn fast_forward_branch() {
148        let dir = tempfile::tempdir().unwrap();
149        init_repo(dir.path());
150        // Create atomic branch, add a commit to it
151        git(dir.path(), &["checkout", "-b", "atomic/ff"]);
152        std::fs::write(dir.path().join("extra.txt"), "more").unwrap();
153        git(dir.path(), &["add", "."]);
154        git(dir.path(), &["commit", "-m", "extra"]);
155        git(dir.path(), &["checkout", "main"]);
156
157        let repo = crate::git::open_repo(dir.path()).unwrap();
158        let base = crate::git::resolve_commit(&repo, "HEAD").unwrap();
159        let mgr = BranchManager::new(&repo, base, "atomic/{component}".into());
160
161        let state = mgr.check_state("refs/heads/atomic/ff").unwrap();
162        assert!(matches!(state, BranchState::FastForward { .. }));
163        // parent_for should return the tip
164        let parent = mgr.parent_for("refs/heads/atomic/ff", false).unwrap();
165        assert_ne!(parent, base);
166    }
167
168    #[test]
169    fn diverged_branch() {
170        let dir = tempfile::tempdir().unwrap();
171        init_repo(dir.path());
172        // Create atomic branch with a commit
173        git(dir.path(), &["checkout", "-b", "atomic/div"]);
174        std::fs::write(dir.path().join("branch.txt"), "branch").unwrap();
175        git(dir.path(), &["add", "."]);
176        git(dir.path(), &["commit", "-m", "branch commit"]);
177        // Go back to main and add a different commit
178        git(dir.path(), &["checkout", "main"]);
179        std::fs::write(dir.path().join("main.txt"), "main").unwrap();
180        git(dir.path(), &["add", "."]);
181        git(dir.path(), &["commit", "-m", "main commit"]);
182
183        let repo = crate::git::open_repo(dir.path()).unwrap();
184        let base = crate::git::resolve_commit(&repo, "HEAD").unwrap();
185        let mgr = BranchManager::new(&repo, base, "atomic/{component}".into());
186
187        let state = mgr.check_state("refs/heads/atomic/div").unwrap();
188        assert!(matches!(state, BranchState::Diverged { .. }));
189
190        // Without force: error
191        assert!(mgr.parent_for("refs/heads/atomic/div", false).is_err());
192        // With force: returns base
193        assert_eq!(mgr.parent_for("refs/heads/atomic/div", true).unwrap(), base);
194    }
195
196    #[test]
197    fn branch_override() {
198        let dir = tempfile::tempdir().unwrap();
199        init_repo(dir.path());
200        let repo = crate::git::open_repo(dir.path()).unwrap();
201        let base = crate::git::resolve_commit(&repo, "HEAD").unwrap();
202        let mgr = BranchManager::new(&repo, base, "atomic/{component}".into());
203
204        let ref_name = mgr.branch_ref_name("frontend", Some("custom/branch"));
205        assert_eq!(ref_name, "refs/heads/custom/branch");
206    }
207}