Skip to content

Commit 47d89b5

Browse files
committed
Add a scoped push path so agents stop toggling global git config
Worktree agents that can't reach the ssh-agent socket have been working around SSH pushes by removing the global insteadOf rewrite, pushing, then restoring it. That window is shared-mutable state: a concurrent agent's push or fetch fails, and a crash or SIGKILL mid toggle leaves the machine's global config wrong for every repo. bin/git-push-scoped authenticates over HTTPS through gh's credential helper and rewrites the SSH remote to HTTPS, both scoped to a single git push invocation via -c. Nothing is written to any config file, so there is nothing to restore and nothing to race over.
1 parent 1e617b1 commit 47d89b5

3 files changed

Lines changed: 198 additions & 0 deletions

File tree

AGENTS.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,18 @@ counts meaningless to compare across branches — always use `bun run test`.
5353

5454
Follow the `style` skill's message format: plain-English summary, no `feat:`/`fix:` prefixes, no filename in the summary. Separate refactors from feature additions. Commit with the user's local git identity.
5555

56+
## Pushing
57+
58+
**Never mutate global git config**, for any reason — not `git config --global`, not toggling `url.*.insteadOf`, not even temporarily with a plan to restore it. Global config is shared-mutable state across every agent and every repo on the machine; a crash or a second agent running concurrently turns a "temporary" toggle into a lasting outage or collision. This is the same hazard class as running `git stash` (also global, also banned).
59+
60+
If SSH push fails because the shell can't reach the ssh-agent socket, use `bin/git-push-scoped` instead of touching config:
61+
62+
```bash
63+
bin/git-push-scoped origin <branch>
64+
```
65+
66+
It authenticates over HTTPS via `gh`'s credential helper and rewrites the SSH remote to HTTPS, both scoped to that one `git push` invocation with `-c`. Nothing is written to any config file, so there is nothing to restore and nothing to collide over.
67+
5668
## Building on Interchange
5769

5870
Interchange is the standard library for this repo, consumed as published `@intx/*` npm packages pinned at 0.2.2 (`@intx/inference` resolves to the vendored copy in `vendor/intx-inference` — upstream 0.2.2 plus the audited patch set on CL-4352). We never modify or push to the upstream interchange repository. Before writing any new infrastructure — plugins, middleware, utilities, state management, logging, authz, inference, tools — check these packages.

bin/git-push-scoped

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
#!/usr/bin/env bash
2+
# Push to a GitHub-hosted remote without touching global git config.
3+
#
4+
# Worktree agents often cannot reach the local ssh-agent socket, so an
5+
# SSH-configured "origin" (git@github.com:...) fails to push. The old
6+
# workaround toggled the *global* `url.git@github.com:.insteadOf` setting
7+
# off and back on, which is shared-mutable state: any other agent pushing
8+
# or fetching during that window breaks, and a crash mid-flight leaves the
9+
# machine altered for every repo, forever.
10+
#
11+
# This script authenticates over HTTPS using `gh`'s credential helper and
12+
# rewrites the SSH URL to HTTPS, both scoped to this single `git push`
13+
# invocation via `-c`. Nothing is written to any git config file, local or
14+
# global, so there is nothing to restore and nothing to collide over.
15+
set -euo pipefail
16+
17+
usage() {
18+
echo "usage: git-push-scoped [-C <dir>] [--] [git-push-args...]" >&2
19+
exit 1
20+
}
21+
22+
dir="."
23+
while [[ $# -gt 0 ]]; do
24+
case "$1" in
25+
-C)
26+
dir=$2
27+
shift 2
28+
;;
29+
--)
30+
shift
31+
break
32+
;;
33+
-h | --help)
34+
usage
35+
;;
36+
*)
37+
break
38+
;;
39+
esac
40+
done
41+
42+
if ! command -v gh >/dev/null 2>&1; then
43+
echo "git-push-scoped: gh is required for the scoped credential helper" >&2
44+
exit 1
45+
fi
46+
47+
exec git -C "$dir" \
48+
-c credential.helper= \
49+
-c credential.helper='!gh auth git-credential' \
50+
-c url."https://github.com/".insteadOf="git@github.com:" \
51+
-c url."https://github.com/".insteadOf="ssh://git@github.com/" \
52+
push "$@"
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* `bin/git-push-scoped` must never write to global git config: not on a
3+
* normal push, not when two pushes race, and not when one is killed
4+
* mid-flight. Every test runs against an isolated HOME/GIT_CONFIG_GLOBAL
5+
* so a bug here can never touch the real machine's config.
6+
*/
7+
8+
import { spawn } from "node:child_process";
9+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from "node:fs";
10+
import { tmpdir } from "node:os";
11+
import { join } from "node:path";
12+
13+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
14+
15+
const SCRIPT = join(import.meta.dir, "../../bin/git-push-scoped");
16+
17+
let root: string;
18+
let globalConfigPath: string;
19+
let fakeBinDir: string;
20+
let env: NodeJS.ProcessEnv;
21+
22+
function run(args: string[], opts: { cwd?: string } = {}) {
23+
return new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
24+
const child = spawn(SCRIPT, args, { cwd: opts.cwd ?? root, env, stdio: "ignore" });
25+
child.on("exit", (code, signal) => resolve({ code, signal }));
26+
});
27+
}
28+
29+
function initBareRemote(name: string): string {
30+
const path = join(root, name);
31+
mkdirSync(path);
32+
spawnSync("git", ["init", "--bare", "-q", path]);
33+
return path;
34+
}
35+
36+
// node:child_process.spawnSync is used only for synchronous test setup below.
37+
import { spawnSync } from "node:child_process";
38+
39+
function initWorkingRepo(name: string, remotePath: string): string {
40+
const path = join(root, name);
41+
mkdirSync(path);
42+
spawnSync("git", ["init", "-q", "-b", "main", path]);
43+
spawnSync("git", ["-C", path, "config", "user.email", "test@example.com"]);
44+
spawnSync("git", ["-C", path, "config", "user.name", "Test"]);
45+
writeFileSync(join(path, "file.txt"), name);
46+
spawnSync("git", ["-C", path, "add", "file.txt"]);
47+
spawnSync("git", ["-C", path, "commit", "-q", "-m", "initial commit"]);
48+
spawnSync("git", ["-C", path, "remote", "add", "origin", remotePath]);
49+
return path;
50+
}
51+
52+
beforeEach(() => {
53+
root = mkdtempSync(join(tmpdir(), "git-push-scoped-"));
54+
55+
// Isolated global config: a known sentinel that must never change.
56+
globalConfigPath = join(root, "global-gitconfig");
57+
writeFileSync(globalConfigPath, "[user]\n\tname = Sentinel\n\temail = sentinel@example.com\n");
58+
59+
// Stub `gh` on PATH so the script's `command -v gh` check passes without
60+
// depending on a real GitHub CLI install or credentials.
61+
fakeBinDir = join(root, "fakebin");
62+
mkdirSync(fakeBinDir);
63+
const ghStub = join(fakeBinDir, "gh");
64+
writeFileSync(ghStub, "#!/usr/bin/env bash\nexit 0\n");
65+
chmodSync(ghStub, 0o755);
66+
67+
env = {
68+
...process.env,
69+
HOME: root,
70+
GIT_CONFIG_GLOBAL: globalConfigPath,
71+
GIT_CONFIG_NOSYSTEM: "1",
72+
PATH: `${fakeBinDir}:${process.env.PATH}`,
73+
};
74+
});
75+
76+
afterEach(() => {
77+
rmSync(root, { recursive: true, force: true });
78+
});
79+
80+
describe("git-push-scoped", () => {
81+
it("pushes successfully without writing to global git config", async () => {
82+
const before = readFileSync(globalConfigPath, "utf8");
83+
const remote = initBareRemote("remote.git");
84+
const work = initWorkingRepo("work", remote);
85+
86+
const { code } = await run(["origin", "main"], { cwd: work });
87+
88+
expect(code).toBe(0);
89+
expect(readFileSync(globalConfigPath, "utf8")).toBe(before);
90+
});
91+
92+
it("leaves global config untouched when two pushes race", async () => {
93+
const before = readFileSync(globalConfigPath, "utf8");
94+
const remoteA = initBareRemote("remote-a.git");
95+
const remoteB = initBareRemote("remote-b.git");
96+
const workA = initWorkingRepo("work-a", remoteA);
97+
const workB = initWorkingRepo("work-b", remoteB);
98+
99+
const [resultA, resultB] = await Promise.all([
100+
run(["origin", "main"], { cwd: workA }),
101+
run(["origin", "main"], { cwd: workB }),
102+
]);
103+
104+
expect(resultA.code).toBe(0);
105+
expect(resultB.code).toBe(0);
106+
expect(readFileSync(globalConfigPath, "utf8")).toBe(before);
107+
});
108+
109+
it("leaves global config untouched when killed mid-flight", async () => {
110+
const before = readFileSync(globalConfigPath, "utf8");
111+
const remote = initBareRemote("remote-slow.git");
112+
const work = initWorkingRepo("work-slow", remote);
113+
114+
// A pre-receive hook that sleeps gives us a reliable window to kill the
115+
// push while it is in flight.
116+
const hookPath = join(remote, "hooks", "pre-receive");
117+
writeFileSync(hookPath, "#!/usr/bin/env bash\nsleep 5\n");
118+
chmodSync(hookPath, 0o755);
119+
120+
const child = spawn(SCRIPT, ["origin", "main"], { cwd: work, env, stdio: "ignore" });
121+
const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
122+
(resolve) => {
123+
child.on("exit", (code, signal) => resolve({ code, signal }));
124+
},
125+
);
126+
127+
await new Promise((resolve) => setTimeout(resolve, 300));
128+
child.kill("SIGKILL");
129+
const { signal } = await exited;
130+
131+
expect(signal).toBe("SIGKILL");
132+
expect(readFileSync(globalConfigPath, "utf8")).toBe(before);
133+
});
134+
});

0 commit comments

Comments
 (0)