1use anyhow::{anyhow, Context, Result};
7use colored_markup::{println_markup, StyleSheet};
8use inquire::Confirm;
9use path_absolutize::Absolutize;
10use patharg::InputArg;
11use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13use std::env;
14use std::fmt;
15use std::fs;
16use std::io;
17use std::io::Read;
18use std::path::{Display, Path, PathBuf};
19use std::process::Command;
20use tabled::{Table, Tabled};
21use walkdir::WalkDir;
22
23#[derive(Debug, Deserialize, Serialize)]
25pub struct RepositoryEntry {
26 pub path: PathBuf,
28}
29
30impl RepositoryEntry {
31 fn current_branch(&self) -> Result<String> {
32 let repo = git2::Repository::open(&self.path)?;
33 let head = repo.head()?;
34 let branch = head.shorthand().unwrap();
35 Ok(branch.to_string())
36 }
37
38 fn behind_remote(&self) -> Result<Option<bool>> {
39 let repo = git2::Repository::open(&self.path)?;
40 let head = repo.head()?;
41 let branch = head.shorthand().unwrap();
42 let branch = repo.find_branch(branch, git2::BranchType::Local)?;
43 if branch.upstream().is_err() {
44 return Ok(None);
45 }
46 let upstream = branch.upstream()?;
47 let (_, behind) = repo.graph_ahead_behind(
48 branch.get().target().unwrap(),
49 upstream.get().target().unwrap(),
50 )?;
51 Ok(Some(behind > 0))
52 }
53
54 fn ahead_remote(&self) -> Result<Option<bool>> {
55 let repo = git2::Repository::open(&self.path)?;
56 let head = repo.head()?;
57 let branch = head.shorthand().unwrap();
58 let branch = repo.find_branch(branch, git2::BranchType::Local)?;
59 if branch.upstream().is_err() {
61 return Ok(None);
62 }
63 let upstream = branch.upstream()?;
64 let (ahead, _) = repo.graph_ahead_behind(
65 branch.get().target().unwrap(),
66 upstream.get().target().unwrap(),
67 )?;
68 Ok(Some(ahead > 0))
69 }
70
71 fn has_stashes(&self) -> Result<bool> {
72 let mut repo = git2::Repository::open(&self.path)?;
73 let mut has_stashes = false;
74 repo.stash_foreach(|_, _, _| {
75 has_stashes = true;
76 false
77 })?;
78 Ok(has_stashes)
79 }
80}
81
82#[derive(Debug, Deserialize, Serialize)]
84pub struct DirectoryEntry {
85 pub path: PathBuf,
87}
88
89impl RepositoryEntry {
90 pub fn state(&self) -> Result<RepositoryState> {
94 let mut state = RepositoryState {
95 entries: HashSet::new(),
96 };
97
98 let git_repo = git2::Repository::open(&self.path)?;
99 let mut status_options = git2::StatusOptions::new();
100 status_options.include_untracked(true);
101 status_options.include_ignored(false);
102 let statuses = git_repo.statuses(Some(&mut status_options))?;
103 for status in statuses.into_iter() {
104 match status.status() {
105 git2::Status::INDEX_NEW
106 | git2::Status::INDEX_MODIFIED
107 | git2::Status::INDEX_DELETED
108 | git2::Status::INDEX_RENAMED
109 | git2::Status::INDEX_TYPECHANGE
110 | git2::Status::WT_NEW
111 | git2::Status::WT_MODIFIED
112 | git2::Status::WT_DELETED
113 | git2::Status::WT_TYPECHANGE
114 | git2::Status::WT_RENAMED
115 | git2::Status::CONFLICTED => {
116 state.entries.insert(EntryState::Dirty);
117 }
118 _ => {}
119 }
120 }
121 anyhow::Ok(state)
122 }
123
124 #[allow(dead_code)]
125 fn is_dirty(&self) -> bool {
126 let state = self.state().unwrap();
127 state.entries.contains(&EntryState::Dirty)
128 }
129}
130
131#[derive(Debug, Deserialize, Serialize, Default)]
133pub struct Config {
134 #[serde(default = "HashMap::new")]
136 pub repositories: HashMap<String, RepositoryEntry>,
137
138 #[serde(default = "HashMap::new")]
140 pub directories: HashMap<String, DirectoryEntry>,
141}
142
143impl Config {
144 pub fn load(path: InputArg) -> Result<Self> {
146 let content = match path {
147 InputArg::Stdin => {
148 let mut buffer = String::new();
149 io::stdin().read_to_string(&mut buffer)?;
150 buffer
151 }
152 InputArg::Path(path) => {
153 let expanded_path = shellexpand::tilde(path.to_str().unwrap());
154 let config_path = PathBuf::from(expanded_path.to_string());
155 fs::read_to_string(config_path)
156 .map_err(|e| anyhow!("Failed to read config file: {}", e))?
157 }
158 };
159
160 toml::from_str(&content)
161 .map_err(|e| anyhow!("Failed to parse config: {}", e))
162 .or_else(|e| {
163 println!("Failed to load config: {}. Using default configuration.", e);
164 Ok(Config::default())
165 })
166 }
167
168 pub fn save(&self) -> Result<()> {
170 let config_path = "~/.config/multigit/config.toml";
171 let config_path = shellexpand::tilde(config_path);
172 let config_path = config_path.to_string();
173 let config_content = toml::to_string(&self)?;
174 std::fs::write(config_path, config_content)?;
175 anyhow::Ok(())
176 }
177
178 pub fn register(&mut self, path: &Path) -> Result<()> {
183 let absolute_path = path.absolutize().context("Failed to get absolute path")?;
184 let name = absolute_path
185 .to_str()
186 .context("Failed to convert path to string")?;
187
188 if !is_git_repository(path) {
189 let entry = DirectoryEntry {
190 path: path.to_path_buf(),
191 };
192 self.directories.insert(name.to_string(), entry);
193 } else {
194 let entry = RepositoryEntry {
195 path: path.to_path_buf(),
196 };
197 self.repositories.insert(name.to_string(), entry);
198 }
199 self.save()?;
200 anyhow::Ok(())
201 }
202
203 pub fn unregister(&mut self, path: &PathBuf) -> Result<()> {
205 let absolute_path = path.absolutize().context("Failed to get absolute path")?;
206 let name = absolute_path
207 .to_str()
208 .context("Failed to convert path to string")?;
209 self.directories.remove(name);
210 self.repositories.remove(name);
211 self.save()?;
212 anyhow::Ok(())
213 }
214}
215
216#[derive(Debug)]
218pub struct Multigit {
219 pub config: Config,
221
222 pub directory: Option<PathBuf>,
223
224 pub style_sheet: StyleSheet<'static>,
226}
227
228impl Multigit {
229 pub fn new(config: Config, directory: Option<PathBuf>) -> Result<Self> {
231 let style_sheet = StyleSheet::parse(
232 "
233 repository { foreground: cyan; }
234 status { foreground: yellow; }
235 command { foreground: green; }
236 divider { foreground: red; }
237 ",
238 )
239 .unwrap();
240
241 anyhow::Ok(Self {
242 config,
243 directory,
244 style_sheet,
245 })
246 }
247
248 fn all_repositories(&self, filter: Option<&Vec<Filter>>) -> Result<Vec<RepositoryEntry>> {
250 let mut repositories: Vec<RepositoryEntry> = Vec::new();
251
252 if self.directory.is_some() {
253 let directory = self.directory.as_ref().unwrap();
254 let directory_repositories = find_repositories(directory)?;
255 let mut repositories: Vec<RepositoryEntry> = Vec::new();
256 for repository in directory_repositories {
257 let repository = RepositoryEntry { path: repository };
258 repositories.push(repository);
259 }
260 return Ok(repositories);
261 } else {
262 for (_, repository) in self.config.repositories.iter() {
263 repositories.push(RepositoryEntry {
264 path: repository.path.clone(),
265 });
266 }
267 for (_, directory) in self.config.directories.iter() {
268 let directory_repositories = find_repositories(&directory.path)?;
269 for repository in directory_repositories {
270 let repository = RepositoryEntry { path: repository };
271 repositories.push(repository);
272 }
273 }
274 }
275
276 if let Some(filter) = filter {
277 if !filter.is_empty() {
278 repositories.retain(|repository| {
279 let state = repository.state().unwrap();
280 for f in filter {
281 match f {
282 Filter::Dirty => {
283 if state.entries.contains(&EntryState::Dirty) {
284 return true;
285 }
286 }
287 }
288 }
289 false
290 });
291 }
292 }
293
294 repositories.sort_by(|a, b| a.path.cmp(&b.path));
295 anyhow::Ok(repositories)
296 }
297
298 #[allow(dead_code)]
299 fn iter_repositories(
300 &self,
301 filter: Option<&Vec<Filter>>,
302 ) -> Result<impl Iterator<Item = RepositoryEntry>> {
303 let repositories = self.all_repositories(filter)?;
304 Ok(repositories.into_iter())
305 }
306
307 fn process_repositories<F>(
308 &self,
309 repositories: &[RepositoryEntry],
310 mut process: F,
311 ) -> Result<()>
312 where
313 F: FnMut(&RepositoryEntry) -> Result<()>,
314 {
315 let mut errors = Vec::new();
316
317 for repository in repositories {
318 if let Err(e) = process(repository) {
319 eprintln!("Error processing repository {:?}: {}", repository.path, e);
320 errors.push(RepositoryError {
321 path: repository.path.clone(),
322 error: e,
323 });
324 }
325 }
326
327 if errors.is_empty() {
328 anyhow::Ok(())
329 } else {
330 Err(anyhow!("Errors occurred in {} repositories", errors.len()))
331 }
332 }
333
334 pub fn register(&mut self, paths: &Vec<PathBuf>) -> Result<()> {
336 if paths.is_empty() {
337 self.config.register(&std::env::current_dir()?)?;
338 } else {
339 for path in paths {
340 self.config.register(path)?;
341 }
342 }
343 self.config.save()?;
344 anyhow::Ok(())
345 }
346
347 pub fn unregister(&mut self, paths: &Vec<PathBuf>, all: &bool) -> Result<()> {
349 if *all {
350 let ans = Confirm::new("Unregister all repositories and directories??")
351 .with_default(false)
352 .prompt()?;
353 match ans {
354 true => {
355 self.config.repositories.clear();
356 self.config.directories.clear();
357 }
358 false => {
359 return anyhow::Ok(());
360 }
361 }
362 } else if paths.is_empty() {
363 self.config.unregister(&std::env::current_dir()?)?;
364 } else {
365 for path in paths {
366 self.config.unregister(path)?;
367 }
368 }
369 self.config.save()?;
370 anyhow::Ok(())
371 }
372
373 pub fn list(&self, filter: Option<&Vec<Filter>>, detailed: &bool) -> Result<()> {
375 let repositories = self.all_repositories(filter)?;
376
377 #[derive(Tabled)]
378 struct Row<'a> {
379 name: String,
380 #[tabled(skip)]
381 path: Display<'a>,
382 state: RepositoryState,
383 current_branch: String,
384 #[tabled(display_with = "display_option")]
385 behind_remote: Option<bool>,
386 #[tabled(display_with = "display_option")]
387 ahead_remote: Option<bool>,
388 has_stashes: bool,
389 }
390
391 let rows = repositories.iter().map(|repository| {
392 let name = repository
393 .path
394 .file_name()
395 .unwrap()
396 .to_str()
397 .unwrap()
398 .to_string();
399 let path = repository.path.display();
400 Row {
401 name,
402 path,
403 state: repository.state().unwrap(),
404 current_branch: repository.current_branch().unwrap(),
405 behind_remote: repository.behind_remote().ok().flatten(),
406 ahead_remote: repository.ahead_remote().ok().flatten(),
407 has_stashes: repository.has_stashes().unwrap(),
408 }
409 });
410
411 if !detailed {
412 for row in rows {
413 println_markup!(&self.style_sheet, "<repository>{}</repository>", row.path);
414 }
415 } else {
416 let table = Table::new(rows).to_string();
417 println!("{}", table);
418 }
419
420 Ok(())
421 }
422
423 pub fn status(&self, filter: Option<&Vec<Filter>>) -> Result<()> {
425 let repositories = self.all_repositories(filter)?;
426 self.process_repositories(&repositories, |repository| {
427 let mut status_options = git2::StatusOptions::new();
428 status_options.include_untracked(true);
429 status_options.include_ignored(false);
430 let repo = git2::Repository::open(&repository.path)?;
431 let status = repo.statuses(Some(&mut status_options))?;
432 if !status.is_empty() {
433 let mut index_new: bool = false;
434 let mut index_modified: bool = false;
435 let mut index_deleted: bool = false;
436 let mut index_renamed: bool = false;
437 let mut index_typechange: bool = false;
438 let mut wt_new: bool = false;
439 let mut wt_modified: bool = false;
440 let mut wt_deleted: bool = false;
441 let mut wt_typechange: bool = false;
442 let mut wt_renamed: bool = false;
443 let mut ignored: bool = false;
444 let mut conflicted: bool = false;
445
446 for entry in status.iter() {
447 match entry.status() {
448 git2::Status::INDEX_NEW => index_new = true,
449 git2::Status::INDEX_MODIFIED => index_modified = true,
450 git2::Status::INDEX_DELETED => index_deleted = true,
451 git2::Status::INDEX_RENAMED => index_renamed = true,
452 git2::Status::INDEX_TYPECHANGE => index_typechange = true,
453 git2::Status::WT_NEW => wt_new = true,
454 git2::Status::WT_MODIFIED => wt_modified = true,
455 git2::Status::WT_DELETED => wt_deleted = true,
456 git2::Status::WT_TYPECHANGE => wt_typechange = true,
457 git2::Status::WT_RENAMED => wt_renamed = true,
458 git2::Status::IGNORED => ignored = true,
459 git2::Status::CONFLICTED => conflicted = true,
460 _ => {}
461 }
462 }
463
464 let mut status_string = String::new();
465
466 if index_new {
467 status_string.push_str(" [new]");
468 }
469 if index_modified {
470 status_string.push_str(" [modified]");
471 }
472 if index_deleted {
473 status_string.push_str(" [deleted]");
474 }
475 if index_renamed {
476 status_string.push_str(" [renamed]");
477 }
478 if index_typechange {
479 status_string.push_str(" [typechange]");
480 }
481 if wt_new {
482 status_string.push_str(" [wt-new]");
483 }
484 if wt_modified {
485 status_string.push_str(" [wt-modified]");
486 }
487 if wt_deleted {
488 status_string.push_str(" [wt-deleted]");
489 }
490 if wt_typechange {
491 status_string.push_str(" [wt-typechange]");
492 }
493 if wt_renamed {
494 status_string.push_str(" [wt-renamed]");
495 }
496 if ignored {
497 status_string.push_str(" [ignored]");
498 }
499 if conflicted {
500 status_string.push_str(" [conflicted]");
501 }
502
503 println_markup!(
504 &self.style_sheet,
505 "<repository>{}</repository><status>{}</status>",
506 repository.path.to_str().unwrap(),
507 status_string
508 );
509 }
510 anyhow::Ok(())
511 })
512 }
513
514 pub fn ui(&self, filter: Option<&Vec<Filter>>) -> Result<()> {
516 let paths_to_open = self.all_repositories(filter)?;
517 if paths_to_open.len() > 1 {
518 let ans = Confirm::new(format!("Open {} repositories?", paths_to_open.len()).as_str())
519 .with_default(false)
520 .prompt()?;
521 if !ans {
522 return anyhow::Ok(());
523 }
524 }
525 for repository in paths_to_open.iter() {
526 println_markup!(
527 &self.style_sheet,
528 "Opening git ui for {}",
529 repository.path.to_str().unwrap()
530 );
531 open_in_git_ui(&repository.path)?;
532 }
533 anyhow::Ok(())
534 }
535
536 pub fn exec(&self, filter: Option<&Vec<Filter>>, commands: &[String]) -> Result<()> {
538 let repositories = self.all_repositories(filter)?;
539 self.process_repositories(&repositories, |repository| {
540 let mut command = std::process::Command::new(&commands[0]);
541 command.args(&commands[1..]);
542 command.current_dir(&repository.path);
543 let status = command.status()?;
544 if !status.success() {
545 return Err(anyhow!("Failed to execute command"));
546 }
547 Ok(())
548 })
549 }
550
551 pub fn git_command(
553 &self,
554 git_command: &str,
555 filter: Option<&Vec<Filter>>,
556 passthrough: &[String],
557 ) -> Result<()> {
558 let repositories = self.all_repositories(filter)?;
559
560 let width = termsize::get().unwrap().cols as usize;
561
562 let divider = "#".repeat(width);
563
564 let mut first_repository = true;
565
566 self.process_repositories(&repositories, |repository| {
567 if !first_repository {
568 println_markup!(&self.style_sheet, "\n<divider>{}</divider>\n", divider);
569 }
570 first_repository = false;
571 println_markup!(
572 &self.style_sheet,
573 "Running `<command>{}</command>` in <repository>{}</repository>\n",
574 git_command,
575 repository.path.to_str().unwrap()
576 );
577 let mut args = vec![git_command];
578 args.extend(passthrough.iter().map(|s| s.as_str()));
579 let mut command = std::process::Command::new("git");
580 command.args(&args);
581 command.current_dir(&repository.path);
582
583 let status = command.status()?;
585
586 if !status.success() {
588 return Err(anyhow!(
589 "Git command {} failed in repository `{}` with exit code {:?}",
590 git_command,
591 repository.path.display(),
592 status.code()
593 ));
594 }
595 Ok(())
596 })
597 }
598
599 pub fn commit(&self, filter: Option<&Vec<Filter>>, passthrough: &[String]) -> Result<()> {
601 self.git_command("commit", filter, passthrough)
602 }
603
604 pub fn add(&self, filter: Option<&Vec<Filter>>, passthrough: &[String]) -> Result<()> {
606 self.git_command("add", filter, passthrough)
607 }
608
609 pub fn push(&self, filter: Option<&Vec<Filter>>, passthrough: &[String]) -> Result<()> {
611 self.git_command("push", filter, passthrough)
612 }
613
614 pub fn pull(&self, filter: Option<&Vec<Filter>>, passthrough: &[String]) -> Result<()> {
616 self.git_command("pull", filter, passthrough)
617 }
618
619 pub fn fetch(&self, filter: Option<&Vec<Filter>>, passthrough: &[String]) -> Result<()> {
621 self.git_command("fetch", filter, passthrough)
622 }
623
624 pub fn config(&self) -> Result<()> {
625 let editor = env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
626 let config_path = "~/.config/multigit/config.toml";
627 let config_path = shellexpand::tilde(config_path);
628 let full_command = format!("{} {}", editor, config_path);
629 let args = shell_words::split(&full_command)?;
630 let (cmd, args) = args.split_first().ok_or("Empty command").unwrap();
631 let status = Command::new(cmd).args(args).status()?;
632 if !status.success() {
633 return Err(anyhow!("Failed to execute command"));
634 }
635 Ok(())
636 }
637}
638
639#[derive(clap::ValueEnum, Clone, Debug, Serialize)]
641pub enum Filter {
642 Dirty,
644}
645
646#[derive(Clone, Debug, Hash, PartialEq, Eq)]
648pub enum EntryState {
649 Dirty,
651}
652
653pub struct RepositoryState {
655 pub entries: HashSet<EntryState>,
657}
658
659pub fn open_in_git_ui(path: &Path) -> Result<()> {
661 let editor = "gitup";
662 let status = std::process::Command::new(editor)
663 .current_dir(path)
664 .status()?;
665 if !status.success() {
666 return Err(anyhow!("Failed to open git ui"));
667 }
668 Ok(())
669}
670
671pub fn find_repositories(path: &Path) -> Result<Vec<PathBuf>> {
673 let mut repositories = Vec::new();
674 let walker = WalkDir::new(path).into_iter().filter_entry(|e| {
675 e.file_type().is_dir() && !is_hidden(e.path()) && e.path().file_name().unwrap() != ".git"
676 });
677 for entry in walker {
678 let entry = entry?;
679 if is_git_repository(entry.path()) {
680 let path = entry.path();
681 repositories.push(path.to_path_buf());
682 }
683 }
684 Ok(repositories)
685}
686
687pub fn is_git_repository(path: &Path) -> bool {
689 path.join(".git").exists()
690}
691
692pub fn is_hidden(path: &Path) -> bool {
694 path.file_name().unwrap().to_str().unwrap().starts_with('.')
695}
696
697pub fn noneify<T>(v: &Vec<T>) -> Option<&Vec<T>> {
699 if v.is_empty() {
700 None
701 } else {
702 Some(v)
703 }
704}
705
706#[allow(dead_code)]
707struct RepositoryError {
708 path: PathBuf,
709 error: anyhow::Error,
710}
711
712impl fmt::Display for EntryState {
713 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
714 match self {
715 EntryState::Dirty => write!(f, "Dirty"),
716 }
717 }
718}
719
720impl fmt::Display for RepositoryState {
721 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
722 if self.entries.is_empty() {
723 write!(f, "Clean")
724 } else {
725 let states: Vec<String> = self.entries.iter().map(|state| state.to_string()).collect();
726 write!(f, "{}", states.join(", "))
727 }
728 }
729}
730
731fn display_option(o: &Option<bool>) -> String {
732 match o {
733 Some(s) => format!("{}", s),
734 None => "".to_string(),
735 }
736}