@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,436 @@
|
|
|
1
|
+
// Remote URLs and the workspace entries that claim them (#549). Grants follow
|
|
2
|
+
// the folder; an entry's optional `remotes` makes them follow the remote too:
|
|
3
|
+
// a checkout whose remotes an entry claims never exceeds that entry's grants
|
|
4
|
+
// (autonomy.ts resolveAutonomy). Patterns and URLs compare normalized: the
|
|
5
|
+
// ssh, scp-like and https forms of one host/owner/repo are equal, the host
|
|
6
|
+
// and a forge path compare case-insensitively, a trailing `.git` is ignored,
|
|
7
|
+
// and a pattern ending in `/*` claims everything under that owner or group.
|
|
8
|
+
import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { readSshConfig, resolveSshHost, type SshConfig } from "../git/rev";
|
|
13
|
+
|
|
14
|
+
const FORGE_SCHEMES = new Set(["ssh", "git", "http", "https", "git+ssh", "ssh+git"]);
|
|
15
|
+
const SSH_SCHEMES = new Set(["ssh", "git+ssh", "ssh+git"]);
|
|
16
|
+
const SSH_443: Record<string, string> = {
|
|
17
|
+
"ssh.github.com": "github.com",
|
|
18
|
+
"altssh.gitlab.com": "gitlab.com",
|
|
19
|
+
};
|
|
20
|
+
const WINDOWS_DRIVE = /^[A-Za-z]:[\\/]/u;
|
|
21
|
+
const SCP_LIKE = /^(?:[^@/\\]+@)?([^/\\:]+):(.+)$/u;
|
|
22
|
+
|
|
23
|
+
const accountHome = (): string => {
|
|
24
|
+
try {
|
|
25
|
+
return os.userInfo().homedir;
|
|
26
|
+
} catch {
|
|
27
|
+
return os.homedir();
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The ssh_config aliases resolve through, as deriveForge resolves them: the
|
|
33
|
+
* account's own ~/.ssh/config (ssh reads the passwd home, never $HOME). Tests
|
|
34
|
+
* swap `read` in-process.
|
|
35
|
+
*/
|
|
36
|
+
export const sshSeam: { read: () => SshConfig } = {
|
|
37
|
+
read: () => readSshConfig(accountHome()),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `host/owner/repo`, lowercased, or null when the path is not a plain forge
|
|
42
|
+
* path: a `~user` home path, `.` or `..` segments, or a doubled `.git`.
|
|
43
|
+
*/
|
|
44
|
+
const forgeKey = (rawHost: string, repo: string, ssh: boolean): string | null => {
|
|
45
|
+
let host = rawHost.toLowerCase().replace(/\.$/u, "");
|
|
46
|
+
if (ssh && host) host = resolveSshHost(host, sshSeam.read()).hostname.toLowerCase();
|
|
47
|
+
host = host.replace(/\.$/u, "");
|
|
48
|
+
// deriveForge's SSH-over-443 endpoints are the same forge.
|
|
49
|
+
host = SSH_443[host] ?? host;
|
|
50
|
+
const trimmed = repo.replaceAll("\\", "/").replace(/^\/+/u, "").replace(/\/+$/u, "");
|
|
51
|
+
if (trimmed.startsWith("~")) return null;
|
|
52
|
+
const segments = trimmed.split("/").filter(Boolean);
|
|
53
|
+
if (segments.some((part) => part === "." || part === "..")) return null;
|
|
54
|
+
const last = segments.length - 1;
|
|
55
|
+
if (last >= 0) {
|
|
56
|
+
segments[last] = segments[last].replace(/\.git$/iu, "");
|
|
57
|
+
if (/\.git$/iu.test(segments[last]) || !segments[last]) return null;
|
|
58
|
+
}
|
|
59
|
+
const clean = segments.join("/");
|
|
60
|
+
return host && clean ? `${host}/${clean.toLowerCase()}` : null;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const caseless = process.platform === "win32" || process.platform === "darwin";
|
|
64
|
+
const canonical = (real: string): string => {
|
|
65
|
+
const slashed = real
|
|
66
|
+
.replaceAll("\\", "/")
|
|
67
|
+
.replace(/\/+$/u, "")
|
|
68
|
+
.replace(/\/\.git$/u, "");
|
|
69
|
+
return `file:${caseless ? slashed.toLowerCase() : slashed}`;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const isRepoDir = (dir: string): boolean =>
|
|
73
|
+
["HEAD", "objects", "refs"].every((name) => {
|
|
74
|
+
try {
|
|
75
|
+
lstatSync(`${dir}/${name}`);
|
|
76
|
+
return true;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The real path of the git directory at `at` (a repository, or a `gitdir:`
|
|
84
|
+
* file), keyed as git reads it: a `commondir` file (read relative to the git
|
|
85
|
+
* directory, as git does) names the repository whose refs and objects a push
|
|
86
|
+
* writes (#549 N3), and refs, objects, config or packed-refs that resolve
|
|
87
|
+
* into another repository make it that repository (the #552 rule, N2); null
|
|
88
|
+
* when it is none, or borrows from something that is not one repository.
|
|
89
|
+
*/
|
|
90
|
+
const gitDirAt = (at: string, depth = 0): string | null => {
|
|
91
|
+
let real: string;
|
|
92
|
+
try {
|
|
93
|
+
// libc realpath: symlinks are followed before `..`, as the kernel does.
|
|
94
|
+
real = realpathSync.native(at);
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
if (depth > 8) return null;
|
|
99
|
+
try {
|
|
100
|
+
if (statSync(real).isFile()) {
|
|
101
|
+
const pointer = /^gitdir:\s*(.+?)\s*$/mu.exec(readFileSync(real, "utf8"))?.[1];
|
|
102
|
+
if (!pointer) return null;
|
|
103
|
+
const target = path.isAbsolute(pointer) ? pointer : `${path.dirname(real)}/${pointer}`;
|
|
104
|
+
return gitDirAt(target, depth + 1);
|
|
105
|
+
}
|
|
106
|
+
let common: string | undefined;
|
|
107
|
+
try {
|
|
108
|
+
common = readFileSync(`${real}/commondir`, "utf8").trim();
|
|
109
|
+
} catch {}
|
|
110
|
+
if (common !== undefined) {
|
|
111
|
+
lstatSync(`${real}/HEAD`);
|
|
112
|
+
return gitDirAt(path.isAbsolute(common) ? common : `${real}/${common}`, depth + 1);
|
|
113
|
+
}
|
|
114
|
+
if (!isRepoDir(real)) return null;
|
|
115
|
+
let lender: string | null = null;
|
|
116
|
+
for (const name of ["refs", "objects", "config", "packed-refs"]) {
|
|
117
|
+
const item = `${real}/${name}`;
|
|
118
|
+
try {
|
|
119
|
+
lstatSync(item);
|
|
120
|
+
} catch {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const resolved = realpathSync.native(item);
|
|
124
|
+
const rel = path.relative(real, resolved);
|
|
125
|
+
if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) continue;
|
|
126
|
+
const owner =
|
|
127
|
+
path.basename(resolved) === name ? gitDirAt(path.dirname(resolved), depth + 1) : null;
|
|
128
|
+
if (owner === null || (lender !== null && lender !== owner)) return null;
|
|
129
|
+
lender = owner;
|
|
130
|
+
}
|
|
131
|
+
return lender ?? real;
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The repository git reaches for the local path `where` (git's own DWIM:
|
|
139
|
+
* `X/.git`, `X`, `X.git/.git`, `X.git`), never normalized before symlinks
|
|
140
|
+
* are followed; null when none is a repository.
|
|
141
|
+
*/
|
|
142
|
+
const localRepo = (where: string): string | null => {
|
|
143
|
+
const bare = where.replace(/[\\/]+$/u, "");
|
|
144
|
+
for (const suffix of ["/.git", "", ".git/.git", ".git"]) {
|
|
145
|
+
const found = gitDirAt(`${bare}${suffix}`);
|
|
146
|
+
if (found) return found;
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A local remote's key. A target (`strict`) must resolve to a repository, or
|
|
153
|
+
* it is unknown (null); a pattern that names no repository yet compares as
|
|
154
|
+
* written, so it can be saved before the repository exists.
|
|
155
|
+
*/
|
|
156
|
+
const fileKey = (where: string, strict: boolean): string | null => {
|
|
157
|
+
const repo = localRepo(where);
|
|
158
|
+
if (repo) return canonical(repo);
|
|
159
|
+
return strict ? null : canonical(path.resolve(where).replace(/\.git$/u, ""));
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The normalized key of a remote URL, or null when it cannot be read. `base`
|
|
164
|
+
* marks a target (what an action reaches): a relative local path resolves
|
|
165
|
+
* against it (the checkout's top, as git does) and a local path must name a
|
|
166
|
+
* repository. Without one (a pattern), a bare `host/owner/repo` reads as https.
|
|
167
|
+
*/
|
|
168
|
+
export const remoteKey = (url: string, base?: string | (() => string)): string | null => {
|
|
169
|
+
const value = url.trim();
|
|
170
|
+
const strict = base !== undefined;
|
|
171
|
+
// `<transport>::<address>` runs a remote helper: no URL to compare.
|
|
172
|
+
if (!value || /^[A-Za-z][A-Za-z0-9+.-]*::/u.test(value)) return null;
|
|
173
|
+
const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//u.exec(value)?.[1]?.toLowerCase();
|
|
174
|
+
if (scheme === "file") {
|
|
175
|
+
// Parsed as text: URL would collapse `..` before the filesystem follows
|
|
176
|
+
// a symlink, which is not what git reaches.
|
|
177
|
+
const rest = value.slice(value.indexOf("://") + 3);
|
|
178
|
+
const slash = rest.indexOf("/");
|
|
179
|
+
const host = slash < 0 ? rest : rest.slice(0, slash);
|
|
180
|
+
if (slash < 0 || (host && host.toLowerCase() !== "localhost")) return null;
|
|
181
|
+
let local: string;
|
|
182
|
+
try {
|
|
183
|
+
local = decodeURIComponent(rest.slice(slash));
|
|
184
|
+
} catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
return fileKey(
|
|
188
|
+
process.platform === "win32" ? local.replace(/^\/(?=[A-Za-z]:)/u, "") : local,
|
|
189
|
+
strict,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
if (scheme) {
|
|
193
|
+
if (!FORGE_SCHEMES.has(scheme)) return null;
|
|
194
|
+
// URL would collapse dot segments (also encoded ones): not a plain path.
|
|
195
|
+
const raw = value.slice(value.indexOf("://") + 3);
|
|
196
|
+
if (/(?:^|\/)\.{1,2}(?:\/|$)|%2e/iu.test(raw)) return null;
|
|
197
|
+
try {
|
|
198
|
+
const parsed = new URL(value);
|
|
199
|
+
return forgeKey(
|
|
200
|
+
parsed.hostname,
|
|
201
|
+
decodeURIComponent(parsed.pathname),
|
|
202
|
+
SSH_SCHEMES.has(scheme),
|
|
203
|
+
);
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// Git for Windows reads any `X:` start as a drive (`C:\r.git`, `C:r.git`), never scp.
|
|
209
|
+
if (
|
|
210
|
+
path.isAbsolute(value) ||
|
|
211
|
+
WINDOWS_DRIVE.test(value) ||
|
|
212
|
+
(process.platform === "win32" && /^[A-Za-z]:/u.test(value))
|
|
213
|
+
)
|
|
214
|
+
return fileKey(value, strict);
|
|
215
|
+
const scp = SCP_LIKE.exec(value);
|
|
216
|
+
if (scp) return forgeKey(scp[1], scp[2], true);
|
|
217
|
+
// Joined as text: `..` after a symlink is resolved by the filesystem.
|
|
218
|
+
if (strict) return fileKey(`${typeof base === "function" ? base() : base}/${value}`, true);
|
|
219
|
+
const slash = value.indexOf("/");
|
|
220
|
+
return slash > 0 ? forgeKey(value.slice(0, slash), value.slice(slash), false) : null;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/** A `remotes` pattern: its key and whether it claims a whole owner (`/*`). */
|
|
224
|
+
export const remotePattern = (pattern: string): { key: string; owner: boolean } | null => {
|
|
225
|
+
const trimmed = pattern.trim();
|
|
226
|
+
const owner = trimmed.endsWith("/*");
|
|
227
|
+
const key = remoteKey(owner ? trimmed.slice(0, -2) : trimmed);
|
|
228
|
+
return key ? { key, owner } : null;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/** Whether `pattern` claims the remote whose key is `key`. */
|
|
232
|
+
export const remoteMatches = (pattern: string, key: string): boolean => {
|
|
233
|
+
const parsed = remotePattern(pattern);
|
|
234
|
+
if (!parsed) return false;
|
|
235
|
+
return parsed.owner ? key.startsWith(`${parsed.key}/`) : key === parsed.key;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export type CheckoutRemote = { name: string; url: string; key: string };
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Every fetch and push URL of the checkout at `cwd`, as git resolves them
|
|
242
|
+
* (`insteadOf` and `pushurl` applied); [] outside a repository. An Error when
|
|
243
|
+
* git did not answer or a URL cannot be read: callers fail closed.
|
|
244
|
+
*/
|
|
245
|
+
export const checkoutRemotes = (cwd: string): CheckoutRemote[] | Error => {
|
|
246
|
+
const env = { ...process.env };
|
|
247
|
+
for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE"])
|
|
248
|
+
delete env[name];
|
|
249
|
+
const git = (args: string[]) =>
|
|
250
|
+
spawnSync("git", args, {
|
|
251
|
+
cwd,
|
|
252
|
+
env,
|
|
253
|
+
encoding: "utf8",
|
|
254
|
+
timeout: 5000,
|
|
255
|
+
windowsHide: true,
|
|
256
|
+
});
|
|
257
|
+
const listed = git(["remote", "-v"]);
|
|
258
|
+
if (listed.status !== 0) {
|
|
259
|
+
if (/not a git repository/iu.test(listed.stderr ?? "")) return [];
|
|
260
|
+
return Error(`git could not list the remotes of ${cwd}`);
|
|
261
|
+
}
|
|
262
|
+
let top: string | undefined;
|
|
263
|
+
const base = (): string => {
|
|
264
|
+
if (top === undefined) {
|
|
265
|
+
const run = git(["rev-parse", "--show-toplevel"]);
|
|
266
|
+
top = run.status === 0 && run.stdout.trim() ? run.stdout.trim() : cwd;
|
|
267
|
+
}
|
|
268
|
+
return top;
|
|
269
|
+
};
|
|
270
|
+
const out: CheckoutRemote[] = [];
|
|
271
|
+
for (const line of listed.stdout.split(/\r?\n/u)) {
|
|
272
|
+
const match = /^([^\t]+)\t(.*) \((?:fetch|push)\)$/u.exec(line);
|
|
273
|
+
if (!match) continue;
|
|
274
|
+
const [, name, url] = match;
|
|
275
|
+
const key = remoteKey(url, base);
|
|
276
|
+
if (key === null) return Error(`remote ${name} of ${cwd} has a URL workit cannot read: ${url}`);
|
|
277
|
+
if (!out.some((item) => item.key === key)) out.push({ name, url, key });
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* What a grant-gated action targets (#549 forks): the push URLs of the remote
|
|
284
|
+
* a push goes to, or the forge repository a PR, merge or rerun acts on. Only
|
|
285
|
+
* these are matched against `remotes`; an Error (unresolvable target) grants
|
|
286
|
+
* nothing. Without a target, every remote of the checkout counts.
|
|
287
|
+
*/
|
|
288
|
+
export type GrantTarget = { remotes: CheckoutRemote[] } | Error;
|
|
289
|
+
/** A target, or how to resolve it: resolved only when some workspace claims remotes. */
|
|
290
|
+
export type LazyTarget = GrantTarget | (() => GrantTarget);
|
|
291
|
+
|
|
292
|
+
const TRANSPORT_ENV = ["GIT_SSH", "GIT_SSH_COMMAND", "GIT_PROXY_COMMAND"];
|
|
293
|
+
|
|
294
|
+
/** Whose home counts as the user's own git config; tests swap `home`. */
|
|
295
|
+
export const userGitSeam = { home: accountHome };
|
|
296
|
+
|
|
297
|
+
const realOrSelf = (file: string): string => {
|
|
298
|
+
try {
|
|
299
|
+
return realpathSync.native(file);
|
|
300
|
+
} catch {
|
|
301
|
+
return path.resolve(file);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The user's own config: the account home's ~/.gitconfig or
|
|
307
|
+
* ~/.config/git/config (never one $HOME, XDG_CONFIG_HOME or GIT_CONFIG_GLOBAL
|
|
308
|
+
* points elsewhere: any shell sets those), or the system file while
|
|
309
|
+
* GIT_CONFIG_SYSTEM does not redirect it.
|
|
310
|
+
*/
|
|
311
|
+
const userLevel = (scope: string, origin: string): boolean => {
|
|
312
|
+
if (scope === "system") return process.env.GIT_CONFIG_SYSTEM === undefined;
|
|
313
|
+
if (scope !== "global" || !origin.startsWith("file:")) return false;
|
|
314
|
+
const file = realOrSelf(origin.slice("file:".length));
|
|
315
|
+
const home = userGitSeam.home();
|
|
316
|
+
return [path.join(home, ".gitconfig"), path.join(home, ".config", "git", "config")]
|
|
317
|
+
.map(realOrSelf)
|
|
318
|
+
.includes(file);
|
|
319
|
+
};
|
|
320
|
+
const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* What makes a push reach something other than its URL says: the remote's
|
|
324
|
+
* `receivepack` or `vcs` (any source), `core.gitProxy`, `core.sshCommand`
|
|
325
|
+
* outside the user's global or system config (repo, worktree, include,
|
|
326
|
+
* `-c`, GIT_CONFIG_*), or GIT_SSH, GIT_SSH_COMMAND or GIT_PROXY_COMMAND in
|
|
327
|
+
* the environment. A global `core.sshCommand` is the user's own setup: an
|
|
328
|
+
* agent writing ~/.gitconfig is outside its worktree. Null when none is set.
|
|
329
|
+
*/
|
|
330
|
+
const transportOverride = (
|
|
331
|
+
git: (args: string[]) => ReturnType<typeof spawnSync>,
|
|
332
|
+
remote: string,
|
|
333
|
+
vars: NodeJS.ProcessEnv,
|
|
334
|
+
): string | null => {
|
|
335
|
+
const env = TRANSPORT_ENV.find((name) => vars[name]);
|
|
336
|
+
if (env) return env;
|
|
337
|
+
// GIT_EXEC_PATH can swap the git-receive-pack a local push runs. git sets it
|
|
338
|
+
// for its own hooks, so only a value other than git's own exec path counts.
|
|
339
|
+
const execPath = vars.GIT_EXEC_PATH;
|
|
340
|
+
if (execPath) {
|
|
341
|
+
const { GIT_EXEC_PATH: _set, ...rest } = vars;
|
|
342
|
+
const own = spawnSync("git", ["--exec-path"], {
|
|
343
|
+
env: rest,
|
|
344
|
+
encoding: "utf8",
|
|
345
|
+
});
|
|
346
|
+
if (own.status !== 0 || realOrSelf(own.stdout.trim()) !== realOrSelf(execPath))
|
|
347
|
+
return "GIT_EXEC_PATH";
|
|
348
|
+
}
|
|
349
|
+
// The subsection keeps its case in git's canonical key.
|
|
350
|
+
const name = escapeRegex(remote);
|
|
351
|
+
const run = git([
|
|
352
|
+
"config",
|
|
353
|
+
"--show-scope",
|
|
354
|
+
"--show-origin",
|
|
355
|
+
"--get-regexp",
|
|
356
|
+
`^(remote\\.${name}\\.(receivepack|vcs)|core\\.(sshcommand|gitproxy))$`,
|
|
357
|
+
]);
|
|
358
|
+
// 1: none set. Anything else but success: unknown.
|
|
359
|
+
if (run.status === 1) return null;
|
|
360
|
+
if (run.status !== 0) return "a git config that cannot be read";
|
|
361
|
+
for (const line of String(run.stdout).split(/\r?\n/u)) {
|
|
362
|
+
const match = /^(\S+)\t(.*?)\t(\S+)(?: |$)/u.exec(line);
|
|
363
|
+
if (!match) continue;
|
|
364
|
+
const [, scope, origin, key] = match;
|
|
365
|
+
if (key === "core.sshcommand" && userLevel(scope, origin)) continue;
|
|
366
|
+
return `${key} (${scope} ${origin})`;
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Every push URL of `remote` (`pushurl` and `pushInsteadOf` applied), or the
|
|
373
|
+
* URL a push names in its place, already rewritten. `replay` carries a raw command's own
|
|
374
|
+
* `-c` options and environment (the shell hook), so they count too.
|
|
375
|
+
*/
|
|
376
|
+
export const pushTarget = (
|
|
377
|
+
cwd: string,
|
|
378
|
+
remote: string,
|
|
379
|
+
replay: { config?: string[]; env?: Record<string, string> } = {},
|
|
380
|
+
): GrantTarget => {
|
|
381
|
+
const env: NodeJS.ProcessEnv = { ...process.env, ...replay.env };
|
|
382
|
+
for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE"])
|
|
383
|
+
delete env[name];
|
|
384
|
+
if (!remote || remote.startsWith("-"))
|
|
385
|
+
return Error(`push remote ${JSON.stringify(remote)} cannot be resolved`);
|
|
386
|
+
const git = (args: string[]) =>
|
|
387
|
+
spawnSync("git", [...(replay.config ?? []), ...args], {
|
|
388
|
+
cwd,
|
|
389
|
+
env,
|
|
390
|
+
encoding: "utf8",
|
|
391
|
+
timeout: 5000,
|
|
392
|
+
windowsHide: true,
|
|
393
|
+
});
|
|
394
|
+
const override = transportOverride(git, remote, env);
|
|
395
|
+
if (override)
|
|
396
|
+
return Error(
|
|
397
|
+
`${override} decides what the push to ${remote} reaches, not its URL, so its target is unknown`,
|
|
398
|
+
);
|
|
399
|
+
const run = git(["remote", "get-url", "--push", "--all", remote]);
|
|
400
|
+
// No remote by that name: git pushes to it as a URL (the caller applies insteadOf).
|
|
401
|
+
const urls =
|
|
402
|
+
run.status === 0
|
|
403
|
+
? run.stdout.split(/\r?\n/u).map((url) => url.trim())
|
|
404
|
+
: run.status === 2
|
|
405
|
+
? [remote]
|
|
406
|
+
: [];
|
|
407
|
+
if (!urls.some(Boolean))
|
|
408
|
+
return Error(`git could not resolve the push URLs of remote ${remote} in ${cwd}`);
|
|
409
|
+
let top: string | undefined;
|
|
410
|
+
const base = (): string => {
|
|
411
|
+
if (top === undefined) {
|
|
412
|
+
const at = git(["rev-parse", "--show-toplevel"]);
|
|
413
|
+
top = at.status === 0 && at.stdout.trim() ? at.stdout.trim() : cwd;
|
|
414
|
+
}
|
|
415
|
+
return top;
|
|
416
|
+
};
|
|
417
|
+
const remotes: CheckoutRemote[] = [];
|
|
418
|
+
for (const url of urls.filter(Boolean)) {
|
|
419
|
+
const key = remoteKey(url, base);
|
|
420
|
+
if (key === null)
|
|
421
|
+
return Error(
|
|
422
|
+
`remote ${remote} has a push URL that names no repository workit can read: ${url}`,
|
|
423
|
+
);
|
|
424
|
+
remotes.push({ name: remote, url, key });
|
|
425
|
+
}
|
|
426
|
+
return { remotes };
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
/** The forge repository a PR, merge or CI rerun acts on, as its https URL. */
|
|
430
|
+
export const forgeTarget = (host: string, repo: string): GrantTarget => {
|
|
431
|
+
const url = `https://${host}/${repo}`;
|
|
432
|
+
const key = host && repo ? remoteKey(url) : null;
|
|
433
|
+
return key === null
|
|
434
|
+
? Error(`the forge repository ${JSON.stringify(repo)} on ${host} cannot be resolved`)
|
|
435
|
+
: { remotes: [{ name: "forge", url, key }] };
|
|
436
|
+
};
|