1#![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 pub static ref STYLE_PUSHED: Style =
48 Style::merge(&[BaseColor::Green.light().into(), Effect::Bold.into()]);
49
50 pub static ref STYLE_SKIPPED: Style =
52 Style::merge(&[BaseColor::Yellow.light().into(), Effect::Bold.into()]);
53}
54
55#[derive(Clone, Debug)]
57pub enum SubmitStatus {
58 Local,
61
62 Unsubmitted,
65
66 Unknown,
68
69 UpToDate,
71
72 NeedsUpdate,
75}
76
77#[derive(Clone, Debug)]
79pub struct CommitStatus {
80 submit_status: SubmitStatus,
82
83 remote_name: Option<String>,
85
86 local_commit_name: Option<String>,
98
99 remote_commit_name: Option<String>,
110}
111
112#[derive(Clone, Debug)]
114pub struct SubmitOptions {
115 pub create: bool,
121
122 pub draft: bool,
130
131 pub execution_strategy: TestExecutionStrategy,
134
135 pub num_jobs: usize,
137
138 pub message: Option<String>,
140}
141
142#[derive(Clone, Debug)]
144pub struct CreateStatus {
145 pub final_commit_oid: NonZeroOid,
149
150 pub local_commit_name: String,
156}
157
158pub trait Forge: Debug {
161 fn query_status(
163 &mut self,
164 commit_set: CommitSet,
165 ) -> EyreExitOr<HashMap<NonZeroOid, CommitStatus>>;
166
167 fn create(
169 &mut self,
170 commits: HashMap<NonZeroOid, CommitStatus>,
171 options: &SubmitOptions,
172 ) -> EyreExitOr<HashMap<NonZeroOid, CreateStatus>>;
173
174 fn update(
176 &mut self,
177 commits: HashMap<NonZeroOid, CommitStatus>,
178 options: &SubmitOptions,
179 ) -> EyreExitOr<()>;
180}
181
182pub 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 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 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 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 let is_github_forge_reliable_enough_for_opt_out_usage = false; 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 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}