feat(git): give network operations the long deadline

run_op now routes fetch/pull/push through Host::git_with_deadline. The
no-prompt environment turned out not to belong here at all — LocalHost puts
it on every call, on both sides of the wire, so ops.rs's own copy of the
same four variables was a second definition waiting to drift. Removed it;
the conformance case git_terminal_prompt_is_disabled guards the behaviour
across local and remote, which the unit test on the constant could not.

Also corrects git_output_with_env's doc comment, which claimed read paths
must not inherit the no-prompt environment. They do, deliberately: a read
path never prompts, and a request arriving over the wire carries no bit
saying which kind it is.
This commit is contained in:
l0ng-ai
2026-08-09 00:34:59 +08:00
parent 5171880056
commit b8757372b3
2 changed files with 18 additions and 50 deletions
+8 -3
View File
@@ -113,9 +113,14 @@ pub fn git_output(cwd: &Path, args: &[&str]) -> io::Result<Output> {
git_output_with_env(cwd, args, &[])
}
/// `git_output` plus extra environment. Network operations (`fetch`/`pull`/
/// `push`) need to be told they have no terminal to prompt at; read paths must
/// *not* inherit that, so the two share a body rather than a config.
/// `git_output` plus extra environment.
///
/// What `LocalHost` passes is the no-prompt set: git and ssh have to fail
/// rather than block on a credential prompt nobody is watching. It goes on
/// every call, not just `fetch`/`pull`/`push` — a read path never prompts, so
/// carrying it there costs nothing, and a remote workspace only inherits it
/// because the far side's `LocalHost` applies the same rule to a request that
/// arrived over the wire with no "this one is a network op" bit on it.
///
/// A `None` value removes the variable instead of setting it.
pub fn git_output_with_env(
+10 -47
View File
@@ -514,24 +514,6 @@ fn batched(prefix: &[&str], specs: &[String]) -> Vec<Vec<String>> {
.collect()
}
/// The environment a network operation needs, for whoever spawns it.
///
/// Without `GIT_TERMINAL_PROMPT=0` git opens `/dev/tty` directly when stdin is
/// closed, and in some environments it gets one and hangs on "Username for
/// ...". A `None` value means the variable is removed.
///
/// This lives here next to the operations that need it, but only
/// `git_output_with_env` can apply it — `Host::git` takes no environment. It is
/// the host layer's job to reach for this.
pub fn network_env() -> &'static [(&'static str, Option<&'static str>)] {
&[
("GIT_TERMINAL_PROMPT", Some("0")),
("GIT_ASKPASS", None),
("SSH_ASKPASS", None),
("SSH_ASKPASS_REQUIRE", Some("never")),
]
}
/// What a failure means, from git's own words.
///
/// Substring matching on English output, checked in a fixed order because the
@@ -637,11 +619,16 @@ pub fn run_op(
.collect::<Vec<_>>()
};
// Network operations still take the plain path: they want
// `GIT_NETWORK_DEADLINE` and `network_env`, and neither can be
// expressed through `Host::git`. Both arrive with the host layer's
// `git_with_deadline`.
let out = host.git(root, &borrowed).map_err(|err| GitOpError {
// A push over a slow link outlives the deadline a `Git` request gets
// by default, which is sized for interactive queries. The no-prompt
// environment is not this layer's business: `LocalHost` puts it on
// every call, on both sides of the wire.
let spawned = if op.is_network() {
host.git_with_deadline(root, &borrowed, GIT_NETWORK_DEADLINE)
} else {
host.git(root, &borrowed)
};
let out = spawned.map_err(|err| GitOpError {
op: label,
kind: GitOpErrorKind::Spawn,
message: err.to_string(),
@@ -1353,30 +1340,6 @@ mod tests {
}
}
#[test]
fn the_network_environment_closes_every_prompt() {
let env = network_env();
assert_eq!(
env.iter().find(|(k, _)| *k == "GIT_TERMINAL_PROMPT"),
Some(&("GIT_TERMINAL_PROMPT", Some("0"))),
);
for key in ["GIT_ASKPASS", "SSH_ASKPASS"] {
assert_eq!(
env.iter().find(|(k, _)| *k == key).map(|(_, v)| *v),
Some(None),
"{key} has to be removed, not set",
);
}
assert_eq!(
env.iter().find(|(k, _)| *k == "SSH_ASKPASS_REQUIRE"),
Some(&("SSH_ASKPASS_REQUIRE", Some("never"))),
);
}
fn kind_of(stderr: &str) -> GitOpErrorKind {
classify(stderr, Some(1))
}
#[test]
fn classify_recognizes_every_shape_of_credential_failure() {
for stderr in [