1use crate::core::GitError;
2use gix::ObjectId;
3
4#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum BranchState {
7 Missing,
9 Current,
11 FastForward { tip: ObjectId },
13 Diverged { tip: ObjectId },
15}
16
17pub 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 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 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 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 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 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 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 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 assert!(mgr.parent_for("refs/heads/atomic/div", false).is_err());
192 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}