1use std::path::Path;
4use std::process::Stdio;
5
6use crate::git_cmd;
7
8pub 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
22pub fn resolve_base_ref(repo_root: &Path, requested: &str) -> Option<String> {
33 if rev_exists(repo_root, requested) {
35 return Some(requested.to_string());
36 }
37
38 if requested != "main" {
41 return None;
42 }
43
44 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 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 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 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 test_git(dir.path())
199 .args(["init", "-b", "trunk"])
200 .output()
201 .unwrap();
202
203 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}