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 "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#[derive(Debug, clap::Args)]
57pub struct Guide {
58 #[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 let delete = Confirm::with_theme(&ColorfulTheme::default())
122 .with_prompt("delete the sandbox?")
123 .default(true)
124 .interact()
125 .unwrap_or(true);
126 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
450fn 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.");
495 tour.say("");
496 tour.say("`list` marks each layer with its place in GitHub's stack - `⛁2/3` -");
497 tour.say("and `status` names the stack, so it is visible which of the two is in");
498 tour.say("charge of a given review.");
499 if tour.pause()?.stop() {
500 return Ok(());
501 }
502
503 tour.banner("3/5 - gotcha: your muscle memory, and other people's stacks");
504 tour.say("GitHub refuses both of these for a pull request in a stack:");
505 tour.note("gh pr merge <n>");
506 tour.note("gh pr edit <n> --base <branch>");
507 tour.say("That is GitHub's refusal, not git-stk's. Reach for either out of habit");
508 tour.say("and it fails - which is why `merge` and `submit` route around them.");
509 tour.say("");
510 tour.say("The second gotcha is the one worth remembering: *reading* a stack is");
511 tour.say("not gated on the setting. A teammate's `gh stack submit`, or the web");
512 tour.say("UI, can put your reviews in a stack - and everything on the previous");
513 tour.say("page then applies whether or not you turned anything on. Turning");
514 tour.say("`stk.githubStacks` off stops git-stk *creating* stacks; it does not");
515 tour.say("restore the old behaviour in a repo where one exists.");
516 if tour.pause()?.stop() {
517 return Ok(());
518 }
519
520 tour.banner("4/5 - gotcha: the shapes GitHub cannot express");
521 tour.say("Adding to a GitHub stack carries no position - it can only append. So");
522 tour.say("a stack can grow on top, and nothing else:");
523 tour.say("");
524 tour.say(" registered: #12 #13 submitted: #12 #13 #14 -> extended");
525 tour.say(" registered: #12 #13 submitted: #11 #12 #13 -> declined");
526 tour.say("");
527 tour.say("The second is an ordinary thing to do - `git stk new --prepend`, or");
528 tour.say("adopting the line onto a release branch - and the new review belongs");
529 tour.say("at the bottom. Appending it would record an order that is not your");
530 tour.say("stack's, and `repair` reads that order back as a parent that `restack`");
531 tour.say("then rebases and force-pushes against. So git-stk declines and says");
532 tour.say("the stack no longer matches. Reordering or removing a layer is the");
533 tour.say("same shape.");
534 tour.say("");
535 tour.say("The way through is to dissolve and re-register:");
536 tour.note("git stk unstack");
537 tour.note("git stk submit --stack");
538 tour.say("`unstack` leaves every review open and standalone - it takes apart the");
539 tour.say("stack, not the work. It asks first, because a stack is dissolved whole");
540 tour.say("and can reach reviews outside your line; `-y` skips the prompt.");
541 if tour.pause()?.stop() {
542 return Ok(());
543 }
544
545 tour.banner("5/5 - the loop, end to end");
546 tour.say("Nothing about the day-to-day changes. Build the stack as always:");
547 tour.note("git stk new feature/api # and again for each layer");
548 tour.note("git stk submit --stack # submits, then registers the stack");
549 tour.say("");
550 tour.say("Land it bottom-up. GitHub merges each layer and retargets the one");
551 tour.say("above onto the trunk; `sync` then cleans up locally:");
552 tour.note("git stk merge --all");
553 tour.say("");
554 tour.say("The stack stays open on GitHub with landed layers still listed, until");
555 tour.say("every layer has landed - so seeing a merged review in the stack map is");
556 tour.say("expected, not a leftover.");
557 tour.say("");
558 tour.say("Two smaller things. On GitHub Enterprise Server the fields `list`");
559 tour.say("reads for the `⛁` marker are not there yet, so the marker simply does");
560 tour.say("not appear - `git stk -v list` says why. And `git stk repair` prefers");
561 tour.say("GitHub's stack over guesswork when rebuilding parents: an order");
562 tour.say("someone stated beats one inferred from ancestry.");
563 tour.say("");
564 tour.say("`git stk guide intro` covers the stack itself, if you have not run it.");
565 tour.finish()
566}
567
568fn undo(tour: &mut Tour) -> Result<()> {
569 tour.banner("1/2 - rewrite the stack");
570 tour.say("Stack-rewriting commands snapshot the stack before they touch it,");
571 tour.say("so git stk undo can put it back. Start with two branches:");
572 tour.stk(&["new", "feature/api"])?;
573 tour.commit("api.txt", "endpoints\n", "add api")?;
574 tour.stk(&["new", "feature/web"])?;
575 tour.commit("web.txt", "pages\n", "add web")?;
576 tour.say("Feedback lands on the bottom branch, leaving the child behind it:");
577 tour.stk(&["down"])?;
578 tour.commit("api.txt", "endpoints\nauth\n", "add auth")?;
579 tour.say("`restack` replays feature/web onto the rewritten feature/api - a real");
580 tour.say("rewrite of its commit:");
581 tour.stk(&["restack"])?;
582 if tour.pause()?.stop() {
583 return Ok(());
584 }
585
586 tour.banner("2/2 - take it back");
587 tour.say("Changed your mind? `undo` reverses the last stack-rewriting command,");
588 tour.say("restoring every branch tip and the stack metadata from that snapshot:");
589 tour.stk(&["undo"])?;
590 tour.say("feature/web is back exactly where it was. `undo` is one level deep and");
591 tour.say("one-shot - a second one has nothing left to restore:");
592 tour.stk_fails(&["undo"])?;
593 tour.say("And it is local only: pushes and already-merged reviews are never");
594 tour.say("reverted - `undo` touches branch tips and metadata, nothing remote.");
595 tour.finish()
596}
597
598fn split(tour: &mut Tour) -> Result<()> {
599 tour.banner("1/3 - a pile of commits on one branch");
600 tour.say("Sometimes you commit several changes on one branch before carving");
601 tour.say("them into reviewable pieces. Start with three commits:");
602 tour.stk(&["new", "feature/checkout"])?;
603 tour.commit("cart.txt", "cart model\n", "add cart model")?;
604 tour.commit("payment.txt", "stripe integration\n", "add payment")?;
605 tour.commit("receipt.txt", "email receipt\n", "send receipt")?;
606 tour.say("`list --commits` nests each branch's own commits, newest first, so");
607 tour.say("you can see the boundaries before splitting:");
608 tour.stk(&["list", "--commits"])?;
609 if tour.pause()?.stop() {
610 return Ok(());
611 }
612
613 tour.banner("2/3 - split into a stack");
614 tour.say("`split --per-commit` makes one branch per commit, bottom-up, named");
615 tour.say("from each subject. The original branch stays as the leaf, and nothing");
616 tour.say("is rewritten - the new branches just point at the existing commits:");
617 tour.stk(&["split", "--per-commit"])?;
618 tour.stk(&["list"])?;
619 tour.say("(Plain `git stk split` opens an interactive picker instead, to group");
620 tour.say("several commits into one branch.)");
621 if tour.pause()?.stop() {
622 return Ok(());
623 }
624
625 tour.banner("3/3 - tidy a name");
626 tour.say("Auto-slugged names are a fine start; `rename` cleans one up and keeps");
627 tour.say("the stack intact - children pointing at the old name are retargeted:");
628 tour.stk(&["rename", "add-cart-model", "feature/cart"])?;
629 tour.stk(&["list"])?;
630 tour.say("From here, `git stk submit --stack` opens one review per branch, in");
631 tour.say("order - the same as any other stack.");
632 tour.finish()
633}
634
635struct Tour<'a> {
640 sandbox: &'a Path,
641 topic: &'a str,
642 term: Term,
643 title: String,
644 lines: Vec<String>,
645}
646
647enum Flow {
649 Continue,
650 Stop,
651}
652
653impl Flow {
654 fn stop(&self) -> bool {
655 matches!(self, Self::Stop)
656 }
657}
658
659impl<'a> Tour<'a> {
660 fn new(sandbox: &'a Path, topic: &'a str) -> Self {
661 Self {
662 sandbox,
663 topic,
664 term: Term::stdout(),
665 title: String::new(),
666 lines: Vec::new(),
667 }
668 }
669
670 fn banner(&mut self, title: &str) {
673 self.title = title.to_owned();
674 self.lines.clear();
675 }
676
677 fn say(&mut self, line: &str) {
679 self.lines.push(style::dim(line));
680 }
681
682 fn note(&mut self, command: &str) {
685 self.lines.push(format!("{} {command}", style::dim("$")));
686 }
687
688 fn stk(&mut self, args: &[&str]) -> Result<()> {
691 let output = self.run_stk(args)?;
692 if !output.status.success() {
693 bail!("`git stk {}` failed in the sandbox", args.join(" "));
694 }
695 Ok(())
696 }
697
698 fn stk_fails(&mut self, args: &[&str]) -> Result<()> {
700 let output = self.run_stk(args)?;
701 if output.status.success() {
702 bail!(
703 "`git stk {}` was expected to stop on the conflict",
704 args.join(" ")
705 );
706 }
707 Ok(())
708 }
709
710 fn run_stk(&mut self, args: &[&str]) -> Result<Output> {
711 self.note(&format!("git stk {}", args.join(" ")));
712 let binary = env::current_exe().context("failed to locate the running binary")?;
713 let output = capture(self.sandbox, &binary, args)?;
714 self.absorb_output(&output);
715 Ok(output)
716 }
717
718 fn show_git(&mut self, display: &str, args: &[&str]) -> Result<()> {
720 self.note(display);
721 let output = capture(self.sandbox, OsStr::new("git"), args)?;
722 self.absorb_output(&output);
723 if !output.status.success() {
724 bail!("`{display}` failed in the sandbox");
725 }
726 Ok(())
727 }
728
729 fn commit(&mut self, file: &str, contents: &str, message: &str) -> Result<()> {
731 self.note(&format!("edit {file}, then git commit -m {message:?}"));
732 fs::write(self.sandbox.join(file), contents).context("failed to write sandbox file")?;
733 run_git(self.sandbox, &["add", file])?;
734 run_git(self.sandbox, &["commit", "-q", "-m", message])
735 }
736
737 fn edit_and_add(&mut self, file: &str, contents: &str) -> Result<()> {
740 self.note(&format!("edit {file}, then git add {file}"));
741 fs::write(self.sandbox.join(file), contents).context("failed to write sandbox file")?;
742 run_git(self.sandbox, &["add", file])
743 }
744
745 fn absorb_output(&mut self, output: &Output) {
747 for stream in [&output.stdout, &output.stderr] {
748 let text = String::from_utf8_lossy(stream);
749 let text = text.trim_end_matches(['\n', '\r']);
750 if text.is_empty() {
751 continue;
752 }
753 for line in text.split('\n') {
754 self.lines.push(line.trim_end_matches('\r').to_owned());
755 }
756 }
757 self.lines.push(String::new());
758 }
759
760 fn pause(&mut self) -> Result<Flow> {
762 self.present("j/k/up/down scroll - space/pgdn page - enter continue - q quit")
763 }
764
765 fn finish(&mut self) -> Result<()> {
767 self.present("j/k/up/down scroll - enter/q to finish")?;
768 Ok(())
769 }
770
771 fn present(&mut self, hint: &str) -> Result<Flow> {
774 self.term.hide_cursor().ok();
775 self.term.clear_screen().ok();
776
777 let mut scroll = 0usize;
778 let mut read_errors = 0u32;
779 let flow = loop {
780 let (rows, cols) = self.term.size();
781 let (rows, cols) = (rows as usize, cols as usize);
782 let body = rows.saturating_sub(2).max(1);
783 let max_scroll = self.lines.len().saturating_sub(body);
784 scroll = scroll.min(max_scroll);
785 self.draw(scroll, cols, body, hint);
786
787 match self.term.read_key() {
788 Ok(key) => {
789 read_errors = 0;
790 match key {
791 Key::ArrowDown | Key::Char('j') => scroll = (scroll + 1).min(max_scroll),
792 Key::ArrowUp | Key::Char('k') => scroll = scroll.saturating_sub(1),
793 Key::PageDown | Key::Char(' ') => scroll = (scroll + body).min(max_scroll),
794 Key::PageUp => scroll = scroll.saturating_sub(body),
795 Key::Home | Key::Char('g') => scroll = 0,
796 Key::End | Key::Char('G') => scroll = max_scroll,
797 Key::Enter => break Flow::Continue,
798 Key::Char('q') | Key::Escape | Key::CtrlC => break Flow::Stop,
799 _ => {}
800 }
801 }
802 Err(_) => {
806 read_errors += 1;
807 if read_errors >= 3 {
808 break Flow::Stop;
809 }
810 }
811 }
812 };
813
814 self.term.show_cursor().ok();
815 self.term.clear_screen().ok();
816 Ok(flow)
817 }
818
819 fn draw(&self, scroll: usize, cols: usize, body: usize, hint: &str) {
823 let bar = Style::new().invert();
824 let header = format!("{} - {}", self.topic, self.title);
825 let mut frame = style::paint(bar, &fit(&format!(" {header}"), cols));
826
827 for row in 0..body {
828 frame.push('\n');
829 let line = self.lines.get(scroll + row).map_or("", String::as_str);
830 frame.push_str(&fit(line, cols));
831 }
832
833 let scrollable = self.lines.len() > body;
834 let footer = if scrollable {
835 format!(
836 " {hint} [{}/{}]",
837 (scroll + body).min(self.lines.len()),
838 self.lines.len()
839 )
840 } else {
841 format!(" {hint}")
842 };
843 frame.push('\n');
844 frame.push_str(&style::paint(bar, &fit(&footer, cols)));
845
846 self.term.move_cursor_to(0, 0).ok();
849 print!("{frame}");
850 let _ = std::io::stdout().flush();
851 }
852}
853
854fn fit(line: &str, width: usize) -> String {
856 let truncated = truncate_str(line, width, "…");
857 pad_str(&truncated, width, Alignment::Left, None).into_owned()
858}
859
860fn sibling_worktree_dir(sandbox: &Path) -> PathBuf {
863 let name = sandbox
864 .file_name()
865 .map(|name| name.to_string_lossy().into_owned())
866 .unwrap_or_else(|| "sandbox".to_owned());
867 sandbox
868 .parent()
869 .unwrap_or(sandbox)
870 .join(format!("{name}-worktrees"))
871}
872
873fn setup_sandbox(sandbox: &Path) -> Result<()> {
874 fs::create_dir_all(sandbox).context("failed to create the sandbox")?;
875 run_git(sandbox, &["init", "-q", "-b", "main"])?;
876 run_git(sandbox, &["config", "user.email", "guide@git-stk.dev"])?;
877 run_git(sandbox, &["config", "user.name", "git-stk guide"])?;
878 run_git(sandbox, &["config", "stk.provider", "demo"])?;
879 run_git(sandbox, &["config", "stk.noUpdateCheck", "true"])?;
880 fs::write(sandbox.join("README.md"), "# guide sandbox\n").context("failed to seed sandbox")?;
881 run_git(sandbox, &["add", "README.md"])?;
882 run_git(sandbox, &["commit", "-q", "-m", "initial commit"])?;
883 Ok(())
884}
885
886fn capture(sandbox: &Path, program: impl AsRef<OsStr>, args: &[&str]) -> Result<Output> {
889 let program = program.as_ref();
890 isolated(Command::new(program).args(args).current_dir(sandbox))
891 .env("CLICOLOR_FORCE", "1")
892 .stdin(Stdio::null())
893 .output()
894 .with_context(|| format!("failed to run {} in the sandbox", program.to_string_lossy()))
895}
896
897fn run_git(sandbox: &Path, args: &[&str]) -> Result<()> {
899 let status = isolated(Command::new("git").args(args).current_dir(sandbox))
900 .status()
901 .context("failed to run git in the sandbox")?;
902 if !status.success() {
903 bail!("`git {}` failed in the sandbox", args.join(" "));
904 }
905 Ok(())
906}
907
908fn isolated(command: &mut Command) -> &mut Command {
911 command
912 .env("GIT_CONFIG_GLOBAL", nul_device())
913 .env("GIT_CONFIG_NOSYSTEM", "1")
914 .env("GIT_EDITOR", "true")
915}
916
917fn nul_device() -> PathBuf {
918 if cfg!(windows) {
919 PathBuf::from("NUL")
920 } else {
921 PathBuf::from("/dev/null")
922 }
923}
924
925fn banner(title: &str) {
926 anstream::println!("{}", style::paint(style::CURRENT, title));
927}
928
929fn say(line: &str) {
930 anstream::println!("{}", style::paint(style::DIM, line));
931}
932
933#[cfg(test)]
934mod tests {
935 use super::fit;
936 use console::measure_text_width;
937
938 #[test]
939 fn fit_pads_short_lines_to_exact_width() {
940 let fitted = fit("ab", 5);
941 assert_eq!(fitted, "ab ");
942 assert_eq!(measure_text_width(&fitted), 5);
943 }
944
945 #[test]
946 fn fit_truncates_long_lines_to_exact_width() {
947 let fitted = fit("abcdefghij", 4);
948 assert_eq!(measure_text_width(&fitted), 4);
949 assert!(fitted.ends_with('…'));
950 }
951
952 #[test]
953 fn fit_measures_width_ignoring_ansi() {
954 let fitted = fit("\x1b[31mred\x1b[0m", 6);
956 assert_eq!(measure_text_width(&fitted), 6);
957 assert!(fitted.contains("\x1b[31m"));
958 }
959}