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}