fix: address review findings (#4814)
This commit is contained in:
parent
de7e32c41c
commit
5cfeaa34c0
|
|
@ -463,6 +463,32 @@ describe('getStatus', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('preserves porcelain v2 submodule dirtiness flags on status rows', async () => {
|
||||
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout:
|
||||
'1 AM S..U 000000 160000 160000 0000000000000000000000000000000000000000 7844cb64e631f17a9ca5b548f3500ef7cecd2f17 nested-repo\n'
|
||||
})
|
||||
|
||||
const result = await getStatus('/repo')
|
||||
|
||||
expect(result.entries).toEqual([
|
||||
{
|
||||
path: 'nested-repo',
|
||||
status: 'added',
|
||||
area: 'staged',
|
||||
submodule: { commitChanged: false, trackedChanges: false, untrackedChanges: true }
|
||||
},
|
||||
{
|
||||
path: 'nested-repo',
|
||||
status: 'modified',
|
||||
area: 'unstaged',
|
||||
submodule: { commitChanged: false, trackedChanges: false, untrackedChanges: true }
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('omits ignored files by default and parses them when requested', async () => {
|
||||
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ export async function getStatus(
|
|||
// Changed entries: "1 XY sub mH mI mW hH path" or "2 XY sub mH mI mW hH X\tscore\tpath\torigPath"
|
||||
const parts = line.split(' ')
|
||||
const xy = parts[1]
|
||||
const submodule = parseSubmoduleStatus(parts[2])
|
||||
const indexStatus = xy[0]
|
||||
const worktreeStatus = xy[1]
|
||||
|
||||
|
|
@ -149,24 +150,41 @@ export async function getStatus(
|
|||
const path = decodeGitCQuotedPath(tabParts[0].split(' ').slice(9).join(' '))
|
||||
const oldPath = decodeGitCQuotedPath(tabParts.slice(1).join('\t'))
|
||||
if (indexStatus !== '.') {
|
||||
entries.push({ path, status: parseStatusChar(indexStatus), area: 'staged', oldPath })
|
||||
entries.push({
|
||||
path,
|
||||
status: parseStatusChar(indexStatus),
|
||||
area: 'staged',
|
||||
oldPath,
|
||||
...(submodule ? { submodule } : {})
|
||||
})
|
||||
}
|
||||
if (worktreeStatus !== '.') {
|
||||
entries.push({
|
||||
path,
|
||||
status: parseStatusChar(worktreeStatus),
|
||||
area: 'unstaged',
|
||||
oldPath
|
||||
oldPath,
|
||||
...(submodule ? { submodule } : {})
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Regular change entry
|
||||
const path = decodeGitCQuotedPath(parts.slice(8).join(' '))
|
||||
if (indexStatus !== '.') {
|
||||
entries.push({ path, status: parseStatusChar(indexStatus), area: 'staged' })
|
||||
entries.push({
|
||||
path,
|
||||
status: parseStatusChar(indexStatus),
|
||||
area: 'staged',
|
||||
...(submodule ? { submodule } : {})
|
||||
})
|
||||
}
|
||||
if (worktreeStatus !== '.') {
|
||||
entries.push({ path, status: parseStatusChar(worktreeStatus), area: 'unstaged' })
|
||||
entries.push({
|
||||
path,
|
||||
status: parseStatusChar(worktreeStatus),
|
||||
area: 'unstaged',
|
||||
...(submodule ? { submodule } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (line.startsWith('? ')) {
|
||||
|
|
@ -429,6 +447,17 @@ function parseStatusChar(char: string): GitFileStatus {
|
|||
}
|
||||
}
|
||||
|
||||
function parseSubmoduleStatus(submoduleField: string | undefined): GitStatusEntry['submodule'] {
|
||||
if (!submoduleField?.startsWith('S')) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
commitChanged: submoduleField[1] === 'C',
|
||||
trackedChanges: submoduleField[2] === 'M',
|
||||
untrackedChanges: submoduleField[3] === 'U'
|
||||
}
|
||||
}
|
||||
|
||||
function parseBranchStatusChar(char: string): GitBranchChangeStatus {
|
||||
switch (char) {
|
||||
case 'M':
|
||||
|
|
|
|||
|
|
@ -47,4 +47,25 @@ describe('parseStatusOutput', () => {
|
|||
{ path: 'src/new name.ts', oldPath: 'src/old name.ts', status: 'renamed', area: 'staged' }
|
||||
])
|
||||
})
|
||||
|
||||
it('parses submodule dirtiness flags from porcelain records', () => {
|
||||
const result = parseStatusOutput(
|
||||
'1 AM S..U 000000 160000 160000 0000000000000000000000000000000000000000 7844cb64e631f17a9ca5b548f3500ef7cecd2f17 nested-repo\n'
|
||||
)
|
||||
|
||||
expect(result.entries).toEqual([
|
||||
{
|
||||
path: 'nested-repo',
|
||||
status: 'added',
|
||||
area: 'staged',
|
||||
submodule: { commitChanged: false, trackedChanges: false, untrackedChanges: true }
|
||||
},
|
||||
{
|
||||
path: 'nested-repo',
|
||||
status: 'modified',
|
||||
area: 'unstaged',
|
||||
submodule: { commitChanged: false, trackedChanges: false, untrackedChanges: true }
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export function parseStatusOutput(stdout: string): {
|
|||
if (line.startsWith('1 ') || line.startsWith('2 ')) {
|
||||
const parts = line.split(' ')
|
||||
const xy = parts[1]
|
||||
const submodule = parseSubmoduleStatus(parts[2])
|
||||
const indexStatus = xy[0]
|
||||
const worktreeStatus = xy[1]
|
||||
|
||||
|
|
@ -83,7 +84,8 @@ export function parseStatusOutput(stdout: string): {
|
|||
path: filePath,
|
||||
status: parseStatusChar(indexStatus),
|
||||
area: 'staged',
|
||||
oldPath
|
||||
oldPath,
|
||||
...(submodule ? { submodule } : {})
|
||||
})
|
||||
}
|
||||
if (worktreeStatus !== '.') {
|
||||
|
|
@ -91,19 +93,26 @@ export function parseStatusOutput(stdout: string): {
|
|||
path: filePath,
|
||||
status: parseStatusChar(worktreeStatus),
|
||||
area: 'unstaged',
|
||||
oldPath
|
||||
oldPath,
|
||||
...(submodule ? { submodule } : {})
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const filePath = parts.slice(8).join(' ')
|
||||
if (indexStatus !== '.') {
|
||||
entries.push({ path: filePath, status: parseStatusChar(indexStatus), area: 'staged' })
|
||||
entries.push({
|
||||
path: filePath,
|
||||
status: parseStatusChar(indexStatus),
|
||||
area: 'staged',
|
||||
...(submodule ? { submodule } : {})
|
||||
})
|
||||
}
|
||||
if (worktreeStatus !== '.') {
|
||||
entries.push({
|
||||
path: filePath,
|
||||
status: parseStatusChar(worktreeStatus),
|
||||
area: 'unstaged'
|
||||
area: 'unstaged',
|
||||
...(submodule ? { submodule } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -133,6 +142,19 @@ export function parseStatusOutput(stdout: string): {
|
|||
}
|
||||
}
|
||||
|
||||
function parseSubmoduleStatus(
|
||||
submoduleField: string | undefined
|
||||
): { commitChanged: boolean; trackedChanges: boolean; untrackedChanges: boolean } | undefined {
|
||||
if (!submoduleField?.startsWith('S')) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
commitChanged: submoduleField[1] === 'C',
|
||||
trackedChanges: submoduleField[2] === 'M',
|
||||
untrackedChanges: submoduleField[3] === 'U'
|
||||
}
|
||||
}
|
||||
|
||||
function parseBranchAheadBehind(line: string): { ahead: number; behind: number } | null {
|
||||
const match = line.match(/^# branch\.ab \+(\d+) -(\d+)$/)
|
||||
if (!match) {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ function buildInputs(overrides: Partial<PrimaryActionInputs> = {}): PrimaryActio
|
|||
return {
|
||||
stagedCount: 1,
|
||||
hasUnstagedChanges: false,
|
||||
hasStageableChanges: false,
|
||||
hasPartiallyStagedChanges: false,
|
||||
hasMessage: true,
|
||||
hasUnresolvedConflicts: false,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ function buildInputs(overrides: Partial<PrimaryActionInputs> = {}): PrimaryActio
|
|||
return {
|
||||
stagedCount: 1,
|
||||
hasUnstagedChanges: false,
|
||||
hasStageableChanges: false,
|
||||
hasPartiallyStagedChanges: false,
|
||||
hasMessage: true,
|
||||
hasUnresolvedConflicts: false,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ function buildInputs(overrides: Partial<PrimaryActionInputs> = {}): PrimaryActio
|
|||
return {
|
||||
stagedCount: 1,
|
||||
hasUnstagedChanges: false,
|
||||
hasStageableChanges: false,
|
||||
hasPartiallyStagedChanges: false,
|
||||
hasMessage: true,
|
||||
hasUnresolvedConflicts: false,
|
||||
|
|
@ -123,6 +124,7 @@ describe('CommitArea primary action icons', () => {
|
|||
baseProps({
|
||||
stagedCount: 0,
|
||||
hasUnstagedChanges: true,
|
||||
hasStageableChanges: true,
|
||||
hasMessage: false,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ function buildInputs(overrides: Partial<PrimaryActionInputs> = {}): PrimaryActio
|
|||
return {
|
||||
stagedCount: 1,
|
||||
hasUnstagedChanges: false,
|
||||
hasStageableChanges: false,
|
||||
hasPartiallyStagedChanges: false,
|
||||
hasMessage: true,
|
||||
hasUnresolvedConflicts: false,
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ import {
|
|||
getDiscardAllPaths,
|
||||
getStageAllPaths,
|
||||
getUnstageAllPaths,
|
||||
isStageableStatusEntry,
|
||||
isSubmoduleWorktreeOnlyChange,
|
||||
runDiscardAllForArea,
|
||||
type DiscardAllArea
|
||||
} from './discard-all-sequence'
|
||||
|
|
@ -259,6 +261,8 @@ 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'
|
||||
|
||||
function createDefaultCollapsedSections(): Set<string> {
|
||||
return new Set(DEFAULT_COLLAPSED_SECTIONS)
|
||||
|
|
@ -2847,19 +2851,28 @@ function SourceControlInner(): React.JSX.Element {
|
|||
worktreePath
|
||||
])
|
||||
|
||||
const stageableUnstagedPaths = useMemo(
|
||||
() => [
|
||||
...getStageAllPaths(grouped.unstaged, 'unstaged'),
|
||||
...getStageAllPaths(grouped.untracked, 'untracked')
|
||||
],
|
||||
[grouped.unstaged, grouped.untracked]
|
||||
)
|
||||
const hasUnstagedChanges = grouped.unstaged.length > 0 || grouped.untracked.length > 0
|
||||
const hasStageableChanges = stageableUnstagedPaths.length > 0
|
||||
const hasPartiallyStagedChanges = useMemo(() => {
|
||||
if (grouped.staged.length === 0 || grouped.unstaged.length === 0) {
|
||||
if (grouped.staged.length === 0 || stageableUnstagedPaths.length === 0) {
|
||||
return false
|
||||
}
|
||||
const unstagedPaths = new Set(grouped.unstaged.map((entry) => entry.path))
|
||||
const unstagedPaths = new Set(stageableUnstagedPaths)
|
||||
return grouped.staged.some((entry) => unstagedPaths.has(entry.path))
|
||||
}, [grouped.staged, grouped.unstaged])
|
||||
}, [grouped.staged, stageableUnstagedPaths])
|
||||
|
||||
const primaryAction: PrimaryAction = useMemo(() => {
|
||||
const action = resolvePrimaryAction({
|
||||
stagedCount: grouped.staged.length,
|
||||
hasUnstagedChanges,
|
||||
hasStageableChanges,
|
||||
hasPartiallyStagedChanges,
|
||||
hasMessage: commitMessage.trim().length > 0,
|
||||
hasUnresolvedConflicts: unresolvedConflicts.length > 0,
|
||||
|
|
@ -2884,6 +2897,7 @@ function SourceControlInner(): React.JSX.Element {
|
|||
commitMessage,
|
||||
grouped.staged.length,
|
||||
hasUnstagedChanges,
|
||||
hasStageableChanges,
|
||||
hasPartiallyStagedChanges,
|
||||
isCommitting,
|
||||
isAbortingOperation,
|
||||
|
|
@ -2905,6 +2919,7 @@ function SourceControlInner(): React.JSX.Element {
|
|||
resolveDropdownItems({
|
||||
stagedCount: grouped.staged.length,
|
||||
hasUnstagedChanges,
|
||||
hasStageableChanges,
|
||||
hasPartiallyStagedChanges,
|
||||
hasMessage: commitMessage.trim().length > 0,
|
||||
hasUnresolvedConflicts: unresolvedConflicts.length > 0,
|
||||
|
|
@ -2925,6 +2940,7 @@ function SourceControlInner(): React.JSX.Element {
|
|||
commitMessage,
|
||||
grouped.staged.length,
|
||||
hasUnstagedChanges,
|
||||
hasStageableChanges,
|
||||
hasPartiallyStagedChanges,
|
||||
isCommitting,
|
||||
conflictOperation,
|
||||
|
|
@ -3106,11 +3122,7 @@ function SourceControlInner(): React.JSX.Element {
|
|||
const bulkStagePaths = useMemo(
|
||||
() =>
|
||||
selectedEntries
|
||||
.filter(
|
||||
(entry) =>
|
||||
(entry.area === 'unstaged' || entry.area === 'untracked') &&
|
||||
entry.entry.conflictStatus !== 'unresolved'
|
||||
)
|
||||
.filter((entry) => isStageableStatusEntry(entry.entry))
|
||||
.map((entry) => entry.entry.path),
|
||||
[selectedEntries]
|
||||
)
|
||||
|
|
@ -6590,6 +6602,7 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
|
|||
const dirPath = parentDir === '.' ? '' : parentDir
|
||||
const isUnresolvedConflict = entry.conflictStatus === 'unresolved'
|
||||
const isResolvedLocally = entry.conflictStatus === 'resolved_locally'
|
||||
const isSubmoduleWorktreeOnly = isSubmoduleWorktreeOnlyChange(entry)
|
||||
const conflictLabel = entry.conflictKind ? CONFLICT_KIND_LABELS[entry.conflictKind] : null
|
||||
// Why: the hint text ("Open and edit…", "Decide whether to…") was removed
|
||||
// from the sidebar because it's not actionable here — the user can only
|
||||
|
|
@ -6607,8 +6620,7 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
|
|||
!isUnresolvedConflict &&
|
||||
!isResolvedLocally &&
|
||||
(entry.area === 'unstaged' || entry.area === 'untracked')
|
||||
const canStage =
|
||||
!isUnresolvedConflict && (entry.area === 'unstaged' || entry.area === 'untracked')
|
||||
const canStage = isStageableStatusEntry(entry)
|
||||
const canUnstage = entry.area === 'staged'
|
||||
|
||||
return (
|
||||
|
|
@ -6662,8 +6674,10 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
|
|||
<span className="ml-1.5 text-[11px] text-muted-foreground">{dirPath}</span>
|
||||
)}
|
||||
</span>
|
||||
{conflictLabel && (
|
||||
<div className="truncate text-[11px] text-muted-foreground">{conflictLabel}</div>
|
||||
{(conflictLabel || isSubmoduleWorktreeOnly) && (
|
||||
<div className="truncate text-[11px] text-muted-foreground">
|
||||
{conflictLabel ?? SUBMODULE_WORKTREE_ONLY_LABEL}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{commentCount > 0 && (
|
||||
|
|
@ -6708,14 +6722,15 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
{canStage && (
|
||||
{(canStage || isSubmoduleWorktreeOnly) && (
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
title="Stage"
|
||||
title={isSubmoduleWorktreeOnly ? SUBMODULE_WORKTREE_ONLY_STAGE_TOOLTIP : 'Stage'}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void onStage(entry.path)
|
||||
}}
|
||||
disabled={isSubmoduleWorktreeOnly}
|
||||
/>
|
||||
)}
|
||||
{canUnstage && (
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import {
|
|||
getDiscardAllPaths,
|
||||
getStageAllPaths,
|
||||
getUnstageAllPaths,
|
||||
isStageableStatusEntry,
|
||||
isSubmoduleWorktreeOnlyChange,
|
||||
runDiscardAllForArea,
|
||||
type DiscardAllArea
|
||||
} from './discard-all-sequence'
|
||||
|
|
@ -109,12 +111,52 @@ describe('getStageAllPaths', () => {
|
|||
expect(getStageAllPaths(entries, 'unstaged')).toEqual(['clean.ts', 'resolved.ts'])
|
||||
})
|
||||
|
||||
it('skips submodule rows that only contain nested worktree dirtiness', () => {
|
||||
const entries: GitStatusEntry[] = [
|
||||
entry({
|
||||
path: 'nested-repo',
|
||||
area: 'unstaged',
|
||||
submodule: { commitChanged: false, trackedChanges: false, untrackedChanges: true }
|
||||
}),
|
||||
entry({
|
||||
path: 'changed-gitlink',
|
||||
area: 'unstaged',
|
||||
submodule: { commitChanged: true, trackedChanges: false, untrackedChanges: true }
|
||||
})
|
||||
]
|
||||
expect(getStageAllPaths(entries, 'unstaged')).toEqual(['changed-gitlink'])
|
||||
})
|
||||
|
||||
it('returns an empty array when nothing matches', () => {
|
||||
expect(getStageAllPaths([], 'unstaged')).toEqual([])
|
||||
expect(getStageAllPaths([entry({ path: 'a.ts', area: 'staged' })], 'unstaged')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('status entry stageability', () => {
|
||||
it('marks nested-only submodule changes as not stageable from the parent repo', () => {
|
||||
const nestedOnly = entry({
|
||||
path: 'nested-repo',
|
||||
area: 'unstaged',
|
||||
submodule: { commitChanged: false, trackedChanges: true, untrackedChanges: false }
|
||||
})
|
||||
|
||||
expect(isSubmoduleWorktreeOnlyChange(nestedOnly)).toBe(true)
|
||||
expect(isStageableStatusEntry(nestedOnly)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps changed submodule gitlinks stageable from the parent repo', () => {
|
||||
const changedGitlink = entry({
|
||||
path: 'nested-repo',
|
||||
area: 'unstaged',
|
||||
submodule: { commitChanged: true, trackedChanges: true, untrackedChanges: true }
|
||||
})
|
||||
|
||||
expect(isSubmoduleWorktreeOnlyChange(changedGitlink)).toBe(false)
|
||||
expect(isStageableStatusEntry(changedGitlink)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUnstageAllPaths', () => {
|
||||
it('returns only staged-area paths', () => {
|
||||
const entries: GitStatusEntry[] = [
|
||||
|
|
|
|||
|
|
@ -32,10 +32,25 @@ export type StageAllArea = 'unstaged' | 'untracked'
|
|||
*/
|
||||
export function getStageAllPaths(entries: readonly GitStatusEntry[], area: StageAllArea): string[] {
|
||||
return entries
|
||||
.filter((entry) => entry.area === area && entry.conflictStatus !== 'unresolved')
|
||||
.filter((entry) => entry.area === area && isStageableStatusEntry(entry))
|
||||
.map((entry) => entry.path)
|
||||
}
|
||||
|
||||
export function isStageableStatusEntry(entry: GitStatusEntry): boolean {
|
||||
return (
|
||||
(entry.area === 'unstaged' || entry.area === 'untracked') &&
|
||||
entry.conflictStatus !== 'unresolved' &&
|
||||
!isSubmoduleWorktreeOnlyChange(entry)
|
||||
)
|
||||
}
|
||||
|
||||
export function isSubmoduleWorktreeOnlyChange(entry: GitStatusEntry): boolean {
|
||||
const submodule = entry.submodule
|
||||
// Why: parent-repo `git add <submodule>` can stage a changed gitlink commit,
|
||||
// but it cannot stage tracked/untracked file dirtiness inside the submodule.
|
||||
return entry.area === 'unstaged' && !!submodule && !submodule.commitChanged
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the paths an "Unstage all" action should operate on.
|
||||
* Every staged row is eligible — `git reset HEAD` on a staged conflict
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ function inputs(overrides: Partial<DropdownActionInputs> = {}): DropdownActionIn
|
|||
return {
|
||||
stagedCount: 0,
|
||||
hasUnstagedChanges: false,
|
||||
hasStageableChanges: false,
|
||||
hasPartiallyStagedChanges: false,
|
||||
hasMessage: false,
|
||||
hasUnresolvedConflicts: false,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ function inputs(overrides: Partial<PrimaryActionInputs> = {}): PrimaryActionInpu
|
|||
return {
|
||||
stagedCount: 0,
|
||||
hasUnstagedChanges: false,
|
||||
hasStageableChanges: false,
|
||||
hasPartiallyStagedChanges: false,
|
||||
hasMessage: false,
|
||||
hasUnresolvedConflicts: false,
|
||||
|
|
@ -287,6 +288,7 @@ describe('resolvePrimaryAction', () => {
|
|||
const result = resolvePrimaryAction(
|
||||
inputs({
|
||||
hasUnstagedChanges: true,
|
||||
hasStageableChanges: true,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 3 }
|
||||
})
|
||||
)
|
||||
|
|
@ -302,6 +304,7 @@ describe('resolvePrimaryAction', () => {
|
|||
const result = resolvePrimaryAction(
|
||||
inputs({
|
||||
hasUnstagedChanges: true,
|
||||
hasStageableChanges: true,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }
|
||||
})
|
||||
)
|
||||
|
|
@ -314,6 +317,7 @@ describe('resolvePrimaryAction', () => {
|
|||
const result = resolvePrimaryAction(
|
||||
inputs({
|
||||
hasUnstagedChanges: true,
|
||||
hasStageableChanges: true,
|
||||
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }
|
||||
})
|
||||
)
|
||||
|
|
@ -322,7 +326,7 @@ describe('resolvePrimaryAction', () => {
|
|||
|
||||
it('returns Stage All on a dirty tree while upstream status is still loading', () => {
|
||||
const result = resolvePrimaryAction(
|
||||
inputs({ hasUnstagedChanges: true, upstreamStatus: undefined })
|
||||
inputs({ hasUnstagedChanges: true, hasStageableChanges: true, upstreamStatus: undefined })
|
||||
)
|
||||
expect(result.kind).toBe('stage')
|
||||
expect(result.disabled).toBe(false)
|
||||
|
|
@ -333,6 +337,7 @@ describe('resolvePrimaryAction', () => {
|
|||
inputs({
|
||||
stagedCount: 1,
|
||||
hasUnstagedChanges: true,
|
||||
hasStageableChanges: true,
|
||||
hasPartiallyStagedChanges: true,
|
||||
hasMessage: true,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
|
||||
|
|
@ -357,6 +362,22 @@ describe('resolvePrimaryAction', () => {
|
|||
expect(result.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('does not return Stage All when dirty rows cannot be staged from the parent repo', () => {
|
||||
const result = resolvePrimaryAction(
|
||||
inputs({
|
||||
hasUnstagedChanges: true,
|
||||
hasStageableChanges: false,
|
||||
upstreamStatus: upstreamInSync
|
||||
})
|
||||
)
|
||||
expect(result).toEqual({
|
||||
kind: 'commit',
|
||||
label: 'Commit',
|
||||
title: 'Stage at least one file to commit',
|
||||
disabled: true
|
||||
})
|
||||
})
|
||||
|
||||
it('still disables Commit (needs message) when staged+dirty without a message', () => {
|
||||
const result = resolvePrimaryAction(
|
||||
inputs({ stagedCount: 1, hasUnstagedChanges: true, hasMessage: false })
|
||||
|
|
@ -368,7 +389,11 @@ describe('resolvePrimaryAction', () => {
|
|||
|
||||
it('returns Stage All when unstaged changes exist on an in-sync branch', () => {
|
||||
const result = resolvePrimaryAction(
|
||||
inputs({ hasUnstagedChanges: true, upstreamStatus: upstreamInSync })
|
||||
inputs({
|
||||
hasUnstagedChanges: true,
|
||||
hasStageableChanges: true,
|
||||
upstreamStatus: upstreamInSync
|
||||
})
|
||||
)
|
||||
expect(result).toEqual({
|
||||
kind: 'stage',
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export type PrimaryAction = {
|
|||
export type PrimaryActionInputs = {
|
||||
stagedCount: number
|
||||
hasUnstagedChanges: boolean
|
||||
hasStageableChanges: boolean
|
||||
hasPartiallyStagedChanges: boolean
|
||||
hasMessage: boolean
|
||||
hasUnresolvedConflicts: boolean
|
||||
|
|
@ -130,6 +131,7 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
|
|||
const {
|
||||
stagedCount,
|
||||
hasUnstagedChanges,
|
||||
hasStageableChanges,
|
||||
hasPartiallyStagedChanges,
|
||||
hasMessage,
|
||||
hasUnresolvedConflicts,
|
||||
|
|
@ -253,7 +255,7 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
|
|||
// with uncommitted changes; push/publish skips the actual user need).
|
||||
// Sits before the upstream-status checks so it works regardless of
|
||||
// whether upstream has resolved yet.
|
||||
if (!hasStaged && hasUnstagedChanges) {
|
||||
if (!hasStaged && hasStageableChanges) {
|
||||
return {
|
||||
kind: 'stage',
|
||||
label: 'Stage All',
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ export type GitConflictKind =
|
|||
export type GitConflictResolutionStatus = 'unresolved' | 'resolved_locally'
|
||||
export type GitConflictStatusSource = 'git' | 'session'
|
||||
export type GitConflictOperation = 'merge' | 'rebase' | 'cherry-pick' | 'unknown'
|
||||
export type GitSubmoduleStatus = {
|
||||
commitChanged: boolean
|
||||
trackedChanges: boolean
|
||||
untrackedChanges: boolean
|
||||
}
|
||||
|
||||
// Compatibility note for non-upgraded consumers:
|
||||
// Any consumer that has not been upgraded to read `conflictStatus` may still
|
||||
|
|
@ -32,6 +37,7 @@ export type GitUncommittedEntry = {
|
|||
conflictKind?: GitConflictKind
|
||||
conflictStatus?: GitConflictResolutionStatus
|
||||
conflictStatusSource?: GitConflictStatusSource
|
||||
submodule?: GitSubmoduleStatus
|
||||
// Working-tree line counts for this entry's staging area (staged vs unstaged
|
||||
// diffs are reported separately). Untracked files count their full contents
|
||||
// as additions. Undefined for binary files and when the diff is unavailable.
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export type {
|
|||
GitStagingArea,
|
||||
GitStatusEntry,
|
||||
GitStatusResult,
|
||||
GitSubmoduleStatus,
|
||||
GitUncommittedEntry,
|
||||
GitUpstreamStatus
|
||||
} from './git-status-types'
|
||||
|
|
|
|||
Loading…
Reference in New Issue