From 754d9dc0e8bfda011b51d01b0378556a0bc3a848 Mon Sep 17 00:00:00 2001 From: weishu Date: Tue, 30 Dec 2025 16:44:18 +0800 Subject: [PATCH] feat: auto-detect git worktree context from git commands When HAPI_WORKTREE_* environment variables are not set, fall back to probing git to detect if running inside a worktree. This enables worktree features to work automatically without explicit env vars. --- cli/src/utils/worktreeEnv.ts | 138 +++++++++++++++++++++++++++++------ 1 file changed, 116 insertions(+), 22 deletions(-) diff --git a/cli/src/utils/worktreeEnv.ts b/cli/src/utils/worktreeEnv.ts index e674513c..5f098df4 100644 --- a/cli/src/utils/worktreeEnv.ts +++ b/cli/src/utils/worktreeEnv.ts @@ -1,26 +1,120 @@ +import { execFileSync } from 'node:child_process'; +import { realpathSync, statSync } from 'node:fs'; +import { basename, dirname, isAbsolute, resolve } from 'node:path'; + import type { WorktreeInfo } from '@/daemon/worktree'; +import { logger } from '@/ui/logger'; export function readWorktreeEnv(): WorktreeInfo | null { - const basePath = process.env.HAPI_WORKTREE_BASE_PATH?.trim(); - const branch = process.env.HAPI_WORKTREE_BRANCH?.trim(); - const name = process.env.HAPI_WORKTREE_NAME?.trim(); - const worktreePath = process.env.HAPI_WORKTREE_PATH?.trim(); - const createdAtRaw = process.env.HAPI_WORKTREE_CREATED_AT?.trim(); - - if (!basePath || !branch || !name || !worktreePath || !createdAtRaw) { - return null; - } - - const createdAt = Number(createdAtRaw); - if (!Number.isFinite(createdAt)) { - return null; - } - - return { - basePath, - branch, - name, - worktreePath, - createdAt - }; + return readWorktreeFromEnv() ?? readWorktreeFromGit(); +} + +function readWorktreeFromEnv(): WorktreeInfo | null { + const basePath = process.env.HAPI_WORKTREE_BASE_PATH?.trim(); + const branch = process.env.HAPI_WORKTREE_BRANCH?.trim(); + const name = process.env.HAPI_WORKTREE_NAME?.trim(); + const worktreePath = process.env.HAPI_WORKTREE_PATH?.trim(); + const createdAtRaw = process.env.HAPI_WORKTREE_CREATED_AT?.trim(); + + if (!basePath || !branch || !name || !worktreePath || !createdAtRaw) { + return null; + } + + const createdAt = Number(createdAtRaw); + if (!Number.isFinite(createdAt)) { + return null; + } + + return { + basePath, + branch, + name, + worktreePath, + createdAt + }; +} + +function readWorktreeFromGit(): WorktreeInfo | null { + const start = Date.now(); + let result: WorktreeInfo | null = null; + + try { + const cwd = process.cwd(); + const isInside = runGit(['rev-parse', '--is-inside-work-tree'], cwd); + if (isInside !== 'true') { + return null; + } + + const gitDir = runGit(['rev-parse', '--git-dir'], cwd); + const gitCommonDir = runGit(['rev-parse', '--git-common-dir'], cwd); + if (!gitDir || !gitCommonDir) { + return null; + } + + const resolvedGitDir = normalizePath(gitDir, cwd); + const resolvedGitCommonDir = normalizePath(gitCommonDir, cwd); + if (resolvedGitDir === resolvedGitCommonDir) { + return null; + } + + const worktreeRoot = runGit(['rev-parse', '--show-toplevel'], cwd); + if (!worktreeRoot) { + return null; + } + const worktreePath = normalizePath(worktreeRoot, cwd); + const basePath = dirname(resolvedGitCommonDir); + + const branch = runGit(['symbolic-ref', '--short', 'HEAD'], cwd) + ?? runGit(['rev-parse', '--short', 'HEAD'], cwd); + if (!branch) { + return null; + } + + result = { + basePath, + branch, + name: basename(worktreePath), + worktreePath, + createdAt: readCreatedAt(worktreePath) + }; + return result; + } finally { + const elapsedMs = Date.now() - start; + logger.debug(`[WORKTREE] Git probe ${result ? 'hit' : 'miss'} in ${elapsedMs}ms`); + } +} + +function runGit(args: string[], cwd: string): string | null { + try { + const output = execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); + return output.length > 0 ? output : null; + } catch { + return null; + } +} + +function normalizePath(rawPath: string, cwd: string): string { + const resolved = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath); + try { + return realpathSync(resolved); + } catch { + return resolved; + } +} + +function readCreatedAt(worktreePath: string): number { + try { + const stat = statSync(worktreePath); + const birthtimeMs = Math.round(stat.birthtimeMs); + if (Number.isFinite(birthtimeMs) && birthtimeMs > 0) { + return birthtimeMs; + } + const ctimeMs = Math.round(stat.ctimeMs); + if (Number.isFinite(ctimeMs) && ctimeMs > 0) { + return ctimeMs; + } + } catch { + return Date.now(); + } + return Date.now(); }