#![warn(missing_docs)]
#![warn(
clippy::all,
clippy::as_conversions,
clippy::clone_on_ref_ptr,
clippy::dbg_macro
)]
#![allow(clippy::too_many_arguments, clippy::blocks_in_if_conditions)]
mod branch_forge;
pub mod github;
pub mod phabricator;
use std::collections::{BTreeSet, HashMap};
use std::fmt::{Debug, Write};
use std::time::SystemTime;
use branch_forge::BranchForge;
use cursive_core::theme::{BaseColor, Effect, Style};
use git_branchless_invoke::CommandContext;
use git_branchless_test::{RawTestOptions, ResolvedTestOptions, Verbosity};
use github::GithubForge;
use itertools::Itertools;
use lazy_static::lazy_static;
use lib::core::dag::{union_all, CommitSet, Dag};
use lib::core::effects::Effects;
use lib::core::eventlog::{EventLogDb, EventReplayer};
use lib::core::formatting::{Pluralize, StyledStringBuilder};
use lib::core::repo_ext::{RepoExt, RepoReferencesSnapshot};
use lib::git::{GitRunInfo, NonZeroOid, Repo};
use lib::try_exit_code;
use lib::util::{ExitCode, EyreExitOr};
use git_branchless_opts::{
ForgeKind, ResolveRevsetOptions, Revset, SubmitArgs, TestExecutionStrategy,
};
use git_branchless_revset::resolve_commits;
use phabricator::PhabricatorForge;
use tracing::{debug, info, instrument, warn};
use crate::github::github_push_remote;
lazy_static! {
pub static ref STYLE_PUSHED: Style =
Style::merge(&[BaseColor::Green.light().into(), Effect::Bold.into()]);
pub static ref STYLE_SKIPPED: Style =
Style::merge(&[BaseColor::Yellow.light().into(), Effect::Bold.into()]);
}
#[derive(Clone, Debug)]
pub enum SubmitStatus {
Local,
Unsubmitted,
Unknown,
UpToDate,
NeedsUpdate,
}
#[derive(Clone, Debug)]
pub struct CommitStatus {
submit_status: SubmitStatus,
remote_name: Option<String>,
local_commit_name: Option<String>,
remote_commit_name: Option<String>,
}
#[derive(Clone, Debug)]
pub struct SubmitOptions {
pub create: bool,
pub draft: bool,
pub execution_strategy: TestExecutionStrategy,
pub num_jobs: usize,
pub message: Option<String>,
}
#[derive(Clone, Debug)]
pub struct CreateStatus {
pub final_commit_oid: NonZeroOid,
pub local_commit_name: String,
}
pub trait Forge: Debug {
fn query_status(
&mut self,
commit_set: CommitSet,
) -> EyreExitOr<HashMap<NonZeroOid, CommitStatus>>;
fn create(
&mut self,
commits: HashMap<NonZeroOid, CommitStatus>,
options: &SubmitOptions,
) -> EyreExitOr<HashMap<NonZeroOid, CreateStatus>>;
fn update(
&mut self,
commits: HashMap<NonZeroOid, CommitStatus>,
options: &SubmitOptions,
) -> EyreExitOr<()>;
}
pub fn command_main(ctx: CommandContext, args: SubmitArgs) -> EyreExitOr<()> {
let CommandContext {
effects,
git_run_info,
} = ctx;
let SubmitArgs {
create,
draft,
strategy,
revsets,
resolve_revset_options,
forge,
message,
dry_run,
} = args;
submit(
&effects,
&git_run_info,
revsets,
&resolve_revset_options,
create,
draft,
strategy,
forge,
message,
dry_run,
)
}
fn submit(
effects: &Effects,
git_run_info: &GitRunInfo,
revsets: Vec<Revset>,
resolve_revset_options: &ResolveRevsetOptions,
create: bool,
draft: bool,
execution_strategy: Option<TestExecutionStrategy>,
forge_kind: Option<ForgeKind>,
message: Option<String>,
dry_run: bool,
) -> EyreExitOr<()> {
let repo = Repo::from_current_dir()?;
let conn = repo.get_db_conn()?;
let event_log_db = EventLogDb::new(&conn)?;
let event_replayer = EventReplayer::from_event_log_db(effects, &repo, &event_log_db)?;
let event_cursor = event_replayer.make_default_cursor();
let references_snapshot = repo.get_references_snapshot()?;
let mut dag = Dag::open_and_sync(
effects,
&repo,
&event_replayer,
event_cursor,
&references_snapshot,
)?;
let commit_set =
match resolve_commits(effects, &repo, &mut dag, &revsets, resolve_revset_options) {
Ok(commit_sets) => union_all(&commit_sets),
Err(err) => {
err.describe(effects)?;
return Ok(Err(ExitCode(1)));
}
};
let raw_test_options = RawTestOptions {
exec: Some("<dummy>".to_string()),
command: None,
dry_run: false,
strategy: execution_strategy,
search: None,
bisect: false,
no_cache: true,
interactive: false,
jobs: None,
verbosity: Verbosity::None,
apply_fixes: false,
};
let ResolvedTestOptions {
command: _,
execution_strategy,
search_strategy: _,
is_dry_run: _,
use_cache: _,
is_interactive: _,
num_jobs,
verbosity: _,
fix_options: _,
} = {
let now = SystemTime::now();
let event_tx_id =
event_log_db.make_transaction_id(now, "resolve test options for submit")?;
try_exit_code!(ResolvedTestOptions::resolve(
now,
effects,
&dag,
&repo,
event_tx_id,
&commit_set,
None,
&raw_test_options,
)?)
};
let submit_options = SubmitOptions {
create,
draft,
execution_strategy,
num_jobs,
message,
};
let unioned_revset = Revset(revsets.iter().map(|Revset(inner)| inner).join(" + "));
let mut forge = select_forge(
effects,
git_run_info,
&repo,
&mut dag,
&event_log_db,
&references_snapshot,
&unioned_revset,
forge_kind,
)?;
let statuses = try_exit_code!(forge.query_status(commit_set)?);
debug!(?statuses, "Commit statuses");
#[allow(clippy::type_complexity)]
let (_local_commits, unsubmitted_commits, commits_to_update, commits_to_skip): (
HashMap<NonZeroOid, CommitStatus>,
HashMap<NonZeroOid, CommitStatus>,
HashMap<NonZeroOid, CommitStatus>,
HashMap<NonZeroOid, CommitStatus>,
) = statuses.into_iter().fold(Default::default(), |acc, elem| {
let (mut local, mut unsubmitted, mut to_update, mut to_skip) = acc;
let (commit_oid, commit_status) = elem;
match commit_status {
CommitStatus {
submit_status: SubmitStatus::Local,
remote_name: _,
local_commit_name: _,
remote_commit_name: _,
} => {
local.insert(commit_oid, commit_status);
}
CommitStatus {
submit_status: SubmitStatus::Unsubmitted,
remote_name: _,
local_commit_name: _,
remote_commit_name: _,
} => {
unsubmitted.insert(commit_oid, commit_status);
}
CommitStatus {
submit_status: SubmitStatus::NeedsUpdate,
remote_name: _,
local_commit_name: _,
remote_commit_name: _,
} => {
to_update.insert(commit_oid, commit_status);
}
CommitStatus {
submit_status: SubmitStatus::UpToDate,
remote_name: _,
local_commit_name: Some(_),
remote_commit_name: _,
} => {
to_skip.insert(commit_oid, commit_status);
}
CommitStatus {
submit_status: SubmitStatus::Unknown,
remote_name: _,
local_commit_name: _,
remote_commit_name: _,
}
| CommitStatus {
submit_status: SubmitStatus::UpToDate,
remote_name: _,
local_commit_name: None,
remote_commit_name: _,
} => {}
}
(local, unsubmitted, to_update, to_skip)
});
let (submitted_commit_names, unsubmitted_commit_names): (BTreeSet<String>, BTreeSet<String>) = {
let unsubmitted_commit_names: BTreeSet<String> = unsubmitted_commits
.values()
.flat_map(|commit_status| commit_status.local_commit_name.clone())
.collect();
if create {
let created_commit_names = if dry_run {
unsubmitted_commit_names.clone()
} else {
let create_statuses =
try_exit_code!(forge.create(unsubmitted_commits, &submit_options)?);
create_statuses
.into_values()
.map(
|CreateStatus {
final_commit_oid: _,
local_commit_name,
}| local_commit_name,
)
.collect()
};
(created_commit_names, Default::default())
} else {
(Default::default(), unsubmitted_commit_names)
}
};
let (updated_commit_names, skipped_commit_names): (BTreeSet<String>, BTreeSet<String>) = {
let updated_commit_names = commits_to_update
.iter()
.flat_map(|(_commit_oid, commit_status)| commit_status.local_commit_name.clone())
.collect();
let skipped_commit_names = commits_to_skip
.iter()
.flat_map(|(_commit_oid, commit_status)| commit_status.local_commit_name.clone())
.collect();
if !dry_run {
try_exit_code!(forge.update(commits_to_update, &submit_options)?);
}
(updated_commit_names, skipped_commit_names)
};
if !submitted_commit_names.is_empty() {
writeln!(
effects.get_output_stream(),
"{} {}: {}",
if dry_run { "Would submit" } else { "Submitted" },
Pluralize {
determiner: None,
amount: submitted_commit_names.len(),
unit: ("commit", "commits"),
},
submitted_commit_names
.into_iter()
.map(|commit_name| effects
.get_glyphs()
.render(
StyledStringBuilder::new()
.append_styled(commit_name, *STYLE_PUSHED)
.build(),
)
.expect("Rendering commit name"))
.join(", ")
)?;
}
if !updated_commit_names.is_empty() {
writeln!(
effects.get_output_stream(),
"{} {}: {}",
if dry_run { "Would update" } else { "Updated" },
Pluralize {
determiner: None,
amount: updated_commit_names.len(),
unit: ("commit", "commits"),
},
updated_commit_names
.into_iter()
.map(|branch_name| effects
.get_glyphs()
.render(
StyledStringBuilder::new()
.append_styled(branch_name, *STYLE_PUSHED)
.build(),
)
.expect("Rendering commit name"))
.join(", ")
)?;
}
if !skipped_commit_names.is_empty() {
writeln!(
effects.get_output_stream(),
"{} {} (already up-to-date): {}",
if dry_run { "Would skip" } else { "Skipped" },
Pluralize {
determiner: None,
amount: skipped_commit_names.len(),
unit: ("commit", "commits"),
},
skipped_commit_names
.into_iter()
.map(|commit_name| effects
.get_glyphs()
.render(
StyledStringBuilder::new()
.append_styled(commit_name, *STYLE_SKIPPED)
.build(),
)
.expect("Rendering commit name"))
.join(", ")
)?;
}
if !unsubmitted_commit_names.is_empty() {
writeln!(
effects.get_output_stream(),
"{} {} (not yet on remote): {}",
if dry_run { "Would skip" } else { "Skipped" },
Pluralize {
determiner: None,
amount: unsubmitted_commit_names.len(),
unit: ("commit", "commits")
},
unsubmitted_commit_names
.into_iter()
.map(|commit_name| effects
.get_glyphs()
.render(
StyledStringBuilder::new()
.append_styled(commit_name, *STYLE_SKIPPED)
.build(),
)
.expect("Rendering commit name"))
.join(", ")
)?;
writeln!(
effects.get_output_stream(),
"\
These commits {} skipped because they {} not already associated with a remote
repository. To submit them, retry this operation with the --create option.",
if dry_run { "would be" } else { "were" },
if dry_run { "are" } else { "were" },
)?;
}
Ok(Ok(()))
}
#[instrument]
fn select_forge<'a>(
effects: &'a Effects,
git_run_info: &'a GitRunInfo,
repo: &'a Repo,
dag: &'a mut Dag,
event_log_db: &'a EventLogDb,
references_snapshot: &'a RepoReferencesSnapshot,
revset: &'a Revset,
forge_kind: Option<ForgeKind>,
) -> eyre::Result<Box<dyn Forge + 'a>> {
let forge_kind = match forge_kind {
Some(forge_kind) => {
info!(?forge_kind, "Forge kind was explicitly set");
Some(forge_kind)
}
None => None,
};
let forge_kind = match forge_kind {
Some(forge_kind) => Some(forge_kind),
None => {
let use_phabricator = if let Some(working_copy_path) = repo.get_working_copy_path() {
let arcconfig_path = &working_copy_path.join(".arcconfig");
let arcconfig_present = arcconfig_path.is_file();
debug!(
?arcconfig_path,
?arcconfig_present,
"Checking arcconfig path to decide whether to use Phabricator"
);
arcconfig_present
} else {
false
};
use_phabricator.then_some(ForgeKind::Phabricator)
}
};
let is_github_forge_reliable_enough_for_opt_out_usage = false; let forge_kind = match (
forge_kind,
is_github_forge_reliable_enough_for_opt_out_usage,
) {
(Some(forge_kind), _) => Some(forge_kind),
(None, true) => github_push_remote(repo)?.map(|_| ForgeKind::Github),
(None, false) => None,
};
let forge_kind = forge_kind.unwrap_or(ForgeKind::Branch);
info!(?forge_kind, "Selected forge kind");
let forge: Box<dyn Forge> = match forge_kind {
ForgeKind::Branch => Box::new(BranchForge {
effects,
git_run_info,
repo,
dag,
event_log_db,
references_snapshot,
}),
ForgeKind::Github => Box::new(GithubForge {
effects,
git_run_info,
repo,
dag,
event_log_db,
client: GithubForge::client(git_run_info.clone()),
}),
ForgeKind::Phabricator => Box::new(PhabricatorForge {
effects,
git_run_info,
repo,
dag,
event_log_db,
revset,
}),
};
Ok(forge)
}