instabox 0.4.3 → 0.5.1
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/README.md +7 -4
- package/bin/instabox.mjs +125 -84
- package/lib/api.mjs +4 -2
- package/lib/boxes.mjs +1 -10
- package/lib/cli.mjs +2 -15
- package/lib/login.mjs +5 -2
- package/package.json +1 -1
- package/skills/instabox/SKILL.md +11 -5
- package/skills/instabox-basic-setup/SKILL.md +108 -0
- package/skills/instabox-basic-setup/scripts/content.mjs +98 -0
- package/skills/instabox-basic-setup/scripts/lib.mjs +233 -0
- package/skills/instabox-basic-setup/scripts/plan.mjs +295 -0
- package/skills/instabox-basic-setup/scripts/push.mjs +252 -0
- package/skills/instabox-basic-setup/scripts/scan.mjs +246 -0
- package/skills/instabox-move-repo/SKILL.md +193 -0
- package/skills/instabox-move-repo/scripts/deps.sh +370 -0
- package/skills/instabox-move-repo/scripts/history.mjs +340 -0
- package/skills/instabox-move-repo/scripts/repo-files.mjs +552 -0
- package/skills/instabox-snapshots/SKILL.md +2 -2
- package/skills/instabox-migrate/SKILL.md +0 -132
- package/skills/instabox-move-all/SKILL.md +0 -158
- package/skills/instabox-move-all/scripts/lib.mjs +0 -226
- package/skills/instabox-move-all/scripts/plan.mjs +0 -263
- package/skills/instabox-move-all/scripts/push-creds.mjs +0 -154
- package/skills/instabox-move-all/scripts/scan.mjs +0 -811
- package/skills/instabox-move-all/scripts/stage-env.mjs +0 -313
- package/skills/instabox-move-all/scripts/tools.mjs +0 -173
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// instabox-move-repo: what of the current git repo (and its worktrees) goes to the box, where it
|
|
3
|
+
// lands, and how big it is. Node >= 18, no dependencies. Run it from anywhere inside the repo or
|
|
4
|
+
// any of its linked worktrees: the plan is always the main checkout plus its worktrees.
|
|
5
|
+
//
|
|
6
|
+
// node repo-files.mjs --dest /data/workspace/NAME the plan with sizes, human readable
|
|
7
|
+
// node repo-files.mjs --dest /data/workspace/NAME --json the same plan as JSON
|
|
8
|
+
// node repo-files.mjs --list0 PATH the files of the checkout at PATH (main
|
|
9
|
+
// or a worktree) for tar, NUL-separated,
|
|
10
|
+
// relative to PATH
|
|
11
|
+
// --skip-worktree PATH leave a linked worktree behind (repeatable; pass it to --list0 runs too)
|
|
12
|
+
// --keep NAME copy an excluded name anyway (repeatable): --keep build (not an untracked
|
|
13
|
+
// .env: those go through the Vault)
|
|
14
|
+
//
|
|
15
|
+
// What goes: all of the main checkout's .git (branches, stashes, the index, unpushed commits, the
|
|
16
|
+
// worktrees' own HEAD and index under .git/worktrees/), every tracked file that exists, and every
|
|
17
|
+
// untracked file git does not ignore, in the main checkout and in each linked worktree. What
|
|
18
|
+
// stays, on top of what git ignores: build output and dependency directories (EXCLUDED_DIRS,
|
|
19
|
+
// EXCLUDED_FILES) that the repo forgot to ignore, and untracked .env files. Tracked files always
|
|
20
|
+
// go, even inside such a directory, so the box shows the same `git status` and HEAD; the only
|
|
21
|
+
// difference is `??` lines for what was left out. File contents are never read, only names and sizes.
|
|
22
|
+
import { spawnSync } from 'node:child_process';
|
|
23
|
+
import { lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
24
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
// Directory names that hold build output, dependencies or caches: regenerated on the box.
|
|
28
|
+
export const EXCLUDED_DIRS = [
|
|
29
|
+
// JavaScript / TypeScript
|
|
30
|
+
'node_modules',
|
|
31
|
+
'bower_components',
|
|
32
|
+
'jspm_packages',
|
|
33
|
+
'.pnpm-store',
|
|
34
|
+
'.npm',
|
|
35
|
+
'.yarn-cache',
|
|
36
|
+
'.next',
|
|
37
|
+
'.nuxt',
|
|
38
|
+
'.output',
|
|
39
|
+
'.svelte-kit',
|
|
40
|
+
'.angular',
|
|
41
|
+
'.expo',
|
|
42
|
+
'.parcel-cache',
|
|
43
|
+
'.turbo',
|
|
44
|
+
'.vercel',
|
|
45
|
+
'.netlify',
|
|
46
|
+
'.docusaurus',
|
|
47
|
+
'.vite',
|
|
48
|
+
'storybook-static',
|
|
49
|
+
// generic build output and caches
|
|
50
|
+
'dist',
|
|
51
|
+
'build',
|
|
52
|
+
'out',
|
|
53
|
+
'target',
|
|
54
|
+
'.cache',
|
|
55
|
+
'coverage',
|
|
56
|
+
'.nyc_output',
|
|
57
|
+
// Python
|
|
58
|
+
'__pycache__',
|
|
59
|
+
'.venv',
|
|
60
|
+
'venv',
|
|
61
|
+
'.tox',
|
|
62
|
+
'.nox',
|
|
63
|
+
'.pytest_cache',
|
|
64
|
+
'.mypy_cache',
|
|
65
|
+
'.ruff_cache',
|
|
66
|
+
'.pytype',
|
|
67
|
+
'.hypothesis',
|
|
68
|
+
'.ipynb_checkpoints',
|
|
69
|
+
'.eggs',
|
|
70
|
+
// JVM, Android
|
|
71
|
+
'.gradle',
|
|
72
|
+
'.kotlin',
|
|
73
|
+
// native and others
|
|
74
|
+
'CMakeFiles',
|
|
75
|
+
'.zig-cache',
|
|
76
|
+
'zig-cache',
|
|
77
|
+
'zig-out',
|
|
78
|
+
'.build',
|
|
79
|
+
'DerivedData',
|
|
80
|
+
'Pods',
|
|
81
|
+
'.dart_tool',
|
|
82
|
+
'.stack-work',
|
|
83
|
+
'dist-newstyle',
|
|
84
|
+
'_build',
|
|
85
|
+
'.terraform',
|
|
86
|
+
'.direnv',
|
|
87
|
+
'.devenv',
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
// Directory name patterns.
|
|
91
|
+
export const EXCLUDED_DIR_PATTERNS = ['bazel-*', 'cmake-build-*', '*.egg-info'];
|
|
92
|
+
|
|
93
|
+
// File name patterns: compiled objects and tool caches.
|
|
94
|
+
export const EXCLUDED_FILES = [
|
|
95
|
+
'*.o',
|
|
96
|
+
'*.obj',
|
|
97
|
+
'*.so',
|
|
98
|
+
'*.dylib',
|
|
99
|
+
'*.dll',
|
|
100
|
+
'*.a',
|
|
101
|
+
'*.lib',
|
|
102
|
+
'*.pyc',
|
|
103
|
+
'*.pyo',
|
|
104
|
+
'*.class',
|
|
105
|
+
'*.tsbuildinfo',
|
|
106
|
+
'.eslintcache',
|
|
107
|
+
'.stylelintcache',
|
|
108
|
+
'.DS_Store',
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
// Untracked .env files never go (secrets belong in the Vault). Templates do.
|
|
112
|
+
const ENV_FILE = /^\.env(?:\..+)?$/;
|
|
113
|
+
const ENV_TEMPLATE = /^\.env\.(?:example|sample|template|dist|defaults)$/;
|
|
114
|
+
const isEnvName = (f) => {
|
|
115
|
+
const b = f.replace(/\/$/, '').split('/').pop();
|
|
116
|
+
return ENV_FILE.test(b) && !ENV_TEMPLATE.test(b);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function globToRe(g) {
|
|
120
|
+
return new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$');
|
|
121
|
+
}
|
|
122
|
+
const DIR_RES = EXCLUDED_DIR_PATTERNS.map(globToRe);
|
|
123
|
+
const FILE_RES = EXCLUDED_FILES.map((g) => [g, globToRe(g)]);
|
|
124
|
+
|
|
125
|
+
function git(args, cwd) {
|
|
126
|
+
const r = spawnSync('git', args, { cwd, encoding: 'buffer', maxBuffer: 1 << 30 });
|
|
127
|
+
if (r.status !== 0) {
|
|
128
|
+
const msg = (r.stderr || '').toString().trim();
|
|
129
|
+
throw new Error(`git ${args.join(' ')}: ${msg || 'failed'}`);
|
|
130
|
+
}
|
|
131
|
+
return r.stdout.toString('utf8');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const split0 = (s) => s.split('\0').filter((x) => x !== '');
|
|
135
|
+
|
|
136
|
+
function lstat(p) {
|
|
137
|
+
try {
|
|
138
|
+
return lstatSync(p);
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Bytes and files under a path; symlinks are counted as links, not followed. skip: absolute paths.
|
|
145
|
+
function treeBytes(p, skip = new Set()) {
|
|
146
|
+
if (skip.has(p)) return { bytes: 0, files: 0 };
|
|
147
|
+
const st = lstat(p);
|
|
148
|
+
if (!st) return { bytes: 0, files: 0 };
|
|
149
|
+
if (!st.isDirectory()) return { bytes: st.size, files: 1 };
|
|
150
|
+
let bytes = 0;
|
|
151
|
+
let files = 0;
|
|
152
|
+
let names = [];
|
|
153
|
+
try {
|
|
154
|
+
names = readdirSync(p);
|
|
155
|
+
} catch {}
|
|
156
|
+
for (const n of names) {
|
|
157
|
+
const t = treeBytes(join(p, n), skip);
|
|
158
|
+
bytes += t.bytes;
|
|
159
|
+
files += t.files;
|
|
160
|
+
}
|
|
161
|
+
return { bytes, files };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Every path under dir (directories too, for tar --no-recursion), relative to base, sorted.
|
|
165
|
+
function walk(dir, base, skip, out = []) {
|
|
166
|
+
if (skip.has(dir)) return out;
|
|
167
|
+
out.push(relative(base, dir));
|
|
168
|
+
let names = [];
|
|
169
|
+
try {
|
|
170
|
+
names = readdirSync(dir).sort();
|
|
171
|
+
} catch {}
|
|
172
|
+
for (const n of names) {
|
|
173
|
+
const p = join(dir, n);
|
|
174
|
+
const st = lstat(p);
|
|
175
|
+
if (!st || skip.has(p)) continue;
|
|
176
|
+
if (st.isDirectory()) walk(p, base, skip, out);
|
|
177
|
+
else out.push(relative(base, p));
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Why an untracked path is left out, or null when it goes. keep: names the user opted back in.
|
|
183
|
+
// Returns { group, kind }: group is the directory prefix (`web/node_modules/`), the file, or the pattern.
|
|
184
|
+
export function excludedBy(path, isDirEntry, keep = new Set()) {
|
|
185
|
+
const parts = path.replace(/\/$/, '').split('/');
|
|
186
|
+
const last = parts.length - 1;
|
|
187
|
+
for (let i = 0; i < parts.length; i++) {
|
|
188
|
+
const n = parts[i];
|
|
189
|
+
// The last component is a directory only for git's `dir/` entries (nested repos) or symlinks.
|
|
190
|
+
if (i === last && !isDirEntry) break;
|
|
191
|
+
if (keep.has(n)) continue;
|
|
192
|
+
if (EXCLUDED_DIRS.includes(n) || DIR_RES.some((re) => re.test(n)))
|
|
193
|
+
return { group: parts.slice(0, i + 1).join('/') + '/', kind: 'build' };
|
|
194
|
+
}
|
|
195
|
+
const base = parts[last];
|
|
196
|
+
if (isEnvName(base)) return { group: path, kind: 'env' };
|
|
197
|
+
if (!keep.has(base)) for (const [g, re] of FILE_RES) if (re.test(base)) return { group: g, kind: 'build' };
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function human(bytes) {
|
|
202
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
203
|
+
const u = ['KB', 'MB', 'GB', 'TB'];
|
|
204
|
+
let v = bytes / 1024;
|
|
205
|
+
let i = 0;
|
|
206
|
+
while (v >= 1024 && i < u.length - 1) {
|
|
207
|
+
v /= 1024;
|
|
208
|
+
i++;
|
|
209
|
+
}
|
|
210
|
+
return `${v >= 10 ? Math.round(v) : v.toFixed(1)} ${u[i]}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const real = (p) => {
|
|
214
|
+
try {
|
|
215
|
+
return realpathSync(p);
|
|
216
|
+
} catch {
|
|
217
|
+
return resolve(p);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
const inside = (p, dir) => p === dir || p.startsWith(dir + '/');
|
|
221
|
+
|
|
222
|
+
// `git worktree list --porcelain -z`, parsed. The first entry is the main checkout.
|
|
223
|
+
export function listWorktrees(cwd) {
|
|
224
|
+
const out = [];
|
|
225
|
+
let cur = null;
|
|
226
|
+
for (const line of git(['worktree', 'list', '--porcelain', '-z'], cwd).split('\0')) {
|
|
227
|
+
if (line === '') {
|
|
228
|
+
if (cur) out.push(cur);
|
|
229
|
+
cur = null;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const sp = line.indexOf(' ');
|
|
233
|
+
const key = sp < 0 ? line : line.slice(0, sp);
|
|
234
|
+
const val = sp < 0 ? '' : line.slice(sp + 1);
|
|
235
|
+
if (key === 'worktree') cur = { path: val };
|
|
236
|
+
else if (!cur) continue;
|
|
237
|
+
else if (key === 'HEAD') cur.head = val;
|
|
238
|
+
else if (key === 'branch') cur.branch = val.replace(/^refs\/heads\//, '');
|
|
239
|
+
else if (key === 'detached') cur.detached = true;
|
|
240
|
+
else if (key === 'bare') cur.bare = true;
|
|
241
|
+
else if (key === 'locked') cur.locked = val || true;
|
|
242
|
+
else if (key === 'prunable') cur.prunable = val || true;
|
|
243
|
+
}
|
|
244
|
+
if (cur) out.push(cur);
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// The files of one checkout (main or linked worktree) to send, relative to it, with its sizes.
|
|
249
|
+
// nested: absolute paths of other worktrees inside it, which are moved on their own.
|
|
250
|
+
function checkoutFiles(top, { keep, nested = [] }) {
|
|
251
|
+
const files = [];
|
|
252
|
+
let trackedBytes = 0;
|
|
253
|
+
for (const f of split0(git(['ls-files', '-z', '--cached'], top))) {
|
|
254
|
+
const st = lstat(join(top, f));
|
|
255
|
+
if (!st) continue; // deleted in the work tree: `git status` shows that from the index
|
|
256
|
+
files.push(f);
|
|
257
|
+
trackedBytes += st.isDirectory() ? 0 : st.size; // a directory here is a submodule
|
|
258
|
+
}
|
|
259
|
+
const trackedFiles = files.length;
|
|
260
|
+
const tracked = new Set(files);
|
|
261
|
+
const nestedRel = nested.map((p) => relative(top, p));
|
|
262
|
+
|
|
263
|
+
let untrackedBytes = 0;
|
|
264
|
+
let untrackedFiles = 0;
|
|
265
|
+
const groups = new Map();
|
|
266
|
+
for (const f of split0(git(['ls-files', '-z', '--others', '--exclude-standard'], top))) {
|
|
267
|
+
const bare = f.replace(/\/$/, '');
|
|
268
|
+
if (tracked.has(f) || nestedRel.some((n) => inside(bare, n))) continue;
|
|
269
|
+
const st = lstat(join(top, bare));
|
|
270
|
+
if (!st) continue;
|
|
271
|
+
const isDir = st.isDirectory();
|
|
272
|
+
const why = excludedBy(f, f.endsWith('/') || isDir || st.isSymbolicLink(), keep);
|
|
273
|
+
if (why) {
|
|
274
|
+
const size = isDir ? treeBytes(join(top, bare)) : { bytes: st.size, files: 1 };
|
|
275
|
+
const g = groups.get(why.group) || { bytes: 0, files: 0, kind: why.kind };
|
|
276
|
+
g.bytes += size.bytes;
|
|
277
|
+
g.files += size.files;
|
|
278
|
+
groups.set(why.group, g);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
files.push(bare);
|
|
282
|
+
untrackedFiles++;
|
|
283
|
+
untrackedBytes += isDir ? 0 : st.size; // a nested repo: only its directory entry goes
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// .env files git ignores (names only): they stay behind too, and the user should know.
|
|
287
|
+
const ignoredEnv = split0(git(['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory'], top))
|
|
288
|
+
.filter((f) => !f.endsWith('/') && isEnvName(f))
|
|
289
|
+
.filter((f) => !nestedRel.some((n) => inside(f, n)));
|
|
290
|
+
const left = [...groups.entries()]
|
|
291
|
+
.map(([path, g]) => ({ path, ...g }))
|
|
292
|
+
.sort((a, b) => b.bytes - a.bytes || a.path.localeCompare(b.path));
|
|
293
|
+
const leftBuild = left.filter((g) => g.kind === 'build');
|
|
294
|
+
const leftBytes = left.reduce((s, g) => s + g.bytes, 0);
|
|
295
|
+
// Staged or unstaged changes to tracked files (untracked ones are counted above).
|
|
296
|
+
const changed = split0(git(['status', '--porcelain', '-z', '--untracked-files=no'], top)).filter(
|
|
297
|
+
(l) => /^[ MADRCUT?!]{2} /.test(l),
|
|
298
|
+
).length;
|
|
299
|
+
return {
|
|
300
|
+
files,
|
|
301
|
+
report: {
|
|
302
|
+
changed,
|
|
303
|
+
tracked: { files: trackedFiles, bytes: trackedBytes },
|
|
304
|
+
untracked: { files: untrackedFiles, bytes: untrackedBytes },
|
|
305
|
+
leftOut: leftBuild,
|
|
306
|
+
leftOutBytes: leftBuild.reduce((s, g) => s + g.bytes, 0),
|
|
307
|
+
envLeftOut: [...left.filter((g) => g.kind === 'env').map((g) => g.path), ...ignoredEnv].sort(),
|
|
308
|
+
envTracked: [...tracked].filter(isEnvName).sort(),
|
|
309
|
+
filesBytes: trackedBytes + untrackedBytes,
|
|
310
|
+
gitRuleFilesBytes: trackedBytes + untrackedBytes + leftBytes,
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function safeName(s) {
|
|
316
|
+
return s.replace(/[^A-Za-z0-9._-]/g, '-');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// The whole plan: the main checkout and each linked worktree, with where each lands on the box.
|
|
320
|
+
// dest: the main checkout's directory on the box. Worktrees inside the main checkout keep their
|
|
321
|
+
// relative path; the others go to <dest>.worktrees/<their directory name>.
|
|
322
|
+
// lite: only where things are, no file lists or sizes (for --list0).
|
|
323
|
+
export function planRepo(cwd = process.cwd(), { dest, keep = [], skip = [], lite = false } = {}) {
|
|
324
|
+
const keepSet = new Set(keep);
|
|
325
|
+
const common = real(resolve(cwd, git(['rev-parse', '--git-common-dir'], cwd).trim()));
|
|
326
|
+
const wts = listWorktrees(cwd);
|
|
327
|
+
const main = wts[0];
|
|
328
|
+
if (!main || main.bare) throw new Error('a bare repository with worktrees: move each worktree by hand');
|
|
329
|
+
if (basename(common) !== '.git')
|
|
330
|
+
throw new Error(`the git directory ${common} is not <checkout>/.git (--separate-git-dir?): move this repo by hand`);
|
|
331
|
+
const mainSrc = real(main.path);
|
|
332
|
+
if (real(dirname(common)) !== mainSrc) throw new Error(`unexpected layout: ${common} is not ${mainSrc}/.git`);
|
|
333
|
+
dest = (dest || `/data/workspace/${safeName(basename(mainSrc))}`).replace(/\/+$/, '');
|
|
334
|
+
if (!/^\/[A-Za-z0-9._/-]+$/.test(dest)) throw new Error(`--dest ${dest}: use an absolute path of letters, digits and ._-/`);
|
|
335
|
+
const skipSet = new Set(skip.map((p) => real(p)));
|
|
336
|
+
const fromHere = real(git(['rev-parse', '--show-toplevel'], cwd).trim());
|
|
337
|
+
|
|
338
|
+
// Each linked worktree's admin dir (.git/worktrees/<id>), by the checkout its gitdir file names.
|
|
339
|
+
const admins = new Map();
|
|
340
|
+
let ids = [];
|
|
341
|
+
try {
|
|
342
|
+
ids = readdirSync(join(common, 'worktrees'));
|
|
343
|
+
} catch {}
|
|
344
|
+
for (const id of ids) {
|
|
345
|
+
let g;
|
|
346
|
+
try {
|
|
347
|
+
g = readFileSync(join(common, 'worktrees', id, 'gitdir'), 'utf8').trim();
|
|
348
|
+
} catch {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const wt = dirname(resolve(join(common, 'worktrees', id), g));
|
|
352
|
+
admins.set(real(wt), join(common, 'worktrees', id));
|
|
353
|
+
admins.set(wt, join(common, 'worktrees', id));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const linked = [];
|
|
357
|
+
const used = new Set();
|
|
358
|
+
for (const w of wts.slice(1)) {
|
|
359
|
+
const src = real(w.path);
|
|
360
|
+
const entry = {
|
|
361
|
+
src,
|
|
362
|
+
branch: w.branch || null,
|
|
363
|
+
detached: !!w.detached,
|
|
364
|
+
head: w.head || null,
|
|
365
|
+
locked: w.locked || false,
|
|
366
|
+
prunable: w.prunable || false,
|
|
367
|
+
};
|
|
368
|
+
const adminDir = admins.get(src) || admins.get(resolve(w.path)) || null;
|
|
369
|
+
entry.adminDir = adminDir;
|
|
370
|
+
entry.id = adminDir ? basename(adminDir) : null;
|
|
371
|
+
const st = lstat(src);
|
|
372
|
+
if (w.prunable || !st || !st.isDirectory()) {
|
|
373
|
+
entry.move = false;
|
|
374
|
+
entry.why = w.prunable ? `prunable: ${w.prunable === true ? 'missing' : w.prunable}` : 'its directory is missing';
|
|
375
|
+
linked.push(entry);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
// Box paths are safe in a single-quoted shell word: [A-Za-z0-9._-/] only.
|
|
379
|
+
let box = inside(src, mainSrc)
|
|
380
|
+
? `${dest}/${relative(mainSrc, src).split('/').map(safeName).join('/')}`
|
|
381
|
+
: `${dest}.worktrees/${safeName(basename(src))}`;
|
|
382
|
+
for (let i = 2, b = box; used.has(box); i++) box = `${b}-${i}`;
|
|
383
|
+
entry.box = box;
|
|
384
|
+
used.add(entry.box);
|
|
385
|
+
entry.move = !skipSet.has(src);
|
|
386
|
+
if (!entry.move) entry.why = 'unchecked (--skip-worktree)';
|
|
387
|
+
linked.push(entry);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const allLinked = linked.map((w) => w.src);
|
|
391
|
+
const nestedOf = (top) => allLinked.filter((p) => p !== top && inside(p, top));
|
|
392
|
+
if (lite) return { main: { src: mainSrc, box: dest, move: true }, worktrees: linked, nestedOf };
|
|
393
|
+
const mainFiles = checkoutFiles(mainSrc, { keep: keepSet, nested: nestedOf(mainSrc) });
|
|
394
|
+
const gitDir = join(mainSrc, '.git');
|
|
395
|
+
// Admin dirs of worktrees that are not moved stay behind, so the box does not list them.
|
|
396
|
+
const adminSkip = new Set(
|
|
397
|
+
linked.filter((w) => !w.move && w.adminDir).map((w) => w.adminDir),
|
|
398
|
+
);
|
|
399
|
+
const gitBytes = treeBytes(real(gitDir), adminSkip).bytes;
|
|
400
|
+
const nestedAbs = new Set(nestedOf(mainSrc));
|
|
401
|
+
const mainDisk = treeBytes(mainSrc, nestedAbs).bytes;
|
|
402
|
+
const plan = {
|
|
403
|
+
from: fromHere,
|
|
404
|
+
main: {
|
|
405
|
+
src: mainSrc,
|
|
406
|
+
box: dest,
|
|
407
|
+
branch: main.branch || null,
|
|
408
|
+
detached: !!main.detached,
|
|
409
|
+
head: main.head || null,
|
|
410
|
+
move: true,
|
|
411
|
+
diskBytes: mainDisk,
|
|
412
|
+
gitBytes,
|
|
413
|
+
...mainFiles.report,
|
|
414
|
+
sendBytes: gitBytes + mainFiles.report.filesBytes,
|
|
415
|
+
},
|
|
416
|
+
worktrees: [],
|
|
417
|
+
};
|
|
418
|
+
for (const w of linked) {
|
|
419
|
+
if (!w.adminDir && w.move !== false) {
|
|
420
|
+
w.move = false;
|
|
421
|
+
w.why = 'not a working git checkout';
|
|
422
|
+
}
|
|
423
|
+
if (w.move === false && !lstat(w.src)) {
|
|
424
|
+
plan.worktrees.push(w);
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const f = checkoutFiles(w.src, { keep: keepSet, nested: nestedOf(w.src) });
|
|
428
|
+
plan.worktrees.push({
|
|
429
|
+
...w,
|
|
430
|
+
diskBytes: treeBytes(w.src, new Set(nestedOf(w.src))).bytes,
|
|
431
|
+
...f.report,
|
|
432
|
+
sendBytes: f.report.filesBytes,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
delete plan.main.filesBytes;
|
|
436
|
+
for (const w of plan.worktrees) delete w.adminDir;
|
|
437
|
+
const moving = [plan.main, ...plan.worktrees.filter((w) => w.move)];
|
|
438
|
+
plan.totals = {
|
|
439
|
+
diskBytes: plan.main.diskBytes + plan.worktrees.reduce((s, w) => s + (w.diskBytes || 0), 0),
|
|
440
|
+
gitRuleBytes: gitBytes + moving.reduce((s, w) => s + w.gitRuleFilesBytes, 0),
|
|
441
|
+
leftOutBytes: moving.reduce((s, w) => s + w.leftOutBytes, 0),
|
|
442
|
+
sendBytes: moving.reduce((s, w) => s + w.sendBytes, 0),
|
|
443
|
+
};
|
|
444
|
+
plan.keep = [...keepSet];
|
|
445
|
+
return plan;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// The NUL list for one checkout: relative paths, directories included for tar --no-recursion.
|
|
449
|
+
export function listFor(path, { keep = [], skip = [] } = {}) {
|
|
450
|
+
const plan = planRepo(path, { keep, skip, lite: true });
|
|
451
|
+
const top = real(git(['rev-parse', '--show-toplevel'], path).trim());
|
|
452
|
+
const isMain = top === plan.main.src;
|
|
453
|
+
const w = isMain ? plan.main : plan.worktrees.find((x) => x.src === top);
|
|
454
|
+
if (!w || !w.move) throw new Error(`${top} is not moved in this plan`);
|
|
455
|
+
const { files } = checkoutFiles(top, { keep: new Set(keep), nested: plan.nestedOf(top) });
|
|
456
|
+
if (!isMain) return ['.git', ...files];
|
|
457
|
+
// The admin dirs of worktrees that stay behind (unchecked, prunable, missing) stay too.
|
|
458
|
+
const skipAdmin = new Set(plan.worktrees.filter((x) => !x.move && x.adminDir).map((x) => x.adminDir));
|
|
459
|
+
return [...walk(real(join(top, '.git')), top, skipAdmin), ...files];
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function describe(w) {
|
|
463
|
+
const at = w.detached ? `detached at ${(w.head || '').slice(0, 12)}` : w.branch ? `branch ${w.branch}` : '';
|
|
464
|
+
const dirty = w.changed ? `${w.changed} uncommitted change${w.changed === 1 ? '' : 's'}` : 'no uncommitted changes';
|
|
465
|
+
const flags = [at, dirty, w.locked ?`locked${w.locked === true ? '' : ': ' + w.locked}` : ''].filter(Boolean);
|
|
466
|
+
return flags.join(', ');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function formatPlan(p) {
|
|
470
|
+
const pad = (s) => s.padStart(8);
|
|
471
|
+
const L = [];
|
|
472
|
+
const block = (w, title) => {
|
|
473
|
+
L.push(`${title} ${w.src}`);
|
|
474
|
+
L.push(` -> ${w.box}${describe(w) ? ` (${describe(w)})` : ''}`);
|
|
475
|
+
L.push(` before: ${pad(human(w.diskBytes))} on disk, ${human(w.gitRuleFilesBytes + (w.gitBytes || 0))} by git's rule`);
|
|
476
|
+
if (w.leftOut.length) {
|
|
477
|
+
L.push(` left out ${human(w.leftOutBytes)} of build output and dependencies git does not ignore:`);
|
|
478
|
+
for (const g of w.leftOut.slice(0, 12))
|
|
479
|
+
L.push(` ${g.path} ${human(g.bytes)} (${g.files} file${g.files === 1 ? '' : 's'})`);
|
|
480
|
+
if (w.leftOut.length > 12) L.push(` ... and ${w.leftOut.length - 12} more`);
|
|
481
|
+
}
|
|
482
|
+
if (w.envLeftOut.length) L.push(` .env files left out (never copied): ${w.envLeftOut.join(', ')}`);
|
|
483
|
+
if (w.envTracked.length) L.push(` committed .env files (they go with .git): ${w.envTracked.join(', ')}`);
|
|
484
|
+
const parts = [];
|
|
485
|
+
if (w.gitBytes !== undefined) parts.push(`.git ${human(w.gitBytes)}`);
|
|
486
|
+
parts.push(`${w.tracked.files} tracked ${human(w.tracked.bytes)}`);
|
|
487
|
+
parts.push(`${w.untracked.files} untracked ${human(w.untracked.bytes)}`);
|
|
488
|
+
L.push(` after: ${pad(human(w.sendBytes))} sent (${parts.join(', ')})`);
|
|
489
|
+
};
|
|
490
|
+
block(p.main, 'main checkout');
|
|
491
|
+
for (const w of p.worktrees) {
|
|
492
|
+
if (w.move) block(w, 'worktree');
|
|
493
|
+
else L.push(`worktree ${w.src} NOT moved: ${w.why}${w.sendBytes !== undefined ? ` (${human(w.sendBytes)})` : ''}`);
|
|
494
|
+
}
|
|
495
|
+
const n = p.worktrees.filter((w) => w.move).length;
|
|
496
|
+
L.push(
|
|
497
|
+
`total: ${human(p.totals.diskBytes)} on disk; git's rule ${human(p.totals.gitRuleBytes)}; left out ${human(
|
|
498
|
+
p.totals.leftOutBytes,
|
|
499
|
+
)}; sending ${human(p.totals.sendBytes)} (main checkout${n ? ` + ${n} worktree${n === 1 ? '' : 's'}` : ''})`,
|
|
500
|
+
);
|
|
501
|
+
if (p.keep.length) L.push(`copied anyway (--keep): ${p.keep.join(', ')}`);
|
|
502
|
+
return L.join('\n') + '\n';
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function main(argv) {
|
|
506
|
+
const keep = [];
|
|
507
|
+
const skip = [];
|
|
508
|
+
let mode = 'report';
|
|
509
|
+
let dest;
|
|
510
|
+
let listPath;
|
|
511
|
+
for (let i = 0; i < argv.length; i++) {
|
|
512
|
+
const a = argv[i];
|
|
513
|
+
const val = () => {
|
|
514
|
+
if (argv[i + 1] === undefined) throw new Error(`${a} needs a value`);
|
|
515
|
+
return argv[++i];
|
|
516
|
+
};
|
|
517
|
+
try {
|
|
518
|
+
if (a === '--json') mode = 'json';
|
|
519
|
+
else if (a === '--list0') (mode = 'list0'), (listPath = val());
|
|
520
|
+
else if (a === '--dest') dest = val();
|
|
521
|
+
else if (a === '--keep') keep.push(val());
|
|
522
|
+
else if (a === '--skip-worktree') skip.push(val());
|
|
523
|
+
else if (a === '-h' || a === '--help') {
|
|
524
|
+
process.stdout.write(
|
|
525
|
+
'usage: node repo-files.mjs [--dest DIR] [--json | --list0 PATH] [--skip-worktree PATH]... [--keep NAME]...\n',
|
|
526
|
+
);
|
|
527
|
+
return 0;
|
|
528
|
+
} else throw new Error(`unknown argument ${a}`);
|
|
529
|
+
} catch (e) {
|
|
530
|
+
process.stderr.write(`repo-files: ${e.message}\n`);
|
|
531
|
+
return 2;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
try {
|
|
535
|
+
if (mode === 'list0') {
|
|
536
|
+
process.stdout.write(listFor(listPath, { keep, skip }).map((f) => f + '\0').join(''));
|
|
537
|
+
return 0;
|
|
538
|
+
}
|
|
539
|
+
const plan = planRepo(process.cwd(), { dest, keep, skip });
|
|
540
|
+
process.stdout.write(mode === 'json' ? JSON.stringify(plan, null, 2) + '\n' : formatPlan(plan));
|
|
541
|
+
return 0;
|
|
542
|
+
} catch (e) {
|
|
543
|
+
process.stderr.write(`repo-files: ${e.message}\n`);
|
|
544
|
+
return 1;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
let isMain = false;
|
|
549
|
+
try {
|
|
550
|
+
isMain = realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
551
|
+
} catch {}
|
|
552
|
+
if (isMain) process.exitCode = main(process.argv.slice(2));
|
|
@@ -25,7 +25,7 @@ instabox snapshot save work --name work-ready --description "deps installed, rep
|
|
|
25
25
|
|
|
26
26
|
```sh
|
|
27
27
|
instabox snapshot ls # your snapshots: id, name, root/data size, source
|
|
28
|
-
instabox create --name work2 --snapshot work-ready
|
|
28
|
+
instabox create --name work2 --snapshot work-ready
|
|
29
29
|
instabox exec work2 -- ls /data/home # same root disk and /data as when saved
|
|
30
30
|
```
|
|
31
31
|
|
|
@@ -58,7 +58,7 @@ Snapshots count toward the account's snapshot and storage limits (`instabox usag
|
|
|
58
58
|
|
|
59
59
|
## Typical agent loop
|
|
60
60
|
|
|
61
|
-
1. `instabox create --name job --snapshot <base
|
|
61
|
+
1. `instabox create --name job --snapshot <base>` (it pauses by itself after 30 idle minutes; add `--timeout 2h --on-timeout kill` for a hard end)
|
|
62
62
|
2. Work with `instabox exec job -- ...`
|
|
63
63
|
3. When the setup is worth keeping: `instabox snapshot save job --name <base>-v2` (no need to stop it)
|
|
64
64
|
4. `instabox rm job` when done.
|