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 #[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}