diff --git a/src/core/worktree-service.js b/src/core/worktree-service.js index 1c38a5e..9a1e07b 100644 --- a/src/core/worktree-service.js +++ b/src/core/worktree-service.js @@ -383,11 +383,19 @@ export function createWorktreeWorkflowService({ directory, git, stateStore, logg return (await git(["rev-parse", "--show-toplevel"], { cwd: directory })).stdout; } catch (error) { if (isMissingGitRepositoryError(error.message || "")) { - throw new Error("This command must run inside a git repository. Initialize a repository first or run it from an existing repo root."); + throw new Error("This command must run inside a git repository. Initialize a repository first or run it from an existing repo root.", { cause: error }); } throw error; } } + async function tryGetRepoRoot() { + try { + return await getRepoRoot(); + } catch (error) { + if (isMissingGitRepositoryError(error.message || "") || isMissingGitRepositoryError(error.cause?.message || "")) return null; + throw error; + } + } async function loadWorkflowConfig(repoRoot) { const [projectConfig, projectConfigC, sidecarConfig] = await Promise.all([ readJsonFile(path.join(repoRoot, "opencode.json")), @@ -668,6 +676,7 @@ export function createWorktreeWorkflowService({ directory, git, stateStore, logg prepare, cleanup, getRepoRoot, + tryGetRepoRoot, getSessionBinding, ensureActiveWorktree, recordToolUsage, diff --git a/src/index.js b/src/index.js index 132d8d1..7c4bfef 100644 --- a/src/index.js +++ b/src/index.js @@ -185,10 +185,11 @@ export const WorktreeWorkflowPlugin = async ({ $, directory, logger: providedLog const sessionID = input?.sessionID; let binding = null; let repoRootForLogging = null; + const repoRoot = await service.tryGetRepoRoot(); + if (!repoRoot) return; if (classification.requiresIsolation) { if (!sessionID) throw new Error(`Isolation required for ${toolName || "tool"} but sessionID is missing.`); - const repoRoot = await service.getRepoRoot(); repoRootForLogging = repoRoot; for (const key of rewritePolicy.opaqueArgKeys) { if (hasOpaqueRepoRootAbsoluteReference({ value: args[key], repoRoot })) { @@ -202,7 +203,6 @@ export const WorktreeWorkflowPlugin = async ({ $, directory, logger: providedLog workspaceRole: deriveWorkspaceRole({ subagentType: args.subagent_type }), }); } else if (sessionID) { - const repoRoot = await service.getRepoRoot(); repoRootForLogging = repoRoot; const { activeTask } = await service.getSessionBinding({ repoRoot, sessionID }); if (activeTask?.worktree_path) binding = { repoRoot, task: activeTask }; @@ -290,15 +290,15 @@ export const WorktreeWorkflowPlugin = async ({ $, directory, logger: providedLog const toolName = input?.tool; const args = input?.args || {}; const sessionID = input?.sessionID; + const repoRoot = await service.tryGetRepoRoot(); + if (!repoRoot) return; if (toolName === "worktree_prepare" && sessionID) { const result = output?.metadata?.result; if (result?.branch && result?.worktree_path) { - const repoRoot = await service.getRepoRoot(); await service.updateStateForPrepare(repoRoot, sessionID, result, "manual"); } } if (toolName === "task" && sessionID) { - const repoRoot = await service.getRepoRoot(); const handoffPath = resolveSafeHandoffPath({ rawPath: extractHandoffArtifactPath(args.prompt), repoRoot, @@ -354,7 +354,8 @@ export const WorktreeWorkflowPlugin = async ({ $, directory, logger: providedLog const existingSystem = Array.isArray(output?.system) ? output.system : []; if (!sessionID || existingSystem.some((entry) => entry.includes(WORKSPACE_SYSTEM_CONTEXT_MARKER))) return; - const repoRoot = await service.getRepoRoot(); + const repoRoot = await service.tryGetRepoRoot(); + if (!repoRoot) return; const { activeTask } = await service.getSessionBinding({ repoRoot, sessionID }); if (!activeTask?.worktree_path) return; diff --git a/test/hook-enforcement.test.js b/test/hook-enforcement.test.js index 12ff7c2..3199518 100644 --- a/test/hook-enforcement.test.js +++ b/test/hook-enforcement.test.js @@ -6,6 +6,22 @@ import path from "node:path"; import { createRuntimeStateStore } from "../src/runtime/state-store.js"; import { createPlugin, createRemoteRepo, executeToolWithMetadata, git, runCommandExecuteBeforeHook, runExperimentalChatSystemTransformHook, runToolExecuteAfterHook, runToolExecuteBeforeHook } from "../test-support/helpers.js"; +test("hooks are no-op outside git repository", async () => { + const fixture = await fs.mkdtemp(path.join(process.env.TMPDIR || "/tmp", "worktree-plugin-no-repo-")); + try { + const plugin = await createPlugin(fixture); + const output = { args: { filePath: "tracked.txt" } }; + await plugin["tool.execute.before"]({ tool: "write", sessionID: "outside-repo" }, output); + assert.equal(output.args.filePath, "tracked.txt"); + await plugin["tool.execute.after"]({ tool: "write", sessionID: "outside-repo" }, {}); + const system = { system: ["Base system prompt"] }; + await plugin["experimental.chat.system.transform"]({ sessionID: "outside-repo" }, system); + assert.deepEqual(system.system, ["Base system prompt"]); + } finally { + await fs.rm(fixture, { recursive: true, force: true }); + } +}); + test("tool.execute.before provisions worktree and rewrites mutating filePath", async () => { const fixture = await createRemoteRepo(); const logs = [];