devrites 5.12.0 → 5.13.1

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.
Files changed (138) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +5 -3
  3. package/docs/architecture.md +2 -2
  4. package/docs/command-map.md +3 -1
  5. package/docs/engine/commands.md +21 -0
  6. package/docs/flow.md +2 -1
  7. package/docs/skills.md +13 -4
  8. package/engine/help.go +4 -1
  9. package/engine/help_test.go +2 -0
  10. package/engine/internal/install/binary.go +4 -0
  11. package/engine/internal/install/install_test.go +22 -0
  12. package/engine/internal/overhaul/admit/admit_test.go +170 -0
  13. package/engine/internal/overhaul/admit/run.go +416 -0
  14. package/engine/internal/overhaul/bench/run.go +248 -0
  15. package/engine/internal/overhaul/bench/run_test.go +139 -0
  16. package/engine/internal/overhaul/overhaul.go +64 -0
  17. package/engine/internal/overhaul/ovio/ovio.go +158 -0
  18. package/engine/internal/overhaul/records/check.go +834 -0
  19. package/engine/internal/overhaul/records/records_test.go +577 -0
  20. package/engine/internal/overhaul/records/run.go +392 -0
  21. package/engine/internal/overhaul/render/run.go +776 -0
  22. package/engine/internal/overhaul/render/run_test.go +293 -0
  23. package/engine/internal/overhaul/score/run.go +600 -0
  24. package/engine/internal/overhaul/score/run_test.go +405 -0
  25. package/engine/internal/overhaul/snapshot/run.go +563 -0
  26. package/engine/internal/overhaul/snapshot/run_test.go +360 -0
  27. package/engine/main.go +4 -0
  28. package/engine/root_routing_test.go +2 -1
  29. package/pack/.claude/skills/overhaul/SKILL.md +201 -0
  30. package/pack/.claude/skills/overhaul/references/audit-domains.md +263 -0
  31. package/pack/.claude/skills/overhaul/references/lifecycle.md +308 -0
  32. package/pack/.claude/skills/overhaul/references/orchestration.md +264 -0
  33. package/pack/.claude/skills/overhaul/references/records.md +137 -0
  34. package/pack/.claude/skills/overhaul/references/repair-and-measurement.md +207 -0
  35. package/pack/.claude/skills/overhaul/references/review-and-report.md +137 -0
  36. package/pack/.claude/skills/overhaul/references/scope-and-safety.md +191 -0
  37. package/pack/.claude/skills/overhaul/references/scoring.md +222 -0
  38. package/pack/.claude/skills/overhaul/references/stack-profiles.md +264 -0
  39. package/pack/.claude/skills/overhaul/roles/backend-engineer.md +61 -0
  40. package/pack/.claude/skills/overhaul/roles/boundary.md +56 -0
  41. package/pack/.claude/skills/overhaul/roles/craft.md +77 -0
  42. package/pack/.claude/skills/overhaul/roles/frontend-engineer.md +63 -0
  43. package/pack/.claude/skills/overhaul/roles/implementer.md +59 -0
  44. package/pack/.claude/skills/overhaul/roles/recon.md +60 -0
  45. package/pack/.claude/skills/overhaul/roles/specialist.md +56 -0
  46. package/pack/.claude/skills/overhaul/roles/verifier.md +60 -0
  47. package/pack/generated/claude/skills/overhaul/SKILL.md +201 -0
  48. package/pack/generated/claude/skills/overhaul/references/audit-domains.md +263 -0
  49. package/pack/generated/claude/skills/overhaul/references/lifecycle.md +308 -0
  50. package/pack/generated/claude/skills/overhaul/references/orchestration.md +264 -0
  51. package/pack/generated/claude/skills/overhaul/references/records.md +137 -0
  52. package/pack/generated/claude/skills/overhaul/references/repair-and-measurement.md +207 -0
  53. package/pack/generated/claude/skills/overhaul/references/review-and-report.md +137 -0
  54. package/pack/generated/claude/skills/overhaul/references/scope-and-safety.md +191 -0
  55. package/pack/generated/claude/skills/overhaul/references/scoring.md +222 -0
  56. package/pack/generated/claude/skills/overhaul/references/stack-profiles.md +264 -0
  57. package/pack/generated/claude/skills/overhaul/roles/backend-engineer.md +61 -0
  58. package/pack/generated/claude/skills/overhaul/roles/boundary.md +56 -0
  59. package/pack/generated/claude/skills/overhaul/roles/craft.md +77 -0
  60. package/pack/generated/claude/skills/overhaul/roles/frontend-engineer.md +63 -0
  61. package/pack/generated/claude/skills/overhaul/roles/implementer.md +59 -0
  62. package/pack/generated/claude/skills/overhaul/roles/recon.md +60 -0
  63. package/pack/generated/claude/skills/overhaul/roles/specialist.md +56 -0
  64. package/pack/generated/claude/skills/overhaul/roles/verifier.md +60 -0
  65. package/pack/generated/codex/skills/overhaul/SKILL.md +201 -0
  66. package/pack/generated/codex/skills/overhaul/agents/openai.yaml +2 -0
  67. package/pack/generated/codex/skills/overhaul/references/audit-domains.md +263 -0
  68. package/pack/generated/codex/skills/overhaul/references/lifecycle.md +308 -0
  69. package/pack/generated/codex/skills/overhaul/references/orchestration.md +264 -0
  70. package/pack/generated/codex/skills/overhaul/references/records.md +137 -0
  71. package/pack/generated/codex/skills/overhaul/references/repair-and-measurement.md +207 -0
  72. package/pack/generated/codex/skills/overhaul/references/review-and-report.md +137 -0
  73. package/pack/generated/codex/skills/overhaul/references/scope-and-safety.md +191 -0
  74. package/pack/generated/codex/skills/overhaul/references/scoring.md +222 -0
  75. package/pack/generated/codex/skills/overhaul/references/stack-profiles.md +264 -0
  76. package/pack/generated/codex/skills/overhaul/roles/backend-engineer.md +61 -0
  77. package/pack/generated/codex/skills/overhaul/roles/boundary.md +56 -0
  78. package/pack/generated/codex/skills/overhaul/roles/craft.md +77 -0
  79. package/pack/generated/codex/skills/overhaul/roles/frontend-engineer.md +63 -0
  80. package/pack/generated/codex/skills/overhaul/roles/implementer.md +59 -0
  81. package/pack/generated/codex/skills/overhaul/roles/recon.md +60 -0
  82. package/pack/generated/codex/skills/overhaul/roles/specialist.md +56 -0
  83. package/pack/generated/codex/skills/overhaul/roles/verifier.md +60 -0
  84. package/pack/generated/devin/skills/overhaul/SKILL.md +201 -0
  85. package/pack/generated/devin/skills/overhaul/references/audit-domains.md +263 -0
  86. package/pack/generated/devin/skills/overhaul/references/lifecycle.md +308 -0
  87. package/pack/generated/devin/skills/overhaul/references/orchestration.md +264 -0
  88. package/pack/generated/devin/skills/overhaul/references/records.md +137 -0
  89. package/pack/generated/devin/skills/overhaul/references/repair-and-measurement.md +207 -0
  90. package/pack/generated/devin/skills/overhaul/references/review-and-report.md +137 -0
  91. package/pack/generated/devin/skills/overhaul/references/scope-and-safety.md +191 -0
  92. package/pack/generated/devin/skills/overhaul/references/scoring.md +222 -0
  93. package/pack/generated/devin/skills/overhaul/references/stack-profiles.md +264 -0
  94. package/pack/generated/devin/skills/overhaul/roles/backend-engineer.md +61 -0
  95. package/pack/generated/devin/skills/overhaul/roles/boundary.md +56 -0
  96. package/pack/generated/devin/skills/overhaul/roles/craft.md +77 -0
  97. package/pack/generated/devin/skills/overhaul/roles/frontend-engineer.md +63 -0
  98. package/pack/generated/devin/skills/overhaul/roles/implementer.md +59 -0
  99. package/pack/generated/devin/skills/overhaul/roles/recon.md +60 -0
  100. package/pack/generated/devin/skills/overhaul/roles/specialist.md +56 -0
  101. package/pack/generated/devin/skills/overhaul/roles/verifier.md +60 -0
  102. package/pack/generated/omp/skills/overhaul/SKILL.md +201 -0
  103. package/pack/generated/omp/skills/overhaul/references/audit-domains.md +263 -0
  104. package/pack/generated/omp/skills/overhaul/references/lifecycle.md +308 -0
  105. package/pack/generated/omp/skills/overhaul/references/orchestration.md +264 -0
  106. package/pack/generated/omp/skills/overhaul/references/records.md +137 -0
  107. package/pack/generated/omp/skills/overhaul/references/repair-and-measurement.md +207 -0
  108. package/pack/generated/omp/skills/overhaul/references/review-and-report.md +137 -0
  109. package/pack/generated/omp/skills/overhaul/references/scope-and-safety.md +191 -0
  110. package/pack/generated/omp/skills/overhaul/references/scoring.md +222 -0
  111. package/pack/generated/omp/skills/overhaul/references/stack-profiles.md +264 -0
  112. package/pack/generated/omp/skills/overhaul/roles/backend-engineer.md +61 -0
  113. package/pack/generated/omp/skills/overhaul/roles/boundary.md +56 -0
  114. package/pack/generated/omp/skills/overhaul/roles/craft.md +77 -0
  115. package/pack/generated/omp/skills/overhaul/roles/frontend-engineer.md +63 -0
  116. package/pack/generated/omp/skills/overhaul/roles/implementer.md +59 -0
  117. package/pack/generated/omp/skills/overhaul/roles/recon.md +60 -0
  118. package/pack/generated/omp/skills/overhaul/roles/specialist.md +56 -0
  119. package/pack/generated/omp/skills/overhaul/roles/verifier.md +60 -0
  120. package/pack/generated/pi/skills/overhaul/SKILL.md +201 -0
  121. package/pack/generated/pi/skills/overhaul/references/audit-domains.md +263 -0
  122. package/pack/generated/pi/skills/overhaul/references/lifecycle.md +308 -0
  123. package/pack/generated/pi/skills/overhaul/references/orchestration.md +264 -0
  124. package/pack/generated/pi/skills/overhaul/references/records.md +137 -0
  125. package/pack/generated/pi/skills/overhaul/references/repair-and-measurement.md +207 -0
  126. package/pack/generated/pi/skills/overhaul/references/review-and-report.md +137 -0
  127. package/pack/generated/pi/skills/overhaul/references/scope-and-safety.md +191 -0
  128. package/pack/generated/pi/skills/overhaul/references/scoring.md +222 -0
  129. package/pack/generated/pi/skills/overhaul/references/stack-profiles.md +264 -0
  130. package/pack/generated/pi/skills/overhaul/roles/backend-engineer.md +61 -0
  131. package/pack/generated/pi/skills/overhaul/roles/boundary.md +56 -0
  132. package/pack/generated/pi/skills/overhaul/roles/craft.md +77 -0
  133. package/pack/generated/pi/skills/overhaul/roles/frontend-engineer.md +63 -0
  134. package/pack/generated/pi/skills/overhaul/roles/implementer.md +59 -0
  135. package/pack/generated/pi/skills/overhaul/roles/recon.md +60 -0
  136. package/pack/generated/pi/skills/overhaul/roles/specialist.md +56 -0
  137. package/pack/generated/pi/skills/overhaul/roles/verifier.md +60 -0
  138. package/package.json +1 -1
@@ -0,0 +1,563 @@
1
+ // Package snapshot implements the /overhaul snapshot tool (snapshot overhaul/1):
2
+ // baseline capture and drift check without touching the repository.
3
+ //
4
+ // Git runs read-only: GIT_OPTIONAL_LOCKS=0, no index refresh, no external diff
5
+ // drivers; no refs, stash or commit. Submodules and nested repositories are
6
+ // recorded as boundaries, never read. Files matching sensitive name patterns or
7
+ // holding private key material are hashed, never copied.
8
+ package snapshot
9
+
10
+ import (
11
+ "bytes"
12
+ "encoding/json"
13
+ "errors"
14
+ "fmt"
15
+ "io"
16
+ "io/fs"
17
+ "os"
18
+ "os/exec"
19
+ "path"
20
+ "path/filepath"
21
+ "reflect"
22
+ "slices"
23
+ "sort"
24
+ "strings"
25
+ "time"
26
+
27
+ "github.com/devrites/devrites/internal/gitenv"
28
+ "github.com/devrites/devrites/internal/overhaul/ovio"
29
+ )
30
+
31
+ const usage = `usage: devrites-engine overhaul snapshot <command> ...
32
+
33
+ capture <repo> <out> copy tracked + non-ignored untracked files to <out>/tree, record index/HEAD state
34
+ fingerprint <repo> content fingerprint of the working tree (tracked + non-ignored untracked)
35
+ verify <repo> <out> [--agent-paths <file>] report index changes, user drift, and agent-owned changes
36
+ state <repo> <out.json> write per-file hashes (the before-image of one attempt)
37
+ delta <before.json> <repo> print paths whose content or mode changed since <before.json>, one per line
38
+ `
39
+
40
+ var sensitive = []string{".env", ".env.*", "*.key", "*.p12", "*.pfx", "*.keystore", "*.jks", ".npmrc", ".pypirc", ".netrc",
41
+ "*credentials*", "*secret*", "*.tfstate", "*.tfvars"}
42
+
43
+ // keyMarker flags private key material in any file, whatever its name.
44
+ var keyMarker = []byte("PRIVATE KEY-----")
45
+
46
+ var diffArgs = []string{"-c", "diff.autoRefreshIndex=false", "diff", "--no-ext-diff", "--no-textconv", "--no-color",
47
+ "--src-prefix=a/", "--dst-prefix=b/", "--binary"}
48
+
49
+ // Fields are declared in sorted order so the manifest matches sort_keys output.
50
+ type entry struct {
51
+ Copied bool `json:"copied"`
52
+ Mode int `json:"mode"`
53
+ Sensitive bool `json:"sensitive"`
54
+ SHA256 string `json:"sha256"`
55
+ Source string `json:"source"`
56
+ }
57
+
58
+ type manifest struct {
59
+ Fingerprint string `json:"fingerprint"`
60
+ Files map[string]*entry `json:"files"`
61
+ Head *string `json:"head"`
62
+ IndexSHA256 string `json:"index_sha256"`
63
+ Root string `json:"root"`
64
+ Schema string `json:"schema"`
65
+ Submodules []string `json:"submodules_not_captured"`
66
+ }
67
+
68
+ // Run executes the tool with its arguments (tool name excluded).
69
+ func Run(args []string, stdout, stderr io.Writer) int {
70
+ code, err := dispatch(args, stdout)
71
+ if err != nil {
72
+ fmt.Fprintln(stderr, err)
73
+ }
74
+ return code
75
+ }
76
+
77
+ var errUsage = errors.New(strings.TrimRight(usage, "\n"))
78
+
79
+ func dispatch(a []string, stdout io.Writer) (int, error) {
80
+ if len(a) == 0 {
81
+ return 2, errUsage
82
+ }
83
+ switch {
84
+ case a[0] == "capture" && len(a) == 3:
85
+ return exit(capture(a[1], a[2], stdout))
86
+ case a[0] == "fingerprint" && len(a) == 2:
87
+ st, err := state(a[1])
88
+ if err != nil {
89
+ return 2, err
90
+ }
91
+ fmt.Fprintln(stdout, fingerprint(st))
92
+ return 0, nil
93
+ case a[0] == "verify" && (len(a) == 3 || len(a) == 5 && a[3] == "--agent-paths"):
94
+ agent := map[string]bool{}
95
+ if len(a) == 5 {
96
+ b, err := os.ReadFile(a[4])
97
+ if err != nil {
98
+ return 2, err
99
+ }
100
+ for _, ln := range strings.Split(string(b), "\n") {
101
+ if ln = strings.TrimSpace(ln); ln != "" {
102
+ agent[ln] = true
103
+ }
104
+ }
105
+ }
106
+ return verify(a[1], a[2], agent, stdout)
107
+ case a[0] == "state" && len(a) == 3:
108
+ st, err := state(a[1])
109
+ if err != nil {
110
+ return 2, err
111
+ }
112
+ out := map[string][]any{}
113
+ for p, v := range st {
114
+ out[p] = []any{v.SHA256, v.Mode}
115
+ }
116
+ return exit(ovio.WriteJSON(a[2], out, 0o644))
117
+ case a[0] == "delta" && len(a) == 3:
118
+ return exit(delta(a[1], a[2], stdout))
119
+ }
120
+ return 2, errUsage
121
+ }
122
+
123
+ func exit(err error) (int, error) {
124
+ if err != nil {
125
+ return 2, err
126
+ }
127
+ return 0, nil
128
+ }
129
+
130
+ func git(root string, args ...string) ([]byte, error) {
131
+ cmd := exec.Command("git", append([]string{"-C", root}, args...)...) // #nosec G204 -- fixed git binary; arguments passed as argv, no shell
132
+ cmd.Env = append(gitenv.Sanitize(os.Environ()), "GIT_OPTIONAL_LOCKS=0", "GIT_TERMINAL_PROMPT=0")
133
+ out, err := cmd.Output()
134
+ if err != nil {
135
+ var ee *exec.ExitError
136
+ if errors.As(err, &ee) {
137
+ return nil, fmt.Errorf("git %s: %v: %s", strings.Join(args, " "), err, bytes.TrimSpace(ee.Stderr))
138
+ }
139
+ return nil, fmt.Errorf("git %s: %v", strings.Join(args, " "), err)
140
+ }
141
+ return out, nil
142
+ }
143
+
144
+ func split0(b []byte) []string {
145
+ var out []string
146
+ for _, s := range strings.Split(string(b), "\x00") {
147
+ if s != "" {
148
+ out = append(out, s)
149
+ }
150
+ }
151
+ return out
152
+ }
153
+
154
+ // files lists tracked paths and non-ignored untracked paths, each sorted and deduplicated.
155
+ func files(root string) (tracked, untracked []string, err error) {
156
+ t, err := git(root, "ls-files", "-z", "--cached")
157
+ if err != nil {
158
+ return nil, nil, err
159
+ }
160
+ u, err := git(root, "ls-files", "-z", "--others", "--exclude-standard")
161
+ if err != nil {
162
+ return nil, nil, err
163
+ }
164
+ tracked = split0(t)
165
+ sort.Strings(tracked)
166
+ tracked = compact(tracked)
167
+ seen := map[string]bool{}
168
+ for _, p := range tracked {
169
+ seen[p] = true
170
+ }
171
+ for _, p := range split0(u) {
172
+ if !seen[p] {
173
+ seen[p] = true
174
+ untracked = append(untracked, p)
175
+ }
176
+ }
177
+ sort.Strings(untracked)
178
+ return tracked, untracked, nil
179
+ }
180
+
181
+ func compact(s []string) []string {
182
+ out := s[:0]
183
+ for i, p := range s {
184
+ if i == 0 || p != s[i-1] {
185
+ out = append(out, p)
186
+ }
187
+ }
188
+ return out
189
+ }
190
+
191
+ // permBits mirrors Python's stat.S_IMODE: permission plus setuid/setgid/sticky bits.
192
+ func permBits(m fs.FileMode) int {
193
+ b := int(m.Perm())
194
+ if m&fs.ModeSetuid != 0 {
195
+ b |= 0o4000
196
+ }
197
+ if m&fs.ModeSetgid != 0 {
198
+ b |= 0o2000
199
+ }
200
+ if m&fs.ModeSticky != 0 {
201
+ b |= 0o1000
202
+ }
203
+ return b
204
+ }
205
+
206
+ func digest(p string) (string, int, error) {
207
+ fi, err := os.Lstat(p)
208
+ if err != nil {
209
+ return "deleted", 0, nil
210
+ }
211
+ switch {
212
+ case fi.Mode()&fs.ModeSymlink != 0:
213
+ target, err := os.Readlink(p)
214
+ if err != nil {
215
+ return "", 0, err
216
+ }
217
+ return "link:" + ovio.SHA256Bytes([]byte(target)), 0o120000, nil
218
+ case fi.IsDir():
219
+ return "directory", 0, nil
220
+ }
221
+ sum, err := ovio.SHA256File(p)
222
+ return sum, permBits(fi.Mode()), err
223
+ }
224
+
225
+ func holdsKey(p string) (bool, error) {
226
+ fi, err := os.Lstat(p)
227
+ if err != nil || !fi.Mode().IsRegular() {
228
+ return false, nil
229
+ }
230
+ f, err := os.Open(p) // #nosec G304 -- file listed by git inside the target repository
231
+ if err != nil {
232
+ return false, err
233
+ }
234
+ defer func() { _ = f.Close() }()
235
+ head, err := io.ReadAll(io.LimitReader(f, 65536))
236
+ return bytes.Contains(head, keyMarker), err
237
+ }
238
+
239
+ func isSensitiveName(p string) bool {
240
+ base := p[strings.LastIndex(p, "/")+1:]
241
+ for _, pat := range sensitive {
242
+ if ok, _ := path.Match(pat, base); ok {
243
+ return true
244
+ }
245
+ }
246
+ return false
247
+ }
248
+
249
+ func state(root string) (map[string]*entry, error) {
250
+ tracked, untracked, err := files(root)
251
+ if err != nil {
252
+ return nil, err
253
+ }
254
+ out := map[string]*entry{}
255
+ for _, g := range []struct {
256
+ src string
257
+ paths []string
258
+ }{{"tracked", tracked}, {"untracked", untracked}} {
259
+ for _, p := range g.paths {
260
+ full := filepath.Join(root, filepath.FromSlash(p))
261
+ sum, mode, err := digest(full)
262
+ if err != nil {
263
+ return nil, err
264
+ }
265
+ sens := isSensitiveName(p)
266
+ if !sens {
267
+ if sens, err = holdsKey(full); err != nil {
268
+ return nil, err
269
+ }
270
+ }
271
+ out[p] = &entry{SHA256: sum, Mode: mode, Source: g.src, Sensitive: sens}
272
+ }
273
+ }
274
+ return out, nil
275
+ }
276
+
277
+ func indexDigest(root string) (string, error) {
278
+ b, err := git(root, "ls-files", "-s", "-z")
279
+ if err != nil {
280
+ return "", err
281
+ }
282
+ return ovio.SHA256Bytes(b), nil
283
+ }
284
+
285
+ // fingerprint is sha256 over sorted "path\0sha\0octal-mode\n" lines.
286
+ func fingerprint(st map[string]*entry) string {
287
+ paths := make([]string, 0, len(st))
288
+ for p := range st {
289
+ paths = append(paths, p)
290
+ }
291
+ sort.Strings(paths)
292
+ var buf bytes.Buffer
293
+ for _, p := range paths {
294
+ fmt.Fprintf(&buf, "%s\x00%s\x00%o\n", p, st[p].SHA256, st[p].Mode)
295
+ }
296
+ return ovio.SHA256Bytes(buf.Bytes())
297
+ }
298
+
299
+ func capture(root, out string, stdout io.Writer) error {
300
+ if _, err := os.Lstat(out); err == nil {
301
+ return fmt.Errorf("refusing to overwrite existing snapshot %s", out)
302
+ }
303
+ absRoot, err := filepath.Abs(root)
304
+ if err != nil {
305
+ return err
306
+ }
307
+ root = ovio.Resolve(absRoot)
308
+ absOut, err := filepath.Abs(out)
309
+ if err != nil {
310
+ return err
311
+ }
312
+ if strings.HasPrefix(ovio.Resolve(absOut), root+string(os.PathSeparator)) {
313
+ return errors.New("snapshot must live outside the target repository")
314
+ }
315
+ idx, err := git(root, "ls-files", "-s", "-z")
316
+ if err != nil {
317
+ return err
318
+ }
319
+ entries := split0(idx)
320
+ subs := map[string]bool{}
321
+ for _, e := range entries {
322
+ meta, p, _ := strings.Cut(e, "\t")
323
+ if f := strings.Fields(meta); len(f) < 3 || f[2] != "0" {
324
+ return errors.New("index has unmerged entries; ask the user which baseline to capture")
325
+ }
326
+ if strings.HasPrefix(e, "160000 ") {
327
+ subs[p] = true
328
+ }
329
+ }
330
+ before := ovio.SHA256Bytes(idx)
331
+ st, err := state(root)
332
+ if err != nil {
333
+ return err
334
+ }
335
+ for p, v := range st {
336
+ if v.SHA256 == "directory" {
337
+ subs[p] = true
338
+ }
339
+ }
340
+ gitlinks := []string{}
341
+ for p := range subs {
342
+ gitlinks = append(gitlinks, p)
343
+ }
344
+ sort.Strings(gitlinks)
345
+
346
+ if err := os.MkdirAll(filepath.Join(out, "tree"), 0o700); err != nil {
347
+ return err
348
+ }
349
+ if err := os.Chmod(out, 0o700); err != nil {
350
+ return err
351
+ }
352
+ for p, v := range st {
353
+ v.Copied = !v.Sensitive && v.SHA256 != "deleted" && v.SHA256 != "directory"
354
+ if v.Copied {
355
+ if err := copyEntry(filepath.Join(root, filepath.FromSlash(p)), filepath.Join(out, "tree", filepath.FromSlash(p))); err != nil {
356
+ return err
357
+ }
358
+ }
359
+ }
360
+ // Sensitive paths stay out of the patches too: hash only, never content.
361
+ safe := []string{"--", "."}
362
+ for p, v := range st {
363
+ if v.Sensitive {
364
+ safe = append(safe, ":(top,exclude,literal)"+p)
365
+ }
366
+ }
367
+ for _, o := range []struct {
368
+ name string
369
+ args []string
370
+ }{
371
+ {"status.bin", []string{"status", "--porcelain=v2", "-z", "--untracked-files=all"}},
372
+ {"index.bin", []string{"ls-files", "-s", "-z"}},
373
+ {"staged.patch", slices.Concat(diffArgs, []string{"--cached"}, safe)},
374
+ {"unstaged.patch", slices.Concat(diffArgs, safe)},
375
+ } {
376
+ b, err := git(root, o.args...)
377
+ if err != nil {
378
+ return err
379
+ }
380
+ if err := os.WriteFile(filepath.Join(out, o.name), b, 0o600); err != nil {
381
+ return err
382
+ }
383
+ }
384
+ var head *string
385
+ if b, err := git(root, "rev-parse", "--verify", "-q", "HEAD"); err == nil {
386
+ if h := strings.TrimSpace(string(b)); h != "" {
387
+ head = &h
388
+ }
389
+ }
390
+ after, err := indexDigest(root)
391
+ if err != nil {
392
+ return err
393
+ }
394
+ if after != before {
395
+ return errors.New("index changed during capture; stop and reconcile with the user")
396
+ }
397
+ now, err := state(root)
398
+ if err != nil {
399
+ return err
400
+ }
401
+ man := manifest{Schema: "overhaul.snapshot/1", Root: root, Head: head, IndexSHA256: before,
402
+ Fingerprint: fingerprint(now), Files: st, Submodules: gitlinks}
403
+ if err := ovio.WriteJSON(filepath.Join(out, "manifest.json"), man, 0o600); err != nil {
404
+ return err
405
+ }
406
+ withheld := []string{}
407
+ for p, v := range st {
408
+ if v.Sensitive {
409
+ withheld = append(withheld, p)
410
+ }
411
+ }
412
+ sort.Strings(withheld)
413
+ b, err := json.Marshal(struct {
414
+ Snapshot string `json:"snapshot"`
415
+ Fingerprint string `json:"fingerprint"`
416
+ Files int `json:"files"`
417
+ Withheld []string `json:"sensitive_not_copied"`
418
+ }{out, man.Fingerprint, len(st), withheld})
419
+ if err != nil {
420
+ return err
421
+ }
422
+ fmt.Fprintln(stdout, string(b))
423
+ return nil
424
+ }
425
+
426
+ // copyEntry copies like shutil.copy2(follow_symlinks=False): symlinks stay
427
+ // symlinks; files keep their mode and modification time.
428
+ func copyEntry(src, dst string) error {
429
+ if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
430
+ return err
431
+ }
432
+ fi, err := os.Lstat(src)
433
+ if err != nil {
434
+ return err
435
+ }
436
+ if fi.Mode()&fs.ModeSymlink != 0 {
437
+ target, err := os.Readlink(src)
438
+ if err != nil {
439
+ return err
440
+ }
441
+ return os.Symlink(target, dst)
442
+ }
443
+ in, err := os.Open(src) // #nosec G304 -- file listed by git inside the target repository
444
+ if err != nil {
445
+ return err
446
+ }
447
+ defer func() { _ = in.Close() }()
448
+ w, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) // #nosec G304 -- destination inside the new owner-only snapshot directory
449
+ if err != nil {
450
+ return err
451
+ }
452
+ _, err = io.Copy(w, in)
453
+ if cerr := w.Close(); err == nil {
454
+ err = cerr
455
+ }
456
+ if err == nil {
457
+ err = os.Chmod(dst, fi.Mode())
458
+ }
459
+ if err == nil {
460
+ err = os.Chtimes(dst, time.Time{}, fi.ModTime())
461
+ }
462
+ return err
463
+ }
464
+
465
+ func verify(root, out string, agent map[string]bool, stdout io.Writer) (int, error) {
466
+ b, err := os.ReadFile(filepath.Join(out, "manifest.json")) // #nosec G304 -- manifest inside the operator-selected snapshot
467
+ if err != nil {
468
+ return 2, err
469
+ }
470
+ var man manifest
471
+ if err := json.Unmarshal(b, &man); err != nil {
472
+ return 2, fmt.Errorf("%s: invalid manifest (%v)", out, err)
473
+ }
474
+ now, err := state(root)
475
+ if err != nil {
476
+ return 2, err
477
+ }
478
+ union := map[string]bool{}
479
+ for p := range man.Files {
480
+ union[p] = true
481
+ }
482
+ for p := range now {
483
+ union[p] = true
484
+ }
485
+ report := struct {
486
+ IndexUnchanged bool `json:"index_unchanged"`
487
+ Agent []string `json:"agent_owned_changes"`
488
+ User []string `json:"changes_outside_agent_paths"`
489
+ Fingerprint string `json:"fingerprint"`
490
+ }{Agent: []string{}, User: []string{}, Fingerprint: fingerprint(now)}
491
+ for p := range union {
492
+ was, cur := man.Files[p], now[p]
493
+ if cur == nil {
494
+ cur = &entry{SHA256: "deleted"}
495
+ }
496
+ if was != nil && was.SHA256 == cur.SHA256 && was.Mode == cur.Mode {
497
+ continue
498
+ }
499
+ if agent[p] {
500
+ report.Agent = append(report.Agent, p)
501
+ } else {
502
+ report.User = append(report.User, p)
503
+ }
504
+ }
505
+ sort.Strings(report.Agent)
506
+ sort.Strings(report.User)
507
+ idx, err := indexDigest(root)
508
+ if err != nil {
509
+ return 2, err
510
+ }
511
+ report.IndexUnchanged = idx == man.IndexSHA256
512
+ j, err := ovio.MarshalIndent(report)
513
+ if err != nil {
514
+ return 2, err
515
+ }
516
+ _, _ = stdout.Write(j)
517
+ if report.IndexUnchanged && len(report.User) == 0 {
518
+ return 0, nil
519
+ }
520
+ return 1, nil
521
+ }
522
+
523
+ func delta(beforePath, root string, stdout io.Writer) error {
524
+ b, err := os.ReadFile(beforePath) // #nosec G304 -- state file is an explicit operator argument
525
+ if err != nil {
526
+ return err
527
+ }
528
+ var old map[string]any
529
+ if err := json.Unmarshal(b, &old); err != nil {
530
+ return fmt.Errorf("%s: invalid state (%v)", beforePath, err)
531
+ }
532
+ st, err := state(root)
533
+ if err != nil {
534
+ return err
535
+ }
536
+ union := map[string]bool{}
537
+ for p := range old {
538
+ union[p] = true
539
+ }
540
+ for p := range st {
541
+ union[p] = true
542
+ }
543
+ paths := make([]string, 0, len(union))
544
+ for p := range union {
545
+ paths = append(paths, p)
546
+ }
547
+ sort.Strings(paths)
548
+ missing := []any{"deleted", float64(0)}
549
+ for _, p := range paths {
550
+ was, ok := old[p]
551
+ if !ok {
552
+ was = missing
553
+ }
554
+ cur := missing
555
+ if v := st[p]; v != nil {
556
+ cur = []any{v.SHA256, float64(v.Mode)}
557
+ }
558
+ if !reflect.DeepEqual(was, cur) {
559
+ fmt.Fprintln(stdout, p)
560
+ }
561
+ }
562
+ return nil
563
+ }