ta_submit/adapter.rs
1//! Core SourceAdapter trait and result types
2//!
3//! The `SourceAdapter` trait (formerly `SubmitAdapter`) is the unified abstraction
4//! for VCS operations. It combines submit operations (commit, push, open review)
5//! with sync operations (fetch upstream, detect conflicts).
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::Path;
10use ta_changeset::DraftPackage;
11use ta_goal::GoalRun;
12use thiserror::Error;
13
14use crate::config::SubmitConfig;
15
16/// Errors that can occur during source operations
17#[derive(Debug, Error)]
18pub enum SubmitError {
19 #[error("Adapter not configured: {0}")]
20 NotConfigured(String),
21
22 #[error("VCS operation failed: {0}")]
23 VcsError(String),
24
25 #[error("IO error: {0}")]
26 IoError(#[from] std::io::Error),
27
28 #[error("Configuration error: {0}")]
29 ConfigError(String),
30
31 #[error("Review creation failed: {0}")]
32 ReviewError(String),
33
34 #[error("Invalid state: {0}")]
35 InvalidState(String),
36
37 #[error("Sync failed: {0}")]
38 SyncError(String),
39
40 #[error("Sync conflict: {conflicts} file(s) in conflict")]
41 SyncConflict { conflicts: usize },
42}
43
44pub type Result<T> = std::result::Result<T, SubmitError>;
45
46/// Result of a commit operation
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct CommitResult {
49 /// Commit identifier (hash, changelist number, etc.)
50 pub commit_id: String,
51
52 /// Human-readable message
53 pub message: String,
54
55 /// Adapter-specific metadata
56 #[serde(default)]
57 pub metadata: HashMap<String, String>,
58
59 /// Gitignored artifacts that were dropped from this commit (v0.13.17.5).
60 /// Known-safe paths (.mcp.json, *.local.toml, .ta/ runtime files) are
61 /// dropped silently; unexpected-ignored paths trigger a warning.
62 #[serde(default)]
63 pub ignored_artifacts: Vec<ta_changeset::IgnoredArtifact>,
64}
65
66/// Result of a push operation
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct PushResult {
69 /// Remote reference (branch name, changelist URL, etc.)
70 pub remote_ref: String,
71
72 /// Human-readable message
73 pub message: String,
74
75 /// Adapter-specific metadata
76 #[serde(default)]
77 pub metadata: HashMap<String, String>,
78}
79
80/// Result of opening a review request
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct ReviewResult {
83 /// Review URL (GitHub PR, Perforce review, etc.)
84 pub review_url: String,
85
86 /// Review identifier
87 pub review_id: String,
88
89 /// Human-readable message
90 pub message: String,
91
92 /// Adapter-specific metadata
93 #[serde(default)]
94 pub metadata: HashMap<String, String>,
95}
96
97/// Result of a sync operation — pulling upstream changes into the local workspace.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct SyncResult {
100 /// Whether upstream had new changes that were incorporated.
101 pub updated: bool,
102
103 /// Files with merge conflicts (empty if none).
104 pub conflicts: Vec<String>,
105
106 /// Number of new upstream commits incorporated.
107 pub new_commits: u32,
108
109 /// Human-readable summary of what happened.
110 pub message: String,
111
112 /// Adapter-specific metadata.
113 #[serde(default)]
114 pub metadata: HashMap<String, String>,
115}
116
117impl SyncResult {
118 /// Whether the sync completed without conflicts.
119 pub fn is_clean(&self) -> bool {
120 self.conflicts.is_empty()
121 }
122}
123
124/// Opaque saved VCS state for save/restore around apply operations.
125///
126/// Each adapter stores its own state (e.g., Git saves the current branch name,
127/// Perforce saves the current changelist). The state is passed back to
128/// `restore_state()` after the apply operation completes.
129pub struct SavedVcsState {
130 /// Adapter name that created this state (for safety checks).
131 pub adapter: String,
132 /// Opaque state data — only the creating adapter knows how to interpret this.
133 pub data: Box<dyn std::any::Any + Send>,
134}
135
136/// Pluggable adapter for source control operations (submit + sync).
137///
138/// The staging->review->apply loop is VCS-agnostic. This trait allows
139/// different implementations for Git, Perforce, SVN, or custom workflows.
140///
141/// Renamed from `SubmitAdapter` in v0.11.1 to reflect the unified scope
142/// (submit + sync). The old name is available as a type alias.
143pub trait SourceAdapter: Send + Sync {
144 /// Create a working branch/changelist/workspace for this goal
145 ///
146 /// For Git: creates a feature branch
147 /// For Perforce: creates a changelist
148 /// For "none": no-op
149 fn prepare(&self, goal: &GoalRun, config: &SubmitConfig) -> Result<()>;
150
151 /// Commit the approved changes from staging
152 ///
153 /// For Git: `git add` + `git commit`
154 /// For Perforce: shelve files
155 /// For "none": no-op
156 fn commit(&self, goal: &GoalRun, pr: &DraftPackage, message: &str) -> Result<CommitResult>;
157
158 /// Push the committed changes
159 ///
160 /// For Git: `git push`
161 /// For Perforce: submit changelist
162 /// For "none": no-op
163 fn push(&self, goal: &GoalRun) -> Result<PushResult>;
164
165 /// Open a review request
166 ///
167 /// For Git: create GitHub/GitLab PR via API or `gh pr create`
168 /// For Perforce: create Swarm review
169 /// For "none": no-op
170 fn open_review(&self, goal: &GoalRun, pr: &DraftPackage) -> Result<ReviewResult>;
171
172 /// Sync the local workspace with upstream changes.
173 ///
174 /// For Git: `git fetch` + merge/rebase/ff per `source.git.sync_strategy`
175 /// For SVN: `svn update`
176 /// For Perforce: `p4 sync`
177 /// For "none": no-op (always returns updated=false)
178 ///
179 /// Returns a `SyncResult` describing what happened. If conflicts are
180 /// detected, `SyncResult.conflicts` is non-empty but the method still
181 /// returns `Ok` — the caller decides how to handle conflicts. Only
182 /// returns `Err` for infrastructure failures (network, permissions).
183 fn sync_upstream(&self) -> Result<SyncResult> {
184 Ok(SyncResult {
185 updated: false,
186 conflicts: vec![],
187 new_commits: 0,
188 message: "No sync operation (default implementation)".to_string(),
189 metadata: HashMap::new(),
190 })
191 }
192
193 /// Adapter display name (for CLI output)
194 fn name(&self) -> &str;
195
196 /// Patterns to exclude from staging copy (VCS metadata dirs, etc.)
197 ///
198 /// Returns patterns in .taignore format: "dirname/", "*.ext", "name".
199 /// These are merged with user .taignore patterns and built-in defaults
200 /// during overlay workspace creation and diffing.
201 fn exclude_patterns(&self) -> Vec<String> {
202 vec![]
203 }
204
205 /// Save working state before apply operations.
206 ///
207 /// Git: saves the current branch name so it can be restored after commit.
208 /// Perforce: saves the current changelist context.
209 /// Default: no-op (returns None).
210 fn save_state(&self) -> Result<Option<SavedVcsState>> {
211 Ok(None)
212 }
213
214 /// Restore working state after apply operations.
215 ///
216 /// Git: switches back to the original branch.
217 /// Perforce: reverts to saved client state.
218 /// Default: no-op.
219 fn restore_state(&self, _state: Option<SavedVcsState>) -> Result<()> {
220 Ok(())
221 }
222
223 /// Get the current branch or workspace name.
224 ///
225 /// Git: current branch name (e.g., "main", "feature/abc-def")
226 /// SVN: working copy URL path
227 /// Perforce: current client/workspace name
228 /// Default: "unknown"
229 fn current_branch(&self) -> Result<String> {
230 Ok("unknown".to_string())
231 }
232
233 /// Get the current revision identifier for the working directory.
234 ///
235 /// Git: short commit hash (e.g., "abc1234")
236 /// SVN: revision number (e.g., "r1234")
237 /// Perforce: changelist number (e.g., "@1234")
238 /// Default: "unknown"
239 fn revision_id(&self) -> Result<String> {
240 Ok("unknown".to_string())
241 }
242
243 /// Check the status of a review/PR by its review ID (e.g., PR number).
244 ///
245 /// Git: uses `gh pr view --json state` to check PR status.
246 /// Returns the current state as a string: "open", "merged", "closed".
247 /// Default: returns None (not supported).
248 fn check_review(&self, _review_id: &str) -> Result<Option<ReviewStatus>> {
249 Ok(None)
250 }
251
252 /// Merge a review/PR into the target branch and sync the local workspace.
253 ///
254 /// Git: calls `gh pr merge` to merge the PR immediately.
255 /// Perforce: calls `p4 submit -c <CL>` to submit the shelved changelist.
256 /// SVN: no-op (SVN commits directly; no separate merge step).
257 /// Default: no-op, returns a guidance message telling the user what to do.
258 ///
259 /// Returns a `MergeResult` describing what happened. `merged = true` means
260 /// the merge was completed immediately; `merged = false` means auto-merge is
261 /// pending (CI must pass first).
262 fn merge_review(&self, _review_id: &str) -> Result<MergeResult> {
263 Ok(MergeResult {
264 merged: false,
265 merge_commit: None,
266 message: "This adapter does not support automatic merging. \
267 Merge the PR manually in your VCS platform, then run `ta sync`."
268 .to_string(),
269 metadata: HashMap::new(),
270 })
271 }
272
273 /// Auto-detect whether this adapter applies to the given project root.
274 ///
275 /// Git: checks for .git/ directory
276 /// SVN: checks for .svn/ directory
277 /// Perforce: checks for P4CONFIG env var or .p4config
278 fn detect(project_root: &Path) -> bool
279 where
280 Self: Sized,
281 {
282 let _ = project_root;
283 false
284 }
285
286 /// Protected submit targets for this adapter (§15 VCS Submit Invariant).
287 ///
288 /// Returns the list of refs/branches/paths that agents must never commit
289 /// directly to. `prepare()` must create an isolation mechanism (feature
290 /// branch, shelved CL, etc.) before `verify_not_on_protected_target()` is
291 /// called.
292 ///
293 /// Default: empty list (no protected targets — applies to adapters that
294 /// handle isolation entirely through their `prepare()` implementation).
295 fn protected_submit_targets(&self) -> Vec<String> {
296 vec![]
297 }
298
299 /// Assert the post-`prepare()` invariant: the adapter must not be
300 /// positioned to commit directly to a protected target (§15).
301 ///
302 /// Called immediately after `prepare()` succeeds, before any commit or
303 /// push. Hard failure aborts the apply workflow.
304 ///
305 /// Default implementation: if `protected_submit_targets()` returns a
306 /// non-empty list, subclasses should override this to check the current
307 /// position. The base implementation is a no-op (safe for adapters whose
308 /// `prepare()` guarantees isolation without needing an extra check).
309 fn verify_not_on_protected_target(&self) -> Result<()> {
310 Ok(())
311 }
312
313 /// Produce environment variables to inject into the agent process so it
314 /// operates on the staging directory instead of the developer's real VCS
315 /// workspace (v0.13.17.3).
316 ///
317 /// Called by `ta run` before spawning the agent. The returned vars are
318 /// merged into the agent's process environment.
319 ///
320 /// Default: no-op (returns empty map). VCS adapters that support isolation
321 /// should override this method.
322 fn stage_env(
323 &self,
324 _staging_dir: &Path,
325 _config: &crate::config::VcsAgentConfig,
326 ) -> Result<HashMap<String, String>> {
327 Ok(HashMap::new())
328 }
329}
330
331/// Result of merging a review (PR, shelved CL, etc.) into the target branch.
332#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct MergeResult {
334 /// Whether the merge was completed (false = pending CI, auto-merge enabled).
335 pub merged: bool,
336 /// Merge commit SHA or changelist number (if available).
337 pub merge_commit: Option<String>,
338 /// Human-readable message about what happened.
339 pub message: String,
340 /// Adapter-specific metadata.
341 #[serde(default)]
342 pub metadata: HashMap<String, String>,
343}
344
345/// Status of a VCS review/PR (v0.11.2.3).
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct ReviewStatus {
348 /// Current state: "open", "merged", "closed", "draft".
349 pub state: String,
350 /// Whether CI checks are passing.
351 pub checks_passing: Option<bool>,
352}
353
354/// Backward-compatible alias: `SubmitAdapter` is the old name for `SourceAdapter`.
355///
356/// Deprecated in v0.11.1. Use `SourceAdapter` instead.
357pub use SourceAdapter as SubmitAdapter;
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 struct MockAdapter;
364 impl SourceAdapter for MockAdapter {
365 fn prepare(&self, _: &GoalRun, _: &SubmitConfig) -> Result<()> {
366 Ok(())
367 }
368 fn commit(&self, _: &GoalRun, _: &DraftPackage, _: &str) -> Result<CommitResult> {
369 unimplemented!()
370 }
371 fn push(&self, _: &GoalRun) -> Result<PushResult> {
372 unimplemented!()
373 }
374 fn open_review(&self, _: &GoalRun, _: &DraftPackage) -> Result<ReviewResult> {
375 unimplemented!()
376 }
377 fn name(&self) -> &str {
378 "mock"
379 }
380 }
381
382 #[test]
383 fn default_protected_targets_empty() {
384 let adapter = MockAdapter;
385 assert!(adapter.protected_submit_targets().is_empty());
386 }
387
388 #[test]
389 fn default_verify_not_on_protected_target_ok() {
390 let adapter = MockAdapter;
391 assert!(adapter.verify_not_on_protected_target().is_ok());
392 }
393
394 #[test]
395 fn sync_result_is_clean_when_no_conflicts() {
396 let result = SyncResult {
397 updated: true,
398 conflicts: vec![],
399 new_commits: 3,
400 message: "ok".to_string(),
401 metadata: HashMap::new(),
402 };
403 assert!(result.is_clean());
404 }
405
406 #[test]
407 fn sync_result_is_not_clean_with_conflicts() {
408 let result = SyncResult {
409 updated: true,
410 conflicts: vec!["src/main.rs".to_string()],
411 new_commits: 3,
412 message: "conflict".to_string(),
413 metadata: HashMap::new(),
414 };
415 assert!(!result.is_clean());
416 }
417
418 #[test]
419 fn sync_result_serialization_roundtrip() {
420 let result = SyncResult {
421 updated: true,
422 conflicts: vec!["a.rs".to_string()],
423 new_commits: 5,
424 message: "synced".to_string(),
425 metadata: [("branch".to_string(), "main".to_string())]
426 .into_iter()
427 .collect(),
428 };
429 let json = serde_json::to_string(&result).unwrap();
430 let restored: SyncResult = serde_json::from_str(&json).unwrap();
431 assert!(restored.updated);
432 assert_eq!(restored.conflicts, vec!["a.rs"]);
433 assert_eq!(restored.new_commits, 5);
434 }
435}