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            .canonicalize()?;
129        let mut index = self.repository.index()?;
130
131        for file in files {
132            let abs_path = file.canonicalize()
133                .with_context(|| format!("Failed to resolve path {:?}", file))?;
134            let relative = abs_path.strip_prefix(&repo_root)
135                .with_context(|| format!("File {:?} is not inside repository root {:?}", abs_path, repo_root))?;
136            if self.repository.is_path_ignored(relative).unwrap_or(false) {
137                debug!("Skipping gitignored file: {:?}", relative);
138                continue;
139            }
140            index.add_path(relative)?;
141
142            // If a Cargo.toml was staged, run `cargo update` and stage the Cargo.lock
143            if relative.file_name().is_some_and(|n| n.eq_ignore_ascii_case("Cargo.toml")) {
144                let cargo_dir = abs_path.parent().unwrap_or(&abs_path);
145                debug!("Running `cargo update` in {:?}", cargo_dir);
146                let status = std::process::Command::new("cargo")
147                    .arg("update")
148                    .arg("--workspace")
149                    .current_dir(cargo_dir)
150                    .stdout(std::process::Stdio::null())
151                    .stderr(std::process::Stdio::null())
152                    .status();
153                match status {
154                    Ok(s) if s.success() => debug!("cargo update succeeded"),
155                    Ok(s) => warn!("cargo update exited with status: {}", s),
156                    Err(e) => warn!("Failed to run cargo update: {}", e),
157                }
158
159                let lock_path = abs_path.with_file_name("Cargo.lock");
160                if lock_path.exists() {
161                    let lock_relative = lock_path.strip_prefix(&repo_root)
162                        .with_context(|| format!("File {:?} is not inside repository root {:?}", lock_path, repo_root))?;
163                    if !self.repository.is_path_ignored(lock_relative).unwrap_or(false) {
164                        debug!("Also staging Cargo.lock: {:?}", lock_relative);
165                        index.add_path(lock_relative)?;
166                    }
167                }
168            }
169        }
170        index.write()?;
171
172        debug!("Staged {} files", files.len());
173        Ok(())
174    }
175
176    /// Creates a commit with the given message
177    pub fn create_commit(&self, message: &str) -> Result<git2::Oid> {
178        info!("Creating commit: {}", message);
179
180        let mut index = self.repository.index()?;
181        let tree_id = index.write_tree()?;
182        let tree = self.repository.find_tree(tree_id)?;
183
184        let sig = self.get_signature()?;
185
186        let parent_commit = match self.repository.head() {
187            Ok(head) => Some(head.peel_to_commit()?),
188            Err(_) => {
189                warn!("No parent commit found - this will be the initial commit");
190                None
191            }
192        };
193
194        let parents: Vec<&git2::Commit> = parent_commit.iter().collect();
195
196        let commit_id = self.repository.commit(
197            Some("HEAD"),
198            &sig,
199            &sig,
200            message,
201            &tree,
202            &parents,
203        )?;
204
205        info!("Created commit: {}", commit_id);
206        Ok(commit_id)
207    }
208
209    /// Creates a tag for the given commit
210    pub fn create_tag(&self, tag_name: &str, commit_id: git2::Oid) -> Result<()> {
211        info!("Creating tag: {}", tag_name);
212
213        let sig = self.get_signature()?;
214        let commit_obj = self.repository
215            .find_object(commit_id, Some(git2::ObjectType::Commit))?;
216
217        self.repository.tag(
218            tag_name,
219            &commit_obj,
220            &sig,
221            &format!("Release {}", tag_name),
222            false,
223        )?;
224
225        info!("Created tag: {}", tag_name);
226        Ok(())
227    }
228
229    /// Pushes commits to the remote
230    pub fn push_commits(&self, remote_name: &str, branch: &str) -> Result<()> {
231        info!("Pushing commits to {}/{}", remote_name, branch);
232
233        let mut remote = self.repository.find_remote(remote_name)
234            .with_context(|| format!("Remote '{}' not found", remote_name))?;
235
236        let callbacks = Self::create_auth_callbacks(self.allow_insecure);
237        let mut push_options = PushOptions::new();
238        push_options.remote_callbacks(callbacks);
239
240        let refspec = format!("refs/heads/{}:refs/heads/{}", branch, branch);
241        remote.push(&[&refspec], Some(&mut push_options))?;
242
243        info!("Pushed commits to {}/{}", remote_name, branch);
244        Ok(())
245    }
246
247    /// Pushes a tag to the remote
248    pub fn push_tag(&self, remote_name: &str, tag_name: &str) -> Result<()> {
249        info!("Pushing tag {} to {}", tag_name, remote_name);
250
251        let mut remote = self.repository.find_remote(remote_name)
252            .with_context(|| format!("Remote '{}' not found", remote_name))?;
253
254        let callbacks = Self::create_auth_callbacks(self.allow_insecure);
255        let mut push_options = PushOptions::new();
256        push_options.remote_callbacks(callbacks);
257
258        let refspec = format!("refs/tags/{}:refs/tags/{}", tag_name, tag_name);
259        remote.push(&[&refspec], Some(&mut push_options))?;
260
261        info!("Pushed tag {} to {}", tag_name, remote_name);
262        Ok(())
263    }
264
265    /// Gets the current branch name
266    pub fn current_branch(&self) -> Result<String> {
267        let head = self.repository.head()?;
268        let branch_name = head.shorthand()
269            .ok_or_else(|| anyhow::anyhow!("Could not determine current branch"))?;
270        Ok(branch_name.to_string())
271    }
272
273    /// Executes git operations based on the GitMode and version
274    pub fn execute_git_mode(&self, mode: GitMode, version: &str, files: &[PathBuf]) -> Result<()> {
275        if mode == GitMode::None {
276            debug!("GitMode::None - skipping git operations");
277            return Ok(());
278        }
279
280        // Stage only the files that were modified by version updates
281        self.stage_files(files)?;
282
283        // Check if there are changes to commit
284        let statuses = self.repository.statuses(None)?;
285        if statuses.is_empty() {
286            warn!("No changes to commit");
287            return Ok(());
288        }
289
290        let commit_message = format!("chore: bump version to {}", version);
291        let tag_name = format!("v{}", version);
292
293        // Create commit for all modes except None
294        let commit_id = self.create_commit(&commit_message)?;
295
296        // Create tag if mode includes tagging
297        let should_tag = matches!(mode, GitMode::CommitPushTag | GitMode::CommitTag);
298        if should_tag {
299            self.create_tag(&tag_name, commit_id)?;
300        }
301
302        // Push if mode includes pushing
303        let should_push = matches!(mode, GitMode::CommitPush | GitMode::CommitPushTag);
304        if should_push {
305            let branch = self.current_branch()?;
306            self.push_commits("origin", &branch)?;
307
308            if should_tag {
309                self.push_tag("origin", &tag_name)?;
310            }
311        }
312
313        Ok(())
314    }
315
316    /// Fetches tags from the remote
317    pub fn fetch_tags(&self, remote_name: &str) -> Result<()> {
318        debug!("Fetching tags from {}", remote_name);
319
320        let mut remote = self.repository.find_remote(remote_name)?;
321
322        let callbacks = Self::create_auth_callbacks(self.allow_insecure);
323        let mut fetch_options = FetchOptions::new();
324        fetch_options.remote_callbacks(callbacks);
325
326        remote.fetch(&["refs/tags/*:refs/tags/*"], Some(&mut fetch_options), None)?;
327
328        debug!("Fetched tags from {}", remote_name);
329        Ok(())
330    }
331
332    /// Gets all tags from the repository
333    pub fn get_tags(&self) -> Result<Vec<String>> {
334        let mut tags = Vec::new();
335
336        self.repository.tag_foreach(|_oid, name| {
337            if let Ok(name_str) = std::str::from_utf8(name) {
338                let tag_name = name_str.trim_start_matches("refs/tags/");
339                tags.push(tag_name.to_string());
340            }
341            true
342        })?;
343
344        Ok(tags)
345    }
346}