Skip to main content

clickup_cli/
git.rs

1//! Git branch-based task ID detection.
2//!
3//! Resolves task IDs from three sources in priority order: explicit CLI arg, the
4//! `CLICKUP_TASK_ID` env var, then the current git branch name. Destructive
5//! commands (`task delete`, `task link`, etc.) opt out of branch resolution.
6
7use crate::config::Config;
8use crate::error::CliError;
9use crate::Cli;
10use regex::Regex;
11use std::process::Command;
12use std::sync::OnceLock;
13
14/// A task ID resolved from some source.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct ResolvedTask {
17    /// The ID to send on the wire. For CU-prefixed matches, this is the
18    /// stripped form (`CU-abc123` → `abc123`). For custom IDs, the whole
19    /// `PROJ-42` is preserved.
20    pub id: String,
21    /// The raw match as it appeared in the source (branch name or CLI arg),
22    /// used for user-facing messages.
23    pub raw: String,
24    /// True when the ID matches the custom-ID shape (`PREFIX-NUMBER`) and
25    /// requires `custom_task_ids=true&team_id=<ws>` on the request.
26    pub is_custom: bool,
27    /// Where the ID came from.
28    pub source: TaskSource,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum TaskSource {
33    Explicit,
34    Env,
35    /// Resolved from a git branch; carries the branch name for breadcrumb output.
36    Branch(String),
37}
38
39/// Conventional-commits prefixes stripped from branch names before regex match.
40/// Case-insensitive, trailing `/` required. Go's 10 + our 4.
41const STRIPPED_PREFIXES: &[&str] = &[
42    "feature/",
43    "feat/",
44    "fix/",
45    "hotfix/",
46    "bugfix/",
47    "release/",
48    "chore/",
49    "docs/",
50    "refactor/",
51    "test/",
52    "ci/",
53    "perf/",
54    "build/",
55    "style/",
56];
57
58/// Prefixes that look like custom task IDs but are actually workflow keywords.
59/// Any `PREFIX-NUMBER` match whose prefix (uppercased) hits this list is
60/// rejected. Go's 9 + our 9.
61const EXCLUDED_CUSTOM_PREFIXES: &[&str] = &[
62    "FEATURE", "FEAT", "BUGFIX", "BUG", "FIX", "HOTFIX", "RELEASE", "CHORE", "DOCS", "DOC",
63    "REFACTOR", "TEST", "CI", "PERF", "BUILD", "STYLE", "WIP", "TMP",
64];
65
66fn cu_regex() -> &'static Regex {
67    static RE: OnceLock<Regex> = OnceLock::new();
68    RE.get_or_init(|| Regex::new(r"(?i)\bCU-([0-9a-z]+)").unwrap())
69}
70
71fn custom_regex() -> &'static Regex {
72    static RE: OnceLock<Regex> = OnceLock::new();
73    RE.get_or_init(|| Regex::new(r"\b([A-Z][A-Z0-9]+-\d+)\b").unwrap())
74}
75
76/// Return the current git branch name, or `None` if not inside a repo, on
77/// detached HEAD, or if `git` is unavailable. Never errors — git absence is
78/// treated the same as "not in a repo".
79pub fn current_branch() -> Option<String> {
80    let out = Command::new("git")
81        .args(["rev-parse", "--abbrev-ref", "HEAD"])
82        .output()
83        .ok()?;
84    if !out.status.success() {
85        return None;
86    }
87    let name = String::from_utf8(out.stdout).ok()?.trim().to_string();
88    if name.is_empty() || name == "HEAD" {
89        return None;
90    }
91    Some(name)
92}
93
94/// Strip one known conventional-commits prefix (case-insensitive) from the
95/// start of a branch name. Returns the remainder if a prefix matched, else the
96/// original input.
97fn strip_prefix(branch: &str) -> &str {
98    let lower = branch.to_ascii_lowercase();
99    for p in STRIPPED_PREFIXES {
100        if lower.starts_with(p) {
101            return &branch[p.len()..];
102        }
103    }
104    branch
105}
106
107/// Extract a task ID from a branch name, or `None` if no pattern matches.
108/// CU- matches take precedence over custom IDs; first match wins within each.
109pub fn extract_task_id(branch: &str) -> Option<ResolvedTask> {
110    let stripped = strip_prefix(branch);
111
112    if let Some(m) = cu_regex().captures(stripped) {
113        let raw = m.get(0).unwrap().as_str().to_string();
114        let id = m.get(1).unwrap().as_str().to_string();
115        return Some(ResolvedTask {
116            id,
117            raw,
118            is_custom: false,
119            source: TaskSource::Branch(branch.to_string()),
120        });
121    }
122
123    for m in custom_regex().captures_iter(stripped) {
124        let matched = m.get(1).unwrap().as_str();
125        let prefix = matched.split('-').next().unwrap_or("");
126        if EXCLUDED_CUSTOM_PREFIXES.contains(&prefix) {
127            continue;
128        }
129        return Some(ResolvedTask {
130            id: matched.to_string(),
131            raw: matched.to_string(),
132            is_custom: true,
133            source: TaskSource::Branch(branch.to_string()),
134        });
135    }
136
137    None
138}
139
140/// Normalize an explicit CLI arg. Strips `CU-` transparently; detects
141/// `PREFIX-NUMBER` as custom. Never fails — any string becomes a `ResolvedTask`
142/// with source `Explicit`.
143pub fn parse_task_id(arg: &str) -> ResolvedTask {
144    let arg = arg.trim();
145
146    if let Some(m) = cu_regex().captures(arg) {
147        // Only strip if the whole arg is a CU- ID (not a branch-like string).
148        if m.get(0).unwrap().as_str().len() == arg.len() {
149            let id = m.get(1).unwrap().as_str().to_string();
150            return ResolvedTask {
151                id,
152                raw: arg.to_string(),
153                is_custom: false,
154                source: TaskSource::Explicit,
155            };
156        }
157    }
158
159    if let Some(m) = custom_regex().captures(arg) {
160        let matched = m.get(1).unwrap().as_str();
161        let prefix = matched.split('-').next().unwrap_or("");
162        if matched.len() == arg.len() && !EXCLUDED_CUSTOM_PREFIXES.contains(&prefix) {
163            return ResolvedTask {
164                id: arg.to_string(),
165                raw: arg.to_string(),
166                is_custom: true,
167                source: TaskSource::Explicit,
168            };
169        }
170    }
171
172    // Plain ID — pass through.
173    ResolvedTask {
174        id: arg.to_string(),
175        raw: arg.to_string(),
176        is_custom: false,
177        source: TaskSource::Explicit,
178    }
179}
180
181/// Is branch detection enabled? Checks `CLICKUP_GIT_DETECT=0` env override,
182/// then `[git] enabled` in config. Defaults to true.
183fn detect_enabled() -> bool {
184    if let Ok(v) = std::env::var("CLICKUP_GIT_DETECT") {
185        if v == "0" || v.eq_ignore_ascii_case("false") {
186            return false;
187        }
188    }
189    let cfg = Config::load().unwrap_or_default();
190    cfg.git.enabled.unwrap_or(true)
191}
192
193fn verbose_enabled() -> bool {
194    let cfg = Config::load().unwrap_or_default();
195    cfg.git.verbose.unwrap_or(true)
196}
197
198/// Emit the "resolved task X from branch Y" breadcrumb to stderr, unless
199/// suppressed by `-q`, a non-table output mode, or `[git] verbose = false`.
200fn maybe_print_breadcrumb(cli: &Cli, task: &ResolvedTask) {
201    if cli.quiet || cli.output != "table" {
202        return;
203    }
204    if !verbose_enabled() {
205        return;
206    }
207    if let TaskSource::Branch(branch) = &task.source {
208        eprintln!("resolved task {} from branch {}", task.raw, branch);
209    }
210}
211
212/// Resolve a task ID from the priority chain: explicit → env → branch. Returns
213/// `None` if nothing is found — callers decide whether that's an error.
214///
215/// `allow_branch` should be `false` for destructive or ambiguous commands
216/// (`task delete`, `task link`, `task unlink`, `guest share-task`,
217/// `guest unshare-task`).
218pub fn resolve_task(
219    cli: &Cli,
220    explicit: Option<&str>,
221    allow_branch: bool,
222) -> Result<Option<ResolvedTask>, CliError> {
223    if let Some(arg) = explicit {
224        let t = parse_task_id(arg);
225        return Ok(Some(t));
226    }
227
228    if let Ok(v) = std::env::var("CLICKUP_TASK_ID") {
229        if !v.is_empty() {
230            let mut t = parse_task_id(&v);
231            t.source = TaskSource::Env;
232            return Ok(Some(t));
233        }
234    }
235
236    if !allow_branch || !detect_enabled() {
237        return Ok(None);
238    }
239
240    let branch = match current_branch() {
241        Some(b) => b,
242        None => return Ok(None),
243    };
244    let resolved = extract_task_id(&branch);
245    if let Some(t) = &resolved {
246        maybe_print_breadcrumb(cli, t);
247    }
248    Ok(resolved)
249}
250
251/// Like `resolve_task`, but errors with a helpful hint when nothing resolves.
252pub fn require_task(
253    cli: &Cli,
254    explicit: Option<&str>,
255    allow_branch: bool,
256) -> Result<ResolvedTask, CliError> {
257    match resolve_task(cli, explicit, allow_branch)? {
258        Some(t) => Ok(t),
259        None => Err(no_task_id_error(allow_branch)),
260    }
261}
262
263fn no_task_id_error(allow_branch: bool) -> CliError {
264    if !allow_branch {
265        return CliError::BranchDetect {
266            message: "No task ID provided. This command does not auto-detect from branch.".into(),
267            hint: "Pass the task ID explicitly.".into(),
268        };
269    }
270    match current_branch() {
271        Some(b) => CliError::BranchDetect {
272            message: format!(
273                "No task ID on the command line and none detected in branch \"{}\".",
274                b
275            ),
276            hint: "Name your branch like feat/CU-abc123-... or PROJ-42-..., or pass the ID \
277                   explicitly."
278                .into(),
279        },
280        None => CliError::BranchDetect {
281            message: "No task ID provided and not inside a git repository.".into(),
282            hint: "Pass the task ID explicitly, or run from a repo whose branch contains a \
283                   task ID (e.g. feat/CU-abc123-...)."
284                .into(),
285        },
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    fn extract(b: &str) -> Option<(String, bool)> {
294        extract_task_id(b).map(|t| (t.id, t.is_custom))
295    }
296
297    #[test]
298    fn cu_plain_branch() {
299        assert_eq!(extract("CU-abc123-foo"), Some(("abc123".into(), false)));
300    }
301
302    #[test]
303    fn cu_with_feat_prefix() {
304        assert_eq!(
305            extract("feat/CU-abc123-foo"),
306            Some(("abc123".into(), false))
307        );
308    }
309
310    #[test]
311    fn cu_lowercase() {
312        assert_eq!(extract("cu-dead01-test"), Some(("dead01".into(), false)));
313    }
314
315    #[test]
316    fn cu_mixed_case_prefix() {
317        assert_eq!(extract("Feature/Cu-Abc123"), Some(("Abc123".into(), false)));
318    }
319
320    #[test]
321    fn cu_with_underscore_after_id() {
322        // Matches Go reference test: underscore is branch separator, not part of ID.
323        assert_eq!(
324            extract("CU-86d1u2bz4_React-Native-Pois-gone"),
325            Some(("86d1u2bz4".into(), false))
326        );
327    }
328
329    #[test]
330    fn cu_with_feature_prefix_and_underscore() {
331        assert_eq!(
332            extract("feature/CU-86d1u2bz4_something"),
333            Some(("86d1u2bz4".into(), false))
334        );
335    }
336
337    #[test]
338    fn custom_id_plain() {
339        assert_eq!(extract("PROJ-42-add-login"), Some(("PROJ-42".into(), true)));
340    }
341
342    #[test]
343    fn custom_id_with_fix_prefix() {
344        assert_eq!(
345            extract("fix/ENG-1234-auth"),
346            Some(("ENG-1234".into(), true))
347        );
348    }
349
350    #[test]
351    fn excluded_prefix_feature() {
352        assert_eq!(extract("FEATURE-123-something"), None);
353    }
354
355    #[test]
356    fn excluded_prefix_bugfix() {
357        assert_eq!(extract("BUGFIX-456-foo"), None);
358    }
359
360    #[test]
361    fn excluded_prefix_wip() {
362        assert_eq!(extract("WIP-1-in-progress"), None);
363    }
364
365    #[test]
366    fn no_match_main() {
367        assert_eq!(extract("main"), None);
368    }
369
370    #[test]
371    fn no_match_draft_work() {
372        assert_eq!(extract("draft-work"), None);
373    }
374
375    #[test]
376    fn no_match_head_literal() {
377        // current_branch() filters HEAD out, but extract_task_id still sees input.
378        assert_eq!(extract("HEAD"), None);
379    }
380
381    #[test]
382    fn cu_first_match_wins() {
383        assert_eq!(extract("CU-aaa-refs-CU-bbb"), Some(("aaa".into(), false)));
384    }
385
386    #[test]
387    fn cu_wins_over_custom() {
388        assert_eq!(
389            extract("feat/CU-abc123-refs-PROJ-42-foo"),
390            Some(("abc123".into(), false))
391        );
392    }
393
394    #[test]
395    fn does_not_match_mid_word() {
396        // Anchored with \b, so xyzCU-abc is rejected.
397        assert_eq!(extract("xyzCU-abc"), None);
398    }
399
400    #[test]
401    fn empty_branch() {
402        assert_eq!(extract(""), None);
403    }
404
405    #[test]
406    fn parse_explicit_cu_stripped() {
407        let t = parse_task_id("CU-abc123");
408        assert_eq!(t.id, "abc123");
409        assert!(!t.is_custom);
410        assert_eq!(t.source, TaskSource::Explicit);
411    }
412
413    #[test]
414    fn parse_explicit_custom_flagged() {
415        let t = parse_task_id("PROJ-42");
416        assert_eq!(t.id, "PROJ-42");
417        assert!(t.is_custom);
418    }
419
420    #[test]
421    fn parse_explicit_plain() {
422        let t = parse_task_id("abc123");
423        assert_eq!(t.id, "abc123");
424        assert!(!t.is_custom);
425    }
426
427    #[test]
428    fn parse_explicit_excluded_prefix_not_custom() {
429        // FEATURE-123 as an explicit arg is not treated as custom — passes through.
430        let t = parse_task_id("FEATURE-123");
431        assert_eq!(t.id, "FEATURE-123");
432        assert!(!t.is_custom);
433    }
434
435    #[test]
436    fn parse_explicit_trims_whitespace() {
437        let t = parse_task_id("  CU-abc123 ");
438        assert_eq!(t.id, "abc123");
439    }
440}