mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge branch 'main' into feat/help-all-command
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "7.1.23",
|
||||
"version": "7.2.0",
|
||||
"name": "@kilocode/cli",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -31,8 +31,6 @@ export namespace FileIgnore {
|
||||
"mypy_cache",
|
||||
".history",
|
||||
".gradle",
|
||||
".kilocode", // kilocode_change — ignore legacy local storage (#8379)
|
||||
".opencode", // kilocode_change — ignore legacy local storage (#8379)
|
||||
])
|
||||
|
||||
const FILES = [
|
||||
|
||||
@@ -10,12 +10,14 @@ import { Config } from "../config/config"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Scheduler } from "../scheduler"
|
||||
import * as KiloSnapshot from "../kilocode/snapshot" // kilocode_change
|
||||
import { Lock } from "../util/lock" // kilocode_change
|
||||
|
||||
export namespace Snapshot {
|
||||
const log = Log.create({ service: "snapshot" })
|
||||
const hour = 60 * 60 * 1000
|
||||
const prune = "7.days"
|
||||
export const MAX_DIFF_SIZE = 256 * 1024 // kilocode_change
|
||||
const MAX_SNAPSHOT_FILE_SIZE = 2 * 1024 * 1024 // kilocode_change — skip files >2MB during snapshot add
|
||||
|
||||
export function init() {
|
||||
Scheduler.register({
|
||||
@@ -36,6 +38,7 @@ export namespace Snapshot {
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!exists) return
|
||||
using _lock = await Lock.write(git) // kilocode_change
|
||||
const result =
|
||||
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}`
|
||||
.quiet()
|
||||
@@ -57,6 +60,7 @@ export namespace Snapshot {
|
||||
const cfg = await Config.get()
|
||||
if (cfg.snapshot === false) return
|
||||
const git = await KiloSnapshot.prepare() // kilocode_change
|
||||
using _lock = await Lock.write(git) // kilocode_change
|
||||
await add(git)
|
||||
const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree`
|
||||
.quiet()
|
||||
@@ -75,6 +79,7 @@ export namespace Snapshot {
|
||||
|
||||
export async function patch(hash: string): Promise<Patch> {
|
||||
const git = await KiloSnapshot.prepare() // kilocode_change
|
||||
using _lock = await Lock.write(git) // kilocode_change
|
||||
await add(git)
|
||||
const result =
|
||||
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .`
|
||||
@@ -103,6 +108,7 @@ export namespace Snapshot {
|
||||
export async function restore(snapshot: string) {
|
||||
log.info("restore", { commit: snapshot })
|
||||
const git = await KiloSnapshot.prepare() // kilocode_change
|
||||
using _lock = await Lock.write(git) // kilocode_change
|
||||
const result =
|
||||
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f`
|
||||
.quiet()
|
||||
@@ -119,41 +125,136 @@ export namespace Snapshot {
|
||||
}
|
||||
}
|
||||
|
||||
export async function revert(patches: Patch[]) {
|
||||
const files = new Set<string>()
|
||||
for (const item of patches) {
|
||||
const git = await KiloSnapshot.prepare() // kilocode_change
|
||||
for (const file of item.files) {
|
||||
if (files.has(file)) continue
|
||||
log.info("reverting", { file, hash: item.hash })
|
||||
const result =
|
||||
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}`
|
||||
.quiet()
|
||||
.cwd(Instance.worktree)
|
||||
.nothrow()
|
||||
if (result.exitCode !== 0) {
|
||||
const relativePath = path.relative(Instance.worktree, file)
|
||||
const checkTree =
|
||||
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}`
|
||||
.quiet()
|
||||
.cwd(Instance.worktree)
|
||||
.nothrow()
|
||||
if (checkTree.exitCode === 0 && checkTree.text().trim()) {
|
||||
log.info("file existed in snapshot but checkout failed, keeping", {
|
||||
file,
|
||||
})
|
||||
} else {
|
||||
log.info("file did not exist in snapshot, deleting", { file })
|
||||
await fs.unlink(file).catch(() => {})
|
||||
}
|
||||
}
|
||||
files.add(file)
|
||||
// kilocode_change start — batched revert: group up to 100 files per git checkout (port of upstream #20564)
|
||||
type RevertOp = { hash: string; file: string; rel: string }
|
||||
|
||||
/** Revert a single file: checkout from snapshot or delete if it didn't exist. */
|
||||
async function revertSingle(git: string, worktree: string, op: RevertOp) {
|
||||
log.info("reverting", { file: op.file, hash: op.hash })
|
||||
const result =
|
||||
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${worktree} checkout ${op.hash} -- ${op.file}`
|
||||
.quiet()
|
||||
.cwd(worktree)
|
||||
.nothrow()
|
||||
if (result.exitCode === 0) return
|
||||
const tree =
|
||||
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${worktree} ls-tree ${op.hash} -- ${op.rel}`
|
||||
.quiet()
|
||||
.cwd(worktree)
|
||||
.nothrow()
|
||||
if (tree.exitCode === 0 && tree.text().trim()) {
|
||||
log.info("file existed in snapshot but checkout failed, keeping", { file: op.file, hash: op.hash })
|
||||
return
|
||||
}
|
||||
log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash })
|
||||
await fs.unlink(op.file).catch(() => {})
|
||||
}
|
||||
|
||||
/** Revert a batch of files sharing the same hash. Falls back to single-file on failure. */
|
||||
async function revertBatch(git: string, worktree: string, batch: RevertOp[]) {
|
||||
const hash = batch[0]!.hash
|
||||
|
||||
// Check which files exist in the snapshot
|
||||
const tree =
|
||||
await $`git -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${worktree} ls-tree --name-only ${hash} -- ${batch.map((op) => op.rel)}`
|
||||
.quiet()
|
||||
.cwd(worktree)
|
||||
.nothrow()
|
||||
|
||||
if (tree.exitCode !== 0) {
|
||||
log.info("batched ls-tree failed, falling back to single-file revert", { hash, files: batch.length })
|
||||
for (const op of batch) await revertSingle(git, worktree, op)
|
||||
return
|
||||
}
|
||||
|
||||
const existing = new Set(tree.text().trim().split("\n").map((l) => l.trim()).filter(Boolean))
|
||||
|
||||
// Checkout files that exist in the snapshot
|
||||
const toCheckout = batch.filter((op) => existing.has(op.rel))
|
||||
if (toCheckout.length) {
|
||||
log.info("reverting", { hash, files: toCheckout.length })
|
||||
const result =
|
||||
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${worktree} checkout ${hash} -- ${toCheckout.map((op) => op.file)}`
|
||||
.quiet()
|
||||
.cwd(worktree)
|
||||
.nothrow()
|
||||
if (result.exitCode !== 0) {
|
||||
log.info("batched checkout failed, falling back to single-file revert", { hash, files: toCheckout.length })
|
||||
for (const op of batch) await revertSingle(git, worktree, op)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Delete files that didn't exist in the snapshot
|
||||
for (const op of batch) {
|
||||
if (existing.has(op.rel)) continue
|
||||
log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash })
|
||||
await fs.unlink(op.file).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/** True when one path is a parent of the other (e.g. "a/b" and "a/b/c"). */
|
||||
function pathsClash(a: string, b: string) {
|
||||
return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)
|
||||
}
|
||||
|
||||
/** Can this op be added to the current batch? */
|
||||
function canBatch(batch: RevertOp[], op: RevertOp): boolean {
|
||||
if (batch.length >= 100) return false
|
||||
if (op.hash !== batch[0]!.hash) return false
|
||||
if (batch.some((existing) => pathsClash(existing.rel, op.rel))) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Group consecutive ops into batches that share the same hash,
|
||||
* have no path conflicts, and contain at most 100 files each.
|
||||
*/
|
||||
function groupIntoBatches(ops: RevertOp[]): RevertOp[][] {
|
||||
const batches: RevertOp[][] = []
|
||||
let batch: RevertOp[] = []
|
||||
|
||||
for (const op of ops) {
|
||||
if (batch.length > 0 && !canBatch(batch, op)) {
|
||||
batches.push(batch)
|
||||
batch = []
|
||||
}
|
||||
batch.push(op)
|
||||
}
|
||||
if (batch.length > 0) batches.push(batch)
|
||||
|
||||
return batches
|
||||
}
|
||||
|
||||
export async function revert(patches: Patch[]) {
|
||||
const git = await KiloSnapshot.prepare() // kilocode_change
|
||||
using _lock = await Lock.write(git) // kilocode_change
|
||||
const worktree = Instance.worktree
|
||||
|
||||
// Deduplicate files preserving patch order
|
||||
const ops: RevertOp[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const item of patches) {
|
||||
for (const file of item.files) {
|
||||
if (seen.has(file)) continue
|
||||
seen.add(file)
|
||||
ops.push({ hash: item.hash, file, rel: path.relative(worktree, file).replaceAll("\\", "/") })
|
||||
}
|
||||
}
|
||||
|
||||
for (const batch of groupIntoBatches(ops)) {
|
||||
if (batch.length === 1) {
|
||||
await revertSingle(git, worktree, batch[0]!)
|
||||
} else {
|
||||
await revertBatch(git, worktree, batch)
|
||||
}
|
||||
}
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
export async function diff(hash: string) {
|
||||
const git = await KiloSnapshot.prepare() // kilocode_change
|
||||
using _lock = await Lock.write(git) // kilocode_change
|
||||
await add(git)
|
||||
const result =
|
||||
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
|
||||
@@ -208,10 +309,11 @@ export namespace Snapshot {
|
||||
diffCache.set(key, pending)
|
||||
return pending
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
async function diffFullUncached(from: string, to: string): Promise<FileDiff[]> {
|
||||
const git = await KiloSnapshot.prepare() // kilocode_change
|
||||
// kilocode_change end
|
||||
using _lock = await Lock.write(git) // kilocode_change
|
||||
const result: FileDiff[] = []
|
||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||
|
||||
@@ -277,26 +379,72 @@ export namespace Snapshot {
|
||||
return KiloSnapshot.gitdir() // kilocode_change
|
||||
}
|
||||
|
||||
// kilocode_change start — incremental add: diff-files + ls-files + size filter (port of upstream #17878)
|
||||
async function add(git: string) {
|
||||
await syncExclude(git)
|
||||
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} add .`
|
||||
const cwd = Instance.directory
|
||||
const worktree = Instance.worktree
|
||||
|
||||
// Run diff-files and ls-files concurrently to find changed + untracked files
|
||||
const [diffResult, otherResult] = await Promise.all([
|
||||
$`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${worktree} diff-files --name-only -z -- .`
|
||||
.quiet()
|
||||
.cwd(cwd)
|
||||
.nothrow(),
|
||||
$`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${worktree} ls-files --others --exclude-standard -z -- .`
|
||||
.quiet()
|
||||
.cwd(cwd)
|
||||
.nothrow(),
|
||||
])
|
||||
|
||||
if (diffResult.exitCode !== 0 || otherResult.exitCode !== 0) {
|
||||
log.warn("failed to list snapshot files", {
|
||||
diffCode: diffResult.exitCode,
|
||||
diffStderr: diffResult.stderr.toString(),
|
||||
otherCode: otherResult.exitCode,
|
||||
otherStderr: otherResult.stderr.toString(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const tracked = diffResult.text().split("\0").filter(Boolean)
|
||||
const all = Array.from(new Set([...tracked, ...otherResult.text().split("\0").filter(Boolean)]))
|
||||
if (!all.length) {
|
||||
await syncExclude(git)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter out oversized files (>2MB)
|
||||
const large = (
|
||||
await Promise.all(
|
||||
all.map(async (item) => {
|
||||
const stat = await fs.stat(path.join(cwd, item)).catch(() => null)
|
||||
return stat?.isFile() && stat.size > MAX_SNAPSHOT_FILE_SIZE ? item : undefined
|
||||
}),
|
||||
)
|
||||
).filter(Boolean) as string[]
|
||||
|
||||
await syncExclude(git, large)
|
||||
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${worktree} add --sparse .`
|
||||
.quiet()
|
||||
.cwd(Instance.directory)
|
||||
.cwd(cwd)
|
||||
.nothrow()
|
||||
}
|
||||
|
||||
async function syncExclude(git: string) {
|
||||
async function syncExclude(git: string, largeFiles: string[] = []) {
|
||||
const file = await excludes()
|
||||
const target = path.join(git, "info", "exclude")
|
||||
await fs.mkdir(path.join(git, "info"), { recursive: true })
|
||||
if (!file) {
|
||||
await Filesystem.write(target, "")
|
||||
return
|
||||
const parts: string[] = []
|
||||
if (file) {
|
||||
const text = await Filesystem.readText(file).catch(() => "")
|
||||
if (text.trim()) parts.push(text.trimEnd())
|
||||
}
|
||||
const text = await Filesystem.readText(file).catch(() => "")
|
||||
|
||||
await Filesystem.write(target, text)
|
||||
for (const item of largeFiles) {
|
||||
parts.push(`/${item.replaceAll("\\", "/")}`)
|
||||
}
|
||||
await Filesystem.write(target, parts.length ? parts.join("\n") + "\n" : "")
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
async function excludes() {
|
||||
const file = await $`git rev-parse --path-format=absolute --git-path info/exclude`
|
||||
|
||||
@@ -1178,3 +1178,414 @@ test("diffFull with whitespace changes", async () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// ── Tests for snapshot optimizations (upstream #17878, #20564) ────────
|
||||
|
||||
test("concurrent track() calls return consistent results", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Snapshot.track()
|
||||
|
||||
await Filesystem.write(`${tmp.path}/a.txt`, "concurrent-change")
|
||||
|
||||
const results = await Promise.all([
|
||||
Snapshot.track(),
|
||||
Snapshot.track(),
|
||||
Snapshot.track(),
|
||||
Snapshot.track(),
|
||||
Snapshot.track(),
|
||||
])
|
||||
|
||||
const hashes = results.filter(Boolean)
|
||||
expect(hashes.length).toBe(5)
|
||||
// All concurrent calls must return the same hash
|
||||
expect(new Set(hashes).size).toBe(1)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("batch revert with many files sharing same hash", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
// Create enough files to trigger batching (>1 per hash)
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await Filesystem.write(`${dir}/file${i}.txt`, `original-${i}`)
|
||||
}
|
||||
await $`git add .`.cwd(dir).quiet()
|
||||
await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet()
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
// Modify all 20 files
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await Filesystem.write(`${tmp.path}/file${i}.txt`, `changed-${i}`)
|
||||
}
|
||||
// Add 5 new files
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await Filesystem.write(`${tmp.path}/new${i}.txt`, `new-${i}`)
|
||||
}
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
expect(patch.files.length).toBe(25)
|
||||
|
||||
await Snapshot.revert([patch])
|
||||
|
||||
// Modified files should be restored
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const content = await Filesystem.readText(`${tmp.path}/file${i}.txt`)
|
||||
expect(content).toBe(`original-${i}`)
|
||||
}
|
||||
// New files should be deleted
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(
|
||||
await fs
|
||||
.access(`${tmp.path}/new${i}.txt`)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("batch revert with files in nested subdirectories", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
await fs.mkdir(`${dir}/a/b/c`, { recursive: true })
|
||||
await fs.mkdir(`${dir}/d/e`, { recursive: true })
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Filesystem.write(`${dir}/a/b/c/file${i}.txt`, `deep-${i}`)
|
||||
await Filesystem.write(`${dir}/d/e/file${i}.txt`, `other-${i}`)
|
||||
}
|
||||
await $`git add .`.cwd(dir).quiet()
|
||||
await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet()
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Filesystem.write(`${tmp.path}/a/b/c/file${i}.txt`, `modified-${i}`)
|
||||
await Filesystem.write(`${tmp.path}/d/e/file${i}.txt`, `modified-${i}`)
|
||||
}
|
||||
|
||||
await Snapshot.revert([await Snapshot.patch(before!)])
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(await Filesystem.readText(`${tmp.path}/a/b/c/file${i}.txt`)).toBe(`deep-${i}`)
|
||||
expect(await Filesystem.readText(`${tmp.path}/d/e/file${i}.txt`)).toBe(`other-${i}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("incremental add skips new files larger than 2MB", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
// Create a file larger than 2MB
|
||||
const largeContent = "x".repeat(3 * 1024 * 1024)
|
||||
await Filesystem.write(`${tmp.path}/large.bin`, largeContent)
|
||||
// Also create a small file
|
||||
await Filesystem.write(`${tmp.path}/small.txt`, "small change")
|
||||
|
||||
const after = await Snapshot.track()
|
||||
expect(after).toBeTruthy()
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
// Small file should be tracked, large file should be excluded
|
||||
const files = patch.files.map((f) => path.basename(f))
|
||||
expect(files).toContain("small.txt")
|
||||
expect(files).not.toContain("large.bin")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("incremental add: tracked files that grow past 2MB are still visible in patch", async () => {
|
||||
// The 2MB size filter only prevents NEW large files from being added to the
|
||||
// snapshot index. Already-tracked files that grow past 2MB still appear in
|
||||
// patch/diff because those compare against the working tree. This matches
|
||||
// upstream OpenCode behavior.
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Filesystem.write(`${tmp.path}/growing.txt`, "small")
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Filesystem.write(`${tmp.path}/growing.txt`, "x".repeat(3 * 1024 * 1024))
|
||||
await Filesystem.write(`${tmp.path}/a.txt`, "changed")
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
const files = patch.files.map((f) => path.basename(f))
|
||||
expect(files).toContain("a.txt")
|
||||
expect(files).toContain("growing.txt")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("concurrent patch() calls return consistent results", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Filesystem.write(`${tmp.path}/a.txt`, "changed")
|
||||
|
||||
const results = await Promise.all([
|
||||
Snapshot.patch(before!),
|
||||
Snapshot.patch(before!),
|
||||
Snapshot.patch(before!),
|
||||
])
|
||||
|
||||
// All should report the same changed files
|
||||
for (const result of results) {
|
||||
expect(result.files).toContain(fwd(tmp.path, "a.txt"))
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("batch revert with mix of modified, new, and deleted files", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await Filesystem.write(`${dir}/file${i}.txt`, `original-${i}`)
|
||||
}
|
||||
await $`git add .`.cwd(dir).quiet()
|
||||
await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet()
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
// Modify some files
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await Filesystem.write(`${tmp.path}/file${i}.txt`, `modified-${i}`)
|
||||
}
|
||||
// Delete some files
|
||||
for (let i = 5; i < 10; i++) {
|
||||
await fs.unlink(`${tmp.path}/file${i}.txt`)
|
||||
}
|
||||
// Add new files
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await Filesystem.write(`${tmp.path}/added${i}.txt`, `added-${i}`)
|
||||
}
|
||||
|
||||
await Snapshot.revert([await Snapshot.patch(before!)])
|
||||
|
||||
// Modified files restored
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`original-${i}`)
|
||||
}
|
||||
// Deleted files restored
|
||||
for (let i = 5; i < 10; i++) {
|
||||
expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`original-${i}`)
|
||||
}
|
||||
// Untouched files unchanged
|
||||
for (let i = 10; i < 15; i++) {
|
||||
expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`original-${i}`)
|
||||
}
|
||||
// New files removed
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(
|
||||
await fs
|
||||
.access(`${tmp.path}/added${i}.txt`)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("batch revert with multiple patches from different snapshots", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Filesystem.write(`${dir}/file${i}.txt`, `v0-${i}`)
|
||||
}
|
||||
await $`git add .`.cwd(dir).quiet()
|
||||
await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet()
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Snapshot A
|
||||
const snapA = await Snapshot.track()
|
||||
expect(snapA).toBeTruthy()
|
||||
|
||||
// Modify first 5 files
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await Filesystem.write(`${tmp.path}/file${i}.txt`, `v1-${i}`)
|
||||
}
|
||||
|
||||
// Snapshot B (captures v1 state)
|
||||
const snapB = await Snapshot.track()
|
||||
expect(snapB).toBeTruthy()
|
||||
|
||||
// Modify last 5 files
|
||||
for (let i = 5; i < 10; i++) {
|
||||
await Filesystem.write(`${tmp.path}/file${i}.txt`, `v2-${i}`)
|
||||
}
|
||||
|
||||
// Revert with patches from two different hashes
|
||||
const patchA = await Snapshot.patch(snapA!)
|
||||
const patchB = await Snapshot.patch(snapB!)
|
||||
// patchA covers files 0-4 (changed in v1) + files 5-9 (changed in v2)
|
||||
// patchB covers files 5-9 (changed in v2)
|
||||
// Reverting [patchA, patchB]: patchA's hash wins for all files (first seen)
|
||||
await Snapshot.revert([patchA, patchB])
|
||||
|
||||
// All files should be at v0 (snapA's state)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`v0-${i}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("concurrent track calls each produce a valid snapshot", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Filesystem.write(`${dir}/file${i}.txt`, `original-${i}`)
|
||||
}
|
||||
await $`git add .`.cwd(dir).quiet()
|
||||
await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet()
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Snapshot.track() // warm up
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Filesystem.write(`${tmp.path}/file${i}.txt`, `changed-${i}`)
|
||||
}
|
||||
|
||||
// Fire 5 concurrent tracks, then verify each hash is a usable snapshot
|
||||
const hashes = (await Promise.all([
|
||||
Snapshot.track(),
|
||||
Snapshot.track(),
|
||||
Snapshot.track(),
|
||||
Snapshot.track(),
|
||||
Snapshot.track(),
|
||||
])).filter(Boolean) as string[]
|
||||
|
||||
expect(hashes.length).toBe(5)
|
||||
|
||||
// Every hash should produce a valid diffFull against itself (empty diff)
|
||||
for (const hash of hashes) {
|
||||
const diff = await Snapshot.diffFull(hash, hash)
|
||||
expect(diff).toEqual([])
|
||||
}
|
||||
|
||||
// Every hash should be usable for restore without error
|
||||
await Snapshot.restore(hashes[0]!)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`changed-${i}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("track after revert produces clean snapshot", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Filesystem.write(`${dir}/file${i}.txt`, `original-${i}`)
|
||||
}
|
||||
await $`git add .`.cwd(dir).quiet()
|
||||
await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet()
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Filesystem.write(`${tmp.path}/file${i}.txt`, `changed-${i}`)
|
||||
}
|
||||
|
||||
await Snapshot.revert([await Snapshot.patch(before!)])
|
||||
|
||||
// After revert, a new track should match the original snapshot
|
||||
const after = await Snapshot.track()
|
||||
expect(after).toBeTruthy()
|
||||
expect(after).toBe(before)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("incremental add tracks newly created files", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
// Create several new files in subdirectories
|
||||
await fs.mkdir(`${tmp.path}/newdir/sub`, { recursive: true })
|
||||
await Filesystem.write(`${tmp.path}/newdir/one.txt`, "one")
|
||||
await Filesystem.write(`${tmp.path}/newdir/sub/two.txt`, "two")
|
||||
await Filesystem.write(`${tmp.path}/three.txt`, "three")
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
const files = patch.files.map((f) => path.basename(f))
|
||||
expect(files).toContain("one.txt")
|
||||
expect(files).toContain("two.txt")
|
||||
expect(files).toContain("three.txt")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("incremental add tracks modified and deleted files", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
// Modify one file, delete the other
|
||||
await Filesystem.write(`${tmp.path}/a.txt`, "modified-a")
|
||||
await fs.unlink(`${tmp.path}/b.txt`)
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
const files = patch.files.map((f) => path.basename(f))
|
||||
expect(files).toContain("a.txt")
|
||||
expect(files).toContain("b.txt")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user