Skip to main content

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