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