Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/main/git/status-submodules.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import path from 'path'
import { execFile } from 'child_process'
import { promisify } from 'util'
import { afterEach, describe, expect, it } from 'vitest'
import { getStatus } from './status'

const execFileAsync = promisify(execFile)

async function git(cwd: string, args: string[]): Promise<void> {
await execFileAsync('git', args, { cwd })
}

describe('getStatus submodules integration', () => {
let root: string | null = null

afterEach(async () => {
if (root) {
await rm(root, { recursive: true, force: true })
root = null
}
})

it('reports submodule metadata from .gitmodules when porcelain status omits clean submodules', async () => {
root = await mkdtemp(path.join(tmpdir(), 'orca-status-submodules-'))
const libraryPath = path.join(root, 'library')
const appPath = path.join(root, 'app')

await git(root, ['init', '-q', libraryPath])
await git(libraryPath, ['config', 'user.email', 'test@example.com'])
await git(libraryPath, ['config', 'user.name', 'Test User'])
await writeFile(path.join(libraryPath, 'README.md'), 'library\n')
await git(libraryPath, ['add', 'README.md'])
await git(libraryPath, ['commit', '-qm', 'init library'])

await git(root, ['init', '-q', appPath])
await git(appPath, ['config', 'user.email', 'test@example.com'])
await git(appPath, ['config', 'user.name', 'Test User'])
await writeFile(path.join(appPath, 'README.md'), 'app\n')
await git(appPath, ['add', 'README.md'])
await git(appPath, ['commit', '-qm', 'init app'])
await git(appPath, [
'-c',
'protocol.file.allow=always',
'submodule',
'add',
'-q',
libraryPath,
'vendor/lib'
])
await git(appPath, ['commit', '-qm', 'add submodule'])

const status = await getStatus(appPath)

expect(status.entries).toEqual([])
expect(status.submodules).toEqual([
expect.objectContaining({
name: 'vendor/lib',
path: 'vendor/lib',
url: libraryPath
})
])
})
})
40 changes: 38 additions & 2 deletions src/main/git/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,41 @@ describe('getStatus', () => {
])
})

it('lists .gitmodules entries even when git status has no changed entries', async () => {
readFileMock.mockImplementation(async (filePath: string) => {
if (filePath.endsWith('.gitmodules')) {
return [
'[submodule "vendor/lib"]',
' path = vendor/lib',
' url = https://example.com/lib.git',
'[submodule "vendor/missing"]',
' path = vendor/missing'
].join('\n')
}
return 'gitdir: /repo/.git/worktrees/feature\n'
})
existsSyncMock.mockReturnValue(false)
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout:
'# branch.oid abc123\n# branch.head feature\n# branch.upstream origin/feature\n# branch.ab +0 -0\n'
})

const result = await getStatus('/repo')

expect(result.entries).toEqual([])
expect(result.submodules).toEqual([
{
name: 'vendor/lib',
path: 'vendor/lib',
url: 'https://example.com/lib.git'
},
{
name: 'vendor/missing',
path: 'vendor/missing'
}
])
})

it('omits ignored files by default and parses them when requested', async () => {
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
existsSyncMock.mockReturnValue(false)
Expand Down Expand Up @@ -859,6 +894,7 @@ describe('getStatus', () => {
await getStatus('/repo')

expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(gitExecFileAsyncMock.mock.calls.some(([args]) => args.includes('diff'))).toBe(false)
})

it('truncates and flags didHitLimit when entries exceed the limit', async () => {
Expand All @@ -875,9 +911,9 @@ describe('getStatus', () => {
expect(result.statusLength).toBeGreaterThan(10)
// First `limit` entries are kept; the rest are dropped.
expect(result.entries.length).toBe(10)
// attachLineStats (numstat) must be skipped when the limit was hit — only
// the single streamed status read should have happened.
// attachLineStats (numstat) must be skipped when the limit was hit.
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(gitExecFileAsyncMock.mock.calls.some(([args]) => args.includes('diff'))).toBe(false)
})

it('does not flag didHitLimit for a normal repo under the limit', async () => {
Expand Down
12 changes: 12 additions & 0 deletions src/main/git/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { getLargeDiffRenderLimit } from '../../shared/large-diff-render-limit'
import type { GitRuntimeOptions } from './git-runtime-options'
import { gitOptionsForWorktree } from './git-runtime-options'
import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output'
import { parseGitmodules } from '../../shared/gitmodules-parser'

const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024
const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES
Expand Down Expand Up @@ -189,6 +190,7 @@ export async function getStatus(

return {
entries,
submodules: await getSubmodules(worktreePath),
conflictOperation,
head,
branch,
Expand All @@ -211,6 +213,16 @@ export async function getStatus(
}
}

async function getSubmodules(worktreePath: string): Promise<GitStatusResult['submodules']> {
try {
// Why: submodule folders are repository metadata from .gitmodules; parent
// worktree dirtiness still comes from ordinary porcelain status rows.
return parseGitmodules(await readFile(path.join(worktreePath, '.gitmodules'), 'utf8'))
} catch {
return []
}
}

async function runNumstat(
worktreePath: string,
cached: boolean,
Expand Down
43 changes: 42 additions & 1 deletion src/main/ipc/filesystem-auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type * as NodePath from 'node:path'
import { mkdir, mkdtemp, realpath, rm, symlink } from 'node:fs/promises'
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
Expand Down Expand Up @@ -178,6 +178,47 @@ describe('filesystem-auth path containment', () => {
}
})

it('authorizes registered .gitmodules submodule roots for git operations', async () => {
invalidateAuthorizedRootsCache()
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-submodule-'))
try {
const repoPath = join(tempRoot, 'repo')
const submodulePath = join(repoPath, 'vendor', 'lib')
await mkdir(submodulePath, { recursive: true })
await writeFile(
join(repoPath, '.gitmodules'),
'[submodule "vendor/lib"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n'
)
await writeFile(join(submodulePath, '.git'), 'gitdir: ../../.git/modules/vendor/lib\n')
const store = makeStore([{ ...repo, id: 'repo-temp', path: repoPath }])

await expect(resolveRegisteredWorktreePath(submodulePath, store)).resolves.toBe(
await realpath(submodulePath)
)
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})

it('does not authorize arbitrary nested git roots that are not in .gitmodules', async () => {
invalidateAuthorizedRootsCache()
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-nested-git-'))
try {
const repoPath = join(tempRoot, 'repo')
const nestedPath = join(repoPath, 'vendor', 'other')
await mkdir(nestedPath, { recursive: true })
await writeFile(join(repoPath, '.gitmodules'), '')
await writeFile(join(nestedPath, '.git'), 'gitdir: ../../.git/modules/vendor/other\n')
const store = makeStore([{ ...repo, id: 'repo-temp', path: repoPath }])

await expect(resolveRegisteredWorktreePath(nestedPath, store)).rejects.toThrow(
'Access denied'
)
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})

it('authorizes local folder-backed project group roots outside child repo roots', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-project-group-'))
try {
Expand Down
56 changes: 55 additions & 1 deletion src/main/ipc/filesystem-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ discovery, canonicalization, and registered-worktree cache checks together so
the security boundary is auditable end to end. */
import { resolve, relative, dirname, basename, isAbsolute, sep } from 'path'
import { realpathSync } from 'fs'
import { realpath } from 'fs/promises'
import { readFile, realpath, stat } from 'fs/promises'
import type { Store } from '../persistence'
import { isRepoRoot, listRepoWorktrees } from '../repo-worktrees'
import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic'
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
import { parseGitmodules } from '../../shared/gitmodules-parser'
import { getProjectGroupSubtreeIds } from '../../shared/project-groups'
import type { FolderWorkspace, ProjectGroup, Repo } from '../../shared/types'

Expand Down Expand Up @@ -447,9 +448,62 @@ export async function resolveRegisteredWorktreePath(
return normalizedTarget
}

if (await isRegisteredSubmoduleGitRoot(normalizedTarget, store)) {
return normalizedTarget
}

throw new Error('Access denied: unknown repository or worktree path')
}

async function isRegisteredSubmoduleGitRoot(targetPath: string, store: Store): Promise<boolean> {
const parentRoot = await findRegisteredWorktreeRootIncludingCanonical(targetPath, store)
if (!parentRoot || parentRoot === targetPath) {
return false
}
if (!(await hasGitMetadata(targetPath))) {
return false
}
const relativeSubmodulePath = relative(parentRoot, targetPath).split(sep).join('/')
try {
const gitmodules = parseGitmodules(await readFile(resolve(parentRoot, '.gitmodules'), 'utf8'))
return gitmodules.some((entry) => entry.path === relativeSubmodulePath)
} catch {
return false
}
}

async function findRegisteredWorktreeRootIncludingCanonical(
targetPath: string,
store: Store
): Promise<string | null> {
const textualRoot = findRegisteredWorktreeRoot(targetPath)
if (textualRoot) {
return textualRoot
}
await ensureAuthorizedRootsCache(store)
const refreshedRoot = findRegisteredWorktreeRoot(targetPath)
if (refreshedRoot) {
return refreshedRoot
}
for (const root of registeredWorktreeRoots) {
const canonicalRoot = await normalizeExistingPath(root)
if (isDescendantOrEqual(targetPath, canonicalRoot)) {
registeredWorktreeRoots.add(canonicalRoot)
return canonicalRoot
}
}
return null
}

async function hasGitMetadata(targetPath: string): Promise<boolean> {
try {
const gitMetadata = await stat(resolve(targetPath, '.git'))
return gitMetadata.isFile() || gitMetadata.isDirectory()
} catch {
return false
}
}

function refreshRegisteredWorktreeRoots(): void {
registeredWorktreeRoots.clear()
for (const roots of registeredWorktreeRootsByRepo.values()) {
Expand Down
32 changes: 32 additions & 0 deletions src/relay/git-handler-status-ops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,36 @@ describe('getStatusOp', () => {
expect(result.didHitLimit).toBeUndefined()
expect(result.entries).toHaveLength(5)
})

it('returns .gitmodules submodules for SSH status results', async () => {
await fs.writeFile(
path.join(tmpDir, '.gitmodules'),
[
'[submodule "vendor/lib"]',
' path = vendor/lib',
' url = https://example.com/lib.git'
].join('\n')
)
const git = vi.fn<GitExec>(async (args) => {
if (args.includes('status')) {
return {
stdout:
'# branch.oid abc123\n# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n',
stderr: ''
}
}
throw new Error(`Unexpected git command: ${args.join(' ')}`)
})

const result = await getStatusOp(git, { worktreePath: tmpDir })

expect(result.entries).toEqual([])
expect(result.submodules).toEqual([
{
name: 'vendor/lib',
path: 'vendor/lib',
url: 'https://example.com/lib.git'
}
])
})
})
13 changes: 13 additions & 0 deletions src/relay/git-handler-status-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type GitLineStats
} from '../shared/git-uncommitted-line-stats'
import { DEFAULT_GIT_STATUS_LIMIT } from '../shared/git-status-limit'
import { parseGitmodules } from '../shared/gitmodules-parser'

export async function resolveGitDir(worktreePath: string): Promise<string> {
const dotGitPath = path.join(worktreePath, '.git')
Expand Down Expand Up @@ -68,6 +69,7 @@ export async function getStatusOp(
branch?: string
upstreamStatus?: GitUpstreamStatus
ignoredPaths?: string[]
submodules?: Record<string, unknown>[]
didHitLimit?: boolean
statusLength?: number
}> {
Expand Down Expand Up @@ -163,6 +165,7 @@ export async function getStatusOp(

return {
entries,
submodules: await getSubmodulesOp(worktreePath),
conflictOperation,
head,
branch,
Expand All @@ -172,6 +175,16 @@ export async function getStatusOp(
}
}

async function getSubmodulesOp(worktreePath: string): Promise<Record<string, unknown>[]> {
try {
// Why: the relay runs beside the repository, including over SSH, so reading
// .gitmodules here preserves remote worktrees without a local path probe.
return parseGitmodules(await readFile(path.join(worktreePath, '.gitmodules'), 'utf8'))
} catch {
return []
}
}

async function runNumstat(
git: GitExec,
worktreePath: string,
Expand Down
Loading