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
34#[derive(Debug, clap::Args)]
36pub struct Guide {
37 #[arg(value_parser = clap::builder::PossibleValuesParser::new(["intro", "conflicts", "repair", "absorb"]))]
39 topic: Option<String>,
40}
41
42impl Run for Guide {
43 fn run(self) -> Result<()> {
44 guide(self.topic.as_deref())
45 }
46}
47
48fn guide(topic: Option<&str>) -> Result<()> {
49 if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
50 bail!("the guide is interactive; run it from a terminal");
51 }
52
53 banner("git stk guide");
54 say("Short interactive tours. Everything happens in a disposable sandbox");
55 say("repository - your real work is never touched, and a built-in demo");
56 say("provider stands in for GitHub: same commands, no network.");
57 say("Each step opens full-screen; scroll with j/k or the arrows, Enter to");
58 say("move on, q to quit.");
59 println!();
60
61 let chosen = match topic {
62 Some(topic) => TOPICS
63 .iter()
64 .find(|(name, _, _)| *name == topic)
65 .context("unknown guide topic")?,
66 None => {
67 let items: Vec<String> = TOPICS
68 .iter()
69 .map(|(name, blurb, _)| format!("{name} - {blurb}"))
70 .collect();
71 let index = Select::with_theme(&ColorfulTheme::default())
72 .with_prompt("which tour?")
73 .items(&items)
74 .default(0)
75 .interact()
76 .context("nothing chosen")?;
77 &TOPICS[index]
78 }
79 };
80 println!();
81
82 let sandbox = env::temp_dir().join(format!("git-stk-guide-{}", std::process::id()));
83 if sandbox.exists() {
84 fs::remove_dir_all(&sandbox).context("failed to clear an old sandbox")?;
85 }
86 say(&format!("sandbox: {}", sandbox.display()));
87 println!();
88 setup_sandbox(&sandbox)?;
89
90 let mut tour = Tour::new(&sandbox, chosen.0);
91 let finished = (chosen.2)(&mut tour);
92
93 let delete = Confirm::with_theme(&ColorfulTheme::default())
95 .with_prompt("delete the sandbox?")
96 .default(true)
97 .interact()
98 .unwrap_or(true);
99 if delete {
100 fs::remove_dir_all(&sandbox).context("failed to remove the sandbox")?;
101 say("sandbox removed");
102 } else {
103 say(&format!("kept: cd {}", sandbox.display()));
104 say("it uses `git config stk.provider demo`, so every command works offline");
105 }
106
107 finished
108}
109
110fn intro(tour: &mut Tour) -> Result<()> {
111 tour.banner("1/5 - a stack is just branches");
112 tour.say("Each branch carries one reviewable change and knows its parent.");
113 tour.say("`new` creates a child of wherever you stand:");
114 tour.stk(&["new", "feature/login"])?;
115 tour.commit("login.txt", "username + password form\n", "add login form")?;
116 tour.stk(&["new", "feature/avatar"])?;
117 tour.commit("avatar.txt", "round avatars\n", "add avatars")?;
118 tour.say("Two branches, stacked. `list` draws the pile, trunk at the bottom:");
119 tour.stk(&["list"])?;
120 if tour.pause()?.stop() {
121 return Ok(());
122 }
123
124 tour.banner("2/5 - submit the whole stack");
125 tour.say("One command opens (or updates) a review per branch, parent-first,");
126 tour.say("and writes a live stack overview into every description:");
127 tour.stk(&["submit", "--stack"])?;
128 tour.stk(&["status"])?;
129 if tour.pause()?.stop() {
130 return Ok(());
131 }
132
133 tour.banner("3/5 - parents move; restack follows");
134 tour.say("Review feedback lands on the bottom branch:");
135 tour.stk(&["down"])?;
136 tour.commit(
137 "login.txt",
138 "username + password form\nremember me\n",
139 "add remember me",
140 )?;
141 tour.say("The child is now behind its parent - `list` notices:");
142 tour.stk(&["list"])?;
143 tour.say("`restack` rebases every descendant back onto its parent:");
144 tour.stk(&["restack"])?;
145 tour.stk(&["top"])?;
146 if tour.pause()?.stop() {
147 return Ok(());
148 }
149
150 tour.banner("4/5 - land the stack");
151 tour.say("`merge --all` repeats merge-bottom-then-sync until the stack is");
152 tour.say("complete: children retarget, merged branches vanish, the overview");
153 tour.say("in every review restyles as history accumulates:");
154 tour.stk(&["merge", "--all", "-y"])?;
155 if tour.pause()?.stop() {
156 return Ok(());
157 }
158
159 tour.banner("5/5 - nothing left but trunk");
160 tour.stk(&["list"])?;
161 tour.say("That is the whole loop: new -> commit -> submit -> merge.");
162 tour.say("On a real repo the provider is detected from your remote; day to day");
163 tour.say("you mostly run `git stk new`, `git stk submit --stack`, and");
164 tour.say("`git stk merge --all`. `git stk status` and the hints fill the gaps.");
165 tour.finish()
166}
167
168fn conflicts(tour: &mut Tour) -> Result<()> {
169 tour.banner("1/3 - set up a collision");
170 tour.say("A two-branch stack where both branches touch the same line:");
171 tour.stk(&["new", "feature/payment"])?;
172 tour.commit("notes.txt", "use stripe\n", "choose payment provider")?;
173 tour.stk(&["new", "feature/receipts"])?;
174 tour.commit("notes.txt", "use stripe with receipts\n", "email receipts")?;
175 tour.say("Now the parent changes its mind about that very line:");
176 tour.stk(&["down"])?;
177 tour.commit("notes.txt", "use paypal\n", "switch to paypal")?;
178 if tour.pause()?.stop() {
179 return Ok(());
180 }
181
182 tour.banner("2/3 - the restack stops, with context");
183 tour.say("Replaying the child onto the rewritten parent cannot succeed; the");
184 tour.say("restack stops, shows git's conflict output, and says what to do:");
185 tour.stk_fails(&["restack"])?;
186 if tour.pause()?.stop() {
187 return Ok(());
188 }
189
190 tour.banner("3/3 - resolve, then continue");
191 tour.say("Fix the file and stage it, exactly like any rebase conflict:");
192 tour.edit_and_add("notes.txt", "use paypal with receipts\n")?;
193 tour.say("`continue` picks the restack back up where it stopped");
194 tour.say("(`git stk abort` would have unwound it instead):");
195 tour.stk(&["continue"])?;
196 tour.stk(&["list"])?;
197 tour.say("Conflicts interrupt the restack, never break it: resolve, continue,");
198 tour.say("and the rest of the stack follows.");
199 tour.finish()
200}
201
202fn repair(tour: &mut Tour) -> Result<()> {
203 tour.banner("1/3 - a healthy stack");
204 tour.stk(&["new", "feature/api"])?;
205 tour.commit("api.txt", "endpoints\n", "add api")?;
206 tour.stk(&["new", "feature/ui"])?;
207 tour.commit("ui.txt", "buttons\n", "add ui")?;
208 tour.stk(&["submit", "--stack"])?;
209 if tour.pause()?.stop() {
210 return Ok(());
211 }
212
213 tour.banner("2/3 - the metadata vanishes");
214 tour.say("Stack parents are plain `branch.<name>.stkParent` entries in");
215 tour.say(".git/config - annotations, not state. Suppose one gets lost:");
216 tour.note("git config --unset branch.feature/ui.stkParent");
217 run_git(
218 tour.sandbox,
219 &["config", "--unset", "branch.feature/ui.stkParent"],
220 )?;
221 tour.say("The stack no longer knows feature/ui belongs to it:");
222 tour.stk(&["list"])?;
223 if tour.pause()?.stop() {
224 return Ok(());
225 }
226
227 tour.banner("3/3 - repair rebuilds it");
228 tour.say("`repair` re-derives parents from review bases (when a provider is");
229 tour.say("reachable) and branch ancestry, and verifies recorded fork points:");
230 tour.stk(&["repair", "--dry-run"])?;
231 tour.stk(&["repair"])?;
232 tour.stk(&["list"])?;
233 tour.say("Branches are the real state; metadata is always recoverable.");
234 tour.say("Anything repair cannot resolve safely, it reports for a manual");
235 tour.say("`git stk adopt`.");
236 tour.finish()
237}
238
239fn absorb(tour: &mut Tour) -> Result<()> {
240 tour.banner("1/3 - fixes scattered across the stack");
241 tour.say("A two-branch stack, each branch owning one file:");
242 tour.stk(&["new", "feature/login"])?;
243 tour.commit("login.txt", "username + password form\n", "add login form")?;
244 tour.stk(&["new", "feature/avatar"])?;
245 tour.commit("avatar.txt", "round avatars\n", "add avatars")?;
246 tour.say("Review comes back: two small fixes, one on each branch's file.");
247 tour.say("You make both edits from the top and stage them, as usual:");
248 tour.edit_and_add("login.txt", "username + password form, with 2FA\n")?;
249 tour.edit_and_add("avatar.txt", "round avatars, lazy-loaded\n")?;
250 tour.say("Both fixes sit staged together, but each belongs to a different commit");
251 tour.say("further down the stack:");
252 tour.stk(&["status"])?;
253 if tour.pause()?.stop() {
254 return Ok(());
255 }
256
257 tour.banner("2/3 - preview where each hunk lands");
258 tour.say("`absorb` blames every staged hunk and routes it to the commit that");
259 tour.say("introduced the lines it touches. `--dry-run` shows the plan first:");
260 tour.stk(&["absorb", "--dry-run"])?;
261 if tour.pause()?.stop() {
262 return Ok(());
263 }
264
265 tour.banner("3/3 - fold them in");
266 tour.say("Run it for real: each fix becomes a `fixup!` of its owning commit, an");
267 tour.say("autosquash rebase folds them in, and every branch ref rides along:");
268 tour.stk(&["absorb"])?;
269 tour.say("The history reads as if the fixes were always there - no extra commits:");
270 tour.show_git(
271 "git log --oneline main..feature/avatar",
272 &[
273 "--no-pager",
274 "-c",
275 "color.ui=always",
276 "log",
277 "--oneline",
278 "main..feature/avatar",
279 ],
280 )?;
281 tour.say("Hunks that cannot be attributed - brand-new lines, trunk-owned lines, a");
282 tour.say("hunk spanning two commits - are left staged and reported, never guessed.");
283 tour.finish()
284}
285
286struct Tour<'a> {
291 sandbox: &'a Path,
292 topic: &'a str,
293 term: Term,
294 title: String,
295 lines: Vec<String>,
296}
297
298enum Flow {
300 Continue,
301 Stop,
302}
303
304impl Flow {
305 fn stop(&self) -> bool {
306 matches!(self, Self::Stop)
307 }
308}
309
310impl<'a> Tour<'a> {
311 fn new(sandbox: &'a Path, topic: &'a str) -> Self {
312 Self {
313 sandbox,
314 topic,
315 term: Term::stdout(),
316 title: String::new(),
317 lines: Vec::new(),
318 }
319 }
320
321 fn banner(&mut self, title: &str) {
324 self.title = title.to_owned();
325 self.lines.clear();
326 }
327
328 fn say(&mut self, line: &str) {
330 self.lines.push(style::dim(line));
331 }
332
333 fn note(&mut self, command: &str) {
336 self.lines.push(format!("{} {command}", style::dim("$")));
337 }
338
339 fn stk(&mut self, args: &[&str]) -> Result<()> {
342 let output = self.run_stk(args)?;
343 if !output.status.success() {
344 bail!("`git stk {}` failed in the sandbox", args.join(" "));
345 }
346 Ok(())
347 }
348
349 fn stk_fails(&mut self, args: &[&str]) -> Result<()> {
351 let output = self.run_stk(args)?;
352 if output.status.success() {
353 bail!(
354 "`git stk {}` was expected to stop on the conflict",
355 args.join(" ")
356 );
357 }
358 Ok(())
359 }
360
361 fn run_stk(&mut self, args: &[&str]) -> Result<Output> {
362 self.note(&format!("git stk {}", args.join(" ")));
363 let binary = env::current_exe().context("failed to locate the running binary")?;
364 let output = capture(self.sandbox, &binary, args)?;
365 self.absorb_output(&output);
366 Ok(output)
367 }
368
369 fn show_git(&mut self, display: &str, args: &[&str]) -> Result<()> {
371 self.note(display);
372 let output = capture(self.sandbox, OsStr::new("git"), args)?;
373 self.absorb_output(&output);
374 if !output.status.success() {
375 bail!("`{display}` failed in the sandbox");
376 }
377 Ok(())
378 }
379
380 fn commit(&mut self, file: &str, contents: &str, message: &str) -> Result<()> {
382 self.note(&format!("edit {file}, then git commit -m {message:?}"));
383 fs::write(self.sandbox.join(file), contents).context("failed to write sandbox file")?;
384 run_git(self.sandbox, &["add", file])?;
385 run_git(self.sandbox, &["commit", "-q", "-m", message])
386 }
387
388 fn edit_and_add(&mut self, file: &str, contents: &str) -> Result<()> {
391 self.note(&format!("edit {file}, then git add {file}"));
392 fs::write(self.sandbox.join(file), contents).context("failed to write sandbox file")?;
393 run_git(self.sandbox, &["add", file])
394 }
395
396 fn absorb_output(&mut self, output: &Output) {
398 for stream in [&output.stdout, &output.stderr] {
399 let text = String::from_utf8_lossy(stream);
400 let text = text.trim_end_matches(['\n', '\r']);
401 if text.is_empty() {
402 continue;
403 }
404 for line in text.split('\n') {
405 self.lines.push(line.trim_end_matches('\r').to_owned());
406 }
407 }
408 self.lines.push(String::new());
409 }
410
411 fn pause(&mut self) -> Result<Flow> {
413 self.present("j/k/up/down scroll - space/pgdn page - enter continue - q quit")
414 }
415
416 fn finish(&mut self) -> Result<()> {
418 self.present("j/k/up/down scroll - enter/q to finish")?;
419 Ok(())
420 }
421
422 fn present(&mut self, hint: &str) -> Result<Flow> {
425 self.term.hide_cursor().ok();
426 self.term.clear_screen().ok();
427
428 let mut scroll = 0usize;
429 let flow = loop {
430 let (rows, cols) = self.term.size();
431 let (rows, cols) = (rows as usize, cols as usize);
432 let body = rows.saturating_sub(2).max(1);
433 let max_scroll = self.lines.len().saturating_sub(body);
434 scroll = scroll.min(max_scroll);
435 self.draw(scroll, cols, body, hint)?;
436
437 match self.term.read_key() {
438 Ok(Key::ArrowDown | Key::Char('j')) => scroll = (scroll + 1).min(max_scroll),
439 Ok(Key::ArrowUp | Key::Char('k')) => scroll = scroll.saturating_sub(1),
440 Ok(Key::PageDown | Key::Char(' ')) => scroll = (scroll + body).min(max_scroll),
441 Ok(Key::PageUp) => scroll = scroll.saturating_sub(body),
442 Ok(Key::Home | Key::Char('g')) => scroll = 0,
443 Ok(Key::End | Key::Char('G')) => scroll = max_scroll,
444 Ok(Key::Enter) => break Flow::Continue,
445 Ok(Key::Char('q') | Key::Escape | Key::CtrlC) => break Flow::Stop,
446 Ok(_) => {}
447 Err(_) => break Flow::Stop,
448 }
449 };
450
451 self.term.show_cursor().ok();
452 self.term.clear_screen().ok();
453 Ok(flow)
454 }
455
456 fn draw(&self, scroll: usize, cols: usize, body: usize, hint: &str) -> Result<()> {
460 let bar = Style::new().invert();
461 let header = format!("{} - {}", self.topic, self.title);
462 let mut frame = style::paint(bar, &fit(&format!(" {header}"), cols));
463
464 for row in 0..body {
465 frame.push('\n');
466 let line = self.lines.get(scroll + row).map_or("", String::as_str);
467 frame.push_str(&fit(line, cols));
468 }
469
470 let scrollable = self.lines.len() > body;
471 let footer = if scrollable {
472 format!(
473 " {hint} [{}/{}]",
474 (scroll + body).min(self.lines.len()),
475 self.lines.len()
476 )
477 } else {
478 format!(" {hint}")
479 };
480 frame.push('\n');
481 frame.push_str(&style::paint(bar, &fit(&footer, cols)));
482
483 self.term.move_cursor_to(0, 0)?;
484 print!("{frame}");
485 std::io::stdout()
486 .flush()
487 .context("failed to draw the guide")?;
488 Ok(())
489 }
490}
491
492fn fit(line: &str, width: usize) -> String {
494 let truncated = truncate_str(line, width, "…");
495 pad_str(&truncated, width, Alignment::Left, None).into_owned()
496}
497
498fn setup_sandbox(sandbox: &Path) -> Result<()> {
499 fs::create_dir_all(sandbox).context("failed to create the sandbox")?;
500 run_git(sandbox, &["init", "-q", "-b", "main"])?;
501 run_git(sandbox, &["config", "user.email", "guide@git-stk.dev"])?;
502 run_git(sandbox, &["config", "user.name", "git-stk guide"])?;
503 run_git(sandbox, &["config", "stk.provider", "demo"])?;
504 run_git(sandbox, &["config", "stk.noUpdateCheck", "true"])?;
505 fs::write(sandbox.join("README.md"), "# guide sandbox\n").context("failed to seed sandbox")?;
506 run_git(sandbox, &["add", "README.md"])?;
507 run_git(sandbox, &["commit", "-q", "-m", "initial commit"])?;
508 Ok(())
509}
510
511fn capture(sandbox: &Path, program: impl AsRef<OsStr>, args: &[&str]) -> Result<Output> {
514 let program = program.as_ref();
515 isolated(Command::new(program).args(args).current_dir(sandbox))
516 .env("CLICOLOR_FORCE", "1")
517 .stdin(Stdio::null())
518 .output()
519 .with_context(|| format!("failed to run {} in the sandbox", program.to_string_lossy()))
520}
521
522fn run_git(sandbox: &Path, args: &[&str]) -> Result<()> {
524 let status = isolated(Command::new("git").args(args).current_dir(sandbox))
525 .status()
526 .context("failed to run git in the sandbox")?;
527 if !status.success() {
528 bail!("`git {}` failed in the sandbox", args.join(" "));
529 }
530 Ok(())
531}
532
533fn isolated(command: &mut Command) -> &mut Command {
536 command
537 .env("GIT_CONFIG_GLOBAL", nul_device())
538 .env("GIT_CONFIG_NOSYSTEM", "1")
539 .env("GIT_EDITOR", "true")
540}
541
542fn nul_device() -> PathBuf {
543 if cfg!(windows) {
544 PathBuf::from("NUL")
545 } else {
546 PathBuf::from("/dev/null")
547 }
548}
549
550fn banner(title: &str) {
551 anstream::println!("{}", style::paint(style::CURRENT, title));
552}
553
554fn say(line: &str) {
555 anstream::println!("{}", style::paint(style::DIM, line));
556}
557
558#[cfg(test)]
559mod tests {
560 use super::fit;
561 use console::measure_text_width;
562
563 #[test]
564 fn fit_pads_short_lines_to_exact_width() {
565 let fitted = fit("ab", 5);
566 assert_eq!(fitted, "ab ");
567 assert_eq!(measure_text_width(&fitted), 5);
568 }
569
570 #[test]
571 fn fit_truncates_long_lines_to_exact_width() {
572 let fitted = fit("abcdefghij", 4);
573 assert_eq!(measure_text_width(&fitted), 4);
574 assert!(fitted.ends_with('…'));
575 }
576
577 #[test]
578 fn fit_measures_width_ignoring_ansi() {
579 let fitted = fit("\x1b[31mred\x1b[0m", 6);
581 assert_eq!(measure_text_width(&fitted), 6);
582 assert!(fitted.contains("\x1b[31m"));
583 }
584}