1use 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 MERGE_STRATEGY_KEY: &str = "stk.mergeStrategy";
18pub const MERGE_WAIT_KEY: &str = "stk.mergeWait";
19pub const SUBMIT_DRAFT_KEY: &str = "stk.submitDraft";
20pub const NO_UPDATE_CHECK_KEY: &str = "stk.noUpdateCheck";
21pub const ABSORB_INCLUDE_UNSTAGED_KEY: &str = "stk.absorbIncludeUnstaged";
22pub const GITLAB_HOST_KEY: &str = "stk.gitlabHost";
23pub const GITEA_HOST_KEY: &str = "stk.giteaHost";
24pub const CHECK_TIMEOUT_KEY: &str = "stk.checkTimeout";
25pub const USE_PR_TEMPLATE_KEY: &str = "stk.usePrTemplate";
26pub const WORKTREE_DIR_KEY: &str = "stk.worktreeDir";
27pub const DEFAULT_REMOTE: &str = "origin";
28
29pub const DEFAULT_CHECK_TIMEOUT_SECS: u64 = 1800;
33
34pub const SETTINGS: &[(&str, &str)] = &[
37 (PROVIDER_KEY, "auto-detect from the remote URL"),
38 (REMOTE_KEY, DEFAULT_REMOTE),
39 (UPDATE_REFS_KEY, "false"),
40 (FETCH_BEFORE_RESTACK_KEY, "false"),
41 (PUSH_ON_RESTACK_KEY, "false"),
42 (PUSH_ON_SUBMIT_KEY, "false"),
43 (SUBMIT_STACK_KEY, "false"),
44 (MERGE_STRATEGY_KEY, "squash"),
45 (MERGE_WAIT_KEY, "false"),
46 (SUBMIT_DRAFT_KEY, "false"),
47 (NO_UPDATE_CHECK_KEY, "false"),
48 (ABSORB_INCLUDE_UNSTAGED_KEY, "false"),
49 (GITLAB_HOST_KEY, "none; gitlab.com is always detected"),
50 (
51 GITEA_HOST_KEY,
52 "none; gitea.com and codeberg.org are always detected",
53 ),
54 (CHECK_TIMEOUT_KEY, "1800 (30m); 0 waits indefinitely"),
55 (USE_PR_TEMPLATE_KEY, "true"),
56 (
57 WORKTREE_DIR_KEY,
58 "a <repo>-worktrees directory beside the repo",
59 ),
60];
61
62pub fn remote() -> Result<String> {
64 Ok(git::config_get(REMOTE_KEY)?.unwrap_or_else(|| DEFAULT_REMOTE.to_owned()))
65}
66
67pub fn gitlab_host() -> Result<Option<String>> {
71 git::config_get(GITLAB_HOST_KEY)
72}
73
74pub fn gitea_host() -> Result<Option<String>> {
78 git::config_get(GITEA_HOST_KEY)
79}
80
81pub fn merge_strategy() -> Result<String> {
83 let strategy = git::config_get(MERGE_STRATEGY_KEY)?.unwrap_or_else(|| "squash".to_owned());
84 match strategy.as_str() {
85 "squash" | "rebase" | "merge" => Ok(strategy),
86 other => anyhow::bail!(
87 "unsupported stk.mergeStrategy value {other:?}; expected squash, rebase, or merge"
88 ),
89 }
90}
91
92pub fn check_timeout() -> Result<Option<Duration>> {
96 parse_check_timeout(git::config_get(CHECK_TIMEOUT_KEY)?.as_deref())
97}
98
99fn parse_check_timeout(value: Option<&str>) -> Result<Option<Duration>> {
100 let seconds = match value {
101 Some(raw) => raw.trim().parse::<u64>().map_err(|_| {
102 anyhow::anyhow!(
103 "invalid {CHECK_TIMEOUT_KEY} value {raw:?}; expected a whole number of seconds"
104 )
105 })?,
106 None => DEFAULT_CHECK_TIMEOUT_SECS,
107 };
108 Ok((seconds > 0).then(|| Duration::from_secs(seconds)))
110}
111
112pub fn bool_setting(key: &str) -> Result<bool> {
114 Ok(git::config_get_bool(key)?.unwrap_or(false))
115}
116
117pub fn use_pr_template() -> Result<bool> {
122 Ok(git::config_get_bool(USE_PR_TEMPLATE_KEY)?.unwrap_or(true))
123}
124
125pub fn push_enabled(mode: PushMode, key: &str) -> Result<bool> {
127 match mode {
128 PushMode::Config => Ok(git::config_get_bool(key)?.unwrap_or(false)),
129 PushMode::Enabled => Ok(true),
130 PushMode::Disabled => Ok(false),
131 }
132}
133
134pub fn fetch_enabled(mode: FetchMode) -> Result<bool> {
136 match mode {
137 FetchMode::Config => Ok(git::config_get_bool(FETCH_BEFORE_RESTACK_KEY)?.unwrap_or(false)),
138 FetchMode::Enabled => Ok(true),
139 FetchMode::Disabled => Ok(false),
140 }
141}
142
143pub fn worktree_dir() -> Result<std::path::PathBuf> {
147 if let Some(configured) = git::config_get(WORKTREE_DIR_KEY)?
148 && !configured.trim().is_empty()
149 {
150 return std::path::absolute(configured.trim()).context("failed to resolve stk.worktreeDir");
151 }
152
153 let root = git::repo_root()?;
154 let name = root
155 .file_name()
156 .map(|name| name.to_string_lossy().into_owned())
157 .unwrap_or_else(|| "repo".to_owned());
158 let parent = root
159 .parent()
160 .context("repository root has no parent directory")?;
161 Ok(parent.join(format!("{name}-worktrees")))
162}
163
164pub fn worktree_path_for(branch: &str) -> Result<std::path::PathBuf> {
169 let mut path = worktree_dir()?;
170 for component in branch.split('/').filter(|part| !part.is_empty()) {
171 if component == "." || component == ".." {
172 bail!("branch name {branch} cannot be used as a worktree path");
173 }
174 path.push(component);
175 }
176 Ok(path)
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn check_timeout_defaults_when_unset() {
185 assert_eq!(
186 parse_check_timeout(None).unwrap(),
187 Some(Duration::from_secs(DEFAULT_CHECK_TIMEOUT_SECS))
188 );
189 }
190
191 #[test]
192 fn check_timeout_zero_waits_indefinitely() {
193 assert_eq!(parse_check_timeout(Some("0")).unwrap(), None);
194 }
195
196 #[test]
197 fn check_timeout_reads_whole_seconds() {
198 assert_eq!(
199 parse_check_timeout(Some("300")).unwrap(),
200 Some(Duration::from_secs(300))
201 );
202 assert_eq!(
204 parse_check_timeout(Some(" 60 ")).unwrap(),
205 Some(Duration::from_secs(60))
206 );
207 }
208
209 #[test]
210 fn check_timeout_rejects_non_numbers() {
211 let error = parse_check_timeout(Some("soon")).unwrap_err();
212 assert!(
213 error.to_string().contains("stk.checkTimeout"),
214 "unexpected error: {error:#}"
215 );
216 }
217}