Skip to main content

git_stk/commands/
guide.rs

1use std::ffi::OsStr;
2use std::io::{IsTerminal, Write};
3use std::path::{Path, PathBuf};
4use std::process::{Command, Output, Stdio};
5use std::{env, fs};
6
7use anstyle::Style;
8use anyhow::{Context, Result, bail};
9use console::{Alignment, Key, Term, pad_str, truncate_str};
10use dialoguer::theme::ColorfulTheme;
11use dialoguer::{Confirm, Select};
12
13use crate::commands::Run;
14use crate::style;
15
16type Walk = fn(&mut Tour) -> Result<()>;
17
18/// The available tours: (topic, menu description, runner).
19const TOPICS: &[(&str, &str, Walk)] = &[
20    ("intro", "create, submit, restack, and land a stack", intro),
21    (
22        "conflicts",
23        "when a restack stops: resolve, continue, abort",
24        conflicts,
25    ),
26    ("repair", "rebuild lost stack metadata", repair),
27    (
28        "absorb",
29        "fold review fixes back into the commits they belong to",
30        absorb,
31    ),
32];
33
34/// Walk the stacked workflow in a disposable sandbox repository.
35#[derive(Debug, clap::Args)]
36pub struct Guide {
37    /// Which tour to run; omit for a menu.
38    #[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    // Hand the sandbox over or clean it up, whether or not the tour ran dry.
94    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
286/// One full-screen step: a pinned title, a scrollable body of narration and
287/// captured command output, and a footer of scroll hints. The tour functions
288/// build a screen with `banner`/`say`/`stk`/..., then `pause` (or `finish`)
289/// renders it and waits for the reader.
290struct Tour<'a> {
291    sandbox: &'a Path,
292    topic: &'a str,
293    term: Term,
294    title: String,
295    lines: Vec<String>,
296}
297
298/// What the reader chose at a `pause`: move on, or quit the tour.
299enum 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    /// Start a fresh screen with `title`. Does not render: content accrues
322    /// until the next `pause`/`finish`.
323    fn banner(&mut self, title: &str) {
324        self.title = title.to_owned();
325        self.lines.clear();
326    }
327
328    /// A line of narration.
329    fn say(&mut self, line: &str) {
330        self.lines.push(style::dim(line));
331    }
332
333    /// A shell-prompt line for a step we narrate but do not capture output
334    /// from (e.g. a manual `git config --unset`).
335    fn note(&mut self, command: &str) {
336        self.lines.push(format!("{} {command}", style::dim("$")));
337    }
338
339    /// Run `git stk <args>` in the sandbox, showing the command and its
340    /// output. Fails if the command does.
341    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    /// Like `stk`, for the step that is supposed to stop (the conflict).
350    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    /// Run a raw `git` command and show it under `display` with its output.
370    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    /// Write `contents` to `file` and commit it, narrating the edit.
381    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    /// Write `contents` to `file` and stage it without committing - a review
389    /// fix, or a resolved conflict.
390    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    /// Append a captured command's output, then a blank separator line.
397    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    /// Render the current screen and wait for the reader to move on or quit.
412    fn pause(&mut self) -> Result<Flow> {
413        self.present("j/k/up/down scroll - space/pgdn page - enter continue - q quit")
414    }
415
416    /// Render the final screen; enter or q both end the tour.
417    fn finish(&mut self) -> Result<()> {
418        self.present("j/k/up/down scroll - enter/q to finish")?;
419        Ok(())
420    }
421
422    /// The pager: draw the framed screen and scroll it until the reader
423    /// presses enter (continue) or q/esc (stop).
424    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    /// Compose and paint one frame: header bar, `body` rows of content from
457    /// `scroll`, and a footer bar. Every row is exactly `cols` wide so each
458    /// frame fully overwrites the last.
459    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
492/// Truncate (ANSI-aware) to `width`, then pad with spaces to exactly `width`.
493fn 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
511/// Run a command in the sandbox and capture its output, forcing color on so
512/// the captured lines look like a real terminal session.
513fn 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
522/// Run a `git` command in the sandbox for its effect, discarding output.
523fn 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
533/// The user's global git config (e.g. stk.pushOnSubmit) must not leak into
534/// the tour.
535fn 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        // Three visible chars wrapped in color codes, padded to width 6.
580        let fitted = fit("\x1b[31mred\x1b[0m", 6);
581        assert_eq!(measure_text_width(&fitted), 6);
582        assert!(fitted.contains("\x1b[31m"));
583    }
584}