Skip to main content

rq_core/
git.rs

1use std::{
2  collections::HashMap,
3  fs,
4  io::Write,
5  path::{Path, PathBuf},
6  process::Stdio,
7};
8
9use eyre::{Context, Result, ensure, eyre};
10
11use crate::{
12  command::command,
13  github::{GitProtocol, GithubRepo},
14  package::QuestPackage,
15  template::QuestTemplate,
16};
17
18pub struct GitRepo {
19  path: PathBuf,
20}
21
22pub const UPSTREAM: &str = "upstream";
23pub const INITIAL_TAG: &str = "initial";
24
25pub enum MergeType {
26  Success,
27  SolutionReset,
28  StarterReset,
29}
30
31macro_rules! git {
32  ($self:expr, $($arg:tt)*) => {{
33    let arg = format!($($arg)*);
34    tracing::debug!("git: {arg}");
35    $self.git(&arg).with_context(|| format!("git failed: {arg}"))
36  }}
37}
38
39macro_rules! git_output {
40  ($self:expr, $($arg:tt)*) => {{
41    let arg = format!($($arg)*);
42    tracing::debug!("git: {arg}");
43    $self.git_output(&arg).with_context(|| format!("git failed: {arg}"))
44  }}
45}
46
47impl GitRepo {
48  pub fn new(path: &Path) -> Self {
49    GitRepo {
50      path: path.to_path_buf(),
51    }
52  }
53
54  pub fn exists(&self) -> bool {
55    let output = git_output!(self, "rev-parse --is-inside-work-tree");
56    match output {
57      Ok(stdout) => stdout.trim() == "true",
58      Err(_) => false,
59    }
60  }
61
62  pub fn clone(path: &Path, url: &str) -> Result<Self> {
63    let output = command(&format!("git clone {url}"), path.parent().unwrap()).output()?;
64    ensure!(
65      output.status.success(),
66      "`git clone {url}` failed, stderr:\n{}",
67      String::from_utf8(output.stderr)?
68    );
69    Ok(GitRepo::new(path))
70  }
71
72  fn git_core(&self, args: &str) -> Result<std::result::Result<String, String>> {
73    let mut cmd = command(&format!("git {args}"), &self.path);
74    cmd.stdout(Stdio::piped());
75    cmd.stderr(Stdio::piped());
76
77    let output = cmd.output()?;
78    if !output.status.success() {
79      return Ok(Err(String::from_utf8(output.stderr)?));
80    }
81
82    let stdout = String::from_utf8(output.stdout)?;
83    Ok(Ok(stdout))
84  }
85
86  fn git(&self, args: &str) -> Result<()> {
87    self.git_output(args)?;
88    Ok(())
89  }
90
91  fn git_output(&self, args: &str) -> Result<String> {
92    self
93      .git_core(args)?
94      .map_err(|stderr| eyre!("git failed with stderr:\n{stderr}"))
95  }
96
97  pub fn setup_upstream(&self, upstream: &GithubRepo) -> Result<()> {
98    let remote = upstream.remote(GitProtocol::Https);
99    git!(self, "remote add {UPSTREAM} {remote}")?;
100    self.fetch(UPSTREAM)?;
101    Ok(())
102  }
103
104  pub fn fetch(&self, remote: &str) -> Result<()> {
105    git!(self, "fetch {remote}")
106  }
107
108  pub fn upstream(&self) -> Result<Option<&'static str>> {
109    let status = command(&format!("git remote get-url {UPSTREAM}"), &self.path)
110      .stdout(Stdio::null())
111      .status()
112      .context("`git remote` failed")?;
113    Ok(status.success().then_some(UPSTREAM))
114  }
115
116  fn apply(&self, patch: &str) -> Result<()> {
117    tracing::trace!("Applying patch:\n{patch}");
118    let mut child = command("git apply -", &self.path)
119      .stdin(Stdio::piped())
120      .stderr(Stdio::piped())
121      .spawn()?;
122    let mut stdin = child.stdin.take().unwrap();
123    stdin.write_all(patch.as_bytes())?;
124    drop(stdin);
125    let output = child.wait_with_output()?;
126    ensure!(
127      output.status.success(),
128      "git apply failed with stderr:\n{}",
129      String::from_utf8(output.stderr)?
130    );
131    tracing::trace!("wtf: {}", String::from_utf8(output.stderr)?);
132    Ok(())
133  }
134
135  pub fn apply_patch(&self, patches: &[&str]) -> Result<MergeType> {
136    let last = patches.last().unwrap();
137    let merge_type = match self.apply(last) {
138      Ok(()) => MergeType::Success,
139      Err(e) => {
140        tracing::warn!("Failed to apply patch: {e:?}");
141        git!(self, "reset --hard {INITIAL_TAG}")?;
142        for patch in patches {
143          self.apply(patch)?;
144        }
145        MergeType::StarterReset
146      }
147    };
148
149    git!(self, "add .")?;
150    git!(self, "commit -m 'Starter code'")?;
151
152    Ok(merge_type)
153  }
154
155  pub fn cherry_pick(&self, base_branch: &str, target_branch: &str) -> Result<MergeType> {
156    let res = git!(
157      self,
158      "cherry-pick {UPSTREAM}/{base_branch}..{UPSTREAM}/{target_branch}"
159    );
160
161    Ok(match res {
162      Ok(_) => MergeType::Success,
163      Err(e) => {
164        tracing::warn!("Merge conflicts when cherry-picking, resorting to hard reset: ${e:?}");
165
166        git!(self, "cherry-pick --abort").context("Failed to abort cherry-pick")?;
167
168        let upstream_target = format!("{UPSTREAM}/{target_branch}");
169        git!(self, "reset --hard {upstream_target}")?;
170
171        git!(self, "reset --soft main").context("Failed to soft reset to main")?;
172
173        git!(self, "commit -m 'Override with reference solution'")?;
174
175        MergeType::SolutionReset
176      }
177    })
178  }
179
180  pub fn create_branch_from(
181    &self,
182    template: &dyn QuestTemplate,
183    base_branch: &str,
184    target_branch: &str,
185  ) -> Result<(String, MergeType)> {
186    git!(self, "checkout -b {target_branch}")?;
187
188    let merge_type = template.apply_patch(self, base_branch, target_branch)?;
189
190    git!(self, "push -u origin {target_branch}")?;
191
192    let head = self.head_commit()?;
193
194    git!(self, "checkout main")?;
195
196    Ok((head, merge_type))
197  }
198
199  pub fn pull(&self) -> Result<()> {
200    git!(self, "pull")
201  }
202
203  pub fn checkout_main(&self) -> Result<()> {
204    git!(self, "checkout main")
205  }
206
207  pub fn head_commit(&self) -> Result<String> {
208    let output = git_output!(self, "rev-parse HEAD").context("Failed to get head commit")?;
209    Ok(output.trim_end().to_string())
210  }
211
212  pub fn reset(&self, branch: &str) -> Result<()> {
213    git!(self, "reset --hard {branch}").context("Failed to reset")?;
214    git!(self, "push --force").context("Failed to push reset branch")?;
215    Ok(())
216  }
217
218  pub fn diff(&self, base: &str, head: &str) -> Result<String> {
219    git_output!(self, "diff {base}..{head}")
220  }
221
222  pub fn contains_file(&self, branch: &str, file: &str) -> Result<bool> {
223    let status = command(&format!("git cat-file -e {branch}:{file}"), &self.path)
224      .status()
225      .with_context(|| format!("Failed to `git cat-file -e {branch}:{file}`"))?;
226    Ok(status.success())
227  }
228
229  pub fn read_file(&self, branch: &str, file: &str) -> Result<String> {
230    git_output!(self, "cat-file -p {branch}:{file}")
231  }
232
233  pub fn show_bin(&self, branch: &str, file: &str) -> Result<Vec<u8>> {
234    let output = command(&format!("git cat-file -p {branch}:{file}"), &self.path)
235      .output()
236      .with_context(|| format!("Failed to `git cat-file -p {branch}:{file}"))?;
237    ensure!(
238      output.status.success(),
239      "git show failed with stderr:\n{}",
240      String::from_utf8(output.stderr)?
241    );
242    Ok(output.stdout)
243  }
244
245  pub fn read_initial_files(&self) -> Result<HashMap<PathBuf, String>> {
246    let ls_tree_out = git_output!(self, "ls-tree -r main --name-only")?;
247    let files = ls_tree_out.trim().split("\n");
248    files
249      .map(|file| {
250        let path = PathBuf::from(file);
251        let contents = self.read_file("main", file)?;
252        Ok((path, contents))
253      })
254      .collect()
255  }
256
257  pub fn is_behind_origin(&self) -> Result<bool> {
258    let out = git_output!(self, "rev-list --count main..origin/main")?;
259    let count = out
260      .trim()
261      .parse::<i32>()
262      .with_context(|| format!("rev-list returned non-numeric output:\n{out}"))?;
263    Ok(count > 0)
264  }
265
266  pub fn write_initial_files(&self, package: &QuestPackage) -> Result<()> {
267    for (rel_path, contents) in &package.initial {
268      let abs_path = self.path.join(rel_path);
269      if let Some(dir) = abs_path.parent() {
270        fs::create_dir_all(dir)
271          .with_context(|| format!("Failed to create directory: {}", dir.display()))?;
272      }
273      fs::write(&abs_path, contents)
274        .with_context(|| format!("Failed to write: {}", abs_path.display()))?;
275    }
276
277    // HACK:Eventually we should either directly package a git repo in the file
278    // or include the permissions
279    #[cfg(unix)]
280    {
281      use std::os::unix::fs::PermissionsExt;
282      let hooks_dir = self.path.join(".githooks");
283      if hooks_dir.exists() {
284        let hooks = fs::read_dir(&hooks_dir)
285          .with_context(|| format!("Failed to read hooks directory: {}", hooks_dir.display()))?;
286        for hook in hooks {
287          let hook = hook.context("Failed to read hooks directory entry")?;
288          let mut perms = hook
289            .metadata()
290            .with_context(|| format!("Failed to read hook metadata: {}", hook.path().display()))?
291            .permissions();
292          perms.set_mode(perms.mode() | 0o111);
293          fs::set_permissions(hook.path(), perms).with_context(|| {
294            format!("Failed to set hook permissions: {}", hook.path().display())
295          })?;
296        }
297      }
298    }
299
300    git!(self, "add .")?;
301    git!(self, "commit -m 'Initial commit'")?;
302    git!(self, "tag {INITIAL_TAG}")?;
303    git!(self, "push -u origin main")?;
304
305    git!(self, "checkout -b meta")?;
306
307    let config_str =
308      toml::to_string_pretty(&package.config).context("Failed to parse package config")?;
309    let toml_path = self.path.join("rqst.toml");
310    fs::write(&toml_path, config_str)
311      .with_context(|| format!("Failed to write TOML to: {}", toml_path.display()))?;
312
313    let pkg_path = self.path.join("package.json.gz");
314    package
315      .save(&pkg_path)
316      .with_context(|| format!("Failed to write package to: {}", pkg_path.display()))?;
317
318    git!(self, "add .")?;
319    git!(self, "commit -m 'Add meta'")?;
320    git!(self, "push -u origin meta")?;
321    git!(self, "checkout main")?;
322
323    Ok(())
324  }
325
326  pub fn install_hooks(&self) -> Result<()> {
327    let hooks_dir = self.path.join(".githooks");
328    if hooks_dir.exists() {
329      let post_checkout = hooks_dir.join("post-checkout");
330      if post_checkout.exists() {
331        let status = command(&post_checkout.display().to_string(), &self.path)
332          .status()
333          .context("post-checkout hook failed")?;
334        ensure!(status.success(), "post-checkout hook failed");
335      }
336
337      git!(self, "config --local core.hooksPath .githooks")?;
338    }
339
340    Ok(())
341  }
342}