From 9063d9ab5e58eb985f8313c6f8389947394af50c Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:15:45 -0400 Subject: [PATCH 1/2] feat: add worktreeOpencodeConfig for per-loop opencode config --- docs/configuration.md | 29 +++++ docs/loop-system.md | 1 + forge-config.jsonc | 9 ++ src/index.ts | 1 + src/types.ts | 9 ++ src/version.ts | 2 +- src/workspace/forge-adapter.ts | 43 ++++--- src/workspace/worktree-opencode-config.ts | 56 +++++++++ test/workspace/forge-adapter.test.ts | 85 +++++++++++++- .../worktree-opencode-config.test.ts | 106 ++++++++++++++++++ 10 files changed, 325 insertions(+), 16 deletions(-) create mode 100644 src/workspace/worktree-opencode-config.ts create mode 100644 test/workspace/worktree-opencode-config.test.ts diff --git a/docs/configuration.md b/docs/configuration.md index 6f632f5fe..f085ac067 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -50,6 +50,7 @@ Default log path: `~/.local/share/opencode/forge/logs/forge.log` or `$XDG_DATA_H | `loop.stallTimeoutMs` | `60000` | Stall watchdog timeout in milliseconds. | | `loop.maxConsecutiveStalls` | `5` | Consecutive stalls before terminating with `stall_timeout`. `0` disables stall termination. | | `loop.allowExternalDirectories` | unset | Absolute host directories that loop, audit, and post-action sessions may read despite worktree isolation. | +| `loop.worktreeOpencodeConfig` | unset | Inline [opencode config](https://opencode.ai/config.json) written as `opencode.jsonc` into each freshly created loop worktree. Enables per-loop customization (MCP servers, model overrides, etc.). Skip-if-exists — never overwrites a committed `opencode.json`/`opencode.jsonc`. The written file is git-excluded to keep it out of loop commits. | ### Worktree Logging @@ -86,6 +87,34 @@ Example: } ``` +### Worktree Opencode Config + +`loop.worktreeOpencodeConfig` writes an inline opencode config file (`opencode.jsonc`) at the root of each freshly created loop worktree. This enables per-loop customization — primarily MCP servers — without modifying the host config or polluting loop commits. + +The config is written only when: +- The worktree has no existing `opencode.json` or `opencode.jsonc` (committed configs are never overwritten) +- The value is a non-empty object + +The written file is added to the worktree's git exclude so it never appears in `git status` or loop commits. + +Example — expose Chrome DevTools MCP inside every loop: + +```jsonc +{ + "loop": { + "worktreeOpencodeConfig": { + "mcp": { + "chrome-devtools": { + "type": "local", + "command": ["npx", "chrome-devtools-mcp@latest", "--isolated"], + "enabled": true + } + } + } + } +} +``` + ## Group Launch `groupLaunch` configures parallel feature orchestration (see the [`launch-group`](tools.md#group-tools) tool). diff --git a/docs/loop-system.md b/docs/loop-system.md index 904f1b458..ca6146ab7 100644 --- a/docs/loop-system.md +++ b/docs/loop-system.md @@ -187,6 +187,7 @@ Benefits of worktree isolation: - Isolation from ongoing development - Safe to experiment without affecting main branch - Branch preserved for later review/merge +- Per-loop customization via `loop.worktreeOpencodeConfig` — inject MCP servers and other [opencode config](https://opencode.ai/config.json) into each worktree without host config changes or commit pollution (see [Configuration Reference](configuration.md#worktree-opencode-config)) ## Sandbox Integration diff --git a/forge-config.jsonc b/forge-config.jsonc index 862fbffee..0cb94a15e 100644 --- a/forge-config.jsonc +++ b/forge-config.jsonc @@ -63,6 +63,15 @@ // identical container path so absolute temp paths match host<->container. Created on startup // if missing. Defaults to "/tmp/oc-forge". // "tmpDir": "/tmp/oc-forge" + // + // Inline opencode config written as opencode.jsonc into each loop worktree (skip-if-exists; + // never overwrites a committed opencode.json/opencode.jsonc). Primarily for enabling per-loop + // MCP servers. The written file is git-excluded so it never enters loop commits. + // "worktreeOpencodeConfig": { + // "mcp": { + // "my-server": { "type": "local", "command": ["npx", "some-mcp-server"], "enabled": true } + // } + // } }, // Max loops from one group running concurrently. Also bounds concurrent planning passes. Default 3. diff --git a/src/index.ts b/src/index.ts index d2114d612..5225d2c86 100644 --- a/src/index.ts +++ b/src/index.ts @@ -300,6 +300,7 @@ export function createForgePlugin(config: PluginConfig): Plugin { sandboxManager, gitService: defaultGitService, getTeardownContext: (loopName) => pendingTeardowns.get(loopName), + worktreeOpencodeConfig: config.loop?.worktreeOpencodeConfig, })) logger.log(`Registered forge workspace adapter (worktrees under ${join(dataDir, 'worktrees')})`) } diff --git a/src/types.ts b/src/types.ts index af447908e..699c5f17b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -74,6 +74,15 @@ export interface LoopConfig { * Defaults to `/tmp/oc-forge`. The directory is created on startup if missing. */ tmpDir?: string + /** + * Inline opencode config object written verbatim as `opencode.jsonc` at the root of each + * freshly created loop worktree, enabling per-loop opencode customization (primarily MCP + * servers, e.g. `{ "mcp": { ... } }`). Written only when the worktree has no existing + * `opencode.json`/`opencode.jsonc` (committed configs are never overwritten). The written file + * is added to the worktree's git exclude so it never enters loop commits. An empty object or + * omission disables the behavior. + */ + worktreeOpencodeConfig?: Record } /** diff --git a/src/version.ts b/src/version.ts index 78d91e693..9fa2621ae 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.5.0-beta.9' +export const VERSION = '0.5.0' diff --git a/src/workspace/forge-adapter.ts b/src/workspace/forge-adapter.ts index 93ec7673d..037f500d3 100644 --- a/src/workspace/forge-adapter.ts +++ b/src/workspace/forge-adapter.ts @@ -7,6 +7,7 @@ import type { SandboxManager } from '../sandbox/manager' import { forgeBranchName, forgeWorktreeDir, forgeWorktreeSlug } from './forge-naming' import { cleanupLoopWorktree } from '../utils/worktree-cleanup' import { defaultGitService, type GitService } from '../utils/git-service' +import { writeWorktreeOpencodeConfig, WORKTREE_OPENCODE_CONFIG_FILENAME } from './worktree-opencode-config' /** @@ -38,6 +39,8 @@ export interface ForgeAdapterDeps { getTeardownContext?: TeardownContextProvider /** Inject a custom GitService (defaults to real git if omitted). */ gitService?: GitService + /** Inline opencode config written as opencode.jsonc into each worktree (skip-if-exists). */ + worktreeOpencodeConfig?: Record } const DEFAULT_TEARDOWN_CONTEXT: TeardownContext = { @@ -48,7 +51,7 @@ const DEFAULT_TEARDOWN_CONTEXT: TeardownContext = { } export function createForgeWorkspaceAdapter(deps: ForgeAdapterDeps): WorkspaceAdapter { - const { dataDir, logger, sandboxManager, getTeardownContext, gitService: gitServiceOpt } = deps + const { dataDir, logger, sandboxManager, getTeardownContext, gitService: gitServiceOpt, worktreeOpencodeConfig } = deps const git = gitServiceOpt ?? defaultGitService function deriveLoopName(info: WorkspaceInfo): string { @@ -65,6 +68,21 @@ export function createForgeWorkspaceAdapter(deps: ForgeAdapterDeps): WorkspaceAd return dir } + function addToGitExclude(directory: string, pattern: string): void { + const excludeRes = git.revParseGitPath(directory, 'info/exclude') + if (!excludeRes.ok || !excludeRes.stdout) return + const excludeFile = excludeRes.stdout.trim() + try { + const content = existsSync(excludeFile) ? readFileSync(excludeFile, 'utf-8') : '' + if (!content.split('\n').some((l) => l.trim() === pattern)) { + appendFileSync(excludeFile, `\n${pattern}\n`) + logger.log(`forge-adapter: added ${pattern} to git exclude in ${directory}`) + } + } catch (err) { + logger.log(`forge-adapter: could not update git exclude: ${err instanceof Error ? err.message : String(err)}`) + } + } + function resolveLoopName(info: WorkspaceInfo): string { try { return deriveLoopName(info) @@ -196,19 +214,16 @@ export function createForgeWorkspaceAdapter(deps: ForgeAdapterDeps): WorkspaceAd // Idempotently add .forge/ to git exclude so overflow/scratch files // never enter loop commits. if (info.directory) { - const excludeRes = git.revParseGitPath(info.directory, 'info/exclude') - if (excludeRes.ok && excludeRes.stdout) { - const excludeFile = excludeRes.stdout.trim() - try { - const content = existsSync(excludeFile) ? readFileSync(excludeFile, 'utf-8') : '' - if (!content.split('\n').some((l) => l.trim() === '.forge/')) { - appendFileSync(excludeFile, '\n.forge/\n') - logger.log(`forge-adapter: added .forge/ to git exclude in ${info.directory}`) - } - } catch (err) { - logger.log(`forge-adapter: could not update git exclude: ${err instanceof Error ? err.message : String(err)}`) - } - } + addToGitExclude(info.directory, '.forge/') + } + + const cfgResult = writeWorktreeOpencodeConfig({ + directory: info.directory, + config: worktreeOpencodeConfig, + logger, + }) + if (cfgResult.written) { + addToGitExclude(info.directory, WORKTREE_OPENCODE_CONFIG_FILENAME) } if (sandboxManager) { diff --git a/src/workspace/worktree-opencode-config.ts b/src/workspace/worktree-opencode-config.ts new file mode 100644 index 000000000..e3843b71a --- /dev/null +++ b/src/workspace/worktree-opencode-config.ts @@ -0,0 +1,56 @@ +import { join } from 'path' +import { existsSync, writeFileSync } from 'fs' +import type { Logger } from '../types' + +/** Filename forge writes the inline opencode config to inside a worktree. */ +export const WORKTREE_OPENCODE_CONFIG_FILENAME = 'opencode.jsonc' + +/** Filenames opencode discovers as a project config; any present means "already configured". */ +const OPENCODE_CONFIG_FILENAMES = ['opencode.jsonc', 'opencode.json'] as const + +export type WriteWorktreeOpencodeConfigReason = 'no-config' | 'exists' | 'written' | 'error' + +export interface WriteWorktreeOpencodeConfigInput { + /** Absolute worktree root directory. */ + directory: string + /** Inline config object from `loop.worktreeOpencodeConfig` (may be undefined/empty). */ + config: Record | undefined + logger: Logger +} + +export interface WriteWorktreeOpencodeConfigResult { + written: boolean + reason: WriteWorktreeOpencodeConfigReason + /** Path written when `written` is true; undefined otherwise. */ + path?: string +} + +/** + * Write the inline opencode config into a worktree as `opencode.jsonc`. + * Skip-if-exists (never overwrites a committed opencode config) and non-fatal: + * any failure is logged and reported via `reason: 'error'`, never thrown. + */ +export function writeWorktreeOpencodeConfig( + input: WriteWorktreeOpencodeConfigInput, +): WriteWorktreeOpencodeConfigResult { + const { directory, config, logger } = input + if (!config || typeof config !== 'object' || Object.keys(config).length === 0) { + return { written: false, reason: 'no-config' } + } + const existing = OPENCODE_CONFIG_FILENAMES.find((name) => existsSync(join(directory, name))) + if (existing) { + logger.log(`worktree-opencode-config: ${existing} already present in ${directory}; skipping`) + return { written: false, reason: 'exists' } + } + const target = join(directory, WORKTREE_OPENCODE_CONFIG_FILENAME) + try { + writeFileSync(target, JSON.stringify(config, null, 2) + '\n', 'utf-8') + logger.log(`worktree-opencode-config: wrote ${WORKTREE_OPENCODE_CONFIG_FILENAME} in ${directory}`) + return { written: true, reason: 'written', path: target } + } catch (err) { + logger.log( + `worktree-opencode-config: failed to write ${WORKTREE_OPENCODE_CONFIG_FILENAME}: ${err instanceof Error ? err.message : String(err)}`, + ) + return { written: false, reason: 'error' } + } +} diff --git a/test/workspace/forge-adapter.test.ts b/test/workspace/forge-adapter.test.ts index 33ce3cd11..e41b873c4 100644 --- a/test/workspace/forge-adapter.test.ts +++ b/test/workspace/forge-adapter.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { createForgeWorkspaceAdapter, type ForgeAdapterDeps } from '../../src/workspace/forge-adapter' import { join, isAbsolute } from 'path' -import { mkdtempSync, existsSync, rmSync, readFileSync } from 'fs' +import { mkdtempSync, existsSync, rmSync, readFileSync, writeFileSync } from 'fs' import { execSync } from 'child_process' import { tmpdir } from 'os' @@ -436,4 +436,87 @@ describe('createForgeWorkspaceAdapter', () => { const target = adapter.target(configured) expect(target).toEqual({ type: 'local', directory: configured.directory }) }) + + it('create writes opencode.jsonc and adds it to git exclude', async () => { + const tmpRepo = mkdtempSync(join(tmpdir(), 'forge-adapter-opencode-')) + try { + execSync('git init && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) + const config = { mcp: { demo: { type: 'local', command: ['x'], enabled: true } } } + const adapter = createForgeWorkspaceAdapter({ + dataDir: tmpDataDir, + logger, + worktreeOpencodeConfig: config, + }) + const configured = adapter.configure(makeInfo('opencode-loop', tmpRepo)) + + await adapter.create(configured, {}) + + const configPath = join(configured.directory, 'opencode.jsonc') + expect(existsSync(configPath)).toBe(true) + expect(JSON.parse(readFileSync(configPath, 'utf-8'))).toEqual(config) + + let excludePath = execSync(`git -C "${configured.directory}" rev-parse --git-path info/exclude`, { + encoding: 'utf-8', + }).trim() + if (!isAbsolute(excludePath)) { + excludePath = join(configured.directory, excludePath) + } + const excludeContent = readFileSync(excludePath, 'utf-8') + expect(excludeContent).toContain('opencode.jsonc') + } finally { + if (existsSync(tmpRepo)) rmSync(tmpRepo, { recursive: true, force: true }) + } + }) + + it('create does not overwrite a committed opencode.jsonc', async () => { + const tmpRepo = mkdtempSync(join(tmpdir(), 'forge-adapter-committed-')) + try { + execSync('git init && git config user.email t@t && git config user.name t && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) + const sentinel = { sentinel: true } + writeFileSync(join(tmpRepo, 'opencode.jsonc'), JSON.stringify(sentinel) + '\n') + execSync('git add opencode.jsonc && git commit -m "add opencode config"', { cwd: tmpRepo, encoding: 'utf-8' }) + + const adapter = createForgeWorkspaceAdapter({ + dataDir: tmpDataDir, + logger, + worktreeOpencodeConfig: { mcp: { other: {} } }, + }) + const configured = adapter.configure(makeInfo('committed-opencode-loop', tmpRepo)) + + await adapter.create(configured, {}) + + const configPath = join(configured.directory, 'opencode.jsonc') + expect(JSON.parse(readFileSync(configPath, 'utf-8'))).toEqual(sentinel) + + // Also verify the committed config was not added to exclude. + let excludePath = execSync(`git -C "${configured.directory}" rev-parse --git-path info/exclude`, { + encoding: 'utf-8', + }).trim() + if (!isAbsolute(excludePath)) { + excludePath = join(configured.directory, excludePath) + } + const excludeContent = readFileSync(excludePath, 'utf-8') + expect(excludeContent).not.toContain('opencode.jsonc') + } finally { + if (existsSync(tmpRepo)) rmSync(tmpRepo, { recursive: true, force: true }) + } + }) + + it('create does not write opencode.jsonc when no config provided', async () => { + const tmpRepo = mkdtempSync(join(tmpdir(), 'forge-adapter-no-opencode-')) + try { + execSync('git init && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) + const adapter = createForgeWorkspaceAdapter({ + dataDir: tmpDataDir, + logger, + }) + const configured = adapter.configure(makeInfo('no-opencode-loop', tmpRepo)) + + await adapter.create(configured, {}) + + expect(existsSync(join(configured.directory, 'opencode.jsonc'))).toBe(false) + } finally { + if (existsSync(tmpRepo)) rmSync(tmpRepo, { recursive: true, force: true }) + } + }) }) diff --git a/test/workspace/worktree-opencode-config.test.ts b/test/workspace/worktree-opencode-config.test.ts new file mode 100644 index 000000000..4d74d63f6 --- /dev/null +++ b/test/workspace/worktree-opencode-config.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { join } from 'path' +import { mkdtempSync, existsSync, rmSync, readFileSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { + writeWorktreeOpencodeConfig, + WORKTREE_OPENCODE_CONFIG_FILENAME, + type WriteWorktreeOpencodeConfigInput, + type WriteWorktreeOpencodeConfigResult, +} from '../../src/workspace/worktree-opencode-config' + +function createMockLogger() { + return { + log: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } +} + +describe('writeWorktreeOpencodeConfig', () => { + let logger: ReturnType + let tmpDir: string + + beforeEach(() => { + logger = createMockLogger() + tmpDir = mkdtempSync(join(tmpdir(), 'worktree-opencode-config-test-')) + }) + + afterEach(() => { + if (existsSync(tmpDir)) { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + function makeInput( + overrides?: Partial, + ): WriteWorktreeOpencodeConfigInput { + return { + directory: tmpDir, + config: undefined, + logger, + ...overrides, + } + } + + it('returns no-config when config is undefined', () => { + const result = writeWorktreeOpencodeConfig(makeInput({ config: undefined })) + expect(result).toEqual({ + written: false, + reason: 'no-config', + }) + expect(existsSync(join(tmpDir, WORKTREE_OPENCODE_CONFIG_FILENAME))).toBe(false) + }) + + it('returns no-config when config is an empty object', () => { + const result = writeWorktreeOpencodeConfig(makeInput({ config: {} })) + expect(result).toEqual({ + written: false, + reason: 'no-config', + }) + expect(existsSync(join(tmpDir, WORKTREE_OPENCODE_CONFIG_FILENAME))).toBe(false) + }) + + it('writes opencode.jsonc in an empty directory', () => { + const config = { + mcp: { + foo: { type: 'local' as const, command: ['x'], enabled: true }, + }, + } + const result = writeWorktreeOpencodeConfig(makeInput({ config })) + expect(result).toEqual({ + written: true, + reason: 'written', + path: join(tmpDir, WORKTREE_OPENCODE_CONFIG_FILENAME), + }) + expect(existsSync(result.path!)).toBe(true) + const written = JSON.parse(readFileSync(result.path!, 'utf-8')) + expect(written).toEqual(config) + }) + + it('skips when opencode.jsonc already exists', () => { + const sentinel = { existing: true } + writeFileSync(join(tmpDir, 'opencode.jsonc'), JSON.stringify(sentinel), 'utf-8') + const config = { mcp: { foo: { type: 'local' as const, command: ['x'], enabled: true } } } + const result = writeWorktreeOpencodeConfig(makeInput({ config })) + expect(result).toEqual({ + written: false, + reason: 'exists', + }) + // Original content stays untouched + expect(readFileSync(join(tmpDir, 'opencode.jsonc'), 'utf-8')).toBe(JSON.stringify(sentinel)) + }) + + it('skips when opencode.json already exists', () => { + const sentinel = { existing: true } + writeFileSync(join(tmpDir, 'opencode.json'), JSON.stringify(sentinel), 'utf-8') + const config = { mcp: { foo: { type: 'local' as const, command: ['x'], enabled: true } } } + const result = writeWorktreeOpencodeConfig(makeInput({ config })) + expect(result).toEqual({ + written: false, + reason: 'exists', + }) + // opencode.jsonc should not be created + expect(existsSync(join(tmpDir, WORKTREE_OPENCODE_CONFIG_FILENAME))).toBe(false) + }) +}) From 445190b0ce976d2f52483acdd4a640611eefc227 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:00:27 -0400 Subject: [PATCH 2/2] refactor: protect forge-written opencode.jsonc from loop commits --- docs/configuration.md | 4 ++ src/utils/git-service.ts | 7 +++ src/workspace/forge-adapter.ts | 46 +++++++++++---- src/workspace/worktree-opencode-config.ts | 2 +- test/helpers/fake-git.ts | 1 + test/utils/git-service.test.ts | 17 ++++++ test/workspace/forge-adapter.test.ts | 72 +++++++++++++++++++++++ 7 files changed, 136 insertions(+), 13 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index f085ac067..b5dd2ee1a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -97,6 +97,10 @@ The config is written only when: The written file is added to the worktree's git exclude so it never appears in `git status` or loop commits. +Notes: +- The written file is ephemeral. Forge deletes its own `opencode.jsonc` before any teardown commit (and the whole worktree is removed on completion), so it can never land in loop history — even if the git-exclude write failed. A repository-tracked `opencode.jsonc` is never deleted (forge did not write it). Because the file is removed at teardown, a restarted loop is rewritten from the current `loop.worktreeOpencodeConfig`, so edits take effect on the next run. +- MCP servers declared here run as **host** processes from the worktree directory. When [Sandbox](sandbox.md) is enabled, only `bash`/`glob`/`grep` execute inside the container; the MCP commands themselves are not container-isolated. + Example — expose Chrome DevTools MCP inside every loop: ```jsonc diff --git a/src/utils/git-service.ts b/src/utils/git-service.ts index 93837c3fc..ca0e2e1a8 100644 --- a/src/utils/git-service.ts +++ b/src/utils/git-service.ts @@ -9,6 +9,8 @@ export interface GitResult { export interface GitService { addAll(cwd: string): GitResult + /** True when `path` (file or directory) is tracked by git in `cwd`. */ + isPathTracked(cwd: string, path: string): boolean statusPorcelain(cwd: string): GitResult commit(cwd: string, message: string): GitResult isInsideWorkTree(cwd: string): boolean @@ -34,6 +36,11 @@ export function createGitService(): GitService { return runGit(['add', '-A'], cwd) }, + isPathTracked(cwd: string, path: string): boolean { + const r = runGit(['ls-files', '--', path], cwd) + return r.ok && r.stdout.trim().length > 0 + }, + statusPorcelain(cwd: string): GitResult { return runGit(['status', '--porcelain'], cwd) }, diff --git a/src/workspace/forge-adapter.ts b/src/workspace/forge-adapter.ts index 037f500d3..aa692927e 100644 --- a/src/workspace/forge-adapter.ts +++ b/src/workspace/forge-adapter.ts @@ -1,6 +1,6 @@ import { join } from 'path' import { mkdir } from 'fs/promises' -import { existsSync, readFileSync, appendFileSync } from 'fs' +import { existsSync, readFileSync, appendFileSync, rmSync } from 'fs' import type { WorkspaceAdapter, WorkspaceInfo } from '@opencode-ai/plugin' import type { Logger } from '../types' import type { SandboxManager } from '../sandbox/manager' @@ -68,15 +68,18 @@ export function createForgeWorkspaceAdapter(deps: ForgeAdapterDeps): WorkspaceAd return dir } - function addToGitExclude(directory: string, pattern: string): void { + function addToGitExclude(directory: string, patterns: string[]): void { + if (patterns.length === 0) return const excludeRes = git.revParseGitPath(directory, 'info/exclude') if (!excludeRes.ok || !excludeRes.stdout) return const excludeFile = excludeRes.stdout.trim() try { const content = existsSync(excludeFile) ? readFileSync(excludeFile, 'utf-8') : '' - if (!content.split('\n').some((l) => l.trim() === pattern)) { - appendFileSync(excludeFile, `\n${pattern}\n`) - logger.log(`forge-adapter: added ${pattern} to git exclude in ${directory}`) + const present = new Set(content.split('\n').map((l) => l.trim())) + const missing = patterns.filter((p) => !present.has(p)) + if (missing.length > 0) { + appendFileSync(excludeFile, `\n${missing.join('\n')}\n`) + logger.log(`forge-adapter: added ${missing.join(', ')} to git exclude in ${directory}`) } } catch (err) { logger.log(`forge-adapter: could not update git exclude: ${err instanceof Error ? err.message : String(err)}`) @@ -91,9 +94,30 @@ export function createForgeWorkspaceAdapter(deps: ForgeAdapterDeps): WorkspaceAd } } + /** + * Remove the forge-written `opencode.jsonc` before a teardown commit so the + * inline per-loop config never enters loop history. Only an untracked file is + * removed: when the repository already tracks an `opencode.jsonc`, forge never + * wrote it (skip-if-exists), so it is left untouched and its edits still commit. + * The worktree itself is torn down at teardown, so the removed file is not lost. + */ + function removeForgeWrittenOpencodeConfig(directory: string): void { + const configPath = join(directory, WORKTREE_OPENCODE_CONFIG_FILENAME) + if (!existsSync(configPath)) return + if (git.isPathTracked(directory, WORKTREE_OPENCODE_CONFIG_FILENAME)) return + try { + rmSync(configPath, { force: true }) + logger.log(`forge-adapter: removed forge-written ${WORKTREE_OPENCODE_CONFIG_FILENAME} before commit in ${directory}`) + } catch (err) { + logger.log(`forge-adapter: could not remove ${WORKTREE_OPENCODE_CONFIG_FILENAME}: ${err instanceof Error ? err.message : String(err)}`) + } + } + async function stepCommitChanges(loopName: string, directory: string, branchLabel: string, ctx: TeardownContext): Promise { if (!ctx.doCommit || !existsSync(directory)) return + removeForgeWrittenOpencodeConfig(directory) + try { const addResult = git.addAll(directory) if (!addResult.ok) { @@ -211,20 +235,18 @@ export function createForgeWorkspaceAdapter(deps: ForgeAdapterDeps): WorkspaceAd } logger.log(`forge-adapter: created worktree ${info.directory} on branch ${info.branch}${branchExists ? ' (reused existing branch)' : ''}${reusedOrphan ? ' (after orphan cleanup)' : ''}`) - // Idempotently add .forge/ to git exclude so overflow/scratch files - // never enter loop commits. - if (info.directory) { - addToGitExclude(info.directory, '.forge/') - } - + // Idempotently add .forge/ (overflow/scratch) and any forge-written + // opencode config to git exclude so they never enter loop commits. + const excludePatterns = ['.forge/'] const cfgResult = writeWorktreeOpencodeConfig({ directory: info.directory, config: worktreeOpencodeConfig, logger, }) if (cfgResult.written) { - addToGitExclude(info.directory, WORKTREE_OPENCODE_CONFIG_FILENAME) + excludePatterns.push(WORKTREE_OPENCODE_CONFIG_FILENAME) } + addToGitExclude(info.directory, excludePatterns) if (sandboxManager) { try { diff --git a/src/workspace/worktree-opencode-config.ts b/src/workspace/worktree-opencode-config.ts index e3843b71a..8ce213826 100644 --- a/src/workspace/worktree-opencode-config.ts +++ b/src/workspace/worktree-opencode-config.ts @@ -8,7 +8,7 @@ export const WORKTREE_OPENCODE_CONFIG_FILENAME = 'opencode.jsonc' /** Filenames opencode discovers as a project config; any present means "already configured". */ const OPENCODE_CONFIG_FILENAMES = ['opencode.jsonc', 'opencode.json'] as const -export type WriteWorktreeOpencodeConfigReason = 'no-config' | 'exists' | 'written' | 'error' +type WriteWorktreeOpencodeConfigReason = 'no-config' | 'exists' | 'written' | 'error' export interface WriteWorktreeOpencodeConfigInput { /** Absolute worktree root directory. */ diff --git a/test/helpers/fake-git.ts b/test/helpers/fake-git.ts index 066122d45..f3027efb6 100644 --- a/test/helpers/fake-git.ts +++ b/test/helpers/fake-git.ts @@ -6,6 +6,7 @@ const defaultOk: GitResult = { ok: true, status: 0, stdout: '', stderr: '' } export function createFakeGitService(overrides?: Partial): GitService { return { addAll: vi.fn<[string], GitResult>(() => ({ ...defaultOk })), + isPathTracked: vi.fn<[string, string], boolean>(() => false), statusPorcelain: vi.fn<[string], GitResult>(() => ({ ...defaultOk })), commit: vi.fn<[string, string], GitResult>(() => ({ ...defaultOk })), isInsideWorkTree: vi.fn<[string], boolean>(() => true), diff --git a/test/utils/git-service.test.ts b/test/utils/git-service.test.ts index b11d9346b..eb928d3fd 100644 --- a/test/utils/git-service.test.ts +++ b/test/utils/git-service.test.ts @@ -119,6 +119,23 @@ describe('GitService', () => { }) }) + describe('isPathTracked', () => { + it('returns true for a committed (tracked) file', () => { + writeFileSync(join(repo, 'tracked.txt'), 'x', 'utf-8') + execSync('git add tracked.txt && git commit -m add', { cwd: repo, encoding: 'utf-8' }) + expect(git.isPathTracked(repo, 'tracked.txt')).toBe(true) + }) + + it('returns false for an untracked file', () => { + writeFileSync(join(repo, 'untracked.txt'), 'x', 'utf-8') + expect(git.isPathTracked(repo, 'untracked.txt')).toBe(false) + }) + + it('returns false for a missing path', () => { + expect(git.isPathTracked(repo, 'nope.txt')).toBe(false) + }) + }) + describe('worktreePrune', () => { it('returns ok', () => { const result = git.worktreePrune(repo) diff --git a/test/workspace/forge-adapter.test.ts b/test/workspace/forge-adapter.test.ts index e41b873c4..f636f6aa3 100644 --- a/test/workspace/forge-adapter.test.ts +++ b/test/workspace/forge-adapter.test.ts @@ -519,4 +519,76 @@ describe('createForgeWorkspaceAdapter', () => { if (existsSync(tmpRepo)) rmSync(tmpRepo, { recursive: true, force: true }) } }) + + it('remove deletes the forge-written opencode.jsonc and keeps it out of the teardown commit', async () => { + const tmpRepo = mkdtempSync(join(tmpdir(), 'forge-adapter-opencode-teardown-')) + try { + execSync('git init && git config user.email t@t && git config user.name t && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) + const adapter = createForgeWorkspaceAdapter({ + dataDir: tmpDataDir, + logger, + worktreeOpencodeConfig: { mcp: { demo: { type: 'local', command: ['x'], enabled: true } } }, + getTeardownContext: () => ({ iteration: 1, reasonLabel: 'completed', doCommit: true }), + }) + const configured = adapter.configure(makeInfo('opencode-teardown-loop', tmpRepo)) + await adapter.create(configured, {}) + execSync('git config user.email t@t && git config user.name t', { cwd: configured.directory, encoding: 'utf-8' }) + execSync('git branch -m forge/opencode-teardown-loop custom/work', { cwd: configured.directory, encoding: 'utf-8' }) + configured.branch = 'custom/work' + + const configPath = join(configured.directory, 'opencode.jsonc') + expect(existsSync(configPath)).toBe(true) + + // Simulate the git-exclude having failed, so opencode.jsonc would otherwise + // be staged by `git add -A`. The teardown deletion must still keep it out. + let excludePath = execSync(`git -C "${configured.directory}" rev-parse --git-path info/exclude`, { encoding: 'utf-8' }).trim() + if (!isAbsolute(excludePath)) excludePath = join(configured.directory, excludePath) + writeFileSync(excludePath, '', 'utf-8') + + // A real pending change so the teardown commit has content to record. + writeFileSync(join(configured.directory, 'pending.txt'), 'hello', 'utf-8') + + await adapter.remove(configured) + + expect(existsSync(configPath)).toBe(false) + const tree = execSync('git ls-tree -r --name-only custom/work', { cwd: tmpRepo, encoding: 'utf-8' }) + expect(tree).toContain('pending.txt') + expect(tree).not.toContain('opencode.jsonc') + } finally { + if (existsSync(tmpRepo)) rmSync(tmpRepo, { recursive: true, force: true }) + } + }) + + it('remove preserves a repo-tracked opencode.jsonc and commits its edits', async () => { + const tmpRepo = mkdtempSync(join(tmpdir(), 'forge-adapter-opencode-tracked-')) + try { + execSync('git init && git config user.email t@t && git config user.name t && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) + writeFileSync(join(tmpRepo, 'opencode.jsonc'), JSON.stringify({ committed: true }) + '\n') + execSync('git add opencode.jsonc && git commit -m "add opencode config"', { cwd: tmpRepo, encoding: 'utf-8' }) + + const adapter = createForgeWorkspaceAdapter({ + dataDir: tmpDataDir, + logger, + worktreeOpencodeConfig: { mcp: { other: {} } }, + getTeardownContext: () => ({ iteration: 1, reasonLabel: 'completed', doCommit: true }), + }) + const configured = adapter.configure(makeInfo('opencode-tracked-loop', tmpRepo)) + await adapter.create(configured, {}) + execSync('git config user.email t@t && git config user.name t', { cwd: configured.directory, encoding: 'utf-8' }) + execSync('git branch -m forge/opencode-tracked-loop custom/tracked', { cwd: configured.directory, encoding: 'utf-8' }) + configured.branch = 'custom/tracked' + + // Loop edits the tracked config; the edit must survive and be committed. + const configPath = join(configured.directory, 'opencode.jsonc') + writeFileSync(configPath, JSON.stringify({ committed: true, edited: true }) + '\n') + + await adapter.remove(configured) + + expect(existsSync(configPath)).toBe(true) + const show = execSync('git show custom/tracked:opencode.jsonc', { cwd: tmpRepo, encoding: 'utf-8' }) + expect(JSON.parse(show)).toEqual({ committed: true, edited: true }) + } finally { + if (existsSync(tmpRepo)) rmSync(tmpRepo, { recursive: true, force: true }) + } + }) })