git-stk 0.12.2

Git-native stacked branch workflow helper
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
//! Shared integration-test harness.
#![allow(dead_code)]

use std::{env, fs, path::Path, process::Command};

use tempfile::TempDir;

pub struct TestRepo {
    /// Held for its Drop: removing it takes the repo and anything beside it.
    dir: TempDir,
    root: std::path::PathBuf,
}

impl TestRepo {
    pub fn new() -> Self {
        Self::init(None)
    }

    /// A repo one level *below* the temp root, named `name`. Only needed by tests
    /// that exercise the default `stk.worktreeDir` - a `<repo>-worktrees` sibling
    /// of the repo - which then lands inside the tempdir and is cleaned up with
    /// it. Under `new` that sibling would be a stray directory in the system temp.
    pub fn new_in_subdir(name: &str) -> Self {
        Self::init(Some(name))
    }

    fn init(subdir: Option<&str>) -> Self {
        let dir = tempfile::tempdir().expect("create temp repo");
        let root = match subdir {
            Some(name) => {
                let root = dir.path().join(name);
                fs::create_dir_all(&root).expect("create repo dir");
                root
            }
            None => dir.path().to_path_buf(),
        };
        let repo = Self { dir, root };
        repo.git(["init", "--initial-branch", "main"]);
        repo.git(["config", "user.email", "test@example.com"]);
        repo.git(["config", "user.name", "Test User"]);
        repo.write("README.md", "# test repo\n");
        repo.git(["add", "README.md"]);
        repo.git(["commit", "-m", "initial commit"]);
        repo
    }

    pub fn path(&self) -> &Path {
        &self.root
    }

    pub fn write(&self, path: &str, contents: &str) {
        fs::write(self.path().join(path), contents).expect("write test file");
    }

    /// Isolate a command from the developer's global and system git config so
    /// the suite stays hermetic (e.g. a global stk.pushOnSubmit=true must not
    /// change test behavior).
    pub fn isolate_git_config(command: &mut Command) {
        command.env("GIT_CONFIG_GLOBAL", nul_device());
        command.env("GIT_CONFIG_NOSYSTEM", "1");
    }

    pub fn git<const N: usize>(&self, args: [&str; N]) -> String {
        let mut command = Command::new("git");
        Self::isolate_git_config(&mut command);
        let output = command
            .args(args)
            .current_dir(self.path())
            .output()
            .expect("run git command");

        assert!(
            output.status.success(),
            "git failed\nstdout:\n{}\nstderr:\n{}",
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        );

        String::from_utf8_lossy(&output.stdout).trim().to_owned()
    }

    pub fn git_status<const N: usize>(&self, args: [&str; N]) -> std::process::Output {
        let mut command = Command::new("git");
        Self::isolate_git_config(&mut command);
        command
            .args(args)
            .current_dir(self.path())
            .output()
            .expect("run git command")
    }

    pub fn stack_output<const N: usize>(&self, args: [&str; N]) -> std::process::Output {
        let mut command = self.stack();
        command.args(args).output().expect("run git-stk command")
    }

    pub fn supports_update_refs(&self) -> bool {
        let output = self.git_status(["rebase", "-h"]);
        let help = format!(
            "{}{}",
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        );
        // Match the name: git may render it as --[no-]update-refs.
        help.contains("update-refs")
    }

    pub fn commit_file(&self, path: &str, contents: &str, message: &str) {
        self.write(path, contents);
        self.git(["add", path]);
        self.git(["commit", "-m", message]);
    }

    pub fn stack(&self) -> assert_cmd::Command {
        self.stack_in(self.path())
    }

    /// Like [`stack`](Self::stack) but run from `dir` - e.g. a linked worktree
    /// of this repo - instead of the main worktree root.
    pub fn stack_in(&self, dir: &Path) -> assert_cmd::Command {
        let mut command = assert_cmd::Command::cargo_bin("git-stk").expect("git-stk binary");
        command.current_dir(dir);
        command.env("GIT_EDITOR", "true");
        command.env("GIT_CONFIG_GLOBAL", nul_device());
        command.env("GIT_CONFIG_NOSYSTEM", "1");
        // Hermetic color: ambient terminal settings must not restyle output.
        command.env_remove("CLICOLOR");
        command.env_remove("CLICOLOR_FORCE");
        command.env_remove("NO_COLOR");
        command
    }
}

/// A cross-platform fake for the external commands git-stk shells out to -
/// `gh`/`glab` by default, or any others (`cargo`, `git-stk`, `pwsh`) named
/// via [`commands`](FakeProvider::commands). Ordered rules are matched
/// against the joined arguments (first match wins, like an `sh` `case
/// "$*"`), realized by the `git-stk-fake-provider` helper binary. Replaces
/// the Unix-only `fake_cli` shell scripts so suites can run on Windows too.
pub struct FakeProvider {
    rules: Vec<serde_json::Value>,
    commands: Vec<String>,
    log: Option<String>,
}

impl Default for FakeProvider {
    fn default() -> Self {
        Self {
            rules: Vec::new(),
            commands: vec!["gh".into(), "glab".into()],
            log: None,
        }
    }
}

impl FakeProvider {
    pub fn new() -> Self {
        Self::default()
    }

    /// Install the fake under these command names instead of `gh`/`glab`
    /// (e.g. `cargo`, `git-stk`, `pwsh`). They all share one rule set.
    pub fn commands(mut self, names: &[&str]) -> Self {
        self.commands = names.iter().map(|n| n.to_string()).collect();
        self
    }

    /// Record every invocation's arguments to `file`, in order - for
    /// asserting the sequence of provider calls.
    pub fn log_all(mut self, file: &str) -> Self {
        self.log = Some(file.to_string());
        self
    }

    /// Respond with `stdout` when the joined args contain `needle`.
    pub fn on(mut self, needle: &str, stdout: &str) -> Self {
        self.rules
            .push(serde_json::json!({ "contains": needle, "stdout": stdout }));
        self
    }

    /// Like [`on`](Self::on), but only once `marker_file` exists. Pair it
    /// with a [`record`](Self::record) of the side effect that creates the
    /// marker to model a provider whose answer flips after a merge/retarget.
    pub fn on_after(mut self, needle: &str, marker_file: &str, stdout: &str) -> Self {
        self.rules.push(serde_json::json!({
            "contains": needle, "if_file": marker_file, "stdout": stdout,
        }));
        self
    }

    /// Respond with `stdout` and overwrite `record_file` with this
    /// invocation (last write wins, like `> file`).
    pub fn record(mut self, needle: &str, record_file: &str, stdout: &str) -> Self {
        self.rules.push(serde_json::json!({
            "contains": needle, "stdout": stdout, "record": record_file,
        }));
        self
    }

    /// Respond with `stdout` and append this invocation to `record_file`
    /// (every call kept, like `>> file`).
    pub fn record_append(mut self, needle: &str, record_file: &str, stdout: &str) -> Self {
        self.rules.push(serde_json::json!({
            "contains": needle, "stdout": stdout, "record": record_file, "append": true,
        }));
        self
    }

    /// A pending `gh pr checks` result: gh's exit code 8 with a table on
    /// stdout, recording the call so a later `on_after` rule can flip the
    /// review to merged on the same poll - modelling an out-of-band merge
    /// mid-wait.
    pub fn record_pending(mut self, needle: &str, record_file: &str, stdout: &str) -> Self {
        self.rules.push(serde_json::json!({
            "contains": needle, "stdout": stdout, "record": record_file, "exit": 8,
        }));
        self
    }

    /// Fail (exit 1) with `stderr` when the joined args contain `needle`.
    pub fn fail(mut self, needle: &str, stderr: &str) -> Self {
        self.rules
            .push(serde_json::json!({ "contains": needle, "stderr": stderr, "exit": 1 }));
        self
    }

    /// Fail (exit 1) with `stdout` as well as `stderr` - for a command like
    /// `gh pr checks` that prints its result table to stdout even when it
    /// exits non-zero on a failure.
    pub fn fail_with_stdout(mut self, needle: &str, stdout: &str, stderr: &str) -> Self {
        self.rules.push(serde_json::json!({
            "contains": needle, "stdout": stdout, "stderr": stderr, "exit": 1,
        }));
        self
    }

    /// The catch-all response (empty needle matches anything).
    pub fn fallback(mut self, stdout: &str) -> Self {
        self.rules
            .push(serde_json::json!({ "contains": "", "stdout": stdout }));
        self
    }

    /// A catch-all that fails (exit 1) - a guard asserting no unexpected call
    /// slips through.
    pub fn fallback_fail(mut self, stderr: &str) -> Self {
        self.rules
            .push(serde_json::json!({ "contains": "", "stderr": stderr, "exit": 1 }));
        self
    }

    /// Write the spec and drop copies of the fake binary (one per command
    /// name) on a PATH dir. Returns the env values the command needs.
    pub fn install(self, repo: &TestRepo) -> FakeProviderEnv {
        // Present only when built under `test-fakes` (always so via `just
        // test`); a `match` rather than `expect` keeps clippy happy while
        // still failing loudly if a bare `cargo test` reaches here.
        let bin = match option_env!("CARGO_BIN_EXE_git-stk-fake-provider") {
            Some(path) => path,
            None => panic!("build tests with the `test-fakes` feature (use `just test`)"),
        };

        // Live under .git so the fake binaries and spec never surface as
        // untracked files - some commands (e.g. `run`) refuse a dirty tree.
        let support = repo.path().join(".git").join("stk-fake");
        let bin_dir = support.join("bin");
        fs::create_dir_all(&bin_dir).expect("create fake bin dir");
        for name in &self.commands {
            let dest = bin_dir.join(format!("{name}{}", env::consts::EXE_SUFFIX));
            fs::copy(bin, &dest).expect("copy fake provider");
        }

        let spec_path = support.join("spec.json");
        let mut spec = serde_json::json!({ "rules": self.rules });
        if let Some(log) = self.log {
            spec["log"] = serde_json::Value::String(log);
        }
        fs::write(&spec_path, spec.to_string()).expect("write fake spec");

        let existing = env::var_os("PATH").unwrap_or_default();
        let mut dirs = vec![bin_dir];
        dirs.extend(env::split_paths(&existing));
        let path = env::join_paths(dirs).expect("join PATH");

        FakeProviderEnv {
            path: path.to_string_lossy().into_owned(),
            spec: spec_path.to_string_lossy().into_owned(),
        }
    }
}

/// The `PATH` and `STK_FAKE_SPEC` a faked command needs.
pub struct FakeProviderEnv {
    pub path: String,
    pub spec: String,
}

impl TestRepo {
    /// A `git stk` command wired to the given provider fake.
    pub fn stack_faked(&self, fake: &FakeProviderEnv) -> assert_cmd::Command {
        let mut command = self.stack();
        command
            .env("PATH", &fake.path)
            .env("STK_FAKE_SPEC", &fake.spec);
        command
    }
}

impl TestRepo {
    /// `git` with `input` on stdin, for the plumbing that reads it.
    fn git_stdin<const N: usize>(&self, args: [&str; N], input: &str) -> String {
        use std::io::Write;
        use std::process::Stdio;

        let mut command = Command::new("git");
        Self::isolate_git_config(&mut command);
        let mut child = command
            .args(args)
            .current_dir(self.path())
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .expect("spawn git command");
        child
            .stdin
            .as_mut()
            .expect("git stdin")
            .write_all(input.as_bytes())
            .expect("write git stdin");
        let output = child.wait_with_output().expect("run git command");
        assert!(output.status.success(), "git failed");
        String::from_utf8_lossy(&output.stdout).trim().to_owned()
    }

    /// Publish `json` as the shared stack metadata ref on `origin`, mirroring
    /// `git::write_blob_ref`. Lets a test stand in for another machine - an
    /// older git-stk, say - writing a document this version has to cope with.
    pub fn write_metadata_ref(&self, json: &str) {
        let blob = self.git_stdin(["hash-object", "-w", "--stdin"], json);
        let entry = format!("100644 blob {blob}\tstack.json\n");
        let tree = self.git_stdin(["mktree"], &entry);
        let commit = self.git(["commit-tree", &tree, "-m", "stack metadata"]);
        self.git(["update-ref", "refs/stk/metadata", &commit]);
        self.git(["push", "--force", "origin", "refs/stk/metadata"]);
    }

    /// Create a bare repo, add it as origin, and push the given branches.
    pub fn add_bare_origin(&self, branches: &[&str]) -> TempDir {
        let bare = tempfile::tempdir().expect("create bare remote");
        Command::new("git")
            .args(["init", "--bare", "--initial-branch", "main"])
            .arg(bare.path())
            .output()
            .expect("init bare remote");

        self.git(["remote", "add", "origin", bare.path().to_str().unwrap()]);
        for branch in branches {
            self.git(["push", "-u", "origin", branch]);
        }
        bare
    }

    pub fn remote_sha(&self, bare: &TempDir, branch: &str) -> String {
        let output = Command::new("git")
            .args(["rev-parse", branch])
            .current_dir(bare.path())
            .output()
            .expect("rev-parse remote branch");
        assert!(output.status.success(), "remote branch {branch} missing");
        String::from_utf8_lossy(&output.stdout).trim().to_owned()
    }
}

impl TestRepo {
    /// Run the bash completion harness: source the registration script, set
    /// up COMP_WORDS for `git stk <words...><TAB>`, invoke the _git_stk shim,
    /// and return COMPREPLY entries.
    /// Unix-only: completion assertions run through a bash harness.
    #[cfg(unix)]
    pub fn complete_git_stk(&self, words: &[&str]) -> String {
        let output = self.stack_output(["completions", "bash"]).stdout;
        let script_path = self.path().join("completions.bash");
        fs::write(&script_path, output).expect("write completions script");

        let comp_words = words
            .iter()
            .map(|word| format!("\"{word}\""))
            .collect::<Vec<_>>()
            .join(" ");
        let harness = format!(
            r#"source "{}"
COMP_WORDS=(git stk {comp_words})
COMP_CWORD={}
_git_stk
printf '%s\n' "${{COMPREPLY[@]}}"
"#,
            script_path.display(),
            words.len() + 1,
        );

        let mut command = Command::new("bash");
        Self::isolate_git_config(&mut command);
        let result = command
            .args(["-c", &harness])
            .current_dir(self.path())
            .output()
            .expect("run bash completion harness");
        assert!(
            result.status.success(),
            "harness failed: {}",
            String::from_utf8_lossy(&result.stderr)
        );
        String::from_utf8_lossy(&result.stdout).into_owned()
    }
}

/// Git's "no config file" sink, per platform.
pub fn nul_device() -> &'static str {
    if cfg!(windows) { "NUL" } else { "/dev/null" }
}