@workit-dev/core 0.0.1 → 1.0.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 +21 -0
- package/README.md +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- package/templates/workit-contract.md +13 -0
|
@@ -0,0 +1,2289 @@
|
|
|
1
|
+
// Raw git/forge commands on an agent's shell (audit B1/B2). Workit's delivery
|
|
2
|
+
// rules (merge grant, verdict gate, protected branches, session trailer) live
|
|
3
|
+
// in its own verbs, so a raw command skips them. The pre-tool hook:
|
|
4
|
+
// - refuses, inside a Workit workspace, the raw forms that bypass a gate:
|
|
5
|
+
// `gh pr merge`, `glab mr merge`, and `git push` to the workspace's push
|
|
6
|
+
// remote onto a protected branch or the default target, naming the
|
|
7
|
+
// workit command;
|
|
8
|
+
// - lets routine raw commands run (`git commit`, a feature-branch push,
|
|
9
|
+
// `gh pr create`, `glab mr create`) with a short nudge, once per kind per
|
|
10
|
+
// session, only for an agent no start text told how to run git
|
|
11
|
+
// (steering.ts), and none on a command that runs workit. Read-only
|
|
12
|
+
// commands (`gh pr view|checks`, `git status`) get none.
|
|
13
|
+
// A raw `git commit` is recorded as the session's `commit.recorded` row so
|
|
14
|
+
// the author's own verdict never reads as independent. The pre-tool hook
|
|
15
|
+
// notes HEAD before the command (a marker in the commit's own repository);
|
|
16
|
+
// the post-tool hook records only a commit that moved HEAD away from it and
|
|
17
|
+
// was made after it, and that no other session claims. Hosts without a
|
|
18
|
+
// post-tool event settle the marker on the session's next shell command.
|
|
19
|
+
//
|
|
20
|
+
// Classification is string parsing only. Git is spawned only to read the new
|
|
21
|
+
// commit after a raw commit and the push remote before a deny; HEAD and the
|
|
22
|
+
// current branch are read from the git dir. Every failure answers "no
|
|
23
|
+
// decision": hooks fail open.
|
|
24
|
+
import { spawnSync } from "node:child_process";
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import os from "node:os";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { releaseBranch, resolveBranchPolicyFor, validateBranchNameFor } from "../core/branch";
|
|
30
|
+
import { grantsMatter, rawGrantDenial, type GrantKind } from "../autonomy";
|
|
31
|
+
import { releaseTagPrefixOf } from "../core/branch-policy";
|
|
32
|
+
import { pushTarget, remoteKey, type LazyTarget } from "../core/remotes";
|
|
33
|
+
import { resolveRuntimeWorkspaceVcs } from "../core/workspaces";
|
|
34
|
+
import { appendHookObserved, readLedger } from "../ledger";
|
|
35
|
+
import { resolveStore, resolveTaskKey, type StoreLocation } from "../store/paths";
|
|
36
|
+
import type { HookDecision, HookInput } from "./protocol";
|
|
37
|
+
import { readSessionMarker, sessionMarkerFile, writeSessionMarker } from "./session-marker";
|
|
38
|
+
import { segmentsOf, ShellAnalysisError, type Segment, type ShellDialect } from "./shell-words";
|
|
39
|
+
|
|
40
|
+
const NONE: HookDecision = { kind: "none" };
|
|
41
|
+
|
|
42
|
+
/** One git/gh/glab invocation: its arguments after global options, and the
|
|
43
|
+
* directory it runs in relative to the hook's cwd (`cd x`, `git -C x`).
|
|
44
|
+
* `dir` is undefined when it cannot be known (`cd -`, `cd $X`, an unmatched
|
|
45
|
+
* `popd`): such an invocation never gets a decision. */
|
|
46
|
+
export type RawInvocation = {
|
|
47
|
+
tool: "git" | "gh" | "glab";
|
|
48
|
+
dir: string | null | undefined;
|
|
49
|
+
args: string[];
|
|
50
|
+
/** git's global options that change its configuration (`-c k=v`,
|
|
51
|
+
* `--config-env`, `--git-dir`): replayed when its config is read. */
|
|
52
|
+
config?: string[];
|
|
53
|
+
/** Assignments before the command that change git's configuration
|
|
54
|
+
* (`GIT_CONFIG_*`, `GIT_DIR`): replayed the same way. */
|
|
55
|
+
env?: Record<string, string>;
|
|
56
|
+
/** Its standard input when the command line spells it out: a heredoc, a
|
|
57
|
+
* here-string or a literal `echo …|`/`printf …|`. */
|
|
58
|
+
stdin?: string;
|
|
59
|
+
/** The variable or option that moved git to a repository the hook cannot
|
|
60
|
+
* read (`GIT_DIR=$X`, `--git-dir "$X"`): `dir` is then undefined. */
|
|
61
|
+
redirect?: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "mksh", "yash", "fish"]);
|
|
65
|
+
const POWERSHELLS = new Set(["pwsh", "powershell"]);
|
|
66
|
+
const EVALS = new Set(["eval", "iex", "invoke-expression"]);
|
|
67
|
+
/**
|
|
68
|
+
* Wrappers that run the command after their options: the options that take
|
|
69
|
+
* a value, and how many operands come before the command (`timeout 30`).
|
|
70
|
+
*/
|
|
71
|
+
const WRAPPERS: Record<string, { values: string; operands?: number }> = {
|
|
72
|
+
sudo: {
|
|
73
|
+
values: "-u -g -h -p -C -D -R -T -r -t -U --user --group --host --prompt --chdir",
|
|
74
|
+
},
|
|
75
|
+
doas: { values: "-u -C" },
|
|
76
|
+
command: { values: "" },
|
|
77
|
+
builtin: { values: "" },
|
|
78
|
+
env: { values: "-u -C --unset --chdir" },
|
|
79
|
+
nohup: { values: "" },
|
|
80
|
+
setsid: { values: "" },
|
|
81
|
+
time: { values: "-f -o --format --output" },
|
|
82
|
+
exec: { values: "-a" },
|
|
83
|
+
nice: { values: "-n --adjustment" },
|
|
84
|
+
ionice: { values: "-c -n -p --class --classdata" },
|
|
85
|
+
stdbuf: { values: "-i -o -e --input --output --error" },
|
|
86
|
+
chronic: { values: "" },
|
|
87
|
+
unbuffer: { values: "" },
|
|
88
|
+
wsl: { values: "-d -u --cd --distribution --user" },
|
|
89
|
+
timeout: { values: "-s -k --signal --kill-after", operands: 1 },
|
|
90
|
+
flock: { values: "-w -E --timeout --conflict-exit-code", operands: 1 },
|
|
91
|
+
coproc: { values: "" },
|
|
92
|
+
taskset: { values: "", operands: 1 },
|
|
93
|
+
watch: { values: "-n -d --interval --differences -q --equexit" },
|
|
94
|
+
"&": { values: "" },
|
|
95
|
+
};
|
|
96
|
+
const WRAPPER_VALUES = new Map(
|
|
97
|
+
Object.entries(WRAPPERS).map(([name, wrapper]) => [name, new Set(wrapper.values.split(" "))]),
|
|
98
|
+
);
|
|
99
|
+
/** Shell keywords that open a compound command: the command follows them. */
|
|
100
|
+
const KEYWORDS = new Set(["if", "then", "else", "elif", "while", "until", "do", "!", "{"]);
|
|
101
|
+
const DECLARATIONS = new Set(["export", "local", "declare", "readonly", "typeset"]);
|
|
102
|
+
const SCRIPT_FLAG = /^-[a-zA-Z]*c[a-zA-Z]*$/;
|
|
103
|
+
const CD = new Set(["cd", "chdir", "set-location", "sl"]);
|
|
104
|
+
const PUSHD = new Set(["pushd", "push-location"]);
|
|
105
|
+
const POPD = new Set(["popd", "pop-location"]);
|
|
106
|
+
/** git global options that take the next word as their value. */
|
|
107
|
+
const GIT_VALUE_OPTIONS = new Set([
|
|
108
|
+
"-c",
|
|
109
|
+
"--git-dir",
|
|
110
|
+
"--work-tree",
|
|
111
|
+
"--namespace",
|
|
112
|
+
"--exec-path",
|
|
113
|
+
"--config-env",
|
|
114
|
+
"--super-prefix",
|
|
115
|
+
]);
|
|
116
|
+
/** git global options that change which configuration it reads. */
|
|
117
|
+
const GIT_CONFIG_OPTIONS = new Set(["-c", "--config-env", "--git-dir"]);
|
|
118
|
+
/** Environment that changes which configuration git reads; nothing that runs code. */
|
|
119
|
+
// Also what decides where a push or a forge call goes (the grant target).
|
|
120
|
+
const GIT_CONFIG_ENV =
|
|
121
|
+
/^(?:GIT_(?:CONFIG\w*|DIR|COMMON_DIR|WORK_TREE|SSH|SSH_COMMAND|PROXY_COMMAND|EXEC_PATH)|GH_HOST|GH_REPO|GITLAB_HOST|GL_HOST)$/;
|
|
122
|
+
const ASSIGNMENT = /^([A-Za-z_]\w*)=([\s\S]*)$/;
|
|
123
|
+
/** Nested scripts (`bash -c`, `eval`, aliases) are followed this deep. */
|
|
124
|
+
const MAX_DEPTH = 4;
|
|
125
|
+
/** One word expands to at most this many (loop lists, brace lists). */
|
|
126
|
+
const MAX_EXPANSIONS = 16;
|
|
127
|
+
|
|
128
|
+
type Dir = string | null | undefined;
|
|
129
|
+
|
|
130
|
+
const isAbsolute = (value: string) =>
|
|
131
|
+
path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
|
|
132
|
+
|
|
133
|
+
/** `$PWD`, `$(pwd)`, `$(git rev-parse --show-toplevel)`: the directory the shell is in. */
|
|
134
|
+
const HERE =
|
|
135
|
+
/^(?:\$PWD|\$\{PWD\}|\$\(\s*pwd\s*\)|`pwd`|\$\(\s*git\s+rev-parse\s+--show-toplevel\s*\))(?=$|[\\/])/;
|
|
136
|
+
|
|
137
|
+
const joinDir = (base: Dir, raw: string): Dir => {
|
|
138
|
+
const next = raw.replace(HERE, ".");
|
|
139
|
+
if (base === undefined || /[$`*?]/.test(next) || next === "-") return undefined;
|
|
140
|
+
const expanded =
|
|
141
|
+
next === "~" || next.startsWith("~/") ? path.join(os.homedir(), next.slice(1)) : next;
|
|
142
|
+
return base === null || isAbsolute(expanded) ? expanded : path.join(base, expanded);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** An inner script's directory, relative to the one it was started in. */
|
|
146
|
+
const rebase = (base: Dir, inner: Dir): Dir =>
|
|
147
|
+
inner === null ? base : inner === undefined ? undefined : joinDir(base, inner);
|
|
148
|
+
|
|
149
|
+
/** The directory operand of `cd`/`Set-Location` (`-Path x`, `-LiteralPath x`). */
|
|
150
|
+
const cdTarget = (words: string[]): string | null => {
|
|
151
|
+
const args = words.slice(1);
|
|
152
|
+
const flag = args.findIndex((word) => /^-(?:literal)?path$/i.test(word));
|
|
153
|
+
if (flag >= 0) return args[flag + 1] ?? null;
|
|
154
|
+
return args.find((word) => word === "-" || !word.startsWith("-")) ?? null;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Drop env assignments, compound-command keywords (`if`, `do`, `{`) and
|
|
159
|
+
* wrappers (`sudo`, `env -i`, `timeout 30`) with their options. The
|
|
160
|
+
* assignments that change git's configuration are kept aside.
|
|
161
|
+
*/
|
|
162
|
+
const stripPrefixes = (input: string[]): { words: string[]; env: Record<string, string> } => {
|
|
163
|
+
let words = input;
|
|
164
|
+
const env: Record<string, string> = {};
|
|
165
|
+
for (;;) {
|
|
166
|
+
const first = words[0];
|
|
167
|
+
if (first === undefined) return { words, env };
|
|
168
|
+
const assignment = ASSIGNMENT.exec(first);
|
|
169
|
+
if (assignment) {
|
|
170
|
+
if (GIT_CONFIG_ENV.test(assignment[1])) env[assignment[1]] = assignment[2];
|
|
171
|
+
words = words.slice(1);
|
|
172
|
+
} else if (KEYWORDS.has(first)) words = words.slice(1);
|
|
173
|
+
// `function f { …` defines f to run its body: the body is read where it stands.
|
|
174
|
+
else if (first === "function") words = words.slice(2);
|
|
175
|
+
else if (Object.hasOwn(WRAPPERS, commandName(first))) {
|
|
176
|
+
const name = commandName(first);
|
|
177
|
+
const values = WRAPPER_VALUES.get(name)!;
|
|
178
|
+
words = words.slice(1);
|
|
179
|
+
// A short cluster takes a value when its last option does (`sudo -iu root`).
|
|
180
|
+
const takesValue = (option: string) =>
|
|
181
|
+
values.has(option) || (/^-[a-zA-Z]{2,}$/.test(option) && values.has(`-${option.at(-1)}`));
|
|
182
|
+
while (words[0]?.startsWith("-") && words[0] !== "--")
|
|
183
|
+
words = words.slice(takesValue(words[0]) ? 2 : 1);
|
|
184
|
+
if (words[0] === "--") words = words.slice(1);
|
|
185
|
+
words = words.slice(WRAPPERS[name].operands ?? 0);
|
|
186
|
+
} else return { words, env };
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const commandName = (word: string | undefined) =>
|
|
191
|
+
(word?.split(/[\\/]/).at(-1) ?? "").toLowerCase().replace(/\.exe$/, "");
|
|
192
|
+
|
|
193
|
+
const VARIABLE = /\$(?:\{(\w+)\}|(\w+))/g;
|
|
194
|
+
|
|
195
|
+
/** One word with the known variables substituted (a loop list gives several). */
|
|
196
|
+
const expandWord = (word: string, vars: ReadonlyMap<string, string[]>): string[] => {
|
|
197
|
+
if (!word.includes("$")) return [word];
|
|
198
|
+
let results = [""];
|
|
199
|
+
let last = 0;
|
|
200
|
+
for (const match of word.matchAll(VARIABLE)) {
|
|
201
|
+
const values = vars.get(match[1] ?? match[2]) ?? [match[0]];
|
|
202
|
+
const before = word.slice(last, match.index);
|
|
203
|
+
results = results
|
|
204
|
+
.flatMap((result) => values.map((value) => result + before + value))
|
|
205
|
+
.slice(0, MAX_EXPANSIONS);
|
|
206
|
+
last = match.index + match[0].length;
|
|
207
|
+
}
|
|
208
|
+
return results.map((result) => result + word.slice(last));
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/** Every command line a segment stands for once its known variables are substituted. */
|
|
212
|
+
const substitute = (words: string[], vars: ReadonlyMap<string, string[]>): string[][] => {
|
|
213
|
+
if (vars.size === 0) return [words];
|
|
214
|
+
let lines: string[][] = [[]];
|
|
215
|
+
for (const word of words) {
|
|
216
|
+
const values = expandWord(word, vars);
|
|
217
|
+
lines = lines
|
|
218
|
+
.flatMap((line) => values.map((value) => [...line, value]))
|
|
219
|
+
.slice(0, MAX_EXPANSIONS);
|
|
220
|
+
}
|
|
221
|
+
return lines;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A segment that only assigns (`B=x`, `export B=x`) or opens a `for` loop
|
|
226
|
+
* over literal words: the variables it sets. Null for any other segment.
|
|
227
|
+
*/
|
|
228
|
+
const assignments = (
|
|
229
|
+
words: string[],
|
|
230
|
+
vars: ReadonlyMap<string, string[]>,
|
|
231
|
+
): Array<[string, string[]]> | null => {
|
|
232
|
+
if (words[0] === "for" && words[2] === "in" && /^\w+$/.test(words[1] ?? "")) {
|
|
233
|
+
const values = words.slice(3).flatMap((word) => expandWord(word, vars));
|
|
234
|
+
return [[words[1], values.slice(0, MAX_EXPANSIONS)]];
|
|
235
|
+
}
|
|
236
|
+
let rest = words;
|
|
237
|
+
if (DECLARATIONS.has(rest[0] ?? "")) {
|
|
238
|
+
rest = rest.slice(1);
|
|
239
|
+
while (rest[0]?.startsWith("-")) rest = rest.slice(1);
|
|
240
|
+
}
|
|
241
|
+
if (rest.length === 0 || !rest.every((word) => ASSIGNMENT.test(word))) return null;
|
|
242
|
+
return rest.map((word) => {
|
|
243
|
+
const [, name, value] = ASSIGNMENT.exec(word)!;
|
|
244
|
+
return [name, expandWord(value, vars)];
|
|
245
|
+
});
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/** What a literal producer piped into the next command writes (`echo x |`, `printf 'x\n' |`). */
|
|
249
|
+
const literalOutput = (segment: Segment | undefined): string | undefined => {
|
|
250
|
+
if (!segment?.pipe) return undefined;
|
|
251
|
+
const { words } = stripPrefixes(segment.words);
|
|
252
|
+
const name = commandName(words[0]);
|
|
253
|
+
if (name === "echo") {
|
|
254
|
+
let args = words.slice(1);
|
|
255
|
+
while (/^-[neE]+$/.test(args[0] ?? "")) args = args.slice(1);
|
|
256
|
+
return args.join(" ");
|
|
257
|
+
}
|
|
258
|
+
if (name === "printf") {
|
|
259
|
+
const [format = "", ...args] = words.slice(1);
|
|
260
|
+
return (args.length ? args.join("\n") : format).replace(/\\n/g, "\n");
|
|
261
|
+
}
|
|
262
|
+
if (name === "cat" && words.length === 1 && segment.stdin) return segment.stdin.join("\n");
|
|
263
|
+
return undefined;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const stdinOf = (segment: Segment, previous: Segment | undefined): string | undefined =>
|
|
267
|
+
segment.stdin ? segment.stdin.join("\n") : literalOutput(previous);
|
|
268
|
+
|
|
269
|
+
/** xargs options that take a value (`-n 1`, `-d '\n'`). */
|
|
270
|
+
const XARGS_VALUES = new Set(
|
|
271
|
+
["-n", "-L", "-l", "-P", "-d", "-E", "-e", "-s", "-a", "--arg-file", "--delimiter"].concat([
|
|
272
|
+
"--max-args",
|
|
273
|
+
"--max-lines",
|
|
274
|
+
"--max-procs",
|
|
275
|
+
"--eof",
|
|
276
|
+
"--max-chars",
|
|
277
|
+
"--process-slot-var",
|
|
278
|
+
]),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The commands `xargs …` runs: its command with the input appended, or
|
|
283
|
+
* with `-I R` replaced per input line. Input that is not spelled out on
|
|
284
|
+
* the command line adds nothing, like a `$VAR` name it stays unjudged.
|
|
285
|
+
*/
|
|
286
|
+
const xargsCommands = (words: string[], input: string | undefined): string[][] => {
|
|
287
|
+
let replace: string | null = null;
|
|
288
|
+
let index = 1;
|
|
289
|
+
for (; index < words.length; index++) {
|
|
290
|
+
const word = words[index];
|
|
291
|
+
if (word === "--") {
|
|
292
|
+
index++;
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
if (!word.startsWith("-")) break;
|
|
296
|
+
if (word === "-I") replace = words[++index] ?? "{}";
|
|
297
|
+
else if (word === "--replace" || word === "-i") replace = "{}";
|
|
298
|
+
else if (word.startsWith("--replace=")) replace = word.slice("--replace=".length);
|
|
299
|
+
else if (/^-[Ii]./.test(word)) replace = word.slice(2);
|
|
300
|
+
else if (XARGS_VALUES.has(word)) index++;
|
|
301
|
+
}
|
|
302
|
+
const command = words.slice(index);
|
|
303
|
+
if (command.length === 0) return [];
|
|
304
|
+
if (input === undefined) return [command];
|
|
305
|
+
if (replace === null) return [[...command, ...input.split(/\s+/).filter(Boolean)]];
|
|
306
|
+
const token = replace;
|
|
307
|
+
return input
|
|
308
|
+
.split("\n")
|
|
309
|
+
.map((line) => line.trim())
|
|
310
|
+
.filter(Boolean)
|
|
311
|
+
.slice(0, MAX_EXPANSIONS)
|
|
312
|
+
.map((line) => command.map((word) => word.replaceAll(token, line)));
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/** The commands `find … -exec cmd … ;` runs. */
|
|
316
|
+
const findCommands = (words: string[]): string[][] => {
|
|
317
|
+
const found: string[][] = [];
|
|
318
|
+
for (let index = 1; index < words.length; index++) {
|
|
319
|
+
if (!/^-(?:exec|execdir|ok|okdir)$/.test(words[index])) continue;
|
|
320
|
+
const end = words.findIndex((word, at) => at > index && (word === ";" || word === "+"));
|
|
321
|
+
found.push(words.slice(index + 1, end < 0 ? undefined : end));
|
|
322
|
+
if (end < 0) break;
|
|
323
|
+
index = end;
|
|
324
|
+
}
|
|
325
|
+
return found;
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
/** PowerShell parameters that take a value; any other is a switch. */
|
|
329
|
+
const POWERSHELL_VALUES =
|
|
330
|
+
/^-(?:f|file|ex|executionpolicy|wd|workingdirectory|config|configurationname|o|outputformat|if|inputformat|inp|w|windowstyle|settingsfile|custompipename|psconsolefile)$/;
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The script `pwsh`/`powershell` runs: `-Command …` (the rest of the line),
|
|
334
|
+
* `-CommandWithArgs`, or `-EncodedCommand` (base64 UTF-16LE). Parameter
|
|
335
|
+
* names match case-insensitively and by unambiguous prefix. Null for a
|
|
336
|
+
* script file or an interactive shell.
|
|
337
|
+
*/
|
|
338
|
+
const powershellScript = (words: string[], stdin?: string): string | null => {
|
|
339
|
+
for (let index = 1; index < words.length; index++) {
|
|
340
|
+
const word = words[index];
|
|
341
|
+
if (!/^[-/]/.test(word)) return null;
|
|
342
|
+
const flag = word.replace(/^[-/]+/, "").toLowerCase();
|
|
343
|
+
if (!flag) return null;
|
|
344
|
+
if (flag === "e" || flag === "ec" || (flag.length >= 2 && "encodedcommand".startsWith(flag))) {
|
|
345
|
+
try {
|
|
346
|
+
return Buffer.from(words[index + 1] ?? "", "base64").toString("utf16le");
|
|
347
|
+
} catch {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (flag === "cwa" || (flag.length >= 8 && "commandwithargs".startsWith(flag)))
|
|
352
|
+
return words[index + 1] ?? null;
|
|
353
|
+
if ("command".startsWith(flag))
|
|
354
|
+
return words[index + 1] === "-" ? (stdin ?? null) : words.slice(index + 1).join(" ");
|
|
355
|
+
if (POWERSHELL_VALUES.test(`-${flag}`)) {
|
|
356
|
+
if (flag === "f" || flag === "file") return null;
|
|
357
|
+
index++;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
/** The script `cmd /c …` (or `/k`) runs. */
|
|
364
|
+
const cmdScript = (words: string[]): string | null => {
|
|
365
|
+
const at = words.findIndex((word, index) => index > 0 && /^\/[ck]$/i.test(word));
|
|
366
|
+
if (at > 0) return words.slice(at + 1).join(" ");
|
|
367
|
+
const attached = words.find((word, index) => index > 0 && /^\/[ck]./i.test(word));
|
|
368
|
+
return attached
|
|
369
|
+
? [attached.slice(2), ...words.slice(words.indexOf(attached) + 1)].join(" ")
|
|
370
|
+
: null;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
/** What a `<( … )` operand reads as a file: a literal producer's output (`<(echo 'x')`). */
|
|
374
|
+
const processSubstitutionText = (word: string | undefined): string | undefined => {
|
|
375
|
+
const inner = /^<\(([\s\S]*)\)$/.exec(word ?? "")?.[1];
|
|
376
|
+
if (inner === undefined) return undefined;
|
|
377
|
+
const segments = segmentsOf(inner);
|
|
378
|
+
return segments.length === 1 ? literalOutput({ ...segments[0], pipe: true }) : undefined;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The script a posix shell runs: `-c '…'`, a `<( … )` script operand, else
|
|
383
|
+
* its stdin when it has no script operand.
|
|
384
|
+
*/
|
|
385
|
+
const shellScript = (words: string[], stdin: string | undefined): string | undefined => {
|
|
386
|
+
const flag = words.findIndex((word, index) => index > 0 && SCRIPT_FLAG.test(word));
|
|
387
|
+
if (flag > 0) return words[flag + 1] === "--" ? words[flag + 2] : words[flag + 1];
|
|
388
|
+
const operand = words.slice(1).find((word) => !word.startsWith("-"));
|
|
389
|
+
if (operand !== undefined) return processSubstitutionText(operand);
|
|
390
|
+
return stdin;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
/** The command `script -c '…'` (util-linux) runs. */
|
|
394
|
+
const scriptCommand = (words: string[]): string | undefined => {
|
|
395
|
+
for (let index = 1; index < words.length; index++) {
|
|
396
|
+
const word = words[index];
|
|
397
|
+
if (word === "--command" || /^-[a-zA-Z]*c$/.test(word)) return words[index + 1];
|
|
398
|
+
if (word.startsWith("--command=")) return word.slice("--command=".length);
|
|
399
|
+
}
|
|
400
|
+
return undefined;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
/** GNU parallel options that take a value. */
|
|
404
|
+
const PARALLEL_VALUES = new Set(["-j", "--jobs", "-N", "-n", "-S", "--sshlogin", "-P", "--delay"]);
|
|
405
|
+
|
|
406
|
+
/** The commands `parallel cmd … ::: a b` runs: one per argument after `:::`. */
|
|
407
|
+
const parallelCommands = (words: string[]): string[][] => {
|
|
408
|
+
let index = 1;
|
|
409
|
+
while (index < words.length && words[index].startsWith("-"))
|
|
410
|
+
index += PARALLEL_VALUES.has(words[index]) ? 2 : 1;
|
|
411
|
+
const separator = words.indexOf(":::", index);
|
|
412
|
+
const command = words.slice(index, separator < 0 ? undefined : separator);
|
|
413
|
+
if (separator < 0) return command.length ? [command] : [];
|
|
414
|
+
return words
|
|
415
|
+
.slice(separator + 1)
|
|
416
|
+
.filter((arg) => !arg.startsWith(":::"))
|
|
417
|
+
.slice(0, MAX_EXPANSIONS)
|
|
418
|
+
.map((arg) =>
|
|
419
|
+
command.some((word) => word.includes("{}"))
|
|
420
|
+
? command.map((word) => word.replaceAll("{}", arg))
|
|
421
|
+
: [...command, arg],
|
|
422
|
+
);
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
/** PowerShell `Start-Process` parameters that take a value, besides -FilePath and -ArgumentList. */
|
|
426
|
+
const START_PROCESS_VALUES =
|
|
427
|
+
/^-(?:workingdirectory|wd|redirectstandard\w*|verb|windowstyle|credential|environment)$/i;
|
|
428
|
+
|
|
429
|
+
/** The command line `Start-Process git -ArgumentList 'push','origin'` starts. */
|
|
430
|
+
const startProcessCommand = (words: string[]): string[] | null => {
|
|
431
|
+
let file: string | undefined;
|
|
432
|
+
let args: string | undefined;
|
|
433
|
+
const positionals: string[] = [];
|
|
434
|
+
for (let index = 1; index < words.length; index++) {
|
|
435
|
+
const word = words[index];
|
|
436
|
+
const flag = word.startsWith("-") ? word.slice(1).toLowerCase() : null;
|
|
437
|
+
if (flag === null) positionals.push(word);
|
|
438
|
+
else if (flag && "filepath".startsWith(flag)) file = words[++index];
|
|
439
|
+
else if (flag && (flag === "args" || "argumentlist".startsWith(flag))) args = words[++index];
|
|
440
|
+
else if (START_PROCESS_VALUES.test(word)) index++;
|
|
441
|
+
}
|
|
442
|
+
file ??= positionals.shift();
|
|
443
|
+
args ??= positionals.shift();
|
|
444
|
+
if (!file) return null;
|
|
445
|
+
return [file, ...(args ?? "").split(/[\s,]+/).filter(Boolean)];
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
/** `env -S 'cmd args'` (`--split-string`): the command line it splits, or null. */
|
|
449
|
+
const splitString = (line: string[]): string | null => {
|
|
450
|
+
if (commandName(line[0]) !== "env") return null;
|
|
451
|
+
for (let index = 1; index < line.length; index++) {
|
|
452
|
+
const word = line[index];
|
|
453
|
+
if (word === "-S" || word === "--split-string") return line.slice(index + 1).join(" ");
|
|
454
|
+
if (word.startsWith("--split-string=")) return line.slice(index).join(" ").slice(15);
|
|
455
|
+
if (/^-S./.test(word)) return [word.slice(2), ...line.slice(index + 1)].join(" ");
|
|
456
|
+
if (!word.startsWith("-") && !ASSIGNMENT.test(word)) return null;
|
|
457
|
+
}
|
|
458
|
+
return null;
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
/** git config options that read, list or remove: nothing is set. */
|
|
462
|
+
const CONFIG_READS =
|
|
463
|
+
/^(?:--get\S*|-l|--list|--unset\S*|--remove-section|--rename-section|-e|--edit|--show-\S+|--name-only)$/;
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The `-c` options equivalent to what one git invocation sets for the ones
|
|
467
|
+
* after it in the same command: `remote add|set-url <name> <url>` and
|
|
468
|
+
* `config [scope] <key> <value>` (`config set <key> <value>`).
|
|
469
|
+
*/
|
|
470
|
+
const configSetBy = (args: readonly string[]): string[] => {
|
|
471
|
+
const positionals = args.filter((arg, index) => index > 0 && !arg.startsWith("-"));
|
|
472
|
+
if (args[0] === "remote" && (positionals[0] === "add" || positionals[0] === "set-url")) {
|
|
473
|
+
const [, name, url] = positionals;
|
|
474
|
+
const key = args.includes("--push") ? "pushurl" : "url";
|
|
475
|
+
return name && url ? ["-c", `remote.${name}.${key}=${url}`] : [];
|
|
476
|
+
}
|
|
477
|
+
if (args[0] !== "config" || args.some((arg) => CONFIG_READS.test(arg))) return [];
|
|
478
|
+
const [key, value] = positionals[0] === "set" ? positionals.slice(1) : positionals;
|
|
479
|
+
return key?.includes(".") && value !== undefined && positionals.length <= 3
|
|
480
|
+
? ["-c", `${key}=${value}`]
|
|
481
|
+
: [];
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* A git invocation from the words after `git`: global options are read up
|
|
486
|
+
* to the subcommand; `-C` moves the directory and the configuration options
|
|
487
|
+
* are kept so an alias is resolved under them.
|
|
488
|
+
*/
|
|
489
|
+
const gitInvocation = (
|
|
490
|
+
words: string[],
|
|
491
|
+
dir: Dir,
|
|
492
|
+
env: Record<string, string> = {},
|
|
493
|
+
stdin?: string,
|
|
494
|
+
inherited: string[] = [],
|
|
495
|
+
): RawInvocation => {
|
|
496
|
+
let here = dir;
|
|
497
|
+
const config = [...inherited];
|
|
498
|
+
let gitDir = env.GIT_DIR;
|
|
499
|
+
let from = "GIT_DIR";
|
|
500
|
+
let index = 0;
|
|
501
|
+
for (; index < words.length; index++) {
|
|
502
|
+
const word = words[index];
|
|
503
|
+
if (word === "-C") here = words[index + 1] ? joinDir(here, words[++index]) : here;
|
|
504
|
+
else if (GIT_CONFIG_OPTIONS.has(word)) {
|
|
505
|
+
if (word === "--git-dir") [gitDir, from] = [words[index + 1], "--git-dir"];
|
|
506
|
+
if (words[index + 1] !== undefined) config.push(word, words[++index]);
|
|
507
|
+
} else if (/^--(?:config-env|git-dir)=/.test(word)) {
|
|
508
|
+
if (word.startsWith("--git-dir="))
|
|
509
|
+
[gitDir, from] = [word.slice("--git-dir=".length), "--git-dir"];
|
|
510
|
+
config.push(word);
|
|
511
|
+
} else if (GIT_VALUE_OPTIONS.has(word)) index++;
|
|
512
|
+
else if (!word.startsWith("-")) break;
|
|
513
|
+
}
|
|
514
|
+
// The common directory holds the refs and remotes a push reads (#545).
|
|
515
|
+
if (env.GIT_COMMON_DIR) [gitDir, from] = [env.GIT_COMMON_DIR, "GIT_COMMON_DIR"];
|
|
516
|
+
// `--git-dir x/.git` (or GIT_DIR) works on the repository at x, wherever the shell is.
|
|
517
|
+
const known = here !== undefined;
|
|
518
|
+
if (gitDir) here = joinDir(here, gitDir.replace(/(?:^|[\\/]+)\.git[\\/]*$/, "") || ".");
|
|
519
|
+
return {
|
|
520
|
+
tool: "git",
|
|
521
|
+
dir: here,
|
|
522
|
+
args: words.slice(index),
|
|
523
|
+
...(config.length ? { config } : {}),
|
|
524
|
+
...(Object.keys(env).length ? { env } : {}),
|
|
525
|
+
...(stdin === undefined ? {} : { stdin }),
|
|
526
|
+
...(known && here === undefined ? { redirect: from } : {}),
|
|
527
|
+
};
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Every git/gh/glab invocation in a shell command: `&&`/`;`/`|` chains,
|
|
532
|
+
* `cd`/`pushd`/`popd` before it (scoped to `( … )` groups), `git -C dir`,
|
|
533
|
+
* env prefixes, compound commands (`if`, `for`, `{ …; }`), literal
|
|
534
|
+
* variables and loop lists, command substitutions, `bash -c '…'`, `eval`,
|
|
535
|
+
* `xargs`, `find -exec`, `pwsh -Command`/`-EncodedCommand`, `cmd /c` and
|
|
536
|
+
* shells fed a heredoc or a literal pipe. One hook call asks several
|
|
537
|
+
* times for the same command: the last answers are kept.
|
|
538
|
+
*/
|
|
539
|
+
export function rawInvocations(
|
|
540
|
+
command: string,
|
|
541
|
+
dialect: ShellDialect = "posix",
|
|
542
|
+
depth = 0,
|
|
543
|
+
inherited: Record<string, string> = {},
|
|
544
|
+
): readonly RawInvocation[] {
|
|
545
|
+
if (depth > 0) return walk(command, dialect, depth, undefined, inherited);
|
|
546
|
+
const key = `${dialect}\0${command}`;
|
|
547
|
+
const cached = RECENT.get(key);
|
|
548
|
+
if (cached) return cached;
|
|
549
|
+
let found: RawInvocation[];
|
|
550
|
+
try {
|
|
551
|
+
found = walk(command, dialect, depth);
|
|
552
|
+
} catch (error) {
|
|
553
|
+
throw error instanceof ShellAnalysisError
|
|
554
|
+
? error
|
|
555
|
+
: new ShellAnalysisError(error instanceof Error ? error.message : String(error));
|
|
556
|
+
}
|
|
557
|
+
RECENT.set(key, found);
|
|
558
|
+
if (RECENT.size > 8) RECENT.delete(RECENT.keys().next().value as string);
|
|
559
|
+
return found;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** The invocations of the last few commands parsed (pure: same text, same answer). */
|
|
563
|
+
const RECENT = new Map<string, readonly RawInvocation[]>();
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Every command line a shell command runs, past the same wrappers, nesting
|
|
567
|
+
* and chains rawInvocations reads (`bash -c '…'`, `flock f -c '…'`, `xargs`,
|
|
568
|
+
* `timeout 30`, env prefixes): the words after the prefixes, outermost first.
|
|
569
|
+
*/
|
|
570
|
+
export function commandLines(command: string, dialect: ShellDialect = "posix"): string[][] {
|
|
571
|
+
const lines: string[][] = [];
|
|
572
|
+
try {
|
|
573
|
+
walk(command, dialect, 0, lines);
|
|
574
|
+
} catch (error) {
|
|
575
|
+
throw error instanceof ShellAnalysisError
|
|
576
|
+
? error
|
|
577
|
+
: new ShellAnalysisError(error instanceof Error ? error.message : String(error));
|
|
578
|
+
}
|
|
579
|
+
return lines;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function walk(
|
|
583
|
+
command: string,
|
|
584
|
+
dialect: ShellDialect,
|
|
585
|
+
depth: number,
|
|
586
|
+
seen?: string[][],
|
|
587
|
+
inherited: Record<string, string> = {},
|
|
588
|
+
): RawInvocation[] {
|
|
589
|
+
const found: RawInvocation[] = [];
|
|
590
|
+
let dir: Dir = null;
|
|
591
|
+
const groups: Array<{ dir: Dir; stack: Dir[] }> = [];
|
|
592
|
+
let stack: Dir[] = [];
|
|
593
|
+
const vars = new Map<string, string[]>();
|
|
594
|
+
// `export GIT_DIR=…` and earlier `git remote add` / `git config` in the
|
|
595
|
+
// same command change what the git invocations after them see.
|
|
596
|
+
const exported: Record<string, string> = { ...inherited };
|
|
597
|
+
// A nested shell inherits the exports and its own prefix (`GIT_DIR=x bash -c …`).
|
|
598
|
+
let prefix: Record<string, string> = {};
|
|
599
|
+
const sessionConfig: string[] = [];
|
|
600
|
+
const nested = (script: string | null | undefined, inner: ShellDialect) => {
|
|
601
|
+
if (!script || depth >= MAX_DEPTH) return;
|
|
602
|
+
const env = { ...exported, ...prefix };
|
|
603
|
+
const inside = seen
|
|
604
|
+
? walk(script, inner, depth + 1, seen, env)
|
|
605
|
+
: rawInvocations(script, inner, depth + 1, env);
|
|
606
|
+
for (const invocation of inside)
|
|
607
|
+
found.push({ ...invocation, dir: rebase(dir, invocation.dir) });
|
|
608
|
+
};
|
|
609
|
+
const visit = (input: string[], stdin: string | undefined, hops: number) => {
|
|
610
|
+
// PowerShell `$x = git …`: the assignment runs the command.
|
|
611
|
+
const line = /^\$[\w:]+$/.test(input[0] ?? "") && input[1] === "=" ? input.slice(2) : input;
|
|
612
|
+
const split = splitString(line);
|
|
613
|
+
if (split !== null) return nested(split, "posix");
|
|
614
|
+
const { words, env } = stripPrefixes(line);
|
|
615
|
+
prefix = env;
|
|
616
|
+
const name = commandName(words[0]);
|
|
617
|
+
if (!name) return;
|
|
618
|
+
seen?.push(words);
|
|
619
|
+
// `flock <file> -c '…'`: the wrapper's script.
|
|
620
|
+
if (name === "-c" || name === "--command") return nested(words[1], "posix");
|
|
621
|
+
const runs = (lines: string[][]) => {
|
|
622
|
+
if (hops < MAX_DEPTH) for (const next of lines) visit(next, undefined, hops + 1);
|
|
623
|
+
};
|
|
624
|
+
// The gates judge what `workit check … -- <cmd>` runs as if it ran raw.
|
|
625
|
+
// (The heavy-command reader, `seen`, stops at workit: a wrapped command
|
|
626
|
+
// already runs under the lease.)
|
|
627
|
+
const wrapped = seen ? null : workitCheckTail(words);
|
|
628
|
+
if (wrapped) {
|
|
629
|
+
// `--cwd <dir>` is where the wrapped command runs, not the shell's directory.
|
|
630
|
+
const outer = dir;
|
|
631
|
+
if (wrapped.cwd !== null) dir = joinDir(dir, wrapped.cwd);
|
|
632
|
+
if ("script" in wrapped) nested(wrapped.script, "posix");
|
|
633
|
+
else runs([wrapped.argv]);
|
|
634
|
+
dir = outer;
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
if (CD.has(name)) {
|
|
638
|
+
const target = cdTarget(words);
|
|
639
|
+
dir = target === null ? os.homedir() : joinDir(dir, target);
|
|
640
|
+
} else if (PUSHD.has(name)) {
|
|
641
|
+
const target = cdTarget(words);
|
|
642
|
+
stack.push(dir);
|
|
643
|
+
dir = target === null || target === "-" ? undefined : joinDir(dir, target);
|
|
644
|
+
} else if (POPD.has(name)) dir = stack.length ? stack.pop() : undefined;
|
|
645
|
+
else if (EVALS.has(name)) {
|
|
646
|
+
// `Invoke-Expression -Command '…'`: the parameter name is not script.
|
|
647
|
+
const args = /^-c(?:o(?:m(?:m(?:a(?:n(?:d)?)?)?)?)?)?$/i.test(words[1] ?? "")
|
|
648
|
+
? words.slice(2)
|
|
649
|
+
: words.slice(1);
|
|
650
|
+
nested(args.join(" "), dialect);
|
|
651
|
+
} else if (SHELLS.has(name)) nested(shellScript(words, stdin), "posix");
|
|
652
|
+
else if ((name === "source" || name === ".") && dialect === "posix")
|
|
653
|
+
nested(processSubstitutionText(words[1]), "posix");
|
|
654
|
+
else if (name === "trap" && dialect === "posix" && !/^-[lp]$/.test(words[1] ?? ""))
|
|
655
|
+
nested(words[1], "posix");
|
|
656
|
+
else if (name === "script") nested(scriptCommand(words), "posix");
|
|
657
|
+
else if (name === "parallel") runs(parallelCommands(words));
|
|
658
|
+
else if (dialect === "powershell" && /^(?:start-process|saps|start)$/.test(name)) {
|
|
659
|
+
const started = startProcessCommand(words);
|
|
660
|
+
if (started) runs([started]);
|
|
661
|
+
} else if (POWERSHELLS.has(name)) nested(powershellScript(words, stdin), "powershell");
|
|
662
|
+
else if (name === "cmd") nested(cmdScript(words), "powershell");
|
|
663
|
+
else if (name === "xargs" || name === "find")
|
|
664
|
+
runs(name === "xargs" ? xargsCommands(words, stdin) : findCommands(words));
|
|
665
|
+
else if (name === "gh" || name === "glab")
|
|
666
|
+
found.push({
|
|
667
|
+
tool: name,
|
|
668
|
+
dir,
|
|
669
|
+
args: words.slice(1),
|
|
670
|
+
...(Object.keys({ ...exported, ...env }).length ? { env: { ...exported, ...env } } : {}),
|
|
671
|
+
...(stdin === undefined ? {} : { stdin }),
|
|
672
|
+
});
|
|
673
|
+
else if (name === "git") {
|
|
674
|
+
const invocation = gitInvocation(
|
|
675
|
+
words.slice(1),
|
|
676
|
+
dir,
|
|
677
|
+
{ ...exported, ...env },
|
|
678
|
+
stdin,
|
|
679
|
+
sessionConfig,
|
|
680
|
+
);
|
|
681
|
+
found.push(invocation);
|
|
682
|
+
sessionConfig.push(...configSetBy(invocation.args));
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
const segments = segmentsOf(command, dialect);
|
|
686
|
+
for (const [index, segment] of segments.entries()) {
|
|
687
|
+
if (segment.group === "open") {
|
|
688
|
+
groups.push({ dir, stack: [...stack] });
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
if (segment.group === "close") {
|
|
692
|
+
const saved = groups.pop();
|
|
693
|
+
if (saved) ({ dir, stack } = saved);
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
if (segment.unanalyzable) throw new ShellAnalysisError(segment.unanalyzable);
|
|
697
|
+
for (const sub of segment.subs ?? []) nested(sub, "posix");
|
|
698
|
+
const set = assignments(segment.words, vars);
|
|
699
|
+
if (set) {
|
|
700
|
+
for (const [name, values] of set) {
|
|
701
|
+
vars.set(name, values);
|
|
702
|
+
if (segment.words[0] === "export" && GIT_CONFIG_ENV.test(name) && values[0] !== undefined)
|
|
703
|
+
exported[name] = values[0];
|
|
704
|
+
}
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
const stdin = stdinOf(segment, segments[index - 1]);
|
|
708
|
+
for (const line of substitute(segment.words, vars)) visit(line, stdin, 0);
|
|
709
|
+
}
|
|
710
|
+
return found;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const LAUNCHERS = new Set([
|
|
714
|
+
"npx",
|
|
715
|
+
"bunx",
|
|
716
|
+
"pnpx",
|
|
717
|
+
"-y",
|
|
718
|
+
"--yes",
|
|
719
|
+
"pnpm",
|
|
720
|
+
"dlx",
|
|
721
|
+
"exec",
|
|
722
|
+
"env",
|
|
723
|
+
"sudo",
|
|
724
|
+
"command",
|
|
725
|
+
]);
|
|
726
|
+
const WORKIT_BIN = /(?:^|[\\/])workit(?:\.exe)?$|^@workit-dev\/cli$/;
|
|
727
|
+
const WORKIT_PACKAGE = /^@workit-dev\/cli(?:@\S*)?$/;
|
|
728
|
+
const PACKAGE_RUNNERS = new Set(["npx", "bunx", "pnpx"]);
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* What a `workit check [flags] -- <cmd…>` (or `workit lock run -- <cmd…>`,
|
|
732
|
+
* also through `npx @workit-dev/cli`) runs: the script of `--shell`,
|
|
733
|
+
* else the argv, and the `--cwd` it runs in (null: the shell's). Null for any
|
|
734
|
+
* other command.
|
|
735
|
+
*/
|
|
736
|
+
export function workitCheckTail(
|
|
737
|
+
words: readonly string[],
|
|
738
|
+
): (({ script: string } | { argv: string[] }) & { cwd: string | null }) | null {
|
|
739
|
+
let at = 0;
|
|
740
|
+
if (PACKAGE_RUNNERS.has(commandName(words[0]))) {
|
|
741
|
+
at = words.findIndex((word, index) => index > 0 && !word.startsWith("-"));
|
|
742
|
+
if (at < 0 || !WORKIT_PACKAGE.test(words[at])) return null;
|
|
743
|
+
} else if (!/^workit(?:\.exe|\.cmd)?$/.test(commandName(words[0]))) return null;
|
|
744
|
+
const { rest: args, cwd } = skipWorkitGlobals(words.slice(at + 1));
|
|
745
|
+
if (args[0] !== "check" && !(args[0] === "lock" && args[1] === "run")) return null;
|
|
746
|
+
const split = args.indexOf("--");
|
|
747
|
+
const tail = split < 0 ? [] : args.slice(split + 1);
|
|
748
|
+
if (!tail.length) return null;
|
|
749
|
+
return args.slice(0, split).includes("--shell")
|
|
750
|
+
? { script: tail.join(" "), cwd }
|
|
751
|
+
: { argv: tail, cwd };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const OUTPUT_FILTERS = new Set(["tail", "head", "grep"]);
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* The one command `command` runs after its wrappers (env prefixes, `timeout`,
|
|
758
|
+
* `nice`, `flock`, `bash -c '<one command>'`), or null when it is a chain,
|
|
759
|
+
* pipe, redirect, substitution, group or heredoc anywhere (`shaped` allows
|
|
760
|
+
* redirects and one trailing `| tail|head|grep` filter).
|
|
761
|
+
*/
|
|
762
|
+
export function simpleCommand(
|
|
763
|
+
command: string,
|
|
764
|
+
dialect: ShellDialect = "posix",
|
|
765
|
+
depth = 0,
|
|
766
|
+
shaped = false,
|
|
767
|
+
): string[] | null {
|
|
768
|
+
let segments: Segment[];
|
|
769
|
+
try {
|
|
770
|
+
segments = segmentsOf(command, dialect);
|
|
771
|
+
} catch {
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
// `shaped`: output shaping around the one command is not a chain: any
|
|
775
|
+
// redirects, and at most one trailing `| tail|head|grep …` filter.
|
|
776
|
+
const filter = shaped && segments.length === 2 ? segments[1] : null;
|
|
777
|
+
if (filter) {
|
|
778
|
+
if (
|
|
779
|
+
!segments[0].pipe ||
|
|
780
|
+
!OUTPUT_FILTERS.has(commandName(filter.words[0])) ||
|
|
781
|
+
filter.group ||
|
|
782
|
+
filter.pipe ||
|
|
783
|
+
filter.unanalyzable ||
|
|
784
|
+
filter.subs?.length ||
|
|
785
|
+
filter.stdin?.length
|
|
786
|
+
)
|
|
787
|
+
return null;
|
|
788
|
+
} else if (segments.length !== 1) return null;
|
|
789
|
+
const [segment] = segments;
|
|
790
|
+
if (
|
|
791
|
+
segment.group ||
|
|
792
|
+
(segment.pipe && !filter) ||
|
|
793
|
+
segment.unanalyzable ||
|
|
794
|
+
(segment.redirects.length && !shaped) ||
|
|
795
|
+
segment.subs?.length ||
|
|
796
|
+
segment.stdin?.length
|
|
797
|
+
)
|
|
798
|
+
return null;
|
|
799
|
+
const { words } = stripPrefixes(segment.words);
|
|
800
|
+
const name = commandName(words[0]);
|
|
801
|
+
if (!name || depth >= MAX_DEPTH) return null;
|
|
802
|
+
if (SHELLS.has(name)) {
|
|
803
|
+
const script = shellScript(words, undefined);
|
|
804
|
+
return script ? simpleCommand(script, "posix", depth + 1) : null;
|
|
805
|
+
}
|
|
806
|
+
if (name === "-c" || name === "--command")
|
|
807
|
+
return words[1] ? simpleCommand(words[1], "posix", depth + 1) : null;
|
|
808
|
+
if (
|
|
809
|
+
EVALS.has(name) ||
|
|
810
|
+
POWERSHELLS.has(name) ||
|
|
811
|
+
["xargs", "find", "parallel", "script", "cmd", "source", "."].includes(name)
|
|
812
|
+
)
|
|
813
|
+
return null;
|
|
814
|
+
return words;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* The arguments of every workit CLI invocation in a shell command: past env
|
|
819
|
+
* assignments and package runners (`npx -y @workit-dev/cli …`), and
|
|
820
|
+
* inside `bash -c '…'` scripts.
|
|
821
|
+
*/
|
|
822
|
+
export function workitInvocations(
|
|
823
|
+
command: string,
|
|
824
|
+
dialect: ShellDialect = "posix",
|
|
825
|
+
depth = 0,
|
|
826
|
+
): string[][] {
|
|
827
|
+
const found: string[][] = [];
|
|
828
|
+
for (const { words } of segmentsOf(command, dialect)) {
|
|
829
|
+
const at = words.findIndex((word) => !/^\w+=/.test(word) && !LAUNCHERS.has(word));
|
|
830
|
+
if (at < 0) continue;
|
|
831
|
+
if (WORKIT_BIN.test(words[at])) {
|
|
832
|
+
found.push(words.slice(at + 1));
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
const shell = SHELLS.has(commandName(words[at]));
|
|
836
|
+
const script = shell && words[at + 1] === "-c" ? words[at + 2] : undefined;
|
|
837
|
+
if (depth < 3 && script !== undefined)
|
|
838
|
+
found.push(...workitInvocations(script, "posix", depth + 1));
|
|
839
|
+
}
|
|
840
|
+
return found;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** Workit's global options before the verb: `--json`, and these (`--name value` or `--name=value`). */
|
|
844
|
+
const WORKIT_GLOBAL = /^--(?:cwd|session|token|as)(=|$)/;
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* An invocation's arguments without the global options before the verb and
|
|
848
|
+
* without any `--cwd` before `--`, and the last `--cwd`. Like the CLI's
|
|
849
|
+
* parseGlobals, `--cwd` counts anywhere before `--`, so `workit <verb> --cwd
|
|
850
|
+
* <dir> …` (the form host allow rules match) runs in `<dir>` here too.
|
|
851
|
+
*/
|
|
852
|
+
export function skipWorkitGlobals(args: readonly string[]): {
|
|
853
|
+
rest: string[];
|
|
854
|
+
cwd: string | null;
|
|
855
|
+
} {
|
|
856
|
+
let cwd: string | null = null;
|
|
857
|
+
let at = 0;
|
|
858
|
+
for (; at < args.length; at++) {
|
|
859
|
+
const match = WORKIT_GLOBAL.exec(args[at]);
|
|
860
|
+
if (match?.[1] === "=") {
|
|
861
|
+
if (args[at].startsWith("--cwd=")) cwd = args[at].slice(6);
|
|
862
|
+
} else if (match && args[at + 1] !== undefined) {
|
|
863
|
+
if (args[at] === "--cwd") cwd = args[at + 1];
|
|
864
|
+
at++;
|
|
865
|
+
} else if (args[at] !== "--json") break;
|
|
866
|
+
}
|
|
867
|
+
const rest: string[] = [];
|
|
868
|
+
for (; at < args.length; at++) {
|
|
869
|
+
const arg = args[at];
|
|
870
|
+
if (arg === "--") {
|
|
871
|
+
rest.push(...args.slice(at));
|
|
872
|
+
break;
|
|
873
|
+
}
|
|
874
|
+
if (arg.startsWith("--cwd=")) cwd = arg.slice("--cwd=".length);
|
|
875
|
+
else if (arg === "--cwd" && args[at + 1] !== undefined) cwd = args[++at];
|
|
876
|
+
else rest.push(arg);
|
|
877
|
+
}
|
|
878
|
+
return { rest, cwd };
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** What one raw invocation means for workit. */
|
|
882
|
+
export type RawAction =
|
|
883
|
+
| { kind: "commit"; amend: boolean }
|
|
884
|
+
| {
|
|
885
|
+
kind: "push";
|
|
886
|
+
/** Branches named by the refspecs; `null` stands for the current branch. */
|
|
887
|
+
targets: Array<string | null>;
|
|
888
|
+
/** Forced without a lease (`--force`, `-f`, `+refspec`). */
|
|
889
|
+
blindForce: boolean;
|
|
890
|
+
/** The remote named on the command line; null pushes to the default one. */
|
|
891
|
+
remote: string | null;
|
|
892
|
+
/** Tags named as `refs/tags/<t>` destinations (bare names are told apart later). */
|
|
893
|
+
tags?: string[];
|
|
894
|
+
/** `--tags`: every tag goes along. */
|
|
895
|
+
allTags?: true;
|
|
896
|
+
/** `--delete`/`-d`: the refspecs are deleted, not pushed. */
|
|
897
|
+
deletes?: true;
|
|
898
|
+
/** Names deleted by a colon refspec (`:chore/old`). */
|
|
899
|
+
deleted?: string[];
|
|
900
|
+
/** `--all`/`--branches`: every local branch. */
|
|
901
|
+
allBranches?: true;
|
|
902
|
+
/** `--mirror`: every ref, deletions included. */
|
|
903
|
+
mirror?: true;
|
|
904
|
+
/** No refspec: push.default and remote.<name>.push decide what goes. */
|
|
905
|
+
implicit?: true;
|
|
906
|
+
/** Glob refspecs (`refs/heads/*:refs/heads/*`), expanded against local refs. */
|
|
907
|
+
globs?: Array<{ source: string; destination: string }>;
|
|
908
|
+
/** `:` (or push.default=matching): every branch both sides have. */
|
|
909
|
+
matching?: true;
|
|
910
|
+
/** `--follow-tags`: annotated tags may go along. */
|
|
911
|
+
followTags?: true;
|
|
912
|
+
/** `--receive-pack`/`--exec`: a command, not the URL, decides what is reached. */
|
|
913
|
+
transport?: true;
|
|
914
|
+
}
|
|
915
|
+
| { kind: "merge"; forge: "gh" | "glab"; via?: "api"; repo?: string }
|
|
916
|
+
| { kind: "pr-create"; forge: "gh" | "glab"; repo?: string }
|
|
917
|
+
/** `gh pr edit|ready`, `glab mr update` (pr), `gh release create|edit|delete`, `glab release create`. */
|
|
918
|
+
| {
|
|
919
|
+
kind: "forge";
|
|
920
|
+
forge: "gh" | "glab";
|
|
921
|
+
grant: "pr" | "release";
|
|
922
|
+
repo?: string;
|
|
923
|
+
}
|
|
924
|
+
| { kind: "pr-read"; forge: "gh" | "glab" };
|
|
925
|
+
|
|
926
|
+
/** git push options that take the next word as their value. */
|
|
927
|
+
const PUSH_VALUE_OPTIONS = new Set(["-o", "--push-option", "--receive-pack", "--exec", "--repo"]);
|
|
928
|
+
|
|
929
|
+
type PushTarget = { name: string | null | undefined; tag?: string };
|
|
930
|
+
|
|
931
|
+
/** The destination of one push refspec: a branch, `null` for HEAD, undefined for another namespace. */
|
|
932
|
+
/** `$(git branch --show-current)` and its spellings: the current branch. */
|
|
933
|
+
const CURRENT_BRANCH =
|
|
934
|
+
/^\$\(\s*git\s+(?:branch\s+--show-current|rev-parse\s+--abbrev-ref\s+HEAD|symbolic-ref\s+--short\s+HEAD)\s*\)$/;
|
|
935
|
+
|
|
936
|
+
const pushDestination = (destination: string): PushTarget => {
|
|
937
|
+
if (destination === "HEAD" || destination === "@" || CURRENT_BRANCH.test(destination))
|
|
938
|
+
return { name: null };
|
|
939
|
+
if (destination.startsWith("refs/heads/")) return { name: destination.slice(11) };
|
|
940
|
+
if (destination.startsWith("refs/tags/")) return { name: undefined, tag: destination.slice(10) };
|
|
941
|
+
if (destination.startsWith("refs/")) return { name: undefined };
|
|
942
|
+
return { name: destination };
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
/** The parts of a push the refspecs name (`[+]<src>[:<dst>]`). */
|
|
946
|
+
const pushRefspecs = (specs: readonly string[]) => {
|
|
947
|
+
let force = false;
|
|
948
|
+
let matching = false;
|
|
949
|
+
const targets: Array<string | null> = [];
|
|
950
|
+
const tags: string[] = [];
|
|
951
|
+
const deleted: string[] = [];
|
|
952
|
+
const globs: Array<{ source: string; destination: string }> = [];
|
|
953
|
+
for (const raw of specs) {
|
|
954
|
+
if (raw.startsWith("+")) force = true;
|
|
955
|
+
const spec = raw.replace(/^\+/, "");
|
|
956
|
+
if (spec.startsWith("^")) continue;
|
|
957
|
+
if (spec === ":") {
|
|
958
|
+
matching = true;
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
961
|
+
const colon = spec.indexOf(":");
|
|
962
|
+
const source = colon >= 0 ? spec.slice(0, colon) : spec;
|
|
963
|
+
const destination = colon >= 0 ? spec.slice(colon + 1) : spec;
|
|
964
|
+
if (!destination) continue;
|
|
965
|
+
if (destination.includes("*")) {
|
|
966
|
+
globs.push({ source, destination });
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
const { name, tag } = pushDestination(destination);
|
|
970
|
+
if (tag !== undefined) tags.push(tag);
|
|
971
|
+
if (name === undefined) continue;
|
|
972
|
+
targets.push(name);
|
|
973
|
+
if (colon === 0 && name !== null) deleted.push(name);
|
|
974
|
+
}
|
|
975
|
+
return { force, matching, targets, tags, deleted, globs };
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
export const pushAction = (args: string[]): RawAction | null => {
|
|
979
|
+
let force = false;
|
|
980
|
+
let lease = false;
|
|
981
|
+
let wholesale = false;
|
|
982
|
+
let allTags = false;
|
|
983
|
+
let deletes = false;
|
|
984
|
+
let allBranches = false;
|
|
985
|
+
let mirror = false;
|
|
986
|
+
let followTags = false;
|
|
987
|
+
let transport = false;
|
|
988
|
+
let remote: string | null = null;
|
|
989
|
+
// The last of --dry-run (or an abbreviation, or -n) and --no-dry-run wins.
|
|
990
|
+
let dryRun = false;
|
|
991
|
+
const positionals: string[] = [];
|
|
992
|
+
for (let index = 1; index < args.length; index++) {
|
|
993
|
+
const arg = args[index];
|
|
994
|
+
if (arg === "--") {
|
|
995
|
+
positionals.push(...args.slice(index + 1));
|
|
996
|
+
break;
|
|
997
|
+
}
|
|
998
|
+
if (/^--dr(?:y(?:-(?:r(?:un?)?)?)?)?$/.test(arg) || /^-[a-zA-Z]*n[a-zA-Z]*$/.test(arg))
|
|
999
|
+
dryRun = true;
|
|
1000
|
+
else if (/^--no-dr(?:y(?:-(?:r(?:un?)?)?)?)?$/.test(arg)) dryRun = false;
|
|
1001
|
+
else if (arg === "--repo") remote = args[++index] ?? null;
|
|
1002
|
+
else if (arg.startsWith("--repo=")) remote = arg.slice("--repo=".length);
|
|
1003
|
+
else if (/^--(?:receive-pack|exec)(?:=|$)/.test(arg)) {
|
|
1004
|
+
transport = true;
|
|
1005
|
+
if (!arg.includes("=")) index++;
|
|
1006
|
+
} else if (PUSH_VALUE_OPTIONS.has(arg)) index++;
|
|
1007
|
+
else if (arg === "--follow-tags") followTags = true;
|
|
1008
|
+
else if (arg === "--force" || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(arg)) force = true;
|
|
1009
|
+
else if (arg.startsWith("--force-with-lease") || arg === "--force-if-includes") lease = true;
|
|
1010
|
+
else if (arg === "--delete" || arg === "-d") deletes = true;
|
|
1011
|
+
else if (arg === "--tags") allTags = wholesale = true;
|
|
1012
|
+
else if (arg === "--all" || arg === "--branches") allBranches = wholesale = true;
|
|
1013
|
+
else if (arg === "--mirror") mirror = wholesale = true;
|
|
1014
|
+
else if (!arg.startsWith("-")) positionals.push(arg);
|
|
1015
|
+
}
|
|
1016
|
+
if (dryRun) return null;
|
|
1017
|
+
// `--repo` names the remote; positionals are then all refspecs.
|
|
1018
|
+
const specs = remote === null ? positionals.slice(1) : positionals;
|
|
1019
|
+
remote ??= positionals[0] ?? null;
|
|
1020
|
+
const parsed = pushRefspecs(specs);
|
|
1021
|
+
// No refspec pushes what push.default says (the current branch by default).
|
|
1022
|
+
const implicit = specs.length === 0 && !wholesale;
|
|
1023
|
+
if (implicit) parsed.targets.push(null);
|
|
1024
|
+
return {
|
|
1025
|
+
kind: "push",
|
|
1026
|
+
targets: parsed.targets,
|
|
1027
|
+
blindForce: (force || parsed.force) && !lease,
|
|
1028
|
+
remote,
|
|
1029
|
+
...(parsed.tags.length ? { tags: parsed.tags } : {}),
|
|
1030
|
+
...(allTags ? { allTags: true as const } : {}),
|
|
1031
|
+
...(deletes ? { deletes: true as const } : {}),
|
|
1032
|
+
...(parsed.deleted.length ? { deleted: parsed.deleted } : {}),
|
|
1033
|
+
...(allBranches ? { allBranches: true as const } : {}),
|
|
1034
|
+
...(mirror ? { mirror: true as const } : {}),
|
|
1035
|
+
...(implicit ? { implicit: true as const } : {}),
|
|
1036
|
+
...(parsed.globs.length ? { globs: parsed.globs } : {}),
|
|
1037
|
+
...(parsed.matching ? { matching: true as const } : {}),
|
|
1038
|
+
...(followTags ? { followTags: true as const } : {}),
|
|
1039
|
+
...(transport ? { transport: true as const } : {}),
|
|
1040
|
+
};
|
|
1041
|
+
};
|
|
1042
|
+
|
|
1043
|
+
/** Options whose next word is their value (never an option or subcommand). */
|
|
1044
|
+
const VALUE_OPTIONS: Record<RawInvocation["tool"], ReadonlySet<string>> = {
|
|
1045
|
+
git: new Set([
|
|
1046
|
+
"-m",
|
|
1047
|
+
"--message",
|
|
1048
|
+
"-F",
|
|
1049
|
+
"--file",
|
|
1050
|
+
"-C",
|
|
1051
|
+
"-c",
|
|
1052
|
+
"--reuse-message",
|
|
1053
|
+
"--reedit-message",
|
|
1054
|
+
"--author",
|
|
1055
|
+
"--date",
|
|
1056
|
+
"--trailer",
|
|
1057
|
+
"-t",
|
|
1058
|
+
"--template",
|
|
1059
|
+
"--cleanup",
|
|
1060
|
+
"--fixup",
|
|
1061
|
+
"--squash",
|
|
1062
|
+
"-o",
|
|
1063
|
+
"--push-option",
|
|
1064
|
+
"--repo",
|
|
1065
|
+
"--receive-pack",
|
|
1066
|
+
"--exec",
|
|
1067
|
+
]),
|
|
1068
|
+
gh: new Set(
|
|
1069
|
+
["-b", "--body", "-F", "--body-file", "-t", "--title", "--subject", "-A", "--author-email"]
|
|
1070
|
+
.concat(["-B", "--base", "-H", "--head", "-l", "--label", "-a", "--assignee"])
|
|
1071
|
+
.concat(["-r", "--reviewer", "-m", "--milestone", "-p", "--project", "-T", "--template"])
|
|
1072
|
+
.concat(["--match-head-commit", "-R", "--repo"]),
|
|
1073
|
+
),
|
|
1074
|
+
glab: new Set(
|
|
1075
|
+
["-m", "--message", "--squash-message", "--sha", "-t", "--title", "-d", "--description"]
|
|
1076
|
+
.concat(["-b", "--target-branch", "-s", "--source-branch", "-l", "--label"])
|
|
1077
|
+
.concat(["-a", "--assignee", "--reviewer", "-R", "--repo"]),
|
|
1078
|
+
),
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* A help request: `help` as the subcommand (`git help push`, `gh pr help`),
|
|
1083
|
+
* or `--help`/`-h` where an option can stand, never as an option's value
|
|
1084
|
+
* (`git commit -m help` is a commit).
|
|
1085
|
+
*/
|
|
1086
|
+
const asksHelp = (invocation: RawInvocation): boolean => {
|
|
1087
|
+
const { tool, args } = invocation;
|
|
1088
|
+
if (args[0] === "help" || (tool !== "git" && args[1] === "help")) return true;
|
|
1089
|
+
const values = VALUE_OPTIONS[tool];
|
|
1090
|
+
for (let index = 0; index < args.length; index++) {
|
|
1091
|
+
const arg = args[index];
|
|
1092
|
+
if (arg === "--") return false;
|
|
1093
|
+
if (arg === "--help" || arg === "-h") return true;
|
|
1094
|
+
if (values.has(arg)) index++;
|
|
1095
|
+
}
|
|
1096
|
+
return false;
|
|
1097
|
+
};
|
|
1098
|
+
|
|
1099
|
+
/** `gh api`/`glab api` options that take a value, long and short. */
|
|
1100
|
+
const API_VALUES = new Set(
|
|
1101
|
+
["--method", "--header", "--raw-field", "--field", "--jq", "--template", "--input"].concat([
|
|
1102
|
+
"--hostname",
|
|
1103
|
+
"--preview",
|
|
1104
|
+
"--cache",
|
|
1105
|
+
"--output",
|
|
1106
|
+
]),
|
|
1107
|
+
);
|
|
1108
|
+
const API_SHORT_VALUES = "XHfFqtp";
|
|
1109
|
+
const API_FIELDS = new Set(["-f", "-F", "--raw-field", "--field"]);
|
|
1110
|
+
/** REST merges: a PR (`pulls/<n>/merge`), a branch into another (`merges`), an MR. */
|
|
1111
|
+
const API_MERGE = {
|
|
1112
|
+
gh: /(?:^|\/)(?:pulls\/[^/?#]+\/merge|merges)\/?(?:[?#]|$)/i,
|
|
1113
|
+
glab: /(?:^|\/)merge_requests\/[^/?#]+\/merge\/?(?:[?#]|$)/i,
|
|
1114
|
+
};
|
|
1115
|
+
/** GraphQL mutations that merge. */
|
|
1116
|
+
const GRAPHQL_MERGE =
|
|
1117
|
+
/\b(?:mergePullRequest|enablePullRequestAutoMerge|mergeBranch|mergeRequestAccept)\b/;
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* `gh api`/`glab api` that merges: a write (any method but GET/HEAD; fields
|
|
1121
|
+
* or `--input` without `-X` make it a POST) to a merge endpoint, or a GraphQL
|
|
1122
|
+
* merge mutation. Options are read wherever they stand; a help flag does not
|
|
1123
|
+
* excuse a merge (it can be another option's value).
|
|
1124
|
+
*/
|
|
1125
|
+
const apiAction = (tool: "gh" | "glab", args: string[], stdin?: string): RawAction | null => {
|
|
1126
|
+
const request = { method: null as string | null, body: false };
|
|
1127
|
+
let endpoint: string | null = null;
|
|
1128
|
+
const values: string[] = [];
|
|
1129
|
+
const take = (name: string, value: string) => {
|
|
1130
|
+
if (name === "-X" || name === "--method") request.method = value.toUpperCase();
|
|
1131
|
+
if (API_FIELDS.has(name) || name === "--input") request.body = true;
|
|
1132
|
+
values.push(value);
|
|
1133
|
+
// `-F query=@-`, `--input -`: the value is read from stdin.
|
|
1134
|
+
const fromStdin = name === "--input" ? value === "-" : /^(?:[^=]*=)?@-$/.test(value);
|
|
1135
|
+
if (fromStdin && stdin !== undefined) values.push(stdin);
|
|
1136
|
+
};
|
|
1137
|
+
for (let index = 1; index < args.length; index++) {
|
|
1138
|
+
const arg = args[index];
|
|
1139
|
+
if (arg === "--") {
|
|
1140
|
+
endpoint ??= args[index + 1] ?? null;
|
|
1141
|
+
break;
|
|
1142
|
+
}
|
|
1143
|
+
if (arg.startsWith("--")) {
|
|
1144
|
+
const eq = arg.indexOf("=");
|
|
1145
|
+
const name = eq > 0 ? arg.slice(0, eq) : arg;
|
|
1146
|
+
if (API_VALUES.has(name)) take(name, eq > 0 ? arg.slice(eq + 1) : (args[++index] ?? ""));
|
|
1147
|
+
} else if (arg.startsWith("-") && arg.length > 1) {
|
|
1148
|
+
// A cluster of short flags; the first one that takes a value ends it.
|
|
1149
|
+
for (let at = 1; at < arg.length; at++) {
|
|
1150
|
+
if (!API_SHORT_VALUES.includes(arg[at])) continue;
|
|
1151
|
+
take(`-${arg[at]}`, at + 1 < arg.length ? arg.slice(at + 1) : (args[++index] ?? ""));
|
|
1152
|
+
break;
|
|
1153
|
+
}
|
|
1154
|
+
} else endpoint ??= arg;
|
|
1155
|
+
}
|
|
1156
|
+
if (!endpoint) return null;
|
|
1157
|
+
endpoint = endpoint.replace(/(?<!:)\/{2,}/g, "/");
|
|
1158
|
+
const merge = { kind: "merge" as const, forge: tool, via: "api" as const };
|
|
1159
|
+
if (/(?:^|\/)graphql\/?$/i.test(endpoint))
|
|
1160
|
+
return values.some((value) => GRAPHQL_MERGE.test(value)) ? merge : null;
|
|
1161
|
+
const verb = request.method ?? (request.body ? "POST" : "GET");
|
|
1162
|
+
if (verb === "GET" || verb === "HEAD") return null;
|
|
1163
|
+
// The repository the path names: gh `repos/OWNER/REPO/…` ({owner} placeholders
|
|
1164
|
+
// are the checkout's), glab `projects/<url-encoded path or id>/…`.
|
|
1165
|
+
const named =
|
|
1166
|
+
tool === "gh"
|
|
1167
|
+
? /(?:^|\/)repos\/([^/{]+\/[^/{]+)\//i.exec(endpoint)?.[1]
|
|
1168
|
+
: /(?:^|\/)projects\/([^/]+)\//i.exec(endpoint)?.[1];
|
|
1169
|
+
let repo: string | undefined;
|
|
1170
|
+
try {
|
|
1171
|
+
repo = named === undefined ? undefined : decodeURIComponent(named) || "?";
|
|
1172
|
+
} catch {
|
|
1173
|
+
repo = "?";
|
|
1174
|
+
}
|
|
1175
|
+
const at = repo === undefined ? {} : { repo };
|
|
1176
|
+
if (API_MERGE[tool].test(endpoint)) return { ...merge, ...at };
|
|
1177
|
+
const grant = /\/releases(?:\/|$|\?)/i.test(endpoint)
|
|
1178
|
+
? "release"
|
|
1179
|
+
: (tool === "gh"
|
|
1180
|
+
? /\/pulls(?:\/\d+)?\/?(?:\?|$)/i
|
|
1181
|
+
: /\/merge_requests(?:\/\d+)?\/?(?:\?|$)/i
|
|
1182
|
+
).test(endpoint)
|
|
1183
|
+
? "pr"
|
|
1184
|
+
: null;
|
|
1185
|
+
return grant ? { kind: "forge", forge: tool, grant, ...at } : null;
|
|
1186
|
+
};
|
|
1187
|
+
|
|
1188
|
+
/** `git subtree push --prefix=<dir> <remote> <branch>`: a push of `<branch>` to `<remote>`. */
|
|
1189
|
+
const subtreePush = (args: string[]): RawAction | null => {
|
|
1190
|
+
const positionals: string[] = [];
|
|
1191
|
+
for (let index = 2; index < args.length; index++) {
|
|
1192
|
+
const arg = args[index];
|
|
1193
|
+
if (arg === "-P" || arg === "--prefix" || arg === "-m" || arg === "--message") index++;
|
|
1194
|
+
else if (!arg.startsWith("-")) positionals.push(arg);
|
|
1195
|
+
}
|
|
1196
|
+
const [remote, branch] = positionals;
|
|
1197
|
+
return remote && branch ? pushAction(["push", remote, `HEAD:refs/heads/${branch}`]) : null;
|
|
1198
|
+
};
|
|
1199
|
+
|
|
1200
|
+
/** The workit meaning of one invocation; null for read-only, help or unrelated ones. */
|
|
1201
|
+
export function rawAction(invocation: RawInvocation): RawAction | null {
|
|
1202
|
+
const args = invocation.args;
|
|
1203
|
+
if (invocation.tool !== "git" && args[0] === "api")
|
|
1204
|
+
return apiAction(invocation.tool, args, invocation.stdin);
|
|
1205
|
+
if (asksHelp(invocation)) return null;
|
|
1206
|
+
const [first] = args;
|
|
1207
|
+
if (invocation.tool === "git") {
|
|
1208
|
+
const second = args[1];
|
|
1209
|
+
if (first === "commit")
|
|
1210
|
+
return args.includes("--dry-run")
|
|
1211
|
+
? null
|
|
1212
|
+
: { kind: "commit", amend: args.includes("--amend") };
|
|
1213
|
+
if (first === "push") return pushAction(args);
|
|
1214
|
+
// `git send-pack <remote> <refspec>…` pushes like `git push`.
|
|
1215
|
+
if (first === "send-pack")
|
|
1216
|
+
return pushAction([
|
|
1217
|
+
"push",
|
|
1218
|
+
...args.slice(1).filter((arg) => arg !== "--stdin"),
|
|
1219
|
+
...(args.includes("--stdin") ? (invocation.stdin ?? "").split(/\s+/).filter(Boolean) : []),
|
|
1220
|
+
]);
|
|
1221
|
+
if (first === "subtree" && second === "push") return subtreePush(args);
|
|
1222
|
+
return null;
|
|
1223
|
+
}
|
|
1224
|
+
const forge = invocation.tool;
|
|
1225
|
+
const noun = forge === "gh" ? "pr" : "mr";
|
|
1226
|
+
if (first !== noun && first !== "release") return null;
|
|
1227
|
+
// The verb stands after any options (`gh pr -R o/r merge 1`).
|
|
1228
|
+
const values = VALUE_OPTIONS[forge];
|
|
1229
|
+
let second: string | undefined;
|
|
1230
|
+
let repo: string | undefined;
|
|
1231
|
+
for (let index = 1; index < args.length; index++) {
|
|
1232
|
+
const arg = args[index];
|
|
1233
|
+
if (arg === "-R" || arg === "--repo") repo = args[++index];
|
|
1234
|
+
else if (/^(?:--repo=|-R)/.test(arg)) repo = arg.replace(/^(?:--repo|-R)=?/, "");
|
|
1235
|
+
else if (values.has(arg)) index++;
|
|
1236
|
+
else if (!arg.startsWith("-")) second ??= arg;
|
|
1237
|
+
}
|
|
1238
|
+
const named = repo === undefined ? {} : { repo };
|
|
1239
|
+
if (first === "release")
|
|
1240
|
+
return (forge === "gh" ? ["create", "edit", "delete"] : ["create"]).includes(second ?? "")
|
|
1241
|
+
? { kind: "forge", forge, grant: "release", ...named }
|
|
1242
|
+
: null;
|
|
1243
|
+
if (second === "merge") return { kind: "merge", forge, ...named };
|
|
1244
|
+
if (second === "create") return { kind: "pr-create", forge, ...named };
|
|
1245
|
+
if ((forge === "gh" ? ["edit", "ready"] : ["update"]).includes(second ?? ""))
|
|
1246
|
+
return { kind: "forge", forge, grant: "pr", ...named };
|
|
1247
|
+
if (["view", "checks", "status", "ci", "list"].includes(second ?? ""))
|
|
1248
|
+
return { kind: "pr-read", forge };
|
|
1249
|
+
return null;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// ---------------------------------------------------------------------------
|
|
1253
|
+
// repository facts (filesystem only)
|
|
1254
|
+
|
|
1255
|
+
type Repo = { location: StoreLocation; dir: string; branch: string | null };
|
|
1256
|
+
|
|
1257
|
+
const repoAt = (dir: string): Repo | null => {
|
|
1258
|
+
const location = resolveStore(dir);
|
|
1259
|
+
if (location instanceof Error || !location.shared) return null;
|
|
1260
|
+
return { location, dir, branch: resolveTaskKey(dir, location).branch };
|
|
1261
|
+
};
|
|
1262
|
+
|
|
1263
|
+
/** A repository Workit manages: it has a Workit store, or workspaces.json matches it. */
|
|
1264
|
+
const isWorkitWorkspace = (repo: Repo): boolean => {
|
|
1265
|
+
if (fs.existsSync(repo.location.dir)) return true;
|
|
1266
|
+
try {
|
|
1267
|
+
return resolveRuntimeWorkspaceVcs(repo.location.top ?? repo.dir) !== null;
|
|
1268
|
+
} catch {
|
|
1269
|
+
return false;
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
|
|
1273
|
+
/** Protected by the branch policy, or the workspace's default target. */
|
|
1274
|
+
export const guardedBranch = (dir: string, branch: string): boolean => {
|
|
1275
|
+
try {
|
|
1276
|
+
const policy = resolveBranchPolicyFor(dir);
|
|
1277
|
+
return (
|
|
1278
|
+
policy.protected.has(branch.toLowerCase()) ||
|
|
1279
|
+
policy.defaultTargetBranch.toLowerCase() === branch.toLowerCase()
|
|
1280
|
+
);
|
|
1281
|
+
} catch {
|
|
1282
|
+
return false;
|
|
1283
|
+
}
|
|
1284
|
+
};
|
|
1285
|
+
|
|
1286
|
+
/** HEAD's commit read from the git dir (loose or packed ref), or null. */
|
|
1287
|
+
const headSha = (repo: Repo): string | null => {
|
|
1288
|
+
const git = repo.location.git;
|
|
1289
|
+
if (!git || git.reftable) return null;
|
|
1290
|
+
try {
|
|
1291
|
+
const head = fs.readFileSync(path.join(git.gitDir, "HEAD"), "utf8").trim();
|
|
1292
|
+
if (/^[0-9a-f]{40,64}$/.test(head)) return head;
|
|
1293
|
+
const ref = /^ref: (refs\/.+)$/.exec(head)?.[1];
|
|
1294
|
+
if (!ref) return null;
|
|
1295
|
+
const common = path.dirname(repo.location.dir);
|
|
1296
|
+
try {
|
|
1297
|
+
return fs.readFileSync(path.join(common, ref), "utf8").trim() || null;
|
|
1298
|
+
} catch {}
|
|
1299
|
+
const packed = fs.readFileSync(path.join(common, "packed-refs"), "utf8");
|
|
1300
|
+
return (
|
|
1301
|
+
new RegExp(`^([0-9a-f]{40,64}) ${ref.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "m").exec(
|
|
1302
|
+
packed,
|
|
1303
|
+
)?.[1] ?? null
|
|
1304
|
+
);
|
|
1305
|
+
} catch {
|
|
1306
|
+
return null;
|
|
1307
|
+
}
|
|
1308
|
+
};
|
|
1309
|
+
|
|
1310
|
+
// ---------------------------------------------------------------------------
|
|
1311
|
+
// git configuration (one `git config --list` per directory and command line)
|
|
1312
|
+
|
|
1313
|
+
type GitConfig = Map<string, string[]>;
|
|
1314
|
+
/** Configuration read once per hook call, keyed by directory and replayed options. */
|
|
1315
|
+
type ConfigCache = Map<string, GitConfig>;
|
|
1316
|
+
|
|
1317
|
+
/**
|
|
1318
|
+
* The configuration an invocation runs under in `dir`, its `-c`/
|
|
1319
|
+
* `--config-env`/`--git-dir` options and GIT_CONFIG_* assignments
|
|
1320
|
+
* replayed. Reading config runs nothing. Empty on any failure.
|
|
1321
|
+
*/
|
|
1322
|
+
const gitConfig = (dir: string, invocation: RawInvocation, cache: ConfigCache): GitConfig => {
|
|
1323
|
+
const key = JSON.stringify([dir, invocation.config ?? [], invocation.env ?? {}]);
|
|
1324
|
+
const cached = cache.get(key);
|
|
1325
|
+
if (cached) return cached;
|
|
1326
|
+
const config: GitConfig = new Map();
|
|
1327
|
+
const listed = spawnSync("git", [...(invocation.config ?? []), "config", "-z", "--list"], {
|
|
1328
|
+
cwd: dir,
|
|
1329
|
+
encoding: "utf8",
|
|
1330
|
+
timeout: 3_000,
|
|
1331
|
+
windowsHide: true,
|
|
1332
|
+
env: {
|
|
1333
|
+
...process.env,
|
|
1334
|
+
...invocation.env,
|
|
1335
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
1336
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
1337
|
+
},
|
|
1338
|
+
});
|
|
1339
|
+
for (const entry of (listed.status === 0 ? listed.stdout : "").split("\0")) {
|
|
1340
|
+
if (!entry) continue;
|
|
1341
|
+
const newline = entry.indexOf("\n");
|
|
1342
|
+
const name = newline < 0 ? entry : entry.slice(0, newline);
|
|
1343
|
+
config.set(name, [
|
|
1344
|
+
...(config.get(name) ?? []),
|
|
1345
|
+
newline < 0 ? "true" : entry.slice(newline + 1),
|
|
1346
|
+
]);
|
|
1347
|
+
}
|
|
1348
|
+
cache.set(key, config);
|
|
1349
|
+
return config;
|
|
1350
|
+
};
|
|
1351
|
+
|
|
1352
|
+
const configValue = (config: GitConfig, key: string) => config.get(key)?.at(-1);
|
|
1353
|
+
|
|
1354
|
+
/** git's own commands (`git --list-cmds=main`): an alias never shadows one. */
|
|
1355
|
+
const GIT_COMMANDS = new Set(
|
|
1356
|
+
(
|
|
1357
|
+
"add am annotate apply archive bisect blame branch bugreport bundle cat-file check-attr " +
|
|
1358
|
+
"check-ignore check-mailmap check-ref-format checkout checkout-index cherry cherry-pick " +
|
|
1359
|
+
"citool clean clone column commit commit-graph commit-tree config count-objects credential " +
|
|
1360
|
+
"daemon describe diagnose diff diff-files diff-index diff-tree difftool fast-export " +
|
|
1361
|
+
"fast-import fetch fetch-pack filter-branch fmt-merge-msg for-each-ref for-each-repo " +
|
|
1362
|
+
"format-patch fsck gc get-tar-commit-id grep gui hash-object help hook index-pack init " +
|
|
1363
|
+
"instaweb interpret-trailers lfs log ls-files ls-remote ls-tree mailinfo mailsplit " +
|
|
1364
|
+
"maintenance merge merge-base merge-file merge-index merge-tree mergetool mktag mktree " +
|
|
1365
|
+
"multi-pack-index mv name-rev notes pack-objects pack-refs patch-id prune pull push " +
|
|
1366
|
+
"range-diff read-tree rebase reflog remote repack replace request-pull rerere reset " +
|
|
1367
|
+
"restore rev-list rev-parse revert rm send-email shortlog show show-branch show-ref " +
|
|
1368
|
+
"sparse-checkout stage stash status stripspace submodule subtree switch symbolic-ref tag " +
|
|
1369
|
+
"update-index update-ref var verify-commit verify-pack verify-tag version whatchanged " +
|
|
1370
|
+
"worktree write-tree"
|
|
1371
|
+
).split(" "),
|
|
1372
|
+
);
|
|
1373
|
+
|
|
1374
|
+
/** The commands workit judges: what help.autocorrect may turn a typo into. */
|
|
1375
|
+
const JUDGED_COMMANDS = [
|
|
1376
|
+
"push",
|
|
1377
|
+
"commit",
|
|
1378
|
+
"switch",
|
|
1379
|
+
"checkout",
|
|
1380
|
+
"branch",
|
|
1381
|
+
"worktree",
|
|
1382
|
+
"stash",
|
|
1383
|
+
"update-ref",
|
|
1384
|
+
"symbolic-ref",
|
|
1385
|
+
"fetch",
|
|
1386
|
+
"pull",
|
|
1387
|
+
"subtree",
|
|
1388
|
+
];
|
|
1389
|
+
|
|
1390
|
+
/** Edits between two words, a swap of neighbours counting as one (git's typos). */
|
|
1391
|
+
const editDistance = (a: string, b: string): number => {
|
|
1392
|
+
let before: number[] = [];
|
|
1393
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
1394
|
+
for (let i = 1; i <= a.length; i++) {
|
|
1395
|
+
const row = [i];
|
|
1396
|
+
for (let j = 1; j <= b.length; j++) {
|
|
1397
|
+
row[j] = Math.min(
|
|
1398
|
+
previous[j] + 1,
|
|
1399
|
+
row[j - 1] + 1,
|
|
1400
|
+
previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
1401
|
+
);
|
|
1402
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1])
|
|
1403
|
+
row[j] = Math.min(row[j], before[j - 2] + 1);
|
|
1404
|
+
}
|
|
1405
|
+
[before, previous] = [previous, row];
|
|
1406
|
+
}
|
|
1407
|
+
return previous[b.length];
|
|
1408
|
+
};
|
|
1409
|
+
|
|
1410
|
+
/** With help.autocorrect set to run, the judged command a typo runs as (unique and within 2 edits). */
|
|
1411
|
+
const autocorrected = (name: string, config: GitConfig): string | null => {
|
|
1412
|
+
const setting = (configValue(config, "help.autocorrect") ?? "0").toLowerCase();
|
|
1413
|
+
if (setting !== "immediate" && !/^-?[1-9]\d*$/.test(setting)) return null;
|
|
1414
|
+
let best: string | null = null;
|
|
1415
|
+
let bestDistance = 3;
|
|
1416
|
+
let tie = false;
|
|
1417
|
+
for (const command of JUDGED_COMMANDS) {
|
|
1418
|
+
const distance = editDistance(name, command);
|
|
1419
|
+
if (distance < bestDistance) [best, bestDistance, tie] = [command, distance, false];
|
|
1420
|
+
else if (distance === bestDistance) tie = true;
|
|
1421
|
+
}
|
|
1422
|
+
return tie ? null : best;
|
|
1423
|
+
};
|
|
1424
|
+
|
|
1425
|
+
const shellQuote = (word: string) => `'${word.replaceAll("'", "'\\''")}'`;
|
|
1426
|
+
|
|
1427
|
+
/** A `!` alias's `$1`…`$9`, `$@` and `$*` with the arguments git hands it. */
|
|
1428
|
+
const positional = (script: string, args: readonly string[]): string => {
|
|
1429
|
+
const word = (arg: string) => (/^[\w./@+:=-]+$/.test(arg) ? arg : shellQuote(arg));
|
|
1430
|
+
return script
|
|
1431
|
+
.replace(/\$\{?([1-9])\}?/g, (_, n: string) => word(args[Number(n) - 1] ?? ""))
|
|
1432
|
+
.replace(/\$\{?[@*]\}?/g, args.map(word).join(" "));
|
|
1433
|
+
};
|
|
1434
|
+
|
|
1435
|
+
/**
|
|
1436
|
+
* A git invocation as git runs it once its alias is resolved: aliases from
|
|
1437
|
+
* the repository and user config, `-c alias.x=…` and GIT_CONFIG_* all count,
|
|
1438
|
+
* and expand recursively. A `!` alias is a shell script, read like any
|
|
1439
|
+
* other command line. With help.autocorrect set to run, a near-miss of a
|
|
1440
|
+
* judged command runs as that command. git's own commands spawn nothing.
|
|
1441
|
+
*/
|
|
1442
|
+
export const expandGitAliases = (
|
|
1443
|
+
root: string,
|
|
1444
|
+
invocation: RawInvocation,
|
|
1445
|
+
cache: ConfigCache = new Map(),
|
|
1446
|
+
depth = 0,
|
|
1447
|
+
): RawInvocation[] => {
|
|
1448
|
+
const sub = invocation.args[0];
|
|
1449
|
+
if (
|
|
1450
|
+
invocation.tool !== "git" ||
|
|
1451
|
+
invocation.dir === undefined ||
|
|
1452
|
+
!sub ||
|
|
1453
|
+
sub.startsWith("-") ||
|
|
1454
|
+
GIT_COMMANDS.has(sub) ||
|
|
1455
|
+
depth >= MAX_DEPTH
|
|
1456
|
+
)
|
|
1457
|
+
return [invocation];
|
|
1458
|
+
const dir = path.resolve(root, invocation.dir ?? ".");
|
|
1459
|
+
if (!fs.existsSync(dir)) return [invocation];
|
|
1460
|
+
const config = gitConfig(dir, invocation, cache);
|
|
1461
|
+
const alias = configValue(config, `alias.${sub.toLowerCase()}`);
|
|
1462
|
+
if (alias === undefined) {
|
|
1463
|
+
const corrected = autocorrected(sub, config);
|
|
1464
|
+
return corrected
|
|
1465
|
+
? [{ ...invocation, args: [corrected, ...invocation.args.slice(1)] }]
|
|
1466
|
+
: [invocation];
|
|
1467
|
+
}
|
|
1468
|
+
const rest = invocation.args.slice(1);
|
|
1469
|
+
const expanded = alias.startsWith("!")
|
|
1470
|
+
? // A shell alias inherits the outer -c options and environment.
|
|
1471
|
+
rawInvocations([positional(alias.slice(1), rest), ...rest.map(shellQuote)].join(" ")).map(
|
|
1472
|
+
(inner) => ({
|
|
1473
|
+
...inner,
|
|
1474
|
+
dir: rebase(invocation.dir, inner.dir),
|
|
1475
|
+
...(inner.tool === "git" && (invocation.config || inner.config)
|
|
1476
|
+
? { config: [...(invocation.config ?? []), ...(inner.config ?? [])] }
|
|
1477
|
+
: {}),
|
|
1478
|
+
...(inner.tool === "git" && (invocation.env || inner.env)
|
|
1479
|
+
? { env: { ...invocation.env, ...inner.env } }
|
|
1480
|
+
: {}),
|
|
1481
|
+
}),
|
|
1482
|
+
)
|
|
1483
|
+
: [
|
|
1484
|
+
gitInvocation(
|
|
1485
|
+
[...(segmentsOf(alias)[0]?.words ?? []), ...rest],
|
|
1486
|
+
invocation.dir,
|
|
1487
|
+
invocation.env,
|
|
1488
|
+
invocation.stdin,
|
|
1489
|
+
invocation.config,
|
|
1490
|
+
),
|
|
1491
|
+
];
|
|
1492
|
+
return expanded.flatMap((inner) => expandGitAliases(root, inner, cache, depth + 1));
|
|
1493
|
+
};
|
|
1494
|
+
|
|
1495
|
+
/** A remote URL in a comparable form: no scheme, credentials, port, trailing `/` or `.git`. */
|
|
1496
|
+
const normalizedUrl = (url: string, dir: string): string => {
|
|
1497
|
+
const trimmed = url.trim();
|
|
1498
|
+
if (/^file:\/\//i.test(trimmed))
|
|
1499
|
+
return path
|
|
1500
|
+
.resolve(dir, trimmed.slice(7))
|
|
1501
|
+
.replace(/\/+$/, "")
|
|
1502
|
+
.replace(/\.git$/, "");
|
|
1503
|
+
const scheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(trimmed);
|
|
1504
|
+
const scp = !scheme && /^(?:[^@/\\]+@)?[^/\\:]{2,}:/.test(trimmed);
|
|
1505
|
+
// A local path keeps its case.
|
|
1506
|
+
if (!scheme && !scp)
|
|
1507
|
+
return path
|
|
1508
|
+
.resolve(dir, trimmed)
|
|
1509
|
+
.replace(/\/+$/, "")
|
|
1510
|
+
.replace(/\.git$/, "");
|
|
1511
|
+
const value = scheme
|
|
1512
|
+
? trimmed
|
|
1513
|
+
.slice(scheme[0].length)
|
|
1514
|
+
.replace(/^[^@/]*@/, "")
|
|
1515
|
+
.replace(/^([^/:]+):\d+(?=\/)/, "$1")
|
|
1516
|
+
: trimmed.replace(/^[^@/]*@/, "").replace(":", "/");
|
|
1517
|
+
// A forge ignores case, doubled slashes and `www.` (`git@host:/Owner/Repo`).
|
|
1518
|
+
return value
|
|
1519
|
+
.toLowerCase()
|
|
1520
|
+
.replace(/\/{2,}/g, "/")
|
|
1521
|
+
.replace(/^www\./, "")
|
|
1522
|
+
.replace(/\/+$/, "")
|
|
1523
|
+
.replace(/\.git$/, "");
|
|
1524
|
+
};
|
|
1525
|
+
|
|
1526
|
+
/** `url.<base>.pushInsteadOf`/`insteadOf`: the longest matching prefix is rewritten. */
|
|
1527
|
+
const rewrittenUrl = (url: string, config: GitConfig): string => {
|
|
1528
|
+
for (const kind of ["pushinsteadof", "insteadof"]) {
|
|
1529
|
+
let best: { base: string; prefix: string } | null = null;
|
|
1530
|
+
for (const [key, prefixes] of config) {
|
|
1531
|
+
if (!key.startsWith("url.") || !key.endsWith(`.${kind}`)) continue;
|
|
1532
|
+
const base = key.slice(4, -(kind.length + 1));
|
|
1533
|
+
for (const prefix of prefixes)
|
|
1534
|
+
if (url.startsWith(prefix) && prefix.length > (best?.prefix.length ?? -1))
|
|
1535
|
+
best = { base, prefix };
|
|
1536
|
+
}
|
|
1537
|
+
if (best) return best.base + url.slice(best.prefix.length);
|
|
1538
|
+
}
|
|
1539
|
+
return url;
|
|
1540
|
+
};
|
|
1541
|
+
|
|
1542
|
+
/** Where a remote name or URL pushes, comparable across names and spellings. */
|
|
1543
|
+
const pushUrl = (config: GitConfig, remote: string, dir: string): string => {
|
|
1544
|
+
const configured =
|
|
1545
|
+
configValue(config, `remote.${remote}.pushurl`) ?? configValue(config, `remote.${remote}.url`);
|
|
1546
|
+
return normalizedUrl(rewrittenUrl(configured ?? remote, config), dir);
|
|
1547
|
+
};
|
|
1548
|
+
|
|
1549
|
+
/** The remote workit pushes to, from the same config `pushRemoteName` reads. */
|
|
1550
|
+
const pushRemoteOf = (config: GitConfig, branch: string | null): string | null => {
|
|
1551
|
+
const remotes = new Set<string>();
|
|
1552
|
+
for (const key of config.keys()) {
|
|
1553
|
+
const name = /^remote\.(.+)\.url$/.exec(key)?.[1];
|
|
1554
|
+
if (name) remotes.add(name);
|
|
1555
|
+
}
|
|
1556
|
+
for (const candidate of [
|
|
1557
|
+
branch ? configValue(config, `branch.${branch}.pushremote`) : undefined,
|
|
1558
|
+
configValue(config, "remote.pushdefault"),
|
|
1559
|
+
branch ? configValue(config, `branch.${branch}.remote`) : undefined,
|
|
1560
|
+
])
|
|
1561
|
+
if (candidate && candidate !== "." && remotes.has(candidate)) return candidate;
|
|
1562
|
+
if (remotes.has("origin")) return "origin";
|
|
1563
|
+
return remotes.size === 1 ? [...remotes][0] : null;
|
|
1564
|
+
};
|
|
1565
|
+
|
|
1566
|
+
/**
|
|
1567
|
+
* A push names the remote workit itself pushes to: none (the default), its
|
|
1568
|
+
* name, or another name or URL that pushes to the same place (a second
|
|
1569
|
+
* remote, an insteadOf rewrite, the URL spelled out).
|
|
1570
|
+
*/
|
|
1571
|
+
const toPushRemote = (repo: Repo, remote: string | null, config: GitConfig): boolean => {
|
|
1572
|
+
// None, or one the shell computes (`"$(git remote get-url origin)"`): it may well be it.
|
|
1573
|
+
if (remote === null || /[$`]/.test(remote)) return true;
|
|
1574
|
+
// No known push remote: the named one may well be it.
|
|
1575
|
+
const pushRemote = pushRemoteOf(config, repo.branch);
|
|
1576
|
+
if (pushRemote === null || pushRemote === remote) return true;
|
|
1577
|
+
return pushUrl(config, remote, repo.dir) === pushUrl(config, pushRemote, repo.dir);
|
|
1578
|
+
};
|
|
1579
|
+
|
|
1580
|
+
// ---------------------------------------------------------------------------
|
|
1581
|
+
// pre-tool: refuse or nudge
|
|
1582
|
+
|
|
1583
|
+
const quote = (text: string) => `\`${text}\``;
|
|
1584
|
+
|
|
1585
|
+
const refusal = (action: RawAction, branch: string | null, dir?: string): string => {
|
|
1586
|
+
if (action.kind === "merge")
|
|
1587
|
+
return `workit: raw ${quote(action.via === "api" ? `${action.forge} api` : action.forge === "gh" ? "gh pr merge" : "glab mr merge")}${action.via === "api" ? " merge" : ""} bypasses the merge grant and the verdict gate. Run ${quote("workit pr merge")} instead (it checks both; ${quote("workit help pr")}).`;
|
|
1588
|
+
// Finishing a release (git-flow main/develop, the github-flow trunk, a
|
|
1589
|
+
// custom protected branch): the push has its own granted verb.
|
|
1590
|
+
const plainPush = action.kind === "push" && !action.blindForce && !action.deletes;
|
|
1591
|
+
if (dir && branch && plainPush && releaseBranch(dir, branch))
|
|
1592
|
+
return `workit: raw ${quote("git push")} onto ${branch}, a protected release branch, bypasses workit's protected-branch check. To finish a release or hotfix, push it with ${quote(`workit git push --release ${branch} [--release ${releaseTagPrefixOf(dir)}<version>]`)}: it pushes ${branch} (fast-forward only) and the release tag, verifies the remote and records it, under the ${quote("release")} grant (if refused, ask the user to run ${quote("workit grant set <workspace> release=true")}). Feature work goes through ${quote("workit git push")} and ${quote("workit pr merge")}.`;
|
|
1593
|
+
return `workit: raw ${quote("git push")}${action.kind === "push" && action.blindForce ? " --force" : ""} onto ${branch}, a protected branch or the default target, bypasses workit's protected-branch check. Push your feature branch with ${quote("workit git push")} (${quote("workit git push --force-with-lease")} to rewrite it) and land it with ${quote("workit pr merge")}.`;
|
|
1594
|
+
};
|
|
1595
|
+
|
|
1596
|
+
type PushAction = Extract<RawAction, { kind: "push" }>;
|
|
1597
|
+
|
|
1598
|
+
/** The local branches and tags, and the remote's branches as last fetched: one git call. */
|
|
1599
|
+
type RefFacts = {
|
|
1600
|
+
heads: Set<string>;
|
|
1601
|
+
tags: Set<string>;
|
|
1602
|
+
remoteHeads: Set<string>;
|
|
1603
|
+
};
|
|
1604
|
+
|
|
1605
|
+
const refFacts = (repo: Repo, remote: string): RefFacts => {
|
|
1606
|
+
const facts: RefFacts = {
|
|
1607
|
+
heads: new Set(),
|
|
1608
|
+
tags: new Set(),
|
|
1609
|
+
remoteHeads: new Set(),
|
|
1610
|
+
};
|
|
1611
|
+
const listed = spawnSync(
|
|
1612
|
+
"git",
|
|
1613
|
+
["for-each-ref", "--format=%(refname)", "refs/heads", "refs/tags", `refs/remotes/${remote}`],
|
|
1614
|
+
{ cwd: repo.dir, encoding: "utf8", timeout: 5_000 },
|
|
1615
|
+
);
|
|
1616
|
+
const remotePrefix = `refs/remotes/${remote}/`;
|
|
1617
|
+
for (const ref of (listed.stdout ?? "").split("\n")) {
|
|
1618
|
+
if (ref.startsWith("refs/heads/")) facts.heads.add(ref.slice(11));
|
|
1619
|
+
else if (ref.startsWith("refs/tags/")) facts.tags.add(ref.slice(10));
|
|
1620
|
+
else if (ref.startsWith(remotePrefix)) facts.remoteHeads.add(ref.slice(remotePrefix.length));
|
|
1621
|
+
}
|
|
1622
|
+
return facts;
|
|
1623
|
+
};
|
|
1624
|
+
|
|
1625
|
+
/**
|
|
1626
|
+
* The branches a glob refspec writes (`refs/heads/*:refs/heads/*`): every
|
|
1627
|
+
* local ref its source matches, mapped onto its destination.
|
|
1628
|
+
*/
|
|
1629
|
+
const globBranches = (repo: Repo, glob: { source: string; destination: string }): string[] => {
|
|
1630
|
+
const source = glob.source.startsWith("refs/") ? glob.source : `refs/heads/${glob.source}`;
|
|
1631
|
+
const [sourcePrefix, sourceSuffix = ""] = source.split("*");
|
|
1632
|
+
const [destinationPrefix, destinationSuffix = ""] = glob.destination.split("*");
|
|
1633
|
+
const listed = spawnSync(
|
|
1634
|
+
"git",
|
|
1635
|
+
[
|
|
1636
|
+
"for-each-ref",
|
|
1637
|
+
"--format=%(refname)",
|
|
1638
|
+
sourcePrefix.slice(0, sourcePrefix.lastIndexOf("/") + 1),
|
|
1639
|
+
],
|
|
1640
|
+
{ cwd: repo.dir, encoding: "utf8", timeout: 5_000, windowsHide: true },
|
|
1641
|
+
);
|
|
1642
|
+
const names: string[] = [];
|
|
1643
|
+
for (const ref of (listed.stdout ?? "").split("\n")) {
|
|
1644
|
+
if (!ref.startsWith(sourcePrefix) || !ref.endsWith(sourceSuffix)) continue;
|
|
1645
|
+
const middle = ref.slice(sourcePrefix.length, ref.length - sourceSuffix.length);
|
|
1646
|
+
const { name } = pushDestination(destinationPrefix + middle + destinationSuffix);
|
|
1647
|
+
if (name) names.push(name);
|
|
1648
|
+
}
|
|
1649
|
+
return names;
|
|
1650
|
+
};
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* The branches a push writes on the remote: its named refspecs, a glob's
|
|
1654
|
+
* matches, and, with no refspec, what `remote.<name>.push` or push.default
|
|
1655
|
+
* picks (`upstream` pushes onto the upstream's name, `matching` onto every
|
|
1656
|
+
* branch both sides have, `nothing` onto none). `null` is the current branch.
|
|
1657
|
+
*/
|
|
1658
|
+
const pushedBranches = (
|
|
1659
|
+
action: PushAction,
|
|
1660
|
+
repo: Repo,
|
|
1661
|
+
config: GitConfig,
|
|
1662
|
+
facts: RefFacts,
|
|
1663
|
+
remote: string,
|
|
1664
|
+
): Array<string | null> => {
|
|
1665
|
+
const targets = action.implicit ? [] : [...action.targets];
|
|
1666
|
+
const globs = [...(action.globs ?? [])];
|
|
1667
|
+
let matching = action.matching === true;
|
|
1668
|
+
const configured = action.implicit ? config.get(`remote.${remote}.push`) : undefined;
|
|
1669
|
+
if (configured?.length) {
|
|
1670
|
+
const parsed = pushRefspecs(configured);
|
|
1671
|
+
targets.push(...parsed.targets);
|
|
1672
|
+
globs.push(...parsed.globs);
|
|
1673
|
+
matching ||= parsed.matching;
|
|
1674
|
+
} else if (action.implicit) {
|
|
1675
|
+
const mode = (configValue(config, "push.default") ?? "simple").toLowerCase();
|
|
1676
|
+
const merge = repo.branch ? configValue(config, `branch.${repo.branch}.merge`) : undefined;
|
|
1677
|
+
if (mode === "matching") matching = true;
|
|
1678
|
+
else if ((mode === "upstream" || mode === "tracking") && merge?.startsWith("refs/heads/"))
|
|
1679
|
+
targets.push(merge.slice(11));
|
|
1680
|
+
else if (mode !== "nothing") targets.push(null);
|
|
1681
|
+
}
|
|
1682
|
+
if (action.allBranches) targets.push(...facts.heads);
|
|
1683
|
+
if (matching) for (const head of facts.heads) if (facts.remoteHeads.has(head)) targets.push(head);
|
|
1684
|
+
for (const glob of globs) targets.push(...globBranches(repo, glob));
|
|
1685
|
+
return targets;
|
|
1686
|
+
};
|
|
1687
|
+
|
|
1688
|
+
/**
|
|
1689
|
+
* The literal names a computed one can stand for: `${X:-main}` is `main`
|
|
1690
|
+
* when X is unset, and `${X}main` is `main` when X is empty.
|
|
1691
|
+
*/
|
|
1692
|
+
const literalReadings = (name: string): string[] => {
|
|
1693
|
+
if (!/[$`]/.test(name)) return [name];
|
|
1694
|
+
const withDefaults = name
|
|
1695
|
+
.replace(/\$\{\w+:?[-=]([^}$`]*)\}/g, "$1")
|
|
1696
|
+
.replace(/\$\{\w+\}|\$\w+/g, "");
|
|
1697
|
+
return /[$`]/.test(withDefaults) || !withDefaults ? [] : [withDefaults];
|
|
1698
|
+
};
|
|
1699
|
+
|
|
1700
|
+
/** A protected branch or the default target; `heads/<b>` reaches `refs/heads/<b>` on the remote. */
|
|
1701
|
+
const guardedTarget = (dir: string, target: string): string | null => {
|
|
1702
|
+
for (const branch of literalReadings(target)) {
|
|
1703
|
+
if (guardedBranch(dir, branch)) return branch;
|
|
1704
|
+
const bare = branch.startsWith("heads/") ? branch.slice(6) : null;
|
|
1705
|
+
if (bare && guardedBranch(dir, bare)) return bare;
|
|
1706
|
+
}
|
|
1707
|
+
return null;
|
|
1708
|
+
};
|
|
1709
|
+
|
|
1710
|
+
/** A bare refspec naming a local tag and no local branch pushes the tag. */
|
|
1711
|
+
const isTag = (facts: RefFacts, name: string): boolean =>
|
|
1712
|
+
facts.tags.has(name) && !facts.heads.has(name);
|
|
1713
|
+
|
|
1714
|
+
/**
|
|
1715
|
+
* A raw tag push (`git push origin v1.0`, `refs/tags/v1.0`, `--tags`) skips
|
|
1716
|
+
* the release grant: denied with the workit command. `--follow-tags` only
|
|
1717
|
+
* nudges (it pushes annotated tags the pushed commits already reach).
|
|
1718
|
+
*/
|
|
1719
|
+
const tagRefusal = (action: PushAction, facts: RefFacts, dir: string): string | null => {
|
|
1720
|
+
const tags = [
|
|
1721
|
+
...(action.tags ?? []),
|
|
1722
|
+
...action.targets.filter((name): name is string => name !== null && isTag(facts, name)),
|
|
1723
|
+
];
|
|
1724
|
+
if (!tags.length && !action.allTags) return null;
|
|
1725
|
+
const shape = `${releaseTagPrefixOf(dir)}<version>`;
|
|
1726
|
+
const named = tags.length ? tags.join(" --release ") : shape;
|
|
1727
|
+
return `workit: raw ${quote(`git push${action.allTags ? " --tags" : ""}`)} of ${tags.length ? `tag ${tags.join(", ")}` : "tags"} bypasses the release grant. Push a release tag with ${quote(`workit git push --release ${named}`)} (${shape} tags whose commit the production branch already has; a tag never moves), under the ${quote("release")} grant (if refused, ask the user to run ${quote("workit grant set <workspace> release=true")}).`;
|
|
1728
|
+
};
|
|
1729
|
+
|
|
1730
|
+
/** The remote's branches (`ls-remote --heads`, bounded); null when it cannot be read. */
|
|
1731
|
+
const remoteHeads = (repo: Repo, remote: string): Set<string> | null => {
|
|
1732
|
+
const listed = spawnSync("git", ["ls-remote", "--heads", remote], {
|
|
1733
|
+
cwd: repo.dir,
|
|
1734
|
+
encoding: "utf8",
|
|
1735
|
+
timeout: 3_000,
|
|
1736
|
+
windowsHide: true,
|
|
1737
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
1738
|
+
});
|
|
1739
|
+
if (listed.status !== 0) return null;
|
|
1740
|
+
return new Set(
|
|
1741
|
+
(listed.stdout ?? "")
|
|
1742
|
+
.split("\n")
|
|
1743
|
+
.map((line) => line.split("\t")[1] ?? "")
|
|
1744
|
+
.filter((ref) => ref.startsWith("refs/heads/"))
|
|
1745
|
+
.map((ref) => ref.slice(11)),
|
|
1746
|
+
);
|
|
1747
|
+
};
|
|
1748
|
+
|
|
1749
|
+
/**
|
|
1750
|
+
* A branch name that would be new on the remote and that the workspace policy
|
|
1751
|
+
* does not allow, with the closest allowed name. An existing remote branch
|
|
1752
|
+
* (`dependabot/…`, a legacy name) is never judged: pushing it is not a new
|
|
1753
|
+
* name. `--all`/`--branches` expand to every local branch.
|
|
1754
|
+
*/
|
|
1755
|
+
const offPolicyPush = (action: PushAction, repo: Repo, facts: RefFacts, remote: string) => {
|
|
1756
|
+
const names = action.allBranches
|
|
1757
|
+
? [...facts.heads]
|
|
1758
|
+
: action.targets.map((target) => target ?? repo.branch);
|
|
1759
|
+
// Asked only when a tracking ref is missing, and listed once per push.
|
|
1760
|
+
// Unreachable answers yes: fail open.
|
|
1761
|
+
let listed: Set<string> | null | undefined;
|
|
1762
|
+
const remoteHas = (branch: string) =>
|
|
1763
|
+
(listed === undefined ? (listed = remoteHeads(repo, remote)) : listed)?.has(branch) ?? true;
|
|
1764
|
+
for (const branch of names) {
|
|
1765
|
+
// A name the shell computes is not judged (like `git switch -c $B`).
|
|
1766
|
+
if (!branch || /[$`]/.test(branch)) continue;
|
|
1767
|
+
if (action.deleted?.includes(branch) || isTag(facts, branch)) continue;
|
|
1768
|
+
if (facts.remoteHeads.has(branch)) continue;
|
|
1769
|
+
const check = validateBranchNameFor(repo.dir, branch);
|
|
1770
|
+
if (check.ok || check.rule !== "allowed_pattern" || remoteHas(branch)) continue;
|
|
1771
|
+
return `workit: raw ${quote("git push")} onto ${branch}: ${check.error}`;
|
|
1772
|
+
}
|
|
1773
|
+
return null;
|
|
1774
|
+
};
|
|
1775
|
+
|
|
1776
|
+
const nudgeFor = (action: RawAction, host: HookInput["host"], session: string): string | null => {
|
|
1777
|
+
switch (action.kind) {
|
|
1778
|
+
case "commit": {
|
|
1779
|
+
// Cursor's shell carries no conversation id: name it explicitly.
|
|
1780
|
+
const workit = host === "cursor" && session ? `workit --session ${session}` : "workit";
|
|
1781
|
+
return action.amend
|
|
1782
|
+
? `workit: raw ${quote("git commit --amend")} is recorded for this session; for new commits prefer ${quote(`${workit} git commit -m '<msg>'`)} (adds the Workit-Session trailer and checks branch and commit policy).`
|
|
1783
|
+
: `workit: prefer ${quote(`${workit} git commit -m '<msg>' [--all | -- <paths>]`)}: it adds the Workit-Session trailer and checks branch and commit policy.`;
|
|
1784
|
+
}
|
|
1785
|
+
case "push":
|
|
1786
|
+
return action.blindForce
|
|
1787
|
+
? `workit: ${quote("git push --force")} overwrites without a lease; use ${quote("workit git push --force-with-lease")}.`
|
|
1788
|
+
: `workit: prefer ${quote("workit git push")}: it refuses protected branches and verifies the remote tip.`;
|
|
1789
|
+
case "pr-create":
|
|
1790
|
+
return `workit: prefer ${quote("workit pr create --fill")} (or --title/--body): it records the PR the merge gate checks.`;
|
|
1791
|
+
// Read-only: raw is fine (the contract says so once).
|
|
1792
|
+
case "pr-read":
|
|
1793
|
+
case "forge":
|
|
1794
|
+
case "merge":
|
|
1795
|
+
return null;
|
|
1796
|
+
}
|
|
1797
|
+
};
|
|
1798
|
+
|
|
1799
|
+
/**
|
|
1800
|
+
* Why a raw push to workit's push remote is refused, or null: `--mirror`,
|
|
1801
|
+
* a protected branch or the default target, a tag, or a new off-policy name.
|
|
1802
|
+
*/
|
|
1803
|
+
const pushRefusal = (action: PushAction, repo: Repo, config: GitConfig): string | null => {
|
|
1804
|
+
if (!toPushRemote(repo, action.remote, config)) return null;
|
|
1805
|
+
if (action.mirror)
|
|
1806
|
+
return `workit: raw ${quote("git push --mirror")} rewrites and deletes every remote ref, protected branches and tags included. Push one branch with ${quote("workit git push")}.`;
|
|
1807
|
+
const remote = action.remote ?? pushRemoteOf(config, repo.branch) ?? "origin";
|
|
1808
|
+
const facts = refFacts(repo, remote);
|
|
1809
|
+
const targets = pushedBranches(action, repo, config, facts, remote);
|
|
1810
|
+
const pushed = { ...action, targets };
|
|
1811
|
+
// `--all` names the new off-policy branch before the protected one it also pushes.
|
|
1812
|
+
const offPolicy = action.allBranches ? offPolicyPush(pushed, repo, facts, remote) : null;
|
|
1813
|
+
if (offPolicy) return offPolicy;
|
|
1814
|
+
for (const target of targets) {
|
|
1815
|
+
const guarded = guardedTarget(repo.dir, target ?? repo.branch ?? "");
|
|
1816
|
+
if (guarded) return refusal(action, guarded, repo.dir);
|
|
1817
|
+
}
|
|
1818
|
+
return (
|
|
1819
|
+
tagRefusal(pushed, facts, repo.dir) ??
|
|
1820
|
+
(action.deletes ? null : offPolicyPush(pushed, repo, facts, remote))
|
|
1821
|
+
);
|
|
1822
|
+
};
|
|
1823
|
+
|
|
1824
|
+
type Located = {
|
|
1825
|
+
action: RawAction;
|
|
1826
|
+
repo: Repo;
|
|
1827
|
+
invocation: RawInvocation;
|
|
1828
|
+
workit: boolean;
|
|
1829
|
+
};
|
|
1830
|
+
|
|
1831
|
+
/**
|
|
1832
|
+
* Raw invocations with a meaning, each with its Workit repository. A git
|
|
1833
|
+
* subcommand that is not one of git's own is resolved as an alias first
|
|
1834
|
+
* (inside a Workit workspace only: that costs one config read). `all` keeps
|
|
1835
|
+
* those in any repository too (`workit: false`): grants govern them.
|
|
1836
|
+
*/
|
|
1837
|
+
const actionsIn = (
|
|
1838
|
+
cwd: string,
|
|
1839
|
+
command: string,
|
|
1840
|
+
dialect: ShellDialect,
|
|
1841
|
+
cache: ConfigCache = new Map(),
|
|
1842
|
+
all = false,
|
|
1843
|
+
): Located[] => {
|
|
1844
|
+
const out: Located[] = [];
|
|
1845
|
+
const repos = new Map<string, { repo: Repo; workit: boolean } | null>();
|
|
1846
|
+
const repoFor = (relative: string | null) => {
|
|
1847
|
+
const dir = path.resolve(cwd, relative ?? ".");
|
|
1848
|
+
if (!repos.has(dir)) {
|
|
1849
|
+
const repo = repoAt(dir);
|
|
1850
|
+
const workit = repo !== null && isWorkitWorkspace(repo);
|
|
1851
|
+
repos.set(dir, repo && (workit || all) ? { repo, workit } : null);
|
|
1852
|
+
}
|
|
1853
|
+
return repos.get(dir) ?? null;
|
|
1854
|
+
};
|
|
1855
|
+
for (const raw of rawInvocations(command, dialect)) {
|
|
1856
|
+
if (raw.dir === undefined) continue;
|
|
1857
|
+
const known = raw.tool !== "git" || GIT_COMMANDS.has(raw.args[0] ?? "");
|
|
1858
|
+
// An alias outside a Workit workspace is read only when a grant could stop a push.
|
|
1859
|
+
const found = known ? null : repoFor(raw.dir);
|
|
1860
|
+
if (!known && !(found?.workit || (found && grantsMatter("push")))) continue;
|
|
1861
|
+
for (const invocation of known ? [raw] : expandGitAliases(cwd, raw, cache)) {
|
|
1862
|
+
if (invocation.dir === undefined) continue;
|
|
1863
|
+
const action = rawAction(invocation);
|
|
1864
|
+
// A forge call that names its repository is judged even outside a checkout.
|
|
1865
|
+
const at = !action
|
|
1866
|
+
? null
|
|
1867
|
+
: (repoFor(invocation.dir) ??
|
|
1868
|
+
(all &&
|
|
1869
|
+
invocation.tool !== "git" &&
|
|
1870
|
+
explicitRepo(
|
|
1871
|
+
invocation.tool === "gh",
|
|
1872
|
+
("repo" in action && action.repo) || "",
|
|
1873
|
+
invocation.env ?? {},
|
|
1874
|
+
)
|
|
1875
|
+
? {
|
|
1876
|
+
repo: { dir: path.resolve(cwd, invocation.dir ?? "."), branch: null } as Repo,
|
|
1877
|
+
workit: false,
|
|
1878
|
+
}
|
|
1879
|
+
: null));
|
|
1880
|
+
if (action && at) out.push({ action, invocation, ...at });
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
return out;
|
|
1884
|
+
};
|
|
1885
|
+
|
|
1886
|
+
/** Is `dir` in a repository Workit manages? */
|
|
1887
|
+
export const inWorkitWorkspace = (dir: string): boolean => {
|
|
1888
|
+
const repo = repoAt(dir);
|
|
1889
|
+
return repo !== null && isWorkitWorkspace(repo);
|
|
1890
|
+
};
|
|
1891
|
+
|
|
1892
|
+
/** A raw push, PR/MR create or merge in a Workit workspace (help and dry runs excluded). */
|
|
1893
|
+
export const rawDelivery = (cwd: string, command: string, dialect: ShellDialect): boolean =>
|
|
1894
|
+
mentionsRawTool(command) &&
|
|
1895
|
+
actionsIn(cwd, command, dialect).some(
|
|
1896
|
+
({ action }) => action.kind !== "commit" && action.kind !== "pr-read",
|
|
1897
|
+
);
|
|
1898
|
+
|
|
1899
|
+
/** Cheap pre-filter: no git/gh/glab word, nothing to do. */
|
|
1900
|
+
const mentionsRawTool = (command: string) => /\b(?:git|gh|glab)\b/i.test(command);
|
|
1901
|
+
|
|
1902
|
+
const dialectOf = (input: HookInput): ShellDialect =>
|
|
1903
|
+
(input.event.kind === "shell.pre" || input.event.kind === "shell.post"
|
|
1904
|
+
? input.event.dialect
|
|
1905
|
+
: undefined) ?? "posix";
|
|
1906
|
+
|
|
1907
|
+
/** The nudge kind an action shows: each is shown once per session (agent). */
|
|
1908
|
+
const nudgeKind = (action: RawAction): string =>
|
|
1909
|
+
action.kind === "commit" && action.amend
|
|
1910
|
+
? "commit-amend"
|
|
1911
|
+
: action.kind === "push" && action.blindForce
|
|
1912
|
+
? "push-force"
|
|
1913
|
+
: action.kind;
|
|
1914
|
+
|
|
1915
|
+
/**
|
|
1916
|
+
* The nudges a session (or one of its subagents) has not been shown yet,
|
|
1917
|
+
* recorded as shown in the per-session marker. Without a session or a
|
|
1918
|
+
* marker, every nudge shows: the throttle fails open to repeating.
|
|
1919
|
+
*/
|
|
1920
|
+
const unseenNudges = (input: HookInput, nudges: Map<string, string>, store: string): string[] => {
|
|
1921
|
+
const session = input.session.id;
|
|
1922
|
+
const file = session ? sessionMarkerFile(input, session, store) : null;
|
|
1923
|
+
if (!file) return [...nudges.values()];
|
|
1924
|
+
const marker = readSessionMarker(file);
|
|
1925
|
+
const agent = input.session.agentId ? `${input.session.agentId}:` : "";
|
|
1926
|
+
const fresh = [...nudges.keys()].filter((kind) => !marker.raw.includes(agent + kind));
|
|
1927
|
+
if (fresh.length)
|
|
1928
|
+
writeSessionMarker(file, {
|
|
1929
|
+
...marker,
|
|
1930
|
+
raw: [...marker.raw, ...fresh.map((kind) => agent + kind)],
|
|
1931
|
+
});
|
|
1932
|
+
return fresh.map((kind) => nudges.get(kind) as string);
|
|
1933
|
+
};
|
|
1934
|
+
|
|
1935
|
+
/** `pkill -f` matches the full command lines, its own shell's included. */
|
|
1936
|
+
const pkillSelfMatch = (command: string, dialect: ShellDialect): string | null => {
|
|
1937
|
+
for (const words of commandLines(command, dialect)) {
|
|
1938
|
+
if (words[0]?.split("/").pop() !== "pkill") continue;
|
|
1939
|
+
const args = words.slice(1);
|
|
1940
|
+
if (!args.some((arg) => arg === "--full" || /^-[A-Za-z]*f[A-Za-z]*$/.test(arg))) continue;
|
|
1941
|
+
const pattern = args.find((arg) => !arg.startsWith("-"));
|
|
1942
|
+
if (!pattern || /\[[^\]]\]/.test(pattern)) continue;
|
|
1943
|
+
const bracketed = pattern.replace(/[A-Za-z0-9]/, (char) => `[${char}]`);
|
|
1944
|
+
return `workit: ${quote(`pkill -f ${pattern}`)} also matches the shell running it and kills it (exit 144). List with ${quote(`pgrep -f '${bracketed}'`)} and kill those pids, or ${quote(`pkill -f '${bracketed}'`)}: a bracketed first character no longer matches this command line.`;
|
|
1945
|
+
}
|
|
1946
|
+
return null;
|
|
1947
|
+
};
|
|
1948
|
+
|
|
1949
|
+
/**
|
|
1950
|
+
* The pre-tool decision for a shell command: deny a gate bypass inside a
|
|
1951
|
+
* Workit workspace, else a nudge (as context) for routine raw delivery
|
|
1952
|
+
* commands, else nothing. Each nudge kind is shown once per session, and a
|
|
1953
|
+
* command that runs a workit verb itself gets none (denies still apply).
|
|
1954
|
+
* `nudge: false` asks for the deny alone and spends no nudge. Never throws.
|
|
1955
|
+
*/
|
|
1956
|
+
export function rawGitPre(
|
|
1957
|
+
input: HookInput,
|
|
1958
|
+
command: string,
|
|
1959
|
+
options: { nudge?: boolean } = {},
|
|
1960
|
+
): HookDecision {
|
|
1961
|
+
try {
|
|
1962
|
+
// No textual pre-filter: quoting, escapes and encoded scripts hide the
|
|
1963
|
+
// tool's name from one (`g''it`, `pwsh -EncodedCommand`).
|
|
1964
|
+
const dialect = dialectOf(input);
|
|
1965
|
+
const nudges = new Map<string, string>();
|
|
1966
|
+
let store: string | null = null;
|
|
1967
|
+
const cache: ConfigCache = new Map();
|
|
1968
|
+
// A push git's environment sends to a repository the hook cannot read (#545).
|
|
1969
|
+
const blind = rawInvocations(command, dialect).find(
|
|
1970
|
+
(raw) => raw.redirect && rawAction(raw)?.kind === "push",
|
|
1971
|
+
);
|
|
1972
|
+
if (blind?.redirect) return deny(blindRedirect(blind.redirect));
|
|
1973
|
+
const located = actionsIn(input.cwd, command, dialect, cache, true);
|
|
1974
|
+
// Grants first (#589): a resolved "not granted" denies; only a crash fails open.
|
|
1975
|
+
for (const { action, repo, invocation } of located) {
|
|
1976
|
+
const refused = grantRefusal(action, repo, invocation, cache);
|
|
1977
|
+
if (refused) return deny(refused);
|
|
1978
|
+
}
|
|
1979
|
+
for (const { action, repo, invocation, workit } of located) {
|
|
1980
|
+
if (!workit) continue;
|
|
1981
|
+
store ??= repo.location.dir;
|
|
1982
|
+
if (action.kind === "merge") return deny(refusal(action, null));
|
|
1983
|
+
if (action.kind === "push") {
|
|
1984
|
+
const refused = pushRefusal(action, repo, gitConfig(repo.dir, invocation, cache));
|
|
1985
|
+
if (refused) return deny(refused);
|
|
1986
|
+
}
|
|
1987
|
+
const nudge = nudgeFor(action, input.host, input.session.id);
|
|
1988
|
+
if (nudge && !nudges.has(nudgeKind(action))) nudges.set(nudgeKind(action), nudge);
|
|
1989
|
+
}
|
|
1990
|
+
const pkill = options.nudge === false ? null : pkillSelfMatch(command, dialect);
|
|
1991
|
+
const pkillNote: HookDecision = pkill ? { kind: "context", text: pkill } : NONE;
|
|
1992
|
+
if (options.nudge === false || !store || nudges.size === 0) return pkillNote;
|
|
1993
|
+
if (workitInvocations(command, dialect).length > 0) return pkillNote;
|
|
1994
|
+
const shown = unseenNudges(input, nudges, store);
|
|
1995
|
+
const texts = [...shown, ...(pkill ? [pkill] : [])];
|
|
1996
|
+
return texts.length ? { kind: "context", text: texts.join("\n") } : NONE;
|
|
1997
|
+
} catch (error) {
|
|
1998
|
+
return error instanceof ShellAnalysisError ? analysisDenial(error) : NONE;
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
/**
|
|
2003
|
+
* The forge repository a gh/glab call acts on: `-R` (gh `[HOST/]OWNER/REPO`
|
|
2004
|
+
* or GH_REPO; glab a URL only), else the cwd's as gh resolves it: the
|
|
2005
|
+
* `gh repo set-default` remote (`gh-resolved`), else upstream, else origin.
|
|
2006
|
+
*/
|
|
2007
|
+
const forgeRepo = (gh: boolean, named: string, env: Record<string, string>, config: GitConfig) => {
|
|
2008
|
+
const repo = explicitRepo(gh, named, env);
|
|
2009
|
+
const host = (
|
|
2010
|
+
gh
|
|
2011
|
+
? (env.GH_HOST ?? process.env.GH_HOST ?? "github.com")
|
|
2012
|
+
: (env.GITLAB_HOST ??
|
|
2013
|
+
env.GL_HOST ??
|
|
2014
|
+
process.env.GITLAB_HOST ??
|
|
2015
|
+
process.env.GL_HOST ??
|
|
2016
|
+
"gitlab.com")
|
|
2017
|
+
).replace(/^\w+:\/\/|\/+$/g, "");
|
|
2018
|
+
const parts = repo.split("/");
|
|
2019
|
+
// gh `[HOST/]OWNER/REPO`; glab `[HOST/]GROUP/…/REPO`, a host having a dot.
|
|
2020
|
+
const hosted = gh ? parts.length > 2 : parts.length > 2 && parts[0].includes(".");
|
|
2021
|
+
let url = /:\/\/|@/.test(repo)
|
|
2022
|
+
? repo
|
|
2023
|
+
: parts.length > 1
|
|
2024
|
+
? `https://${hosted ? "" : `${host}/`}${repo}`
|
|
2025
|
+
: "";
|
|
2026
|
+
if (!repo) {
|
|
2027
|
+
const resolved = gh
|
|
2028
|
+
? [...config.keys()].find((key) => /^remote\..+\.gh-resolved$/.test(key))?.slice(7, -12)
|
|
2029
|
+
: undefined;
|
|
2030
|
+
const remote =
|
|
2031
|
+
resolved ?? ["upstream", "origin"].find((name) => config.has(`remote.${name}.url`));
|
|
2032
|
+
url = rewrittenUrl(configValue(config, `remote.${remote}.url`) ?? "", config);
|
|
2033
|
+
}
|
|
2034
|
+
const key = remoteKey(url);
|
|
2035
|
+
return key ? { remotes: [{ name: "forge", url, key }] } : Error("its repository is unknown");
|
|
2036
|
+
};
|
|
2037
|
+
|
|
2038
|
+
/** The repository a gh/glab call names itself (`-R`, an API path, GH_REPO), or "". */
|
|
2039
|
+
const explicitRepo = (gh: boolean, named: string, env: Record<string, string>): string =>
|
|
2040
|
+
named || (gh ? (env.GH_REPO ?? process.env.GH_REPO ?? "") : "");
|
|
2041
|
+
|
|
2042
|
+
/**
|
|
2043
|
+
* Why a raw command the grants govern does not run (#589), or null: a push
|
|
2044
|
+
* of branches (push) or tags (release), a PR/MR create or edit (pr), a
|
|
2045
|
+
* forge release (release), a merge (merge). Local commands are never judged.
|
|
2046
|
+
*/
|
|
2047
|
+
const grantRefusal = (
|
|
2048
|
+
action: RawAction,
|
|
2049
|
+
repo: Repo,
|
|
2050
|
+
invocation: RawInvocation,
|
|
2051
|
+
cache: ConfigCache,
|
|
2052
|
+
): string | null => {
|
|
2053
|
+
const env = invocation.env ?? {};
|
|
2054
|
+
const kinds: GrantKind[] = [];
|
|
2055
|
+
let target: LazyTarget = () =>
|
|
2056
|
+
forgeRepo(
|
|
2057
|
+
action.kind !== "commit" && "forge" in action && action.forge === "gh",
|
|
2058
|
+
("repo" in action && action.repo) || "",
|
|
2059
|
+
env,
|
|
2060
|
+
gitConfig(repo.dir, invocation, cache),
|
|
2061
|
+
);
|
|
2062
|
+
if (action.kind === "push") {
|
|
2063
|
+
target = () => {
|
|
2064
|
+
const config = gitConfig(repo.dir, invocation, cache);
|
|
2065
|
+
const remote = action.remote ?? pushRemoteOf(config, repo.branch) ?? "origin";
|
|
2066
|
+
if (action.transport || /[$`]/.test(remote))
|
|
2067
|
+
return Error("--receive-pack or the shell decides it");
|
|
2068
|
+
const replay = (invocation.config ?? []).filter(
|
|
2069
|
+
(item, at, all) => !item.startsWith("--git-dir") && all[at - 1] !== "--git-dir",
|
|
2070
|
+
);
|
|
2071
|
+
return pushTarget(
|
|
2072
|
+
repo.dir,
|
|
2073
|
+
config.has(`remote.${remote}.url`) ? remote : rewrittenUrl(remote, config),
|
|
2074
|
+
{ config: replay, env },
|
|
2075
|
+
);
|
|
2076
|
+
};
|
|
2077
|
+
if (
|
|
2078
|
+
action.targets.some((name) => !name || !localTag(repo, name)) ||
|
|
2079
|
+
action.allBranches ||
|
|
2080
|
+
action.mirror ||
|
|
2081
|
+
action.globs ||
|
|
2082
|
+
action.matching
|
|
2083
|
+
)
|
|
2084
|
+
kinds.push("push");
|
|
2085
|
+
if (
|
|
2086
|
+
action.tags ||
|
|
2087
|
+
action.allTags ||
|
|
2088
|
+
action.mirror ||
|
|
2089
|
+
action.targets.some((name) => name && localTag(repo, name))
|
|
2090
|
+
)
|
|
2091
|
+
kinds.push("release");
|
|
2092
|
+
} else if (action.kind === "merge") kinds.push("merge");
|
|
2093
|
+
else if (action.kind === "pr-create") kinds.push("pr");
|
|
2094
|
+
else if (action.kind === "forge") kinds.push(action.grant);
|
|
2095
|
+
for (const kind of kinds) {
|
|
2096
|
+
const denied = rawGrantDenial(repo.dir, kind, target);
|
|
2097
|
+
if (denied)
|
|
2098
|
+
return `${action.kind === "merge" ? `${refusal(action, null)} ` : ""}workit: raw ${quote(invocation.tool)} here needs the ${kind} grant: ${denied.error}. ${denied.unblock}; meanwhile ${quote(kind === "push" ? "workit git push" : kind === "release" ? "workit git push --release" : `workit pr ${kind === "pr" ? "create" : kind}`)} says what is allowed.`;
|
|
2099
|
+
}
|
|
2100
|
+
return null;
|
|
2101
|
+
};
|
|
2102
|
+
|
|
2103
|
+
/** A local tag `name` (`git push origin v1.0` pushes the tag): read from the git dir. */
|
|
2104
|
+
const localTag = (repo: Repo, name: string): boolean => {
|
|
2105
|
+
const common = path.dirname(repo.location.dir);
|
|
2106
|
+
try {
|
|
2107
|
+
return (
|
|
2108
|
+
fs.existsSync(path.join(common, "refs/tags", name)) ||
|
|
2109
|
+
fs.readFileSync(path.join(common, "packed-refs"), "utf8").includes(` refs/tags/${name}\n`)
|
|
2110
|
+
);
|
|
2111
|
+
} catch {
|
|
2112
|
+
return false;
|
|
2113
|
+
}
|
|
2114
|
+
};
|
|
2115
|
+
|
|
2116
|
+
const deny = (reason: string): HookDecision => ({
|
|
2117
|
+
kind: "deny",
|
|
2118
|
+
reason,
|
|
2119
|
+
unblock: null,
|
|
2120
|
+
});
|
|
2121
|
+
|
|
2122
|
+
const blindRedirect = (from: string): string =>
|
|
2123
|
+
`workit: ${quote(from)} sends this push to a repository the shell hook cannot read, so it does not run. ${from.startsWith("-") ? `Drop ${quote(from)}` : `Unset ${from}`}, or cd into the repository and push with ${quote("workit git push")}.`;
|
|
2124
|
+
|
|
2125
|
+
/**
|
|
2126
|
+
* A command the shell hook could not analyze is refused, not waved through:
|
|
2127
|
+
* an agent-written command that defeats the parser could hide anything.
|
|
2128
|
+
*/
|
|
2129
|
+
const SPLIT_IT =
|
|
2130
|
+
"Split it into simpler commands (no deeply nested substitutions or huge brace lists) and run them one at a time.";
|
|
2131
|
+
|
|
2132
|
+
export const analysisDenial = (error: ShellAnalysisError): HookDecision => ({
|
|
2133
|
+
kind: "deny",
|
|
2134
|
+
// Hosts show the reason alone: it carries the unblock too.
|
|
2135
|
+
reason: `workit: the shell hook could not analyze this command (${error.message}), so it does not run. ${SPLIT_IT}`,
|
|
2136
|
+
unblock: SPLIT_IT,
|
|
2137
|
+
});
|
|
2138
|
+
|
|
2139
|
+
// ---------------------------------------------------------------------------
|
|
2140
|
+
// recording raw commits (B2)
|
|
2141
|
+
|
|
2142
|
+
/** The marker key for a shell call that has no post-tool event (Cursor). */
|
|
2143
|
+
export const NEXT_COMMAND = "next";
|
|
2144
|
+
/** A marker older than this is a command that never reported back. */
|
|
2145
|
+
const MARKER_TTL_SECONDS = 3_600;
|
|
2146
|
+
|
|
2147
|
+
type Marker = { head: string | null; at: number };
|
|
2148
|
+
|
|
2149
|
+
const markerFile = (repo: Repo, session: string, key: string) =>
|
|
2150
|
+
path.join(
|
|
2151
|
+
repo.location.dir,
|
|
2152
|
+
"hooks",
|
|
2153
|
+
`commit-${createHash("sha256").update(`${session}\0${key}`).digest("hex").slice(0, 32)}.json`,
|
|
2154
|
+
);
|
|
2155
|
+
|
|
2156
|
+
/** The marker key of one shell call: its tool-use id, else the command. */
|
|
2157
|
+
export const callKey = (toolUseId: string | null, command: string): string =>
|
|
2158
|
+
toolUseId ?? `cmd:${createHash("sha256").update(command).digest("hex")}`;
|
|
2159
|
+
|
|
2160
|
+
/**
|
|
2161
|
+
* Before a shell call that runs a raw `git commit` in a Workit workspace:
|
|
2162
|
+
* note HEAD and the time in that repository's store, under the session and
|
|
2163
|
+
* `key`. Never throws.
|
|
2164
|
+
*/
|
|
2165
|
+
export function noteRawCommit(input: HookInput, command: string, key: string): void {
|
|
2166
|
+
try {
|
|
2167
|
+
if (!input.session.id || !/\bcommit\b/.test(command)) return;
|
|
2168
|
+
const at = Math.floor(Date.now() / 1000);
|
|
2169
|
+
for (const { action, repo } of actionsIn(input.cwd, command, dialectOf(input))) {
|
|
2170
|
+
if (action.kind !== "commit") continue;
|
|
2171
|
+
const file = markerFile(repo, input.session.id, key);
|
|
2172
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
2173
|
+
fs.writeFileSync(file, `${JSON.stringify({ head: headSha(repo), at } satisfies Marker)}\n`);
|
|
2174
|
+
}
|
|
2175
|
+
} catch {
|
|
2176
|
+
// Fail open: a missing marker only loses this record.
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
type HeadCommit = {
|
|
2181
|
+
sha: string;
|
|
2182
|
+
committed: number;
|
|
2183
|
+
subject: string;
|
|
2184
|
+
trailer: string | null;
|
|
2185
|
+
};
|
|
2186
|
+
|
|
2187
|
+
const headCommit = (dir: string): HeadCommit | null => {
|
|
2188
|
+
const run = spawnSync(
|
|
2189
|
+
"git",
|
|
2190
|
+
[
|
|
2191
|
+
"log",
|
|
2192
|
+
"-1",
|
|
2193
|
+
"--format=%H%x1f%ct%x1f%s%x1f%(trailers:key=Workit-Session,valueonly,separator=%x2c)",
|
|
2194
|
+
],
|
|
2195
|
+
{
|
|
2196
|
+
cwd: dir,
|
|
2197
|
+
encoding: "utf8",
|
|
2198
|
+
timeout: 5_000,
|
|
2199
|
+
windowsHide: true,
|
|
2200
|
+
env: {
|
|
2201
|
+
...process.env,
|
|
2202
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
2203
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
2204
|
+
LC_ALL: "C",
|
|
2205
|
+
},
|
|
2206
|
+
},
|
|
2207
|
+
);
|
|
2208
|
+
const [sha, committed, subject = "", trailer = ""] = (
|
|
2209
|
+
run.status === 0 ? run.stdout.trim() : ""
|
|
2210
|
+
).split("\x1f");
|
|
2211
|
+
return sha && /^[0-9a-f]{40,64}$/.test(sha)
|
|
2212
|
+
? {
|
|
2213
|
+
sha,
|
|
2214
|
+
committed: Number(committed),
|
|
2215
|
+
subject,
|
|
2216
|
+
trailer: trailer.trim() || null,
|
|
2217
|
+
}
|
|
2218
|
+
: null;
|
|
2219
|
+
};
|
|
2220
|
+
|
|
2221
|
+
/**
|
|
2222
|
+
* Settle one marker: record HEAD for the session when HEAD moved away from
|
|
2223
|
+
* the noted commit, the new commit was made at or after the note, and no
|
|
2224
|
+
* other session claims it (a ledger row or a Workit-Session trailer).
|
|
2225
|
+
*/
|
|
2226
|
+
const settle = (input: HookInput, repo: Repo, file: string): void => {
|
|
2227
|
+
let marker: Marker;
|
|
2228
|
+
try {
|
|
2229
|
+
marker = JSON.parse(fs.readFileSync(file, "utf8")) as Marker;
|
|
2230
|
+
} catch {
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
fs.rmSync(file, { force: true });
|
|
2234
|
+
const session = input.session.id;
|
|
2235
|
+
const now = Date.now() / 1000;
|
|
2236
|
+
if (typeof marker.at !== "number" || now - marker.at > MARKER_TTL_SECONDS) return;
|
|
2237
|
+
if (!session || !repo.branch) return;
|
|
2238
|
+
const head = headCommit(repo.dir);
|
|
2239
|
+
if (!head || head.sha === marker.head || head.committed < marker.at) return;
|
|
2240
|
+
if (head.trailer && head.trailer !== session) return;
|
|
2241
|
+
const ledger = readLedger(repo.dir);
|
|
2242
|
+
if (!ledger.ok) return;
|
|
2243
|
+
const claimed = ledger.value.rows.some(
|
|
2244
|
+
(row) => row.type === "commit.recorded" && (row.sha === head.sha || row.head === head.sha),
|
|
2245
|
+
);
|
|
2246
|
+
if (claimed) return;
|
|
2247
|
+
appendHookObserved(repo.dir, {
|
|
2248
|
+
type: "commit.recorded",
|
|
2249
|
+
actor: { host: input.host, session, agentId: input.session.agentId },
|
|
2250
|
+
branch: repo.branch,
|
|
2251
|
+
head: head.sha,
|
|
2252
|
+
sha: head.sha,
|
|
2253
|
+
session,
|
|
2254
|
+
agentId: input.session.agentId,
|
|
2255
|
+
subject: head.subject.slice(0, 200),
|
|
2256
|
+
files: [],
|
|
2257
|
+
fileCount: null,
|
|
2258
|
+
via: "raw_shell",
|
|
2259
|
+
});
|
|
2260
|
+
};
|
|
2261
|
+
|
|
2262
|
+
/** Post-tool: a raw `git commit` the pre-tool hook noted is recorded for the session. */
|
|
2263
|
+
export function rawGitPost(input: HookInput, command: string, key: string): void {
|
|
2264
|
+
try {
|
|
2265
|
+
if (!input.session.id || !/\bcommit\b/.test(command)) return;
|
|
2266
|
+
for (const { action, repo } of actionsIn(input.cwd, command, dialectOf(input)))
|
|
2267
|
+
if (action.kind === "commit") settle(input, repo, markerFile(repo, input.session.id, key));
|
|
2268
|
+
} catch {
|
|
2269
|
+
// Fail open: a lost record only weakens author detection.
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
/**
|
|
2274
|
+
* A host without a post-tool event: the session's previous raw commit in
|
|
2275
|
+
* the checkout it works in is settled on its next shell command (one stat
|
|
2276
|
+
* when nothing is pending). A commit made in another repository through
|
|
2277
|
+
* `cd`/`-C` is settled only when a later command runs there.
|
|
2278
|
+
*/
|
|
2279
|
+
export function settlePendingCommit(input: HookInput): void {
|
|
2280
|
+
try {
|
|
2281
|
+
if (!input.session.id) return;
|
|
2282
|
+
const repo = repoAt(input.cwd);
|
|
2283
|
+
if (!repo) return;
|
|
2284
|
+
const file = markerFile(repo, input.session.id, NEXT_COMMAND);
|
|
2285
|
+
if (fs.existsSync(file)) settle(input, repo, file);
|
|
2286
|
+
} catch {
|
|
2287
|
+
// Fail open.
|
|
2288
|
+
}
|
|
2289
|
+
}
|