@pmelab/gtd 61.0.0 → 62.1.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 +15 -4
- package/dist/chunks/{testkit-C9sq0x07.mjs → testkit-CPzWnl3P.mjs} +76 -63
- package/dist/gtd.bundle.mjs +282 -80
- package/dist/gtd.core.mjs +121 -118
- package/dist/testkit/gherkin.mjs +1 -1
- package/dist/testkit.mjs +1 -1
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +2 -0
- package/src/cli/Cli.ts +18 -1
- package/src/cli/Launch.test.ts +52 -0
- package/src/cli/Launch.ts +144 -0
- package/src/cli/Program.test.ts +4 -3
- package/src/cli/Program.ts +41 -1
- package/src/cli/index.ts +1 -0
- package/src/edge/Edge.test.ts +3 -3
- package/src/edge/Edge.ts +17 -71
- package/src/emit/GitScript.ts +7 -0
- package/src/emit/index.ts +1 -0
- package/src/platform/PinFile.ts +61 -0
- package/src/platform/index.ts +1 -0
- package/src/replay/Trailers.test.ts +0 -37
- package/src/replay/Trailers.ts +2 -23
- package/src/step/planStart.test.ts +0 -1
- package/src/step/planStart.ts +1 -5
- package/src/twins/EmittedScriptRecognizer.ts +3 -2
- package/src/twins/PinFile.test.ts +107 -0
- package/src/twins/PinFile.ts +12 -0
- package/src/twins/index.ts +1 -0
- package/src/workflow/fingerprint.ts +2 -2
- package/src/workflow/index.ts +2 -2
- package/src/workflow/load.ts +14 -1
- package/src/workflow/pin.ts +15 -7
package/dist/testkit/gherkin.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as runsItsScript, r as testRepo } from "../chunks/testkit-
|
|
1
|
+
import { n as runsItsScript, r as testRepo } from "../chunks/testkit-CPzWnl3P.mjs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
import { After, AfterAll, Given, QuickPickleWorld, Then, When, setWorldConstructor } from "quickpickle";
|
package/dist/testkit.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { H as installContext } from "./chunks/flows-DENt0uWV.mjs";
|
|
2
|
-
import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-
|
|
2
|
+
import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-CPzWnl3P.mjs";
|
|
3
3
|
export { inTurn, installContext, runsItsScript, testRepo, verdict, verdictWhen, whenPrompted };
|
package/package.json
CHANGED
package/src/cli/Cli.test.ts
CHANGED
|
@@ -114,6 +114,7 @@ describe("parseArgv — scope", () => {
|
|
|
114
114
|
["validate", "--json"],
|
|
115
115
|
["abandon", "--json"],
|
|
116
116
|
["restore", "--json"],
|
|
117
|
+
["repin", "--json"],
|
|
117
118
|
["start", "--json"],
|
|
118
119
|
["start", "fix", "--json"],
|
|
119
120
|
["history", "fetch", "--json"],
|
|
@@ -1116,6 +1117,7 @@ describe("standaloneKinds / needsOf", () => {
|
|
|
1116
1117
|
"start",
|
|
1117
1118
|
"abandon",
|
|
1118
1119
|
"restore",
|
|
1120
|
+
"repin",
|
|
1119
1121
|
"next",
|
|
1120
1122
|
"validate",
|
|
1121
1123
|
"ui",
|
package/src/cli/Cli.ts
CHANGED
|
@@ -131,6 +131,7 @@ const SCRIPT_KINDS: ReadonlySet<Command["kind"]> = new Set<Command["kind"]>([
|
|
|
131
131
|
"start",
|
|
132
132
|
"abandon",
|
|
133
133
|
"restore",
|
|
134
|
+
"repin",
|
|
134
135
|
"validate",
|
|
135
136
|
"historyFetch",
|
|
136
137
|
])
|
|
@@ -448,6 +449,16 @@ With no <name>, list every workflow and door it can start. An unknown <name>, th
|
|
|
448
449
|
summary: "Bring back the last abandoned process",
|
|
449
450
|
details: `Bring back the process the last \`gtd abandon\` stopped: the printed script resets HEAD to that process's last commit. Refuses on a dirty working tree, when nothing was abandoned, or when commits made since would be lost.`,
|
|
450
451
|
},
|
|
452
|
+
{
|
|
453
|
+
token: "repin",
|
|
454
|
+
kind: "repin",
|
|
455
|
+
arity: "none",
|
|
456
|
+
group: "Start and stop",
|
|
457
|
+
summary: "Pin the process to the live gtd and workflow",
|
|
458
|
+
details: `Switch the process underway to the live engine and workflow, the way out when the workflow needs a fix mid-process: the printed script runs the pin beat again, writing \`gtd bundle\`'s output to .gtd/process.gtdpin and committing that file alone. Always runs on the gtd on PATH, never on the pin. Refuses when no process is underway, when the next beat is a pin or unpin beat already, or when the live workflow does not replay the process.
|
|
459
|
+
|
|
460
|
+
s=$(gtd repin --json=script) && sh -c "$s"`,
|
|
461
|
+
},
|
|
451
462
|
{
|
|
452
463
|
token: "summary",
|
|
453
464
|
kind: "summary",
|
|
@@ -632,6 +643,7 @@ const flagHeader = (row: FlagRow): string => {
|
|
|
632
643
|
|
|
633
644
|
const GLOBAL_OPTIONS: readonly (readonly [string, string])[] = [
|
|
634
645
|
["-v, --verbose", "Narrate on stderr what gtd resolved"],
|
|
646
|
+
["--live", "Run this gtd, not the process's pin; it may misread the process"],
|
|
635
647
|
["-h, --help", "Print help; gtd <command> --help for one command"],
|
|
636
648
|
["-V, --version", "Print the version"],
|
|
637
649
|
["--", "End the options: every word after it is an argument"],
|
|
@@ -643,7 +655,11 @@ const EXTRA_TOPICS: readonly (readonly [string, string, string])[] = [
|
|
|
643
655
|
"Print help for gtd or one command",
|
|
644
656
|
"Print gtd's overview, or with <command> that command's own help.",
|
|
645
657
|
],
|
|
646
|
-
[
|
|
658
|
+
[
|
|
659
|
+
"version",
|
|
660
|
+
"Print the version",
|
|
661
|
+
"Print gtd's version, and the version of a pinned process's gtd.",
|
|
662
|
+
],
|
|
647
663
|
]
|
|
648
664
|
|
|
649
665
|
const EXAMPLES = ` $ gtd start list what can start
|
|
@@ -1301,6 +1317,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
|
|
|
1301
1317
|
| "lsp"
|
|
1302
1318
|
| "abandon"
|
|
1303
1319
|
| "restore"
|
|
1320
|
+
| "repin"
|
|
1304
1321
|
| "next"
|
|
1305
1322
|
| "validate"
|
|
1306
1323
|
| "guide"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
2
|
+
import { GTD_VERSION, PIN_KEY } from "../install/index.js"
|
|
3
|
+
import { launchFor } from "./Launch.js"
|
|
4
|
+
|
|
5
|
+
const pin =
|
|
6
|
+
(header = "gtdpin 1 gtd 1.2.3") =>
|
|
7
|
+
() => ({
|
|
8
|
+
path: "/repo/.gtd/process.gtdpin",
|
|
9
|
+
bytes: Buffer.from(`${header}\n`),
|
|
10
|
+
})
|
|
11
|
+
const argv = (...words: string[]) => ["node", "gtd", ...words]
|
|
12
|
+
|
|
13
|
+
describe("launchFor", () => {
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
delete (globalThis as { [PIN_KEY]?: unknown })[PIN_KEY]
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("hands a state command over to the pin", () => {
|
|
19
|
+
expect(launchFor(argv("next"), pin())).toMatchObject({ kind: "pin", version: "1.2.3" })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("never hands over from inside a pin", () => {
|
|
23
|
+
;(globalThis as { [PIN_KEY]?: unknown })[PIN_KEY] = { version: "1.2.3" }
|
|
24
|
+
expect(launchFor(argv("next"), pin())).toEqual({ kind: "live", argv: argv("next") })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("strips --live, but keeps one after --", () => {
|
|
28
|
+
expect(launchFor(argv("--live", "start", "--", "--live"), pin())).toEqual({
|
|
29
|
+
kind: "live",
|
|
30
|
+
argv: argv("start", "--", "--live"),
|
|
31
|
+
})
|
|
32
|
+
expect(launchFor(argv("start", "--", "--live"), pin())).toMatchObject({
|
|
33
|
+
kind: "pin",
|
|
34
|
+
argv: argv("start", "--", "--live"),
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("leaves the version to this gtd without a pin, and for a file that is no pin", () => {
|
|
39
|
+
expect(launchFor(argv("-V"), () => undefined)).toEqual({ kind: "live", argv: argv("-V") })
|
|
40
|
+
expect(launchFor(argv("version"), pin("garbage"))).toEqual({
|
|
41
|
+
kind: "output",
|
|
42
|
+
stdout: `${GTD_VERSION}\n`,
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("names a newer pin's version even when it cannot run it", () => {
|
|
47
|
+
expect(launchFor(argv("--version"), pin("gtdpin 9 gtd 99.0.0"))).toEqual({
|
|
48
|
+
kind: "output",
|
|
49
|
+
stdout: `${GTD_VERSION} (this process is pinned to gtd 99.0.0)\n`,
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { PinFile } from "../platform/index.js"
|
|
2
|
+
import { GTD_VERSION, PIN_KEY } from "../install/index.js"
|
|
3
|
+
import { PIN_FORMAT, pinHeader, readPin } from "../workflow/index.js"
|
|
4
|
+
import { EXIT_RUNTIME_ERROR } from "../wire/index.js"
|
|
5
|
+
import { parseArgv } from "./Cli.js"
|
|
6
|
+
import type { Command } from "./Program.js"
|
|
7
|
+
|
|
8
|
+
/** What the core a pin carries lacks, or what must read the live state: these never hand over. */
|
|
9
|
+
const LIVE_KINDS: ReadonlySet<Command["kind"]> = new Set<Command["kind"]>([
|
|
10
|
+
"ui",
|
|
11
|
+
"lsp",
|
|
12
|
+
"guide",
|
|
13
|
+
"bundle",
|
|
14
|
+
"repin",
|
|
15
|
+
"historyList",
|
|
16
|
+
"historyShow",
|
|
17
|
+
"historyMetrics",
|
|
18
|
+
"historyJudges",
|
|
19
|
+
"historyFetch",
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
export type Launch =
|
|
23
|
+
| { readonly kind: "live"; readonly argv: readonly string[] }
|
|
24
|
+
| {
|
|
25
|
+
readonly kind: "pin"
|
|
26
|
+
readonly argv: readonly string[]
|
|
27
|
+
readonly path: string
|
|
28
|
+
readonly version: string
|
|
29
|
+
/** Unpacked only when run: its failure is the hand-over's. */
|
|
30
|
+
readonly source: () => string
|
|
31
|
+
}
|
|
32
|
+
| { readonly kind: "output"; readonly stdout: string }
|
|
33
|
+
| { readonly kind: "refuse"; readonly message: string }
|
|
34
|
+
|
|
35
|
+
const LIVE_FLAG = "--live"
|
|
36
|
+
|
|
37
|
+
// Only before `--`: after it, `--live` is an argument.
|
|
38
|
+
const optionWords = (argv: readonly string[]): readonly string[] => {
|
|
39
|
+
const end = argv.indexOf("--", 2)
|
|
40
|
+
return argv.slice(2, end < 0 ? undefined : end)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const withoutLiveFlag = (argv: readonly string[]): readonly string[] => {
|
|
44
|
+
const end = argv.indexOf("--", 2)
|
|
45
|
+
return argv.filter((word, i) => word !== LIVE_FLAG || i < 2 || (end >= 0 && i > end))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether this gtd runs the command or hands it to the process's pin. A pin
|
|
50
|
+
* this gtd cannot run is refused, never guessed at; a usage error the live
|
|
51
|
+
* parser finds is still the pin's to judge, since its engine may read
|
|
52
|
+
* another CLI.
|
|
53
|
+
*/
|
|
54
|
+
export const launchFor = (argv: readonly string[], pinFile: PinFile): Launch => {
|
|
55
|
+
// The engine a pin carries is this same code: once inside one, never hand over again.
|
|
56
|
+
if ((globalThis as { [PIN_KEY]?: unknown })[PIN_KEY] !== undefined) return { kind: "live", argv }
|
|
57
|
+
const live = optionWords(argv).includes(LIVE_FLAG)
|
|
58
|
+
const rest = withoutLiveFlag(argv)
|
|
59
|
+
if (live) return { kind: "live", argv: rest }
|
|
60
|
+
const plan = parseArgv(rest)
|
|
61
|
+
const version = plan.kind === "output" && plan.stdout === `${GTD_VERSION}\n`
|
|
62
|
+
if (plan.kind === "output" && !version) return { kind: "live", argv: rest }
|
|
63
|
+
if (plan.kind === "command" && LIVE_KINDS.has(plan.command.kind))
|
|
64
|
+
return { kind: "live", argv: rest }
|
|
65
|
+
const found = pinFile()
|
|
66
|
+
if (found === undefined) return { kind: "live", argv: rest }
|
|
67
|
+
const header = pinHeader(found.bytes)
|
|
68
|
+
if (version)
|
|
69
|
+
return {
|
|
70
|
+
kind: "output",
|
|
71
|
+
stdout:
|
|
72
|
+
header === undefined
|
|
73
|
+
? plan.stdout
|
|
74
|
+
: `${GTD_VERSION} (this process is pinned to gtd ${header.version})\n`,
|
|
75
|
+
}
|
|
76
|
+
if (header === undefined || header.format !== PIN_FORMAT) {
|
|
77
|
+
const what =
|
|
78
|
+
header === undefined
|
|
79
|
+
? "is no pin this gtd can read"
|
|
80
|
+
: `is pin format ${header.format}, made by gtd ${header.version}`
|
|
81
|
+
return {
|
|
82
|
+
kind: "refuse",
|
|
83
|
+
message: `gtd: ${found.path} ${what}; gtd ${GTD_VERSION} runs pin format ${PIN_FORMAT} — upgrade gtd (\`gtd --live\` runs this gtd instead, and may misread the process)`,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
kind: "pin",
|
|
88
|
+
argv: rest,
|
|
89
|
+
path: found.path,
|
|
90
|
+
version: header.version,
|
|
91
|
+
source: () => readPin(found.bytes).source,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface Launcher {
|
|
96
|
+
readonly stdout: (chunk: string) => void
|
|
97
|
+
readonly stderr: (chunk: string) => void
|
|
98
|
+
readonly exit: (code: number) => void
|
|
99
|
+
readonly live: (argv: readonly string[]) => void | Promise<void>
|
|
100
|
+
readonly pin: (launch: Extract<Launch, { kind: "pin" }>) => Promise<void>
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const isJsonFlag = (word: string): boolean => word === "--json" || word.startsWith("--json=")
|
|
104
|
+
|
|
105
|
+
// The CLI's own error envelope too, which a `--json` driver reads.
|
|
106
|
+
const refusalText = (argv: readonly string[], message: string): string =>
|
|
107
|
+
(optionWords(argv).some(isJsonFlag)
|
|
108
|
+
? `${JSON.stringify({ state: "error", prompt: message })}\n`
|
|
109
|
+
: "") + `${message}\n`
|
|
110
|
+
|
|
111
|
+
const runPin = async (
|
|
112
|
+
launch: Extract<Launch, { kind: "pin" }>,
|
|
113
|
+
launcher: Launcher,
|
|
114
|
+
): Promise<void> => {
|
|
115
|
+
const words = optionWords(launch.argv)
|
|
116
|
+
if (words.includes("--verbose") || words.includes("-v"))
|
|
117
|
+
launcher.stderr(`launcher: running the pin ${launch.path} (gtd ${launch.version})\n`)
|
|
118
|
+
try {
|
|
119
|
+
await launcher.pin(launch)
|
|
120
|
+
} catch (error) {
|
|
121
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
122
|
+
launcher.stderr(`gtd: could not run the pin ${launch.path}: ${reason}\n`)
|
|
123
|
+
launcher.exit(EXIT_RUNTIME_ERROR)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const runLauncher = async (
|
|
128
|
+
argv: readonly string[],
|
|
129
|
+
pinFile: PinFile,
|
|
130
|
+
launcher: Launcher,
|
|
131
|
+
): Promise<void> => {
|
|
132
|
+
const launch = launchFor(argv, pinFile)
|
|
133
|
+
switch (launch.kind) {
|
|
134
|
+
case "live":
|
|
135
|
+
return launcher.live(launch.argv)
|
|
136
|
+
case "output":
|
|
137
|
+
return launcher.stdout(launch.stdout)
|
|
138
|
+
case "refuse":
|
|
139
|
+
launcher.stderr(refusalText(argv, launch.message))
|
|
140
|
+
return launcher.exit(EXIT_RUNTIME_ERROR)
|
|
141
|
+
case "pin":
|
|
142
|
+
return runPin(launch, launcher)
|
|
143
|
+
}
|
|
144
|
+
}
|
package/src/cli/Program.test.ts
CHANGED
|
@@ -33,7 +33,6 @@ import {
|
|
|
33
33
|
restoredOutcome,
|
|
34
34
|
} from "../emit/index.js"
|
|
35
35
|
import { GitService, HISTORY_REF, Host } from "../platform/index.js"
|
|
36
|
-
import { contentHash } from "../workflow/index.js"
|
|
37
36
|
import { WORKFLOWS_REPO } from "../install/index.js"
|
|
38
37
|
|
|
39
38
|
const run = async (
|
|
@@ -111,7 +110,7 @@ export const hotfix = workflow(
|
|
|
111
110
|
expect(exitCode).toBe(0)
|
|
112
111
|
expect(stdout).toContain(
|
|
113
112
|
commitAll(
|
|
114
|
-
`gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-
|
|
113
|
+
`gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-Format: 2`,
|
|
115
114
|
),
|
|
116
115
|
)
|
|
117
116
|
expect(repo.commitHistory()).toHaveLength(before)
|
|
@@ -1718,6 +1717,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
|
|
|
1718
1717
|
start: { kind: "start", name: "fix", args: [], vars: {} },
|
|
1719
1718
|
abandon: { kind: "abandon" },
|
|
1720
1719
|
restore: { kind: "restore" },
|
|
1720
|
+
repin: { kind: "repin" },
|
|
1721
1721
|
next: { kind: "next" },
|
|
1722
1722
|
validate: { kind: "validate" },
|
|
1723
1723
|
check: { kind: "check", mode: "qa", file: ".gtd/TODO.md" },
|
|
@@ -1744,7 +1744,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
|
|
|
1744
1744
|
const NO_COMMITS_MESSAGE =
|
|
1745
1745
|
"gtd: the repository has no commits yet — make an initial commit, then run gtd again"
|
|
1746
1746
|
|
|
1747
|
-
it("derives exactly the
|
|
1747
|
+
it("derives exactly the eighteen non-standalone kinds — a canary for the table-driven cases below", () => {
|
|
1748
1748
|
expect(stateKinds.sort()).toEqual(
|
|
1749
1749
|
[
|
|
1750
1750
|
"abandon",
|
|
@@ -1754,6 +1754,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
|
|
|
1754
1754
|
"land",
|
|
1755
1755
|
"next",
|
|
1756
1756
|
"restore",
|
|
1757
|
+
"repin",
|
|
1757
1758
|
"summary",
|
|
1758
1759
|
"validate",
|
|
1759
1760
|
"ui",
|
package/src/cli/Program.ts
CHANGED
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
summaryRun,
|
|
66
66
|
type RenderedRest,
|
|
67
67
|
} from "../edge/index.js"
|
|
68
|
+
import { formatCommitMessage, PIN_STATES } from "../replay/index.js"
|
|
68
69
|
import { planStart, planStep as planStepPure, type JudgeVerdict } from "../step/index.js"
|
|
69
70
|
import {
|
|
70
71
|
BUILT_IN_MODE_NAMES,
|
|
@@ -82,6 +83,9 @@ import {
|
|
|
82
83
|
hardResetTo,
|
|
83
84
|
mixedResetTo,
|
|
84
85
|
unpinScript,
|
|
86
|
+
repinScript,
|
|
87
|
+
commitPath,
|
|
88
|
+
transitionOutcome,
|
|
85
89
|
ScriptSurface,
|
|
86
90
|
updateRef,
|
|
87
91
|
type RunnableScript,
|
|
@@ -154,6 +158,7 @@ export type Command =
|
|
|
154
158
|
}
|
|
155
159
|
| { readonly kind: "abandon" }
|
|
156
160
|
| { readonly kind: "restore" }
|
|
161
|
+
| { readonly kind: "repin" }
|
|
157
162
|
| { readonly kind: "next" }
|
|
158
163
|
| { readonly kind: "validate" }
|
|
159
164
|
| {
|
|
@@ -619,7 +624,6 @@ const startWorkflow = (
|
|
|
619
624
|
first: started,
|
|
620
625
|
commandLabel,
|
|
621
626
|
vars: varOverrides,
|
|
622
|
-
config: rest.config,
|
|
623
627
|
},
|
|
624
628
|
)
|
|
625
629
|
if (plan.kind === "refusal") {
|
|
@@ -863,6 +867,40 @@ const runRestoreCommand = (
|
|
|
863
867
|
yield* writeScript(json, out, combinedScript(required, ""))
|
|
864
868
|
})
|
|
865
869
|
|
|
870
|
+
/**
|
|
871
|
+
* `gtd repin`: the pin beat's script and landing, on demand. It runs live,
|
|
872
|
+
* so reading the rest first refuses a live workflow that cannot replay the
|
|
873
|
+
* process before any pin is written.
|
|
874
|
+
*/
|
|
875
|
+
const runRepinCommand = (
|
|
876
|
+
json: JsonMode,
|
|
877
|
+
out: ArtifactOut,
|
|
878
|
+
): Effect.Effect<void, Error, CommandRequirements> =>
|
|
879
|
+
Effect.gen(function* () {
|
|
880
|
+
const rest = yield* currentRest
|
|
881
|
+
const refusal =
|
|
882
|
+
rest.pin === "add"
|
|
883
|
+
? "the next beat pins the process already — run it (`gtd next --json=content`), then land"
|
|
884
|
+
: rest.pin === "remove"
|
|
885
|
+
? "the flow has ended — its unpin beat is next, so there is nothing to repin"
|
|
886
|
+
: noProcessUnderway(rest)
|
|
887
|
+
? "no gtd process is underway — nothing to repin"
|
|
888
|
+
: undefined
|
|
889
|
+
if (refusal !== undefined) return yield* Effect.fail(new Error(`gtd repin: ${refusal}`))
|
|
890
|
+
const message = formatCommitMessage({
|
|
891
|
+
actor: "check",
|
|
892
|
+
from: PIN_STATES.add,
|
|
893
|
+
to: rest.step.name,
|
|
894
|
+
pin: "add",
|
|
895
|
+
})
|
|
896
|
+
const steps: EmitStep[] = [
|
|
897
|
+
{ kind: "gitWrite", command: repinScript(PIN_PATH) },
|
|
898
|
+
{ kind: "gitWrite", command: commitPath(PIN_PATH, message) },
|
|
899
|
+
{ kind: "outcome", command: transitionOutcome(PIN_STATES.add, rest.step.name) },
|
|
900
|
+
]
|
|
901
|
+
yield* writeScript(json, out, combinedScript(emitScripts(steps).required, ""))
|
|
902
|
+
})
|
|
903
|
+
|
|
866
904
|
/**
|
|
867
905
|
* The mode's own validate command for `file`: a declared `validate:` command
|
|
868
906
|
* verbatim; a `"builtin"` validator or no mode at all names the leaf
|
|
@@ -1340,6 +1378,8 @@ const dispatchVoidCommand = (
|
|
|
1340
1378
|
return runAbandonCommand(json, out)
|
|
1341
1379
|
case "restore":
|
|
1342
1380
|
return runRestoreCommand(json, out)
|
|
1381
|
+
case "repin":
|
|
1382
|
+
return runRepinCommand(json, out)
|
|
1343
1383
|
case "next":
|
|
1344
1384
|
return runNextCommand(json, out)
|
|
1345
1385
|
case "validate":
|
package/src/cli/index.ts
CHANGED
package/src/edge/Edge.test.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { keptRef, processHistoryRef } from "../platform/index.js"
|
|
|
23
23
|
import { GitService, Workspace, type GitOperations, type WorkspaceOps } from "../platform/index.js"
|
|
24
24
|
import type { Mark } from "../flows/index.js"
|
|
25
25
|
import { formatCommitMessage, type CommitSpec } from "../replay/index.js"
|
|
26
|
-
import {
|
|
26
|
+
import { StartFlags } from "../workflow/index.js"
|
|
27
27
|
import {
|
|
28
28
|
fakeGitOperations,
|
|
29
29
|
InMemRepo,
|
|
@@ -217,7 +217,7 @@ describe("currentRun", () => {
|
|
|
217
217
|
const before = headOf(repo)
|
|
218
218
|
const opening = await enter(repo, "side")
|
|
219
219
|
expect(repo.lastCommitMessage(opening)).toBe(
|
|
220
|
-
`gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-
|
|
220
|
+
`gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Format: 2`,
|
|
221
221
|
)
|
|
222
222
|
const fixed = await land(repo, { "fix.txt": "x\n" })
|
|
223
223
|
const run = await provide(currentRun, repo)
|
|
@@ -250,7 +250,7 @@ export const again = workflow(async () => {
|
|
|
250
250
|
const before = headOf(repo)
|
|
251
251
|
const opening = await enter(repo, "again")
|
|
252
252
|
expect(repo.lastCommitMessage(opening)).toBe(
|
|
253
|
-
`gtd(human): idle\n\nGtd-Workflow: again\nGtd-
|
|
253
|
+
`gtd(human): idle\n\nGtd-Workflow: again\nGtd-Format: 2`,
|
|
254
254
|
)
|
|
255
255
|
const fixed = await land(repo, { "fix.txt": "x\n" })
|
|
256
256
|
const run = await provide(currentRun, repo)
|
package/src/edge/Edge.ts
CHANGED
|
@@ -12,8 +12,6 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
ConfigDiscovery,
|
|
14
14
|
ConfigService,
|
|
15
|
-
changedConfigFiles,
|
|
16
|
-
contentHash,
|
|
17
15
|
multilineSetting,
|
|
18
16
|
resolveVars,
|
|
19
17
|
resolveStartVars,
|
|
@@ -173,8 +171,6 @@ export interface ProcessRun {
|
|
|
173
171
|
readonly approvals: readonly ApprovalEntry[]
|
|
174
172
|
/** The process settings recorded in the process's first commit (empty for a process started before pinning existed). */
|
|
175
173
|
readonly pinnedVars: Record<string, string>
|
|
176
|
-
/** Absent for a process started before recording existed. */
|
|
177
|
-
readonly pinnedConfig: { readonly commit: string; readonly files: ConfigFingerprint } | undefined
|
|
178
174
|
/** HEAD's own gtd commit, when it is one — `empty` is whether it changed nothing. */
|
|
179
175
|
readonly headTurn:
|
|
180
176
|
| {
|
|
@@ -248,17 +244,6 @@ const runOf = (
|
|
|
248
244
|
: Effect.fail(new Error(fault))
|
|
249
245
|
}
|
|
250
246
|
|
|
251
|
-
// The newest recording, not the first process commit's: the episode is
|
|
252
|
-
// located by the CURRENT config's first step, so a config that renamed it can
|
|
253
|
-
// move the boundary onto the start commit itself, or behind it.
|
|
254
|
-
const pinnedConfigOf = (history: History): ProcessRun["pinnedConfig"] => {
|
|
255
|
-
for (let i = history.length - 1; i >= 0; i--) {
|
|
256
|
-
const files = parseCommitMessage(history[i]!.message).config
|
|
257
|
-
if (Object.keys(files).length > 0) return { commit: history[i]!.hash, files }
|
|
258
|
-
}
|
|
259
|
-
return undefined
|
|
260
|
-
}
|
|
261
|
-
|
|
262
247
|
const approvalEntryOf = (
|
|
263
248
|
hash: string,
|
|
264
249
|
message: ReturnType<typeof parseCommitMessage>,
|
|
@@ -300,7 +285,6 @@ const buildRun = (
|
|
|
300
285
|
marks: parsed.flatMap((m) => m.marks),
|
|
301
286
|
approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i]!)),
|
|
302
287
|
pinnedVars: { ...first?.vars },
|
|
303
|
-
pinnedConfig: pinnedConfigOf(history.slice(Math.max(location.baseIndex, 0))),
|
|
304
288
|
headTurn: headTurnOf(head),
|
|
305
289
|
closingHash,
|
|
306
290
|
episode: {
|
|
@@ -420,37 +404,6 @@ const computeProcessRun = (
|
|
|
420
404
|
runOf(history, locateEpisode(def, history)),
|
|
421
405
|
)
|
|
422
406
|
|
|
423
|
-
// A file matching its copy in the recording commit is unchanged: the hashes
|
|
424
|
-
// are taken before the commit's own hooks run, and a hook may reformat it.
|
|
425
|
-
const changedSinceStart = (
|
|
426
|
-
pinned: ProcessRun["pinnedConfig"],
|
|
427
|
-
current: ConfigFingerprint,
|
|
428
|
-
workspace: WorkspaceOps,
|
|
429
|
-
): readonly string[] => {
|
|
430
|
-
if (pinned === undefined) return []
|
|
431
|
-
return changedConfigFiles(pinned.files, current).filter((path) => {
|
|
432
|
-
const committed = workspace.readCommittedSync(path, pinned.commit)
|
|
433
|
-
return (
|
|
434
|
-
current[path] === undefined ||
|
|
435
|
-
committed === undefined ||
|
|
436
|
-
contentHash(committed) !== current[path]
|
|
437
|
-
)
|
|
438
|
-
})
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
const warnConfigChanges = (
|
|
442
|
-
run: ProcessRun,
|
|
443
|
-
current: ConfigFingerprint,
|
|
444
|
-
workspace: WorkspaceOps,
|
|
445
|
-
): Effect.Effect<void, never, Narrator> =>
|
|
446
|
-
Effect.gen(function* () {
|
|
447
|
-
const changed = changedSinceStart(run.pinnedConfig, current, workspace)
|
|
448
|
-
if (changed.length === 0) return
|
|
449
|
-
yield* (yield* Narrator).warn(
|
|
450
|
-
`gtd: warning: the config changed since this process started (${changed.join(", ")}) — the rest of the process replays under the changed config`,
|
|
451
|
-
)
|
|
452
|
-
})
|
|
453
|
-
|
|
454
407
|
type ConfigRequirements = GitService | ConfigService | ConfigDiscovery | Narrator | Workspace | Host
|
|
455
408
|
|
|
456
409
|
/** The run alone, never replaying — `gtd abandon` must work even when replay would refuse, short of a history it cannot read. */
|
|
@@ -876,8 +829,6 @@ interface Rest extends ResolvedRest {
|
|
|
876
829
|
readonly run: ProcessRun
|
|
877
830
|
readonly vars: Record<string, string>
|
|
878
831
|
readonly env: Record<string, string>
|
|
879
|
-
/** What a landing that starts the process records. */
|
|
880
|
-
readonly config: ConfigFingerprint
|
|
881
832
|
readonly changes: readonly PendingChange[]
|
|
882
833
|
readonly memory: string | undefined
|
|
883
834
|
readonly memoryResumed: boolean
|
|
@@ -1030,11 +981,13 @@ export const restAt = (
|
|
|
1030
981
|
path: e.path,
|
|
1031
982
|
}))
|
|
1032
983
|
: []
|
|
1033
|
-
|
|
1034
|
-
//
|
|
1035
|
-
//
|
|
1036
|
-
|
|
1037
|
-
|
|
984
|
+
// Only the current rest notices, so a command must read it once — a
|
|
985
|
+
// second read notices twice. An earlier ref (restore's tip) is not the
|
|
986
|
+
// process the command acts on.
|
|
987
|
+
if (ref === undefined && config.drift.length > 0)
|
|
988
|
+
yield* (yield* Narrator).warn(
|
|
989
|
+
`gtd: notice: the live config differs from the pinned one (${config.drift.join(", ")}); \`gtd repin\` to switch`,
|
|
990
|
+
)
|
|
1038
991
|
return {
|
|
1039
992
|
def,
|
|
1040
993
|
workflowName: resolved.name,
|
|
@@ -1048,7 +1001,6 @@ export const restAt = (
|
|
|
1048
1001
|
run,
|
|
1049
1002
|
vars,
|
|
1050
1003
|
env,
|
|
1051
|
-
config: configNow,
|
|
1052
1004
|
changes,
|
|
1053
1005
|
memory: memory.key,
|
|
1054
1006
|
memoryResumed: memory.resumed,
|
|
@@ -1406,8 +1358,7 @@ const pinRefusal = (pin: PinChange): Landing => ({
|
|
|
1406
1358
|
: `gtd land: ${PIN_PATH} is still there — run the unpin beat's script (\`gtd next --json=content\`), then land`,
|
|
1407
1359
|
})
|
|
1408
1360
|
|
|
1409
|
-
const startTrailers = (rest: Rest, starts: boolean) =>
|
|
1410
|
-
starts ? { vars: rest.vars, config: rest.config } : {}
|
|
1361
|
+
const startTrailers = (rest: Rest, starts: boolean) => (starts ? { vars: rest.vars } : {})
|
|
1411
1362
|
|
|
1412
1363
|
const outcomeMarks = (outcome: ReplayOutcome): readonly Mark[] =>
|
|
1413
1364
|
outcome.kind === "rest" || outcome.kind === "ended" ? outcome.marks : []
|
|
@@ -1543,13 +1494,13 @@ export interface HistoryTrail {
|
|
|
1543
1494
|
readonly versionWarning?: string
|
|
1544
1495
|
}
|
|
1545
1496
|
|
|
1546
|
-
/**
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
): readonly string[] => {
|
|
1552
|
-
const paths = new Set<string>(
|
|
1497
|
+
/**
|
|
1498
|
+
* Repository-relative paths of today's config files; one outside the
|
|
1499
|
+
* repository cannot be read at a ref, so it is left out. A file only the old
|
|
1500
|
+
* config read needs no check: whichever file imported it differs too.
|
|
1501
|
+
*/
|
|
1502
|
+
const inRepoConfigPaths = (root: string, files: readonly string[]): readonly string[] => {
|
|
1503
|
+
const paths = new Set<string>()
|
|
1553
1504
|
for (const file of files) paths.add(relative(root, resolve(root, file)).split(sep).join("/"))
|
|
1554
1505
|
return [...paths].filter((p) => p !== "" && !p.startsWith("..") && !isAbsolute(p)).sort()
|
|
1555
1506
|
}
|
|
@@ -1569,16 +1520,11 @@ const differingConfigPaths = (
|
|
|
1569
1520
|
workspace: Workspace["Service"],
|
|
1570
1521
|
root: string,
|
|
1571
1522
|
config: { readonly fingerprint: ConfigFingerprint },
|
|
1572
|
-
run: ProcessRun,
|
|
1573
1523
|
hash: string,
|
|
1574
1524
|
) =>
|
|
1575
1525
|
Effect.gen(function* () {
|
|
1576
1526
|
const differing: string[] = []
|
|
1577
|
-
for (const path of inRepoConfigPaths(
|
|
1578
|
-
root,
|
|
1579
|
-
Object.keys(config.fingerprint),
|
|
1580
|
-
run.pinnedConfig?.files ?? {},
|
|
1581
|
-
)) {
|
|
1527
|
+
for (const path of inRepoConfigPaths(root, Object.keys(config.fingerprint))) {
|
|
1582
1528
|
const atTip = yield* Effect.orElseSucceed(git.readFileAtRef(hash, path), () => undefined)
|
|
1583
1529
|
if (atTip !== workspace.readSync(path)) differing.push(path)
|
|
1584
1530
|
}
|
|
@@ -1664,7 +1610,7 @@ const replayFinishedProcess = (
|
|
|
1664
1610
|
// A refusal is often caused by the version change, so it carries the warning.
|
|
1665
1611
|
const withWarning = (err: Error): Error =>
|
|
1666
1612
|
versionWarning === undefined ? err : new Error(`${err.message}\n${versionWarning}`)
|
|
1667
|
-
const differing = yield* differingConfigPaths(git, workspace, host.root, config,
|
|
1613
|
+
const differing = yield* differingConfigPaths(git, workspace, host.root, config, hash)
|
|
1668
1614
|
if (differing.length > 0)
|
|
1669
1615
|
return yield* Effect.fail(
|
|
1670
1616
|
withWarning(configRefusal(how.command, hash, differing, how.configReason)),
|
package/src/emit/GitScript.ts
CHANGED
|
@@ -44,6 +44,13 @@ export const pinScript = (path: string): string => {
|
|
|
44
44
|
return `${mkdir}gtd bundle >${p} || { rm -f -- ${p}; exit 1; }`
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** `gtd repin`'s pin write: a failed `gtd bundle` leaves the committed pin in place. */
|
|
48
|
+
export const repinScript = (path: string): string => {
|
|
49
|
+
const p = shellQuote(path)
|
|
50
|
+
const next = shellQuote(`${path}.new`)
|
|
51
|
+
return `gtd bundle >${next} || { rm -f -- ${next}; exit 1; }\nmv -f -- ${next} ${p}`
|
|
52
|
+
}
|
|
53
|
+
|
|
47
54
|
/** An unpin beat's script, and `gtd abandon`'s removal of the pin. */
|
|
48
55
|
export const unpinScript = (path: string): string => `rm -f -- ${shellQuote(path)}`
|
|
49
56
|
|