mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
* Offer Resolve with AI for paused merge, rebase, and cherry-pick A paused operation can need recovery after every conflict is staged, but the Resolve with AI action only appeared when unmerged paths remained. The operation banner now offers it whenever AI actions are visible and an operation is in progress. Starting an AI conflict-resolution agent is also blocked while a Git operation is running. The composer, the banner and card buttons, and the agent launch itself check the running-operation state, so a launch that began during agent detection cannot start while Git is still running. * Block paused-operation recovery only on Orca's own Git work The previous gate scanned the host's process table and refused "Resolve with AI" whenever it could not prove no Git process was running. That refused recovery on older paired hosts, on Windows whenever any Git process ran anywhere on the machine, and while a paged `git reflog` or `git grep` was open in a terminal. - The host tracks Orca's own mutating Git commands per worktree (including ones still queued) and reports them through `git.mutationInFlight`. Reads and fetch/push/ls-remote are not tracked. - Recovery is refused only when the host reports that work. Older hosts without the method keep today's behavior. - The gate applies only to launches from the paused-operation banner and conflict card, not every resolveConflicts launch, so the Checks panel's PR-conflict flow is unchanged. - The agent prompt now tells the agent to stop if index.lock exists or another git command is running, which covers Git started outside Orca on every host. With no unmerged files, the prompt asks the agent to find why Git paused instead of listing zero conflicts. * Preserve Git mutation state until terminated commands exit - Cancel stale recovery checks and agent launches when their dialog or scope closes - Guide rebase recovery agents to amend fixes after failed exec steps * Cover failed rebase exec recovery with real Git * Recognize global Git options in worktree mutation tracking
188 lines
6.3 KiB
TypeScript
188 lines
6.3 KiB
TypeScript
import * as path from 'node:path'
|
|
import { readFile, realpath, stat } from 'node:fs/promises'
|
|
import { waitForPromiseWithSignal } from './abort-signal-reason'
|
|
import { parseGitdirMarkerPayload } from './gitdir-marker-payload'
|
|
import { runWithGitOperationLock } from './git-operation-lock'
|
|
import { findGitSubcommandIndex, GIT_GLOBAL_OPTIONS_WITH_VALUE } from './git-command-classification'
|
|
import {
|
|
foldWslUncPathCaseInsensitiveParts,
|
|
isWslUncPath,
|
|
toWindowsWslDrivePath
|
|
} from './wsl-paths'
|
|
|
|
function abortError(): Error {
|
|
const error = new Error('The operation was aborted.')
|
|
error.name = 'AbortError'
|
|
return error
|
|
}
|
|
type GitFetchHeadCommand = { needsLock: boolean; cwd: string; gitDir?: string }
|
|
|
|
export function resolveGitFetchHeadCommand(
|
|
args: readonly string[],
|
|
initialCwd: string
|
|
): GitFetchHeadCommand {
|
|
let cwd = initialCwd
|
|
let gitDir: string | undefined
|
|
const subcommandIndex = findGitSubcommandIndex(args)
|
|
for (let index = 0; index < args.length && index !== subcommandIndex; index += 1) {
|
|
const arg = args[index]
|
|
if (arg === '-C' && args[index + 1]) {
|
|
cwd = path.resolve(cwd, args[index + 1])
|
|
index += 1
|
|
continue
|
|
}
|
|
if (arg.startsWith('-C') && arg.length > 2) {
|
|
cwd = path.resolve(cwd, arg.slice(2))
|
|
continue
|
|
}
|
|
if (arg === '--git-dir' && args[index + 1]) {
|
|
gitDir = path.resolve(cwd, args[index + 1])
|
|
index += 1
|
|
continue
|
|
}
|
|
if (arg.startsWith('--git-dir=')) {
|
|
gitDir = path.resolve(cwd, arg.slice('--git-dir='.length))
|
|
continue
|
|
}
|
|
if (GIT_GLOBAL_OPTIONS_WITH_VALUE.has(arg)) {
|
|
index += 1
|
|
}
|
|
}
|
|
const subcommand = args[subcommandIndex]
|
|
if (subcommand === 'pull') {
|
|
return { needsLock: true, cwd, gitDir }
|
|
}
|
|
if (subcommand !== 'fetch') {
|
|
return { needsLock: false, cwd, gitDir }
|
|
}
|
|
let writesFetchHead = true
|
|
let updatesRemoteTrackingRef = false
|
|
for (const arg of args.slice(subcommandIndex + 1)) {
|
|
if (arg === '--no-write-fetch-head') {
|
|
writesFetchHead = false
|
|
} else if (arg === '--write-fetch-head') {
|
|
writesFetchHead = true
|
|
} else if (arg.includes(':refs/remotes/')) {
|
|
updatesRemoteTrackingRef = true
|
|
}
|
|
}
|
|
// Why: explicit tracking-ref updates race sibling-worktree fetch transactions even without FETCH_HEAD.
|
|
return { needsLock: writesFetchHead || updatesRemoteTrackingRef, cwd, gitDir }
|
|
}
|
|
|
|
/**
|
|
* `node:path` itself, spelled so a test can drive the Win32 rules on a POSIX runner. Node picks the
|
|
* same submodule for the default export, so this is the host's own behaviour, not an emulation.
|
|
*/
|
|
function hostPath(): typeof path.posix {
|
|
return process.platform === 'win32' ? path.win32 : path.posix
|
|
}
|
|
|
|
/**
|
|
* Where a Git metadata pointer (a `.git` gitfile payload or a `commondir`) lands in the reading
|
|
* host's namespace.
|
|
*
|
|
* Why: git running inside WSL writes these in the guest namespace, and under a drive-spelled base
|
|
* `path.resolve` reads `/mnt/c/repo/.git` as the non-existent `C:\mnt\c\repo\.git`, so the commondir
|
|
* walk dead-ends and every linked worktree of one repo gets its own fetch lane.
|
|
*
|
|
* Why the UNC base is excluded rather than handed to `resolveGitMetadataPath`: win32 `path.resolve`
|
|
* already carries a WSL UNC base's distro onto a guest-rooted pointer, and a main worktree's `.git`
|
|
* is a directory with no pointer to translate, so its key stays on that UNC spelling. Rewriting only
|
|
* the linked worktrees to `C:\...` would split one repo across two lanes.
|
|
*/
|
|
function resolveMetadataPointer(basePath: string, rawPointer: string): string {
|
|
if (process.platform === 'win32' && !isWslUncPath(basePath)) {
|
|
const drivePath = toWindowsWslDrivePath(rawPointer)
|
|
if (drivePath) {
|
|
return drivePath
|
|
}
|
|
}
|
|
return hostPath().resolve(basePath, rawPointer)
|
|
}
|
|
|
|
/**
|
|
* Windows aliases `\\wsl$` to `\\wsl.localhost` and folds the distro name and any drvfs tail
|
|
* case-insensitively, so two spellings of one WSL repo must not open two fetch lanes.
|
|
*/
|
|
function canonicalizeFetchHeadLockKey(key: string): string {
|
|
if (process.platform !== 'win32') {
|
|
return key
|
|
}
|
|
return foldWslUncPathCaseInsensitiveParts(key) ?? key
|
|
}
|
|
|
|
/** `realpath` takes no signal, so a hung 9P/UNC lookup outlives the cancelled fetch without this. */
|
|
async function realpathOrResolve(target: string, signal: AbortSignal | undefined): Promise<string> {
|
|
try {
|
|
return await waitForPromiseWithSignal(realpath(target), signal)
|
|
} catch {
|
|
// Why the synthetic error rather than the signal's reason: callers classify on `name`.
|
|
if (signal?.aborted) {
|
|
throw abortError()
|
|
}
|
|
return hostPath().resolve(target)
|
|
}
|
|
}
|
|
|
|
async function fetchLockPath(
|
|
worktreePath: string,
|
|
signal: AbortSignal | undefined,
|
|
explicitGitDir?: string
|
|
): Promise<string> {
|
|
let current = await realpathOrResolve(worktreePath, signal)
|
|
let gitDir = explicitGitDir
|
|
while (!gitDir) {
|
|
const dotGitPath = hostPath().join(current, '.git')
|
|
try {
|
|
const metadata = await waitForPromiseWithSignal(stat(dotGitPath), signal)
|
|
if (metadata.isDirectory()) {
|
|
gitDir = dotGitPath
|
|
break
|
|
}
|
|
const contents = await readFile(dotGitPath, { encoding: 'utf-8', signal })
|
|
const marker = parseGitdirMarkerPayload(contents)
|
|
if (marker) {
|
|
gitDir = resolveMetadataPointer(current, marker)
|
|
break
|
|
}
|
|
} catch {
|
|
if (signal?.aborted) {
|
|
throw abortError()
|
|
}
|
|
}
|
|
const parent = hostPath().dirname(current)
|
|
if (parent === current) {
|
|
gitDir = hostPath().join(current, '.git')
|
|
break
|
|
}
|
|
current = parent
|
|
}
|
|
let commonGitDir = gitDir
|
|
try {
|
|
const contents = await readFile(hostPath().join(gitDir, 'commondir'), {
|
|
encoding: 'utf-8',
|
|
signal
|
|
})
|
|
if (contents.trim()) {
|
|
commonGitDir = resolveMetadataPointer(gitDir, contents.trim())
|
|
}
|
|
} catch {
|
|
if (signal?.aborted) {
|
|
throw abortError()
|
|
}
|
|
}
|
|
const canonicalGitDir = await realpathOrResolve(commonGitDir, signal)
|
|
return canonicalizeFetchHeadLockKey(hostPath().join(canonicalGitDir, 'FETCH_HEAD'))
|
|
}
|
|
|
|
export async function runWithGitFetchHeadLock<T>(
|
|
worktreePath: string,
|
|
signal: AbortSignal | undefined,
|
|
run: () => Promise<T>,
|
|
explicitGitDir?: string
|
|
): Promise<T> {
|
|
const key = await fetchLockPath(worktreePath, signal, explicitGitDir)
|
|
return runWithGitOperationLock(key, signal, run)
|
|
}
|