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 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 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 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 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 let home = dirs::home_dir();
56 if let Some(ref home) = home {
57 let ssh_dir = home.join(".ssh");
58
59 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 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 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 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 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 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 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 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 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 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 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 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 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 self.stage_files(files)?;
282
283 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 let commit_id = self.create_commit(&commit_message)?;
295
296 let should_tag = matches!(mode, GitMode::CommitPushTag | GitMode::CommitTag);
298 if should_tag {
299 self.create_tag(&tag_name, commit_id)?;
300 }
301
302 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 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 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}