Skip to main content

release_kit/plan/
gather.rs

1//! The observation: everything the planner reads off the target, the
2//! host, and — where asked — the forge, gathered once and stamped in the
3//! evidence ledger.
4//!
5//! This is the I/O half of planning. It reads and never writes, and it
6//! answers with data the pure planner consumes: the record as bytes and
7//! as a parsed document, the configuration, every destination's bytes,
8//! the repository's facts, the pin, and the forge's trunk tip where the
9//! read was opted into.
10
11use std::collections::BTreeMap;
12use std::process::Command;
13
14use camino::Utf8Path;
15
16use crate::config::Config;
17use crate::diagnostic::{Diagnostic, Reason};
18use crate::digest::Digest;
19use crate::error::RkError;
20use crate::landing::manifest::{self, Manifest, Style, Workflow};
21use crate::landing::{self, Params};
22use crate::release::ReleaseSource;
23
24use super::PinState;
25use super::classify::RepositoryFacts;
26use super::evidence::{EvidenceKind, Ledger};
27
28/// The landing record, as read.
29#[derive(Debug)]
30pub enum RecordRead {
31    /// No record at the target.
32    Absent,
33    /// A record this engine read.
34    Present {
35        /// The parsed record.
36        manifest: Box<Manifest>,
37        /// Its bytes, as found.
38        bytes: Vec<u8>,
39    },
40    /// A record this engine could not read.
41    Invalid {
42        /// Why.
43        reason: String,
44    },
45}
46
47/// The committed configuration, as read.
48#[derive(Debug)]
49pub enum ConfigRead {
50    /// No configuration at the target.
51    Absent,
52    /// A configuration this engine read.
53    Present {
54        /// The parsed configuration.
55        config: Box<Config>,
56        /// Its bytes, as found.
57        bytes: Vec<u8>,
58    },
59    /// A configuration this engine could not read.
60    Invalid {
61        /// Why.
62        reason: String,
63        /// Its bytes, as found.
64        bytes: Vec<u8>,
65    },
66}
67
68/// What the forge said.
69#[derive(Debug)]
70pub enum ForgeRead {
71    /// The forge was not asked.
72    NotObserved {
73        /// Why.
74        reason: String,
75    },
76    /// The forge was asked about the trunk.
77    Observed {
78        /// The trunk asked about.
79        trunk: String,
80        /// The trunk's tip at the remote, where it has one.
81        remote_tip: Option<String>,
82        /// The forge's version, where the forge reports one and the read
83        /// asked for it.
84        version: Option<String>,
85    },
86}
87
88/// The generator the binding's committed artifact needs, as found on the
89/// host.
90#[derive(Debug, Clone)]
91pub struct GeneratorRead {
92    /// The tool, as `versions.toml` names it.
93    pub name: String,
94    /// The version the host reports, where the tool answers.
95    pub host: Option<String>,
96}
97
98/// The evidence ids each section of the observation cites.
99#[derive(Debug, Default)]
100pub struct Refs {
101    /// The repository facts.
102    pub repository: Vec<String>,
103    /// The record.
104    pub record: Option<String>,
105    /// The configuration.
106    pub configuration: Option<String>,
107    /// Each destination read, by path.
108    pub destinations: BTreeMap<String, String>,
109    /// The host.
110    pub host: Vec<String>,
111    /// The pin.
112    pub pin: Option<String>,
113    /// The forge.
114    pub forge: Vec<String>,
115}
116
117/// Everything observed, as data.
118#[derive(Debug)]
119pub struct Observation {
120    /// The target, as given.
121    pub target: String,
122    /// Whether the target is a git repository.
123    pub git: bool,
124    /// The technology the version file names.
125    pub tech: Option<String>,
126    /// The forge the origin remote maps to.
127    pub forge_name: Option<String>,
128    /// The project path from the origin remote.
129    pub repo: Option<String>,
130    /// The facts the verdict reads.
131    pub facts: RepositoryFacts,
132    /// The record.
133    pub record: RecordRead,
134    /// The configuration.
135    pub config: ConfigRead,
136    /// Every destination present, by path, as bytes: the whole file, or
137    /// the marked block for the two block destinations.
138    pub files: BTreeMap<String, Vec<u8>>,
139    /// The hook file's defect, where it has one.
140    pub hooks_defect: Option<String>,
141    /// The pin the wired manager records.
142    pub pin: Option<PinState>,
143    /// The managers whose file is present and names no release-kit.
144    pub unwired_managers: Vec<String>,
145    /// The generator on the host, where the technology has one and the
146    /// host was asked.
147    pub generator: Option<GeneratorRead>,
148    /// The forge.
149    pub forge: ForgeRead,
150    /// The ledger, stamped as each fact was read.
151    pub ledger: Ledger,
152    /// The ids the sections cite.
153    pub refs: Refs,
154}
155
156/// The landing parameters, resolved or not, with what withheld the Nix
157/// destinations where the target cannot take them.
158#[derive(Debug)]
159pub struct Resolution {
160    /// The parameters, where they resolved.
161    pub params: Option<Params>,
162    /// Which layer answered each parameter.
163    pub sources: BTreeMap<String, String>,
164    /// Why they did not resolve, where they did not.
165    pub unresolved: Option<String>,
166    /// The Nix destinations withheld, with the one reason.
167    pub nix_withheld: Option<(Vec<String>, String)>,
168}
169
170/// The explicit answers a request carries.
171#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
172pub struct Flags {
173    /// Binding override.
174    pub tech: Option<String>,
175    /// Forge override.
176    pub forge: Option<String>,
177    /// Repository override.
178    pub repo: Option<String>,
179    /// Workflow override.
180    pub workflow: Option<String>,
181    /// Release style override.
182    pub style: Option<String>,
183    /// Nix capability override.
184    pub nix: Option<bool>,
185}
186
187/// One request to observe a target.
188pub struct Request<'a> {
189    /// The target.
190    pub target: &'a Utf8Path,
191    /// The explicit answers.
192    pub flags: &'a Flags,
193    /// The decisions selected, by id.
194    pub decisions: &'a BTreeMap<String, String>,
195    /// Whether to read the forge.
196    pub observe_forge: bool,
197    /// The instant every evidence item is stamped with.
198    pub clock: &'a str,
199    /// The candidate source, for the reads that validate against it.
200    pub source: &'a dyn ReleaseSource,
201    /// Paths beyond the landing's destinations whose presence the plan
202    /// reads: the ones the candidate's guidance names.
203    pub extra_paths: &'a [String],
204}
205
206/// Read the target.
207///
208/// # Errors
209///
210/// Returns [`RkError::Missing`] for a target that is not a directory,
211/// the assessment's own failures where git cannot answer for a
212/// repository, and [`RkError::Io`] for a read that fails for a reason
213/// other than absence. A record or a configuration that does not read
214/// is an observation, not a failure.
215pub fn observe(request: &Request<'_>) -> Result<Observation, RkError> {
216    let target = request.target;
217    if !target.is_dir() {
218        return Err(RkError::missing(
219            Diagnostic::new(
220                Reason::TargetNotFound,
221                format!("target {target} is not a directory"),
222            )
223            .expected("an existing repository to plan for"),
224        ));
225    }
226    let clock = request.clock;
227    let mut ledger = Ledger::new();
228    let mut refs = Refs::default();
229    let record = read_record(target, clock, &mut ledger, &mut refs)?;
230    let config = read_config(target, clock, &mut ledger, &mut refs)?;
231    let facts = crate::assess::gather_facts(target)?;
232    refs.repository.push(ledger.observe(
233        "repository",
234        EvidenceKind::Repository,
235        "rk",
236        clock,
237        None,
238        "marker scan, payload destinations, git tag --list, git for-each-ref, version file",
239    ));
240    let files = read_destinations(
241        target,
242        &record,
243        request.extra_paths,
244        clock,
245        &mut ledger,
246        &mut refs,
247    )?;
248    let hooks_defect = landing::hooks_file_defect(request.source, target)?;
249    refs.host.push(ledger.observe(
250        "host",
251        EvidenceKind::Host,
252        "rk",
253        clock,
254        None,
255        "the engine's own version",
256    ));
257    let (pin, unwired_managers) = read_pin(target, clock, &mut ledger, &mut refs);
258    let forge = if request.observe_forge {
259        let trunk = crate::config::trunk_of(target.as_std_path())?;
260        let remote_tip = remote_tip(target, &trunk)?;
261        refs.forge.push(ledger.observe(
262            "forge:trunk",
263            EvidenceKind::Forge,
264            "git",
265            clock,
266            None,
267            format!("git ls-remote --heads origin {trunk}"),
268        ));
269        ForgeRead::Observed {
270            trunk,
271            remote_tip,
272            version: None,
273        }
274    } else {
275        ForgeRead::NotObserved {
276            reason: "the forge read was not requested; --observe forge opts in".into(),
277        }
278    };
279    Ok(Observation {
280        target: target.to_string(),
281        git: facts.git,
282        tech: facts.tech.map(str::to_owned),
283        forge_name: facts.forge.map(str::to_owned),
284        repo: facts.repo.clone(),
285        facts: RepositoryFacts {
286            release_markers: facts.release_markers,
287            collisions: facts.collisions,
288            tags: facts.tags,
289            long_lived_branches: facts.long_lived_branches,
290        },
291        record,
292        config,
293        files,
294        hooks_defect,
295        pin,
296        unwired_managers,
297        generator: None,
298        forge,
299        ledger,
300        refs,
301    })
302}
303
304/// Read the host tools the resolved parameters make relevant.
305///
306/// Each read is stamped: the binding's generator, and the forge's version
307/// where the forge was already asked and is one that reports a floor.
308/// This runs after the resolution, because which tool matters depends on
309/// it.
310pub fn observe_host_tools(
311    observation: &mut Observation,
312    tech: Option<&str>,
313    forge: Option<&str>,
314    clock: &str,
315) {
316    if let Some((name, _)) = tech.and_then(super::compatibility::generator_for) {
317        let host = generator_version(name);
318        observation.refs.host.push(observation.ledger.observe(
319            format!("host:{name}"),
320            EvidenceKind::Host,
321            name,
322            clock,
323            None,
324            format!("{} --version", generator_bin(name)),
325        ));
326        observation.generator = Some(GeneratorRead {
327            name: name.to_owned(),
328            host,
329        });
330    }
331    if let (Some("gitlab"), ForgeRead::Observed { version, .. }) = (forge, &mut observation.forge) {
332        *version = gitlab_version();
333        observation.refs.forge.push(observation.ledger.observe(
334            "forge:version",
335            EvidenceKind::Forge,
336            "glab",
337            clock,
338            None,
339            "glab api version",
340        ));
341    }
342}
343
344/// The binary a generator runs as, honoring the override that keeps the
345/// tests hermetic: `RK_DIST_BIN` for cargo-dist.
346fn generator_bin(name: &str) -> String {
347    match name {
348        "cargo-dist" => std::env::var("RK_DIST_BIN").unwrap_or_else(|_| "dist".to_owned()),
349        other => other.to_owned(),
350    }
351}
352
353/// The generator's version as the host reports it, or `None` where the
354/// tool is absent or answers nothing readable.
355fn generator_version(name: &str) -> Option<String> {
356    let out = Command::new(generator_bin(name))
357        .arg("--version")
358        .output()
359        .ok()?;
360    if !out.status.success() {
361        return None;
362    }
363    let text = String::from_utf8_lossy(&out.stdout);
364    text.split_whitespace()
365        .find(|word| crate::release::declared::version_key(word).is_some())
366        .map(|word| word.trim_start_matches('v').to_owned())
367}
368
369/// The GitLab instance's version through the forge CLI's read-only
370/// `GET /version`, or `None` where nothing readable comes back.
371fn gitlab_version() -> Option<String> {
372    let out = Command::new(crate::probes::forge_bin(crate::detect::Forge::Gitlab))
373        .args(["api", "version"])
374        .output()
375        .ok()?;
376    if !out.status.success() {
377        return None;
378    }
379    let body: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
380    body["version"].as_str().map(str::to_owned)
381}
382
383/// The record, as bytes and as a document, stamped.
384fn read_record(
385    target: &Utf8Path,
386    clock: &str,
387    ledger: &mut Ledger,
388    refs: &mut Refs,
389) -> Result<RecordRead, RkError> {
390    let bytes = read_optional(&target.join(manifest::MANIFEST_PATH))?;
391    let record = match (&bytes, manifest::load(target)) {
392        (None, _) => RecordRead::Absent,
393        (Some(bytes), Ok(Some(manifest))) => RecordRead::Present {
394            manifest: Box::new(manifest),
395            bytes: bytes.clone(),
396        },
397        (Some(_), Ok(None)) => RecordRead::Invalid {
398            reason: "the record vanished between two reads".into(),
399        },
400        (Some(_), Err(error)) => RecordRead::Invalid {
401            reason: error.to_string(),
402        },
403    };
404    refs.record = Some(ledger.observe(
405        "record",
406        EvidenceKind::Record,
407        "rk",
408        clock,
409        bytes.as_deref().map(Digest::of),
410        format!("read {}", manifest::MANIFEST_PATH),
411    ));
412    Ok(record)
413}
414
415/// The configuration, as bytes and as a document, stamped.
416fn read_config(
417    target: &Utf8Path,
418    clock: &str,
419    ledger: &mut Ledger,
420    refs: &mut Refs,
421) -> Result<ConfigRead, RkError> {
422    let bytes = read_optional(&target.join(crate::config::CONFIG_PATH))?;
423    let config = match (&bytes, crate::config::load(target.as_std_path())) {
424        (None, _) => ConfigRead::Absent,
425        (Some(bytes), Ok(Some(config))) => ConfigRead::Present {
426            config: Box::new(config),
427            bytes: bytes.clone(),
428        },
429        (Some(bytes), Ok(None)) => ConfigRead::Invalid {
430            reason: "the configuration vanished between two reads".into(),
431            bytes: bytes.clone(),
432        },
433        (Some(bytes), Err(error)) => ConfigRead::Invalid {
434            reason: error.to_string(),
435            bytes: bytes.clone(),
436        },
437    };
438    refs.configuration = Some(ledger.observe(
439        "configuration",
440        EvidenceKind::Configuration,
441        "rk",
442        clock,
443        bytes.as_deref().map(Digest::of),
444        format!("read {}", crate::config::CONFIG_PATH),
445    ));
446    Ok(config)
447}
448
449/// Every destination the payload can land, and every one the record
450/// names, as bytes, each stamped.
451fn read_destinations(
452    target: &Utf8Path,
453    record: &RecordRead,
454    extra_paths: &[String],
455    clock: &str,
456    ledger: &mut Ledger,
457    refs: &mut Refs,
458) -> Result<BTreeMap<String, Vec<u8>>, RkError> {
459    let mut paths: Vec<String> = landing::destinations().map(str::to_owned).collect();
460    if let RecordRead::Present { manifest, .. } = record {
461        paths.extend(manifest.files.iter().map(|file| file.destination.clone()));
462    }
463    paths.extend(extra_paths.iter().cloned());
464    paths.sort();
465    paths.dedup();
466    let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
467    for path in paths {
468        let bytes = landing::read_recorded(target, &path)?;
469        let id = ledger.observe(
470            format!("destination:{path}"),
471            EvidenceKind::Destination,
472            "rk",
473            clock,
474            bytes.as_deref().map(Digest::of),
475            if landing::block_markers(&path).is_some() {
476                "read the marked block"
477            } else {
478                "read the file"
479            },
480        );
481        refs.destinations.insert(path.clone(), id);
482        if let Some(bytes) = bytes {
483            files.insert(path, bytes);
484        }
485    }
486    Ok(files)
487}
488
489/// The pin the wired manager records, where exactly one names
490/// release-kit, stamped, beside the managers whose file is present and
491/// names none.
492fn read_pin(
493    target: &Utf8Path,
494    clock: &str,
495    ledger: &mut Ledger,
496    refs: &mut Refs,
497) -> (Option<PinState>, Vec<String>) {
498    let Ok(observed) = crate::self_depend::observe(target) else {
499        return (None, Vec::new());
500    };
501    let unwired: Vec<String> = observed
502        .managers
503        .iter()
504        .filter(|entry| {
505            entry.present == crate::self_depend::Presence::Present && entry.pin == "unpinned"
506        })
507        .map(|entry| entry.manager.as_str().to_owned())
508        .collect();
509    let pin = observed.wired.and_then(|manager| {
510        let entry = observed.entry(manager)?;
511        Some(PinState {
512            manager: manager.as_str().to_owned(),
513            file: entry.file.clone()?,
514            version: entry.version.clone()?,
515        })
516    });
517    if let Some(pin) = &pin {
518        refs.pin = Some(ledger.observe(
519            "pin",
520            EvidenceKind::Pin,
521            "rk self-depend",
522            clock,
523            files_digest(target, &pin.file),
524            format!("read {}", pin.file),
525        ));
526    }
527    (pin, unwired)
528}
529
530/// Resolve the landing parameters over the flags, the decisions, the
531/// configuration, and the record, and say which layer answered each.
532///
533/// A decision the operator selected for the workflow mode or the release
534/// style answers as a flag would. The resolution never refuses: an
535/// unresolved identity is a reason the planner turns into a blocked
536/// precondition.
537///
538/// # Errors
539///
540/// Returns [`RkError::Usage`] for a flag value outside its grammar, and
541/// the Nix shape read's failures other than absence.
542pub fn resolve(request: &Request<'_>, observation: &Observation) -> Result<Resolution, RkError> {
543    let flags = request.flags;
544    let workflow_flag = flags
545        .workflow
546        .clone()
547        .or_else(|| request.decisions.get("workflow-mode").cloned());
548    let style_flag = flags
549        .style
550        .clone()
551        .or_else(|| request.decisions.get("release-style").cloned());
552    let inputs = landing::Inputs {
553        tech: flags.tech.as_deref(),
554        forge: flags.forge.as_deref(),
555        repo: flags.repo.as_deref(),
556        workflow: workflow_flag.as_deref().map(Workflow::parse).transpose()?,
557        style: style_flag.as_deref().map(Style::parse).transpose()?,
558        nix: flags.nix,
559    };
560    let config: Option<&Config> = match &observation.config {
561        ConfigRead::Present { config, .. } => Some(config),
562        ConfigRead::Absent | ConfigRead::Invalid { .. } => None,
563    };
564    let record: Option<&Manifest> = match &observation.record {
565        RecordRead::Present { manifest, .. } => Some(manifest),
566        RecordRead::Absent | RecordRead::Invalid { .. } => None,
567    };
568    let sources = sources(
569        &inputs,
570        flags,
571        request.decisions,
572        config,
573        record,
574        observation,
575    );
576    let resolved = Params::resolve(
577        request.source,
578        request.target,
579        &inputs,
580        config,
581        record,
582        landing::Purpose::Preview,
583    );
584    let (params, unresolved) = match resolved {
585        Ok(params) => (Some(params), None),
586        Err(error) => (None, Some(error.to_string())),
587    };
588    let nix_withheld = match &params {
589        Some(params) if params.nix() => landing::nix_withholding(request.target, record)?
590            .map(|(set, reason)| (set.iter().map(|path| (*path).to_owned()).collect(), reason)),
591        _ => None,
592    };
593    Ok(Resolution {
594        params,
595        sources,
596        unresolved,
597        nix_withheld,
598    })
599}
600
601/// Which layer answered each parameter: `flag`, `decision`,
602/// `configuration`, `record`, `detected`, or `default`.
603fn sources(
604    inputs: &landing::Inputs<'_>,
605    flags: &Flags,
606    decisions: &BTreeMap<String, String>,
607    config: Option<&Config>,
608    record: Option<&Manifest>,
609    observation: &Observation,
610) -> BTreeMap<String, String> {
611    let mut sources = BTreeMap::new();
612    let layer = |flag: bool, configured: bool, recorded: bool, detected: bool| {
613        if flag {
614            "flag"
615        } else if configured {
616            "configuration"
617        } else if recorded {
618            "record"
619        } else if detected {
620            "detected"
621        } else {
622            "default"
623        }
624    };
625    identity_sources(&mut sources, inputs, config, record, observation, layer);
626    let decided = |id: &str, flag: bool, configured: bool, recorded: bool| {
627        if flag {
628            "flag"
629        } else if decisions.contains_key(id) {
630            "decision"
631        } else if configured {
632            "configuration"
633        } else if recorded {
634            "record"
635        } else {
636            "default"
637        }
638    };
639    sources.insert(
640        "workflow".to_owned(),
641        decided(
642            "workflow-mode",
643            flags.workflow.is_some(),
644            config.is_some_and(|c| c.landing.workflow.is_some()),
645            record.is_some(),
646        )
647        .to_owned(),
648    );
649    sources.insert(
650        "style".to_owned(),
651        decided(
652            "release-style",
653            flags.style.is_some(),
654            config.is_some_and(|c| c.landing.style.is_some()),
655            record.is_some_and(|r| r.parameters.style.is_some()),
656        )
657        .to_owned(),
658    );
659    sources.insert(
660        "nix".to_owned(),
661        layer(
662            inputs.nix.is_some(),
663            config.is_some_and(|c| c.landing.nix.is_some()),
664            record.is_some(),
665            false,
666        )
667        .to_owned(),
668    );
669    for (key, configured) in [
670        ("trunk", config.is_some_and(|c| c.project.trunk.is_some())),
671        (
672            "line_prefix",
673            config.is_some_and(|c| c.setup.line_prefix.is_some()),
674        ),
675        (
676            "security_contact",
677            config.is_some_and(|c| c.security.contact.is_some()),
678        ),
679        (
680            "security_response",
681            config.is_some_and(|c| c.security.response.is_some()),
682        ),
683    ] {
684        sources.insert(
685            key.to_owned(),
686            layer(false, configured, record.is_some(), false).to_owned(),
687        );
688    }
689    sources
690}
691
692/// The three identity parameters: flag, configuration, record, or the
693/// detection.
694fn identity_sources(
695    sources: &mut BTreeMap<String, String>,
696    inputs: &landing::Inputs<'_>,
697    config: Option<&Config>,
698    record: Option<&Manifest>,
699    observation: &Observation,
700    layer: impl Fn(bool, bool, bool, bool) -> &'static str,
701) {
702    sources.insert(
703        "tech".to_owned(),
704        layer(
705            inputs.tech.is_some(),
706            config.is_some_and(|c| !c.project.tech.is_empty()),
707            record.is_some(),
708            observation.tech.is_some(),
709        )
710        .to_owned(),
711    );
712    sources.insert(
713        "forge".to_owned(),
714        layer(
715            inputs.forge.is_some(),
716            config.is_some_and(|c| !c.project.forge.is_empty()),
717            record.is_some(),
718            observation.forge_name.is_some(),
719        )
720        .to_owned(),
721    );
722    sources.insert(
723        "repo".to_owned(),
724        layer(
725            inputs.repo.is_some(),
726            config.is_some_and(|c| !c.project.repo.is_empty()),
727            record.is_some(),
728            observation.repo.is_some(),
729        )
730        .to_owned(),
731    );
732}
733
734/// The bytes at `path`, or `None` where nothing is there.
735fn read_optional(path: &Utf8Path) -> Result<Option<Vec<u8>>, RkError> {
736    match std::fs::read(path) {
737        Ok(bytes) => Ok(Some(bytes)),
738        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
739        Err(error) => Err(RkError::Io(error)),
740    }
741}
742
743/// The digest of a file under the target, where it reads.
744fn files_digest(target: &Utf8Path, rel: &str) -> Option<Digest> {
745    std::fs::read(target.join(rel))
746        .ok()
747        .map(|bytes| Digest::of(&bytes))
748}
749
750/// The trunk's tip at `origin`, through one read-only git call.
751///
752/// A remote without the branch answers `None`; a remote that cannot be
753/// reached is a failure, because a forge asked and not answering is not
754/// an observation.
755fn remote_tip(target: &Utf8Path, trunk: &str) -> Result<Option<String>, RkError> {
756    let mut command = Command::new(crate::probes::git_bin());
757    for var in crate::maintenance::GIT_HOOK_VARS {
758        command.env_remove(var);
759    }
760    let out = command
761        .arg("-C")
762        .arg(target)
763        .args(["ls-remote", "--heads", "origin", trunk])
764        .output()
765        .map_err(|error| {
766            RkError::subprocess(
767                Diagnostic::new(
768                    Reason::SubprocessSpawn,
769                    format!("git could not be spawned: {error}"),
770                )
771                .expected("git on PATH, or RK_GIT_BIN naming it"),
772            )
773        })?;
774    if !out.status.success() {
775        return Err(RkError::subprocess(
776            Diagnostic::new(
777                Reason::ForgeTemporary,
778                format!(
779                    "git ls-remote could not read origin: {}",
780                    String::from_utf8_lossy(&out.stderr).trim()
781                ),
782            )
783            .expected("a reachable origin remote, or a plan without --observe forge"),
784        ));
785    }
786    Ok(String::from_utf8_lossy(&out.stdout)
787        .lines()
788        .find_map(|line| line.split_whitespace().next().map(str::to_owned)))
789}