gr/
git.rs

1//! Git commands. It defines the public entrypoints to execute git commands and
2//! interact with the git repositories.
3//!
4//! Git commands are just public functions that return a [`Result<CmdInfo>`].
5//! The [`CmdInfo`] is an enum that defines the different types of information
6//! returned by git operations. These operations can execute in parallel and the
7//! result can be combined at the end to make a decision before opening a merge
8//! request.
9//!
10//! All public functions take a [`Runner`] as a parameter and return a
11//! [`Result<CmdInfo>`].
12
13use std::sync::Arc;
14
15use crate::cmds::merge_request;
16use crate::cmds::merge_request::SummaryOptions;
17use crate::error;
18use crate::error::AddContext;
19use crate::error::GRError;
20use crate::io::CmdInfo;
21use crate::io::ShellResponse;
22use crate::io::TaskRunner;
23use crate::remote::RemoteURL;
24use crate::Result;
25
26/// Gather the status of the local git repository.
27///
28/// Takes a [`Runner`] as a parameter and returns a result encapsulating a
29/// [`CmdInfo::StatusModified`]. Untracked files are not being considered.
30pub fn status(exec: Arc<impl TaskRunner<Response = ShellResponse>>) -> Result<CmdInfo> {
31    let cmd_params = ["git", "status", "--short"];
32    let response = exec.run(cmd_params)?;
33    handle_git_status(&response)
34}
35
36fn handle_git_status(response: &ShellResponse) -> Result<CmdInfo> {
37    let modified = response
38        .body
39        .split('\n')
40        .filter(|s| {
41            let fields = s.split(' ').collect::<Vec<&str>>();
42            if fields.len() == 3 && fields[1] == "M" {
43                return true;
44            }
45            false
46        })
47        .count();
48    if modified > 0 {
49        return Ok(CmdInfo::StatusModified(true));
50    }
51    Ok(CmdInfo::StatusModified(false))
52}
53
54/// Gather the current branch name in the local git repository.
55pub fn current_branch(runner: Arc<impl TaskRunner<Response = ShellResponse>>) -> Result<CmdInfo> {
56    // Does not work for git version at least 2.18
57    // let cmd_params = ["git", "branch", "--show-current"];
58    // Use rev-parse for older versions of git that don't support --show-current.
59    let cmd_params = ["git", "rev-parse", "--abbrev-ref", "HEAD"];
60    let response = runner.run(cmd_params).err_context(format!(
61        "Failed to get current branch. Command: {}",
62        cmd_params.join(" ")
63    ))?;
64    Ok(CmdInfo::Branch(response.body))
65}
66
67/// Fetch the last commits from the remote.
68///
69/// The remote is considered to be the default remote, .i.e origin.
70/// Takes a [`Runner`] as a parameter and the encapsulated result is a
71/// [`CmdInfo::Ignore`].
72pub fn fetch(exec: Arc<impl TaskRunner>, remote_alias: String) -> Result<CmdInfo> {
73    let cmd_params = ["git", "fetch", &remote_alias];
74    exec.run(cmd_params).err_context(format!(
75        "Failed to git fetch. Command: {}",
76        cmd_params.join(" ")
77    ))?;
78    Ok(CmdInfo::Ignore)
79}
80
81pub fn add(exec: &impl TaskRunner) -> Result<CmdInfo> {
82    let cmd_params = ["git", "add", "-u"];
83    exec.run(cmd_params).err_context(format!(
84        "Failed to git add changes. Command: {}",
85        cmd_params.join(" ")
86    ))?;
87    Ok(CmdInfo::Ignore)
88}
89
90pub fn commit(exec: &impl TaskRunner, message: &str) -> Result<CmdInfo> {
91    let cmd_params = ["git", "commit", "-m", message];
92    exec.run(cmd_params).err_context(format!(
93        "Failed to git commit changes. Command: {}",
94        cmd_params.join(" ")
95    ))?;
96    Ok(CmdInfo::Ignore)
97}
98
99/// Get the origin remote url from the local git repository.
100pub fn remote_url(exec: &impl TaskRunner<Response = ShellResponse>) -> Result<CmdInfo> {
101    let cmd_params = ["git", "remote", "get-url", "--all", "origin"];
102    let response = exec.run(cmd_params)?;
103    handle_git_remote_url(&response)
104}
105
106fn handle_git_remote_url(response: &ShellResponse) -> Result<CmdInfo> {
107    let fields = response.body.split(':').collect::<Vec<&str>>();
108    match fields.len() {
109        // git@github.com:jordilin/gitar.git
110        2 => {
111            let domain: Vec<&str> = fields[0].split('@').collect();
112            if domain.len() == 2 {
113                let remote_path_partial: Vec<&str> = fields[1].split(".git").collect();
114                return Ok(CmdInfo::RemoteUrl(RemoteURL::new(
115                    domain[1].to_string(),
116                    remote_path_partial[0].to_string(),
117                )));
118            }
119            // https://github.com/jordilin/gitar.git
120            let remote_path_partial = fields[1].split('/').skip(2).collect::<Vec<&str>>();
121            let host = remote_path_partial[0];
122            let project = remote_path_partial[2].split(".git").collect::<Vec<&str>>();
123            let project_path = format!("{}/{}", remote_path_partial[1], project[0]);
124            Ok(CmdInfo::RemoteUrl(RemoteURL::new(
125                host.to_string(),
126                project_path,
127            )))
128        }
129        // ssh://git@gitlab-web:2000/jordilin/gitar.git
130        3 => {
131            let domain: Vec<&str> = fields[1].split('@').collect();
132            let remote_path_partial = fields[2].split('/').skip(1).collect::<Vec<&str>>();
133            let remote_path = remote_path_partial
134                .join("/")
135                .strip_suffix(".git")
136                .unwrap() // TODO handle this?
137                .to_string();
138            Ok(CmdInfo::RemoteUrl(RemoteURL::new(
139                domain[1].to_string(),
140                remote_path,
141            )))
142        }
143        _ => {
144            let trace = format!("git configuration error: {}", response.body);
145            Err(error::gen(trace))
146        }
147    }
148}
149
150/// Get the last commit summary from the local git repository.
151///
152/// This will be used as the default title for the merge request. Takes a
153/// [`Runner`] as a parameter and the encapsulated result is a
154/// [`CmdInfo::LastCommitSummary`].
155pub fn commit_summary(
156    runner: Arc<impl TaskRunner<Response = ShellResponse>>,
157    commit: &Option<String>,
158) -> Result<CmdInfo> {
159    let mut cmd_params = vec!["git", "log", "--format=%s", "-n1"];
160    if let Some(commit) = commit {
161        cmd_params.push(commit);
162    }
163    let response = runner.run(cmd_params)?;
164    Ok(CmdInfo::CommitSummary(response.body))
165}
166
167pub fn outgoing_commits(
168    runner: &impl TaskRunner<Response = ShellResponse>,
169    remote: &str,
170    default_branch: &str,
171    summary_options: &merge_request::SummaryOptions,
172) -> Result<String> {
173    let cmd = match summary_options {
174        SummaryOptions::Short => vec![
175            "git".to_string(),
176            "log".to_string(),
177            format!("{}/{}..", remote, default_branch),
178            "--reverse".to_string(),
179            "--pretty=format:%s - %h %d".to_string(),
180        ],
181        SummaryOptions::Long => vec![
182            "git".to_string(),
183            "log".to_string(),
184            format!("{}/{}..", remote, default_branch),
185            "--reverse".to_string(),
186            "--pretty=format:%s - %h %d%n%b".to_string(),
187        ],
188        // If None, that's an application error. This is checked in the CLI
189        // interface.
190        SummaryOptions::None => Err(GRError::ApplicationError(
191            "Invalid summary. It needs to be Short or Long, but never None".to_string(),
192        ))?,
193    };
194    let response = runner.run(cmd)?;
195    Ok(response.body)
196}
197
198pub fn patch<S: Into<String>, T: Into<String>>(
199    runner: &impl TaskRunner<Response = ShellResponse>,
200    current_branch: S,
201    target_branch: T,
202) -> Result<String> {
203    let cmd = vec![
204        "git".to_string(),
205        "diff".to_string(),
206        target_branch.into(),
207        current_branch.into(),
208    ];
209    let response = runner.run(cmd)?;
210    Ok(response.body)
211}
212
213pub fn push(runner: &impl TaskRunner, remote: &str, repo: &Repo, force: bool) -> Result<CmdInfo> {
214    let force_str = if force { "+" } else { "" };
215    let cmd = format!("git push {} {}{}", remote, force_str, repo.current_branch);
216    let cmd_params = cmd.split(' ').collect::<Vec<&str>>();
217    runner.run(cmd_params)?;
218    Ok(CmdInfo::Ignore)
219}
220
221pub fn rebase(runner: &impl TaskRunner, remote_alias: &str) -> Result<CmdInfo> {
222    let cmd = format!("git rebase {remote_alias}");
223    let cmd_params = cmd.split(' ').collect::<Vec<&str>>();
224    runner.run(cmd_params)?;
225    Ok(CmdInfo::Ignore)
226}
227
228pub fn commit_message(
229    runner: Arc<impl TaskRunner<Response = ShellResponse>>,
230    commit: &Option<String>,
231) -> Result<CmdInfo> {
232    let mut cmd_params = vec!["git", "log", "--pretty=format:%b", "-n1"];
233    if let Some(commit) = commit {
234        cmd_params.push(commit);
235    }
236    let response = runner.run(cmd_params)?;
237    Ok(CmdInfo::CommitMessage(response.body))
238}
239
240pub fn checkout(runner: &impl TaskRunner<Response = ShellResponse>, branch: &str) -> Result<()> {
241    let git_cmd = format!("git checkout origin/{branch} -b {branch}");
242    let cmd_params = ["/bin/sh", "-c", &git_cmd];
243    runner.run(cmd_params).err_context(format!(
244        "Failed to git checkout remote branch. Command: {}",
245        cmd_params.join(" ")
246    ))?;
247    Ok(())
248}
249
250/// Repo represents a local git repository
251#[derive(Clone, Debug, Default)]
252pub struct Repo {
253    current_branch: String,
254    dirty: bool,
255    title: String,
256    last_commit_message: String,
257}
258
259impl Repo {
260    pub fn new() -> Self {
261        Self::default()
262    }
263
264    pub fn with_current_branch(&mut self, branch: &str) {
265        self.current_branch = branch.to_string();
266    }
267
268    pub fn with_status(&mut self, dirty: bool) {
269        self.dirty = dirty;
270    }
271
272    pub fn with_title(&mut self, title: &str) {
273        self.title = title.to_string();
274    }
275
276    pub fn with_branch(&mut self, branch: &str) {
277        self.current_branch = branch.to_string();
278    }
279
280    pub fn with_last_commit_message(&mut self, message: &str) {
281        self.last_commit_message = message.to_string();
282    }
283
284    pub fn current_branch(&self) -> &str {
285        &self.current_branch
286    }
287
288    pub fn dirty(&self) -> bool {
289        self.dirty
290    }
291
292    pub fn title(&self) -> &str {
293        &self.title
294    }
295
296    pub fn last_commit_message(&self) -> &str {
297        &self.last_commit_message
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    use crate::test::utils::{get_contract, ContractType, MockRunner};
306
307    #[test]
308    fn test_git_repo_has_modified_files() {
309        let response = ShellResponse::builder()
310            .body(get_contract(
311                ContractType::Git,
312                "git_status_modified_files.txt",
313            ))
314            .build()
315            .unwrap();
316        let runner = Arc::new(MockRunner::new(vec![response]));
317        let cmd_info = status(runner).unwrap();
318        if let CmdInfo::StatusModified(dirty) = cmd_info {
319            assert!(dirty);
320        } else {
321            panic!("Expected CmdInfo::StatusModified");
322        }
323    }
324
325    #[test]
326    fn test_git_repo_has_untracked_and_modified_files_is_modified() {
327        let response = ShellResponse::builder()
328            .body(get_contract(
329                ContractType::Git,
330                "git_status_untracked_and_modified_files.txt",
331            ))
332            .build()
333            .unwrap();
334        let runner = Arc::new(MockRunner::new(vec![response]));
335        let cmd_info = status(runner).unwrap();
336        if let CmdInfo::StatusModified(dirty) = cmd_info {
337            assert!(dirty);
338        } else {
339            panic!("Expected CmdInfo::StatusModified");
340        }
341    }
342
343    #[test]
344    fn test_git_status_command_is_correct() {
345        let response = ShellResponse::builder().build().unwrap();
346        let runner = Arc::new(MockRunner::new(vec![response]));
347        status(runner.clone()).unwrap();
348        // assert_eq!("git status --short", runner.cmd.borrow().as_str());
349        assert_eq!("git status --short", *runner.cmd());
350    }
351
352    #[test]
353    fn test_git_repo_is_clean() {
354        let response = ShellResponse::builder()
355            .body(get_contract(ContractType::Git, "git_status_clean_repo.txt"))
356            .build()
357            .unwrap();
358        let runner = Arc::new(MockRunner::new(vec![response]));
359        let cmd_info = status(runner).unwrap();
360        if let CmdInfo::StatusModified(dirty) = cmd_info {
361            assert!(!dirty);
362        } else {
363            panic!("Expected CmdInfo::StatusModified");
364        }
365    }
366
367    #[test]
368    fn test_git_repo_has_untracked_files_treats_repo_as_no_local_modifications() {
369        let response = ShellResponse::builder()
370            .body(get_contract(
371                ContractType::Git,
372                "git_status_untracked_files.txt",
373            ))
374            .build()
375            .unwrap();
376        let runner = Arc::new(MockRunner::new(vec![response]));
377        let cmd_info = status(runner).unwrap();
378        if let CmdInfo::StatusModified(dirty) = cmd_info {
379            assert!(!dirty);
380        } else {
381            panic!("Expected CmdInfo::StatusModified");
382        }
383    }
384
385    #[test]
386    fn test_git_remote_url_cmd_is_correct() {
387        let response = ShellResponse::builder()
388            .body("git@github.com:jordilin/mr.git".to_string())
389            .build()
390            .unwrap();
391        let runner = MockRunner::new(vec![response]);
392        remote_url(&runner).unwrap();
393        assert_eq!("git remote get-url --all origin", *runner.cmd());
394    }
395
396    #[test]
397    fn test_get_remote_git_url() {
398        let response = ShellResponse::builder()
399            .body("git@github.com:jordilin/mr.git".to_string())
400            .build()
401            .unwrap();
402        let runner = MockRunner::new(vec![response]);
403        let cmdinfo = remote_url(&runner).unwrap();
404        match cmdinfo {
405            CmdInfo::RemoteUrl(url) => {
406                assert_eq!("github.com", url.domain());
407                assert_eq!("jordilin/mr", url.path());
408                assert_eq!("jordilin_mr", url.config_encoded_project_path());
409            }
410            _ => panic!("Failed to parse remote url"),
411        }
412    }
413
414    #[test]
415    fn test_get_remote_https_url() {
416        let response = ShellResponse::builder()
417            .body("https://github.com/jordilin/gitar.git".to_string())
418            .build()
419            .unwrap();
420        let runner = MockRunner::new(vec![response]);
421        let cmdinfo = remote_url(&runner).unwrap();
422        match cmdinfo {
423            CmdInfo::RemoteUrl(url) => {
424                assert_eq!("github.com", url.domain());
425                assert_eq!("jordilin/gitar", url.path());
426                assert_eq!("jordilin_gitar", url.config_encoded_project_path());
427            }
428            _ => panic!("Failed to parse remote url"),
429        }
430    }
431
432    #[test]
433    fn test_get_remote_ssh_url() {
434        let response = ShellResponse::builder()
435            .body("ssh://git@gitlab-web:2222/testgroup/testsubproject.git".to_string())
436            .build()
437            .unwrap();
438        let runner = MockRunner::new(vec![response]);
439        let cmdinfo = remote_url(&runner).unwrap();
440        match cmdinfo {
441            CmdInfo::RemoteUrl(url) => {
442                assert_eq!("gitlab-web", url.domain());
443                assert_eq!("testgroup/testsubproject", url.path());
444                assert_eq!(
445                    "testgroup_testsubproject",
446                    url.config_encoded_project_path()
447                );
448            }
449            _ => panic!("Failed to parse remote url"),
450        }
451    }
452
453    #[test]
454    fn test_remote_url_no_remote() {
455        let response = ShellResponse::builder()
456            .status(1)
457            .body("error: No such remote 'origin'".to_string())
458            .build()
459            .unwrap();
460        let runner = MockRunner::new(vec![response]);
461        assert!(remote_url(&runner).is_err())
462    }
463
464    #[test]
465    fn test_empty_remote_url() {
466        let response = ShellResponse::builder().build().unwrap();
467        let runner = MockRunner::new(vec![response]);
468        assert!(remote_url(&runner).is_err())
469    }
470
471    #[test]
472    fn test_git_fetch_cmd_is_correct() {
473        let response = ShellResponse::builder().build().unwrap();
474        let runner = Arc::new(MockRunner::new(vec![response]));
475        fetch(runner.clone(), "origin".to_string()).unwrap();
476        assert_eq!("git fetch origin", *runner.cmd());
477    }
478
479    #[test]
480    fn test_gather_current_branch_cmd_is_correct() {
481        let response = ShellResponse::builder().build().unwrap();
482        let runner = Arc::new(MockRunner::new(vec![response]));
483        current_branch(runner.clone()).unwrap();
484        assert_eq!("git rev-parse --abbrev-ref HEAD", *runner.cmd());
485    }
486
487    #[test]
488    fn test_gather_current_branch_ok() {
489        let response = ShellResponse::builder()
490            .body(get_contract(ContractType::Git, "git_current_branch.txt"))
491            .build()
492            .unwrap();
493        let runner = Arc::new(MockRunner::new(vec![response]));
494        let cmdinfo = current_branch(runner).unwrap();
495        if let CmdInfo::Branch(branch) = cmdinfo {
496            assert_eq!("main", branch);
497        } else {
498            panic!("Expected CmdInfo::Branch");
499        }
500    }
501
502    #[test]
503    fn test_last_commit_summary_cmd_is_correct() {
504        let response = ShellResponse::builder()
505            .body("Add README".to_string())
506            .build()
507            .unwrap();
508        let runner = Arc::new(MockRunner::new(vec![response]));
509        commit_summary(runner.clone(), &None).unwrap();
510        assert_eq!("git log --format=%s -n1", *runner.cmd());
511    }
512
513    #[test]
514    fn test_last_commit_summary_get_last_commit() {
515        let response = ShellResponse::builder()
516            .body("Add README".to_string())
517            .build()
518            .unwrap();
519        let runner = MockRunner::new(vec![response]);
520        let title = commit_summary(Arc::new(runner), &None).unwrap();
521        if let CmdInfo::CommitSummary(title) = title {
522            assert_eq!("Add README", title);
523        } else {
524            panic!("Expected CmdInfo::LastCommitSummary");
525        }
526    }
527
528    #[test]
529    fn test_last_commit_summary_errors() {
530        let response = ShellResponse::builder()
531            .status(1)
532            .body("Could not retrieve last commit".to_string())
533            .build()
534            .unwrap();
535        let runner = Arc::new(MockRunner::new(vec![response]));
536        assert!(commit_summary(runner, &None).is_err());
537    }
538
539    #[test]
540    fn test_commit_summary_specific_sha_cmd_is_correct() {
541        let response = ShellResponse::builder()
542            .body("Add README".to_string())
543            .build()
544            .unwrap();
545        let runner = Arc::new(MockRunner::new(vec![response]));
546        commit_summary(runner.clone(), &Some("123456".to_string())).unwrap();
547        assert_eq!("git log --format=%s -n1 123456", *runner.cmd());
548    }
549
550    #[test]
551    fn test_git_push_cmd_is_correct() {
552        let response = ShellResponse::builder().build().unwrap();
553        let runner = MockRunner::new(vec![response]);
554        let mut repo = Repo::new();
555        repo.with_current_branch("new_feature");
556        push(&runner, "origin", &repo, false).unwrap();
557        assert_eq!("git push origin new_feature", *runner.cmd());
558    }
559
560    #[test]
561    fn test_git_push_cmd_fails() {
562        let response = ShellResponse::builder()
563            .status(1)
564            .body(get_contract(ContractType::Git, "git_push_failure.txt"))
565            .build()
566            .unwrap();
567        let runner = MockRunner::new(vec![response]);
568        let mut repo = Repo::new();
569        repo.with_current_branch("new_feature");
570        assert!(push(&runner, "origin", &repo, false).is_err());
571    }
572
573    #[test]
574    fn test_git_force_push_cmd_is_correct() {
575        let response = ShellResponse::builder().build().unwrap();
576        let runner = MockRunner::new(vec![response]);
577        let mut repo = Repo::new();
578        repo.with_current_branch("new_feature");
579        let force = true;
580        push(&runner, "origin", &repo, force).unwrap();
581        assert_eq!("git push origin +new_feature", *runner.cmd());
582    }
583
584    #[test]
585    fn test_repo_is_dirty_if_there_are_local_changes() {
586        let mut repo = Repo::new();
587        repo.with_status(true);
588        assert!(repo.dirty())
589    }
590
591    #[test]
592    fn test_repo_title_based_on_cmdinfo_lastcommit_summary() {
593        let mut repo = Repo::new();
594        repo.with_title("Add README");
595        assert_eq!(repo.title(), "Add README")
596    }
597
598    #[test]
599    fn test_repo_current_branch_based_on_cmdinfo_branch() {
600        let mut repo = Repo::new();
601        repo.with_current_branch("new_feature");
602        assert_eq!(repo.current_branch(), "new_feature")
603    }
604
605    #[test]
606    fn test_git_rebase_cmd_is_correct() {
607        let response = ShellResponse::builder().build().unwrap();
608        let runner = MockRunner::new(vec![response]);
609        rebase(&runner, "origin/main").unwrap();
610        assert_eq!("git rebase origin/main", *runner.cmd());
611    }
612
613    #[test]
614    fn test_git_rebase_fails_throws_error() {
615        let response = ShellResponse::builder()
616            .status(1)
617            .body(get_contract(
618                ContractType::Git,
619                "git_rebase_wrong_origin.txt",
620            ))
621            .build()
622            .unwrap();
623        let runner = MockRunner::new(vec![response]);
624        assert!(rebase(&runner, "origin/main").is_err())
625    }
626
627    #[test]
628    fn test_outgoing_commits_cmd_is_ok_short_summary() {
629        let response = ShellResponse::builder().build().unwrap();
630        let runner = MockRunner::new(vec![response]);
631        outgoing_commits(&runner, "origin", "main", &SummaryOptions::Short).unwrap();
632        let expected_cmd = "git log origin/main.. --reverse --pretty=format:%s - %h %d".to_string();
633        assert_eq!(expected_cmd, *runner.cmd());
634    }
635
636    #[test]
637    fn test_outgoing_commits_cmd_is_ok_long_summary() {
638        let response = ShellResponse::builder().build().unwrap();
639        let runner = MockRunner::new(vec![response]);
640        outgoing_commits(&runner, "origin", "main", &SummaryOptions::Long).unwrap();
641        let expected_cmd =
642            "git log origin/main.. --reverse --pretty=format:%s - %h %d%n%b".to_string();
643        assert_eq!(expected_cmd, *runner.cmd());
644    }
645
646    #[test]
647    fn test_outgoing_commits_cmd_error_no_summary_option() {
648        let response = ShellResponse::builder().build().unwrap();
649        let runner = MockRunner::new(vec![response]);
650        let result = outgoing_commits(&runner, "origin", "main", &SummaryOptions::None);
651        match result {
652            Err(err) => match err.downcast_ref::<GRError>() {
653                Some(GRError::ApplicationError(_)) => (),
654                _ => panic!("Expected ApplicationError"),
655            },
656            _ => panic!("Expected ApplicationError"),
657        }
658    }
659
660    #[test]
661    fn test_patch_cmd_is_ok() {
662        let response = ShellResponse::builder().build().unwrap();
663        let runner = MockRunner::new(vec![response]);
664        patch(&runner, "feature", "main").unwrap();
665        let expected_cmd = "git diff main feature".to_string();
666        assert_eq!(expected_cmd, *runner.cmd());
667    }
668
669    #[test]
670    fn test_last_commit_message_cmd_is_ok() {
671        let response = ShellResponse::builder().build().unwrap();
672        let runner = Arc::new(MockRunner::new(vec![response]));
673        commit_message(runner.clone(), &None).unwrap();
674        let expected_cmd = "git log --pretty=format:%b -n1".to_string();
675        assert_eq!(expected_cmd, *runner.cmd());
676    }
677
678    #[test]
679    fn test_commit_message_from_specific_commit_cmd_is_ok() {
680        let response = ShellResponse::builder().build().unwrap();
681        let runner = Arc::new(MockRunner::new(vec![response]));
682        commit_message(runner.clone(), &Some("123456".to_string())).unwrap();
683        let expected_cmd = "git log --pretty=format:%b -n1 123456".to_string();
684        assert_eq!(expected_cmd, *runner.cmd());
685    }
686
687    #[test]
688    fn test_git_add_changes_cmd_is_ok() {
689        let response = ShellResponse::builder().build().unwrap();
690        let runner = MockRunner::new(vec![response]);
691        add(&runner).unwrap();
692        let expected_cmd = "git add -u".to_string();
693        assert_eq!(expected_cmd, *runner.cmd());
694    }
695
696    #[test]
697    fn test_git_add_changes_cmd_is_err() {
698        let response = ShellResponse::builder()
699            .status(1)
700            .body("error: could not add changes".to_string())
701            .build()
702            .unwrap();
703        let runner = MockRunner::new(vec![response]);
704        assert!(add(&runner).is_err());
705    }
706
707    #[test]
708    fn test_git_commit_message_is_ok() {
709        let response = ShellResponse::builder()
710            .body("Add README".to_string())
711            .build()
712            .unwrap();
713        let runner = MockRunner::new(vec![response]);
714        commit(&runner, "Add README").unwrap();
715        let expected_cmd = "git commit -m Add README".to_string();
716        assert_eq!(expected_cmd, *runner.cmd());
717    }
718
719    #[test]
720    fn test_git_commit_message_is_err() {
721        let response = ShellResponse::builder()
722            .status(1)
723            .body("error: could not commit changes".to_string())
724            .build()
725            .unwrap();
726        let runner = MockRunner::new(vec![response]);
727        assert!(commit(&runner, "Add README").is_err());
728    }
729}