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    /// Resolve a git reference to its target OID.
104    ///
105    /// This helper handles both lightweight and annotated tags:
106    /// - Lightweight tags: Direct reference to a commit
107    /// - Annotated tags: Tag object that points to a commit
108    ///
109    /// # Arguments
110    ///
111    /// * `repo` - The git repository
112    /// * `reference_name` - The name of the reference (e.g., "v1.0.0", "refs/tags/v1.0.0")
113    ///
114    /// # Returns
115    ///
116    /// Returns `Some(Oid)` if the reference can be resolved, `None` otherwise.
117    ///
118    /// # Errors
119    ///
120    /// Returns `ScmError` if the reference cannot be found or resolved.
121    fn resolve_reference_target(
122        repo: &Repository,
123        reference_name: &str,
124    ) -> Result<Option<git2::Oid>, ScmError> {
125        // Try to find the reference - works for both full refs and short names
126        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        // Get the target OID - for lightweight tags it's the commit,
137        // for annotated tags we need to peel to get the actual commit
138        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    /// Convert git2 time to `DateTime`
155    fn git_time_to_datetime(time: &Time) -> DateTime<Utc> {
156        Utc.timestamp_opt(time.seconds(), 0).unwrap()
157    }
158
159    /// Parse commit from git2 commit object
160    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    /// Get the repository root path
171    #[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        // First, get the end_oid synchronously
188        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            // If we have a tag, find its target oid now
196            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        // If no tag provided, find the previous tag asynchronously
218        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        // Now collect all commits synchronously
237        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                // Stop at end commit
259                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            // Sort by version (descending)
293            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            // Stage files
445            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            // Get current HEAD as parent
464            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        // Note: This method signature requires returning a reference tied to self,
536        // but we can't return a reference from inside the Mutex guard.
537        // This is a known limitation - for now we'll return None.
538        // A proper fix would require changing the trait to return PathBuf or similar.
539        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}