Skip to main content

gkit_core/
config.rs

1//! Resolve a repo's base/integration branch — the single branch gkit treats as
2//! "the trunk" for the correct-branch check. Replaces the zsh's hardcoded
3//! `dev|main|master`. Resolution order:
4//!   1. explicit CLI `--base-branch`
5//!   2. per-repo `git config gkit.baseBranch`
6//!   3. a remote-tracking branch — `origin/main`, else `origin/master`
7//!   4. otherwise **unresolved**: a base couldn't be determined (e.g. a
8//!      single-branch clone of a feature branch). The correct-branch check then
9//!      fails rather than silently passing against the wrong base.
10
11use crate::git::Git;
12use std::collections::HashSet;
13use std::path::Path;
14
15/// Where a resolved base branch came from — surfaced by `logoff -v`.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum BaseSource {
18    /// Explicit CLI `--base-branch`.
19    Flag,
20    /// `git config gkit.baseBranch`.
21    Config,
22    /// Derived from a remote-tracking branch (`origin/main`, else `origin/master`).
23    Remote,
24    /// Could not be determined from any source.
25    Unresolved,
26}
27
28/// A base branch plus where it was resolved from. `name` is `None` only when
29/// `source` is [`BaseSource::Unresolved`].
30#[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    /// The unresolved sentinel: no base, fails the correct-branch check.
56    pub fn unresolved() -> Self {
57        Self {
58            name: None,
59            source: BaseSource::Unresolved,
60        }
61    }
62
63    /// Human-readable "branch (how it was derived)" for `logoff -v`.
64    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
76/// Resolve the base branch for the `logoff` correct-branch check (see module docs).
77pub 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    // Derive from remote-tracking branches: main first, then master. A single-branch
89    // clone of a feature branch has neither -> unresolved (correct-branch fails).
90    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
111/// Read `gkit.solo` (a bool) for a repo. Default `false` (team workflow) when
112/// unset or unparsable. When `true`, the correct-branch check additionally flags
113/// sitting on an integration branch while feature branches exist on the remote —
114/// meaningful for a solo developer where every remote branch is their own.
115/// Honors git's config layering: `--global` for a personal default, repo config
116/// to override. Set manually (e.g. `git config gkit.solo true`) — `gkit clone`
117/// does not stamp it.
118pub 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
123/// Read `gkit.allowDiverged` (a bool) for a repo. Default `false` when unset or
124/// unparsable. When `true`, the `not-behind-base` check (R6) no longer **fails**
125/// the gate for a feature branch behind base — the divergence is tolerated but
126/// still surfaced as a default-level marker. Like `gkit.solo`, it honors git's
127/// config layering (`--global` for a default, repo config to override) and is set
128/// manually (`gkit clone` does not stamp it). Does **not** suppress R6's
129/// fail-closed cases (unresolved/absent base, detached) — those are config errors.
130pub 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
135/// The repo's local `gkit.conf` — the **absolute conf path** `gkit clone` (and a
136/// conf-mode `gkit stamp`) stamps so `gkit stamp` (run inside the repo, no arg) can
137/// find the conf that drives it. `None` when unset or empty. Read with `--local`
138/// deliberately: `gkit.conf` is a per-repo fact, never inherited from `--global`.
139pub 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
145/// Current branch, or `None` if HEAD is detached (no symbolic ref).
146pub 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
155/// Resolve the base branch to *switch to* (for stmb). Unlike [`resolve_base`]
156/// this never falls back to HEAD (HEAD is the feature branch here): CLI override →
157/// `gkit.baseBranch` → `origin/HEAD` default branch. `None` if undeterminable.
158pub 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    /// `for-each-ref refs/remotes/origin/*` stub listing the given remote branches.
193    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        // main wins over master.
229        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        // e.g. a single-branch clone of a feature branch.
247        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        // unset / failing config -> false
259        assert!(!resolve_solo(
260            &FakeGit::new().fail("config --get --bool gkit.solo"),
261            d()
262        ));
263        // explicit true / false
264        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        // set -> Some(absolute path)
293        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        // unset (failing) -> None
301        assert_eq!(
302            resolve_conf(&FakeGit::new().fail("config --local --get gkit.conf"), d()),
303            None
304        );
305        // success but empty -> None
306        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        // config unset -> use origin/HEAD default, NOT the (feature) HEAD
326        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        // override still wins
332        assert_eq!(
333            resolve_switch_base(&g, d(), Some("main")),
334            Some("main".into())
335        );
336    }
337}