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 |