devrites 3.2.23 → 3.2.24
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 +6 -0
- package/README.md +1 -1
- package/engine/hooks.go +49 -7
- package/engine/internal/lib/reconcile.go +187 -43
- package/engine/internal/lib/reconcile_test.go +74 -0
- package/engine/internal/lib/util.go +22 -1
- package/engine/tests/hook_test.go +27 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to DevRites are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and DevRites adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases are generated automatically by [semantic-release](https://semantic-release.gitbook.io/) from Conventional Commits on `main`.
|
|
4
4
|
|
|
5
|
+
## [3.2.24](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.23...v3.2.24) (2026-07-28)
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
* **devrites:** close review safety gaps ([33e736a](https://github.com/ViktorsBaikers/DevRites/commit/33e736a3d9d69fd166827235ed1ce2310aeca75a))
|
|
10
|
+
|
|
5
11
|
## [3.2.23](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.22...v3.2.23) (2026-07-28)
|
|
6
12
|
|
|
7
13
|
### Fixed
|
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ final commit, push, and tag, and it requires a typed `GO` confirmation.
|
|
|
25
25
|
Unattended runs may create local WIP checkpoint commits along the way, but only
|
|
26
26
|
Ship collapses and pushes them.
|
|
27
27
|
|
|
28
|
-
**Status:** [`v3.2.
|
|
28
|
+
**Status:** [`v3.2.24`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.24): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
|
|
29
29
|
|
|
30
30
|
## Quick start
|
|
31
31
|
|
package/engine/hooks.go
CHANGED
|
@@ -389,18 +389,37 @@ func safeReadonlyShellSegment(segment string) bool {
|
|
|
389
389
|
base := strings.ToLower(filepath.Base(strings.Trim(fields[0], `"'`)))
|
|
390
390
|
args := fields[1:]
|
|
391
391
|
switch base {
|
|
392
|
-
case "true", "false", "pwd", "ls", "cat", "head", "tail", "
|
|
393
|
-
"grep", "
|
|
394
|
-
"readlink", "realpath", "basename", "dirname", "cmp", "
|
|
395
|
-
"
|
|
392
|
+
case "true", "false", "pwd", "ls", "cat", "head", "tail", "more",
|
|
393
|
+
"grep", "wc", "uniq", "cut", "tr", "stat", "file",
|
|
394
|
+
"readlink", "realpath", "basename", "dirname", "cmp", "jq",
|
|
395
|
+
"du", "df", "printenv", "which", "date", "uname", "id", "whoami",
|
|
396
396
|
"ps", "echo", "printf", "test", "[", "cd", "sha256sum", "shasum":
|
|
397
397
|
return true
|
|
398
|
+
case "less":
|
|
399
|
+
return !hasShellOption(args, "oO", "--log-file")
|
|
400
|
+
case "rg":
|
|
401
|
+
return !hasShellOption(args, "", "--pre", "--hostname-bin")
|
|
402
|
+
case "sort":
|
|
403
|
+
return !hasShellOption(args, "o", "--output", "--compress-program")
|
|
404
|
+
case "diff":
|
|
405
|
+
return !hasShellOption(args, "", "--output")
|
|
406
|
+
case "yq":
|
|
407
|
+
return !hasShellOption(args, "i", "--inplace", "--in-place")
|
|
408
|
+
case "tree":
|
|
409
|
+
return !hasShellOption(args, "o", "--output")
|
|
398
410
|
case "find":
|
|
399
|
-
return !
|
|
411
|
+
return !hasArgPrefix(args, "-delete") &&
|
|
412
|
+
!hasArgPrefix(args, "-exec") &&
|
|
413
|
+
!hasArgPrefix(args, "-ok") &&
|
|
414
|
+
!hasArgPrefix(args, "-fprint") &&
|
|
415
|
+
!hasArgPrefix(args, "-fprintf") &&
|
|
416
|
+
!hasArgPrefix(args, "-fls")
|
|
400
417
|
case "sed":
|
|
401
|
-
return !
|
|
418
|
+
return !hasShellOption(args, "i", "--in-place", "--inplace")
|
|
402
419
|
case "git":
|
|
403
|
-
return len(args) > 0 &&
|
|
420
|
+
return len(args) > 0 &&
|
|
421
|
+
hasAnyArg(args[:1], "diff", "status", "show", "log", "rev-parse", "ls-files", "grep", "blame") &&
|
|
422
|
+
!hasShellOption(args[1:], "", "--output", "--ext-diff", "--textconv", "--open-files-in-pager")
|
|
404
423
|
case "go":
|
|
405
424
|
if len(args) == 0 || !hasAnyArg(args[:1], "test", "vet", "list", "version", "env") {
|
|
406
425
|
return false
|
|
@@ -453,6 +472,7 @@ func safePackageProof(args []string) bool {
|
|
|
453
472
|
|
|
454
473
|
func hasAnyArg(args []string, wants ...string) bool {
|
|
455
474
|
for _, arg := range args {
|
|
475
|
+
arg = strings.Trim(arg, `"'`)
|
|
456
476
|
for _, want := range wants {
|
|
457
477
|
if arg == want {
|
|
458
478
|
return true
|
|
@@ -464,6 +484,7 @@ func hasAnyArg(args []string, wants ...string) bool {
|
|
|
464
484
|
|
|
465
485
|
func hasArgPrefix(args []string, prefix string) bool {
|
|
466
486
|
for _, arg := range args {
|
|
487
|
+
arg = strings.Trim(arg, `"'`)
|
|
467
488
|
if strings.HasPrefix(arg, prefix) {
|
|
468
489
|
return true
|
|
469
490
|
}
|
|
@@ -471,6 +492,27 @@ func hasArgPrefix(args []string, prefix string) bool {
|
|
|
471
492
|
return false
|
|
472
493
|
}
|
|
473
494
|
|
|
495
|
+
func hasShellOption(args []string, shortOptions string, longOptions ...string) bool {
|
|
496
|
+
for _, arg := range args {
|
|
497
|
+
arg = strings.Trim(arg, `"'`)
|
|
498
|
+
if arg == "--" {
|
|
499
|
+
return false
|
|
500
|
+
}
|
|
501
|
+
if strings.HasPrefix(arg, "--") {
|
|
502
|
+
for _, option := range longOptions {
|
|
503
|
+
if arg == option || strings.HasPrefix(arg, option+"=") {
|
|
504
|
+
return true
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
continue
|
|
508
|
+
}
|
|
509
|
+
if strings.HasPrefix(arg, "-") && strings.ContainsAny(strings.TrimPrefix(arg, "-"), shortOptions) {
|
|
510
|
+
return true
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return false
|
|
514
|
+
}
|
|
515
|
+
|
|
474
516
|
// hookReviewerReadonly keeps every DevRites leaf except slice-wright read only.
|
|
475
517
|
// It always enforces declared DevRites runs. Undeclared runs keep the legacy
|
|
476
518
|
// observe mode.
|
|
@@ -327,12 +327,12 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
327
327
|
if pendingReceipt != "" {
|
|
328
328
|
defer func() { _ = os.Remove(pendingReceipt) }()
|
|
329
329
|
}
|
|
330
|
-
if err :=
|
|
331
|
-
fmt.Fprintf(stderr, "reconcile: source baseline restored, but cannot
|
|
330
|
+
if err := commitContentAddressedReceipt(receiptPath, pendingReceipt, receiptData); err != nil {
|
|
331
|
+
fmt.Fprintf(stderr, "reconcile: source baseline restored, but cannot persist abort receipt; retained window left open: %v\n", err)
|
|
332
332
|
return 6
|
|
333
333
|
}
|
|
334
|
-
if err :=
|
|
335
|
-
fmt.Fprintf(stderr, "reconcile: source baseline restored and
|
|
334
|
+
if err := closeWindow(); err != nil {
|
|
335
|
+
fmt.Fprintf(stderr, "reconcile: source baseline restored and receipt persisted, but cannot close rejected slice window: %v\n", err)
|
|
336
336
|
return 6
|
|
337
337
|
}
|
|
338
338
|
fmt.Fprintf(stdout, "reconcile: aborted rejected slice window for %s; restored %d source path(s); receipt %s.\n", slug, len(changed), receiptName)
|
|
@@ -962,25 +962,17 @@ func restoreTreePaths(gitRoot string, env []string, targetTree string, changed [
|
|
|
962
962
|
return fmt.Errorf("create restore directory: %w", err)
|
|
963
963
|
}
|
|
964
964
|
defer func() { _ = os.RemoveAll(materialized) }()
|
|
965
|
-
index := filepath.Join(materialized, "index")
|
|
966
|
-
restoreEnv := append(append([]string{}, env...), "GIT_INDEX_FILE="+index)
|
|
967
|
-
if _, err := reconcileGitOutput(gitRoot, restoreEnv, "read-tree", targetTree); err != nil {
|
|
968
|
-
return err
|
|
969
|
-
}
|
|
970
965
|
materializedTree := filepath.Join(materialized, "tree")
|
|
971
966
|
if err := os.MkdirAll(materializedTree, 0o700); err != nil {
|
|
972
967
|
return fmt.Errorf("create restore tree: %w", err)
|
|
973
968
|
}
|
|
974
|
-
gitPrefix := filepath.ToSlash(materializedTree) + "/"
|
|
975
|
-
if _, err := reconcileGitOutput(
|
|
976
|
-
gitRoot,
|
|
977
|
-
restoreEnv,
|
|
978
|
-
"-c", "core.autocrlf=false",
|
|
979
|
-
"checkout-index", "--all", "--force", "--prefix="+gitPrefix,
|
|
980
|
-
); err != nil {
|
|
981
|
-
return err
|
|
982
|
-
}
|
|
983
969
|
|
|
970
|
+
type restoreEntry struct {
|
|
971
|
+
path, target, parent, source string
|
|
972
|
+
mode os.FileMode
|
|
973
|
+
symlink, exists bool
|
|
974
|
+
}
|
|
975
|
+
entries := make([]restoreEntry, 0, len(changed))
|
|
984
976
|
for _, changedPath := range changed {
|
|
985
977
|
if changedPath == "." || changedPath == ".devrites" ||
|
|
986
978
|
strings.HasPrefix(changedPath, reconcileDevritesPathPrefix) ||
|
|
@@ -992,39 +984,111 @@ func restoreTreePaths(gitRoot string, env []string, targetTree string, changed [
|
|
|
992
984
|
if !safepath.WithinResolved(targetParent, gitRoot) {
|
|
993
985
|
return fmt.Errorf("restore parent escapes repository through a symlink: %s", changedPath)
|
|
994
986
|
}
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
987
|
+
if targetInfo, targetErr := os.Lstat(target); targetErr == nil {
|
|
988
|
+
if targetInfo.IsDir() {
|
|
989
|
+
return fmt.Errorf("refusing to remove live directory at restore path %s", changedPath)
|
|
990
|
+
}
|
|
991
|
+
} else if !os.IsNotExist(targetErr) {
|
|
992
|
+
return fmt.Errorf("inspect restore target %s: %w", changedPath, targetErr)
|
|
999
993
|
}
|
|
1000
|
-
|
|
1001
|
-
|
|
994
|
+
|
|
995
|
+
treeEntry, err := reconcileGitOutput(
|
|
996
|
+
gitRoot, env, "ls-tree", "-z", targetTree, "--", ":(top,literal)"+changedPath,
|
|
997
|
+
)
|
|
998
|
+
if err != nil {
|
|
999
|
+
return err
|
|
1002
1000
|
}
|
|
1003
|
-
if
|
|
1001
|
+
if len(treeEntry) == 0 {
|
|
1002
|
+
entries = append(entries, restoreEntry{path: changedPath, target: target, parent: targetParent})
|
|
1004
1003
|
continue
|
|
1005
1004
|
}
|
|
1006
|
-
|
|
1007
|
-
|
|
1005
|
+
record := strings.TrimSuffix(string(treeEntry), "\x00")
|
|
1006
|
+
if strings.Contains(record, "\x00") {
|
|
1007
|
+
return fmt.Errorf("ambiguous tree entry for %s", changedPath)
|
|
1008
|
+
}
|
|
1009
|
+
header, entryPath, ok := strings.Cut(record, "\t")
|
|
1010
|
+
fields := strings.Fields(header)
|
|
1011
|
+
if !ok || len(fields) != 3 || entryPath != changedPath {
|
|
1012
|
+
return fmt.Errorf("invalid tree entry for %s", changedPath)
|
|
1013
|
+
}
|
|
1014
|
+
if fields[0] == "160000" || fields[1] == "commit" {
|
|
1015
|
+
return fmt.Errorf("cannot safely restore changed submodule worktree %s", changedPath)
|
|
1016
|
+
}
|
|
1017
|
+
entry := restoreEntry{
|
|
1018
|
+
path: changedPath,
|
|
1019
|
+
target: target,
|
|
1020
|
+
parent: targetParent,
|
|
1021
|
+
source: filepath.Join(materializedTree, filepath.FromSlash(changedPath)),
|
|
1022
|
+
exists: true,
|
|
1023
|
+
symlink: fields[0] == "120000",
|
|
1024
|
+
}
|
|
1025
|
+
switch fields[0] {
|
|
1026
|
+
case "100644":
|
|
1027
|
+
entry.mode = 0o644
|
|
1028
|
+
case "100755":
|
|
1029
|
+
entry.mode = 0o755
|
|
1030
|
+
case "120000":
|
|
1031
|
+
default:
|
|
1032
|
+
return fmt.Errorf("unsupported tree mode %s for %s", fields[0], changedPath)
|
|
1008
1033
|
}
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1034
|
+
if err := os.MkdirAll(filepath.Dir(entry.source), 0o700); err != nil {
|
|
1035
|
+
return fmt.Errorf("create materialized parent for %s: %w", changedPath, err)
|
|
1036
|
+
}
|
|
1037
|
+
source, err := os.OpenFile(entry.source, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
|
1038
|
+
if err != nil {
|
|
1039
|
+
return fmt.Errorf("create materialized source %s: %w", changedPath, err)
|
|
1040
|
+
}
|
|
1041
|
+
writeErr := runGitCommandToWriter(gitRoot, env, source, "cat-file", "blob", fields[2])
|
|
1042
|
+
closeErr := source.Close()
|
|
1043
|
+
if writeErr != nil {
|
|
1044
|
+
return fmt.Errorf("materialize source %s: %w", changedPath, writeErr)
|
|
1045
|
+
}
|
|
1046
|
+
if closeErr != nil {
|
|
1047
|
+
return fmt.Errorf("close materialized source %s: %w", changedPath, closeErr)
|
|
1048
|
+
}
|
|
1049
|
+
entries = append(entries, entry)
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
for _, entry := range entries {
|
|
1053
|
+
if err := os.RemoveAll(entry.target); err != nil {
|
|
1054
|
+
return fmt.Errorf("remove source path %s: %w", entry.path, err)
|
|
1055
|
+
}
|
|
1056
|
+
if !entry.exists {
|
|
1057
|
+
continue
|
|
1058
|
+
}
|
|
1059
|
+
if err := os.MkdirAll(entry.parent, 0o755); err != nil {
|
|
1060
|
+
return fmt.Errorf("create restore parent for %s: %w", entry.path, err)
|
|
1061
|
+
}
|
|
1062
|
+
if entry.symlink {
|
|
1063
|
+
link, err := os.ReadFile(entry.source)
|
|
1020
1064
|
if err != nil {
|
|
1021
|
-
return fmt.Errorf("read clean
|
|
1065
|
+
return fmt.Errorf("read clean symlink %s: %w", entry.path, err)
|
|
1022
1066
|
}
|
|
1023
|
-
if err := os.
|
|
1024
|
-
return fmt.Errorf("restore
|
|
1067
|
+
if err := os.Symlink(string(link), entry.target); err != nil {
|
|
1068
|
+
return fmt.Errorf("restore symlink %s: %w", entry.path, err)
|
|
1025
1069
|
}
|
|
1026
|
-
|
|
1027
|
-
|
|
1070
|
+
continue
|
|
1071
|
+
}
|
|
1072
|
+
source, err := os.Open(entry.source)
|
|
1073
|
+
if err != nil {
|
|
1074
|
+
return fmt.Errorf("open clean file %s: %w", entry.path, err)
|
|
1075
|
+
}
|
|
1076
|
+
target, err := os.OpenFile(entry.target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, entry.mode)
|
|
1077
|
+
if err != nil {
|
|
1078
|
+
_ = source.Close()
|
|
1079
|
+
return fmt.Errorf("create restored file %s: %w", entry.path, err)
|
|
1080
|
+
}
|
|
1081
|
+
_, copyErr := io.Copy(target, source)
|
|
1082
|
+
sourceCloseErr := source.Close()
|
|
1083
|
+
targetCloseErr := target.Close()
|
|
1084
|
+
if copyErr != nil {
|
|
1085
|
+
return fmt.Errorf("restore file %s: %w", entry.path, copyErr)
|
|
1086
|
+
}
|
|
1087
|
+
if sourceCloseErr != nil {
|
|
1088
|
+
return fmt.Errorf("close clean file %s: %w", entry.path, sourceCloseErr)
|
|
1089
|
+
}
|
|
1090
|
+
if targetCloseErr != nil {
|
|
1091
|
+
return fmt.Errorf("close restored file %s: %w", entry.path, targetCloseErr)
|
|
1028
1092
|
}
|
|
1029
1093
|
}
|
|
1030
1094
|
return nil
|
|
@@ -1193,6 +1257,9 @@ func worktreeTree(gitRoot, objectDir string, excludedRoots ...string) (string, e
|
|
|
1193
1257
|
return "", fmt.Errorf("exclude %s from worktree snapshot: %w", rel, err)
|
|
1194
1258
|
}
|
|
1195
1259
|
}
|
|
1260
|
+
if err := rewriteIndexWithRawWorktreeBytes(gitRoot, env); err != nil {
|
|
1261
|
+
return "", err
|
|
1262
|
+
}
|
|
1196
1263
|
|
|
1197
1264
|
out, err := runGitCommand(gitRoot, env, "write-tree")
|
|
1198
1265
|
if err != nil {
|
|
@@ -1205,6 +1272,83 @@ func worktreeTree(gitRoot, objectDir string, excludedRoots ...string) (string, e
|
|
|
1205
1272
|
return tree, nil
|
|
1206
1273
|
}
|
|
1207
1274
|
|
|
1275
|
+
func rewriteIndexWithRawWorktreeBytes(gitRoot string, env []string) error {
|
|
1276
|
+
out, err := runGitCommand(gitRoot, env, "ls-files", "--stage", "-z")
|
|
1277
|
+
if err != nil {
|
|
1278
|
+
return fmt.Errorf("list worktree snapshot entries: %w", err)
|
|
1279
|
+
}
|
|
1280
|
+
type rawEntry struct {
|
|
1281
|
+
mode, path string
|
|
1282
|
+
}
|
|
1283
|
+
var batch, individual []rawEntry
|
|
1284
|
+
for _, record := range strings.Split(string(out), "\x00") {
|
|
1285
|
+
if record == "" {
|
|
1286
|
+
continue
|
|
1287
|
+
}
|
|
1288
|
+
header, filename, ok := strings.Cut(record, "\t")
|
|
1289
|
+
fields := strings.Fields(header)
|
|
1290
|
+
if !ok || len(fields) != 3 || fields[2] != "0" {
|
|
1291
|
+
return fmt.Errorf("invalid worktree snapshot index entry")
|
|
1292
|
+
}
|
|
1293
|
+
if fields[0] != "100644" && fields[0] != "100755" {
|
|
1294
|
+
continue
|
|
1295
|
+
}
|
|
1296
|
+
entry := rawEntry{mode: fields[0], path: filename}
|
|
1297
|
+
if strings.ContainsRune(filename, '\n') {
|
|
1298
|
+
individual = append(individual, entry)
|
|
1299
|
+
} else {
|
|
1300
|
+
batch = append(batch, entry)
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
var indexInfo strings.Builder
|
|
1305
|
+
if len(batch) > 0 {
|
|
1306
|
+
var paths strings.Builder
|
|
1307
|
+
for _, entry := range batch {
|
|
1308
|
+
paths.WriteString(entry.path)
|
|
1309
|
+
paths.WriteByte('\n')
|
|
1310
|
+
}
|
|
1311
|
+
hashes, err := runGitCommandInput(
|
|
1312
|
+
gitRoot, env, []byte(paths.String()),
|
|
1313
|
+
"hash-object", "-w", "--no-filters", "--stdin-paths",
|
|
1314
|
+
)
|
|
1315
|
+
if err != nil {
|
|
1316
|
+
return fmt.Errorf("hash raw worktree files: %w", err)
|
|
1317
|
+
}
|
|
1318
|
+
objectIDs := strings.Fields(string(hashes))
|
|
1319
|
+
if len(objectIDs) != len(batch) {
|
|
1320
|
+
return fmt.Errorf("hash raw worktree files: got %d object ids for %d paths", len(objectIDs), len(batch))
|
|
1321
|
+
}
|
|
1322
|
+
for i, entry := range batch {
|
|
1323
|
+
if !reconcileObjectID.MatchString(objectIDs[i]) {
|
|
1324
|
+
return fmt.Errorf("hash raw worktree file %s: invalid object id", entry.path)
|
|
1325
|
+
}
|
|
1326
|
+
fmt.Fprintf(&indexInfo, "%s %s\t%s\x00", entry.mode, objectIDs[i], entry.path)
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
for _, entry := range individual {
|
|
1330
|
+
hash, err := runGitCommand(gitRoot, env, "hash-object", "-w", "--no-filters", "--", entry.path)
|
|
1331
|
+
if err != nil {
|
|
1332
|
+
return fmt.Errorf("hash raw worktree file %s: %w", entry.path, err)
|
|
1333
|
+
}
|
|
1334
|
+
objectID := strings.TrimSpace(string(hash))
|
|
1335
|
+
if !reconcileObjectID.MatchString(objectID) {
|
|
1336
|
+
return fmt.Errorf("hash raw worktree file %s: invalid object id", entry.path)
|
|
1337
|
+
}
|
|
1338
|
+
fmt.Fprintf(&indexInfo, "%s %s\t%s\x00", entry.mode, objectID, entry.path)
|
|
1339
|
+
}
|
|
1340
|
+
if indexInfo.Len() == 0 {
|
|
1341
|
+
return nil
|
|
1342
|
+
}
|
|
1343
|
+
if _, err := runGitCommandInput(
|
|
1344
|
+
gitRoot, env, []byte(indexInfo.String()),
|
|
1345
|
+
"update-index", "-z", "--index-info",
|
|
1346
|
+
); err != nil {
|
|
1347
|
+
return fmt.Errorf("record raw worktree files: %w", err)
|
|
1348
|
+
}
|
|
1349
|
+
return nil
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1208
1352
|
func reconcileGitEnv(gitRoot, objectDir string) ([]string, error) {
|
|
1209
1353
|
out, err := runGitCommand(gitRoot, nil, "rev-parse", "--git-common-dir")
|
|
1210
1354
|
if err != nil {
|
|
@@ -392,6 +392,80 @@ func TestReconcileAbortRestoresOriginalSourceAndClosesWindow(t *testing.T) {
|
|
|
392
392
|
}
|
|
393
393
|
}
|
|
394
394
|
|
|
395
|
+
func TestReconcileAbortRestoresOriginalCRLFBytes(t *testing.T) {
|
|
396
|
+
gitRoot := newGitRepo(t)
|
|
397
|
+
root := workspace(t, "feat")
|
|
398
|
+
writeWrightAllowlist(t, root, "feat", "seed.go")
|
|
399
|
+
if out, err := exec.Command("git", "-C", gitRoot, "config", "core.autocrlf", "true").CombinedOutput(); err != nil {
|
|
400
|
+
t.Fatalf("configure core.autocrlf: %v\n%s", err, out)
|
|
401
|
+
}
|
|
402
|
+
baseline := []byte("package main\r\n\r\nfunc userWork() {}\r\n")
|
|
403
|
+
if err := os.WriteFile(filepath.Join(gitRoot, "seed.go"), baseline, 0o644); err != nil {
|
|
404
|
+
t.Fatal(err)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
|
|
408
|
+
t.Fatalf("snapshot = %d, want 0\n%s", code, out)
|
|
409
|
+
}
|
|
410
|
+
writeFile(t, filepath.Join(gitRoot, "seed.go"), "package main\n\nfunc rejectedWriterChange() {}\n")
|
|
411
|
+
|
|
412
|
+
if code, out := runReconcile(t, root, "abort", "feat"); code != 0 {
|
|
413
|
+
t.Fatalf("abort = %d, want 0\n%s", code, out)
|
|
414
|
+
}
|
|
415
|
+
restored, err := os.ReadFile(filepath.Join(gitRoot, "seed.go"))
|
|
416
|
+
if err != nil {
|
|
417
|
+
t.Fatal(err)
|
|
418
|
+
}
|
|
419
|
+
if !bytes.Equal(restored, baseline) {
|
|
420
|
+
t.Fatalf("restored bytes = %q, want original bytes %q", restored, baseline)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
func TestReconcileAbortPreservesChangedSubmoduleWorktree(t *testing.T) {
|
|
425
|
+
submoduleOrigin := newGitRepo(t)
|
|
426
|
+
gitRoot := newGitRepo(t)
|
|
427
|
+
git := func(dir string, args ...string) string {
|
|
428
|
+
t.Helper()
|
|
429
|
+
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
|
430
|
+
out, err := cmd.CombinedOutput()
|
|
431
|
+
if err != nil {
|
|
432
|
+
t.Fatalf("git %v: %v\n%s", args, err, out)
|
|
433
|
+
}
|
|
434
|
+
return strings.TrimSpace(string(out))
|
|
435
|
+
}
|
|
436
|
+
git(gitRoot, "-c", "protocol.file.allow=always", "submodule", "add", "-q", submoduleOrigin, "sub")
|
|
437
|
+
commitAll(t, gitRoot, "add submodule")
|
|
438
|
+
|
|
439
|
+
root := workspace(t, "feat")
|
|
440
|
+
writeWrightAllowlist(t, root, "feat")
|
|
441
|
+
if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
|
|
442
|
+
t.Fatalf("snapshot = %d, want 0\n%s", code, out)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
writeFile(t, filepath.Join(submoduleOrigin, "seed.go"), "package changed\n")
|
|
446
|
+
commitAll(t, submoduleOrigin, "advance submodule")
|
|
447
|
+
advanced := git(submoduleOrigin, "rev-parse", "HEAD")
|
|
448
|
+
git(filepath.Join(gitRoot, "sub"), "-c", "protocol.file.allow=always", "fetch", "-q", "origin")
|
|
449
|
+
git(filepath.Join(gitRoot, "sub"), "checkout", "-q", advanced)
|
|
450
|
+
local := []byte("uncommitted submodule work\n")
|
|
451
|
+
if err := os.WriteFile(filepath.Join(gitRoot, "sub", "local.txt"), local, 0o644); err != nil {
|
|
452
|
+
t.Fatal(err)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
code, out := runReconcile(t, root, "abort", "feat")
|
|
456
|
+
if code != 6 {
|
|
457
|
+
t.Fatalf("abort = %d, want fail-closed 6 for a changed submodule\n%s", code, out)
|
|
458
|
+
}
|
|
459
|
+
if restored, err := os.ReadFile(filepath.Join(gitRoot, "sub", "local.txt")); err != nil {
|
|
460
|
+
t.Fatalf("abort removed the live submodule worktree: %v\n%s", err, out)
|
|
461
|
+
} else if !bytes.Equal(restored, local) {
|
|
462
|
+
t.Fatalf("abort changed uncommitted submodule bytes: %q", restored)
|
|
463
|
+
}
|
|
464
|
+
if !isFile(filepath.Join(featureDir(root, "feat"), reconcileBaseName)) {
|
|
465
|
+
t.Fatal("failed abort removed its retained baseline marker")
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
395
469
|
func TestReconcileAbortFailsClosedWhenObjectDatabaseIsMissing(t *testing.T) {
|
|
396
470
|
gitRoot := newGitRepo(t)
|
|
397
471
|
root := workspace(t, "feat")
|
|
@@ -5,6 +5,7 @@ import (
|
|
|
5
5
|
"context"
|
|
6
6
|
"errors"
|
|
7
7
|
"fmt"
|
|
8
|
+
"io"
|
|
8
9
|
"os"
|
|
9
10
|
"os/exec"
|
|
10
11
|
"path/filepath"
|
|
@@ -57,11 +58,27 @@ func (e *gitCommandError) Error() string {
|
|
|
57
58
|
func (e *gitCommandError) Unwrap() error { return e.err }
|
|
58
59
|
|
|
59
60
|
func runGitCommand(dir string, env []string, args ...string) ([]byte, error) {
|
|
61
|
+
return runGitCommandIO(dir, env, nil, nil, args...)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func runGitCommandInput(dir string, env []string, input []byte, args ...string) ([]byte, error) {
|
|
65
|
+
return runGitCommandIO(dir, env, input, nil, args...)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func runGitCommandToWriter(dir string, env []string, stdout io.Writer, args ...string) error {
|
|
69
|
+
_, err := runGitCommandIO(dir, env, nil, stdout, args...)
|
|
70
|
+
return err
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func runGitCommandIO(dir string, env []string, input []byte, stdout io.Writer, args ...string) ([]byte, error) {
|
|
60
74
|
ctx, cancel := context.WithTimeout(context.Background(), gitCommandTimeout)
|
|
61
75
|
defer cancel()
|
|
62
76
|
commandArgs := append([]string{"-C", dir}, args...)
|
|
63
77
|
cmd := exec.CommandContext(ctx, "git", commandArgs...)
|
|
64
78
|
cmd.WaitDelay = 2 * time.Second
|
|
79
|
+
if input != nil {
|
|
80
|
+
cmd.Stdin = bytes.NewReader(input)
|
|
81
|
+
}
|
|
65
82
|
if env == nil {
|
|
66
83
|
env = os.Environ()
|
|
67
84
|
} else {
|
|
@@ -76,7 +93,11 @@ func runGitCommand(dir string, env []string, args ...string) ([]byte, error) {
|
|
|
76
93
|
"LC_ALL=C",
|
|
77
94
|
)
|
|
78
95
|
var output cappedGitOutput
|
|
79
|
-
|
|
96
|
+
if stdout == nil {
|
|
97
|
+
cmd.Stdout = &output
|
|
98
|
+
} else {
|
|
99
|
+
cmd.Stdout = stdout
|
|
100
|
+
}
|
|
80
101
|
cmd.Stderr = &output
|
|
81
102
|
err := cmd.Run()
|
|
82
103
|
if err == nil {
|
|
@@ -293,6 +293,33 @@ func TestHookReviewerReadonlyAllowsSafeBash(t *testing.T) {
|
|
|
293
293
|
}
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
func TestHookReviewerReadonlyRejectsWriteCapableInspectionFlags(t *testing.T) {
|
|
297
|
+
root := newWorkspace(t)
|
|
298
|
+
env := []string{
|
|
299
|
+
"DEVRITES_AGENT_RUN=1",
|
|
300
|
+
"DEVRITES_ACTIVE_AGENT=devrites-code-reviewer",
|
|
301
|
+
}
|
|
302
|
+
for _, command := range []string{
|
|
303
|
+
"sed --in-place=.bak s/a/b/ src/app.go",
|
|
304
|
+
"sort -o src/app.go input.txt",
|
|
305
|
+
"find . -fls src/index.txt",
|
|
306
|
+
"git diff --output=src/diff.txt",
|
|
307
|
+
} {
|
|
308
|
+
in := hookPayload(t, map[string]any{
|
|
309
|
+
"tool_name": "Bash",
|
|
310
|
+
"tool_input": map[string]any{"command": command},
|
|
311
|
+
})
|
|
312
|
+
out, errOut, code := runDevritesIO(t, root, in, env,
|
|
313
|
+
"hook", "reviewer-readonly", "--harness=codex")
|
|
314
|
+
if code != 0 {
|
|
315
|
+
t.Fatalf("%q exit=%d stderr=%q", command, code, errOut)
|
|
316
|
+
}
|
|
317
|
+
if decision, _ := parsePermissionDecision(t, out); decision != "deny" {
|
|
318
|
+
t.Errorf("write-capable inspection command was not denied: %q", command)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
296
323
|
func TestHookReviewerReadonlyNonBashIsSilent(t *testing.T) {
|
|
297
324
|
root := newWorkspace(t)
|
|
298
325
|
in := `{"tool_name":"Read","tool_input":{"file_path":"secret.txt"}}`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "devrites",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.24",
|
|
4
4
|
"description": "DevRites: a disciplined senior-engineer workflow pack for Claude Code and Codex",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"homepage": "https://github.com/ViktorsBaikers/DevRites#readme",
|