mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
* feat(worktrees): support project-level worktree.sharedDirectories in orca.yaml Follow-up to #7549: `.worktreeinclude` copies gitignored paths into each new worktree, which is right for `.env`/`.vscode/` but wrong for large rebuildable directories. Copying `node_modules` per worktree is slow and duplicates disk, and each worktree's install then diverges. Adds `worktree.sharedDirectories` to `orca.yaml` — a versioned, in-repo list of gitignored directories that are symlinked (shared) into every new local worktree, so one install serves them all. Adds to, never replaces, the per-user Worktree Shared Paths setting. `createWorktreeSharedPaths` uses a new 'share' materialization mode that always symlinks. The existing 'link' mode APFS clone-copies on macOS, which would give each worktree an independent node_modules and defeat the point; 'link' and 'copy' behavior are unchanged. Entries must exist as gitignored directories in the primary checkout; absolute paths, `..` traversal, and `.git` are rejected. Resolution never throws, so a malformed orca.yaml cannot block worktree creation. Remote (SSH) creation skips this, as it does symlink paths and `.worktreeinclude`. Closes #10451 * fix(worktrees): keep worktrees deletable after sharing a directory A directory-only ignore rule (`node_modules/`, the common spelling) matches the primary checkout's real directory, so the shared directory resolves and gets symlinked — but it never matches the worktree's symlink, so Git reports that link as untracked. Deletion only tolerated the per-user shared paths, so every worktree in such a repo became permanently dirty: the clean preflight threw "uncommitted or untracked changes" and `git worktree remove` refused without --force. Feed the configured `orca.yaml` shared directories into the same tolerate-and-unlink machinery the per-user shared paths already use, at both deletion call sites. The names are read unfiltered, since the create-time resolver drops exactly the entry deletion needs most. * test(worktrees): register createWorktreeSharedPaths in the runtime symlink mock orca-runtime.ts imports createWorktreeSharedPaths, but the vi.mock factory for ../ipc/worktree-symlinks never listed it. Vitest resolves omitted exports lazily, so this only stays green because no runtime test configures a repo with worktree.sharedDirectories — the first one that does would fail on a mock resolution error rather than on its own assertion. * fix(source-control): don't count shared symlinks as uncommitted changes A directory-only ignore rule (`node_modules/`) matches the primary checkout's real directory but never the worktree's symlink, so Git reports the shared link as untracked for the life of the worktree. That made every affected worktree read as dirty: a phantom row in the diff view, and Create PR blocked with `blockedReason: 'dirty'` telling the user to commit an entry they cannot commit, because it is a symlink Orca created. Status and the review-creation preflight now drop untracked entries that are both declared shared (per-user shared paths or orca.yaml sharedDirectories) and actually symlinks on disk. Both conditions are required, so a regular file at a declared name, or a symlink nobody declared, still counts as user work. The decision fails closed: anything not positively identified stays dirty. The preflight moves to `--porcelain -z` so paths with spaces or non-ASCII bytes are compared raw rather than C-quoted, with a parser that consumes the origin field a rename emits instead of reading it as its own record. Symlink detection moves to a leaf module: importing it from ipc/worktree-symlinks would pull APFS cloning, and its child_process dependency, into the status graph. SSH is unaffected and left alone — remote worktree creation skips the symlink and shared-directory passes, so a remote worktree never has one. * fix(source-control): wire shared links into local status * fix(worktrees): resolve the status repo once and reject uncollapsed shared paths `git:status` resolved the registered worktree's repo twice per call — once inside `getLocalGitOptionsForRegisteredWorktree` and again for the shared-link lookup — walking every repo's worktree meta on a polling path. `apps/./web` also survived `sharedDirectories` normalization: `resolve()` collapses it when the symlink is created but Git reports the collapsed path, so every later comparison misses and the link reads as permanent untracked work. Also stop resolving shared links for SSH repos in review creation: `repo.path` names a path on the remote host. Adds the missing wiring coverage for review creation and runtime status, plus the untracked-only conjunct in both filters — all four were mutation-verified to leave the suite green before these tests. * test(worktrees): pin the resolver-to-status seam for shared directories The resolver's output and the status filter were only tested apart — status used a hardcoded `['node_modules']`. Feed the resolved directories back through `getWorktreeSharedLinkPaths` into a real `getStatus` so a resolver that ever returned a differently-spelled path can no longer leave the link showing as a phantom untracked row. * fix(worktrees): try a directory junction before a symlink on Windows A plain `fs.symlink` needs Developer Mode or admin on Windows, so an ordinary Windows user got EPERM, the per-path catch logged and continued, and the worktree came up with no shared directory and no signal. A directory junction needs no privilege, and the rest of the codebase already uses one for win32 directory links. The symlink stays as a fallback rather than being replaced: a junction cannot target a UNC path, and a WSL project's repo lives behind one, so replacing it outright would trade the local-volume bug for a WSL regression. Safe for the removal path either way — Windows reports a junction as both a symlink and a directory, so the `isSymbolicLink()` unlink that runs before `git worktree remove` still fires and still refuses to follow it. * fix(worktrees): keep NUL bytes and tolerated links out of the removal error The removal preflight switches to `git status --porcelain -z` whenever it has shared links to tolerate, then attached that raw stdout to the error. `.trim()` does not strip interior NULs, so the message reached the user as `?? node_modules<NUL>?? precious.txt<NUL>` — raw control bytes, and it named the shared link, the one entry that is not the user's work and cannot be committed away. Parse the NUL-delimited output once and use it for both the clean verdict and the error text, so the two can never disagree about what blocks removal. The `-z` switch stays: it is what keeps paths with spaces or non-ASCII names comparable against the configured entry. * chore(worktrees): drop stray reformatting and note why the SSH guard exists Committing the merge staged 792 files, so lint-staged ran the formatter across all of them and rewrapped three renderer files that were already unformatted on main. Nothing was lost — they were byte-identical to main ignoring whitespace — but they showed up in the pull request as unrelated changed files. Restored to main's exact bytes. Committed with --no-verify on purpose: the pre-commit formatter is what introduced the rewrapping, so letting it run again would simply reapply it. Every check it would have run was run by hand instead — lint, typecheck, and the IPC and source-control suites all pass, and the three restored files are expected to fail a format check because that is main's current state. Also records why the connection guard on the shared-link lookup is not dead code: the remote dirty check ignores those paths, so the guard's only effect is avoiding a stray local read and the bad cache entry it would leave behind. * refactor(source-control): drop a scan-everything guard and freeze the cached list The dirty check built a filtered array only to read its length, so it always scanned every status record; asking whether any record is untracked stops at the first one and reads the same either way. The cached shared-directory list was also handhanded out by reference, so a caller that mutated it would corrupt every read for the rest of the cache window. Marking the return readonly prevents that at compile time; copying on return would work too but would allocate on the status-polling path, and there is exactly one caller, which only spreads it.
886 lines
32 KiB
TypeScript
886 lines
32 KiB
TypeScript
import { mkdtempSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { GlobalSettings } from '../../shared/types'
|
|
import type * as GitStatusModule from '../git/status'
|
|
import type * as CommitMessageTextGenerationModule from '../text-generation/commit-message-text-generation'
|
|
import type * as PullRequestContextModule from '../text-generation/pull-request-context'
|
|
import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git'
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
abortMerge: vi.fn(),
|
|
abortRebase: vi.fn(),
|
|
checkoutBranch: vi.fn(),
|
|
listLocalBranches: vi.fn(),
|
|
getStagedCommitContext: vi.fn(),
|
|
getPullRequestDraftContext: vi.fn(),
|
|
generateCommitMessageFromContext: vi.fn(),
|
|
generatePullRequestFieldsFromContext: vi.fn(),
|
|
resolveCommitMessageSettings: vi.fn(),
|
|
resolveHostedReviewBodyForGeneration: vi.fn(),
|
|
getSshGitProvider: vi.fn(),
|
|
getStatus: vi.fn()
|
|
}))
|
|
|
|
vi.mock('../git/status', async () => ({
|
|
...(await vi.importActual<typeof GitStatusModule>('../git/status')),
|
|
abortMerge: mocks.abortMerge,
|
|
abortRebase: mocks.abortRebase,
|
|
getStagedCommitContext: mocks.getStagedCommitContext,
|
|
getStatus: mocks.getStatus
|
|
}))
|
|
|
|
vi.mock('../git/checkout', () => ({
|
|
checkoutBranch: mocks.checkoutBranch,
|
|
listLocalBranches: mocks.listLocalBranches
|
|
}))
|
|
|
|
vi.mock('../text-generation/commit-message-text-generation', async () => ({
|
|
...(await vi.importActual<typeof CommitMessageTextGenerationModule>(
|
|
'../text-generation/commit-message-text-generation'
|
|
)),
|
|
generateCommitMessageFromContext: mocks.generateCommitMessageFromContext,
|
|
generatePullRequestFieldsFromContext: mocks.generatePullRequestFieldsFromContext,
|
|
resolveCommitMessageSettings: mocks.resolveCommitMessageSettings
|
|
}))
|
|
|
|
vi.mock('../text-generation/pull-request-context', async () => ({
|
|
...(await vi.importActual<typeof PullRequestContextModule>(
|
|
'../text-generation/pull-request-context'
|
|
)),
|
|
getPullRequestDraftContext: mocks.getPullRequestDraftContext
|
|
}))
|
|
|
|
vi.mock('../providers/ssh-git-dispatch', () => ({
|
|
getSshGitProvider: mocks.getSshGitProvider
|
|
}))
|
|
|
|
vi.mock('../source-control/pull-request-template', () => ({
|
|
resolveHostedReviewBodyForGeneration: mocks.resolveHostedReviewBodyForGeneration
|
|
}))
|
|
|
|
const tempDirs: string[] = []
|
|
|
|
function makeWorktree(path: string, linkedIssue: number | null = null): ResolvedRuntimeGitWorktree {
|
|
// Why: `satisfies Partial<…>` keeps every field name and type checked against the
|
|
// real worktree shape (the widening cast alone would let these tests keep passing
|
|
// against a `linkedIssue` key production no longer has) while still allowing the
|
|
// fixture to omit the fields these tests never read.
|
|
const worktree = {
|
|
id: 'wt-1',
|
|
repoId: 'repo-1',
|
|
path,
|
|
linkedIssue,
|
|
git: {
|
|
path,
|
|
branch: 'main',
|
|
isBare: false,
|
|
isMainWorktree: false,
|
|
head: 'a'.repeat(40)
|
|
}
|
|
} satisfies Partial<ResolvedRuntimeGitWorktree>
|
|
return worktree as unknown as ResolvedRuntimeGitWorktree
|
|
}
|
|
|
|
function makeCommands(worktreePath: string): RuntimeGitCommands {
|
|
return new RuntimeGitCommands({
|
|
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath) }),
|
|
getRuntimeSettings: () => ({}) as GlobalSettings
|
|
})
|
|
}
|
|
|
|
describe('RuntimeGitCommands', () => {
|
|
beforeEach(() => {
|
|
mocks.abortMerge.mockReset()
|
|
mocks.abortRebase.mockReset()
|
|
mocks.getStagedCommitContext.mockReset()
|
|
mocks.getPullRequestDraftContext.mockReset()
|
|
mocks.generateCommitMessageFromContext.mockReset()
|
|
mocks.generatePullRequestFieldsFromContext.mockReset()
|
|
mocks.resolveCommitMessageSettings.mockReset()
|
|
mocks.resolveHostedReviewBodyForGeneration.mockReset()
|
|
mocks.resolveHostedReviewBodyForGeneration.mockImplementation(async ({ body }) => body)
|
|
mocks.getSshGitProvider.mockReset()
|
|
mocks.getStatus.mockReset()
|
|
mocks.checkoutBranch.mockReset()
|
|
mocks.listLocalBranches.mockReset()
|
|
})
|
|
|
|
afterEach(() => {
|
|
while (tempDirs.length > 0) {
|
|
rmSync(tempDirs.pop()!, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('aborts a local merge through the resolved worktree', async () => {
|
|
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
|
tempDirs.push(worktreePath)
|
|
const commands = makeCommands(worktreePath)
|
|
mocks.abortMerge.mockResolvedValue(undefined)
|
|
|
|
await expect(commands.abortRuntimeGitMerge('id:wt-1')).resolves.toEqual({ ok: true })
|
|
|
|
expect(mocks.abortMerge).toHaveBeenCalledWith(worktreePath, {})
|
|
})
|
|
|
|
// Why: a directory-only ignore rule (`node_modules/`) never matches the shared
|
|
// symlink, so Git reports it untracked forever. Runtime/CLI status has to tell
|
|
// getStatus which untracked entries are Orca's own (issue #10451); nothing else
|
|
// asserts this call site supplies them.
|
|
it('passes the repo shared link paths through local runtime status', async () => {
|
|
mocks.getStatus.mockResolvedValue({ entries: [], conflictOperation: 'none' })
|
|
const commands = new RuntimeGitCommands({
|
|
resolveRuntimeGitTarget: async () => ({
|
|
worktree: makeWorktree('/workspace/feature'),
|
|
repo: { path: '/workspace/repo', symlinkPaths: ['node_modules'] } as never
|
|
}),
|
|
getRuntimeSettings: () => ({}) as GlobalSettings
|
|
})
|
|
|
|
await commands.getRuntimeGitStatus('id:wt-1')
|
|
|
|
expect(mocks.getStatus).toHaveBeenCalledWith('/workspace/feature', {
|
|
sharedLinkPaths: ['node_modules']
|
|
})
|
|
})
|
|
|
|
it('does not resolve shared link paths for a remote runtime status', async () => {
|
|
const provider = { getStatus: vi.fn().mockResolvedValue({ entries: [] }) }
|
|
mocks.getSshGitProvider.mockReturnValue(provider)
|
|
const commands = new RuntimeGitCommands({
|
|
resolveRuntimeGitTarget: async () => ({
|
|
worktree: makeWorktree('/remote/repo'),
|
|
repo: { path: '/remote/repo', symlinkPaths: ['node_modules'] } as never,
|
|
connectionId: 'conn-1'
|
|
}),
|
|
getRuntimeSettings: () => ({}) as GlobalSettings
|
|
})
|
|
|
|
await commands.getRuntimeGitStatus('id:wt-1')
|
|
|
|
expect(provider.getStatus).toHaveBeenCalledWith('/remote/repo')
|
|
expect(mocks.getStatus).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('aborts a remote merge through the SSH git provider', async () => {
|
|
const provider = { abortMerge: vi.fn().mockResolvedValue(undefined) }
|
|
mocks.getSshGitProvider.mockReturnValue(provider)
|
|
const commands = new RuntimeGitCommands({
|
|
resolveRuntimeGitTarget: async () => ({
|
|
worktree: makeWorktree('/remote/repo'),
|
|
connectionId: 'conn-1'
|
|
}),
|
|
getRuntimeSettings: () => ({}) as GlobalSettings
|
|
})
|
|
|
|
await expect(commands.abortRuntimeGitMerge('id:wt-1')).resolves.toEqual({ ok: true })
|
|
|
|
expect(provider.abortMerge).toHaveBeenCalledWith('/remote/repo')
|
|
expect(mocks.abortMerge).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('aborts a local rebase through the resolved worktree', async () => {
|
|
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
|
tempDirs.push(worktreePath)
|
|
const commands = makeCommands(worktreePath)
|
|
mocks.abortRebase.mockResolvedValue(undefined)
|
|
|
|
await expect(commands.abortRuntimeGitRebase('id:wt-1')).resolves.toEqual({ ok: true })
|
|
|
|
expect(mocks.abortRebase).toHaveBeenCalledWith(worktreePath, {})
|
|
})
|
|
|
|
it('aborts a remote rebase through the SSH git provider', async () => {
|
|
const provider = { abortRebase: vi.fn().mockResolvedValue(undefined) }
|
|
mocks.getSshGitProvider.mockReturnValue(provider)
|
|
const commands = new RuntimeGitCommands({
|
|
resolveRuntimeGitTarget: async () => ({
|
|
worktree: makeWorktree('/remote/repo'),
|
|
connectionId: 'conn-1'
|
|
}),
|
|
getRuntimeSettings: () => ({}) as GlobalSettings
|
|
})
|
|
|
|
await expect(commands.abortRuntimeGitRebase('id:wt-1')).resolves.toEqual({ ok: true })
|
|
|
|
expect(provider.abortRebase).toHaveBeenCalledWith('/remote/repo')
|
|
expect(mocks.abortRebase).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('checks out a local branch through the resolved worktree', async () => {
|
|
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
|
tempDirs.push(worktreePath)
|
|
const commands = makeCommands(worktreePath)
|
|
mocks.checkoutBranch.mockResolvedValue(undefined)
|
|
|
|
await expect(commands.checkoutRuntimeGitBranch('id:wt-1', 'feature/x')).resolves.toEqual({
|
|
ok: true,
|
|
branch: 'feature/x'
|
|
})
|
|
|
|
expect(mocks.checkoutBranch).toHaveBeenCalledWith(worktreePath, 'feature/x', {})
|
|
})
|
|
|
|
it('checks out a remote branch through the SSH git provider', async () => {
|
|
const provider = { checkoutBranch: vi.fn().mockResolvedValue(undefined) }
|
|
mocks.getSshGitProvider.mockReturnValue(provider)
|
|
const commands = new RuntimeGitCommands({
|
|
resolveRuntimeGitTarget: async () => ({
|
|
worktree: makeWorktree('/remote/repo'),
|
|
connectionId: 'conn-1'
|
|
}),
|
|
getRuntimeSettings: () => ({}) as GlobalSettings
|
|
})
|
|
|
|
await expect(commands.checkoutRuntimeGitBranch('id:wt-1', 'feature/x')).resolves.toEqual({
|
|
ok: true,
|
|
branch: 'feature/x'
|
|
})
|
|
|
|
expect(provider.checkoutBranch).toHaveBeenCalledWith('/remote/repo', 'feature/x')
|
|
expect(mocks.checkoutBranch).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('lists local branches through the resolved worktree', async () => {
|
|
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
|
tempDirs.push(worktreePath)
|
|
const commands = makeCommands(worktreePath)
|
|
mocks.listLocalBranches.mockResolvedValue({ current: 'main', branches: ['main', 'feature/x'] })
|
|
|
|
await expect(commands.listRuntimeGitLocalBranches('id:wt-1')).resolves.toEqual({
|
|
current: 'main',
|
|
branches: ['main', 'feature/x']
|
|
})
|
|
|
|
expect(mocks.listLocalBranches).toHaveBeenCalledWith(worktreePath, {})
|
|
})
|
|
|
|
it('lists remote local branches through the SSH git provider', async () => {
|
|
const provider = {
|
|
listLocalBranches: vi.fn().mockResolvedValue({ current: 'main', branches: ['main'] })
|
|
}
|
|
mocks.getSshGitProvider.mockReturnValue(provider)
|
|
const commands = new RuntimeGitCommands({
|
|
resolveRuntimeGitTarget: async () => ({
|
|
worktree: makeWorktree('/remote/repo'),
|
|
connectionId: 'conn-1'
|
|
}),
|
|
getRuntimeSettings: () => ({}) as GlobalSettings
|
|
})
|
|
|
|
await expect(commands.listRuntimeGitLocalBranches('id:wt-1')).resolves.toEqual({
|
|
current: 'main',
|
|
branches: ['main']
|
|
})
|
|
|
|
expect(provider.listLocalBranches).toHaveBeenCalledWith('/remote/repo')
|
|
expect(mocks.listLocalBranches).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects slash-only git mutation paths before they can target the worktree root', async () => {
|
|
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
|
tempDirs.push(worktreePath)
|
|
const commands = makeCommands(worktreePath)
|
|
|
|
await expect(commands.bulkDiscardRuntimeGitPaths('id:wt-1', ['///'])).rejects.toThrow(
|
|
'invalid_relative_path'
|
|
)
|
|
await expect(commands.discardRuntimeGitPath('id:wt-1', '///')).rejects.toThrow(
|
|
'invalid_relative_path'
|
|
)
|
|
})
|
|
|
|
it('prepares the selected local agent environment before generating commit messages', async () => {
|
|
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
|
tempDirs.push(worktreePath)
|
|
const context = {
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
}
|
|
const params = { agentId: 'codex', model: 'gpt-5.4-mini', thinkingLevel: 'low' }
|
|
mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params })
|
|
mocks.getStagedCommitContext.mockResolvedValue(context)
|
|
mocks.generateCommitMessageFromContext.mockResolvedValue({
|
|
success: true,
|
|
message: 'docs: update readme'
|
|
})
|
|
const commands = new RuntimeGitCommands({
|
|
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath) }),
|
|
getRuntimeSettings: () =>
|
|
({
|
|
commitMessageAi: { enabled: true, agentId: 'codex' },
|
|
agentCmdOverrides: {},
|
|
enableGitHubAttribution: false
|
|
}) as GlobalSettings,
|
|
getCommitMessageAgentEnvironment: () => ({
|
|
prepareForCodexLaunch: () => '/managed/codex-home'
|
|
})
|
|
})
|
|
|
|
await expect(commands.generateRuntimeCommitMessage('id:wt-1')).resolves.toEqual({
|
|
success: true,
|
|
message: 'docs: update readme'
|
|
})
|
|
|
|
expect(mocks.resolveCommitMessageSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
commitMessageAi: { enabled: true, agentId: 'codex' }
|
|
}),
|
|
'local',
|
|
'commitMessage',
|
|
null
|
|
)
|
|
expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith(
|
|
context,
|
|
params,
|
|
expect.objectContaining({
|
|
kind: 'local',
|
|
cwd: worktreePath,
|
|
env: expect.objectContaining({ CODEX_HOME: '/managed/codex-home' })
|
|
})
|
|
)
|
|
})
|
|
|
|
it('routes local WSL project runtime commit-message generation through the runtime target', async () => {
|
|
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
|
tempDirs.push(worktreePath)
|
|
const context = {
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
}
|
|
const params = { agentId: 'codex', model: 'gpt-5.4-mini', thinkingLevel: 'low' }
|
|
const prepareForCodexLaunch = vi.fn(() => '\\\\wsl.localhost\\Ubuntu\\home\\tester\\.codex')
|
|
mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params })
|
|
mocks.getStagedCommitContext.mockResolvedValue(context)
|
|
mocks.generateCommitMessageFromContext.mockResolvedValue({
|
|
success: true,
|
|
message: 'docs: update readme'
|
|
})
|
|
const commands = new RuntimeGitCommands({
|
|
resolveRuntimeGitTarget: async () => ({
|
|
worktree: makeWorktree(worktreePath),
|
|
localGitOptions: { wslDistro: 'Ubuntu' }
|
|
}),
|
|
getRuntimeSettings: () =>
|
|
({
|
|
commitMessageAi: { enabled: true, agentId: 'codex' },
|
|
agentCmdOverrides: {},
|
|
enableGitHubAttribution: false
|
|
}) as GlobalSettings,
|
|
getCommitMessageAgentEnvironment: () => ({
|
|
prepareForCodexLaunch
|
|
})
|
|
})
|
|
|
|
await expect(commands.generateRuntimeCommitMessage('id:wt-1')).resolves.toEqual({
|
|
success: true,
|
|
message: 'docs: update readme'
|
|
})
|
|
|
|
expect(mocks.getStagedCommitContext).toHaveBeenCalledWith(worktreePath, {
|
|
wslDistro: 'Ubuntu'
|
|
})
|
|
expect(prepareForCodexLaunch).toHaveBeenCalledWith({
|
|
runtime: 'wsl',
|
|
wslDistro: 'Ubuntu'
|
|
})
|
|
expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith(
|
|
context,
|
|
params,
|
|
expect.objectContaining({
|
|
kind: 'local',
|
|
cwd: worktreePath,
|
|
wslDistro: 'Ubuntu',
|
|
env: expect.objectContaining({ CODEX_HOME: '/home/tester/.codex' })
|
|
})
|
|
)
|
|
})
|
|
|
|
it('uses one-shot resolved params before runtime commit-message defaults', async () => {
|
|
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
|
tempDirs.push(worktreePath)
|
|
const context = {
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
}
|
|
const sourceControlAiResolvedParams = {
|
|
agentId: 'codex' as const,
|
|
model: 'gpt-5.5',
|
|
thinkingLevel: 'high',
|
|
customPrompt: 'Use Conventional Commits.'
|
|
}
|
|
mocks.getStagedCommitContext.mockResolvedValue(context)
|
|
mocks.generateCommitMessageFromContext.mockResolvedValue({
|
|
success: true,
|
|
message: 'feat: update readme'
|
|
})
|
|
const commands = new RuntimeGitCommands({
|
|
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath) }),
|
|
getRuntimeSettings: () =>
|
|
({
|
|
sourceControlAi: {
|
|
commitMessage: {
|
|
enabled: true,
|
|
agentId: 'cursor',
|
|
customPrompt: 'Saved default that should not win.'
|
|
}
|
|
}
|
|
}) as unknown as GlobalSettings
|
|
})
|
|
|
|
await expect(
|
|
commands.generateRuntimeCommitMessage('id:wt-1', { sourceControlAiResolvedParams })
|
|
).resolves.toEqual({
|
|
success: true,
|
|
message: 'feat: update readme'
|
|
})
|
|
|
|
expect(mocks.resolveCommitMessageSettings).not.toHaveBeenCalled()
|
|
expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith(
|
|
context,
|
|
sourceControlAiResolvedParams,
|
|
expect.objectContaining({
|
|
kind: 'local',
|
|
cwd: worktreePath
|
|
})
|
|
)
|
|
})
|
|
|
|
it('uses one-shot resolved params before runtime pull-request defaults', async () => {
|
|
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
|
tempDirs.push(worktreePath)
|
|
const context = {
|
|
base: 'main',
|
|
branch: 'feature/source-control-ai',
|
|
commitSummary: 'abc123 feat: test',
|
|
changeSummary: 'M README.md',
|
|
patch: '+hello',
|
|
currentTitle: '',
|
|
currentBody: '',
|
|
currentDraft: false
|
|
}
|
|
const sourceControlAiResolvedParams = {
|
|
agentId: 'codex' as const,
|
|
model: 'gpt-5.5',
|
|
thinkingLevel: 'high',
|
|
commandInputTemplate: '{basePrompt}\n\nUse release-note style.'
|
|
}
|
|
mocks.getPullRequestDraftContext.mockResolvedValue(context)
|
|
mocks.generatePullRequestFieldsFromContext.mockResolvedValue({
|
|
success: true,
|
|
fields: {
|
|
base: 'main',
|
|
title: 'Improve Source Control AI',
|
|
body: 'Body',
|
|
draft: false
|
|
}
|
|
})
|
|
const commands = new RuntimeGitCommands({
|
|
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath) }),
|
|
getRuntimeSettings: () =>
|
|
({
|
|
sourceControlAi: {
|
|
pullRequest: {
|
|
enabled: true,
|
|
agentId: 'cursor',
|
|
customPrompt: 'Saved default that should not win.'
|
|
}
|
|
}
|
|
}) as unknown as GlobalSettings
|
|
})
|
|
|
|
await expect(
|
|
commands.generateRuntimePullRequestFields(
|
|
'id:wt-1',
|
|
{ base: 'main', title: '', body: '', draft: false },
|
|
{ sourceControlAiResolvedParams }
|
|
)
|
|
).resolves.toEqual({
|
|
success: true,
|
|
fields: {
|
|
base: 'main',
|
|
title: 'Improve Source Control AI',
|
|
body: 'Body',
|
|
draft: false
|
|
}
|
|
})
|
|
|
|
expect(mocks.resolveCommitMessageSettings).not.toHaveBeenCalled()
|
|
expect(mocks.generatePullRequestFieldsFromContext).toHaveBeenCalledWith(
|
|
context,
|
|
sourceControlAiResolvedParams,
|
|
expect.objectContaining({
|
|
kind: 'local',
|
|
cwd: worktreePath
|
|
})
|
|
)
|
|
})
|
|
|
|
it('loads the hosted review template before generating pull-request fields', async () => {
|
|
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
|
tempDirs.push(worktreePath)
|
|
const templateBody = '## Summary\n\n## Testing\n\n- [ ] Required checks'
|
|
const context = {
|
|
base: 'main',
|
|
branch: 'feature/template-aware-pr',
|
|
branchChangedByPreparation: false,
|
|
commitSummary: 'abc123 feat: test',
|
|
changeSummary: 'M README.md',
|
|
patch: '+hello',
|
|
currentTitle: '',
|
|
currentBody: templateBody,
|
|
currentDraft: false
|
|
}
|
|
const sourceControlAiResolvedParams = {
|
|
agentId: 'codex' as const,
|
|
model: 'gpt-5.5'
|
|
}
|
|
mocks.resolveHostedReviewBodyForGeneration.mockResolvedValue(templateBody)
|
|
mocks.getPullRequestDraftContext.mockResolvedValue(context)
|
|
mocks.generatePullRequestFieldsFromContext.mockResolvedValue({
|
|
success: true,
|
|
fields: {
|
|
base: 'main',
|
|
title: 'Use existing template',
|
|
body: templateBody,
|
|
draft: false
|
|
}
|
|
})
|
|
const commands = new RuntimeGitCommands({
|
|
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath) }),
|
|
getRuntimeSettings: () => ({}) as GlobalSettings
|
|
})
|
|
|
|
await commands.generateRuntimePullRequestFields(
|
|
'id:wt-1',
|
|
{
|
|
base: 'main',
|
|
title: '',
|
|
body: '',
|
|
draft: false,
|
|
provider: 'gitlab',
|
|
useTemplate: true
|
|
},
|
|
{ sourceControlAiResolvedParams }
|
|
)
|
|
|
|
expect(mocks.resolveHostedReviewBodyForGeneration).toHaveBeenCalledWith({
|
|
body: '',
|
|
repoPath: worktreePath,
|
|
connectionId: undefined,
|
|
provider: 'gitlab',
|
|
useTemplate: true
|
|
})
|
|
expect(mocks.getPullRequestDraftContext).toHaveBeenCalledWith(
|
|
expect.any(Function),
|
|
expect.objectContaining({
|
|
currentBody: templateBody
|
|
})
|
|
)
|
|
})
|
|
|
|
it('resolves remote commit-message settings against the SSH host cache', async () => {
|
|
const worktreePath = '/remote/repo'
|
|
const context = {
|
|
branch: 'main',
|
|
stagedSummary: 'M\tREADME.md',
|
|
stagedPatch: '+hello'
|
|
}
|
|
const params = { agentId: 'cursor', model: 'remote-model' }
|
|
mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params })
|
|
mocks.generateCommitMessageFromContext.mockResolvedValue({
|
|
success: true,
|
|
message: 'docs: update remote readme'
|
|
})
|
|
const provider = {
|
|
getStagedCommitContext: vi.fn().mockResolvedValue(context),
|
|
executeCommitMessagePlan: vi.fn()
|
|
}
|
|
mocks.getSshGitProvider.mockReturnValue(provider)
|
|
const commands = new RuntimeGitCommands({
|
|
resolveRuntimeGitTarget: async () => ({
|
|
worktree: makeWorktree(worktreePath),
|
|
connectionId: 'conn-1'
|
|
}),
|
|
getRuntimeSettings: () =>
|
|
({
|
|
commitMessageAi: {
|
|
enabled: true,
|
|
agentId: 'cursor',
|
|
selectedModelByAgentByHost: { 'ssh:conn-1': { cursor: 'remote-model' } }
|
|
}
|
|
}) as unknown as GlobalSettings
|
|
})
|
|
|
|
await expect(commands.generateRuntimeCommitMessage('id:wt-1')).resolves.toEqual({
|
|
success: true,
|
|
message: 'docs: update remote readme'
|
|
})
|
|
|
|
expect(mocks.resolveCommitMessageSettings).toHaveBeenCalledWith(
|
|
expect.any(Object),
|
|
'ssh:conn-1',
|
|
'commitMessage',
|
|
null
|
|
)
|
|
expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith(
|
|
context,
|
|
params,
|
|
expect.objectContaining({
|
|
kind: 'remote',
|
|
cwd: worktreePath
|
|
})
|
|
)
|
|
})
|
|
|
|
it('enriches the local commit context with the workspace linked issue', async () => {
|
|
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
|
tempDirs.push(worktreePath)
|
|
const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' }
|
|
const params = { agentId: 'codex', model: 'gpt-5.4-mini' }
|
|
mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params })
|
|
mocks.getStagedCommitContext.mockResolvedValue(context)
|
|
mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'docs' })
|
|
const commands = new RuntimeGitCommands({
|
|
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath, 123) }),
|
|
getRuntimeSettings: () => ({}) as GlobalSettings
|
|
})
|
|
|
|
await commands.generateRuntimeCommitMessage('id:wt-1')
|
|
|
|
expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith(
|
|
{ ...context, linkedIssue: 123 },
|
|
params,
|
|
expect.objectContaining({ kind: 'local' })
|
|
)
|
|
})
|
|
|
|
it('enriches the SSH commit context with the workspace linked issue', async () => {
|
|
const worktreePath = '/home/tester/wt'
|
|
const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' }
|
|
const params = { agentId: 'cursor', model: 'remote-model' }
|
|
mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params })
|
|
mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'docs' })
|
|
mocks.getSshGitProvider.mockReturnValue({
|
|
getStagedCommitContext: vi.fn().mockResolvedValue(context),
|
|
executeCommitMessagePlan: vi.fn()
|
|
})
|
|
const commands = new RuntimeGitCommands({
|
|
resolveRuntimeGitTarget: async () => ({
|
|
worktree: makeWorktree(worktreePath, 77),
|
|
connectionId: 'conn-1'
|
|
}),
|
|
getRuntimeSettings: () => ({}) as GlobalSettings
|
|
})
|
|
|
|
await commands.generateRuntimeCommitMessage('id:wt-1')
|
|
|
|
expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith(
|
|
{ ...context, linkedIssue: 77 },
|
|
params,
|
|
expect.objectContaining({ kind: 'remote' })
|
|
)
|
|
})
|
|
|
|
it('prefers live meta over the linked issue projected onto the resolved worktree', async () => {
|
|
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
|
tempDirs.push(worktreePath)
|
|
const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' }
|
|
const params = { agentId: 'codex', model: 'gpt-5.4-mini' }
|
|
mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params })
|
|
mocks.getStagedCommitContext.mockResolvedValue(context)
|
|
mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'docs' })
|
|
const getWorktreeLinkedIssue = vi.fn(() => 321)
|
|
const commands = new RuntimeGitCommands({
|
|
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath, 123) }),
|
|
getRuntimeSettings: () => ({}) as GlobalSettings,
|
|
getWorktreeLinkedIssue
|
|
})
|
|
|
|
await commands.generateRuntimeCommitMessage('id:wt-1')
|
|
|
|
expect(getWorktreeLinkedIssue).toHaveBeenCalledWith('wt-1')
|
|
expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith(
|
|
{ ...context, linkedIssue: 321 },
|
|
params,
|
|
expect.objectContaining({ kind: 'local' })
|
|
)
|
|
})
|
|
|
|
it('drops a stale worktree issue number when live meta reports the workspace unlinked', async () => {
|
|
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
|
tempDirs.push(worktreePath)
|
|
const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' }
|
|
mocks.resolveCommitMessageSettings.mockReturnValue({
|
|
ok: true,
|
|
params: { agentId: 'codex', model: 'gpt-5.4-mini' }
|
|
})
|
|
mocks.getStagedCommitContext.mockResolvedValue(context)
|
|
mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'docs' })
|
|
const commands = new RuntimeGitCommands({
|
|
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath, 123) }),
|
|
getRuntimeSettings: () => ({}) as GlobalSettings,
|
|
getWorktreeLinkedIssue: () => null
|
|
})
|
|
|
|
await commands.generateRuntimeCommitMessage('id:wt-1')
|
|
|
|
expect(mocks.generateCommitMessageFromContext.mock.calls[0][0]).not.toHaveProperty(
|
|
'linkedIssue'
|
|
)
|
|
})
|
|
|
|
it('keeps the cached issue number when live meta is unavailable rather than unlinked', async () => {
|
|
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
|
tempDirs.push(worktreePath)
|
|
const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' }
|
|
const params = { agentId: 'codex', model: 'gpt-5.4-mini' }
|
|
mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params })
|
|
mocks.getStagedCommitContext.mockResolvedValue(context)
|
|
mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'docs' })
|
|
const commands = new RuntimeGitCommands({
|
|
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath, 123) }),
|
|
getRuntimeSettings: () => ({}) as GlobalSettings,
|
|
// Why: what the host reports when its store is not initialized yet.
|
|
getWorktreeLinkedIssue: () => undefined
|
|
})
|
|
|
|
await commands.generateRuntimeCommitMessage('id:wt-1')
|
|
|
|
expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith(
|
|
{ ...context, linkedIssue: 123 },
|
|
params,
|
|
expect.objectContaining({ kind: 'local' })
|
|
)
|
|
})
|
|
|
|
it('reads pull-request linked issues from live meta too', async () => {
|
|
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
|
tempDirs.push(worktreePath)
|
|
const context = {
|
|
base: 'main',
|
|
branch: 'feature/login',
|
|
commitSummary: 'abc123 feat: test',
|
|
changeSummary: 'M README.md',
|
|
patch: '+hello',
|
|
currentTitle: '',
|
|
currentBody: '',
|
|
currentDraft: false
|
|
}
|
|
mocks.getPullRequestDraftContext.mockResolvedValue(context)
|
|
mocks.generatePullRequestFieldsFromContext.mockResolvedValue({ success: true, fields: {} })
|
|
const commands = new RuntimeGitCommands({
|
|
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath, 123) }),
|
|
getRuntimeSettings: () => ({}) as GlobalSettings,
|
|
getWorktreeLinkedIssue: () => 321
|
|
})
|
|
|
|
await commands.generateRuntimePullRequestFields(
|
|
'id:wt-1',
|
|
{ base: 'main', title: '', body: '', draft: false },
|
|
{ sourceControlAiResolvedParams: { agentId: 'codex' as const, model: 'gpt-5.5' } }
|
|
)
|
|
|
|
expect(mocks.generatePullRequestFieldsFromContext.mock.calls[0][0]).toEqual({
|
|
...context,
|
|
linkedIssue: 321
|
|
})
|
|
})
|
|
|
|
it('leaves the commit context untouched when no issue is linked', async () => {
|
|
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
|
tempDirs.push(worktreePath)
|
|
const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' }
|
|
const params = { agentId: 'codex', model: 'gpt-5.4-mini' }
|
|
mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params })
|
|
mocks.getStagedCommitContext.mockResolvedValue(context)
|
|
mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'docs' })
|
|
|
|
await makeCommands(worktreePath).generateRuntimeCommitMessage('id:wt-1')
|
|
|
|
expect(mocks.generateCommitMessageFromContext.mock.calls[0][0]).not.toHaveProperty(
|
|
'linkedIssue'
|
|
)
|
|
})
|
|
|
|
it('shares one linked-issue attach across both pull-request branches', async () => {
|
|
const context = {
|
|
base: 'main',
|
|
branch: 'feature/login',
|
|
commitSummary: 'abc123 feat: test',
|
|
changeSummary: 'M README.md',
|
|
patch: '+hello',
|
|
currentTitle: '',
|
|
currentBody: '',
|
|
currentDraft: false
|
|
}
|
|
const params = { agentId: 'codex' as const, model: 'gpt-5.5' }
|
|
mocks.generatePullRequestFieldsFromContext.mockResolvedValue({ success: true, fields: {} })
|
|
mocks.getSshGitProvider.mockReturnValue({
|
|
exec: vi.fn(),
|
|
executeCommitMessagePlan: vi.fn()
|
|
})
|
|
|
|
for (const connectionId of [undefined, 'conn-1']) {
|
|
const worktreePath = connectionId
|
|
? '/home/tester/wt'
|
|
: mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
|
if (!connectionId) {
|
|
tempDirs.push(worktreePath)
|
|
}
|
|
mocks.getPullRequestDraftContext.mockResolvedValue(context)
|
|
const commands = new RuntimeGitCommands({
|
|
resolveRuntimeGitTarget: async () => ({
|
|
worktree: makeWorktree(worktreePath, 55),
|
|
...(connectionId ? { connectionId } : {})
|
|
}),
|
|
getRuntimeSettings: () => ({}) as GlobalSettings
|
|
})
|
|
|
|
await commands.generateRuntimePullRequestFields(
|
|
'id:wt-1',
|
|
{ base: 'main', title: '', body: '', draft: false },
|
|
{ sourceControlAiResolvedParams: params }
|
|
)
|
|
}
|
|
|
|
expect(mocks.generatePullRequestFieldsFromContext.mock.calls).toHaveLength(2)
|
|
for (const call of mocks.generatePullRequestFieldsFromContext.mock.calls) {
|
|
expect(call[0]).toEqual({ ...context, linkedIssue: 55 })
|
|
}
|
|
})
|
|
|
|
it('leaves the pull-request context untouched when no issue is linked', async () => {
|
|
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
|
tempDirs.push(worktreePath)
|
|
const context = {
|
|
base: 'main',
|
|
branch: 'feature/login',
|
|
commitSummary: 'abc123 feat: test',
|
|
changeSummary: 'M README.md',
|
|
patch: '+hello',
|
|
currentTitle: '',
|
|
currentBody: '',
|
|
currentDraft: false
|
|
}
|
|
const params = { agentId: 'codex' as const, model: 'gpt-5.5' }
|
|
mocks.getPullRequestDraftContext.mockResolvedValue(context)
|
|
mocks.generatePullRequestFieldsFromContext.mockResolvedValue({ success: true, fields: {} })
|
|
|
|
await makeCommands(worktreePath).generateRuntimePullRequestFields(
|
|
'id:wt-1',
|
|
{ base: 'main', title: '', body: '', draft: false },
|
|
{ sourceControlAiResolvedParams: params }
|
|
)
|
|
|
|
expect(mocks.generatePullRequestFieldsFromContext.mock.calls).toHaveLength(1)
|
|
expect(mocks.generatePullRequestFieldsFromContext.mock.calls[0][0]).not.toHaveProperty(
|
|
'linkedIssue'
|
|
)
|
|
})
|
|
})
|