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, PublishArgs, PublishBranchArgs, RecoverArgs, RecoverableArgs, RegisterArgs,
ReleaseAcknowledgeArgs, ReleaseCommand, ReleaseDeclarationArgs, ReleaseDiscoverArgs,
ReleaseLatestArgs, ReleaseStatusArgs, ReleaseTargetsArgs, ReposArgs, ResolveArgs,
RulesCheckArgs, RulesCommand, SessionCommand, SessionHoldersArgs, SessionOpenArgs,
SessionTokenArgs, StatusArgs, SweepArgs, SweepFormat, SyncArgs,
};
use crate::declaration::{RegistryId, RepositoryPath};
use crate::error::{self, Error, Result};
use crate::event::EventFilter;
use crate::host::ProtectionSource;
use crate::landed::Landed;
use crate::providers::Providers;
use crate::publish::{DraftReason, PublishOutcome, PublishRequest, Retention, Subject};
use crate::registry::Registry;
use crate::releases::{
Acknowledgement, Baseline, DeclarationSource, Probe, ReleaseAnswer, ReleaseMethod,
ReleaseStatus, ReleaseTarget, RepositoryReleases, TargetName, TargetSource,
};
use crate::session::{Lifecycle, Provenance, Scope, SessionRequest, SessionToken};
use crate::store::{self, Resolution};
use crate::stream::Stream;
use crate::{
git, guidance, import, integrate, lock, policy, provenance, publish, publish_branch, recover,
status, stream, sweep, workspace,
};
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::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),
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),
},
}
}
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::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 origin = args.origin.as_ref().map(url::Url::to_string);
let resolution = store::register(&args.path, origin.as_deref())?;
let registry = store::load()?;
let (file, source) = policy::load(®istry)?;
let resolved = policy::resolve_for(&file, &source, &resolution);
println!("{}", resolution.key);
println!(" alias: {}", resolution.alias);
print_policy(" ", &resolved);
println!(" gate: {}", resolution.identity.gate);
let coverage = store::merge_path_coverage(
&resolution,
&resolution.publication,
resolved.policy.publication,
);
println!(" merge-path coverage: {}", coverage.describe());
if coverage == store::Coverage::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: &policy::Resolved) {
println!(
"{indent}publication: {} (from {})",
policy::spell(resolved.policy.publication),
resolved.publication_from
);
println!(
"{indent}approvals: {} (from {})",
spell_approvals(resolved.policy.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 registry = store::load()?;
if registry.identities.is_empty() {
println!("no repositories registered");
return Ok(0);
}
let rules = if args.audit_gates {
Some(policy::load(®istry)?)
} else {
None
};
for (key, identity) in ®istry.identities {
println!("{key}\t{}", identity.gate);
let checkouts: Vec<(&String, &crate::registry::Checkout)> = registry
.checkouts
.iter()
.filter(|(_, checkout)| checkout.identity == *key)
.collect();
if let (true, Some((_, first))) = (args.audit_gates, checkouts.first()) {
println!(
" required checks: {}",
required_checks_line(key, first, providers)
);
}
for (alias, checkout) in checkouts {
println!(" {alias}\t{}", checkout.path.display());
if let Some((file, source)) = &rules {
let resolution = Resolution {
key: key.clone(),
identity: identity.clone(),
alias: alias.clone(),
publication: checkout.path.clone(),
};
let resolved = policy::resolve_for(file, source, &resolution);
print_policy(" ", &resolved);
let coverage = store::merge_path_coverage(
&resolution,
&checkout.path,
resolved.policy.publication,
);
println!(" merge-path coverage: {}", coverage.describe());
}
}
}
Ok(0)
}
fn required_checks_line(
key: &str,
checkout: &crate::registry::Checkout,
providers: &Providers<'_>,
) -> String {
let Some(slug) = crate::gh::slug(key) else {
return format!(
"none: {key:?} is not a {} repository, so no host answers for it",
crate::gh::HOST
);
};
let asked = git::default_branch(&checkout.path, "origin").and_then(|base| {
providers
.hosting
.for_repo(&slug)?
.required_checks_on(&base)
.map(|checks| (base, checks))
});
let (base, answer) = match asked {
Ok(asked) => asked,
Err(error) => return format!("unreadable — {error}"),
};
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 registry = store::load()?;
let resolution = store::resolve(®istry, &args.repo)?;
let identity = providers.vcs.resolve_identity(&args.repo)?;
debug_assert_eq!(identity, resolution.identity);
let (file, source) = policy::load(®istry)?;
let resolved = policy::resolve_for(&file, &source, &resolution);
println!(
"{}",
serde_json::json!({
"identity": resolution.key,
"alias": resolution.alias,
"origin": resolution.identity.origin,
"publication": policy::spell(resolved.policy.publication),
"approvals": spell_approvals(resolved.policy.approvals),
"gate": resolution.identity.gate,
"publication_checkout": resolution.publication.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(),
base: args.base.clone(),
execution_checkout: args.execution_checkout.clone(),
pool: args.pool,
overflow: args.overflow,
};
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 holders = crate::session_holders(&args.repo)?;
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()
);
}
}
Ok(0)
}
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 recover_branch(args: &RecoverArgs, providers: &Providers<'_>) -> Result<u8> {
let registry = store::load()?;
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(),
)?;
let token = format!("recover-{}", policy::branch_slug(&args.branch));
let mut stream = Stream::open(&token)?;
report_publication(recover::run(
®istry,
&args.repo,
&args.branch,
title,
body,
providers.hosting,
&mut stream,
))
}
fn publish_branch(args: &PublishBranchArgs, providers: &Providers<'_>) -> Result<u8> {
let registry = store::load()?;
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(),
)?;
let token = format!("publish-branch-{}", policy::branch_slug(&args.branch));
let mut stream = Stream::open(&token)?;
report_publication(publish_branch::run(
®istry,
&args.repo,
&args.branch,
title,
body,
args.policy,
providers.hosting,
&mut stream,
))
}
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 => resolve_here(®istry).ok(),
};
let scope = match &covered {
Some(resolution) => Scope::Repo(resolution.alias.clone()),
None => Scope::All,
};
let rows = match args.all {
true => providers.vcs.preserved(scope)?,
false => providers.vcs.recoverable(scope)?,
};
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; \
`onevcs recoverable --all` lists every preserved branch, each saying what \
became of its work and what says so.";
if args.json {
if let Some(scoped) = &scoped {
eprintln!("onevcs: this answer covers {scoped}. {widen}");
}
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 {
Some(scoped) => {
println!("{none} in {scoped}. Every branch of it has reached its base or a remote.")
}
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()),
}
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(),
));
}
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());
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),
]),
);
continue;
}
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}"),
}
}
if !args.all {
println!("{withheld}");
}
if scoped.is_some() {
println!("{widen}");
}
Ok(0)
}
fn report_status(args: &StatusArgs, providers: &Providers<'_>) -> Result<u8> {
let registry = store::load()?;
let report = status::run(®istry, &args.reference, providers.hosting)?;
if args.json {
println!("{}", serde_json::to_string(&report).map_err(serialization)?);
} else {
print!("{}", report.render());
}
Ok(0)
}
fn import_branch(args: &ImportArgs) -> Result<u8> {
let registry = store::load()?;
let imported = import::run(
®istry,
&args.repo,
&args.branch,
args.from.as_deref(),
args.r#as.as_deref(),
)?;
println!(
"{} {} in {} from {}, at {}",
match imported.wrote {
import::Wrote::Created => "imported",
import::Wrote::FastForwarded => "fast-forwarded",
import::Wrote::Unchanged => "already had",
},
imported.name,
imported.destination.display(),
imported.source.describe(),
imported.tip,
);
Ok(0)
}
fn integrate_branches(args: &IntegrateArgs) -> Result<u8> {
let registry = store::load()?;
let resolution = resolve_here(®istry)?;
let token = format!("integrate-{}", policy::branch_slug(&resolution.alias));
let mut stream = Stream::open(&token)?;
let outcome = integrate::run(&resolution, &args.branches, args.push, &mut stream)?;
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 registry = store::load()?;
let resolution = resolve_here(®istry)?;
let checkout = &resolution.publication;
let branch = match args.branch.clone() {
Some(branch) => workspace::Ref::try_from(branch).map_err(|reason| Error::Invalid {
reason: format!("{reason}: it is not a valid branch name"),
})?,
None => workspace::Ref::from_git(git::default_branch(checkout, "origin")?),
};
if git::current_branch(checkout)? != *branch {
return Err(Error::Invalid {
reason: format!(
"{} does not have {branch:?} checked out; sync only ever fast-forwards the \
branch a checkout is already on",
checkout.display()
),
});
}
let before = git::head_sha(checkout)?;
git::fetch(checkout, "origin")?;
git::merge_ff_only(checkout, &format!("origin/{branch}"))?;
let now = git::head_sha(checkout)?;
println!(
"{identity}: {branch} {moved} origin/{branch} at {now}, in {checkout}",
identity = resolution.key,
moved = match now == before {
true => "was already level with",
false => "fast-forwarded to",
},
checkout = checkout.display(),
);
Ok(0)
}
fn sweep_workspaces(args: &SweepArgs) -> Result<u8> {
let report = sweep::run(args.dry_run, 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 token = args.token.as_str();
let filter = args.filter.as_deref().map(load_filter).transpose()?;
let mut reader = stream::Reader::open(token)?;
let session = SessionToken(token.to_owned());
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 record in reader.records()? {
let line = record.text.clone();
if let Some(filter) = &filter {
let crate::event::Line::Known(known) = stream::attributed_record(record, token)?
else {
continue;
};
if !filter.matches(&known.envelope) {
continue;
}
}
writeln!(out, "{line}").map_err(|e| {
error::invalid(format!("cannot write the event stream for {token:?}: {e}"))
})?;
}
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!("{}", stream::read_artifact(id)?);
Ok(0)
}
fn rules_check(args: &RulesCheckArgs) -> Result<u8> {
let registry = store::load()?;
let resolution = store::resolve(®istry, &args.repo)?;
let (file, source) = policy::load(®istry)?;
let normalized = store::normalize(&resolution.identity.origin);
let resolved = policy::resolve(&file, &source, &normalized, &resolution.publication);
println!("repo: {}", args.repo);
println!("identity: {}", resolution.key);
println!("checkout: {}", resolution.publication.display());
println!("rules: {}", resolved.source);
match &resolved.matched {
Some(matched) => println!(
"matched: rule {} {}",
matched.index,
describe_match(&matched.criteria)
),
None => println!("matched: no rule; the default applies"),
}
print_policy("", &resolved);
println!(
"trailer_prefix: {} (from {})",
provenance::from_rules(&file).prefix(),
if file.trailer_prefix.is_some() {
"the rules file"
} else {
"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) -> Result<u8> {
let status = crate::release_status(&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 resolve_here(registry: &Registry) -> Result<Resolution> {
let here = std::env::current_dir()
.map_err(|e| error::invalid(format!("cannot read the current directory: {e}")))?;
let canonical = std::fs::canonicalize(&here).unwrap_or(here);
let mut candidate: Option<&Path> = Some(canonical.as_path());
while let Some(path) = candidate {
for (alias, checkout) in ®istry.checkouts {
if checkout.path == path {
return store::resolve(registry, alias);
}
}
candidate = path.parent();
}
Err(Error::Invalid {
reason: format!(
"{} is not inside a registered checkout; register it with `onevcs register PATH`",
canonical.display()
),
})
}
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}"))
}