1use std::path::{Path, PathBuf};
42
43use serde_json::json;
44
45use crate::boundary::TermScope;
46use crate::error::{Error, Result};
47use crate::event::EventKind;
48use crate::rules::MergePolicy;
49use crate::store::{self, Resolution};
50use crate::stream::Stream;
51use crate::workspace::{object, Ref};
52use crate::{git, guidance, home, ids, lock, merge_path, policy, provenance, publish, queue};
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum Status {
57 Merged,
59 AlreadyMerged,
61 Skipped(String),
64}
65
66impl Status {
67 pub fn describe(&self) -> String {
69 match self {
70 Status::Merged => "merged".to_owned(),
71 Status::AlreadyMerged => "already-merged".to_owned(),
72 Status::Skipped(reason) => format!("skipped ({reason})"),
73 }
74 }
75}
76
77#[derive(Debug, Clone)]
79pub struct BranchOutcome {
80 pub branch: Ref,
82 pub status: Status,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum Ending {
93 Unchanged,
95 Advanced,
97 AdvancedAndPushed,
99}
100
101impl Ending {
102 pub fn advanced(self) -> bool {
104 self != Ending::Unchanged
105 }
106
107 pub fn pushed(self) -> bool {
109 self == Ending::AdvancedAndPushed
110 }
111}
112
113#[derive(Debug, Clone)]
115pub struct Outcome {
116 pub base: Ref,
118 pub branches: Vec<BranchOutcome>,
120 pub ending: Ending,
122}
123
124pub fn run(
126 resolution: &Resolution,
127 candidates: &[String],
128 push: bool,
129 term_scope: &TermScope,
130 stream: &mut Stream,
131) -> Result<Outcome> {
132 let registry = store::load()?;
137 let (file, source) = policy::load(®istry)?;
138 let resolved = policy::resolve_for(&file, &source, resolution);
139 let publication = resolved.policy.publication;
140 if publication != MergePolicy::LocalDirect {
141 return Err(Error::Invalid {
142 reason: format!(
143 "direct integration is refused for identity {:?}: its publication policy \
144 resolves to {} (from {}; rules: {}), and the train lands only an identity \
145 whose policy is local-direct. Set publication: local-direct for it there to \
146 land locally, or publish each branch through its change-request path: {}",
147 resolution.key,
148 policy::spell(publication),
149 resolved.publication_from,
150 resolved.source,
151 change_request_route(resolution, candidates),
152 ),
153 });
154 }
155 let root = &resolution.publication;
156 if store::merge_path_coverage(resolution, root, publication) == store::Coverage::None {
161 eprintln!(
162 "onevcs: warning: nothing on this identity's merge path runs a gate, so what this \
163 train lands is unproven. Install an executable pre-push hook in {}, or confirm \
164 what covers it with `{}`",
165 root.display(),
166 guidance::command(["onevcs", "repos", "--audit-gates"]),
167 );
168 }
169 let base = git::current_branch(root)?;
170 if git::is_dirty(root)? {
171 return Err(Error::Invalid {
172 reason: format!(
173 "the base worktree {} is dirty; the train advances that base and will not \
174 build on work nobody recorded. Commit or stash what it holds, then re-run `{}`",
175 root.display(),
176 train_command(candidates, push),
177 ),
178 });
179 }
180 for branch in candidates {
181 if !git::is_valid_branch_name(branch) {
182 return Err(Error::Invalid {
183 reason: format!(
184 "{branch:?} is not a valid branch name; `onevcs recoverable` lists every \
185 preserved branch by name and the checkout it is in"
186 ),
187 });
188 }
189 if branch == &base {
190 return Err(Error::Invalid {
191 reason: format!(
192 "the base branch {base:?} cannot also be a candidate; re-run `onevcs \
193 integrate` naming only the branches to land on it"
194 ),
195 });
196 }
197 if !git::branch_exists(root, branch) {
198 return Err(Error::Invalid {
199 reason: format!(
200 "{root:?} has no local branch {branch:?}; `onevcs recoverable` lists this \
201 identity's unpublished branches and the checkout each is in",
202 root = root.display()
203 ),
204 });
205 }
206 }
207 if candidates
208 .iter()
209 .collect::<std::collections::BTreeSet<_>>()
210 .len()
211 != candidates.len()
212 {
213 return Err(Error::Invalid {
214 reason: "a branch is offered to the train twice; re-run `onevcs integrate` naming \
215 each branch once, in the order they should land"
216 .to_owned(),
217 });
218 }
219
220 let identity = lock::git_identity(&git::common_dir(root)?);
221 let turn = queue::turn(&identity, &crate::publish::NeverCancelled)?;
222 stream.emit(
223 EventKind::LockWait,
224 object(json!({
225 "identity": identity,
226 "elapsed": turn.waited.as_secs_f64(),
227 "queue_position": turn.position,
228 })),
229 );
230 stream.emit(
231 EventKind::LockAcquired,
232 object(json!({"identity": identity})),
233 );
234
235 let trailers = provenance::from_rules(&file);
236 let outcome = train(
237 resolution, &base, candidates, push, &trailers, term_scope, stream,
238 );
239 drop(turn);
240 outcome
241}
242
243fn change_request_route(resolution: &Resolution, candidates: &[String]) -> String {
251 let repo = resolution.publication.to_string_lossy();
252 candidates
253 .iter()
254 .map(|branch| {
255 format!(
256 "`{}`",
257 guidance::command(["onevcs", "publish-branch", branch, "--repo", &repo])
258 )
259 })
260 .collect::<Vec<_>>()
261 .join(", ")
262}
263
264fn train_command(candidates: &[String], push: bool) -> String {
269 let mut argv: Vec<&str> = vec!["onevcs", "integrate"];
270 argv.extend(candidates.iter().map(String::as_str));
271 if push {
272 argv.push("--push");
273 }
274 guidance::command(argv)
275}
276
277fn train(
278 resolution: &Resolution,
279 base: &str,
280 candidates: &[String],
281 push: bool,
282 trailers: &provenance::Trailers,
283 term_scope: &TermScope,
284 stream: &mut Stream,
285) -> Result<Outcome> {
286 let root = &resolution.publication;
287 let has_remote = git::has_remote(root, "origin");
288 if has_remote {
289 git::fetch(root, "origin")?;
290 stream.emit(
291 EventKind::Fetch,
292 object(json!({"remote": "origin", "checkout": root.display().to_string()})),
293 );
294 }
295 let remote_base = crate::vcs::base_ref(root, base);
296 let environment = merge_path::comparison_env("origin", base);
297
298 let initial = git::head_sha(root)?;
299 let workspace = home::workspaces_dir()?
300 .join("integrations")
301 .join(ids::unique());
302 home::ensure_dir(&workspace)?;
303
304 let train = Train {
305 resolution,
306 base,
307 remote_base: &remote_base,
308 workspace: &workspace,
309 trailers,
310 };
311 let mut branches = Vec::new();
312 for branch in candidates {
313 branches.push(one(&train, branch)?);
314 }
315
316 let mut ending = if git::head_sha(root)? == initial {
317 Ending::Unchanged
318 } else {
319 Ending::Advanced
320 };
321 if push && ending.advanced() {
322 if !has_remote {
323 return Err(Error::Invalid {
324 reason: format!(
325 "{} has no origin to push to; the base advanced locally, so re-run \
326 `onevcs integrate` without --push, or give the checkout an origin",
327 root.display()
328 ),
329 });
330 }
331 let compared = format!("origin/{base}");
334 crate::boundary::evidence::guard_publication(
335 crate::Providers::real().hosting,
336 &resolution.key,
337 &crate::boundary::screen::Outgoing {
338 repo: root,
339 base: git::tip(root, &compared).map(|_| compared.as_str()),
340 tip: base,
341 branch: Some(base),
342 title: None,
343 body: None,
344 },
345 term_scope,
346 )?;
347 stream.begin_attempt();
350 let hooked = crate::gate_run::runs_pre_push(root);
351 let started = crate::gate_run::Moment::now();
352 let result = git::push(root, base, "origin", &environment)?;
353 let ended = crate::gate_run::Moment::now();
354 let kept = publish::record_push(
362 stream,
363 &Ref::from_git(base),
364 &result,
365 None,
366 crate::event::Phase::Integrate,
367 None,
368 )?;
369 publish::record_pre_push(stream, hooked, started, ended, &result);
370 if !result.accepted() {
371 return Err(publish::rejected(
377 &format!("the push of {base:?}"),
378 &result,
379 &kept,
380 None,
384 ));
385 }
386 ending = Ending::AdvancedAndPushed;
387 }
388 let _ = std::fs::remove_dir_all(&workspace);
389 Ok(Outcome {
390 base: Ref::from_git(base),
391 branches,
392 ending,
393 })
394}
395
396struct Train<'a> {
398 resolution: &'a Resolution,
399 base: &'a str,
401 remote_base: &'a str,
403 workspace: &'a Path,
405 trailers: &'a provenance::Trailers,
407}
408
409fn one(train: &Train, branch: &str) -> Result<BranchOutcome> {
410 let Train {
411 resolution,
412 base,
413 remote_base,
414 workspace,
415 trailers,
416 } = *train;
417 let root = &resolution.publication;
418 if let Some(prefix) = provenance::unrecognized(root, base, branch, trailers)?.first() {
422 return Ok(skipped(
423 branch,
424 &format!(
425 "provenance under the trailer prefix {prefix:?}, which this host is not \
426 configured to read; set trailer_prefix to {prefix:?} in the rules file, then \
427 land it with `{}`",
428 guidance::command([
429 "onevcs",
430 "recover",
431 branch,
432 "--repo",
433 &root.to_string_lossy()
434 ])
435 ),
436 ));
437 }
438 let unattested = provenance::unattested(root, base, branch, trailers)?;
442 if !unattested.is_empty() {
443 return Ok(BranchOutcome {
444 branch: Ref::from_git(branch),
445 status: Status::Skipped(format!(
446 "incomplete provenance ({} unattested commit(s)); this branch belongs to `{}`",
447 unattested.len(),
448 guidance::command([
449 "onevcs",
450 "recover",
451 branch,
452 "--repo",
453 &root.to_string_lossy()
454 ])
455 )),
456 });
457 }
458
459 let parent: PathBuf = workspace.join(policy::branch_slug(branch));
460 home::ensure_dir(&parent)?;
461 let worktree = parent.join("worktree");
462 git::worktree_add_existing(root, &worktree, branch)?;
463
464 let outcome = (|| -> Result<BranchOutcome> {
465 if let git::Integrated::Conflicted(conflict) = git::merge_into_branch(
469 &worktree,
470 remote_base,
471 &format!("Merge {remote_base} into {branch}"),
472 )? {
473 return Ok(skipped(
474 branch,
475 &format!(
476 "conflict with the current base in {}",
477 guidance::listed(conflict.paths())
478 ),
479 ));
480 }
481 if let git::Integrated::Conflicted(conflict) = git::merge_into_branch(
482 &worktree,
483 base,
484 &format!("Merge integration train {base} into {branch}"),
485 )? {
486 return Ok(skipped(
487 branch,
488 &format!(
489 "conflict with an earlier candidate in {}",
490 guidance::listed(conflict.paths())
491 ),
492 ));
493 }
494 if !git::is_ancestor(root, &git::head_sha(root)?, branch)? {
499 return Ok(skipped(
500 branch,
501 "not-ready: the base advanced while this candidate was being built",
502 ));
503 }
504 let subject =
505 match provenance::publication_subject(&worktree, base, "HEAD", None, trailers)? {
506 Ok(subject) => subject,
507 Err(reason) => {
512 return Ok(skipped(
513 branch,
514 &format!(
515 "{reason}; publish it with `{} --title <T>`",
516 guidance::command([
517 "onevcs",
518 "publish-branch",
519 branch,
520 "--repo",
521 &root.to_string_lossy()
522 ])
523 ),
524 ))
525 }
526 };
527 let attested = provenance::attestation_trailers(&worktree, base, "HEAD", trailers)?;
528 let message = publish::compose_message(&subject, &attested);
529 let landed = squash_publish(root, base, branch, &message, workspace)?;
530 Ok(BranchOutcome {
531 branch: Ref::from_git(branch),
532 status: if landed {
533 Status::Merged
534 } else {
535 Status::AlreadyMerged
536 },
537 })
538 })();
539
540 git::worktree_remove(root, &worktree)?;
541 let _ = std::fs::remove_dir_all(&parent);
542 outcome
543}
544
545fn skipped(branch: &str, reason: &str) -> BranchOutcome {
546 BranchOutcome {
547 branch: Ref::from_git(branch),
548 status: Status::Skipped(reason.to_owned()),
549 }
550}
551
552fn squash_publish(
554 root: &Path,
555 base: &str,
556 branch: &str,
557 message: &str,
558 workspace: &Path,
559) -> Result<bool> {
560 let parent = workspace.join(format!("publish-{}", ids::unique()));
561 home::ensure_dir(&parent)?;
562 let scratch = parent.join("worktree");
563 git::worktree_add_detached(root, &scratch, base)?;
564 let landed = (|| -> Result<bool> {
565 let Some(sha) = git::merge_squash(&scratch, branch, message)? else {
566 return Ok(false);
567 };
568 git::merge_ff_only(root, &sha)?;
569 Ok(true)
570 })();
571 git::worktree_remove(root, &scratch)?;
572 let _ = std::fs::remove_dir_all(&parent);
573 landed
574}