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