Skip to main content

anodizer_core/git/
mod.rs

1use anyhow::{Result, bail};
2use std::path::Path;
3use std::process::Command;
4
5mod commits;
6mod conventional;
7mod detect;
8mod github_api;
9mod github_probe;
10mod mailmap;
11mod remote;
12mod semver;
13mod slug;
14mod snapshot_sde;
15mod status;
16mod tags;
17pub mod worktree;
18
19#[cfg(test)]
20mod tests;
21
22pub use commits::{
23    CHANGELOG_PROVENANCE_PREFIX, Commit, CommitWithFiles, CommitterIdentity, RELEASE_COMMIT_PREFIX,
24    SHORT_COMMIT_LEN, add_path_in, branches_containing_sha_in, changelog_regenerated_marker,
25    changelog_regenerated_recorded_in, commit_in, commit_subject_in, commits_between_in,
26    commits_with_subjects_in, count_commits_since_last_tag_in, get_all_commits, get_all_commits_in,
27    get_all_commits_paths, get_all_commits_paths_in, get_all_commits_paths_with_files_in,
28    get_commit_messages_between, get_commit_messages_between_in, get_commit_messages_between_path,
29    get_commit_messages_between_path_in, get_commits_between, get_commits_between_in,
30    get_commits_between_paths, get_commits_between_paths_in,
31    get_commits_between_paths_with_files_in, get_commits_reachable_paths_in,
32    get_commits_reachable_paths_with_files_in, get_current_branch, get_current_branch_in,
33    get_current_branch_in_with_env, get_head_commit, get_head_commit_in, get_last_commit_messages,
34    get_last_commit_messages_in, get_last_commit_messages_path, get_last_commit_messages_path_in,
35    get_short_commit, get_short_commit_in, has_changes_since, has_changes_since_in,
36    has_commits_since_tag, has_commits_since_tag_in, head_commit_hash_in, head_commit_timestamp_in,
37    is_branchlike, log_subjects_for_range, parse_commit_output, parse_commit_output_with_files,
38    paths_changed_since_tag, paths_changed_since_tag_in, push_branch_in, release_bump_subject,
39    release_bump_subject_prefix, reset_hard_in, resolve_rollback_identity, rev_parse_in,
40    rev_verify_commit_in, revert_commit_in, short_commit_str, stage_and_commit,
41    stage_and_commit_in,
42};
43pub use conventional::{ConventionalLevel, classify_commit};
44pub use detect::{GitInfo, TagSource, detect_git_info, detect_git_info_in};
45pub use github_api::{
46    GITHUB_TOKEN_ENV_LADDER, commit_author_login, commit_author_login_with_binary,
47    create_tag_via_github_api, create_tag_via_github_api_in, gh_api_delete_with_binary, gh_api_get,
48    gh_api_get_paginated, gh_api_get_paginated_with_binary, gh_api_get_with_binary,
49    gh_api_get_with_binary_with_env, github_token_env_hint, github_token_hint,
50    resolve_github_token, resolve_github_token_with_env,
51};
52pub use github_probe::{
53    GithubRepoProbe, RepoAccessOutcomes, RepoProbe, github_repo_probe, github_repo_push_check,
54    indeterminate_check, is_rate_limit_signature, is_secondary_rate_limit_signature,
55    probe_to_push_check, response_is_rate_limited,
56};
57pub use mailmap::canonical_author_email_in;
58// The owner/repo detectors are intentionally NOT re-exported publicly: the
59// only public path to a repository identity is a `slug::resolve_*` function,
60// which applies the config-override -> remote precedence in one place. See
61// `slug.rs`. `detect_remote_web_base_in` stays public — it is the dedicated
62// host-preserving web-base resolver (changelog compare footers) and has no
63// owner/name duplication to funnel.
64pub use remote::{detect_remote_web_base_in, has_remote_in, parse_remote_web_base};
65pub use semver::{SemVer, parse_semver, parse_semver_tag, split_tag_family, version_from_tag};
66pub use slug::{
67    RepoSlug, resolve_github_slug, resolve_github_slug_in, resolve_repo_slug, resolve_repo_slug_in,
68};
69pub use snapshot_sde::resolve_snapshot_sde;
70pub use status::{
71    check_git_available, git_status_porcelain, git_status_porcelain_in,
72    git_status_porcelain_result_in, is_git_dirty, is_git_dirty_in, is_git_repo, is_git_repo_in,
73    is_shallow_clone, is_shallow_clone_in, list_tracked_files_in, local_git_user_email,
74    local_git_user_email_in, local_git_user_name, local_git_user_name_in,
75};
76pub use tags::{
77    AtomicPushSpec, TagPosition, create_and_push_tag, create_and_push_tag_in,
78    create_tag_local_only, delete_local_tag_in, delete_remote_tag_in, extract_tag_prefix,
79    filter_ignored_tags, find_latest_tag_matching, find_latest_tag_matching_in,
80    find_latest_tag_matching_with_prefix, find_latest_tag_matching_with_prefix_in,
81    find_previous_tag, find_previous_tag_in, find_previous_tag_in_family,
82    find_previous_tag_in_family_in, find_previous_tag_with_prefix,
83    find_previous_tag_with_prefix_in, get_all_semver_tags, get_all_semver_tags_in,
84    get_branch_semver_tags, get_branch_semver_tags_in, get_first_commit, get_first_commit_in,
85    get_tags_at_head, get_tags_at_head_in, get_tags_at_sha_in, has_version_placeholder,
86    head_is_at_tag, is_nightly_tag, list_remote_tag_names_in, list_tags_with_prefix,
87    per_crate_tag_prefix, push_branch_and_tags_atomic_in, render_ignore_patterns,
88    strip_monorepo_prefix, tag_family_glob, tag_points_at_head, tag_points_at_head_in,
89    tag_position_in,
90};
91pub use worktree::Worktree;
92
93/// Run `git` in `cwd` and return stdout, trimmed.
94///
95/// Shared low-level git invocation wrapper. Path-taking so callers
96/// that don't own the process cwd — notably tests against a `tempfile::tempdir()`
97/// fixture repo — can drive git without mutating the process-wide cwd
98/// (which would race every other parallel test). The no-arg public wrappers
99/// in sibling submodules delegate here with `std::env::current_dir()`.
100///
101/// On non-zero exit the stderr is passed through
102/// [`crate::redact::redact_process_env`] before interpolation, so any
103/// token-bearing remote URL git might echo (e.g.
104/// `https://ghp_xxx@github.com/...` produced by an `extraHeader` config
105/// leak) is scrubbed in the bail message.
106pub(crate) fn git_output_in(cwd: &Path, args: &[&str]) -> Result<String> {
107    // `GIT_TERMINAL_PROMPT=0` + `LC_ALL=C` pinned on every spawn so:
108    //   - a credential helper / nested config can't hang the wrapper
109    //     waiting for interactive input (unattended CI hosts),
110    //   - locale-sensitive stderr / stdout messages (e.g. "not found",
111    //     "remote ref does not exist") stay in English so substring
112    //     matching in caller code is stable.
113    let output = Command::new("git")
114        .current_dir(cwd)
115        .args(args)
116        .env("GIT_TERMINAL_PROMPT", "0")
117        .env("LC_ALL", "C")
118        .output()?;
119    if !output.status.success() {
120        let stderr_raw = String::from_utf8_lossy(&output.stderr);
121        let stderr_trim = stderr_raw.trim();
122        // Some git failure paths print only on stdout (notably `git commit`
123        // with "nothing to commit, working tree clean"). When stderr is
124        // empty, fall back to stdout so the bail message is diagnostic
125        // instead of `failed: ` with no further detail.
126        let detail = if stderr_trim.is_empty() {
127            String::from_utf8_lossy(&output.stdout).trim().to_string()
128        } else {
129            stderr_trim.to_string()
130        };
131        let raw = format!("git {} failed: {}", args.join(" "), detail);
132        bail!("{}", crate::redact::redact_process_env(&raw));
133    }
134    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
135}