OXYGENHUB

oxy describe names builds; tags name changes; commit -F 6a81dbab471d

oxy describe without -m names the working copy as git describe does: the
base state's tag, else the nearest tag below it with the number of
changes it adds and the newest head's ChangeId (v0.1.0-2-8c5ea12a), else
the bookmark and ChangeId, and -dirty for uncommitted work; --json gives
the parts. With -m it still changes a head's message.

A tag whose state has one head names that change wherever a change is
expected (oxy show v0.1.0), and tag list shows the heads rather than the
state hash. oxy commit -F FILE (or - for standard input) reads the
message.

closes #18
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 01:50 UTC
Signature
✔ signed by Andy McCall
Hash
6a81dbab471d184c699e085848305936b681e8f1cf0ec605191547e17ba57840
Depends on
132b884b9a8d 1c2bd9abdd4f 23b217d75848 28fc23d7706a 50a046e439e9 614541c3cebd 75036530b621 75167672ebcd 7b9809e67c3d 86e682c242c6 94084087a876 957fee30826e 982864068a59 c1fe1fb25021 c2d790f9fa95 c6aa5bd9d1c8 d42369abf180 fce44ae9ad29 ff0af1ee299d
Depended on by
642da2636de2 64dc5197f9df 69953b507188 c895bc52e4e0 b689779e9e74 530a0fc650a2 2c9ddac3c387 98a1620a3840 14edbaaf5291 4fe7d6045860 83d61e803ccd e7ac6c2e84e4
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
24 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified crates/oxy-core/src/repo/inspect.rs +92 −0
…
373 unchanged lines
374 374
        }
375 375
    }
376 376
}
377 +
378 +
/// What `oxy describe` reports about the working copy (spec §10.1).
379 +
#[derive(Clone, Debug, PartialEq, Eq)]
380 +
pub struct Description {
381 +
    /// The tag of the base state, or the nearest tag below it.
382 +
    pub tag: Option<String>,
383 +
    /// Changes in the base state that the tag's state lacks (0: exact).
384 +
    pub distance: usize,
385 +
    pub bookmark: String,
386 +
    /// The base's newest head.
387 +
    pub change: ChangeId,
388 +
    pub hash: ChangeHash,
389 +
    /// Whether the working copy has uncommitted changes.
390 +
    pub dirty: bool,
391 +
}
392 +
393 +
impl Description {
394 +
    /// `v1.2`, `v1.2-3-8c5ea12a`, or `develop-8c5ea12a`, and `-dirty`.
395 +
    pub fn line(&self) -> String {
396 +
        let short = self.change.short(8);
397 +
        let base = match (&self.tag, self.distance) {
398 +
            (Some(t), 0) => t.clone(),
399 +
            (Some(t), n) => format!("{t}-{n}-{short}"),
400 +
            (None, _) => format!("{}-{short}", self.bookmark),
401 +
        };
402 +
        if self.dirty {
403 +
            format!("{base}-dirty")
404 +
        } else {
405 +
            base
406 +
        }
407 +
    }
408 +
}
409 +
410 +
impl Repo {
411 +
    /// Name the working copy for builds (`oxy describe`, like `git
412 +
    /// describe`): the tag of its base state; else the nearest tag whose
413 +
    /// state the base contains, with how many changes it adds and its
414 +
    /// newest head; else the bookmark and that head. `dirty` when the
415 +
    /// working copy has uncommitted changes.
416 +
    pub fn describe_working_copy(&self) -> Result<Description> {
417 +
        let snap = self.snapshot_status("oxy describe")?;
418 +
        let dirty = !snap.statuses.is_empty();
419 +
        let base = snap.base.maximal(&snap.wc.deps);
420 +
        let Some(&newest) = base
421 +
            .iter()
422 +
            .max_by_key(|h| snap.base.get(h).map(|c| (c.hlc, **h)))
423 +
        else {
424 +
            return fail("the working copy sits on no change yet");
425 +
        };
426 +
        let Some(change) = snap.base.get(&newest).map(|m| m.change_id) else {
427 +
            return fail("the working copy's base is not loaded");
428 +
        };
429 +
        let mut sorted = base.clone();
430 +
        sorted.sort();
431 +
        let mut best: Option<(usize, String)> = None;
432 +
        for t in self.tags()? {
433 +
            if !t.heads.iter().all(|h| snap.base.contains(h)) {
434 +
                continue;
435 +
            }
436 +
            let mut tagged: std::collections::HashSet<ChangeHash> =
437 +
                t.heads.iter().copied().collect();
438 +
            for h in &t.heads {
439 +
                tagged.extend(snap.base.closure(h).iter().copied());
440 +
            }
441 +
            let mut heads = t.heads.clone();
442 +
            heads.sort();
443 +
            let distance = if heads == sorted {
444 +
                0
445 +
            } else {
446 +
                snap.base.len().saturating_sub(tagged.len()).max(1)
447 +
            };
448 +
            if best
449 +
                .as_ref()
450 +
                .is_none_or(|(d, n)| distance < *d || (distance == *d && t.name < *n))
451 +
            {
452 +
                best = Some((distance, t.name));
453 +
            }
454 +
        }
455 +
        let (distance, tag) = match best {
456 +
            Some((d, t)) => (d, Some(t)),
457 +
            None => (0, None),
458 +
        };
459 +
        Ok(Description {
460 +
            tag,
461 +
            distance,
462 +
            bookmark: self.config.bookmark.clone(),
463 +
            change,
464 +
            hash: newest,
465 +
            dirty,
466 +
        })
467 +
    }
468 +
}
modified crates/oxy-core/src/repo/mod.rs +9 −5
…
90 unchanged lines
91 91
pub use hooks::{json_array, json_str, HookOutcome};
92 92
pub use hosted::FileEdit;
93 93
pub use identity::{Rotation, SignatureStatus};
94 -
pub use inspect::{BisectStep, BlameLine};
94 +
pub use inspect::{BisectStep, BlameLine, Description};
95 95
pub use policy::PolicyUpdate;
96 96
pub use prompt::{PromptInfo, DEFAULT_PROMPT_FORMAT, GLYPH_PROMPT_FORMAT};
97 97
pub use snapshot::{
…
1311 unchanged lines
1409 1409
        Ok(vec![self.resolve_change(spec)?])
1410 1410
    }
1411 1411
1412 -
    /// Resolve a change by ChangeId prefix, `#hash` prefix, `@` (working copy), or bookmark.
1412 +
    /// Resolve a change by ChangeId prefix, `#hash` prefix, `@` (working
1413 +
    /// copy), bookmark or tag (the one head of its state).
1413 1414
    pub fn resolve_change(&self, spec: &str) -> Result<ChangeHash> {
1414 1415
        if spec == "@" {
1415 1416
            return Ok(self.working_copy()?.0);
1416 1417
        }
1417 -
        if let Some(st) = self.view()?.bookmarks.get(spec) {
1418 -
            let heads = self.store.get_state(st)?.heads;
1418 +
        let named = match self.view()?.bookmarks.get(spec) {
1419 +
            Some(st) => Some(("bookmark", self.store.get_state(st)?.heads)),
1420 +
            None => self.tag_heads(spec)?.map(|h| ("tag", h)),
1421 +
        };
1422 +
        if let Some((kind, heads)) = named {
1419 1423
            return match heads.len() {
1420 1424
                1 => Ok(heads[0]),
1421 -
                n => fail(format!("bookmark {spec} has {n} heads; name a change, or use it where a state is expected")),
1425 +
                n => fail(format!("{kind} {spec} has {n} heads; name a change, or use it where a state is expected")),
1422 1426
            };
1423 1427
        }
1424 1428
        if spec.is_empty() || spec == "#" {
…
150 unchanged lines
modified docs/backlog-overnight-2.md +2 −1
…
75 unchanged lines
76 76
- [x] M6 Flow API: `GET /repos/{o}/{n}/flow/runs`, `/flow/runs/{n}` with jobs, a job's log; checks by head hash as well as state (feedback 11). JSON 404s under `/api/v1` (feedback 13). Per-repository run numbers, or a note (feedback 12).
77 77
- [x] M7 Server push errors name the operation and bookmark and log the error chain (feedback 2).
78 78
- [x] M8 `merge` updates a working copy that is clean against the pre-merge base; `checkout` tells "removed by you" from "not yet written"; `bookmark switch --help` says what happens to uncommitted work (feedback 3).
79 -
- [ ] M9 Small oxy items: `oxy describe` (tag, else bookmark + change, `+dirty`) and tags accepted where a change is (feedback 4); `commit -F` (6); `submodule status` shows added-but-unpinned and has `--json` (5, 7); doctor before the first commit (8).
79 +
- [x] M9a `oxy describe` naming (tag, else nearest tag + distance + change, else bookmark + change, `-dirty`, `--json`), tags accepted where a change is and `tag list` showing heads (feedback 4); `commit -F` (6).
80 +
- [ ] M9b `submodule status` shows added-but-unpinned and has `--json` (5, 7); doctor before the first commit (8).
80 81
- [ ] M10 Hub: say "signed by an unlinked key" on changes and in `/api/v1/user`, pointing at Settings (feedback 14).
81 82
- [ ] P1 **Conflict resolution on OxygenHub** (Andy's list, item 1). A resolver page for a conflicted state:
82 83
  - Per conflict region: pick a side, several sides, or none; edit freely; tree conflicts too (keep a candidate).
…
456 unchanged lines
modified docs/spec.md +3 −3
…
422 unchanged lines
423 423
State { version [0]: 1, heads [1]: [ChangeHash] }   // sorted, unique; may be empty
424 424
```
425 425
426 -
A repository holds any number of bookmarks. Each workspace has a **current bookmark** (`oxy bookmark switch NAME`, recorded per workspace) that its working copy sits on and that `commit`, `log`, `push`, and `pull` act on by default. Switching refuses uncommitted work unless told what to do with it: `--discard` drops it, and `--carry` takes it along when every path it touches is the same in NAME's state as in the working copy's base (so the same edits apply there), refusing and naming the paths otherwise. `oxy bookmark create feat && oxy bookmark switch feat --carry` moves work started on one bookmark onto a new one. `oxy bookmark create NAME [CHANGE..]` names a new state (default: the current working copy's base heads), `set NAME CHANGE..` moves one, `delete NAME` removes it (its changes stay until unreachable, §21), `list` shows all with their heads and the remote-tracking difference, and `oxy merge NAME` unions the named bookmark's heads into the current one, which is the whole of merging in this model: no merge change, conflicts reified (§9); a working copy without changes follows the merge, whichever state it sat on. `oxy checkout NAME` materializes a bookmark without switching to it; with uncommitted work it refuses, naming each changed path and how it changed (a *removed* path is in the working copy's base but not on disk), unless `--discard` or `--carry` (as for switching) says what to do with the work. Bookmark names are resolved before change prefixes wherever a change is expected.
426 +
A repository holds any number of bookmarks. Each workspace has a **current bookmark** (`oxy bookmark switch NAME`, recorded per workspace) that its working copy sits on and that `commit`, `log`, `push`, and `pull` act on by default. Switching refuses uncommitted work unless told what to do with it: `--discard` drops it, and `--carry` takes it along when every path it touches is the same in NAME's state as in the working copy's base (so the same edits apply there), refusing and naming the paths otherwise. `oxy bookmark create feat && oxy bookmark switch feat --carry` moves work started on one bookmark onto a new one. `oxy bookmark create NAME [CHANGE..]` names a new state (default: the current working copy's base heads), `set NAME CHANGE..` moves one, `delete NAME` removes it (its changes stay until unreachable, §21), `list` shows all with their heads and the remote-tracking difference, and `oxy merge NAME` unions the named bookmark's heads into the current one, which is the whole of merging in this model: no merge change, conflicts reified (§9); a working copy without changes follows the merge, whichever state it sat on. `oxy checkout NAME` materializes a bookmark without switching to it; with uncommitted work it refuses, naming each changed path and how it changed (a *removed* path is in the working copy's base but not on disk), unless `--discard` or `--carry` (as for switching) says what to do with the work. Bookmark names, then tag names, are resolved before change prefixes wherever a change is expected (a bookmark or tag names a change when its state has one head). For builds, `oxy describe` without `-m` names the working copy as `git describe` does: its base state's tag (`v1.2`), else the nearest tag whose state the base contains with the number of changes it adds and the newest head's ChangeId (`v1.2-3-8c5ea12a`), else the bookmark and that ChangeId (`develop-8c5ea12a`), with `-dirty` when the working copy has uncommitted changes; `--json` gives the parts.
427 427
428 428
Every reference, attestation, view, and checkpoint therefore names a state by one ObjectRef however many heads it has. Multiple heads are normal in this model: every independent change is a head until something depends on it, so a busy bookmark may have dozens. That is not a conflict in the §9 sense; `oxy log` shows the heads as parallel, and a state object keeps the cost of naming them constant for everything that refers to the bookmark. The empty state, `State { heads: [] }`, is what a new bookmark points at.
429 429
…
58 unchanged lines
488 488
The working copy is a real change with `kind = working-copy`. Before running any command that reads repository state, `oxy` snapshots the working copy: it diffs the file system against the working-copy change's materialized state and rewrites the working-copy change to include the difference. The change therefore always describes the working directory. To prevent operation log bloat, a command that snapshots the working copy MUST NOT record a new Operation (§12.1) if the filesystem diff was empty (i.e., the working-copy change's hash did not change).
489 489
490 490
- The working-copy change is unsigned and never pushed while `kind = working-copy`.
491 -
- `oxy commit` (alias `oxy describe`) sets the message, sets `kind = normal`, and starts a fresh working-copy change on top of it. The change is signed immediately if `core.sign = "change"`; with the default `core.sign = "seal"` it is left unsigned locally and covered by a seal at push time (§14.4).
491 +
- `oxy commit` (`-m MSG`, or `-F FILE` with `-` for standard input) sets the message, sets `kind = normal`, and starts a fresh working-copy change on top of it. The change is signed immediately if `core.sign = "change"`; with the default `core.sign = "seal"` it is left unsigned locally and covered by a seal at push time (§14.4).
492 492
- `oxy new`, `oxy edit <change>`, and `oxy abandon` move which change the workspace edits.
493 493
- There is no staging area and no stash. A partial commit is a split of the working copy: `oxy commit PATH..` records only the named paths (and every edit of theirs) and leaves the rest as uncommitted work; `oxy commit --interactive` offers each hunk (one `Insert` or `Delete` edit on one file, or one tree operation) for inclusion, like `git add -p`; `oxy split` does the same to an existing change. A hunk that another selected hunk refers to is selected with it, so the committed change is always well-formed (§8.3).
494 494
…
348 unchanged lines
843 843
oxy status [--json]                 oxy diff                     oxy prompt [--format F]
844 844
oxy commit -m MSG [--depends-on CHANGE]..
845 845
oxy log [-n N] [-v]                 oxy show CHANGE
846 -
oxy describe [CHANGE] -m MSG        oxy amend [-m MSG] [--into CHANGE]
846 +
oxy describe [CHANGE] [-m MSG]      oxy amend [-m MSG] [--into CHANGE]
847 847
oxy split CHANGE [PATH]..           oxy abandon CHANGE           oxy resolve
848 848
oxy checkout [CHANGE].. [--discard|--carry] oxy undo                     oxy op log
849 849
oxy tag create NAME [CHANGE].. [-m MSG]   oxy tag list
…
162 unchanged lines
modified src/cli/mod.rs +41 −6
…
87 unchanged lines
88 88
    /// Record the working copy as a change on the current bookmark
89 89
    Commit {
90 90
        /// The change's message: a subject line, then optionally a blank line and a body
91 -
        #[arg(short, long)]
92 -
        message: String,
91 +
        #[arg(short, long, required_unless_present = "file", conflicts_with = "file")]
92 +
        message: Option<String>,
93 +
        /// Read the message from FILE (`-` for standard input)
94 +
        #[arg(short = 'F', long, value_name = "FILE")]
95 +
        file: Option<PathBuf>,
93 96
        /// Extra explicit dependencies (ChangeId prefix, #hash prefix)
94 97
        #[arg(long = "depends-on")]
95 98
        depends_on: Vec<String>,
…
92 unchanged lines
188 191
        #[arg(long)]
189 192
        json: bool,
190 193
    },
191 -
    /// Change the message of a head (default: the head the working copy sits on)
194 +
    /// With -m, change the message of a head; without, name the working
195 +
    /// copy for a build: its tag, or the nearest tag plus distance and
196 +
    /// change, or the bookmark and change, and -dirty
192 197
    Describe {
193 198
        /// The new message
194 199
        #[arg(short, long)]
195 -
        message: String,
200 +
        message: Option<String>,
196 201
        /// The head to describe (default: the one the working copy sits on)
197 202
        change: Option<String>,
203 +
        /// Machine-readable output (naming only)
204 +
        #[arg(long, conflicts_with = "message")]
205 +
        json: bool,
198 206
    },
199 207
    /// Fold the working copy into the head it sits on, or into CHANGE with --into
200 208
    Amend {
…
665 unchanged lines
866 874
        } => workspace::prompt(&format, glyphs, color.as_deref()),
867 875
        Cmd::Commit {
868 876
            message,
877 +
            file,
869 878
            depends_on,
870 879
            paths,
871 880
            interactive,
872 881
            hunks,
873 -
        } => workspace::commit(&message, &depends_on, &paths, interactive, &hunks),
882 +
        } => {
883 +
            let message = match (message, file) {
884 +
                (Some(m), _) => m,
885 +
                (None, Some(f)) if f.as_os_str() == "-" => {
886 +
                    let mut s = String::new();
887 +
                    std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
888 +
                    s
889 +
                }
890 +
                (None, Some(f)) => std::fs::read_to_string(&f).map_err(|e| {
891 +
                    anyhow::anyhow!("reading the message from {}: {e}", f.display())
892 +
                })?,
893 +
                (None, None) => anyhow::bail!("a commit message is required (-m or -F)"),
894 +
            };
895 +
            workspace::commit(&message, &depends_on, &paths, interactive, &hunks)
896 +
        }
874 897
        Cmd::Cat { path, change } => workspace::cat(&path, change.as_deref()),
875 898
        Cmd::Root { json } => workspace::root(json),
876 899
        Cmd::Version { json } => workspace::version(json),
…
18 unchanged lines
895 918
            json,
896 919
        } => workspace::log(limit, verbose, path.as_deref(), json),
897 920
        Cmd::Diff { change, json } => workspace::diff(change.as_deref(), json),
898 -
        Cmd::Describe { message, change } => workspace::describe(&message, change.as_deref()),
921 +
        Cmd::Describe {
922 +
            message,
923 +
            change,
924 +
            json,
925 +
        } => match message {
926 +
            Some(m) => workspace::describe(&m, change.as_deref()),
927 +
            None if change.is_some() => {
928 +
                anyhow::bail!(
929 +
                    "naming works on the working copy; to change a head's message pass -m"
930 +
                )
931 +
            }
932 +
            None => workspace::describe_name(json),
933 +
        },
899 934
        Cmd::Amend { message, into } => workspace::amend(message.as_deref(), into.as_deref()),
900 935
        Cmd::Split { change, paths } => workspace::split(&change, &paths),
901 936
        Cmd::Abandon { change } => workspace::abandon(&change),
…
196 unchanged lines
modified src/cli/workspace.rs +31 −1
…
1204 unchanged lines
1205 1205
    }
1206 1206
}
1207 1207
1208 +
/// `oxy describe` without -m: the working copy's name for a build.
1209 +
pub fn describe_name(as_json: bool) -> Result<i32> {
1210 +
    let repo = open()?;
1211 +
    let d = repo.describe_working_copy()?;
1212 +
    if as_json {
1213 +
        println!(
1214 +
            "{}",
1215 +
            json::obj([
1216 +
                ("describe", json::s(&d.line())),
1217 +
                ("tag", json::opt(d.tag.as_ref(), |t| json::s(t))),
1218 +
                ("distance", json::n(d.distance)),
1219 +
                ("bookmark", json::s(&d.bookmark)),
1220 +
                ("change", json::s(&d.change.to_hex())),
1221 +
                ("hash", json::s(&d.hash.to_hex())),
1222 +
                ("dirty", json::b(d.dirty)),
1223 +
            ])
1224 +
        );
1225 +
    } else {
1226 +
        println!("{}", d.line());
1227 +
    }
1228 +
    Ok(0)
1229 +
}
1230 +
1208 1231
pub fn describe(message: &str, change: Option<&str>) -> Result<i32> {
1209 1232
    let repo = open()?;
1210 1233
    let h = match change {
…
176 unchanged lines
1387 1410
            "{} {} {}{}{}{}",
1388 1411
            ui::ok(ui::glyph("⚑", "#")),
1389 1412
            ui::bookmark(&t.name),
1390 -
            ui::hash(format!("#{}", t.state.short(12))),
1413 +
            // The heads, as `oxy log` shows changes, not the state hash.
1414 +
            ui::hash(
1415 +
                t.heads
1416 +
                    .iter()
1417 +
                    .map(|h| format!("#{}", h.short(12)))
1418 +
                    .collect::<Vec<_>>()
1419 +
                    .join(" ")
1420 +
            ),
1391 1421
            ui::sep() + &ui::dim(plural(t.heads.len(), "head")),
1392 1422
            if t.message.is_empty() {
1393 1423
                String::new()
…
343 unchanged lines
modified tests/cli_json.rs +55 −0
…
214 unchanged lines
215 215
        (1, Some(true))
216 216
    );
217 217
}
218 +
219 +
/// `oxy commit -F`, `oxy describe` naming and tags as changes (feedback
220 +
/// 4 and 6).
221 +
#[test]
222 +
fn commit_from_a_file_and_describe_for_builds() {
223 +
    let tmp = tempfile::tempdir().unwrap();
224 +
    let home = tmp.path().join("home");
225 +
    std::fs::create_dir_all(&home).unwrap();
226 +
    let dir = tmp.path().join("r");
227 +
    std::fs::create_dir_all(&dir).unwrap();
228 +
    assert_eq!(oxy(&dir, &home, &["init"]).0, 0);
229 +
    write(&dir, "a.txt", "a\n");
230 +
    write(
231 +
        tmp.path(),
232 +
        "msg.txt",
233 +
        "Subject from a file\n\nA body\nof two lines.\n",
234 +
    );
235 +
    let msg = tmp.path().join("msg.txt");
236 +
    let (code, _, err) = oxy(&dir, &home, &["commit", "-F", msg.to_str().unwrap()]);
237 +
    assert_eq!(code, 0, "{err}");
238 +
    let log = json(&dir, &home, &["log", "--json", "-n", "2"]);
239 +
    assert_eq!(
240 +
        log["changes"][1]["message"],
241 +
        "Subject from a file\n\nA body\nof two lines."
242 +
    );
243 +
    assert_ne!(oxy(&dir, &home, &["commit"]).0, 0, "a message is required");
244 +
245 +
    // Untagged: bookmark and change; a tag names it; later work counts.
246 +
    let d = json(&dir, &home, &["describe", "--json"]);
247 +
    assert_eq!(
248 +
        (d["tag"].clone(), d["dirty"].clone()),
249 +
        (Value::Null, Value::Bool(false))
250 +
    );
251 +
    let change = d["change"].as_str().unwrap().to_string();
252 +
    assert_eq!(d["describe"], format!("main-{}", &change[..8]));
253 +
    assert_eq!(
254 +
        oxy(&dir, &home, &["tag", "create", "v0.1.0", "-m", "first"]).0,
255 +
        0
256 +
    );
257 +
    let (_, out, _) = oxy(&dir, &home, &["describe"]);
258 +
    assert_eq!(String::from_utf8_lossy(&out).trim(), "v0.1.0");
259 +
    let (code, out, err) = oxy(&dir, &home, &["show", "v0.1.0"]);
260 +
    assert_eq!(code, 0, "a tag names its change: {err}");
261 +
    assert!(String::from_utf8_lossy(&out).contains("Subject from a file"));
262 +
    write(&dir, "b.txt", "b\n");
263 +
    let d = json(&dir, &home, &["describe", "--json"]);
264 +
    assert_eq!(d["describe"], "v0.1.0-dirty");
265 +
    assert_eq!(oxy(&dir, &home, &["commit", "-m", "b"]).0, 0);
266 +
    write(&dir, "c.txt", "c\n");
267 +
    assert_eq!(oxy(&dir, &home, &["commit", "-m", "c"]).0, 0);
268 +
    let d = json(&dir, &home, &["describe", "--json"]);
269 +
    let change = d["change"].as_str().unwrap();
270 +
    assert_eq!(d["distance"], 2);
271 +
    assert_eq!(d["describe"], format!("v0.1.0-2-{}", &change[..8]));
272 +
}