terramend 0.2.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 +661 -0
- package/README.md +145 -0
- package/dist/agents/claude.d.ts +73 -0
- package/dist/agents/claudePretoolGate.d.ts +99 -0
- package/dist/agents/gateServer.d.ts +7 -0
- package/dist/agents/index.d.ts +6 -0
- package/dist/agents/nativeFsDenies.d.ts +28 -0
- package/dist/agents/opencode.d.ts +231 -0
- package/dist/agents/opencodePlugin.d.ts +85 -0
- package/dist/agents/opencodeShared.d.ts +40 -0
- package/dist/agents/postRun.d.ts +132 -0
- package/dist/agents/reviewer.d.ts +38 -0
- package/dist/agents/sessionLabeler.d.ts +97 -0
- package/dist/agents/shared.d.ts +189 -0
- package/dist/agents/subagentModels.d.ts +19 -0
- package/dist/agents/subagentToolGates.d.ts +55 -0
- package/dist/cli.mjs +197426 -0
- package/dist/external.d.ts +227 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +196783 -0
- package/dist/internal/index.d.ts +18 -0
- package/dist/internal.js +1714 -0
- package/dist/lifecycle.d.ts +2 -0
- package/dist/main.d.ts +8 -0
- package/dist/mcp/arkConfig.d.ts +1 -0
- package/dist/mcp/checkSuite.d.ts +25 -0
- package/dist/mcp/checkout.d.ts +77 -0
- package/dist/mcp/comment.d.ts +119 -0
- package/dist/mcp/commitInfo.d.ts +9 -0
- package/dist/mcp/crosswalk.d.ts +105 -0
- package/dist/mcp/dependencies.d.ts +8 -0
- package/dist/mcp/geminiSanitizer.d.ts +28 -0
- package/dist/mcp/git.d.ts +46 -0
- package/dist/mcp/guardrails.d.ts +104 -0
- package/dist/mcp/issue.d.ts +18 -0
- package/dist/mcp/issueComments.d.ts +9 -0
- package/dist/mcp/issueEvents.d.ts +9 -0
- package/dist/mcp/issueInfo.d.ts +9 -0
- package/dist/mcp/labels.d.ts +12 -0
- package/dist/mcp/localContext.d.ts +19 -0
- package/dist/mcp/moduleExtraction.d.ts +71 -0
- package/dist/mcp/moduleTests.d.ts +104 -0
- package/dist/mcp/modules.d.ts +179 -0
- package/dist/mcp/output.d.ts +12 -0
- package/dist/mcp/pathSafety.d.ts +14 -0
- package/dist/mcp/policy.d.ts +48 -0
- package/dist/mcp/pr.d.ts +49 -0
- package/dist/mcp/prInfo.d.ts +9 -0
- package/dist/mcp/providerSchema.d.ts +50 -0
- package/dist/mcp/review.d.ts +199 -0
- package/dist/mcp/reviewComments.d.ts +178 -0
- package/dist/mcp/roots.d.ts +58 -0
- package/dist/mcp/scope.d.ts +15 -0
- package/dist/mcp/selectMode.d.ts +18 -0
- package/dist/mcp/server.d.ts +48 -0
- package/dist/mcp/shared.d.ts +47 -0
- package/dist/mcp/shell.d.ts +37 -0
- package/dist/mcp/staleFix.d.ts +51 -0
- package/dist/mcp/terraform/cost.d.ts +55 -0
- package/dist/mcp/terraform/currency.d.ts +94 -0
- package/dist/mcp/terraform/decisions.d.ts +178 -0
- package/dist/mcp/terraform/findings.d.ts +75 -0
- package/dist/mcp/terraform/plan.d.ts +157 -0
- package/dist/mcp/terraform/scanners.d.ts +131 -0
- package/dist/mcp/terraform/tools.d.ts +63 -0
- package/dist/mcp/terraform/types.d.ts +172 -0
- package/dist/mcp/terraform.d.ts +22 -0
- package/dist/mcp/terratest.d.ts +83 -0
- package/dist/mcp/upload.d.ts +6 -0
- package/dist/models.d.ts +171 -0
- package/dist/modes.d.ts +26 -0
- package/dist/prep/index.d.ts +7 -0
- package/dist/prep/installNodeDependencies.d.ts +2 -0
- package/dist/prep/installPythonDependencies.d.ts +2 -0
- package/dist/prep/types.d.ts +31 -0
- package/dist/reviewQuality.d.ts +64 -0
- package/dist/skills/terraform-best-practices/SKILL.md +369 -0
- package/dist/toolState.d.ts +135 -0
- package/dist/utils/activity.d.ts +40 -0
- package/dist/utils/agent.d.ts +20 -0
- package/dist/utils/agentHangReport.d.ts +38 -0
- package/dist/utils/apiFetch.d.ts +19 -0
- package/dist/utils/apiKeys.d.ts +41 -0
- package/dist/utils/apiUrl.d.ts +20 -0
- package/dist/utils/assets.d.ts +8 -0
- package/dist/utils/billingErrors.d.ts +85 -0
- package/dist/utils/body.d.ts +34 -0
- package/dist/utils/buildTerramendFooter.d.ts +25 -0
- package/dist/utils/byokFallback.d.ts +85 -0
- package/dist/utils/claudeSubscription.d.ts +30 -0
- package/dist/utils/cli.d.ts +10 -0
- package/dist/utils/codexHome.d.ts +29 -0
- package/dist/utils/codexOAuth.d.ts +60 -0
- package/dist/utils/diffCoverage.d.ts +63 -0
- package/dist/utils/errorReport.d.ts +17 -0
- package/dist/utils/exitHandler.d.ts +8 -0
- package/dist/utils/fixDoubleEscapedString.d.ts +1 -0
- package/dist/utils/gitAuth.d.ts +84 -0
- package/dist/utils/gitAuthServer.d.ts +24 -0
- package/dist/utils/github.d.ts +78 -0
- package/dist/utils/globals.d.ts +3 -0
- package/dist/utils/install.d.ts +60 -0
- package/dist/utils/instructions.d.ts +48 -0
- package/dist/utils/leapingComment.d.ts +11 -0
- package/dist/utils/learnings.d.ts +62 -0
- package/dist/utils/learningsTruncate.d.ts +25 -0
- package/dist/utils/lifecycle.d.ts +57 -0
- package/dist/utils/log.d.ts +111 -0
- package/dist/utils/normalizeEnv.d.ts +30 -0
- package/dist/utils/openCodeModels.d.ts +11 -0
- package/dist/utils/overrides.d.ts +40 -0
- package/dist/utils/packageManager.d.ts +49 -0
- package/dist/utils/patchWorkflowRunFields.d.ts +29 -0
- package/dist/utils/payload.d.ts +105 -0
- package/dist/utils/prSummary.d.ts +61 -0
- package/dist/utils/progressComment.d.ts +146 -0
- package/dist/utils/providerErrors.d.ts +31 -0
- package/dist/utils/rangeDiff.d.ts +51 -0
- package/dist/utils/remediationCommand.d.ts +55 -0
- package/dist/utils/retry.d.ts +13 -0
- package/dist/utils/reviewCleanup.d.ts +14 -0
- package/dist/utils/run.d.ts +9 -0
- package/dist/utils/runContext.d.ts +60 -0
- package/dist/utils/runContextData.d.ts +23 -0
- package/dist/utils/runErrorRenderer.d.ts +64 -0
- package/dist/utils/runLifecycle.d.ts +86 -0
- package/dist/utils/runStartupLog.d.ts +15 -0
- package/dist/utils/secrets.d.ts +22 -0
- package/dist/utils/setup.d.ts +90 -0
- package/dist/utils/shell.d.ts +32 -0
- package/dist/utils/skills.d.ts +10 -0
- package/dist/utils/subprocess.d.ts +80 -0
- package/dist/utils/terraformMcp.d.ts +42 -0
- package/dist/utils/time.d.ts +15 -0
- package/dist/utils/timer.d.ts +23 -0
- package/dist/utils/todoTracking.d.ts +16 -0
- package/dist/utils/token.d.ts +39 -0
- package/dist/utils/version.d.ts +2 -0
- package/dist/utils/versioning.d.ts +7 -0
- package/dist/utils/vertex.d.ts +16 -0
- package/dist/utils/workflow.d.ts +13 -0
- package/package.json +119 -0
- package/src/agents/claude.test.ts +1016 -0
- package/src/agents/claude.ts +1246 -0
- package/src/agents/claudePretoolGate.test.ts +28 -0
- package/src/agents/claudePretoolGate.ts +173 -0
- package/src/agents/gateServer.test.ts +204 -0
- package/src/agents/gateServer.ts +124 -0
- package/src/agents/index.ts +10 -0
- package/src/agents/nativeFsDenies.ts +82 -0
- package/src/agents/opencode.test.ts +1440 -0
- package/src/agents/opencode.ts +1312 -0
- package/src/agents/opencodePlugin.ts +222 -0
- package/src/agents/opencodeShared.test.ts +34 -0
- package/src/agents/opencodeShared.ts +121 -0
- package/src/agents/postRun.test.ts +549 -0
- package/src/agents/postRun.ts +535 -0
- package/src/agents/reviewer.ts +104 -0
- package/src/agents/sessionLabeler.test.ts +247 -0
- package/src/agents/sessionLabeler.ts +178 -0
- package/src/agents/shared.test.ts +76 -0
- package/src/agents/shared.ts +292 -0
- package/src/agents/subagentModels.test.ts +113 -0
- package/src/agents/subagentModels.ts +40 -0
- package/src/agents/subagentRegistration.test.ts +41 -0
- package/src/agents/subagentToolGates.ts +114 -0
- package/src/cli.test.ts +129 -0
- package/src/cli.ts +105 -0
- package/src/commands/gha.test.ts +192 -0
- package/src/commands/gha.ts +188 -0
- package/src/commands/mcp.ts +122 -0
- package/src/config.ts +1 -0
- package/src/entry.ts +7 -0
- package/src/entryPost.stdlibOnly.test.ts +109 -0
- package/src/entryPost.ts +99 -0
- package/src/external.test.ts +16 -0
- package/src/external.ts +302 -0
- package/src/index.ts +11 -0
- package/src/internal/index.ts +71 -0
- package/src/lifecycle.ts +2 -0
- package/src/main.test.ts +873 -0
- package/src/main.ts +712 -0
- package/src/mcp/__fixtures__/terramend-scratch-pr-49-review-3485940013.json +110 -0
- package/src/mcp/__fixtures__/terramend-scratch-pr-64-review-3531000326.json +14 -0
- package/src/mcp/__fixtures__/terramend-test-repo-pr-1.diff.json +67 -0
- package/src/mcp/__snapshots__/checkout.test.ts.snap +109 -0
- package/src/mcp/__snapshots__/reviewComments.test.ts.snap +71 -0
- package/src/mcp/arkConfig.ts +7 -0
- package/src/mcp/checkSuite.test.ts +245 -0
- package/src/mcp/checkSuite.ts +255 -0
- package/src/mcp/checkout.test.ts +752 -0
- package/src/mcp/checkout.ts +886 -0
- package/src/mcp/comment.test.ts +772 -0
- package/src/mcp/comment.ts +582 -0
- package/src/mcp/commitInfo.test.ts +127 -0
- package/src/mcp/commitInfo.ts +61 -0
- package/src/mcp/crosswalk.test.ts +106 -0
- package/src/mcp/crosswalk.ts +339 -0
- package/src/mcp/dependencies.test.ts +309 -0
- package/src/mcp/dependencies.ts +189 -0
- package/src/mcp/geminiSanitizer.test.ts +287 -0
- package/src/mcp/geminiSanitizer.ts +207 -0
- package/src/mcp/git.test.ts +1083 -0
- package/src/mcp/git.ts +890 -0
- package/src/mcp/guardrails.test.ts +705 -0
- package/src/mcp/guardrails.ts +465 -0
- package/src/mcp/issue.test.ts +113 -0
- package/src/mcp/issue.ts +73 -0
- package/src/mcp/issueComments.test.ts +69 -0
- package/src/mcp/issueComments.ts +48 -0
- package/src/mcp/issueEvents.test.ts +134 -0
- package/src/mcp/issueEvents.ts +100 -0
- package/src/mcp/issueInfo.test.ts +104 -0
- package/src/mcp/issueInfo.ts +72 -0
- package/src/mcp/labels.test.ts +52 -0
- package/src/mcp/labels.ts +34 -0
- package/src/mcp/localContext.ts +28 -0
- package/src/mcp/localServer.test.ts +75 -0
- package/src/mcp/localServer.ts +131 -0
- package/src/mcp/moduleExtraction.test.ts +261 -0
- package/src/mcp/moduleExtraction.ts +313 -0
- package/src/mcp/moduleTests.test.ts +269 -0
- package/src/mcp/moduleTests.ts +421 -0
- package/src/mcp/modules.test.ts +640 -0
- package/src/mcp/modules.ts +696 -0
- package/src/mcp/output.test.ts +96 -0
- package/src/mcp/output.ts +70 -0
- package/src/mcp/pathSafety.test.ts +44 -0
- package/src/mcp/pathSafety.ts +28 -0
- package/src/mcp/policy.test.ts +282 -0
- package/src/mcp/policy.ts +199 -0
- package/src/mcp/pr.test.ts +387 -0
- package/src/mcp/pr.ts +194 -0
- package/src/mcp/prInfo.test.ts +96 -0
- package/src/mcp/prInfo.ts +91 -0
- package/src/mcp/providerSchema.test.ts +85 -0
- package/src/mcp/providerSchema.ts +175 -0
- package/src/mcp/review.test.ts +936 -0
- package/src/mcp/review.ts +923 -0
- package/src/mcp/reviewComments.test.ts +549 -0
- package/src/mcp/reviewComments.ts +896 -0
- package/src/mcp/roots.test.ts +175 -0
- package/src/mcp/roots.ts +217 -0
- package/src/mcp/scope.test.ts +59 -0
- package/src/mcp/scope.ts +65 -0
- package/src/mcp/security.test.ts +720 -0
- package/src/mcp/selectMode.test.ts +210 -0
- package/src/mcp/selectMode.ts +181 -0
- package/src/mcp/server.test.ts +292 -0
- package/src/mcp/server.ts +403 -0
- package/src/mcp/shared.ts +100 -0
- package/src/mcp/shell.test.ts +520 -0
- package/src/mcp/shell.ts +505 -0
- package/src/mcp/staleFix.test.ts +237 -0
- package/src/mcp/staleFix.ts +277 -0
- package/src/mcp/terraform/cost.ts +163 -0
- package/src/mcp/terraform/currency.test.ts +338 -0
- package/src/mcp/terraform/currency.ts +336 -0
- package/src/mcp/terraform/decisions.ts +527 -0
- package/src/mcp/terraform/findings.ts +333 -0
- package/src/mcp/terraform/plan.ts +348 -0
- package/src/mcp/terraform/scanners.ts +809 -0
- package/src/mcp/terraform/tools.test.ts +1071 -0
- package/src/mcp/terraform/tools.ts +908 -0
- package/src/mcp/terraform/types.ts +305 -0
- package/src/mcp/terraform.test.ts +1957 -0
- package/src/mcp/terraform.ts +23 -0
- package/src/mcp/terratest.test.ts +105 -0
- package/src/mcp/terratest.ts +196 -0
- package/src/mcp/toolFiltering.test.ts +85 -0
- package/src/mcp/upload.test.ts +180 -0
- package/src/mcp/upload.ts +112 -0
- package/src/models.test.ts +300 -0
- package/src/models.ts +708 -0
- package/src/modes.test.ts +107 -0
- package/src/modes.ts +880 -0
- package/src/prep/index.ts +43 -0
- package/src/prep/installNodeDependencies.test.ts +298 -0
- package/src/prep/installNodeDependencies.ts +196 -0
- package/src/prep/installPythonDependencies.test.ts +268 -0
- package/src/prep/installPythonDependencies.ts +199 -0
- package/src/prep/types.ts +38 -0
- package/src/reviewQuality.test.ts +63 -0
- package/src/reviewQuality.ts +134 -0
- package/src/runCli.test.ts +214 -0
- package/src/runCli.ts +282 -0
- package/src/skills/terraform-best-practices/SKILL.md +369 -0
- package/src/toolState.test.ts +45 -0
- package/src/toolState.ts +252 -0
- package/src/utils/activity.test.ts +188 -0
- package/src/utils/activity.ts +210 -0
- package/src/utils/agent.test.ts +251 -0
- package/src/utils/agent.ts +139 -0
- package/src/utils/agentHangReport.test.ts +203 -0
- package/src/utils/agentHangReport.ts +170 -0
- package/src/utils/apiFetch.test.ts +115 -0
- package/src/utils/apiFetch.ts +62 -0
- package/src/utils/apiKeys.test.ts +344 -0
- package/src/utils/apiKeys.ts +206 -0
- package/src/utils/apiUrl.test.ts +30 -0
- package/src/utils/apiUrl.ts +59 -0
- package/src/utils/assets.test.ts +153 -0
- package/src/utils/assets.ts +107 -0
- package/src/utils/billingErrors.test.ts +121 -0
- package/src/utils/billingErrors.ts +189 -0
- package/src/utils/body.test.ts +217 -0
- package/src/utils/body.ts +168 -0
- package/src/utils/buildTerramendFooter.test.ts +38 -0
- package/src/utils/buildTerramendFooter.ts +82 -0
- package/src/utils/byokFallback.test.ts +205 -0
- package/src/utils/byokFallback.ts +128 -0
- package/src/utils/claudeSubscription.test.ts +179 -0
- package/src/utils/claudeSubscription.ts +93 -0
- package/src/utils/cli.ts +31 -0
- package/src/utils/codexHome.test.ts +190 -0
- package/src/utils/codexHome.ts +191 -0
- package/src/utils/codexOAuth.ts +147 -0
- package/src/utils/codexRefreshDetect.test.ts +85 -0
- package/src/utils/codexRefreshDetect.ts +35 -0
- package/src/utils/diffCoverage.test.ts +468 -0
- package/src/utils/diffCoverage.ts +404 -0
- package/src/utils/errorReport.test.ts +135 -0
- package/src/utils/errorReport.ts +83 -0
- package/src/utils/exitHandler.ts +35 -0
- package/src/utils/fixDoubleEscapedString.ts +9 -0
- package/src/utils/ghaCore.ts +13 -0
- package/src/utils/gitAuth.test.ts +322 -0
- package/src/utils/gitAuth.ts +263 -0
- package/src/utils/gitAuthServer.test.ts +260 -0
- package/src/utils/gitAuthServer.ts +182 -0
- package/src/utils/github.test.ts +615 -0
- package/src/utils/github.ts +538 -0
- package/src/utils/globals.ts +9 -0
- package/src/utils/humanEditCapture.test.ts +100 -0
- package/src/utils/humanEditCapture.ts +193 -0
- package/src/utils/install.test.ts +768 -0
- package/src/utils/install.ts +492 -0
- package/src/utils/instructions.test.ts +240 -0
- package/src/utils/instructions.ts +543 -0
- package/src/utils/leapingComment.test.ts +51 -0
- package/src/utils/leapingComment.ts +18 -0
- package/src/utils/learnings.test.ts +87 -0
- package/src/utils/learnings.ts +138 -0
- package/src/utils/learningsTocRender.test.ts +116 -0
- package/src/utils/learningsTruncate.test.ts +39 -0
- package/src/utils/learningsTruncate.ts +42 -0
- package/src/utils/lifecycle.test.ts +195 -0
- package/src/utils/lifecycle.ts +198 -0
- package/src/utils/log.test.ts +402 -0
- package/src/utils/log.ts +432 -0
- package/src/utils/normalizeEnv.test.ts +91 -0
- package/src/utils/normalizeEnv.ts +106 -0
- package/src/utils/openCodeModels.ts +82 -0
- package/src/utils/overrides.test.ts +89 -0
- package/src/utils/overrides.ts +98 -0
- package/src/utils/packageManager.test.ts +321 -0
- package/src/utils/packageManager.ts +257 -0
- package/src/utils/patchWorkflowRunFields.test.ts +92 -0
- package/src/utils/patchWorkflowRunFields.ts +150 -0
- package/src/utils/payload.test.ts +497 -0
- package/src/utils/payload.ts +371 -0
- package/src/utils/postApiFetch.ts +51 -0
- package/src/utils/prSummary.test.ts +224 -0
- package/src/utils/prSummary.ts +147 -0
- package/src/utils/progressComment.ts +261 -0
- package/src/utils/providerErrors.test.ts +315 -0
- package/src/utils/providerErrors.ts +172 -0
- package/src/utils/rangeDiff.test.ts +236 -0
- package/src/utils/rangeDiff.ts +182 -0
- package/src/utils/remediationCommand.test.ts +163 -0
- package/src/utils/remediationCommand.ts +119 -0
- package/src/utils/retry.test.ts +153 -0
- package/src/utils/retry.ts +58 -0
- package/src/utils/reviewCleanup.ts +106 -0
- package/src/utils/run.ts +99 -0
- package/src/utils/runContext.ts +145 -0
- package/src/utils/runContextData.ts +58 -0
- package/src/utils/runErrorRenderer.test.ts +95 -0
- package/src/utils/runErrorRenderer.ts +259 -0
- package/src/utils/runFixture.ts +76 -0
- package/src/utils/runLifecycle.ts +237 -0
- package/src/utils/runStartupLog.ts +60 -0
- package/src/utils/secrets.test.ts +103 -0
- package/src/utils/secrets.ts +177 -0
- package/src/utils/setup.test.ts +509 -0
- package/src/utils/setup.ts +352 -0
- package/src/utils/shell.ts +103 -0
- package/src/utils/skills.test.ts +46 -0
- package/src/utils/skills.ts +67 -0
- package/src/utils/subprocess.test.ts +170 -0
- package/src/utils/subprocess.ts +438 -0
- package/src/utils/terraformMcp.test.ts +63 -0
- package/src/utils/terraformMcp.ts +83 -0
- package/src/utils/time.test.ts +105 -0
- package/src/utils/time.ts +59 -0
- package/src/utils/timer.test.ts +91 -0
- package/src/utils/timer.ts +72 -0
- package/src/utils/todoTracking.test.ts +223 -0
- package/src/utils/todoTracking.ts +167 -0
- package/src/utils/token.test.ts +239 -0
- package/src/utils/token.ts +186 -0
- package/src/utils/version.ts +10 -0
- package/src/utils/versioning.test.ts +34 -0
- package/src/utils/versioning.ts +44 -0
- package/src/utils/vertex.ts +85 -0
- package/src/utils/workflow.ts +25 -0
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
AUTH_REQUIRED_REDIRECT,
|
|
6
|
+
classifyPushError,
|
|
7
|
+
DeleteBranchTool,
|
|
8
|
+
GitFetchTool,
|
|
9
|
+
GitTool,
|
|
10
|
+
NOSHELL_BLOCKED_ARGS,
|
|
11
|
+
NOSHELL_BLOCKED_SUBCOMMANDS,
|
|
12
|
+
PushBranchTool,
|
|
13
|
+
PushTagsTool,
|
|
14
|
+
rejectIfLeadingDash,
|
|
15
|
+
rejectSpecialRef,
|
|
16
|
+
validateTagName,
|
|
17
|
+
} from "#app/mcp/git";
|
|
18
|
+
import type { ToolContext } from "#app/mcp/server";
|
|
19
|
+
import type { ToolResult } from "#app/mcp/shared";
|
|
20
|
+
|
|
21
|
+
const mocks = vi.hoisted(() => ({
|
|
22
|
+
$: vi.fn<
|
|
23
|
+
(
|
|
24
|
+
cmd: string,
|
|
25
|
+
args: string[],
|
|
26
|
+
opts?: {
|
|
27
|
+
log?: boolean;
|
|
28
|
+
onError?: (r: { status: number; stdout: string; stderr: string }) => void;
|
|
29
|
+
},
|
|
30
|
+
) => string
|
|
31
|
+
>(),
|
|
32
|
+
$git: vi.fn<(sub: string, args: string[], opts?: unknown) => Promise<unknown>>(),
|
|
33
|
+
$gitFetchWithDeepen:
|
|
34
|
+
vi.fn<(args: string[], opts?: unknown, label?: string) => Promise<unknown>>(),
|
|
35
|
+
executeLifecycleHook: vi.fn<(params: unknown) => Promise<{ failure?: unknown }>>(),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock("#app/utils/shell", () => ({ $: mocks.$ }));
|
|
39
|
+
vi.mock("#app/utils/gitAuth", () => ({
|
|
40
|
+
$git: mocks.$git,
|
|
41
|
+
$gitFetchWithDeepen: mocks.$gitFetchWithDeepen,
|
|
42
|
+
}));
|
|
43
|
+
vi.mock("#app/utils/lifecycle", () => ({ executeLifecycleHook: mocks.executeLifecycleHook }));
|
|
44
|
+
vi.mock("#app/mcp/guardrails", () => ({
|
|
45
|
+
assertNoBlockedDestroy: vi.fn(),
|
|
46
|
+
assertNoSecretsInDiff: vi.fn(),
|
|
47
|
+
enforceProtectedPaths: vi.fn(),
|
|
48
|
+
enforceRemediationPaths: vi.fn(),
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
/** invoke a tool's execute the way fastmcp would, bypassing schema validation */
|
|
52
|
+
function runTool(t: { execute: unknown }, params: Record<string, unknown>): Promise<ToolResult> {
|
|
53
|
+
const exec = t.execute as (args: unknown, context?: unknown) => Promise<ToolResult>;
|
|
54
|
+
return exec(params);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function textOf(result: ToolResult): string {
|
|
58
|
+
return result.content[0]?.text ?? "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type CtxOverrides = {
|
|
62
|
+
push?: "enabled" | "restricted" | "disabled";
|
|
63
|
+
shell?: "enabled" | "restricted" | "disabled";
|
|
64
|
+
defaultBranch?: string;
|
|
65
|
+
pushUrl?: string | undefined;
|
|
66
|
+
pushDest?: { remoteName: string; remoteBranch: string; localBranch: string };
|
|
67
|
+
prepushFailureCount?: number;
|
|
68
|
+
prepushScript?: string | null;
|
|
69
|
+
event?: Record<string, unknown>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function makeCtx(over: CtxOverrides = {}): ToolContext {
|
|
73
|
+
return {
|
|
74
|
+
repo: { data: { default_branch: over.defaultBranch ?? "main" } },
|
|
75
|
+
payload: {
|
|
76
|
+
push: over.push ?? "enabled",
|
|
77
|
+
shell: over.shell ?? "disabled",
|
|
78
|
+
event: over.event ?? { trigger: "unknown" },
|
|
79
|
+
},
|
|
80
|
+
gitToken: "test-token",
|
|
81
|
+
prepushScript: over.prepushScript ?? null,
|
|
82
|
+
toolState: {
|
|
83
|
+
pushUrl: "pushUrl" in over ? over.pushUrl : "https://github.com/o/r.git",
|
|
84
|
+
pushDest: over.pushDest,
|
|
85
|
+
prepushFailureCount: over.prepushFailureCount ?? 0,
|
|
86
|
+
},
|
|
87
|
+
} as unknown as ToolContext;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type DollarResponse = string | (() => string);
|
|
91
|
+
|
|
92
|
+
/** route `$("git", args)` calls by "cmd arg arg..." key. arrays are consumed
|
|
93
|
+
* in order for repeated identical calls. unknown calls throw loudly. */
|
|
94
|
+
function dispatch(table: Record<string, DollarResponse | DollarResponse[]>): void {
|
|
95
|
+
mocks.$.mockImplementation((cmd, args) => {
|
|
96
|
+
const key = `${cmd} ${args.join(" ")}`;
|
|
97
|
+
const entry = table[key];
|
|
98
|
+
if (entry === undefined) throw new Error(`unexpected $ call: ${key}`);
|
|
99
|
+
const next = Array.isArray(entry) ? entry.shift() : entry;
|
|
100
|
+
if (next === undefined) throw new Error(`exhausted responses for: ${key}`);
|
|
101
|
+
return typeof next === "function" ? next() : next;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// re-export the normalizeUrl function for testing
|
|
106
|
+
// note: in a real scenario, we'd export this from git.ts or move to a shared utils file
|
|
107
|
+
function normalizeUrl(url: string): string {
|
|
108
|
+
return url.replace(/\.git$/, "").toLowerCase();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe("normalizeUrl", () => {
|
|
112
|
+
it("removes .git suffix", () => {
|
|
113
|
+
expect(normalizeUrl("https://github.com/owner/repo.git")).toBe("https://github.com/owner/repo");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("lowercases URL", () => {
|
|
117
|
+
expect(normalizeUrl("https://github.com/Owner/Repo")).toBe("https://github.com/owner/repo");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("handles URL without .git suffix", () => {
|
|
121
|
+
expect(normalizeUrl("https://github.com/owner/repo")).toBe("https://github.com/owner/repo");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("handles combined case and .git suffix", () => {
|
|
125
|
+
expect(normalizeUrl("https://github.com/OWNER/REPO.git")).toBe("https://github.com/owner/repo");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("push URL validation", () => {
|
|
130
|
+
// these tests document the expected behavior
|
|
131
|
+
// actual integration testing happens via the agent test suite
|
|
132
|
+
|
|
133
|
+
it("should block push when actual URL differs from pushUrl", () => {
|
|
134
|
+
// pushUrl is set by setupGit (base repo) or checkout_pr (fork repo)
|
|
135
|
+
const pushUrl = "https://github.com/fork-owner/repo.git";
|
|
136
|
+
const actualUrl = "https://github.com/base-owner/repo.git"; // different repo
|
|
137
|
+
|
|
138
|
+
const pushUrlNormalized = normalizeUrl(pushUrl);
|
|
139
|
+
const actualUrlNormalized = normalizeUrl(actualUrl);
|
|
140
|
+
|
|
141
|
+
expect(pushUrlNormalized).not.toBe(actualUrlNormalized);
|
|
142
|
+
// in real code, this mismatch would throw an error
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("should allow push when actual URL matches pushUrl", () => {
|
|
146
|
+
const pushUrl = "https://github.com/fork-owner/repo.git";
|
|
147
|
+
const actualUrl = "https://github.com/fork-owner/repo"; // same repo, no .git
|
|
148
|
+
|
|
149
|
+
const pushUrlNormalized = normalizeUrl(pushUrl);
|
|
150
|
+
const actualUrlNormalized = normalizeUrl(actualUrl);
|
|
151
|
+
|
|
152
|
+
expect(pushUrlNormalized).toBe(actualUrlNormalized);
|
|
153
|
+
// in real code, this would allow the push
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("should handle case differences in URLs", () => {
|
|
157
|
+
const pushUrl = "https://github.com/Owner/Repo.git";
|
|
158
|
+
const actualUrl = "https://github.com/owner/repo";
|
|
159
|
+
|
|
160
|
+
const pushUrlNormalized = normalizeUrl(pushUrl);
|
|
161
|
+
const actualUrlNormalized = normalizeUrl(actualUrl);
|
|
162
|
+
|
|
163
|
+
expect(pushUrlNormalized).toBe(actualUrlNormalized);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("classifyPushError", () => {
|
|
168
|
+
describe("concurrent-push", () => {
|
|
169
|
+
it("matches client-side non-fast-forward (`fetch first`)", () => {
|
|
170
|
+
const msg =
|
|
171
|
+
"git push failed (exit 1): To https://github.com/o/r.git\n" +
|
|
172
|
+
" ! [rejected] feature -> feature (fetch first)\n" +
|
|
173
|
+
"error: failed to push some refs to 'https://github.com/o/r.git'\n" +
|
|
174
|
+
"hint: Updates were rejected because the remote contains work";
|
|
175
|
+
expect(classifyPushError(msg)).toBe("concurrent-push");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("matches client-side `non-fast-forward` wording", () => {
|
|
179
|
+
const msg = "! [rejected] main -> main (non-fast-forward)";
|
|
180
|
+
expect(classifyPushError(msg)).toBe("concurrent-push");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("matches server-side `cannot lock ref` (the case from #571)", () => {
|
|
184
|
+
const msg =
|
|
185
|
+
"remote: error: cannot lock ref 'refs/heads/feature': is at " +
|
|
186
|
+
"abc123 but expected def456\n" +
|
|
187
|
+
" ! [remote rejected] feature -> feature (cannot lock ref ...)";
|
|
188
|
+
expect(classifyPushError(msg)).toBe("concurrent-push");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("transient", () => {
|
|
193
|
+
it("matches RPC failed with HTTP 502", () => {
|
|
194
|
+
expect(
|
|
195
|
+
classifyPushError(
|
|
196
|
+
"fatal: unable to access 'https://github.com/o/r.git/': The requested URL returned error: 502",
|
|
197
|
+
),
|
|
198
|
+
).toBe("transient");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("matches early EOF mid-pack", () => {
|
|
202
|
+
expect(
|
|
203
|
+
classifyPushError("fatal: the remote end hung up unexpectedly\nfatal: early EOF"),
|
|
204
|
+
).toBe("transient");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("matches RPC failed", () => {
|
|
208
|
+
expect(
|
|
209
|
+
classifyPushError("fatal: RPC failed; curl 56 OpenSSL SSL_read: Connection reset by peer"),
|
|
210
|
+
).toBe("transient");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("matches HTTP/2 stream not closed cleanly", () => {
|
|
214
|
+
expect(
|
|
215
|
+
classifyPushError("fatal: HTTP/2 stream 7 was not closed cleanly: PROTOCOL_ERROR (err 1)"),
|
|
216
|
+
).toBe("transient");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("matches DNS resolution failure", () => {
|
|
220
|
+
expect(classifyPushError("fatal: Could not resolve host: github.com")).toBe("transient");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("matches unexpected disconnect during sideband read", () => {
|
|
224
|
+
expect(classifyPushError("fatal: unexpected disconnect while reading sideband packet")).toBe(
|
|
225
|
+
"transient",
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("classifies HTTP 429 (rate-limit / abuse detection) as transient", () => {
|
|
230
|
+
// 429 is the documented exception to the otherwise-permanent 4xx class —
|
|
231
|
+
// GitHub's abuse detection occasionally surfaces it on git push.
|
|
232
|
+
expect(
|
|
233
|
+
classifyPushError(
|
|
234
|
+
"fatal: unable to access 'https://github.com/o/r.git/': The requested URL returned error: 429",
|
|
235
|
+
),
|
|
236
|
+
).toBe("transient");
|
|
237
|
+
expect(classifyPushError("remote: HTTP 429: too many requests")).toBe("transient");
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("unknown", () => {
|
|
242
|
+
it("does NOT classify auth/403 as transient", () => {
|
|
243
|
+
// permission denied is permanent within a run — retrying just wastes
|
|
244
|
+
// time. must NOT match the HTTP-5xx regex.
|
|
245
|
+
expect(
|
|
246
|
+
classifyPushError(
|
|
247
|
+
"remote: Permission to o/r.git denied to bot.\n" +
|
|
248
|
+
"fatal: unable to access 'https://github.com/o/r.git/': The requested URL returned error: 403",
|
|
249
|
+
),
|
|
250
|
+
).toBe("unknown");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("does NOT classify protected-branch rejection as concurrent-push", () => {
|
|
254
|
+
expect(
|
|
255
|
+
classifyPushError(
|
|
256
|
+
" ! [remote rejected] main -> main (push declined due to repository rule violations)",
|
|
257
|
+
),
|
|
258
|
+
).toBe("unknown");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("does NOT classify 404 as transient", () => {
|
|
262
|
+
expect(
|
|
263
|
+
classifyPushError(
|
|
264
|
+
"fatal: unable to access 'https://github.com/o/r.git/': The requested URL returned error: 404",
|
|
265
|
+
),
|
|
266
|
+
).toBe("unknown");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("returns unknown for an empty message", () => {
|
|
270
|
+
expect(classifyPushError("")).toBe("unknown");
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("ordering", () => {
|
|
275
|
+
it("prefers concurrent-push over transient when both signals appear", () => {
|
|
276
|
+
// a server-side cannot-lock-ref response that also includes an HTTP
|
|
277
|
+
// 5xx in the libcurl envelope should still route to the recovery
|
|
278
|
+
// path, not a blind retry.
|
|
279
|
+
const msg =
|
|
280
|
+
"remote: error: cannot lock ref 'refs/heads/feature': is at A but expected B\n" +
|
|
281
|
+
"fatal: unable to access ...: The requested URL returned error: 500";
|
|
282
|
+
expect(classifyPushError(msg)).toBe("concurrent-push");
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe("ref/tag validators", () => {
|
|
288
|
+
it("rejectIfLeadingDash blocks values starting with '-'", () => {
|
|
289
|
+
expect(() => rejectIfLeadingDash("--upload-pack=evil", "ref")).toThrow(/starts with '-'/);
|
|
290
|
+
expect(() => rejectIfLeadingDash("main", "ref")).not.toThrow();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("rejectSpecialRef blocks fully-qualified ref paths", () => {
|
|
294
|
+
expect(() => rejectSpecialRef("refs/heads/main", "branch")).toThrow(/fully-qualified ref path/);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("rejectSpecialRef blocks symbolic refs", () => {
|
|
298
|
+
for (const ref of ["HEAD", "FETCH_HEAD", "ORIG_HEAD", "MERGE_HEAD"]) {
|
|
299
|
+
expect(() => rejectSpecialRef(ref, "branch")).toThrow(/symbolic ref/);
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("rejectSpecialRef blocks refspec/revision syntax characters", () => {
|
|
304
|
+
for (const ref of ["evil:refs/heads/main", "+main", "main^", "ma~in", "a b", "a*", "a[b"]) {
|
|
305
|
+
expect(() => rejectSpecialRef(ref, "branch")).toThrow(/refspec\/revision syntax/);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("rejectSpecialRef allows ordinary branch names", () => {
|
|
310
|
+
expect(() => rejectSpecialRef("feature/foo-1.2", "branch")).not.toThrow();
|
|
311
|
+
expect(() => rejectSpecialRef("pr-123", "branch")).not.toThrow();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("validateTagName allows conservative tag names and blocks injection shapes", () => {
|
|
315
|
+
expect(() => validateTagName("v1.2.3")).not.toThrow();
|
|
316
|
+
expect(() => validateTagName("release/2026-06")).not.toThrow();
|
|
317
|
+
expect(() => validateTagName("-v1")).toThrow(/starts with '-'/);
|
|
318
|
+
expect(() => validateTagName("foo:refs/heads/main")).toThrow(/refspec or flag/);
|
|
319
|
+
expect(() => validateTagName("a b")).toThrow(/refspec or flag/);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe("pushWithRetry backoff (via push_tags / delete_branch)", () => {
|
|
324
|
+
beforeEach(() => {
|
|
325
|
+
vi.resetAllMocks();
|
|
326
|
+
vi.useFakeTimers();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
afterEach(() => {
|
|
330
|
+
vi.useRealTimers();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("retries transient failures with backoff and eventually succeeds", async () => {
|
|
334
|
+
mocks.$git
|
|
335
|
+
.mockRejectedValueOnce(new Error("fatal: RPC failed; HTTP 502"))
|
|
336
|
+
.mockRejectedValueOnce(new Error("fatal: early EOF"))
|
|
337
|
+
.mockResolvedValueOnce({ stdout: "", stderr: "" });
|
|
338
|
+
|
|
339
|
+
const t = PushTagsTool(makeCtx({ push: "enabled" }));
|
|
340
|
+
const resultP = runTool(t, { tag: "v1.0.0", force: false });
|
|
341
|
+
await vi.runAllTimersAsync();
|
|
342
|
+
const result = await resultP;
|
|
343
|
+
|
|
344
|
+
expect(result.isError).toBeUndefined();
|
|
345
|
+
expect(textOf(result)).toContain("v1.0.0");
|
|
346
|
+
expect(mocks.$git).toHaveBeenCalledTimes(3);
|
|
347
|
+
expect(mocks.$git).toHaveBeenCalledWith("push", ["origin", "refs/tags/v1.0.0"], {
|
|
348
|
+
token: "test-token",
|
|
349
|
+
disableHooks: true,
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("gives up after exhausting all retry attempts and surfaces the final error", async () => {
|
|
354
|
+
mocks.$git.mockRejectedValue(new Error("fatal: Could not resolve host: github.com"));
|
|
355
|
+
|
|
356
|
+
const t = PushTagsTool(makeCtx({ push: "enabled" }));
|
|
357
|
+
const resultP = runTool(t, { tag: "v2.0.0", force: false });
|
|
358
|
+
await vi.runAllTimersAsync();
|
|
359
|
+
const result = await resultP;
|
|
360
|
+
|
|
361
|
+
expect(result.isError).toBe(true);
|
|
362
|
+
expect(textOf(result)).toContain("Could not resolve host");
|
|
363
|
+
// 1 original attempt + 5 backoff retries
|
|
364
|
+
expect(mocks.$git).toHaveBeenCalledTimes(6);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("does not retry non-transient (unknown) errors", async () => {
|
|
368
|
+
mocks.$git.mockRejectedValue(new Error("The requested URL returned error: 403"));
|
|
369
|
+
|
|
370
|
+
const t = PushTagsTool(makeCtx({ push: "enabled" }));
|
|
371
|
+
const resultP = runTool(t, { tag: "v3.0.0", force: false });
|
|
372
|
+
await vi.runAllTimersAsync();
|
|
373
|
+
const result = await resultP;
|
|
374
|
+
|
|
375
|
+
expect(result.isError).toBe(true);
|
|
376
|
+
expect(mocks.$git).toHaveBeenCalledTimes(1);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("does not retry concurrent-push errors (they need caller intervention)", async () => {
|
|
380
|
+
mocks.$git.mockRejectedValue(new Error("! [rejected] x -> x (non-fast-forward)"));
|
|
381
|
+
|
|
382
|
+
const t = DeleteBranchTool(makeCtx({ push: "enabled" }));
|
|
383
|
+
const resultP = runTool(t, { branchName: "feature" });
|
|
384
|
+
await vi.runAllTimersAsync();
|
|
385
|
+
const result = await resultP;
|
|
386
|
+
|
|
387
|
+
expect(result.isError).toBe(true);
|
|
388
|
+
expect(mocks.$git).toHaveBeenCalledTimes(1);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("stops retrying when a transient error turns into an unknown one", async () => {
|
|
392
|
+
mocks.$git
|
|
393
|
+
.mockRejectedValueOnce(new Error("fatal: RPC failed mid-stream"))
|
|
394
|
+
.mockRejectedValueOnce(new Error("remote: Permission denied"));
|
|
395
|
+
|
|
396
|
+
const t = PushTagsTool(makeCtx({ push: "enabled" }));
|
|
397
|
+
const resultP = runTool(t, { tag: "v4.0.0", force: false });
|
|
398
|
+
await vi.runAllTimersAsync();
|
|
399
|
+
const result = await resultP;
|
|
400
|
+
|
|
401
|
+
expect(result.isError).toBe(true);
|
|
402
|
+
expect(textOf(result)).toContain("Permission denied");
|
|
403
|
+
expect(mocks.$git).toHaveBeenCalledTimes(2);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
describe("push_tags tool", () => {
|
|
408
|
+
beforeEach(() => {
|
|
409
|
+
vi.resetAllMocks();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("requires push: enabled", async () => {
|
|
413
|
+
const t = PushTagsTool(makeCtx({ push: "restricted" }));
|
|
414
|
+
const result = await runTool(t, { tag: "v1", force: false });
|
|
415
|
+
expect(result.isError).toBe(true);
|
|
416
|
+
expect(textOf(result)).toContain("requires push: enabled");
|
|
417
|
+
expect(mocks.$git).not.toHaveBeenCalled();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("rejects tag names that could be parsed as refspecs", async () => {
|
|
421
|
+
const t = PushTagsTool(makeCtx({ push: "enabled" }));
|
|
422
|
+
const result = await runTool(t, { tag: "foo:refs/heads/main", force: false });
|
|
423
|
+
expect(result.isError).toBe(true);
|
|
424
|
+
expect(textOf(result)).toContain("refspec or flag");
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("adds -f when force is requested and honors shell: enabled hooks", async () => {
|
|
428
|
+
mocks.$git.mockResolvedValue({ stdout: "", stderr: "" });
|
|
429
|
+
const t = PushTagsTool(makeCtx({ push: "enabled", shell: "enabled" }));
|
|
430
|
+
const result = await runTool(t, { tag: "v9", force: true });
|
|
431
|
+
expect(result.isError).toBeUndefined();
|
|
432
|
+
expect(mocks.$git).toHaveBeenCalledWith("push", ["-f", "origin", "refs/tags/v9"], {
|
|
433
|
+
token: "test-token",
|
|
434
|
+
disableHooks: false,
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
describe("delete_branch tool", () => {
|
|
440
|
+
beforeEach(() => {
|
|
441
|
+
vi.resetAllMocks();
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("requires push: enabled", async () => {
|
|
445
|
+
const t = DeleteBranchTool(makeCtx({ push: "restricted" }));
|
|
446
|
+
const result = await runTool(t, { branchName: "feature" });
|
|
447
|
+
expect(result.isError).toBe(true);
|
|
448
|
+
expect(textOf(result)).toContain("requires push: enabled");
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("rejects refs/heads/... and symbolic-ref forms", async () => {
|
|
452
|
+
const t = DeleteBranchTool(makeCtx({ push: "enabled" }));
|
|
453
|
+
expect(textOf(await runTool(t, { branchName: "refs/heads/main" }))).toContain(
|
|
454
|
+
"fully-qualified ref path",
|
|
455
|
+
);
|
|
456
|
+
expect(textOf(await runTool(t, { branchName: "HEAD" }))).toContain("symbolic ref");
|
|
457
|
+
expect(textOf(await runTool(t, { branchName: ":other" }))).toContain("refspec/revision syntax");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("blocks deleting the default branch even with push: enabled", async () => {
|
|
461
|
+
const t = DeleteBranchTool(makeCtx({ push: "enabled", defaultBranch: "main" }));
|
|
462
|
+
const result = await runTool(t, { branchName: "main" });
|
|
463
|
+
expect(result.isError).toBe(true);
|
|
464
|
+
expect(textOf(result)).toContain("cannot delete the default branch 'main'");
|
|
465
|
+
expect(mocks.$git).not.toHaveBeenCalled();
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("deletes via an explicit refs/heads/ refspec so tags can't be hit", async () => {
|
|
469
|
+
mocks.$git.mockResolvedValue({ stdout: "", stderr: "" });
|
|
470
|
+
const t = DeleteBranchTool(makeCtx({ push: "enabled" }));
|
|
471
|
+
const result = await runTool(t, { branchName: "feature" });
|
|
472
|
+
expect(result.isError).toBeUndefined();
|
|
473
|
+
expect(textOf(result)).toContain("feature");
|
|
474
|
+
expect(mocks.$git).toHaveBeenCalledWith("push", ["origin", "--delete", "refs/heads/feature"], {
|
|
475
|
+
token: "test-token",
|
|
476
|
+
disableHooks: true,
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
describe("git_fetch tool", () => {
|
|
482
|
+
beforeEach(() => {
|
|
483
|
+
vi.resetAllMocks();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("rejects refs with a leading dash", async () => {
|
|
487
|
+
const t = GitFetchTool(makeCtx());
|
|
488
|
+
const result = await runTool(t, { ref: "--upload-pack=evil" });
|
|
489
|
+
expect(result.isError).toBe(true);
|
|
490
|
+
expect(mocks.$gitFetchWithDeepen).not.toHaveBeenCalled();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("fetches the requested ref, appending --depth when provided", async () => {
|
|
494
|
+
mocks.$gitFetchWithDeepen.mockResolvedValue({ stdout: "", stderr: "" });
|
|
495
|
+
const t = GitFetchTool(makeCtx());
|
|
496
|
+
|
|
497
|
+
expect((await runTool(t, { ref: "main" })).isError).toBeUndefined();
|
|
498
|
+
expect(mocks.$gitFetchWithDeepen).toHaveBeenCalledWith(
|
|
499
|
+
["--no-tags", "origin", "main"],
|
|
500
|
+
{ token: "test-token" },
|
|
501
|
+
"git_fetch",
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
await runTool(t, { ref: "pull/12/head", depth: 1 });
|
|
505
|
+
expect(mocks.$gitFetchWithDeepen).toHaveBeenCalledWith(
|
|
506
|
+
["--no-tags", "origin", "pull/12/head", "--depth=1"],
|
|
507
|
+
{ token: "test-token" },
|
|
508
|
+
"git_fetch",
|
|
509
|
+
);
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
describe("git tool command validation", () => {
|
|
514
|
+
beforeEach(() => {
|
|
515
|
+
vi.resetAllMocks();
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("rejects args[0] duplicating the subcommand (case-insensitive)", async () => {
|
|
519
|
+
const t = GitTool(makeCtx());
|
|
520
|
+
const result = await runTool(t, { command: "status", args: ["STATUS"] });
|
|
521
|
+
expect(result.isError).toBe(true);
|
|
522
|
+
expect(textOf(result)).toContain("duplicates the subcommand");
|
|
523
|
+
expect(mocks.$).not.toHaveBeenCalled();
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("redirects auth-required subcommands to the dedicated tools", async () => {
|
|
527
|
+
const t = GitTool(makeCtx());
|
|
528
|
+
for (const [command, redirect] of Object.entries(AUTH_REQUIRED_REDIRECT)) {
|
|
529
|
+
const result = await runTool(t, { command });
|
|
530
|
+
expect(result.isError).toBe(true);
|
|
531
|
+
expect(textOf(result)).toContain(redirect);
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("blocks dangerous subcommands when shell is disabled", async () => {
|
|
536
|
+
const t = GitTool(makeCtx({ shell: "disabled" }));
|
|
537
|
+
for (const [command, message] of Object.entries(NOSHELL_BLOCKED_SUBCOMMANDS)) {
|
|
538
|
+
const result = await runTool(t, { command });
|
|
539
|
+
expect(result.isError).toBe(true);
|
|
540
|
+
expect(textOf(result)).toContain(message);
|
|
541
|
+
}
|
|
542
|
+
expect(mocks.$).not.toHaveBeenCalled();
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("allows those subcommands when shell is restricted (sandboxed shell exists)", async () => {
|
|
546
|
+
dispatch({ "git config user.name": "someone" });
|
|
547
|
+
const t = GitTool(makeCtx({ shell: "restricted" }));
|
|
548
|
+
const result = await runTool(t, { command: "config", args: ["user.name"] });
|
|
549
|
+
expect(result.isError).toBeUndefined();
|
|
550
|
+
expect(textOf(result)).toContain("someone");
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it("blocks code-executing arg flags (exact and = forms) when shell is disabled", async () => {
|
|
554
|
+
const t = GitTool(makeCtx({ shell: "disabled" }));
|
|
555
|
+
for (const flag of NOSHELL_BLOCKED_ARGS) {
|
|
556
|
+
for (const arg of [flag, `${flag}=evil`]) {
|
|
557
|
+
const result = await runTool(t, { command: "log", args: [arg] });
|
|
558
|
+
expect(result.isError).toBe(true);
|
|
559
|
+
expect(textOf(result)).toContain("can execute arbitrary code");
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("does not false-positive on flags that merely share a prefix (--exclude)", async () => {
|
|
565
|
+
dispatch({ "git log --exclude=refs/x": "deadbeef commit" });
|
|
566
|
+
const t = GitTool(makeCtx({ shell: "disabled" }));
|
|
567
|
+
const result = await runTool(t, { command: "log", args: ["--exclude=refs/x"] });
|
|
568
|
+
expect(result.isError).toBeUndefined();
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it("passes through ordinary commands and returns their output", async () => {
|
|
572
|
+
dispatch({ "git status": "On branch main" });
|
|
573
|
+
const t = GitTool(makeCtx());
|
|
574
|
+
const result = await runTool(t, { command: "status" });
|
|
575
|
+
expect(result.isError).toBeUndefined();
|
|
576
|
+
expect(textOf(result)).toContain("On branch main");
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it("collapses very long (but under-cap) output into a log group", async () => {
|
|
580
|
+
const longOutput = Array.from({ length: 250 }, (_, i) => `line-${i}`).join("\n");
|
|
581
|
+
dispatch({ "git log": longOutput });
|
|
582
|
+
const t = GitTool(makeCtx());
|
|
583
|
+
const result = await runTool(t, { command: "log" });
|
|
584
|
+
expect(result.isError).toBeUndefined();
|
|
585
|
+
expect(textOf(result)).toContain("line-249");
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
describe("git tool merge-base --is-ancestor exit-code handling", () => {
|
|
590
|
+
beforeEach(() => {
|
|
591
|
+
vi.resetAllMocks();
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it("returns isAncestor: true on exit 0", async () => {
|
|
595
|
+
mocks.$.mockReturnValue("");
|
|
596
|
+
const t = GitTool(makeCtx());
|
|
597
|
+
const result = await runTool(t, { command: "merge-base", args: ["--is-ancestor", "a", "b"] });
|
|
598
|
+
expect(result.isError).toBeUndefined();
|
|
599
|
+
expect(textOf(result)).toMatch(/isAncestor.*true/);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("treats exit 1 as data (not an ancestor), not an error", async () => {
|
|
603
|
+
mocks.$.mockImplementation((_cmd, _args, opts) => {
|
|
604
|
+
opts?.onError?.({ status: 1, stdout: "", stderr: "" });
|
|
605
|
+
return "";
|
|
606
|
+
});
|
|
607
|
+
const t = GitTool(makeCtx());
|
|
608
|
+
const result = await runTool(t, { command: "merge-base", args: ["--is-ancestor", "a", "b"] });
|
|
609
|
+
expect(result.isError).toBeUndefined();
|
|
610
|
+
expect(textOf(result)).toMatch(/isAncestor.*false/);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("surfaces exit codes > 1 as real errors with stderr detail", async () => {
|
|
614
|
+
mocks.$.mockImplementation((_cmd, _args, opts) => {
|
|
615
|
+
opts?.onError?.({ status: 128, stdout: "", stderr: "fatal: bad revision 'nope'" });
|
|
616
|
+
return "";
|
|
617
|
+
});
|
|
618
|
+
const t = GitTool(makeCtx());
|
|
619
|
+
const result = await runTool(t, {
|
|
620
|
+
command: "merge-base",
|
|
621
|
+
args: ["--is-ancestor", "nope", "b"],
|
|
622
|
+
});
|
|
623
|
+
expect(result.isError).toBe(true);
|
|
624
|
+
expect(textOf(result)).toContain("exit 128");
|
|
625
|
+
expect(textOf(result)).toContain("bad revision");
|
|
626
|
+
});
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
describe("git tool symmetric-diff trap", () => {
|
|
630
|
+
beforeEach(() => {
|
|
631
|
+
vi.resetAllMocks();
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it("rejects a bare ref diff when the ref has commits HEAD lacks", async () => {
|
|
635
|
+
dispatch({ "git rev-list --count HEAD..origin/main": "3" });
|
|
636
|
+
const t = GitTool(makeCtx());
|
|
637
|
+
const result = await runTool(t, { command: "diff", args: ["origin/main"] });
|
|
638
|
+
expect(result.isError).toBe(true);
|
|
639
|
+
expect(textOf(result)).toContain("inverse of 3 commit(s)");
|
|
640
|
+
expect(textOf(result)).toContain("--merge-base origin/main");
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it("allows a bare ref diff against an ancestor (HEAD strictly ahead)", async () => {
|
|
644
|
+
dispatch({
|
|
645
|
+
"git rev-list --count HEAD..origin/main": "0",
|
|
646
|
+
"git diff origin/main": "diff body",
|
|
647
|
+
});
|
|
648
|
+
const t = GitTool(makeCtx());
|
|
649
|
+
const result = await runTool(t, { command: "diff", args: ["origin/main"] });
|
|
650
|
+
expect(result.isError).toBeUndefined();
|
|
651
|
+
expect(textOf(result)).toContain("diff body");
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("rejects a two-dot range only when BOTH sides have unique commits", async () => {
|
|
655
|
+
dispatch({
|
|
656
|
+
"git rev-list --count b..a": "2",
|
|
657
|
+
"git rev-list --count a..b": "5",
|
|
658
|
+
});
|
|
659
|
+
const t = GitTool(makeCtx());
|
|
660
|
+
const result = await runTool(t, { command: "diff", args: ["a..b"] });
|
|
661
|
+
expect(result.isError).toBe(true);
|
|
662
|
+
// the more-ahead side is named in the recovery instructions
|
|
663
|
+
expect(textOf(result)).toContain("inverse of 5 commit(s)");
|
|
664
|
+
expect(textOf(result)).toContain("'b'");
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it("allows a degenerate two-dot range (one side is an ancestor)", async () => {
|
|
668
|
+
dispatch({
|
|
669
|
+
"git rev-list --count b..a": "0",
|
|
670
|
+
"git rev-list --count a..b": "4",
|
|
671
|
+
"git diff a..b": "ok",
|
|
672
|
+
});
|
|
673
|
+
const t = GitTool(makeCtx());
|
|
674
|
+
const result = await runTool(t, { command: "diff", args: ["a..b"] });
|
|
675
|
+
expect(result.isError).toBeUndefined();
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("expands the `A..` shorthand to `A..HEAD`", async () => {
|
|
679
|
+
dispatch({
|
|
680
|
+
"git rev-list --count HEAD..a": "1",
|
|
681
|
+
"git rev-list --count a..HEAD": "1",
|
|
682
|
+
});
|
|
683
|
+
const t = GitTool(makeCtx());
|
|
684
|
+
const result = await runTool(t, { command: "diff", args: ["a.."] });
|
|
685
|
+
expect(result.isError).toBe(true);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it("never blocks three-dot (merge-base) diffs", async () => {
|
|
689
|
+
dispatch({ "git diff a...b": "merge-base diff" });
|
|
690
|
+
const t = GitTool(makeCtx());
|
|
691
|
+
const result = await runTool(t, { command: "diff", args: ["a...b"] });
|
|
692
|
+
expect(result.isError).toBeUndefined();
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it("never blocks --merge-base diffs and probes nothing", async () => {
|
|
696
|
+
dispatch({ "git diff --merge-base origin/main": "mb diff" });
|
|
697
|
+
const t = GitTool(makeCtx());
|
|
698
|
+
const result = await runTool(t, { command: "diff", args: ["--merge-base", "origin/main"] });
|
|
699
|
+
expect(result.isError).toBeUndefined();
|
|
700
|
+
expect(mocks.$).toHaveBeenCalledTimes(1);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it("ignores pathspecs after -- and unresolvable refs", async () => {
|
|
704
|
+
dispatch({
|
|
705
|
+
// rev-list probe for the pathspec-looking positional throws (unresolvable)
|
|
706
|
+
"git rev-list --count HEAD..src/foo.ts": () => {
|
|
707
|
+
throw new Error("fatal: bad revision");
|
|
708
|
+
},
|
|
709
|
+
"git diff src/foo.ts -- origin/main": "diff",
|
|
710
|
+
});
|
|
711
|
+
const t = GitTool(makeCtx());
|
|
712
|
+
const result = await runTool(t, {
|
|
713
|
+
command: "diff",
|
|
714
|
+
args: ["src/foo.ts", "--", "origin/main"],
|
|
715
|
+
});
|
|
716
|
+
expect(result.isError).toBeUndefined();
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
describe("git tool huge-output spill", () => {
|
|
721
|
+
beforeEach(() => {
|
|
722
|
+
vi.resetAllMocks();
|
|
723
|
+
vi.stubEnv("TERRAMEND_TEMP_DIR", tmpdir());
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
afterEach(() => {
|
|
727
|
+
vi.unstubAllEnvs();
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it("spills >50K-char output to a tmp file and returns a head preview", async () => {
|
|
731
|
+
const body = Array.from({ length: 2000 }, (_, i) => `log-line-${i} ${"x".repeat(30)}`).join(
|
|
732
|
+
"\n",
|
|
733
|
+
);
|
|
734
|
+
expect(body.length).toBeGreaterThan(50_000);
|
|
735
|
+
dispatch({ "git log": body });
|
|
736
|
+
|
|
737
|
+
const t = GitTool(makeCtx());
|
|
738
|
+
const result = await runTool(t, { command: "log" });
|
|
739
|
+
expect(result.isError).toBeUndefined();
|
|
740
|
+
const text = textOf(result);
|
|
741
|
+
expect(text).toContain("log-line-0");
|
|
742
|
+
expect(text).toContain("log-line-49");
|
|
743
|
+
expect(text).not.toContain("log-line-1999");
|
|
744
|
+
expect(text).toContain("output truncated");
|
|
745
|
+
|
|
746
|
+
// the full body is persisted to the path named in the response
|
|
747
|
+
const match = text.match(/saved to (\S*git-log-[0-9a-f]{8}\.txt)/);
|
|
748
|
+
expect(match).not.toBeNull();
|
|
749
|
+
const savedPath = match?.[1] ?? "";
|
|
750
|
+
expect(readFileSync(savedPath, "utf-8")).toBe(body);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it("caps the inline preview when the head lines are themselves huge", async () => {
|
|
754
|
+
const body = "y".repeat(60_001);
|
|
755
|
+
dispatch({ "git show": body });
|
|
756
|
+
|
|
757
|
+
const t = GitTool(makeCtx());
|
|
758
|
+
const result = await runTool(t, { command: "show" });
|
|
759
|
+
expect(result.isError).toBeUndefined();
|
|
760
|
+
const text = textOf(result);
|
|
761
|
+
expect(text).toContain("…");
|
|
762
|
+
expect(text).toContain("output truncated");
|
|
763
|
+
// preview is hard-capped at 5000 chars + ellipsis, not the whole line
|
|
764
|
+
const previewLine = text.split("\n")[0] ?? "";
|
|
765
|
+
expect(previewLine.length).toBeLessThanOrEqual(5_001);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it("errors when TERRAMEND_TEMP_DIR is unset", async () => {
|
|
769
|
+
vi.stubEnv("TERRAMEND_TEMP_DIR", "");
|
|
770
|
+
dispatch({ "git log": "z".repeat(50_001) });
|
|
771
|
+
const t = GitTool(makeCtx());
|
|
772
|
+
const result = await runTool(t, { command: "log" });
|
|
773
|
+
expect(result.isError).toBe(true);
|
|
774
|
+
expect(textOf(result)).toContain("TERRAMEND_TEMP_DIR not set");
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
describe("push_branch tool", () => {
|
|
779
|
+
const REMOTE_URL = "https://github.com/o/r.git";
|
|
780
|
+
|
|
781
|
+
beforeEach(() => {
|
|
782
|
+
vi.resetAllMocks();
|
|
783
|
+
mocks.$git.mockResolvedValue({ stdout: "", stderr: "" });
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
/** dispatch table for a clean push of local `feature` to origin/feature */
|
|
787
|
+
function happyDispatch(extra: Record<string, DollarResponse | DollarResponse[]> = {}): void {
|
|
788
|
+
dispatch({
|
|
789
|
+
"git status --porcelain": "",
|
|
790
|
+
"git remote get-url --push origin": REMOTE_URL,
|
|
791
|
+
"git rev-parse HEAD": "abc1234",
|
|
792
|
+
...extra,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function featureCtx(over: CtxOverrides = {}): ToolContext {
|
|
797
|
+
return makeCtx({
|
|
798
|
+
pushDest: { remoteName: "origin", remoteBranch: "feature", localBranch: "feature" },
|
|
799
|
+
...over,
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
it("refuses to push when push is disabled", async () => {
|
|
804
|
+
const t = PushBranchTool(makeCtx({ push: "disabled" }));
|
|
805
|
+
const result = await runTool(t, { branchName: "feature", force: false });
|
|
806
|
+
expect(result.isError).toBe(true);
|
|
807
|
+
expect(textOf(result)).toContain("read-only");
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it("rejects special-ref branch names before touching git", async () => {
|
|
811
|
+
const t = PushBranchTool(makeCtx());
|
|
812
|
+
const result = await runTool(t, { branchName: "refs/heads/main", force: false });
|
|
813
|
+
expect(result.isError).toBe(true);
|
|
814
|
+
expect(textOf(result)).toContain("fully-qualified ref path");
|
|
815
|
+
expect(mocks.$).not.toHaveBeenCalled();
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
it("resolves the current branch from HEAD when branchName is omitted", async () => {
|
|
819
|
+
happyDispatch({ "git rev-parse --abbrev-ref HEAD": "feature" });
|
|
820
|
+
const t = PushBranchTool(featureCtx());
|
|
821
|
+
const result = await runTool(t, { force: false });
|
|
822
|
+
expect(result.isError).toBeUndefined();
|
|
823
|
+
expect(textOf(result)).toContain("successfully pushed feature to origin/feature");
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it("blocks pushing from a dirty working tree, including the status output", async () => {
|
|
827
|
+
dispatch({ "git status --porcelain": " M main.tf\n?? stray.txt" });
|
|
828
|
+
const t = PushBranchTool(featureCtx());
|
|
829
|
+
const result = await runTool(t, { branchName: "feature", force: false });
|
|
830
|
+
expect(result.isError).toBe(true);
|
|
831
|
+
expect(textOf(result)).toContain("working tree is not clean");
|
|
832
|
+
expect(textOf(result)).toContain(" M main.tf");
|
|
833
|
+
expect(textOf(result)).not.toContain("prepush hook failed earlier");
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it("mentions the earlier prepush failure in the dirty-tree error", async () => {
|
|
837
|
+
dispatch({ "git status --porcelain": " M main.tf" });
|
|
838
|
+
const t = PushBranchTool(featureCtx({ prepushFailureCount: 1 }));
|
|
839
|
+
const result = await runTool(t, { branchName: "feature", force: false });
|
|
840
|
+
expect(textOf(result)).toContain("prepush hook failed earlier this run");
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it("fails fast when pushUrl was never set by setupGit", async () => {
|
|
844
|
+
dispatch({ "git status --porcelain": "" });
|
|
845
|
+
const t = PushBranchTool(featureCtx({ pushUrl: undefined }));
|
|
846
|
+
const result = await runTool(t, { branchName: "feature", force: false });
|
|
847
|
+
expect(result.isError).toBe(true);
|
|
848
|
+
expect(textOf(result)).toContain("pushUrl not set");
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
it("blocks pushes whose destination URL does not match the expected repo", async () => {
|
|
852
|
+
dispatch({
|
|
853
|
+
"git status --porcelain": "",
|
|
854
|
+
"git remote get-url --push origin": "https://github.com/evil/r.git",
|
|
855
|
+
});
|
|
856
|
+
const t = PushBranchTool(featureCtx());
|
|
857
|
+
const result = await runTool(t, { branchName: "feature", force: false });
|
|
858
|
+
expect(result.isError).toBe(true);
|
|
859
|
+
expect(textOf(result)).toContain("destination does not match expected repository");
|
|
860
|
+
expect(textOf(result)).toContain("tampered");
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
it("falls back to branch config when no stored destination matches", async () => {
|
|
864
|
+
dispatch({
|
|
865
|
+
"git status --porcelain": "",
|
|
866
|
+
"git config branch.feature.pushRemote": "upstream",
|
|
867
|
+
"git config branch.feature.merge": "refs/heads/topic",
|
|
868
|
+
"git remote get-url --push upstream": REMOTE_URL,
|
|
869
|
+
"git rev-parse HEAD": "abc1234",
|
|
870
|
+
});
|
|
871
|
+
const t = PushBranchTool(makeCtx());
|
|
872
|
+
const result = await runTool(t, { branchName: "feature", force: false });
|
|
873
|
+
expect(result.isError).toBeUndefined();
|
|
874
|
+
// local and remote branch names differ -> refspec form
|
|
875
|
+
expect(mocks.$git).toHaveBeenCalledWith("push", ["-u", "upstream", "feature:topic"], {
|
|
876
|
+
token: "test-token",
|
|
877
|
+
disableHooks: true,
|
|
878
|
+
});
|
|
879
|
+
expect(textOf(result)).toContain("successfully pushed feature to upstream/topic");
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it("falls back to origin/<branch> when the branch has no push config", async () => {
|
|
883
|
+
dispatch({
|
|
884
|
+
"git status --porcelain": "",
|
|
885
|
+
"git config branch.feature.pushRemote": () => {
|
|
886
|
+
throw new Error("Command failed with exit code 1");
|
|
887
|
+
},
|
|
888
|
+
"git remote get-url --push origin": REMOTE_URL,
|
|
889
|
+
"git rev-parse HEAD": "abc1234",
|
|
890
|
+
});
|
|
891
|
+
const t = PushBranchTool(makeCtx());
|
|
892
|
+
const result = await runTool(t, { branchName: "feature", force: false });
|
|
893
|
+
expect(result.isError).toBeUndefined();
|
|
894
|
+
expect(mocks.$git).toHaveBeenCalledWith("push", ["-u", "origin", "feature"], {
|
|
895
|
+
token: "test-token",
|
|
896
|
+
disableHooks: true,
|
|
897
|
+
});
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
it("blocks pr-N pushes to a foreign remote branch when the run is not scoped to PR N", async () => {
|
|
901
|
+
dispatch({
|
|
902
|
+
"git status --porcelain": "",
|
|
903
|
+
"git remote get-url --push origin": REMOTE_URL,
|
|
904
|
+
});
|
|
905
|
+
const t = PushBranchTool(
|
|
906
|
+
makeCtx({
|
|
907
|
+
pushDest: {
|
|
908
|
+
remoteName: "origin",
|
|
909
|
+
remoteBranch: "someone-elses-branch",
|
|
910
|
+
localBranch: "pr-7",
|
|
911
|
+
},
|
|
912
|
+
event: { is_pr: true, issue_number: 8 },
|
|
913
|
+
}),
|
|
914
|
+
);
|
|
915
|
+
const result = await runTool(t, { branchName: "pr-7", force: false });
|
|
916
|
+
expect(result.isError).toBe(true);
|
|
917
|
+
expect(textOf(result)).toContain("not scoped to PR #7");
|
|
918
|
+
expect(textOf(result)).toContain("wrong branch");
|
|
919
|
+
expect(mocks.$git).not.toHaveBeenCalled();
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
it("allows pr-N pushes to a differing remote branch when the run IS scoped to PR N", async () => {
|
|
923
|
+
dispatch({
|
|
924
|
+
"git status --porcelain": "",
|
|
925
|
+
"git remote get-url --push origin": REMOTE_URL,
|
|
926
|
+
"git rev-parse HEAD": "abc1234",
|
|
927
|
+
});
|
|
928
|
+
const t = PushBranchTool(
|
|
929
|
+
makeCtx({
|
|
930
|
+
pushDest: { remoteName: "origin", remoteBranch: "their-branch", localBranch: "pr-7" },
|
|
931
|
+
event: { is_pr: true, issue_number: 7 },
|
|
932
|
+
}),
|
|
933
|
+
);
|
|
934
|
+
const result = await runTool(t, { branchName: "pr-7", force: false });
|
|
935
|
+
expect(result.isError).toBeUndefined();
|
|
936
|
+
expect(mocks.$git).toHaveBeenCalledWith("push", ["-u", "origin", "pr-7:their-branch"], {
|
|
937
|
+
token: "test-token",
|
|
938
|
+
disableHooks: true,
|
|
939
|
+
});
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it("blocks pushes to the default branch in restricted mode", async () => {
|
|
943
|
+
dispatch({
|
|
944
|
+
"git status --porcelain": "",
|
|
945
|
+
"git remote get-url --push origin": REMOTE_URL,
|
|
946
|
+
});
|
|
947
|
+
const t = PushBranchTool(
|
|
948
|
+
makeCtx({
|
|
949
|
+
push: "restricted",
|
|
950
|
+
pushDest: { remoteName: "origin", remoteBranch: "main", localBranch: "feature" },
|
|
951
|
+
}),
|
|
952
|
+
);
|
|
953
|
+
const result = await runTool(t, { branchName: "feature", force: false });
|
|
954
|
+
expect(result.isError).toBe(true);
|
|
955
|
+
expect(textOf(result)).toContain("cannot push directly to default branch 'main'");
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
it("uses --force and reports it in the result", async () => {
|
|
959
|
+
happyDispatch();
|
|
960
|
+
const t = PushBranchTool(featureCtx());
|
|
961
|
+
const result = await runTool(t, { branchName: "feature", force: true });
|
|
962
|
+
expect(result.isError).toBeUndefined();
|
|
963
|
+
expect(mocks.$git).toHaveBeenCalledWith("push", ["--force", "-u", "origin", "feature"], {
|
|
964
|
+
token: "test-token",
|
|
965
|
+
disableHooks: true,
|
|
966
|
+
});
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
it("renders concurrent-push recovery instructions (merge-only under shell: disabled)", async () => {
|
|
970
|
+
happyDispatch();
|
|
971
|
+
mocks.$git.mockRejectedValue(
|
|
972
|
+
new Error("! [rejected] feature -> feature (fetch first)\nhint: Updates were rejected"),
|
|
973
|
+
);
|
|
974
|
+
const t = PushBranchTool(featureCtx({ shell: "disabled" }));
|
|
975
|
+
const result = await runTool(t, { branchName: "feature", force: false });
|
|
976
|
+
expect(result.isError).toBe(true);
|
|
977
|
+
const text = textOf(result);
|
|
978
|
+
expect(text).toContain("push rejected: the remote branch 'feature' has new commits");
|
|
979
|
+
expect(text).toContain('git_fetch({ ref: "feature" })');
|
|
980
|
+
expect(text).toContain('git({ command: "merge", args: ["origin/feature"] })');
|
|
981
|
+
expect(text).not.toContain("rebase");
|
|
982
|
+
expect(text).toContain("retry push_branch");
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
it("offers rebase as an alternative in the recovery when shell is not disabled", async () => {
|
|
986
|
+
happyDispatch();
|
|
987
|
+
mocks.$git.mockRejectedValue(new Error("! [rejected] feature -> feature (non-fast-forward)"));
|
|
988
|
+
const t = PushBranchTool(featureCtx({ shell: "enabled" }));
|
|
989
|
+
const result = await runTool(t, { branchName: "feature", force: false });
|
|
990
|
+
expect(textOf(result)).toContain("(or 'rebase')");
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
it("rethrows non-concurrent push errors verbatim", async () => {
|
|
994
|
+
happyDispatch();
|
|
995
|
+
mocks.$git.mockRejectedValue(new Error("remote rejected (repository rule violations)"));
|
|
996
|
+
const t = PushBranchTool(featureCtx());
|
|
997
|
+
const result = await runTool(t, { branchName: "feature", force: false });
|
|
998
|
+
expect(result.isError).toBe(true);
|
|
999
|
+
expect(textOf(result)).toContain("repository rule violations");
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
describe("prepush hook handling", () => {
|
|
1003
|
+
it("runs the hook and pushes when it succeeds without mutating the tree", async () => {
|
|
1004
|
+
dispatch({
|
|
1005
|
+
"git status --porcelain": ["", ""],
|
|
1006
|
+
"git remote get-url --push origin": REMOTE_URL,
|
|
1007
|
+
"git rev-parse HEAD": "abc1234",
|
|
1008
|
+
});
|
|
1009
|
+
mocks.executeLifecycleHook.mockResolvedValue({});
|
|
1010
|
+
const t = PushBranchTool(featureCtx({ prepushScript: "make check" }));
|
|
1011
|
+
const result = await runTool(t, { branchName: "feature", force: false });
|
|
1012
|
+
expect(result.isError).toBeUndefined();
|
|
1013
|
+
expect(mocks.executeLifecycleHook).toHaveBeenCalledWith({
|
|
1014
|
+
event: "prepush",
|
|
1015
|
+
script: "make check",
|
|
1016
|
+
});
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
it("renders exit failures with script output and disabled-shell guidance", async () => {
|
|
1020
|
+
happyDispatch();
|
|
1021
|
+
mocks.executeLifecycleHook.mockResolvedValue({
|
|
1022
|
+
failure: { kind: "exit", exitCode: 2, output: "lint exploded" },
|
|
1023
|
+
});
|
|
1024
|
+
const ctx = featureCtx({ prepushScript: "make check", shell: "disabled" });
|
|
1025
|
+
const result = await runTool(PushBranchTool(ctx), { branchName: "feature", force: false });
|
|
1026
|
+
expect(result.isError).toBe(true);
|
|
1027
|
+
const text = textOf(result);
|
|
1028
|
+
expect(text).toContain("prepush hook failed with exit code 2");
|
|
1029
|
+
expect(text).toContain("lint exploded");
|
|
1030
|
+
expect(text).toContain("shell access is disabled");
|
|
1031
|
+
expect(text).toContain("next push_branch call will SKIP the hook");
|
|
1032
|
+
expect(
|
|
1033
|
+
(ctx as unknown as { toolState: { prepushFailureCount: number } }).toolState
|
|
1034
|
+
.prepushFailureCount,
|
|
1035
|
+
).toBe(1);
|
|
1036
|
+
expect(mocks.$git).not.toHaveBeenCalled();
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
it("renders timeout failures with shell-enabled iterate guidance", async () => {
|
|
1040
|
+
happyDispatch();
|
|
1041
|
+
mocks.executeLifecycleHook.mockResolvedValue({ failure: { kind: "timeout" } });
|
|
1042
|
+
const ctx = featureCtx({ prepushScript: "make check", shell: "enabled" });
|
|
1043
|
+
const result = await runTool(PushBranchTool(ctx), { branchName: "feature", force: false });
|
|
1044
|
+
const text = textOf(result);
|
|
1045
|
+
expect(text).toContain("timed out");
|
|
1046
|
+
expect(text).toContain("run the hook command yourself via the shell tool");
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
it("renders spawn failures", async () => {
|
|
1050
|
+
happyDispatch();
|
|
1051
|
+
mocks.executeLifecycleHook.mockResolvedValue({
|
|
1052
|
+
failure: { kind: "spawn", spawnError: "ENOENT" },
|
|
1053
|
+
});
|
|
1054
|
+
const ctx = featureCtx({ prepushScript: "make check" });
|
|
1055
|
+
const result = await runTool(PushBranchTool(ctx), { branchName: "feature", force: false });
|
|
1056
|
+
expect(textOf(result)).toContain("failed to spawn: ENOENT");
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
it("blocks the push when the hook mutates tracked files", async () => {
|
|
1060
|
+
dispatch({
|
|
1061
|
+
"git status --porcelain": ["", " M generated.ts"],
|
|
1062
|
+
"git remote get-url --push origin": REMOTE_URL,
|
|
1063
|
+
});
|
|
1064
|
+
mocks.executeLifecycleHook.mockResolvedValue({});
|
|
1065
|
+
const t = PushBranchTool(featureCtx({ prepushScript: "make check" }));
|
|
1066
|
+
const result = await runTool(t, { branchName: "feature", force: false });
|
|
1067
|
+
expect(result.isError).toBe(true);
|
|
1068
|
+
const text = textOf(result);
|
|
1069
|
+
expect(text).toContain("the prepush hook modified the working tree");
|
|
1070
|
+
expect(text).toContain(" M generated.ts");
|
|
1071
|
+
expect(mocks.$git).not.toHaveBeenCalled();
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
it("skips the hook after an earlier failure and says so in the result", async () => {
|
|
1075
|
+
happyDispatch();
|
|
1076
|
+
const t = PushBranchTool(featureCtx({ prepushScript: "make check", prepushFailureCount: 1 }));
|
|
1077
|
+
const result = await runTool(t, { branchName: "feature", force: false });
|
|
1078
|
+
expect(result.isError).toBeUndefined();
|
|
1079
|
+
expect(mocks.executeLifecycleHook).not.toHaveBeenCalled();
|
|
1080
|
+
expect(textOf(result)).toContain("prepush hook skipped");
|
|
1081
|
+
});
|
|
1082
|
+
});
|
|
1083
|
+
});
|