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