comisai 1.0.10 → 1.0.12

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 (251) hide show
  1. package/README.md +3 -3
  2. package/node_modules/@comis/agent/dist/executor/pi-executor.d.ts +1 -4
  3. package/node_modules/@comis/agent/package.json +1 -1
  4. package/node_modules/@comis/channels/package.json +1 -1
  5. package/node_modules/@comis/cli/dist/wizard/steps/06-channels.js +88 -11
  6. package/node_modules/@comis/cli/dist/wizard/steps/10-write-config.js +4 -0
  7. package/node_modules/@comis/cli/dist/wizard/types.d.ts +1 -0
  8. package/node_modules/@comis/cli/package.json +1 -1
  9. package/node_modules/@comis/core/package.json +1 -1
  10. package/node_modules/@comis/daemon/package.json +1 -1
  11. package/node_modules/@comis/gateway/package.json +1 -1
  12. package/node_modules/@comis/infra/package.json +1 -1
  13. package/node_modules/@comis/memory/package.json +1 -1
  14. package/node_modules/@comis/scheduler/package.json +1 -1
  15. package/node_modules/@comis/shared/package.json +1 -1
  16. package/node_modules/@comis/skills/package.json +1 -1
  17. package/package.json +18 -18
  18. package/node_modules/@comis/core/dist/approval/approval-gate.test.d.ts +0 -1
  19. package/node_modules/@comis/core/dist/approval/approval-gate.test.js +0 -1003
  20. package/node_modules/@comis/core/dist/bootstrap.test.d.ts +0 -1
  21. package/node_modules/@comis/core/dist/bootstrap.test.js +0 -150
  22. package/node_modules/@comis/core/dist/config/backup.test.d.ts +0 -1
  23. package/node_modules/@comis/core/dist/config/backup.test.js +0 -160
  24. package/node_modules/@comis/core/dist/config/env-substitution.test.d.ts +0 -1
  25. package/node_modules/@comis/core/dist/config/env-substitution.test.js +0 -259
  26. package/node_modules/@comis/core/dist/config/field-metadata.test.d.ts +0 -1
  27. package/node_modules/@comis/core/dist/config/field-metadata.test.js +0 -72
  28. package/node_modules/@comis/core/dist/config/git-manager.test.d.ts +0 -1
  29. package/node_modules/@comis/core/dist/config/git-manager.test.js +0 -1042
  30. package/node_modules/@comis/core/dist/config/immutable-keys.test.d.ts +0 -1
  31. package/node_modules/@comis/core/dist/config/immutable-keys.test.js +0 -283
  32. package/node_modules/@comis/core/dist/config/include-resolver.test.d.ts +0 -1
  33. package/node_modules/@comis/core/dist/config/include-resolver.test.js +0 -292
  34. package/node_modules/@comis/core/dist/config/layered.test.d.ts +0 -1
  35. package/node_modules/@comis/core/dist/config/layered.test.js +0 -211
  36. package/node_modules/@comis/core/dist/config/loader.test.d.ts +0 -1
  37. package/node_modules/@comis/core/dist/config/loader.test.js +0 -300
  38. package/node_modules/@comis/core/dist/config/migrate.test.d.ts +0 -1
  39. package/node_modules/@comis/core/dist/config/migrate.test.js +0 -244
  40. package/node_modules/@comis/core/dist/config/partial-validator.test.d.ts +0 -1
  41. package/node_modules/@comis/core/dist/config/partial-validator.test.js +0 -98
  42. package/node_modules/@comis/core/dist/config/schema-agent-model.test.d.ts +0 -1
  43. package/node_modules/@comis/core/dist/config/schema-agent-model.test.js +0 -279
  44. package/node_modules/@comis/core/dist/config/schema-agent-session.test.d.ts +0 -1
  45. package/node_modules/@comis/core/dist/config/schema-agent-session.test.js +0 -242
  46. package/node_modules/@comis/core/dist/config/schema-agent.test.d.ts +0 -1
  47. package/node_modules/@comis/core/dist/config/schema-agent.test.js +0 -645
  48. package/node_modules/@comis/core/dist/config/schema-channel.test.d.ts +0 -1
  49. package/node_modules/@comis/core/dist/config/schema-channel.test.js +0 -341
  50. package/node_modules/@comis/core/dist/config/schema-coalescer.test.d.ts +0 -1
  51. package/node_modules/@comis/core/dist/config/schema-coalescer.test.js +0 -51
  52. package/node_modules/@comis/core/dist/config/schema-context-engine.test.d.ts +0 -1
  53. package/node_modules/@comis/core/dist/config/schema-context-engine.test.js +0 -797
  54. package/node_modules/@comis/core/dist/config/schema-context-guard.test.d.ts +0 -1
  55. package/node_modules/@comis/core/dist/config/schema-context-guard.test.js +0 -111
  56. package/node_modules/@comis/core/dist/config/schema-daemon.test.d.ts +0 -1
  57. package/node_modules/@comis/core/dist/config/schema-daemon.test.js +0 -107
  58. package/node_modules/@comis/core/dist/config/schema-delivery-timing.test.d.ts +0 -1
  59. package/node_modules/@comis/core/dist/config/schema-delivery-timing.test.js +0 -51
  60. package/node_modules/@comis/core/dist/config/schema-documentation.test.d.ts +0 -1
  61. package/node_modules/@comis/core/dist/config/schema-documentation.test.js +0 -63
  62. package/node_modules/@comis/core/dist/config/schema-gateway.test.d.ts +0 -1
  63. package/node_modules/@comis/core/dist/config/schema-gateway.test.js +0 -219
  64. package/node_modules/@comis/core/dist/config/schema-gemini-cache.test.d.ts +0 -1
  65. package/node_modules/@comis/core/dist/config/schema-gemini-cache.test.js +0 -41
  66. package/node_modules/@comis/core/dist/config/schema-integrations.test.d.ts +0 -1
  67. package/node_modules/@comis/core/dist/config/schema-integrations.test.js +0 -92
  68. package/node_modules/@comis/core/dist/config/schema-lifecycle-reactions.test.d.ts +0 -1
  69. package/node_modules/@comis/core/dist/config/schema-lifecycle-reactions.test.js +0 -61
  70. package/node_modules/@comis/core/dist/config/schema-misc.test.d.ts +0 -1
  71. package/node_modules/@comis/core/dist/config/schema-misc.test.js +0 -394
  72. package/node_modules/@comis/core/dist/config/schema-observability.test.d.ts +0 -1
  73. package/node_modules/@comis/core/dist/config/schema-observability.test.js +0 -76
  74. package/node_modules/@comis/core/dist/config/schema-providers.test.d.ts +0 -1
  75. package/node_modules/@comis/core/dist/config/schema-providers.test.js +0 -294
  76. package/node_modules/@comis/core/dist/config/schema-queue.test.d.ts +0 -1
  77. package/node_modules/@comis/core/dist/config/schema-queue.test.js +0 -234
  78. package/node_modules/@comis/core/dist/config/schema-response-prefix.test.d.ts +0 -1
  79. package/node_modules/@comis/core/dist/config/schema-response-prefix.test.js +0 -36
  80. package/node_modules/@comis/core/dist/config/schema-security.test.d.ts +0 -1
  81. package/node_modules/@comis/core/dist/config/schema-security.test.js +0 -54
  82. package/node_modules/@comis/core/dist/config/schema-sender-trust-display.test.d.ts +0 -1
  83. package/node_modules/@comis/core/dist/config/schema-sender-trust-display.test.js +0 -60
  84. package/node_modules/@comis/core/dist/config/schema-serializer.test.d.ts +0 -1
  85. package/node_modules/@comis/core/dist/config/schema-serializer.test.js +0 -73
  86. package/node_modules/@comis/core/dist/config/schema-telegram-file-guard.test.d.ts +0 -1
  87. package/node_modules/@comis/core/dist/config/schema-telegram-file-guard.test.js +0 -39
  88. package/node_modules/@comis/core/dist/context/context.test.d.ts +0 -1
  89. package/node_modules/@comis/core/dist/context/context.test.js +0 -276
  90. package/node_modules/@comis/core/dist/domain/agent-response.test.d.ts +0 -1
  91. package/node_modules/@comis/core/dist/domain/agent-response.test.js +0 -159
  92. package/node_modules/@comis/core/dist/domain/credential-mapping.test.d.ts +0 -1
  93. package/node_modules/@comis/core/dist/domain/credential-mapping.test.js +0 -135
  94. package/node_modules/@comis/core/dist/domain/delivery-origin.test.d.ts +0 -1
  95. package/node_modules/@comis/core/dist/domain/delivery-origin.test.js +0 -68
  96. package/node_modules/@comis/core/dist/domain/execution-graph.test.d.ts +0 -1
  97. package/node_modules/@comis/core/dist/domain/execution-graph.test.js +0 -441
  98. package/node_modules/@comis/core/dist/domain/memory-entry.test.d.ts +0 -1
  99. package/node_modules/@comis/core/dist/domain/memory-entry.test.js +0 -193
  100. package/node_modules/@comis/core/dist/domain/normalized-message.test.d.ts +0 -1
  101. package/node_modules/@comis/core/dist/domain/normalized-message.test.js +0 -255
  102. package/node_modules/@comis/core/dist/domain/session-key.test.d.ts +0 -1
  103. package/node_modules/@comis/core/dist/domain/session-key.test.js +0 -291
  104. package/node_modules/@comis/core/dist/domain/subagent-context-config.test.d.ts +0 -1
  105. package/node_modules/@comis/core/dist/domain/subagent-context-config.test.js +0 -131
  106. package/node_modules/@comis/core/dist/domain/subagent-context-types.test.d.ts +0 -1
  107. package/node_modules/@comis/core/dist/domain/subagent-context-types.test.js +0 -182
  108. package/node_modules/@comis/core/dist/event-bus/bus.test.d.ts +0 -1
  109. package/node_modules/@comis/core/dist/event-bus/bus.test.js +0 -152
  110. package/node_modules/@comis/core/dist/event-bus/events-agent.test.d.ts +0 -1
  111. package/node_modules/@comis/core/dist/event-bus/events-agent.test.js +0 -409
  112. package/node_modules/@comis/core/dist/event-bus/events-channel.test.d.ts +0 -1
  113. package/node_modules/@comis/core/dist/event-bus/events-channel.test.js +0 -240
  114. package/node_modules/@comis/core/dist/event-bus/events-infra.test.d.ts +0 -1
  115. package/node_modules/@comis/core/dist/event-bus/events-infra.test.js +0 -416
  116. package/node_modules/@comis/core/dist/event-bus/events-messaging.test.d.ts +0 -1
  117. package/node_modules/@comis/core/dist/event-bus/events-messaging.test.js +0 -433
  118. package/node_modules/@comis/core/dist/event-bus/events.test.d.ts +0 -1
  119. package/node_modules/@comis/core/dist/event-bus/events.test.js +0 -93
  120. package/node_modules/@comis/core/dist/hooks/hook-runner-global.test.d.ts +0 -1
  121. package/node_modules/@comis/core/dist/hooks/hook-runner-global.test.js +0 -29
  122. package/node_modules/@comis/core/dist/hooks/hook-runner.test.d.ts +0 -1
  123. package/node_modules/@comis/core/dist/hooks/hook-runner.test.js +0 -490
  124. package/node_modules/@comis/core/dist/hooks/hook-strategies.test.d.ts +0 -1
  125. package/node_modules/@comis/core/dist/hooks/hook-strategies.test.js +0 -209
  126. package/node_modules/@comis/core/dist/hooks/integration.test.d.ts +0 -1
  127. package/node_modules/@comis/core/dist/hooks/integration.test.js +0 -235
  128. package/node_modules/@comis/core/dist/hooks/plugin-registry.test.d.ts +0 -1
  129. package/node_modules/@comis/core/dist/hooks/plugin-registry.test.js +0 -470
  130. package/node_modules/@comis/core/dist/load-env.test.d.ts +0 -1
  131. package/node_modules/@comis/core/dist/load-env.test.js +0 -21
  132. package/node_modules/@comis/core/dist/security/action-classifier.test.d.ts +0 -1
  133. package/node_modules/@comis/core/dist/security/action-classifier.test.js +0 -298
  134. package/node_modules/@comis/core/dist/security/audit-aggregator.test.d.ts +0 -1
  135. package/node_modules/@comis/core/dist/security/audit-aggregator.test.js +0 -128
  136. package/node_modules/@comis/core/dist/security/audit.test.d.ts +0 -1
  137. package/node_modules/@comis/core/dist/security/audit.test.js +0 -154
  138. package/node_modules/@comis/core/dist/security/canary-token.test.d.ts +0 -1
  139. package/node_modules/@comis/core/dist/security/canary-token.test.js +0 -45
  140. package/node_modules/@comis/core/dist/security/config-redaction.test.d.ts +0 -1
  141. package/node_modules/@comis/core/dist/security/config-redaction.test.js +0 -107
  142. package/node_modules/@comis/core/dist/security/external-content.test.d.ts +0 -1
  143. package/node_modules/@comis/core/dist/security/external-content.test.js +0 -210
  144. package/node_modules/@comis/core/dist/security/injection-patterns.test.d.ts +0 -1
  145. package/node_modules/@comis/core/dist/security/injection-patterns.test.js +0 -213
  146. package/node_modules/@comis/core/dist/security/injection-rate-limiter.test.d.ts +0 -1
  147. package/node_modules/@comis/core/dist/security/injection-rate-limiter.test.js +0 -194
  148. package/node_modules/@comis/core/dist/security/input-guard.test.d.ts +0 -1
  149. package/node_modules/@comis/core/dist/security/input-guard.test.js +0 -215
  150. package/node_modules/@comis/core/dist/security/input-security-guard.test.d.ts +0 -1
  151. package/node_modules/@comis/core/dist/security/input-security-guard.test.js +0 -215
  152. package/node_modules/@comis/core/dist/security/input-validator.test.d.ts +0 -1
  153. package/node_modules/@comis/core/dist/security/input-validator.test.js +0 -133
  154. package/node_modules/@comis/core/dist/security/log-sanitizer.test.d.ts +0 -1
  155. package/node_modules/@comis/core/dist/security/log-sanitizer.test.js +0 -260
  156. package/node_modules/@comis/core/dist/security/memory-write-validator.test.d.ts +0 -1
  157. package/node_modules/@comis/core/dist/security/memory-write-validator.test.js +0 -62
  158. package/node_modules/@comis/core/dist/security/output-guard.test.d.ts +0 -1
  159. package/node_modules/@comis/core/dist/security/output-guard.test.js +0 -397
  160. package/node_modules/@comis/core/dist/security/safe-path.test.d.ts +0 -1
  161. package/node_modules/@comis/core/dist/security/safe-path.test.js +0 -95
  162. package/node_modules/@comis/core/dist/security/scoped-secret-manager.test.d.ts +0 -1
  163. package/node_modules/@comis/core/dist/security/scoped-secret-manager.test.js +0 -231
  164. package/node_modules/@comis/core/dist/security/secret-access.test.d.ts +0 -1
  165. package/node_modules/@comis/core/dist/security/secret-access.test.js +0 -41
  166. package/node_modules/@comis/core/dist/security/secret-crypto.test.d.ts +0 -1
  167. package/node_modules/@comis/core/dist/security/secret-crypto.test.js +0 -113
  168. package/node_modules/@comis/core/dist/security/secret-manager.test.d.ts +0 -1
  169. package/node_modules/@comis/core/dist/security/secret-manager.test.js +0 -394
  170. package/node_modules/@comis/core/dist/security/secret-ref-resolver.test.d.ts +0 -1
  171. package/node_modules/@comis/core/dist/security/secret-ref-resolver.test.js +0 -268
  172. package/node_modules/@comis/core/dist/security/secrets-audit.test.d.ts +0 -10
  173. package/node_modules/@comis/core/dist/security/secrets-audit.test.js +0 -295
  174. package/node_modules/@comis/core/dist/security/ssrf-guard.test.d.ts +0 -1
  175. package/node_modules/@comis/core/dist/security/ssrf-guard.test.js +0 -127
  176. package/node_modules/@comis/core/dist/security/token-generator.test.d.ts +0 -1
  177. package/node_modules/@comis/core/dist/security/token-generator.test.js +0 -35
  178. package/node_modules/@comis/core/dist/tool-metadata.test.d.ts +0 -1
  179. package/node_modules/@comis/core/dist/tool-metadata.test.js +0 -112
  180. package/node_modules/@comis/memory/dist/compaction.test.d.ts +0 -1
  181. package/node_modules/@comis/memory/dist/compaction.test.js +0 -298
  182. package/node_modules/@comis/memory/dist/context-schema.test.d.ts +0 -1
  183. package/node_modules/@comis/memory/dist/context-schema.test.js +0 -246
  184. package/node_modules/@comis/memory/dist/context-store.test.d.ts +0 -1
  185. package/node_modules/@comis/memory/dist/context-store.test.js +0 -708
  186. package/node_modules/@comis/memory/dist/credential-mapping-schema.test.d.ts +0 -7
  187. package/node_modules/@comis/memory/dist/credential-mapping-schema.test.js +0 -73
  188. package/node_modules/@comis/memory/dist/credential-mapping-store.test.d.ts +0 -8
  189. package/node_modules/@comis/memory/dist/credential-mapping-store.test.js +0 -340
  190. package/node_modules/@comis/memory/dist/delivery-mirror-adapter.test.d.ts +0 -1
  191. package/node_modules/@comis/memory/dist/delivery-mirror-adapter.test.js +0 -165
  192. package/node_modules/@comis/memory/dist/delivery-queue-adapter.test.d.ts +0 -1
  193. package/node_modules/@comis/memory/dist/delivery-queue-adapter.test.js +0 -263
  194. package/node_modules/@comis/memory/dist/embedding-batch-indexer.test.d.ts +0 -1
  195. package/node_modules/@comis/memory/dist/embedding-batch-indexer.test.js +0 -202
  196. package/node_modules/@comis/memory/dist/embedding-cache-lru.test.d.ts +0 -1
  197. package/node_modules/@comis/memory/dist/embedding-cache-lru.test.js +0 -241
  198. package/node_modules/@comis/memory/dist/embedding-cache-sqlite.test.d.ts +0 -8
  199. package/node_modules/@comis/memory/dist/embedding-cache-sqlite.test.js +0 -678
  200. package/node_modules/@comis/memory/dist/embedding-cache.test.d.ts +0 -1
  201. package/node_modules/@comis/memory/dist/embedding-cache.test.js +0 -213
  202. package/node_modules/@comis/memory/dist/embedding-fingerprint.test.d.ts +0 -1
  203. package/node_modules/@comis/memory/dist/embedding-fingerprint.test.js +0 -87
  204. package/node_modules/@comis/memory/dist/embedding-hash.test.d.ts +0 -1
  205. package/node_modules/@comis/memory/dist/embedding-hash.test.js +0 -31
  206. package/node_modules/@comis/memory/dist/embedding-integration.test.d.ts +0 -8
  207. package/node_modules/@comis/memory/dist/embedding-integration.test.js +0 -232
  208. package/node_modules/@comis/memory/dist/embedding-provider-factory.test.d.ts +0 -1
  209. package/node_modules/@comis/memory/dist/embedding-provider-factory.test.js +0 -176
  210. package/node_modules/@comis/memory/dist/embedding-provider-local.test.d.ts +0 -8
  211. package/node_modules/@comis/memory/dist/embedding-provider-local.test.js +0 -76
  212. package/node_modules/@comis/memory/dist/embedding-provider-openai.test.d.ts +0 -8
  213. package/node_modules/@comis/memory/dist/embedding-provider-openai.test.js +0 -100
  214. package/node_modules/@comis/memory/dist/embedding-queue.test.d.ts +0 -1
  215. package/node_modules/@comis/memory/dist/embedding-queue.test.js +0 -236
  216. package/node_modules/@comis/memory/dist/hybrid-search.test.d.ts +0 -1
  217. package/node_modules/@comis/memory/dist/hybrid-search.test.js +0 -476
  218. package/node_modules/@comis/memory/dist/identity-link-store.test.d.ts +0 -1
  219. package/node_modules/@comis/memory/dist/identity-link-store.test.js +0 -88
  220. package/node_modules/@comis/memory/dist/memory-api.test.d.ts +0 -1
  221. package/node_modules/@comis/memory/dist/memory-api.test.js +0 -495
  222. package/node_modules/@comis/memory/dist/memory-concurrency.test.d.ts +0 -20
  223. package/node_modules/@comis/memory/dist/memory-concurrency.test.js +0 -222
  224. package/node_modules/@comis/memory/dist/named-graph-store.test.d.ts +0 -1
  225. package/node_modules/@comis/memory/dist/named-graph-store.test.js +0 -227
  226. package/node_modules/@comis/memory/dist/observability-store.test.d.ts +0 -1
  227. package/node_modules/@comis/memory/dist/observability-store.test.js +0 -704
  228. package/node_modules/@comis/memory/dist/row-mapper.test.d.ts +0 -1
  229. package/node_modules/@comis/memory/dist/row-mapper.test.js +0 -409
  230. package/node_modules/@comis/memory/dist/schema.test.d.ts +0 -1
  231. package/node_modules/@comis/memory/dist/schema.test.js +0 -240
  232. package/node_modules/@comis/memory/dist/secret-store-schema.test.d.ts +0 -7
  233. package/node_modules/@comis/memory/dist/secret-store-schema.test.js +0 -90
  234. package/node_modules/@comis/memory/dist/session-store.test.d.ts +0 -1
  235. package/node_modules/@comis/memory/dist/session-store.test.js +0 -262
  236. package/node_modules/@comis/memory/dist/setup-secrets.test.d.ts +0 -1
  237. package/node_modules/@comis/memory/dist/setup-secrets.test.js +0 -140
  238. package/node_modules/@comis/memory/dist/sqlite-memory-adapter.test.d.ts +0 -1
  239. package/node_modules/@comis/memory/dist/sqlite-memory-adapter.test.js +0 -888
  240. package/node_modules/@comis/memory/dist/sqlite-secret-store.test.d.ts +0 -8
  241. package/node_modules/@comis/memory/dist/sqlite-secret-store.test.js +0 -245
  242. package/node_modules/@comis/shared/dist/abort.test.d.ts +0 -1
  243. package/node_modules/@comis/shared/dist/abort.test.js +0 -71
  244. package/node_modules/@comis/shared/dist/result.test.d.ts +0 -1
  245. package/node_modules/@comis/shared/dist/result.test.js +0 -178
  246. package/node_modules/@comis/shared/dist/suppress-error.test.d.ts +0 -1
  247. package/node_modules/@comis/shared/dist/suppress-error.test.js +0 -50
  248. package/node_modules/@comis/shared/dist/timeout.test.d.ts +0 -1
  249. package/node_modules/@comis/shared/dist/timeout.test.js +0 -75
  250. package/node_modules/@comis/shared/dist/ttl-cache.test.d.ts +0 -1
  251. package/node_modules/@comis/shared/dist/ttl-cache.test.js +0 -81
@@ -1,1042 +0,0 @@
1
- import { describe, it, expect, vi } from "vitest";
2
- import { ok, err } from "@comis/shared";
3
- import { createConfigGitManager, encodeCommitMessage, GITIGNORE_CONTENT, parseCommitMessage, } from "./git-manager.js";
4
- function createSha(index) {
5
- return `${"a".repeat(7)}${String(index).padStart(33, "0")}`;
6
- }
7
- /**
8
- * Create mock deps with a simulated in-memory git repo.
9
- *
10
- * Handles git init, add, commit, log, diff, status, rev-parse, cat-file,
11
- * and checkout commands with deterministic responses.
12
- */
13
- function createMockDeps(opts) {
14
- const calls = [];
15
- const writtenFiles = opts?.writtenFiles ?? new Map();
16
- const repo = {
17
- initialized: opts?.preInitialized ?? false,
18
- commits: [],
19
- workingTree: new Map(opts?.initialFiles ?? []),
20
- stagedFiles: new Set(),
21
- dirty: false,
22
- };
23
- // If pre-initialized, create an initial commit
24
- if (opts?.preInitialized) {
25
- repo.commits.push({
26
- sha: createSha(0),
27
- message: "Initial config snapshot",
28
- timestamp: "2026-02-25T10:00:00+00:00",
29
- files: new Map(repo.workingTree),
30
- });
31
- }
32
- const execGit = async (args, cwd) => {
33
- calls.push({ args, cwd });
34
- const command = args[0];
35
- // Check for forced failures
36
- const cmdKey = args.join(" ");
37
- if (opts?.failCommands) {
38
- for (const [pattern, errorMsg] of opts.failCommands) {
39
- if (cmdKey.includes(pattern)) {
40
- return err(errorMsg);
41
- }
42
- }
43
- }
44
- switch (command) {
45
- case "status": {
46
- if (!repo.initialized) {
47
- return err("fatal: not a git repository");
48
- }
49
- if (repo.dirty) {
50
- return ok(" M config.yaml\n");
51
- }
52
- return ok("");
53
- }
54
- case "init": {
55
- repo.initialized = true;
56
- return ok("Initialized empty Git repository");
57
- }
58
- case "add": {
59
- if (!repo.initialized) {
60
- return err("fatal: not a git repository");
61
- }
62
- // Track staged files
63
- for (let i = 1; i < args.length; i++) {
64
- const pattern = args[i];
65
- if (pattern === "--ignore-errors")
66
- continue;
67
- if (pattern.includes("*")) {
68
- // Glob — stage all matching files from working tree
69
- const ext = pattern.replace("*", "");
70
- for (const [name] of repo.workingTree) {
71
- if (name.endsWith(ext)) {
72
- repo.stagedFiles.add(name);
73
- }
74
- }
75
- }
76
- else {
77
- repo.stagedFiles.add(pattern);
78
- }
79
- }
80
- return ok("");
81
- }
82
- case "commit": {
83
- if (!repo.initialized) {
84
- return err("fatal: not a git repository");
85
- }
86
- // Extract message from -m flag
87
- const mIndex = args.indexOf("-m");
88
- const message = mIndex >= 0 ? args[mIndex + 1] : "";
89
- // Check for --allow-empty
90
- const allowEmpty = args.includes("--allow-empty");
91
- if (!allowEmpty && repo.stagedFiles.size === 0 && !repo.dirty) {
92
- return err("nothing to commit, working tree clean");
93
- }
94
- const sha = createSha(repo.commits.length);
95
- const files = new Map(repo.workingTree);
96
- repo.commits.push({
97
- sha,
98
- message,
99
- timestamp: `2026-02-25T1${String(repo.commits.length).padStart(1, "0")}:00:00+00:00`,
100
- files,
101
- });
102
- repo.stagedFiles.clear();
103
- repo.dirty = false;
104
- return ok(`[main ${sha.slice(0, 7)}] ${message.split("\n")[0]}`);
105
- }
106
- case "rev-parse": {
107
- if (!repo.initialized) {
108
- return err("fatal: not a git repository");
109
- }
110
- if (args[1] === "HEAD") {
111
- if (repo.commits.length === 0) {
112
- return err("fatal: ambiguous argument 'HEAD'");
113
- }
114
- return ok(repo.commits[repo.commits.length - 1].sha);
115
- }
116
- if (args[1] === "--verify" && args[2] === "HEAD~1") {
117
- if (repo.commits.length < 2) {
118
- return err("fatal: ambiguous argument 'HEAD~1'");
119
- }
120
- return ok(repo.commits[repo.commits.length - 2].sha);
121
- }
122
- return err(`fatal: bad revision '${args[1]}'`);
123
- }
124
- case "log": {
125
- if (!repo.initialized || repo.commits.length === 0) {
126
- return err("fatal: your current branch 'main' does not have any commits yet");
127
- }
128
- // Parse --max-count
129
- const maxCountArg = args.find((a) => a.startsWith("--max-count="));
130
- const maxCount = maxCountArg
131
- ? parseInt(maxCountArg.split("=")[1], 10)
132
- : repo.commits.length;
133
- // Build log output matching the format %H%n%aI%n%B---END---
134
- const entries = [...repo.commits]
135
- .reverse()
136
- .slice(0, maxCount);
137
- const output = entries
138
- .map((c) => `${c.sha}\n${c.timestamp}\n${c.message}\n`)
139
- .join("---END---");
140
- return ok(output + "---END---");
141
- }
142
- case "diff": {
143
- if (!repo.initialized) {
144
- return err("fatal: not a git repository");
145
- }
146
- // Return a mock unified diff
147
- return ok("--- a/config.yaml\n+++ b/config.yaml\n@@ -1,3 +1,3 @@\n setting: old\n-value: before\n+value: after\n other: same\n");
148
- }
149
- case "cat-file": {
150
- if (!repo.initialized) {
151
- return err("fatal: not a git repository");
152
- }
153
- const targetSha = args[2];
154
- const found = repo.commits.find((c) => c.sha === targetSha);
155
- if (found) {
156
- return ok("commit");
157
- }
158
- return err(`fatal: Not a valid object name ${targetSha}`);
159
- }
160
- case "checkout": {
161
- if (!repo.initialized) {
162
- return err("fatal: not a git repository");
163
- }
164
- // Find the target commit and restore files
165
- const sha = args[1];
166
- const commit = repo.commits.find((c) => c.sha === sha);
167
- if (commit) {
168
- // Restore files from that commit
169
- for (const [name, content] of commit.files) {
170
- repo.workingTree.set(name, content);
171
- }
172
- repo.dirty = true;
173
- return ok("");
174
- }
175
- return err(`error: pathspec '${sha}' did not match any file(s) known to git`);
176
- }
177
- default:
178
- return ok("");
179
- }
180
- };
181
- const deps = {
182
- configDir: "/test/config",
183
- execGit,
184
- writeFile: async (relativePath, content) => {
185
- writtenFiles.set(relativePath, content);
186
- return ok(undefined);
187
- },
188
- };
189
- return { deps, calls, repo, writtenFiles };
190
- }
191
- // ---------------------------------------------------------------------------
192
- // Tests: Task 1 — init, commit, checkDirty, auto-reinit, best-effort
193
- // ---------------------------------------------------------------------------
194
- describe("config/git-manager", () => {
195
- describe("init()", () => {
196
- it("creates .git directory by running git init", async () => {
197
- const { deps, calls, repo } = createMockDeps();
198
- const manager = createConfigGitManager(deps);
199
- const result = await manager.init();
200
- expect(result.ok).toBe(true);
201
- expect(repo.initialized).toBe(true);
202
- // Should have called git status (check existing), git init, git add, git commit
203
- const commands = calls.map((c) => c.args[0]);
204
- expect(commands).toContain("status");
205
- expect(commands).toContain("init");
206
- expect(commands).toContain("commit");
207
- });
208
- it("writes .gitignore with YAML whitelist pattern", async () => {
209
- const { deps, writtenFiles } = createMockDeps();
210
- const manager = createConfigGitManager(deps);
211
- await manager.init();
212
- expect(writtenFiles.has(".gitignore")).toBe(true);
213
- const content = writtenFiles.get(".gitignore");
214
- expect(content).toContain("!*.yaml");
215
- expect(content).toContain("!*.yml");
216
- expect(content).toContain("!.gitignore");
217
- expect(content).toBe(GITIGNORE_CONTENT);
218
- });
219
- it("creates initial commit with allow-empty", async () => {
220
- const { deps, calls } = createMockDeps();
221
- const manager = createConfigGitManager(deps);
222
- await manager.init();
223
- const commitCall = calls.find((c) => c.args[0] === "commit" && c.args.includes("--allow-empty"));
224
- expect(commitCall).toBeDefined();
225
- expect(commitCall.args).toContain("Initial config snapshot");
226
- });
227
- it("returns ok() early if repo already exists", async () => {
228
- const { deps, calls } = createMockDeps({ preInitialized: true });
229
- const manager = createConfigGitManager(deps);
230
- const result = await manager.init();
231
- expect(result.ok).toBe(true);
232
- // Should only call status (which succeeds) — no init needed
233
- const commands = calls.map((c) => c.args[0]);
234
- expect(commands).not.toContain("init");
235
- });
236
- it("is idempotent — second call is a no-op", async () => {
237
- const { deps, calls } = createMockDeps();
238
- const manager = createConfigGitManager(deps);
239
- await manager.init();
240
- const callsAfterFirst = calls.length;
241
- const result = await manager.init();
242
- expect(result.ok).toBe(true);
243
- // Second call should not make any new git calls (cached initialized flag)
244
- expect(calls.length).toBe(callsAfterFirst);
245
- });
246
- it("returns err() on git init failure", async () => {
247
- const failCommands = new Map([["init", "fatal: permission denied"]]);
248
- const { deps } = createMockDeps({ failCommands });
249
- const manager = createConfigGitManager(deps);
250
- const result = await manager.init();
251
- expect(result.ok).toBe(false);
252
- if (!result.ok) {
253
- expect(result.error).toContain("git init failed");
254
- }
255
- });
256
- });
257
- describe("commit()", () => {
258
- it("stages YAML files and creates commit with structured metadata", async () => {
259
- const initialFiles = new Map([["config.yaml", "setting: value"]]);
260
- const { deps, calls, repo } = createMockDeps({
261
- preInitialized: true,
262
- initialFiles,
263
- });
264
- // Mark as dirty so commit succeeds
265
- repo.dirty = true;
266
- const manager = createConfigGitManager(deps);
267
- const result = await manager.commit({
268
- section: "agent",
269
- key: "agent.model",
270
- summary: "Changed model to claude-opus",
271
- });
272
- expect(result.ok).toBe(true);
273
- if (result.ok) {
274
- expect(result.value).toBeTruthy();
275
- expect(result.value.length).toBeGreaterThan(0);
276
- }
277
- // Verify commit message contains structured metadata
278
- const commitCall = calls.find((c) => c.args[0] === "commit" && !c.args.includes("--allow-empty"));
279
- expect(commitCall).toBeDefined();
280
- const message = commitCall.args[commitCall.args.indexOf("-m") + 1];
281
- expect(message).toContain("config: Changed model to claude-opus");
282
- expect(message).toContain("[section] agent");
283
- expect(message).toContain("[key] agent.model");
284
- });
285
- it("returns commit SHA on success", async () => {
286
- const { deps, repo } = createMockDeps({ preInitialized: true });
287
- repo.dirty = true;
288
- const manager = createConfigGitManager(deps);
289
- const result = await manager.commit({
290
- section: "gateway",
291
- summary: "Updated rate limit",
292
- });
293
- expect(result.ok).toBe(true);
294
- if (result.ok) {
295
- // SHA should be non-empty
296
- expect(result.value.length).toBeGreaterThan(0);
297
- expect(result.value).not.toBe("");
298
- }
299
- });
300
- it("triggers lazy init when repo is not initialized", async () => {
301
- const { deps, calls, repo } = createMockDeps();
302
- // After init, mark dirty so commit succeeds
303
- const originalExecGit = deps.execGit;
304
- deps.execGit = async (args, cwd) => {
305
- const result = await originalExecGit(args, cwd);
306
- // After init completes, make the repo dirty for the actual commit
307
- if (args[0] === "commit" && args.includes("--allow-empty")) {
308
- repo.dirty = true;
309
- }
310
- return result;
311
- };
312
- const manager = createConfigGitManager(deps);
313
- const result = await manager.commit({
314
- section: "agent",
315
- summary: "First change",
316
- });
317
- expect(result.ok).toBe(true);
318
- // Should have called init first
319
- const commands = calls.map((c) => c.args[0]);
320
- expect(commands).toContain("init");
321
- });
322
- it("returns ok('') when nothing to commit", async () => {
323
- const { deps } = createMockDeps({ preInitialized: true });
324
- const manager = createConfigGitManager(deps);
325
- const result = await manager.commit({
326
- section: "agent",
327
- summary: "No changes",
328
- });
329
- expect(result.ok).toBe(true);
330
- if (result.ok) {
331
- expect(result.value).toBe("");
332
- }
333
- });
334
- it("omits optional metadata fields when undefined", async () => {
335
- const { deps, calls, repo } = createMockDeps({ preInitialized: true });
336
- repo.dirty = true;
337
- const manager = createConfigGitManager(deps);
338
- await manager.commit({
339
- section: "daemon",
340
- summary: "Updated logging level",
341
- });
342
- const commitCall = calls.find((c) => c.args[0] === "commit" && !c.args.includes("--allow-empty"));
343
- expect(commitCall).toBeDefined();
344
- const message = commitCall.args[commitCall.args.indexOf("-m") + 1];
345
- expect(message).toContain("[section] daemon");
346
- expect(message).not.toContain("[key]");
347
- expect(message).not.toContain("[agent]");
348
- expect(message).not.toContain("[user]");
349
- expect(message).not.toContain("[trace]");
350
- });
351
- it("includes all optional metadata when provided", async () => {
352
- const { deps, calls, repo } = createMockDeps({ preInitialized: true });
353
- repo.dirty = true;
354
- const manager = createConfigGitManager(deps);
355
- await manager.commit({
356
- section: "agent",
357
- key: "agent.model",
358
- agent: "assistant-1",
359
- user: "admin",
360
- traceId: "trace-abc-123",
361
- summary: "Full metadata commit",
362
- });
363
- const commitCall = calls.find((c) => c.args[0] === "commit" && !c.args.includes("--allow-empty"));
364
- const message = commitCall.args[commitCall.args.indexOf("-m") + 1];
365
- expect(message).toContain("[section] agent");
366
- expect(message).toContain("[key] agent.model");
367
- expect(message).toContain("[agent] assistant-1");
368
- expect(message).toContain("[user] admin");
369
- expect(message).toContain("[trace] trace-abc-123");
370
- });
371
- });
372
- describe("checkDirty()", () => {
373
- it("returns true when YAML files have uncommitted changes", async () => {
374
- const { deps, repo } = createMockDeps({ preInitialized: true });
375
- repo.dirty = true;
376
- const manager = createConfigGitManager(deps);
377
- const result = await manager.checkDirty();
378
- expect(result.ok).toBe(true);
379
- if (result.ok) {
380
- expect(result.value).toBe(true);
381
- }
382
- });
383
- it("returns false when working tree is clean", async () => {
384
- const { deps } = createMockDeps({ preInitialized: true });
385
- const manager = createConfigGitManager(deps);
386
- const result = await manager.checkDirty();
387
- expect(result.ok).toBe(true);
388
- if (result.ok) {
389
- expect(result.value).toBe(false);
390
- }
391
- });
392
- it("calls init() first to ensure repo exists", async () => {
393
- const { deps, calls } = createMockDeps();
394
- const manager = createConfigGitManager(deps);
395
- await manager.checkDirty();
396
- // Should have init-related calls before status
397
- const commands = calls.map((c) => c.args[0]);
398
- expect(commands).toContain("init");
399
- });
400
- });
401
- describe("auto-reinitialize", () => {
402
- it("re-initializes when git command returns 'not a git repository'", async () => {
403
- let callCount = 0;
404
- const warnCalls = [];
405
- const writtenFiles = new Map();
406
- const mockLogger = {
407
- debug: vi.fn(),
408
- warn: (...args) => {
409
- warnCalls.push(args);
410
- },
411
- };
412
- // Start initialized so init() succeeds, but then fail on first status
413
- // command from checkDirty with "not a git repository"
414
- let removeDirCalled = false;
415
- const execGit = async (args) => {
416
- callCount++;
417
- // First status check for init — succeed (pretend repo exists)
418
- if (args[0] === "status" && callCount === 1) {
419
- return ok("");
420
- }
421
- // Second status check from checkDirty via execWithReinit — fail
422
- if (args[0] === "status" && callCount === 2) {
423
- return err("fatal: not a git repository (or any of the parent directories): .git");
424
- }
425
- // After reinit: status check in initRepo — fail (since we "removed" .git)
426
- if (args[0] === "status" && callCount > 2 && !removeDirCalled) {
427
- return err("fatal: not a git repository");
428
- }
429
- // Handle init after reinit
430
- if (args[0] === "init") {
431
- return ok("Initialized empty Git repository");
432
- }
433
- // Handle add, commit, etc.
434
- if (args[0] === "add")
435
- return ok("");
436
- if (args[0] === "commit")
437
- return ok("[main abc1234] Initial config snapshot");
438
- // After reinit, status works
439
- if (args[0] === "status") {
440
- return ok("");
441
- }
442
- return ok("");
443
- };
444
- const deps = {
445
- configDir: "/test/config",
446
- execGit,
447
- writeFile: async (rel, content) => {
448
- writtenFiles.set(rel, content);
449
- return ok(undefined);
450
- },
451
- removeDir: async () => {
452
- removeDirCalled = true;
453
- return ok(undefined);
454
- },
455
- logger: mockLogger,
456
- };
457
- const manager = createConfigGitManager(deps);
458
- // First call succeeds (init)
459
- const initResult = await manager.init();
460
- expect(initResult.ok).toBe(true);
461
- // checkDirty triggers the "not a git repository" error and auto-reinit
462
- const dirtyResult = await manager.checkDirty();
463
- // Should have logged warning
464
- expect(warnCalls.length).toBeGreaterThan(0);
465
- const warnObj = warnCalls[0];
466
- expect(warnObj[0].hint).toContain("re-initialized");
467
- });
468
- });
469
- describe("best-effort (never throw)", () => {
470
- it("init() returns err() on git failure, never throws", async () => {
471
- const failCommands = new Map([["init", "fatal: permission denied"]]);
472
- const { deps } = createMockDeps({ failCommands });
473
- const manager = createConfigGitManager(deps);
474
- const result = await manager.init();
475
- expect(result.ok).toBe(false);
476
- // No exception thrown — we got here
477
- });
478
- it("commit() returns err() on git failure, never throws", async () => {
479
- const failCommands = new Map([
480
- ["commit -m", "fatal: unable to create commit"],
481
- ]);
482
- const { deps, repo } = createMockDeps({
483
- preInitialized: true,
484
- failCommands,
485
- });
486
- repo.dirty = true;
487
- const manager = createConfigGitManager(deps);
488
- const result = await manager.commit({
489
- section: "test",
490
- summary: "Should fail",
491
- });
492
- expect(result.ok).toBe(false);
493
- if (!result.ok) {
494
- expect(result.error).toContain("unable to create commit");
495
- }
496
- });
497
- it("checkDirty() returns err() on git failure, never throws", async () => {
498
- const failCommands = new Map([
499
- ["status --porcelain", "fatal: bad config"],
500
- ]);
501
- const { deps } = createMockDeps({
502
- preInitialized: true,
503
- failCommands,
504
- });
505
- const manager = createConfigGitManager(deps);
506
- const result = await manager.checkDirty();
507
- expect(result.ok).toBe(false);
508
- });
509
- });
510
- describe("parseCommitMessage()", () => {
511
- it("extracts summary from 'config: ' prefix", () => {
512
- const body = "config: Updated model\n\n[section] agent";
513
- const meta = parseCommitMessage(body);
514
- expect(meta.summary).toBe("Updated model");
515
- });
516
- it("extracts all metadata tags", () => {
517
- const body = [
518
- "config: Full metadata",
519
- "",
520
- "[section] agent",
521
- "[key] agent.model",
522
- "[agent] assistant-1",
523
- "[user] admin",
524
- "[trace] trace-123",
525
- ].join("\n");
526
- const meta = parseCommitMessage(body);
527
- expect(meta.section).toBe("agent");
528
- expect(meta.key).toBe("agent.model");
529
- expect(meta.agent).toBe("assistant-1");
530
- expect(meta.user).toBe("admin");
531
- expect(meta.traceId).toBe("trace-123");
532
- });
533
- it("uses 'unknown' section when no [section] tag found", () => {
534
- const meta = parseCommitMessage("Initial config snapshot");
535
- expect(meta.section).toBe("unknown");
536
- });
537
- it("handles commit messages without 'config: ' prefix", () => {
538
- const meta = parseCommitMessage("Initial config snapshot");
539
- expect(meta.summary).toBe("Initial config snapshot");
540
- });
541
- });
542
- // -----------------------------------------------------------------------
543
- // Tests: Task 2 — history, diff, rollback
544
- // -----------------------------------------------------------------------
545
- describe("history()", () => {
546
- it("returns up to 10 entries by default, newest first", async () => {
547
- const { deps, repo } = createMockDeps({ preInitialized: true });
548
- // Create several commits
549
- for (let i = 1; i <= 5; i++) {
550
- repo.commits.push({
551
- sha: createSha(i),
552
- message: `config: Change ${i}\n\n[section] agent`,
553
- timestamp: `2026-02-25T1${i}:00:00+00:00`,
554
- files: new Map(),
555
- });
556
- }
557
- const manager = createConfigGitManager(deps);
558
- const result = await manager.history();
559
- expect(result.ok).toBe(true);
560
- if (result.ok) {
561
- // Should include the initial commit + 5 added = 6 total
562
- expect(result.value.length).toBe(6);
563
- // Newest first (reversed in our mock)
564
- expect(result.value[0].metadata.summary).toBe("Change 5");
565
- }
566
- });
567
- it("respects limit option", async () => {
568
- const { deps, repo } = createMockDeps({ preInitialized: true });
569
- for (let i = 1; i <= 10; i++) {
570
- repo.commits.push({
571
- sha: createSha(i),
572
- message: `config: Change ${i}\n\n[section] agent`,
573
- timestamp: `2026-02-25T1${i}:00:00+00:00`,
574
- files: new Map(),
575
- });
576
- }
577
- const manager = createConfigGitManager(deps);
578
- const result = await manager.history({ limit: 5 });
579
- expect(result.ok).toBe(true);
580
- if (result.ok) {
581
- expect(result.value.length).toBe(5);
582
- }
583
- });
584
- it("filters by section when specified", async () => {
585
- const { deps, repo } = createMockDeps({ preInitialized: true });
586
- repo.commits.push({
587
- sha: createSha(1),
588
- message: "config: Agent change\n\n[section] agent",
589
- timestamp: "2026-02-25T11:00:00+00:00",
590
- files: new Map(),
591
- });
592
- repo.commits.push({
593
- sha: createSha(2),
594
- message: "config: Gateway change\n\n[section] gateway",
595
- timestamp: "2026-02-25T12:00:00+00:00",
596
- files: new Map(),
597
- });
598
- repo.commits.push({
599
- sha: createSha(3),
600
- message: "config: Another agent change\n\n[section] agent",
601
- timestamp: "2026-02-25T13:00:00+00:00",
602
- files: new Map(),
603
- });
604
- const manager = createConfigGitManager(deps);
605
- const result = await manager.history({ section: "agent" });
606
- expect(result.ok).toBe(true);
607
- if (result.ok) {
608
- // Only agent entries (not gateway, not initial which has section "unknown")
609
- expect(result.value.every((e) => e.metadata.section === "agent")).toBe(true);
610
- expect(result.value.length).toBe(2);
611
- }
612
- });
613
- it("returns ok([]) on empty repo (no commits besides initial)", async () => {
614
- // Create a mock where log returns "does not have any commits"
615
- const failCommands = new Map([
616
- [
617
- "log",
618
- "fatal: your current branch 'main' does not have any commits yet",
619
- ],
620
- ]);
621
- const { deps } = createMockDeps({
622
- preInitialized: true,
623
- failCommands,
624
- });
625
- const manager = createConfigGitManager(deps);
626
- const result = await manager.history();
627
- expect(result.ok).toBe(true);
628
- if (result.ok) {
629
- expect(result.value).toEqual([]);
630
- }
631
- });
632
- it("parses structured metadata from commit messages", async () => {
633
- const { deps, repo } = createMockDeps({ preInitialized: true });
634
- repo.commits.push({
635
- sha: createSha(1),
636
- message: "config: Updated model\n\n[section] agent\n[key] agent.model\n[agent] assistant-1\n[user] admin\n[trace] trace-abc",
637
- timestamp: "2026-02-25T11:00:00+00:00",
638
- files: new Map(),
639
- });
640
- const manager = createConfigGitManager(deps);
641
- const result = await manager.history();
642
- expect(result.ok).toBe(true);
643
- if (result.ok) {
644
- const entry = result.value[0];
645
- expect(entry.metadata.section).toBe("agent");
646
- expect(entry.metadata.key).toBe("agent.model");
647
- expect(entry.metadata.agent).toBe("assistant-1");
648
- expect(entry.metadata.user).toBe("admin");
649
- expect(entry.metadata.traceId).toBe("trace-abc");
650
- expect(entry.metadata.summary).toBe("Updated model");
651
- }
652
- });
653
- });
654
- describe("diff()", () => {
655
- it("returns unified diff of last commit vs previous when no SHA given", async () => {
656
- const { deps, repo } = createMockDeps({ preInitialized: true });
657
- // Add a second commit so HEAD~1 exists
658
- repo.commits.push({
659
- sha: createSha(1),
660
- message: "config: Second commit",
661
- timestamp: "2026-02-25T11:00:00+00:00",
662
- files: new Map(),
663
- });
664
- const manager = createConfigGitManager(deps);
665
- const result = await manager.diff();
666
- expect(result.ok).toBe(true);
667
- if (result.ok) {
668
- // Mock returns a unified diff format
669
- expect(result.value).toContain("---");
670
- expect(result.value).toContain("+++");
671
- expect(result.value).toContain("@@");
672
- }
673
- });
674
- it("returns diff from specified SHA to HEAD", async () => {
675
- const { deps, repo, calls } = createMockDeps({ preInitialized: true });
676
- repo.commits.push({
677
- sha: createSha(1),
678
- message: "config: Second commit",
679
- timestamp: "2026-02-25T11:00:00+00:00",
680
- files: new Map(),
681
- });
682
- const manager = createConfigGitManager(deps);
683
- const targetSha = createSha(0);
684
- const result = await manager.diff(targetSha);
685
- expect(result.ok).toBe(true);
686
- // Verify diff was called with the specified SHA
687
- const diffCall = calls.find((c) => c.args[0] === "diff" && c.args.some((a) => a.includes(targetSha)));
688
- expect(diffCall).toBeDefined();
689
- });
690
- it("returns ok('') on single-commit repo (no previous)", async () => {
691
- const { deps } = createMockDeps({ preInitialized: true });
692
- const manager = createConfigGitManager(deps);
693
- const result = await manager.diff();
694
- expect(result.ok).toBe(true);
695
- if (result.ok) {
696
- expect(result.value).toBe("");
697
- }
698
- });
699
- it("uses -U3 for 3 context lines", async () => {
700
- const { deps, repo, calls } = createMockDeps({ preInitialized: true });
701
- repo.commits.push({
702
- sha: createSha(1),
703
- message: "config: Second commit",
704
- timestamp: "2026-02-25T11:00:00+00:00",
705
- files: new Map(),
706
- });
707
- const manager = createConfigGitManager(deps);
708
- await manager.diff();
709
- const diffCall = calls.find((c) => c.args[0] === "diff");
710
- expect(diffCall).toBeDefined();
711
- expect(diffCall.args).toContain("-U3");
712
- });
713
- });
714
- describe("rollback()", () => {
715
- it("restores files from target SHA and creates forward commit", async () => {
716
- const { deps, repo, calls } = createMockDeps({ preInitialized: true });
717
- // Add commits with different file states
718
- const filesV1 = new Map([["config.yaml", "version: 1"]]);
719
- repo.commits.push({
720
- sha: createSha(1),
721
- message: "config: Version 1\n\n[section] agent",
722
- timestamp: "2026-02-25T11:00:00+00:00",
723
- files: filesV1,
724
- });
725
- const filesV2 = new Map([["config.yaml", "version: 2"]]);
726
- repo.commits.push({
727
- sha: createSha(2),
728
- message: "config: Version 2\n\n[section] agent",
729
- timestamp: "2026-02-25T12:00:00+00:00",
730
- files: filesV2,
731
- });
732
- // Also set working tree to v2
733
- repo.workingTree.set("config.yaml", "version: 2");
734
- const manager = createConfigGitManager(deps);
735
- const targetSha = createSha(1);
736
- const result = await manager.rollback(targetSha);
737
- expect(result.ok).toBe(true);
738
- if (result.ok) {
739
- expect(result.value).toBeTruthy();
740
- }
741
- // Verify checkout was called with the target SHA
742
- const checkoutCall = calls.find((c) => c.args[0] === "checkout" && c.args[1] === targetSha);
743
- expect(checkoutCall).toBeDefined();
744
- // Verify a forward commit was created (not a reset)
745
- const commitCalls = calls.filter((c) => c.args[0] === "commit" && !c.args.includes("--allow-empty"));
746
- expect(commitCalls.length).toBeGreaterThan(0);
747
- const rollbackCommit = commitCalls[commitCalls.length - 1];
748
- const message = rollbackCommit.args[rollbackCommit.args.indexOf("-m") + 1];
749
- expect(message).toContain("rollback to");
750
- });
751
- it("returns new commit SHA after rollback", async () => {
752
- const { deps, repo } = createMockDeps({ preInitialized: true });
753
- repo.commits.push({
754
- sha: createSha(1),
755
- message: "config: V1\n\n[section] agent",
756
- timestamp: "2026-02-25T11:00:00+00:00",
757
- files: new Map([["config.yaml", "v1"]]),
758
- });
759
- repo.workingTree.set("config.yaml", "v2");
760
- const manager = createConfigGitManager(deps);
761
- const result = await manager.rollback(createSha(1));
762
- expect(result.ok).toBe(true);
763
- if (result.ok) {
764
- // Should return a SHA (new forward commit)
765
- expect(result.value.length).toBeGreaterThan(0);
766
- }
767
- });
768
- it("returns err() with invalid SHA", async () => {
769
- const { deps } = createMockDeps({ preInitialized: true });
770
- const manager = createConfigGitManager(deps);
771
- const result = await manager.rollback("invalid-sha-does-not-exist");
772
- expect(result.ok).toBe(false);
773
- if (!result.ok) {
774
- expect(result.error).toContain("Invalid SHA");
775
- }
776
- });
777
- it("preserves history after rollback (forward commit, not reset)", async () => {
778
- const { deps, repo } = createMockDeps({ preInitialized: true });
779
- const sha1 = createSha(1);
780
- repo.commits.push({
781
- sha: sha1,
782
- message: "config: V1\n\n[section] agent",
783
- timestamp: "2026-02-25T11:00:00+00:00",
784
- files: new Map([["config.yaml", "v1"]]),
785
- });
786
- const sha2 = createSha(2);
787
- repo.commits.push({
788
- sha: sha2,
789
- message: "config: V2\n\n[section] agent",
790
- timestamp: "2026-02-25T12:00:00+00:00",
791
- files: new Map([["config.yaml", "v2"]]),
792
- });
793
- repo.workingTree.set("config.yaml", "v2");
794
- const manager = createConfigGitManager(deps);
795
- await manager.rollback(sha1);
796
- // History should include all commits: initial + v1 + v2 + rollback
797
- const historyResult = await manager.history({ limit: 10 });
798
- expect(historyResult.ok).toBe(true);
799
- if (historyResult.ok) {
800
- // At least 4 commits (initial + v1 + v2 + rollback)
801
- expect(historyResult.value.length).toBeGreaterThanOrEqual(4);
802
- // The rollback commit should contain "rollback" in its summary
803
- const rollbackEntry = historyResult.value.find((e) => e.metadata.summary.includes("rollback"));
804
- expect(rollbackEntry).toBeDefined();
805
- }
806
- });
807
- it("includes [section] * in rollback commit metadata", async () => {
808
- const { deps, repo, calls } = createMockDeps({ preInitialized: true });
809
- repo.commits.push({
810
- sha: createSha(1),
811
- message: "config: V1\n\n[section] agent",
812
- timestamp: "2026-02-25T11:00:00+00:00",
813
- files: new Map([["config.yaml", "v1"]]),
814
- });
815
- repo.workingTree.set("config.yaml", "v2");
816
- const manager = createConfigGitManager(deps);
817
- await manager.rollback(createSha(1));
818
- const commitCalls = calls.filter((c) => c.args[0] === "commit" &&
819
- !c.args.includes("--allow-empty"));
820
- const rollbackCommit = commitCalls[commitCalls.length - 1];
821
- const message = rollbackCommit.args[rollbackCommit.args.indexOf("-m") + 1];
822
- expect(message).toContain("[section] *");
823
- });
824
- });
825
- // -----------------------------------------------------------------------
826
- // Tests: gc and squash (GITGC-01, GITGC-02)
827
- // -----------------------------------------------------------------------
828
- describe("gc()", () => {
829
- it("runs git garbage collection", async () => {
830
- const { deps, calls } = createMockDeps({ preInitialized: true });
831
- const manager = createConfigGitManager(deps);
832
- const result = await manager.gc();
833
- expect(result.ok).toBe(true);
834
- if (result.ok) {
835
- expect(result.value.prunedObjects).toBe(true);
836
- }
837
- // Verify gc was called with --aggressive --prune=now
838
- const gcCall = calls.find((c) => c.args[0] === "gc" &&
839
- c.args.includes("--aggressive") &&
840
- c.args.includes("--prune=now"));
841
- expect(gcCall).toBeDefined();
842
- });
843
- it("returns err on git failure", async () => {
844
- const failCommands = new Map([["gc", "fatal: gc failed"]]);
845
- const { deps } = createMockDeps({ preInitialized: true, failCommands });
846
- const manager = createConfigGitManager(deps);
847
- const result = await manager.gc();
848
- expect(result.ok).toBe(false);
849
- if (!result.ok) {
850
- expect(result.error).toContain("git gc failed");
851
- }
852
- });
853
- });
854
- // -----------------------------------------------------------------------
855
- // Tests: CORE-08 — encodeCommitMessage sanitization
856
- // -----------------------------------------------------------------------
857
- describe("encodeCommitMessage (CORE-08)", () => {
858
- it("strips newlines from metadata fields", () => {
859
- const message = encodeCommitMessage({
860
- section: "agent",
861
- summary: "line1\nline2\rline3",
862
- });
863
- expect(message).not.toContain("\nline2");
864
- expect(message).toContain("line1 line2 line3");
865
- });
866
- it("strips leading [ from metadata fields", () => {
867
- const message = encodeCommitMessage({
868
- section: "[injected] tag",
869
- summary: "normal summary",
870
- });
871
- expect(message).toContain("[section] injected] tag");
872
- expect(message).not.toContain("[section] [injected]");
873
- });
874
- it("round-trips clean metadata correctly", () => {
875
- const original = { section: "gateway", summary: "Updated host", key: "host", agent: "bot-1" };
876
- const encoded = encodeCommitMessage(original);
877
- const decoded = parseCommitMessage(encoded);
878
- expect(decoded.section).toBe("gateway");
879
- expect(decoded.summary).toBe("Updated host");
880
- expect(decoded.key).toBe("host");
881
- expect(decoded.agent).toBe("bot-1");
882
- });
883
- it("sanitizes all optional fields", () => {
884
- const message = encodeCommitMessage({
885
- section: "test",
886
- summary: "test",
887
- key: "line\none",
888
- agent: "[bad]agent",
889
- user: "user\r\ninjected",
890
- traceId: "[[trace",
891
- });
892
- expect(message).not.toMatch(/\[key\] line\n/);
893
- expect(message).toContain("[key] line one");
894
- expect(message).toContain("[agent] bad]agent");
895
- expect(message).toContain("[user] user injected");
896
- expect(message).toContain("[trace] trace");
897
- });
898
- });
899
- describe("squash()", () => {
900
- it("returns squashedCount: 0 when fewer than 2 old commits", async () => {
901
- // Use a custom execGit that returns only 1 commit for log --reverse
902
- let initDone = false;
903
- const execGit = async (args) => {
904
- if (args[0] === "status" && !initDone) {
905
- initDone = true;
906
- return ok("");
907
- }
908
- if (args[0] === "log" && args.includes("--reverse")) {
909
- return ok("abc1234567890000000000000000000000000000 2026-01-01T00:00:00+00:00\n");
910
- }
911
- return ok("");
912
- };
913
- const deps = {
914
- configDir: "/test/config",
915
- execGit,
916
- writeFile: async () => ok(undefined),
917
- };
918
- const manager = createConfigGitManager(deps);
919
- const result = await manager.squash("2099-01-01T00:00:00Z");
920
- expect(result.ok).toBe(true);
921
- if (result.ok) {
922
- expect(result.value.squashedCount).toBe(0);
923
- expect(result.value.newRootSha).toBe("");
924
- }
925
- });
926
- it("squashes old commits and rebases newer ones", async () => {
927
- let initDone = false;
928
- const calls = [];
929
- const execGit = async (args) => {
930
- calls.push({ args });
931
- if (args[0] === "status" && !initDone) {
932
- initDone = true;
933
- return ok("");
934
- }
935
- // Return 5 commits: 3 old (2025), 2 new (2026)
936
- if (args[0] === "log" && args.includes("--reverse")) {
937
- return ok([
938
- "aaa0000000000000000000000000000000000001 2025-01-01T00:00:00+00:00",
939
- "aaa0000000000000000000000000000000000002 2025-06-01T00:00:00+00:00",
940
- "aaa0000000000000000000000000000000000003 2025-12-01T00:00:00+00:00",
941
- "bbb0000000000000000000000000000000000004 2026-02-01T00:00:00+00:00",
942
- "bbb0000000000000000000000000000000000005 2026-02-15T00:00:00+00:00",
943
- ].join("\n"));
944
- }
945
- // rev-parse for tree
946
- if (args[0] === "rev-parse" && args[1]?.includes("^{tree}")) {
947
- return ok("treeSha123456");
948
- }
949
- // commit-tree
950
- if (args[0] === "commit-tree") {
951
- return ok("newRootSha789");
952
- }
953
- // symbolic-ref
954
- if (args[0] === "symbolic-ref") {
955
- return ok("main");
956
- }
957
- // rebase
958
- if (args[0] === "rebase") {
959
- return ok("Successfully rebased");
960
- }
961
- return ok("");
962
- };
963
- const deps = {
964
- configDir: "/test/config",
965
- execGit,
966
- writeFile: async () => ok(undefined),
967
- };
968
- const manager = createConfigGitManager(deps);
969
- // Squash everything older than 2026-01-01
970
- const result = await manager.squash("2026-01-01T00:00:00Z");
971
- expect(result.ok).toBe(true);
972
- if (result.ok) {
973
- expect(result.value.squashedCount).toBe(3);
974
- expect(result.value.newRootSha).toBe("newRootSha789");
975
- }
976
- // Verify rebase was called with correct args
977
- const rebaseCall = calls.find((c) => c.args[0] === "rebase");
978
- expect(rebaseCall).toBeDefined();
979
- expect(rebaseCall.args).toContain("--onto");
980
- expect(rebaseCall.args).toContain("newRootSha789");
981
- });
982
- it("returns err on invalid date", async () => {
983
- const { deps } = createMockDeps({ preInitialized: true });
984
- const manager = createConfigGitManager(deps);
985
- const result = await manager.squash("not-a-date");
986
- expect(result.ok).toBe(false);
987
- if (!result.ok) {
988
- expect(result.error).toContain("Invalid date");
989
- }
990
- });
991
- it("aborts rebase on failure and returns err", async () => {
992
- let initDone = false;
993
- const calls = [];
994
- const execGit = async (args) => {
995
- calls.push({ args });
996
- if (args[0] === "status" && !initDone) {
997
- initDone = true;
998
- return ok("");
999
- }
1000
- if (args[0] === "log" && args.includes("--reverse")) {
1001
- return ok([
1002
- "aaa0000000000000000000000000000000000001 2025-01-01T00:00:00+00:00",
1003
- "aaa0000000000000000000000000000000000002 2025-06-01T00:00:00+00:00",
1004
- "bbb0000000000000000000000000000000000003 2026-02-01T00:00:00+00:00",
1005
- ].join("\n"));
1006
- }
1007
- if (args[0] === "rev-parse" && args[1]?.includes("^{tree}")) {
1008
- return ok("treeSha123456");
1009
- }
1010
- if (args[0] === "commit-tree") {
1011
- return ok("newRootSha789");
1012
- }
1013
- if (args[0] === "symbolic-ref") {
1014
- return ok("main");
1015
- }
1016
- // Rebase fails
1017
- if (args[0] === "rebase" && !args.includes("--abort")) {
1018
- return err("CONFLICT: merge conflict in config.yaml");
1019
- }
1020
- // Rebase --abort succeeds
1021
- if (args[0] === "rebase" && args.includes("--abort")) {
1022
- return ok("Rebase aborted");
1023
- }
1024
- return ok("");
1025
- };
1026
- const deps = {
1027
- configDir: "/test/config",
1028
- execGit,
1029
- writeFile: async () => ok(undefined),
1030
- };
1031
- const manager = createConfigGitManager(deps);
1032
- const result = await manager.squash("2026-01-01T00:00:00Z");
1033
- expect(result.ok).toBe(false);
1034
- if (!result.ok) {
1035
- expect(result.error).toContain("Squash rebase failed");
1036
- }
1037
- // Verify rebase --abort was called
1038
- const abortCall = calls.find((c) => c.args[0] === "rebase" && c.args.includes("--abort"));
1039
- expect(abortCall).toBeDefined();
1040
- });
1041
- });
1042
- });