git_branchless_submit/
lib.rs

1//! Push the user's commits to a remote.
2
3#![warn(missing_docs)]
4#![warn(
5    clippy::all,
6    clippy::as_conversions,
7    clippy::clone_on_ref_ptr,
8    clippy::dbg_macro
9)]
10#![allow(clippy::too_many_arguments, clippy::blocks_in_if_conditions)]
11
12mod branch_forge;
13pub mod github;
14pub mod phabricator;
15
16use std::collections::{BTreeSet, HashMap};
17use std::fmt::{Debug, Write};
18use std::time::SystemTime;
19
20use branch_forge::BranchForge;
21use cursive_core::theme::{BaseColor, Effect, Style};
22use git_branchless_invoke::CommandContext;
23use git_branchless_test::{RawTestOptions, ResolvedTestOptions, Verbosity};
24use github::GithubForge;
25use itertools::Itertools;
26use lazy_static::lazy_static;
27use lib::core::dag::{union_all, CommitSet, Dag};
28use lib::core::effects::Effects;
29use lib::core::eventlog::{EventLogDb, EventReplayer};
30use lib::core::formatting::{Pluralize, StyledStringBuilder};
31use lib::core::repo_ext::{RepoExt, RepoReferencesSnapshot};
32use lib::git::{GitRunInfo, NonZeroOid, Repo};
33use lib::try_exit_code;
34use lib::util::{ExitCode, EyreExitOr};
35
36use git_branchless_opts::{
37    ForgeKind, ResolveRevsetOptions, Revset, SubmitArgs, TestExecutionStrategy,
38};
39use git_branchless_revset::resolve_commits;
40use phabricator::PhabricatorForge;
41use tracing::{debug, info, instrument, warn};
42
43use crate::github::github_push_remote;
44
45lazy_static! {
46    /// The style for branches which were successfully submitted.
47    pub static ref STYLE_PUSHED: Style =
48        Style::merge(&[BaseColor::Green.light().into(), Effect::Bold.into()]);
49
50    /// The style for branches which were not submitted.
51    pub static ref STYLE_SKIPPED: Style =
52        Style::merge(&[BaseColor::Yellow.light().into(), Effect::Bold.into()]);
53}
54
55/// The status of a commit, indicating whether it needs to be updated remotely.
56#[derive(Clone, Debug)]
57pub enum SubmitStatus {
58    /// The commit exists locally and there is no intention to push it to the
59    /// remote.
60    Local,
61
62    /// The commit exists locally and will eventually be pushed to the remote,
63    /// but it has not been pushed yet.
64    Unsubmitted,
65
66    /// It could not be determined whether the remote commit exists.
67    Unknown,
68
69    /// The same commit exists both locally and remotely.
70    UpToDate,
71
72    /// The commit exists locally but is associated with a different remote
73    /// commit, so it needs to be updated.
74    NeedsUpdate,
75}
76
77/// Information about each commit.
78#[derive(Clone, Debug)]
79pub struct CommitStatus {
80    /// The status of this commit, indicating whether it needs to be updated.
81    submit_status: SubmitStatus,
82
83    /// The Git remote associated with this commit, if any.
84    remote_name: Option<String>,
85
86    /// An identifier corresponding to the commit in the local repository. This
87    /// may be a branch name, a change ID, the commit summary, etc.
88    ///
89    /// This does not necessarily correspond to the commit's name/identifier in
90    /// the forge (e.g. not a code review link); see `remote_commit_name`
91    /// instead.
92    ///
93    /// The calling code will only use this for display purposes, but an
94    /// individual forge implementation can return this from
95    /// `Forge::query_status` and it will be passed back to
96    /// `Forge::create`/`Forge::update`.
97    local_commit_name: Option<String>,
98
99    /// An identifier corresponding to the commit in the remote repository. This
100    /// may be a branch name, a change ID, a code review link, etc.
101    ///
102    /// This does not necessarily correspond to the commit's name/identifier in
103    /// the local repository; see `local_commit_name` instead.
104    ///
105    /// The calling code will only use this for display purposes, but an
106    /// individual forge implementation can return this from
107    /// `Forge::query_status` and it will be passed back to
108    /// `Forge::create`/`Forge::update`.
109    remote_commit_name: Option<String>,
110}
111
112/// Options for submitting commits to the forge.
113#[derive(Clone, Debug)]
114pub struct SubmitOptions {
115    /// Create associated branches, code reviews, etc. for each of the provided commits.
116    ///
117    /// This should be an idempotent behavior, i.e. setting `create` to `true`
118    /// and submitting a commit which already has an associated remote item
119    /// should not have any additional effect.
120    pub create: bool,
121
122    /// When creating new code reviews for the currently-submitting commits,
123    /// configure those code reviews to indicate that they are not yet ready for
124    /// review.
125    ///
126    /// If a "draft" state is not meaningful for the forge, then has no effect.
127    /// If a given commit is already submitted, then has no effect for that
128    /// commit's code review.
129    pub draft: bool,
130
131    /// For implementations which need to use the working copy to create the
132    /// code review, the appropriate execution strategy to do so.
133    pub execution_strategy: TestExecutionStrategy,
134
135    /// The number of jobs to use when submitting commits.
136    pub num_jobs: usize,
137
138    /// An optional message to include with the create or update operation.
139    pub message: Option<String>,
140}
141
142/// The result of creating a commit.
143#[derive(Clone, Debug)]
144pub struct CreateStatus {
145    /// The commit OID after carrying out the creation process. Usually, this
146    /// will be the same as the original commit OID, unless the forge amends it
147    /// (e.g. to include a change ID).
148    pub final_commit_oid: NonZeroOid,
149
150    /// An identifier corresponding to the commit, for display purposes only.
151    /// This may be a branch name, a change ID, the commit summary, etc.
152    ///
153    /// This does not necessarily correspond to the commit's name/identifier in
154    /// the forge (e.g. not a code review link).
155    pub local_commit_name: String,
156}
157
158/// "Forge" refers to a Git hosting provider, such as GitHub, GitLab, etc.
159/// Commits can be pushed for review to a forge.
160pub trait Forge: Debug {
161    /// Get the status of the provided commits.
162    fn query_status(
163        &mut self,
164        commit_set: CommitSet,
165    ) -> EyreExitOr<HashMap<NonZeroOid, CommitStatus>>;
166
167    /// Submit the provided set of commits for review.
168    fn create(
169        &mut self,
170        commits: HashMap<NonZeroOid, CommitStatus>,
171        options: &SubmitOptions,
172    ) -> EyreExitOr<HashMap<NonZeroOid, CreateStatus>>;
173
174    /// Update existing remote commits to match their local versions.
175    fn update(
176        &mut self,
177        commits: HashMap<NonZeroOid, CommitStatus>,
178        options: &SubmitOptions,
179    ) -> EyreExitOr<()>;
180}
181
182/// `submit` command.
183pub fn command_main(ctx: CommandContext, args: SubmitArgs) -> EyreExitOr<()> {
184    let CommandContext {
185        effects,
186        git_run_info,
187    } = ctx;
188    let SubmitArgs {
189        revsets,
190        resolve_revset_options,
191        forge_kind,
192        create,
193        draft,
194        message,
195        num_jobs,
196        execution_strategy,
197        dry_run,
198    } = args;
199    submit(
200        &effects,
201        &git_run_info,
202        revsets,
203        &resolve_revset_options,
204        forge_kind,
205        create,
206        draft,
207        message,
208        num_jobs,
209        execution_strategy,
210        dry_run,
211    )
212}
213
214fn submit(
215    effects: &Effects,
216    git_run_info: &GitRunInfo,
217    revsets: Vec<Revset>,
218    resolve_revset_options: &ResolveRevsetOptions,
219    forge_kind: Option<ForgeKind>,
220    create: bool,
221    draft: bool,
222    message: Option<String>,
223    num_jobs: Option<usize>,
224    execution_strategy: Option<TestExecutionStrategy>,
225    dry_run: bool,
226) -> EyreExitOr<()> {
227    let repo = Repo::from_current_dir()?;
228    let conn = repo.get_db_conn()?;
229    let event_log_db = EventLogDb::new(&conn)?;
230    let event_replayer = EventReplayer::from_event_log_db(effects, &repo, &event_log_db)?;
231    let event_cursor = event_replayer.make_default_cursor();
232    let references_snapshot = repo.get_references_snapshot()?;
233    let mut dag = Dag::open_and_sync(
234        effects,
235        &repo,
236        &event_replayer,
237        event_cursor,
238        &references_snapshot,
239    )?;
240
241    let commit_set =
242        match resolve_commits(effects, &repo, &mut dag, &revsets, resolve_revset_options) {
243            Ok(commit_sets) => union_all(&commit_sets),
244            Err(err) => {
245                err.describe(effects)?;
246                return Ok(Err(ExitCode(1)));
247            }
248        };
249
250    let raw_test_options = RawTestOptions {
251        exec: Some("<dummy>".to_string()),
252        command: None,
253        dry_run: false,
254        strategy: execution_strategy,
255        search: None,
256        bisect: false,
257        no_cache: true,
258        interactive: false,
259        jobs: num_jobs,
260        verbosity: Verbosity::None,
261        apply_fixes: false,
262    };
263    let ResolvedTestOptions {
264        command: _,
265        execution_strategy,
266        search_strategy: _,
267        is_dry_run: _,
268        use_cache: _,
269        is_interactive: _,
270        num_jobs,
271        verbosity: _,
272        fix_options: _,
273    } = {
274        let now = SystemTime::now();
275        let event_tx_id =
276            event_log_db.make_transaction_id(now, "resolve test options for submit")?;
277        try_exit_code!(ResolvedTestOptions::resolve(
278            now,
279            effects,
280            &dag,
281            &repo,
282            event_tx_id,
283            &commit_set,
284            None,
285            &raw_test_options,
286        )?)
287    };
288    let submit_options = SubmitOptions {
289        create,
290        draft,
291        execution_strategy,
292        num_jobs,
293        message,
294    };
295
296    let unioned_revset = Revset(revsets.iter().map(|Revset(inner)| inner).join(" + "));
297    let mut forge = select_forge(
298        effects,
299        git_run_info,
300        &repo,
301        &mut dag,
302        &event_log_db,
303        &references_snapshot,
304        &unioned_revset,
305        forge_kind,
306    )?;
307    let statuses = try_exit_code!(forge.query_status(commit_set)?);
308    debug!(?statuses, "Commit statuses");
309
310    #[allow(clippy::type_complexity)]
311    let (_local_commits, unsubmitted_commits, commits_to_update, commits_to_skip): (
312        HashMap<NonZeroOid, CommitStatus>,
313        HashMap<NonZeroOid, CommitStatus>,
314        HashMap<NonZeroOid, CommitStatus>,
315        HashMap<NonZeroOid, CommitStatus>,
316    ) = statuses.into_iter().fold(Default::default(), |acc, elem| {
317        let (mut local, mut unsubmitted, mut to_update, mut to_skip) = acc;
318        let (commit_oid, commit_status) = elem;
319        match commit_status {
320            CommitStatus {
321                submit_status: SubmitStatus::Local,
322                remote_name: _,
323                local_commit_name: _,
324                remote_commit_name: _,
325            } => {
326                local.insert(commit_oid, commit_status);
327            }
328
329            CommitStatus {
330                submit_status: SubmitStatus::Unsubmitted,
331                remote_name: _,
332                local_commit_name: _,
333                remote_commit_name: _,
334            } => {
335                unsubmitted.insert(commit_oid, commit_status);
336            }
337
338            CommitStatus {
339                submit_status: SubmitStatus::NeedsUpdate,
340                remote_name: _,
341                local_commit_name: _,
342                remote_commit_name: _,
343            } => {
344                to_update.insert(commit_oid, commit_status);
345            }
346
347            CommitStatus {
348                submit_status: SubmitStatus::UpToDate,
349                remote_name: _,
350                local_commit_name: Some(_),
351                remote_commit_name: _,
352            } => {
353                to_skip.insert(commit_oid, commit_status);
354            }
355
356            // Don't know what to do in these cases 🙃.
357            CommitStatus {
358                submit_status: SubmitStatus::Unknown,
359                remote_name: _,
360                local_commit_name: _,
361                remote_commit_name: _,
362            }
363            | CommitStatus {
364                submit_status: SubmitStatus::UpToDate,
365                remote_name: _,
366                local_commit_name: None,
367                remote_commit_name: _,
368            } => {}
369        }
370        (local, unsubmitted, to_update, to_skip)
371    });
372
373    let (submitted_commit_names, unsubmitted_commit_names): (BTreeSet<String>, BTreeSet<String>) = {
374        let unsubmitted_commit_names: BTreeSet<String> = unsubmitted_commits
375            .values()
376            .flat_map(|commit_status| commit_status.local_commit_name.clone())
377            .collect();
378        if create {
379            let created_commit_names = if dry_run {
380                unsubmitted_commit_names.clone()
381            } else {
382                let create_statuses =
383                    try_exit_code!(forge.create(unsubmitted_commits, &submit_options)?);
384                create_statuses
385                    .into_values()
386                    .map(
387                        |CreateStatus {
388                             final_commit_oid: _,
389                             local_commit_name,
390                         }| local_commit_name,
391                    )
392                    .collect()
393            };
394            (created_commit_names, Default::default())
395        } else {
396            (Default::default(), unsubmitted_commit_names)
397        }
398    };
399
400    let (updated_commit_names, skipped_commit_names): (BTreeSet<String>, BTreeSet<String>) = {
401        let updated_commit_names = commits_to_update
402            .iter()
403            .flat_map(|(_commit_oid, commit_status)| commit_status.local_commit_name.clone())
404            .collect();
405        let skipped_commit_names = commits_to_skip
406            .iter()
407            .flat_map(|(_commit_oid, commit_status)| commit_status.local_commit_name.clone())
408            .collect();
409
410        if !dry_run {
411            try_exit_code!(forge.update(commits_to_update, &submit_options)?);
412        }
413        (updated_commit_names, skipped_commit_names)
414    };
415
416    if !submitted_commit_names.is_empty() {
417        writeln!(
418            effects.get_output_stream(),
419            "{} {}: {}",
420            if dry_run { "Would submit" } else { "Submitted" },
421            Pluralize {
422                determiner: None,
423                amount: submitted_commit_names.len(),
424                unit: ("commit", "commits"),
425            },
426            submitted_commit_names
427                .into_iter()
428                .map(|commit_name| effects
429                    .get_glyphs()
430                    .render(
431                        StyledStringBuilder::new()
432                            .append_styled(commit_name, *STYLE_PUSHED)
433                            .build(),
434                    )
435                    .expect("Rendering commit name"))
436                .join(", ")
437        )?;
438    }
439    if !updated_commit_names.is_empty() {
440        writeln!(
441            effects.get_output_stream(),
442            "{} {}: {}",
443            if dry_run { "Would update" } else { "Updated" },
444            Pluralize {
445                determiner: None,
446                amount: updated_commit_names.len(),
447                unit: ("commit", "commits"),
448            },
449            updated_commit_names
450                .into_iter()
451                .map(|branch_name| effects
452                    .get_glyphs()
453                    .render(
454                        StyledStringBuilder::new()
455                            .append_styled(branch_name, *STYLE_PUSHED)
456                            .build(),
457                    )
458                    .expect("Rendering commit name"))
459                .join(", ")
460        )?;
461    }
462    if !skipped_commit_names.is_empty() {
463        writeln!(
464            effects.get_output_stream(),
465            "{} {} (already up-to-date): {}",
466            if dry_run { "Would skip" } else { "Skipped" },
467            Pluralize {
468                determiner: None,
469                amount: skipped_commit_names.len(),
470                unit: ("commit", "commits"),
471            },
472            skipped_commit_names
473                .into_iter()
474                .map(|commit_name| effects
475                    .get_glyphs()
476                    .render(
477                        StyledStringBuilder::new()
478                            .append_styled(commit_name, *STYLE_SKIPPED)
479                            .build(),
480                    )
481                    .expect("Rendering commit name"))
482                .join(", ")
483        )?;
484    }
485    if !unsubmitted_commit_names.is_empty() {
486        writeln!(
487            effects.get_output_stream(),
488            "{} {} (not yet on remote): {}",
489            if dry_run { "Would skip" } else { "Skipped" },
490            Pluralize {
491                determiner: None,
492                amount: unsubmitted_commit_names.len(),
493                unit: ("commit", "commits")
494            },
495            unsubmitted_commit_names
496                .into_iter()
497                .map(|commit_name| effects
498                    .get_glyphs()
499                    .render(
500                        StyledStringBuilder::new()
501                            .append_styled(commit_name, *STYLE_SKIPPED)
502                            .build(),
503                    )
504                    .expect("Rendering commit name"))
505                .join(", ")
506        )?;
507        writeln!(
508            effects.get_output_stream(),
509            "\
510These commits {} skipped because they {} not already associated with a remote
511repository. To submit them, retry this operation with the --create option.",
512            if dry_run { "would be" } else { "were" },
513            if dry_run { "are" } else { "were" },
514        )?;
515    }
516
517    Ok(Ok(()))
518}
519
520#[instrument]
521fn select_forge<'a>(
522    effects: &'a Effects,
523    git_run_info: &'a GitRunInfo,
524    repo: &'a Repo,
525    dag: &'a mut Dag,
526    event_log_db: &'a EventLogDb,
527    references_snapshot: &'a RepoReferencesSnapshot,
528    revset: &'a Revset,
529    forge_kind: Option<ForgeKind>,
530) -> eyre::Result<Box<dyn Forge + 'a>> {
531    // Check if explicitly set:
532    let forge_kind = match forge_kind {
533        Some(forge_kind) => {
534            info!(?forge_kind, "Forge kind was explicitly set");
535            Some(forge_kind)
536        }
537        None => None,
538    };
539
540    // Check Phabricator:
541    let forge_kind = match forge_kind {
542        Some(forge_kind) => Some(forge_kind),
543        None => {
544            let use_phabricator = if let Some(working_copy_path) = repo.get_working_copy_path() {
545                let arcconfig_path = &working_copy_path.join(".arcconfig");
546                let arcconfig_present = arcconfig_path.is_file();
547                debug!(
548                    ?arcconfig_path,
549                    ?arcconfig_present,
550                    "Checking arcconfig path to decide whether to use Phabricator"
551                );
552                arcconfig_present
553            } else {
554                false
555            };
556            use_phabricator.then_some(ForgeKind::Phabricator)
557        }
558    };
559
560    // Check Github:
561    let is_github_forge_reliable_enough_for_opt_out_usage = false; // as of 2024-04-06 it's too buggy; see https://github.com/arxanas/git-branchless/discussions/1259
562    let forge_kind = match (
563        forge_kind,
564        is_github_forge_reliable_enough_for_opt_out_usage,
565    ) {
566        (Some(forge_kind), _) => Some(forge_kind),
567        (None, true) => github_push_remote(repo)?.map(|_| ForgeKind::Github),
568        (None, false) => None,
569    };
570
571    // Default:
572    let forge_kind = forge_kind.unwrap_or(ForgeKind::Branch);
573
574    info!(?forge_kind, "Selected forge kind");
575    let forge: Box<dyn Forge> = match forge_kind {
576        ForgeKind::Branch => Box::new(BranchForge {
577            effects,
578            git_run_info,
579            repo,
580            dag,
581            event_log_db,
582            references_snapshot,
583        }),
584
585        ForgeKind::Github => Box::new(GithubForge {
586            effects,
587            git_run_info,
588            repo,
589            dag,
590            event_log_db,
591            client: GithubForge::client(git_run_info.clone()),
592        }),
593
594        ForgeKind::Phabricator => Box::new(PhabricatorForge {
595            effects,
596            git_run_info,
597            repo,
598            dag,
599            event_log_db,
600            revset,
601        }),
602    };
603    Ok(forge)
604}