//! Commands that act on the local repository and working copy. use super::format::{author_name, deps_line, deps_of, plural, short_id, short_meta_id, signature_line, signature_mark, subject_of, when}; use super::open; use super::ui; use anyhow::Result; use oxy_core::objects::ChangeKind; use oxy_core::repo::{render_change, ChangeStatus, Repo}; use std::path::PathBuf; pub fn init(path: Option) -> Result { let path = path.unwrap_or(std::env::current_dir()?); let repo = Repo::init(&path)?; println!("{} oxy repository in {}", ui::done("Initialized"), ui::path(repo.root().display())); println!("{}", ui::kv("bookmark", ui::bookmark(&repo.config.bookmark))); println!("{}", ui::kv("author", format!("{} <{}>", ui::author(&repo.config.author_name), repo.config.author_email))); Ok(0) } fn status_tag(s: ChangeStatus) -> &'static str { match s { ChangeStatus::Added => "A", ChangeStatus::Modified => "M", ChangeStatus::Removed => "D", ChangeStatus::Renamed => "R", ChangeStatus::ModeChanged => "X", ChangeStatus::ConflictEdited => "C!", ChangeStatus::Resolved => "V", } } pub fn status(json: bool) -> Result { let repo = open()?; let sp = ui::spinner("Snapshotting the working copy…"); let snap = repo.snapshot("oxy status")?; let heads = repo.bookmark_heads()?; sp.clear(); if json { println!("{}", repo.status_json(&snap)?); return Ok(if snap.base_snapshot.is_conflicted() { 2 } else { 0 }); } println!( "{} {}{}working copy {}{}{}", ui::dim("On"), ui::bookmark(&repo.config.bookmark), ui::sep(), ui::id(snap.wc.change_id.short(8)), ui::sep(), ui::dim(plural(heads.len(), "head")) ); if snap.statuses.is_empty() { println!(" {} {}", ui::ok(ui::glyph("✔", "*")), ui::dim("nothing to commit, working copy clean")); } else { println!(); for s in &snap.statuses { println!(" {} {}", ui::status_mark(status_tag(s.status)), ui::path(&s.path)); } } if !snap.base_snapshot.is_conflicted() { return Ok(0); } println!(); println!("{} {}", ui::section(ui::err("Conflicts")), ui::dim("edit the marked files, or the .oxy-conflict-N candidates, then commit")); for c in &snap.base_snapshot.tree.conflicts { println!(" {} tree: {c:?}", ui::err(ui::glyph("✱", "!"))); } 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) => ui::ok(format!("{} resolved in working copy", ui::glyph("✔", "*"))), Some(ChangeStatus::ConflictEdited) => ui::warn(format!("{} markers remain", ui::glyph("‼", "!"))), _ => ui::err(format!("{} unresolved", ui::glyph("✱", "!"))), }; println!(" {} {state}", ui::path(p)); } } Ok(2) } pub fn commit(message: &str, depends_on: &[String]) -> Result { let repo = open()?; let mut deps = Vec::new(); for d in depends_on { deps.push(repo.resolve_change(d)?); } let sp = ui::spinner("Committing…"); let h = repo.commit(message, &deps)?; let c = repo.store().get_change(&h)?; sp.done(&format!("{} {} {} {}", ui::done("Committed"), ui::id(short_id(&c)), ui::hash(format!("#{}", h.short(12))), ui::subject(subject_of(message)))); Ok(0) } /// The log: one entry per change, newest first, joined by a rail. The /// default view is the subject and a metadata line; `verbose` adds the full /// message, dependencies, and the authenticating key. pub fn log(limit: Option, verbose: bool) -> Result { let repo = open()?; let snap = repo.snapshot("oxy log")?; let conflicted = snap.base_snapshot.is_conflicted(); let entries = repo.log()?; let total = entries.len(); let shown = limit.unwrap_or(total).min(total); let rail = ui::rail(); for (i, e) in entries.iter().take(shown).enumerate() { let c = &e.change; let bullet = ui::log_bullet(e.is_working_copy, e.is_head, e.is_working_copy && conflicted); let mut labels = Vec::new(); if e.is_working_copy { labels.push(ui::warn("working copy")); } if c.kind == ChangeKind::Resolution { labels.push(ui::warn("resolution")); } let labels = if labels.is_empty() { String::new() } else { format!(" {}{}{}", ui::dim("("), labels.join(ui::dim(", ").as_str()), ui::dim(")")) }; let subject = if e.is_working_copy && c.message.is_empty() { ui::dim(if snap.statuses.is_empty() { "(clean)" } else { "(uncommitted changes)" }) } else { ui::subject(subject_of(&c.message)) }; println!("{bullet} {}{labels} {subject}", ui::id(short_meta_id(c))); let meta = format!( "{}{}{}{}{}{}{}", ui::hash(format!("#{}", e.hash.short(12))), ui::sep(), ui::author(author_name(&repo, c)), ui::sep(), ui::dim(ui::ago(c.hlc.wall_ms)), ui::sep(), if e.is_working_copy { ui::dim("not yet committed") } else { signature_mark(&repo, &e.hash)? } ); println!("{rail} {meta}"); if verbose { if !e.is_working_copy { println!("{rail} {}", ui::dim(format!("{} {}", when(c.hlc.wall_ms), signature_line(&repo, &e.hash)?))); } if !c.deps.is_empty() { println!("{rail} {} {}", ui::dim(ui::glyph("↳", "->")), ui::dim(deps_of(&c.deps))); } let body: Vec<&str> = c.message.lines().skip(1).skip_while(|l| l.trim().is_empty()).collect(); if !body.is_empty() { println!("{rail}"); for line in body { println!("{rail} {line}"); } } } if i + 1 < shown { println!("{rail}"); } } if shown < total { println!("{}", ui::dim(format!("{} {} more; pass -n to see them", ui::glyph("…", "..."), total - shown))); } Ok(0) } pub fn diff() -> Result { let repo = open()?; let snap = repo.snapshot("oxy diff")?; print!("{}", render_change(&snap.wc, &snap.base, &snap.base_snapshot)); Ok(0) } pub fn show(change: &str) -> Result { let repo = open()?; repo.snapshot("oxy show")?; let h = repo.resolve_change(change)?; let c = repo.store().get_change(&h)?; println!("{} {} {}", ui::section("change"), ui::id(short_id(&c)), ui::hash(format!("#{h}"))); println!("{}", ui::kv("kind", format!("{:?}", c.kind).to_lowercase())); println!("{}", ui::kv("author", ui::author(author_name(&repo, &oxy_core::state::ChangeMeta::of(&c))))); println!("{}", ui::kv("date", format!("{} {}", when(c.hlc.wall_ms), ui::dim(format!("({})", ui::ago(c.hlc.wall_ms)))))); println!("{}", ui::kv("deps", ui::dim(deps_line(&c)))); println!("{}", ui::kv("sig", signature_line(&repo, &h)?)); println!(); for line in (if c.message.is_empty() { "(no description)" } else { &c.message }).lines() { println!(" {line}"); } println!(); print!("{}", repo.describe_change(&h)?); Ok(0) } pub fn checkout(changes: &[String], discard: bool) -> Result { let repo = open()?; let heads = if changes.is_empty() { repo.bookmark_heads()? } else { let mut heads = Vec::new(); for c in changes { heads.extend(repo.resolve_heads(c)?); } heads }; let spec = if changes.is_empty() { repo.config.bookmark.clone() } else { changes.join(" ") }; let sp = ui::spinner(&format!("Checking out {spec}…")); let r = repo.checkout(&heads, discard, &format!("oxy checkout {spec}"))?; sp.done(&format!("{} on {}: {} written, {} removed", ui::done("Working copy"), ui::bookmark(&spec), plural(r.written, "file"), r.removed)); for n in &r.notes { println!(" {} {n}", ui::note()); } if r.conflicted { println!(" {} {}", ui::caution(), ui::warn("this state has conflicts; see oxy status")); } Ok(0) } pub fn resolve() -> Result { 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) } } pub fn undo() -> Result { let repo = open()?; let op = repo.undo()?; println!("{} now at: {}", ui::done("Undone;"), ui::dim(&op.command)); Ok(0) } pub fn op_log() -> Result { let repo = open()?; for (i, (r, op)) in repo.op_log()?.into_iter().enumerate() { let bullet = if i == 0 { ui::ok(ui::glyph("●", "@")) } else { ui::dim(ui::glyph("○", "o")) }; let cmd = subject_of(&op.command).split("\\n").next().unwrap_or(""); println!("{bullet} {}{}{} {}", ui::hash(r.short(12)), ui::sep(), ui::dim(format!("{:<16}", ui::ago(op.hlc.wall_ms))), if i == 0 { ui::subject(cmd) } else { cmd.to_string() }); } Ok(0) } pub fn gc(now: bool) -> Result { let repo = open()?; let sp = ui::spinner("Packing and pruning…"); let r = repo.gc(now)?; sp.clear(); if r.packed.objects > 0 { println!( "Packed {}: {} bytes -> {} bytes ({:.0}%).", plural(r.packed.objects, "loose object"), r.packed.raw_bytes, r.packed.packed_bytes, 100.0 * r.packed.packed_bytes as f64 / r.packed.raw_bytes.max(1) as f64 ); } println!("{} reachable; pruned {} ({} bytes){}{}; {} present.", plural(r.reachable, "object"), plural(r.pruned.objects, "unreachable object"), r.pruned.bytes, if r.pruned.deferred > 0 { format!("; {} deferred by the grace period (use --now)", plural(r.pruned.deferred, "object")) } else { String::new() }, if r.merged_packs > 0 { format!("; merged {} into one", plural(r.merged_packs, "pack")) } else { String::new() }, plural(repo.store().pack_count(), "pack") ); Ok(0) } /// The single head the working copy sits on, for commands that default to it. fn current_head(repo: &Repo) -> Result { let snap = repo.snapshot("oxy")?; let base = snap.base.maximal(&snap.wc.deps); match base.as_slice() { [h] => Ok(*h), _ => anyhow::bail!("the working copy sits on {} heads; name the change explicitly", base.len()), } } pub fn describe(message: &str, change: Option<&str>) -> Result { let repo = open()?; let h = match change { Some(c) => repo.resolve_change(c)?, None => current_head(&repo)?, }; let new = repo.describe(&h, message)?; let c = repo.store().get_change(&new)?; println!("{} {} {} {}", ui::done("Described"), ui::id(short_id(&c)), ui::hash(format!("#{}", new.short(12))), ui::subject(subject_of(message))); Ok(0) } pub fn amend(message: Option<&str>, into: Option<&str>) -> Result { let repo = open()?; let new = match into { Some(spec) => { let target = repo.resolve_change(spec)?; repo.amend_into(&target, message)? } None => repo.amend(message)?, }; let c = repo.store().get_change(&new)?; println!("{} {} {} {}", ui::done("Amended"), ui::id(short_id(&c)), ui::hash(format!("#{}", new.short(12))), ui::subject(subject_of(&c.message))); Ok(0) } pub fn abandon(change: &str) -> Result { let repo = open()?; let h = repo.resolve_change(change)?; let c = repo.store().get_change(&h)?; let notes = repo.abandon(&h)?; println!("{} {} {} {}", ui::done("Abandoned"), ui::id(short_id(&c)), ui::hash(format!("#{}", h.short(12))), ui::dim("its content remains on disk as uncommitted work if the working copy sat on it")); for n in notes { println!(" {} {n}", ui::note()); } Ok(0) } pub fn checkpoint() -> Result { let repo = open()?; let sp = ui::spinner("Writing checkpoint…"); let (r, changes, bytes) = repo.checkpoint()?; sp.done(&format!("{} {} of {} ({} bytes) for {}", ui::done("Checkpoint"), ui::hash(format!("#{}", r.short(12))), plural(changes, "change"), bytes, ui::id(&repo.config.bookmark))); Ok(0) } /// For shell prompts: silent (exit 1) outside a repository, otherwise one line. pub fn prompt(format: &str) -> Result { let Ok(repo) = Repo::open(&std::env::current_dir()?) else { return Ok(1) }; let info = repo.quick_status()?; println!("{}", info.format(format)); Ok(0) } pub fn sparse(paths: &[String]) -> Result { let mut repo = open()?; if paths.is_empty() { if repo.config.sparse_paths.is_empty() { println!("No sparse filter: the whole tree is materialized."); } else { println!("{}", ui::heading("Sparse filter")); for p in &repo.config.sparse_paths { println!(" {}", ui::path(p)); } } return Ok(0); } let snap = repo.snapshot("oxy sparse")?; if !snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty() { anyhow::bail!("the working copy has changes; commit them before changing the sparse filter"); } repo.config.set("filter.paths", &paths.join(","))?; repo.save_config()?; let heads = repo.bookmark_heads()?; let sp = ui::spinner("Re-checking out with the new filter…"); let r = repo.checkout(&heads, true, "oxy sparse (checkout)")?; sp.done(&format!("{} {}: {} written, {} removed", ui::ok("Sparse filter set to"), ui::path(paths.join(",")), plural(r.written, "file"), r.removed)); Ok(0) } pub fn tag_create(name: &str, changes: &[String], message: &str) -> Result { let repo = open()?; repo.snapshot("oxy tag")?; let heads = if changes.is_empty() { repo.bookmark_heads()? } else { let mut heads = Vec::new(); for c in changes { heads.extend(repo.resolve_heads(c)?); } heads }; let t = repo.tag_create(name, &heads, message)?; println!( "{} {} {} {} ({}){}", ui::done("Tagged"), ui::bookmark(&t.name), ui::dim(ui::glyph("→", "->")), ui::hash(format!("#{}", t.state.short(12))), plural(t.heads.len(), "head"), t.signer.map(|s| format!(", signed by {s}")).unwrap_or_else(|| ui::dim(", unsigned")) ); Ok(0) } pub fn tag_list() -> Result { let repo = open()?; let tags = repo.tags()?; if tags.is_empty() { println!("{}", ui::dim("No tags.")); } for t in tags { println!( "{} {} {}{}{}{}", ui::ok(ui::glyph("⚑", "#")), ui::bookmark(&t.name), ui::hash(format!("#{}", t.state.short(12))), ui::sep() + &ui::dim(plural(t.heads.len(), "head")), if t.message.is_empty() { String::new() } else { format!(" {}", ui::subject(subject_of(&t.message))) }, t.signer.map(|s| ui::sep() + &ui::ok(format!("{} {s}", ui::glyph("✔", "v")))).unwrap_or_default() ); } Ok(0) } pub fn workspace_add(path: &std::path::Path, changes: &[String]) -> Result { let repo = open()?; let heads = if changes.is_empty() { None } else { let mut heads = Vec::new(); for c in changes { heads.extend(repo.resolve_heads(c)?); } Some(heads) }; let sp = ui::spinner(&format!("Creating workspace at {}…", path.display())); let ws = repo.workspace_add(path, heads.as_deref())?; sp.done(&format!("{} {} (workspace {})", ui::done("Workspace created at"), ui::path(ws.root().display()), ui::id(ws.workspace().short(8)))); Ok(0) } pub fn workspace_list() -> Result { let repo = open()?; for w in repo.workspaces()? { println!( "{} {} {}{}", if w.current { ui::ok(ui::glyph("●", "*")) } else { ui::dim(ui::glyph("○", "o")) }, ui::id(w.id.short(8)), ui::path(w.path.display()), w.working_copy.map(|h| ui::sep() + &ui::dim(format!("working copy #{}", h.short(12)))).unwrap_or_else(|| ui::sep() + &ui::warn("not in view")) ); } Ok(0) } pub fn workspace_forget(spec: &str) -> Result { let repo = open()?; let all = repo.workspaces()?; let target = std::fs::canonicalize(spec).ok(); let matches: Vec<_> = all.iter().filter(|w| w.id.starts_with_hex(spec) || target.as_ref().is_some_and(|t| *t == w.path)).collect(); let w = match matches.as_slice() { [w] => *w, [] => anyhow::bail!("no workspace matches {spec:?}"), _ => anyhow::bail!("{spec:?} is ambiguous"), }; repo.workspace_forget(&w.id)?; println!("{} workspace {} at {}; its files were left in place.", ui::done("Forgot"), ui::id(w.id.short(8)), ui::path(w.path.display())); Ok(0) } pub fn lock(path: &str) -> Result { let repo = open()?; repo.lock_path(path)?; println!("{} {} {}", ui::done("Locked"), ui::path(path), ui::dim("(advisory, local)")); Ok(0) } pub fn unlock(path: &str) -> Result { let repo = open()?; repo.unlock_path(path)?; println!("{} {}", ui::done("Unlocked"), ui::path(path)); Ok(0) } pub fn split(change: &str, paths: &[String]) -> Result { let repo = open()?; let h = repo.resolve_change(change)?; let (h1, h2) = repo.split(&h, paths)?; let c1 = repo.store().get_change(&h1)?; let c2 = repo.store().get_change(&h2)?; println!("{} into {} {} and {} {}", ui::done("Split"), ui::id(short_id(&c1)), ui::hash(format!("#{}", h1.short(12))), ui::id(short_id(&c2)), ui::hash(format!("#{}", h2.short(12)))); Ok(0) } fn heads_from(repo: &Repo, changes: &[String]) -> Result> { let mut heads = Vec::new(); for c in changes { heads.extend(repo.resolve_heads(c)?); } Ok(heads) } pub fn bookmark_create(name: &str, changes: &[String]) -> Result { let repo = open()?; repo.snapshot("oxy bookmark create")?; let heads = if changes.is_empty() { None } else { Some(heads_from(&repo, changes)?) }; let heads = repo.bookmark_create(name, heads.as_deref())?; println!("{} bookmark {} {} {}", ui::done("Created"), ui::bookmark(name), ui::dim(ui::glyph("→", "->")), ui::dim(plural(heads.len(), "head"))); println!(" {} {}", ui::note(), ui::dim(format!("switch to it with oxy bookmark switch {name}"))); Ok(0) } pub fn bookmark_set(name: &str, changes: &[String]) -> Result { let repo = open()?; repo.snapshot("oxy bookmark set")?; let heads = heads_from(&repo, changes)?; let heads = repo.bookmark_set(name, &heads)?; println!("{} bookmark {} {} {}", ui::done("Moved"), ui::bookmark(name), ui::dim(ui::glyph("→", "->")), ui::dim(plural(heads.len(), "head"))); Ok(0) } pub fn bookmark_delete(name: &str) -> Result { let repo = open()?; repo.bookmark_delete(name)?; println!("{} bookmark {} {}", ui::done("Deleted"), ui::bookmark(name), ui::dim("(its changes stay until unreachable)")); Ok(0) } pub fn bookmark_list() -> Result { let repo = open()?; for b in repo.bookmarks()? { let mark = if b.current { ui::ok(ui::glyph("●", "*")) } else { ui::dim(ui::glyph("○", "o")) }; let mut tail = String::new(); if let Some(n) = b.ahead.filter(|n| *n > 0) { tail += &ui::ok(format!(" {}{n}", ui::glyph("↑", "+"))); } if let Some(n) = b.behind.filter(|n| *n > 0) { tail += &ui::warn(format!(" {}{n}", ui::glyph("↓", "-"))); } let head = b.heads.first().map(|h| repo.store().get_change(h).map(|c| ui::subject(subject_of(&c.message))).unwrap_or_default()).unwrap_or_else(|| ui::dim("(empty)")); println!("{mark} {}{}{}{tail} {head}", ui::bookmark(&b.name), ui::sep(), ui::dim(plural(b.heads.len(), "head"))); } Ok(0) } pub fn bookmark_switch(name: &str, discard: bool) -> Result { let mut repo = open()?; let sp = ui::spinner(&format!("Switching to {name}…")); let r = repo.bookmark_switch(name, discard)?; sp.done(&format!("{} to {}: {} written, {} removed", ui::done("Switched"), ui::bookmark(name), plural(r.written, "file"), r.removed)); if r.conflicted { println!(" {} {}", ui::caution(), ui::warn("this state has conflicts; see oxy status")); } Ok(0) } pub fn merge(bookmark: &str) -> Result { let repo = open()?; let sp = ui::spinner(&format!("Merging {bookmark}…")); let r = repo.merge(bookmark)?; if !r.changed { sp.done(&format!("{} {}", ui::ok(ui::glyph("✔", "*")), ui::dim(format!("{} already contains {bookmark}", repo.config.bookmark)))); return Ok(0); } sp.done(&format!( "{} {} into {}: now {}{}", ui::done("Merged"), ui::bookmark(bookmark), ui::bookmark(&repo.config.bookmark), plural(r.heads.len(), "head"), ui::dim(if r.checked_out { "; working copy updated" } else { "; run oxy checkout to update the working copy" }) )); let snap = repo.snapshot("oxy merge")?; if snap.base_snapshot.is_conflicted() { println!(" {} {}", ui::caution(), ui::warn("the result has conflicts; see oxy status")); } Ok(0) }