OXYGENHUB

Pack merging above 16 packs; modern CLI presentation (colour, spinners, --plain, NO_COLOR); oxy prompt with manifest-based dirty check and docs/prompt.md 135212de5370

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-26 22:18 UTC
Signature
✔ signed by Andy McCall
Hash
135212de5370ab32bf3501985a41667bb087f10dec75ffea2c37fea92be69542
Depends on
252e8cb23fbc 2b5ee371060e 5b1114a04905 614541c3cebd 750251b21a96 79ab8528ff62 83e0677b07fe 96f7610fa666 982864068a59 ba160f60219e c07e0e7d2d85 c3069537d7c5 d02e545dc265 e993a086c13f fce44ae9ad29 ff0af1ee299d
Depended on by
232aabb5452d 569802ebae0e 642da2636de2 9bd98cfb4f7e d36ba2f79f95 767ac6cd0545 e59512f2f075 b689779e9e74 530a0fc650a2 cb8b8082fa73 7505194116e7 f6dccc4533c8 80677b7a3513 648189388f1a 58dfe0e134eb 9ae4d15a0e4b 2cd40ea1c04c f874dc8659c2 8d80356371c5 b35df36b7dc5 94cdbc576ce1 236f35c0baba 0389c3e82f6c e96a430c78f3 33472ccc08cc e226a6cc2f71 1ec3f008e96a 3604179679eb c07406bcc237 98a1620a3840 2bf22305bc48 69692f14b80f d4dbab97e948 638fb21420d7 f9466021286a 4cce912a0953 1966f5db15ee 76740263089c d0990c2bff74 da652d6392d8 8b276e0106d1 69816d688300 0554aaa7ddef eaf3e9068180 d3cd7ef95da6 da868d5d2646 86e1824f2d0b dfb23a3a8cfa fc1a466a1e66 1c2bd9abdd4f cc7a00565161 0bdc3f91a39f d42369abf180 37c299cec358 d82e44097323 75167672ebcd c6aa5bd9d1c8 957fee30826e 4dee277c327a 7b9809e67c3d b3c5a59a875a 9ac1fbe211b4 694805cca585 50a046e439e9 b7f102fcbb17 30ea9c1e70fa 6e723b81f5b9 6706bdcfb63e 94084087a876 28fc23d7706a 6d3eefbce2f0 cdb5beba7f05 2863456a0f6d 7421309f5372 13eddcc46544 7a716eb94dee
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
92 atoms, 3 tree operations, 18 files

Browse the tree at this change

added crates/oxy-core/src/repo/prompt.rs +207 −0
1 +
//! Fast, side-effect-free status for shell prompts.
2 +
//!
3 +
//! Every snapshot and checkout writes a manifest of the working copy as it
4 +
//! was last seen: `size`, `mtime`, and BLAKE3 of each file. `quick_status`
5 +
//! walks the working copy and compares against that manifest, reading a
6 +
//! file's content only when its size or mtime changed, and records no
7 +
//! operation. Ahead/behind counts come from walking the store between the
8 +
//! local bookmark and its `origin` tracking state.
9 +
10 +
use super::*;
11 +
use std::time::UNIX_EPOCH;
12 +
13 +
#[derive(Debug, Clone, Default, PartialEq, Eq)]
14 +
pub struct PromptInfo {
15 +
    pub bookmark: String,
16 +
    /// Working-copy ChangeId prefix.
17 +
    pub change_id: String,
18 +
    pub heads: usize,
19 +
    pub dirty: bool,
20 +
    pub conflicted: bool,
21 +
    /// Changes local `main` has that `origin/main` lacks, when tracking exists.
22 +
    pub ahead: Option<usize>,
23 +
    pub behind: Option<usize>,
24 +
}
25 +
26 +
impl PromptInfo {
27 +
    /// Expand `%b` bookmark, `%c` change id, `%h` heads, `%d` dirty marker
28 +
    /// (`*`), `%x` conflict marker (`!`), `%a`/`%B` ahead/behind counts
29 +
    /// (empty when unknown), `%A`/`%D` `+n`/`-n` when non-zero, `%%` percent.
30 +
    pub fn format(&self, fmt: &str) -> String {
31 +
        let mut out = String::new();
32 +
        let mut chars = fmt.chars();
33 +
        while let Some(c) = chars.next() {
34 +
            if c != '%' {
35 +
                out.push(c);
36 +
                continue;
37 +
            }
38 +
            match chars.next() {
39 +
                Some('b') => out.push_str(&self.bookmark),
40 +
                Some('c') => out.push_str(&self.change_id),
41 +
                Some('h') => out.push_str(&self.heads.to_string()),
42 +
                Some('d') => {
43 +
                    if self.dirty {
44 +
                        out.push('*');
45 +
                    }
46 +
                }
47 +
                Some('x') => {
48 +
                    if self.conflicted {
49 +
                        out.push('!');
50 +
                    }
51 +
                }
52 +
                Some('a') => out.push_str(&self.ahead.map(|n| n.to_string()).unwrap_or_default()),
53 +
                Some('B') => out.push_str(&self.behind.map(|n| n.to_string()).unwrap_or_default()),
54 +
                Some('A') => {
55 +
                    if let Some(n) = self.ahead.filter(|n| *n > 0) {
56 +
                        out.push_str(&format!("+{n}"));
57 +
                    }
58 +
                }
59 +
                Some('D') => {
60 +
                    if let Some(n) = self.behind.filter(|n| *n > 0) {
61 +
                        out.push_str(&format!("-{n}"));
62 +
                    }
63 +
                }
64 +
                Some('%') => out.push('%'),
65 +
                Some(other) => {
66 +
                    out.push('%');
67 +
                    out.push(other);
68 +
                }
69 +
                None => out.push('%'),
70 +
            }
71 +
        }
72 +
        out
73 +
    }
74 +
}
75 +
76 +
/// Path → (size, mtime in nanoseconds, BLAKE3 hex) as last seen.
77 +
type Manifest = BTreeMap<String, (u64, u128, String)>;
78 +
79 +
pub const DEFAULT_PROMPT_FORMAT: &str = "%b %c%d%x%A%D";
80 +
81 +
/// One manifest line per file: size, mtime in nanoseconds, hash, path.
82 +
fn manifest_line(path: &str, size: u64, mtime_ns: u128, hash: &blake3::Hash) -> String {
83 +
    format!("{size}\t{mtime_ns}\t{}\t{path}\n", hash.to_hex())
84 +
}
85 +
86 +
impl Repo {
87 +
    fn manifest_path(&self) -> PathBuf {
88 +
        self.oxy.join("workspaces").join(self.workspace.to_hex()).join("manifest")
89 +
    }
90 +
91 +
    /// Record the working copy as it is now (after a snapshot, commit, or
92 +
    /// checkout), with header lines saying whether the base state is
93 +
    /// conflicted and whether the working-copy change holds uncommitted work.
94 +
    pub(crate) fn write_manifest(&self, conflicted: bool, uncommitted: bool) -> Result<()> {
95 +
        let mut text = format!("# conflicted={}\n# uncommitted={}\n", conflicted as u8, uncommitted as u8);
96 +
        for (path, e) in self.walk_working_copy()? {
97 +
            let full = self.root.join(&path);
98 +
            let (size, mtime_ns) = match fs::symlink_metadata(&full) {
99 +
                Ok(m) => (m.len(), m.modified().ok().and_then(|t| t.duration_since(UNIX_EPOCH).ok()).map(|d| d.as_nanos()).unwrap_or(0)),
100 +
                Err(_) => (e.content.len() as u64, 0),
101 +
            };
102 +
            text.push_str(&manifest_line(&path, size, mtime_ns, &blake3::hash(&e.content)));
103 +
        }
104 +
        let p = self.manifest_path();
105 +
        fs::create_dir_all(p.parent().unwrap())?;
106 +
        write_atomic(&p, text.as_bytes())
107 +
    }
108 +
109 +
    /// Forget the manifest after an operation that changed the working-copy
110 +
    /// change without a snapshot (undo, rewrites); the prompt shows blank
111 +
    /// markers until the next snapshot.
112 +
    pub(crate) fn drop_manifest(&self) {
113 +
        let _ = fs::remove_file(self.manifest_path());
114 +
    }
115 +
116 +
    fn read_manifest(&self) -> Result<Option<(bool, bool, Manifest)>> {
117 +
        let text = match fs::read_to_string(self.manifest_path()) {
118 +
            Ok(t) => t,
119 +
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
120 +
            Err(e) => return Err(e.into()),
121 +
        };
122 +
        let mut out = BTreeMap::new();
123 +
        let mut conflicted = false;
124 +
        let mut uncommitted = false;
125 +
        for line in text.lines() {
126 +
            if let Some(v) = line.strip_prefix("# conflicted=") {
127 +
                conflicted = v.trim() == "1";
128 +
                continue;
129 +
            }
130 +
            if let Some(v) = line.strip_prefix("# uncommitted=") {
131 +
                uncommitted = v.trim() == "1";
132 +
                continue;
133 +
            }
134 +
            let f: Vec<&str> = line.splitn(4, '\t').collect();
135 +
            if f.len() == 4 {
136 +
                out.insert(f[3].to_string(), (f[0].parse().unwrap_or(0), f[1].parse().unwrap_or(0), f[2].to_string()));
137 +
            }
138 +
        }
139 +
        Ok(Some((conflicted, uncommitted, out)))
140 +
    }
141 +
142 +
    /// Whether the working copy differs from the last manifest. Without a
143 +
    /// manifest the answer is `None`.
144 +
    pub fn quick_dirty(&self) -> Result<Option<bool>> {
145 +
        let Some((_, uncommitted, manifest)) = self.read_manifest()? else { return Ok(None) };
146 +
        if uncommitted {
147 +
            return Ok(Some(true));
148 +
        }
149 +
        let mut seen = 0usize;
150 +
        let mut wb = ignore::WalkBuilder::new(&self.root);
151 +
        wb.hidden(false).git_ignore(false).git_global(false).git_exclude(false).require_git(false).follow_links(false).add_custom_ignore_filename(".oxyignore");
152 +
        wb.filter_entry(|e| e.file_name() != OXY_DIR);
153 +
        for entry in wb.build() {
154 +
            let Ok(entry) = entry else { continue };
155 +
            let Some(ft) = entry.file_type() else { continue };
156 +
            if ft.is_dir() {
157 +
                continue;
158 +
            }
159 +
            let rel: String = entry.path().strip_prefix(&self.root).unwrap().components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::<Vec<_>>().join("/");
160 +
            let Some((size, mtime_ns, hash)) = manifest.get(&rel) else { return Ok(Some(true)) };
161 +
            seen += 1;
162 +
            let m = fs::symlink_metadata(entry.path())?;
163 +
            let now_mtime = m.modified().ok().and_then(|t| t.duration_since(UNIX_EPOCH).ok()).map(|d| d.as_nanos()).unwrap_or(0);
164 +
            if ft.is_symlink() {
165 +
                let target = fs::read_link(entry.path())?;
166 +
                if blake3::hash(target.to_string_lossy().as_bytes()).to_hex().as_str() != hash {
167 +
                    return Ok(Some(true));
168 +
                }
169 +
                continue;
170 +
            }
171 +
            if m.len() != *size {
172 +
                return Ok(Some(true));
173 +
            }
174 +
            if now_mtime != *mtime_ns && blake3::hash(&fs::read(entry.path())?).to_hex().as_str() != hash {
175 +
                return Ok(Some(true));
176 +
            }
177 +
        }
178 +
        Ok(Some(seen != manifest.len()))
179 +
    }
180 +
181 +
    /// Prompt information without recording anything.
182 +
    pub fn quick_status(&self) -> Result<PromptInfo> {
183 +
        let view = self.view()?;
184 +
        let bookmark = self.config.bookmark.clone();
185 +
        let heads = self.heads_of(&view, &bookmark)?;
186 +
        let wc_hash = view.workspaces.get(&self.workspace).copied();
187 +
        let change_id = match wc_hash {
188 +
            Some(h) => self.store.get_change(&h)?.change_id.short(8),
189 +
            None => String::new(),
190 +
        };
191 +
        // Conflictedness was recorded by the last snapshot; without a
192 +
        // manifest (never snapshotted) both flags are unknown and shown clear.
193 +
        let manifest = self.read_manifest()?;
194 +
        let conflicted = manifest.as_ref().map(|(c, _, _)| *c).unwrap_or(false);
195 +
        let dirty = self.quick_dirty()?.unwrap_or(false);
196 +
        let (ahead, behind) = match view.remotes.get("origin").and_then(|b| b.get(&bookmark)) {
197 +
            Some(st) => {
198 +
                let remote_heads = self.store.get_state(st)?.heads;
199 +
                let ahead = self.negotiate(&remote_heads, &heads).map(|v| v.len()).ok();
200 +
                let behind = self.negotiate(&heads, &remote_heads).map(|v| v.len()).ok();
201 +
                (ahead, behind)
202 +
            }
203 +
            None => (None, None),
204 +
        };
205 +
        Ok(PromptInfo { bookmark, change_id, heads: heads.len(), dirty, conflicted, ahead, behind })
206 +
    }
207 +
}
added docs/prompt.md +82 −0
1 +
# Shell prompt integration
2 +
3 +
`oxy prompt` prints one line about the current repository in a few
4 +
milliseconds and records nothing. Outside an Oxygen repository it prints
5 +
nothing and exits 1, so it is safe to call from any prompt.
6 +
7 +
```
8 +
$ oxy prompt
9 +
main 658990aa*+2
10 +
```
11 +
12 +
That is bookmark, working-copy change id, `*` when the working copy has
13 +
uncommitted changes, `!` when the state has conflicts, and `+n`/`-n` for
14 +
changes ahead of and behind `origin`. The default format is `%b %c%d%x%A%D`;
15 +
`--format` takes any string with these placeholders:
16 +
17 +
| Placeholder | Meaning |
18 +
|---|---|
19 +
| `%b` | bookmark |
20 +
| `%c` | working-copy ChangeId prefix |
21 +
| `%h` | number of heads |
22 +
| `%d` | `*` when dirty |
23 +
| `%x` | `!` when the base state has conflicts |
24 +
| `%A` / `%D` | `+n` ahead / `-n` behind origin, empty when zero or unknown |
25 +
| `%a` / `%B` | raw ahead / behind counts |
26 +
| `%%` | a percent sign |
27 +
28 +
Dirtiness is decided from a manifest written at every snapshot and checkout:
29 +
files are compared by size and modification time first and by content hash
30 +
only when those differ, so the cost is one directory walk.
31 +
32 +
## bash and oh-my-bash
33 +
34 +
Add to `~/.bashrc` (or to a custom oh-my-bash theme in
35 +
`~/.oh-my-bash/custom/themes/<name>/<name>.theme.sh`):
36 +
37 +
```bash
38 +
# Oxygen repository info for the prompt; silent outside a repository.
39 +
oxy_prompt_info() {
40 +
  local info
41 +
  info=$(oxy prompt --format '%b %c%d%x%A%D' 2>/dev/null) || return
42 +
  local color='\[\e[36m\]' reset='\[\e[0m\]' warn='\[\e[33m\]'
43 +
  case "$info" in
44 +
    *'*'*|*'!'*) printf ' %b(%s)%b' "$warn" "$info" "$reset" ;;
45 +
    *)           printf ' %b(%s)%b' "$color" "$info" "$reset" ;;
46 +
  esac
47 +
}
48 +
```
49 +
50 +
Then reference it from `PS1`, next to your Git info:
51 +
52 +
```bash
53 +
PS1='\u@\h \w$(git_prompt_info 2>/dev/null)$(oxy_prompt_info)\$ '
54 +
```
55 +
56 +
With oh-my-bash, most themes build the prompt in a function such as
57 +
`_omb_theme_PROMPT_COMMAND`; append `$(oxy_prompt_info)` to the `PS1`
58 +
assignment there, or copy the theme into `custom/themes` and edit that copy
59 +
so updates do not overwrite it.
60 +
61 +
## zsh
62 +
63 +
```zsh
64 +
oxy_prompt_info() {
65 +
  local info
66 +
  info=$(oxy prompt 2>/dev/null) || return
67 +
  print -n " %F{cyan}($info)%f"
68 +
}
69 +
setopt PROMPT_SUBST
70 +
PROMPT='%n@%m %~$(oxy_prompt_info) %# '
71 +
```
72 +
73 +
## Notes
74 +
75 +
- The dirty and conflict markers come from the manifest written by the last
76 +
  `oxy status`, commit, or checkout; in a repository that has never been
77 +
  snapshotted they are blank until the first `oxy status`.
78 +
79 +
- The counts against `origin` use the tracking state from the last
80 +
  `oxy fetch`, `pull`, or `push`; the prompt never contacts the network.
81 +
- `oxy prompt` uses plain text by default so shells can colour it; colour is
82 +
  added in the shell function, not by oxy.
added src/cli/ui.rs +100 −0
1 +
//! Terminal presentation: colour, emphasis, and a live spinner while a
2 +
//! command works. Everything degrades to plain text when stdout is not a
3 +
//! terminal, when `NO_COLOR` is set, or with `--plain`.
4 +
5 +
use console::{style, StyledObject, Term};
6 +
use indicatif::{ProgressBar, ProgressStyle};
7 +
use std::sync::atomic::{AtomicBool, Ordering};
8 +
use std::time::Duration;
9 +
10 +
static PLAIN: AtomicBool = AtomicBool::new(false);
11 +
static INIT: std::sync::Once = std::sync::Once::new();
12 +
13 +
/// Decide once whether to use colour and animation.
14 +
pub fn init(plain_flag: bool) {
15 +
    INIT.call_once(|| {
16 +
        let no_color = std::env::var_os("NO_COLOR").is_some();
17 +
        let tty = Term::stdout().features().is_attended();
18 +
        let plain = plain_flag || no_color || !tty;
19 +
        PLAIN.store(plain, Ordering::Relaxed);
20 +
        console::set_colors_enabled(!plain);
21 +
    });
22 +
}
23 +
24 +
pub fn plain() -> bool {
25 +
    PLAIN.load(Ordering::Relaxed)
26 +
}
27 +
28 +
fn s<T: std::fmt::Display>(v: T) -> StyledObject<T> {
29 +
    style(v)
30 +
}
31 +
32 +
/// A ChangeId prefix.
33 +
pub fn id<T: std::fmt::Display>(v: T) -> String {
34 +
    s(v).cyan().bold().to_string()
35 +
}
36 +
/// A hash prefix.
37 +
pub fn hash<T: std::fmt::Display>(v: T) -> String {
38 +
    s(v).dim().to_string()
39 +
}
40 +
pub fn path<T: std::fmt::Display>(v: T) -> String {
41 +
    s(v).bold().to_string()
42 +
}
43 +
pub fn ok<T: std::fmt::Display>(v: T) -> String {
44 +
    s(v).green().to_string()
45 +
}
46 +
pub fn warn<T: std::fmt::Display>(v: T) -> String {
47 +
    s(v).yellow().to_string()
48 +
}
49 +
pub fn err<T: std::fmt::Display>(v: T) -> String {
50 +
    s(v).red().bold().to_string()
51 +
}
52 +
pub fn dim<T: std::fmt::Display>(v: T) -> String {
53 +
    s(v).dim().to_string()
54 +
}
55 +
pub fn heading<T: std::fmt::Display>(v: T) -> String {
56 +
    s(v).bold().underlined().to_string()
57 +
}
58 +
/// A status tag: green for additions, yellow for modifications, red for removals and conflicts.
59 +
pub fn tag(t: &str) -> String {
60 +
    match t {
61 +
        "A" => s(t).green().bold().to_string(),
62 +
        "M" | "X" => s(t).yellow().bold().to_string(),
63 +
        "D" | "C!" => s(t).red().bold().to_string(),
64 +
        "R" => s(t).cyan().bold().to_string(),
65 +
        other => other.to_string(),
66 +
    }
67 +
}
68 +
69 +
/// A live spinner with a message, shown only on a terminal. Drop or call
70 +
/// `done` to clear it; `done` prints the final line in its place.
71 +
pub struct Spinner {
72 +
    bar: Option<ProgressBar>,
73 +
}
74 +
75 +
pub fn spinner(msg: &str) -> Spinner {
76 +
    if plain() {
77 +
        return Spinner { bar: None };
78 +
    }
79 +
    let bar = ProgressBar::new_spinner();
80 +
    bar.set_style(ProgressStyle::with_template("{spinner:.cyan} {msg}").unwrap().tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "✔"]));
81 +
    bar.set_message(msg.to_string());
82 +
    bar.enable_steady_tick(Duration::from_millis(80));
83 +
    Spinner { bar: Some(bar) }
84 +
}
85 +
86 +
impl Spinner {
87 +
    /// Replace the spinner with a final line (or print it plainly).
88 +
    pub fn done(self, msg: &str) {
89 +
        match &self.bar {
90 +
            Some(b) => b.finish_with_message(msg.to_string()),
91 +
            None => println!("{msg}"),
92 +
        }
93 +
    }
94 +
    /// Clear the spinner without printing.
95 +
    pub fn clear(self) {
96 +
        if let Some(b) = &self.bar {
97 +
            b.finish_and_clear();
98 +
        }
99 +
    }
100 +
}
modified Cargo.lock +133 −0
…
278 unchanged lines
279 279
]
280 280
281 281
[[package]]
282 +
name = "bumpalo"
283 +
version = "3.20.3"
284 +
source = "registry+https://github.com/rust-lang/crates.io-index"
285 +
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
286 +
287 +
[[package]]
282 288
name = "bytes"
283 289
version = "1.12.1"
284 290
source = "registry+https://github.com/rust-lang/crates.io-index"
…
70 unchanged lines
355 361
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
356 362
357 363
[[package]]
364 +
name = "console"
365 +
version = "0.15.11"
366 +
source = "registry+https://github.com/rust-lang/crates.io-index"
367 +
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
368 +
dependencies = [
369 +
 "encode_unicode",
370 +
 "libc",
371 +
 "once_cell",
372 +
 "unicode-width",
373 +
 "windows-sys 0.59.0",
374 +
]
375 +
376 +
[[package]]
358 377
name = "const-oid"
359 378
version = "0.10.2"
360 379
source = "registry+https://github.com/rust-lang/crates.io-index"
…
181 unchanged lines
542 561
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
543 562
544 563
[[package]]
564 +
name = "encode_unicode"
565 +
version = "1.0.0"
566 +
source = "registry+https://github.com/rust-lang/crates.io-index"
567 +
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
568 +
569 +
[[package]]
545 570
name = "equivalent"
546 571
version = "1.0.2"
547 572
source = "registry+https://github.com/rust-lang/crates.io-index"
…
397 unchanged lines
945 970
]
946 971
947 972
[[package]]
973 +
name = "indicatif"
974 +
version = "0.17.11"
975 +
source = "registry+https://github.com/rust-lang/crates.io-index"
976 +
checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
977 +
dependencies = [
978 +
 "console",
979 +
 "number_prefix",
980 +
 "portable-atomic",
981 +
 "unicode-width",
982 +
 "web-time",
983 +
]
984 +
985 +
[[package]]
948 986
name = "is_terminal_polyfill"
949 987
version = "1.70.2"
950 988
source = "registry+https://github.com/rust-lang/crates.io-index"
…
16 unchanged lines
967 1005
]
968 1006
969 1007
[[package]]
1008 +
name = "js-sys"
1009 +
version = "0.3.106"
1010 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1011 +
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
1012 +
dependencies = [
1013 +
 "cfg-if",
1014 +
 "futures-util",
1015 +
 "wasm-bindgen",
1016 +
]
1017 +
1018 +
[[package]]
970 1019
name = "keccak"
971 1020
version = "0.2.2"
972 1021
source = "registry+https://github.com/rust-lang/crates.io-index"
…
150 unchanged lines
1123 1172
]
1124 1173
1125 1174
[[package]]
1175 +
name = "number_prefix"
1176 +
version = "0.4.0"
1177 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1178 +
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
1179 +
1180 +
[[package]]
1126 1181
name = "oid-registry"
1127 1182
version = "0.8.1"
1128 1183
source = "registry+https://github.com/rust-lang/crates.io-index"
…
20 unchanged lines
1149 1204
dependencies = [
1150 1205
 "anyhow",
1151 1206
 "clap",
1207 +
 "console",
1208 +
 "indicatif",
1152 1209
 "libc",
1153 1210
 "oxy-core",
1154 1211
 "oxy-server",
…
72 unchanged lines
1227 1284
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
1228 1285
1229 1286
[[package]]
1287 +
name = "portable-atomic"
1288 +
version = "1.15.0"
1289 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1290 +
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
1291 +
1292 +
[[package]]
1230 1293
name = "potential_utf"
1231 1294
version = "0.1.6"
1232 1295
source = "registry+https://github.com/rust-lang/crates.io-index"
…
650 unchanged lines
1883 1946
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
1884 1947
1885 1948
[[package]]
1949 +
name = "unicode-width"
1950 +
version = "0.2.2"
1951 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1952 +
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
1953 +
1954 +
[[package]]
1886 1955
name = "untrusted"
1887 1956
version = "0.9.0"
1888 1957
source = "registry+https://github.com/rust-lang/crates.io-index"
…
56 unchanged lines
1945 2014
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
1946 2015
1947 2016
[[package]]
2017 +
name = "wasm-bindgen"
2018 +
version = "0.2.129"
2019 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2020 +
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
2021 +
dependencies = [
2022 +
 "cfg-if",
2023 +
 "once_cell",
2024 +
 "rustversion",
2025 +
 "wasm-bindgen-macro",
2026 +
 "wasm-bindgen-shared",
2027 +
]
2028 +
2029 +
[[package]]
2030 +
name = "wasm-bindgen-macro"
2031 +
version = "0.2.129"
2032 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2033 +
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
2034 +
dependencies = [
2035 +
 "quote",
2036 +
 "wasm-bindgen-macro-support",
2037 +
]
2038 +
2039 +
[[package]]
2040 +
name = "wasm-bindgen-macro-support"
2041 +
version = "0.2.129"
2042 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2043 +
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
2044 +
dependencies = [
2045 +
 "bumpalo",
2046 +
 "proc-macro2",
2047 +
 "quote",
2048 +
 "syn 3.0.6",
2049 +
 "wasm-bindgen-shared",
2050 +
]
2051 +
2052 +
[[package]]
2053 +
name = "wasm-bindgen-shared"
2054 +
version = "0.2.129"
2055 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2056 +
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
2057 +
dependencies = [
2058 +
 "unicode-ident",
2059 +
]
2060 +
2061 +
[[package]]
2062 +
name = "web-time"
2063 +
version = "1.1.0"
2064 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2065 +
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
2066 +
dependencies = [
2067 +
 "js-sys",
2068 +
 "wasm-bindgen",
2069 +
]
2070 +
2071 +
[[package]]
1948 2072
name = "webpki-roots"
1949 2073
version = "0.26.11"
1950 2074
source = "registry+https://github.com/rust-lang/crates.io-index"
…
37 unchanged lines
1988 2112
1989 2113
[[package]]
1990 2114
name = "windows-sys"
2115 +
version = "0.59.0"
2116 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2117 +
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
2118 +
dependencies = [
2119 +
 "windows-targets",
2120 +
]
2121 +
2122 +
[[package]]
2123 +
name = "windows-sys"
1991 2124
version = "0.61.2"
1992 2125
source = "registry+https://github.com/rust-lang/crates.io-index"
1993 2126
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
…
244 unchanged lines
modified Cargo.toml +2 −0
…
24 unchanged lines
25 25
clap = { version = "4", features = ["derive"] }
26 26
anyhow = "1"
27 27
libc = "0.2"
28 +
console = "0.15"
29 +
indicatif = "0.17"
28 30
29 31
[dev-dependencies]
30 32
tempfile = "3"
modified crates/oxy-core/src/repo/checkout.rs +1 −0
…
87 unchanged lines
88 88
        view.workspaces.insert(self.workspace, wc_hash);
89 89
        self.record_op(&[op_ref], command, view)?;
90 90
        report.conflicted = target.is_conflicted();
91 +
        self.write_manifest(report.conflicted, false)?;
91 92
        Ok(report)
92 93
    }
93 94
…
60 unchanged lines
modified crates/oxy-core/src/repo/gc.rs +4 −1
…
15 unchanged lines
16 16
    pub packed: PackReport,
17 17
    pub pruned: PruneReport,
18 18
    pub reachable: usize,
19 +
    /// Packs folded into one because there were more than `MAX_PACKS`.
20 +
    pub merged_packs: usize,
19 21
}
20 22
21 23
impl Repo {
…
4 unchanged lines
26 28
        let keep = self.reachable()?;
27 29
        let grace = if now { std::time::Duration::ZERO } else { std::time::Duration::from_secs(self.config.gc_grace_days * 86_400) };
28 30
        let pruned = self.store.prune(&keep, grace)?;
29 -
        Ok(GcReport { packed, pruned, reachable: keep.len() })
31 +
        let merged_packs = self.store.merge_packs()?;
32 +
        Ok(GcReport { packed, pruned, reachable: keep.len(), merged_packs })
30 33
    }
31 34
32 35
    /// Every object that must survive garbage collection.
…
88 unchanged lines
modified crates/oxy-core/src/repo/history.rs +2 −0
…
68 unchanged lines
69 69
        view.bookmarks.insert(bookmark, self.store.put_state(&StateObject::new(new_heads))?);
70 70
        view.workspaces.insert(self.workspace, wc_hash);
71 71
        self.record_op(&[op_ref], command, view)?;
72 +
        self.drop_manifest();
72 73
        Ok(new_hash)
73 74
    }
74 75
…
51 unchanged lines
126 127
        view.bookmarks.insert(bookmark, self.store.put_state(&StateObject::new(new_heads))?);
127 128
        view.workspaces.insert(self.workspace, new_wc);
128 129
        self.record_op(&[op_ref], command, view)?;
130 +
        self.drop_manifest();
129 131
        Ok(new_hash)
130 132
    }
131 133
…
79 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +4 −0
…
58 unchanged lines
59 59
mod history;
60 60
mod identity;
61 61
mod policy;
62 +
mod prompt;
62 63
mod snapshot;
63 64
mod sync;
64 65
#[cfg(test)]
…
2 unchanged lines
67 68
pub use checkout::CheckoutReport;
68 69
pub use config::{read_config_file, Config};
69 70
pub use gc::GcReport;
71 +
pub use prompt::{PromptInfo, DEFAULT_PROMPT_FORMAT};
70 72
pub use git::{GitExportReport, GitImportReport};
71 73
pub use identity::SignatureStatus;
72 74
pub use snapshot::{added_node_paths, has_conflict_markers, is_binary, referenced_changes, render_change, ChangeStatus, PathStatus, SnapshotResult};
…
160 unchanged lines
233 235
    pub(crate) fn maybe_autopack(&self) -> Result<()> {
234 236
        if self.store.loose_count()? > AUTOPACK_THRESHOLD {
235 237
            self.store.pack_loose()?;
238 +
            self.store.merge_packs()?;
236 239
        }
237 240
        Ok(())
238 241
    }
…
122 unchanged lines
361 364
        };
362 365
        let parent_op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?;
363 366
        write_atomic(&self.oxy.join("ops").join("head"), parent.to_hex().as_bytes())?;
367 +
        self.drop_manifest();
364 368
        Ok(parent_op)
365 369
    }
366 370
…
219 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +3 −0
…
142 unchanged lines
143 143
            view.workspaces.insert(self.workspace, new_hash);
144 144
            self.record_op(&[op_ref], &format!("{command}{SNAPSHOT_SUFFIX}"), view)?;
145 145
        }
146 +
        // The prompt compares against this without recording anything.
147 +
        self.write_manifest(base_snapshot.is_conflicted(), !wc.tree_ops.is_empty() || !wc.edits.is_empty())?;
146 148
        Ok(SnapshotResult { wc_hash: new_hash, wc, base, base_snapshot, statuses, changed, resolves, contributors })
147 149
    }
148 150
…
64 unchanged lines
213 215
        view.bookmarks.insert(self.config.bookmark.clone(), self.store.put_state(&StateObject::new(new_heads))?);
214 216
        view.workspaces.insert(self.workspace, wc_hash);
215 217
        self.record_op(&[op_ref], &format!("oxy commit -m {message:?}"), view)?;
218 +
        self.write_manifest(snap.base_snapshot.is_conflicted() && !snap.resolves, false)?;
216 219
        self.maybe_autopack()?;
217 220
        Ok(hash)
218 221
    }
…
376 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +46 −0
…
1112 unchanged lines
1113 1113
    assert!(repo.latest_checkpoint("main").unwrap().is_some());
1114 1114
    assert!(repo.load_state_checkpointed(&repo.bookmark_heads().unwrap()).unwrap().is_some());
1115 1115
}
1116 +
1117 +
#[test]
1118 +
fn quick_status_for_prompts() {
1119 +
    hermetic();
1120 +
    let dir = tempfile::tempdir().unwrap();
1121 +
    let root = dir.path();
1122 +
    let origin = Repo::init(&root.join("origin")).unwrap();
1123 +
    let url = root.join("origin").to_string_lossy().to_string();
1124 +
    let a = Repo::clone(&url, &root.join("a")).unwrap().with_home(root.join("home"));
1125 +
    let ops_before = a.op_log().unwrap().len();
1126 +
    let info = a.quick_status().unwrap();
1127 +
    assert_eq!((info.bookmark.as_str(), info.heads, info.dirty, info.conflicted, info.ahead, info.behind), ("main", 0, false, false, Some(0), Some(0)));
1128 +
    write(&root.join("a"), "f.txt", b"f\n");
1129 +
    assert!(a.quick_status().unwrap().dirty, "a new file is dirty");
1130 +
    assert_eq!(a.op_log().unwrap().len(), ops_before, "quick_status records nothing");
1131 +
    // A snapshotted but uncommitted change is still dirty.
1132 +
    a.snapshot("status").unwrap();
1133 +
    assert!(a.quick_status().unwrap().dirty);
1134 +
    a.commit("f", &[]).unwrap();
1135 +
    let info = a.quick_status().unwrap();
1136 +
    assert!(!info.dirty, "clean right after a commit");
1137 +
    assert_eq!((info.ahead, info.behind), (Some(1), Some(0)));
1138 +
    assert_eq!(info.format("%b %c%d%x%A%D"), format!("main {}+1", info.change_id));
1139 +
    assert_eq!(info.format("[%h heads] %a/%B %%"), "[1 heads] 1/0 %");
1140 +
    // Touching a file without changing it is not dirty; changing content of
1141 +
    // the same size is.
1142 +
    let p = root.join("a/f.txt");
1143 +
    let content = fs::read(&p).unwrap();
1144 +
    fs::write(&p, &content).unwrap();
1145 +
    assert!(!a.quick_status().unwrap().dirty);
1146 +
    fs::write(&p, b"g\n").unwrap();
1147 +
    assert!(a.quick_status().unwrap().dirty);
1148 +
    fs::write(&p, b"f\n").unwrap();
1149 +
    // Push, then a second clone commits and pushes: we are behind.
1150 +
    assert!(a.push("origin", false).unwrap()[0].ok);
1151 +
    assert_eq!(a.quick_status().unwrap().ahead, Some(0));
1152 +
    let b = Repo::clone(&url, &root.join("b")).unwrap().with_home(root.join("home-b"));
1153 +
    write(&root.join("b"), "g.txt", b"g\n");
1154 +
    b.commit("g", &[]).unwrap();
1155 +
    b.push("origin", false).unwrap();
1156 +
    a.fetch("origin").unwrap();
1157 +
    let info = a.quick_status().unwrap();
1158 +
    assert_eq!((info.ahead, info.behind), (Some(0), Some(1)));
1159 +
    assert_eq!(info.format("%A%D"), "-1");
1160 +
    let _ = origin;
1161 +
}
modified crates/oxy-core/src/store.rs +37 −0
…
106 unchanged lines
107 107
    pub packed_bytes: u64,
108 108
}
109 109
110 +
/// Packs tolerated before `merge_packs` folds them into one (spec §13.4).
111 +
pub const MAX_PACKS: usize = 16;
112 +
110 113
/// What `prune` did.
111 114
#[derive(Debug, Clone, Default, PartialEq, Eq)]
112 115
pub struct PruneReport {
…
92 unchanged lines
205 208
        self.write_pack(&loose.iter().map(|(r, _)| *r).collect::<Vec<_>>(), &loose.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>(), &[])
206 209
    }
207 210
211 +
    /// Fold every pack into one when there are more than `MAX_PACKS`
212 +
    /// (spec §13.4). Returns how many packs were merged.
213 +
    pub fn merge_packs(&self) -> Result<usize> {
214 +
        let (refs, paths): (Vec<ObjectRef>, Vec<PathBuf>) = {
215 +
            let packs = self.packs.borrow();
216 +
            if packs.len() <= MAX_PACKS {
217 +
                return Ok(0);
218 +
            }
219 +
            (packs.iter().flat_map(|p| p.refs.iter().copied()).collect(), packs.iter().map(|p| p.path.clone()).collect())
220 +
        };
221 +
        let n = paths.len();
222 +
        self.write_pack(&refs, &[], &paths)?;
223 +
        Ok(n)
224 +
    }
225 +
208 226
    /// Delete every object not in `keep` (spec §21). Loose objects and packs
209 227
    /// younger than `grace` are left alone, since a concurrent writer may not
210 228
    /// have recorded its references yet. Packs holding unreachable objects
…
408 unchanged lines
619 637
    }
620 638
621 639
    #[test]
640 +
    fn many_packs_merge_into_one() {
641 +
        let dir = tempfile::tempdir().unwrap();
642 +
        let s = Store::open(dir.path()).unwrap();
643 +
        let mut refs = Vec::new();
644 +
        for i in 0..(MAX_PACKS + 2) {
645 +
            refs.push(s.put(ObjectType::Chunk, format!("pack {i}").as_bytes()).unwrap());
646 +
            s.pack_loose().unwrap();
647 +
        }
648 +
        assert_eq!(s.pack_count(), MAX_PACKS + 2);
649 +
        assert_eq!(s.merge_packs().unwrap(), MAX_PACKS + 2);
650 +
        assert_eq!(s.pack_count(), 1);
651 +
        assert_eq!(s.merge_packs().unwrap(), 0);
652 +
        for (i, r) in refs.iter().enumerate() {
653 +
            assert_eq!(s.get(r).unwrap().1, format!("pack {i}").as_bytes());
654 +
        }
655 +
        assert_eq!(Store::open(dir.path()).unwrap().pack_count(), 1);
656 +
    }
657 +
658 +
    #[test]
622 659
    fn prune_removes_unreachable_loose_and_packed_objects() {
623 660
        use std::collections::HashSet;
624 661
        let dir = tempfile::tempdir().unwrap();
…
55 unchanged lines
modified docs/plan.md +3 −0
…
13 unchanged lines
14 14
15 15
- [ ] Checkpoints (spec §11): serialize a `State` to a checkpoint object, load the nearest checkpoint in `load_state` and replay only the difference, create one automatically every `core.checkpoint-interval` changes (default 256) and on demand (`oxy checkpoint`). Benchmark before/after on a 5,000-change synthetic repository and record the numbers here.
16 16
- [ ] Pack merging (spec §13.4): merge packs when more than 16 exist; `oxy gc` reports it.
17 +
- [ ] Modern CLI presentation (user request, 2026-09-26 23:20): colour and bold for ids, paths, and statuses; a live spinner or updating status line while a command works (snapshot, fetch, push, import, gc); indented, grouped output like Claude Code's. Use `console`/`indicatif` or equivalent; detect a TTY and honour `NO_COLOR` and `--no-color`/`--plain` so pipes and scripts get plain text; keep `--json` where it exists. Touch every command's output for consistency.
18 +
- [ ] Shell prompt integration (user request, 2026-09-26 23:30): `oxy prompt` prints a compact one-line summary for shell prompts in a few milliseconds and with no side effects (no snapshot operation recorded, no locks): bookmark, working-copy ChangeId prefix, head count, a dirty marker (compare the file system against the working copy cheaply: sizes and mtimes first, content only when they differ), conflict marker, and ahead/behind counts against `remotes/origin/<bookmark>` when known. Provide `docs/prompt.md` with an oh-my-bash/bash snippet (`oxy_prompt_info` function, silent outside an oxy repository, colours via `--plain` off) and a zsh variant. Format flags: `--format` with placeholders, `--plain`.
19 +
- [ ] (Pending) The user's second request in the same message was cut off after "Can we also look at"; ask them to complete it in the morning.
17 20
- [ ] Lazy blobs and the `objects` endpoint (spec §13.5, §16.1, §17): `clone --max-blob SIZE`, absent blobs recorded with their remote, fetched on first read; server endpoint; wire encoding in spec §16.3.
18 21
- [ ] Sparse checkout (spec §17): `filter.paths` in config, `clone --sparse PATH..`, status/commit/checkout honour it.
19 22
- [ ] Shallow clone (spec §17): `clone --depth N`, shallow boundary with a checkpoint, `oxy deepen`.
…
15 unchanged lines
modified src/cli/identity.rs +9 −3
…
1 unchanged lines
2 2
3 3
use super::format::{short_id, signature_line, when};
4 4
use super::open;
5 +
use super::ui;
5 6
use anyhow::Result;
6 7
use oxy_core::hash::ChangeHash;
7 8
use oxy_core::identity::Identity;
…
44 unchanged lines
52 53
    for h in hashes {
53 54
        let c = repo.store().get_change(&h)?;
54 55
        let line = signature_line(&repo, &h)?;
55 -
        if line.starts_with("INVALID") {
56 +
        let styled = if line.starts_with("INVALID") {
56 57
            bad += 1;
57 -
        }
58 -
        println!("{} #{}  {line}", short_id(&c), h.short(12));
58 +
            ui::err(&line)
59 +
        } else if line == "unsigned" {
60 +
            ui::dim(&line)
61 +
        } else {
62 +
            ui::ok(&line)
63 +
        };
64 +
        println!("{} {}  {styled}", ui::id(short_id(&c)), ui::hash(format!("#{}", h.short(12))));
59 65
    }
60 66
    Ok(if bad > 0 { 1 } else { 0 })
61 67
}
…
87 unchanged lines
modified src/cli/mod.rs +12 −0
…
2 unchanged lines
3 3
mod format;
4 4
mod identity;
5 5
mod sync;
6 +
pub mod ui;
6 7
mod workspace;
7 8
8 9
use anyhow::{Context, Result};
…
4 unchanged lines
13 14
#[derive(Parser)]
14 15
#[command(name = "oxy", version, about = "Oxygen: breathing fresh air into version control")]
15 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,
16 20
    #[command(subcommand)]
17 21
    cmd: Cmd,
18 22
}
…
6 unchanged lines
25 29
    Clone { url: String, dest: Option<PathBuf> },
26 30
    /// Show the working copy: changed paths and conflicts
27 31
    Status,
32 +
    /// One-line summary for shell prompts; fast and records nothing
33 +
    Prompt {
34 +
        /// Placeholders: %b bookmark, %c change id, %h heads, %d dirty (*), %x conflict (!), %A/%D ahead/behind (+n/-n), %a/%B raw counts
35 +
        #[arg(long, default_value = oxy_core::repo::DEFAULT_PROMPT_FORMAT)]
36 +
        format: String,
37 +
    },
28 38
    /// Record the working copy as a change on the current bookmark
29 39
    Commit {
30 40
        #[arg(short, long)]
…
174 unchanged lines
205 215
/// Parse arguments and run; returns the process exit code.
206 216
pub fn run() -> Result<i32> {
207 217
    let cli = Cli::parse();
218 +
    ui::init(cli.plain);
208 219
    match cli.cmd {
209 220
        Cmd::Init { path } => workspace::init(path),
210 221
        Cmd::Status => workspace::status(),
222 +
        Cmd::Prompt { format } => workspace::prompt(&format),
211 223
        Cmd::Commit { message, depends_on } => workspace::commit(&message, &depends_on),
212 224
        Cmd::Log => workspace::log(),
213 225
        Cmd::Diff => workspace::diff(),
…
37 unchanged lines
modified src/cli/sync.rs +25 −12
…
1 unchanged lines
2 2
3 3
use super::format::plural;
4 4
use super::open;
5 +
use super::ui;
5 6
use anyhow::{Context, Result};
6 7
use oxy_core::repo::Repo;
7 8
use std::path::PathBuf;
…
3 unchanged lines
11 12
        let base = url.trim_end_matches('/').rsplit('/').next().unwrap_or("repo");
12 13
        PathBuf::from(base)
13 14
    });
15 +
    let sp = ui::spinner(&format!("Cloning {url}…"));
14 16
    let repo = Repo::clone(url, &dest)?;
15 17
    let heads = repo.bookmark_heads()?;
16 -
    println!("Cloned into {} ({url} on {}: {})", repo.root().display(), repo.config.bookmark, plural(heads.len(), "head"));
18 +
    sp.done(&format!("{} into {} ({} on {}: {})", ui::ok("Cloned"), ui::path(repo.root().display()), url, ui::id(&repo.config.bookmark), plural(heads.len(), "head")));
17 19
    Ok(0)
18 20
}
19 21
…
35 unchanged lines
55 57
pub fn fetch(remote: Option<&str>) -> Result<i32> {
56 58
    let repo = open()?;
57 59
    let remote = remote_name(remote);
60 +
    let sp = ui::spinner(&format!("Fetching from {remote}…"));
58 61
    let report = repo.fetch(&remote)?;
62 +
    sp.clear();
59 63
    for (name, heads, n) in &report.refs {
60 -
        println!("{remote}/{name}: {}, {}", plural(heads.len(), "head"), plural(*n, "new change"));
64 +
        println!("{} {}, {}", ui::id(format!("{remote}/{name}")), plural(heads.len(), "head"), plural(*n, "new change"));
61 65
    }
62 66
    if report.unverified {
63 -
        eprintln!("warning: {remote} has no trust policy; nothing fetched from it was verified");
67 +
        eprintln!("{} {remote} has no trust policy; nothing fetched from it was verified", ui::warn("warning:"));
64 68
    }
65 69
    Ok(0)
66 70
}
…
1 unchanged lines
68 72
pub fn pull(remote: Option<&str>) -> Result<i32> {
69 73
    let repo = open()?;
70 74
    let remote = remote_name(remote);
75 +
    let sp = ui::spinner(&format!("Pulling from {remote}…"));
71 76
    let r = repo.pull(&remote)?;
72 77
    if !r.changed {
73 -
        println!("Already up to date.");
78 +
        sp.done(&ui::dim("Already up to date."));
74 79
        return Ok(0);
75 80
    }
76 -
    println!(
77 -
        "{} now has {}{}",
78 -
        repo.config.bookmark,
81 +
    sp.done(&format!(
82 +
        "{} {} now has {}{}",
83 +
        ui::ok("Pulled:"),
84 +
        ui::id(&repo.config.bookmark),
79 85
        plural(r.heads.len(), "head"),
80 86
        if r.checked_out { "; working copy updated" } else { "; run `oxy checkout` to update the working copy" }
81 -
    );
87 +
    ));
82 88
    let snap = repo.snapshot("oxy pull")?;
83 89
    if snap.base_snapshot.is_conflicted() {
84 -
        println!("The result has conflicts; see oxy status.");
90 +
        println!("  {}", ui::warn("the result has conflicts; see oxy status"));
85 91
    }
86 92
    Ok(0)
87 93
}
…
1 unchanged lines
89 95
pub fn push(remote: Option<&str>, force: bool) -> Result<i32> {
90 96
    let repo = open()?;
91 97
    let remote = remote_name(remote);
98 +
    let sp = ui::spinner(&format!("Pushing to {remote}…"));
99 +
    let results = repo.push(&remote, force)?;
100 +
    sp.clear();
92 101
    let mut code = 0;
93 -
    for r in repo.push(&remote, force)? {
102 +
    for r in results {
94 103
        if r.ok {
95 -
            println!("{remote}/{}: {}", r.name, if r.reason.is_empty() { "updated" } else { r.reason.as_str() });
104 +
            println!("{} {}", ui::id(format!("{remote}/{}", r.name)), ui::ok(if r.reason.is_empty() { "updated" } else { r.reason.as_str() }));
96 105
        } else {
97 -
            println!("{remote}/{}: rejected: {}", r.name, r.reason);
106 +
            println!("{} {} {}", ui::id(format!("{remote}/{}", r.name)), ui::err("rejected:"), r.reason);
98 107
            code = 1;
99 108
        }
100 109
    }
…
19 unchanged lines
120 129
121 130
pub fn git_export(git_dir: &std::path::Path) -> Result<i32> {
122 131
    let repo = open()?;
132 +
    let sp = ui::spinner("Exporting to Git…");
123 133
    let r = repo.git_export(git_dir)?;
134 +
    sp.clear();
124 135
    println!(
125 136
        "Exported {} to {} (branch {}: {}{}); {} already exported.",
126 137
        plural(r.exported, "change"),
…
9 unchanged lines
136 147
pub fn git_import(git_dir: &std::path::Path, dest: &std::path::Path, branch: &str) -> Result<i32> {
137 148
    let repo = Repo::init(dest)?;
138 149
    let started = std::time::Instant::now();
150 +
    let sp = ui::spinner(&format!("Importing {} {branch}…", git_dir.display()));
139 151
    let r = repo.import_git(git_dir, branch)?;
152 +
    sp.clear();
140 153
    println!(
141 154
        "Imported {} ({} merges) from {} {branch} into {} in {:.1?}: {} now has {}.{}",
142 155
        plural(r.commits, "commit"),
…
10 unchanged lines
modified src/cli/workspace.rs +52 −28
…
1 unchanged lines
2 2
3 3
use super::format::{deps_line, plural, short_id, signature_line, when};
4 4
use super::open;
5 +
use super::ui;
5 6
use anyhow::Result;
6 7
use oxy_core::repo::{render_change, ChangeStatus, Repo};
7 8
use std::path::PathBuf;
…
1 unchanged lines
9 10
pub fn init(path: Option<PathBuf>) -> Result<i32> {
10 11
    let path = path.unwrap_or(std::env::current_dir()?);
11 12
    let repo = Repo::init(&path)?;
12 -
    println!("Initialized oxy repository in {}", repo.root().display());
13 -
    println!("Bookmark: {}   Author: {} <{}>", repo.config.bookmark, repo.config.author_name, repo.config.author_email);
13 +
    println!("{} oxy repository in {}", ui::ok("Initialized"), ui::path(repo.root().display()));
14 +
    println!("  bookmark {}   author {} <{}>", ui::id(&repo.config.bookmark), repo.config.author_name, repo.config.author_email);
14 15
    Ok(0)
15 16
}
16 17
…
10 unchanged lines
27 28
28 29
pub fn status() -> Result<i32> {
29 30
    let repo = open()?;
31 +
    let sp = ui::spinner("Snapshotting the working copy…");
30 32
    let snap = repo.snapshot("oxy status")?;
31 33
    let heads = repo.bookmark_heads()?;
32 -
    println!("Working copy: {} on {} ({})", snap.wc.change_id.short(8), repo.config.bookmark, plural(heads.len(), "head"));
34 +
    sp.clear();
35 +
    println!(
36 +
        "{} {} on {} {}",
37 +
        ui::heading("Working copy"),
38 +
        ui::id(snap.wc.change_id.short(8)),
39 +
        ui::id(&repo.config.bookmark),
40 +
        ui::dim(format!("({})", plural(heads.len(), "head")))
41 +
    );
33 42
    if snap.statuses.is_empty() {
34 -
        println!("The working copy is clean.");
43 +
        println!("  {}", ui::dim("clean"));
35 44
    }
36 45
    for s in &snap.statuses {
37 -
        println!("{:2} {}", status_tag(s.status), s.path);
46 +
        println!("  {:2} {}", ui::tag(status_tag(s.status)), ui::path(&s.path));
38 47
    }
39 48
    if !snap.base_snapshot.is_conflicted() {
40 49
        return Ok(0);
41 50
    }
42 -
    println!("\nConflicts in the base state (edit the marked files, or the .oxy-conflict-N candidates, then commit):");
51 +
    println!();
52 +
    println!("{} {}", ui::err("Conflicts"), ui::dim("edit the marked files, or the .oxy-conflict-N candidates, then commit"));
43 53
    for c in &snap.base_snapshot.tree.conflicts {
44 54
        println!("  tree: {c:?}");
45 55
    }
46 56
    for (p, f) in &snap.base_snapshot.files {
47 57
        if f.is_conflicted() {
48 58
            let state = match snap.statuses.iter().find(|s| s.path == *p).map(|s| s.status) {
49 -
                Some(ChangeStatus::Resolved) => "resolved in working copy",
50 -
                Some(ChangeStatus::ConflictEdited) => "markers remain",
51 -
                _ => "unresolved",
59 +
                Some(ChangeStatus::Resolved) => ui::ok("resolved in working copy"),
60 +
                Some(ChangeStatus::ConflictEdited) => ui::warn("markers remain"),
61 +
                _ => ui::err("unresolved"),
52 62
            };
53 -
            println!("  {p}  ({state})");
63 +
            println!("  {}  {state}", ui::path(p));
54 64
        }
55 65
    }
56 66
    Ok(2)
…
5 unchanged lines
62 72
    for d in depends_on {
63 73
        deps.push(repo.resolve_change(d)?);
64 74
    }
75 +
    let sp = ui::spinner("Committing…");
65 76
    let h = repo.commit(message, &deps)?;
66 77
    let c = repo.store().get_change(&h)?;
67 -
    println!("Committed {} #{}: {}", short_id(&c), h.short(12), message.lines().next().unwrap_or(""));
78 +
    sp.done(&format!("{} {} {} {}", ui::ok("Committed"), ui::id(short_id(&c)), ui::hash(format!("#{}", h.short(12))), message.lines().next().unwrap_or("")));
68 79
    Ok(0)
69 80
}
70 81
…
9 unchanged lines
80 91
        if e.is_head {
81 92
            flags.push("head");
82 93
        }
83 -
        let flags = if flags.is_empty() { String::new() } else { format!(" ({})", flags.join(", ")) };
84 -
        println!("{} #{}{}", short_id(c), e.hash.short(12), flags);
85 -
        println!("    {}", when(c.hlc.wall_ms));
94 +
        let flags = if flags.is_empty() { String::new() } else { ui::warn(format!(" ({})", flags.join(", "))) };
95 +
        println!("{} {}{}", ui::id(short_id(c)), ui::hash(format!("#{}", e.hash.short(12))), flags);
96 +
        println!("    {}", ui::dim(when(c.hlc.wall_ms)));
86 97
        if !c.deps.is_empty() {
87 -
            println!("    deps: {}", deps_line(c));
98 +
            println!("    {}", ui::dim(format!("deps: {}", deps_line(c))));
88 99
        }
89 100
        let msg = if c.message.is_empty() { "(no description)" } else { &c.message };
90 101
        for line in msg.lines() {
…
16 unchanged lines
107 118
    repo.snapshot("oxy show")?;
108 119
    let h = repo.resolve_change(change)?;
109 120
    let c = repo.store().get_change(&h)?;
110 -
    println!("change {} #{}", short_id(&c), h);
111 -
    println!("kind:   {:?}", c.kind);
112 -
    println!("date:   {}", when(c.hlc.wall_ms));
113 -
    println!("deps:   {}", deps_line(&c));
114 -
    println!("sig:    {}", signature_line(&repo, &h)?);
121 +
    println!("{} {} {}", ui::heading("change"), ui::id(short_id(&c)), ui::hash(format!("#{h}")));
122 +
    println!("  kind  {:?}", c.kind);
123 +
    println!("  date  {}", when(c.hlc.wall_ms));
124 +
    println!("  deps  {}", ui::dim(deps_line(&c)));
125 +
    println!("  sig   {}", signature_line(&repo, &h)?);
115 126
    println!("\n{}\n", if c.message.is_empty() { "(no description)" } else { &c.message });
116 127
    print!("{}", repo.describe_change(&h)?);
117 128
    Ok(0)
…
7 unchanged lines
125 136
        changes.iter().map(|c| repo.resolve_change(c)).collect::<Result<Vec<_>, _>>()?
126 137
    };
127 138
    let spec = if changes.is_empty() { repo.config.bookmark.clone() } else { changes.join(" ") };
139 +
    let sp = ui::spinner(&format!("Checking out {spec}…"));
128 140
    let r = repo.checkout(&heads, discard, &format!("oxy checkout {spec}"))?;
129 -
    println!("Working copy now on {spec}: {} written, {} removed", plural(r.written, "file"), r.removed);
141 +
    sp.done(&format!("{} on {}: {} written, {} removed", ui::ok("Working copy"), ui::id(&spec), plural(r.written, "file"), r.removed));
130 142
    if r.conflicted {
131 -
        println!("This state has conflicts; see oxy status.");
143 +
        println!("  {}", ui::warn("this state has conflicts; see oxy status"));
132 144
    }
133 145
    Ok(0)
134 146
}
…
37 unchanged lines
172 184
pub fn undo() -> Result<i32> {
173 185
    let repo = open()?;
174 186
    let op = repo.undo()?;
175 -
    println!("Undid the last operation; now at: {}", op.command);
187 +
    println!("{} now at: {}", ui::ok("Undone;"), ui::dim(&op.command));
176 188
    Ok(0)
177 189
}
178 190
179 191
pub fn op_log() -> Result<i32> {
180 192
    let repo = open()?;
181 193
    for (i, (r, op)) in repo.op_log()?.into_iter().enumerate() {
182 -
        println!("{}{} {}  {}", if i == 0 { "@ " } else { "  " }, r.short(12), when(op.hlc.wall_ms), op.command);
194 +
        println!("{}{} {}  {}", if i == 0 { ui::ok("@ ") } else { "  ".into() }, ui::hash(r.short(12)), ui::dim(when(op.hlc.wall_ms)), op.command);
183 195
    }
184 196
    Ok(0)
185 197
}
186 198
187 199
pub fn gc(now: bool) -> Result<i32> {
188 200
    let repo = open()?;
201 +
    let sp = ui::spinner("Packing and pruning…");
189 202
    let r = repo.gc(now)?;
203 +
    sp.clear();
190 204
    if r.packed.objects > 0 {
191 205
        println!(
192 206
            "Packed {}: {} bytes -> {} bytes ({:.0}%).",
…
3 unchanged lines
196 210
            100.0 * r.packed.packed_bytes as f64 / r.packed.raw_bytes.max(1) as f64
197 211
        );
198 212
    }
199 -
    println!("{} reachable; pruned {} ({} bytes){}; {} present.",
213 +
    println!("{} reachable; pruned {} ({} bytes){}{}; {} present.",
200 214
        plural(r.reachable, "object"),
201 215
        plural(r.pruned.objects, "unreachable object"),
202 216
        r.pruned.bytes,
203 217
        if r.pruned.deferred > 0 { format!("; {} deferred by the grace period (use --now)", plural(r.pruned.deferred, "object")) } else { String::new() },
218 +
        if r.merged_packs > 0 { format!("; merged {} into one", plural(r.merged_packs, "pack")) } else { String::new() },
204 219
        plural(repo.store().pack_count(), "pack")
205 220
    );
206 221
    Ok(0)
…
17 unchanged lines
224 239
    };
225 240
    let new = repo.describe(&h, message)?;
226 241
    let c = repo.store().get_change(&new)?;
227 -
    println!("Described {} #{}: {}", short_id(&c), new.short(12), message.lines().next().unwrap_or(""));
242 +
    println!("{} {} {} {}", ui::ok("Described"), ui::id(short_id(&c)), ui::hash(format!("#{}", new.short(12))), message.lines().next().unwrap_or(""));
228 243
    Ok(0)
229 244
}
230 245
…
1 unchanged lines
232 247
    let repo = open()?;
233 248
    let new = repo.amend(message)?;
234 249
    let c = repo.store().get_change(&new)?;
235 -
    println!("Amended {} #{}: {}", short_id(&c), new.short(12), c.message.lines().next().unwrap_or(""));
250 +
    println!("{} {} {} {}", ui::ok("Amended"), ui::id(short_id(&c)), ui::hash(format!("#{}", new.short(12))), c.message.lines().next().unwrap_or(""));
236 251
    Ok(0)
237 252
}
238 253
…
8 unchanged lines
247 262
248 263
pub fn checkpoint() -> Result<i32> {
249 264
    let repo = open()?;
265 +
    let sp = ui::spinner("Writing checkpoint…");
250 266
    let (r, changes, bytes) = repo.checkpoint()?;
251 -
    println!("Checkpoint #{} of {} ({} bytes) written for {}.", r.short(12), plural(changes, "change"), bytes, repo.config.bookmark);
267 +
    sp.done(&format!("{} {} of {} ({} bytes) for {}", ui::ok("Checkpoint"), ui::hash(format!("#{}", r.short(12))), plural(changes, "change"), bytes, ui::id(&repo.config.bookmark)));
268 +
    Ok(0)
269 +
}
270 +
271 +
/// For shell prompts: silent (exit 1) outside a repository, otherwise one line.
272 +
pub fn prompt(format: &str) -> Result<i32> {
273 +
    let Ok(repo) = Repo::open(&std::env::current_dir()?) else { return Ok(1) };
274 +
    let info = repo.quick_status()?;
275 +
    println!("{}", info.format(format));
252 276
    Ok(0)
253 277
}
modified src/main.rs +2 −1
…
14 unchanged lines
15 15
    match cli::run() {
16 16
        Ok(code) => std::process::exit(code),
17 17
        Err(e) => {
18 -
            eprintln!("error: {e:#}");
18 +
            cli::ui::init(false);
19 +
            eprintln!("{} {e:#}", cli::ui::err("error:"));
19 20
            std::process::exit(1);
20 21
        }
21 22
    }
…
1 unchanged lines