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
50#[derive(Debug, clap::Args)]
52pub struct Guide {
53 #[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 let delete = Confirm::with_theme(&ColorfulTheme::default())
111 .with_prompt("delete the sandbox?")
112 .default(true)
113 .interact()
114 .unwrap_or(true);
115 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 tour.say("`up` and `down` take a distance, so a tall stack is one hop:");
173 tour.stk(&["down", "2"])?;
174 tour.stk(&["up", "2"])?;
175 if tour.pause()?.stop() {
176 return Ok(());
177 }
178
179 tour.banner("4/5 - land the stack");
180 tour.say("`merge --all` repeats merge-bottom-then-sync until the stack is");
181 tour.say("complete: children retarget, landed branches vanish, the overview");
182 tour.say("in every review restyles as history accumulates:");
183 tour.stk(&["merge", "--all", "-y"])?;
184 tour.say("Only merged reviews are cleaned up. If closing a review means you are");
185 tour.say("done with the branch too, `git config stk.cleanClosed true` has");
186 tour.say("`sync` and `cleanup` treat closed ones the same way.");
187 if tour.pause()?.stop() {
188 return Ok(());
189 }
190
191 tour.banner("5/5 - nothing left but trunk");
192 tour.stk(&["list"])?;
193 tour.say("That is the whole loop: new -> commit -> submit -> merge.");
194 tour.say("On a real repo the provider is detected from your remote; day to day");
195 tour.say("you mostly run `git stk new`, `git stk submit --stack`, and");
196 tour.say("`git stk merge --all`. `git stk status` and the hints fill the gaps.");
197 tour.finish()
198}
199
200fn conflicts(tour: &mut Tour) -> Result<()> {
201 tour.banner("1/3 - set up a collision");
202 tour.say("A two-branch stack where both branches touch the same line:");
203 tour.stk(&["new", "feature/payment"])?;
204 tour.commit("notes.txt", "use stripe\n", "choose payment provider")?;
205 tour.stk(&["new", "feature/receipts"])?;
206 tour.commit("notes.txt", "use stripe with receipts\n", "email receipts")?;
207 tour.say("Now the parent changes its mind about that very line:");
208 tour.stk(&["down"])?;
209 tour.commit("notes.txt", "use paypal\n", "switch to paypal")?;
210 if tour.pause()?.stop() {
211 return Ok(());
212 }
213
214 tour.banner("2/3 - the restack stops, with context");
215 tour.say("Replaying the child onto the rewritten parent cannot succeed; the");
216 tour.say("restack stops, shows git's conflict output, and says what to do:");
217 tour.stk_fails(&["restack"])?;
218 if tour.pause()?.stop() {
219 return Ok(());
220 }
221
222 tour.banner("3/3 - resolve, then continue");
223 tour.say("Fix the file and stage it, exactly like any rebase conflict:");
224 tour.edit_and_add("notes.txt", "use paypal with receipts\n")?;
225 tour.say("`continue` picks the restack back up where it stopped");
226 tour.say("(`git stk abort` would have unwound it instead):");
227 tour.stk(&["continue"])?;
228 tour.stk(&["list"])?;
229 tour.say("Conflicts interrupt the restack, never break it: resolve, continue,");
230 tour.say("and the rest of the stack follows.");
231 tour.finish()
232}
233
234fn repair(tour: &mut Tour) -> Result<()> {
235 tour.banner("1/3 - a healthy stack");
236 tour.stk(&["new", "feature/api"])?;
237 tour.commit("api.txt", "endpoints\n", "add api")?;
238 tour.stk(&["new", "feature/ui"])?;
239 tour.commit("ui.txt", "buttons\n", "add ui")?;
240 tour.stk(&["submit", "--stack"])?;
241 if tour.pause()?.stop() {
242 return Ok(());
243 }
244
245 tour.banner("2/3 - the metadata vanishes");
246 tour.say("Stack parents are plain `branch.<name>.stkParent` entries in");
247 tour.say(".git/config - annotations, not state. Suppose one gets lost:");
248 tour.note("git config --unset branch.feature/ui.stkParent");
249 run_git(
250 tour.sandbox,
251 &["config", "--unset", "branch.feature/ui.stkParent"],
252 )?;
253 tour.say("The stack no longer knows feature/ui belongs to it:");
254 tour.stk(&["list"])?;
255 if tour.pause()?.stop() {
256 return Ok(());
257 }
258
259 tour.banner("3/3 - repair rebuilds it");
260 tour.say("`repair` re-derives parents from review bases (when a provider is");
261 tour.say("reachable) and branch ancestry, and verifies recorded fork points:");
262 tour.stk(&["repair", "--dry-run"])?;
263 tour.stk(&["repair"])?;
264 tour.stk(&["list"])?;
265 tour.say("Branches are the real state; metadata is always recoverable.");
266 tour.say("Anything repair cannot resolve safely, it reports for a manual");
267 tour.say("`git stk adopt`.");
268 tour.finish()
269}
270
271fn absorb(tour: &mut Tour) -> Result<()> {
272 tour.banner("1/3 - fixes scattered across the stack");
273 tour.say("A two-branch stack, each branch owning one file:");
274 tour.stk(&["new", "feature/login"])?;
275 tour.commit("login.txt", "username + password form\n", "add login form")?;
276 tour.stk(&["new", "feature/avatar"])?;
277 tour.commit("avatar.txt", "round avatars\n", "add avatars")?;
278 tour.say("Review comes back: two small fixes, one on each branch's file.");
279 tour.say("You make both edits from the top and stage them, as usual:");
280 tour.edit_and_add("login.txt", "username + password form, with 2FA\n")?;
281 tour.edit_and_add("avatar.txt", "round avatars, lazy-loaded\n")?;
282 tour.say("Both fixes sit staged together, but each belongs to a different commit");
283 tour.say("further down the stack:");
284 tour.stk(&["status"])?;
285 if tour.pause()?.stop() {
286 return Ok(());
287 }
288
289 tour.banner("2/3 - preview where each hunk lands");
290 tour.say("`absorb` blames every staged hunk and routes it to the commit that");
291 tour.say("introduced the lines it touches. `--dry-run` shows the plan first:");
292 tour.stk(&["absorb", "--dry-run"])?;
293 if tour.pause()?.stop() {
294 return Ok(());
295 }
296
297 tour.banner("3/3 - fold them in");
298 tour.say("Run it for real: each fix becomes a `fixup!` of its owning commit, an");
299 tour.say("autosquash rebase folds them in, and every branch ref rides along:");
300 tour.stk(&["absorb"])?;
301 tour.say("The history reads as if the fixes were always there - no extra commits:");
302 tour.show_git(
303 "git log --oneline main..feature/avatar",
304 &[
305 "--no-pager",
306 "-c",
307 "color.ui=always",
308 "log",
309 "--oneline",
310 "main..feature/avatar",
311 ],
312 )?;
313 tour.say("Hunks that cannot be attributed - brand-new lines, trunk-owned lines, a");
314 tour.say("hunk spanning two commits - are left staged and reported, never guessed.");
315 tour.finish()
316}
317
318fn adopt(tour: &mut Tour) -> Result<()> {
319 tour.banner("1/3 - adopt a hand-made branch");
320 tour.say("Not every branch begins with `git stk new`. Suppose you branched off");
321 tour.say("the trunk by hand and did some work:");
322 tour.note("git switch -c feature/logging");
323 run_git(tour.sandbox, &["switch", "-c", "feature/logging"])?;
324 tour.commit("logging.txt", "structured logs\n", "add logging")?;
325 tour.say("git-stk has no metadata for it yet. `adopt` records its parent -");
326 tour.say("metadata only, nothing is rewritten - folding it into a stack:");
327 tour.stk(&["adopt", "--parent", "main"])?;
328 tour.stk(&["list"])?;
329 if tour.pause()?.stop() {
330 return Ok(());
331 }
332
333 tour.banner("2/3 - move a branch onto another");
334 tour.say("Two branches, each started independently off the trunk:");
335 tour.note("git switch main");
336 run_git(tour.sandbox, &["switch", "main"])?;
337 tour.stk(&["new", "feature/api"])?;
338 tour.commit("api.txt", "endpoints\n", "add api")?;
339 tour.note("git switch main");
340 run_git(tour.sandbox, &["switch", "main"])?;
341 tour.stk(&["new", "feature/web"])?;
342 tour.commit("web.txt", "pages\n", "add web")?;
343 tour.say("`list` shows them as siblings on the trunk:");
344 tour.stk(&["list", "--all"])?;
345 tour.say("But feature/web really belongs on top of feature/api. Re-point its");
346 tour.say("parent with `adopt`, then `restack` replays its commits onto the new");
347 tour.say("base (only its own commits move; the parent's are already there):");
348 tour.stk(&["adopt", "--parent", "feature/api"])?;
349 tour.stk(&["restack"])?;
350 tour.stk(&["list"])?;
351 if tour.pause()?.stop() {
352 return Ok(());
353 }
354
355 tour.banner("3/3 - detach: the inverse");
356 tour.say("`detach` drops a branch's stack metadata, leaving the branch and its");
357 tour.say("commits untouched - handy when something was adopted by mistake:");
358 tour.stk(&["detach", "feature/web"])?;
359 tour.stk(&["list", "--all"])?;
360 tour.say("feature/web still exists; git-stk just no longer tracks it. Re-`adopt`");
361 tour.say("it onto any parent whenever you want it back in a stack.");
362 tour.finish()
363}
364
365fn worktrees(tour: &mut Tour) -> Result<()> {
366 tour.banner("1/4 - a branch in a worktree of its own");
367 tour.say("A git worktree is a second checkout of the same repository, on its");
368 tour.say("own branch. `new --worktree` makes one instead of checking the branch");
369 tour.say("out here - so you keep whatever you were doing:");
370 tour.stk(&["new", "feature/api"])?;
371 tour.commit("api.txt", "endpoints\n", "add api")?;
372 tour.stk(&["new", "feature/web", "--worktree"])?;
373 tour.say("Note what did *not* happen: we are still on feature/api. The new");
374 tour.say("branch lives somewhere else entirely.");
375 tour.show_git("git branch --show-current", &["branch", "--show-current"])?;
376 tour.say("`list` says where each branch lives, so the stack is still one view:");
377 tour.stk(&["list", "--local"])?;
378 if tour.pause()?.stop() {
379 return Ok(());
380 }
381
382 tour.banner("2/4 - gotcha: a branch can only be checked out once");
383 tour.say("Git refuses to check out a branch a second worktree already holds.");
384 tour.say("That is the central worktree gotcha, and it makes `up` impossible -");
385 tour.say("moving up the stack is now a `cd`, not a checkout:");
386 tour.stk_fails(&["up"])?;
387 tour.say("So navigation can print where to go instead, and let the shell move:");
388 tour.note("cd \"$(git stk up --from-path)\"");
389 tour.say("`git stk setup --wrapper` wraps that in an `stk` shell function for");
390 tour.say("you, completions included, so `stk up`/`down`/`top`/`bottom` follow the");
391 tour.say("branch wherever it lives - into a worktree, or an ordinary checkout");
392 tour.say("when it is here. Every other `stk` command falls through to `git stk`.");
393 tour.say("bash and zsh only; the README has the snippet to write by hand.");
394 if tour.pause()?.stop() {
395 return Ok(());
396 }
397
398 tour.banner("3/4 - gotcha: rewrites refuse up front");
399 tour.say("Git will not rebase a branch another worktree holds either. So when a");
400 tour.say("parent moves and a held branch would need replaying:");
401 tour.commit("api.txt", "endpoints\nauth\n", "add auth")?;
402 tour.say("`restack` refuses before touching anything, rather than rewriting half");
403 tour.say("the stack and failing in the middle:");
404 tour.stk_fails(&["restack"])?;
405 tour.say("Nothing was rewritten. Hand the branch back with");
406 tour.say("`git -C <path> checkout --detach`, then try again - that works even when");
407 tour.say("the holder is your main worktree, which cannot be removed. `undo` and");
408 tour.say("`cleanup` are just as");
409 tour.say("careful: undo refuses rather than rewinding a branch under a worktree");
410 tour.say("that would not notice, and cleanup keeps such a branch and moves on");
411 tour.say("instead of failing the whole run.");
412 if tour.pause()?.stop() {
413 return Ok(());
414 }
415
416 tour.banner("4/4 - run, and who owns a worktree");
417 tour.say("`run` uses a throwaway worktree of its own, so checking the stack");
418 tour.say("never moves your HEAD and never needs a clean tree:");
419 tour.edit_and_add("api.txt", "endpoints\nauth\nwork in progress\n")?;
420 tour.say("Uncommitted work, and `run` still walks every branch:");
421 tour.stk(&["run", "--", "git", "log", "--oneline", "-1"])?;
422 tour.say("The catch: a fresh worktree has no untracked build output - no");
423 tour.say("node_modules, no target/ - so a command that needs those may fail");
424 tour.say("there having passed in your checkout. `--no-worktree` runs the old way.");
425 tour.say("");
426 tour.say("Finally, ownership. git-stk removes the worktrees *it* created (from");
427 tour.say("`new --worktree`) once their branch lands, and never touches one you");
428 tour.say("made by hand - nor an owned one with uncommitted work still in it.");
429 tour.say("");
430 tour.say("One consequence worth knowing: land a review from inside that branch's");
431 tour.say("own worktree and the branch is kept, not deleted - it is checked out");
432 tour.say("right where you are standing. It stays in the stack, so `cd` to the");
433 tour.say("main checkout afterwards and `git stk cleanup <branch>` removes the");
434 tour.say("branch and its worktree together.");
435 tour.finish()
436}
437
438fn undo(tour: &mut Tour) -> Result<()> {
439 tour.banner("1/2 - rewrite the stack");
440 tour.say("Stack-rewriting commands snapshot the stack before they touch it,");
441 tour.say("so git stk undo can put it back. Start with two branches:");
442 tour.stk(&["new", "feature/api"])?;
443 tour.commit("api.txt", "endpoints\n", "add api")?;
444 tour.stk(&["new", "feature/web"])?;
445 tour.commit("web.txt", "pages\n", "add web")?;
446 tour.say("Feedback lands on the bottom branch, leaving the child behind it:");
447 tour.stk(&["down"])?;
448 tour.commit("api.txt", "endpoints\nauth\n", "add auth")?;
449 tour.say("`restack` replays feature/web onto the rewritten feature/api - a real");
450 tour.say("rewrite of its commit:");
451 tour.stk(&["restack"])?;
452 if tour.pause()?.stop() {
453 return Ok(());
454 }
455
456 tour.banner("2/2 - take it back");
457 tour.say("Changed your mind? `undo` reverses the last stack-rewriting command,");
458 tour.say("restoring every branch tip and the stack metadata from that snapshot:");
459 tour.stk(&["undo"])?;
460 tour.say("feature/web is back exactly where it was. `undo` is one level deep and");
461 tour.say("one-shot - a second one has nothing left to restore:");
462 tour.stk_fails(&["undo"])?;
463 tour.say("And it is local only: pushes and already-merged reviews are never");
464 tour.say("reverted - `undo` touches branch tips and metadata, nothing remote.");
465 tour.finish()
466}
467
468fn split(tour: &mut Tour) -> Result<()> {
469 tour.banner("1/3 - a pile of commits on one branch");
470 tour.say("Sometimes you commit several changes on one branch before carving");
471 tour.say("them into reviewable pieces. Start with three commits:");
472 tour.stk(&["new", "feature/checkout"])?;
473 tour.commit("cart.txt", "cart model\n", "add cart model")?;
474 tour.commit("payment.txt", "stripe integration\n", "add payment")?;
475 tour.commit("receipt.txt", "email receipt\n", "send receipt")?;
476 tour.say("`list --commits` nests each branch's own commits, newest first, so");
477 tour.say("you can see the boundaries before splitting:");
478 tour.stk(&["list", "--commits"])?;
479 if tour.pause()?.stop() {
480 return Ok(());
481 }
482
483 tour.banner("2/3 - split into a stack");
484 tour.say("`split --per-commit` makes one branch per commit, bottom-up, named");
485 tour.say("from each subject. The original branch stays as the leaf, and nothing");
486 tour.say("is rewritten - the new branches just point at the existing commits:");
487 tour.stk(&["split", "--per-commit"])?;
488 tour.stk(&["list"])?;
489 tour.say("(Plain `git stk split` opens an interactive picker instead, to group");
490 tour.say("several commits into one branch.)");
491 if tour.pause()?.stop() {
492 return Ok(());
493 }
494
495 tour.banner("3/3 - tidy a name");
496 tour.say("Auto-slugged names are a fine start; `rename` cleans one up and keeps");
497 tour.say("the stack intact - children pointing at the old name are retargeted:");
498 tour.stk(&["rename", "add-cart-model", "feature/cart"])?;
499 tour.stk(&["list"])?;
500 tour.say("From here, `git stk submit --stack` opens one review per branch, in");
501 tour.say("order - the same as any other stack.");
502 tour.finish()
503}
504
505struct Tour<'a> {
510 sandbox: &'a Path,
511 topic: &'a str,
512 term: Term,
513 title: String,
514 lines: Vec<String>,
515}
516
517enum Flow {
519 Continue,
520 Stop,
521}
522
523impl Flow {
524 fn stop(&self) -> bool {
525 matches!(self, Self::Stop)
526 }
527}
528
529impl<'a> Tour<'a> {
530 fn new(sandbox: &'a Path, topic: &'a str) -> Self {
531 Self {
532 sandbox,
533 topic,
534 term: Term::stdout(),
535 title: String::new(),
536 lines: Vec::new(),
537 }
538 }
539
540 fn banner(&mut self, title: &str) {
543 self.title = title.to_owned();
544 self.lines.clear();
545 }
546
547 fn say(&mut self, line: &str) {
549 self.lines.push(style::dim(line));
550 }
551
552 fn note(&mut self, command: &str) {
555 self.lines.push(format!("{} {command}", style::dim("$")));
556 }
557
558 fn stk(&mut self, args: &[&str]) -> Result<()> {
561 let output = self.run_stk(args)?;
562 if !output.status.success() {
563 bail!("`git stk {}` failed in the sandbox", args.join(" "));
564 }
565 Ok(())
566 }
567
568 fn stk_fails(&mut self, args: &[&str]) -> Result<()> {
570 let output = self.run_stk(args)?;
571 if output.status.success() {
572 bail!(
573 "`git stk {}` was expected to stop on the conflict",
574 args.join(" ")
575 );
576 }
577 Ok(())
578 }
579
580 fn run_stk(&mut self, args: &[&str]) -> Result<Output> {
581 self.note(&format!("git stk {}", args.join(" ")));
582 let binary = env::current_exe().context("failed to locate the running binary")?;
583 let output = capture(self.sandbox, &binary, args)?;
584 self.absorb_output(&output);
585 Ok(output)
586 }
587
588 fn show_git(&mut self, display: &str, args: &[&str]) -> Result<()> {
590 self.note(display);
591 let output = capture(self.sandbox, OsStr::new("git"), args)?;
592 self.absorb_output(&output);
593 if !output.status.success() {
594 bail!("`{display}` failed in the sandbox");
595 }
596 Ok(())
597 }
598
599 fn commit(&mut self, file: &str, contents: &str, message: &str) -> Result<()> {
601 self.note(&format!("edit {file}, then git commit -m {message:?}"));
602 fs::write(self.sandbox.join(file), contents).context("failed to write sandbox file")?;
603 run_git(self.sandbox, &["add", file])?;
604 run_git(self.sandbox, &["commit", "-q", "-m", message])
605 }
606
607 fn edit_and_add(&mut self, file: &str, contents: &str) -> Result<()> {
610 self.note(&format!("edit {file}, then git add {file}"));
611 fs::write(self.sandbox.join(file), contents).context("failed to write sandbox file")?;
612 run_git(self.sandbox, &["add", file])
613 }
614
615 fn absorb_output(&mut self, output: &Output) {
617 for stream in [&output.stdout, &output.stderr] {
618 let text = String::from_utf8_lossy(stream);
619 let text = text.trim_end_matches(['\n', '\r']);
620 if text.is_empty() {
621 continue;
622 }
623 for line in text.split('\n') {
624 self.lines.push(line.trim_end_matches('\r').to_owned());
625 }
626 }
627 self.lines.push(String::new());
628 }
629
630 fn pause(&mut self) -> Result<Flow> {
632 self.present("j/k/up/down scroll - space/pgdn page - enter continue - q quit")
633 }
634
635 fn finish(&mut self) -> Result<()> {
637 self.present("j/k/up/down scroll - enter/q to finish")?;
638 Ok(())
639 }
640
641 fn present(&mut self, hint: &str) -> Result<Flow> {
644 self.term.hide_cursor().ok();
645 self.term.clear_screen().ok();
646
647 let mut scroll = 0usize;
648 let mut read_errors = 0u32;
649 let flow = loop {
650 let (rows, cols) = self.term.size();
651 let (rows, cols) = (rows as usize, cols as usize);
652 let body = rows.saturating_sub(2).max(1);
653 let max_scroll = self.lines.len().saturating_sub(body);
654 scroll = scroll.min(max_scroll);
655 self.draw(scroll, cols, body, hint);
656
657 match self.term.read_key() {
658 Ok(key) => {
659 read_errors = 0;
660 match key {
661 Key::ArrowDown | Key::Char('j') => scroll = (scroll + 1).min(max_scroll),
662 Key::ArrowUp | Key::Char('k') => scroll = scroll.saturating_sub(1),
663 Key::PageDown | Key::Char(' ') => scroll = (scroll + body).min(max_scroll),
664 Key::PageUp => scroll = scroll.saturating_sub(body),
665 Key::Home | Key::Char('g') => scroll = 0,
666 Key::End | Key::Char('G') => scroll = max_scroll,
667 Key::Enter => break Flow::Continue,
668 Key::Char('q') | Key::Escape | Key::CtrlC => break Flow::Stop,
669 _ => {}
670 }
671 }
672 Err(_) => {
676 read_errors += 1;
677 if read_errors >= 3 {
678 break Flow::Stop;
679 }
680 }
681 }
682 };
683
684 self.term.show_cursor().ok();
685 self.term.clear_screen().ok();
686 Ok(flow)
687 }
688
689 fn draw(&self, scroll: usize, cols: usize, body: usize, hint: &str) {
693 let bar = Style::new().invert();
694 let header = format!("{} - {}", self.topic, self.title);
695 let mut frame = style::paint(bar, &fit(&format!(" {header}"), cols));
696
697 for row in 0..body {
698 frame.push('\n');
699 let line = self.lines.get(scroll + row).map_or("", String::as_str);
700 frame.push_str(&fit(line, cols));
701 }
702
703 let scrollable = self.lines.len() > body;
704 let footer = if scrollable {
705 format!(
706 " {hint} [{}/{}]",
707 (scroll + body).min(self.lines.len()),
708 self.lines.len()
709 )
710 } else {
711 format!(" {hint}")
712 };
713 frame.push('\n');
714 frame.push_str(&style::paint(bar, &fit(&footer, cols)));
715
716 self.term.move_cursor_to(0, 0).ok();
719 print!("{frame}");
720 let _ = std::io::stdout().flush();
721 }
722}
723
724fn fit(line: &str, width: usize) -> String {
726 let truncated = truncate_str(line, width, "…");
727 pad_str(&truncated, width, Alignment::Left, None).into_owned()
728}
729
730fn sibling_worktree_dir(sandbox: &Path) -> PathBuf {
733 let name = sandbox
734 .file_name()
735 .map(|name| name.to_string_lossy().into_owned())
736 .unwrap_or_else(|| "sandbox".to_owned());
737 sandbox
738 .parent()
739 .unwrap_or(sandbox)
740 .join(format!("{name}-worktrees"))
741}
742
743fn setup_sandbox(sandbox: &Path) -> Result<()> {
744 fs::create_dir_all(sandbox).context("failed to create the sandbox")?;
745 run_git(sandbox, &["init", "-q", "-b", "main"])?;
746 run_git(sandbox, &["config", "user.email", "guide@git-stk.dev"])?;
747 run_git(sandbox, &["config", "user.name", "git-stk guide"])?;
748 run_git(sandbox, &["config", "stk.provider", "demo"])?;
749 run_git(sandbox, &["config", "stk.noUpdateCheck", "true"])?;
750 fs::write(sandbox.join("README.md"), "# guide sandbox\n").context("failed to seed sandbox")?;
751 run_git(sandbox, &["add", "README.md"])?;
752 run_git(sandbox, &["commit", "-q", "-m", "initial commit"])?;
753 Ok(())
754}
755
756fn capture(sandbox: &Path, program: impl AsRef<OsStr>, args: &[&str]) -> Result<Output> {
759 let program = program.as_ref();
760 isolated(Command::new(program).args(args).current_dir(sandbox))
761 .env("CLICOLOR_FORCE", "1")
762 .stdin(Stdio::null())
763 .output()
764 .with_context(|| format!("failed to run {} in the sandbox", program.to_string_lossy()))
765}
766
767fn run_git(sandbox: &Path, args: &[&str]) -> Result<()> {
769 let status = isolated(Command::new("git").args(args).current_dir(sandbox))
770 .status()
771 .context("failed to run git in the sandbox")?;
772 if !status.success() {
773 bail!("`git {}` failed in the sandbox", args.join(" "));
774 }
775 Ok(())
776}
777
778fn isolated(command: &mut Command) -> &mut Command {
781 command
782 .env("GIT_CONFIG_GLOBAL", nul_device())
783 .env("GIT_CONFIG_NOSYSTEM", "1")
784 .env("GIT_EDITOR", "true")
785}
786
787fn nul_device() -> PathBuf {
788 if cfg!(windows) {
789 PathBuf::from("NUL")
790 } else {
791 PathBuf::from("/dev/null")
792 }
793}
794
795fn banner(title: &str) {
796 anstream::println!("{}", style::paint(style::CURRENT, title));
797}
798
799fn say(line: &str) {
800 anstream::println!("{}", style::paint(style::DIM, line));
801}
802
803#[cfg(test)]
804mod tests {
805 use super::fit;
806 use console::measure_text_width;
807
808 #[test]
809 fn fit_pads_short_lines_to_exact_width() {
810 let fitted = fit("ab", 5);
811 assert_eq!(fitted, "ab ");
812 assert_eq!(measure_text_width(&fitted), 5);
813 }
814
815 #[test]
816 fn fit_truncates_long_lines_to_exact_width() {
817 let fitted = fit("abcdefghij", 4);
818 assert_eq!(measure_text_width(&fitted), 4);
819 assert!(fitted.ends_with('…'));
820 }
821
822 #[test]
823 fn fit_measures_width_ignoring_ansi() {
824 let fitted = fit("\x1b[31mred\x1b[0m", 6);
826 assert_eq!(measure_text_width(&fitted), 6);
827 assert!(fitted.contains("\x1b[31m"));
828 }
829}