mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
Bind review pushes to execution-host endpoint authority
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest'
|
||||
import { gitExecFileAsync } from './command-runner/git-exec-file'
|
||||
import { readGitRemoteTrackingRef } from '../../shared/git-remote-tracking-ref'
|
||||
import { getPublishTargetStatus } from '../../shared/git-publish-target-status'
|
||||
|
||||
let root: string
|
||||
let env: NodeJS.ProcessEnv
|
||||
const run = (args: string[]) => gitExecFileAsync(args, { cwd: root, env, timeout: 10_000 })
|
||||
const git = async (...args: string[]) => (await run(args)).stdout.trim()
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-refspec-abbreviation-'))
|
||||
env = {
|
||||
...process.env,
|
||||
HOME: root,
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_CONFIG_GLOBAL: join(root, 'empty-config')
|
||||
}
|
||||
await git('init', '-q')
|
||||
await git(
|
||||
'-c',
|
||||
'user.name=Fixture',
|
||||
'-c',
|
||||
'user.email=fixture@example.invalid',
|
||||
'-c',
|
||||
'commit.gpgSign=false',
|
||||
'commit',
|
||||
'--allow-empty',
|
||||
'-qm',
|
||||
'base'
|
||||
)
|
||||
await git('branch', '-M', 'feature')
|
||||
await git('clone', '--bare', '-q', root, join(root, 'remote.git'))
|
||||
await git('remote', 'add', 'origin', join(root, 'remote.git'))
|
||||
})
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['feature:refs/custom/exact-feature', 'refs/custom/exact-feature'],
|
||||
['heads/feature:refs/custom/exact-feature', 'refs/custom/exact-feature'],
|
||||
['feature:custom', 'refs/heads/custom'],
|
||||
['feature:heads/tracked', 'refs/heads/tracked'],
|
||||
['feature:tags/tracked', 'refs/tags/tracked'],
|
||||
['feature:remotes/origin/tracked', 'refs/remotes/origin/tracked']
|
||||
])('corroborates source and destination like real Git for %s', async (mapping, ref) => {
|
||||
await git('config', 'remote.origin.fetch', mapping)
|
||||
await git('fetch', '-q', 'origin')
|
||||
expect(await readGitRemoteTrackingRef(run, 'origin', 'feature')).toBe(ref)
|
||||
expect(
|
||||
await getPublishTargetStatus(run, { remoteName: 'origin', branchName: 'feature' })
|
||||
).toMatchObject({ hasUpstream: true, upstreamIdentity: { trackingRef: ref } })
|
||||
const before = await git('for-each-ref', '--format=%(refname) %(objectname)')
|
||||
await git('fetch', '--dry-run', 'origin')
|
||||
expect(await git('for-each-ref', '--format=%(refname) %(objectname)')).toBe(before)
|
||||
})
|
||||
|
||||
it('does not relabel a higher-ranked tag or a missing source as the review branch', async () => {
|
||||
await git('--git-dir', join(root, 'remote.git'), 'tag', 'feature')
|
||||
await git('config', 'remote.origin.fetch', 'feature:refs/custom/from-tag')
|
||||
await git('fetch', '-q', 'origin')
|
||||
expect(await git('rev-parse', '--verify', 'refs/custom/from-tag')).toBeTruthy()
|
||||
expect(await readGitRemoteTrackingRef(run, 'origin', 'feature')).toBeNull()
|
||||
await git('config', 'remote.origin.fetch', 'missing:refs/custom/from-tag')
|
||||
expect(await readGitRemoteTrackingRef(run, 'origin', 'missing')).toBeNull()
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { reviewHeadKey } from '../../shared/git-review-push-authority'
|
||||
import type { GitUpstreamStatus } from '../../shared/git-status-types'
|
||||
import type { GitPushTarget } from '../../shared/worktree/types'
|
||||
import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe'
|
||||
@@ -9,10 +10,17 @@ import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight
|
||||
* compile error instead of a silently shared lease between two different targets.
|
||||
*/
|
||||
function pushTargetKeyParts(pushTarget: GitPushTarget): readonly unknown[] {
|
||||
const { remoteName, branchName, remoteUrl, remoteCreated, ...rest } = pushTarget
|
||||
const { remoteName, branchName, remoteUrl, remoteCreated, reviewHead, ...rest } = pushTarget
|
||||
const exhaustive: Record<string, never> = rest
|
||||
void exhaustive
|
||||
return ['explicit-target', remoteName, branchName, remoteUrl ?? null, remoteCreated ?? null]
|
||||
return [
|
||||
'explicit-target',
|
||||
remoteName,
|
||||
branchName,
|
||||
remoteUrl ?? null,
|
||||
remoteCreated ?? null,
|
||||
reviewHeadKey(reviewHead) ?? null
|
||||
]
|
||||
}
|
||||
|
||||
export type GitUpstreamStatusExecutionIdentity =
|
||||
|
||||
+11
-10
@@ -284,11 +284,18 @@ describe('git remote operations', () => {
|
||||
})
|
||||
|
||||
it('uses an explicit push target even when it differs from the local branch name', async () => {
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' })
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }).mockResolvedValueOnce({
|
||||
stdout: 'origin\thttps://github.com/team/repo.git (push)',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
await gitPush('/repo', false, {
|
||||
reviewHead: {
|
||||
provider: 'github',
|
||||
host: 'github.com',
|
||||
repository: 'team/repo',
|
||||
branchName: 'contributor/fix-sidebar'
|
||||
},
|
||||
remoteName: 'origin',
|
||||
branchName: 'contributor/fix-sidebar'
|
||||
})
|
||||
@@ -297,13 +304,7 @@ describe('git remote operations', () => {
|
||||
['push', '--set-upstream', 'origin', 'HEAD:refs/heads/contributor/fix-sidebar'],
|
||||
{ cwd: '/repo' }
|
||||
)
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[['check-ref-format', '--branch', 'contributor/fix-sidebar'], { cwd: '/repo' }],
|
||||
[
|
||||
['push', '--set-upstream', 'origin', 'HEAD:refs/heads/contributor/fix-sidebar'],
|
||||
{ cwd: '/repo' }
|
||||
]
|
||||
])
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', '-v'], { cwd: '/repo' })
|
||||
})
|
||||
|
||||
it('passes --force-with-lease when requested', async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertGitReviewPushAuthority } from '../../shared/git-review-push-authority'
|
||||
import {
|
||||
normalizeGitErrorMessage,
|
||||
runPullWithDivergenceFallback
|
||||
@@ -38,6 +39,10 @@ export async function gitPush(
|
||||
try {
|
||||
if (pushTarget) {
|
||||
await validateGitPushTarget(worktreePath, pushTarget, options)
|
||||
await assertGitReviewPushAuthority(
|
||||
(args) => gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options)),
|
||||
pushTarget
|
||||
)
|
||||
}
|
||||
// Why: push to the branch's configured upstream when one exists. PR-created
|
||||
// worktrees can track a contributor fork remote; hardcoding origin here
|
||||
|
||||
@@ -73,7 +73,9 @@ it.each([
|
||||
trackingRef: upstreamBefore!.upstreamRef
|
||||
})
|
||||
const pushTarget = { remoteName: remote, branchName: expectedBranch }
|
||||
expect(hasUsableHostedReviewPushTarget({ pushTarget, upstreamStatus: statusBefore })).toBe(true)
|
||||
expect(hasUsableHostedReviewPushTarget({ pushTarget, upstreamStatus: statusBefore })).toBe(
|
||||
false
|
||||
)
|
||||
const { upstreamIdentity: _identity, ...oldPeerStatus } = statusBefore
|
||||
expect(hasUsableHostedReviewPushTarget({ pushTarget, upstreamStatus: oldPeerStatus })).toBe(
|
||||
false
|
||||
@@ -86,7 +88,7 @@ it.each([
|
||||
upstreamName: 'unrelated/display/label'
|
||||
}
|
||||
})
|
||||
).toBe(true)
|
||||
).toBe(false)
|
||||
const watch = (trackingRef?: string) =>
|
||||
resolveGitStatusUpstreamRef(
|
||||
(args) => run(args),
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('getPRForBranch', () => {
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['api', 'repos/stablyai/orca/pulls/1738'], {
|
||||
cwd: '/repo-root'
|
||||
})
|
||||
expect(target).toEqual({
|
||||
expect(target).toMatchObject({
|
||||
pushTarget: {
|
||||
remoteName: 'pr-prateek-orca',
|
||||
branchName: 'prateek/fix-sidebar-agents-toggle',
|
||||
@@ -134,7 +134,7 @@ describe('getPRForBranch', () => {
|
||||
})
|
||||
getRemoteUrlForRepoMock.mockResolvedValueOnce('git@github.com:stablyai/orca.git')
|
||||
|
||||
await expect(getPullRequestPushTarget('/repo-root', 1738)).resolves.toEqual({
|
||||
await expect(getPullRequestPushTarget('/repo-root', 1738)).resolves.toMatchObject({
|
||||
pushTarget: {
|
||||
remoteName: 'pr-prateek-orca',
|
||||
branchName: 'prateek/fix-sidebar-agents-toggle',
|
||||
@@ -145,6 +145,10 @@ describe('getPRForBranch', () => {
|
||||
})
|
||||
|
||||
it('omits maintainerCanModify when the API does not report the flag', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: 'origin\thttps://github.com/stablyai/orca.git (push)',
|
||||
stderr: ''
|
||||
})
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
||||
getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
@@ -162,7 +166,7 @@ describe('getPRForBranch', () => {
|
||||
})
|
||||
})
|
||||
|
||||
await expect(getPullRequestPushTarget('/repo-root', 1738)).resolves.toEqual({
|
||||
await expect(getPullRequestPushTarget('/repo-root', 1738)).resolves.toMatchObject({
|
||||
pushTarget: {
|
||||
remoteName: 'origin',
|
||||
branchName: 'fix-sidebar'
|
||||
@@ -171,6 +175,10 @@ describe('getPRForBranch', () => {
|
||||
})
|
||||
|
||||
it('uses origin for same-repository PR push targets', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: 'origin\thttps://github.com/stablyai/orca.git (push)',
|
||||
stderr: ''
|
||||
})
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
||||
getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
@@ -188,13 +196,13 @@ describe('getPRForBranch', () => {
|
||||
})
|
||||
})
|
||||
|
||||
await expect(getPullRequestPushTarget('/repo-root', 1738)).resolves.toEqual({
|
||||
await expect(getPullRequestPushTarget('/repo-root', 1738)).resolves.toMatchObject({
|
||||
pushTarget: {
|
||||
remoteName: 'origin',
|
||||
branchName: 'fix-sidebar'
|
||||
}
|
||||
})
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', '-v'], { cwd: '/repo-root' })
|
||||
})
|
||||
|
||||
it('keeps getRepoSlug origin-based on a fork checkout (#7331)', async () => {
|
||||
@@ -286,6 +294,10 @@ describe('getPRForBranch', () => {
|
||||
})
|
||||
|
||||
it('routes GHES push-target probes through the Enterprise host', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: 'origin\thttps://github.acme-corp.com/team/orca.git (push)',
|
||||
stderr: ''
|
||||
})
|
||||
const ghes = { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' }
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({
|
||||
candidates: [ghes],
|
||||
@@ -307,7 +319,7 @@ describe('getPRForBranch', () => {
|
||||
})
|
||||
})
|
||||
|
||||
await expect(getPullRequestPushTarget('/repo-root', 7)).resolves.toEqual({
|
||||
await expect(getPullRequestPushTarget('/repo-root', 7)).resolves.toMatchObject({
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature' }
|
||||
})
|
||||
// Why: the candidate probe must pin options.host so the runner targets the
|
||||
@@ -342,7 +354,7 @@ describe('getPRForBranch', () => {
|
||||
})
|
||||
})
|
||||
|
||||
await expect(getPullRequestPushTarget('/repo-root', 7)).resolves.toEqual({
|
||||
await expect(getPullRequestPushTarget('/repo-root', 7)).resolves.toMatchObject({
|
||||
pushTarget: {
|
||||
remoteName: 'pr-team-orca',
|
||||
branchName: 'feature',
|
||||
@@ -352,6 +364,10 @@ describe('getPRForBranch', () => {
|
||||
})
|
||||
|
||||
it('probes additional PR repo candidates when the first lookup is not found', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: 'origin\thttps://github.com/fork/orca.git (push)',
|
||||
stderr: ''
|
||||
})
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({
|
||||
candidates: [
|
||||
{ owner: 'fork', repo: 'orca' },
|
||||
@@ -377,7 +393,7 @@ describe('getPRForBranch', () => {
|
||||
})
|
||||
})
|
||||
|
||||
await expect(getPullRequestPushTarget('/repo-root', 1849)).resolves.toEqual({
|
||||
await expect(getPullRequestPushTarget('/repo-root', 1849)).resolves.toMatchObject({
|
||||
pushTarget: {
|
||||
remoteName: 'origin',
|
||||
branchName: 'feature/test'
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { readGitReviewPushAuthority } from '../../../../shared/git-review-push-authority'
|
||||
import { requireSshGitProvider } from '../../../providers/ssh-git-dispatch'
|
||||
import type { IssueSourcePreference } from '../../../../shared/repo-types'
|
||||
import type { GitPushTarget } from '../../../../shared/worktree/types'
|
||||
import {
|
||||
ghExecFileAsync,
|
||||
gitExecFileAsync,
|
||||
acquire,
|
||||
release,
|
||||
ghRepoExecOptions,
|
||||
@@ -122,13 +125,26 @@ export async function getPullRequestPushTarget(
|
||||
if (!owner || !repo || !branchName || !cloneUrl || !sshUrl) {
|
||||
return null
|
||||
}
|
||||
const reviewHead = {
|
||||
provider: 'github' as const,
|
||||
host: matchedRepository.host ?? 'github.com',
|
||||
repository: `${owner}/${repo}`,
|
||||
branchName
|
||||
}
|
||||
if (
|
||||
origin &&
|
||||
githubRepoIdentityKey(origin) ===
|
||||
githubRepoIdentityKey({ owner, repo, host: matchedRepository.host })
|
||||
) {
|
||||
const pushTarget = { remoteName: 'origin', branchName, reviewHead }
|
||||
const runGit = connectionId
|
||||
? (args: string[]) => requireSshGitProvider(connectionId).exec(args, repoPath)
|
||||
: (args: string[]) => gitExecFileAsync(args, { cwd: repoPath, ...localGitOptions })
|
||||
if ((await readGitReviewPushAuthority(runGit, pushTarget)).kind !== 'verified') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
pushTarget: { remoteName: 'origin', branchName },
|
||||
pushTarget,
|
||||
...(maintainerCanModify !== undefined ? { maintainerCanModify } : {})
|
||||
}
|
||||
}
|
||||
@@ -142,6 +158,7 @@ export async function getPullRequestPushTarget(
|
||||
}
|
||||
return {
|
||||
pushTarget: {
|
||||
reviewHead,
|
||||
remoteName: sanitizeRemoteName(owner, repo),
|
||||
branchName,
|
||||
remoteUrl: pickPushRemoteUrl({ originUrl, cloneUrl, sshUrl })
|
||||
|
||||
@@ -1,123 +1 @@
|
||||
import { normalizeGitHubRemoteHost } from '../../shared/git-remote-host-alias'
|
||||
import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types'
|
||||
|
||||
export type GitHubRemoteIdentity = GitHubOwnerRepo & { host: string }
|
||||
|
||||
// Why: HTTP ports identify the GHES web/API endpoint; SSH and git ports are
|
||||
// transport-only and must not leak into gh's host identity.
|
||||
function hostFromRemoteUrl(url: URL): string {
|
||||
const protocol = url.protocol.toLowerCase()
|
||||
return protocol === 'http:' || protocol === 'https:' ? url.host : url.hostname
|
||||
}
|
||||
|
||||
function parseGitHubRemotePath(path: string): Pick<GitHubRemoteIdentity, 'owner' | 'repo'> | null {
|
||||
const parts = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/')
|
||||
if (parts.length !== 2) {
|
||||
return null
|
||||
}
|
||||
const [owner, repoWithSuffix] = parts
|
||||
const repo = repoWithSuffix.replace(/\.git$/i, '')
|
||||
if (!owner || !repo) {
|
||||
return null
|
||||
}
|
||||
return { owner, repo }
|
||||
}
|
||||
|
||||
/** SCP-style / ssh:// / git+ssh:// remotes may use an OpenSSH Host alias. */
|
||||
export function remoteUrlUsesSshTransport(remoteUrl: string): boolean {
|
||||
const trimmed = remoteUrl.trim().toLowerCase()
|
||||
return (
|
||||
trimmed.startsWith('git@') || trimmed.startsWith('ssh://') || trimmed.startsWith('git+ssh://')
|
||||
)
|
||||
}
|
||||
|
||||
/** SSH transport host as written, preserving SCP alias case. */
|
||||
export function rawSshTransportHost(remoteUrl: string): string | null {
|
||||
const trimmed = remoteUrl.trim()
|
||||
const scpMatch = trimmed.match(/^git@([^:]+):/i)
|
||||
if (scpMatch) {
|
||||
return scpMatch[1]
|
||||
}
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
if (!['ssh:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
return url.hostname || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Non-GitHub SSH host that may need OpenSSH alias expansion. */
|
||||
export function gitHubSshConfigHostAlias(remoteUrl: string): string | null {
|
||||
if (!remoteUrlUsesSshTransport(remoteUrl)) {
|
||||
return null
|
||||
}
|
||||
const identity = parseGitHubRemoteIdentity(remoteUrl)
|
||||
if (!identity || identity.host === 'github.com') {
|
||||
return null
|
||||
}
|
||||
return rawSshTransportHost(remoteUrl) ?? identity.host
|
||||
}
|
||||
|
||||
export function parseGitHubRemoteIdentity(remoteUrl: string): GitHubRemoteIdentity | null {
|
||||
const trimmed = remoteUrl.trim()
|
||||
const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/i)
|
||||
if (sshMatch) {
|
||||
return { host: normalizeGitHubRemoteHost(sshMatch[1]), owner: sshMatch[2], repo: sshMatch[3] }
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
if (!['git:', 'git+ssh:', 'http:', 'https:', 'ssh:'].includes(url.protocol.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
const path = parseGitHubRemotePath(url.pathname)
|
||||
return path ? { host: normalizeGitHubRemoteHost(hostFromRemoteUrl(url)), ...path } : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGitHubOwnerRepo(remoteUrl: string): GitHubOwnerRepo | null {
|
||||
const identity = parseGitHubRemoteIdentity(remoteUrl)
|
||||
if (!identity || identity.host.toLowerCase() !== 'github.com') {
|
||||
return null
|
||||
}
|
||||
return { owner: identity.owner, repo: identity.repo }
|
||||
}
|
||||
|
||||
/** Parse github.com identity using an expanded SSH HostName. */
|
||||
export function parseGitHubOwnerRepoWithResolvedSshHostname(
|
||||
remoteUrl: string,
|
||||
resolvedSshHostname: string | null | undefined
|
||||
): GitHubOwnerRepo | null {
|
||||
const direct = parseGitHubOwnerRepo(remoteUrl)
|
||||
if (direct) {
|
||||
return direct
|
||||
}
|
||||
if (!remoteUrlUsesSshTransport(remoteUrl)) {
|
||||
return null
|
||||
}
|
||||
if (!resolvedSshHostname?.trim()) {
|
||||
return null
|
||||
}
|
||||
const identity = parseGitHubRemoteIdentity(remoteUrl)
|
||||
if (!identity) {
|
||||
return null
|
||||
}
|
||||
if (normalizeGitHubRemoteHost(resolvedSshHostname.trim()) !== 'github.com') {
|
||||
return null
|
||||
}
|
||||
return { owner: identity.owner, repo: identity.repo }
|
||||
}
|
||||
|
||||
/** Effective forge host after optional SSH config HostName expansion. */
|
||||
export function effectiveGitHubRemoteHost(
|
||||
parsedHost: string,
|
||||
resolvedSshHostname?: string | null
|
||||
): string {
|
||||
const candidate = resolvedSshHostname?.trim() || parsedHost
|
||||
return normalizeGitHubRemoteHost(candidate)
|
||||
}
|
||||
export * from '../../shared/github/remote-identity-parsing'
|
||||
|
||||
@@ -132,8 +132,7 @@ describe('resolveGitHubPrStartPoint compare base', () => {
|
||||
baseBranch: 'same-repo-head-sha',
|
||||
compareBaseRef: 'refs/remotes/origin/main',
|
||||
headSha: 'same-repo-head-sha',
|
||||
branchNameOverride: 'feature/fix',
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
|
||||
branchNameOverride: 'feature/fix'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -164,8 +163,7 @@ describe('resolveGitHubPrStartPoint compare base', () => {
|
||||
expect(result).toEqual({
|
||||
baseBranch: 'same-repo-head-sha',
|
||||
headSha: 'same-repo-head-sha',
|
||||
branchNameOverride: 'feature/fix',
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
|
||||
branchNameOverride: 'feature/fix'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -469,6 +469,9 @@ describe('resolveGitHubPrStartPoint', () => {
|
||||
})
|
||||
|
||||
it('returns the verified head SHA, branch override, and push target when same-repo branch fetch succeeds', async () => {
|
||||
getPullRequestPushTargetMock.mockResolvedValue({
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/add-feature' }
|
||||
})
|
||||
const fetchRemoteTrackingRef = vi.fn(async () => {})
|
||||
const gitExec = vi.fn(async (args: string[]) => {
|
||||
const url = remoteGetUrl(args)
|
||||
|
||||
@@ -84,9 +84,7 @@ export async function resolveGitHubPrStartPoint(
|
||||
}
|
||||
}
|
||||
|
||||
if (isCrossRepository) {
|
||||
await resolvePushTarget()
|
||||
}
|
||||
await resolvePushTarget()
|
||||
|
||||
let remote: string
|
||||
try {
|
||||
@@ -243,6 +241,6 @@ export async function resolveGitHubPrStartPoint(
|
||||
...(compareBaseFetched && compareBaseRef ? { compareBaseRef } : {}),
|
||||
headSha,
|
||||
branchNameOverride: headRefName,
|
||||
pushTarget: { remoteName: remote, branchName: headRefName }
|
||||
...(pushTarget ? { pushTarget } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createServer } from 'node:net'
|
||||
import { once } from 'node:events'
|
||||
import { afterAll, beforeAll, expect, it, vi } from 'vitest'
|
||||
import { spawnProcess } from '../../shared/child-process/run-process'
|
||||
import { gitExecFileAsync as realGit } from '../git/command-runner/git-exec-file'
|
||||
import { getPublishTargetStatus } from '../../shared/git-publish-target-status'
|
||||
import { hasUsableHostedReviewPushTarget } from '../../shared/hosted-review-push-target-admission'
|
||||
import { resolveRelayPushTarget } from '../../relay/git-handler-push-target'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
root: '',
|
||||
endpoint: '',
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
pushes: [] as string[]
|
||||
}))
|
||||
vi.mock('./gh-utils', () => ({
|
||||
acquire: async () => {},
|
||||
release: () => {},
|
||||
githubRepoContext: () => ({}),
|
||||
ghRepoExecOptions: () => ({}),
|
||||
getRemoteUrlForRepo: async () => state.endpoint,
|
||||
gitExecFileAsync: (args: string[]) =>
|
||||
realGit(args, { cwd: state.root, env: state.env, timeout: 10_000 }),
|
||||
ghExecFileAsync: async () => ({
|
||||
stdout: JSON.stringify({
|
||||
head: {
|
||||
ref: 'feature',
|
||||
repo: {
|
||||
name: 'repo',
|
||||
owner: { login: 'team' },
|
||||
clone_url: state.endpoint,
|
||||
ssh_url: state.endpoint
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}))
|
||||
vi.mock('./github-api-repository', () => ({
|
||||
getGitHubApiRepositoryForRemote: async () => ({ host: '127.0.0.1', owner: 'team', repo: 'repo' }),
|
||||
githubHostExecOptions: () => ({})
|
||||
}))
|
||||
vi.mock('./client/pull-request-lookup-candidates', () => ({
|
||||
resolvePullRequestLookupCandidates: async () => [
|
||||
{ host: '127.0.0.1', owner: 'team', repo: 'repo' }
|
||||
]
|
||||
}))
|
||||
vi.mock('./client', async () => ({
|
||||
getPullRequestPushTarget: (await import('./client/lookup/pull-request-push-target'))
|
||||
.getPullRequestPushTarget,
|
||||
getWorkItem: async () => ({ type: 'pr', branchName: 'feature' })
|
||||
}))
|
||||
vi.mock('../git/runner', () => ({
|
||||
gitExecFileAsync: async (args: string[]) => {
|
||||
const result = await realGit(
|
||||
args[0] === 'push' ? ['push', '--dry-run', '--porcelain', ...args.slice(1)] : args,
|
||||
{ cwd: state.root, env: state.env, timeout: 10_000 }
|
||||
)
|
||||
if (args[0] === 'push') {
|
||||
state.pushes.push(result.stdout)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}))
|
||||
import { getPullRequestPushTarget } from './client/lookup/pull-request-push-target'
|
||||
import { resolveGitHubPrStartPoint } from './pr-start-point'
|
||||
import { gitPush } from '../git/remote'
|
||||
|
||||
let root: string
|
||||
let daemon: ReturnType<typeof spawnProcess>
|
||||
let other: string
|
||||
const run = (args: string[]) => realGit(args, { cwd: root, env: state.env, timeout: 10_000 })
|
||||
const git = async (...args: string[]) => (await run(args)).stdout.trim()
|
||||
const refs = () =>
|
||||
Promise.all(
|
||||
[root, join(root, 'team/repo.git'), join(root, 'other/repo.git')].map(
|
||||
async (cwd) =>
|
||||
(
|
||||
await realGit(['for-each-ref', '--format=%(refname) %(objectname)'], {
|
||||
cwd,
|
||||
env: state.env
|
||||
})
|
||||
).stdout
|
||||
)
|
||||
)
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-push-authority-'))
|
||||
state.root = root
|
||||
state.env = {
|
||||
...process.env,
|
||||
HOME: root,
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_CONFIG_GLOBAL: join(root, 'empty-config'),
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
}
|
||||
await git('init', '-q')
|
||||
await git(
|
||||
'-c',
|
||||
'user.name=Fixture',
|
||||
'-c',
|
||||
'user.email=fixture@example.invalid',
|
||||
'-c',
|
||||
'commit.gpgSign=false',
|
||||
'commit',
|
||||
'--allow-empty',
|
||||
'-qm',
|
||||
'base'
|
||||
)
|
||||
await git('branch', '-M', 'feature')
|
||||
for (const path of ['team/repo.git', 'other/repo.git']) {
|
||||
await git('clone', '--bare', '-q', root, join(root, path))
|
||||
}
|
||||
const server = createServer()
|
||||
server.listen(0, '127.0.0.1')
|
||||
await once(server, 'listening')
|
||||
const port = (server.address() as { port: number }).port
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
daemon = spawnProcess({
|
||||
program: 'git',
|
||||
args: [
|
||||
'daemon',
|
||||
'--verbose',
|
||||
'--export-all',
|
||||
'--enable=receive-pack',
|
||||
'--listen=127.0.0.1',
|
||||
`--port=${port}`,
|
||||
`--base-path=${root}`,
|
||||
root
|
||||
],
|
||||
cwd: root,
|
||||
env: state.env
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('Git daemon startup timed out')), 10_000)
|
||||
daemon.once('error', reject)
|
||||
daemon.stderr.on('data', (chunk) => {
|
||||
if (String(chunk).includes('Ready to rumble')) {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
state.endpoint = `git://127.0.0.1:${port}/team/repo.git`
|
||||
other = `git://127.0.0.1:${port}/other/repo.git`
|
||||
await git('remote', 'add', 'origin', state.endpoint)
|
||||
await git('fetch', '-q', 'origin')
|
||||
})
|
||||
afterAll(async () => {
|
||||
if (daemon) {
|
||||
expect(daemon.spawnargs).toContain(root)
|
||||
const closed = once(daemon, 'close')
|
||||
daemon.kill('SIGTERM')
|
||||
await closed
|
||||
}
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('carries shipping hydrated identity through status, admission, local and relay execution', async () => {
|
||||
const resolved = await resolveGitHubPrStartPoint({
|
||||
repoPath: root,
|
||||
prNumber: 42,
|
||||
gitExec: run,
|
||||
resolveRemote: async () => 'origin',
|
||||
fetchRemoteTrackingRef: async () => {
|
||||
await git('fetch', '-q', 'origin')
|
||||
},
|
||||
fetchPullRequestHeadRef: async () => {
|
||||
throw new Error('unexpected fork fetch')
|
||||
}
|
||||
})
|
||||
expect(resolved).not.toHaveProperty('error')
|
||||
if ('error' in resolved || !resolved.pushTarget) {
|
||||
throw new Error('Missing hydrated target')
|
||||
}
|
||||
const target = resolved.pushTarget
|
||||
const status = await getPublishTargetStatus(run, target)
|
||||
expect(
|
||||
hasUsableHostedReviewPushTarget({
|
||||
pushTarget: target,
|
||||
upstreamStatus: status,
|
||||
hasResolvableHostedReviewPushTargetLink: true
|
||||
})
|
||||
).toBe(true)
|
||||
const before = await refs()
|
||||
const config = await readFile(join(root, '.git/config'))
|
||||
await gitPush(root, false, target)
|
||||
expect(state.pushes.at(-1)).toContain(state.endpoint)
|
||||
const relay = await resolveRelayPushTarget((args) => run(args), root, target)
|
||||
await git('push', '--dry-run', '--porcelain', relay!.remote, relay!.refspec)
|
||||
expect(await refs()).toEqual(before)
|
||||
expect(await readFile(join(root, '.git/config'))).toEqual(config)
|
||||
|
||||
for (const urls of [[other], [state.endpoint, other]]) {
|
||||
await git('config', '--replace-all', 'remote.origin.pushurl', urls[0]!)
|
||||
if (urls[1]) {
|
||||
await git('config', '--add', 'remote.origin.pushurl', urls[1])
|
||||
}
|
||||
const configBefore = await readFile(join(root, '.git/config'))
|
||||
expect(await getPullRequestPushTarget(root, 42)).toBeNull()
|
||||
const denied = await getPublishTargetStatus(run, target)
|
||||
expect(
|
||||
hasUsableHostedReviewPushTarget({
|
||||
pushTarget: target,
|
||||
upstreamStatus: denied,
|
||||
hasResolvableHostedReviewPushTargetLink: true
|
||||
})
|
||||
).toBe(false)
|
||||
await expect(gitPush(root, false, target)).rejects.toThrow('authority')
|
||||
await expect(resolveRelayPushTarget((args) => run(args), root, target)).rejects.toThrow(
|
||||
'authority'
|
||||
)
|
||||
const control = await git(
|
||||
'push',
|
||||
'--dry-run',
|
||||
'--porcelain',
|
||||
'origin',
|
||||
'HEAD:refs/heads/feature'
|
||||
)
|
||||
for (const url of urls) {
|
||||
expect(control).toContain(url)
|
||||
}
|
||||
expect(await refs()).toEqual(before)
|
||||
expect(await readFile(join(root, '.git/config'))).toEqual(configBefore)
|
||||
}
|
||||
})
|
||||
@@ -245,6 +245,11 @@ export function mapMRToWorkItem(
|
||||
labels,
|
||||
updatedAt: data.updated_at ?? '',
|
||||
author: data.author?.username ?? null,
|
||||
...(projectRef &&
|
||||
data.source_project_id !== undefined &&
|
||||
data.source_project_id === data.target_project_id
|
||||
? { headProjectRef: projectRef }
|
||||
: {}),
|
||||
branchName: data.source_branch,
|
||||
baseRefName: data.target_branch,
|
||||
isCrossRepository:
|
||||
|
||||
@@ -1,128 +1 @@
|
||||
import type { GitLabProjectRef } from '../../shared/gitlab-types'
|
||||
|
||||
export type ProjectRef = GitLabProjectRef
|
||||
|
||||
/**
|
||||
* Hosts always treated as GitLab. Self-hosted instances are added at
|
||||
* runtime via `getGlabKnownHosts()`, which inspects `glab auth status`.
|
||||
*/
|
||||
export const DEFAULT_GITLAB_HOSTS = ['gitlab.com'] as const
|
||||
|
||||
export function normalizeGitLabHost(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
// Why: host recognition is port-aware so two services on the same hostname
|
||||
// but different ports (e.g. a GitLab on :8080 and a Gitea on :3030) are not
|
||||
// conflated. The hostname (port-less) part is kept for legacy known-host
|
||||
// entries that were recorded without a port.
|
||||
function hostnameOf(host: string): string {
|
||||
// `host` may be `name` or `name:port`. Strip a trailing `:digits` port.
|
||||
return host.replace(/:\d+$/, '')
|
||||
}
|
||||
|
||||
function stripGitSuffix(path: string): string {
|
||||
return path.replace(/\/+$/, '').replace(/\.git$/i, '')
|
||||
}
|
||||
|
||||
// Why: the GitLab host identity is the web/API endpoint, which is what `glab
|
||||
// --hostname` and the known-hosts list speak in terms of. For http(s)
|
||||
// remotes the URL port IS that endpoint port (e.g. self-hosted on :8080),
|
||||
// so it must be kept. For ssh/git remotes the port is a transport port
|
||||
// (e.g. ssh on :2222) that does not identify the GitLab instance, so it is
|
||||
// dropped and only the hostname is used.
|
||||
function hostIdentityFromUrl(url: URL): string {
|
||||
const protocol = url.protocol.toLowerCase()
|
||||
if (protocol === 'http:' || protocol === 'https:') {
|
||||
return url.host
|
||||
}
|
||||
return url.hostname
|
||||
}
|
||||
|
||||
function makeProjectRefForTrustedHost(host: string, path: string): ProjectRef | null {
|
||||
const normalizedHost = normalizeGitLabHost(host)
|
||||
const normalizedPath = stripGitSuffix(path.replace(/^\/+/, '')).trim()
|
||||
// Reject paths without at least one group segment — `gitlab.com:foo`
|
||||
// alone is not a project reference.
|
||||
if (!normalizedPath.includes('/')) {
|
||||
return null
|
||||
}
|
||||
return { host: normalizedHost, path: normalizedPath }
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `urlHost` (which may include a `:port`) match a known-host entry?
|
||||
* - An exact match (including any port) always counts.
|
||||
* - A known entry WITHOUT a port also matches a URL host on the same
|
||||
* hostname regardless of the URL's port — this preserves recognition for
|
||||
* legacy `gitlab.com` / bare-hostname known entries.
|
||||
* - A known entry WITH a port only matches a URL host with the exact same
|
||||
* port, so `gitlab.example.com:8443` does not accept a
|
||||
* `gitea.example.com:3000` (or same-host different-port) remote.
|
||||
*/
|
||||
function knownHostMatches(urlHost: string, knownHost: string): boolean {
|
||||
if (urlHost === knownHost) {
|
||||
return true
|
||||
}
|
||||
if (hostnameOf(knownHost) === knownHost) {
|
||||
// Known entry has no port — match on hostname alone.
|
||||
return hostnameOf(urlHost) === knownHost
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function makeProjectRef(
|
||||
host: string,
|
||||
path: string,
|
||||
knownHosts: readonly string[]
|
||||
): ProjectRef | null {
|
||||
const normalizedHost = normalizeGitLabHost(host)
|
||||
const normalizedKnownHosts = knownHosts.map(normalizeGitLabHost)
|
||||
if (!normalizedKnownHosts.some((knownHost) => knownHostMatches(normalizedHost, knownHost))) {
|
||||
return null
|
||||
}
|
||||
return makeProjectRefForTrustedHost(normalizedHost, path)
|
||||
}
|
||||
|
||||
export function parseRemoteProjectRefCandidate(remoteUrl: string): ProjectRef | null {
|
||||
const trimmed = remoteUrl.trim()
|
||||
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
||||
const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/)
|
||||
if (scpLike) {
|
||||
return makeProjectRefForTrustedHost(scpLike[1], scpLike[2])
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
return makeProjectRefForTrustedHost(hostIdentityFromUrl(url), url.pathname)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGitLabProjectRef(
|
||||
remoteUrl: string,
|
||||
knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS
|
||||
): ProjectRef | null {
|
||||
const trimmed = remoteUrl.trim()
|
||||
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
||||
const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/)
|
||||
if (scpLike) {
|
||||
return makeProjectRef(scpLike[1], scpLike[2], knownHosts)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
return makeProjectRef(hostIdentityFromUrl(url), url.pathname, knownHosts)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
export * from '../../shared/gitlab-project-ref-parser'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveStoredReviewPushTarget } from '../../worktree-review-push-target'
|
||||
import { ipcMain } from 'electron'
|
||||
import type { GitPushTarget } from '../../../../shared/worktree/types'
|
||||
import { gitFastForward, gitPull, gitPullRebaseFromBase, gitPush } from '../../../git/remote'
|
||||
@@ -33,6 +34,7 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl
|
||||
): Promise<void> => {
|
||||
// Why: coerce to strict boolean so a malformed payload (e.g. string 'false') can't enable --set-upstream; mirror in src/relay/git-handler.ts.
|
||||
const publish = args.publish === true
|
||||
args = { ...args, pushTarget: resolveStoredReviewPushTarget(store, args) }
|
||||
if (args.connectionId) {
|
||||
if (args.pushTarget) {
|
||||
assertGitPushTargetShape(args.pushTarget)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertGitReviewPushAuthority } from '../../shared/git-review-push-authority'
|
||||
// Why: preparing a fork-PR push target means adding (or reusing) the contributor's
|
||||
// fork as a git remote, fetching the head, and wiring the new branch's upstream.
|
||||
// The git-driven core lives here behind an injectable `execGit` seam so the
|
||||
@@ -109,6 +110,12 @@ export async function prepareWorktreePushTargetWithExec(
|
||||
const existingRemote = await findRemoteForUrl(execGit, repoPath, target.remoteUrl)
|
||||
if (existingRemote) {
|
||||
remoteName = existingRemote
|
||||
if (target.reviewHead) {
|
||||
await assertGitReviewPushAuthority((args) => execGit(args, repoPath), {
|
||||
...target,
|
||||
remoteName
|
||||
})
|
||||
}
|
||||
// Why: if a later PR worktree reuses an Orca-created fork remote, it
|
||||
// must inherit ownership so deleting the final user can remove it.
|
||||
remoteCreated = isRemoteCreatedByKnownWorktree(existingRemote)
|
||||
@@ -144,6 +151,12 @@ export async function prepareWorktreePushTargetWithExec(
|
||||
}
|
||||
|
||||
try {
|
||||
if (target.reviewHead) {
|
||||
await assertGitReviewPushAuthority((args) => execGit(args, repoPath), {
|
||||
...target,
|
||||
remoteName
|
||||
})
|
||||
}
|
||||
await execGit(
|
||||
['fetch', remoteName, buildNarrowForkFetchRefspec(remoteName, target.branchName)],
|
||||
repoPath
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { resolveStoredReviewPushTarget } from './worktree-review-push-target'
|
||||
import { reviewTarget } from '../../shared/__fixtures__/git-review-target'
|
||||
import type { Store } from '../persistence'
|
||||
|
||||
it('rejects missing and superseded targets at the local IPC metadata boundary', () => {
|
||||
const meta = {
|
||||
linkedPR: 42,
|
||||
pushTarget: undefined as ReturnType<typeof reviewTarget> | undefined
|
||||
}
|
||||
const store = {
|
||||
getAllWorktreeMetaForHost: vi.fn(() => ({ 'repo::/repo/wt': meta }))
|
||||
} as unknown as Store
|
||||
expect(() => resolveStoredReviewPushTarget(store, { worktreePath: '/repo/wt' })).toThrow(
|
||||
'unresolved'
|
||||
)
|
||||
meta.pushTarget = reviewTarget('origin', 'feature')
|
||||
expect(resolveStoredReviewPushTarget(store, { worktreePath: '/repo/wt' })).toEqual(
|
||||
meta.pushTarget
|
||||
)
|
||||
expect(() =>
|
||||
resolveStoredReviewPushTarget(store, {
|
||||
worktreePath: '/repo/wt',
|
||||
pushTarget: reviewTarget('fork', 'feature')
|
||||
})
|
||||
).toThrow('changed')
|
||||
expect(store.getAllWorktreeMetaForHost).toHaveBeenCalledWith('local')
|
||||
})
|
||||
|
||||
it('reads SSH-owned metadata without substituting the local row', () => {
|
||||
const store = {
|
||||
getAllWorktreeMetaForHost: vi.fn(() => ({ 'repo::/repo/wt': { linkedGitLabMR: 42 } }))
|
||||
} as unknown as Store
|
||||
expect(() =>
|
||||
resolveStoredReviewPushTarget(store, { worktreePath: '/repo/wt', connectionId: 'host' })
|
||||
).toThrow('unresolved')
|
||||
expect(store.getAllWorktreeMetaForHost).toHaveBeenCalledWith('ssh:host')
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import { splitWorktreeId } from '../../shared/worktree/id'
|
||||
import { linkedReviewOperationTarget } from '../../shared/linked-review-operation-target'
|
||||
import type { GitPushTarget } from '../../shared/worktree/types'
|
||||
import { readAllWorktreeMetaForHost } from '../persistence/host-qualified-worktree-meta'
|
||||
import type { Store } from '../persistence'
|
||||
|
||||
export function resolveStoredReviewPushTarget(
|
||||
store: Store,
|
||||
args: {
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
pushTarget?: GitPushTarget
|
||||
}
|
||||
): GitPushTarget | undefined {
|
||||
const host = args.connectionId ? toSshExecutionHostId(args.connectionId) : LOCAL_EXECUTION_HOST_ID
|
||||
const comparable = (path: string): string => (args.connectionId ? path : resolve(path))
|
||||
const entries = Object.entries(readAllWorktreeMetaForHost(store, host)).filter(([id]) => {
|
||||
const parsed = splitWorktreeId(id)
|
||||
return parsed && comparable(parsed.worktreePath) === comparable(args.worktreePath)
|
||||
})
|
||||
if (entries.length > 1) {
|
||||
throw new Error('Review push workspace ownership is ambiguous.')
|
||||
}
|
||||
return linkedReviewOperationTarget(entries[0]?.[1], args.pushTarget)
|
||||
}
|
||||
@@ -463,8 +463,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
expect(result).toMatchObject({
|
||||
baseBranch: 'def456',
|
||||
headSha: 'def456',
|
||||
branchNameOverride: 'feature/add-feature',
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/add-feature' }
|
||||
branchNameOverride: 'feature/add-feature'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { reviewTarget } from '../../shared/__fixtures__/git-review-target'
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import { SshGitProvider } from './ssh-git-provider'
|
||||
import { createMockMux, type MockMultiplexer } from './ssh-git-provider-test-harness'
|
||||
@@ -38,17 +39,19 @@ describe('SshGitProvider', () => {
|
||||
})
|
||||
|
||||
it('pushBranch sends git.push request and forwards publish mode and target', async () => {
|
||||
await provider.pushBranch('/home/user/repo', true, {
|
||||
remoteName: 'pr-fork-orca',
|
||||
branchName: 'contributor/fix'
|
||||
mux.request.mockResolvedValue({
|
||||
stdout: 'pr-fork-orca\thttps://github.com/team/repo.git (push)',
|
||||
stderr: ''
|
||||
})
|
||||
await provider.pushBranch(
|
||||
'/home/user/repo',
|
||||
true,
|
||||
reviewTarget('pr-fork-orca', 'contributor/fix')
|
||||
)
|
||||
expect(mux.request).toHaveBeenCalledWith('git.push', {
|
||||
worktreePath: '/home/user/repo',
|
||||
publish: true,
|
||||
pushTarget: {
|
||||
remoteName: 'pr-fork-orca',
|
||||
branchName: 'contributor/fix'
|
||||
}
|
||||
pushTarget: reviewTarget('pr-fork-orca', 'contributor/fix')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader'
|
||||
import { assertGitReviewPushAuthority } from '../../shared/git-review-push-authority'
|
||||
import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync'
|
||||
import type { GitPushTarget } from '../../shared/worktree/types'
|
||||
import { REBASE_FROM_BASE_RPC_TIMEOUT_MS } from '../../shared/git-rebase-source'
|
||||
@@ -11,6 +13,15 @@ export class SshGitRemoteSyncProvider extends SshGitWorkingTreeProvider {
|
||||
options: { forceWithLease?: boolean } = {}
|
||||
): Promise<void> {
|
||||
await this.runWithGitReadInvalidation(async () => {
|
||||
if (pushTarget) {
|
||||
await assertGitReviewPushAuthority(
|
||||
async (args) =>
|
||||
(await requestGitStreamable(this.mux, 'git.exec', { args, cwd: worktreePath })) as {
|
||||
stdout: string
|
||||
},
|
||||
pushTarget
|
||||
)
|
||||
}
|
||||
await this.mux.request('git.push', {
|
||||
worktreePath,
|
||||
publish,
|
||||
|
||||
@@ -122,8 +122,7 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
baseBranch: 'origin/feature/fix',
|
||||
compareBaseRef: 'refs/remotes/origin/main',
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
|
||||
compareBaseRef: 'refs/remotes/origin/main'
|
||||
})
|
||||
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
@@ -191,8 +190,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
baseBranch: 'origin/feature/fix',
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
|
||||
baseBranch: 'origin/feature/fix'
|
||||
})
|
||||
expect(result).not.toHaveProperty('compareBaseRef')
|
||||
expect(result).not.toHaveProperty('error')
|
||||
@@ -252,8 +250,7 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
baseBranch: 'origin/feature/fix',
|
||||
compareBaseRef: 'refs/remotes/origin/main',
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
|
||||
compareBaseRef: 'refs/remotes/origin/main'
|
||||
})
|
||||
} finally {
|
||||
warnSpy.mockRestore()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GitPushTargetParam } from './git-push-target-schema'
|
||||
import { z } from 'zod'
|
||||
import { OptionalGitAdmissionTier } from './git-admission-tier-schema'
|
||||
|
||||
@@ -206,13 +207,6 @@ export const GitBulkPaths = WorktreeSelector.extend({
|
||||
filePaths: z.array(z.string().min(1, 'Missing file path'))
|
||||
})
|
||||
|
||||
const GitPushTargetParam = z.object({
|
||||
remoteName: z.string(),
|
||||
branchName: z.string(),
|
||||
remoteUrl: z.string().optional(),
|
||||
remoteCreated: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const GitPush = WorktreeSelector.extend({
|
||||
publish: z.boolean().optional(),
|
||||
forceWithLease: z.boolean().optional(),
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const GitPushTargetParam = z.object({
|
||||
remoteName: z.string(),
|
||||
branchName: z.string(),
|
||||
remoteUrl: z.string().optional(),
|
||||
remoteCreated: z.boolean().optional(),
|
||||
reviewHead: z
|
||||
.object({
|
||||
provider: z.enum(['github', 'gitlab']),
|
||||
host: z.string().min(1),
|
||||
repository: z.string().min(1),
|
||||
branchName: z.string().min(1)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { GitPush } from './git-params'
|
||||
import { WorktreeSet } from './worktree-schemas'
|
||||
import { WorktreeCreate } from './worktree-create-schemas'
|
||||
import { reviewTarget } from '../../../../shared/__fixtures__/git-review-target'
|
||||
|
||||
it('preserves provider head identity through push, metadata and creation RPC schemas', () => {
|
||||
const pushTarget = reviewTarget('origin', 'feature')
|
||||
expect(GitPush.parse({ worktree: 'id:wt', pushTarget }).pushTarget).toEqual(pushTarget)
|
||||
expect(WorktreeSet.parse({ worktree: 'id:wt', pushTarget }).pushTarget).toEqual(pushTarget)
|
||||
expect(
|
||||
WorktreeCreate.parse({ repo: 'id:repo', branch: 'feature', pushTarget }).pushTarget
|
||||
).toEqual(pushTarget)
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GitPushTargetParam } from './git-push-target-schema'
|
||||
import { z } from 'zod'
|
||||
import { isTuiAgent } from '../../../../shared/tui-agent-config'
|
||||
import { workspaceSourceSchema } from '../../../../shared/telemetry-events'
|
||||
@@ -61,13 +62,7 @@ export const WorktreeCreate = z
|
||||
presetId: OptionalString
|
||||
})
|
||||
.optional(),
|
||||
pushTarget: z
|
||||
.object({
|
||||
remoteName: z.string(),
|
||||
branchName: z.string(),
|
||||
remoteUrl: OptionalString
|
||||
})
|
||||
.optional(),
|
||||
pushTarget: GitPushTargetParam.optional(),
|
||||
runHooks: OptionalBoolean,
|
||||
activate: OptionalBoolean,
|
||||
// Why: activation on create is view intent, so it is addressed like worktree.activate.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GitPushTargetParam } from './git-push-target-schema'
|
||||
import { z } from 'zod'
|
||||
import { isTuiAgent } from '../../../../shared/tui-agent-config'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
@@ -142,14 +143,7 @@ export const WorktreeSet = WorktreeSelector.extend({
|
||||
sparsePresetId: OptionalString,
|
||||
baseRef: OptionalString,
|
||||
workspaceStatus: OptionalString,
|
||||
pushTarget: z
|
||||
.object({
|
||||
remoteName: z.string(),
|
||||
branchName: z.string(),
|
||||
remoteUrl: OptionalString
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
pushTarget: GitPushTargetParam.nullable().optional(),
|
||||
diffComments: z.array(z.unknown()).optional(),
|
||||
mobileDiffReview: z.unknown().optional(),
|
||||
parentWorktree: OptionalString,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { linkedReviewOperationTarget } from '../../shared/linked-review-operation-target'
|
||||
import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync'
|
||||
import type { GitUpstreamStatus } from '../../shared/git-status-types'
|
||||
import type { GitPushTarget } from '../../shared/worktree/types'
|
||||
@@ -201,6 +202,7 @@ export class RuntimeGitSyncCommands {
|
||||
forceWithLease?: boolean
|
||||
): Promise<{ ok: true }> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
pushTarget = linkedReviewOperationTarget(target.worktree, pushTarget)
|
||||
const provider = requireRuntimeGitProvider(target)
|
||||
if (provider) {
|
||||
const materializedPushTarget = pushTarget
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import type { Store } from '../persistence'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { mapMRToWorkItem } from '../gitlab/mappers'
|
||||
const mocks = vi.hoisted(() => ({ git: vi.fn(), item: vi.fn() }))
|
||||
vi.mock('../git/runner', () => ({ gitExecFileAsync: mocks.git }))
|
||||
vi.mock('../gitlab/client', () => ({
|
||||
getProjectRefForRemote: async () => ({ host: 'gitlab.com', path: 'team/repo' }),
|
||||
getWorkItemByProjectRef: mocks.item
|
||||
}))
|
||||
vi.mock('../gitlab/gl-utils', () => ({ getGlabKnownHosts: async () => ['gitlab.com'] }))
|
||||
vi.mock('../project-runtime-git-options', () => ({
|
||||
getLocalProjectGitExecOptions: () => ({}),
|
||||
getLocalProjectWorktreeGitOptions: () => ({})
|
||||
}))
|
||||
vi.mock('./runtime-gitlab-issue-source-remote', () => ({
|
||||
resolveRuntimeGitLabIssueSourceRemote: async () => 'origin'
|
||||
}))
|
||||
import { resolveRuntimeGitLabWorktreeBase } from './runtime-gitlab-worktree-base'
|
||||
|
||||
it.each([
|
||||
['git@gitlab.com:team/repo.git', true],
|
||||
['git@gitlab.com:other/repo.git', false],
|
||||
['git@gitlab.com:team/repo.git\norigin\tgit@gitlab.com:other/repo.git', false]
|
||||
])('binds same-project GitLab hydration to all execution endpoints %s', async (urls, accepted) => {
|
||||
mocks.item.mockResolvedValue(
|
||||
mapMRToWorkItem(
|
||||
{
|
||||
title: 'Review',
|
||||
source_branch: 'feature',
|
||||
target_branch: 'main',
|
||||
source_project_id: 7,
|
||||
target_project_id: 7
|
||||
},
|
||||
'repo',
|
||||
{ host: 'gitlab.com', path: 'team/repo' }
|
||||
)
|
||||
)
|
||||
mocks.git.mockImplementation(async (args: string[]) => ({
|
||||
stdout:
|
||||
args[0] === 'remote'
|
||||
? urls
|
||||
.split('\n')
|
||||
.map((url, index) => `${index === 0 ? 'origin\t' : ''}${url} (push)`)
|
||||
.join('\n')
|
||||
: 'oid',
|
||||
stderr: ''
|
||||
}))
|
||||
const result = await resolveRuntimeGitLabWorktreeBase(
|
||||
{ repoSelector: 'repo', mrIid: 42 },
|
||||
{
|
||||
store: {} as Store,
|
||||
resolveRepo: async () => ({ id: 'repo', path: '/repo' }) as Repo
|
||||
}
|
||||
)
|
||||
expect(result).not.toHaveProperty('error')
|
||||
if ('error' in result) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
expect(!!result.pushTarget).toBe(accepted)
|
||||
if (accepted) {
|
||||
expect(result.pushTarget?.reviewHead).toEqual({
|
||||
provider: 'gitlab',
|
||||
host: 'gitlab.com',
|
||||
repository: 'team/repo',
|
||||
branchName: 'feature'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readGitReviewPushAuthority } from '../../shared/git-review-push-authority'
|
||||
import type { GitPushTarget } from '../../shared/worktree/types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
@@ -55,7 +56,8 @@ export async function resolveRuntimeGitLabWorktreeBase(
|
||||
let sourceBranch = args.sourceBranch?.trim() ?? ''
|
||||
let targetBranch = args.targetBranch?.trim() ?? ''
|
||||
let isCrossRepository = args.isCrossRepository === true
|
||||
if (!sourceBranch) {
|
||||
let pushTarget: GitPushTarget | undefined
|
||||
if (!sourceBranch || !isCrossRepository) {
|
||||
let discoveryRemote: string
|
||||
try {
|
||||
discoveryRemote = await resolveRuntimeGitLabIssueSourceRemote(
|
||||
@@ -86,15 +88,30 @@ export async function resolveRuntimeGitLabWorktreeBase(
|
||||
repo.connectionId ?? null,
|
||||
localWorktreeGitOptions
|
||||
)
|
||||
if (!item || item.type !== 'mr') {
|
||||
if ((!item || item.type !== 'mr') && !sourceBranch) {
|
||||
return { error: `MR !${args.mrIid} not found.` }
|
||||
}
|
||||
sourceBranch = (item.branchName ?? '').trim()
|
||||
targetBranch = (item.baseRefName ?? '').trim()
|
||||
if (item?.headProjectRef && item.branchName) {
|
||||
const candidate: GitPushTarget = {
|
||||
remoteName: discoveryRemote,
|
||||
branchName: item.branchName,
|
||||
reviewHead: {
|
||||
provider: 'gitlab',
|
||||
host: item.headProjectRef.host,
|
||||
repository: item.headProjectRef.path,
|
||||
branchName: item.branchName
|
||||
}
|
||||
}
|
||||
if ((await readGitReviewPushAuthority(gitExec, candidate)).kind === 'verified') {
|
||||
pushTarget = candidate
|
||||
}
|
||||
}
|
||||
sourceBranch = item?.branchName?.trim() || sourceBranch
|
||||
targetBranch = item?.baseRefName?.trim() || targetBranch
|
||||
if (!sourceBranch) {
|
||||
return { error: `MR !${args.mrIid} has no source branch.` }
|
||||
}
|
||||
if (item.isCrossRepository === true) {
|
||||
if (item?.isCrossRepository === true) {
|
||||
isCrossRepository = true
|
||||
}
|
||||
}
|
||||
@@ -151,6 +168,6 @@ export async function resolveRuntimeGitLabWorktreeBase(
|
||||
return {
|
||||
baseBranch: remoteRef,
|
||||
...(compareBaseFetched ? { compareBaseRef } : {}),
|
||||
pushTarget: { remoteName: remote, branchName: sourceBranch }
|
||||
...(pushTarget ? { pushTarget } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ describe('validateGitExecArgs', () => {
|
||||
[['remote', 'add', '../escape', 'https://github.com/contributor/orca.git']],
|
||||
[['remote', 'remove', '-f']],
|
||||
[['remote', 'add', 'fork', 'ext::sh -c payload']],
|
||||
[['remote', 'add', 'fork', 'https://evil.test/contributor/orca.git']],
|
||||
[['remote', 'add', 'fork', 'https://gitlab.example/contributor/orca.git?unsupported=1']],
|
||||
[['remote', 'add', 'fork', '/etc/passwd']]
|
||||
])('rejects unsafe remote write args %j', (args) => {
|
||||
expectBlocked(args, 'Destructive git remote operations')
|
||||
|
||||
@@ -163,12 +163,21 @@ describe('resolveRelayPushTarget', () => {
|
||||
})
|
||||
|
||||
it('uses an explicit push target without reading branch config', async () => {
|
||||
const git = vi.fn(async () => ({ stdout: '', stderr: '' }))
|
||||
const git = vi.fn(async () => ({
|
||||
stdout: 'fork\thttps://github.com/team/repo.git (push)',
|
||||
stderr: ''
|
||||
}))
|
||||
|
||||
await expect(
|
||||
resolveRelayPushTarget(git, '/repo', {
|
||||
remoteName: 'fork',
|
||||
branchName: 'feature/head'
|
||||
branchName: 'feature/head',
|
||||
reviewHead: {
|
||||
provider: 'github',
|
||||
host: 'github.com',
|
||||
repository: 'team/repo',
|
||||
branchName: 'feature/head'
|
||||
}
|
||||
})
|
||||
).resolves.toEqual({
|
||||
remote: 'fork',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertGitReviewPushAuthority } from '../shared/git-review-push-authority'
|
||||
import { assertGitPushTargetShape } from '../shared/git-push-target-validation'
|
||||
import {
|
||||
resolveConfiguredGitPushTarget,
|
||||
@@ -20,6 +21,7 @@ export async function resolveRelayPushTarget(
|
||||
// Why here and not in the shared resolver: an explicit target arrives over the wire,
|
||||
// so the host re-validates its shape and asks Git to vet the branch name itself.
|
||||
await git(['check-ref-format', '--branch', explicitTarget.branchName], worktreePath)
|
||||
await assertGitReviewPushAuthority((args) => git(args, worktreePath), explicitTarget)
|
||||
return {
|
||||
remote: explicitTarget.remoteName,
|
||||
refspec: `HEAD:refs/heads/${explicitTarget.branchName}`
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { reviewHeadKey } from '../../../../shared/git-review-push-authority'
|
||||
import type { GitStatusEntry, GitUpstreamStatus } from '../../../../shared/git-status-types'
|
||||
import type { GitPushTarget } from '../../../../shared/worktree/types'
|
||||
|
||||
@@ -92,6 +93,7 @@ export function buildChecksPanelGitStatusContextKey(
|
||||
localExecutionScope: input.localExecutionScope ?? null,
|
||||
pushTarget: input.pushTarget
|
||||
? {
|
||||
reviewHead: reviewHeadKey(input.pushTarget.reviewHead) ?? null,
|
||||
remoteName: input.pushTarget.remoteName,
|
||||
branchName: input.pushTarget.branchName,
|
||||
remoteUrl: input.pushTarget.remoteUrl ?? null,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { reviewHeadKey } from '../../../../shared/git-review-push-authority'
|
||||
import type { GitStatusResult, GitUpstreamStatus } from '../../../../shared/git-status-types'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import type { GitPushTarget } from '../../../../shared/worktree/types'
|
||||
@@ -24,6 +25,7 @@ function getRuntimeEnvironmentKey(
|
||||
|
||||
function getPushTargetKey(pushTarget: GitPushTarget): readonly unknown[] {
|
||||
return [
|
||||
reviewHeadKey(pushTarget.reviewHead) ?? null,
|
||||
pushTarget.remoteName,
|
||||
pushTarget.branchName,
|
||||
pushTarget.remoteUrl ?? null,
|
||||
|
||||
+10
-5
@@ -1,3 +1,4 @@
|
||||
import { reviewTarget } from '../../../../shared/__fixtures__/git-review-target'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hasPositiveHostedReviewNumberLink,
|
||||
@@ -239,6 +240,10 @@ describe('hasUsableHostedReviewPushTarget', () => {
|
||||
|
||||
it('distinguishes equal labels with different named remote and merge identities', () => {
|
||||
const upstreamStatus = {
|
||||
reviewPushAuthority: {
|
||||
kind: 'verified' as const,
|
||||
reviewHead: reviewTarget('origin/team', 'feature').reviewHead
|
||||
},
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/team/feature',
|
||||
ahead: 1,
|
||||
@@ -252,7 +257,7 @@ describe('hasUsableHostedReviewPushTarget', () => {
|
||||
expect(
|
||||
hasUsableHostedReviewPushTarget({
|
||||
upstreamStatus,
|
||||
pushTarget: { remoteName: 'origin/team', branchName: 'feature' }
|
||||
pushTarget: reviewTarget('origin/team', 'feature')
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
@@ -270,7 +275,7 @@ describe('hasUsableHostedReviewPushTarget', () => {
|
||||
selector: { kind: 'literal-url' }
|
||||
}
|
||||
},
|
||||
pushTarget: { remoteName: 'origin/team', branchName: 'feature' }
|
||||
pushTarget: reviewTarget('origin/team', 'feature')
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
@@ -303,12 +308,12 @@ describe('hasUsableHostedReviewPushTarget', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts either persisted target metadata or branch-configured push metadata', () => {
|
||||
it('rejects legacy target metadata and retains ordinary configured push policy', () => {
|
||||
expect(
|
||||
hasUsableHostedReviewPushTarget({
|
||||
pushTarget: { remoteName: 'fork', branchName: 'feature' }
|
||||
})
|
||||
).toBe(true)
|
||||
).toBe(false)
|
||||
expect(
|
||||
hasUsableHostedReviewPushTarget({
|
||||
pushTarget: { remoteName: 'fork', branchName: 'feature' },
|
||||
@@ -324,7 +329,7 @@ describe('hasUsableHostedReviewPushTarget', () => {
|
||||
behind: 0
|
||||
}
|
||||
})
|
||||
).toBe(true)
|
||||
).toBe(false)
|
||||
expect(
|
||||
hasUsableHostedReviewPushTarget({
|
||||
upstreamStatus: {
|
||||
|
||||
@@ -628,8 +628,7 @@ describe('runtime git client', () => {
|
||||
await generateRuntimeCommitMessage(context)
|
||||
await cancelRuntimeGenerateCommitMessage(context)
|
||||
await pushRuntimeGit(context, {
|
||||
publish: true,
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature' }
|
||||
publish: true
|
||||
})
|
||||
await fetchRuntimeGit(context, { remoteName: 'fork', branchName: 'feature' })
|
||||
await fastForwardRuntimeGit(context, { remoteName: 'fork', branchName: 'feature' })
|
||||
@@ -670,8 +669,7 @@ describe('runtime git client', () => {
|
||||
method: 'git.push',
|
||||
params: {
|
||||
worktree: 'id:wt-1',
|
||||
publish: true,
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature' }
|
||||
publish: true
|
||||
},
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { hasUsableHostedReviewPushTarget } from '../../../shared/hosted-review-push-target-admission'
|
||||
import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../../shared/git-fork-sync'
|
||||
import type { GitUpstreamStatus } from '../../../shared/git-status-types'
|
||||
import { REBASE_FROM_BASE_RPC_TIMEOUT_MS } from '../../../shared/git-rebase-source'
|
||||
@@ -195,6 +196,17 @@ export async function pushRuntimeGit(
|
||||
})
|
||||
return
|
||||
}
|
||||
if (
|
||||
args.pushTarget &&
|
||||
!hasUsableHostedReviewPushTarget({
|
||||
pushTarget: args.pushTarget,
|
||||
upstreamStatus: await getRuntimeGitUpstreamStatus(context, args.pushTarget)
|
||||
})
|
||||
) {
|
||||
throw new Error(
|
||||
'The execution host has not verified the review push endpoints. Update or reconnect the host and retry.'
|
||||
)
|
||||
}
|
||||
await callRuntimeRpc(
|
||||
target,
|
||||
'git.push',
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { reviewTarget } from '../../../shared/__fixtures__/git-review-target'
|
||||
const rpc = vi.hoisted(() => vi.fn())
|
||||
vi.mock('./runtime-rpc-client', () => ({
|
||||
getActiveRuntimeTarget: () => ({ kind: 'remote', environmentId: 'env' }),
|
||||
callRuntimeRpc: rpc
|
||||
}))
|
||||
import { pushRuntimeGit } from './runtime-git-sync-client'
|
||||
const context = {
|
||||
settings: { activeRuntimeEnvironmentId: 'env' },
|
||||
worktreeId: 'wt',
|
||||
worktreePath: '/repo'
|
||||
}
|
||||
const target = reviewTarget('origin', 'feature')
|
||||
beforeEach(() => rpc.mockReset())
|
||||
it.each([
|
||||
undefined,
|
||||
{ kind: 'mismatch', reviewHead: target.reviewHead },
|
||||
{ kind: 'ambiguous', reviewHead: target.reviewHead }
|
||||
])('rejects old or unverified host evidence %j before push', async (authority) => {
|
||||
rpc.mockResolvedValue({ hasUpstream: true, ahead: 1, behind: 0, reviewPushAuthority: authority })
|
||||
await expect(pushRuntimeGit(context, { pushTarget: target })).rejects.toThrow('not verified')
|
||||
expect(rpc).toHaveBeenCalledOnce()
|
||||
expect(rpc.mock.calls[0]?.[1]).toBe('git.upstreamStatus')
|
||||
})
|
||||
it('dispatches only after host status corroborates the same target identity', async () => {
|
||||
rpc
|
||||
.mockResolvedValueOnce({
|
||||
hasUpstream: true,
|
||||
ahead: 1,
|
||||
behind: 0,
|
||||
upstreamIdentity: {
|
||||
selector: { kind: 'named-remote', value: 'origin' },
|
||||
mergeRef: 'refs/heads/feature',
|
||||
trackingRef: null
|
||||
},
|
||||
reviewPushAuthority: { kind: 'verified', reviewHead: target.reviewHead }
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
await pushRuntimeGit(context, { pushTarget: target })
|
||||
expect(rpc.mock.calls[1]?.[1]).toBe('git.push')
|
||||
expect(rpc.mock.calls[1]?.[2]).toMatchObject({ pushTarget: target })
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { reviewTarget } from '../../../../shared/__fixtures__/git-review-target'
|
||||
import type { GitUpstreamStatus } from '../../../../shared/git-status-types'
|
||||
import { hasUsableHostedReviewPushTarget } from '../../../../shared/hosted-review-push-target-admission'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
@@ -571,12 +572,16 @@ it('preserves identity-only upstream delivery and old-peer uncertainty', () => {
|
||||
store.getState().setUpstreamStatus('wt-identity', upstreamStatus)
|
||||
const usable = () =>
|
||||
hasUsableHostedReviewPushTarget({
|
||||
pushTarget: { remoteName: 'origin/team', branchName: 'feature' },
|
||||
pushTarget: reviewTarget('origin/team', 'feature'),
|
||||
upstreamStatus: store.getState().remoteStatusesByWorktree['wt-identity']
|
||||
})
|
||||
const oldPeer = { hasUpstream: true, upstreamName: 'origin/team/feature', ahead: 1, behind: 0 }
|
||||
const canonical: GitUpstreamStatus = {
|
||||
...oldPeer,
|
||||
reviewPushAuthority: {
|
||||
kind: 'verified',
|
||||
reviewHead: reviewTarget('origin/team', 'feature').reviewHead
|
||||
},
|
||||
upstreamIdentity: {
|
||||
selector: { kind: 'named-remote', value: 'origin/team' },
|
||||
mergeRef: 'refs/heads/feature',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { reviewTarget } from '../../../../shared/__fixtures__/git-review-target'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { createEditorStore } from './editor-slice-test-harness'
|
||||
import { makeWorktree } from './worktrees-slice-test-fixtures'
|
||||
@@ -53,16 +54,19 @@ it.each(['linkedPR', 'linkedGitLabMR'] as const)(
|
||||
// A failed lookup retains the same unresolved state; no configured push fallback is admitted.
|
||||
await expect(store.getState().pushBranch('wt', '/repo')).rejects.toThrow('unresolved')
|
||||
for (const remoteName of ['contributor', 'other-repository']) {
|
||||
worktree.pushTarget = { remoteName, branchName: 'feature' }
|
||||
worktree.pushTarget = reviewTarget(
|
||||
remoteName,
|
||||
'feature',
|
||||
link === 'linkedPR' ? 'github' : 'gitlab'
|
||||
)
|
||||
await store.getState().pushBranch('wt', '/repo')
|
||||
expect(push).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ pushTarget: worktree.pushTarget })
|
||||
)
|
||||
await expect(
|
||||
store.getState().pushBranch('wt', '/repo', false, undefined, {
|
||||
remoteName: 'origin',
|
||||
branchName: 'feature'
|
||||
})
|
||||
store
|
||||
.getState()
|
||||
.pushBranch('wt', '/repo', false, undefined, reviewTarget('origin', 'feature'))
|
||||
).rejects.toThrow('changed')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1 @@
|
||||
import { isPositiveHostedReviewNumber } from '../../../../../../shared/hosted-review'
|
||||
import type { GitPushTarget, Worktree } from '../../../../../../shared/worktree/types'
|
||||
|
||||
export function linkedReviewOperationTarget(
|
||||
worktree: Worktree | undefined,
|
||||
requested: GitPushTarget | undefined,
|
||||
fallbackGitHubPR?: number
|
||||
): GitPushTarget | undefined {
|
||||
if (
|
||||
!isPositiveHostedReviewNumber(worktree?.linkedPR) &&
|
||||
!isPositiveHostedReviewNumber(worktree?.linkedGitLabMR) &&
|
||||
!isPositiveHostedReviewNumber(fallbackGitHubPR)
|
||||
) {
|
||||
return requested
|
||||
}
|
||||
const resolved = worktree?.pushTarget
|
||||
if (!resolved) {
|
||||
throw new Error('The linked review push target is still unresolved. Retry after it loads.')
|
||||
}
|
||||
if (
|
||||
requested &&
|
||||
(requested.remoteName !== resolved.remoteName ||
|
||||
requested.branchName !== resolved.branchName ||
|
||||
requested.remoteUrl !== resolved.remoteUrl)
|
||||
) {
|
||||
throw new Error('The linked review push target changed. Retry with the current target.')
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
export { linkedReviewOperationTarget } from '../../../../../../shared/linked-review-operation-target'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { reviewHeadKey } from '../../../../../../shared/git-review-push-authority'
|
||||
import { areGitUpstreamIdentitiesEqual } from '../../../../../../shared/git-upstream-identity'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type {
|
||||
@@ -120,6 +121,9 @@ export function areUpstreamStatusesEqual(
|
||||
prev !== undefined &&
|
||||
prev.hasUpstream === next.hasUpstream &&
|
||||
prev.upstreamName === next.upstreamName &&
|
||||
prev.reviewPushAuthority?.kind === next.reviewPushAuthority?.kind &&
|
||||
reviewHeadKey(prev.reviewPushAuthority?.reviewHead) ===
|
||||
reviewHeadKey(next.reviewPushAuthority?.reviewHead) &&
|
||||
areGitUpstreamIdentitiesEqual(prev.upstreamIdentity, next.upstreamIdentity) &&
|
||||
prev.ahead === next.ahead &&
|
||||
prev.behind === next.behind &&
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { reviewHeadKey } from '../../../../shared/git-review-push-authority'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
|
||||
function indexUnambiguousWorktrees(
|
||||
@@ -21,6 +22,7 @@ function branchScopedReviewContextMatches(left: Worktree, right: Worktree): bool
|
||||
left.linkedBitbucketPR === right.linkedBitbucketPR &&
|
||||
left.linkedAzureDevOpsPR === right.linkedAzureDevOpsPR &&
|
||||
left.linkedGiteaPR === right.linkedGiteaPR &&
|
||||
reviewHeadKey(left.pushTarget?.reviewHead) === reviewHeadKey(right.pushTarget?.reviewHead) &&
|
||||
left.pushTarget?.remoteName === right.pushTarget?.remoteName &&
|
||||
left.pushTarget?.branchName === right.pushTarget?.branchName
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { reviewTarget } from '../../../../shared/__fixtures__/git-review-target'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '../types'
|
||||
import type { RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture'
|
||||
@@ -37,7 +38,7 @@ describe('worktree remote runtime mutations', () => {
|
||||
|
||||
it('resolves and persists a push target when manually linking a GitHub PR', async () => {
|
||||
const store = createTestStore()
|
||||
const pushTarget = { remoteName: 'origin', branchName: 'bot/pr-bug-scan-2504' }
|
||||
const pushTarget = reviewTarget('origin', 'bot/pr-bug-scan-2504')
|
||||
const wt = makeWorktree({
|
||||
id: 'repo1::/path/wt1',
|
||||
repoId: 'repo1',
|
||||
@@ -79,7 +80,7 @@ describe('worktree remote runtime mutations', () => {
|
||||
|
||||
it('clears a stale push target when unlinking the GitHub PR that supplied it', async () => {
|
||||
const store = createTestStore()
|
||||
const pushTarget = { remoteName: 'fork', branchName: 'owner/old-pr' }
|
||||
const pushTarget = reviewTarget('fork', 'owner/old-pr')
|
||||
const wt = makeWorktree({
|
||||
id: 'repo1::/path/wt1',
|
||||
repoId: 'repo1',
|
||||
@@ -112,7 +113,7 @@ describe('worktree remote runtime mutations', () => {
|
||||
path: '/path/wt1',
|
||||
branch: 'refs/heads/review-branch',
|
||||
linkedPR: 2548,
|
||||
pushTarget: { remoteName: 'fork', branchName: 'owner/old-pr' }
|
||||
pushTarget: reviewTarget('fork', 'owner/old-pr')
|
||||
})
|
||||
store.setState({
|
||||
repos: [
|
||||
@@ -139,8 +140,8 @@ describe('worktree remote runtime mutations', () => {
|
||||
|
||||
it('clears an older GitHub link and target when replacing it with a GitLab MR', async () => {
|
||||
const store = createTestStore()
|
||||
const oldPushTarget = { remoteName: 'fork', branchName: 'owner/old-pr' }
|
||||
const newPushTarget = { remoteName: 'upstream', branchName: 'owner/new-mr' }
|
||||
const oldPushTarget = reviewTarget('fork', 'owner/old-pr')
|
||||
const newPushTarget = reviewTarget('upstream', 'owner/new-mr')
|
||||
const wt = makeWorktree({
|
||||
id: 'repo1::/path/wt1',
|
||||
repoId: 'repo1',
|
||||
@@ -202,7 +203,7 @@ describe('worktree remote runtime mutations', () => {
|
||||
|
||||
it('resolves a manually linked GitHub PR through the worktree owner runtime', async () => {
|
||||
const store = createTestStore()
|
||||
const pushTarget = { remoteName: 'fork', branchName: 'owner-runtime/manual-pr' }
|
||||
const pushTarget = reviewTarget('fork', 'owner-runtime/manual-pr')
|
||||
const wt = makeWorktree({
|
||||
id: 'repo1::/remote/wt1',
|
||||
repoId: 'repo1',
|
||||
@@ -262,7 +263,7 @@ describe('worktree remote runtime mutations', () => {
|
||||
|
||||
it('sends a runtime clear when unlinking a review-owned push target', async () => {
|
||||
const store = createTestStore()
|
||||
const pushTarget = { remoteName: 'fork', branchName: 'owner-runtime/old-pr' }
|
||||
const pushTarget = reviewTarget('fork', 'owner-runtime/old-pr')
|
||||
const wt = makeWorktree({
|
||||
id: 'repo1::/remote/wt1',
|
||||
repoId: 'repo1',
|
||||
@@ -323,7 +324,7 @@ describe('worktree remote runtime mutations', () => {
|
||||
|
||||
it('does not resolve a push target when re-saving the same linked GitHub PR', async () => {
|
||||
const store = createTestStore()
|
||||
const pushTarget = { remoteName: 'origin', branchName: 'bot/pr-bug-scan-2504' }
|
||||
const pushTarget = reviewTarget('origin', 'bot/pr-bug-scan-2504')
|
||||
const wt = makeWorktree({
|
||||
id: 'repo1::/path/wt1',
|
||||
repoId: 'repo1',
|
||||
@@ -349,7 +350,7 @@ describe('worktree remote runtime mutations', () => {
|
||||
|
||||
it('recovers a missing push target when re-saving the same linked GitHub PR', async () => {
|
||||
const store = createTestStore()
|
||||
const pushTarget = { remoteName: 'fork', branchName: 'contributor/fix' }
|
||||
const pushTarget = reviewTarget('fork', 'contributor/fix')
|
||||
const wt = makeWorktree({
|
||||
id: 'repo1::/path/wt1',
|
||||
repoId: 'repo1',
|
||||
@@ -383,10 +384,7 @@ describe('worktree remote runtime mutations', () => {
|
||||
|
||||
it('hydrates a missing push target for an existing linked GitHub PR', async () => {
|
||||
const store = createTestStore()
|
||||
const pushTarget = {
|
||||
remoteName: 'pr-tmchow-orca',
|
||||
branchName: 'tmchow/worktree-delete-button'
|
||||
}
|
||||
const pushTarget = reviewTarget('pr-tmchow-orca', 'tmchow/worktree-delete-button')
|
||||
const wt = makeWorktree({
|
||||
id: 'repo1::/path/wt1',
|
||||
repoId: 'repo1',
|
||||
@@ -420,7 +418,7 @@ describe('worktree remote runtime mutations', () => {
|
||||
|
||||
it('hydrates a missing linked GitHub PR push target through the active remote runtime', async () => {
|
||||
const store = createTestStore()
|
||||
const pushTarget = { remoteName: 'fork', branchName: 'feature/runtime-pr' }
|
||||
const pushTarget = reviewTarget('fork', 'feature/runtime-pr')
|
||||
const wt = makeWorktree({
|
||||
id: 'repo1::/path/runtime-wt',
|
||||
repoId: 'repo1',
|
||||
@@ -475,7 +473,7 @@ describe('worktree remote runtime mutations', () => {
|
||||
|
||||
it('hydrates a host-stamped linked GitHub PR push target through the worktree owner runtime', async () => {
|
||||
const store = createTestStore()
|
||||
const pushTarget = { remoteName: 'fork', branchName: 'feature/owner-runtime-pr' }
|
||||
const pushTarget = reviewTarget('fork', 'feature/owner-runtime-pr')
|
||||
const wt = makeWorktree({
|
||||
id: 'repo1::/path/owner-runtime-wt',
|
||||
repoId: 'repo1',
|
||||
@@ -531,7 +529,7 @@ describe('worktree remote runtime mutations', () => {
|
||||
|
||||
it('hydrates an SSH-owned linked GitHub PR push target through local IPC when a runtime is focused', async () => {
|
||||
const store = createTestStore()
|
||||
const pushTarget = { remoteName: 'fork', branchName: 'feature/ssh-pr' }
|
||||
const pushTarget = reviewTarget('fork', 'feature/ssh-pr')
|
||||
const wt = makeWorktree({
|
||||
id: 'repo-ssh::/home/orca/runtime-wt',
|
||||
repoId: 'repo-ssh',
|
||||
@@ -674,7 +672,7 @@ describe('worktree remote runtime mutations', () => {
|
||||
it('cleans up the in-flight lookup when restore is skipped for a genuinely ambiguous owner', async () => {
|
||||
const store = createTestStore()
|
||||
const worktreeId = 'repo-shared::/same/path'
|
||||
const pushTarget = { remoteName: 'fork', branchName: 'feature/disambiguated' }
|
||||
const pushTarget = reviewTarget('fork', 'feature/disambiguated')
|
||||
const ownedWorktree = makeWorktree({
|
||||
id: worktreeId,
|
||||
repoId: 'repo-shared',
|
||||
@@ -740,7 +738,7 @@ describe('worktree remote runtime mutations', () => {
|
||||
|
||||
it('hydrates a missing push target for an existing linked GitLab MR when supported', async () => {
|
||||
const store = createTestStore()
|
||||
const pushTarget = { remoteName: 'upstream', branchName: 'feature/mr' }
|
||||
const pushTarget = reviewTarget('upstream', 'feature/mr')
|
||||
const wt = makeWorktree({
|
||||
id: 'repo1::/path/wt1',
|
||||
repoId: 'repo1',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { reviewTarget } from '../../../../shared/__fixtures__/git-review-target'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '../types'
|
||||
import { makeWorktree } from './worktrees-slice-test-fixtures'
|
||||
@@ -48,7 +49,7 @@ it('hydrates queued review repository identity and rejects a superseded queue lo
|
||||
worktreesByRepo: { repo1: [wt] },
|
||||
prCache: queued(42)
|
||||
} as Partial<AppState>)
|
||||
const target = { remoteName: 'contributor', branchName: 'feature' }
|
||||
const target = reviewTarget('contributor', 'feature')
|
||||
let resolve!: (value: unknown) => void
|
||||
mockApi.worktrees.resolvePrBase.mockImplementationOnce(
|
||||
() =>
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ export function createEnsureHostedReviewPushTarget(
|
||||
): WorktreeSlice['ensureHostedReviewPushTarget'] {
|
||||
return async (worktreeId) => {
|
||||
const worktree = get().getKnownWorktreeById(worktreeId)
|
||||
if (!worktree || worktree.pushTarget) {
|
||||
if (!worktree || worktree.pushTarget?.reviewHead) {
|
||||
return
|
||||
}
|
||||
const lookup = getHostedReviewPushTargetLookup(
|
||||
@@ -35,7 +35,7 @@ export function createEnsureHostedReviewPushTarget(
|
||||
return
|
||||
}
|
||||
const current = get().getKnownWorktreeById(worktreeId)
|
||||
if (!current || current.pushTarget) {
|
||||
if (!current || current.pushTarget?.reviewHead) {
|
||||
return
|
||||
}
|
||||
const currentLookup = getHostedReviewPushTargetLookup(
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function resolveGitHubReviewPushTarget(
|
||||
console.warn(`Failed to resolve push target for PR #${prNumber}: ${result.error}`)
|
||||
return undefined
|
||||
}
|
||||
return result.pushTarget
|
||||
return result.pushTarget?.reviewHead ? result.pushTarget : undefined
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to resolve push target for PR #${prNumber}:`,
|
||||
@@ -59,7 +59,7 @@ export async function resolveGitLabReviewPushTarget(
|
||||
console.warn(`Failed to resolve push target for MR !${mrIid}: ${result.error}`)
|
||||
return undefined
|
||||
}
|
||||
return result.pushTarget
|
||||
return result.pushTarget?.reviewHead ? result.pushTarget : undefined
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to resolve push target for MR !${mrIid}:`,
|
||||
|
||||
@@ -83,7 +83,7 @@ export function createUpdateWorktreeMeta(
|
||||
linkedPrForPushTarget !== null &&
|
||||
normalizedUpdates.pushTarget === undefined &&
|
||||
existingWorktree &&
|
||||
!existingWorktree.pushTarget
|
||||
!existingWorktree.pushTarget?.reviewHead
|
||||
? trySettingsForWorktreeOwner(get(), worktreeId, executionHostId)
|
||||
: null
|
||||
const resolvedPushTarget =
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { GitPushTarget, GitReviewHead } from '../worktree/types'
|
||||
|
||||
export function reviewTarget(
|
||||
remoteName: string,
|
||||
branchName: string,
|
||||
provider: GitReviewHead['provider'] = 'github'
|
||||
): GitPushTarget {
|
||||
return {
|
||||
remoteName,
|
||||
branchName,
|
||||
reviewHead: {
|
||||
provider,
|
||||
host: provider === 'github' ? 'github.com' : 'gitlab.com',
|
||||
repository: 'team/repo',
|
||||
branchName
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readGitRemoteTrackingRef } from './git-remote-tracking-ref'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -71,6 +72,23 @@ describeBinaryCompatibility('real Git binary compatibility', () => {
|
||||
})
|
||||
}
|
||||
|
||||
it('resolves abbreviated fetch mappings using Git-ranked source evidence', async () => {
|
||||
await runGit(['update-ref', 'refs/heads/abbreviated-source', 'HEAD'])
|
||||
await runGit(['clone', '--bare', '.', 'abbreviated-remote.git'])
|
||||
await runGit(['remote', 'add', 'abbreviated', './abbreviated-remote.git'])
|
||||
await runGit([
|
||||
'config',
|
||||
'remote.abbreviated.fetch',
|
||||
'abbreviated-source:refs/custom/abbreviated'
|
||||
])
|
||||
await runGit(['fetch', 'abbreviated'])
|
||||
expect(await readGitRemoteTrackingRef(runGit, 'abbreviated', 'abbreviated-source')).toBe(
|
||||
'refs/custom/abbreviated'
|
||||
)
|
||||
await runGit(['--git-dir=abbreviated-remote.git', 'tag', 'abbreviated-source', 'HEAD'])
|
||||
expect(await readGitRemoteTrackingRef(runGit, 'abbreviated', 'abbreviated-source')).toBeNull()
|
||||
})
|
||||
|
||||
function supports(major: number, minor: number): boolean {
|
||||
return version.major > major || (version.major === major && version.minor >= minor)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readGitReviewPushAuthority } from './git-review-push-authority'
|
||||
import { readGitRemoteTrackingRef } from './git-remote-tracking-ref'
|
||||
import type { GitUpstreamStatusIdentity } from './git-upstream-identity'
|
||||
import type { GitUpstreamStatus } from './git-status-types'
|
||||
@@ -19,6 +20,7 @@ export async function getPublishTargetStatus(
|
||||
target: GitPushTarget,
|
||||
getBehindCommitsArePatchEquivalent?: (upstreamName: string) => Promise<boolean>
|
||||
): Promise<GitUpstreamStatus> {
|
||||
const reviewPushAuthority = await readGitReviewPushAuthority(runGit, target)
|
||||
const upstreamName = getPublishTargetDisplayName(target)
|
||||
const remoteRef = await readGitRemoteTrackingRef(runGit, target.remoteName, target.branchName)
|
||||
const upstreamIdentity: GitUpstreamStatusIdentity = {
|
||||
@@ -29,6 +31,7 @@ export async function getPublishTargetStatus(
|
||||
|
||||
if (!remoteRef) {
|
||||
return {
|
||||
...(target.reviewHead ? { reviewPushAuthority } : {}),
|
||||
hasUpstream: false,
|
||||
upstreamName,
|
||||
upstreamIdentity: { ...upstreamIdentity, trackingRef: null },
|
||||
@@ -53,6 +56,7 @@ export async function getPublishTargetStatus(
|
||||
: undefined
|
||||
|
||||
return {
|
||||
...(target.reviewHead ? { reviewPushAuthority } : {}),
|
||||
hasUpstream: true,
|
||||
upstreamName,
|
||||
upstreamIdentity,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { GitPushTarget } from './worktree/types'
|
||||
|
||||
const SAFE_REMOTE_NAME_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
||||
const GITHUB_CLONE_URL = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\.git$/
|
||||
const GITHUB_SSH_URL = /^git@github\.com:[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\.git$/
|
||||
|
||||
function assertString(value: unknown, name: string): asserts value is string {
|
||||
if (typeof value !== 'string') {
|
||||
@@ -29,7 +27,33 @@ export function isSafeGitRemoteName(remoteName: string): boolean {
|
||||
// Why: the relay allows a fork remote to be added via git.exec, so the exec
|
||||
// validator needs the same URL rule the pushTarget-carrying RPCs already apply.
|
||||
export function isSafePushTargetRemoteUrl(remoteUrl: string): boolean {
|
||||
return GITHUB_CLONE_URL.test(remoteUrl) || GITHUB_SSH_URL.test(remoteUrl)
|
||||
const scp = /^git@([A-Za-z0-9.-]+):(.+)$/.exec(remoteUrl)
|
||||
let path: string
|
||||
if (scp) {
|
||||
path = scp[2]!
|
||||
} else {
|
||||
try {
|
||||
const url = new URL(remoteUrl)
|
||||
if (
|
||||
!['https:', 'ssh:'].includes(url.protocol) ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash ||
|
||||
(url.username && (url.protocol !== 'ssh:' || url.username !== 'git'))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
path = url.pathname.slice(1)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const parts = path.split('/')
|
||||
return (
|
||||
parts.length >= 2 &&
|
||||
path.endsWith('.git') &&
|
||||
parts.every((part) => part !== '.' && part !== '..' && /^[A-Za-z0-9_.-]+$/.test(part))
|
||||
)
|
||||
}
|
||||
|
||||
export function assertGitPushTargetShape(target: unknown): asserts target is GitPushTarget {
|
||||
@@ -45,6 +69,21 @@ export function assertGitPushTargetShape(target: unknown): asserts target is Git
|
||||
if (!candidate.branchName || candidate.branchName.startsWith('-')) {
|
||||
throw new Error(`Invalid git branch name: ${candidate.branchName}`)
|
||||
}
|
||||
if (candidate.reviewHead !== undefined) {
|
||||
const head = candidate.reviewHead as Record<string, unknown> | null
|
||||
if (
|
||||
!head ||
|
||||
(head.provider !== 'github' && head.provider !== 'gitlab') ||
|
||||
typeof head.host !== 'string' ||
|
||||
!head.host ||
|
||||
typeof head.repository !== 'string' ||
|
||||
!head.repository.includes('/') ||
|
||||
typeof head.branchName !== 'string' ||
|
||||
head.branchName !== candidate.branchName
|
||||
) {
|
||||
throw new Error('Invalid provider review head identity.')
|
||||
}
|
||||
}
|
||||
if (candidate.remoteUrl !== undefined) {
|
||||
assertString(candidate.remoteUrl, 'remote URL')
|
||||
if (!isSafePushTargetRemoteUrl(candidate.remoteUrl)) {
|
||||
|
||||
@@ -50,3 +50,26 @@ it('does not promote a stale second destination when the first configured mappin
|
||||
'refs/remotes/origin/feature'
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['feature:refs/custom/feature', 'refs/custom/feature'],
|
||||
['heads/feature:refs/custom/feature', 'refs/custom/feature'],
|
||||
['feature:custom', 'refs/heads/custom'],
|
||||
['feature:heads/tracked', 'refs/heads/tracked'],
|
||||
['feature:remotes/origin/tracked', 'refs/remotes/origin/tracked'],
|
||||
['refs/tags/feature:refs/custom/tag', null],
|
||||
['feature*:refs/custom/wild*', null]
|
||||
])(
|
||||
'resolves Git exact abbreviations separately from wildcard patterns %s',
|
||||
async (mapping, expected) => {
|
||||
const run = vi.fn(async (args: string[]) => ({
|
||||
stdout:
|
||||
args[0] === 'config'
|
||||
? mapping!
|
||||
: args[0] === 'ls-remote'
|
||||
? 'oid\trefs/heads/feature'
|
||||
: 'oid'
|
||||
}))
|
||||
expect(await readGitRemoteTrackingRef(run, 'origin', 'feature')).toBe(expected)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -26,6 +26,33 @@ function isMissingTrackingRef(error: unknown): boolean {
|
||||
return candidate.code === 1 || /(?:exited with|exit code) 1\b/i.test(candidate.message)
|
||||
}
|
||||
|
||||
async function resolveAbbreviatedFetchSource(
|
||||
runGit: GitCommandRunner,
|
||||
remote: string,
|
||||
abbreviation: string,
|
||||
source: string
|
||||
): Promise<boolean> {
|
||||
// Git remote.c chooses the highest-ranked refname_match, including tags before heads.
|
||||
const candidates = [
|
||||
abbreviation,
|
||||
`refs/${abbreviation}`,
|
||||
`refs/tags/${abbreviation}`,
|
||||
`refs/heads/${abbreviation}`,
|
||||
`refs/remotes/${abbreviation}`,
|
||||
`refs/remotes/${abbreviation}/HEAD`
|
||||
]
|
||||
if (!candidates.includes(source)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const { stdout } = await runGit(['ls-remote', '--refs', '--', remote, ...candidates])
|
||||
const refs = new Set(stdout.split(/\r?\n/).map((line) => line.split('\t')[1]))
|
||||
return candidates.find((candidate) => refs.has(candidate)) === source
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Only configured fetch destinations establish tracking authority, regardless of namespace.
|
||||
export async function readGitRemoteTrackingRef(
|
||||
runGit: GitCommandRunner,
|
||||
@@ -51,11 +78,22 @@ export async function readGitRemoteTrackingRef(
|
||||
if (!from || !to || extra !== undefined) {
|
||||
continue
|
||||
}
|
||||
const match = refspecMatch(from, source)
|
||||
const match =
|
||||
refspecMatch(from, source) ??
|
||||
(!from.includes('*') &&
|
||||
!from.startsWith('refs/') &&
|
||||
(await resolveAbbreviatedFetchSource(runGit, remote, from, source))
|
||||
? ''
|
||||
: null)
|
||||
if (match === null || from.includes('*') !== to.includes('*') || to.split('*').length > 2) {
|
||||
continue
|
||||
}
|
||||
const ref = to.replace('*', () => match)
|
||||
const destination = to.replace('*', () => match)
|
||||
const ref = destination.startsWith('refs/')
|
||||
? destination
|
||||
: /^(heads|tags|remotes)\//.test(destination)
|
||||
? `refs/${destination}`
|
||||
: `refs/heads/${destination}`
|
||||
if (!isSafeGitRefName(ref)) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import {
|
||||
assertGitReviewPushAuthority,
|
||||
readGitReviewPushAuthority
|
||||
} from './git-review-push-authority'
|
||||
import type { GitPushTarget } from './worktree/types'
|
||||
|
||||
const target: GitPushTarget = {
|
||||
remoteName: 'origin',
|
||||
branchName: 'feature',
|
||||
reviewHead: {
|
||||
provider: 'github',
|
||||
host: 'github.com',
|
||||
repository: 'team/repo',
|
||||
branchName: 'feature'
|
||||
}
|
||||
}
|
||||
|
||||
it.each([
|
||||
[['https://github.com/team/repo.git'], 'verified'],
|
||||
[['git@github.com:team/repo.git'], 'verified'],
|
||||
[['ssh://git@ssh.github.com:443/team/repo.git'], 'verified'],
|
||||
[['https://github.com/other/repo.git'], 'mismatch'],
|
||||
[['https://github.com/team/other.git'], 'mismatch'],
|
||||
[['https://github.other.com/team/repo.git'], 'mismatch'],
|
||||
[['https://github.com/team/repo.git', 'https://github.com/other/repo.git'], 'ambiguous'],
|
||||
[['https://github.com/team/repo.git', 'git@github.com:team/repo.git'], 'ambiguous'],
|
||||
[[], 'unverifiable']
|
||||
])('binds the entire push destination set %j', async (urls, expected) => {
|
||||
const run = vi.fn(async () => ({
|
||||
stdout: [
|
||||
'origin\thttps://github.com/team/repo.git (fetch)',
|
||||
...urls.map((url) => `origin\t${url} (push)`)
|
||||
].join('\n')
|
||||
}))
|
||||
expect((await readGitReviewPushAuthority(run, target)).kind).toBe(expected)
|
||||
expect(run).toHaveBeenCalledWith(['remote', '-v'])
|
||||
if (expected !== 'verified') {
|
||||
await expect(assertGitReviewPushAuthority(run, target)).rejects.toThrow(String(expected))
|
||||
}
|
||||
})
|
||||
|
||||
it('does not promote legacy metadata, matching fetch identity, or remoteCreated', async () => {
|
||||
const run = vi.fn()
|
||||
expect(
|
||||
await readGitReviewPushAuthority(run, {
|
||||
remoteName: 'origin',
|
||||
branchName: 'feature',
|
||||
remoteCreated: true
|
||||
})
|
||||
).toEqual({ kind: 'unresolved' })
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('corroborates GitLab nested project identity and host aliases', async () => {
|
||||
const gitlab: GitPushTarget = {
|
||||
...target,
|
||||
reviewHead: {
|
||||
provider: 'gitlab',
|
||||
host: 'gitlab.com',
|
||||
repository: 'team/group/repo',
|
||||
branchName: 'feature'
|
||||
}
|
||||
}
|
||||
expect(
|
||||
(
|
||||
await readGitReviewPushAuthority(
|
||||
async () => ({
|
||||
stdout: 'origin\tssh://git@altssh.gitlab.com:443/team/group/repo.git (push)'
|
||||
}),
|
||||
gitlab
|
||||
)
|
||||
).kind
|
||||
).toBe('verified')
|
||||
})
|
||||
|
||||
it('reports failed host reads as unverifiable and never substitutes execution', async () => {
|
||||
const run = vi.fn().mockRejectedValue(new Error('disconnected'))
|
||||
expect((await readGitReviewPushAuthority(run, target)).kind).toBe('unverifiable')
|
||||
expect(run).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { parseGitHubRemoteIdentity } from './github/remote-identity-parsing'
|
||||
import { parseRemoteProjectRefCandidate } from './gitlab-project-ref-parser'
|
||||
import { normalizeGitLabRemoteHost } from './git-remote-host-alias'
|
||||
import { parseGitRemoteVerboseLine } from './git-remote-url-index'
|
||||
import type { GitPushTarget, GitReviewHead } from './worktree/types'
|
||||
|
||||
type GitRunner = (args: string[]) => Promise<{ stdout: string }>
|
||||
export type GitReviewPushAuthority = {
|
||||
kind: 'verified' | 'unresolved' | 'ambiguous' | 'unverifiable' | 'mismatch'
|
||||
reviewHead?: GitReviewHead
|
||||
}
|
||||
|
||||
export function reviewHeadKey(head: GitReviewHead | undefined): string | undefined {
|
||||
return head
|
||||
? JSON.stringify([
|
||||
head.provider,
|
||||
head.host.toLowerCase(),
|
||||
head.repository.toLowerCase(),
|
||||
head.branchName
|
||||
])
|
||||
: undefined
|
||||
}
|
||||
|
||||
function endpointMatchesHead(endpoint: string, head: GitReviewHead): boolean {
|
||||
if (head.provider === 'github') {
|
||||
const identity = parseGitHubRemoteIdentity(endpoint)
|
||||
return (
|
||||
!!identity &&
|
||||
identity.host.toLowerCase() === head.host.toLowerCase() &&
|
||||
`${identity.owner}/${identity.repo}`.toLowerCase() === head.repository.toLowerCase()
|
||||
)
|
||||
}
|
||||
const identity = parseRemoteProjectRefCandidate(endpoint)
|
||||
return (
|
||||
!!identity &&
|
||||
normalizeGitLabRemoteHost(identity.host) === head.host.toLowerCase() &&
|
||||
identity.path.toLowerCase() === head.repository.toLowerCase()
|
||||
)
|
||||
}
|
||||
|
||||
// Always read through the execution host; persisted hydration and fetch URLs are not push evidence.
|
||||
export async function readGitReviewPushAuthority(
|
||||
runGit: GitRunner,
|
||||
target: GitPushTarget
|
||||
): Promise<GitReviewPushAuthority> {
|
||||
const reviewHead = target.reviewHead
|
||||
if (!reviewHead) {
|
||||
return { kind: 'unresolved' }
|
||||
}
|
||||
if (reviewHead.branchName !== target.branchName) {
|
||||
return { kind: 'mismatch', reviewHead }
|
||||
}
|
||||
try {
|
||||
const { stdout } = await runGit(['remote', '-v'])
|
||||
const entries = stdout
|
||||
.split(/\r?\n/)
|
||||
.map(parseGitRemoteVerboseLine)
|
||||
.filter((entry) => entry !== null)
|
||||
if (entries.length > 512 || new Set(entries.map((entry) => entry.name)).size > 128) {
|
||||
return { kind: 'unverifiable', reviewHead }
|
||||
}
|
||||
const endpoints = entries.filter(
|
||||
(entry) => entry.name === target.remoteName && entry.direction === 'push'
|
||||
)
|
||||
if (endpoints.length > 1) {
|
||||
return { kind: 'ambiguous', reviewHead }
|
||||
}
|
||||
if (!endpoints[0]) {
|
||||
return { kind: 'unverifiable', reviewHead }
|
||||
}
|
||||
return {
|
||||
kind: endpointMatchesHead(endpoints[0].url, reviewHead) ? 'verified' : 'mismatch',
|
||||
reviewHead
|
||||
}
|
||||
} catch {
|
||||
return { kind: 'unverifiable', reviewHead }
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertGitReviewPushAuthority(
|
||||
runGit: GitRunner,
|
||||
target: GitPushTarget
|
||||
): Promise<void> {
|
||||
const result = await readGitReviewPushAuthority(runGit, target)
|
||||
if (result.kind !== 'verified') {
|
||||
throw new Error(
|
||||
`Review push endpoint authority is ${result.kind}. Resolve the review target and remote configuration before retrying.`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { GitReviewPushAuthority } from './git-review-push-authority'
|
||||
import type { GitUpstreamStatusIdentity } from './git-upstream-identity'
|
||||
export type GitFileStatus = 'modified' | 'added' | 'deleted' | 'renamed' | 'untracked' | 'copied'
|
||||
export type GitStagingArea = 'staged' | 'unstaged' | 'untracked'
|
||||
@@ -93,6 +94,8 @@ export type GitStatusResult = {
|
||||
// Kept as a named type because explicit upstream refreshes can still fail for
|
||||
// reasons unrelated to working-tree status (e.g., no upstream is expected).
|
||||
export type GitUpstreamStatus = {
|
||||
/** Absent on old peers: never authorizes a review push. */
|
||||
reviewPushAuthority?: GitReviewPushAuthority
|
||||
hasUpstream: boolean
|
||||
upstreamName?: string
|
||||
/** Optional on older execution hosts; absence does not establish operation identity. */
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { normalizeGitHubRemoteHost } from '../git-remote-host-alias'
|
||||
import type { GitHubOwnerRepo } from './pull-request-types'
|
||||
|
||||
export type GitHubRemoteIdentity = GitHubOwnerRepo & { host: string }
|
||||
|
||||
// Why: HTTP ports identify the GHES web/API endpoint; SSH and git ports are
|
||||
// transport-only and must not leak into gh's host identity.
|
||||
function hostFromRemoteUrl(url: URL): string {
|
||||
const protocol = url.protocol.toLowerCase()
|
||||
return protocol === 'http:' || protocol === 'https:' ? url.host : url.hostname
|
||||
}
|
||||
|
||||
function parseGitHubRemotePath(path: string): Pick<GitHubRemoteIdentity, 'owner' | 'repo'> | null {
|
||||
const parts = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/')
|
||||
if (parts.length !== 2) {
|
||||
return null
|
||||
}
|
||||
const [owner, repoWithSuffix] = parts
|
||||
const repo = repoWithSuffix.replace(/\.git$/i, '')
|
||||
if (!owner || !repo) {
|
||||
return null
|
||||
}
|
||||
return { owner, repo }
|
||||
}
|
||||
|
||||
/** SCP-style / ssh:// / git+ssh:// remotes may use an OpenSSH Host alias. */
|
||||
export function remoteUrlUsesSshTransport(remoteUrl: string): boolean {
|
||||
const trimmed = remoteUrl.trim().toLowerCase()
|
||||
return (
|
||||
trimmed.startsWith('git@') || trimmed.startsWith('ssh://') || trimmed.startsWith('git+ssh://')
|
||||
)
|
||||
}
|
||||
|
||||
/** SSH transport host as written, preserving SCP alias case. */
|
||||
export function rawSshTransportHost(remoteUrl: string): string | null {
|
||||
const trimmed = remoteUrl.trim()
|
||||
const scpMatch = trimmed.match(/^git@([^:]+):/i)
|
||||
if (scpMatch) {
|
||||
return scpMatch[1]
|
||||
}
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
if (!['ssh:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
return url.hostname || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Non-GitHub SSH host that may need OpenSSH alias expansion. */
|
||||
export function gitHubSshConfigHostAlias(remoteUrl: string): string | null {
|
||||
if (!remoteUrlUsesSshTransport(remoteUrl)) {
|
||||
return null
|
||||
}
|
||||
const identity = parseGitHubRemoteIdentity(remoteUrl)
|
||||
if (!identity || identity.host === 'github.com') {
|
||||
return null
|
||||
}
|
||||
return rawSshTransportHost(remoteUrl) ?? identity.host
|
||||
}
|
||||
|
||||
export function parseGitHubRemoteIdentity(remoteUrl: string): GitHubRemoteIdentity | null {
|
||||
const trimmed = remoteUrl.trim()
|
||||
const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/i)
|
||||
if (sshMatch) {
|
||||
return { host: normalizeGitHubRemoteHost(sshMatch[1]), owner: sshMatch[2], repo: sshMatch[3] }
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
if (!['git:', 'git+ssh:', 'http:', 'https:', 'ssh:'].includes(url.protocol.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
const path = parseGitHubRemotePath(url.pathname)
|
||||
return path ? { host: normalizeGitHubRemoteHost(hostFromRemoteUrl(url)), ...path } : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGitHubOwnerRepo(remoteUrl: string): GitHubOwnerRepo | null {
|
||||
const identity = parseGitHubRemoteIdentity(remoteUrl)
|
||||
if (!identity || identity.host.toLowerCase() !== 'github.com') {
|
||||
return null
|
||||
}
|
||||
return { owner: identity.owner, repo: identity.repo }
|
||||
}
|
||||
|
||||
/** Parse github.com identity using an expanded SSH HostName. */
|
||||
export function parseGitHubOwnerRepoWithResolvedSshHostname(
|
||||
remoteUrl: string,
|
||||
resolvedSshHostname: string | null | undefined
|
||||
): GitHubOwnerRepo | null {
|
||||
const direct = parseGitHubOwnerRepo(remoteUrl)
|
||||
if (direct) {
|
||||
return direct
|
||||
}
|
||||
if (!remoteUrlUsesSshTransport(remoteUrl)) {
|
||||
return null
|
||||
}
|
||||
if (!resolvedSshHostname?.trim()) {
|
||||
return null
|
||||
}
|
||||
const identity = parseGitHubRemoteIdentity(remoteUrl)
|
||||
if (!identity) {
|
||||
return null
|
||||
}
|
||||
if (normalizeGitHubRemoteHost(resolvedSshHostname.trim()) !== 'github.com') {
|
||||
return null
|
||||
}
|
||||
return { owner: identity.owner, repo: identity.repo }
|
||||
}
|
||||
|
||||
/** Effective forge host after optional SSH config HostName expansion. */
|
||||
export function effectiveGitHubRemoteHost(
|
||||
parsedHost: string,
|
||||
resolvedSshHostname?: string | null
|
||||
): string {
|
||||
const candidate = resolvedSshHostname?.trim() || parsedHost
|
||||
return normalizeGitHubRemoteHost(candidate)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { GitLabProjectRef } from './gitlab-types'
|
||||
|
||||
export type ProjectRef = GitLabProjectRef
|
||||
|
||||
/**
|
||||
* Hosts always treated as GitLab. Self-hosted instances are added at
|
||||
* runtime via `getGlabKnownHosts()`, which inspects `glab auth status`.
|
||||
*/
|
||||
export const DEFAULT_GITLAB_HOSTS = ['gitlab.com'] as const
|
||||
|
||||
export function normalizeGitLabHost(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
// Why: host recognition is port-aware so two services on the same hostname
|
||||
// but different ports (e.g. a GitLab on :8080 and a Gitea on :3030) are not
|
||||
// conflated. The hostname (port-less) part is kept for legacy known-host
|
||||
// entries that were recorded without a port.
|
||||
function hostnameOf(host: string): string {
|
||||
// `host` may be `name` or `name:port`. Strip a trailing `:digits` port.
|
||||
return host.replace(/:\d+$/, '')
|
||||
}
|
||||
|
||||
function stripGitSuffix(path: string): string {
|
||||
return path.replace(/\/+$/, '').replace(/\.git$/i, '')
|
||||
}
|
||||
|
||||
// Why: the GitLab host identity is the web/API endpoint, which is what `glab
|
||||
// --hostname` and the known-hosts list speak in terms of. For http(s)
|
||||
// remotes the URL port IS that endpoint port (e.g. self-hosted on :8080),
|
||||
// so it must be kept. For ssh/git remotes the port is a transport port
|
||||
// (e.g. ssh on :2222) that does not identify the GitLab instance, so it is
|
||||
// dropped and only the hostname is used.
|
||||
function hostIdentityFromUrl(url: URL): string {
|
||||
const protocol = url.protocol.toLowerCase()
|
||||
if (protocol === 'http:' || protocol === 'https:') {
|
||||
return url.host
|
||||
}
|
||||
return url.hostname
|
||||
}
|
||||
|
||||
function makeProjectRefForTrustedHost(host: string, path: string): ProjectRef | null {
|
||||
const normalizedHost = normalizeGitLabHost(host)
|
||||
const normalizedPath = stripGitSuffix(path.replace(/^\/+/, '')).trim()
|
||||
// Reject paths without at least one group segment — `gitlab.com:foo`
|
||||
// alone is not a project reference.
|
||||
if (!normalizedPath.includes('/')) {
|
||||
return null
|
||||
}
|
||||
return { host: normalizedHost, path: normalizedPath }
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `urlHost` (which may include a `:port`) match a known-host entry?
|
||||
* - An exact match (including any port) always counts.
|
||||
* - A known entry WITHOUT a port also matches a URL host on the same
|
||||
* hostname regardless of the URL's port — this preserves recognition for
|
||||
* legacy `gitlab.com` / bare-hostname known entries.
|
||||
* - A known entry WITH a port only matches a URL host with the exact same
|
||||
* port, so `gitlab.example.com:8443` does not accept a
|
||||
* `gitea.example.com:3000` (or same-host different-port) remote.
|
||||
*/
|
||||
function knownHostMatches(urlHost: string, knownHost: string): boolean {
|
||||
if (urlHost === knownHost) {
|
||||
return true
|
||||
}
|
||||
if (hostnameOf(knownHost) === knownHost) {
|
||||
// Known entry has no port — match on hostname alone.
|
||||
return hostnameOf(urlHost) === knownHost
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function makeProjectRef(
|
||||
host: string,
|
||||
path: string,
|
||||
knownHosts: readonly string[]
|
||||
): ProjectRef | null {
|
||||
const normalizedHost = normalizeGitLabHost(host)
|
||||
const normalizedKnownHosts = knownHosts.map(normalizeGitLabHost)
|
||||
if (!normalizedKnownHosts.some((knownHost) => knownHostMatches(normalizedHost, knownHost))) {
|
||||
return null
|
||||
}
|
||||
return makeProjectRefForTrustedHost(normalizedHost, path)
|
||||
}
|
||||
|
||||
export function parseRemoteProjectRefCandidate(remoteUrl: string): ProjectRef | null {
|
||||
const trimmed = remoteUrl.trim()
|
||||
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
||||
const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/)
|
||||
if (scpLike) {
|
||||
return makeProjectRefForTrustedHost(scpLike[1], scpLike[2])
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
return makeProjectRefForTrustedHost(hostIdentityFromUrl(url), url.pathname)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGitLabProjectRef(
|
||||
remoteUrl: string,
|
||||
knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS
|
||||
): ProjectRef | null {
|
||||
const trimmed = remoteUrl.trim()
|
||||
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
||||
const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/)
|
||||
if (scpLike) {
|
||||
return makeProjectRef(scpLike[1], scpLike[2], knownHosts)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
return makeProjectRef(hostIdentityFromUrl(url), url.pathname, knownHosts)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,8 @@ export type GitLabMRReviewersUpdateResult =
|
||||
| { ok: false; error: string }
|
||||
|
||||
export type GitLabWorkItem = {
|
||||
/** Present only when provider project IDs establish the source repository. */
|
||||
headProjectRef?: GitLabProjectRef
|
||||
id: string
|
||||
type: 'issue' | 'mr'
|
||||
number: number
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { reviewHeadKey } from './git-review-push-authority'
|
||||
import type { GitUpstreamStatus } from './git-status-types'
|
||||
import type { GitPushTarget } from './worktree/types'
|
||||
|
||||
@@ -10,10 +11,13 @@ export function hasUsableHostedReviewPushTarget(args: {
|
||||
const identity = args.upstreamStatus?.upstreamIdentity
|
||||
if (args.pushTarget) {
|
||||
return (
|
||||
args.upstreamStatus === undefined ||
|
||||
(identity?.selector.kind === 'named-remote' &&
|
||||
identity.selector.value === args.pushTarget.remoteName &&
|
||||
identity.mergeRef === `refs/heads/${args.pushTarget.branchName}`)
|
||||
!!args.pushTarget.reviewHead &&
|
||||
args.upstreamStatus?.reviewPushAuthority?.kind === 'verified' &&
|
||||
reviewHeadKey(args.upstreamStatus.reviewPushAuthority.reviewHead) ===
|
||||
reviewHeadKey(args.pushTarget.reviewHead) &&
|
||||
identity?.selector.kind === 'named-remote' &&
|
||||
identity.selector.value === args.pushTarget.remoteName &&
|
||||
identity.mergeRef === `refs/heads/${args.pushTarget.branchName}`
|
||||
)
|
||||
}
|
||||
if (args.hasResolvableHostedReviewPushTargetLink) {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { reviewHeadKey } from './git-review-push-authority'
|
||||
import { isPositiveHostedReviewNumber } from './hosted-review'
|
||||
import type { GitPushTarget, Worktree } from './worktree/types'
|
||||
|
||||
export function linkedReviewOperationTarget(
|
||||
worktree: Pick<Worktree, 'linkedPR' | 'linkedGitLabMR' | 'pushTarget'> | undefined,
|
||||
requested: GitPushTarget | undefined,
|
||||
fallbackGitHubPR?: number
|
||||
): GitPushTarget | undefined {
|
||||
if (
|
||||
!isPositiveHostedReviewNumber(worktree?.linkedPR) &&
|
||||
!isPositiveHostedReviewNumber(worktree?.linkedGitLabMR) &&
|
||||
!isPositiveHostedReviewNumber(fallbackGitHubPR)
|
||||
) {
|
||||
return requested
|
||||
}
|
||||
const resolved = worktree?.pushTarget
|
||||
if (!resolved?.reviewHead) {
|
||||
throw new Error('The linked review push target is still unresolved. Retry after it loads.')
|
||||
}
|
||||
if (
|
||||
requested &&
|
||||
(requested.remoteName !== resolved.remoteName ||
|
||||
requested.branchName !== resolved.branchName ||
|
||||
requested.remoteUrl !== resolved.remoteUrl ||
|
||||
reviewHeadKey(requested.reviewHead) !== reviewHeadKey(resolved.reviewHead))
|
||||
) {
|
||||
throw new Error('The linked review push target changed. Retry with the current target.')
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
@@ -181,7 +181,16 @@ export type AutomationWorkspaceProvenanceRequest = {
|
||||
createRequestId: string
|
||||
}
|
||||
|
||||
export type GitReviewHead = {
|
||||
provider: 'github' | 'gitlab'
|
||||
host: string
|
||||
repository: string
|
||||
branchName: string
|
||||
}
|
||||
|
||||
export type GitPushTarget = {
|
||||
/** Provider-reported identity; execution must corroborate current operation endpoints. */
|
||||
reviewHead?: GitReviewHead
|
||||
remoteName: string
|
||||
branchName: string
|
||||
remoteUrl?: string
|
||||
|
||||
Reference in New Issue
Block a user