Infra: unify git root discovery

This commit is contained in:
Gustavo Madeira Santana
2026-02-18 00:45:38 -05:00
parent 639d0221ff
commit 7ea7b7e7af
4 changed files with 134 additions and 51 deletions

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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<string> {
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();
});
});

72
src/infra/git-root.ts Normal file
View File

@@ -0,0 +1,72 @@
import fs from "node:fs";
import path from "node:path";
export const DEFAULT_GIT_DISCOVERY_MAX_DEPTH = 12;
function walkUpFrom<T>(
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;
});
}