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