just_shield/
github_facts.rs1use std::io;
8
9pub trait GithubFacts {
11 fn resolve_ref(&self, owner_repo: &str, git_ref: &str) -> io::Result<Option<String>>;
14
15 fn commit_reachable(&self, _owner_repo: &str, _sha: &str) -> io::Result<Option<bool>> {
18 Ok(None)
19 }
20
21 fn ref_timestamp(&self, _owner_repo: &str, _git_ref: &str) -> io::Result<Option<i64>> {
24 Ok(None)
25 }
26
27 fn ref_count(&self, _owner_repo: &str) -> io::Result<Option<usize>> {
30 Ok(None)
31 }
32}
33
34pub struct GitRemote;
36
37impl GithubFacts for GitRemote {
38 fn resolve_ref(&self, owner_repo: &str, git_ref: &str) -> io::Result<Option<String>> {
39 let url = format!("https://github.com/{owner_repo}.git");
40 let peeled = format!("{git_ref}^{{}}");
43 let out = std::process::Command::new("git")
44 .args(["ls-remote", &url, git_ref, &peeled])
45 .output()?;
46 if !out.status.success() {
47 return Err(io::Error::other(format!(
48 "git ls-remote 실패 ({owner_repo}): {}",
49 String::from_utf8_lossy(&out.stderr).trim()
50 )));
51 }
52 Ok(pick_best(&String::from_utf8_lossy(&out.stdout)))
53 }
54
55 fn commit_reachable(&self, owner_repo: &str, sha: &str) -> io::Result<Option<bool>> {
56 Self::commit_reachable_impl(owner_repo, sha)
57 }
58
59 fn ref_timestamp(&self, owner_repo: &str, git_ref: &str) -> io::Result<Option<i64>> {
60 Self::ref_timestamp_impl(owner_repo, git_ref)
61 }
62
63 fn ref_count(&self, owner_repo: &str) -> io::Result<Option<usize>> {
64 let url = format!("https://github.com/{owner_repo}.git");
65 let out = std::process::Command::new("git")
66 .args(["ls-remote", "--tags", &url])
67 .output()?;
68 if !out.status.success() {
69 return Ok(None);
71 }
72 Ok(Some(
73 String::from_utf8_lossy(&out.stdout)
74 .lines()
75 .filter(|l| !l.trim().is_empty())
76 .count(),
77 ))
78 }
79}
80
81impl GitRemote {
82 fn shallow_fetch(
84 owner_repo: &str,
85 want: &str,
86 ) -> io::Result<(bool, String, std::path::PathBuf)> {
87 let url = format!("https://github.com/{owner_repo}.git");
88 let key = want
89 .bytes()
90 .fold(0u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64));
91 let tmp =
92 std::env::temp_dir().join(format!("just-shield-fetch-{}-{key:x}", std::process::id()));
93 let _ = std::fs::remove_dir_all(&tmp);
94 std::fs::create_dir_all(&tmp)?;
95 let init = std::process::Command::new("git")
96 .args(["init", "-q"])
97 .current_dir(&tmp)
98 .output()?;
99 if !init.status.success() {
100 return Err(io::Error::other("git init 실패"));
101 }
102 let fetch = std::process::Command::new("git")
103 .args(["fetch", "--quiet", "--depth=1", &url, want])
104 .current_dir(&tmp)
105 .output()?;
106 let stderr = String::from_utf8_lossy(&fetch.stderr).to_lowercase();
107 Ok((fetch.status.success(), stderr, tmp))
108 }
109
110 fn commit_reachable_impl(owner_repo: &str, sha: &str) -> io::Result<Option<bool>> {
113 let (ok, stderr, tmp) = Self::shallow_fetch(owner_repo, sha)?;
114 let _ = std::fs::remove_dir_all(&tmp);
115 if ok {
116 return Ok(Some(true));
117 }
118 if stderr.contains("not our ref") || stderr.contains("unadvertised object") {
120 return Ok(Some(false));
121 }
122 Err(io::Error::other(format!(
123 "git fetch 실패 ({owner_repo}@{sha}): {}",
124 stderr.trim()
125 )))
126 }
127
128 fn ref_timestamp_impl(owner_repo: &str, git_ref: &str) -> io::Result<Option<i64>> {
129 let (ok, stderr, tmp) = Self::shallow_fetch(owner_repo, git_ref)?;
130 if !ok {
131 let _ = std::fs::remove_dir_all(&tmp);
132 if stderr.contains("couldn't find remote ref") {
133 return Ok(None);
134 }
135 return Err(io::Error::other(format!(
136 "git fetch 실패 ({owner_repo}@{git_ref}): {}",
137 stderr.trim()
138 )));
139 }
140 let log = std::process::Command::new("git")
141 .args(["log", "-1", "--format=%ct", "FETCH_HEAD"])
142 .current_dir(&tmp)
143 .output()?;
144 let _ = std::fs::remove_dir_all(&tmp);
145 if !log.status.success() {
146 return Ok(None);
147 }
148 Ok(String::from_utf8_lossy(&log.stdout).trim().parse().ok())
149 }
150}
151
152fn pick_best(output: &str) -> Option<String> {
154 let mut tag = None;
155 let mut other = None;
156 for line in output.lines() {
157 let Some((sha, name)) = line.split_once('\t') else {
158 continue;
159 };
160 if name.ends_with("^{}") {
161 return Some(sha.to_string());
162 }
163 if name.starts_with("refs/tags/") {
164 tag.get_or_insert_with(|| sha.to_string());
165 } else {
166 other.get_or_insert_with(|| sha.to_string());
167 }
168 }
169 tag.or(other)
170}
171
172#[cfg(test)]
173mod tests {
174 use super::pick_best;
175
176 #[test]
177 fn prefers_peeled_then_tag_then_branch() {
178 let out = "aaa\trefs/heads/v4\nbbb\trefs/tags/v4\nccc\trefs/tags/v4^{}\n";
179 assert_eq!(pick_best(out).as_deref(), Some("ccc"));
180 let out = "aaa\trefs/heads/v4\nbbb\trefs/tags/v4\n";
181 assert_eq!(pick_best(out).as_deref(), Some("bbb"));
182 assert_eq!(pick_best(""), None);
183 }
184}