refactor(effect): use Git service in file and storage (#21803)

This commit is contained in:
Kit Langton
2026-04-09 22:49:36 -04:00
committed by GitHub
parent eca11ca71a
commit 91786d2fc1
5 changed files with 109 additions and 121 deletions
+88 -105
View File
@@ -11,7 +11,6 @@ import path from "path"
import z from "zod" import z from "zod"
import { Global } from "../global" import { Global } from "../global"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Protected } from "./protected" import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep" import { Ripgrep } from "./ripgrep"
@@ -344,6 +343,7 @@ export namespace File {
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const appFs = yield* AppFileSystem.Service const appFs = yield* AppFileSystem.Service
const git = yield* Git.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("File.state")(() => Effect.fn("File.state")(() =>
@@ -410,6 +410,10 @@ export namespace File {
cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void))) cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
}) })
const gitText = Effect.fnUntraced(function* (args: string[]) {
return (yield* git.run(args, { cwd: Instance.directory })).text()
})
const init = Effect.fn("File.init")(function* () { const init = Effect.fn("File.init")(function* () {
yield* ensure() yield* ensure()
}) })
@@ -417,100 +421,87 @@ export namespace File {
const status = Effect.fn("File.status")(function* () { const status = Effect.fn("File.status")(function* () {
if (Instance.project.vcs !== "git") return [] if (Instance.project.vcs !== "git") return []
return yield* Effect.promise(async () => { const diffOutput = yield* gitText([
const diffOutput = ( "-c",
await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { "core.fsmonitor=false",
cwd: Instance.directory, "-c",
"core.quotepath=false",
"diff",
"--numstat",
"HEAD",
])
const changed: File.Info[] = []
if (diffOutput.trim()) {
for (const line of diffOutput.trim().split("\n")) {
const [added, removed, file] = line.split("\t")
changed.push({
path: file,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
}) })
).text()
const changed: File.Info[] = []
if (diffOutput.trim()) {
for (const line of diffOutput.trim().split("\n")) {
const [added, removed, file] = line.split("\t")
changed.push({
path: file,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
})
}
} }
}
const untrackedOutput = ( const untrackedOutput = yield* gitText([
await Git.run( "-c",
[ "core.fsmonitor=false",
"-c", "-c",
"core.fsmonitor=false", "core.quotepath=false",
"-c", "ls-files",
"core.quotepath=false", "--others",
"ls-files", "--exclude-standard",
"--others", ])
"--exclude-standard",
],
{
cwd: Instance.directory,
},
)
).text()
if (untrackedOutput.trim()) { if (untrackedOutput.trim()) {
for (const file of untrackedOutput.trim().split("\n")) { for (const file of untrackedOutput.trim().split("\n")) {
try { const content = yield* appFs
const content = await Filesystem.readText(path.join(Instance.directory, file)) .readFileString(path.join(Instance.directory, file))
changed.push({ .pipe(Effect.catch(() => Effect.succeed<string | undefined>(undefined)))
path: file, if (content === undefined) continue
added: content.split("\n").length, changed.push({
removed: 0, path: file,
status: "added", added: content.split("\n").length,
}) removed: 0,
} catch { status: "added",
continue })
}
}
} }
}
const deletedOutput = ( const deletedOutput = yield* gitText([
await Git.run( "-c",
[ "core.fsmonitor=false",
"-c", "-c",
"core.fsmonitor=false", "core.quotepath=false",
"-c", "diff",
"core.quotepath=false", "--name-only",
"diff", "--diff-filter=D",
"--name-only", "HEAD",
"--diff-filter=D", ])
"HEAD",
],
{
cwd: Instance.directory,
},
)
).text()
if (deletedOutput.trim()) { if (deletedOutput.trim()) {
for (const file of deletedOutput.trim().split("\n")) { for (const file of deletedOutput.trim().split("\n")) {
changed.push({ changed.push({
path: file, path: file,
added: 0, added: 0,
removed: 0, removed: 0,
status: "deleted", status: "deleted",
}) })
}
} }
}
return changed.map((item) => { return changed.map((item) => {
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path) const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
return { return {
...item, ...item,
path: path.relative(Instance.directory, full), path: path.relative(Instance.directory, full),
} }
})
}) })
}) })
const read = Effect.fn("File.read")(function* (file: string) { const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) {
using _ = log.time("read", { file }) using _ = log.time("read", { file })
const full = path.join(Instance.directory, file) const full = path.join(Instance.directory, file)
@@ -558,27 +549,19 @@ export namespace File {
) )
if (Instance.project.vcs === "git") { if (Instance.project.vcs === "git") {
return yield* Effect.promise(async (): Promise<File.Content> => { let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file])
let diff = ( if (!diff.trim()) {
await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory }) diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
).text() }
if (!diff.trim()) { if (diff.trim()) {
diff = ( const original = yield* git.show(Instance.directory, "HEAD", file)
await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { const patch = structuredPatch(file, file, original, content, "old", "new", {
cwd: Instance.directory, context: Infinity,
}) ignoreWhitespace: true,
).text() })
} return { type: "text" as const, content, patch, diff: formatPatch(patch) }
if (diff.trim()) { }
const original = (await Git.run(["show", `HEAD:${file}`], { cwd: Instance.directory })).text() return { type: "text" as const, content }
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,
})
return { type: "text", content, patch, diff: formatPatch(patch) }
}
return { type: "text", content }
})
} }
return { type: "text" as const, content } return { type: "text" as const, content }
@@ -660,7 +643,7 @@ export namespace File {
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
+5 -6
View File
@@ -71,6 +71,7 @@ export namespace FileWatcher {
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const config = yield* Config.Service const config = yield* Config.Service
const git = yield* Git.Service
const state = yield* InstanceState.make( const state = yield* InstanceState.make(
Effect.fn("FileWatcher.state")( Effect.fn("FileWatcher.state")(
@@ -131,11 +132,9 @@ export namespace FileWatcher {
} }
if (Instance.project.vcs === "git") { if (Instance.project.vcs === "git") {
const result = yield* Effect.promise(() => const result = yield* git.run(["rev-parse", "--git-dir"], {
Git.run(["rev-parse", "--git-dir"], { cwd: Instance.project.worktree,
cwd: Instance.project.worktree, })
}),
)
const vcsDir = const vcsDir =
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
@@ -161,7 +160,7 @@ export namespace FileWatcher {
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
+12 -9
View File
@@ -11,7 +11,11 @@ import { Git } from "@/git"
export namespace Storage { export namespace Storage {
const log = Log.create({ service: "storage" }) const log = Log.create({ service: "storage" })
type Migration = (dir: string, fs: AppFileSystem.Interface) => Effect.Effect<void, AppFileSystem.Error> type Migration = (
dir: string,
fs: AppFileSystem.Interface,
git: Git.Interface,
) => Effect.Effect<void, AppFileSystem.Error>
export const NotFoundError = NamedError.create( export const NotFoundError = NamedError.create(
"NotFoundError", "NotFoundError",
@@ -83,7 +87,7 @@ export namespace Storage {
} }
const MIGRATIONS: Migration[] = [ const MIGRATIONS: Migration[] = [
Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface) { Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface, git: Git.Interface) {
const project = path.resolve(dir, "../project") const project = path.resolve(dir, "../project")
if (!(yield* fs.isDir(project))) return if (!(yield* fs.isDir(project))) return
const projectDirs = yield* fs.glob("*", { const projectDirs = yield* fs.glob("*", {
@@ -110,11 +114,9 @@ export namespace Storage {
} }
if (!worktree) continue if (!worktree) continue
if (!(yield* fs.isDir(worktree))) continue if (!(yield* fs.isDir(worktree))) continue
const result = yield* Effect.promise(() => const result = yield* git.run(["rev-list", "--max-parents=0", "--all"], {
Git.run(["rev-list", "--max-parents=0", "--all"], { cwd: worktree,
cwd: worktree, })
}),
)
const [id] = result const [id] = result
.text() .text()
.split("\n") .split("\n")
@@ -220,6 +222,7 @@ export namespace Storage {
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* AppFileSystem.Service const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const locks = yield* RcMap.make({ const locks = yield* RcMap.make({
lookup: () => TxReentrantLock.make(), lookup: () => TxReentrantLock.make(),
idleTimeToLive: 0, idleTimeToLive: 0,
@@ -236,7 +239,7 @@ export namespace Storage {
for (let i = migration; i < MIGRATIONS.length; i++) { for (let i = migration; i < MIGRATIONS.length; i++) {
log.info("running migration", { index: i }) log.info("running migration", { index: i })
const step = MIGRATIONS[i]! const step = MIGRATIONS[i]!
const exit = yield* Effect.exit(step(dir, fs)) const exit = yield* Effect.exit(step(dir, fs, git))
if (Exit.isFailure(exit)) { if (Exit.isFailure(exit)) {
log.error("failed to run migration", { index: i, cause: exit.cause }) log.error("failed to run migration", { index: i, cause: exit.cause })
break break
@@ -327,7 +330,7 @@ export namespace Storage {
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
@@ -7,6 +7,7 @@ import { tmpdir } from "../fixture/fixture"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { Config } from "../../src/config/config" import { Config } from "../../src/config/config"
import { FileWatcher } from "../../src/file/watcher" import { FileWatcher } from "../../src/file/watcher"
import { Git } from "../../src/git"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows) // Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
@@ -32,6 +33,7 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
fn: async () => { fn: async () => {
const layer: Layer.Layer<FileWatcher.Service, never, never> = FileWatcher.layer.pipe( const layer: Layer.Layer<FileWatcher.Service, never, never> = FileWatcher.layer.pipe(
Layer.provide(Config.defaultLayer), Layer.provide(Config.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(watcherConfigLayer), Layer.provide(watcherConfigLayer),
) )
const rt = ManagedRuntime.make(layer) const rt = ManagedRuntime.make(layer)
@@ -3,6 +3,7 @@ import fs from "fs/promises"
import path from "path" import path from "path"
import { Effect, Layer, ManagedRuntime } from "effect" import { Effect, Layer, ManagedRuntime } from "effect"
import { AppFileSystem } from "../../src/filesystem" import { AppFileSystem } from "../../src/filesystem"
import { Git } from "../../src/git"
import { Global } from "../../src/global" import { Global } from "../../src/global"
import { Storage } from "../../src/storage/storage" import { Storage } from "../../src/storage/storage"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
@@ -47,7 +48,7 @@ async function withStorage<T>(
root: string, root: string,
fn: (run: <A, E>(body: Effect.Effect<A, E, Storage.Service>) => Promise<A>) => Promise<T>, fn: (run: <A, E>(body: Effect.Effect<A, E, Storage.Service>) => Promise<A>) => Promise<T>,
) { ) {
const rt = ManagedRuntime.make(Storage.layer.pipe(Layer.provide(layer(root)))) const rt = ManagedRuntime.make(Storage.layer.pipe(Layer.provide(layer(root)), Layer.provide(Git.defaultLayer)))
try { try {
return await fn((body) => rt.runPromise(body)) return await fn((body) => rt.runPromise(body))
} finally { } finally {