Skip to main content

update_version/
git.rs

1use anyhow::{Context, Result};
2use git2::{Cred, CredentialType, FetchOptions, PushOptions, RemoteCallbacks, Repository, Signature};
3use log::{debug, info, warn};
4use std::cell::Cell;
5use std::path::{Path, PathBuf};
6
7use crate::arguments::GitMode;
8
9pub struct GitTracker {
10    pub repository: Repository,
11    pub allow_insecure: bool,
12}
13
14impl GitTracker {
15    /// Opens an existing repository at the given path
16    pub fn open(path: impl AsRef<Path>, allow_insecure: bool) -> Result<Self> {
17        let path = path.as_ref();
18        let repository = Repository::discover(path)
19            .with_context(|| format!("Failed to find git repository at {:?}", path))?;
20
21        debug!("Opened repository at {:?}", repository.path());
22
23        Ok(GitTracker { repository, allow_insecure })
24    }
25
26    /// Creates authentication callbacks that use local git credentials
27    fn create_auth_callbacks(allow_insecure: bool) -> RemoteCallbacks<'static> {
28        let mut callbacks = RemoteCallbacks::new();
29        let attempts = Cell::new(0u32);
30
31        callbacks.credentials(move |url, username_from_url, allowed_types| {
32            let attempt = attempts.get() + 1;
33            attempts.set(attempt);
34            debug!(
35                "Credentials callback attempt {}: url={}, username_from_url={:?}, allowed_types={:?}",
36                attempt, url, username_from_url, allowed_types
37            );
38
39            // Prevent infinite loops
40            if attempt > 5 {
41                warn!("Too many credential attempts, authentication likely failing");
42                return Err(git2::Error::from_str("authentication failed after multiple attempts"));
43            }
44
45            let username = username_from_url.unwrap_or("git");
46
47            // Try SSH agent first if SSH is allowed
48            if allowed_types.contains(CredentialType::SSH_KEY) {
49                debug!("Trying SSH agent authentication");
50                if let Ok(cred) = Cred::ssh_key_from_agent(username) {
51                    return Ok(cred);
52                }
53
54                // Try default SSH key locations
55                let home = dirs::home_dir();
56                if let Some(ref home) = home {
57                    let ssh_dir = home.join(".ssh");
58
59                    // Try common key names
60                    for key_name in &["id_ed25519", "id_rsa", "id_ecdsa"] {
61                        let private_key = ssh_dir.join(key_name);
62                        let public_key = ssh_dir.join(format!("{}.pub", key_name));
63
64                        if private_key.exists() {
65                            debug!("Trying SSH key: {:?}", private_key);
66                            if let Ok(cred) = Cred::ssh_key(
67                                username,
68                                if public_key.exists() { Some(public_key.as_path()) } else { None },
69                                &private_key,
70                                None,
71                            ) {
72                                return Ok(cred);
73                            }
74                        }
75                    }
76                }
77            }
78
79            // Try credential helper for HTTPS
80            if allowed_types.contains(CredentialType::USER_PASS_PLAINTEXT) {
81                debug!("Trying credential helper");
82                if let Ok(cred) = Cred::credential_helper(
83                    &git2::Config::open_default()?,
84                    url,
85                    username_from_url,
86                ) {
87                    return Ok(cred);
88                }
89            }
90
91            // Try default credentials as last resort
92            if allowed_types.contains(CredentialType::DEFAULT) {
93                debug!("Trying default credentials");
94                return Cred::default();
95            }
96
97            Err(git2::Error::from_str("no suitable credentials found"))
98        });
99
100        if allow_insecure {
101            callbacks.certificate_check(|_cert, _host| Ok(git2::CertificateCheckStatus::CertificateOk));
102        }
103
104        callbacks
105    }
106
107    /// Gets the repository signature from local git config
108    fn get_signature(&self) -> Result<Signature<'_>> {
109        self.repository.signature()
110            .context("Failed to get git signature. Please configure user.name and user.email in git config")
111    }
112
113    /// Stages all modified and new files in the repository
114    pub fn stage_all(&self) -> Result<()> {
115        let mut index = self.repository.index()?;
116
117        index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
118        index.write()?;
119
120        debug!("Staged all changes");
121        Ok(())
122    }
123
124    /// Stages only the specified files in the repository
125    pub fn stage_files(&self, files: &[PathBuf]) -> Result<()> {
126        let repo_root = self.repository.workdir()
127            .ok_or_else(|| anyhow::anyhow!("Bare repository not supported"))?;
128        let mut index = self.repository.index()?;
129
130        for file in files {
131            let relative = file.strip_prefix(repo_root)
132                .with_context(|| format!("File {:?} is not inside repository root {:?}", file, repo_root))?;
133            index.add_path(relative)?;
134        }
135        index.write()?;
136
137        debug!("Staged {} files", files.len());
138        Ok(())
139    }
140
141    /// Creates a commit with the given message
142    pub fn create_commit(&self, message: &str) -> Result<git2::Oid> {
143        info!("Creating commit: {}", message);
144
145        let mut index = self.repository.index()?;
146        let tree_id = index.write_tree()?;
147        let tree = self.repository.find_tree(tree_id)?;
148
149        let sig = self.get_signature()?;
150
151        let parent_commit = match self.repository.head() {
152            Ok(head) => Some(head.peel_to_commit()?),
153            Err(_) => {
154                warn!("No parent commit found - this will be the initial commit");
155                None
156            }
157        };
158
159        let parents: Vec<&git2::Commit> = parent_commit.iter().collect();
160
161        let commit_id = self.repository.commit(
162            Some("HEAD"),
163            &sig,
164            &sig,
165            message,
166            &tree,
167            &parents,
168        )?;
169
170        info!("Created commit: {}", commit_id);
171        Ok(commit_id)
172    }
173
174    /// Creates a tag for the given commit
175    pub fn create_tag(&self, tag_name: &str, commit_id: git2::Oid) -> Result<()> {
176        info!("Creating tag: {}", tag_name);
177
178        let sig = self.get_signature()?;
179        let commit_obj = self.repository
180            .find_object(commit_id, Some(git2::ObjectType::Commit))?;
181
182        self.repository.tag(
183            tag_name,
184            &commit_obj,
185            &sig,
186            &format!("Release {}", tag_name),
187            false,
188        )?;
189
190        info!("Created tag: {}", tag_name);
191        Ok(())
192    }
193
194    /// Pushes commits to the remote
195    pub fn push_commits(&self, remote_name: &str, branch: &str) -> Result<()> {
196        info!("Pushing commits to {}/{}", remote_name, branch);
197
198        let mut remote = self.repository.find_remote(remote_name)
199            .with_context(|| format!("Remote '{}' not found", remote_name))?;
200
201        let callbacks = Self::create_auth_callbacks(self.allow_insecure);
202        let mut push_options = PushOptions::new();
203        push_options.remote_callbacks(callbacks);
204
205        let refspec = format!("refs/heads/{}:refs/heads/{}", branch, branch);
206        remote.push(&[&refspec], Some(&mut push_options))?;
207
208        info!("Pushed commits to {}/{}", remote_name, branch);
209        Ok(())
210    }
211
212    /// Pushes a tag to the remote
213    pub fn push_tag(&self, remote_name: &str, tag_name: &str) -> Result<()> {
214        info!("Pushing tag {} to {}", tag_name, remote_name);
215
216        let mut remote = self.repository.find_remote(remote_name)
217            .with_context(|| format!("Remote '{}' not found", remote_name))?;
218
219        let callbacks = Self::create_auth_callbacks(self.allow_insecure);
220        let mut push_options = PushOptions::new();
221        push_options.remote_callbacks(callbacks);
222
223        let refspec = format!("refs/tags/{}:refs/tags/{}", tag_name, tag_name);
224        remote.push(&[&refspec], Some(&mut push_options))?;
225
226        info!("Pushed tag {} to {}", tag_name, remote_name);
227        Ok(())
228    }
229
230    /// Gets the current branch name
231    pub fn current_branch(&self) -> Result<String> {
232        let head = self.repository.head()?;
233        let branch_name = head.shorthand()
234            .ok_or_else(|| anyhow::anyhow!("Could not determine current branch"))?;
235        Ok(branch_name.to_string())
236    }
237
238    /// Executes git operations based on the GitMode and version
239    pub fn execute_git_mode(&self, mode: GitMode, version: &str, files: &[PathBuf]) -> Result<()> {
240        if mode == GitMode::None {
241            debug!("GitMode::None - skipping git operations");
242            return Ok(());
243        }
244
245        // Stage only the files that were modified by version updates
246        self.stage_files(files)?;
247
248        // Check if there are changes to commit
249        let statuses = self.repository.statuses(None)?;
250        if statuses.is_empty() {
251            warn!("No changes to commit");
252            return Ok(());
253        }
254
255        let commit_message = format!("chore: bump version to {}", version);
256        let tag_name = format!("v{}", version);
257
258        // Create commit for all modes except None
259        let commit_id = self.create_commit(&commit_message)?;
260
261        // Create tag if mode includes tagging
262        let should_tag = matches!(mode, GitMode::CommitPushTag | GitMode::CommitTag);
263        if should_tag {
264            self.create_tag(&tag_name, commit_id)?;
265        }
266
267        // Push if mode includes pushing
268        let should_push = matches!(mode, GitMode::CommitPush | GitMode::CommitPushTag);
269        if should_push {
270            let branch = self.current_branch()?;
271            self.push_commits("origin", &branch)?;
272
273            if should_tag {
274                self.push_tag("origin", &tag_name)?;
275            }
276        }
277
278        Ok(())
279    }
280
281    /// Fetches tags from the remote
282    pub fn fetch_tags(&self, remote_name: &str) -> Result<()> {
283        debug!("Fetching tags from {}", remote_name);
284
285        let mut remote = self.repository.find_remote(remote_name)?;
286
287        let callbacks = Self::create_auth_callbacks(self.allow_insecure);
288        let mut fetch_options = FetchOptions::new();
289        fetch_options.remote_callbacks(callbacks);
290
291        remote.fetch(&["refs/tags/*:refs/tags/*"], Some(&mut fetch_options), None)?;
292
293        debug!("Fetched tags from {}", remote_name);
294        Ok(())
295    }
296
297    /// Gets all tags from the repository
298    pub fn get_tags(&self) -> Result<Vec<String>> {
299        let mut tags = Vec::new();
300
301        self.repository.tag_foreach(|_oid, name| {
302            if let Ok(name_str) = std::str::from_utf8(name) {
303                let tag_name = name_str.trim_start_matches("refs/tags/");
304                tags.push(tag_name.to_string());
305            }
306            true
307        })?;
308
309        Ok(tags)
310    }
311}