Skip to main content

git_stk/commands/
guide.rs

1use std::io::IsTerminal;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::{env, fs};
5
6use anyhow::{Context, Result, bail};
7use dialoguer::theme::ColorfulTheme;
8use dialoguer::{Confirm, Select};
9
10use crate::commands::Run;
11use crate::style;
12
13type Tour = fn(&Path) -> Result<()>;
14
15/// The available tours: (topic, menu description, runner).
16const TOPICS: &[(&str, &str, Tour)] = &[
17    ("intro", "create, submit, restack, and land a stack", intro),
18    (
19        "conflicts",
20        "when a restack stops: resolve, continue, abort",
21        conflicts,
22    ),
23    ("repair", "rebuild lost stack metadata", repair),
24    (
25        "absorb",
26        "fold review fixes back into the commits they belong to",
27        absorb,
28    ),
29];
30
31/// Walk the stacked workflow in a disposable sandbox repository.
32#[derive(Debug, clap::Args)]
33pub struct Guide {
34    /// Which tour to run; omit for a menu.
35    #[arg(value_parser = clap::builder::PossibleValuesParser::new(["intro", "conflicts", "repair", "absorb"]))]
36    topic: Option<String>,
37}
38
39impl Run for Guide {
40    fn run(self) -> Result<()> {
41        guide(self.topic.as_deref())
42    }
43}
44
45fn guide(topic: Option<&str>) -> Result<()> {
46    if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
47        bail!("the guide is interactive; run it from a terminal");
48    }
49
50    banner("git stk guide");
51    say("Short interactive tours. Everything happens in a disposable sandbox");
52    say("repository - your real work is never touched, and a built-in demo");
53    say("provider stands in for GitHub: same commands, no network.");
54    println!();
55
56    let chosen = match topic {
57        Some(topic) => TOPICS
58            .iter()
59            .find(|(name, _, _)| *name == topic)
60            .context("unknown guide topic")?,
61        None => {
62            let items: Vec<String> = TOPICS
63                .iter()
64                .map(|(name, blurb, _)| format!("{name} - {blurb}"))
65                .collect();
66            let index = Select::with_theme(&ColorfulTheme::default())
67                .with_prompt("which tour?")
68                .items(&items)
69                .default(0)
70                .interact()
71                .context("nothing chosen")?;
72            &TOPICS[index]
73        }
74    };
75    println!();
76
77    let sandbox = env::temp_dir().join(format!("git-stk-guide-{}", std::process::id()));
78    if sandbox.exists() {
79        fs::remove_dir_all(&sandbox).context("failed to clear an old sandbox")?;
80    }
81    say(&format!("sandbox: {}", sandbox.display()));
82    println!();
83    setup_sandbox(&sandbox)?;
84
85    let finished = (chosen.2)(&sandbox);
86    println!();
87
88    // Hand the sandbox over or clean it up, whether or not the tour ran dry.
89    let delete = Confirm::with_theme(&ColorfulTheme::default())
90        .with_prompt("delete the sandbox?")
91        .default(true)
92        .interact()
93        .unwrap_or(true);
94    if delete {
95        fs::remove_dir_all(&sandbox).context("failed to remove the sandbox")?;
96        say("sandbox removed");
97    } else {
98        say(&format!("kept: cd {}", sandbox.display()));
99        say("it uses `git config stk.provider demo`, so every command works offline");
100    }
101
102    finished
103}
104
105fn intro(sandbox: &Path) -> Result<()> {
106    banner("1/5 - a stack is just branches");
107    say("Each branch carries one reviewable change and knows its parent.");
108    say("`new` creates a child of wherever you stand:");
109    run_stk(sandbox, &["new", "feature/login"])?;
110    commit(
111        sandbox,
112        "login.txt",
113        "username + password form\n",
114        "add login form",
115    )?;
116    run_stk(sandbox, &["new", "feature/avatar"])?;
117    commit(sandbox, "avatar.txt", "round avatars\n", "add avatars")?;
118    say("Two branches, stacked. `list` draws the pile, trunk at the bottom:");
119    run_stk(sandbox, &["list"])?;
120    if !proceed()? {
121        return Ok(());
122    }
123
124    banner("2/5 - submit the whole stack");
125    say("One command opens (or updates) a review per branch, parent-first,");
126    say("and writes a live stack overview into every description:");
127    run_stk(sandbox, &["submit", "--stack"])?;
128    run_stk(sandbox, &["status"])?;
129    if !proceed()? {
130        return Ok(());
131    }
132
133    banner("3/5 - parents move; restack follows");
134    say("Review feedback lands on the bottom branch:");
135    run_stk(sandbox, &["down"])?;
136    commit(
137        sandbox,
138        "login.txt",
139        "username + password form\nremember me\n",
140        "add remember me",
141    )?;
142    say("The child is now behind its parent - `list` notices:");
143    run_stk(sandbox, &["list"])?;
144    say("`restack` rebases every descendant back onto its parent:");
145    run_stk(sandbox, &["restack"])?;
146    run_stk(sandbox, &["top"])?;
147    if !proceed()? {
148        return Ok(());
149    }
150
151    banner("4/5 - land the stack");
152    say("`merge --all` repeats merge-bottom-then-sync until the stack is");
153    say("complete: children retarget, merged branches vanish, the overview");
154    say("in every review restyles as history accumulates:");
155    run_stk(sandbox, &["merge", "--all", "-y"])?;
156    if !proceed()? {
157        return Ok(());
158    }
159
160    banner("5/5 - nothing left but trunk");
161    run_stk(sandbox, &["list"])?;
162    say("That is the whole loop: new -> commit -> submit -> merge.");
163    say("On a real repo the provider is detected from your remote; day to day");
164    say("you mostly run `git stk new`, `git stk submit --stack`, and");
165    say("`git stk merge --all`. `git stk status` and the hints fill the gaps.");
166    Ok(())
167}
168
169fn conflicts(sandbox: &Path) -> Result<()> {
170    banner("1/3 - set up a collision");
171    say("A two-branch stack where both branches touch the same line:");
172    run_stk(sandbox, &["new", "feature/payment"])?;
173    commit(
174        sandbox,
175        "notes.txt",
176        "use stripe\n",
177        "choose payment provider",
178    )?;
179    run_stk(sandbox, &["new", "feature/receipts"])?;
180    commit(
181        sandbox,
182        "notes.txt",
183        "use stripe with receipts\n",
184        "email receipts",
185    )?;
186    say("Now the parent changes its mind about that very line:");
187    run_stk(sandbox, &["down"])?;
188    commit(sandbox, "notes.txt", "use paypal\n", "switch to paypal")?;
189    if !proceed()? {
190        return Ok(());
191    }
192
193    banner("2/3 - the restack stops, with context");
194    say("Replaying the child onto the rewritten parent cannot succeed; the");
195    say("restack stops, shows git's conflict output, and says what to do:");
196    run_stk_failing(sandbox, &["restack"])?;
197    if !proceed()? {
198        return Ok(());
199    }
200
201    banner("3/3 - resolve, then continue");
202    say("Fix the file and stage it, exactly like any rebase conflict:");
203    resolve(sandbox, "notes.txt", "use paypal with receipts\n")?;
204    say("`continue` picks the restack back up where it stopped");
205    say("(`git stk abort` would have unwound it instead):");
206    run_stk(sandbox, &["continue"])?;
207    run_stk(sandbox, &["list"])?;
208    say("Conflicts interrupt the restack, never break it: resolve, continue,");
209    say("and the rest of the stack follows.");
210    Ok(())
211}
212
213fn repair(sandbox: &Path) -> Result<()> {
214    banner("1/3 - a healthy stack");
215    run_stk(sandbox, &["new", "feature/api"])?;
216    commit(sandbox, "api.txt", "endpoints\n", "add api")?;
217    run_stk(sandbox, &["new", "feature/ui"])?;
218    commit(sandbox, "ui.txt", "buttons\n", "add ui")?;
219    run_stk(sandbox, &["submit", "--stack"])?;
220    if !proceed()? {
221        return Ok(());
222    }
223
224    banner("2/3 - the metadata vanishes");
225    say("Stack parents are plain `branch.<name>.stkParent` entries in");
226    say(".git/config - annotations, not state. Suppose one gets lost:");
227    shell_step("git config --unset branch.feature/ui.stkParent");
228    git(
229        sandbox,
230        &["config", "--unset", "branch.feature/ui.stkParent"],
231    )?;
232    say("The stack no longer knows feature/ui belongs to it:");
233    run_stk(sandbox, &["list"])?;
234    if !proceed()? {
235        return Ok(());
236    }
237
238    banner("3/3 - repair rebuilds it");
239    say("`repair` re-derives parents from review bases (when a provider is");
240    say("reachable) and branch ancestry, and verifies recorded fork points:");
241    run_stk(sandbox, &["repair", "--dry-run"])?;
242    run_stk(sandbox, &["repair"])?;
243    run_stk(sandbox, &["list"])?;
244    say("Branches are the real state; metadata is always recoverable.");
245    say("Anything repair cannot resolve safely, it reports for a manual");
246    say("`git stk adopt`.");
247    Ok(())
248}
249
250fn absorb(sandbox: &Path) -> Result<()> {
251    banner("1/3 - fixes scattered across the stack");
252    say("A two-branch stack, each branch owning one file:");
253    run_stk(sandbox, &["new", "feature/login"])?;
254    commit(
255        sandbox,
256        "login.txt",
257        "username + password form\n",
258        "add login form",
259    )?;
260    run_stk(sandbox, &["new", "feature/avatar"])?;
261    commit(sandbox, "avatar.txt", "round avatars\n", "add avatars")?;
262    say("Review comes back: two small fixes, one on each branch's file.");
263    say("You make both edits from the top and stage them, as usual:");
264    stage_fix(sandbox, "login.txt", "username + password form, with 2FA\n")?;
265    stage_fix(sandbox, "avatar.txt", "round avatars, lazy-loaded\n")?;
266    say("Both fixes sit staged together, but each belongs to a different commit");
267    say("further down the stack:");
268    run_stk(sandbox, &["status"])?;
269    if !proceed()? {
270        return Ok(());
271    }
272
273    banner("2/3 - preview where each hunk lands");
274    say("`absorb` blames every staged hunk and routes it to the commit that");
275    say("introduced the lines it touches. `--dry-run` shows the plan first:");
276    run_stk(sandbox, &["absorb", "--dry-run"])?;
277    if !proceed()? {
278        return Ok(());
279    }
280
281    banner("3/3 - fold them in");
282    say("Run it for real: each fix becomes a `fixup!` of its owning commit, an");
283    say("autosquash rebase folds them in, and every branch ref rides along:");
284    run_stk(sandbox, &["absorb"])?;
285    say("The history reads as if the fixes were always there - no extra commits:");
286    shell_step("git log --oneline main..feature/avatar");
287    git(
288        sandbox,
289        &["--no-pager", "log", "--oneline", "main..feature/avatar"],
290    )?;
291    println!();
292    say("Hunks that cannot be attributed - brand-new lines, trunk-owned lines, a");
293    say("hunk spanning two commits - are left staged and reported, never guessed.");
294    Ok(())
295}
296
297fn setup_sandbox(sandbox: &Path) -> Result<()> {
298    fs::create_dir_all(sandbox).context("failed to create the sandbox")?;
299    git(sandbox, &["init", "-q", "-b", "main"])?;
300    git(sandbox, &["config", "user.email", "guide@git-stk.dev"])?;
301    git(sandbox, &["config", "user.name", "git-stk guide"])?;
302    git(sandbox, &["config", "stk.provider", "demo"])?;
303    git(sandbox, &["config", "stk.noUpdateCheck", "true"])?;
304    fs::write(sandbox.join("README.md"), "# guide sandbox\n").context("failed to seed sandbox")?;
305    git(sandbox, &["add", "README.md"])?;
306    git(sandbox, &["commit", "-q", "-m", "initial commit"])?;
307    Ok(())
308}
309
310/// Run the tool itself inside the sandbox, narrating the invocation. The
311/// child inherits the terminal so its colors come through.
312fn run_stk(sandbox: &Path, args: &[&str]) -> Result<()> {
313    anstream::println!(
314        "{} {}",
315        style::paint(style::DIM, "$ git stk"),
316        args.join(" ")
317    );
318    let binary = env::current_exe().context("failed to locate the running binary")?;
319    let status = isolated(Command::new(binary).args(args).current_dir(sandbox))
320        .status()
321        .context("failed to run git-stk in the sandbox")?;
322    if !status.success() {
323        bail!("`git stk {}` failed in the sandbox", args.join(" "));
324    }
325    println!();
326    Ok(())
327}
328
329/// Like [`run_stk`], for the step that is supposed to stop (the conflict).
330fn run_stk_failing(sandbox: &Path, args: &[&str]) -> Result<()> {
331    anstream::println!(
332        "{} {}",
333        style::paint(style::DIM, "$ git stk"),
334        args.join(" ")
335    );
336    let binary = env::current_exe().context("failed to locate the running binary")?;
337    let status = isolated(Command::new(binary).args(args).current_dir(sandbox))
338        .status()
339        .context("failed to run git-stk in the sandbox")?;
340    if status.success() {
341        bail!(
342            "`git stk {}` was expected to stop on the conflict",
343            args.join(" ")
344        );
345    }
346    println!();
347    Ok(())
348}
349
350/// Resolve a conflicted file: write the merged contents and stage them.
351fn resolve(sandbox: &Path, file: &str, contents: &str) -> Result<()> {
352    shell_step(&format!("edit {file}, then git add {file}"));
353    fs::write(sandbox.join(file), contents).context("failed to write sandbox file")?;
354    git(sandbox, &["add", file])
355}
356
357/// Edit a tracked file and stage the change, without committing - a review
358/// fix waiting to be absorbed.
359fn stage_fix(sandbox: &Path, file: &str, contents: &str) -> Result<()> {
360    shell_step(&format!("edit {file}, then git add {file}"));
361    fs::write(sandbox.join(file), contents).context("failed to write sandbox file")?;
362    git(sandbox, &["add", file])
363}
364
365fn shell_step(narration: &str) {
366    anstream::println!("{} {narration}", style::paint(style::DIM, "$"));
367}
368
369fn commit(sandbox: &Path, file: &str, contents: &str, message: &str) -> Result<()> {
370    anstream::println!(
371        "{} edit {file}, then git commit -m {message:?}",
372        style::paint(style::DIM, "$"),
373    );
374    fs::write(sandbox.join(file), contents).context("failed to write sandbox file")?;
375    git(sandbox, &["add", file])?;
376    git(sandbox, &["commit", "-q", "-m", message])?;
377    Ok(())
378}
379
380fn git(sandbox: &Path, args: &[&str]) -> Result<()> {
381    let status = isolated(Command::new("git").args(args).current_dir(sandbox))
382        .status()
383        .context("failed to run git in the sandbox")?;
384    if !status.success() {
385        bail!("`git {}` failed in the sandbox", args.join(" "));
386    }
387    Ok(())
388}
389
390/// The user's global git config (e.g. stk.pushOnSubmit) must not leak into
391/// the tour.
392fn isolated(command: &mut Command) -> &mut Command {
393    command
394        .env("GIT_CONFIG_GLOBAL", nul_device())
395        .env("GIT_CONFIG_NOSYSTEM", "1")
396        .env("GIT_EDITOR", "true")
397}
398
399fn nul_device() -> PathBuf {
400    if cfg!(windows) {
401        PathBuf::from("NUL")
402    } else {
403        PathBuf::from("/dev/null")
404    }
405}
406
407fn proceed() -> Result<bool> {
408    println!();
409    Ok(Confirm::with_theme(&ColorfulTheme::default())
410        .with_prompt("continue?")
411        .default(true)
412        .interact()
413        .unwrap_or(false))
414}
415
416fn banner(title: &str) {
417    anstream::println!("{}", style::paint(style::CURRENT, title));
418}
419
420fn say(line: &str) {
421    anstream::println!("{}", style::paint(style::DIM, line));
422}