wtg_cli/backend/
git_backend.rs1use async_trait::async_trait;
6use chrono::{DateTime, Utc};
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10use super::{Backend, NoticeCallback};
11use crate::changelog;
12use crate::error::{WtgError, WtgResult};
13use crate::git::{CommitInfo, FileInfo, GitRepo, TagInfo, looks_like_commit_hash};
14use crate::github::GitHubClient;
15use crate::parse_input::{ParsedQuery, Query};
16use crate::release_filter::ReleaseFilter;
17
18pub struct GitBackend {
23 repo: GitRepo,
24}
25
26impl GitBackend {
27 #[must_use]
29 pub const fn new(repo: GitRepo) -> Self {
30 Self { repo }
31 }
32
33 pub const fn git_repo(&self) -> &GitRepo {
35 &self.repo
36 }
37
38 pub fn set_notice_callback(&mut self, cb: NoticeCallback) {
40 self.repo.set_notice_callback(cb);
41 }
42
43 fn find_best_tag_for_commit(
45 &self,
46 commit_hash: &str,
47 filter: &ReleaseFilter,
48 ) -> Option<TagInfo> {
49 if let Some(tag_name) = filter.specific_tag() {
51 let tag = self
53 .repo
54 .get_tags()
55 .into_iter()
56 .find(|t| t.name == tag_name)?;
57
58 if self.repo.tag_contains_commit(&tag.commit_hash, commit_hash) {
60 return Some(tag);
61 }
62 return None;
63 }
64
65 let candidates = self.repo.tags_containing_commit(commit_hash);
66 if candidates.is_empty() {
67 return None;
68 }
69
70 let filtered = filter.filter_tags(candidates);
72
73 if filtered.is_empty() {
74 return None;
75 }
76
77 let timestamps: HashMap<String, i64> = filtered
79 .iter()
80 .map(|tag| {
81 (
82 tag.commit_hash.clone(),
83 self.repo.get_commit_timestamp(&tag.commit_hash),
84 )
85 })
86 .collect();
87
88 Self::pick_best_tag(&filtered, ×tamps)
90 }
91
92 fn pick_best_tag(candidates: &[TagInfo], timestamps: &HashMap<String, i64>) -> Option<TagInfo> {
94 fn select_with_pred<F>(
95 candidates: &[TagInfo],
96 timestamps: &HashMap<String, i64>,
97 predicate: F,
98 ) -> Option<TagInfo>
99 where
100 F: Fn(&TagInfo) -> bool,
101 {
102 candidates
103 .iter()
104 .filter(|tag| predicate(tag))
105 .min_by_key(|tag| {
106 timestamps
107 .get(&tag.commit_hash)
108 .copied()
109 .unwrap_or(i64::MAX)
110 })
111 .cloned()
112 }
113
114 select_with_pred(candidates, timestamps, |t| t.is_release && t.is_semver())
116 .or_else(|| {
117 select_with_pred(candidates, timestamps, |t| !t.is_release && t.is_semver())
118 })
119 .or_else(|| {
120 select_with_pred(candidates, timestamps, |t| t.is_release && !t.is_semver())
121 })
122 .or_else(|| {
123 select_with_pred(candidates, timestamps, |t| !t.is_release && !t.is_semver())
124 })
125 }
126
127 fn disambiguate_input_string(&self, input: &str) -> WtgResult<Query> {
128 if self.repo.get_tags().iter().any(|tag| tag.name == input) {
129 return Ok(Query::Tag(input.to_string()));
130 }
131
132 if self.repo.has_path_at_head(input) {
133 return Ok(Query::FilePath {
134 branch: "HEAD".to_string(),
135 path: PathBuf::from(input),
136 });
137 }
138
139 if looks_like_commit_hash(input) && self.repo.find_commit_local(input).is_some() {
140 return Ok(Query::GitCommit(input.to_string()));
141 }
142
143 Err(WtgError::NotFound(input.to_string()))
144 }
145
146 fn disambiguate_unknown_path(&self, segments: &[String]) -> Option<Query> {
147 let (branch, remainder) = self.repo.find_branch_path_match(segments)?;
148 let mut path = PathBuf::new();
149 for segment in remainder {
150 path.push(segment);
151 }
152 Some(Query::FilePath { branch, path })
153 }
154}
155
156#[async_trait]
157impl Backend for GitBackend {
158 async fn find_commit(&self, hash: &str) -> WtgResult<CommitInfo> {
166 self.repo
168 .find_commit(hash)?
169 .ok_or_else(|| WtgError::NotFound(hash.to_string()))
170 }
171
172 async fn enrich_commit(&self, mut commit: CommitInfo) -> CommitInfo {
173 if commit.commit_url.is_none()
175 && let Some(repo_info) = self.repo.github_remote()
176 {
177 commit.commit_url = Some(GitHubClient::commit_url(&repo_info, &commit.hash));
178 }
179 commit
180 }
181
182 async fn find_file(&self, branch: &str, path: &str) -> WtgResult<FileInfo> {
187 self.repo
188 .find_file_on_branch(branch, path)
189 .ok_or_else(|| WtgError::NotFound(path.to_string()))
190 }
191
192 async fn find_tag(&self, name: &str) -> WtgResult<TagInfo> {
197 self.repo
198 .get_tags()
199 .into_iter()
200 .find(|t| t.name == name)
201 .ok_or_else(|| WtgError::NotFound(name.to_string()))
202 }
203
204 async fn find_previous_tag(&self, tag_name: &str) -> WtgResult<Option<TagInfo>> {
205 let tags = self.repo.get_tags();
206 let current_tag = tags.iter().find(|t| t.name == tag_name);
207
208 let Some(current) = current_tag else {
209 return Ok(None);
210 };
211
212 if current.is_semver() {
214 let mut semver_tags: Vec<_> = tags.iter().filter(|t| t.is_semver()).collect();
215
216 semver_tags.sort_by(|a, b| {
218 let a_semver = a.semver_info.as_ref().unwrap();
219 let b_semver = b.semver_info.as_ref().unwrap();
220 a_semver.cmp(b_semver)
221 });
222
223 if let Some(pos) = semver_tags.iter().position(|t| t.name == tag_name)
225 && pos > 0
226 {
227 return Ok(Some(semver_tags[pos - 1].clone()));
228 }
229 return Ok(None);
230 }
231
232 let current_timestamp = self.repo.get_commit_timestamp(¤t.commit_hash);
234
235 let mut candidates: Vec<_> = tags
236 .iter()
237 .filter(|t| t.name != tag_name)
238 .filter(|t| t.commit_hash != current.commit_hash)
239 .filter(|t| self.repo.get_commit_timestamp(&t.commit_hash) < current_timestamp)
240 .collect();
241
242 candidates.sort_by(|a, b| {
244 let a_ts = self.repo.get_commit_timestamp(&a.commit_hash);
245 let b_ts = self.repo.get_commit_timestamp(&b.commit_hash);
246 b_ts.cmp(&a_ts)
247 });
248
249 Ok(candidates.first().map(|t| (*t).clone()))
250 }
251
252 async fn commits_between_tags(
253 &self,
254 from_tag: &str,
255 to_tag: &str,
256 limit: usize,
257 ) -> WtgResult<Vec<CommitInfo>> {
258 Ok(self.repo.commits_between(from_tag, to_tag, limit))
259 }
260
261 async fn disambiguate_query(&self, query: &ParsedQuery) -> WtgResult<Query> {
262 match query {
263 ParsedQuery::Resolved(resolved) => Ok(resolved.clone()),
264 ParsedQuery::Unknown(input) => self.disambiguate_input_string(input),
265 ParsedQuery::UnknownPath { segments } => self
266 .disambiguate_unknown_path(segments)
267 .ok_or_else(|| WtgError::NotFound(segments.join("/"))),
268 }
269 }
270
271 async fn find_release_for_commit(
272 &self,
273 commit_hash: &str,
274 _commit_date: Option<DateTime<Utc>>,
275 filter: &ReleaseFilter,
276 ) -> Option<TagInfo> {
277 self.find_best_tag_for_commit(commit_hash, filter)
278 }
279
280 async fn changelog_for_version(&self, version: &str) -> Option<String> {
281 changelog::parse_changelog_for_version(self.repo.path(), version)
282 }
283
284 fn commit_url(&self, hash: &str) -> Option<String> {
289 self.repo
290 .github_remote()
291 .map(|ri| GitHubClient::commit_url(&ri, hash))
292 }
293
294 fn tag_url(&self, tag: &str) -> Option<String> {
295 self.repo
296 .github_remote()
297 .map(|ri| GitHubClient::tag_url(&ri, tag))
298 }
299
300 fn release_tag_url(&self, tag: &str) -> Option<String> {
301 self.repo
302 .github_remote()
303 .map(|ri| GitHubClient::release_tag_url(&ri, tag))
304 }
305
306 fn author_url_from_email(&self, email: &str) -> Option<String> {
307 GitHubClient::author_url_from_email(email)
308 }
309}