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 | } |