git_workspace/
repository.rs

1use anyhow::{anyhow, Context};
2use console::{strip_ansi_codes, truncate_str};
3use git2::build::CheckoutBuilder;
4use git2::{Repository as Git2Repository, StatusOptions};
5use indicatif::ProgressBar;
6use serde::{Deserialize, Serialize};
7use std::io::{BufRead, BufReader};
8use std::path::{Path, PathBuf};
9use std::process::{Command, Stdio};
10
11// Eq, Ord and friends are needed to order the list of repositories
12#[derive(Deserialize, Serialize, Debug, Clone, Eq, Ord, PartialEq, PartialOrd)]
13pub struct Repository {
14    path: String,
15    url: String,
16    pub upstream: Option<String>,
17    pub branch: Option<String>,
18}
19
20impl Repository {
21    pub fn new(
22        path: String,
23        url: String,
24        branch: Option<String>,
25        upstream: Option<String>,
26    ) -> Repository {
27        // We have to normalize repository names here. On windows if you do `path.join(self.name())`
28        // it will cause issues if the name contains a forward slash. So here we just normalize it
29        // to the path separator on the system.
30        let norm_path = if cfg!(windows) {
31            path.replace('/', std::path::MAIN_SEPARATOR.to_string().as_str())
32        } else {
33            path
34        };
35
36        Repository {
37            path: norm_path,
38            url,
39            branch,
40            upstream,
41        }
42    }
43
44    pub fn set_upstream(&self, root: &Path) -> anyhow::Result<()> {
45        let upstream = match &self.upstream {
46            Some(upstream) => upstream,
47            None => return Ok(()),
48        };
49
50        let mut command = Command::new("git");
51        let child = command
52            .arg("-C")
53            .arg(root.join(self.name()))
54            .arg("remote")
55            .arg("rm")
56            .arg("upstream")
57            .stdout(Stdio::null())
58            .stderr(Stdio::null());
59
60        child.status()?;
61
62        let mut command = Command::new("git");
63        let child = command
64            .arg("-C")
65            .arg(root.join(self.name()))
66            .arg("remote")
67            .arg("add")
68            .arg("upstream")
69            .arg(upstream);
70
71        let output = child.output()?;
72        if !output.status.success() {
73            let stderr =
74                std::str::from_utf8(&output.stderr).with_context(|| "Error decoding git output")?;
75            return Err(anyhow!(
76                "Failed to set upstream on repo {}: {}",
77                root.display(),
78                stderr.trim()
79            ));
80        }
81        Ok(())
82    }
83
84    fn run_with_progress(
85        &self,
86        command: &mut Command,
87        progress_bar: &ProgressBar,
88    ) -> anyhow::Result<()> {
89        progress_bar.set_message(format!("{}: starting", self.name()));
90        let mut spawned = command
91            .stdin(Stdio::null())
92            .stdout(Stdio::piped())
93            .stderr(Stdio::piped())
94            .spawn()
95            .with_context(|| format!("Error starting command {:?}", command))?;
96
97        let mut last_line = format!("{}: running...", self.name());
98        progress_bar.set_message(last_line.clone());
99
100        if let Some(ref mut stderr) = spawned.stderr {
101            let lines = BufReader::new(stderr).split(b'\r');
102            for line in lines {
103                let output = line.unwrap();
104                if output.is_empty() {
105                    continue;
106                }
107                let line = std::str::from_utf8(&output).unwrap();
108                let plain_line = strip_ansi_codes(line).replace('\n', " ");
109                let truncated_line = truncate_str(plain_line.trim(), 70, "...");
110                progress_bar.set_message(format!("{}: {}", self.name(), truncated_line));
111                last_line = plain_line;
112            }
113        }
114        let exit_code = spawned
115            .wait()
116            .context("Error waiting for process to finish")?;
117        if !exit_code.success() {
118            return Err(anyhow!(
119                "Git exited with code {}: {}",
120                exit_code.code().unwrap(),
121                last_line
122            ));
123        }
124        Ok(())
125    }
126
127    pub fn execute_cmd(
128        &self,
129        root: &Path,
130        progress_bar: &ProgressBar,
131        cmd: &str,
132        args: &[String],
133    ) -> anyhow::Result<()> {
134        let mut command = Command::new(cmd);
135        let child = command.args(args).current_dir(root.join(self.name()));
136
137        self.run_with_progress(child, progress_bar)
138            .with_context(|| format!("Error running command in repo {}", self.name()))?;
139
140        Ok(())
141    }
142
143    pub fn switch_to_primary_branch(&self, root: &Path) -> anyhow::Result<()> {
144        let branch = match &self.branch {
145            None => return Ok(()),
146            Some(b) => b,
147        };
148        let repo = Git2Repository::init(root.join(self.name()))?;
149        let status = repo.statuses(Some(&mut StatusOptions::default()))?;
150        if !status.is_empty() {
151            return Err(anyhow!(
152                "Repository is dirty, cannot switch to branch {}",
153                branch
154            ));
155        }
156        repo.set_head(&format!("refs/heads/{}", branch))
157            .with_context(|| format!("Cannot find branch {}", branch))?;
158        repo.checkout_head(Some(CheckoutBuilder::default().safe().force()))
159            .with_context(|| format!("Error checking out branch {}", branch))?;
160        Ok(())
161    }
162
163    pub fn clone(&self, root: &Path, progress_bar: &ProgressBar) -> anyhow::Result<()> {
164        let mut command = Command::new("git");
165
166        let child = command
167            .arg("clone")
168            .arg("--recurse-submodules")
169            .arg("--progress")
170            .arg(&self.url)
171            .arg(root.join(self.name()));
172
173        self.run_with_progress(child, progress_bar)
174            .with_context(|| {
175                format!("Error cloning repo into {} from {}", self.name(), &self.url)
176            })?;
177
178        Ok(())
179    }
180    pub fn name(&self) -> &String {
181        &self.path
182    }
183    pub fn get_path(&self, root: &Path) -> anyhow::Result<PathBuf> {
184        let joined = root.join(self.name());
185        joined
186            .canonicalize()
187            .with_context(|| format!("Cannot resolve {}", joined.display()))
188    }
189    pub fn exists(&self, root: &Path) -> bool {
190        match self.get_path(root) {
191            Ok(path) => {
192                let git_dir = root.join(path).join(".git");
193                git_dir.exists() && git_dir.is_dir()
194            }
195            Err(_) => false,
196        }
197    }
198}