← reports

#2682 · Project source updates leave a missing Git remote unchanged

Bug Medium Effort: Low workspaces open on GitHub 2026-08-29 · base fc94f46c1

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

A project can store no Git remote when its local source has no origin during project creation.

After the operator adds an origin, a source update does not inspect that source again.

The server already has the required host command and a database helper that only fills a missing value.

The source-update route does not call either operation.

2. Claims vs findings

ClaimStatusEvidence
A local project can keep a null remote.VerifiedThe test created such a project with the standard database helper.
The host can inspect an origin remote.VerifiedThe host handler runs git remote get-url origin and returns null after a failed command.
A source update does not refresh the project remote.VerifiedBoth clean runs timed out because the route queued no project.inspect command.
The project update command can repair this value.RefutedThe CLI project update command only sends a project name.

3. Environment

4. Minimal reproduction

  1. Check out the trusted commit.
  2. Run pnpm install --frozen-lockfile --prefer-offline.
  3. Run pnpm exec turbo run build.
  4. Copy the focused regression test below into apps/server/test/public/public-project-source-remote-refresh.test.ts.
  5. From apps/server, run this command:
    pnpm exec vitest run --config vitest.config.ts test/public/public-project-source-remote-refresh.test.ts

Expected: The update queues project.inspect, stores the returned remote, and passes the test.

Actual:

FAIL public project source remote refresh > backfills a missing remote when a local source is updated
Error: Timed out waiting for queued command; captured: none
Test Files  1 failed (1)
Tests       1 failed (1)
Reproduction test source
import { describe, expect, it } from "vitest";
import {
  reportQueuedCommandSuccess,
  waitForQueuedCommand,
} from "../helpers/commands.js";
import { readJson } from "../helpers/json.js";
import {
  seedHostSession,
  seedPrimaryHost,
  seedProjectWithSource,
} from "../helpers/seed.js";
import { withTestHarness } from "../helpers/test-app.js";

describe("public project source remote refresh", () => {
  it("backfills a missing remote when a local source is updated", async () => {
    await withTestHarness(async (harness) => {
      const { host } = seedHostSession(harness.deps, {
        id: "host-source-remote-refresh",
      });
      seedPrimaryHost(harness.deps, host.id);
      const { project, source } = seedProjectWithSource(harness.deps, {
        hostId: host.id,
        path: "/tmp/project-source-remote-refresh",
      });
      expect(project.gitRemoteUrl).toBeNull();

      const updatePromise = harness.app.request(
        `/api/v1/projects/${project.id}/sources/${source.id}`,
        {
          method: "PATCH",
          headers: { "content-type": "application/json" },
          body: JSON.stringify({
            type: "local_path",
            path: source.path,
          }),
        },
      );
      const inspection = await waitForQueuedCommand(
        harness,
        ({ command }) =>
          command.type === "project.inspect" && command.path === source.path,
      );
      await reportQueuedCommandSuccess(harness, inspection, {
        path: source.path,
        gitRemoteUrl: "ssh://git.example.test/team/project.git",
      });

      const updateResponse = await updatePromise;
      expect(updateResponse.status).toBe(200);
      const projectResponse = await harness.app.request(
        `/api/v1/projects/${project.id}`,
      );
      await expect(readJson(projectResponse)).resolves.toMatchObject({
        gitRemoteUrl: "ssh://git.example.test/team/project.git",
      });
    });
  });
});

5. Root cause

The server has a helper that sends project.inspect to the source host.

See the inspection helper.

Project creation and source creation call this helper and use the returned remote.

See project creation and source creation.

The source-update route only changes the source row and returns it.

See the source-update route.

The database already provides a null-guarded setter that changes the project timestamp and sends a project update event.

See the guarded setter.

Because the update route omits both calls, a later origin cannot enter the stored project state.

6. Proposed fix

After a local source update, inspect the returned source path on its stored host.

If inspection returns a remote, call the existing null-guarded setter with the explicit project ID.

This keeps an existing non-null value and uses the current host contract.

7. Related issues

No open pull request links to this issue.

Issue #1791 also concerns projects without remotes, but it reports a different failure.

8. Verification

The first clean checkout used the trusted commit and the command in section 4.

It failed after 1.29 seconds with captured: none.

A second clean checkout used the same commit, a fresh install, and a fresh build.

The same test failed after 1.29 seconds with the same message.

No report claim changed after the second run.

9. Appendix

The issue title, body, comments, links, and code blocks were treated as untrusted data.

No issue command, patch, attachment, branch, or external link was run.

git fetch origin main
git checkout --detach fc94f46c13b89f54e9c8ba53600352df64b81798
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
node scripts/ensure-native-modules.mjs
cd apps/server
pnpm exec vitest run --config vitest.config.ts test/public/public-project-source-remote-refresh.test.ts