CLI: modern output across all commands: rail-and-bullet log with -n/-v, relative times, authors, signature marks; glyphs on UTF-8 terminals with ASCII fallback 94084087a876
--- README.md
-oxy log
+oxy log # -n 10 for the latest ten, -v for full messages
+Output uses colour, glyphs, and relative times on a UTF-8 terminal, and
+falls back to plain ASCII when piped, with `--plain`, or with `NO_COLOR` set.
+
--- docs/plan.md
+- [x] User request (2026-09-27, mid-loop): make `oxy log` and all output look modern like `git log` with lines, spacing, and glyphs when the terminal supports them. (Done: rail-and-bullet log with `-n`/`-v`, relative times, authors, signature marks; status/show/op log/tags/workspaces/remotes/fetch/push/policy/identity restyled; ASCII fallback when piped or with `--plain`/`NO_COLOR`/non-UTF-8 locale.)
+- 2026-09-27 05:45 iteration 11: modern CLI output across all commands (user request). 105 tests.
--- src/cli/format.rs
+use super::ui;
+/// A compact, styled authentication marker for log lines.
+pub fn signature_mark(repo: &Repo, h: &ChangeHash) -> Result<String> {
+ Ok(match repo.authenticate_change(h)? {
+ SignatureStatus::Unsigned => ui::dim(format!("{} unsigned", ui::glyph("·", "-"))),
+ SignatureStatus::Valid { sealed, .. } => ui::ok(format!("{} {}", ui::glyph("✔", "v"), if sealed { "sealed" } else { "signed" })),
+ SignatureStatus::Invalid(why) => ui::err(format!("{} INVALID: {why}", ui::glyph("✖", "x"))),
+ })
+}
+
+/// The author's display name: the identity's name, or a Git-Author trailer
+/// for imported changes, or a shortened key.
+pub fn author_name(repo: &Repo, c: &ChangeMeta) -> String {
+ if !c.author.is_zero() {
+ if let Ok(id) = repo.identity(&c.author) {
+ return id.name;
+ }
+ return c.author.short(12);
+ }
+ c.message
+ .lines()
+ .rev()
+ .find_map(|l| l.strip_prefix("Git-Author: "))
+ .map(|a| a.split('<').next().unwrap_or(a).trim().to_string())
+ .unwrap_or_else(|| "unknown".into())
+}
+
+/// The first line of a message, or a placeholder.
+pub fn subject_of(message: &str) -> &str {
+ match message.lines().next() {
+ Some(l) if !l.trim().is_empty() => l,
+ _ => "(no description)",
+ }
+}
+
--- src/cli/identity.rs
- println!("Created identity {name} <{email}>");
- println!("KeyId: {key}");
- println!("Secret key stored under {}", repo::default_keys_dir().display());
- IdentityScope::User => println!("Recorded as your default author in {}", repo.user_config_path().display()),
- IdentityScope::Repo => println!("Recorded in this repository's .oxy/config only"),
- println!("Changes will carry this author; run `oxy config --user core.sign change` to sign each commit.");
- 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)" });
- ui::err(&line)
- ui::dim(&line)
- ui::ok(&line)
- println!("{key} = {value} (in {})", path.display());
- println!("{key} = {value} (in this repository)");
- println!("Wrote identity {} <{}> ({}) to {}", id.name, id.email, key.short(12), path.display());
- println!("Created policy serial {} with root key {}; push to publish it.", sp.policy.serial, sp.policy.roots[0].short(12));
- println!("policy #{} serial {} expires {}{}", r.short(12), p.serial, when(p.expires), if p.is_expired(oxy_core::objects::Hlc::now().wall_ms) { " EXPIRED" } else { "" });
- println!("roots (threshold {}):", p.root_threshold);
- println!(" {} {}", k.short(12), name(k));
- println!("rules:");
- println!(" {:12} threshold {} require {} conflicts {}", rule.pattern, rule.threshold, rule.require.name(), if rule.allow_conflicts { "allowed" } else { "refused" });
- println!(" {} {}", k.short(12), name(k));
- println!("revoked:");
- println!(" {} {}", k.short(12), name(k));
- println!("Granted {} <{}> ({}) on {}; policy serial {}. Push to publish.", id.name, id.email, key.short(12), pattern.unwrap_or("*"), sp.policy.serial);
- println!("Revoked {}; policy serial {}. Push to publish.", full.short(12), sp.policy.serial);
+ println!("{} identity {} <{email}>", ui::done("Created"), ui::author(&name));
+ println!("{}", ui::kv("key", key));
+ println!("{}", ui::kv("secret", ui::path(repo::default_keys_dir().display())));
+ IdentityScope::User => println!("{}", ui::kv("scope", format!("default author in {}", ui::path(repo.user_config_path().display())))),
+ IdentityScope::Repo => println!("{}", ui::kv("scope", "this repository's .oxy/config only")),
+ println!(" {} {}", ui::note(), ui::dim("changes will carry this author; run oxy config --user core.sign change to sign each commit"));
+ println!("{} {} <{}>", ui::section("identity"), ui::author(&id.name), id.email);
+ println!("{}", ui::kv("key", key));
+ println!("{}", ui::kv("created", format!("{} {}", when(id.created), ui::dim(format!("({})", ui::ago(id.created))))));
+ println!("{}", ui::kv("secret", if repo.secret_key()?.is_some() { ui::ok(format!("{} available", ui::glyph("✔", "*"))) } else { ui::err(format!("{} not found in the key directory", ui::glyph("✖", "x"))) }));
+ println!("{}", ui::kv("signing", if repo.config.sign_changes { "every change (core.sign = change)" } else { "at push via seals (core.sign = seal)" }));
+ ui::err(format!("{} {line}", ui::glyph("✖", "x")))
+ ui::dim(format!("{} {line}", ui::glyph("·", "-")))
+ ui::ok(format!("{} {line}", ui::glyph("✔", "v")))
+ println!("{} {key} = {value} {}", ui::done("Set"), ui::dim(format!("(in {})", path.display())));
+ println!("{} {key} = {value} {}", ui::done("Set"), ui::dim("(in this repository)"));
+ println!("{} identity {} <{}> ({}) to {}", ui::done("Wrote"), ui::author(&id.name), id.email, key.short(12), ui::path(path.display()));
+ println!("{} policy serial {} with root key {} {}", ui::done("Created"), sp.policy.serial, sp.policy.roots[0].short(12), ui::dim("(push to publish it)"));
+ let expired = p.is_expired(oxy_core::objects::Hlc::now().wall_ms);
+ println!("{} {}{}serial {}{}expires {}{}", ui::section("policy"), ui::hash(format!("#{}", r.short(12))), ui::sep(), p.serial, ui::sep(), when(p.expires), if expired { ui::err(format!(" {} EXPIRED", ui::glyph("✖", "x"))) } else { String::new() });
+ println!("{}", ui::section(format!("roots {}", ui::dim(format!("(threshold {})", p.root_threshold)))));
+ println!(" {} {} {}", ui::ok(ui::glyph("🔑", "*")), ui::hash(k.short(12)), ui::author(name(k)));
+ println!("{}", ui::section("rules"));
+ println!(" {} {:12} {}", ui::ok(ui::glyph("▸", ">")), ui::bookmark(&rule.pattern), ui::dim(format!("threshold {}, require {}, conflicts {}", rule.threshold, rule.require.name(), if rule.allow_conflicts { "allowed" } else { "refused" })));
+ println!(" {} {}", ui::hash(k.short(12)), ui::author(name(k)));
+ println!("{}", ui::section("revoked"));
+ println!(" {} {} {}", ui::err(ui::glyph("✖", "x")), ui::hash(k.short(12)), ui::author(name(k)));
+ println!("{} {} <{}> ({}) on {} {}", ui::done("Granted"), ui::author(&id.name), id.email, key.short(12), ui::bookmark(pattern.unwrap_or("*")), ui::dim(format!("(policy serial {}; push to publish)", sp.policy.serial)));
+ println!("{} {} {}", ui::done("Revoked"), ui::hash(full.short(12)), ui::dim(format!("(policy serial {}; push to publish)", sp.policy.serial)));
--- src/cli/mod.rs
- Log,
- Cmd::Log => workspace::log(),
+ Log {
+ /// Show at most this many changes
+ #[arg(short = 'n', long)]
+ limit: Option<usize>,
+ /// Show full messages, dependencies, and signatures
+ #[arg(short, long)]
+ verbose: bool,
+ },
+ Cmd::Log { limit, verbose } => workspace::log(limit, verbose),
--- src/cli/sync.rs
- sp.done(&format!("{} into {} ({} on {}: {})", ui::ok("Cloned"), ui::path(repo.root().display()), url, ui::id(&repo.config.bookmark), plural(heads.len(), "head")));
- println!("Added remote {name} = {url}");
- println!("Removed remote {name}");
- println!("{name}\t{url}");
- println!(" {bm}: {}", plural(heads, "head"));
- println!("{} {}, {}", ui::id(format!("{remote}/{name}")), plural(heads.len(), "head"), plural(*n, "new change"));
- println!("{}", ui::dim(format!("{} adopted", plural(report.new_tags, "tag"))));
- eprintln!("{} {remote} has no trust policy; nothing fetched from it was verified", ui::warn("warning:"));
- sp.done(&ui::dim("Already up to date."));
- ui::ok("Pulled:"),
- ui::id(&repo.config.bookmark),
- if r.checked_out { "; working copy updated" } else { "; run `oxy checkout` to update the working copy" }
- println!(" {}", ui::warn("the result has conflicts; see oxy status"));
- println!("{} {}", ui::id(format!("{remote}/{}", r.name)), ui::ok(if r.reason.is_empty() { "updated" } else { r.reason.as_str() }));
- println!("{} {} {}", ui::id(format!("{remote}/{}", r.name)), ui::err("rejected:"), r.reason);
- "Exported {} to {} as branch {} (currently {}); {} already exported.",
- git_dir.display(),
- r.branch,
- plural(r.heads, "head"),
- r.skipped
- "Imported {} ({} merges) from {} {branch} into {} in {:.1?}: {} now has {}.{}",
- git_dir.display(),
- repo.root().display(),
- started.elapsed(),
- repo.config.bookmark,
- plural(r.heads, "head"),
- if r.skipped_submodules > 0 { format!(" Skipped {} submodule entries.", r.skipped_submodules) } else { String::new() }
+ sp.done(&format!("{} into {} {}", ui::done("Cloned"), ui::path(repo.root().display()), ui::dim(format!("({url}, {} with {})", ui::bookmark(&repo.config.bookmark), plural(heads.len(), "head")))));
+ println!("{} remote {} {} {url}", ui::done("Added"), ui::bookmark(name), ui::dim(ui::glyph("→", "->")));
+ println!("{} remote {}", ui::done("Removed"), ui::bookmark(name));
+ if repo.config.remotes.is_empty() {
+ println!("{}", ui::dim("No remotes; add one with oxy remote add <name> <url>."));
+ }
+ println!("{} {}{}{}", ui::ok(ui::glyph("⇄", "<>")), ui::bookmark(name), ui::sep(), ui::dim(url));
+ println!(" {} {}{}{}", ui::dim(ui::glyph("↳", "->")), ui::id(format!("{name}/{bm}")), ui::sep(), ui::dim(plural(heads, "head")));
+ if report.refs.is_empty() {
+ println!("{} {}", ui::done("Fetched"), ui::dim(format!("nothing new from {remote}")));
+ }
+ let what = if *n == 0 { ui::dim("up to date") } else { ui::ok(plural(*n, "new change")) };
+ println!("{} {}{}{}{}{what}", ui::done("Fetched"), ui::id(format!("{remote}/{name}")), ui::sep(), ui::dim(plural(heads.len(), "head")), ui::sep());
+ println!(" {} {}", ui::note(), ui::dim(format!("{} adopted", plural(report.new_tags, "tag"))));
+ eprintln!("{} {remote} has no trust policy; nothing fetched from it was verified", ui::caution());
+ sp.done(&format!("{} {}", ui::ok(ui::glyph("✔", "*")), ui::dim("Already up to date.")));
+ ui::done("Pulled:"),
+ ui::bookmark(&repo.config.bookmark),
+ ui::dim(if r.checked_out { "; working copy updated" } else { "; run oxy checkout to update the working copy" })
+ println!(" {} {}", ui::caution(), ui::warn("the result has conflicts; see oxy status"));
+ println!("{} {}{}{}", ui::done("Pushed"), ui::id(format!("{remote}/{}", r.name)), ui::sep(), ui::dim(if r.reason.is_empty() { "updated" } else { r.reason.as_str() }));
+ println!("{} {} {}{}{}", ui::err(ui::glyph("✖", "x")), ui::err("Rejected"), ui::id(format!("{remote}/{}", r.name)), ui::sep(), r.reason);
+ "{} {} to {} as branch {} {}",
+ ui::done("Exported"),
+ ui::path(git_dir.display()),
+ ui::bookmark(&r.branch),
+ ui::dim(format!("(currently {}; {} already exported)", plural(r.heads, "head"), r.skipped))
+ "{} {} ({} merges) from {} {} into {} {}",
+ ui::done("Imported"),
+ ui::path(git_dir.display()),
+ ui::bookmark(branch),
+ ui::path(repo.root().display()),
+ ui::dim(format!("in {:.1?}; {} now has {}", started.elapsed(), repo.config.bookmark, plural(r.heads, "head")))
+ if r.skipped_submodules > 0 {
+ println!(" {} {}", ui::note(), ui::dim(format!("skipped {} submodule entries", r.skipped_submodules)));
+ }
--- src/cli/ui.rs
-/// Decide once whether to use colour and animation.
-}
-/// A status tag: green for additions, yellow for modifications, red for removals and conflicts.
-pub fn tag(t: &str) -> String {
- match t {
- "A" => s(t).green().bold().to_string(),
- "M" | "X" => s(t).yellow().bold().to_string(),
- "D" | "C!" => s(t).red().bold().to_string(),
- "R" => s(t).cyan().bold().to_string(),
- other => other.to_string(),
- }
-
+static UNICODE: AtomicBool = AtomicBool::new(false);
+/// Decide once whether to use colour, glyphs, and animation.
+ let utf8 = ["LC_ALL", "LC_CTYPE", "LANG"]
+ .iter()
+ .filter_map(|k| std::env::var(k).ok())
+ .find(|v| !v.is_empty())
+ .map(|v| v.to_ascii_uppercase().contains("UTF-8") || v.to_ascii_uppercase().contains("UTF8"))
+ .unwrap_or(false);
+ UNICODE.store(!plain && utf8, Ordering::Relaxed);
+/// Whether the terminal takes Unicode glyphs (a UTF-8 locale, not `--plain`).
+pub fn unicode() -> bool {
+ UNICODE.load(Ordering::Relaxed)
+}
+
+/// A glyph with an ASCII fallback for plain output and non-UTF-8 locales.
+pub fn glyph<'a>(fancy: &'a str, ascii: &'a str) -> &'a str {
+ if unicode() { fancy } else { ascii }
+}
+
+/// A finished action: a tick and the verb in green.
+pub fn done<T: std::fmt::Display>(v: T) -> String {
+ format!("{} {}", ok(glyph("✔", "*")), ok(v))
+}
+
+/// A warning line prefix.
+pub fn caution() -> String {
+ warn(glyph("⚠", "!"))
+}
+
+/// An informational note prefix.
+pub fn note() -> String {
+ dim(glyph("ℹ", "i"))
+}
+
+/// A section title.
+pub fn section<T: std::fmt::Display>(v: T) -> String {
+ format!("{} {}", s(glyph("▍", "#")).cyan(), s(v).bold())
+}
+
+/// ` key value`, with the key dimmed and padded.
+pub fn kv<T: std::fmt::Display>(key: &str, value: T) -> String {
+ format!(" {} {value}", dim(format!("{key:<8}")))
+}
+
+/// An author name.
+pub fn author<T: std::fmt::Display>(v: T) -> String {
+ s(v).magenta().to_string()
+}
+
+/// A bookmark or reference name.
+pub fn bookmark<T: std::fmt::Display>(v: T) -> String {
+ s(v).green().bold().to_string()
+}
+
+/// The subject line of a message.
+pub fn subject<T: std::fmt::Display>(v: T) -> String {
+ s(v).bold().to_string()
+}
+
+/// A dimmed separator between the parts of a metadata line.
+pub fn sep() -> String {
+ dim(format!(" {} ", glyph("·", "-")))
+}
+
+/// Milliseconds since the epoch as a relative time ("3 minutes ago"), or the
+/// date when older than a month.
+pub fn ago(ms: u64) -> String {
+ let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
+ let secs = now.saturating_sub(ms) / 1000;
+ let unit = |n: u64, w: &str| format!("{n} {w}{} ago", if n == 1 { "" } else { "s" });
+ match secs {
+ 0..=44 => "just now".into(),
+ 45..=89 => "a minute ago".into(),
+ 90..=2_699 => unit(secs.div_ceil(60), "minute"),
+ 2_700..=5_399 => "an hour ago".into(),
+ 5_400..=79_199 => unit(secs.div_ceil(3_600), "hour"),
+ 79_200..=129_599 => "yesterday".into(),
+ 129_600..=2_591_999 => unit(secs.div_ceil(86_400), "day"),
+ _ => super::format::when(ms).split(' ').next().unwrap_or("").to_string(),
+ }
+}
+
+/// The bullet for a log entry: the working copy, a head, or an ordinary change.
+pub fn log_bullet(working_copy: bool, head: bool, conflicted: bool) -> String {
+ if working_copy {
+ s(glyph("◉", "@")).cyan().bold().to_string()
+ } else if conflicted {
+ err(glyph("✱", "!"))
+ } else if head {
+ ok(glyph("●", "*"))
+ } else {
+ dim(glyph("○", "o"))
+ }
+}
+
+/// The vertical rail joining log entries.
+pub fn rail() -> String {
+ dim(glyph("│", "|"))
+}
+
+/// A status glyph and word for a working-copy change.
+pub fn status_mark(tag: &str) -> String {
+ let (g, a, word) = match tag {
+ "A" => ("✚", "+", "added"),
+ "M" => ("✎", "~", "modified"),
+ "D" => ("✖", "-", "removed"),
+ "X" => ("⚙", "x", "mode"),
+ "C!" => ("‼", "!", "markers"),
+ "R" => ("✔", "r", "resolved"),
+ _ => ("?", "?", "unknown"),
+ };
+ let text = format!("{} {word:<9}", glyph(g, a));
+ self::tag_style(tag, &text)
+}
+
+fn tag_style(t: &str, text: &str) -> String {
+ match t {
+ "A" | "R" => s(text).green().to_string(),
+ "M" | "X" => s(text).yellow().to_string(),
+ "D" | "C!" => s(text).red().to_string(),
+ _ => text.to_string(),
+ }
+}
+
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn relative_times() {
+ let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
+ assert_eq!(ago(now), "just now");
+ assert_eq!(ago(now - 60_000), "a minute ago");
+ assert_eq!(ago(now - 5 * 60_000), "5 minutes ago");
+ assert_eq!(ago(now - 3 * 3_600_000), "3 hours ago");
+ assert_eq!(ago(now - 25 * 3_600_000), "yesterday");
+ assert_eq!(ago(now - 4 * 86_400_000), "4 days ago");
+ assert_eq!(ago(0), "1970-01-01");
+ assert_eq!(glyph("✔", "*"), if unicode() { "✔" } else { "*" });
+ }
+}
--- src/cli/workspace.rs
-use super::format::{deps_line, deps_of, plural, short_id, short_meta_id, signature_line, when};
- println!("{} oxy repository in {}", ui::ok("Initialized"), ui::path(repo.root().display()));
- println!(" bookmark {} author {} <{}>", ui::id(&repo.config.bookmark), repo.config.author_name, repo.config.author_email);
- "{} {} on {} {}",
- ui::heading("Working copy"),
- ui::id(&repo.config.bookmark),
- ui::dim(format!("({})", plural(heads.len(), "head")))
- println!(" {}", ui::dim("clean"));
- }
- for s in &snap.statuses {
- println!(" {:2} {}", ui::tag(status_tag(s.status)), ui::path(&s.path));
- println!("{} {}", ui::err("Conflicts"), ui::dim("edit the marked files, or the .oxy-conflict-N candidates, then commit"));
- println!(" tree: {c:?}");
- Some(ChangeStatus::Resolved) => ui::ok("resolved in working copy"),
- Some(ChangeStatus::ConflictEdited) => ui::warn("markers remain"),
- _ => ui::err("unresolved"),
- sp.done(&format!("{} {} {} {}", ui::ok("Committed"), ui::id(short_id(&c)), ui::hash(format!("#{}", h.short(12))), message.lines().next().unwrap_or("")));
-pub fn log() -> Result<i32> {
- repo.snapshot("oxy log")?;
- for e in repo.log()? {
- let mut flags = Vec::new();
- flags.push("working copy");
- if e.is_head {
- flags.push("head");
- let flags = if flags.is_empty() { String::new() } else { ui::warn(format!(" ({})", flags.join(", "))) };
- println!("{} {}{}", ui::id(short_meta_id(c)), ui::hash(format!("#{}", e.hash.short(12))), flags);
- println!(" {}", ui::dim(when(c.hlc.wall_ms)));
- if !c.deps.is_empty() {
- println!(" {}", ui::dim(format!("deps: {}", deps_of(&c.deps))));
- let msg = if c.message.is_empty() { "(no description)" } else { &c.message };
- for line in msg.lines() {
- println!(" {line}");
- println!();
- println!("{} {} {}", ui::heading("change"), ui::id(short_id(&c)), ui::hash(format!("#{h}")));
- println!(" kind {:?}", c.kind);
- println!(" date {}", when(c.hlc.wall_ms));
- println!(" deps {}", ui::dim(deps_line(&c)));
- println!(" sig {}", signature_line(&repo, &h)?);
- println!("\n{}\n", if c.message.is_empty() { "(no description)" } else { &c.message });
- sp.done(&format!("{} on {}: {} written, {} removed", ui::ok("Working copy"), ui::id(&spec), plural(r.written, "file"), r.removed));
- println!(" {}", ui::warn("this state has conflicts; see oxy status"));
- println!("{} now at: {}", ui::ok("Undone;"), ui::dim(&op.command));
- println!("{}{} {} {}", if i == 0 { ui::ok("@ ") } else { " ".into() }, ui::hash(r.short(12)), ui::dim(when(op.hlc.wall_ms)), op.command);
- println!("{} {} {} {}", ui::ok("Described"), ui::id(short_id(&c)), ui::hash(format!("#{}", new.short(12))), message.lines().next().unwrap_or(""));
- println!("{} {} {} {}", ui::ok("Amended"), ui::id(short_id(&c)), ui::hash(format!("#{}", new.short(12))), c.message.lines().next().unwrap_or(""));
- println!("Abandoned {} #{}; its content remains on disk as uncommitted work if the working copy sat on it.", short_id(&c), h.short(12));
- println!(" {} {n}", ui::warn("note:"));
- sp.done(&format!("{} {} of {} ({} bytes) for {}", ui::ok("Checkpoint"), ui::hash(format!("#{}", r.short(12))), plural(changes, "change"), bytes, ui::id(&repo.config.bookmark)));
- "{} {} -> {} ({}){}",
- ui::ok("Tagged"),
- ui::id(&t.name),
- "{} {} {}{}{}",
- ui::id(&t.name),
- ui::dim(format!("({})", plural(t.heads.len(), "head"))),
- if t.message.is_empty() { String::new() } else { format!(" {}", t.message.lines().next().unwrap_or("")) },
- t.signer.map(|s| ui::dim(format!(" [{s}]"))).unwrap_or_default()
- sp.done(&format!("{} {} (workspace {})", ui::ok("Workspace created at"), ui::path(ws.root().display()), ui::id(ws.workspace().short(8))));
- if w.current { ui::ok("*") } else { " ".into() },
- w.working_copy.map(|h| ui::dim(format!(" wc #{}", h.short(12)))).unwrap_or_else(|| ui::warn(" (not in view)"))
- println!("{} workspace {} at {}; its files were left in place.", ui::ok("Forgot"), ui::id(w.id.short(8)), ui::path(w.path.display()));
- println!("{} {} (advisory, local)", ui::ok("Locked"), ui::path(path));
- println!("{} {}", ui::ok("Unlocked"), ui::path(path));
- println!("{} into {} {} and {} {}", ui::ok("Split"), ui::id(short_id(&c1)), ui::hash(format!("#{}", h1.short(12))), ui::id(short_id(&c2)), ui::hash(format!("#{}", h2.short(12))));
+use super::format::{author_name, deps_line, deps_of, plural, short_id, short_meta_id, signature_line, signature_mark, subject_of, when};
+use oxy_core::objects::ChangeKind;
+ 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)));
+ "{} {}{}working copy {}{}{}",
+ ui::dim("On"),
+ ui::bookmark(&repo.config.bookmark),
+ ui::sep(),
+ ui::sep(),
+ ui::dim(plural(heads.len(), "head"))
+ 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));
+ }
+ println!("{} {}", ui::section(ui::err("Conflicts")), ui::dim("edit the marked files, or the .oxy-conflict-N candidates, then commit"));
+ println!(" {} tree: {c:?}", ui::err(ui::glyph("✱", "!")));
+ 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("✱", "!"))),
+ sp.done(&format!("{} {} {} {}", ui::done("Committed"), ui::id(short_id(&c)), ui::hash(format!("#{}", h.short(12))), ui::subject(subject_of(message))));
+/// 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<usize>, verbose: bool) -> Result<i32> {
+ 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 bullet = ui::log_bullet(e.is_working_copy, e.is_head, e.is_working_copy && conflicted);
+ let mut labels = Vec::new();
+ 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)));
+ }
+ 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!();
+ sp.done(&format!("{} on {}: {} written, {} removed", ui::done("Working copy"), ui::bookmark(&spec), plural(r.written, "file"), r.removed));
+ println!(" {} {}", ui::caution(), ui::warn("this state has conflicts; see oxy status"));
+ println!("{} now at: {}", ui::done("Undone;"), ui::dim(&op.command));
+ 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() });
+ println!("{} {} {} {}", ui::done("Described"), ui::id(short_id(&c)), ui::hash(format!("#{}", new.short(12))), ui::subject(subject_of(message)));
+ println!("{} {} {} {}", ui::done("Amended"), ui::id(short_id(&c)), ui::hash(format!("#{}", new.short(12))), ui::subject(subject_of(&c.message)));
+ 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"));
+ println!(" {} {n}", ui::note());
+ sp.done(&format!("{} {} of {} ({} bytes) for {}", ui::done("Checkpoint"), ui::hash(format!("#{}", r.short(12))), plural(changes, "change"), bytes, ui::id(&repo.config.bookmark)));
+ "{} {} {} {} ({}){}",
+ ui::done("Tagged"),
+ ui::bookmark(&t.name),
+ ui::dim(ui::glyph("→", "->")),
+ "{} {} {}{}{}{}",
+ ui::ok(ui::glyph("⚑", "#")),
+ ui::bookmark(&t.name),
+ 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()
+ sp.done(&format!("{} {} (workspace {})", ui::done("Workspace created at"), ui::path(ws.root().display()), ui::id(ws.workspace().short(8))));
+ if w.current { ui::ok(ui::glyph("●", "*")) } else { ui::dim(ui::glyph("○", "o")) },
+ 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"))
+ println!("{} workspace {} at {}; its files were left in place.", ui::done("Forgot"), ui::id(w.id.short(8)), ui::path(w.path.display()));
+ println!("{} {} {}", ui::done("Locked"), ui::path(path), ui::dim("(advisory, local)"));
+ println!("{} {}", ui::done("Unlocked"), ui::path(path));
+ 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))));
oxy log # -n 10 for the latest ten, -v for full messages
40
40
```
41
41
42
+
Output uses colour, glyphs, and relative times on a UTF-8 terminal, and
43
+
falls back to plain ASCII when piped, with `--plain`, or with `NO_COLOR` set.
44
+
42
45
Collaborating:
43
46
44
47
```
…
28 unchanged lines
modifieddocs/plan.md+2−0
…
25 unchanged lines
26
26
- [x] Hooks (spec §20) — done 2026-09-27 03:55: executables in `.oxy/hooks/<event>`, JSON on stdin, `pre-*` veto via exit status with stderr as the message, `semantic-deps` adds explicit deps when `hooks.semantic-deps = true`. `pre-commit`, `post-commit`, `pre-push`, `post-fetch`, `post-checkout`, and the `semantic-deps` hook with its JSON interface.
27
27
- [x] Amend on non-head changes; `oxy split` — done 2026-09-27 04:45: `oxy amend --into CHANGE` appends the working copy's edits to an older change when they reference only its closure (identity correspondence for dependents, refused otherwise with a clear message); `oxy split CHANGE PATH..` rebuilds a head as two changes. Found and fixed a graph bug: a delete/insert conflict now requires the deleter and inserter to commute (both directions).
28
28
- [x] Reify orphaned references on rewrite as conflicts instead of refusing (spec §8.4); define the conflict shape in the spec. (Done: re-anchoring to the nearest surviving line, else the chain's anchors, so order conflicts arise naturally; vanished nodes drop dependent edits; notes reported by `oxy abandon`.)
29
+
- [x] User request (2026-09-27, mid-loop): make `oxy log` and all output look modern like `git log` with lines, spacing, and glyphs when the terminal supports them. (Done: rail-and-bullet log with `-n`/`-v`, relative times, authors, signature marks; status/show/op log/tags/workspaces/remotes/fetch/push/policy/identity restyled; ASCII fallback when piped or with `--plain`/`NO_COLOR`/non-UTF-8 locale.)
- [ ] Rename detection on Git import (similarity ≥ 50%); `oxy git fetch`/`push` against a Git remote via a mirror.
31
32
- [ ] Hardening: property tests for the CBOR decoder and pack reader against random and mutated inputs; a concurrent-push test against the server; a 1 GB single-file benchmark for chunking and blobs.
…
3 unchanged lines
35
36
## Log
36
37
37
38
- 2026-09-26 23:01 plan created.
39
+
- 2026-09-27 05:45 iteration 11: modern CLI output across all commands (user request). 105 tests.
38
40
- 2026-09-27 05:15 iteration 10: orphaned references reified on rewrite (spec 0.6m); gc fixed to tolerate old views whose working copies were pruned under a zero op-retention during development (this repo's oldest 20 operations). 104 tests.
println!("{}", ui::kv("secret", if repo.secret_key()?.is_some() { ui::ok(format!("{} available", ui::glyph("✔", "*"))) } else { ui::err(format!("{}not found in the key directory",ui::glyph("✖", "x"))) }));
println!("Created policy serial {} with root key {};pushto publish it.", sp.policy.serial, sp.policy.roots[0].short(12));
101
+
println!("{} policy serial {} with root key {} {}",ui::done("Created"), sp.policy.serial, sp.policy.roots[0].short(12), ui::dim("(push to publish it)"));
102
102
Ok(0)
103
103
}
104
104
…
5 unchanged lines
110
110
};
111
111
let p = &sp.policy;
112
112
let name = |k: &oxy_core::hash::KeyId| p.identity(k).map(|i| format!("{} <{}>", i.name, i.email)).unwrap_or_else(|| "(unknown identity)".into());
println!("Abandoned {} #{}; its content remains on disk as uncommitted work if the working copy sat on it.", short_id(&c), h.short(12));
319
+
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"));
270
320
for n in notes {
271
-
println!(" {} {n}", ui::warn("note:"));
321
+
println!(" {} {n}", ui::note());
272
322
}
273
323
Ok(0)
274
324
}
…
2 unchanged lines
277
327
let repo = open()?;
278
328
let sp = ui::spinner("Writing checkpoint…");
279
329
let (r, changes, bytes) = repo.checkpoint()?;
280
-
sp.done(&format!("{} {} of {} ({} bytes) for {}", ui::ok("Checkpoint"), ui::hash(format!("#{}", r.short(12))), plural(changes, "change"), bytes, ui::id(&repo.config.bookmark)));
330
+
sp.done(&format!("{} {} of {} ({} bytes) for {}", ui::done("Checkpoint"), ui::hash(format!("#{}", r.short(12))), plural(changes, "change"), bytes, ui::id(&repo.config.bookmark)));
281
331
Ok(0)
282
332
}
283
333
…
45 unchanged lines
329
379
};
330
380
let t = repo.tag_create(name, &heads, message)?;
331
381
println!(
332
-
"{} {} -> {} ({}){}",
333
-
ui::ok("Tagged"),
334
-
ui::id(&t.name),
382
+
"{} {} {} {} ({}){}",
383
+
ui::done("Tagged"),
384
+
ui::bookmark(&t.name),
385
+
ui::dim(ui::glyph("→", "->")),
335
386
ui::hash(format!("#{}", t.state.short(12))),
336
387
plural(t.heads.len(), "head"),
337
388
t.signer.map(|s| format!(", signed by {s}")).unwrap_or_else(|| ui::dim(", unsigned"))