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
//! Resolved git-operation identity: `gh`-auth env overrides plus an optional
//! commit-author override, applied to the managed/provisioner git subprocesses
//! (#2184).
//!
//! Why: [`crate::core::gh_identity`] resolves a `GithubConfig` into env
//! overrides for `gh`/git-credential-helper auth, but the managed spawn path
//! (`daemon::managed_routes::lifecycle::spawn_managed`) and the workspace
//! provisioner (`provisioner::workspace::RealGitBackend`) need TWO things
//! bundled together for every git subprocess they run: those same env
//! overrides (so `git clone`/`fetch` over HTTPS authenticate as the right
//! identity when a credential helper consults them) AND an optional commit
//! author override (`-c user.name=`/`-c user.email=`) that #2184 introduces as
//! a NET-NEW per-project setting — `tm` previously never set a commit identity
//! on any git invocation it made. [`GitIdentity`] is that bundle;
//! [`resolve_git_identity`] builds one from the resolved `GithubConfig` plus
//! the project's own commit-identity fields; [`resolve_for_config`] is the
//! convenience entry point that also finds the matching project (by
//! `repo_url`) in a loaded [`TrustyToolsConfig`].
//!
//! What: `GitIdentity::env` are the `(name, value)` pairs to apply via
//! `Command::envs`; `GitIdentity::commit_config_args` renders the `-c
//! user.name=…`/`-c user.email=…` args to prepend to a `git` invocation (empty
//! when neither is set, so an unconfigured project's git commands are
//! byte-for-byte identical to pre-#2184 behaviour).
//!
//! Test: `resolve_git_identity_*`, `resolve_for_config_*`,
//! `commit_config_args_*` in the inline `tests` module.
//!
//! ## Account enforcement wiring (#2081/#3312)
//!
//! [`resolve_for_config`] above stays deliberately pure (no I/O) — every
//! existing test here needs that. But #2081's per-project account
//! enforcement (verifying, and self-healing, WHICH account is active inside
//! a resolved `config_dir`) had no production call site until #3312.
//! [`resolve_for_config_enforced`] is the daemon's wired entry point: it
//! resolves via [`resolve_for_config`] unchanged, then — only when the
//! selected `GithubConfig` pairs `config_dir` with an `account` (see
//! `core::gh_account::configured_account_pair`) — runs the verify/correct
//! check on the Tokio blocking pool (it may shell out to `gh`) BEFORE
//! returning, so a wrong-account `config_dir` can never reach the clone/push
//! `RealGitBackend` performs. The catalog sync and the local→managed
//! redirect path (`daemon::managed_routes::lifecycle`) both call this instead
//! of the plain resolver.
use crate::core::gh_account::{configured_account_pair, ensure_gh_account_in_dir};
use crate::core::gh_identity::{GhIdentityError, resolve_gh_env, select_github_config};
use crate::core::trusty_tools_config::{GithubConfig, TrustyToolsConfig};
use crate::project::record::repo_url_matches;
/// A resolved git-operation identity: auth env overrides plus an optional
/// commit-author override.
///
/// Why: bundling both concerns lets `RealGitBackend` apply a SINGLE resolved
/// value to every git subprocess it runs, rather than threading two separate
/// values through the `GitBackend` trait.
/// What: `env` — ordered `(name, value)` overrides (mirrors
/// [`crate::core::gh_identity::GhEnv::vars`]); `commit_name`/`commit_email` —
/// optional git commit author overrides. All empty/`None` (the `Default`) is
/// the pre-#2184 ambient behaviour: no env overrides, no commit identity
/// applied.
/// Test: `commit_config_args_empty_when_unset`,
/// `commit_config_args_both_set`, `commit_config_args_name_only`.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GitIdentity {
/// Env overrides to apply to the git subprocess (e.g. `GH_CONFIG_DIR`,
/// `GH_TOKEN`, `GH_HOST`) so an HTTPS credential helper picks the right
/// identity.
pub env: Vec<(String, String)>,
/// #6668: identity vars REMOVED from the git subprocess before `env` is
/// applied. A credential helper resolves an inherited `GH_TOKEN` ahead of
/// the `GH_CONFIG_DIR` this identity binds, so setting alone is not enough.
pub env_remove: Vec<String>,
/// Git commit author name override, applied as `-c user.name=<name>`.
pub commit_name: Option<String>,
/// Git commit author email override, applied as `-c user.email=<email>`.
pub commit_email: Option<String>,
}
impl GitIdentity {
/// Whether this identity carries no overrides at all (fully ambient).
///
/// Why: lets callers skip building a command wrapper entirely when there
/// is nothing to apply.
/// What: true when `env` is empty and both commit fields are `None`.
/// Test: covered by `resolve_git_identity_none_when_nothing_configured`.
pub fn is_empty(&self) -> bool {
self.env.is_empty()
&& self.env_remove.is_empty()
&& self.commit_name.is_none()
&& self.commit_email.is_none()
}
/// Render the `-c user.name=…`/`-c user.email=…` args for a `git` command.
///
/// Why: git accepts repeated `-c key=value` overrides BEFORE the
/// subcommand; centralising the rendering means every git subprocess site
/// builds the identical arg shape.
/// What: returns `[]` when neither field is set; otherwise one `-c
/// user.name=<name>` pair and/or one `-c user.email=<email>` pair, in that
/// order, only for the fields that are `Some`.
/// Test: `commit_config_args_empty_when_unset`,
/// `commit_config_args_both_set`, `commit_config_args_name_only`.
pub fn commit_config_args(&self) -> Vec<String> {
let mut args = Vec::new();
if let Some(name) = &self.commit_name {
args.push("-c".to_string());
args.push(format!("user.name={name}"));
}
if let Some(email) = &self.commit_email {
args.push("-c".to_string());
args.push(format!("user.email={email}"));
}
args
}
}
/// Build a [`GitIdentity`] from already-selected `GithubConfig` tiers plus
/// commit-identity fields.
///
/// Why: the pure combination step — apply [`select_github_config`]'s
/// project-over-global precedence, resolve it into env overrides via
/// [`resolve_gh_env`], and attach the (project-scoped-only; #2184 does not
/// define a global commit identity tier) commit author override — kept
/// separate from any config/registry lookup so it is unit-testable without a
/// loaded [`TrustyToolsConfig`].
/// What: returns `Err` only when [`resolve_gh_env`] refuses an `account`-only
/// binding (see `core::gh_identity` module docs).
/// Test: `resolve_git_identity_project_overrides_global`,
/// `resolve_git_identity_falls_back_to_global`,
/// `resolve_git_identity_none_when_nothing_configured`,
/// `resolve_git_identity_commit_identity_independent_of_github`.
pub fn resolve_git_identity(
project_github: Option<&GithubConfig>,
global_github: Option<&GithubConfig>,
commit_name: Option<&str>,
commit_email: Option<&str>,
) -> Result<GitIdentity, GhIdentityError> {
let selected = select_github_config(project_github, global_github);
let env = resolve_gh_env(selected)?;
Ok(GitIdentity {
env: env.vars().to_vec(),
// #6668: carry the removals with the overrides — a bound identity that
// leaves an inherited GH_TOKEN in place is not bound.
env_remove: env.unset_vars().to_vec(),
commit_name: commit_name.map(str::to_string),
commit_email: commit_email.map(str::to_string),
})
}
/// Resolve the [`GitIdentity`] for `repo_url` from a loaded [`TrustyToolsConfig`].
///
/// Why: `spawn_managed`'s clone-based and local-redirect provisioning paths
/// already load `TrustyToolsConfig` once per spawn; this is the one place that
/// turns that config plus the target `repo_url` into the identity
/// `RealGitBackend` should use, so the daemon's two call sites cannot diverge.
/// What: finds the first `config.projects` entry whose `repo_url` matches (via
/// [`repo_url_matches`], which compares parsed `owner/repo` when possible) and
/// uses ITS `github`/`commit_name`/`commit_email` fields; falls back to the
/// global `config.github` tier when no project matches or the matched project
/// has no `github` binding of its own (commit identity is project-scoped only
/// — no global fallback tier). No match at all → fully ambient `GitIdentity`
/// (`is_empty()` — no regression).
/// Test: `resolve_for_config_matches_project_by_repo_url`,
/// `resolve_for_config_falls_back_to_global_github`,
/// `resolve_for_config_ambient_when_no_match`.
pub fn resolve_for_config(
config: &TrustyToolsConfig,
repo_url: &str,
) -> Result<GitIdentity, GhIdentityError> {
let matched = config
.projects
.iter()
.find(|p| repo_url_matches(&p.repo_url, repo_url));
let project_github = matched.and_then(|p| p.github.as_ref());
let commit_name = matched.and_then(|p| p.commit_name.as_deref());
let commit_email = matched.and_then(|p| p.commit_email.as_deref());
resolve_git_identity(
project_github,
config.github.as_ref(),
commit_name,
commit_email,
)
}
/// Resolve the effective [`GithubConfig`] for `repo_url` — the SAME
/// project-over-global precedence [`resolve_for_config`] applies internally
/// — exposed so a caller that needs the pre-[`GhEnv`](crate::core::gh_identity::GhEnv) config (the #2081/#3312
/// account-enforcement decision) does not have to reimplement the project
/// lookup and risk diverging from it.
///
/// Why: [`resolve_for_config_enforced`] needs to know WHICH `GithubConfig`
/// would be used for `repo_url` in order to decide whether an `account` is
/// paired with a `config_dir` (see `core::gh_account::configured_account_pair`);
/// factoring the lookup out keeps that decision and `resolve_for_config`
/// itself from ever disagreeing on which tier won.
/// What: mirrors `resolve_for_config`'s `matched`/`project_github` steps,
/// then applies [`select_github_config`].
/// Test: `select_github_config_for_project_wins`,
/// `select_github_config_for_falls_back_to_global`,
/// `select_github_config_for_no_match_is_global`.
pub fn select_github_config_for<'a>(
config: &'a TrustyToolsConfig,
repo_url: &str,
) -> Option<&'a GithubConfig> {
let project_github = config
.projects
.iter()
.find(|p| repo_url_matches(&p.repo_url, repo_url))
.and_then(|p| p.github.as_ref());
select_github_config(project_github, config.github.as_ref())
}
/// Resolve `repo_url`'s [`GitIdentity`] AND enforce any `#2081` account
/// pairing before returning — the daemon's wired entry point (#3312) in
/// place of the plain [`resolve_for_config`].
///
/// Why: `resolve_for_config` (and the pure `resolve_git_identity`/
/// `resolve_gh_env` chain under it) intentionally does NO I/O — every
/// existing test in this module depends on that. But the resolved
/// `config_dir` may itself have the WRONG account active (the exact #2081
/// incident shape), and only a real `gh auth status` check inside that
/// directory can catch it. The daemon's two spawn paths
/// (the catalog sync, the local→managed redirect) both provision a
/// clone via `RealGitBackend` using the identity this resolves — enforcing
/// HERE, before either call, means a wrong-account `config_dir` can never
/// reach a clone/push. The check runs on `tokio::task::spawn_blocking` (it
/// may shell out to `gh`, bounded by `GH_ENFORCE_TIMEOUT`) so it never stalls
/// the async executor, mirroring `gh_account::resolve_gh_account_env_for_registry`'s
/// existing blocking-pool convention for `gh` subprocess calls from an async
/// context.
/// What: resolves via [`resolve_for_config`] first, surfacing its typed error
/// unchanged on failure. When [`select_github_config_for`]'s result pairs
/// `config_dir` with a non-blank `account` (`configured_account_pair`
/// returns `Some`), verifies/self-heals the active account inside that
/// directory via [`ensure_gh_account_in_dir`] on the blocking pool; a
/// mismatch that cannot be corrected — or a panicked blocking task — is a
/// hard `Err`, never a silent pass. No `account` paired with `config_dir` →
/// no enforcement, `resolve_for_config`'s result is returned unchanged (the
/// pre-#3312, unconfigured-project behaviour, byte-for-byte).
/// Test: `resolve_for_config_enforced_self_heals_mismatch`,
/// `resolve_for_config_enforced_skips_when_account_unset`, and
/// `resolve_for_config_enforced_fails_closed_on_switch_failure` — which also
/// carries the causality proof: it asserts `resolve_for_config` ALONE (the
/// exact pre-#3312 production path) still resolves `Ok` on the same
/// mismatched-account fixture that `resolve_for_config_enforced` correctly
/// rejects.
pub async fn resolve_for_config_enforced(
config: &TrustyToolsConfig,
repo_url: &str,
) -> anyhow::Result<GitIdentity> {
let identity = resolve_for_config(config, repo_url)?;
if let Some(target) = configured_account_pair(select_github_config_for(config, repo_url)) {
// #5849: the host travels with the account, so enforcement attests the
// host this project's `gh` calls actually run on.
tokio::task::spawn_blocking(move || {
ensure_gh_account_in_dir(&target.account, &target.config_dir, &target.host)
})
.await
.map_err(|e| anyhow::anyhow!("gh account enforcement task panicked: {e}"))??;
}
Ok(identity)
}
#[cfg(test)]
mod tests {
use super::*;
fn gh(account: &str) -> GithubConfig {
GithubConfig {
account: None,
config_dir: Some(std::path::PathBuf::from(format!("/cfg/{account}"))),
token_env: None,
host: None,
}
}
// ── resolve_git_identity ──────────────────────────────────────────────
/// Why: a project-scoped `github:` binding must win over the global one.
/// Test: itself.
#[test]
fn resolve_git_identity_project_overrides_global() {
let project = gh("project");
let global = gh("global");
let identity = resolve_git_identity(Some(&project), Some(&global), None, None).expect("ok");
assert_eq!(
identity.env,
vec![("GH_CONFIG_DIR".to_string(), "/cfg/project".to_string())]
);
}
/// Why: with no project binding, the global tier must be used.
/// Test: itself.
#[test]
fn resolve_git_identity_falls_back_to_global() {
let global = gh("global");
let identity = resolve_git_identity(None, Some(&global), None, None).expect("ok");
assert_eq!(
identity.env,
vec![("GH_CONFIG_DIR".to_string(), "/cfg/global".to_string())]
);
}
/// Why: with nothing configured anywhere, the identity must be fully
/// ambient — no regression for projects with no #2184 binding.
/// Test: itself.
#[test]
fn resolve_git_identity_none_when_nothing_configured() {
let identity = resolve_git_identity(None, None, None, None).expect("ok");
assert!(identity.is_empty());
}
/// Why: commit identity is an independent axis from the `gh` env
/// overrides — it must apply even when no `github:` binding exists at
/// either tier.
/// Test: itself.
#[test]
fn resolve_git_identity_commit_identity_independent_of_github() {
let identity =
resolve_git_identity(None, None, Some("Bot"), Some("bot@example.com")).expect("ok");
assert!(identity.env.is_empty());
assert_eq!(identity.commit_name.as_deref(), Some("Bot"));
assert_eq!(identity.commit_email.as_deref(), Some("bot@example.com"));
}
// ── resolve_for_config ────────────────────────────────────────────────
fn project_config(
repo_url: &str,
github: Option<GithubConfig>,
) -> crate::core::trusty_tools_config::ProjectConfig {
crate::core::trusty_tools_config::ProjectConfig {
name: "p".into(),
repo_url: repo_url.into(),
default_branch: None,
stack_hint: None,
tags: None,
description: None,
gh_user: None,
gh_account: None,
github,
commit_name: Some("Project Bot".into()),
commit_email: Some("bot@project.example.com".into()),
untracked_sync: None,
worktree: None,
}
}
/// Why: `resolve_for_config` must find the project by matching `repo_url`
/// (tolerating `.git`/scheme differences via `repo_url_matches`) and use
/// its `github`/commit-identity fields.
/// Test: itself.
#[test]
fn resolve_for_config_matches_project_by_repo_url() {
let config = TrustyToolsConfig {
projects: vec![project_config(
"https://github.com/acme/widget.git",
Some(gh("project")),
)],
..Default::default()
};
let identity = resolve_for_config(&config, "https://github.com/acme/widget").expect("ok");
assert_eq!(
identity.env,
vec![("GH_CONFIG_DIR".to_string(), "/cfg/project".to_string())]
);
assert_eq!(identity.commit_name.as_deref(), Some("Project Bot"));
assert_eq!(
identity.commit_email.as_deref(),
Some("bot@project.example.com")
);
}
/// Why: a project match with NO `github:` binding of its own must fall
/// back to the global tier (project-over-global, not project-only).
/// Test: itself.
#[test]
fn resolve_for_config_falls_back_to_global_github() {
let config = TrustyToolsConfig {
github: Some(gh("global")),
projects: vec![project_config("https://github.com/acme/widget", None)],
..Default::default()
};
let identity = resolve_for_config(&config, "https://github.com/acme/widget").expect("ok");
assert_eq!(
identity.env,
vec![("GH_CONFIG_DIR".to_string(), "/cfg/global".to_string())]
);
}
/// Why: a `repo_url` matching NO declared project must resolve to a fully
/// ambient identity — the no-regression guarantee for repos `tm` clones
/// that were never declared in `config.projects`.
/// Test: itself.
#[test]
fn resolve_for_config_ambient_when_no_match() {
let config = TrustyToolsConfig::default();
let identity = resolve_for_config(&config, "https://github.com/someone/else").expect("ok");
assert!(identity.is_empty());
}
// ── select_github_config_for ──────────────────────────────────────────
/// Why: `select_github_config_for` must agree with what
/// `resolve_for_config` actually uses — a project's own binding wins.
/// Test: itself.
#[test]
fn select_github_config_for_project_wins() {
let config = TrustyToolsConfig {
github: Some(gh("global")),
projects: vec![project_config(
"https://github.com/acme/widget.git",
Some(gh("project")),
)],
..Default::default()
};
let selected = select_github_config_for(&config, "https://github.com/acme/widget");
assert_eq!(selected, Some(&gh("project")));
}
/// Why: no project-level binding must fall back to global, matching
/// `resolve_for_config`'s own fallback.
/// Test: itself.
#[test]
fn select_github_config_for_falls_back_to_global() {
let config = TrustyToolsConfig {
github: Some(gh("global")),
projects: vec![project_config("https://github.com/acme/widget", None)],
..Default::default()
};
let selected = select_github_config_for(&config, "https://github.com/acme/widget");
assert_eq!(selected, Some(&gh("global")));
}
/// Why: no match at all with no global tier either must resolve to
/// `None` — the ambient case.
/// Test: itself.
#[test]
fn select_github_config_for_no_match_is_global() {
let config = TrustyToolsConfig::default();
assert_eq!(
select_github_config_for(&config, "https://github.com/someone/else"),
None
);
}
// ── resolve_for_config_enforced (#2081/#3312) ─────────────────────────
//
// #7059: `core::gh_scoped_stub` answers the scoped `gh` calls in-process.
// These tests used a fake `gh` shell script on the process-global `PATH`,
// which a sibling test's `PATH` restore could remove mid-run — the real
// `gh` then answered and the 5 s enforcement ceiling turned into a flake.
use crate::core::gh_scoped_stub::GhScopedStub;
fn fake_gh_lock() -> std::sync::MutexGuard<'static, ()> {
crate::core::trusty_tools_config::env_test_lock()
}
/// Drive `fut` to completion on a fresh current-thread runtime, blocking
/// the CALLING thread synchronously (no `.await` in the caller's own
/// frame). Lets these tests hold the plain `std::sync::MutexGuard` from
/// [`fake_gh_lock`] for their entire body — including while
/// `resolve_for_config_enforced` internally awaits its `spawn_blocking`
/// check — without tripping `clippy::await_holding_lock` (that lint
/// detects a guard held across a literal `.await` inside an async
/// fn/block; a plain `#[test]` blockingly driving a future via
/// `Runtime::block_on` has no such point). Using the SAME crate-wide
/// `env_test_lock` (rather than a second, `tokio`-aware lock) is what
/// actually matters here: it is the one lock every other env-mutating
/// test in this test binary (`core::gh_account_enforce`'s tests included)
/// already serialises on, so this module cannot race them.
fn block_on<F: std::future::Future>(fut: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build current-thread runtime for test")
.block_on(fut)
}
/// Remove any ambient `GH_TOKEN`/`GITHUB_TOKEN`, returning them to restore.
///
/// #5849: an ambient token makes `ensure_gh_account_in_dir` refuse, so a
/// shell or CI job that exports one would otherwise decide these outcomes.
fn strip_ambient_tokens() -> Vec<(&'static str, Option<std::ffi::OsString>)> {
["GH_TOKEN", "GITHUB_TOKEN"]
.into_iter()
.map(|name| {
let prior = std::env::var_os(name);
// SAFETY: guarded by `fake_gh_lock` in every caller.
unsafe { std::env::remove_var(name) };
(name, prior)
})
.collect()
}
fn restore_ambient_tokens(prior: Vec<(&'static str, Option<std::ffi::OsString>)>) {
for (name, value) in prior {
// SAFETY: guarded by `fake_gh_lock` in every caller.
unsafe {
match value {
Some(v) => std::env::set_var(name, v),
None => std::env::remove_var(name),
}
}
}
}
fn account_paired_config(config_dir: &std::path::Path, account: &str) -> TrustyToolsConfig {
TrustyToolsConfig {
github: Some(GithubConfig {
config_dir: Some(config_dir.to_path_buf()),
token_env: None,
account: Some(account.to_string()),
host: None,
}),
..Default::default()
}
}
/// Why (#3312): the exact #2081 incident shape (the resolved `config_dir`
/// has the WRONG account active) must be self-healed before
/// `resolve_for_config_enforced` returns `Ok` — proving the daemon's
/// spawn paths now actually enforce this rather than merely resolving an
/// identity blind to what's active inside it.
/// Test: itself.
#[test]
fn resolve_for_config_enforced_self_heals_mismatch() {
let _g = fake_gh_lock();
let config_dir = tempfile::tempdir().expect("config tempdir");
let (stub, _stub) = GhScopedStub::logged_in_as("wrong-account").install();
let prior_tokens = strip_ambient_tokens();
let config = account_paired_config(config_dir.path(), "bobmatnyc");
let result = block_on(resolve_for_config_enforced(
&config,
"https://github.com/acme/widget",
));
restore_ambient_tokens(prior_tokens);
assert!(result.is_ok(), "expected self-heal to succeed: {result:?}");
assert_eq!(
stub.active_account(),
"bobmatnyc",
"the switch must have taken effect"
);
}
/// Why (#3312): when the switch itself fails, `resolve_for_config_enforced`
/// must hard-fail — never return a `GitIdentity` a caller could still
/// clone/push with. This is the causality proof required for #3312:
/// `resolve_for_config` ALONE (the exact pre-#3312 production path,
/// still used unchanged by this very function internally) resolves `Ok`
/// on this identical mismatched-account fixture, which is exactly the
/// silent-wrong-account bug the issue describes. Only the NEW enforced
/// wrapper catches it.
/// Test: itself.
#[test]
fn resolve_for_config_enforced_fails_closed_on_switch_failure() {
let _g = fake_gh_lock();
let config_dir = tempfile::tempdir().expect("config tempdir");
let (_stub_handle, _stub) = GhScopedStub::logged_in_as("wrong-account")
.with_failing_switch()
.install();
let prior_tokens = strip_ambient_tokens();
let config = account_paired_config(config_dir.path(), "bobmatnyc");
// The causal proof: the PLAIN pre-#3312 resolver is blind to the
// mismatch and proceeds regardless.
let unenforced = resolve_for_config(&config, "https://github.com/acme/widget");
assert!(
unenforced.is_ok(),
"sanity: the plain resolver must still ignore active-account state: {unenforced:?}"
);
// The wired daemon path must NOT proceed.
let enforced = block_on(resolve_for_config_enforced(
&config,
"https://github.com/acme/widget",
));
restore_ambient_tokens(prior_tokens);
let err = enforced.expect_err("mismatched account with a failed switch must be an Err");
assert!(err.to_string().contains("bobmatnyc"), "err: {err}");
}
/// Why (#3312): a project with `config_dir` but no paired `account` (the
/// pre-#3312, still-supported shape) must see NO enforcement — proven by
/// scripting every scoped `gh` answer to fail and asserting resolution
/// still succeeds (only possible if `gh` was never invoked).
/// Test: itself.
#[test]
fn resolve_for_config_enforced_skips_when_account_unset() {
let _g = fake_gh_lock();
let config_dir = tempfile::tempdir().expect("config tempdir");
let (_stub_handle, _stub) = GhScopedStub::logged_in_as("irrelevant")
.with_failing_api()
.with_failing_switch()
.install();
let config = TrustyToolsConfig {
github: Some(gh(&config_dir.path().display().to_string())),
..Default::default()
};
let result = block_on(resolve_for_config_enforced(
&config,
"https://github.com/acme/widget",
));
assert!(
result.is_ok(),
"no account paired with config_dir must skip enforcement entirely: {result:?}"
);
}
// ── commit_config_args ────────────────────────────────────────────────
/// Why: an unconfigured identity must render NO `-c` args, so a git
/// invocation for a project with no commit-identity override is
/// byte-for-byte identical to pre-#2184 behaviour.
/// Test: itself.
#[test]
fn commit_config_args_empty_when_unset() {
assert!(GitIdentity::default().commit_config_args().is_empty());
}
/// Why: both fields set must render both `-c` pairs, name before email.
/// Test: itself.
#[test]
fn commit_config_args_both_set() {
let identity = GitIdentity {
commit_name: Some("Bot".into()),
commit_email: Some("bot@example.com".into()),
..Default::default()
};
assert_eq!(
identity.commit_config_args(),
vec![
"-c".to_string(),
"user.name=Bot".to_string(),
"-c".to_string(),
"user.email=bot@example.com".to_string(),
]
);
}
/// Why: only the set field must render — no spurious empty override.
/// Test: itself.
#[test]
fn commit_config_args_name_only() {
let identity = GitIdentity {
commit_name: Some("Bot".into()),
..Default::default()
};
assert_eq!(
identity.commit_config_args(),
vec!["-c".to_string(), "user.name=Bot".to_string()]
);
}
}