Files
orca/src/relay/git-status-output-parser.ts
T
lvfenandJinjing b776506809 Support submodule diffs and upstream-base compares in Source Control (#6350)
* feat(source-control): show submodule diffs with lazy expansion

Dirty submodules now expand inline in Source Control to reveal their
inner changes, with file-level diffs that are read-only from the parent
worktree. Inner status is fetched lazily only when a submodule is
expanded, so status polling never recurses into (possibly nested)
submodules. Adds a submodule-status path across local and SSH runtimes
and git providers.

* feat(source-control): add compare-against-current-branch setting

Adds a global setting (default off) that defaults the Source Control
compare base to the current branch's upstream so the panel prioritizes
local changes instead of the full delta versus the repository default
branch. When the branch has no upstream, the compare view falls back to
working-tree-only. This affects only the compare/diff view; the Pull
Request and rebase merge target are unchanged.

* refactor(source-control): extract submodule status hook and entry-action gates

Moves the lazy submodule-expansion state into a useSourceControlSubmoduleStatus
hook and centralizes per-row stage/unstage/discard eligibility into
source-control-entry-actions, shrinking SourceControl.tsx and keeping the
read-only submodule rules consistent across the row UI, bulk actions, and tests.
The hook adds a generation guard so a slow submodule-status response from a
previous worktree (common over SSH) can't write stale status into the current
panel. On the relay side, configured submodule paths are read through a
short-TTL per-instance cache so a burst of diff clicks does not re-read
.gitmodules over the SSH link. Adds tests for the new modules.

* fix(source-control): address submodule/compare review feedback

- Degrade git.submoduleStatus to an actionable reconnect hint when an older
  SSH relay lacks the RPC, mirroring clone()/worktreeIsClean fallbacks.
- Keep the branch-compare summary while upstream status is still loading so
  it no longer flickers when switching worktrees with prefer-upstream on.
- Mark the compare-base switch as type="button" to avoid form submission.
- Add diff base / source control keywords to the Git settings search catalog.
- Assert the compare-base toggle's own switch state and updateSettings call.

* fix(source-control): address second-round submodule/compare review feedback

- Route submodule inner diffs through resolveSubmoduleWorktreePath so a
  crafted .gitmodules path can't escape the selected worktree
- Clear statusReadsInFlight alongside the diff dedupe on git mutations so a
  post-mutation getStatus() can't join a stale in-flight read
- Clear the SSH diff dedupe in getSubmoduleStatus to mirror getStatus
- Derive list-view selection from the submodule-injected rows so expanded
  submodule children are selectable
- Refresh commit history when the upstream compare base changes

* Support staged submodule expansion and refine default compare base

- Support expanding and diffing staged submodule changes (HEAD vs index) independently of unstaged changes (index vs worktree).
- Track submodule expansion states using a compound key of area and path to prevent conflicts between staged and unstaged listings.
- Update the compare-against-upstream setting to a segmented control for the "Default Compare Base" policy.
- Fall back to the repository default branch when comparing a branch with no upstream, preventing comparison views from unexpectedly disappearing.

* Fix submodule staging behavior, WSL caching, and double-click toggles

- Namespace submodule path cache per WSL distro to prevent cross-distro
  collisions.
- Preserve the staged area of child entries when expanding unstaged
  submodules so staged inner changes do not open empty diffs.
- Prefix oldPath with the submodule path for renamed inner entries.
- Ignore click events where detail > 1 to prevent double-clicks from
  instantly collapsing newly expanded submodules.

* Secure submodule path resolution and prevent stale status updates

* Extract and centralize submodule path validation into a new
  `resolveSubmoduleWorktreePath` helper to prevent path traversal
  exploits when resolving paths from untrusted `.gitmodules` files.
* Invalidate submodule expansion state and increment the query
  generation whenever the active runtime environment or connection
  route changes, preventing out-of-order responses from writing
  stale data.

* Set git identity via CLI config options in test commits

- Extract test email and name into constants.
- Use `-c` config flags to pass user identity to `git commit` dynamically.
- This ensures commits succeed in submodule checkouts or CI environments
  where a local or global identity is not configured.

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
2026-06-28 23:43:33 -07:00

178 lines
5.0 KiB
TypeScript

export function parseStatusChar(char: string): string {
switch (char) {
case 'M':
return 'modified'
case 'A':
return 'added'
case 'D':
return 'deleted'
case 'R':
return 'renamed'
case 'C':
return 'copied'
default:
return 'modified'
}
}
/**
* Parse `git status --porcelain=v2` output into structured entries.
* Does NOT handle unmerged entries (those require worktree access).
*/
export function parseStatusOutput(stdout: string): {
entries: Record<string, unknown>[]
unmergedLines: string[]
ignoredPaths: string[]
head?: string
branch?: string
upstreamStatus: {
hasUpstream: boolean
upstreamName?: string
ahead: number
behind: number
}
} {
const entries: Record<string, unknown>[] = []
const unmergedLines: string[] = []
const ignoredPaths: string[] = []
let head: string | undefined
let branch: string | undefined
let upstreamName: string | undefined
let upstreamAheadBehind: { ahead: number; behind: number } | null = null
for (const line of stdout.split(/\r?\n/)) {
if (!line) {
continue
}
if (line.startsWith('# branch.oid ')) {
head = line.slice('# branch.oid '.length).trim()
continue
}
if (line.startsWith('# branch.head ')) {
const branchHead = line.slice('# branch.head '.length).trim()
branch = branchHead && branchHead !== '(detached)' ? `refs/heads/${branchHead}` : ''
continue
}
if (line.startsWith('# branch.upstream ')) {
upstreamName = line.slice('# branch.upstream '.length).trim() || undefined
continue
}
if (line.startsWith('# branch.ab ')) {
upstreamAheadBehind = parseBranchAheadBehind(line)
continue
}
if (line.startsWith('1 ') || line.startsWith('2 ')) {
const parts = line.split(' ')
const xy = parts[1]
const indexStatus = xy[0]
const worktreeStatus = xy[1]
if (line.startsWith('2 ')) {
// Why: porcelain v2 type-2 format is `2 XY sub mH mI mW hH hI Xscore path\torigPath`.
// The new path starts after 9 fixed fields and can contain spaces; origPath follows the tab.
const tabParts = line.split('\t')
const filePath = tabParts[0].split(' ').slice(9).join(' ')
const oldPath = tabParts.slice(1).join('\t')
if (indexStatus !== '.') {
entries.push({
path: filePath,
status: parseStatusChar(indexStatus),
area: 'staged',
oldPath,
...submoduleStatusField(parts[2], indexStatus)
})
}
if (worktreeStatus !== '.') {
entries.push({
path: filePath,
status: parseStatusChar(worktreeStatus),
area: 'unstaged',
oldPath,
...submoduleStatusField(parts[2], worktreeStatus)
})
}
} else {
const filePath = parts.slice(8).join(' ')
if (indexStatus !== '.') {
entries.push({
path: filePath,
status: parseStatusChar(indexStatus),
area: 'staged',
...submoduleStatusField(parts[2], indexStatus)
})
}
if (worktreeStatus !== '.') {
entries.push({
path: filePath,
status: parseStatusChar(worktreeStatus),
area: 'unstaged',
...submoduleStatusField(parts[2], worktreeStatus)
})
}
}
} else if (line.startsWith('? ')) {
entries.push({ path: line.slice(2), status: 'untracked', area: 'untracked' })
} else if (line.startsWith('! ')) {
ignoredPaths.push(line.slice(2))
} else if (line.startsWith('u ')) {
unmergedLines.push(line)
}
}
return {
entries,
unmergedLines,
ignoredPaths,
head,
branch,
upstreamStatus: upstreamName
? {
hasUpstream: true,
upstreamName,
ahead: upstreamAheadBehind?.ahead ?? 0,
behind: upstreamAheadBehind?.behind ?? 0
}
: { hasUpstream: false, ahead: 0, behind: 0 }
}
}
function parseSubmoduleStatus(
submoduleField: string | undefined,
statusChar = '.'
): { commitChanged: boolean; trackedChanges: boolean; untrackedChanges: boolean } | undefined {
if (!submoduleField?.startsWith('S')) {
return undefined
}
return {
commitChanged: submoduleField[1] === 'C' || (submoduleField === 'S...' && statusChar === 'M'),
trackedChanges: submoduleField[2] === 'M',
untrackedChanges: submoduleField[3] === 'U'
}
}
function submoduleStatusField(
submoduleField: string | undefined,
statusChar: string
):
| { submodule: { commitChanged: boolean; trackedChanges: boolean; untrackedChanges: boolean } }
| {} {
const submodule = parseSubmoduleStatus(submoduleField, statusChar)
return submodule ? { submodule } : {}
}
function parseBranchAheadBehind(line: string): { ahead: number; behind: number } | null {
const match = line.match(/^# branch\.ab \+(\d+) -(\d+)$/)
if (!match) {
return null
}
return {
ahead: Number.parseInt(match[1], 10),
behind: Number.parseInt(match[2], 10)
}
}