Skip to main content

governor_git/
adapter.rs

1//! Git adapter implementation
2
3use 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/// Git adapter configuration
15#[derive(Debug, Clone)]
16pub struct GitAdapterConfig {
17    /// Path to repository
18    pub repository_path: Option<PathBuf>,
19    /// Remote name
20    pub default_remote: String,
21    /// Whether to sign commits
22    pub sign_commits: bool,
23    /// Whether to sign tags
24    pub sign_tags: bool,
25    /// Commit message template
26    pub commit_template: Option<String>,
27    /// Tag name template
28    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
57/// Git adapter implementation
58pub struct GitAdapter {
59    repo: Arc<Mutex<Repository>>,
60    config: GitAdapterConfig,
61}
62
63// Safety: GitAdapter uses Arc<Mutex<>> to provide thread-safe access
64// The unsafe impl is no longer needed since Arc<Mutex<T>> provides Sync automatically
65// when T is Send, which Repository is
66
67impl GitAdapter {
68    /// Open a Git repository at the given path or current directory
69    ///
70    /// # Errors
71    ///
72    /// Returns `ScmError` if the repository cannot be discovered or opened
73    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    /// Get the underlying git2 repository
89    #[must_use]
90    pub fn repository(&self) -> Arc<Mutex<Repository>> {
91        Arc::clone(&self.repo)
92    }
93
94    /// Helper to execute a function with repository access
95    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    /// Convert git2 time to `DateTime`
104    fn git_time_to_datetime(time: &Time) -> DateTime<Utc> {
105        Utc.timestamp_opt(time.seconds(), 0).unwrap()
106    }
107
108    /// Parse commit from git2 commit object
109    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    /// Get the repository root path
120    #[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        // First, get the end_oid synchronously
137        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            // If we have a tag, find its target oid now
145            let end_oid_str = if let Some(tag_name) = &tag_name {
146                // Try to resolve tag - works for both lightweight and annotated tags
147                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                // Get the target OID - for lightweight tags it's the commit,
153                // for annotated tags we need to peel to get the actual commit
154                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        // If no tag provided, find the previous tag asynchronously
184        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        // Now collect all commits synchronously
214        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                // Stop at end commit
236                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            // Sort by version (descending)
270            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            // Stage files
422            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            // Get current HEAD as parent
441            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        // Note: This method signature requires returning a reference tied to self,
513        // but we can't return a reference from inside the Mutex guard.
514        // This is a known limitation - for now we'll return None.
515        // A proper fix would require changing the trait to return PathBuf or similar.
516        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                    // Try to get the target of the reference (tag)
537                    // For lightweight tags, this is the commit directly
538                    // For annotated tags, we need to peel to the tag object
539                    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}