Skip to main content

tokmd_git/
refs.rs

1//! Git revision and base-reference resolution.
2
3use std::path::Path;
4use std::process::Stdio;
5
6use crate::git_cmd;
7
8/// Check whether a git revision resolves to a valid commit.
9pub fn rev_exists(repo_root: &Path, rev: &str) -> bool {
10    git_cmd()
11        .arg("-C")
12        .arg(repo_root)
13        .args(["rev-parse", "--verify", "--quiet", "--end-of-options"])
14        .arg(format!("{rev}^{{commit}}"))
15        .stdout(Stdio::null())
16        .stderr(Stdio::null())
17        .status()
18        .map(|s| s.success())
19        .unwrap_or(false)
20}
21
22/// Resolve a base ref with a fallback chain for CI environments.
23///
24/// Fallback order:
25/// 1. `requested` itself (fast path)
26/// 2. `TOKMD_GIT_BASE_REF` env var
27/// 3. `origin/{GITHUB_BASE_REF}` (GitHub Actions)
28/// 4. `origin/HEAD` (remote default branch)
29/// 5. `origin/main`, `main`, `origin/master`, `master`
30///
31/// Returns `None` if nothing resolves.
32pub fn resolve_base_ref(repo_root: &Path, requested: &str) -> Option<String> {
33    // Fast path: the requested ref exists
34    if rev_exists(repo_root, requested) {
35        return Some(requested.to_string());
36    }
37
38    // Only use fallback resolution for the CLI default (`main`).
39    // Explicitly requested bases should fail fast if missing.
40    if requested != "main" {
41        return None;
42    }
43
44    // TOKMD_GIT_BASE_REF env override
45    if let Ok(env_ref) = std::env::var("TOKMD_GIT_BASE_REF")
46        && env_base_ref_is_safe(&env_ref)
47        && rev_exists(repo_root, &env_ref)
48    {
49        return Some(env_ref);
50    }
51
52    // GitHub Actions: origin/$GITHUB_BASE_REF
53    if let Ok(gh_base) = std::env::var("GITHUB_BASE_REF")
54        && env_base_ref_is_safe(&gh_base)
55    {
56        let candidate = format!("origin/{gh_base}");
57        if rev_exists(repo_root, &candidate) {
58            return Some(candidate);
59        }
60    }
61
62    // Remote default branch
63    static FALLBACKS: &[&str] = &[
64        "origin/HEAD",
65        "origin/main",
66        "main",
67        "origin/master",
68        "master",
69    ];
70
71    for candidate in FALLBACKS {
72        if rev_exists(repo_root, candidate) {
73            return Some((*candidate).to_string());
74        }
75    }
76
77    None
78}
79
80fn env_base_ref_is_safe(ref_name: &str) -> bool {
81    !ref_name.is_empty()
82        && !ref_name.starts_with('-')
83        && !ref_name
84            .chars()
85            .any(|c| c.is_whitespace() || c.is_control() || c == '\\')
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use std::process::Command;
92
93    use crate::{git_available, git_cmd};
94
95    fn test_git(dir: &Path) -> Command {
96        let mut cmd = git_cmd();
97        cmd.arg("-C").arg(dir);
98        cmd
99    }
100
101    #[test]
102    fn rev_exists_finds_head_in_repo() {
103        if !git_available() {
104            return;
105        }
106        let dir = tempfile::tempdir().unwrap();
107
108        // Init repo and create a commit so HEAD resolves
109        test_git(dir.path()).arg("init").output().unwrap();
110        test_git(dir.path())
111            .args(["config", "user.email", "test@test.com"])
112            .output()
113            .unwrap();
114        test_git(dir.path())
115            .args(["config", "user.name", "Test"])
116            .output()
117            .unwrap();
118        std::fs::write(dir.path().join("f.txt"), "hello").unwrap();
119        test_git(dir.path()).args(["add", "."]).output().unwrap();
120        test_git(dir.path())
121            .args(["commit", "-m", "init"])
122            .output()
123            .unwrap();
124
125        assert!(rev_exists(dir.path(), "HEAD"));
126        assert!(!rev_exists(dir.path(), "nonexistent-branch-abc123"));
127    }
128
129    #[test]
130    fn rev_exists_treats_option_like_ref_as_missing() {
131        if !git_available() {
132            return;
133        }
134        let dir = tempfile::tempdir().unwrap();
135
136        test_git(dir.path())
137            .args(["init", "-b", "main"])
138            .output()
139            .unwrap();
140        test_git(dir.path())
141            .args(["config", "user.email", "test@test.com"])
142            .output()
143            .unwrap();
144        test_git(dir.path())
145            .args(["config", "user.name", "Test"])
146            .output()
147            .unwrap();
148        std::fs::write(dir.path().join("f.txt"), "hello").unwrap();
149        test_git(dir.path()).args(["add", "."]).output().unwrap();
150        test_git(dir.path())
151            .args(["commit", "-m", "init"])
152            .output()
153            .unwrap();
154
155        assert!(!rev_exists(dir.path(), "--help"));
156    }
157
158    #[test]
159    fn resolve_base_ref_returns_requested_when_valid() {
160        if !git_available() {
161            return;
162        }
163        let dir = tempfile::tempdir().unwrap();
164
165        test_git(dir.path())
166            .args(["init", "-b", "main"])
167            .output()
168            .unwrap();
169        test_git(dir.path())
170            .args(["config", "user.email", "test@test.com"])
171            .output()
172            .unwrap();
173        test_git(dir.path())
174            .args(["config", "user.name", "Test"])
175            .output()
176            .unwrap();
177        std::fs::write(dir.path().join("f.txt"), "hello").unwrap();
178        test_git(dir.path()).args(["add", "."]).output().unwrap();
179        test_git(dir.path())
180            .args(["commit", "-m", "init"])
181            .output()
182            .unwrap();
183
184        assert_eq!(
185            resolve_base_ref(dir.path(), "main"),
186            Some("main".to_string())
187        );
188    }
189
190    #[test]
191    fn resolve_base_ref_returns_none_when_nothing_resolves() {
192        if !git_available() {
193            return;
194        }
195        let dir = tempfile::tempdir().unwrap();
196
197        // Init on "trunk" with no commits, no remotes
198        test_git(dir.path())
199            .args(["init", "-b", "trunk"])
200            .output()
201            .unwrap();
202
203        // No commits exist, so even "trunk" won't resolve to a commit
204        assert_eq!(resolve_base_ref(dir.path(), "nonexistent"), None);
205    }
206
207    #[test]
208    fn env_base_ref_accepts_common_refs() {
209        for ref_name in [
210            "HEAD",
211            "HEAD~1",
212            "feature/foo",
213            "release/v1.2.3",
214            "dependabot/cargo/foo-1.2.3",
215            "origin/main",
216            "af6004c",
217        ] {
218            assert!(
219                env_base_ref_is_safe(ref_name),
220                "expected env base ref to be safe: {ref_name}"
221            );
222        }
223    }
224
225    #[test]
226    fn env_base_ref_rejects_ambiguous_or_malformed_refs() {
227        for ref_name in [
228            "",
229            "-bad",
230            "--help",
231            "feature foo",
232            "main\nnext",
233            "main\0next",
234            r"feature\foo",
235        ] {
236            assert!(
237                !env_base_ref_is_safe(ref_name),
238                "expected env base ref to be rejected: {ref_name:?}"
239            );
240        }
241    }
242}