| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 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 |
|
| 17 |
pub change_id: String, |
| 18 |
pub heads: usize, |
| 19 |
pub dirty: bool, |
| 20 |
pub conflicted: bool, |
| 21 |
|
| 22 |
pub ahead: Option<usize>, |
| 23 |
pub behind: Option<usize>, |
| 24 |
} |
| 25 |
|
| 26 |
impl PromptInfo { |
| 27 |
|
| 28 |
|
| 29 |
pub fn state_glyph(&self) -> &'static str { |
| 30 |
if self.conflicted { |
| 31 |
"‼" |
| 32 |
} else if self.dirty { |
| 33 |
"✎" |
| 34 |
} else { |
| 35 |
"✔" |
| 36 |
} |
| 37 |
} |
| 38 |
|
| 39 |
|
| 40 |
pub fn state_color(&self) -> u8 { |
| 41 |
if self.conflicted { |
| 42 |
31 |
| 43 |
} else if self.dirty { |
| 44 |
33 |
| 45 |
} else { |
| 46 |
36 |
| 47 |
} |
| 48 |
} |
| 49 |
|
| 50 |
|
| 51 |
|
| 52 |
pub fn format_colored(&self, fmt: &str, guard: (&str, &str)) -> String { |
| 53 |
format!( |
| 54 |
"{}\x1b[{}m{}{}{}\x1b[0m{}", |
| 55 |
guard.0, |
| 56 |
self.state_color(), |
| 57 |
guard.1, |
| 58 |
self.format(fmt), |
| 59 |
guard.0, |
| 60 |
guard.1 |
| 61 |
) |
| 62 |
} |
| 63 |
|
| 64 |
|
| 65 |
|
| 66 |
|
| 67 |
|
| 68 |
|
| 69 |
pub fn format(&self, fmt: &str) -> String { |
| 70 |
let mut out = String::new(); |
| 71 |
let mut chars = fmt.chars(); |
| 72 |
while let Some(c) = chars.next() { |
| 73 |
if c != '%' { |
| 74 |
out.push(c); |
| 75 |
continue; |
| 76 |
} |
| 77 |
match chars.next() { |
| 78 |
Some('b') => out.push_str(&self.bookmark), |
| 79 |
Some('c') => out.push_str(&self.change_id), |
| 80 |
Some('h') => out.push_str(&self.heads.to_string()), |
| 81 |
Some('d') => { |
| 82 |
if self.dirty { |
| 83 |
out.push('*'); |
| 84 |
} |
| 85 |
} |
| 86 |
Some('x') => { |
| 87 |
if self.conflicted { |
| 88 |
out.push('!'); |
| 89 |
} |
| 90 |
} |
| 91 |
Some('a') => out.push_str(&self.ahead.map(|n| n.to_string()).unwrap_or_default()), |
| 92 |
Some('B') => out.push_str(&self.behind.map(|n| n.to_string()).unwrap_or_default()), |
| 93 |
Some('A') => { |
| 94 |
if let Some(n) = self.ahead.filter(|n| *n > 0) { |
| 95 |
out.push_str(&format!("+{n}")); |
| 96 |
} |
| 97 |
} |
| 98 |
Some('D') => { |
| 99 |
if let Some(n) = self.behind.filter(|n| *n > 0) { |
| 100 |
out.push_str(&format!("-{n}")); |
| 101 |
} |
| 102 |
} |
| 103 |
Some('s') => out.push_str(self.state_glyph()), |
| 104 |
Some('U') => { |
| 105 |
if let Some(n) = self.ahead.filter(|n| *n > 0) { |
| 106 |
out.push_str(&format!("↑{n}")); |
| 107 |
} |
| 108 |
} |
| 109 |
Some('O') => { |
| 110 |
if let Some(n) = self.behind.filter(|n| *n > 0) { |
| 111 |
out.push_str(&format!("↓{n}")); |
| 112 |
} |
| 113 |
} |
| 114 |
Some('i') => out.push_str(ICON), |
| 115 |
Some('%') => out.push('%'), |
| 116 |
Some(other) => { |
| 117 |
out.push('%'); |
| 118 |
out.push(other); |
| 119 |
} |
| 120 |
None => out.push('%'), |
| 121 |
} |
| 122 |
} |
| 123 |
out |
| 124 |
} |
| 125 |
} |
| 126 |
|
| 127 |
|
| 128 |
|
| 129 |
|
| 130 |
#[derive(Clone, Debug)] |
| 131 |
pub(crate) struct ManifestEntry { |
| 132 |
pub size: u64, |
| 133 |
pub mtime_ns: u128, |
| 134 |
pub hash: [u8; 32], |
| 135 |
pub blob: Option<ObjectRef>, |
| 136 |
|
| 137 |
|
| 138 |
pub graph: Option<[u8; 32]>, |
| 139 |
|
| 140 |
|
| 141 |
pub exec: bool, |
| 142 |
} |
| 143 |
|
| 144 |
#[derive(Clone, Debug, Default)] |
| 145 |
pub(crate) struct Manifest { |
| 146 |
|
| 147 |
pub written_ns: u128, |
| 148 |
pub entries: BTreeMap<String, ManifestEntry>, |
| 149 |
|
| 150 |
|
| 151 |
|
| 152 |
pub dirs: Vec<(String, u128)>, |
| 153 |
|
| 154 |
pub nested: Vec<String>, |
| 155 |
} |
| 156 |
|
| 157 |
|
| 158 |
|
| 159 |
pub(crate) type CachedManifest = ((u64, u128, u64), (bool, bool, std::sync::Arc<Manifest>)); |
| 160 |
|
| 161 |
|
| 162 |
pub(crate) fn now_ns() -> u128 { |
| 163 |
std::time::SystemTime::now() |
| 164 |
.duration_since(UNIX_EPOCH) |
| 165 |
.map(|d| d.as_nanos()) |
| 166 |
.unwrap_or(0) |
| 167 |
} |
| 168 |
|
| 169 |
pub(crate) fn mtime_ns(m: &fs::Metadata) -> u128 { |
| 170 |
m.modified() |
| 171 |
.ok() |
| 172 |
.and_then(|t| t.duration_since(UNIX_EPOCH).ok()) |
| 173 |
.map(|d| d.as_nanos()) |
| 174 |
.unwrap_or(0) |
| 175 |
} |
| 176 |
|
| 177 |
pub const DEFAULT_PROMPT_FORMAT: &str = "%b %c%d%x%A%D"; |
| 178 |
|
| 179 |
pub const ICON: &str = "⚛"; |
| 180 |
|
| 181 |
pub const GLYPH_PROMPT_FORMAT: &str = "%i %b %c %s%U%O"; |
| 182 |
|
| 183 |
|
| 184 |
|
| 185 |
|
| 186 |
|
| 187 |
|
| 188 |
|
| 189 |
|
| 190 |
|
| 191 |
|
| 192 |
|
| 193 |
const MANIFEST_MAGIC: &[u8; 8] = b"oxyman3\0"; |
| 194 |
|
| 195 |
const MANIFEST_WRITTEN: std::ops::Range<usize> = 10..26; |
| 196 |
|
| 197 |
const MANIFEST_SUM: std::ops::Range<usize> = 26..58; |
| 198 |
|
| 199 |
fn manifest_sum(buf: &[u8]) -> [u8; 32] { |
| 200 |
let mut h = blake3::Hasher::new(); |
| 201 |
h.update(&buf[..MANIFEST_SUM.start]); |
| 202 |
h.update_rayon(&buf[MANIFEST_SUM.end..]); |
| 203 |
*h.finalize().as_bytes() |
| 204 |
} |
| 205 |
|
| 206 |
fn encode_manifest( |
| 207 |
conflicted: bool, |
| 208 |
uncommitted: bool, |
| 209 |
written_ns: u128, |
| 210 |
dirs: &[(String, u128)], |
| 211 |
nested: &[String], |
| 212 |
entries: &mut dyn Iterator<Item = (&str, ManifestEntry)>, |
| 213 |
) -> Vec<u8> { |
| 214 |
let mut w = crate::flat::Writer::default(); |
| 215 |
w.fixed(MANIFEST_MAGIC); |
| 216 |
w.u8(conflicted as u8); |
| 217 |
w.u8(uncommitted as u8); |
| 218 |
w.fixed(&written_ns.to_le_bytes()); |
| 219 |
w.fixed(&[0; 32]); |
| 220 |
w.count(dirs.len()); |
| 221 |
for (d, t) in dirs { |
| 222 |
w.bytes(d.as_bytes()); |
| 223 |
w.fixed(&t.to_le_bytes()); |
| 224 |
} |
| 225 |
w.count(nested.len()); |
| 226 |
for n in nested { |
| 227 |
w.bytes(n.as_bytes()); |
| 228 |
} |
| 229 |
for (path, e) in entries { |
| 230 |
w.bytes(path.as_bytes()); |
| 231 |
w.u64(e.size); |
| 232 |
w.fixed(&e.mtime_ns.to_le_bytes()); |
| 233 |
w.fixed(&e.hash); |
| 234 |
w.opt_object(&e.blob); |
| 235 |
match e.graph { |
| 236 |
None => w.u8(0), |
| 237 |
Some(k) => { |
| 238 |
w.u8(1); |
| 239 |
w.fixed(&k); |
| 240 |
} |
| 241 |
} |
| 242 |
w.u8(e.exec as u8); |
| 243 |
} |
| 244 |
let sum = manifest_sum(&w.0); |
| 245 |
w.0[MANIFEST_SUM].copy_from_slice(&sum); |
| 246 |
w.0 |
| 247 |
} |
| 248 |
|
| 249 |
fn decode_manifest(buf: &[u8]) -> Option<(bool, bool, Manifest)> { |
| 250 |
let mut r = crate::flat::Reader::new(buf); |
| 251 |
if &r.fixed::<8>().ok()? != MANIFEST_MAGIC { |
| 252 |
return None; |
| 253 |
} |
| 254 |
let conflicted = r.u8().ok()? == 1; |
| 255 |
let uncommitted = r.u8().ok()? == 1; |
| 256 |
let written_ns = u128::from_le_bytes(r.fixed().ok()?); |
| 257 |
if r.fixed::<32>().ok()? != manifest_sum(buf) { |
| 258 |
return None; |
| 259 |
} |
| 260 |
let mut dirs = Vec::new(); |
| 261 |
for _ in 0..r.count().ok()? { |
| 262 |
let d = std::str::from_utf8(r.bytes().ok()?).ok()?.to_string(); |
| 263 |
dirs.push((d, u128::from_le_bytes(r.fixed().ok()?))); |
| 264 |
} |
| 265 |
let mut nested = Vec::new(); |
| 266 |
for _ in 0..r.count().ok()? { |
| 267 |
nested.push(std::str::from_utf8(r.bytes().ok()?).ok()?.to_string()); |
| 268 |
} |
| 269 |
let mut entries = Vec::new(); |
| 270 |
while !r.at_end() { |
| 271 |
let path = std::str::from_utf8(r.bytes().ok()?).ok()?.to_string(); |
| 272 |
let e = ManifestEntry { |
| 273 |
size: r.u64().ok()?, |
| 274 |
mtime_ns: u128::from_le_bytes(r.fixed().ok()?), |
| 275 |
hash: r.fixed().ok()?, |
| 276 |
blob: r.opt_object().ok()?, |
| 277 |
graph: match r.u8().ok()? { |
| 278 |
0 => None, |
| 279 |
_ => Some(r.fixed().ok()?), |
| 280 |
}, |
| 281 |
exec: r.u8().ok()? == 1, |
| 282 |
}; |
| 283 |
entries.push((path, e)); |
| 284 |
} |
| 285 |
|
| 286 |
let entries = entries.into_iter().collect(); |
| 287 |
Some(( |
| 288 |
conflicted, |
| 289 |
uncommitted, |
| 290 |
Manifest { |
| 291 |
written_ns, |
| 292 |
entries, |
| 293 |
dirs, |
| 294 |
nested, |
| 295 |
}, |
| 296 |
)) |
| 297 |
} |
| 298 |
|
| 299 |
|
| 300 |
enum Tracked { |
| 301 |
File(super::walk::Stat), |
| 302 |
Link(Vec<u8>), |
| 303 |
} |
| 304 |
|
| 305 |
|
| 306 |
|
| 307 |
|
| 308 |
|
| 309 |
|
| 310 |
|
| 311 |
fn stat_tracked(root: &Path, rels: &[&String]) -> Vec<Option<Tracked>> { |
| 312 |
use rayon::prelude::*; |
| 313 |
let mut by_dir: HashMap<&str, Vec<(usize, &str)>> = HashMap::new(); |
| 314 |
for (i, rel) in rels.iter().enumerate() { |
| 315 |
let (dir, name) = rel.rsplit_once('/').unwrap_or(("", rel.as_str())); |
| 316 |
by_dir.entry(dir).or_default().push((i, name)); |
| 317 |
} |
| 318 |
let groups: Vec<(&str, Vec<(usize, &str)>)> = by_dir.into_iter().collect(); |
| 319 |
let found: Vec<(usize, Option<Tracked>)> = groups |
| 320 |
.par_iter() |
| 321 |
.flat_map_iter(|(dir, names)| { |
| 322 |
let handle = fs::File::open(root.join(dir)).ok(); |
| 323 |
names |
| 324 |
.iter() |
| 325 |
.map(|(i, name)| { |
| 326 |
let rel = rels[*i]; |
| 327 |
let now = match &handle { |
| 328 |
Some(d) => stat_in(d, name, || root.join(rel)), |
| 329 |
None => None, |
| 330 |
}; |
| 331 |
(*i, now) |
| 332 |
}) |
| 333 |
.collect::<Vec<_>>() |
| 334 |
}) |
| 335 |
.collect(); |
| 336 |
let mut out: Vec<Option<Tracked>> = (0..rels.len()).map(|_| None).collect(); |
| 337 |
for (i, now) in found { |
| 338 |
out[i] = now; |
| 339 |
} |
| 340 |
out |
| 341 |
} |
| 342 |
|
| 343 |
|
| 344 |
|
| 345 |
#[cfg(unix)] |
| 346 |
fn stat_in(dir: &fs::File, name: &str, path: impl Fn() -> PathBuf) -> Option<Tracked> { |
| 347 |
use std::os::fd::AsRawFd; |
| 348 |
let c = std::ffi::CString::new(name).ok()?; |
| 349 |
let mut st: libc::stat = unsafe { std::mem::zeroed() }; |
| 350 |
|
| 351 |
|
| 352 |
let r = unsafe { |
| 353 |
libc::fstatat( |
| 354 |
dir.as_raw_fd(), |
| 355 |
c.as_ptr(), |
| 356 |
&mut st, |
| 357 |
libc::AT_SYMLINK_NOFOLLOW, |
| 358 |
) |
| 359 |
}; |
| 360 |
if r != 0 { |
| 361 |
return None; |
| 362 |
} |
| 363 |
match st.st_mode & libc::S_IFMT { |
| 364 |
libc::S_IFREG => Some(Tracked::File(super::walk::Stat { |
| 365 |
size: st.st_size as u64, |
| 366 |
|
| 367 |
mtime_ns: if st.st_mtime < 0 { |
| 368 |
0 |
| 369 |
} else { |
| 370 |
st.st_mtime as u128 * 1_000_000_000 + st.st_mtime_nsec as u128 |
| 371 |
}, |
| 372 |
exec: st.st_mode & 0o111 != 0, |
| 373 |
})), |
| 374 |
libc::S_IFLNK => { |
| 375 |
let t = fs::read_link(path()).ok()?; |
| 376 |
Some(Tracked::Link(t.to_string_lossy().as_bytes().to_vec())) |
| 377 |
} |
| 378 |
_ => None, |
| 379 |
} |
| 380 |
} |
| 381 |
|
| 382 |
#[cfg(not(unix))] |
| 383 |
fn stat_in(_dir: &fs::File, _name: &str, path: impl Fn() -> PathBuf) -> Option<Tracked> { |
| 384 |
let p = path(); |
| 385 |
let m = fs::symlink_metadata(&p).ok()?; |
| 386 |
if m.file_type().is_symlink() { |
| 387 |
let t = fs::read_link(&p).ok()?; |
| 388 |
Some(Tracked::Link(t.to_string_lossy().as_bytes().to_vec())) |
| 389 |
} else if m.is_file() { |
| 390 |
Some(Tracked::File(super::walk::Stat::of(&m))) |
| 391 |
} else { |
| 392 |
None |
| 393 |
} |
| 394 |
} |
| 395 |
|
| 396 |
|
| 397 |
|
| 398 |
const STATUS_MAGIC: &[u8; 8] = b"oxystat1"; |
| 399 |
|
| 400 |
fn status_byte(s: ChangeStatus) -> u8 { |
| 401 |
match s { |
| 402 |
ChangeStatus::Added => 0, |
| 403 |
ChangeStatus::Modified => 1, |
| 404 |
ChangeStatus::Removed => 2, |
| 405 |
ChangeStatus::Renamed => 3, |
| 406 |
ChangeStatus::ModeChanged => 4, |
| 407 |
ChangeStatus::ConflictEdited => 5, |
| 408 |
ChangeStatus::Resolved => 6, |
| 409 |
} |
| 410 |
} |
| 411 |
|
| 412 |
fn encode_status_cache(wc: &ChangeHash, stamp: &[u8; 32], statuses: &[PathStatus]) -> Vec<u8> { |
| 413 |
let mut out = Vec::with_capacity(80 + statuses.len() * 40); |
| 414 |
out.extend_from_slice(STATUS_MAGIC); |
| 415 |
out.extend_from_slice(&wc.0); |
| 416 |
out.extend_from_slice(stamp); |
| 417 |
out.extend_from_slice(&(statuses.len() as u32).to_le_bytes()); |
| 418 |
for s in statuses { |
| 419 |
out.push(status_byte(s.status)); |
| 420 |
out.extend_from_slice(&(s.path.len() as u32).to_le_bytes()); |
| 421 |
out.extend_from_slice(s.path.as_bytes()); |
| 422 |
} |
| 423 |
out |
| 424 |
} |
| 425 |
|
| 426 |
fn decode_status_cache(b: &[u8]) -> Option<(ChangeHash, [u8; 32], Vec<PathStatus>)> { |
| 427 |
let rest = b.strip_prefix(STATUS_MAGIC)?; |
| 428 |
let wc = ObjectRef(rest.get(..32)?.try_into().ok()?); |
| 429 |
let stamp: [u8; 32] = rest.get(32..64)?.try_into().ok()?; |
| 430 |
let n = u32::from_le_bytes(rest.get(64..68)?.try_into().ok()?) as usize; |
| 431 |
let mut at = 68; |
| 432 |
let mut out = Vec::with_capacity(n.min(1 << 16)); |
| 433 |
for _ in 0..n { |
| 434 |
let status = match *rest.get(at)? { |
| 435 |
0 => ChangeStatus::Added, |
| 436 |
1 => ChangeStatus::Modified, |
| 437 |
2 => ChangeStatus::Removed, |
| 438 |
3 => ChangeStatus::Renamed, |
| 439 |
4 => ChangeStatus::ModeChanged, |
| 440 |
5 => ChangeStatus::ConflictEdited, |
| 441 |
6 => ChangeStatus::Resolved, |
| 442 |
_ => return None, |
| 443 |
}; |
| 444 |
let len = u32::from_le_bytes(rest.get(at + 1..at + 5)?.try_into().ok()?) as usize; |
| 445 |
let path = std::str::from_utf8(rest.get(at + 5..at + 5 + len)?) |
| 446 |
.ok()? |
| 447 |
.to_string(); |
| 448 |
out.push(PathStatus { path, status }); |
| 449 |
at += 5 + len; |
| 450 |
} |
| 451 |
(at == rest.len()).then_some((wc, stamp, out)) |
| 452 |
} |
| 453 |
|
| 454 |
impl Repo { |
| 455 |
pub(crate) fn manifest_path(&self) -> PathBuf { |
| 456 |
self.oxy |
| 457 |
.join("workspaces") |
| 458 |
.join(self.workspace.to_hex()) |
| 459 |
.join("manifest") |
| 460 |
} |
| 461 |
|
| 462 |
|
| 463 |
|
| 464 |
|
| 465 |
pub(crate) fn write_manifest(&self, conflicted: bool, uncommitted: bool) -> Result<()> { |
| 466 |
let entries = self.walk_working_copy()?; |
| 467 |
self.write_manifest_entries(conflicted, uncommitted, &entries, &HashMap::new()) |
| 468 |
} |
| 469 |
|
| 470 |
|
| 471 |
|
| 472 |
pub(crate) fn write_manifest_entries( |
| 473 |
&self, |
| 474 |
conflicted: bool, |
| 475 |
uncommitted: bool, |
| 476 |
entries: &BTreeMap<String, FsEntry>, |
| 477 |
blobs: &HashMap<String, ObjectRef>, |
| 478 |
) -> Result<()> { |
| 479 |
let dirs = self.walked_dirs.take().unwrap_or_default(); |
| 480 |
self.write_manifest_entries_as_of(conflicted, uncommitted, entries, blobs, now_ns(), dirs) |
| 481 |
} |
| 482 |
|
| 483 |
|
| 484 |
|
| 485 |
pub(crate) fn write_manifest_entries_as_of( |
| 486 |
&self, |
| 487 |
conflicted: bool, |
| 488 |
uncommitted: bool, |
| 489 |
entries: &BTreeMap<String, FsEntry>, |
| 490 |
blobs: &HashMap<String, ObjectRef>, |
| 491 |
written: u128, |
| 492 |
(dirs, nested): WalkedDirs, |
| 493 |
) -> Result<()> { |
| 494 |
self.manifest_cache.take(); |
| 495 |
self.listing_cache.take(); |
| 496 |
let mut newest_ns: u128 = 0; |
| 497 |
let mut rows = entries.iter().map(|(path, e)| { |
| 498 |
let (size, mtime_ns) = match e.stat { |
| 499 |
Some(known) => known, |
| 500 |
None => match fs::symlink_metadata(self.root.join(path)) { |
| 501 |
Ok(m) => (m.len(), mtime_ns(&m)), |
| 502 |
Err(_) => (e.content.len() as u64, 0), |
| 503 |
}, |
| 504 |
}; |
| 505 |
newest_ns = newest_ns.max(mtime_ns); |
| 506 |
let entry = ManifestEntry { |
| 507 |
size, |
| 508 |
mtime_ns, |
| 509 |
hash: e |
| 510 |
.content_hash |
| 511 |
.unwrap_or_else(|| *blake3::hash(&e.content).as_bytes()), |
| 512 |
blob: blobs.get(path).copied().or(e.blob), |
| 513 |
graph: e.graph_key, |
| 514 |
exec: e.exec, |
| 515 |
}; |
| 516 |
(path.as_str(), entry) |
| 517 |
}); |
| 518 |
let new = encode_manifest(conflicted, uncommitted, written, &dirs, &nested, &mut rows); |
| 519 |
drop(rows); |
| 520 |
let p = self.manifest_path(); |
| 521 |
|
| 522 |
|
| 523 |
|
| 524 |
|
| 525 |
if let Ok(old) = fs::read(&p) { |
| 526 |
let (w, sum) = (MANIFEST_WRITTEN, MANIFEST_SUM); |
| 527 |
if old.len() == new.len() |
| 528 |
&& old[..w.start] == new[..w.start] |
| 529 |
&& old[sum.end..] == new[sum.end..] |
| 530 |
{ |
| 531 |
let old_written = u128::from_le_bytes(old[w.clone()].try_into().unwrap()); |
| 532 |
if newest_ns + 2_000_000_000 < old_written { |
| 533 |
return Ok(()); |
| 534 |
} |
| 535 |
} |
| 536 |
} |
| 537 |
fs::create_dir_all(p.parent().unwrap())?; |
| 538 |
write_cache(&p, &new) |
| 539 |
} |
| 540 |
|
| 541 |
|
| 542 |
|
| 543 |
|
| 544 |
pub(crate) fn rewrite_manifest( |
| 545 |
&self, |
| 546 |
conflicted: bool, |
| 547 |
uncommitted: bool, |
| 548 |
keys: &HashMap<String, [u8; 32]>, |
| 549 |
) -> Result<()> { |
| 550 |
self.manifest_cache.take(); |
| 551 |
self.listing_cache.take(); |
| 552 |
let p = self.manifest_path(); |
| 553 |
let Some((_, _, m)) = fs::read(&p).ok().and_then(|b| decode_manifest(&b)) else { |
| 554 |
return self.write_manifest(conflicted, uncommitted); |
| 555 |
}; |
| 556 |
let mut rows = m.entries.iter().map(|(path, e)| { |
| 557 |
let mut e = e.clone(); |
| 558 |
if let Some(k) = keys.get(path) { |
| 559 |
e.graph = Some(*k); |
| 560 |
} |
| 561 |
(path.as_str(), e) |
| 562 |
}); |
| 563 |
write_cache( |
| 564 |
&p, |
| 565 |
&encode_manifest( |
| 566 |
conflicted, |
| 567 |
uncommitted, |
| 568 |
m.written_ns, |
| 569 |
&m.dirs, |
| 570 |
&m.nested, |
| 571 |
&mut rows, |
| 572 |
), |
| 573 |
) |
| 574 |
} |
| 575 |
|
| 576 |
|
| 577 |
|
| 578 |
|
| 579 |
pub(crate) fn drop_manifest(&self) { |
| 580 |
self.manifest_cache.take(); |
| 581 |
self.listing_cache.take(); |
| 582 |
let _ = fs::remove_file(self.manifest_path()); |
| 583 |
} |
| 584 |
|
| 585 |
|
| 586 |
|
| 587 |
|
| 588 |
|
| 589 |
pub fn recorded_conflicted(&self) -> Result<bool> { |
| 590 |
Ok(self.read_manifest()?.is_some_and(|(c, _, _)| c)) |
| 591 |
} |
| 592 |
|
| 593 |
pub(crate) fn read_manifest(&self) -> Result<Option<(bool, bool, std::sync::Arc<Manifest>)>> { |
| 594 |
let path = self.manifest_path(); |
| 595 |
let id = match fs::metadata(&path) { |
| 596 |
Ok(m) => { |
| 597 |
#[cfg(unix)] |
| 598 |
let ino = std::os::unix::fs::MetadataExt::ino(&m); |
| 599 |
#[cfg(not(unix))] |
| 600 |
let ino = 0; |
| 601 |
(m.len(), mtime_ns(&m), ino) |
| 602 |
} |
| 603 |
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), |
| 604 |
Err(e) => return Err(e.into()), |
| 605 |
}; |
| 606 |
if let Some((cached_id, m)) = &*self.manifest_cache.borrow() { |
| 607 |
if *cached_id == id { |
| 608 |
return Ok(Some(m.clone())); |
| 609 |
} |
| 610 |
} |
| 611 |
let Some((c, u, m)) = self.parse_manifest(&path)? else { |
| 612 |
return Ok(None); |
| 613 |
}; |
| 614 |
let m = (c, u, std::sync::Arc::new(m)); |
| 615 |
*self.manifest_cache.borrow_mut() = Some((id, m.clone())); |
| 616 |
Ok(Some(m)) |
| 617 |
} |
| 618 |
|
| 619 |
fn parse_manifest(&self, path: &std::path::Path) -> Result<Option<(bool, bool, Manifest)>> { |
| 620 |
let buf = match fs::read(path) { |
| 621 |
Ok(b) => b, |
| 622 |
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), |
| 623 |
Err(e) => return Err(e.into()), |
| 624 |
}; |
| 625 |
Ok(decode_manifest(&buf)) |
| 626 |
} |
| 627 |
|
| 628 |
|
| 629 |
|
| 630 |
|
| 631 |
|
| 632 |
|
| 633 |
|
| 634 |
|
| 635 |
pub fn clean_by_manifest(&self) -> Result<bool> { |
| 636 |
let _t = crate::timing::phase("status: check the manifest"); |
| 637 |
|
| 638 |
let not = |why: &str| { |
| 639 |
if crate::timing::enabled() { |
| 640 |
eprintln!("timing status: not clean by the manifest: {why}"); |
| 641 |
} |
| 642 |
Ok(false) |
| 643 |
}; |
| 644 |
let Some((conflicted, uncommitted, m)) = self.read_manifest()? else { |
| 645 |
return not("no manifest"); |
| 646 |
}; |
| 647 |
if conflicted || uncommitted || self.root.join(".oxymodules").exists() { |
| 648 |
return not("conflicted, uncommitted or subrepos"); |
| 649 |
} |
| 650 |
let started = now_ns(); |
| 651 |
if self.disk_differs(&m, true)? { |
| 652 |
return not("a file differs"); |
| 653 |
} |
| 654 |
|
| 655 |
|
| 656 |
|
| 657 |
|
| 658 |
|
| 659 |
|
| 660 |
|
| 661 |
let window = 2_000_000_000; |
| 662 |
let listed = self.listed_dirs.take(); |
| 663 |
let racy = m.dirs.iter().any(|(_, t)| t + window >= m.written_ns) |
| 664 |
|| m.entries |
| 665 |
.values() |
| 666 |
.any(|e| e.mtime_ns + window >= m.written_ns); |
| 667 |
if (racy || (m.dirs.is_empty() && listed.is_some())) && started > m.written_ns { |
| 668 |
let (dirs, nested) = listed.unwrap_or_else(|| (m.dirs.clone(), m.nested.clone())); |
| 669 |
self.restamp_manifest(&m, conflicted, uncommitted, started, &dirs, &nested); |
| 670 |
} |
| 671 |
Ok(true) |
| 672 |
} |
| 673 |
|
| 674 |
|
| 675 |
|
| 676 |
fn restamp_manifest( |
| 677 |
&self, |
| 678 |
m: &Manifest, |
| 679 |
conflicted: bool, |
| 680 |
uncommitted: bool, |
| 681 |
written_ns: u128, |
| 682 |
dirs: &[(String, u128)], |
| 683 |
nested: &[String], |
| 684 |
) { |
| 685 |
let _t = crate::timing::phase("status: restamp the manifest"); |
| 686 |
let mut rows = m.entries.iter().map(|(p, e)| (p.as_str(), e.clone())); |
| 687 |
let bytes = encode_manifest(conflicted, uncommitted, written_ns, dirs, nested, &mut rows); |
| 688 |
self.manifest_cache.take(); |
| 689 |
let _ = write_cache(&self.manifest_path(), &bytes); |
| 690 |
} |
| 691 |
|
| 692 |
|
| 693 |
|
| 694 |
|
| 695 |
fn racy_change(&self, m: &Manifest) -> Option<String> { |
| 696 |
use rayon::prelude::*; |
| 697 |
let racy: Vec<(&String, &ManifestEntry)> = m |
| 698 |
.entries |
| 699 |
.iter() |
| 700 |
.filter(|(_, e)| e.mtime_ns + 2_000_000_000 >= m.written_ns) |
| 701 |
.collect(); |
| 702 |
let root = &self.root; |
| 703 |
racy.par_iter() |
| 704 |
.find_any(|(rel, e)| { |
| 705 |
let path = root.join(rel.as_str()); |
| 706 |
let same = match fs::symlink_metadata(&path) { |
| 707 |
Ok(md) if md.file_type().is_symlink() => fs::read_link(&path).is_ok_and(|t| { |
| 708 |
*blake3::hash(t.to_string_lossy().as_bytes()).as_bytes() == e.hash |
| 709 |
}), |
| 710 |
Ok(md) if md.is_file() => { |
| 711 |
md.len() == e.size |
| 712 |
&& fs::read(&path).is_ok_and(|b| *blake3::hash(&b).as_bytes() == e.hash) |
| 713 |
} |
| 714 |
_ => false, |
| 715 |
}; |
| 716 |
!same |
| 717 |
}) |
| 718 |
.map(|(rel, _)| rel.to_string()) |
| 719 |
} |
| 720 |
|
| 721 |
|
| 722 |
|
| 723 |
|
| 724 |
|
| 725 |
|
| 726 |
|
| 727 |
pub fn status_by_manifest(&self) -> Result<Option<Vec<PathStatus>>> { |
| 728 |
|
| 729 |
|
| 730 |
self.listing_cache.take(); |
| 731 |
let Some((conflicted, uncommitted, m)) = self.read_manifest()? else { |
| 732 |
return Ok(None); |
| 733 |
}; |
| 734 |
if !uncommitted { |
| 735 |
return Ok(self.clean_by_manifest()?.then(Vec::new)); |
| 736 |
} |
| 737 |
let _t = crate::timing::phase("status: by the status cache"); |
| 738 |
if conflicted || self.root.join(".oxymodules").exists() { |
| 739 |
return Ok(None); |
| 740 |
} |
| 741 |
let Some((wc, stamp, statuses)) = fs::read(self.status_cache_path()) |
| 742 |
.ok() |
| 743 |
.and_then(|b| decode_status_cache(&b)) |
| 744 |
else { |
| 745 |
return Ok(None); |
| 746 |
}; |
| 747 |
let now = self.view()?.workspaces.get(&self.workspace).copied(); |
| 748 |
if now != Some(wc) || self.manifest_stamp() != Some(stamp) { |
| 749 |
return Ok(None); |
| 750 |
} |
| 751 |
if self.disk_differs(&m, true)? { |
| 752 |
return Ok(None); |
| 753 |
} |
| 754 |
|
| 755 |
#[cfg(test)] |
| 756 |
{ |
| 757 |
let snap = self.snapshot_status("status cache check")?; |
| 758 |
let key = |v: &[PathStatus]| { |
| 759 |
let mut k: Vec<(String, u8)> = v |
| 760 |
.iter() |
| 761 |
.map(|p| (p.path.clone(), status_byte(p.status))) |
| 762 |
.collect(); |
| 763 |
k.sort(); |
| 764 |
k |
| 765 |
}; |
| 766 |
assert_eq!( |
| 767 |
key(&statuses), |
| 768 |
key(&snap.statuses), |
| 769 |
"status cache disagrees with a snapshot" |
| 770 |
); |
| 771 |
} |
| 772 |
Ok(Some(statuses)) |
| 773 |
} |
| 774 |
|
| 775 |
fn status_cache_path(&self) -> PathBuf { |
| 776 |
self.manifest_path().with_file_name("status") |
| 777 |
} |
| 778 |
|
| 779 |
|
| 780 |
fn manifest_stamp(&self) -> Option<[u8; 32]> { |
| 781 |
use std::io::Read; |
| 782 |
let mut head = [0u8; MANIFEST_SUM.end]; |
| 783 |
fs::File::open(self.manifest_path()) |
| 784 |
.and_then(|mut f| f.read_exact(&mut head)) |
| 785 |
.ok()?; |
| 786 |
head[MANIFEST_SUM].try_into().ok() |
| 787 |
} |
| 788 |
|
| 789 |
|
| 790 |
|
| 791 |
|
| 792 |
pub(crate) fn write_status_cache(&self, wc: Option<&ChangeHash>, statuses: &[PathStatus]) { |
| 793 |
let path = self.status_cache_path(); |
| 794 |
let (Some(wc), Some(stamp)) = (wc, self.manifest_stamp()) else { |
| 795 |
let _ = fs::remove_file(&path); |
| 796 |
return; |
| 797 |
}; |
| 798 |
let _ = write_cache(&path, &encode_status_cache(wc, &stamp, statuses)); |
| 799 |
} |
| 800 |
|
| 801 |
|
| 802 |
|
| 803 |
pub fn quick_dirty(&self) -> Result<Option<bool>> { |
| 804 |
self.quick_dirty_keeping(false) |
| 805 |
} |
| 806 |
|
| 807 |
|
| 808 |
|
| 809 |
fn quick_dirty_keeping(&self, keep: bool) -> Result<Option<bool>> { |
| 810 |
let Some((_, uncommitted, manifest)) = self.read_manifest()? else { |
| 811 |
return Ok(None); |
| 812 |
}; |
| 813 |
if uncommitted { |
| 814 |
return Ok(Some(true)); |
| 815 |
} |
| 816 |
Ok(Some(self.disk_differs(&manifest, keep)?)) |
| 817 |
} |
| 818 |
|
| 819 |
|
| 820 |
|
| 821 |
|
| 822 |
fn disk_differs(&self, manifest: &Manifest, keep: bool) -> Result<bool> { |
| 823 |
|
| 824 |
|
| 825 |
|
| 826 |
|
| 827 |
let racy = self.racy_change(manifest).is_some(); |
| 828 |
if let Some(differs) = self.disk_differs_by_dirs_keeping(manifest, keep, racy) { |
| 829 |
return Ok(differs || racy); |
| 830 |
} |
| 831 |
if racy { |
| 832 |
return Ok(true); |
| 833 |
} |
| 834 |
self.disk_differs_listing(manifest, keep) |
| 835 |
} |
| 836 |
|
| 837 |
|
| 838 |
|
| 839 |
|
| 840 |
|
| 841 |
|
| 842 |
|
| 843 |
#[cfg(test)] |
| 844 |
pub(crate) fn disk_differs_by_dirs(&self, manifest: &Manifest) -> Option<bool> { |
| 845 |
self.disk_differs_by_dirs_keeping(manifest, false, false) |
| 846 |
} |
| 847 |
|
| 848 |
|
| 849 |
|
| 850 |
|
| 851 |
|
| 852 |
fn disk_differs_by_dirs_keeping( |
| 853 |
&self, |
| 854 |
manifest: &Manifest, |
| 855 |
keep: bool, |
| 856 |
racy: bool, |
| 857 |
) -> Option<bool> { |
| 858 |
use rayon::prelude::*; |
| 859 |
if manifest.dirs.is_empty() || !self.config.sparse_paths.is_empty() { |
| 860 |
return None; |
| 861 |
} |
| 862 |
let _t = crate::timing::phase("status: directories and files by stat"); |
| 863 |
let root = &self.root; |
| 864 |
let dirs_same = manifest.dirs.par_iter().all(|(rel, t)| { |
| 865 |
*t + 2_000_000_000 < manifest.written_ns |
| 866 |
&& fs::metadata(root.join(rel)).is_ok_and(|m| m.is_dir() && mtime_ns(&m) == *t) |
| 867 |
}); |
| 868 |
if !dirs_same { |
| 869 |
return None; |
| 870 |
} |
| 871 |
let rels: Vec<&String> = manifest.entries.keys().collect(); |
| 872 |
let now = stat_tracked(root, &rels); |
| 873 |
let differs_from = |me: &ManifestEntry, rel: &str, now: &Tracked| match now { |
| 874 |
Tracked::Link(t) => *blake3::hash(t).as_bytes() != me.hash, |
| 875 |
Tracked::File(m) => { |
| 876 |
m.size != me.size |
| 877 |
|| m.exec != me.exec |
| 878 |
|| (m.mtime_ns != me.mtime_ns |
| 879 |
&& !fs::read(root.join(rel)) |
| 880 |
.is_ok_and(|b| *blake3::hash(&b).as_bytes() == me.hash)) |
| 881 |
} |
| 882 |
}; |
| 883 |
|
| 884 |
|
| 885 |
if now.iter().any(Option::is_none) { |
| 886 |
return None; |
| 887 |
} |
| 888 |
|
| 889 |
let entries: Vec<&ManifestEntry> = manifest.entries.values().collect(); |
| 890 |
let differs = rels |
| 891 |
.par_iter() |
| 892 |
.zip(entries.par_iter()) |
| 893 |
.zip(now.par_iter()) |
| 894 |
.any(|((rel, me), n)| differs_from(me, rel, n.as_ref().unwrap())); |
| 895 |
if keep && (differs || racy) { |
| 896 |
let mut listing = super::walk::Listing { |
| 897 |
dirs: manifest.dirs.clone(), |
| 898 |
nested: manifest.nested.iter().map(|n| root.join(n)).collect(), |
| 899 |
..Default::default() |
| 900 |
}; |
| 901 |
for (rel, n) in rels.into_iter().zip(now) { |
| 902 |
match n.unwrap() { |
| 903 |
Tracked::File(m) => listing.files.push((rel.clone(), root.join(rel), m)), |
| 904 |
Tracked::Link(t) => listing.symlinks.push((rel.clone(), t)), |
| 905 |
} |
| 906 |
} |
| 907 |
*self.listing_cache.borrow_mut() = Some(listing); |
| 908 |
} |
| 909 |
if !differs { |
| 910 |
|
| 911 |
|
| 912 |
*self.untracked_nested.borrow_mut() = manifest |
| 913 |
.nested |
| 914 |
.iter() |
| 915 |
.filter(|rel| !manifest.entries.contains_key(*rel)) |
| 916 |
.cloned() |
| 917 |
.collect(); |
| 918 |
} |
| 919 |
Some(differs) |
| 920 |
} |
| 921 |
|
| 922 |
|
| 923 |
fn disk_differs_listing(&self, manifest: &Manifest, keep: bool) -> Result<bool> { |
| 924 |
use rayon::prelude::*; |
| 925 |
let sparse = &self.config.sparse_paths; |
| 926 |
let in_scope = |p: &str| { |
| 927 |
sparse.is_empty() |
| 928 |
|| sparse |
| 929 |
.iter() |
| 930 |
.any(|s| p == s || p.starts_with(&format!("{s}/"))) |
| 931 |
}; |
| 932 |
|
| 933 |
|
| 934 |
|
| 935 |
|
| 936 |
let known = |p: &str| manifest.entries.contains_key(p); |
| 937 |
let Ok(listing) = super::walk::list_knowing(&self.root, &in_scope, &known) else { |
| 938 |
return Ok(true); |
| 939 |
}; |
| 940 |
let differs = || -> Result<bool> { |
| 941 |
let links_differ = listing.symlinks.iter().any(|(rel, target)| { |
| 942 |
manifest |
| 943 |
.entries |
| 944 |
.get(rel) |
| 945 |
.is_none_or(|me| *blake3::hash(target).as_bytes() != me.hash) |
| 946 |
}); |
| 947 |
if links_differ { |
| 948 |
return Ok(true); |
| 949 |
} |
| 950 |
|
| 951 |
|
| 952 |
let files_differ = listing.files.par_iter().any(|(rel, path, m)| { |
| 953 |
manifest.entries.get(rel).is_none_or(|me| { |
| 954 |
m.size != me.size |
| 955 |
|| m.exec != me.exec |
| 956 |
|| (m.mtime_ns != me.mtime_ns |
| 957 |
&& !fs::read(path) |
| 958 |
.is_ok_and(|b| *blake3::hash(&b).as_bytes() == me.hash)) |
| 959 |
}) |
| 960 |
}); |
| 961 |
if files_differ { |
| 962 |
return Ok(true); |
| 963 |
} |
| 964 |
let seen = listing.files.len() + listing.symlinks.len(); |
| 965 |
if seen == manifest.entries.len() { |
| 966 |
return Ok(false); |
| 967 |
} |
| 968 |
|
| 969 |
|
| 970 |
|
| 971 |
|
| 972 |
let mut listed: Vec<&str> = listing |
| 973 |
.files |
| 974 |
.iter() |
| 975 |
.map(|(r, _, _)| r.as_str()) |
| 976 |
.chain(listing.symlinks.iter().map(|(r, _)| r.as_str())) |
| 977 |
.collect(); |
| 978 |
listed.sort_unstable(); |
| 979 |
let mut at = listed.iter().peekable(); |
| 980 |
let skipped: Vec<(&String, &ManifestEntry)> = manifest |
| 981 |
.entries |
| 982 |
.iter() |
| 983 |
.filter(|(rel, _)| { |
| 984 |
while at.next_if(|l| **l < rel.as_str()).is_some() {} |
| 985 |
at.peek().is_none_or(|l| **l != rel.as_str()) |
| 986 |
}) |
| 987 |
.collect(); |
| 988 |
let root = &self.root; |
| 989 |
Ok(skipped.par_iter().any(|(rel, me)| { |
| 990 |
let path = root.join(rel.as_str()); |
| 991 |
let Ok(m) = fs::symlink_metadata(&path) else { |
| 992 |
return true; |
| 993 |
}; |
| 994 |
!m.file_type().is_symlink() |
| 995 |
&& (m.len() != me.size |
| 996 |
|| super::is_executable(&m) != me.exec |
| 997 |
|| (mtime_ns(&m) != me.mtime_ns |
| 998 |
&& !fs::read(&path) |
| 999 |
.is_ok_and(|b| *blake3::hash(&b).as_bytes() == me.hash))) |
| 1000 |
})) |
| 1001 |
}; |
| 1002 |
let dirty = differs()?; |
| 1003 |
if !dirty { |
| 1004 |
|
| 1005 |
|
| 1006 |
let mut nested: Vec<String> = listing |
| 1007 |
.nested |
| 1008 |
.iter() |
| 1009 |
.map(|d| self.rel_path(d)) |
| 1010 |
.filter(|rel| !manifest.entries.contains_key(rel)) |
| 1011 |
.collect(); |
| 1012 |
nested.sort(); |
| 1013 |
*self.untracked_nested.borrow_mut() = nested; |
| 1014 |
|
| 1015 |
|
| 1016 |
*self.listed_dirs.borrow_mut() = Some(( |
| 1017 |
listing.dirs.clone(), |
| 1018 |
listing.nested.iter().map(|d| self.rel_path(d)).collect(), |
| 1019 |
)); |
| 1020 |
} |
| 1021 |
if dirty && keep { |
| 1022 |
*self.listing_cache.borrow_mut() = Some(listing); |
| 1023 |
} |
| 1024 |
Ok(dirty) |
| 1025 |
} |
| 1026 |
|
| 1027 |
|
| 1028 |
pub fn quick_status(&self) -> Result<PromptInfo> { |
| 1029 |
let view = self.view()?; |
| 1030 |
let bookmark = self.config.bookmark.clone(); |
| 1031 |
let heads = self.heads_of(&view, &bookmark)?; |
| 1032 |
let wc_hash = view.workspaces.get(&self.workspace).copied(); |
| 1033 |
let change_id = match wc_hash { |
| 1034 |
Some(h) => self.store.get_change_meta(&h)?.change_id.short(8), |
| 1035 |
None => String::new(), |
| 1036 |
}; |
| 1037 |
|
| 1038 |
|
| 1039 |
let manifest = self.read_manifest()?; |
| 1040 |
let conflicted = manifest.as_ref().map(|(c, _, _)| *c).unwrap_or(false); |
| 1041 |
let dirty = self.quick_dirty()?.unwrap_or(false); |
| 1042 |
let (ahead, behind) = match view |
| 1043 |
.remotes |
| 1044 |
.get(&self.default_remote()) |
| 1045 |
.and_then(|b| b.get(&bookmark)) |
| 1046 |
{ |
| 1047 |
Some(st) => { |
| 1048 |
let remote_heads = self.store.get_state(st)?.heads; |
| 1049 |
let ahead = self.negotiate(&remote_heads, &heads).map(|v| v.len()).ok(); |
| 1050 |
let behind = self.negotiate(&heads, &remote_heads).map(|v| v.len()).ok(); |
| 1051 |
(ahead, behind) |
| 1052 |
} |
| 1053 |
None => (None, None), |
| 1054 |
}; |
| 1055 |
Ok(PromptInfo { |
| 1056 |
bookmark, |
| 1057 |
change_id, |
| 1058 |
heads: heads.len(), |
| 1059 |
dirty, |
| 1060 |
conflicted, |
| 1061 |
ahead, |
| 1062 |
behind, |
| 1063 |
}) |
| 1064 |
} |
| 1065 |
} |
| 1066 |
|
| 1067 |
#[cfg(test)] |
| 1068 |
mod tests { |
| 1069 |
use super::*; |
| 1070 |
|
| 1071 |
#[test] |
| 1072 |
fn placeholders_and_glyphs() { |
| 1073 |
let clean = PromptInfo { |
| 1074 |
bookmark: "main".into(), |
| 1075 |
change_id: "658990aa".into(), |
| 1076 |
heads: 1, |
| 1077 |
dirty: false, |
| 1078 |
conflicted: false, |
| 1079 |
ahead: Some(0), |
| 1080 |
behind: None, |
| 1081 |
}; |
| 1082 |
assert_eq!(clean.format(DEFAULT_PROMPT_FORMAT), "main 658990aa"); |
| 1083 |
assert_eq!(clean.format(GLYPH_PROMPT_FORMAT), "⚛ main 658990aa ✔"); |
| 1084 |
let busy = PromptInfo { |
| 1085 |
dirty: true, |
| 1086 |
ahead: Some(2), |
| 1087 |
behind: Some(3), |
| 1088 |
..clean.clone() |
| 1089 |
}; |
| 1090 |
assert_eq!(busy.format(DEFAULT_PROMPT_FORMAT), "main 658990aa*+2-3"); |
| 1091 |
assert_eq!(busy.format(GLYPH_PROMPT_FORMAT), "⚛ main 658990aa ✎↑2↓3"); |
| 1092 |
let bad = PromptInfo { |
| 1093 |
conflicted: true, |
| 1094 |
..busy.clone() |
| 1095 |
}; |
| 1096 |
assert_eq!(bad.format("%s%x%d %h %a/%B %%"), "‼!* 1 2/3 %"); |
| 1097 |
assert_eq!(bad.state_color(), 31); |
| 1098 |
assert_eq!( |
| 1099 |
busy.format_colored("%b", ("\\[", "\\]")), |
| 1100 |
"\\[\x1b[33m\\]main\\[\x1b[0m\\]" |
| 1101 |
); |
| 1102 |
assert_eq!(clean.format_colored("%b", ("", "")), "\x1b[36mmain\x1b[0m"); |
| 1103 |
assert_eq!(clean.format("%q"), "%q"); |
| 1104 |
} |
| 1105 |
} |