Skip to main content

governor_core/traits/
source_control.rs

1//! Source control trait (Git abstraction)
2//!
3//! This module provides a trait abstraction for source control operations,
4//! primarily designed for Git but extensible to other VCS.
5
6use async_trait::async_trait;
7
8use crate::domain::{commit::Commit, version::SemanticVersion, workspace::WorkingTreeStatus};
9
10/// Error type for source control operations
11#[derive(Debug, thiserror::Error)]
12pub enum ScmError {
13    /// Repository not found at the specified path
14    #[error("Repository not found: {0}")]
15    NotFound(String),
16
17    /// Git operation failed
18    #[error("Git operation failed: {0}")]
19    GitError(String),
20
21    /// No git repository found
22    #[error("No git repository found")]
23    NotAGitRepo,
24
25    /// Tag not found
26    #[error("Tag not found: {0}")]
27    TagNotFound(String),
28
29    /// IO error
30    #[error("IO error: {0}")]
31    Io(#[from] std::io::Error),
32
33    /// No commits found in the repository
34    #[error("No commits found")]
35    NoCommits,
36}
37
38/// Trait for source control operations
39///
40/// Provides an abstraction over Git (and potentially other VCS) operations
41/// needed for release automation.
42///
43/// # Examples
44///
45/// The trait is used by implementors that provide Git (or other VCS) operations:
46///
47/// ```text
48/// use governor_core::traits::source_control::SourceControl;
49///
50/// // A concrete implementation would provide:
51/// let commits = scm.get_commits_since(Some("v1.0.0")).await?;
52/// for commit in commits {
53///     println!("{}: {}", commit.hash, commit.message);
54/// }
55/// ```
56#[async_trait]
57pub trait SourceControl: Send + Sync {
58    /// Get the name of this SCM (e.g., "git")
59    fn name(&self) -> &str;
60
61    /// Get commits since a specific tag/ref
62    ///
63    /// # Arguments
64    ///
65    /// * `tag` - Optional tag/ref to get commits since. If `None`, returns all commits.
66    ///
67    /// # Returns
68    ///
69    /// Vector of commits in reverse chronological order (newest first).
70    ///
71    /// # Errors
72    ///
73    /// Returns `ScmError::TagNotFound` if the specified tag doesn't exist.
74    async fn get_commits_since(&self, tag: Option<&str>) -> Result<Vec<Commit>, ScmError>;
75
76    /// Get the last tag matching a pattern
77    ///
78    /// # Arguments
79    ///
80    /// * `pattern` - Optional pattern to filter tags (e.g., "v" for version tags).
81    ///
82    /// # Returns
83    ///
84    /// The most recent tag name, or `None` if no tags found.
85    async fn get_last_tag(&self, pattern: Option<&str>) -> Result<Option<String>, ScmError>;
86
87    /// Create a new tag
88    ///
89    /// # Arguments
90    ///
91    /// * `name` - Tag name (e.g., "v1.0.0")
92    /// * `message` - Tag message
93    ///
94    /// # Returns
95    ///
96    /// The created tag name.
97    ///
98    /// # Errors
99    ///
100    /// Returns `ScmError::GitError` if tag creation fails.
101    async fn create_tag(&self, name: &str, message: &str) -> Result<String, ScmError>;
102
103    /// Delete a tag
104    ///
105    /// # Errors
106    ///
107    /// Returns `ScmError::TagNotFound` if the tag doesn't exist.
108    async fn delete_tag(&self, name: &str) -> Result<(), ScmError>;
109
110    /// Get current commit hash
111    ///
112    /// # Returns
113    ///
114    /// The full SHA-1 hash of HEAD.
115    async fn get_current_commit(&self) -> Result<String, ScmError>;
116
117    /// Get current branch name
118    ///
119    /// # Returns
120    ///
121    /// The current branch name (e.g., "main").
122    async fn get_current_branch(&self) -> Result<String, ScmError>;
123
124    /// Check if working tree is clean
125    ///
126    /// # Returns
127    ///
128    /// Status of the working tree including modified, added, deleted, and untracked files.
129    async fn get_working_tree_status(&self) -> Result<WorkingTreeStatus, ScmError>;
130
131    /// Create a commit with staged changes
132    ///
133    /// # Arguments
134    ///
135    /// * `message` - Commit message
136    /// * `files` - List of file paths to include in the commit
137    ///
138    /// # Returns
139    ///
140    /// The created commit hash.
141    async fn commit(&self, message: &str, files: &[String]) -> Result<String, ScmError>;
142
143    /// Stage files for commit
144    ///
145    /// # Arguments
146    ///
147    /// * `files` - List of file paths to stage
148    async fn stage_files(&self, files: &[String]) -> Result<(), ScmError>;
149
150    /// Push to remote
151    ///
152    /// # Arguments
153    ///
154    /// * `remote` - Remote name (e.g., "origin"). If `None`, uses default.
155    /// * `branch` - Branch name to push. If `None`, pushes current branch.
156    async fn push(&self, remote: Option<&str>, branch: Option<&str>) -> Result<(), ScmError>;
157
158    /// Get repository root path
159    fn repository_root(&self) -> Option<&std::path::Path>;
160
161    /// Check if we're on a specific branch
162    async fn is_on_branch(&self, branch: &str) -> Result<bool, ScmError>;
163
164    /// Get tags for a specific commit
165    ///
166    /// # Returns
167    ///
168    /// List of tag names pointing to this commit.
169    async fn get_tags_for_commit(&self, commit_hash: &str) -> Result<Vec<String>, ScmError>;
170
171    /// Get remote URL
172    ///
173    /// # Arguments
174    ///
175    /// * `remote` - Remote name (e.g., "origin"). If `None`, uses default.
176    ///
177    /// # Returns
178    ///
179    /// The fetch/push URL of the remote, or `None` if not configured.
180    async fn get_remote_url(&self, remote: Option<&str>) -> Result<Option<String>, ScmError>;
181}
182
183/// Configuration for source control operations
184#[derive(Debug, Clone)]
185pub struct ScmConfig {
186    /// Path to repository (None = current directory)
187    pub repository_path: Option<std::path::PathBuf>,
188    /// Remote name to use for push/pull
189    pub default_remote: String,
190    /// Whether to sign commits
191    pub sign_commits: bool,
192    /// Whether to sign tags
193    pub sign_tags: bool,
194    /// Commit message template
195    pub commit_template: Option<String>,
196    /// Tag name template
197    pub tag_template: Option<String>,
198}
199
200impl Default for ScmConfig {
201    fn default() -> Self {
202        Self {
203            repository_path: None,
204            default_remote: "origin".to_string(),
205            sign_commits: false,
206            sign_tags: false,
207            commit_template: Some("chore(release): bump version to {{version}}".to_string()),
208            tag_template: Some("v{{version}}".to_string()),
209        }
210    }
211}
212
213/// Helper function to format commit message from template
214///
215/// The template supports `{version}` placeholder which will be replaced with the actual version.
216#[must_use]
217pub fn format_commit_message(template: &str, version: &SemanticVersion) -> String {
218    template.replace("{{version}}", &version.to_string())
219}
220
221/// Helper function to format tag name from template
222///
223/// The template supports `{version}` placeholder which will be replaced with the actual version.
224#[must_use]
225pub fn format_tag_name(template: &str, version: &SemanticVersion) -> String {
226    template.replace("{{version}}", &version.to_string())
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn test_format_commit_message() {
235        let version = SemanticVersion::parse("1.2.3").unwrap();
236        let template = "chore(release): bump version to {{version}}";
237        assert_eq!(
238            format_commit_message(template, &version),
239            "chore(release): bump version to 1.2.3"
240        );
241    }
242
243    #[test]
244    fn test_format_tag_name() {
245        let version = SemanticVersion::parse("1.2.3").unwrap();
246        let template = "v{{version}}";
247        assert_eq!(format_tag_name(template, &version), "v1.2.3");
248    }
249}