use std::io::Write;
use std::path::Path;
use crate::change::{ChangeDescription, SessionChange};
use crate::cli::{
ArtifactCommand, ChangeCommand, ChangeDescribeArgs, ChangeReadyArgs, ChangeShowArgs, Command,
EventsArgs, ImportArgs, IntegrateArgs, PoolCommand, PoolMaintainArgs, PoolPruneArgs,
PoolStatusArgs, PreserveArgs, PublishArgs, PublishBranchArgs, RecoverArgs, RecoverableArgs,
RegisterArgs, ReleaseAcknowledgeArgs, ReleaseCommand, ReleaseDeclarationArgs,
ReleaseDiscoverArgs, ReleaseLatestArgs, ReleaseStatusArgs, ReleaseTargetsArgs, ReposArgs,
ResolveArgs, RetireArgs, RetireFinishedArgs, RulesCheckArgs, RulesCommand, SessionCommand,
SessionHoldersArgs, SessionOpenArgs, SessionTokenArgs, StatusArgs, SupersedeArgs, SweepArgs,
SweepFormat, SyncArgs,
};
use crate::declaration::{RegistryId, RepositoryPath};
use crate::error::{self, Error, Result};
use crate::event::{ArtifactId, EventFilter};
use crate::host::ProtectionSource;
use crate::import::Wrote;
use crate::landed::Landed;
use crate::ops::{
BasePush, BranchPublishRequest, GateAudit, ImportRequest, IntegrateRequest, MergePathCoverage,
RecoverRequest, RequiredChecksAnswer, ResolvedPolicy, Sweeping, TrailerPrefixSource,
};
use crate::preserve::Preservation;
use crate::providers::Providers;
use crate::publish::{DraftReason, PublishOutcome, PublishRequest, Retention, Subject};
use crate::releases::{
Acknowledgement, Baseline, DeclarationSource, Probe, ReleaseAnswer, ReleaseMethod,
ReleaseStatus, ReleaseTarget, RepositoryReleases, TargetName, TargetSource,
};
use crate::session::{
Lifecycle, Provenance, Scope, Selection, SessionHolder, SessionRequest, SessionToken,
};
use crate::store;
use crate::{git, guidance, label, lock, policy, publish};
pub const POOL_EXHAUSTED_EXIT: u8 = 4;
pub fn run(command: &Command, providers: &Providers<'_>) -> u8 {
match dispatch(command, providers) {
Ok(code) => code,
Err(error) => {
eprintln!("onevcs: {error}");
match error {
Error::PoolExhausted { .. } => POOL_EXHAUSTED_EXIT,
other => publish::exit_code(&other),
}
}
}
}
fn dispatch(command: &Command, providers: &Providers<'_>) -> Result<u8> {
git::check_bounds()?;
lock::timeout_seconds()?;
match command {
Command::Register(args) => register(args),
Command::Repos(args) => repos(args, providers),
Command::Resolve(args) => resolve(args, providers),
Command::Session { command } => match command {
SessionCommand::Open(args) => session_open(args, providers),
SessionCommand::Adopt(args) => session_adopt(args, providers),
SessionCommand::Close(args) => session_close(args, providers),
SessionCommand::Holders(args) => session_holders(args),
},
Command::Publish(args) => publish_session(args, providers),
Command::PublishBranch(args) => publish_branch(args, providers),
Command::Change { command } => match command {
ChangeCommand::Show(args) => change_show(args, providers),
ChangeCommand::Describe(args) => change_describe(args, providers),
ChangeCommand::Ready(args) => change_ready(args, providers),
},
Command::Preserve(args) => preserve_branch(args),
Command::Recover(args) => recover_branch(args, providers),
Command::Recoverable(args) => recoverable(args, providers),
Command::Status(args) => report_status(args, providers),
Command::Import(args) => import_branch(args),
Command::Integrate(args) => integrate_branches(args),
Command::Sync(args) => sync(args),
Command::Sweep(args) => sweep_workspaces(args),
Command::Events(args) => events(args, providers),
Command::Artifact { command } => match command {
ArtifactCommand::Cat(args) => artifact(&args.id),
},
Command::Rules { command } => match command {
RulesCommand::Check(args) => rules_check(args),
},
Command::Release { command } => match command {
ReleaseCommand::Targets(args) => release_targets(args),
ReleaseCommand::Discover(args) => release_discover(args),
ReleaseCommand::Latest(args) => release_latest(args),
ReleaseCommand::Status(args) => release_status(args, providers),
ReleaseCommand::Acknowledge(args) => release_acknowledge(args),
ReleaseCommand::Declaration(args) => release_declaration(args),
},
Command::Pool { command } => match command {
PoolCommand::Status(args) => pool_status(args),
PoolCommand::Prune(args) => pool_prune(args),
PoolCommand::Maintain(args) => pool_maintain(args),
},
Command::Retire(args) => retire_branch(args, providers),
Command::Reclaim(args) => reclaim_branch(args, providers),
Command::RetireFinished(args) => retire_finished(args, providers),
Command::Supersede(args) => supersede(args),
}
}
pub const RETIREMENT_REFUSED_EXIT: u8 = 4;
fn retire_branch(args: &RetireArgs, providers: &Providers<'_>) -> Result<u8> {
let retired = crate::retire(
providers,
&retire_request(args, crate::RetireMode::Lossless),
)?;
render_retired(args, &retired, "retire")
}
fn reclaim_branch(args: &RetireArgs, providers: &Providers<'_>) -> Result<u8> {
let retired = crate::retire(providers, &retire_request(args, crate::RetireMode::Reclaim))?;
render_retired(args, &retired, "reclaim")
}
fn retire_request(args: &RetireArgs, mode: crate::RetireMode) -> crate::RetireRequest {
crate::RetireRequest {
repo: args.repo.clone(),
branch: args.branch.clone(),
mode,
dry_run: args.dry_run,
}
}
fn render_retired(args: &RetireArgs, retired: &crate::Retired, verb: &str) -> Result<u8> {
let code = match retired.outcome {
crate::RetireOutcome::Kept => RETIREMENT_REFUSED_EXIT,
crate::RetireOutcome::Incomplete => 1,
_ => 0,
};
if args.json {
print_json(retired)?;
return Ok(code);
}
for line in crate::retire::describe_retired(retired, verb) {
println!("{line}");
}
Ok(code)
}
fn retire_finished(args: &RetireFinishedArgs, providers: &Providers<'_>) -> Result<u8> {
let registry = store::load()?;
let identities: Vec<String> = match &args.repo {
Some(repo) => vec![store::resolve(®istry, repo)?.key],
None => {
let mut keys: Vec<String> = registry
.checkouts
.values()
.map(|checkout| checkout.identity.clone())
.collect();
keys.sort_unstable();
keys.dedup();
keys
}
};
let exclude = identities
.iter()
.flat_map(|identity| {
args.exclude.iter().map(move |branch| crate::BranchRef {
identity: identity.clone(),
branch: branch.clone(),
})
})
.collect();
let report = crate::retire_finished(
providers,
&crate::RetirePass {
scope: match &args.repo {
Some(repo) => Scope::Repo(repo.clone()),
None => Scope::All,
},
exclude,
dry_run: args.dry_run,
},
)?;
if args.json {
return print_json(&report);
}
for line in crate::retire::describe_pass(&report) {
println!("{line}");
}
Ok(0)
}
fn supersede(args: &SupersedeArgs) -> Result<u8> {
let labels = label::parse_all(&args.label)?;
crate::record_supersession(&crate::Supersession {
repo: args.repo.clone(),
branch: args.branch.clone(),
superseded_by: args.by.clone(),
landing: args.landing.clone(),
labels,
})?;
println!(
"recorded: {} is superseded by {}, which landed at {}",
args.branch, args.by, args.landing
);
Ok(0)
}
fn pool_status(args: &PoolStatusArgs) -> Result<u8> {
let status = crate::pool_status(&args.repo)?;
if args.json {
return print_json(&status);
}
let capacity = &status.capacity;
println!("identity: {}", capacity.identity);
println!(
"pool: {} (slots: {} created, {} idle, {} in use, {} maintaining)",
capacity.pool, capacity.slots, capacity.idle, capacity.in_use, capacity.maintaining
);
println!(
"overflow: {} ({} in use)",
capacity.overflow, capacity.overflow_in_use
);
println!("admits: {}", capacity.admits);
for slot in &status.slots {
let state = match &slot.state {
crate::SlotState::Idle => "idle".to_owned(),
crate::SlotState::InUse { session } => format!("in use by {}", session.0),
crate::SlotState::Maintaining { pid, since } => {
format!("maintaining (pid {pid}, since {since})")
}
crate::SlotState::Broken { reason } => format!("broken: {reason}"),
};
println!("slot {}: {state}", slot.number);
println!(" path: {}", slot.path.display());
println!(
" execution checkout: {}",
slot.execution_checkout.display()
);
println!(
" last maintained: {}",
slot.last_maintained.as_deref().unwrap_or("never")
);
println!(
" last outcome: {}",
match slot.last_outcome {
None => "none".to_owned(),
Some(crate::MaintenanceOutcome::Succeeded) => "succeeded".to_owned(),
Some(crate::MaintenanceOutcome::Failed { exit: Some(exit) }) => {
format!("failed (exit {exit})")
}
Some(crate::MaintenanceOutcome::Failed { exit: None }) => {
"failed (ended by a signal)".to_owned()
}
Some(crate::MaintenanceOutcome::TimedOut) => "timed out".to_owned(),
}
);
}
Ok(0)
}
fn pool_prune(args: &PoolPruneArgs) -> Result<u8> {
let report = crate::pool_prune(&args.repo)?;
if args.json {
return print_json(&report);
}
for number in &report.removed {
println!("removed slot {number}");
}
for (number, why) in &report.kept {
println!("kept slot {number}: {why}");
}
if report.removed.is_empty() && report.kept.is_empty() {
println!("no slots");
}
Ok(0)
}
fn pool_maintain(args: &PoolMaintainArgs) -> Result<u8> {
let scope = match &args.repo {
Some(repo) => Scope::Repo(repo.clone()),
None => Scope::All,
};
let report = crate::pool_maintain(scope, args.older_than)?;
let code = match report.every_command_succeeded() {
true => 0,
false => 1,
};
if args.json {
print_json(&report)?;
return Ok(code);
}
let mut ran = 0;
let mut succeeded = 0;
let mut failed = 0;
let mut timed_out = 0;
for identity in &report.identities {
if let crate::IdentityOutcome::Slots(slots) = &identity.outcome {
for slot in slots {
if let crate::SlotOutcome::Ran { outcome, .. } = &slot.outcome {
ran += 1;
match outcome {
crate::MaintenanceOutcome::Succeeded => succeeded += 1,
crate::MaintenanceOutcome::Failed { .. } => failed += 1,
crate::MaintenanceOutcome::TimedOut => timed_out += 1,
}
}
}
}
}
println!(
"onevcs pool maintain: ran {ran} command(s) — {succeeded} succeeded, {failed} failed, \
{timed_out} timed out — over {} identity(ies){}.",
report.identities.len(),
match args.older_than {
Some(span) => format!(", skipping slots maintained within {span}"),
None => String::new(),
}
);
for identity in &report.identities {
match &identity.outcome {
crate::IdentityOutcome::NoMaintainCommand => {
println!("{} — no maintain command", identity.identity);
}
crate::IdentityOutcome::NoSlots => println!("{} — no slots", identity.identity),
crate::IdentityOutcome::Claimed { by_pid } => println!(
"{} — claimed: another pool maintain (pid {by_pid}) is maintaining it right now",
identity.identity
),
crate::IdentityOutcome::Slots(slots) => {
println!("{}:", identity.identity);
for slot in slots {
println!(
" slot {} — {}",
slot.number,
describe_slot_outcome(&slot.outcome)
);
}
}
}
}
Ok(code)
}
fn describe_slot_outcome(outcome: &crate::SlotOutcome) -> String {
match outcome {
crate::SlotOutcome::NotDue { last_maintained } => {
format!("kept: not due, last maintained {last_maintained}")
}
crate::SlotOutcome::InUse { session } => {
format!("kept: session {} is working in it", session.0)
}
crate::SlotOutcome::Unavailable { holder } => format!("kept: {holder}"),
crate::SlotOutcome::Broken { reason } => format!("kept: {reason}"),
crate::SlotOutcome::Ran {
outcome,
duration_ms,
log,
} => format!(
"ran: {} in {duration_ms} ms{}",
match outcome {
crate::MaintenanceOutcome::Succeeded => "succeeded".to_owned(),
crate::MaintenanceOutcome::Failed { exit: Some(exit) } => {
format!("failed (exit {exit})")
}
crate::MaintenanceOutcome::Failed { exit: None } => {
"failed (ended by a signal, or never started)".to_owned()
}
crate::MaintenanceOutcome::TimedOut => "timed out".to_owned(),
},
match log {
Some(log) => format!(", log: onevcs artifact cat {}", log.0),
None => String::new(),
}
),
}
}
fn register(args: &RegisterArgs) -> Result<u8> {
let registered = crate::register_checkout(&args.path, args.origin.as_ref())?;
println!("{}", registered.identity);
println!(" alias: {}", registered.alias);
print_policy(" ", ®istered.policy);
println!(" gate: {}", registered.gate);
println!(" merge-path coverage: {}", registered.coverage.describe());
if registered.coverage == MergePathCoverage::None {
eprintln!(
"onevcs: warning: nothing on this identity's merge path runs a gate, so a \
publication is unproven. Install an executable pre-push hook."
);
}
Ok(0)
}
fn print_policy(indent: &str, resolved: &ResolvedPolicy) {
println!(
"{indent}publication: {} (from {})",
policy::spell(resolved.publication),
resolved.publication_from
);
println!(
"{indent}approvals: {} (from {})",
spell_approvals(resolved.approvals),
resolved.approvals_from
);
}
fn spell_approvals(approvals: crate::rules::Approvals) -> &'static str {
match approvals {
crate::rules::Approvals::Required => "required",
crate::rules::Approvals::None => "none",
}
}
fn repos(args: &ReposArgs, providers: &Providers<'_>) -> Result<u8> {
let listed = crate::repositories(
providers,
match args.audit_gates {
true => GateAudit::Asked,
false => GateAudit::Skipped,
},
)?;
if listed.is_empty() {
println!("no repositories registered");
return Ok(0);
}
for repository in &listed {
println!("{}\t{}", repository.identity, repository.gate);
if let Some(answer) = &repository.required_checks {
println!(
" required checks: {}",
required_checks_line(&repository.identity, answer)
);
}
for checkout in &repository.checkouts {
println!(" {}\t{}", checkout.alias, checkout.path.display());
if let Some(audit) = &checkout.audit {
print_policy(" ", &audit.policy);
println!(" merge-path coverage: {}", audit.coverage.describe());
}
}
}
Ok(0)
}
fn required_checks_line(key: &str, answer: &RequiredChecksAnswer) -> String {
let (base, answer) = match answer {
RequiredChecksAnswer::NotHosted => {
return format!(
"none: {key:?} is not a {} repository, so no host answers for it",
crate::gh::HOST
)
}
RequiredChecksAnswer::Unreadable { reason } => return format!("unreadable — {reason}"),
RequiredChecksAnswer::Answered { base, checks } => (base, checks),
};
let names = answer.checks.iter().cloned().collect::<Vec<_>>().join(", ");
if answer.complete() {
return if answer.checks.is_empty() {
format!(
"none required: neither the repository's rulesets nor its branch protection \
names one for {base}"
)
} else {
format!(
"{names} (required by the repository's rulesets and branch protection for \
{base})"
)
};
}
let unconsulted = answer
.unconsulted
.iter()
.map(|(source, why)| format!("{} was not consulted: {why}", source.describe()))
.collect::<Vec<_>>()
.join("; ");
let consulted = ProtectionSource::every()
.filter(|source| !answer.unconsulted.contains_key(source))
.map(ProtectionSource::describe)
.collect::<Vec<_>>()
.join(" and ");
if answer.checks.is_empty() {
format!("unknown — {consulted} name none for {base}, and {unconsulted}")
} else {
format!("{names} (required by {consulted} for {base}; incomplete — {unconsulted})")
}
}
fn resolve(args: &ResolveArgs, providers: &Providers<'_>) -> Result<u8> {
let resolved = crate::resolve_repository(providers, &args.repo)?;
println!(
"{}",
serde_json::json!({
"identity": resolved.identity,
"alias": resolved.alias,
"origin": resolved.origin,
"publication": policy::spell(resolved.policy.publication),
"approvals": spell_approvals(resolved.policy.approvals),
"gate": resolved.gate,
"publication_checkout": resolved.publication_checkout.display().to_string(),
})
);
Ok(0)
}
fn session_open(args: &SessionOpenArgs, providers: &Providers<'_>) -> Result<u8> {
let registry = store::load()?;
let request = SessionRequest {
repo: args.repo.clone(),
branch: args.branch.clone(),
branch_name: args.branch_name.clone(),
branch_prefix: args.branch_prefix.clone(),
base: args.base.clone(),
execution_checkout: args.execution_checkout.clone(),
pool: args.pool,
overflow: args.overflow,
labels: label::parse_all(&args.label)?,
};
let _ = ®istry;
let session = providers.vcs.open_session(request)?;
println!(
"{}",
serde_json::to_string(&session).map_err(serialization)?
);
Ok(0)
}
fn session_adopt(args: &SessionTokenArgs, providers: &Providers<'_>) -> Result<u8> {
let token = SessionToken(args.token.clone());
let session = providers.vcs.adopt_session(token.clone())?;
println!(
"{}",
serde_json::to_string(&session).map_err(serialization)?
);
if providers.vcs.session(&token)?.provenance == Provenance::IncompleteStep {
eprintln!(
"onevcs: this branch carries incomplete-step provenance, so it must pass its \
merge path through `onevcs recover` before it may be published."
);
}
Ok(0)
}
fn session_close(args: &SessionTokenArgs, providers: &Providers<'_>) -> Result<u8> {
let session = crate::close_session(providers, &SessionToken(args.token.clone()))?;
println!("{} closed", session.token.0);
Ok(0)
}
fn session_holders(args: &SessionHoldersArgs) -> Result<u8> {
let wanted = label::parse_all(&args.label)?;
let holders: Vec<SessionHolder> = crate::session_holders(&args.repo)?
.into_iter()
.filter(|holder| label::matches(&holder.labels, &wanted))
.collect();
if args.json {
println!(
"{}",
serde_json::to_string(&holders).map_err(serialization)?
);
} else {
for holder in holders {
println!(
"{}\t{}\t{}\tpid={}\t{}\t{}{}",
holder.token.0,
match holder.state {
Lifecycle::Open => "open",
Lifecycle::Closed => "closed",
},
holder.liveness.as_str(),
holder.owner_pid,
holder.branch,
holder.worktree.display(),
spell_labels(&holder.labels),
);
}
}
Ok(0)
}
fn spell_labels(labels: &std::collections::BTreeMap<String, String>) -> String {
labels
.iter()
.map(|(key, value)| format!("\t{key}={value}"))
.collect()
}
fn selection_named(selection: &Selection) -> Option<String> {
if selection.is_empty() {
return None;
}
let spelled: Vec<String> = selection
.sessions
.iter()
.map(|token| format!("--session {}", token.0))
.chain(
selection
.labels
.iter()
.map(|(key, value)| format!("--label {key}={value}")),
)
.collect();
Some(format!(
"Only the preserved branches of the sessions `{}` names are listed; nothing else \
here was looked at.",
spelled.join(" ")
))
}
fn publish_session(args: &PublishArgs, providers: &Providers<'_>) -> Result<u8> {
let title = explicit_title(args.title.as_ref())?;
let body = explicit_body(
&["onevcs", "publish", &args.token],
args.body.as_ref(),
args.body_file.as_deref(),
)?;
let draft = held_draft(&args.token, args.draft, args.draft_reason.as_deref())?;
let publication = crate::publish(
providers,
&SessionToken(args.token.clone()),
&PublishRequest {
policy: args.policy,
title,
body,
draft,
},
)?;
let PublishOutcome::Failed {
kind,
reason,
retained,
} = &publication.outcome
else {
println!("{}", publication.outcome.describe());
return Ok(0);
};
eprintln!("onevcs: {reason}");
match retained {
Some(Retention::HandedBack(checkout)) => eprintln!(
"onevcs: branch {:?} is preserved in {}",
publication.branch,
checkout.display()
),
Some(Retention::Refused(checkout)) => eprintln!(
"onevcs: warning: {} refused branch {:?}, so nothing outside this session carries it",
checkout.display(),
publication.branch
),
None => {}
}
Ok(kind.exit_code())
}
fn held_draft(token: &str, draft: bool, reason: Option<&str>) -> Result<Option<DraftReason>> {
match (draft, reason) {
(false, None) => Ok(None),
(false, Some(_)) => Err(error::invalid(format!(
"--draft-reason says why a draft is held, and nothing asked for a draft. Add \
--draft — `{}` — or drop the reason",
guidance::command([
"onevcs",
"publish",
token,
"--draft",
"--draft-reason",
"TEXT"
]),
))),
(true, reason) => {
let reason = DraftReason::Held {
because: reason.map_or_else(|| DEFAULT_HELD_REASON.to_owned(), str::to_owned),
};
reason.checked()?;
Ok(Some(reason))
}
}
}
const DEFAULT_HELD_REASON: &str =
"the session that opened this change request is holding it as a draft while its work is \
still being made";
fn change_show(args: &ChangeShowArgs, providers: &Providers<'_>) -> Result<u8> {
let token = SessionToken(args.token.clone());
match crate::session_change(providers, &token)? {
Some(change) => print_change(&change, args.json)?,
None if args.json => println!("null"),
None => println!(
"session {} has no open change request on the host",
args.token
),
}
Ok(0)
}
fn change_describe(args: &ChangeDescribeArgs, providers: &Providers<'_>) -> Result<u8> {
let title = explicit_title(args.title.as_ref())?;
let body = described_body(&args.token, args.body.as_ref(), args.body_file.as_deref())?;
let change = crate::describe_change(
providers,
&SessionToken(args.token.clone()),
&ChangeDescription { title, body },
)?;
print_change(&change, args.json)?;
Ok(0)
}
fn change_ready(args: &ChangeReadyArgs, providers: &Providers<'_>) -> Result<u8> {
let change = crate::ready_change(providers, &SessionToken(args.token.clone()))?;
print_change(&change, args.json)?;
Ok(0)
}
fn described_body(token: &str, body: Option<&String>, body_file: Option<&Path>) -> Result<String> {
let prefix = ["onevcs", "change", "describe", token];
explicit_body(&prefix, body, body_file)?.ok_or_else(|| {
let keeping = |option: &str, value: &str| {
let mut argv = prefix.to_vec();
argv.extend([option, value]);
guidance::command(argv)
};
error::invalid(format!(
"a description is the change request's body, and neither --body nor --body-file \
names one. Hand it over as a file — `{}` — or as text: `{}`",
keeping("--body-file", "PATH"),
keeping("--body", "TEXT"),
))
})
}
fn print_change(change: &SessionChange, json: bool) -> Result<()> {
if json {
println!("{}", serde_json::to_string(change).map_err(serialization)?);
return Ok(());
}
println!("change request: {}", change.url);
println!("id: {}", change.id.0);
println!("base: {}", change.base);
println!(
"draft: {}",
if change.draft {
"yes (the host holds it as a draft)"
} else {
"no (open for review)"
}
);
println!("title: {}", change.title);
println!("body:");
for line in change.body.lines() {
println!(" {line}");
}
Ok(())
}
fn report_publication(outcome: Result<PublishOutcome>) -> Result<u8> {
match outcome {
Ok(outcome) => {
println!("{}", outcome.describe());
Ok(0)
}
Err(error) => {
eprintln!("onevcs: {error}");
Ok(publish::exit_code(&error))
}
}
}
fn explicit_title(title: Option<&String>) -> Result<Option<Subject>> {
title
.cloned()
.map(Subject::try_from)
.transpose()
.map_err(error::invalid)
}
fn explicit_body(
command_prefix: &[&str],
body: Option<&String>,
body_file: Option<&Path>,
) -> Result<Option<String>> {
match (body, body_file) {
(Some(_), Some(path)) => {
let named = path.to_string_lossy();
let keeping = |option: &str, value: &str| {
let mut argv = command_prefix.to_vec();
argv.extend([option, value]);
guidance::command(argv)
};
Err(error::invalid(format!(
"--body and --body-file both name the body of the change request, and it is \
opened with one body. Keep the one that holds it: `{}` for the body in {}, or \
`{}` for the text as typed",
keeping("--body-file", &named),
path.display(),
keeping("--body", "TEXT"),
)))
}
(Some(body), None) => Ok(Some(body.clone())),
(None, Some(path)) => std::fs::read_to_string(path)
.map(Some)
.map_err(error::at("read the change request's body from", path)),
(None, None) => Ok(None),
}
}
fn preserve_branch(args: &PreserveArgs) -> Result<u8> {
let preserved = crate::preserve(&crate::PreserveRequest {
repo: args.repo.clone(),
branch: args.branch.clone(),
})?;
let branch = &preserved.branch;
match preserved.outcome {
Preservation::Pushed => println!(
"preserved: branch {branch:?} of {identity} is on {remote} at {commit}, pushed \
from {from}. Nothing was published — no change request, no merge path, no base \
touched — so it still needs the verb `onevcs recoverable` names to land it",
identity = preserved.identity,
remote = spelled(preserved.remote.as_deref()),
commit = spelled(preserved.commit.as_deref()),
from = preserved.from.display(),
),
Preservation::AlreadyOnOrigin => println!(
"already on origin: {remote} carries branch {branch:?} of {identity} at \
{commit}, so nothing was pushed",
identity = preserved.identity,
remote = spelled(preserved.remote.as_deref()),
commit = spelled(preserved.commit.as_deref()),
),
Preservation::NoRemote => println!(
"no remote: {from} holds branch {branch:?} of {identity} at {commit} and has no \
`origin` to push it to, so nothing was attempted and nothing outside this host \
carries that commit",
identity = preserved.identity,
commit = spelled(preserved.commit.as_deref()),
from = preserved.from.display(),
),
}
Ok(0)
}
fn spelled(value: Option<&str>) -> &str {
value.unwrap_or("unrecorded")
}
fn recover_branch(args: &RecoverArgs, providers: &Providers<'_>) -> Result<u8> {
let title = explicit_title(args.title.as_ref())?;
let body = explicit_body(
&[
"onevcs",
"recover",
&args.branch,
"--repo",
&args.repo.to_string_lossy(),
],
args.body.as_ref(),
args.body_file.as_deref(),
)?;
report_publication(crate::recover(
providers,
&RecoverRequest {
repo: args.repo.clone(),
branch: args.branch.clone(),
title,
body,
},
))
}
fn publish_branch(args: &PublishBranchArgs, providers: &Providers<'_>) -> Result<u8> {
let title = explicit_title(args.title.as_ref())?;
let body = explicit_body(
&[
"onevcs",
"publish-branch",
&args.branch,
"--repo",
&args.repo.to_string_lossy(),
],
args.body.as_ref(),
args.body_file.as_deref(),
)?;
report_publication(crate::publish_branch(
providers,
&BranchPublishRequest {
repo: args.repo.clone(),
branch: args.branch.clone(),
title,
body,
policy: args.policy,
},
))
}
fn recoverable(args: &RecoverableArgs, providers: &Providers<'_>) -> Result<u8> {
let registry = store::load()?;
let covered = match &args.repo {
Some(repo) => Some(store::resolve_path(®istry, repo)?),
None => store::resolve_here(®istry).ok(),
};
let scope = match &covered {
Some(resolution) => Scope::Repo(resolution.alias.clone()),
None => Scope::All,
};
let selection = Selection {
sessions: args.session.iter().cloned().map(SessionToken).collect(),
labels: label::parse_all(&args.label)?,
};
let rows = match args.all {
true => providers.vcs.preserved_matching(scope, &selection)?,
false => providers.vcs.recoverable_matching(scope, &selection)?,
};
let scoped = covered.as_ref().map(|resolution| match &args.repo {
Some(repo) => format!(
"{} — the identity `--repo {}` names, whose publication checkout is {}",
resolution.key,
repo.display(),
resolution.publication.display()
),
None => format!(
"{} — the identity of {}, the registered checkout this was run in",
resolution.key,
resolution.publication.display()
),
});
let widen = match args.repo {
Some(_) => {
"Only that identity is covered: run `onevcs recoverable` without `--repo`, from a \
directory outside every registered checkout, to see them all."
}
None => {
"Only that identity is covered: run `onevcs recoverable` from a directory \
outside every registered checkout to see them all."
}
};
let withheld = "Branches whose work reached their base are not listed, nor are branches \
that provably hold nothing beyond it; `onevcs recoverable --all` lists \
every preserved branch, each saying what became of its work and what says \
so.";
let narrowed = selection_named(&selection);
if args.json {
if let Some(scoped) = &scoped {
eprintln!("onevcs: this answer covers {scoped}. {widen}");
}
if let Some(narrowed) = &narrowed {
eprintln!("onevcs: {narrowed}");
}
if !args.all {
eprintln!("onevcs: {withheld}");
}
println!("{}", serde_json::to_string(&rows).map_err(serialization)?);
return Ok(0);
}
let what = match args.all {
true => "preserved branch(es), whatever became of the work",
false => "preserved unpublished branch(es)",
};
if rows.is_empty() {
let none = match args.all {
true => "No preserved branches",
false => "No preserved unpublished branches",
};
match (&scoped, &narrowed) {
(Some(scoped), Some(narrowed)) => println!("{none} in {scoped}. {narrowed}"),
(None, Some(narrowed)) => println!(
"{none} across the registered identities. \
{narrowed}"
),
(Some(scoped), None) => {
println!("{none} in {scoped}. Every branch of it has reached its base or a remote.")
}
(None, None) => println!(
"{none}. Every branch across the registered identities has reached its base \
or a remote."
),
}
if !args.all {
println!("{withheld}");
}
if scoped.is_some() {
println!("{widen}");
}
return Ok(0);
}
match &scoped {
Some(scoped) => println!("{} {what} in {scoped}:", rows.len()),
None => println!("{} {what} across every registered identity:", rows.len()),
}
if let Some(narrowed) = &narrowed {
println!("{narrowed}");
}
for row in rows {
let kind = match row.branch.provenance {
Provenance::IncompleteStep => "incomplete step (provenance marker)",
Provenance::Complete => "complete",
};
let mut marks: Vec<String> = Vec::new();
match &row.landed {
Landed::Yes { .. } => marks.push(format!("landed — {}", row.landed.tier())),
Landed::InPart { .. } => marks.push(format!("landed in part — {}", row.landed.tier())),
Landed::Unknown => marks.push("may have landed".to_owned()),
Landed::No => {}
}
if row.held_by.is_some() {
marks.push("held by a live session".to_owned());
}
if let Some(net) = row.net_negative {
marks.push(format!(
"net-negative: {added} added, {removed} removed",
added = net.added(),
removed = net.removed(),
));
}
if row.on_origin.is_some() {
marks.push("on origin".to_owned());
}
let marked = match marks.is_empty() {
true => String::new(),
false => format!(" — {}", marks.join("; ")),
};
println!("{} [{}] {kind}{marked}", row.branch.branch, row.identity);
println!(" Found in: {}", row.checkout.display());
if let Some(on_origin) = &row.on_origin {
println!(
" On origin: {remote} carries it at {commit}, put there by `onevcs \
preserve` and published by nothing. The work survives this host going \
away; landing it is still the command below",
remote = on_origin.remote,
commit = on_origin.commit,
);
}
println!(" Stopped because: {}", row.stopped_because);
if let Some(net) = row.net_negative {
println!(
" Net-negative: it removes {removed} line(s) and adds {added} since it forked \
from {base}, so landing it unread would strip work. Read it first with \
`{diff}`",
removed = net.removed(),
added = net.added(),
base = row.branch.base,
diff = guidance::command([
"git",
"-C",
&row.checkout.to_string_lossy(),
"diff",
"--stat",
&format!("{}...{}", row.branch.base, row.branch.branch),
]),
);
}
let command = guidance::command(row.recover_command.iter().map(String::as_str));
if let Landed::Yes { evidence } = &row.landed {
println!(
" Landed: {tier} ({commit}) says this branch's work reached {base}. Nothing \
to resume — publishing it again would re-open a change request for work \
{base} already carries",
tier = row.landed.tier(),
commit = evidence.commit(),
base = row.branch.base,
);
continue;
}
if let Landed::InPart { evidence, unlanded } = &row.landed {
println!(
" Landed in part: {tier} ({commit}) says work of this branch reached {base}, \
and it has {unlanded} commit(s) since that the landing does not carry. Those \
are what publishing it now would land",
tier = row.landed.tier(),
commit = evidence.commit(),
base = row.branch.base,
);
}
if row.landed == Landed::Unknown {
println!(
" Not decided: nothing records that this branch's work reached {base} — no \
landing, no change request's number in {base}'s history, and no landing \
trailer — and comparing content settles nothing here, so {base} may already \
carry this work. Read it with `{diff}`; if it really has not landed, \
`{command}` lands it",
base = row.branch.base,
diff = guidance::command([
"git",
"-C",
&row.checkout.to_string_lossy(),
"diff",
"--stat",
&format!("{}...{}", row.branch.base, row.branch.branch),
]),
);
render_reclaim(®istry, &row);
continue;
}
render_retirement(®istry, &row);
match &row.held_by {
Some(held) => println!(
" Not ready: session {token} still holds this branch and {because}, so \
running `{command}` now would publish a branch mid-flight. Its worktree is \
{worktree}; wait for it, or close it with `{close}`, and then run that command",
token = held.token.0,
because = held.holding.because(),
worktree = held.worktree.display(),
close = guidance::command(["onevcs", "session", "close", &held.token.0]),
),
None => println!(" Resume: {command}"),
}
render_reclaim(®istry, &row);
}
if !args.all {
println!("{withheld}");
}
if scoped.is_some() {
println!("{widen}");
}
Ok(0)
}
fn render_retirement(registry: &crate::registry::Registry, row: &crate::Recoverable) {
let Some(retirement) = &row.retirement else {
return;
};
match (retirement.class, retirement.reason) {
(crate::RetirementClass::Retirable, _) => println!(
" Retirable: it holds nothing beyond {base} ({proof}); `{command}` deletes it \
everywhere this host holds it",
base = row.branch.base,
proof = retirement
.proof
.as_ref()
.map(crate::RetirementProof::describe)
.unwrap_or_default(),
command = guidance::command([
"onevcs",
"retire",
&row.branch.branch,
"--repo",
&publication_of(registry, row),
]),
),
(crate::RetirementClass::Keep, Some(reason))
if reason != crate::KeepReason::UnmergedUniqueCommits =>
{
println!(" Kept: {}", retirement.verdict())
}
_ => {}
}
}
fn render_reclaim(registry: &crate::registry::Registry, row: &crate::Recoverable) {
let Some(retirement) = &row.retirement else {
return;
};
if retirement.class != crate::RetirementClass::SupersededWithChanges {
return;
}
println!(
" Reclaim: {}",
guidance::command([
"onevcs",
"reclaim",
&row.branch.branch,
"--repo",
&publication_of(registry, row),
])
);
if let Some(by) = &retirement.superseded_by {
println!(
" Superseded by: {} (landed at {})",
by.branch, by.landing
);
if !by.labels.is_empty() {
println!(
" Labels: {}",
crate::retire::spelled_labels(&by.labels)
);
}
}
println!(
" Differs from {} in: {}",
retirement.base,
retirement.differing_paths.join(", ")
);
}
fn publication_of(registry: &crate::registry::Registry, row: &crate::Recoverable) -> String {
store::resolve(registry, &row.identity)
.map(|resolution| resolution.publication.display().to_string())
.unwrap_or_else(|_| row.identity.clone())
}
fn report_status(args: &StatusArgs, providers: &Providers<'_>) -> Result<u8> {
let report = crate::work_status(providers, &args.reference)?;
if args.json {
return print_json(&report);
}
print!("{}", report.render());
Ok(0)
}
fn import_branch(args: &ImportArgs) -> Result<u8> {
let imported = crate::import_branch(&ImportRequest {
repo: args.repo.clone(),
branch: args.branch.clone(),
from: args.from.clone(),
under: args.r#as.clone(),
})?;
println!(
"{} {} in {} from {}, at {}",
match imported.wrote {
Wrote::Created => "imported",
Wrote::FastForwarded => "fast-forwarded",
Wrote::Unchanged => "already had",
},
imported.name,
imported.destination.display(),
imported.source.describe(),
imported.tip,
);
Ok(0)
}
fn integrate_branches(args: &IntegrateArgs) -> Result<u8> {
let outcome = crate::integrate(&IntegrateRequest {
branches: args.branches.clone(),
push: match args.push {
true => BasePush::Push,
false => BasePush::Keep,
},
})?;
println!("Integration train for {}:", outcome.base);
for branch in &outcome.branches {
println!(" {}: {}", branch.branch, branch.status.describe());
}
println!("Base advanced: {}", yes_or_no(outcome.ending.advanced()));
println!("Pushed: {}", yes_or_no(outcome.ending.pushed()));
Ok(0)
}
fn sync(args: &SyncArgs) -> Result<u8> {
let synced = crate::sync(args.branch.as_deref())?;
println!(
"{identity}: {branch} {moved} origin/{branch} at {now}, in {checkout}",
identity = synced.identity,
branch = synced.branch,
moved = match synced.moved() {
false => "was already level with",
true => "fast-forwarded to",
},
now = synced.after,
checkout = synced.checkout.display(),
);
Ok(0)
}
fn sweep_workspaces(args: &SweepArgs) -> Result<u8> {
let report = crate::sweep(
match args.dry_run {
true => Sweeping::Rehearse,
false => Sweeping::Reclaim,
},
args.min_age_hours,
)?;
match args.format {
SweepFormat::Json => print_json(&report),
SweepFormat::Text => {
println!("{report}");
Ok(0)
}
}
}
fn events(args: &EventsArgs, providers: &Providers<'_>) -> Result<u8> {
let filter = args.filter.as_deref().map(load_filter).transpose()?;
let session = SessionToken(args.token.clone());
let mut lines = crate::EventLines::open(&session, filter)?;
loop {
let closed = args.follow
&& providers
.vcs
.session(&session)
.map(|record| record.lifecycle == Lifecycle::Closed)
.unwrap_or(true);
let mut out = std::io::stdout().lock();
for line in lines.read()? {
writeln!(out, "{}", line.text).map_err(|e| {
error::invalid(format!(
"cannot write the event stream for {:?}: {e}",
args.token
))
})?;
}
drop(out);
if !args.follow || closed {
return Ok(0);
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
fn load_filter(spec: &str) -> Result<EventFilter> {
let document = if spec.trim_start().starts_with('{') {
spec.to_owned()
} else {
let path = Path::new(spec);
std::fs::read_to_string(path).map_err(error::at("read the event filter at", path))?
};
EventFilter::parse(&document).map_err(|refusal| error::invalid(refusal.to_string()))
}
fn artifact(id: &str) -> Result<u8> {
print!("{}", crate::read_artifact(&ArtifactId(id.to_owned()))?);
Ok(0)
}
fn rules_check(args: &RulesCheckArgs) -> Result<u8> {
let checked = crate::rules_check(&args.repo)?;
println!("repo: {}", args.repo);
println!("identity: {}", checked.identity);
println!("checkout: {}", checked.checkout.display());
println!("rules: {}", checked.rules);
match &checked.matched {
Some(matched) => println!(
"matched: rule {} {}",
matched.index,
describe_match(&matched.criteria)
),
None => println!("matched: no rule; the default applies"),
}
print_policy("", &checked.policy);
println!(
"trailer_prefix: {} (from {})",
checked.trailer_prefix,
match checked.trailer_prefix_source {
TrailerPrefixSource::RulesFile => "the rules file",
TrailerPrefixSource::BuiltIn => "the default",
}
);
Ok(0)
}
fn release_targets(args: &ReleaseTargetsArgs) -> Result<u8> {
let releases = crate::release_targets(&args.repo)?;
if args.json {
return print_json(&releases);
}
print_releases(&releases);
Ok(0)
}
fn print_releases(releases: &RepositoryReleases) {
println!("identity: {}", releases.identity);
println!("adoption: {}", releases.adoption);
println!(
"default target: {}",
releases
.default_target
.as_ref()
.map_or_else(|| "none".to_owned(), TargetName::to_string)
);
println!("declaration: {}", declaration_state(&releases.declaration));
if releases.targets.is_empty() {
println!("targets: none");
return;
}
println!("targets:");
for target in &releases.targets {
println!(
" {}\t{}\t{}\t{}",
target.name,
target.style(),
describe(target),
source_of(releases, &target.name),
);
}
}
fn declaration_state(declaration: &DeclarationSource) -> String {
match declaration {
DeclarationSource::Declared { document, declared } => format!(
"declared: {count} target(s) in {document}",
count = declared.targets.len(),
document = document.display(),
),
DeclarationSource::Undeclared { looked_in } => format!(
"undeclared: no {file} in {looked_in}",
file = crate::declaration::FILE,
looked_in = looked_in.display(),
),
DeclarationSource::Unreadable { reason } => format!("unreadable: {reason}"),
}
}
fn source_of(releases: &RepositoryReleases, name: &TargetName) -> String {
releases
.sources
.get(name)
.map_or_else(|| "unknown".to_owned(), TargetSource::to_string)
}
fn release_discover(args: &ReleaseDiscoverArgs) -> Result<u8> {
let discovery = crate::release_discovery(&args.repo)?;
if args.json {
return print_json(&discovery);
}
print_releases(&discovery.releases);
if discovery.released.is_empty() {
return Ok(0);
}
println!("released:");
for release in &discovery.released {
println!(
" {}\t{}\t{}",
release.target,
release.style,
answered(&release.answer),
);
}
Ok(0)
}
fn answered(answer: &ReleaseAnswer) -> String {
match answer {
ReleaseAnswer::Released { version } => format!("released: {version}"),
ReleaseAnswer::NoRelease => "no release yet".to_owned(),
ReleaseAnswer::NotAnswered { reason } => format!("not answered: {reason}"),
}
}
fn describe(target: &ReleaseTarget) -> String {
match &target.release {
ReleaseMethod::Automated {
probe: Probe::Script { script, args, .. },
} => match args.is_empty() {
true => format!("script {}", script.display()),
false => format!("script {} {}", script.display(), args.join(" ")),
},
ReleaseMethod::Automated {
probe: Probe::Shell { shell, .. },
} => format!("shell {shell}"),
ReleaseMethod::HumanStep { action } => format!("action: {action}"),
}
}
fn release_declaration(args: &ReleaseDeclarationArgs) -> Result<u8> {
let declared = crate::read_release_declaration(&args.path)?;
if args.json {
return print_json(&declared);
}
println!("schema version: {}", declared.schema_version);
println!(
"probe: {}",
declared
.probe
.as_ref()
.map_or_else(|| "none".to_owned(), RepositoryPath::to_string)
);
println!("targets:");
for target in &declared.targets {
println!(" {}\t{}\t{}", target.name, target.id, target.what);
println!(" published by: {}", target.published_by);
if let Some(manifest) = target.manifest.as_ref() {
println!(" manifest: {manifest}");
}
if !target.covers.is_empty() {
println!(
" covers: {}",
target
.covers
.iter()
.map(RegistryId::to_string)
.collect::<Vec<_>>()
.join(", ")
);
}
if let Some(instructions) = target.adoption_instructions.as_ref() {
println!(" adoption instructions:");
for line in instructions.lines() {
println!(" {line}");
}
}
}
if !declared.retired.is_empty() {
println!("retired:");
for entry in &declared.retired {
println!(" {}\t{}", entry.id, entry.why);
}
}
Ok(0)
}
fn release_latest(args: &ReleaseLatestArgs) -> Result<u8> {
let answer = crate::release_latest(&args.repo, args.target.as_ref())?;
if args.json {
return print_json(&answer);
}
match answer {
ReleaseAnswer::Released { version } => println!("released: {version}"),
ReleaseAnswer::NoRelease => println!("no release yet"),
ReleaseAnswer::NotAnswered { reason } => println!("not answered: {reason}"),
}
Ok(0)
}
fn release_status(args: &ReleaseStatusArgs, providers: &Providers<'_>) -> Result<u8> {
let status = crate::release_status_with(providers, &args.reference, args.target.as_ref())?;
if args.json {
return print_json(&status);
}
match status {
ReleaseStatus::Released {
target,
style,
version,
source,
} => println!("released: {target} {version} ({style}, {source})"),
ReleaseStatus::NotReleased { at_landing, now } => println!(
"not released: at landing {landing}, now {now}",
landing = spell_baseline(&at_landing),
now = spell_version(&now),
),
ReleaseStatus::AwaitingHumanStep {
target,
action,
since,
} => println!("awaiting human step: {target} since {since}\n action: {action}"),
ReleaseStatus::NotAnswered { reason } => println!("not answered: {reason}"),
ReleaseStatus::NotLanded => println!("not landed"),
}
Ok(0)
}
fn release_acknowledge(args: &ReleaseAcknowledgeArgs) -> Result<u8> {
let recorded =
crate::acknowledge_release(&args.reference, &args.target, &args.version, args.supersede)?;
if args.json {
return print_json(&recorded);
}
print!("{}", render_acknowledgement(&recorded));
Ok(0)
}
fn render_acknowledgement(recorded: &Acknowledgement) -> String {
let mut rendered = format!(
"acknowledged: {target} {version} for landing {commit}\n identity: {identity}\n \
recorded at: {at} by {actor}\n",
target = recorded.target,
version = recorded.version,
commit = recorded.landing_commit,
identity = recorded.identity,
at = recorded.recorded_at,
actor = recorded.actor,
);
for replaced in &recorded.superseded {
rendered.push_str(&format!(
" superseded: {version} recorded at {at} by {actor}\n",
version = replaced.version,
at = replaced.recorded_at,
actor = replaced.actor,
));
}
rendered
}
fn spell_baseline(baseline: &Baseline) -> String {
match baseline {
Baseline::At { version } => version.clone(),
Baseline::NoRelease => "no release at landing".to_owned(),
}
}
fn spell_version(version: &str) -> &str {
match version.is_empty() {
true => "no release",
false => version,
}
}
fn print_json<T: serde::Serialize>(value: &T) -> Result<u8> {
println!("{}", serde_json::to_string(value).map_err(serialization)?);
Ok(0)
}
fn describe_match(criteria: &crate::rules::RuleMatch) -> String {
let mut parts = Vec::new();
if let Some(host) = &criteria.host {
parts.push(format!("host: {host}"));
}
if let Some(owner) = &criteria.owner {
parts.push(format!("owner: {owner}"));
}
if let Some(name) = &criteria.name {
parts.push(format!("name: {name}"));
}
if let Some(path) = &criteria.path {
parts.push(format!("path: {path}"));
}
format!("{{{}}}", parts.join(", "))
}
fn yes_or_no(answer: bool) -> &'static str {
if answer {
"yes"
} else {
"no"
}
}
fn serialization(failure: serde_json::Error) -> Error {
error::invalid(format!("cannot serialize the result: {failure}"))
}