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, stream)?);
314 }
315
316 let final_head = git::head_sha(root)?;
317 let mut ending = if final_head == initial {
318 Ending::Unchanged
319 } else {
320 Ending::Advanced
321 };
322 if push && ending.advanced() {
323 if !has_remote {
324 return Err(Error::Invalid {
325 reason: format!(
326 "{} has no origin to push to; the base advanced locally, so re-run \
327 `onevcs integrate` without --push, or give the checkout an origin",
328 root.display()
329 ),
330 });
331 }
332 let compared = format!("origin/{base}");
335 crate::boundary::evidence::guard_publication(
336 crate::Providers::real().hosting,
337 &resolution.key,
338 &crate::boundary::screen::Outgoing {
339 repo: root,
340 base: git::tip(root, &compared).map(|_| compared.as_str()),
341 tip: base,
342 branch: Some(base),
343 title: None,
344 body: None,
345 },
346 term_scope,
347 )?;
348 stream.begin_attempt();
351 let hooked = crate::gate_run::runs_pre_push(root);
352 let started = crate::gate_run::Moment::now();
353 let result = git::push(root, base, "origin", &environment)?;
354 let ended = crate::gate_run::Moment::now();
355 let kept = publish::record_push(
363 stream,
364 &Ref::from_git(base),
365 &result,
366 None,
367 crate::event::Phase::Integrate,
368 None,
369 )?;
370 publish::record_pre_push(stream, hooked, started, ended, &result);
371 if !result.accepted() {
372 return Err(publish::rejected(
378 &format!("the push of {base:?}"),
379 &result,
380 &kept,
381 None,
385 ));
386 }
387 ending = Ending::AdvancedAndPushed;
388 }
389 let _ = std::fs::remove_dir_all(&workspace);
390 if ending.advanced() {
391 stream.base_arrived(base, &final_head, candidates);
392 }
393 Ok(Outcome {
394 base: Ref::from_git(base),
395 branches,
396 ending,
397 })
398}
399
400struct Train<'a> {
402 resolution: &'a Resolution,
403 base: &'a str,
405 remote_base: &'a str,
407 workspace: &'a Path,
409 trailers: &'a provenance::Trailers,
411}
412
413fn one(train: &Train, branch: &str, stream: &mut Stream) -> Result<BranchOutcome> {
414 let Train {
415 resolution,
416 base,
417 remote_base,
418 workspace,
419 trailers,
420 } = *train;
421 let root = &resolution.publication;
422 if let Some(prefix) = provenance::unrecognized(root, base, branch, trailers)?.first() {
426 return Ok(skipped(
427 branch,
428 &format!(
429 "provenance under the trailer prefix {prefix:?}, which this host is not \
430 configured to read; set trailer_prefix to {prefix:?} in the rules file, then \
431 land it with `{}`",
432 guidance::command([
433 "onevcs",
434 "recover",
435 branch,
436 "--repo",
437 &root.to_string_lossy()
438 ])
439 ),
440 ));
441 }
442 let unattested = provenance::unattested(root, base, branch, trailers)?;
446 if !unattested.is_empty() {
447 return Ok(BranchOutcome {
448 branch: Ref::from_git(branch),
449 status: Status::Skipped(format!(
450 "incomplete provenance ({} unattested commit(s)); this branch belongs to `{}`",
451 unattested.len(),
452 guidance::command([
453 "onevcs",
454 "recover",
455 branch,
456 "--repo",
457 &root.to_string_lossy()
458 ])
459 )),
460 });
461 }
462
463 let parent: PathBuf = workspace.join(policy::branch_slug(branch));
464 home::ensure_dir(&parent)?;
465 let worktree = parent.join("worktree");
466 git::worktree_add_existing(root, &worktree, branch)?;
467
468 let outcome = (|| -> Result<BranchOutcome> {
469 if let git::Integrated::Conflicted(conflict) = git::merge_into_branch(
473 &worktree,
474 remote_base,
475 &format!("Merge {remote_base} into {branch}"),
476 )? {
477 return Ok(skipped(
478 branch,
479 &format!(
480 "conflict with the current base in {}",
481 guidance::listed(conflict.paths())
482 ),
483 ));
484 }
485 if let git::Integrated::Conflicted(conflict) = git::merge_into_branch(
486 &worktree,
487 base,
488 &format!("Merge integration train {base} into {branch}"),
489 )? {
490 return Ok(skipped(
491 branch,
492 &format!(
493 "conflict with an earlier candidate in {}",
494 guidance::listed(conflict.paths())
495 ),
496 ));
497 }
498 if !git::is_ancestor(root, &git::head_sha(root)?, branch)? {
503 return Ok(skipped(
504 branch,
505 "not-ready: the base advanced while this candidate was being built",
506 ));
507 }
508 let subject =
509 match provenance::publication_subject(&worktree, base, "HEAD", None, trailers)? {
510 Ok(subject) => subject,
511 Err(reason) => {
516 return Ok(skipped(
517 branch,
518 &format!(
519 "{reason}; publish it with `{} --title <T>`",
520 guidance::command([
521 "onevcs",
522 "publish-branch",
523 branch,
524 "--repo",
525 &root.to_string_lossy()
526 ])
527 ),
528 ))
529 }
530 };
531 let attested = provenance::attestation_trailers(&worktree, base, "HEAD", trailers)?;
532 let message = publish::compose_message(&subject, &attested);
533 let landed = squash_publish(root, base, branch, &message, workspace, stream)?;
534 Ok(BranchOutcome {
535 branch: Ref::from_git(branch),
536 status: if landed {
537 Status::Merged
538 } else {
539 Status::AlreadyMerged
540 },
541 })
542 })();
543
544 git::worktree_remove(root, &worktree)?;
545 let _ = std::fs::remove_dir_all(&parent);
546 outcome
547}
548
549fn skipped(branch: &str, reason: &str) -> BranchOutcome {
550 BranchOutcome {
551 branch: Ref::from_git(branch),
552 status: Status::Skipped(reason.to_owned()),
553 }
554}
555
556fn squash_publish(
558 root: &Path,
559 base: &str,
560 branch: &str,
561 message: &str,
562 workspace: &Path,
563 stream: &mut Stream,
564) -> Result<bool> {
565 let parent = workspace.join(format!("publish-{}", ids::unique()));
566 home::ensure_dir(&parent)?;
567 let scratch = parent.join("worktree");
568 git::worktree_add_detached(root, &scratch, base)?;
569 let landed = (|| -> Result<bool> {
570 let Some(sha) = git::merge_squash(&scratch, branch, message)? else {
571 return Ok(false);
572 };
573 git::merge_ff_only(root, &sha)?;
574 if stream.in_operation() {
575 stream.emit(
576 EventKind::MergeCompleted,
577 object(json!({"branch": branch, "base": base, "sha": sha})),
578 );
579 }
580 Ok(true)
581 })();
582 git::worktree_remove(root, &scratch)?;
583 let _ = std::fs::remove_dir_all(&parent);
584 landed
585}