devrites 3.2.16 → 3.2.18
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/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/engine/Makefile +3 -3
- package/engine/hooks.go +20 -4
- package/engine/hooks_agent_dispatch.go +297 -48
- package/engine/hooks_events_test.go +40 -0
- package/engine/hooks_workspace.go +26 -6
- package/engine/internal/devritespaths/paths.go +80 -0
- package/engine/internal/install/install.go +36 -16
- package/engine/internal/install/install_test.go +50 -1
- package/engine/internal/iohooks/iohooks.go +5 -3
- package/engine/internal/iohooks/iohooks_test.go +11 -0
- package/engine/internal/lib/adoption.go +36 -11
- package/engine/internal/lib/adoption_test.go +30 -0
- package/engine/internal/lib/analyze.go +10 -2
- package/engine/internal/lib/archivesearch.go +41 -9
- package/engine/internal/lib/archivesearch_test.go +28 -0
- package/engine/internal/lib/buildreadiness.go +5 -1
- package/engine/internal/lib/closeout.go +34 -11
- package/engine/internal/lib/closeout_test.go +99 -0
- package/engine/internal/lib/context.go +6 -1
- package/engine/internal/lib/dispatchwaive.go +31 -0
- package/engine/internal/lib/dispatchwaive_test.go +23 -0
- package/engine/internal/lib/extensions.go +5 -1
- package/engine/internal/lib/learnings.go +8 -2
- package/engine/internal/lib/ledger.go +17 -4
- package/engine/internal/lib/ledger_test.go +24 -0
- package/engine/internal/lib/mutationgate.go +6 -2
- package/engine/internal/lib/reconcile.go +201 -64
- package/engine/internal/lib/reconcile_test.go +159 -12
- package/engine/internal/lib/resolve.go +8 -3
- package/engine/internal/lib/resolve_remediation_test.go +14 -0
- package/engine/internal/lib/runbook.go +4 -1
- package/engine/internal/lib/runbook_context_test.go +43 -0
- package/engine/internal/lib/testintegrity.go +4 -4
- package/engine/internal/lib/testintegrity_test.go +15 -0
- package/engine/internal/lib/tickafk.go +6 -2
- package/engine/internal/lib/util.go +128 -23
- package/engine/internal/profile/profile.go +27 -13
- package/engine/internal/profile/profile_test.go +13 -0
- package/engine/main.go +4 -1
- package/engine/testdata/golden/TestParityReconcile/close-window.golden +1 -0
- package/engine/testdata/golden/TestParityReconcile/not-git.golden +1 -1
- package/engine/testdata/golden/TestParityTestIntegrity/not-git.golden +1 -1
- package/engine/tests/hook_test.go +353 -17
- package/engine/tests/parity_reconcile_test.go +4 -1
- package/engine/tests/parity_testintegrity_test.go +1 -1
- package/pack/.claude/agents/devrites-code-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-devex-reviewer.md +12 -8
- package/pack/.claude/agents/devrites-doubt-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-forge-judge.md +4 -1
- package/pack/.claude/agents/devrites-frontend-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-performance-reviewer.md +10 -9
- package/pack/.claude/agents/devrites-plan-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-proof-runner.md +14 -14
- package/pack/.claude/agents/devrites-retrospector.md +3 -0
- package/pack/.claude/agents/devrites-security-auditor.md +3 -0
- package/pack/.claude/agents/devrites-simplifier-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-slice-wright.md +4 -2
- package/pack/.claude/agents/devrites-spec-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-strategy-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-test-analyst.md +3 -0
- package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +8 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/agents.md +10 -0
- package/pack/.claude/skills/devrites-source-driven/SKILL.md +1 -1
- package/pack/.claude/skills/rite-build/reference/phase-contract.md +17 -3
- package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +3 -1
- package/pack/.claude/skills/rite-prove/SKILL.md +25 -14
- package/pack/.claude/skills/rite-temper/SKILL.md +1 -1
- package/pack/.claude/skills/rite-upgrade/SKILL.md +1 -1
- package/pack/.claude/skills/rite-vet/SKILL.md +18 -15
- package/pack/generated/claude/agents/devrites-code-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-devex-reviewer.md +12 -8
- package/pack/generated/claude/agents/devrites-doubt-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-forge-judge.md +4 -1
- package/pack/generated/claude/agents/devrites-frontend-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-performance-reviewer.md +10 -9
- package/pack/generated/claude/agents/devrites-plan-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-proof-runner.md +14 -14
- package/pack/generated/claude/agents/devrites-retrospector.md +3 -0
- package/pack/generated/claude/agents/devrites-security-auditor.md +3 -0
- package/pack/generated/claude/agents/devrites-simplifier-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-slice-wright.md +4 -2
- package/pack/generated/claude/agents/devrites-spec-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-strategy-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-test-analyst.md +3 -0
- package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +8 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/agents.md +10 -0
- package/pack/generated/claude/skills/devrites-source-driven/SKILL.md +1 -1
- package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +17 -3
- package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +3 -1
- package/pack/generated/claude/skills/rite-prove/SKILL.md +25 -14
- package/pack/generated/claude/skills/rite-temper/SKILL.md +1 -1
- package/pack/generated/claude/skills/rite-upgrade/SKILL.md +1 -1
- package/pack/generated/claude/skills/rite-vet/SKILL.md +18 -15
- package/pack/generated/codex/agents/devrites-code-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-devex-reviewer.toml +11 -7
- package/pack/generated/codex/agents/devrites-doubt-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-forge-judge.toml +4 -1
- package/pack/generated/codex/agents/devrites-frontend-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-performance-reviewer.toml +9 -8
- package/pack/generated/codex/agents/devrites-plan-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-proof-runner.toml +13 -13
- package/pack/generated/codex/agents/devrites-retrospector.toml +3 -0
- package/pack/generated/codex/agents/devrites-security-auditor.toml +3 -0
- package/pack/generated/codex/agents/devrites-simplifier-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-slice-wright.toml +4 -2
- package/pack/generated/codex/agents/devrites-spec-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-strategy-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-test-analyst.toml +3 -0
- package/pack/generated/codex/hooks.json +1 -1
- package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +8 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/agents.md +10 -0
- package/pack/generated/codex/skills/devrites-source-driven/SKILL.md +1 -1
- package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +17 -3
- package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +3 -1
- package/pack/generated/codex/skills/rite-prove/SKILL.md +25 -14
- package/pack/generated/codex/skills/rite-temper/SKILL.md +1 -1
- package/pack/generated/codex/skills/rite-upgrade/SKILL.md +1 -1
- package/pack/generated/codex/skills/rite-vet/SKILL.md +18 -15
- package/package.json +1 -1
- package/scripts/codex-generate.sh +1 -1
|
@@ -7,12 +7,12 @@ import (
|
|
|
7
7
|
"fmt"
|
|
8
8
|
"io"
|
|
9
9
|
"os"
|
|
10
|
-
"os/exec"
|
|
11
10
|
"path"
|
|
12
11
|
"path/filepath"
|
|
13
12
|
"sort"
|
|
14
13
|
"strings"
|
|
15
14
|
|
|
15
|
+
"github.com/devrites/devrites/internal/devritespaths"
|
|
16
16
|
"github.com/devrites/devrites/internal/forge"
|
|
17
17
|
"github.com/devrites/devrites/internal/safepath"
|
|
18
18
|
)
|
|
@@ -38,7 +38,8 @@ const (
|
|
|
38
38
|
// while retaining the original source baseline.
|
|
39
39
|
// `check` compares the captured state with the current state and retains the
|
|
40
40
|
// immutable baseline for the later test-integrity gate.
|
|
41
|
-
// `
|
|
41
|
+
// `restore-check` rolls back only source drift introduced after the last clean
|
|
42
|
+
// check. `close` revalidates and then removes the private window artifacts.
|
|
42
43
|
//
|
|
43
44
|
// 0 clean check, snapshot/close completed, or skipped (not a git repo)
|
|
44
45
|
// 5 VIOLATION: a path changed outside the orchestrator allowlist
|
|
@@ -50,8 +51,7 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
50
51
|
slug = activeSlug(root)
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
if slug == "" || !isDir(d) {
|
|
54
|
+
if slug == "" {
|
|
55
55
|
s := slug
|
|
56
56
|
if s == "" {
|
|
57
57
|
s = "<unset>"
|
|
@@ -59,6 +59,15 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
59
59
|
fmt.Fprintf(stderr, "reconcile: no active workspace (slug=%s): nothing to reconcile.\n", s)
|
|
60
60
|
return 6
|
|
61
61
|
}
|
|
62
|
+
d, err := devritespaths.ExistingFeatureDirChecked(root, slug)
|
|
63
|
+
if err != nil {
|
|
64
|
+
if os.IsNotExist(err) {
|
|
65
|
+
fmt.Fprintf(stderr, "reconcile: no active workspace (slug=%s): nothing to reconcile.\n", slug)
|
|
66
|
+
} else {
|
|
67
|
+
fmt.Fprintf(stderr, "reconcile: invalid workspace for %s: %v\n", slug, err)
|
|
68
|
+
}
|
|
69
|
+
return 6
|
|
70
|
+
}
|
|
62
71
|
|
|
63
72
|
base := filepath.Join(d, reconcileBaseName)
|
|
64
73
|
capturedAllowlist := filepath.Join(d, reconcileAllowlistName)
|
|
@@ -84,6 +93,17 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
84
93
|
}
|
|
85
94
|
|
|
86
95
|
if mode == "close" {
|
|
96
|
+
info, err := os.Lstat(checked)
|
|
97
|
+
if err != nil || !info.Mode().IsRegular() {
|
|
98
|
+
fmt.Fprintln(stderr, `reconcile: active slice window has no clean check marker; run "reconcile check" before closing it.`)
|
|
99
|
+
return 6
|
|
100
|
+
}
|
|
101
|
+
// Defense in depth: a prior marker proves the explicit check occurred;
|
|
102
|
+
// rerunning it here binds close to the current source and canonical
|
|
103
|
+
// workspace state rather than trusting a stale marker.
|
|
104
|
+
if code := Reconcile(root, []string{"check", slug}, stdout, stderr); code != 0 {
|
|
105
|
+
return code
|
|
106
|
+
}
|
|
87
107
|
if err := closeWindow(); err != nil {
|
|
88
108
|
fmt.Fprintf(stderr, "reconcile: cannot close slice window: %v\n", err)
|
|
89
109
|
return 6
|
|
@@ -93,10 +113,10 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
93
113
|
}
|
|
94
114
|
|
|
95
115
|
cwd, _ := os.Getwd()
|
|
96
|
-
gitRoot := gitToplevel(cwd)
|
|
97
|
-
if
|
|
98
|
-
fmt.
|
|
99
|
-
return
|
|
116
|
+
gitRoot, err := gitToplevel(cwd)
|
|
117
|
+
if err != nil {
|
|
118
|
+
fmt.Fprintf(stderr, "reconcile: cannot resolve git worktree: %v\n", err)
|
|
119
|
+
return 6
|
|
100
120
|
}
|
|
101
121
|
|
|
102
122
|
switch mode {
|
|
@@ -200,6 +220,55 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
200
220
|
fmt.Fprintf(stdout, "reconcile: snapshot captured for %s.\n", slug)
|
|
201
221
|
return 0
|
|
202
222
|
|
|
223
|
+
case "restore-check":
|
|
224
|
+
_, env, activeObjects, active, err := loadReconcileBaseline(gitRoot, d)
|
|
225
|
+
if err != nil {
|
|
226
|
+
fmt.Fprintf(stderr, "reconcile: invalid snapshot: %v\n", err)
|
|
227
|
+
return 6
|
|
228
|
+
}
|
|
229
|
+
if !active {
|
|
230
|
+
fmt.Fprintln(stderr, `reconcile: no snapshot (.reconcile-base/.reconcile-objects): nothing can be restored.`)
|
|
231
|
+
return 6
|
|
232
|
+
}
|
|
233
|
+
checkedData, err := os.ReadFile(checked)
|
|
234
|
+
if err != nil {
|
|
235
|
+
fmt.Fprintln(stderr, `reconcile: active slice window has no clean check marker; nothing can be restored.`)
|
|
236
|
+
return 6
|
|
237
|
+
}
|
|
238
|
+
checkedTree := strings.TrimSpace(string(checkedData))
|
|
239
|
+
if checkedTree == "" || strings.ContainsAny(checkedTree, " \t\r\n") {
|
|
240
|
+
fmt.Fprintf(stderr, "reconcile: invalid %s: expected one tree id\n", reconcileCheckedName)
|
|
241
|
+
return 6
|
|
242
|
+
}
|
|
243
|
+
nowTree, err := worktreeTree(gitRoot, activeObjects, root)
|
|
244
|
+
if err != nil {
|
|
245
|
+
fmt.Fprintf(stderr, "reconcile: cannot capture worktree for restore: %v\n", err)
|
|
246
|
+
return 6
|
|
247
|
+
}
|
|
248
|
+
changed, err := restoreTreeDelta(gitRoot, env, checkedTree, nowTree)
|
|
249
|
+
if err != nil {
|
|
250
|
+
fmt.Fprintf(stderr, "reconcile: cannot restore post-check source drift: %v\n", err)
|
|
251
|
+
return 6
|
|
252
|
+
}
|
|
253
|
+
restoredTree, err := worktreeTree(gitRoot, activeObjects, root)
|
|
254
|
+
if err != nil {
|
|
255
|
+
fmt.Fprintf(stderr, "reconcile: cannot verify restored worktree: %v\n", err)
|
|
256
|
+
return 6
|
|
257
|
+
}
|
|
258
|
+
if restoredTree != checkedTree {
|
|
259
|
+
fmt.Fprintln(stderr, "reconcile: restored worktree does not match the last clean check; retained window left open.")
|
|
260
|
+
return 6
|
|
261
|
+
}
|
|
262
|
+
if len(changed) == 0 {
|
|
263
|
+
fmt.Fprintln(stdout, "reconcile: no post-check source drift to restore.")
|
|
264
|
+
return 0
|
|
265
|
+
}
|
|
266
|
+
fmt.Fprintln(stdout, "reconcile: restored only the post-check source delta:")
|
|
267
|
+
for _, changedPath := range changed {
|
|
268
|
+
fmt.Fprintf(stdout, " - %s\n", changedPath)
|
|
269
|
+
}
|
|
270
|
+
return 0
|
|
271
|
+
|
|
203
272
|
case "check":
|
|
204
273
|
baseTree, env, _, active, err := loadReconcileBaseline(gitRoot, d)
|
|
205
274
|
if err != nil {
|
|
@@ -240,6 +309,29 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
240
309
|
fmt.Fprintf(stderr, "reconcile: cannot capture worktree: %v\n", err)
|
|
241
310
|
return 6
|
|
242
311
|
}
|
|
312
|
+
if checkedData, checkedErr := os.ReadFile(checked); checkedErr == nil {
|
|
313
|
+
checkedTree := strings.TrimSpace(string(checkedData))
|
|
314
|
+
if checkedTree == "" || strings.ContainsAny(checkedTree, " \t\r\n") {
|
|
315
|
+
fmt.Fprintf(stderr, "reconcile: invalid %s: expected one tree id\n", reconcileCheckedName)
|
|
316
|
+
return 6
|
|
317
|
+
}
|
|
318
|
+
if checkedTree != nowTree {
|
|
319
|
+
changedAfterCheck, err := changedTreePaths(gitRoot, env, checkedTree, nowTree)
|
|
320
|
+
if err != nil {
|
|
321
|
+
fmt.Fprintf(stderr, "reconcile: cannot compare with the last clean check: %v\n", err)
|
|
322
|
+
return 6
|
|
323
|
+
}
|
|
324
|
+
sort.Strings(changedAfterCheck)
|
|
325
|
+
fmt.Fprintln(stderr, "reconcile: STOP: source changed after the last clean check; root-owned artifact gates must not edit source:")
|
|
326
|
+
for _, changedPath := range changedAfterCheck {
|
|
327
|
+
fmt.Fprintf(stderr, " - %s\n", changedPath)
|
|
328
|
+
}
|
|
329
|
+
return 5
|
|
330
|
+
}
|
|
331
|
+
} else if !os.IsNotExist(checkedErr) {
|
|
332
|
+
fmt.Fprintf(stderr, "reconcile: cannot read %s: %v\n", reconcileCheckedName, checkedErr)
|
|
333
|
+
return 6
|
|
334
|
+
}
|
|
243
335
|
changed, err := changedTreePaths(gitRoot, env, baseTree, nowTree)
|
|
244
336
|
if err != nil {
|
|
245
337
|
fmt.Fprintf(stderr, "reconcile: cannot compare worktree: %v\n", err)
|
|
@@ -282,7 +374,7 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
282
374
|
return 0
|
|
283
375
|
|
|
284
376
|
default:
|
|
285
|
-
fmt.Fprintln(stderr, "reconcile: usage: devrites-engine reconcile <snapshot|check|close> [slug]")
|
|
377
|
+
fmt.Fprintln(stderr, "reconcile: usage: devrites-engine reconcile <snapshot|check|restore-check|close> [slug]")
|
|
286
378
|
return 6
|
|
287
379
|
}
|
|
288
380
|
}
|
|
@@ -692,6 +784,92 @@ func changedDevritesPaths(before, after []devritesStateEntry) []string {
|
|
|
692
784
|
return paths
|
|
693
785
|
}
|
|
694
786
|
|
|
787
|
+
// restoreTreeDelta materializes the last clean tree in a private directory and
|
|
788
|
+
// copies back only paths that changed afterward. It never touches the real Git
|
|
789
|
+
// index and rejects parent symlink escapes before deleting or writing.
|
|
790
|
+
func restoreTreeDelta(gitRoot string, env []string, checkedTree, currentTree string) ([]string, error) {
|
|
791
|
+
changed, err := changedTreePaths(gitRoot, env, checkedTree, currentTree)
|
|
792
|
+
if err != nil {
|
|
793
|
+
return nil, err
|
|
794
|
+
}
|
|
795
|
+
sort.Strings(changed)
|
|
796
|
+
if len(changed) == 0 {
|
|
797
|
+
return nil, nil
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
materialized, err := os.MkdirTemp("", "devrites-reconcile-restore-*")
|
|
801
|
+
if err != nil {
|
|
802
|
+
return nil, fmt.Errorf("create restore directory: %w", err)
|
|
803
|
+
}
|
|
804
|
+
defer func() { _ = os.RemoveAll(materialized) }()
|
|
805
|
+
index := filepath.Join(materialized, "index")
|
|
806
|
+
restoreEnv := append(append([]string{}, env...), "GIT_INDEX_FILE="+index)
|
|
807
|
+
if _, err := reconcileGitOutput(gitRoot, restoreEnv, "read-tree", checkedTree); err != nil {
|
|
808
|
+
return nil, err
|
|
809
|
+
}
|
|
810
|
+
materializedTree := filepath.Join(materialized, "tree")
|
|
811
|
+
if err := os.MkdirAll(materializedTree, 0o700); err != nil {
|
|
812
|
+
return nil, fmt.Errorf("create restore tree: %w", err)
|
|
813
|
+
}
|
|
814
|
+
gitPrefix := filepath.ToSlash(materializedTree) + "/"
|
|
815
|
+
if _, err := reconcileGitOutput(
|
|
816
|
+
gitRoot,
|
|
817
|
+
restoreEnv,
|
|
818
|
+
"-c", "core.autocrlf=false",
|
|
819
|
+
"checkout-index", "--all", "--force", "--prefix="+gitPrefix,
|
|
820
|
+
); err != nil {
|
|
821
|
+
return nil, err
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
for _, changedPath := range changed {
|
|
825
|
+
if changedPath == "." || changedPath == ".devrites" ||
|
|
826
|
+
strings.HasPrefix(changedPath, reconcileDevritesPathPrefix) ||
|
|
827
|
+
path.Clean(changedPath) != changedPath {
|
|
828
|
+
return nil, fmt.Errorf("unsafe restore path %q", changedPath)
|
|
829
|
+
}
|
|
830
|
+
target := filepath.Join(gitRoot, filepath.FromSlash(changedPath))
|
|
831
|
+
targetParent := filepath.Dir(target)
|
|
832
|
+
if !safepath.WithinResolved(targetParent, gitRoot) {
|
|
833
|
+
return nil, fmt.Errorf("restore parent escapes repository through a symlink: %s", changedPath)
|
|
834
|
+
}
|
|
835
|
+
source := filepath.Join(materializedTree, filepath.FromSlash(changedPath))
|
|
836
|
+
sourceInfo, sourceErr := os.Lstat(source)
|
|
837
|
+
if sourceErr != nil && !os.IsNotExist(sourceErr) {
|
|
838
|
+
return nil, fmt.Errorf("inspect clean source %s: %w", changedPath, sourceErr)
|
|
839
|
+
}
|
|
840
|
+
if err := os.RemoveAll(target); err != nil {
|
|
841
|
+
return nil, fmt.Errorf("remove post-check path %s: %w", changedPath, err)
|
|
842
|
+
}
|
|
843
|
+
if os.IsNotExist(sourceErr) {
|
|
844
|
+
continue
|
|
845
|
+
}
|
|
846
|
+
if err := os.MkdirAll(targetParent, 0o755); err != nil {
|
|
847
|
+
return nil, fmt.Errorf("create restore parent for %s: %w", changedPath, err)
|
|
848
|
+
}
|
|
849
|
+
switch {
|
|
850
|
+
case sourceInfo.Mode()&os.ModeSymlink != 0:
|
|
851
|
+
link, err := os.Readlink(source)
|
|
852
|
+
if err != nil {
|
|
853
|
+
return nil, fmt.Errorf("read clean symlink %s: %w", changedPath, err)
|
|
854
|
+
}
|
|
855
|
+
if err := os.Symlink(link, target); err != nil {
|
|
856
|
+
return nil, fmt.Errorf("restore symlink %s: %w", changedPath, err)
|
|
857
|
+
}
|
|
858
|
+
case sourceInfo.Mode().IsRegular():
|
|
859
|
+
data, err := os.ReadFile(source)
|
|
860
|
+
if err != nil {
|
|
861
|
+
return nil, fmt.Errorf("read clean file %s: %w", changedPath, err)
|
|
862
|
+
}
|
|
863
|
+
if err := os.WriteFile(target, data, sourceInfo.Mode().Perm()); err != nil {
|
|
864
|
+
return nil, fmt.Errorf("restore file %s: %w", changedPath, err)
|
|
865
|
+
}
|
|
866
|
+
default:
|
|
867
|
+
return nil, fmt.Errorf("unsupported clean tree entry for %s: %s", changedPath, sourceInfo.Mode())
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return changed, nil
|
|
871
|
+
}
|
|
872
|
+
|
|
695
873
|
// worktreeTree captures the working tree (tracked + untracked, .gitignore
|
|
696
874
|
// honored) as a git tree object via a throwaway index, excluding separately
|
|
697
875
|
// fingerprinted DevRites roots. Seeding from HEAD keeps committed paths tracked
|
|
@@ -715,49 +893,29 @@ func worktreeTree(gitRoot, objectDir string, excludedRoots ...string) (string, e
|
|
|
715
893
|
}
|
|
716
894
|
env = append(env, "GIT_INDEX_FILE="+idx)
|
|
717
895
|
|
|
718
|
-
|
|
719
|
-
read.Env = env
|
|
720
|
-
readOut, readErr := read.CombinedOutput()
|
|
896
|
+
_, readErr := runGitCommand(gitRoot, env, "read-tree", "HEAD")
|
|
721
897
|
if readErr != nil {
|
|
722
898
|
readFailure := fmt.Errorf("git read-tree HEAD: %w", readErr)
|
|
723
|
-
if detail := strings.TrimSpace(string(readOut)); detail != "" {
|
|
724
|
-
readFailure = fmt.Errorf("git read-tree HEAD: %w: %s", readErr, detail)
|
|
725
|
-
}
|
|
726
899
|
// An unborn branch is the one valid reason HEAD cannot seed the index.
|
|
727
900
|
// Distinguish it from a corrupt or unreadable existing ref so the gate
|
|
728
901
|
// still fails closed on real repository errors.
|
|
729
|
-
|
|
730
|
-
symbolic.Env = env
|
|
731
|
-
refOut, symbolicErr := symbolic.CombinedOutput()
|
|
902
|
+
refOut, symbolicErr := runGitCommand(gitRoot, env, "symbolic-ref", "--quiet", "HEAD")
|
|
732
903
|
if symbolicErr != nil {
|
|
733
904
|
return "", readFailure
|
|
734
905
|
}
|
|
735
906
|
ref := strings.TrimSpace(string(refOut))
|
|
736
|
-
|
|
737
|
-
exists.Env = env
|
|
738
|
-
if err := exists.Run(); err == nil {
|
|
907
|
+
if _, err := runGitCommand(gitRoot, env, "show-ref", "--verify", "--quiet", ref); err == nil {
|
|
739
908
|
return "", readFailure
|
|
740
|
-
} else if
|
|
909
|
+
} else if code, ok := gitErrorExitCode(err); !ok || code != 1 {
|
|
741
910
|
return "", fmt.Errorf("inspect HEAD ref %s: %w", ref, err)
|
|
742
911
|
}
|
|
743
912
|
|
|
744
|
-
|
|
745
|
-
empty.Env = env
|
|
746
|
-
if out, err := empty.CombinedOutput(); err != nil {
|
|
747
|
-
if detail := strings.TrimSpace(string(out)); detail != "" {
|
|
748
|
-
return "", fmt.Errorf("git read-tree --empty: %w: %s", err, detail)
|
|
749
|
-
}
|
|
913
|
+
if _, err := runGitCommand(gitRoot, env, "read-tree", "--empty"); err != nil {
|
|
750
914
|
return "", fmt.Errorf("git read-tree --empty: %w", err)
|
|
751
915
|
}
|
|
752
916
|
}
|
|
753
917
|
|
|
754
|
-
|
|
755
|
-
add.Env = env
|
|
756
|
-
addOut, err := add.CombinedOutput()
|
|
757
|
-
if err != nil {
|
|
758
|
-
if detail := strings.TrimSpace(string(addOut)); detail != "" {
|
|
759
|
-
return "", fmt.Errorf("git add -A: %w: %s", err, detail)
|
|
760
|
-
}
|
|
918
|
+
if _, err := runGitCommand(gitRoot, env, "add", "-A"); err != nil {
|
|
761
919
|
return "", fmt.Errorf("git add -A: %w", err)
|
|
762
920
|
}
|
|
763
921
|
for _, excludedRoot := range excludedRoots {
|
|
@@ -765,23 +923,16 @@ func worktreeTree(gitRoot, objectDir string, excludedRoots ...string) (string, e
|
|
|
765
923
|
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
766
924
|
continue
|
|
767
925
|
}
|
|
768
|
-
|
|
769
|
-
|
|
926
|
+
if _, err := runGitCommand(
|
|
927
|
+
gitRoot, env, "rm", "-r", "--cached", "--ignore-unmatch", "--",
|
|
770
928
|
":(top,literal)"+filepath.ToSlash(rel),
|
|
771
|
-
)
|
|
772
|
-
|
|
773
|
-
if out, err := rm.CombinedOutput(); err != nil {
|
|
774
|
-
return "", fmt.Errorf("exclude %s from worktree snapshot: %w: %s", rel, err, strings.TrimSpace(string(out)))
|
|
929
|
+
); err != nil {
|
|
930
|
+
return "", fmt.Errorf("exclude %s from worktree snapshot: %w", rel, err)
|
|
775
931
|
}
|
|
776
932
|
}
|
|
777
933
|
|
|
778
|
-
|
|
779
|
-
write.Env = env
|
|
780
|
-
out, err := write.CombinedOutput()
|
|
934
|
+
out, err := runGitCommand(gitRoot, env, "write-tree")
|
|
781
935
|
if err != nil {
|
|
782
|
-
if detail := strings.TrimSpace(string(out)); detail != "" {
|
|
783
|
-
return "", fmt.Errorf("git write-tree: %w: %s", err, detail)
|
|
784
|
-
}
|
|
785
936
|
return "", fmt.Errorf("git write-tree: %w", err)
|
|
786
937
|
}
|
|
787
938
|
tree := strings.TrimSpace(string(out))
|
|
@@ -792,10 +943,9 @@ func worktreeTree(gitRoot, objectDir string, excludedRoots ...string) (string, e
|
|
|
792
943
|
}
|
|
793
944
|
|
|
794
945
|
func reconcileGitEnv(gitRoot, objectDir string) ([]string, error) {
|
|
795
|
-
|
|
796
|
-
out, err := common.CombinedOutput()
|
|
946
|
+
out, err := runGitCommand(gitRoot, nil, "rev-parse", "--git-common-dir")
|
|
797
947
|
if err != nil {
|
|
798
|
-
return nil, fmt.Errorf("git rev-parse --git-common-dir: %w
|
|
948
|
+
return nil, fmt.Errorf("git rev-parse --git-common-dir: %w", err)
|
|
799
949
|
}
|
|
800
950
|
commonDir := strings.TrimSpace(string(out))
|
|
801
951
|
if !filepath.IsAbs(commonDir) {
|
|
@@ -935,18 +1085,5 @@ func treeFileContent(gitRoot string, env []string, tree, filename string) ([]byt
|
|
|
935
1085
|
}
|
|
936
1086
|
|
|
937
1087
|
func reconcileGitOutput(gitRoot string, env []string, args ...string) ([]byte, error) {
|
|
938
|
-
|
|
939
|
-
cmd := exec.Command("git", commandArgs...)
|
|
940
|
-
if env != nil {
|
|
941
|
-
cmd.Env = env
|
|
942
|
-
}
|
|
943
|
-
out, err := cmd.CombinedOutput()
|
|
944
|
-
if err != nil {
|
|
945
|
-
detail := strings.TrimSpace(string(out))
|
|
946
|
-
if detail != "" {
|
|
947
|
-
return nil, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, detail)
|
|
948
|
-
}
|
|
949
|
-
return nil, fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
|
|
950
|
-
}
|
|
951
|
-
return out, nil
|
|
1088
|
+
return runGitCommand(gitRoot, env, args...)
|
|
952
1089
|
}
|
|
@@ -93,6 +93,20 @@ func TestWorktreeTreeErrorsOutsideGitRepo(t *testing.T) {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
func TestReconcileFailsClosedWhenGitIsUnavailable(t *testing.T) {
|
|
97
|
+
newGitRepo(t)
|
|
98
|
+
root := workspace(t, "slice")
|
|
99
|
+
t.Setenv("PATH", t.TempDir())
|
|
100
|
+
|
|
101
|
+
code, output := runReconcile(t, root, "snapshot", "slice")
|
|
102
|
+
if code != 6 {
|
|
103
|
+
t.Fatalf("reconcile snapshot code=%d, want 6 when git is unavailable\n%s", code, output)
|
|
104
|
+
}
|
|
105
|
+
if !strings.Contains(output, "cannot resolve git worktree") {
|
|
106
|
+
t.Fatalf("missing fail-closed diagnostic:\n%s", output)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
96
110
|
func TestWorktreeTreeCapturesUntrackedFiles(t *testing.T) {
|
|
97
111
|
dir := newGitRepo(t)
|
|
98
112
|
objects := t.TempDir()
|
|
@@ -199,6 +213,86 @@ func TestReconcileSnapshotThenCleanCheck(t *testing.T) {
|
|
|
199
213
|
}
|
|
200
214
|
}
|
|
201
215
|
|
|
216
|
+
func TestReconcileRepeatedCheckRejectsAllowlistedSourceDrift(t *testing.T) {
|
|
217
|
+
gitRoot := newGitRepo(t)
|
|
218
|
+
root := workspace(t, "feat")
|
|
219
|
+
writeWrightAllowlist(t, root, "feat", "seed.go")
|
|
220
|
+
|
|
221
|
+
if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
|
|
222
|
+
t.Fatalf("snapshot = %d, want 0\n%s", code, out)
|
|
223
|
+
}
|
|
224
|
+
writeFile(t, filepath.Join(gitRoot, "seed.go"), "package main\n\nfunc builtByWright() {}\n")
|
|
225
|
+
if code, out := runReconcile(t, root, "check", "feat"); code != 0 {
|
|
226
|
+
t.Fatalf("first check = %d, want 0\n%s", code, out)
|
|
227
|
+
}
|
|
228
|
+
if code, out := runReconcile(t, root, "check", "feat"); code != 0 {
|
|
229
|
+
t.Fatalf("unchanged repeated check = %d, want 0\n%s", code, out)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
writeFile(t, filepath.Join(gitRoot, "seed.go"), "package main\n\nfunc changedByArtifactGate() {}\n")
|
|
233
|
+
code, out := runReconcile(t, root, "check", "feat")
|
|
234
|
+
if code != 5 {
|
|
235
|
+
t.Fatalf("post-gate drift check = %d, want 5\n%s", code, out)
|
|
236
|
+
}
|
|
237
|
+
if !strings.Contains(out, "source changed after the last clean check") ||
|
|
238
|
+
!strings.Contains(out, "seed.go") {
|
|
239
|
+
t.Fatalf("post-gate drift diagnostic missing reason/path:\n%s", out)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
func TestReconcileRestoreCheckRollsBackOnlyPostCheckDelta(t *testing.T) {
|
|
244
|
+
gitRoot := newGitRepo(t)
|
|
245
|
+
if _, err := runGitCommand(gitRoot, nil, "config", "core.autocrlf", "true"); err != nil {
|
|
246
|
+
t.Fatal(err)
|
|
247
|
+
}
|
|
248
|
+
root := workspace(t, "feat")
|
|
249
|
+
writeWrightAllowlist(t, root, "feat", "seed.go", "kept.go")
|
|
250
|
+
writeFile(t, filepath.Join(gitRoot, "kept.go"), "package main\n\nfunc beforeWright() {}\n")
|
|
251
|
+
|
|
252
|
+
if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
|
|
253
|
+
t.Fatalf("snapshot = %d, want 0\n%s", code, out)
|
|
254
|
+
}
|
|
255
|
+
wrightSeed := "package main\n\nfunc builtByWright() {}\n"
|
|
256
|
+
wrightKept := "package main\n\nfunc keptByWright() {}\n"
|
|
257
|
+
writeFile(t, filepath.Join(gitRoot, "seed.go"), wrightSeed)
|
|
258
|
+
writeFile(t, filepath.Join(gitRoot, "kept.go"), wrightKept)
|
|
259
|
+
if code, out := runReconcile(t, root, "check", "feat"); code != 0 {
|
|
260
|
+
t.Fatalf("check = %d, want 0\n%s", code, out)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
writeFile(t, filepath.Join(gitRoot, "seed.go"), "package main\n\nfunc corruptedByGate() {}\n")
|
|
264
|
+
if err := os.Remove(filepath.Join(gitRoot, "kept.go")); err != nil {
|
|
265
|
+
t.Fatal(err)
|
|
266
|
+
}
|
|
267
|
+
writeFile(t, filepath.Join(gitRoot, "gate-added.go"), "package main\n")
|
|
268
|
+
|
|
269
|
+
code, out := runReconcile(t, root, "restore-check", "feat")
|
|
270
|
+
if code != 0 {
|
|
271
|
+
t.Fatalf("restore-check = %d, want 0\n%s", code, out)
|
|
272
|
+
}
|
|
273
|
+
for _, changedPath := range []string{"gate-added.go", "kept.go", "seed.go"} {
|
|
274
|
+
if !strings.Contains(out, changedPath) {
|
|
275
|
+
t.Errorf("restore output omitted %s:\n%s", changedPath, out)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if data, err := os.ReadFile(filepath.Join(gitRoot, "seed.go")); err != nil {
|
|
279
|
+
t.Fatal(err)
|
|
280
|
+
} else if string(data) != wrightSeed {
|
|
281
|
+
t.Fatalf("seed.go = %q, want retained wright content %q", data, wrightSeed)
|
|
282
|
+
}
|
|
283
|
+
if data, err := os.ReadFile(filepath.Join(gitRoot, "kept.go")); err != nil {
|
|
284
|
+
t.Fatal(err)
|
|
285
|
+
} else if string(data) != wrightKept {
|
|
286
|
+
t.Fatalf("kept.go = %q, want retained wright content %q", data, wrightKept)
|
|
287
|
+
}
|
|
288
|
+
if _, err := os.Stat(filepath.Join(gitRoot, "gate-added.go")); !os.IsNotExist(err) {
|
|
289
|
+
t.Fatalf("post-check added source remains: %v", err)
|
|
290
|
+
}
|
|
291
|
+
if code, out := runReconcile(t, root, "check", "feat"); code != 0 {
|
|
292
|
+
t.Fatalf("post-restore check = %d, want 0\n%s", code, out)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
202
296
|
func TestReconcileCheckFlagsUnauthorizedChange(t *testing.T) {
|
|
203
297
|
gitRoot := newGitRepo(t)
|
|
204
298
|
root := workspace(t, "feat")
|
|
@@ -698,25 +792,78 @@ func TestReconcileRefreshRequiresPriorCleanCheck(t *testing.T) {
|
|
|
698
792
|
}
|
|
699
793
|
}
|
|
700
794
|
|
|
701
|
-
func
|
|
795
|
+
func TestReconcileCloseRequiresCleanCheck(t *testing.T) {
|
|
702
796
|
newGitRepo(t)
|
|
703
797
|
root := workspace(t, "feat")
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
798
|
+
writeWrightAllowlist(t, root, "feat", "seed.go")
|
|
799
|
+
if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
|
|
800
|
+
t.Fatalf("snapshot = %d, want 0\n%s", code, out)
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
code, out := runReconcile(t, root, "close", "feat")
|
|
804
|
+
if code != 6 {
|
|
805
|
+
t.Fatalf("unchecked close = %d, want 6\n%s", code, out)
|
|
806
|
+
}
|
|
807
|
+
if !strings.Contains(out, "no clean check marker") {
|
|
808
|
+
t.Fatalf("missing clean-check diagnostic:\n%s", out)
|
|
809
|
+
}
|
|
810
|
+
if !isFile(filepath.Join(featureDir(root, "feat"), reconcileBaseName)) ||
|
|
811
|
+
!isDir(filepath.Join(featureDir(root, "feat"), reconcileObjectsName)) {
|
|
812
|
+
t.Fatal("unchecked close destroyed the retained baseline")
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
func TestReconcileCloseRejectsPostCheckSourceDrift(t *testing.T) {
|
|
817
|
+
gitRoot := newGitRepo(t)
|
|
818
|
+
root := workspace(t, "feat")
|
|
819
|
+
writeWrightAllowlist(t, root, "feat", "seed.go")
|
|
820
|
+
if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
|
|
821
|
+
t.Fatalf("snapshot = %d, want 0\n%s", code, out)
|
|
822
|
+
}
|
|
823
|
+
writeFile(t, filepath.Join(gitRoot, "seed.go"), "package main\n\nfunc builtByWright() {}\n")
|
|
824
|
+
if code, out := runReconcile(t, root, "check", "feat"); code != 0 {
|
|
825
|
+
t.Fatalf("check = %d, want 0\n%s", code, out)
|
|
826
|
+
}
|
|
827
|
+
writeFile(t, filepath.Join(gitRoot, "seed.go"), "package main\n\nfunc changedAfterCheck() {}\n")
|
|
828
|
+
|
|
829
|
+
code, out := runReconcile(t, root, "close", "feat")
|
|
830
|
+
if code != 5 {
|
|
831
|
+
t.Fatalf("drifted close = %d, want 5\n%s", code, out)
|
|
832
|
+
}
|
|
833
|
+
if !strings.Contains(out, "source changed after the last clean check") ||
|
|
834
|
+
!strings.Contains(out, "seed.go") {
|
|
835
|
+
t.Fatalf("missing post-check drift diagnostic:\n%s", out)
|
|
836
|
+
}
|
|
837
|
+
if !isFile(filepath.Join(featureDir(root, "feat"), reconcileBaseName)) ||
|
|
838
|
+
!isDir(filepath.Join(featureDir(root, "feat"), reconcileObjectsName)) {
|
|
839
|
+
t.Fatal("drifted close destroyed the retained baseline")
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
func TestReconcileCloseRejectsWorkspaceSymlinkEscape(t *testing.T) {
|
|
844
|
+
newGitRepo(t)
|
|
845
|
+
root := t.TempDir()
|
|
846
|
+
outside := t.TempDir()
|
|
847
|
+
if err := os.MkdirAll(filepath.Join(root, "work"), 0o755); err != nil {
|
|
848
|
+
t.Fatal(err)
|
|
849
|
+
}
|
|
850
|
+
if err := os.Symlink(outside, filepath.Join(root, "work", "feat")); err != nil {
|
|
851
|
+
t.Skipf("symlinks unavailable: %v", err)
|
|
852
|
+
}
|
|
853
|
+
writeFile(t, filepath.Join(outside, reconcileBaseName), "outside\n")
|
|
854
|
+
if err := os.MkdirAll(filepath.Join(outside, reconcileObjectsName), 0o700); err != nil {
|
|
707
855
|
t.Fatal(err)
|
|
708
856
|
}
|
|
709
857
|
|
|
710
858
|
code, out := runReconcile(t, root, "close", "feat")
|
|
711
|
-
if code !=
|
|
712
|
-
t.Fatalf("close = %d, want
|
|
859
|
+
if code != 6 {
|
|
860
|
+
t.Fatalf("symlinked close = %d, want 6\n%s", code, out)
|
|
713
861
|
}
|
|
714
|
-
if !strings.Contains(out, "
|
|
715
|
-
t.
|
|
862
|
+
if !strings.Contains(out, "workspace") {
|
|
863
|
+
t.Fatalf("missing workspace-path diagnostic:\n%s", out)
|
|
716
864
|
}
|
|
717
|
-
if isFile(filepath.Join(
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
t.Fatal("close retained private baseline state")
|
|
865
|
+
if !isFile(filepath.Join(outside, reconcileBaseName)) ||
|
|
866
|
+
!isDir(filepath.Join(outside, reconcileObjectsName)) {
|
|
867
|
+
t.Fatal("symlinked close deleted state outside the DevRites root")
|
|
721
868
|
}
|
|
722
869
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
package lib
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"errors"
|
|
4
5
|
"fmt"
|
|
5
6
|
"io"
|
|
7
|
+
"io/fs"
|
|
6
8
|
"os"
|
|
7
9
|
"path/filepath"
|
|
8
10
|
"regexp"
|
|
@@ -182,9 +184,12 @@ func resolveNextQID(qpath string, stdout, stderr io.Writer) int {
|
|
|
182
184
|
if qpath == "" {
|
|
183
185
|
return fail(stderr, "Usage: devrites-engine resolve next-qid <questions.md path>", 5)
|
|
184
186
|
}
|
|
185
|
-
|
|
186
|
-
if
|
|
187
|
-
|
|
187
|
+
content, err := os.ReadFile(qpath)
|
|
188
|
+
if err != nil {
|
|
189
|
+
if !errors.Is(err, fs.ErrNotExist) {
|
|
190
|
+
return fail(stderr, "read questions.md: "+err.Error(), 5)
|
|
191
|
+
}
|
|
192
|
+
content = nil
|
|
188
193
|
}
|
|
189
194
|
qid, err := nextQuestionID(content, clockNow())
|
|
190
195
|
if err != nil {
|
|
@@ -48,6 +48,20 @@ func TestResolveAcceptsCanonicalUppercaseQuestionID(t *testing.T) {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
func TestResolveNextQIDReportsUnreadableQuestions(t *testing.T) {
|
|
52
|
+
qpath := filepath.Join(t.TempDir(), "questions.md")
|
|
53
|
+
if err := os.Mkdir(qpath, 0o755); err != nil {
|
|
54
|
+
t.Fatal(err)
|
|
55
|
+
}
|
|
56
|
+
var stdout, stderr bytes.Buffer
|
|
57
|
+
if code := resolveNextQID(qpath, &stdout, &stderr); code != 5 {
|
|
58
|
+
t.Fatalf("code = %d, want 5; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
|
59
|
+
}
|
|
60
|
+
if !strings.Contains(stderr.String(), "read questions.md") {
|
|
61
|
+
t.Fatalf("stderr = %q, want read error", stderr.String())
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
51
65
|
func resolveWorkspace(t *testing.T, questions string) string {
|
|
52
66
|
t.Helper()
|
|
53
67
|
root := t.TempDir()
|
|
@@ -151,7 +151,10 @@ func executeRunbook(root, path string, start int, runID string, dry bool, stdout
|
|
|
151
151
|
return 3
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
|
-
|
|
154
|
+
if err := writeRunbookState(root, runbookState{RunID: runID, Source: path, Next: len(steps), Status: "completed"}); err != nil {
|
|
155
|
+
fmt.Fprintf(stderr, "runbook: %v\n", err)
|
|
156
|
+
return 1
|
|
157
|
+
}
|
|
155
158
|
fmt.Fprintf(stdout, "runbook: completed %s\n", runID)
|
|
156
159
|
return 0
|
|
157
160
|
}
|