Skip to content
Open
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
138 changes: 113 additions & 25 deletions packages/opencode/src/project/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,51 +44,110 @@ export namespace Project {
export async function fromDirectory(directory: string) {
log.info("fromDirectory", { directory })

const { id, worktree, vcs } = await iife(async () => {
const { id, worktree, vcs, oldProjectID } = await iife(async () => {
const matches = Filesystem.up({ targets: [".git"], start: directory })
const git = await matches.next().then((x) => x.value)
await matches.return()
if (git) {
let worktree = path.dirname(git)
let id = await Bun.file(path.join(git, "opencode"))
// First resolve the actual worktree path before generating ID
worktree = await $`git rev-parse --show-toplevel`
.quiet()
.nothrow()
.cwd(worktree)
.text()
.then((x) => path.resolve(worktree, x.trim()))

// Get the actual git directory (handles worktrees where .git is a file pointing elsewhere)
const gitDir = await $`git rev-parse --git-dir`
.quiet()
.nothrow()
.cwd(worktree)
.text()
.then((x) => path.resolve(worktree, x.trim()))

// Detect if this is a linked worktree (gitDir contains .git/worktrees/)
const isLinkedWorktree = gitDir.includes(".git/worktrees/")

// Read cached root commit from .git/opencode (backwards compat with old opencode)
const cachedRootCommit = await Bun.file(path.join(gitDir, "opencode"))
.text()
.then((x) => x.trim())
.catch(() => {})
if (!id) {
const roots = await $`git rev-list --max-parents=0 --all`
.quiet()
.nothrow()
.cwd(worktree)
.text()
.then((x) =>
x
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted(),
)
id = roots[0]
if (id) Bun.file(path.join(git, "opencode")).write(id)

// For linked worktrees, also check for cached worktree hash
const cachedWorktreeHash = isLinkedWorktree
? await Bun.file(path.join(gitDir, "opencode-worktree"))
.text()
.then((x) => x.trim())
.catch(() => {})
: undefined

// If we have cached values, construct the ID and return early
if (cachedRootCommit) {
if (isLinkedWorktree && cachedWorktreeHash) {
const id = `${cachedRootCommit}|${cachedWorktreeHash}`
return { id, worktree, vcs: "git", oldProjectID: undefined }
}
if (isLinkedWorktree && !cachedWorktreeHash) {
// Linked worktree opened for first time after upgrade - need to migrate from old ID format
// Old format used root commit only, new format includes worktree hash
const worktreeHash = Bun.hash(worktree).toString(16)
const id = `${cachedRootCommit}|${worktreeHash}`
await Bun.file(path.join(gitDir, "opencode-worktree")).write(worktreeHash)
return { id, worktree, vcs: "git", oldProjectID: cachedRootCommit }
}
if (!isLinkedWorktree) {
return { id: cachedRootCommit, worktree, vcs: "git", oldProjectID: undefined }
}
}
if (!id)

const roots = await $`git rev-list --max-parents=0 --all`
.quiet()
.nothrow()
.cwd(worktree)
.text()
.then((x) =>
x
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted(),
)
const rootCommit = roots[0]
if (!rootCommit)
return {
id: "global",
worktree,
vcs: "git",
oldProjectID: undefined,
}
worktree = await $`git rev-parse --show-toplevel`
.quiet()
.nothrow()
.cwd(worktree)
.text()
.then((x) => path.resolve(worktree, x.trim()))
return { id, worktree, vcs: "git" }

// For main worktree: use root commit as ID (backwards compat)
// For linked worktrees: use root commit + "|" + hash of worktree path
let id: string
if (isLinkedWorktree) {
const worktreeHash = Bun.hash(worktree).toString(16)
id = `${rootCommit}|${worktreeHash}`
await Bun.file(path.join(gitDir, "opencode-worktree")).write(worktreeHash)
} else {
id = rootCommit
}

// Write root commit to .git/opencode
if (!cachedRootCommit) {
await Bun.file(path.join(gitDir, "opencode")).write(rootCommit)
}

// No migration needed - main worktree keeps same ID format
return { id, worktree, vcs: "git", oldProjectID: undefined }
}

return {
id: "global",
worktree: "/",
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
oldProjectID: undefined,
}
})

Expand All @@ -105,6 +164,10 @@ export namespace Project {
}
if (id !== "global") {
await migrateFromGlobal(id, worktree)
// Migrate from old project ID format (root commit only) to new format (hash of root commit + worktree)
if (oldProjectID) {
await migrateSessions(oldProjectID, id, worktree)
}
}
}
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing)
Expand Down Expand Up @@ -180,6 +243,31 @@ export namespace Project {
})
}

async function migrateSessions(oldProjectID: string, newProjectID: string, worktree: string) {
const oldProject = await Storage.read<Info>(["project", oldProjectID]).catch(() => undefined)
if (!oldProject) return
// Only migrate if the old project's worktree matches this worktree
if (oldProject.worktree !== worktree) return

const oldSessions = await Storage.list(["session", oldProjectID]).catch(() => [])
if (oldSessions.length === 0) return

log.info("migrating sessions", { from: oldProjectID, to: newProjectID, worktree, count: oldSessions.length })

await work(10, oldSessions, async (key) => {
const sessionID = key[key.length - 1]
const session = await Storage.read<Session.Info>(key).catch(() => undefined)
if (!session) return

session.projectID = newProjectID
log.info("migrating session", { sessionID, from: oldProjectID, to: newProjectID })
await Storage.write(["session", newProjectID, sessionID], session)
await Storage.remove(key)
}).catch((error) => {
log.error("failed to migrate sessions", { error, from: oldProjectID, to: newProjectID })
})
}

export async function setInitialized(projectID: string) {
await Storage.update<Info>(["project", projectID], (draft) => {
draft.time.initialized = Date.now()
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/snapshot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export namespace Snapshot {
.nothrow()
// Configure git to not convert line endings on Windows
await $`git --git-dir ${git} config core.autocrlf false`.quiet().nothrow()
// Disable fsmonitor to avoid hangs when worktree is a linked git worktree
await $`git --git-dir ${git} config core.fsmonitor false`.quiet().nothrow()
log.info("initialized")
}
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
Expand Down
36 changes: 36 additions & 0 deletions packages/opencode/test/project/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,48 @@ describe("Project.fromDirectory", () => {

expect(project).toBeDefined()
expect(project.id).not.toBe("global")
expect(project.id).not.toContain("|") // main worktree uses root commit only
expect(project.vcs).toBe("git")
expect(project.worktree).toBe(tmp.path)

const opencodeFile = path.join(tmp.path, ".git", "opencode")
const fileExists = await Bun.file(opencodeFile).exists()
expect(fileExists).toBe(true)

// Should not create opencode-worktree file for main worktree
const worktreeFile = path.join(tmp.path, ".git", "opencode-worktree")
const worktreeFileExists = await Bun.file(worktreeFile).exists()
expect(worktreeFileExists).toBe(false)
})

test("should use different ID format for linked worktrees", async () => {
await using tmp = await tmpdir({ git: true })
const worktreePath = `${tmp.path}-worktree`
await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet()

try {
const mainProject = await Project.fromDirectory(tmp.path)
const linkedProject = await Project.fromDirectory(worktreePath)

// Main worktree uses root commit only
expect(mainProject.id).not.toContain("|")

// Linked worktree uses root commit + "|" + hash
expect(linkedProject.id).toContain("|")
expect(linkedProject.id.startsWith(mainProject.id + "|")).toBe(true)

// Different IDs for different worktrees
expect(linkedProject.id).not.toBe(mainProject.id)

// Linked worktree should have opencode-worktree file
const gitDir = await $`git rev-parse --git-dir`.cwd(worktreePath).quiet().text()
const worktreeFile = path.join(gitDir.trim(), "opencode-worktree")
const worktreeFileExists = await Bun.file(worktreeFile).exists()
expect(worktreeFileExists).toBe(true)
} finally {
await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow()
await $`rm -rf ${worktreePath}`.quiet()
}
})
})

Expand Down