/** * Conservative cwd Git classification (saga j8dtju.5 — Workspace domain, phase 1). * * Classifies a cwd into one of three buckets WITHOUT persisting, binding, or creating * anything — classification is a read-only probe used by callers (the future catalog, the * daemon's legacy-session reconciliation in `.7`) to decide what to do next, never an action. * * Buckets: * - `non-git` — cwd is not inside any Git repository. * - `main-checkout` — cwd is in a repository AND this worktree is the main checkout (the * `.git` directory lives at the repo common-dir; `gitDir === commonDir`). * - `linked-worktree` — cwd is in a repository and this worktree is a LINKED worktree (the * per-worktree git dir lives under `/worktrees/` and * differs from `commonDir`). * * The classifier delegates identity resolution to {@link GitRepositoryResolver} so there is one * code path producing canonical identity. It does NOT consult `git worktree list` for * classification (that is a richer query the catalog uses for enumeration); the common-dir vs * git-dir relationship is sufficient and cheaper. */ import type { ResolvedWorkspaceIdentity } from '@/core/git/resolver'; import { GitRepositoryResolver, type ResolveIdentityOutcome } from '@/core/git/resolver'; import type { GitRunner } from '@/core/git/runner'; /** The discriminant for a classified cwd. */ export type CwdClassificationKind = 'non-git' | 'main-checkout' | 'linked-worktree'; /** A read-only classification result. Carries identity only when Git-backed. */ export type CwdClassification = | { kind: 'non-git'; message: string } | { kind: 'main-checkout'; identity: ResolvedWorkspaceIdentity } | { kind: 'linked-worktree'; identity: ResolvedWorkspaceIdentity }; /** * Classify `cwd` against Git. Returns the bucket + identity (when Git-backed). Never mutates * Git or persists anything. A `probe-failed` resolution (e.g. a transient git timeout) is * surfaced as `non-git` conservatively — this is classification, not a retry surface; the * caller that needs to distinguish a transient failure should call the resolver directly. */ export async function classifyCwd( cwd: string, resolver: GitRepositoryResolver, ): Promise { const outcome: ResolveIdentityOutcome = await resolver.resolveIdentity(cwd); if (outcome.kind !== 'ok') { return { kind: 'non-git', message: outcome.message }; } return classifyResolved(outcome.identity); } /** Classify an already-resolved identity (no extra Git calls). Pure. */ export function classifyResolved(identity: ResolvedWorkspaceIdentity): CwdClassification { // A main checkout's per-worktree git dir IS the common dir. A linked worktree's git dir is a // subdirectory of commonDir (commonDir/worktrees/), so the two paths differ. Both are // realpath-canonicalized by the resolver, so a string compare is safe across spellings. if (identity.worktree.gitDir === identity.repository.commonDir) { return { kind: 'main-checkout', identity }; } return { kind: 'linked-worktree', identity }; } /** * The canonical main-checkout root of a repository, derived from its canonical common-dir. * * For a normal (non-bare) checkout the common-dir is the repository's `.git` directory * (`/.git`) — shared by the main checkout and EVERY linked worktree — so the * project root is the path with the trailing `/.git` stripped. For a bare repository the * common-dir IS the repository root and is returned as-is. A common-dir that does not end * with `/.git` (e.g. a submodule's `.git/modules/...` gitdir) falls back to the common-dir * itself: the derivation is a best-effort display projection, never an authority. * * The result is a DISPLAY path for grouping/labeling (e.g. naming a worktree's original * project in the web dashboard or CLI) — it is NOT the common-dir itself and must not be * treated as a git-dir. Pure (string arithmetic only). */ export function repositoryRootFromCommonDir(commonDir: string): string { const trimmed = commonDir.replace(/\/+$/, ''); const suffix = '/.git'; if (trimmed.length > suffix.length && trimmed.endsWith(suffix)) { return trimmed.slice(0, trimmed.length - suffix.length); } return trimmed; } /** * Build a classifier bound to a specific runner. Convenience for callers that already hold a * runner; the classifier is stateless and safe to share. */ export function createClassifier(runner: GitRunner): { classify(cwd: string): Promise; resolver: GitRepositoryResolver; } { const resolver = new GitRepositoryResolver(runner); return { resolver, async classify(cwd: string): Promise { return classifyCwd(cwd, resolver); }, }; }