mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
+25







![opencode-agent[bot]](/assets/img/avatar_default.png)

Johnny Eric Amancio
opencode-agent[bot]
LukeParkerDev
Shoubhit Dash
Simon Klee
Brendan Allan
Brendan Allan
starptech
Cortex Code
Dax
Dax Raad
Frank
opencode-agent[bot]
Aiden Cline
Michael Hart
smagnuso
Orca丶
Adam
Aarav Sareen
Kit Langton
James Long
Dustin Deus
Ulises Jeremias
Jack
Jérôme Benoit
Ariane Emory
LIU Xinyu
Colin McDonnell
Sebastian
opencode
Kamesh Sampath
pcadena-lila
weiconghe
alberto
kilo-maintainer[bot]
ef6b152ff8
* feat(worktree): add managed workspace cloning (#30117) * test(tui): skip crashing keymap textarea renderer * fix(core): allow skipping migration execution * fix(opencode): remove automatic full session diffs (#30127) * chore: generate * refactor(worktree): move project out of repository * zen: deepseek flash * fix(tui): remount session view on session switch (#30129) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> * go: minimax m3 * refactor(opencode): inline local provider helpers (#30169) * refactor(opencode): simplify provider setup flow (#30173) * fix(app): show project sessions before path sync resolves (#30167) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> * fix(core): preserve session metadata migration identity (#30176) * refactor(session): align namespace imports and inline trivial helpers (#30180) * opencode(run): add queued prompt management (#30103) Direct run mode previously made submitted follow-up prompts irrevocable while a response was still running. Let users edit or remove queued prompts before dispatch without interrupting the active turn. * chore: generate * fix(acp): honor session/cancel by aborting the running turn (#30145) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(tui): prevent prompt corruption when pasting near wide characters (#29710) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Simon Klee <hello@simonklee.dk> * fix(opencode): avoid nullable webfetch format schema (#30215) * chore: generate * fix(core): contain lsp warmup defects (#30226) * add run --replay mode (#30239) * chore: generate * chore: update nix node_modules hashes * fix(stats): restore leaderboard spacing * fix(stats): center top models dot grid * fix(stats): stabilize top models hover * fix(stats): align big-pickle provider resolution (#30274) * feat(app): v2 desktop UI improvements (#29689) Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> * chore: generate * fix(tui): clarify inline subagent rows (#30051) * fix(tui): handle events across workspaces (#30281) * feat(core): update Copilot for token-based billing (#30181) * fix(tui): keep background marker with subagent label (#30271) * fix(tui): keep retry attempt before message (#30275) * chore: generate * fix(opencode): enforce storage path invariants (#29666) * chore: generate * feat(core): add location-based permission service (#30287) * chore: generate * fix(tui): preserve live parts during session hydration (#30300) * fix(app): restore deferred MCP status updates (#30220) * fix: export v2 stylesheets and declare core node types (#30312) * chore: update nix node_modules hashes * fix(app): avoid suspending on pending child path (#30314) * fix(opencode): remove sunsetted gpt-5.2 and gpt-5.3-codex from allowed models for codex subscriptions (#30316) * chore: generate * feat(core): expose session location * chore: generate * fix(opencode): preserve websocket api errors (#30321) * refactor(core): simplify session pagination * feat(core): add location filesystem contract * feat(core): add dummy location filesystem layer * chore: generate * feat(opencode): add filesystem read and list routes * chore: generate * infra: stats * sync * feat(app): inset new layout session panels (#30342) * fix(app): tab title truncation and close button positioning (#30349) * tui: show model context in run footer (#30380) * tui: revert OpenTUI upgrade to 0.2.16 (#30383) * chore: update nix node_modules hashes * feat(core): add managed repository cache (#30408) * chore: generate * chore: generate * sync * feat(stats): add cache ratio section * feat(core): add flagged project references (#30414) * chore: generate * feat(core): support named migrations (#30418) * fix(stats): clean retired provider rows during sync (#30420) * fix(stats): mention opencode go in top models copy * feat(core): expose project reference filesystem access (#30423) * chore: generate * sync * fix(tui): scope diff viewer to session directory (#30426) * test: widen provider header timeout margin (#30427) * fix(plugin): restore private git install fallback (#30430) * fix(stats): remove leaderboard nav link * chore(opencode): remove scout agent (#30435) * chore: generate * feat(stats): improve cache ratio chart * chore: generate * fix(effect-drizzle-sqlite): preserve transaction begin errors (#30448) * chore: bump effect beta to 74 (#30449) * Revert "tui: revert OpenTUI upgrade to 0.2.16 (#30383)" (#30452) * chore: update nix node_modules hashes * refactor(opencode): improve startup time by 38% (#30453) Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> * chore: generate * fix(opencode): patch empty Gemini replay messages (#30463) * chore: generate * refactor(core): consolidate filesystem services (#30447) * chore: generate * run: enable interactive replay by default (#30465) * chore: update nix node_modules hashes * refactor(opencode): remove JSON storage migration (#30461) * chore: generate * chore: update nix node_modules hashes * fix(tui): stop idle background task spinner (#30484) * refactor(core): move v1 schemas into core (#30473) * chore: generate * fix: task id passed to background job for continuation (#30485) * chore: generate * feat(core): project copying and tracking directories (#30139) * chore: generate * fix(opencode): preserve signed thinking during anthropic reorder (#30182) * Revert "fix(opencode): preserve signed thinking during anthropic reorder" (#30502) * fix: rm tool reorder logic from old bug (#30483) * chore: generate * feat(app): polish home projects list UI (#30436) * feat(app): polish select-v2 component (#30446) Co-authored-by: Brendan Allan <git@brendonovich.dev> * fix(github): enforce existing git author identity (#30507) * feat(app): new update button (#30460) Co-authored-by: Brendan Allan <git@brendonovich.dev> * fix(opencode): fallback to sh for curl upgrade (#30499) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(ui): render whole-file patches as complete diffs (#30516) * chore: generate * feat(app): add servers tab to settings dialog (#29675) * refactor(core): consolidate pty service (#30537) * chore: generate * tui: truncate sidebar file paths (#30531) * chore: update nix node_modules hashes * feat(stats): add geo breakdown (#30456) * chore: generate * chore: update nix node_modules hashes * fix(acp): classify apply_patch as edit (#30564) * fix(acp): classify task as think (#30565) * fix(acp): include external directory permission context (#30567) * fix(acp): clean read tool display content (#30569) * fix(tui): route question responses by session directory (#30578) * fix(stats): serve stats og image from banner * docs(go): add Qwen3.7 Plus model (#30594) * fix(openai): preserve websocket idle state (#30586) * refactor(core): remove ai sdk option fields (#30581) * chore: generate * test(core): cover v1 provider option lowering (#30599) * chore: generate * refactor(core): nest model api id (#30603) * fix(core): expose azure openai xhigh efforts (#30620) * feat(core): add skill registry and file agent loading (#30617) * chore: generate * chore: update nix node_modules hashes * fix(stats): count all go usage * chore: remove zed extension and automation (#30628) * fix(opencode): preserve variant for delegated tasks (#30630) * zen: update nvidia tos * fix(opencode): route SAP AI Core reasoning variants through modelParams (#30482) * chore: generate * fix(app): hide unavailable titlebar update (#30642) * feat(app): v2 thinking level selector (#30646) * fix(app,ui): session review reactivity and VCS query cache (#30660) * feat(core): add embedded v2 session runtime and tool foundation (#30632) * chore: generate * chore: update nix node_modules hashes * docs: correct compaction prune default (#30670) * fix(opencode): avoid shell cancel race (#30641) * feat: bump bedrock and add proper mantle support for openai models through aws bedrock (#30464) * test: wait for shell truncation readiness (#30679) * chore: update nix node_modules hashes * refactor(opencode): clean up task tool prompts (#30687) * feat(core): add command registry (#30624) * chore: generate * fix(acp): replay loaded session transcript (#30645) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(core): reset pre-launch session projections (#30728) * feat(tui): improve experimental session switcher (#30738) * fix(opencode): respect disabled auto compaction on overflow (#30749) * zen: nemotron 3 ultra * fix(enterprise): install hono standard validator peer (#30740) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> * fix build * chore: update nix node_modules hashes * make scripts executable * fix(tui): show toast when variant_list keybind used with no variants (#30724) * fix(opencode): `ACP.loadSession` should replay all messages (#30761) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(opencode): attribute task child agent on creation (#30786) * fix(tui): add Vue syntax highlighting (#30802) * fix: bump @openrouter/ai-sdk-provider to 2.9.0 (#30800) * feat(core): moving sessions (#30640) * chore: generate * tweak: background agent prompting to avoid polling issues (#30790) * upgrade opentui to 0.3.2 (#30748) * chore: update nix node_modules hashes * feat(desktop): surface local server startup failures (#30822) * ci: publish * refactor(core): make v2 session inputs event sourced (#30785) * chore: generate * fix(llm): normalize OpenAI function tool schemas * chore: generate * feat(stats): refresh stats routes and homepage (#30419) * fix(stats): sort metric charts by top usage * feat(core): add public native API (#30828) * chore: generate * feat(app): color themes (#30824) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> * chore: generate * sync release versions for v1.16.0 * feat(core): attach global native tools (#30832) * chore: generate * feat(core): add Snowflake Cortex provider (#29901) Co-authored-by: Cortex Code <noreply@snowflake.com> * chore: generate * feat(core): persist v2 session context epochs (#30789) * chore: generate * feat(tui): allow backgrounding synchronous subagents (#30488) * fix(app): improve tab handling (#30669) * chore: generate * fix(tui): prioritize models slash autocomplete (#30848) * fix(tui): route permission replies to session directory (#30851) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> * fix(cli): harden daemon lifecycle (#30844) * chore: generate * feat(app): improve desktop multi-server support (#30678) Co-authored-by: Brendan Allan <git@brendonovich.dev> * chore: generate * fix(app): handle tab overflow and scrolling in titlebar (#30886) * fix(app): tab overflow (#30894) * tui: guard path formatting inputs (#30469) Fixes #27726, #25216, #24856, #24294, #17071, #29164, #24837, #16865, #14279, #29895 * opencode/run: refresh themes after terminal reloads (#30917) * chore: generate * fix(tui): fall back to local cwd when editor spawns in attach mode (#30583) * docs: update Go Qwen tiered pricing (#30936) * chore: generate * feat(tui): add diff hunk navigation (#30935) * chore: rm fuzzy search on references (#30931) * fix: use mapError instead of orDie for context snapshot decoding (#30905) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(core): recover corrupted models cache (#30947) * chore: bun install (#30968) * fix(opencode): resolve Bedrock hang by using node build conditions (#30873) * fix(workflows): retry nix-hashes compute-hash on transient failure (#30743) * fix(stats): scroll model charts to latest on mobile * fix(opencode): prevent destructive edit matches (#30932) * chore: generate * fix(core): respect v2 default agents (#30969) * chore: generate * test(opencode): remove disposal event wait race (#30971) * test(opencode): remove shell timeout output race (#30974) * fix(opencode): gate reasoning summaries by provider (#30973) * feat(core): admit v2 skill guidance (#30843) * fix(workflows): serialize desktop release uploads (#30978) * fix(stats): add mobile chart end spacing * release: v1.16.2 * refactor: kilo compat for v1.16.2 * fix(opencode): address v1.16.2 merge regressions * chore: update kilo-vscode visual regression baselines * fix(opencode): restore Kilo behavior after v1.16.2 merge * fix(opencode): retry Windows migration cleanup * test(opencode): restore clone and macOS watcher coverage * fix(opencode): address second-pass review for #12099 Preserve imported usage and retry partial JSON migrations. Refresh active dependency patches, remove the obsolete GCP patch, and regenerate Kilo HttpApi branding. --------- Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Michael Hart <mhart@cloudflare.com> Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: smagnuso <smagnuso@gmail.com> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: Orca丶 <93272799+dauphinYan@users.noreply.github.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Ulises Jeremias <ulisescf.24@gmail.com> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com> Co-authored-by: Ariane Emory <97994360+ariane-emory@users.noreply.github.com> Co-authored-by: LIU Xinyu <contact@lxy.cc> Co-authored-by: Colin McDonnell <colinmcd94@gmail.com> Co-authored-by: Sebastian <hasta84@gmail.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Kamesh Sampath <kamesh.sampath@hotmail.com> Co-authored-by: Cortex Code <noreply@snowflake.com> Co-authored-by: pcadena-lila <pcadena@lila.ai> Co-authored-by: weiconghe <46336277+weiconghe@users.noreply.github.com> Co-authored-by: alberto <914199+alblez@users.noreply.github.com> Co-authored-by: kilo-maintainer[bot] <kilo-maintainer[bot]@users.noreply.github.com>
1122 lines
41 KiB
TypeScript
1122 lines
41 KiB
TypeScript
import { afterEach, expect } from "bun:test"
|
|
import { $ } from "bun"
|
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
|
import fs from "fs/promises"
|
|
import path from "path"
|
|
import { Effect, Fiber, Layer } from "effect"
|
|
import { Snapshot } from "../../src/snapshot"
|
|
import {
|
|
disposeAllInstances,
|
|
provideInstance,
|
|
testInstanceStoreLayer,
|
|
TestInstance,
|
|
tmpdirScoped,
|
|
} from "../fixture/fixture"
|
|
import { testEffect } from "../lib/effect"
|
|
|
|
const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, FSUtil.defaultLayer, testInstanceStoreLayer))
|
|
|
|
// Git always outputs /-separated paths internally. Snapshot.patch() joins them
|
|
// with path.join (which produces \ on Windows) then normalizes back to /.
|
|
// This helper does the same for expected values so assertions match cross-platform.
|
|
const fwd = (...parts: string[]) => path.join(...parts).replaceAll("\\", "/")
|
|
const SNAPSHOT_BATCH_BOUNDARY = 100
|
|
const OVER_BATCH_COUNT = SNAPSHOT_BATCH_BOUNDARY + 1
|
|
const MIXED_BATCH_GROUP_COUNT = Math.ceil(OVER_BATCH_COUNT / 4)
|
|
|
|
afterEach(async () => {
|
|
await disposeAllInstances()
|
|
})
|
|
|
|
const exec = (cwd: string, command: string[]) =>
|
|
Effect.promise(async () => {
|
|
const proc = Bun.spawn(command, { cwd, stdout: "ignore", stderr: "pipe" })
|
|
const code = await proc.exited
|
|
if (code !== 0) throw new Error(`${command.join(" ")} failed: ${await new Response(proc.stderr).text()}`)
|
|
})
|
|
|
|
const write = (file: string, content: string | Uint8Array) =>
|
|
FSUtil.Service.use((fs) => fs.writeWithDirs(file, content))
|
|
const readText = (file: string) => FSUtil.Service.use((fs) => fs.readFileString(file))
|
|
const exists = (file: string) => FSUtil.Service.use((fs) => fs.existsSafe(file))
|
|
const mkdirp = (dir: string) => FSUtil.Service.use((fs) => fs.ensureDir(dir))
|
|
const rm = (file: string) =>
|
|
FSUtil.Service.use((fs) => fs.remove(file, { recursive: true, force: true }).pipe(Effect.ignore))
|
|
|
|
const initialize = Effect.fn("SnapshotTest.initialize")(function* (dir: string) {
|
|
const unique = Math.random().toString(36).slice(2)
|
|
const aContent = `A${unique}`
|
|
const bContent = `B${unique}`
|
|
yield* write(`${dir}/a.txt`, aContent)
|
|
yield* write(`${dir}/b.txt`, bContent)
|
|
return { aContent, bContent }
|
|
})
|
|
|
|
type Bootstrapped = { path: string; extra: { aContent: string; bContent: string } }
|
|
|
|
const bootstrap = Effect.fn("SnapshotTest.bootstrap")(function* () {
|
|
const tmp = yield* TestInstance
|
|
return { path: tmp.directory, extra: yield* initialize(tmp.directory) }
|
|
})
|
|
|
|
const withTrackedSnapshot = <A, E, R>(
|
|
fn: (input: { tmp: Bootstrapped; snapshot: Snapshot.Interface; before: string }) => Effect.Effect<A, E, R>,
|
|
) =>
|
|
Effect.gen(function* () {
|
|
const tmp = yield* bootstrap()
|
|
const snapshot = yield* Snapshot.Service
|
|
const before = yield* snapshot.track()
|
|
expect(before).toBeTruthy()
|
|
return yield* fn({ tmp, snapshot, before: before! })
|
|
})
|
|
|
|
const bootstrapScoped = Effect.fn("SnapshotTest.bootstrapScoped")(function* () {
|
|
const dir = yield* tmpdirScoped({ git: true }).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer))
|
|
return { path: dir, extra: yield* initialize(dir) }
|
|
})
|
|
|
|
const scopedGitTmpdir = () => tmpdirScoped({ git: true }).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer))
|
|
|
|
const cleanupWorktree = (repo: string, worktree: string, files: string[] = []) =>
|
|
Effect.promise(async () => {
|
|
await $`git worktree remove --force ${worktree}`.cwd(repo).quiet().nothrow()
|
|
await fs.rm(worktree, { recursive: true, force: true }).catch(() => undefined)
|
|
await Promise.all(files.map((file) => fs.rm(file, { recursive: true, force: true }).catch(() => undefined)))
|
|
})
|
|
|
|
const withGitConfigGlobal = <A, E, R>(config: string, self: Effect.Effect<A, E, R>) =>
|
|
Effect.acquireUseRelease(
|
|
Effect.sync(() => {
|
|
const previous = process.env.GIT_CONFIG_GLOBAL
|
|
process.env.GIT_CONFIG_GLOBAL = config
|
|
return previous
|
|
}),
|
|
() => self,
|
|
(previous) =>
|
|
Effect.sync(() => {
|
|
if (previous) process.env.GIT_CONFIG_GLOBAL = previous
|
|
else delete process.env.GIT_CONFIG_GLOBAL
|
|
}),
|
|
)
|
|
|
|
it.instance(
|
|
"tracks deleted files correctly",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* rm(`${tmp.path}/a.txt`)
|
|
expect((yield* snapshot.patch(before)).files).toContain(fwd(tmp.path, "a.txt"))
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"revert should remove new files",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* write(`${tmp.path}/new.txt`, "NEW")
|
|
const patch = yield* snapshot.patch(before)
|
|
yield* snapshot.revert([patch])
|
|
expect(yield* exists(`${tmp.path}/new.txt`)).toBe(false)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"revert in subdirectory",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* mkdirp(`${tmp.path}/sub`)
|
|
yield* write(`${tmp.path}/sub/file.txt`, "SUB")
|
|
const patch = yield* snapshot.patch(before)
|
|
yield* snapshot.revert([patch])
|
|
expect(yield* exists(`${tmp.path}/sub/file.txt`)).toBe(false)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"multiple file operations",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* rm(`${tmp.path}/a.txt`)
|
|
yield* write(`${tmp.path}/c.txt`, "C")
|
|
yield* mkdirp(`${tmp.path}/dir`)
|
|
yield* write(`${tmp.path}/dir/d.txt`, "D")
|
|
yield* write(`${tmp.path}/b.txt`, "MODIFIED")
|
|
const patch = yield* snapshot.patch(before)
|
|
yield* snapshot.revert([patch])
|
|
expect(yield* readText(`${tmp.path}/a.txt`)).toBe(tmp.extra.aContent)
|
|
expect(yield* exists(`${tmp.path}/c.txt`)).toBe(false)
|
|
expect(yield* readText(`${tmp.path}/b.txt`)).toBe(tmp.extra.bContent)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"empty directory handling",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* mkdirp(`${tmp.path}/empty`)
|
|
expect((yield* snapshot.patch(before)).files.length).toBe(0)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"binary file handling",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* write(`${tmp.path}/image.png`, new Uint8Array([0x89, 0x50, 0x4e, 0x47]))
|
|
const patch = yield* snapshot.patch(before)
|
|
expect(patch.files).toContain(fwd(tmp.path, "image.png"))
|
|
yield* snapshot.revert([patch])
|
|
expect(yield* exists(`${tmp.path}/image.png`)).toBe(false)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"symlink handling",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.promise(() => fs.symlink(`${tmp.path}/a.txt`, `${tmp.path}/link.txt`, "file"))
|
|
expect((yield* snapshot.patch(before)).files).toContain(fwd(tmp.path, "link.txt"))
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"file under size limit handling",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* write(`${tmp.path}/large.txt`, "x".repeat(1024 * 1024))
|
|
expect((yield* snapshot.patch(before)).files).toContain(fwd(tmp.path, "large.txt"))
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"large added files are skipped",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* write(`${tmp.path}/huge.txt`, new Uint8Array(2 * 1024 * 1024 + 1))
|
|
expect((yield* snapshot.patch(before)).files).toEqual([])
|
|
expect(yield* snapshot.diff(before)).toBe("")
|
|
expect(yield* snapshot.track()).toBe(before)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"nested directory revert",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* mkdirp(`${tmp.path}/level1/level2/level3`)
|
|
yield* write(`${tmp.path}/level1/level2/level3/deep.txt`, "DEEP")
|
|
const patch = yield* snapshot.patch(before)
|
|
yield* snapshot.revert([patch])
|
|
expect(yield* exists(`${tmp.path}/level1/level2/level3/deep.txt`)).toBe(false)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"special characters in filenames",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* write(`${tmp.path}/file with spaces.txt`, "SPACES")
|
|
yield* write(`${tmp.path}/file-with-dashes.txt`, "DASHES")
|
|
yield* write(`${tmp.path}/file_with_underscores.txt`, "UNDERSCORES")
|
|
const files = (yield* snapshot.patch(before)).files
|
|
expect(files).toContain(fwd(tmp.path, "file with spaces.txt"))
|
|
expect(files).toContain(fwd(tmp.path, "file-with-dashes.txt"))
|
|
expect(files).toContain(fwd(tmp.path, "file_with_underscores.txt"))
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"revert with empty patches",
|
|
Effect.gen(function* () {
|
|
yield* bootstrap()
|
|
const snapshot = yield* Snapshot.Service
|
|
yield* snapshot.revert([])
|
|
yield* snapshot.revert([{ hash: "dummy", files: [] }])
|
|
}),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"patch with invalid hash",
|
|
withTrackedSnapshot(({ tmp, snapshot }) =>
|
|
Effect.gen(function* () {
|
|
yield* write(`${tmp.path}/test.txt`, "TEST")
|
|
const patch = yield* snapshot.patch("invalid-hash-12345")
|
|
expect(patch.files).toEqual([])
|
|
expect(patch.hash).toBe("invalid-hash-12345")
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"revert non-existent file",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* snapshot.revert([{ hash: before, files: [`${tmp.path}/nonexistent.txt`] }])
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"unicode filenames",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
const unicodeFiles = [
|
|
{ path: fwd(tmp.path, "文件.txt"), content: "chinese content" },
|
|
{ path: fwd(tmp.path, "🚀rocket.txt"), content: "emoji content" },
|
|
{ path: fwd(tmp.path, "café.txt"), content: "accented content" },
|
|
{ path: fwd(tmp.path, "файл.txt"), content: "cyrillic content" },
|
|
]
|
|
yield* Effect.all(
|
|
unicodeFiles.map((file) => write(file.path, file.content)),
|
|
{ concurrency: "unbounded" },
|
|
)
|
|
const patch = yield* snapshot.patch(before)
|
|
expect(patch.files.length).toBe(4)
|
|
for (const file of unicodeFiles) expect(patch.files).toContain(file.path)
|
|
yield* snapshot.revert([patch])
|
|
for (const file of unicodeFiles) expect(yield* exists(file.path)).toBe(false)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance.skip(
|
|
"unicode filenames modification and restore",
|
|
Effect.gen(function* () {
|
|
const tmp = yield* bootstrap()
|
|
const snapshot = yield* Snapshot.Service
|
|
const chineseFile = fwd(tmp.path, "文件.txt")
|
|
const cyrillicFile = fwd(tmp.path, "файл.txt")
|
|
yield* write(chineseFile, "original chinese")
|
|
yield* write(cyrillicFile, "original cyrillic")
|
|
const before = yield* snapshot.track()
|
|
expect(before).toBeTruthy()
|
|
yield* write(chineseFile, "modified chinese")
|
|
yield* write(cyrillicFile, "modified cyrillic")
|
|
const patch = yield* snapshot.patch(before!)
|
|
expect(patch.files).toContain(chineseFile)
|
|
expect(patch.files).toContain(cyrillicFile)
|
|
yield* snapshot.revert([patch])
|
|
expect(yield* readText(chineseFile)).toBe("original chinese")
|
|
expect(yield* readText(cyrillicFile)).toBe("original cyrillic")
|
|
}),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"unicode filenames in subdirectories",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* mkdirp(`${tmp.path}/目录/подкаталог`)
|
|
const deepFile = fwd(tmp.path, "目录", "подкаталог", "文件.txt")
|
|
yield* write(deepFile, "deep unicode content")
|
|
const patch = yield* snapshot.patch(before)
|
|
expect(patch.files).toContain(deepFile)
|
|
yield* snapshot.revert([patch])
|
|
expect(yield* exists(deepFile)).toBe(false)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"very long filenames",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
const longFile = fwd(tmp.path, `${"a".repeat(200)}.txt`)
|
|
yield* write(longFile, "long filename content")
|
|
const patch = yield* snapshot.patch(before)
|
|
expect(patch.files).toContain(longFile)
|
|
yield* snapshot.revert([patch])
|
|
expect(yield* exists(longFile)).toBe(false)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"hidden files",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* write(`${tmp.path}/.hidden`, "hidden content")
|
|
yield* write(`${tmp.path}/.gitignore`, "*.log")
|
|
yield* write(`${tmp.path}/.config`, "config content")
|
|
const patch = yield* snapshot.patch(before)
|
|
expect(patch.files).toContain(fwd(tmp.path, ".hidden"))
|
|
expect(patch.files).toContain(fwd(tmp.path, ".gitignore"))
|
|
expect(patch.files).toContain(fwd(tmp.path, ".config"))
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"nested symlinks",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* mkdirp(`${tmp.path}/sub/dir`)
|
|
yield* write(`${tmp.path}/sub/dir/target.txt`, "target content")
|
|
yield* Effect.promise(() => fs.symlink(`${tmp.path}/sub/dir/target.txt`, `${tmp.path}/sub/dir/link.txt`, "file"))
|
|
yield* Effect.promise(() => fs.symlink(`${tmp.path}/sub`, `${tmp.path}/sub-link`, "dir"))
|
|
const patch = yield* snapshot.patch(before)
|
|
expect(patch.files).toContain(fwd(tmp.path, "sub", "dir", "link.txt"))
|
|
expect(patch.files).toContain(fwd(tmp.path, "sub-link"))
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"file permissions and ownership changes",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.promise(() => fs.chmod(`${tmp.path}/a.txt`, 0o600))
|
|
yield* Effect.promise(() => fs.chmod(`${tmp.path}/a.txt`, 0o755))
|
|
yield* Effect.promise(() => fs.chmod(`${tmp.path}/a.txt`, 0o644))
|
|
expect((yield* snapshot.patch(before)).files.length).toBe(0)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"circular symlinks",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.promise(() =>
|
|
fs.symlink(`${tmp.path}/circular`, `${tmp.path}/circular`, "dir").catch(() => undefined),
|
|
)
|
|
expect((yield* snapshot.patch(before)).files.length).toBeGreaterThanOrEqual(0)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.live(
|
|
"source project gitignore is respected - ignored files are not snapshotted",
|
|
Effect.gen(function* () {
|
|
const dir = yield* scopedGitTmpdir()
|
|
yield* write(`${dir}/.gitignore`, "*.ignored\nbuild/\nnode_modules/\n")
|
|
yield* write(`${dir}/tracked.txt`, "tracked content")
|
|
yield* write(`${dir}/ignored.ignored`, "ignored content")
|
|
yield* mkdirp(`${dir}/build`)
|
|
yield* write(`${dir}/build/output.js`, "build output")
|
|
yield* write(`${dir}/normal.js`, "normal js")
|
|
yield* exec(dir, ["git", "add", "."])
|
|
yield* exec(dir, ["git", "commit", "-m", "init"])
|
|
yield* Effect.gen(function* () {
|
|
const snapshot = yield* Snapshot.Service
|
|
const before = yield* snapshot.track()
|
|
expect(before).toBeTruthy()
|
|
yield* write(`${dir}/tracked.txt`, "modified tracked")
|
|
yield* write(`${dir}/new.ignored`, "new ignored")
|
|
yield* write(`${dir}/new-tracked.txt`, "new tracked")
|
|
yield* write(`${dir}/build/new-build.js`, "new build file")
|
|
const patch = yield* snapshot.patch(before!)
|
|
expect(patch.files).toContain(fwd(dir, "new-tracked.txt"))
|
|
expect(patch.files).toContain(fwd(dir, "tracked.txt"))
|
|
expect(patch.files).not.toContain(fwd(dir, "new.ignored"))
|
|
expect(patch.files).not.toContain(fwd(dir, "ignored.ignored"))
|
|
expect(patch.files).not.toContain(fwd(dir, "build/output.js"))
|
|
expect(patch.files).not.toContain(fwd(dir, "build/new-build.js"))
|
|
}).pipe(provideInstance(dir))
|
|
}),
|
|
)
|
|
|
|
it.instance(
|
|
"gitignore changes",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* write(`${tmp.path}/.gitignore`, "*.ignored")
|
|
yield* write(`${tmp.path}/test.ignored`, "ignored content")
|
|
yield* write(`${tmp.path}/normal.txt`, "normal content")
|
|
const patch = yield* snapshot.patch(before)
|
|
expect(patch.files).toContain(fwd(tmp.path, ".gitignore"))
|
|
expect(patch.files).toContain(fwd(tmp.path, "normal.txt"))
|
|
expect(patch.files).not.toContain(fwd(tmp.path, "test.ignored"))
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"files tracked in snapshot but now gitignored are filtered out",
|
|
Effect.gen(function* () {
|
|
const tmp = yield* bootstrap()
|
|
const snapshot = yield* Snapshot.Service
|
|
yield* write(`${tmp.path}/later-ignored.txt`, "initial content")
|
|
const before = yield* snapshot.track()
|
|
expect(before).toBeTruthy()
|
|
yield* write(`${tmp.path}/later-ignored.txt`, "modified content")
|
|
yield* write(`${tmp.path}/.gitignore`, "later-ignored.txt\n")
|
|
yield* write(`${tmp.path}/still-tracked.txt`, "new tracked file")
|
|
const patch = yield* snapshot.patch(before!)
|
|
expect(patch.files).not.toContain(fwd(tmp.path, "later-ignored.txt"))
|
|
expect(patch.files).toContain(fwd(tmp.path, ".gitignore"))
|
|
expect(patch.files).toContain(fwd(tmp.path, "still-tracked.txt"))
|
|
}),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"gitignore updated between track calls filters from diff",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* write(`${tmp.path}/a.txt`, "modified content")
|
|
yield* write(`${tmp.path}/.gitignore`, "a.txt\n")
|
|
yield* write(`${tmp.path}/b.txt`, "also modified")
|
|
const after = yield* snapshot.track()
|
|
expect(after).toBeTruthy()
|
|
const diffs = yield* snapshot.diffFull(before, after!)
|
|
expect(diffs.some((x) => x.file === "a.txt")).toBe(false)
|
|
expect(diffs.some((x) => x.file === ".gitignore")).toBe(true)
|
|
expect(diffs.some((x) => x.file === "b.txt")).toBe(true)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"git info exclude changes",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
const file = `${tmp.path}/.git/info/exclude`
|
|
yield* write(file, `${(yield* Effect.promise(() => Bun.file(file).text())).trimEnd()}\nignored.txt\n`)
|
|
yield* write(`${tmp.path}/ignored.txt`, "ignored content")
|
|
yield* write(`${tmp.path}/normal.txt`, "normal content")
|
|
const patch = yield* snapshot.patch(before)
|
|
expect(patch.files).toContain(fwd(tmp.path, "normal.txt"))
|
|
expect(patch.files).not.toContain(fwd(tmp.path, "ignored.txt"))
|
|
const after = yield* snapshot.track()
|
|
const diffs = yield* snapshot.diffFull(before, after!)
|
|
expect(diffs.some((x) => x.file === "normal.txt")).toBe(true)
|
|
expect(diffs.some((x) => x.file === "ignored.txt")).toBe(false)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"git info exclude keeps global excludes",
|
|
Effect.gen(function* () {
|
|
const tmp = yield* bootstrap()
|
|
const global = `${tmp.path}/global.ignore`
|
|
const config = `${tmp.path}/global.gitconfig`
|
|
yield* write(global, "global.tmp\n")
|
|
yield* write(config, `[core]\n\texcludesFile = ${global.replaceAll("\\", "/")}\n`)
|
|
yield* withGitConfigGlobal(
|
|
config,
|
|
Effect.gen(function* () {
|
|
const snapshot = yield* Snapshot.Service
|
|
const before = yield* snapshot.track()
|
|
expect(before).toBeTruthy()
|
|
const file = `${tmp.path}/.git/info/exclude`
|
|
yield* write(file, `${(yield* Effect.promise(() => Bun.file(file).text())).trimEnd()}\ninfo.tmp\n`)
|
|
yield* write(`${tmp.path}/global.tmp`, "global content")
|
|
yield* write(`${tmp.path}/info.tmp`, "info content")
|
|
yield* write(`${tmp.path}/normal.txt`, "normal content")
|
|
const patch = yield* snapshot.patch(before!)
|
|
expect(patch.files).toContain(fwd(tmp.path, "normal.txt"))
|
|
expect(patch.files).not.toContain(fwd(tmp.path, "global.tmp"))
|
|
expect(patch.files).not.toContain(fwd(tmp.path, "info.tmp"))
|
|
}),
|
|
)
|
|
}),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"concurrent file operations during patch",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
const fiber = yield* Effect.gen(function* () {
|
|
for (let i = 0; i < 10; i++) {
|
|
yield* write(`${tmp.path}/concurrent${i}.txt`, `concurrent${i}`)
|
|
yield* Effect.sleep("1 millis")
|
|
}
|
|
}).pipe(Effect.forkScoped)
|
|
const patch = yield* snapshot.patch(before)
|
|
yield* Fiber.join(fiber)
|
|
expect(patch.files.length).toBeGreaterThanOrEqual(0)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.live(
|
|
"snapshot state isolation between projects",
|
|
Effect.gen(function* () {
|
|
const tmp1 = yield* bootstrapScoped()
|
|
const tmp2 = yield* bootstrapScoped()
|
|
yield* Effect.gen(function* () {
|
|
const snapshot = yield* Snapshot.Service
|
|
const before1 = yield* snapshot.track()
|
|
yield* write(`${tmp1.path}/project1.txt`, "project1 content")
|
|
const patch1 = yield* snapshot.patch(before1!)
|
|
expect(patch1.files).toContain(fwd(tmp1.path, "project1.txt"))
|
|
}).pipe(provideInstance(tmp1.path))
|
|
yield* Effect.gen(function* () {
|
|
const snapshot = yield* Snapshot.Service
|
|
const before2 = yield* snapshot.track()
|
|
yield* write(`${tmp2.path}/project2.txt`, "project2 content")
|
|
const patch2 = yield* snapshot.patch(before2!)
|
|
expect(patch2.files).toContain(fwd(tmp2.path, "project2.txt"))
|
|
expect(patch2.files).not.toContain(fwd(tmp1.path, "project1.txt"))
|
|
}).pipe(provideInstance(tmp2.path))
|
|
}),
|
|
)
|
|
|
|
it.live(
|
|
"patch detects changes in secondary worktree",
|
|
Effect.gen(function* () {
|
|
const tmp = yield* bootstrapScoped()
|
|
const worktreePath = `${tmp.path}-worktree`
|
|
yield* exec(tmp.path, ["git", "worktree", "add", worktreePath, "HEAD"])
|
|
yield* Effect.addFinalizer(() => cleanupWorktree(tmp.path, worktreePath))
|
|
yield* Effect.gen(function* () {
|
|
const snapshot = yield* Snapshot.Service
|
|
expect(yield* snapshot.track()).toBeTruthy()
|
|
}).pipe(provideInstance(tmp.path))
|
|
yield* Effect.gen(function* () {
|
|
const snapshot = yield* Snapshot.Service
|
|
const before = yield* snapshot.track()
|
|
expect(before).toBeTruthy()
|
|
const worktreeFile = fwd(worktreePath, "worktree.txt")
|
|
yield* write(worktreeFile, "worktree content")
|
|
expect((yield* snapshot.patch(before!)).files).toContain(worktreeFile)
|
|
}).pipe(provideInstance(worktreePath))
|
|
}),
|
|
)
|
|
|
|
it.live(
|
|
"revert only removes files in invoking worktree",
|
|
Effect.gen(function* () {
|
|
const tmp = yield* bootstrapScoped()
|
|
const worktreePath = `${tmp.path}-worktree`
|
|
const primaryFile = `${tmp.path}/worktree.txt`
|
|
yield* exec(tmp.path, ["git", "worktree", "add", worktreePath, "HEAD"])
|
|
yield* Effect.addFinalizer(() => cleanupWorktree(tmp.path, worktreePath, [primaryFile]))
|
|
yield* Effect.gen(function* () {
|
|
const snapshot = yield* Snapshot.Service
|
|
expect(yield* snapshot.track()).toBeTruthy()
|
|
}).pipe(provideInstance(tmp.path))
|
|
yield* write(primaryFile, "primary content")
|
|
yield* Effect.gen(function* () {
|
|
const snapshot = yield* Snapshot.Service
|
|
const before = yield* snapshot.track()
|
|
expect(before).toBeTruthy()
|
|
const worktreeFile = fwd(worktreePath, "worktree.txt")
|
|
yield* write(worktreeFile, "worktree content")
|
|
const patch = yield* snapshot.patch(before!)
|
|
yield* snapshot.revert([patch])
|
|
expect(yield* exists(worktreeFile)).toBe(false)
|
|
}).pipe(provideInstance(worktreePath))
|
|
expect(yield* readText(primaryFile)).toBe("primary content")
|
|
}),
|
|
)
|
|
|
|
it.live(
|
|
"diff reports worktree-only/shared edits and ignores primary-only",
|
|
Effect.gen(function* () {
|
|
const tmp = yield* bootstrapScoped()
|
|
const worktreePath = `${tmp.path}-worktree`
|
|
yield* exec(tmp.path, ["git", "worktree", "add", worktreePath, "HEAD"])
|
|
yield* Effect.addFinalizer(() =>
|
|
cleanupWorktree(tmp.path, worktreePath, [`${tmp.path}/shared.txt`, `${tmp.path}/primary-only.txt`]),
|
|
)
|
|
yield* Effect.gen(function* () {
|
|
const snapshot = yield* Snapshot.Service
|
|
expect(yield* snapshot.track()).toBeTruthy()
|
|
}).pipe(provideInstance(tmp.path))
|
|
yield* Effect.gen(function* () {
|
|
const snapshot = yield* Snapshot.Service
|
|
const before = yield* snapshot.track()
|
|
expect(before).toBeTruthy()
|
|
yield* write(`${worktreePath}/worktree-only.txt`, "worktree diff content")
|
|
yield* write(`${worktreePath}/shared.txt`, "worktree edit")
|
|
yield* write(`${tmp.path}/shared.txt`, "primary edit")
|
|
yield* write(`${tmp.path}/primary-only.txt`, "primary change")
|
|
const diff = yield* snapshot.diff(before!)
|
|
expect(diff).toContain("worktree-only.txt")
|
|
expect(diff).toContain("shared.txt")
|
|
expect(diff).not.toContain("primary-only.txt")
|
|
}).pipe(provideInstance(worktreePath))
|
|
}),
|
|
)
|
|
|
|
it.instance(
|
|
"track with no changes returns same hash",
|
|
withTrackedSnapshot(({ snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
expect(yield* snapshot.track()).toBe(before)
|
|
expect(yield* snapshot.track()).toBe(before)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"diff function with various changes",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* rm(`${tmp.path}/a.txt`)
|
|
yield* write(`${tmp.path}/new.txt`, "new content")
|
|
yield* write(`${tmp.path}/b.txt`, "modified content")
|
|
const diff = yield* snapshot.diff(before)
|
|
expect(diff).toContain("a.txt")
|
|
expect(diff).toContain("b.txt")
|
|
expect(diff).toContain("new.txt")
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"restore function",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* rm(`${tmp.path}/a.txt`)
|
|
yield* write(`${tmp.path}/new.txt`, "new content")
|
|
yield* write(`${tmp.path}/b.txt`, "modified")
|
|
yield* snapshot.restore(before)
|
|
expect(yield* exists(`${tmp.path}/a.txt`)).toBe(true)
|
|
expect(yield* readText(`${tmp.path}/a.txt`)).toBe(tmp.extra.aContent)
|
|
expect(yield* exists(`${tmp.path}/new.txt`)).toBe(true)
|
|
expect(yield* readText(`${tmp.path}/b.txt`)).toBe(tmp.extra.bContent)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"revert should not delete files that existed but were deleted in snapshot",
|
|
Effect.gen(function* () {
|
|
const tmp = yield* bootstrap()
|
|
const snapshot = yield* Snapshot.Service
|
|
const snapshot1 = yield* snapshot.track()
|
|
expect(snapshot1).toBeTruthy()
|
|
yield* rm(`${tmp.path}/a.txt`)
|
|
const snapshot2 = yield* snapshot.track()
|
|
expect(snapshot2).toBeTruthy()
|
|
yield* write(`${tmp.path}/a.txt`, "recreated content")
|
|
const patch = yield* snapshot.patch(snapshot2!)
|
|
expect(patch.files).toContain(fwd(tmp.path, "a.txt"))
|
|
yield* snapshot.revert([patch])
|
|
expect(yield* exists(`${tmp.path}/a.txt`)).toBe(false)
|
|
}),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"revert preserves file that existed in snapshot when deleted then recreated",
|
|
Effect.gen(function* () {
|
|
const tmp = yield* bootstrap()
|
|
const snapshot = yield* Snapshot.Service
|
|
yield* write(`${tmp.path}/existing.txt`, "original content")
|
|
const hash = yield* snapshot.track()
|
|
expect(hash).toBeTruthy()
|
|
yield* rm(`${tmp.path}/existing.txt`)
|
|
yield* write(`${tmp.path}/existing.txt`, "recreated")
|
|
yield* write(`${tmp.path}/newfile.txt`, "new")
|
|
const patch = yield* snapshot.patch(hash!)
|
|
expect(patch.files).toContain(fwd(tmp.path, "existing.txt"))
|
|
expect(patch.files).toContain(fwd(tmp.path, "newfile.txt"))
|
|
yield* snapshot.revert([patch])
|
|
expect(yield* exists(`${tmp.path}/newfile.txt`)).toBe(false)
|
|
expect(yield* exists(`${tmp.path}/existing.txt`)).toBe(true)
|
|
expect(yield* readText(`${tmp.path}/existing.txt`)).toBe("original content")
|
|
}),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"diffFull sets status based on git change type",
|
|
Effect.gen(function* () {
|
|
const tmp = yield* bootstrap()
|
|
const snapshot = yield* Snapshot.Service
|
|
yield* write(`${tmp.path}/grow.txt`, "one\n")
|
|
yield* write(`${tmp.path}/trim.txt`, "line1\nline2\n")
|
|
yield* write(`${tmp.path}/delete.txt`, "gone")
|
|
const before = yield* snapshot.track()
|
|
expect(before).toBeTruthy()
|
|
yield* write(`${tmp.path}/grow.txt`, "one\ntwo\n")
|
|
yield* write(`${tmp.path}/trim.txt`, "line1\n")
|
|
yield* rm(`${tmp.path}/delete.txt`)
|
|
yield* write(`${tmp.path}/added.txt`, "new")
|
|
const after = yield* snapshot.track()
|
|
expect(after).toBeTruthy()
|
|
const diffs = yield* snapshot.diffFull(before!, after!)
|
|
expect(diffs.length).toBe(4)
|
|
expect(diffs.find((d) => d.file === "added.txt")!.status).toBe("added")
|
|
expect(diffs.find((d) => d.file === "delete.txt")!.status).toBe("deleted")
|
|
const grow = diffs.find((d) => d.file === "grow.txt")!
|
|
expect(grow.status).toBe("modified")
|
|
expect(grow.additions).toBeGreaterThan(0)
|
|
expect(grow.deletions).toBe(0)
|
|
const trim = diffs.find((d) => d.file === "trim.txt")!
|
|
expect(trim.status).toBe("modified")
|
|
expect(trim.additions).toBe(0)
|
|
expect(trim.deletions).toBeGreaterThan(0)
|
|
}),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"diffFull with new file additions",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* write(`${tmp.path}/new.txt`, "new content")
|
|
const after = yield* snapshot.track()
|
|
expect(after).toBeTruthy()
|
|
const diffs = yield* snapshot.diffFull(before, after!)
|
|
expect(diffs.length).toBe(1)
|
|
expect(diffs[0].file).toBe("new.txt")
|
|
expect(diffs[0].patch).toContain("+new content")
|
|
expect(diffs[0].additions).toBe(1)
|
|
expect(diffs[0].deletions).toBe(0)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"diffFull with a large interleaved mixed diff",
|
|
Effect.gen(function* () {
|
|
const tmp = yield* bootstrap()
|
|
const snapshot = yield* Snapshot.Service
|
|
const ids = Array.from({ length: MIXED_BATCH_GROUP_COUNT }, (_, i) => i.toString().padStart(3, "0"))
|
|
const mod = ids.map((id) => fwd(tmp.path, "mix", `${id}-mod.txt`))
|
|
const del = ids.map((id) => fwd(tmp.path, "mix", `${id}-del.txt`))
|
|
const add = ids.map((id) => fwd(tmp.path, "mix", `${id}-add.txt`))
|
|
const bin = ids.map((id) => fwd(tmp.path, "mix", `${id}-bin.bin`))
|
|
yield* mkdirp(`${tmp.path}/mix`)
|
|
yield* Effect.all(
|
|
[
|
|
...mod.map((file, i) => write(file, `before-${ids[i]}-é\n🙂\nline`)),
|
|
...del.map((file, i) => write(file, `gone-${ids[i]}\n你好`)),
|
|
...bin.map((file, i) => write(file, new Uint8Array([0, i, 255, i % 251]))),
|
|
],
|
|
{ concurrency: "unbounded" },
|
|
)
|
|
const before = yield* snapshot.track()
|
|
expect(before).toBeTruthy()
|
|
yield* Effect.all(
|
|
[
|
|
...mod.map((file, i) => write(file, `after-${ids[i]}-é\n🚀\nline`)),
|
|
...add.map((file, i) => write(file, `new-${ids[i]}\nこんにちは`)),
|
|
...bin.map((file, i) => write(file, new Uint8Array([9, i, 8, i % 251]))),
|
|
...del.map((file) => rm(file)),
|
|
],
|
|
{ concurrency: "unbounded" },
|
|
)
|
|
const after = yield* snapshot.track()
|
|
expect(after).toBeTruthy()
|
|
const diffs = yield* snapshot.diffFull(before!, after!)
|
|
expect(diffs).toHaveLength(ids.length * 4)
|
|
const map = new Map(diffs.map((item) => [item.file, item]))
|
|
for (let i = 0; i < ids.length; i++) {
|
|
const m = map.get(fwd("mix", `${ids[i]}-mod.txt`))
|
|
expect(m).toBeDefined()
|
|
expect(m!.patch).toContain(`-before-${ids[i]}-é`)
|
|
expect(m!.patch).toContain(`+after-${ids[i]}-é`)
|
|
expect(m!.status).toBe("modified")
|
|
const d = map.get(fwd("mix", `${ids[i]}-del.txt`))
|
|
expect(d).toBeDefined()
|
|
expect(d!.patch).toContain(`-gone-${ids[i]}`)
|
|
expect(d!.status).toBe("deleted")
|
|
const a = map.get(fwd("mix", `${ids[i]}-add.txt`))
|
|
expect(a).toBeDefined()
|
|
expect(a!.patch).toContain(`+new-${ids[i]}`)
|
|
expect(a!.status).toBe("added")
|
|
const b = map.get(fwd("mix", `${ids[i]}-bin.bin`))
|
|
expect(b).toBeDefined()
|
|
expect(b!.patch).toBe("")
|
|
expect(b!.additions).toBe(0)
|
|
expect(b!.deletions).toBe(0)
|
|
expect(b!.status).toBe("modified")
|
|
}
|
|
}),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"diffFull preserves git diff order across batch boundaries",
|
|
Effect.gen(function* () {
|
|
const tmp = yield* bootstrap()
|
|
const snapshot = yield* Snapshot.Service
|
|
const ids = Array.from({ length: OVER_BATCH_COUNT }, (_, i) => i.toString().padStart(3, "0"))
|
|
yield* mkdirp(`${tmp.path}/order`)
|
|
yield* Effect.all(
|
|
ids.map((id) => write(`${tmp.path}/order/${id}.txt`, `before-${id}`)),
|
|
{ concurrency: "unbounded" },
|
|
)
|
|
const before = yield* snapshot.track()
|
|
expect(before).toBeTruthy()
|
|
yield* Effect.all(
|
|
ids.map((id) => write(`${tmp.path}/order/${id}.txt`, `after-${id}`)),
|
|
{ concurrency: "unbounded" },
|
|
)
|
|
const after = yield* snapshot.track()
|
|
expect(after).toBeTruthy()
|
|
expect((yield* snapshot.diffFull(before!, after!)).map((item) => item.file)).toEqual(
|
|
ids.map((id) => `order/${id}.txt`),
|
|
)
|
|
}),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"diffFull with file modifications",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* write(`${tmp.path}/b.txt`, "modified content")
|
|
const after = yield* snapshot.track()
|
|
expect(after).toBeTruthy()
|
|
const diffs = yield* snapshot.diffFull(before, after!)
|
|
expect(diffs.length).toBe(1)
|
|
expect(diffs[0].file).toBe("b.txt")
|
|
expect(diffs[0].patch).toContain(`-${tmp.extra.bContent}`)
|
|
expect(diffs[0].patch).toContain("+modified content")
|
|
expect(diffs[0].additions).toBeGreaterThan(0)
|
|
expect(diffs[0].deletions).toBeGreaterThan(0)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"diffFull with file deletions",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* rm(`${tmp.path}/a.txt`)
|
|
const after = yield* snapshot.track()
|
|
expect(after).toBeTruthy()
|
|
const diffs = yield* snapshot.diffFull(before, after!)
|
|
expect(diffs.length).toBe(1)
|
|
expect(diffs[0].file).toBe("a.txt")
|
|
expect(diffs[0].patch).toContain(`-${tmp.extra.aContent}`)
|
|
expect(diffs[0].additions).toBe(0)
|
|
expect(diffs[0].deletions).toBe(1)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"diffFull with multiple line additions",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* write(`${tmp.path}/multi.txt`, "line1\nline2\nline3")
|
|
const after = yield* snapshot.track()
|
|
expect(after).toBeTruthy()
|
|
const diffs = yield* snapshot.diffFull(before, after!)
|
|
expect(diffs.length).toBe(1)
|
|
expect(diffs[0].file).toBe("multi.txt")
|
|
expect(diffs[0].patch).toContain("+line1")
|
|
expect(diffs[0].patch).toContain("+line3")
|
|
expect(diffs[0].additions).toBe(3)
|
|
expect(diffs[0].deletions).toBe(0)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"diffFull with addition and deletion",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* write(`${tmp.path}/added.txt`, "added content")
|
|
yield* rm(`${tmp.path}/a.txt`)
|
|
const after = yield* snapshot.track()
|
|
expect(after).toBeTruthy()
|
|
const diffs = yield* snapshot.diffFull(before, after!)
|
|
expect(diffs.length).toBe(2)
|
|
const added = diffs.find((d) => d.file === "added.txt")!
|
|
expect(added.patch).toContain("+added content")
|
|
expect(added.additions).toBe(1)
|
|
expect(added.deletions).toBe(0)
|
|
const removed = diffs.find((d) => d.file === "a.txt")!
|
|
expect(removed.patch).toContain(`-${tmp.extra.aContent}`)
|
|
expect(removed.additions).toBe(0)
|
|
expect(removed.deletions).toBe(1)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"diffFull with multiple additions and deletions",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* write(`${tmp.path}/multi1.txt`, "line1\nline2\nline3")
|
|
yield* write(`${tmp.path}/multi2.txt`, "single line")
|
|
yield* rm(`${tmp.path}/a.txt`)
|
|
yield* rm(`${tmp.path}/b.txt`)
|
|
const after = yield* snapshot.track()
|
|
expect(after).toBeTruthy()
|
|
const diffs = yield* snapshot.diffFull(before, after!)
|
|
expect(diffs.length).toBe(4)
|
|
expect(diffs.find((d) => d.file === "multi1.txt")!.additions).toBe(3)
|
|
expect(diffs.find((d) => d.file === "multi1.txt")!.deletions).toBe(0)
|
|
expect(diffs.find((d) => d.file === "multi2.txt")!.additions).toBe(1)
|
|
expect(diffs.find((d) => d.file === "multi2.txt")!.deletions).toBe(0)
|
|
expect(diffs.find((d) => d.file === "a.txt")!.additions).toBe(0)
|
|
expect(diffs.find((d) => d.file === "a.txt")!.deletions).toBe(1)
|
|
expect(diffs.find((d) => d.file === "b.txt")!.additions).toBe(0)
|
|
expect(diffs.find((d) => d.file === "b.txt")!.deletions).toBe(1)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"diffFull with no changes",
|
|
withTrackedSnapshot(({ snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
const after = yield* snapshot.track()
|
|
expect(after).toBeTruthy()
|
|
expect((yield* snapshot.diffFull(before, after!)).length).toBe(0)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"diffFull with binary file changes",
|
|
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
|
Effect.gen(function* () {
|
|
yield* write(`${tmp.path}/binary.bin`, new Uint8Array([0x00, 0x01, 0x02, 0x03]))
|
|
const after = yield* snapshot.track()
|
|
expect(after).toBeTruthy()
|
|
const diffs = yield* snapshot.diffFull(before, after!)
|
|
expect(diffs.length).toBe(1)
|
|
expect(diffs[0].file).toBe("binary.bin")
|
|
expect(diffs[0].patch).toBe("")
|
|
}),
|
|
),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"diffFull with whitespace changes",
|
|
Effect.gen(function* () {
|
|
const tmp = yield* bootstrap()
|
|
const snapshot = yield* Snapshot.Service
|
|
yield* write(`${tmp.path}/whitespace.txt`, "line1\nline2")
|
|
const before = yield* snapshot.track()
|
|
expect(before).toBeTruthy()
|
|
yield* write(`${tmp.path}/whitespace.txt`, "line1\n\nline2\n")
|
|
const after = yield* snapshot.track()
|
|
expect(after).toBeTruthy()
|
|
const diffs = yield* snapshot.diffFull(before!, after!)
|
|
expect(diffs.length).toBe(1)
|
|
expect(diffs[0].file).toBe("whitespace.txt")
|
|
expect(diffs[0].additions).toBeGreaterThan(0)
|
|
}),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"revert with overlapping files across patches uses first patch hash",
|
|
Effect.gen(function* () {
|
|
const tmp = yield* bootstrap()
|
|
const snapshot = yield* Snapshot.Service
|
|
yield* write(`${tmp.path}/shared.txt`, "v1")
|
|
const snap1 = yield* snapshot.track()
|
|
expect(snap1).toBeTruthy()
|
|
yield* write(`${tmp.path}/shared.txt`, "v2")
|
|
const snap2 = yield* snapshot.track()
|
|
expect(snap2).toBeTruthy()
|
|
yield* write(`${tmp.path}/shared.txt`, "v3")
|
|
const patch1 = yield* snapshot.patch(snap1!)
|
|
const patch2 = yield* snapshot.patch(snap2!)
|
|
expect(patch1.files).toContain(fwd(tmp.path, "shared.txt"))
|
|
expect(patch2.files).toContain(fwd(tmp.path, "shared.txt"))
|
|
yield* snapshot.revert([patch1, patch2])
|
|
expect(yield* readText(`${tmp.path}/shared.txt`)).toBe("v1")
|
|
}),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"revert preserves patch order when the same hash appears again",
|
|
Effect.gen(function* () {
|
|
const tmp = yield* bootstrap()
|
|
const snapshot = yield* Snapshot.Service
|
|
yield* mkdirp(`${tmp.path}/foo`)
|
|
yield* write(`${tmp.path}/foo/bar`, "v1")
|
|
yield* write(`${tmp.path}/a.txt`, "v1")
|
|
const snap1 = yield* snapshot.track()
|
|
expect(snap1).toBeTruthy()
|
|
yield* rm(`${tmp.path}/foo`)
|
|
yield* write(`${tmp.path}/foo`, "v2")
|
|
yield* write(`${tmp.path}/a.txt`, "v2")
|
|
const snap2 = yield* snapshot.track()
|
|
expect(snap2).toBeTruthy()
|
|
yield* rm(`${tmp.path}/foo`)
|
|
yield* write(`${tmp.path}/a.txt`, "v3")
|
|
yield* snapshot.revert([
|
|
{ hash: snap1!, files: [fwd(tmp.path, "a.txt")] },
|
|
{ hash: snap2!, files: [fwd(tmp.path, "foo")] },
|
|
{ hash: snap1!, files: [fwd(tmp.path, "foo", "bar")] },
|
|
])
|
|
expect(yield* readText(`${tmp.path}/a.txt`)).toBe("v1")
|
|
expect((yield* Effect.promise(() => fs.stat(`${tmp.path}/foo`))).isDirectory()).toBe(true)
|
|
expect(yield* readText(`${tmp.path}/foo/bar`)).toBe("v1")
|
|
}),
|
|
{ git: true },
|
|
)
|
|
|
|
it.instance(
|
|
"revert handles large mixed batches across chunk boundaries",
|
|
Effect.gen(function* () {
|
|
const tmp = yield* bootstrap()
|
|
const snapshot = yield* Snapshot.Service
|
|
const base = Array.from({ length: OVER_BATCH_COUNT }, (_, i) => fwd(tmp.path, "batch", `${i}.txt`))
|
|
const fresh = [fwd(tmp.path, "fresh", "0.txt")]
|
|
yield* mkdirp(`${tmp.path}/batch`)
|
|
yield* mkdirp(`${tmp.path}/fresh`)
|
|
yield* Effect.all(
|
|
base.map((file, i) => write(file, `base-${i}`)),
|
|
{ concurrency: "unbounded" },
|
|
)
|
|
const snap = yield* snapshot.track()
|
|
expect(snap).toBeTruthy()
|
|
yield* Effect.all(
|
|
[...base.map((file, i) => write(file, `next-${i}`)), ...fresh.map((file, i) => write(file, `fresh-${i}`))],
|
|
{ concurrency: "unbounded" },
|
|
)
|
|
const patch = yield* snapshot.patch(snap!)
|
|
expect(patch.files.length).toBe(base.length + fresh.length)
|
|
yield* snapshot.revert([patch])
|
|
for (let i = 0; i < base.length; i++) expect(yield* readText(base[i])).toBe(`base-${i}`)
|
|
for (const file of fresh) expect(yield* exists(file)).toBe(false)
|
|
}),
|
|
{ git: true },
|
|
)
|