@soulguard/core 0.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.
Files changed (94) hide show
  1. package/README.md +127 -0
  2. package/dist/cli.js +8838 -0
  3. package/dist/index.js +6555 -0
  4. package/package.json +35 -0
  5. package/src/approve.test.ts +386 -0
  6. package/src/approve.ts +352 -0
  7. package/src/cli/approve-command.ts +107 -0
  8. package/src/cli/diff-command.test.ts +61 -0
  9. package/src/cli/diff-command.ts +81 -0
  10. package/src/cli/init-command.ts +83 -0
  11. package/src/cli/reset-command.ts +36 -0
  12. package/src/cli/status-command.test.ts +90 -0
  13. package/src/cli/status-command.ts +78 -0
  14. package/src/cli/sync-command.test.ts +67 -0
  15. package/src/cli/sync-command.ts +105 -0
  16. package/src/cli.ts +224 -0
  17. package/src/console-live.ts +32 -0
  18. package/src/console-mock.ts +48 -0
  19. package/src/console.ts +12 -0
  20. package/src/constants.ts +21 -0
  21. package/src/diff.test.ts +189 -0
  22. package/src/diff.ts +212 -0
  23. package/src/git.test.ts +180 -0
  24. package/src/git.ts +131 -0
  25. package/src/glob.test.ts +74 -0
  26. package/src/glob.ts +84 -0
  27. package/src/index.ts +100 -0
  28. package/src/init.test.ts +234 -0
  29. package/src/init.ts +317 -0
  30. package/src/policy.test.ts +123 -0
  31. package/src/policy.ts +100 -0
  32. package/src/reset.test.ts +68 -0
  33. package/src/reset.ts +63 -0
  34. package/src/result.ts +14 -0
  35. package/src/schema.test.ts +27 -0
  36. package/src/schema.ts +22 -0
  37. package/src/self-protection.test.ts +139 -0
  38. package/src/self-protection.ts +63 -0
  39. package/src/status.test.ts +241 -0
  40. package/src/status.ts +114 -0
  41. package/src/sync.test.ts +172 -0
  42. package/src/sync.ts +101 -0
  43. package/src/system-ops-mock.ts +243 -0
  44. package/src/system-ops-node.test.ts +183 -0
  45. package/src/system-ops-node.ts +499 -0
  46. package/src/system-ops.ts +109 -0
  47. package/src/types.ts +91 -0
  48. package/src/vault-check.test.ts +41 -0
  49. package/src/vault-check.ts +24 -0
  50. package/test-e2e/Dockerfile +29 -0
  51. package/test-e2e/cases/approve-with-hash/expected.txt +16 -0
  52. package/test-e2e/cases/approve-with-hash/test.sh +23 -0
  53. package/test-e2e/cases/diff-no-changes/expected.txt +5 -0
  54. package/test-e2e/cases/diff-no-changes/test.sh +7 -0
  55. package/test-e2e/cases/diff-no-staging/expected.txt +1 -0
  56. package/test-e2e/cases/diff-no-staging/test.sh +6 -0
  57. package/test-e2e/cases/diff-shows-changes/expected.txt +13 -0
  58. package/test-e2e/cases/diff-shows-changes/test.sh +12 -0
  59. package/test-e2e/cases/diff-vault-missing/expected.txt +6 -0
  60. package/test-e2e/cases/diff-vault-missing/test.sh +10 -0
  61. package/test-e2e/cases/glob-ledger-files/expected.txt +52 -0
  62. package/test-e2e/cases/glob-ledger-files/test.sh +28 -0
  63. package/test-e2e/cases/glob-vault-files/expected.txt +41 -0
  64. package/test-e2e/cases/glob-vault-files/test.sh +30 -0
  65. package/test-e2e/cases/init-blocked-by-agent/expected.txt +11 -0
  66. package/test-e2e/cases/init-blocked-by-agent/test.sh +15 -0
  67. package/test-e2e/cases/init-happy/expected.txt +18 -0
  68. package/test-e2e/cases/init-happy/test.sh +20 -0
  69. package/test-e2e/cases/init-idempotent/expected.txt +13 -0
  70. package/test-e2e/cases/init-idempotent/test.sh +14 -0
  71. package/test-e2e/cases/reset-staging/expected.txt +16 -0
  72. package/test-e2e/cases/reset-staging/test.sh +23 -0
  73. package/test-e2e/cases/self-protection-blocks-invalid/expected.txt +12 -0
  74. package/test-e2e/cases/self-protection-blocks-invalid/test.sh +25 -0
  75. package/test-e2e/cases/status-clean/expected.txt +9 -0
  76. package/test-e2e/cases/status-clean/test.sh +8 -0
  77. package/test-e2e/cases/status-drifted/expected.txt +9 -0
  78. package/test-e2e/cases/status-drifted/test.sh +11 -0
  79. package/test-e2e/cases/status-no-config/expected.txt +1 -0
  80. package/test-e2e/cases/status-no-config/test.sh +2 -0
  81. package/test-e2e/cases/sync-fix-drift/expected.txt +15 -0
  82. package/test-e2e/cases/sync-fix-drift/test.sh +14 -0
  83. package/test-e2e/cases/sync-no-sudo-clean/expected.txt +11 -0
  84. package/test-e2e/cases/sync-no-sudo-clean/test.sh +9 -0
  85. package/test-e2e/cases/sync-no-sudo-drift/expected.txt +7 -0
  86. package/test-e2e/cases/sync-no-sudo-drift/test.sh +10 -0
  87. package/test-e2e/cases/vault-remove-file/expected.txt +26 -0
  88. package/test-e2e/cases/vault-remove-file/test.sh +31 -0
  89. package/test-e2e/cases/vault-write-blocked/expected.txt +8 -0
  90. package/test-e2e/cases/vault-write-blocked/test.sh +14 -0
  91. package/test-e2e/run-tests.sh +76 -0
  92. package/test-integration/Dockerfile +17 -0
  93. package/test-integration/system-ops-node.integration.test.ts +170 -0
  94. package/tsconfig.json +8 -0
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Shared constants for soulguard.
3
+ */
4
+
5
+ import type { FileOwnership, SoulguardConfig } from "./types.js";
6
+
7
+ /** System user/group identity for soulguard */
8
+ export const IDENTITY = { user: "soulguardian", group: "soulguard" } as const;
9
+
10
+ /** Default vault file ownership */
11
+ export const VAULT_OWNERSHIP: FileOwnership = {
12
+ user: IDENTITY.user,
13
+ group: IDENTITY.group,
14
+ mode: "444",
15
+ } as const;
16
+
17
+ /** Sensible default config — vaults soulguard's own config */
18
+ export const DEFAULT_CONFIG: SoulguardConfig = {
19
+ vault: ["soulguard.json"],
20
+ ledger: [],
21
+ } as const;
@@ -0,0 +1,189 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { diff } from "./diff.js";
3
+ import { MockSystemOps } from "./system-ops-mock.js";
4
+ import type { SoulguardConfig } from "./types.js";
5
+
6
+ const WORKSPACE = "/test/workspace";
7
+
8
+ function makeMock() {
9
+ return new MockSystemOps(WORKSPACE);
10
+ }
11
+
12
+ function makeConfig(vault: string[] = ["SOUL.md"]): SoulguardConfig {
13
+ return { vault, ledger: [] };
14
+ }
15
+
16
+ describe("diff", () => {
17
+ test("no changes → all unchanged, hasChanges false", async () => {
18
+ const ops = makeMock();
19
+ ops.addFile(".soulguard/staging", "");
20
+ ops.addFile("SOUL.md", "# Soul");
21
+ ops.addFile(".soulguard/staging/SOUL.md", "# Soul");
22
+
23
+ const result = await diff({ ops, config: makeConfig() });
24
+
25
+ expect(result.ok).toBe(true);
26
+ if (!result.ok) return;
27
+ expect(result.value.hasChanges).toBe(false);
28
+ expect(result.value.files).toHaveLength(1);
29
+ expect(result.value.files[0]!.status).toBe("unchanged");
30
+ });
31
+
32
+ test("modified file → shows diff, hasChanges true", async () => {
33
+ const ops = makeMock();
34
+ ops.addFile(".soulguard/staging", "");
35
+ ops.addFile("SOUL.md", "# Soul\noriginal");
36
+ ops.addFile(".soulguard/staging/SOUL.md", "# Soul\nmodified");
37
+
38
+ const result = await diff({ ops, config: makeConfig() });
39
+
40
+ expect(result.ok).toBe(true);
41
+ if (!result.ok) return;
42
+ expect(result.value.hasChanges).toBe(true);
43
+ expect(result.value.files[0]!.status).toBe("modified");
44
+ expect(result.value.files[0]!.diff).toContain("-original");
45
+ expect(result.value.files[0]!.diff).toContain("+modified");
46
+ });
47
+
48
+ test("vault exists but staging deleted → deleted status", async () => {
49
+ const ops = makeMock();
50
+ ops.addFile(".soulguard/staging", "");
51
+ ops.addFile("SOUL.md", "# Soul");
52
+
53
+ const result = await diff({ ops, config: makeConfig() });
54
+
55
+ expect(result.ok).toBe(true);
56
+ if (!result.ok) return;
57
+ expect(result.value.hasChanges).toBe(true);
58
+ expect(result.value.files[0]!.status).toBe("deleted");
59
+ expect(result.value.files[0]!.protectedHash).toBeDefined();
60
+ expect(result.value.approvalHash).toBeDefined();
61
+ });
62
+
63
+ test("neither vault nor staging exists → staging_missing status", async () => {
64
+ const ops = makeMock();
65
+ ops.addFile(".soulguard/staging", "");
66
+
67
+ const result = await diff({ ops, config: makeConfig() });
68
+
69
+ expect(result.ok).toBe(true);
70
+ if (!result.ok) return;
71
+ expect(result.value.files[0]!.status).toBe("staging_missing");
72
+ });
73
+
74
+ test("vault file missing → vault_missing status", async () => {
75
+ const ops = makeMock();
76
+ ops.addFile(".soulguard/staging", "");
77
+ ops.addFile(".soulguard/staging/SOUL.md", "# New Soul");
78
+
79
+ const result = await diff({ ops, config: makeConfig() });
80
+
81
+ expect(result.ok).toBe(true);
82
+ if (!result.ok) return;
83
+ expect(result.value.hasChanges).toBe(true);
84
+ expect(result.value.files[0]!.status).toBe("vault_missing");
85
+ });
86
+
87
+ test("no staging dir → no_staging error", async () => {
88
+ const ops = makeMock();
89
+ ops.addFile("SOUL.md", "# Soul");
90
+
91
+ const result = await diff({ ops, config: makeConfig() });
92
+
93
+ expect(result.ok).toBe(false);
94
+ if (result.ok) return;
95
+ expect(result.error.kind).toBe("no_staging");
96
+ });
97
+
98
+ test("specific files filter works", async () => {
99
+ const ops = makeMock();
100
+ ops.addFile(".soulguard/staging", "");
101
+ ops.addFile("SOUL.md", "# Soul");
102
+ ops.addFile(".soulguard/staging/SOUL.md", "# Soul");
103
+ ops.addFile("AGENTS.md", "# Agents");
104
+ ops.addFile(".soulguard/staging/AGENTS.md", "# Agents modified");
105
+
106
+ const config = makeConfig(["SOUL.md", "AGENTS.md"]);
107
+ const result = await diff({ ops, config, files: ["SOUL.md"] });
108
+
109
+ expect(result.ok).toBe(true);
110
+ if (!result.ok) return;
111
+ expect(result.value.files).toHaveLength(1);
112
+ expect(result.value.files[0]!.path).toBe("SOUL.md");
113
+ });
114
+
115
+ test("globs resolve to matching files", async () => {
116
+ const ops = makeMock();
117
+ ops.addFile(".soulguard/staging", "");
118
+ ops.addFile("SOUL.md", "# Soul");
119
+ ops.addFile(".soulguard/staging/SOUL.md", "# Soul");
120
+ ops.addFile("memory/day1.md", "notes");
121
+ ops.addFile(".soulguard/staging/memory/day1.md", "notes");
122
+
123
+ const config = makeConfig(["SOUL.md", "memory/**"]);
124
+ const result = await diff({ ops, config });
125
+
126
+ expect(result.ok).toBe(true);
127
+ if (!result.ok) return;
128
+ expect(result.value.files).toHaveLength(2);
129
+ expect(result.value.files.map((f) => f.path)).toContain("SOUL.md");
130
+ expect(result.value.files.map((f) => f.path)).toContain("memory/day1.md");
131
+ });
132
+
133
+ test("approvalHash is present when changes exist", async () => {
134
+ const ops = makeMock();
135
+ ops.addFile(".soulguard/staging", "");
136
+ ops.addFile("SOUL.md", "original");
137
+ ops.addFile(".soulguard/staging/SOUL.md", "modified");
138
+
139
+ const result = await diff({ ops, config: makeConfig() });
140
+ expect(result.ok).toBe(true);
141
+ if (!result.ok) return;
142
+ expect(result.value.approvalHash).toBeDefined();
143
+ expect(typeof result.value.approvalHash).toBe("string");
144
+ expect(result.value.approvalHash!.length).toBe(64); // SHA-256 hex
145
+ });
146
+
147
+ test("approvalHash is undefined when no changes", async () => {
148
+ const ops = makeMock();
149
+ ops.addFile(".soulguard/staging", "");
150
+ ops.addFile("SOUL.md", "same");
151
+ ops.addFile(".soulguard/staging/SOUL.md", "same");
152
+
153
+ const result = await diff({ ops, config: makeConfig() });
154
+ expect(result.ok).toBe(true);
155
+ if (!result.ok) return;
156
+ expect(result.value.approvalHash).toBeUndefined();
157
+ });
158
+
159
+ test("approvalHash is deterministic", async () => {
160
+ const ops = makeMock();
161
+ ops.addFile(".soulguard/staging", "");
162
+ ops.addFile("SOUL.md", "original");
163
+ ops.addFile(".soulguard/staging/SOUL.md", "modified");
164
+
165
+ const r1 = await diff({ ops, config: makeConfig() });
166
+ const r2 = await diff({ ops, config: makeConfig() });
167
+ expect(r1.ok && r2.ok).toBe(true);
168
+ if (r1.ok && r2.ok) {
169
+ expect(r1.value.approvalHash).toBe(r2.value.approvalHash);
170
+ }
171
+ });
172
+
173
+ test("approvalHash changes when staging content changes", async () => {
174
+ const ops = makeMock();
175
+ ops.addFile(".soulguard/staging", "");
176
+ ops.addFile("SOUL.md", "original");
177
+ ops.addFile(".soulguard/staging/SOUL.md", "modified-v1");
178
+
179
+ const r1 = await diff({ ops, config: makeConfig() });
180
+
181
+ ops.addFile(".soulguard/staging/SOUL.md", "modified-v2");
182
+ const r2 = await diff({ ops, config: makeConfig() });
183
+
184
+ expect(r1.ok && r2.ok).toBe(true);
185
+ if (r1.ok && r2.ok) {
186
+ expect(r1.value.approvalHash).not.toBe(r2.value.approvalHash);
187
+ }
188
+ });
189
+ });
package/src/diff.ts ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * soulguard diff — compare vault files against their staging copies.
3
+ */
4
+
5
+ import { createHash } from "node:crypto";
6
+ import { createTwoFilesPatch } from "diff";
7
+ import type { Result } from "./result.js";
8
+ import { ok, err } from "./result.js";
9
+ import type { SoulguardConfig } from "./types.js";
10
+ import type { SystemOperations } from "./system-ops.js";
11
+ import { resolvePatterns } from "./glob.js";
12
+
13
+ // ── Types ──────────────────────────────────────────────────────────────
14
+
15
+ export type FileDiff = {
16
+ path: string;
17
+ status: "modified" | "unchanged" | "vault_missing" | "staging_missing" | "deleted";
18
+ /** Unified diff string (only for modified) */
19
+ diff?: string;
20
+ protectedHash?: string;
21
+ stagedHash?: string;
22
+ };
23
+
24
+ export type DiffResult = {
25
+ files: FileDiff[];
26
+ hasChanges: boolean;
27
+ /**
28
+ * Approval hash — SHA-256 of all modified file paths + staged content hashes,
29
+ * sorted deterministically. Used for hash-based approve integrity check.
30
+ * Only present when there are changes.
31
+ */
32
+ approvalHash?: string;
33
+ };
34
+
35
+ export type DiffError =
36
+ | { kind: "no_staging" }
37
+ | { kind: "no_config" }
38
+ | { kind: "read_failed"; path: string; message: string };
39
+
40
+ export type DiffOptions = {
41
+ ops: SystemOperations;
42
+ config: SoulguardConfig;
43
+ /** Specific files to diff (default: all vault files) */
44
+ files?: string[];
45
+ };
46
+
47
+ // ── Implementation ─────────────────────────────────────────────────────
48
+
49
+ const STAGING_DIR = ".soulguard/staging";
50
+
51
+ /**
52
+ * Compare vault files against their staging copies.
53
+ */
54
+ export async function diff(options: DiffOptions): Promise<Result<DiffResult, DiffError>> {
55
+ const { ops, config, files: filterFiles } = options;
56
+
57
+ // Check staging directory exists
58
+ const stagingExists = await ops.exists(STAGING_DIR);
59
+ if (!stagingExists.ok) {
60
+ return err({ kind: "read_failed", path: STAGING_DIR, message: stagingExists.error.message });
61
+ }
62
+ if (!stagingExists.value) {
63
+ return err({ kind: "no_staging" });
64
+ }
65
+
66
+ // Resolve glob patterns to concrete file paths
67
+ const resolved = await resolvePatterns(ops, config.vault);
68
+ if (!resolved.ok) {
69
+ return err({ kind: "read_failed", path: "glob", message: resolved.error.message });
70
+ }
71
+ let vaultFiles = resolved.value;
72
+ if (filterFiles && filterFiles.length > 0) {
73
+ const filterSet = new Set(filterFiles);
74
+ vaultFiles = vaultFiles.filter((p) => filterSet.has(p));
75
+ }
76
+
77
+ const fileDiffs: FileDiff[] = [];
78
+
79
+ for (const path of vaultFiles) {
80
+ const stagingPath = `${STAGING_DIR}/${path}`;
81
+
82
+ const [vaultExists, stagingFileExists] = await Promise.all([
83
+ ops.exists(path),
84
+ ops.exists(stagingPath),
85
+ ]);
86
+
87
+ if (!vaultExists.ok) {
88
+ return err({ kind: "read_failed", path, message: vaultExists.error.message });
89
+ }
90
+ if (!stagingFileExists.ok) {
91
+ return err({
92
+ kind: "read_failed",
93
+ path: stagingPath,
94
+ message: stagingFileExists.error.message,
95
+ });
96
+ }
97
+
98
+ // Missing cases
99
+ if (vaultExists.value && !stagingFileExists.value) {
100
+ // Vault file exists but staging copy deleted → agent wants to delete this file
101
+ const vaultHash = await ops.hashFile(path);
102
+ fileDiffs.push({
103
+ path,
104
+ status: "deleted",
105
+ protectedHash: vaultHash.ok ? vaultHash.value : undefined,
106
+ });
107
+ continue;
108
+ }
109
+ if (!vaultExists.value && stagingFileExists.value) {
110
+ // New file — hash staging so it's covered by the approval hash
111
+ const newHash = await ops.hashFile(stagingPath);
112
+ fileDiffs.push({
113
+ path,
114
+ status: "vault_missing",
115
+ stagedHash: newHash.ok ? newHash.value : undefined,
116
+ });
117
+ continue;
118
+ }
119
+ if (!vaultExists.value && !stagingFileExists.value) {
120
+ fileDiffs.push({ path, status: "staging_missing" });
121
+ continue;
122
+ }
123
+
124
+ // Both exist — compare hashes
125
+ const [vaultHash, stagingHash] = await Promise.all([
126
+ ops.hashFile(path),
127
+ ops.hashFile(stagingPath),
128
+ ]);
129
+
130
+ if (!vaultHash.ok) {
131
+ return err({ kind: "read_failed", path, message: "hash failed" });
132
+ }
133
+ if (!stagingHash.ok) {
134
+ return err({ kind: "read_failed", path: stagingPath, message: "hash failed" });
135
+ }
136
+
137
+ if (vaultHash.value === stagingHash.value) {
138
+ fileDiffs.push({
139
+ path,
140
+ status: "unchanged",
141
+ protectedHash: vaultHash.value,
142
+ stagedHash: stagingHash.value,
143
+ });
144
+ continue;
145
+ }
146
+
147
+ // Modified — generate unified diff
148
+ const [vaultContent, stagingContent] = await Promise.all([
149
+ ops.readFile(path),
150
+ ops.readFile(stagingPath),
151
+ ]);
152
+
153
+ if (!vaultContent.ok) {
154
+ return err({ kind: "read_failed", path, message: "read failed" });
155
+ }
156
+ if (!stagingContent.ok) {
157
+ return err({ kind: "read_failed", path: stagingPath, message: "read failed" });
158
+ }
159
+
160
+ const unifiedDiff = createTwoFilesPatch(
161
+ `a/${path}`,
162
+ `b/${path}`,
163
+ vaultContent.value,
164
+ stagingContent.value,
165
+ );
166
+
167
+ fileDiffs.push({
168
+ path,
169
+ status: "modified",
170
+ diff: unifiedDiff,
171
+ protectedHash: vaultHash.value,
172
+ stagedHash: stagingHash.value,
173
+ });
174
+ }
175
+
176
+ const hasChanges = fileDiffs.some((f) => f.status !== "unchanged");
177
+
178
+ // Compute approval hash from modified files (deterministic)
179
+ let approvalHash: string | undefined;
180
+ if (hasChanges) {
181
+ approvalHash = computeApprovalHash(fileDiffs);
182
+ }
183
+
184
+ return ok({ files: fileDiffs, hasChanges, approvalHash });
185
+ }
186
+
187
+ /**
188
+ * Compute a deterministic approval hash from file diffs.
189
+ * Covers all modified files — sorted by path, hashing path + stagedHash pairs.
190
+ * This is the integrity token for hash-based approve.
191
+ */
192
+ export function computeApprovalHash(files: FileDiff[]): string {
193
+ // Include modified, new (vault_missing), and deleted files in the hash
194
+ const actionable = files
195
+ .filter(
196
+ (f) =>
197
+ ((f.status === "modified" || f.status === "vault_missing") && f.stagedHash) ||
198
+ f.status === "deleted",
199
+ )
200
+ .sort((a, b) => a.path.localeCompare(b.path));
201
+
202
+ const hash = createHash("sha256");
203
+ for (const f of actionable) {
204
+ if (f.status === "deleted") {
205
+ // Use a sentinel for deletions — hash includes the vault hash to prevent replay
206
+ hash.update(`${f.path}\0DELETED\0${f.protectedHash ?? ""}\0`);
207
+ } else {
208
+ hash.update(`${f.path}\0${f.stagedHash}\0`);
209
+ }
210
+ }
211
+ return hash.digest("hex");
212
+ }
@@ -0,0 +1,180 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { MockSystemOps } from "./system-ops-mock.js";
3
+ import {
4
+ isGitEnabled,
5
+ gitCommit,
6
+ vaultCommitMessage,
7
+ ledgerCommitMessage,
8
+ commitLedgerFiles,
9
+ } from "./git.js";
10
+
11
+ describe("isGitEnabled", () => {
12
+ test("returns true when git not false and .git exists", async () => {
13
+ const ops = new MockSystemOps("/workspace");
14
+ ops.addFile(".git", "");
15
+ expect(await isGitEnabled(ops, { vault: [], ledger: [] })).toBe(true);
16
+ });
17
+
18
+ test("returns true when git explicitly true", async () => {
19
+ const ops = new MockSystemOps("/workspace");
20
+ ops.addFile(".git", "");
21
+ expect(await isGitEnabled(ops, { vault: [], ledger: [], git: true })).toBe(true);
22
+ });
23
+
24
+ test("returns false when git is false", async () => {
25
+ const ops = new MockSystemOps("/workspace");
26
+ ops.addFile(".git", "");
27
+ expect(await isGitEnabled(ops, { vault: [], ledger: [], git: false })).toBe(false);
28
+ });
29
+
30
+ test("returns false when no .git directory", async () => {
31
+ const ops = new MockSystemOps("/workspace");
32
+ expect(await isGitEnabled(ops, { vault: [], ledger: [] })).toBe(false);
33
+ });
34
+ });
35
+
36
+ describe("gitCommit", () => {
37
+ test("stages files and commits when changes exist", async () => {
38
+ const ops = new MockSystemOps("/workspace");
39
+ // First diff --cached --quiet (pre-check) succeeds (no pre-existing staged changes)
40
+ // Second diff --cached --quiet (post-add) fails (= our files are staged)
41
+ ops.execFailOnCall.set("git diff --cached --quiet", new Set([1]));
42
+ const result = await gitCommit(ops, ["SOUL.md", "AGENTS.md"], "test commit");
43
+ expect(result.ok).toBe(true);
44
+ if (!result.ok) return;
45
+ expect(result.value.committed).toBe(true);
46
+ if (result.value.committed) {
47
+ expect(result.value.files).toEqual(["SOUL.md", "AGENTS.md"]);
48
+ expect(result.value.message).toBe("test commit");
49
+ }
50
+ const execOps = ops.ops.filter((op) => op.kind === "exec");
51
+ expect(execOps).toEqual([
52
+ { kind: "exec", command: "git", args: ["diff", "--cached", "--quiet"] },
53
+ { kind: "exec", command: "git", args: ["add", "--", "SOUL.md"] },
54
+ { kind: "exec", command: "git", args: ["add", "--", "AGENTS.md"] },
55
+ { kind: "exec", command: "git", args: ["diff", "--cached", "--quiet"] },
56
+ {
57
+ kind: "exec",
58
+ command: "git",
59
+ args: [
60
+ "commit",
61
+ "--author",
62
+ "SoulGuardian <soulguardian@soulguard.ai>",
63
+ "-m",
64
+ "test commit",
65
+ ],
66
+ },
67
+ ]);
68
+ });
69
+
70
+ test("returns nothing_staged when nothing to commit", async () => {
71
+ const ops = new MockSystemOps("/workspace");
72
+ const result = await gitCommit(ops, ["SOUL.md"], "test commit");
73
+ expect(result.ok).toBe(true);
74
+ if (!result.ok) return;
75
+ expect(result.value.committed).toBe(false);
76
+ if (!result.value.committed) {
77
+ expect(result.value.reason).toBe("nothing_staged");
78
+ }
79
+ });
80
+
81
+ test("returns dirty_staging when user has pre-existing staged changes", async () => {
82
+ const ops = new MockSystemOps("/workspace");
83
+ // Make the pre-check diff --cached --quiet fail (= there are staged changes)
84
+ ops.failingExecs.add("git diff --cached --quiet");
85
+ const result = await gitCommit(ops, ["SOUL.md"], "test commit");
86
+ expect(result.ok).toBe(true);
87
+ if (!result.ok) return;
88
+ expect(result.value.committed).toBe(false);
89
+ if (!result.value.committed) {
90
+ expect(result.value.reason).toBe("dirty_staging");
91
+ }
92
+ // Should NOT have called git add or git commit
93
+ const gitOps = ops.ops.filter((o) => o.kind === "exec" && o.command === "git");
94
+ expect(gitOps).toHaveLength(1); // only the diff check
95
+ });
96
+
97
+ test("returns no_files with empty files array", async () => {
98
+ const ops = new MockSystemOps("/workspace");
99
+ const result = await gitCommit(ops, [], "empty");
100
+ expect(result.ok).toBe(true);
101
+ if (!result.ok) return;
102
+ expect(result.value.committed).toBe(false);
103
+ if (!result.value.committed) {
104
+ expect(result.value.reason).toBe("no_files");
105
+ }
106
+ });
107
+ });
108
+
109
+ describe("commit messages", () => {
110
+ test("vaultCommitMessage with files only", () => {
111
+ expect(vaultCommitMessage(["SOUL.md"])).toBe("soulguard: vault update — SOUL.md");
112
+ });
113
+
114
+ test("vaultCommitMessage with approval message", () => {
115
+ expect(vaultCommitMessage(["SOUL.md", "AGENTS.md"], "identity refresh")).toBe(
116
+ "soulguard: vault update — SOUL.md, AGENTS.md\n\nidentity refresh",
117
+ );
118
+ });
119
+
120
+ test("ledgerCommitMessage", () => {
121
+ expect(ledgerCommitMessage()).toBe("soulguard: ledger sync");
122
+ });
123
+ });
124
+
125
+ describe("commitLedgerFiles", () => {
126
+ test("commits ledger files when git enabled and changes exist", async () => {
127
+ const ops = new MockSystemOps("/workspace");
128
+ ops.addFile(".git", "");
129
+ // Pre-check succeeds (no pre-existing staged), post-add fails (= our files staged)
130
+ ops.execFailOnCall.set("git diff --cached --quiet", new Set([1]));
131
+
132
+ const result = await commitLedgerFiles(ops, {
133
+ vault: [],
134
+ ledger: ["MEMORY.md", "memory/*.md"],
135
+ git: true,
136
+ });
137
+
138
+ expect(result.ok).toBe(true);
139
+ if (!result.ok) return;
140
+ expect(result.value.committed).toBe(true);
141
+ if (result.value.committed) {
142
+ expect(result.value.message).toBe("soulguard: ledger sync");
143
+ // Globs are filtered out — only MEMORY.md staged
144
+ expect(result.value.files).toEqual(["MEMORY.md"]);
145
+ }
146
+ });
147
+
148
+ test("returns git_disabled when git not enabled", async () => {
149
+ const ops = new MockSystemOps("/workspace");
150
+ const result = await commitLedgerFiles(ops, { vault: [], ledger: ["MEMORY.md"], git: false });
151
+
152
+ expect(result.ok).toBe(true);
153
+ if (!result.ok) return;
154
+ expect(result.value).toEqual({ committed: false, reason: "git_disabled" });
155
+ });
156
+
157
+ test("returns no_files when no ledger files", async () => {
158
+ const ops = new MockSystemOps("/workspace");
159
+ ops.addFile(".git", "");
160
+ const result = await commitLedgerFiles(ops, { vault: [], ledger: [], git: true });
161
+
162
+ expect(result.ok).toBe(true);
163
+ if (!result.ok) return;
164
+ expect(result.value).toEqual({ committed: false, reason: "no_files" });
165
+ });
166
+
167
+ test("returns no_files when only glob patterns", async () => {
168
+ const ops = new MockSystemOps("/workspace");
169
+ ops.addFile(".git", "");
170
+ const result = await commitLedgerFiles(ops, {
171
+ vault: [],
172
+ ledger: ["memory/*.md"],
173
+ git: true,
174
+ });
175
+
176
+ expect(result.ok).toBe(true);
177
+ if (!result.ok) return;
178
+ expect(result.value).toEqual({ committed: false, reason: "no_files" });
179
+ });
180
+ });