Files
orca/src/shared/git-remote-identity.ts
T
Brennan Benson 29a5f89b8a Keep same-machine folders with matching Git remotes in separate projects (#32272)
* WIP: same-host same-remote checkouts keep their own project id

* WIP: claim the shared project id per checkout surface; drop the display-only clone split

* WIP: index derived ids by repo in the hydration normalizer

* Resolve workspace project affiliation from current checkout owners

* fix: preserve concrete project ownership across catalogs and operations

* fix: combine recent project selector imports

* fix: enforce canonical catalog reads and preserve search continuity

* fix: scope catalog lint exceptions to renderer files

* Resolve project search ownership before native candidate limits

* Combine duplicate session scope imports

* fix: derive returned history row ownership on the host

* Reconcile full session history replies by content

* Require authoritative project catalogs for terminal snapshots

* Preserve registered project claims and partial history results

* test: check orphaned setup cleanup state without assertions

* fix: align catalog callers and native search fixture contracts

* test: check full repo shapes in runtime routing fixtures

* Fix checked catalog inputs in compiler regression fixtures

* Keep checked PTY fixture repos mutable

* test: align catalog fixtures with canonical readers

* test: check catalog and runtime fixture inputs

* test: check catalog fixtures without outer assertions

* test: follow persistence harness relocation

* Adapt catalog test inputs to integrated main checks

* Test History hashing across cached depth revalidation

* test(settings): expect host-qualified setup removal

* test: capture host split listings and scope relay recovery

* ci: regenerate merge ref

* test: type host-split listing diagnostics
2026-10-26 19:07:06 -07:00

149 lines
4.3 KiB
TypeScript

import { isUnresolvedSshHostAlias } from './git-remote-host-alias'
export type GitRemoteIdentity = {
canonicalKey: string
remoteName: string
remoteUrl: string
}
export type GitRemoteKeyParts = {
host: string
tail: string
}
/** Split a `canonicalKey` into `host` + path tail so the host can be alias-normalized. */
export function splitGitRemoteKey(
canonicalKey: string | null | undefined,
normalizeHost: (host: string) => string
): GitRemoteKeyParts | null {
const key = canonicalKey?.trim().replace(/\/+$/, '').toLowerCase() ?? ''
const separator = key.indexOf('/')
if (separator <= 0 || separator === key.length - 1) {
return null
}
return {
host: normalizeHost(key.slice(0, separator).replace(/:\d+$/, '')),
tail: key.slice(separator + 1)
}
}
/** Compare pasted/probed remote identities; a host-only mismatch is unknown when the probed host is an unexpanded SSH alias. */
export function matchGitRemoteKeyParts(
identity: GitRemoteKeyParts,
target: GitRemoteKeyParts
): boolean | 'unknown' {
if (identity.tail !== target.tail) {
return false
}
if (identity.host === target.host) {
return true
}
return isUnresolvedSshHostAlias(identity.host) ? 'unknown' : false
}
type GitRemoteEntry = {
name: string
url: string
}
function stripGitSuffix(path: string): string {
return path.endsWith('.git') ? path.slice(0, -4) : path
}
function normalizeRemotePath(path: string): string {
return stripGitSuffix(path.replace(/^\/+/, '').replace(/\/+$/, ''))
}
function normalizeRemoteHost(host: string): string {
return host.trim().toLowerCase()
}
function isLocalFilesystemRemote(remoteUrl: string): boolean {
return /^[A-Za-z]:[\\/]/.test(remoteUrl)
}
export function normalizeGitRemoteUrl(remoteUrl: string): string | null {
const trimmed = remoteUrl.trim()
if (!trimmed) {
return null
}
if (isLocalFilesystemRemote(trimmed)) {
return null
}
const scpMatch = trimmed.includes('://') ? null : /^([^@\s:]+@)?([^:\s]+):(.+)$/.exec(trimmed)
if (scpMatch) {
const host = normalizeRemoteHost(scpMatch[2] ?? '')
const path = normalizeRemotePath(scpMatch[3] ?? '')
return host && path ? `${host}/${path}` : null
}
try {
const parsed = new URL(trimmed)
const host = normalizeRemoteHost(parsed.hostname)
const path = normalizeRemotePath(parsed.pathname)
return host && path ? `${host}/${path}` : null
} catch {
return null
}
}
export function parseGitRemoteVerboseOutput(stdout: string): GitRemoteEntry[] {
const entries: GitRemoteEntry[] = []
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim()
// Partial clones append their object filter after the fetch marker.
const match = /^(\S+)\s+(.+?)\s+\(fetch\)(?:\s+\[[^\]\r\n]*\])?$/.exec(line)
if (!match) {
continue
}
const name = match[1]?.trim()
const url = match[2]?.trim()
if (name && url) {
entries.push({ name, url })
}
}
return entries
}
function primaryRemoteSortKey(entry: GitRemoteEntry): number {
if (entry.name === 'upstream') {
return 0
}
if (entry.name === 'origin') {
return 1
}
return 2
}
export function deriveGitRemoteIdentity(stdout: string): GitRemoteIdentity | null {
const entries = parseGitRemoteVerboseOutput(stdout)
.map((entry) => ({
...entry,
canonicalKey: normalizeGitRemoteUrl(entry.url)
}))
.filter((entry): entry is GitRemoteEntry & { canonicalKey: string } => !!entry.canonicalKey)
.sort((left, right) => {
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
}
export function normalizeSavedGitRemoteIdentity(
identity: GitRemoteIdentity | null | undefined
): GitRemoteIdentity | null {
const canonicalKey =
typeof identity?.canonicalKey === 'string' ? identity.canonicalKey.trim() : ''
const remoteName = typeof identity?.remoteName === 'string' ? identity.remoteName.trim() : ''
const remoteUrl = typeof identity?.remoteUrl === 'string' ? identity.remoteUrl.trim() : ''
return canonicalKey && remoteName && remoteUrl ? { canonicalKey, remoteName, remoteUrl } : null
}