@bevel-software/platform-core-backend 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.
- package/LICENSE +202 -0
- package/dist/assets.d.ts +5 -0
- package/dist/assets.d.ts.map +1 -0
- package/dist/assets.js +23 -0
- package/dist/assets.js.map +1 -0
- package/dist/core/core-ports.d.ts +83 -0
- package/dist/core/core-ports.d.ts.map +1 -0
- package/dist/core/core-ports.js +12 -0
- package/dist/core/core-ports.js.map +1 -0
- package/dist/core/create-core-server.d.ts +62 -0
- package/dist/core/create-core-server.d.ts.map +1 -0
- package/dist/core/create-core-server.js +204 -0
- package/dist/core/create-core-server.js.map +1 -0
- package/dist/core/create-core-services.d.ts +95 -0
- package/dist/core/create-core-services.d.ts.map +1 -0
- package/dist/core/create-core-services.js +352 -0
- package/dist/core/create-core-services.js.map +1 -0
- package/dist/core-config.d.ts +119 -0
- package/dist/core-config.d.ts.map +1 -0
- package/dist/core-config.js +240 -0
- package/dist/core-config.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/modules/access/access-control.interface.d.ts +348 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -0
- package/dist/modules/access/access-control.interface.js +2 -0
- package/dist/modules/access/access-control.interface.js.map +1 -0
- package/dist/modules/access/access-control.service.d.ts +323 -0
- package/dist/modules/access/access-control.service.d.ts.map +1 -0
- package/dist/modules/access/access-control.service.js +1431 -0
- package/dist/modules/access/access-control.service.js.map +1 -0
- package/dist/modules/access/access-errors.d.ts +34 -0
- package/dist/modules/access/access-errors.d.ts.map +1 -0
- package/dist/modules/access/access-errors.js +40 -0
- package/dist/modules/access/access-errors.js.map +1 -0
- package/dist/modules/access/access-mutation.service.d.ts +176 -0
- package/dist/modules/access/access-mutation.service.d.ts.map +1 -0
- package/dist/modules/access/access-mutation.service.js +307 -0
- package/dist/modules/access/access-mutation.service.js.map +1 -0
- package/dist/modules/access/access-splice.d.ts +98 -0
- package/dist/modules/access/access-splice.d.ts.map +1 -0
- package/dist/modules/access/access-splice.js +346 -0
- package/dist/modules/access/access-splice.js.map +1 -0
- package/dist/modules/access/access.routes.d.ts +10 -0
- package/dist/modules/access/access.routes.d.ts.map +1 -0
- package/dist/modules/access/access.routes.js +757 -0
- package/dist/modules/access/access.routes.js.map +1 -0
- package/dist/modules/access/creator-access.d.ts +113 -0
- package/dist/modules/access/creator-access.d.ts.map +1 -0
- package/dist/modules/access/creator-access.js +212 -0
- package/dist/modules/access/creator-access.js.map +1 -0
- package/dist/modules/access/kb-read-filter.d.ts +41 -0
- package/dist/modules/access/kb-read-filter.d.ts.map +1 -0
- package/dist/modules/access/kb-read-filter.js +60 -0
- package/dist/modules/access/kb-read-filter.js.map +1 -0
- package/dist/modules/access/roles-admin.service.d.ts +240 -0
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -0
- package/dist/modules/access/roles-admin.service.js +630 -0
- package/dist/modules/access/roles-admin.service.js.map +1 -0
- package/dist/modules/access/roles-edit.d.ts +91 -0
- package/dist/modules/access/roles-edit.d.ts.map +1 -0
- package/dist/modules/access/roles-edit.js +232 -0
- package/dist/modules/access/roles-edit.js.map +1 -0
- package/dist/modules/access/roles-yaml-guard.d.ts +56 -0
- package/dist/modules/access/roles-yaml-guard.d.ts.map +1 -0
- package/dist/modules/access/roles-yaml-guard.js +79 -0
- package/dist/modules/access/roles-yaml-guard.js.map +1 -0
- package/dist/modules/admin/admin-access.routes.d.ts +18 -0
- package/dist/modules/admin/admin-access.routes.d.ts.map +1 -0
- package/dist/modules/admin/admin-access.routes.js +26 -0
- package/dist/modules/admin/admin-access.routes.js.map +1 -0
- package/dist/modules/admin/admin-access.service.d.ts +24 -0
- package/dist/modules/admin/admin-access.service.d.ts.map +1 -0
- package/dist/modules/admin/admin-access.service.js +41 -0
- package/dist/modules/admin/admin-access.service.js.map +1 -0
- package/dist/modules/admin/admin.interface.d.ts +11 -0
- package/dist/modules/admin/admin.interface.d.ts.map +1 -0
- package/dist/modules/admin/admin.interface.js +2 -0
- package/dist/modules/admin/admin.interface.js.map +1 -0
- package/dist/modules/auth/account-erasure.service.d.ts +80 -0
- package/dist/modules/auth/account-erasure.service.d.ts.map +1 -0
- package/dist/modules/auth/account-erasure.service.js +91 -0
- package/dist/modules/auth/account-erasure.service.js.map +1 -0
- package/dist/modules/auth/auth.middleware.d.ts +23 -0
- package/dist/modules/auth/auth.middleware.d.ts.map +1 -0
- package/dist/modules/auth/auth.middleware.js +68 -0
- package/dist/modules/auth/auth.middleware.js.map +1 -0
- package/dist/modules/auth/auth.routes.d.ts +25 -0
- package/dist/modules/auth/auth.routes.d.ts.map +1 -0
- package/dist/modules/auth/auth.routes.js +72 -0
- package/dist/modules/auth/auth.routes.js.map +1 -0
- package/dist/modules/auth/auth.service.d.ts +76 -0
- package/dist/modules/auth/auth.service.d.ts.map +1 -0
- package/dist/modules/auth/auth.service.js +168 -0
- package/dist/modules/auth/auth.service.js.map +1 -0
- package/dist/modules/code-mode/code-mode-names.d.ts +16 -0
- package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -0
- package/dist/modules/code-mode/code-mode-names.js +30 -0
- package/dist/modules/code-mode/code-mode-names.js.map +1 -0
- package/dist/modules/code-mode/code-mode.tool.d.ts +13 -0
- package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -0
- package/dist/modules/code-mode/code-mode.tool.js +112 -0
- package/dist/modules/code-mode/code-mode.tool.js.map +1 -0
- package/dist/modules/code-mode/index.d.ts +3 -0
- package/dist/modules/code-mode/index.d.ts.map +1 -0
- package/dist/modules/code-mode/index.js +3 -0
- package/dist/modules/code-mode/index.js.map +1 -0
- package/dist/modules/database/connection.d.ts +6 -0
- package/dist/modules/database/connection.d.ts.map +1 -0
- package/dist/modules/database/connection.js +12 -0
- package/dist/modules/database/connection.js.map +1 -0
- package/dist/modules/database/core-schema.d.ts +2273 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -0
- package/dist/modules/database/core-schema.js +413 -0
- package/dist/modules/database/core-schema.js.map +1 -0
- package/dist/modules/database/migrate.d.ts +10 -0
- package/dist/modules/database/migrate.d.ts.map +1 -0
- package/dist/modules/database/migrate.js +40 -0
- package/dist/modules/database/migrate.js.map +1 -0
- package/dist/modules/database/schema.d.ts +12 -0
- package/dist/modules/database/schema.d.ts.map +1 -0
- package/dist/modules/database/schema.js +12 -0
- package/dist/modules/database/schema.js.map +1 -0
- package/dist/modules/diff/diff-paths.d.ts +2 -0
- package/dist/modules/diff/diff-paths.d.ts.map +1 -0
- package/dist/modules/diff/diff-paths.js +9 -0
- package/dist/modules/diff/diff-paths.js.map +1 -0
- package/dist/modules/diff/diff.config.d.ts +8 -0
- package/dist/modules/diff/diff.config.d.ts.map +1 -0
- package/dist/modules/diff/diff.config.js +13 -0
- package/dist/modules/diff/diff.config.js.map +1 -0
- package/dist/modules/diff/diff.interface.d.ts +73 -0
- package/dist/modules/diff/diff.interface.d.ts.map +1 -0
- package/dist/modules/diff/diff.interface.js +2 -0
- package/dist/modules/diff/diff.interface.js.map +1 -0
- package/dist/modules/diff/diff.routes.d.ts +35 -0
- package/dist/modules/diff/diff.routes.d.ts.map +1 -0
- package/dist/modules/diff/diff.routes.js +228 -0
- package/dist/modules/diff/diff.routes.js.map +1 -0
- package/dist/modules/diff/diff.service.d.ts +51 -0
- package/dist/modules/diff/diff.service.d.ts.map +1 -0
- package/dist/modules/diff/diff.service.js +489 -0
- package/dist/modules/diff/diff.service.js.map +1 -0
- package/dist/modules/diff/line-diff.d.ts +16 -0
- package/dist/modules/diff/line-diff.d.ts.map +1 -0
- package/dist/modules/diff/line-diff.js +67 -0
- package/dist/modules/diff/line-diff.js.map +1 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +27 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -0
- package/dist/modules/mcp/mcp-auth.middleware.js +116 -0
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -0
- package/dist/modules/mcp/mcp-session-store.d.ts +74 -0
- package/dist/modules/mcp/mcp-session-store.d.ts.map +1 -0
- package/dist/modules/mcp/mcp-session-store.js +131 -0
- package/dist/modules/mcp/mcp-session-store.js.map +1 -0
- package/dist/modules/mcp/mcp.routes.d.ts +24 -0
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -0
- package/dist/modules/mcp/mcp.routes.js +229 -0
- package/dist/modules/mcp/mcp.routes.js.map +1 -0
- package/dist/modules/mcp/mcp.service.d.ts +198 -0
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -0
- package/dist/modules/mcp/mcp.service.js +840 -0
- package/dist/modules/mcp/mcp.service.js.map +1 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +76 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js +291 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -0
- package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts +18 -0
- package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts.map +1 -0
- package/dist/modules/mcp/oauth/oauth-consent.routes.js +52 -0
- package/dist/modules/mcp/oauth/oauth-consent.routes.js.map +1 -0
- package/dist/modules/mcp/oauth/oauth-state.d.ts +32 -0
- package/dist/modules/mcp/oauth/oauth-state.d.ts.map +1 -0
- package/dist/modules/mcp/oauth/oauth-state.js +34 -0
- package/dist/modules/mcp/oauth/oauth-state.js.map +1 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts +92 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +728 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -0
- package/dist/modules/secrets-vault/index.d.ts +7 -0
- package/dist/modules/secrets-vault/index.d.ts.map +1 -0
- package/dist/modules/secrets-vault/index.js +6 -0
- package/dist/modules/secrets-vault/index.js.map +1 -0
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +63 -0
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -0
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +269 -0
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -0
- package/dist/modules/secrets-vault/secrets-variable-loader.d.ts +46 -0
- package/dist/modules/secrets-vault/secrets-variable-loader.d.ts.map +1 -0
- package/dist/modules/secrets-vault/secrets-variable-loader.js +87 -0
- package/dist/modules/secrets-vault/secrets-variable-loader.js.map +1 -0
- package/dist/modules/secrets-vault/secrets-vault.contract.d.ts +210 -0
- package/dist/modules/secrets-vault/secrets-vault.contract.d.ts.map +1 -0
- package/dist/modules/secrets-vault/secrets-vault.contract.js +90 -0
- package/dist/modules/secrets-vault/secrets-vault.contract.js.map +1 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +30 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.js +490 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -0
- package/dist/modules/skills/index.d.ts +5 -0
- package/dist/modules/skills/index.d.ts.map +1 -0
- package/dist/modules/skills/index.js +4 -0
- package/dist/modules/skills/index.js.map +1 -0
- package/dist/modules/skills/skills.contract.d.ts +58 -0
- package/dist/modules/skills/skills.contract.d.ts.map +1 -0
- package/dist/modules/skills/skills.contract.js +10 -0
- package/dist/modules/skills/skills.contract.js.map +1 -0
- package/dist/modules/skills/skills.routes.d.ts +15 -0
- package/dist/modules/skills/skills.routes.d.ts.map +1 -0
- package/dist/modules/skills/skills.routes.js +34 -0
- package/dist/modules/skills/skills.routes.js.map +1 -0
- package/dist/modules/skills/skills.service.d.ts +21 -0
- package/dist/modules/skills/skills.service.d.ts.map +1 -0
- package/dist/modules/skills/skills.service.js +202 -0
- package/dist/modules/skills/skills.service.js.map +1 -0
- package/dist/modules/skills/skills.tools.d.ts +15 -0
- package/dist/modules/skills/skills.tools.d.ts.map +1 -0
- package/dist/modules/skills/skills.tools.js +96 -0
- package/dist/modules/skills/skills.tools.js.map +1 -0
- package/dist/modules/tool-auth/external-api-key.errors.d.ts +14 -0
- package/dist/modules/tool-auth/external-api-key.errors.d.ts.map +1 -0
- package/dist/modules/tool-auth/external-api-key.errors.js +23 -0
- package/dist/modules/tool-auth/external-api-key.errors.js.map +1 -0
- package/dist/modules/tool-auth/external-api-key.interface.d.ts +90 -0
- package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -0
- package/dist/modules/tool-auth/external-api-key.interface.js +2 -0
- package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -0
- package/dist/modules/tool-auth/external-api-key.service.d.ts +25 -0
- package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -0
- package/dist/modules/tool-auth/external-api-key.service.js +164 -0
- package/dist/modules/tool-auth/external-api-key.service.js.map +1 -0
- package/dist/modules/tool-auth/internal-token.service.d.ts +62 -0
- package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -0
- package/dist/modules/tool-auth/internal-token.service.js +85 -0
- package/dist/modules/tool-auth/internal-token.service.js.map +1 -0
- package/dist/modules/tool-auth/llm-usage-meter.d.ts +16 -0
- package/dist/modules/tool-auth/llm-usage-meter.d.ts.map +1 -0
- package/dist/modules/tool-auth/llm-usage-meter.js +6 -0
- package/dist/modules/tool-auth/llm-usage-meter.js.map +1 -0
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +101 -0
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -0
- package/dist/modules/tool-auth/tool-auth.middleware.js +166 -0
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -0
- package/dist/modules/tool-helpers/index.d.ts +19 -0
- package/dist/modules/tool-helpers/index.d.ts.map +1 -0
- package/dist/modules/tool-helpers/index.js +18 -0
- package/dist/modules/tool-helpers/index.js.map +1 -0
- package/dist/modules/tool-helpers/tool-context.d.ts +35 -0
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -0
- package/dist/modules/tool-helpers/tool-context.js +66 -0
- package/dist/modules/tool-helpers/tool-context.js.map +1 -0
- package/dist/modules/tool-helpers/tool-def.d.ts +33 -0
- package/dist/modules/tool-helpers/tool-def.d.ts.map +1 -0
- package/dist/modules/tool-helpers/tool-def.js +57 -0
- package/dist/modules/tool-helpers/tool-def.js.map +1 -0
- package/dist/modules/tool-helpers/tool-handler.d.ts +19 -0
- package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -0
- package/dist/modules/tool-helpers/tool-handler.js +84 -0
- package/dist/modules/tool-helpers/tool-handler.js.map +1 -0
- package/dist/modules/tool-helpers/tool.contract.d.ts +64 -0
- package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -0
- package/dist/modules/tool-helpers/tool.contract.js +24 -0
- package/dist/modules/tool-helpers/tool.contract.js.map +1 -0
- package/dist/modules/tool-helpers/validate-token.d.ts +31 -0
- package/dist/modules/tool-helpers/validate-token.d.ts.map +1 -0
- package/dist/modules/tool-helpers/validate-token.js +20 -0
- package/dist/modules/tool-helpers/validate-token.js.map +1 -0
- package/dist/modules/tool-manuals/index.d.ts +5 -0
- package/dist/modules/tool-manuals/index.d.ts.map +1 -0
- package/dist/modules/tool-manuals/index.js +5 -0
- package/dist/modules/tool-manuals/index.js.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +182 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.js +19 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.js.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +24 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.js +102 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +108 -0
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.service.js +710 -0
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +35 -0
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.tools.js +157 -0
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -0
- package/dist/modules/tool-registry/manual.routes.d.ts +6 -0
- package/dist/modules/tool-registry/manual.routes.d.ts.map +1 -0
- package/dist/modules/tool-registry/manual.routes.js +76 -0
- package/dist/modules/tool-registry/manual.routes.js.map +1 -0
- package/dist/modules/tool-registry/tool-registry.d.ts +23 -0
- package/dist/modules/tool-registry/tool-registry.d.ts.map +1 -0
- package/dist/modules/tool-registry/tool-registry.js +46 -0
- package/dist/modules/tool-registry/tool-registry.js.map +1 -0
- package/dist/modules/tool-registry/tool.contract.d.ts +48 -0
- package/dist/modules/tool-registry/tool.contract.d.ts.map +1 -0
- package/dist/modules/tool-registry/tool.contract.js +2 -0
- package/dist/modules/tool-registry/tool.contract.js.map +1 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +14 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.js +437 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -0
- package/dist/modules/workflow/event-bus.d.ts +107 -0
- package/dist/modules/workflow/event-bus.d.ts.map +1 -0
- package/dist/modules/workflow/event-bus.js +224 -0
- package/dist/modules/workflow/event-bus.js.map +1 -0
- package/dist/modules/workflow/events.routes.d.ts +80 -0
- package/dist/modules/workflow/events.routes.d.ts.map +1 -0
- package/dist/modules/workflow/events.routes.js +216 -0
- package/dist/modules/workflow/events.routes.js.map +1 -0
- package/dist/modules/workflow/file-change-notifier.d.ts +38 -0
- package/dist/modules/workflow/file-change-notifier.d.ts.map +1 -0
- package/dist/modules/workflow/file-change-notifier.js +22 -0
- package/dist/modules/workflow/file-change-notifier.js.map +1 -0
- package/dist/modules/workflow/file-lock.service.d.ts +57 -0
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -0
- package/dist/modules/workflow/file-lock.service.js +202 -0
- package/dist/modules/workflow/file-lock.service.js.map +1 -0
- package/dist/modules/workflow/git/branch-name.d.ts +10 -0
- package/dist/modules/workflow/git/branch-name.d.ts.map +1 -0
- package/dist/modules/workflow/git/branch-name.js +76 -0
- package/dist/modules/workflow/git/branch-name.js.map +1 -0
- package/dist/modules/workflow/git/clone-config.d.ts +61 -0
- package/dist/modules/workflow/git/clone-config.d.ts.map +1 -0
- package/dist/modules/workflow/git/clone-config.js +69 -0
- package/dist/modules/workflow/git/clone-config.js.map +1 -0
- package/dist/modules/workflow/git/git-version.d.ts +34 -0
- package/dist/modules/workflow/git/git-version.d.ts.map +1 -0
- package/dist/modules/workflow/git/git-version.js +63 -0
- package/dist/modules/workflow/git/git-version.js.map +1 -0
- package/dist/modules/workflow/git/git.service.d.ts +427 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -0
- package/dist/modules/workflow/git/git.service.js +1987 -0
- package/dist/modules/workflow/git/git.service.js.map +1 -0
- package/dist/modules/workflow/git/mutex.d.ts +11 -0
- package/dist/modules/workflow/git/mutex.d.ts.map +1 -0
- package/dist/modules/workflow/git/mutex.js +23 -0
- package/dist/modules/workflow/git/mutex.js.map +1 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts +124 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -0
- package/dist/modules/workflow/git/pull-request.service.js +328 -0
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -0
- package/dist/modules/workflow/locking-filesystem.d.ts +133 -0
- package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -0
- package/dist/modules/workflow/locking-filesystem.js +400 -0
- package/dist/modules/workflow/locking-filesystem.js.map +1 -0
- package/dist/modules/workflow/pending-commits.service.d.ts +221 -0
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -0
- package/dist/modules/workflow/pending-commits.service.js +364 -0
- package/dist/modules/workflow/pending-commits.service.js.map +1 -0
- package/dist/modules/workflow/pending-commits.worker.d.ts +148 -0
- package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -0
- package/dist/modules/workflow/pending-commits.worker.js +245 -0
- package/dist/modules/workflow/pending-commits.worker.js.map +1 -0
- package/dist/modules/workflow/read-only-filesystem.d.ts +24 -0
- package/dist/modules/workflow/read-only-filesystem.d.ts.map +1 -0
- package/dist/modules/workflow/read-only-filesystem.js +39 -0
- package/dist/modules/workflow/read-only-filesystem.js.map +1 -0
- package/dist/modules/workflow/recovery-bot.d.ts +31 -0
- package/dist/modules/workflow/recovery-bot.d.ts.map +1 -0
- package/dist/modules/workflow/recovery-bot.js +64 -0
- package/dist/modules/workflow/recovery-bot.js.map +1 -0
- package/dist/modules/workflow/review-workflow/review-workflow.interface.d.ts +137 -0
- package/dist/modules/workflow/review-workflow/review-workflow.interface.d.ts.map +1 -0
- package/dist/modules/workflow/review-workflow/review-workflow.interface.js +2 -0
- package/dist/modules/workflow/review-workflow/review-workflow.interface.js.map +1 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts +26 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +636 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -0
- package/dist/modules/workflow/sanitize-error.d.ts +29 -0
- package/dist/modules/workflow/sanitize-error.d.ts.map +1 -0
- package/dist/modules/workflow/sanitize-error.js +60 -0
- package/dist/modules/workflow/sanitize-error.js.map +1 -0
- package/dist/modules/workflow/session-ontology.policy.d.ts +52 -0
- package/dist/modules/workflow/session-ontology.policy.d.ts.map +1 -0
- package/dist/modules/workflow/session-ontology.policy.js +62 -0
- package/dist/modules/workflow/session-ontology.policy.js.map +1 -0
- package/dist/modules/workflow/session-ontology.service.d.ts +100 -0
- package/dist/modules/workflow/session-ontology.service.d.ts.map +1 -0
- package/dist/modules/workflow/session-ontology.service.js +143 -0
- package/dist/modules/workflow/session-ontology.service.js.map +1 -0
- package/dist/modules/workflow/workflow-hooks.d.ts +87 -0
- package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -0
- package/dist/modules/workflow/workflow-hooks.js +35 -0
- package/dist/modules/workflow/workflow-hooks.js.map +1 -0
- package/dist/modules/workflow/workflow.errors.d.ts +171 -0
- package/dist/modules/workflow/workflow.errors.d.ts.map +1 -0
- package/dist/modules/workflow/workflow.errors.js +260 -0
- package/dist/modules/workflow/workflow.errors.js.map +1 -0
- package/dist/modules/workflow/workflow.routes.d.ts +26 -0
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -0
- package/dist/modules/workflow/workflow.routes.js +804 -0
- package/dist/modules/workflow/workflow.routes.js.map +1 -0
- package/dist/modules/workflow/workflow.service.d.ts +385 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -0
- package/dist/modules/workflow/workflow.service.js +1155 -0
- package/dist/modules/workflow/workflow.service.js.map +1 -0
- package/dist/modules/workspace/bevel-ignore.d.ts +25 -0
- package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -0
- package/dist/modules/workspace/bevel-ignore.js +57 -0
- package/dist/modules/workspace/bevel-ignore.js.map +1 -0
- package/dist/modules/workspace/kb-seed.interface.d.ts +36 -0
- package/dist/modules/workspace/kb-seed.interface.d.ts.map +1 -0
- package/dist/modules/workspace/kb-seed.interface.js +2 -0
- package/dist/modules/workspace/kb-seed.interface.js.map +1 -0
- package/dist/modules/workspace/kb-seed.service.d.ts +70 -0
- package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -0
- package/dist/modules/workspace/kb-seed.service.js +325 -0
- package/dist/modules/workspace/kb-seed.service.js.map +1 -0
- package/dist/modules/workspace/routine-write-policy.d.ts +49 -0
- package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -0
- package/dist/modules/workspace/routine-write-policy.js +46 -0
- package/dist/modules/workspace/routine-write-policy.js.map +1 -0
- package/dist/modules/workspace/session-ontology.gate.d.ts +112 -0
- package/dist/modules/workspace/session-ontology.gate.d.ts.map +1 -0
- package/dist/modules/workspace/session-ontology.gate.js +161 -0
- package/dist/modules/workspace/session-ontology.gate.js.map +1 -0
- package/dist/modules/workspace/session-sink.d.ts +25 -0
- package/dist/modules/workspace/session-sink.d.ts.map +1 -0
- package/dist/modules/workspace/session-sink.js +8 -0
- package/dist/modules/workspace/session-sink.js.map +1 -0
- package/dist/modules/workspace/spill-store.d.ts +24 -0
- package/dist/modules/workspace/spill-store.d.ts.map +1 -0
- package/dist/modules/workspace/spill-store.js +87 -0
- package/dist/modules/workspace/spill-store.js.map +1 -0
- package/dist/modules/workspace/workspace.routes.d.ts +25 -0
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -0
- package/dist/modules/workspace/workspace.routes.js +1046 -0
- package/dist/modules/workspace/workspace.routes.js.map +1 -0
- package/dist/modules/workspace/workspace.service.d.ts +339 -0
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -0
- package/dist/modules/workspace/workspace.service.js +1221 -0
- package/dist/modules/workspace/workspace.service.js.map +1 -0
- package/dist/modules/workspace/workspace.tools.d.ts +19 -0
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -0
- package/dist/modules/workspace/workspace.tools.js +767 -0
- package/dist/modules/workspace/workspace.tools.js.map +1 -0
- package/dist/shared/frontmatter-id.d.ts +35 -0
- package/dist/shared/frontmatter-id.d.ts.map +1 -0
- package/dist/shared/frontmatter-id.js +68 -0
- package/dist/shared/frontmatter-id.js.map +1 -0
- package/dist/shared/fs-walk.d.ts +9 -0
- package/dist/shared/fs-walk.d.ts.map +1 -0
- package/dist/shared/fs-walk.js +34 -0
- package/dist/shared/fs-walk.js.map +1 -0
- package/dist/shared/hash-email.d.ts +11 -0
- package/dist/shared/hash-email.d.ts.map +1 -0
- package/dist/shared/hash-email.js +14 -0
- package/dist/shared/hash-email.js.map +1 -0
- package/dist/shared/kb-layout.d.ts +38 -0
- package/dist/shared/kb-layout.d.ts.map +1 -0
- package/dist/shared/kb-layout.js +102 -0
- package/dist/shared/kb-layout.js.map +1 -0
- package/dist/shared/kb-layout.test.d.ts +2 -0
- package/dist/shared/kb-layout.test.d.ts.map +1 -0
- package/dist/shared/kb-layout.test.js +67 -0
- package/dist/shared/kb-layout.test.js.map +1 -0
- package/dist/shared/ssrf.d.ts +20 -0
- package/dist/shared/ssrf.d.ts.map +1 -0
- package/dist/shared/ssrf.js +89 -0
- package/dist/shared/ssrf.js.map +1 -0
- package/dist/shared/token-crypto.d.ts +16 -0
- package/dist/shared/token-crypto.d.ts.map +1 -0
- package/dist/shared/token-crypto.js +49 -0
- package/dist/shared/token-crypto.js.map +1 -0
- package/dist/shared/ttl-cache.d.ts +16 -0
- package/dist/shared/ttl-cache.d.ts.map +1 -0
- package/dist/shared/ttl-cache.js +27 -0
- package/dist/shared/ttl-cache.js.map +1 -0
- package/dist/shared/utcp-namespace.d.ts +46 -0
- package/dist/shared/utcp-namespace.d.ts.map +1 -0
- package/dist/shared/utcp-namespace.js +82 -0
- package/dist/shared/utcp-namespace.js.map +1 -0
- package/dist/version.d.ts +17 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +19 -0
- package/dist/version.js.map +1 -0
- package/kb-template/.bevelignore +21 -0
- package/kb-template/Agents/.gitkeep +0 -0
- package/kb-template/Agents/README.md +38 -0
- package/kb-template/CLAUDE.md +419 -0
- package/kb-template/Data/.gitkeep +0 -0
- package/kb-template/Data/README.md +39 -0
- package/kb-template/KnowledgeBase/.gitkeep +0 -0
- package/kb-template/KnowledgeBase/README.md +37 -0
- package/kb-template/Pipelines/.gitkeep +0 -0
- package/kb-template/Pipelines/README.md +38 -0
- package/kb-template/Skills/.gitkeep +0 -0
- package/kb-template/Skills/README.md +32 -0
- package/kb-template/Tools/.gitkeep +0 -0
- package/kb-template/Tools/README.md +42 -0
- package/kb-template/access.md +36 -0
- package/migrations/0000_core_init.sql +243 -0
- package/migrations/meta/0000_snapshot.json +1480 -0
- package/migrations/meta/_journal.json +13 -0
- package/package.json +80 -0
- package/src/assets.ts +25 -0
- package/src/core/core-ports.ts +91 -0
- package/src/core/create-core-server.ts +338 -0
- package/src/core/create-core-services.ts +501 -0
- package/src/core-config.ts +257 -0
- package/src/index.ts +69 -0
- package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -0
- package/src/modules/access/__tests__/access-control.read-batch.test.ts +132 -0
- package/src/modules/access/__tests__/access-control.service.test.ts +1011 -0
- package/src/modules/access/__tests__/access-mutation.service.test.ts +439 -0
- package/src/modules/access/__tests__/access-splice.test.ts +247 -0
- package/src/modules/access/__tests__/access.routes.revoke.test.ts +407 -0
- package/src/modules/access/__tests__/creator-access.test.ts +217 -0
- package/src/modules/access/__tests__/grant-sources.test.ts +204 -0
- package/src/modules/access/__tests__/kb-read-filter.test.ts +70 -0
- package/src/modules/access/__tests__/roles-admin.service.test.ts +469 -0
- package/src/modules/access/__tests__/roles-edit.test.ts +138 -0
- package/src/modules/access/__tests__/roles-yaml-guard.test.ts +70 -0
- package/src/modules/access/__tests__/roles.routes.test.ts +234 -0
- package/src/modules/access/access-control.interface.ts +383 -0
- package/src/modules/access/access-control.service.ts +1794 -0
- package/src/modules/access/access-errors.ts +60 -0
- package/src/modules/access/access-mutation.service.ts +369 -0
- package/src/modules/access/access-splice.ts +413 -0
- package/src/modules/access/access.routes.ts +894 -0
- package/src/modules/access/creator-access.ts +270 -0
- package/src/modules/access/kb-read-filter.ts +76 -0
- package/src/modules/access/roles-admin.service.ts +741 -0
- package/src/modules/access/roles-edit.ts +248 -0
- package/src/modules/access/roles-yaml-guard.ts +89 -0
- package/src/modules/admin/admin-access.routes.ts +29 -0
- package/src/modules/admin/admin-access.service.ts +38 -0
- package/src/modules/admin/admin.interface.ts +10 -0
- package/src/modules/auth/account-erasure.service.ts +183 -0
- package/src/modules/auth/auth.middleware.ts +78 -0
- package/src/modules/auth/auth.routes.ts +97 -0
- package/src/modules/auth/auth.service.ts +199 -0
- package/src/modules/code-mode/code-mode-names.ts +38 -0
- package/src/modules/code-mode/code-mode.tool.ts +117 -0
- package/src/modules/code-mode/index.ts +6 -0
- package/src/modules/database/connection.ts +15 -0
- package/src/modules/database/core-schema.ts +431 -0
- package/src/modules/database/migrate.ts +43 -0
- package/src/modules/database/schema.ts +11 -0
- package/src/modules/diff/__tests__/diff.routes.read-gate.test.ts +87 -0
- package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -0
- package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +184 -0
- package/src/modules/diff/__tests__/diff.service.test.ts +202 -0
- package/src/modules/diff/diff-paths.ts +9 -0
- package/src/modules/diff/diff.config.ts +12 -0
- package/src/modules/diff/diff.interface.ts +78 -0
- package/src/modules/diff/diff.routes.ts +277 -0
- package/src/modules/diff/diff.service.ts +508 -0
- package/src/modules/diff/line-diff.ts +79 -0
- package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +287 -0
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +249 -0
- package/src/modules/mcp/__tests__/mcp-proxy-helpers.test.ts +253 -0
- package/src/modules/mcp/__tests__/mcp-session-store.test.ts +151 -0
- package/src/modules/mcp/__tests__/mcp.e2e.test.ts +150 -0
- package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +92 -0
- package/src/modules/mcp/__tests__/mcp.service.test.ts +414 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +126 -0
- package/src/modules/mcp/mcp-session-store.ts +162 -0
- package/src/modules/mcp/mcp.routes.ts +263 -0
- package/src/modules/mcp/mcp.service.ts +973 -0
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +401 -0
- package/src/modules/mcp/oauth/oauth-consent.routes.ts +60 -0
- package/src/modules/mcp/oauth/oauth-state.ts +61 -0
- package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +277 -0
- package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +206 -0
- package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +178 -0
- package/src/modules/secrets-vault/__tests__/scopes-covered.test.ts +101 -0
- package/src/modules/secrets-vault/__tests__/secrets-variable-loader.test.ts +54 -0
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -0
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +792 -0
- package/src/modules/secrets-vault/index.ts +25 -0
- package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +289 -0
- package/src/modules/secrets-vault/secrets-variable-loader.ts +104 -0
- package/src/modules/secrets-vault/secrets-vault.contract.ts +270 -0
- package/src/modules/secrets-vault/secrets-vault.routes.ts +526 -0
- package/src/modules/skills/__tests__/skills.service.test.ts +176 -0
- package/src/modules/skills/index.ts +10 -0
- package/src/modules/skills/skills.contract.ts +54 -0
- package/src/modules/skills/skills.routes.ts +38 -0
- package/src/modules/skills/skills.service.ts +245 -0
- package/src/modules/skills/skills.tools.ts +121 -0
- package/src/modules/tool-auth/__tests__/external-api-key.service.test.ts +358 -0
- package/src/modules/tool-auth/__tests__/internal-token.service.test.ts +79 -0
- package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +88 -0
- package/src/modules/tool-auth/external-api-key.errors.ts +25 -0
- package/src/modules/tool-auth/external-api-key.interface.ts +99 -0
- package/src/modules/tool-auth/external-api-key.service.ts +209 -0
- package/src/modules/tool-auth/internal-token.service.ts +124 -0
- package/src/modules/tool-auth/llm-usage-meter.ts +19 -0
- package/src/modules/tool-auth/tool-auth.middleware.ts +220 -0
- package/src/modules/tool-helpers/__tests__/phase0-loopback-spike.test.ts +129 -0
- package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +95 -0
- package/src/modules/tool-helpers/__tests__/tools-surface.test.ts +146 -0
- package/src/modules/tool-helpers/__tests__/validate-token.test.ts +75 -0
- package/src/modules/tool-helpers/index.ts +18 -0
- package/src/modules/tool-helpers/tool-context.ts +98 -0
- package/src/modules/tool-helpers/tool-def.ts +73 -0
- package/src/modules/tool-helpers/tool-handler.ts +91 -0
- package/src/modules/tool-helpers/tool.contract.ts +85 -0
- package/src/modules/tool-helpers/validate-token.ts +50 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +200 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +499 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -0
- package/src/modules/tool-manuals/index.ts +12 -0
- package/src/modules/tool-manuals/tool-manuals.contract.ts +183 -0
- package/src/modules/tool-manuals/tool-manuals.routes.ts +110 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +781 -0
- package/src/modules/tool-manuals/tool-manuals.tools.ts +202 -0
- package/src/modules/tool-registry/__tests__/tool-registry.test.ts +64 -0
- package/src/modules/tool-registry/manual.routes.ts +87 -0
- package/src/modules/tool-registry/tool-registry.ts +55 -0
- package/src/modules/tool-registry/tool.contract.ts +52 -0
- package/src/modules/workflow/__tests__/event-bus.test.ts +322 -0
- package/src/modules/workflow/__tests__/file-change-notifier.test.ts +51 -0
- package/src/modules/workflow/__tests__/format-affected-owners.test.ts +67 -0
- package/src/modules/workflow/__tests__/locking-filesystem.test.ts +445 -0
- package/src/modules/workflow/__tests__/pending-commits.canonical.test.ts +36 -0
- package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +243 -0
- package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +308 -0
- package/src/modules/workflow/__tests__/sanitize-error.test.ts +67 -0
- package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +62 -0
- package/src/modules/workflow/__tests__/session-ontology.service.test.ts +200 -0
- package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +232 -0
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +380 -0
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +340 -0
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +192 -0
- package/src/modules/workflow/agent-tools/workflow.tools.ts +502 -0
- package/src/modules/workflow/event-bus.ts +262 -0
- package/src/modules/workflow/events.routes.ts +266 -0
- package/src/modules/workflow/file-change-notifier.ts +54 -0
- package/src/modules/workflow/file-lock.service.ts +269 -0
- package/src/modules/workflow/git/__tests__/branch-name.test.ts +86 -0
- package/src/modules/workflow/git/__tests__/branchAuthor.test.ts +80 -0
- package/src/modules/workflow/git/__tests__/clone-config.test.ts +15 -0
- package/src/modules/workflow/git/__tests__/git-test-helpers.ts +47 -0
- package/src/modules/workflow/git/__tests__/git-version.failure.test.ts +24 -0
- package/src/modules/workflow/git/__tests__/git-version.test.ts +49 -0
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +389 -0
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +124 -0
- package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +105 -0
- package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +211 -0
- package/src/modules/workflow/git/__tests__/git.service.createBranch.test.ts +154 -0
- package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +320 -0
- package/src/modules/workflow/git/__tests__/git.service.diffFileAtCommit.test.ts +202 -0
- package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +453 -0
- package/src/modules/workflow/git/__tests__/git.service.list-branches.test.ts +198 -0
- package/src/modules/workflow/git/__tests__/git.service.listTrackedFiles.test.ts +125 -0
- package/src/modules/workflow/git/__tests__/git.service.logForFile.test.ts +219 -0
- package/src/modules/workflow/git/__tests__/git.service.mergeChangeRequest.test.ts +145 -0
- package/src/modules/workflow/git/__tests__/git.service.pendingChanges.test.ts +240 -0
- package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +272 -0
- package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +232 -0
- package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +101 -0
- package/src/modules/workflow/git/__tests__/git.service.status.test.ts +127 -0
- package/src/modules/workflow/git/__tests__/mutex.test.ts +78 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +225 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +101 -0
- package/src/modules/workflow/git/branch-name.ts +70 -0
- package/src/modules/workflow/git/clone-config.ts +71 -0
- package/src/modules/workflow/git/git-version.ts +70 -0
- package/src/modules/workflow/git/git.service.ts +2248 -0
- package/src/modules/workflow/git/mutex.ts +22 -0
- package/src/modules/workflow/git/pull-request.service.ts +447 -0
- package/src/modules/workflow/locking-filesystem.ts +489 -0
- package/src/modules/workflow/pending-commits.service.ts +444 -0
- package/src/modules/workflow/pending-commits.worker.ts +361 -0
- package/src/modules/workflow/read-only-filesystem.ts +54 -0
- package/src/modules/workflow/recovery-bot.ts +73 -0
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +560 -0
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +176 -0
- package/src/modules/workflow/review-workflow/__tests__/merge-gate.test.ts +258 -0
- package/src/modules/workflow/review-workflow/review-workflow.interface.ts +199 -0
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +806 -0
- package/src/modules/workflow/sanitize-error.ts +62 -0
- package/src/modules/workflow/session-ontology.policy.ts +70 -0
- package/src/modules/workflow/session-ontology.service.ts +178 -0
- package/src/modules/workflow/workflow-hooks.ts +101 -0
- package/src/modules/workflow/workflow.errors.ts +285 -0
- package/src/modules/workflow/workflow.routes.ts +858 -0
- package/src/modules/workflow/workflow.service.ts +1555 -0
- package/src/modules/workspace/__tests__/kb-seed.service.test.ts +213 -0
- package/src/modules/workspace/__tests__/routine-write-policy.test.ts +68 -0
- package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -0
- package/src/modules/workspace/__tests__/spill-store.test.ts +43 -0
- package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +233 -0
- package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +176 -0
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +303 -0
- package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +276 -0
- package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +120 -0
- package/src/modules/workspace/__tests__/workspace.service.test.ts +565 -0
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +504 -0
- package/src/modules/workspace/bevel-ignore.ts +62 -0
- package/src/modules/workspace/kb-seed.interface.ts +36 -0
- package/src/modules/workspace/kb-seed.service.ts +361 -0
- package/src/modules/workspace/routine-write-policy.ts +79 -0
- package/src/modules/workspace/session-ontology.gate.ts +189 -0
- package/src/modules/workspace/session-sink.ts +25 -0
- package/src/modules/workspace/spill-store.ts +87 -0
- package/src/modules/workspace/workspace.routes.ts +1114 -0
- package/src/modules/workspace/workspace.service.ts +1343 -0
- package/src/modules/workspace/workspace.tools.ts +885 -0
- package/src/shared/__tests__/frontmatter-id.test.ts +88 -0
- package/src/shared/__tests__/fs-walk.test.ts +42 -0
- package/src/shared/__tests__/ssrf.test.ts +53 -0
- package/src/shared/__tests__/utcp-namespace.test.ts +19 -0
- package/src/shared/frontmatter-id.ts +72 -0
- package/src/shared/fs-walk.ts +30 -0
- package/src/shared/hash-email.ts +14 -0
- package/src/shared/kb-layout.test.ts +88 -0
- package/src/shared/kb-layout.ts +101 -0
- package/src/shared/ssrf.ts +78 -0
- package/src/shared/token-crypto.ts +54 -0
- package/src/shared/ttl-cache.ts +27 -0
- package/src/shared/utcp-namespace.ts +88 -0
- package/src/version.ts +19 -0
|
@@ -0,0 +1,1987 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { AccessDeniedError } from '../../access/access-errors.js';
|
|
6
|
+
import { WorkspaceMutex } from './mutex.js';
|
|
7
|
+
import { cloneTrackingConfigArgs, SAFE_IMPLICIT_FETCH_ARGS } from './clone-config.js';
|
|
8
|
+
import { assertValidBranchName, assertValidRelativePath, isBranchAuthoredBy, isProtectedBranch, PROTECTED_BRANCHES, } from './branch-name.js';
|
|
9
|
+
import { BranchAuthorshipError, WorkflowValidationError, ProtectedBranchError, } from '../workflow.errors.js';
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
function redact(msg) {
|
|
12
|
+
const token = process.env.GITHUB_TOKEN;
|
|
13
|
+
return token ? msg.replaceAll(token, '***') : msg;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Fallback committer identity. Every workflow commit overrides the author via
|
|
17
|
+
* `--author=…` so the real human shows up in `git log`; this is just the
|
|
18
|
+
* committer git records need. Mirrors the value `WorkspaceService` stamps on
|
|
19
|
+
* each clone — set again where an operation must not depend on that ambient
|
|
20
|
+
* config (e.g. the merge below).
|
|
21
|
+
*/
|
|
22
|
+
const BOT_NAME = 'Bevel Workflow';
|
|
23
|
+
const BOT_EMAIL = 'bevel-workflow@bevel.software';
|
|
24
|
+
/**
|
|
25
|
+
* Git parses `--author="Name <email>"` by scanning for the last `<` and `>` and
|
|
26
|
+
* taking everything before the `<` as the name. Stray angle brackets, newlines, or
|
|
27
|
+
* carriage returns in either field break that parser or let a caller forge a
|
|
28
|
+
* different author line. Reject rather than try to escape.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Parse the NUL-delimited output of `git status --porcelain=v1 -z` into a
|
|
32
|
+
* deduplicated list of paths the next commit would include. For a rename /
|
|
33
|
+
* copy entry the record spans two NUL-separated fields (new-path, old-path);
|
|
34
|
+
* we keep the new path and discard the old one.
|
|
35
|
+
*/
|
|
36
|
+
export function parsePorcelainZ(stdout) {
|
|
37
|
+
const tokens = stdout.split('\0').filter((t) => t.length > 0);
|
|
38
|
+
const paths = new Set();
|
|
39
|
+
let i = 0;
|
|
40
|
+
while (i < tokens.length) {
|
|
41
|
+
const token = tokens[i];
|
|
42
|
+
// Every entry is "XY <path>" — 2 status chars, a space, then the path.
|
|
43
|
+
if (token.length < 4) {
|
|
44
|
+
i++;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const x = token[0];
|
|
48
|
+
const y = token[1];
|
|
49
|
+
paths.add(token.slice(3));
|
|
50
|
+
// Rename or copy in either the index or worktree position carries an
|
|
51
|
+
// extra NUL-separated old-path field we must skip.
|
|
52
|
+
i += (x === 'R' || x === 'C' || y === 'R' || y === 'C') ? 2 : 1;
|
|
53
|
+
}
|
|
54
|
+
return Array.from(paths);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Default commit subject used when a caller (typically `LockingFilesystem`'s
|
|
58
|
+
* auto-commit on lock release) doesn't supply one. Must stay within the 200-
|
|
59
|
+
* char subject limit enforced just below — deep KB paths can themselves exceed
|
|
60
|
+
* 200 chars, so a naive `Update <path>` would trip the validator and the
|
|
61
|
+
* release would propagate the error back to the agent's tool call. Fall back
|
|
62
|
+
* to the basename, then hard-truncate as a last resort.
|
|
63
|
+
*/
|
|
64
|
+
function buildDefaultCommitSummary(repoRelativePath) {
|
|
65
|
+
const full = `Update ${repoRelativePath}`;
|
|
66
|
+
if (full.length <= 200)
|
|
67
|
+
return full;
|
|
68
|
+
const basename = repoRelativePath.split('/').pop() ?? repoRelativePath;
|
|
69
|
+
const basenameSummary = `Update ${basename}`;
|
|
70
|
+
if (basenameSummary.length <= 200)
|
|
71
|
+
return basenameSummary;
|
|
72
|
+
return basenameSummary.slice(0, 199) + '…';
|
|
73
|
+
}
|
|
74
|
+
function assertValidAuthor(user) {
|
|
75
|
+
if (!user.name || !user.email) {
|
|
76
|
+
throw new WorkflowValidationError('commit author name and email are required');
|
|
77
|
+
}
|
|
78
|
+
if (/[\r\n<>]/.test(user.name)) {
|
|
79
|
+
throw new WorkflowValidationError('commit author name contains invalid characters');
|
|
80
|
+
}
|
|
81
|
+
if (/[\r\n<>\s]/.test(user.email) || !/^[^@\s]+@[^@\s]+$/.test(user.email)) {
|
|
82
|
+
throw new WorkflowValidationError('commit author email is not a valid address');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Cheap binary heuristic. Git's own `diff` treats a file as binary if it sees a
|
|
87
|
+
* NUL byte in the first ~8KB, and the same rule works here: text files don't
|
|
88
|
+
* contain NULs, binary formats almost always do early in the file.
|
|
89
|
+
*/
|
|
90
|
+
function looksBinary(buf) {
|
|
91
|
+
const sample = buf.subarray(0, Math.min(buf.length, 8192));
|
|
92
|
+
return sample.indexOf(0) !== -1;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Matches `git diff`'s output for adding a new binary file — the UI just
|
|
96
|
+
* echoes the patch text, so this header renders as a neutral "can't preview"
|
|
97
|
+
* notice without needing special-case rendering in DiffView.
|
|
98
|
+
*/
|
|
99
|
+
function synthesizeBinaryAddDiff(relativePath) {
|
|
100
|
+
return [
|
|
101
|
+
`diff --git a/${relativePath} b/${relativePath}`,
|
|
102
|
+
'new file mode 100644',
|
|
103
|
+
`Binary files /dev/null and b/${relativePath} differ`,
|
|
104
|
+
'',
|
|
105
|
+
].join('\n');
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Produce a unified diff resembling `git diff --no-index /dev/null <path>` output
|
|
109
|
+
* so the UI can render an untracked file as "all added" using the same DiffView
|
|
110
|
+
* it uses for committed changes. No mode detection — new files get 100644.
|
|
111
|
+
*/
|
|
112
|
+
function synthesizeNewFileDiff(relativePath, contents) {
|
|
113
|
+
const header = [
|
|
114
|
+
`diff --git a/${relativePath} b/${relativePath}`,
|
|
115
|
+
'new file mode 100644',
|
|
116
|
+
'--- /dev/null',
|
|
117
|
+
`+++ b/${relativePath}`,
|
|
118
|
+
].join('\n');
|
|
119
|
+
if (contents === '')
|
|
120
|
+
return `${header}\n`;
|
|
121
|
+
const endsWithNewline = contents.endsWith('\n');
|
|
122
|
+
// Drop the trailing empty string `split` introduces when the file ends with a newline
|
|
123
|
+
// so the line count matches what git itself would report in the hunk header.
|
|
124
|
+
const body = endsWithNewline ? contents.slice(0, -1) : contents;
|
|
125
|
+
const lines = body.split('\n');
|
|
126
|
+
const hunk = `@@ -0,0 +1,${lines.length} @@`;
|
|
127
|
+
const bodyText = lines.map((l) => `+${l}`).join('\n');
|
|
128
|
+
const trailer = endsWithNewline ? '' : '\n\';
|
|
129
|
+
return `${header}\n${hunk}\n${bodyText}${trailer}\n`;
|
|
130
|
+
}
|
|
131
|
+
/** Mirror of {@link synthesizeBinaryAddDiff} for the removed direction. */
|
|
132
|
+
function synthesizeBinaryRemoveDiff(relativePath) {
|
|
133
|
+
return [
|
|
134
|
+
`diff --git a/${relativePath} b/${relativePath}`,
|
|
135
|
+
'deleted file mode 100644',
|
|
136
|
+
`Binary files a/${relativePath} and /dev/null differ`,
|
|
137
|
+
'',
|
|
138
|
+
].join('\n');
|
|
139
|
+
}
|
|
140
|
+
/** Mirror of {@link synthesizeNewFileDiff} for when the working-tree file sits on the "from" side. */
|
|
141
|
+
function synthesizeDeletedFileDiff(relativePath, contents) {
|
|
142
|
+
const header = [
|
|
143
|
+
`diff --git a/${relativePath} b/${relativePath}`,
|
|
144
|
+
'deleted file mode 100644',
|
|
145
|
+
`--- a/${relativePath}`,
|
|
146
|
+
'+++ /dev/null',
|
|
147
|
+
].join('\n');
|
|
148
|
+
if (contents === '')
|
|
149
|
+
return `${header}\n`;
|
|
150
|
+
const endsWithNewline = contents.endsWith('\n');
|
|
151
|
+
const body = endsWithNewline ? contents.slice(0, -1) : contents;
|
|
152
|
+
const lines = body.split('\n');
|
|
153
|
+
const hunk = `@@ -1,${lines.length} +0,0 @@`;
|
|
154
|
+
const bodyText = lines.map((l) => `-${l}`).join('\n');
|
|
155
|
+
const trailer = endsWithNewline ? '' : '\n\';
|
|
156
|
+
return `${header}\n${hunk}\n${bodyText}${trailer}\n`;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Synthesize a diff for an untracked working-tree file when the current branch
|
|
160
|
+
* is one side of a branch-vs-branch comparison. `direction` says which side the
|
|
161
|
+
* working tree sits on: 'added' = working tree is the "to" side (all `+`),
|
|
162
|
+
* 'removed' = working tree is the "from" side (all `-`).
|
|
163
|
+
*/
|
|
164
|
+
async function synthesizeUntrackedSideDiff(cwd, relativePath, direction) {
|
|
165
|
+
try {
|
|
166
|
+
const buf = await fs.readFile(path.join(cwd, relativePath));
|
|
167
|
+
if (looksBinary(buf)) {
|
|
168
|
+
return direction === 'added'
|
|
169
|
+
? synthesizeBinaryAddDiff(relativePath)
|
|
170
|
+
: synthesizeBinaryRemoveDiff(relativePath);
|
|
171
|
+
}
|
|
172
|
+
const text = buf.toString('utf8');
|
|
173
|
+
return direction === 'added'
|
|
174
|
+
? synthesizeNewFileDiff(relativePath, text)
|
|
175
|
+
: synthesizeDeletedFileDiff(relativePath, text);
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
if (err.code === 'ENOENT')
|
|
179
|
+
return '';
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
export class GitService {
|
|
184
|
+
workspaceService;
|
|
185
|
+
hooks;
|
|
186
|
+
kbDirName;
|
|
187
|
+
mutex;
|
|
188
|
+
accessControl;
|
|
189
|
+
// Per-workspace fetch state, kept OUTSIDE the workspace mutex on purpose.
|
|
190
|
+
// `git fetch` only writes `refs/remotes/origin/*` and packs new objects —
|
|
191
|
+
// it doesn't touch HEAD, the index, or the working tree, so it's safe to
|
|
192
|
+
// run alongside checkout/status/commit. Holding the mutex during a network
|
|
193
|
+
// round-trip used to park every other git op behind a slow origin (the
|
|
194
|
+
// worst case for branch-switching UX).
|
|
195
|
+
fetchLocks = new Map();
|
|
196
|
+
lastImplicitFetchAt = new Map();
|
|
197
|
+
static IMPLICIT_FETCH_TTL_MS = 5_000;
|
|
198
|
+
// Cap on concurrent `aheadBehindForBranch` calls inside `listBranches`. Each
|
|
199
|
+
// call serializes up to 3 `git rev-list` spawns, so 8 workers ≈ ≤24
|
|
200
|
+
// concurrent processes worst case — fast for typical (<8) feature-branch
|
|
201
|
+
// counts, calm under large branch lists.
|
|
202
|
+
static MAX_AHEAD_BEHIND_CONCURRENCY = 8;
|
|
203
|
+
constructor(workspaceService,
|
|
204
|
+
/**
|
|
205
|
+
* Workflow lifecycle hooks. This service only consults the ADVISORY
|
|
206
|
+
* `commitValidation` hooks (registered by the enterprise kb module in the
|
|
207
|
+
* composition root — the constructor-injected `IKbValidator` this
|
|
208
|
+
* replaced). No hooks registered = no commit-time validation, which is
|
|
209
|
+
* safe because validation is advisory and never blocks a commit.
|
|
210
|
+
*/
|
|
211
|
+
hooks, kbDirName, mutex = new WorkspaceMutex(), accessControl = null) {
|
|
212
|
+
this.workspaceService = workspaceService;
|
|
213
|
+
this.hooks = hooks;
|
|
214
|
+
this.kbDirName = kbDirName;
|
|
215
|
+
this.mutex = mutex;
|
|
216
|
+
this.accessControl = accessControl;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* "Does the pending-commits queue hold rows for this workspace?" — used by
|
|
220
|
+
* `statusInternal` to tell an expected dirty tree (queued saves still
|
|
221
|
+
* draining) from a genuinely orphaned one. Injected as a narrow probe via
|
|
222
|
+
* setter (not constructor) because the queue service is constructed after
|
|
223
|
+
* this one in the composition root; `null` (e.g. in tests) keeps the old
|
|
224
|
+
* always-warn behavior.
|
|
225
|
+
*/
|
|
226
|
+
pendingCommitsProbe = null;
|
|
227
|
+
setPendingCommitsProbe(probe) {
|
|
228
|
+
this.pendingCommitsProbe = probe;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Mark a workspace as freshly fetched. Called by `WorkspaceService` right
|
|
232
|
+
* after a branch's clone is created — `git clone` already downloaded every
|
|
233
|
+
* ref, so the first `listBranches` can skip the redundant implicit
|
|
234
|
+
* `git fetch` (see `fetchOriginIfStale`).
|
|
235
|
+
*/
|
|
236
|
+
noteWorkspaceFetched(workspaceId) {
|
|
237
|
+
this.lastImplicitFetchAt.set(workspaceId, Date.now());
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Run the registered ADVISORY commit-validation hooks at a commit site.
|
|
241
|
+
* Preserves the semantics of the injected validator this replaced: a
|
|
242
|
+
* `mustFix` report is logged (via `formatWarning`) but never blocks, and a
|
|
243
|
+
* hook that throws is caught and logged — we'd rather land the commit than
|
|
244
|
+
* refuse the save because a validator crashed. Each hook gets its own
|
|
245
|
+
* try/catch so one crashing hook can't silence another.
|
|
246
|
+
*/
|
|
247
|
+
async runCommitValidationHooks(ctx, formatWarning) {
|
|
248
|
+
for (const hook of this.hooks.commitValidationHooks()) {
|
|
249
|
+
try {
|
|
250
|
+
const report = await hook(ctx);
|
|
251
|
+
if (report && report.mustFix.length > 0) {
|
|
252
|
+
console.warn(formatWarning(report));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch (validatorErr) {
|
|
256
|
+
// Validator failure is non-fatal — advisory only.
|
|
257
|
+
console.warn('[git] validator crashed (advisory only, ignoring):', validatorErr instanceof Error ? validatorErr.message : validatorErr);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Resolve write permission for a list of repo-relative paths against the
|
|
263
|
+
* access tree at a specific ref. Throws `AccessDeniedError` for the first
|
|
264
|
+
* path the caller lacks `write` on, with the eligible-roles + users
|
|
265
|
+
* payload attached so the frontend can render a useful refusal message.
|
|
266
|
+
*
|
|
267
|
+
* Always uses an at-ref lookup (never the working tree) so a user can't
|
|
268
|
+
* grant themselves access by editing `roles.yaml` / `access.md` locally.
|
|
269
|
+
* Callers pass:
|
|
270
|
+
* - `HEAD` for commit / revert gates — the pre-operation state is the
|
|
271
|
+
* authoritative source for whether the user can write these paths.
|
|
272
|
+
* - `origin/<branch>` for push gates — the published state is the
|
|
273
|
+
* authoritative source; local rebases or commit-tree manipulation
|
|
274
|
+
* can't slip changes past this check.
|
|
275
|
+
*
|
|
276
|
+
* Bootstrap: if the ref doesn't carry usable config (no `roles.yaml` /
|
|
277
|
+
* malformed / first commit creating the system), `canWriteBatchAtRef`
|
|
278
|
+
* returns null and we treat that as "no rules in force yet" → allow.
|
|
279
|
+
* Once config exists at the ref, gating kicks in.
|
|
280
|
+
*
|
|
281
|
+
* `accessControl` is optional on the constructor so existing tests that
|
|
282
|
+
* don't exercise these paths can keep their stub WorkspaceService — when
|
|
283
|
+
* it's null we skip the check entirely (legacy behaviour, only the
|
|
284
|
+
* branch-name guards remain). Production wires it up via composition root.
|
|
285
|
+
*/
|
|
286
|
+
async assertCanWriteAtRef(workspaceId, ref, userEmail, paths) {
|
|
287
|
+
if (!this.accessControl || paths.length === 0)
|
|
288
|
+
return;
|
|
289
|
+
const result = await this.accessControl.canWriteBatchAtRef(workspaceId, ref, userEmail, paths);
|
|
290
|
+
if (!result)
|
|
291
|
+
return; // bootstrap: no config at ref → default-allow
|
|
292
|
+
for (const p of paths) {
|
|
293
|
+
if (!result.get(p)) {
|
|
294
|
+
const eligible = await this.accessControl.eligibleWritersAtRef(workspaceId, ref, p);
|
|
295
|
+
throw new AccessDeniedError({
|
|
296
|
+
path: p,
|
|
297
|
+
eligibleRoles: eligible?.roles ?? [],
|
|
298
|
+
eligibleUsers: eligible?.users ?? [],
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async status(workspaceId) {
|
|
304
|
+
return this.mutex.run(workspaceId, () => this.statusInternal(workspaceId));
|
|
305
|
+
}
|
|
306
|
+
async listBranches(workspaceId, opts = {}) {
|
|
307
|
+
const cwd = await this.repoDir(workspaceId);
|
|
308
|
+
// Run the fetch BEFORE entering the mutex so a slow origin can't block
|
|
309
|
+
// local-only git ops (checkout/status) on this workspace. The fetch has
|
|
310
|
+
// its own per-workspace lock + TTL, so concurrent listBranches callers
|
|
311
|
+
// share a single in-flight fetch instead of stacking up. `freshFetch`
|
|
312
|
+
// bypasses the TTL — used for user-initiated refreshes (opening the branch
|
|
313
|
+
// selector) so a draft another workspace just deleted is pruned right away
|
|
314
|
+
// instead of lingering up to one TTL window.
|
|
315
|
+
await this.fetchOriginIfStale(cwd, workspaceId, opts.freshFetch);
|
|
316
|
+
return this.mutex.run(workspaceId, async () => {
|
|
317
|
+
// Union local heads with origin's remote-tracking refs: a fresh per-user
|
|
318
|
+
// clone only has one local head (HEAD's default), so without this we'd hide
|
|
319
|
+
// target-company-state and every draft pushed by another user.
|
|
320
|
+
const { stdout } = await this.git(cwd, [
|
|
321
|
+
'for-each-ref',
|
|
322
|
+
'--format=%(refname:short)%09%(refname)',
|
|
323
|
+
'refs/heads',
|
|
324
|
+
'refs/remotes/origin',
|
|
325
|
+
]);
|
|
326
|
+
const buckets = new Map();
|
|
327
|
+
for (const line of stdout.split('\n')) {
|
|
328
|
+
const trimmed = line.trim();
|
|
329
|
+
if (!trimmed)
|
|
330
|
+
continue;
|
|
331
|
+
const tab = trimmed.indexOf('\t');
|
|
332
|
+
if (tab < 0)
|
|
333
|
+
continue;
|
|
334
|
+
const rawShort = trimmed.slice(0, tab);
|
|
335
|
+
const ref = trimmed.slice(tab + 1);
|
|
336
|
+
if (!rawShort || !ref)
|
|
337
|
+
continue;
|
|
338
|
+
// Skip symbolic refs up-front. Different git versions report
|
|
339
|
+
// `refs/remotes/origin/HEAD` as either `origin` (older) or `origin/HEAD`
|
|
340
|
+
// (modern) for `refname:short`, and a literal `HEAD` ref can also
|
|
341
|
+
// appear under `refs/heads/` in pathological repos.
|
|
342
|
+
if (rawShort === 'HEAD' || rawShort === 'origin' || rawShort === 'origin/HEAD')
|
|
343
|
+
continue;
|
|
344
|
+
const isLocal = ref.startsWith('refs/heads/');
|
|
345
|
+
const shortName = isLocal ? rawShort : rawShort.replace(/^origin\//, '');
|
|
346
|
+
if (!shortName)
|
|
347
|
+
continue;
|
|
348
|
+
// Final guard: the strip above turns `origin/HEAD` into `HEAD`. Even
|
|
349
|
+
// if a future git version lands us here, don't bucket it as a branch.
|
|
350
|
+
if (shortName === 'HEAD')
|
|
351
|
+
continue;
|
|
352
|
+
const existing = buckets.get(shortName);
|
|
353
|
+
if (!existing) {
|
|
354
|
+
buckets.set(shortName, {
|
|
355
|
+
shortName,
|
|
356
|
+
ref,
|
|
357
|
+
hasLocal: isLocal,
|
|
358
|
+
hasRemote: !isLocal,
|
|
359
|
+
});
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
// Prefer the local entry's ref when both exist — ahead/behind against the
|
|
363
|
+
// configured upstream is more meaningful than against origin directly.
|
|
364
|
+
if (isLocal && !existing.hasLocal) {
|
|
365
|
+
existing.ref = ref;
|
|
366
|
+
existing.hasLocal = true;
|
|
367
|
+
}
|
|
368
|
+
else if (!isLocal) {
|
|
369
|
+
existing.hasRemote = true;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const rows = Array.from(buckets.values()).sort((a, b) => {
|
|
373
|
+
if (a.hasLocal !== b.hasLocal)
|
|
374
|
+
return a.hasLocal ? -1 : 1;
|
|
375
|
+
return a.shortName.localeCompare(b.shortName);
|
|
376
|
+
});
|
|
377
|
+
// Compute ahead/behind for every branch with bounded concurrency — each
|
|
378
|
+
// branch is 1–3 `git rev-list` spawns, and the unbounded `Promise.all`
|
|
379
|
+
// we used before could put N×3 processes in flight on repos with many
|
|
380
|
+
// branches. Workers pull indices off a shared counter and write into a
|
|
381
|
+
// pre-sized array, so input order (and the sort above) is preserved.
|
|
382
|
+
const infos = new Array(rows.length);
|
|
383
|
+
let nextIndex = 0;
|
|
384
|
+
const workerCount = Math.min(GitService.MAX_AHEAD_BEHIND_CONCURRENCY, rows.length);
|
|
385
|
+
await Promise.all(Array.from({ length: workerCount }, async () => {
|
|
386
|
+
while (true) {
|
|
387
|
+
const i = nextIndex++;
|
|
388
|
+
if (i >= rows.length)
|
|
389
|
+
return;
|
|
390
|
+
const row = rows[i];
|
|
391
|
+
const { ahead, behind } = await this.aheadBehindForBranch(cwd, row.shortName, row.ref, row.hasLocal);
|
|
392
|
+
infos[i] = {
|
|
393
|
+
name: row.shortName,
|
|
394
|
+
isProtected: isProtectedBranch(row.shortName),
|
|
395
|
+
ahead,
|
|
396
|
+
behind,
|
|
397
|
+
hasRemote: row.hasRemote,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
}));
|
|
401
|
+
return infos;
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Run `git fetch --prune origin` outside the workspace mutex, with a short
|
|
406
|
+
* TTL so back-to-back callers (mount + dropdown-open + 30s poll) coalesce
|
|
407
|
+
* onto a single in-flight fetch instead of each waiting their turn behind
|
|
408
|
+
* the network round-trip. Failures (offline, bad auth, no remote) are
|
|
409
|
+
* logged and swallowed so the branch list still serves stale local data
|
|
410
|
+
* instead of returning 500.
|
|
411
|
+
*
|
|
412
|
+
* Safe to keep outside the mutex: fetch only writes remote-tracking refs
|
|
413
|
+
* and the object store, neither of which checkout/status/commit touch.
|
|
414
|
+
*/
|
|
415
|
+
async fetchOriginIfStale(cwd, workspaceId, force = false) {
|
|
416
|
+
const last = this.lastImplicitFetchAt.get(workspaceId) ?? 0;
|
|
417
|
+
if (!force && Date.now() - last < GitService.IMPLICIT_FETCH_TTL_MS)
|
|
418
|
+
return;
|
|
419
|
+
const inFlight = this.fetchLocks.get(workspaceId);
|
|
420
|
+
if (inFlight) {
|
|
421
|
+
// Another caller is already fetching for this workspace — share its
|
|
422
|
+
// result rather than firing a redundant network round-trip. Awaiting
|
|
423
|
+
// the same promise also means subsequent listBranches calls see the
|
|
424
|
+
// newly fetched refs.
|
|
425
|
+
await inFlight;
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const runFetch = async () => {
|
|
429
|
+
try {
|
|
430
|
+
// http.lowSpeedLimit/Time abort the fetch if the transfer stalls below
|
|
431
|
+
// 1KB/s for 10 seconds — prevents a hung origin from parking us
|
|
432
|
+
// forever and starving the TTL refresh.
|
|
433
|
+
await this.git(cwd, [
|
|
434
|
+
'-c', 'http.lowSpeedLimit=1000',
|
|
435
|
+
'-c', 'http.lowSpeedTime=10',
|
|
436
|
+
// This driver runs OUTSIDE the workspace mutex (see the caller), so
|
|
437
|
+
// it may only run the safe implicit-fetch shape — see
|
|
438
|
+
// `SAFE_IMPLICIT_FETCH_ARGS` in `clone-config.ts` for the rationale.
|
|
439
|
+
...SAFE_IMPLICIT_FETCH_ARGS,
|
|
440
|
+
]);
|
|
441
|
+
this.lastImplicitFetchAt.set(workspaceId, Date.now());
|
|
442
|
+
}
|
|
443
|
+
catch (err) {
|
|
444
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
445
|
+
console.warn(`[git] implicit fetch failed for workspace ${workspaceId}: ${msg}`);
|
|
446
|
+
// Stamp the TTL on failure too so a hard-down origin doesn't make
|
|
447
|
+
// every subsequent listBranches retry the (still-failing) fetch and
|
|
448
|
+
// pile up 10s timeouts. Stale local refs are better than spinning.
|
|
449
|
+
this.lastImplicitFetchAt.set(workspaceId, Date.now());
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
const p = runFetch();
|
|
453
|
+
this.fetchLocks.set(workspaceId, p);
|
|
454
|
+
try {
|
|
455
|
+
await p;
|
|
456
|
+
}
|
|
457
|
+
finally {
|
|
458
|
+
// Drop the lock once the fetch settles so the next caller past the TTL
|
|
459
|
+
// can spawn a fresh one. Compare-and-delete in case a future race
|
|
460
|
+
// somehow swapped a different promise in.
|
|
461
|
+
if (this.fetchLocks.get(workspaceId) === p) {
|
|
462
|
+
this.fetchLocks.delete(workspaceId);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
async createBranch(workspaceId, name, fromBase) {
|
|
467
|
+
assertValidBranchName(name);
|
|
468
|
+
if (isProtectedBranch(name)) {
|
|
469
|
+
throw new ProtectedBranchError(name, 'creating a protected branch');
|
|
470
|
+
}
|
|
471
|
+
if (fromBase)
|
|
472
|
+
assertValidBranchName(fromBase);
|
|
473
|
+
return this.mutex.run(workspaceId, async () => {
|
|
474
|
+
const cwd = await this.repoDir(workspaceId);
|
|
475
|
+
// With no fromBase, git branches from HEAD — i.e. whichever branch the
|
|
476
|
+
// user has currently checked out.
|
|
477
|
+
const args = fromBase ? ['branch', name, fromBase] : ['branch', name];
|
|
478
|
+
await this.git(cwd, args);
|
|
479
|
+
// Publish to origin immediately. The per-branch workspace bootstrap
|
|
480
|
+
// (`WorkspaceService.getOrCreateForBranch`) shells out to
|
|
481
|
+
// `git clone -b <name>` against origin — without this push the next
|
|
482
|
+
// navigation to the new draft's URL fails with
|
|
483
|
+
// "Remote branch <name> not found in upstream origin". Roll the local
|
|
484
|
+
// ref back on failure so a retry isn't blocked by "branch already exists".
|
|
485
|
+
try {
|
|
486
|
+
await this.git(cwd, ['push', '-u', 'origin', name]);
|
|
487
|
+
}
|
|
488
|
+
catch (err) {
|
|
489
|
+
await this.git(cwd, ['branch', '-D', name]).catch(() => undefined);
|
|
490
|
+
throw err;
|
|
491
|
+
}
|
|
492
|
+
const { ahead, behind } = await this.aheadBehindForBranch(cwd, name, `refs/heads/${name}`, true);
|
|
493
|
+
// Drop the now-vestigial local head. Under the per-branch workspace model
|
|
494
|
+
// this clone's identity is its OWN branch; the new draft lives in its own
|
|
495
|
+
// workspace (cloned from origin on first navigation), so this clone never
|
|
496
|
+
// checks `name` out again. Leaving a local `refs/heads/<name>` here means
|
|
497
|
+
// that when the draft is later deleted from a DIFFERENT workspace (which
|
|
498
|
+
// removes it from origin and from that clone), this clone's local head
|
|
499
|
+
// survives `fetch --prune` — `prune` only drops remote-tracking refs — and
|
|
500
|
+
// the draft resurfaces in the picker as a phantom orphan that "won't
|
|
501
|
+
// delete". Removing it now keeps the draft as a pure remote-tracking ref,
|
|
502
|
+
// which prune cleans up like any other. The push already published it to
|
|
503
|
+
// origin, so the BranchInfo below still reports `hasRemote: true`.
|
|
504
|
+
await this.git(cwd, ['branch', '-D', name]).catch(() => undefined);
|
|
505
|
+
// `isCurrent` removed from BranchInfo with the per-branch workspace
|
|
506
|
+
// migration — the workspace's identity IS the current branch, so
|
|
507
|
+
// consumers derive it from the workspaceId. No need to call
|
|
508
|
+
// `currentBranch(cwd)` here anymore either.
|
|
509
|
+
return {
|
|
510
|
+
name,
|
|
511
|
+
isProtected: false,
|
|
512
|
+
ahead,
|
|
513
|
+
behind,
|
|
514
|
+
hasRemote: true,
|
|
515
|
+
};
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
// `switchBranch` removed. Under the per-branch workspace model
|
|
519
|
+
// (`workspaces/<encodeURIComponent(branch)>/`) the active branch IS the
|
|
520
|
+
// workspace's identity — there is no "check out a different branch on this
|
|
521
|
+
// workspace's clone" operation. Switching branches in the UI navigates to
|
|
522
|
+
// a different workspace; the destination workspace's clone is already on
|
|
523
|
+
// its branch by construction (`WorkspaceService.getOrCreateForBranch`).
|
|
524
|
+
//
|
|
525
|
+
// Two consequences:
|
|
526
|
+
// - The legacy `DirtyWorkingTreeError` gate this method used to throw
|
|
527
|
+
// can no longer surface as a user-facing 409.
|
|
528
|
+
// - The diff-backup reseed that used to fire here is unnecessary: a
|
|
529
|
+
// fresh per-branch workspace gets its backup seeded by the workspace
|
|
530
|
+
// bootstrap path, not by a branch switch on an existing one.
|
|
531
|
+
async deleteBranch(workspaceId, name, user, opts = {}) {
|
|
532
|
+
assertValidBranchName(name);
|
|
533
|
+
if (isProtectedBranch(name)) {
|
|
534
|
+
throw new ProtectedBranchError(name, 'deleting a protected branch');
|
|
535
|
+
}
|
|
536
|
+
return this.mutex.run(workspaceId, async () => {
|
|
537
|
+
const cwd = await this.repoDir(workspaceId);
|
|
538
|
+
const current = await this.currentBranch(cwd);
|
|
539
|
+
if (current === name) {
|
|
540
|
+
throw new WorkflowValidationError(`Cannot delete "${name}" while it's the checked-out branch.`);
|
|
541
|
+
}
|
|
542
|
+
if (opts.onlyIfNoRemote) {
|
|
543
|
+
// Legacy orphan-cleanup path (PR merged + remote head pruned). Skip
|
|
544
|
+
// the authorship check — callers prune any orphan they encounter,
|
|
545
|
+
// not just their own — but keep the "refuse if origin still has the
|
|
546
|
+
// ref" safety: a present remote means the branch is still live.
|
|
547
|
+
if (await this.refExists(cwd, `refs/remotes/origin/${name}`)) {
|
|
548
|
+
throw new WorkflowValidationError(`Branch "${name}" still exists on origin — refusing to auto-prune.`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
// Authored-or-admin delete path. The branch's author (per the
|
|
553
|
+
// `<email-localpart>/...` naming convention — see `isBranchAuthoredBy`)
|
|
554
|
+
// can always delete their own draft. Admins (per the workspace's
|
|
555
|
+
// `roles.yaml`) can additionally clean up anyone's branch, including
|
|
556
|
+
// unprefixed CLI branches like `fix/...` that have no recognisable
|
|
557
|
+
// author. We detect admin via `canWrite(_, _, 'roles.yaml')` because
|
|
558
|
+
// that predicate is the existing source of truth for `Admin`-role
|
|
559
|
+
// membership inside the access model — no separate `isAdmin()` plumbing
|
|
560
|
+
// needed.
|
|
561
|
+
const isAuthor = isBranchAuthoredBy(name, user.email);
|
|
562
|
+
const isAdmin = this.accessControl
|
|
563
|
+
? await this.accessControl.canWrite(workspaceId, user.email, 'roles.yaml')
|
|
564
|
+
: false;
|
|
565
|
+
if (!isAuthor && !isAdmin) {
|
|
566
|
+
throw new BranchAuthorshipError(name);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
// Local delete. `-D` force-deletes even if the branch isn't "fully
|
|
570
|
+
// merged" from git's POV — squash-merged PRs leave a local branch whose
|
|
571
|
+
// commits don't appear on origin/<base> verbatim, but the changes are
|
|
572
|
+
// in fact merged. Force is the right default for our workflow.
|
|
573
|
+
//
|
|
574
|
+
// Skip when no local ref exists — the picker can surface branches that
|
|
575
|
+
// exist only as `refs/remotes/origin/<name>` (the user hasn't worked on
|
|
576
|
+
// them locally), and "discard from origin" must still work for those.
|
|
577
|
+
if (await this.refExists(cwd, `refs/heads/${name}`)) {
|
|
578
|
+
await this.git(cwd, ['branch', '-D', name]);
|
|
579
|
+
}
|
|
580
|
+
// Remote delete. Skip in `onlyIfNoRemote` mode (the contract is
|
|
581
|
+
// local-only cleanup) and skip when there's no remote ref to delete.
|
|
582
|
+
// A push failure here surfaces to the caller — the local ref is
|
|
583
|
+
// already gone, and we don't try to resurrect it because rolling back
|
|
584
|
+
// a "merged-into-the-deleted-branch" local commit would be even more
|
|
585
|
+
// confusing than the half-finished state.
|
|
586
|
+
if (!opts.onlyIfNoRemote
|
|
587
|
+
&& await this.refExists(cwd, `refs/remotes/origin/${name}`)) {
|
|
588
|
+
await this.git(cwd, ['push', 'origin', '--delete', name]);
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* True iff the given ref (e.g. `refs/heads/foo`, `refs/remotes/origin/foo`)
|
|
594
|
+
* exists in this workspace's clone. Wraps `git show-ref --verify --quiet`
|
|
595
|
+
* — exits 0 when the ref is present, 1 with no stderr when it's missing.
|
|
596
|
+
* Any other exit code / stderr means a real failure and is rethrown so
|
|
597
|
+
* "ref not found" can't silently mask filesystem / repo corruption.
|
|
598
|
+
*
|
|
599
|
+
* `this.git()` wraps execFile failures and stores the original exit code on
|
|
600
|
+
* `wrapped.exitCode` (not `.code`) — check the wrapped property name, or
|
|
601
|
+
* the "ref not found" path silently becomes "rethrow every show-ref error".
|
|
602
|
+
*/
|
|
603
|
+
async refExists(cwd, ref) {
|
|
604
|
+
try {
|
|
605
|
+
await this.git(cwd, ['show-ref', '--verify', '--quiet', ref]);
|
|
606
|
+
return true;
|
|
607
|
+
}
|
|
608
|
+
catch (err) {
|
|
609
|
+
const exitCode = err?.exitCode;
|
|
610
|
+
const stderr = err?.stderr ?? '';
|
|
611
|
+
const refNotFound = exitCode === 1 && stderr.trim() === '';
|
|
612
|
+
if (refNotFound)
|
|
613
|
+
return false;
|
|
614
|
+
throw err;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// `forkCurrentToDraft` removed: under the per-branch workspace model, every
|
|
618
|
+
// branch IS its own workspace by construction. The "stuck on protected
|
|
619
|
+
// branch with dirty edits" escape hatch this method implemented can no
|
|
620
|
+
// longer occur — save=share auto-commits each write, and the user can
|
|
621
|
+
// never end up with uncommitted work that needs to be carried onto a new
|
|
622
|
+
// draft. Creating a draft is just `createBranch` + URL navigation to the
|
|
623
|
+
// new branch's workspace.
|
|
624
|
+
// `discardChanges` removed: under save=share, the working tree is never
|
|
625
|
+
// dirty by design — every write goes through `LockingFilesystem`, which
|
|
626
|
+
// auto-commits on lock release. There's nothing to discard. The method
|
|
627
|
+
// was only meaningful when users could accumulate uncommitted edits
|
|
628
|
+
// between explicit save actions.
|
|
629
|
+
async commit(workspaceId, user, req) {
|
|
630
|
+
const subject = req.summary?.trim();
|
|
631
|
+
if (!subject)
|
|
632
|
+
throw new WorkflowValidationError('commit summary is required');
|
|
633
|
+
if (subject.length > 200) {
|
|
634
|
+
throw new WorkflowValidationError('commit summary must be ≤ 200 characters');
|
|
635
|
+
}
|
|
636
|
+
if (subject.includes('\n')) {
|
|
637
|
+
throw new WorkflowValidationError('commit summary must be a single line');
|
|
638
|
+
}
|
|
639
|
+
assertValidAuthor(user);
|
|
640
|
+
return this.mutex.run(workspaceId, async () => {
|
|
641
|
+
const cwd = await this.repoDir(workspaceId);
|
|
642
|
+
const branch = await this.currentBranch(cwd);
|
|
643
|
+
// One-change-per-file invariant from PLAN.md: each commit must touch
|
|
644
|
+
// exactly one path. Empty folders are represented via `.gitkeep`, so
|
|
645
|
+
// "creating a folder" is also one file. We compute the touched set
|
|
646
|
+
// unconditionally (the protected-branch gate below reuses it) and
|
|
647
|
+
// refuse anything other than exactly one path before any access
|
|
648
|
+
// check runs — keeps the error message stable across permission
|
|
649
|
+
// outcomes.
|
|
650
|
+
const { stdout: porcelain } = await this.git(cwd, [
|
|
651
|
+
'status', '--porcelain=v1', '-z',
|
|
652
|
+
]);
|
|
653
|
+
const touched = parsePorcelainZ(porcelain);
|
|
654
|
+
if (touched.length === 0) {
|
|
655
|
+
throw new WorkflowValidationError('No pending changes to commit', {
|
|
656
|
+
kind: 'no-pending-changes',
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
if (touched.length > 1) {
|
|
660
|
+
throw new WorkflowValidationError(`Each change must affect exactly one file. ${touched.length} files are currently pending: ${touched.join(', ')}. Commit them one at a time.`, { kind: 'too-many-files-dirty', paths: touched });
|
|
661
|
+
}
|
|
662
|
+
// Gate by access-control rules — but ONLY on protected branches.
|
|
663
|
+
// Feature/draft branches are free-for-all: anyone can commit anything
|
|
664
|
+
// because nothing canonical changes until a CR merges, and the CR's
|
|
665
|
+
// approval gate (`origin/<base>` access tree) is the real security
|
|
666
|
+
// boundary. Without this guard, the standard "fork to draft then
|
|
667
|
+
// propose" workflow would 403 for non-admins — they can't write
|
|
668
|
+
// protected, can't write the draft they forked from it either.
|
|
669
|
+
if (isProtectedBranch(branch)) {
|
|
670
|
+
// Gate against the access tree as it exists at HEAD — the pre-commit
|
|
671
|
+
// state. Reading from the working tree would let the user broaden
|
|
672
|
+
// their own access by editing access.md alongside the change they're
|
|
673
|
+
// trying to push through.
|
|
674
|
+
await this.assertCanWriteAtRef(workspaceId, 'HEAD', user.email, touched);
|
|
675
|
+
}
|
|
676
|
+
// Validation is **advisory**, not a gate. We still run the registered
|
|
677
|
+
// commit-validation hooks (the report surfaces in logs + can drive UI
|
|
678
|
+
// warnings later), but a `mustFix` result no longer blocks the commit —
|
|
679
|
+
// the user's right to save their work shouldn't depend on the rest of
|
|
680
|
+
// the KB being clean.
|
|
681
|
+
await this.runCommitValidationHooks({ workspaceId, branch, paths: touched }, (report) => `[git] commit on workspace=${workspaceId} branch=${branch} landing with ${report.mustFix.length} unresolved KB validator issue(s); the editor surfaces them but commit is no longer blocked.`);
|
|
682
|
+
await this.git(cwd, ['add', '-A']);
|
|
683
|
+
const args = [
|
|
684
|
+
'commit',
|
|
685
|
+
`--author=${user.name} <${user.email}>`,
|
|
686
|
+
'-m',
|
|
687
|
+
subject,
|
|
688
|
+
];
|
|
689
|
+
if (req.description && req.description.trim()) {
|
|
690
|
+
args.push('-m', req.description.trim());
|
|
691
|
+
}
|
|
692
|
+
await this.git(cwd, args);
|
|
693
|
+
const { stdout } = await this.git(cwd, [
|
|
694
|
+
'log',
|
|
695
|
+
'-1',
|
|
696
|
+
'--pretty=format:%H%x00%an%x00%ae%x00%s%x00%aI',
|
|
697
|
+
]);
|
|
698
|
+
const [sha, authorName, authorEmail, subj, committedAt] = stdout.split('\x00');
|
|
699
|
+
// The commit may have changed roles.yaml or access.md — drop cached state.
|
|
700
|
+
this.accessControl?.invalidate(workspaceId);
|
|
701
|
+
return {
|
|
702
|
+
sha: sha?.trim() ?? '',
|
|
703
|
+
authorName: authorName ?? '',
|
|
704
|
+
authorEmail: authorEmail ?? '',
|
|
705
|
+
subject: subj ?? subject,
|
|
706
|
+
committedAt: committedAt?.trim() ?? new Date().toISOString(),
|
|
707
|
+
};
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Commit a single path — bypasses the one-file-per-change guard in
|
|
712
|
+
* `commit()` because the caller has already scoped the change to one
|
|
713
|
+
* path (typically the lock-release flow, where the lock itself
|
|
714
|
+
* guarantees the file is the only one whose edits this user owns).
|
|
715
|
+
*
|
|
716
|
+
* `git add <path>` then `git commit` — other dirty paths in the working
|
|
717
|
+
* tree are left alone. Validation + protected-branch access gating still
|
|
718
|
+
* run, since `path` could itself be `roles.yaml` / `access.md`.
|
|
719
|
+
*
|
|
720
|
+
* Returns `null` when the path has nothing to commit (no diff against
|
|
721
|
+
* HEAD, not untracked) — keeps lock-release idempotent on "I opened the
|
|
722
|
+
* editor but didn't actually change anything" flows.
|
|
723
|
+
*/
|
|
724
|
+
/**
|
|
725
|
+
* Reset the file's working-tree state to HEAD — used when a write
|
|
726
|
+
* partially completed but the surrounding flow can't commit it
|
|
727
|
+
* (validator was advisory so this is rare, but op-throws + push-
|
|
728
|
+
* blocked-by-access-control still hit this path). Tracked files
|
|
729
|
+
* `git checkout HEAD --` reverts to the committed content; untracked
|
|
730
|
+
* files (a half-written new file) are deleted outright. Either way
|
|
731
|
+
* the working tree returns to a clean state so the next acquirer
|
|
732
|
+
* doesn't inherit half-finished bytes.
|
|
733
|
+
*
|
|
734
|
+
* Idempotent: a path that's already clean stays clean. Missing paths
|
|
735
|
+
* are silently ignored.
|
|
736
|
+
*/
|
|
737
|
+
async discardPath(workspaceId, relativePath) {
|
|
738
|
+
assertValidRelativePath(relativePath);
|
|
739
|
+
const repoRelativePath = this.stripRepoPrefix(relativePath);
|
|
740
|
+
return this.mutex.run(workspaceId, async () => {
|
|
741
|
+
const cwd = await this.repoDir(workspaceId);
|
|
742
|
+
// Tracked-at-HEAD? `ls-files --error-unmatch` returns 0 if so.
|
|
743
|
+
let trackedAtHead = false;
|
|
744
|
+
try {
|
|
745
|
+
await this.git(cwd, ['ls-files', '--error-unmatch', '--', repoRelativePath]);
|
|
746
|
+
trackedAtHead = true;
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
trackedAtHead = false;
|
|
750
|
+
}
|
|
751
|
+
if (trackedAtHead) {
|
|
752
|
+
await this.git(cwd, ['checkout', 'HEAD', '--', repoRelativePath]).catch((err) => {
|
|
753
|
+
console.warn(`[git] discardPath checkout failed for "${repoRelativePath}":`, err instanceof Error ? err.message : err);
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
// Untracked new file — remove from working tree.
|
|
758
|
+
const fileAbs = path.join(cwd, repoRelativePath);
|
|
759
|
+
await fs.rm(fileAbs, { force: true }).catch((err) => {
|
|
760
|
+
console.warn(`[git] discardPath rm failed for "${repoRelativePath}":`, err instanceof Error ? err.message : err);
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
async commitFile(workspaceId, user, relativePath, summary, skipValidator) {
|
|
766
|
+
assertValidRelativePath(relativePath);
|
|
767
|
+
assertValidAuthor(user);
|
|
768
|
+
const repoRelativePath = this.stripRepoPrefix(relativePath);
|
|
769
|
+
return this.mutex.run(workspaceId, async () => {
|
|
770
|
+
const cwd = await this.repoDir(workspaceId);
|
|
771
|
+
const branch = await this.currentBranch(cwd);
|
|
772
|
+
// Path-scoped status — is this specific file dirty? `--porcelain` on
|
|
773
|
+
// a single path returns empty when there's nothing to commit.
|
|
774
|
+
const { stdout: scoped } = await this.git(cwd, [
|
|
775
|
+
'status', '--porcelain=v1', '--', repoRelativePath,
|
|
776
|
+
]);
|
|
777
|
+
if (!scoped.trim())
|
|
778
|
+
return null;
|
|
779
|
+
// **No access check at commit time.** Per the "disk is the source of
|
|
780
|
+
// truth" rule, by the time content is on disk it must not be rejected.
|
|
781
|
+
// The permission check that used to live here now runs at lock
|
|
782
|
+
// acquisition (see `WorkflowService.acquireLock`'s
|
|
783
|
+
// `assertCanWriteAtPath`), so a user can't even start editing a file
|
|
784
|
+
// they lack write permission on — they never reach this commit. The
|
|
785
|
+
// legacy gate could destroy a user's edits when access changed
|
|
786
|
+
// mid-session; lock-time gating eliminates that destructive window.
|
|
787
|
+
//
|
|
788
|
+
// `GitService.commit` (the multi-file variant called by the agent's
|
|
789
|
+
// `commit_change` tool) still gates at commit time because it's used
|
|
790
|
+
// for files dirtied OUTSIDE the LockingFilesystem (e.g. agent
|
|
791
|
+
// manual-merge resolution), where no acquireLock ran.
|
|
792
|
+
// Validation is advisory; see `commit()` above for full rationale.
|
|
793
|
+
// `skipValidator` is set by callers that commit content which was
|
|
794
|
+
// already validated when it was first written — notably the reject
|
|
795
|
+
// flow, which re-commits a pre-agent baseline. Running the validator
|
|
796
|
+
// there is pure latency (it parses the whole KB and regenerates
|
|
797
|
+
// `diagram.md`) for a result that's neither enforced nor surfaced.
|
|
798
|
+
if (!skipValidator) {
|
|
799
|
+
await this.runCommitValidationHooks({ workspaceId, branch, paths: [repoRelativePath] }, (report) => `[git] commitFile on workspace=${workspaceId} branch=${branch} path=${repoRelativePath} landing with ${report.mustFix.length} unresolved KB validator issue(s); commit is no longer blocked.`);
|
|
800
|
+
}
|
|
801
|
+
await this.git(cwd, ['add', '--', repoRelativePath]);
|
|
802
|
+
const subject = summary?.trim() || buildDefaultCommitSummary(repoRelativePath);
|
|
803
|
+
if (subject.length > 200) {
|
|
804
|
+
throw new WorkflowValidationError('commit summary must be ≤ 200 characters');
|
|
805
|
+
}
|
|
806
|
+
if (subject.includes('\n')) {
|
|
807
|
+
throw new WorkflowValidationError('commit summary must be a single line');
|
|
808
|
+
}
|
|
809
|
+
await this.git(cwd, [
|
|
810
|
+
'commit',
|
|
811
|
+
`--author=${user.name} <${user.email}>`,
|
|
812
|
+
'-m', subject,
|
|
813
|
+
]);
|
|
814
|
+
const { stdout } = await this.git(cwd, [
|
|
815
|
+
'log',
|
|
816
|
+
'-1',
|
|
817
|
+
'--pretty=format:%H%x00%an%x00%ae%x00%s%x00%aI',
|
|
818
|
+
]);
|
|
819
|
+
const [sha, authorName, authorEmail, subj, committedAt] = stdout.split('\x00');
|
|
820
|
+
this.accessControl?.invalidate(workspaceId);
|
|
821
|
+
return {
|
|
822
|
+
sha: sha?.trim() ?? '',
|
|
823
|
+
authorName: authorName ?? '',
|
|
824
|
+
authorEmail: authorEmail ?? '',
|
|
825
|
+
subject: subj ?? subject,
|
|
826
|
+
committedAt: committedAt?.trim() ?? new Date().toISOString(),
|
|
827
|
+
};
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Atomic multi-file commit — commits whatever is currently dirty in the
|
|
832
|
+
* working tree as ONE commit attributed to `user`, then returns its
|
|
833
|
+
* attribution (or null when the tree is clean — idempotent re-apply). The
|
|
834
|
+
* multi-file sibling of `commit()`: same `git add -A` + commit, but WITHOUT
|
|
835
|
+
* the one-file-per-change guard — the caller has assembled + written + validated
|
|
836
|
+
* a batch (bulk node upload; the role-rename rewrite via `LockingFilesystem`).
|
|
837
|
+
*
|
|
838
|
+
* Deliberately does NOT write file content itself — disk writes are the
|
|
839
|
+
* caller's job. This is the git layer: it only stages + commits what is already
|
|
840
|
+
* on disk (the working tree is expected otherwise-clean under save=share, so
|
|
841
|
+
* `git add -A` stages exactly the caller's batch). Caller pushes separately
|
|
842
|
+
* (mirrors `commitFile` + `push`).
|
|
843
|
+
*/
|
|
844
|
+
async commitChanges(workspaceId, user, summary) {
|
|
845
|
+
assertValidAuthor(user);
|
|
846
|
+
const subject = summary?.trim();
|
|
847
|
+
if (!subject)
|
|
848
|
+
throw new WorkflowValidationError('commit summary is required');
|
|
849
|
+
if (subject.length > 200)
|
|
850
|
+
throw new WorkflowValidationError('commit summary must be ≤ 200 characters');
|
|
851
|
+
if (subject.includes('\n'))
|
|
852
|
+
throw new WorkflowValidationError('commit summary must be a single line');
|
|
853
|
+
return this.mutex.run(workspaceId, async () => {
|
|
854
|
+
const cwd = await this.repoDir(workspaceId);
|
|
855
|
+
const branch = await this.currentBranch(cwd);
|
|
856
|
+
// Nothing dirty → no-op (idempotent). Compute touched BEFORE `git add` so
|
|
857
|
+
// the protected-branch gate sees the same path set `commit()` would.
|
|
858
|
+
const { stdout: porcelain } = await this.git(cwd, ['status', '--porcelain=v1', '-z']);
|
|
859
|
+
const touched = parsePorcelainZ(porcelain);
|
|
860
|
+
if (touched.length === 0)
|
|
861
|
+
return null;
|
|
862
|
+
// Protected-branch access gate (defence in depth — callers already gate at
|
|
863
|
+
// lock-acquire / upload-mint, but this primitive is generic). Gate every
|
|
864
|
+
// touched path against the access tree at HEAD, like `commit()`.
|
|
865
|
+
if (isProtectedBranch(branch)) {
|
|
866
|
+
await this.assertCanWriteAtRef(workspaceId, 'HEAD', user.email, touched);
|
|
867
|
+
}
|
|
868
|
+
await this.git(cwd, ['add', '-A']);
|
|
869
|
+
await this.git(cwd, ['commit', `--author=${user.name} <${user.email}>`, '-m', subject]);
|
|
870
|
+
const { stdout } = await this.git(cwd, [
|
|
871
|
+
'log', '-1', '--pretty=format:%H%x00%an%x00%ae%x00%s%x00%aI',
|
|
872
|
+
]);
|
|
873
|
+
const [sha, authorName, authorEmail, subj, committedAt] = stdout.split('\x00');
|
|
874
|
+
this.accessControl?.invalidate(workspaceId);
|
|
875
|
+
return {
|
|
876
|
+
sha: sha?.trim() ?? '',
|
|
877
|
+
authorName: authorName ?? '',
|
|
878
|
+
authorEmail: authorEmail ?? '',
|
|
879
|
+
subject: subj ?? subject,
|
|
880
|
+
committedAt: committedAt?.trim() ?? new Date().toISOString(),
|
|
881
|
+
};
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
async push(workspaceId, user) {
|
|
885
|
+
return this.mutex.run(workspaceId, async () => {
|
|
886
|
+
const cwd = await this.repoDir(workspaceId);
|
|
887
|
+
const branch = await this.currentBranch(cwd);
|
|
888
|
+
// Access gate fires only when pushing to a protected branch. Pushing
|
|
889
|
+
// a feature/draft branch to its same-named remote can't change
|
|
890
|
+
// canonical state — the PR merge gate (`origin/<base>` access tree)
|
|
891
|
+
// is the real security boundary. Skipping the check here lets non-
|
|
892
|
+
// admins push their drafts freely so they can propose changes via
|
|
893
|
+
// PR. Gate against `origin/<branch>` — the published state — for
|
|
894
|
+
// protected pushes; that's defence-in-depth against local rebases or
|
|
895
|
+
// `commit-tree` shenanigans that might bypass the commit-time gate.
|
|
896
|
+
if (isProtectedBranch(branch)) {
|
|
897
|
+
const touched = await this.unpushedTouchedPaths(cwd);
|
|
898
|
+
await this.assertCanWriteAtRef(workspaceId, `origin/${branch}`, user.email, touched);
|
|
899
|
+
}
|
|
900
|
+
await this.git(cwd, ['push', '-u', 'origin', branch]);
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
// `forcePush` method removed. The workflow layer no longer auto-force-pushes
|
|
904
|
+
// as a recovery — when the cooperative `pull --rebase` + retry can't
|
|
905
|
+
// reconcile, we throw `PushNeedsAgentResolutionError` and hand off to the
|
|
906
|
+
// agent. The agent has git + gh CLI access and can choose to run a raw
|
|
907
|
+
// `git push --force-with-lease` itself when it has determined that's the
|
|
908
|
+
// correct call (i.e. it semantically merged + force-pushed, or determined
|
|
909
|
+
// the displacing commits were spurious). Removing the wrapper method
|
|
910
|
+
// prevents accidental reintroduction as an "easy fallback" — force-push
|
|
911
|
+
// belongs in the agent's tool surface (raw CLI), not the system's
|
|
912
|
+
// automatic-recovery path.
|
|
913
|
+
/**
|
|
914
|
+
* Attempt a local merge of `origin/<targetBranch>` into the currently-
|
|
915
|
+
* checked-out branch. Used by the workflow module to auto-merge target →
|
|
916
|
+
* source when a change request is opened or refreshed (PLAN §1).
|
|
917
|
+
*
|
|
918
|
+
* Two outcomes:
|
|
919
|
+
* - `{ kind: 'clean', alreadyUpToDate }` — merge succeeded, possibly as a
|
|
920
|
+
* no-op (`alreadyUpToDate: true`) or with a new merge commit attributed
|
|
921
|
+
* to `user` with a descriptive subject + body so reviewers can see why
|
|
922
|
+
* the merge landed without inspecting the diff.
|
|
923
|
+
* - `{ kind: 'conflicts', paths }` — `git merge` reported conflicts. The
|
|
924
|
+
* merge is aborted to leave the working tree clean before returning
|
|
925
|
+
* the conflicting paths so the caller can route to the agent for
|
|
926
|
+
* resolution.
|
|
927
|
+
*
|
|
928
|
+
* Requires the source branch to already be checked out and the working
|
|
929
|
+
* tree clean — both refused with structured errors before any git op runs.
|
|
930
|
+
*/
|
|
931
|
+
async mergeFromOrigin(workspaceId, sourceBranch, targetBranch, user) {
|
|
932
|
+
assertValidBranchName(sourceBranch);
|
|
933
|
+
assertValidBranchName(targetBranch);
|
|
934
|
+
assertValidAuthor(user);
|
|
935
|
+
return this.mutex.run(workspaceId, async () => {
|
|
936
|
+
const cwd = await this.repoDir(workspaceId);
|
|
937
|
+
// Under the per-branch workspace model, the workspace's identity is
|
|
938
|
+
// the source branch — the legacy "current branch must equal sourceBranch"
|
|
939
|
+
// guard was an artifact of the single-workspace-many-branches model and
|
|
940
|
+
// is no longer needed. The legacy dirty-tree refusal is also gone:
|
|
941
|
+
// save=share guarantees the tree is clean before any auto-merge runs,
|
|
942
|
+
// and the only way it could be dirty now is an upstream bug, which
|
|
943
|
+
// `statusInternal` already logs server-side.
|
|
944
|
+
// Refresh the target ref so we merge against the latest published
|
|
945
|
+
// state. Failing the fetch here is non-recoverable for this
|
|
946
|
+
// operation: a stale `origin/<target>` would let us silently miss
|
|
947
|
+
// conflicts the next push would surface, and the whole point of
|
|
948
|
+
// `mergeFromOrigin` is to surface those conflicts now. Propagate
|
|
949
|
+
// the error so the auto-merge aborts cleanly instead of producing
|
|
950
|
+
// a merge based on a stale ref. Explicit destination refspec for the
|
|
951
|
+
// same reason as `mergeChangeRequest`: a bare `fetch origin <branch>`
|
|
952
|
+
// updates `origin/<branch>` only as far as `remote.origin.fetch` allows,
|
|
953
|
+
// and a drifted refspec would hand this merge the stale ref it is
|
|
954
|
+
// explicitly trying to avoid.
|
|
955
|
+
await this.git(cwd, [
|
|
956
|
+
'fetch', '--no-write-fetch-head', 'origin',
|
|
957
|
+
`+refs/heads/${targetBranch}:refs/remotes/origin/${targetBranch}`,
|
|
958
|
+
]);
|
|
959
|
+
// `--no-commit --no-ff` so we control the commit (need to override
|
|
960
|
+
// author) and so we never sneak a fast-forward past the merge-commit
|
|
961
|
+
// explanation gate.
|
|
962
|
+
let mergeStdout = '';
|
|
963
|
+
try {
|
|
964
|
+
const r = await this.git(cwd, [
|
|
965
|
+
'merge', '--no-commit', '--no-ff', `origin/${targetBranch}`,
|
|
966
|
+
]);
|
|
967
|
+
mergeStdout = r.stdout;
|
|
968
|
+
}
|
|
969
|
+
catch (err) {
|
|
970
|
+
// Conflict or other failure. Disambiguate via the shared porcelain parse.
|
|
971
|
+
const conflictPaths = await this.conflictedPaths(cwd);
|
|
972
|
+
// Always attempt to abort so a partial merge doesn't poison the tree.
|
|
973
|
+
try {
|
|
974
|
+
await this.git(cwd, ['merge', '--abort']);
|
|
975
|
+
}
|
|
976
|
+
catch {
|
|
977
|
+
// Already aborted (no MERGE_HEAD) — fine.
|
|
978
|
+
}
|
|
979
|
+
if (conflictPaths.length === 0)
|
|
980
|
+
throw err;
|
|
981
|
+
return { kind: 'conflicts', paths: conflictPaths };
|
|
982
|
+
}
|
|
983
|
+
// No conflicts. Detect "already up to date" — git prints that exact
|
|
984
|
+
// phrase and stages nothing. Otherwise the merge is staged and waiting
|
|
985
|
+
// for the commit we now author as `user`.
|
|
986
|
+
if (/Already up to date/i.test(mergeStdout)) {
|
|
987
|
+
return { kind: 'clean', alreadyUpToDate: true };
|
|
988
|
+
}
|
|
989
|
+
const subject = `Refresh ${sourceBranch} from ${targetBranch}`;
|
|
990
|
+
const body = `Workflow auto-merge: incorporates the latest ${targetBranch} into ${sourceBranch} ` +
|
|
991
|
+
`as part of opening or refreshing a change request. ` +
|
|
992
|
+
`Conflicts (if any) would have been surfaced to the caller before this commit landed; ` +
|
|
993
|
+
`none were detected, so the merge applied cleanly.`;
|
|
994
|
+
await this.git(cwd, [
|
|
995
|
+
'commit',
|
|
996
|
+
`--author=${user.name} <${user.email}>`,
|
|
997
|
+
'-m', subject,
|
|
998
|
+
'-m', body,
|
|
999
|
+
]);
|
|
1000
|
+
this.accessControl?.invalidate(workspaceId);
|
|
1001
|
+
return { kind: 'clean', alreadyUpToDate: false };
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Merge a change request locally and publish it to the target branch — the
|
|
1006
|
+
* provider-agnostic replacement for `gh pr merge`. Runs in the *base* branch's
|
|
1007
|
+
* workspace: reset to the published base tip, merge the source's published
|
|
1008
|
+
* tip as a `--no-ff` commit authored by the human triggerer, and push.
|
|
1009
|
+
*
|
|
1010
|
+
* Serialized per base workspace via the mutex, with a bounded retry: if the
|
|
1011
|
+
* push is rejected because the base advanced (a concurrent merge), we reset to
|
|
1012
|
+
* the new base and re-merge on top of it. Conflicts are deterministic — they
|
|
1013
|
+
* return immediately (no retry) so the caller can route into resolution.
|
|
1014
|
+
*
|
|
1015
|
+
* Returns the merge commit SHA, or the conflicting paths.
|
|
1016
|
+
*/
|
|
1017
|
+
async mergeChangeRequest(baseWorkspaceId, sourceBranch, targetBranch, commit, user) {
|
|
1018
|
+
assertValidBranchName(sourceBranch);
|
|
1019
|
+
assertValidBranchName(targetBranch);
|
|
1020
|
+
assertValidAuthor(user);
|
|
1021
|
+
const MAX_ATTEMPTS = 3;
|
|
1022
|
+
return this.mutex.run(baseWorkspaceId, async () => {
|
|
1023
|
+
const cwd = await this.repoDir(baseWorkspaceId);
|
|
1024
|
+
// `git merge`/`git commit` need a committer identity. Prod clones are
|
|
1025
|
+
// stamped by WorkspaceService, but stamp here too so the merge never
|
|
1026
|
+
// depends on that ambient config (it would fail "Committer identity
|
|
1027
|
+
// unknown" on a clone that lacks it).
|
|
1028
|
+
await this.git(cwd, ['config', 'user.name', BOT_NAME]);
|
|
1029
|
+
await this.git(cwd, ['config', 'user.email', BOT_EMAIL]);
|
|
1030
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
|
1031
|
+
// Explicit destination refspecs, because the two `origin/<branch>`
|
|
1032
|
+
// reads below MUST see the tips this fetch just retrieved. A bare
|
|
1033
|
+
// `fetch origin <branch>` only updates `refs/remotes/origin/<branch>`
|
|
1034
|
+
// *opportunistically* — via whatever `remote.origin.fetch` happens to
|
|
1035
|
+
// say — and this PR exists because that refspec drifts. Narrowed (or
|
|
1036
|
+
// dropped) it leaves `origin/<source>` stale, so the merge below lands
|
|
1037
|
+
// an old source tip on the base and every push attempt burns against
|
|
1038
|
+
// the same stale ref. Naming the destinations removes the dependency
|
|
1039
|
+
// on ambient config; both branches are `assertValidBranchName`-checked
|
|
1040
|
+
// at the top of this method.
|
|
1041
|
+
//
|
|
1042
|
+
// `--no-write-fetch-head`: a multi-ref fetch writes one for-merge
|
|
1043
|
+
// FETCH_HEAD entry PER ref — the exact shape that kills a concurrent
|
|
1044
|
+
// `git pull` in this clone (see `pull`). Only the `origin/<branch>`
|
|
1045
|
+
// refs below are read.
|
|
1046
|
+
await this.git(cwd, [
|
|
1047
|
+
'fetch', '--no-write-fetch-head', 'origin',
|
|
1048
|
+
`+refs/heads/${targetBranch}:refs/remotes/origin/${targetBranch}`,
|
|
1049
|
+
`+refs/heads/${sourceBranch}:refs/remotes/origin/${sourceBranch}`,
|
|
1050
|
+
]);
|
|
1051
|
+
// Match the published base tip exactly. Protected base branches never
|
|
1052
|
+
// carry local commits (save=share + no direct commits), so this only
|
|
1053
|
+
// discards a prior attempt's state, never real work.
|
|
1054
|
+
await this.git(cwd, ['checkout', targetBranch]);
|
|
1055
|
+
await this.git(cwd, ['reset', '--hard', `origin/${targetBranch}`]);
|
|
1056
|
+
// `--no-commit --no-ff` so we author the merge commit as the human and
|
|
1057
|
+
// never fast-forward past the merge record.
|
|
1058
|
+
try {
|
|
1059
|
+
await this.git(cwd, ['merge', '--no-commit', '--no-ff', `origin/${sourceBranch}`]);
|
|
1060
|
+
}
|
|
1061
|
+
catch (err) {
|
|
1062
|
+
const paths = await this.conflictedPaths(cwd);
|
|
1063
|
+
await this.git(cwd, ['merge', '--abort']).catch(() => undefined);
|
|
1064
|
+
if (paths.length > 0)
|
|
1065
|
+
return { kind: 'conflicts', paths };
|
|
1066
|
+
// Not a conflict — surface the underlying git error so the real cause
|
|
1067
|
+
// (e.g. missing identity, unrelated histories) is diagnosable.
|
|
1068
|
+
throw new Error(`git merge failed without detectable conflicts: ${redact(err instanceof Error ? err.message : String(err))}`);
|
|
1069
|
+
}
|
|
1070
|
+
// Nothing staged ⇒ base already contains source (empty CR). The base tip
|
|
1071
|
+
// is the "merged" state; report it without an empty commit.
|
|
1072
|
+
const { stdout: staged } = await this.git(cwd, ['diff', '--cached', '--name-only']);
|
|
1073
|
+
if (staged.trim() === '') {
|
|
1074
|
+
const { stdout: sha } = await this.git(cwd, ['rev-parse', 'HEAD']);
|
|
1075
|
+
await this.git(cwd, ['merge', '--abort']).catch(() => undefined);
|
|
1076
|
+
return { kind: 'merged', sha: sha.trim() };
|
|
1077
|
+
}
|
|
1078
|
+
await this.git(cwd, [
|
|
1079
|
+
'commit',
|
|
1080
|
+
`--author=${user.name} <${user.email}>`,
|
|
1081
|
+
'-m', commit.subject,
|
|
1082
|
+
'-m', commit.body,
|
|
1083
|
+
]);
|
|
1084
|
+
const { stdout: sha } = await this.git(cwd, ['rev-parse', 'HEAD']);
|
|
1085
|
+
try {
|
|
1086
|
+
await this.git(cwd, ['push', 'origin', `HEAD:refs/heads/${targetBranch}`]);
|
|
1087
|
+
this.accessControl?.invalidate(baseWorkspaceId);
|
|
1088
|
+
return { kind: 'merged', sha: sha.trim() };
|
|
1089
|
+
}
|
|
1090
|
+
catch (err) {
|
|
1091
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1092
|
+
// Base moved under us — reset to the new tip and re-merge next loop.
|
|
1093
|
+
if (/non-fast-forward|\[rejected\]|fetch first/i.test(msg) && attempt < MAX_ATTEMPTS) {
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
throw err;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
throw new Error(`merge push to "${targetBranch}" kept being rejected after ${MAX_ATTEMPTS} attempts (base moving concurrently)`);
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
/** Conflicted paths from a half-done merge, parsed from porcelain status. */
|
|
1103
|
+
async conflictedPaths(cwd) {
|
|
1104
|
+
const { stdout } = await this.git(cwd, ['status', '--porcelain=v1']);
|
|
1105
|
+
const paths = [];
|
|
1106
|
+
for (const line of stdout.split('\n')) {
|
|
1107
|
+
if (/^(UU|AA|DD|AU|UA|DU|UD) /.test(line))
|
|
1108
|
+
paths.push(line.slice(3));
|
|
1109
|
+
}
|
|
1110
|
+
return paths;
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Paths changed in commits that exist locally but not on the upstream ref —
|
|
1114
|
+
* i.e. exactly what the next `push` would publish. Falls back to "every
|
|
1115
|
+
* commit not reachable from any remote ref" when there's no upstream yet
|
|
1116
|
+
* (first push).
|
|
1117
|
+
*/
|
|
1118
|
+
async unpushedTouchedPaths(cwd) {
|
|
1119
|
+
let range;
|
|
1120
|
+
try {
|
|
1121
|
+
await this.git(cwd, ['rev-parse', '--verify', '--quiet', '@{u}']);
|
|
1122
|
+
range = ['@{u}..HEAD'];
|
|
1123
|
+
}
|
|
1124
|
+
catch {
|
|
1125
|
+
range = ['HEAD', '--not', '--remotes'];
|
|
1126
|
+
}
|
|
1127
|
+
const { stdout } = await this.git(cwd, [
|
|
1128
|
+
'log', '--name-only', '--pretty=format:', ...range,
|
|
1129
|
+
]);
|
|
1130
|
+
const set = new Set();
|
|
1131
|
+
for (const line of stdout.split('\n')) {
|
|
1132
|
+
const trimmed = line.trim();
|
|
1133
|
+
if (trimmed)
|
|
1134
|
+
set.add(trimmed);
|
|
1135
|
+
}
|
|
1136
|
+
return [...set];
|
|
1137
|
+
}
|
|
1138
|
+
async fetch(workspaceId) {
|
|
1139
|
+
return this.mutex.run(workspaceId, async () => {
|
|
1140
|
+
const cwd = await this.repoDir(workspaceId);
|
|
1141
|
+
// `--no-write-fetch-head`: callers want the remote-tracking refs, not
|
|
1142
|
+
// FETCH_HEAD, and leaving that shared file alone keeps this fetch from
|
|
1143
|
+
// steering a concurrent refresh in the same clone (see `pull`).
|
|
1144
|
+
await this.git(cwd, ['fetch', '--no-write-fetch-head', 'origin']);
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Collapse the clone's drifted tracking config back to one fetch refspec and
|
|
1149
|
+
* one upstream ref, then refresh `refs/remotes/origin/<branch>` through an
|
|
1150
|
+
* explicit refspec — never touching the shared `FETCH_HEAD`. Returns the ref
|
|
1151
|
+
* the caller should operate on.
|
|
1152
|
+
*
|
|
1153
|
+
* Both halves matter, and neither is sufficient alone:
|
|
1154
|
+
*
|
|
1155
|
+
* - The self-heal. A multi-valued `branch.<name>.merge` makes every bare
|
|
1156
|
+
* fetch in this clone write more than one *for-merge* FETCH_HEAD entry,
|
|
1157
|
+
* which is the ammunition behind the failure below; a duplicated
|
|
1158
|
+
* `remote.origin.fetch` refspec is the same class of drift (see
|
|
1159
|
+
* `clone-config.ts`). `--replace-all` collapses either back to the single
|
|
1160
|
+
* value the clone was created with. Best effort — the fetch names its refs
|
|
1161
|
+
* explicitly, so a failure here is harmless.
|
|
1162
|
+
*
|
|
1163
|
+
* - Never reading or writing `FETCH_HEAD`. `git pull` is a two-step
|
|
1164
|
+
* operation — fetch, then read the rebase target out of `.git/FETCH_HEAD`
|
|
1165
|
+
* — and `FETCH_HEAD` is a single mutable file shared by every git process
|
|
1166
|
+
* in the clone. This service is not the only writer: `fetchOriginIfStale`
|
|
1167
|
+
* (outside the workspace mutex, see `listBranches`) and
|
|
1168
|
+
* `WorkspaceService.ensureRemotesFetched` (outside it entirely, driven by
|
|
1169
|
+
* the CR-list poll) both run bare `git fetch --prune origin`, and a bare
|
|
1170
|
+
* fetch writes one for-merge entry per `branch.<name>.merge` value. Let one
|
|
1171
|
+
* of those land in the window between the pull's own fetch and its read, on
|
|
1172
|
+
* a clone whose merge config has drifted to two values, and pull sees two
|
|
1173
|
+
* merge heads and dies with "Cannot rebase onto multiple branches" — the
|
|
1174
|
+
* reported failure. (Measured: ~40% of refreshes with one concurrent fetch
|
|
1175
|
+
* loop. The drifted config alone never breaks it — an explicit
|
|
1176
|
+
* `pull --rebase origin <branch>` survives every drift shape on its own.)
|
|
1177
|
+
*
|
|
1178
|
+
* So: fetch with an explicit destination refspec, which updates
|
|
1179
|
+
* `refs/remotes/origin/<branch>` regardless of any `remote.origin.fetch`
|
|
1180
|
+
* drift, and let the caller name that ref. `--no-write-fetch-head` (git
|
|
1181
|
+
* 2.29+, asserted at boot by `git-version.ts`) keeps this refresh from
|
|
1182
|
+
* perturbing anyone else's `FETCH_HEAD` in turn. Nothing here reads shared
|
|
1183
|
+
* mutable state, so no concurrent git process can steer or break it.
|
|
1184
|
+
*
|
|
1185
|
+
* Shared by the two refresh paths (`pull`, `resetToRemote`) so they can't
|
|
1186
|
+
* drift apart — which is precisely the failure class this guards against.
|
|
1187
|
+
*/
|
|
1188
|
+
async refreshRemoteBranchRef(cwd, branch) {
|
|
1189
|
+
for (const args of cloneTrackingConfigArgs(branch)) {
|
|
1190
|
+
await this.git(cwd, args).catch(() => undefined);
|
|
1191
|
+
}
|
|
1192
|
+
await this.git(cwd, [
|
|
1193
|
+
'fetch', '--no-write-fetch-head', 'origin',
|
|
1194
|
+
`+refs/heads/${branch}:refs/remotes/origin/${branch}`,
|
|
1195
|
+
]);
|
|
1196
|
+
return `refs/remotes/origin/${branch}`;
|
|
1197
|
+
}
|
|
1198
|
+
async pull(workspaceId) {
|
|
1199
|
+
return this.mutex.run(workspaceId, async () => {
|
|
1200
|
+
const cwd = await this.repoDir(workspaceId);
|
|
1201
|
+
const branch = await this.currentBranch(cwd);
|
|
1202
|
+
try {
|
|
1203
|
+
// Refresh WITHOUT `git pull`, and without touching `FETCH_HEAD` — see
|
|
1204
|
+
// `refreshRemoteBranchRef` for why both halves are load-bearing.
|
|
1205
|
+
//
|
|
1206
|
+
// `--autostash` is load-bearing for the cooperative push recovery.
|
|
1207
|
+
// Under save=share the working tree is *supposed* to stay clean, but
|
|
1208
|
+
// in practice it can carry modified tracked artifacts (e.g. the
|
|
1209
|
+
// validator regenerates a dashboard HTML on every commit) that the
|
|
1210
|
+
// single-file commit didn't pick up. A rebase without it aborts
|
|
1211
|
+
// outright on those — "cannot rebase: You have unstaged
|
|
1212
|
+
// changes" — which is exactly the failure that strands a diverged
|
|
1213
|
+
// branch in an unrecoverable loop (the retry push stays non-fast-
|
|
1214
|
+
// forward forever). Autostash stashes those modifications, rebases
|
|
1215
|
+
// the local commits onto origin, then reapplies them, so the retry
|
|
1216
|
+
// push can fast-forward. Untracked files don't block rebase and are
|
|
1217
|
+
// left untouched.
|
|
1218
|
+
const remoteRef = await this.refreshRemoteBranchRef(cwd, branch);
|
|
1219
|
+
await this.git(cwd, ['rebase', '--autostash', remoteRef]);
|
|
1220
|
+
}
|
|
1221
|
+
catch (err) {
|
|
1222
|
+
// A failed rebase leaves the repo in a "REBASE_HEAD" / detached-apply state,
|
|
1223
|
+
// which makes every subsequent git command behave unexpectedly. Return the
|
|
1224
|
+
// working tree to a clean state before surfacing the original error.
|
|
1225
|
+
await this.git(cwd, ['rebase', '--abort']).catch(() => undefined);
|
|
1226
|
+
throw err;
|
|
1227
|
+
}
|
|
1228
|
+
this.accessControl?.invalidate(workspaceId);
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Hard-reset the workspace's checked-out branch to `origin/<branch>`, fetching
|
|
1233
|
+
* first. This is the break-glass primitive behind roles.yaml recovery: it
|
|
1234
|
+
* makes the local clone EXACTLY match origin, discarding any local divergence
|
|
1235
|
+
* (e.g. a half-finished recovery commit from a prior failed push, or commits
|
|
1236
|
+
* the clone was behind on). The caller then writes its fix on top of the
|
|
1237
|
+
* current origin tip, so the follow-up push fast-forwards instead of being
|
|
1238
|
+
* rejected non-fast-forward.
|
|
1239
|
+
*
|
|
1240
|
+
* Destructive by design and intentionally NOT gated on the protected-branch
|
|
1241
|
+
* rule — it only ever moves the LOCAL ref to match origin (no remote mutation),
|
|
1242
|
+
* and recovery must work precisely on the protected default branch. Under
|
|
1243
|
+
* save=share there is no precious unpushed local work to lose (every save
|
|
1244
|
+
* auto-pushes); the one exception is a failed recovery commit, which the
|
|
1245
|
+
* caller re-creates.
|
|
1246
|
+
*/
|
|
1247
|
+
async resetToRemote(workspaceId, branch) {
|
|
1248
|
+
assertValidBranchName(branch);
|
|
1249
|
+
return this.mutex.run(workspaceId, async () => {
|
|
1250
|
+
const cwd = await this.repoDir(workspaceId);
|
|
1251
|
+
// Same self-heal + FETCH_HEAD-free refresh as `pull`, then reset to the
|
|
1252
|
+
// remote-tracking ref it returns — never `FETCH_HEAD`. The stakes here
|
|
1253
|
+
// are higher than a failed refresh: this is a hard reset, so a raced
|
|
1254
|
+
// FETCH_HEAD would silently reset the workspace to whatever branch the
|
|
1255
|
+
// other fetch happened to write. Deliberately no rebase — a divergent
|
|
1256
|
+
// local commit can never conflict.
|
|
1257
|
+
const remoteRef = await this.refreshRemoteBranchRef(cwd, branch);
|
|
1258
|
+
await this.git(cwd, ['reset', '--hard', remoteRef]);
|
|
1259
|
+
this.accessControl?.invalidate(workspaceId);
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
async resolveForkBase(workspaceId, branch) {
|
|
1263
|
+
assertValidBranchName(branch);
|
|
1264
|
+
return this.mutex.run(workspaceId, async () => {
|
|
1265
|
+
const cwd = await this.repoDir(workspaceId);
|
|
1266
|
+
let best = null;
|
|
1267
|
+
for (const p of PROTECTED_BRANCHES) {
|
|
1268
|
+
if (p === branch)
|
|
1269
|
+
continue;
|
|
1270
|
+
const res = await this.tryAheadBehind(cwd, branch, `origin/${p}`);
|
|
1271
|
+
if (res && (best === null || res.ahead < best.ahead)) {
|
|
1272
|
+
best = { base: p, ahead: res.ahead };
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
return best?.base ?? null;
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Count commits on `branch` that aren't on `baseBranch`. Identical to the
|
|
1280
|
+
* "ahead" half of `tryAheadBehind` but exposed as a public method because
|
|
1281
|
+
* background callers (the routine runner) need to gate "open a CR or
|
|
1282
|
+
* clean the branch up?" on it. Returns 0 when either ref can't be
|
|
1283
|
+
* resolved — a missing branch can't possibly be ahead of anything, and
|
|
1284
|
+
* misreporting as `> 0` would block legitimate cleanup.
|
|
1285
|
+
*/
|
|
1286
|
+
async countCommitsAhead(workspaceId, branch, baseBranch) {
|
|
1287
|
+
assertValidBranchName(branch);
|
|
1288
|
+
assertValidBranchName(baseBranch);
|
|
1289
|
+
if (branch === baseBranch)
|
|
1290
|
+
return 0;
|
|
1291
|
+
return this.mutex.run(workspaceId, async () => {
|
|
1292
|
+
const cwd = await this.repoDir(workspaceId);
|
|
1293
|
+
// Compare against the *remote* base — the routine flow pushes commits
|
|
1294
|
+
// to origin as they're made, and matching here against the same ref the
|
|
1295
|
+
// CR creator uses (`origin/<base>`) keeps the two readings consistent.
|
|
1296
|
+
const res = await this.tryAheadBehind(cwd, branch, `origin/${baseBranch}`);
|
|
1297
|
+
return res?.ahead ?? 0;
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Whether `head` and `origin/<base>` have any common ancestor. False when
|
|
1302
|
+
* `head` is an orphan branch or was created from an unrelated history —
|
|
1303
|
+
* GitHub refuses PRs in that state with a `no history in common` error,
|
|
1304
|
+
* so PR creation pre-flights this and hands off to the agent on false.
|
|
1305
|
+
*/
|
|
1306
|
+
async haveSharedHistory(workspaceId, base, head) {
|
|
1307
|
+
assertValidBranchName(base);
|
|
1308
|
+
assertValidBranchName(head);
|
|
1309
|
+
return this.mutex.run(workspaceId, async () => {
|
|
1310
|
+
const cwd = await this.repoDir(workspaceId);
|
|
1311
|
+
try {
|
|
1312
|
+
const { stdout } = await this.git(cwd, [
|
|
1313
|
+
'merge-base',
|
|
1314
|
+
`origin/${base}`,
|
|
1315
|
+
head,
|
|
1316
|
+
]);
|
|
1317
|
+
return stdout.trim().length > 0;
|
|
1318
|
+
}
|
|
1319
|
+
catch (err) {
|
|
1320
|
+
// git merge-base exits 1 specifically for "no common ancestor" — the
|
|
1321
|
+
// very condition this method tests for. Any other failure (exit 128
|
|
1322
|
+
// for invalid args / corrupt repo, ENOENT for missing git binary,
|
|
1323
|
+
// etc.) is an infra problem that must bubble up rather than silently
|
|
1324
|
+
// masquerade as "no shared history".
|
|
1325
|
+
const exitCode = err.exitCode;
|
|
1326
|
+
if (exitCode === 1)
|
|
1327
|
+
return false;
|
|
1328
|
+
throw err;
|
|
1329
|
+
}
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
async diffStat(workspaceId, base) {
|
|
1333
|
+
return this.mutex.run(workspaceId, async () => {
|
|
1334
|
+
const cwd = await this.repoDir(workspaceId);
|
|
1335
|
+
if (base)
|
|
1336
|
+
assertValidBranchName(base.replace(/^origin\//, ''));
|
|
1337
|
+
const against = base ?? '@{upstream}';
|
|
1338
|
+
try {
|
|
1339
|
+
// `git diff <ref>` (no dots) compares <ref> to the working tree, so
|
|
1340
|
+
// this counts uncommitted + committed changes that would land if the
|
|
1341
|
+
// caller committed and pushed right now — that's what the share
|
|
1342
|
+
// dialog previews. For PR creation the working tree is already clean
|
|
1343
|
+
// (the push just ran), so the answer matches the old three-dot form.
|
|
1344
|
+
const { stdout } = await this.git(cwd, ['diff', '--name-only', against]);
|
|
1345
|
+
return stdout.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
1346
|
+
}
|
|
1347
|
+
catch {
|
|
1348
|
+
// No upstream / unknown base — nothing to diff against.
|
|
1349
|
+
return [];
|
|
1350
|
+
}
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
async pendingChanges(workspaceId) {
|
|
1354
|
+
return this.mutex.run(workspaceId, async () => {
|
|
1355
|
+
const cwd = await this.repoDir(workspaceId);
|
|
1356
|
+
const { stdout } = await this.git(cwd, ['status', '--porcelain=v1', '-z']);
|
|
1357
|
+
return parsePorcelainZ(stdout);
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* Repo-relative paths of every VCS-tracked file (NUL-delimited, so paths with
|
|
1362
|
+
* spaces or bracketed prefixes survive intact). Untracked / ignored files are
|
|
1363
|
+
* excluded — the caller gets exactly the working tree's committed contents.
|
|
1364
|
+
*/
|
|
1365
|
+
async listTrackedFiles(workspaceId) {
|
|
1366
|
+
return this.mutex.run(workspaceId, async () => {
|
|
1367
|
+
const cwd = await this.repoDir(workspaceId);
|
|
1368
|
+
const { stdout } = await this.git(cwd, ['ls-files', '-z']);
|
|
1369
|
+
return stdout.split('\0').filter(Boolean);
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
async revertCommit(workspaceId, user, sha) {
|
|
1373
|
+
if (!/^[a-f0-9]{7,40}$/i.test(sha)) {
|
|
1374
|
+
throw new WorkflowValidationError('invalid commit sha');
|
|
1375
|
+
}
|
|
1376
|
+
assertValidAuthor(user);
|
|
1377
|
+
return this.mutex.run(workspaceId, async () => {
|
|
1378
|
+
const cwd = await this.repoDir(workspaceId);
|
|
1379
|
+
const branch = await this.currentBranch(cwd);
|
|
1380
|
+
// Revert creates a new commit reversing `sha`. Same model as commit:
|
|
1381
|
+
// gate only on protected branches; reverts on feature/draft branches
|
|
1382
|
+
// are free (they produce a commit that would still need to merge via
|
|
1383
|
+
// PR to affect canonical state).
|
|
1384
|
+
if (isProtectedBranch(branch)) {
|
|
1385
|
+
const { stdout: diffOut } = await this.git(cwd, [
|
|
1386
|
+
'diff-tree', '--no-commit-id', '--name-only', '-r', sha,
|
|
1387
|
+
]);
|
|
1388
|
+
const touched = diffOut.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
1389
|
+
await this.assertCanWriteAtRef(workspaceId, 'HEAD', user.email, touched);
|
|
1390
|
+
}
|
|
1391
|
+
await this.git(cwd, [
|
|
1392
|
+
'revert',
|
|
1393
|
+
'--no-edit',
|
|
1394
|
+
`--author=${user.name} <${user.email}>`,
|
|
1395
|
+
sha,
|
|
1396
|
+
]);
|
|
1397
|
+
const { stdout } = await this.git(cwd, [
|
|
1398
|
+
'log',
|
|
1399
|
+
'-1',
|
|
1400
|
+
'--pretty=format:%H%x00%an%x00%ae%x00%s%x00%aI',
|
|
1401
|
+
]);
|
|
1402
|
+
const [headSha, authorName, authorEmail, subj, committedAt] = stdout.split('\x00');
|
|
1403
|
+
// A revert may have touched roles.yaml or access.md — drop cached
|
|
1404
|
+
// access state so the next gate read reflects the rolled-back tree.
|
|
1405
|
+
// Mirrors the commit / pull paths.
|
|
1406
|
+
this.accessControl?.invalidate(workspaceId);
|
|
1407
|
+
return {
|
|
1408
|
+
sha: headSha?.trim() ?? '',
|
|
1409
|
+
authorName: authorName ?? '',
|
|
1410
|
+
authorEmail: authorEmail ?? '',
|
|
1411
|
+
subject: subj ?? '',
|
|
1412
|
+
committedAt: committedAt?.trim() ?? new Date().toISOString(),
|
|
1413
|
+
};
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
async logForFile(workspaceId, relativePath, limit = 20) {
|
|
1417
|
+
assertValidRelativePath(relativePath);
|
|
1418
|
+
const max = Math.max(1, Math.min(limit, 100));
|
|
1419
|
+
const repoRelativePath = this.stripRepoPrefix(relativePath);
|
|
1420
|
+
return this.mutex.run(workspaceId, async () => {
|
|
1421
|
+
const cwd = await this.repoDir(workspaceId);
|
|
1422
|
+
const { stdout } = await this.git(cwd, [
|
|
1423
|
+
'log',
|
|
1424
|
+
`--max-count=${max}`,
|
|
1425
|
+
'--pretty=format:%H%x00%an%x00%ae%x00%s%x00%aI',
|
|
1426
|
+
'--',
|
|
1427
|
+
repoRelativePath,
|
|
1428
|
+
]);
|
|
1429
|
+
return stdout
|
|
1430
|
+
.split('\n')
|
|
1431
|
+
.map((line) => line.trim())
|
|
1432
|
+
.filter(Boolean)
|
|
1433
|
+
.map((line) => {
|
|
1434
|
+
const [sha, authorName, authorEmail, subject, committedAt] = line.split('\x00');
|
|
1435
|
+
return {
|
|
1436
|
+
sha: sha ?? '',
|
|
1437
|
+
authorName: authorName ?? '',
|
|
1438
|
+
authorEmail: authorEmail ?? '',
|
|
1439
|
+
subject: subject ?? '',
|
|
1440
|
+
committedAt: committedAt ?? '',
|
|
1441
|
+
};
|
|
1442
|
+
});
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
async diffFileAtCommit(workspaceId, relativePath, sha) {
|
|
1446
|
+
assertValidRelativePath(relativePath);
|
|
1447
|
+
if (!/^[a-f0-9]{7,40}$/i.test(sha)) {
|
|
1448
|
+
throw new WorkflowValidationError('invalid commit sha');
|
|
1449
|
+
}
|
|
1450
|
+
const repoRelativePath = this.stripRepoPrefix(relativePath);
|
|
1451
|
+
return this.mutex.run(workspaceId, async () => {
|
|
1452
|
+
const cwd = await this.repoDir(workspaceId);
|
|
1453
|
+
try {
|
|
1454
|
+
const { stdout } = await this.git(cwd, [
|
|
1455
|
+
'show',
|
|
1456
|
+
sha,
|
|
1457
|
+
'--',
|
|
1458
|
+
repoRelativePath,
|
|
1459
|
+
]);
|
|
1460
|
+
return stdout;
|
|
1461
|
+
}
|
|
1462
|
+
catch (err) {
|
|
1463
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1464
|
+
throw new Error(`Failed to execute git show for "${relativePath}" at "${sha}": ${msg}`, { cause: err });
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
async diffFileBetweenBranches(workspaceId, relativePath, fromBranch, toBranch) {
|
|
1469
|
+
assertValidRelativePath(relativePath);
|
|
1470
|
+
assertValidBranchName(fromBranch);
|
|
1471
|
+
assertValidBranchName(toBranch);
|
|
1472
|
+
if (fromBranch === toBranch) {
|
|
1473
|
+
throw new WorkflowValidationError('from and to branches must differ');
|
|
1474
|
+
}
|
|
1475
|
+
const repoRelativePath = this.stripRepoPrefix(relativePath);
|
|
1476
|
+
return this.mutex.run(workspaceId, async () => {
|
|
1477
|
+
const cwd = await this.repoDir(workspaceId);
|
|
1478
|
+
const fromRef = await this.resolveBranchRef(cwd, fromBranch);
|
|
1479
|
+
const toRef = await this.resolveBranchRef(cwd, toBranch);
|
|
1480
|
+
// When one side is the currently-checked-out branch, diff against the
|
|
1481
|
+
// working tree instead of the branch's HEAD commit. Otherwise saves
|
|
1482
|
+
// that haven't been committed yet (which is the normal state in the
|
|
1483
|
+
// share-changes flow) are invisible — the user just edited the file
|
|
1484
|
+
// and would see "identical" if we always compared committed states.
|
|
1485
|
+
const current = await this.currentBranch(cwd);
|
|
1486
|
+
const isToCurrent = toBranch === current;
|
|
1487
|
+
const isFromCurrent = fromBranch === current;
|
|
1488
|
+
// `git diff <ref> -- <path>` ignores untracked working-tree files, so a
|
|
1489
|
+
// brand-new file (created on disk but never committed) renders as
|
|
1490
|
+
// identical between branches. When the current branch is one side and
|
|
1491
|
+
// the path is untracked locally, synthesize an added/removed diff —
|
|
1492
|
+
// same pattern diffFileWorking uses for HEAD-vs-working-tree.
|
|
1493
|
+
if (isToCurrent !== isFromCurrent &&
|
|
1494
|
+
(await this.isPathUntracked(cwd, repoRelativePath))) {
|
|
1495
|
+
return synthesizeUntrackedSideDiff(cwd, repoRelativePath, isToCurrent ? 'added' : 'removed');
|
|
1496
|
+
}
|
|
1497
|
+
const args = ['diff', '--no-color'];
|
|
1498
|
+
if (isToCurrent && !isFromCurrent) {
|
|
1499
|
+
// `git diff <ref>` compares ref → working tree, with the working
|
|
1500
|
+
// tree's contents shown as `+`. That's exactly the +/- direction
|
|
1501
|
+
// we want when "to" is the current branch.
|
|
1502
|
+
args.push(fromRef);
|
|
1503
|
+
}
|
|
1504
|
+
else if (isFromCurrent && !isToCurrent) {
|
|
1505
|
+
// Reverse so the +/- direction matches "from current → to ref":
|
|
1506
|
+
// ref's contents show as `+`, working tree shows as `-`.
|
|
1507
|
+
args.push('-R', toRef);
|
|
1508
|
+
}
|
|
1509
|
+
else {
|
|
1510
|
+
// Neither side is the current branch — pure commit-vs-commit.
|
|
1511
|
+
// Two-dot, not three-dot: three-dot would be merge-base semantics
|
|
1512
|
+
// (PR-style) which would hide changes on the from-side since the
|
|
1513
|
+
// fork point — wrong for "compare these two versions right now".
|
|
1514
|
+
args.push(fromRef, toRef);
|
|
1515
|
+
}
|
|
1516
|
+
args.push('--', repoRelativePath);
|
|
1517
|
+
try {
|
|
1518
|
+
const { stdout } = await this.git(cwd, args);
|
|
1519
|
+
return stdout;
|
|
1520
|
+
}
|
|
1521
|
+
catch (err) {
|
|
1522
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1523
|
+
throw new Error(`Failed to diff "${relativePath}" between "${fromBranch}" and "${toBranch}": ${msg}`, { cause: err });
|
|
1524
|
+
}
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* Head/base commit SHAs for a change request, computed locally — no provider
|
|
1529
|
+
* API. `head` is the source branch tip, `base` the target branch tip, both
|
|
1530
|
+
* resolved on `origin/*` after a fetch so a force-push is reflected. These are
|
|
1531
|
+
* what approvals pin against and what the diff range is built from.
|
|
1532
|
+
*/
|
|
1533
|
+
async resolvePrShas(workspaceId, baseBranch, headBranch) {
|
|
1534
|
+
assertValidBranchName(baseBranch);
|
|
1535
|
+
assertValidBranchName(headBranch);
|
|
1536
|
+
// Fetch (a network round-trip) BEFORE taking the mutex so a slow origin can't
|
|
1537
|
+
// block unrelated local git ops on this workspace; the lock guards only the
|
|
1538
|
+
// local ref resolution + rev-parse below.
|
|
1539
|
+
const cwd = await this.repoDir(workspaceId);
|
|
1540
|
+
await this.fetchPrRefs(cwd, baseBranch, headBranch);
|
|
1541
|
+
return this.mutex.run(workspaceId, async () => {
|
|
1542
|
+
const baseRef = await this.resolveBranchRef(cwd, baseBranch);
|
|
1543
|
+
const headRef = await this.resolveBranchRef(cwd, headBranch);
|
|
1544
|
+
const [{ stdout: baseSha }, { stdout: headSha }] = await Promise.all([
|
|
1545
|
+
this.git(cwd, ['rev-parse', baseRef]),
|
|
1546
|
+
this.git(cwd, ['rev-parse', headRef]),
|
|
1547
|
+
]);
|
|
1548
|
+
return { baseSha: baseSha.trim(), headSha: headSha.trim() };
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* The changed-file list for a change request, computed locally via three-dot
|
|
1553
|
+
* (merge-base) diff — the same semantics a GitHub PR shows. Replaces the
|
|
1554
|
+
* `gh api …/pulls/:n/files` call so any git host works.
|
|
1555
|
+
*
|
|
1556
|
+
* `--name-status` + `--numstat` (both rename-aware via `-M`) drive the file
|
|
1557
|
+
* list, statuses, and +/- counts; per-file patches are generated for up to
|
|
1558
|
+
* `patchCap` files (default 400) — beyond that `patch` is left undefined,
|
|
1559
|
+
* exactly like the old API's binary/oversized files (the UI renders those with
|
|
1560
|
+
* no inline diff). Patch generation is skipped for binary files.
|
|
1561
|
+
*/
|
|
1562
|
+
async changedFilesForPr(workspaceId, baseBranch, headBranch, opts = {}) {
|
|
1563
|
+
assertValidBranchName(baseBranch);
|
|
1564
|
+
assertValidBranchName(headBranch);
|
|
1565
|
+
const patchCap = opts.patchCap ?? 400;
|
|
1566
|
+
// Fetch outside the mutex (network round-trip) so origin latency can't hold
|
|
1567
|
+
// the workspace lock; the lock guards only the local diff work below.
|
|
1568
|
+
const cwd = await this.repoDir(workspaceId);
|
|
1569
|
+
await this.fetchPrRefs(cwd, baseBranch, headBranch);
|
|
1570
|
+
return this.mutex.run(workspaceId, async () => {
|
|
1571
|
+
const baseRef = await this.resolveBranchRef(cwd, baseBranch);
|
|
1572
|
+
const headRef = await this.resolveBranchRef(cwd, headBranch);
|
|
1573
|
+
const range = `${baseRef}...${headRef}`; // three-dot = changes on head since merge-base
|
|
1574
|
+
const [{ stdout: nameStatusOut }, { stdout: numstatOut }] = await Promise.all([
|
|
1575
|
+
this.git(cwd, ['diff', '-M', '-z', '--name-status', range]),
|
|
1576
|
+
this.git(cwd, ['diff', '-M', '-z', '--numstat', range]),
|
|
1577
|
+
]);
|
|
1578
|
+
const statuses = parseNameStatusZ(nameStatusOut);
|
|
1579
|
+
const counts = parseNumstatZ(numstatOut);
|
|
1580
|
+
// `--name-status` and `--numstat` enumerate the same files in the same
|
|
1581
|
+
// order (same `-M` over the same range), so we zip by index. If the two
|
|
1582
|
+
// ever disagree in length, the index alignment is unsafe — fall back to
|
|
1583
|
+
// zeroed counts (statuses/paths stay correct) rather than pin the wrong
|
|
1584
|
+
// +/- to a file.
|
|
1585
|
+
const aligned = counts.length === statuses.length;
|
|
1586
|
+
if (!aligned) {
|
|
1587
|
+
console.warn(`[cr] diff name-status/numstat length mismatch (${statuses.length} vs ${counts.length}) ` +
|
|
1588
|
+
`for ${range} — reporting file list without +/- counts`);
|
|
1589
|
+
}
|
|
1590
|
+
const files = statuses.map((s, i) => {
|
|
1591
|
+
const c = (aligned ? counts[i] : undefined) ?? { additions: 0, deletions: 0, isBinary: false };
|
|
1592
|
+
return {
|
|
1593
|
+
path: s.path,
|
|
1594
|
+
previousPath: s.previousPath,
|
|
1595
|
+
status: s.status,
|
|
1596
|
+
additions: c.additions,
|
|
1597
|
+
deletions: c.deletions,
|
|
1598
|
+
patch: undefined,
|
|
1599
|
+
isBinary: c.isBinary,
|
|
1600
|
+
sha: '',
|
|
1601
|
+
rawUrl: '',
|
|
1602
|
+
};
|
|
1603
|
+
});
|
|
1604
|
+
// Generate per-file patches for the first `patchCap` non-binary files.
|
|
1605
|
+
let generated = 0;
|
|
1606
|
+
for (const f of files) {
|
|
1607
|
+
if (generated >= patchCap)
|
|
1608
|
+
break;
|
|
1609
|
+
if (f.isBinary)
|
|
1610
|
+
continue;
|
|
1611
|
+
f.patch = await this.filePatchForPr(cwd, range, f);
|
|
1612
|
+
generated += 1;
|
|
1613
|
+
}
|
|
1614
|
+
return files;
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
/**
|
|
1618
|
+
* Just the repo-relative paths changed by a change request (three-dot), no
|
|
1619
|
+
* patches — the cheap version used to build CR-list summaries and owner
|
|
1620
|
+
* routing. `changedFilesForPr` is the full version with statuses + diffs.
|
|
1621
|
+
*/
|
|
1622
|
+
async changedPathsForPr(workspaceId, baseBranch, headBranch) {
|
|
1623
|
+
assertValidBranchName(baseBranch);
|
|
1624
|
+
assertValidBranchName(headBranch);
|
|
1625
|
+
// Fetch outside the mutex (network round-trip) so origin latency can't hold
|
|
1626
|
+
// the workspace lock; the lock guards only the local diff below.
|
|
1627
|
+
const cwd = await this.repoDir(workspaceId);
|
|
1628
|
+
await this.fetchPrRefs(cwd, baseBranch, headBranch);
|
|
1629
|
+
return this.mutex.run(workspaceId, async () => {
|
|
1630
|
+
const baseRef = await this.resolveBranchRef(cwd, baseBranch);
|
|
1631
|
+
const headRef = await this.resolveBranchRef(cwd, headBranch);
|
|
1632
|
+
const { stdout } = await this.git(cwd, [
|
|
1633
|
+
'diff', '-M', '--name-only', `${baseRef}...${headRef}`,
|
|
1634
|
+
]);
|
|
1635
|
+
return stdout.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
/** Fetch the two branches a CR spans so origin refs reflect the latest push. */
|
|
1639
|
+
async fetchPrRefs(cwd, baseBranch, headBranch) {
|
|
1640
|
+
// Best-effort: a branch may be local-only (rare) or origin briefly
|
|
1641
|
+
// unreachable; `resolveBranchRef` still falls back to whatever is local.
|
|
1642
|
+
// `--no-write-fetch-head` — this runs on the CR-detail path (polled around
|
|
1643
|
+
// every merge) and only the `origin/<branch>` refs are read; writing two
|
|
1644
|
+
// for-merge FETCH_HEAD entries here can break a concurrent refresh in the
|
|
1645
|
+
// same clone (see `pull`).
|
|
1646
|
+
//
|
|
1647
|
+
// Explicit destination refspecs for the same reason as `mergeChangeRequest`:
|
|
1648
|
+
// "reflect the latest push" is exactly what a drifted `remote.origin.fetch`
|
|
1649
|
+
// silently stops delivering, and the CR diff would then be computed against
|
|
1650
|
+
// a stale head. Every caller `assertValidBranchName`s both branches.
|
|
1651
|
+
await this.git(cwd, [
|
|
1652
|
+
'fetch', '--no-write-fetch-head', 'origin',
|
|
1653
|
+
`+refs/heads/${baseBranch}:refs/remotes/origin/${baseBranch}`,
|
|
1654
|
+
`+refs/heads/${headBranch}:refs/remotes/origin/${headBranch}`,
|
|
1655
|
+
]).catch(() => undefined);
|
|
1656
|
+
}
|
|
1657
|
+
/** Unified-diff patch for one file across the CR range (rename-aware). */
|
|
1658
|
+
async filePatchForPr(cwd, range, file) {
|
|
1659
|
+
// For a rename, pass both endpoints so `-M` renders it as a rename patch;
|
|
1660
|
+
// otherwise the single (destination, or base-side for deletes) path.
|
|
1661
|
+
const pathspec = file.previousPath && file.previousPath !== file.path
|
|
1662
|
+
? ['--', file.previousPath, file.path]
|
|
1663
|
+
: ['--', file.path];
|
|
1664
|
+
try {
|
|
1665
|
+
const { stdout } = await this.git(cwd, ['diff', '-M', '--no-color', range, ...pathspec]);
|
|
1666
|
+
return stdout.length > 0 ? stdout : undefined;
|
|
1667
|
+
}
|
|
1668
|
+
catch {
|
|
1669
|
+
// A patch failure is cosmetic — the file still shows in the list.
|
|
1670
|
+
return undefined;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
// `workingStatus` + `diffFileWorking` removed: both reported the dirty
|
|
1674
|
+
// state of a workspace's working tree, which under save=share is always
|
|
1675
|
+
// empty. The frontend used these to render a "pending uncommitted edits"
|
|
1676
|
+
// panel and per-file diff — neither has a meaningful state to show
|
|
1677
|
+
// anymore. `diffFileBetweenBranches` + `diffFileAtCommit` still cover
|
|
1678
|
+
// the meaningful diff cases (cross-branch comparisons and history).
|
|
1679
|
+
/**
|
|
1680
|
+
* Strip the `knowledge-base/` prefix from a workspace-relative path so
|
|
1681
|
+
* it lines up with where git actually runs (`cwd = repoDir`, i.e. inside
|
|
1682
|
+
* `knowledge-base/`). The frontend stores `openFilePath` as a
|
|
1683
|
+
* workspace-relative path including that prefix because the file viewer
|
|
1684
|
+
* reads/writes through `WorkspaceService.readFile`, which resolves from
|
|
1685
|
+
* the workspace root. Git pathspecs, on the other hand, are resolved from
|
|
1686
|
+
* cwd — so without this normalization `git diff -- knowledge-base/X`
|
|
1687
|
+
* silently looks for `knowledge-base/knowledge-base/X` and returns
|
|
1688
|
+
* nothing, which the UI renders as "identical".
|
|
1689
|
+
*
|
|
1690
|
+
* No-op for paths already repo-relative (the agent tools and tests pass
|
|
1691
|
+
* those directly).
|
|
1692
|
+
*/
|
|
1693
|
+
stripRepoPrefix(relativePath) {
|
|
1694
|
+
return relativePath.startsWith(`${this.kbDirName}/`)
|
|
1695
|
+
? relativePath.slice(this.kbDirName.length + 1)
|
|
1696
|
+
: relativePath;
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* Resolve a branch name to a concrete ref the local clone knows about.
|
|
1700
|
+
* Tries the local head first, falls back to the matching remote-tracking
|
|
1701
|
+
* ref. The workspace clone fetches all remotes on creation, so protected
|
|
1702
|
+
* branches are reachable through `origin/` even when the user has never
|
|
1703
|
+
* personally checked them out.
|
|
1704
|
+
*/
|
|
1705
|
+
async resolveBranchRef(cwd, branch) {
|
|
1706
|
+
for (const ref of [`refs/heads/${branch}`, `refs/remotes/origin/${branch}`]) {
|
|
1707
|
+
try {
|
|
1708
|
+
await this.git(cwd, ['rev-parse', '--verify', '--quiet', ref]);
|
|
1709
|
+
return ref;
|
|
1710
|
+
}
|
|
1711
|
+
catch {
|
|
1712
|
+
// Try the next candidate.
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
throw new WorkflowValidationError(`unknown branch: ${branch}`);
|
|
1716
|
+
}
|
|
1717
|
+
// ── internals ──────────────────────────────────────────────────────────
|
|
1718
|
+
async statusInternal(workspaceId) {
|
|
1719
|
+
const cwd = await this.repoDir(workspaceId);
|
|
1720
|
+
const branch = await this.currentBranch(cwd);
|
|
1721
|
+
// Defensive sanity check. Under save=share a dirty working tree has two
|
|
1722
|
+
// very different meanings:
|
|
1723
|
+
// - Rows exist in `pending_commits` for this workspace → EXPECTED. The
|
|
1724
|
+
// saves are on disk and their commits are still draining through the
|
|
1725
|
+
// background worker (a bulk write can queue hundreds of rows, taking
|
|
1726
|
+
// minutes). Not a bug — log quietly for observability.
|
|
1727
|
+
// - No queued rows → files are dirty with nothing scheduled to commit
|
|
1728
|
+
// them: an upstream bug (lock-release crashed mid-write, an external
|
|
1729
|
+
// `git` invocation left state behind, etc.). Warn loudly.
|
|
1730
|
+
// Either way it's never surfaced to the user: the structured status no
|
|
1731
|
+
// longer carries an isDirty bit, which removes the only path by which a
|
|
1732
|
+
// transient working-tree blip used to leak into a user-facing 409.
|
|
1733
|
+
const porcelain = await this.git(cwd, ['status', '--porcelain=v1']);
|
|
1734
|
+
if (porcelain.stdout.trim().length > 0) {
|
|
1735
|
+
let queueExplainsIt = false;
|
|
1736
|
+
if (this.pendingCommitsProbe) {
|
|
1737
|
+
try {
|
|
1738
|
+
queueExplainsIt = await this.pendingCommitsProbe(workspaceId);
|
|
1739
|
+
}
|
|
1740
|
+
catch {
|
|
1741
|
+
// Probe failure (DB blip) must not break status — fall through to
|
|
1742
|
+
// the loud warning, which is the safe-side default.
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
const dirtyList = porcelain.stdout.trim().replace(/\s+/g, ' ');
|
|
1746
|
+
if (queueExplainsIt) {
|
|
1747
|
+
console.log(`[git] workspace=${workspaceId} branch=${branch} working tree is dirty while ` +
|
|
1748
|
+
`pending commits drain (expected — the background worker is catching up). ` +
|
|
1749
|
+
`Files: ${dirtyList}`);
|
|
1750
|
+
}
|
|
1751
|
+
else {
|
|
1752
|
+
console.warn(`[git] workspace=${workspaceId} branch=${branch} has a non-clean working ` +
|
|
1753
|
+
`tree under save=share with NO queued pending commits — this should never ` +
|
|
1754
|
+
`happen and likely indicates a missed lock-release commit. Files: ${dirtyList}`);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
let hasUpstream = true;
|
|
1758
|
+
let unmergedFromUpstream = false;
|
|
1759
|
+
try {
|
|
1760
|
+
// We don't expose `unpushedCommits` anymore — under save=share every
|
|
1761
|
+
// commit is auto-pushed on lock release, so ahead is always 0. We only
|
|
1762
|
+
// care about "is origin ahead of us?" for the auto-pull driver.
|
|
1763
|
+
const behind = await this.git(cwd, ['rev-list', '--count', 'HEAD..@{u}']);
|
|
1764
|
+
unmergedFromUpstream = (parseInt(behind.stdout.trim(), 10) || 0) > 0;
|
|
1765
|
+
}
|
|
1766
|
+
catch {
|
|
1767
|
+
// Branch has never been published — nothing upstream to compare against.
|
|
1768
|
+
// Surfaces via `hasUpstream` so the UI can flag a never-shared draft.
|
|
1769
|
+
hasUpstream = false;
|
|
1770
|
+
}
|
|
1771
|
+
return { branch, hasUpstream, unmergedFromUpstream };
|
|
1772
|
+
}
|
|
1773
|
+
async currentBranch(cwd) {
|
|
1774
|
+
const { stdout } = await this.git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
1775
|
+
return stdout.trim();
|
|
1776
|
+
}
|
|
1777
|
+
/**
|
|
1778
|
+
* Ahead/behind of `ref` (a full ref like `refs/heads/foo` or
|
|
1779
|
+
* `refs/remotes/origin/foo`). When the branch exists locally (`hasLocal`),
|
|
1780
|
+
* prefer its configured upstream; otherwise — or when no upstream is set —
|
|
1781
|
+
* fall back to the nearest protected branch on origin, picking whichever
|
|
1782
|
+
* minimises "ahead" as a proxy for the fork point.
|
|
1783
|
+
*/
|
|
1784
|
+
async aheadBehindForBranch(cwd, shortName, ref, hasLocal) {
|
|
1785
|
+
if (hasLocal) {
|
|
1786
|
+
const upstream = await this.tryAheadBehind(cwd, ref, `${shortName}@{upstream}`);
|
|
1787
|
+
if (upstream)
|
|
1788
|
+
return upstream;
|
|
1789
|
+
}
|
|
1790
|
+
let best = null;
|
|
1791
|
+
for (const p of PROTECTED_BRANCHES) {
|
|
1792
|
+
if (p === shortName)
|
|
1793
|
+
continue;
|
|
1794
|
+
const res = await this.tryAheadBehind(cwd, ref, `refs/remotes/origin/${p}`);
|
|
1795
|
+
if (res && (best === null || res.ahead < best.ahead))
|
|
1796
|
+
best = res;
|
|
1797
|
+
}
|
|
1798
|
+
return best ?? { ahead: null, behind: null };
|
|
1799
|
+
}
|
|
1800
|
+
async tryAheadBehind(cwd, branch, base) {
|
|
1801
|
+
try {
|
|
1802
|
+
const { stdout } = await this.git(cwd, [
|
|
1803
|
+
'rev-list',
|
|
1804
|
+
'--left-right',
|
|
1805
|
+
'--count',
|
|
1806
|
+
`${base}...${branch}`,
|
|
1807
|
+
]);
|
|
1808
|
+
const [behind, ahead] = stdout.trim().split(/\s+/).map((n) => parseInt(n, 10) || 0);
|
|
1809
|
+
return { ahead, behind };
|
|
1810
|
+
}
|
|
1811
|
+
catch {
|
|
1812
|
+
return null;
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
/**
|
|
1816
|
+
* Read a repo-relative file's contents at an arbitrary git ref (a branch,
|
|
1817
|
+
* `origin/<branch>`, or a sha) WITHOUT checking it out. Returns `null` when
|
|
1818
|
+
* the path does not exist at that ref (mirrors the access resolver's
|
|
1819
|
+
* `showAtRef`) — the caller distinguishes "absent" from "present but empty".
|
|
1820
|
+
* Read-only: `git show <ref>:<path>` never mutates the working tree.
|
|
1821
|
+
*/
|
|
1822
|
+
async readFileAtRef(workspaceId, ref, repoRelativePath) {
|
|
1823
|
+
const cwd = await this.repoDir(workspaceId);
|
|
1824
|
+
try {
|
|
1825
|
+
const { stdout } = await this.git(cwd, ['show', `${ref}:${repoRelativePath}`]);
|
|
1826
|
+
return stdout;
|
|
1827
|
+
}
|
|
1828
|
+
catch (err) {
|
|
1829
|
+
// ONLY "the path doesn't exist at this ref" maps to null. `git show
|
|
1830
|
+
// <ref>:<path>` reports that as `fatal: path '<p>' does not exist in
|
|
1831
|
+
// '<ref>'` (or `... exists on disk, but not in '<ref>'`). Any other
|
|
1832
|
+
// failure — an unresolvable ref, a corrupt object, a repo/IO error —
|
|
1833
|
+
// must PROPAGATE, not be misread as "absent": callers like
|
|
1834
|
+
// `preserveBaseRolesYaml` compare base-vs-head content and rely on the
|
|
1835
|
+
// absent/present distinction being truthful, so a swallowed error
|
|
1836
|
+
// (both refs → null → "identical") would fail OPEN. Fail closed instead.
|
|
1837
|
+
const stderr = err.stderr ?? (err instanceof Error ? err.message : String(err));
|
|
1838
|
+
if (/does not exist in|exists on disk, but not in/.test(stderr))
|
|
1839
|
+
return null;
|
|
1840
|
+
throw err;
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
async repoDir(workspaceId) {
|
|
1844
|
+
return path.join(await this.workspaceService.getWorkspacePath(workspaceId), this.kbDirName);
|
|
1845
|
+
}
|
|
1846
|
+
async isPathUntracked(cwd, relativePath) {
|
|
1847
|
+
const { stdout } = await this.git(cwd, [
|
|
1848
|
+
'ls-files',
|
|
1849
|
+
'--others',
|
|
1850
|
+
'--exclude-standard',
|
|
1851
|
+
'--',
|
|
1852
|
+
relativePath,
|
|
1853
|
+
]);
|
|
1854
|
+
return stdout.trim().length > 0;
|
|
1855
|
+
}
|
|
1856
|
+
async git(cwd, args) {
|
|
1857
|
+
try {
|
|
1858
|
+
const { stdout, stderr } = await execFileAsync('git', args, {
|
|
1859
|
+
cwd,
|
|
1860
|
+
// `GIT_LITERAL_PATHSPECS=1` makes git treat every pathspec literally
|
|
1861
|
+
// instead of interpreting `[`, `]`, `*`, `?`, `!`, or `:(magic)` as
|
|
1862
|
+
// glob / magic syntax. KB files routinely arrive with bracketed
|
|
1863
|
+
// prefixes like `[Approved] foo.docx` or `[Updated 2025] bar.md`; the
|
|
1864
|
+
// upload pipeline (`writeFileBinary` + `releaseLock` + `commitFile`)
|
|
1865
|
+
// passes the relative path straight through to `git add` / `git
|
|
1866
|
+
// checkout -- <path>`, which would otherwise glob and either match the
|
|
1867
|
+
// wrong file or no file at all. Setting it once here covers every git
|
|
1868
|
+
// subprocess this service spawns.
|
|
1869
|
+
//
|
|
1870
|
+
// `LC_ALL=C` / `LANG=C` force git's human-readable output (including
|
|
1871
|
+
// stderr) to stable, English, locale-independent text. Callers that
|
|
1872
|
+
// classify errors by message — e.g. `readFileAtRef` distinguishing a
|
|
1873
|
+
// "path does not exist in <ref>" absence from a hard failure — would
|
|
1874
|
+
// otherwise misread a translated message on a non-English host and, for
|
|
1875
|
+
// the fail-closed roles.yaml preservation path, fail OPEN.
|
|
1876
|
+
env: { ...process.env, GIT_LITERAL_PATHSPECS: '1', LC_ALL: 'C', LANG: 'C' },
|
|
1877
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
1878
|
+
});
|
|
1879
|
+
return { stdout: stdout.toString(), stderr: stderr.toString() };
|
|
1880
|
+
}
|
|
1881
|
+
catch (err) {
|
|
1882
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1883
|
+
// Skip past any leading `-c key=val` pairs so the error names the actual
|
|
1884
|
+
// git subcommand that failed (e.g. "git fetch failed:" not "git -c failed:").
|
|
1885
|
+
let i = 0;
|
|
1886
|
+
while (i < args.length && args[i] === '-c')
|
|
1887
|
+
i += 2;
|
|
1888
|
+
const subcommand = args[i] ?? args[0];
|
|
1889
|
+
const wrapped = new Error(`git ${subcommand} failed: ${redact(msg)}`);
|
|
1890
|
+
// Preserve the underlying exit code / stderr so callers can distinguish
|
|
1891
|
+
// expected non-zero exits (e.g. merge-base exit 1 = no common ancestor)
|
|
1892
|
+
// from infra failures (ENOENT, exit 128) without parsing message strings.
|
|
1893
|
+
const original = err;
|
|
1894
|
+
if (typeof original.code === 'number')
|
|
1895
|
+
wrapped.exitCode = original.code;
|
|
1896
|
+
if (typeof original.stderr === 'string')
|
|
1897
|
+
wrapped.stderr = redact(original.stderr);
|
|
1898
|
+
throw wrapped;
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
/** Map a git status letter (`git diff --name-status`) to the DTO's `PrFileStatus`. */
|
|
1903
|
+
function mapGitStatus(letter) {
|
|
1904
|
+
switch (letter[0]) {
|
|
1905
|
+
case 'A': return 'added';
|
|
1906
|
+
case 'D': return 'removed';
|
|
1907
|
+
case 'R': return 'renamed';
|
|
1908
|
+
case 'C': return 'copied';
|
|
1909
|
+
case 'T': return 'changed'; // type change (e.g. file ↔ symlink)
|
|
1910
|
+
case 'M':
|
|
1911
|
+
default: return 'modified';
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
/**
|
|
1915
|
+
* Parse `git diff -M -z --name-status` output. NUL-separated tokens: each record
|
|
1916
|
+
* is `<statusLetter>\0<path>`, or for a rename/copy `<Rxxx|Cxxx>\0<old>\0<new>`.
|
|
1917
|
+
*/
|
|
1918
|
+
export function parseNameStatusZ(out) {
|
|
1919
|
+
const tokens = out.split('\0');
|
|
1920
|
+
const entries = [];
|
|
1921
|
+
let i = 0;
|
|
1922
|
+
while (i < tokens.length) {
|
|
1923
|
+
const raw = tokens[i];
|
|
1924
|
+
if (!raw) {
|
|
1925
|
+
i += 1;
|
|
1926
|
+
continue;
|
|
1927
|
+
} // trailing empty token after the final NUL
|
|
1928
|
+
const letter = raw[0];
|
|
1929
|
+
if (letter === 'R' || letter === 'C') {
|
|
1930
|
+
const previousPath = tokens[i + 1];
|
|
1931
|
+
const path = tokens[i + 2];
|
|
1932
|
+
if (path === undefined)
|
|
1933
|
+
break; // malformed tail — stop rather than throw
|
|
1934
|
+
entries.push({ status: mapGitStatus(letter), path, previousPath });
|
|
1935
|
+
i += 3;
|
|
1936
|
+
}
|
|
1937
|
+
else {
|
|
1938
|
+
const path = tokens[i + 1];
|
|
1939
|
+
if (path === undefined)
|
|
1940
|
+
break;
|
|
1941
|
+
entries.push({ status: mapGitStatus(letter), path });
|
|
1942
|
+
i += 2;
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
return entries;
|
|
1946
|
+
}
|
|
1947
|
+
/**
|
|
1948
|
+
* Parse `git diff -M -z --numstat` output, in the SAME file order as
|
|
1949
|
+
* `--name-status` (both driven by `-M` over the same range). Each record is
|
|
1950
|
+
* `<add>\t<del>\t<path>` for a normal change, or `<add>\t<del>\t\0<old>\0<new>`
|
|
1951
|
+
* for a rename (empty path segment before the two NUL-separated names). Binary
|
|
1952
|
+
* files report `-` for both counts.
|
|
1953
|
+
*/
|
|
1954
|
+
export function parseNumstatZ(out) {
|
|
1955
|
+
const tokens = out.split('\0');
|
|
1956
|
+
const entries = [];
|
|
1957
|
+
let i = 0;
|
|
1958
|
+
while (i < tokens.length) {
|
|
1959
|
+
const raw = tokens[i];
|
|
1960
|
+
if (!raw) {
|
|
1961
|
+
i += 1;
|
|
1962
|
+
continue;
|
|
1963
|
+
}
|
|
1964
|
+
const m = raw.match(/^(-|\d+)\t(-|\d+)\t(.*)$/s);
|
|
1965
|
+
if (!m) {
|
|
1966
|
+
i += 1;
|
|
1967
|
+
continue;
|
|
1968
|
+
}
|
|
1969
|
+
const isBinary = m[1] === '-' && m[2] === '-';
|
|
1970
|
+
const entry = {
|
|
1971
|
+
additions: m[1] === '-' ? 0 : Number(m[1]),
|
|
1972
|
+
deletions: m[2] === '-' ? 0 : Number(m[2]),
|
|
1973
|
+
isBinary,
|
|
1974
|
+
};
|
|
1975
|
+
if (m[3] === '') {
|
|
1976
|
+
// Rename: the two names follow as separate NUL-terminated tokens.
|
|
1977
|
+
i += 3;
|
|
1978
|
+
}
|
|
1979
|
+
else {
|
|
1980
|
+
i += 1;
|
|
1981
|
+
}
|
|
1982
|
+
entries.push(entry);
|
|
1983
|
+
}
|
|
1984
|
+
return entries;
|
|
1985
|
+
}
|
|
1986
|
+
export { PROTECTED_BRANCHES };
|
|
1987
|
+
//# sourceMappingURL=git.service.js.map
|