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
15const 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#[derive(Debug, clap::Args)]
33pub struct Guide {
34 #[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 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
310fn 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
329fn 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
350fn 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
357fn 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
390fn 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}