diff --git a/packages/core/src/snapshot.ts b/packages/core/src/snapshot.ts index e9d5785b598..d842fb75233 100644 --- a/packages/core/src/snapshot.ts +++ b/packages/core/src/snapshot.ts @@ -105,10 +105,15 @@ const layer = Layer.effect( ) const repository = repositoryFiber.pipe(Effect.uninterruptible, Effect.flatMap(Fiber.join)) + // The worktree is a real path, so the Location must be one too: a symlinked or junctioned path would fall outside + // it, and a path in another letter case would become a pathspec that matches nothing. const scope = Effect.fnUntraced(function* (worktree: AbsolutePath) { - if (!FSUtil.contains(worktree, location.directory)) + const directory = yield* fs + .realPath(location.directory) + .pipe(Effect.mapError((cause) => failure("capture", cause))) + if (!FSUtil.contains(worktree, directory)) return yield* new Error({ operation: "capture", message: "Location is outside the project" }) - return RelativePath.make(path.relative(worktree, location.directory).replaceAll("\\", "/") || ".") + return RelativePath.make(path.relative(worktree, directory).replaceAll("\\", "/") || ".") }) const enabled = () => location.vcs?.type === "git" && state.get().enabled diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts index c057c4dbfbe..12bbc691f96 100644 --- a/packages/core/test/snapshot.test.ts +++ b/packages/core/test/snapshot.test.ts @@ -216,6 +216,58 @@ describe("Snapshot", () => { ), ) + testEffect(Layer.empty).live("captures and restores a Location opened through a symlinked path", () => + withTempDir((tmp) => + Effect.gen(function* () { + const project = path.join(tmp.path, "project") + const link = path.join(tmp.path, "link") + yield* Effect.promise(async () => { + await fs.mkdir(path.join(project, "scope"), { recursive: true }) + await fs.writeFile(path.join(project, "scope", "tracked.txt"), "one\n") + await initGit(project) + // A junction needs no privilege on Windows; other platforms ignore the type. + await fs.symlink(project, link, "junction") + }) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.capture() + yield* Effect.promise(() => fs.writeFile(path.join(link, "scope", "tracked.txt"), "two\n")) + const after = yield* snapshot.capture() + expect(before).toBeDefined() + expect(after).toBeDefined() + if (!before || !after) return + expect(yield* snapshot.files({ from: before, to: after })).toEqual([RelativePath.make("scope/tracked.txt")]) + yield* snapshot.restore({ files: new Map([[RelativePath.make("scope/tracked.txt"), before]]) }) + expect(yield* read(path.join(project, "scope", "tracked.txt"))).toBe("one\n") + }).pipe(Effect.provide(snapshotLayer(tmp.path, path.join(link, "scope")))) + }), + ), + ) + ;(process.platform === "win32" ? testEffect(Layer.empty).live : testEffect(Layer.empty).live.skip)( + "captures a Location opened with different letter case", + () => + withTempDir((tmp) => + Effect.gen(function* () { + const project = path.join(tmp.path, "project") + yield* Effect.promise(async () => { + await fs.mkdir(path.join(project, "scope"), { recursive: true }) + await fs.writeFile(path.join(project, "scope", "tracked.txt"), "one\n") + await initGit(project) + }) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.capture() + yield* Effect.promise(() => fs.writeFile(path.join(project, "scope", "tracked.txt"), "two\n")) + const after = yield* snapshot.capture() + expect(before).toBeDefined() + expect(after).toBeDefined() + if (!before || !after) return + expect(yield* snapshot.files({ from: before, to: after })).toEqual([RelativePath.make("scope/tracked.txt")]) + }).pipe(Effect.provide(snapshotLayer(tmp.path, path.join(project, "SCOPE")))) + }), + ), + ) + testEffect(Layer.empty).live("restores many files from several trees and removes paths absent from them", () => withTempDir((tmp) => Effect.gen(function* () {