git_together_ssh/
lib.rs

1#![recursion_limit = "1024"]
2#[macro_use]
3extern crate error_chain;
4extern crate git2;
5extern crate shellexpand;
6
7use std::collections::HashMap;
8use std::env;
9use std::ffi::OsString;
10use std::path::PathBuf;
11use std::process::Command;
12
13use dialoguer::Input;
14use dialoguer::theme::ColorfulTheme;
15
16use author::{Author, AuthorParser};
17use config::Config;
18use errors::*;
19
20pub mod author;
21pub mod config;
22pub mod errors;
23pub mod git;
24
25const NAMESPACE: &str = "git-together";
26const TRIGGERS: [&str; 2] = ["with", "together"];
27
28fn namespaced(name: &str) -> String {
29    format!("{}.{}", NAMESPACE, name)
30}
31
32pub fn run() -> Result<i32> {
33    let all_args: Vec<_> = env::args().skip(1).collect();
34    let mut args: Vec<&str> = all_args.iter().map(String::as_ref).collect();
35
36    let mut gt = if args.contains(&"--global") {
37        GitTogether::new(ConfigScope::Global)
38    } else {
39        GitTogether::new(ConfigScope::Local)
40    }?;
41
42    args.retain(|&arg| arg != "--global");
43
44    let mut skip_next = false;
45    let command = args
46        .iter()
47        .find(|x| {
48            if skip_next {
49                skip_next = false;
50                return false;
51            }
52            match x {
53                &&"-c" | &&"--exec-path" | &&"--git-dir" | &&"--work-tree" | &&"--namespace"
54                | &&"--super-prefix" | &&"--list-cmds" | &&"-C" => {
55                    skip_next = true;
56                    false
57                }
58                &&"--version" | &&"--help" => true,
59                v if v.starts_with('-') => false,
60                _ => true,
61            }
62        })
63        .unwrap_or(&"");
64
65    let mut split_args = args.split(|x| x == command);
66    let global_args = split_args.next().unwrap_or(&[]);
67    let command_args = split_args.next().unwrap_or(&[]);
68
69    let code = if TRIGGERS.contains(command) {
70        match command_args {
71            [] => {
72                let inits = gt.get_active()?;
73                let inits: Vec<_> = inits.iter().map(String::as_ref).collect();
74                let authors = gt.get_authors(&inits)?;
75
76                for (initials, author) in inits.iter().zip(authors.iter()) {
77                    println!("{}: {}", initials, author);
78                }
79            }
80            ["--list"] => {
81                let authors = gt.all_authors()?;
82                let mut sorted: Vec<_> = authors.iter().collect();
83                sorted.sort_by(|a, b| a.0.cmp(b.0));
84
85                for (initials, author) in sorted {
86                    println!("{}: {}", initials, author);
87                }
88            }
89            ["--clear"] => {
90                gt.clear_active()?;
91            }
92            ["--version"] => {
93                println!(
94                    "{} {}",
95                    option_env!("CARGO_PKG_NAME").unwrap_or("git-together"),
96                    option_env!("CARGO_PKG_VERSION").unwrap_or("unknown version")
97                );
98            }
99            _ => {
100                let authors = gt.set_active(command_args)?;
101                for author in authors {
102                    println!("{}", author);
103                }
104            }
105        }
106
107        0
108    } else if gt.is_signoff_cmd(command) {
109        if command == &"merge" {
110            env::set_var("GIT_TOGETHER_NO_SIGNOFF", "1");
111        }
112
113        let mut cmd = Command::new("git");
114        let cmd = cmd.args(global_args);
115        let cmd = cmd.arg(command);
116        let cmd = gt.signoff(cmd)?;
117        let cmd = cmd.args(command_args);
118
119        let status = cmd.status().chain_err(|| "failed to execute process")?;
120        if status.success() {
121            gt.rotate_active()?;
122        }
123        status.code().ok_or("process terminated by signal")?
124    } else if gt.is_ssh_cmd(command) {
125        // check that cert exists
126        let cert_initials = gt.get_active()?.first().ok_or("Cannot get author's initials")?.to_string();
127        let cert_filename = format!("{}{}", "id_", cert_initials);
128        let cert_path: PathBuf = [shellexpand::tilde("~/.ssh").to_string(), cert_filename].iter().collect();
129        let cert_path_str = cert_path.clone().into_os_string().into_string().unwrap_or_default();
130        if !cert_path.as_path().is_file() {
131            panic!("SSH file for author '{}' not found! Expected path: {}", cert_initials, cert_path_str);
132        }
133        let git_ssh_cmd = format!("{}{}{}", "ssh -i ", cert_path_str, " -F /dev/null");
134
135        // save existing and set new env var
136        let existing_git_ssh_command = match env::var_os("GIT_SSH_COMMAND") {
137            Some(val) => val,
138            None => OsString::new()
139        };
140        env::set_var("GIT_SSH_COMMAND", git_ssh_cmd);
141
142        let mut cmd = Command::new("git");
143        let cmd = cmd.args(global_args);
144        let cmd = cmd.arg(command);
145        let cmd = cmd.args(command_args);
146
147        let status = cmd.status().chain_err(|| "failed to execute process")?;
148
149        // reset back to existing env var or delete if none
150        if existing_git_ssh_command.is_empty() {
151            env::remove_var("GIT_SSH_COMMAND");
152        } else {
153            env::set_var("GIT_SSH_COMMAND", existing_git_ssh_command);
154        }
155
156        status.code().ok_or("process terminated by signal")?
157    } else if gt.is_clone_cmd(command) {
158        let ssh_cert_folder_path = shellexpand::tilde("~/.ssh").to_string();
159        // Helper function to get initials directly from user input
160        let user_input_initials = || -> String {
161            let user_initials: String = Input::with_theme(&ColorfulTheme::default())
162                .with_prompt("Please enter your initials")
163                .interact_text().unwrap_or("".into());
164
165            user_initials
166        };
167        // Get initials from .git-together's active list, or from user input if:
168        //      - .git-together file is not found
169        //      - .git-together file's active list is empty
170        let empty_str = &String::new(); // need this out here due to Rust ownership rules
171        let active_initials = gt.get_active().unwrap_or(vec![]).first().unwrap_or(empty_str).to_string();
172        let cert_initials = if active_initials.is_empty() { user_input_initials() } else { active_initials.to_string() };
173
174        let cert_filename = format!("{}{}", "id_", cert_initials);
175        let cert_path: PathBuf = [ssh_cert_folder_path, cert_filename].iter().collect();
176        let cert_path_str = cert_path.clone().into_os_string().into_string().unwrap_or_default();
177        if !cert_path.as_path().is_file() {
178            panic!("SSH file for author '{}' not found! Expected path: {}", cert_initials, cert_path_str);
179        }
180        let git_ssh_cmd = format!("{}{}{}", "ssh -i ", cert_path_str, " -F /dev/null");
181
182        // save existing and set new env var
183        let existing_git_ssh_command = match env::var_os("GIT_SSH_COMMAND") {
184            Some(val) => val,
185            None => OsString::new()
186        };
187        env::set_var("GIT_SSH_COMMAND", git_ssh_cmd);
188
189        let mut cmd = Command::new("git");
190        let cmd = cmd.args(global_args);
191        let cmd = cmd.arg(command);
192        let cmd = cmd.args(command_args);
193
194        let status = cmd.status().chain_err(|| "failed to execute process")?;
195
196        // reset back to existing env var or delete if none
197        if existing_git_ssh_command.is_empty() {
198            env::remove_var("GIT_SSH_COMMAND");
199        } else {
200            env::set_var("GIT_SSH_COMMAND", existing_git_ssh_command);
201        }
202
203        status.code().ok_or("process terminated by signal")?
204    } else {
205        let status = Command::new("git")
206            .args(args)
207            .status()
208            .chain_err(|| "failed to execute process")?;
209
210        status.code().ok_or("process terminated by signal")?
211    };
212
213    Ok(code)
214}
215
216pub struct GitTogether<C> {
217    config: C,
218    author_parser: AuthorParser,
219}
220
221pub enum ConfigScope {
222    Local,
223    Global,
224}
225
226impl GitTogether<git::Config> {
227    pub fn new(scope: ConfigScope) -> Result<Self> {
228        let config = match scope {
229            ConfigScope::Local => {
230                let repo = git::Repo::new();
231                if let Ok(ref repo) = repo {
232                    let _ = repo.auto_include(&format!(".{}", NAMESPACE));
233                };
234
235                repo.and_then(|r| r.config())
236                    .or_else(|_| git::Config::new(scope))?
237            }
238            ConfigScope::Global => git::Config::new(scope)?,
239        };
240
241        let domain = config.get(&namespaced("domain")).ok();
242        let author_parser = AuthorParser { domain };
243
244        Ok(GitTogether {
245            config,
246            author_parser,
247        })
248    }
249}
250
251impl<C: config::Config> GitTogether<C> {
252    pub fn set_active(&mut self, inits: &[&str]) -> Result<Vec<Author>> {
253        let authors = self.get_authors(inits)?;
254        self.config.set(&namespaced("active"), &inits.join("+"))?;
255
256        self.save_original_user()?;
257        if let Some(author) = authors.get(0) {
258            self.set_user(&author.name, &author.email)?;
259        }
260
261        Ok(authors)
262    }
263
264    pub fn clear_active(&mut self) -> Result<()> {
265        self.config.clear(&namespaced("active"))?;
266
267        let _ = self.config.clear("user.name");
268        let _ = self.config.clear("user.email");
269
270        Ok(())
271    }
272
273    fn save_original_user(&mut self) -> Result<()> {
274        if let Ok(name) = self.config.get("user.name") {
275            let key = namespaced("user.name");
276            self.config
277                .get(&key)
278                .map(|_| ())
279                .or_else(|_| self.config.set(&key, &name))?;
280        }
281
282        if let Ok(email) = self.config.get("user.email") {
283            let key = namespaced("user.email");
284            self.config
285                .get(&key)
286                .map(|_| ())
287                .or_else(|_| self.config.set(&key, &email))?;
288        }
289
290        Ok(())
291    }
292
293    fn set_user(&mut self, name: &str, email: &str) -> Result<()> {
294        self.config.set("user.name", name)?;
295        self.config.set("user.email", email)?;
296
297        Ok(())
298    }
299
300    pub fn all_authors(&self) -> Result<HashMap<String, Author>> {
301        let mut authors = HashMap::new();
302        let raw = self.config.get_all(&namespaced("authors."))?;
303        for (name, value) in raw {
304            let initials = name.split('.').last().ok_or("")?;
305            let author = self.parse_author(initials, &value)?;
306            authors.insert(initials.into(), author);
307        }
308        Ok(authors)
309    }
310
311    pub fn is_signoff_cmd(&self, cmd: &str) -> bool {
312        let signoffs = ["commit", "merge", "revert"];
313        signoffs.contains(&cmd) || self.is_alias(cmd)
314    }
315
316    pub fn is_ssh_cmd(&self, cmd: &str) -> bool {
317        let ssh_cmds = ["fetch", "pull", "push"];
318        ssh_cmds.contains(&cmd) || self.is_alias(cmd)
319    }
320
321    pub fn is_clone_cmd(&self, cmd: &str) -> bool {
322        let cmd_array = ["clone"];
323        cmd_array.contains(&cmd) || self.is_alias(cmd)
324    }
325
326    fn is_alias(&self, cmd: &str) -> bool {
327        self.config
328            .get(&namespaced("aliases"))
329            .unwrap_or_else(|_| String::new())
330            .split(',')
331            .filter(|s| s != &"")
332            .any(|a| a == cmd)
333    }
334
335    pub fn signoff<'a>(&self, cmd: &'a mut Command) -> Result<&'a mut Command> {
336        let active = self.config.get(&namespaced("active"))?;
337        let inits: Vec<_> = active.split('+').collect();
338        let authors = self.get_authors(&inits)?;
339
340        let (author, committer) = match *authors.as_slice() {
341            [] => {
342                return Err("".into());
343            }
344            [ref solo] => (solo, solo),
345            [ref author, ref committer, ..] => (author, committer),
346        };
347
348        let cmd = cmd
349            .env("GIT_AUTHOR_NAME", author.name.clone())
350            .env("GIT_AUTHOR_EMAIL", author.email.clone())
351            .env("GIT_COMMITTER_NAME", committer.name.clone())
352            .env("GIT_COMMITTER_EMAIL", committer.email.clone());
353
354        let no_signoff = env::var("GIT_TOGETHER_NO_SIGNOFF").is_ok();
355        Ok(if !no_signoff && author != committer {
356            cmd.arg("--signoff")
357        } else {
358            cmd
359        })
360    }
361
362    fn get_active(&self) -> Result<Vec<String>> {
363        self.config
364            .get(&namespaced("active"))
365            .map(|active| active.split('+').map(|s| s.into()).collect())
366    }
367
368    pub fn rotate_active(&mut self) -> Result<()> {
369        self.get_active().and_then(|active| {
370            let mut inits: Vec<_> = active.iter().map(String::as_ref).collect();
371            if !inits.is_empty() {
372                let author = inits.remove(0);
373                inits.push(author);
374            }
375            self.set_active(&inits[..]).map(|_| ())
376        })
377    }
378
379    fn get_authors(&self, inits: &[&str]) -> Result<Vec<Author>> {
380        inits
381            .iter()
382            .map(|&initials| self.get_author(initials))
383            .collect()
384    }
385
386    fn get_author(&self, initials: &str) -> Result<Author> {
387        self.config
388            .get(&namespaced(&format!("authors.{}", initials)))
389            .chain_err(|| format!("author not found for '{}'", initials))
390            .and_then(|raw| self.parse_author(initials, &raw))
391    }
392
393    fn parse_author(&self, initials: &str, raw: &str) -> Result<Author> {
394        self.author_parser
395            .parse(raw)
396            .chain_err(|| format!("invalid author for '{}': '{}'", initials, raw))
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use std::collections::HashMap;
403    use std::ops::Index;
404
405    use author::{Author, AuthorParser};
406    use config::Config;
407
408    use super::*;
409
410    #[test]
411    fn get_authors() {
412        let config = MockConfig::new(&[
413            ("git-together.authors.jh", ""),
414            ("git-together.authors.nn", "Naomi Nagata"),
415            ("git-together.authors.ab", "Amos Burton; aburton"),
416            ("git-together.authors.ak", "Alex Kamal; akamal"),
417            ("git-together.authors.ca", "Chrisjen Avasarala;"),
418            ("git-together.authors.bd", "Bobbie Draper; bdraper@mars.mil"),
419            (
420                "git-together.authors.jm",
421                "Joe Miller; jmiller@starhelix.com",
422            ),
423        ]);
424        let author_parser = AuthorParser {
425            domain: Some("rocinante.com".into()),
426        };
427        let gt = GitTogether {
428            config,
429            author_parser,
430        };
431
432        assert!(gt.get_authors(&["jh"]).is_err());
433        assert!(gt.get_authors(&["nn"]).is_err());
434        assert!(gt.get_authors(&["ca"]).is_err());
435        assert!(gt.get_authors(&["jh", "bd"]).is_err());
436
437        assert_eq!(
438            gt.get_authors(&["ab", "ak"]).unwrap(),
439            vec![
440                Author {
441                    name: "Amos Burton".into(),
442                    email: "aburton@rocinante.com".into(),
443                },
444                Author {
445                    name: "Alex Kamal".into(),
446                    email: "akamal@rocinante.com".into(),
447                },
448            ]
449        );
450        assert_eq!(
451            gt.get_authors(&["ab", "bd", "jm"]).unwrap(),
452            vec![
453                Author {
454                    name: "Amos Burton".into(),
455                    email: "aburton@rocinante.com".into(),
456                },
457                Author {
458                    name: "Bobbie Draper".into(),
459                    email: "bdraper@mars.mil".into(),
460                },
461                Author {
462                    name: "Joe Miller".into(),
463                    email: "jmiller@starhelix.com".into(),
464                },
465            ]
466        );
467    }
468
469    #[test]
470    fn set_active_solo() {
471        let config = MockConfig::new(&[
472            ("git-together.authors.jh", "James Holden; jholden"),
473            ("git-together.authors.nn", "Naomi Nagata; nnagata"),
474            ("user.name", "Bobbie Draper"),
475            ("user.email", "bdraper@mars.mil"),
476        ]);
477        let author_parser = AuthorParser {
478            domain: Some("rocinante.com".into()),
479        };
480        let mut gt = GitTogether {
481            config,
482            author_parser,
483        };
484
485        gt.set_active(&["jh"]).unwrap();
486        assert_eq!(gt.get_active().unwrap(), vec!["jh"]);
487        assert_eq!(gt.config["user.name"], "James Holden");
488        assert_eq!(gt.config["user.email"], "jholden@rocinante.com");
489        assert_eq!(gt.config["git-together.user.name"], "Bobbie Draper");
490        assert_eq!(gt.config["git-together.user.email"], "bdraper@mars.mil");
491    }
492
493    #[test]
494    fn set_active_pair() {
495        let config = MockConfig::new(&[
496            ("git-together.authors.jh", "James Holden; jholden"),
497            ("git-together.authors.nn", "Naomi Nagata; nnagata"),
498            ("user.name", "Bobbie Draper"),
499            ("user.email", "bdraper@mars.mil"),
500        ]);
501        let author_parser = AuthorParser {
502            domain: Some("rocinante.com".into()),
503        };
504        let mut gt = GitTogether {
505            config,
506            author_parser,
507        };
508
509        gt.set_active(&["nn", "jh"]).unwrap();
510        assert_eq!(gt.get_active().unwrap(), vec!["nn", "jh"]);
511        assert_eq!(gt.config["user.name"], "Naomi Nagata");
512        assert_eq!(gt.config["user.email"], "nnagata@rocinante.com");
513        assert_eq!(gt.config["git-together.user.name"], "Bobbie Draper");
514        assert_eq!(gt.config["git-together.user.email"], "bdraper@mars.mil");
515    }
516
517    #[test]
518    fn clear_active_pair() {
519        let config = MockConfig::new(&[
520            ("git-together.authors.jh", "James Holden; jholden"),
521            ("git-together.authors.nn", "Naomi Nagata; nnagata"),
522            ("user.name", "Bobbie Draper"),
523            ("user.email", "bdraper@mars.mil"),
524        ]);
525        let author_parser = AuthorParser {
526            domain: Some("rocinante.com".into()),
527        };
528        let mut gt = GitTogether {
529            config,
530            author_parser,
531        };
532
533        gt.set_active(&["nn", "jh"]).unwrap();
534        gt.clear_active().unwrap();
535        assert!(gt.get_active().is_err());
536        assert!(gt.config.get("user.name").is_err());
537        assert!(gt.config.get("user.email").is_err());
538    }
539
540    #[test]
541    fn multiple_set_active() {
542        let config = MockConfig::new(&[
543            ("git-together.authors.jh", "James Holden; jholden"),
544            ("git-together.authors.nn", "Naomi Nagata; nnagata"),
545            ("user.name", "Bobbie Draper"),
546            ("user.email", "bdraper@mars.mil"),
547        ]);
548        let author_parser = AuthorParser {
549            domain: Some("rocinante.com".into()),
550        };
551        let mut gt = GitTogether {
552            config,
553            author_parser,
554        };
555
556        gt.set_active(&["nn"]).unwrap();
557        gt.set_active(&["jh"]).unwrap();
558        assert_eq!(gt.config["git-together.user.name"], "Bobbie Draper");
559        assert_eq!(gt.config["git-together.user.email"], "bdraper@mars.mil");
560    }
561
562    #[test]
563    fn rotate_active() {
564        let config = MockConfig::new(&[
565            ("git-together.active", "jh+nn"),
566            ("git-together.authors.jh", "James Holden; jholden"),
567            ("git-together.authors.nn", "Naomi Nagata; nnagata"),
568        ]);
569        let author_parser = AuthorParser {
570            domain: Some("rocinante.com".into()),
571        };
572        let mut gt = GitTogether {
573            config,
574            author_parser,
575        };
576
577        gt.rotate_active().unwrap();
578        assert_eq!(gt.get_active().unwrap(), vec!["nn", "jh"]);
579    }
580
581    #[test]
582    fn all_authors() {
583        let config = MockConfig::new(&[
584            ("git-together.active", "jh+nn"),
585            ("git-together.authors.ab", "Amos Burton; aburton"),
586            ("git-together.authors.bd", "Bobbie Draper; bdraper@mars.mil"),
587            (
588                "git-together.authors.jm",
589                "Joe Miller; jmiller@starhelix.com",
590            ),
591        ]);
592        let author_parser = AuthorParser {
593            domain: Some("rocinante.com".into()),
594        };
595        let gt = GitTogether {
596            config,
597            author_parser,
598        };
599
600        let all_authors = gt.all_authors().unwrap();
601        assert_eq!(all_authors.len(), 3);
602        assert_eq!(
603            all_authors["ab"],
604            Author {
605                name: "Amos Burton".into(),
606                email: "aburton@rocinante.com".into(),
607            }
608        );
609        assert_eq!(
610            all_authors["bd"],
611            Author {
612                name: "Bobbie Draper".into(),
613                email: "bdraper@mars.mil".into(),
614            }
615        );
616        assert_eq!(
617            all_authors["jm"],
618            Author {
619                name: "Joe Miller".into(),
620                email: "jmiller@starhelix.com".into(),
621            }
622        );
623    }
624
625    #[test]
626    fn is_signoff_cmd_basics() {
627        let config = MockConfig::new(&[]);
628        let author_parser = AuthorParser {
629            domain: Some("rocinante.com".into()),
630        };
631        let gt = GitTogether {
632            config,
633            author_parser,
634        };
635
636        assert_eq!(gt.is_signoff_cmd("commit"), true);
637        assert_eq!(gt.is_signoff_cmd("merge"), true);
638        assert_eq!(gt.is_signoff_cmd("revert"), true);
639        assert_eq!(gt.is_signoff_cmd("bisect"), false);
640    }
641
642    #[test]
643    fn is_signoff_cmd_aliases() {
644        let config = MockConfig::new(&[("git-together.aliases", "ci,m,rv")]);
645        let author_parser = AuthorParser {
646            domain: Some("rocinante.com".into()),
647        };
648        let gt = GitTogether {
649            config,
650            author_parser,
651        };
652
653        assert_eq!(gt.is_signoff_cmd("ci"), true);
654        assert_eq!(gt.is_signoff_cmd("m"), true);
655        assert_eq!(gt.is_signoff_cmd("rv"), true);
656    }
657
658    struct MockConfig {
659        data: HashMap<String, String>,
660    }
661
662    impl MockConfig {
663        fn new(data: &[(&str, &str)]) -> MockConfig {
664            MockConfig {
665                data: data.iter().map(|&(k, v)| (k.into(), v.into())).collect(),
666            }
667        }
668    }
669
670    impl<'a> Index<&'a str> for MockConfig {
671        type Output = String;
672
673        fn index(&self, key: &'a str) -> &String {
674            self.data.index(key)
675        }
676    }
677
678    impl Config for MockConfig {
679        fn get(&self, name: &str) -> Result<String> {
680            self.data
681                .get(name.into())
682                .cloned()
683                .ok_or(format!("name not found: '{}'", name).into())
684        }
685
686        fn get_all(&self, glob: &str) -> Result<HashMap<String, String>> {
687            Ok(self
688                .data
689                .iter()
690                .filter(|&(name, _)| name.contains(glob))
691                .map(|(name, value)| (name.clone(), value.clone()))
692                .collect())
693        }
694
695        fn add(&mut self, _: &str, _: &str) -> Result<()> {
696            unimplemented!();
697        }
698
699        fn set(&mut self, name: &str, value: &str) -> Result<()> {
700            self.data.insert(name.into(), value.into());
701            Ok(())
702        }
703
704        fn clear(&mut self, name: &str) -> Result<()> {
705            self.data.remove(name.into());
706            Ok(())
707        }
708    }
709}