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 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 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 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 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 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 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 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 pub fn add(&self, file: &Path) -> Result<()> {
289 git!(self, "add {}", file.display())
290 }
291
292 pub fn commit(&self, message: &str) -> Result<()> {
294 git!(
295 self,
296 "commit --message={}",
297 shell_escape::escape(message.into())
298 )
299 }
300
301 pub fn push(&self, branch: &Branch) -> Result<()> {
303 git!(self, "push --set-upstream origin {branch}")
304 }
305
306 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 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 pub fn diff(&self, base: &Ref, head: &Ref) -> Result<String> {
320 git_output!(self, "diff {base}..{head}")
321 }
322
323 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 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 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 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 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 #[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}