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 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 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 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 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 let empty_str = &String::new(); 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 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 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}