rvlib-shipkit 0.38.0 → 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/CHANGELOG.md +371 -16
- package/README.md +26 -14
- package/package.json +2 -2
- package/src/assets/gen/comfyts-gen.ts +27 -10
- package/src/assets/gen/generate-roll.ts +1 -1
- package/src/assets/kit-config.ts +6 -6
- package/src/assets/run-assets.ts +14 -10
- package/src/assets/server/studio-server.ts +6 -5
- package/src/check.ts +343 -0
- package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
- package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
- package/src/checks/SK003-claude-md-shape.check.ts +66 -0
- package/src/checks/SK004-gitignore-policy.check.ts +309 -0
- package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
- package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
- package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
- package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
- package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
- package/src/checks/SK010-undeclared-unit.check.ts +111 -0
- package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
- package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
- package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
- package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
- package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
- package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
- package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
- package/src/checks/SK018-test-files-run.check.ts +28 -0
- package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
- package/src/checks/SK020-self-spawning-check.check.ts +147 -0
- package/src/checks/contracts.check.ts +20 -0
- package/src/cli/cmd_Asks.ts +3 -2
- package/src/cli/cmd_Assets.ts +2 -2
- package/src/cli/cmd_Bump.ts +1 -1
- package/src/cli/cmd_Check.ts +6 -6
- package/src/cli/cmd_Checks.ts +95 -0
- package/src/cli/cmd_Ci.ts +48 -13
- package/src/cli/cmd_Commands.ts +2 -6
- package/src/cli/cmd_ConfigKeys.ts +69 -0
- package/src/cli/cmd_Contracts.ts +1 -1
- package/src/cli/cmd_Fleet.ts +6 -2
- package/src/cli/cmd_Hooks.ts +1 -1
- package/src/cli/cmd_Keys.ts +43 -18
- package/src/cli/cmd_Mv.ts +2 -2
- package/src/cli/cmd_Prose.ts +10 -1
- package/src/cli/cmd_Releases.ts +2 -2
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli/cmd_Start.ts +6 -6
- package/src/cli/cmd_Status.ts +25 -0
- package/src/cli/cmd_Tui.ts +2 -2
- package/src/cli.ts +7 -4
- package/src/collections/actions.ts +4 -4
- package/src/collections/checks.ts +258 -0
- package/src/collections/collection-types.ts +254 -1
- package/src/collections/comfy.ts +23 -0
- package/src/collections/md-body.ts +14 -0
- package/src/collections/pings.ts +38 -12
- package/src/collections/posts.ts +19 -3
- package/src/collections/providers.ts +198 -91
- package/src/collections/reflections.ts +41 -0
- package/src/collections/state-rules.ts +73 -0
- package/src/collections/test-time.ts +288 -0
- package/src/collections/tests.ts +181 -0
- package/src/collections/wikis.ts +66 -0
- package/src/comfy/comfy-panel.ts +292 -0
- package/src/contracts/apply-contracts.ts +17 -12
- package/src/contracts/contract-types.ts +16 -4
- package/src/contracts/contracts-engine.ts +78 -42
- package/src/contracts/manifests/discord.contract.ts +6 -6
- package/src/contracts/manifests/identity.contract.ts +3 -3
- package/src/contracts/manifests/itch.contract.ts +12 -15
- package/src/contracts/manifests/social.contract.ts +5 -5
- package/src/contracts/manifests/steam.contract.ts +16 -16
- package/src/contracts/run-contracts.ts +4 -3
- package/src/core/attach.ts +96 -0
- package/src/core/canonical-scripts.ts +25 -0
- package/src/core/check-ctx.ts +204 -14
- package/src/core/check-file-names.ts +112 -0
- package/src/core/check-pass.ts +84 -0
- package/src/core/check-runner.ts +796 -0
- package/src/core/config-keys.ts +256 -0
- package/src/core/config-rewrite.ts +77 -0
- package/src/core/discover-checks.ts +166 -90
- package/src/core/discover-legacy-checks.ts +26 -0
- package/src/core/doc-block.ts +13 -0
- package/src/core/floor-resolve.ts +264 -0
- package/src/core/floor.ts +20 -137
- package/src/core/git-bin.ts +24 -0
- package/src/core/git-root.ts +108 -0
- package/src/core/instance.ts +195 -0
- package/src/core/jobs.ts +118 -0
- package/src/core/layout.ts +52 -0
- package/src/core/legacy-shapes.ts +207 -0
- package/src/core/lib-root.ts +5 -0
- package/src/core/locked-write.ts +110 -39
- package/src/core/machine-commit.ts +5 -4
- package/src/core/migrate-layout.ts +557 -0
- package/src/core/motherhome.ts +2 -8
- package/src/core/nesting.ts +4 -12
- package/src/core/one-way-in-policy.ts +73 -17
- package/src/core/outcome-words.ts +45 -0
- package/src/core/package-manager.ts +86 -0
- package/src/core/parallel.ts +21 -0
- package/src/core/process-tree.ts +44 -0
- package/src/core/raw-access.ts +93 -0
- package/src/core/read-set.ts +152 -0
- package/src/core/run-check.ts +155 -213
- package/src/core/run-ci.ts +153 -189
- package/src/core/run-fix.ts +96 -0
- package/src/core/run-fixers.ts +53 -30
- package/src/core/self-update.ts +9 -3
- package/src/core/slots.ts +83 -0
- package/src/core/spawn-timeout.ts +34 -3
- package/src/core/when-block.ts +401 -0
- package/src/core/when.ts +135 -0
- package/src/core/write-status.ts +5 -2
- package/src/hooks/leak-guard.ts +10 -8
- package/src/hooks/one-way-in-guard.ts +13 -5
- package/src/hooks/root-identity.ts +2 -1
- package/src/hooks/run-hooks.ts +35 -23
- package/src/init-repo.ts +39 -23
- package/src/ledger/check-stats.ts +155 -0
- package/src/ledger/check-table.ts +179 -0
- package/src/ledger/db.ts +255 -0
- package/src/ledger/gate-checks.ts +433 -0
- package/src/ledger/git-key.ts +116 -77
- package/src/ledger/git-scan.ts +96 -35
- package/src/ledger/ledger.ts +116 -42
- package/src/ledger/live-runs.ts +111 -0
- package/src/ledger/live-timeline.ts +97 -0
- package/src/ledger/repo-view.ts +73 -0
- package/src/ledger/run-log.ts +448 -0
- package/src/ledger/run-timeline.ts +173 -0
- package/src/ledger/store.ts +23 -12
- package/src/ledger/suite-log.ts +161 -0
- package/src/ledger/toolchain-identity.ts +7 -1
- package/src/ledger/world-cache.ts +111 -0
- package/src/mv/mv-md.ts +16 -6
- package/src/problems/extract.ts +182 -0
- package/src/problems/problem-board.ts +37 -0
- package/src/problems/problem-line.ts +77 -0
- package/src/prose/prose-view.ts +90 -0
- package/src/prose/run-prose.ts +60 -19
- package/src/prose/ste.ts +140 -24
- package/src/reflections/reflections-queue.ts +81 -0
- package/src/release/git-facts.ts +15 -9
- package/src/release/run-bump.ts +6 -5
- package/src/release/run-release.ts +25 -11
- package/src/release/run-releases.ts +2 -2
- package/src/release/unit-gate.ts +7 -54
- package/src/release/unit-resolve.ts +282 -0
- package/src/release/units.ts +33 -217
- package/src/repo-config.ts +155 -85
- package/src/rules/rule.ts +36 -96
- package/src/rules/untrack-generated.ts +5 -4
- package/src/scaffold/files/vscode-tasks.json +10 -31
- package/src/scaffold/jsonc-spans.ts +113 -0
- package/src/scaffold/project-scaffold.ts +16 -43
- package/src/scan.ts +98 -0
- package/src/serve/gate-trigger.ts +148 -0
- package/src/serve/health-schedule.ts +102 -0
- package/src/serve/hub-app/chart-view.ts +179 -0
- package/src/serve/hub-app/check-view.tsx +345 -0
- package/src/serve/hub-app/check-words.ts +164 -0
- package/src/serve/hub-app/checks-svc.ts +280 -0
- package/src/serve/hub-app/clock-svc.ts +33 -0
- package/src/serve/hub-app/col-card.tsx +23 -39
- package/src/serve/hub-app/cols-svc.ts +44 -20
- package/src/serve/hub-app/comfy-svc.ts +171 -0
- package/src/serve/hub-app/comfy-view.tsx +81 -0
- package/src/serve/hub-app/dur.ts +41 -0
- package/src/serve/hub-app/hash-route.ts +121 -0
- package/src/serve/hub-app/header-bar.tsx +117 -0
- package/src/serve/hub-app/health-svc.ts +23 -28
- package/src/serve/hub-app/help.tsx +29 -0
- package/src/serve/hub-app/hub-app.tsx +122 -119
- package/src/serve/hub-app/hub-st.ts +115 -42
- package/src/serve/hub-app/hub-types.ts +117 -2
- package/src/serve/hub-app/hub.css +534 -192
- package/src/serve/hub-app/index.html +2 -0
- package/src/serve/hub-app/live-svc.ts +174 -0
- package/src/serve/hub-app/md.ts +15 -0
- package/src/serve/hub-app/media-svc.ts +4 -1
- package/src/serve/hub-app/media-view.tsx +15 -0
- package/src/serve/hub-app/next-run.ts +68 -0
- package/src/serve/hub-app/project-gutter.css +65 -0
- package/src/serve/hub-app/project-gutter.tsx +128 -0
- package/src/serve/hub-app/projects-svc.ts +226 -0
- package/src/serve/hub-app/prose-hits-st.ts +124 -0
- package/src/serve/hub-app/prose-hits-view.tsx +176 -0
- package/src/serve/hub-app/prose-hits.css +21 -0
- package/src/serve/hub-app/run-strip.ts +47 -0
- package/src/serve/hub-app/runs-tabs.ts +24 -0
- package/src/serve/hub-app/test-view.tsx +107 -0
- package/src/serve/hub-app/tests-svc.ts +62 -0
- package/src/serve/hub-app/tests-view.tsx +238 -0
- package/src/serve/hub-app/time-ago.tsx +34 -0
- package/src/serve/hub-app/timeline-chart.tsx +274 -0
- package/src/serve/hub-app/timeline-geometry.ts +314 -0
- package/src/serve/hub-app/timeline-svc.ts +62 -0
- package/src/serve/hub-app/timeline-view.tsx +245 -0
- package/src/serve/hub-app/tree-build.ts +410 -0
- package/src/serve/hub-app/tree-row.tsx +42 -0
- package/src/serve/hub-app/tree-svc.ts +174 -0
- package/src/serve/hub-app/wikis-view.tsx +53 -0
- package/src/serve/other-repos.ts +79 -0
- package/src/serve/project-gutter-route.ts +350 -0
- package/src/serve/project-gutter.ts +449 -0
- package/src/serve/run-serve.ts +245 -16
- package/src/serve/same-origin.ts +14 -0
- package/src/serve/serve-hub.ts +213 -18
- package/src/shipkit.ts +19 -12
- package/src/smoke/run-smoke.ts +3 -2
- package/src/start-repo.ts +75 -36
- package/src/suite/judge-suites.ts +216 -18
- package/src/suite/kept-timings.ts +58 -0
- package/src/suite/runner-shapes.ts +88 -0
- package/src/suite/suite-cli.ts +147 -24
- package/src/suite/suite-cpu-preload.ts +92 -0
- package/src/suite/test-units.ts +243 -0
- package/src/ts/config.ts +4 -69
- package/src/ts/jsonc.ts +69 -0
- package/src/ts/lex.ts +143 -0
- package/src/ts/rename.ts +73 -10
- package/src/ts/source-graph.ts +28 -8
- package/src/ts/tsconfig-projects.ts +6 -97
- package/src/ts/tsconfig-resolve.ts +108 -0
- package/src/tui/build-steps-panel.ts +61 -0
- package/src/tui/checks-panel.ts +24 -16
- package/src/tui/run-tui.ts +2 -2
- package/src/tui/tui-panel.ts +5 -4
- package/src/wiki/graph-colors.ts +68 -0
- package/src/wiki/graph-mount.ts +7 -9
- package/src/wiki/graph-page.ts +42 -25
- package/src/wiki/graph-positions.ts +105 -0
- package/src/wiki/run-wiki.ts +1 -1
- package/src/wiki/serve-graph.ts +33 -3
- package/src/cli/cmd_Rules.ts +0 -51
- package/src/cli/cmd_Units.ts +0 -82
- package/src/collections/graphs.ts +0 -32
- package/src/contracts/contracts-once.ts +0 -43
- package/src/core/check-suite.ts +0 -302
- package/src/core/repair-contract-dirs.ts +0 -136
- package/src/core/task-runner.ts +0 -339
- package/src/ledger/gate-units.ts +0 -138
- package/src/ledger/unit-table.ts +0 -105
- package/src/rules/SK002-contracts-fulfillness.ts +0 -24
- package/src/rules/SK003-claude-md-shape.ts +0 -70
- package/src/rules/SK004-gitignore-policy.ts +0 -238
- package/src/rules/SK010-undeclared-release-unit.ts +0 -127
- package/src/rules/rule-registry.ts +0 -64
- package/src/rules/run-fix.ts +0 -72
- package/src/tui/tasks-panel.ts +0 -60
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
// layout-sweep: skip
|
|
2
|
+
// Carry a repo from the old root layout to `.shipkit/` (core/layout.ts), and
|
|
3
|
+
// repair a contract folder typed under a wrong name, in ONE pass and ONE commit.
|
|
4
|
+
//
|
|
5
|
+
// Nothing here is a decision, so no human is asked: `.rv-<x>/` became
|
|
6
|
+
// `.shipkit/<x>/`, `asks/`, `hooks/` and the `tmp/` scratch moved in, the
|
|
7
|
+
// generated files moved out of the root. A typo (`rv-reflections`, `.rv-reflection`) is the same
|
|
8
|
+
// move, because a folder under a name no scanner reads is silence.
|
|
9
|
+
//
|
|
10
|
+
// The invariant a reader breaks first: A MOVE NEVER CHANGES WHAT GIT TRACKS.
|
|
11
|
+
// A tracked file stays tracked under its new name (added with -f, because
|
|
12
|
+
// `.shipkit/*` is ignored and only some children are re-included), an
|
|
13
|
+
// untracked file moves on disk and stays untracked. The one exception is a
|
|
14
|
+
// typo folder, whose files git only tracked because the missing dot kept the
|
|
15
|
+
// ignore rule from matching: those leave the index when the target is ignored.
|
|
16
|
+
//
|
|
17
|
+
// A file carrying the SWEEP_SKIP marker is never rewritten: it names the old
|
|
18
|
+
// layout on purpose (this module, its tests, the legacy lines of SK004).
|
|
19
|
+
//
|
|
20
|
+
// Refusals, every one retried by the next run and printed by the caller:
|
|
21
|
+
// a target that already holds files (merging decides which file wins), a
|
|
22
|
+
// tracked path to move or rewrite that carries uncommitted edits (the commit
|
|
23
|
+
// would take them along), a merge or rebase in progress, a nested repo.
|
|
24
|
+
import { accessSync, constants, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'fs'
|
|
25
|
+
import { homedir } from 'os'
|
|
26
|
+
import { dirname, join, posix } from 'path'
|
|
27
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
28
|
+
import { gitBin } from './git-bin.ts'
|
|
29
|
+
import { probeGitRoot } from './git-root.ts'
|
|
30
|
+
import { LAYOUT, MACHINE_PRIVATE, SHIPKIT_DIR } from './layout.ts'
|
|
31
|
+
import { machineCommit } from './machine-commit.ts'
|
|
32
|
+
import { nestedIn } from './nesting.ts'
|
|
33
|
+
|
|
34
|
+
/** the prefix every contract folder carried before `.shipkit/` */
|
|
35
|
+
export const LEGACY_CONTRACT_PREFIX = '.rv-'
|
|
36
|
+
|
|
37
|
+
export const MIGRATION_MESSAGE = 'chore(machine): move the shipkit folders under .shipkit/'
|
|
38
|
+
|
|
39
|
+
/** the contract names a typo is matched against */
|
|
40
|
+
const KNOWN = ['identity', 'assets', 'comfy', 'private', 'reflections', 'social', 'journal', 'domain', 'itch', 'steam', 'discord'] as const
|
|
41
|
+
|
|
42
|
+
/** root files shipkit used to generate on every run */
|
|
43
|
+
export const LEGACY_GENERATED = ['STATUS.md', 'STATUS.md.lock', 'STATUS.full.md', 'STATUS.full.md.lock'] as const
|
|
44
|
+
|
|
45
|
+
/** the root scratch folder, `.shipkit/tmp/` now */
|
|
46
|
+
export const LEGACY_TMP = 'tmp'
|
|
47
|
+
|
|
48
|
+
const LEGACY_CACHE = `${LEGACY_TMP}/.shipkit`
|
|
49
|
+
|
|
50
|
+
/** a lease a killed run left in the old scratch: never worth a refusal, the
|
|
51
|
+
* one at the target is the live one */
|
|
52
|
+
const STALE_LEASE = /^shipkit-[^/]+\.lock$/
|
|
53
|
+
|
|
54
|
+
/** what the old policy block kept out of git, rendered by SK004 while a repo
|
|
55
|
+
* still holds `.rv-*` folders so none of them is exposed before it moves */
|
|
56
|
+
export const LEGACY_VENUES = ['.rv-itch/', '.rv-steam/', '.rv-discord/']
|
|
57
|
+
export const LEGACY_IGNORED = ['.rv-private/', '.rv-reflections/', '.rv-social/', '.rv-journal/', '.rv-domain/', '.rv-assets/out/', ...LEGACY_VENUES]
|
|
58
|
+
|
|
59
|
+
/** the shape of a name, ignoring the leading dots, the separators, the case and
|
|
60
|
+
* a trailing plural. `rv-reflections`, `rv_reflection` and `RV-Reflections`
|
|
61
|
+
* are the same intent, `reflections` and `docs` are not */
|
|
62
|
+
const shapeOf = (s: string): string => s.toLowerCase().replace(/^\.+/, '').replace(/[-_.]/g, '').replace(/s$/, '')
|
|
63
|
+
|
|
64
|
+
const LEGACY_NAME = /^\.rv-([a-z0-9][a-z0-9-]*)$/
|
|
65
|
+
|
|
66
|
+
export type MoveKind = 'contract' | 'typo' | 'asks' | 'hooks' | 'cache' | 'tmp'
|
|
67
|
+
/** a move onto a folder that already exists, whose files collide nowhere.
|
|
68
|
+
* `drop`: stale leases left behind rather than moved over the live ones */
|
|
69
|
+
export type MergeMove = LayoutMove & { merge: true; drop?: string[] }
|
|
70
|
+
export type LayoutMove = { from: string; to: string; kind: MoveKind }
|
|
71
|
+
export type LayoutRefusal = { from: string; to: string; reason: string }
|
|
72
|
+
export type LayoutPlan = { moves: LayoutMove[]; refusals: LayoutRefusal[] }
|
|
73
|
+
|
|
74
|
+
export type PlanInput = {
|
|
75
|
+
/** directory names at the repo root */
|
|
76
|
+
rootDirs: string[]
|
|
77
|
+
/** names already under `.shipkit/` */
|
|
78
|
+
shipkitEntries?: string[]
|
|
79
|
+
/** repo.config sets `asks.dir`, so the queue lives where the repo said */
|
|
80
|
+
asksDirSet?: boolean
|
|
81
|
+
/** core.hooksPath points at `hooks/` and its pre-commit is shipkit's */
|
|
82
|
+
hooksOurs?: boolean
|
|
83
|
+
/** `tmp/.shipkit` exists */
|
|
84
|
+
legacyCache?: boolean
|
|
85
|
+
/** a clone or a worktree sits somewhere under `tmp/` */
|
|
86
|
+
tmpHoldsRepo?: boolean
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** a `.git` anywhere under a folder: a clone or a linked worktree, whose
|
|
90
|
+
* registration names its path, so moving it breaks it */
|
|
91
|
+
export function holdsRepo(dir: string): boolean {
|
|
92
|
+
try {
|
|
93
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
94
|
+
if (e.name === '.git') return true
|
|
95
|
+
if (e.isDirectory() && e.name !== 'node_modules' && holdsRepo(join(dir, e.name))) return true
|
|
96
|
+
}
|
|
97
|
+
} catch { /* unreadable: the move itself reports it */ }
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** PURE: what to move, given what sits in a repo root */
|
|
102
|
+
export function planLayoutMigration(i: PlanInput): LayoutPlan {
|
|
103
|
+
const taken = new Set((i.shipkitEntries ?? []).map((e) => `${SHIPKIT_DIR}/${e}`))
|
|
104
|
+
const moves: LayoutMove[] = []
|
|
105
|
+
const refusals: LayoutRefusal[] = []
|
|
106
|
+
const add = (from: string, to: string, kind: MoveKind): void => {
|
|
107
|
+
const twin = moves.find((m) => m.to === to)
|
|
108
|
+
if (taken.has(to)) {
|
|
109
|
+
refusals.push({ from, to, reason: `${twin != null ? `${twin.from}/ moves there too` : 'the target already exists'}, and merging them decides which file wins` })
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
taken.add(to)
|
|
113
|
+
moves.push({ from, to, kind })
|
|
114
|
+
}
|
|
115
|
+
// exact legacy names first, so a typo beside its correct twin is the one refused
|
|
116
|
+
const dirs = [...i.rootDirs].sort((a, b) => Number(!LEGACY_NAME.test(a)) - Number(!LEGACY_NAME.test(b)) || a.localeCompare(b))
|
|
117
|
+
for (const d of dirs) {
|
|
118
|
+
const m = LEGACY_NAME.exec(d)
|
|
119
|
+
// a legacy name one plural or separator off a known one lands on the known one
|
|
120
|
+
if (m != null) { add(d, `${SHIPKIT_DIR}/${KNOWN.find((k) => shapeOf(k) === shapeOf(m[1]!)) ?? m[1]}`, 'contract'); continue }
|
|
121
|
+
if (d === SHIPKIT_DIR) continue
|
|
122
|
+
const typo = KNOWN.find((k) => shapeOf(d) === shapeOf(`rv${k}`))
|
|
123
|
+
if (typo != null) add(d, `${SHIPKIT_DIR}/${typo}`, 'typo')
|
|
124
|
+
}
|
|
125
|
+
if (i.rootDirs.includes('asks') && i.asksDirSet !== true) add('asks', LAYOUT.asks, 'asks')
|
|
126
|
+
if (i.rootDirs.includes('hooks') && i.hooksOurs === true) add('hooks', LAYOUT.hooks, 'hooks')
|
|
127
|
+
// a cache is never worth a refusal: a warm one at the target wins. It
|
|
128
|
+
// leaves the scratch before the scratch itself moves
|
|
129
|
+
if (i.legacyCache === true) {
|
|
130
|
+
if (taken.has(LAYOUT.cache)) moves.push({ from: LEGACY_CACHE, to: LAYOUT.cache, kind: 'cache' })
|
|
131
|
+
else add(LEGACY_CACHE, LAYOUT.cache, 'cache')
|
|
132
|
+
}
|
|
133
|
+
// every repo's scratch, shipkit's own logs and the repo's plans alike
|
|
134
|
+
if (i.rootDirs.includes(LEGACY_TMP)) {
|
|
135
|
+
if (i.tmpHoldsRepo === true) refusals.push({ from: LEGACY_TMP, to: LAYOUT.tmp, reason: 'a git checkout sits under it, and a moved worktree loses its registration' })
|
|
136
|
+
else add(LEGACY_TMP, LAYOUT.tmp, 'tmp')
|
|
137
|
+
}
|
|
138
|
+
return { moves, refusals }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** the text rewrite a contract move implies: `.rv-<x>` → its target
|
|
142
|
+
* (`.shipkit/<x>` unless `targets` says otherwise), never a machine-global
|
|
143
|
+
* `~/.rv-<x>`, never a longer name that merely starts the same.
|
|
144
|
+
*
|
|
145
|
+
* `at` is the file's own path before and after the move. A relative path
|
|
146
|
+
* (`../.rv-identity/brand.ts`) that resolved to a moved folder is recomputed
|
|
147
|
+
* from the file's new place, so two folders that moved together still find
|
|
148
|
+
* each other (`../identity/brand.ts`). */
|
|
149
|
+
export function sweepText(text: string, targets: Record<string, string> | 'all', at?: { from: string; to: string }): string {
|
|
150
|
+
const names = targets === 'all' ? null : Object.keys(targets)
|
|
151
|
+
if (names != null && names.length === 0) return text
|
|
152
|
+
const alt = names == null ? '[a-z0-9][a-z0-9-]*' : names.map((n) => n.slice(LEGACY_CONTRACT_PREFIX.length).replace(/[-]/g, '\\-')).join('|')
|
|
153
|
+
const re = new RegExp(`(?<!~/)(?<![\\w.-])((?:\\.\\.?/)*)\\.rv-(${alt})(?![\\w-])`, 'g')
|
|
154
|
+
return text.replace(re, (_m, prefix: string, x: string) => {
|
|
155
|
+
const legacy = `${LEGACY_CONTRACT_PREFIX}${x}`
|
|
156
|
+
const target = (targets === 'all' ? undefined : targets[legacy]) ?? `${SHIPKIT_DIR}/${x}`
|
|
157
|
+
if (prefix === '' || at == null || posix.normalize(posix.join(posix.dirname(at.from), prefix + legacy)) !== legacy) return prefix + target
|
|
158
|
+
return relativeFrom(at.to, target)
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** a relative specifier from one repo path to another, always starting with `./` or `../` */
|
|
163
|
+
const relativeFrom = (file: string, target: string): string => {
|
|
164
|
+
const rel = posix.relative(posix.dirname(file), target)
|
|
165
|
+
return rel.startsWith('../') ? rel : `./${rel}`
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** the same for a path built at run time from the file's own folder:
|
|
169
|
+
* `join(import.meta.dir, '..', 'public/x.png')` resolved to the repo root from
|
|
170
|
+
* `.rv-identity/`, and must climb one more level from `.shipkit/identity/`.
|
|
171
|
+
* Only the leading `'.'` and `'..'` arguments are rewritten */
|
|
172
|
+
function rebaseDirJoins(text: string, at: { from: string; to: string }, moves: LayoutMove[]): string {
|
|
173
|
+
return text.replace(/\b(join|resolve)\((\s*)(import\.meta\.dirname|import\.meta\.dir|__dirname)((?:\s*,\s*(['"])\.\.?\5)+)/g, (m, fn: string, sp: string, base: string, segs: string) => {
|
|
174
|
+
const q = /['"]/.exec(segs)![0]
|
|
175
|
+
const parts = [...segs.matchAll(/(['"])(\.\.?)\1/g)].map((x) => x[2]!)
|
|
176
|
+
const target = movedTo(moves, posix.normalize(posix.join(posix.dirname(at.from), ...parts)))
|
|
177
|
+
if (target.startsWith('../')) return m
|
|
178
|
+
const rel = posix.relative(posix.dirname(at.to), target)
|
|
179
|
+
const next = rel === '' ? [] : rel.split('/')
|
|
180
|
+
if (next.some((x) => x !== '..') || next.join('/') === parts.filter((x) => x !== '.').join('/')) return m
|
|
181
|
+
return `${fn}(${sp}${base}${next.map((x) => `, ${q}${x}${q}`).join('')}`
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** PURE: a file that moved keeps pointing at what it pointed at. Only what is
|
|
186
|
+
* a path by its SHAPE is touched: an import or require specifier, a markdown
|
|
187
|
+
* link, and `join(import.meta.dir, '..')`. Any other string is left alone,
|
|
188
|
+
* because `'../'` in `p.replace('../', '')` is not a path. Each path is
|
|
189
|
+
* resolved from where the file WAS, carried through the moves, and written
|
|
190
|
+
* from where it IS; one that still resolves the same way stays byte for byte */
|
|
191
|
+
export function rebaseRelative(text: string, at: { from: string; to: string }, moves: LayoutMove[]): string {
|
|
192
|
+
if (at.from === at.to) return text
|
|
193
|
+
const rebase = (spec: string): string => {
|
|
194
|
+
const target = movedTo(moves, posix.normalize(posix.join(posix.dirname(at.from), spec)))
|
|
195
|
+
if (target.startsWith('../')) return spec
|
|
196
|
+
return posix.normalize(posix.join(posix.dirname(at.to), spec)) === target ? spec : relativeFrom(at.to, target)
|
|
197
|
+
}
|
|
198
|
+
return rebaseDirJoins(text, at, moves)
|
|
199
|
+
.replace(/(\bfrom\s+|\bimport\s+|\bimport\s*\(\s*|\brequire\s*\(\s*)(['"])(\.\.?\/[^'"\s]+)\2/g, (_m, lead: string, q: string, spec: string) => `${lead}${q}${rebase(spec)}${q}`)
|
|
200
|
+
.replace(/(\]\()(\.\.?\/[^)\s#]+)/g, (_m, lead: string, spec: string) => `${lead}${rebase(spec)}`)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** PURE: the .gitignore after the move. Old names turn into new ones, except
|
|
204
|
+
* the folders still on disk (`keep`), whose lines keep guarding them. A
|
|
205
|
+
* negation of one FILE inside a folder (`!.shipkit/social/README.md`) is dead
|
|
206
|
+
* under `.shipkit/*`, because git never looks inside an excluded folder, so
|
|
207
|
+
* the folder is re-included and re-ignored first, and the file comes back */
|
|
208
|
+
export function sweepGitignore(text: string, keep: string[] = []): string {
|
|
209
|
+
const out: string[] = []
|
|
210
|
+
const have = new Set(text.split('\n'))
|
|
211
|
+
for (const line of text.split('\n')) {
|
|
212
|
+
const kept = keep.some((k) => new RegExp(`(?<![\\w-])${k.replace(/[.]/g, '\\.')}(?![\\w-])`).test(line))
|
|
213
|
+
const next = kept ? line : sweepText(line, 'all')
|
|
214
|
+
const deep = /^!\.shipkit\/([^/]+)\/(.+)$/.exec(next)
|
|
215
|
+
if (!kept && next !== line && deep != null) {
|
|
216
|
+
for (const pre of [`!${SHIPKIT_DIR}/${deep[1]}/`, `${SHIPKIT_DIR}/${deep[1]}/*`])
|
|
217
|
+
if (!have.has(pre)) { out.push(pre); have.add(pre) }
|
|
218
|
+
}
|
|
219
|
+
out.push(next)
|
|
220
|
+
}
|
|
221
|
+
return out.join('\n')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** the relative file paths under a folder */
|
|
225
|
+
const filesUnder = (dir: string, pre = ''): string[] => {
|
|
226
|
+
const out: string[] = []
|
|
227
|
+
for (const e of readdirSync(join(dir, pre), { withFileTypes: true })) {
|
|
228
|
+
const rel = pre === '' ? e.name : `${pre}/${e.name}`
|
|
229
|
+
if (e.isDirectory()) out.push(...filesUnder(dir, rel))
|
|
230
|
+
else out.push(rel)
|
|
231
|
+
}
|
|
232
|
+
return out
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** a target that already exists is the normal case in a checkout that pulled
|
|
236
|
+
* the move: the tracked files arrived, the untracked ones (drafts, private
|
|
237
|
+
* notes) stayed behind in the old folder. When no file name exists on both
|
|
238
|
+
* sides, merging decides nothing, so it moves. A real collision stays refused */
|
|
239
|
+
function mergeWhereNothingCollides(root: string, plan: LayoutPlan): LayoutPlan {
|
|
240
|
+
const moves: LayoutMove[] = [...plan.moves]
|
|
241
|
+
const refusals: LayoutRefusal[] = []
|
|
242
|
+
for (const r of plan.refusals) {
|
|
243
|
+
const from = join(root, r.from)
|
|
244
|
+
const to = join(root, r.to)
|
|
245
|
+
const twin = moves.some((m) => m.to === r.to)
|
|
246
|
+
const scratch = r.from === LEGACY_TMP
|
|
247
|
+
let clash = true
|
|
248
|
+
let drop: string[] = []
|
|
249
|
+
try {
|
|
250
|
+
const both = twin || !isDir(from) || !isDir(to) ? null : filesUnder(from).filter((f) => existsSync(join(to, f)))
|
|
251
|
+
drop = scratch && both != null ? both.filter((f) => STALE_LEASE.test(f)) : []
|
|
252
|
+
clash = both == null || both.length > drop.length
|
|
253
|
+
} catch { clash = true }
|
|
254
|
+
if (clash) refusals.push(r)
|
|
255
|
+
else moves.push({ from: r.from, to: r.to, kind: scratch ? 'tmp' : LEGACY_NAME.test(r.from) ? 'contract' : 'typo', merge: true, ...(drop.length > 0 ? { drop } : {}) } as MergeMove)
|
|
256
|
+
}
|
|
257
|
+
return { moves, refusals }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** move every entry of one folder into another that holds none of the same names */
|
|
261
|
+
function mergeInto(from: string, to: string): void {
|
|
262
|
+
for (const e of readdirSync(from, { withFileTypes: true })) {
|
|
263
|
+
const src = join(from, e.name)
|
|
264
|
+
const dst = join(to, e.name)
|
|
265
|
+
if (e.isDirectory() && isDir(dst)) mergeInto(src, dst)
|
|
266
|
+
else renameSync(src, dst)
|
|
267
|
+
}
|
|
268
|
+
rmSync(from, { recursive: true, force: true })
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** where a repo-relative path sat before the moves */
|
|
272
|
+
const movedFrom = (moves: LayoutMove[], rel: string): string => {
|
|
273
|
+
const m = moves.find((x) => rel === x.to || rel.startsWith(`${x.to}/`))
|
|
274
|
+
return m == null ? rel : m.from + rel.slice(m.to.length)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const canWrite = (p: string): boolean => { try { accessSync(p, constants.W_OK); return true } catch { return false } }
|
|
278
|
+
|
|
279
|
+
/** where a repo-relative path sits once the moves are made */
|
|
280
|
+
const movedTo = (moves: LayoutMove[], rel: string): string => {
|
|
281
|
+
const m = moves.find((x) => rel === x.from || rel.startsWith(`${x.from}/`))
|
|
282
|
+
return m == null ? rel : m.to + rel.slice(m.from.length)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const isChangelog = (rel: string): boolean => /(^|\/)changelog\.md$/i.test(rel)
|
|
286
|
+
|
|
287
|
+
/** a file that names the old layout on purpose carries this, and the sweep leaves it alone */
|
|
288
|
+
export const SWEEP_SKIP = 'layout-sweep: skip'
|
|
289
|
+
|
|
290
|
+
export type LayoutResult = {
|
|
291
|
+
moved: LayoutMove[]
|
|
292
|
+
refusals: LayoutRefusal[]
|
|
293
|
+
/** tracked files whose text the sweep rewrote */
|
|
294
|
+
swept: string[]
|
|
295
|
+
/** legacy generated files removed from the root */
|
|
296
|
+
removed: string[]
|
|
297
|
+
/** why nothing happened, when nothing could */
|
|
298
|
+
deferred?: string
|
|
299
|
+
/** the commit, when one landed */
|
|
300
|
+
commit?: string
|
|
301
|
+
/** moved, and the commit did not land: the dirt is this run's */
|
|
302
|
+
uncommitted?: true
|
|
303
|
+
/** the paths the commit would have carried, for the one who lands it by hand */
|
|
304
|
+
pending?: string[]
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const git = (root: string, args: string[]): { code: number; out: string } => {
|
|
308
|
+
const r = Bun.spawnSync([gitBin(), ...args], { cwd: root })
|
|
309
|
+
return { code: r.exitCode, out: `${r.stdout.toString()}${r.stderr.toString()}` }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const dirsIn = (root: string): string[] => {
|
|
313
|
+
try { return readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) } catch { return [] }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const isDir = (p: string): boolean => { try { return statSync(p).isDirectory() } catch { return false } }
|
|
317
|
+
|
|
318
|
+
/** does core.hooksPath point at the legacy `hooks/`, holding a shipkit pre-commit */
|
|
319
|
+
export function legacyHooksOurs(root: string): boolean {
|
|
320
|
+
const r = git(root, ['config', 'core.hooksPath'])
|
|
321
|
+
const v = r.out.trim().replace(/\/+$/, '')
|
|
322
|
+
if (v !== 'hooks' && v !== join(root, 'hooks')) return false
|
|
323
|
+
try { return readFileSync(join(root, 'hooks', 'pre-commit'), 'utf8').includes('shipkit') } catch { return false }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** a checkout that received the move through a merge, not through this pass,
|
|
327
|
+
* still has core.hooksPath on the vanished `hooks/`, and git runs no hook
|
|
328
|
+
* from a folder that is not there: the commit gates go dark in silence */
|
|
329
|
+
function repointStaleHooks(root: string): void {
|
|
330
|
+
const v = git(root, ['config', 'core.hooksPath']).out.trim().replace(/\/+$/, '')
|
|
331
|
+
if (v !== 'hooks' && v !== join(root, 'hooks')) return
|
|
332
|
+
if (existsSync(join(root, 'hooks')) || !existsSync(join(root, LAYOUT.hooks, 'pre-commit'))) return
|
|
333
|
+
git(root, ['config', 'core.hooksPath', LAYOUT.hooks])
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** the machine-global private folder moves the same way, once per machine.
|
|
337
|
+
* The old path stays as a symlink to the new one, so a repo still on an
|
|
338
|
+
* older shipkit keeps reading its keyword list. The link goes once
|
|
339
|
+
* `shipkit fleet` shows no repo on a version that reads it */
|
|
340
|
+
export function moveMachinePrivate(home: string): void {
|
|
341
|
+
if (home === '') return
|
|
342
|
+
const old = join(home, '.rv-private')
|
|
343
|
+
const next = join(home, MACHINE_PRIVATE)
|
|
344
|
+
try {
|
|
345
|
+
if (!lstatSync(old).isDirectory() || existsSync(next)) return
|
|
346
|
+
mkdirSync(dirname(next), { recursive: true })
|
|
347
|
+
renameSync(old, next)
|
|
348
|
+
symlinkSync(posix.relative(home, next), old)
|
|
349
|
+
} catch { /* absent, or a link already: nothing to move */ }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** what the planner reads off disk and config */
|
|
353
|
+
export function readPlanInput(root: string, config?: RepoConfig): PlanInput {
|
|
354
|
+
return {
|
|
355
|
+
rootDirs: dirsIn(root),
|
|
356
|
+
shipkitEntries: isDir(join(root, SHIPKIT_DIR)) ? readdirSync(join(root, SHIPKIT_DIR)) : [],
|
|
357
|
+
asksDirSet: config?.asks?.dir != null,
|
|
358
|
+
hooksOurs: existsSync(join(root, 'hooks')) && legacyHooksOurs(root),
|
|
359
|
+
legacyCache: isDir(join(root, LEGACY_CACHE)),
|
|
360
|
+
tmpHoldsRepo: isDir(join(root, LEGACY_TMP)) && holdsRepo(join(root, LEGACY_TMP)),
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** a repo still carrying the old layout: the names a check reports */
|
|
365
|
+
export function legacyLayoutLeft(root: string, config?: RepoConfig): string[] {
|
|
366
|
+
const plan = planLayoutMigration(readPlanInput(root, config))
|
|
367
|
+
return [...plan.moves.filter((m) => m.kind !== 'cache').map((m) => `${m.from}/`), ...plan.refusals.map((r) => `${r.from}/`)]
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const lsFiles = (root: string, paths: string[]): string[] => {
|
|
371
|
+
if (paths.length === 0) return []
|
|
372
|
+
const r = git(root, ['ls-files', '-z', '--', ...paths])
|
|
373
|
+
return r.code === 0 ? r.out.split('\0').filter((l) => l !== '') : []
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** tracked text files naming a legacy folder, CHANGELOG files left as history */
|
|
377
|
+
function sweepTargets(root: string, legacyNames: string[]): string[] {
|
|
378
|
+
if (legacyNames.length === 0) return []
|
|
379
|
+
const r = git(root, ['grep', '-lIz', '-F', ...legacyNames.flatMap((n) => ['-e', n])])
|
|
380
|
+
if (r.code !== 0) return []
|
|
381
|
+
return r.out.split('\0').filter((l) => l !== '' && !isChangelog(l) && l !== '.gitignore')
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** tracked paths among these with staged or unstaged edits */
|
|
385
|
+
function dirtyAmong(root: string, paths: string[]): string[] {
|
|
386
|
+
if (paths.length === 0) return []
|
|
387
|
+
const r = git(root, ['status', '--porcelain', '-z', '--untracked-files=no', '--', ...paths])
|
|
388
|
+
return r.out.split('\0').filter((l) => l.length > 3).map((l) => l.slice(3))
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function inProgress(root: string): string | null {
|
|
392
|
+
const g = probeGitRoot(root).root
|
|
393
|
+
if (g == null) return null
|
|
394
|
+
for (const f of ['MERGE_HEAD', 'REBASE_HEAD', 'CHERRY_PICK_HEAD', 'rebase-merge', 'rebase-apply'])
|
|
395
|
+
if (existsSync(join(g.gitDir, f))) return `a ${f.toLowerCase().replace(/_head$/, '').replace(/-.*/, '')} is in progress`
|
|
396
|
+
if (git(root, ['symbolic-ref', '--quiet', 'HEAD']).code !== 0) return 'HEAD is detached'
|
|
397
|
+
return null
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export type MigrateOpts = {
|
|
401
|
+
config?: RepoConfig
|
|
402
|
+
/** re-render the SK004 block into this .gitignore text (SK004 owns the
|
|
403
|
+
* block). `legacy`: an old folder is still on disk, so its name stays ignored */
|
|
404
|
+
renderGitignore?: (text: string, legacy: boolean) => string
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** tracked symlinks, with where each points */
|
|
408
|
+
function trackedLinks(root: string): { rel: string; target: string }[] {
|
|
409
|
+
const r = git(root, ['ls-files', '-s', '-z'])
|
|
410
|
+
if (r.code !== 0) return []
|
|
411
|
+
const out: { rel: string; target: string }[] = []
|
|
412
|
+
for (const row of r.out.split('\0')) {
|
|
413
|
+
if (!row.startsWith('120000 ')) continue
|
|
414
|
+
const rel = row.slice(row.indexOf('\t') + 1)
|
|
415
|
+
try { out.push({ rel, target: readlinkSync(join(root, rel)) }) } catch { /* gone from disk: nothing to relink */ }
|
|
416
|
+
}
|
|
417
|
+
return out
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Apply the plan. Returns what moved, what the sweep rewrote and whether the
|
|
422
|
+
* commit landed. Never throws: a permission error or a race leaves the repo
|
|
423
|
+
* where it was, and the next run tries again.
|
|
424
|
+
*/
|
|
425
|
+
export function migrateLayout(root: string, o: MigrateOpts = {}): LayoutResult {
|
|
426
|
+
repointStaleHooks(root)
|
|
427
|
+
moveMachinePrivate(homedir())
|
|
428
|
+
const input = readPlanInput(root, o.config)
|
|
429
|
+
const plan = mergeWhereNothingCollides(root, planLayoutMigration(input))
|
|
430
|
+
const legacyStatus = LEGACY_GENERATED.filter((f) => existsSync(join(root, f)))
|
|
431
|
+
const out: LayoutResult = { moved: [], refusals: plan.refusals, swept: [], removed: [] }
|
|
432
|
+
if (plan.moves.length === 0 && legacyStatus.length === 0) return out
|
|
433
|
+
|
|
434
|
+
const inGit = probeGitRoot(root).root != null
|
|
435
|
+
if (inGit) {
|
|
436
|
+
if (nestedIn(root) != null) return { ...out, deferred: 'this repo is a folder of a larger git repo, whose index is not shipkit\'s to rewrite' }
|
|
437
|
+
const busy = inProgress(root)
|
|
438
|
+
if (busy != null) return { ...out, deferred: busy }
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const contractNames = plan.moves.filter((m) => m.kind === 'contract').map((m) => m.from)
|
|
442
|
+
const targets = Object.fromEntries(plan.moves.filter((m) => m.kind === 'contract').map((m) => [m.from, m.to]))
|
|
443
|
+
const sources = plan.moves.filter((m) => m.kind !== 'cache').map((m) => m.from)
|
|
444
|
+
const tracked = inGit ? lsFiles(root, [...sources, ...legacyStatus]) : []
|
|
445
|
+
// a moved file is rebased whether or not it names an old folder
|
|
446
|
+
const swept = inGit ? [...new Set([...sweepTargets(root, contractNames), ...tracked.filter((t) => /\.(m?[jt]sx?|json|md)$/.test(t))])] : []
|
|
447
|
+
const links = inGit ? trackedLinks(root) : []
|
|
448
|
+
// commit the ignore file when git tracks it, or when this pass creates it:
|
|
449
|
+
// it is what keeps the moved private folders out. An existing untracked
|
|
450
|
+
// one is the repo's choice, and naming it in the pathspec refuses the commit
|
|
451
|
+
const giExisted = existsSync(join(root, '.gitignore'))
|
|
452
|
+
const giTracked = inGit && lsFiles(root, ['.gitignore']).length > 0
|
|
453
|
+
if (inGit) {
|
|
454
|
+
const dirty = dirtyAmong(root, [...tracked, ...swept, ...links.map((l) => l.rel), '.gitignore'])
|
|
455
|
+
if (dirty.length > 0) return { ...out, deferred: `${dirty.slice(0, 3).join(', ')}${dirty.length > 3 ? ` +${dirty.length - 3}` : ''} carries uncommitted edits, and the move commit would take them along` }
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// every rewrite is computed, and every write is checked, BEFORE anything
|
|
459
|
+
// moves: a move that stops part way leaves no old folder for the next run
|
|
460
|
+
// to find, so there is no resuming it, only not starting it
|
|
461
|
+
const moves = plan.moves.filter((m) => m.kind !== 'cache' || !existsSync(join(root, m.to)))
|
|
462
|
+
const writes: { now: string; text: string }[] = []
|
|
463
|
+
for (const rel of swept) {
|
|
464
|
+
const text = readFileSync(join(root, rel), 'utf8')
|
|
465
|
+
if (text.includes(SWEEP_SKIP)) continue
|
|
466
|
+
const now = movedTo(moves, rel)
|
|
467
|
+
const next = sweepText(rebaseRelative(text, { from: rel, to: now }, moves), targets, { from: rel, to: now })
|
|
468
|
+
if (next !== text) writes.push({ now, text: next })
|
|
469
|
+
}
|
|
470
|
+
const relinks: { now: string; target: string }[] = []
|
|
471
|
+
for (const l of links) {
|
|
472
|
+
const now = movedTo(moves, l.rel)
|
|
473
|
+
if (l.target.startsWith('/')) continue
|
|
474
|
+
const was = posix.normalize(posix.join(posix.dirname(l.rel), l.target))
|
|
475
|
+
const target = movedTo(moves, was)
|
|
476
|
+
if (target === was && now === l.rel) continue
|
|
477
|
+
const next = posix.relative(posix.dirname(now), target)
|
|
478
|
+
if (next !== l.target) relinks.push({ now, target: next })
|
|
479
|
+
}
|
|
480
|
+
const unwritable = [
|
|
481
|
+
...writes.map((w) => join(root, movedFrom(moves, w.now))),
|
|
482
|
+
...moves.flatMap((m) => [join(root, m.from), dirname(join(root, m.from))]),
|
|
483
|
+
join(root, '.gitignore'),
|
|
484
|
+
].filter((p) => existsSync(p) && !canWrite(p))
|
|
485
|
+
if (unwritable.length > 0) return { ...out, deferred: `${unwritable.slice(0, 3).map((p) => p.slice(root.length + 1) || '.').join(', ')} is not writable, so the move would stop part way` }
|
|
486
|
+
|
|
487
|
+
const gi = join(root, '.gitignore')
|
|
488
|
+
const giOriginal = existsSync(gi) ? readFileSync(gi, 'utf8') : ''
|
|
489
|
+
const render = (text: string, legacy: boolean): string => (o.renderGitignore != null ? o.renderGitignore(text, legacy) : text)
|
|
490
|
+
const stillOld = (): string[] => dirsIn(root).filter((d) => d.startsWith(LEGACY_CONTRACT_PREFIX))
|
|
491
|
+
try {
|
|
492
|
+
// 1. both layouts ignored while any folder is in flight
|
|
493
|
+
const giBoth = render(sweepGitignore(giOriginal, dirsIn(root).filter((d) => d.startsWith(LEGACY_CONTRACT_PREFIX))), true)
|
|
494
|
+
if (giBoth !== giOriginal) writeFileSync(gi, giBoth)
|
|
495
|
+
|
|
496
|
+
// 2. the folders
|
|
497
|
+
mkdirSync(join(root, SHIPKIT_DIR), { recursive: true })
|
|
498
|
+
for (const m of plan.moves) {
|
|
499
|
+
const from = join(root, m.from)
|
|
500
|
+
const to = join(root, m.to)
|
|
501
|
+
if (m.kind === 'cache' && existsSync(to)) { rmSync(from, { recursive: true, force: true }); continue }
|
|
502
|
+
mkdirSync(dirname(to), { recursive: true })
|
|
503
|
+
if ('merge' in m) {
|
|
504
|
+
for (const f of (m as MergeMove).drop ?? []) rmSync(join(from, f), { force: true })
|
|
505
|
+
mergeInto(from, to)
|
|
506
|
+
}
|
|
507
|
+
else renameSync(from, to)
|
|
508
|
+
out.moved.push(m)
|
|
509
|
+
}
|
|
510
|
+
for (const f of legacyStatus) { rmSync(join(root, f), { force: true }); out.removed.push(f) }
|
|
511
|
+
|
|
512
|
+
// 3. the text and the links that named the old folders
|
|
513
|
+
for (const w of writes) { writeFileSync(join(root, w.now), w.text); out.swept.push(w.now) }
|
|
514
|
+
for (const l of relinks) { rmSync(join(root, l.now), { force: true }); symlinkSync(l.target, join(root, l.now)); out.swept.push(l.now) }
|
|
515
|
+
|
|
516
|
+
// 4. the old names leave the ignore file only with their folders
|
|
517
|
+
const left = stillOld()
|
|
518
|
+
const giFinal = render(sweepGitignore(giOriginal, left), left.length > 0)
|
|
519
|
+
if (giFinal !== readFileSync(gi, 'utf8')) writeFileSync(gi, giFinal)
|
|
520
|
+
} catch (e) {
|
|
521
|
+
// whatever moved is under .shipkit/, whatever did not is still old:
|
|
522
|
+
// both stay ignored, and nothing pretends a later run finishes it
|
|
523
|
+
try { writeFileSync(gi, render(sweepGitignore(giOriginal, stillOld()), true)) } catch { /* the message below says what to check */ }
|
|
524
|
+
return { ...out, deferred: `the move stopped part way (${(e as Error).message.slice(0, 120)}). What moved is under ${SHIPKIT_DIR}/ and both layouts stay ignored: finish the rest by hand, then commit` }
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const hooksMoved = out.moved.some((m) => m.kind === 'hooks')
|
|
528
|
+
if (hooksMoved && inGit) git(root, ['config', 'core.hooksPath', LAYOUT.hooks])
|
|
529
|
+
if (!inGit) return out
|
|
530
|
+
|
|
531
|
+
// 4. the index: every tracked file keeps its tracking under the new name
|
|
532
|
+
const renamed = (rel: string): string | null => {
|
|
533
|
+
for (const m of out.moved) if (rel === m.from || rel.startsWith(`${m.from}/`)) return m.kind === 'typo' ? null : m.to + rel.slice(m.from.length)
|
|
534
|
+
return null
|
|
535
|
+
}
|
|
536
|
+
const typoTracked = tracked.filter((rel) => out.moved.some((m) => m.kind === 'typo' && rel.startsWith(`${m.from}/`)))
|
|
537
|
+
const typoKept = typoTracked
|
|
538
|
+
.map((rel) => { const m = out.moved.find((x) => rel.startsWith(`${x.from}/`))!; return m.to + rel.slice(m.from.length) })
|
|
539
|
+
.filter((to) => git(root, ['check-ignore', '-q', '--no-index', to]).code !== 0)
|
|
540
|
+
const added = [...tracked.map(renamed).filter((p): p is string => p != null), ...typoKept]
|
|
541
|
+
if (tracked.length > 0) git(root, ['rm', '-r', '-q', '--cached', '--ignore-unmatch', '--', ...tracked])
|
|
542
|
+
if (added.length > 0) git(root, ['add', '-f', '--', ...added])
|
|
543
|
+
const giNew = !giExisted && existsSync(join(root, '.gitignore'))
|
|
544
|
+
if (giNew) git(root, ['add', '--', '.gitignore'])
|
|
545
|
+
// a swept file inside a moved folder is committed only when it stayed tracked
|
|
546
|
+
const inMoved = (p: string): boolean => out.moved.some((m) => p.startsWith(`${m.to}/`))
|
|
547
|
+
const sweptTracked = out.swept.filter((p) => !inMoved(p) || added.includes(p))
|
|
548
|
+
// the ignore file rides only when this pass changed it: a second move of a
|
|
549
|
+
// recreated tmp/ changes nothing git tracks, and an empty commit is no failure
|
|
550
|
+
const giChanged = giNew || (giTracked && readFileSync(join(root, '.gitignore'), 'utf8') !== giOriginal)
|
|
551
|
+
const touched = [...new Set([...tracked, ...added, ...sweptTracked, ...(giChanged ? ['.gitignore'] : [])])]
|
|
552
|
+
if (touched.length === 0) return out
|
|
553
|
+
const landed = machineCommit({ anchor: root, message: MIGRATION_MESSAGE, paths: touched.map((p) => join(root, p)) })
|
|
554
|
+
if (!landed) return { ...out, uncommitted: true, pending: touched }
|
|
555
|
+
out.commit = git(root, ['rev-parse', '--short', 'HEAD']).out.trim()
|
|
556
|
+
return out
|
|
557
|
+
}
|
package/src/core/motherhome.ts
CHANGED
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|
// path no longer exists are pruned on every write. Churn control: a row
|
|
11
11
|
// rewrites only when its DATA changes or its last-seen day is older than
|
|
12
12
|
// 7 days. Never fails the calling command.
|
|
13
|
-
import { existsSync, readFileSync
|
|
13
|
+
import { existsSync, readFileSync } from 'fs'
|
|
14
14
|
import { homedir } from 'os'
|
|
15
15
|
import { join, resolve } from 'path'
|
|
16
16
|
import type { RepoConfig } from '../repo-config.ts'
|
|
17
17
|
import { atomicWrite, withFileLock } from './locked-write.ts'
|
|
18
|
+
import { isLinkedWorktree } from './git-root.ts'
|
|
18
19
|
import { machineCommit } from './machine-commit.ts'
|
|
19
20
|
|
|
20
21
|
const BEGIN = '<!-- >>> shipkit local-projects — machine-written, edit outside the markers -->'
|
|
@@ -69,13 +70,6 @@ function consumptionMode(root: string): string {
|
|
|
69
70
|
} catch { return '?' }
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
/** a LINKED worktree, where `.git` is a file pointing at the main checkout
|
|
73
|
-
* rather than a directory. Cheap on purpose: this runs on every check pass in
|
|
74
|
-
* every repo, and spawning git to learn it would cost more than the write. */
|
|
75
|
-
function isLinkedWorktree(root: string): boolean {
|
|
76
|
-
try { return statSync(join(root, '.git')).isFile() } catch { return false }
|
|
77
|
-
}
|
|
78
|
-
|
|
79
73
|
export type MotherhomeOutcome = { wrote: boolean; note?: string }
|
|
80
74
|
|
|
81
75
|
export function reportToMotherhome(root: string, config: RepoConfig, today: string = new Date().toISOString().slice(0, 10)): MotherhomeOutcome {
|
package/src/core/nesting.ts
CHANGED
|
@@ -7,21 +7,13 @@
|
|
|
7
7
|
// not, because the fix pass runs before any rule can say so.
|
|
8
8
|
import { existsSync, realpathSync } from 'fs'
|
|
9
9
|
import { dirname, join } from 'path'
|
|
10
|
-
|
|
11
|
-
const gitLine = (cwd: string, args: string[]): string | null => {
|
|
12
|
-
try {
|
|
13
|
-
const p = Bun.spawnSync(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
14
|
-
return p.exitCode === 0 ? p.stdout.toString().trim() : null
|
|
15
|
-
} catch {
|
|
16
|
-
return null
|
|
17
|
-
}
|
|
18
|
-
}
|
|
10
|
+
import { gitRoot } from './git-root.ts'
|
|
19
11
|
|
|
20
12
|
/** the work tree root holding `root` when it is a PARENT of `root`. null when
|
|
21
13
|
* `root` is its own work tree root, or sits outside git */
|
|
22
14
|
export function nestedIn(root: string): string | null {
|
|
23
|
-
const top =
|
|
24
|
-
if (top == null
|
|
15
|
+
const top = gitRoot(root)?.top
|
|
16
|
+
if (top == null) return null
|
|
25
17
|
try {
|
|
26
18
|
return realpathSync(top) === realpathSync(root) ? null : top
|
|
27
19
|
} catch {
|
|
@@ -31,7 +23,7 @@ export function nestedIn(root: string): string | null {
|
|
|
31
23
|
|
|
32
24
|
/** `root` relative to its work tree root, posix: '' at the root, else ending in '/' */
|
|
33
25
|
export function treePrefix(root: string): string {
|
|
34
|
-
return
|
|
26
|
+
return gitRoot(root)?.prefix ?? ''
|
|
35
27
|
}
|
|
36
28
|
|
|
37
29
|
/** the nearest repo.config.ts at or above `dir` and strictly below the work
|