Skip to main content

git_stk/
settings.rs

1//! Every stk-owned git config key and its resolution logic, in one place.
2
3use std::time::Duration;
4
5use anyhow::{Context, Result, bail};
6
7use crate::cli::{FetchMode, PushMode};
8use crate::git;
9
10pub const PROVIDER_KEY: &str = "stk.provider";
11pub const REMOTE_KEY: &str = "stk.remote";
12pub const UPDATE_REFS_KEY: &str = "stk.updateRefs";
13pub const FETCH_BEFORE_RESTACK_KEY: &str = "stk.fetchBeforeRestack";
14pub const PUSH_ON_RESTACK_KEY: &str = "stk.pushOnRestack";
15pub const PUSH_ON_SUBMIT_KEY: &str = "stk.pushOnSubmit";
16pub const SUBMIT_STACK_KEY: &str = "stk.submitStack";
17pub const CLEAN_CLOSED_KEY: &str = "stk.cleanClosed";
18pub const MERGE_STRATEGY_KEY: &str = "stk.mergeStrategy";
19pub const MERGE_WAIT_KEY: &str = "stk.mergeWait";
20pub const SUBMIT_DRAFT_KEY: &str = "stk.submitDraft";
21pub const NO_UPDATE_CHECK_KEY: &str = "stk.noUpdateCheck";
22pub const ABSORB_INCLUDE_UNSTAGED_KEY: &str = "stk.absorbIncludeUnstaged";
23/// Register a submitted stack with GitHub's native stacked pull requests. Off
24/// by default: the feature is in public preview, so creating a stack on
25/// someone's behalf is opt-in.
26///
27/// Only creating one is gated. Reading a stack, following it, and dissolving
28/// it are all unconditional - `merge`, `submit`, `cleanup`, `status`,
29/// `repair`, and `unstack` obey a stack GitHub already holds, because a stack
30/// made outside git-stk changes what the platform accepts either way: GitHub
31/// refuses `gh pr merge` and `gh pr edit --base` for a review in one. Undoing
32/// a registration must not need the setting that made it, either.
33pub const GITHUB_STACKS_KEY: &str = "stk.githubStacks";
34pub const GITLAB_HOST_KEY: &str = "stk.gitlabHost";
35pub const GITEA_HOST_KEY: &str = "stk.giteaHost";
36pub const CHECK_TIMEOUT_KEY: &str = "stk.checkTimeout";
37pub const USE_PR_TEMPLATE_KEY: &str = "stk.usePrTemplate";
38pub const WORKTREE_DIR_KEY: &str = "stk.worktreeDir";
39pub const DEFAULT_REMOTE: &str = "origin";
40
41/// How long `merge --wait` polls a review's checks before giving up, unless
42/// `stk.checkTimeout` overrides it. Generous so a slow-but-real CI is not cut
43/// off; the point is to bound a pipeline that never settles, not a long one.
44pub const DEFAULT_CHECK_TIMEOUT_SECS: u64 = 1800;
45
46/// Every `[stk]` setting the tool reads, with its default behavior. Shown by
47/// `git stk config`.
48pub const SETTINGS: &[(&str, &str)] = &[
49    (PROVIDER_KEY, "auto-detect from the remote URL"),
50    (REMOTE_KEY, DEFAULT_REMOTE),
51    (UPDATE_REFS_KEY, "false"),
52    (FETCH_BEFORE_RESTACK_KEY, "false"),
53    (PUSH_ON_RESTACK_KEY, "false"),
54    (PUSH_ON_SUBMIT_KEY, "false"),
55    (SUBMIT_STACK_KEY, "false"),
56    (CLEAN_CLOSED_KEY, "false"),
57    (MERGE_STRATEGY_KEY, "squash"),
58    (MERGE_WAIT_KEY, "false"),
59    (SUBMIT_DRAFT_KEY, "false"),
60    (NO_UPDATE_CHECK_KEY, "false"),
61    (ABSORB_INCLUDE_UNSTAGED_KEY, "false"),
62    (GITHUB_STACKS_KEY, "false"),
63    (GITLAB_HOST_KEY, "none; gitlab.com is always detected"),
64    (
65        GITEA_HOST_KEY,
66        "none; gitea.com and codeberg.org are always detected",
67    ),
68    (CHECK_TIMEOUT_KEY, "1800 (30m); 0 waits indefinitely"),
69    (USE_PR_TEMPLATE_KEY, "true"),
70    (
71        WORKTREE_DIR_KEY,
72        "a <repo>-worktrees directory beside the repo",
73    ),
74];
75
76/// The remote used for provider detection, trunk discovery, and pushes.
77pub fn remote() -> Result<String> {
78    Ok(git::config_get(REMOTE_KEY)?.unwrap_or_else(|| DEFAULT_REMOTE.to_owned()))
79}
80
81/// A self-hosted GitLab host (e.g. `gitlab.example.com`) to recognize as
82/// GitLab alongside gitlab.com (`stk.gitlabHost`). `glab` reads the host from
83/// the git remote on its own, so this only widens stk's provider detection.
84pub fn gitlab_host() -> Result<Option<String>> {
85    git::config_get(GITLAB_HOST_KEY)
86}
87
88/// A self-hosted Gitea/Forgejo host (e.g. `gitea.example.com`) to recognize as
89/// Gitea alongside gitea.com and codeberg.org (`stk.giteaHost`). `tea` reads
90/// the host from the git remote itself, so this only widens stk's detection.
91pub fn gitea_host() -> Result<Option<String>> {
92    git::config_get(GITEA_HOST_KEY)
93}
94
95/// The merge strategy for `git stk merge`: squash, rebase, or merge.
96pub fn merge_strategy() -> Result<String> {
97    let strategy = git::config_get(MERGE_STRATEGY_KEY)?.unwrap_or_else(|| "squash".to_owned());
98    match strategy.as_str() {
99        "squash" | "rebase" | "merge" => Ok(strategy),
100        other => anyhow::bail!(
101            "unsupported stk.mergeStrategy value {other:?}; expected squash, rebase, or merge"
102        ),
103    }
104}
105
106/// How long `merge --wait` keeps polling a review's checks before giving up,
107/// from `stk.checkTimeout` (whole seconds). `0` waits indefinitely; unset uses
108/// [`DEFAULT_CHECK_TIMEOUT_SECS`].
109pub fn check_timeout() -> Result<Option<Duration>> {
110    parse_check_timeout(git::config_get(CHECK_TIMEOUT_KEY)?.as_deref())
111}
112
113fn parse_check_timeout(value: Option<&str>) -> Result<Option<Duration>> {
114    let seconds = match value {
115        Some(raw) => raw.trim().parse::<u64>().map_err(|_| {
116            anyhow::anyhow!(
117                "invalid {CHECK_TIMEOUT_KEY} value {raw:?}; expected a whole number of seconds"
118            )
119        })?,
120        None => DEFAULT_CHECK_TIMEOUT_SECS,
121    };
122    // Zero is the explicit "wait forever" escape hatch.
123    Ok((seconds > 0).then(|| Duration::from_secs(seconds)))
124}
125
126/// A boolean setting's value, defaulting to false when unset.
127pub fn bool_setting(key: &str) -> Result<bool> {
128    Ok(git::config_get_bool(key)?.unwrap_or(false))
129}
130
131/// Whether to seed a new review's body from the repo's PR/MR template
132/// (`stk.usePrTemplate`). Defaults to true - unlike most bool settings - so
133/// the template is honored out of the box; set false to opt into a lean,
134/// git-stk-only body.
135pub fn use_pr_template() -> Result<bool> {
136    Ok(git::config_get_bool(USE_PR_TEMPLATE_KEY)?.unwrap_or(true))
137}
138
139/// Resolve a `--push`/`--no-push` flag pair against its config-key default.
140pub fn push_enabled(mode: PushMode, key: &str) -> Result<bool> {
141    match mode {
142        PushMode::Config => Ok(git::config_get_bool(key)?.unwrap_or(false)),
143        PushMode::Enabled => Ok(true),
144        PushMode::Disabled => Ok(false),
145    }
146}
147
148/// Resolve a `--fetch`/`--no-fetch` flag pair against `stk.fetchBeforeRestack`.
149pub fn fetch_enabled(mode: FetchMode) -> Result<bool> {
150    match mode {
151        FetchMode::Config => Ok(git::config_get_bool(FETCH_BEFORE_RESTACK_KEY)?.unwrap_or(false)),
152        FetchMode::Enabled => Ok(true),
153        FetchMode::Disabled => Ok(false),
154    }
155}
156
157/// Where `new --worktree` puts a branch's worktree. From `stk.worktreeDir`, or a
158/// sibling of the repo root named `<repo>-worktrees` - outside the repo, so the
159/// worktrees do not land in its own file watchers, ignore rules, or `git status`.
160///
161/// Anchored on the *main* worktree, not the current one, so every worktree of a
162/// repo agrees on one directory: derived from wherever the command ran, `new
163/// --worktree` inside a worktree would nest the next one under it, and `repair`
164/// would look for owned worktrees somewhere they were never put.
165pub fn worktree_dir() -> Result<std::path::PathBuf> {
166    if let Some(configured) = git::config_get(WORKTREE_DIR_KEY)?
167        && !configured.trim().is_empty()
168    {
169        return std::path::absolute(configured.trim()).context("failed to resolve stk.worktreeDir");
170    }
171
172    let root = git::main_worktree_root()?;
173    let name = root
174        .file_name()
175        .map(|name| name.to_string_lossy().into_owned())
176        .unwrap_or_else(|| "repo".to_owned());
177    let parent = root
178        .parent()
179        .context("repository root has no parent directory")?;
180    Ok(parent.join(format!("{name}-worktrees")))
181}
182
183/// The worktree directory for `branch`, under [`worktree_dir`]. Branch names nest
184/// as real directories, so `feat/a` lands at `<dir>/feat/a` and its basename
185/// still matches the branch tail - the way git's own path-to-branch guessing
186/// reads a worktree path.
187pub fn worktree_path_for(branch: &str) -> Result<std::path::PathBuf> {
188    let mut path = worktree_dir()?;
189    for component in branch.split('/').filter(|part| !part.is_empty()) {
190        if component == "." || component == ".." {
191            bail!("branch name {branch} cannot be used as a worktree path");
192        }
193        path.push(component);
194    }
195    Ok(path)
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn check_timeout_defaults_when_unset() {
204        assert_eq!(
205            parse_check_timeout(None).unwrap(),
206            Some(Duration::from_secs(DEFAULT_CHECK_TIMEOUT_SECS))
207        );
208    }
209
210    #[test]
211    fn check_timeout_zero_waits_indefinitely() {
212        assert_eq!(parse_check_timeout(Some("0")).unwrap(), None);
213    }
214
215    #[test]
216    fn check_timeout_reads_whole_seconds() {
217        assert_eq!(
218            parse_check_timeout(Some("300")).unwrap(),
219            Some(Duration::from_secs(300))
220        );
221        // Surrounding whitespace is tolerated (git config values can carry it).
222        assert_eq!(
223            parse_check_timeout(Some(" 60 ")).unwrap(),
224            Some(Duration::from_secs(60))
225        );
226    }
227
228    #[test]
229    fn check_timeout_rejects_non_numbers() {
230        let error = parse_check_timeout(Some("soon")).unwrap_err();
231        assert!(
232            error.to_string().contains("stk.checkTimeout"),
233            "unexpected error: {error:#}"
234        );
235    }
236}