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 git_time_to_datetime(time: &Time) -> DateTime<Utc> {
105 Utc.timestamp_opt(time.seconds(), 0).unwrap()
106 }
107
108 fn parse_commit(commit: &git2::Commit) -> Commit {
110 Commit::new(
111 commit.id().to_string(),
112 commit.message().unwrap_or("").to_string(),
113 commit.author().name().unwrap_or("").to_string(),
114 commit.author().email().unwrap_or("").to_string(),
115 Self::git_time_to_datetime(&commit.time()),
116 )
117 }
118
119 #[must_use]
121 pub fn repo_root(&self) -> PathBuf {
122 self.with_repo(|repo| {
123 repo.workdir()
124 .map_or_else(|| PathBuf::from("."), std::path::Path::to_path_buf)
125 })
126 }
127}
128
129#[async_trait]
130impl SourceControl for GitAdapter {
131 fn name(&self) -> &'static str {
132 "git"
133 }
134
135 async fn get_commits_since(&self, tag: Option<&str>) -> Result<Vec<Commit>, ScmError> {
136 let (start_oid_str, end_oid_str, _tag_name) = self.with_repo(|repo| {
138 let head = repo.head().map_err(|e| ScmError::GitError(e.to_string()))?;
139
140 let start_oid = head.target().ok_or(ScmError::NoCommits)?;
141
142 let tag_name = tag.map(std::string::ToString::to_string);
143
144 let end_oid_str = if let Some(tag_name) = &tag_name {
146 let reference = repo
148 .find_reference(tag_name)
149 .or_else(|_| repo.resolve_reference_from_short_name(tag_name))
150 .map_err(|_| ScmError::TagNotFound(tag_name.clone()))?;
151
152 let target_oid = reference
155 .peel_to_tag()
156 .ok()
157 .map(|tag_obj| tag_obj.target_id())
158 .or_else(|| {
159 reference.peel(git2::ObjectType::Any).ok().and_then(|obj| {
160 obj.as_commit()
161 .map(git2::Commit::id)
162 .or_else(|| obj.as_tag().map(git2::Tag::target_id))
163 .or_else(|| Some(obj.id()))
164 })
165 });
166
167 Some(
168 target_oid
169 .map(|id: git2::Oid| id.to_string())
170 .unwrap_or_default(),
171 )
172 } else {
173 None
174 };
175
176 Ok::<(String, Option<String>, Option<String>), ScmError>((
177 start_oid.to_string(),
178 end_oid_str,
179 tag_name,
180 ))
181 })?;
182
183 let end_oid_str = if end_oid_str.is_none() {
185 match self.get_last_tag(None).await {
186 Ok(Some(prev_tag)) => self.with_repo(|repo| {
187 let reference = repo
188 .resolve_reference_from_short_name(&prev_tag)
189 .or_else(|_| repo.find_reference(&prev_tag))
190 .map_err(|_| ScmError::GitError("Could not resolve tag".to_string()))?;
191
192 let target_oid = reference
193 .peel_to_tag()
194 .ok()
195 .map(|tag_obj| tag_obj.target_id())
196 .or_else(|| {
197 reference.peel(git2::ObjectType::Any).ok().and_then(|obj| {
198 obj.as_commit()
199 .map(git2::Commit::id)
200 .or_else(|| obj.as_tag().map(git2::Tag::target_id))
201 .or_else(|| Some(obj.id()))
202 })
203 });
204
205 Ok::<Option<String>, ScmError>(target_oid.map(|id: git2::Oid| id.to_string()))
206 })?,
207 _ => None,
208 }
209 } else {
210 end_oid_str
211 };
212
213 let commits = self.with_repo(|repo| {
215 let start_oid = git2::Oid::from_str(&start_oid_str)
216 .map_err(|e| ScmError::GitError(e.to_string()))?;
217
218 let end_oid = end_oid_str
219 .as_ref()
220 .and_then(|s| git2::Oid::from_str(s).ok());
221
222 let mut revwalk = repo
223 .revwalk()
224 .map_err(|e| ScmError::GitError(e.to_string()))?;
225
226 revwalk
227 .push(start_oid)
228 .map_err(|e| ScmError::GitError(e.to_string()))?;
229
230 let mut result = Vec::new();
231
232 for oid in revwalk {
233 let oid = oid.map_err(|e| ScmError::GitError(e.to_string()))?;
234
235 if let Some(end) = end_oid
237 && oid == end
238 {
239 break;
240 }
241
242 let commit = repo
243 .find_commit(oid)
244 .map_err(|e| ScmError::GitError(e.to_string()))?;
245
246 result.push(Self::parse_commit(&commit));
247 }
248
249 Ok::<Vec<Commit>, ScmError>(result)
250 })?;
251
252 Ok(commits)
253 }
254
255 async fn get_last_tag(&self, pattern: Option<&str>) -> Result<Option<String>, ScmError> {
256 let tags = self.with_repo(|repo| {
257 let tag_names = repo
258 .tag_names(None)
259 .map_err(|e| ScmError::GitError(e.to_string()))?;
260
261 let pattern_re = pattern.map(|p| regex::Regex::new(&format!("^{p}")).unwrap());
262
263 let mut tags: Vec<String> = tag_names
264 .iter()
265 .filter_map(|t| t.map(std::string::ToString::to_string))
266 .filter(|t| pattern_re.as_ref().is_none_or(|re| re.is_match(t)))
267 .collect();
268
269 tags.sort_by(|a, b| {
271 let v_a = SemanticVersion::parse(a.trim_start_matches('v'));
272 let v_b = SemanticVersion::parse(b.trim_start_matches('v'));
273 match (v_a, v_b) {
274 (Ok(va), Ok(vb)) => vb.version.cmp(&va.version),
275 _ => b.cmp(a),
276 }
277 });
278
279 Ok::<Vec<String>, ScmError>(tags)
280 })?;
281
282 Ok(tags.into_iter().next())
283 }
284
285 async fn create_tag(&self, name: &str, message: &str) -> Result<String, ScmError> {
286 self.with_repo(|repo| {
287 let head = repo
288 .head()
289 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
290
291 let target = head
292 .peel_to_commit()
293 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
294
295 let sig = repo
296 .signature()
297 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
298
299 let _oid = repo
300 .tag(
301 name,
302 target.as_object(),
303 &sig,
304 message,
305 self.config.sign_tags,
306 )
307 .map_err(|e: git2::Error| {
308 ScmError::GitError(format!("Failed to create tag: {e}"))
309 })?;
310
311 Ok::<(), ScmError>(())
312 })?;
313
314 Ok(name.to_string())
315 }
316
317 async fn delete_tag(&self, name: &str) -> Result<(), ScmError> {
318 self.with_repo(|repo| {
319 let mut reference = repo
320 .find_reference(&format!("refs/tags/{name}"))
321 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
322
323 reference
324 .delete()
325 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
326
327 Ok::<(), ScmError>(())
328 })
329 }
330
331 async fn get_current_commit(&self) -> Result<String, ScmError> {
332 self.with_repo(|repo| {
333 let head = repo
334 .head()
335 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
336
337 Ok::<String, ScmError>(head.target().ok_or(ScmError::NoCommits)?.to_string())
338 })
339 }
340
341 async fn get_current_branch(&self) -> Result<String, ScmError> {
342 self.with_repo(|repo| {
343 let head = repo
344 .head()
345 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
346
347 let branch = if head.is_branch() {
348 head.shorthand().unwrap_or("unknown").to_string()
349 } else {
350 "HEAD".to_string()
351 };
352
353 Ok::<String, ScmError>(branch)
354 })
355 }
356
357 async fn get_working_tree_status(&self) -> Result<WorkingTreeStatus, ScmError> {
358 self.with_repo(|repo| {
359 let mut status_opts = StatusOptions::new();
360 status_opts
361 .include_untracked(true)
362 .recurse_untracked_dirs(true);
363
364 let statuses = repo
365 .statuses(Some(&mut status_opts))
366 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
367
368 let mut working_tree = WorkingTreeStatus {
369 has_changes: false,
370 modified: Vec::new(),
371 added: Vec::new(),
372 deleted: Vec::new(),
373 untracked: Vec::new(),
374 };
375
376 for entry in statuses.iter() {
377 let path = entry.path().unwrap_or("").to_string();
378 let status = entry.status();
379
380 if status.is_index_new() || status.is_wt_new() {
381 working_tree.untracked.push(path.clone());
382 }
383 if status.is_index_modified() || status.is_wt_modified() {
384 working_tree.modified.push(path.clone());
385 }
386 if status.is_index_deleted() || status.is_wt_deleted() {
387 working_tree.deleted.push(path.clone());
388 }
389 if status.is_index_renamed() || status.is_wt_renamed() {
390 working_tree.added.push(path);
391 }
392 }
393
394 working_tree.has_changes = !working_tree.modified.is_empty()
395 || !working_tree.added.is_empty()
396 || !working_tree.deleted.is_empty()
397 || !working_tree.untracked.is_empty();
398
399 Ok::<WorkingTreeStatus, ScmError>(working_tree)
400 })
401 }
402
403 async fn commit(&self, message: &str, files: &[String]) -> Result<String, ScmError> {
404 let files_to_check: Vec<PathBuf> = files
405 .iter()
406 .filter_map(|f| {
407 let p = Path::new(f);
408 if p.exists() {
409 Some(p.to_path_buf())
410 } else {
411 None
412 }
413 })
414 .collect();
415
416 self.with_repo(|repo| {
417 let mut index = repo
418 .index()
419 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
420
421 for path in &files_to_check {
423 index
424 .add_path(path)
425 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
426 }
427
428 index
429 .write()
430 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
431
432 let tree_id = index
433 .write_tree()
434 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
435
436 let tree = repo
437 .find_tree(tree_id)
438 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
439
440 let head = repo
442 .head()
443 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
444 let parent_commit = head
445 .peel_to_commit()
446 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
447
448 let sig = repo
449 .signature()
450 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
451
452 let oid = repo
453 .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent_commit])
454 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
455
456 Ok::<String, ScmError>(oid.to_string())
457 })
458 }
459
460 async fn stage_files(&self, files: &[String]) -> Result<(), ScmError> {
461 let files_to_stage: Vec<PathBuf> = files
462 .iter()
463 .filter_map(|f| {
464 let p = Path::new(f);
465 if p.exists() {
466 Some(p.to_path_buf())
467 } else {
468 None
469 }
470 })
471 .collect();
472
473 self.with_repo(|repo| {
474 let mut index = repo
475 .index()
476 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
477
478 for path in &files_to_stage {
479 index
480 .add_path(path)
481 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
482 }
483
484 index
485 .write()
486 .map_err(|e: git2::Error| ScmError::GitError(e.to_string()))?;
487
488 Ok::<(), ScmError>(())
489 })
490 }
491
492 async fn push(&self, remote: Option<&str>, branch: Option<&str>) -> Result<(), ScmError> {
493 let remote_name = remote.unwrap_or(&self.config.default_remote).to_string();
494 let branch_name = branch.unwrap_or("main").to_string();
495
496 self.with_repo(|repo| {
497 let mut remote_obj = repo
498 .find_remote(&remote_name)
499 .map_err(|_| ScmError::GitError(format!("Remote '{remote_name}' not found")))?;
500
501 let branch_ref = format!("refs/heads/{branch_name}");
502
503 remote_obj
504 .push(&[&branch_ref], None)
505 .map_err(|e: git2::Error| ScmError::GitError(format!("Push failed: {e}")))?;
506
507 Ok::<(), ScmError>(())
508 })
509 }
510
511 fn repository_root(&self) -> Option<&Path> {
512 None
517 }
518
519 async fn is_on_branch(&self, branch: &str) -> Result<bool, ScmError> {
520 let current = self.get_current_branch().await?;
521 Ok(current == branch)
522 }
523
524 async fn get_tags_for_commit(&self, commit_hash: &str) -> Result<Vec<String>, ScmError> {
525 let oid = Oid::from_str(commit_hash).map_err(|e| ScmError::GitError(e.to_string()))?;
526
527 self.with_repo(|repo| {
528 let tag_names = repo
529 .tag_names(None)
530 .map_err(|e| ScmError::GitError(e.to_string()))?;
531
532 let mut tags = Vec::new();
533
534 for tag_name in tag_names.iter().flatten() {
535 if let Ok(reference) = repo.find_reference(tag_name) {
536 let target_oid = reference
540 .peel_to_tag()
541 .ok()
542 .map(|tag| tag.target_id())
543 .or_else(|| {
544 reference.peel(git2::ObjectType::Any).ok().and_then(|obj| {
545 obj.as_commit()
546 .map(git2::Commit::id)
547 .or_else(|| obj.as_tag().map(git2::Tag::target_id))
548 .or_else(|| Some(obj.id()))
549 })
550 });
551
552 let Some(target_oid) = target_oid else {
553 continue;
554 };
555
556 if target_oid == oid {
557 tags.push(tag_name.to_string());
558 }
559 }
560 }
561
562 Ok::<Vec<String>, ScmError>(tags)
563 })
564 }
565
566 async fn get_remote_url(&self, remote: Option<&str>) -> Result<Option<String>, ScmError> {
567 let remote_name = remote.unwrap_or(&self.config.default_remote).to_string();
568
569 self.with_repo(|repo| {
570 let remote_obj = repo
571 .find_remote(&remote_name)
572 .map_err(|_| ScmError::GitError(format!("Remote '{remote_name}' not found")))?;
573
574 Ok::<Option<String>, ScmError>(remote_obj.url().map(String::from))
575 })
576 }
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582
583 #[test]
584 fn test_git_adapter_config_default() {
585 let config = GitAdapterConfig::default();
586 assert_eq!(config.default_remote, "origin");
587 assert!(!config.sign_commits);
588 assert!(!config.sign_tags);
589 }
590}