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