Improve refresh local base ref UX (#2570)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
1790e3bb9b
commit
91991d6186
|
|
@ -45,6 +45,8 @@ const NATIVE_MODULES = ['better-sqlite3', 'node-pty', 'cpu-features']
|
|||
const onlyModules = NATIVE_MODULES.filter((m) => !ignoreModules.includes(m))
|
||||
const forceRebuild = process.env.ORCA_FORCE_NATIVE_REBUILD === '1'
|
||||
|
||||
ensureElectronPackageInstalled()
|
||||
|
||||
if (!forceRebuild) {
|
||||
// Why: Windows cannot unlink a loaded .node DLL, so avoid @electron/rebuild
|
||||
// when the current install already works with Electron's ABI.
|
||||
|
|
@ -123,6 +125,37 @@ try {
|
|||
process.exit(1)
|
||||
}
|
||||
|
||||
function ensureElectronPackageInstalled() {
|
||||
try {
|
||||
require('electron')
|
||||
return
|
||||
} catch (/** @type {any} */ err) {
|
||||
if (!isElectronPackageInstallError(err)) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Why: CI has observed Electron's postinstall exiting cleanly without
|
||||
// writing path.txt; native rebuild and tests both need the binary path.
|
||||
console.log('[rebuild] Electron package binary is missing; rerunning Electron install.')
|
||||
try {
|
||||
execFileSync(process.execPath, [require.resolve('electron/install.js')], {
|
||||
cwd: projectDir,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
} catch (/** @type {any} */ err) {
|
||||
console.error('[rebuild] Electron install retry failed:', err?.message ?? err)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
try {
|
||||
require('electron')
|
||||
} catch (/** @type {any} */ err) {
|
||||
console.error('[rebuild] Electron package is still unavailable after retry:', err?.message ?? err)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
function probeElectronNativeModules(moduleNames) {
|
||||
let electronExecutable
|
||||
try {
|
||||
|
|
@ -217,6 +250,10 @@ function isPostinstall() {
|
|||
return process.env.npm_lifecycle_event === 'postinstall'
|
||||
}
|
||||
|
||||
function isElectronPackageInstallError(error) {
|
||||
return /Electron failed to install correctly/i.test(formatError(error))
|
||||
}
|
||||
|
||||
function formatError(error) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -289,7 +289,7 @@ describe('addWorktree', () => {
|
|||
|
||||
await expect(
|
||||
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
|
||||
).resolves.toBeUndefined()
|
||||
).resolves.toEqual({})
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'addWorktree: failed to set branch.feature/test.base for /repo-feature',
|
||||
|
|
@ -314,7 +314,7 @@ describe('addWorktree', () => {
|
|||
|
||||
await expect(
|
||||
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
|
||||
).resolves.toBeUndefined()
|
||||
).resolves.toEqual({})
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'addWorktree: failed to set push.autoSetupRemote for /repo-feature',
|
||||
|
|
@ -336,7 +336,7 @@ describe('addWorktree', () => {
|
|||
|
||||
await expect(
|
||||
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
|
||||
).resolves.toBeUndefined()
|
||||
).resolves.toEqual({})
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'addWorktree: failed to set push.autoSetupRemote for /repo-feature',
|
||||
|
|
@ -481,11 +481,11 @@ describe('addWorktree', () => {
|
|||
const worktreeListOutput =
|
||||
'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /repo-other\nHEAD def456\nbranch refs/heads/feature\n'
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
||||
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
|
||||
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
|
||||
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo)
|
||||
.mockResolvedValueOnce({ stdout: '' }) // reset --hard (in /repo)
|
||||
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
||||
.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
||||
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
|
||||
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
|
||||
|
|
@ -494,11 +494,11 @@ describe('addWorktree', () => {
|
|||
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[['merge-base', '--is-ancestor', 'main', 'origin/main'], { cwd: '/repo' }],
|
||||
[['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }],
|
||||
[['merge-base', '--is-ancestor', 'main', 'refs/remotes/origin/main'], { cwd: '/repo' }],
|
||||
[['worktree', 'list', '--porcelain'], { cwd: '/repo' }],
|
||||
[['status', '--porcelain', '--untracked-files=no'], { cwd: '/repo' }],
|
||||
[['reset', '--hard', 'origin/main'], { cwd: '/repo' }],
|
||||
[['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }],
|
||||
[['reset', '--hard', 'refs/remotes/origin/main'], { cwd: '/repo' }],
|
||||
[
|
||||
[
|
||||
'worktree',
|
||||
|
|
@ -530,11 +530,11 @@ describe('addWorktree', () => {
|
|||
const worktreeListOutput =
|
||||
'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n\nworktree /repo-main-wt\nHEAD def456\nbranch refs/heads/main\n'
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
||||
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
|
||||
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
|
||||
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo-main-wt)
|
||||
.mockResolvedValueOnce({ stdout: '' }) // reset --hard (in /repo-main-wt)
|
||||
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
||||
.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
||||
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
|
||||
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
|
||||
|
|
@ -542,12 +542,12 @@ describe('addWorktree', () => {
|
|||
|
||||
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls[2]).toEqual([
|
||||
expect(gitExecFileAsyncMock.mock.calls[3]).toEqual([
|
||||
['status', '--porcelain', '--untracked-files=no'],
|
||||
expect.objectContaining({ cwd: '/repo-main-wt' })
|
||||
])
|
||||
expect(gitExecFileAsyncMock.mock.calls[3]).toEqual([
|
||||
['reset', '--hard', 'origin/main'],
|
||||
expect(gitExecFileAsyncMock.mock.calls[4]).toEqual([
|
||||
['reset', '--hard', 'refs/remotes/origin/main'],
|
||||
expect.objectContaining({ cwd: '/repo-main-wt' })
|
||||
])
|
||||
})
|
||||
|
|
@ -555,10 +555,10 @@ describe('addWorktree', () => {
|
|||
it('uses update-ref when localBranch is not checked out in any worktree', async () => {
|
||||
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n'
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
||||
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
|
||||
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
|
||||
.mockResolvedValueOnce({ stdout: '' }) // update-ref
|
||||
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
||||
.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
||||
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
|
||||
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
|
||||
|
|
@ -566,8 +566,8 @@ describe('addWorktree', () => {
|
|||
|
||||
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls[2]).toEqual([
|
||||
['update-ref', 'refs/heads/main', 'origin/main'],
|
||||
expect(gitExecFileAsyncMock.mock.calls[3]).toEqual([
|
||||
['update-ref', 'refs/heads/main', 'refs/remotes/origin/main'],
|
||||
expect.objectContaining({ cwd: '/repo' })
|
||||
])
|
||||
})
|
||||
|
|
@ -575,20 +575,27 @@ describe('addWorktree', () => {
|
|||
it('skips update when the owning worktree is dirty', async () => {
|
||||
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
||||
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
|
||||
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
|
||||
.mockResolvedValueOnce({ stdout: ' M package.json\n' }) // status --porcelain (dirty)
|
||||
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
||||
.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
||||
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
|
||||
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
|
||||
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
|
||||
|
||||
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
|
||||
const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
|
||||
|
||||
// No reset --hard or update-ref — just merge-base, worktree list, status, worktree add, base config, config --get, config --local set
|
||||
expect(result.localBaseRefRefresh).toEqual({
|
||||
status: 'skipped_dirty_worktree',
|
||||
baseRef: 'origin/main',
|
||||
localBranch: 'main',
|
||||
ownerWorktreePath: '/repo'
|
||||
})
|
||||
|
||||
// No reset --hard or update-ref — just base resolution, merge-base, worktree list, status, worktree add, base config, config --get, config --local set
|
||||
expect(gitExecFileAsyncMock.mock.calls).toHaveLength(8)
|
||||
expect(gitExecFileAsyncMock.mock.calls[3]?.[0]).toEqual([
|
||||
expect(gitExecFileAsyncMock.mock.calls[0]?.[0]).toEqual([
|
||||
'rev-parse',
|
||||
'--verify',
|
||||
'--quiet',
|
||||
|
|
@ -624,8 +631,8 @@ describe('addWorktree', () => {
|
|||
})
|
||||
|
||||
it('skips updating the local branch when it has diverged', async () => {
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not a fast-forward'))
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not a fast-forward'))
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
||||
resolveCreationBaseConfigWrite()
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
|
||||
|
|
@ -635,11 +642,11 @@ describe('addWorktree', () => {
|
|||
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[
|
||||
['merge-base', '--is-ancestor', 'main', 'origin/main'],
|
||||
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'],
|
||||
expect.objectContaining({ cwd: '/repo' })
|
||||
],
|
||||
[
|
||||
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'],
|
||||
['merge-base', '--is-ancestor', 'main', 'refs/remotes/origin/main'],
|
||||
expect.objectContaining({ cwd: '/repo' })
|
||||
],
|
||||
[
|
||||
|
|
@ -739,14 +746,55 @@ describe('addWorktree', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('does not report a local base refresh for slash-containing local branch names', async () => {
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('no remote ref')) // rev-parse refs/remotes/release/main^{commit}
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/heads/release/main^{commit}
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
||||
resolveCreationBaseConfigWrite()
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
|
||||
|
||||
const result = await addWorktree(
|
||||
'/repo',
|
||||
'/repo-feature',
|
||||
'feature/release',
|
||||
'release/main',
|
||||
true
|
||||
)
|
||||
|
||||
expect(result).toEqual({})
|
||||
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
['rev-parse', '--verify', '--quiet', 'refs/remotes/release/main^{commit}'],
|
||||
['rev-parse', '--verify', '--quiet', 'refs/heads/release/main^{commit}'],
|
||||
[
|
||||
'worktree',
|
||||
'add',
|
||||
'--no-track',
|
||||
'-b',
|
||||
'feature/release',
|
||||
'/repo-feature',
|
||||
'refs/heads/release/main'
|
||||
],
|
||||
[
|
||||
'config',
|
||||
'--local',
|
||||
'--replace-all',
|
||||
'branch.feature/release.base',
|
||||
'refs/heads/release/main'
|
||||
],
|
||||
['config', '--get', 'push.autoSetupRemote'],
|
||||
['config', '--local', 'push.autoSetupRemote', 'true']
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the remote name from the base ref instead of hardcoding origin', async () => {
|
||||
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/upstream/main^{commit}
|
||||
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
|
||||
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
|
||||
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain
|
||||
.mockResolvedValueOnce({ stdout: '' }) // reset --hard
|
||||
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/upstream/main^{commit}
|
||||
.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
||||
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
|
||||
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
|
||||
|
|
@ -754,13 +802,17 @@ describe('addWorktree', () => {
|
|||
|
||||
await addWorktree('/repo', '/repo-feature', 'feature/test', 'upstream/main', true)
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls[0]?.[0]).toEqual([
|
||||
expect(gitExecFileAsyncMock.mock.calls[1]?.[0]).toEqual([
|
||||
'merge-base',
|
||||
'--is-ancestor',
|
||||
'main',
|
||||
'upstream/main'
|
||||
'refs/remotes/upstream/main'
|
||||
])
|
||||
expect(gitExecFileAsyncMock.mock.calls[4]?.[0]).toEqual([
|
||||
'reset',
|
||||
'--hard',
|
||||
'refs/remotes/upstream/main'
|
||||
])
|
||||
expect(gitExecFileAsyncMock.mock.calls[3]?.[0]).toEqual(['reset', '--hard', 'upstream/main'])
|
||||
})
|
||||
|
||||
it('unsets branch base config during sparse setup cleanup after creation succeeds', async () => {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,15 @@
|
|||
import { stat } from 'fs/promises'
|
||||
import { join, posix, win32 } from 'path'
|
||||
import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
|
||||
import type { GitWorktreeInfo } from '../../shared/types'
|
||||
import type { GitWorktreeInfo, LocalBaseRefRefreshResult } from '../../shared/types'
|
||||
import { gitExecFileAsync, translateWslOutputPaths } from './runner'
|
||||
import { resolveGitDir } from './status'
|
||||
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
|
||||
|
||||
export type AddWorktreeResult = {
|
||||
localBaseRefRefresh?: LocalBaseRefRefreshResult
|
||||
}
|
||||
|
||||
type SparseWorktreeCreateError = Error & {
|
||||
cleanupFailed?: boolean
|
||||
}
|
||||
|
|
@ -192,6 +196,84 @@ export async function listWorktrees(repoPath: string): Promise<GitWorktreeInfo[]
|
|||
}
|
||||
}
|
||||
|
||||
async function refreshLocalBaseRefForWorktreeCreate(
|
||||
repoPath: string,
|
||||
baseBranch: string,
|
||||
remoteTrackingRef: string
|
||||
): Promise<LocalBaseRefRefreshResult | undefined> {
|
||||
const remoteRefPrefix = 'refs/remotes/'
|
||||
if (!remoteTrackingRef.startsWith(remoteRefPrefix)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Why: Only refs proven to be remote-tracking refs get refresh status.
|
||||
// Local branches can contain slashes (e.g. release/2026) and must not
|
||||
// produce a fake "Local 2026 was not refreshed" warning.
|
||||
const shortRemoteRef = remoteTrackingRef.slice(remoteRefPrefix.length)
|
||||
const slashIndex = shortRemoteRef.indexOf('/')
|
||||
if (slashIndex <= 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const localBranch = shortRemoteRef.slice(slashIndex + 1)
|
||||
const fullRef = `refs/heads/${localBranch}`
|
||||
const resultBase = { baseRef: baseBranch, localBranch }
|
||||
|
||||
try {
|
||||
// Why: We only fast-forward the local branch pointer. A force-move (`branch -f`)
|
||||
// would silently destroy unpushed local commits if the branch has diverged from
|
||||
// remote. `merge-base --is-ancestor` returns exit 0 when localBranch is an
|
||||
// ancestor of baseBranch — i.e. the update is a safe fast-forward.
|
||||
await gitExecFileAsync(['merge-base', '--is-ancestor', localBranch, remoteTrackingRef], {
|
||||
cwd: repoPath
|
||||
})
|
||||
} catch {
|
||||
// merge-base fails if the local branch doesn't exist or has diverged.
|
||||
return { ...resultBase, status: 'skipped_not_fast_forward' }
|
||||
}
|
||||
|
||||
try {
|
||||
// Why: We need to find which worktree (if any) has localBranch checked
|
||||
// out, because moving the ref without updating that worktree's files would
|
||||
// leave it looking massively dirty. A sibling worktree we don't control is
|
||||
// just as vulnerable as the primary one.
|
||||
const { stdout: worktreeListOutput } = await gitExecFileAsync(
|
||||
['worktree', 'list', '--porcelain'],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
const worktrees = parseWorktreeList(translateWslOutputPaths(worktreeListOutput, repoPath))
|
||||
const ownerWorktree = worktrees.find((wt) => wt.branch === fullRef)
|
||||
|
||||
if (ownerWorktree) {
|
||||
// Why: localBranch is checked out in a worktree. We can only safely
|
||||
// update if that worktree is clean, and we must use `reset --hard`
|
||||
// (run inside that worktree) so the files move with the ref.
|
||||
const { stdout: status } = await gitExecFileAsync(
|
||||
['status', '--porcelain', '--untracked-files=no'],
|
||||
{ cwd: ownerWorktree.path }
|
||||
)
|
||||
if (status.trim()) {
|
||||
return {
|
||||
...resultBase,
|
||||
status: 'skipped_dirty_worktree',
|
||||
ownerWorktreePath: ownerWorktree.path
|
||||
}
|
||||
}
|
||||
await gitExecFileAsync(['reset', '--hard', remoteTrackingRef], { cwd: ownerWorktree.path })
|
||||
return { ...resultBase, status: 'updated', ownerWorktreePath: ownerWorktree.path }
|
||||
}
|
||||
|
||||
// Why: localBranch is not checked out anywhere, so there is no working
|
||||
// tree to desync. `update-ref` is safe here.
|
||||
await gitExecFileAsync(['update-ref', fullRef, remoteTrackingRef], { cwd: repoPath })
|
||||
return { ...resultBase, status: 'updated' }
|
||||
} catch {
|
||||
// update-ref/reset can fail on locked refs, filesystem errors, or unusual
|
||||
// worktree states. Worktree creation should still proceed.
|
||||
return { ...resultBase, status: 'skipped_error' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new worktree.
|
||||
* @param repoPath - Path to the main repo (or bare repo)
|
||||
|
|
@ -211,61 +293,8 @@ export async function addWorktree(
|
|||
refreshLocalBaseRef = false,
|
||||
noCheckout = false,
|
||||
options: { checkoutExistingBranch?: boolean } = {}
|
||||
): Promise<void> {
|
||||
// Why: Some users want Orca-created worktrees to make plain commands like
|
||||
// `git diff main...HEAD` work out of the box, while others do not want
|
||||
// worktree creation to mutate their local main/master ref at all. Keep this
|
||||
// behavior behind an explicit setting so the default stays conservative.
|
||||
if (baseBranch && refreshLocalBaseRef && !options.checkoutExistingBranch) {
|
||||
// Why: We split on '/' instead of matching a hardcoded 'origin/' prefix because
|
||||
// callers may pass arbitrary remotes (e.g. 'upstream/main'), not just 'origin'.
|
||||
const slashIndex = baseBranch.indexOf('/')
|
||||
if (slashIndex > 0) {
|
||||
const localBranch = baseBranch.slice(slashIndex + 1)
|
||||
try {
|
||||
// Why: We only fast-forward the local branch pointer. A force-move (`branch -f`)
|
||||
// would silently destroy unpushed local commits if the branch has diverged from
|
||||
// remote. `merge-base --is-ancestor` returns exit 0 when localBranch is an
|
||||
// ancestor of baseBranch — i.e. the update is a safe fast-forward.
|
||||
await gitExecFileAsync(['merge-base', '--is-ancestor', localBranch, baseBranch], {
|
||||
cwd: repoPath
|
||||
})
|
||||
// Why: We need to find which worktree (if any) has localBranch checked
|
||||
// out, because moving the ref without updating that worktree's files would
|
||||
// leave it looking massively dirty. A sibling worktree we don't control is
|
||||
// just as vulnerable as the primary one.
|
||||
const { stdout: worktreeListOutput } = await gitExecFileAsync(
|
||||
['worktree', 'list', '--porcelain'],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
const worktrees = parseWorktreeList(translateWslOutputPaths(worktreeListOutput, repoPath))
|
||||
const fullRef = `refs/heads/${localBranch}`
|
||||
const ownerWorktree = worktrees.find((wt) => wt.branch === fullRef)
|
||||
|
||||
if (ownerWorktree) {
|
||||
// Why: localBranch is checked out in a worktree. We can only safely
|
||||
// update if that worktree is clean, and we must use `reset --hard`
|
||||
// (run inside that worktree) so the files move with the ref.
|
||||
const { stdout: status } = await gitExecFileAsync(
|
||||
['status', '--porcelain', '--untracked-files=no'],
|
||||
{ cwd: ownerWorktree.path }
|
||||
)
|
||||
if (!status.trim()) {
|
||||
await gitExecFileAsync(['reset', '--hard', baseBranch], { cwd: ownerWorktree.path })
|
||||
}
|
||||
} else {
|
||||
// Why: localBranch is not checked out anywhere, so there is no working
|
||||
// tree to desync. `update-ref` is safe here.
|
||||
await gitExecFileAsync(['update-ref', fullRef, baseBranch], { cwd: repoPath })
|
||||
}
|
||||
} catch {
|
||||
// merge-base fails if the local branch doesn't exist or has diverged;
|
||||
// update-ref fails on locked/corrupted refs or filesystem errors.
|
||||
// Both cases are non-fatal — skip the update silently.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
): Promise<AddWorktreeResult> {
|
||||
let localBaseRefRefresh: LocalBaseRefRefreshResult | undefined
|
||||
const args = ['worktree', 'add']
|
||||
let effectiveBase: string | undefined
|
||||
if (noCheckout) {
|
||||
|
|
@ -285,13 +314,24 @@ export async function addWorktree(
|
|||
effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) =>
|
||||
hasWorktreeBaseCommitRef(repoPath, qualifiedRef)
|
||||
)
|
||||
// Why: resolving the creation base first distinguishes real
|
||||
// remote-tracking refs from slash-containing local branch names.
|
||||
// The mutation stays behind the explicit setting so the default
|
||||
// remains conservative.
|
||||
if (refreshLocalBaseRef) {
|
||||
localBaseRefRefresh = await refreshLocalBaseRefForWorktreeCreate(
|
||||
repoPath,
|
||||
baseBranch,
|
||||
effectiveBase
|
||||
)
|
||||
}
|
||||
args.push(effectiveBase)
|
||||
}
|
||||
}
|
||||
await gitExecFileAsync(args, { cwd: repoPath })
|
||||
|
||||
if (options.checkoutExistingBranch) {
|
||||
return
|
||||
return localBaseRefRefresh ? { localBaseRefRefresh } : {}
|
||||
}
|
||||
|
||||
if (effectiveBase) {
|
||||
|
|
@ -354,6 +394,7 @@ export async function addWorktree(
|
|||
} catch (error) {
|
||||
console.warn(`addWorktree: failed to set push.autoSetupRemote for ${worktreePath}`, error)
|
||||
}
|
||||
return localBaseRefRefresh ? { localBaseRefRefresh } : {}
|
||||
}
|
||||
|
||||
export async function addSparseWorktree(
|
||||
|
|
@ -364,10 +405,11 @@ export async function addSparseWorktree(
|
|||
baseBranch?: string,
|
||||
refreshLocalBaseRef = false,
|
||||
options: { checkoutExistingBranch?: boolean } = {}
|
||||
): Promise<void> {
|
||||
): Promise<AddWorktreeResult> {
|
||||
let created = false
|
||||
let addResult: AddWorktreeResult = {}
|
||||
try {
|
||||
await addWorktree(
|
||||
addResult = await addWorktree(
|
||||
repoPath,
|
||||
worktreePath,
|
||||
branch,
|
||||
|
|
@ -380,6 +422,7 @@ export async function addSparseWorktree(
|
|||
await gitExecFileAsync(['sparse-checkout', 'init', '--cone'], { cwd: worktreePath })
|
||||
await gitExecFileAsync(['sparse-checkout', 'set', '--', ...directories], { cwd: worktreePath })
|
||||
await gitExecFileAsync(['checkout', branch], { cwd: worktreePath })
|
||||
return addResult
|
||||
} catch (error) {
|
||||
const wrapped: SparseWorktreeCreateError =
|
||||
error instanceof Error ? (error as SparseWorktreeCreateError) : new Error(String(error))
|
||||
|
|
|
|||
|
|
@ -16,11 +16,13 @@ import type {
|
|||
CreateWorktreeArgs,
|
||||
CreateWorktreeResult,
|
||||
GitPushTarget,
|
||||
LocalBaseRefRefreshResult,
|
||||
Repo,
|
||||
WorktreeMeta
|
||||
} from '../../shared/types'
|
||||
import { getPRForBranch } from '../github/client'
|
||||
import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree'
|
||||
import { listWorktrees, addWorktree, addSparseWorktree, parseWorktreeList } from '../git/worktree'
|
||||
import type { AddWorktreeResult } from '../git/worktree'
|
||||
import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo'
|
||||
import { validateGitPushTarget } from '../git/push-target-validation'
|
||||
import { assertGitPushTargetShape } from '../../shared/git-push-target-validation'
|
||||
|
|
@ -635,6 +637,57 @@ async function resolveRemoteTrackingBaseSsh(
|
|||
}
|
||||
}
|
||||
|
||||
async function refreshLocalBaseRefForRemoteWorktreeCreate(
|
||||
provider: SshGitProvider,
|
||||
repoPath: string,
|
||||
remoteTrackingBase: RemoteTrackingBase
|
||||
): Promise<LocalBaseRefRefreshResult> {
|
||||
const resultBase = {
|
||||
baseRef: remoteTrackingBase.base,
|
||||
localBranch: remoteTrackingBase.branch
|
||||
}
|
||||
const fullRef = `refs/heads/${remoteTrackingBase.branch}`
|
||||
|
||||
try {
|
||||
await provider.exec(
|
||||
['merge-base', '--is-ancestor', remoteTrackingBase.branch, remoteTrackingBase.ref],
|
||||
repoPath
|
||||
)
|
||||
} catch {
|
||||
return { ...resultBase, status: 'skipped_not_fast_forward' }
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout: worktreeListOutput } = await provider.exec(
|
||||
['worktree', 'list', '--porcelain'],
|
||||
repoPath
|
||||
)
|
||||
const worktrees = parseWorktreeList(worktreeListOutput)
|
||||
const ownerWorktree = worktrees.find((wt) => wt.branch === fullRef)
|
||||
|
||||
if (ownerWorktree) {
|
||||
const { stdout: status } = await provider.exec(
|
||||
['status', '--porcelain', '--untracked-files=no'],
|
||||
ownerWorktree.path
|
||||
)
|
||||
if (status.trim()) {
|
||||
return {
|
||||
...resultBase,
|
||||
status: 'skipped_dirty_worktree',
|
||||
ownerWorktreePath: ownerWorktree.path
|
||||
}
|
||||
}
|
||||
await provider.exec(['reset', '--hard', remoteTrackingBase.ref], ownerWorktree.path)
|
||||
return { ...resultBase, status: 'updated', ownerWorktreePath: ownerWorktree.path }
|
||||
}
|
||||
|
||||
await provider.exec(['update-ref', fullRef, remoteTrackingBase.ref], repoPath)
|
||||
return { ...resultBase, status: 'updated' }
|
||||
} catch {
|
||||
return { ...resultBase, status: 'skipped_error' }
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('worktrees:changed', { repoId })
|
||||
|
|
@ -779,6 +832,10 @@ export async function createRemoteWorktree(
|
|||
/* best-effort */
|
||||
}
|
||||
}
|
||||
const localBaseRefRefresh =
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate && !checkoutExistingBranch && remoteTrackingBase
|
||||
? await refreshLocalBaseRefForRemoteWorktreeCreate(provider, repo.path, remoteTrackingBase)
|
||||
: undefined
|
||||
|
||||
const fsProvider = getSshFilesystemProvider(repo.connectionId!)
|
||||
if (fsProvider) {
|
||||
|
|
@ -975,7 +1032,8 @@ export async function createRemoteWorktree(
|
|||
notifyWorktreesChanged(mainWindow, repo.id)
|
||||
return {
|
||||
worktree,
|
||||
...(setup ? { setup } : {})
|
||||
...(setup ? { setup } : {}),
|
||||
...(localBaseRefRefresh ? { localBaseRefRefresh } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1219,44 +1277,43 @@ export async function createLocalWorktree(
|
|||
}
|
||||
|
||||
const existingBranchOption = { checkoutExistingBranch }
|
||||
if (sparseDirectories.length > 0) {
|
||||
await (checkoutExistingBranch
|
||||
? addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
existingBranchOption
|
||||
)
|
||||
: addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||||
))
|
||||
} else {
|
||||
await (checkoutExistingBranch
|
||||
? addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
false,
|
||||
existingBranchOption
|
||||
)
|
||||
: addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||||
))
|
||||
}
|
||||
const addResult: AddWorktreeResult =
|
||||
(await (sparseDirectories.length > 0
|
||||
? checkoutExistingBranch
|
||||
? addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
existingBranchOption
|
||||
)
|
||||
: addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||||
)
|
||||
: checkoutExistingBranch
|
||||
? addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
false,
|
||||
existingBranchOption
|
||||
)
|
||||
: addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||||
))) ?? {}
|
||||
|
||||
let configuredPushTarget: GitPushTarget | undefined
|
||||
if (preparedPushTarget) {
|
||||
|
|
@ -1383,6 +1440,7 @@ export async function createLocalWorktree(
|
|||
notifyWorktreesChanged(mainWindow, repo.id)
|
||||
return {
|
||||
worktree,
|
||||
...(setup ? { setup } : {})
|
||||
...(setup ? { setup } : {}),
|
||||
...(addResult.localBaseRefRefresh ? { localBaseRefRefresh: addResult.localBaseRefRefresh } : {})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const {
|
|||
handleMock,
|
||||
removeHandlerMock,
|
||||
listWorktreesMock,
|
||||
parseWorktreeListMock,
|
||||
assertWorktreeCleanForRemovalMock,
|
||||
addWorktreeMock,
|
||||
addSparseWorktreeMock,
|
||||
|
|
@ -38,6 +39,18 @@ const {
|
|||
handleMock: vi.fn(),
|
||||
removeHandlerMock: vi.fn(),
|
||||
listWorktreesMock: vi.fn(),
|
||||
parseWorktreeListMock: vi.fn((output: string) =>
|
||||
output
|
||||
.trim()
|
||||
.split(/\n\s*\n/)
|
||||
.filter(Boolean)
|
||||
.map((block, index) => {
|
||||
const lines = block.split(/\r?\n/)
|
||||
const path = lines.find((line) => line.startsWith('worktree '))?.slice(9) ?? ''
|
||||
const branch = lines.find((line) => line.startsWith('branch '))?.slice(7) ?? ''
|
||||
return { path, branch, head: String(index), isBare: false, isMainWorktree: index === 0 }
|
||||
})
|
||||
),
|
||||
assertWorktreeCleanForRemovalMock: vi.fn(),
|
||||
addWorktreeMock: vi.fn(),
|
||||
addSparseWorktreeMock: vi.fn(),
|
||||
|
|
@ -78,6 +91,7 @@ vi.mock('electron', () => ({
|
|||
|
||||
vi.mock('../git/worktree', () => ({
|
||||
listWorktrees: listWorktreesMock,
|
||||
parseWorktreeList: parseWorktreeListMock,
|
||||
assertWorktreeCleanForRemoval: assertWorktreeCleanForRemovalMock,
|
||||
addWorktree: addWorktreeMock,
|
||||
addSparseWorktree: addSparseWorktreeMock,
|
||||
|
|
@ -1110,6 +1124,90 @@ describe('registerWorktreeHandlers', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('returns SSH local base refresh skip status when the owning worktree is dirty', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
displayName: 'ssh',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'conn-1',
|
||||
worktreeBaseRef: 'origin/main'
|
||||
}
|
||||
const provider = {
|
||||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'worktree' && args[1] === 'list') {
|
||||
return {
|
||||
stdout: 'worktree /remote/repo\nHEAD abc123\nbranch refs/heads/main\n',
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
if (args[0] === 'status') {
|
||||
return { stdout: ' M package.json\n', stderr: '' }
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
}),
|
||||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/remote/improve-dashboard',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/improve-dashboard',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
}
|
||||
const mux = {
|
||||
request: vi.fn().mockResolvedValue(undefined),
|
||||
notify: vi.fn()
|
||||
}
|
||||
store.getSettings.mockReturnValue({
|
||||
branchPrefix: 'none',
|
||||
nestWorkspaces: false,
|
||||
refreshLocalBaseRefOnWorktreeCreate: true,
|
||||
workspaceDir: '/workspace'
|
||||
})
|
||||
store.getRepos.mockReturnValue([repo])
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||||
|
||||
const result = await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-ssh',
|
||||
name: 'improve-dashboard'
|
||||
})
|
||||
|
||||
expect(provider.exec).toHaveBeenCalledWith(
|
||||
['merge-base', '--is-ancestor', 'main', 'refs/remotes/origin/main'],
|
||||
'/remote/repo'
|
||||
)
|
||||
expect(provider.exec).toHaveBeenCalledWith(['worktree', 'list', '--porcelain'], '/remote/repo')
|
||||
expect(provider.exec).toHaveBeenCalledWith(
|
||||
['status', '--porcelain', '--untracked-files=no'],
|
||||
'/remote/repo'
|
||||
)
|
||||
expect(provider.exec).not.toHaveBeenCalledWith(
|
||||
['reset', '--hard', 'refs/remotes/origin/main'],
|
||||
expect.any(String)
|
||||
)
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
localBaseRefRefresh: {
|
||||
status: 'skipped_dirty_worktree',
|
||||
baseRef: 'origin/main',
|
||||
localBranch: 'main',
|
||||
ownerWorktreePath: '/remote/repo'
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('reads remote orca.yaml and returns a setup launch payload during SSH create', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
|
|
|
|||
|
|
@ -304,6 +304,7 @@ import {
|
|||
assertWorktreeCleanForRemoval,
|
||||
removeWorktree
|
||||
} from '../git/worktree'
|
||||
import type { AddWorktreeResult } from '../git/worktree'
|
||||
import { isENOENT } from '../ipc/filesystem-auth'
|
||||
import {
|
||||
createSetupRunnerScript,
|
||||
|
|
@ -7292,44 +7293,43 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
|
||||
const existingBranchOption = { checkoutExistingBranch }
|
||||
if (sparseDirectories.length > 0) {
|
||||
await (checkoutExistingBranch
|
||||
? addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
existingBranchOption
|
||||
)
|
||||
: addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||||
))
|
||||
} else {
|
||||
await (checkoutExistingBranch
|
||||
? addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
false,
|
||||
existingBranchOption
|
||||
)
|
||||
: addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||||
))
|
||||
}
|
||||
const addResult: AddWorktreeResult =
|
||||
(await (sparseDirectories.length > 0
|
||||
? checkoutExistingBranch
|
||||
? addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
existingBranchOption
|
||||
)
|
||||
: addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||||
)
|
||||
: checkoutExistingBranch
|
||||
? addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
false,
|
||||
existingBranchOption
|
||||
)
|
||||
: addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||||
))) ?? {}
|
||||
|
||||
let configuredPushTarget: GitPushTarget | undefined
|
||||
if (preparedPushTarget) {
|
||||
|
|
@ -7602,7 +7602,10 @@ export class OrcaRuntimeService {
|
|||
},
|
||||
...(lineageInput ? { lineage, warnings: lineageWarnings } : {}),
|
||||
...(setup ? { setup } : {}),
|
||||
...(warning ? { warning } : {})
|
||||
...(warning ? { warning } : {}),
|
||||
...(addResult.localBaseRefRefresh
|
||||
? { localBaseRefRefresh: addResult.localBaseRefRefresh }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,22 +71,42 @@ export function GitPane({
|
|||
) : null,
|
||||
matchesSettingsSearch(searchQuery, {
|
||||
title: 'Refresh Local Base Ref',
|
||||
description: 'Optionally fast-forward local main or master when creating worktrees.',
|
||||
keywords: ['main', 'master', 'origin/main', 'git diff', 'base ref', 'worktree']
|
||||
description:
|
||||
'Safely fast-forward local main or master so AI tools and diffs use a fresh base.',
|
||||
keywords: [
|
||||
'main',
|
||||
'master',
|
||||
'origin/main',
|
||||
'git diff',
|
||||
'base ref',
|
||||
'fresh base',
|
||||
'safely',
|
||||
'worktree'
|
||||
]
|
||||
}) ? (
|
||||
<SearchableSetting
|
||||
key="refresh-base-ref"
|
||||
title="Refresh Local Base Ref"
|
||||
description="Optionally fast-forward local main or master when creating worktrees."
|
||||
keywords={['main', 'master', 'origin/main', 'git diff', 'base ref', 'worktree']}
|
||||
description="Safely fast-forward local main or master so AI tools and diffs use a fresh base."
|
||||
keywords={[
|
||||
'main',
|
||||
'master',
|
||||
'origin/main',
|
||||
'git diff',
|
||||
'base ref',
|
||||
'fresh base',
|
||||
'safely',
|
||||
'worktree'
|
||||
]}
|
||||
className="flex items-center justify-between gap-4 py-2"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<Label>Refresh Local Base Ref</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, Orca updates your local <code>main</code> or <code>master</code> before
|
||||
creating a worktree. This helps AI tools and diffs compare your branch against the
|
||||
latest base branch. Orca only does this when it is safe.
|
||||
Turn this on if you or AI tools use commands like <code>git diff main...HEAD</code>.
|
||||
Orca first refreshes the remote base, then safely fast-forwards the matching local{' '}
|
||||
<code>main</code> or <code>master</code> so those commands do not compare against stale
|
||||
history. Orca skips the update if the local branch is dirty or diverged.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -8,8 +8,17 @@ export const GIT_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
|||
},
|
||||
{
|
||||
title: 'Refresh Local Base Ref',
|
||||
description: 'Optionally fast-forward local main or master when creating worktrees.',
|
||||
keywords: ['main', 'master', 'origin/main', 'git diff', 'base ref', 'worktree']
|
||||
description: 'Safely fast-forward local main or master so AI tools and diffs use a fresh base.',
|
||||
keywords: [
|
||||
'main',
|
||||
'master',
|
||||
'origin/main',
|
||||
'git diff',
|
||||
'base ref',
|
||||
'fresh base',
|
||||
'safely',
|
||||
'worktree'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'GitHub API Budget',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldCancelVirtualizedScrollOffsetRestore } from './useVirtualizedScrollAnchor'
|
||||
import { shouldCancelVirtualizedScrollOffsetRestore } from './virtualizedScrollOffsetRestore'
|
||||
|
||||
describe('shouldCancelVirtualizedScrollOffsetRestore', () => {
|
||||
it('keeps a pending restore when direct user scroll input is not tracked', () => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
type RefObject
|
||||
} from 'react'
|
||||
import type { Virtualizer } from '@tanstack/react-virtual'
|
||||
import { shouldCancelVirtualizedScrollOffsetRestore } from './virtualizedScrollOffsetRestore'
|
||||
|
||||
export type VirtualizedScrollAnchor = {
|
||||
fallbackKeys?: readonly string[]
|
||||
|
|
@ -16,13 +17,6 @@ export type VirtualizedScrollAnchor = {
|
|||
export const VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT = 'orca-record-virtualized-scroll-anchor'
|
||||
const RECORD_ANCHOR_SCROLL_IDLE_DELAY_MS = 150
|
||||
|
||||
export function shouldCancelVirtualizedScrollOffsetRestore(args: {
|
||||
hasDirectScrollInput?: () => boolean
|
||||
restoring: boolean
|
||||
}): boolean {
|
||||
return args.restoring && args.hasDirectScrollInput?.() === true
|
||||
}
|
||||
|
||||
type UseVirtualizedScrollAnchorOptions<
|
||||
TRow,
|
||||
TScrollElement extends Element,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
export function shouldCancelVirtualizedScrollOffsetRestore(args: {
|
||||
hasDirectScrollInput?: () => boolean
|
||||
restoring: boolean
|
||||
}): boolean {
|
||||
return args.restoring && args.hasDirectScrollInput?.() === true
|
||||
}
|
||||
|
|
@ -7,15 +7,23 @@ import { create } from 'zustand'
|
|||
import type { AppState } from '../types'
|
||||
import type {
|
||||
DetectedWorktreeListResult,
|
||||
LocalBaseRefRefreshResult,
|
||||
Worktree,
|
||||
WorktreeLineage
|
||||
} from '../../../../shared/types'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
createCompatibleRuntimeStatusResponseIfNeeded,
|
||||
type RuntimeEnvironmentCallRequest
|
||||
} from '../../runtime/runtime-compatibility-test-fixture'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
warning: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
const runtimeEnvironmentTransportCall = vi.fn()
|
||||
const worktreeListMock = vi.fn().mockResolvedValue([])
|
||||
|
|
@ -963,6 +971,70 @@ describe('createWorktree base status merge', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
status: 'skipped_dirty_worktree',
|
||||
expectedReason: 'uncommitted changes'
|
||||
},
|
||||
{
|
||||
status: 'skipped_not_fast_forward',
|
||||
expectedReason: 'cannot be fast-forwarded cleanly'
|
||||
},
|
||||
{
|
||||
status: 'skipped_error',
|
||||
expectedReason: 'Git returned an error'
|
||||
}
|
||||
] satisfies {
|
||||
status: LocalBaseRefRefreshResult['status']
|
||||
expectedReason: string
|
||||
}[])('warns when local base ref refresh returns $status', async ({ status, expectedReason }) => {
|
||||
const store = createTestStore()
|
||||
const wt = makeWorktree({
|
||||
id: 'repo1::/path/wt1',
|
||||
repoId: 'repo1',
|
||||
path: '/path/wt1'
|
||||
})
|
||||
mockApi.worktrees.create.mockResolvedValue({
|
||||
worktree: wt,
|
||||
localBaseRefRefresh: {
|
||||
status,
|
||||
baseRef: 'origin/main',
|
||||
localBranch: 'main',
|
||||
ownerWorktreePath: '/repo'
|
||||
}
|
||||
})
|
||||
|
||||
await store.getState().createWorktree('repo1', 'feature', 'origin/main')
|
||||
|
||||
expect(toast.warning).toHaveBeenCalledWith('Local main was not refreshed', {
|
||||
description: expect.stringContaining(expectedReason)
|
||||
})
|
||||
const description = vi.mocked(toast.warning).mock.calls.at(-1)?.[1]?.description
|
||||
expect(description).not.toContain('AI tools')
|
||||
expect(description).not.toContain('git diff')
|
||||
})
|
||||
|
||||
it('does not warn when the local base ref refresh succeeds', async () => {
|
||||
const store = createTestStore()
|
||||
const wt = makeWorktree({
|
||||
id: 'repo1::/path/wt1',
|
||||
repoId: 'repo1',
|
||||
path: '/path/wt1'
|
||||
})
|
||||
mockApi.worktrees.create.mockResolvedValue({
|
||||
worktree: wt,
|
||||
localBaseRefRefresh: {
|
||||
status: 'updated',
|
||||
baseRef: 'origin/main',
|
||||
localBranch: 'main'
|
||||
}
|
||||
})
|
||||
|
||||
await store.getState().createWorktree('repo1', 'feature', 'origin/main')
|
||||
|
||||
expect(toast.warning).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stamps manualOrder on create while Manual sort is active', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({ sortBy: 'manual' } as Partial<AppState>)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { StateCreator } from 'zustand'
|
|||
import type { AppState } from '../types'
|
||||
import type {
|
||||
DetectedWorktreeListResult,
|
||||
LocalBaseRefRefreshResult,
|
||||
Worktree,
|
||||
WorkspaceVisibleTabType,
|
||||
GitPushTarget,
|
||||
|
|
@ -26,6 +27,7 @@ import {
|
|||
import { getHostedReviewCacheKey } from './hosted-review'
|
||||
import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from './github-cache-key'
|
||||
import { moveFocusToRendererBeforeFocusedWebviewHidden } from './browser-webview-cleanup'
|
||||
import { toast } from 'sonner'
|
||||
import { requestVirtualizedScrollAnchorRecord } from '@/hooks/requestVirtualizedScrollAnchorRecord'
|
||||
export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers'
|
||||
|
||||
|
|
@ -33,6 +35,32 @@ export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers'
|
|||
// UI hydration parity this slice used before `worktree.detectedList` existed.
|
||||
const REMOTE_WORKTREE_LIST_PARITY_LIMIT = 10_000
|
||||
|
||||
function showLocalBaseRefRefreshToast(result: LocalBaseRefRefreshResult | undefined): void {
|
||||
if (!result || result.status === 'updated') {
|
||||
return
|
||||
}
|
||||
|
||||
let reason: string
|
||||
switch (result.status) {
|
||||
case 'skipped_dirty_worktree':
|
||||
reason =
|
||||
'the worktree where it is checked out has uncommitted changes. Commit, stash, or discard those changes, then try again.'
|
||||
break
|
||||
case 'skipped_not_fast_forward':
|
||||
reason =
|
||||
'the local branch does not exist or cannot be fast-forwarded cleanly from the remote base. Check for local-only commits before updating it manually.'
|
||||
break
|
||||
case 'skipped_error':
|
||||
reason =
|
||||
'Git returned an error while updating the local ref. Check the repo for locked refs or unusual worktree state, then try again.'
|
||||
break
|
||||
}
|
||||
|
||||
toast.warning(`Local ${result.localBranch} was not refreshed`, {
|
||||
description: `Workspace created from ${result.baseRef}, but Orca could not fast-forward local ${result.localBranch} because ${reason}`
|
||||
})
|
||||
}
|
||||
|
||||
function arraysShallowEqual(a: string[] | undefined, b: string[] | undefined): boolean {
|
||||
if (a === b) {
|
||||
return true
|
||||
|
|
@ -945,6 +973,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
sortEpoch: s.sortEpoch + 1
|
||||
}
|
||||
})
|
||||
showLocalBaseRefRefreshToast(result.localBaseRefRefresh)
|
||||
return result
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
|
|
|||
|
|
@ -1316,6 +1316,14 @@ export type CreateWorktreeResult = {
|
|||
setup?: WorktreeSetupLaunch
|
||||
warning?: string
|
||||
initialBaseStatus?: WorktreeBaseStatusEvent
|
||||
localBaseRefRefresh?: LocalBaseRefRefreshResult
|
||||
}
|
||||
|
||||
export type LocalBaseRefRefreshResult = {
|
||||
status: 'updated' | 'skipped_dirty_worktree' | 'skipped_not_fast_forward' | 'skipped_error'
|
||||
baseRef: string
|
||||
localBranch: string
|
||||
ownerWorktreePath?: string
|
||||
}
|
||||
|
||||
export type WorktreeBaseStatusKind = 'checking' | 'current' | 'drift' | 'base_changed' | 'unknown'
|
||||
|
|
|
|||
Loading…
Reference in New Issue