//! `oxy`: command-line interface for Oxygen. use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; use oxy_core::repo::{ChangeStatus, Repo}; 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, }, /// Undo the last operation Undo, /// List operations #[command(name = "op")] Op { #[command(subcommand)] cmd: OpCmd, }, } #[derive(Subcommand)] enum OpCmd { Log, } 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!", }; println!("{tag:2} {}", s.path); } let mut code = 0; if snap.base_snapshot.is_conflicted() { println!("\nConflicts in the base state:"); for c in &snap.base_snapshot.tree.conflicts { println!(" tree: {c:?}"); } for (p, f) in &snap.base_snapshot.files { if f.is_conflicted() { println!(" {p}"); } } 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!("\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::Undo => { let repo = open()?; let op = repo.undo()?; println!("Undid the last operation; now at: {}", op.command); 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) } }) }