multigit/
lib.rs

1//! A library for managing multiple Git repositories.
2//!
3//! This library provides functionalities to register, unregister, list, and perform Git operations on multiple repositories.
4//! It supports filtering repositories based on their state and provides utilities to execute commands across repositories.
5
6use 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/// Represents an entry for a single Git repository.
24#[derive(Debug, Deserialize, Serialize)]
25pub struct RepositoryEntry {
26    /// The path to the repository.
27    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 no upstream is set, return None
60        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/// Represents an entry for a directory containing Git repositories.
83#[derive(Debug, Deserialize, Serialize)]
84pub struct DirectoryEntry {
85    /// The path to the directory.
86    pub path: PathBuf,
87}
88
89impl RepositoryEntry {
90    /// Retrieves the state of the repository.
91    ///
92    /// Returns a `RepositoryState` containing information about the repository's status.
93    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/// Configuration data for the application, including registered repositories and directories.
132#[derive(Debug, Deserialize, Serialize, Default)]
133pub struct Config {
134    /// A map of repository names to their entries.
135    #[serde(default = "HashMap::new")]
136    pub repositories: HashMap<String, RepositoryEntry>,
137
138    /// A map of directory names to their entries.
139    #[serde(default = "HashMap::new")]
140    pub directories: HashMap<String, DirectoryEntry>,
141}
142
143impl Config {
144    /// Loads the configuration from the default config file.
145    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    /// Saves the current configuration to the default config file.
169    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    /// Registers a path as a repository or directory.
179    ///
180    /// If the path is a Git repository, it is added to the repositories map.
181    /// If the path is a directory containing repositories, it is added to the directories map.
182    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    /// Unregisters a repository or directory.
204    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/// Represents the main application handling multiple repositories.
217#[derive(Debug)]
218pub struct Multigit {
219    /// The configuration containing repositories and directories.
220    pub config: Config,
221
222    pub directory: Option<PathBuf>,
223
224    /// The stylesheet used for colored output.
225    pub style_sheet: StyleSheet<'static>,
226}
227
228impl Multigit {
229    /// Creates a new instance of `Multigit`.
230    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    /// Retrieves all repositories, optionally filtering them.
249    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    /// Registers paths as repositories or directories.
335    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    /// Unregisters repositories or directories.
348    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    /// Lists all registered repositories.
374    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    /// Shows the status of all repositories.
424    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    /// Opens the configured Git UI for the selected repositories.
515    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    /// Executes a custom command in the selected repositories.
537    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    /// Executes a Git command with optional arguments in the selected repositories.
552    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            // Execute the command and capture the status
584            let status = command.status()?;
585
586            // Check if the command was successful
587            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    /// Commits changes in the selected repositories.
600    pub fn commit(&self, filter: Option<&Vec<Filter>>, passthrough: &[String]) -> Result<()> {
601        self.git_command("commit", filter, passthrough)
602    }
603
604    /// Adds files to the staging area in the selected repositories.
605    pub fn add(&self, filter: Option<&Vec<Filter>>, passthrough: &[String]) -> Result<()> {
606        self.git_command("add", filter, passthrough)
607    }
608
609    /// Pushes changes to remote repositories.
610    pub fn push(&self, filter: Option<&Vec<Filter>>, passthrough: &[String]) -> Result<()> {
611        self.git_command("push", filter, passthrough)
612    }
613
614    /// Pulls changes from remote repositories.
615    pub fn pull(&self, filter: Option<&Vec<Filter>>, passthrough: &[String]) -> Result<()> {
616        self.git_command("pull", filter, passthrough)
617    }
618
619    /// Fetchs changes from remote repositories.
620    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/// Enum representing possible filters for repositories.
640#[derive(clap::ValueEnum, Clone, Debug, Serialize)]
641pub enum Filter {
642    /// Filter repositories that have uncommitted changes.
643    Dirty,
644}
645
646/// Enum representing the state of repository entries.
647#[derive(Clone, Debug, Hash, PartialEq, Eq)]
648pub enum EntryState {
649    /// Indicates that the repository has uncommitted changes.
650    Dirty,
651}
652
653/// Represents the state of a repository.
654pub struct RepositoryState {
655    /// A set of entry states.
656    pub entries: HashSet<EntryState>,
657}
658
659/// Opens the configured Git UI for a given repository path.
660pub 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
671/// Finds all Git repositories within a given path.
672pub 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
687/// Checks if a path is a Git repository.
688pub fn is_git_repository(path: &Path) -> bool {
689    path.join(".git").exists()
690}
691
692/// Checks if a path is hidden (starts with a dot).
693pub fn is_hidden(path: &Path) -> bool {
694    path.file_name().unwrap().to_str().unwrap().starts_with('.')
695}
696
697/// Returns `None` if the vector is empty, otherwise returns `Some(&Vec<T>)`.
698pub 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}