Skip to main content

wtg_cli/backend/
git_backend.rs

1//! Pure local git backend implementation.
2//!
3//! This backend wraps a `GitRepo` and provides git-only operations.
4
5use 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
18/// Pure local git backend wrapping a `GitRepo`.
19///
20/// Uses `GitRepo` for all operations including smart fetching.
21/// Cannot access GitHub API, so PR/Issue queries will return `Unsupported`.
22pub struct GitBackend {
23    repo: GitRepo,
24}
25
26impl GitBackend {
27    /// Create a `GitBackend` from an existing `GitRepo`.
28    #[must_use]
29    pub const fn new(repo: GitRepo) -> Self {
30        Self { repo }
31    }
32
33    /// Get a reference to the underlying `GitRepo`.
34    pub const fn git_repo(&self) -> &GitRepo {
35        &self.repo
36    }
37
38    /// Set the notice callback for emitting operational messages.
39    pub fn set_notice_callback(&mut self, cb: NoticeCallback) {
40        self.repo.set_notice_callback(cb);
41    }
42
43    /// Find tags containing a commit and pick the best one, applying the filter.
44    fn find_best_tag_for_commit(
45        &self,
46        commit_hash: &str,
47        filter: &ReleaseFilter,
48    ) -> Option<TagInfo> {
49        // Fast path for specific tag lookup
50        if let Some(tag_name) = filter.specific_tag() {
51            // Find the tag first
52            let tag = self
53                .repo
54                .get_tags()
55                .into_iter()
56                .find(|t| t.name == tag_name)?;
57
58            // Check if the commit is in this tag
59            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        // Apply filter to candidates
71        let filtered = filter.filter_tags(candidates);
72
73        if filtered.is_empty() {
74            return None;
75        }
76
77        // Build timestamp map for sorting
78        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        // Pick best tag: prefer semver releases, then semver, then any release, then any
89        Self::pick_best_tag(&filtered, &timestamps)
90    }
91
92    /// Pick the best tag from candidates based on priority rules.
93    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        // Priority: released semver > unreleased semver > released non-semver > unreleased non-semver
115        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    // Note: backend_for_pr() uses default (returns None) since GitBackend
159    // doesn't have API access for cross-project resolution.
160
161    // ============================================
162    // Commit operations
163    // ============================================
164
165    async fn find_commit(&self, hash: &str) -> WtgResult<CommitInfo> {
166        // Use smart find that can fetch on demand
167        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        // Add commit URL if we have repo info
174        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    // ============================================
183    // File operations
184    // ============================================
185
186    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    // ============================================
193    // Tag/Release operations
194    // ============================================
195
196    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, find previous by semver ordering
213        if current.is_semver() {
214            let mut semver_tags: Vec<_> = tags.iter().filter(|t| t.is_semver()).collect();
215
216            // Sort by semver (ascending)
217            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            // Find current position and return previous
224            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        // Non-semver: find most recent tag on an earlier commit
233        let current_timestamp = self.repo.get_commit_timestamp(&current.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        // Sort by timestamp descending (most recent first)
243        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    // ============================================
285    // URL generation
286    // ============================================
287
288    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}