trusty-mpm 1.7.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
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
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
//! Tests for the registry-backed daemon `gh` identity lookup (#5850).
//!
//! Why: this module decides whether a housekeeping `gh pr list` runs as the
//! account a project is PINNED to or as whoever the machine's global gh config
//! names, so every arm needs its own assertion — and the failure arms need one
//! most, because a fail-OPEN there is silent by construction: the probe
//! succeeds, answers about the wrong account's view of the world, and the
//! survey reports "no pull request found".
//!
//! The happy path writes the registry through the REAL
//! [`crate::project::ProjectRegistry`] write path, so the sync reader under
//! test is proven against the bytes the operator-facing pinning paths actually
//! produce, not against a hand-written fixture that could drift from them. The
//! failure arms write `projects.json` by hand, because a real registry cannot
//! produce a corrupt one on demand.
//!
//! Nothing here shells out to `gh` or reads `~/.config/gh`: every config dir is
//! a temp dir and every `hosts.yml` is written by a fixture.
//! Test: itself.

use std::path::Path;

use super::pinned_gh_env_in;
use crate::core::trusty_tools_config::GithubConfig;
use crate::project::ProjectRegistry;
use crate::project::record::Project;
use crate::session_manager::worktree_reclaim_gh::resolve_daemon_gh_env_in;

/// A repository only the pinned account can see.
const ORIGIN: &str = "https://github.com/duettoresearch/jev-matching";

/// Build a minimal `Project` fixture, mirroring the register-route shape.
fn project(name: &str, repo_url: &str, gh_account: Option<&str>) -> Project {
    Project {
        name: name.to_string(),
        repo_url: repo_url.to_string(),
        default_branch: "main".to_string(),
        stack_hint: None,
        tags: vec![],
        description: None,
        gh_user: None,
        gh_account: gh_account.map(str::to_string),
        github: None,
        commit_name: None,
        commit_email: None,
        worktree: None,
    }
}

/// A `hosts.yml` naming one logged-in github.com account.
fn write_hosts_yml(config_dir: &Path, account: &str) {
    std::fs::create_dir_all(config_dir).expect("config dir");
    std::fs::write(
        config_dir.join("hosts.yml"),
        format!("github.com:\n    git_protocol: https\n    user: {account}\n"),
    )
    .expect("hosts.yml");
}

/// Write a `projects.json` verbatim — the shape a real registry publishes.
fn write_registry(registry_dir: &Path, body: &str) {
    std::fs::create_dir_all(registry_dir).expect("registry dir");
    std::fs::write(registry_dir.join("projects.json"), body).expect("projects.json");
}

/// The value of one resolved override.
fn value_of(env: &crate::core::gh_identity::GhEnv, key: &str) -> String {
    env.vars()
        .iter()
        .find(|(k, _)| k == key)
        .map(|(_, v)| v.clone())
        .unwrap_or_else(|| panic!("{key} not set; got {:?}", env.vars()))
}

/// 🔴 #5850 REGRESSION: the pin `tm --user <login> <url>` persists must reach a
/// daemon-side `gh` spawn.
///
/// Why this is the assertion: before this module the daemon read only the
/// STATIC `TrustyToolsConfig` `projects:` list, which the registry write path
/// never touches — so this exact registration resolved to an EMPTY binding and
/// `gh pr list` ran as the machine's global account. Registering through the
/// real `ProjectRegistry` and reading it back synchronously is what proves the
/// two halves meet.
/// Test: itself.
#[tokio::test]
async fn registry_pin_resolves_the_projects_scoped_config_dir() {
    let registry_dir = tempfile::tempdir().expect("tempdir");
    let config_dir = tempfile::tempdir().expect("tempdir");
    write_hosts_yml(config_dir.path(), "bob-duetto");

    let registry = ProjectRegistry::load(registry_dir.path())
        .await
        .expect("load");
    registry
        .register(Project {
            github: Some(GithubConfig {
                config_dir: Some(config_dir.path().to_path_buf()),
                ..GithubConfig::default()
            }),
            ..project("jev-matching", ORIGIN, Some("bob-duetto"))
        })
        .await
        .expect("register");

    let env = pinned_gh_env_in(registry_dir.path(), ORIGIN)
        .expect("a readable registry must not fail")
        .expect("the registered pin must resolve");
    assert_eq!(
        value_of(&env, "GH_CONFIG_DIR"),
        config_dir.path().to_string_lossy(),
        "the daemon must spawn gh inside the project's own scoped config dir"
    );
    // #6668: the binding is only real if an inherited token cannot outrank it.
    assert!(
        env.unset_vars().iter().any(|k| k == "GH_TOKEN"),
        "an inherited GH_TOKEN must be cleared; got {:?}",
        env.unset_vars()
    );
}

/// A repository no registered project names is not pinned, and falls through.
///
/// Why: this is the ONLY outcome allowed to reach the static-config fallback.
/// If it ever became an `Err`, every unregistered worktree on the host would
/// block instead of resolving the way it did before #5850.
/// Test: itself.
#[tokio::test]
async fn registry_pin_is_absent_for_an_unregistered_origin() {
    let registry_dir = tempfile::tempdir().expect("tempdir");
    let registry = ProjectRegistry::load(registry_dir.path())
        .await
        .expect("load");
    registry
        .register(project("widget", "https://github.com/acme/widget", None))
        .await
        .expect("register");

    assert_eq!(
        pinned_gh_env_in(registry_dir.path(), ORIGIN).expect("readable"),
        None,
        "an unregistered origin pins nothing and must fall through"
    );
}

/// A host that has registered nothing pins nothing.
///
/// Why: an absent `projects.json` is the state of every fresh install, and
/// folding it in with an unreadable one would turn first-run into a hard
/// failure for every worktree.
/// Test: itself.
#[test]
fn an_absent_registry_file_is_not_a_pin() {
    let registry_dir = tempfile::tempdir().expect("tempdir");
    assert_eq!(
        pinned_gh_env_in(registry_dir.path(), ORIGIN).expect("absent is not an error"),
        None
    );
}

/// 🔴 FAIL-CLOSED arm 1: a registry that cannot be READ blocks.
///
/// Why: an I/O failure leaves "does this repository pin an account?"
/// unanswered, and the wrong answer is not visible — `gh` would succeed as the
/// global account and report a private repository as having no pull requests.
/// `projects.json` is created as a DIRECTORY rather than chmod-ed, so the
/// failure is deterministic for any user, root included.
/// Test: itself.
#[test]
fn an_unreadable_registry_fails_closed() {
    let registry_dir = tempfile::tempdir().expect("tempdir");
    std::fs::create_dir_all(registry_dir.path().join("projects.json")).expect("blocker");
    let err = pinned_gh_env_in(registry_dir.path(), ORIGIN)
        .expect_err("an unreadable registry must refuse, never fall back");
    assert!(
        err.contains("refusing to probe"),
        "the refusal must say it refused; got: {err}"
    );
}

/// 🔴 FAIL-CLOSED arm 2a: a registry DOCUMENT that does not parse blocks.
/// Test: itself.
#[test]
fn a_malformed_registry_document_fails_closed() {
    let registry_dir = tempfile::tempdir().expect("tempdir");
    write_registry(registry_dir.path(), "{ this is not json");
    let err = pinned_gh_env_in(registry_dir.path(), ORIGIN)
        .expect_err("an unparsable registry must refuse, never fall back");
    assert!(
        err.contains("did not parse") && err.contains("refusing to probe"),
        "got: {err}"
    );
}

/// 🔴 FAIL-CLOSED arm 2b: a MATCHING record that does not parse blocks, and the
/// refusal names the record.
///
/// Why: a record whose `gh_account` is not a string could carry a pin this
/// process cannot read. Treating it as "unpinned" is the silent substitution.
/// Test: itself.
#[test]
fn a_malformed_matching_record_fails_closed() {
    let registry_dir = tempfile::tempdir().expect("tempdir");
    write_registry(
        registry_dir.path(),
        &format!(
            r#"{{"projects":{{"jev-matching":{{"name":"jev-matching","repo_url":"{ORIGIN}","default_branch":"main","gh_account":42}}}}}}"#
        ),
    );
    let err = pinned_gh_env_in(registry_dir.path(), ORIGIN)
        .expect_err("an unparsable matching record must refuse");
    assert!(
        err.contains("jev-matching") && err.contains("refusing to probe"),
        "the refusal must name the record; got: {err}"
    );
}

/// One broken record must not block every OTHER project on the host.
///
/// Why: 261 worktrees share one `projects.json`. Parsing the whole map
/// strictly would let a single corrupt entry suspend pull-request lookups for
/// the entire registry — a much worse outage than the one #5850 fixes.
/// Test: itself.
#[test]
fn a_malformed_unrelated_record_does_not_block_a_match() {
    let registry_dir = tempfile::tempdir().expect("tempdir");
    let config_dir = tempfile::tempdir().expect("tempdir");
    write_hosts_yml(config_dir.path(), "bob-duetto");
    write_registry(
        registry_dir.path(),
        &format!(
            r#"{{"projects":{{
                "broken":{{"repo_url":"https://github.com/acme/widget","gh_account":42}},
                "jev-matching":{{"name":"jev-matching","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"{}"}}}}
            }}}}"#,
            config_dir.path().display()
        ),
    );
    let env = pinned_gh_env_in(registry_dir.path(), ORIGIN)
        .expect("a sibling's corruption must not block this repository")
        .expect("the pin must resolve");
    assert_eq!(
        value_of(&env, "GH_CONFIG_DIR"),
        config_dir.path().to_string_lossy()
    );
}

/// 🔴 FAIL-CLOSED arm 3: a pinned config dir holding NO credential blocks, and
/// the refusal names the account.
///
/// Why: this is the shape `auto_persist_account_selection` leaves behind when
/// `gh auth login` has not been run inside the scoped dir yet. Falling back to
/// the global account here is the #5851 wrong-identity defect, restated for the
/// daemon's housekeeping path.
/// Test: itself.
#[test]
fn a_pinned_config_dir_without_a_credential_fails_closed() {
    let registry_dir = tempfile::tempdir().expect("tempdir");
    let config_dir = tempfile::tempdir().expect("tempdir");
    write_registry(
        registry_dir.path(),
        &format!(
            r#"{{"projects":{{"jev-matching":{{"name":"jev-matching","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"{}"}}}}}}}}"#,
            config_dir.path().display()
        ),
    );
    let err = pinned_gh_env_in(registry_dir.path(), ORIGIN)
        .expect_err("an empty config dir must refuse, never fall back");
    assert!(
        err.contains("bob-duetto") && err.contains("refusing to fall back"),
        "the refusal must name the pinned account; got: {err}"
    );
}

/// 🔴 FAIL-CLOSED arm 3b: an account pinned with NO config dir blocks.
///
/// Why (#5851): `gh auth token -u <account>` returns the globally-active
/// account's token on a keyring-backed host, so honouring an account-only pin
/// by minting a token is exactly the wrong-user probe this change prevents.
/// Test: itself.
#[tokio::test]
async fn an_account_only_pin_fails_closed_naming_the_account() {
    let registry_dir = tempfile::tempdir().expect("tempdir");
    let registry = ProjectRegistry::load(registry_dir.path())
        .await
        .expect("load");
    registry
        .register(project("jev-matching", ORIGIN, Some("bob-duetto")))
        .await
        .expect("register");

    let err = pinned_gh_env_in(registry_dir.path(), ORIGIN)
        .expect_err("an account-only pin must refuse, never fall back");
    assert!(
        err.contains("bob-duetto") && err.contains("refusing to probe"),
        "the refusal must name the pinned account; got: {err}"
    );
}

/// 🔴 FAIL-CLOSED arm 3c: a `token_env` pin whose variable is unset blocks.
///
/// Why: `resolve_gh_env` skips a `token_env` it cannot read, so the binding
/// resolves to NO identity. Reading that as "nothing pinned" hands the probe to
/// the static config and then the machine's global account.
/// Test: itself.
#[test]
fn an_unset_token_env_pin_fails_closed() {
    const VAR: &str = "TRUSTY_MPM_TEST_5850_UNSET_TOKEN_ENV";
    assert!(std::env::var_os(VAR).is_none(), "{VAR} must stay unset");
    let registry_dir = tempfile::tempdir().expect("tempdir");
    write_registry(
        registry_dir.path(),
        &format!(
            r#"{{"projects":{{"jev-matching":{{"name":"jev-matching","repo_url":"{ORIGIN}","default_branch":"main","github":{{"token_env":"{VAR}"}}}}}}}}"#
        ),
    );
    let err = pinned_gh_env_in(registry_dir.path(), ORIGIN)
        .expect_err("an unreadable token_env pin must refuse, never fall back");
    assert!(
        err.contains(VAR) && err.contains("refusing to fall back"),
        "the refusal must name the variable; got: {err}"
    );
}

/// A registered record with no `gh_account` and no `github` section is not a
/// pin, and falls through unchanged.
/// Test: itself.
#[test]
fn a_record_pinning_nothing_is_not_a_pin() {
    let registry_dir = tempfile::tempdir().expect("tempdir");
    write_registry(
        registry_dir.path(),
        &format!(
            r#"{{"projects":{{"jev-matching":{{"name":"jev-matching","repo_url":"{ORIGIN}","default_branch":"main"}}}}}}"#
        ),
    );
    assert_eq!(
        pinned_gh_env_in(registry_dir.path(), ORIGIN).expect("readable"),
        None,
        "a record that pins nothing must fall through"
    );
}

/// 🔴 #5850 REGRESSION: an unpinned record must not shadow a pinned one for
/// the same repository.
///
/// Why this is the assertion: the lookup used to take the FIRST matching record
/// in name order. `Jev-Matching` sorts before `jev-matching`, so the unpinned
/// one won, the lookup answered "nothing pinned", and the probe ran as the
/// machine's global account.
/// Test: itself.
#[test]
fn an_unpinned_record_does_not_shadow_a_pinned_one() {
    let registry_dir = tempfile::tempdir().expect("tempdir");
    let config_dir = tempfile::tempdir().expect("tempdir");
    write_hosts_yml(config_dir.path(), "bob-duetto");
    write_registry(
        registry_dir.path(),
        &format!(
            r#"{{"projects":{{
                "Jev-Matching":{{"name":"Jev-Matching","repo_url":"{ORIGIN}.git","default_branch":"main"}},
                "jev-matching":{{"name":"jev-matching","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"{}"}}}}
            }}}}"#,
            config_dir.path().display()
        ),
    );
    let env = pinned_gh_env_in(registry_dir.path(), ORIGIN)
        .expect("readable")
        .expect("the pinned record must win over the unpinned one");
    assert_eq!(
        value_of(&env, "GH_CONFIG_DIR"),
        config_dir.path().to_string_lossy()
    );
}

/// 🔴 FAIL-CLOSED: two records for one repository pinning DIFFERENT identities
/// block, naming both records.
///
/// Why: picking one by position would probe as an account the operator may not
/// have meant; there is no order in which that choice is right.
/// Test: itself.
#[test]
fn two_disagreeing_pins_for_one_repository_fail_closed() {
    let registry_dir = tempfile::tempdir().expect("tempdir");
    write_registry(
        registry_dir.path(),
        &format!(
            r#"{{"projects":{{
                "a-jev":{{"name":"a-jev","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bobmatnyc"}},
                "b-jev":{{"name":"b-jev","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto"}}
            }}}}"#
        ),
    );
    let err = pinned_gh_env_in(registry_dir.path(), ORIGIN)
        .expect_err("disagreeing pins must refuse, never pick one");
    assert!(
        err.contains("a-jev") && err.contains("b-jev") && err.contains("refusing to probe"),
        "the refusal must name both records; got: {err}"
    );
}

/// 🔴 FAIL-CLOSED: a MATCHING record that does not parse blocks even when
/// another matching record carries a usable pin.
///
/// Why: the broken record may pin a different account; "the other one pins" is
/// not an answer to that.
/// Test: itself.
#[test]
fn a_malformed_second_matching_record_fails_closed() {
    let registry_dir = tempfile::tempdir().expect("tempdir");
    let config_dir = tempfile::tempdir().expect("tempdir");
    write_hosts_yml(config_dir.path(), "bob-duetto");
    write_registry(
        registry_dir.path(),
        &format!(
            r#"{{"projects":{{
                "a-jev":{{"name":"a-jev","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"{}"}}}},
                "b-jev":{{"name":"b-jev","repo_url":"{ORIGIN}","default_branch":"main","gh_account":42}}
            }}}}"#,
            config_dir.path().display()
        ),
    );
    let err = pinned_gh_env_in(registry_dir.path(), ORIGIN)
        .expect_err("an unparsable matching record must refuse");
    assert!(
        err.contains("b-jev"),
        "the refusal must name the record; got: {err}"
    );
}

/// 🔴 #5850 REGRESSION: two records pinning the SAME login agree, and the one
/// carrying a `config_dir` wins.
///
/// Why this shape: `seed_from_config` writes `jev` with only `gh_account`, and
/// `tm <url> --user bob-duetto` later writes `jev-matching` with the same login
/// plus a scoped `config_dir`. Comparing whole records called that a conflict
/// and refused a repository that has one unambiguous identity.
/// Test: itself.
#[test]
fn same_login_pins_prefer_the_one_with_a_config_dir() {
    let registry_dir = tempfile::tempdir().expect("tempdir");
    let config_dir = tempfile::tempdir().expect("tempdir");
    write_hosts_yml(config_dir.path(), "bob-duetto");
    write_registry(
        registry_dir.path(),
        &format!(
            r#"{{"projects":{{
                "jev":{{"name":"jev","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto"}},
                "jev-matching":{{"name":"jev-matching","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"{}"}}}}
            }}}}"#,
            config_dir.path().display()
        ),
    );
    let env = pinned_gh_env_in(registry_dir.path(), ORIGIN)
        .expect("one login is not a disagreement")
        .expect("the config_dir pin must resolve");
    assert_eq!(
        value_of(&env, "GH_CONFIG_DIR"),
        config_dir.path().to_string_lossy()
    );
}

/// 🔴 #5850 REGRESSION: logins that differ only in case name one account.
///
/// Why: GitHub logins are case-insensitive, so `Bob-Duetto` and `bob-duetto`
/// are the same identity and must not block the repository.
/// Test: itself.
#[test]
fn same_login_in_a_different_case_agrees() {
    let registry_dir = tempfile::tempdir().expect("tempdir");
    let config_dir = tempfile::tempdir().expect("tempdir");
    write_hosts_yml(config_dir.path(), "bob-duetto");
    write_registry(
        registry_dir.path(),
        &format!(
            r#"{{"projects":{{
                "a-jev":{{"name":"a-jev","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"Bob-Duetto","github":{{"config_dir":"{0}"}}}},
                "b-jev":{{"name":"b-jev","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"{0}"}}}}
            }}}}"#,
            config_dir.path().display()
        ),
    );
    let env = pinned_gh_env_in(registry_dir.path(), ORIGIN)
        .expect("a case difference is not a disagreement")
        .expect("the pin must resolve");
    assert_eq!(
        value_of(&env, "GH_CONFIG_DIR"),
        config_dir.path().to_string_lossy()
    );
}

/// 🔴 FAIL-CLOSED: one login pinned through two DIFFERENT config dirs blocks.
///
/// Why: the two dirs may hold different credentials, so which one `gh` should
/// read is unanswered.
/// Test: itself.
#[test]
fn same_login_with_conflicting_config_dirs_fails_closed() {
    let registry_dir = tempfile::tempdir().expect("tempdir");
    write_registry(
        registry_dir.path(),
        &format!(
            r#"{{"projects":{{
                "a-jev":{{"name":"a-jev","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"/tmp/gh-one"}}}},
                "b-jev":{{"name":"b-jev","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"/tmp/gh-two"}}}}
            }}}}"#
        ),
    );
    let err = pinned_gh_env_in(registry_dir.path(), ORIGIN)
        .expect_err("two config dirs for one repository must refuse");
    assert!(
        err.contains("a-jev") && err.contains("b-jev") && err.contains("refusing to probe"),
        "got: {err}"
    );
}

/// 🔴 #5850 REGRESSION: a `config_dir` pin with NO login agrees with a login
/// pin on the same dir, and the chosen pin inherits that login.
///
/// Why: `seed_from_config` and `tm projects register --gh-config-dir` both write
/// a `github: {config_dir}` record with no login. Treating "no login" as a
/// login of its own refused the repository ("different gh accounts") and
/// spawned the session as the global account.
/// Test: itself.
#[test]
fn a_no_login_pin_agrees_with_a_login_pin_on_the_same_config_dir() {
    let registry_dir = tempfile::tempdir().expect("tempdir");
    let config_dir = tempfile::tempdir().expect("tempdir");
    write_hosts_yml(config_dir.path(), "bob-duetto");
    write_registry(
        registry_dir.path(),
        &format!(
            r#"{{"projects":{{
                "jev":{{"name":"jev","repo_url":"{ORIGIN}","default_branch":"main","github":{{"config_dir":"{0}"}}}},
                "widget":{{"name":"widget","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"{0}","account":"bob-duetto"}}}}
            }}}}"#,
            config_dir.path().display()
        ),
    );
    let pin = super::read_pin(registry_dir.path(), ORIGIN)
        .expect("a missing login is not a disagreement")
        .expect("the pins must resolve");
    assert_eq!(
        pin.account.as_deref(),
        Some("bob-duetto"),
        "the login must be inherited"
    );
    let env = pinned_gh_env_in(registry_dir.path(), ORIGIN)
        .expect("a missing login is not a disagreement")
        .expect("the pin must resolve");
    assert_eq!(
        value_of(&env, "GH_CONFIG_DIR"),
        config_dir.path().to_string_lossy()
    );
}

/// 🔴 FAIL-CLOSED: a no-login `config_dir` pin next to a login pin with a
/// DIFFERENT dir still blocks — leaving the login out never skips the dir check.
/// Test: itself.
#[test]
fn a_no_login_pin_with_a_different_config_dir_fails_closed() {
    let registry_dir = tempfile::tempdir().expect("tempdir");
    write_registry(
        registry_dir.path(),
        &format!(
            r#"{{"projects":{{
                "jev":{{"name":"jev","repo_url":"{ORIGIN}","default_branch":"main","github":{{"config_dir":"/tmp/gh-one"}}}},
                "widget":{{"name":"widget","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"/tmp/gh-two"}}}}
            }}}}"#
        ),
    );
    let err =
        pinned_gh_env_in(registry_dir.path(), ORIGIN).expect_err("two config dirs must refuse");
    assert!(
        err.contains("different gh config dirs") && !err.contains("different gh accounts"),
        "the refusal must name the real conflict; got: {err}"
    );
}

/// A host-only record next to a pinned duplicate does not block the pin.
///
/// Why: a host-only binding names no identity. Counting it as a pin would make
/// it "disagree" with the real one and block the repository.
/// Test: itself.
#[test]
fn a_host_only_duplicate_does_not_block_the_pinned_record() {
    let registry_dir = tempfile::tempdir().expect("tempdir");
    let config_dir = tempfile::tempdir().expect("tempdir");
    write_hosts_yml(config_dir.path(), "bob-duetto");
    write_registry(
        registry_dir.path(),
        &format!(
            r#"{{"projects":{{
                "a-jev":{{"name":"a-jev","repo_url":"{ORIGIN}","default_branch":"main","github":{{"host":"github.com"}}}},
                "b-jev":{{"name":"b-jev","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"{}"}}}}
            }}}}"#,
            config_dir.path().display()
        ),
    );
    let env = pinned_gh_env_in(registry_dir.path(), ORIGIN)
        .expect("a host-only duplicate must not block")
        .expect("the pinned record must resolve");
    assert_eq!(
        value_of(&env, "GH_CONFIG_DIR"),
        config_dir.path().to_string_lossy()
    );
}

/// A record that sets only `github.host` names no identity, and falls through.
///
/// Why: `GH_HOST` alone selects no account, so returning it as a pin would stop
/// the static tier from ever being consulted for that repository.
/// Test: itself.
#[test]
fn a_host_only_binding_is_not_a_pin() {
    let registry_dir = tempfile::tempdir().expect("tempdir");
    write_registry(
        registry_dir.path(),
        &format!(
            r#"{{"projects":{{"jev-matching":{{"name":"jev-matching","repo_url":"{ORIGIN}","default_branch":"main","github":{{"host":"github.example.com"}}}}}}}}"#
        ),
    );
    assert_eq!(
        pinned_gh_env_in(registry_dir.path(), ORIGIN).expect("readable"),
        None
    );
}

/// 🔴 FAIL-CLOSED: a registry document with no `projects` key blocks.
///
/// Why: `ProjectStore` always writes the key, so `{}` is not a registry this
/// process understands — reading it as "no pin" is the fallback.
/// Test: itself.
#[test]
fn a_registry_without_a_projects_key_fails_closed() {
    let registry_dir = tempfile::tempdir().expect("tempdir");
    write_registry(registry_dir.path(), "{}");
    let err = pinned_gh_env_in(registry_dir.path(), ORIGIN)
        .expect_err("a document without `projects` must refuse");
    assert!(
        err.contains("did not parse") && err.contains("refusing to probe"),
        "got: {err}"
    );
}

/// 🔴 FAIL-CLOSED arm 4: a registry refusal reaches the daemon's `gh` spawn as
/// a [`GhFailure`], never as the static/ambient identity.
///
/// Why: every arm above returns an `Err`, but the refusal only protects the
/// operator if `resolve_daemon_gh_env` propagates it. Swallowing it there
/// would fall through to `TrustyToolsConfig` and the machine's global account.
/// Test: itself.
#[test]
fn daemon_gh_env_refuses_when_the_registry_cannot_answer() {
    let dir = tempfile::tempdir().expect("tempdir");
    let registry_dir = tempfile::tempdir().expect("tempdir");
    write_registry(registry_dir.path(), "{ this is not json");
    let failure = resolve_daemon_gh_env_in(dir.path(), ORIGIN, registry_dir.path())
        .expect_err("a registry refusal must block the gh spawn, never fall back");
    assert!(
        failure.to_string().contains("refusing to probe"),
        "the caller must surface the refusal; got: {failure}"
    );
}

/// The daemon's `gh` spawn runs inside the registry-pinned config dir, keyed by
/// the repository the caller passes. `dir` is not a git checkout, so this also
/// proves the lookup no longer re-reads `origin` from git (#5850).
/// Test: itself.
#[test]
fn daemon_gh_env_uses_the_registry_pin() {
    let dir = tempfile::tempdir().expect("tempdir");
    let registry_dir = tempfile::tempdir().expect("tempdir");
    let config_dir = tempfile::tempdir().expect("tempdir");
    write_hosts_yml(config_dir.path(), "bob-duetto");
    write_registry(
        registry_dir.path(),
        &format!(
            r#"{{"projects":{{"jev-matching":{{"name":"jev-matching","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"{}"}}}}}}}}"#,
            config_dir.path().display()
        ),
    );
    let env = resolve_daemon_gh_env_in(
        dir.path(),
        "duettoresearch/jev-matching",
        registry_dir.path(),
    )
    .expect("a usable pin must resolve");
    assert_eq!(
        value_of(&env, "GH_CONFIG_DIR"),
        config_dir.path().to_string_lossy()
    );
}