Skip to main content

repo_quest/
git.rs

1use std::{
2  cell::OnceCell,
3  collections::HashMap,
4  fmt, fs,
5  io::Write,
6  path::{Path, PathBuf},
7  process::{Command, Stdio},
8};
9
10use eyre::{Context, Result, bail, ensure, eyre};
11use serde::{Deserialize, Serialize};
12
13use crate::{command::command, package::QuestPackage};
14
15pub struct GitRepo {
16  path: PathBuf,
17  upstream: OnceCell<Option<&'static str>>,
18}
19
20const UPSTREAM_REMOTE: &str = "upstream";
21const INITIAL_TAG: &str = "initial";
22
23pub enum MergeType {
24  Success,
25  Reset,
26}
27
28impl MergeType {
29  pub fn is_reset(&self) -> bool {
30    matches!(self, MergeType::Reset)
31  }
32}
33
34macro_rules! git {
35  ($self:expr, $($arg:tt)*) => {{
36    let arg = format!($($arg)*);
37    tracing::debug!("git: {arg}");
38    $self.git(&arg).with_context(|| format!("git failed: {arg}"))
39  }}
40}
41
42macro_rules! git_output {
43  ($self:expr, $($arg:tt)*) => {{
44    let arg = format!($($arg)*);
45    tracing::debug!("git: {arg}");
46    $self.git_output(&arg).with_context(|| format!("git failed: {arg}"))
47  }}
48}
49
50macro_rules! string_newtype {
51  ($name:ident) => {
52    #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
53    pub struct $name(String);
54
55    impl $name {
56      pub fn new(s: impl Into<String>) -> Self {
57        $name(s.into())
58      }
59
60      pub fn as_str(&self) -> &str {
61        &self.0
62      }
63    }
64
65    impl fmt::Display for $name {
66      fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        write!(f, "{}", self.0)
68      }
69    }
70  };
71}
72
73string_newtype!(Branch);
74string_newtype!(Ref);
75
76impl Branch {
77  pub fn main() -> Self {
78    Branch::new("main")
79  }
80
81  pub fn meta() -> Self {
82    Branch::new("meta")
83  }
84}
85
86impl From<&Branch> for Ref {
87  fn from(value: &Branch) -> Self {
88    Ref::new(&value.0)
89  }
90}
91
92impl GitRepo {
93  pub fn new(path: &Path) -> Self {
94    GitRepo {
95      path: path.to_path_buf(),
96      upstream: OnceCell::new(),
97    }
98  }
99
100  fn git_command(&self, args: &str) -> Command {
101    let mut cmd = command(&format!("git {args}"), &self.path);
102    cmd.stdout(Stdio::piped());
103    cmd.stderr(Stdio::piped());
104    cmd
105  }
106
107  fn git_core(&self, args: &str) -> Result<std::result::Result<String, String>> {
108    let output = self.git_command(args).output()?;
109    if !output.status.success() {
110      return Ok(Err(String::from_utf8(output.stderr)?));
111    }
112
113    let stdout = String::from_utf8(output.stdout)?;
114    Ok(Ok(stdout))
115  }
116
117  fn git(&self, args: &str) -> Result<()> {
118    self.git_output(args)?;
119    Ok(())
120  }
121
122  fn git_output(&self, args: &str) -> Result<String> {
123    self
124      .git_core(args)?
125      .map_err(|stderr| eyre!("git failed with stderr:\n{stderr}"))
126  }
127
128  /// Returns true if the directory is within a git
129  pub fn exists(&self) -> bool {
130    let output = git_output!(self, "rev-parse --is-inside-work-tree");
131    match output {
132      Ok(stdout) => stdout.trim() == "true",
133      Err(_) => false,
134    }
135  }
136
137  /// Clones repo from `url` into the parent of `path`.
138  pub fn clone(path: &Path, url: &str) -> Result<Self> {
139    let repo_dir = path.parent().expect("Repo path is somehow root");
140    let output = command(&format!("git clone {url}"), repo_dir).output()?;
141    ensure!(
142      output.status.success(),
143      "`git clone {url}` failed, stderr:\n{}",
144      String::from_utf8(output.stderr)?
145    );
146    Ok(GitRepo::new(path))
147  }
148
149  /// Add and fetch an upstream repo.
150  pub fn setup_upstream(&self, remote: &str) -> Result<()> {
151    git!(self, "remote add {UPSTREAM_REMOTE} {remote}")?;
152    git!(self, "fetch {UPSTREAM_REMOTE}")?;
153    Ok(())
154  }
155
156  /// Returns the repo's upstream remote, if it exists.
157  pub fn upstream(&self) -> Result<Option<&'static str>> {
158    match self.upstream.get() {
159      Some(upstream) => Ok(*upstream),
160      None => {
161        let status = self
162          .git_command(&format!("remote get-url {UPSTREAM_REMOTE}"))
163          .status()
164          .context("`git remote` failed")?;
165        let upstream = status.success().then_some(UPSTREAM_REMOTE);
166        self
167          .upstream
168          .set(upstream)
169          .expect("GitRepo::upstream already initialized");
170        Ok(upstream)
171      }
172    }
173  }
174
175  fn apply_patch(&self, patch: &str) -> Result<()> {
176    tracing::trace!("Applying patch:\n{patch}");
177
178    let mut child = command("git apply -", &self.path)
179      .stdin(Stdio::piped())
180      .stderr(Stdio::piped())
181      .spawn()?;
182
183    let mut stdin = child.stdin.take().unwrap();
184    stdin.write_all(patch.as_bytes())?;
185    drop(stdin);
186
187    let output = child.wait_with_output()?;
188    ensure!(
189      output.status.success(),
190      "git apply failed with stderr:\n{}",
191      String::from_utf8(output.stderr)?
192    );
193
194    Ok(())
195  }
196
197  /// Given a list of patches, attempts to apply the last patch in the list.
198  /// If the application fails, then hard resets the repo back to its initial state,
199  /// and applies all patches in succession.
200  pub fn apply_patch_with_fallback(&self, patches: &[&str]) -> Result<MergeType> {
201    let last = patches.last().unwrap();
202    let merge_type = match self.apply_patch(last) {
203      Ok(()) => MergeType::Success,
204      Err(e) => {
205        tracing::warn!("Failed to apply patch: {e:?}");
206        git!(self, "reset --hard {INITIAL_TAG}")?;
207        for patch in patches {
208          self.apply_patch(patch)?;
209        }
210        MergeType::Reset
211      }
212    };
213
214    git!(self, "add .")?;
215    self.commit("Starter code")?;
216
217    Ok(merge_type)
218  }
219
220  fn first_commit_off_main(&self, git_ref: &Ref) -> Result<Ref> {
221    let merge_base =
222      git_output!(self, "merge-base main {git_ref}").context("Failed to get merge-base")?;
223    let merge_base = merge_base.trim();
224    let commits = git_output!(self, "rev-list --ancestry-path {merge_base}..{git_ref}")
225      .context("Failed to get rev-list")?;
226    Ok(Ref::new(commits.lines().last().unwrap()))
227  }
228
229  fn head_detached(&self) -> Result<bool> {
230    let status = self.git_command("symbolic-ref --quiet HEAD").status()?;
231    match status.code() {
232      Some(0) => Ok(false),
233      Some(1) => Ok(true),
234      _ => bail!("symbolic-ref failed"),
235    }
236  }
237
238  fn current_branch(&self) -> Result<Branch> {
239    ensure!(!self.head_detached()?, "head detached");
240    let output = git_output!(self, "rev-parse --abbrev-ref HEAD")?;
241    Ok(Branch::new(output.trim()))
242  }
243
244  /// Given a range of commits described by refs `from..to`, attempts to cherry-pick them onto
245  /// the current branch. If this fails, then hard reset to the `to` ref without overwriting the history.
246  pub fn cherry_pick_with_fallback(&self, from: &str, to: &str) -> Result<MergeType> {
247    let res = git!(self, "cherry-pick {from}..{to}");
248
249    match res {
250      Ok(()) => Ok(MergeType::Success),
251      Err(e) => {
252        tracing::warn!("Merge conflicts when cherry-picking, resorting to hard reset: ${e:?}");
253
254        git!(self, "cherry-pick --abort").context("Failed to abort cherry-pick")?;
255
256        let cur_branch = self.current_branch()?;
257        let cur_ref = Ref::from(&cur_branch);
258        let first_commit = self.first_commit_off_main(&cur_ref)?;
259
260        git!(self, "reset --hard {to}")?;
261        git!(self, "reset --soft origin/{cur_branch}")?;
262        git!(self, "checkout {first_commit} README.md")?;
263        self.commit("Hard reset to reference solution")?;
264
265        Ok(MergeType::Reset)
266      }
267    }
268  }
269
270  /// Creates a new branch from main, pulling to ensure it's up-to-date.
271  ///
272  /// TODO: what if there's unstaged changes?
273  pub fn create_branch_from_main(&self, branch: &Branch) -> Result<()> {
274    self.checkout(&Branch::main())?;
275    git!(self, "pull").context("Failed to pull main")?;
276    git!(self, "checkout -b {branch}")
277  }
278
279  pub fn delete_local_branch(&self, branch: &Branch) -> Result<()> {
280    git!(self, "branch -D {branch}")
281  }
282
283  pub fn delete_remote_branch(&self, branch: &Branch) -> Result<()> {
284    git!(self, "push origin :{branch}")
285  }
286
287  /// Runs `git add <file>`
288  pub fn add(&self, file: &Path) -> Result<()> {
289    git!(self, "add {}", file.display())
290  }
291
292  /// Runs `git commit --message=<message>`
293  pub fn commit(&self, message: &str) -> Result<()> {
294    git!(
295      self,
296      "commit --message={}",
297      shell_escape::escape(message.into())
298    )
299  }
300
301  /// Runs `git push -u origin <branch>`
302  pub fn push(&self, branch: &Branch) -> Result<()> {
303    git!(self, "push --set-upstream origin {branch}")
304  }
305
306  /// Returns the commit at the head of the current branch
307  pub fn head_commit(&self) -> Result<Ref> {
308    let output = git_output!(self, "rev-parse HEAD").context("Failed to get head commit")?;
309    Ok(Ref::new(output.trim_end()))
310  }
311
312  /// Hard resets current branch to `branch` and force pushes current branch.
313  pub fn hard_reset(&self, branch: &Branch) -> Result<()> {
314    git!(self, "reset --hard {branch}").context("Failed to reset")?;
315    git!(self, "push --force --set-upstream origin").context("Failed to push reset branch")
316  }
317
318  /// Returns the output of `git diff <base>..<head>`
319  pub fn diff(&self, base: &Ref, head: &Ref) -> Result<String> {
320    git_output!(self, "diff {base}..{head}")
321  }
322
323  /// Returns true if the `branch` head contains a file at `path`
324  pub fn contains_file(&self, branch: &Branch, path: &Path) -> Result<bool> {
325    let output = command(
326      &format!("git cat-file -e {branch}:{}", path.display()),
327      &self.path,
328    )
329    .output()
330    .with_context(|| format!("Failed to `git cat-file -e {branch}:{}`", path.display()))?;
331    Ok(output.status.success())
332  }
333
334  /// Returns the contents of `file` at the head of `branch` as a string
335  pub fn read_file_string(&self, branch: &Branch, file: &Path) -> Result<String> {
336    git_output!(self, "cat-file -p {branch}:{}", file.display())
337  }
338
339  /// Returns the contents of `file` at the head of `branch` as a byte array
340  pub fn read_file_bytes(&self, branch: &Branch, file: &Path) -> Result<Vec<u8>> {
341    let output = command(
342      &format!("git cat-file -p {branch}:{}", file.display()),
343      &self.path,
344    )
345    .output()
346    .with_context(|| format!("Failed to `git cat-file -p {branch}:{}", file.display()))?;
347    ensure!(
348      output.status.success(),
349      "git show failed with stderr:\n{}",
350      String::from_utf8(output.stderr)?
351    );
352    Ok(output.stdout)
353  }
354
355  /// Returns a list of all file paths checked in at the head of `branch`
356  pub fn file_paths(&self, branch: &Branch) -> Result<Vec<PathBuf>> {
357    let ls_tree_out = git_output!(self, "ls-tree -r --name-only {branch}")?;
358    let paths = ls_tree_out.trim().lines();
359    Ok(paths.map(PathBuf::from).collect())
360  }
361
362  /// Returns the contents of all files checked in at the head of `branch`
363  pub fn read_files(&self, branch: &Branch) -> Result<HashMap<PathBuf, String>> {
364    self
365      .file_paths(branch)?
366      .into_iter()
367      .map(|path| {
368        let contents = self.read_file_string(branch, &path)?;
369        Ok((path, contents))
370      })
371      .collect()
372  }
373
374  pub fn write_initial_files(&self, package: &QuestPackage) -> Result<()> {
375    for (rel_path, contents) in &package.initial {
376      let abs_path = self.path.join(rel_path);
377      if let Some(dir) = abs_path.parent() {
378        fs::create_dir_all(dir)
379          .with_context(|| format!("Failed to create directory: {}", dir.display()))?;
380      }
381      fs::write(&abs_path, contents)
382        .with_context(|| format!("Failed to write: {}", abs_path.display()))?;
383    }
384
385    // HACK:Eventually we should either directly package a git repo in the file
386    // or include the permissions
387    #[cfg(unix)]
388    {
389      use std::os::unix::fs::PermissionsExt;
390      let hooks_dir = self.path.join(".githooks");
391      if hooks_dir.exists() {
392        let hooks = fs::read_dir(&hooks_dir)
393          .with_context(|| format!("Failed to read hooks directory: {}", hooks_dir.display()))?;
394        for hook in hooks {
395          let hook = hook.context("Failed to read hooks directory entry")?;
396          let mut perms = hook
397            .metadata()
398            .with_context(|| format!("Failed to read hook metadata: {}", hook.path().display()))?
399            .permissions();
400          perms.set_mode(perms.mode() | 0o111);
401          fs::set_permissions(hook.path(), perms).with_context(|| {
402            format!("Failed to set hook permissions: {}", hook.path().display())
403          })?;
404        }
405      }
406    }
407
408    self.add(Path::new("."))?;
409    self.commit("Initial commit")?;
410    git!(self, "tag {INITIAL_TAG}")?;
411    self.push(&Branch::main())?;
412
413    git!(self, "checkout -b meta")?;
414
415    let config_str =
416      toml::to_string_pretty(&package.config).context("Failed to parse package config")?;
417    let toml_path = self.path.join("rqst.toml");
418    fs::write(&toml_path, config_str)
419      .with_context(|| format!("Failed to write TOML to: {}", toml_path.display()))?;
420
421    let pkg_path = self.path.join("package.json.gz");
422    package
423      .save(&pkg_path)
424      .with_context(|| format!("Failed to write package to: {}", pkg_path.display()))?;
425
426    self.add(Path::new("."))?;
427    self.commit("Add meta")?;
428    self.push(&Branch::meta())?;
429    self.checkout(&Branch::main())?;
430
431    Ok(())
432  }
433
434  pub fn checkout(&self, branch: &Branch) -> Result<()> {
435    git!(self, "checkout {branch}")
436  }
437
438  pub fn install_hooks(&self) -> Result<()> {
439    let hooks_dir = self.path.join(".githooks");
440    if hooks_dir.exists() {
441      let post_checkout = hooks_dir.join("post-checkout");
442      if post_checkout.exists() {
443        let status = command(&post_checkout.display().to_string(), &self.path)
444          .status()
445          .context("post-checkout hook failed")?;
446        ensure!(status.success(), "post-checkout hook failed");
447      }
448
449      git!(self, "config --local core.hooksPath .githooks")?;
450    }
451    Ok(())
452  }
453
454  pub fn contains_unstaged_changes(&self) -> Result<bool> {
455    let status = self.git_command("diff-index --quiet HEAD --").status()?;
456    Ok(!status.success())
457  }
458}