1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
//! Recovering a preserved branch: verifying interrupted work and publishing it.
//!
//! `recover`, `publish-branch`, and `integrate` are three verbs, and which one a
//! branch belongs to is decided by its **provenance**, never by its name. A branch
//! carrying an unattested incomplete marker is interrupted work, and only this verb
//! may publish it — because publishing it means writing the attestation that says
//! the step that stopped was verified after all. A branch whose commits are all
//! complete belongs to one of the other two, and this verb hands it over by name
//! rather than only refusing.
//!
//! Everything around the attestation — locating the branch, cloning it, cutting a
//! worktree, merging the change base, publishing — is [`crate::branch`], which
//! `publish-branch` runs too.
use std::path::Path;
use serde_json::json;
use crate::boundary::TermScope;
use crate::branch::{self, Verb};
use crate::error::{Error, Result};
use crate::event::EventKind;
use crate::host::Hosting;
use crate::publish::{PublishOutcome, Subject};
use crate::registry::Registry;
use crate::rules::MergePolicy;
use crate::store;
use crate::stream::Stream;
use crate::workspace::object;
use crate::{guidance, provenance};
/// Verify and publish a preserved branch.
#[expect(
clippy::too_many_arguments,
reason = "the parameters are the verb's own operands, kept in step with publish_branch::run"
)]
pub fn run(
registry: &Registry,
repo: &Path,
branch: &str,
title: Option<Subject>,
body: Option<String>,
term_scope: &TermScope,
hosting: &dyn Hosting,
stream: &mut Stream,
) -> Result<PublishOutcome> {
let landing = branch::prepare(registry, Verb::Recover, repo, branch, None)?;
let unattested = landing.unattested()?;
if unattested.is_empty() {
return Err(Error::Invalid {
reason: nothing_to_recover(&landing)?,
});
}
if let Some(reason) = attests_nothing(&landing) {
return Err(Error::Invalid { reason });
}
// Before the attestation rather than inside the publication: a branch none of
// whose subjects fit is answered by `--title`, and an operator who has to pass
// one should meet that refusal on the branch as they left it.
landing.check_subject(title.as_ref())?;
// The attestation is written before the publication, so a rejected push leaves
// a branch whose marker is cleared by a verdict that was actually reached.
landing.sync_change_base(stream, hosting)?;
let attested = provenance::attest(
&landing.worktree,
&landing.compared_change_base,
&landing.trailers,
)?;
stream.emit(
EventKind::RecoveryAttested,
object(json!({
"branch": branch,
"markers": unattested,
"attestation": attested,
})),
);
landing.publish(title, body, term_scope, hosting, stream)
}
/// Why a branch with no unattested marker is not this verb's, and whose it is.
fn nothing_to_recover(landing: &branch::Landing) -> Result<String> {
let branch = &landing.branch;
if let Some(prefix) = landing.unrecognized()?.first() {
// Not "all of them are complete": they are markers this host cannot read,
// and the branch is interrupted work whatever wrote it.
return Ok(landing.unreadable_prefix(prefix));
}
if landing.ahead()?.is_empty() {
return Ok(format!(
"branch {branch:?} has nothing ahead of {}; there is no preserved work to recover. \
`onevcs recoverable` lists the branches that do carry unpublished work",
landing.change_base
));
}
Ok(format!(
"branch {branch:?} carries no unattested incomplete provenance: it has commits ahead of \
{}, and all of them are complete. `recover` publishes interrupted work; publish a \
completed branch with `{}`",
landing.change_base,
complete_branch_verb(landing),
))
}
/// The command that publishes a *complete* branch of this identity.
///
/// The merge train lands exactly the identities whose resolved publication policy is
/// `local-direct`, and this handoff is decided from that same policy — the one the
/// landing already resolved — so it names the train for precisely the identities the
/// train accepts and `publish-branch` for the rest. Deciding it from anything else
/// sends an operator to a verb that refuses them, and an operator refused twice
/// reaches for raw `git`.
fn complete_branch_verb(landing: &branch::Landing) -> String {
if landing.resolved.policy.publication == MergePolicy::LocalDirect {
crate::guidance::command(["onevcs", "integrate", &landing.branch])
} else {
landing.command_for(Verb::PublishBranch)
}
}
/// Why an attestation would attest nothing, when it would.
///
/// An identity that names no complete bar and whose merge path runs nothing has
/// nothing for a recovery to clear the marker *with*, so the refusal names both
/// ways to give it one rather than only stating that it has neither.
///
/// The second half is [`store::merge_path_coverage`] — the same question `onevcs
/// register` warns on and `onevcs repos --audit-gates` reports, so the guard, the
/// warning, and the audit cannot come to disagree about which identities are
/// covered. It detects coverage rather than taking a repository's word for it,
/// which is what lets a remote identity answer for the required checks that
/// actually rule on its change requests.
fn attests_nothing(landing: &branch::Landing) -> Option<String> {
if landing.resolution.identity.gate != store::NOOP_GATE
|| store::merge_path_coverage(
&landing.resolution,
&landing.source,
landing.resolved.policy.publication,
) != store::Coverage::None
{
return None;
}
Some(format!(
"identity {:?} names no complete bar and nothing on its merge path verifies one, so a \
recovery attestation would attest nothing. Give it one of the two: put an executable \
pre-push hook in {}, which is what judges a publishing push, or resolve this identity \
to a change-request policy in the rules file so a host's required checks judge its \
change requests. Confirm what covers it with `{}`, then re-run `{}`",
landing.resolution.key,
landing.source.display(),
guidance::command(["onevcs", "repos", "--audit-gates"]),
landing.command(),
))
}