#157: rewrite SSH origin to HTTPS in the worktree #183
@@ -126,6 +126,7 @@ public final class GitWorktrees implements Worktrees {
|
||||
afterWorktreeAdded.accept(wt);
|
||||
requireCredentialFreeHttpsOrigin(wt);
|
||||
configureEnvironmentCredentialHelper(repoRoot, wt);
|
||||
configureHttpsUrlRewriteForSshOrigin(repoRoot, wt);
|
||||
isolateToolSurface(wt);
|
||||
return wt;
|
||||
}
|
||||
@@ -186,6 +187,63 @@ public final class GitWorktrees implements Worktrees {
|
||||
ENVIRONMENT_CREDENTIAL_HELPER);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link #configureEnvironmentCredentialHelper} only ever fires for an HTTPS origin — Git never
|
||||
* consults a {@code credential.helper} for an SSH transport. This repo's own origin is
|
||||
* {@code ssh://git@git.ltms.dev:2224/fleet/fleetd.git}, and once {@code memberCredentials.policy:
|
||||
* allow-list} blocks {@code SSH_AUTH_SOCK} (fleetd #157), a member sitting on an SSH origin has no
|
||||
* way to authenticate at all — there is no private key file on this host, only an ssh-agent socket.
|
||||
*
|
||||
* <p>The fix is a <em>worktree-scoped</em> URL rewrite: {@code url.<https-base>.insteadOf
|
||||
* <ssh-base>}, set with {@code --worktree} so it lands only in
|
||||
* {@code <worktree>/.git/worktrees/<name>/config.worktree} (enabled by
|
||||
* {@code extensions.worktreeConfig}, already turned on above) and never touches the shared
|
||||
* repo-level config the primary checkout also reads. {@code insteadOf} — not
|
||||
* {@code pushInsteadOf} — because a member may also need to fetch or rebase, and a fetch over SSH
|
||||
* fails for the exact same missing-agent reason a push would.
|
||||
*
|
||||
* <p>The host (and, for the rewrite's SSH-side match, the port) come from parsing the origin
|
||||
* itself — never a hardcoded forge host, which is exactly what #177 removed. An origin that is
|
||||
* already {@code https://} is left alone; the credential helper already covers it. An origin
|
||||
* that is neither {@code ssh://} nor {@code https://} — including the scp-like shorthand
|
||||
* ({@code git@host:path}, no scheme) — is left untouched deliberately: that shorthand's
|
||||
* {@code host:path} split is defined by the user's ssh_config aliases, not by URI syntax, so
|
||||
* guessing at it risks rewriting to the wrong place. A repo provisioned from that form keeps
|
||||
* today's (broken, if the policy blocks the agent) SSH-only behaviour rather than a wrong rewrite.
|
||||
*/
|
||||
private void configureHttpsUrlRewriteForSshOrigin(String repoRoot, String worktreePath) {
|
||||
if (exitCode("git", "-C", repoRoot, "config", "--get", "remote.origin.url") != 0) {
|
||||
return;
|
||||
}
|
||||
String origin = exec("git", "-C", repoRoot, "config", "--get", "remote.origin.url").trim();
|
||||
URI uri;
|
||||
try {
|
||||
uri = new URI(origin);
|
||||
} catch (URISyntaxException e) {
|
||||
log.warn("origin URL {} is not a valid URI; skipping worktree HTTPS rewrite", origin);
|
||||
return;
|
||||
}
|
||||
String scheme = uri.getScheme();
|
||||
if (!"ssh".equalsIgnoreCase(scheme)) {
|
||||
// Already https:// (the credential helper covers it), or a scheme-less/scp-like origin
|
||||
// left alone on purpose — see the javadoc above.
|
||||
log.debug("origin scheme is not ssh ({}) — no worktree HTTPS rewrite needed", origin);
|
||||
return;
|
||||
}
|
||||
String host = uri.getHost();
|
||||
String authority = uri.getRawAuthority();
|
||||
if (host == null || host.isBlank() || authority == null || authority.isBlank()) {
|
||||
log.warn("ssh origin {} has no resolvable host; skipping worktree HTTPS rewrite", origin);
|
||||
return;
|
||||
}
|
||||
String sshBase = "ssh://" + authority + "/";
|
||||
String httpsBase = "https://" + host + "/";
|
||||
exec("git", "-C", worktreePath, "config", "--worktree", "--replace-all",
|
||||
"url." + httpsBase + ".insteadOf", sshBase);
|
||||
log.info("worktree {} rewrites {} to {} (worktree-scoped; parent checkout untouched)",
|
||||
worktreePath, sshBase, httpsBase);
|
||||
}
|
||||
|
||||
/**
|
||||
* Neutralize the worktree's worktree-hostile project configs so a worker inherits only the tools
|
||||
* and environment its launcher mounts (the bridge via {@code --mcp-config}, the opencode config
|
||||
|
||||
@@ -269,6 +269,71 @@ class GitWorktreesTest {
|
||||
assertFalse(credential.contains("operator-secret"), "Git used the inherited global helper");
|
||||
}
|
||||
|
||||
/**
|
||||
* fleetd #157 follow-up. An SSH origin never consults {@code credential.helper} — the environment
|
||||
* credential helper set by {@link GitWorktrees#add} is therefore useless when a member's origin is
|
||||
* SSH, which is exactly this repo's shape. The worktree must instead get a worktree-scoped
|
||||
* {@code url.<https>.insteadOf <ssh>} rewrite so both fetch and push resolve to HTTPS, while the
|
||||
* parent checkout — sharing the same repo-level origin config — must resolve the original SSH URL
|
||||
* completely unchanged. The host/port here (a synthetic {@code forge.example.test:2222}, not
|
||||
* {@code git.ltms.dev}) proves the rewrite is derived from the origin, not a hardcoded constant.
|
||||
*/
|
||||
@Test
|
||||
void aProvisionedWorktreeRewritesAnSshOriginToHttpsWorktreeScoped(@TempDir Path tmp) throws Exception {
|
||||
Path repo = initRepo(tmp.resolve("repo"));
|
||||
git(repo, "remote", "add", "origin", "ssh://git@forge.example.test:2222/acme/proj.git");
|
||||
|
||||
String wt = new GitWorktrees(tmp.resolve("wts").toString())
|
||||
.add(repo.toString(), "fleetd-157-ssh-rewrite", "HEAD");
|
||||
|
||||
Path worktree = Path.of(wt);
|
||||
assertEquals("https://forge.example.test/acme/proj.git",
|
||||
gitOutput(worktree, "remote", "get-url", "origin").trim(),
|
||||
"worktree fetch URL was not rewritten to HTTPS");
|
||||
assertEquals("https://forge.example.test/acme/proj.git",
|
||||
gitOutput(worktree, "remote", "get-url", "--push", "origin").trim(),
|
||||
"worktree push URL was not rewritten to HTTPS");
|
||||
// The raw config value is unchanged — only the resolved URL is rewritten, via insteadOf.
|
||||
assertEquals("ssh://git@forge.example.test:2222/acme/proj.git",
|
||||
gitOutput(worktree, "config", "--get", "remote.origin.url").trim());
|
||||
|
||||
assertEquals("ssh://git@forge.example.test:2222/acme/proj.git",
|
||||
gitOutput(repo, "remote", "get-url", "origin").trim(),
|
||||
"the parent checkout's fetch URL must be untouched");
|
||||
assertEquals("ssh://git@forge.example.test:2222/acme/proj.git",
|
||||
gitOutput(repo, "remote", "get-url", "--push", "origin").trim(),
|
||||
"the parent checkout's push URL must be untouched");
|
||||
}
|
||||
|
||||
/** An origin already on HTTPS is left alone — the environment credential helper already covers it. */
|
||||
@Test
|
||||
void aProvisionedWorktreeLeavesAnHttpsOriginAlone(@TempDir Path tmp) throws Exception {
|
||||
Path repo = initRepo(tmp.resolve("repo"));
|
||||
git(repo, "remote", "add", "origin", "https://git.ltms.dev/akb/kb.git");
|
||||
|
||||
String wt = new GitWorktrees(tmp.resolve("wts").toString())
|
||||
.add(repo.toString(), "fleetd-157-https-noop", "HEAD");
|
||||
|
||||
Path worktree = Path.of(wt);
|
||||
assertEquals("https://git.ltms.dev/akb/kb.git",
|
||||
gitOutput(worktree, "remote", "get-url", "origin").trim());
|
||||
assertEquals(1, exitCode("git", "-C", wt, "config", "--worktree", "--get-regexp", "^url\\."),
|
||||
"no url.*.insteadOf rewrite should be added for an already-HTTPS origin");
|
||||
}
|
||||
|
||||
/** Test-local exit-code probe, mirroring {@link GitWorktrees#exitCode} for an assertion the
|
||||
* production class does not expose. */
|
||||
private static int exitCode(String... command) throws Exception {
|
||||
ProcessBuilder pb = new ProcessBuilder(command).redirectErrorStream(true);
|
||||
pb.environment().put("GIT_CONFIG_GLOBAL", "/dev/null");
|
||||
pb.environment().put("GIT_CONFIG_SYSTEM", "/dev/null");
|
||||
pb.environment().put("GIT_TERMINAL_PROMPT", "0");
|
||||
Process p = pb.start();
|
||||
p.getInputStream().readAllBytes();
|
||||
assertTrue(p.waitFor(30, TimeUnit.SECONDS), "command timed out: " + String.join(" ", command));
|
||||
return p.exitValue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void provisioningRefusesAWorktreeWhoseOriginStillHasHttpsUserInfo(@TempDir Path tmp) throws Exception {
|
||||
Path repo = initRepo(tmp.resolve("repo"));
|
||||
|
||||
Reference in New Issue
Block a user