1use crate::{commands::branch_fetch, fail, types::Repo, utils::display_link};
2use colored::Colorize as _;
3use std::{
4 env, io,
5 path::{Path, PathBuf},
6 process::{self, Output},
7};
8
9use anyhow::{anyhow, Result};
10use once_cell::sync::Lazy;
11use reqwest::Client;
12
13use crate::{
14 trace,
15 types::{Branch, BranchAndRemote, GitHubResponse, Remote},
16 utils::{make_request, normalize_commit_msg, with_uuid},
17};
18
19pub fn is_valid_branch_name(branch_name: &str) -> bool {
21 branch_name
22 .chars()
23 .all(|ch| ch.is_alphanumeric() || ch == '.' || ch == '-' || ch == '/' || ch == '_')
24}
25
26pub static GITHUB_REMOTE_PREFIX: &str = "git@github.com:";
27pub static GITHUB_REMOTE_SUFFIX: &str = ".git";
28
29pub fn spawn_git(args: &[&str], git_dir: &Path) -> Result<Output, io::Error> {
30 process::Command::new("git")
31 .args(args)
32 .current_dir(git_dir)
33 .output()
34}
35
36pub fn get_git_output(output: &Output, args: &[&str]) -> anyhow::Result<String> {
37 if output.status.success() {
38 Ok(String::from_utf8_lossy(&output.stdout)
39 .trim_end()
40 .to_owned())
41 } else {
42 Err(anyhow::anyhow!(
43 "Git command failed.\nCommand: git {}\nStdout: {}\nStderr: {}",
44 args.join(" "),
45 String::from_utf8_lossy(&output.stdout),
46 String::from_utf8_lossy(&output.stderr),
47 ))
48 }
49}
50
51pub fn get_git_root() -> anyhow::Result<PathBuf> {
52 let current_dir = env::current_dir()?;
53
54 let args = ["rev-parse", "--show-toplevel"];
55
56 let root = spawn_git(&args, ¤t_dir)?;
57
58 get_git_output(&root, &args).map(Into::into)
59}
60
61pub static GIT_ROOT: Lazy<PathBuf> = Lazy::new(|| match get_git_root() {
62 Ok(root) => root,
63 Err(err) => {
64 fail!("Failed to determine Git root directory.\n{err}");
65 process::exit(1)
66 }
67});
68
69type Git = Lazy<Box<dyn Fn(&[&str]) -> Result<String> + Send + Sync>>;
70
71pub static GIT: Git = Lazy::new(|| {
72 Box::new(move |args: &[&str]| -> Result<String> {
73 trace!("$ git {}", args.join(" "));
74 get_git_output(&spawn_git(args, &GIT_ROOT)?, args)
75 })
76});
77
78pub fn add_remote_branch(info: &BranchAndRemote, commit_hash: Option<&str>) -> anyhow::Result<()> {
80 if let Err(err) = GIT(&[
81 "remote",
82 "add",
83 &info.remote.local_remote_alias,
84 &info.remote.repository_url,
85 ]) {
86 GIT(&["remote", "remove", &info.remote.local_remote_alias])?;
87 return Err(anyhow!("Could not fetch remote: {err}"));
88 }
89
90 trace!(
91 "Added remote {} for repository {}",
92 &info.remote.repository_url,
93 &info.remote.local_remote_alias
94 );
95
96 if let Err(err) = GIT(&[
97 "fetch",
98 &info.remote.repository_url,
99 &format!(
100 "{}:{}",
101 info.branch.upstream_branch_name, info.branch.local_branch_name
102 ),
103 ]) {
104 return Err(anyhow!(
105 "We couldn't find branch {} of GitHub repository {}. Are you sure it \
106 exists?\n{err}",
107 info.branch.upstream_branch_name,
108 info.remote.repository_url
109 ));
110 }
111
112 trace!(
113 "Fetched branch {} as {} from repository {}",
114 info.branch.upstream_branch_name,
115 info.branch.local_branch_name,
116 &info.remote.repository_url
117 );
118
119 if let Some(commit_hash) = commit_hash {
120 GIT(&[
121 "branch",
122 "--force",
123 &info.branch.local_branch_name,
124 commit_hash,
125 ])
126 .map_err(|err| {
127 anyhow!(
128 "We couldn't find commit {} \
129 of branch {}. Are you sure it exists?\n{err}",
130 commit_hash,
131 info.branch.local_branch_name
132 )
133 })?;
134
135 trace!("...and did a hard reset to commit {commit_hash}",);
136 };
137
138 Ok(())
139}
140
141pub fn clean_up_remote(remote: &str, branch: &str) -> anyhow::Result<()> {
143 GIT(&["branch", "--delete", "--force", branch])?;
145 GIT(&["remote", "remove", remote])?;
146 Ok(())
147}
148
149pub fn checkout_from_remote(branch: &str, remote: &str) -> anyhow::Result<String> {
150 let current_branch = GIT(&["rev-parse", "--abbrev-ref", "HEAD"]).or_else(|err| {
151 clean_up_remote(remote, branch)?;
152 Err(anyhow!(
153 "Couldn't get the current branch. This usually happens \
154 when the current branch does not have any commits.\n{err}"
155 ))
156 })?;
157
158 if let Err(err) = GIT(&["checkout", branch]) {
159 clean_up_remote(remote, branch)?;
160 return Err(anyhow!(
161 "Could not checkout branch: {branch}, which belongs to remote {remote}\n{err}"
162 ));
163 };
164
165 Ok(current_branch)
166}
167
168pub fn merge_into_main(
169 local_branch: &str,
170 remote_branch: &str,
171) -> anyhow::Result<String, anyhow::Error> {
172 trace!("Merging branch {local_branch}");
173
174 if let Err(err) = GIT(&["merge", "--squash", local_branch]) {
175 GIT(&["reset", "--hard"])?;
177 return Err(anyhow!("Could not merge {remote_branch}\n{err}"));
178 };
179
180 GIT(&[
182 "commit",
183 "--message",
184 &format!("patchy: Merge {local_branch}",),
185 ])?;
186
187 Ok(format!("Merged {remote_branch} successfully"))
188}
189
190pub async fn merge_pull_request(
191 info: BranchAndRemote,
192 pull_request: &str,
193 pr_title: &str,
194 pr_url: &str,
195) -> anyhow::Result<()> {
196 merge_into_main(
197 &info.branch.local_branch_name,
198 &info.branch.upstream_branch_name,
199 )
200 .map_err(|err| {
201 let pr = display_link(
202 &format!(
203 "{}{}{}{}",
204 "#".bright_blue(),
205 pull_request.bright_blue(),
206 " ".bright_blue(),
207 pr_title.bright_blue().italic()
208 ),
209 pr_url,
210 );
211
212 let support_url = display_link(
213 "Merge conflicts (github)",
214 "https://github.com/nik-rev/patchy?tab=readme-ov-file#merge-conflicts",
215 )
216 .bright_blue();
217
218 anyhow!(
219 "Could not merge branch {} into the current branch for pull request {pr} \
220 since the merge is non-trivial.\nYou will need to merge it yourself:\n {} \
221 {0}\nNote: To learn how to merge only once and re-use for subsequent \
222 invocations of patchy, see {support_url}\nSkipping this PR. Error \
223 message from git:\n{err}",
224 &info.branch.local_branch_name.bright_cyan(),
225 "git merge --squash".bright_blue()
226 )
227 })?;
228
229 let has_unstaged_changes = GIT(&["diff", "--cached", "--quiet"]).is_err();
230
231 if has_unstaged_changes {
232 GIT(&[
233 "commit",
234 "--message",
235 &format!(
236 "patchy: auto-merge pull request {}",
237 &pr_url.replace("github.com", "redirect.github.com")
238 ),
239 ])?;
240 }
241
242 clean_up_remote(
243 &info.remote.local_remote_alias,
244 &info.branch.local_branch_name,
245 )?;
246
247 Ok(())
248}
249
250enum AvailableBranch {
251 First,
253 Other(String),
256}
257
258fn first_available_branch(branch: &str) -> AvailableBranch {
272 let branch_exists = GIT(&["rev-parse", "--verify", branch]).is_err();
273
274 if branch_exists {
275 return AvailableBranch::First;
276 }
277
278 let number = (2..)
280 .find(|current| {
281 let branch_with_num = format!("{}-{branch}", current);
282 GIT(&["rev-parse", "--verify", &branch_with_num]).is_err()
283 })
284 .expect("There will eventually be a #-branch which is available.");
285
286 let branch_name = format!("{number}-{branch}");
287
288 AvailableBranch::Other(branch_name)
289}
290
291pub async fn fetch_branch(
292 item: branch_fetch::Item,
293 client: &Client,
294) -> anyhow::Result<(Repo, BranchAndRemote)> {
295 let url = format!("https://api.github.com/repos/{}", item.repo);
296
297 let response = make_request(client, &url)
298 .await
299 .map_err(|err| anyhow!("Could not fetch branch: {}\n{err}\n", item.repo))?;
300
301 let response: Repo = serde_json::from_str(&response).map_err(|err| {
302 anyhow!("Could not parse response.\n{response}. Could not parse because: \n{err}")
303 })?;
304
305 let info = BranchAndRemote {
306 branch: Branch {
307 local_branch_name: item.local_branch_name.map_or_else(
308 || {
309 let branch_name = &format!("{}/{}", item.repo, item.branch);
310
311 match first_available_branch(branch_name) {
312 AvailableBranch::First => branch_name.to_string(),
313 AvailableBranch::Other(branch) => branch,
314 }
315 },
316 Into::into,
317 ),
318 upstream_branch_name: item.branch,
319 },
320 remote: Remote {
321 repository_url: response.clone_url.clone(),
322 local_remote_alias: with_uuid(&item.repo),
323 },
324 };
325
326 add_remote_branch(&info, item.commit_hash.as_deref()).map_err(|err| {
327 anyhow!(
328 "Could not add remote branch {}, skipping.\n{err}",
329 item.repo
330 )
331 })?;
332
333 Ok((response, info))
334}
335
336pub async fn fetch_pull_request(
337 repo: &str,
338 pull_request: &str,
339 client: &Client,
340 custom_branch_name: Option<&str>,
341 commit_hash: Option<&str>,
342) -> anyhow::Result<(GitHubResponse, BranchAndRemote)> {
343 let url = format!("https://api.github.com/repos/{}/pulls/{pull_request}", repo);
344
345 let response = make_request(client, &url)
346 .await
347 .map_err(|err| anyhow!("Could not fetch pull request #{pull_request}\n{err}\n"))?;
348
349 let response: GitHubResponse = serde_json::from_str(&response).map_err(|err| {
350 anyhow!("Could not parse response.\n{response}. Could not parse because: \n{err}")
351 })?;
352
353 let info = BranchAndRemote {
354 branch: Branch {
355 upstream_branch_name: response.head.r#ref.clone(),
356 local_branch_name: custom_branch_name.map_or_else(
357 || {
358 let branch_name = &format!("{pull_request}/{}", &response.head.r#ref);
359
360 match first_available_branch(branch_name) {
361 AvailableBranch::First => branch_name.to_string(),
362 AvailableBranch::Other(branch) => branch,
363 }
364 },
365 Into::into,
366 ),
367 },
368 remote: Remote {
369 repository_url: response.head.repo.clone_url.clone(),
370 local_remote_alias: with_uuid(&format!(
371 "{title}-{}",
372 pull_request,
373 title = normalize_commit_msg(&response.html_url)
374 )),
375 },
376 };
377
378 add_remote_branch(&info, commit_hash).map_err(|err| {
379 anyhow!("Could not add remote branch for pull request #{pull_request}, skipping.\n{err}")
380 })?;
381
382 Ok((response, info))
383}