@timekast/factory 2.3.0 → 2.4.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/commands/provision.js +26 -23
- package/dist/commands/prune.js +255 -0
- package/dist/commands/update.js +62 -8
- package/dist/index.js +14 -0
- package/dist/lib/constants.js +13 -0
- package/dist/lib/flags.js +14 -0
- package/dist/lib/prune-git.js +81 -0
- package/dist/lib/prune-pr-check.js +119 -0
- package/dist/lib/prune-remote.js +464 -0
- package/dist/lib/prune-tiers.js +253 -0
- package/dist/lib/prune-update.js +78 -0
- package/package.json +1 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git plumbing for `factory prune`: list what the repo tracks, and commit a set of
|
|
3
|
+
* untrack + add operations WITHOUT going through the shared index.
|
|
4
|
+
*
|
|
5
|
+
* Why a private index and not the kit's usual commit-by-pathspec (`GIT.md §3.6.1`): a
|
|
6
|
+
* pathspec commit takes the listed paths' content from the WORKING TREE, so a path removed
|
|
7
|
+
* with `git rm --cached` (still on disk, now ignored) is put right back — the commit records
|
|
8
|
+
* no deletion and the methodology stays published. Staging the removal in the real index
|
|
9
|
+
* instead would leave it there for any other commit to swallow. A throwaway index seeded from
|
|
10
|
+
* HEAD gives both properties: the commit holds exactly these operations, and nothing another
|
|
11
|
+
* session staged can ride along.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, rmSync } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { execa } from 'execa';
|
|
16
|
+
import { CLIError } from './cli-error.js';
|
|
17
|
+
/** Every path the index tracks (POSIX, repo-relative). */
|
|
18
|
+
export async function trackedFiles(rootDir) {
|
|
19
|
+
const res = await execa('git', ['ls-files', '-z'], { cwd: rootDir, reject: false });
|
|
20
|
+
if (res.exitCode !== 0) {
|
|
21
|
+
throw new CLIError(`No se pudo listar los archivos del repo (\`git ls-files\`): ${res.stderr}`);
|
|
22
|
+
}
|
|
23
|
+
return res.stdout.split('\0').filter(Boolean);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Commit `remove` + `add` on top of HEAD through a private index, then bring the real index
|
|
27
|
+
* of those paths in line with the new HEAD. Hooks run as for any commit. Nothing to commit →
|
|
28
|
+
* `{ committed: false }`, no empty commit.
|
|
29
|
+
*/
|
|
30
|
+
export async function commitWithPrivateIndex(rootDir, input) {
|
|
31
|
+
const gitDirRes = await execa('git', ['rev-parse', '--absolute-git-dir'], {
|
|
32
|
+
cwd: rootDir,
|
|
33
|
+
reject: false,
|
|
34
|
+
});
|
|
35
|
+
if (gitDirRes.exitCode !== 0) {
|
|
36
|
+
throw new CLIError('Esto no es un repo git.');
|
|
37
|
+
}
|
|
38
|
+
const indexFile = path.join(gitDirRes.stdout.trim(), `tk-prune-index-${process.pid}`);
|
|
39
|
+
const env = { ...process.env, GIT_INDEX_FILE: indexFile };
|
|
40
|
+
const run = (args) => execa('git', args, { cwd: rootDir, env, reject: false });
|
|
41
|
+
try {
|
|
42
|
+
const head = await execa('git', ['rev-parse', '--verify', '-q', 'HEAD'], {
|
|
43
|
+
cwd: rootDir,
|
|
44
|
+
reject: false,
|
|
45
|
+
});
|
|
46
|
+
const seeded = await run(head.exitCode === 0 ? ['read-tree', 'HEAD'] : ['read-tree', '--empty']);
|
|
47
|
+
if (seeded.exitCode !== 0)
|
|
48
|
+
throw new CLIError(`\`git read-tree\` falló: ${seeded.stderr}`);
|
|
49
|
+
if (input.remove.length > 0) {
|
|
50
|
+
const rm = await run(['rm', '--cached', '-q', '--ignore-unmatch', '--', ...input.remove]);
|
|
51
|
+
if (rm.exitCode !== 0)
|
|
52
|
+
throw new CLIError(`\`git rm --cached\` falló: ${rm.stderr}`);
|
|
53
|
+
}
|
|
54
|
+
if (input.add.length > 0) {
|
|
55
|
+
const add = await run(['add', '-f', '--', ...input.add]);
|
|
56
|
+
if (add.exitCode !== 0)
|
|
57
|
+
throw new CLIError(`\`git add\` falló: ${add.stderr}`);
|
|
58
|
+
}
|
|
59
|
+
const diff = await run(['diff', '--cached', '--quiet']);
|
|
60
|
+
if (diff.exitCode === 0)
|
|
61
|
+
return { committed: false };
|
|
62
|
+
const commit = await run(['commit', '-q', '-m', input.message]);
|
|
63
|
+
if (commit.exitCode !== 0) {
|
|
64
|
+
throw new CLIError(`El commit falló (¿un hook lo rechazó o falta identidad git?):\n${commit.stderr || commit.stdout}`);
|
|
65
|
+
}
|
|
66
|
+
const sha = await execa('git', ['rev-parse', '--short', 'HEAD'], { cwd: rootDir });
|
|
67
|
+
// The real index still holds the pre-commit state of these paths: reset them to the new
|
|
68
|
+
// HEAD so `git status` shows the removals as done, not as staged re-additions.
|
|
69
|
+
await execa('git', ['reset', '-q', '--', ...input.remove, ...input.add], {
|
|
70
|
+
cwd: rootDir,
|
|
71
|
+
reject: false,
|
|
72
|
+
});
|
|
73
|
+
return { committed: true, sha: sha.stdout.trim() };
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
for (const f of [indexFile, `${indexFile}.lock`]) {
|
|
77
|
+
if (existsSync(f))
|
|
78
|
+
rmSync(f, { force: true });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hard refusal `/integrate` runs on a collaborator's pull request BEFORE checking it out
|
|
3
|
+
* (`factory prune check-pr`). A member runs the PR's code on their own machine — a risk the
|
|
4
|
+
* org accepted for product code, and only for product code — so any change to what runs
|
|
5
|
+
* around the product (hooks, CI, scripts, tooling config, the methodology, the prune switch)
|
|
6
|
+
* is refused by path, mechanically, never by the agent's judgement. A PR refused here goes
|
|
7
|
+
* back to the collaborator, or a member redoes it.
|
|
8
|
+
*
|
|
9
|
+
* SSOT of the list: `REJECTED_PATHS` below. `tk-integrate/methodology/rejected-paths.md`
|
|
10
|
+
* explains it and points here.
|
|
11
|
+
*/
|
|
12
|
+
import { CLIError } from './cli-error.js';
|
|
13
|
+
export const REJECTED_PATHS = [
|
|
14
|
+
{ pattern: '.claude/', why: 'el cerebro del kit (reglas, hooks del agente, políticas)' },
|
|
15
|
+
{ pattern: 'CLAUDE.md', why: 'instrucciones del agente' },
|
|
16
|
+
{ pattern: '.husky/', why: 'hooks de git: corren en la máquina de quien commitea' },
|
|
17
|
+
{ pattern: '.github/', why: 'CI y configuración de GitHub' },
|
|
18
|
+
{ pattern: 'scripts/', why: 'scripts que corren los hooks y `pnpm verify`' },
|
|
19
|
+
{ pattern: '.gitignore', why: 'decide qué de la metodología entra al repo' },
|
|
20
|
+
{ pattern: '.timekast/', why: 'el interruptor del repo compartido y el estado del kit' },
|
|
21
|
+
{
|
|
22
|
+
pattern: '**/*.config.*',
|
|
23
|
+
why: 'configuración de herramientas (next, vitest, eslint, playwright…)',
|
|
24
|
+
},
|
|
25
|
+
{ pattern: 'vitest.setup*', why: 'código que corre antes de cada test' },
|
|
26
|
+
{ pattern: '.npmrc', why: 'configuración de pnpm (registro, scripts)' },
|
|
27
|
+
{ pattern: '.pnpmfile.cjs', why: 'código que pnpm ejecuta al instalar' },
|
|
28
|
+
{ pattern: 'pnpm-workspace.yaml', why: 'configuración de pnpm' },
|
|
29
|
+
];
|
|
30
|
+
/** The `package.json` fields a PR may not change (scripts run on the member's machine). */
|
|
31
|
+
export const REJECTED_PACKAGE_FIELDS = ['scripts', 'pnpm'];
|
|
32
|
+
function segmentRx(pattern) {
|
|
33
|
+
return new RegExp(`^${pattern
|
|
34
|
+
.split('*')
|
|
35
|
+
.map((p) => p.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
|
|
36
|
+
.join('[^/]*')}$`);
|
|
37
|
+
}
|
|
38
|
+
/** Does `relPath` fall under `pattern`? A leading `**` + `/` matches the basename at any depth. */
|
|
39
|
+
export function pathRejected(pattern, relPath) {
|
|
40
|
+
if (pattern.startsWith('**/')) {
|
|
41
|
+
const base = relPath.split('/').pop() ?? relPath;
|
|
42
|
+
return segmentRx(pattern.slice(3)).test(base);
|
|
43
|
+
}
|
|
44
|
+
if (pattern.endsWith('/'))
|
|
45
|
+
return relPath.startsWith(pattern);
|
|
46
|
+
return segmentRx(pattern).test(relPath);
|
|
47
|
+
}
|
|
48
|
+
/** Read the PR's files from GitHub (no checkout) and apply the refusal. */
|
|
49
|
+
export async function checkPr(repo, pr, deps) {
|
|
50
|
+
const filesRes = await deps.gh([
|
|
51
|
+
'api',
|
|
52
|
+
'--paginate',
|
|
53
|
+
`repos/${repo}/pulls/${pr}/files?per_page=100`,
|
|
54
|
+
'--jq',
|
|
55
|
+
'.[] | [.filename, (.previous_filename // "")] | @tsv',
|
|
56
|
+
]);
|
|
57
|
+
if (filesRes.exitCode !== 0) {
|
|
58
|
+
throw new CLIError(`No pude leer los archivos del PR #${pr} de \`${repo}\`: ${filesRes.stderr}`);
|
|
59
|
+
}
|
|
60
|
+
const files = [
|
|
61
|
+
...new Set(filesRes.stdout
|
|
62
|
+
.split('\n')
|
|
63
|
+
.flatMap((line) => line.split('\t'))
|
|
64
|
+
.map((p) => p.trim())
|
|
65
|
+
.filter(Boolean)),
|
|
66
|
+
].sort();
|
|
67
|
+
const rejected = [];
|
|
68
|
+
for (const p of files) {
|
|
69
|
+
const hit = REJECTED_PATHS.find((r) => pathRejected(r.pattern, p));
|
|
70
|
+
if (hit)
|
|
71
|
+
rejected.push({ path: p, why: hit.why });
|
|
72
|
+
}
|
|
73
|
+
const packageFields = [];
|
|
74
|
+
if (files.includes('package.json')) {
|
|
75
|
+
const meta = await deps.gh([
|
|
76
|
+
'pr',
|
|
77
|
+
'view',
|
|
78
|
+
String(pr),
|
|
79
|
+
'--repo',
|
|
80
|
+
repo,
|
|
81
|
+
'--json',
|
|
82
|
+
'baseRefOid,headRefOid',
|
|
83
|
+
'--jq',
|
|
84
|
+
'[.baseRefOid, .headRefOid] | @tsv',
|
|
85
|
+
]);
|
|
86
|
+
if (meta.exitCode !== 0)
|
|
87
|
+
throw new CLIError(`No pude leer el PR #${pr}: ${meta.stderr}`);
|
|
88
|
+
const [base, head] = meta.stdout.trim().split('\t');
|
|
89
|
+
const read = async (ref) => {
|
|
90
|
+
const r = await deps.gh([
|
|
91
|
+
'api',
|
|
92
|
+
`repos/${repo}/contents/package.json?ref=${ref}`,
|
|
93
|
+
'-H',
|
|
94
|
+
'Accept: application/vnd.github.raw',
|
|
95
|
+
]);
|
|
96
|
+
if (r.exitCode !== 0)
|
|
97
|
+
return null;
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(r.stdout);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const [before, after] = [await read(base), await read(head)];
|
|
106
|
+
if (!before || !after) {
|
|
107
|
+
// Unreadable or invalid JSON on either side: the comparison cannot clear it.
|
|
108
|
+
packageFields.push('(no se pudo comparar package.json)');
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
for (const field of REJECTED_PACKAGE_FIELDS) {
|
|
112
|
+
if (JSON.stringify(before[field] ?? null) !== JSON.stringify(after[field] ?? null)) {
|
|
113
|
+
packageFields.push(field);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { rejected, packageFields, files };
|
|
119
|
+
}
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GitHub side of `factory prune`: the cut to a repo whose history never held the
|
|
3
|
+
* methodology (`cutover`), inviting collaborators (`invite`), closing the old repo (`finish`)
|
|
4
|
+
* and auditing a repo's history (`audit`).
|
|
5
|
+
*
|
|
6
|
+
* Everything runs with the `gh` session of the member who runs it — their permissions, never
|
|
7
|
+
* a token of the org. `gh`, the push URL and the mirror clone are injected (`PruneDeps`) so the
|
|
8
|
+
* suite drives every branch against local bare repos and a scripted `gh`, never GitHub.
|
|
9
|
+
*
|
|
10
|
+
* The cut is the one irreversible step, so its order is the safety argument:
|
|
11
|
+
* 1. read-only preflight that refuses on anything doubtful (and stops there without
|
|
12
|
+
* `--confirm`);
|
|
13
|
+
* 2. a snapshot of `main` with the tier applied, built and VALIDATED locally;
|
|
14
|
+
* 3. `<repo>-next` created and pushed — the original is untouched until here;
|
|
15
|
+
* 4. the original's `main` re-checked, then two renames (`<repo>` → `<repo>-legacy`,
|
|
16
|
+
* `<repo>-next` → `<repo>`), with the first undone if the second fails.
|
|
17
|
+
* Progress is saved in the git dir, so `--resume` continues where a failure stopped.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { execa } from 'execa';
|
|
23
|
+
import { CLIError } from './cli-error.js';
|
|
24
|
+
import { GITIGNORE_FILE } from './constants.js';
|
|
25
|
+
import { syncManagedBlock } from './gitattributes.js';
|
|
26
|
+
import { PRUNE_MARKER_PATH, PRUNE_TIERS_PATH, getTier, isRestricted, readPruneTiers, renderGitignoreBlock, stripLegacyCollabBlock, validatePruneTiers, writePruneMarker, } from './prune-tiers.js';
|
|
27
|
+
export const defaultPruneDeps = {
|
|
28
|
+
gh: async (args) => {
|
|
29
|
+
const r = await execa('gh', args, { reject: false });
|
|
30
|
+
return { exitCode: r.exitCode ?? 1, stdout: r.stdout, stderr: r.stderr };
|
|
31
|
+
},
|
|
32
|
+
pushUrl: (repo) => `https://github.com/${repo}.git`,
|
|
33
|
+
cloneMirror: async (repo, dir) => {
|
|
34
|
+
const r = await execa('gh', ['repo', 'clone', repo, dir, '--', '--mirror', '-q'], {
|
|
35
|
+
reject: false,
|
|
36
|
+
});
|
|
37
|
+
if (r.exitCode !== 0)
|
|
38
|
+
throw new CLIError(`No se pudo clonar \`${repo}\`: ${r.stderr}`);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
async function git(cwd, args, input) {
|
|
42
|
+
const r = await execa('git', args, {
|
|
43
|
+
cwd,
|
|
44
|
+
reject: false,
|
|
45
|
+
...(input !== undefined ? { input } : {}),
|
|
46
|
+
});
|
|
47
|
+
return { exitCode: r.exitCode ?? 1, stdout: r.stdout, stderr: r.stderr };
|
|
48
|
+
}
|
|
49
|
+
async function gitOk(cwd, args, what) {
|
|
50
|
+
const r = await git(cwd, args);
|
|
51
|
+
if (r.exitCode !== 0)
|
|
52
|
+
throw new CLIError(`${what} falló: ${r.stderr || r.stdout}`);
|
|
53
|
+
return r.stdout;
|
|
54
|
+
}
|
|
55
|
+
async function ghOk(deps, args, what) {
|
|
56
|
+
const r = await deps.gh(args);
|
|
57
|
+
if (r.exitCode !== 0)
|
|
58
|
+
throw new CLIError(`${what} falló: ${r.stderr || r.stdout}`);
|
|
59
|
+
return r.stdout;
|
|
60
|
+
}
|
|
61
|
+
/** `gh api <path>` succeeded? (404 and every other failure → false). */
|
|
62
|
+
async function ghExists(deps, apiPath) {
|
|
63
|
+
return (await deps.gh(['api', apiPath, '--silent'])).exitCode === 0;
|
|
64
|
+
}
|
|
65
|
+
function splitRepo(repo) {
|
|
66
|
+
const m = /^([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+)$/.exec(repo);
|
|
67
|
+
if (!m)
|
|
68
|
+
throw new CLIError(`\`${repo}\` no tiene la forma \`owner/nombre\`.`);
|
|
69
|
+
return { owner: m[1], name: m[2] };
|
|
70
|
+
}
|
|
71
|
+
/** `owner/name` of the `origin` remote (https or ssh). */
|
|
72
|
+
export async function originRepo(rootDir) {
|
|
73
|
+
const url = (await git(rootDir, ['remote', 'get-url', 'origin'])).stdout.trim();
|
|
74
|
+
const m = /github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/.exec(url);
|
|
75
|
+
if (!m) {
|
|
76
|
+
throw new CLIError(`No pude leer el repo de GitHub de \`origin\` (${url || 'sin remote'}). Pásalo con \`--repo=owner/nombre\`.`);
|
|
77
|
+
}
|
|
78
|
+
return `${m[1]}/${m[2]}`;
|
|
79
|
+
}
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Snapshot
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
/**
|
|
84
|
+
* Apply the tier to a working tree: the managed `.gitignore` block + the switch. CMLL's
|
|
85
|
+
* hand-written block is removed first, or the sync would add a second one beside it.
|
|
86
|
+
* Returns whether that legacy block was there.
|
|
87
|
+
*/
|
|
88
|
+
export function applyTierFiles(dir, tierName, tier, at) {
|
|
89
|
+
const gitignorePath = path.join(dir, GITIGNORE_FILE);
|
|
90
|
+
let legacyBlockRemoved = false;
|
|
91
|
+
if (existsSync(gitignorePath)) {
|
|
92
|
+
const stripped = stripLegacyCollabBlock(readFileSync(gitignorePath, 'utf8'));
|
|
93
|
+
if (stripped.removed) {
|
|
94
|
+
writeFileSync(gitignorePath, stripped.content, 'utf8');
|
|
95
|
+
legacyBlockRemoved = true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
syncManagedBlock(dir, renderGitignoreBlock(tierName, tier), GITIGNORE_FILE);
|
|
99
|
+
writePruneMarker(dir, { tier: tierName, at });
|
|
100
|
+
return legacyBlockRemoved;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Build, in `outDir`, a one-commit repo holding `sha`'s tree with the tier applied. The
|
|
104
|
+
* snapshot's own registry decides (it is what the new repo will enforce); a `main` that does
|
|
105
|
+
* not carry it yet falls back to the working tree's.
|
|
106
|
+
*/
|
|
107
|
+
export async function buildSnapshot(rootDir, sha, tierName, outDir, message) {
|
|
108
|
+
mkdirSync(outDir, { recursive: true });
|
|
109
|
+
await gitOk(outDir, ['init', '-q', '-b', 'main'], '`git init` del snapshot');
|
|
110
|
+
const tar = path.join(outDir, '..', `snapshot-${process.pid}.tar`);
|
|
111
|
+
try {
|
|
112
|
+
await gitOk(rootDir, ['archive', '--format=tar', '--output', tar, sha], '`git archive`');
|
|
113
|
+
const x = await execa('tar', ['-xf', tar, '-C', outDir], { reject: false });
|
|
114
|
+
if (x.exitCode !== 0)
|
|
115
|
+
throw new CLIError(`No se pudo extraer el snapshot: ${x.stderr}`);
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
rmSync(tar, { force: true });
|
|
119
|
+
}
|
|
120
|
+
const tiers = existsSync(path.join(outDir, PRUNE_TIERS_PATH))
|
|
121
|
+
? readPruneTiers(outDir)
|
|
122
|
+
: readPruneTiers(rootDir);
|
|
123
|
+
const tier = getTier(tiers, tierName);
|
|
124
|
+
applyTierFiles(outDir, tierName, tier, new Date().toISOString());
|
|
125
|
+
await gitOk(outDir, ['add', '-A'], '`git add` del snapshot');
|
|
126
|
+
// The snapshot is a fresh repo: it only sees the global git identity. The member's identity
|
|
127
|
+
// as the source repo resolves it (local over global) authors the commit instead.
|
|
128
|
+
const identity = [];
|
|
129
|
+
for (const key of ['user.name', 'user.email']) {
|
|
130
|
+
const value = (await git(rootDir, ['config', key])).stdout.trim();
|
|
131
|
+
if (value)
|
|
132
|
+
identity.push('-c', `${key}=${value}`);
|
|
133
|
+
}
|
|
134
|
+
await gitOk(outDir, [...identity, 'commit', '-q', '-m', message], 'El commit del snapshot');
|
|
135
|
+
return { sha: (await gitOk(outDir, ['rev-parse', 'HEAD'], '`rev-parse`')).trim(), tier };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Restricted paths the snapshot would publish — by the classifier AND by git's own reading of
|
|
139
|
+
* the generated `.gitignore` (`check-ignore --no-index`). Either one non-empty aborts the cut.
|
|
140
|
+
*/
|
|
141
|
+
export async function snapshotOffenders(dir, tier) {
|
|
142
|
+
const files = (await gitOk(dir, ['ls-tree', '-r', '--name-only', 'HEAD'], '`ls-tree`'))
|
|
143
|
+
.split('\n')
|
|
144
|
+
.filter(Boolean);
|
|
145
|
+
const byTier = files.filter((p) => isRestricted(tier, p));
|
|
146
|
+
const ignored = await git(dir, ['check-ignore', '--no-index', '--stdin'], files.join('\n'));
|
|
147
|
+
const byGit = ignored.stdout.split('\n').filter(Boolean);
|
|
148
|
+
return [...new Set([...byTier, ...byGit])].sort();
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Mirror-clone `repo` and check its whole history: one root, and no restricted path in any
|
|
152
|
+
* commit of any ref (pull-request refs included — a member's old clone pushed through a PR
|
|
153
|
+
* is exactly what this catches). The tier is the one the repo's own HEAD declares.
|
|
154
|
+
*/
|
|
155
|
+
export async function pruneAudit(repo, deps) {
|
|
156
|
+
const tmp = mkdtempSync(path.join(tmpdir(), 'tk-prune-audit-'));
|
|
157
|
+
try {
|
|
158
|
+
const dir = path.join(tmp, 'mirror.git');
|
|
159
|
+
await deps.cloneMirror(repo, dir);
|
|
160
|
+
const show = (rel) => git(dir, ['show', `HEAD:${rel}`]);
|
|
161
|
+
const marker = await show(PRUNE_MARKER_PATH);
|
|
162
|
+
const registry = await show(PRUNE_TIERS_PATH);
|
|
163
|
+
if (marker.exitCode !== 0 || registry.exitCode !== 0) {
|
|
164
|
+
throw new CLIError(`\`${repo}\` no es un repo podado: su HEAD no trae \`${PRUNE_MARKER_PATH}\` y \`${PRUNE_TIERS_PATH}\`.`);
|
|
165
|
+
}
|
|
166
|
+
const tierName = JSON.parse(marker.stdout).tier;
|
|
167
|
+
const data = JSON.parse(registry.stdout);
|
|
168
|
+
const errors = validatePruneTiers(data);
|
|
169
|
+
if (errors.length > 0)
|
|
170
|
+
throw new CLIError(`\`${PRUNE_TIERS_PATH}\` del repo es inválido.`);
|
|
171
|
+
const tier = getTier(data, tierName);
|
|
172
|
+
const roots = (await gitOk(dir, ['rev-list', '--max-parents=0', '--all'], '`rev-list`'))
|
|
173
|
+
.split('\n')
|
|
174
|
+
.filter(Boolean);
|
|
175
|
+
// Every path that ever existed was added by some commit, so the union of the paths each
|
|
176
|
+
// commit touches is the union of every tree in the history.
|
|
177
|
+
const touched = await gitOk(dir, ['log', '--all', '--name-only', '--no-renames', '--format='], '`git log`');
|
|
178
|
+
const leaks = [...new Set(touched.split('\n').filter(Boolean))]
|
|
179
|
+
.filter((p) => isRestricted(tier, p))
|
|
180
|
+
.sort();
|
|
181
|
+
return { tierName, roots, leaks };
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async function statePath(rootDir) {
|
|
188
|
+
const gitDir = (await gitOk(rootDir, ['rev-parse', '--absolute-git-dir'], '`rev-parse`')).trim();
|
|
189
|
+
return path.join(gitDir, 'tk-prune-cutover.json');
|
|
190
|
+
}
|
|
191
|
+
/** Read-only checks; throws a `CLIError` naming the first thing that blocks the cut. */
|
|
192
|
+
export async function cutoverPreflight(rootDir, flags, deps) {
|
|
193
|
+
const repo = flags.repo ?? (await originRepo(rootDir));
|
|
194
|
+
const { owner, name } = splitRepo(repo);
|
|
195
|
+
const info = await deps.gh([
|
|
196
|
+
'api',
|
|
197
|
+
`repos/${repo}`,
|
|
198
|
+
'--jq',
|
|
199
|
+
'{admin: .permissions.admin, default: .default_branch, ownerType: .owner.type}',
|
|
200
|
+
]);
|
|
201
|
+
if (info.exitCode !== 0)
|
|
202
|
+
throw new CLIError(`No pude leer \`${repo}\`: ${info.stderr}`);
|
|
203
|
+
const meta = JSON.parse(info.stdout);
|
|
204
|
+
if (!meta.admin)
|
|
205
|
+
throw new CLIError(`Necesitas ser admin de \`${repo}\` para el corte.`);
|
|
206
|
+
if (meta.ownerType !== 'Organization') {
|
|
207
|
+
throw new CLIError(`\`${repo}\` no pertenece a una organización; el corte es para repos de la org.`);
|
|
208
|
+
}
|
|
209
|
+
const login = (await ghOk(deps, ['api', 'user', '--jq', '.login'], 'Leer tu usuario')).trim();
|
|
210
|
+
const role = await deps.gh(['api', `orgs/${owner}/memberships/${login}`, '--jq', '.role']);
|
|
211
|
+
if (role.stdout.trim() !== 'admin') {
|
|
212
|
+
const can = await deps.gh([
|
|
213
|
+
'api',
|
|
214
|
+
`orgs/${owner}`,
|
|
215
|
+
'--jq',
|
|
216
|
+
'.members_can_create_private_repositories',
|
|
217
|
+
]);
|
|
218
|
+
if (can.stdout.trim() !== 'true') {
|
|
219
|
+
throw new CLIError(`La org \`${owner}\` no deja a sus miembros crear repos privados y no eres owner: pide el corte a un owner.`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const fetched = await git(rootDir, ['fetch', '-q', 'origin']);
|
|
223
|
+
if (fetched.exitCode !== 0)
|
|
224
|
+
throw new CLIError(`\`git fetch origin\` falló: ${fetched.stderr}`);
|
|
225
|
+
if ((await git(rootDir, ['status', '--porcelain'])).stdout.trim() !== '') {
|
|
226
|
+
throw new CLIError('Tienes cambios sin commitear. Commitéalos o descártalos antes del corte.');
|
|
227
|
+
}
|
|
228
|
+
const unpushed = (await git(rootDir, ['log', '--branches', '--not', '--remotes', '--oneline'])).stdout.trim();
|
|
229
|
+
if (unpushed !== '') {
|
|
230
|
+
throw new CLIError(`Tienes commits sin pushear; el corte no los llevaría:\n${unpushed}`);
|
|
231
|
+
}
|
|
232
|
+
const mainSha = (await gitOk(rootDir, ['rev-parse', 'origin/main'], 'Leer `origin/main`')).trim();
|
|
233
|
+
const hasDevelop = (await git(rootDir, ['rev-parse', '-q', '--verify', 'origin/develop'])).exitCode === 0;
|
|
234
|
+
const developAhead = hasDevelop
|
|
235
|
+
? (await gitOk(rootDir, ['log', '--oneline', 'origin/main..origin/develop'], '`git log`'))
|
|
236
|
+
.split('\n')
|
|
237
|
+
.filter(Boolean)
|
|
238
|
+
: [];
|
|
239
|
+
if (developAhead.length > 0 && !flags.discardDevelop) {
|
|
240
|
+
throw new CLIError(`\`develop\` tiene ${developAhead.length} commit(s) que no están en \`main\`, y el repo nuevo ` +
|
|
241
|
+
'nace de `main`: se perderían.\n' +
|
|
242
|
+
developAhead.map((l) => ` • ${l}`).join('\n') +
|
|
243
|
+
'\nLlévalos a `main` primero (/deploy), o confirma por escrito que se pierden con `--discard-develop`.');
|
|
244
|
+
}
|
|
245
|
+
const next = `${owner}/${name}-next`;
|
|
246
|
+
const legacy = `${owner}/${name}-legacy`;
|
|
247
|
+
if (!flags.resume) {
|
|
248
|
+
for (const taken of [next, legacy]) {
|
|
249
|
+
if (await ghExists(deps, `repos/${taken}`)) {
|
|
250
|
+
throw new CLIError(`\`${taken}\` ya existe. Renómbralo o bórralo antes del corte.`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { repo, next, legacy, mainSha, defaultBranch: meta.default, developAhead };
|
|
255
|
+
}
|
|
256
|
+
/** The cut. Without `flags.confirm`, returns `null` after the preflight. */
|
|
257
|
+
export async function pruneCutover(rootDir, flags, deps) {
|
|
258
|
+
const file = await statePath(rootDir);
|
|
259
|
+
const prior = flags.resume && existsSync(file)
|
|
260
|
+
? JSON.parse(readFileSync(file, 'utf8'))
|
|
261
|
+
: null;
|
|
262
|
+
if (flags.resume && !prior)
|
|
263
|
+
throw new CLIError('No hay un corte a medias que retomar.');
|
|
264
|
+
const preflight = await cutoverPreflight(rootDir, prior ? { ...flags, repo: prior.repo } : flags, deps);
|
|
265
|
+
if (!flags.confirm)
|
|
266
|
+
return { preflight, result: null };
|
|
267
|
+
const { repo, next, legacy } = preflight;
|
|
268
|
+
const state = prior ?? {
|
|
269
|
+
repo,
|
|
270
|
+
mainSha: preflight.mainSha,
|
|
271
|
+
defaultBranch: preflight.defaultBranch,
|
|
272
|
+
done: [],
|
|
273
|
+
};
|
|
274
|
+
const save = () => writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
275
|
+
const mark = (step) => {
|
|
276
|
+
state.done.push(step);
|
|
277
|
+
save();
|
|
278
|
+
};
|
|
279
|
+
const warnings = [];
|
|
280
|
+
let snapshot = '(retomado)';
|
|
281
|
+
if (!state.done.includes('pushed')) {
|
|
282
|
+
const tmp = mkdtempSync(path.join(tmpdir(), 'tk-prune-snapshot-'));
|
|
283
|
+
try {
|
|
284
|
+
const outDir = path.join(tmp, 'repo');
|
|
285
|
+
const built = await buildSnapshot(rootDir, state.mainSha, flags.tier, outDir, `chore: start ${splitRepo(repo).name} without the methodology (from main ${state.mainSha.slice(0, 7)})`);
|
|
286
|
+
snapshot = built.sha.slice(0, 7);
|
|
287
|
+
const offenders = await snapshotOffenders(outDir, built.tier);
|
|
288
|
+
if (offenders.length > 0) {
|
|
289
|
+
throw new CLIError(`El snapshot trae ${offenders.length} ruta(s) restringida(s); no subí nada:\n` +
|
|
290
|
+
offenders.map((p) => ` • ${p}`).join('\n'));
|
|
291
|
+
}
|
|
292
|
+
await ghOk(deps, ['repo', 'create', next, '--private'], `Crear \`${next}\``);
|
|
293
|
+
save();
|
|
294
|
+
await gitOk(outDir, ['push', '-q', deps.pushUrl(next), 'HEAD:refs/heads/main', 'HEAD:refs/heads/develop'], `Subir el snapshot a \`${next}\``);
|
|
295
|
+
mark('pushed');
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (!state.done.includes('renamed-legacy')) {
|
|
302
|
+
const live = (await ghOk(deps, ['api', `repos/${repo}/branches/main`, '--jq', '.commit.sha'], 'Releer `main`')).trim();
|
|
303
|
+
if (live !== state.mainSha) {
|
|
304
|
+
throw new CLIError(`\`main\` de \`${repo}\` cambió durante el corte (${state.mainSha.slice(0, 7)} → ${live.slice(0, 7)}). ` +
|
|
305
|
+
`No renombré nada; \`${next}\` quedó creado. Bórralo y vuelve a empezar.`);
|
|
306
|
+
}
|
|
307
|
+
await ghOk(deps, ['api', '-X', 'PATCH', `repos/${repo}`, '-f', `name=${splitRepo(legacy).name}`], `Renombrar \`${repo}\` a \`${legacy}\``);
|
|
308
|
+
mark('renamed-legacy');
|
|
309
|
+
}
|
|
310
|
+
if (!state.done.includes('renamed-new')) {
|
|
311
|
+
const renamed = await deps.gh([
|
|
312
|
+
'api',
|
|
313
|
+
'-X',
|
|
314
|
+
'PATCH',
|
|
315
|
+
`repos/${next}`,
|
|
316
|
+
'-f',
|
|
317
|
+
`name=${splitRepo(repo).name}`,
|
|
318
|
+
]);
|
|
319
|
+
if (renamed.exitCode !== 0) {
|
|
320
|
+
const back = await deps.gh([
|
|
321
|
+
'api',
|
|
322
|
+
'-X',
|
|
323
|
+
'PATCH',
|
|
324
|
+
`repos/${legacy}`,
|
|
325
|
+
'-f',
|
|
326
|
+
`name=${splitRepo(repo).name}`,
|
|
327
|
+
]);
|
|
328
|
+
state.done = state.done.filter((s) => s !== 'renamed-legacy');
|
|
329
|
+
save();
|
|
330
|
+
throw new CLIError(`No pude renombrar \`${next}\` a \`${repo}\` (${renamed.stderr.trim()}). ` +
|
|
331
|
+
(back.exitCode === 0
|
|
332
|
+
? `Regresé \`${legacy}\` a \`${repo}\`; el original quedó como estaba.`
|
|
333
|
+
: `🔴 Tampoco pude regresar \`${legacy}\` a \`${repo}\`: hazlo a mano en GitHub.`));
|
|
334
|
+
}
|
|
335
|
+
mark('renamed-new');
|
|
336
|
+
}
|
|
337
|
+
if (!state.done.includes('settings')) {
|
|
338
|
+
const tries = [
|
|
339
|
+
[
|
|
340
|
+
['api', '-X', 'PATCH', `repos/${repo}`, '-f', `default_branch=${state.defaultBranch}`],
|
|
341
|
+
`rama default \`${state.defaultBranch}\``,
|
|
342
|
+
],
|
|
343
|
+
[
|
|
344
|
+
['api', '-X', 'PATCH', `repos/${repo}`, '-F', 'allow_forking=true'],
|
|
345
|
+
'permitir forks en el repo',
|
|
346
|
+
],
|
|
347
|
+
[
|
|
348
|
+
[
|
|
349
|
+
'api',
|
|
350
|
+
'-X',
|
|
351
|
+
'PUT',
|
|
352
|
+
`repos/${repo}/actions/permissions/fork-pr-workflows-private-repos`,
|
|
353
|
+
'-F',
|
|
354
|
+
'run_workflows_from_fork_pull_requests=false',
|
|
355
|
+
],
|
|
356
|
+
'no correr workflows de PRs de forks',
|
|
357
|
+
],
|
|
358
|
+
];
|
|
359
|
+
for (const [args, what] of tries) {
|
|
360
|
+
const r = await deps.gh(args);
|
|
361
|
+
if (r.exitCode !== 0)
|
|
362
|
+
warnings.push(`No se aplicó: ${what} (${r.stderr.trim()}). Hazlo en GitHub.`);
|
|
363
|
+
}
|
|
364
|
+
mark('settings');
|
|
365
|
+
}
|
|
366
|
+
const notMigrated = await listNotMigrated(legacy, deps);
|
|
367
|
+
rmSync(file, { force: true });
|
|
368
|
+
return { preflight, result: { repo, legacy, snapshot, notMigrated, warnings } };
|
|
369
|
+
}
|
|
370
|
+
/** What lives on the old repo and does not travel with the snapshot (read-only). */
|
|
371
|
+
async function listNotMigrated(legacy, deps) {
|
|
372
|
+
const probes = [
|
|
373
|
+
[
|
|
374
|
+
`repos/${legacy}/actions/variables`,
|
|
375
|
+
'[.variables[].name] | join(", ")',
|
|
376
|
+
'Variables de GitHub Actions',
|
|
377
|
+
],
|
|
378
|
+
[`repos/${legacy}/environments`, '[.environments[].name] | join(", ")', 'Environments'],
|
|
379
|
+
[`repos/${legacy}/hooks`, 'length', 'Webhooks'],
|
|
380
|
+
[`repos/${legacy}/keys`, 'length', 'Deploy keys'],
|
|
381
|
+
[`repos/${legacy}/issues?state=open&per_page=100`, 'length', 'Issues y PRs abiertos'],
|
|
382
|
+
];
|
|
383
|
+
const out = [];
|
|
384
|
+
for (const [apiPath, jq, label] of probes) {
|
|
385
|
+
const r = await deps.gh(['api', apiPath, '--jq', jq]);
|
|
386
|
+
const value = r.stdout.trim();
|
|
387
|
+
if (r.exitCode !== 0)
|
|
388
|
+
out.push(`${label}: no se pudo leer — revísalo en \`${legacy}\``);
|
|
389
|
+
else if (value !== '' && value !== '0')
|
|
390
|
+
out.push(`${label}: ${value}`);
|
|
391
|
+
}
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
// Invite
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
/** Invite an outside collaborator with read access, after checking they are not a member. */
|
|
398
|
+
export async function pruneInvite(repo, user, deps) {
|
|
399
|
+
const { owner } = splitRepo(repo);
|
|
400
|
+
if (!/^[A-Za-z0-9-]+$/.test(user))
|
|
401
|
+
throw new CLIError(`\`${user}\` no es un usuario de GitHub válido.`);
|
|
402
|
+
if (!(await ghExists(deps, `repos/${repo}/contents/${PRUNE_MARKER_PATH}`))) {
|
|
403
|
+
throw new CLIError(`\`${repo}\` no está podado (falta \`${PRUNE_MARKER_PATH}\` en su rama default). ` +
|
|
404
|
+
'Un colaborador externo vería la metodología: corre el corte primero.');
|
|
405
|
+
}
|
|
406
|
+
if (await ghExists(deps, `orgs/${owner}/members/${user}`)) {
|
|
407
|
+
throw new CLIError(`\`${user}\` es miembro de \`${owner}\`: los socios clonan y corren \`factory update\`, no se invitan como colaboradores.`);
|
|
408
|
+
}
|
|
409
|
+
const r = await deps.gh([
|
|
410
|
+
'api',
|
|
411
|
+
'-X',
|
|
412
|
+
'PUT',
|
|
413
|
+
`repos/${repo}/collaborators/${user}`,
|
|
414
|
+
'-f',
|
|
415
|
+
'permission=pull',
|
|
416
|
+
]);
|
|
417
|
+
if (r.exitCode !== 0) {
|
|
418
|
+
if (/403|Forbidden|must have admin/i.test(r.stderr)) {
|
|
419
|
+
throw new CLIError(`GitHub rechazó la invitación (403). La org \`${owner}\` probablemente no deja a los admins de repo ` +
|
|
420
|
+
'invitar colaboradores externos: pídeselo a un owner de la org.');
|
|
421
|
+
}
|
|
422
|
+
throw new CLIError(`No se pudo invitar a \`${user}\`: ${r.stderr}`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/** Close the legacy repo once the new one has deployed to Production, then audit it. */
|
|
426
|
+
export async function pruneFinish(repo, deps) {
|
|
427
|
+
const { owner, name } = splitRepo(repo);
|
|
428
|
+
const legacy = `${owner}/${name}-legacy`;
|
|
429
|
+
const ids = (await ghOk(deps, ['api', `repos/${repo}/deployments?environment=Production&per_page=20`, '--jq', '.[].id'], 'Leer los deployments'))
|
|
430
|
+
.split('\n')
|
|
431
|
+
.filter(Boolean);
|
|
432
|
+
let deployed = false;
|
|
433
|
+
for (const id of ids) {
|
|
434
|
+
const state = await deps.gh([
|
|
435
|
+
'api',
|
|
436
|
+
`repos/${repo}/deployments/${id}/statuses?per_page=1`,
|
|
437
|
+
'--jq',
|
|
438
|
+
'.[0].state',
|
|
439
|
+
]);
|
|
440
|
+
if (state.stdout.trim() === 'success') {
|
|
441
|
+
deployed = true;
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (!deployed) {
|
|
446
|
+
throw new CLIError(`\`${repo}\` no tiene un deployment de Production exitoso todavía. Reconecta Vercel al repo nuevo, ` +
|
|
447
|
+
'haz el primer deploy y vuelve a correr `factory prune finish`.');
|
|
448
|
+
}
|
|
449
|
+
// The legacy repo can no longer be edited once archived, so everything goes first.
|
|
450
|
+
await ghOk(deps, ['api', '-X', 'PATCH', `repos/${legacy}`, '-F', 'allow_forking=false'], `Apagar los forks de \`${legacy}\``);
|
|
451
|
+
const outside = (await ghOk(deps, [
|
|
452
|
+
'api',
|
|
453
|
+
`repos/${legacy}/collaborators?affiliation=outside&per_page=100`,
|
|
454
|
+
'--jq',
|
|
455
|
+
'.[].login',
|
|
456
|
+
], `Listar colaboradores de \`${legacy}\``))
|
|
457
|
+
.split('\n')
|
|
458
|
+
.filter(Boolean);
|
|
459
|
+
for (const login of outside) {
|
|
460
|
+
await ghOk(deps, ['api', '-X', 'DELETE', `repos/${legacy}/collaborators/${login}`], `Quitar a \`${login}\` de \`${legacy}\``);
|
|
461
|
+
}
|
|
462
|
+
await ghOk(deps, ['api', '-X', 'PATCH', `repos/${legacy}`, '-F', 'archived=true'], `Archivar \`${legacy}\``);
|
|
463
|
+
return { removedCollaborators: outside, audit: await pruneAudit(repo, deps) };
|
|
464
|
+
}
|