mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-28 04:26:11 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc7d4ebb69 | ||
|
|
46465000a0 | ||
|
|
bee852b5cf |
@@ -14,6 +14,7 @@ const FOLDERS = new Set([
|
||||
".git",
|
||||
".svn",
|
||||
".hg",
|
||||
".jj",
|
||||
".vscode",
|
||||
".idea",
|
||||
".turbo",
|
||||
|
||||
@@ -84,6 +84,7 @@ import { WebSearchPlugins } from "./websearch/index.js"
|
||||
import { PluginRuntime } from "./runtime.js"
|
||||
import { SkillPlugin } from "./skill.js"
|
||||
import { VcsHgPlugin } from "./vcs/hg.js"
|
||||
import { VcsJjPlugin } from "./vcs/jj.js"
|
||||
import { SystemPromptPlugin } from "./system-prompt.js"
|
||||
import { VariantPlugin } from "./variant.js"
|
||||
import { VcsGitPlugin } from "./vcs/git.js"
|
||||
@@ -238,6 +239,7 @@ const pre = [
|
||||
MCPCodeModeExclusionPlugin.Plugin,
|
||||
WellKnownPlugin.Plugin,
|
||||
VcsGitPlugin.Plugin,
|
||||
VcsJjPlugin.Plugin,
|
||||
AgentPlugin.Plugin,
|
||||
PlanPlugin.Plugin,
|
||||
CommandPlugin.Plugin,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
export * as VcsJjPlugin from "./jj.js"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Location } from "../../location.js"
|
||||
import { ProjectJj } from "../../project/jj.js"
|
||||
import type { Adapter, BranchOptions, DiffOptions } from "../../vcs.js"
|
||||
import { countPatch, emptyPatch, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES, splitGitPatch } from "../../vcs/patch.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: ProjectJj.id,
|
||||
vcs: ProjectJj.vcs,
|
||||
effect: Effect.fn("VcsJjPlugin")(function* (ctx) {
|
||||
const location = yield* Location.Service
|
||||
if (location.vcs?.type !== "jj" && location.vcsBackend !== "jj") return
|
||||
|
||||
const processes = yield* AppProcess.Service
|
||||
const adapter = make(processes, location.directory)
|
||||
|
||||
yield* ctx.vcs.transform((draft) => {
|
||||
draft.add({
|
||||
id: "jj",
|
||||
name: "Jujutsu",
|
||||
info: () => adapter.info(),
|
||||
branches: (input) => adapter.branches({ search: input.search, limit: input.limit }),
|
||||
status: () => adapter.status(),
|
||||
diff: (input) => adapter.diff(input.mode, { context: input.context }),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
function make(proc: AppProcess.Interface, directory: string): Adapter {
|
||||
const run = Effect.fnUntraced(
|
||||
function* (args: string[], options?: { metadata?: boolean; maxOutputBytes?: number }) {
|
||||
const result = yield* proc.run(
|
||||
ChildProcess.make(
|
||||
"jj",
|
||||
["--color", "never", "--no-pager", ...(options?.metadata ? ["--ignore-working-copy"] : []), ...args],
|
||||
{ cwd: directory, extendEnv: true, stdin: "ignore" },
|
||||
),
|
||||
{ maxOutputBytes: options?.maxOutputBytes },
|
||||
)
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
text: () => result.stdout.toString("utf8"),
|
||||
truncated: result.stdoutTruncated || result.stderrTruncated,
|
||||
}
|
||||
},
|
||||
Effect.orElseSucceed(() => ({ exitCode: 1, text: () => "", truncated: false })),
|
||||
)
|
||||
|
||||
const bookmarks = Effect.fnUntraced(function* (revision?: string) {
|
||||
const result = yield* run(
|
||||
["bookmark", "list", ...(revision ? ["-r", revision] : []), "--sort", "name", "-T", 'name ++ "\\0"'],
|
||||
{ metadata: true },
|
||||
)
|
||||
if (result.exitCode !== 0) return []
|
||||
return result.text().split("\0").filter(Boolean)
|
||||
})
|
||||
|
||||
const base = Effect.fnUntraced(function* () {
|
||||
const trunk = (yield* bookmarks("trunk()"))[0]
|
||||
if (trunk) return trunk
|
||||
const list = yield* bookmarks()
|
||||
if (list.includes("main")) return "main"
|
||||
if (list.includes("master")) return "master"
|
||||
return undefined
|
||||
})
|
||||
|
||||
const changes = Effect.fnUntraced(function* (revision: string[], options?: DiffOptions) {
|
||||
const listed = yield* run(["diff", ...revision, "-T", 'status ++ "\\t" ++ path ++ "\\0"', "."])
|
||||
if (listed.exitCode !== 0) return []
|
||||
const items = listed
|
||||
.text()
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.flatMap((entry) => {
|
||||
const separator = entry.indexOf("\t")
|
||||
if (separator === -1) return []
|
||||
const code = entry.slice(0, separator)
|
||||
const file = entry.slice(separator + 1)
|
||||
if (!file) return []
|
||||
const status: FileStatus["status"] =
|
||||
code === "added" || code === "copied" ? "added" : code === "removed" ? "deleted" : "modified"
|
||||
return [{ file, status }]
|
||||
})
|
||||
if (items.length === 0) return []
|
||||
|
||||
const result = yield* run(
|
||||
["diff", ...revision, "--git", "--context", String(options?.context ?? PATCH_CONTEXT_LINES), "."],
|
||||
{ metadata: true, maxOutputBytes: MAX_TOTAL_PATCH_BYTES },
|
||||
)
|
||||
const patches = splitGitPatch({
|
||||
text: result.exitCode === 0 ? result.text() : "",
|
||||
truncated: result.truncated,
|
||||
})
|
||||
return items
|
||||
.map((item, index) => {
|
||||
const patch = patches[index] ?? emptyPatch(item.file)
|
||||
return { ...item, patch, ...countPatch(patch) } satisfies FileDiff.Info
|
||||
})
|
||||
.toSorted((a, b) => a.file.localeCompare(b.file))
|
||||
})
|
||||
|
||||
return {
|
||||
info: Effect.fn("VcsJj.info")(function* () {
|
||||
const [current, root] = yield* Effect.all([bookmarks("@"), base()], { concurrency: 2 })
|
||||
return { branch: { current: current[0], default: root } } satisfies Info
|
||||
}),
|
||||
branches: Effect.fn("VcsJj.branches")(function* (options?: BranchOptions) {
|
||||
const search = options?.search?.trim().toLowerCase()
|
||||
return (yield* bookmarks())
|
||||
.filter((bookmark) => !search || bookmark.toLowerCase().includes(search))
|
||||
.slice(0, options?.limit) satisfies BranchList
|
||||
}),
|
||||
status: Effect.fn("VcsJj.status")(function* () {
|
||||
return (yield* changes(["-r", "@"], { context: 0 })).map((item) => ({
|
||||
file: item.file,
|
||||
additions: item.additions,
|
||||
deletions: item.deletions,
|
||||
status: item.status,
|
||||
}))
|
||||
}),
|
||||
diff: Effect.fn("VcsJj.diff")(function* (mode: Mode, options?: DiffOptions) {
|
||||
if (mode === "working") return yield* changes(["-r", "@"], options)
|
||||
const root = yield* base()
|
||||
if (!root) return []
|
||||
return yield* changes(["--from", `fork_point(${root} | @)`], options)
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { ProjectMarkers } from "./project/markers.js"
|
||||
import { ProjectSchema } from "./project/schema.js"
|
||||
import { ProjectJj } from "./project/jj.js"
|
||||
import { ProjectTable, upsertProject } from "./project/sql.js"
|
||||
import { WorktreeTable } from "./worktree/sql.js"
|
||||
|
||||
@@ -127,7 +128,7 @@ const layer = Layer.effect(
|
||||
directories.push({
|
||||
projectID: project.id,
|
||||
directory: project.directory,
|
||||
strategy: project.vcs.type === "git" ? "git" : undefined,
|
||||
strategy: project.vcs.type === "git" && project.vcsBackend !== "jj" ? "git" : undefined,
|
||||
})
|
||||
// A missing directory row means this directory's resolution is a new durable
|
||||
// fact (copy.ts registers copy directories directly; those never strand
|
||||
@@ -313,19 +314,34 @@ const layer = Layer.effect(
|
||||
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
|
||||
const directory = AbsolutePath.make(yield* fs.resolve(input))
|
||||
const marker = yield* markers.discover(directory)
|
||||
const discovered = marker?.type === "jj" ? yield* ProjectJj.discover(fs, marker.marker) : undefined
|
||||
const native = yield* fs.up({ targets: [".git", ".hg"], start: directory, mode: "first" }).pipe(
|
||||
Effect.map((matches) => matches[0]),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
const repo =
|
||||
const repository =
|
||||
native && path.basename(native) === ".git"
|
||||
? yield* git.repo.discover(AbsolutePath.make(path.dirname(native)))
|
||||
: undefined
|
||||
if (repo && (!marker || FSUtil.contains(marker.directory, repo.worktree))) {
|
||||
const jj =
|
||||
discovered &&
|
||||
repository &&
|
||||
repository.worktree !== discovered.directory &&
|
||||
FSUtil.contains(discovered.directory, repository.worktree)
|
||||
? undefined
|
||||
: discovered
|
||||
const backing =
|
||||
jj && (!repository || repository.worktree !== jj.directory)
|
||||
? yield* git.repo.discover(jj.canonical)
|
||||
: repository
|
||||
const repo = jj && backing?.worktree !== jj.canonical && backing?.worktree !== jj.directory ? undefined : backing
|
||||
if (repo && (!marker || FSUtil.contains(marker.directory, repo.worktree) || jj?.canonical === repo.worktree)) {
|
||||
const previous = yield* cached(repo.commonDirectory)
|
||||
const id = (yield* remote(repo)) ?? previous ?? (yield* rootCommit(repo))
|
||||
const canonical =
|
||||
repo.gitDirectory === repo.commonDirectory
|
||||
const workspace = jj && jj.directory !== repo.worktree
|
||||
const canonical = workspace
|
||||
? repo.worktree
|
||||
: repo.gitDirectory === repo.commonDirectory
|
||||
? repo.worktree
|
||||
: yield* git.worktree.list(repo).pipe(
|
||||
Effect.map((items) => items.find((item) => item.kind === "main")?.directory ?? repo.worktree),
|
||||
@@ -334,10 +350,11 @@ const layer = Layer.effect(
|
||||
return yield* persist({
|
||||
previous,
|
||||
id: id ?? ID.global,
|
||||
directory: repo.worktree,
|
||||
directory: workspace ? jj.directory : repo.worktree,
|
||||
canonical,
|
||||
vcs: { type: "git" as const, store: repo.commonDirectory },
|
||||
...(marker?.directory === repo.worktree && marker.type !== "git" ? { vcsBackend: marker.type } : {}),
|
||||
...(marker && marker.directory === repo.worktree && marker.type !== "git" ? { vcsBackend: marker.type } : {}),
|
||||
...(jj ? { vcsBackend: "jj" } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -350,7 +367,18 @@ const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
|
||||
if (marker) {
|
||||
if (jj) {
|
||||
const previous = yield* cached(jj.store)
|
||||
return yield* persist({
|
||||
previous,
|
||||
id: previous ?? ID.make(Hash.fast(`jj-repository:${jj.store}`)),
|
||||
directory: jj.directory,
|
||||
canonical: jj.canonical,
|
||||
vcs: { type: "jj", store: jj.store },
|
||||
})
|
||||
}
|
||||
|
||||
if (marker && marker.type !== "jj") {
|
||||
const previous = yield* cached(marker.marker)
|
||||
return yield* persist({
|
||||
previous,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export * as ProjectJj from "./jj.js"
|
||||
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { AbsolutePath } from "../schema.js"
|
||||
|
||||
export const id = "opencode.vcs.jj"
|
||||
export const vcs = { id: "jj", markers: [".jj"] }
|
||||
|
||||
export const discover = Effect.fn("ProjectJj.discover")(function* (fs: FSUtil.Interface, metadata: AbsolutePath) {
|
||||
const reference = path.join(metadata, "repo")
|
||||
const direct = yield* fs.isDir(reference)
|
||||
const pointer = direct ? undefined : yield* fs.readFileString(reference).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (!direct && !pointer?.trim()) return undefined
|
||||
|
||||
const store = yield* fs.realPath(pointer ? path.resolve(metadata, pointer.trim()) : reference).pipe(
|
||||
Effect.map((value) => AbsolutePath.make(value)),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
if (!store || !(yield* fs.isDir(store))) return undefined
|
||||
|
||||
const directory = yield* fs.realPath(path.dirname(metadata)).pipe(
|
||||
Effect.map((value) => AbsolutePath.make(value)),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
if (!directory) return undefined
|
||||
|
||||
return { directory, store, canonical: AbsolutePath.make(path.dirname(path.dirname(store))) }
|
||||
})
|
||||
@@ -14,6 +14,7 @@ import { PluginModule } from "../plugin/module.js"
|
||||
import { PluginSourceDirectory } from "../plugin/source-directory.js"
|
||||
import { SdkPlugins } from "../plugin/sdk.js"
|
||||
import { AbsolutePath } from "../schema.js"
|
||||
import { ProjectJj } from "./jj.js"
|
||||
|
||||
export interface Match {
|
||||
readonly type: string
|
||||
@@ -35,7 +36,7 @@ const layer = Layer.effect(
|
||||
const global = yield* Global.Service
|
||||
const npm = yield* Npm.Service
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const known = new Set([".git", ".hg"])
|
||||
const known = new Set([".git", ".hg", ...ProjectJj.vcs.markers])
|
||||
const loaded = new Map<string, Versioned | undefined>()
|
||||
|
||||
const discover = Effect.fn("ProjectMarkers.discover")(function* (directory: AbsolutePath) {
|
||||
@@ -70,7 +71,9 @@ const layer = Layer.effect(
|
||||
)
|
||||
},
|
||||
)
|
||||
const declarations = new Map<string, { readonly id: string; readonly markers: readonly string[] }>()
|
||||
const declarations = new Map<string, { readonly id: string; readonly markers: readonly string[] }>([
|
||||
[ProjectJj.id, ProjectJj.vcs],
|
||||
])
|
||||
|
||||
for (const plugin of sdk.all()) {
|
||||
if (!plugin.vcs) continue
|
||||
|
||||
@@ -22,6 +22,8 @@ test("parcel patterns ignore built-in folders at any depth", async () => {
|
||||
"nested/node_modules/package/index.js",
|
||||
"nested/.git",
|
||||
"nested/.git/HEAD",
|
||||
"nested/.jj",
|
||||
"nested/.jj/repo/op_store/operations/current",
|
||||
"nested/dist",
|
||||
"nested/dist/index.js",
|
||||
]) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectSchema } from "@opencode-ai/core/project/schema"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { WorktreeTable } from "@opencode-ai/core/worktree/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
@@ -525,6 +526,163 @@ describe("Project.resolve", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const itJj = Bun.which("jj") ? it : { live: it.live.skip }
|
||||
|
||||
itJj.live("detects standalone Jujutsu repositories from nested directories", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(async () => {
|
||||
await $`jj git init --no-colocate`.cwd(tmp.path).quiet()
|
||||
await fs.mkdir(path.join(tmp.path, "a", "b"), { recursive: true })
|
||||
})
|
||||
const project = yield* Project.Service
|
||||
const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
|
||||
|
||||
expect(result.vcs?.type).toBe("jj")
|
||||
expect(result.directory).toBe(yield* real(tmp.path))
|
||||
expect(result.canonical).toBe(result.directory)
|
||||
expect(result.id).not.toBe(Project.ID.global)
|
||||
expect((yield* project.resolve(abs(tmp.path))).id).toBe(result.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("ignores incomplete Jujutsu metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, ".jj")))
|
||||
const project = yield* Project.Service
|
||||
|
||||
expect((yield* project.resolve(abs(tmp.path))).vcs).toBeUndefined()
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, ".jj", "repo"), " \n"))
|
||||
expect((yield* project.resolve(abs(tmp.path))).vcs).toBeUndefined()
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, ".jj", "repo"), "../missing"))
|
||||
expect((yield* project.resolve(abs(tmp.path))).vcs).toBeUndefined()
|
||||
|
||||
yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
|
||||
expect((yield* project.resolve(abs(tmp.path))).vcs?.type).toBe("git")
|
||||
}),
|
||||
)
|
||||
|
||||
itJj.live("preserves Git project identity in colocated Jujutsu repositories", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(async () => {
|
||||
await initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" })
|
||||
await $`jj git init --colocate`.cwd(tmp.path).quiet()
|
||||
})
|
||||
const project = yield* Project.Service
|
||||
const result = yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(result.id).toBe(remoteID("github.com/owner/repo"))
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
expect(result.directory).toBe(yield* real(tmp.path))
|
||||
}),
|
||||
)
|
||||
|
||||
itJj.live("shares standalone Jujutsu identity and canonical root across workspaces", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const main = path.join(tmp.path, "main")
|
||||
const secondary = path.join(tmp.path, "secondary")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(main)
|
||||
await $`jj git init --no-colocate`.cwd(main).quiet()
|
||||
await $`jj workspace add ${secondary}`.cwd(main).quiet()
|
||||
})
|
||||
const project = yield* Project.Service
|
||||
const first = yield* project.resolve(abs(main))
|
||||
const second = yield* project.resolve(abs(secondary))
|
||||
|
||||
expect(second.id).toBe(first.id)
|
||||
expect(second.vcs?.type).toBe("jj")
|
||||
expect(second.directory).toBe(yield* real(secondary))
|
||||
expect(second.canonical).toBe(yield* real(main))
|
||||
}),
|
||||
)
|
||||
|
||||
itJj.live("shares colocated Git identity with secondary Jujutsu workspaces", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const main = path.join(tmp.path, "main")
|
||||
const secondary = path.join(tmp.path, "secondary")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(main)
|
||||
await initRepo(main, { commit: true, remote: "git@github.com:owner/jj-workspaces.git" })
|
||||
await $`jj git init --colocate`.cwd(main).quiet()
|
||||
await $`jj workspace add ${secondary}`.cwd(main).quiet()
|
||||
})
|
||||
const project = yield* Project.Service
|
||||
const first = yield* project.resolve(abs(main))
|
||||
const second = yield* project.resolve(abs(secondary))
|
||||
|
||||
expect(second.id).toBe(first.id)
|
||||
expect(second.vcs).toMatchObject({ type: "git" })
|
||||
expect(second.vcsBackend).toBe("jj")
|
||||
expect(second.directory).toBe(yield* real(secondary))
|
||||
expect(second.canonical).toBe(yield* real(main))
|
||||
const database = yield* Database.Service
|
||||
const rows = yield* database.db.select().from(WorktreeTable).all()
|
||||
expect(rows.find((item) => item.directory === second.directory)?.strategy).toBeNull()
|
||||
}),
|
||||
)
|
||||
|
||||
itJj.live("prefers a nested Jujutsu repository over its parent Git repository", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const nested = path.join(tmp.path, "nested")
|
||||
yield* Effect.promise(async () => {
|
||||
await initRepo(tmp.path, { commit: true })
|
||||
await fs.mkdir(nested)
|
||||
await $`jj git init --no-colocate`.cwd(nested).quiet()
|
||||
})
|
||||
const project = yield* Project.Service
|
||||
const result = yield* project.resolve(abs(nested))
|
||||
|
||||
expect(result.vcs?.type).toBe("jj")
|
||||
expect(result.directory).toBe(yield* real(nested))
|
||||
}),
|
||||
)
|
||||
|
||||
itJj.live("prefers a nested Git repository over its parent Jujutsu repository", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const nested = path.join(tmp.path, "nested")
|
||||
yield* Effect.promise(async () => {
|
||||
await $`jj git init --no-colocate`.cwd(tmp.path).quiet()
|
||||
await fs.mkdir(nested)
|
||||
await initRepo(nested, { commit: true })
|
||||
})
|
||||
const project = yield* Project.Service
|
||||
const result = yield* project.resolve(abs(nested))
|
||||
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
expect(result.directory).toBe(yield* real(nested))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("prefers git when both git and mercurial metadata exist", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Vcs } from "@opencode-ai/core/vcs"
|
||||
import { VcsGitPlugin } from "@opencode-ai/core/plugin/vcs/git"
|
||||
import { VcsJjPlugin } from "@opencode-ai/core/plugin/vcs/jj"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
import { host } from "./plugin/host"
|
||||
|
||||
const describeJj = Bun.which("jj") ? describe : describe.skip
|
||||
|
||||
const provide = (directory: string, input: { worktree: string; colocated?: boolean }) =>
|
||||
Effect.provide(
|
||||
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node, AppProcess.node, FSUtil.node]), [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of({
|
||||
...location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{
|
||||
projectDirectory: AbsolutePath.make(input.worktree),
|
||||
vcs: input.colocated
|
||||
? { type: "git", store: AbsolutePath.make(path.join(input.worktree, ".git")) }
|
||||
: { type: "jj", store: AbsolutePath.make(path.join(input.worktree, ".jj", "repo")) },
|
||||
},
|
||||
),
|
||||
...(input.colocated ? { vcsBackend: "jj" } : {}),
|
||||
}),
|
||||
),
|
||||
],
|
||||
]),
|
||||
)
|
||||
|
||||
const withJj = <A, E, R>(
|
||||
f: (directory: string) => Effect.Effect<A, E, R>,
|
||||
options: { colocated?: boolean; nested?: string } = {},
|
||||
) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.promise(async () => {
|
||||
await jj(tmp.path, "git", "init", ...(options.colocated ? [] : ["--no-colocate"]))
|
||||
if (options.nested) await fs.mkdir(path.join(tmp.path, options.nested), { recursive: true })
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const context = host()
|
||||
const scoped = { ...context, vcs: { ...context.vcs, transform: vcs.transform, reload: vcs.reload } }
|
||||
if (options.colocated) yield* VcsGitPlugin.Plugin.effect(scoped)
|
||||
yield* VcsJjPlugin.Plugin.effect(scoped)
|
||||
return yield* f(tmp.path)
|
||||
}).pipe(
|
||||
provide(path.join(tmp.path, options.nested ?? ""), {
|
||||
worktree: tmp.path,
|
||||
colocated: options.colocated,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
async function jj(directory: string, ...args: string[]) {
|
||||
await $`jj --quiet ${args}`.cwd(directory).quiet()
|
||||
}
|
||||
|
||||
describeJj("Vcs jujutsu", () => {
|
||||
it.live("reports modified, deleted, and added files", () =>
|
||||
withJj((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(directory, "keep.txt"), "one\ntwo\n")
|
||||
await fs.writeFile(path.join(directory, "gone.txt"), "bye\n")
|
||||
await jj(directory, "commit", "-m", "initial")
|
||||
await fs.writeFile(path.join(directory, "keep.txt"), "one\nthree\n")
|
||||
await fs.rm(path.join(directory, "gone.txt"))
|
||||
await fs.writeFile(path.join(directory, "new file.txt"), "hello\nworld\n")
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
expect(yield* vcs.status()).toEqual([
|
||||
{ file: "gone.txt", additions: 0, deletions: 1, status: "deleted" },
|
||||
{ file: "keep.txt", additions: 1, deletions: 1, status: "modified" },
|
||||
{ file: "new file.txt", additions: 2, deletions: 0, status: "added" },
|
||||
])
|
||||
const diff = yield* vcs.diff("working")
|
||||
expect(diff.map((item) => ({ file: item.file, status: item.status }))).toEqual([
|
||||
{ file: "gone.txt", status: "deleted" },
|
||||
{ file: "keep.txt", status: "modified" },
|
||||
{ file: "new file.txt", status: "added" },
|
||||
])
|
||||
expect(diff[0].patch).toContain("-bye")
|
||||
expect(diff[1].patch).toContain("+three")
|
||||
expect(diff[2].patch).toContain("+hello")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves patches and counts for filenames with tabs and newlines", () =>
|
||||
withJj((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(directory, "line\nname.txt"), "first\nsecond\n")
|
||||
await fs.writeFile(path.join(directory, "tab\tname.txt"), "third\n")
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
expect(yield* vcs.status()).toEqual([
|
||||
{ file: "line\nname.txt", additions: 2, deletions: 0, status: "added" },
|
||||
{ file: "tab\tname.txt", additions: 1, deletions: 0, status: "added" },
|
||||
])
|
||||
const diff = yield* vcs.diff("working")
|
||||
expect(diff[0].patch).toContain("+first")
|
||||
expect(diff[1].patch).toContain("+third")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves rename destinations and their patches", () =>
|
||||
withJj((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(directory, "before.txt"), "one\ntwo\n")
|
||||
await jj(directory, "commit", "-m", "initial")
|
||||
await fs.rename(path.join(directory, "before.txt"), path.join(directory, "after.txt"))
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
const diff = yield* vcs.diff("working")
|
||||
expect(diff).toMatchObject([{ file: "after.txt", status: "modified", additions: 0, deletions: 0 }])
|
||||
expect(diff[0].patch).toContain("rename to after.txt")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("prefers Jujutsu over Git in colocated repositories", () =>
|
||||
withJj(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(directory, "file.txt"), "initial\n")
|
||||
await jj(directory, "commit", "-m", "initial")
|
||||
await jj(directory, "bookmark", "create", "feature", "-r", "@")
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
yield* vcs.reload()
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: undefined } })
|
||||
expect(yield* vcs.branches()).toEqual(["feature"])
|
||||
}),
|
||||
{ colocated: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("lists and filters bookmarks without inventing an active bookmark", () =>
|
||||
withJj((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(directory, "file.txt"), "initial\n")
|
||||
await jj(directory, "commit", "-m", "initial")
|
||||
await jj(directory, "bookmark", "create", "main", "-r", "@-")
|
||||
await jj(directory, "bookmark", "create", "feature-one", "feature-two", "-r", "@-")
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
yield* vcs.reload()
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: undefined, default: "main" } })
|
||||
expect(yield* vcs.branches()).toEqual(["feature-one", "feature-two", "main"])
|
||||
expect(yield* vcs.branches({ search: "FEATURE", limit: 1 })).toEqual(["feature-one"])
|
||||
expect(yield* vcs.branches({ search: "*" })).toEqual([])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("uses the configured trunk bookmark instead of an unrelated main bookmark", () =>
|
||||
withJj((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(directory, "file.txt"), "initial\n")
|
||||
await jj(directory, "commit", "-m", "initial")
|
||||
await jj(directory, "bookmark", "create", "main", "-r", "@-")
|
||||
await fs.writeFile(path.join(directory, "file.txt"), "initial\nnext\n")
|
||||
await jj(directory, "commit", "-m", "develop")
|
||||
await jj(directory, "bookmark", "create", "develop", "-r", "@-")
|
||||
await jj(directory, "config", "set", "--repo", 'revset-aliases."trunk()"', "develop")
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
yield* vcs.reload()
|
||||
expect((yield* vcs.info()).branch.default).toBe("develop")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("scopes status and diffs to nested directories", () =>
|
||||
withJj(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(directory, "outside.txt"), "outside\n")
|
||||
await fs.writeFile(path.join(directory, "nested", "inside.txt"), "inside\n")
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
expect(yield* vcs.status()).toEqual([
|
||||
{ file: "nested/inside.txt", additions: 1, deletions: 0, status: "added" },
|
||||
])
|
||||
expect((yield* vcs.diff("working")).map((item) => item.file)).toEqual(["nested/inside.txt"])
|
||||
}),
|
||||
{ nested: "nested" },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("respects the diff context option", () =>
|
||||
withJj((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const body = Array.from({ length: 20 }, (_, index) => `line-${index}`).join("\n") + "\n"
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(directory, "file.txt"), body)
|
||||
await jj(directory, "commit", "-m", "initial")
|
||||
await fs.writeFile(path.join(directory, "file.txt"), body.replace("line-10", "changed"))
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
expect((yield* vcs.diff("working"))[0].patch).toContain("line-0")
|
||||
const tight = yield* vcs.diff("working", { context: 1 })
|
||||
expect(tight[0].patch).toContain("line-9")
|
||||
expect(tight[0].patch).not.toContain("line-0")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("compares a change against the main bookmark fork point", () =>
|
||||
withJj((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
|
||||
await jj(directory, "commit", "-m", "initial")
|
||||
await jj(directory, "bookmark", "create", "main", "-r", "@-")
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
expect(yield* vcs.diff("branch")).toEqual([])
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "file.txt"), "one\ntwo\n"))
|
||||
const diff = yield* vcs.diff("branch")
|
||||
expect(diff.map((item) => ({ file: item.file, status: item.status }))).toEqual([
|
||||
{ file: "file.txt", status: "modified" },
|
||||
])
|
||||
expect(diff[0].patch).toContain("+two")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -17,7 +17,7 @@ export async function localProjectDirectory(cwd: string) {
|
||||
const repositories = await Promise.all(
|
||||
directories.map((directory) =>
|
||||
Promise.all(
|
||||
[".git", ".hg"].map((name) =>
|
||||
[".git", ".hg", ".jj"].map((name) =>
|
||||
stat(path.join(directory, name)).then(
|
||||
() => true,
|
||||
(error) => (isMissingPath(error) ? false : Promise.reject(error)),
|
||||
|
||||
@@ -68,6 +68,18 @@ test("uses an Hg root for a missing project plugin directory", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("uses a Jujutsu root for a missing project plugin directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const project = path.join(tmp.path, "repo")
|
||||
const cwd = path.join(project, "package")
|
||||
await mkdir(path.join(project, ".jj"), { recursive: true })
|
||||
await mkdir(cwd, { recursive: true })
|
||||
|
||||
expect(await tuiPluginDirectories(cwd, path.join(tmp.path, "config"))).toContain(
|
||||
path.join(project, ".opencode", "plugins", "tui"),
|
||||
)
|
||||
})
|
||||
|
||||
test("truncates fractional mtimes in fresh specifiers", () => {
|
||||
// A dot in the query makes Bun's compiled binaries skip runtime plugin
|
||||
// hooks for the import, breaking JSX/solid rewriting for external plugins.
|
||||
|
||||
Reference in New Issue
Block a user