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
26#[derive(Debug, clap::Args)]
28pub struct Guide {
29 #[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 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
258fn 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
277fn 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
298fn 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
330fn 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}