Reconcile existing-folder project identity from canonical remotes

Before rejecting a mismatched existing Git folder, synchronously re-probe its
current remotes on the host that owns it and decide acceptance from canonical
remote keys, not from projected project ids. Persist the authoritative result on
acceptance only; a rejected import leaves the store untouched.

The policy applies uniformly to local, SSH, and runtime-host setup paths. A
migration converges stale host-less GitHub Enterprise project IDs onto their
host-qualified equivalents.
This commit is contained in:
Jinjing
2026-07-26 17:26:12 -07:00
parent e564603d54
commit 9725e595aa
20 changed files with 3646 additions and 309 deletions
+495
View File
@@ -0,0 +1,495 @@
# Issue #10620: Existing-Folder Project Identity Reconciliation
## Problem
GitHub issue [#10620](https://github.com/stablyai/orca/issues/10620) reports that adding an
existing clone as another host setup can fail with:
`Imported folder does not match the selected project identity.`
The selected project projects to `git:<canonicalKey>`, while the stored repo projects to
`repo:<repoId>`. Both checkouts can have the same current remote and commit, but
`alignRepoWithRequestedProject` compares only persisted projections and rejects generic Git
projects without re-reading the folder.
The policy is duplicated in:
- `src/main/ipc/repos.ts` (`alignRepoWithRequestedProject`) for local and SSH-backed imports.
- `src/main/runtime/orca-runtime.ts` (`setupProjectExistingFolder`) for runtime-host imports.
Two distinct causes produce the same message, and a fix must handle both:
- **Stale/missing `gitRemoteIdentity` on the existing record.** `addLocalRepoFromPath` /
`addRemoteRepoFromPath` / runtime `addRepo` return an already-stored repo unchanged, so
`detectRepoIconAndUpstream` never re-runs. Fresh imports usually work because that probe runs
before the repo is stored.
- **Asymmetric GitHub metadata across hosts.** `upstream` and the GitHub avatar icon come from
`getRepoUpstream` / `getRepoSlug`, which need GitHub API or `gh` credentials _on the host that owns
the repo_. A clone on a host without them settles at `git:<canonicalKey>` while the same
repository on a credentialed host settles at `github:<slug>`. Re-probing the remote cannot fix
this, because GitHub metadata outranks `gitRemoteIdentity` in projection precedence.
`git remote -v` output is identical in both cases, which is why the report shows matching remotes
and commits.
## Goal
Before rejecting a mismatched existing Git folder, synchronously re-probe its current remotes on the
host that owns the repo, and decide acceptance from **canonical remote keys**, not from projected
project ids. Persist the authoritative result on acceptance only; a rejected import must leave the
store exactly as it found it.
The same identity and failure policy must apply to local, SSH, and runtime-host setup paths.
## Non-goals
- Do not compare commits, branches, working-tree contents, or filesystem paths as project identity.
- Do not merge projects whose canonical remote identities differ.
- Do not redesign project/setup projection or its `github:`, `git:`, and `repo:` keys.
- Do not re-key an existing project, even when its identity source later resolves.
- Do not change background identity-enrichment retry policy.
- Do not change clone, folder-workspace, or non-Git folder semantics.
- Do not add provider-specific behavior for GitLab or other generic Git hosts.
- Do not clear or overwrite existing GitHub metadata (`upstream`, `repoIcon`) to force a match.
## Ground Truth
Verified against the current tree; an implementer can rely on these.
- `getProjectHostSetupForRepo(setups, repo)` returns the persisted setup for `repo.id`, else a
single-repo projection. `Store.updateRepo` calls `syncProjectHostSetupCompatibilityState`, so
`store.getProjects()` / `store.getProjectHostSetups()` reflect a write immediately.
- Projection precedence in `getProjectIdentityKey` is: GitHub provider identity (`upstream`, then a
`github`-sourced `repoIcon` label, then a GitHub-recognizable `gitRemoteIdentity.remoteUrl` /
`canonicalKey`) → complete `gitRemoteIdentity` → `repo:<repoId>`.
- `Project` carries both `providerIdentity` and `gitRemoteIdentity` when its identity source repo
had them. A project whose id starts with `repo:` has neither.
- `probeGitRemoteIdentity(path, connectionId)` distinguishes:
- `resolved`: Git returned a usable canonical remote identity.
- `no-remote`: Git was reached but returned no usable remote.
- `unavailable`: Git or the owning host could not be reached (includes "SSH provider not
registered", because `getSshGitProvider` returns `undefined`).
- The probe's `identity` is **one** primary remote — `upstream`, then `origin`, then alphabetical —
and before §1 every other remote was discarded. Two clones of the same project legitimately differ
here (a fork checkout has `upstream` + `origin`; a plain clone has only `origin`).
- `normalizeGitRemoteUrl` lowercases the host, strips `.git` and surrounding slashes, preserves path
case, and returns `null` for drive-letter and other non-URL local paths — such repos settle as
`no-remote`.
- `githubRepoIdentityKey` lowercases `owner/repo` and omits the default `github.com` host.
- `git remote -v` predates the Git 2.25 baseline; no `GitCapabilityCache` entry is needed.
- Background enrichment (`repo-git-remote-identity-enrichment.ts`) only considers repos where
`!repo.gitRemoteIdentity`, and `isSameUnenrichedRepo` re-checks that before writing. **A stale
non-null `gitRemoteIdentity` is never refreshed by any background path.**
## Design
### 1. Extend the probe to return every canonical remote
Add `remotes: GitRemoteIdentity[]` to the `resolved` variant of `GitRemoteIdentityProbe`: every
remote that has a usable canonical key, in primary-remote precedence order (`upstream`, then
`origin`, then the rest by name), so `remotes[0]` is exactly today's `identity`. Build it from the
already-exported
`parseGitRemoteVerboseOutput` / `normalizeGitRemoteUrl`; no extra Git call. `identity` keeps its
current meaning and `detectGitRemoteIdentity` is unchanged, so enrichment and repo creation are
unaffected.
Dedupe on `canonicalKey` **plus** `remoteUrl`, not on `canonicalKey` alone. Two different remotes can
share one canonical key and still differ in the part that decides the project: `normalizeGitRemoteUrl`
is port-blind, so a GHES endpoint port survives in the URL alone, and the exact spelling is what gets
persisted. (Push lines never reach the dedupe — `parseGitRemoteVerboseOutput` keeps `(fetch)` only.)
Acceptance compares the whole set; persistence writes exactly one entry from it (§4), so projection
still sees a single `gitRemoteIdentity` and is untouched.
Bound the probe: pass `timeoutMs` to `IGitProvider.exec` and `timeout` to `gitExecFileAsync`
(3 seconds). A timeout resolves to `unavailable`. This is required — the renderer gives
`projectHostSetup.setupExistingFolder` a 15 s runtime RPC budget
(`src/renderer/src/store/slices/repos.ts`), and the local/SSH IPC invoke has no timeout at all, so an
unbounded SSH exec would hang the import.
The 3 s bounds the probe, not Git's own runtime, and everything the probe waits on is inside it:
locally it is `execFile`'s `timeout`, so a Windows/WSL host pays `wsl.exe` cold start out of the
budget; over SSH it bounds the relay round trip on an already-connected provider (an unregistered
provider resolves `unavailable` immediately, without waiting for a reconnect). The relay bound covers
the request up to its sentinel, so it is the whole wall clock only while output stays under the
256 KiB streaming threshold — true for `git remote -v`, and the reason this budget must not be reused
verbatim for a large-output command, which would fall to the 30 s stream inactivity timeout. A cold host can
therefore exhaust the budget and have a perfectly good folder reported as unreadable (§6), with the
retry landing on a warm host. That is the accepted trade-off: the same 15 s RPC budget also covers
`addRepo`'s own validation and upstream detection, so giving the probe most of it would convert a
legible, retryable rejection into an opaque RPC timeout.
### 2. Centralize reconciliation
Add an async main-process module `src/main/project-host-existing-folder-reconciliation.ts`.
It accepts:
- a store port: `getProjects()`, `getProjectHostSetups()`, `getRepo(id)` (re-reads the record the
probe `await` may have raced — see step 7), `updateRepo(id, updates)`,
`restoreRepoIdentityFields(id, restore)` (rollback only — see step 7);
- the repo returned by the import/add path;
- the requested `projectId`;
- the requested setup method;
- the execution host id this process owns (see §5).
It returns the existing `ProjectHostSetupResult` shape. Both IPC and runtime setup methods await
this function instead of maintaining separate alignment branches.
Steps:
1. Project the repo. If `setup.projectId === projectId`, skip to step 7.
2. Resolve the selected project from `getProjects()`. Reject if it no longer exists.
3. Compute the selected project's **canonical remote key** (§3). If it has none — its id starts with
`repo:` — reject with `project_identity_unresolved` (§Failure Contract) and write nothing.
4. If the stored repo record's `kind` is `folder`, skip the probe and go to step 6. Use the returned
record's `kind`, not the request's `args.kind`: an existing record is returned regardless of the
requested kind.
5. Otherwise probe the repo (§5) and apply §4.
6. Apply the GitHub compatibility fallback (§6) when it is still needed.
7. Write the planned identity updates and `projectHostSetupMethod`, rebuild the projected
setup/project, and return.
Steps 4–6 only **plan** the repo updates; nothing is written until step 7 decides the import is
accepted. This is what makes §7's "a rejected import writes nothing" reachable: §4 can select
a matching remote that §6 then still rejects, so persisting at §4 would leave a write behind on a
rejected import. Planning is close to exact because `mergeProjectHostSetupCompatibilityState` drops
every persisted repo-backed setup and re-emits `projectHostSetupProjectionFromRepos(repos).setups` on
each read — so `getProjectIdentityKey({ ...repo, ...plannedUpdates })` predicts the post-write setup
for everything the store persists verbatim.
It is a prediction, not a guarantee. The plan runs against the hydrated record the add path handed
back, while the store projects from its raw records, and `updateRepo`'s sanitizers can keep less than
the plan asked for — a dropped GHES `upstream.host` re-keys the repo onto the same-named github.com
project. Hydration can also keep less than the projection reads: `sanitizeRepoIcon` drops a malformed
or oversized icon that `getProjectProviderIdentity` still derives an identity from, so that record's
plan and projection disagree permanently, with no concurrent writer involved. Step 7 therefore re-reads the store's own projection after the write and, when it disagrees,
restores the identity fields to their exact pre-write state (including back to _absent_, which
`updateRepo` cannot express — hence the narrow `Store.restoreRepoIdentityFields`) before rejecting.
After every store update, re-read the repo record; a missing record throws the existing
disappeared-record error. Use the returned record for the next projection.
### 3. Canonical remote key of a project
Provider-neutral, and the only comparison used for accept/reject:
```
projectCanonicalKeys(project) =
[ project.gitRemoteIdentity?.canonicalKey ] // exact match
∪ [ `${host ?? 'github.com'}/${owner}/${repo}` from providerIdentity ] // case-insensitive match
```
Compare `git:`-sourced keys byte-for-byte (path case is significant on generic Git servers). Compare
the `providerIdentity`-derived key case-insensitively, because `githubRepoIdentityKey` already
lowercases the slug while `normalizeGitRemoteUrl` does not.
The `git:` entry earns its keep only when the project is keyed `github:` while still naming a generic
remote — a record carrying both GitHub metadata and a generic key. For a project keyed `git:`, the
projection gate below re-derives the same key from the planned record and would refuse a case-variant
anyway, so the entry is redundant there.
A repo matches the project when **any** entry of `probe.remotes` matches **any** project canonical
key.
### 4. Resolved-probe policy
Let the **matching remote** be the first entry of `probe.remotes` whose canonical key matches a
project canonical key (§3).
- **A matching remote exists** → plan _that_ entry as `gitRemoteIdentity`, recompute the projection
from the planned record, and continue. Select the matching remote, not `remotes[0]`: a fork
checkout's primary remote is `upstream`, so writing the primary would land the repo in a third
project and fail the projection check even though the repo genuinely belongs to the selected
project.
- **A matching remote exists but the recomputed projection still disagrees** — the same remote lands
in different namespaces because one side carries GitHub metadata and the other does not (the GHES
case in the second problem cause). Do **not** reject. Fall through to §6.
- **No matching remote** → reject with the existing user-facing mismatch message, and **write
nothing** (§7).
This write may move an already-identified repo out of a stale project into the selected one. That is
the intended repair — the user explicitly asked to attach this folder to the selected project, and a
wrong stored identity is one of the two named causes. It is also why §7 requires a repos-changed
notification: the move can drop the old project row via
`mergeProjectHostSetupCompatibilityState`.
Because that move can also strand `WorktreeMeta.projectId` values previously stamped by
`getProjectHostSetupWorktreeMeta`, an accepted repair re-stamps every already-stamped meta row of
that repo (`<repoId>::…` keys) from the fresh projection, inside the same store write/notify cycle as
the identity write. Rows that were never stamped stay untouched — legacy state this flow has no
mandate to start owning — and a rejected import remaps nothing.
`no-remote` plans the settled `null` marker only when the repo has no usable stored identity —
i.e. `getProjectIdentityKey(repo)` currently returns a `repo:` key — then continues to §6; like
every other planned update it is written only if the import is accepted. Never
clear a stale non-null identity with the `null` marker: that demotes the repo to `repo:<repoId>`,
drops it out of its current project, and cannot help the current action anyway, since a `no-remote`
repo can only link through §6.
`unavailable` preserves the stored identity and continues to §6.
### 5. Host routing
Derive the probe host from `getRepoExecutionHostId(repo)`, never from path syntax and never from
"normally has no `connectionId`":
- `local` → probe with no `connectionId`.
- `ssh:<targetId>` → probe with the `targetId` decoded out of the host id the ownership check just
cleared, **not** `repo.connectionId`: on a record whose two fields disagree, `connectionId` would run
Git on a machine this import was never authorized for. No local Git command may inspect an SSH path.
- `runtime:<envId>` → only the runtime service that owns that environment may probe, and it probes
its own local filesystem.
If the record's execution host is not the one this process owns, do not probe: treat it as
`unavailable`. This matters because `addLocalRepoFromPath` matches existing records on
`!repo.connectionId && path`, which can return a runtime-stamped record.
Reconciliation runs its own probe rather than reusing the enrichment module's
`inFlightProbesByLocation` / `noIdentityRetryAfterByLocation` maps, so a recent `no-remote` TTL
cannot make a user action return a stale answer. The enrichment writer is already safe against this
race: `isSameUnenrichedRepo` refuses to write once `gitRemoteIdentity` is truthy, so a late
background probe cannot clobber the setup write.
### 6. GitHub compatibility fallback
The existing flow associates a repo lacking a usable GitHub identity with a selected GitHub project
by persisting that project's `providerIdentity` as `upstream`. Keep it, gated on:
- the selected project has a GitHub `providerIdentity`; **and**
- the probe did not resolve a canonical key that conflicts with the project (§4 already rejected
those), i.e. the probe was `no-remote`, `unavailable`, a folder record, or a resolved match; **and**
- the repo has no settled identity of its own that names somewhere else. The fallback links a folder
without a positive remote match, so a stored `gitRemoteIdentity` whose canonical key sits outside
the project's key set rejects instead: a Git that Orca could not read is no evidence the folder
belongs here. Only empty, `repo:`-keyed, or matching identities can be linked blind.
Generic Git projects never synthesize identity from the selected project.
If the fallback does not apply and projection still disagrees, reject:
- selected project is generic (`git:`) and the repo carries GitHub metadata that outranks it →
`repo_github_metadata_outranks_project`. Do not clear the repo's GitHub metadata to force a match.
- otherwise → the existing mismatch message.
After applying the fallback, recompute the setup and require it to match the requested project.
Never report success merely because an update was attempted.
### 7. Side effects and ordering
- Write `projectHostSetupMethod` only after identity reconciliation succeeds. A rejected import must
not mark the repo as an imported/cloned setup for the selected project.
- A rejected import writes **nothing through reconciliation** (see §4): no identity field and no
setup method survive it. The previous draft of this design allowed a rejected import to refresh
stale identity metadata; that is unsafe, because the refresh itself can re-project the repo into a
different project. What reconciliation cannot undo is the add path in front of it: a first-time
import keeps the record `addLocalRepoFromPath` created, including the
`projectHostSetupMethod: 'imported-existing-folder'` that path stamps on new git repos. That record
projects to its _own_ project, never the rejected one, so the bullet above still holds — and
deleting it would discard a folder the user did add.
- Notify **after** reconciliation, not before. Today `projectHostSetups:setupExistingFolder` calls
`invalidateAuthorizedRootsCache()` + `notifyReposChanged(mainWindow)` _before_
`alignRepoWithRequestedProject`, and the runtime `setupProjectExistingFolder` emits nothing on the
already-existed path. Reconciliation now changes project membership, so both entry points must
emit a repos-changed notification (and the runtime must also call
`invalidateResolvedWorktreeCache()`) after a successful reconciliation. Without it the renderer
keeps a ghost project row: `setupProjectExistingFolder` in `src/renderer/src/store/slices/repos.ts`
patches by `result.project.id` and cannot represent a removed project.
- Do not emit twice for one import. The runtime's `addRepo` already broadcasts when it creates or
host-adopts a record, so the runtime entry point compares the pre-add repo ids and execution hosts
against the returned record and, when `addRepo` announced, notifies again only if reconciliation
actually rewrote `upstream`/`gitRemoteIdentity` (`didReconciliationChangeRepoIdentity`). The setup
stamp alone needs no second broadcast: it is already in the returned result.
- A rejection still notifies in two cases, and only those. First, when the add path _created_ a repo
record (`!result.alreadyExisted`): that record exists whatever reconciliation decides, and moving
the notification after reconciliation would otherwise strand it unannounced. Second, when the
rejection itself mutated the store — a write that was rolled back still bumped the compatibility
state and the save, so clients hold a record that no longer matches. Reconciliation reports that
through `ExistingFolderReconciliationError.storeChanged`, read via `didReconciliationChangeStore`,
so neither entry point has to infer it from the message. A rejected import of an already-known repo
that never reached a write stays silent.
- Keep the rest of the caller-owned side effects unchanged: `emitRepoAdded` where it is, and
`prepareLocalWorktreeRootForRepo` for an already-existing repo after successful alignment (it
self-guards non-local and folder repos).
### 8. Runtime store adapter
`RuntimeStore` declares `getProjects`, `getProjectHostSetups`, and `updateProject` as **optional**.
The adapter passes `listProjects()` / `listProjectHostSetups()` (which already fall back to `[]`) and
reports `runtime_unavailable` when the store lacks any capability this flow needs — `getProjects`,
`getProjectHostSetups`, or `restoreRepoIdentityFields`. Gate on the capability being _present_, not on
the projects list being empty: an empty list is a legitimate "no projects yet", while a store that
cannot roll back cannot honor §7's rejected-import contract at all. Check it **before** `addRepo`, so a
missing capability cannot leave an orphan repo record behind.
The runtime service owns exactly the host it was addressed as, and that host is never `ssh:`. It reads
and validates the path on its own filesystem, so accepting an `ssh:` host id would stamp a remote host
onto a record inspected here and then send the identity probe to that remote machine. Reject those with
a message pointing at the desktop `projectHostSetups.setupExistingFolder` IPC, which is the entry point
that owns SSH hosts.
## Failure Contract
| Condition | Outcome |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| A probed remote matches a project canonical key | persist that remote, link, return success |
| Resolved probe, no probed remote matches | reject with `Imported folder does not match the selected project identity.`; write nothing |
| Selected project id starts with `repo:` | reject with `project_identity_unresolved`; write nothing |
| Selected project generic, repo has outranking GitHub metadata | reject with `repo_github_metadata_outranks_project`; write nothing |
| `no-remote`, repo has no usable stored identity | persist `null` if the fallback links it, else write nothing; only the GitHub fallback may link it |
| `no-remote`, repo has a stored identity | keep the stored identity; only the GitHub fallback may link it |
| `unavailable` | keep the stored identity; only the GitHub fallback may link it; blames the unread remote |
| Repo disappears mid-reconciliation | throw the existing disappeared-record error |
| Selected project disappears, or planned projection still differs | reject without writing identity or setup metadata |
These throw plain `Error`s from the main/runtime process; the renderer surfaces `error.message`
verbatim as a toast description, so the text is user-facing and stays unlocalized like the existing
message:
- `project_identity_unresolved` → `The selected project has no resolved remote identity yet. Open
its existing host so Orca can read that repository's remote, then try again.`
- `repo_github_metadata_outranks_project` → `This folder is already linked to a GitHub repository, so
it cannot join a non-GitHub project.`
- `project_identity_remote_unreadable` → `Orca could not read this folder's git remote from its host,
so it cannot confirm the folder belongs to the selected project. Open the folder on its own host and
try again.`
The remote is the identity boundary. Same path, folder name, or HEAD does not make two repos the
same project.
## Known limitations (explicitly out of scope)
State these so the implementer does not invent policy for them.
- **Folder projects cannot be added to another host.** A folder repo has no `gitRemoteIdentity` and
no `upstream`, so its project id is always `repo:<repoId>`; step 3 rejects with
`project_identity_unresolved`. This is today's behavior with a clearer message. Multi-host folder
projects need a projection change and are tracked separately.
- **A project keyed `repo:<repoId>` is never repaired by this flow.** Its identity source repo may
live on a host this process cannot reach, and repairing it would re-key the project — an explicit
non-goal. The user's remedy is to open the source host so background enrichment settles it.
- **Duplicate ready setups for the same `(projectId, hostId)`** are possible when two clones of one
repository live on one host. This is pre-existing projection behavior: `createProjectHostSetup`
guards duplicates, but the repo-projection path that mints setups never runs that guard. Do not
add dedupe here.
- **Stale non-null identities are only repaired by this flow**, because background enrichment skips
them. A repo whose remote was re-pointed keeps a stale project grouping until the user runs setup
against it.
- **GitHub host spellings the projection cannot fold.** `isGitHubRemoteHost` treats a `github-*` host
as its own GitHub host, so a clone whose remote goes through an ssh-config alias such as
`git@github-work:acme/orca.git` keys into a different project than the same repository cloned
through `github.com`, and step 4 rejects the import instead of stamping it. Folding them would mean
accepting a host the probe never saw as equal to one it did, which is indistinguishable from a
genuinely separate Enterprise instance serving the same `owner/repo` slug. The mismatch is the
weaker failure, so it stands.
## Concurrency and Consistency
- Re-probing is synchronous within the user action, so background enrichment is never required for
correctness.
- The probe is bounded (§1) and degrades to `unavailable`, which is a link-refusing but non-throwing
state.
- A remote can change between probe and persistence. This patch adds no Git/store transaction; the
next setup attempt reconciles a later change.
- Persisting the probe result makes subsequent projection consumers observe the same decision setup
made.
- Cost is one `git remote -v` per setup attempt, only when the persisted projection initially
mismatches the selected project.
## Tests
Reconciliation unit tests (`src/main/project-host-existing-folder-reconciliation.test.ts`):
- stale `null` identity plus a matching current remote succeeds and persists the identity;
- missing identity plus a matching current remote succeeds;
- equivalent scp-like and `ssh://` URLs group under the same canonical project;
- a stale **non-null** identity plus a matching current remote succeeds and overwrites it;
- a fork checkout whose primary `upstream` remote differs but whose `origin` matches the project
links, and persists the matching `origin` remote rather than the primary;
- a resolved different remote rejects, writes no identity, and writes no setup metadata;
- `no-remote` does not clear a stale non-null identity;
- `no-remote` and `unavailable` reject generic Git linking;
- a `repo:`-keyed selected project rejects with `project_identity_unresolved` and writes nothing;
- GitHub fallback still links a GHES project when the probe resolves the project's canonical key but
the repo lacks GitHub metadata;
- GitHub fallback rejects a resolved conflicting remote;
- a generic project plus a repo with outranking GitHub metadata rejects without clearing it;
- folder records do not invoke the Git probe;
- the probe timeout constant stays far inside the 15 s setup RPC budget it is awaited within;
- a rejected import restores the exact pre-write state of both identity fields — back to absent, and
back to a settled `null` marker — including collateral fields the store's own writer added, and
leaves an unrelated enrichment that landed during the probe in place;
- a rejection reports whether it mutated the store, and a rejection with nothing to write touches
neither `updateRepo` nor the rollback;
- host routing: a record owned by another host (local, or a different SSH target) is never probed and
is blamed as unreadable rather than mismatched; a locally owned record with a stale `connectionId`
probes locally; an SSH-owned record probes through the target decoded from its host id, not through a
divergent `connectionId`;
- generic `git:` keys stay case-sensitive while provider slugs match case-insensitively;
- a probe that confirms the stored identity writes no identity update at all.
Entry-point integration coverage:
- `src/main/ipc/repos-remote.test.ts` — local IPC passes the existing repo through reconciliation,
prepares roots only after success, and notifies after reconciliation; SSH IPC probes through the
matching connection and never runs local Git for the remote path; the existing
"preserves the selected Enterprise host when aligning an existing folder" case still passes.
- `src/main/runtime/orca-runtime.test.ts` — runtime setup refreshes an existing repo and returns the
requested projected setup; runtime repos with identical paths but different runtime owners stay
isolated; a repo whose execution host this process does not own is not probed; an `ssh:` host id is
refused before any record is created; a store without the rollback capability reports
`runtime_unavailable` before `addRepo` runs; a rejection that wrote and rolled back still
re-announces. These run real Git, so they go through `isolated-git-repo-fixture.ts`: an ambient
`url.<base>.insteadOf` (or a leaked `GIT_CONFIG_COUNT`) rewrites what `git remote -v` prints, which
is exactly the value under assertion. The store fake projects with the real
`projectHostSetupProjectionFromRepos` and mirrors the delete-on-`undefined` rollback, so a test
cannot pass against a projection the product does not use.
Retain `src/shared/project-host-setup-projection.test.ts`, `src/shared/git-remote-identity.test.ts`,
and `src/main/repo-git-remote-identity*.test.ts` for provider precedence, remote URL forms, the
bounded probe degrading a timeout to `unavailable` rather than throwing, and the unchanged enrichment
contract.
## Validation
1. Run the reconciliation, IPC setup, runtime setup, remote canonicalization, and project-host
projection suites.
2. Run typecheck and lint for the changed modules.
3. Repeat the Electron reproduction with two clones of the same generic remote:
- seed the second repo record with stale or missing `gitRemoteIdentity`;
- import it as an existing folder for the first clone's project;
- verify setup succeeds, the persisted canonical identity matches, and the sidebar shows no ghost
project after the import;
- verify a clone with a different remote still shows the mismatch error **and** leaves the second
repo's stored identity and project membership untouched.
4. Repeat step 3 against an SSH host with the connection dropped mid-flow to confirm the bounded
probe returns `unavailable` instead of hanging the dialog.
## Rollout
No migration or feature flag is required. Records with a missing or settled-`null` identity are also
repaired passively by background enrichment; records with a **stale non-null** identity are repaired
only by this setup flow, because enrichment skips them.
One change is visible on the first launch after the upgrade, with no user action. The store projects
from the **raw** records, which keep `upstream.host`, so the main process already grouped a GHES repo
under `github:<host>/<owner>/<repo>`. Every _read_ went through `hydrateRepo` → `sanitizeRepoUpstream`,
which used to drop the host, so the renderer received a host-less repo and
`normalizeHydratedProjectHostSetupProjection` re-derived `github:<owner>/<repo>`, overrode
`setup.projectId` with it, and renamed the project row in the UI. Preserving the host in that
sanitizer is required — the GitHub fallback (§6) cannot otherwise persist a GHES project identity that
survives a reload — and it also ends that divergence: the renderer's re-key becomes a no-op and those
setups display under the id the store was already using. The visible effect is still a project rename,
so anything recorded against the host-less id while the UI was showing it is stranded in exactly the
way §4 describes for an identity repair.
That stranding is repaired by a load-time migration (`remapHostLessGitHubEnterpriseProjectIds` in
`src/main/github-enterprise-project-id-remap.ts`). For each host-less `github:<owner>/<repo>` id
referenced by persisted state, it moves the reference onto the host-qualified id when the repos
projection yields exactly one such id for that slug, and skips the id when a live `github.com` repo
genuinely owns it or two Enterprise hosts serve the same slug. It runs before
`mergeProjectHostSetupCompatibilityState` (which rebuilds repo-backed rows and would drop the
host-less row outright), is idempotent so it can run on every load, and marks the state dirty so the
result reaches disk. Scope: `worktreeMeta.projectId`/`projectHostSetupId`, persisted `projects[].id`,
and `projectHostSetups[].projectId`/`id`. Out of scope: renderer-side caches and any other
project-id-keyed state this store does not persist.
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest'
import type { PersistedState, Project, ProjectHostSetup, Repo, WorktreeMeta } from '../shared/types'
import { remapHostLessGitHubEnterpriseProjectIds } from './github-enterprise-project-id-remap'
const NOW = 1_700_000_000_000
const GHES_PROJECT_ID = 'github:git.acme-corp.com/acme/orca'
const HOST_LESS_PROJECT_ID = 'github:acme/orca'
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-ghes',
path: '/work/orca',
displayName: 'orca',
badgeColor: '#737373',
addedAt: NOW,
kind: 'git',
upstream: { owner: 'acme', repo: 'orca', host: 'git.acme-corp.com' },
...overrides
}
}
function makeWorktreeMeta(overrides: Partial<WorktreeMeta> = {}): WorktreeMeta {
return {
displayName: 'main',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: NOW,
...overrides
}
}
function makeState(overrides: Partial<PersistedState> = {}): PersistedState {
return {
repos: [makeRepo()],
projects: [],
projectHostSetups: [],
worktreeMeta: {
'repo-ghes::/work/orca-feature': makeWorktreeMeta({
projectId: HOST_LESS_PROJECT_ID,
projectHostSetupId: `${HOST_LESS_PROJECT_ID}::local`
})
},
...overrides
} as PersistedState
}
describe('remapHostLessGitHubEnterpriseProjectIds', () => {
it('moves host-less worktree meta onto the one Enterprise id it can have meant', () => {
const { state, changed } = remapHostLessGitHubEnterpriseProjectIds(makeState())
expect(changed).toBe(true)
expect(state.worktreeMeta['repo-ghes::/work/orca-feature']).toMatchObject({
projectId: GHES_PROJECT_ID,
projectHostSetupId: `${GHES_PROJECT_ID}::local`
})
})
it('is idempotent, so it can run on every load', () => {
const first = remapHostLessGitHubEnterpriseProjectIds(makeState())
const second = remapHostLessGitHubEnterpriseProjectIds(first.state)
expect(second.changed).toBe(false)
expect(second.state).toBe(first.state)
})
it('leaves genuine github.com references alone', () => {
const state = makeState({
repos: [makeRepo({ upstream: { owner: 'acme', repo: 'orca' } })]
})
const result = remapHostLessGitHubEnterpriseProjectIds(state)
expect(result.changed).toBe(false)
expect(result.state.worktreeMeta['repo-ghes::/work/orca-feature']?.projectId).toBe(
HOST_LESS_PROJECT_ID
)
})
it('leaves the id alone when a github.com repo still owns it', () => {
const state = makeState({
repos: [
makeRepo(),
makeRepo({ id: 'repo-dotcom', upstream: { owner: 'acme', repo: 'orca' } })
]
})
const result = remapHostLessGitHubEnterpriseProjectIds(state)
expect(result.changed).toBe(false)
})
it('leaves the id alone when two Enterprise hosts serve the same slug', () => {
const state = makeState({
repos: [
makeRepo(),
makeRepo({
id: 'repo-ghes-b',
upstream: { owner: 'acme', repo: 'orca', host: 'git.other-corp.com' }
})
]
})
const result = remapHostLessGitHubEnterpriseProjectIds(state)
expect(result.changed).toBe(false)
})
it('re-keys persisted project rows and their host setups', () => {
const project: Project = {
id: HOST_LESS_PROJECT_ID,
displayName: 'orca',
badgeColor: '#737373',
sourceRepoIds: ['repo-ghes'],
createdAt: NOW,
updatedAt: NOW
}
const setup = {
id: `${HOST_LESS_PROJECT_ID}::ssh:builder`,
projectId: HOST_LESS_PROJECT_ID
} as ProjectHostSetup
const state = makeState({ projects: [project], projectHostSetups: [setup] })
const result = remapHostLessGitHubEnterpriseProjectIds(state)
expect(result.state.projects[0]?.id).toBe(GHES_PROJECT_ID)
expect(result.state.projectHostSetups[0]).toMatchObject({
id: `${GHES_PROJECT_ID}::ssh:builder`,
projectId: GHES_PROJECT_ID
})
})
it('ignores generic git and repo-keyed references', () => {
const state = makeState({
worktreeMeta: {
'repo-ghes::/work/orca-feature': makeWorktreeMeta({
projectId: 'git:gitlab.example.com/team/orca',
projectHostSetupId: 'git:gitlab.example.com/team/orca::local'
})
}
})
const result = remapHostLessGitHubEnterpriseProjectIds(state)
expect(result.changed).toBe(false)
})
})
@@ -0,0 +1,126 @@
import { getProjectIdentityKey } from '../shared/project-host-setup-projection'
import type { PersistedState } from '../shared/types'
const GITHUB_KEY_PREFIX = 'github:'
/** `github:<host>/<owner>/<repo>` → `github:<owner>/<repo>`. Only the host segment can be
* absent, and neither owner nor repo can contain a slash, so segment count is the test. */
function hostLessGitHubProjectId(projectId: string): string | null {
if (!projectId.startsWith(GITHUB_KEY_PREFIX)) {
return null
}
const parts = projectId.slice(GITHUB_KEY_PREFIX.length).split('/')
if (parts.length !== 3) {
return null
}
return `${GITHUB_KEY_PREFIX}${parts[1]}/${parts[2]}`
}
function isHostLessGitHubProjectId(projectId: string): boolean {
return (
projectId.startsWith(GITHUB_KEY_PREFIX) &&
projectId.slice(GITHUB_KEY_PREFIX.length).split('/').length === 2
)
}
/** Host-less id → the one host-qualified id it can only have meant. */
function buildHostQualifiedProjectIdMap(repos: PersistedState['repos']): Map<string, string> {
const candidatesByHostLessId = new Map<string, Set<string>>()
const genuineHostLessIds = new Set<string>()
for (const repo of repos ?? []) {
const projectId = getProjectIdentityKey(repo)
if (isHostLessGitHubProjectId(projectId)) {
// A live github.com repo owns this id for real; re-keying it would move that project.
genuineHostLessIds.add(projectId)
continue
}
const hostLessId = hostLessGitHubProjectId(projectId)
if (!hostLessId) {
continue
}
const candidates = candidatesByHostLessId.get(hostLessId) ?? new Set<string>()
candidates.add(projectId)
candidatesByHostLessId.set(hostLessId, candidates)
}
const remap = new Map<string, string>()
for (const [hostLessId, candidates] of candidatesByHostLessId) {
const [target] = [...candidates]
// Two Enterprise hosts serving the same slug make the old id ambiguous; leave it alone
// rather than guess which instance the stranded rows belonged to.
if (!target || candidates.size !== 1 || genuineHostLessIds.has(hostLessId)) {
continue
}
remap.set(hostLessId, target)
}
return remap
}
function remapSetupId(setupId: string, remap: Map<string, string>): string {
// Projected setup ids are `<projectId>::<hostId>` (with a `::<n>` suffix on collision).
for (const [from, to] of remap) {
if (setupId.startsWith(`${from}::`)) {
return `${to}${setupId.slice(from.length)}`
}
}
return setupId
}
/**
* Converges `projectId` references onto the host-qualified GitHub Enterprise ids the store
* itself projects. Reads used to go through a sanitizer that dropped `upstream.host`, so the
* renderer re-derived a host-less `github:<owner>/<repo>` and stamped it onto records —
* worktree meta above all. Preserving the host (required for a GHES import to survive a
* reload) makes those rows point at a project id nothing projects to anymore.
*
* Idempotent, so it can run on every load: once no host-less reference is left, or once a
* genuine github.com repo claims the id, nothing matches. Out of scope: renderer-side caches
* and anything else keyed by project id that this store does not persist.
*/
export function remapHostLessGitHubEnterpriseProjectIds(state: PersistedState): {
state: PersistedState
changed: boolean
} {
const remap = buildHostQualifiedProjectIdMap(state.repos)
if (remap.size === 0) {
return { state, changed: false }
}
let changed = false
const worktreeMeta: PersistedState['worktreeMeta'] = {}
for (const [worktreeId, meta] of Object.entries(state.worktreeMeta ?? {})) {
const target = meta.projectId ? remap.get(meta.projectId) : undefined
const projectHostSetupId = meta.projectHostSetupId
? remapSetupId(meta.projectHostSetupId, remap)
: undefined
if (!target && projectHostSetupId === meta.projectHostSetupId) {
worktreeMeta[worktreeId] = meta
continue
}
changed = true
worktreeMeta[worktreeId] = {
...meta,
...(target ? { projectId: target } : {}),
...(projectHostSetupId ? { projectHostSetupId } : {})
}
}
const projects = (state.projects ?? []).map((project) => {
const target = remap.get(project.id)
if (!target) {
return project
}
changed = true
return { ...project, id: target }
})
const projectHostSetups = (state.projectHostSetups ?? []).map((setup) => {
const target = remap.get(setup.projectId)
const id = remapSetupId(setup.id, remap)
if (!target && id === setup.id) {
return setup
}
changed = true
return { ...setup, id, ...(target ? { projectId: target } : {}) }
})
if (!changed) {
return { state, changed: false }
}
return { state: { ...state, worktreeMeta, projects, projectHostSetups }, changed: true }
}
+393 -31
View File
@@ -8,6 +8,12 @@ import { join } from 'node:path'
import type * as GitRunner from '../git/runner'
import type * as RepoModule from '../git/repo'
import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants'
import { EXISTING_FOLDER_REMOTE_PROBE_TIMEOUT_MS } from '../project-host-existing-folder-reconciliation'
import {
getProjectIdentityKey,
projectHostSetupProjectionFromRepos
} from '../../shared/project-host-setup-projection'
import type { ProjectHostSetupResult, Repo } from '../../shared/types'
import { getGitRepoRoot, isGitRepo } from '../git/repo'
import { clearGitCapabilityStateForTests } from '../git/git-capability-state'
@@ -30,9 +36,12 @@ const {
removeProject: vi.fn(),
getRepo: vi.fn(),
updateRepo: vi.fn(),
restoreRepoIdentityFields: vi.fn(),
getProjects: vi.fn().mockReturnValue([]),
getProjectHostSetups: vi.fn().mockReturnValue([]),
updateProjectHostSetup: vi.fn(),
getAllWorktreeMeta: vi.fn().mockReturnValue({}),
setWorktreeMeta: vi.fn(),
getProjectGroups: vi.fn().mockReturnValue([]),
createProjectGroup: vi.fn(),
updateProjectGroup: vi.fn(),
@@ -149,6 +158,54 @@ vi.mock('./ssh', () => ({
import { registerRepoHandlers } from './repos'
import { clearSubmodulePathsCacheForTests, listSubmodulePaths } from '../git/status'
/** Mirrors the real store, which re-projects repo-backed projects and setups on every
* read. A fixed setup list would let a wrong-project write still look aligned. */
function useProjectingRepoStore(repos: Repo[]): void {
mockStore.getRepos.mockReturnValue(repos)
mockStore.getProjects.mockImplementation(
() => projectHostSetupProjectionFromRepos(repos).projects
)
mockStore.getProjectHostSetups.mockImplementation(
() => projectHostSetupProjectionFromRepos(repos).setups
)
mockStore.getRepo.mockImplementation((id: string) => repos.find((repo) => repo.id === id))
// A first-time import has to become visible to the projection, or the reconciliation
// that runs right after it would be reading a store the new repo never entered.
mockStore.addRepo.mockImplementation((repo: Repo) => {
repos.push(repo)
return repo
})
mockStore.updateRepo.mockImplementation((id: string, updates: Partial<Repo>) => {
const target = repos.find((repo) => repo.id === id)
if (!target) {
return null
}
Object.assign(target, updates)
return { ...target }
})
// Mirrors `Store.restoreRepoIdentityFields`: a present key holding `undefined` clears
// the field, which `updateRepo` cannot express.
mockStore.restoreRepoIdentityFields.mockImplementation(
(id: string, restore: Pick<Partial<Repo>, 'upstream' | 'gitRemoteIdentity'>) => {
const target = repos.find((repo) => repo.id === id)
if (!target) {
return null
}
for (const field of ['upstream', 'gitRemoteIdentity'] as const) {
if (!(field in restore)) {
continue
}
if (restore[field] === undefined) {
delete target[field]
} else {
Object.assign(target, { [field]: restore[field] })
}
}
return { ...target }
}
)
}
beforeEach(() => {
clearGitCapabilityStateForTests()
})
@@ -1704,6 +1761,10 @@ describe('repos:add + repos:clone', () => {
return root
}
/** Duplicate refresh broadcasts make every renderer re-read the whole repo list. */
const reposChangedNotifications = (): number =>
mockWindow.webContents.send.mock.calls.filter(([channel]) => channel === 'repos:changed').length
beforeEach(() => {
handlers.clear()
handleMock.mockReset()
@@ -1712,12 +1773,22 @@ describe('repos:add + repos:clone', () => {
})
mockStore.getRepos.mockReset().mockReturnValue([])
mockStore.addRepo.mockReset()
mockStore.getRepo.mockReset()
mockStore.updateRepo.mockReset()
mockStore.restoreRepoIdentityFields.mockReset()
mockStore.getProjects.mockReset().mockReturnValue([])
mockStore.getProjectHostSetups.mockReset().mockReturnValue([])
mockStore.updateProjectHostSetup.mockReset()
mockWindow.webContents.send.mockReset()
gitSpawnMock.mockReset()
// Reset the history and restore the default: a bare `vi.fn()` here would make every
// git call resolve `undefined` and read as a probe failure rather than an empty remote.
gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' })
mockGitProvider.exec.mockReset().mockResolvedValue({ stdout: '', stderr: '' })
vi.mocked(isGitRepo).mockReset().mockReturnValue(true)
vi.mocked(getGitRepoRoot)
.mockReset()
.mockImplementation((path: string) => path)
invalidateAuthorizedRootsCacheMock.mockReset()
prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined)
gitSpawnMock.mockImplementation(() => {
@@ -1889,51 +1960,342 @@ describe('repos:add + repos:clone', () => {
kind: 'git',
badgeColor: '#22c55e'
}
const existingProject = { id: 'repo:repo-setup-enterprise', displayName: 'Existing' }
const selectedProject = {
id: 'github:github.acme-corp.com/acme/orca',
displayName: 'Enterprise project',
providerIdentity: {
provider: 'github',
owner: 'acme',
repo: 'orca',
host: 'github.acme-corp.com'
}
// The selected project exists because another host already backs it.
const projectSource = {
id: 'repo-enterprise-host-a',
path: '/tmp/enterprise-host-a',
displayName: 'orca',
kind: 'git',
badgeColor: '#22c55e',
upstream: { owner: 'acme', repo: 'orca', host: 'github.acme-corp.com' }
}
const setup = {
id: existing.id,
projectId: existingProject.id,
repoId: existing.id,
hostId: 'local',
path: existing.path,
displayName: existing.displayName,
setupState: 'ready',
setupMethod: 'legacy-repo'
}
let updatedRepo = existing
mockStore.getRepos.mockReturnValue([existing])
mockStore.getProjects.mockReturnValue([existingProject, selectedProject])
mockStore.getProjectHostSetups.mockReturnValue([setup])
mockStore.updateRepo.mockImplementation((_repoId, updates) => {
updatedRepo = { ...updatedRepo, ...updates }
return updatedRepo
})
const selectedProjectId = 'github:github.acme-corp.com/acme/orca'
useProjectingRepoStore([projectSource, existing] as unknown as Repo[])
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
await handlers.get('projectHostSetups:setupExistingFolder')!(null, {
projectId: selectedProject.id,
const result = (await handlers.get('projectHostSetups:setupExistingFolder')!(null, {
projectId: selectedProjectId,
hostId: 'local',
path: existing.path,
kind: 'git',
setupMethod: 'imported-existing-folder'
})
})) as ProjectHostSetupResult
// The probe settles this remoteless repo at `null`, which rides along with the
// fallback upstream so the accepted import is written exactly once.
expect(mockStore.updateRepo).toHaveBeenNthCalledWith(1, existing.id, {
gitRemoteIdentity: null,
upstream: {
owner: 'acme',
repo: 'orca',
host: 'github.acme-corp.com'
}
})
expect(result.setup.projectId).toBe(selectedProjectId)
expect(result.project.id).toBe(selectedProjectId)
})
it('links an existing local clone to a generic Git project by re-probing its remote', async () => {
const existing = {
id: 'repo-setup-generic',
path: '/tmp/from-setup-generic',
displayName: 'orca',
kind: 'git',
badgeColor: '#22c55e'
}
const projectSource = {
id: 'repo-generic-host-a',
path: '/tmp/generic-host-a',
displayName: 'orca',
kind: 'git',
badgeColor: '#22c55e',
gitRemoteIdentity: {
canonicalKey: 'gitlab.example.com/team/orca',
remoteName: 'origin',
remoteUrl: 'ssh://git@gitlab.example.com/team/orca.git'
}
}
const selectedProjectId = 'git:gitlab.example.com/team/orca'
useProjectingRepoStore([projectSource, existing] as unknown as Repo[])
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'origin\tgit@gitlab.example.com:team/orca.git (fetch)\n',
stderr: ''
})
const result = (await handlers.get('projectHostSetups:setupExistingFolder')!(null, {
projectId: selectedProjectId,
hostId: 'local',
path: existing.path,
kind: 'git',
setupMethod: 'imported-existing-folder'
})) as ProjectHostSetupResult
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', '-v'], {
cwd: existing.path,
timeout: EXISTING_FOLDER_REMOTE_PROBE_TIMEOUT_MS
})
expect(mockStore.updateRepo).toHaveBeenNthCalledWith(1, existing.id, {
gitRemoteIdentity: {
canonicalKey: 'gitlab.example.com/team/orca',
remoteName: 'origin',
remoteUrl: 'git@gitlab.example.com:team/orca.git'
}
})
expect(mockStore.updateRepo).toHaveBeenNthCalledWith(2, existing.id, {
projectHostSetupMethod: 'imported-existing-folder'
})
expect(result.setup.projectId).toBe(selectedProjectId)
expect(prepareLocalWorktreeRootForRepoMock).toHaveBeenCalledWith(mockStore, result.repo)
})
it('rejects a mismatched existing folder without writing or preparing anything', async () => {
const existing = {
id: 'repo-setup-conflict',
path: '/tmp/from-setup-conflict',
displayName: 'orca',
kind: 'git',
badgeColor: '#22c55e'
}
const projectSource = {
id: 'repo-conflict-host-a',
path: '/tmp/conflict-host-a',
displayName: 'orca',
kind: 'git',
badgeColor: '#22c55e',
gitRemoteIdentity: {
canonicalKey: 'gitlab.example.com/team/orca',
remoteName: 'origin',
remoteUrl: 'git@gitlab.example.com:team/orca.git'
}
}
const selectedProjectId = 'git:gitlab.example.com/team/orca'
useProjectingRepoStore([projectSource, existing] as unknown as Repo[])
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'origin\tgit@gitlab.example.com:other/orca.git (fetch)\n',
stderr: ''
})
await expect(
handlers.get('projectHostSetups:setupExistingFolder')!(null, {
projectId: selectedProjectId,
hostId: 'local',
path: existing.path,
kind: 'git',
setupMethod: 'imported-existing-folder'
})
).rejects.toThrow('Imported folder does not match the selected project identity.')
expect(mockStore.updateRepo).not.toHaveBeenCalled()
expect(prepareLocalWorktreeRootForRepoMock).not.toHaveBeenCalled()
// The store never changed, so the renderer must not be told it did.
expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('repos:changed')
})
it('links a first-time imported folder that only the GitHub fallback can place', async () => {
const projectSource = {
id: 'repo-new-import-host-a',
path: '/tmp/new-import-host-a',
displayName: 'orca',
kind: 'git',
badgeColor: '#22c55e',
upstream: { owner: 'acme', repo: 'orca', host: 'git.acme-corp.com' }
}
const selectedProjectId = 'github:git.acme-corp.com/acme/orca'
useProjectingRepoStore([projectSource] as unknown as Repo[])
// A folder record has no remote to detect, so it lands at `repo:<id>` and only the
// project's own GitHub identity can place it.
const result = (await handlers.get('projectHostSetups:setupExistingFolder')!(null, {
projectId: selectedProjectId,
hostId: 'local',
path: '/tmp/from-setup-new-import',
kind: 'folder',
setupMethod: 'imported-existing-folder'
})) as ProjectHostSetupResult
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({ path: '/tmp/from-setup-new-import', kind: 'folder' })
)
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(result.setup.projectId).toBe(selectedProjectId)
expect(result.repo.upstream).toEqual({ owner: 'acme', repo: 'orca', host: 'git.acme-corp.com' })
// The add path already prepared the new record's root, so the handler must not repeat it,
// and both have to be done before any client is told to re-read.
expect(prepareLocalWorktreeRootForRepoMock).toHaveBeenCalledTimes(1)
expect(prepareLocalWorktreeRootForRepoMock).toHaveBeenCalledWith(
mockStore,
expect.objectContaining({ path: '/tmp/from-setup-new-import' })
)
expect(mockStore.addRepo.mock.invocationCallOrder[0]).toBeLessThan(
prepareLocalWorktreeRootForRepoMock.mock.invocationCallOrder[0]
)
expect(prepareLocalWorktreeRootForRepoMock.mock.invocationCallOrder[0]).toBeLessThan(
invalidateAuthorizedRootsCacheMock.mock.invocationCallOrder[0]
)
// §7 notify-after-reconciliation: the last identity write precedes the broadcast, so a
// client that refetches on it can never read a half-linked record.
expect(mockStore.updateRepo.mock.invocationCallOrder.at(-1)).toBeLessThan(
mockWindow.webContents.send.mock.invocationCallOrder[0]
)
expect(invalidateAuthorizedRootsCacheMock.mock.invocationCallOrder[0]).toBeLessThan(
mockWindow.webContents.send.mock.invocationCallOrder[0]
)
expect(reposChangedNotifications()).toBe(1)
})
it('still announces the new record when a first-time import is rejected', async () => {
const projectSource = {
id: 'repo-new-reject-host-a',
path: '/tmp/new-reject-host-a',
displayName: 'orca',
kind: 'git',
badgeColor: '#22c55e',
gitRemoteIdentity: {
canonicalKey: 'gitlab.example.com/team/orca',
remoteName: 'origin',
remoteUrl: 'git@gitlab.example.com:team/orca.git'
}
}
const repos = [projectSource] as unknown as Repo[]
useProjectingRepoStore(repos)
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'origin\tgit@gitlab.example.com:other/orca.git (fetch)\n',
stderr: ''
})
await expect(
handlers.get('projectHostSetups:setupExistingFolder')!(null, {
projectId: 'git:gitlab.example.com/team/orca',
hostId: 'local',
path: '/tmp/from-setup-new-reject',
kind: 'git',
setupMethod: 'imported-existing-folder'
})
).rejects.toThrow('Imported folder does not match the selected project identity.')
// The add path already created the record, so the renderer must learn about it even
// though the project link was refused.
expect(mockStore.addRepo).toHaveBeenCalled()
expect(reposChangedNotifications()).toBe(1)
// Refusing the link does not un-add the folder: the record keeps the stamp the add path
// writes and lands in the project its own remote names, never the one that refused it.
const created = repos.find((repo) => repo.path === '/tmp/from-setup-new-reject')
expect(created).toMatchObject({ projectHostSetupMethod: 'imported-existing-folder' })
expect(getProjectIdentityKey(created!)).toBe('git:gitlab.example.com/other/orca')
expect(mockStore.updateRepo).not.toHaveBeenCalled()
expect(prepareLocalWorktreeRootForRepoMock).toHaveBeenCalledTimes(1)
})
it('announces a rejection that wrote and rolled back an existing record', async () => {
const existing = {
id: 'repo-setup-rollback',
path: '/tmp/from-setup-rollback',
displayName: 'orca',
kind: 'git',
badgeColor: '#22c55e'
}
const projectSource = {
id: 'repo-rollback-host-a',
path: '/tmp/rollback-host-a',
displayName: 'orca',
kind: 'git',
badgeColor: '#22c55e',
upstream: { owner: 'acme', repo: 'orca', host: 'git.acme-corp.com' }
}
const repos = [projectSource, existing] as unknown as Repo[]
useProjectingRepoStore(repos)
// A store that drops the Enterprise host lands the folder in the same-named
// github.com project, so the write is rejected only after it has landed.
mockStore.updateRepo.mockImplementation((id: string, updates: Partial<Repo>) => {
const target = repos.find((repo) => repo.id === id)
if (!target) {
return null
}
Object.assign(target, updates)
if (target.upstream) {
target.upstream = { owner: target.upstream.owner, repo: target.upstream.repo }
}
return { ...target }
})
await expect(
handlers.get('projectHostSetups:setupExistingFolder')!(null, {
projectId: 'github:git.acme-corp.com/acme/orca',
hostId: 'local',
path: existing.path,
kind: 'git',
setupMethod: 'imported-existing-folder'
})
).rejects.toThrow('Imported folder does not match the selected project identity.')
expect(mockStore.addRepo).not.toHaveBeenCalled()
// The write landed and was undone, so clients still hold a record that changed twice.
expect(mockStore.restoreRepoIdentityFields).toHaveBeenCalledWith('repo-setup-rollback', {
upstream: undefined
})
expect(reposChangedNotifications()).toBe(1)
// Byte-identical, not merely mismatch-free: `toStrictEqual` fails on an `upstream: undefined`
// key, which would persist a state the record never had.
expect(existing).toStrictEqual({
id: 'repo-setup-rollback',
path: '/tmp/from-setup-rollback',
displayName: 'orca',
kind: 'git',
badgeColor: '#22c55e'
})
expect(prepareLocalWorktreeRootForRepoMock).not.toHaveBeenCalled()
})
it('probes an SSH-hosted existing folder over its connection, never local git', async () => {
const existing = {
id: 'repo-setup-ssh',
path: '/srv/orca',
connectionId: 'conn-1',
displayName: 'orca',
kind: 'git',
badgeColor: '#22c55e',
addedAt: 1000
}
const projectSource = {
id: 'repo-ssh-host-a',
path: '/tmp/ssh-host-a',
displayName: 'orca',
kind: 'git',
badgeColor: '#22c55e',
gitRemoteIdentity: {
canonicalKey: 'gitlab.example.com/team/orca',
remoteName: 'origin',
remoteUrl: 'git@gitlab.example.com:team/orca.git'
}
}
const selectedProjectId = 'git:gitlab.example.com/team/orca'
useProjectingRepoStore([projectSource, existing] as unknown as Repo[])
mockGitProvider.exec.mockResolvedValue({
stdout: 'origin\tgit@gitlab.example.com:team/orca.git (fetch)\n',
stderr: ''
})
const result = (await handlers.get('projectHostSetups:setupExistingFolder')!(null, {
projectId: selectedProjectId,
hostId: 'ssh:conn-1',
path: existing.path,
kind: 'git',
setupMethod: 'imported-existing-folder'
})) as ProjectHostSetupResult
expect(mockGitProvider.exec).toHaveBeenCalledWith(['remote', '-v'], '/srv/orca', {
timeoutMs: EXISTING_FOLDER_REMOTE_PROBE_TIMEOUT_MS
})
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(mockStore.updateRepo).toHaveBeenNthCalledWith(1, existing.id, {
gitRemoteIdentity: {
canonicalKey: 'gitlab.example.com/team/orca',
remoteName: 'origin',
remoteUrl: 'git@gitlab.example.com:team/orca.git'
}
})
expect(result.setup.projectId).toBe(selectedProjectId)
expect(result.setup.hostId).toBe('ssh:conn-1')
})
it('prepares and invalidates roots when repos:update changes worktree base path', () => {
+31 -53
View File
@@ -84,11 +84,16 @@ import { getCohortAtEmit } from '../telemetry/cohort-classifier'
import type { RepoMethod } from '../../shared/telemetry-events'
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment'
import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-projection'
import {
didReconciliationChangeStore,
reconcileExistingFolderProjectIdentity
} from '../project-host-existing-folder-reconciliation'
import {
getRepoExecutionHostId,
normalizeExecutionHostId,
parseExecutionHostId,
toSshExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
type ExecutionHostId
} from '../../shared/execution-host'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
@@ -118,51 +123,6 @@ function emitRepoAdded(method: RepoMethod, alreadyExisted: boolean, isGitRepo?:
track('repo_added', props)
}
function buildProjectHostSetupResult(store: Store, repo: Repo): ProjectHostSetupResult {
const setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
const project = store.getProjects().find((entry) => entry.id === setup.projectId)
if (!project) {
throw new Error(`Project setup was created without a project record: ${setup.projectId}`)
}
return { project, setup, repo }
}
function alignRepoWithRequestedProject(
store: Store,
repo: Repo,
projectId: string,
setupMethod: ProjectHostSetupExistingFolderArgs['setupMethod'] = 'imported-existing-folder'
): ProjectHostSetupResult {
let setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
if (setup.projectId !== projectId) {
const project = store.getProjects().find((entry) => entry.id === projectId)
if (!project?.providerIdentity || project.providerIdentity.provider !== 'github') {
throw new Error('Imported folder does not match the selected project identity.')
}
// Why: stamp the selected project's provider identity when the folder lacks upstream, so projection can merge it.
const updated = store.updateRepo(repo.id, {
upstream: {
owner: project.providerIdentity.owner,
repo: project.providerIdentity.repo,
...(project.providerIdentity.host ? { host: project.providerIdentity.host } : {})
}
})
if (!updated) {
throw new Error(`Project setup repo disappeared before it could be linked: ${repo.id}`)
}
repo = updated
setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
}
const updated = store.updateRepo(repo.id, { projectHostSetupMethod: setupMethod })
if (!updated) {
throw new Error(
`Project setup repo disappeared before setup metadata could be linked: ${repo.id}`
)
}
repo = updated
return buildProjectHostSetupResult(store, repo)
}
async function addLocalRepoFromPath(
store: Store,
path: string,
@@ -1266,15 +1226,33 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
if ('error' in result) {
throw new Error(result.error)
}
emitRepoAdded('folder_picker', result.alreadyExisted)
let aligned: ProjectHostSetupResult
try {
aligned = await reconcileExistingFolderProjectIdentity({
store,
repo: result.repo,
projectId: args.projectId,
...(args.setupMethod ? { setupMethod: args.setupMethod } : {}),
ownedExecutionHostId:
parsedHost.kind === 'ssh'
? toSshExecutionHostId(parsedHost.targetId)
: LOCAL_EXECUTION_HOST_ID
})
} catch (error) {
// A rejected import usually leaves the store untouched, so notify only for a
// real change: the repo record the add path just created, or an identity write
// that landed before the rejection and was rolled back underneath it.
if (!result.alreadyExisted || didReconciliationChangeStore(error)) {
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
}
throw error
}
// Reconciliation can move the repo between projects, so notify only once the
// membership it reports is the one the store actually holds.
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
emitRepoAdded('folder_picker', result.alreadyExisted)
const aligned = alignRepoWithRequestedProject(
store,
result.repo,
args.projectId,
args.setupMethod
)
if (result.alreadyExisted) {
await prepareLocalWorktreeRootForRepo(store, aligned.repo)
}
+134
View File
@@ -594,6 +594,36 @@ describe('Store', () => {
})
})
it('remaps a host-less GitHub Enterprise project reference on load', async () => {
writeDataFile({
...getDefaultPersistedState(testState.dir),
repos: [
makeRepo({
id: 'r1',
upstream: { owner: 'acme', repo: 'orca', host: 'git.acme-corp.com' }
})
],
worktreeMeta: {
'r1::/repo': {
projectId: 'github:acme/orca',
projectHostSetupId: 'github:acme/orca::local'
}
}
})
const store = await createStore()
expect(store.getWorktreeMeta('r1::/repo')).toMatchObject({
projectId: 'github:git.acme-corp.com/acme/orca',
projectHostSetupId: 'github:git.acme-corp.com/acme/orca::local'
})
// Derived at load, so it has to reach disk without an explicit mutation.
store.flush()
expect((readDataFile() as PersistedState).worktreeMeta['r1::/repo']?.projectId).toBe(
'github:git.acme-corp.com/acme/orca'
)
})
it('migrates legacy WSL agent settings into the global Windows runtime default', async () => {
writeDataFile({
schemaVersion: 1,
@@ -4386,6 +4416,31 @@ describe('Store', () => {
expect(reloaded.getRepo('r1')!.upstream).toBeNull()
})
it('updateRepo keeps the Enterprise host on repo upstream metadata', async () => {
const store = await createStore()
store.addRepo(makeRepo())
// Why: the host is what separates `github:git.acme-corp.com/acme/orca` from
// `github:acme/orca`; dropping it silently re-projects the repo into the
// wrong project when existing-folder setup stamps a GHES project identity.
const updated = store.updateRepo('r1', {
upstream: { owner: 'acme', repo: 'orca', host: ' Git.Acme-Corp.com ' }
})
expect(updated!.upstream).toEqual({
owner: 'acme',
repo: 'orca',
host: 'git.acme-corp.com'
})
store.flush()
const reloaded = await createStore()
expect(reloaded.getRepo('r1')!.upstream).toEqual({
owner: 'acme',
repo: 'orca',
host: 'git.acme-corp.com'
})
})
it('updateRepo persists the resolved no-usable-remote identity marker', async () => {
const store = await createStore()
store.addRepo(makeRepo())
@@ -4409,6 +4464,85 @@ describe('Store', () => {
expect(reloaded.getRepo('r1')!.gitRemoteIdentity).toBeNull()
})
it('updateRepo cannot clear the identity fields back to absent', async () => {
const store = await createStore()
store.addRepo(makeRepo())
store.updateRepo('r1', { gitRemoteIdentity: null, upstream: { owner: 'acme', repo: 'orca' } })
// `undefined` means "leave alone" for every other caller, so it must stay a no-op here.
const updated = store.updateRepo('r1', { gitRemoteIdentity: undefined, upstream: undefined })
expect(updated!.gitRemoteIdentity).toBeNull()
expect(updated!.upstream).toEqual({ owner: 'acme', repo: 'orca' })
})
it('restoreRepoIdentityFields clears the identity fields back to absent', async () => {
const store = await createStore()
store.addRepo(makeRepo())
store.updateRepo('r1', { gitRemoteIdentity: null, upstream: { owner: 'acme', repo: 'orca' } })
// Absent is a distinct state from the `null` markers: rolling a rejected import back
// to `null` would falsely settle "no remote" and suppress the fork-upstream backfill.
const restored = store.restoreRepoIdentityFields('r1', {
gitRemoteIdentity: undefined,
upstream: undefined
})
expect('gitRemoteIdentity' in restored!).toBe(false)
expect('upstream' in restored!).toBe(false)
store.flush()
const reloaded = await createStore()
expect('gitRemoteIdentity' in reloaded.getRepo('r1')!).toBe(false)
expect('upstream' in reloaded.getRepo('r1')!).toBe(false)
})
it('restoreRepoIdentityFields restores previous values and leaves omitted fields alone', async () => {
const identity = {
canonicalKey: 'gitlab.example.com/ava/orca',
remoteName: 'origin',
remoteUrl: 'git@gitlab.example.com:ava/orca.git'
}
const store = await createStore()
store.addRepo(makeRepo())
store.updateRepo('r1', {
gitRemoteIdentity: {
canonicalKey: 'gitlab.example.com/team/orca',
remoteName: 'origin',
remoteUrl: 'git@gitlab.example.com:team/orca.git'
},
upstream: { owner: 'acme', repo: 'orca' }
})
const restored = store.restoreRepoIdentityFields('r1', { gitRemoteIdentity: identity })
expect(restored!.gitRemoteIdentity).toEqual(identity)
expect(restored!.upstream).toEqual({ owner: 'acme', repo: 'orca' })
store.flush()
const reloaded = await createStore()
expect(reloaded.getRepo('r1')!.gitRemoteIdentity).toEqual(identity)
})
it('restoreRepoIdentityFields restores the no-usable-remote marker', async () => {
const store = await createStore()
store.addRepo(makeRepo())
store.updateRepo('r1', {
gitRemoteIdentity: {
canonicalKey: 'gitlab.example.com/team/orca',
remoteName: 'origin',
remoteUrl: 'git@gitlab.example.com:team/orca.git'
}
})
expect(
store.restoreRepoIdentityFields('r1', { gitRemoteIdentity: null })!.gitRemoteIdentity
).toBeNull()
})
it('restoreRepoIdentityFields returns null for nonexistent id', async () => {
const store = await createStore()
expect(store.restoreRepoIdentityFields('nope', { upstream: undefined })).toBeNull()
})
it('getRepo does not expose invalid persisted repo upstream metadata', async () => {
const store = await createStore()
store.addRepo(makeRepo({ upstream: { owner: '', repo: 42 } as never }))
+56 -2
View File
@@ -71,6 +71,8 @@ import {
normalizeProjectRuntimePreference
} from '../shared/project-execution-runtime'
import { projectHostSetupProjectionFromRepos } from '../shared/project-host-setup-projection'
import { isDefaultGitHubHost } from '../shared/github-repository-identity-key'
import { remapHostLessGitHubEnterpriseProjectIds } from './github-enterprise-project-id-remap'
import type { GitRemoteIdentity } from '../shared/git-remote-identity'
import {
buildTaskSourceContextFromRepo,
@@ -1309,10 +1311,18 @@ function sanitizeRepoUpstream(value: unknown): Repo['upstream'] | undefined {
if (!value || typeof value !== 'object') {
return undefined
}
const candidate = value as { owner?: unknown; repo?: unknown }
const candidate = value as { owner?: unknown; repo?: unknown; host?: unknown }
const owner = typeof candidate.owner === 'string' ? candidate.owner.trim() : ''
const repo = typeof candidate.repo === 'string' ? candidate.repo.trim() : ''
return owner && repo ? { owner, repo } : undefined
// Why: the host is the github.com-vs-GHES boundary that project identity keys
// are built from; dropping it re-projects an Enterprise repo onto the
// same-named github.com project. github.com itself stays implicit.
const rawHost = typeof candidate.host === 'string' ? candidate.host.trim().toLowerCase() : ''
const host = rawHost && !isDefaultGitHubHost(rawHost) ? rawHost : undefined
if (!owner || !repo) {
return undefined
}
return host ? { owner, repo, host } : { owner, repo }
}
function sanitizeGitRemoteIdentity(value: unknown): GitRemoteIdentity | null | undefined {
@@ -3432,6 +3442,13 @@ export class Store {
}
const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? [])
// Before the compatibility merge: it rebuilds repo-backed rows from `repos`, so a
// host-less GHES reference left in place would lose its project row outright.
const enterpriseProjectIdRemap = remapHostLessGitHubEnterpriseProjectIds({ ...result, repos })
if (enterpriseProjectIdRemap.changed) {
this.loadNeedsSave = true
}
result = enterpriseProjectIdRemap.state
const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos)
if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) {
this.loadNeedsSave = true
@@ -4517,6 +4534,43 @@ export class Store {
return this.hydrateRepo(repo)
}
/** Rollback-only counterpart to `updateRepo` for the two identity fields. Key presence
* means "restore this field" and an `undefined` value means "back to absent" — which
* `updateRepo` deliberately cannot express, since there `undefined` means "leave alone".
* Kept narrow so undoing a rejected import cannot widen general patch semantics.
*
* Callers must snapshot from a hydrated read (`getRepo`/`getRepos`), the same shape this
* writes: a snapshot of the raw record can hold fields hydration would have filled in,
* and restoring those would persist derived state as if the user had set it. */
restoreRepoIdentityFields(
id: string,
restore: Pick<Partial<Repo>, 'upstream' | 'gitRemoteIdentity'>
): Repo | null {
const repo = this.state.repos.find((r) => r.id === id)
if (!repo) {
return null
}
if ('upstream' in restore) {
const upstream = sanitizeRepoUpstream(restore.upstream)
if (upstream === undefined) {
delete repo.upstream
} else {
repo.upstream = upstream
}
}
if ('gitRemoteIdentity' in restore) {
const gitRemoteIdentity = sanitizeGitRemoteIdentity(restore.gitRemoteIdentity)
if (gitRemoteIdentity === undefined) {
delete repo.gitRemoteIdentity
} else {
repo.gitRemoteIdentity = gitRemoteIdentity
}
}
this.syncProjectHostSetupCompatibilityState()
this.scheduleSave()
return this.hydrateRepo(repo)
}
private syncProjectHostSetupCompatibilityState(): void {
const compatibilityState = mergeProjectHostSetupCompatibilityState(this.state, this.state.repos)
this.state.projects = compatibilityState.projects
@@ -0,0 +1,155 @@
import { vi } from 'vitest'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../shared/execution-host'
import { normalizeGitRemoteUrl, type GitRemoteIdentity } from '../shared/git-remote-identity'
import { projectHostSetupProjectionFromRepos } from '../shared/project-host-setup-projection'
import type { Repo, RepoProjectHostSetupMethod, WorktreeMeta } from '../shared/types'
import {
reconcileExistingFolderProjectIdentity,
type ExistingFolderReconciliationStore
} from './project-host-existing-folder-reconciliation'
import { probeGitRemoteIdentity } from './repo-git-remote-identity'
// Shared by the reconciliation specs, which split by concern (policy vs host routing) and
// need the same projection-backed store fake and canonical-key fixtures.
export const NOW = 1_700_000_000_000
export const TEAM_SCP_URL = 'git@gitlab.example.com:team/orca.git'
export const TEAM_SSH_URL = 'ssh://git@gitlab.example.com/team/orca.git'
export const FORK_URL = 'git@gitlab.example.com:ava/orca.git'
export const OTHER_URL = 'git@gitlab.example.com:other/orca.git'
export const GHES_URL = 'git@git.acme-corp.com:acme/orca.git'
export const GHES_OTHER_URL = 'git@git.acme-corp.com:other/orca.git'
export const GITHUB_URL = 'git@github.com:acme/orca.git'
export const GITHUB_SSH_ALIAS_URL = 'git@ssh.github.com:acme/orca.git'
export const TEAM_PROJECT_ID = 'git:gitlab.example.com/team/orca'
export const FORK_PROJECT_ID = 'git:gitlab.example.com/ava/orca'
export const GHES_PROJECT_ID = 'github:git.acme-corp.com/acme/orca'
/** Derives the canonical key the way the probe does, so URL-shape equivalence is real. */
export function identity(remoteName: string, remoteUrl: string): GitRemoteIdentity {
const canonicalKey = normalizeGitRemoteUrl(remoteUrl)
if (!canonicalKey) {
throw new Error(`unusable remote url in fixture: ${remoteUrl}`)
}
return { canonicalKey, remoteName, remoteUrl }
}
export const teamIdentity = identity('origin', TEAM_SCP_URL)
export const forkIdentity = identity('origin', FORK_URL)
export const otherIdentity = identity('origin', OTHER_URL)
export const ghesIdentity = identity('origin', GHES_URL)
export function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-imported',
path: '/work/orca',
displayName: 'orca',
badgeColor: '#737373',
addedAt: NOW,
kind: 'git',
...overrides
}
}
export type FakeStore = ExistingFolderReconciliationStore & {
updateRepo: ReturnType<typeof vi.fn>
restoreRepoIdentityFields: ReturnType<typeof vi.fn>
setWorktreeMeta: ReturnType<typeof vi.fn>
worktreeMeta: Record<string, WorktreeMeta>
}
export function makeWorktreeMeta(overrides: Partial<WorktreeMeta> = {}): WorktreeMeta {
return {
displayName: 'main',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: NOW,
...overrides
}
}
export function makeStore(
repos: Repo[],
/** Stands in for the real store's persistence sanitizers, which can keep less than
* the caller asked for. */
sanitize: (updates: Partial<Repo>) => Partial<Repo> = (updates) => updates,
worktreeMeta: Record<string, WorktreeMeta> = {}
): FakeStore {
return {
worktreeMeta,
getAllWorktreeMeta: () => worktreeMeta,
setWorktreeMeta: vi.fn((worktreeId: string, meta: Partial<WorktreeMeta>) => {
const updated = { ...(worktreeMeta[worktreeId] ?? makeWorktreeMeta()), ...meta }
worktreeMeta[worktreeId] = updated
return updated
}),
// The real store re-projects repo-backed setups on every read, so projecting here
// keeps the fake faithful to what a write actually changes.
getProjects: () => projectHostSetupProjectionFromRepos(repos, NOW).projects,
getProjectHostSetups: () => projectHostSetupProjectionFromRepos(repos, NOW).setups,
getRepo: (id: string) => repos.find((repo) => repo.id === id),
updateRepo: vi.fn((id: string, updates: Partial<Repo>) => {
const target = repos.find((repo) => repo.id === id)
if (!target) {
return null
}
Object.assign(target, sanitize(updates))
return { ...target }
}),
// Mirrors `Store.restoreRepoIdentityFields`: a present key holding `undefined`
// clears the field, which `updateRepo` cannot express.
restoreRepoIdentityFields: vi.fn(
(id: string, restore: Pick<Partial<Repo>, 'upstream' | 'gitRemoteIdentity'>) => {
const target = repos.find((repo) => repo.id === id)
if (!target) {
return null
}
for (const field of ['upstream', 'gitRemoteIdentity'] as const) {
if (!(field in restore)) {
continue
}
if (restore[field] === undefined) {
delete target[field]
} else {
Object.assign(target, { [field]: restore[field] })
}
}
return { ...target }
}
)
}
}
export function reconcile(
store: ExistingFolderReconciliationStore,
repo: Repo,
projectId: string,
options: { ownedExecutionHostId?: ExecutionHostId; setupMethod?: RepoProjectHostSetupMethod } = {}
) {
return reconcileExistingFolderProjectIdentity({
store,
repo,
projectId,
ownedExecutionHostId: options.ownedExecutionHostId ?? LOCAL_EXECUTION_HOST_ID,
...(options.setupMethod ? { setupMethod: options.setupMethod } : {})
})
}
export function mockResolved(...remotes: GitRemoteIdentity[]): void {
const primary = remotes[0]
if (!primary) {
throw new Error('mockResolved needs at least one remote')
}
vi.mocked(probeGitRemoteIdentity).mockResolvedValue({
status: 'resolved',
identity: primary,
remotes
})
}
@@ -0,0 +1,899 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../shared/execution-host'
import {
didReconciliationChangeRepoIdentity,
didReconciliationChangeStore,
EXISTING_FOLDER_REMOTE_PROBE_TIMEOUT_MS,
PROJECT_IDENTITY_MISMATCH_MESSAGE,
PROJECT_IDENTITY_REMOTE_UNREADABLE_MESSAGE,
PROJECT_IDENTITY_UNRESOLVED_MESSAGE,
REPO_GITHUB_METADATA_OUTRANKS_PROJECT_MESSAGE
} from './project-host-existing-folder-reconciliation'
import {
FORK_PROJECT_ID,
forkIdentity,
GHES_OTHER_URL,
GHES_PROJECT_ID,
ghesIdentity,
GITHUB_SSH_ALIAS_URL,
GITHUB_URL,
identity,
makeRepo,
makeStore,
makeWorktreeMeta,
mockResolved,
otherIdentity,
reconcile,
TEAM_PROJECT_ID,
TEAM_SCP_URL,
TEAM_SSH_URL,
teamIdentity
} from './project-host-existing-folder-reconciliation-fixture'
import { probeGitRemoteIdentity } from './repo-git-remote-identity'
vi.mock('./repo-git-remote-identity', () => ({ probeGitRemoteIdentity: vi.fn() }))
beforeEach(() => {
vi.clearAllMocks()
})
describe('reconcileExistingFolderProjectIdentity', () => {
it('links a clone whose stored identity settled as no-remote', async () => {
const imported = makeRepo({ gitRemoteIdentity: null })
const store = makeStore([
makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }),
imported
])
mockResolved(teamIdentity)
const result = await reconcile(store, imported, TEAM_PROJECT_ID)
expect(result.setup.projectId).toBe(TEAM_PROJECT_ID)
expect(result.project.id).toBe(TEAM_PROJECT_ID)
expect(store.updateRepo.mock.calls[0]).toEqual([
'repo-imported',
{ gitRemoteIdentity: teamIdentity }
])
expect(store.updateRepo.mock.calls[1]).toEqual([
'repo-imported',
{ projectHostSetupMethod: 'imported-existing-folder' }
])
expect(probeGitRemoteIdentity).toHaveBeenCalledWith('/work/orca', null, {
timeoutMs: EXISTING_FOLDER_REMOTE_PROBE_TIMEOUT_MS
})
})
it('bounds the probe well inside the setup call it blocks', () => {
// Awaited inline inside a setup call the renderer caps at 15 s, which also has to
// cover `addRepo`'s own validation — so an unreachable remote must give up early.
expect(EXISTING_FOLDER_REMOTE_PROBE_TIMEOUT_MS).toBe(3000)
})
it('links a clone whose identity was never probed', async () => {
const imported = makeRepo()
const store = makeStore([
makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }),
imported
])
mockResolved(teamIdentity)
await expect(reconcile(store, imported, TEAM_PROJECT_ID)).resolves.toMatchObject({
setup: { projectId: TEAM_PROJECT_ID }
})
expect(imported.gitRemoteIdentity).toEqual(teamIdentity)
})
it('treats scp-like and ssh:// remotes as the same project', async () => {
const imported = makeRepo({ gitRemoteIdentity: null })
const store = makeStore([
makeRepo({ id: 'repo-host-a', gitRemoteIdentity: identity('origin', TEAM_SSH_URL) }),
imported
])
mockResolved(teamIdentity)
const result = await reconcile(store, imported, TEAM_PROJECT_ID)
expect(result.setup.projectId).toBe(TEAM_PROJECT_ID)
expect(imported.gitRemoteIdentity?.remoteUrl).toBe(TEAM_SCP_URL)
})
it('repairs a stale non-null identity from the current remote', async () => {
const imported = makeRepo({ gitRemoteIdentity: otherIdentity })
const store = makeStore([
makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }),
imported
])
mockResolved(teamIdentity)
const result = await reconcile(store, imported, TEAM_PROJECT_ID)
expect(result.setup.projectId).toBe(TEAM_PROJECT_ID)
expect(imported.gitRemoteIdentity).toEqual(teamIdentity)
})
it('persists the matching fork remote instead of the primary remote', async () => {
const imported = makeRepo({ gitRemoteIdentity: null })
const store = makeStore([
makeRepo({ id: 'repo-host-a', gitRemoteIdentity: forkIdentity }),
imported
])
// A fork checkout's primary remote is `upstream`; persisting it would land the
// repo in the upstream project instead of the fork the user selected.
mockResolved(identity('upstream', TEAM_SCP_URL), forkIdentity)
const result = await reconcile(store, imported, FORK_PROJECT_ID)
expect(result.setup.projectId).toBe(FORK_PROJECT_ID)
expect(imported.gitRemoteIdentity).toEqual(forkIdentity)
})
it('rejects and writes nothing when no probed remote matches', async () => {
const imported = makeRepo({ gitRemoteIdentity: null })
const store = makeStore([
makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }),
imported
])
mockResolved(otherIdentity)
await expect(reconcile(store, imported, TEAM_PROJECT_ID)).rejects.toThrow(
PROJECT_IDENTITY_MISMATCH_MESSAGE
)
expect(store.updateRepo).not.toHaveBeenCalled()
expect(imported.gitRemoteIdentity).toBeNull()
expect(imported.projectHostSetupMethod).toBeUndefined()
})
it('keeps a stale identity when git reports no remote', async () => {
const imported = makeRepo({ gitRemoteIdentity: otherIdentity })
const store = makeStore([
makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }),
imported
])
vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'no-remote' })
await expect(reconcile(store, imported, TEAM_PROJECT_ID)).rejects.toThrow(
PROJECT_IDENTITY_MISMATCH_MESSAGE
)
expect(store.updateRepo).not.toHaveBeenCalled()
expect(imported.gitRemoteIdentity).toEqual(otherIdentity)
})
it('refuses the GitHub fallback over a settled remote that names another project', async () => {
const imported = makeRepo({ gitRemoteIdentity: teamIdentity })
const store = makeStore([
makeRepo({
id: 'repo-host-a',
gitRemoteIdentity: ghesIdentity,
upstream: { owner: 'acme', repo: 'orca', host: 'git.acme-corp.com' }
}),
imported
])
vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'no-remote' })
// An unread git is no evidence: stamping the GHES identity here would leave the repo
// holding a gitlab remote while claiming to be an Enterprise checkout, and the `null`
// marker cannot be settled either — that would evict it from `TEAM_PROJECT_ID`.
await expect(reconcile(store, imported, GHES_PROJECT_ID)).rejects.toThrow(
PROJECT_IDENTITY_MISMATCH_MESSAGE
)
expect(store.updateRepo).not.toHaveBeenCalled()
expect(imported.gitRemoteIdentity).toEqual(teamIdentity)
expect('upstream' in imported).toBe(false)
})
it('still runs the GitHub fallback when the unread repo names the project itself', async () => {
// Same unread probe, but the stored remote is the project's own: the folder is not
// claiming anywhere else, it is only missing the GitHub metadata §6 supplies.
const imported = makeRepo({ gitRemoteIdentity: ghesIdentity })
const store = makeStore([
makeRepo({
id: 'repo-host-a',
gitRemoteIdentity: ghesIdentity,
upstream: { owner: 'acme', repo: 'orca', host: 'git.acme-corp.com' }
}),
imported
])
vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'unavailable' })
const result = await reconcile(store, imported, GHES_PROJECT_ID)
expect(result.setup.projectId).toBe(GHES_PROJECT_ID)
expect(store.updateRepo.mock.calls[0]).toEqual([
'repo-imported',
{ upstream: { owner: 'acme', repo: 'orca', host: 'git.acme-corp.com' } }
])
})
it('does not settle the no-remote marker when the import is rejected', async () => {
const imported = makeRepo()
const store = makeStore([
makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }),
imported
])
vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'no-remote' })
await expect(reconcile(store, imported, TEAM_PROJECT_ID)).rejects.toThrow(
PROJECT_IDENTITY_MISMATCH_MESSAGE
)
expect(store.updateRepo).not.toHaveBeenCalled()
expect(imported.gitRemoteIdentity).toBeUndefined()
})
it('blames the unread remote, not the folder, when the probe fails', async () => {
const imported = makeRepo({ gitRemoteIdentity: null })
const store = makeStore([
makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }),
imported
])
vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'unavailable' })
await expect(reconcile(store, imported, TEAM_PROJECT_ID)).rejects.toThrow(
PROJECT_IDENTITY_REMOTE_UNREADABLE_MESSAGE
)
expect(store.updateRepo).not.toHaveBeenCalled()
})
it('rejects when the selected project has no resolved identity of its own', async () => {
const imported = makeRepo({ gitRemoteIdentity: null })
const store = makeStore([makeRepo({ id: 'repo-host-a', gitRemoteIdentity: null }), imported])
await expect(reconcile(store, imported, 'repo:repo-host-a')).rejects.toThrow(
PROJECT_IDENTITY_UNRESOLVED_MESSAGE
)
expect(probeGitRemoteIdentity).not.toHaveBeenCalled()
expect(store.updateRepo).not.toHaveBeenCalled()
})
it('rejects when the selected project no longer exists', async () => {
const imported = makeRepo({ gitRemoteIdentity: null })
const store = makeStore([imported])
await expect(reconcile(store, imported, TEAM_PROJECT_ID)).rejects.toThrow('Project not found')
expect(store.updateRepo).not.toHaveBeenCalled()
})
it('links an uncredentialed clone to a GHES project through the GitHub fallback', async () => {
const imported = makeRepo({ gitRemoteIdentity: null })
const store = makeStore([
makeRepo({
id: 'repo-host-a',
gitRemoteIdentity: ghesIdentity,
upstream: { owner: 'acme', repo: 'orca', host: 'git.acme-corp.com' }
}),
imported
])
mockResolved(ghesIdentity)
const result = await reconcile(store, imported, GHES_PROJECT_ID)
expect(result.setup.projectId).toBe(GHES_PROJECT_ID)
expect(store.updateRepo.mock.calls[0]).toEqual([
'repo-imported',
{
gitRemoteIdentity: ghesIdentity,
upstream: { owner: 'acme', repo: 'orca', host: 'git.acme-corp.com' }
}
])
})
it('links a GHES clone whose endpoint port only survives in the project identity', async () => {
const imported = makeRepo({ gitRemoteIdentity: null })
// The project key keeps the HTTPS endpoint port; the canonical remote key never
// does, so a literal comparison of the two can never match the right folder. The
// host is not spelled like GitHub, so only that literal key can carry the match.
const store = makeStore([
makeRepo({
id: 'repo-host-a',
upstream: { owner: 'acme', repo: 'orca', host: 'git.acme-corp.com:8443' }
}),
imported
])
mockResolved(ghesIdentity)
const result = await reconcile(store, imported, 'github:git.acme-corp.com:8443/acme/orca')
expect(result.setup.projectId).toBe('github:git.acme-corp.com:8443/acme/orca')
expect(imported.gitRemoteIdentity).toEqual(ghesIdentity)
expect(imported.upstream).toEqual({
owner: 'acme',
repo: 'orca',
host: 'git.acme-corp.com:8443'
})
})
it('links a clone that reaches github.com through the ssh.github.com alias', async () => {
const imported = makeRepo({ gitRemoteIdentity: null })
const store = makeStore([
makeRepo({ id: 'repo-host-a', gitRemoteIdentity: identity('origin', GITHUB_URL) }),
imported
])
const aliased = identity('origin', GITHUB_SSH_ALIAS_URL)
mockResolved(aliased)
const result = await reconcile(store, imported, 'github:acme/orca')
expect(result.setup.projectId).toBe('github:acme/orca')
expect(imported.gitRemoteIdentity).toEqual(aliased)
})
it('rejects and unstamps when the store cannot persist the identity it was given', async () => {
const imported = makeRepo({ kind: 'folder' })
// A store that drops the Enterprise host lands the folder in the same-named
// github.com project; the plan predicts from its own object and cannot see that.
const store = makeStore(
[
makeRepo({
id: 'repo-host-a',
upstream: { owner: 'acme', repo: 'orca', host: 'git.acme-corp.com' }
}),
imported
],
(updates) =>
updates.upstream
? { ...updates, upstream: { owner: updates.upstream.owner, repo: updates.upstream.repo } }
: updates
)
const before = structuredClone(imported)
await expect(reconcile(store, imported, GHES_PROJECT_ID)).rejects.toThrow(
PROJECT_IDENTITY_MISMATCH_MESSAGE
)
// Absent, not `null`: a `null` upstream reads as "already resolved" to the fork
// backfill, so it would permanently suppress the enrichment this repo still needs.
expect('upstream' in imported).toBe(false)
expect(imported).toEqual(before)
})
it('leaves upstream absent when the rejected write never landed', async () => {
const imported = makeRepo({ kind: 'folder' })
// A store that drops the write outright has nothing to undo, and `null` is not a
// free stand-in for absent: it reads as "resolved" to the fork-upstream backfill.
const store = makeStore(
[
makeRepo({
id: 'repo-host-a',
upstream: { owner: 'acme', repo: 'orca', host: 'git.acme-corp.com' }
}),
imported
],
({ upstream: _dropped, ...rest }) => rest
)
const before = structuredClone(imported)
await expect(reconcile(store, imported, GHES_PROJECT_ID)).rejects.toThrow(
PROJECT_IDENTITY_MISMATCH_MESSAGE
)
expect('upstream' in imported).toBe(false)
expect(imported).toEqual(before)
// Nothing landed, so there is nothing to undo — the rollback must stay silent.
expect(store.updateRepo).toHaveBeenCalledTimes(1)
expect(store.restoreRepoIdentityFields).not.toHaveBeenCalled()
})
it('restores the previous remote identity when a rejected import already wrote one', async () => {
const imported = makeRepo({ gitRemoteIdentity: forkIdentity })
// A concurrent enrichment stamping GitHub metadata re-keys the repo out of the
// generic project the write was aiming at, so the verification fails after the write.
const store = makeStore(
[makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }), imported],
(updates) =>
updates.gitRemoteIdentity
? { ...updates, upstream: { owner: 'acme', repo: 'orca' } }
: updates
)
mockResolved(teamIdentity)
const before = structuredClone(imported)
await expect(reconcile(store, imported, TEAM_PROJECT_ID)).rejects.toThrow(
PROJECT_IDENTITY_MISMATCH_MESSAGE
)
// The whole record, not just the field under test: the rollback has to undo the
// collateral `upstream` the store's own writer added, or the repo stays re-keyed.
expect(imported).toEqual(before)
})
it('restores the settled no-remote marker rather than clearing it to absent', async () => {
const imported = makeRepo({ gitRemoteIdentity: null })
const store = makeStore(
[makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }), imported],
(updates) =>
updates.gitRemoteIdentity
? { ...updates, upstream: { owner: 'acme', repo: 'orca' } }
: updates
)
mockResolved(teamIdentity)
const before = structuredClone(imported)
await expect(reconcile(store, imported, TEAM_PROJECT_ID)).rejects.toThrow(
PROJECT_IDENTITY_MISMATCH_MESSAGE
)
expect(imported.gitRemoteIdentity).toBeNull()
expect(imported).toEqual(before)
})
it('reports a rejection that already mutated the store', async () => {
const imported = makeRepo({ gitRemoteIdentity: forkIdentity })
const store = makeStore(
[makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }), imported],
(updates) =>
updates.gitRemoteIdentity
? { ...updates, upstream: { owner: 'acme', repo: 'orca' } }
: updates
)
mockResolved(teamIdentity)
const error = await reconcile(store, imported, TEAM_PROJECT_ID).catch((thrown) => thrown)
// The write and its rollback both landed, so every client still holds a stale record.
expect(didReconciliationChangeStore(error)).toBe(true)
})
it('reports a rejection decided before any write', async () => {
const imported = makeRepo({ gitRemoteIdentity: null })
const store = makeStore([
makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }),
imported
])
mockResolved(otherIdentity)
const error = await reconcile(store, imported, TEAM_PROJECT_ID).catch((thrown) => thrown)
expect(didReconciliationChangeStore(error)).toBe(false)
})
it('keeps an enrichment that landed while the probe was in flight', async () => {
const stored = makeRepo()
const store = makeStore([
makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }),
stored
])
// The add path hands back a detached record; planning re-reads the store so the
// enrichment is part of the decision instead of something a rollback could undo.
const handedBack = structuredClone(stored)
vi.mocked(probeGitRemoteIdentity).mockImplementation(async () => {
stored.upstream = { owner: 'acme', repo: 'orca' }
return { status: 'resolved', identity: teamIdentity, remotes: [teamIdentity] }
})
// The enrichment's GitHub metadata outranks the generic project the user selected.
await expect(reconcile(store, handedBack, TEAM_PROJECT_ID)).rejects.toThrow(
REPO_GITHUB_METADATA_OUTRANKS_PROJECT_MESSAGE
)
expect(stored.upstream).toEqual({ owner: 'acme', repo: 'orca' })
expect('gitRemoteIdentity' in stored).toBe(false)
expect(store.updateRepo).not.toHaveBeenCalled()
expect(store.restoreRepoIdentityFields).not.toHaveBeenCalled()
})
it('writes the probe-confirmed identity when only the store record is behind', async () => {
// The caller's view already matches the project but the store's record does not, so
// planning from the handed-back record would "succeed" with nothing written and then
// reject on a store that still keys the repo as `repo:<id>`.
const stored = makeRepo()
const store = makeStore([
makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }),
stored
])
mockResolved(teamIdentity)
const result = await reconcile(
store,
makeRepo({ gitRemoteIdentity: teamIdentity }),
TEAM_PROJECT_ID
)
expect(result.setup.projectId).toBe(TEAM_PROJECT_ID)
expect(store.updateRepo.mock.calls[0]).toEqual([
'repo-imported',
{ gitRemoteIdentity: teamIdentity }
])
expect(stored.gitRemoteIdentity).toEqual(teamIdentity)
})
it('leaves the store untouched when a rejection had nothing to write', async () => {
// The store's record already matches the project the probe confirms, so there is no
// write to plan. A rollback here would be a mutation nothing announces.
const stored = makeRepo({
gitRemoteIdentity: teamIdentity,
upstream: { owner: 'acme', repo: 'orca' }
})
const store = makeStore([
makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }),
stored
])
mockResolved(teamIdentity)
const error = await reconcile(store, structuredClone(stored), TEAM_PROJECT_ID).catch(
(thrown) => thrown
)
expect(error).toHaveProperty('message', REPO_GITHUB_METADATA_OUTRANKS_PROJECT_MESSAGE)
expect(didReconciliationChangeStore(error)).toBe(false)
expect(store.updateRepo).not.toHaveBeenCalled()
expect(store.restoreRepoIdentityFields).not.toHaveBeenCalled()
})
it('remaps worktree meta when an identity repair re-keys the repo', async () => {
const imported = makeRepo({ gitRemoteIdentity: otherIdentity })
const store = makeStore(
[makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }), imported],
undefined,
{
'repo-imported::/work/orca-feature': makeWorktreeMeta({
projectId: 'git:gitlab.example.com/other/orca',
projectHostSetupId: 'git:gitlab.example.com/other/orca::local'
}),
'repo-other::/work/unrelated': makeWorktreeMeta({
projectId: 'git:gitlab.example.com/other/orca',
projectHostSetupId: 'git:gitlab.example.com/other/orca::local'
})
}
)
mockResolved(teamIdentity)
const result = await reconcile(store, imported, TEAM_PROJECT_ID)
expect(store.worktreeMeta['repo-imported::/work/orca-feature']).toMatchObject({
projectId: TEAM_PROJECT_ID,
projectHostSetupId: result.setup.id,
hostId: result.setup.hostId
})
// Another repo's workspaces are not this repair's business, even under the same old id.
expect(store.worktreeMeta['repo-other::/work/unrelated']?.projectId).toBe(
'git:gitlab.example.com/other/orca'
)
})
it('leaves an unstamped worktree meta row alone', async () => {
const imported = makeRepo({ gitRemoteIdentity: otherIdentity })
const store = makeStore(
[makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }), imported],
undefined,
{ 'repo-imported::/work/orca-feature': makeWorktreeMeta() }
)
mockResolved(teamIdentity)
await reconcile(store, imported, TEAM_PROJECT_ID)
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
})
it('does not touch worktree meta when the project membership never moved', async () => {
const imported = makeRepo({ gitRemoteIdentity: teamIdentity })
const store = makeStore([imported], undefined, {
'repo-imported::/work/orca-feature': makeWorktreeMeta({ projectId: TEAM_PROJECT_ID })
})
await reconcile(store, imported, TEAM_PROJECT_ID)
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
})
it('does not remap worktree meta when the import is rejected', async () => {
const imported = makeRepo({ gitRemoteIdentity: otherIdentity })
const store = makeStore(
[makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }), imported],
undefined,
{
'repo-imported::/work/orca-feature': makeWorktreeMeta({
projectId: 'git:gitlab.example.com/other/orca'
})
}
)
mockResolved(forkIdentity)
await expect(reconcile(store, imported, TEAM_PROJECT_ID)).rejects.toThrow(
PROJECT_IDENTITY_MISMATCH_MESSAGE
)
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
})
it('reports whether reconciliation rewrote the repo identity', async () => {
const before = makeRepo({ gitRemoteIdentity: null })
expect(didReconciliationChangeRepoIdentity(before, { ...before })).toBe(false)
expect(
didReconciliationChangeRepoIdentity(before, { ...before, gitRemoteIdentity: teamIdentity })
).toBe(true)
expect(
didReconciliationChangeRepoIdentity(before, {
...before,
upstream: { owner: 'acme', repo: 'orca' }
})
).toBe(true)
})
it('keeps a generic git project apart from a case-variant path on the same host', async () => {
// Generic git servers serve `Team/orca` and `team/orca` as different repositories.
const imported = makeRepo({ gitRemoteIdentity: null })
const store = makeStore([
makeRepo({
id: 'repo-host-a',
gitRemoteIdentity: identity('origin', 'git@gitlab.example.com:Team/orca.git')
}),
imported
])
mockResolved(teamIdentity)
await expect(reconcile(store, imported, 'git:gitlab.example.com/Team/orca')).rejects.toThrow(
PROJECT_IDENTITY_MISMATCH_MESSAGE
)
expect(store.updateRepo).not.toHaveBeenCalled()
})
it('recognizes a folder by the project git key when the project is GitHub-keyed', async () => {
// The project's own record carries GitHub metadata, so it keys `github:` — but it also
// names the generic remote this folder has, and that git key is the only entry that can
// recognize it. §6 then supplies the GitHub metadata the folder lacks.
const imported = makeRepo({ gitRemoteIdentity: null })
const store = makeStore([
makeRepo({
id: 'repo-host-a',
upstream: { owner: 'acme', repo: 'orca' },
gitRemoteIdentity: teamIdentity
}),
imported
])
mockResolved(teamIdentity)
const result = await reconcile(store, imported, 'github:acme/orca')
expect(result.setup.projectId).toBe('github:acme/orca')
expect(imported).toMatchObject({
gitRemoteIdentity: teamIdentity,
upstream: { owner: 'acme', repo: 'orca' }
})
})
it('still compares that git key byte-for-byte, GitHub-keyed project or not', async () => {
const imported = makeRepo({ gitRemoteIdentity: null })
const store = makeStore([
makeRepo({
id: 'repo-host-a',
upstream: { owner: 'acme', repo: 'orca' },
gitRemoteIdentity: identity('origin', 'git@gitlab.example.com:Team/orca.git')
}),
imported
])
mockResolved(teamIdentity)
await expect(reconcile(store, imported, 'github:acme/orca')).rejects.toThrow(
PROJECT_IDENTITY_MISMATCH_MESSAGE
)
expect(store.updateRepo).not.toHaveBeenCalled()
})
it('links a GHES clone whose provider slug differs only by case', async () => {
// The provider key is lowercased on the way in, so the two spellings name one repo.
const imported = makeRepo({ gitRemoteIdentity: null })
const store = makeStore([
makeRepo({
id: 'repo-host-a',
upstream: { owner: 'Acme', repo: 'Orca', host: 'git.acme-corp.com' }
}),
imported
])
mockResolved(ghesIdentity)
const result = await reconcile(store, imported, GHES_PROJECT_ID)
expect(result.setup.projectId).toBe(GHES_PROJECT_ID)
expect(imported.gitRemoteIdentity).toEqual(ghesIdentity)
})
it('does not rewrite an identity the probe confirms is already stored', async () => {
const imported = makeRepo({ gitRemoteIdentity: ghesIdentity })
const store = makeStore([
makeRepo({
id: 'repo-host-a',
gitRemoteIdentity: ghesIdentity,
upstream: { owner: 'acme', repo: 'orca', host: 'git.acme-corp.com' }
}),
imported
])
mockResolved(ghesIdentity)
const result = await reconcile(store, imported, GHES_PROJECT_ID)
expect(result.setup.projectId).toBe(GHES_PROJECT_ID)
expect(store.updateRepo.mock.calls[0]).toEqual([
'repo-imported',
{ upstream: { owner: 'acme', repo: 'orca', host: 'git.acme-corp.com' } }
])
})
it('rejects a conflicting remote before the GitHub fallback can run', async () => {
const imported = makeRepo({ gitRemoteIdentity: null })
const store = makeStore([
makeRepo({
id: 'repo-host-a',
gitRemoteIdentity: ghesIdentity,
upstream: { owner: 'acme', repo: 'orca', host: 'git.acme-corp.com' }
}),
imported
])
mockResolved(identity('origin', GHES_OTHER_URL))
await expect(reconcile(store, imported, GHES_PROJECT_ID)).rejects.toThrow(
PROJECT_IDENTITY_MISMATCH_MESSAGE
)
expect(store.updateRepo).not.toHaveBeenCalled()
})
it('rejects a generic project when the repo carries outranking GitHub metadata', async () => {
const imported = makeRepo({ upstream: { owner: 'acme', repo: 'orca' } })
const store = makeStore([
makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }),
imported
])
mockResolved(teamIdentity)
await expect(reconcile(store, imported, TEAM_PROJECT_ID)).rejects.toThrow(
REPO_GITHUB_METADATA_OUTRANKS_PROJECT_MESSAGE
)
expect(store.updateRepo).not.toHaveBeenCalled()
expect(imported.upstream).toEqual({ owner: 'acme', repo: 'orca' })
})
it('never probes a folder record and still allows the GitHub fallback', async () => {
const imported = makeRepo({ kind: 'folder' })
const store = makeStore([
makeRepo({ id: 'repo-host-a', upstream: { owner: 'acme', repo: 'orca' } }),
imported
])
const result = await reconcile(store, imported, 'github:acme/orca')
expect(probeGitRemoteIdentity).not.toHaveBeenCalled()
expect(result.setup.projectId).toBe('github:acme/orca')
expect(store.updateRepo.mock.calls[0]).toEqual([
'repo-imported',
{ upstream: { owner: 'acme', repo: 'orca' } }
])
})
it('rejects a folder record for a generic Git project without probing', async () => {
const imported = makeRepo({ kind: 'folder' })
const store = makeStore([
makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }),
imported
])
await expect(reconcile(store, imported, TEAM_PROJECT_ID)).rejects.toThrow(
PROJECT_IDENTITY_MISMATCH_MESSAGE
)
expect(probeGitRemoteIdentity).not.toHaveBeenCalled()
expect(store.updateRepo).not.toHaveBeenCalled()
})
it('does not probe a record owned by a different execution host', async () => {
const imported = makeRepo({ connectionId: 'builder' })
const store = makeStore([
makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }),
imported
])
await expect(reconcile(store, imported, TEAM_PROJECT_ID)).rejects.toThrow(
PROJECT_IDENTITY_REMOTE_UNREADABLE_MESSAGE
)
expect(probeGitRemoteIdentity).not.toHaveBeenCalled()
expect(store.updateRepo).not.toHaveBeenCalled()
})
it('probes a locally owned record locally even when it names a connection', async () => {
// `executionHostId` is the authority; routing by the stale `connectionId` would run
// git on a different machine than the one the ownership check just cleared.
const imported = makeRepo({ executionHostId: LOCAL_EXECUTION_HOST_ID, connectionId: 'builder' })
const store = makeStore([
makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }),
imported
])
mockResolved(teamIdentity)
await reconcile(store, imported, TEAM_PROJECT_ID)
expect(probeGitRemoteIdentity).toHaveBeenCalledWith('/work/orca', null, {
timeoutMs: EXISTING_FOLDER_REMOTE_PROBE_TIMEOUT_MS
})
})
it('probes an SSH-owned record through its own connection', async () => {
const imported = makeRepo({ connectionId: 'builder', path: '/srv/orca' })
const store = makeStore([
makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }),
imported
])
mockResolved(teamIdentity)
const result = await reconcile(store, imported, TEAM_PROJECT_ID, {
ownedExecutionHostId: toSshExecutionHostId('builder')
})
expect(result.setup.projectId).toBe(TEAM_PROJECT_ID)
expect(probeGitRemoteIdentity).toHaveBeenCalledWith('/srv/orca', 'builder', {
timeoutMs: EXISTING_FOLDER_REMOTE_PROBE_TIMEOUT_MS
})
})
it('probes the SSH host named by executionHostId, not a divergent connectionId', async () => {
// Both fields exist and disagree: routing by `connectionId` would run git on a machine
// the ownership check never cleared. The host id is percent-encoded, so the target has
// to be decoded out of it rather than sliced.
const imported = makeRepo({
executionHostId: toSshExecutionHostId('build box'),
connectionId: 'other-host',
path: '/srv/orca'
})
const store = makeStore([
makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }),
imported
])
mockResolved(teamIdentity)
await reconcile(store, imported, TEAM_PROJECT_ID, {
ownedExecutionHostId: toSshExecutionHostId('build box')
})
expect(probeGitRemoteIdentity).toHaveBeenCalledWith('/srv/orca', 'build box', {
timeoutMs: EXISTING_FOLDER_REMOTE_PROBE_TIMEOUT_MS
})
})
it('does not probe an SSH-owned record from another SSH host', async () => {
const imported = makeRepo({ connectionId: 'builder', path: '/srv/orca' })
const store = makeStore([
makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }),
imported
])
await expect(
reconcile(store, imported, TEAM_PROJECT_ID, {
ownedExecutionHostId: toSshExecutionHostId('other-host')
})
).rejects.toThrow(PROJECT_IDENTITY_REMOTE_UNREADABLE_MESSAGE)
expect(probeGitRemoteIdentity).not.toHaveBeenCalled()
})
it('probes a runtime-owned record on its own filesystem', async () => {
const imported = makeRepo({ executionHostId: 'runtime:env-1' })
const store = makeStore([
makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }),
imported
])
mockResolved(teamIdentity)
await reconcile(store, imported, TEAM_PROJECT_ID, { ownedExecutionHostId: 'runtime:env-1' })
expect(probeGitRemoteIdentity).toHaveBeenCalledWith('/work/orca', null, {
timeoutMs: EXISTING_FOLDER_REMOTE_PROBE_TIMEOUT_MS
})
})
it('writes only setup metadata when the repo already projects to the selected project', async () => {
const imported = makeRepo({ gitRemoteIdentity: teamIdentity })
const store = makeStore([imported])
const result = await reconcile(store, imported, TEAM_PROJECT_ID, { setupMethod: 'cloned' })
expect(probeGitRemoteIdentity).not.toHaveBeenCalled()
expect(store.updateRepo.mock.calls).toEqual([
['repo-imported', { projectHostSetupMethod: 'cloned' }]
])
expect(result.setup.setupMethod).toBe('cloned')
})
it('throws the disappeared-record error when the repo vanishes mid-reconciliation', async () => {
const imported = makeRepo({ gitRemoteIdentity: null })
const store = makeStore([
makeRepo({ id: 'repo-host-a', gitRemoteIdentity: teamIdentity }),
imported
])
mockResolved(teamIdentity)
store.updateRepo.mockReturnValue(null)
await expect(reconcile(store, imported, TEAM_PROJECT_ID)).rejects.toThrow(
'Project setup repo disappeared'
)
})
})
@@ -0,0 +1,372 @@
import {
getProjectHostSetupForRepo,
getProjectIdentityKey,
isGitHubBackedRepo
} from '../shared/project-host-setup-projection'
import {
getRepoExecutionHostId,
parseExecutionHostId,
type ExecutionHostId
} from '../shared/execution-host'
import { isFolderRepo } from '../shared/repo-kind'
import type {
Project,
ProjectHostSetup,
ProjectHostSetupResult,
Repo,
RepoProjectHostSetupMethod,
WorktreeMeta
} from '../shared/types'
import { remapWorktreeMetaToRepoProject } from './project-identity-repair-worktree-meta'
import {
isSameRemoteIdentity,
isSameUpstream,
matchesProject,
projectRemoteMatch,
type ProjectRemoteMatch
} from './project-remote-identity-match'
import { probeGitRemoteIdentity, type GitRemoteIdentityProbe } from './repo-git-remote-identity'
/** The import dialog has a 15s runtime RPC budget and the local/SSH IPC invoke has
* none, so an unreachable host must fail the probe long before either gives up. */
export const EXISTING_FOLDER_REMOTE_PROBE_TIMEOUT_MS = 3000
export const PROJECT_IDENTITY_MISMATCH_MESSAGE =
'Imported folder does not match the selected project identity.'
export const PROJECT_IDENTITY_UNRESOLVED_MESSAGE =
"The selected project has no resolved remote identity yet. Open its existing host so Orca can read that repository's remote, then try again."
export const REPO_GITHUB_METADATA_OUTRANKS_PROJECT_MESSAGE =
'This folder is already linked to a GitHub repository, so it cannot join a non-GitHub project.'
export const PROJECT_IDENTITY_REMOTE_UNREADABLE_MESSAGE =
"Orca could not read this folder's git remote from its host, so it cannot confirm the folder belongs to the selected project. Open the folder on its own host and try again."
export type ExistingFolderReconciliationStore = {
getProjects(): Project[]
getProjectHostSetups(): ProjectHostSetup[]
getRepo(id: string): Repo | undefined
updateRepo(
id: string,
updates: Pick<Partial<Repo>, 'gitRemoteIdentity' | 'upstream' | 'projectHostSetupMethod'>
): Repo | null
/** Rollback-only: key presence restores a field, `undefined` restores it to absent.
* `updateRepo` cannot express that, and widening it for one rollback would change
* patch semantics for every caller. */
restoreRepoIdentityFields(
id: string,
restore: Pick<Partial<Repo>, 'upstream' | 'gitRemoteIdentity'>
): Repo | null
getAllWorktreeMeta(): Record<string, WorktreeMeta>
setWorktreeMeta(worktreeId: string, meta: Partial<WorktreeMeta>): unknown
}
/** Carries whether a rejected import left a store mutation behind, so callers know
* whether their refresh notification is still required. */
class ExistingFolderReconciliationError extends Error {
readonly storeChanged: boolean
constructor(message: string, storeChanged: boolean, options?: { cause?: unknown }) {
super(message, options)
this.name = 'ExistingFolderReconciliationError'
this.storeChanged = storeChanged
}
}
/** True when a rejected reconciliation mutated the store, so its caller must still
* notify — the write, its rollback, and the compatibility-state resync are all real
* changes that a renderer holding the pre-import record cannot see otherwise. */
export function didReconciliationChangeStore(error: unknown): boolean {
return error instanceof ExistingFolderReconciliationError && error.storeChanged
}
export type ExistingFolderReconciliationArgs = {
store: ExistingFolderReconciliationStore
/** The record the add/import path returned; its `kind` and host win over request hints. */
repo: Repo
projectId: string
setupMethod?: RepoProjectHostSetupMethod
/** The one execution host this process can run git on; anything else is unprobeable. */
ownedExecutionHostId: ExecutionHostId
}
type RepoIdentityUpdates = Pick<Repo, 'gitRemoteIdentity' | 'upstream'>
type RepoIdentitySnapshot = Pick<Partial<Repo>, 'gitRemoteIdentity' | 'upstream'>
function buildProjectHostSetupResult(
store: Pick<ExistingFolderReconciliationStore, 'getProjects' | 'getProjectHostSetups'>,
repo: Repo
): ProjectHostSetupResult {
const setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
const project = store.getProjects().find((entry) => entry.id === setup.projectId)
if (!project) {
throw new Error(`Project setup was created without a project record: ${setup.projectId}`)
}
return { project, setup, repo }
}
async function probeOwnedRepoRemotes(
repo: Repo,
ownedExecutionHostId: ExecutionHostId
): Promise<GitRemoteIdentityProbe> {
const hostId = getRepoExecutionHostId(repo)
// A record owned by another host says nothing about this one, and probing it
// locally would run git against a path that does not exist here.
if (hostId !== ownedExecutionHostId) {
return { status: 'unavailable' }
}
// Route to the connection named by the host id we just authorized, not to
// `repo.connectionId`: on a record whose two fields disagree, the latter would run
// git on a different machine than the one this probe was cleared for.
const parsed = parseExecutionHostId(hostId)
const connectionId = parsed?.kind === 'ssh' ? parsed.targetId : null
return await probeGitRemoteIdentity(repo.path, connectionId, {
timeoutMs: EXISTING_FOLDER_REMOTE_PROBE_TIMEOUT_MS
})
}
function planRemoteIdentityUpdate(
repo: Repo,
probe: GitRemoteIdentityProbe,
match: ProjectRemoteMatch
): RepoIdentityUpdates {
if (probe.status === 'resolved') {
const matched = probe.remotes.find((remote) => matchesProject(remote, match))
if (!matched) {
throw new Error(PROJECT_IDENTITY_MISMATCH_MESSAGE)
}
// Persist the matching remote, not the primary: a fork checkout's primary is
// `upstream`, which would land the repo in a third project.
return isSameRemoteIdentity(repo.gitRemoteIdentity, matched)
? {}
: { gitRemoteIdentity: matched }
}
// Never clear a stale non-null identity with the settled `null` marker: that demotes
// the repo to `repo:<repoId>` and drops it out of its current project for nothing.
if (
probe.status === 'no-remote' &&
repo.gitRemoteIdentity !== null &&
getProjectIdentityKey(repo).startsWith('repo:')
) {
return { gitRemoteIdentity: null }
}
return {}
}
function requireProjectRemoteMatch(
store: Pick<ExistingFolderReconciliationStore, 'getProjects'>,
projectId: string
): { project: Project; match: ProjectRemoteMatch } {
const project = store.getProjects().find((entry) => entry.id === projectId)
if (!project) {
throw new Error(`Project not found: ${projectId}`)
}
const match = projectRemoteMatch(project)
if (match.identityKey === null) {
throw new Error(PROJECT_IDENTITY_UNRESOLVED_MESSAGE)
}
return { project, match }
}
/** The fallback stamps the project's identity without a positive remote match, so it must
* not run over a settled remote that names somewhere else: a git Orca could not read is
* no evidence the folder belongs here. Only repos with no usable identity of their own —
* pending, `repo:`-keyed, or the settled no-remote marker — can be linked blind. */
function mayStampProjectIdentity(
identity: Repo['gitRemoteIdentity'],
probe: GitRemoteIdentityProbe,
match: ProjectRemoteMatch
): boolean {
if (probe.status === 'resolved') {
// A resolved probe already matched (§4 rejected the rest), so its remote is the evidence.
return true
}
return !identity || matchesProject(identity, match)
}
/** Decides accept/reject without touching the store, so a rejected import writes nothing.
* Runs against the record the store holds now, not the one the add path handed back. */
function planIdentityUpdates(args: {
repo: Repo
projectId: string
project: Project
match: ProjectRemoteMatch
probe: GitRemoteIdentityProbe
}): RepoIdentityUpdates {
const { repo, projectId, project, match, probe } = args
const updates = planRemoteIdentityUpdate(repo, probe, match)
const planned = { ...repo, ...updates }
if (getProjectIdentityKey(planned) === projectId) {
return updates
}
// The same remote can land in different namespaces when only one side carries
// GitHub metadata; stamping the selected project's identity closes that gap.
const providerIdentity = project.providerIdentity
if (
providerIdentity?.provider === 'github' &&
mayStampProjectIdentity(planned.gitRemoteIdentity, probe, match)
) {
const withUpstream: RepoIdentityUpdates = {
...updates,
upstream: {
owner: providerIdentity.owner,
repo: providerIdentity.repo,
...(providerIdentity.host ? { host: providerIdentity.host } : {})
}
}
if (getProjectIdentityKey({ ...repo, ...withUpstream }) !== projectId) {
throw new Error(PROJECT_IDENTITY_MISMATCH_MESSAGE)
}
return withUpstream
}
// Generic git projects never synthesize identity, and clearing the repo's GitHub
// metadata to force a match would re-key it out of its real project.
throw new Error(rejectionMessage(repo, updates, probe))
}
function rejectionMessage(
repo: Repo,
updates: RepoIdentityUpdates,
probe: GitRemoteIdentityProbe
): string {
if (isGitHubBackedRepo({ ...repo, ...updates })) {
return REPO_GITHUB_METADATA_OUTRANKS_PROJECT_MESSAGE
}
// An unread remote is not a mismatch: a repo owned by another host, or one whose probe
// failed, was never compared against anything. Saying "does not match" blames a folder
// Orca never looked at and leaves the user with nothing to act on.
if (probe.status === 'unavailable' && !isFolderRepo(repo)) {
return PROJECT_IDENTITY_REMOTE_UNREADABLE_MESSAGE
}
return PROJECT_IDENTITY_MISMATCH_MESSAGE
}
/** A rejected import must not leave its identity writes behind (§7): the refresh itself
* can re-project the repo out of the project it was already in. Restores the exact
* pre-write snapshot of both identity fields — including back to absent, which is a
* different state from the `null` markers (a `null` upstream is terminal for the fork
* backfill, a `null` identity falsely settles "this folder has no remote"). Nothing is
* written when the record already matches the snapshot, so a write the store dropped
* costs no second mutation. */
function revertIdentityWrites(
store: ExistingFolderReconciliationStore,
repoId: string,
previous: RepoIdentitySnapshot,
current: Repo
): void {
const restore: RepoIdentitySnapshot = {}
if (!isSameUpstream(current.upstream, previous.upstream)) {
restore.upstream = previous.upstream
}
if (!isSameRemoteIdentity(current.gitRemoteIdentity, previous.gitRemoteIdentity)) {
restore.gitRemoteIdentity = previous.gitRemoteIdentity
}
if (!('upstream' in restore) && !('gitRemoteIdentity' in restore)) {
return
}
store.restoreRepoIdentityFields(repoId, restore)
}
/** True when reconciliation actually rewrote the repo's identity fields. Callers that
* already broadcast for the add itself use this to skip a second full-list refresh. */
export function didReconciliationChangeRepoIdentity(before: Repo, after: Repo): boolean {
return (
!isSameUpstream(before.upstream, after.upstream) ||
!isSameRemoteIdentity(before.gitRemoteIdentity, after.gitRemoteIdentity)
)
}
/** Every read and write below races a user deleting the project, so each one has to say
* which step lost the record. */
function requireLiveRepo(repo: Repo | null | undefined, repoId: string, stage: string): Repo {
if (!repo) {
throw new Error(`Project setup repo disappeared before ${stage}: ${repoId}`)
}
return repo
}
/** The identity fields as they stood before the write, with an absent field left as an
* absent key so the rollback can tell "never set" from the `null` markers. */
function snapshotIdentityFields(repo: Repo): RepoIdentitySnapshot {
return {
...(repo.upstream !== undefined ? { upstream: repo.upstream } : {}),
...(repo.gitRemoteIdentity !== undefined ? { gitRemoteIdentity: repo.gitRemoteIdentity } : {})
}
}
/**
* Single identity policy for "add this existing folder to the selected project",
* shared by local IPC, SSH IPC, and runtime-host setup. Acceptance is decided from
* the folder's current canonical remotes, never from projected project ids alone.
*/
export async function reconcileExistingFolderProjectIdentity(
args: ExistingFolderReconciliationArgs
): Promise<ProjectHostSetupResult> {
// Every rejection leaves through `ExistingFolderReconciliationError` so callers never
// have to infer from the message whether the store was touched before the throw.
const progress = { storeChanged: false }
try {
return await reconcileWithinStore(args, progress)
} catch (error) {
if (error instanceof ExistingFolderReconciliationError) {
throw error
}
throw new ExistingFolderReconciliationError(
error instanceof Error ? error.message : String(error),
progress.storeChanged,
{ cause: error }
)
}
}
async function reconcileWithinStore(
args: ExistingFolderReconciliationArgs,
progress: { storeChanged: boolean }
): Promise<ProjectHostSetupResult> {
const { store, projectId } = args
let repo = args.repo
const setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
if (setup.projectId !== projectId) {
const { project, match } = requireProjectRemoteMatch(store, projectId)
// Probe from the record the add path returned: its `kind` and execution host are what
// the ownership check authorized.
const probe: GitRemoteIdentityProbe = isFolderRepo(repo)
? { status: 'unavailable' }
: await probeOwnedRepoRemotes(repo, args.ownedExecutionHostId)
// Plan and snapshot from the store, not from `args.repo`: the probe above is the one
// `await` in this function, and background identity enrichment can land inside it.
// Planning against a stale view would either discard that write on rollback or
// conclude "already matching" while the store is still behind.
const linkStage = 'it could be linked'
const live = requireLiveRepo(store.getRepo(repo.id), repo.id, linkStage)
const previous = snapshotIdentityFields(live)
const updates = planIdentityUpdates({ repo: live, projectId, project, match, probe })
repo = live
const wroteIdentity = Object.keys(updates).length > 0
if (wroteIdentity) {
progress.storeChanged = true
repo = requireLiveRepo(store.updateRepo(repo.id, updates), repo.id, linkStage)
}
// The plan predicts from an in-memory record; only the store knows what its
// sanitizers kept and what a concurrent writer changed underneath it.
if (getProjectHostSetupForRepo(store.getProjectHostSetups(), repo).projectId !== projectId) {
// Only a write can need reverting: with none, the store holds nothing this flow put
// there, and writing over its own fields would be a mutation nothing announces.
if (wroteIdentity) {
revertIdentityWrites(store, repo.id, previous, repo)
}
throw new Error(PROJECT_IDENTITY_MISMATCH_MESSAGE)
}
// The repo just moved projects, so meta stamped from the old projection has to follow
// it before this flow's caller notifies.
remapWorktreeMetaToRepoProject(store, repo)
}
// Only a reconciled repo is marked as an imported/cloned setup for this project.
progress.storeChanged = true
const updated = store.updateRepo(repo.id, {
projectHostSetupMethod: args.setupMethod ?? 'imported-existing-folder'
})
return buildProjectHostSetupResult(
store,
requireLiveRepo(updated, repo.id, 'setup metadata could be linked')
)
}
@@ -0,0 +1,30 @@
import { getProjectHostSetupWorktreeMeta } from '../shared/project-host-setup-projection'
import type { ProjectHostSetup, Repo, WorktreeMeta } from '../shared/types'
type WorktreeMetaProjectStore = {
getProjectHostSetups(): ProjectHostSetup[]
getAllWorktreeMeta(): Record<string, WorktreeMeta>
setWorktreeMeta(worktreeId: string, meta: Partial<WorktreeMeta>): unknown
}
/** An identity repair re-keys the repo into the selected project, which can drop the old project
* row entirely, orphaning every `WorktreeMeta` row `getProjectHostSetupWorktreeMeta` stamped from
* the old projection. Only already-stamped rows are touched: an unstamped row is legacy state
* this flow has no mandate to start owning. */
export function remapWorktreeMetaToRepoProject(store: WorktreeMetaProjectStore, repo: Repo): void {
const stamp = getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo)
// Worktree meta ids are `<repoId>::<path>` (folder workspace instances add a suffix).
const prefix = `${repo.id}::`
for (const [worktreeId, meta] of Object.entries(store.getAllWorktreeMeta())) {
if (!worktreeId.startsWith(prefix) || !meta.projectId) {
continue
}
if (
meta.projectId === stamp.projectId &&
meta.projectHostSetupId === stamp.projectHostSetupId
) {
continue
}
store.setWorktreeMeta(worktreeId, stamp)
}
}
+78
View File
@@ -0,0 +1,78 @@
import { getProjectIdentityKey } from '../shared/project-host-setup-projection'
import type { GitRemoteIdentity } from '../shared/git-remote-identity'
import type { Project, Repo } from '../shared/types'
/** `git:`-sourced keys compare byte-for-byte (path case is significant on generic git
* servers); provider-derived keys compare case-insensitively because
* `githubRepoIdentityKey` lowercases the slug while `normalizeGitRemoteUrl` does not. */
type ProjectCanonicalKey = { key: string; caseSensitive: boolean }
export type ProjectRemoteMatch = {
canonicalKeys: ProjectCanonicalKey[]
/** The key the project itself projects to. Running a remote through the same
* builder folds spellings the literal keys cannot: GHES endpoint ports, which
* `parseGitHubRemoteUrl` keeps and `normalizeGitRemoteUrl` drops, and the
* `ssh.github.com` alias. `null` when the project has no identity at all. */
identityKey: string | null
}
export function projectRemoteMatch(project: Project): ProjectRemoteMatch {
const canonicalKeys: ProjectCanonicalKey[] = []
const gitKey = project.gitRemoteIdentity?.canonicalKey?.trim()
if (gitKey) {
canonicalKeys.push({ key: gitKey, caseSensitive: true })
}
const identity = project.providerIdentity
const owner = identity?.owner.trim()
const repo = identity?.repo.trim()
if (owner && repo) {
// Strip the GHES endpoint port: `normalizeGitRemoteUrl` keys every remote off the
// hostname, so a ported provider host could never match a probed remote at all.
const host = (identity?.host?.trim() || 'github.com').replace(/:\d+$/, '')
canonicalKeys.push({ key: `${host}/${owner}/${repo}`, caseSensitive: false })
}
const identityKey = getProjectIdentityKey({
id: '',
...(identity ? { upstream: identity } : {}),
...(project.gitRemoteIdentity ? { gitRemoteIdentity: project.gitRemoteIdentity } : {})
})
return { canonicalKeys, identityKey: identityKey.startsWith('repo:') ? null : identityKey }
}
export function matchesProject(remote: GitRemoteIdentity, match: ProjectRemoteMatch): boolean {
const matchesLiteralKey = match.canonicalKeys.some((entry) =>
entry.caseSensitive
? entry.key === remote.canonicalKey
: entry.key.toLowerCase() === remote.canonicalKey.toLowerCase()
)
return (
matchesLiteralKey ||
(match.identityKey !== null &&
getProjectIdentityKey({ id: '', gitRemoteIdentity: remote }) === match.identityKey)
)
}
export function isSameRemoteIdentity(
stored: Repo['gitRemoteIdentity'],
candidate: Repo['gitRemoteIdentity']
): boolean {
if (!stored || !candidate) {
return stored === candidate
}
return (
stored.canonicalKey === candidate.canonicalKey &&
stored.remoteName === candidate.remoteName &&
stored.remoteUrl === candidate.remoteUrl
)
}
export function isSameUpstream(stored: Repo['upstream'], candidate: Repo['upstream']): boolean {
if (!stored || !candidate) {
return stored === candidate
}
return (
stored.owner === candidate.owner &&
stored.repo === candidate.repo &&
(stored.host ?? null) === (candidate.host ?? null)
)
}
@@ -24,7 +24,11 @@ const remoteIdentity: GitRemoteIdentity = {
remoteUrl: 'git@git.company.test:team/sample-app.git'
}
const resolvedProbe: GitRemoteIdentityProbe = { status: 'resolved', identity: remoteIdentity }
const resolvedProbe: GitRemoteIdentityProbe = {
status: 'resolved',
identity: remoteIdentity,
remotes: [remoteIdentity]
}
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
+50 -5
View File
@@ -16,16 +16,61 @@ describe('probeGitRemoteIdentity', () => {
it('resolves the canonical identity for a non-GitHub remote', async () => {
vi.mocked(gitExecFileAsync).mockResolvedValue({ stdout: gitlabRemote, stderr: '' })
const identity = {
canonicalKey: 'gitlab.example.com/team/orca',
remoteName: 'origin',
remoteUrl: 'git@gitlab.example.com:team/orca.git'
}
await expect(probeGitRemoteIdentity('/repos/orca')).resolves.toEqual({
status: 'resolved',
identity: {
canonicalKey: 'gitlab.example.com/team/orca',
remoteName: 'origin',
remoteUrl: 'git@gitlab.example.com:team/orca.git'
}
identity,
remotes: [identity]
})
})
it('returns every canonical remote of a fork checkout, primary first', async () => {
vi.mocked(gitExecFileAsync).mockResolvedValue({
stdout: [
'origin\tgit@gitlab.example.com:ava/orca.git (fetch)',
'origin\tgit@gitlab.example.com:ava/orca.git (push)',
'upstream\tgit@gitlab.example.com:team/orca.git (fetch)',
'mirror\tgit@gitlab.example.com:team/orca.git (fetch)',
''
].join('\n'),
stderr: ''
})
const probe = await probeGitRemoteIdentity('/repos/orca')
expect(probe.status).toBe('resolved')
// `upstream` outranks `origin`, and the duplicate `mirror` URL is deduped away.
expect(
probe.status === 'resolved' && probe.remotes.map((remote) => remote.canonicalKey)
).toEqual(['gitlab.example.com/team/orca', 'gitlab.example.com/ava/orca'])
expect(probe.status === 'resolved' && probe.identity.remoteName).toBe('upstream')
})
it('bounds the local probe when a timeout is requested', async () => {
vi.mocked(gitExecFileAsync).mockResolvedValue({ stdout: gitlabRemote, stderr: '' })
await probeGitRemoteIdentity('/repos/orca', null, { timeoutMs: 3000 })
expect(gitExecFileAsync).toHaveBeenCalledWith(['remote', '-v'], {
cwd: '/repos/orca',
timeout: 3000
})
})
it('bounds the SSH probe and degrades a timeout to unavailable', async () => {
const exec = vi.fn().mockRejectedValue(new Error('command timed out'))
vi.mocked(getSshGitProvider).mockReturnValue({ exec } as never)
await expect(
probeGitRemoteIdentity('/repos/orca', 'builder', { timeoutMs: 3000 })
).resolves.toEqual({ status: 'unavailable' })
expect(exec).toHaveBeenCalledWith(['remote', '-v'], '/repos/orca', { timeoutMs: 3000 })
})
it('settles on no-remote when git answers with nothing usable', async () => {
vi.mocked(gitExecFileAsync).mockResolvedValue({ stdout: '', stderr: '' })
+38 -9
View File
@@ -1,28 +1,57 @@
import { deriveGitRemoteIdentity, type GitRemoteIdentity } from '../shared/git-remote-identity'
import { deriveGitRemoteIdentities, type GitRemoteIdentity } from '../shared/git-remote-identity'
import { gitExecFileAsync } from './git/runner'
import { getSshGitProvider } from './providers/ssh-git-dispatch'
/** `no-remote` means git answered and the repo has no usable remote;
* `unavailable` means the probe never reached git (host down, SSH not up
* yet, git error) and says nothing about the repo. */
* yet, git error, timeout) and says nothing about the repo. */
export type GitRemoteIdentityProbe =
| { status: 'resolved'; identity: GitRemoteIdentity }
/** `remotes[0]` is `identity`; the rest are the repo's other canonical remotes. */
| { status: 'resolved'; identity: GitRemoteIdentity; remotes: GitRemoteIdentity[] }
| { status: 'no-remote' }
| { status: 'unavailable' }
export type GitRemoteIdentityProbeOptions = {
/** Bounds the git call; a timeout degrades to `unavailable`. Unset = unbounded. */
timeoutMs?: number
}
// Spread at each use: callees take a mutable `string[]`, so never hand out this shared array.
const GIT_REMOTE_VERBOSE_ARGS = ['remote', '-v'] as const
async function execGitRemoteVerbose(
repoPath: string,
connectionId: string | null | undefined,
timeoutMs: number | undefined
): Promise<{ stdout: string } | undefined> {
if (connectionId) {
const provider = getSshGitProvider(connectionId)
if (!provider) {
return undefined
}
return timeoutMs === undefined
? await provider.exec([...GIT_REMOTE_VERBOSE_ARGS], repoPath)
: await provider.exec([...GIT_REMOTE_VERBOSE_ARGS], repoPath, { timeoutMs })
}
return await gitExecFileAsync([...GIT_REMOTE_VERBOSE_ARGS], {
cwd: repoPath,
...(timeoutMs === undefined ? {} : { timeout: timeoutMs })
})
}
export async function probeGitRemoteIdentity(
repoPath: string,
connectionId?: string | null
connectionId?: string | null,
options?: GitRemoteIdentityProbeOptions
): Promise<GitRemoteIdentityProbe> {
try {
const result = connectionId
? await getSshGitProvider(connectionId)?.exec(['remote', '-v'], repoPath)
: await gitExecFileAsync(['remote', '-v'], { cwd: repoPath })
const result = await execGitRemoteVerbose(repoPath, connectionId, options?.timeoutMs)
if (!result) {
return { status: 'unavailable' }
}
const identity = deriveGitRemoteIdentity(result.stdout)
return identity ? { status: 'resolved', identity } : { status: 'no-remote' }
const remotes = deriveGitRemoteIdentities(result.stdout)
const identity = remotes[0]
return identity ? { status: 'resolved', identity, remotes } : { status: 'no-remote' }
} catch {
// Repo creation must not fail because a best-effort remote probe failed.
return { status: 'unavailable' }
@@ -0,0 +1,41 @@
import { execFileSync } from 'node:child_process'
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
/**
* Test-only git environment that no ambient config can reach. A developer shell or CI
* runner carrying `url.<base>.insteadOf` — or Orca's own `GIT_CONFIG_COUNT` injection —
* rewrites what `git remote -v` prints, which silently changes the canonical remote key
* remote-identity tests assert on.
*
* Stays on the Git 2.25 baseline: `GIT_CONFIG_GLOBAL` needs 2.32, so global config is
* redirected by moving `HOME`/`USERPROFILE`/`XDG_CONFIG_HOME` instead.
*
* Apply the entries to `process.env` (`vi.stubEnv`) rather than to one spawn: the code
* under test spawns its own git and inherits the ambient environment. The caller owns
* `root` and must remove it, or every test that calls this leaves a temp dir behind.
*/
export function createIsolatedGitEnv(): { root: string; entries: Record<string, string> } {
const root = mkdtempSync(join(tmpdir(), 'orca-git-config-'))
return {
root,
entries: {
HOME: root,
USERPROFILE: root,
XDG_CONFIG_HOME: join(root, '.config'),
GIT_CONFIG_NOSYSTEM: '1',
// Overrides any inherited count; `GIT_CONFIG_KEY_*` entries past it are ignored.
GIT_CONFIG_COUNT: '0'
}
}
}
/** Initializes a repo with a single `origin`. Requires the isolated env to be applied. */
export function initGitRepoWithOrigin(repoPath: string, originUrl?: string): void {
// `git init -b` needs 2.28; the default branch name is irrelevant to these tests.
execFileSync('git', ['init'], { cwd: repoPath, stdio: 'ignore' })
if (originUrl) {
execFileSync('git', ['remote', 'add', 'origin', originUrl], { cwd: repoPath, stdio: 'ignore' })
}
}
+418 -138
View File
@@ -1,11 +1,10 @@
/* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import type * as GitUsernameModule from '../git/git-username'
import { performance } from 'node:perf_hooks'
import { EventEmitter } from 'node:events'
import { randomUUID } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import { mkdirSync } from 'node:fs'
import { mkdirSync, rmSync } from 'node:fs'
import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { basename, join, win32 } from 'node:path'
@@ -89,6 +88,7 @@ import {
} from '../../shared/agent-prompt-injection'
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text'
import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection'
import { createIsolatedGitEnv, initGitRepoWithOrigin } from './isolated-git-repo-fixture'
import {
registerSshFilesystemProvider,
unregisterSshFilesystemProvider
@@ -1553,6 +1553,65 @@ describe('OrcaRuntimeService.dedupeWorktreeCreate', () => {
})
})
/** Redirects git config for this test and for the git the runtime spawns itself, so an
* ambient `insteadOf` cannot rewrite the remote URL the assertions depend on. */
function useIsolatedGitEnv(): void {
const { root, entries } = createIsolatedGitEnv()
for (const [key, value] of Object.entries(entries)) {
vi.stubEnv(key, value)
}
onTestFinished(() => {
vi.unstubAllEnvs()
rmSync(root, { recursive: true, force: true })
})
}
/** Store fake for existing-folder setup: real projection over `repos`, plus the narrow
* rollback contract. `sanitize` stands in for persistence sanitizers, which can keep
* less than the caller asked for. */
function makeProjectSetupRuntimeStore(
repos: Record<string, unknown>[],
sanitize: (updates: Record<string, unknown>) => Record<string, unknown> = (updates) => updates
) {
return {
...store,
getRepos: () => [...repos] as never,
addRepo: (repo: Record<string, unknown>) => {
repos.push(repo)
},
getRepo: (id: string) => repos.find((repo) => repo.id === id) as never,
updateRepo: (id: string, updates: Record<string, unknown>) => {
const target = repos.find((repo) => repo.id === id)
if (!target) {
return null
}
Object.assign(target, sanitize(updates))
return { ...target } as never
},
// Mirrors `Store.restoreRepoIdentityFields`: a present key holding `undefined`
// clears the field, which `updateRepo` cannot express.
restoreRepoIdentityFields: (id: string, restore: Record<string, unknown>) => {
const target = repos.find((repo) => repo.id === id)
if (!target) {
return null
}
for (const field of ['upstream', 'gitRemoteIdentity']) {
if (!(field in restore)) {
continue
}
if (restore[field] === undefined) {
delete target[field]
} else {
target[field] = restore[field]
}
}
return { ...target } as never
},
getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never,
getProjectHostSetups: () => projectHostSetupProjectionFromRepos(repos as never).setups as never
}
}
describe('OrcaRuntimeService', () => {
it('projects runtime-backed settings to paired clients', () => {
const terminalQuickCommands = [
@@ -6790,63 +6849,24 @@ describe('OrcaRuntimeService', () => {
it('sets up an existing folder on a fresh runtime after importing the repo project', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-project-setup-'))
const repos: Record<string, unknown>[] = []
getRepoUpstreamMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
const runtimeStore = {
...store,
getRepos: () => [...repos] as never,
addRepo: (repo: Record<string, unknown>) => {
repos.push(repo)
},
getRepo: (id: string) => repos.find((repo) => repo.id === id) as never,
updateRepo: (id: string, updates: Record<string, unknown>) => {
const index = repos.findIndex((repo) => repo.id === id)
if (index === -1) {
return null
}
repos[index] = { ...repos[index], ...updates }
return repos[index] as never
},
getProjects: () =>
repos
.map((repo) => {
const upstream = repo.upstream as { owner: string; repo: string } | undefined
if (!upstream) {
return null
}
return {
id: `github:${upstream.owner}/${upstream.repo}`,
displayName: repo.displayName,
badgeColor: repo.badgeColor,
providerIdentity: { provider: 'github', owner: upstream.owner, repo: upstream.repo },
sourceRepoIds: [repo.id],
createdAt: repo.addedAt,
updatedAt: repo.addedAt
}
})
.filter(Boolean) as never,
getProjectHostSetups: () =>
repos.map((repo) => {
const upstream = repo.upstream as { owner: string; repo: string } | undefined
return {
id: repo.id,
projectId: upstream ? `github:${upstream.owner}/${upstream.repo}` : repo.id,
hostId: 'local',
repoId: repo.id,
path: repo.path,
displayName: repo.displayName,
kind: repo.kind,
setupState: 'ready',
setupMethod: repo.projectHostSetupMethod ?? 'legacy-repo',
createdAt: repo.addedAt,
updatedAt: repo.addedAt
}
}) as never
}
const runtime = new OrcaRuntimeService(runtimeStore as never)
// The project exists because another host already imported it; the fresh folder has to
// earn the same identity from its own remote, with nothing stamped at add time.
const repos: Record<string, unknown>[] = [
{
id: 'repo-imported',
path: '/imported/orca',
displayName: 'orca',
badgeColor: 'blue',
addedAt: 1,
kind: 'git',
upstream: { owner: 'stablyai', repo: 'orca' }
}
]
const runtime = new OrcaRuntimeService(makeProjectSetupRuntimeStore(repos) as never)
try {
execFileSync('git', ['init'], { cwd: tempRoot, stdio: 'ignore' })
useIsolatedGitEnv()
initGitRepoWithOrigin(tempRoot, 'https://github.com/stablyai/orca.git')
const result = await runtime.setupProjectExistingFolder({
projectId: 'github:stablyai/orca',
hostId: 'runtime:env-1',
@@ -6859,9 +6879,20 @@ describe('OrcaRuntimeService', () => {
expect(result.repo.path).toBe(tempRoot)
expect(result.setup).toMatchObject({
projectId: 'github:stablyai/orca',
hostId: 'runtime:env-1',
path: tempRoot,
setupMethod: 'imported-existing-folder'
})
expect(repos).toHaveLength(2)
expect(repos[1]).toMatchObject({
path: tempRoot,
executionHostId: 'runtime:env-1',
gitRemoteIdentity: {
canonicalKey: 'github.com/stablyai/orca',
remoteName: 'origin',
remoteUrl: 'https://github.com/stablyai/orca.git'
}
})
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
@@ -6871,29 +6902,12 @@ describe('OrcaRuntimeService', () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-project-host-'))
const repos: Record<string, unknown>[] = []
getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
const runtimeStore = {
...store,
getRepos: () => [...repos] as never,
addRepo: (repo: Record<string, unknown>) => {
repos.push(repo)
},
getRepo: (id: string) => repos.find((repo) => repo.id === id) as never,
updateRepo: (id: string, updates: Record<string, unknown>) => {
const index = repos.findIndex((repo) => repo.id === id)
if (index === -1) {
return null
}
repos[index] = { ...repos[index], ...updates }
return repos[index] as never
},
getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never,
getProjectHostSetups: () =>
projectHostSetupProjectionFromRepos(repos as never).setups as never
}
const runtimeStore = makeProjectSetupRuntimeStore(repos)
const runtime = new OrcaRuntimeService(runtimeStore as never)
try {
execFileSync('git', ['init'], { cwd: tempRoot, stdio: 'ignore' })
useIsolatedGitEnv()
initGitRepoWithOrigin(tempRoot)
const first = await runtime.setupProjectExistingFolder({
projectId: 'github:stablyai/orca',
hostId: 'runtime:env-1',
@@ -6943,6 +6957,323 @@ describe('OrcaRuntimeService', () => {
}
})
it('links a stale runtime folder record to a generic Git project by re-probing its remote', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-project-remote-'))
// Same path, other host: an already-stored record is returned unchanged by addRepo,
// so only reconciliation can repair the identity it never had.
const otherHostRepo = {
id: 'repo-host-a',
path: tempRoot,
displayName: 'orca',
badgeColor: 'blue',
addedAt: 1,
kind: 'git',
executionHostId: 'runtime:env-2',
gitRemoteIdentity: {
canonicalKey: 'gitlab.example.com/team/orca',
remoteName: 'origin',
remoteUrl: 'ssh://git@gitlab.example.com/team/orca.git'
}
}
const repos: Record<string, unknown>[] = [
otherHostRepo,
{
id: 'repo-stale',
path: tempRoot,
displayName: 'orca',
badgeColor: 'blue',
addedAt: 2,
kind: 'git',
executionHostId: 'runtime:env-1'
}
]
const runtime = new OrcaRuntimeService(makeProjectSetupRuntimeStore(repos) as never)
const reposChanged = vi.fn()
runtime.setNotifier({ reposChanged } as never)
try {
useIsolatedGitEnv()
initGitRepoWithOrigin(tempRoot, 'git@gitlab.example.com:team/orca.git')
const result = await runtime.setupProjectExistingFolder({
projectId: 'git:gitlab.example.com/team/orca',
hostId: 'runtime:env-1',
path: tempRoot,
kind: 'git',
setupMethod: 'imported-existing-folder'
})
expect(result.repo.id).toBe('repo-stale')
expect(result.setup).toMatchObject({
projectId: 'git:gitlab.example.com/team/orca',
hostId: 'runtime:env-1',
setupMethod: 'imported-existing-folder'
})
expect(result.repo.gitRemoteIdentity).toEqual({
canonicalKey: 'gitlab.example.com/team/orca',
remoteName: 'origin',
remoteUrl: 'git@gitlab.example.com:team/orca.git'
})
// The env-2 record shares the path but not the host, so this import must not touch it.
expect(repos).toHaveLength(2)
expect(repos[0]).toEqual(otherHostRepo)
// `addRepo` returned the stored record without announcing, so the identity repair owes
// clients the broadcast.
expect(reposChanged).toHaveBeenCalled()
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})
it('rejects a runtime existing folder whose remote belongs to another project', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-project-conflict-'))
// Both records carry `upstream` so the one-shot fork backfill `setNotifier` starts
// finds nothing to write and cannot broadcast over the assertion below.
const repos: Record<string, unknown>[] = [
{
id: 'repo-host-a',
path: '/elsewhere/orca',
displayName: 'orca',
badgeColor: 'blue',
addedAt: 1,
kind: 'git',
executionHostId: 'runtime:env-2',
upstream: null,
gitRemoteIdentity: {
canonicalKey: 'gitlab.example.com/team/orca',
remoteName: 'origin',
remoteUrl: 'git@gitlab.example.com:team/orca.git'
}
},
{
id: 'repo-stale',
path: tempRoot,
displayName: 'orca',
badgeColor: 'blue',
addedAt: 2,
kind: 'git',
executionHostId: 'runtime:env-1',
upstream: null
}
]
const runtime = new OrcaRuntimeService(makeProjectSetupRuntimeStore(repos) as never)
const reposChanged = vi.fn()
runtime.setNotifier({ reposChanged } as never)
try {
useIsolatedGitEnv()
initGitRepoWithOrigin(tempRoot, 'git@gitlab.example.com:other/orca.git')
await expect(
runtime.setupProjectExistingFolder({
projectId: 'git:gitlab.example.com/team/orca',
hostId: 'runtime:env-1',
path: tempRoot,
kind: 'git',
setupMethod: 'imported-existing-folder'
})
).rejects.toThrow('Imported folder does not match the selected project identity.')
// A rejected import writes nothing: no repaired identity, no setup claim.
expect(repos).toHaveLength(2)
expect(repos[1]).not.toHaveProperty('projectHostSetupMethod')
expect(repos[1]).not.toHaveProperty('gitRemoteIdentity')
// Nothing changed, so nothing is announced — the counterpart to the rollback case,
// which must announce.
expect(reposChanged).not.toHaveBeenCalled()
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})
it('rolls a rejected runtime import back to absent and re-announces the repos', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-project-rollback-'))
const repos: Record<string, unknown>[] = [
{
id: 'repo-host-a',
path: '/elsewhere/orca',
displayName: 'orca',
badgeColor: 'blue',
addedAt: 1,
kind: 'git',
executionHostId: 'runtime:env-2',
upstream: { owner: 'acme', repo: 'orca', host: 'git.acme-corp.com' }
},
{
id: 'repo-stale',
path: tempRoot,
displayName: 'orca',
badgeColor: 'blue',
addedAt: 2,
kind: 'folder',
executionHostId: 'runtime:env-1'
}
]
// A store that drops the Enterprise host lands the folder in the same-named
// github.com project, so the write is refused only after it has landed.
const runtimeStore = makeProjectSetupRuntimeStore(repos, (updates) =>
updates.upstream
? {
...updates,
upstream: {
owner: (updates.upstream as Record<string, unknown>).owner,
repo: (updates.upstream as Record<string, unknown>).repo
}
}
: updates
)
const runtime = new OrcaRuntimeService(runtimeStore as never)
const reposChanged = vi.fn()
runtime.setNotifier({ reposChanged } as never)
try {
await expect(
runtime.setupProjectExistingFolder({
projectId: 'github:git.acme-corp.com/acme/orca',
hostId: 'runtime:env-1',
path: tempRoot,
kind: 'folder',
setupMethod: 'imported-existing-folder'
})
).rejects.toThrow('Imported folder does not match the selected project identity.')
// Absent, not `null`: `null` reads as "already resolved" to the fork backfill.
expect(repos[1]).not.toHaveProperty('upstream')
expect(repos[1]).not.toHaveProperty('projectHostSetupMethod')
// The write and its rollback both landed, so clients hold a stale record either way.
expect(reposChanged).toHaveBeenCalled()
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})
it('announces a first-time runtime import once, not once per write', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-project-first-import-'))
// Backs the project so the projection can resolve it. The imported path is new, so
// `addRepo` creates and announces a record whose detected upstream already keys into the
// project — reconciliation writes only the setup stamp, which needs no second broadcast.
const repos: Record<string, unknown>[] = [
{
id: 'repo-host-a',
path: '/elsewhere/orca',
displayName: 'orca',
badgeColor: 'blue',
addedAt: 1,
kind: 'git',
executionHostId: 'runtime:env-2',
upstream: { owner: 'acme', repo: 'orca' }
}
]
const runtime = new OrcaRuntimeService(makeProjectSetupRuntimeStore(repos) as never)
const reposChanged = vi.fn()
runtime.setNotifier({ reposChanged } as never)
try {
useIsolatedGitEnv()
initGitRepoWithOrigin(tempRoot, 'git@github.com:acme/orca.git')
const result = await runtime.setupProjectExistingFolder({
projectId: 'github:acme/orca',
hostId: 'runtime:env-1',
path: tempRoot,
kind: 'git',
setupMethod: 'imported-existing-folder'
})
expect(result.setup).toMatchObject({ projectId: 'github:acme/orca' })
expect(reposChanged).toHaveBeenCalledTimes(1)
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})
it('refuses to set up an SSH-hosted existing folder from the runtime service', async () => {
const repos: Record<string, unknown>[] = [
{
id: 'repo-host-a',
path: '/elsewhere/orca',
displayName: 'orca',
badgeColor: 'blue',
addedAt: 1,
kind: 'git',
gitRemoteIdentity: {
canonicalKey: 'gitlab.example.com/team/orca',
remoteName: 'origin',
remoteUrl: 'git@gitlab.example.com:team/orca.git'
}
}
]
const runtime = new OrcaRuntimeService(makeProjectSetupRuntimeStore(repos) as never)
// This service only runs git on its own filesystem: accepting `ssh:` would stamp a
// remote host onto a record it validated locally, then probe that remote machine.
await expect(
runtime.setupProjectExistingFolder({
projectId: 'git:gitlab.example.com/team/orca',
hostId: 'ssh:conn-1',
path: '/srv/orca',
kind: 'git',
setupMethod: 'imported-existing-folder'
})
).rejects.toThrow('desktop projectHostSetups.setupExistingFolder IPC')
expect(repos).toHaveLength(1)
})
it('refuses an ssh host before the project clone writes anything', async () => {
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-'))
const clonePath = join(destination, 'orca')
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
const repos: Record<string, unknown>[] = []
const runtimeStore = makeProjectSetupRuntimeStore(repos)
spawnSpy.mockImplementation(() => {
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
proc.stderr = new EventEmitter()
queueMicrotask(() => {
mkdirSync(clonePath, { recursive: true })
initGitRepoWithOrigin(clonePath)
proc.emit('close', 0, null)
})
return proc as never
})
const runtime = new OrcaRuntimeService(runtimeStore as never)
try {
useIsolatedGitEnv()
// The clone persists and broadcasts a repo record of its own, so refusing the host
// only in `setupProjectExistingFolder` would leave that record and a working copy.
await expect(
runtime.setupProjectClone({
projectId: 'git:gitlab.example.com/team/orca',
hostId: 'ssh:conn-1',
url: 'https://example.com/orca.git',
destination
})
).rejects.toThrow('desktop projectHostSetups.setupExistingFolder IPC')
expect(spawnSpy).not.toHaveBeenCalled()
expect(repos).toHaveLength(0)
} finally {
spawnSpy.mockRestore()
await rm(destination, { recursive: true, force: true })
}
})
it('reports runtime_unavailable before adding a repo the store cannot project', async () => {
const repos: Record<string, unknown>[] = []
const { restoreRepoIdentityFields: _unsupported, ...withoutRollback } =
makeProjectSetupRuntimeStore(repos)
const runtime = new OrcaRuntimeService(withoutRollback as never)
await expect(
runtime.setupProjectExistingFolder({
projectId: 'git:gitlab.example.com/team/orca',
hostId: 'runtime:env-1',
path: '/work/orca',
kind: 'folder'
})
).rejects.toThrow('runtime_unavailable')
// Checked before the add, or a store that cannot project leaves an orphan record.
expect(repos).toHaveLength(0)
})
it('keeps path-only runtime addRepo reuse working when a host-qualified repo already exists', async () => {
const repos: Record<string, unknown>[] = [
{
@@ -7062,31 +7393,13 @@ describe('OrcaRuntimeService', () => {
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
const repos: Record<string, unknown>[] = []
getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
const runtimeStore = {
...store,
getRepos: () => [...repos] as never,
addRepo: (repo: Record<string, unknown>) => {
repos.push(repo)
},
getRepo: (id: string) => repos.find((repo) => repo.id === id) as never,
updateRepo: (id: string, updates: Record<string, unknown>) => {
const index = repos.findIndex((repo) => repo.id === id)
if (index === -1) {
return null
}
repos[index] = { ...repos[index], ...updates }
return repos[index] as never
},
getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never,
getProjectHostSetups: () =>
projectHostSetupProjectionFromRepos(repos as never).setups as never
}
const runtimeStore = makeProjectSetupRuntimeStore(repos)
spawnSpy.mockImplementation(() => {
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
proc.stderr = new EventEmitter()
queueMicrotask(() => {
mkdirSync(clonePath, { recursive: true })
execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' })
initGitRepoWithOrigin(clonePath)
proc.emit('close', 0, null)
})
return proc as never
@@ -7094,6 +7407,7 @@ describe('OrcaRuntimeService', () => {
const runtime = new OrcaRuntimeService(runtimeStore as never)
try {
useIsolatedGitEnv()
const result = await runtime.setupProjectClone({
projectId: 'github:stablyai/orca',
hostId: 'runtime:env-1',
@@ -7124,31 +7438,13 @@ describe('OrcaRuntimeService', () => {
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
const repos: Record<string, unknown>[] = []
getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
const runtimeStore = {
...store,
getRepos: () => [...repos] as never,
addRepo: (repo: Record<string, unknown>) => {
repos.push(repo)
},
getRepo: (id: string) => repos.find((repo) => repo.id === id) as never,
updateRepo: (id: string, updates: Record<string, unknown>) => {
const index = repos.findIndex((repo) => repo.id === id)
if (index === -1) {
return null
}
repos[index] = { ...repos[index], ...updates }
return repos[index] as never
},
getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never,
getProjectHostSetups: () =>
projectHostSetupProjectionFromRepos(repos as never).setups as never
}
const runtimeStore = makeProjectSetupRuntimeStore(repos)
spawnSpy.mockImplementation(() => {
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
proc.stderr = new EventEmitter()
queueMicrotask(() => {
mkdirSync(clonePath, { recursive: true })
execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' })
initGitRepoWithOrigin(clonePath)
proc.emit('close', 0, null)
})
return proc as never
@@ -7156,6 +7452,7 @@ describe('OrcaRuntimeService', () => {
const runtime = new OrcaRuntimeService(runtimeStore as never)
try {
useIsolatedGitEnv()
const cloned = await runtime.cloneRepo('https://example.com/orca.git', destination)
expect(cloned).toMatchObject({
path: clonePath
@@ -7204,31 +7501,13 @@ describe('OrcaRuntimeService', () => {
}
]
getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
const runtimeStore = {
...store,
getRepos: () => [...repos] as never,
addRepo: (repo: Record<string, unknown>) => {
repos.push(repo)
},
getRepo: (id: string) => repos.find((repo) => repo.id === id) as never,
updateRepo: (id: string, updates: Record<string, unknown>) => {
const index = repos.findIndex((repo) => repo.id === id)
if (index === -1) {
return null
}
repos[index] = { ...repos[index], ...updates }
return repos[index] as never
},
getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never,
getProjectHostSetups: () =>
projectHostSetupProjectionFromRepos(repos as never).setups as never
}
const runtimeStore = makeProjectSetupRuntimeStore(repos)
spawnSpy.mockImplementation(() => {
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
proc.stderr = new EventEmitter()
queueMicrotask(() => {
mkdirSync(clonePath, { recursive: true })
execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' })
initGitRepoWithOrigin(clonePath)
proc.emit('close', 0, null)
})
return proc as never
@@ -7236,6 +7515,7 @@ describe('OrcaRuntimeService', () => {
const runtime = new OrcaRuntimeService(runtimeStore as never)
try {
useIsolatedGitEnv()
const result = await runtime.setupProjectClone({
projectId: 'github:stablyai/orca',
hostId: 'runtime:env-2',
+88 -40
View File
@@ -172,6 +172,7 @@ import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree-removal'
import {
LOCAL_EXECUTION_HOST_ID,
getRepoExecutionHostId,
normalizeExecutionHostId,
parseExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
@@ -235,10 +236,12 @@ import {
splitWorktreeId,
splitWorktreeIdForFilesystem
} from '../../shared/worktree-id'
import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection'
import {
getProjectHostSetupForRepo,
getProjectHostSetupWorktreeMeta
} from '../../shared/project-host-setup-projection'
didReconciliationChangeRepoIdentity,
didReconciliationChangeStore,
reconcileExistingFolderProjectIdentity
} from '../project-host-existing-folder-reconciliation'
import { parsePtySessionId } from '../../shared/pty-session-id-format'
import { clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits'
import { isFolderRepo } from '../../shared/repo-kind'
@@ -963,6 +966,7 @@ type RuntimeStore = {
getRepo: Store['getRepo']
addRepo: Store['addRepo']
updateRepo: Store['updateRepo']
restoreRepoIdentityFields?: Store['restoreRepoIdentityFields']
getProjects?: Store['getProjects']
updateProject?: Store['updateProject']
getProjectHostSetups?: Store['getProjectHostSetups']
@@ -14910,54 +14914,98 @@ export class OrcaRuntimeService {
return result
}
/** The checks both project-setup entry points must pass before anything is written.
* `setupProjectClone` clones first, so leaving them to `setupProjectExistingFolder`
* would refuse only after a full working copy and a persisted, broadcast repo record
* stamped with the refused host had already landed. */
private requireOwnedProjectSetupHost(hostId: ExecutionHostId): {
store: RuntimeStore
restoreRepoIdentityFields: NonNullable<RuntimeStore['restoreRepoIdentityFields']>
ownedExecutionHostId: ExecutionHostId
} {
const store = this.store
// A store without project capabilities projects no projects at all. Check it before
// `addRepo` so a missing capability cannot leave an orphan repo record behind.
if (!store?.getProjects || !store.getProjectHostSetups || !store.restoreRepoIdentityFields) {
throw new Error('runtime_unavailable')
}
// This service only ever runs git on its own filesystem, so it owns exactly the host
// it was addressed as — but it can never be an SSH host: `addRepo` validates and
// reads the path locally, so accepting `ssh:` would stamp a remote host onto a record
// inspected here and then send the identity probe to that remote machine.
const ownedExecutionHostId = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID
if (parseExecutionHostId(ownedExecutionHostId)?.kind === 'ssh') {
throw new Error(
'SSH hosts must be set up through the desktop projectHostSetups.setupExistingFolder IPC.'
)
}
return {
store,
restoreRepoIdentityFields: store.restoreRepoIdentityFields.bind(store),
ownedExecutionHostId
}
}
async setupProjectExistingFolder(
args: ProjectHostSetupExistingFolderArgs
): Promise<ProjectHostSetupResult> {
if (!this.store) {
throw new Error('runtime_unavailable')
const { store, restoreRepoIdentityFields, ownedExecutionHostId } =
this.requireOwnedProjectSetupHost(args.hostId)
const knownRepoHostsById = new Map(
store.getRepos().map((entry) => [entry.id, entry.executionHostId ?? null])
)
const repo = await this.addRepo(
args.path,
args.kind === 'folder' ? 'folder' : 'git',
args.hostId
)
// `addRepo` broadcasts when it creates or host-adopts a record; broadcasting again on
// success would force a second full renderer repo/project refresh for nothing.
const addRepoNotified =
!knownRepoHostsById.has(repo.id) ||
knownRepoHostsById.get(repo.id) !== (repo.executionHostId ?? null)
const reconciliationStore = {
getProjects: () => this.listProjects(),
getProjectHostSetups: () => this.listProjectHostSetups(),
getRepo: (id: string) => store.getRepo(id),
updateRepo: (id: string, updates: Parameters<Store['updateRepo']>[1]) =>
store.updateRepo(id, updates),
restoreRepoIdentityFields,
getAllWorktreeMeta: () => store.getAllWorktreeMeta(),
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) =>
store.setWorktreeMeta(worktreeId, meta)
}
let repo = await this.addRepo(args.path, args.kind === 'folder' ? 'folder' : 'git', args.hostId)
let setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo)
if (setup.projectId !== args.projectId) {
const existingProject = this.listProjects().find((project) => project.id === args.projectId)
if (
!existingProject?.providerIdentity ||
existingProject.providerIdentity.provider !== 'github'
) {
throw new Error('Imported folder does not match the selected project identity.')
}
const updated = this.store.updateRepo(repo.id, {
upstream: {
owner: existingProject.providerIdentity.owner,
repo: existingProject.providerIdentity.repo,
...(existingProject.providerIdentity.host
? { host: existingProject.providerIdentity.host }
: {})
}
let result: ProjectHostSetupResult
try {
result = await reconcileExistingFolderProjectIdentity({
store: reconciliationStore,
repo,
projectId: args.projectId,
...(args.setupMethod ? { setupMethod: args.setupMethod } : {}),
ownedExecutionHostId
})
if (!updated) {
throw new Error(`Project setup repo disappeared before it could be linked: ${repo.id}`)
} catch (error) {
// `addRepo` already notified for a brand-new record, but a rejection that still
// mutated the store leaves every client holding a stale repo without a second one.
if (didReconciliationChangeStore(error)) {
this.invalidateResolvedWorktreeCache()
this.notifyReposChanged()
}
repo = updated
setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo)
throw error
}
const setupMethod = args.setupMethod ?? 'imported-existing-folder'
const updated = this.store.updateRepo(repo.id, { projectHostSetupMethod: setupMethod })
if (!updated) {
throw new Error(
`Project setup repo disappeared before setup metadata could be linked: ${repo.id}`
)
this.invalidateResolvedWorktreeCache()
// Reconciliation can move the repo between projects, and only an identity write does
// that — the setup-method stamp it always writes is already in the returned result.
if (!addRepoNotified || didReconciliationChangeRepoIdentity(repo, result.repo)) {
this.notifyReposChanged()
}
repo = updated
setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo)
const project = this.listProjects().find((entry) => entry.id === setup.projectId)
if (!project) {
throw new Error(`Project setup was created without a project record: ${setup.projectId}`)
}
return { project, setup, repo }
return result
}
async setupProjectClone(args: ProjectHostSetupCloneArgs): Promise<ProjectHostSetupResult> {
// `cloneRepo` writes and broadcasts a repo record of its own, so the host has to be
// refused here rather than by the `setupProjectExistingFolder` call below.
this.requireOwnedProjectSetupHost(args.hostId)
const repo = await this.cloneRepo(args.url, args.destination, args.hostId)
return await this.setupProjectExistingFolder({
projectId: args.projectId,
+65 -21
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { deriveGitRemoteIdentity, normalizeGitRemoteUrl } from './git-remote-identity'
import { deriveGitRemoteIdentities, normalizeGitRemoteUrl } from './git-remote-identity'
describe('normalizeGitRemoteUrl', () => {
it('normalizes HTTPS and SSH GitHub remotes to the same canonical key', () => {
@@ -44,35 +44,79 @@ describe('normalizeGitRemoteUrl', () => {
})
})
describe('deriveGitRemoteIdentity', () => {
it('prefers upstream, then origin, then the first named remote', () => {
describe('deriveGitRemoteIdentities', () => {
it('lists every remote in primary precedence order and drops unusable ones', () => {
expect(
deriveGitRemoteIdentity(
deriveGitRemoteIdentities(
[
'mirror\tC:\\Repos\\sample-app.git (fetch)',
'origin\tgit@git.company.test:forks/sample-app.git (fetch)',
'upstream\thttps://git.company.test/team/sample-app.git (fetch)'
].join('\r\n')
)
).toEqual([
{
canonicalKey: 'git.company.test/team/sample-app',
remoteName: 'upstream',
remoteUrl: 'https://git.company.test/team/sample-app.git'
},
{
canonicalKey: 'git.company.test/forks/sample-app',
remoteName: 'origin',
remoteUrl: 'git@git.company.test:forks/sample-app.git'
}
])
})
it('ignores push lines and falls back to the first named remote', () => {
expect(
deriveGitRemoteIdentities(
[
'origin\tgit@git.company.test:forks/sample-app.git (fetch)',
'origin\tgit@git.company.test:forks/sample-app.git (push)',
'upstream\thttps://git.company.test/team/sample-app.git (fetch)',
'upstream\thttps://git.company.test/team/sample-app.git (push)'
].join('\n')
).map((remote) => remote.remoteName)
).toEqual(['upstream', 'origin'])
expect(
deriveGitRemoteIdentities('mirror\tgit@git.company.test:team/sample-app.git (fetch)')
).toMatchObject([{ canonicalKey: 'git.company.test/team/sample-app', remoteName: 'mirror' }])
})
it('collapses remote names that share one URL', () => {
expect(
deriveGitRemoteIdentities(
[
'origin\tgit@git.company.test:team/sample-app.git (fetch)',
'github\tgit@git.company.test:team/sample-app.git (fetch)'
].join('\n')
)
).toEqual({
canonicalKey: 'git.company.test/team/sample-app',
remoteName: 'upstream',
remoteUrl: 'https://git.company.test/team/sample-app.git'
})
).toEqual([
{
canonicalKey: 'git.company.test/team/sample-app',
remoteName: 'origin',
remoteUrl: 'git@git.company.test:team/sample-app.git'
}
])
})
expect(
deriveGitRemoteIdentity('origin\tgit@git.company.test:team/sample-app.git (fetch)')
).toMatchObject({
canonicalKey: 'git.company.test/team/sample-app',
remoteName: 'origin'
})
it('keeps two spellings of one canonical key so an endpoint port survives', () => {
// The port lives in the URL, not the canonical key, and decides which project a
// GHES clone belongs to; collapsing on the key alone would discard the only
// spelling that can match a ported project.
const remotes = deriveGitRemoteIdentities(
[
'upstream\tgit@ghe.company.test:team/sample-app.git (fetch)',
'origin\thttps://ghe.company.test:8443/team/sample-app.git (fetch)'
].join('\n')
)
expect(
deriveGitRemoteIdentity('mirror\tgit@git.company.test:team/sample-app.git (fetch)')
).toMatchObject({
canonicalKey: 'git.company.test/team/sample-app',
remoteName: 'mirror'
})
expect(remotes.map((remote) => remote.remoteUrl)).toEqual([
'git@ghe.company.test:team/sample-app.git',
'https://ghe.company.test:8443/team/sample-app.git'
])
expect(new Set(remotes.map((remote) => remote.canonicalKey)).size).toBe(1)
})
})
+19 -9
View File
@@ -81,7 +81,12 @@ function primaryRemoteSortKey(entry: GitRemoteEntry): number {
return 2
}
export function deriveGitRemoteIdentity(stdout: string): GitRemoteIdentity | null {
/** Every remote with a usable canonical key, in primary-remote precedence order — so
* `[0]` is the probe's primary `identity`. Only byte-identical URLs
* collapse: the canonical key alone is too coarse, because a GHES endpoint port lives
* in the URL and not in the key, so two spellings of one repo can still belong to two
* different projects. */
export function deriveGitRemoteIdentities(stdout: string): GitRemoteIdentity[] {
const entries = parseGitRemoteVerboseOutput(stdout)
.map((entry) => ({
...entry,
@@ -92,12 +97,17 @@ export function deriveGitRemoteIdentity(stdout: string): GitRemoteIdentity | nul
const priority = primaryRemoteSortKey(left) - primaryRemoteSortKey(right)
return priority === 0 ? left.name.localeCompare(right.name) : priority
})
const selected = entries[0]
return selected
? {
canonicalKey: selected.canonicalKey,
remoteName: selected.name,
remoteUrl: selected.url
}
: null
const bySpelling = new Map<string, GitRemoteIdentity>()
for (const entry of entries) {
// `\0`, never a raw NUL byte: a raw NUL makes git treat this source file as binary.
const spelling = `${entry.canonicalKey}\0${entry.url}`
if (!bySpelling.has(spelling)) {
bySpelling.set(spelling, {
canonicalKey: entry.canonicalKey,
remoteName: entry.name,
remoteUrl: entry.url
})
}
}
return [...bySpelling.values()]
}