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}