mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
* fix(workspaces): gate the GitHub palette number match on repo identity `repoMatchesGitHubSlug` returned the permissive `'unknown'` whenever the repo displayName was not in `owner/repo` form and no upstream metadata existed — the common basename-named non-fork case. The caller only rejects on `false`, so a pasted issue/PR URL could activate a workspace in a different repo that happened to share the number, since issue/PR numbers are per-repo. Mirror the GitLab gate from #14381: fall back to the probed `gitRemoteIdentity.canonicalKey` before giving up, comparing host and owner/repo after normalizing port, `www.`, and case. An `upstream`-derived identity stays `'unknown'` because `deriveGitRemoteIdentity` ranks `upstream` above `origin`, so a fork's own origin is invisible and rejecting would drop URLs from the fork the user actually checked out. The canonicalKey compare runs after the displayName branch: displayName is compared host-agnostically, so mirrors and host aliases of the same owner/repo keep matching as they do today, and the probed remote only fills in where no name evidence exists. Refs STA-4237 * fix(workspaces): keep SSH host aliases matching in the palette identity gate `git remote -v` reports ssh.github.com, www., and ~/.ssh/config `Host` aliases verbatim, so comparing a probed canonicalKey against a pasted URL host rejected legitimate GitHub/GitLab remotes. Normalize the alias hosts both sides can fold offline, and downgrade a host-only mismatch to 'unknown' when the probed host is dotless (an unexpandable OpenSSH alias); dotted hosts like ghe.example.com still lose. Lifts the GitHub host normalizer into shared instead of a third copy. * fix(repos): keep the www host fold out of the derived project identity getProjectIdentityKey feeds the persisted Project id, so folding www. there re-keyed existing projects on upgrade and dropped localWindowsRuntimePreference. Restrict the fold to the palette's URL-vs-remote comparison, and pin the derived id for a www. remote so it cannot drift silently again.
144 lines
3.8 KiB
TypeScript
144 lines
3.8 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 a probed remote against a pasted URL. A host-only mismatch is `'unknown'` when the
|
|
* probed host is an SSH alias, since `git remote -v` reports it unexpanded.
|
|
*/
|
|
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()
|
|
if (!line.endsWith('(fetch)')) {
|
|
continue
|
|
}
|
|
const match = /^(\S+)\s+(.+?)\s+\(fetch\)$/.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
|
|
}
|