OXYGENHUB

Shell completions, man pages, and help for every argument d42369abf180

oxy completions bash|zsh|fish|elvish|powershell prints a completion
script and oxy man writes a page per command (104 of them) or prints
oxy.1, both generated from the command definitions. The oh-my-bash
plugin loads the bash completions.

Help text now covers every command and argument (112 were bare, such as
commit's --message and every --json), and a test keeps it that way, so
--help, completions and man pages stay complete. The guide shows how to
install them.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 08:51 UTC
Signature
✔ signed by Andy McCall
Hash
d42369abf1808edc68cfa051eca7024328f39d9732923c52053dca4bdf6a5a65
Depends on
05e71ed6cdf1 1196b29b5fa9 132b884b9a8d 135212de5370 23b217d75848 252e8cb23fbc 2863456a0f6d 28fc23d7706a 30ea9c1e70fa 387335ff7f4d 3e5772ccf9da 4dee277c327a 4e88e774777a 614541c3cebd 694805cca585 728da0f92161 7421309f5372 750251b21a96 75167672ebcd 7aa2dc9369e3 7b9809e67c3d 83e0677b07fe 957fee30826e b3c5a59a875a c3069537d7c5 c6aa5bd9d1c8 cdb5beba7f05 d82e44097323 e02bf0cd0533 e993a086c13f ee06a6c557b7 fce44ae9ad29 ff0af1ee299d
Depended on by
284fe47d98b9 9bd98cfb4f7e 157d02e95586 b63fc7a77fc6 0299076f09a2 733ea16a9701 e59512f2f075 a39754d02c58 b582948f1912 bf3a67819b59 d069701a4997 614f55e14eee 2c9ddac3c387 7ba155f20aa0 f874dc8659c2 9aa399044d9d 98a1620a3840 14edbaaf5291 ff1f62fee654 e0d56dfca51d 06d16dc2bc13 4fe7d6045860 83d61e803ccd 6a81dbab471d f8c695764c95 e3d071cecfe2 99e3e0af08df
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
121 atoms, 1 tree operation, 7 files

Browse the tree at this change

added tests/cli_docs.rs +69 −0
1 +
//! Shell completions and man pages come from the command definitions.
2 +
3 +
use std::process::Command;
4 +
5 +
fn oxy(args: &[&str]) -> (bool, String) {
6 +
    let out = Command::new(env!("CARGO_BIN_EXE_oxy"))
7 +
        .args(args)
8 +
        .env("NO_COLOR", "1")
9 +
        .output()
10 +
        .unwrap();
11 +
    (
12 +
        out.status.success(),
13 +
        String::from_utf8_lossy(&out.stdout).into_owned(),
14 +
    )
15 +
}
16 +
17 +
#[test]
18 +
fn completions_for_each_shell() {
19 +
    for (shell, marker) in [
20 +
        ("bash", "_oxy()"),
21 +
        ("zsh", "#compdef oxy"),
22 +
        ("fish", "complete -c oxy"),
23 +
        ("powershell", "Register-ArgumentCompleter"),
24 +
    ] {
25 +
        let (ok, text) = oxy(&["completions", shell]);
26 +
        assert!(ok, "{shell}");
27 +
        assert!(
28 +
            text.contains(marker),
29 +
            "{shell}: {}",
30 +
            &text[..text.len().min(200)]
31 +
        );
32 +
        assert!(
33 +
            text.contains("commit") && text.contains("doctor"),
34 +
            "{shell} knows the commands"
35 +
        );
36 +
    }
37 +
    assert!(
38 +
        !oxy(&["completions", "tcsh"]).0,
39 +
        "an unknown shell is refused"
40 +
    );
41 +
}
42 +
43 +
#[test]
44 +
fn man_pages_for_every_command() {
45 +
    let dir = tempfile::tempdir().unwrap();
46 +
    let (ok, _) = oxy(&["man", "--dir", &dir.path().to_string_lossy()]);
47 +
    assert!(ok);
48 +
    for page in [
49 +
        "oxy.1",
50 +
        "oxy-commit.1",
51 +
        "oxy-remote-add.1",
52 +
        "oxy-identity-delegate-add.1",
53 +
        "oxy-doctor.1",
54 +
    ] {
55 +
        let text = std::fs::read_to_string(dir.path().join(page))
56 +
            .unwrap_or_else(|_| panic!("{page} missing"));
57 +
        assert!(
58 +
            text.starts_with(".ie") || text.contains(".TH"),
59 +
            "{page} is roff"
60 +
        );
61 +
    }
62 +
    let commit = std::fs::read_to_string(dir.path().join("oxy-commit.1")).unwrap();
63 +
    assert!(
64 +
        commit.contains("subject line"),
65 +
        "argument help reaches the page"
66 +
    );
67 +
    let (ok, top) = oxy(&["man"]);
68 +
    assert!(ok && top.contains(".TH oxy"), "oxy man prints the top page");
69 +
}
modified Cargo.lock +27 −0
…
330 unchanged lines
331 331
]
332 332
333 333
[[package]]
334 +
name = "clap_complete"
335 +
version = "4.6.11"
336 +
source = "registry+https://github.com/rust-lang/crates.io-index"
337 +
checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f"
338 +
dependencies = [
339 +
 "clap",
340 +
]
341 +
342 +
[[package]]
334 343
name = "clap_derive"
335 344
version = "4.6.7"
336 345
source = "registry+https://github.com/rust-lang/crates.io-index"
…
12 unchanged lines
349 358
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
350 359
351 360
[[package]]
361 +
name = "clap_mangen"
362 +
version = "0.3.3"
363 +
source = "registry+https://github.com/rust-lang/crates.io-index"
364 +
checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4"
365 +
dependencies = [
366 +
 "clap",
367 +
 "roff",
368 +
]
369 +
370 +
[[package]]
352 371
name = "cmov"
353 372
version = "0.5.4"
354 373
source = "registry+https://github.com/rust-lang/crates.io-index"
…
849 unchanged lines
1204 1223
dependencies = [
1205 1224
 "anyhow",
1206 1225
 "clap",
1226 +
 "clap_complete",
1227 +
 "clap_mangen",
1207 1228
 "console",
1208 1229
 "indicatif",
1209 1230
 "libc",
…
211 unchanged lines
1421 1442
]
1422 1443
1423 1444
[[package]]
1445 +
name = "roff"
1446 +
version = "1.1.1"
1447 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1448 +
checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
1449 +
1450 +
[[package]]
1424 1451
name = "rustc_version"
1425 1452
version = "0.4.1"
1426 1453
source = "registry+https://github.com/rust-lang/crates.io-index"
…
946 unchanged lines
modified Cargo.toml +2 −0
…
27 unchanged lines
28 28
console = "0.15"
29 29
indicatif = "0.17"
30 30
serde_json = "1"
31 +
clap_complete = "4"
32 +
clap_mangen = "0.3"
31 33
32 34
[dev-dependencies]
33 35
tempfile = "3"
modified contrib/oh-my-bash/oxy.sh +6 −0
…
37 unchanged lines
38 38
  fi
39 39
  _omb_util_print "${OXY_CHAR}${bookmark} ${change} ${glyph}${up}${down}|${color}"
40 40
}
41 +
42 +
# Tab completion for oxy's commands and options.
43 +
if command -v oxy >/dev/null 2>&1; then
44 +
  # shellcheck disable=SC1090
45 +
  source <(oxy completions bash 2>/dev/null)
46 +
fi
modified docs/guide.md +11 −0
…
428 unchanged lines
429 429
  another. Nothing to fix; the state is their union.
430 430
- **Undo anything**: `oxy undo` reverts the last operation, including
431 431
  pulls, checkouts, and rewrites.
432 +
433 +
## Shell completions and man pages
434 +
435 +
```sh
436 +
oxy completions bash > ~/.local/share/bash-completion/completions/oxy   # bash
437 +
oxy completions zsh > "${fpath[1]}/_oxy"                                 # zsh
438 +
oxy completions fish > ~/.config/fish/completions/oxy.fish               # fish
439 +
oxy man --dir ~/.local/share/man/man1                                    # then: man oxy-commit
440 +
```
441 +
442 +
The oh-my-bash plugin in `contrib/oh-my-bash` loads the bash completions itself.
modified docs/spec.md +1 −0
…
852 unchanged lines
853 853
oxy root                            oxy cat PATH [-r CHANGE]     oxy version
854 854
oxy fsck [--json]                   check integrity (§21.1)
855 855
oxy doctor [--offline] [--json]     check identity, config and remotes (§21.2)
856 +
oxy completions SHELL               shell completion script     oxy man [--dir DIR]   man pages
856 857
oxy hunks                           oxy discard PATH.. | --hunks N,M
857 858
```
858 859
…
141 unchanged lines
modified src/cli/mod.rs +246 −17
…
30 unchanged lines
31 31
enum Cmd {
32 32
    /// Create a repository in PATH (default: current directory)
33 33
    Init {
34 +
        /// Directory to create the repository in (default: the current directory)
34 35
        path: Option<PathBuf>,
35 36
        /// No working copy: an object store and references only, for hosting
36 37
        #[arg(long)]
…
1 unchanged lines
38 39
    },
39 40
    /// Clone a repository from a local path or an http(s) URL
40 41
    Clone {
42 +
        /// Local path or http(s) URL of the repository
41 43
        url: String,
44 +
        /// Directory to clone into (default: the last part of the URL)
42 45
        dest: Option<PathBuf>,
43 46
        /// Leave blobs larger than this (e.g. 1M) to be fetched on first read
44 47
        #[arg(long)]
…
7 unchanged lines
52 55
    },
53 56
    /// Fetch more history below a shallow clone's boundary
54 57
    Deepen {
58 +
        /// Levels of history to add below the boundary
55 59
        #[arg(default_value_t = 10)]
56 60
        depth: u32,
61 +
        /// Remote to fetch the history from
57 62
        #[arg(long, default_value = "origin")]
58 63
        remote: String,
59 64
    },
60 65
    /// Show or set the sparse filter (paths to materialize); re-checks out the bookmark
61 -
    Sparse { paths: Vec<String> },
66 +
    Sparse {
67 +
        /// Paths to materialize; none prints the current filter
68 +
        paths: Vec<String>,
69 +
    },
62 70
    /// Show the working copy: changed paths and conflicts
63 71
    Status {
64 72
        /// Machine-readable output: paths, and every conflict with its kind and contributing changes
…
14 unchanged lines
79 87
    },
80 88
    /// Record the working copy as a change on the current bookmark
81 89
    Commit {
90 +
        /// The change's message: a subject line, then optionally a blank line and a body
82 91
        #[arg(short, long)]
83 92
        message: String,
84 93
        /// Extra explicit dependencies (ChangeId prefix, #hash prefix)
…
10 unchanged lines
95 104
    },
96 105
    /// Fold CHANGE into one of its dependencies (keeps that change's id)
97 106
    Squash {
107 +
        /// The change to fold (ChangeId prefix, #hash prefix)
98 108
        change: String,
109 +
        /// The dependency that absorbs it
99 110
        #[arg(long)]
100 111
        into: String,
101 112
    },
102 113
    /// Re-express CHANGE against another base state
103 114
    Rebase {
115 +
        /// The change to re-express (ChangeId prefix, #hash prefix)
104 116
        change: String,
105 117
        /// The changes, bookmark or tag whose state becomes the new base
106 118
        #[arg(long, required = true, num_args = 1..)]
…
1 unchanged lines
108 120
    },
109 121
    /// Show which change introduced each line of a file
110 122
    Blame {
123 +
        /// File to annotate, relative to the repository root
111 124
        path: String,
125 +
        /// Machine-readable output: one JSON document on stdout
112 126
        #[arg(long)]
113 127
        json: bool,
114 128
    },
…
13 unchanged lines
128 142
        /// Only changes touching this path (renames followed)
129 143
        #[arg(long)]
130 144
        path: Option<String>,
145 +
        /// Machine-readable output: one JSON document on stdout
131 146
        #[arg(long)]
132 147
        json: bool,
133 148
    },
…
1 unchanged lines
135 150
    Diff {
136 151
        /// A change instead of the working copy
137 152
        change: Option<String>,
153 +
        /// Machine-readable output: one JSON document on stdout
138 154
        #[arg(long)]
139 155
        json: bool,
140 156
    },
141 157
    /// Print a file as it is in a state (default: the working copy's base)
142 158
    Cat {
159 +
        /// File to print, relative to the repository root
143 160
        path: String,
144 161
        /// A change, bookmark, or tag
145 162
        #[arg(short = 'r', long)]
…
1 unchanged lines
147 164
    },
148 165
    /// Print the repository root, workspace, and bookmark
149 166
    Root {
167 +
        /// Machine-readable output: one JSON document on stdout
150 168
        #[arg(long)]
151 169
        json: bool,
152 170
    },
153 171
    /// Print the version of oxy, its repository format, and protocol
154 172
    Version {
173 +
        /// Machine-readable output: one JSON document on stdout
155 174
        #[arg(long)]
156 175
        json: bool,
157 176
    },
158 177
    /// Restore paths to the working copy's base, or undo chosen hunks
159 178
    Discard {
179 +
        /// Paths to restore; none with --hunks, or everything with neither
160 180
        paths: Vec<String>,
161 181
        /// Hunk indices as `oxy hunks` numbers them, comma separated
162 182
        #[arg(long, value_delimiter = ',')]
…
1 unchanged lines
164 184
    },
165 185
    /// List the working copy's hunks, numbered as commit --hunks expects
166 186
    Hunks {
187 +
        /// Machine-readable output: one JSON document on stdout
167 188
        #[arg(long)]
168 189
        json: bool,
169 190
    },
170 191
    /// Change the message of a head (default: the head the working copy sits on)
171 192
    Describe {
193 +
        /// The new message
172 194
        #[arg(short, long)]
173 195
        message: String,
196 +
        /// The head to describe (default: the one the working copy sits on)
174 197
        change: Option<String>,
175 198
    },
176 199
    /// Fold the working copy into the head it sits on, or into CHANGE with --into
177 200
    Amend {
201 +
        /// Replace the message too
178 202
        #[arg(short, long)]
179 203
        message: Option<String>,
180 204
        /// Amend an older change; the edits must touch only lines it already knows
…
1 unchanged lines
182 206
        into: Option<String>,
183 207
    },
184 208
    /// Split a head: the named paths become a separate first change
185 -
    Split { change: String, paths: Vec<String> },
209 +
    Split {
210 +
        /// The head to split
211 +
        change: String,
212 +
        /// Paths whose edits become the first change
213 +
        paths: Vec<String>,
214 +
    },
186 215
    /// Drop a head from the bookmark; its content stays on disk as uncommitted work
187 -
    Abandon { change: String },
216 +
    Abandon {
217 +
        /// The head to drop (ChangeId prefix, #hash prefix)
218 +
        change: String,
219 +
    },
188 220
    /// Show one change (ChangeId prefix, #hash prefix, or @)
189 221
    Show {
222 +
        /// ChangeId prefix, #hash prefix, or @ for the working copy
190 223
        change: String,
224 +
        /// Machine-readable output: one JSON document on stdout
191 225
        #[arg(long)]
192 226
        json: bool,
193 227
    },
194 228
    /// Move the working copy to the state formed by CHANGE... (default: the bookmark heads)
195 229
    Checkout {
230 +
        /// Changes whose state to check out (default: the bookmark heads)
196 231
        changes: Vec<String>,
197 232
        /// Throw away uncommitted working-copy changes
198 233
        #[arg(long)]
…
1 unchanged lines
200 235
    },
201 236
    /// Report which conflicts of the working copy's base are resolved by the current files
202 237
    Resolve {
238 +
        /// Machine-readable output: one JSON document on stdout
203 239
        #[arg(long)]
204 240
        json: bool,
205 241
    },
…
1 unchanged lines
207 243
    Undo,
208 244
    /// Write a checkpoint of the current bookmark's state now
209 245
    Checkpoint,
246 +
    /// Print a shell completion script: `oxy completions bash > ~/.local/share/bash-completion/completions/oxy`
247 +
    Completions {
248 +
        /// bash, zsh, fish, elvish or powershell
249 +
        shell: clap_complete::Shell,
250 +
    },
251 +
    /// Write man pages (oxy.1 and one per command) into DIR, or print oxy.1
252 +
    Man {
253 +
        /// Directory to write every page into, e.g. ~/.local/share/man/man1
254 +
        #[arg(long)]
255 +
        dir: Option<PathBuf>,
256 +
    },
210 257
    /// Check the identity, configuration and remotes, and say what to do about any problem
211 258
    Doctor {
212 259
        /// Do not contact the remotes
213 260
        #[arg(long)]
214 261
        offline: bool,
262 +
        /// Machine-readable output: one JSON document on stdout
215 263
        #[arg(long)]
216 264
        json: bool,
217 265
    },
218 266
    /// Check the repository's integrity: objects, operation log, references, signatures, checkpoints
219 267
    Fsck {
268 +
        /// Machine-readable output: one JSON document on stdout
220 269
        #[arg(long)]
221 270
        json: bool,
222 271
    },
…
10 unchanged lines
233 282
        cmd: OpCmd,
234 283
    },
235 284
    /// Take the advisory local lock on a lockable file, making it writable
236 -
    Lock { path: String },
285 +
    Lock {
286 +
        /// The lockable file
287 +
        path: String,
288 +
    },
237 289
    /// Release the local lock and make the file read-only again
238 -
    Unlock { path: String },
290 +
    Unlock {
291 +
        /// The locked file
292 +
        path: String,
293 +
    },
239 294
    /// Workspaces: extra checkout directories sharing this repository
240 295
    Workspace {
241 296
        #[command(subcommand)]
…
5 unchanged lines
247 302
        cmd: BookmarkCmd,
248 303
    },
249 304
    /// Union another bookmark's heads into the current one (no merge change)
250 -
    Merge { bookmark: String },
305 +
    Merge {
306 +
        /// The bookmark whose heads to add
307 +
        bookmark: String,
308 +
    },
251 309
    /// Subrepositories: nested repositories pinned to a state
252 310
    Submodule {
253 311
        #[command(subcommand)]
…
11 unchanged lines
265 323
    },
266 324
    /// Fetch every bookmark of a remote into its tracking references
267 325
    Fetch {
326 +
        /// Remote name (default: origin)
268 327
        remote: Option<String>,
328 +
        /// Machine-readable output: one JSON document on stdout
269 329
        #[arg(long)]
270 330
        json: bool,
271 331
    },
272 332
    /// Fetch, then union the remote bookmark into the local one (no merge change)
273 333
    Pull {
334 +
        /// Remote name (default: origin)
274 335
        remote: Option<String>,
336 +
        /// Machine-readable output: one JSON document on stdout
275 337
        #[arg(long)]
276 338
        json: bool,
277 339
    },
278 340
    /// Push the current bookmark to a remote
279 341
    Push {
342 +
        /// Remote name (default: origin)
280 343
        remote: Option<String>,
281 344
        /// Allow dropping heads the remote has
282 345
        #[arg(long)]
…
1 unchanged lines
284 347
        /// Push this bookmark instead of the workspace's current one
285 348
        #[arg(short = 'b', long)]
286 349
        bookmark: Option<String>,
350 +
        /// Machine-readable output: one JSON document on stdout
287 351
        #[arg(long)]
288 352
        json: bool,
289 353
    },
…
2 unchanged lines
292 356
        /// Directory with one repository per subdirectory
293 357
        #[arg(long, default_value = ".")]
294 358
        root: PathBuf,
359 +
        /// Address and port to listen on
295 360
        #[arg(long, default_value = "127.0.0.1:8890")]
296 361
        bind: String,
297 362
        /// Bearer token allowed to push (repeatable); none means open
…
13 unchanged lines
311 376
    },
312 377
    /// Seal changes with your key: named ones, or every change on the current bookmark you can authenticate but have not
313 378
    Seal {
379 +
        /// Changes to seal (default: every change on the bookmark you can authenticate and have not sealed)
314 380
        changes: Vec<String>,
315 381
        /// Seal every change of a hub proposal and push the seal (your approval)
316 382
        #[arg(long, conflicts_with = "changes")]
317 383
        proposal: Option<String>,
384 +
        /// Machine-readable output: one JSON document on stdout
318 385
        #[arg(long)]
319 386
        json: bool,
320 387
    },
…
4 unchanged lines
325 392
    },
326 393
    /// Verify the signatures on a change (default: every change in the log)
327 394
    Verify {
395 +
        /// The change to verify (default: every change in the log)
328 396
        change: Option<String>,
397 +
        /// Machine-readable output: one JSON document on stdout
329 398
        #[arg(long)]
330 399
        json: bool,
331 400
    },
…
9 unchanged lines
341 410
    },
342 411
    /// 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)
343 412
    Config {
413 +
        /// The key, such as author.name or remote.origin.token
344 414
        key: String,
415 +
        /// The value to set
345 416
        value: String,
346 417
        /// Write to the user file (~/.config/oxy/config.toml) instead of this repository
347 418
        #[arg(long)]
…
3 unchanged lines
351 422
352 423
#[derive(Subcommand)]
353 424
enum OpCmd {
425 +
    /// List operations, newest first
354 426
    Log {
427 +
        /// Machine-readable output: one JSON document on stdout
355 428
        #[arg(long)]
356 429
        json: bool,
357 430
    },
…
2 unchanged lines
360 433
#[derive(Subcommand)]
361 434
enum WorkspaceCmd {
362 435
    /// Create a workspace at PATH checked out on CHANGE... (default: the bookmark heads)
363 -
    Add { path: PathBuf, changes: Vec<String> },
436 +
    Add {
437 +
        /// Directory for the new workspace (must be empty or absent)
438 +
        path: PathBuf,
439 +
        /// Changes whose state to check out there
440 +
        changes: Vec<String>,
441 +
    },
442 +
    /// List workspaces and their working-copy changes
364 443
    List {
444 +
        /// Machine-readable output: one JSON document on stdout
365 445
        #[arg(long)]
366 446
        json: bool,
367 447
    },
368 448
    /// Stop tracking a workspace (by id prefix or path); its files stay
369 -
    Forget { workspace: String },
449 +
    Forget {
450 +
        /// Workspace id prefix or path
451 +
        workspace: String,
452 +
    },
370 453
}
371 454
372 455
#[derive(Subcommand)]
…
1 unchanged lines
374 457
    /// Begin; remembers where the working copy was
375 458
    Start,
376 459
    /// Mark CHANGE... (or the state under test) as good
377 -
    Good { changes: Vec<String> },
460 +
    Good {
461 +
        /// Changes known to be good (default: the state under test)
462 +
        changes: Vec<String>,
463 +
    },
378 464
    /// Mark CHANGE... (or the state under test) as bad
379 -
    Bad { changes: Vec<String> },
465 +
    Bad {
466 +
        /// Changes known to be bad (default: the state under test)
467 +
        changes: Vec<String>,
468 +
    },
380 469
    /// The state under test cannot be tested; try another
381 470
    Skip,
382 471
    /// Run a command for each step: exit 0 good, 125 skip, else bad
383 472
    Run {
473 +
        /// Command and arguments to run at each step
384 474
        #[arg(required = true, trailing_var_arg = true)]
385 475
        command: Vec<String>,
386 476
    },
387 477
    /// Where the search stands
388 478
    Status {
479 +
        /// Machine-readable output: one JSON document on stdout
389 480
        #[arg(long)]
390 481
        json: bool,
391 482
    },
…
4 unchanged lines
396 487
#[derive(Subcommand)]
397 488
enum BookmarkCmd {
398 489
    /// Create a bookmark at CHANGE... (default: the working copy's base)
399 -
    Create { name: String, changes: Vec<String> },
490 +
    Create {
491 +
        /// Name of the new bookmark
492 +
        name: String,
493 +
        /// Its heads (default: the working copy's base)
494 +
        changes: Vec<String>,
495 +
    },
400 496
    /// Point an existing bookmark at CHANGE...
401 -
    Set { name: String, changes: Vec<String> },
497 +
    Set {
498 +
        /// The bookmark to move
499 +
        name: String,
500 +
        /// Its new heads
501 +
        changes: Vec<String>,
502 +
    },
402 503
    /// Remove a bookmark; its changes stay
403 -
    Delete { name: String },
504 +
    Delete {
505 +
        /// The bookmark to remove
506 +
        name: String,
507 +
    },
404 508
    /// List bookmarks with their heads and difference from origin
405 509
    List {
510 +
        /// Machine-readable output: one JSON document on stdout
406 511
        #[arg(long)]
407 512
        json: bool,
408 513
    },
409 514
    /// Make NAME this workspace's bookmark and check it out
410 515
    Switch {
516 +
        /// The bookmark to work on
411 517
        name: String,
412 518
        /// Throw away uncommitted working-copy changes
413 519
        #[arg(long)]
…
5 unchanged lines
419 525
enum SubmoduleCmd {
420 526
    /// Clone URL (Oxygen or Git) into PATH and register it in .oxymodules
421 527
    Add {
528 +
        /// URL or path of the Oxygen or Git repository
422 529
        url: String,
530 +
        /// Where to put it, relative to the repository root
423 531
        path: String,
424 532
        /// Bookmark (Oxygen) or branch (Git) to check out and track
425 533
        #[arg(long)]
…
4 unchanged lines
430 538
        /// Clone nested repositories that are absent
431 539
        #[arg(long)]
432 540
        init: bool,
541 +
        /// Subrepos to update (default: all)
433 542
        paths: Vec<String>,
434 543
    },
435 544
    /// List subrepos with their pins and on-disk state
436 545
    Status {
546 +
        /// Machine-readable output: one JSON document on stdout
437 547
        #[arg(long)]
438 548
        json: bool,
439 549
    },
440 550
    /// Unregister PATH; the directory stays, the node goes with the next commit
441 -
    Remove { path: String },
551 +
    Remove {
552 +
        /// The subrepo to unregister
553 +
        path: String,
554 +
    },
442 555
}
443 556
444 557
#[derive(Subcommand)]
445 558
enum TagCmd {
446 559
    /// Tag the state of CHANGE... (default: the bookmark heads)
447 560
    Create {
561 +
        /// Name of the tag; tags never move
448 562
        name: String,
563 +
        /// Changes whose state to tag (default: the bookmark heads)
449 564
        changes: Vec<String>,
565 +
        /// A message recorded with the tag
450 566
        #[arg(short, long, default_value = "")]
451 567
        message: String,
452 568
    },
569 +
    /// List tags and the states they name
453 570
    List {
571 +
        /// Machine-readable output: one JSON document on stdout
454 572
        #[arg(long)]
455 573
        json: bool,
456 574
    },
…
1 unchanged lines
458 576
459 577
#[derive(Subcommand)]
460 578
enum RemoteCmd {
579 +
    /// Add a remote
461 580
    Add {
581 +
        /// Name to refer to the remote by, such as origin
462 582
        name: String,
583 +
        /// Local path or http(s) URL of the repository
463 584
        url: String,
464 585
    },
586 +
    /// Remove a remote
465 587
    Remove {
588 +
        /// The remote to remove
466 589
        name: String,
467 590
    },
591 +
    /// List remotes and their URLs
468 592
    List {
593 +
        /// Machine-readable output: one JSON document on stdout
469 594
        #[arg(long)]
470 595
        json: bool,
471 596
    },
…
3 unchanged lines
475 600
enum ProposalCmd {
476 601
    /// Show a proposal by its hub URL
477 602
    Show {
603 +
        /// The proposal's URL on the hub
478 604
        url: String,
605 +
        /// Machine-readable output: one JSON document on stdout
479 606
        #[arg(long)]
480 607
        json: bool,
481 608
    },
482 609
    /// Fetch the proposal's changes, set the target bookmark to the projected state, and push with your key
483 610
    Land {
611 +
        /// The proposal's URL on the hub
484 612
        url: String,
485 613
        /// Land even when the hub reports blockers
486 614
        #[arg(long)]
…
5 unchanged lines
492 620
enum IdentityCmd {
493 621
    /// Generate a composite Ed25519 + ML-DSA-65 key pair and make it your author identity
494 622
    Create {
623 +
        /// Your name (default: author.name)
495 624
        #[arg(long)]
496 625
        name: Option<String>,
626 +
        /// Your email (default: author.email)
497 627
        #[arg(long)]
498 628
        email: Option<String>,
499 629
        /// Record the author in this repository only, not in the user config
…
6 unchanged lines
506 636
    /// Show the configured identity
507 637
    Show,
508 638
    /// Write your identity object to a file, to hand to a policy holder
509 -
    Export { path: Option<PathBuf> },
639 +
    Export {
640 +
        /// File to write (default: stdout)
641 +
        path: Option<PathBuf>,
642 +
    },
510 643
    /// Sign a host's challenge (hex, at least 16 bytes) to prove you hold your key; prints the signature as hex
511 644
    SignChallenge {
645 +
        /// The host's challenge, as hex
512 646
        challenge: String,
647 +
        /// Machine-readable output: one JSON document on stdout
513 648
        #[arg(long)]
514 649
        json: bool,
515 650
    },
…
1 unchanged lines
517 652
    Rotate,
518 653
    /// Re-issue your identity with a new expiry (in days from now; 0 = never)
519 654
    Renew {
655 +
        /// Days from now until it expires; 0 for never
520 656
        #[arg(long)]
521 657
        expires: u64,
522 658
    },
…
1 unchanged lines
524 660
    Revoke {
525 661
        /// KeyId prefix (default: your own key)
526 662
        key: Option<String>,
663 +
        /// Why, recorded in the revocation
527 664
        #[arg(long, default_value = "")]
528 665
        reason: String,
529 666
    },
…
7 unchanged lines
537 674
#[derive(Subcommand)]
538 675
enum DelegateCmd {
539 676
    /// Add a delegate from an exported identity file
540 -
    Add { identity_file: PathBuf },
677 +
    Add {
678 +
        /// The delegate's exported identity file
679 +
        identity_file: PathBuf,
680 +
    },
541 681
    /// Remove a delegate by KeyId prefix
542 -
    Remove { key: String },
682 +
    Remove {
683 +
        /// KeyId prefix of the delegate
684 +
        key: String,
685 +
    },
543 686
    /// List delegates
544 687
    List,
545 688
}
…
2 unchanged lines
548 691
enum GitCmd {
549 692
    /// Export the current bookmark to a Git repository (created bare if absent)
550 693
    Export {
694 +
        /// Git repository to write to (created bare if absent)
551 695
        git_dir: PathBuf,
552 696
        /// Git branch to write (default: the bookmark name)
553 697
        #[arg(long)]
…
1 unchanged lines
555 699
    },
556 700
    /// Fetch a branch from a Git remote (path or URL) into the current bookmark
557 701
    Fetch {
702 +
        /// Git remote: a path or URL
558 703
        url: String,
704 +
        /// Branch to fetch
559 705
        #[arg(long, default_value = "main")]
560 706
        branch: String,
561 707
    },
562 708
    /// Push the current bookmark to a branch of a Git remote (fetch first)
563 709
    Push {
710 +
        /// Git remote: a path or URL
564 711
        url: String,
712 +
        /// Branch to push to
565 713
        #[arg(long, default_value = "main")]
566 714
        branch: String,
567 715
    },
568 716
    /// Import a Git branch into a new Oxygen repository at DEST
569 717
    Import {
718 +
        /// Git repository to read
570 719
        git_dir: PathBuf,
720 +
        /// Directory for the new Oxygen repository
571 721
        dest: PathBuf,
722 +
        /// Branch to import
572 723
        #[arg(long, default_value = "main")]
573 724
        branch: String,
574 725
    },
…
5 unchanged lines
580 731
    Init,
581 732
    /// Show the current policy
582 733
    Show {
734 +
        /// Machine-readable output: one JSON document on stdout
583 735
        #[arg(long)]
584 736
        json: bool,
585 737
    },
586 738
    /// Allow an identity (from `oxy identity export`) to update bookmarks
587 739
    Grant {
740 +
        /// The identity to grant, exported with `oxy identity export`
588 741
        identity_file: PathBuf,
589 742
        /// Reference pattern the grant applies to (default: every bookmark)
590 743
        #[arg(long)]
…
4 unchanged lines
595 748
    },
596 749
    /// Revoke a key (KeyId prefix of an identity in the policy)
597 750
    Revoke {
751 +
        /// KeyId prefix of the identity
598 752
        key: String,
599 753
        /// Where to write the proposal when more root signatures are needed
600 754
        #[arg(long)]
…
6 unchanged lines
607 761
    },
608 762
    /// Add your root signature to a proposal file; applies it once the threshold is met
609 763
    Sign {
764 +
        /// The proposal file to sign
610 765
        proposal: PathBuf,
611 766
        /// Where to write the proposal if it still needs signatures (default: in place)
612 767
        #[arg(long)]
…
5 unchanged lines
618 773
enum RootCmd {
619 774
    /// Make an exported identity a root key
620 775
    Add {
776 +
        /// The identity to make a root, exported with `oxy identity export`
621 777
        identity_file: PathBuf,
778 +
        /// Where to write the proposal when more root signatures are needed
622 779
        #[arg(long)]
623 780
        out: Option<PathBuf>,
624 781
    },
625 782
    /// Remove a root key by KeyId prefix
626 783
    Remove {
784 +
        /// KeyId prefix of the root key
627 785
        key: String,
786 +
        /// Where to write the proposal when more root signatures are needed
628 787
        #[arg(long)]
629 788
        out: Option<PathBuf>,
630 789
    },
631 790
    /// Require this many root signatures on policy updates
632 791
    Threshold {
792 +
        /// Number of root signatures
633 793
        n: u32,
794 +
        /// Where to write the proposal when more root signatures are needed
634 795
        #[arg(long)]
635 796
        out: Option<PathBuf>,
636 797
    },
…
4 unchanged lines
641 802
    Repo::open(&std::env::current_dir()?).context("opening repository")
642 803
}
643 804
805 +
/// `oxy man`: every page into `dir`, or the top-level page on stdout.
806 +
fn man(dir: Option<PathBuf>) -> Result<i32> {
807 +
    use clap::CommandFactory;
808 +
    let cmd = Cli::command();
809 +
    match dir {
810 +
        Some(dir) => {
811 +
            std::fs::create_dir_all(&dir)?;
812 +
            clap_mangen::generate_to(cmd, &dir)?;
813 +
            let pages = std::fs::read_dir(&dir)?
814 +
                .filter(|e| {
815 +
                    e.as_ref()
816 +
                        .is_ok_and(|e| e.path().extension().is_some_and(|x| x == "1"))
817 +
                })
818 +
                .count();
819 +
            println!(
820 +
                "{}",
821 +
                ui::done(format!("Wrote {pages} man pages to {}", dir.display()))
822 +
            );
823 +
        }
824 +
        None => clap_mangen::Man::new(cmd).render(&mut std::io::stdout())?,
825 +
    }
826 +
    Ok(0)
827 +
}
828 +
644 829
/// Parse arguments and run; returns the process exit code.
645 830
pub fn run() -> Result<i32> {
646 831
    let cli = Cli::parse();
647 832
    ui::init(cli.plain);
648 833
    match cli.cmd {
834 +
        Cmd::Completions { shell } => {
835 +
            use clap::CommandFactory;
836 +
            clap_complete::generate(shell, &mut Cli::command(), "oxy", &mut std::io::stdout());
837 +
            Ok(0)
838 +
        }
839 +
        Cmd::Man { dir } => man(dir),
649 840
        Cmd::Init { path, bare } => workspace::init(path, bare),
650 841
        Cmd::Status { json } => workspace::status(json),
651 842
        Cmd::Prompt {
…
179 unchanged lines
831 1022
        Cmd::Config { key, value, user } => identity::config(&key, &value, user),
832 1023
    }
833 1024
}
1025 +
1026 +
#[cfg(test)]
1027 +
mod help_tests {
1028 +
    use super::Cli;
1029 +
    use clap::CommandFactory;
1030 +
1031 +
    /// Every command and every argument explains itself, so `--help`,
1032 +
    /// completions and the man pages are complete.
1033 +
    #[test]
1034 +
    fn every_command_and_argument_has_help() {
1035 +
        fn walk(cmd: &clap::Command, path: &str, missing: &mut Vec<String>) {
1036 +
            let here = format!("{path} {}", cmd.get_name()).trim().to_string();
1037 +
            if cmd.get_about().is_none() && path != "" {
1038 +
                missing.push(format!("{here} (command)"));
1039 +
            }
1040 +
            for a in cmd.get_arguments() {
1041 +
                let id = a.get_id().as_str();
1042 +
                if id == "help" || id == "version" || a.is_global_set() && path != "" {
1043 +
                    continue;
1044 +
                }
1045 +
                if a.get_help().is_none() && a.get_long_help().is_none() {
1046 +
                    missing.push(format!("{here} {id}"));
1047 +
                }
1048 +
            }
1049 +
            for sub in cmd.get_subcommands() {
1050 +
                walk(sub, &here, missing);
1051 +
            }
1052 +
        }
1053 +
        let mut missing = Vec::new();
1054 +
        walk(&Cli::command(), "", &mut missing);
1055 +
        assert!(
1056 +
            missing.is_empty(),
1057 +
            "{} without help:\n{}",
1058 +
            missing.len(),
1059 +
            missing.join("\n")
1060 +
        );
1061 +
    }
1062 +
}