@pmelab/gtd 26.1.0 → 27.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +16 -3
- package/dist/chunks/{Schema-B5yK7QrT.mjs → Schema-CyImoHTj.mjs} +56 -3
- package/dist/chunks/{reserved-6pAgFGgB.mjs → reserved-BL9krPao.mjs} +1 -1
- package/dist/chunks/{wire-DA7q2ikx.mjs → wire-BY1S-obW.mjs} +5 -2
- package/dist/gtd.bundle.mjs +85 -16
- package/dist/testkit.mjs +163 -17
- package/dist/wire.mjs +2 -2
- package/dist/workflows/feature.mjs +1 -1
- package/dist/workflows/fix.mjs +1 -1
- package/dist/workflows/review.mjs +1 -1
- package/package.json +2 -2
- package/src/cli/Cli.test.ts +53 -1
- package/src/cli/Cli.ts +48 -6
- package/src/cli/Output.ts +3 -1
- package/src/cli/Program.test.ts +1 -0
- package/src/cli/Program.ts +28 -7
- package/src/cli/Strays.test.ts +88 -0
- package/src/cli/Strays.ts +56 -0
- package/src/platform/Git.test.ts +96 -1
- package/src/platform/Git.ts +24 -0
- package/src/step/index.ts +1 -0
- package/src/step/strayPaths.test.ts +17 -0
- package/src/step/strayPaths.ts +8 -0
- package/src/testkit/FakeGitOperations.ts +2 -0
- package/src/testkit/GitTiers.ts +67 -1
- package/src/testkit/InMemRepo.ts +7 -0
- package/src/testkit/Layers.test.ts +34 -1
- package/src/testkit/Layers.ts +21 -1
- package/src/testkit/TestRepo.ts +1 -1
- package/src/wire/ExitCodes.test.ts +11 -3
- package/src/wire/ExitCodes.ts +3 -0
- package/src/wire/Glob.test.ts +35 -0
- package/src/wire/Glob.ts +1 -1
package/src/cli/Program.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
type NextMatch,
|
|
43
43
|
type StatusChange,
|
|
44
44
|
selectPath,
|
|
45
|
+
EXIT_OK,
|
|
45
46
|
} from "../wire/index.js"
|
|
46
47
|
import {
|
|
47
48
|
currentRest,
|
|
@@ -94,13 +95,16 @@ import {
|
|
|
94
95
|
type ArtifactOut,
|
|
95
96
|
type CommandRequirements,
|
|
96
97
|
type JsonMode,
|
|
98
|
+
type SuccessExit,
|
|
97
99
|
} from "./Output.js"
|
|
98
100
|
export {
|
|
99
101
|
SelectorUsageError,
|
|
100
102
|
type ArtifactOut,
|
|
101
103
|
type CommandRequirements,
|
|
102
104
|
type JsonMode,
|
|
105
|
+
type SuccessExit,
|
|
103
106
|
} from "./Output.js"
|
|
107
|
+
import { runStraysCommand } from "./Strays.js"
|
|
104
108
|
import { runHistoryFetchCommand, runHistoryListCommand, runHistoryShowCommand } from "./History.js"
|
|
105
109
|
|
|
106
110
|
/**
|
|
@@ -161,6 +165,13 @@ export type Command =
|
|
|
161
165
|
| { readonly kind: "historyList" }
|
|
162
166
|
| { readonly kind: "historyShow"; readonly ref: string }
|
|
163
167
|
| { readonly kind: "historyFetch"; readonly remote: string }
|
|
168
|
+
| {
|
|
169
|
+
readonly kind: "strays"
|
|
170
|
+
/** Each side of `<base>..<tip>`; an empty side is `HEAD`, as git reads it. */
|
|
171
|
+
readonly base: string
|
|
172
|
+
readonly tip: string
|
|
173
|
+
readonly globs: readonly string[]
|
|
174
|
+
}
|
|
164
175
|
| {
|
|
165
176
|
readonly kind: "judgeRun"
|
|
166
177
|
readonly provider?: "fixed" | "jev" | "llm"
|
|
@@ -1149,6 +1160,7 @@ export const needsOf = (kind: Command["kind"]): Needs => {
|
|
|
1149
1160
|
case "install":
|
|
1150
1161
|
case "loop":
|
|
1151
1162
|
case "judgeRun":
|
|
1163
|
+
case "strays":
|
|
1152
1164
|
return "none"
|
|
1153
1165
|
case "check":
|
|
1154
1166
|
case "uncheck":
|
|
@@ -1166,18 +1178,27 @@ export const standaloneKinds = (): readonly Command["kind"][] => [
|
|
|
1166
1178
|
"install",
|
|
1167
1179
|
"loop",
|
|
1168
1180
|
"judgeRun",
|
|
1181
|
+
"strays",
|
|
1169
1182
|
]
|
|
1170
1183
|
|
|
1171
1184
|
/**
|
|
1172
1185
|
* Dispatches to the named `run*Command` handler for every `Command.kind` —
|
|
1173
1186
|
* the counterpart of `Cli.ts`'s `parseArgv`, which has already validated
|
|
1174
|
-
* every field a handler receives.
|
|
1175
|
-
*
|
|
1176
|
-
* `runCli` supplies `EXIT_OK` once, after this Effect succeeds.
|
|
1187
|
+
* every field a handler receives. Only `strays` chooses its exit code; every
|
|
1188
|
+
* other branch returns `Effect<void>` and resolves to `EXIT_OK`.
|
|
1177
1189
|
*/
|
|
1190
|
+
const dispatchCommand = (
|
|
1191
|
+
command: Command,
|
|
1192
|
+
json: JsonMode,
|
|
1193
|
+
out: ArtifactOut,
|
|
1194
|
+
): Effect.Effect<SuccessExit, Error, CommandRequirements> => {
|
|
1195
|
+
if (command.kind === "strays") return runStraysCommand(command, json, out)
|
|
1196
|
+
return Effect.as(dispatchVoidCommand(command, json, out), EXIT_OK)
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1178
1199
|
// fallow-ignore-next-line complexity
|
|
1179
1200
|
const dispatchVoidCommand = (
|
|
1180
|
-
command: Command,
|
|
1201
|
+
command: Exclude<Command, { kind: "strays" }>,
|
|
1181
1202
|
json: JsonMode,
|
|
1182
1203
|
out: ArtifactOut,
|
|
1183
1204
|
): Effect.Effect<void, Error, CommandRequirements> => {
|
|
@@ -1255,8 +1276,8 @@ export const runCommand = (
|
|
|
1255
1276
|
command: Command,
|
|
1256
1277
|
json: JsonMode,
|
|
1257
1278
|
out: ArtifactOut,
|
|
1258
|
-
): Effect.Effect<
|
|
1259
|
-
const dispatch =
|
|
1279
|
+
): Effect.Effect<SuccessExit, Error, CommandRequirements> => {
|
|
1280
|
+
const dispatch = dispatchCommand(command, json, out)
|
|
1260
1281
|
if (needsOf(command.kind) !== "state") return dispatch
|
|
1261
1282
|
return Effect.gen(function* () {
|
|
1262
1283
|
const git = yield* GitService
|
|
@@ -1278,6 +1299,6 @@ export const runCommand = (
|
|
|
1278
1299
|
for (const warning of config.warnings) {
|
|
1279
1300
|
yield* narrator.warn(`gtd: warning: ${formatDiagnostic(warning)}`)
|
|
1280
1301
|
}
|
|
1281
|
-
yield* dispatch
|
|
1302
|
+
return yield* dispatch
|
|
1282
1303
|
})
|
|
1283
1304
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_STRAYS_FOUND } from "../wire/index.js"
|
|
4
|
+
import { InMemRepo, makeCapturingCliIo } from "../testkit/index.js"
|
|
5
|
+
import { runCli } from "./index.js"
|
|
6
|
+
|
|
7
|
+
const seed = (): InMemRepo => {
|
|
8
|
+
const repo = new InMemRepo()
|
|
9
|
+
repo.writeFile("README.md", "x\n")
|
|
10
|
+
repo.commitAllWithPrefix("base")
|
|
11
|
+
repo.updateRef("base", "HEAD")
|
|
12
|
+
repo.writeFile("src/a.ts", "a\n")
|
|
13
|
+
repo.writeFile("notes/b.md", "b\n")
|
|
14
|
+
repo.writeFile("notes/c.md", "c\n")
|
|
15
|
+
repo.commitAllWithPrefix("work")
|
|
16
|
+
return repo
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const strays = async (repo: InMemRepo, ...args: string[]) => {
|
|
20
|
+
const { io, result } = makeCapturingCliIo(repo)
|
|
21
|
+
await Effect.runPromise(runCli(["node", "gtd.js", "strays", ...args], io))
|
|
22
|
+
return result()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("gtd strays — stderr commentary", () => {
|
|
26
|
+
it("quiet: only the N of M summary", async () => {
|
|
27
|
+
const { stderr, exitCode } = await strays(seed(), "base..HEAD", "src/**")
|
|
28
|
+
expect(stderr).toBe("gtd strays: 2 of 3 touched files outside the allowlist\n")
|
|
29
|
+
expect(exitCode).toBe(EXIT_STRAYS_FOUND)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("--verbose: one stray: line per path, then the summary", async () => {
|
|
33
|
+
const { stderr } = await strays(seed(), "base..HEAD", "src/**", "--verbose")
|
|
34
|
+
expect(stderr).toBe(
|
|
35
|
+
'stray: "notes/b.md"\nstray: "notes/c.md"\ngtd strays: 2 of 3 touched files outside the allowlist\n',
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("a path with a line break and an escape reaches stderr JSON-quoted", async () => {
|
|
40
|
+
const repo = seed()
|
|
41
|
+
repo.writeFile("x\n\x1b[2J", "x\n")
|
|
42
|
+
repo.commitAllWithPrefix("odd")
|
|
43
|
+
const { stderr } = await strays(repo, "base..HEAD", "src/**", "--verbose")
|
|
44
|
+
expect(stderr).toContain('stray: "x\\n\\u001b[2J"\n')
|
|
45
|
+
expect(stderr).not.toContain("\x1b")
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("clean run: the single all-inside line", async () => {
|
|
49
|
+
const { stderr, exitCode } = await strays(seed(), "base..HEAD", "src/**", "notes/*")
|
|
50
|
+
expect(stderr).toBe("gtd strays: no strays — 3 touched files, all inside the allowlist\n")
|
|
51
|
+
expect(exitCode).toBe(EXIT_OK)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe("gtd strays --json=<index>", () => {
|
|
56
|
+
it("--json=0 prints the first stray, exit 10", async () => {
|
|
57
|
+
const { stdout, exitCode } = await strays(seed(), "base..HEAD", "src/**", "--json=0")
|
|
58
|
+
expect(stdout).toBe("notes/b.md\n")
|
|
59
|
+
expect(exitCode).toBe(EXIT_STRAYS_FOUND)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("--json=0 on a clean range prints nothing, exit 0", async () => {
|
|
63
|
+
const { stdout, exitCode } = await strays(seed(), "base..HEAD", "src/**", "notes/*", "--json=0")
|
|
64
|
+
expect(stdout).toBe("")
|
|
65
|
+
expect(exitCode).toBe(EXIT_OK)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("an index past the end prints nothing and keeps exit 10", async () => {
|
|
69
|
+
const { stdout, exitCode } = await strays(seed(), "base..HEAD", "src/**", "--json=5")
|
|
70
|
+
expect(stdout).toBe("")
|
|
71
|
+
expect(exitCode).toBe(EXIT_STRAYS_FOUND)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe("gtd strays — unknown revisions", () => {
|
|
76
|
+
it.each([
|
|
77
|
+
["nope..HEAD", "nope"],
|
|
78
|
+
["HEAD..nope", "nope"],
|
|
79
|
+
["HEAD..--output=x", "--output=x"],
|
|
80
|
+
])("%s: exit 1, empty stdout, no file written", async (range, side) => {
|
|
81
|
+
const repo = seed()
|
|
82
|
+
const { stdout, stderr, exitCode } = await strays(repo, range)
|
|
83
|
+
expect(stdout).toBe("")
|
|
84
|
+
expect(stderr).toContain(`unknown revision '${side}'`)
|
|
85
|
+
expect(exitCode).toBe(EXIT_RUNTIME_ERROR)
|
|
86
|
+
expect(repo.readFile("x")).toBeUndefined()
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { GitService, GtdError, Narrator, type GitOperations } from "../platform/index.js"
|
|
3
|
+
import { strayPaths } from "../step/index.js"
|
|
4
|
+
import { EXIT_OK, EXIT_STRAYS_FOUND } from "../wire/index.js"
|
|
5
|
+
import {
|
|
6
|
+
SelectorUsageError,
|
|
7
|
+
writeSelection,
|
|
8
|
+
type ArtifactOut,
|
|
9
|
+
type JsonMode,
|
|
10
|
+
type SuccessExit,
|
|
11
|
+
} from "./Output.js"
|
|
12
|
+
|
|
13
|
+
// `^{commit}` peels to a commit: a tree or blob would make `git diff` print paths not anchored at the repo root.
|
|
14
|
+
const resolveSide = (git: GitOperations, side: string): Effect.Effect<string, Error> =>
|
|
15
|
+
git
|
|
16
|
+
.resolveRef(`${side}^{commit}`)
|
|
17
|
+
.pipe(Effect.mapError(() => new GtdError(`gtd strays: unknown revision '${side}'`)))
|
|
18
|
+
|
|
19
|
+
/** stdout carries the array alone; the summary is ungated stderr so a quiet run still explains its exit code, the per-path lines are narration. */
|
|
20
|
+
export const runStraysCommand = (
|
|
21
|
+
command: {
|
|
22
|
+
readonly base: string
|
|
23
|
+
readonly tip: string
|
|
24
|
+
readonly globs: readonly string[]
|
|
25
|
+
},
|
|
26
|
+
json: JsonMode,
|
|
27
|
+
out: ArtifactOut,
|
|
28
|
+
): Effect.Effect<SuccessExit, Error, GitService | Narrator> =>
|
|
29
|
+
Effect.gen(function* () {
|
|
30
|
+
const git = yield* GitService
|
|
31
|
+
const narrator = yield* Narrator
|
|
32
|
+
const base = yield* resolveSide(git, command.base)
|
|
33
|
+
const tip = yield* resolveSide(git, command.tip)
|
|
34
|
+
const touched = yield* git.diffPaths(base, tip)
|
|
35
|
+
const strays = strayPaths(touched, command.globs)
|
|
36
|
+
const total = touched.length
|
|
37
|
+
|
|
38
|
+
if (json.kind === "select") {
|
|
39
|
+
// An index past the end is absent here, not a usage error: it must not mask the exit 0/10 verdict.
|
|
40
|
+
yield* writeSelection(out, strays, json.path).pipe(
|
|
41
|
+
Effect.catchIf(
|
|
42
|
+
(error) => error instanceof SelectorUsageError && /^\d+$/.test(json.path),
|
|
43
|
+
() => Effect.void,
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
} else out.write(`${JSON.stringify(strays)}\n`)
|
|
47
|
+
|
|
48
|
+
// JSON-quoted: a path is author-controlled and must not carry terminal escapes.
|
|
49
|
+
for (const path of strays) yield* narrator.narrate(`stray: ${JSON.stringify(path)}`)
|
|
50
|
+
yield* narrator.warn(
|
|
51
|
+
strays.length === 0
|
|
52
|
+
? `gtd strays: no strays — ${total} touched files, all inside the allowlist`
|
|
53
|
+
: `gtd strays: ${strays.length} of ${total} touched files outside the allowlist`,
|
|
54
|
+
)
|
|
55
|
+
return strays.length === 0 ? EXIT_OK : EXIT_STRAYS_FOUND
|
|
56
|
+
})
|
package/src/platform/Git.test.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process"
|
|
2
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { NodeContext } from "@effect/platform-node"
|
|
1
6
|
import { Cause, Effect, Exit } from "effect"
|
|
2
7
|
import { describe, expect, it } from "vitest"
|
|
3
|
-
import { InvalidRefError, resolvedRefOrCorrupted } from "./Git.js"
|
|
8
|
+
import { GitService, InvalidRefError, resolvedRefOrCorrupted } from "./Git.js"
|
|
9
|
+
import { Host } from "./index.js"
|
|
4
10
|
import { gitTiers, runGitServiceContract, runGitScriptContract } from "../testkit/index.js"
|
|
5
11
|
|
|
6
12
|
/**
|
|
@@ -45,3 +51,92 @@ for (const makeTier of gitTiers) {
|
|
|
45
51
|
runGitScriptContract(makeTier)
|
|
46
52
|
})
|
|
47
53
|
}
|
|
54
|
+
|
|
55
|
+
describe("diffPaths [Live]", () => {
|
|
56
|
+
it("stays repo-root-relative with diff.relative=true and a subdirectory cwd", async () => {
|
|
57
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-diffpaths-")))
|
|
58
|
+
try {
|
|
59
|
+
const git = (...args: string[]) =>
|
|
60
|
+
execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: "pipe" }).trim()
|
|
61
|
+
git("init")
|
|
62
|
+
git("config", "user.email", "test@test.com")
|
|
63
|
+
git("config", "user.name", "Test")
|
|
64
|
+
git("config", "commit.gpgsign", "false")
|
|
65
|
+
git("config", "diff.relative", "true")
|
|
66
|
+
mkdirSync(join(root, "sub"))
|
|
67
|
+
writeFileSync(join(root, "sub", "a.txt"), "a")
|
|
68
|
+
writeFileSync(join(root, "top.txt"), "t")
|
|
69
|
+
git("add", "-A")
|
|
70
|
+
git("commit", "-m", "one")
|
|
71
|
+
const base = git("rev-parse", "HEAD")
|
|
72
|
+
writeFileSync(join(root, "sub", "a.txt"), "b")
|
|
73
|
+
writeFileSync(join(root, "top.txt"), "u")
|
|
74
|
+
git("add", "-A")
|
|
75
|
+
git("commit", "-m", "two")
|
|
76
|
+
const tip = git("rev-parse", "HEAD")
|
|
77
|
+
const paths = await Effect.runPromise(
|
|
78
|
+
Effect.flatMap(GitService, (g) => g.diffPaths(base, tip)).pipe(
|
|
79
|
+
Effect.provide(GitService.Live),
|
|
80
|
+
Effect.provide(Host.layer({ root: join(root, "sub"), home: root, env: {} })),
|
|
81
|
+
Effect.provide(NodeContext.layer),
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
expect([...paths].sort()).toEqual(["sub/a.txt", "top.txt"])
|
|
85
|
+
} finally {
|
|
86
|
+
rmSync(root, { recursive: true, force: true })
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// An exported GIT_DIR/GIT_WORK_TREE would send this test's commits to the real repo;
|
|
91
|
+
// the pinned config keeps a global commit.gpgsign or missing identity from failing them.
|
|
92
|
+
const cleanEnv = {
|
|
93
|
+
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))),
|
|
94
|
+
GIT_CONFIG_COUNT: "3",
|
|
95
|
+
GIT_CONFIG_KEY_0: "commit.gpgsign",
|
|
96
|
+
GIT_CONFIG_VALUE_0: "false",
|
|
97
|
+
GIT_CONFIG_KEY_1: "user.email",
|
|
98
|
+
GIT_CONFIG_VALUE_1: "test@test.com",
|
|
99
|
+
GIT_CONFIG_KEY_2: "user.name",
|
|
100
|
+
GIT_CONFIG_VALUE_2: "Test",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
it("reports a submodule pointer bump whatever diff.ignoreSubmodules and .gitmodules ignore say", async () => {
|
|
104
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-diffpaths-sm-")))
|
|
105
|
+
try {
|
|
106
|
+
const run = (cwd: string, ...args: string[]) =>
|
|
107
|
+
execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe", env: cleanEnv }).trim()
|
|
108
|
+
const source = join(root, "source")
|
|
109
|
+
const repo = join(root, "super")
|
|
110
|
+
mkdirSync(source)
|
|
111
|
+
mkdirSync(repo)
|
|
112
|
+
run(source, "init")
|
|
113
|
+
writeFileSync(join(source, "f.txt"), "1")
|
|
114
|
+
run(source, "add", "-A")
|
|
115
|
+
run(source, "commit", "-m", "s1")
|
|
116
|
+
run(repo, "init")
|
|
117
|
+
run(repo, "config", "diff.ignoreSubmodules", "all")
|
|
118
|
+
run(repo, "-c", "protocol.file.allow=always", "submodule", "add", source, "sub")
|
|
119
|
+
run(repo, "config", "-f", ".gitmodules", "submodule.sub.ignore", "all")
|
|
120
|
+
run(repo, "add", "-A")
|
|
121
|
+
run(repo, "commit", "-m", "one")
|
|
122
|
+
const base = run(repo, "rev-parse", "HEAD")
|
|
123
|
+
const clone = join(repo, "sub")
|
|
124
|
+
writeFileSync(join(clone, "f.txt"), "2")
|
|
125
|
+
run(clone, "add", "-A")
|
|
126
|
+
run(clone, "commit", "-m", "s2")
|
|
127
|
+
run(repo, "add", "--force", "sub")
|
|
128
|
+
run(repo, "commit", "-m", "two")
|
|
129
|
+
const tip = run(repo, "rev-parse", "HEAD")
|
|
130
|
+
const paths = await Effect.runPromise(
|
|
131
|
+
Effect.flatMap(GitService, (g) => g.diffPaths(base, tip)).pipe(
|
|
132
|
+
Effect.provide(GitService.Live),
|
|
133
|
+
Effect.provide(Host.layer({ root: repo, home: root, env: {} })),
|
|
134
|
+
Effect.provide(NodeContext.layer),
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
expect([...paths]).toEqual(["sub"])
|
|
138
|
+
} finally {
|
|
139
|
+
rmSync(root, { recursive: true, force: true })
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
})
|
package/src/platform/Git.ts
CHANGED
|
@@ -94,6 +94,15 @@ export interface GitReaderOperations {
|
|
|
94
94
|
readonly changedPaths: (
|
|
95
95
|
base?: string,
|
|
96
96
|
) => Effect.Effect<ReadonlyArray<{ readonly path: string; readonly status: string }>, Error>
|
|
97
|
+
/**
|
|
98
|
+
* Paths whose content differs between the trees of `base` and `tip`: added,
|
|
99
|
+
* modified and deleted alike; a rename reports both paths (no rename
|
|
100
|
+
* detection); a submodule pointer change reports the submodule path whatever
|
|
101
|
+
* `diff.ignoreSubmodules` or `.gitmodules` `ignore` say. Repo-root-relative
|
|
102
|
+
* whatever the cwd or `diff.relative` config; never reads the index or the
|
|
103
|
+
* working tree. Fails when either side does not resolve.
|
|
104
|
+
*/
|
|
105
|
+
readonly diffPaths: (base: string, tip: string) => Effect.Effect<ReadonlyArray<string>, Error>
|
|
97
106
|
}
|
|
98
107
|
|
|
99
108
|
export interface GitWriterOperations {
|
|
@@ -382,6 +391,21 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
|
|
|
382
391
|
|
|
383
392
|
readFileAtRef: (ref: string, path: string) => exec("git", "show", `${ref}:${path}`),
|
|
384
393
|
|
|
394
|
+
diffPaths: (base: string, tip: string) =>
|
|
395
|
+
exec(
|
|
396
|
+
"git",
|
|
397
|
+
"diff",
|
|
398
|
+
"--name-only",
|
|
399
|
+
"-z",
|
|
400
|
+
"--no-renames",
|
|
401
|
+
"--no-relative",
|
|
402
|
+
"--ignore-submodules=none",
|
|
403
|
+
"--end-of-options",
|
|
404
|
+
base,
|
|
405
|
+
tip,
|
|
406
|
+
"--",
|
|
407
|
+
).pipe(Effect.map(splitNul)),
|
|
408
|
+
|
|
385
409
|
changedPaths: (base?: string) =>
|
|
386
410
|
Effect.gen(function* () {
|
|
387
411
|
const ref = base ?? "HEAD"
|
package/src/step/index.ts
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { strayPaths } from "./index.js"
|
|
3
|
+
|
|
4
|
+
describe("strayPaths", () => {
|
|
5
|
+
it("returns the paths no glob matches, sorted", () => {
|
|
6
|
+
expect(strayPaths(["src/a.ts", "z.md", "a.md"], ["src/**"])).toEqual(["a.md", "z.md"])
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it("an empty allowlist returns every touched path", () => {
|
|
10
|
+
expect(strayPaths(["b", "a"], [])).toEqual(["a", "b"])
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("an empty touched list returns []", () => {
|
|
14
|
+
expect(strayPaths([], ["src/**"])).toEqual([])
|
|
15
|
+
expect(strayPaths([], [])).toEqual([])
|
|
16
|
+
})
|
|
17
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { globMatches } from "../wire/index.js"
|
|
2
|
+
|
|
3
|
+
/** Globs use the `access.write` dialect (`globMatches`). */
|
|
4
|
+
export const strayPaths = (
|
|
5
|
+
touched: ReadonlyArray<string>,
|
|
6
|
+
allow: ReadonlyArray<string>,
|
|
7
|
+
): ReadonlyArray<string> =>
|
|
8
|
+
touched.filter((path) => !allow.some((glob) => globMatches(path, glob))).sort()
|
|
@@ -76,6 +76,8 @@ const makeGitReaderOps = (repo: InMemRepo, root: string): GitReaderOperations =>
|
|
|
76
76
|
Effect.succeed(repo.subjectHistory(pageSize, skip, head)),
|
|
77
77
|
|
|
78
78
|
changedPaths: (base?: string) => Effect.succeed(repo.changedPathsWorktree(base)),
|
|
79
|
+
|
|
80
|
+
diffPaths: (base: string, tip: string) => tryCatch(() => repo.diffPaths(base, tip)),
|
|
79
81
|
})
|
|
80
82
|
|
|
81
83
|
const failOn = (output: string | undefined): void => {
|
package/src/testkit/GitTiers.ts
CHANGED
|
@@ -409,6 +409,7 @@ export const CONTRACT_COVERED_OPERATIONS: ReadonlySet<keyof GitOperations> = new
|
|
|
409
409
|
"subjectHistory",
|
|
410
410
|
"readFileAtRef",
|
|
411
411
|
"changedPaths",
|
|
412
|
+
"diffPaths",
|
|
412
413
|
"commitAllWithPrefix",
|
|
413
414
|
"softResetTo",
|
|
414
415
|
"commitAsIs",
|
|
@@ -434,7 +435,7 @@ const PATHOLOGICAL_PATHS: ReadonlyArray<{ label: string; path: string }> = [
|
|
|
434
435
|
]
|
|
435
436
|
|
|
436
437
|
/**
|
|
437
|
-
* Exercise
|
|
438
|
+
* Exercise every `GitOperations` methods identically against `makeTier()` —
|
|
438
439
|
* called once per tier by `src/platform/Git.test.ts`. A capability-gated group
|
|
439
440
|
* (`t.capabilities.X`) is skipped, not faked, on a tier that can't support it.
|
|
440
441
|
*/
|
|
@@ -480,6 +481,7 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
|
|
|
480
481
|
g.readFileAtRef("HEAD", "second.txt"),
|
|
481
482
|
g.changedPaths(),
|
|
482
483
|
g.changedPaths("HEAD~1"),
|
|
484
|
+
g.diffPaths("HEAD~1", "HEAD"),
|
|
483
485
|
]),
|
|
484
486
|
)
|
|
485
487
|
expect(await t.observe.snapshot()).toBe(before)
|
|
@@ -988,6 +990,70 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
|
|
|
988
990
|
})
|
|
989
991
|
})
|
|
990
992
|
|
|
993
|
+
describe("diffPaths", () => {
|
|
994
|
+
const sorted = (paths: ReadonlyArray<string>) => [...paths].sort()
|
|
995
|
+
|
|
996
|
+
it("reports added, modified and deleted paths between two commits", async () => {
|
|
997
|
+
t.seed.commit("chore: seed", { "mod.txt": "one", "del.txt": "bye" })
|
|
998
|
+
const base = t.observe.resolveRef("HEAD")
|
|
999
|
+
t.seed.writeFile("mod.txt", "two")
|
|
1000
|
+
t.seed.stageAll()
|
|
1001
|
+
t.seed.commitDeletion("del.txt", "chore: delete")
|
|
1002
|
+
t.seed.commit("feat: add", { "add.txt": "new", "mod.txt": "two" })
|
|
1003
|
+
const tip = t.observe.resolveRef("HEAD")
|
|
1004
|
+
const paths = await runGit(t, (g) => g.diffPaths(base, tip))
|
|
1005
|
+
expect(sorted(paths)).toEqual(["add.txt", "del.txt", "mod.txt"])
|
|
1006
|
+
})
|
|
1007
|
+
|
|
1008
|
+
it("reports both paths of a rename", async () => {
|
|
1009
|
+
t.seed.commit("chore: seed", { "old.txt": "same content unique-marker\n" })
|
|
1010
|
+
const base = t.observe.resolveRef("HEAD")
|
|
1011
|
+
t.seed.deleteFile("old.txt")
|
|
1012
|
+
t.seed.commit("refactor: rename", { "new.txt": "same content unique-marker\n" })
|
|
1013
|
+
const tip = t.observe.resolveRef("HEAD")
|
|
1014
|
+
expect(sorted(await runGit(t, (g) => g.diffPaths(base, tip)))).toEqual(["new.txt", "old.txt"])
|
|
1015
|
+
})
|
|
1016
|
+
|
|
1017
|
+
it("omits a file changed then restored between the endpoints", async () => {
|
|
1018
|
+
t.seed.commit("chore: seed", { "flip.txt": "one" })
|
|
1019
|
+
const base = t.observe.resolveRef("HEAD")
|
|
1020
|
+
t.seed.commit("chore: change", { "flip.txt": "two" })
|
|
1021
|
+
t.seed.commit("chore: restore", { "flip.txt": "one" })
|
|
1022
|
+
const tip = t.observe.resolveRef("HEAD")
|
|
1023
|
+
expect(await runGit(t, (g) => g.diffPaths(base, tip))).toEqual([])
|
|
1024
|
+
})
|
|
1025
|
+
|
|
1026
|
+
it("never reports staged, unstaged or untracked changes", async () => {
|
|
1027
|
+
t.seed.commit("chore: seed", { "a.txt": "a", "b.txt": "b" })
|
|
1028
|
+
const base = t.observe.resolveRef("HEAD")
|
|
1029
|
+
t.seed.writeFile("a.txt", "staged")
|
|
1030
|
+
t.seed.stageAll()
|
|
1031
|
+
t.seed.writeFile("b.txt", "unstaged")
|
|
1032
|
+
t.seed.writeFile("untracked.txt", "u")
|
|
1033
|
+
expect(await runGit(t, (g) => g.diffPaths(base, "HEAD"))).toEqual([])
|
|
1034
|
+
})
|
|
1035
|
+
|
|
1036
|
+
it("is [] for diffPaths(x, x)", async () => {
|
|
1037
|
+
const head = t.observe.resolveRef("HEAD")
|
|
1038
|
+
expect(await runGit(t, (g) => g.diffPaths(head, head))).toEqual([])
|
|
1039
|
+
})
|
|
1040
|
+
|
|
1041
|
+
for (const { label, path } of PATHOLOGICAL_PATHS) {
|
|
1042
|
+
it(`reports a path containing ${label}, verbatim`, async () => {
|
|
1043
|
+
const base = t.observe.resolveRef("HEAD")
|
|
1044
|
+
t.seed.commit("feat: pathological", { [path]: "content" })
|
|
1045
|
+
expect(await runGit(t, (g) => g.diffPaths(base, "HEAD"))).toEqual([path])
|
|
1046
|
+
})
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
it("fails when either side does not resolve", async () => {
|
|
1050
|
+
const bad = await runGitExit(t, (g) => g.diffPaths("totally-invalid-ref-xyz", "HEAD"))
|
|
1051
|
+
expect(Exit.isFailure(bad)).toBe(true)
|
|
1052
|
+
const badTip = await runGitExit(t, (g) => g.diffPaths("HEAD", "totally-invalid-ref-xyz"))
|
|
1053
|
+
expect(Exit.isFailure(badTip)).toBe(true)
|
|
1054
|
+
})
|
|
1055
|
+
})
|
|
1056
|
+
|
|
991
1057
|
describe("changedPaths", () => {
|
|
992
1058
|
it("unions a tracked modification, a tracked deletion, and an untracked addition, deduplicated by path", async () => {
|
|
993
1059
|
t.seed.commit("chore: seed", { "modme.txt": "orig", "delme.txt": "bye" })
|
package/src/testkit/InMemRepo.ts
CHANGED
|
@@ -270,6 +270,13 @@ export class InMemRepo {
|
|
|
270
270
|
return diffTrees(baseTree, worktreeTree)
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
+
diffPaths(base: string, tip: string): string[] {
|
|
274
|
+
for (const ref of [base, tip]) {
|
|
275
|
+
if (this.resolveRef(ref) === null) throw new Error(`Invalid ref: ${ref}`)
|
|
276
|
+
}
|
|
277
|
+
return diffTrees(this.treeAt(base), this.treeAt(tip)).map((e) => e.path)
|
|
278
|
+
}
|
|
279
|
+
|
|
273
280
|
/** The worktree content of `path`, or `undefined` when absent. A LIVE read — never a snapshot. */
|
|
274
281
|
readFile(path: string): string | undefined {
|
|
275
282
|
return this.worktree.get(path)
|
|
@@ -2,7 +2,8 @@ import { Effect } from "effect"
|
|
|
2
2
|
import { describe, expect, it } from "vitest"
|
|
3
3
|
import { ConfigDiscovery, LEGACY_RC_FILES, walkUp } from "../workflow/index.js"
|
|
4
4
|
import { InMemRepo } from "./index.js"
|
|
5
|
-
import { testLayers } from "./Layers.js"
|
|
5
|
+
import { ambientFlagEnv, testLayers } from "./Layers.js"
|
|
6
|
+
import { Host } from "../platform/index.js"
|
|
6
7
|
|
|
7
8
|
// The in-memory `ConfigDiscovery` counterpart walks the SAME root→home chain
|
|
8
9
|
// and looks for the SAME `LEGACY_RC_FILES` as `ConfigDiscovery.Live` — both
|
|
@@ -56,3 +57,35 @@ describe("the in-memory ConfigDiscovery counterpart", () => {
|
|
|
56
57
|
expect(found).toEqual([`${HOME}/${LEGACY_RC_FILES[1]}`])
|
|
57
58
|
})
|
|
58
59
|
})
|
|
60
|
+
|
|
61
|
+
describe("ambient GTD_FLAG_* reaches the in-memory Host", () => {
|
|
62
|
+
const hostEnv = (ambient: Record<string, string>, env?: Record<string, string>) => {
|
|
63
|
+
const before = { ...process.env }
|
|
64
|
+
Object.assign(process.env, ambient)
|
|
65
|
+
return Effect.runPromise(
|
|
66
|
+
Effect.map(Host, (h) => h.env).pipe(
|
|
67
|
+
Effect.provide(testLayers(new InMemRepo(), env ? { env } : {})),
|
|
68
|
+
Effect.ensuring(
|
|
69
|
+
Effect.sync(() => {
|
|
70
|
+
for (const k of Object.keys(ambient)) {
|
|
71
|
+
if (before[k] === undefined) delete process.env[k]
|
|
72
|
+
else process.env[k] = before[k]
|
|
73
|
+
}
|
|
74
|
+
}),
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
it("carries the ambient flag, and explicit env wins", async () => {
|
|
81
|
+
expect((await hostEnv({ GTD_FLAG_ALPHA: "1" }))["GTD_FLAG_ALPHA"]).toBe("1")
|
|
82
|
+
const env = await hostEnv({ GTD_FLAG_ALPHA: "1" }, { GTD_FLAG_ALPHA: "0" })
|
|
83
|
+
expect(env["GTD_FLAG_ALPHA"]).toBe("0")
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("picks only GTD_FLAG_* keys", () => {
|
|
87
|
+
expect(ambientFlagEnv({ GTD_FLAG_X: "1", PATH: "/bin", GTD_FLAG_U: undefined })).toEqual({
|
|
88
|
+
GTD_FLAG_X: "1",
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
})
|
package/src/testkit/Layers.ts
CHANGED
|
@@ -216,6 +216,21 @@ const missingToolCommands: ToolCommands["Type"] = {
|
|
|
216
216
|
export const gitTestLayer = (repo: InMemRepo, root = "/repo"): Layer.Layer<GitService> =>
|
|
217
217
|
Layer.succeed(GitService, fakeGitOperations(repo, root))
|
|
218
218
|
|
|
219
|
+
/**
|
|
220
|
+
* The one sanctioned leak of the real `process.env` into the in-memory `Host`:
|
|
221
|
+
* the runner's `GTD_FLAG_*` state, so a flag-on `npm test` state exercises the
|
|
222
|
+
* flag in memory as it does live. A test asserting one flag side sets the flag
|
|
223
|
+
* itself; explicit env wins.
|
|
224
|
+
*/
|
|
225
|
+
export const ambientFlagEnv = (
|
|
226
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
227
|
+
): Record<string, string> =>
|
|
228
|
+
Object.fromEntries(
|
|
229
|
+
Object.entries(env).filter(
|
|
230
|
+
(e): e is [string, string] => e[0].startsWith("GTD_FLAG_") && e[1] !== undefined,
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
|
|
219
234
|
/** Every layer `makeProgram` needs — `CommandRequirements`'s return type here IS the guarantee: a new port added there fails this function's typecheck instead of silently under-providing. */
|
|
220
235
|
export function testLayers(
|
|
221
236
|
repo: InMemRepo,
|
|
@@ -231,7 +246,12 @@ export function testLayers(
|
|
|
231
246
|
// symlinks against): both sides of `assertRunningFromRepoRoot`'s
|
|
232
247
|
// comparison collapse to `root` when given anything, exactly like the
|
|
233
248
|
// fake's `root` "resolving" to itself before this port existed.
|
|
234
|
-
Host.layer({
|
|
249
|
+
Host.layer({
|
|
250
|
+
root,
|
|
251
|
+
home,
|
|
252
|
+
env: { ...ambientFlagEnv(), ...opts.env },
|
|
253
|
+
realPath: () => Effect.succeed(root),
|
|
254
|
+
}),
|
|
235
255
|
ConfigService.Live,
|
|
236
256
|
makeInMemoryConfigDiscovery(repo, root),
|
|
237
257
|
Narrator.layer(opts.narrate ?? (() => {}), opts.verbose ?? true),
|
package/src/testkit/TestRepo.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface TestRepoOptions {
|
|
|
19
19
|
readonly workflow: WorkflowModule
|
|
20
20
|
/** Committed alongside the config, before any process starts. */
|
|
21
21
|
readonly files?: Readonly<Record<string, string>>
|
|
22
|
-
/** The environment gtd sees; the real `process.env` is never read. */
|
|
22
|
+
/** The environment gtd sees; the real `process.env` is never read, except ambient `GTD_FLAG_*` (explicit env wins). */
|
|
23
23
|
readonly env?: Readonly<Record<string, string>>
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -5,16 +5,24 @@ import {
|
|
|
5
5
|
EXIT_RUNTIME_ERROR,
|
|
6
6
|
EXIT_SIGINT,
|
|
7
7
|
EXIT_SIGTERM,
|
|
8
|
+
EXIT_STRAYS_FOUND,
|
|
8
9
|
EXIT_USAGE_ERROR,
|
|
9
10
|
} from "./index.js"
|
|
10
11
|
|
|
11
12
|
describe("EXIT_CODES", () => {
|
|
12
|
-
it("is frozen at exactly {0, 1, 2, 130, 143} — a new code breaks every driver", () => {
|
|
13
|
-
expect(EXIT_CODES).toEqual(new Set([0, 1, 2, 130, 143]))
|
|
13
|
+
it("is frozen at exactly {0, 1, 2, 10, 130, 143} — a new code breaks every driver", () => {
|
|
14
|
+
expect(EXIT_CODES).toEqual(new Set([0, 1, 2, 10, 130, 143]))
|
|
14
15
|
})
|
|
15
16
|
|
|
16
17
|
it("each named constant is a member of the set", () => {
|
|
17
|
-
for (const code of [
|
|
18
|
+
for (const code of [
|
|
19
|
+
EXIT_OK,
|
|
20
|
+
EXIT_RUNTIME_ERROR,
|
|
21
|
+
EXIT_USAGE_ERROR,
|
|
22
|
+
EXIT_STRAYS_FOUND,
|
|
23
|
+
EXIT_SIGINT,
|
|
24
|
+
EXIT_SIGTERM,
|
|
25
|
+
]) {
|
|
18
26
|
expect(EXIT_CODES.has(code)).toBe(true)
|
|
19
27
|
}
|
|
20
28
|
})
|