mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
* 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>
178 lines
5.0 KiB
TypeScript
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)
|
|
}
|
|
}
|