1use std::ffi::OsStr;
2use std::io::{IsTerminal, Write};
3use std::path::{Path, PathBuf};
4use std::process::{Command, Output, Stdio};
5use std::{env, fs};
6
7use anstyle::Style;
8use anyhow::{Context, Result, bail};
9use console::{Alignment, Key, Term, pad_str, truncate_str};
10use dialoguer::theme::ColorfulTheme;
11use dialoguer::{Confirm, Select};
12
13use crate::commands::Run;
14use crate::style;
15
16type Walk = fn(&mut Tour) -> Result<()>;
17
18const TOPICS: &[(&str, &str, Walk)] = &[
20 ("intro", "create, submit, restack, and land a stack", intro),
21 (
22 "conflicts",
23 "when a restack stops: resolve, continue, abort",
24 conflicts,
25 ),
26 ("repair", "rebuild lost stack metadata", repair),
27 (
28 "absorb",
29 "fold review fixes back into the commits they belong to",
30 absorb,
31 ),
32 (
33 "adopt",
34 "adopt a branch into a stack, or move it to a new parent",
35 adopt,
36 ),
37 ("undo", "reverse the last stack-rewriting command", undo),
38];
39
40#[derive(Debug, clap::Args)]
42pub struct Guide {
43 #[arg(value_parser = clap::builder::PossibleValuesParser::new(["intro", "conflicts", "repair", "absorb", "adopt", "undo"]))]
45 topic: Option<String>,
46}
47
48impl Run for Guide {
49 fn run(self) -> Result<()> {
50 guide(self.topic.as_deref())
51 }
52}
53
54fn guide(topic: Option<&str>) -> Result<()> {
55 if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
56 bail!("the guide is interactive; run it from a terminal");
57 }
58
59 banner("git stk guide");
60 say("Short interactive tours. Everything happens in a disposable sandbox");
61 say("repository - your real work is never touched, and a built-in demo");
62 say("provider stands in for GitHub: same commands, no network.");
63 say("Each step opens full-screen; scroll with j/k or the arrows, Enter to");
64 say("move on, q to quit.");
65 println!();
66
67 let chosen = match topic {
68 Some(topic) => TOPICS
69 .iter()
70 .find(|(name, _, _)| *name == topic)
71 .context("unknown guide topic")?,
72 None => {
73 let items: Vec<String> = TOPICS
74 .iter()
75 .map(|(name, blurb, _)| format!("{name} - {blurb}"))
76 .collect();
77 let index = Select::with_theme(&ColorfulTheme::default())
78 .with_prompt("which tour?")
79 .items(&items)
80 .default(0)
81 .interact()
82 .context("nothing chosen")?;
83 &TOPICS[index]
84 }
85 };
86 println!();
87
88 let sandbox = env::temp_dir().join(format!("git-stk-guide-{}", std::process::id()));
89 if sandbox.exists() {
90 fs::remove_dir_all(&sandbox).context("failed to clear an old sandbox")?;
91 }
92 say(&format!("sandbox: {}", sandbox.display()));
93 println!();
94 setup_sandbox(&sandbox)?;
95
96 let mut tour = Tour::new(&sandbox, chosen.0);
97 let finished = (chosen.2)(&mut tour);
98
99 let delete = Confirm::with_theme(&ColorfulTheme::default())
101 .with_prompt("delete the sandbox?")
102 .default(true)
103 .interact()
104 .unwrap_or(true);
105 if delete {
106 fs::remove_dir_all(&sandbox).context("failed to remove the sandbox")?;
107 say("sandbox removed");
108 } else {
109 say(&format!("kept: cd {}", sandbox.display()));
110 say("it uses `git config stk.provider demo`, so every command works offline");
111 }
112
113 finished
114}
115
116fn intro(tour: &mut Tour) -> Result<()> {
117 tour.banner("1/5 - a stack is just branches");
118 tour.say("Each branch carries one reviewable change and knows its parent.");
119 tour.say("`new` creates a child of wherever you stand:");
120 tour.stk(&["new", "feature/login"])?;
121 tour.commit("login.txt", "username + password form\n", "add login form")?;
122 tour.stk(&["new", "feature/avatar"])?;
123 tour.commit("avatar.txt", "round avatars\n", "add avatars")?;
124 tour.say("Two branches, stacked. `list` draws the pile, trunk at the bottom:");
125 tour.stk(&["list"])?;
126 if tour.pause()?.stop() {
127 return Ok(());
128 }
129
130 tour.banner("2/5 - submit the whole stack");
131 tour.say("One command opens (or updates) a review per branch, parent-first,");
132 tour.say("and writes a live stack overview into every description:");
133 tour.stk(&["submit", "--stack"])?;
134 tour.stk(&["status"])?;
135 if tour.pause()?.stop() {
136 return Ok(());
137 }
138
139 tour.banner("3/5 - parents move; restack follows");
140 tour.say("Review feedback lands on the bottom branch:");
141 tour.stk(&["down"])?;
142 tour.commit(
143 "login.txt",
144 "username + password form\nremember me\n",
145 "add remember me",
146 )?;
147 tour.say("The child is now behind its parent - `list` notices:");
148 tour.stk(&["list"])?;
149 tour.say("`restack` rebases every descendant back onto its parent:");
150 tour.stk(&["restack"])?;
151 tour.stk(&["top"])?;
152 if tour.pause()?.stop() {
153 return Ok(());
154 }
155
156 tour.banner("4/5 - land the stack");
157 tour.say("`merge --all` repeats merge-bottom-then-sync until the stack is");
158 tour.say("complete: children retarget, merged branches vanish, the overview");
159 tour.say("in every review restyles as history accumulates:");
160 tour.stk(&["merge", "--all", "-y"])?;
161 if tour.pause()?.stop() {
162 return Ok(());
163 }
164
165 tour.banner("5/5 - nothing left but trunk");
166 tour.stk(&["list"])?;
167 tour.say("That is the whole loop: new -> commit -> submit -> merge.");
168 tour.say("On a real repo the provider is detected from your remote; day to day");
169 tour.say("you mostly run `git stk new`, `git stk submit --stack`, and");
170 tour.say("`git stk merge --all`. `git stk status` and the hints fill the gaps.");
171 tour.finish()
172}
173
174fn conflicts(tour: &mut Tour) -> Result<()> {
175 tour.banner("1/3 - set up a collision");
176 tour.say("A two-branch stack where both branches touch the same line:");
177 tour.stk(&["new", "feature/payment"])?;
178 tour.commit("notes.txt", "use stripe\n", "choose payment provider")?;
179 tour.stk(&["new", "feature/receipts"])?;
180 tour.commit("notes.txt", "use stripe with receipts\n", "email receipts")?;
181 tour.say("Now the parent changes its mind about that very line:");
182 tour.stk(&["down"])?;
183 tour.commit("notes.txt", "use paypal\n", "switch to paypal")?;
184 if tour.pause()?.stop() {
185 return Ok(());
186 }
187
188 tour.banner("2/3 - the restack stops, with context");
189 tour.say("Replaying the child onto the rewritten parent cannot succeed; the");
190 tour.say("restack stops, shows git's conflict output, and says what to do:");
191 tour.stk_fails(&["restack"])?;
192 if tour.pause()?.stop() {
193 return Ok(());
194 }
195
196 tour.banner("3/3 - resolve, then continue");
197 tour.say("Fix the file and stage it, exactly like any rebase conflict:");
198 tour.edit_and_add("notes.txt", "use paypal with receipts\n")?;
199 tour.say("`continue` picks the restack back up where it stopped");
200 tour.say("(`git stk abort` would have unwound it instead):");
201 tour.stk(&["continue"])?;
202 tour.stk(&["list"])?;
203 tour.say("Conflicts interrupt the restack, never break it: resolve, continue,");
204 tour.say("and the rest of the stack follows.");
205 tour.finish()
206}
207
208fn repair(tour: &mut Tour) -> Result<()> {
209 tour.banner("1/3 - a healthy stack");
210 tour.stk(&["new", "feature/api"])?;
211 tour.commit("api.txt", "endpoints\n", "add api")?;
212 tour.stk(&["new", "feature/ui"])?;
213 tour.commit("ui.txt", "buttons\n", "add ui")?;
214 tour.stk(&["submit", "--stack"])?;
215 if tour.pause()?.stop() {
216 return Ok(());
217 }
218
219 tour.banner("2/3 - the metadata vanishes");
220 tour.say("Stack parents are plain `branch.<name>.stkParent` entries in");
221 tour.say(".git/config - annotations, not state. Suppose one gets lost:");
222 tour.note("git config --unset branch.feature/ui.stkParent");
223 run_git(
224 tour.sandbox,
225 &["config", "--unset", "branch.feature/ui.stkParent"],
226 )?;
227 tour.say("The stack no longer knows feature/ui belongs to it:");
228 tour.stk(&["list"])?;
229 if tour.pause()?.stop() {
230 return Ok(());
231 }
232
233 tour.banner("3/3 - repair rebuilds it");
234 tour.say("`repair` re-derives parents from review bases (when a provider is");
235 tour.say("reachable) and branch ancestry, and verifies recorded fork points:");
236 tour.stk(&["repair", "--dry-run"])?;
237 tour.stk(&["repair"])?;
238 tour.stk(&["list"])?;
239 tour.say("Branches are the real state; metadata is always recoverable.");
240 tour.say("Anything repair cannot resolve safely, it reports for a manual");
241 tour.say("`git stk adopt`.");
242 tour.finish()
243}
244
245fn absorb(tour: &mut Tour) -> Result<()> {
246 tour.banner("1/3 - fixes scattered across the stack");
247 tour.say("A two-branch stack, each branch owning one file:");
248 tour.stk(&["new", "feature/login"])?;
249 tour.commit("login.txt", "username + password form\n", "add login form")?;
250 tour.stk(&["new", "feature/avatar"])?;
251 tour.commit("avatar.txt", "round avatars\n", "add avatars")?;
252 tour.say("Review comes back: two small fixes, one on each branch's file.");
253 tour.say("You make both edits from the top and stage them, as usual:");
254 tour.edit_and_add("login.txt", "username + password form, with 2FA\n")?;
255 tour.edit_and_add("avatar.txt", "round avatars, lazy-loaded\n")?;
256 tour.say("Both fixes sit staged together, but each belongs to a different commit");
257 tour.say("further down the stack:");
258 tour.stk(&["status"])?;
259 if tour.pause()?.stop() {
260 return Ok(());
261 }
262
263 tour.banner("2/3 - preview where each hunk lands");
264 tour.say("`absorb` blames every staged hunk and routes it to the commit that");
265 tour.say("introduced the lines it touches. `--dry-run` shows the plan first:");
266 tour.stk(&["absorb", "--dry-run"])?;
267 if tour.pause()?.stop() {
268 return Ok(());
269 }
270
271 tour.banner("3/3 - fold them in");
272 tour.say("Run it for real: each fix becomes a `fixup!` of its owning commit, an");
273 tour.say("autosquash rebase folds them in, and every branch ref rides along:");
274 tour.stk(&["absorb"])?;
275 tour.say("The history reads as if the fixes were always there - no extra commits:");
276 tour.show_git(
277 "git log --oneline main..feature/avatar",
278 &[
279 "--no-pager",
280 "-c",
281 "color.ui=always",
282 "log",
283 "--oneline",
284 "main..feature/avatar",
285 ],
286 )?;
287 tour.say("Hunks that cannot be attributed - brand-new lines, trunk-owned lines, a");
288 tour.say("hunk spanning two commits - are left staged and reported, never guessed.");
289 tour.finish()
290}
291
292fn adopt(tour: &mut Tour) -> Result<()> {
293 tour.banner("1/3 - adopt a hand-made branch");
294 tour.say("Not every branch begins with `git stk new`. Suppose you branched off");
295 tour.say("the trunk by hand and did some work:");
296 tour.note("git switch -c feature/logging");
297 run_git(tour.sandbox, &["switch", "-c", "feature/logging"])?;
298 tour.commit("logging.txt", "structured logs\n", "add logging")?;
299 tour.say("git-stk has no metadata for it yet. `adopt` records its parent -");
300 tour.say("metadata only, nothing is rewritten - folding it into a stack:");
301 tour.stk(&["adopt", "--parent", "main"])?;
302 tour.stk(&["list"])?;
303 if tour.pause()?.stop() {
304 return Ok(());
305 }
306
307 tour.banner("2/3 - move a branch onto another");
308 tour.say("Two branches, each started independently off the trunk:");
309 tour.note("git switch main");
310 run_git(tour.sandbox, &["switch", "main"])?;
311 tour.stk(&["new", "feature/api"])?;
312 tour.commit("api.txt", "endpoints\n", "add api")?;
313 tour.note("git switch main");
314 run_git(tour.sandbox, &["switch", "main"])?;
315 tour.stk(&["new", "feature/web"])?;
316 tour.commit("web.txt", "pages\n", "add web")?;
317 tour.say("`list` shows them as siblings on the trunk:");
318 tour.stk(&["list", "--all"])?;
319 tour.say("But feature/web really belongs on top of feature/api. Re-point its");
320 tour.say("parent with `adopt`, then `restack` replays its commits onto the new");
321 tour.say("base (only its own commits move; the parent's are already there):");
322 tour.stk(&["adopt", "--parent", "feature/api"])?;
323 tour.stk(&["restack"])?;
324 tour.stk(&["list"])?;
325 if tour.pause()?.stop() {
326 return Ok(());
327 }
328
329 tour.banner("3/3 - detach: the inverse");
330 tour.say("`detach` drops a branch's stack metadata, leaving the branch and its");
331 tour.say("commits untouched - handy when something was adopted by mistake:");
332 tour.stk(&["detach", "feature/web"])?;
333 tour.stk(&["list", "--all"])?;
334 tour.say("feature/web still exists; git-stk just no longer tracks it. Re-`adopt`");
335 tour.say("it onto any parent whenever you want it back in a stack.");
336 tour.finish()
337}
338
339fn undo(tour: &mut Tour) -> Result<()> {
340 tour.banner("1/2 - rewrite the stack");
341 tour.say("Stack-rewriting commands - restack, sync, merge, cleanup, rename -");
342 tour.say("snapshot the stack before they touch it. Start with two branches:");
343 tour.stk(&["new", "feature/api"])?;
344 tour.commit("api.txt", "endpoints\n", "add api")?;
345 tour.stk(&["new", "feature/web"])?;
346 tour.commit("web.txt", "pages\n", "add web")?;
347 tour.say("Feedback lands on the bottom branch, leaving the child behind it:");
348 tour.stk(&["down"])?;
349 tour.commit("api.txt", "endpoints\nauth\n", "add auth")?;
350 tour.say("`restack` replays feature/web onto the rewritten feature/api - a real");
351 tour.say("rewrite of its commit:");
352 tour.stk(&["restack"])?;
353 if tour.pause()?.stop() {
354 return Ok(());
355 }
356
357 tour.banner("2/2 - take it back");
358 tour.say("Changed your mind? `undo` reverses the last stack-rewriting command,");
359 tour.say("restoring every branch tip and the stack metadata from that snapshot:");
360 tour.stk(&["undo"])?;
361 tour.say("feature/web is back exactly where it was. `undo` is one level deep and");
362 tour.say("one-shot - a second one has nothing left to restore:");
363 tour.stk_fails(&["undo"])?;
364 tour.say("And it is local only: pushes and already-merged reviews are never");
365 tour.say("reverted - `undo` touches branch tips and metadata, nothing remote.");
366 tour.finish()
367}
368
369struct Tour<'a> {
374 sandbox: &'a Path,
375 topic: &'a str,
376 term: Term,
377 title: String,
378 lines: Vec<String>,
379}
380
381enum Flow {
383 Continue,
384 Stop,
385}
386
387impl Flow {
388 fn stop(&self) -> bool {
389 matches!(self, Self::Stop)
390 }
391}
392
393impl<'a> Tour<'a> {
394 fn new(sandbox: &'a Path, topic: &'a str) -> Self {
395 Self {
396 sandbox,
397 topic,
398 term: Term::stdout(),
399 title: String::new(),
400 lines: Vec::new(),
401 }
402 }
403
404 fn banner(&mut self, title: &str) {
407 self.title = title.to_owned();
408 self.lines.clear();
409 }
410
411 fn say(&mut self, line: &str) {
413 self.lines.push(style::dim(line));
414 }
415
416 fn note(&mut self, command: &str) {
419 self.lines.push(format!("{} {command}", style::dim("$")));
420 }
421
422 fn stk(&mut self, args: &[&str]) -> Result<()> {
425 let output = self.run_stk(args)?;
426 if !output.status.success() {
427 bail!("`git stk {}` failed in the sandbox", args.join(" "));
428 }
429 Ok(())
430 }
431
432 fn stk_fails(&mut self, args: &[&str]) -> Result<()> {
434 let output = self.run_stk(args)?;
435 if output.status.success() {
436 bail!(
437 "`git stk {}` was expected to stop on the conflict",
438 args.join(" ")
439 );
440 }
441 Ok(())
442 }
443
444 fn run_stk(&mut self, args: &[&str]) -> Result<Output> {
445 self.note(&format!("git stk {}", args.join(" ")));
446 let binary = env::current_exe().context("failed to locate the running binary")?;
447 let output = capture(self.sandbox, &binary, args)?;
448 self.absorb_output(&output);
449 Ok(output)
450 }
451
452 fn show_git(&mut self, display: &str, args: &[&str]) -> Result<()> {
454 self.note(display);
455 let output = capture(self.sandbox, OsStr::new("git"), args)?;
456 self.absorb_output(&output);
457 if !output.status.success() {
458 bail!("`{display}` failed in the sandbox");
459 }
460 Ok(())
461 }
462
463 fn commit(&mut self, file: &str, contents: &str, message: &str) -> Result<()> {
465 self.note(&format!("edit {file}, then git commit -m {message:?}"));
466 fs::write(self.sandbox.join(file), contents).context("failed to write sandbox file")?;
467 run_git(self.sandbox, &["add", file])?;
468 run_git(self.sandbox, &["commit", "-q", "-m", message])
469 }
470
471 fn edit_and_add(&mut self, file: &str, contents: &str) -> Result<()> {
474 self.note(&format!("edit {file}, then git add {file}"));
475 fs::write(self.sandbox.join(file), contents).context("failed to write sandbox file")?;
476 run_git(self.sandbox, &["add", file])
477 }
478
479 fn absorb_output(&mut self, output: &Output) {
481 for stream in [&output.stdout, &output.stderr] {
482 let text = String::from_utf8_lossy(stream);
483 let text = text.trim_end_matches(['\n', '\r']);
484 if text.is_empty() {
485 continue;
486 }
487 for line in text.split('\n') {
488 self.lines.push(line.trim_end_matches('\r').to_owned());
489 }
490 }
491 self.lines.push(String::new());
492 }
493
494 fn pause(&mut self) -> Result<Flow> {
496 self.present("j/k/up/down scroll - space/pgdn page - enter continue - q quit")
497 }
498
499 fn finish(&mut self) -> Result<()> {
501 self.present("j/k/up/down scroll - enter/q to finish")?;
502 Ok(())
503 }
504
505 fn present(&mut self, hint: &str) -> Result<Flow> {
508 self.term.hide_cursor().ok();
509 self.term.clear_screen().ok();
510
511 let mut scroll = 0usize;
512 let mut read_errors = 0u32;
513 let flow = loop {
514 let (rows, cols) = self.term.size();
515 let (rows, cols) = (rows as usize, cols as usize);
516 let body = rows.saturating_sub(2).max(1);
517 let max_scroll = self.lines.len().saturating_sub(body);
518 scroll = scroll.min(max_scroll);
519 self.draw(scroll, cols, body, hint);
520
521 match self.term.read_key() {
522 Ok(key) => {
523 read_errors = 0;
524 match key {
525 Key::ArrowDown | Key::Char('j') => scroll = (scroll + 1).min(max_scroll),
526 Key::ArrowUp | Key::Char('k') => scroll = scroll.saturating_sub(1),
527 Key::PageDown | Key::Char(' ') => scroll = (scroll + body).min(max_scroll),
528 Key::PageUp => scroll = scroll.saturating_sub(body),
529 Key::Home | Key::Char('g') => scroll = 0,
530 Key::End | Key::Char('G') => scroll = max_scroll,
531 Key::Enter => break Flow::Continue,
532 Key::Char('q') | Key::Escape | Key::CtrlC => break Flow::Stop,
533 _ => {}
534 }
535 }
536 Err(_) => {
540 read_errors += 1;
541 if read_errors >= 3 {
542 break Flow::Stop;
543 }
544 }
545 }
546 };
547
548 self.term.show_cursor().ok();
549 self.term.clear_screen().ok();
550 Ok(flow)
551 }
552
553 fn draw(&self, scroll: usize, cols: usize, body: usize, hint: &str) {
557 let bar = Style::new().invert();
558 let header = format!("{} - {}", self.topic, self.title);
559 let mut frame = style::paint(bar, &fit(&format!(" {header}"), cols));
560
561 for row in 0..body {
562 frame.push('\n');
563 let line = self.lines.get(scroll + row).map_or("", String::as_str);
564 frame.push_str(&fit(line, cols));
565 }
566
567 let scrollable = self.lines.len() > body;
568 let footer = if scrollable {
569 format!(
570 " {hint} [{}/{}]",
571 (scroll + body).min(self.lines.len()),
572 self.lines.len()
573 )
574 } else {
575 format!(" {hint}")
576 };
577 frame.push('\n');
578 frame.push_str(&style::paint(bar, &fit(&footer, cols)));
579
580 self.term.move_cursor_to(0, 0).ok();
583 print!("{frame}");
584 let _ = std::io::stdout().flush();
585 }
586}
587
588fn fit(line: &str, width: usize) -> String {
590 let truncated = truncate_str(line, width, "…");
591 pad_str(&truncated, width, Alignment::Left, None).into_owned()
592}
593
594fn setup_sandbox(sandbox: &Path) -> Result<()> {
595 fs::create_dir_all(sandbox).context("failed to create the sandbox")?;
596 run_git(sandbox, &["init", "-q", "-b", "main"])?;
597 run_git(sandbox, &["config", "user.email", "guide@git-stk.dev"])?;
598 run_git(sandbox, &["config", "user.name", "git-stk guide"])?;
599 run_git(sandbox, &["config", "stk.provider", "demo"])?;
600 run_git(sandbox, &["config", "stk.noUpdateCheck", "true"])?;
601 fs::write(sandbox.join("README.md"), "# guide sandbox\n").context("failed to seed sandbox")?;
602 run_git(sandbox, &["add", "README.md"])?;
603 run_git(sandbox, &["commit", "-q", "-m", "initial commit"])?;
604 Ok(())
605}
606
607fn capture(sandbox: &Path, program: impl AsRef<OsStr>, args: &[&str]) -> Result<Output> {
610 let program = program.as_ref();
611 isolated(Command::new(program).args(args).current_dir(sandbox))
612 .env("CLICOLOR_FORCE", "1")
613 .stdin(Stdio::null())
614 .output()
615 .with_context(|| format!("failed to run {} in the sandbox", program.to_string_lossy()))
616}
617
618fn run_git(sandbox: &Path, args: &[&str]) -> Result<()> {
620 let status = isolated(Command::new("git").args(args).current_dir(sandbox))
621 .status()
622 .context("failed to run git in the sandbox")?;
623 if !status.success() {
624 bail!("`git {}` failed in the sandbox", args.join(" "));
625 }
626 Ok(())
627}
628
629fn isolated(command: &mut Command) -> &mut Command {
632 command
633 .env("GIT_CONFIG_GLOBAL", nul_device())
634 .env("GIT_CONFIG_NOSYSTEM", "1")
635 .env("GIT_EDITOR", "true")
636}
637
638fn nul_device() -> PathBuf {
639 if cfg!(windows) {
640 PathBuf::from("NUL")
641 } else {
642 PathBuf::from("/dev/null")
643 }
644}
645
646fn banner(title: &str) {
647 anstream::println!("{}", style::paint(style::CURRENT, title));
648}
649
650fn say(line: &str) {
651 anstream::println!("{}", style::paint(style::DIM, line));
652}
653
654#[cfg(test)]
655mod tests {
656 use super::fit;
657 use console::measure_text_width;
658
659 #[test]
660 fn fit_pads_short_lines_to_exact_width() {
661 let fitted = fit("ab", 5);
662 assert_eq!(fitted, "ab ");
663 assert_eq!(measure_text_width(&fitted), 5);
664 }
665
666 #[test]
667 fn fit_truncates_long_lines_to_exact_width() {
668 let fitted = fit("abcdefghij", 4);
669 assert_eq!(measure_text_width(&fitted), 4);
670 assert!(fitted.ends_with('…'));
671 }
672
673 #[test]
674 fn fit_measures_width_ignoring_ansi() {
675 let fitted = fit("\x1b[31mred\x1b[0m", 6);
677 assert_eq!(measure_text_width(&fitted), 6);
678 assert!(fitted.contains("\x1b[31m"));
679 }
680}