Infer the repository from the forge remote, whatever it is named

45b666198cd8 · AtlantisPleb · · parent a2615b70c8c4

Infer the repository from the forge remote, whatever it is named

`GitRunner.inferRepository` read the remote literally named `origin`, so every
command that infers — `repo view`, `repo clone`, and the whole `issue` and
`project` groups — failed in a checkout set up the way this project requires.
`CLAUDE.md` says to push with `git push openagents HEAD:main`,
`ops/ci/push-remote-check.sh` refuses a non-forge push, and `docs/taxonomy.md`
states that the forge is authority while `origin` is the GitHub mirror. Both
this repository and the openagents monorepo now carry the forge remote alone,
so neither could infer. A contributor who kept a GitHub `origin` fared worse
than one who did not: inference succeeded against a mirror, and the CLI then
made API calls about a record the forge does not own.

A remote's name is a local convention. Its URL is the fact. Inference now reads
every remote and takes the first whose URL is an exact `/<owner>/<repo>.git`
path on the API origin the invocation is already talking to, which admits a
forge remote under any name and excludes a mirror under any name. Where several
remotes point at the forge, `origin` is preferred so a checkout that resolved
before this change resolves to the same repository, then `openagents`, then the
rest as `git remote -v` lists them.

The refusal now names every remote it examined and why each was rejected. The
old sentence asked the reader to "configure an admitted OpenAgents origin
remote", which reads as a requirement to name a remote `origin` — the wrong
path, and the one that produced this bug.

The fix is at the single seam every one of those commands shares.

Closes #157.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTmy4SEXrHXouw5sZbs3f4
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes
#157

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified packages/openagents-cli/README.md
  • modified packages/openagents-cli/src/git-runner.ts
  • modified packages/openagents-cli/test/git-runner.test.ts

Diff

3 files changed, +227 -17

packages/openagents-cli/README.md modified +16 -5

@@ -215,8 +215,18 @@ automatically. While the server creates repository storage, the CLI writes the

215 215
current state and a five-second heartbeat to standard error.
216 216
217 217
`repo view` and `repo clone` accept `-R, --repo <owner>/<name>`. When you omit a
218
repository, the CLI infers it only from an exact `/<owner>/<repo>.git` URL
219
on the selected OpenAgents API origin.
218
repository, the CLI infers it from this checkout's Git remotes.
219
220
A remote's name does not decide this; its URL does. The CLI reads every remote
221
and takes the first whose URL is an exact `/<owner>/<repo>.git` path on the
222
OpenAgents API origin in use, so a forge remote named `openagents` and one named
223
`origin` both work. A GitHub mirror is never inferred, whatever it is called,
224
because the forge is the authority for issues and projects and a mirror is not.
225
When more than one remote points at the forge, `origin` wins, then `openagents`,
226
then the rest in the order `git remote -v` lists them.
227
228
When no remote qualifies, the CLI names each remote it examined and why it was
229
rejected, and asks for `OWNER/REPO` instead.
220 230
221 231
`repo delete` permanently deletes a repository you own, including its Git
222 232
history, issues, projects, and import records. It accepts an explicit

@@ -290,9 +300,10 @@ openagents project item-remove 2 175

290 300
Projects are repository-scoped, so every project command takes the same
291 301
`-R, --repo` and remote inference the issue commands take.
292 302
293
Every issue and project command accepts `-R, --repo <owner>/<name>` and falls
294
back to the origin remote the way `repo view` does. Issue and project numbers
295
are bare integers; a leading `#` is accepted and never required.
303
Every issue and project command accepts `-R, --repo <owner>/<name>` and
304
otherwise infers the repository from the forge remote the way `repo view` does,
305
whatever that remote is named. Issue and project numbers are bare integers; a
306
leading `#` is accepted and never required.
296 307
297 308
Add `--json` before a subcommand to return machine-readable output. Add
298 309
`--no-color`, or set `NO_COLOR`, to disable ANSI output. The clone command
packages/openagents-cli/src/git-runner.ts modified +98 -12

@@ -86,7 +86,7 @@ export const repositoryFromRemoteUrl = Effect.fn("GitRunner.repositoryFromRemote

86 86
    try: () => new URL(remoteUrl),
87 87
    catch: () =>
88 88
      new InputError({
89
        message: "The origin remote is not an admitted OpenAgents repository URL.",
89
        message: "That Git remote URL is not an admitted OpenAgents repository URL.",
90 90
      }),
91 91
  });
92 92
  const parts = remote.pathname.split("/");

@@ -104,7 +104,7 @@ export const repositoryFromRemoteUrl = Effect.fn("GitRunner.repositoryFromRemote

104 104
    !repositoryWithSuffix.endsWith(".git")
105 105
  ) {
106 106
    return yield* new InputError({
107
      message: "The origin remote is not an admitted OpenAgents repository URL.",
107
      message: "That Git remote URL is not an admitted OpenAgents repository URL.",
108 108
    });
109 109
  }
110 110
  const repository = repositoryWithSuffix.slice(0, -4);

@@ -112,7 +112,7 @@ export const repositoryFromRemoteUrl = Effect.fn("GitRunner.repositoryFromRemote

112 112
    try: () => [decodeURIComponent(owner), decodeURIComponent(repository)] as const,
113 113
    catch: () =>
114 114
      new InputError({
115
        message: "The origin remote is not an admitted OpenAgents repository URL.",
115
        message: "That Git remote URL is not an admitted OpenAgents repository URL.",
116 116
      }),
117 117
  });
118 118
  if (

@@ -122,12 +122,67 @@ export const repositoryFromRemoteUrl = Effect.fn("GitRunner.repositoryFromRemote

122 122
    decodedRepository.length === 0
123 123
  ) {
124 124
    return yield* new InputError({
125
      message: "The origin remote is not an admitted OpenAgents repository URL.",
125
      message: "That Git remote URL is not an admitted OpenAgents repository URL.",
126 126
    });
127 127
  }
128 128
  return `${decodedOwner}/${decodedRepository}`;
129 129
});
130 130
131
export interface GitRemoteUrl {
132
  readonly name: string;
133
  readonly url: string;
134
}
135
136
/**
137
 * Reads `git remote -v` into one URL per remote.
138
 *
139
 * Each remote prints a fetch line and a push line. The fetch URL is the one a
140
 * repository is read from, so it wins; a remote configured for push alone
141
 * still contributes the URL it has.
142
 */
143
export const parseGitRemotes = (output: string): ReadonlyArray<GitRemoteUrl> => {
144
  const urls = new Map<string, string>();
145
  for (const rawLine of output.split("\n")) {
146
    const match = /^(\S+)\s+(.+)\s+\((fetch|push)\)$/u.exec(rawLine.trim());
147
    if (match === null) continue;
148
    const name = match[1];
149
    const url = match[2];
150
    if (name === undefined || url === undefined) continue;
151
    if (match[3] === "fetch" || !urls.has(name)) urls.set(name, url);
152
  }
153
  return [...urls].map(([name, url]) => ({ name, url }));
154
};
155
156
/**
157
 * Remote names tried first, in order.
158
 *
159
 * A name never admits a remote; only its URL does. This order decides between
160
 * remotes that already point at the API origin in use. `origin` comes first so
161
 * a checkout that resolved before this rule existed still resolves to the same
162
 * repository, then the forge name this project's own documentation uses. Any
163
 * other remote follows in the order `git remote -v` printed it, which git
164
 * emits by name, so the same checkout always answers the same way.
165
 */
166
const PREFERRED_REMOTE_NAMES: ReadonlyArray<string> = ["origin", "openagents"];
167
168
export const orderedRemoteNames = (names: ReadonlyArray<string>): ReadonlyArray<string> => [
169
  ...PREFERRED_REMOTE_NAMES.filter((preferred) => names.includes(preferred)),
170
  ...names.filter((name) => !PREFERRED_REMOTE_NAMES.includes(name)),
171
];
172
173
/** Why one remote is not the repository, in the words a reader can act on. */
174
const remoteRejection = (origin: string, url: string): string => {
175
  let remote: URL;
176
  try {
177
    remote = new URL(url);
178
  } catch {
179
    return "is not an HTTP URL";
180
  }
181
  if (remote.protocol !== "http:" && remote.protocol !== "https:") return "is not an HTTP URL";
182
  if (remote.origin !== origin) return `points at ${remote.origin}`;
183
  return "is not an OWNER/REPO.git path on that origin";
184
};
185
131 186
export const gitRunnerLayer = Layer.effect(
132 187
  GitRunner,
133 188
  Effect.gen(function* () {

@@ -257,21 +312,52 @@ export const gitRunnerLayer = Layer.effect(

257 312
      return { remote, nextPushArguments: ["push", "-u", remote, "HEAD"] };
258 313
    });
259 314
315
    /**
316
     * The repository this checkout belongs to, taken from its Git remotes.
317
     *
318
     * A remote's name is a local convention: this project's own contract names
319
     * the forge remote `openagents` and reserves `origin` for the GitHub
320
     * mirror, while other checkouts name the forge `origin`. The URL is the
321
     * fact, so a remote is admitted only when its URL is a repository URL on
322
     * the API origin this invocation is already talking to. A mirror is
323
     * therefore never inferred, whatever it is called.
324
     */
260 325
    const inferRepository = Effect.fn("GitRunner.inferRepository")(function* (
261 326
      origin: string,
262 327
      directory = ".",
263 328
    ) {
264
      const existing = yield* runGit(
265
        "git origin lookup",
266
        ["remote", "get-url", "--", "origin"],
267
        directory,
268
      );
269
      if (existing.exitCode !== 0 || existing.stdout.length === 0) {
329
      const listed = yield* runGit("git remote lookup", ["remote", "-v"], directory);
330
      if (listed.exitCode !== 0) {
331
        return yield* new InputError({
332
          message: `The CLI could not read the Git remotes of ${directory}. Pass OWNER/REPO instead.`,
333
        });
334
      }
335
336
      const remotes = parseGitRemotes(listed.stdout);
337
      if (remotes.length === 0) {
270 338
        return yield* new InputError({
271
          message: "Pass OWNER/REPO or configure an admitted OpenAgents origin remote.",
339
          message: `This checkout has no Git remotes. Pass OWNER/REPO, or add a remote for ${origin}.`,
272 340
        });
273 341
      }
274
      return yield* repositoryFromRemoteUrl(origin, existing.stdout);
342
343
      const byName = new Map(remotes.map((remote) => [remote.name, remote.url]));
344
      const rejected: Array<string> = [];
345
      for (const name of orderedRemoteNames([...byName.keys()])) {
346
        const url = byName.get(name);
347
        if (url === undefined) continue;
348
        const repository = yield* repositoryFromRemoteUrl(origin, url).pipe(
349
          Effect.orElseSucceed((): string | undefined => undefined),
350
        );
351
        if (repository !== undefined) return repository;
352
        rejected.push(`${name} ${remoteRejection(origin, url)}`);
353
      }
354
355
      return yield* new InputError({
356
        message:
357
          `No Git remote of this checkout is a repository on ${origin}, the OpenAgents origin in use: ` +
358
          `${rejected.join("; ")}. A remote's name does not decide this; its URL does. ` +
359
          "Pass OWNER/REPO instead.",
360
      });
275 361
    });
276 362
277 363
    const configureCredentialHelper = Effect.fn("GitRunner.configureCredentialHelper")(function* (
packages/openagents-cli/test/git-runner.test.ts modified +113

@@ -10,6 +10,8 @@ import {

10 10
  GitRunner,
11 11
  gitCloneArgv,
12 12
  gitRunnerLayer,
13
  orderedRemoteNames,
14
  parseGitRemotes,
13 15
  repositoryFromRemoteUrl,
14 16
} from "../src/git-runner.js";
15 17

@@ -144,3 +146,114 @@ describe("git clone argument construction", () => {

144 146
    }
145 147
  });
146 148
});
149
150
const FORGE = "http://localhost:4000";
151
152
/** A throwaway checkout carrying exactly the remotes a case is about. */
153
const withRemotes = async (
154
  remotes: ReadonlyArray<readonly [string, string]>,
155
  assert: (directory: string) => Promise<void>,
156
) => {
157
  const directory = await mkdtemp(join(tmpdir(), "openagents-cli-remotes-"));
158
  try {
159
    execFileSync("git", ["init", "--quiet", directory]);
160
    for (const [name, url] of remotes) {
161
      execFileSync("git", ["-C", directory, "remote", "add", name, url]);
162
    }
163
    await assert(directory);
164
  } finally {
165
    await rm(directory, { recursive: true, force: true });
166
  }
167
};
168
169
const infer = (directory: string) =>
170
  Effect.runPromise(
171
    Effect.gen(function* () {
172
      const git = yield* GitRunner;
173
      return yield* git.inferRepository(FORGE, directory);
174
    }).pipe(Effect.provide(gitRunnerLayer.pipe(Layer.provide(NodeServices.layer)))),
175
  );
176
177
describe("repository inference from Git remotes", () => {
178
  it("reads one URL per remote and prefers the fetch URL", () => {
179
    expect(
180
      parseGitRemotes(
181
        [
182
          "openagents\thttp://localhost:4000/octavia/project.git (fetch)",
183
          "openagents\thttp://localhost:4000/octavia/project.git (push)",
184
          "mirror\thttps://github.com/octavia/project.git (fetch)",
185
          "mirror\tno-push (push)",
186
        ].join("\n"),
187
      ),
188
    ).toEqual([
189
      { name: "openagents", url: "http://localhost:4000/octavia/project.git" },
190
      { name: "mirror", url: "https://github.com/octavia/project.git" },
191
    ]);
192
  });
193
194
  it("tries origin, then the documented forge name, then the rest as listed", () => {
195
    expect(orderedRemoteNames(["upstream", "openagents", "origin"])).toEqual([
196
      "origin",
197
      "openagents",
198
      "upstream",
199
    ]);
200
    expect(orderedRemoteNames(["zulu", "alpha"])).toEqual(["zulu", "alpha"]);
201
  });
202
203
  it("infers from a forge remote named openagents, the name this project mandates", async () => {
204
    await withRemotes([["openagents", `${FORGE}/octavia/project.git`]], async (directory) => {
205
      await expect(infer(directory)).resolves.toBe("octavia/project");
206
    });
207
  });
208
209
  it("still infers where the forge remote is named origin", async () => {
210
    await withRemotes([["origin", `${FORGE}/octavia/project.git`]], async (directory) => {
211
      await expect(infer(directory)).resolves.toBe("octavia/project");
212
    });
213
  });
214
215
  it("ignores a GitHub mirror and takes the forge remote beside it", async () => {
216
    await withRemotes(
217
      [
218
        ["origin", "https://github.com/octavia/project.git"],
219
        ["openagents", `${FORGE}/octavia/forge-name.git`],
220
      ],
221
      async (directory) => {
222
        await expect(infer(directory)).resolves.toBe("octavia/forge-name");
223
      },
224
    );
225
  });
226
227
  it("prefers origin when both remotes are on the forge, so existing setups do not move", async () => {
228
    await withRemotes(
229
      [
230
        ["openagents", `${FORGE}/octavia/second.git`],
231
        ["origin", `${FORGE}/octavia/first.git`],
232
      ],
233
      async (directory) => {
234
        await expect(infer(directory)).resolves.toBe("octavia/first");
235
      },
236
    );
237
  });
238
239
  it("refuses a checkout whose only remote is a GitHub mirror, and says why", async () => {
240
    await withRemotes([["origin", "https://github.com/octavia/project.git"]], async (directory) => {
241
      await expect(infer(directory)).rejects.toThrow(/origin points at https:\/\/github\.com/u);
242
      await expect(infer(directory)).rejects.toThrow(/name does not decide this/u);
243
    });
244
  });
245
246
  it("names a remote on the forge whose path is not a repository", async () => {
247
    await withRemotes([["openagents", `${FORGE}/octavia/deep/project.git`]], async (directory) => {
248
      await expect(infer(directory)).rejects.toThrow(
249
        /openagents is not an OWNER\/REPO\.git path on that origin/u,
250
      );
251
    });
252
  });
253
254
  it("refuses a checkout with no remotes at all", async () => {
255
    await withRemotes([], async (directory) => {
256
      await expect(infer(directory)).rejects.toThrow(/has no Git remotes/u);
257
    });
258
  });
259
});

This page updates live while a promote is in flight · changelog