//! `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 }, /// Clone a repository from a local path Clone { url: String, dest: Option }, /// Manage remotes Remote { #[command(subcommand)] cmd: RemoteCmd, }, /// Fetch every bookmark of a remote into its tracking references Fetch { remote: Option }, /// Fetch, then union the remote bookmark into the local one (no merge change) Pull { remote: Option }, /// Push the current bookmark to a remote Push { remote: Option, /// Allow dropping heads the remote has #[arg(long)] force: bool, }, /// 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 RemoteCmd { Add { name: String, url: String }, Remove { name: String }, List, } #[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.authenticate_change(h)? { SignatureStatus::Unsigned => "unsigned".to_string(), SignatureStatus::Valid { key, name, email, sealed } => { format!("{} by {name} <{email}> key {}", if sealed { "sealed" } else { "signed" }, key.short(12)) } SignatureStatus::Invalid(why) => format!("INVALID signature: {why}"), }) } fn main() { // Behave like other CLI tools when output is piped into `head`: die // quietly on SIGPIPE instead of panicking on a failed write. #[cfg(unix)] unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL); } 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::Clone { url, dest } => { let dest = dest.unwrap_or_else(|| { let base = url.trim_end_matches('/').rsplit('/').next().unwrap_or("repo"); PathBuf::from(base) }); let repo = Repo::clone(&url, &dest)?; let heads = repo.bookmark_heads()?; println!("Cloned into {} ({} on {}: {} head{})", repo.root().display(), url, repo.config.bookmark, heads.len(), if heads.len() == 1 { "" } else { "s" }); Ok(0) } Cmd::Remote { cmd } => { let mut repo = open()?; match cmd { RemoteCmd::Add { name, url } => { repo.config.set(&format!("remote.{name}.url"), &url)?; repo.save_config()?; println!("Added remote {name} = {url}"); } RemoteCmd::Remove { name } => { repo.config.set(&format!("remote.{name}.url"), "")?; repo.save_config()?; println!("Removed remote {name}"); } RemoteCmd::List => { let view = repo.view()?; for (name, url) in &repo.config.remotes { println!("{name}\t{url}"); if let Some(bms) = view.remotes.get(name) { for (bm, st) in bms { let heads = repo.store().get_state(st).map(|s| s.heads.len()).unwrap_or(0); println!(" {bm}: {heads} head{}", if heads == 1 { "" } else { "s" }); } } } } } Ok(0) } Cmd::Fetch { remote } => { let repo = open()?; let remote = remote.unwrap_or_else(|| "origin".into()); for (name, heads, n) in repo.fetch(&remote)? { println!("{remote}/{name}: {} head{}, {n} new change{}", heads.len(), if heads.len() == 1 { "" } else { "s" }, if n == 1 { "" } else { "s" }); } Ok(0) } Cmd::Pull { remote } => { let repo = open()?; let remote = remote.unwrap_or_else(|| "origin".into()); let r = repo.pull(&remote)?; if !r.changed { println!("Already up to date."); } else { println!("{} now has {} head{}{}", repo.config.bookmark, r.heads.len(), if r.heads.len() == 1 { "" } else { "s" }, if r.checked_out { "; working copy updated" } else { "; run `oxy checkout` to update the working copy" }); let snap = repo.snapshot("oxy pull")?; if snap.base_snapshot.is_conflicted() { println!("The result has conflicts; see oxy status."); } } Ok(0) } Cmd::Push { remote, force } => { let repo = open()?; let remote = remote.unwrap_or_else(|| "origin".into()); let mut code = 0; for r in repo.push(&remote, force)? { if r.ok { println!("{remote}/{}: updated", r.name); } else { println!("{remote}/{}: rejected: {}", r.name, r.reason); code = 1; } } Ok(code) } 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) } }) }