OXYGENHUB

User config file layered under repo config, user-scoped identities; split the CLI into modules; fix clone remote persistence fce44ae9ad29

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-26 18:55 UTC
Signature
✔ signed by Andy McCall
Hash
fce44ae9ad290390138d421f79733a5257e2ce15cbc87ad780861d1b44677c17
Depends on
096f447ccda0 1983686fb7a7 20247a03a596 5b1114a04905 5b9c6ad45f6e 750251b21a96 86e682c242c6 f9c98deef326 ff0af1ee299d
Depended on by
232aabb5452d 284fe47d98b9 aded4110f0bd c397a8c36a9f 91334b7fa574 569802ebae0e 642da2636de2 64dc5197f9df c0d4075cadc0 4afe288b7946 9bd98cfb4f7e 544d0541a7e5 157d02e95586 b63fc7a77fc6 d36ba2f79f95 69953b507188 0299076f09a2 44f1ebe7aa5b 733ea16a9701 767ac6cd0545 51d4862a16d8 e59512f2f075 4b9d06778581 68dcf21fe055 c895bc52e4e0 3a6effc74835 74b4a3c62ea4 a39754d02c58 b582948f1912 8e9c64d16ded b9bf7eec1f2d 9b723f69b032 b689779e9e74 530a0fc650a2 34fad4a92451 d6e0d5b8b339 cb8b8082fa73 79b5a90ffd43 7505194116e7 f6dccc4533c8 8b7986dd3cb0 42ccf504aa50 f0406c3b40ed 54e2e6309aa3 4afab467e35f 72484195899a bf3a67819b59 b0a0c9afae2d 7993d465675b 5ad2b56544c5 d069701a4997 614f55e14eee 5cde7882d767 58dfe0e134eb 2c9ddac3c387 47ce8b8e2d4b 7ba155f20aa0 9ae4d15a0e4b d8d9367878a6 9aa399044d9d 8d80356371c5 a46f3e4f50b1 827281eed2ed b35df36b7dc5 631fc8970bd0 5b4c51307b14 fddf64baa3d2 33472ccc08cc 10b4fd430eab 77445673174b 3604179679eb c07406bcc237 98a1620a3840 2bf22305bc48 14edbaaf5291 ff1f62fee654 69692f14b80f d4dbab97e948 e266b7d4065c 4cce912a0953 06d16dc2bc13 7eac74abc586 da652d6392d8 69816d688300 d3cd7ef95da6 da868d5d2646 d443639bcf35 7d9dab131937 1a3b62032ed9 4fe7d6045860 e293c90dca62 dfb23a3a8cfa fc1a466a1e66 83d61e803ccd 8ab265dc4a51 e7ac6c2e84e4 6a81dbab471d 1c2bd9abdd4f 61e3bce7ea5b f8c695764c95 cc7a00565161 60a1223c894f e3d071cecfe2 62794ecc7f74 99e3e0af08df d42369abf180 3e5772ccf9da 387335ff7f4d 4213c41dfc64 d82e44097323 119bbf50cb86 7aa2dc9369e3 b0e64e81e4fb 05e71ed6cdf1 75167672ebcd e02bf0cd0533 1196b29b5fa9 c6aa5bd9d1c8 728da0f92161 957fee30826e 4dee277c327a 7b9809e67c3d b3c5a59a875a 694805cca585 50a046e439e9 30ea9c1e70fa 6e723b81f5b9 6706bdcfb63e 94084087a876 28fc23d7706a 23b217d75848 cdb5beba7f05 4e88e774777a 132b884b9a8d 2863456a0f6d 7421309f5372 135212de5370 252e8cb23fbc c3069537d7c5 e8b4e156f2fa 79ab8528ff62 ba160f60219e 79d03f826ecd 614541c3cebd d02e545dc265 83e0677b07fe 2b5ee371060e
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
52 atoms, 6 tree operations, 9 files

Browse the tree at this change

added src/cli/format.rs +62 −0
1 +
//! Shared presentation helpers.
2 +
3 +
use anyhow::Result;
4 +
use oxy_core::hash::ChangeHash;
5 +
use oxy_core::objects::Change;
6 +
use oxy_core::repo::{Repo, SignatureStatus};
7 +
8 +
pub fn short_id(c: &Change) -> String {
9 +
    c.change_id.short(8)
10 +
}
11 +
12 +
/// `n head` / `n heads`.
13 +
pub fn plural(n: usize, word: &str) -> String {
14 +
    format!("{n} {word}{}", if n == 1 { "" } else { "s" })
15 +
}
16 +
17 +
/// Milliseconds since the Unix epoch as `YYYY-MM-DD HH:MM:SSZ`.
18 +
pub fn when(ms: u64) -> String {
19 +
    let secs = (ms / 1000) as i64;
20 +
    let days = secs.div_euclid(86_400);
21 +
    let rem = secs.rem_euclid(86_400);
22 +
    // Civil-from-days (Howard Hinnant), UTC.
23 +
    let z = days + 719_468;
24 +
    let era = z.div_euclid(146_097);
25 +
    let doe = z - era * 146_097;
26 +
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
27 +
    let y = yoe + era * 400;
28 +
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
29 +
    let mp = (5 * doy + 2) / 153;
30 +
    let d = doy - (153 * mp + 2) / 5 + 1;
31 +
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
32 +
    let y = if m <= 2 { y + 1 } else { y };
33 +
    format!("{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}Z", rem / 3600, (rem % 3600) / 60, rem % 60)
34 +
}
35 +
36 +
/// One line describing how a change is authenticated.
37 +
pub fn signature_line(repo: &Repo, h: &ChangeHash) -> Result<String> {
38 +
    Ok(match repo.authenticate_change(h)? {
39 +
        SignatureStatus::Unsigned => "unsigned".to_string(),
40 +
        SignatureStatus::Valid { key, name, email, sealed } => {
41 +
            format!("{} by {name} <{email}> key {}", if sealed { "sealed" } else { "signed" }, key.short(12))
42 +
        }
43 +
        SignatureStatus::Invalid(why) => format!("INVALID signature: {why}"),
44 +
    })
45 +
}
46 +
47 +
pub fn deps_line(c: &Change) -> String {
48 +
    c.deps.iter().map(|d| format!("#{}", d.short(12))).collect::<Vec<_>>().join(" ")
49 +
}
50 +
51 +
#[cfg(test)]
52 +
mod tests {
53 +
    use super::*;
54 +
55 +
    #[test]
56 +
    fn dates() {
57 +
        assert_eq!(when(0), "1970-01-01 00:00:00Z");
58 +
        assert_eq!(when(1_700_000_000_000), "2023-11-14 22:13:20Z");
59 +
        assert_eq!(plural(1, "head"), "1 head");
60 +
        assert_eq!(plural(2, "head"), "2 heads");
61 +
    }
62 +
}
added src/cli/identity.rs +73 −0
1 +
//! Identity, verification, and configuration commands.
2 +
3 +
use super::format::{short_id, signature_line, when};
4 +
use super::open;
5 +
use anyhow::Result;
6 +
use oxy_core::hash::ChangeHash;
7 +
use oxy_core::repo::{self, IdentityScope};
8 +
9 +
pub fn create(name: Option<String>, email: Option<String>, local: bool) -> Result<i32> {
10 +
    let mut repo = open()?;
11 +
    let name = name.unwrap_or_else(|| repo.config.author_name.clone());
12 +
    let email = email.unwrap_or_else(|| repo.config.author_email.clone());
13 +
    let scope = if local { IdentityScope::Repo } else { IdentityScope::User };
14 +
    let (key, _) = repo.create_identity(&name, &email, scope)?;
15 +
    println!("Created identity {name} <{email}>");
16 +
    println!("KeyId: {key}");
17 +
    println!("Secret key stored under {}", repo::default_keys_dir().display());
18 +
    match scope {
19 +
        IdentityScope::User => println!("Recorded as your default author in {}", repo.user_config_path().display()),
20 +
        IdentityScope::Repo => println!("Recorded in this repository's .oxy/config only"),
21 +
    }
22 +
    if !repo.config.sign_changes {
23 +
        println!("Changes will carry this author; run `oxy config --user core.sign change` to sign each commit.");
24 +
    }
25 +
    Ok(0)
26 +
}
27 +
28 +
pub fn show() -> Result<i32> {
29 +
    let repo = open()?;
30 +
    let Some(key) = repo.config.author_key else {
31 +
        println!("No identity configured; run `oxy identity create`.");
32 +
        return Ok(0);
33 +
    };
34 +
    let id = repo.identity(&key)?;
35 +
    println!("{} <{}>", id.name, id.email);
36 +
    println!("KeyId:   {key}");
37 +
    println!("Created: {}", when(id.created));
38 +
    println!("Secret:  {}", if repo.secret_key()?.is_some() { "available" } else { "NOT FOUND in key directory" });
39 +
    println!("Signing: {}", if repo.config.sign_changes { "every change (core.sign = change)" } else { "at push via seals (core.sign = seal)" });
40 +
    Ok(0)
41 +
}
42 +
43 +
pub fn verify(change: Option<&str>) -> Result<i32> {
44 +
    let repo = open()?;
45 +
    let hashes: Vec<ChangeHash> = match change {
46 +
        Some(c) => vec![repo.resolve_change(c)?],
47 +
        None => repo.log()?.into_iter().filter(|e| !e.is_working_copy).map(|e| e.hash).collect(),
48 +
    };
49 +
    let mut bad = 0;
50 +
    for h in hashes {
51 +
        let c = repo.store().get_change(&h)?;
52 +
        let line = signature_line(&repo, &h)?;
53 +
        if line.starts_with("INVALID") {
54 +
            bad += 1;
55 +
        }
56 +
        println!("{} #{}  {line}", short_id(&c), h.short(12));
57 +
    }
58 +
    Ok(if bad > 0 { 1 } else { 0 })
59 +
}
60 +
61 +
pub fn config(key: &str, value: &str, user: bool) -> Result<i32> {
62 +
    if user {
63 +
        let path = repo::default_user_config();
64 +
        repo::Config::set_user(&path, key, value)?;
65 +
        println!("{key} = {value} (in {})", path.display());
66 +
    } else {
67 +
        let mut repo = open()?;
68 +
        repo.config.set(key, value)?;
69 +
        repo.save_config()?;
70 +
        println!("{key} = {value} (in this repository)");
71 +
    }
72 +
    Ok(0)
73 +
}
added src/cli/mod.rs +167 −0
1 +
//! Command-line definitions and dispatch.
2 +
3 +
mod format;
4 +
mod identity;
5 +
mod sync;
6 +
mod workspace;
7 +
8 +
use anyhow::{Context, Result};
9 +
use clap::{Parser, Subcommand};
10 +
use oxy_core::repo::Repo;
11 +
use std::path::PathBuf;
12 +
13 +
#[derive(Parser)]
14 +
#[command(name = "oxy", version, about = "Oxygen: breathing fresh air into version control")]
15 +
struct Cli {
16 +
    #[command(subcommand)]
17 +
    cmd: Cmd,
18 +
}
19 +
20 +
#[derive(Subcommand)]
21 +
enum Cmd {
22 +
    /// Create a repository in PATH (default: current directory)
23 +
    Init { path: Option<PathBuf> },
24 +
    /// Clone a repository from a local path or an http(s) URL
25 +
    Clone { url: String, dest: Option<PathBuf> },
26 +
    /// Show the working copy: changed paths and conflicts
27 +
    Status,
28 +
    /// Record the working copy as a change on the current bookmark
29 +
    Commit {
30 +
        #[arg(short, long)]
31 +
        message: String,
32 +
        /// Extra explicit dependencies (ChangeId prefix, #hash prefix)
33 +
        #[arg(long = "depends-on")]
34 +
        depends_on: Vec<String>,
35 +
    },
36 +
    /// List changes reachable from the bookmark and the working copy
37 +
    Log,
38 +
    /// Show what the working copy changes against its base
39 +
    Diff,
40 +
    /// Show one change (ChangeId prefix, #hash prefix, or @)
41 +
    Show { change: String },
42 +
    /// Move the working copy to the state formed by CHANGE... (default: the bookmark heads)
43 +
    Checkout {
44 +
        changes: Vec<String>,
45 +
        /// Throw away uncommitted working-copy changes
46 +
        #[arg(long)]
47 +
        discard: bool,
48 +
    },
49 +
    /// Report which conflicts of the working copy's base are resolved by the current files
50 +
    Resolve,
51 +
    /// Undo the last operation
52 +
    Undo,
53 +
    /// List operations
54 +
    #[command(name = "op")]
55 +
    Op {
56 +
        #[command(subcommand)]
57 +
        cmd: OpCmd,
58 +
    },
59 +
    /// Manage remotes
60 +
    Remote {
61 +
        #[command(subcommand)]
62 +
        cmd: RemoteCmd,
63 +
    },
64 +
    /// Fetch every bookmark of a remote into its tracking references
65 +
    Fetch { remote: Option<String> },
66 +
    /// Fetch, then union the remote bookmark into the local one (no merge change)
67 +
    Pull { remote: Option<String> },
68 +
    /// Push the current bookmark to a remote
69 +
    Push {
70 +
        remote: Option<String>,
71 +
        /// Allow dropping heads the remote has
72 +
        #[arg(long)]
73 +
        force: bool,
74 +
    },
75 +
    /// Serve repositories under ROOT over HTTP (spec §16); TLS is terminated in front
76 +
    Serve {
77 +
        /// Directory with one repository per subdirectory
78 +
        #[arg(long, default_value = ".")]
79 +
        root: PathBuf,
80 +
        #[arg(long, default_value = "127.0.0.1:8890")]
81 +
        bind: String,
82 +
        /// Bearer token allowed to push (repeatable); none means open
83 +
        #[arg(long = "token")]
84 +
        tokens: Vec<String>,
85 +
    },
86 +
    /// Manage the signing identity
87 +
    Identity {
88 +
        #[command(subcommand)]
89 +
        cmd: IdentityCmd,
90 +
    },
91 +
    /// Verify the signatures on a change (default: every change in the log)
92 +
    Verify { change: Option<String> },
93 +
    /// Set a configuration key (author.*, core.sign, workspace.bookmark, remote.<name>.url|token)
94 +
    Config {
95 +
        key: String,
96 +
        value: String,
97 +
        /// Write to the user file (~/.config/oxy/config.toml) instead of this repository
98 +
        #[arg(long)]
99 +
        user: bool,
100 +
    },
101 +
}
102 +
103 +
#[derive(Subcommand)]
104 +
enum OpCmd {
105 +
    Log,
106 +
}
107 +
108 +
#[derive(Subcommand)]
109 +
enum RemoteCmd {
110 +
    Add { name: String, url: String },
111 +
    Remove { name: String },
112 +
    List,
113 +
}
114 +
115 +
#[derive(Subcommand)]
116 +
enum IdentityCmd {
117 +
    /// Generate a composite Ed25519 + ML-DSA-65 key pair and make it your author identity
118 +
    Create {
119 +
        #[arg(long)]
120 +
        name: Option<String>,
121 +
        #[arg(long)]
122 +
        email: Option<String>,
123 +
        /// Record the author in this repository only, not in the user config
124 +
        #[arg(long)]
125 +
        local: bool,
126 +
    },
127 +
    /// Show the configured identity
128 +
    Show,
129 +
}
130 +
131 +
/// Open the repository containing the current directory.
132 +
fn open() -> Result<Repo> {
133 +
    Repo::open(&std::env::current_dir()?).context("opening repository")
134 +
}
135 +
136 +
/// Parse arguments and run; returns the process exit code.
137 +
pub fn run() -> Result<i32> {
138 +
    let cli = Cli::parse();
139 +
    match cli.cmd {
140 +
        Cmd::Init { path } => workspace::init(path),
141 +
        Cmd::Status => workspace::status(),
142 +
        Cmd::Commit { message, depends_on } => workspace::commit(&message, &depends_on),
143 +
        Cmd::Log => workspace::log(),
144 +
        Cmd::Diff => workspace::diff(),
145 +
        Cmd::Show { change } => workspace::show(&change),
146 +
        Cmd::Checkout { changes, discard } => workspace::checkout(&changes, discard),
147 +
        Cmd::Resolve => workspace::resolve(),
148 +
        Cmd::Undo => workspace::undo(),
149 +
        Cmd::Op { cmd: OpCmd::Log } => workspace::op_log(),
150 +
        Cmd::Clone { url, dest } => sync::clone(&url, dest),
151 +
        Cmd::Remote { cmd } => match cmd {
152 +
            RemoteCmd::Add { name, url } => sync::remote_add(&name, &url),
153 +
            RemoteCmd::Remove { name } => sync::remote_remove(&name),
154 +
            RemoteCmd::List => sync::remote_list(),
155 +
        },
156 +
        Cmd::Fetch { remote } => sync::fetch(remote.as_deref()),
157 +
        Cmd::Pull { remote } => sync::pull(remote.as_deref()),
158 +
        Cmd::Push { remote, force } => sync::push(remote.as_deref(), force),
159 +
        Cmd::Serve { root, bind, tokens } => sync::serve(root, &bind, tokens),
160 +
        Cmd::Identity { cmd } => match cmd {
161 +
            IdentityCmd::Create { name, email, local } => identity::create(name, email, local),
162 +
            IdentityCmd::Show => identity::show(),
163 +
        },
164 +
        Cmd::Verify { change } => identity::verify(change.as_deref()),
165 +
        Cmd::Config { key, value, user } => identity::config(&key, &value, user),
166 +
    }
167 +
}
added src/cli/sync.rs +110 −0
1 +
//! Commands that talk to remotes, and the reference server.
2 +
3 +
use super::format::plural;
4 +
use super::open;
5 +
use anyhow::{Context, Result};
6 +
use oxy_core::repo::Repo;
7 +
use std::path::PathBuf;
8 +
9 +
pub fn clone(url: &str, dest: Option<PathBuf>) -> Result<i32> {
10 +
    let dest = dest.unwrap_or_else(|| {
11 +
        let base = url.trim_end_matches('/').rsplit('/').next().unwrap_or("repo");
12 +
        PathBuf::from(base)
13 +
    });
14 +
    let repo = Repo::clone(url, &dest)?;
15 +
    let heads = repo.bookmark_heads()?;
16 +
    println!("Cloned into {} ({url} on {}: {})", repo.root().display(), repo.config.bookmark, plural(heads.len(), "head"));
17 +
    Ok(0)
18 +
}
19 +
20 +
pub fn remote_add(name: &str, url: &str) -> Result<i32> {
21 +
    let mut repo = open()?;
22 +
    repo.config.set(&format!("remote.{name}.url"), url)?;
23 +
    repo.save_config()?;
24 +
    println!("Added remote {name} = {url}");
25 +
    Ok(0)
26 +
}
27 +
28 +
pub fn remote_remove(name: &str) -> Result<i32> {
29 +
    let mut repo = open()?;
30 +
    repo.config.set(&format!("remote.{name}.url"), "")?;
31 +
    repo.save_config()?;
32 +
    println!("Removed remote {name}");
33 +
    Ok(0)
34 +
}
35 +
36 +
pub fn remote_list() -> Result<i32> {
37 +
    let repo = open()?;
38 +
    let view = repo.view()?;
39 +
    for (name, url) in &repo.config.remotes {
40 +
        println!("{name}\t{url}");
41 +
        if let Some(bms) = view.remotes.get(name) {
42 +
            for (bm, st) in bms {
43 +
                let heads = repo.store().get_state(st).map(|s| s.heads.len()).unwrap_or(0);
44 +
                println!("    {bm}: {}", plural(heads, "head"));
45 +
            }
46 +
        }
47 +
    }
48 +
    Ok(0)
49 +
}
50 +
51 +
fn remote_name(remote: Option<&str>) -> String {
52 +
    remote.unwrap_or("origin").to_string()
53 +
}
54 +
55 +
pub fn fetch(remote: Option<&str>) -> Result<i32> {
56 +
    let repo = open()?;
57 +
    let remote = remote_name(remote);
58 +
    for (name, heads, n) in repo.fetch(&remote)? {
59 +
        println!("{remote}/{name}: {}, {}", plural(heads.len(), "head"), plural(n, "new change"));
60 +
    }
61 +
    Ok(0)
62 +
}
63 +
64 +
pub fn pull(remote: Option<&str>) -> Result<i32> {
65 +
    let repo = open()?;
66 +
    let remote = remote_name(remote);
67 +
    let r = repo.pull(&remote)?;
68 +
    if !r.changed {
69 +
        println!("Already up to date.");
70 +
        return Ok(0);
71 +
    }
72 +
    println!(
73 +
        "{} now has {}{}",
74 +
        repo.config.bookmark,
75 +
        plural(r.heads.len(), "head"),
76 +
        if r.checked_out { "; working copy updated" } else { "; run `oxy checkout` to update the working copy" }
77 +
    );
78 +
    let snap = repo.snapshot("oxy pull")?;
79 +
    if snap.base_snapshot.is_conflicted() {
80 +
        println!("The result has conflicts; see oxy status.");
81 +
    }
82 +
    Ok(0)
83 +
}
84 +
85 +
pub fn push(remote: Option<&str>, force: bool) -> Result<i32> {
86 +
    let repo = open()?;
87 +
    let remote = remote_name(remote);
88 +
    let mut code = 0;
89 +
    for r in repo.push(&remote, force)? {
90 +
        if r.ok {
91 +
            println!("{remote}/{}: {}", r.name, if r.reason.is_empty() { "updated" } else { r.reason.as_str() });
92 +
        } else {
93 +
            println!("{remote}/{}: rejected: {}", r.name, r.reason);
94 +
            code = 1;
95 +
        }
96 +
    }
97 +
    Ok(code)
98 +
}
99 +
100 +
pub fn serve(root: PathBuf, bind: &str, tokens: Vec<String>) -> Result<i32> {
101 +
    let listener = std::net::TcpListener::bind(bind).with_context(|| format!("binding {bind}"))?;
102 +
    let root = root.canonicalize()?;
103 +
    println!(
104 +
        "Serving repositories under {} on http://{bind}/<repo>/oxy/v1/ ({})",
105 +
        root.display(),
106 +
        if tokens.is_empty() { "open: no push tokens configured" } else { "push requires a token" }
107 +
    );
108 +
    oxy_server::run_blocking(listener, oxy_server::ServerConfig { root, tokens })?;
109 +
    Ok(0)
110 +
}
added src/cli/workspace.rs +185 −0
1 +
//! Commands that act on the local repository and working copy.
2 +
3 +
use super::format::{deps_line, plural, short_id, signature_line, when};
4 +
use super::open;
5 +
use anyhow::Result;
6 +
use oxy_core::repo::{render_change, ChangeStatus, Repo};
7 +
use std::path::PathBuf;
8 +
9 +
pub fn init(path: Option<PathBuf>) -> Result<i32> {
10 +
    let path = path.unwrap_or(std::env::current_dir()?);
11 +
    let repo = Repo::init(&path)?;
12 +
    println!("Initialized oxy repository in {}", repo.root().display());
13 +
    println!("Bookmark: {}   Author: {} <{}>", repo.config.bookmark, repo.config.author_name, repo.config.author_email);
14 +
    Ok(0)
15 +
}
16 +
17 +
fn status_tag(s: ChangeStatus) -> &'static str {
18 +
    match s {
19 +
        ChangeStatus::Added => "A",
20 +
        ChangeStatus::Modified => "M",
21 +
        ChangeStatus::Removed => "D",
22 +
        ChangeStatus::ModeChanged => "X",
23 +
        ChangeStatus::ConflictEdited => "C!",
24 +
        ChangeStatus::Resolved => "R",
25 +
    }
26 +
}
27 +
28 +
pub fn status() -> Result<i32> {
29 +
    let repo = open()?;
30 +
    let snap = repo.snapshot("oxy status")?;
31 +
    let heads = repo.bookmark_heads()?;
32 +
    println!("Working copy: {} on {} ({})", snap.wc.change_id.short(8), repo.config.bookmark, plural(heads.len(), "head"));
33 +
    if snap.statuses.is_empty() {
34 +
        println!("The working copy is clean.");
35 +
    }
36 +
    for s in &snap.statuses {
37 +
        println!("{:2} {}", status_tag(s.status), s.path);
38 +
    }
39 +
    if !snap.base_snapshot.is_conflicted() {
40 +
        return Ok(0);
41 +
    }
42 +
    println!("\nConflicts in the base state (edit the marked files, or the .oxy-conflict-N candidates, then commit):");
43 +
    for c in &snap.base_snapshot.tree.conflicts {
44 +
        println!("  tree: {c:?}");
45 +
    }
46 +
    for (p, f) in &snap.base_snapshot.files {
47 +
        if f.is_conflicted() {
48 +
            let state = match snap.statuses.iter().find(|s| s.path == *p).map(|s| s.status) {
49 +
                Some(ChangeStatus::Resolved) => "resolved in working copy",
50 +
                Some(ChangeStatus::ConflictEdited) => "markers remain",
51 +
                _ => "unresolved",
52 +
            };
53 +
            println!("  {p}  ({state})");
54 +
        }
55 +
    }
56 +
    Ok(2)
57 +
}
58 +
59 +
pub fn commit(message: &str, depends_on: &[String]) -> Result<i32> {
60 +
    let repo = open()?;
61 +
    let mut deps = Vec::new();
62 +
    for d in depends_on {
63 +
        deps.push(repo.resolve_change(d)?);
64 +
    }
65 +
    let h = repo.commit(message, &deps)?;
66 +
    let c = repo.store().get_change(&h)?;
67 +
    println!("Committed {} #{}: {}", short_id(&c), h.short(12), message.lines().next().unwrap_or(""));
68 +
    Ok(0)
69 +
}
70 +
71 +
pub fn log() -> Result<i32> {
72 +
    let repo = open()?;
73 +
    repo.snapshot("oxy log")?;
74 +
    for e in repo.log()? {
75 +
        let c = &e.change;
76 +
        let mut flags = Vec::new();
77 +
        if e.is_working_copy {
78 +
            flags.push("working copy");
79 +
        }
80 +
        if e.is_head {
81 +
            flags.push("head");
82 +
        }
83 +
        let flags = if flags.is_empty() { String::new() } else { format!(" ({})", flags.join(", ")) };
84 +
        println!("{} #{}{}", short_id(c), e.hash.short(12), flags);
85 +
        println!("    {}", when(c.hlc.wall_ms));
86 +
        if !c.deps.is_empty() {
87 +
            println!("    deps: {}", deps_line(c));
88 +
        }
89 +
        let msg = if c.message.is_empty() { "(no description)" } else { &c.message };
90 +
        for line in msg.lines() {
91 +
            println!("    {line}");
92 +
        }
93 +
        println!();
94 +
    }
95 +
    Ok(0)
96 +
}
97 +
98 +
pub fn diff() -> Result<i32> {
99 +
    let repo = open()?;
100 +
    let snap = repo.snapshot("oxy diff")?;
101 +
    print!("{}", render_change(&snap.wc, &snap.base, &snap.base_snapshot));
102 +
    Ok(0)
103 +
}
104 +
105 +
pub fn show(change: &str) -> Result<i32> {
106 +
    let repo = open()?;
107 +
    repo.snapshot("oxy show")?;
108 +
    let h = repo.resolve_change(change)?;
109 +
    let c = repo.store().get_change(&h)?;
110 +
    println!("change {} #{}", short_id(&c), h);
111 +
    println!("kind:   {:?}", c.kind);
112 +
    println!("date:   {}", when(c.hlc.wall_ms));
113 +
    println!("deps:   {}", deps_line(&c));
114 +
    println!("sig:    {}", signature_line(&repo, &h)?);
115 +
    println!("\n{}\n", if c.message.is_empty() { "(no description)" } else { &c.message });
116 +
    print!("{}", repo.describe_change(&h)?);
117 +
    Ok(0)
118 +
}
119 +
120 +
pub fn checkout(changes: &[String], discard: bool) -> Result<i32> {
121 +
    let repo = open()?;
122 +
    let heads = if changes.is_empty() {
123 +
        repo.bookmark_heads()?
124 +
    } else {
125 +
        changes.iter().map(|c| repo.resolve_change(c)).collect::<Result<Vec<_>, _>>()?
126 +
    };
127 +
    let spec = if changes.is_empty() { repo.config.bookmark.clone() } else { changes.join(" ") };
128 +
    let r = repo.checkout(&heads, discard, &format!("oxy checkout {spec}"))?;
129 +
    println!("Working copy now on {spec}: {} written, {} removed", plural(r.written, "file"), r.removed);
130 +
    if r.conflicted {
131 +
        println!("This state has conflicts; see oxy status.");
132 +
    }
133 +
    Ok(0)
134 +
}
135 +
136 +
pub fn resolve() -> Result<i32> {
137 +
    let repo = open()?;
138 +
    let snap = repo.snapshot("oxy resolve")?;
139 +
    if !snap.base_snapshot.is_conflicted() {
140 +
        println!("The working copy's base has no conflicts.");
141 +
        return Ok(0);
142 +
    }
143 +
    let mut remaining = 0;
144 +
    for (p, f) in &snap.base_snapshot.files {
145 +
        if !f.is_conflicted() {
146 +
            continue;
147 +
        }
148 +
        match snap.statuses.iter().find(|s| s.path == *p).map(|s| s.status) {
149 +
            Some(ChangeStatus::Resolved) => println!("resolved   {p}"),
150 +
            Some(ChangeStatus::ConflictEdited) => {
151 +
                remaining += 1;
152 +
                println!("markers    {p}");
153 +
            }
154 +
            _ => {
155 +
                remaining += 1;
156 +
                println!("unresolved {p}");
157 +
            }
158 +
        }
159 +
    }
160 +
    for c in &snap.base_snapshot.tree.conflicts {
161 +
        remaining += 1;
162 +
        println!("tree       {c:?}");
163 +
    }
164 +
    if remaining == 0 {
165 +
        println!("All conflicts are resolved; `oxy commit` will record the resolution.");
166 +
        Ok(0)
167 +
    } else {
168 +
        Ok(2)
169 +
    }
170 +
}
171 +
172 +
pub fn undo() -> Result<i32> {
173 +
    let repo = open()?;
174 +
    let op = repo.undo()?;
175 +
    println!("Undid the last operation; now at: {}", op.command);
176 +
    Ok(0)
177 +
}
178 +
179 +
pub fn op_log() -> Result<i32> {
180 +
    let repo = open()?;
181 +
    for (i, (r, op)) in repo.op_log()?.into_iter().enumerate() {
182 +
        println!("{}{} {}  {}", if i == 0 { "@ " } else { "  " }, r.short(12), when(op.hlc.wall_ms), op.command);
183 +
    }
184 +
    Ok(0)
185 +
}
modified crates/oxy-core/src/repo.rs +196 −52
…
47 unchanged lines
48 48
    Err(RepoError::Msg(s.into()))
49 49
}
50 50
51 -
/// Repository-local settings (`.oxy/config`, `key = value` lines).
51 +
/// Effective settings: defaults, then the user file (`~/.config/oxy/config.toml`,
52 +
/// spec §19), then the repository file (`.oxy/config`). Both files are
53 +
/// `key = "value"` lines. `save` writes only the repository layer.
52 54
#[derive(Clone, Debug, PartialEq, Eq)]
53 55
pub struct Config {
56 +
    /// Keys set in the repository file (or via `set`), written by `save`.
57 +
    repo_layer: BTreeMap<String, String>,
54 58
    pub author_name: String,
55 59
    pub author_email: String,
56 60
    /// The KeyId of the author's identity, once `create_identity` has run.
…
12 unchanged lines
69 73
    fn default_for_user() -> Config {
70 74
        let user = std::env::var("USER").unwrap_or_else(|_| "unknown".into());
71 75
        Config {
76 +
            repo_layer: BTreeMap::new(),
72 77
            author_name: std::env::var("OXY_AUTHOR_NAME").unwrap_or_else(|_| user.clone()),
73 78
            author_email: std::env::var("OXY_AUTHOR_EMAIL").unwrap_or_else(|_| format!("{user}@localhost")),
74 79
            author_key: None,
…
4 unchanged lines
79 84
        }
80 85
    }
81 86
82 -
    /// Set a key as `oxy config` does; returns false for unknown keys.
87 +
    /// Set a key in the repository layer, as `oxy config` does.
83 88
    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
89 +
        self.apply(key, value)?;
90 +
        if value.is_empty() {
91 +
            self.repo_layer.remove(key);
92 +
        } else {
93 +
            self.repo_layer.insert(key.to_string(), value.to_string());
94 +
        }
95 +
        Ok(())
96 +
    }
97 +
98 +
    /// Set a key in the user file without touching any repository.
99 +
    pub fn set_user(user_path: &Path, key: &str, value: &str) -> Result<()> {
100 +
        Config::default_for_user().apply(key, value)?;
101 +
        let mut entries = read_config_file(user_path)?;
102 +
        if value.is_empty() {
103 +
            entries.remove(key);
104 +
        } else {
105 +
            entries.insert(key.to_string(), value.to_string());
106 +
        }
107 +
        if let Some(parent) = user_path.parent() {
108 +
            fs::create_dir_all(parent)?;
109 +
        }
110 +
        write_config_file(user_path, &entries)
111 +
    }
112 +
113 +
    /// Apply a key to the effective values.
114 +
    fn apply(&mut self, key: &str, value: &str) -> Result<()> {
84 115
        match key {
85 116
            "author.name" => self.author_name = value.into(),
86 117
            "author.email" => self.author_email = value.into(),
…
35 unchanged lines
122 153
        Ok(())
123 154
    }
124 155
125 -
    fn load(path: &Path) -> Result<Config> {
156 +
    /// Defaults, then the user file if it exists, then the repository file.
157 +
    fn load(user_path: Option<&Path>, repo_path: &Path) -> Result<Config> {
126 158
        let mut c = Config::default_for_user();
127 -
        for line in fs::read_to_string(path)?.lines() {
128 -
            let line = line.trim();
129 -
            if line.is_empty() || line.starts_with('#') {
130 -
                continue;
159 +
        if let Some(u) = user_path {
160 +
            for (k, v) in read_config_file(u)? {
161 +
                let _ = c.apply(&k, &v);
131 162
            }
132 -
            let Some((k, v)) = line.split_once('=') else { continue };
133 -
            let v = v.trim().trim_matches('"').to_string();
134 -
            let _ = c.set(k.trim(), &v);
163 +
        for (k, v) in read_config_file(repo_path)? {
164 +
            if c.apply(&k, &v).is_ok() {
165 +
                c.repo_layer.insert(k, v);
166 +
            }
167 +
        }
135 168
        }
136 169
        Ok(c)
137 170
    }
138 171
139 172
    fn save(&self, path: &Path) -> Result<()> {
140 -
        let mut text = format!(
141 -
            "author.name = \"{}\"\nauthor.email = \"{}\"\nauthor.key = \"{}\"\ncore.sign = \"{}\"\nworkspace.bookmark = \"{}\"\n",
142 -
            self.author_name,
143 -
            self.author_email,
144 -
            self.author_key.map(|k| k.to_hex()).unwrap_or_default(),
145 -
            if self.sign_changes { "change" } else { "seal" },
146 -
            self.bookmark
147 -
        );
148 -
        for (name, url) in &self.remotes {
149 -
            text.push_str(&format!("remote.{name}.url = \"{url}\"\n"));
150 -
            if let Some(t) = self.remote_tokens.get(name) {
151 -
                text.push_str(&format!("remote.{name}.token = \"{t}\"\n"));
152 -
            }
173 +
        write_config_file(path, &self.repo_layer)
174 +
    }
175 +
176 +
    /// Keys explicitly set for this repository.
177 +
    pub fn repo_layer(&self) -> &BTreeMap<String, String> {
178 +
        &self.repo_layer
179 +
    }
180 +
}
181 +
182 +
/// Read `key = "value"` lines; a missing file is empty.
183 +
pub fn read_config_file(path: &Path) -> Result<BTreeMap<String, String>> {
184 +
    let text = match fs::read_to_string(path) {
185 +
        Ok(t) => t,
186 +
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
187 +
        Err(e) => return Err(e.into()),
188 +
    };
189 +
    let mut out = BTreeMap::new();
190 +
    for line in text.lines() {
191 +
        let line = line.trim();
192 +
        if line.is_empty() || line.starts_with('#') {
193 +
            continue;
153 194
        }
154 -
        write_atomic(path, text.as_bytes())
195 +
        let Some((k, v)) = line.split_once('=') else { continue };
196 +
        out.insert(k.trim().to_string(), v.trim().trim_matches('"').to_string());
155 197
    }
198 +
    Ok(out)
199 +
}
200 +
201 +
fn write_config_file(path: &Path, entries: &BTreeMap<String, String>) -> Result<()> {
202 +
    let text: String = entries.iter().map(|(k, v)| format!("{k} = \"{v}\"\n")).collect();
203 +
    write_atomic(path, text.as_bytes())
204 +
}
205 +
206 +
impl Config {
156 207
}
157 208
158 209
fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
…
65 unchanged lines
224 275
    store: Store,
225 276
    workspace: WorkspaceId,
226 277
    keys_dir: PathBuf,
278 +
    user_config: PathBuf,
227 279
    pub config: Config,
228 280
}
229 281
230 -
/// Where secret keys live: `$OXY_HOME/keys`, else `$XDG_CONFIG_HOME/oxy/keys`,
231 -
/// else `~/.config/oxy/keys`.
232 -
pub fn default_keys_dir() -> PathBuf {
282 +
/// The user's oxy directory: `$OXY_HOME`, else `$XDG_CONFIG_HOME/oxy`, else
283 +
/// `~/.config/oxy`. It holds `config.toml` (spec §19) and `keys/`.
284 +
pub fn default_home() -> PathBuf {
233 285
    if let Ok(h) = std::env::var("OXY_HOME") {
234 -
        return PathBuf::from(h).join("keys");
286 +
        return PathBuf::from(h);
235 287
    }
236 288
    if let Ok(x) = std::env::var("XDG_CONFIG_HOME") {
237 -
        return PathBuf::from(x).join("oxy").join("keys");
289 +
        return PathBuf::from(x).join("oxy");
238 290
    }
239 291
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
240 -
    PathBuf::from(home).join(".config").join("oxy").join("keys")
292 +
    PathBuf::from(home).join(".config").join("oxy")
293 +
}
294 +
295 +
pub fn default_keys_dir() -> PathBuf {
296 +
    default_home().join("keys")
297 +
}
298 +
299 +
pub fn default_user_config() -> PathBuf {
300 +
    default_home().join("config.toml")
301 +
}
302 +
303 +
/// Where `create_identity` records the author.
304 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
305 +
pub enum IdentityScope {
306 +
    /// The user file: every repository of this user signs with it.
307 +
    User,
308 +
    /// This repository only.
309 +
    Repo,
241 310
}
242 311
243 312
/// Outcome of verifying the signatures on a change (spec §14.3, §14.4).
…
29 unchanged lines
273 342
        fs::create_dir_all(oxy.join("ops"))?;
274 343
        write_atomic(&oxy.join("format"), FORMAT_LINE.as_bytes())?;
275 344
        write_atomic(&oxy.join("id"), hex::encode(id).as_bytes())?;
276 -
        let config = Config::default_for_user();
345 +
        let config = Config::load(Some(&default_user_config()), &oxy.join("config"))?;
277 346
        config.save(&oxy.join("config"))?;
278 347
        let workspace = WorkspaceId::random();
279 348
        write_atomic(&oxy.join("workspace"), workspace.to_hex().as_bytes())?;
280 349
        let store = Store::open(oxy.join("objects"))?;
281 -
        let repo = Repo { root, oxy, store, workspace, keys_dir: default_keys_dir(), config };
350 +
        let repo = Repo { root, oxy, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
282 351
283 352
        let wc = repo.new_working_copy(vec![], "");
284 353
        let wc_hash = repo.store.put_change(&wc)?;
…
12 unchanged lines
297 366
            let oxy = d.join(OXY_DIR);
298 367
            if oxy.is_dir() {
299 368
                let format = fs::read_to_string(oxy.join("format"))?;
300 -
                let config = Config::load(&oxy.join("config"))?;
369 +
                let config = Config::load(Some(&default_user_config()), &oxy.join("config"))?;
301 370
                let workspace = WorkspaceId::from_hex(fs::read_to_string(oxy.join("workspace"))?.trim())
302 371
                    .ok_or_else(|| RepoError::Msg("corrupt .oxy/workspace".into()))?;
303 372
                let store = Store::open(oxy.join("objects"))?;
304 -
                let repo = Repo { root: d.to_path_buf(), oxy, store, workspace, keys_dir: default_keys_dir(), config };
373 +
                let repo = Repo { root: d.to_path_buf(), oxy, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
305 374
                if format == FORMAT1_LINE {
306 375
                    repo.migrate_format1()?;
307 376
                } else if format != FORMAT_LINE {
…
60 unchanged lines
368 437
        self.workspace
369 438
    }
370 439
371 -
    /// Use a different secret key directory (tests, or `--keys-dir`).
372 -
    pub fn with_keys_dir(mut self, dir: impl Into<PathBuf>) -> Repo {
373 -
        self.keys_dir = dir.into();
440 +
    /// Use a different user directory for keys and the user config file
441 +
    /// (tests, or `$OXY_HOME`). Reloads the effective configuration.
442 +
    pub fn with_home(mut self, dir: impl Into<PathBuf>) -> Repo {
443 +
        let dir = dir.into();
444 +
        self.keys_dir = dir.join("keys");
445 +
        self.user_config = dir.join("config.toml");
446 +
        if let Ok(c) = Config::load(Some(&self.user_config), &self.oxy.join("config")) {
447 +
            self.config = c;
448 +
        }
374 449
        self
375 450
    }
376 451
452 +
    pub fn user_config_path(&self) -> &Path {
453 +
        &self.user_config
454 +
    }
455 +
456 +
    /// Set a key in the user file and refresh the effective configuration.
457 +
    pub fn set_user_config(&mut self, key: &str, value: &str) -> Result<()> {
458 +
        Config::set_user(&self.user_config, key, value)?;
459 +
        self.config = Config::load(Some(&self.user_config), &self.oxy.join("config"))?;
460 +
        Ok(())
461 +
    }
462 +
377 463
    pub fn save_config(&self) -> Result<()> {
378 464
        self.config.save(&self.oxy.join("config"))
379 465
    }
…
5 unchanged lines
385 471
    // ------------------------------------------------------------ identity
386 472
387 473
    /// Generate a composite key pair, store its identity object, keep the
388 -
    /// secret in the key directory, and make it this repository's author.
389 -
    pub fn create_identity(&mut self, name: &str, email: &str) -> Result<(KeyId, Identity)> {
474 +
    /// secret in the key directory, and record the author at `scope`.
475 +
    pub fn create_identity(&mut self, name: &str, email: &str, scope: IdentityScope) -> Result<(KeyId, Identity)> {
390 476
        let sk = SecretKey::generate();
391 477
        let id = sk.identity(name, email, Hlc::now().wall_ms);
392 478
        let key = self.store.put_identity(&id)?;
393 479
        fs::create_dir_all(&self.keys_dir)?;
394 480
        let path = self.keys_dir.join(format!("{}.secret", key.to_hex()));
395 481
        write_atomic(&path, &sk.encode(&key))?;
482 +
        write_atomic(&self.keys_dir.join(format!("{}.identity", key.to_hex())), &id.encode())?;
396 483
        #[cfg(unix)]
397 484
        {
398 485
            use std::os::unix::fs::PermissionsExt;
399 486
            fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
400 487
        }
401 -
        self.config.author_name = name.into();
402 -
        self.config.author_email = email.into();
403 -
        self.config.author_key = Some(key);
404 -
        self.save_config()?;
488 +
        match scope {
489 +
            IdentityScope::Repo => {
490 +
                self.config.set("author.name", name)?;
491 +
                self.config.set("author.email", email)?;
492 +
                self.config.set("author.key", &key.to_hex())?;
493 +
                self.save_config()?;
494 +
            }
495 +
            IdentityScope::User => {
496 +
                Config::set_user(&self.user_config, "author.name", name)?;
497 +
                Config::set_user(&self.user_config, "author.email", email)?;
498 +
                Config::set_user(&self.user_config, "author.key", &key.to_hex())?;
499 +
                self.config = Config::load(Some(&self.user_config), &self.oxy.join("config"))?;
500 +
            }
501 +
        }
405 502
        Ok((key, id))
406 503
    }
407 504
408 505
    /// The secret key for `author.key`, if configured and present on disk.
506 +
    /// The identity object is kept beside the secret so a repository that has
507 +
    /// never seen it (a fresh clone) can store it when it first signs.
409 508
    pub fn secret_key(&self) -> Result<Option<(KeyId, SecretKey)>> {
410 509
        let Some(key) = self.config.author_key else { return Ok(None) };
411 510
        let path = self.keys_dir.join(format!("{}.secret", key.to_hex()));
…
3 unchanged lines
415 514
                if k != key {
416 515
                    return fail(format!("{} holds a different key than its name", path.display()));
417 516
                }
517 +
                if !self.store.has(&key) {
518 +
                    let id_path = self.keys_dir.join(format!("{}.identity", key.to_hex()));
519 +
                    if let Ok(bytes) = fs::read(&id_path) {
520 +
                        let id = Identity::decode(&bytes)?;
521 +
                        if id.hash() == key {
522 +
                            self.store.put_identity(&id)?;
523 +
                        }
524 +
                    }
525 +
                }
418 526
                Ok(Some((k, sk)))
419 527
            }
420 528
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
…
563 unchanged lines
984 1092
        let remote = open_remote(url, std::env::var("OXY_TOKEN").ok())?;
985 1093
        let id = remote.repo_id()?;
986 1094
        let mut repo = Repo::init_with_id(dest, id)?;
987 -
        repo.config.remotes.insert("origin".into(), url.to_string());
1095 +
        repo.config.set("remote.origin.url", url)?;
988 1096
        repo.save_config()?;
989 1097
        repo.fetch("origin")?;
990 1098
        let (op_ref, op) = repo.head_op()?;
…
1199 unchanged lines
2190 2298
    fn identities_and_signed_commits() {
2191 2299
        let dir = tempfile::tempdir().unwrap();
2192 2300
        let root = dir.path();
2193 -
        let mut repo = Repo::init(root).unwrap().with_keys_dir(root.join("keys"));
2301 +
        let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
2194 2302
        write(root, "a.txt", b"a\n");
2195 2303
        let h0 = repo.commit("unsigned", &[]).unwrap();
2196 2304
        assert_eq!(repo.verify_change(&h0).unwrap(), SignatureStatus::Unsigned);
2197 2305
        assert_eq!(repo.store().get_change(&h0).unwrap().author, ObjectRef::ZERO);
2198 2306
2199 -
        let (key, id) = repo.create_identity("Ada", "[email protected]").unwrap();
2307 +
        let (key, id) = repo.create_identity("Ada", "[email protected]", IdentityScope::Repo).unwrap();
2200 2308
        assert_eq!(id.hash(), key);
2201 2309
        assert_eq!(repo.config.author_key, Some(key));
2202 2310
        // Config survives reopen; the secret key is found again.
2203 -
        let reopened = Repo::open(root).unwrap().with_keys_dir(root.join("keys"));
2311 +
        let reopened = Repo::open(root).unwrap().with_home(root.join("home"));
2204 2312
        assert_eq!(reopened.config.author_key, Some(key));
2205 2313
        assert_eq!(reopened.secret_key().unwrap().unwrap().0, key);
2206 2314
…
27 unchanged lines
2234 2342
        assert!(matches!(repo.verify_change(&h2).unwrap(), SignatureStatus::Invalid(_)));
2235 2343
2236 2344
        // Without the secret key, signing at commit fails cleanly.
2237 -
        let no_keys = Repo::open(root).unwrap().with_keys_dir(root.join("nowhere"));
2345 +
        let no_keys = Repo::open(root).unwrap().with_home(root.join("nowhere"));
2238 2346
        write(root, "a.txt", b"z\n");
2239 2347
        assert!(no_keys.commit("cannot sign", &[]).is_err());
2240 2348
    }
…
65 unchanged lines
2306 2414
        let origin_url = origin_path.to_string_lossy().to_string();
2307 2415
2308 2416
        // A clones, creates an identity (default core.sign = seal), commits, pushes.
2309 -
        let mut a = Repo::clone(&origin_url, &root.join("a")).unwrap().with_keys_dir(root.join("keys-a"));
2417 +
        let mut a = Repo::clone(&origin_url, &root.join("a")).unwrap().with_home(root.join("home-a"));
2310 2418
        assert_eq!(a.id().unwrap(), origin.id().unwrap());
2311 -
        let (key_a, _) = a.create_identity("Ada", "[email protected]").unwrap();
2419 +
        let (key_a, _) = a.create_identity("Ada", "[email protected]", IdentityScope::Repo).unwrap();
2312 2420
        write(&root.join("a"), "f.txt", b"a\n");
2313 2421
        let h1 = a.commit("first", &[]).unwrap();
2314 2422
        let results = a.push("origin", false).unwrap();
…
71 unchanged lines
2386 2494
        let root = dir.path();
2387 2495
        let origin = Repo::init(&root.join("origin")).unwrap();
2388 2496
        let url = root.join("origin").to_string_lossy().to_string();
2389 -
        let mut a = Repo::clone(&url, &root.join("a")).unwrap().with_keys_dir(root.join("keys"));
2390 -
        let (key, _) = a.create_identity("Ada", "a@b", ).unwrap();
2497 +
        let mut a = Repo::clone(&url, &root.join("a")).unwrap().with_home(root.join("home"));
2498 +
        let (key, _) = a.create_identity("Ada", "a@b", IdentityScope::Repo).unwrap();
2391 2499
        write(&root.join("a"), "f.txt", b"x\n");
2392 2500
        let h1 = a.commit("one", &[]).unwrap();
2393 2501
        assert!(a.push("origin", false).unwrap()[0].ok);
…
28 unchanged lines
2422 2530
    }
2423 2531
2424 2532
    #[test]
2533 +
    fn user_config_supplies_defaults_and_clones_sign() {
2534 +
        let dir = tempfile::tempdir().unwrap();
2535 +
        let root = dir.path();
2536 +
        let home = root.join("home");
2537 +
        let origin = Repo::init(&root.join("origin")).unwrap();
2538 +
        let url = root.join("origin").to_string_lossy().to_string();
2539 +
2540 +
        // Identity at user scope: the repository file stays free of author keys.
2541 +
        let mut a = Repo::init(&root.join("a")).unwrap().with_home(&home);
2542 +
        let (key, _) = a.create_identity("Ada", "[email protected]", IdentityScope::User).unwrap();
2543 +
        assert!(!a.config.repo_layer().contains_key("author.key"));
2544 +
        assert_eq!(a.config.author_key, Some(key));
2545 +
        assert!(home.join("config.toml").exists());
2546 +
        a.set_user_config("core.sign", "change").unwrap();
2547 +
        assert!(a.config.sign_changes);
2548 +
2549 +
        // Repository settings override user settings and are the only ones saved.
2550 +
        a.config.set("core.sign", "seal").unwrap();
2551 +
        a.save_config().unwrap();
2552 +
        let again = Repo::open(&root.join("a")).unwrap().with_home(&home);
2553 +
        assert!(!again.config.sign_changes);
2554 +
        assert_eq!(again.config.author_key, Some(key));
2555 +
        assert_eq!(read_config_file(&root.join("a/.oxy/config")).unwrap().get("core.sign").map(String::as_str), Some("seal"));
2556 +
2557 +
        // A fresh clone made by this user signs with the user-level identity,
2558 +
        // even though the clone's store has never seen the identity object.
2559 +
        let b = Repo::clone(&url, &root.join("b")).unwrap().with_home(&home);
2560 +
        assert!(b.config.sign_changes);
2561 +
        write(&root.join("b"), "f.txt", b"f\n");
2562 +
        let h = b.commit("signed from a clone", &[]).unwrap();
2563 +
        assert!(matches!(b.verify_change(&h).unwrap(), SignatureStatus::Valid { key: k, .. } if k == key));
2564 +
        assert!(b.push("origin", false).unwrap()[0].ok);
2565 +
        assert!(matches!(origin.verify_change(&h).unwrap(), SignatureStatus::Valid { .. }));
2566 +
    }
2567 +
2568 +
    #[test]
2425 2569
    fn format1_repositories_are_migrated_on_open() {
2426 2570
        use crate::oplog::{Format1Op, Format1View};
2427 2571
        let dir = tempfile::tempdir().unwrap();
…
112 unchanged lines
modified crates/oxy-server/tests/http.rs +2 −2
…
27 unchanged lines
28 28
    let base = start(&served, vec![]);
29 29
    let url = format!("{base}/origin");
30 30
31 -
    let mut a = Repo::clone(&url, &root.join("a")).unwrap().with_keys_dir(root.join("keys"));
32 -
    let (key, _) = a.create_identity("Ada", "[email protected]").unwrap();
31 +
    let mut a = Repo::clone(&url, &root.join("a")).unwrap().with_home(root.join("home"));
32 +
    let (key, _) = a.create_identity("Ada", "[email protected]", oxy_core::repo::IdentityScope::Repo).unwrap();
33 33
    write(&root.join("a"), "f.txt", b"hello\n");
34 34
    let h1 = a.commit("hello", &[]).unwrap();
35 35
    let r = a.push("origin", false).unwrap();
…
54 unchanged lines
modified docs/spec.md +7 −3
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.5a |
5 +
| **Status** | Draft 0.5b |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
705 unchanged lines
714 714
| `.oxyignore` | Ignore rules. Gitignore syntax and semantics, per directory, plus `.oxy/info/ignore` and the global ignore file. |
715 715
| `.oxyattributes` | Per-path attributes: `binary`, `eol=lf|crlf`, `nocompress`, `lockable`, `filter=<name>`. Gitattributes syntax. |
716 716
| `.oxy/config` | Repository-local settings, TOML. |
717 -
| `~/.config/oxy/config.toml` | User settings: identity key path, aliases, default profile. |
717 +
| `~/.config/oxy/config.toml` | User settings, the same keys as `.oxy/config`: `author.name`, `author.email`, `author.key`, `core.sign`, and `remote.<name>.token`. Overridden by `$OXY_HOME/config.toml` when `$OXY_HOME` is set. |
718 +
719 +
Settings resolve in order: the repository file overrides the user file, which overrides environment variables (`OXY_AUTHOR_NAME`, `OXY_AUTHOR_EMAIL`, `OXY_TOKEN`) and built-in defaults. `oxy config` writes the repository file; `oxy config --user` writes the user file. Secret keys live beside the user file in `keys/`, one `<KeyId>.secret` and one `<KeyId>.identity` per key, so any repository the user works in can store the identity object when it first signs.
718 720
719 721
Filters (`filter=<name>`) are clean/smudge programs configured in user or repo config, as in Git. Implementations MUST refuse to run filters defined in `.oxy/config` of a freshly cloned repository until the user approves them.
720 722
…
97 unchanged lines
818 820
819 821
---
820 822
821 -
*Changelog.* Draft 0.5a (2026-09-26): §15.1 allows a reference server behind TLS termination and requires authenticated pushes when credentials exist; §16.3 defines the CBOR message encodings (push validation moves to §16.4).
823 +
*Changelog.* Draft 0.5b (2026-09-26): §19 defines the user configuration file's keys, the precedence of repository over user over environment, and the key directory layout.
824 +
825 +
Draft 0.5a (2026-09-26): §15.1 allows a reference server behind TLS termination and requires authenticated pushes when credentials exist; §16.3 defines the CBOR message encodings (push validation moves to §16.4).
822 826
823 827
Draft 0.5 (2026-09-26): bookmarks and tags point at a `state` object naming the heads, so attestations, views, and references carry one ObjectRef per bookmark however many heads it has; repository format 2 records this, and format-1 repositories migrate on open.
824 828
…
9 unchanged lines
modified src/main.rs +5 −470
1 1
//! `oxy`: command-line interface for Oxygen.
2 -
3 -
use anyhow::{bail, Context, Result};
4 -
use clap::{Parser, Subcommand};
5 -
use oxy_core::repo::{ChangeStatus, Repo, SignatureStatus};
6 -
use std::path::PathBuf;
7 -
8 -
#[derive(Parser)]
9 -
#[command(name = "oxy", version, about = "Oxygen: breathing fresh air into version control")]
10 -
struct Cli {
11 -
    #[command(subcommand)]
12 -
    cmd: Cmd,
13 -
}
14 -
15 -
#[derive(Subcommand)]
16 -
enum Cmd {
17 -
    /// Create a repository in PATH (default: current directory)
18 -
    Init { path: Option<PathBuf> },
19 -
    /// Clone a repository from a local path
20 -
    Clone { url: String, dest: Option<PathBuf> },
21 -
    /// Manage remotes
22 -
    Remote {
23 -
        #[command(subcommand)]
24 -
        cmd: RemoteCmd,
25 -
    },
26 -
    /// Fetch every bookmark of a remote into its tracking references
27 -
    Fetch { remote: Option<String> },
28 -
    /// Fetch, then union the remote bookmark into the local one (no merge change)
29 -
    Pull { remote: Option<String> },
30 -
    /// Push the current bookmark to a remote
31 -
    Push {
32 -
        remote: Option<String>,
33 -
        /// Allow dropping heads the remote has
34 -
        #[arg(long)]
35 -
        force: bool,
36 -
    },
37 -
    /// Show the working copy: changed paths and conflicts
38 -
    Status,
39 -
    /// Record the working copy as a change on the current bookmark
40 -
    Commit {
41 -
        #[arg(short, long)]
42 -
        message: String,
43 -
        /// Extra explicit dependencies (ChangeId prefix, #hash prefix)
44 -
        #[arg(long = "depends-on")]
45 -
        depends_on: Vec<String>,
46 -
    },
47 -
    /// List changes reachable from the bookmark and the working copy
48 -
    Log,
49 -
    /// Show what the working copy changes against its base
50 -
    Diff,
51 -
    /// Show one change (ChangeId prefix, #hash prefix, or @)
52 -
    Show { change: String },
53 -
    /// Move the working copy to the state formed by CHANGE... (default: the bookmark heads)
54 -
    Checkout {
55 -
        changes: Vec<String>,
56 -
        /// Throw away uncommitted working-copy changes
57 -
        #[arg(long)]
58 -
        discard: bool,
59 -
    },
60 -
    /// Report which conflicts of the working copy's base are resolved by the current files
61 -
    Resolve,
62 -
    /// Serve repositories under ROOT over HTTP (spec §16); TLS is terminated in front
63 -
    Serve {
64 -
        /// Directory with one repository per subdirectory
65 -
        #[arg(long, default_value = ".")]
66 -
        root: PathBuf,
67 -
        #[arg(long, default_value = "127.0.0.1:8890")]
68 -
        bind: String,
69 -
        /// Bearer token allowed to push (repeatable); none means open
70 -
        #[arg(long = "token")]
71 -
        tokens: Vec<String>,
72 -
    },
73 -
    /// Undo the last operation
74 -
    Undo,
75 -
    /// Manage the signing identity
76 -
    Identity {
77 -
        #[command(subcommand)]
78 -
        cmd: IdentityCmd,
79 -
    },
80 -
    /// Verify the signatures on a change (default: every change in the log)
81 -
    Verify { change: Option<String> },
82 -
    /// Set a repository configuration key (author.*, core.sign, workspace.bookmark, remote.<name>.url|token)
83 -
    Config { key: String, value: String },
84 -
    /// List operations
85 -
    #[command(name = "op")]
86 -
    Op {
87 -
        #[command(subcommand)]
88 -
        cmd: OpCmd,
89 -
    },
90 -
}
91 -
92 -
#[derive(Subcommand)]
93 -
enum OpCmd {
94 -
    Log,
95 -
}
96 -
97 -
#[derive(Subcommand)]
98 -
enum RemoteCmd {
99 -
    Add { name: String, url: String },
100 -
    Remove { name: String },
101 -
    List,
102 -
}
103 -
104 -
#[derive(Subcommand)]
105 -
enum IdentityCmd {
106 -
    /// Generate a composite Ed25519 + ML-DSA-65 key pair and make it the author
107 -
    Create {
108 -
        #[arg(long)]
109 -
        name: Option<String>,
110 -
        #[arg(long)]
111 -
        email: Option<String>,
112 -
    },
113 -
    /// Show the configured identity
114 -
    Show,
115 -
}
2 +
//!
3 +
//! Command definitions and dispatch live in `cli`; each command group has
4 +
//! its own module there.
116 5
117 -
fn signature_line(repo: &Repo, h: &oxy_core::hash::ChangeHash) -> Result<String> {
118 -
    Ok(match repo.authenticate_change(h)? {
119 -
        SignatureStatus::Unsigned => "unsigned".to_string(),
120 -
        SignatureStatus::Valid { key, name, email, sealed } => {
121 -
            format!("{} by {name} <{email}> key {}", if sealed { "sealed" } else { "signed" }, key.short(12))
122 -
        }
123 -
        SignatureStatus::Invalid(why) => format!("INVALID signature: {why}"),
124 -
    })
125 -
}
6 +
mod cli;
126 7
127 8
fn main() {
128 9
    // Behave like other CLI tools when output is piped into `head`: die
…
2 unchanged lines
131 12
    unsafe {
132 13
        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
133 14
    }
134 -
    match run() {
15 +
    match cli::run() {
135 16
        Ok(code) => std::process::exit(code),
136 17
        Err(e) => {
137 18
            eprintln!("error: {e:#}");
138 19
            std::process::exit(1);
139 20
        }
140 21
    }
141 -
}
142 -
143 -
fn open() -> Result<Repo> {
144 -
    Repo::open(&std::env::current_dir()?).context("opening repository")
145 -
}
146 -
147 -
fn short_id(c: &oxy_core::objects::Change) -> String {
148 -
    c.change_id.short(8)
149 -
}
150 -
151 -
fn when(ms: u64) -> String {
152 -
    let secs = (ms / 1000) as i64;
153 -
    let days = secs.div_euclid(86_400);
154 -
    let rem = secs.rem_euclid(86_400);
155 -
    // Civil-from-days (Howard Hinnant), UTC.
156 -
    let z = days + 719_468;
157 -
    let era = z.div_euclid(146_097);
158 -
    let doe = z - era * 146_097;
159 -
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
160 -
    let y = yoe + era * 400;
161 -
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
162 -
    let mp = (5 * doy + 2) / 153;
163 -
    let d = doy - (153 * mp + 2) / 5 + 1;
164 -
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
165 -
    let y = if m <= 2 { y + 1 } else { y };
166 -
    format!("{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}Z", rem / 3600, (rem % 3600) / 60, rem % 60)
167 -
}
168 -
169 -
fn run() -> Result<i32> {
170 -
    let cli = Cli::parse();
171 -
    match cli.cmd {
172 -
        Cmd::Init { path } => {
173 -
            let path = path.unwrap_or(std::env::current_dir()?);
174 -
            let repo = Repo::init(&path)?;
175 -
            println!("Initialized oxy repository in {}", repo.root().display());
176 -
            println!("Bookmark: {}   Author: {} <{}>", repo.config.bookmark, repo.config.author_name, repo.config.author_email);
177 -
            Ok(0)
178 -
        }
179 -
        Cmd::Clone { url, dest } => {
180 -
            let dest = dest.unwrap_or_else(|| {
181 -
                let base = url.trim_end_matches('/').rsplit('/').next().unwrap_or("repo");
182 -
                PathBuf::from(base)
183 -
            });
184 -
            let repo = Repo::clone(&url, &dest)?;
185 -
            let heads = repo.bookmark_heads()?;
186 -
            println!("Cloned into {} ({} on {}: {} head{})", repo.root().display(), url, repo.config.bookmark, heads.len(), if heads.len() == 1 { "" } else { "s" });
187 -
            Ok(0)
188 -
        }
189 -
        Cmd::Remote { cmd } => {
190 -
            let mut repo = open()?;
191 -
            match cmd {
192 -
                RemoteCmd::Add { name, url } => {
193 -
                    repo.config.set(&format!("remote.{name}.url"), &url)?;
194 -
                    repo.save_config()?;
195 -
                    println!("Added remote {name} = {url}");
196 -
                }
197 -
                RemoteCmd::Remove { name } => {
198 -
                    repo.config.set(&format!("remote.{name}.url"), "")?;
199 -
                    repo.save_config()?;
200 -
                    println!("Removed remote {name}");
201 -
                }
202 -
                RemoteCmd::List => {
203 -
                    let view = repo.view()?;
204 -
                    for (name, url) in &repo.config.remotes {
205 -
                        println!("{name}\t{url}");
206 -
                        if let Some(bms) = view.remotes.get(name) {
207 -
                            for (bm, st) in bms {
208 -
                                let heads = repo.store().get_state(st).map(|s| s.heads.len()).unwrap_or(0);
209 -
                                println!("    {bm}: {heads} head{}", if heads == 1 { "" } else { "s" });
210 -
                            }
211 -
                        }
212 -
                    }
213 -
                }
214 -
            }
215 -
            Ok(0)
216 -
        }
217 -
        Cmd::Fetch { remote } => {
218 -
            let repo = open()?;
219 -
            let remote = remote.unwrap_or_else(|| "origin".into());
220 -
            for (name, heads, n) in repo.fetch(&remote)? {
221 -
                println!("{remote}/{name}: {} head{}, {n} new change{}", heads.len(), if heads.len() == 1 { "" } else { "s" }, if n == 1 { "" } else { "s" });
222 -
            }
223 -
            Ok(0)
224 -
        }
225 -
        Cmd::Pull { remote } => {
226 -
            let repo = open()?;
227 -
            let remote = remote.unwrap_or_else(|| "origin".into());
228 -
            let r = repo.pull(&remote)?;
229 -
            if !r.changed {
230 -
                println!("Already up to date.");
231 -
            } else {
232 -
                println!("{} now has {} head{}{}", repo.config.bookmark, r.heads.len(), if r.heads.len() == 1 { "" } else { "s" }, if r.checked_out { "; working copy updated" } else { "; run `oxy checkout` to update the working copy" });
233 -
                let snap = repo.snapshot("oxy pull")?;
234 -
                if snap.base_snapshot.is_conflicted() {
235 -
                    println!("The result has conflicts; see oxy status.");
236 -
                }
237 -
            }
238 -
            Ok(0)
239 -
        }
240 -
        Cmd::Push { remote, force } => {
241 -
            let repo = open()?;
242 -
            let remote = remote.unwrap_or_else(|| "origin".into());
243 -
            let mut code = 0;
244 -
            for r in repo.push(&remote, force)? {
245 -
                if r.ok {
246 -
                    println!("{remote}/{}: updated", r.name);
247 -
                } else {
248 -
                    println!("{remote}/{}: rejected: {}", r.name, r.reason);
249 -
                    code = 1;
250 -
                }
251 -
            }
252 -
            Ok(code)
253 -
        }
254 -
        Cmd::Status => {
255 -
            let repo = open()?;
256 -
            let snap = repo.snapshot("oxy status")?;
257 -
            let heads = repo.bookmark_heads()?;
258 -
            println!(
259 -
                "Working copy: {} on {} ({} head{})",
260 -
                snap.wc.change_id.short(8),
261 -
                repo.config.bookmark,
262 -
                heads.len(),
263 -
                if heads.len() == 1 { "" } else { "s" }
264 -
            );
265 -
            if snap.statuses.is_empty() {
266 -
                println!("The working copy is clean.");
267 -
            }
268 -
            for s in &snap.statuses {
269 -
                let tag = match s.status {
270 -
                    ChangeStatus::Added => "A",
271 -
                    ChangeStatus::Modified => "M",
272 -
                    ChangeStatus::Removed => "D",
273 -
                    ChangeStatus::ModeChanged => "X",
274 -
                    ChangeStatus::ConflictEdited => "C!",
275 -
                    ChangeStatus::Resolved => "R",
276 -
                };
277 -
                println!("{tag:2} {}", s.path);
278 -
            }
279 -
            let mut code = 0;
280 -
            if snap.base_snapshot.is_conflicted() {
281 -
                println!("\nConflicts in the base state (edit the marked files, or the .oxy-conflict-N candidates, then commit):");
282 -
                for c in &snap.base_snapshot.tree.conflicts {
283 -
                    println!("  tree: {c:?}");
284 -
                }
285 -
                for (p, f) in &snap.base_snapshot.files {
286 -
                    if f.is_conflicted() {
287 -
                        let state = match snap.statuses.iter().find(|s| s.path == *p).map(|s| s.status) {
288 -
                            Some(ChangeStatus::Resolved) => "resolved in working copy",
289 -
                            Some(ChangeStatus::ConflictEdited) => "markers remain",
290 -
                            _ => "unresolved",
291 -
                        };
292 -
                        println!("  {p}  ({state})");
293 -
                    }
294 -
                }
295 -
                code = 2;
296 -
            }
297 -
            Ok(code)
298 -
        }
299 -
        Cmd::Commit { message, depends_on } => {
300 -
            let repo = open()?;
301 -
            let mut deps = Vec::new();
302 -
            for d in &depends_on {
303 -
                deps.push(repo.resolve_change(d)?);
304 -
            }
305 -
            let h = repo.commit(&message, &deps)?;
306 -
            let c = repo.store().get_change(&h)?;
307 -
            println!("Committed {} #{}: {}", short_id(&c), h.short(12), message.lines().next().unwrap_or(""));
308 -
            Ok(0)
309 -
        }
310 -
        Cmd::Log => {
311 -
            let repo = open()?;
312 -
            repo.snapshot("oxy log")?;
313 -
            for e in repo.log()? {
314 -
                let c = &e.change;
315 -
                let mut flags = Vec::new();
316 -
                if e.is_working_copy {
317 -
                    flags.push("working copy");
318 -
                }
319 -
                if e.is_head {
320 -
                    flags.push("head");
321 -
                }
322 -
                let flags = if flags.is_empty() { String::new() } else { format!(" ({})", flags.join(", ")) };
323 -
                println!("{} #{}{}", short_id(c), e.hash.short(12), flags);
324 -
                println!("    {}", when(c.hlc.wall_ms));
325 -
                if !c.deps.is_empty() {
326 -
                    println!("    deps: {}", c.deps.iter().map(|d| format!("#{}", d.short(12))).collect::<Vec<_>>().join(" "));
327 -
                }
328 -
                let msg = if c.message.is_empty() { "(no description)" } else { &c.message };
329 -
                for line in msg.lines() {
330 -
                    println!("    {line}");
331 -
                }
332 -
                println!();
333 -
            }
334 -
            Ok(0)
335 -
        }
336 -
        Cmd::Diff => {
337 -
            let repo = open()?;
338 -
            let snap = repo.snapshot("oxy diff")?;
339 -
            print!("{}", oxy_core::repo::render_change(&snap.wc, &snap.base, &snap.base_snapshot));
340 -
            Ok(0)
341 -
        }
342 -
        Cmd::Show { change } => {
343 -
            let repo = open()?;
344 -
            repo.snapshot("oxy show")?;
345 -
            let h = repo.resolve_change(&change)?;
346 -
            let c = repo.store().get_change(&h)?;
347 -
            println!("change {} #{}", short_id(&c), h);
348 -
            println!("kind:   {:?}", c.kind);
349 -
            println!("date:   {}", when(c.hlc.wall_ms));
350 -
            println!("deps:   {}", c.deps.iter().map(|d| format!("#{}", d.short(12))).collect::<Vec<_>>().join(" "));
351 -
            println!("sig:    {}", signature_line(&repo, &h)?);
352 -
            println!("\n{}\n", if c.message.is_empty() { "(no description)" } else { &c.message });
353 -
            print!("{}", repo.describe_change(&h)?);
354 -
            Ok(0)
355 -
        }
356 -
        Cmd::Checkout { changes, discard } => {
357 -
            let repo = open()?;
358 -
            let heads = if changes.is_empty() {
359 -
                repo.bookmark_heads()?
360 -
            } else {
361 -
                changes.iter().map(|c| repo.resolve_change(c)).collect::<Result<Vec<_>, _>>()?
362 -
            };
363 -
            let spec = if changes.is_empty() { repo.config.bookmark.clone() } else { changes.join(" ") };
364 -
            let r = repo.checkout(&heads, discard, &format!("oxy checkout {spec}"))?;
365 -
            println!("Working copy now on {spec}: {} file(s) written, {} removed", r.written, r.removed);
366 -
            if r.conflicted {
367 -
                println!("This state has conflicts; see oxy status.");
368 -
            }
369 -
            Ok(0)
370 -
        }
371 -
        Cmd::Resolve => {
372 -
            let repo = open()?;
373 -
            let snap = repo.snapshot("oxy resolve")?;
374 -
            if !snap.base_snapshot.is_conflicted() {
375 -
                println!("The working copy's base has no conflicts.");
376 -
                return Ok(0);
377 -
            }
378 -
            let mut remaining = 0;
379 -
            for (p, f) in &snap.base_snapshot.files {
380 -
                if !f.is_conflicted() {
381 -
                    continue;
382 -
                }
383 -
                match snap.statuses.iter().find(|s| s.path == *p).map(|s| s.status) {
384 -
                    Some(ChangeStatus::Resolved) => println!("resolved   {p}"),
385 -
                    Some(ChangeStatus::ConflictEdited) => {
386 -
                        remaining += 1;
387 -
                        println!("markers    {p}");
388 -
                    }
389 -
                    _ => {
390 -
                        remaining += 1;
391 -
                        println!("unresolved {p}");
392 -
                    }
393 -
                }
394 -
            }
395 -
            for c in &snap.base_snapshot.tree.conflicts {
396 -
                remaining += 1;
397 -
                println!("tree       {c:?}");
398 -
            }
399 -
            if remaining == 0 {
400 -
                println!("All conflicts are resolved; `oxy commit` will record the resolution.");
401 -
                Ok(0)
402 -
            } else {
403 -
                Ok(2)
404 -
            }
405 -
        }
406 -
        Cmd::Serve { root, bind, tokens } => {
407 -
            let listener = std::net::TcpListener::bind(&bind).with_context(|| format!("binding {bind}"))?;
408 -
            let root = root.canonicalize()?;
409 -
            println!("Serving repositories under {} on http://{bind}/<repo>/oxy/v1/ ({})", root.display(), if tokens.is_empty() { "open: no push tokens configured" } else { "push requires a token" });
410 -
            oxy_server::run_blocking(listener, oxy_server::ServerConfig { root, tokens })?;
411 -
            Ok(0)
412 -
        }
413 -
        Cmd::Undo => {
414 -
            let repo = open()?;
415 -
            let op = repo.undo()?;
416 -
            println!("Undid the last operation; now at: {}", op.command);
417 -
            Ok(0)
418 -
        }
419 -
        Cmd::Identity { cmd: IdentityCmd::Create { name, email } } => {
420 -
            let mut repo = open()?;
421 -
            let name = name.unwrap_or_else(|| repo.config.author_name.clone());
422 -
            let email = email.unwrap_or_else(|| repo.config.author_email.clone());
423 -
            let (key, _) = repo.create_identity(&name, &email)?;
424 -
            println!("Created identity {name} <{email}>");
425 -
            println!("KeyId: {key}");
426 -
            println!("Secret key stored under {}", oxy_core::repo::default_keys_dir().display());
427 -
            if !repo.config.sign_changes {
428 -
                println!("Changes will carry this author; run `oxy config core.sign change` to sign each commit.");
429 -
            }
430 -
            Ok(0)
431 -
        }
432 -
        Cmd::Identity { cmd: IdentityCmd::Show } => {
433 -
            let repo = open()?;
434 -
            match repo.config.author_key {
435 -
                None => println!("No identity configured; run `oxy identity create`."),
436 -
                Some(key) => {
437 -
                    let id = repo.identity(&key)?;
438 -
                    println!("{} <{}>", id.name, id.email);
439 -
                    println!("KeyId:   {key}");
440 -
                    println!("Created: {}", when(id.created));
441 -
                    println!("Secret:  {}", if repo.secret_key()?.is_some() { "available" } else { "NOT FOUND in key directory" });
442 -
                    println!("Signing: {}", if repo.config.sign_changes { "every change (core.sign = change)" } else { "at push via seals (core.sign = seal)" });
443 -
                }
444 -
            }
445 -
            Ok(0)
446 -
        }
447 -
        Cmd::Verify { change } => {
448 -
            let repo = open()?;
449 -
            let hashes: Vec<oxy_core::hash::ChangeHash> = match change {
450 -
                Some(c) => vec![repo.resolve_change(&c)?],
451 -
                None => repo.log()?.into_iter().filter(|e| !e.is_working_copy).map(|e| e.hash).collect(),
452 -
            };
453 -
            let mut bad = 0;
454 -
            for h in hashes {
455 -
                let c = repo.store().get_change(&h)?;
456 -
                let line = signature_line(&repo, &h)?;
457 -
                if line.starts_with("INVALID") {
458 -
                    bad += 1;
459 -
                }
460 -
                println!("{} #{}  {}", short_id(&c), h.short(12), line);
461 -
            }
462 -
            Ok(if bad > 0 { 1 } else { 0 })
463 -
        }
464 -
        Cmd::Config { key, value } => {
465 -
            let mut repo = open()?;
466 -
            repo.config.set(&key, &value)?;
467 -
            repo.save_config()?;
468 -
            println!("{key} = {value}");
469 -
            Ok(0)
470 -
        }
471 -
        Cmd::Op { cmd: OpCmd::Log } => {
472 -
            let repo = open()?;
473 -
            for (i, (r, op)) in repo.op_log()?.into_iter().enumerate() {
474 -
                println!("{}{} {}  {}", if i == 0 { "@ " } else { "  " }, r.short(12), when(op.hlc.wall_ms), op.command);
475 -
            }
476 -
            Ok(0)
477 -
        }
478 -
    }
479 -
    .map_err(|e: anyhow::Error| e)
480 -
    .or_else(|e| {
481 -
        if e.to_string().is_empty() {
482 -
            bail!("unknown error")
483 -
        } else {
484 -
            Err(e)
485 -
        }
486 -
    })
487 22
}