Exclude submodule worktree-only changes from Stage All action (#7298)

- Prevent the "Stage All" button from being enabled when only nested
  submodule worktree changes are present.
- Remove the disabled stage button on submodule worktree-only rows,
  replacing it with an explanatory tooltip.
- Fix WSL terminal environment assertion in daemon PTY tests to allow
  inherited agent-hook environment variables.
This commit is contained in:
Jinjing 2026-07-04 00:21:55 -07:00 committed by GitHub
parent 7c77ccab7d
commit 0982882436
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 102 additions and 28 deletions

View File

@ -2028,15 +2028,14 @@ describe('createPtySubprocess', () => {
}
}
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
expect.any(Array),
expect.objectContaining({
env: expect.objectContaining({
ORCA_TERMINAL_HANDLE: 'term_wsl',
WSLENV: 'FOO/u:ORCA_TERMINAL_HANDLE/u:POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD'
})
})
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[0]).toBe('wsl.exe')
expect(spawnCall[1]).toEqual(expect.any(Array))
expect(spawnCall[2].env.ORCA_TERMINAL_HANDLE).toBe('term_wsl')
// Why: the daemon inherits optional agent-hook env in development. This
// test owns only the terminal handle and Powerlevel10k WSLENV contract.
expect(spawnCall[2].env.WSLENV?.split(':')).toEqual(
expect.arrayContaining(['FOO/u', 'ORCA_TERMINAL_HANDLE/u', POWERLEVEL10K_WIZARD_DISABLE_ENV])
)
})

View File

@ -47,6 +47,7 @@ const mocks = vi.hoisted(() => {
openBranchDiff: vi.fn(),
createEmptySplitGroup: vi.fn(),
discardRuntimeGitPath: vi.fn(),
bulkStageRuntimeGitPaths: vi.fn(),
refreshGitStatusForWorktree: vi.fn(),
requestEditorSaveQuiesce: vi.fn(),
notifyEditorExternalFileChange: vi.fn()
@ -85,7 +86,8 @@ vi.mock('@/runtime/runtime-git-client', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>()
return {
...actual,
discardRuntimeGitPath: mocks.calls.discardRuntimeGitPath
discardRuntimeGitPath: mocks.calls.discardRuntimeGitPath,
bulkStageRuntimeGitPaths: mocks.calls.bulkStageRuntimeGitPaths
}
})
@ -140,6 +142,7 @@ function resetState(overrides: Partial<Record<string, unknown>> = {}): void {
vi.clearAllMocks()
mocks.calls.createEmptySplitGroup.mockReturnValue('group-2')
mocks.calls.discardRuntimeGitPath.mockResolvedValue(undefined)
mocks.calls.bulkStageRuntimeGitPaths.mockResolvedValue(undefined)
mocks.calls.refreshGitStatusForWorktree.mockResolvedValue(undefined)
mocks.calls.requestEditorSaveQuiesce.mockResolvedValue(undefined)
mocks.state = {
@ -437,13 +440,76 @@ describe('SourceControl preview row opens', () => {
const row = container.querySelector<HTMLDivElement>(
'[data-source-control-path="packages/nested"]'
)
expect(row?.textContent).toContain('Submodule changes - stage inside submodule')
expect(row?.textContent).toContain('Stage inside submodule')
expect(
row?.querySelector('[title*="cannot stage file changes inside a submodule"]')
).not.toBeNull()
const stageButton = row?.querySelector<HTMLButtonElement>(
'button[aria-label="Stage these changes inside the submodule"]'
expect(row?.querySelector<HTMLButtonElement>('button[aria-label="Stage"]')).toBeNull()
})
it('does not render a commit-area Stage All primary for submodule worktree-only rows', () => {
resetState({
gitStatusByWorktree: {
[mocks.activeWorktree.id]: [
gitEntry({
path: 'june-11th-launch',
submodule: { commitChanged: false, trackedChanges: true, untrackedChanges: false }
})
]
}
})
renderSourceControl()
const row = container.querySelector<HTMLDivElement>(
'[data-source-control-path="june-11th-launch"]'
)
expect(row?.textContent).toContain('Stage inside submodule')
const stageAllButton = [...container.querySelectorAll<HTMLButtonElement>('button')].find(
(button) => button.textContent?.trim() === 'Stage All'
)
expect(stageAllButton).toBeUndefined()
const commitButton = [...container.querySelectorAll<HTMLButtonElement>('button')].find(
(button) => button.textContent?.trim() === 'Commit'
)
expect(commitButton).toBeDefined()
expect(commitButton?.disabled).toBe(true)
act(() => {
commitButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(mocks.calls.bulkStageRuntimeGitPaths).not.toHaveBeenCalled()
})
it('keeps the commit-area Stage All primary for changed submodule gitlinks', async () => {
resetState({
gitStatusByWorktree: {
[mocks.activeWorktree.id]: [
gitEntry({
path: 'packages/nested',
submodule: { commitChanged: true, trackedChanges: false, untrackedChanges: false }
})
]
}
})
renderSourceControl()
const stageAllButton = [...container.querySelectorAll<HTMLButtonElement>('button')].find(
(button) => button.textContent?.trim() === 'Stage All'
)
expect(stageAllButton).toBeDefined()
expect(stageAllButton?.disabled).toBe(false)
await act(async () => {
stageAllButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await Promise.resolve()
})
expect(mocks.calls.bulkStageRuntimeGitPaths).toHaveBeenCalledWith(
expect.objectContaining({ worktreeId: mocks.activeWorktree.id, worktreePath: '/repo/wt' }),
['packages/nested']
)
expect(stageButton).not.toBeNull()
expect(stageButton?.getAttribute('aria-disabled')).toBe('true')
})
it('passes preview=true when a plain branch row click opens a branch diff tab', () => {

View File

@ -536,8 +536,9 @@ const SOURCE_CONTROL_TREE_DIRECTORY_PADDING_PX = 8
const SOURCE_CONTROL_TREE_FILE_PADDING_PX = 20
const EMPTY_GIT_HISTORY_STATE: GitHistoryPanelState = { status: 'idle' }
const DEFAULT_COLLAPSED_SECTIONS = ['history'] as const
const SUBMODULE_WORKTREE_ONLY_LABEL = 'Submodule changes - stage inside submodule'
const SUBMODULE_WORKTREE_ONLY_STAGE_TOOLTIP = 'Stage these changes inside the submodule'
const SUBMODULE_WORKTREE_ONLY_LABEL = 'Stage inside submodule'
const SUBMODULE_WORKTREE_ONLY_TOOLTIP =
'The parent repo (including Stage All) cannot stage file changes inside a submodule'
const SUBMODULE_LOADING_LABEL = 'Loading submodule changes…'
const SUBMODULE_EMPTY_LABEL = 'No changes in submodule'
const SUBMODULE_ERROR_LABEL = 'Failed to load submodule changes'
@ -3995,7 +3996,12 @@ function SourceControlInner(): React.JSX.Element {
])
const hasUnstagedChanges = grouped.unstaged.length > 0 || grouped.untracked.length > 0
const hasStageableChanges = hasUnstagedChanges
const hasStageableChanges = useMemo(
() =>
grouped.unstaged.some(isStageableStatusEntry) ||
grouped.untracked.some(isStageableStatusEntry),
[grouped.unstaged, grouped.untracked]
)
const hasPartiallyStagedChanges = useMemo(() => {
if (grouped.staged.length === 0 || grouped.unstaged.length === 0) {
return false
@ -8115,9 +8121,17 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
<span className="ml-1.5 text-[11px] text-muted-foreground">{dirPath}</span>
)}
</span>
{(conflictLabel || isSubmoduleWorktreeOnly) && (
<div className="truncate text-[11px] text-muted-foreground">
{conflictLabel ?? SUBMODULE_WORKTREE_ONLY_LABEL}
{conflictLabel && (
<div className="truncate text-[11px] text-muted-foreground">{conflictLabel}</div>
)}
{isSubmoduleWorktreeOnly && (
// Why: parent git can stage a changed gitlink, but not nested
// worktree dirtiness. Keep that boundary visible in the row.
<div
className="truncate text-[11px] text-muted-foreground"
title={SUBMODULE_WORKTREE_ONLY_TOOLTIP}
>
{SUBMODULE_WORKTREE_ONLY_LABEL}
</div>
)}
</div>
@ -8176,19 +8190,14 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
}}
/>
)}
{(canStage || isSubmoduleWorktreeOnly) && (
{canStage && (
<ActionButton
icon={Plus}
title={
isSubmoduleWorktreeOnly
? SUBMODULE_WORKTREE_ONLY_STAGE_TOOLTIP
: translate('auto.components.right.sidebar.SourceControl.8cde1a2fb0', 'Stage')
}
title={translate('auto.components.right.sidebar.SourceControl.8cde1a2fb0', 'Stage')}
onClick={(event) => {
event.stopPropagation()
void onStage(entry.path)
}}
disabled={isSubmoduleWorktreeOnly}
/>
)}
{canUnstage && (