diff --git a/src/agents/system-prompt-params.ts b/src/agents/system-prompt-params.ts index e35709009c..8aca020420 100644 --- a/src/agents/system-prompt-params.ts +++ b/src/agents/system-prompt-params.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; +import { findGitRoot } from "../infra/git-root.js"; import { formatUserTime, resolveUserTimeFormat, @@ -92,24 +93,3 @@ function resolveRepoRoot(params: { } return undefined; } - -function findGitRoot(startDir: string): string | null { - let current = path.resolve(startDir); - for (let i = 0; i < 12; i += 1) { - const gitPath = path.join(current, ".git"); - try { - const stat = fs.statSync(gitPath); - if (stat.isDirectory() || stat.isFile()) { - return current; - } - } catch { - // ignore missing .git at this level - } - const parent = path.dirname(current); - if (parent === current) { - break; - } - current = parent; - } - return null; -} diff --git a/src/infra/git-commit.ts b/src/infra/git-commit.ts index e2b1d07f9d..44778ce5a0 100644 --- a/src/infra/git-commit.ts +++ b/src/infra/git-commit.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; +import { resolveGitHeadPath } from "./git-root.js"; const formatCommit = (value?: string | null) => { if (!value) { @@ -13,35 +14,6 @@ const formatCommit = (value?: string | null) => { return trimmed.length > 7 ? trimmed.slice(0, 7) : trimmed; }; -const resolveGitHead = (startDir: string) => { - let current = startDir; - for (let i = 0; i < 12; i += 1) { - const gitPath = path.join(current, ".git"); - try { - const stat = fs.statSync(gitPath); - if (stat.isDirectory()) { - return path.join(gitPath, "HEAD"); - } - if (stat.isFile()) { - const raw = fs.readFileSync(gitPath, "utf-8"); - const match = raw.match(/gitdir:\s*(.+)/i); - if (match?.[1]) { - const resolved = path.resolve(current, match[1].trim()); - return path.join(resolved, "HEAD"); - } - } - } catch { - // ignore missing .git at this level - } - const parent = path.dirname(current); - if (parent === current) { - break; - } - current = parent; - } - return null; -}; - let cachedCommit: string | null | undefined; const readCommitFromPackageJson = () => { @@ -102,7 +74,7 @@ export const resolveCommitHash = (options: { cwd?: string; env?: NodeJS.ProcessE return cachedCommit; } try { - const headPath = resolveGitHead(options.cwd ?? process.cwd()); + const headPath = resolveGitHeadPath(options.cwd ?? process.cwd()); if (!headPath) { cachedCommit = null; return cachedCommit; diff --git a/src/infra/git-root.test.ts b/src/infra/git-root.test.ts new file mode 100644 index 0000000000..ed313ac9f0 --- /dev/null +++ b/src/infra/git-root.test.ts @@ -0,0 +1,59 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { findGitRoot, resolveGitHeadPath } from "./git-root.js"; + +async function makeTempDir(label: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), `openclaw-${label}-`)); +} + +describe("git-root", () => { + it("finds git root and HEAD path when .git is a directory", async () => { + const temp = await makeTempDir("git-root-dir"); + const repoRoot = path.join(temp, "repo"); + const workspace = path.join(repoRoot, "nested", "workspace"); + await fs.mkdir(path.join(repoRoot, ".git"), { recursive: true }); + await fs.mkdir(workspace, { recursive: true }); + + expect(findGitRoot(workspace)).toBe(repoRoot); + expect(resolveGitHeadPath(workspace)).toBe(path.join(repoRoot, ".git", "HEAD")); + }); + + it("resolves HEAD path when .git is a gitdir pointer file", async () => { + const temp = await makeTempDir("git-root-file"); + const repoRoot = path.join(temp, "repo"); + const workspace = path.join(repoRoot, "nested", "workspace"); + const gitDir = path.join(repoRoot, ".actual-git"); + await fs.mkdir(workspace, { recursive: true }); + await fs.mkdir(gitDir, { recursive: true }); + await fs.writeFile(path.join(repoRoot, ".git"), "gitdir: .actual-git\n", "utf-8"); + + expect(findGitRoot(workspace)).toBe(repoRoot); + expect(resolveGitHeadPath(workspace)).toBe(path.join(gitDir, "HEAD")); + }); + + it("keeps root detection for .git file and skips invalid gitdir content for HEAD lookup", async () => { + const temp = await makeTempDir("git-root-invalid-file"); + const parentRoot = path.join(temp, "repo"); + const childRoot = path.join(parentRoot, "child"); + const nested = path.join(childRoot, "nested"); + await fs.mkdir(path.join(parentRoot, ".git"), { recursive: true }); + await fs.mkdir(nested, { recursive: true }); + await fs.writeFile(path.join(childRoot, ".git"), "not-a-gitdir-pointer\n", "utf-8"); + + expect(findGitRoot(nested)).toBe(childRoot); + expect(resolveGitHeadPath(nested)).toBe(path.join(parentRoot, ".git", "HEAD")); + }); + + it("respects maxDepth traversal limit", async () => { + const temp = await makeTempDir("git-root-depth"); + const repoRoot = path.join(temp, "repo"); + const nested = path.join(repoRoot, "a", "b", "c"); + await fs.mkdir(path.join(repoRoot, ".git"), { recursive: true }); + await fs.mkdir(nested, { recursive: true }); + + expect(findGitRoot(nested, { maxDepth: 2 })).toBeNull(); + expect(resolveGitHeadPath(nested, { maxDepth: 2 })).toBeNull(); + }); +}); diff --git a/src/infra/git-root.ts b/src/infra/git-root.ts new file mode 100644 index 0000000000..9966947079 --- /dev/null +++ b/src/infra/git-root.ts @@ -0,0 +1,72 @@ +import fs from "node:fs"; +import path from "node:path"; + +export const DEFAULT_GIT_DISCOVERY_MAX_DEPTH = 12; + +function walkUpFrom( + startDir: string, + opts: { maxDepth?: number }, + resolveAtDir: (dir: string) => T | null | undefined, +): T | null { + let current = path.resolve(startDir); + const maxDepth = opts.maxDepth ?? DEFAULT_GIT_DISCOVERY_MAX_DEPTH; + for (let i = 0; i < maxDepth; i += 1) { + const resolved = resolveAtDir(current); + if (resolved !== null && resolved !== undefined) { + return resolved; + } + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + return null; +} + +function hasGitMarker(repoRoot: string): boolean { + const gitPath = path.join(repoRoot, ".git"); + try { + const stat = fs.statSync(gitPath); + return stat.isDirectory() || stat.isFile(); + } catch { + return false; + } +} + +export function findGitRoot(startDir: string, opts: { maxDepth?: number } = {}): string | null { + // A `.git` file counts as a repo marker even if it is not a valid gitdir pointer. + return walkUpFrom(startDir, opts, (repoRoot) => (hasGitMarker(repoRoot) ? repoRoot : null)); +} + +function resolveGitDirFromMarker(repoRoot: string): string | null { + const gitPath = path.join(repoRoot, ".git"); + try { + const stat = fs.statSync(gitPath); + if (stat.isDirectory()) { + return gitPath; + } + if (!stat.isFile()) { + return null; + } + const raw = fs.readFileSync(gitPath, "utf-8"); + const match = raw.match(/gitdir:\s*(.+)/i); + if (!match?.[1]) { + return null; + } + return path.resolve(repoRoot, match[1].trim()); + } catch { + return null; + } +} + +export function resolveGitHeadPath( + startDir: string, + opts: { maxDepth?: number } = {}, +): string | null { + // Stricter than findGitRoot: keep walking until a resolvable git dir is found. + return walkUpFrom(startDir, opts, (repoRoot) => { + const gitDir = resolveGitDirFromMarker(repoRoot); + return gitDir ? path.join(gitDir, "HEAD") : null; + }); +}