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