git-sich 0.2.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +1 -0
  2. package/dist/cli.js +199 -4
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -94,6 +94,7 @@ setup
94
94
  new <layer> [--remote <url> | --gh [name]]
95
95
  create a layer (--gh: private GitHub repo)
96
96
  attach <layer> <url> join an existing layer (collaborators)
97
+ detach <layer> [--force] [--yes] remove a layer here; files and remote stay
97
98
 
98
99
  ownership
99
100
  claim <layer> <path...> [--move] claim files/folders for a layer, stage them
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { realpathSync as realpathSync3, statSync } from "node:fs";
5
5
  import { resolve as resolve6 } from "node:path";
6
6
  // package.json
7
- var version = "0.2.0";
7
+ var version = "0.3.0";
8
8
 
9
9
  // src/ui.ts
10
10
  class SichError extends Error {
@@ -42,6 +42,23 @@ function printError(message) {
42
42
  process.stderr.write(`${ce.red("sich:")} ${message}
43
43
  `);
44
44
  }
45
+ async function confirm(question) {
46
+ process.stderr.write(`${question} [y/N] `);
47
+ let answer = "";
48
+ try {
49
+ for await (const chunk of process.stdin) {
50
+ answer += String(chunk);
51
+ if (answer.includes(`
52
+ `))
53
+ break;
54
+ }
55
+ } catch {}
56
+ if (!process.stdin.isTTY)
57
+ process.stderr.write(`
58
+ `);
59
+ return /^y(es)?$/i.test(answer.split(`
60
+ `)[0].trim());
61
+ }
45
62
  function plural(n, word, pluralWord = word + "s") {
46
63
  return `${n} ${n === 1 ? word : pluralWord}`;
47
64
  }
@@ -405,6 +422,7 @@ var COMMANDS = [
405
422
  "init",
406
423
  "new",
407
424
  "attach",
425
+ "detach",
408
426
  "claim",
409
427
  "unclaim",
410
428
  "add",
@@ -1164,7 +1182,7 @@ function cmdLs(ctx, args) {
1164
1182
 
1165
1183
  // src/commands/layer.ts
1166
1184
  import { spawnSync as spawnSync2 } from "node:child_process";
1167
- import { existsSync as existsSync6, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
1185
+ import { existsSync as existsSync6, readdirSync as readdirSync2, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
1168
1186
  import { basename as basename3, isAbsolute as isAbsolute2, join as join4, resolve as resolve5 } from "node:path";
1169
1187
  function assertFree(ctx, layer) {
1170
1188
  validateLayerName(layer);
@@ -1290,6 +1308,166 @@ function cmdAttach(ctx, args) {
1290
1308
  installLayerHooks(ctx, layer);
1291
1309
  return 0;
1292
1310
  }
1311
+ var IN_PROGRESS = [
1312
+ ["MERGE_HEAD", "merge"],
1313
+ ["rebase-merge", "rebase"],
1314
+ ["rebase-apply/applying", "am"],
1315
+ ["rebase-apply", "rebase"],
1316
+ ["CHERRY_PICK_HEAD", "cherry-pick"],
1317
+ ["REVERT_HEAD", "revert"]
1318
+ ];
1319
+ var preferredRemote = (remotes) => remotes.includes("origin") ? "origin" : remotes[0];
1320
+ function onlyInGitDir(repo) {
1321
+ const run = `sich ${repo.name}`;
1322
+ const losses = [];
1323
+ const count = (revs, allowFail = false) => Number(git(repo, ["rev-list", "--count", ...revs, "--"], { allowFail }).stdout.trim() || 0);
1324
+ const remote = preferredRemote(remotesOf(repo));
1325
+ if (!remote) {
1326
+ const n = count(["--exclude=refs/stash", "--all"]);
1327
+ losses.push({
1328
+ what: `${plural(n, "commit")} with no remote to push to`,
1329
+ fix: `${run} remote add origin <url>, then sich push ${repo.name}`
1330
+ });
1331
+ } else {
1332
+ const current = currentBranch(repo);
1333
+ const refs = lines(git(repo, ["for-each-ref", "--format=%(objectname) %(refname)"]).stdout).map((l) => {
1334
+ const [oid, ref] = l.split(" ");
1335
+ return { oid, ref };
1336
+ });
1337
+ for (const { ref } of refs.filter((r) => r.ref.startsWith("refs/heads/"))) {
1338
+ const n = count([ref, "--not", "--remotes"]);
1339
+ if (!n)
1340
+ continue;
1341
+ const branch = ref.slice("refs/heads/".length);
1342
+ losses.push({
1343
+ what: `${plural(n, "commit")} on ${branch} not on any remote`,
1344
+ fix: branch === current ? `sich push ${repo.name}` : `${run} push -u ${remote} ${shellQuote(branch)}`
1345
+ });
1346
+ }
1347
+ let advertised;
1348
+ const remoteHas = (oid) => {
1349
+ if (advertised === undefined) {
1350
+ const r = git(repo, ["ls-remote", remote], { allowFail: true });
1351
+ advertised = r.code === 0 ? new Set(lines(r.stdout).map((l) => l.split("\t")[0])) : null;
1352
+ }
1353
+ return advertised?.has(oid) ?? false;
1354
+ };
1355
+ const others = refs.filter((r) => !/^refs\/(heads|remotes|prefetch)\//.test(r.ref) && r.ref !== "refs/stash");
1356
+ for (const { oid, ref } of others) {
1357
+ const n = count([`${ref}^{commit}`, "--not", "--remotes", "--branches"], true);
1358
+ if (!n || remoteHas(oid))
1359
+ continue;
1360
+ const name = ref.startsWith("refs/tags/") ? ref.slice("refs/tags/".length) : ref;
1361
+ const unchecked = advertised === null ? ` (could not reach ${remote} to check)` : "";
1362
+ losses.push({
1363
+ what: `${plural(n, "commit")} only on ${name === ref ? ref : `tag ${name}`}${unchecked}`,
1364
+ fix: `${run} push ${remote} ${shellQuote(name)}`
1365
+ });
1366
+ }
1367
+ if (current === null) {
1368
+ const n = count(["HEAD", "--not", "--remotes", "--branches", "--tags"]);
1369
+ if (n) {
1370
+ losses.push({ what: `${plural(n, "commit")} only on the detached HEAD`, fix: `${run} switch -c <branch>` });
1371
+ }
1372
+ }
1373
+ }
1374
+ const stashes = lines(git(repo, ["stash", "list"]).stdout).length;
1375
+ if (stashes) {
1376
+ losses.push({ what: plural(stashes, "stash entry", "stash entries"), fix: `${run} stash pop, then commit` });
1377
+ }
1378
+ const staged = splitZ(git(repo, ["diff", "--cached", "--name-only", "-z", "--no-renames", "--diff-filter=du"]).stdout);
1379
+ const changed = new Set(splitZ(git(repo, ["diff", "--name-only", "-z", "--no-renames"]).stdout));
1380
+ for (const e of splitZ(git(repo, ["ls-files", "-v", "-z"]).stdout)) {
1381
+ if (/^[a-zS] /.test(e))
1382
+ changed.add(e.slice(2));
1383
+ }
1384
+ const stagedOnly = staged.filter((f) => changed.has(f));
1385
+ if (stagedOnly.length) {
1386
+ losses.push({
1387
+ what: `staged versions of ${listSome(stagedOnly)} that differ from what is on disk`,
1388
+ fix: `${run} commit -m <msg>, then sich push ${repo.name}`
1389
+ });
1390
+ }
1391
+ const worktrees = lines(git(repo, ["worktree", "list", "--porcelain"]).stdout).filter((l) => l.startsWith("worktree ")).slice(1).map((l) => l.slice("worktree ".length));
1392
+ if (worktrees.length) {
1393
+ losses.push({
1394
+ what: `${plural(worktrees.length, "linked worktree")}: ${listSome(worktrees)}`,
1395
+ fix: `${run} worktree remove <path>`
1396
+ });
1397
+ }
1398
+ const modules = join4(repo.gitDir, "modules");
1399
+ if (existsSync6(modules) && readdirSync2(modules).length) {
1400
+ losses.push({ what: `submodule repos in .sich/${repo.name}/modules/`, fix: `push and remove the submodules` });
1401
+ }
1402
+ const op = IN_PROGRESS.find(([file]) => existsSync6(join4(repo.gitDir, file)))?.[1];
1403
+ if (op)
1404
+ losses.push({ what: `a ${op} in progress`, fix: `finish it, or ${run} ${op} --abort` });
1405
+ return losses;
1406
+ }
1407
+ async function cmdDetach(ctx, args) {
1408
+ const p = parseArgs(args, { "--force": "bool", "--yes": "bool" }, { "-y": "--yes" });
1409
+ const [layer, ...extra] = p.positionals;
1410
+ if (!layer || extra.length)
1411
+ fail("usage: sich detach <layer> [--force] [--yes]");
1412
+ const repo = requireLayer(ctx, layer);
1413
+ const losses = onlyInGitDir(repo);
1414
+ if (losses.length && !p.flags["--force"]) {
1415
+ for (const l of losses)
1416
+ out(`${c.red("✗")} ${l.what} (fix: ${l.fix})`);
1417
+ fail(`detaching ${layer} would lose what exists only in .sich/${layer}/ (see above); --force discards it`);
1418
+ }
1419
+ const claims = readManifest(manifestPath(ctx, layer), layer).claims;
1420
+ const remote = preferredRemote(remotesOf(repo));
1421
+ const url = remote && git(repo, ["remote", "get-url", remote], { allowFail: true }).stdout.trim();
1422
+ if (p.flags["--yes"]) {
1423
+ for (const l of losses)
1424
+ warn(`discarding ${l.what}`);
1425
+ } else {
1426
+ const onDisk = claims.filter((cl) => existsSync6(join4(ctx.root, bare(cl))));
1427
+ const row = (label, text) => out(` ${label}${" ".repeat(10 - label.length)}${text}`);
1428
+ out(`detaching ${c.bold(layer)}:`);
1429
+ row("deletes", `.sich/${layer}/ (its git data: local history, index, hooks, config)`);
1430
+ if (existsSync6(manifestPath(ctx, layer))) {
1431
+ row("deletes", `${manifestRel(layer)} (claims list: ${claims.length ? listSome(claims) : "empty"})`);
1432
+ }
1433
+ for (const l of losses)
1434
+ row("discards", l.what);
1435
+ row("keeps", onDisk.length ? `${listSome(onDisk)} on disk, no longer hidden from base` : "files: none on disk");
1436
+ if (url)
1437
+ row("keeps", `remote ${remote} (${url}) and everything pushed to it`);
1438
+ if (!await confirm(`Detach ${layer}?`)) {
1439
+ fail(process.stdin.isTTY ? "not detached" : "not detached (no answer on stdin; --yes skips the question)");
1440
+ }
1441
+ const shown = new Set(losses.map((l) => l.what));
1442
+ const added = onlyInGitDir(repo).filter((l) => !shown.has(l.what));
1443
+ if (added.length) {
1444
+ for (const l of added)
1445
+ out(`${c.red("✗")} ${l.what} (fix: ${l.fix})`);
1446
+ fail(`${layer} changed while waiting for the answer (see above); not detached`);
1447
+ }
1448
+ }
1449
+ const manifest = existsSync6(manifestPath(ctx, layer));
1450
+ rmSync(repo.gitDir, { recursive: true, force: true });
1451
+ rmSync(manifestPath(ctx, layer), { force: true });
1452
+ const deleted = `git data -> .sich/${layer}/${manifest ? `, claims list -> ${manifestRel(layer)}` : ""}`;
1453
+ out(`${c.green("detached")} layer ${c.bold(layer)}: deleted ${deleted}`);
1454
+ for (const t of excludeTargets(ctx)) {
1455
+ if (!applyExclude(t, true))
1456
+ continue;
1457
+ const file = excludeFileShown(t.repo);
1458
+ out(t.repo.name === "base" ? `no longer hidden from base -> ${file}` : `updated the excludes of ${t.repo.name} -> ${file}`);
1459
+ }
1460
+ const untracked = ["ls-files", "-z", "--others", "--exclude-standard", "--directory", "--no-empty-directory"];
1461
+ const visible = claims.length ? splitZ(git(ctx.base, [...untracked, "--", ...claims]).stdout) : [];
1462
+ if (visible.length) {
1463
+ warn(`base now sees ${listSome(visible)} as untracked ` + `(delete, .gitignore or claim ${visible.length === 1 ? "it" : "them"} before committing base)`);
1464
+ }
1465
+ if (url) {
1466
+ const away = claims.some((cl) => existsSync6(join4(ctx.root, bare(cl)))) ? "move its files away, then " : "";
1467
+ note(`its remote is untouched; to attach again: ${away}sich attach ${layer} ${shellQuote(url)}`);
1468
+ }
1469
+ return 0;
1470
+ }
1293
1471
 
1294
1472
  // src/commands/status.ts
1295
1473
  function parseStatus(raw) {
@@ -1525,6 +1703,7 @@ setup
1525
1703
  new <layer> [--remote <url> | --gh [name]]
1526
1704
  create a layer (--gh: private GitHub repo)
1527
1705
  attach <layer> <url> join an existing layer (collaborators)
1706
+ detach <layer> [--force] [--yes] remove a layer here; files and remote stay
1528
1707
 
1529
1708
  ownership
1530
1709
  claim <layer> <path...> [--move] claim files/folders for a layer, stage them
@@ -1587,6 +1766,21 @@ its pre-commit and pre-merge-commit hooks (see sich init).
1587
1766
  Joins an existing layer: fetches <url> and checks out its default branch into
1588
1767
  this working tree, and installs its pre-commit and pre-merge-commit hooks (see
1589
1768
  sich init). Refuses to overwrite existing files.`,
1769
+ detach: `usage: sich detach <layer> [--force] [--yes]
1770
+
1771
+ The opposite of attach: removes a layer from this working tree by deleting its
1772
+ git data (.sich/<layer>/) and claims list (.sich/<layer>.paths). Its files stay
1773
+ on disk, no longer hidden from base (nothing stops base from committing them
1774
+ any more), and its remote is left alone: attach it again any time.
1775
+ Refuses if that would lose what exists only in .sich/<layer>/: commits on no
1776
+ remote (for branches: as of the last fetch or push), stashes, staged versions
1777
+ of files that changed again on disk, linked worktrees, submodule repos, or a
1778
+ merge, rebase or similar in progress. Uncommitted changes to files don't
1779
+ count, since the files stay.
1780
+ Lists what it deletes and what it keeps, then asks before doing it (no answer,
1781
+ e.g. without a terminal, means no).
1782
+ --force detach anyway, discarding all of that
1783
+ -y, --yes don't ask`,
1590
1784
  claim: `usage: sich claim <layer> <path...> [--move]
1591
1785
 
1592
1786
  Claims paths (files or directories, relative to the current directory) for a
@@ -1669,6 +1863,7 @@ var HANDLERS = {
1669
1863
  init: cmdInit,
1670
1864
  new: cmdNew,
1671
1865
  attach: cmdAttach,
1866
+ detach: cmdDetach,
1672
1867
  claim: cmdClaim,
1673
1868
  unclaim: cmdUnclaim,
1674
1869
  add: cmdClaim,
@@ -1695,7 +1890,7 @@ function changeDir(cwd, dir) {
1695
1890
  fail(`-C ${dir}: no such directory`);
1696
1891
  }
1697
1892
  }
1698
- function main(argv) {
1893
+ async function main(argv) {
1699
1894
  let cwd = realpathSync3.native(process.cwd());
1700
1895
  let i = 0;
1701
1896
  for (;i < argv.length; i++) {
@@ -1754,7 +1949,7 @@ function main(argv) {
1754
1949
  }
1755
1950
  var code;
1756
1951
  try {
1757
- code = main(process.argv.slice(2));
1952
+ code = await main(process.argv.slice(2));
1758
1953
  } catch (e) {
1759
1954
  if (e instanceof SichError)
1760
1955
  printError(e.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-sich",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Private git layers sharing one working tree with a normal (public) repo",
5
5
  "keywords": [
6
6
  "git",