patchy/commands/
run.rs

1use std::{fs, process};
2
3use anyhow::anyhow;
4use colored::Colorize as _;
5
6use crate::{
7    backup::{files, restore},
8    commands::{init, pr_fetch::ignore_octothorpe},
9    confirm_prompt, fail,
10    flags::Flag,
11    git_commands::{
12        add_remote_branch, checkout_from_remote, clean_up_remote, fetch_pull_request,
13        merge_pull_request, GIT, GIT_ROOT,
14    },
15    info, success, trace,
16    types::{Branch, BranchAndRemote, CommandArgs, Configuration, Remote},
17    utils::{display_link, with_uuid},
18    APP_NAME, CONFIG_FILE, CONFIG_ROOT, INDENT,
19};
20
21pub static RUN_YES_FLAG: Flag<'static> = Flag {
22    short: "-y",
23    long: "--yes",
24    description: "Do not prompt when overwriting local-branch specified in the config",
25};
26
27/// Parses user inputs of the form `<head><syntax><commit-hash>`
28///
29/// Returns the user's input but also the commit hash if it exists
30pub fn parse_if_maybe_hash(input: &str, syntax: &str) -> (String, Option<String>) {
31    let parts: Vec<_> = input.split(syntax).collect();
32
33    let len = parts.len();
34
35    if len == 1 {
36        // The string does not contain the <syntax>, so the user chose to use the latest commit rather than a specific one
37        (input.into(), None)
38    } else {
39        // They want to use a specific commit
40        let output: String = parts[0..len - 1].iter().map(|s| String::from(*s)).collect();
41        let commit_hash: Option<String> = Some(parts[len - 1].into());
42        (output, commit_hash)
43    }
44}
45
46pub async fn run(args: &CommandArgs) -> anyhow::Result<()> {
47    println!();
48
49    let config_path = GIT_ROOT.join(CONFIG_ROOT);
50    let has_yes_flag = RUN_YES_FLAG.is_in(args);
51
52    let config_file_path = config_path.join(CONFIG_FILE);
53
54    let Ok(config_raw) = fs::read_to_string(config_file_path.clone()) else {
55        fail!("Could not find configuration file at {CONFIG_ROOT}/{CONFIG_FILE}");
56
57        // We don't want to have *any* sort of prompt when using the -y flag since that would be problematic in scripts
58        if !has_yes_flag
59            && confirm_prompt!(
60                "Would you like us to run {} {} to initialize it?",
61                "patchy".bright_blue(),
62                "init".bright_yellow(),
63            )
64        {
65            if let Err(err) = init(args) {
66                fail!("{err}");
67                process::exit(1);
68            };
69        } else if has_yes_flag {
70            eprintln!(
71                "You can create it with {} {}",
72                "patchy".bright_blue(),
73                "init".bright_yellow()
74            );
75        } else {
76            // user said "no" in the prompt, so we don't do any initializing
77        }
78
79        // We don't want to read the default configuration file as config_raw. Since it's empty there's no reason why the user would want to run it.
80
81        process::exit(0);
82    };
83
84    trace!("Using configuration file {config_file_path:?}");
85
86    let config = toml::from_str::<Configuration>(&config_raw).map_err(|err| {
87        anyhow!("Could not parse `{CONFIG_ROOT}/{CONFIG_FILE}` configuration file:\n{err}")
88    })?;
89
90    let (remote_branch, commit_hash) = parse_if_maybe_hash(&config.remote_branch, " @ ");
91
92    if config.repo.is_empty() {
93        return Err(anyhow::anyhow!(
94            r#"You haven't specified a `repo` in your config, which can be for example:
95  - "helix-editor/helix"
96  - "microsoft/vscode"
97
98  For more information see this guide: https://github.com/nik-rev/patchy/blob/main/README.md""#
99        ));
100    }
101
102    let config_files = fs::read_dir(&config_path).map_err(|err| {
103        anyhow!(
104            "Could not read files in directory {:?}\n{err}",
105            &config_path
106        )
107    })?;
108
109    let backed_up_files = files(config_files).map_err(|err| {
110        anyhow!("Could not create backups for configuration files, aborting.\n{err}")
111    })?;
112
113    let info = BranchAndRemote {
114        branch: Branch {
115            upstream_branch_name: remote_branch.clone(),
116            local_branch_name: with_uuid(&remote_branch),
117        },
118        remote: Remote {
119            repository_url: format!("https://github.com/{}.git", config.repo),
120            local_remote_alias: with_uuid(&config.repo),
121        },
122    };
123
124    add_remote_branch(&info, commit_hash.as_deref())?;
125
126    let previous_branch = checkout_from_remote(
127        &info.branch.local_branch_name,
128        &info.remote.local_remote_alias,
129    )?;
130
131    let client = reqwest::Client::new();
132
133    if config.pull_requests.is_empty() {
134        info!(
135            "You haven't specified any pull requests to fetch in your config, {}",
136            display_link(
137                "see the instructions on how to configure patchy.",
138                "https://github.com/nik-rev/patchy?tab=readme-ov-file#config"
139            )
140        );
141    } else {
142        // TODO: make this concurrent, see https://users.rust-lang.org/t/processing-subprocesses-concurrently/79638/3
143        // Git cannot handle multiple threads executing commands in the same repository, so we can't use threads, but we can run processes in the background
144        for pull_request in &config.pull_requests {
145            let pull_request = ignore_octothorpe(pull_request);
146            let (pull_request, commit_hash) = parse_if_maybe_hash(&pull_request, " @ ");
147            // TODO: refactor this to not use such deep nesting
148            match fetch_pull_request(
149                &config.repo,
150                &pull_request,
151                &client,
152                None,
153                commit_hash.as_deref(),
154            )
155            .await
156            {
157                Ok((response, info)) => {
158                    match merge_pull_request(
159                        info,
160                        &pull_request,
161                        &response.title,
162                        &response.html_url,
163                    )
164                    .await
165                    {
166                        Ok(()) => {
167                            success!(
168                                "Merged pull request {}",
169                                display_link(
170                                    &format!(
171                                        "{}{}{}{}",
172                                        "#".bright_blue(),
173                                        pull_request.bright_blue(),
174                                        " ".bright_blue(),
175                                        &response.title.bright_blue().italic()
176                                    ),
177                                    &response.html_url
178                                ),
179                            );
180                        }
181                        Err(err) => {
182                            fail!("{err}");
183                            continue;
184                        }
185                    };
186                }
187                Err(err) => {
188                    fail!("Could not fetch branch from remote\n{err}");
189                    continue;
190                }
191            }
192        }
193    }
194
195    if let Err(err) = fs::create_dir_all(GIT_ROOT.join(CONFIG_ROOT)) {
196        GIT(&["checkout", &previous_branch])?;
197
198        clean_up_remote(
199            &info.remote.local_remote_alias,
200            &info.branch.local_branch_name,
201        )?;
202
203        return Err(anyhow!("Could not create directory {CONFIG_ROOT}\n{err}"));
204    };
205
206    for (file_name, _file, contents) in &backed_up_files {
207        restore(file_name, contents).map_err(|err| anyhow!("Could not restore backups:\n{err}"))?;
208
209        // apply patches if they exist
210        if let Some(patches) = &config.patches {
211            let file_name = file_name
212                .to_str()
213                .and_then(|file_name| file_name.get(0..file_name.len() - 6))
214                .unwrap_or_default();
215
216            if patches.contains(file_name) {
217                if let Err(err) = GIT(&[
218                    "am",
219                    "--keep-cr",
220                    "--signoff",
221                    &format!(
222                        "{}/{file_name}.patch",
223                        GIT_ROOT.join(CONFIG_ROOT).to_str().unwrap_or_default()
224                    ),
225                ]) {
226                    GIT(&["am", "--abort"])?;
227                    return Err(anyhow!(
228                        "Could not apply patch {file_name}, skipping\n{err}"
229                    ));
230                };
231
232                let last_commit_message = GIT(&["log", "-1", "--format=%B"])?;
233                success!(
234                    "Applied patch {file_name} {}",
235                    last_commit_message
236                        .lines()
237                        .next()
238                        .unwrap_or_default()
239                        .bright_blue()
240                        .italic()
241                );
242            }
243        }
244    }
245
246    GIT(&["add", CONFIG_ROOT])?;
247    GIT(&[
248        "commit",
249        "--message",
250        &format!("{APP_NAME}: Restore configuration files"),
251    ])?;
252
253    let temporary_branch = with_uuid("temp-branch");
254
255    GIT(&["switch", "--create", &temporary_branch])?;
256
257    clean_up_remote(
258        &info.remote.local_remote_alias,
259        &info.branch.local_branch_name,
260    )?;
261
262    if has_yes_flag
263        || confirm_prompt!(
264            "Overwrite branch {}? This is irreversible.",
265            config.local_branch.cyan()
266        )
267    {
268        // forcefully renames the branch we are currently on into the branch specified by the user.
269        // WARNING: this is a destructive action which erases the original branch
270        GIT(&[
271            "branch",
272            "--move",
273            "--force",
274            &temporary_branch,
275            &config.local_branch,
276        ])?;
277        if has_yes_flag {
278            info!(
279                "Overwrote branch {} since you supplied the {} flag",
280                config.local_branch.cyan(),
281                "--yes".bright_magenta()
282            );
283        }
284        println!("\n{INDENT}{}", "  Success!\n".bright_green().bold());
285    } else {
286        let command = format!(
287            "  git branch --move --force {temporary_branch} {}",
288            config.local_branch
289        );
290        let command = format!("\n{INDENT}{}\n", command.bright_magenta());
291        println!(
292            "\n{INDENT}  You can still manually overwrite {} with the following command:\n  {command}",
293            config.local_branch.cyan(),
294        );
295        process::exit(1)
296    }
297
298    Ok(())
299}