1use crate::git::Git;
12use std::collections::HashSet;
13use std::path::Path;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum BaseSource {
18 Flag,
20 Config,
22 Remote,
24 Unresolved,
26}
27
28#[derive(Debug, Clone)]
31pub struct ResolvedBase {
32 pub name: Option<String>,
33 pub source: BaseSource,
34}
35
36impl ResolvedBase {
37 fn flag(name: &str) -> Self {
38 Self {
39 name: Some(name.to_string()),
40 source: BaseSource::Flag,
41 }
42 }
43 fn config(name: &str) -> Self {
44 Self {
45 name: Some(name.to_string()),
46 source: BaseSource::Config,
47 }
48 }
49 fn remote(name: &str) -> Self {
50 Self {
51 name: Some(name.to_string()),
52 source: BaseSource::Remote,
53 }
54 }
55 pub fn unresolved() -> Self {
57 Self {
58 name: None,
59 source: BaseSource::Unresolved,
60 }
61 }
62
63 pub fn describe(&self) -> String {
65 match (&self.name, self.source) {
66 (Some(b), BaseSource::Flag) => format!("{b} (from --base-branch)"),
67 (Some(b), BaseSource::Config) => format!("{b} (from git config gkit.baseBranch)"),
68 (Some(b), BaseSource::Remote) => format!("{b} (derived from remote origin/{b})"),
69 _ => "UNRESOLVED — gkit.baseBranch unset and no origin/main or origin/master \
70 (correct-branch can't be checked)"
71 .to_string(),
72 }
73 }
74}
75
76pub fn resolve_base(git: &dyn Git, dir: &Path, cli_override: Option<&str>) -> ResolvedBase {
78 if let Some(b) = cli_override {
79 let b = b.trim();
80 if !b.is_empty() {
81 return ResolvedBase::flag(b);
82 }
83 }
84 let cfg = git.run(dir, &["config", "--get", "gkit.baseBranch"]);
85 if cfg.success && !cfg.trimmed().is_empty() {
86 return ResolvedBase::config(cfg.trimmed());
87 }
88 let remotes: HashSet<String> = git
91 .run(
92 dir,
93 &[
94 "for-each-ref",
95 "--format=%(refname:short)",
96 "refs/remotes/origin/*",
97 ],
98 )
99 .stdout
100 .lines()
101 .filter_map(|l| l.trim().strip_prefix("origin/").map(str::to_string))
102 .collect();
103 for cand in ["main", "master"] {
104 if remotes.contains(cand) {
105 return ResolvedBase::remote(cand);
106 }
107 }
108 ResolvedBase::unresolved()
109}
110
111pub fn resolve_solo(git: &dyn Git, dir: &Path) -> bool {
119 let o = git.run(dir, &["config", "--get", "--bool", "gkit.solo"]);
120 o.success && o.trimmed() == "true"
121}
122
123pub fn resolve_allow_diverged(git: &dyn Git, dir: &Path) -> bool {
131 let o = git.run(dir, &["config", "--get", "--bool", "gkit.allowDiverged"]);
132 o.success && o.trimmed() == "true"
133}
134
135pub fn resolve_conf(git: &dyn Git, dir: &Path) -> Option<String> {
140 let o = git.run(dir, &["config", "--local", "--get", "gkit.conf"]);
141 let v = o.trimmed();
142 (o.success && !v.is_empty()).then(|| v.to_string())
143}
144
145pub fn current_branch_opt(git: &dyn Git, dir: &Path) -> Option<String> {
147 let o = git.run(dir, &["symbolic-ref", "--short", "HEAD"]);
148 if o.success {
149 Some(o.trimmed().to_string())
150 } else {
151 None
152 }
153}
154
155pub fn resolve_switch_base(
159 git: &dyn Git,
160 dir: &Path,
161 cli_override: Option<&str>,
162) -> Option<String> {
163 if let Some(b) = cli_override {
164 if !b.trim().is_empty() {
165 return Some(b.trim().to_string());
166 }
167 }
168 let cfg = git.run(dir, &["config", "--get", "gkit.baseBranch"]);
169 if cfg.success && !cfg.trimmed().is_empty() {
170 return Some(cfg.trimmed().to_string());
171 }
172 let head = git.run(
173 dir,
174 &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
175 );
176 if head.success {
177 return head.trimmed().strip_prefix("origin/").map(str::to_string);
178 }
179 None
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use crate::git::test_support::FakeGit;
186 use std::path::Path;
187
188 fn d() -> &'static Path {
189 Path::new("/x")
190 }
191
192 fn with_remotes(g: FakeGit, branches: &[&str]) -> FakeGit {
194 let listing = branches
195 .iter()
196 .map(|b| format!("origin/{b}"))
197 .collect::<Vec<_>>()
198 .join("\n");
199 g.ok(
200 "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
201 &listing,
202 )
203 }
204
205 #[test]
206 fn cli_override_wins() {
207 let g = FakeGit::new().ok("config --get gkit.baseBranch", "dev");
208 let r = resolve_base(&g, d(), Some("main"));
209 assert_eq!(r.name.as_deref(), Some("main"));
210 assert_eq!(r.source, BaseSource::Flag);
211 }
212
213 #[test]
214 fn falls_back_to_git_config() {
215 let g = FakeGit::new().ok("config --get gkit.baseBranch", "dev");
216 let r = resolve_base(&g, d(), None);
217 assert_eq!(r.name.as_deref(), Some("dev"));
218 assert_eq!(r.source, BaseSource::Config);
219 }
220
221 #[test]
222 fn derives_main_from_remote_when_config_unset() {
223 let g = with_remotes(
224 FakeGit::new().fail("config --get gkit.baseBranch"),
225 &["feature-x", "main", "master"],
226 );
227 let r = resolve_base(&g, d(), None);
228 assert_eq!(r.name.as_deref(), Some("main"));
230 assert_eq!(r.source, BaseSource::Remote);
231 }
232
233 #[test]
234 fn derives_master_when_no_main() {
235 let g = with_remotes(
236 FakeGit::new().fail("config --get gkit.baseBranch"),
237 &["master", "feature-y"],
238 );
239 let r = resolve_base(&g, d(), None);
240 assert_eq!(r.name.as_deref(), Some("master"));
241 assert_eq!(r.source, BaseSource::Remote);
242 }
243
244 #[test]
245 fn unresolved_when_no_config_and_no_main_master() {
246 let g = with_remotes(
248 FakeGit::new().fail("config --get gkit.baseBranch"),
249 &["feature-only"],
250 );
251 let r = resolve_base(&g, d(), None);
252 assert_eq!(r.name, None);
253 assert_eq!(r.source, BaseSource::Unresolved);
254 }
255
256 #[test]
257 fn resolve_solo_defaults_false_and_reads_bool() {
258 assert!(!resolve_solo(
260 &FakeGit::new().fail("config --get --bool gkit.solo"),
261 d()
262 ));
263 assert!(resolve_solo(
265 &FakeGit::new().ok("config --get --bool gkit.solo", "true"),
266 d()
267 ));
268 assert!(!resolve_solo(
269 &FakeGit::new().ok("config --get --bool gkit.solo", "false"),
270 d()
271 ));
272 }
273
274 #[test]
275 fn resolve_allow_diverged_defaults_false_and_reads_bool() {
276 assert!(!resolve_allow_diverged(
277 &FakeGit::new().fail("config --get --bool gkit.allowDiverged"),
278 d()
279 ));
280 assert!(resolve_allow_diverged(
281 &FakeGit::new().ok("config --get --bool gkit.allowDiverged", "true"),
282 d()
283 ));
284 assert!(!resolve_allow_diverged(
285 &FakeGit::new().ok("config --get --bool gkit.allowDiverged", "false"),
286 d()
287 ));
288 }
289
290 #[test]
291 fn resolve_conf_reads_local_or_none() {
292 assert_eq!(
294 resolve_conf(
295 &FakeGit::new().ok("config --local --get gkit.conf", "/abs/repos.toml"),
296 d()
297 ),
298 Some("/abs/repos.toml".into())
299 );
300 assert_eq!(
302 resolve_conf(&FakeGit::new().fail("config --local --get gkit.conf"), d()),
303 None
304 );
305 assert_eq!(
307 resolve_conf(
308 &FakeGit::new().ok("config --local --get gkit.conf", ""),
309 d()
310 ),
311 None
312 );
313 }
314
315 #[test]
316 fn current_branch_opt_detects_detached() {
317 let on = FakeGit::new().ok("symbolic-ref --short HEAD", "feat");
318 assert_eq!(current_branch_opt(&on, d()), Some("feat".into()));
319 let detached = FakeGit::new().fail("symbolic-ref --short HEAD");
320 assert_eq!(current_branch_opt(&detached, d()), None);
321 }
322
323 #[test]
324 fn switch_base_uses_origin_head_not_current() {
325 let g = FakeGit::new().fail("config --get gkit.baseBranch").ok(
327 "symbolic-ref --short refs/remotes/origin/HEAD",
328 "origin/dev",
329 );
330 assert_eq!(resolve_switch_base(&g, d(), None), Some("dev".into()));
331 assert_eq!(
333 resolve_switch_base(&g, d(), Some("main")),
334 Some("main".into())
335 );
336 }
337}