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
package/src/mcp/git.ts
ADDED
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { regex } from "arkregex";
|
|
5
|
+
import { type } from "arktype";
|
|
6
|
+
import {
|
|
7
|
+
assertNoBlockedDestroy,
|
|
8
|
+
assertNoSecretsInDiff,
|
|
9
|
+
enforceProtectedPaths,
|
|
10
|
+
enforceRemediationPaths,
|
|
11
|
+
} from "#app/mcp/guardrails";
|
|
12
|
+
import type { ToolContext } from "#app/mcp/server";
|
|
13
|
+
import { execute, tool } from "#app/mcp/shared";
|
|
14
|
+
import type { StoredPushDest } from "#app/toolState";
|
|
15
|
+
import { log } from "#app/utils/cli";
|
|
16
|
+
import { $git, $gitFetchWithDeepen } from "#app/utils/gitAuth";
|
|
17
|
+
import { executeLifecycleHook, type LifecycleHookFailure } from "#app/utils/lifecycle";
|
|
18
|
+
import { $ } from "#app/utils/shell";
|
|
19
|
+
|
|
20
|
+
type PushDestination = {
|
|
21
|
+
remoteName: string;
|
|
22
|
+
remoteBranch: string;
|
|
23
|
+
url: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* get where git would actually push this branch.
|
|
28
|
+
* prefers the stored destination from toolState (set by checkout_pr) when it
|
|
29
|
+
* matches the current branch, because git config reads can silently fail in
|
|
30
|
+
* certain environments causing pushes to the wrong remote branch.
|
|
31
|
+
*
|
|
32
|
+
* falls back to reading branch.X.pushRemote and branch.X.merge from git config,
|
|
33
|
+
* and finally to origin/<branch> for branches created without checkout_pr.
|
|
34
|
+
*/
|
|
35
|
+
function getPushDestination(
|
|
36
|
+
branch: string,
|
|
37
|
+
storedDest: StoredPushDest | undefined,
|
|
38
|
+
): PushDestination {
|
|
39
|
+
// prefer stored destination from checkout_pr when it matches the current branch
|
|
40
|
+
if (storedDest && storedDest.localBranch === branch) {
|
|
41
|
+
log.debug(`using stored push destination: ${storedDest.remoteName}/${storedDest.remoteBranch}`);
|
|
42
|
+
const url = $("git", ["remote", "get-url", "--push", storedDest.remoteName], {
|
|
43
|
+
log: false,
|
|
44
|
+
}).trim();
|
|
45
|
+
return { remoteName: storedDest.remoteName, remoteBranch: storedDest.remoteBranch, url };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// fall back to git config (for branches not created by checkout_pr)
|
|
49
|
+
try {
|
|
50
|
+
const pushRemote = $("git", ["config", `branch.${branch}.pushRemote`], { log: false }).trim();
|
|
51
|
+
const merge = $("git", ["config", `branch.${branch}.merge`], { log: false }).trim();
|
|
52
|
+
const remoteBranch = merge.replace(/^refs\/heads\//, "");
|
|
53
|
+
const url = $("git", ["remote", "get-url", "--push", pushRemote], { log: false }).trim();
|
|
54
|
+
return { remoteName: pushRemote, remoteBranch, url };
|
|
55
|
+
} catch {
|
|
56
|
+
// no push config - branch was created locally without checkout_pr
|
|
57
|
+
log.debug(`no push config for ${branch}, falling back to origin/${branch}`);
|
|
58
|
+
const url = $("git", ["remote", "get-url", "--push", "origin"], { log: false }).trim();
|
|
59
|
+
return { remoteName: "origin", remoteBranch: branch, url };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* normalize URL for comparison (handle .git suffix, case)
|
|
65
|
+
*/
|
|
66
|
+
function normalizeUrl(url: string): string {
|
|
67
|
+
return url.replace(/\.git$/, "").toLowerCase();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// SECURITY: reject refs/branch names that begin with "-". git's parseopt
|
|
71
|
+
// accepts options intermixed with positional args, so a ref like
|
|
72
|
+
// "--upload-pack=evil" could be interpreted as a flag rather than a refspec.
|
|
73
|
+
export function rejectIfLeadingDash(value: string, kind: string): void {
|
|
74
|
+
if (value.startsWith("-")) {
|
|
75
|
+
throw new Error(`Blocked: ${kind} '${value}' starts with '-' — git could parse it as a flag.`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// SECURITY: branch inputs to push/delete must be bare branch names. a branch
|
|
80
|
+
// name like "refs/heads/main" bypasses the restricted-mode default-branch
|
|
81
|
+
// check below (which does exact-string compare against "main"), and symbolic
|
|
82
|
+
// refs (HEAD / FETCH_HEAD / ORIG_HEAD / MERGE_HEAD) would resolve to
|
|
83
|
+
// whatever commit those refs point at — both routes let an agent push to
|
|
84
|
+
// protected branches even under push: restricted. checkout_pr only ever
|
|
85
|
+
// stores bare names like "pr-123", so nothing legitimate relies on the
|
|
86
|
+
// refs/... form here.
|
|
87
|
+
const SYMBOLIC_REFS = new Set(["HEAD", "FETCH_HEAD", "ORIG_HEAD", "MERGE_HEAD"]);
|
|
88
|
+
export function rejectSpecialRef(value: string, kind: string): void {
|
|
89
|
+
rejectIfLeadingDash(value, kind);
|
|
90
|
+
if (value.startsWith("refs/")) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Blocked: ${kind} '${value}' is a fully-qualified ref path. Use a bare branch name (e.g. 'feature/foo' or 'main'), not a 'refs/heads/...' form.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (SYMBOLIC_REFS.has(value)) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Blocked: ${kind} '${value}' is a git symbolic ref, not a branch name. Pass the resolved branch name (e.g. 'main'), or omit branchName to push the current branch.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
// SECURITY: git interprets ':' and leading '+' as refspec syntax, not as
|
|
101
|
+
// part of a branch name. without this check, an agent under push:restricted
|
|
102
|
+
// can smuggle a full refspec through branchName:
|
|
103
|
+
// - "evil:refs/heads/main" → pushes local 'evil' to remote main
|
|
104
|
+
// - ":refs/heads/main" → deletes remote main
|
|
105
|
+
// - ":other" → deletes remote 'other' under push:restricted
|
|
106
|
+
// - "+main" → force-push refspec
|
|
107
|
+
// the default-branch guard downstream is an exact-string compare, so any
|
|
108
|
+
// character that lets git parse the value as <src>:<dst> (or as a force
|
|
109
|
+
// prefix) bypasses it. git's own check-ref-format forbids ':', '+', '^',
|
|
110
|
+
// '~', '?', '*', '[', '\\', and whitespace in branch names, so rejecting
|
|
111
|
+
// them here cannot false-positive against a legitimate branch name.
|
|
112
|
+
const BAD = /[:+^~?*[\\\s]/;
|
|
113
|
+
const badMatch = value.match(BAD);
|
|
114
|
+
if (badMatch) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Blocked: ${kind} '${value}' contains '${badMatch[0]}', which git interprets as refspec/revision syntax, not as part of a branch name.`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// SECURITY: validate tag names so the push_tags refspec can't be split into
|
|
122
|
+
// a <src>:<dst> refspec that targets a non-tag ref. without this, a tag like
|
|
123
|
+
// "foo:refs/heads/main" becomes "refs/tags/foo:refs/heads/main" and git
|
|
124
|
+
// pushes the local tag's commit to remote main — a back door around the
|
|
125
|
+
// branch-push rules in push_branch. keep the allow-list conservative (git's
|
|
126
|
+
// own check-ref-format forbids far more, but we only need enough to block
|
|
127
|
+
// refspec injection).
|
|
128
|
+
export function validateTagName(tag: string): void {
|
|
129
|
+
rejectIfLeadingDash(tag, "tag");
|
|
130
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(tag)) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`Blocked: tag '${tag}' contains characters that could be parsed as a refspec or flag. Tags must match [A-Za-z0-9._/-]+.`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* validate that the push destination matches expected URL.
|
|
139
|
+
* pushUrl is set by setupGit (base repo) and updated by checkout_pr (fork repo).
|
|
140
|
+
*/
|
|
141
|
+
function validatePushDestination(ctx: ToolContext, branch: string): PushDestination {
|
|
142
|
+
const pushUrl = ctx.toolState.pushUrl;
|
|
143
|
+
if (!pushUrl) throw new Error("pushUrl not set - setupGit must run before push_branch");
|
|
144
|
+
|
|
145
|
+
const dest = getPushDestination(branch, ctx.toolState.pushDest);
|
|
146
|
+
|
|
147
|
+
if (normalizeUrl(dest.url) !== normalizeUrl(pushUrl)) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`Push blocked: destination does not match expected repository.\n` +
|
|
150
|
+
`Expected: ${pushUrl}\n` +
|
|
151
|
+
`Actual: ${dest.url}\n` +
|
|
152
|
+
`Git configuration may have been tampered with.`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return dest;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export const PushBranch = type({
|
|
160
|
+
branchName: type.string
|
|
161
|
+
.describe("The branch name to push (defaults to current branch)")
|
|
162
|
+
.optional(),
|
|
163
|
+
force: type.boolean.describe("Force push (use with caution)").default(false),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// classify an error from `$git("push", ...)` to decide retry vs. recovery
|
|
167
|
+
// vs. rethrow. exported for tests.
|
|
168
|
+
//
|
|
169
|
+
// - `concurrent-push`: server-side compare-and-swap failed because the ref
|
|
170
|
+
// advanced between fetch and push. recovery is fetch + integrate + retry.
|
|
171
|
+
// matches both the client-side detection (`fetch first` /
|
|
172
|
+
// `non-fast-forward`) and the server-side detection (`cannot lock ref`
|
|
173
|
+
// with `is at <SHA1> but expected <SHA2>`).
|
|
174
|
+
// - `transient`: network or upstream server hiccup (RPC failed mid-stream,
|
|
175
|
+
// HTTP 5xx, early EOF, reset, timeout, dns flake). push is idempotent so
|
|
176
|
+
// verbatim retry with backoff is safe.
|
|
177
|
+
// - `unknown`: anything else (including auth/permission/protected-branch
|
|
178
|
+
// rejections). retrying these wastes time; surface to the caller.
|
|
179
|
+
//
|
|
180
|
+
// kept conservative: a misclassification of `unknown` -> `transient` would
|
|
181
|
+
// cause two extra round-trips on a permanently-failing push, while the
|
|
182
|
+
// reverse (true transient labeled `unknown`) just falls back to current
|
|
183
|
+
// behavior. so we only mark as transient when the error string is
|
|
184
|
+
// unambiguously a network/server-side fault, not a refusal.
|
|
185
|
+
export type PushErrorKind = "concurrent-push" | "transient" | "unknown";
|
|
186
|
+
|
|
187
|
+
const CONCURRENT_PUSH_PATTERNS = ["fetch first", "non-fast-forward", "cannot lock ref"] as const;
|
|
188
|
+
|
|
189
|
+
const TRANSIENT_PATTERNS: RegExp[] = [
|
|
190
|
+
/RPC failed/i,
|
|
191
|
+
/early EOF/,
|
|
192
|
+
/the remote end hung up unexpectedly/,
|
|
193
|
+
/Connection reset/i,
|
|
194
|
+
/Could not resolve host/i,
|
|
195
|
+
/Operation timed out/i,
|
|
196
|
+
/HTTP\/2 stream \d+ was not closed cleanly/i,
|
|
197
|
+
/unexpected disconnect while reading sideband packet/i,
|
|
198
|
+
// libcurl HTTP 5xx surfaced by git over https. matches both the
|
|
199
|
+
// libcurl-style "The requested URL returned error: 502" and the more
|
|
200
|
+
// recent "HTTP 502" wording. most 4xx is intentionally excluded —
|
|
201
|
+
// 401/403/404 indicate auth/permission problems that are not
|
|
202
|
+
// retry-safe — but 429 (rate-limited / abuse detection) IS retry-safe
|
|
203
|
+
// and GitHub occasionally surfaces it on git push, so it's included
|
|
204
|
+
// explicitly below.
|
|
205
|
+
/HTTP 5\d\d/,
|
|
206
|
+
/returned error: 5\d\d/i,
|
|
207
|
+
/HTTP 429/,
|
|
208
|
+
/returned error: 429/i,
|
|
209
|
+
// github installation tokens can 401 for seconds after minting while
|
|
210
|
+
// replicating (@octokit/auth-app retries the same class). git push
|
|
211
|
+
// surfaces it as "Invalid username or token", distinct from 403
|
|
212
|
+
// permission denied — safe to backoff-retry with the same token.
|
|
213
|
+
/Invalid username or token/,
|
|
214
|
+
/Authentication failed for 'https:\/\/github\.com\//,
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
export function classifyPushError(msg: string): PushErrorKind {
|
|
218
|
+
if (CONCURRENT_PUSH_PATTERNS.some((p) => msg.includes(p))) return "concurrent-push";
|
|
219
|
+
if (TRANSIENT_PATTERNS.some((p) => p.test(msg))) return "transient";
|
|
220
|
+
return "unknown";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// exponential backoff delays before retry attempts 2-6. attempt 1 is the
|
|
224
|
+
// original push. total worst-case added latency: ~60s. larger than it looks
|
|
225
|
+
// like it needs to be, on purpose: github installation-token replication lag
|
|
226
|
+
// can exceed 20s, and the same token surfaces as "Invalid username or token"
|
|
227
|
+
// until it propagates to the push edge. re-minting does not help (a fresh
|
|
228
|
+
// token has the same lag), so the cure is to wait out the propagation with
|
|
229
|
+
// the same token. a short window reddens CI (notably the push-restricted
|
|
230
|
+
// e2e); ~60s rides it out while still bounding a permanently-failing push.
|
|
231
|
+
const TRANSIENT_RETRY_DELAYS_MS = [2000, 4000, 8000, 16000, 30000];
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* push with backoff retry on transient failures (network 5xx, connection
|
|
235
|
+
* reset, and the freshly-minted-token 401 github surfaces as "Invalid
|
|
236
|
+
* username or token" while the installation token replicates across edges —
|
|
237
|
+
* see TRANSIENT_PATTERNS). concurrent-push and permission rejections are not
|
|
238
|
+
* retried — they need caller intervention.
|
|
239
|
+
*
|
|
240
|
+
* shared by push_branch, push_tags, and delete_branch so all three are
|
|
241
|
+
* equally resilient to github's post-mint replication lag. before this,
|
|
242
|
+
* only push_branch retried, so a tag push or branch delete that happened to
|
|
243
|
+
* hit an un-replicated edge failed outright even though the token was valid.
|
|
244
|
+
*/
|
|
245
|
+
async function pushWithRetry(args: string[], token: string, disableHooks: boolean): Promise<void> {
|
|
246
|
+
let lastErr: unknown;
|
|
247
|
+
for (let attempt = 0; attempt <= TRANSIENT_RETRY_DELAYS_MS.length; attempt++) {
|
|
248
|
+
try {
|
|
249
|
+
await $git("push", args, { token, disableHooks });
|
|
250
|
+
if (attempt > 0) log.info(`push succeeded on attempt ${attempt + 1}`);
|
|
251
|
+
return;
|
|
252
|
+
} catch (err) {
|
|
253
|
+
lastErr = err;
|
|
254
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
255
|
+
if (classifyPushError(msg) === "transient" && attempt < TRANSIENT_RETRY_DELAYS_MS.length) {
|
|
256
|
+
// jitter avoids lockstep retries when several agents are hit by the
|
|
257
|
+
// same upstream blip simultaneously.
|
|
258
|
+
const baseDelay = TRANSIENT_RETRY_DELAYS_MS[attempt] ?? 5000;
|
|
259
|
+
const delay = Math.round(baseDelay * (0.75 + Math.random() * 0.5));
|
|
260
|
+
log.info(
|
|
261
|
+
`push attempt ${attempt + 1} failed (transient), retrying in ${delay}ms: ${msg.slice(0, 300)}`,
|
|
262
|
+
);
|
|
263
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
throw err;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function PushBranchTool(ctx: ToolContext) {
|
|
273
|
+
const defaultBranch = ctx.repo.data.default_branch || "main";
|
|
274
|
+
const pushPermission = ctx.payload.push;
|
|
275
|
+
|
|
276
|
+
return tool({
|
|
277
|
+
name: "push_branch",
|
|
278
|
+
description:
|
|
279
|
+
"Push the current branch to the remote repository. Omit branchName to push the current branch (recommended). " +
|
|
280
|
+
'Example: `push_branch({})` to push the current branch. Example: `push_branch({ branchName: "pr-1" })` to push a specific local branch. ' +
|
|
281
|
+
"If specifying branchName, use the LOCAL branch name (e.g., 'pr-1'), not the remote branch name. " +
|
|
282
|
+
"The correct remote and remote branch are determined automatically from branch config set by checkout_pr. " +
|
|
283
|
+
"Requires a clean working tree. Runs the repository prepush hook (if configured) — best-effort. If the hook fails, the tool returns the failure output and every subsequent call this run skips the hook. " +
|
|
284
|
+
"Never force push unless explicitly requested. Pushes to the default branch are blocked in restricted mode. " +
|
|
285
|
+
"If the response reports a timeout, the underlying push may have actually succeeded — verify with `git log origin/<branch>` (or this tool with command 'log') before retrying, otherwise you'll push a duplicate.",
|
|
286
|
+
parameters: PushBranch,
|
|
287
|
+
execute: execute(async ({ branchName, force }) => {
|
|
288
|
+
// permission check
|
|
289
|
+
if (pushPermission === "disabled") {
|
|
290
|
+
throw new Error("Push is disabled. This repository is configured for read-only access.");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
|
|
294
|
+
// check the resolved branch too — rev-parse could surface a weird current
|
|
295
|
+
// branch name that would otherwise bypass the user-facing check. use
|
|
296
|
+
// rejectSpecialRef so "refs/heads/main" and symbolic refs like HEAD
|
|
297
|
+
// can't slip past the default-branch guard below.
|
|
298
|
+
rejectSpecialRef(branch, "branch");
|
|
299
|
+
|
|
300
|
+
// reject push if working tree is dirty — forces agent to commit or discard before pushing
|
|
301
|
+
const status = $("git", ["status", "--porcelain"], { log: false });
|
|
302
|
+
if (status) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`push blocked: working tree is not clean (tracked changes and/or untracked files). commit, discard, or remove stray artifacts before pushing.\n\n` +
|
|
305
|
+
`git status:\n${status}` +
|
|
306
|
+
(ctx.toolState.prepushFailureCount > 0
|
|
307
|
+
? "\n\nnote: the prepush hook failed earlier this run — once the working tree is clean, push_branch will skip the hook."
|
|
308
|
+
: ""),
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Remediate-mode guardrail: refuse to push if the run touched files
|
|
313
|
+
// outside the Terraform allow-list. No-op in every other mode.
|
|
314
|
+
enforceRemediationPaths(ctx);
|
|
315
|
+
|
|
316
|
+
// Remediate-mode guardrail: refuse to push if terraform_plan showed the
|
|
317
|
+
// change would destroy/replace a stateful (data-bearing) resource, unless
|
|
318
|
+
// the operator allowed it via `allow_replace`. No-op when no plan ran.
|
|
319
|
+
assertNoBlockedDestroy(ctx);
|
|
320
|
+
|
|
321
|
+
// §2.7 — refuse to push if the run touched a path the operator marked
|
|
322
|
+
// never-auto-modify via `protected_paths`. No-op when unset.
|
|
323
|
+
enforceProtectedPaths(ctx);
|
|
324
|
+
|
|
325
|
+
// §2.8 — refuse to push if the diff inlines a literal secret (a fix must
|
|
326
|
+
// reference a variable/secret store, never paste the value).
|
|
327
|
+
assertNoSecretsInDiff(ctx);
|
|
328
|
+
|
|
329
|
+
// validate push destination matches expected URL
|
|
330
|
+
const pushDest = validatePushDestination(ctx, branch);
|
|
331
|
+
|
|
332
|
+
// backstop against subagent-induced cross-PR clobbers: a subagent
|
|
333
|
+
// shares cwd + toolState with the orchestrator, so its `checkout_pr(N)`
|
|
334
|
+
// moves HEAD to pr-N and persists pushDest pointing at the foreign
|
|
335
|
+
// PR's remote branch. refuse pr-N → origin/<other> pushes unless this
|
|
336
|
+
// run is itself scoped to PR N (zed-industries/cloud, 2026-05-18).
|
|
337
|
+
const prBranchMatch = branch.match(/^pr-(\d+)$/);
|
|
338
|
+
if (prBranchMatch && pushDest.remoteBranch !== branch) {
|
|
339
|
+
const prNumber = Number(prBranchMatch[1]);
|
|
340
|
+
const event = ctx.payload.event;
|
|
341
|
+
const runScoped = event.is_pr === true && event.issue_number === prNumber;
|
|
342
|
+
if (!runScoped) {
|
|
343
|
+
throw new Error(
|
|
344
|
+
`push blocked: local branch '${branch}' would push to '${pushDest.remoteName}/${pushDest.remoteBranch}', ` +
|
|
345
|
+
`but this run is not scoped to PR #${prNumber}. ` +
|
|
346
|
+
`the 'pr-${prNumber}' branch was created by a prior checkout_pr call (likely from a subagent — subagents share the working tree and toolState with the orchestrator). ` +
|
|
347
|
+
`you have probably landed your commit on the wrong branch. ` +
|
|
348
|
+
`switch to your own feature branch first (e.g. 'git checkout <feature-branch>') and then push. ` +
|
|
349
|
+
`if the push to PR #${prNumber} is intentional, this run needs to be triggered against that PR.`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// block pushes to default branch in restricted mode
|
|
355
|
+
if (pushPermission === "restricted" && pushDest.remoteBranch === defaultBranch) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
`Push blocked: cannot push directly to default branch '${pushDest.remoteBranch}'. ` +
|
|
358
|
+
`Create a feature branch and open a PR instead.`,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// use refspec when local and remote branch names differ
|
|
363
|
+
const refspec =
|
|
364
|
+
branch === pushDest.remoteBranch ? branch : `${branch}:${pushDest.remoteBranch}`;
|
|
365
|
+
const pushArgs = force
|
|
366
|
+
? ["--force", "-u", pushDest.remoteName, refspec]
|
|
367
|
+
: ["-u", pushDest.remoteName, refspec];
|
|
368
|
+
|
|
369
|
+
const prepushSkipped = ctx.toolState.prepushFailureCount > 0;
|
|
370
|
+
if (prepushSkipped) {
|
|
371
|
+
log.info(`» skipping prepush hook (failed earlier this run)`);
|
|
372
|
+
} else if (ctx.prepushScript) {
|
|
373
|
+
const prepushHook = await executeLifecycleHook({
|
|
374
|
+
event: "prepush",
|
|
375
|
+
script: ctx.prepushScript,
|
|
376
|
+
});
|
|
377
|
+
if (prepushHook.failure) {
|
|
378
|
+
ctx.toolState.prepushFailureCount += 1;
|
|
379
|
+
throw new Error(buildPrepushFailureMessage(prepushHook.failure, ctx.payload.shell));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// re-verify clean working tree after prepush. a hook that writes tracked
|
|
383
|
+
// files (formatter, type generator, build artifacts) would leave those
|
|
384
|
+
// changes uncommitted — pushing now would silently drop them, and the
|
|
385
|
+
// agent would report a "successful push" of code the hook had expected
|
|
386
|
+
// to be included.
|
|
387
|
+
const postHookStatus = $("git", ["status", "--porcelain"], { log: false });
|
|
388
|
+
if (postHookStatus) {
|
|
389
|
+
throw new Error(
|
|
390
|
+
`push blocked: the prepush hook modified the working tree. those changes are not included in the push. commit or discard them (or change the hook to not mutate tracked files) before retrying.\n\n` +
|
|
391
|
+
`git status:\n${postHookStatus}`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
log.debug(`pushing ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch}`);
|
|
397
|
+
if (force) {
|
|
398
|
+
log.warning(`force pushing - this will overwrite remote history`);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// push is idempotent, so pushWithRetry rides out transient failures
|
|
402
|
+
// (5xx, reset, freshly-minted-token 401). concurrent-push is not
|
|
403
|
+
// transient — it surfaces here so we can render the integrate-and-retry
|
|
404
|
+
// recovery the agent needs.
|
|
405
|
+
try {
|
|
406
|
+
await pushWithRetry(pushArgs, ctx.gitToken, ctx.payload.shell !== "enabled");
|
|
407
|
+
} catch (err) {
|
|
408
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
409
|
+
if (classifyPushError(msg) === "concurrent-push") {
|
|
410
|
+
// git rebase is blocked through the MCP tool when shell is disabled
|
|
411
|
+
// (rebase --exec can execute arbitrary code). merge always works and
|
|
412
|
+
// integrates remote changes cleanly, so suggest it as the default.
|
|
413
|
+
const integrateStep =
|
|
414
|
+
ctx.payload.shell === "disabled"
|
|
415
|
+
? `2. use the git tool to merge the remote branch into yours: git({ command: "merge", args: ["origin/${pushDest.remoteBranch}"] })`
|
|
416
|
+
: `2. use the git tool to rebase or merge your changes on top: git({ command: "merge", args: ["origin/${pushDest.remoteBranch}"] }) (or 'rebase')`;
|
|
417
|
+
throw new Error(
|
|
418
|
+
`push rejected: the remote branch '${pushDest.remoteBranch}' has new commits you don't have locally (often a concurrent push to the same branch).\n\n` +
|
|
419
|
+
`to resolve this:\n` +
|
|
420
|
+
`1. use git_fetch to fetch the remote branch: git_fetch({ ref: "${pushDest.remoteBranch}" })\n` +
|
|
421
|
+
`${integrateStep}\n` +
|
|
422
|
+
`3. resolve any merge conflicts if needed\n` +
|
|
423
|
+
`4. retry push_branch`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
throw err;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const pushedSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
|
|
430
|
+
log.info(
|
|
431
|
+
`» pushed branch ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch} (sha ${pushedSha})`,
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
const baseMsg = `successfully pushed ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch}`;
|
|
435
|
+
const message = prepushSkipped
|
|
436
|
+
? `${baseMsg} (prepush hook skipped — failed earlier this run).`
|
|
437
|
+
: baseMsg;
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
success: true,
|
|
441
|
+
branch,
|
|
442
|
+
remoteBranch: pushDest.remoteBranch,
|
|
443
|
+
remote: pushDest.remoteName,
|
|
444
|
+
force,
|
|
445
|
+
prepushSkipped,
|
|
446
|
+
message,
|
|
447
|
+
};
|
|
448
|
+
}),
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** agent-facing prepush failure message: script output + bypass guidance,
|
|
453
|
+
* with no generic lifecycle retry advice (which would conflict). */
|
|
454
|
+
function buildPrepushFailureMessage(
|
|
455
|
+
failure: LifecycleHookFailure,
|
|
456
|
+
shell: ToolContext["payload"]["shell"],
|
|
457
|
+
): string {
|
|
458
|
+
const header =
|
|
459
|
+
failure.kind === "exit"
|
|
460
|
+
? `prepush hook failed with exit code ${failure.exitCode}.\n\nscript output:\n${failure.output || "(empty)"}`
|
|
461
|
+
: failure.kind === "timeout"
|
|
462
|
+
? `prepush hook timed out — the script is hung or doing too much work.`
|
|
463
|
+
: `prepush hook failed to spawn: ${failure.spawnError}.`;
|
|
464
|
+
|
|
465
|
+
const ifRealBug =
|
|
466
|
+
shell === "disabled"
|
|
467
|
+
? `fix it before pushing again — shell access is disabled in this run, so you can't re-run the hook command yourself.`
|
|
468
|
+
: `run the hook command yourself via the shell tool to iterate (push_branch will NOT re-run it).`;
|
|
469
|
+
|
|
470
|
+
return (
|
|
471
|
+
`${header}\n\n` +
|
|
472
|
+
`this repo's prepush hook is best-effort: the next push_branch call will SKIP the hook and proceed. ` +
|
|
473
|
+
`if the failure is unrelated to your changes (pre-existing breakage, flaky check), just call push_branch again. ` +
|
|
474
|
+
`if it could be a real bug in your code, ${ifRealBug}`
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// commands that require authentication - redirect to dedicated tools.
|
|
479
|
+
// exported so tests can exercise the same table the runtime uses.
|
|
480
|
+
//
|
|
481
|
+
// note: the `pull` redirect intentionally does not mention `rebase` — under
|
|
482
|
+
// shell=disabled rebase is itself blocked by NOSHELL_BLOCKED_SUBCOMMANDS, so
|
|
483
|
+
// advertising it here would just send the agent into a second block. agents
|
|
484
|
+
// under shell=restricted/enabled who prefer rebase can invoke it directly;
|
|
485
|
+
// the redirect's job is to name the canonical alternative (merge), which
|
|
486
|
+
// works in all modes.
|
|
487
|
+
export const AUTH_REQUIRED_REDIRECT: Record<string, string> = {
|
|
488
|
+
push: "use the push_branch tool instead — it handles authentication and permission checks.",
|
|
489
|
+
fetch: "use the git_fetch tool instead — it handles authentication.",
|
|
490
|
+
pull: "use git_fetch to fetch the remote ref, then call this git tool with command 'merge' locally.",
|
|
491
|
+
clone: "the repository is already cloned. use checkout_pr for PR branches.",
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
// SECURITY: subcommands blocked when shell is disabled.
|
|
495
|
+
// in disabled mode the agent has no shell access, so these subcommands are the
|
|
496
|
+
// primary escape vectors for arbitrary code execution. in restricted mode the
|
|
497
|
+
// agent already has shell in a stripped sandbox, so blocking these is redundant.
|
|
498
|
+
// exported so tests stay in sync with the runtime table.
|
|
499
|
+
export const NOSHELL_BLOCKED_SUBCOMMANDS: Record<string, string> = {
|
|
500
|
+
config: "Blocked: git config can set up filter drivers or hooks that execute arbitrary code.",
|
|
501
|
+
submodule:
|
|
502
|
+
"Blocked: git submodule can reference malicious repositories and execute code on update.",
|
|
503
|
+
"update-index":
|
|
504
|
+
"Blocked: git update-index can modify index entries in ways that bypass file protections.",
|
|
505
|
+
"filter-branch": "Blocked: git filter-branch executes arbitrary code on repository history.",
|
|
506
|
+
replace: "Blocked: git replace can redirect object lookups.",
|
|
507
|
+
// subcommands that accept --exec or similar flags for arbitrary code execution
|
|
508
|
+
rebase:
|
|
509
|
+
"Blocked: git rebase --exec can execute arbitrary shell commands. Use 'merge' instead to integrate remote changes.",
|
|
510
|
+
bisect:
|
|
511
|
+
"Blocked: git bisect run can execute arbitrary shell commands. Bisect by hand (bisect start/good/bad/reset) is not available through this tool either — ask the user to run the bisect if needed.",
|
|
512
|
+
// difftool/mergetool exist to shell out to external diff/merge programs.
|
|
513
|
+
// both accept `--extcmd` / `-x` (difftool) or configured tool commands
|
|
514
|
+
// (mergetool) that run arbitrary code. NOSHELL_BLOCKED_ARGS catches the
|
|
515
|
+
// long `--extcmd` form, but not the `-x` short form — and globally blocking
|
|
516
|
+
// `-x` would false-positive on `git cherry-pick -x`. block the subcommands
|
|
517
|
+
// wholesale instead; neither has a meaningful use in an automated agent
|
|
518
|
+
// workflow (agents use `git diff` / `git show` for diffs and resolve
|
|
519
|
+
// conflicts via file edits, not a TUI merge tool).
|
|
520
|
+
difftool:
|
|
521
|
+
"Blocked: git difftool runs an external diff program via --extcmd/-x or configured tool and can execute arbitrary shell commands. Use 'diff' (or 'show' for single commits) to inspect changes — those output directly and don't invoke an external tool.",
|
|
522
|
+
mergetool:
|
|
523
|
+
"Blocked: git mergetool runs an external merge program configured via mergetool.<name>.cmd and can execute arbitrary shell commands. Resolve conflicts by editing the files directly (conflict markers are written into the working tree) and then commit.",
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
// SECURITY: subcommand-specific arg flags that execute code.
|
|
527
|
+
// only blocked when shell is disabled — in restricted mode the agent already
|
|
528
|
+
// has shell access in a stripped sandbox, so these provide no additional security.
|
|
529
|
+
//
|
|
530
|
+
// NOTE: global git flags like -c and --config-env are NOT included here
|
|
531
|
+
// because they only work before the subcommand. in the MCP tool, the
|
|
532
|
+
// subcommand is always first, so -c in args is parsed as a subcommand flag
|
|
533
|
+
// (e.g., git log -c = combined diff format), not config injection.
|
|
534
|
+
// the subcommand check (rejecting "-" prefix) already blocks that attack.
|
|
535
|
+
//
|
|
536
|
+
// matched as: arg === flag OR arg starts with flag + "="
|
|
537
|
+
// (avoids false positives like --exclude matching --exec).
|
|
538
|
+
// exported so tests stay in sync with the runtime flag set.
|
|
539
|
+
export const NOSHELL_BLOCKED_ARGS = ["--exec", "--extcmd", "--upload-pack", "--receive-pack"];
|
|
540
|
+
|
|
541
|
+
const COLLAPSE_THRESHOLD = 200;
|
|
542
|
+
|
|
543
|
+
/** above this, the full body is spilled to a tmp file and only a short head
|
|
544
|
+
* preview is logged + returned inline. mirrors `capOutput` in `mcp/shell.ts`
|
|
545
|
+
* (which uses 5000 for shell commands; diff/log outputs need more headroom).
|
|
546
|
+
* the operator log gets a single summary line instead of a 1000+ line dump. */
|
|
547
|
+
const MAX_GIT_OUTPUT_CHARS = 50_000;
|
|
548
|
+
const OVERFLOW_PREVIEW_LINES = 50;
|
|
549
|
+
/** absolute char cap on the inline preview, in case the first
|
|
550
|
+
* `OVERFLOW_PREVIEW_LINES` lines contain a minified blob / binary diff /
|
|
551
|
+
* single very long line that would blow the agent's context anyway. */
|
|
552
|
+
const OVERFLOW_PREVIEW_MAX_CHARS = 5_000;
|
|
553
|
+
|
|
554
|
+
/** detect refs in `git diff` args that would produce a symmetric (two-dot)
|
|
555
|
+
* diff including the inverse of commits that landed on `<ref>` since the
|
|
556
|
+
* branch forked. returns the offending arg + the ref that's ahead + count of
|
|
557
|
+
* unmerged commits, or null if the call is safe. silently ignores args that
|
|
558
|
+
* aren't refs (paths, pathspecs), three-dot ranges (those are merge-base
|
|
559
|
+
* diffs, the correct shape), and any call passing `--merge-base` (git's own
|
|
560
|
+
* shorthand for a merge-base diff, also safe). see [run 26545933188](https://github.com/terramend/app/actions/runs/26545933188)
|
|
561
|
+
* for the failure mode this guards against. */
|
|
562
|
+
function detectSymmetricDiffTrap(
|
|
563
|
+
args: string[],
|
|
564
|
+
): { arg: string; aheadRef: string; ahead: number } | null {
|
|
565
|
+
// git's own `--merge-base` flag (2.30+) produces a safe merge-base diff
|
|
566
|
+
// regardless of the positional ref; the GHA runner has git 2.54.x.
|
|
567
|
+
if (args.includes("--merge-base")) return null;
|
|
568
|
+
// ignore everything after `--` (pathspec separator)
|
|
569
|
+
const endIdx = args.indexOf("--");
|
|
570
|
+
const positionals = (endIdx === -1 ? args : args.slice(0, endIdx)).filter(
|
|
571
|
+
(a) => !a.startsWith("-"),
|
|
572
|
+
);
|
|
573
|
+
for (const p of positionals) {
|
|
574
|
+
if (p.includes("...")) continue; // three-dot = merge-base diff, safe
|
|
575
|
+
// bare ref `A`: implicit second side is HEAD; agent's intent is
|
|
576
|
+
// "what my branch changed vs <ref>". fires when <ref> has commits HEAD
|
|
577
|
+
// doesn't (branch behind base). diffs against an ancestor (HEAD ahead
|
|
578
|
+
// of <ref>) are the legitimate "what did I add since X" case and must
|
|
579
|
+
// not be blocked.
|
|
580
|
+
//
|
|
581
|
+
// two-dot range `A..B`: degenerate when one side is an ancestor of the
|
|
582
|
+
// other (the tree diff equals the merge-base diff — safe). only the
|
|
583
|
+
// truly-diverged case (BOTH sides have commits the other lacks) pulls
|
|
584
|
+
// unwanted inverse-of-progress into the diff. shorthand expansions:
|
|
585
|
+
// `A..` → `A..HEAD`, `..A` → `HEAD..A`.
|
|
586
|
+
if (p.includes("..")) {
|
|
587
|
+
const parts = p.split("..");
|
|
588
|
+
if (parts.length !== 2) continue;
|
|
589
|
+
const left = parts[0] || "HEAD";
|
|
590
|
+
const right = parts[1] || "HEAD";
|
|
591
|
+
const leftAhead = countAhead(right, left);
|
|
592
|
+
const rightAhead = countAhead(left, right);
|
|
593
|
+
if (leftAhead === null || rightAhead === null) continue;
|
|
594
|
+
if (leftAhead > 0 && rightAhead > 0) {
|
|
595
|
+
const aheadRef = leftAhead >= rightAhead ? left : right;
|
|
596
|
+
return { arg: p, aheadRef, ahead: Math.max(leftAhead, rightAhead) };
|
|
597
|
+
}
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
const ahead = countAhead("HEAD", p);
|
|
601
|
+
if (ahead === null) continue;
|
|
602
|
+
if (ahead > 0) return { arg: p, aheadRef: p, ahead };
|
|
603
|
+
}
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** `rev-list --count head..base` = commits on `base` not on `head`. returns
|
|
608
|
+
* null if either ref is unresolvable (probably a pathspec). */
|
|
609
|
+
function countAhead(head: string, base: string): number | null {
|
|
610
|
+
try {
|
|
611
|
+
const out = $("git", ["rev-list", "--count", `${head}..${base}`], { log: false }).trim();
|
|
612
|
+
const n = parseInt(out, 10);
|
|
613
|
+
return Number.isFinite(n) ? n : null;
|
|
614
|
+
} catch {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** persist `output` to a tmp file and return an agent-facing string that
|
|
620
|
+
* leads with a head preview and ends with a sentinel pointing at the path.
|
|
621
|
+
* keeps the operator log to a single summary line for the overflow case. */
|
|
622
|
+
function spillGitOutput(params: {
|
|
623
|
+
command: string;
|
|
624
|
+
args: string[];
|
|
625
|
+
output: string;
|
|
626
|
+
lineCount: number;
|
|
627
|
+
}): { output: string; outputPath: string } {
|
|
628
|
+
const tempDir = process.env.TERRAMEND_TEMP_DIR;
|
|
629
|
+
if (!tempDir) throw new Error("TERRAMEND_TEMP_DIR not set");
|
|
630
|
+
const outputPath = join(tempDir, `git-${params.command}-${randomUUID().slice(0, 8)}.txt`);
|
|
631
|
+
writeFileSync(outputPath, params.output);
|
|
632
|
+
const previewByLines = params.output.split("\n").slice(0, OVERFLOW_PREVIEW_LINES).join("\n");
|
|
633
|
+
const preview =
|
|
634
|
+
previewByLines.length <= OVERFLOW_PREVIEW_MAX_CHARS
|
|
635
|
+
? previewByLines
|
|
636
|
+
: `${previewByLines.slice(0, OVERFLOW_PREVIEW_MAX_CHARS)}…`;
|
|
637
|
+
log.info(
|
|
638
|
+
`» git ${params.command} ${params.args.join(" ")}: ${params.lineCount} lines / ${params.output.length} chars → ${outputPath}`,
|
|
639
|
+
);
|
|
640
|
+
return {
|
|
641
|
+
output: `${preview}\n\n... [output truncated; full ${params.lineCount}-line / ${params.output.length}-char body saved to ${outputPath} — read selectively with \`read({ filePath: "${outputPath}" })\`] ...`,
|
|
642
|
+
outputPath,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// SECURITY: subcommand must match [a-z][a-z0-9-]* to reject flags passed as the subcommand.
|
|
647
|
+
// this blocks injection of global git options like -c, -C, --exec-path, --config-env, etc.
|
|
648
|
+
//
|
|
649
|
+
// critical attack: git -c "alias.x=!evil-command" x
|
|
650
|
+
// -> sets alias "x" to a shell command via -c config injection, then runs it
|
|
651
|
+
// -> achieves arbitrary code execution even with shell=disabled
|
|
652
|
+
const subcommandPattern = regex("^[a-z][a-z0-9-]*$");
|
|
653
|
+
|
|
654
|
+
const Git = type({
|
|
655
|
+
command: type(subcommandPattern).describe("Git command (e.g., 'status', 'log', 'diff')"),
|
|
656
|
+
args: type.string.array().describe("Additional arguments for the git command").optional(),
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
export function GitTool(ctx: ToolContext) {
|
|
660
|
+
return tool({
|
|
661
|
+
name: "git",
|
|
662
|
+
description:
|
|
663
|
+
"Run a git subcommand. `command` is the subcommand ONLY — never repeat it inside `args`. " +
|
|
664
|
+
"`args` is optional; omit it entirely for no-flag invocations like plain `git status`. " +
|
|
665
|
+
'Example: `git({ command: "status" })` for plain `git status`. ' +
|
|
666
|
+
'Example: `git({ command: "log", args: ["--oneline", "-n", "20"] })`. ' +
|
|
667
|
+
'Example: `git({ command: "diff", args: ["--merge-base", "origin/main"] })` — merge-base diff including uncommitted edits (single MCP call). ' +
|
|
668
|
+
'Example: `git({ command: "diff", args: ["origin/main...HEAD"] })` — three-dot, committed-only changes vs merge-base. ' +
|
|
669
|
+
"For PR-scope diffs ALWAYS use `--merge-base <base>` or three-dot `<base>...HEAD`. " +
|
|
670
|
+
"Bare `<base>` and two-dot `<base>..HEAD` are symmetric (working-tree-or-HEAD vs ref): when your branch is behind `<base>` they include the inverse of every commit on `<base>` you lack — pure noise, and this tool will reject those forms when the divergence is detected. " +
|
|
671
|
+
"Output >50K chars is spilled to a tmp file; the tool returns a head preview + path you can `read` selectively. " +
|
|
672
|
+
"For push/fetch, use the dedicated MCP tools (push_branch, git_fetch). " +
|
|
673
|
+
"git pull is not available — use git_fetch then this tool with command 'merge'.",
|
|
674
|
+
parameters: Git,
|
|
675
|
+
execute: execute(async (params) => {
|
|
676
|
+
const command = params.command;
|
|
677
|
+
const args = params.args ?? [];
|
|
678
|
+
|
|
679
|
+
// guard: {command:"status",args:["status"]} → `git status status`, where
|
|
680
|
+
// git silently treats args[0] as a pathspec. when nothing matches the
|
|
681
|
+
// path, status prints "nothing to commit, working tree clean" even on a
|
|
682
|
+
// dirty tree — a real model failure mode that burned a ~$3 run before
|
|
683
|
+
// self-correction. generalises to every subcommand (`diff diff`,
|
|
684
|
+
// `log log`, etc.).
|
|
685
|
+
if (args[0]?.toLowerCase() === command.toLowerCase()) {
|
|
686
|
+
throw new Error(
|
|
687
|
+
`git ${command}: '${args[0]}' duplicates the subcommand — drop args[0] ` +
|
|
688
|
+
`(the subcommand only belongs in 'command'). git would otherwise parse it as ` +
|
|
689
|
+
`a pathspec and silently return empty/clean output when nothing matches. ` +
|
|
690
|
+
`if you really meant a pathspec named '${args[0]}', use args: ["--", "${args[0]}"].`,
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const redirect = AUTH_REQUIRED_REDIRECT[command];
|
|
695
|
+
if (redirect) {
|
|
696
|
+
throw new Error(`git ${command} is not available through this tool — ${redirect}`);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// SECURITY: block dangerous subcommands when shell is disabled.
|
|
700
|
+
// in restricted mode the agent has shell in a stripped sandbox, so blocking
|
|
701
|
+
// these through the MCP tool is redundant (agent can do it via shell).
|
|
702
|
+
if (ctx.payload.shell === "disabled") {
|
|
703
|
+
const blocked = NOSHELL_BLOCKED_SUBCOMMANDS[command];
|
|
704
|
+
if (blocked) {
|
|
705
|
+
throw new Error(blocked);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// block subcommand-specific flags that execute arbitrary code
|
|
709
|
+
for (const arg of args) {
|
|
710
|
+
const isBlocked = NOSHELL_BLOCKED_ARGS.some(
|
|
711
|
+
(flag) => arg === flag || arg.startsWith(`${flag}=`),
|
|
712
|
+
);
|
|
713
|
+
if (isBlocked) {
|
|
714
|
+
throw new Error(
|
|
715
|
+
`Blocked: '${arg}' flag can execute arbitrary code and is not allowed.`,
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// reject symmetric (two-dot or bare-ref) diffs whose endpoints have
|
|
722
|
+
// commits each other doesn't — those include the *inverse* of every
|
|
723
|
+
// commit on the diverged side, ballooning the diff and confusing
|
|
724
|
+
// reviewer subagents. three-dot (`A...B`) and `--merge-base` are
|
|
725
|
+
// always allowed (both produce merge-base diffs).
|
|
726
|
+
if (command === "diff") {
|
|
727
|
+
const trap = detectSymmetricDiffTrap(args);
|
|
728
|
+
if (trap) {
|
|
729
|
+
throw new Error(
|
|
730
|
+
`git diff '${trap.arg}' would include the inverse of ${trap.ahead} commit(s) on '${trap.aheadRef}' that aren't on the other side — that's a symmetric tree diff full of upstream noise, not your branch's own changes.\n\n` +
|
|
731
|
+
`use one of:\n` +
|
|
732
|
+
` - git diff --merge-base ${trap.aheadRef} (one MCP call; merge-base diff, includes uncommitted edits)\n` +
|
|
733
|
+
` - git diff ${trap.aheadRef}...HEAD (three-dot; merge-base diff of committed-only changes)\n\n` +
|
|
734
|
+
`if you ALSO need the PR's pre-formatted diff, the orchestrator's checkout_pr response includes a \`diffPath\` you can \`read\` directly without invoking git at all.`,
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// `git merge-base --is-ancestor` uses exit codes as data: 0 = ancestor,
|
|
740
|
+
// 1 = not-an-ancestor, >1 = real error. Surface the binary answer
|
|
741
|
+
// instead of throwing on exit 1. see #766.
|
|
742
|
+
if (command === "merge-base" && args.includes("--is-ancestor")) {
|
|
743
|
+
let isAncestor = true;
|
|
744
|
+
$("git", [command, ...args], {
|
|
745
|
+
log: false,
|
|
746
|
+
onError: (r) => {
|
|
747
|
+
if (r.status === 1) {
|
|
748
|
+
isAncestor = false;
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
const detail = [r.stderr, r.stdout]
|
|
752
|
+
.map((s) => s.trim())
|
|
753
|
+
.filter(Boolean)
|
|
754
|
+
.join("\n");
|
|
755
|
+
throw new Error(
|
|
756
|
+
`git merge-base --is-ancestor failed (exit ${r.status}): ${detail || "Unknown error"}`,
|
|
757
|
+
);
|
|
758
|
+
},
|
|
759
|
+
});
|
|
760
|
+
return { success: true, isAncestor };
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const output = $("git", [command, ...args], { log: false });
|
|
764
|
+
const lineCount = output.split("\n").length;
|
|
765
|
+
if (output.length > MAX_GIT_OUTPUT_CHARS) {
|
|
766
|
+
const spilled = spillGitOutput({ command, args, output, lineCount });
|
|
767
|
+
return { success: true, output: spilled.output, outputPath: spilled.outputPath };
|
|
768
|
+
}
|
|
769
|
+
if (lineCount > COLLAPSE_THRESHOLD) {
|
|
770
|
+
log.group(`git ${command} output (${lineCount} lines)`, () => {
|
|
771
|
+
log.info(output);
|
|
772
|
+
});
|
|
773
|
+
} else if (output) {
|
|
774
|
+
log.info(output);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
return { success: true, output };
|
|
778
|
+
}),
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const GitFetch = type({
|
|
783
|
+
ref: type.string.describe("Ref to fetch: branch name, tag, or 'pull/N/head' for PRs"),
|
|
784
|
+
depth: type.number.describe("Fetch depth (for shallow clones)").optional(),
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
export function GitFetchTool(ctx: ToolContext) {
|
|
788
|
+
return tool({
|
|
789
|
+
name: "git_fetch",
|
|
790
|
+
description:
|
|
791
|
+
"Fetch refs from remote repository. Use this instead of git fetch directly. " +
|
|
792
|
+
'Example: `git_fetch({ ref: "main" })`. With depth: `git_fetch({ ref: "pull/1234/head", depth: 1 })`.',
|
|
793
|
+
parameters: GitFetch,
|
|
794
|
+
execute: execute(async (params) => {
|
|
795
|
+
rejectIfLeadingDash(params.ref, "ref");
|
|
796
|
+
const fetchArgs = ["--no-tags", "origin", params.ref];
|
|
797
|
+
if (params.depth !== undefined) {
|
|
798
|
+
fetchArgs.push(`--depth=${params.depth}`);
|
|
799
|
+
}
|
|
800
|
+
await $gitFetchWithDeepen(fetchArgs, { token: ctx.gitToken }, "git_fetch");
|
|
801
|
+
return { success: true, ref: params.ref };
|
|
802
|
+
}),
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const DeleteBranch = type({
|
|
807
|
+
branchName: type.string.describe("Remote branch to delete"),
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
export function DeleteBranchTool(ctx: ToolContext) {
|
|
811
|
+
const pushPermission = ctx.payload.push;
|
|
812
|
+
const defaultBranch = ctx.repo.data.default_branch || "main";
|
|
813
|
+
|
|
814
|
+
return tool({
|
|
815
|
+
name: "delete_branch",
|
|
816
|
+
description:
|
|
817
|
+
"Delete a remote branch. Requires push: enabled permission. " +
|
|
818
|
+
"Deletion of the repository's default branch is always blocked regardless of permission mode.",
|
|
819
|
+
parameters: DeleteBranch,
|
|
820
|
+
execute: execute(async (params) => {
|
|
821
|
+
if (pushPermission !== "enabled") {
|
|
822
|
+
throw new Error(
|
|
823
|
+
"Branch deletion requires push: enabled permission. " +
|
|
824
|
+
"Current mode only allows pushing to non-protected branches.",
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// delete_branch is already gated on push: enabled, but also block the
|
|
829
|
+
// refs/heads/... and symbolic-ref forms so this tool can't be tricked
|
|
830
|
+
// into deleting a protected ref that wouldn't match a bare-name check.
|
|
831
|
+
rejectSpecialRef(params.branchName, "branchName");
|
|
832
|
+
|
|
833
|
+
// defense-in-depth: deleting the default branch is catastrophic and
|
|
834
|
+
// unlike pushing to main it has no easy revert path (GitHub retains
|
|
835
|
+
// refs for 30 days but restoring requires the reflog or a direct SHA).
|
|
836
|
+
// push: enabled authorizes pushes, not wholesale removal of the
|
|
837
|
+
// repository's primary branch. block it locally even if GitHub branch
|
|
838
|
+
// protection would also reject — some repos disable protection on
|
|
839
|
+
// default branches and we should not rely on that config for safety.
|
|
840
|
+
if (params.branchName === defaultBranch) {
|
|
841
|
+
throw new Error(
|
|
842
|
+
`Blocked: cannot delete the default branch '${defaultBranch}'. ` +
|
|
843
|
+
`If you really need to delete or rename it, do it manually via the repository settings.`,
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// use refs/heads/<name> explicitly so a same-named tag can't be deleted
|
|
848
|
+
// by accident. `push --delete <bare-name>` resolves against both remote
|
|
849
|
+
// branches and tags; a tag-only match would silently remove the tag.
|
|
850
|
+
// rejectSpecialRef guarantees branchName is a bare name, so the
|
|
851
|
+
// branchName construction here can't collide with user-supplied refs.
|
|
852
|
+
await pushWithRetry(
|
|
853
|
+
["origin", "--delete", `refs/heads/${params.branchName}`],
|
|
854
|
+
ctx.gitToken,
|
|
855
|
+
ctx.payload.shell !== "enabled",
|
|
856
|
+
);
|
|
857
|
+
log.info(`» deleted branch ${params.branchName}`);
|
|
858
|
+
return { success: true, deleted: params.branchName };
|
|
859
|
+
}),
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const PushTags = type({
|
|
864
|
+
tag: type.string.describe("Tag name to push"),
|
|
865
|
+
force: type.boolean.describe("Force push the tag").default(false),
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
export function PushTagsTool(ctx: ToolContext) {
|
|
869
|
+
const pushPermission = ctx.payload.push;
|
|
870
|
+
|
|
871
|
+
return tool({
|
|
872
|
+
name: "push_tags",
|
|
873
|
+
description: "Push a tag to remote. Requires push: enabled permission.",
|
|
874
|
+
parameters: PushTags,
|
|
875
|
+
execute: execute(async (params) => {
|
|
876
|
+
if (pushPermission !== "enabled") {
|
|
877
|
+
throw new Error(
|
|
878
|
+
"Tag pushing requires push: enabled permission. " +
|
|
879
|
+
"Current mode only allows pushing branches.",
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
validateTagName(params.tag);
|
|
884
|
+
const pushArgs = [...(params.force ? ["-f"] : []), "origin", `refs/tags/${params.tag}`];
|
|
885
|
+
await pushWithRetry(pushArgs, ctx.gitToken, ctx.payload.shell !== "enabled");
|
|
886
|
+
log.info(`» pushed tag ${params.tag}`);
|
|
887
|
+
return { success: true, tag: params.tag };
|
|
888
|
+
}),
|
|
889
|
+
});
|
|
890
|
+
}
|