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
//! WHICH GitHub repository a worktree's pull-request lookups belong to (#7057).
//!
//! Why: every `gh` call on the reclaim and worktree-removal paths used to leave
//! the repository UNSTATED and let `gh` infer it from the working directory.
//! That inference is ambient — it depends on which remote `gh` picks, on a
//! `remote.<name>.gh-resolved` config key, and, when the directory git does not
//! root a repository at, on whatever enclosing repository a walk up the
//! filesystem finds. On 2026-09-07 a `tm session prune-worktrees --merged-prs`
//! run for `1m-consulting/adaptive-crm` resolved against
//! `hotstats/hotstats-product-poc` — a different registered project on the same
//! machine — and the merged-PR gate then reported "no pull request found" for
//! branches whose pull requests had just merged. Nothing in the argv named a
//! repository, so nothing in the output could disclose the substitution either.
//!
//! The HOST is part of that answer. `gh --repo` takes `[HOST/]OWNER/REPO` and
//! resolves a bare `OWNER/REPO` against its own default host, so a slug built
//! from a GitHub Enterprise remote — or from any host a `GH_HOST` override
//! points at — that dropped the host asked github.com instead, and answered
//! from there if a repository with those two names existed. That is the same
//! wrong-repository substitution, one level up, and equally silent.
//!
//! An SSH `Host` ALIAS is not a host (#7196). A multi-account operator writes
//! `git@gh-work:acme-corp/widgets.git` and lets `~/.ssh/config` rewrite
//! `gh-work` to `github.com`. Nothing but `ssh` performs that rewrite, so
//! the alias arrived here as the GitHub host and `gh --repo
//! gh-work/acme-corp/widgets` failed with "error connecting to
//! gh-work" — four merged-PR worktrees refused at gate 5 on 2026-09-08,
//! and no repository behind such an alias could ever be reclaimed. An SSH
//! remote's host is now resolved through [`super::ssh_host_alias`] before it
//! becomes part of a slug.
//!
//! What: [`repo_slug_for`] derives `[host/]owner/repo` from the TARGET
//! directory's own `origin` remote, falling back to the checkout that owns it
//! only when the directory carries no `origin` of its own. [`parse_repo_slug`]
//! is the URL half, and is where the host is resolved and then kept or — for
//! [`DEFAULT_GH_HOST`], which `gh` assumes — omitted. Both fail CLOSED: a
//! missing remote, an unparseable one, and an SSH alias that resolves to
//! nothing are each an `Err` naming the directory and what was read, never a
//! guess and never another repository — the [ADR-0045]
//! absent-vs-undeterminable rule, applied to a gate whose ALLOW deletes a
//! checkout.
//!
//! [ADR-0045]: ../../../../docs/adr/0045-distinguish-absent-from-undeterminable-on-destructive-paths.md
//!
//! Test: `worktree_repo_slug_tests`.
use Path;
use SshHostAliases;
use registry_root_for;
use git_stdout;
/// URL schemes whose `owner/repo` tail this module trusts.
///
/// Why: the tail of a filesystem path also looks like `owner/repo`
/// (`/tmp/fixtures/remote.git` would read as `fixtures/remote`), so a remote
/// that is not a network URL must yield nothing rather than a plausible-looking
/// slug. `file://` is deliberately absent for the same reason.
/// Test: `a_local_path_remote_names_no_repository`.
const REMOTE_URL_SCHEMES: & = &;
/// The host `gh --repo` addresses when a slug names none (#7057).
///
/// Why: `gh` resolves a bare `OWNER/REPO` against its own default host, so only
/// a remote already on that host may drop it. Every other host has to survive
/// into the slug or the lookup silently changes which server it asks.
const DEFAULT_GH_HOST: &str = "github.com";
/// The SSH transports whose host `~/.ssh/config` may rename (#7196).
///
/// Why: only `ssh` consults that config, so only a remote git hands to `ssh`
/// can carry an alias. Resolving an `https://` host through it would rewrite a
/// host git never sends to `ssh` at all — a change of SERVER made on evidence
/// that does not apply.
const SSH_URL_SCHEME: &str = "ssh://";
/// Why a git remote URL names no repository this module will ask `gh` about.
///
/// Why: the two cases need different refusals. "This is a filesystem path"
/// tells the operator the remote is not a GitHub one at all; "`gh-work`
/// resolves to nothing" tells them which `~/.ssh/config` entry is missing. One
/// `None` for both is what made #7196 read as a `gh` connection error.
/// What: carried by [`parse_repo_slug`] and rendered by [`refusal`].
/// Test: `an_unparseable_origin_refuses_and_quotes_the_url`,
/// `an_unresolvable_ssh_alias_refuses_and_names_the_alias`.
pub
/// The `[host/]owner/repo` a git remote URL names.
///
/// Why: `gh --repo` takes `[HOST/]OWNER/REPO`, and the two spellings a GitHub
/// remote arrives in — scp-like (`git@github.com:owner/repo.git`) and URL
/// (`https://github.com/owner/repo.git`) — reach it by different routes. Both
/// are parsed here so no call site grows its own half-parser.
///
/// #7057: the HOST is part of the answer. A slug built from a GitHub Enterprise
/// remote used to arrive as bare `owner/repo`, which `gh` then resolved against
/// its default host — answering for a same-named repository there, with nothing
/// in the reply disclosing the substitution. That is the same wrong-repository
/// defect this module exists to close, one host up.
///
/// #7196: an SSH host is an ALIAS until `~/.ssh/config` says otherwise, so
/// `aliases` is consulted before the host is trusted — see [`resolve_ssh_host`].
/// What: strips a recognised scheme and its authority, or the scp-like
/// `[user@]host:` prefix, then takes the last two non-empty path segments with
/// any `.git` suffix removed. The authority's host — userinfo and port removed,
/// lowercased, and for an SSH transport resolved through `aliases` — is
/// prepended unless it is [`DEFAULT_GH_HOST`], which `gh` assumes.
/// Test: `https_remote_yields_its_owner_and_repo`,
/// `ssh_remote_yields_its_owner_and_repo`,
/// `scp_like_remote_yields_its_owner_and_repo`,
/// `a_non_default_host_survives_into_the_slug`,
/// `an_ssh_alias_resolves_to_the_host_it_names`,
/// `an_unresolvable_ssh_alias_refuses_and_names_the_alias`,
/// `an_enterprise_worktree_names_its_host_in_the_repo_flag`,
/// `a_local_path_remote_names_no_repository`,
/// `a_file_url_remote_names_no_repository`.
pub
/// The machine an SSH remote's host names, resolving a `~/.ssh/config` alias.
///
/// Why: `gh --repo [HOST/]OWNER/REPO` addresses a SERVER, and an alias
/// addresses none — passing `gh-work` through produced a slug `gh` could
/// only fail to connect to, which gate 5 then read as "the merged-PR lookup
/// failed" for a tree it should have reclaimed (#7196).
/// What: three outcomes, in order. A host `aliases` renames becomes the name it
/// gives. A host carrying a `.` is taken as a real machine, which is what keeps
/// the #7057 GitHub Enterprise case working when the operator declares no alias
/// for it. Anything else — a bare name nothing renames — is
/// [`SlugRefusal::UnresolvedSshAlias`], because a dotless name is not a
/// reachable host and guessing which server it meant is the substitution this
/// module exists to prevent.
/// Test: `an_ssh_alias_resolves_to_the_host_it_names`,
/// `an_unresolvable_ssh_alias_refuses_and_names_the_alias`,
/// `an_undeclared_dotted_ssh_host_is_taken_at_face_value`.
/// The lowercased host an authority names, without userinfo or port.
///
/// The port addresses the SERVER and the userinfo the CALLER; neither selects a
/// repository, so neither belongs in a `--repo` slug. A trailing `:<digits>` is
/// the only thing treated as a port — `[::1]` keeps its colons because what
/// follows the last one is not all digits.
/// The last two path segments of `path` as `owner/repo`.
/// The `[host/]owner/repo` every `gh` call rooted at `dir` must be pinned to
/// (#7057).
///
/// Why: see the module doc. The repository is a property of the DIRECTORY under
/// inspection, so it is read there rather than inherited from the caller, the
/// daemon's working directory, or another registered project.
/// What: `git -C <dir> config --get remote.origin.url` (through
/// [`git_stdout`], which strips the environment able to point git at a
/// different repository), parsed by [`parse_repo_slug`]. A directory with no
/// `origin` of its own falls back to the checkout git names as the owner of its
/// worktree registry — and only there. The result carries the remote's host
/// whenever that is not [`DEFAULT_GH_HOST`]. Every other outcome is an `Err`
/// whose text names the directory and what was read, so a caller can print it.
/// Test: `two_worktrees_with_different_origins_resolve_to_different_repos`,
/// `an_enterprise_worktree_names_its_host_in_the_repo_flag`,
/// `a_directory_with_no_origin_falls_back_to_its_owning_checkout`,
/// `a_non_repository_directory_resolves_to_no_repository` — each through
/// [`repo_slug_with`], which is this function's whole body. Nothing tests this
/// wrapper directly, because the only thing it adds is a read of the
/// operator's own `~/.ssh/config` and a test that read it would answer
/// differently on two machines (#7196).
pub
/// The remote name every caller means when it does not name one.
pub const DEFAULT_REMOTE: &str = "origin";
/// The remote a branch's commits were pushed to, when it is not `origin`
/// (#7850).
///
/// Why: git already records the answer, in the same three keys `git push` itself
/// consults, so the guard reads it rather than inferring one. Resolving it is a
/// correction to WHICH repository is asked, never a relaxation of what must be
/// found there: a MERGED pull request is still required, and asking the wrong
/// repository could only ever produce a false DENY.
/// What: the first non-empty of `branch.<branch>.pushRemote`,
/// `remote.pushDefault`, `branch.<branch>.remote` — git's own precedence — and
/// `None` when none is set, when the value is blank, or when git cannot be
/// asked at all. `None` means the caller uses [`DEFAULT_REMOTE`], which is the
/// pre-#7850 behaviour, so every failure branch here lands on the old verdict.
/// Test: `push_remote_follows_gits_own_precedence_and_is_none_when_unset`,
/// `a_fork_branch_is_searched_in_the_repository_it_was_pushed_to`.
pub
/// The repositories a branch's merged-PR lookup searches, `origin` first
/// (#8403).
///
/// Why: #7850 replaced `origin` with the branch's push remote outright, so a
/// fork workflow whose pull request merged in `origin` — the common
/// cross-repository case — searched only the fork (`<account>/<repo>`) and was
/// refused. Repository identity comes from `origin`; the push remote is a
/// second place to look, never a replacement.
/// What: `[origin]`, plus the push remote's slug when [`push_remote_for_branch`]
/// names a remote other than [`DEFAULT_REMOTE`] whose repository differs. An
/// unreadable or unparseable URL for EITHER remote is an `Err` — fail closed,
/// never a lookup aimed at a guess.
/// Test: `a_fork_branch_searches_origin_first_then_its_push_remote`,
/// `an_unparseable_origin_refuses_even_with_a_valid_push_remote`.
pub
/// [`merged_pr_search_repos_with`] against the current user's SSH aliases.
pub
/// The first repository in `repos` whose lookup reports a merged PR (#8403).
///
/// Why: a merged PR in any of the checkout's own remotes is positive evidence;
/// an unanswerable repository is not evidence of absence (ADR-0045).
/// What: returns the first `Ok` with `count > 0`. Otherwise any `Err` wins —
/// joined, naming every repository that failed — so a repository that could
/// not be asked denies; only when every repository answered is the last
/// zero-count answer returned.
/// Test: `a_merged_pr_in_origin_grants_when_the_fork_cannot_be_resolved`,
/// `an_unanswerable_repository_denies_when_no_other_found_a_merge`.
pub
/// [`repo_slug_with`], asked of a named remote rather than of `origin` (#7196,
/// #7850). #8403: its only production caller is [`merged_pr_search_repos_with`],
/// which asks `origin` first and the push remote second.
///
/// Why: same reason as [`repo_slug_with`] — the operator's `~/.ssh/config` is
/// not readable by a test that must answer the same on two machines.
/// What: [`repo_slug_with`]'s body with the remote name supplied.
/// Test: `a_fork_branch_is_searched_in_the_repository_it_was_pushed_to`.
pub
/// [`repo_slug_for`], against a stated SSH alias table (#7196).
///
/// Why: the alias table is the operator's `~/.ssh/config`, which no test may
/// read — a machine whose config renames `gh-work` and one whose config
/// does not would give the same test two answers. Taking the table as a
/// parameter is how a test states its own, without an environment variable this
/// crate is not allowed to set.
/// What: exactly [`repo_slug_for`]'s body; `repo_slug_for` is this with the
/// current user's table.
/// Test: `an_aliased_origin_resolves_through_the_given_ssh_config`,
/// `an_unresolvable_ssh_alias_refuses_and_names_the_alias`.
pub
/// `dir`'s URL for the named remote, or `None` when git reports none.
/// The slug of the checkout that owns `dir`'s worktree registry.
///
/// The fallback is ONE hop and never recurses: a checkout that is its own
/// registry root and still has no such remote has nothing left to fall back to.
/// Are these two paths the same directory, symlinks resolved?
/// The refusal for a directory whose repository cannot be established at all.
/// The refusal text for a remote URL that yields no slug.
///
/// Why: the operator's next action differs by case. An unparseable remote means
/// this is not a GitHub repository; an unresolved alias means one `~/.ssh/config`
/// entry is missing, and naming the alias is what turns a `gh` connection error
/// into an actionable line (#7196).
/// What: one sentence per [`SlugRefusal`] variant, each naming the directory and
/// what was read, the URL's credentials redacted.
/// Test: `an_unparseable_origin_refuses_and_quotes_the_url`,
/// `an_unresolvable_ssh_alias_refuses_and_names_the_alias`,
/// `a_refusal_never_quotes_the_origin_credentials_9124`.