//! `oxy`: command-line interface for Oxygen. use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; use oxy_core::repo::{ChangeStatus, Repo, SignatureStatus}; use std::path::PathBuf; #[derive(Parser)] #[command(name = "oxy", version, about = "Oxygen: breathing fresh air into version control")] struct Cli { #[command(subcommand)] cmd: Cmd, } #[derive(Subcommand)] enum Cmd { /// Create a repository in PATH (default: current directory) Init { path: Option }, /// Show the working copy: changed paths and conflicts Status, /// Record the working copy as a change on the current bookmark Commit { #[arg(short, long)] message: String, /// Extra explicit dependencies (ChangeId prefix, #hash prefix) #[arg(long = "depends-on")] depends_on: Vec, }, /// List changes reachable from the bookmark and the working copy Log, /// Show what the working copy changes against its base Diff, /// Show one change (ChangeId prefix, #hash prefix, or @) Show { change: String }, /// Move the working copy to the state formed by CHANGE... (default: the bookmark heads) Checkout { changes: Vec, /// Throw away uncommitted working-copy changes #[arg(long)] discard: bool, }, /// Report which conflicts of the working copy's base are resolved by the current files Resolve, /// Undo the last operation Undo, /// Manage the signing identity Identity { #[command(subcommand)] cmd: IdentityCmd, }, /// Verify the signatures on a change (default: every change in the log) Verify { change: Option }, /// Set a repository configuration key (author.name, author.email, author.key, core.sign, workspace.bookmark) Config { key: String, value: String }, /// List operations #[command(name = "op")] Op { #[command(subcommand)] cmd: OpCmd, }, } #[derive(Subcommand)] enum OpCmd { Log, } #[derive(Subcommand)] enum IdentityCmd { /// Generate a composite Ed25519 + ML-DSA-65 key pair and make it the author Create { #[arg(long)] name: Option, #[arg(long)] email: Option, }, /// Show the configured identity Show, } fn signature_line(repo: &Repo, h: &oxy_core::hash::ChangeHash) -> Result { Ok(match repo.verify_change(h)? { SignatureStatus::Unsigned => "unsigned".to_string(), SignatureStatus::Valid { key, name, email } => format!("signed by {name} <{email}> key {}", key.short(12)), SignatureStatus::Invalid(why) => format!("INVALID signature: {why}"), }) } fn main() { match run() { Ok(code) => std::process::exit(code), Err(e) => { eprintln!("error: {e:#}"); std::process::exit(1); } } } fn open() -> Result { Repo::open(&std::env::current_dir()?).context("opening repository") } fn short_id(c: &oxy_core::objects::Change) -> String { c.change_id.short(8) } fn when(ms: u64) -> String { let secs = (ms / 1000) as i64; let days = secs.div_euclid(86_400); let rem = secs.rem_euclid(86_400); // Civil-from-days (Howard Hinnant), UTC. let z = days + 719_468; let era = z.div_euclid(146_097); let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = if m <= 2 { y + 1 } else { y }; format!("{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}Z", rem / 3600, (rem % 3600) / 60, rem % 60) } fn run() -> Result { let cli = Cli::parse(); match cli.cmd { Cmd::Init { path } => { let path = path.unwrap_or(std::env::current_dir()?); let repo = Repo::init(&path)?; println!("Initialized oxy repository in {}", repo.root().display()); println!("Bookmark: {} Author: {} <{}>", repo.config.bookmark, repo.config.author_name, repo.config.author_email); Ok(0) } Cmd::Status => { let repo = open()?; let snap = repo.snapshot("oxy status")?; let heads = repo.bookmark_heads()?; println!( "Working copy: {} on {} ({} head{})", snap.wc.change_id.short(8), repo.config.bookmark, heads.len(), if heads.len() == 1 { "" } else { "s" } ); if snap.statuses.is_empty() { println!("The working copy is clean."); } for s in &snap.statuses { let tag = match s.status { ChangeStatus::Added => "A", ChangeStatus::Modified => "M", ChangeStatus::Removed => "D", ChangeStatus::ModeChanged => "X", ChangeStatus::ConflictEdited => "C!", ChangeStatus::Resolved => "R", }; println!("{tag:2} {}", s.path); } let mut code = 0; if snap.base_snapshot.is_conflicted() { println!("\nConflicts in the base state (edit the marked files, or the .oxy-conflict-N candidates, then commit):"); for c in &snap.base_snapshot.tree.conflicts { println!(" tree: {c:?}"); } for (p, f) in &snap.base_snapshot.files { if f.is_conflicted() { let state = match snap.statuses.iter().find(|s| s.path == *p).map(|s| s.status) { Some(ChangeStatus::Resolved) => "resolved in working copy", Some(ChangeStatus::ConflictEdited) => "markers remain", _ => "unresolved", }; println!(" {p} ({state})"); } } code = 2; } Ok(code) } Cmd::Commit { message, depends_on } => { let repo = open()?; let mut deps = Vec::new(); for d in &depends_on { deps.push(repo.resolve_change(d)?); } let h = repo.commit(&message, &deps)?; let c = repo.store().get_change(&h)?; println!("Committed {} #{}: {}", short_id(&c), h.short(12), message.lines().next().unwrap_or("")); Ok(0) } Cmd::Log => { let repo = open()?; repo.snapshot("oxy log")?; for e in repo.log()? { let c = &e.change; let mut flags = Vec::new(); if e.is_working_copy { flags.push("working copy"); } if e.is_head { flags.push("head"); } let flags = if flags.is_empty() { String::new() } else { format!(" ({})", flags.join(", ")) }; println!("{} #{}{}", short_id(c), e.hash.short(12), flags); println!(" {}", when(c.hlc.wall_ms)); if !c.deps.is_empty() { println!(" deps: {}", c.deps.iter().map(|d| format!("#{}", d.short(12))).collect::>().join(" ")); } let msg = if c.message.is_empty() { "(no description)" } else { &c.message }; for line in msg.lines() { println!(" {line}"); } println!(); } Ok(0) } Cmd::Diff => { let repo = open()?; let snap = repo.snapshot("oxy diff")?; print!("{}", oxy_core::repo::render_change(&snap.wc, &snap.base, &snap.base_snapshot)); Ok(0) } Cmd::Show { change } => { let repo = open()?; repo.snapshot("oxy show")?; let h = repo.resolve_change(&change)?; let c = repo.store().get_change(&h)?; println!("change {} #{}", short_id(&c), h); println!("kind: {:?}", c.kind); println!("date: {}", when(c.hlc.wall_ms)); println!("deps: {}", c.deps.iter().map(|d| format!("#{}", d.short(12))).collect::>().join(" ")); println!("sig: {}", signature_line(&repo, &h)?); println!("\n{}\n", if c.message.is_empty() { "(no description)" } else { &c.message }); print!("{}", repo.describe_change(&h)?); Ok(0) } Cmd::Checkout { changes, discard } => { let repo = open()?; let heads = if changes.is_empty() { repo.bookmark_heads()? } else { changes.iter().map(|c| repo.resolve_change(c)).collect::, _>>()? }; let spec = if changes.is_empty() { repo.config.bookmark.clone() } else { changes.join(" ") }; let r = repo.checkout(&heads, discard, &format!("oxy checkout {spec}"))?; println!("Working copy now on {spec}: {} file(s) written, {} removed", r.written, r.removed); if r.conflicted { println!("This state has conflicts; see oxy status."); } Ok(0) } Cmd::Resolve => { let repo = open()?; let snap = repo.snapshot("oxy resolve")?; if !snap.base_snapshot.is_conflicted() { println!("The working copy's base has no conflicts."); return Ok(0); } let mut remaining = 0; for (p, f) in &snap.base_snapshot.files { if !f.is_conflicted() { continue; } match snap.statuses.iter().find(|s| s.path == *p).map(|s| s.status) { Some(ChangeStatus::Resolved) => println!("resolved {p}"), Some(ChangeStatus::ConflictEdited) => { remaining += 1; println!("markers {p}"); } _ => { remaining += 1; println!("unresolved {p}"); } } } for c in &snap.base_snapshot.tree.conflicts { remaining += 1; println!("tree {c:?}"); } if remaining == 0 { println!("All conflicts are resolved; `oxy commit` will record the resolution."); Ok(0) } else { Ok(2) } } Cmd::Undo => { let repo = open()?; let op = repo.undo()?; println!("Undid the last operation; now at: {}", op.command); Ok(0) } Cmd::Identity { cmd: IdentityCmd::Create { name, email } } => { let mut repo = open()?; let name = name.unwrap_or_else(|| repo.config.author_name.clone()); let email = email.unwrap_or_else(|| repo.config.author_email.clone()); let (key, _) = repo.create_identity(&name, &email)?; println!("Created identity {name} <{email}>"); println!("KeyId: {key}"); println!("Secret key stored under {}", oxy_core::repo::default_keys_dir().display()); if !repo.config.sign_changes { println!("Changes will carry this author; run `oxy config core.sign change` to sign each commit."); } Ok(0) } Cmd::Identity { cmd: IdentityCmd::Show } => { let repo = open()?; match repo.config.author_key { None => println!("No identity configured; run `oxy identity create`."), Some(key) => { let id = repo.identity(&key)?; println!("{} <{}>", id.name, id.email); println!("KeyId: {key}"); println!("Created: {}", when(id.created)); println!("Secret: {}", if repo.secret_key()?.is_some() { "available" } else { "NOT FOUND in key directory" }); println!("Signing: {}", if repo.config.sign_changes { "every change (core.sign = change)" } else { "at push via seals (core.sign = seal)" }); } } Ok(0) } Cmd::Verify { change } => { let repo = open()?; let hashes: Vec = match change { Some(c) => vec![repo.resolve_change(&c)?], None => repo.log()?.into_iter().filter(|e| !e.is_working_copy).map(|e| e.hash).collect(), }; let mut bad = 0; for h in hashes { let c = repo.store().get_change(&h)?; let line = signature_line(&repo, &h)?; if line.starts_with("INVALID") { bad += 1; } println!("{} #{} {}", short_id(&c), h.short(12), line); } Ok(if bad > 0 { 1 } else { 0 }) } Cmd::Config { key, value } => { let mut repo = open()?; repo.config.set(&key, &value)?; repo.save_config()?; println!("{key} = {value}"); Ok(0) } Cmd::Op { cmd: OpCmd::Log } => { let repo = open()?; for (i, (r, op)) in repo.op_log()?.into_iter().enumerate() { println!("{}{} {} {}", if i == 0 { "@ " } else { " " }, r.short(12), when(op.hlc.wall_ms), op.command); } Ok(0) } } .map_err(|e: anyhow::Error| e) .or_else(|e| { if e.to_string().is_empty() { bail!("unknown error") } else { Err(e) } }) }