OXYGENHUB

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))));
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-26 23:34 UTC
Signature
✔ signed by Andy McCall
Hash
94084087a8766e8cd30a51344f56f5429934de0cfa139739f3ecd41ff80056b1
Depends on
045469792ee1 132b884b9a8d 135212de5370 23b217d75848 252e8cb23fbc 2863456a0f6d 4e88e774777a 614541c3cebd 648724d8e0de 6706bdcfb63e 79ab8528ff62 7bc302317004 83e0677b07fe 96f7610fa666 c3069537d7c5 cdb5beba7f05 d02e545dc265 e8b4e156f2fa fce44ae9ad29
Depended on by
284fe47d98b9 0299076f09a2 530a0fc650a2 34fad4a92451 bf3a67819b59 5cde7882d767 2c9ddac3c387 47ce8b8e2d4b b35df36b7dc5 33472ccc08cc 3604179679eb c07406bcc237 98a1620a3840 2bf22305bc48 ff1f62fee654 4cce912a0953 69816d688300 83d61e803ccd 6a81dbab471d cc7a00565161 75167672ebcd e02bf0cd0533 c6aa5bd9d1c8 957fee30826e 4dee277c327a 6e723b81f5b9 28fc23d7706a
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
98 atoms, 0 tree operations, 8 files

Browse the tree at this change

modified README.md +4 −1
…
35 unchanged lines
36 36
oxy init                                 # in a project directory
37 37
oxy status
38 38
oxy commit -m "First change"
39 -
oxy log
39 +
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
modified docs/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.)
29 30
- [ ] Identity lifecycle: `oxy identity rotate|revoke`, expiry, delegates (`oxy identity delegate add|remove`), multi-signer policy updates.
30 31
- [ ] 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.
39 41
- 2026-09-27 04:45 iteration 9: amend --into and split; graph delete/insert fix. 104 tests.
40 42
- 2026-09-27 03:55 iteration 8: hooks. 102 tests.
…
7 unchanged lines
modified src/cli/format.rs +35 −0
1 1
//! Shared presentation helpers.
2 2
3 +
use super::ui;
3 4
use anyhow::Result;
4 5
use oxy_core::hash::ChangeHash;
5 6
use oxy_core::objects::Change;
…
43 unchanged lines
49 50
    })
50 51
}
51 52
53 +
/// A compact, styled authentication marker for log lines.
54 +
pub fn signature_mark(repo: &Repo, h: &ChangeHash) -> Result<String> {
55 +
    Ok(match repo.authenticate_change(h)? {
56 +
        SignatureStatus::Unsigned => ui::dim(format!("{} unsigned", ui::glyph("·", "-"))),
57 +
        SignatureStatus::Valid { sealed, .. } => ui::ok(format!("{} {}", ui::glyph("✔", "v"), if sealed { "sealed" } else { "signed" })),
58 +
        SignatureStatus::Invalid(why) => ui::err(format!("{} INVALID: {why}", ui::glyph("✖", "x"))),
59 +
    })
60 +
}
61 +
62 +
/// The author's display name: the identity's name, or a Git-Author trailer
63 +
/// for imported changes, or a shortened key.
64 +
pub fn author_name(repo: &Repo, c: &ChangeMeta) -> String {
65 +
    if !c.author.is_zero() {
66 +
        if let Ok(id) = repo.identity(&c.author) {
67 +
            return id.name;
68 +
        }
69 +
        return c.author.short(12);
70 +
    }
71 +
    c.message
72 +
        .lines()
73 +
        .rev()
74 +
        .find_map(|l| l.strip_prefix("Git-Author: "))
75 +
        .map(|a| a.split('<').next().unwrap_or(a).trim().to_string())
76 +
        .unwrap_or_else(|| "unknown".into())
77 +
}
78 +
79 +
/// The first line of a message, or a placeholder.
80 +
pub fn subject_of(message: &str) -> &str {
81 +
    match message.lines().next() {
82 +
        Some(l) if !l.trim().is_empty() => l,
83 +
        _ => "(no description)",
84 +
    }
85 +
}
86 +
52 87
pub fn deps_line(c: &Change) -> String {
53 88
    deps_of(&c.deps)
54 89
}
…
17 unchanged lines
modified src/cli/identity.rs +29 −28
…
14 unchanged lines
15 15
    let email = email.unwrap_or_else(|| repo.config.author_email.clone());
16 16
    let scope = if local { IdentityScope::Repo } else { IdentityScope::User };
17 17
    let (key, _) = repo.create_identity(&name, &email, scope)?;
18 -
    println!("Created identity {name} <{email}>");
19 -
    println!("KeyId: {key}");
20 -
    println!("Secret key stored under {}", repo::default_keys_dir().display());
18 +
    println!("{} identity {} <{email}>", ui::done("Created"), ui::author(&name));
19 +
    println!("{}", ui::kv("key", key));
20 +
    println!("{}", ui::kv("secret", ui::path(repo::default_keys_dir().display())));
21 21
    match scope {
22 -
        IdentityScope::User => println!("Recorded as your default author in {}", repo.user_config_path().display()),
23 -
        IdentityScope::Repo => println!("Recorded in this repository's .oxy/config only"),
22 +
        IdentityScope::User => println!("{}", ui::kv("scope", format!("default author in {}", ui::path(repo.user_config_path().display())))),
23 +
        IdentityScope::Repo => println!("{}", ui::kv("scope", "this repository's .oxy/config only")),
24 24
    }
25 25
    if !repo.config.sign_changes {
26 -
        println!("Changes will carry this author; run `oxy config --user core.sign change` to sign each commit.");
26 +
        println!("  {} {}", ui::note(), ui::dim("changes will carry this author; run oxy config --user core.sign change to sign each commit"));
27 27
    }
28 28
    Ok(0)
29 29
}
…
5 unchanged lines
35 35
        return Ok(0);
36 36
    };
37 37
    let id = repo.identity(&key)?;
38 -
    println!("{} <{}>", id.name, id.email);
39 -
    println!("KeyId:   {key}");
40 -
    println!("Created: {}", when(id.created));
41 -
    println!("Secret:  {}", if repo.secret_key()?.is_some() { "available" } else { "NOT FOUND in key directory" });
42 -
    println!("Signing: {}", if repo.config.sign_changes { "every change (core.sign = change)" } else { "at push via seals (core.sign = seal)" });
38 +
    println!("{} {} <{}>", ui::section("identity"), ui::author(&id.name), id.email);
39 +
    println!("{}", ui::kv("key", key));
40 +
    println!("{}", ui::kv("created", format!("{} {}", when(id.created), ui::dim(format!("({})", ui::ago(id.created))))));
41 +
    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"))) }));
42 +
    println!("{}", ui::kv("signing", if repo.config.sign_changes { "every change (core.sign = change)" } else { "at push via seals (core.sign = seal)" }));
43 43
    Ok(0)
44 44
}
45 45
…
12 unchanged lines
58 58
        let line = signature_line(&repo, &h)?;
59 59
        let styled = if line.starts_with("INVALID") {
60 60
            bad += 1;
61 -
            ui::err(&line)
61 +
            ui::err(format!("{} {line}", ui::glyph("✖", "x")))
62 62
        } else if line == "unsigned" {
63 -
            ui::dim(&line)
63 +
            ui::dim(format!("{} {line}", ui::glyph("·", "-")))
64 64
        } else {
65 -
            ui::ok(&line)
65 +
            ui::ok(format!("{} {line}", ui::glyph("✔", "v")))
66 66
        };
67 67
        println!("{} {}  {styled}", ui::id(short_id(&c)), ui::hash(format!("#{}", h.short(12))));
68 68
    }
…
4 unchanged lines
73 73
    if user {
74 74
        let path = repo::default_user_config();
75 75
        repo::Config::set_user(&path, key, value)?;
76 -
        println!("{key} = {value} (in {})", path.display());
76 +
        println!("{} {key} = {value} {}", ui::done("Set"), ui::dim(format!("(in {})", path.display())));
77 77
    } else {
78 78
        let mut repo = open()?;
79 79
        repo.config.set(key, value)?;
80 80
        repo.save_config()?;
81 -
        println!("{key} = {value} (in this repository)");
81 +
        println!("{} {key} = {value} {}", ui::done("Set"), ui::dim("(in this repository)"));
82 82
    }
83 83
    Ok(0)
84 84
}
…
6 unchanged lines
91 91
    let id = repo.identity(&key)?;
92 92
    let path = path.unwrap_or_else(|| PathBuf::from(format!("{}.identity", key.short(16))));
93 93
    std::fs::write(&path, id.encode())?;
94 -
    println!("Wrote identity {} <{}> ({}) to {}", id.name, id.email, key.short(12), path.display());
94 +
    println!("{} identity {} <{}> ({}) to {}", ui::done("Wrote"), ui::author(&id.name), id.email, key.short(12), ui::path(path.display()));
95 95
    Ok(0)
96 96
}
97 97
98 98
pub fn policy_init() -> Result<i32> {
99 99
    let repo = open()?;
100 100
    let sp = repo.policy_init()?;
101 -
    println!("Created policy serial {} with root key {}; push to 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());
113 -
    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 { "" });
114 -
    println!("roots (threshold {}):", p.root_threshold);
113 +
    let expired = p.is_expired(oxy_core::objects::Hlc::now().wall_ms);
114 +
    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() });
115 +
    println!("{}", ui::section(format!("roots {}", ui::dim(format!("(threshold {})", p.root_threshold)))));
115 116
    for k in &p.roots {
116 -
        println!("  {}  {}", k.short(12), name(k));
117 +
        println!("  {} {}  {}", ui::ok(ui::glyph("🔑", "*")), ui::hash(k.short(12)), ui::author(name(k)));
117 118
    }
118 -
    println!("rules:");
119 +
    println!("{}", ui::section("rules"));
119 120
    for rule in &p.rules {
120 -
        println!("  {:12} threshold {} require {} conflicts {}", rule.pattern, rule.threshold, rule.require.name(), if rule.allow_conflicts { "allowed" } else { "refused" });
121 +
        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" })));
121 122
        for k in &rule.keys {
122 -
            println!("      {}  {}", k.short(12), name(k));
123 +
            println!("      {}  {}", ui::hash(k.short(12)), ui::author(name(k)));
123 124
        }
124 125
    }
125 126
    if !p.revoked.is_empty() {
126 -
        println!("revoked:");
127 +
        println!("{}", ui::section("revoked"));
127 128
        for k in &p.revoked {
128 -
            println!("  {}  {}", k.short(12), name(k));
129 +
            println!("  {} {}  {}", ui::err(ui::glyph("✖", "x")), ui::hash(k.short(12)), ui::author(name(k)));
129 130
        }
130 131
    }
131 132
    Ok(0)
…
4 unchanged lines
136 137
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
137 138
    let key = id.hash();
138 139
    let sp = repo.policy_grant(id.clone(), pattern)?;
139 -
    println!("Granted {} <{}> ({}) on {}; policy serial {}. Push to publish.", id.name, id.email, key.short(12), pattern.unwrap_or("*"), sp.policy.serial);
140 +
    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)));
140 141
    Ok(0)
141 142
}
142 143
…
9 unchanged lines
152 153
        _ => anyhow::bail!("{key:?} is ambiguous"),
153 154
    };
154 155
    let sp = repo.policy_revoke(&full)?;
155 -
    println!("Revoked {}; policy serial {}. Push to publish.", full.short(12), sp.policy.serial);
156 +
    println!("{} {} {}", ui::done("Revoked"), ui::hash(full.short(12)), ui::dim(format!("(policy serial {}; push to publish)", sp.policy.serial)));
156 157
    Ok(0)
157 158
}
modified src/cli/mod.rs +9 −2
…
64 unchanged lines
65 65
        depends_on: Vec<String>,
66 66
    },
67 67
    /// List changes reachable from the bookmark and the working copy
68 -
    Log,
68 +
    Log {
69 +
        /// Show at most this many changes
70 +
        #[arg(short = 'n', long)]
71 +
        limit: Option<usize>,
72 +
        /// Show full messages, dependencies, and signatures
73 +
        #[arg(short, long)]
74 +
        verbose: bool,
75 +
    },
69 76
    /// Show what the working copy changes against its base
70 77
    Diff,
71 78
    /// Change the message of a head (default: the head the working copy sits on)
…
213 unchanged lines
285 292
        Cmd::Status => workspace::status(),
286 293
        Cmd::Prompt { format } => workspace::prompt(&format),
287 294
        Cmd::Commit { message, depends_on } => workspace::commit(&message, &depends_on),
288 -
        Cmd::Log => workspace::log(),
295 +
        Cmd::Log { limit, verbose } => workspace::log(limit, verbose),
289 296
        Cmd::Diff => workspace::diff(),
290 297
        Cmd::Describe { message, change } => workspace::describe(&message, change.as_deref()),
291 298
        Cmd::Amend { message, into } => workspace::amend(message.as_deref(), into.as_deref()),
…
49 unchanged lines
modified src/cli/sync.rs +36 −27
…
18 unchanged lines
19 19
        None => Repo::clone_with(url, &dest, max_blob, sparse)?,
20 20
    };
21 21
    let heads = repo.bookmark_heads()?;
22 -
    sp.done(&format!("{} into {} ({} on {}: {})", ui::ok("Cloned"), ui::path(repo.root().display()), url, ui::id(&repo.config.bookmark), plural(heads.len(), "head")));
22 +
    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")))));
23 23
    Ok(0)
24 24
}
25 25
…
1 unchanged lines
27 27
    let mut repo = open()?;
28 28
    repo.config.set(&format!("remote.{name}.url"), url)?;
29 29
    repo.save_config()?;
30 -
    println!("Added remote {name} = {url}");
30 +
    println!("{} remote {} {} {url}", ui::done("Added"), ui::bookmark(name), ui::dim(ui::glyph("→", "->")));
31 31
    Ok(0)
32 32
}
33 33
…
1 unchanged lines
35 35
    let mut repo = open()?;
36 36
    repo.config.set(&format!("remote.{name}.url"), "")?;
37 37
    repo.save_config()?;
38 -
    println!("Removed remote {name}");
38 +
    println!("{} remote {}", ui::done("Removed"), ui::bookmark(name));
39 39
    Ok(0)
40 40
}
41 41
42 42
pub fn remote_list() -> Result<i32> {
43 43
    let repo = open()?;
44 44
    let view = repo.view()?;
45 +
    if repo.config.remotes.is_empty() {
46 +
        println!("{}", ui::dim("No remotes; add one with oxy remote add <name> <url>."));
47 +
    }
45 48
    for (name, url) in &repo.config.remotes {
46 -
        println!("{name}\t{url}");
49 +
        println!("{} {}{}{}", ui::ok(ui::glyph("⇄", "<>")), ui::bookmark(name), ui::sep(), ui::dim(url));
47 50
        if let Some(bms) = view.remotes.get(name) {
48 51
            for (bm, st) in bms {
49 52
                let heads = repo.store().get_state(st).map(|s| s.heads.len()).unwrap_or(0);
50 -
                println!("    {bm}: {}", plural(heads, "head"));
53 +
                println!("    {} {}{}{}", ui::dim(ui::glyph("↳", "->")), ui::id(format!("{name}/{bm}")), ui::sep(), ui::dim(plural(heads, "head")));
51 54
            }
52 55
        }
53 56
    }
…
10 unchanged lines
64 67
    let sp = ui::spinner(&format!("Fetching from {remote}…"));
65 68
    let report = repo.fetch(&remote)?;
66 69
    sp.clear();
70 +
    if report.refs.is_empty() {
71 +
        println!("{} {}", ui::done("Fetched"), ui::dim(format!("nothing new from {remote}")));
72 +
    }
67 73
    for (name, heads, n) in &report.refs {
68 -
        println!("{} {}, {}", ui::id(format!("{remote}/{name}")), plural(heads.len(), "head"), plural(*n, "new change"));
74 +
        let what = if *n == 0 { ui::dim("up to date") } else { ui::ok(plural(*n, "new change")) };
75 +
        println!("{} {}{}{}{}{what}", ui::done("Fetched"), ui::id(format!("{remote}/{name}")), ui::sep(), ui::dim(plural(heads.len(), "head")), ui::sep());
69 76
    }
70 77
    if report.new_tags > 0 {
71 -
        println!("{}", ui::dim(format!("{} adopted", plural(report.new_tags, "tag"))));
78 +
        println!("  {} {}", ui::note(), ui::dim(format!("{} adopted", plural(report.new_tags, "tag"))));
72 79
    }
73 80
    if report.unverified {
74 -
        eprintln!("{} {remote} has no trust policy; nothing fetched from it was verified", ui::warn("warning:"));
81 +
        eprintln!("{} {remote} has no trust policy; nothing fetched from it was verified", ui::caution());
75 82
    }
76 83
    Ok(0)
77 84
}
…
4 unchanged lines
82 89
    let sp = ui::spinner(&format!("Pulling from {remote}…"));
83 90
    let r = repo.pull(&remote)?;
84 91
    if !r.changed {
85 -
        sp.done(&ui::dim("Already up to date."));
92 +
        sp.done(&format!("{} {}", ui::ok(ui::glyph("✔", "*")), ui::dim("Already up to date.")));
86 93
        return Ok(0);
87 94
    }
88 95
    sp.done(&format!(
89 96
        "{} {} now has {}{}",
90 -
        ui::ok("Pulled:"),
91 -
        ui::id(&repo.config.bookmark),
97 +
        ui::done("Pulled:"),
98 +
        ui::bookmark(&repo.config.bookmark),
92 99
        plural(r.heads.len(), "head"),
93 -
        if r.checked_out { "; working copy updated" } else { "; run `oxy checkout` to update the working copy" }
100 +
        ui::dim(if r.checked_out { "; working copy updated" } else { "; run oxy checkout to update the working copy" })
94 101
    ));
95 102
    let snap = repo.snapshot("oxy pull")?;
96 103
    if snap.base_snapshot.is_conflicted() {
97 -
        println!("  {}", ui::warn("the result has conflicts; see oxy status"));
104 +
        println!("  {} {}", ui::caution(), ui::warn("the result has conflicts; see oxy status"));
98 105
    }
99 106
    Ok(0)
100 107
}
…
7 unchanged lines
108 115
    let mut code = 0;
109 116
    for r in results {
110 117
        if r.ok {
111 -
            println!("{} {}", ui::id(format!("{remote}/{}", r.name)), ui::ok(if r.reason.is_empty() { "updated" } else { r.reason.as_str() }));
118 +
            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() }));
112 119
        } else {
113 -
            println!("{} {} {}", ui::id(format!("{remote}/{}", r.name)), ui::err("rejected:"), r.reason);
120 +
            println!("{} {} {}{}{}", ui::err(ui::glyph("✖", "x")), ui::err("Rejected"), ui::id(format!("{remote}/{}", r.name)), ui::sep(), r.reason);
114 121
            code = 1;
115 122
        }
116 123
    }
…
23 unchanged lines
140 147
    let r = repo.git_export(git_dir)?;
141 148
    sp.clear();
142 149
    println!(
143 -
        "Exported {} to {} as branch {} (currently {}); {} already exported.",
150 +
        "{} {} to {} as branch {} {}",
151 +
        ui::done("Exported"),
144 152
        plural(r.exported, "new state"),
145 -
        git_dir.display(),
146 -
        r.branch,
147 -
        plural(r.heads, "head"),
148 -
        r.skipped
153 +
        ui::path(git_dir.display()),
154 +
        ui::bookmark(&r.branch),
155 +
        ui::dim(format!("(currently {}; {} already exported)", plural(r.heads, "head"), r.skipped))
149 156
    );
150 157
    Ok(0)
151 158
}
…
5 unchanged lines
157 164
    let r = repo.import_git(git_dir, branch)?;
158 165
    sp.clear();
159 166
    println!(
160 -
        "Imported {} ({} merges) from {} {branch} into {} in {:.1?}: {} now has {}.{}",
167 +
        "{} {} ({} merges) from {} {} into {} {}",
168 +
        ui::done("Imported"),
161 169
        plural(r.commits, "commit"),
162 170
        r.merges,
163 -
        git_dir.display(),
164 -
        repo.root().display(),
165 -
        started.elapsed(),
166 -
        repo.config.bookmark,
167 -
        plural(r.heads, "head"),
168 -
        if r.skipped_submodules > 0 { format!(" Skipped {} submodule entries.", r.skipped_submodules) } else { String::new() }
171 +
        ui::path(git_dir.display()),
172 +
        ui::bookmark(branch),
173 +
        ui::path(repo.root().display()),
174 +
        ui::dim(format!("in {:.1?}; {} now has {}", started.elapsed(), repo.config.bookmark, plural(r.heads, "head")))
169 175
    );
176 +
    if r.skipped_submodules > 0 {
177 +
        println!("  {} {}", ui::note(), ui::dim(format!("skipped {} submodule entries", r.skipped_submodules)));
178 +
    }
170 179
    Ok(0)
171 180
}
172 181
…
11 unchanged lines
modified src/cli/ui.rs +142 −12
…
7 unchanged lines
8 8
use std::time::Duration;
9 9
10 10
static PLAIN: AtomicBool = AtomicBool::new(false);
11 +
static UNICODE: AtomicBool = AtomicBool::new(false);
11 12
static INIT: std::sync::Once = std::sync::Once::new();
12 13
13 -
/// Decide once whether to use colour and animation.
14 +
/// Decide once whether to use colour, glyphs, and animation.
14 15
pub fn init(plain_flag: bool) {
15 16
    INIT.call_once(|| {
16 17
        let no_color = std::env::var_os("NO_COLOR").is_some();
…
1 unchanged lines
18 19
        let plain = plain_flag || no_color || !tty;
19 20
        PLAIN.store(plain, Ordering::Relaxed);
20 21
        console::set_colors_enabled(!plain);
22 +
        let utf8 = ["LC_ALL", "LC_CTYPE", "LANG"]
23 +
            .iter()
24 +
            .filter_map(|k| std::env::var(k).ok())
25 +
            .find(|v| !v.is_empty())
26 +
            .map(|v| v.to_ascii_uppercase().contains("UTF-8") || v.to_ascii_uppercase().contains("UTF8"))
27 +
            .unwrap_or(false);
28 +
        UNICODE.store(!plain && utf8, Ordering::Relaxed);
21 29
    });
22 30
}
23 31
…
1 unchanged lines
25 33
    PLAIN.load(Ordering::Relaxed)
26 34
}
27 35
36 +
/// Whether the terminal takes Unicode glyphs (a UTF-8 locale, not `--plain`).
37 +
pub fn unicode() -> bool {
38 +
    UNICODE.load(Ordering::Relaxed)
39 +
}
40 +
41 +
/// A glyph with an ASCII fallback for plain output and non-UTF-8 locales.
42 +
pub fn glyph<'a>(fancy: &'a str, ascii: &'a str) -> &'a str {
43 +
    if unicode() { fancy } else { ascii }
44 +
}
45 +
46 +
/// A finished action: a tick and the verb in green.
47 +
pub fn done<T: std::fmt::Display>(v: T) -> String {
48 +
    format!("{} {}", ok(glyph("✔", "*")), ok(v))
49 +
}
50 +
51 +
/// A warning line prefix.
52 +
pub fn caution() -> String {
53 +
    warn(glyph("⚠", "!"))
54 +
}
55 +
56 +
/// An informational note prefix.
57 +
pub fn note() -> String {
58 +
    dim(glyph("ℹ", "i"))
59 +
}
60 +
61 +
/// A section title.
62 +
pub fn section<T: std::fmt::Display>(v: T) -> String {
63 +
    format!("{} {}", s(glyph("▍", "#")).cyan(), s(v).bold())
64 +
}
65 +
66 +
/// `  key   value`, with the key dimmed and padded.
67 +
pub fn kv<T: std::fmt::Display>(key: &str, value: T) -> String {
68 +
    format!("  {} {value}", dim(format!("{key:<8}")))
69 +
}
70 +
71 +
/// An author name.
72 +
pub fn author<T: std::fmt::Display>(v: T) -> String {
73 +
    s(v).magenta().to_string()
74 +
}
75 +
76 +
/// A bookmark or reference name.
77 +
pub fn bookmark<T: std::fmt::Display>(v: T) -> String {
78 +
    s(v).green().bold().to_string()
79 +
}
80 +
81 +
/// The subject line of a message.
82 +
pub fn subject<T: std::fmt::Display>(v: T) -> String {
83 +
    s(v).bold().to_string()
84 +
}
85 +
86 +
/// A dimmed separator between the parts of a metadata line.
87 +
pub fn sep() -> String {
88 +
    dim(format!(" {} ", glyph("·", "-")))
89 +
}
90 +
91 +
/// Milliseconds since the epoch as a relative time ("3 minutes ago"), or the
92 +
/// date when older than a month.
93 +
pub fn ago(ms: u64) -> String {
94 +
    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
95 +
    let secs = now.saturating_sub(ms) / 1000;
96 +
    let unit = |n: u64, w: &str| format!("{n} {w}{} ago", if n == 1 { "" } else { "s" });
97 +
    match secs {
98 +
        0..=44 => "just now".into(),
99 +
        45..=89 => "a minute ago".into(),
100 +
        90..=2_699 => unit(secs.div_ceil(60), "minute"),
101 +
        2_700..=5_399 => "an hour ago".into(),
102 +
        5_400..=79_199 => unit(secs.div_ceil(3_600), "hour"),
103 +
        79_200..=129_599 => "yesterday".into(),
104 +
        129_600..=2_591_999 => unit(secs.div_ceil(86_400), "day"),
105 +
        _ => super::format::when(ms).split(' ').next().unwrap_or("").to_string(),
106 +
    }
107 +
}
108 +
109 +
/// The bullet for a log entry: the working copy, a head, or an ordinary change.
110 +
pub fn log_bullet(working_copy: bool, head: bool, conflicted: bool) -> String {
111 +
    if working_copy {
112 +
        s(glyph("◉", "@")).cyan().bold().to_string()
113 +
    } else if conflicted {
114 +
        err(glyph("✱", "!"))
115 +
    } else if head {
116 +
        ok(glyph("●", "*"))
117 +
    } else {
118 +
        dim(glyph("○", "o"))
119 +
    }
120 +
}
121 +
122 +
/// The vertical rail joining log entries.
123 +
pub fn rail() -> String {
124 +
    dim(glyph("│", "|"))
125 +
}
126 +
127 +
/// A status glyph and word for a working-copy change.
128 +
pub fn status_mark(tag: &str) -> String {
129 +
    let (g, a, word) = match tag {
130 +
        "A" => ("✚", "+", "added"),
131 +
        "M" => ("✎", "~", "modified"),
132 +
        "D" => ("✖", "-", "removed"),
133 +
        "X" => ("⚙", "x", "mode"),
134 +
        "C!" => ("‼", "!", "markers"),
135 +
        "R" => ("✔", "r", "resolved"),
136 +
        _ => ("?", "?", "unknown"),
137 +
    };
138 +
    let text = format!("{} {word:<9}", glyph(g, a));
139 +
    self::tag_style(tag, &text)
140 +
}
141 +
142 +
fn tag_style(t: &str, text: &str) -> String {
143 +
    match t {
144 +
        "A" | "R" => s(text).green().to_string(),
145 +
        "M" | "X" => s(text).yellow().to_string(),
146 +
        "D" | "C!" => s(text).red().to_string(),
147 +
        _ => text.to_string(),
148 +
    }
149 +
}
150 +
28 151
fn s<T: std::fmt::Display>(v: T) -> StyledObject<T> {
29 152
    style(v)
30 153
}
…
23 unchanged lines
54 177
}
55 178
pub fn heading<T: std::fmt::Display>(v: T) -> String {
56 179
    s(v).bold().underlined().to_string()
57 -
}
58 -
/// A status tag: green for additions, yellow for modifications, red for removals and conflicts.
59 -
pub fn tag(t: &str) -> String {
60 -
    match t {
61 -
        "A" => s(t).green().bold().to_string(),
62 -
        "M" | "X" => s(t).yellow().bold().to_string(),
63 -
        "D" | "C!" => s(t).red().bold().to_string(),
64 -
        "R" => s(t).cyan().bold().to_string(),
65 -
        other => other.to_string(),
66 -
    }
67 180
}
68 -
69 181
/// A live spinner with a message, shown only on a terminal. Drop or call
70 182
/// `done` to clear it; `done` prints the final line in its place.
71 183
pub struct Spinner {
…
26 unchanged lines
98 210
        }
99 211
    }
100 212
}
213 +
214 +
#[cfg(test)]
215 +
mod tests {
216 +
    use super::*;
217 +
218 +
    #[test]
219 +
    fn relative_times() {
220 +
        let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
221 +
        assert_eq!(ago(now), "just now");
222 +
        assert_eq!(ago(now - 60_000), "a minute ago");
223 +
        assert_eq!(ago(now - 5 * 60_000), "5 minutes ago");
224 +
        assert_eq!(ago(now - 3 * 3_600_000), "3 hours ago");
225 +
        assert_eq!(ago(now - 25 * 3_600_000), "yesterday");
226 +
        assert_eq!(ago(now - 4 * 86_400_000), "4 days ago");
227 +
        assert_eq!(ago(0), "1970-01-01");
228 +
        assert_eq!(glyph("✔", "*"), if unicode() { "✔" } else { "*" });
229 +
    }
230 +
}
modified src/cli/workspace.rs +115 −63
1 1
//! Commands that act on the local repository and working copy.
2 2
3 -
use super::format::{deps_line, deps_of, plural, short_id, short_meta_id, signature_line, when};
3 +
use super::format::{author_name, deps_line, deps_of, plural, short_id, short_meta_id, signature_line, signature_mark, subject_of, when};
4 4
use super::open;
5 5
use super::ui;
6 6
use anyhow::Result;
7 +
use oxy_core::objects::ChangeKind;
7 8
use oxy_core::repo::{render_change, ChangeStatus, Repo};
8 9
use std::path::PathBuf;
9 10
10 11
pub fn init(path: Option<PathBuf>) -> Result<i32> {
11 12
    let path = path.unwrap_or(std::env::current_dir()?);
12 13
    let repo = Repo::init(&path)?;
13 -
    println!("{} oxy repository in {}", ui::ok("Initialized"), ui::path(repo.root().display()));
14 -
    println!("  bookmark {}   author {} <{}>", ui::id(&repo.config.bookmark), repo.config.author_name, repo.config.author_email);
14 +
    println!("{} oxy repository in {}", ui::done("Initialized"), ui::path(repo.root().display()));
15 +
    println!("{}", ui::kv("bookmark", ui::bookmark(&repo.config.bookmark)));
16 +
    println!("{}", ui::kv("author", format!("{} <{}>", ui::author(&repo.config.author_name), repo.config.author_email)));
15 17
    Ok(0)
16 18
}
17 19
…
15 unchanged lines
33 35
    let heads = repo.bookmark_heads()?;
34 36
    sp.clear();
35 37
    println!(
36 -
        "{} {} on {} {}",
37 -
        ui::heading("Working copy"),
38 +
        "{} {}{}working copy {}{}{}",
39 +
        ui::dim("On"),
40 +
        ui::bookmark(&repo.config.bookmark),
41 +
        ui::sep(),
38 42
        ui::id(snap.wc.change_id.short(8)),
39 -
        ui::id(&repo.config.bookmark),
40 -
        ui::dim(format!("({})", plural(heads.len(), "head")))
43 +
        ui::sep(),
44 +
        ui::dim(plural(heads.len(), "head"))
41 45
    );
42 46
    if snap.statuses.is_empty() {
43 -
        println!("  {}", ui::dim("clean"));
44 -
    }
45 -
    for s in &snap.statuses {
46 -
        println!("  {:2} {}", ui::tag(status_tag(s.status)), ui::path(&s.path));
47 +
        println!("  {} {}", ui::ok(ui::glyph("✔", "*")), ui::dim("nothing to commit, working copy clean"));
48 +
    } else {
49 +
        println!();
50 +
        for s in &snap.statuses {
51 +
            println!("  {} {}", ui::status_mark(status_tag(s.status)), ui::path(&s.path));
52 +
        }
47 53
    }
48 54
    if !snap.base_snapshot.is_conflicted() {
49 55
        return Ok(0);
50 56
    }
51 57
    println!();
52 -
    println!("{} {}", ui::err("Conflicts"), ui::dim("edit the marked files, or the .oxy-conflict-N candidates, then commit"));
58 +
    println!("{} {}", ui::section(ui::err("Conflicts")), ui::dim("edit the marked files, or the .oxy-conflict-N candidates, then commit"));
53 59
    for c in &snap.base_snapshot.tree.conflicts {
54 -
        println!("  tree: {c:?}");
60 +
        println!("  {} tree: {c:?}", ui::err(ui::glyph("✱", "!")));
55 61
    }
56 62
    for (p, f) in &snap.base_snapshot.files {
57 63
        if f.is_conflicted() {
58 64
            let state = match snap.statuses.iter().find(|s| s.path == *p).map(|s| s.status) {
59 -
                Some(ChangeStatus::Resolved) => ui::ok("resolved in working copy"),
60 -
                Some(ChangeStatus::ConflictEdited) => ui::warn("markers remain"),
61 -
                _ => ui::err("unresolved"),
65 +
                Some(ChangeStatus::Resolved) => ui::ok(format!("{} resolved in working copy", ui::glyph("✔", "*"))),
66 +
                Some(ChangeStatus::ConflictEdited) => ui::warn(format!("{} markers remain", ui::glyph("‼", "!"))),
67 +
                _ => ui::err(format!("{} unresolved", ui::glyph("✱", "!"))),
62 68
            };
63 69
            println!("  {}  {state}", ui::path(p));
64 70
        }
…
10 unchanged lines
75 81
    let sp = ui::spinner("Committing…");
76 82
    let h = repo.commit(message, &deps)?;
77 83
    let c = repo.store().get_change(&h)?;
78 -
    sp.done(&format!("{} {} {} {}", ui::ok("Committed"), ui::id(short_id(&c)), ui::hash(format!("#{}", h.short(12))), message.lines().next().unwrap_or("")));
84 +
    sp.done(&format!("{} {} {}  {}", ui::done("Committed"), ui::id(short_id(&c)), ui::hash(format!("#{}", h.short(12))), ui::subject(subject_of(message))));
79 85
    Ok(0)
80 86
}
81 87
82 -
pub fn log() -> Result<i32> {
88 +
/// The log: one entry per change, newest first, joined by a rail. The
89 +
/// default view is the subject and a metadata line; `verbose` adds the full
90 +
/// message, dependencies, and the authenticating key.
91 +
pub fn log(limit: Option<usize>, verbose: bool) -> Result<i32> {
83 92
    let repo = open()?;
84 -
    repo.snapshot("oxy log")?;
85 -
    for e in repo.log()? {
93 +
    let snap = repo.snapshot("oxy log")?;
94 +
    let conflicted = snap.base_snapshot.is_conflicted();
95 +
    let entries = repo.log()?;
96 +
    let total = entries.len();
97 +
    let shown = limit.unwrap_or(total).min(total);
98 +
    let rail = ui::rail();
99 +
    for (i, e) in entries.iter().take(shown).enumerate() {
86 100
        let c = &e.change;
87 -
        let mut flags = Vec::new();
101 +
        let bullet = ui::log_bullet(e.is_working_copy, e.is_head, e.is_working_copy && conflicted);
102 +
        let mut labels = Vec::new();
88 103
        if e.is_working_copy {
89 -
            flags.push("working copy");
104 +
            labels.push(ui::warn("working copy"));
90 105
        }
91 -
        if e.is_head {
92 -
            flags.push("head");
106 +
        if c.kind == ChangeKind::Resolution {
107 +
            labels.push(ui::warn("resolution"));
93 108
        }
94 -
        let flags = if flags.is_empty() { String::new() } else { ui::warn(format!(" ({})", flags.join(", "))) };
95 -
        println!("{} {}{}", ui::id(short_meta_id(c)), ui::hash(format!("#{}", e.hash.short(12))), flags);
96 -
        println!("    {}", ui::dim(when(c.hlc.wall_ms)));
97 -
        if !c.deps.is_empty() {
98 -
            println!("    {}", ui::dim(format!("deps: {}", deps_of(&c.deps))));
109 +
        let labels = if labels.is_empty() { String::new() } else { format!(" {}{}{}", ui::dim("("), labels.join(ui::dim(", ").as_str()), ui::dim(")")) };
110 +
        let subject = if e.is_working_copy && c.message.is_empty() {
111 +
            ui::dim(if snap.statuses.is_empty() { "(clean)" } else { "(uncommitted changes)" })
112 +
        } else {
113 +
            ui::subject(subject_of(&c.message))
114 +
        };
115 +
        println!("{bullet} {}{labels}  {subject}", ui::id(short_meta_id(c)));
116 +
        let meta = format!(
117 +
            "{}{}{}{}{}{}{}",
118 +
            ui::hash(format!("#{}", e.hash.short(12))),
119 +
            ui::sep(),
120 +
            ui::author(author_name(&repo, c)),
121 +
            ui::sep(),
122 +
            ui::dim(ui::ago(c.hlc.wall_ms)),
123 +
            ui::sep(),
124 +
            if e.is_working_copy { ui::dim("not yet committed") } else { signature_mark(&repo, &e.hash)? }
125 +
        );
126 +
        println!("{rail} {meta}");
127 +
        if verbose {
128 +
            if !e.is_working_copy {
129 +
                println!("{rail} {}", ui::dim(format!("{} {}", when(c.hlc.wall_ms), signature_line(&repo, &e.hash)?)));
130 +
            }
131 +
            if !c.deps.is_empty() {
132 +
                println!("{rail} {} {}", ui::dim(ui::glyph("↳", "->")), ui::dim(deps_of(&c.deps)));
133 +
            }
134 +
            let body: Vec<&str> = c.message.lines().skip(1).skip_while(|l| l.trim().is_empty()).collect();
135 +
            if !body.is_empty() {
136 +
                println!("{rail}");
137 +
                for line in body {
138 +
                    println!("{rail}   {line}");
139 +
                }
140 +
            }
99 141
        }
100 -
        let msg = if c.message.is_empty() { "(no description)" } else { &c.message };
101 -
        for line in msg.lines() {
102 -
            println!("    {line}");
142 +
        if i + 1 < shown {
143 +
            println!("{rail}");
103 144
        }
104 -
        println!();
105 145
    }
146 +
    if shown < total {
147 +
        println!("{}", ui::dim(format!("{} {} more; pass -n to see them", ui::glyph("…", "..."), total - shown)));
148 +
    }
106 149
    Ok(0)
107 150
}
108 151
…
9 unchanged lines
118 161
    repo.snapshot("oxy show")?;
119 162
    let h = repo.resolve_change(change)?;
120 163
    let c = repo.store().get_change(&h)?;
121 -
    println!("{} {} {}", ui::heading("change"), ui::id(short_id(&c)), ui::hash(format!("#{h}")));
122 -
    println!("  kind  {:?}", c.kind);
123 -
    println!("  date  {}", when(c.hlc.wall_ms));
124 -
    println!("  deps  {}", ui::dim(deps_line(&c)));
125 -
    println!("  sig   {}", signature_line(&repo, &h)?);
126 -
    println!("\n{}\n", if c.message.is_empty() { "(no description)" } else { &c.message });
164 +
    println!("{} {} {}", ui::section("change"), ui::id(short_id(&c)), ui::hash(format!("#{h}")));
165 +
    println!("{}", ui::kv("kind", format!("{:?}", c.kind).to_lowercase()));
166 +
    println!("{}", ui::kv("author", ui::author(author_name(&repo, &oxy_core::state::ChangeMeta::of(&c)))));
167 +
    println!("{}", ui::kv("date", format!("{} {}", when(c.hlc.wall_ms), ui::dim(format!("({})", ui::ago(c.hlc.wall_ms))))));
168 +
    println!("{}", ui::kv("deps", ui::dim(deps_line(&c))));
169 +
    println!("{}", ui::kv("sig", signature_line(&repo, &h)?));
170 +
    println!();
171 +
    for line in (if c.message.is_empty() { "(no description)" } else { &c.message }).lines() {
172 +
        println!("    {line}");
173 +
    }
174 +
    println!();
127 175
    print!("{}", repo.describe_change(&h)?);
128 176
    Ok(0)
129 177
}
…
12 unchanged lines
142 190
    let spec = if changes.is_empty() { repo.config.bookmark.clone() } else { changes.join(" ") };
143 191
    let sp = ui::spinner(&format!("Checking out {spec}…"));
144 192
    let r = repo.checkout(&heads, discard, &format!("oxy checkout {spec}"))?;
145 -
    sp.done(&format!("{} on {}: {} written, {} removed", ui::ok("Working copy"), ui::id(&spec), plural(r.written, "file"), r.removed));
193 +
    sp.done(&format!("{} on {}: {} written, {} removed", ui::done("Working copy"), ui::bookmark(&spec), plural(r.written, "file"), r.removed));
146 194
    if r.conflicted {
147 -
        println!("  {}", ui::warn("this state has conflicts; see oxy status"));
195 +
        println!("  {} {}", ui::caution(), ui::warn("this state has conflicts; see oxy status"));
148 196
    }
149 197
    Ok(0)
150 198
}
…
37 unchanged lines
188 236
pub fn undo() -> Result<i32> {
189 237
    let repo = open()?;
190 238
    let op = repo.undo()?;
191 -
    println!("{} now at: {}", ui::ok("Undone;"), ui::dim(&op.command));
239 +
    println!("{} now at: {}", ui::done("Undone;"), ui::dim(&op.command));
192 240
    Ok(0)
193 241
}
194 242
195 243
pub fn op_log() -> Result<i32> {
196 244
    let repo = open()?;
197 245
    for (i, (r, op)) in repo.op_log()?.into_iter().enumerate() {
198 -
        println!("{}{} {}  {}", if i == 0 { ui::ok("@ ") } else { "  ".into() }, ui::hash(r.short(12)), ui::dim(when(op.hlc.wall_ms)), op.command);
246 +
        let bullet = if i == 0 { ui::ok(ui::glyph("●", "@")) } else { ui::dim(ui::glyph("○", "o")) };
247 +
        let cmd = subject_of(&op.command).split("\\n").next().unwrap_or("");
248 +
        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() });
199 249
    }
200 250
    Ok(0)
201 251
}
…
41 unchanged lines
243 293
    };
244 294
    let new = repo.describe(&h, message)?;
245 295
    let c = repo.store().get_change(&new)?;
246 -
    println!("{} {} {} {}", ui::ok("Described"), ui::id(short_id(&c)), ui::hash(format!("#{}", new.short(12))), message.lines().next().unwrap_or(""));
296 +
    println!("{} {} {}  {}", ui::done("Described"), ui::id(short_id(&c)), ui::hash(format!("#{}", new.short(12))), ui::subject(subject_of(message)));
247 297
    Ok(0)
248 298
}
249 299
…
7 unchanged lines
257 307
        None => repo.amend(message)?,
258 308
    };
259 309
    let c = repo.store().get_change(&new)?;
260 -
    println!("{} {} {} {}", ui::ok("Amended"), ui::id(short_id(&c)), ui::hash(format!("#{}", new.short(12))), c.message.lines().next().unwrap_or(""));
310 +
    println!("{} {} {}  {}", ui::done("Amended"), ui::id(short_id(&c)), ui::hash(format!("#{}", new.short(12))), ui::subject(subject_of(&c.message)));
261 311
    Ok(0)
262 312
}
263 313
…
2 unchanged lines
266 316
    let h = repo.resolve_change(change)?;
267 317
    let c = repo.store().get_change(&h)?;
268 318
    let notes = repo.abandon(&h)?;
269 -
    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"))
…
9 unchanged lines
347 398
    }
348 399
    for t in tags {
349 400
        println!(
350 -
            "{} {} {}{}{}",
351 -
            ui::id(&t.name),
401 +
            "{} {} {}{}{}{}",
402 +
            ui::ok(ui::glyph("⚑", "#")),
403 +
            ui::bookmark(&t.name),
352 404
            ui::hash(format!("#{}", t.state.short(12))),
353 -
            ui::dim(format!("({})", plural(t.heads.len(), "head"))),
354 -
            if t.message.is_empty() { String::new() } else { format!("  {}", t.message.lines().next().unwrap_or("")) },
355 -
            t.signer.map(|s| ui::dim(format!("  [{s}]"))).unwrap_or_default()
405 +
            ui::sep() + &ui::dim(plural(t.heads.len(), "head")),
406 +
            if t.message.is_empty() { String::new() } else { format!("  {}", ui::subject(subject_of(&t.message))) },
407 +
            t.signer.map(|s| ui::sep() + &ui::ok(format!("{} {s}", ui::glyph("✔", "v")))).unwrap_or_default()
356 408
        );
357 409
    }
358 410
    Ok(0)
…
12 unchanged lines
371 423
    };
372 424
    let sp = ui::spinner(&format!("Creating workspace at {}…", path.display()));
373 425
    let ws = repo.workspace_add(path, heads.as_deref())?;
374 -
    sp.done(&format!("{} {} (workspace {})", ui::ok("Workspace created at"), ui::path(ws.root().display()), ui::id(ws.workspace().short(8))));
426 +
    sp.done(&format!("{} {} (workspace {})", ui::done("Workspace created at"), ui::path(ws.root().display()), ui::id(ws.workspace().short(8))));
375 427
    Ok(0)
376 428
}
377 429
…
2 unchanged lines
380 432
    for w in repo.workspaces()? {
381 433
        println!(
382 434
            "{} {} {}{}",
383 -
            if w.current { ui::ok("*") } else { " ".into() },
435 +
            if w.current { ui::ok(ui::glyph("●", "*")) } else { ui::dim(ui::glyph("○", "o")) },
384 436
            ui::id(w.id.short(8)),
385 437
            ui::path(w.path.display()),
386 -
            w.working_copy.map(|h| ui::dim(format!("  wc #{}", h.short(12)))).unwrap_or_else(|| ui::warn("  (not in view)"))
438 +
            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"))
387 439
        );
388 440
    }
389 441
    Ok(0)
…
10 unchanged lines
400 452
        _ => anyhow::bail!("{spec:?} is ambiguous"),
401 453
    };
402 454
    repo.workspace_forget(&w.id)?;
403 -
    println!("{} workspace {} at {}; its files were left in place.", ui::ok("Forgot"), ui::id(w.id.short(8)), ui::path(w.path.display()));
455 +
    println!("{} workspace {} at {}; its files were left in place.", ui::done("Forgot"), ui::id(w.id.short(8)), ui::path(w.path.display()));
404 456
    Ok(0)
405 457
}
406 458
407 459
pub fn lock(path: &str) -> Result<i32> {
408 460
    let repo = open()?;
409 461
    repo.lock_path(path)?;
410 -
    println!("{} {} (advisory, local)", ui::ok("Locked"), ui::path(path));
462 +
    println!("{} {} {}", ui::done("Locked"), ui::path(path), ui::dim("(advisory, local)"));
411 463
    Ok(0)
412 464
}
413 465
414 466
pub fn unlock(path: &str) -> Result<i32> {
415 467
    let repo = open()?;
416 468
    repo.unlock_path(path)?;
417 -
    println!("{} {}", ui::ok("Unlocked"), ui::path(path));
469 +
    println!("{} {}", ui::done("Unlocked"), ui::path(path));
418 470
    Ok(0)
419 471
}
420 472
…
3 unchanged lines
424 476
    let (h1, h2) = repo.split(&h, paths)?;
425 477
    let c1 = repo.store().get_change(&h1)?;
426 478
    let c2 = repo.store().get_change(&h2)?;
427 -
    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))));
479 +
    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))));
428 480
    Ok(0)
429 481
}