arcane-cli 0.22.13 → 0.23.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/dist/assets/.claude/commands/spell-sync-pull-request.md +13 -0
- package/dist/assets/.github/prompts/spell-create-pull-request.prompt.md +2 -1
- package/dist/assets/.github/prompts/spell-ship.prompt.md +1 -1
- package/dist/assets/.github/prompts/spell-sync-pull-request.prompt.md +160 -0
- package/dist/index.js +221 -6
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Use PROACTIVELY whenever an open PR has fallen behind its target branch and needs a safe rebase/sync, or when spell-create-pull-request/spell-ship stop on a conflict they route here.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Sync Pull Request
|
|
6
|
+
|
|
7
|
+
Invoke the Arcane `spell-sync-pull-request` spell workflow.
|
|
8
|
+
|
|
9
|
+
See the full prompt at `.github/prompts/spell-sync-pull-request.prompt.md` for the complete workflow definition.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
@.github/prompts/spell-sync-pull-request.prompt.md
|
|
@@ -27,6 +27,7 @@ Related spells:
|
|
|
27
27
|
- `spell-commit-work` — land your work as Conventional-Commits commits before opening the PR.
|
|
28
28
|
- `spell-ship` — the broader release flow that records the PR URL after this spell creates it.
|
|
29
29
|
- `spell-address-review` — resolve reviewer feedback once the PR is open.
|
|
30
|
+
- `spell-sync-pull-request` — the recovery path when Step 0.6's rebase guard hits a conflict.
|
|
30
31
|
|
|
31
32
|
## Arguments
|
|
32
33
|
|
|
@@ -60,7 +61,7 @@ Before enabling auto-complete/auto-merge or completing an existing PR, resolve `
|
|
|
60
61
|
3. **Ensure the branch is on origin (upstream):** if `git rev-parse --abbrev-ref --symbolic-full-name @{u}` fails, the branch was never pushed — run `git push -u origin <branch>`. The provider CLIs can only open a PR for a branch that exists on the remote.
|
|
61
62
|
4. **STOP** if the branch has no commits ahead of the target: `git log origin/<target>..HEAD --oneline` is empty → nothing to PR.
|
|
62
63
|
5. Check for an existing **open** PR for this branch (provider-specific, Step 2). If one exists, print its URL and stop — never create duplicates.
|
|
63
|
-
6. **Mandatory rebase on target (governance guard):** `git fetch origin` then `git rebase origin/<target>`. On clean rebase, `git push --force-with-lease` (only if the branch existed remotely before the rebase). On conflicts: **STOP**, list conflicting files, ask the user to resolve; never push a branch that will produce a merge conflict on the target (see git-conventions "🛑 Agent-mandatory pre-PR guard"). This step is not optional and is not skippable by calling a different PR-creation tool.
|
|
64
|
+
6. **Mandatory rebase on target (governance guard):** `git fetch origin` then `git rebase origin/<target>`. On clean rebase, `git push --force-with-lease` (only if the branch existed remotely before the rebase). On conflicts: **STOP**, list conflicting files, ask the user to resolve **or run `spell-sync-pull-request`** — it draws the same mechanical-vs-ambiguous conflict line this guard doesn't attempt to, with a recoverable ref before it touches anything; never push a branch that will produce a merge conflict on the target (see git-conventions "🛑 Agent-mandatory pre-PR guard"). This step is not optional and is not skippable by calling a different PR-creation tool.
|
|
64
65
|
7. **Auto-suggest `--docs-only`** (only if not already passed): if every changed file vs target is documentation/session files (`*.md`, `TODO.md`, `DECISIONS.md`, `journal/**`, `IDEAS.md`, `FEEDBACK.md`), print a one-line hint suggesting `--docs-only` and proceed with the standard path.
|
|
65
66
|
|
|
66
67
|
## Step 1 — Gather context
|
|
@@ -35,7 +35,7 @@ Workflow:
|
|
|
35
35
|
2. **Sync with the target branch** — actually perform the sync, do not just assert it:
|
|
36
36
|
- Run `git fetch origin` to get the latest refs.
|
|
37
37
|
- Merge the target branch into the current branch (e.g. `git merge origin/<target>`).
|
|
38
|
-
- **On merge conflicts:** STOP and ask the user to resolve them. Do not attempt to auto-resolve.
|
|
38
|
+
- **On merge conflicts:** STOP and ask the user to resolve them, or run `spell-sync-pull-request` — it handles exactly this case, with a recoverable ref and an explicit mechanical-vs-ambiguous conflict boundary. Do not attempt to auto-resolve here.
|
|
39
39
|
- After a clean merge (or once the user has resolved conflicts), re-run the full test suite and confirm it passes.
|
|
40
40
|
- Push the synced branch (e.g. `git push`).
|
|
41
41
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Spell — Sync Pull Request
|
|
3
|
+
description: Safely sync an open PR's branch with its target when it has fallen behind — recoverable pre-rebase ref, mechanical-vs-ambiguous conflict handling, force-with-lease push, provider-verified landing. Use when a PR needs rebasing onto a moved target, or was routed here from spell-create-pull-request/spell-ship's own conflict-stop points.
|
|
4
|
+
argument-hint: '[PR number or branch name]'
|
|
5
|
+
agent: agent
|
|
6
|
+
last_updated: 2026-08-31
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Executive Summary
|
|
10
|
+
|
|
11
|
+
- This spell safely syncs an open PR's branch with its target branch once the target has moved ahead.
|
|
12
|
+
- It is the recovery path `spell-create-pull-request` and `spell-ship` route to when they detect a
|
|
13
|
+
conflict they deliberately don't try to resolve themselves.
|
|
14
|
+
- It draws a hard line between conflicts safe to resolve mechanically (identical changes on both sides)
|
|
15
|
+
and conflicts that need a human — it never guesses on the ambiguous side of that line.
|
|
16
|
+
- Every history-rewriting step is preceded by a recoverable ref, and every push uses
|
|
17
|
+
`--force-with-lease`, never bare `--force`.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
Sync the specified PR's branch (or the current branch's own open PR) with its target branch.
|
|
22
|
+
|
|
23
|
+
Context to consult:
|
|
24
|
+
|
|
25
|
+
- `.arcane/governance/git-conventions.md` — the declared merge-strategy ladder (Merge no-fast-forward or
|
|
26
|
+
Rebase-and-fast-forward sanctioned; Squash and Semi-linear merge never sanctioned) and branch policy.
|
|
27
|
+
- `.arcane/governance/testing-standards.md` — coverage thresholds for the post-replay gate re-run.
|
|
28
|
+
|
|
29
|
+
Related spells:
|
|
30
|
+
|
|
31
|
+
- `spell-create-pull-request` — routes here from its Step 0.6 conflict-stop; reuses its Step 2
|
|
32
|
+
provider-detection table rather than re-deriving it (D8).
|
|
33
|
+
- `spell-ship` — routes here from its Step 2 conflict-stop.
|
|
34
|
+
|
|
35
|
+
## Step 0 — Guard checks
|
|
36
|
+
|
|
37
|
+
1. **Require a clean worktree.** Run `git status --porcelain`. Any output — staged, unstaged, or
|
|
38
|
+
untracked — **STOPS** this spell before it touches anything. Sync is not the place to also carry
|
|
39
|
+
uncommitted work through a rebase.
|
|
40
|
+
2. **Resolve the active PR, source branch, base branch, and expected head SHA.** If a PR number or
|
|
41
|
+
branch name was given as an argument, resolve from that; otherwise resolve from
|
|
42
|
+
`git branch --show-current` and the open PR for it (reuse `spell-create-pull-request`'s Step 2
|
|
43
|
+
provider-detection and open-PR-lookup calls — same commands, not restated here). Record the
|
|
44
|
+
branch's current HEAD SHA now, before anything else runs — this is what "expected head SHA" means
|
|
45
|
+
for Step 5's post-push verification once rebase/replay changes it to a new value.
|
|
46
|
+
3. **STOP** if no open PR is found for the resolved branch — this spell syncs an *existing* PR; if none
|
|
47
|
+
exists yet, direct the user to `spell-create-pull-request` first.
|
|
48
|
+
|
|
49
|
+
## Step 1 — Create the recoverable ref
|
|
50
|
+
|
|
51
|
+
Before any history-rewriting operation, tag the current HEAD so this sync is never a one-way door:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
git tag "sync-backup/<branch>/$(date +%Y%m%d-%H%M%S)" HEAD
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Report the tag name in the final output (Step 6). **Never delete this tag automatically** — leave it for
|
|
58
|
+
the human to remove once they've confirmed the sync landed correctly; this spell only ever adds it.
|
|
59
|
+
|
|
60
|
+
## Step 2 — Fetch and apply the declared merge policy
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
git fetch origin
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Resolve the repository's actual merge-strategy ladder from `git-conventions.md` rather than assuming:
|
|
67
|
+
|
|
68
|
+
- **Rebase-and-fast-forward** (the common case for a PR that's simply fallen behind): `git rebase origin/<target>`.
|
|
69
|
+
- **Merge no-fast-forward** (when the repo's declared policy or the PR's own history indicates merge
|
|
70
|
+
commits are the norm here): `git merge origin/<target>`.
|
|
71
|
+
- **Never** produce a squash or semi-linear result as part of this sync — those are never sanctioned,
|
|
72
|
+
regardless of which of the two above applies.
|
|
73
|
+
|
|
74
|
+
## Step 3 — Classify any conflict: mechanical vs. ambiguous
|
|
75
|
+
|
|
76
|
+
If Step 2 completes with no conflicts, skip straight to Step 4 — **this is the common case and it must
|
|
77
|
+
not be treated as a special case of conflict handling.**
|
|
78
|
+
|
|
79
|
+
If Step 2 reports conflicts, read every conflicted file's conflict markers before doing anything else,
|
|
80
|
+
then classify **each conflicted hunk independently** — a single file can contain both kinds:
|
|
81
|
+
|
|
82
|
+
- **Mechanically resolvable** — both sides made the identical change (e.g. the same formatting fix, the
|
|
83
|
+
same import added the same way), or one side's change is a strict superset of the other's with no
|
|
84
|
+
semantic divergence. Resolve by keeping the superset/identical content, verify no line from either
|
|
85
|
+
side's *unique* content was dropped, stage the file, and continue the rebase/merge.
|
|
86
|
+
- **Ambiguous** — the same lines changed with different intent, or you cannot establish with confidence
|
|
87
|
+
that one side's content is a strict superset of the other's. **STOP.** Do not guess, do not pick a
|
|
88
|
+
side, do not attempt a "reasonable-looking" merge of the two. Abort the in-progress rebase/merge
|
|
89
|
+
(`git rebase --abort` / `git merge --abort`) so the branch is left exactly as it was, then report:
|
|
90
|
+
- The exact conflicting file(s) and hunk(s).
|
|
91
|
+
- Both sides' conflicting content, verbatim.
|
|
92
|
+
- That the recoverable ref from Step 1 still points at the pre-sync state.
|
|
93
|
+
- A direct instruction to resolve manually and re-run this spell, or to resolve and push directly.
|
|
94
|
+
|
|
95
|
+
Never proceed past an ambiguous hunk on the theory that "the rest of the file is fine" — abort the whole
|
|
96
|
+
operation and hand off, per the line above.
|
|
97
|
+
|
|
98
|
+
## Step 4 — Re-run project gates before pushing
|
|
99
|
+
|
|
100
|
+
A clean replay is not the same as a working tree. Before pushing:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
npm run typecheck && npm run lint && npm run test
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
If this repository has `check:version-bump` and the sync touched any distributable path, run it too. **A
|
|
107
|
+
gate failure here is the same class of stop as an ambiguous conflict** — do not push a tree that fails
|
|
108
|
+
its own gates just because the git-level replay succeeded. Report which gate failed and stop; the
|
|
109
|
+
recoverable ref from Step 1 is still available.
|
|
110
|
+
|
|
111
|
+
## Step 5 — Push and verify
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
git push --force-with-lease
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
- **On success:** proceed to provider verification below.
|
|
118
|
+
- **On lease rejection** (the remote moved again since this sync's own Step 2 fetch — someone else
|
|
119
|
+
pushed to the branch in the meantime): **do not retry with `--force`, and do not retry
|
|
120
|
+
`--force-with-lease` blindly.** Re-fetch and re-evaluate from Step 2 as if starting over — the branch
|
|
121
|
+
state this sync was built against no longer exists. This is a genuinely new sync, not a retry of the
|
|
122
|
+
same one.
|
|
123
|
+
|
|
124
|
+
**Provider verification (never assume a successful push means the PR reflects it):**
|
|
125
|
+
|
|
126
|
+
| Provider | Verify |
|
|
127
|
+
| --- | --- |
|
|
128
|
+
| GitHub | `gh pr view <PR#> --json headRefOid,mergeable` — `headRefOid` must equal the new HEAD SHA from Step 5; `mergeable` should not report a conflict state. |
|
|
129
|
+
| Azure DevOps | `az repos pr show --id <PR_ID> --output json --query "{sha:lastMergeSourceCommit.commitId, status:mergeStatus}"` — the SHA must equal the new HEAD; `status` should not report `conflicts`. |
|
|
130
|
+
|
|
131
|
+
If the reported SHA does not match, or the mergeable/status field reports a conflict despite a clean
|
|
132
|
+
local push, **do not report success** — this is the exact "dispatched is not succeeded" gap EF-21
|
|
133
|
+
already established for `spell-close-session`; a push completing without error is dispatched, not
|
|
134
|
+
verified. Re-check once after a short wait (provider APIs can lag); if it still doesn't match, stop and
|
|
135
|
+
report the discrepancy rather than declaring the sync done.
|
|
136
|
+
|
|
137
|
+
## Step 6 — Report
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
✓ PR synced: <URL>
|
|
141
|
+
Branch: <branch> → <target>
|
|
142
|
+
Method: rebase-and-fast-forward | merge-no-fast-forward
|
|
143
|
+
Conflicts: none | resolved (mechanical) | none — no rebase needed
|
|
144
|
+
Recoverable ref: sync-backup/<branch>/<timestamp>
|
|
145
|
+
New head SHA: <sha> (provider-confirmed)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
- If Step 3 resolved any mechanical conflicts, list exactly which files and what was kept from each side.
|
|
149
|
+
- If Step 3 aborted on an ambiguous conflict, this report never runs — Step 3's own STOP report is final.
|
|
150
|
+
- Never delete the Step 1 recoverable ref as part of this report — that is the human's call.
|
|
151
|
+
|
|
152
|
+
## Rules
|
|
153
|
+
|
|
154
|
+
- Never push with bare `--force` — only `--force-with-lease`, and never retry a rejected lease blindly.
|
|
155
|
+
- Never guess on an ambiguous conflict — abort and hand off, every time, with no exception for
|
|
156
|
+
"obviously trivial-looking" content on either side that this spell cannot independently verify.
|
|
157
|
+
- Never skip the recoverable ref (Step 1), even for a sync expected to be clean — the ref costs nothing
|
|
158
|
+
and the alternative is an unrecoverable mistake on the case that turns out not to be clean.
|
|
159
|
+
- Never report a sync as complete on a push success alone — provider verification (Step 5) is mandatory.
|
|
160
|
+
- Never delete the recoverable ref automatically.
|
package/dist/index.js
CHANGED
|
@@ -492,10 +492,12 @@ var COMPONENTS = [
|
|
|
492
492
|
},
|
|
493
493
|
{
|
|
494
494
|
name: "spells-delivery",
|
|
495
|
-
description: "Pull-request lifecycle \u2014 open a PR, respond to review feedback",
|
|
495
|
+
description: "Pull-request lifecycle \u2014 open a PR, sync a stale one, respond to review feedback",
|
|
496
496
|
files: [
|
|
497
497
|
".github/prompts/spell-create-pull-request.prompt.md",
|
|
498
498
|
".claude/commands/spell-create-pull-request.md",
|
|
499
|
+
".github/prompts/spell-sync-pull-request.prompt.md",
|
|
500
|
+
".claude/commands/spell-sync-pull-request.md",
|
|
499
501
|
".github/prompts/spell-address-review.prompt.md",
|
|
500
502
|
".claude/commands/spell-address-review.md"
|
|
501
503
|
]
|
|
@@ -3595,8 +3597,8 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
|
|
|
3595
3597
|
// src/commands/doctor.ts
|
|
3596
3598
|
import { access as access4, readFile as readFile6, mkdir as mkdir6, copyFile as fsCopyFile2 } from "fs/promises";
|
|
3597
3599
|
import { join as join11, dirname as dirname3 } from "path";
|
|
3598
|
-
import { execFile as
|
|
3599
|
-
import { promisify } from "util";
|
|
3600
|
+
import { execFile as execFile3 } from "child_process";
|
|
3601
|
+
import { promisify as promisify2 } from "util";
|
|
3600
3602
|
|
|
3601
3603
|
// src/config/incidents.ts
|
|
3602
3604
|
var INCIDENT_QUEUE = [
|
|
@@ -3663,8 +3665,177 @@ function evaluateIncidentGate(records) {
|
|
|
3663
3665
|
};
|
|
3664
3666
|
}
|
|
3665
3667
|
|
|
3666
|
-
// src/
|
|
3668
|
+
// src/modules/platform-policy.ts
|
|
3669
|
+
import { execFile as execFile2 } from "child_process";
|
|
3670
|
+
import { promisify } from "util";
|
|
3667
3671
|
var execFileAsync = promisify(execFile2);
|
|
3672
|
+
var SANCTIONED_MERGE_METHODS = ["merge", "rebase"];
|
|
3673
|
+
function compareToLadder(effective, declaredButBlocked, interactionNote) {
|
|
3674
|
+
const disallowedButPermitted = effective.filter(
|
|
3675
|
+
(m) => !SANCTIONED_MERGE_METHODS.includes(m)
|
|
3676
|
+
);
|
|
3677
|
+
const missingSanctioned = SANCTIONED_MERGE_METHODS.filter(
|
|
3678
|
+
(m) => !effective.includes(m)
|
|
3679
|
+
);
|
|
3680
|
+
const passed = disallowedButPermitted.length === 0 && missingSanctioned.length === 0;
|
|
3681
|
+
const messages = [];
|
|
3682
|
+
if (interactionNote) messages.push(interactionNote);
|
|
3683
|
+
if (disallowedButPermitted.length > 0) {
|
|
3684
|
+
messages.push(
|
|
3685
|
+
`disallowed method(s) effectively permitted: ${disallowedButPermitted.join(", ")} (never sanctioned per git-conventions.md)`
|
|
3686
|
+
);
|
|
3687
|
+
}
|
|
3688
|
+
if (missingSanctioned.length > 0) {
|
|
3689
|
+
messages.push(
|
|
3690
|
+
`sanctioned method(s) not effectively available: ${missingSanctioned.join(", ")}`
|
|
3691
|
+
);
|
|
3692
|
+
}
|
|
3693
|
+
return {
|
|
3694
|
+
passed,
|
|
3695
|
+
effectiveMergeMethods: effective,
|
|
3696
|
+
declaredButBlocked,
|
|
3697
|
+
disallowedButPermitted,
|
|
3698
|
+
message: passed ? `effective merge methods [${effective.join(", ")}] match the declared ladder` : messages.join("; ")
|
|
3699
|
+
};
|
|
3700
|
+
}
|
|
3701
|
+
function evaluateGitHubMergePolicy(rulesets) {
|
|
3702
|
+
const active = rulesets.filter(
|
|
3703
|
+
(r) => r.enforcement === "active" && r.target === "branch"
|
|
3704
|
+
);
|
|
3705
|
+
if (active.length === 0) {
|
|
3706
|
+
return {
|
|
3707
|
+
passed: false,
|
|
3708
|
+
effectiveMergeMethods: null,
|
|
3709
|
+
declaredButBlocked: [],
|
|
3710
|
+
disallowedButPermitted: [],
|
|
3711
|
+
message: "No active branch ruleset covers the target branch (checked via Rulesets, not the classic protection endpoint, which false-negatives on Rulesets-only repos)."
|
|
3712
|
+
};
|
|
3713
|
+
}
|
|
3714
|
+
let allowedMergeMethods = null;
|
|
3715
|
+
let hasRequiredLinearHistory = false;
|
|
3716
|
+
for (const ruleset of active) {
|
|
3717
|
+
for (const rule of ruleset.rules) {
|
|
3718
|
+
if (rule.type === "required_linear_history") {
|
|
3719
|
+
hasRequiredLinearHistory = true;
|
|
3720
|
+
}
|
|
3721
|
+
if (rule.type === "pull_request") {
|
|
3722
|
+
const methods = rule.parameters?.["allowed_merge_methods"];
|
|
3723
|
+
if (Array.isArray(methods)) {
|
|
3724
|
+
const typed = methods;
|
|
3725
|
+
allowedMergeMethods = allowedMergeMethods === null ? typed : allowedMergeMethods.filter((m) => typed.includes(m));
|
|
3726
|
+
}
|
|
3727
|
+
}
|
|
3728
|
+
}
|
|
3729
|
+
}
|
|
3730
|
+
const nominal = allowedMergeMethods ?? ["merge", "squash", "rebase"];
|
|
3731
|
+
const effective = hasRequiredLinearHistory ? nominal.filter((m) => m !== "merge") : nominal;
|
|
3732
|
+
const declaredButBlocked = hasRequiredLinearHistory && nominal.includes("merge") ? ["merge"] : [];
|
|
3733
|
+
const interactionNote = declaredButBlocked.length > 0 ? `allowed_merge_methods declares [${nominal.join(", ")}] but a required_linear_history rule silently blocks merge commits regardless -- effective methods are [${effective.join(", ")}]` : void 0;
|
|
3734
|
+
return compareToLadder(effective, declaredButBlocked, interactionNote);
|
|
3735
|
+
}
|
|
3736
|
+
async function fetchGitHubRulesets(owner, repo) {
|
|
3737
|
+
try {
|
|
3738
|
+
const { stdout: listOut } = await execFileAsync("gh", [
|
|
3739
|
+
"api",
|
|
3740
|
+
`repos/${owner}/${repo}/rulesets`
|
|
3741
|
+
]);
|
|
3742
|
+
const summaries = JSON.parse(listOut);
|
|
3743
|
+
const detailed = await Promise.all(
|
|
3744
|
+
summaries.map(async (s) => {
|
|
3745
|
+
const { stdout } = await execFileAsync("gh", [
|
|
3746
|
+
"api",
|
|
3747
|
+
`repos/${owner}/${repo}/rulesets/${s.id}`
|
|
3748
|
+
]);
|
|
3749
|
+
return JSON.parse(stdout);
|
|
3750
|
+
})
|
|
3751
|
+
);
|
|
3752
|
+
return detailed;
|
|
3753
|
+
} catch {
|
|
3754
|
+
return null;
|
|
3755
|
+
}
|
|
3756
|
+
}
|
|
3757
|
+
function evaluateAdoMergePolicy(policies) {
|
|
3758
|
+
const enabled = policies.filter((p) => p.isEnabled);
|
|
3759
|
+
if (enabled.length === 0) {
|
|
3760
|
+
return {
|
|
3761
|
+
passed: false,
|
|
3762
|
+
effectiveMergeMethods: null,
|
|
3763
|
+
declaredButBlocked: [],
|
|
3764
|
+
disallowedButPermitted: [],
|
|
3765
|
+
message: 'No enabled "Limit merge types" branch policy found for the target branch.'
|
|
3766
|
+
};
|
|
3767
|
+
}
|
|
3768
|
+
const effective = /* @__PURE__ */ new Set();
|
|
3769
|
+
for (const policy of enabled) {
|
|
3770
|
+
const s = policy.settings ?? {};
|
|
3771
|
+
if (s.allowNoFastForward) effective.add("merge");
|
|
3772
|
+
if (s.allowRebase || s.allowRebaseMerge) effective.add("rebase");
|
|
3773
|
+
if (s.useSquashMerge) effective.add("squash");
|
|
3774
|
+
}
|
|
3775
|
+
return compareToLadder([...effective], []);
|
|
3776
|
+
}
|
|
3777
|
+
async function fetchAdoMergeTypePolicies(org, project, repoName, branch) {
|
|
3778
|
+
try {
|
|
3779
|
+
const orgUrl = `https://dev.azure.com/${org}`;
|
|
3780
|
+
const { stdout: repoOut } = await execFileAsync("az", [
|
|
3781
|
+
"repos",
|
|
3782
|
+
"show",
|
|
3783
|
+
"--repository",
|
|
3784
|
+
repoName,
|
|
3785
|
+
"--org",
|
|
3786
|
+
orgUrl,
|
|
3787
|
+
"--project",
|
|
3788
|
+
project,
|
|
3789
|
+
"--query",
|
|
3790
|
+
"id",
|
|
3791
|
+
"--output",
|
|
3792
|
+
"tsv"
|
|
3793
|
+
]);
|
|
3794
|
+
const repositoryId = repoOut.trim();
|
|
3795
|
+
if (!repositoryId) return null;
|
|
3796
|
+
const { stdout } = await execFileAsync("az", [
|
|
3797
|
+
"repos",
|
|
3798
|
+
"policy",
|
|
3799
|
+
"list",
|
|
3800
|
+
"--repository-id",
|
|
3801
|
+
repositoryId,
|
|
3802
|
+
"--branch",
|
|
3803
|
+
branch,
|
|
3804
|
+
"--org",
|
|
3805
|
+
orgUrl,
|
|
3806
|
+
"--project",
|
|
3807
|
+
project,
|
|
3808
|
+
"--output",
|
|
3809
|
+
"json"
|
|
3810
|
+
]);
|
|
3811
|
+
return JSON.parse(stdout);
|
|
3812
|
+
} catch {
|
|
3813
|
+
return null;
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
function parseGitHubRemote(url) {
|
|
3817
|
+
const match = /github\.com[/:]([^/]+)\/([^/.]+?)(?:\.git)?\/?$/.exec(url);
|
|
3818
|
+
if (!match) return null;
|
|
3819
|
+
const [, owner, repo] = match;
|
|
3820
|
+
if (!owner || !repo) return null;
|
|
3821
|
+
return { owner, repo };
|
|
3822
|
+
}
|
|
3823
|
+
function parseAdoRemote(url) {
|
|
3824
|
+
const modern = /dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/?]+)/.exec(url);
|
|
3825
|
+
if (modern) {
|
|
3826
|
+
const [, org, project, repo] = modern;
|
|
3827
|
+
if (org && project && repo) return { org, project, repo };
|
|
3828
|
+
}
|
|
3829
|
+
const legacy = /([^./]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/?]+)/.exec(url);
|
|
3830
|
+
if (legacy) {
|
|
3831
|
+
const [, org, project, repo] = legacy;
|
|
3832
|
+
if (org && project && repo) return { org, project, repo };
|
|
3833
|
+
}
|
|
3834
|
+
return null;
|
|
3835
|
+
}
|
|
3836
|
+
|
|
3837
|
+
// src/commands/doctor.ts
|
|
3838
|
+
var execFileAsync2 = promisify2(execFile3);
|
|
3668
3839
|
async function checkNodeVersion() {
|
|
3669
3840
|
const name = "Node.js version";
|
|
3670
3841
|
const raw = process.version;
|
|
@@ -3702,7 +3873,7 @@ async function checkVSCodeExtension(id, label) {
|
|
|
3702
3873
|
let installBin = "code";
|
|
3703
3874
|
for (const bin of ["code", "code-insiders"]) {
|
|
3704
3875
|
try {
|
|
3705
|
-
await
|
|
3876
|
+
await execFileAsync2(bin, ["--version"]);
|
|
3706
3877
|
installBin = bin;
|
|
3707
3878
|
break;
|
|
3708
3879
|
} catch {
|
|
@@ -3894,6 +4065,49 @@ async function checkPushPolicy(targetDir) {
|
|
|
3894
4065
|
message: `declared "blocked" but not enforced \u2014 ${missing.join("; ")}. The manifest claims a protection this repository does not have.`
|
|
3895
4066
|
};
|
|
3896
4067
|
}
|
|
4068
|
+
async function checkPlatformBranchPolicy(targetDir) {
|
|
4069
|
+
const name = "Platform branch/merge policy (T11)";
|
|
4070
|
+
const targets = await pushTargets(targetDir);
|
|
4071
|
+
const origin = targets.find((t) => t.remote === "origin") ?? targets[0];
|
|
4072
|
+
const url = origin?.urls[0];
|
|
4073
|
+
if (!url) {
|
|
4074
|
+
return { name, passed: true, message: "no remote configured \u2014 nothing to check", blocking: false };
|
|
4075
|
+
}
|
|
4076
|
+
const github = parseGitHubRemote(url);
|
|
4077
|
+
if (github) {
|
|
4078
|
+
const rulesets = await fetchGitHubRulesets(github.owner, github.repo);
|
|
4079
|
+
if (rulesets === null) {
|
|
4080
|
+
return {
|
|
4081
|
+
name,
|
|
4082
|
+
passed: false,
|
|
4083
|
+
blocking: false,
|
|
4084
|
+
message: `could not query GitHub Rulesets for ${github.owner}/${github.repo} \u2014 is \`gh\` installed and authenticated? Verify manually: gh api repos/${github.owner}/${github.repo}/rulesets`
|
|
4085
|
+
};
|
|
4086
|
+
}
|
|
4087
|
+
const result = evaluateGitHubMergePolicy(rulesets);
|
|
4088
|
+
return { name, passed: result.passed, blocking: false, message: result.message };
|
|
4089
|
+
}
|
|
4090
|
+
const ado = parseAdoRemote(url);
|
|
4091
|
+
if (ado) {
|
|
4092
|
+
const policies = await fetchAdoMergeTypePolicies(ado.org, ado.project, ado.repo, "main");
|
|
4093
|
+
if (policies === null) {
|
|
4094
|
+
return {
|
|
4095
|
+
name,
|
|
4096
|
+
passed: false,
|
|
4097
|
+
blocking: false,
|
|
4098
|
+
message: `could not query the Azure DevOps "Limit merge types" branch policy for ${ado.org}/${ado.project}/${ado.repo} \u2014 is \`az\` installed and authenticated? Verify manually in the ADO web UI under Branch Policies.`
|
|
4099
|
+
};
|
|
4100
|
+
}
|
|
4101
|
+
const result = evaluateAdoMergePolicy(policies);
|
|
4102
|
+
return { name, passed: result.passed, blocking: false, message: result.message };
|
|
4103
|
+
}
|
|
4104
|
+
return {
|
|
4105
|
+
name,
|
|
4106
|
+
passed: true,
|
|
4107
|
+
blocking: false,
|
|
4108
|
+
message: "remote is neither GitHub nor Azure DevOps \u2014 platform policy verification not available for this provider"
|
|
4109
|
+
};
|
|
4110
|
+
}
|
|
3897
4111
|
async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
3898
4112
|
console.log("\nspell doctor \u2014 checking your Arcane environment\n");
|
|
3899
4113
|
const results = await Promise.all([
|
|
@@ -3902,7 +4116,8 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
|
3902
4116
|
checkArcaneManifest(targetDir),
|
|
3903
4117
|
Promise.resolve(checkIncidentReleaseGate()),
|
|
3904
4118
|
checkPullRebase(targetDir),
|
|
3905
|
-
checkPushPolicy(targetDir)
|
|
4119
|
+
checkPushPolicy(targetDir),
|
|
4120
|
+
checkPlatformBranchPolicy(targetDir)
|
|
3906
4121
|
]);
|
|
3907
4122
|
const sessionResults = await checkSessionContinuity(targetDir);
|
|
3908
4123
|
results.push(...sessionResults);
|