Skip to content

Commit 005b226

Browse files
IgorTavcarclaude
andcommitted
fix(config): load project config from worktree root in linked worktrees (anomalyco#15936)
Cherry-picked from upstream PR anomalyco#15936. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent c445fac commit 005b226

2 files changed

Lines changed: 110 additions & 0 deletions

File tree

packages/opencode/src/config/paths.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,35 @@ import { Flag } from "@/flag/flag"
88
import { Global } from "@/global"
99

1010
export namespace ConfigPaths {
11+
/**
12+
* Returns true if `worktree` is a filesystem ancestor of (or equal to) `directory`.
13+
* For linked worktrees, the two paths may be on completely different branches of the
14+
* directory tree (e.g., directory = ~/.local/share/opencode/worktree/…,
15+
* worktree = ~/code/myproject/), so this check is necessary before relying on
16+
* upward filesystem traversal to visit the worktree root.
17+
*/
18+
function isAncestorOrEqual(ancestor: string, descendant: string): boolean {
19+
const sep = path.sep
20+
return descendant === ancestor || descendant.startsWith(ancestor.endsWith(sep) ? ancestor : ancestor + sep)
21+
}
22+
1123
export async function projectFiles(name: string, directory: string, worktree: string) {
1224
const files: string[] = []
25+
26+
// When `directory` is a linked worktree on a different filesystem branch than `worktree`
27+
// (e.g., ~/.local/share/opencode/worktree/<hash>/<name>/ vs ~/code/myproject/), the
28+
// upward traversal from `directory` will never visit `worktree`. Explicitly check
29+
// the worktree root first so those files are loaded at the lowest-priority slot
30+
// (project-level base), allowing any config found in `directory`'s own tree to override.
31+
if (!isAncestorOrEqual(worktree, directory)) {
32+
for (const file of [`${name}.jsonc`, `${name}.json`]) {
33+
const worktreeFile = path.join(worktree, file)
34+
if (await Filesystem.exists(worktreeFile)) {
35+
files.push(worktreeFile)
36+
}
37+
}
38+
}
39+
1340
for (const file of [`${name}.jsonc`, `${name}.json`]) {
1441
const found = await Filesystem.findUp(file, directory, worktree)
1542
for (const resolved of found.toReversed()) {
@@ -20,8 +47,21 @@ export namespace ConfigPaths {
2047
}
2148

2249
export async function directories(directory: string, worktree: string) {
50+
// For linked worktrees, `directory` may be on a different filesystem branch than `worktree`.
51+
// Filesystem.up() walks upward from `directory` and will never visit `worktree/.opencode`
52+
// in that case. Collect it explicitly and insert it before the per-directory entries so
53+
// that the project-level .opencode config is loaded at the correct (lower) priority.
54+
const linkedWorktreeOcDir: string[] = []
55+
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG && !isAncestorOrEqual(worktree, directory)) {
56+
const worktreeOcDir = path.join(worktree, ".opencode")
57+
if (await Filesystem.exists(worktreeOcDir)) {
58+
linkedWorktreeOcDir.push(worktreeOcDir)
59+
}
60+
}
61+
2362
return [
2463
Global.Path.config,
64+
...linkedWorktreeOcDir,
2565
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
2666
? await Array.fromAsync(
2767
Filesystem.up({

packages/opencode/test/config/config.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import fs from "fs/promises"
88
import { pathToFileURL } from "url"
99
import { Global } from "../../src/global"
1010
import { Filesystem } from "../../src/util/filesystem"
11+
import { ConfigPaths } from "../../src/config/paths"
1112

1213
// Get managed config directory from environment (set in preload.ts)
1314
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
@@ -1949,3 +1950,72 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
19491950
}
19501951
})
19511952
})
1953+
1954+
describe("ConfigPaths linked worktree", () => {
1955+
test("projectFiles: finds config in worktree root when directory is on a different filesystem branch", async () => {
1956+
// Simulate a linked worktree scenario:
1957+
// worktreeRoot = ~/code/myproject/ (git common dir)
1958+
// linkedDir = ~/.local/share/opencode/worktree/<hash>/<name>/ (linked worktree)
1959+
// The two paths are on completely different branches of the directory tree.
1960+
await using worktreeRoot = await tmpdir({
1961+
init: async (dir) => {
1962+
// Place opencode.json directly in the worktree root
1963+
await fs.writeFile(path.join(dir, "opencode.json"), JSON.stringify({ model: "from-worktree-root" }))
1964+
},
1965+
})
1966+
await using linkedDir = await tmpdir()
1967+
1968+
// Sanity: linkedDir is NOT under worktreeRoot
1969+
expect(linkedDir.path.startsWith(worktreeRoot.path + path.sep)).toBe(false)
1970+
1971+
const files = await ConfigPaths.projectFiles("opencode", linkedDir.path, worktreeRoot.path)
1972+
1973+
// The worktree root's opencode.json should be included
1974+
expect(files).toContain(path.join(worktreeRoot.path, "opencode.json"))
1975+
})
1976+
1977+
test("directories: finds .opencode dir in worktree root when directory is on a different filesystem branch", async () => {
1978+
await using worktreeRoot = await tmpdir({
1979+
init: async (dir) => {
1980+
// Place .opencode/opencode.json in the worktree root
1981+
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
1982+
await fs.writeFile(
1983+
path.join(dir, ".opencode", "opencode.json"),
1984+
JSON.stringify({ model: "from-dotOpencode" }),
1985+
)
1986+
},
1987+
})
1988+
await using linkedDir = await tmpdir()
1989+
1990+
// Sanity: linkedDir is NOT under worktreeRoot
1991+
expect(linkedDir.path.startsWith(worktreeRoot.path + path.sep)).toBe(false)
1992+
1993+
const dirs = await ConfigPaths.directories(linkedDir.path, worktreeRoot.path)
1994+
1995+
// The worktree root's .opencode directory should be included
1996+
expect(dirs).toContain(path.join(worktreeRoot.path, ".opencode"))
1997+
})
1998+
1999+
test("projectFiles: normal (non-linked) project still works correctly", async () => {
2000+
// directory IS under worktree — existing behaviour should be unchanged
2001+
await using worktreeRoot = await tmpdir({
2002+
init: async (dir) => {
2003+
await fs.writeFile(path.join(dir, "opencode.json"), JSON.stringify({ model: "root-model" }))
2004+
},
2005+
})
2006+
// Create a subdirectory that IS under worktreeRoot
2007+
const subDir = path.join(worktreeRoot.path, "subproject")
2008+
await fs.mkdir(subDir, { recursive: true })
2009+
await fs.writeFile(path.join(subDir, "opencode.json"), JSON.stringify({ model: "sub-model" }))
2010+
2011+
const files = await ConfigPaths.projectFiles("opencode", subDir, worktreeRoot.path)
2012+
2013+
// Both root and subproject files should appear; subproject wins (applied last)
2014+
expect(files).toContain(path.join(worktreeRoot.path, "opencode.json"))
2015+
expect(files).toContain(path.join(subDir, "opencode.json"))
2016+
// subproject entry must come after root entry so it takes precedence when merged
2017+
expect(files.indexOf(path.join(subDir, "opencode.json"))).toBeGreaterThan(
2018+
files.indexOf(path.join(worktreeRoot.path, "opencode.json")),
2019+
)
2020+
})
2021+
})

0 commit comments

Comments
 (0)