patchy/
git_commands.rs

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
19/// A valid branch name consists of alphanumeric characters, but also '.', '-', '/' or '_'
20pub 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, &current_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
78/// Fetches a branch of a remote into local. Optionally accepts a commit hash for versioning.
79pub 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
141/// Removes a remote and its branch
142pub fn clean_up_remote(remote: &str, branch: &str) -> anyhow::Result<()> {
143    // NOTE: Caller needs to ensure this function only runs if the script created the branch or if the user gave explicit permission
144    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        // nukes the worktree
176        GIT(&["reset", "--hard"])?;
177        return Err(anyhow!("Could not merge {remote_branch}\n{err}"));
178    };
179
180    // --squash will NOT commit anything. So we need to make it manually
181    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    /// In this case, we can just use the original `branch` that we passed in
252    First,
253    /// The first branch was available, so we slapped on some arbitrary identifier at the end
254    /// Represents a branch like some-branch-2, some-branch-3
255    Other(String),
256}
257
258/// Given a branch, either return this branch or the first available branch with an identifier at the end (a `-#`) where `#` represents a number
259/// So we can keep on "trying" for a branch that isn't used. We might try `some-branch`, and if it already exists we will then try:
260///
261/// - some-branch-2
262/// - some-branch-3
263/// - some-branch-4
264/// - ...
265///
266/// Stopping when we find the first available
267///
268/// We do not want to return a branch if it already exists, since we don't want to overwrite any branch potentially losing the user their work
269///
270/// We also don't want to ask for a prompt for a custom name, as it would be pretty annoying to specify a name for each branch if you have like 30 pull requests you want to merge
271fn 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    // the first number for which the branch does not exist
279    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}