Compare commits

...
11 changed files with 642 additions and 11 deletions
+1
View File
@@ -14,6 +14,7 @@ const FOLDERS = new Set([
".git",
".svn",
".hg",
".jj",
".vscode",
".idea",
".turbo",
+2
View File
@@ -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,
+136
View File
@@ -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)
}),
}
}
+36 -8
View File
@@ -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,
+30
View File
@@ -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))) }
})
+5 -2
View File
@@ -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",
]) {
+158
View File
@@ -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(
+259
View File
@@ -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")
}),
),
)
})
+1 -1
View File
@@ -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.