OXYGENHUB
# 6e723b81f5b9 oxygen / src / cli / mod.rs
15.5 KiB · Rust · last changed Lazy blobs: clone --max-blob / filter.max-blob, absent-blob registry fetched on first read, objects endpoint (spec 0.6e) 1 month ago source blame history raw
1
//! Command-line definitions and dispatch.
2
3
mod format;
4
mod identity;
5
mod sync;
6
pub mod ui;
7
mod workspace;
8
9
use anyhow::{Context, Result};
10
use clap::{Parser, Subcommand};
11
use oxy_core::repo::Repo;
12
use std::path::PathBuf;
13
14
#[derive(Parser)]
15
#[command(name = "oxy", version, about = "Oxygen: breathing fresh air into version control")]
16
struct Cli {
17
    /// Plain output: no colour, no spinner (also implied by NO_COLOR or a non-terminal)
18
    #[arg(long, global = true)]
19
    plain: bool,
20
    #[command(subcommand)]
21
    cmd: Cmd,
22
}
23
24
#[derive(Subcommand)]
25
enum Cmd {
26
    /// Create a repository in PATH (default: current directory)
27
    Init { path: Option<PathBuf> },
28
    /// Clone a repository from a local path or an http(s) URL
29
    Clone {
30
        url: String,
31
        dest: Option<PathBuf>,
32
        /// Leave blobs larger than this (e.g. 1M) to be fetched on first read
33
        #[arg(long)]
34
        max_blob: Option<String>,
35
        /// Materialize only these paths (repeatable); implies lazy blobs
36
        #[arg(long = "sparse")]
37
        sparse: Vec<String>,
38
        /// Shallow clone: fetch only this many levels of history below the heads
39
        #[arg(long)]
40
        depth: Option<u32>,
41
    },
42
    /// Fetch more history below a shallow clone's boundary
43
    Deepen {
44
        #[arg(default_value_t = 10)]
45
        depth: u32,
46
        #[arg(long, default_value = "origin")]
47
        remote: String,
48
    },
49
    /// Show or set the sparse filter (paths to materialize); re-checks out the bookmark
50
    Sparse { paths: Vec<String> },
51
    /// Show the working copy: changed paths and conflicts
52
    Status,
53
    /// One-line summary for shell prompts; fast and records nothing
54
    Prompt {
55
        /// Placeholders: %b bookmark, %c change id, %h heads, %d dirty (*), %x conflict (!), %A/%D ahead/behind (+n/-n), %a/%B raw counts
56
        #[arg(long, default_value = oxy_core::repo::DEFAULT_PROMPT_FORMAT)]
57
        format: String,
58
    },
59
    /// Record the working copy as a change on the current bookmark
60
    Commit {
61
        #[arg(short, long)]
62
        message: String,
63
        /// Extra explicit dependencies (ChangeId prefix, #hash prefix)
64
        #[arg(long = "depends-on")]
65
        depends_on: Vec<String>,
66
    },
67
    /// List changes reachable from the bookmark and the working copy
68
    Log {
69
        /// Show at most this many changes
70
        #[arg(short = 'n', long)]
71
        limit: Option<usize>,
72
        /// Show full messages, dependencies, and signatures
73
        #[arg(short, long)]
74
        verbose: bool,
75
    },
76
    /// Show what the working copy changes against its base
77
    Diff,
78
    /// Change the message of a head (default: the head the working copy sits on)
79
    Describe {
80
        #[arg(short, long)]
81
        message: String,
82
        change: Option<String>,
83
    },
84
    /// Fold the working copy into the head it sits on, or into CHANGE with --into
85
    Amend {
86
        #[arg(short, long)]
87
        message: Option<String>,
88
        /// Amend an older change; the edits must touch only lines it already knows
89
        #[arg(long)]
90
        into: Option<String>,
91
    },
92
    /// Split a head: the named paths become a separate first change
93
    Split {
94
        change: String,
95
        paths: Vec<String>,
96
    },
97
    /// Drop a head from the bookmark; its content stays on disk as uncommitted work
98
    Abandon { change: String },
99
    /// Show one change (ChangeId prefix, #hash prefix, or @)
100
    Show { change: String },
101
    /// Move the working copy to the state formed by CHANGE... (default: the bookmark heads)
102
    Checkout {
103
        changes: Vec<String>,
104
        /// Throw away uncommitted working-copy changes
105
        #[arg(long)]
106
        discard: bool,
107
    },
108
    /// Report which conflicts of the working copy's base are resolved by the current files
109
    Resolve,
110
    /// Undo the last operation
111
    Undo,
112
    /// Write a checkpoint of the current bookmark's state now
113
    Checkpoint,
114
    /// Pack loose objects and prune unreachable ones older than the grace period
115
    Gc {
116
        /// Ignore the grace period (core.gc-grace) and prune immediately
117
        #[arg(long)]
118
        now: bool,
119
    },
120
    /// List operations
121
    #[command(name = "op")]
122
    Op {
123
        #[command(subcommand)]
124
        cmd: OpCmd,
125
    },
126
    /// Take the advisory local lock on a lockable file, making it writable
127
    Lock { path: String },
128
    /// Release the local lock and make the file read-only again
129
    Unlock { path: String },
130
    /// Workspaces: extra checkout directories sharing this repository
131
    Workspace {
132
        #[command(subcommand)]
133
        cmd: WorkspaceCmd,
134
    },
135
    /// Tags: immutable named states
136
    Tag {
137
        #[command(subcommand)]
138
        cmd: TagCmd,
139
    },
140
    /// Manage remotes
141
    Remote {
142
        #[command(subcommand)]
143
        cmd: RemoteCmd,
144
    },
145
    /// Fetch every bookmark of a remote into its tracking references
146
    Fetch { remote: Option<String> },
147
    /// Fetch, then union the remote bookmark into the local one (no merge change)
148
    Pull { remote: Option<String> },
149
    /// Push the current bookmark to a remote
150
    Push {
151
        remote: Option<String>,
152
        /// Allow dropping heads the remote has
153
        #[arg(long)]
154
        force: bool,
155
    },
156
    /// Serve repositories under ROOT over HTTPS (with --tls-cert/--tls-key) or plain HTTP
157
    Serve {
158
        /// Directory with one repository per subdirectory
159
        #[arg(long, default_value = ".")]
160
        root: PathBuf,
161
        #[arg(long, default_value = "127.0.0.1:8890")]
162
        bind: String,
163
        /// Bearer token allowed to push (repeatable); none means open
164
        #[arg(long = "token")]
165
        tokens: Vec<String>,
166
        /// PEM certificate chain; enables HTTPS together with --tls-key
167
        #[arg(long, requires = "tls_key")]
168
        tls_cert: Option<PathBuf>,
169
        /// PEM private key
170
        #[arg(long, requires = "tls_cert")]
171
        tls_key: Option<PathBuf>,
172
    },
173
    /// Manage the signing identity
174
    Identity {
175
        #[command(subcommand)]
176
        cmd: IdentityCmd,
177
    },
178
    /// Verify the signatures on a change (default: every change in the log)
179
    Verify { change: Option<String> },
180
    /// Git interoperability
181
    Git {
182
        #[command(subcommand)]
183
        cmd: GitCmd,
184
    },
185
    /// Manage the trust policy (who may update which bookmarks)
186
    Policy {
187
        #[command(subcommand)]
188
        cmd: PolicyCmd,
189
    },
190
    /// Set a configuration key (author.*, core.sign, core.op-retention, core.gc-grace, core.checkpoint-interval, filter.max-blob, filter.paths, workspace.bookmark, remote.<name>.url|token|ca)
191
    Config {
192
        key: String,
193
        value: String,
194
        /// Write to the user file (~/.config/oxy/config.toml) instead of this repository
195
        #[arg(long)]
196
        user: bool,
197
    },
198
}
199
200
#[derive(Subcommand)]
201
enum OpCmd {
202
    Log,
203
}
204
205
#[derive(Subcommand)]
206
enum WorkspaceCmd {
207
    /// Create a workspace at PATH checked out on CHANGE... (default: the bookmark heads)
208
    Add { path: PathBuf, changes: Vec<String> },
209
    List,
210
    /// Stop tracking a workspace (by id prefix or path); its files stay
211
    Forget { workspace: String },
212
}
213
214
#[derive(Subcommand)]
215
enum TagCmd {
216
    /// Tag the state of CHANGE... (default: the bookmark heads)
217
    Create {
218
        name: String,
219
        changes: Vec<String>,
220
        #[arg(short, long, default_value = "")]
221
        message: String,
222
    },
223
    List,
224
}
225
226
#[derive(Subcommand)]
227
enum RemoteCmd {
228
    Add { name: String, url: String },
229
    Remove { name: String },
230
    List,
231
}
232
233
#[derive(Subcommand)]
234
enum IdentityCmd {
235
    /// Generate a composite Ed25519 + ML-DSA-65 key pair and make it your author identity
236
    Create {
237
        #[arg(long)]
238
        name: Option<String>,
239
        #[arg(long)]
240
        email: Option<String>,
241
        /// Record the author in this repository only, not in the user config
242
        #[arg(long)]
243
        local: bool,
244
        /// Expire the identity this many days from now
245
        #[arg(long)]
246
        expires: Option<u64>,
247
    },
248
    /// Show the configured identity
249
    Show,
250
    /// Write your identity object to a file, to hand to a policy holder
251
    Export { path: Option<PathBuf> },
252
    /// Move to a fresh key pair; the policy follows if it names your key
253
    Rotate,
254
    /// Re-issue your identity with a new expiry (in days from now; 0 = never)
255
    Renew {
256
        #[arg(long)]
257
        expires: u64,
258
    },
259
    /// Revoke a key: yours by default, or any key if you hold a policy root
260
    Revoke {
261
        /// KeyId prefix (default: your own key)
262
        key: Option<String>,
263
        #[arg(long, default_value = "")]
264
        reason: String,
265
    },
266
    /// Keys allowed to sign or seal on your behalf
267
    Delegate {
268
        #[command(subcommand)]
269
        cmd: DelegateCmd,
270
    },
271
}
272
273
#[derive(Subcommand)]
274
enum DelegateCmd {
275
    /// Add a delegate from an exported identity file
276
    Add { identity_file: PathBuf },
277
    /// Remove a delegate by KeyId prefix
278
    Remove { key: String },
279
    /// List delegates
280
    List,
281
}
282
283
#[derive(Subcommand)]
284
enum GitCmd {
285
    /// Export the current bookmark to a Git repository (created bare if absent)
286
    Export { git_dir: PathBuf },
287
    /// Import a Git branch into a new Oxygen repository at DEST
288
    Import {
289
        git_dir: PathBuf,
290
        dest: PathBuf,
291
        #[arg(long, default_value = "main")]
292
        branch: String,
293
    },
294
}
295
296
#[derive(Subcommand)]
297
enum PolicyCmd {
298
    /// Create the initial policy with your identity as the only root
299
    Init,
300
    /// Show the current policy
301
    Show,
302
    /// Allow an identity (from `oxy identity export`) to update bookmarks
303
    Grant {
304
        identity_file: PathBuf,
305
        /// Reference pattern the grant applies to (default: every bookmark)
306
        #[arg(long)]
307
        pattern: Option<String>,
308
        /// Where to write the proposal when more root signatures are needed
309
        #[arg(long)]
310
        out: Option<PathBuf>,
311
    },
312
    /// Revoke a key (KeyId prefix of an identity in the policy)
313
    Revoke {
314
        key: String,
315
        /// Where to write the proposal when more root signatures are needed
316
        #[arg(long)]
317
        out: Option<PathBuf>,
318
    },
319
    /// Manage root keys and the signature threshold for policy updates
320
    Root {
321
        #[command(subcommand)]
322
        cmd: RootCmd,
323
    },
324
    /// Add your root signature to a proposal file; applies it once the threshold is met
325
    Sign {
326
        proposal: PathBuf,
327
        /// Where to write the proposal if it still needs signatures (default: in place)
328
        #[arg(long)]
329
        out: Option<PathBuf>,
330
    },
331
}
332
333
#[derive(Subcommand)]
334
enum RootCmd {
335
    /// Make an exported identity a root key
336
    Add {
337
        identity_file: PathBuf,
338
        #[arg(long)]
339
        out: Option<PathBuf>,
340
    },
341
    /// Remove a root key by KeyId prefix
342
    Remove {
343
        key: String,
344
        #[arg(long)]
345
        out: Option<PathBuf>,
346
    },
347
    /// Require this many root signatures on policy updates
348
    Threshold {
349
        n: u32,
350
        #[arg(long)]
351
        out: Option<PathBuf>,
352
    },
353
}
354
355
/// Open the repository containing the current directory.
356
fn open() -> Result<Repo> {
357
    Repo::open(&std::env::current_dir()?).context("opening repository")
358
}
359
360
/// Parse arguments and run; returns the process exit code.
361
pub fn run() -> Result<i32> {
362
    let cli = Cli::parse();
363
    ui::init(cli.plain);
364
    match cli.cmd {
365
        Cmd::Init { path } => workspace::init(path),
366
        Cmd::Status => workspace::status(),
367
        Cmd::Prompt { format } => workspace::prompt(&format),
368
        Cmd::Commit { message, depends_on } => workspace::commit(&message, &depends_on),
369
        Cmd::Log { limit, verbose } => workspace::log(limit, verbose),
370
        Cmd::Diff => workspace::diff(),
371
        Cmd::Describe { message, change } => workspace::describe(&message, change.as_deref()),
372
        Cmd::Amend { message, into } => workspace::amend(message.as_deref(), into.as_deref()),
373
        Cmd::Split { change, paths } => workspace::split(&change, &paths),
374
        Cmd::Abandon { change } => workspace::abandon(&change),
375
        Cmd::Show { change } => workspace::show(&change),
376
        Cmd::Checkout { changes, discard } => workspace::checkout(&changes, discard),
377
        Cmd::Resolve => workspace::resolve(),
378
        Cmd::Undo => workspace::undo(),
379
        Cmd::Gc { now } => workspace::gc(now),
380
        Cmd::Checkpoint => workspace::checkpoint(),
381
        Cmd::Op { cmd: OpCmd::Log } => workspace::op_log(),
382
        Cmd::Clone { url, dest, max_blob, sparse, depth } => sync::clone(&url, dest, max_blob.as_deref(), &sparse, depth),
383
        Cmd::Deepen { depth, remote } => sync::deepen(&remote, depth),
384
        Cmd::Sparse { paths } => workspace::sparse(&paths),
385
        Cmd::Lock { path } => workspace::lock(&path),
386
        Cmd::Unlock { path } => workspace::unlock(&path),
387
        Cmd::Workspace { cmd } => match cmd {
388
            WorkspaceCmd::Add { path, changes } => workspace::workspace_add(&path, &changes),
389
            WorkspaceCmd::List => workspace::workspace_list(),
390
            WorkspaceCmd::Forget { workspace } => workspace::workspace_forget(&workspace),
391
        },
392
        Cmd::Tag { cmd } => match cmd {
393
            TagCmd::Create { name, changes, message } => workspace::tag_create(&name, &changes, &message),
394
            TagCmd::List => workspace::tag_list(),
395
        },
396
        Cmd::Remote { cmd } => match cmd {
397
            RemoteCmd::Add { name, url } => sync::remote_add(&name, &url),
398
            RemoteCmd::Remove { name } => sync::remote_remove(&name),
399
            RemoteCmd::List => sync::remote_list(),
400
        },
401
        Cmd::Fetch { remote } => sync::fetch(remote.as_deref()),
402
        Cmd::Pull { remote } => sync::pull(remote.as_deref()),
403
        Cmd::Push { remote, force } => sync::push(remote.as_deref(), force),
404
        Cmd::Serve { root, bind, tokens, tls_cert, tls_key } => sync::serve(root, &bind, tokens, tls_cert.zip(tls_key)),
405
        Cmd::Identity { cmd } => match cmd {
406
            IdentityCmd::Create { name, email, local, expires } => identity::create(name, email, local, expires),
407
            IdentityCmd::Show => identity::show(),
408
            IdentityCmd::Export { path } => identity::export(path),
409
            IdentityCmd::Rotate => identity::rotate(),
410
            IdentityCmd::Renew { expires } => identity::renew(expires),
411
            IdentityCmd::Revoke { key, reason } => identity::revoke(key.as_deref(), &reason),
412
            IdentityCmd::Delegate { cmd } => match cmd {
413
                DelegateCmd::Add { identity_file } => identity::delegate_add(&identity_file),
414
                DelegateCmd::Remove { key } => identity::delegate_remove(&key),
415
                DelegateCmd::List => identity::delegate_list(),
416
            },
417
        },
418
        Cmd::Git { cmd: GitCmd::Export { git_dir } } => sync::git_export(&git_dir),
419
        Cmd::Git { cmd: GitCmd::Import { git_dir, dest, branch } } => sync::git_import(&git_dir, &dest, &branch),
420
        Cmd::Policy { cmd } => match cmd {
421
            PolicyCmd::Init => identity::policy_init(),
422
            PolicyCmd::Show => identity::policy_show(),
423
            PolicyCmd::Grant { identity_file, pattern, out } => identity::policy_grant(&identity_file, pattern.as_deref(), out),
424
            PolicyCmd::Revoke { key, out } => identity::policy_revoke(&key, out),
425
            PolicyCmd::Root { cmd } => match cmd {
426
                RootCmd::Add { identity_file, out } => identity::policy_root_add(&identity_file, out),
427
                RootCmd::Remove { key, out } => identity::policy_root_remove(&key, out),
428
                RootCmd::Threshold { n, out } => identity::policy_threshold(n, out),
429
            },
430
            PolicyCmd::Sign { proposal, out } => identity::policy_sign(&proposal, out),
431
        },
432
        Cmd::Verify { change } => identity::verify(change.as_deref()),
433
        Cmd::Config { key, value, user } => identity::config(&key, &value, user),
434
    }
435
}