/** * Submodule diff routing for the SSH relay. * * Why: the parent repo lists a submodule as a single gitlink row, and a gitlink * path can't be read as a blob (`git show HEAD:` is a "bad object"). These * helpers route a gitlink root to a synthesized pointer diff and resolve the * configured submodule paths so inner files recurse into the submodule worktree. * Split from git-handler-ops.ts to keep that file under the max-lines budget. */ import * as path from 'node:path' import { buildDiffResult } from './git-diff-result' import { parseBranchDiff } from './git-handler-utils' import { parseNumstat } from '../shared/git-uncommitted-line-stats' import { readBlobAtOid, type GitBufferExec, type GitExec } from './git-handler-ops' /** * Short TTL for the configured-submodule-paths cache, matching the local * handler (src/main/git/status.ts) so a burst of diff clicks on a worktree * doesn't re-read `.gitmodules` over the (possibly high-latency) SSH link. */ export const SUBMODULE_PATHS_CACHE_TTL_MS = 5_000 export const MAX_SUBMODULE_PATHS_CACHE_ENTRIES = 512 type SubmodulePathsCacheEntry = { paths: string[]; expiresAt: number } export type SubmodulePathsCache = { entries: Map generation: number } export function createSubmodulePathsCache(): SubmodulePathsCache { return { entries: new Map(), generation: 0 } } export function clearSubmodulePathsCache(cache: SubmodulePathsCache): void { cache.entries.clear() // Why: a pre-mutation SSH read must not restore stale .gitmodules paths // after the mutation invalidated them. cache.generation += 1 } export function getSubmodulePathsCacheCount(cache: SubmodulePathsCache): number { return cache.entries.size } function getCachedSubmodulePaths( cache: SubmodulePathsCache, worktreePath: string, now: number ): string[] | null { const cached = cache.entries.get(worktreePath) if (!cached) { return null } if (cached.expiresAt <= now) { cache.entries.delete(worktreePath) return null } cache.entries.delete(worktreePath) cache.entries.set(worktreePath, cached) return cached.paths } function pruneExpiredSubmodulePaths(cache: SubmodulePathsCache, now: number): void { for (const [worktreePath, entry] of cache.entries) { if (entry.expiresAt <= now) { cache.entries.delete(worktreePath) } } } function rememberSubmodulePaths( cache: SubmodulePathsCache, worktreePath: string, paths: string[], now: number ): void { cache.entries.delete(worktreePath) cache.entries.set(worktreePath, { paths, expiresAt: now + SUBMODULE_PATHS_CACHE_TTL_MS }) while (cache.entries.size > MAX_SUBMODULE_PATHS_CACHE_ENTRIES) { const oldestPath = cache.entries.keys().next().value if (oldestPath === undefined) { break } cache.entries.delete(oldestPath) } } /** * Cached variant of {@link listSubmodulePaths}. The cache is passed in (held by * the GitHandler instance) so it is naturally bound to the connection lifecycle * and never leaks across relay instances or tests. An empty result is cached * too, so a submodule-free repo doesn't re-read `.gitmodules` on every diff. */ export async function listSubmodulePathsCached( git: GitExec, worktreePath: string, cache: SubmodulePathsCache, now: number = Date.now() ): Promise { const cached = getCachedSubmodulePaths(cache, worktreePath, now) if (cached) { return cached } // Why: prune on misses so disconnected worktrees cannot accumulate while // repeated SSH diff clicks keep their O(1) cache-hit path. pruneExpiredSubmodulePaths(cache, now) const cacheGeneration = cache.generation const paths = await listSubmodulePaths(git, worktreePath) if (cacheGeneration === cache.generation) { rememberSubmodulePaths(cache, worktreePath, paths, now) } return paths } /** * Configured submodule paths (relative, forward-slash) read from `.gitmodules`. * Used to route gitlink/inner diffs without an index-wide `ls-files` scan. */ export async function listSubmodulePaths(git: GitExec, worktreePath: string): Promise { try { const { stdout } = await git( ['config', '--file', '.gitmodules', '--get-regexp', '^submodule\\..*\\.path$'], worktreePath ) return stdout .split(/\r?\n/) .map((line) => { const spaceIndex = line.indexOf(' ') return spaceIndex === -1 ? '' : line .slice(spaceIndex + 1) .trim() .replace(/\/+$/, '') }) .filter((value) => value.length > 0) } catch { return [] } } export function findContainingSubmodule(submodulePaths: string[], filePath: string): string | null { const normalized = filePath.replace(/\\/g, '/').replace(/\/+$/, '') let best: string | null = null for (const sub of submodulePaths) { if (normalized === sub || normalized.startsWith(`${sub}/`)) { if (!best || sub.length > best.length) { best = sub } } } return best } // Why: .gitmodules is repo-controlled; validate its paths before relay reads // or diffs inside a submodule worktree. export function resolveSubmoduleWorktreePath(worktreePath: string, submodulePath: string): string { if (!submodulePath || submodulePath.includes('\0') || path.isAbsolute(submodulePath)) { throw new Error('Access denied: invalid submodule path') } const resolved = path.resolve(worktreePath, submodulePath) const rel = path.relative(path.resolve(worktreePath), resolved) if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) { throw new Error('Access denied: submodule path resolves outside the worktree') } return resolved } async function readGitlinkOidFromTree( git: GitExec, worktreePath: string, ref: string, submodulePath: string ): Promise { try { const { stdout } = await git(['ls-tree', ref, '--', submodulePath], worktreePath) return stdout.match(/^160000 commit ([0-9a-f]+)\t/m)?.[1] ?? '' } catch { return '' } } async function readGitlinkOidFromIndex( git: GitExec, worktreePath: string, submodulePath: string ): Promise { try { const { stdout } = await git(['ls-files', '-s', '--', submodulePath], worktreePath) return stdout.match(/^160000 ([0-9a-f]+) /m)?.[1] ?? '' } catch { return '' } } async function readWorkingSubmoduleHead( git: GitExec, submoduleWorktreePath: string ): Promise { try { const { stdout } = await git(['rev-parse', 'HEAD'], submoduleWorktreePath) return stdout.trim() } catch { return '' } } /** * Resolve the submodule's recorded commit (parent index, falling back to HEAD) * and its checked-out worktree commit. When these differ the gitlink moved. */ export async function resolveSubmoduleCommitRange( git: GitExec, worktreePath: string, submodulePath: string, staged = false ): Promise<{ fromOid: string; toOid: string }> { const submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, submodulePath) const fromOid = staged ? await readGitlinkOidFromTree(git, worktreePath, 'HEAD', submodulePath) : (await readGitlinkOidFromIndex(git, worktreePath, submodulePath)) || (await readGitlinkOidFromTree(git, worktreePath, 'HEAD', submodulePath)) const toOid = staged ? await readGitlinkOidFromIndex(git, worktreePath, submodulePath) : await readWorkingSubmoduleHead(git, submoduleWorktreePath) return { fromOid, toOid } } /** * List files changed between two submodule commits as status rows (area * `unstaged`), so an expanded moved-pointer submodule shows its committed file * changes instead of an empty working-tree status. */ export async function computeSubmoduleRangeEntries( git: GitExec, submoduleWorktreePath: string, fromOid: string, toOid: string ): Promise[]> { let nameStatus = '' let numstat = '' try { const [statusResult, numstatResult] = await Promise.all([ git( ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', fromOid, toOid], submoduleWorktreePath ), git( ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', '-C', fromOid, toOid], submoduleWorktreePath ) ]) nameStatus = statusResult.stdout numstat = numstatResult.stdout } catch { return [] } return parseBranchDiff(nameStatus, parseNumstat(numstat)).map((entry) => ({ ...entry, area: 'unstaged' })) } /** * Diff a file inside a submodule across two of its commits (recorded vs * checked-out), mirroring the local handler's commit-range route. */ export async function buildSubmoduleInnerCommitRangeDiff( gitBuffer: GitBufferExec, submoduleWorktreePath: string, innerPath: string, fromOid: string, toOid: string ) { const left = await readBlobAtOid(gitBuffer, submoduleWorktreePath, fromOid, innerPath) const right = await readBlobAtOid(gitBuffer, submoduleWorktreePath, toOid, innerPath) return buildDiffResult(left.content, right.content, left.isBinary, right.isBinary, innerPath) } /** * Synthesize a gitlink pointer diff (one-line `Subproject commit ` swap), * matching git's own rendering of submodule commit changes. */ export async function computeSubmodulePointerDiff( git: GitExec, worktreePath: string, submodulePath: string, staged: boolean, compareAgainstHead = false ) { const submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, submodulePath) let leftOid = '' let rightOid = '' if (staged) { leftOid = await readGitlinkOidFromTree(git, worktreePath, 'HEAD', submodulePath) rightOid = await readGitlinkOidFromIndex(git, worktreePath, submodulePath) } else if (compareAgainstHead) { leftOid = await readGitlinkOidFromTree(git, worktreePath, 'HEAD', submodulePath) rightOid = await readWorkingSubmoduleHead(git, submoduleWorktreePath) } else { leftOid = (await readGitlinkOidFromIndex(git, worktreePath, submodulePath)) || (await readGitlinkOidFromTree(git, worktreePath, 'HEAD', submodulePath)) rightOid = await readWorkingSubmoduleHead(git, submoduleWorktreePath) } return buildDiffResult( leftOid ? `Subproject commit ${leftOid}\n` : '', rightOid ? `Subproject commit ${rightOid}\n` : '', false, false, submodulePath ) }