git_branchless_submit/
phabricator.rs

1//! Phabricator backend for submitting patch stacks.
2
3use std::collections::{HashMap, HashSet};
4use std::fmt::{self, Debug, Display, Write};
5use std::io;
6use std::path::PathBuf;
7use std::process::{Command, Stdio};
8use std::str::FromStr;
9use std::time::SystemTime;
10
11use cursive_core::theme::Effect;
12use cursive_core::utils::markup::StyledString;
13use git_branchless_opts::Revset;
14use git_branchless_test::{
15    run_tests, FixInfo, ResolvedTestOptions, TestOutput, TestResults, TestStatus,
16    TestingAbortedError, Verbosity,
17};
18use itertools::Itertools;
19use lazy_static::lazy_static;
20use lib::core::check_out::CheckOutCommitOptions;
21use lib::core::dag::{CommitSet, Dag};
22use lib::core::effects::{Effects, OperationType, WithProgress};
23use lib::core::eventlog::EventLogDb;
24use lib::core::formatting::StyledStringBuilder;
25use lib::core::rewrite::{
26    execute_rebase_plan, BuildRebasePlanError, BuildRebasePlanOptions, ExecuteRebasePlanOptions,
27    ExecuteRebasePlanResult, RebasePlanBuilder, RebasePlanPermissions, RepoResource,
28};
29use lib::git::{Commit, GitRunInfo, MaybeZeroOid, NonZeroOid, Repo, RepoError, TestCommand};
30use lib::try_exit_code;
31use lib::util::{ExitCode, EyreExitOr};
32use rayon::ThreadPoolBuilder;
33use regex::bytes::Regex;
34use serde::{Deserialize, Serialize};
35use thiserror::Error;
36use tracing::{instrument, warn};
37
38use crate::{CommitStatus, CreateStatus, Forge, SubmitOptions, SubmitStatus, STYLE_PUSHED};
39
40/// Wrapper around the Phabricator "ID" type. (This is *not* a PHID, just a
41/// regular ID).
42#[derive(Clone, Debug, Serialize, Deserialize, Eq, Hash, PartialEq)]
43#[serde(transparent)]
44pub struct Id(pub String);
45
46impl Display for Id {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        let Self(id) = self;
49        write!(f, "D{id}")
50    }
51}
52
53#[derive(Clone, Debug, Serialize, Deserialize, Eq, Hash, PartialEq)]
54#[serde(transparent)]
55struct Phid(pub String);
56
57#[derive(Clone, Debug, Default, Serialize, Eq, PartialEq)]
58struct DifferentialQueryRequest {
59    ids: Vec<Id>,
60    phids: Vec<Phid>,
61}
62
63#[derive(Debug, Serialize, Eq, PartialEq)]
64struct DifferentialEditRequest {
65    #[serde(rename = "objectIdentifier")]
66    id: Id, // could also be a PHID
67    transactions: Vec<DifferentialEditTransaction>,
68}
69
70#[derive(Debug, Default, Serialize, Eq, PartialEq)]
71struct DifferentialEditTransaction {
72    r#type: String,
73    value: Vec<Phid>,
74}
75
76#[derive(Debug, Deserialize)]
77struct ConduitResponse<T> {
78    #[serde(rename = "errorMessage")]
79    error_message: Option<String>,
80    response: Option<T>,
81}
82
83impl<T> ConduitResponse<T> {
84    fn check_err(self) -> std::result::Result<T, String> {
85        let Self {
86            error_message,
87            response,
88        } = self;
89        match error_message {
90            Some(error_message) => Err(error_message),
91            None => match response {
92                None => Err("(no error message)".to_string()),
93                Some(response) => Ok(response),
94            },
95        }
96    }
97}
98
99impl<T: Default> Default for ConduitResponse<T> {
100    fn default() -> Self {
101        Self {
102            error_message: Default::default(),
103            response: Default::default(),
104        }
105    }
106}
107
108#[derive(Debug, Deserialize)]
109struct DifferentialQueryRevisionResponse {
110    id: Id,
111    phid: Phid,
112
113    #[serde(default)]
114    hashes: Vec<(String, String)>,
115
116    #[serde(default)]
117    auxiliary: DifferentialQueryAuxiliaryResponse,
118}
119
120#[derive(Debug, Default, Deserialize)]
121struct DifferentialQueryAuxiliaryResponse {
122    // TODO: add `default`
123    #[serde(rename = "phabricator:depends-on")]
124    phabricator_depends_on: Vec<Phid>,
125}
126
127/// Error type.
128#[allow(missing_docs)]
129#[derive(Debug, Error)]
130pub enum Error {
131    #[error("no working copy for repository at path: {}", .repo_path.display())]
132    NoWorkingCopy { repo_path: PathBuf },
133
134    #[error("could not iterate commits: {0}")]
135    IterCommits(#[source] eyre::Error),
136
137    #[error("could not look up commits: {0}")]
138    LookUpCommits(#[source] RepoError),
139
140    #[error("no commit with hash {commit_oid:?}: {source}")]
141    NoSuchCommit {
142        source: RepoError,
143        commit_oid: NonZeroOid,
144    },
145
146    #[error("invocation to `arc {args}` failed: {source}", args = args.join(" "))]
147    InvokeArc {
148        source: io::Error,
149        args: Vec<String>,
150    },
151
152    #[error("communication with `arc {args}` failed: {source}", args = args.join(" "))]
153    CommunicateWithArc {
154        source: serde_json::Error,
155        args: Vec<String>,
156    },
157
158    #[error("could not create phab for {commit_oid} when running `arc {args}` (exit code {exit_code}): {message}", args = args.join(" "))]
159    CreatePhab {
160        exit_code: i32,
161        message: String,
162        commit_oid: NonZeroOid,
163        args: Vec<String>,
164    },
165
166    #[error("could not query dependencies when running `arc {args}` (exit code {exit_code}): {message}", args = args.join(" "))]
167    QueryDependencies {
168        exit_code: i32,
169        message: String,
170        args: Vec<String>,
171    },
172
173    #[error("could not update dependencies when running `arc {args}` (exit code {exit_code}): {message}", args = args.join(" "))]
174    UpdateDependencies {
175        exit_code: i32,
176        message: String,
177        args: Vec<String>,
178    },
179
180    #[error("could not parse response when running `arc {args}`: {source}; with output: {output}", args = args.join(" "))]
181    ParseResponse {
182        source: serde_json::Error,
183        output: String,
184        args: Vec<String>,
185    },
186
187    #[error("error when calling Conduit API with request {request:?}: {message}")]
188    Conduit {
189        request: Box<dyn Debug + Send + Sync>,
190        message: String,
191    },
192
193    #[error("could not make transaction ID: {source}")]
194    MakeTransactionId { source: eyre::Error },
195
196    #[error("could not execute `arc diff` on commits: {source}")]
197    ExecuteArcDiff { source: eyre::Error },
198
199    #[error("could not verify permissions to rewrite commits: {source}")]
200    VerifyPermissions { source: eyre::Error },
201
202    #[error("could not build rebase plan")]
203    BuildRebasePlan(BuildRebasePlanError),
204
205    #[error("failed to rewrite commits with exit code {}", exit_code.0)]
206    RewriteCommits { exit_code: ExitCode },
207
208    #[error(transparent)]
209    Fmt(#[from] fmt::Error),
210
211    #[error(transparent)]
212    DagError(#[from] eden_dag::Error),
213}
214
215/// Result type.
216pub type Result<T> = std::result::Result<T, Error>;
217
218/// When this environment variable is set, the implementation of the Phabricator
219/// forge will make mock calls instead of actually invoking `arc`.
220pub const SHOULD_MOCK_ENV_KEY: &str = "BRANCHLESS_SUBMIT_PHABRICATOR_MOCK";
221
222fn should_mock() -> bool {
223    std::env::var_os(SHOULD_MOCK_ENV_KEY).is_some()
224}
225
226/// The [Phabricator](https://en.wikipedia.org/wiki/Phabricator) code review system.
227///
228/// Note that Phabricator is no longer actively maintained, but many
229/// organizations still use it.
230#[allow(missing_docs)]
231#[derive(Debug)]
232pub struct PhabricatorForge<'a> {
233    pub effects: &'a Effects,
234    pub git_run_info: &'a GitRunInfo,
235    pub repo: &'a Repo,
236    pub dag: &'a mut Dag,
237    pub event_log_db: &'a EventLogDb<'a>,
238    pub revset: &'a Revset,
239}
240
241impl Forge for PhabricatorForge<'_> {
242    #[instrument]
243    fn query_status(
244        &mut self,
245        commit_set: CommitSet,
246    ) -> eyre::Result<std::result::Result<HashMap<NonZeroOid, CommitStatus>, ExitCode>> {
247        let commit_oids = self.dag.commit_set_to_vec(&commit_set)?;
248        let commit_oid_to_revision: HashMap<NonZeroOid, Option<Id>> = commit_oids
249            .into_iter()
250            .map(|commit_oid| -> eyre::Result<_> {
251                let revision_id = self.get_revision_id(commit_oid)?;
252                Ok((commit_oid, revision_id))
253            })
254            .try_collect()?;
255
256        let revisions = if should_mock() {
257            Default::default()
258        } else {
259            self.query_revisions(&DifferentialQueryRequest {
260                ids: commit_oid_to_revision.values().flatten().cloned().collect(),
261                phids: Default::default(),
262            })?
263        };
264        let commit_hashes: HashMap<Id, NonZeroOid> = revisions
265            .into_iter()
266            .filter_map(|item| {
267                let hashes: HashMap<String, String> = item.hashes.iter().cloned().collect();
268                if hashes.is_empty() {
269                    None
270                } else {
271                    // `gtcm` stands for "git commit" (as opposed to `gttr`, also returned in the same list, or `hgcm`, which stands for "hg commit").
272                    match hashes.get("gtcm") {
273                        None => {
274                            warn!(?item, "No Git commit hash in item");
275                            None
276                        }
277                        Some(commit_oid) => match NonZeroOid::from_str(commit_oid.as_str()) {
278                            Ok(commit_oid) => Some((item.id, commit_oid)),
279                            Err(err) => {
280                                warn!(?err, "Couldn't parse Git commit OID");
281                                None
282                            }
283                        },
284                    }
285                }
286            })
287            .collect();
288
289        let statuses = commit_oid_to_revision
290            .into_iter()
291            .map(|(commit_oid, id)| {
292                let status = CommitStatus {
293                    submit_status: match id {
294                        Some(id) => match commit_hashes.get(&id) {
295                            Some(remote_commit_oid) => {
296                                if remote_commit_oid == &commit_oid {
297                                    SubmitStatus::UpToDate
298                                } else {
299                                    SubmitStatus::NeedsUpdate
300                                }
301                            }
302                            None => {
303                                warn!(?commit_oid, ?id, "No remote commit hash found for commit");
304                                SubmitStatus::NeedsUpdate
305                            }
306                        },
307                        None => SubmitStatus::Unsubmitted,
308                    },
309                    remote_name: None,
310                    local_commit_name: None,
311                    remote_commit_name: None,
312                };
313                (commit_oid, status)
314            })
315            .collect();
316        Ok(Ok(statuses))
317    }
318
319    #[instrument]
320    fn create(
321        &mut self,
322        commits: HashMap<NonZeroOid, CommitStatus>,
323        options: &SubmitOptions,
324    ) -> eyre::Result<std::result::Result<HashMap<NonZeroOid, CreateStatus>, ExitCode>> {
325        let SubmitOptions {
326            create: _,
327            draft,
328            execution_strategy,
329            num_jobs,
330            message: _,
331        } = options;
332
333        let commit_set = commits.keys().copied().collect();
334        let commit_oids = self.dag.sort(&commit_set).map_err(Error::IterCommits)?;
335        let commits: Vec<Commit> = commit_oids
336            .iter()
337            .map(|commit_oid| self.repo.find_commit_or_fail(*commit_oid))
338            .collect::<std::result::Result<_, _>>()
339            .map_err(Error::LookUpCommits)?;
340        let now = SystemTime::now();
341        let event_tx_id = self
342            .event_log_db
343            .make_transaction_id(now, "phabricator create")
344            .map_err(|err| Error::MakeTransactionId { source: err })?;
345        let build_options = BuildRebasePlanOptions {
346            force_rewrite_public_commits: false,
347            dump_rebase_constraints: false,
348            dump_rebase_plan: false,
349            detect_duplicate_commits_via_patch_id: false,
350        };
351        let execute_options = ExecuteRebasePlanOptions {
352            now,
353            event_tx_id,
354            preserve_timestamps: true,
355            force_in_memory: true,
356            force_on_disk: false,
357            resolve_merge_conflicts: false,
358            check_out_commit_options: CheckOutCommitOptions {
359                render_smartlog: false,
360                ..Default::default()
361            },
362        };
363        let permissions =
364            RebasePlanPermissions::verify_rewrite_set(self.dag, build_options, &commit_set)
365                .map_err(|err| Error::VerifyPermissions { source: err })?
366                .map_err(Error::BuildRebasePlan)?;
367        let command = if !should_mock() {
368            let mut args = vec!["arc", "diff", "--create", "--verbatim", "--allow-untracked"];
369            if *draft {
370                args.push("--draft");
371            }
372            args.extend(["--", "HEAD^"]);
373            TestCommand::Args(args.into_iter().map(ToString::to_string).collect())
374        } else {
375            TestCommand::String(
376                r#"git commit --amend --message "$(git show --no-patch --format=%B HEAD)
377
378Differential Revision: https://phabricator.example.com/D000$(git rev-list --count HEAD)
379            "
380            "#
381                .to_string(),
382            )
383        };
384
385        let test_results = match run_tests(
386            now,
387            self.effects,
388            self.git_run_info,
389            self.dag,
390            self.repo,
391            self.event_log_db,
392            self.revset,
393            &commits,
394            &ResolvedTestOptions {
395                command,
396                execution_strategy: *execution_strategy,
397                search_strategy: None,
398                is_dry_run: false,
399                use_cache: false,
400                is_interactive: false,
401                num_jobs: *num_jobs,
402                verbosity: Verbosity::None,
403                fix_options: Some((execute_options.clone(), permissions.clone())),
404            },
405        ) {
406            Ok(Ok(test_results)) => test_results,
407            Ok(Err(exit_code)) => return Ok(Err(exit_code)),
408            Err(err) => return Err(Error::ExecuteArcDiff { source: err }.into()),
409        };
410
411        let TestResults {
412            search_bounds: _,
413            test_outputs,
414            testing_aborted_error,
415        } = test_results;
416        if let Some(testing_aborted_error) = testing_aborted_error {
417            let TestingAbortedError {
418                commit_oid,
419                exit_code,
420            } = testing_aborted_error;
421            writeln!(
422                self.effects.get_output_stream(),
423                "Uploading was aborted with exit code {exit_code} due to commit {}",
424                self.effects.get_glyphs().render(
425                    self.repo
426                        .friendly_describe_commit_from_oid(self.effects.get_glyphs(), commit_oid)?
427                )?,
428            )?;
429            return Ok(Err(ExitCode(1)));
430        }
431
432        let rebase_plan = {
433            let mut builder = RebasePlanBuilder::new(self.dag, permissions);
434            for (commit_oid, test_output) in test_outputs {
435                let head_commit_oid = match test_output.test_status {
436                    TestStatus::CheckoutFailed
437                    | TestStatus::SpawnTestFailed(_)
438                    | TestStatus::TerminatedBySignal
439                    | TestStatus::AlreadyInProgress
440                    | TestStatus::ReadCacheFailed(_)
441                    | TestStatus::Indeterminate { .. }
442                    | TestStatus::Abort { .. }
443                    | TestStatus::Failed { .. } => {
444                        self.render_failed_test(commit_oid, &test_output)?;
445                        return Ok(Err(ExitCode(1)));
446                    }
447                    TestStatus::Passed {
448                        cached: _,
449                        fix_info:
450                            FixInfo {
451                                head_commit_oid,
452                                snapshot_tree_oid: _,
453                            },
454                        interactive: _,
455                    } => head_commit_oid,
456                };
457
458                let commit = self.repo.find_commit_or_fail(commit_oid)?;
459                builder.move_subtree(commit.get_oid(), commit.get_parent_oids())?;
460                builder.replace_commit(commit.get_oid(), head_commit_oid.unwrap_or(commit_oid))?;
461            }
462
463            let pool = ThreadPoolBuilder::new().build()?;
464            let repo_pool = RepoResource::new_pool(self.repo)?;
465            match builder.build(self.effects, &pool, &repo_pool)? {
466                Ok(Some(rebase_plan)) => rebase_plan,
467                Ok(None) => return Ok(Ok(Default::default())),
468                Err(err) => {
469                    err.describe(self.effects, self.repo, self.dag)?;
470                    return Ok(Err(ExitCode(1)));
471                }
472            }
473        };
474
475        let rewritten_oids = match execute_rebase_plan(
476            self.effects,
477            self.git_run_info,
478            self.repo,
479            self.event_log_db,
480            &rebase_plan,
481            &execute_options,
482        )? {
483            ExecuteRebasePlanResult::Succeeded {
484                rewritten_oids: Some(rewritten_oids),
485            } => rewritten_oids,
486            ExecuteRebasePlanResult::Succeeded {
487                rewritten_oids: None,
488            } => {
489                warn!("No rewritten commit OIDs were produced by rebase plan execution");
490                Default::default()
491            }
492            ExecuteRebasePlanResult::DeclinedToMerge {
493                failed_merge_info: _,
494            } => {
495                writeln!(
496                    self.effects.get_error_stream(),
497                    "BUG: Merge failed, but rewording shouldn't cause any merge failures."
498                )?;
499                return Ok(Err(ExitCode(1)));
500            }
501            ExecuteRebasePlanResult::Failed { exit_code } => {
502                return Ok(Err(exit_code));
503            }
504        };
505
506        let mut create_statuses = HashMap::new();
507        for commit_oid in commit_oids {
508            let final_commit_oid = match rewritten_oids.get(&commit_oid) {
509                Some(MaybeZeroOid::NonZero(commit_oid)) => *commit_oid,
510                Some(MaybeZeroOid::Zero) => {
511                    warn!(?commit_oid, "Commit was rewritten to the zero OID",);
512                    commit_oid
513                }
514                None => commit_oid,
515            };
516            let local_branch_name = {
517                match self.get_revision_id(final_commit_oid)? {
518                    Some(Id(id)) => format!("D{id}"),
519                    None => {
520                        writeln!(
521                            self.effects.get_output_stream(),
522                            "Failed to upload (link to newly-created revision not found in commit message): {}",
523                            self.effects.get_glyphs().render(
524                                self.repo.friendly_describe_commit_from_oid(
525                                    self.effects.get_glyphs(),
526                                    final_commit_oid
527                                )?
528                            )?,
529                        )?;
530                        return Ok(Err(ExitCode(1)));
531                    }
532                }
533            };
534            create_statuses.insert(
535                commit_oid,
536                CreateStatus {
537                    final_commit_oid,
538                    local_commit_name: local_branch_name,
539                },
540            );
541        }
542
543        let final_commit_oids: CommitSet = create_statuses
544            .values()
545            .map(|create_status| {
546                let CreateStatus {
547                    final_commit_oid,
548                    local_commit_name: _,
549                } = create_status;
550                *final_commit_oid
551            })
552            .collect();
553        self.dag.sync_from_oids(
554            self.effects,
555            self.repo,
556            CommitSet::empty(),
557            final_commit_oids.clone(),
558        )?;
559        match self.update_dependencies(&final_commit_oids, &final_commit_oids)? {
560            Ok(()) => {}
561            Err(exit_code) => return Ok(Err(exit_code)),
562        }
563
564        Ok(Ok(create_statuses))
565    }
566
567    #[instrument]
568    fn update(
569        &mut self,
570        commits: HashMap<NonZeroOid, crate::CommitStatus>,
571        options: &SubmitOptions,
572    ) -> EyreExitOr<()> {
573        let SubmitOptions {
574            create: _,
575            draft: _,
576            execution_strategy,
577            num_jobs,
578            message,
579        } = options;
580
581        let commit_set = commits.keys().copied().collect();
582        // Sort for consistency with `update_dependencies`.
583        let commit_oids = self.dag.sort(&commit_set)?;
584        let commits: Vec<_> = commit_oids
585            .into_iter()
586            .map(|commit_oid| self.repo.find_commit_or_fail(commit_oid))
587            .try_collect()?;
588
589        let now = SystemTime::now();
590        let event_tx_id = self
591            .event_log_db
592            .make_transaction_id(now, "phabricator update")?;
593        let build_options = BuildRebasePlanOptions {
594            force_rewrite_public_commits: false,
595            dump_rebase_constraints: false,
596            dump_rebase_plan: false,
597            detect_duplicate_commits_via_patch_id: false,
598        };
599        let execute_options = ExecuteRebasePlanOptions {
600            now,
601            event_tx_id,
602            preserve_timestamps: true,
603            force_in_memory: true,
604            force_on_disk: false,
605            resolve_merge_conflicts: false,
606            check_out_commit_options: CheckOutCommitOptions {
607                render_smartlog: false,
608                ..Default::default()
609            },
610        };
611        let permissions =
612            RebasePlanPermissions::verify_rewrite_set(self.dag, build_options, &commit_set)
613                .map_err(|err| Error::VerifyPermissions { source: err })?
614                .map_err(Error::BuildRebasePlan)?;
615        let test_options = ResolvedTestOptions {
616            command: if !should_mock() {
617                let mut args = vec![
618                    "arc",
619                    "diff",
620                    "--head",
621                    "HEAD",
622                    "HEAD^",
623                    "--allow-untracked",
624                ];
625                args.extend(match message {
626                    Some(message) => ["-m", message.as_ref()],
627                    None => ["-m", "update"],
628                });
629                TestCommand::Args(args.into_iter().map(ToString::to_string).collect())
630            } else {
631                TestCommand::String("echo Submitting $(git rev-parse HEAD)".to_string())
632            },
633            execution_strategy: *execution_strategy,
634            search_strategy: None,
635            is_dry_run: false,
636            use_cache: false,
637            is_interactive: false,
638            num_jobs: *num_jobs,
639            verbosity: Verbosity::None,
640            fix_options: Some((execute_options, permissions)),
641        };
642        let TestResults {
643            search_bounds: _,
644            test_outputs,
645            testing_aborted_error,
646        } = try_exit_code!(run_tests(
647            now,
648            self.effects,
649            self.git_run_info,
650            self.dag,
651            self.repo,
652            self.event_log_db,
653            self.revset,
654            &commits,
655            &test_options,
656        )?);
657        if let Some(testing_aborted_error) = testing_aborted_error {
658            let TestingAbortedError {
659                commit_oid,
660                exit_code,
661            } = testing_aborted_error;
662            writeln!(
663                self.effects.get_output_stream(),
664                "Updating was aborted with exit code {exit_code} due to commit {}",
665                self.effects.get_glyphs().render(
666                    self.repo
667                        .friendly_describe_commit_from_oid(self.effects.get_glyphs(), commit_oid)?
668                )?,
669            )?;
670            return Ok(Err(ExitCode(1)));
671        }
672
673        let (success_commits, failure_commits): (Vec<_>, Vec<_>) = test_outputs
674            .into_iter()
675            .partition(|(_commit_oid, test_output)| match test_output.test_status {
676                TestStatus::Passed { .. } => true,
677                TestStatus::CheckoutFailed
678                | TestStatus::SpawnTestFailed(_)
679                | TestStatus::TerminatedBySignal
680                | TestStatus::AlreadyInProgress
681                | TestStatus::ReadCacheFailed(_)
682                | TestStatus::Indeterminate { .. }
683                | TestStatus::Abort { .. }
684                | TestStatus::Failed { .. } => false,
685            });
686        if !failure_commits.is_empty() {
687            let effects = self.effects;
688            writeln!(
689                effects.get_output_stream(),
690                "Failed when running command: {}",
691                effects.get_glyphs().render(
692                    StyledStringBuilder::new()
693                        .append_styled(test_options.command.to_string(), Effect::Bold)
694                        .build()
695                )?
696            )?;
697            for (commit_oid, test_output) in failure_commits {
698                self.render_failed_test(commit_oid, &test_output)?;
699            }
700            return Ok(Err(ExitCode(1)));
701        }
702
703        try_exit_code!(self.update_dependencies(
704            &success_commits
705                .into_iter()
706                .map(|(commit_oid, _test_output)| commit_oid)
707                .collect(),
708            &CommitSet::empty()
709        )?);
710        Ok(Ok(()))
711    }
712}
713
714impl PhabricatorForge<'_> {
715    fn query_revisions(
716        &self,
717        request: &DifferentialQueryRequest,
718    ) -> Result<Vec<DifferentialQueryRevisionResponse>> {
719        // The API call seems to hang if we don't specify any IDs; perhaps it's
720        // fetching everything?
721        if request == &DifferentialQueryRequest::default() {
722            return Ok(Default::default());
723        }
724
725        let args = vec![
726            "call-conduit".to_string(),
727            "--".to_string(),
728            "differential.query".to_string(),
729        ];
730        let mut child = Command::new("arc")
731            .args(&args)
732            .stdin(Stdio::piped())
733            .stdout(Stdio::piped())
734            .stderr(Stdio::inherit())
735            .spawn()
736            .map_err(|err| Error::InvokeArc {
737                source: err,
738                args: args.clone(),
739            })?;
740        serde_json::to_writer_pretty(child.stdin.take().unwrap(), request).map_err(|err| {
741            Error::CommunicateWithArc {
742                source: err,
743                args: args.clone(),
744            }
745        })?;
746        let result = child.wait_with_output().map_err(|err| Error::InvokeArc {
747            source: err,
748            args: args.clone(),
749        })?;
750        if !result.status.success() {
751            return Err(Error::QueryDependencies {
752                exit_code: result.status.code().unwrap_or(-1),
753                message: String::from_utf8_lossy(&result.stdout).into_owned(),
754                args,
755            });
756        }
757
758        let output: ConduitResponse<Vec<DifferentialQueryRevisionResponse>> =
759            serde_json::from_slice(&result.stdout).map_err(|err| Error::ParseResponse {
760                source: err,
761                output: String::from_utf8_lossy(&result.stdout).into_owned(),
762                args: args.clone(),
763            })?;
764        let response = output.check_err().map_err(|message| Error::Conduit {
765            request: Box::new(request.clone()),
766            message,
767        })?;
768        Ok(response)
769    }
770
771    /// Query the dependencies of a set of commits from Phabricator (not locally).
772    pub fn query_remote_dependencies(
773        &self,
774        commit_oids: HashSet<NonZeroOid>,
775    ) -> Result<HashMap<NonZeroOid, HashSet<NonZeroOid>>> {
776        // Convert commit hashes to IDs.
777        let commit_oid_to_id: HashMap<NonZeroOid, Option<Id>> = {
778            let mut result = HashMap::new();
779            for commit_oid in commit_oids.iter().copied() {
780                let revision_id = self.get_revision_id(commit_oid)?;
781                result.insert(commit_oid, revision_id);
782            }
783            result
784        };
785
786        // Get the reverse mapping of IDs to commit hashes. Note that not every commit
787        // hash will have an ID -- specifically those which haven't been submitted yet.
788        let id_to_commit_oid: HashMap<Id, NonZeroOid> = commit_oid_to_id
789            .iter()
790            .filter_map(|(commit_oid, id)| id.as_ref().map(|v| (v.clone(), *commit_oid)))
791            .collect();
792
793        // Query for revision information by ID.
794        let query_ids: Vec<Id> = commit_oid_to_id
795            .values()
796            .filter_map(|id| id.as_ref().cloned())
797            .collect();
798
799        let revisions = self.query_revisions(&DifferentialQueryRequest {
800            ids: query_ids,
801            phids: Default::default(),
802        })?;
803
804        // Get the dependency PHIDs for each revision ID.
805        let dependency_phids: HashMap<Id, Vec<Phid>> = revisions
806            .into_iter()
807            .map(|revision| {
808                let DifferentialQueryRevisionResponse {
809                    id,
810                    phid: _,
811                    hashes: _,
812                    auxiliary:
813                        DifferentialQueryAuxiliaryResponse {
814                            phabricator_depends_on,
815                        },
816                } = revision;
817                (id, phabricator_depends_on)
818            })
819            .collect();
820
821        // Convert the dependency PHIDs back into revision IDs.
822        let dependency_ids: HashMap<Id, Vec<Id>> = {
823            let all_phids: Vec<Phid> = dependency_phids.values().flatten().cloned().collect();
824            let revisions = self.query_revisions(&DifferentialQueryRequest {
825                ids: Default::default(),
826                phids: all_phids,
827            })?;
828            let phid_to_id: HashMap<Phid, Id> = revisions
829                .into_iter()
830                .map(|revision| {
831                    let DifferentialQueryRevisionResponse {
832                        id,
833                        phid,
834                        hashes: _,
835                        auxiliary: _,
836                    } = revision;
837                    (phid, id)
838                })
839                .collect();
840            dependency_phids
841                .into_iter()
842                .map(|(id, dependency_phids)| {
843                    (
844                        id,
845                        dependency_phids
846                            .into_iter()
847                            .filter_map(|dependency_phid| phid_to_id.get(&dependency_phid))
848                            .cloned()
849                            .collect(),
850                    )
851                })
852                .collect()
853        };
854
855        // Use the looked-up IDs to convert the commit dependencies. Note that
856        // there may be dependencies not expressed in the set of commits, in
857        // which case... FIXME.
858        let result: HashMap<NonZeroOid, HashSet<NonZeroOid>> = commit_oid_to_id
859            .into_iter()
860            .map(|(commit_oid, id)| {
861                let dependency_ids = match id {
862                    None => Default::default(),
863                    Some(id) => match dependency_ids.get(&id) {
864                        None => Default::default(),
865                        Some(dependency_ids) => dependency_ids
866                            .iter()
867                            .filter_map(|dependency_id| id_to_commit_oid.get(dependency_id))
868                            .copied()
869                            .collect(),
870                    },
871                };
872                (commit_oid, dependency_ids)
873            })
874            .collect();
875        Ok(result)
876    }
877
878    fn update_dependencies(
879        &self,
880        commits: &CommitSet,
881        newly_created_commits: &CommitSet,
882    ) -> eyre::Result<std::result::Result<(), ExitCode>> {
883        // Make sure to update dependencies in topological order to prevent
884        // dependency cycles.
885        let commit_oids = self.dag.sort(commits)?;
886
887        let (effects, progress) = self.effects.start_operation(OperationType::UpdateCommits);
888
889        // Newly-created commits won't have been observed by the DAG, so add them in manually here.
890        let draft_commits = self.dag.query_draft_commits()?.union(newly_created_commits);
891
892        for commit_oid in commit_oids.into_iter().with_progress(progress) {
893            let id = match self.get_revision_id(commit_oid)? {
894                Some(id) => id,
895                None => {
896                    warn!(?commit_oid, "No Phabricator commit ID for latest commit");
897                    continue;
898                }
899            };
900            let commit = self.repo.find_commit_or_fail(commit_oid)?;
901            let parent_oids = commit.get_parent_oids();
902
903            let mut parent_revision_ids = Vec::new();
904            for parent_oid in parent_oids {
905                if !self.dag.set_contains(&draft_commits, parent_oid)? {
906                    // FIXME: this will exclude commits that used to be part of
907                    // the stack but have since landed.
908                    continue;
909                }
910                let parent_revision_id = match self.get_revision_id(parent_oid)? {
911                    Some(id) => id,
912                    None => continue,
913                };
914                parent_revision_ids.push(parent_revision_id);
915            }
916
917            let id_str = effects.get_glyphs().render(Self::render_id(&id))?;
918            if parent_revision_ids.is_empty() {
919                writeln!(
920                    effects.get_output_stream(),
921                    "Setting {id_str} as stack root (no dependencies)",
922                )?;
923            } else {
924                writeln!(
925                    effects.get_output_stream(),
926                    "Stacking {id_str} on top of {}",
927                    effects.get_glyphs().render(StyledStringBuilder::join(
928                        ", ",
929                        parent_revision_ids.iter().map(Self::render_id).collect()
930                    ))?,
931                )?;
932            }
933
934            match self.set_dependencies(id, parent_revision_ids)? {
935                Ok(()) => {}
936                Err(exit_code) => return Ok(Err(exit_code)),
937            }
938        }
939        Ok(Ok(()))
940    }
941
942    fn render_id(id: &Id) -> StyledString {
943        StyledStringBuilder::new()
944            .append_styled(id.to_string(), *STYLE_PUSHED)
945            .build()
946    }
947
948    fn set_dependencies(
949        &self,
950        id: Id,
951        parent_revision_ids: Vec<Id>,
952    ) -> eyre::Result<std::result::Result<(), ExitCode>> {
953        let effects = self.effects;
954
955        if should_mock() {
956            return Ok(Ok(()));
957        }
958
959        let revisions = self.query_revisions(&DifferentialQueryRequest {
960            ids: parent_revision_ids,
961            phids: Default::default(),
962        })?;
963        let parent_revision_phids: Vec<Phid> = revisions
964            .into_iter()
965            .map(|response| response.phid)
966            .collect();
967        let request = DifferentialEditRequest {
968            id,
969            transactions: vec![DifferentialEditTransaction {
970                r#type: "parents.set".to_string(),
971                value: parent_revision_phids,
972            }],
973        };
974
975        let args = vec![
976            "call-conduit".to_string(),
977            "--".to_string(),
978            "differential.revision.edit".to_string(),
979        ];
980        let mut child = Command::new("arc")
981            .args(&args)
982            .stdin(Stdio::piped())
983            .stdout(Stdio::piped())
984            .stderr(Stdio::inherit())
985            .spawn()
986            .map_err(|err| Error::InvokeArc {
987                source: err,
988                args: args.clone(),
989            })?;
990        serde_json::to_writer_pretty(child.stdin.take().unwrap(), &request).map_err(|err| {
991            Error::CommunicateWithArc {
992                source: err,
993                args: args.clone(),
994            }
995        })?;
996        let result = child.wait_with_output().map_err(|err| Error::InvokeArc {
997            source: err,
998            args: args.clone(),
999        })?;
1000        if !result.status.success() {
1001            let args = args.join(" ");
1002            let exit_code = ExitCode::try_from(result.status)?;
1003            let ExitCode(exit_code_isize) = exit_code;
1004            writeln!(
1005                effects.get_output_stream(),
1006                "Could not update dependencies when running `arc {args}` (exit code {exit_code_isize}):",
1007            )?;
1008            writeln!(
1009                effects.get_output_stream(),
1010                "{}",
1011                String::from_utf8_lossy(&result.stdout)
1012            )?;
1013            return Ok(Err(exit_code));
1014        }
1015
1016        Ok(Ok(()))
1017    }
1018
1019    /// Given a commit for D123, returns a string like "123" by parsing the
1020    /// commit message.
1021    pub fn get_revision_id(&self, commit_oid: NonZeroOid) -> Result<Option<Id>> {
1022        let commit =
1023            self.repo
1024                .find_commit_or_fail(commit_oid)
1025                .map_err(|err| Error::NoSuchCommit {
1026                    source: err,
1027                    commit_oid,
1028                })?;
1029        let message = commit.get_message_raw();
1030
1031        lazy_static! {
1032            static ref RE: Regex = Regex::new(
1033                r"(?mx)
1034^
1035Differential[\ ]Revision:[\ ]
1036    (.+ /)?
1037    D(?P<diff>[0-9]+)
1038$",
1039            )
1040            .expect("Failed to compile `extract_diff_number` regex");
1041        }
1042        let captures = match RE.captures(message.as_slice()) {
1043            Some(captures) => captures,
1044            None => return Ok(None),
1045        };
1046        let diff_number = &captures["diff"];
1047        let diff_number = String::from_utf8(diff_number.to_vec())
1048            .expect("Regex should have confirmed that this string was only ASCII digits");
1049        Ok(Some(Id(diff_number)))
1050    }
1051
1052    fn render_failed_test(
1053        &self,
1054        commit_oid: NonZeroOid,
1055        test_output: &TestOutput,
1056    ) -> eyre::Result<()> {
1057        let commit = self.repo.find_commit_or_fail(commit_oid)?;
1058        writeln!(
1059            self.effects.get_output_stream(),
1060            "{}",
1061            self.effects
1062                .get_glyphs()
1063                .render(test_output.test_status.describe(
1064                    self.effects.get_glyphs(),
1065                    &commit,
1066                    false
1067                )?)?,
1068        )?;
1069        let stdout = std::fs::read_to_string(&test_output.stdout_path)?;
1070        write!(self.effects.get_output_stream(), "Stdout:\n{stdout}")?;
1071        let stderr = std::fs::read_to_string(&test_output.stderr_path)?;
1072        write!(self.effects.get_output_stream(), "Stderr:\n{stderr}")?;
1073        Ok(())
1074    }
1075}