Skip to main content

onevcs/
preserve.rs

1//! Putting an unpublished branch on its identity's origin, without publishing it.
2//!
3//! The verb a shutdown reaches for. Stopping work on a host tears every dispatch
4//! down: whatever a worker had not committed is lost, and whatever it *had*
5//! committed sits on a branch in a run clone on a machine that is about to go away.
6//! Asking a worker to commit is only half an answer — the commit has to reach
7//! somewhere that outlives the host — and this is the other half.
8//!
9//! **Nothing here is a publication, and every one of those absences is load-bearing.**
10//! No change request is opened, no merge path is run, no base branch is touched,
11//! nothing is force-pushed, no provenance marker is cleared and no attestation is
12//! written. A branch carrying an unattested incomplete-step marker is preserved
13//! exactly as it stands and still needs `onevcs recover` afterwards — which is why
14//! the verb is called *preserve*: the user wants the work **kept**, not merged, and a
15//! branch that quietly acquired a change request or a merge-path verdict nobody asked
16//! for would be a worse outcome than losing it.
17//!
18//! It reaches git and nothing else, so it takes no [`Hosting`](crate::Hosting) and
19//! adds no method to [`Vcs`](crate::Vcs) — a method there would break every outside
20//! implementor of that trait for an operation with no host in it. It sits beside
21//! `workspace_capacity` and `pool_maintain`, which are free functions for the same
22//! reason.
23
24use std::path::PathBuf;
25
26use serde::{Deserialize, Serialize};
27use serde_json::json;
28
29use crate::boundary::TermScope;
30use crate::error::{Error, Result};
31use crate::event::EventKind;
32use crate::git::{self, RemoteTip};
33use crate::registry::Registry;
34use crate::store::{self, Resolution};
35use crate::stream::Stream;
36use crate::workspace::{self, object};
37use crate::{branch, guidance};
38
39/// What to preserve, and where to look for it.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct PreserveRequest {
42    // llmlint: ignore-block[invalid_states_unrepresentable] both fields are what a caller
43    // *typed*, before this crate has decided what either names, and `docs/contract.md`
44    // spells them as they are — a request `onepipeline` builds from a shutdown's own
45    // arguments. `repo` is deliberately the widest of the four forms `publish-branch
46    // --repo` takes, which no type in this crate is the closed set of, and narrowing
47    // `branch` to `Ref` would move the refusal for an unusable name out of `run` — where
48    // it is a message naming the branch and the report that lists the branches there are
49    // — and into a conversion a caller would have to perform to ask the question.
50    /// The repository: an identity key, a registered alias, an origin URL, or a
51    /// path, read exactly as `publish-branch --repo` reads one.
52    pub repo: String,
53    /// The branch to put on that identity's origin under its own name.
54    pub branch: String,
55    // llmlint: ignore-end[invalid_states_unrepresentable]
56    /// Which private repositories the public boundary check derives its terms from,
57    /// should the origin be public. Unset is every registered private one.
58    #[serde(default, skip_serializing_if = "TermScope::is_registry")]
59    pub term_scope: TermScope,
60}
61
62/// What preserving one branch found to do.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "kebab-case")]
65pub enum Preservation {
66    /// The branch was pushed; the remote now carries
67    /// [`commit`](Preserved::commit) under that name.
68    Pushed,
69    /// The origin already had the branch at this commit. Nothing was pushed.
70    AlreadyOnOrigin,
71    /// The identity has no origin to push to. Nothing was attempted.
72    NoRemote,
73}
74
75/// The word [`Preservation::Pushed`] travels as in a `branch-preserved` payload.
76///
77/// The three words are spelled here rather than at the two ends, because `status` reads
78/// a recorded preservation's outcome from this field **alone** — the payload carries the
79/// same five keys whichever word it is — so a writer and a reader that spelled one
80/// differently would answer "this branch is nowhere" about a branch on its origin, and
81/// nothing about the payload's shape would show it.
82pub(crate) const PUSHED: &str = "pushed";
83/// The word [`Preservation::AlreadyOnOrigin`] travels as. See [`PUSHED`].
84pub(crate) const ALREADY_ON_ORIGIN: &str = "already-on-origin";
85/// The word [`Preservation::NoRemote`] travels as. See [`PUSHED`].
86pub(crate) const NO_REMOTE: &str = "no-remote";
87
88impl Preservation {
89    /// The word this outcome travels as in a `branch-preserved` payload.
90    ///
91    /// An exhaustive match rather than a serialization, for the reason
92    /// `EventKind::wire` is one: stamping an event cannot fail, and an outcome added
93    /// here cannot reach a stream unnamed.
94    fn wire(self) -> &'static str {
95        match self {
96            Preservation::Pushed => PUSHED,
97            Preservation::AlreadyOnOrigin => ALREADY_ON_ORIGIN,
98            Preservation::NoRemote => NO_REMOTE,
99        }
100    }
101}
102
103/// What preserving one branch did.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct Preserved {
106    // llmlint: ignore-block[invalid_states_unrepresentable] every field of this value is
107    // spelled by `docs/contract.md`, which two later consumers are written against, so
108    // none of them is this module's to tighten — and each is already held to what it is
109    // where that matters: the branch by `run`'s own `check-ref-format` refusal, the
110    // remote by `git::is_usable_remote` where a recorded preservation is read back, and
111    // the commit by `ObjectId::parse` there. What the `Option`s could otherwise represent
112    // is closed off where this value is produced: `run` reads the tip before it looks for
113    // an origin, so every one of its three answers carries the commit, and only the
114    // outcome with nowhere to have gone carries no remote.
115    /// The identity the branch belongs to.
116    pub identity: String,
117    /// The branch, under the name it already had: a preservation puts it on the origin
118    /// as it stands and never renames, prefixes or namespaces it, because the verbs
119    /// that land it afterwards look for it by this name.
120    pub branch: String,
121    /// The checkout, run clone or session worktree the branch was pushed from.
122    ///
123    /// One of the locations `workspace::checkouts_of` searches, which is the search
124    /// the landing verbs use: a registered checkout, or the run clone a live
125    /// session's worktree commits into.
126    pub from: PathBuf,
127    /// The origin URL it went to, and `None` only for
128    /// [`NoRemote`](Preservation::NoRemote) — the one outcome with nowhere to have
129    /// gone.
130    pub remote: Option<String>,
131    /// The commit the branch stands at, as this verb read it: the one that was
132    /// pushed, the one the origin already carried, or — for
133    /// [`NoRemote`](Preservation::NoRemote) — the one **nothing outside this host
134    /// carries**, which is the commit a shutdown most needs named.
135    ///
136    /// Filled for every outcome. It is an `Option` because the contract names the
137    /// field so, and a caller reads which outcome this was from
138    /// [`outcome`](Preserved::outcome) alone rather than from whether a field is
139    /// there.
140    pub commit: Option<String>,
141    /// Which of the three things this found to do.
142    pub outcome: Preservation,
143    // llmlint: ignore-end[invalid_states_unrepresentable]
144}
145
146/// Put one branch on its identity's origin under its own name.
147///
148/// The library form of `onevcs preserve`, and the whole of the verb: the command is a
149/// rendering of this value.
150pub fn run(registry: &Registry, request: &PreserveRequest) -> Result<Preserved> {
151    let resolution = store::resolve(registry, &request.repo)?;
152    let branch = &request.branch;
153    if !git::is_valid_branch_name(branch) {
154        return Err(Error::Invalid {
155            reason: format!(
156                "{branch:?} is not a valid branch name; `onevcs recoverable` lists every \
157                 preserved branch by name and the checkout it is in"
158            ),
159        });
160    }
161    // The identity's own root, named by git itself off the remote's advertised head —
162    // used only to compare the copies of the branch below, never pushed to and never
163    // merged with.
164    let base = git::default_branch(&resolution.publication, "origin")?;
165    // A worktree over the branch that is part way through a merge holds work the branch
166    // does not carry yet, and pushing the branch as it stands would put a copy on the
167    // origin that the merge's conclusion then has to be reconciled with.
168    branch::refuse_unfinished_merges(
169        registry,
170        &resolution,
171        branch,
172        &format!("preserve it with `{}`", command(&resolution, branch)),
173    )?;
174    // The one location search `recover`, `publish-branch`, `import` and `status` use,
175    // run clones included — so a branch a live dispatch committed to a moment ago is
176    // found, and a branch this verb can preserve is a branch those verbs can land.
177    let from = branch::locate(
178        registry,
179        &resolution,
180        branch,
181        &base,
182        &format!("preserve it with `{}`", command(&resolution, branch)),
183    )?;
184
185    let mut stream = open_stream(&resolution, branch)?;
186    // Read before the origin is looked for, because every outcome names it — the commit
187    // an identity with nowhere to push it most of all: that is the one nothing outside
188    // this host carries, and a shutdown report that did not name it would say a branch
189    // is at risk without saying which work is.
190    let reference = format!("refs/heads/{branch}");
191    let commit = git::tip(&from, &reference).ok_or_else(|| Error::Invalid {
192        reason: format!(
193            "branch {branch:?} resolves to no commit in {}, so there is nothing to \
194             preserve; `onevcs recoverable` lists every preserved branch and the checkout \
195             it is in",
196            from.display()
197        ),
198    })?;
199    // A location with no `origin` is the whole of what decides this. The identity's
200    // *policy* is never consulted: `local-direct` says how a change is published and
201    // says nothing about whether the repository has an origin — this host's own
202    // `ai-orchestrator` is `local-direct` over a `github.com` origin — so the question
203    // is only ever whether there is somewhere to push.
204    if !git::has_remote(&from, ORIGIN) {
205        return Ok(report(
206            &mut stream,
207            Preserved {
208                identity: resolution.key.clone(),
209                branch: branch.clone(),
210                from,
211                remote: None,
212                commit: Some(commit),
213                outcome: Preservation::NoRemote,
214            },
215        ));
216    }
217    // A run clone's `origin` is the identity's own origin URL — `git::clone_sharing`
218    // sets it — so this is the real origin rather than the checkout that lent the
219    // objects.
220    let remote = git::remote_url(&from, ORIGIN)?;
221    // Asked of the remote itself rather than of this repository's view of it: a
222    // remote-tracking ref is frozen at the last fetch, and a stale one would report a
223    // branch as already kept when the origin has never had it. A remote that could not
224    // be asked answers neither way, and the push below is what then decides — nothing
225    // here concludes anything from an answer nobody gave.
226    if let RemoteTip::At(advertised) = git::remote_tip(&from, ORIGIN, branch, &[])? {
227        if advertised.as_str() == commit {
228            return Ok(report(
229                &mut stream,
230                Preserved {
231                    identity: resolution.key.clone(),
232                    branch: branch.clone(),
233                    from,
234                    remote: Some(remote),
235                    commit: Some(commit),
236                    outcome: Preservation::AlreadyOnOrigin,
237                },
238            ));
239        }
240    }
241    // A preservation lands nothing, and it still puts the branch on the origin, where a
242    // public origin shows it to everybody: so it is held to the boundary like any other
243    // write there, against the identity's root.
244    let compared = format!("{ORIGIN}/{base}");
245    crate::boundary::evidence::guard_publication(
246        crate::Providers::real().hosting,
247        &resolution.key,
248        &crate::boundary::screen::Outgoing {
249            repo: &from,
250            base: git::tip(&from, &compared).map(|_| compared.as_str()),
251            tip: &reference,
252            branch: Some(branch),
253            title: None,
254            body: None,
255        },
256        &request.term_scope,
257    )?;
258    let pushed = git::push_preserving(&from, branch, ORIGIN)?;
259    if !pushed.accepted() {
260        return Err(refused(&resolution, branch, &remote, &pushed));
261    }
262    Ok(report(
263        &mut stream,
264        Preserved {
265            identity: resolution.key.clone(),
266            branch: branch.clone(),
267            from,
268            remote: Some(remote),
269            commit: Some(commit),
270            outcome: Preservation::Pushed,
271        },
272    ))
273}
274
275/// The remote a preservation pushes to, which is the only one it knows.
276const ORIGIN: &str = "origin";
277
278/// Record what this did, and hand the answer back.
279///
280/// One place, so the three outcomes cannot come to be recorded differently — and so
281/// the record exists for all three: "the identity has no origin" is exactly as much
282/// of an answer to *did this work reach somewhere that outlives the host* as "it was
283/// pushed" is, and a reader of the stream must be able to tell the two apart from a
284/// verb that ran.
285fn report(stream: &mut Stream, preserved: Preserved) -> Preserved {
286    // All five fields, for every outcome, and `remote` written as `null` where there was
287    // none rather than left out. The usual house rule is the opposite — an optional field
288    // of a *reported document* is omitted, so a consumer that never heard of it is never
289    // handed one — and this payload is deliberately the other thing: it is one kind with
290    // one shape, read by this crate's own two readers, and what those readers must never
291    // do is infer the outcome from which keys arrived. `outcome` is the whole of that
292    // answer, so the keys beside it stay the same three whichever word it carries, and a
293    // `no-remote` record says both "there is nowhere this went" and "this is the commit
294    // that is at risk".
295    let payload = object(json!({
296        "branch": preserved.branch,
297        "identity": preserved.identity,
298        "remote": preserved.remote,
299        "commit": preserved.commit,
300        "outcome": preserved.outcome.wire(),
301    }));
302    // Deliberately not `EventKind::Push`: the one producer of that kind is a
303    // publication, and a reader counting pushes to find publications must not meet a
304    // preservation.
305    stream.emit(EventKind::BranchPreserved, payload);
306    preserved
307}
308
309/// The stream a preservation of one branch is recorded on.
310///
311/// The branch's own session stream where a session record names it, so everything
312/// about that piece of work stays in one file — and a synthetic token otherwise,
313/// which is what `publish-branch-<slug>` and `recover-<slug>` already are. Either
314/// way the event carries the identity as a label and the branch in its payload, so
315/// `onevcs status` and `onevcs recoverable` find it whichever it was.
316fn open_stream(resolution: &Resolution, branch: &str) -> Result<Stream> {
317    let token = workspace::all()?
318        .into_iter()
319        .find(|record| record.identity == resolution.key && *record.branch == *branch)
320        .map(|record| record.token.to_string())
321        .unwrap_or_else(|| preserve_token(branch));
322    let mut stream = Stream::open(&token)?;
323    stream.label("identity", &resolution.key);
324    Ok(stream)
325}
326
327/// The synthetic stream token a preservation of a session-less branch is recorded
328/// under.
329///
330/// Read by `status::relevant_streams` under the same spelling, so the two cannot come
331/// to disagree about where a preservation of one branch was written.
332pub(crate) fn preserve_token(branch: &str) -> String {
333    crate::status::keyed::Verb::Preserve.stream(branch)
334}
335
336/// The invocation that preserves this branch again, quoted so that running it as
337/// printed runs it over the same two arguments.
338fn command(resolution: &Resolution, branch: &str) -> String {
339    let repo = resolution.publication.to_string_lossy();
340    guidance::command(["onevcs", "preserve", branch, "--repo", &repo])
341}
342
343/// A preserving push the origin declined: what git said about the one ref it was
344/// given, and what that leaves.
345///
346/// [`Error::PushRejected`] because that is what happened — git declined the push —
347/// and never `GateFailed`: no merge path ran, and none was asked to. The per-ref
348/// summary comes from `Pushed::refusal`, which digs it out of `--porcelain`'s own
349/// output, because `--porcelain` puts the ref status on stdout and git's usual `!
350/// [rejected] …` line then never reaches stderr. A failure that never reached a ref
351/// at all — no credential, no reachable remote — has no such summary, and git's last
352/// line is the whole answer.
353///
354/// It names the identity and the branch because the caller that meets this is
355/// preserving many branches at once on a host that is shutting down: it reports this
356/// one and carries on, and a refusal that did not say which branch it was about would
357/// leave nothing to report.
358fn refused(resolution: &Resolution, branch: &str, remote: &str, pushed: &git::Pushed) -> Error {
359    let summary = pushed
360        .refusal()
361        .unwrap_or_else(|| pushed.output().lines().next_back().unwrap_or("").trim());
362    Error::PushRejected {
363        reason: format!(
364            "{remote} declined branch {branch:?} of identity {key:?}: {summary}. Nothing was \
365             pushed and nothing was replaced — a preserving push is never forced, so a \
366             non-fast-forward is reported rather than resolved by discarding what the origin \
367             has. Reconcile the two in {from}, then run `{again}`",
368            key = resolution.key,
369            from = resolution.publication.display(),
370            again = command(resolution, branch),
371        ),
372    }
373}