1use async_trait::async_trait;
4use chrono::{DateTime, TimeZone, Utc};
5use git2::{Oid, Repository, StatusOptions, Time};
6use std::path::{Path, PathBuf};
7use std::sync::{Arc, Mutex};
8
9use governor_core::domain::commit::Commit;
10use governor_core::domain::version::SemanticVersion;
11use governor_core::domain::workspace::WorkingTreeStatus;
12use governor_core::traits::source_control::{ScmConfig, ScmError, SourceControl};
13
14#[derive(Debug, Clone)]
16pub struct GitAdapterConfig {
17 pub repository_path: Option<PathBuf>,
19 pub default_remote: String,
21 pub sign_commits: bool,
23 pub sign_tags: bool,
25 pub commit_template: Option<String>,
27 pub tag_template: Option<String>,
29}
30
31impl Default for GitAdapterConfig {
32 fn default() -> Self {
33 Self {
34 repository_path: None,
35 default_remote: "origin".to_string(),
36 sign_commits: false,
37 sign_tags: false,
38 commit_template: Some("chore(release): bump version to {{version}}".to_string()),
39 tag_template: Some("v{{version}}".to_string()),
40 }
41 }
42}
43
44impl From<ScmConfig> for GitAdapterConfig {
45 fn from(config: ScmConfig) -> Self {
46 Self {
47 repository_path: config.repository_path,
48 default_remote: config.default_remote,
49 sign_commits: config.sign_commits,
50 sign_tags: config.sign_tags,
51 commit_template: config.commit_template,
52 tag_template: config.tag_template,
53 }
54 }
55}
56
57pub struct GitAdapter {
59 repo: Arc<Mutex<Repository>>,
60 config: GitAdapterConfig,
61}
62
63impl GitAdapter {
68 pub fn open(config: GitAdapterConfig) -> Result<Self, ScmError> {
74 let path = config
75 .repository_path
76 .clone()
77 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
78
79 let repo = Repository::discover(&path)
80 .map_err(|e| ScmError::NotFound(format!("Failed to discover git repo: {e}")))?;
81
82 Ok(Self {
83 repo: Arc::new(Mutex::new(repo)),
84 config,
85 })
86 }
87
88 #[must_use]
90 pub fn repository(&self) -> Arc<Mutex<Repository>> {
91 Arc::clone(&self.repo)
92 }
93
94 fn with_repo<F, R>(&self, f: F) -> R
96 where
97 F: FnOnce(&Repository) -> R,
98 {
99 let repo = self.repo.lock().unwrap();
100 f(&repo)
101 }
102
103 fn resolve_reference_target(
122 repo: &Repository,
123 reference_name: &str,
124 ) -> Result<Option<git2::Oid>, ScmError> {
125 let reference = repo
127 .find_reference(reference_name)
128 .or_else(|_| repo.resolve_reference_from_short_name(reference_name))
129 .map_err(|e| {
130 ScmError::GitError(format!(
131 "Failed to resolve git reference `{reference_name}`: {e}. \
132 Ensure the reference exists in the repository."
133 ))
134 })?;
135
136 let target_oid = reference
139 .peel_to_tag()
140 .ok()
141 .map(|tag_obj| tag_obj.target_id())
142 .or_else(|| {
143 reference.peel(git2::ObjectType::Any).ok().and_then(|obj| {
144 obj.as_commit()
145 .map(git2::Commit::id)
146 .or_else(|| obj.as_tag().map(git2::Tag::target_id))
147 .or_else(|| Some(obj.id()))
148 })
149 });
150
151 Ok(target_oid)
152 }
153
154 fn git_time_to_datetime(time: &Time) -> DateTime<Utc> {
156 Utc.timestamp_opt(time.seconds(), 0).unwrap()
157 }
158
159 fn parse_commit(commit: &git2::Commit) -> Commit {
161 Commit::new(
162 commit.id().to_string(),
163 commit.message().unwrap_or("").to_string(),
164 commit.author().name().unwrap_or("").to_string(),
165 commit.author().email().unwrap_or("").to_string(),
166 Self::git_time_to_datetime(&commit.time()),
167 )
168 }
169
170 #[must_use]
172 pub fn repo_root(&self) -> PathBuf {
173 self.with_repo(|repo| {
174 repo.workdir()
175 .map_or_else(|| PathBuf::from("."), std::path::Path::to_path_buf)
176 })
177 }
178}
179
180#[async_trait]
181impl SourceControl for GitAdapter {
182 fn name(&self) -> &'static str {
183 "git"
184 }
185
186 async fn get_commits_since(&self, tag: Option<&str>) -> Result<Vec<Commit>, ScmError> {
187 let (start_oid_str, end_oid_str, _tag_name) = self.with_repo(|repo| {
189 let head = repo.head().map_err(|e| ScmError::GitError(e.to_string()))?;
190
191 let start_oid = head.target().ok_or(ScmError::NoCommits)?;
192
193 let tag_name = tag.map(std::string::ToString::to_string);
194
195 let end_oid_str = if let Some(tag_name) = &tag_name {
197 let target_oid = Self::resolve_reference_target(repo, tag_name).map_err(|_| {
198 ScmError::TagNotFound(format!("Tag `{tag_name}` not found in repository"))
199 })?;
200
201 Some(
202 target_oid
203 .map(|id: git2::Oid| id.to_string())
204 .unwrap_or_default(),
205 )
206 } else {
207 None
208 };
209
210 Ok::<(String, Option<String>, Option<String>), ScmError>((
211 start_oid.to_string(),
212 end_oid_str,
213 tag_name,
214 ))
215 })?;
216
217 let end_oid_str = if end_oid_str.is_none() {
219 match self.get_last_tag(None).await {
220 Ok(Some(prev_tag)) => self.with_repo(|repo| {
221 let target_oid =
222 Self::resolve_reference_target(repo, &prev_tag).map_err(|e| {
223 ScmError::GitError(format!(
224 "Failed to resolve tag `{prev_tag}` while finding commits: {e}"
225 ))
226 })?;
227
228 Ok::<Option<String>, ScmError>(target_oid.map(|id: git2::Oid| id.to_string()))
229 })?,
230 _ => None,
231 }
232 } else {
233 end_oid_str
234 };
235
236 let commits = self.with_repo(|repo| {
238 let start_oid = git2::Oid::from_str(&start_oid_str)
239 .map_err(|e| ScmError::GitError(e.to_string()))?;
240
241 let end_oid = end_oid_str
242 .as_ref()
243 .and_then(|s| git2::Oid::from_str(s).ok());
244
245 let mut revwalk = repo
246 .revwalk()
247 .map_err(|e| ScmError::GitError(e.to_string()))?;
248
249 revwalk
250 .push(start_oid)
251 .map_err(|e| ScmError::GitError(e.to_string()))?;
252
253 let mut result = Vec::new();
254
255 for oid in revwalk {
256 let oid = oid.map_err(|e| ScmError::GitError(e.to_string()))?;
257
258 if let Some(end) = end_oid
260 && oid == end
261 {
262 break;
263 }
264
265 let commit = repo
266 .find_commit(oid)
267 .map_err(|e| ScmError::GitError(e.to_string()))?;
268
269 result.push(Self::parse_commit(&commit));
270 }
271
272 Ok::<Vec<Commit>, ScmError>(result)
273 })?;
274
275 Ok(commits)
276 }
277
278 async fn get_last_tag(&self, pattern: Option<&str>) -> Result<Option<String>, ScmError> {
279 let tags = self.with_repo(|repo| {
280 let tag_names = repo
281 .tag_names(None)
282 .map_err(|e| ScmError::GitError(e.to_string()))?;
283
284 let pattern_re = pattern.map(|p| regex::Regex::new(&format!("^{p}")).unwrap());
285
286 let mut tags: Vec<String> = tag_names
287 .iter()
288 .filter_map(|t| t.map(std::string::ToString::to_string))
289 .filter(|t| pattern_re.as_ref().is_none_or(|re| re.is_match(t)))
290 .collect();
291
292 tags.sort_by(|a, b| {
294 let v_a = SemanticVersion::parse(a.trim_start_matches('v'));
295 let v_b = SemanticVersion::parse(b.trim_start_matches('v'));
296 match (v_a, v_b) {
297 (Ok(va), Ok(vb)) => vb.version.cmp(&va.version),
298 _ => b.cmp(a),
299 }
300 });
301
302 Ok::<Vec<String>, ScmError>(tags)
303 })?;
304
305 Ok(tags.into_iter().next())
306 }
307
308 async fn create_tag(&self, name: &str, message: &str) -> Result<String, ScmError> {
309 self.with_repo(|repo| {
310 let head = repo
311 .head()
312 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
313
314 let target = head
315 .peel_to_commit()
316 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
317
318 let sig = repo
319 .signature()
320 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
321
322 let _oid = repo
323 .tag(
324 name,
325 target.as_object(),
326 &sig,
327 message,
328 self.config.sign_tags,
329 )
330 .map_err(|e: git2::Error| {
331 ScmError::GitError(format!("Failed to create tag: {e}"))
332 })?;
333
334 Ok::<(), ScmError>(())
335 })?;
336
337 Ok(name.to_string())
338 }
339
340 async fn delete_tag(&self, name: &str) -> Result<(), ScmError> {
341 self.with_repo(|repo| {
342 let mut reference = repo
343 .find_reference(&format!("refs/tags/{name}"))
344 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
345
346 reference
347 .delete()
348 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
349
350 Ok::<(), ScmError>(())
351 })
352 }
353
354 async fn get_current_commit(&self) -> Result<String, ScmError> {
355 self.with_repo(|repo| {
356 let head = repo
357 .head()
358 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
359
360 Ok::<String, ScmError>(head.target().ok_or(ScmError::NoCommits)?.to_string())
361 })
362 }
363
364 async fn get_current_branch(&self) -> Result<String, ScmError> {
365 self.with_repo(|repo| {
366 let head = repo
367 .head()
368 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
369
370 let branch = if head.is_branch() {
371 head.shorthand().unwrap_or("unknown").to_string()
372 } else {
373 "HEAD".to_string()
374 };
375
376 Ok::<String, ScmError>(branch)
377 })
378 }
379
380 async fn get_working_tree_status(&self) -> Result<WorkingTreeStatus, ScmError> {
381 self.with_repo(|repo| {
382 let mut status_opts = StatusOptions::new();
383 status_opts
384 .include_untracked(true)
385 .recurse_untracked_dirs(true);
386
387 let statuses = repo
388 .statuses(Some(&mut status_opts))
389 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
390
391 let mut working_tree = WorkingTreeStatus {
392 has_changes: false,
393 modified: Vec::new(),
394 added: Vec::new(),
395 deleted: Vec::new(),
396 untracked: Vec::new(),
397 };
398
399 for entry in statuses.iter() {
400 let path = entry.path().unwrap_or("").to_string();
401 let status = entry.status();
402
403 if status.is_index_new() || status.is_wt_new() {
404 working_tree.untracked.push(path.clone());
405 }
406 if status.is_index_modified() || status.is_wt_modified() {
407 working_tree.modified.push(path.clone());
408 }
409 if status.is_index_deleted() || status.is_wt_deleted() {
410 working_tree.deleted.push(path.clone());
411 }
412 if status.is_index_renamed() || status.is_wt_renamed() {
413 working_tree.added.push(path);
414 }
415 }
416
417 working_tree.has_changes = !working_tree.modified.is_empty()
418 || !working_tree.added.is_empty()
419 || !working_tree.deleted.is_empty()
420 || !working_tree.untracked.is_empty();
421
422 Ok::<WorkingTreeStatus, ScmError>(working_tree)
423 })
424 }
425
426 async fn commit(&self, message: &str, files: &[String]) -> Result<String, ScmError> {
427 let files_to_check: Vec<PathBuf> = files
428 .iter()
429 .filter_map(|f| {
430 let p = Path::new(f);
431 if p.exists() {
432 Some(p.to_path_buf())
433 } else {
434 None
435 }
436 })
437 .collect();
438
439 self.with_repo(|repo| {
440 let mut index = repo
441 .index()
442 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
443
444 for path in &files_to_check {
446 index
447 .add_path(path)
448 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
449 }
450
451 index
452 .write()
453 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
454
455 let tree_id = index
456 .write_tree()
457 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
458
459 let tree = repo
460 .find_tree(tree_id)
461 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
462
463 let head = repo
465 .head()
466 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
467 let parent_commit = head
468 .peel_to_commit()
469 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
470
471 let sig = repo
472 .signature()
473 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
474
475 let oid = repo
476 .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent_commit])
477 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
478
479 Ok::<String, ScmError>(oid.to_string())
480 })
481 }
482
483 async fn stage_files(&self, files: &[String]) -> Result<(), ScmError> {
484 let files_to_stage: Vec<PathBuf> = files
485 .iter()
486 .filter_map(|f| {
487 let p = Path::new(f);
488 if p.exists() {
489 Some(p.to_path_buf())
490 } else {
491 None
492 }
493 })
494 .collect();
495
496 self.with_repo(|repo| {
497 let mut index = repo
498 .index()
499 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
500
501 for path in &files_to_stage {
502 index
503 .add_path(path)
504 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
505 }
506
507 index
508 .write()
509 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
510
511 Ok::<(), ScmError>(())
512 })
513 }
514
515 async fn push(&self, remote: Option<&str>, branch: Option<&str>) -> Result<(), ScmError> {
516 let remote_name = remote.unwrap_or(&self.config.default_remote).to_string();
517 let branch_name = branch.unwrap_or("main").to_string();
518
519 self.with_repo(|repo| {
520 let mut remote_obj = repo
521 .find_remote(&remote_name)
522 .map_err(|_| ScmError::GitError(format!("Remote '{remote_name}' not found")))?;
523
524 let branch_ref = format!("refs/heads/{branch_name}");
525
526 remote_obj
527 .push(&[&branch_ref], None)
528 .map_err(|e: git2::Error| ScmError::GitError(format!("Push failed: {e}")))?;
529
530 Ok::<(), ScmError>(())
531 })
532 }
533
534 fn repository_root(&self) -> Option<&Path> {
535 None
540 }
541
542 async fn is_on_branch(&self, branch: &str) -> Result<bool, ScmError> {
543 let current = self.get_current_branch().await?;
544 Ok(current == branch)
545 }
546
547 async fn get_tags_for_commit(&self, commit_hash: &str) -> Result<Vec<String>, ScmError> {
548 let oid = Oid::from_str(commit_hash)
549 .map_err(|e| ScmError::GitError(format!("Invalid commit hash `{commit_hash}`: {e}")))?;
550
551 self.with_repo(|repo| {
552 let tag_names = repo
553 .tag_names(None)
554 .map_err(|e| ScmError::GitError(format!("Failed to get tag names: {e}")))?;
555
556 let mut tags = Vec::new();
557
558 for tag_name in tag_names.iter().flatten() {
559 if let Ok(Some(target_oid)) = Self::resolve_reference_target(repo, tag_name)
560 && target_oid == oid
561 {
562 tags.push(tag_name.to_string());
563 }
564 }
565
566 Ok::<Vec<String>, ScmError>(tags)
567 })
568 }
569
570 async fn get_remote_url(&self, remote: Option<&str>) -> Result<Option<String>, ScmError> {
571 let remote_name = remote.unwrap_or(&self.config.default_remote).to_string();
572
573 self.with_repo(|repo| {
574 let remote_obj = repo
575 .find_remote(&remote_name)
576 .map_err(|_| ScmError::GitError(format!("Remote '{remote_name}' not found")))?;
577
578 Ok::<Option<String>, ScmError>(remote_obj.url().map(String::from))
579 })
580 }
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586
587 #[test]
588 fn test_git_adapter_config_default() {
589 let config = GitAdapterConfig::default();
590 assert_eq!(config.default_remote, "origin");
591 assert!(!config.sign_commits);
592 assert!(!config.sign_tags);
593 }
594}