Skip to main content

anodizer_core/git/
github_api.rs

1use anyhow::{Context as _, Result, bail};
2use std::collections::HashMap;
3use std::path::Path;
4use std::process::Command;
5use std::sync::{Mutex, OnceLock};
6
7use super::git_output_in;
8use super::remote::detect_github_repo_in;
9use super::tags::create_and_push_tag_in;
10
11/// GET a GitHub API endpoint via the `gh` CLI (single request, no pagination).
12///
13/// Returns the parsed JSON response. Useful for endpoints that return a single
14/// object (e.g. the Compare API) rather than a paginated array.
15pub fn gh_api_get(endpoint: &str, token: Option<&str>) -> Result<serde_json::Value> {
16    gh_api_get_with_binary(Path::new("gh"), endpoint, token)
17}
18
19/// GET a GitHub API endpoint via `gh_binary` (single request, no pagination).
20///
21/// Path-taking sibling of [`gh_api_get`] so tests can point at a missing or
22/// stub binary inside a `tempfile::tempdir()` without mutating `PATH`.
23/// When `gh_binary` has no separator (e.g. `Path::new("gh")`),
24/// [`Command::new`] falls back to a PATH lookup — the production
25/// behavior — so the wrapper is a true no-op in normal use.
26pub fn gh_api_get_with_binary(
27    gh_binary: &Path,
28    endpoint: &str,
29    token: Option<&str>,
30) -> Result<serde_json::Value> {
31    let mut cmd = Command::new(gh_binary);
32    cmd.args(["api", endpoint]);
33    if let Some(tok) = token {
34        cmd.env("GITHUB_TOKEN", tok);
35    }
36    let output = cmd
37        .stdout(std::process::Stdio::piped())
38        .stderr(std::process::Stdio::piped())
39        .output()
40        .with_context(|| format!("failed to spawn gh CLI ({})", gh_binary.display()))?;
41    if !output.status.success() {
42        let stderr_raw = String::from_utf8_lossy(&output.stderr);
43        let raw = format!("gh api GET {} failed: {}", endpoint, stderr_raw.trim());
44        bail!("{}", redact_gh_stderr(&raw, token));
45    }
46    let stdout = String::from_utf8_lossy(&output.stdout);
47    serde_json::from_str(&stdout).context("failed to parse gh api response")
48}
49
50/// Cache key for [`COMMIT_LOGIN_CACHE`]: `(owner, repo, author_email)`.
51type LoginCacheKey = (String, String, String);
52
53/// Process-wide memo of commit-author login lookups. Failed lookups are
54/// cached as `None` so an offline / unauthenticated run costs at most one
55/// API attempt per unique author email, even when several CLI entry points
56/// render changelogs for many crates in the same invocation.
57static COMMIT_LOGIN_CACHE: OnceLock<Mutex<HashMap<LoginCacheKey, Option<String>>>> =
58    OnceLock::new();
59
60/// Resolve a commit author's GitHub login from a representative commit SHA
61/// via `GET /repos/{owner}/{repo}/commits/{sha}` → `.author.login`.
62///
63/// Best-effort by design: any failure (no `gh`, no auth, offline, unknown
64/// SHA, commit email not linked to a GitHub account) returns `None` with at
65/// most a debug-level trace — callers fall back to name-based rendering and
66/// must never fail a release pipeline over a missing login.
67///
68/// Results (including failures) are memoized process-wide per
69/// `(owner, repo, email)`, so each unique author email costs one API call
70/// per run regardless of how many commits or crates reference it.
71pub fn commit_author_login(
72    owner: &str,
73    repo: &str,
74    email: &str,
75    sha: &str,
76    token: Option<&str>,
77) -> Option<String> {
78    commit_author_login_with_binary(Path::new("gh"), owner, repo, email, sha, token)
79}
80
81/// Path-taking sibling of [`commit_author_login`] so tests can point at a
82/// missing or stub binary without mutating `PATH`.
83pub fn commit_author_login_with_binary(
84    gh_binary: &Path,
85    owner: &str,
86    repo: &str,
87    email: &str,
88    sha: &str,
89    token: Option<&str>,
90) -> Option<String> {
91    if owner.is_empty() || repo.is_empty() || email.is_empty() || sha.is_empty() {
92        return None;
93    }
94    let key = (owner.to_string(), repo.to_string(), email.to_string());
95    let cache = COMMIT_LOGIN_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
96    // A poisoned lock only means another thread panicked mid-insert; the map
97    // itself is still a valid memo, so recover it rather than panic here.
98    {
99        let guard = match cache.lock() {
100            Ok(g) => g,
101            Err(poisoned) => poisoned.into_inner(),
102        };
103        if let Some(hit) = guard.get(&key) {
104            return hit.clone();
105        }
106    }
107    let endpoint = format!("/repos/{owner}/{repo}/commits/{sha}");
108    let resolved = match gh_api_get_with_binary(gh_binary, &endpoint, token) {
109        Ok(v) => v
110            .pointer("/author/login")
111            .and_then(|l| l.as_str())
112            .filter(|s| !s.is_empty())
113            .map(str::to_string),
114        Err(e) => {
115            tracing::debug!(
116                "commit_author_login: lookup for {} failed (keeping name-based rendering): {}",
117                email,
118                e
119            );
120            None
121        }
122    };
123    let mut guard = match cache.lock() {
124        Ok(g) => g,
125        Err(poisoned) => poisoned.into_inner(),
126    };
127    guard.insert(key, resolved.clone());
128    resolved
129}
130
131/// Resolve the GitHub token for API calls through the codebase-standard
132/// chain: explicit value (CLI flag / context option) → `ANODIZER_GITHUB_TOKEN`
133/// → `GITHUB_TOKEN`. Empty strings count as absent at every link — GitHub
134/// Actions materializes missing secrets as `""`, which must not
135/// short-circuit the fallback to the next link.
136///
137/// `env` is the injectable env source (pass `Context::env_var` or a
138/// map-backed closure in tests) so the chain is testable without mutating
139/// process env.
140pub fn resolve_github_token_with_env(
141    explicit: Option<&str>,
142    env: &dyn Fn(&str) -> Option<String>,
143) -> Option<String> {
144    let non_empty = |s: String| if s.is_empty() { None } else { Some(s) };
145    explicit
146        .filter(|t| !t.is_empty())
147        .map(str::to_string)
148        .or_else(|| env("ANODIZER_GITHUB_TOKEN").and_then(non_empty))
149        .or_else(|| env("GITHUB_TOKEN").and_then(non_empty))
150}
151
152/// Process-env wrapper of [`resolve_github_token_with_env`] for call sites
153/// without a `Context` (e.g. the `bump`/`tag` changelog-sync write path,
154/// whose commands expose no `--token` flag).
155pub fn resolve_github_token(explicit: Option<&str>) -> Option<String> {
156    resolve_github_token_with_env(explicit, &|key| std::env::var(key).ok())
157}
158
159/// Redact secrets from `gh` CLI stderr before interpolating into a bail
160/// message. `token` is the `GITHUB_TOKEN` value passed to the
161/// subprocess; if the user-supplied token leaks (e.g. via a verbose `gh`
162/// error that echoes the auth header), it is replaced with `$GITHUB_TOKEN`
163/// regardless of whether the value matches the `redact::is_secret`
164/// heuristics. Also strips inline URL credentials and any other secret
165/// env-var values reachable from the parent process env.
166fn redact_gh_stderr(stderr: &str, token: Option<&str>) -> String {
167    let stripped = crate::redact::redact_url_credentials(stderr);
168    let mut env: Vec<(String, String)> = std::env::vars().collect();
169    if let Some(tok) = token
170        && !tok.is_empty()
171    {
172        env.push(("GITHUB_TOKEN".to_string(), tok.to_string()));
173    }
174    crate::redact::string(&stripped, &env)
175}
176
177/// GET a GitHub API endpoint via the `gh` CLI, with pagination.
178///
179/// Returns a JSON array of all pages concatenated. The caller is responsible for
180/// ensuring that `gh` is installed and authenticated.
181pub fn gh_api_get_paginated(endpoint: &str, token: Option<&str>) -> Result<Vec<serde_json::Value>> {
182    gh_api_get_paginated_with_binary(Path::new("gh"), endpoint, token)
183}
184
185/// Paginated GET via `gh_binary`. Path-taking sibling of
186/// [`gh_api_get_paginated`].
187pub fn gh_api_get_paginated_with_binary(
188    gh_binary: &Path,
189    endpoint: &str,
190    token: Option<&str>,
191) -> Result<Vec<serde_json::Value>> {
192    let mut cmd = Command::new(gh_binary);
193    cmd.args(["api", "--paginate", endpoint]);
194    if let Some(tok) = token {
195        cmd.env("GITHUB_TOKEN", tok);
196    }
197    let output = cmd
198        .stdout(std::process::Stdio::piped())
199        .stderr(std::process::Stdio::piped())
200        .output()
201        .with_context(|| format!("failed to spawn gh CLI ({})", gh_binary.display()))?;
202
203    if !output.status.success() {
204        let stderr_raw = String::from_utf8_lossy(&output.stderr);
205        let raw = format!("gh api GET {} failed: {}", endpoint, stderr_raw.trim());
206        bail!("{}", redact_gh_stderr(&raw, token));
207    }
208
209    let stdout = String::from_utf8_lossy(&output.stdout);
210
211    // Try parsing the entire response first before falling back to splitting.
212    // This avoids the split_inclusive(']') approach corrupting non-array responses.
213    if let Ok(serde_json::Value::Array(arr)) = serde_json::from_str::<serde_json::Value>(&stdout) {
214        return Ok(arr);
215    }
216    if let Ok(val) = serde_json::from_str::<serde_json::Value>(&stdout) {
217        // Single object response (e.g. non-list endpoint) — wrap in a vec.
218        return Ok(vec![val]);
219    }
220
221    // Whole-parse failed — gh --paginate may return multiple JSON arrays
222    // concatenated (e.g. `[...][...]`). Split on `]` boundaries and parse each chunk.
223    let mut all_items = Vec::new();
224    for chunk in stdout.split_inclusive(']') {
225        let trimmed = chunk.trim();
226        if trimmed.is_empty() {
227            continue;
228        }
229        if let Ok(serde_json::Value::Array(arr)) =
230            serde_json::from_str::<serde_json::Value>(trimmed)
231        {
232            all_items.extend(arr);
233        } else if let Ok(val) = serde_json::from_str::<serde_json::Value>(trimmed) {
234            all_items.push(val);
235        } else {
236            // Log unparseable chunks so corrupt data doesn't go unnoticed.
237            // The chunk may carry secret-shaped request/response data, and the
238            // tracing subscriber performs NO redaction of its own — so redact
239            // here (process-env secret values + inline URL credentials) before
240            // emitting. Cap the logged chunk at 200 bytes — an HTTP body in an
241            // error context should convey "what server said" without dumping a
242            // multi-MB stack trace to the user's terminal.
243            let snippet = &trimmed[..trimmed.len().min(200)];
244            let redacted = crate::redact::redact_process_env(snippet);
245            tracing::warn!(
246                "gh_api_get_paginated: failed to parse JSON chunk ({} bytes): {:?}",
247                trimmed.len(),
248                redacted,
249            );
250        }
251    }
252    Ok(all_items)
253}
254
255/// POST via `gh_binary`. Internal helper consumed by
256/// [`create_tag_via_github_api_in`]; takes an explicit binary path so
257/// tests can drive the failure path against a missing or stub binary.
258fn gh_api_post_with_binary(
259    gh_binary: &Path,
260    endpoint: &str,
261    body: &serde_json::Value,
262) -> Result<serde_json::Value> {
263    use std::io::Write;
264
265    let body_str = serde_json::to_string(body)?;
266
267    let mut child = Command::new(gh_binary)
268        .args(["api", "--method", "POST", endpoint, "--input", "-"])
269        .stdin(std::process::Stdio::piped())
270        .stdout(std::process::Stdio::piped())
271        .stderr(std::process::Stdio::piped())
272        .spawn()
273        .with_context(|| format!("failed to spawn gh CLI ({})", gh_binary.display()))?;
274
275    if let Some(ref mut stdin) = child.stdin {
276        stdin.write_all(body_str.as_bytes())?;
277    }
278    child.stdin.take(); // close stdin
279
280    let output = child.wait_with_output()?;
281    if !output.status.success() {
282        let stderr_raw = String::from_utf8_lossy(&output.stderr);
283        // `gh_api_post` does not currently accept a token argument, but
284        // routing through `redact_process_env` still covers any token
285        // exported as `GITHUB_TOKEN` / `GH_TOKEN` in the parent env, plus
286        // inline URL credentials. Redact the full bail string so an
287        // endpoint containing a secret-shaped path segment is also covered.
288        let raw = format!("gh api POST {} failed: {}", endpoint, stderr_raw.trim());
289        bail!("{}", crate::redact::redact_process_env(&raw));
290    }
291
292    let response: serde_json::Value = serde_json::from_slice(&output.stdout)
293        .with_context(|| format!("failed to parse GitHub API response from {}", endpoint))?;
294    Ok(response)
295}
296
297/// Create a tag via the GitHub API (using the `gh` CLI).
298///
299/// This avoids the need for local git push access. Requires the `gh` CLI to be
300/// installed and authenticated (`gh auth login`). The GitHub API creates a
301/// lightweight tag object pointing at the HEAD commit on the default branch.
302///
303/// Falls back to [`create_and_push_tag_in`] if `gh` is not available.
304pub fn create_tag_via_github_api(
305    tag: &str,
306    message: &str,
307    dry_run: bool,
308    log: &crate::log::StageLogger,
309    strict: bool,
310) -> Result<()> {
311    create_tag_via_github_api_in(
312        &std::env::current_dir()?,
313        Path::new("gh"),
314        tag,
315        message,
316        dry_run,
317        log,
318        strict,
319    )
320}
321
322/// Path-taking sibling of [`create_tag_via_github_api`].
323///
324/// `cwd` is the repository the tag should be created against (used for
325/// `git remote get-url origin` and `git rev-parse HEAD` lookups, plus
326/// the local `git tag -a` fallback when `gh_binary` is missing).
327/// `gh_binary` is the path to the `gh` CLI; pass `Path::new("gh")` to
328/// keep the production PATH-lookup behavior.
329#[allow(clippy::too_many_arguments)]
330pub fn create_tag_via_github_api_in(
331    cwd: &Path,
332    gh_binary: &Path,
333    tag: &str,
334    message: &str,
335    dry_run: bool,
336    log: &crate::log::StageLogger,
337    strict: bool,
338) -> Result<()> {
339    if dry_run {
340        log.status(&format!(
341            "(dry-run) would create tag {} via GitHub API (\"{}\")",
342            tag, message
343        ));
344        return Ok(());
345    }
346
347    // Detect owner/repo from the origin remote.
348    let (owner, repo) = detect_github_repo_in(cwd)?;
349
350    // Get the current HEAD SHA to point the tag at.
351    let sha = git_output_in(cwd, &["rev-parse", "HEAD"])?;
352
353    let body = serde_json::json!({
354        "tag": tag,
355        "message": message,
356        "object": sha,
357        "type": "commit",
358        "tagger": {
359            "name": git_output_in(cwd, &["config", "user.name"]).unwrap_or_else(|_| "anodizer".to_string()),
360            "email": git_output_in(cwd, &["config", "user.email"]).unwrap_or_else(|_| "anodizer@users.noreply.github.com".to_string()),
361            "date": crate::sde::resolve_now().to_rfc3339(),
362        }
363    });
364
365    let tag_endpoint = format!("/repos/{owner}/{repo}/git/tags");
366    let response = match gh_api_post_with_binary(gh_binary, &tag_endpoint, &body) {
367        Ok(resp) => resp,
368        Err(e) => {
369            if e.to_string().contains("failed to spawn gh CLI") {
370                if strict {
371                    anyhow::bail!(
372                        "gh CLI not found, cannot create tag via GitHub API (strict mode)"
373                    );
374                }
375                log.warn("gh CLI not found, falling back to local git tag + push");
376                return create_and_push_tag_in(cwd, tag, message, dry_run, log, strict);
377            }
378            return Err(e);
379        }
380    };
381
382    let tag_sha = response["sha"]
383        .as_str()
384        .ok_or_else(|| anyhow::anyhow!("GitHub API response missing 'sha' field"))?;
385
386    let ref_body = serde_json::json!({
387        "ref": format!("refs/tags/{}", tag),
388        "sha": tag_sha,
389    });
390
391    let ref_endpoint = format!("/repos/{owner}/{repo}/git/refs");
392    gh_api_post_with_binary(gh_binary, &ref_endpoint, &ref_body)?;
393
394    Ok(())
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    /// `dry_run=true` must short-circuit before any subprocess spawn.
402    #[test]
403    fn create_tag_dry_run_short_circuits() {
404        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
405        // Even with no git repo / no gh CLI, dry-run must succeed.
406        let result = create_tag_via_github_api("v1.0.0", "msg", true, &log, false);
407        assert!(result.is_ok(), "dry-run must succeed: {result:?}");
408    }
409
410    /// Redact: the token must be replaced with the literal `$GITHUB_TOKEN`
411    /// placeholder when it appears verbatim in the stderr output. Catches
412    /// the case where `gh` echoes the auth header in a verbose error.
413    #[test]
414    fn redact_gh_stderr_replaces_token_value() {
415        let secret = "ghp_abcdefghijklmnopqrstuvwxyz0123456789";
416        let stderr = format!("HTTP 401: token {secret} is invalid");
417        let redacted = redact_gh_stderr(&stderr, Some(secret));
418        assert!(
419            !redacted.contains(secret),
420            "token leaked into redacted output: {redacted}"
421        );
422    }
423
424    #[test]
425    fn redact_gh_stderr_with_no_token_still_strips_url_creds() {
426        // Inline URL credentials must be redacted even with no explicit
427        // token argument.
428        let stderr = "auth failed: https://user:secret-pw@github.com/o/r.git rejected";
429        let redacted = redact_gh_stderr(stderr, None);
430        assert!(
431            !redacted.contains("secret-pw"),
432            "URL credential leaked: {redacted}"
433        );
434    }
435
436    #[test]
437    fn redact_gh_stderr_empty_token_is_noop_on_token_field() {
438        // An empty Some("") token must not pollute the env vector with a
439        // zero-length value (that would match every position in the string).
440        let stderr = "plain error message without credentials";
441        let redacted = redact_gh_stderr(stderr, Some(""));
442        assert_eq!(redacted, stderr);
443    }
444
445    /// A missing `gh` binary must degrade to `None` (never an error/panic):
446    /// the changelog pipeline keeps name-based rendering. The failure is
447    /// memoized, so the second call returns the cached `None` without
448    /// re-attempting a spawn.
449    #[test]
450    fn commit_author_login_missing_binary_degrades_to_none_and_caches() {
451        let tmp = tempfile::tempdir().unwrap();
452        let missing = tmp.path().join("nonexistent-gh");
453        let first = commit_author_login_with_binary(
454            &missing,
455            "owner-cal-test",
456            "repo-cal-test",
457            "a@example.com",
458            "0123456789abcdef0123456789abcdef01234567",
459            None,
460        );
461        assert_eq!(first, None, "missing binary must yield None");
462        // Cached-failure path: same (owner, repo, email) key short-circuits
463        // before any spawn attempt.
464        let second = commit_author_login_with_binary(
465            &missing,
466            "owner-cal-test",
467            "repo-cal-test",
468            "a@example.com",
469            "fedcba9876543210fedcba9876543210fedcba98",
470            None,
471        );
472        assert_eq!(second, None);
473    }
474
475    /// Empty inputs short-circuit to `None` without touching the cache or
476    /// spawning anything.
477    #[test]
478    fn commit_author_login_empty_inputs_are_none() {
479        let gh = Path::new("gh");
480        assert_eq!(
481            commit_author_login_with_binary(gh, "", "r", "e", "s", None),
482            None
483        );
484        assert_eq!(
485            commit_author_login_with_binary(gh, "o", "", "e", "s", None),
486            None
487        );
488        assert_eq!(
489            commit_author_login_with_binary(gh, "o", "r", "", "s", None),
490            None
491        );
492        assert_eq!(
493            commit_author_login_with_binary(gh, "o", "r", "e", "", None),
494            None
495        );
496    }
497
498    /// Chain order: explicit beats `ANODIZER_GITHUB_TOKEN` beats
499    /// `GITHUB_TOKEN`; empty strings are absent at every link. Uses a
500    /// map-backed env closure — no process-env mutation, no network.
501    #[test]
502    fn resolve_github_token_chain_order_and_empty_filtering() {
503        let env_with = |pairs: &[(&str, &str)]| {
504            let map: HashMap<String, String> = pairs
505                .iter()
506                .map(|(k, v)| (k.to_string(), v.to_string()))
507                .collect();
508            move |key: &str| map.get(key).cloned()
509        };
510
511        let both = env_with(&[
512            ("ANODIZER_GITHUB_TOKEN", "anod-tok"),
513            ("GITHUB_TOKEN", "gh-tok"),
514        ]);
515        assert_eq!(
516            resolve_github_token_with_env(Some("explicit-tok"), &both),
517            Some("explicit-tok".to_string()),
518            "explicit token must win over both env vars"
519        );
520        assert_eq!(
521            resolve_github_token_with_env(None, &both),
522            Some("anod-tok".to_string()),
523            "ANODIZER_GITHUB_TOKEN must win over GITHUB_TOKEN"
524        );
525
526        let gh_only = env_with(&[("GITHUB_TOKEN", "gh-tok")]);
527        assert_eq!(
528            resolve_github_token_with_env(None, &gh_only),
529            Some("gh-tok".to_string()),
530            "GITHUB_TOKEN alone must resolve (the CI-gap pin)"
531        );
532        assert_eq!(
533            resolve_github_token_with_env(Some(""), &gh_only),
534            Some("gh-tok".to_string()),
535            "empty explicit token must fall through to env"
536        );
537
538        let empty_anod = env_with(&[("ANODIZER_GITHUB_TOKEN", ""), ("GITHUB_TOKEN", "gh-tok")]);
539        assert_eq!(
540            resolve_github_token_with_env(None, &empty_anod),
541            Some("gh-tok".to_string()),
542            "empty ANODIZER_GITHUB_TOKEN (GHA missing-secret materialization) must not short-circuit"
543        );
544
545        let none = env_with(&[]);
546        assert_eq!(resolve_github_token_with_env(None, &none), None);
547        assert_eq!(resolve_github_token_with_env(Some(""), &none), None);
548    }
549
550    /// `gh_api_get_with_binary` must surface a user-actionable spawn
551    /// failure when the binary path doesn't exist on disk.
552    ///
553    /// Drives the function with a temp-dir-relative path that points to
554    /// nothing, asserting the error mentions "spawn gh" so the operator
555    /// can correlate it with their missing-`gh` install state.
556    #[test]
557    fn gh_api_get_with_binary_bails_when_binary_missing() {
558        let tmp = tempfile::tempdir().unwrap();
559        let missing = tmp.path().join("nonexistent-gh");
560        let err = gh_api_get_with_binary(&missing, "/repos/x/y", None)
561            .expect_err("missing binary must error");
562        let msg = err.to_string();
563        assert!(
564            msg.contains("spawn gh") || msg.contains(&missing.display().to_string()),
565            "expected actionable error mentioning spawn gh or the binary path, got: {msg}"
566        );
567    }
568
569    /// Same guarantee for the paginated sibling.
570    #[test]
571    fn gh_api_get_paginated_with_binary_bails_when_binary_missing() {
572        let tmp = tempfile::tempdir().unwrap();
573        let missing = tmp.path().join("nonexistent-gh");
574        let err = gh_api_get_paginated_with_binary(&missing, "/repos/x/y", None)
575            .expect_err("missing binary must error");
576        let msg = err.to_string();
577        assert!(
578            msg.contains("spawn gh") || msg.contains(&missing.display().to_string()),
579            "expected actionable error mentioning spawn gh or the binary path, got: {msg}"
580        );
581    }
582
583    /// `create_tag_via_github_api_in` with `strict=true` must error
584    /// when `cwd` is not a git repo — the inner `detect_github_repo_in`
585    /// drives `git remote get-url origin` and fails there.
586    ///
587    /// Skips when `git` isn't on PATH (mirrors `tool_on_path` patterns
588    /// elsewhere in the suite).
589    #[test]
590    fn create_tag_via_github_api_in_bails_when_not_a_git_repo() {
591        if Command::new("git")
592            .arg("--version")
593            .output()
594            .map(|o| !o.status.success())
595            .unwrap_or(true)
596        {
597            return;
598        }
599        let tmp = tempfile::tempdir().unwrap();
600        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
601        let err = create_tag_via_github_api_in(
602            tmp.path(),
603            Path::new("gh"),
604            "v1.0.0",
605            "msg",
606            false,
607            &log,
608            true,
609        )
610        .expect_err("non-git cwd must error");
611        let msg = err.to_string();
612        assert!(
613            msg.contains("git") || msg.contains("remote"),
614            "expected error to mention git or remote, got: {msg}"
615        );
616    }
617
618    /// Dry-run short-circuit must also fire on the cwd-injectable entry
619    /// point — covers the new branch without re-hitting the inner
620    /// detection codepath.
621    #[test]
622    fn create_tag_via_github_api_in_dry_run_short_circuits() {
623        let tmp = tempfile::tempdir().unwrap();
624        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
625        let result = create_tag_via_github_api_in(
626            tmp.path(),
627            Path::new("gh"),
628            "v1.0.0",
629            "msg",
630            true,
631            &log,
632            false,
633        );
634        assert!(result.is_ok(), "dry-run must succeed: {result:?}");
635    }
636}