Skip to main content

git_stk/commands/
guide.rs

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
18/// The available tours: (topic, menu description, runner).
19const 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        "worktrees",
45        "a branch per worktree: the flows and the gotchas",
46        worktrees,
47    ),
48];
49
50/// Walk the stacked workflow in a disposable sandbox repository.
51#[derive(Debug, clap::Args)]
52pub struct Guide {
53    /// Which tour to run; omit for a menu.
54    #[arg(value_parser = clap::builder::PossibleValuesParser::new(["intro", "conflicts", "repair", "absorb", "adopt", "split", "undo", "worktrees"]))]
55    topic: Option<String>,
56}
57
58impl Run for Guide {
59    fn run(self) -> Result<()> {
60        guide(self.topic.as_deref())
61    }
62}
63
64fn guide(topic: Option<&str>) -> Result<()> {
65    if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
66        bail!("the guide is interactive; run it from a terminal");
67    }
68
69    banner("git stk guide");
70    say("Short interactive tours. Everything happens in a disposable sandbox");
71    say("repository - your real work is never touched, and a built-in demo");
72    say("provider stands in for GitHub: same commands, no network.");
73    say("Each step opens full-screen; scroll with j/k or the arrows, Enter to");
74    say("move on, q to quit.");
75    println!();
76
77    let chosen = match topic {
78        Some(topic) => TOPICS
79            .iter()
80            .find(|(name, _, _)| *name == topic)
81            .context("unknown guide topic")?,
82        None => {
83            let items: Vec<String> = TOPICS
84                .iter()
85                .map(|(name, blurb, _)| format!("{name} - {blurb}"))
86                .collect();
87            let index = Select::with_theme(&ColorfulTheme::default())
88                .with_prompt("which tour?")
89                .items(&items)
90                .default(0)
91                .interact()
92                .context("nothing chosen")?;
93            &TOPICS[index]
94        }
95    };
96    println!();
97
98    let sandbox = env::temp_dir().join(format!("git-stk-guide-{}", std::process::id()));
99    if sandbox.exists() {
100        fs::remove_dir_all(&sandbox).context("failed to clear an old sandbox")?;
101    }
102    say(&format!("sandbox: {}", sandbox.display()));
103    println!();
104    setup_sandbox(&sandbox)?;
105
106    let mut tour = Tour::new(&sandbox, chosen.0);
107    let finished = (chosen.2)(&mut tour);
108
109    // Hand the sandbox over or clean it up, whether or not the tour ran dry.
110    let delete = Confirm::with_theme(&ColorfulTheme::default())
111        .with_prompt("delete the sandbox?")
112        .default(true)
113        .interact()
114        .unwrap_or(true);
115    // The worktrees tour creates linked worktrees, which live beside the
116    // sandbox rather than inside it - so removing the sandbox alone would leave
117    // them behind.
118    let worktrees = sibling_worktree_dir(&sandbox);
119    if delete {
120        fs::remove_dir_all(&sandbox).context("failed to remove the sandbox")?;
121        if worktrees.exists() {
122            fs::remove_dir_all(&worktrees).context("failed to remove the sandbox worktrees")?;
123        }
124        say("sandbox removed");
125    } else {
126        say(&format!("kept: cd {}", sandbox.display()));
127        if worktrees.exists() {
128            say(&format!("its worktrees: {}", worktrees.display()));
129        }
130        say("it uses `git config stk.provider demo`, so every command works offline");
131    }
132
133    finished
134}
135
136fn intro(tour: &mut Tour) -> Result<()> {
137    tour.banner("1/5 - a stack is just branches");
138    tour.say("Each branch carries one reviewable change and knows its parent.");
139    tour.say("`new` creates a child of wherever you stand:");
140    tour.stk(&["new", "feature/login"])?;
141    tour.commit("login.txt", "username + password form\n", "add login form")?;
142    tour.stk(&["new", "feature/avatar"])?;
143    tour.commit("avatar.txt", "round avatars\n", "add avatars")?;
144    tour.say("Two branches, stacked. `list` draws the pile, trunk at the bottom:");
145    tour.stk(&["list"])?;
146    if tour.pause()?.stop() {
147        return Ok(());
148    }
149
150    tour.banner("2/5 - submit the whole stack");
151    tour.say("One command opens (or updates) a review per branch, parent-first,");
152    tour.say("and writes a live stack overview into every description:");
153    tour.stk(&["submit", "--stack"])?;
154    tour.stk(&["status"])?;
155    if tour.pause()?.stop() {
156        return Ok(());
157    }
158
159    tour.banner("3/5 - parents move; restack follows");
160    tour.say("Review feedback lands on the bottom branch:");
161    tour.stk(&["down"])?;
162    tour.commit(
163        "login.txt",
164        "username + password form\nremember me\n",
165        "add remember me",
166    )?;
167    tour.say("The child is now behind its parent - `list` notices:");
168    tour.stk(&["list"])?;
169    tour.say("`restack` rebases every descendant back onto its parent:");
170    tour.stk(&["restack"])?;
171    tour.stk(&["top"])?;
172    if tour.pause()?.stop() {
173        return Ok(());
174    }
175
176    tour.banner("4/5 - land the stack");
177    tour.say("`merge --all` repeats merge-bottom-then-sync until the stack is");
178    tour.say("complete: children retarget, merged branches vanish, the overview");
179    tour.say("in every review restyles as history accumulates:");
180    tour.stk(&["merge", "--all", "-y"])?;
181    if tour.pause()?.stop() {
182        return Ok(());
183    }
184
185    tour.banner("5/5 - nothing left but trunk");
186    tour.stk(&["list"])?;
187    tour.say("That is the whole loop: new -> commit -> submit -> merge.");
188    tour.say("On a real repo the provider is detected from your remote; day to day");
189    tour.say("you mostly run `git stk new`, `git stk submit --stack`, and");
190    tour.say("`git stk merge --all`. `git stk status` and the hints fill the gaps.");
191    tour.finish()
192}
193
194fn conflicts(tour: &mut Tour) -> Result<()> {
195    tour.banner("1/3 - set up a collision");
196    tour.say("A two-branch stack where both branches touch the same line:");
197    tour.stk(&["new", "feature/payment"])?;
198    tour.commit("notes.txt", "use stripe\n", "choose payment provider")?;
199    tour.stk(&["new", "feature/receipts"])?;
200    tour.commit("notes.txt", "use stripe with receipts\n", "email receipts")?;
201    tour.say("Now the parent changes its mind about that very line:");
202    tour.stk(&["down"])?;
203    tour.commit("notes.txt", "use paypal\n", "switch to paypal")?;
204    if tour.pause()?.stop() {
205        return Ok(());
206    }
207
208    tour.banner("2/3 - the restack stops, with context");
209    tour.say("Replaying the child onto the rewritten parent cannot succeed; the");
210    tour.say("restack stops, shows git's conflict output, and says what to do:");
211    tour.stk_fails(&["restack"])?;
212    if tour.pause()?.stop() {
213        return Ok(());
214    }
215
216    tour.banner("3/3 - resolve, then continue");
217    tour.say("Fix the file and stage it, exactly like any rebase conflict:");
218    tour.edit_and_add("notes.txt", "use paypal with receipts\n")?;
219    tour.say("`continue` picks the restack back up where it stopped");
220    tour.say("(`git stk abort` would have unwound it instead):");
221    tour.stk(&["continue"])?;
222    tour.stk(&["list"])?;
223    tour.say("Conflicts interrupt the restack, never break it: resolve, continue,");
224    tour.say("and the rest of the stack follows.");
225    tour.finish()
226}
227
228fn repair(tour: &mut Tour) -> Result<()> {
229    tour.banner("1/3 - a healthy stack");
230    tour.stk(&["new", "feature/api"])?;
231    tour.commit("api.txt", "endpoints\n", "add api")?;
232    tour.stk(&["new", "feature/ui"])?;
233    tour.commit("ui.txt", "buttons\n", "add ui")?;
234    tour.stk(&["submit", "--stack"])?;
235    if tour.pause()?.stop() {
236        return Ok(());
237    }
238
239    tour.banner("2/3 - the metadata vanishes");
240    tour.say("Stack parents are plain `branch.<name>.stkParent` entries in");
241    tour.say(".git/config - annotations, not state. Suppose one gets lost:");
242    tour.note("git config --unset branch.feature/ui.stkParent");
243    run_git(
244        tour.sandbox,
245        &["config", "--unset", "branch.feature/ui.stkParent"],
246    )?;
247    tour.say("The stack no longer knows feature/ui belongs to it:");
248    tour.stk(&["list"])?;
249    if tour.pause()?.stop() {
250        return Ok(());
251    }
252
253    tour.banner("3/3 - repair rebuilds it");
254    tour.say("`repair` re-derives parents from review bases (when a provider is");
255    tour.say("reachable) and branch ancestry, and verifies recorded fork points:");
256    tour.stk(&["repair", "--dry-run"])?;
257    tour.stk(&["repair"])?;
258    tour.stk(&["list"])?;
259    tour.say("Branches are the real state; metadata is always recoverable.");
260    tour.say("Anything repair cannot resolve safely, it reports for a manual");
261    tour.say("`git stk adopt`.");
262    tour.finish()
263}
264
265fn absorb(tour: &mut Tour) -> Result<()> {
266    tour.banner("1/3 - fixes scattered across the stack");
267    tour.say("A two-branch stack, each branch owning one file:");
268    tour.stk(&["new", "feature/login"])?;
269    tour.commit("login.txt", "username + password form\n", "add login form")?;
270    tour.stk(&["new", "feature/avatar"])?;
271    tour.commit("avatar.txt", "round avatars\n", "add avatars")?;
272    tour.say("Review comes back: two small fixes, one on each branch's file.");
273    tour.say("You make both edits from the top and stage them, as usual:");
274    tour.edit_and_add("login.txt", "username + password form, with 2FA\n")?;
275    tour.edit_and_add("avatar.txt", "round avatars, lazy-loaded\n")?;
276    tour.say("Both fixes sit staged together, but each belongs to a different commit");
277    tour.say("further down the stack:");
278    tour.stk(&["status"])?;
279    if tour.pause()?.stop() {
280        return Ok(());
281    }
282
283    tour.banner("2/3 - preview where each hunk lands");
284    tour.say("`absorb` blames every staged hunk and routes it to the commit that");
285    tour.say("introduced the lines it touches. `--dry-run` shows the plan first:");
286    tour.stk(&["absorb", "--dry-run"])?;
287    if tour.pause()?.stop() {
288        return Ok(());
289    }
290
291    tour.banner("3/3 - fold them in");
292    tour.say("Run it for real: each fix becomes a `fixup!` of its owning commit, an");
293    tour.say("autosquash rebase folds them in, and every branch ref rides along:");
294    tour.stk(&["absorb"])?;
295    tour.say("The history reads as if the fixes were always there - no extra commits:");
296    tour.show_git(
297        "git log --oneline main..feature/avatar",
298        &[
299            "--no-pager",
300            "-c",
301            "color.ui=always",
302            "log",
303            "--oneline",
304            "main..feature/avatar",
305        ],
306    )?;
307    tour.say("Hunks that cannot be attributed - brand-new lines, trunk-owned lines, a");
308    tour.say("hunk spanning two commits - are left staged and reported, never guessed.");
309    tour.finish()
310}
311
312fn adopt(tour: &mut Tour) -> Result<()> {
313    tour.banner("1/3 - adopt a hand-made branch");
314    tour.say("Not every branch begins with `git stk new`. Suppose you branched off");
315    tour.say("the trunk by hand and did some work:");
316    tour.note("git switch -c feature/logging");
317    run_git(tour.sandbox, &["switch", "-c", "feature/logging"])?;
318    tour.commit("logging.txt", "structured logs\n", "add logging")?;
319    tour.say("git-stk has no metadata for it yet. `adopt` records its parent -");
320    tour.say("metadata only, nothing is rewritten - folding it into a stack:");
321    tour.stk(&["adopt", "--parent", "main"])?;
322    tour.stk(&["list"])?;
323    if tour.pause()?.stop() {
324        return Ok(());
325    }
326
327    tour.banner("2/3 - move a branch onto another");
328    tour.say("Two branches, each started independently off the trunk:");
329    tour.note("git switch main");
330    run_git(tour.sandbox, &["switch", "main"])?;
331    tour.stk(&["new", "feature/api"])?;
332    tour.commit("api.txt", "endpoints\n", "add api")?;
333    tour.note("git switch main");
334    run_git(tour.sandbox, &["switch", "main"])?;
335    tour.stk(&["new", "feature/web"])?;
336    tour.commit("web.txt", "pages\n", "add web")?;
337    tour.say("`list` shows them as siblings on the trunk:");
338    tour.stk(&["list", "--all"])?;
339    tour.say("But feature/web really belongs on top of feature/api. Re-point its");
340    tour.say("parent with `adopt`, then `restack` replays its commits onto the new");
341    tour.say("base (only its own commits move; the parent's are already there):");
342    tour.stk(&["adopt", "--parent", "feature/api"])?;
343    tour.stk(&["restack"])?;
344    tour.stk(&["list"])?;
345    if tour.pause()?.stop() {
346        return Ok(());
347    }
348
349    tour.banner("3/3 - detach: the inverse");
350    tour.say("`detach` drops a branch's stack metadata, leaving the branch and its");
351    tour.say("commits untouched - handy when something was adopted by mistake:");
352    tour.stk(&["detach", "feature/web"])?;
353    tour.stk(&["list", "--all"])?;
354    tour.say("feature/web still exists; git-stk just no longer tracks it. Re-`adopt`");
355    tour.say("it onto any parent whenever you want it back in a stack.");
356    tour.finish()
357}
358
359fn worktrees(tour: &mut Tour) -> Result<()> {
360    tour.banner("1/4 - a branch in a worktree of its own");
361    tour.say("A git worktree is a second checkout of the same repository, on its");
362    tour.say("own branch. `new --worktree` makes one instead of checking the branch");
363    tour.say("out here - so you keep whatever you were doing:");
364    tour.stk(&["new", "feature/api"])?;
365    tour.commit("api.txt", "endpoints\n", "add api")?;
366    tour.stk(&["new", "feature/web", "--worktree"])?;
367    tour.say("Note what did *not* happen: we are still on feature/api. The new");
368    tour.say("branch lives somewhere else entirely.");
369    tour.show_git("git branch --show-current", &["branch", "--show-current"])?;
370    tour.say("`list` says where each branch lives, so the stack is still one view:");
371    tour.stk(&["list", "--local"])?;
372    if tour.pause()?.stop() {
373        return Ok(());
374    }
375
376    tour.banner("2/4 - gotcha: a branch can only be checked out once");
377    tour.say("Git refuses to check out a branch a second worktree already holds.");
378    tour.say("That is the central worktree gotcha, and it makes `up` impossible -");
379    tour.say("moving up the stack is now a `cd`, not a checkout:");
380    tour.stk_fails(&["up"])?;
381    tour.say("So navigation can print where to go instead, and let the shell move:");
382    tour.note("cd \"$(git stk up --from-path)\"");
383    tour.say("Put that in a shell function and `up`/`down`/`top`/`bottom` follow the");
384    tour.say("branch wherever it lives - into a worktree, or an ordinary checkout");
385    tour.say("when it is here. The README has the snippet.");
386    if tour.pause()?.stop() {
387        return Ok(());
388    }
389
390    tour.banner("3/4 - gotcha: rewrites refuse up front");
391    tour.say("Git will not rebase a branch another worktree holds either. So when a");
392    tour.say("parent moves and a held branch would need replaying:");
393    tour.commit("api.txt", "endpoints\nauth\n", "add auth")?;
394    tour.say("`restack` refuses before touching anything, rather than rewriting half");
395    tour.say("the stack and failing in the middle:");
396    tour.stk_fails(&["restack"])?;
397    tour.say("Nothing was rewritten. Hand the branch back with");
398    tour.say("`git -C <path> checkout --detach`, then try again - that works even when");
399    tour.say("the holder is your main worktree, which cannot be removed. `undo` and");
400    tour.say("`cleanup` are just as");
401    tour.say("careful: undo refuses rather than rewinding a branch under a worktree");
402    tour.say("that would not notice, and cleanup keeps such a branch and moves on");
403    tour.say("instead of failing the whole run.");
404    if tour.pause()?.stop() {
405        return Ok(());
406    }
407
408    tour.banner("4/4 - run, and who owns a worktree");
409    tour.say("`run` uses a throwaway worktree of its own, so checking the stack");
410    tour.say("never moves your HEAD and never needs a clean tree:");
411    tour.edit_and_add("api.txt", "endpoints\nauth\nwork in progress\n")?;
412    tour.say("Uncommitted work, and `run` still walks every branch:");
413    tour.stk(&["run", "--", "git", "log", "--oneline", "-1"])?;
414    tour.say("The catch: a fresh worktree has no untracked build output - no");
415    tour.say("node_modules, no target/ - so a command that needs those may fail");
416    tour.say("there having passed in your checkout. `--no-worktree` runs the old way.");
417    tour.say("");
418    tour.say("Finally, ownership. git-stk removes the worktrees *it* created (from");
419    tour.say("`new --worktree`) once their branch lands, and never touches one you");
420    tour.say("made by hand - nor an owned one with uncommitted work still in it.");
421    tour.finish()
422}
423
424fn undo(tour: &mut Tour) -> Result<()> {
425    tour.banner("1/2 - rewrite the stack");
426    tour.say("Stack-rewriting commands snapshot the stack before they touch it,");
427    tour.say("so git stk undo can put it back. Start with two branches:");
428    tour.stk(&["new", "feature/api"])?;
429    tour.commit("api.txt", "endpoints\n", "add api")?;
430    tour.stk(&["new", "feature/web"])?;
431    tour.commit("web.txt", "pages\n", "add web")?;
432    tour.say("Feedback lands on the bottom branch, leaving the child behind it:");
433    tour.stk(&["down"])?;
434    tour.commit("api.txt", "endpoints\nauth\n", "add auth")?;
435    tour.say("`restack` replays feature/web onto the rewritten feature/api - a real");
436    tour.say("rewrite of its commit:");
437    tour.stk(&["restack"])?;
438    if tour.pause()?.stop() {
439        return Ok(());
440    }
441
442    tour.banner("2/2 - take it back");
443    tour.say("Changed your mind? `undo` reverses the last stack-rewriting command,");
444    tour.say("restoring every branch tip and the stack metadata from that snapshot:");
445    tour.stk(&["undo"])?;
446    tour.say("feature/web is back exactly where it was. `undo` is one level deep and");
447    tour.say("one-shot - a second one has nothing left to restore:");
448    tour.stk_fails(&["undo"])?;
449    tour.say("And it is local only: pushes and already-merged reviews are never");
450    tour.say("reverted - `undo` touches branch tips and metadata, nothing remote.");
451    tour.finish()
452}
453
454fn split(tour: &mut Tour) -> Result<()> {
455    tour.banner("1/3 - a pile of commits on one branch");
456    tour.say("Sometimes you commit several changes on one branch before carving");
457    tour.say("them into reviewable pieces. Start with three commits:");
458    tour.stk(&["new", "feature/checkout"])?;
459    tour.commit("cart.txt", "cart model\n", "add cart model")?;
460    tour.commit("payment.txt", "stripe integration\n", "add payment")?;
461    tour.commit("receipt.txt", "email receipt\n", "send receipt")?;
462    tour.say("`list --commits` nests each branch's own commits, newest first, so");
463    tour.say("you can see the boundaries before splitting:");
464    tour.stk(&["list", "--commits"])?;
465    if tour.pause()?.stop() {
466        return Ok(());
467    }
468
469    tour.banner("2/3 - split into a stack");
470    tour.say("`split --per-commit` makes one branch per commit, bottom-up, named");
471    tour.say("from each subject. The original branch stays as the leaf, and nothing");
472    tour.say("is rewritten - the new branches just point at the existing commits:");
473    tour.stk(&["split", "--per-commit"])?;
474    tour.stk(&["list"])?;
475    tour.say("(Plain `git stk split` opens an interactive picker instead, to group");
476    tour.say("several commits into one branch.)");
477    if tour.pause()?.stop() {
478        return Ok(());
479    }
480
481    tour.banner("3/3 - tidy a name");
482    tour.say("Auto-slugged names are a fine start; `rename` cleans one up and keeps");
483    tour.say("the stack intact - children pointing at the old name are retargeted:");
484    tour.stk(&["rename", "add-cart-model", "feature/cart"])?;
485    tour.stk(&["list"])?;
486    tour.say("From here, `git stk submit --stack` opens one review per branch, in");
487    tour.say("order - the same as any other stack.");
488    tour.finish()
489}
490
491/// One full-screen step: a pinned title, a scrollable body of narration and
492/// captured command output, and a footer of scroll hints. The tour functions
493/// build a screen with `banner`/`say`/`stk`/..., then `pause` (or `finish`)
494/// renders it and waits for the reader.
495struct Tour<'a> {
496    sandbox: &'a Path,
497    topic: &'a str,
498    term: Term,
499    title: String,
500    lines: Vec<String>,
501}
502
503/// What the reader chose at a `pause`: move on, or quit the tour.
504enum Flow {
505    Continue,
506    Stop,
507}
508
509impl Flow {
510    fn stop(&self) -> bool {
511        matches!(self, Self::Stop)
512    }
513}
514
515impl<'a> Tour<'a> {
516    fn new(sandbox: &'a Path, topic: &'a str) -> Self {
517        Self {
518            sandbox,
519            topic,
520            term: Term::stdout(),
521            title: String::new(),
522            lines: Vec::new(),
523        }
524    }
525
526    /// Start a fresh screen with `title`. Does not render: content accrues
527    /// until the next `pause`/`finish`.
528    fn banner(&mut self, title: &str) {
529        self.title = title.to_owned();
530        self.lines.clear();
531    }
532
533    /// A line of narration.
534    fn say(&mut self, line: &str) {
535        self.lines.push(style::dim(line));
536    }
537
538    /// A shell-prompt line for a step we narrate but do not capture output
539    /// from (e.g. a manual `git config --unset`).
540    fn note(&mut self, command: &str) {
541        self.lines.push(format!("{} {command}", style::dim("$")));
542    }
543
544    /// Run `git stk <args>` in the sandbox, showing the command and its
545    /// output. Fails if the command does.
546    fn stk(&mut self, args: &[&str]) -> Result<()> {
547        let output = self.run_stk(args)?;
548        if !output.status.success() {
549            bail!("`git stk {}` failed in the sandbox", args.join(" "));
550        }
551        Ok(())
552    }
553
554    /// Like `stk`, for the step that is supposed to stop (the conflict).
555    fn stk_fails(&mut self, args: &[&str]) -> Result<()> {
556        let output = self.run_stk(args)?;
557        if output.status.success() {
558            bail!(
559                "`git stk {}` was expected to stop on the conflict",
560                args.join(" ")
561            );
562        }
563        Ok(())
564    }
565
566    fn run_stk(&mut self, args: &[&str]) -> Result<Output> {
567        self.note(&format!("git stk {}", args.join(" ")));
568        let binary = env::current_exe().context("failed to locate the running binary")?;
569        let output = capture(self.sandbox, &binary, args)?;
570        self.absorb_output(&output);
571        Ok(output)
572    }
573
574    /// Run a raw `git` command and show it under `display` with its output.
575    fn show_git(&mut self, display: &str, args: &[&str]) -> Result<()> {
576        self.note(display);
577        let output = capture(self.sandbox, OsStr::new("git"), args)?;
578        self.absorb_output(&output);
579        if !output.status.success() {
580            bail!("`{display}` failed in the sandbox");
581        }
582        Ok(())
583    }
584
585    /// Write `contents` to `file` and commit it, narrating the edit.
586    fn commit(&mut self, file: &str, contents: &str, message: &str) -> Result<()> {
587        self.note(&format!("edit {file}, then git commit -m {message:?}"));
588        fs::write(self.sandbox.join(file), contents).context("failed to write sandbox file")?;
589        run_git(self.sandbox, &["add", file])?;
590        run_git(self.sandbox, &["commit", "-q", "-m", message])
591    }
592
593    /// Write `contents` to `file` and stage it without committing - a review
594    /// fix, or a resolved conflict.
595    fn edit_and_add(&mut self, file: &str, contents: &str) -> Result<()> {
596        self.note(&format!("edit {file}, then git add {file}"));
597        fs::write(self.sandbox.join(file), contents).context("failed to write sandbox file")?;
598        run_git(self.sandbox, &["add", file])
599    }
600
601    /// Append a captured command's output, then a blank separator line.
602    fn absorb_output(&mut self, output: &Output) {
603        for stream in [&output.stdout, &output.stderr] {
604            let text = String::from_utf8_lossy(stream);
605            let text = text.trim_end_matches(['\n', '\r']);
606            if text.is_empty() {
607                continue;
608            }
609            for line in text.split('\n') {
610                self.lines.push(line.trim_end_matches('\r').to_owned());
611            }
612        }
613        self.lines.push(String::new());
614    }
615
616    /// Render the current screen and wait for the reader to move on or quit.
617    fn pause(&mut self) -> Result<Flow> {
618        self.present("j/k/up/down scroll - space/pgdn page - enter continue - q quit")
619    }
620
621    /// Render the final screen; enter or q both end the tour.
622    fn finish(&mut self) -> Result<()> {
623        self.present("j/k/up/down scroll - enter/q to finish")?;
624        Ok(())
625    }
626
627    /// The pager: draw the framed screen and scroll it until the reader
628    /// presses enter (continue) or q/esc (stop).
629    fn present(&mut self, hint: &str) -> Result<Flow> {
630        self.term.hide_cursor().ok();
631        self.term.clear_screen().ok();
632
633        let mut scroll = 0usize;
634        let mut read_errors = 0u32;
635        let flow = loop {
636            let (rows, cols) = self.term.size();
637            let (rows, cols) = (rows as usize, cols as usize);
638            let body = rows.saturating_sub(2).max(1);
639            let max_scroll = self.lines.len().saturating_sub(body);
640            scroll = scroll.min(max_scroll);
641            self.draw(scroll, cols, body, hint);
642
643            match self.term.read_key() {
644                Ok(key) => {
645                    read_errors = 0;
646                    match key {
647                        Key::ArrowDown | Key::Char('j') => scroll = (scroll + 1).min(max_scroll),
648                        Key::ArrowUp | Key::Char('k') => scroll = scroll.saturating_sub(1),
649                        Key::PageDown | Key::Char(' ') => scroll = (scroll + body).min(max_scroll),
650                        Key::PageUp => scroll = scroll.saturating_sub(body),
651                        Key::Home | Key::Char('g') => scroll = 0,
652                        Key::End | Key::Char('G') => scroll = max_scroll,
653                        Key::Enter => break Flow::Continue,
654                        Key::Char('q') | Key::Escape | Key::CtrlC => break Flow::Stop,
655                        _ => {}
656                    }
657                }
658                // A transient read error (e.g. a resize interrupting the read)
659                // should redraw and retry, not end the tour - but bail if they
660                // keep coming so a vanished terminal can't spin forever.
661                Err(_) => {
662                    read_errors += 1;
663                    if read_errors >= 3 {
664                        break Flow::Stop;
665                    }
666                }
667            }
668        };
669
670        self.term.show_cursor().ok();
671        self.term.clear_screen().ok();
672        Ok(flow)
673    }
674
675    /// Compose and paint one frame: header bar, `body` rows of content from
676    /// `scroll`, and a footer bar. Every row is exactly `cols` wide so each
677    /// frame fully overwrites the last.
678    fn draw(&self, scroll: usize, cols: usize, body: usize, hint: &str) {
679        let bar = Style::new().invert();
680        let header = format!("{} - {}", self.topic, self.title);
681        let mut frame = style::paint(bar, &fit(&format!(" {header}"), cols));
682
683        for row in 0..body {
684            frame.push('\n');
685            let line = self.lines.get(scroll + row).map_or("", String::as_str);
686            frame.push_str(&fit(line, cols));
687        }
688
689        let scrollable = self.lines.len() > body;
690        let footer = if scrollable {
691            format!(
692                " {hint}   [{}/{}]",
693                (scroll + body).min(self.lines.len()),
694                self.lines.len()
695            )
696        } else {
697            format!(" {hint}")
698        };
699        frame.push('\n');
700        frame.push_str(&style::paint(bar, &fit(&footer, cols)));
701
702        // Best effort: a failed frame must not abort the loop, or the cursor
703        // restore at the end of `present` would be skipped.
704        self.term.move_cursor_to(0, 0).ok();
705        print!("{frame}");
706        let _ = std::io::stdout().flush();
707    }
708}
709
710/// Truncate (ANSI-aware) to `width`, then pad with spaces to exactly `width`.
711fn fit(line: &str, width: usize) -> String {
712    let truncated = truncate_str(line, width, "…");
713    pad_str(&truncated, width, Alignment::Left, None).into_owned()
714}
715
716/// Where `new --worktree` puts the sandbox's worktrees by default: a
717/// `<sandbox>-worktrees` sibling. Mirrors `settings::worktree_dir`.
718fn sibling_worktree_dir(sandbox: &Path) -> PathBuf {
719    let name = sandbox
720        .file_name()
721        .map(|name| name.to_string_lossy().into_owned())
722        .unwrap_or_else(|| "sandbox".to_owned());
723    sandbox
724        .parent()
725        .unwrap_or(sandbox)
726        .join(format!("{name}-worktrees"))
727}
728
729fn setup_sandbox(sandbox: &Path) -> Result<()> {
730    fs::create_dir_all(sandbox).context("failed to create the sandbox")?;
731    run_git(sandbox, &["init", "-q", "-b", "main"])?;
732    run_git(sandbox, &["config", "user.email", "guide@git-stk.dev"])?;
733    run_git(sandbox, &["config", "user.name", "git-stk guide"])?;
734    run_git(sandbox, &["config", "stk.provider", "demo"])?;
735    run_git(sandbox, &["config", "stk.noUpdateCheck", "true"])?;
736    fs::write(sandbox.join("README.md"), "# guide sandbox\n").context("failed to seed sandbox")?;
737    run_git(sandbox, &["add", "README.md"])?;
738    run_git(sandbox, &["commit", "-q", "-m", "initial commit"])?;
739    Ok(())
740}
741
742/// Run a command in the sandbox and capture its output, forcing color on so
743/// the captured lines look like a real terminal session.
744fn capture(sandbox: &Path, program: impl AsRef<OsStr>, args: &[&str]) -> Result<Output> {
745    let program = program.as_ref();
746    isolated(Command::new(program).args(args).current_dir(sandbox))
747        .env("CLICOLOR_FORCE", "1")
748        .stdin(Stdio::null())
749        .output()
750        .with_context(|| format!("failed to run {} in the sandbox", program.to_string_lossy()))
751}
752
753/// Run a `git` command in the sandbox for its effect, discarding output.
754fn run_git(sandbox: &Path, args: &[&str]) -> Result<()> {
755    let status = isolated(Command::new("git").args(args).current_dir(sandbox))
756        .status()
757        .context("failed to run git in the sandbox")?;
758    if !status.success() {
759        bail!("`git {}` failed in the sandbox", args.join(" "));
760    }
761    Ok(())
762}
763
764/// The user's global git config (e.g. stk.pushOnSubmit) must not leak into
765/// the tour.
766fn isolated(command: &mut Command) -> &mut Command {
767    command
768        .env("GIT_CONFIG_GLOBAL", nul_device())
769        .env("GIT_CONFIG_NOSYSTEM", "1")
770        .env("GIT_EDITOR", "true")
771}
772
773fn nul_device() -> PathBuf {
774    if cfg!(windows) {
775        PathBuf::from("NUL")
776    } else {
777        PathBuf::from("/dev/null")
778    }
779}
780
781fn banner(title: &str) {
782    anstream::println!("{}", style::paint(style::CURRENT, title));
783}
784
785fn say(line: &str) {
786    anstream::println!("{}", style::paint(style::DIM, line));
787}
788
789#[cfg(test)]
790mod tests {
791    use super::fit;
792    use console::measure_text_width;
793
794    #[test]
795    fn fit_pads_short_lines_to_exact_width() {
796        let fitted = fit("ab", 5);
797        assert_eq!(fitted, "ab   ");
798        assert_eq!(measure_text_width(&fitted), 5);
799    }
800
801    #[test]
802    fn fit_truncates_long_lines_to_exact_width() {
803        let fitted = fit("abcdefghij", 4);
804        assert_eq!(measure_text_width(&fitted), 4);
805        assert!(fitted.ends_with('…'));
806    }
807
808    #[test]
809    fn fit_measures_width_ignoring_ansi() {
810        // Three visible chars wrapped in color codes, padded to width 6.
811        let fitted = fit("\x1b[31mred\x1b[0m", 6);
812        assert_eq!(measure_text_width(&fitted), 6);
813        assert!(fitted.contains("\x1b[31m"));
814    }
815}