@rtorcato/repo-tooling 3.10.1 → 3.11.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.
@@ -27,6 +27,23 @@ import { detectLanguage } from '../cli/utils/detect-language.js';
27
27
  import { resolveLanguageModule } from '../languages/registry.js';
28
28
  import { applyGithubSettings } from './github-settings.js';
29
29
  import { closeCompletedMilestones } from './milestones.js';
30
+ /**
31
+ * A fixer giving up on something the user has to resolve — a wrong flag, not a
32
+ * crash. Thrown rather than printed-and-exited because a fixer runs underneath
33
+ * `--json`: only the command layer knows whether the failure should surface as
34
+ * an error payload on stdout or a red line on stderr. `code` joins the same
35
+ * vocabulary as the command's own `no-lockfile` / `unknown-target` errors.
36
+ */
37
+ export class FixerAbort extends Error {
38
+ code;
39
+ hint;
40
+ constructor(code, message, hint) {
41
+ super(message);
42
+ this.code = code;
43
+ this.hint = hint;
44
+ this.name = 'FixerAbort';
45
+ }
46
+ }
30
47
  /** The repo's language module, resolved from its marker files. */
31
48
  async function moduleFor(targetDir) {
32
49
  return resolveLanguageModule(await detectLanguage(targetDir));
@@ -34,16 +51,17 @@ async function moduleFor(targetDir) {
34
51
  /**
35
52
  * Where to install user-global skills, asking only when nothing resolves. Under
36
53
  * `--yes` / `--json` a prompt is not available — `--json` would have its payload
37
- * corrupted by one — so an unresolvable destination becomes an advisory and a
38
- * no-op rather than a guess at a directory the user never mentioned.
54
+ * corrupted by one — and guessing at a directory the user never mentioned is not
55
+ * an option either, so the run fails: `--skills-dir` is required in that case
56
+ * (#411). It used to warn and no-op, which exited 0 with an empty `filesWritten`
57
+ * that no `--json` consumer could tell apart from "already up to date".
39
58
  */
40
59
  async function resolveInstallDir(explicit, assumeYes) {
41
60
  const { dir } = await resolveSkillsDir(explicit);
42
61
  if (dir)
43
62
  return dir;
44
63
  if (assumeYes) {
45
- console.error(chalk.yellow(' skipped — no ~/.claude/skills found; pass --skills-dir <path>'));
46
- return null;
64
+ throw new FixerAbort('no-skills-dir', 'no ~/.claude/skills found, and --yes/--json cannot prompt for one', 'pass --skills-dir <path>');
47
65
  }
48
66
  const { answer } = await inquirer.prompt([
49
67
  {
@@ -115,7 +115,15 @@ async function probeRepo(exec) {
115
115
  autoMerge: d.allow_auto_merge === true,
116
116
  squashMerge: d.allow_squash_merge === true,
117
117
  deleteOnMerge: d.delete_branch_on_merge === true,
118
- mergeVisible: ['allow_auto_merge', 'allow_squash_merge', 'delete_branch_on_merge'].every((k) => typeof d[k] === 'boolean'),
118
+ mergeCommit: d.allow_merge_commit === true,
119
+ rebaseMerge: d.allow_rebase_merge === true,
120
+ mergeVisible: [
121
+ 'allow_auto_merge',
122
+ 'allow_squash_merge',
123
+ 'delete_branch_on_merge',
124
+ 'allow_merge_commit',
125
+ 'allow_rebase_merge',
126
+ ].every((k) => typeof d[k] === 'boolean'),
119
127
  },
120
128
  };
121
129
  }
@@ -197,6 +205,16 @@ async function checkBranchProtection(exec, nwo, branch) {
197
205
  };
198
206
  return { check, status: 'ok', detail: `${branch} protected per standard` };
199
207
  }
208
+ /**
209
+ * Squash is required *and exclusive* (#410). Leaving merge-commit or rebase
210
+ * enabled only makes them available on the merge button, and one mis-click puts
211
+ * every intermediate branch commit on the default branch: semantic-release then
212
+ * reads subjects nobody reviewed (a stray `fix:` inside a docs PR cuts a
213
+ * release), and `ai-issue-loop`'s Pass 2 stops finding the `(#N)` squash subject
214
+ * it uses to confirm work landed, so worktrees leak. Observed on `js-common`
215
+ * #204, whose CONTRIBUTING already said squash-only — the rule existed, nothing
216
+ * enforced it.
217
+ */
200
218
  function checkMergeSettings(info) {
201
219
  const check = 'Merge settings';
202
220
  // The token can't see the merge-setting fields (no admin:read) — skip rather
@@ -210,14 +228,18 @@ function checkMergeSettings(info) {
210
228
  deltas.push('squash-merge disabled');
211
229
  if (!info.deleteOnMerge)
212
230
  deltas.push('delete-branch-on-merge disabled');
231
+ if (info.mergeCommit)
232
+ deltas.push('merge commits allowed (squash only)');
233
+ if (info.rebaseMerge)
234
+ deltas.push('rebase merging allowed (squash only)');
213
235
  if (deltas.length)
214
236
  return {
215
237
  check,
216
238
  status: 'drift',
217
239
  detail: deltas.join('; '),
218
- hint: 'Enable auto-merge, squash-merge, and delete-branch-on-merge in repo settings',
240
+ hint: 'Enable auto-merge and delete-branch-on-merge, and make squash the only merge method',
219
241
  };
220
- return { check, status: 'ok', detail: 'auto-merge, squash-merge, delete-on-merge all on' };
242
+ return { check, status: 'ok', detail: 'squash-only, auto-merge and delete-on-merge on' };
221
243
  }
222
244
  async function checkWorkflowPermissions(exec, nwo) {
223
245
  const check = 'Workflow permissions';
@@ -370,7 +392,7 @@ export function buildGhApplyCommands(state) {
370
392
  const commands = [];
371
393
  if (state.merge)
372
394
  commands.push({
373
- label: 'merge settings (auto-merge, squash, delete-on-merge)',
395
+ label: 'merge settings (squash-only, auto-merge, delete-on-merge)',
374
396
  args: [
375
397
  'api',
376
398
  '-X',
@@ -382,6 +404,10 @@ export function buildGhApplyCommands(state) {
382
404
  'allow_squash_merge=true',
383
405
  '-F',
384
406
  'delete_branch_on_merge=true',
407
+ '-F',
408
+ 'allow_merge_commit=false',
409
+ '-F',
410
+ 'allow_rebase_merge=false',
385
411
  ],
386
412
  });
387
413
  if (state.protection)
@@ -9,7 +9,7 @@ import { LOCKFILE_NAME, readLockfile, updateLockfileConfig, writeLockfile, } fro
9
9
  import { runDoctor } from './doctor.js';
10
10
  import { declinedInLock, lockfilePatchForTarget } from './fix-targets.js';
11
11
  import { computeFileList } from './setup-presets.js';
12
- import { BASE_FIXERS } from '../../base/fixers.js';
12
+ import { BASE_FIXERS, FixerAbort, } from '../../base/fixers.js';
13
13
  import { FIXERS, readPackageJson } from '../../languages/js/fixers.js';
14
14
  import { PERL_FIXERS } from '../../languages/perl/fixers.js';
15
15
  import { PYTHON_FIXERS } from '../../languages/python/fixers.js';
@@ -398,9 +398,25 @@ export async function fixCommand(target, options = {}) {
398
398
  console.log(chalk.gray(' skipped\n'));
399
399
  return;
400
400
  }
401
+ // ponytail: only the targeted path is guarded, because the only fixer that
402
+ // aborts is `claude-skills` and it is explicitOnly — the bulk loop below
403
+ // cannot reach it. A non-explicitOnly fixer that throws FixerAbort would
404
+ // surface as an unhandled rejection there; guard the loop when one exists.
401
405
  const outcome = await applyFixer(fixer, effectiveResult, targetDir, pkg, lock, dryRun, silent, {
402
406
  skillsDir: options.skillsDir,
403
407
  assumeYes,
408
+ }).catch((err) => {
409
+ if (!(err instanceof FixerAbort))
410
+ throw err;
411
+ if (json) {
412
+ console.log(JSON.stringify({ directory: targetDir, target: fixer.target, error: err.code, hint: err.hint }, null, 2));
413
+ }
414
+ else {
415
+ console.error(chalk.red(`\n❌ ${fixer.target}: ${err.message}`));
416
+ if (err.hint)
417
+ console.error(chalk.gray(` ${err.hint}\n`));
418
+ }
419
+ process.exit(1);
404
420
  });
405
421
  actions.push(recordFor(fixer.target, result.check, effectiveResult.status, outcome.dryRun ? 'dry-run' : 'applied', outcome.filesWritten, conflict));
406
422
  if (json)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.10.1",
3
+ "version": "3.11.0",
4
4
  "description": "One CLI to scaffold, audit and fix your repo's whole toolchain — linting, tests, commits, releases & CI.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -32,13 +32,14 @@ the protected branch — it would deadlock every PR.
32
32
 
33
33
  **If you ever switch to real approvals** — a second GitHub account reviewing as
34
34
  someone else, so `--approve` works and the `ai-ok-*` labels become unnecessary —
35
- know that `@rtorcato/repo-tooling` will fight you. Its `GITHUB_STANDARD`
36
- (`src/base/github-settings.ts`) treats any `required_pull_request_reviews` as
37
- drift because required review deadlocks solo Dependabot auto-merge, and
38
- `fix github-settings` silently PUTs it back to `null`. So the next unrelated
39
- `doctor`/`fix` run would strip your approval rule and hand merges back to the
40
- labels, with nothing in the output tying it to this pipeline. Change the standard
41
- there first, or don't go down that path.
35
+ first check what else writes your branch protection. Any repo-settings tool that
36
+ treats `required_pull_request_reviews` as drift will PUT it back to `null` on its
37
+ next run, because required review deadlocks solo Dependabot auto-merge. Your
38
+ approval rule vanishes, merges hand themselves back to the labels, and nothing in
39
+ that tool's output ties the change to this pipeline. `@rtorcato/repo-tooling`,
40
+ which ships this skill, is one such tool — its repo-settings standard asserts
41
+ `required_pull_request_reviews: null`, so change that standard before you rely on
42
+ real approvals.
42
43
 
43
44
  The same constraint makes everything an agent posts *look* hand-written by the
44
45
  owner. So **every comment any agent leaves — review, blocked, gave-up, declined —
@@ -805,10 +806,14 @@ times against one trivial issue and watch the labels advance.
805
806
  ## Repo prerequisites
806
807
 
807
808
  ```bash
808
- gh api repos/$OWNER_REPO --jq '{allow_squash_merge, allow_auto_merge, delete_branch_on_merge}'
809
+ gh api repos/$OWNER_REPO --jq '{allow_squash_merge, allow_merge_commit, allow_rebase_merge, allow_auto_merge, delete_branch_on_merge}'
809
810
  gh api repos/$OWNER_REPO/branches/main/protection --jq '{contexts: .required_status_checks.contexts, reviews: .required_pull_request_reviews}'
810
811
  ```
811
812
 
812
- Need: squash + auto-merge + delete-on-merge all true, at least one required
813
- status check, and `required_pull_request_reviews: null`. See the
814
- `github-pr-workflow` skill for the one-time bootstrap.
813
+ Need: auto-merge + delete-on-merge + squash all true, **`allow_merge_commit` and
814
+ `allow_rebase_merge` both false**, at least one required status check, and
815
+ `required_pull_request_reviews: null`. Squash has to be the *only* method, not
816
+ merely an available one: Pass 2 confirms a PR landed by finding its `(#N)` squash
817
+ subject on `main`, and a merge commit leaves nothing to find — the worktree then
818
+ survives every tick and its `ai-wip` slot leaks. See the `github-pr-workflow`
819
+ skill for the one-time bootstrap.