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::error::{WtgError, WtgResult};
12use crate::git::{CommitInfo, FileInfo, GitRepo, TagInfo, looks_like_commit_hash};
13use crate::github::GitHubClient;
14use crate::parse_input::{ParsedQuery, Query};
15
16/// Pure local git backend wrapping a `GitRepo`.
17///
18/// Uses `GitRepo` for all operations including smart fetching.
19/// Cannot access GitHub API, so PR/Issue queries will return `Unsupported`.
20pub struct GitBackend {
21    repo: GitRepo,
22}
23
24impl GitBackend {
25    /// Create a `GitBackend` from an existing `GitRepo`.
26    #[must_use]
27    pub const fn new(repo: GitRepo) -> Self {
28        Self { repo }
29    }
30
31    /// Get a reference to the underlying `GitRepo`.
32    pub const fn git_repo(&self) -> &GitRepo {
33        &self.repo
34    }
35
36    /// Set the notice callback for emitting operational messages.
37    pub fn set_notice_callback(&mut self, cb: NoticeCallback) {
38        self.repo.set_notice_callback(cb);
39    }
40
41    /// Find tags containing a commit and pick the best one.
42    fn find_best_tag_for_commit(&self, commit_hash: &str) -> Option<TagInfo> {
43        let candidates = self.repo.tags_containing_commit(commit_hash);
44        if candidates.is_empty() {
45            return None;
46        }
47
48        // Build timestamp map for sorting
49        let timestamps: HashMap<String, i64> = candidates
50            .iter()
51            .map(|tag| {
52                (
53                    tag.commit_hash.clone(),
54                    self.repo.get_commit_timestamp(&tag.commit_hash),
55                )
56            })
57            .collect();
58
59        // Pick best tag: prefer semver releases, then semver, then any release, then any
60        Self::pick_best_tag(&candidates, &timestamps)
61    }
62
63    /// Pick the best tag from candidates based on priority rules.
64    fn pick_best_tag(candidates: &[TagInfo], timestamps: &HashMap<String, i64>) -> Option<TagInfo> {
65        fn select_with_pred<F>(
66            candidates: &[TagInfo],
67            timestamps: &HashMap<String, i64>,
68            predicate: F,
69        ) -> Option<TagInfo>
70        where
71            F: Fn(&TagInfo) -> bool,
72        {
73            candidates
74                .iter()
75                .filter(|tag| predicate(tag))
76                .min_by_key(|tag| {
77                    timestamps
78                        .get(&tag.commit_hash)
79                        .copied()
80                        .unwrap_or(i64::MAX)
81                })
82                .cloned()
83        }
84
85        // Priority: released semver > unreleased semver > released non-semver > unreleased non-semver
86        select_with_pred(candidates, timestamps, |t| t.is_release && t.is_semver())
87            .or_else(|| {
88                select_with_pred(candidates, timestamps, |t| !t.is_release && t.is_semver())
89            })
90            .or_else(|| {
91                select_with_pred(candidates, timestamps, |t| t.is_release && !t.is_semver())
92            })
93            .or_else(|| {
94                select_with_pred(candidates, timestamps, |t| !t.is_release && !t.is_semver())
95            })
96    }
97
98    fn disambiguate_input_string(&self, input: &str) -> WtgResult<Query> {
99        if self.repo.get_tags().iter().any(|tag| tag.name == input) {
100            return Ok(Query::Tag(input.to_string()));
101        }
102
103        if self.repo.has_path_at_head(input) {
104            return Ok(Query::FilePath {
105                branch: "HEAD".to_string(),
106                path: PathBuf::from(input),
107            });
108        }
109
110        if looks_like_commit_hash(input) && self.repo.find_commit_local(input).is_some() {
111            return Ok(Query::GitCommit(input.to_string()));
112        }
113
114        Err(WtgError::NotFound(input.to_string()))
115    }
116
117    fn disambiguate_unknown_path(&self, segments: &[String]) -> Option<Query> {
118        let (branch, remainder) = self.repo.find_branch_path_match(segments)?;
119        let mut path = PathBuf::new();
120        for segment in remainder {
121            path.push(segment);
122        }
123        Some(Query::FilePath { branch, path })
124    }
125}
126
127#[async_trait]
128impl Backend for GitBackend {
129    // Note: backend_for_pr() uses default (returns None) since GitBackend
130    // doesn't have API access for cross-project resolution.
131
132    // ============================================
133    // Commit operations
134    // ============================================
135
136    async fn find_commit(&self, hash: &str) -> WtgResult<CommitInfo> {
137        // Use smart find that can fetch on demand
138        self.repo
139            .find_commit(hash)?
140            .ok_or_else(|| WtgError::NotFound(hash.to_string()))
141    }
142
143    async fn enrich_commit(&self, mut commit: CommitInfo) -> CommitInfo {
144        // Add commit URL if we have repo info
145        if commit.commit_url.is_none()
146            && let Some(repo_info) = self.repo.github_remote()
147        {
148            commit.commit_url = Some(GitHubClient::commit_url(&repo_info, &commit.hash));
149        }
150        commit
151    }
152
153    // ============================================
154    // File operations
155    // ============================================
156
157    async fn find_file(&self, branch: &str, path: &str) -> WtgResult<FileInfo> {
158        self.repo
159            .find_file_on_branch(branch, path)
160            .ok_or_else(|| WtgError::NotFound(path.to_string()))
161    }
162
163    // ============================================
164    // Tag/Release operations
165    // ============================================
166
167    async fn find_tag(&self, name: &str) -> WtgResult<TagInfo> {
168        self.repo
169            .get_tags()
170            .into_iter()
171            .find(|t| t.name == name)
172            .ok_or_else(|| WtgError::NotFound(name.to_string()))
173    }
174
175    async fn disambiguate_query(&self, query: &ParsedQuery) -> WtgResult<Query> {
176        match query {
177            ParsedQuery::Resolved(resolved) => Ok(resolved.clone()),
178            ParsedQuery::Unknown(input) => self.disambiguate_input_string(input),
179            ParsedQuery::UnknownPath { segments } => self
180                .disambiguate_unknown_path(segments)
181                .ok_or_else(|| WtgError::NotFound(segments.join("/"))),
182        }
183    }
184
185    async fn find_release_for_commit(
186        &self,
187        commit_hash: &str,
188        _commit_date: Option<DateTime<Utc>>,
189    ) -> Option<TagInfo> {
190        self.find_best_tag_for_commit(commit_hash)
191    }
192
193    // ============================================
194    // URL generation
195    // ============================================
196
197    fn commit_url(&self, hash: &str) -> Option<String> {
198        self.repo
199            .github_remote()
200            .map(|ri| GitHubClient::commit_url(&ri, hash))
201    }
202
203    fn tag_url(&self, tag: &str) -> Option<String> {
204        self.repo
205            .github_remote()
206            .map(|ri| GitHubClient::tag_url(&ri, tag))
207    }
208
209    fn author_url_from_email(&self, email: &str) -> Option<String> {
210        GitHubClient::author_url_from_email(email)
211    }
212}