Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 128 additions & 56 deletions src/services/tags.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import { createHash } from "node:crypto";
import { execSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import { CONFIG } from "../config.js";
import { normalize, resolve, isAbsolute, basename, dirname, join } from "node:path";
import { realpathSync, existsSync } from "node:fs";
import {
normalize,
resolve,
isAbsolute,
basename,
dirname,
join,
delimiter,
relative,
} from "node:path";
import { accessSync, constants, realpathSync, existsSync } from "node:fs";

function sha256(input: string): string {
return createHash("sha256").update(input).digest("hex").slice(0, 16);
Expand All @@ -25,6 +34,110 @@ function sha256(input: string): string {
*/
const PROJECT_MARKER = ".opencode-mem-project";

function canonicalPath(path: string): string {
try {
return process.platform === "win32"
? normalize(realpathSync.native(path))
: normalize(realpathSync(path));
} catch {
return normalize(resolve(path));
}
}

function isPathInside(root: string, candidate: string): boolean {
const rel = relative(root, candidate);
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
}

function findUntrustedProjectRoot(directory: string): string {
let current = canonicalPath(directory);
while (true) {
if (existsSync(join(current, ".git")) || existsSync(join(current, PROJECT_MARKER))) {
return current;
}
const parent = dirname(current);
if (parent === current) return canonicalPath(directory);
current = parent;
}
}

interface GitCommand {
executable: string;
shell: false | string;
}

function resolveTrustedWindowsShell(untrustedRoot: string): string | null {
const candidates = [
process.env.ComSpec,
process.env.SystemRoot ? join(process.env.SystemRoot, "System32", "cmd.exe") : undefined,
];

for (const path of candidates) {
if (!path || !isAbsolute(path)) continue;
try {
accessSync(path, constants.X_OK);
const candidate = canonicalPath(path);
if (!isPathInside(untrustedRoot, candidate)) return candidate;
} catch {
// Try the next trusted system-shell location.
}
}
return null;
}

/**
* Resolve Git only from absolute PATH entries outside the repository being
* inspected. In particular, never let Windows resolve a repository-local
* git.exe/git.cmd/git.bat from the child process working directory.
*/
function resolveTrustedGitCommand(directory: string): GitCommand | null {
const untrustedRoot = findUntrustedProjectRoot(directory);
const executableNames =
process.platform === "win32" ? ["git.exe", "git.cmd", "git.bat"] : ["git"];

for (const rawEntry of (process.env.PATH ?? "").split(delimiter)) {
const entry = rawEntry.trim().replace(/^"(.*)"$/, "$1");
if (!entry || !isAbsolute(entry)) continue;

for (const executableName of executableNames) {
const candidatePath = join(entry, executableName);
try {
accessSync(candidatePath, constants.X_OK);
const executable = canonicalPath(candidatePath);
if (isPathInside(untrustedRoot, executable)) continue;

if (executableName === "git.exe" || process.platform !== "win32") {
return { executable, shell: false };
}

const shell = resolveTrustedWindowsShell(untrustedRoot);
if (shell) return { executable, shell };
} catch {
// Continue searching PATH. Missing Git uses the existing null fallbacks.
}
}
}

return null;
}

function runGit(args: string[], directory: string = process.cwd()): string | null {
const gitCommand = resolveTrustedGitCommand(directory);
if (!gitCommand) return null;

try {
const output = execFileSync(gitCommand.executable, args, {
encoding: "utf-8",
cwd: directory,
stdio: ["ignore", "pipe", "ignore"],
shell: gitCommand.shell,
}).trim();
return output || null;
} catch {
return null;
}
}

/**
* Walk up from `directory` (inclusive) to the filesystem root looking for the
* {@link PROJECT_MARKER}. Returns the first directory that contains it, or
Expand Down Expand Up @@ -55,54 +168,22 @@ export interface TagInfo {
gitRepoUrl?: string;
}

export function getGitEmail(): string | null {
try {
const email = execSync("git config user.email", {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return email || null;
} catch {
return null;
}
export function getGitEmail(directory: string = process.cwd()): string | null {
return runGit(["config", "user.email"], directory);
}

export function getGitName(): string | null {
try {
const name = execSync("git config user.name", {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return name || null;
} catch {
return null;
}
export function getGitName(directory: string = process.cwd()): string | null {
return runGit(["config", "user.name"], directory);
}

export function getGitRepoUrl(directory: string): string | null {
try {
const url = execSync("git config --get remote.origin.url", {
encoding: "utf-8",
cwd: directory,
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return url || null;
} catch {
return null;
}
return runGit(["config", "--get", "remote.origin.url"], directory);
}

export function getGitCommonDir(directory: string): string | null {
try {
const commonDir = execSync("git rev-parse --git-common-dir", {
encoding: "utf-8",
cwd: directory,
stdio: ["ignore", "pipe", "ignore"],
}).trim();

if (!commonDir) {
return null;
}
const commonDir = runGit(["rev-parse", "--git-common-dir"], directory);
if (!commonDir) return null;

const resolved = isAbsolute(commonDir)
? normalize(commonDir)
Expand All @@ -121,16 +202,7 @@ export function getGitCommonDir(directory: string): string | null {
}

export function getGitTopLevel(directory: string): string | null {
try {
const topLevel = execSync("git rev-parse --show-toplevel", {
encoding: "utf-8",
cwd: directory,
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return topLevel || null;
} catch {
return null;
}
return runGit(["rev-parse", "--show-toplevel"], directory);
}

// Git-only fallbacks, kept separate so the marker-aware entry points below
Expand Down Expand Up @@ -178,9 +250,9 @@ export function getProjectName(directory: string): string {
return parts[parts.length - 1] || directory;
}

export function getUserTagInfo(): TagInfo {
const email = CONFIG.userEmailOverride || getGitEmail();
const name = CONFIG.userNameOverride || getGitName();
export function getUserTagInfo(directory: string = process.cwd()): TagInfo {
const email = CONFIG.userEmailOverride || getGitEmail(directory);
const name = CONFIG.userNameOverride || getGitName(directory);

if (email) {
return {
Expand Down Expand Up @@ -226,7 +298,7 @@ export function getTags(directory: string): {
project: TagInfo;
} {
return {
user: getUserTagInfo(),
user: getUserTagInfo(directory),
project: getProjectTagInfo(directory),
};
}
92 changes: 89 additions & 3 deletions tests/project-scope.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
import { afterEach, describe, expect, it } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { basename, join } from "node:path";
import {
copyFileSync,
existsSync,
mkdtempSync,
realpathSync,
rmSync,
writeFileSync,
mkdirSync,
} from "node:fs";
import { basename, join, normalize } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import { findMarkerProjectRoot, getGitCommonDir, getProjectTagInfo } from "../src/services/tags.js";
import {
findMarkerProjectRoot,
getGitCommonDir,
getGitTopLevel,
getProjectTagInfo,
getTags,
} from "../src/services/tags.js";

const createdDirs: string[] = [];

Expand Down Expand Up @@ -41,6 +55,78 @@ afterEach(() => {
});

describe("project scope identity", () => {
it.skipIf(process.platform !== "win32")(
"ignores repository-local Git command shims on Windows",
() => {
for (const extension of ["bat", "cmd"]) {
const repoDir = mkdtempSync(join(tmpdir(), `opencode-mem-git-shim-${extension}-`));
createdDirs.push(repoDir);
run("git init", repoDir);

const sentinel = join(repoDir, "shim-executed.txt");
writeFileSync(
join(repoDir, `git.${extension}`),
`@echo off\r\n>"${sentinel}" echo executed\r\nexit /b 1\r\n`,
"utf-8"
);

expect(getGitCommonDir(repoDir)).not.toBeNull();
expect(existsSync(sentinel)).toBe(false);
}
}
);

it.skipIf(process.platform !== "win32")(
"rejects Git executables elsewhere inside a nested repository",
() => {
const repoDir = mkdtempSync(join(tmpdir(), "opencode-mem-git-exe-"));
createdDirs.push(repoDir);
run("git init", repoDir);
run("git config user.email nested@example.com", repoDir);
const nestedDir = join(repoDir, "src", "nested");
const fakeBin = join(repoDir, "tools");
mkdirSync(nestedDir, { recursive: true });
mkdirSync(fakeBin, { recursive: true });

copyFileSync(
join(process.env.SystemRoot!, "System32", "where.exe"),
join(fakeBin, "git.exe")
);
const oldPath = process.env.PATH;
try {
process.env.PATH = `${fakeBin};${oldPath ?? ""}`;
expect(getGitCommonDir(nestedDir)).toBe(getGitCommonDir(repoDir));
expect(getTags(repoDir).user.userEmail).toBe("nested@example.com");
} finally {
process.env.PATH = oldPath;
}
}
);

it.skipIf(process.platform !== "win32")(
"preserves trusted Git command wrappers outside the repository",
() => {
const repoDir = mkdtempSync(join(tmpdir(), "opencode-mem-git-wrapper-repo-"));
const wrapperDir = mkdtempSync(join(tmpdir(), "opencode-mem-git-wrapper-bin-"));
createdDirs.push(repoDir, wrapperDir);
run("git init", repoDir);
const realGit = execSync("where.exe git.exe", { encoding: "utf-8" })
.trim()
.split(/\r?\n/)[0]!;
writeFileSync(join(wrapperDir, "git.cmd"), `@"${realGit}" %*\r\n`, "utf-8");

const oldPath = process.env.PATH;
try {
process.env.PATH = wrapperDir;
expect(normalize(realpathSync.native(getGitTopLevel(repoDir)!))).toBe(
normalize(realpathSync.native(repoDir))
);
} finally {
process.env.PATH = oldPath;
}
}
);

it("uses one project tag across worktrees in the same repo", () => {
const { repoDir, worktreeDir } = createRepoWithWorktree();

Expand Down