1use 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 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}