Skip to main content

hanzo_mcp/tools/
git_tool.rs

1/// Unified version control tool (HIP-0300)
2///
3/// Handles all VCS operations:
4/// - status: Working tree status
5/// - diff: Show differences
6/// - apply: Apply patch
7/// - commit: Create commit
8/// - branch: Branch operations
9/// - checkout: Switch branches
10/// - log: Commit history
11
12use anyhow::{anyhow, Result};
13use serde::{Deserialize, Serialize};
14use serde_json::{json, Value};
15use tokio::process::Command;
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18#[serde(rename_all = "snake_case")]
19pub enum VcsAction {
20    Status, Diff, Apply, Commit, Branch, Checkout, Log,
21    Blame, Show, Stash, Tag, Remote, Merge, Rebase, CherryPick,
22    Reset, Clean, Init, Clone, Fetch, Pull, Push, Config,
23    Worktree, Reflog, Shortlog, RevParse, Describe, Bisect,
24    Help,
25}
26
27impl Default for VcsAction {
28    fn default() -> Self {
29        Self::Help
30    }
31}
32
33impl std::str::FromStr for VcsAction {
34    type Err = anyhow::Error;
35
36    fn from_str(s: &str) -> Result<Self> {
37        match s.to_lowercase().as_str() {
38            "status" | "st" => Ok(Self::Status),
39            "diff" | "d" => Ok(Self::Diff),
40            "apply" | "patch" => Ok(Self::Apply),
41            "commit" | "ci" => Ok(Self::Commit),
42            "branch" | "br" => Ok(Self::Branch),
43            "checkout" | "co" | "switch" => Ok(Self::Checkout),
44            "log" | "history" => Ok(Self::Log),
45            "blame" => Ok(Self::Blame),
46            "show" => Ok(Self::Show),
47            "stash" => Ok(Self::Stash),
48            "tag" => Ok(Self::Tag),
49            "remote" => Ok(Self::Remote),
50            "merge" => Ok(Self::Merge),
51            "rebase" => Ok(Self::Rebase),
52            "cherry_pick" | "cherry-pick" => Ok(Self::CherryPick),
53            "reset" => Ok(Self::Reset),
54            "clean" => Ok(Self::Clean),
55            "init" => Ok(Self::Init),
56            "clone" => Ok(Self::Clone),
57            "fetch" => Ok(Self::Fetch),
58            "pull" => Ok(Self::Pull),
59            "push" => Ok(Self::Push),
60            "config" => Ok(Self::Config),
61            "worktree" => Ok(Self::Worktree),
62            "reflog" => Ok(Self::Reflog),
63            "shortlog" => Ok(Self::Shortlog),
64            "rev_parse" | "rev-parse" => Ok(Self::RevParse),
65            "describe" => Ok(Self::Describe),
66            "bisect" => Ok(Self::Bisect),
67            "help" | "" => Ok(Self::Help),
68            _ => Err(anyhow!("Unknown action: {}", s)),
69        }
70    }
71}
72
73#[derive(Debug, Clone, Default, Serialize, Deserialize)]
74pub struct GitToolArgs {
75    pub action: Option<String>,
76    pub path: Option<String>,
77    pub message: Option<String>,
78    pub branch: Option<String>,
79    pub patch: Option<String>,
80    pub count: Option<usize>,
81    pub staged: Option<bool>,
82    pub target: Option<String>,
83    pub file: Option<String>,
84    pub remote: Option<String>,
85    pub url: Option<String>,
86    pub key: Option<String>,
87    pub value: Option<String>,
88    pub args: Option<Vec<String>>,
89    pub force: Option<bool>,
90}
91
92pub struct GitToolDefinition;
93
94impl GitToolDefinition {
95    pub fn schema() -> Value {
96        json!({
97            "name": "git",
98            "description": "Version control: status, diff, apply, commit, branch, checkout, log, blame, show, stash, tag, remote, merge, rebase, cherry_pick, reset, clean, init, clone, fetch, pull, push, config, worktree, reflog, shortlog, rev_parse, describe, bisect",
99            "inputSchema": {
100                "type": "object",
101                "properties": {
102                    "action": {
103                        "type": "string",
104                        "enum": ["status", "diff", "apply", "commit", "branch", "checkout", "log", "blame", "show", "stash", "tag", "remote", "merge", "rebase", "cherry_pick", "reset", "clean", "init", "clone", "fetch", "pull", "push", "config", "worktree", "reflog", "shortlog", "rev_parse", "describe", "bisect", "help"],
105                        "description": "VCS action"
106                    },
107                    "path": { "type": "string", "description": "Repository path", "default": "." },
108                    "message": { "type": "string", "description": "Commit message or tag message" },
109                    "branch": { "type": "string", "description": "Branch name" },
110                    "patch": { "type": "string", "description": "Patch content for apply" },
111                    "count": { "type": "number", "description": "Number of entries", "default": 10 },
112                    "staged": { "type": "boolean", "description": "Show staged changes only" },
113                    "target": { "type": "string", "description": "Target ref, subcommand, or commit" },
114                    "file": { "type": "string", "description": "File path" },
115                    "remote": { "type": "string", "description": "Remote name" },
116                    "url": { "type": "string", "description": "URL for clone/remote" },
117                    "key": { "type": "string", "description": "Config key" },
118                    "value": { "type": "string", "description": "Config value" },
119                    "force": { "type": "boolean", "description": "Force operation" }
120                },
121                "required": ["action"]
122            }
123        })
124    }
125}
126
127pub struct GitTool {
128    cwd: String,
129}
130
131impl GitTool {
132    pub fn new() -> Self {
133        Self {
134            cwd: std::env::current_dir()
135                .map(|p| p.to_string_lossy().to_string())
136                .unwrap_or_else(|_| ".".to_string()),
137        }
138    }
139
140    pub async fn execute(&self, args: GitToolArgs) -> Result<Value> {
141        let action: VcsAction = args.action.as_deref().unwrap_or("help").parse()?;
142        let cwd = args.path.as_deref().unwrap_or(&self.cwd);
143
144        match action {
145            VcsAction::Status => self.status(cwd).await,
146            VcsAction::Diff => self.diff(cwd, &args).await,
147            VcsAction::Apply => self.apply(cwd, &args).await,
148            VcsAction::Commit => self.commit(cwd, &args).await,
149            VcsAction::Branch => self.branch(cwd, &args).await,
150            VcsAction::Checkout => self.checkout(cwd, &args).await,
151            VcsAction::Log => self.log(cwd, &args).await,
152            VcsAction::Blame => self.blame(cwd, &args).await,
153            VcsAction::Show => self.show(cwd, &args).await,
154            VcsAction::Stash => self.stash(cwd, &args).await,
155            VcsAction::Tag => self.tag(cwd, &args).await,
156            VcsAction::Remote => self.remote(cwd, &args).await,
157            VcsAction::Merge => self.merge(cwd, &args).await,
158            VcsAction::Rebase => self.rebase(cwd, &args).await,
159            VcsAction::CherryPick => self.cherry_pick(cwd, &args).await,
160            VcsAction::Reset => self.reset(cwd, &args).await,
161            VcsAction::Clean => self.clean(cwd, &args).await,
162            VcsAction::Init => self.init(cwd).await,
163            VcsAction::Clone => self.clone_repo(cwd, &args).await,
164            VcsAction::Fetch => self.fetch(cwd, &args).await,
165            VcsAction::Pull => self.pull(cwd, &args).await,
166            VcsAction::Push => self.push(cwd, &args).await,
167            VcsAction::Config => self.config(cwd, &args).await,
168            VcsAction::Worktree => self.worktree(cwd, &args).await,
169            VcsAction::Reflog => self.reflog(cwd, &args).await,
170            VcsAction::Shortlog => self.shortlog(cwd, &args).await,
171            VcsAction::RevParse => self.rev_parse(cwd, &args).await,
172            VcsAction::Describe => self.describe(cwd, &args).await,
173            VcsAction::Bisect => self.bisect(cwd, &args).await,
174            VcsAction::Help => Ok(self.help()),
175        }
176    }
177
178    async fn git(&self, cwd: &str, args: &[&str]) -> Result<String> {
179        let output = Command::new("git")
180            .args(args)
181            .current_dir(cwd)
182            .output()
183            .await?;
184
185        if output.status.success() {
186            Ok(String::from_utf8_lossy(&output.stdout).to_string())
187        } else {
188            let stderr = String::from_utf8_lossy(&output.stderr);
189            Err(anyhow!("git error: {}", stderr.trim()))
190        }
191    }
192
193    async fn status(&self, cwd: &str) -> Result<Value> {
194        let out = self.git(cwd, &["status", "--porcelain=v1"]).await?;
195        let branch = self.git(cwd, &["branch", "--show-current"]).await
196            .unwrap_or_default().trim().to_string();
197
198        let files: Vec<Value> = out.lines()
199            .filter(|l| !l.is_empty())
200            .map(|l| {
201                let status = &l[..2];
202                let file = l[3..].trim();
203                json!({ "status": status.trim(), "file": file })
204            })
205            .collect();
206
207        Ok(json!({
208            "ok": true,
209            "data": { "branch": branch, "files": files, "clean": files.is_empty() },
210            "error": null,
211            "meta": { "tool": "git", "action": "status" }
212        }))
213    }
214
215    async fn diff(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
216        let mut git_args = vec!["diff"];
217        if args.staged.unwrap_or(false) {
218            git_args.push("--staged");
219        }
220        let out = self.git(cwd, &git_args).await?;
221
222        Ok(json!({
223            "ok": true,
224            "data": { "diff": out, "lines": out.lines().count() },
225            "error": null,
226            "meta": { "tool": "git", "action": "diff" }
227        }))
228    }
229
230    async fn apply(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
231        let patch = args.patch.as_deref()
232            .ok_or_else(|| anyhow!("patch content required"))?;
233
234        // Write patch to temp file and apply
235        let tmp = format!("{}/.__vcs_patch_tmp", cwd);
236        tokio::fs::write(&tmp, patch).await?;
237        let result = self.git(cwd, &["apply", &tmp]).await;
238        let _ = tokio::fs::remove_file(&tmp).await;
239
240        match result {
241            Ok(_) => Ok(json!({
242                "ok": true,
243                "data": { "applied": true },
244                "error": null,
245                "meta": { "tool": "git", "action": "apply" }
246            })),
247            Err(e) => Ok(json!({
248                "ok": false,
249                "data": null,
250                "error": { "code": "APPLY_FAILED", "message": e.to_string() },
251                "meta": { "tool": "git", "action": "apply" }
252            })),
253        }
254    }
255
256    async fn commit(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
257        let message = args.message.as_deref()
258            .ok_or_else(|| anyhow!("message required"))?;
259
260        let out = self.git(cwd, &["commit", "-m", message]).await?;
261
262        Ok(json!({
263            "ok": true,
264            "data": { "output": out.trim() },
265            "error": null,
266            "meta": { "tool": "git", "action": "commit" }
267        }))
268    }
269
270    async fn branch(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
271        if let Some(name) = &args.branch {
272            let out = self.git(cwd, &["branch", name]).await?;
273            Ok(json!({
274                "ok": true,
275                "data": { "created": name, "output": out.trim() },
276                "error": null,
277                "meta": { "tool": "git", "action": "branch" }
278            }))
279        } else {
280            let out = self.git(cwd, &["branch", "-a"]).await?;
281            let branches: Vec<&str> = out.lines().map(|l| l.trim()).collect();
282            Ok(json!({
283                "ok": true,
284                "data": { "branches": branches },
285                "error": null,
286                "meta": { "tool": "git", "action": "branch" }
287            }))
288        }
289    }
290
291    async fn checkout(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
292        let branch = args.branch.as_deref()
293            .ok_or_else(|| anyhow!("branch required"))?;
294
295        let out = self.git(cwd, &["checkout", branch]).await?;
296
297        Ok(json!({
298            "ok": true,
299            "data": { "branch": branch, "output": out.trim() },
300            "error": null,
301            "meta": { "tool": "git", "action": "checkout" }
302        }))
303    }
304
305    async fn log(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
306        let count = args.count.unwrap_or(10).to_string();
307        let out = self.git(cwd, &["log", "--oneline", "-n", &count]).await?;
308
309        let entries: Vec<Value> = out.lines()
310            .filter(|l| !l.is_empty())
311            .map(|l| {
312                let parts: Vec<&str> = l.splitn(2, ' ').collect();
313                json!({
314                    "hash": parts.first().unwrap_or(&""),
315                    "message": parts.get(1).unwrap_or(&"")
316                })
317            })
318            .collect();
319
320        Ok(json!({
321            "ok": true,
322            "data": { "entries": entries, "count": entries.len() },
323            "error": null,
324            "meta": { "tool": "git", "action": "log" }
325        }))
326    }
327
328    async fn blame(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
329        let file = args.file.as_deref().or(args.target.as_deref()).ok_or_else(|| anyhow!("file required"))?;
330        let out = self.git(cwd, &["blame", "--porcelain", file]).await?;
331        Ok(json!({"ok": true, "data": {"output": out}, "meta": {"tool": "git", "action": "blame"}}))
332    }
333
334    async fn show(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
335        let target = args.target.as_deref().unwrap_or("HEAD");
336        let out = self.git(cwd, &["show", "--stat", target]).await?;
337        Ok(json!({"ok": true, "data": {"output": out}, "meta": {"tool": "git", "action": "show"}}))
338    }
339
340    async fn stash(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
341        let sub = args.target.as_deref().unwrap_or("list");
342        let mut cmd = vec!["stash"];
343        cmd.push(sub);
344        if let Some(msg) = args.message.as_deref() { if sub == "push" { cmd.push("-m"); cmd.push(msg); } }
345        let out = self.git(cwd, &cmd).await?;
346        Ok(json!({"ok": true, "data": {"output": out.trim()}, "meta": {"tool": "git", "action": "stash"}}))
347    }
348
349    async fn tag(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
350        if let Some(name) = args.target.as_deref() {
351            let mut cmd = vec!["tag"];
352            if let Some(msg) = args.message.as_deref() { cmd.extend_from_slice(&["-a", name, "-m", msg]); } else { cmd.push(name); }
353            let out = self.git(cwd, &cmd).await?;
354            Ok(json!({"ok": true, "data": {"created": name, "output": out.trim()}, "meta": {"tool": "git", "action": "tag"}}))
355        } else {
356            let out = self.git(cwd, &["tag", "-l"]).await?;
357            let tags: Vec<&str> = out.lines().collect();
358            Ok(json!({"ok": true, "data": {"tags": tags}, "meta": {"tool": "git", "action": "tag"}}))
359        }
360    }
361
362    async fn remote(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
363        let sub = args.target.as_deref().unwrap_or("list");
364        match sub {
365            "list" => { let out = self.git(cwd, &["remote", "-v"]).await?; Ok(json!({"ok": true, "data": {"output": out.trim()}, "meta": {"tool": "git", "action": "remote"}})) }
366            "add" => { let name = args.remote.as_deref().ok_or_else(|| anyhow!("remote name required"))?; let url = args.url.as_deref().ok_or_else(|| anyhow!("url required"))?; let out = self.git(cwd, &["remote", "add", name, url]).await?; Ok(json!({"ok": true, "data": {"added": name, "output": out.trim()}, "meta": {"tool": "git", "action": "remote"}})) }
367            "remove" => { let name = args.remote.as_deref().ok_or_else(|| anyhow!("remote name required"))?; let out = self.git(cwd, &["remote", "remove", name]).await?; Ok(json!({"ok": true, "data": {"removed": name, "output": out.trim()}, "meta": {"tool": "git", "action": "remote"}})) }
368            _ => { let out = self.git(cwd, &["remote", sub]).await?; Ok(json!({"ok": true, "data": {"output": out.trim()}, "meta": {"tool": "git", "action": "remote"}})) }
369        }
370    }
371
372    async fn merge(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
373        let branch = args.branch.as_deref().or(args.target.as_deref()).ok_or_else(|| anyhow!("branch required"))?;
374        let out = self.git(cwd, &["merge", branch]).await?;
375        Ok(json!({"ok": true, "data": {"output": out.trim()}, "meta": {"tool": "git", "action": "merge"}}))
376    }
377
378    async fn rebase(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
379        let target = args.target.as_deref().or(args.branch.as_deref()).ok_or_else(|| anyhow!("target required"))?;
380        let out = self.git(cwd, &["rebase", target]).await?;
381        Ok(json!({"ok": true, "data": {"output": out.trim()}, "meta": {"tool": "git", "action": "rebase"}}))
382    }
383
384    async fn cherry_pick(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
385        let commit = args.target.as_deref().ok_or_else(|| anyhow!("commit hash required"))?;
386        let out = self.git(cwd, &["cherry-pick", commit]).await?;
387        Ok(json!({"ok": true, "data": {"output": out.trim()}, "meta": {"tool": "git", "action": "cherry_pick"}}))
388    }
389
390    async fn reset(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
391        let target = args.target.as_deref().unwrap_or("HEAD");
392        let mode = if args.force.unwrap_or(false) { "--hard" } else { "--mixed" };
393        let out = self.git(cwd, &["reset", mode, target]).await?;
394        Ok(json!({"ok": true, "data": {"output": out.trim()}, "meta": {"tool": "git", "action": "reset"}}))
395    }
396
397    async fn clean(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
398        let mut cmd = vec!["clean", "-fd"];
399        if args.force.unwrap_or(false) { cmd.push("-x"); }
400        let out = self.git(cwd, &cmd).await?;
401        Ok(json!({"ok": true, "data": {"output": out.trim()}, "meta": {"tool": "git", "action": "clean"}}))
402    }
403
404    async fn init(&self, cwd: &str) -> Result<Value> {
405        let out = self.git(cwd, &["init"]).await?;
406        Ok(json!({"ok": true, "data": {"output": out.trim()}, "meta": {"tool": "git", "action": "init"}}))
407    }
408
409    async fn clone_repo(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
410        let url = args.url.as_deref().or(args.target.as_deref()).ok_or_else(|| anyhow!("url required"))?;
411        let mut cmd = vec!["clone", url];
412        if let Some(path) = args.file.as_deref() { cmd.push(path); }
413        let out = self.git(cwd, &cmd).await?;
414        Ok(json!({"ok": true, "data": {"output": out.trim()}, "meta": {"tool": "git", "action": "clone"}}))
415    }
416
417    async fn fetch(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
418        let remote = args.remote.as_deref().unwrap_or("origin");
419        let out = self.git(cwd, &["fetch", remote]).await?;
420        Ok(json!({"ok": true, "data": {"output": out.trim()}, "meta": {"tool": "git", "action": "fetch"}}))
421    }
422
423    async fn pull(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
424        let remote = args.remote.as_deref().unwrap_or("origin");
425        let mut cmd = vec!["pull", remote];
426        if let Some(branch) = args.branch.as_deref() { cmd.push(branch); }
427        let out = self.git(cwd, &cmd).await?;
428        Ok(json!({"ok": true, "data": {"output": out.trim()}, "meta": {"tool": "git", "action": "pull"}}))
429    }
430
431    async fn push(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
432        let remote = args.remote.as_deref().unwrap_or("origin");
433        let mut cmd = vec!["push", remote];
434        if let Some(branch) = args.branch.as_deref() { cmd.push(branch); }
435        if args.force.unwrap_or(false) { cmd.push("--force"); }
436        let out = self.git(cwd, &cmd).await?;
437        Ok(json!({"ok": true, "data": {"output": out.trim()}, "meta": {"tool": "git", "action": "push"}}))
438    }
439
440    async fn config(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
441        if let Some(key) = args.key.as_deref() {
442            if let Some(val) = args.value.as_deref() {
443                let out = self.git(cwd, &["config", key, val]).await?;
444                Ok(json!({"ok": true, "data": {"set": key, "value": val, "output": out.trim()}, "meta": {"tool": "git", "action": "config"}}))
445            } else {
446                let out = self.git(cwd, &["config", "--get", key]).await?;
447                Ok(json!({"ok": true, "data": {"key": key, "value": out.trim()}, "meta": {"tool": "git", "action": "config"}}))
448            }
449        } else {
450            let out = self.git(cwd, &["config", "--list"]).await?;
451            Ok(json!({"ok": true, "data": {"output": out.trim()}, "meta": {"tool": "git", "action": "config"}}))
452        }
453    }
454
455    async fn worktree(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
456        let sub = args.target.as_deref().unwrap_or("list");
457        let mut cmd = vec!["worktree", sub];
458        if let Some(path) = args.file.as_deref() { cmd.push(path); }
459        if let Some(branch) = args.branch.as_deref() { cmd.push(branch); }
460        let out = self.git(cwd, &cmd).await?;
461        Ok(json!({"ok": true, "data": {"output": out.trim()}, "meta": {"tool": "git", "action": "worktree"}}))
462    }
463
464    async fn reflog(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
465        let count = args.count.unwrap_or(10).to_string();
466        let out = self.git(cwd, &["reflog", "-n", &count]).await?;
467        Ok(json!({"ok": true, "data": {"output": out.trim()}, "meta": {"tool": "git", "action": "reflog"}}))
468    }
469
470    async fn shortlog(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
471        let out = self.git(cwd, &["shortlog", "-sn", "HEAD"]).await?;
472        Ok(json!({"ok": true, "data": {"output": out.trim()}, "meta": {"tool": "git", "action": "shortlog"}}))
473    }
474
475    async fn rev_parse(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
476        let target = args.target.as_deref().unwrap_or("HEAD");
477        let out = self.git(cwd, &["rev-parse", target]).await?;
478        Ok(json!({"ok": true, "data": {"hash": out.trim()}, "meta": {"tool": "git", "action": "rev_parse"}}))
479    }
480
481    async fn describe(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
482        let target = args.target.as_deref().unwrap_or("HEAD");
483        let out = self.git(cwd, &["describe", "--tags", "--always", target]).await?;
484        Ok(json!({"ok": true, "data": {"description": out.trim()}, "meta": {"tool": "git", "action": "describe"}}))
485    }
486
487    async fn bisect(&self, cwd: &str, args: &GitToolArgs) -> Result<Value> {
488        let sub = args.target.as_deref().unwrap_or("status");
489        let mut cmd = vec!["bisect", sub];
490        if let Some(commit) = args.branch.as_deref() { cmd.push(commit); }
491        let out = self.git(cwd, &cmd).await?;
492        Ok(json!({"ok": true, "data": {"output": out.trim()}, "meta": {"tool": "git", "action": "bisect"}}))
493    }
494
495    fn help(&self) -> Value {
496        json!({
497            "ok": true,
498            "data": {
499                "tool": "git",
500                "actions": {
501                    "status": "Working tree status",
502                    "diff": "Show differences (unified patch format)",
503                    "apply": "Apply patch (requires patch)",
504                    "commit": "Create commit (requires message)",
505                    "branch": "List or create branches",
506                    "checkout": "Switch branches (requires branch)",
507                    "log": "Commit history (optional count)"
508                }
509            },
510            "error": null,
511            "meta": { "tool": "git", "action": "help" }
512        })
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    #[test]
521    fn test_vcs_action_parse() {
522        let action: VcsAction = "status".parse().unwrap();
523        assert_eq!(action, VcsAction::Status);
524    }
525
526    #[test]
527    fn test_vcs_action_aliases() {
528        assert_eq!("st".parse::<VcsAction>().unwrap(), VcsAction::Status);
529        assert_eq!("co".parse::<VcsAction>().unwrap(), VcsAction::Checkout);
530        assert_eq!("ci".parse::<VcsAction>().unwrap(), VcsAction::Commit);
531    }
532}