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
//! One answer to "does this repository have a remote?" (#8934).
//!
//! Why: the owner ruled on 2026-09-30 that a managed session must run in a git
//! repository with no `origin` remote — local-only worktrees, no push and no
//! PR, merges kept local. Every tm path that spawns, fetches, pushes, opens a
//! PR or pins a gh account has to ask the same question, and two predicates
//! would drift. A local-only repository also has no GitHub identity to prove,
//! so #8914's rule that an unprovable pin fails closed means its gh is
//! disabled outright rather than left to the machine's active account — except
//! for an allow-listed supervisor (the Architect), whose directory has no
//! origin by design and which needs the machine's gh accounts (07:47Z ruling).
//! What: [`remote_mode`] classifies a directory; [`gh_disabled_for`] decides
//! whether a local-only root gets the no-gh pin; [`local_only_gh_vars`] is that
//! pin; [`local_default_branch`] names the branch a local-only worktree is cut
//! from (there is no `origin/<default>` to fetch); [`LOCAL_ONLY_SKIP`] is the
//! one-line notice a skipped push/PR step prints.
//! Test: `remote_mode_tests.rs`.
use std::path::{Path, PathBuf};
use crate::core::config::MpmConfig;
/// The notice every skipped push, PR, fetch or remote-branch step prints.
pub const LOCAL_ONLY_SKIP: &str = "local-only repo: no remote; skipping push/PR";
/// The `GH_CONFIG_DIR` a local-only session is pinned to.
///
/// Why: gh cannot read or create a directory under `/dev/null`, so every gh
/// command exits before it authenticates ("failed to read configuration: …
/// not a directory", measured with the installed gh on 2026-09-30) — whatever
/// token the session later sets, and with no directory to write a login into.
pub const LOCAL_ONLY_GH_CONFIG_DIR: &str = "/dev/null/tm-local-only-repo-no-gh";
/// The value a local-only session gets in `GH_TOKEN` and `GH_ENTERPRISE_TOKEN`.
///
/// Why: an env token outranks every other gh credential source, so filling
/// both keeps an inherited real token from reaching gh; it authenticates as
/// nobody and is not a secret.
pub const LOCAL_ONLY_GH_TOKEN: &str = "tm-local-only-repo-no-gh";
/// Whether a directory's repository has an `origin` remote.
///
/// Why: see the module docs — the three answers need different handling, and
/// an `Option` would read "not a repository" the same as "no remote".
/// What: `Origin` carries the configured URL; `LocalOnly` is a git work tree
/// with no `origin` (other remotes are ignored: tm pushes only to `origin`),
/// carrying the work tree's top level; `NotARepository` is a directory git
/// positively reports is not in a repository.
/// Test: `remote_mode_reads_origin_local_only_and_non_repo`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RemoteMode {
/// `origin` is configured; the value is its URL.
Origin(String),
/// A git work tree with no `origin` remote, rooted at `root`.
LocalOnly {
/// `git rev-parse --show-toplevel` of the directory asked about.
root: PathBuf,
},
/// Not inside a git work tree.
NotARepository,
}
impl RemoteMode {
/// Whether this is a repository with no `origin` remote.
pub fn is_local_only(&self) -> bool {
matches!(self, RemoteMode::LocalOnly { .. })
}
}
/// Classify `path` by its `origin` remote (#8934).
///
/// Why: the single predicate every remote-touching tm path shares.
/// What: reads `origin` through
/// [`crate::daemon::managed_routes::inproject::get_origin_url`] (which keeps a
/// git failure apart from an absent remote, #4734, and propagates it as
/// `Err`); with no `origin`, asks `git rev-parse --show-toplevel`. Success is
/// `LocalOnly`; a failure whose stderr says "not a git repository" is
/// `NotARepository`; any other failure (a `safe.directory` "dubious
/// ownership" refusal, an unrunnable git) is `Err`, never a guess.
/// Test: `remote_mode_reads_origin_local_only_and_non_repo`,
/// `remote_mode_propagates_an_unreadable_git_dir`,
/// `a_git_failure_that_is_not_not_a_repository_is_an_error`.
pub fn remote_mode(path: &Path) -> Result<RemoteMode, String> {
if let Some(url) = crate::daemon::managed_routes::inproject::get_origin_url(path)? {
return Ok(RemoteMode::Origin(url));
}
let out = trusty_common::git::command()
.arg("-C")
.arg(path)
.args(["rev-parse", "--show-toplevel"])
.output()
.map_err(|e| format!("could not run `git rev-parse` in '{}': {e}", path.display()))?;
classify_toplevel(path, out.status.success(), &out.stdout, &out.stderr)
}
/// [`remote_mode`]'s reading of a `git rev-parse --show-toplevel` result for a
/// directory with no `origin` (#8934 LOW 8).
/// Test: `a_git_failure_that_is_not_not_a_repository_is_an_error`.
fn classify_toplevel(
path: &Path,
success: bool,
stdout: &[u8],
stderr: &[u8],
) -> Result<RemoteMode, String> {
if success {
let root = String::from_utf8_lossy(stdout).trim().to_string();
return Ok(RemoteMode::LocalOnly { root: root.into() });
}
let stderr = String::from_utf8_lossy(stderr);
if stderr.contains("not a git repository") {
return Ok(RemoteMode::NotARepository);
}
Err(format!(
"`git rev-parse --show-toplevel` failed in '{}': {}",
path.display(),
stderr.trim()
))
}
/// Whether `path` is the ROOT of a local-only repository — a `.git` entry of
/// its own and no `origin` remote.
///
/// Why: the daemon's local-only spawn serves a repository root only, the same
/// rule the in-project path applies to a GitHub checkout (ADR-0055).
/// What: `path/.git` exists and [`remote_mode`] says `LocalOnly`.
/// Test: `local_only_root_is_the_repo_root_only`.
pub fn is_local_only_root(path: &Path) -> Result<bool, String> {
if !path.join(".git").exists() {
return Ok(false);
}
Ok(remote_mode(path)?.is_local_only())
}
/// Whether a local-only repository rooted at `root` gets the no-gh pin.
///
/// Why: the Architect's directory has no origin by design and it needs the
/// machine's gh accounts (07:47Z ruling). Only the allow-listed profile
/// decides that — a project's own `.trusty-mpm.toml` alone cannot (#8453).
/// What: `true` unless [`crate::core::session_profile::resolve`] says
/// supervisor for `root` under `config`.
/// Test: `an_allow_listed_supervisor_keeps_gh`,
/// `a_self_declared_supervisor_does_not_keep_gh`.
pub fn gh_disabled_for(root: &Path, config: &MpmConfig) -> bool {
!crate::core::session_profile::resolve(root, config).is_supervisor()
}
/// The spawn-env pin for a session in a local-only repository (#8934).
///
/// Why: an absent pin hands the session the machine's active gh account — the
/// wrong-identity outcome #8914 closed — so "no remote" is pinned explicitly.
/// What: `GH_CONFIG_DIR` = [`LOCAL_ONLY_GH_CONFIG_DIR`], both token vars =
/// [`LOCAL_ONLY_GH_TOKEN`], and a git credential clamp like #8914's
/// `git_credential_pin`: `credential.helper` reset to an empty list and
/// `GIT_TERMINAL_PROMPT=0`, so HTTPS git cannot reach a stored credential
/// either. Setting `GH_CONFIG_DIR` also makes
/// [`crate::core::gh_identity::inherited_identity_to_clear`] strip an
/// inherited `GITHUB_TOKEN` / `GITHUB_ENTERPRISE_TOKEN` from the child.
/// Test: `a_local_only_repo_spawns_with_gh_disabled`,
/// `local_only_gh_env_strips_every_inherited_token`.
pub fn local_only_gh_vars() -> Vec<(String, String)> {
[
("GH_CONFIG_DIR", LOCAL_ONLY_GH_CONFIG_DIR),
("GH_TOKEN", LOCAL_ONLY_GH_TOKEN),
("GH_ENTERPRISE_TOKEN", LOCAL_ONLY_GH_TOKEN),
("GIT_CONFIG_COUNT", "1"),
("GIT_CONFIG_KEY_0", "credential.helper"),
("GIT_CONFIG_VALUE_0", ""),
("GIT_TERMINAL_PROMPT", "0"),
]
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
/// The gh spawn vars for a session in the local-only repository at `root`.
///
/// Why: every daemon spawn, resume and relaunch path reaches this through
/// `core::gh_account::resolve_gh_account_env_for_registry`, so the supervisor
/// exemption is decided once.
/// What: [`local_only_gh_vars`] when [`gh_disabled_for`]; otherwise no vars —
/// the allow-listed supervisor keeps the machine's gh. Logs which at `info`.
/// Test: `an_allow_listed_supervisor_keeps_gh`,
/// `a_self_declared_supervisor_does_not_keep_gh`.
pub fn local_only_spawn_vars(root: &Path, config: &MpmConfig) -> Vec<(String, String)> {
if gh_disabled_for(root, config) {
tracing::info!(root = %root.display(), "{LOCAL_ONLY_SKIP}; gh is disabled for this session");
return local_only_gh_vars();
}
tracing::info!(root = %root.display(), "{LOCAL_ONLY_SKIP}; allow-listed supervisor keeps gh");
Vec::new()
}
/// The branch a local-only worktree is cut from (#8934).
///
/// Why: with no `origin/<default>`, the root's checked-out branch is whatever
/// the operator last switched to; a session must start from the repository's
/// default branch, not from a feature branch.
/// What: the first of `init.defaultBranch` (as git resolves it from `repo`),
/// `main`, `master` that exists as a local branch. `Err` naming the repository
/// when none does.
/// Test: `local_default_branch_ignores_a_checked_out_feature_branch`,
/// `local_default_branch_refuses_when_none_exists`.
pub fn local_default_branch(repo: &Path) -> Result<String, String> {
let git = |args: &[&str]| {
trusty_common::git::command()
.arg("-C")
.arg(repo)
.args(args)
.output()
.ok()
.filter(|out| out.status.success())
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
};
let configured = git(&["config", "--get", "init.defaultBranch"]).filter(|b| !b.is_empty());
configured
.into_iter()
.chain(["main".to_string(), "master".to_string()])
.find(|b| {
git(&[
"rev-parse",
"--verify",
"--quiet",
&format!("refs/heads/{b}"),
])
.is_some()
})
.ok_or_else(|| {
format!(
"local-only repo '{}' has no local default branch (no `init.defaultBranch` \
branch, no `main`, no `master`); create one before starting a worktree session",
repo.display()
)
})
}
#[cfg(test)]
#[path = "remote_mode_tests.rs"]
mod tests;