git-stk 0.12.3

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
mod common;

use common::{FakeProvider, TestRepo};
use predicates::prelude::PredicateBooleanExt;

/// A two-branch GitHub stack (feature/a <- feature/b) with the provider
/// configured. Returns the repo positioned on the top branch.
fn two_branch_stack() -> TestRepo {
    let repo = TestRepo::new();
    repo.git(["config", "stk.provider", "github"]);
    repo.stack().args(["new", "feature/a"]).assert().success();
    repo.commit_file("a.txt", "a\n", "a work");
    repo.stack().args(["new", "feature/b"]).assert().success();
    repo.commit_file("b.txt", "b\n", "b work");
    repo
}

/// A batched-annotation GraphQL response for feature/a (#9) and feature/b (#10)
/// with the given per-branch rollup state, queue entry, and reviews. `list`
/// reads only what it needs, so the same body serves the with/without-reviews
/// cases.
fn graphql_batch(
    a_state: &str,
    a_queue: &str,
    a_reviews: &str,
    b_state: &str,
    b_queue: &str,
    b_reviews: &str,
) -> String {
    format!(
        r#"{{"data":{{"repository":{{
        "p0":{{"nodes":[{{"number":9,"headRefName":"feature/a","mergeQueueEntry":{a_queue},
            "latestReviews":{{"nodes":{a_reviews}}},
            "commits":{{"nodes":[{{"commit":{{"statusCheckRollup":{{"contexts":{{"nodes":[
                {{"name":"build","status":"COMPLETED","conclusion":"{a_state}"}}]}}}}}}}}]}}}}]}},
        "p1":{{"nodes":[{{"number":10,"headRefName":"feature/b","mergeQueueEntry":{b_queue},
            "latestReviews":{{"nodes":{b_reviews}}},
            "commits":{{"nodes":[{{"commit":{{"statusCheckRollup":{{"contexts":{{"nodes":[
                {{"name":"build","status":"COMPLETED","conclusion":"{b_state}"}}]}}}}}}}}]}}}}]}}
    }}}}}}"#
    )
}

fn github_fake(repo: &TestRepo, graphql: &str) -> common::FakeProviderEnv {
    FakeProvider::new()
        .on("repo view", r#"{"nameWithOwner":"owner/repo"}"#)
        .on("api graphql", graphql)
        .fallback("[]")
        .install(repo)
}

#[test]
fn list_shows_a_status_dot_next_to_the_pr_number() {
    let repo = two_branch_stack();
    let graphql = graphql_batch("SUCCESS", "null", "[]", "FAILURE", "null", "[]");
    let fake = github_fake(&repo, &graphql);

    // A green check for the passing PR, a red one for the failing PR - each
    // next to its number.
    repo.stack_faked(&fake)
        .arg("list")
        .assert()
        .success()
        .stdout(predicates::str::contains("🟢 #9"))
        .stdout(predicates::str::contains("🔴 #10"));
}

#[test]
fn list_reviews_lists_the_review_tallies_under_each_branch() {
    let repo = two_branch_stack();
    let graphql = graphql_batch(
        "SUCCESS",
        "null",
        r#"[{"state":"APPROVED"},{"state":"APPROVED"}]"#,
        "FAILURE",
        "null",
        r#"[{"state":"CHANGES_REQUESTED"},{"state":"COMMENTED"}]"#,
    );
    let fake = github_fake(&repo, &graphql);

    repo.stack_faked(&fake)
        .args(["list", "--reviews"])
        .assert()
        .success()
        .stdout(predicates::str::contains("2 approvals"))
        .stdout(predicates::str::contains("1 requested change"))
        .stdout(predicates::str::contains("1 comment"));
}

#[test]
fn list_reviews_marks_a_pr_with_no_reviews() {
    let repo = two_branch_stack();
    let graphql = graphql_batch("SUCCESS", "null", "[]", "SUCCESS", "null", "[]");
    let fake = github_fake(&repo, &graphql);

    repo.stack_faked(&fake)
        .args(["list", "--reviews"])
        .assert()
        .success()
        .stdout(predicates::str::contains("(no reviews)"));
}

#[test]
fn list_marks_a_pr_that_sits_in_the_merge_queue() {
    let repo = two_branch_stack();
    // feature/b is queued (🕑), feature/a is not.
    let graphql = graphql_batch(
        "SUCCESS",
        "null",
        "[]",
        "SUCCESS",
        r#"{"state":"QUEUED"}"#,
        "[]",
    );
    let fake = github_fake(&repo, &graphql);

    repo.stack_faked(&fake)
        .arg("list")
        .assert()
        .success()
        // Queued -> just the clock, no CI dot alongside it.
        .stdout(predicates::str::contains("🕑 #10"))
        .stdout(predicates::str::contains("🟢 #10").not())
        // The un-queued branch keeps its normal CI dot.
        .stdout(predicates::str::contains("🟢 #9"));
}

#[test]
fn list_local_makes_no_provider_calls() {
    let repo = two_branch_stack();
    // Any provider invocation fails; --local must still succeed by never making
    // one, and shows no review numbers.
    let fake = FakeProvider::new()
        .fallback_fail("no network allowed under --local")
        .install(&repo);

    repo.stack_faked(&fake)
        .args(["list", "--local"])
        .assert()
        .success()
        .stdout(predicates::str::contains("feature/a"))
        .stdout(predicates::str::contains("feature/b"))
        .stdout(predicates::str::contains("#").not());
}

#[test]
fn list_reviews_conflicts_with_commits_format_and_local() {
    let repo = TestRepo::new();
    repo.stack().args(["new", "feature/a"]).assert().success();

    let conflicting: [&[&str]; 3] = [
        &["list", "--reviews", "--commits"],
        &["list", "--reviews", "--format", "markdown"],
        &["list", "--reviews", "--local"],
    ];
    for args in conflicting {
        repo.stack().args(args).assert().failure();
    }
}

#[test]
fn status_shows_a_status_dot_for_the_reviews_pr() {
    let repo = two_branch_stack();
    repo.git(["switch", "feature/a"]);
    let fake = FakeProvider::new()
        .on(
            "pr view 9 --json statusCheckRollup",
            r#"{"statusCheckRollup":[{"status":"COMPLETED","conclusion":"SUCCESS"}]}"#,
        )
        .on(
            "--head feature/a",
            r##"[{"number":9,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/9","title":"A"}]"##,
        )
        .fallback("[]")
        .install(&repo);

    repo.stack_faked(&fake)
        .args(["status", "feature/a"])
        .assert()
        .success()
        .stdout(predicates::str::contains("🟢 #9"));
}

/// The platform's own stack is distinct from the tree git-stk draws, so `list`
/// marks the layers it holds and `status` names it.
#[test]
fn list_and_status_show_a_platform_stack() {
    let repo = TestRepo::new();
    repo.git(["config", "stk.provider", "github"]);
    repo.stack().args(["new", "feature/a"]).assert().success();
    repo.commit_file("a.txt", "a\n", "a work");

    let graphql = r##"{"data":{"repository":{"p0":{"nodes":[{"number":12,"headRefName":"feature/a",
        "mergeQueueEntry":null,"stack":{"number":6,"size":3},"stackEntry":{"position":2},
        "commits":{"nodes":[{"commit":{"statusCheckRollup":{"state":"SUCCESS"}}}]}}]}}}}"##;
    let stacks = r##"[{"number":6,"open":true,"base":{"ref":"main"},"pull_requests":[
        {"number":11,"head":{"ref":"below"}},
        {"number":12,"head":{"ref":"feature/a"}},
        {"number":13,"head":{"ref":"above"}}]}]"##;
    let fake = FakeProvider::new()
        .log_all("calls.txt")
        .on("api graphql", graphql)
        .on("repo view", r##"{"nameWithOwner":"owner/repo"}"##)
        .on("repos/owner/repo/stacks", stacks)
        .on("feature/a", r##"[{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://example.com/12","title":"A work"}]"##)
        .fallback("[]")
        .install(&repo);

    repo.stack_faked(&fake)
        .args(["list"])
        .assert()
        .success()
        .stdout(predicates::str::contains("⛁2/3"));

    std::fs::remove_file(repo.path().join("calls.txt")).ok();
    repo.stack_faked(&fake)
        .args(["status"])
        .assert()
        .success()
        // Position and size from the same source `list` reads, so the two
        // cannot disagree about one stack's size.
        .stdout(predicates::str::contains("stack: github stack 6 (2 of 3)"));

    // And from the query `status` was already making: no paginated walk of a
    // listing that keeps every stack the repo has ever had.
    let calls = std::fs::read_to_string(repo.path().join("calls.txt")).expect("call log");
    assert!(
        !calls.contains("repos/owner/repo/stacks"),
        "status paid a REST listing call for what the annotate query answers:\n{calls}"
    );
}

/// A host that rejects the preview fields is a lasting property of that host,
/// so the notice lives under `--verbose`: silent by default, like the same
/// fact reaching `status` as a 404, but findable by anyone chasing a `⛁` that
/// never appears.
#[test]
fn list_says_so_under_verbose_when_the_host_rejects_the_stack_fields() {
    let repo = TestRepo::new();
    repo.git(["config", "stk.provider", "github"]);
    repo.stack().args(["new", "feature/a"]).assert().success();
    repo.commit_file("a.txt", "a\n", "a work");

    let without = r##"{"data":{"repository":{"p0":{"nodes":[{"number":12,"headRefName":"feature/a",
        "mergeQueueEntry":null,
        "commits":{"nodes":[{"commit":{"statusCheckRollup":{"state":"SUCCESS"}}}]}}]}}}}"##;
    let fake = FakeProvider::new()
        // The first query carries the stack fields and is rejected; the retry
        // drops them and succeeds.
        .fail_with_stdout(
            "stackEntry",
            "",
            "Field 'stack' doesn't exist on type 'PullRequest'",
        )
        .on("api graphql", without)
        .on("repo view", r##"{"nameWithOwner":"owner/repo"}"##)
        .fallback("[]")
        .install(&repo);

    // Silent by default - the fields are missing on every run, so saying so
    // every time would be permanent noise.
    repo.stack_faked(&fake)
        .args(["list"])
        .assert()
        .success()
        .stderr(predicates::str::contains("stacked-pull-request fields").not())
        .stdout(predicates::str::contains("").not());

    // Under `--verbose` it is there, which is what a missing `⛁` means.
    repo.stack_faked(&fake)
        .args(["-v", "list"])
        .assert()
        .success()
        .stderr(predicates::str::contains(
            "did not accept the stacked-pull-request fields",
        ));
}

/// And a failure that is not the fields must not be blamed on them. Offline,
/// rate-limited, or no `gh` at all: dropping the fields does not help, so the
/// warning would be a confident wrong diagnosis - and `list` still prints the
/// tree without annotations, as it promises to.
#[test]
fn list_stays_quiet_when_the_provider_fails_for_another_reason() {
    let repo = TestRepo::new();
    repo.git(["config", "stk.provider", "github"]);
    repo.stack().args(["new", "feature/a"]).assert().success();
    repo.commit_file("a.txt", "a\n", "a work");

    let fake = FakeProvider::new()
        .on("repo view", r##"{"nameWithOwner":"owner/repo"}"##)
        // Both attempts fail, with and without the fields.
        .fail("api graphql", "error connecting to api.github.com")
        .fallback("[]")
        .install(&repo);

    repo.stack_faked(&fake)
        .args(["list"])
        .assert()
        .success()
        .stdout(predicates::str::contains("feature/a"))
        .stderr(predicates::str::contains("stacked-pull-request fields").not());
}

/// `status` asks about one branch, so it must not pay for a listing of every
/// open review. Only GitHub can fold the answer into a batched query; every
/// other provider keeps the per-call path, and `mr list` here would be a walk
/// of the whole project for two facts about one branch.
#[test]
fn status_does_not_list_every_review_on_gitlab() {
    let repo = TestRepo::new();
    repo.git(["config", "stk.provider", "gitlab"]);
    repo.stack().args(["new", "feature/a"]).assert().success();
    repo.commit_file("a.txt", "a\n", "a work");

    let fake = FakeProvider::new()
        .log_all("calls.txt")
        .fallback(
            r##"[{"iid":34,"state":"opened","target_branch":"main","source_branch":"feature/a","web_url":"https://gitlab.com/o/r/-/merge_requests/34"}]"##,
        )
        .install(&repo);

    repo.stack_faked(&fake)
        .args(["status", "feature/a"])
        .assert()
        .success()
        .stdout(predicates::str::contains("review: !34 open"));

    let calls = std::fs::read_to_string(repo.path().join("calls.txt")).expect("call log");
    assert!(
        !calls.lines().any(|line| line.contains("mr list")
            && !line.contains("--source-branch")
            && !line.contains("--branch")),
        "status listed every open review to annotate one branch:\n{calls}"
    );
}

/// A merged review still names the stack it landed in - the platform keeps a
/// landed layer listed - and that answer comes from REST, since the annotate
/// query asks `states: OPEN` and cannot match. It must not spend the GraphQL
/// call discovering that.
#[test]
fn status_names_the_stack_of_a_merged_review_without_the_open_query() {
    let repo = TestRepo::new();
    repo.git(["config", "stk.provider", "github"]);
    repo.stack().args(["new", "feature/a"]).assert().success();
    repo.commit_file("a.txt", "a\n", "a work");

    let stacks = r##"[{"number":6,"open":true,"base":{"ref":"main"},"pull_requests":[
        {"number":11,"state":"open","head":{"ref":"below"}},
        {"number":12,"state":"closed","head":{"ref":"feature/a"}},
        {"number":13,"state":"open","head":{"ref":"above"}}]}]"##;
    let fake = FakeProvider::new()
        .log_all("calls.txt")
        .on("repo view", r##"{"nameWithOwner":"owner/repo"}"##)
        .on("repos/owner/repo/stacks", stacks)
        .on("feature/a --state merged", r##"[{"number":12,"state":"MERGED","baseRefName":"main","headRefName":"feature/a","url":"https://example.com/12","title":"A work"}]"##)
        .on("feature/a", "[]")
        .fallback("[]")
        .install(&repo);

    repo.stack_faked(&fake)
        .args(["status"])
        .assert()
        .success()
        .stdout(predicates::str::contains("stack: github stack 6 (2 of 3)"));

    let calls = std::fs::read_to_string(repo.path().join("calls.txt")).expect("call log");
    assert!(
        !calls.contains("api graphql"),
        "the annotate query asks states:OPEN and cannot match a merged review:\n{calls}"
    );
}

/// End to end for #331: a superseded cancelled run beside the successful
/// re-run of the same check, on the same head. `list` must read the newest per
/// check, not the whole list.
#[test]
fn list_ignores_a_cancelled_run_a_later_one_superseded() {
    let repo = two_branch_stack();
    let graphql = r#"{"data":{"repository":{
        "p0":{"nodes":[{"number":9,"headRefName":"feature/a","mergeQueueEntry":null,
            "commits":{"nodes":[{"commit":{"statusCheckRollup":{"contexts":{"nodes":[
                {"name":"plan","status":"COMPLETED","conclusion":"SUCCESS","startedAt":"2026-08-29T23:01:56Z"},
                {"name":"plan","status":"COMPLETED","conclusion":"CANCELLED","startedAt":"2026-08-29T23:01:52Z"}
            ]}}}}]}}]},
        "p1":{"nodes":[{"number":10,"headRefName":"feature/b","mergeQueueEntry":null,
            "commits":{"nodes":[{"commit":{"statusCheckRollup":{"contexts":{"nodes":[
                {"name":"plan","status":"COMPLETED","conclusion":"CANCELLED"}
            ]}}}}]}}]}
    }}}"#;
    let fake = github_fake(&repo, graphql);

    repo.stack_faked(&fake)
        .arg("list")
        .assert()
        .success()
        // The superseded cancellation is not a failure.
        .stdout(predicates::str::contains("🟢 #9"))
        .stdout(predicates::str::contains("🔴 #9").not())
        // But one that is the newest run of its check is not green either.
        .stdout(predicates::str::contains("⚪ #10"));
}