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 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 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 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 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 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 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 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 self.stage_files(files)?;
247
248 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 let commit_id = self.create_commit(&commit_message)?;
260
261 let should_tag = matches!(mode, GitMode::CommitPushTag | GitMode::CommitTag);
263 if should_tag {
264 self.create_tag(&tag_name, commit_id)?;
265 }
266
267 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 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 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}