@ozyman42/ozy-cli 0.1.0 → 0.2.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.
Files changed (2) hide show
  1. package/dist/index.js +439 -269
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -2784,7 +2784,7 @@ var require_ssh_config2 = __commonJS((exports) => {
2784
2784
  exports.default = ssh_config_1.default;
2785
2785
  });
2786
2786
  // package.json
2787
- var version = "0.1.0";
2787
+ var version = "0.2.1";
2788
2788
 
2789
2789
  // node_modules/commander/esm.mjs
2790
2790
  var import__ = __toESM(require_commander(), 1);
@@ -8028,22 +8028,30 @@ function parseIndexGitlinks(output) {
8028
8028
  return { commit: match2[1], path: match2[2] };
8029
8029
  });
8030
8030
  }
8031
- function findGitDirs(root, rel = "") {
8031
+ function scanModuleDirs(root, modulePaths, rel = "") {
8032
+ const gitDirs = [];
8033
+ const orphanDirs = [];
8032
8034
  if (!existsSync2(root))
8033
- return [];
8034
- const results = [];
8035
+ return { gitDirs, orphanDirs };
8035
8036
  for (const entry of readdirSync(root)) {
8036
8037
  const abs = join(root, entry);
8037
- const relPath = rel ? `${rel}/${entry}` : entry;
8038
8038
  if (!statSync(abs).isDirectory())
8039
8039
  continue;
8040
+ const relPath = rel ? `${rel}/${entry}` : entry;
8040
8041
  if (existsSync2(join(abs, "HEAD"))) {
8041
- results.push(relPath);
8042
+ gitDirs.push(relPath);
8042
8043
  } else {
8043
- results.push(...findGitDirs(abs, relPath));
8044
+ const isPrefix = [...modulePaths].some((p) => p.startsWith(relPath + "/"));
8045
+ if (isPrefix) {
8046
+ const sub = scanModuleDirs(abs, modulePaths, relPath);
8047
+ gitDirs.push(...sub.gitDirs);
8048
+ orphanDirs.push(...sub.orphanDirs);
8049
+ } else {
8050
+ orphanDirs.push(relPath);
8051
+ }
8044
8052
  }
8045
8053
  }
8046
- return results;
8054
+ return { gitDirs, orphanDirs };
8047
8055
  }
8048
8056
  async function readModuleDirRemoteUrl(abs) {
8049
8057
  const configPath = join(abs, "config");
@@ -8053,15 +8061,38 @@ async function readModuleDirRemoteUrl(abs) {
8053
8061
  const match2 = content.match(/\[remote "origin"\][^\[]*\n\s+url\s*=\s*(.+)/);
8054
8062
  return match2?.[1].trim();
8055
8063
  }
8064
+ async function spawnGitInDir(args, cwd) {
8065
+ const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
8066
+ const [code, out, err] = await Promise.all([
8067
+ proc.exited,
8068
+ new Response(proc.stdout).text(),
8069
+ new Response(proc.stderr).text()
8070
+ ]);
8071
+ return code === 0 ? out.trim() : { error: err.trim().split(`
8072
+ `)[0] };
8073
+ }
8056
8074
  async function readWorkingTreeEntry(repoRoot, subPath) {
8057
- const dotGit = join(repoRoot, subPath, ".git");
8058
- if (!existsSync2(dotGit))
8059
- return { path: subPath, type: "missing" };
8060
- if (statSync(dotGit).isDirectory())
8061
- return { path: subPath, type: "dir" };
8062
- const content = await readFile3(dotGit, "utf8");
8063
- const match2 = content.match(/^gitdir:\s*(.+)$/m);
8064
- return { path: subPath, type: "file", gitdirTarget: match2?.[1].trim() };
8075
+ const workingTreeAbs = join(repoRoot, subPath);
8076
+ const dotGit = join(workingTreeAbs, ".git");
8077
+ let type;
8078
+ let gitdirTarget;
8079
+ if (!existsSync2(dotGit)) {
8080
+ type = "missing";
8081
+ } else if (statSync(dotGit).isDirectory()) {
8082
+ type = "dir";
8083
+ } else {
8084
+ type = "file";
8085
+ const content = await readFile3(dotGit, "utf8");
8086
+ const match2 = content.match(/^gitdir:\s*(.+)$/m);
8087
+ gitdirTarget = match2?.[1].trim();
8088
+ }
8089
+ if (!existsSync2(workingTreeAbs))
8090
+ return { path: subPath, type, gitdirTarget };
8091
+ const [remote, headCommit] = await Promise.all([
8092
+ spawnGitInDir(["remote", "get-url", "origin"], workingTreeAbs),
8093
+ spawnGitInDir(["rev-parse", "HEAD"], workingTreeAbs)
8094
+ ]);
8095
+ return { path: subPath, type, gitdirTarget, remote, headCommit };
8065
8096
  }
8066
8097
  async function readGitState(repoRoot, extraPaths = []) {
8067
8098
  const gitDir = resolve2(repoRoot, ".git");
@@ -8071,8 +8102,9 @@ async function readGitState(repoRoot, extraPaths = []) {
8071
8102
  ]);
8072
8103
  const config = parseGitConfig(configContent);
8073
8104
  const index = parseIndexGitlinks(lsOutput);
8105
+ const modulePaths = new Set(extraPaths);
8074
8106
  const modulesRoot = resolve2(gitDir, "modules");
8075
- const moduleDirPaths = findGitDirs(modulesRoot);
8107
+ const { gitDirs: moduleDirPaths, orphanDirs: orphanModuleDirs } = scanModuleDirs(modulesRoot, modulePaths);
8076
8108
  const moduleDirs = await Promise.all(moduleDirPaths.map(async (rel) => ({
8077
8109
  relativePath: rel,
8078
8110
  remoteUrl: await readModuleDirRemoteUrl(join(modulesRoot, rel))
@@ -8085,11 +8117,11 @@ async function readGitState(repoRoot, extraPaths = []) {
8085
8117
  ])
8086
8118
  ];
8087
8119
  const workingTree = await Promise.all(allPaths.map((p) => readWorkingTreeEntry(repoRoot, p)));
8088
- return { config, moduleDirs, index, workingTree };
8120
+ return { config, moduleDirs, orphanModuleDirs, index, workingTree };
8089
8121
  }
8090
8122
 
8091
8123
  // src/commands/git/sync-submodules/fix.ts
8092
- import { existsSync as existsSync3 } from "fs";
8124
+ import { existsSync as existsSync3, statSync as statSync2 } from "fs";
8093
8125
  import { readFile as readFile4, writeFile, rename, mkdir, rm } from "fs/promises";
8094
8126
  import { resolve as resolve3, join as join2, dirname, relative } from "path";
8095
8127
  function removeExtraConfigSubmodules(content, validNames) {
@@ -8118,295 +8150,359 @@ function removeExtraConfigSubmodules(content, validNames) {
8118
8150
  return { result: output.join(`
8119
8151
  `), removed };
8120
8152
  }
8121
- function repoPath(url) {
8122
- const stripped = url.replace(/\.git$/, "");
8123
- if (stripped.startsWith("http")) {
8124
- const parts = stripped.split("/");
8125
- return parts.slice(-2).join("/");
8126
- }
8127
- const colonIdx = stripped.indexOf(":");
8128
- return colonIdx >= 0 ? stripped.slice(colonIdx + 1) : stripped;
8129
- }
8130
8153
  async function spawnGit(args, cwd) {
8131
- const proc = Bun.spawn(["git", ...args], { cwd, stdout: "inherit", stderr: "inherit" });
8154
+ const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
8132
8155
  const code = await proc.exited;
8133
- if (code !== 0)
8134
- throw new Error(`git ${args.join(" ")} exited with code ${code}`);
8156
+ if (code !== 0) {
8157
+ const err = await new Response(proc.stderr).text();
8158
+ throw new Error(`git ${args.join(" ")} exited with code ${code}
8159
+ ${err.trim()}`);
8160
+ }
8135
8161
  }
8136
- async function fixModuleDirs(repoRoot, modules, moduleDirs) {
8162
+ async function fixGitConfig(repoRoot, modules) {
8163
+ const configPath = resolve3(repoRoot, ".git", "config");
8164
+ const content = await readFile4(configPath, "utf8");
8165
+ const validNames = new Set(modules.map((m) => m.name));
8166
+ const { result: afterRemoval, removed } = removeExtraConfigSubmodules(content, validNames);
8167
+ const existingNames = new Set([...afterRemoval.matchAll(/^\[submodule "(.+)"\]$/gm)].map((m) => m[1]));
8168
+ const missing = modules.filter((m) => !existingNames.has(m.name));
8169
+ let result = afterRemoval;
8170
+ for (const m of missing) {
8171
+ if (!result.endsWith(`
8172
+ `))
8173
+ result += `
8174
+ `;
8175
+ result += `[submodule "${m.name}"]
8176
+ active = true
8177
+ url = ${m.url}
8178
+ path = ${m.path}
8179
+ `;
8180
+ }
8181
+ if (removed.length === 0 && missing.length === 0)
8182
+ return [];
8183
+ await writeFile(configPath, result, "utf8");
8184
+ return [
8185
+ ...removed.map((name) => ({ location: [".git", "config", name], action: "deleted" })),
8186
+ ...missing.map((m) => ({ location: [".git", "config", m.name], action: "synced" }))
8187
+ ];
8188
+ }
8189
+ async function fixModuleDirs(repoRoot, modules, moduleDirs, orphanModuleDirs) {
8190
+ const fixes = [];
8137
8191
  const modulesRoot = resolve3(repoRoot, ".git", "modules");
8138
- const gmByRepoPath = new Map(modules.map((m) => [repoPath(m.url), m]));
8192
+ const gmByPath = new Map(modules.map((m) => [m.path, m]));
8139
8193
  for (const d of moduleDirs) {
8140
- const prefix = ` .git/modules/${d.relativePath}`;
8141
- if (!d.remoteUrl) {
8142
- console.log(`${prefix}: no remote URL, skipping`);
8143
- continue;
8144
- }
8145
- const gm = gmByRepoPath.get(repoPath(d.remoteUrl));
8194
+ const gm = gmByPath.get(d.relativePath);
8146
8195
  if (!gm) {
8147
8196
  await rm(join2(modulesRoot, d.relativePath), { recursive: true, force: true });
8148
- console.log(`${prefix}: deleted (orphaned, no matching submodule)`);
8149
- continue;
8150
- }
8151
- if (d.relativePath === gm.path) {
8152
- console.log(`${prefix}: already correct`);
8153
- continue;
8154
- }
8155
- const targetAbs = join2(modulesRoot, gm.path);
8156
- if (existsSync3(targetAbs)) {
8157
- console.log(`${prefix}: target ${gm.path} already exists, skipping`);
8158
- continue;
8197
+ fixes.push({ location: [".git", "modules", ...d.relativePath.split("/")], action: "deleted" });
8159
8198
  }
8160
- await mkdir(dirname(targetAbs), { recursive: true });
8161
- await rename(join2(modulesRoot, d.relativePath), targetAbs);
8162
- console.log(`${prefix} \u2192 ${gm.path}`);
8199
+ }
8200
+ for (const rel of orphanModuleDirs) {
8201
+ await rm(join2(modulesRoot, rel), { recursive: true, force: true });
8202
+ fixes.push({ location: [".git", "modules", ...rel.split("/")], action: "deleted" });
8163
8203
  }
8164
8204
  for (const m of modules) {
8165
8205
  const workingTreeDir = resolve3(repoRoot, m.path);
8166
8206
  const moduleGitDir = join2(modulesRoot, m.path);
8167
8207
  const dotGit = join2(workingTreeDir, ".git");
8168
- if (!existsSync3(workingTreeDir) || !existsSync3(moduleGitDir) || existsSync3(dotGit))
8208
+ if (!existsSync3(workingTreeDir))
8209
+ continue;
8210
+ if (existsSync3(dotGit) && statSync2(dotGit).isDirectory()) {
8211
+ if (existsSync3(moduleGitDir))
8212
+ continue;
8213
+ await mkdir(dirname(moduleGitDir), { recursive: true });
8214
+ await rename(dotGit, moduleGitDir);
8215
+ const rel2 = relative(workingTreeDir, moduleGitDir);
8216
+ await writeFile(dotGit, `gitdir: ${rel2}
8217
+ `, "utf8");
8218
+ fixes.push({ location: [...m.path.split("/"), ".git"], action: "synced", detail: "migrated standalone .git" });
8219
+ continue;
8220
+ }
8221
+ if (!existsSync3(moduleGitDir)) {
8222
+ await mkdir(dirname(moduleGitDir), { recursive: true });
8223
+ await spawnGit(["clone", "--bare", m.url, moduleGitDir], repoRoot);
8224
+ const configFile = `.git/modules/${m.path}/config`;
8225
+ const worktree = relative(moduleGitDir, workingTreeDir);
8226
+ await spawnGit(["config", "-f", configFile, "core.bare", "false"], repoRoot);
8227
+ await spawnGit(["config", "-f", configFile, "core.worktree", worktree], repoRoot);
8228
+ fixes.push({ location: [".git", "modules", ...m.path.split("/")], action: "synced", detail: "initialized" });
8229
+ continue;
8230
+ }
8231
+ if (existsSync3(dotGit))
8169
8232
  continue;
8170
8233
  const rel = relative(workingTreeDir, moduleGitDir);
8171
8234
  await writeFile(dotGit, `gitdir: ${rel}
8172
8235
  `, "utf8");
8173
- console.log(` wrote ${m.path}/.git \u2192 ${rel}`);
8236
+ fixes.push({ location: [...m.path.split("/"), ".git"], action: "synced" });
8174
8237
  }
8175
- console.log(" running: git submodule sync");
8176
- await spawnGit(["submodule", "sync"], repoRoot);
8177
8238
  for (const m of modules) {
8178
8239
  const moduleGitDir = join2(modulesRoot, m.path);
8179
8240
  if (!existsSync3(moduleGitDir))
8180
8241
  continue;
8242
+ const configFile = `.git/modules/${m.path}/config`;
8243
+ const readConfig = (key) => Bun.spawn(["git", "config", "-f", configFile, key], { cwd: repoRoot, stdout: "pipe", stderr: "pipe" });
8244
+ const [currentUrl, currentWorktree] = await Promise.all([
8245
+ new Response(readConfig("remote.origin.url").stdout).text().then((t) => t.trim()),
8246
+ new Response(readConfig("core.worktree").stdout).text().then((t) => t.trim())
8247
+ ]);
8181
8248
  const worktree = relative(moduleGitDir, resolve3(repoRoot, m.path));
8182
- await spawnGit(["config", "-f", `.git/modules/${m.path}/config`, "core.worktree", worktree], repoRoot);
8183
- console.log(` set core.worktree for ${m.path}: ${worktree}`);
8249
+ if (currentUrl !== m.url) {
8250
+ await spawnGit(["config", "-f", configFile, "remote.origin.url", m.url], repoRoot);
8251
+ fixes.push({ location: [".git", "modules", ...m.path.split("/"), "config"], action: "synced", detail: "remote.origin.url" });
8252
+ }
8253
+ if (currentWorktree !== worktree) {
8254
+ await spawnGit(["config", "-f", configFile, "core.worktree", worktree], repoRoot);
8255
+ fixes.push({ location: [".git", "modules", ...m.path.split("/"), "config"], action: "synced", detail: "core.worktree" });
8256
+ }
8184
8257
  }
8258
+ return fixes;
8185
8259
  }
8186
- async function fixIndexGitlinks(repoRoot, modules) {
8260
+ async function fixIndexGitlinks(repoRoot, modules, currentIndex) {
8261
+ const currentShas = new Map(currentIndex.map((e) => [e.path, e.commit]));
8262
+ const fixes = [];
8187
8263
  for (const m of modules) {
8188
8264
  const workingTree = resolve3(repoRoot, m.path);
8189
- if (!existsSync3(workingTree)) {
8190
- console.log(` ${m.path}: working tree missing, skipping`);
8265
+ if (!existsSync3(workingTree))
8191
8266
  continue;
8192
- }
8193
- const shaProc = Bun.spawn(["git", "rev-parse", "HEAD"], { cwd: workingTree, stdout: "pipe" });
8267
+ const shaProc = Bun.spawn(["git", "rev-parse", "HEAD"], { cwd: workingTree, stdout: "pipe", stderr: "pipe" });
8194
8268
  const sha = (await new Response(shaProc.stdout).text()).trim();
8195
- if (!/^[0-9a-f]{40}$/.test(sha)) {
8196
- console.log(` ${m.path}: could not read HEAD (${sha}), skipping`);
8269
+ if (!/^[0-9a-f]{40}$/.test(sha))
8197
8270
  continue;
8271
+ const current = currentShas.get(m.path);
8272
+ await spawnGit(["update-index", "--add", "--cacheinfo", `160000,${sha},${m.path}`], repoRoot);
8273
+ if (current !== sha) {
8274
+ fixes.push({ location: [".git", "index"], action: "synced", detail: `${m.path}: ${sha.slice(0, 8)}` });
8198
8275
  }
8199
- await spawnGit(["update-index", "--cacheinfo", `160000,${sha},${m.path}`], repoRoot);
8200
- console.log(` ${m.path}: ${sha.slice(0, 8)}`);
8201
- }
8202
- }
8203
- async function fixGitConfig(repoRoot, modules) {
8204
- const configPath = resolve3(repoRoot, ".git", "config");
8205
- const content = await readFile4(configPath, "utf8");
8206
- const validNames = new Set(modules.map((m) => m.name));
8207
- const { result, removed } = removeExtraConfigSubmodules(content, validNames);
8208
- if (removed.length === 0) {
8209
- console.log(" .git/config: no extra submodule entries");
8210
- return;
8211
- }
8212
- await writeFile(configPath, result, "utf8");
8213
- for (const name of removed) {
8214
- console.log(` .git/config: removed [submodule "${name}"]`);
8215
8276
  }
8277
+ return fixes;
8216
8278
  }
8217
8279
 
8218
8280
  // src/commands/git/sync-submodules/printer.ts
8219
8281
  var RED = "\x1B[31m";
8282
+ var GREEN = "\x1B[32m";
8283
+ var CYAN = "\x1B[36m";
8284
+ var DIM = "\x1B[2m";
8285
+ var BOLD = "\x1B[1m";
8220
8286
  var RESET = "\x1B[0m";
8221
- function renderField(f, indent) {
8222
- const text = f.key !== undefined ? `${indent} ${f.key}=${f.value}` : `${indent} ${f.value}`;
8223
- return f.error ? `${text} ${RED}\u2190 ${f.error}${RESET}` : text;
8224
- }
8225
-
8226
- class PrintableItem {
8227
- headerError() {
8228
- return;
8229
- }
8230
- print(indent = " ") {
8231
- const e = this.headerError();
8232
- console.log(e ? `${indent}${this.header()} ${RED}\u2190 ${e}${RESET}` : `${indent}${this.header()}`);
8233
- for (const f of this.fields())
8234
- console.log(renderField(f, indent));
8235
- }
8236
- static printGroup(header, items) {
8237
- console.log(`
8238
- === ${header} ===`);
8239
- for (const item of items)
8240
- item.print();
8287
+ var ok = `${GREEN}\u2713${RESET}`;
8288
+ var fail = (msg) => `${RED}\u2717 ${msg}${RESET}`;
8289
+ var fsName = (s) => `${CYAN}${s}${RESET}`;
8290
+ var key = (s) => `${DIM}${s}${RESET}`;
8291
+ function render(lines, prefix = "") {
8292
+ for (let i = 0;i < lines.length; i++) {
8293
+ const last = i === lines.length - 1;
8294
+ const branch = last ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
8295
+ const child = last ? " " : "\u2502 ";
8296
+ const l = lines[i];
8297
+ const label = l.isKey ? key(l.label) : fsName(l.label);
8298
+ let out = `${prefix}${branch}${label}`;
8299
+ if (l.value !== undefined)
8300
+ out += `: ${l.value}`;
8301
+ if (l.status !== undefined)
8302
+ out += ` ${l.status}`;
8303
+ console.log(out);
8304
+ if (l.children?.length)
8305
+ render(l.children, prefix + child);
8241
8306
  }
8242
8307
  }
8243
-
8244
- class GhostItem extends PrintableItem {
8245
- label;
8246
- message;
8247
- constructor(label, message) {
8248
- super();
8249
- this.label = label;
8250
- this.message = message;
8251
- }
8252
- header() {
8253
- return this.label;
8254
- }
8255
- fields() {
8256
- return [];
8257
- }
8258
- print(indent = " ") {
8259
- console.log(`${RED}${indent}${this.label} \u2190 ${this.message}${RESET}`);
8260
- }
8308
+ function buildPathTree(parts, children) {
8309
+ if (parts.length === 1)
8310
+ return { label: parts[0], children };
8311
+ return { label: parts[0], children: [buildPathTree(parts.slice(1), children)] };
8261
8312
  }
8262
-
8263
- class GitmodulesEntryItem extends PrintableItem {
8264
- m;
8265
- constructor(m) {
8266
- super();
8267
- this.m = m;
8268
- }
8269
- header() {
8270
- return `[${this.m.name}]`;
8271
- }
8272
- fields() {
8273
- return [
8274
- { key: "path", value: this.m.path },
8275
- { key: "url", value: this.m.url }
8276
- ];
8277
- }
8313
+ function resultLine(label, result) {
8314
+ if (result === undefined)
8315
+ return { label, isKey: true, status: fail("unknown") };
8316
+ if (typeof result === "string")
8317
+ return { label, isKey: true, value: result };
8318
+ return { label, isKey: true, status: fail(result.error) };
8278
8319
  }
8279
-
8280
- class GitConfigEntryItem extends PrintableItem {
8281
- e;
8282
- gm;
8283
- isDuplicate;
8284
- constructor(e, gm, isDuplicate) {
8285
- super();
8286
- this.e = e;
8287
- this.gm = gm;
8288
- this.isDuplicate = isDuplicate;
8289
- }
8290
- header() {
8291
- return `[${this.e.name}]`;
8292
- }
8293
- headerError() {
8294
- if (this.isDuplicate)
8295
- return "duplicate";
8296
- if (!this.gm)
8297
- return "not in .gitmodules";
8298
- }
8299
- fields() {
8300
- const { e, gm } = this;
8301
- return [
8302
- { key: "path", value: e.path ?? "(none)", error: gm && e.path !== gm.path ? `expected ${gm.path}` : undefined },
8303
- { key: "url", value: e.url ?? "(none)", error: gm && e.url !== gm.url ? `expected ${gm.url}` : undefined },
8304
- { key: "active", value: String(e.active ?? "(unset)") }
8305
- ];
8306
- }
8320
+ function moduleHasIssues(m, cfg, dir, idx, headSha, wt) {
8321
+ if (!cfg)
8322
+ return true;
8323
+ if (cfg.path !== m.path || cfg.url !== m.url)
8324
+ return true;
8325
+ if (!dir)
8326
+ return true;
8327
+ if (!idx)
8328
+ return true;
8329
+ if (headSha && idx.commit !== headSha)
8330
+ return true;
8331
+ if (!wt || wt.type === "missing" || wt.type === "dir")
8332
+ return true;
8333
+ if (typeof wt.headCommit !== "string")
8334
+ return true;
8335
+ if (typeof wt.remote !== "string" || wt.remote !== m.url)
8336
+ return true;
8337
+ return false;
8307
8338
  }
8308
-
8309
- class ModuleDirItem extends PrintableItem {
8310
- d;
8311
- gm;
8312
- constructor(d, gm) {
8313
- super();
8314
- this.d = d;
8315
- this.gm = gm;
8316
- }
8317
- header() {
8318
- return this.d.relativePath;
8319
- }
8320
- headerError() {
8321
- return !this.gm ? "not in .gitmodules" : undefined;
8339
+ function printSyncState(modules, state) {
8340
+ const byName = new Map(state.config.map((c) => [c.name, c]));
8341
+ const byPath = new Map(state.moduleDirs.map((d) => [d.relativePath, d]));
8342
+ const byIndexPath = new Map(state.index.map((i) => [i.path, i]));
8343
+ const byTreePath = new Map(state.workingTree.map((w) => [w.path, w]));
8344
+ const modulePaths = new Set(modules.map((m) => m.path));
8345
+ const moduleNames = new Set(modules.map((m) => m.name));
8346
+ console.log("=== submodules ===");
8347
+ for (const m of modules) {
8348
+ const cfg = byName.get(m.name);
8349
+ const dir = byPath.get(m.path);
8350
+ const idx = byIndexPath.get(m.path);
8351
+ const wt = byTreePath.get(m.path);
8352
+ const headSha = typeof wt?.headCommit === "string" ? wt.headCommit : undefined;
8353
+ console.log(`
8354
+ ${BOLD}[${m.name}]${RESET}`);
8355
+ if (!moduleHasIssues(m, cfg, dir, idx, headSha, wt)) {
8356
+ console.log(`\u2514\u2500\u2500 ${ok}`);
8357
+ continue;
8358
+ }
8359
+ const gitmodulesNode = {
8360
+ label: ".gitmodules",
8361
+ children: [
8362
+ { label: "path", isKey: true, value: m.path },
8363
+ { label: "url", isKey: true, value: m.url }
8364
+ ]
8365
+ };
8366
+ let configNode;
8367
+ if (!cfg) {
8368
+ configNode = { label: "config", status: fail("missing") };
8369
+ } else {
8370
+ configNode = {
8371
+ label: "config",
8372
+ children: [
8373
+ {
8374
+ label: "path",
8375
+ isKey: true,
8376
+ value: cfg.path ?? "(missing)",
8377
+ status: cfg.path === m.path ? ok : fail(`expected ${m.path}`)
8378
+ },
8379
+ {
8380
+ label: "url",
8381
+ isKey: true,
8382
+ value: cfg.url ?? "(missing)",
8383
+ status: cfg.url === m.url ? ok : fail(`expected ${m.url}`)
8384
+ }
8385
+ ]
8386
+ };
8387
+ }
8388
+ const modulesNode = {
8389
+ label: "modules",
8390
+ children: [{ label: m.path, status: dir ? ok : fail("missing") }]
8391
+ };
8392
+ let indexNode;
8393
+ if (!idx) {
8394
+ indexNode = { label: "index", status: fail("missing") };
8395
+ } else {
8396
+ const idxSha8 = idx.commit.slice(0, 8);
8397
+ const matches = headSha && idx.commit === headSha;
8398
+ const mismatches = headSha && idx.commit !== headSha;
8399
+ indexNode = {
8400
+ label: "index",
8401
+ value: idxSha8,
8402
+ status: matches ? ok : mismatches ? fail(`head is ${headSha.slice(0, 8)}`) : undefined
8403
+ };
8404
+ }
8405
+ const dotGitNode = {
8406
+ label: ".git",
8407
+ children: [configNode, modulesNode, indexNode]
8408
+ };
8409
+ const wtLeaves = [];
8410
+ if (!wt || wt.type === "missing") {
8411
+ wtLeaves.push({ label: ".git", status: fail("missing") });
8412
+ } else if (wt.type === "dir") {
8413
+ wtLeaves.push({ label: ".git", status: fail("directory (standalone clone)") });
8414
+ } else {
8415
+ wtLeaves.push({ label: ".git", value: `gitdir \u2192 ${wt.gitdirTarget}` });
8416
+ }
8417
+ if (wt) {
8418
+ const head = headSha ?? wt.headCommit;
8419
+ wtLeaves.push(resultLine("head", typeof head === "string" ? head.slice(0, 8) : head));
8420
+ if (typeof wt.remote === "string") {
8421
+ wtLeaves.push({
8422
+ label: "remote",
8423
+ isKey: true,
8424
+ value: wt.remote,
8425
+ status: wt.remote === m.url ? ok : fail(`expected ${m.url}`)
8426
+ });
8427
+ } else {
8428
+ wtLeaves.push(resultLine("remote", wt.remote));
8429
+ }
8430
+ }
8431
+ const workingTreeNode = buildPathTree(m.path.split("/"), wtLeaves);
8432
+ render([gitmodulesNode, dotGitNode, workingTreeNode]);
8322
8433
  }
8323
- fields() {
8324
- const { d, gm } = this;
8325
- return [
8326
- { key: "remote", value: d.remoteUrl ?? "(none)", error: gm && d.remoteUrl && d.remoteUrl !== gm.url ? `expected ${gm.url}` : undefined }
8327
- ];
8434
+ const staleConfig = state.config.filter((c) => !moduleNames.has(c.name));
8435
+ const staleDirs = state.moduleDirs.filter((d) => !modulePaths.has(d.relativePath));
8436
+ const staleIndex = state.index.filter((i) => !modulePaths.has(i.path));
8437
+ if (staleConfig.length || staleDirs.length || state.orphanModuleDirs.length || staleIndex.length) {
8438
+ console.log(`
8439
+ ${RED}${BOLD}=== stale (not in .gitmodules) ===${RESET}`);
8440
+ const staleNodes = [];
8441
+ if (staleConfig.length) {
8442
+ staleNodes.push({
8443
+ label: ".git/config",
8444
+ children: staleConfig.map((c) => ({ label: c.name }))
8445
+ });
8446
+ }
8447
+ if (staleDirs.length || state.orphanModuleDirs.length) {
8448
+ staleNodes.push({
8449
+ label: ".git/modules",
8450
+ children: [
8451
+ ...staleDirs.map((d) => ({ label: d.relativePath })),
8452
+ ...state.orphanModuleDirs.map((d) => ({ label: d }))
8453
+ ]
8454
+ });
8455
+ }
8456
+ if (staleIndex.length) {
8457
+ staleNodes.push({
8458
+ label: ".git/index",
8459
+ children: staleIndex.map((i) => ({ label: i.path, isKey: true, value: i.commit.slice(0, 8) }))
8460
+ });
8461
+ }
8462
+ render(staleNodes);
8328
8463
  }
8329
8464
  }
8330
-
8331
- class IndexGitlinkItem extends PrintableItem {
8332
- e;
8333
- knownPath;
8334
- constructor(e, knownPath) {
8335
- super();
8336
- this.e = e;
8337
- this.knownPath = knownPath;
8338
- }
8339
- header() {
8340
- return this.e.path;
8341
- }
8342
- headerError() {
8343
- return !this.knownPath ? "not in .gitmodules" : undefined;
8344
- }
8345
- fields() {
8346
- return [{ key: "commit", value: this.e.commit.slice(0, 8) }];
8465
+ function addToFixTree(tree, fix, depth = 0) {
8466
+ const seg = fix.location[depth];
8467
+ if (!tree.has(seg))
8468
+ tree.set(seg, { subtree: new Map, fixes: [] });
8469
+ const node = tree.get(seg);
8470
+ if (depth === fix.location.length - 1) {
8471
+ node.fixes.push(fix);
8472
+ } else {
8473
+ addToFixTree(node.subtree, fix, depth + 1);
8347
8474
  }
8348
8475
  }
8349
-
8350
- class WorkingTreeItem extends PrintableItem {
8351
- e;
8352
- constructor(e) {
8353
- super();
8354
- this.e = e;
8355
- }
8356
- header() {
8357
- return this.e.path;
8358
- }
8359
- headerError() {
8360
- if (this.e.type === "missing")
8361
- return "missing";
8362
- if (this.e.type === "dir")
8363
- return "expected gitdir file, got directory";
8364
- }
8365
- fields() {
8366
- if (this.e.type === "file")
8367
- return [{ value: `gitdir -> ${this.e.gitdirTarget}` }];
8368
- return [];
8476
+ function fixTreeToLines(tree) {
8477
+ const lines = [];
8478
+ for (const [label, { subtree, fixes }] of tree) {
8479
+ const children = fixTreeToLines(subtree);
8480
+ if (fixes.length > 0) {
8481
+ for (const fix of fixes) {
8482
+ const actionStr = fix.action === "deleted" ? `${RED}deleted${RESET}` : `${GREEN}synced${RESET}`;
8483
+ const status = fix.detail ? `${actionStr} ${fix.detail}` : actionStr;
8484
+ lines.push({ label, status, children: children.length > 0 ? children : undefined });
8485
+ }
8486
+ } else {
8487
+ lines.push({ label, children });
8488
+ }
8369
8489
  }
8490
+ return lines;
8491
+ }
8492
+ function printFixes(fixes) {
8493
+ if (fixes.length === 0)
8494
+ return;
8495
+ const tree = new Map;
8496
+ for (const fix of fixes)
8497
+ addToFixTree(tree, fix);
8498
+ console.log(`
8499
+ applied fixes:`);
8500
+ render(fixTreeToLines(tree));
8370
8501
  }
8371
8502
 
8372
8503
  // src/commands/git/sync-submodules/index.ts
8373
8504
  var REPO_ROOT = process.cwd();
8374
8505
  var GITMODULES_PATH = resolve4(REPO_ROOT, ".gitmodules");
8375
- function buildSections(modules, state) {
8376
- const byName = new Map(modules.map((m) => [m.name, m]));
8377
- const byPath = new Map(modules.map((m) => [m.path, m]));
8378
- const gitmodulesItems = modules.map((m) => new GitmodulesEntryItem(m));
8379
- const seenConfigNames = new Set;
8380
- const configItems = state.config.map((e) => {
8381
- const isDuplicate = seenConfigNames.has(e.name);
8382
- seenConfigNames.add(e.name);
8383
- return new GitConfigEntryItem(e, byName.get(e.name), isDuplicate);
8384
- });
8385
- for (const m of modules) {
8386
- if (!seenConfigNames.has(m.name))
8387
- configItems.push(new GhostItem(`[${m.name}]`, "missing from .git/config"));
8388
- }
8389
- const seenModulePaths = new Set;
8390
- const moduleDirItems = state.moduleDirs.map((d) => {
8391
- seenModulePaths.add(d.relativePath);
8392
- return new ModuleDirItem(d, byPath.get(d.relativePath));
8393
- });
8394
- for (const m of modules) {
8395
- if (!seenModulePaths.has(m.path))
8396
- moduleDirItems.push(new GhostItem(m.path, "missing from .git/modules/"));
8397
- }
8398
- const seenIndexPaths = new Set;
8399
- const indexItems = state.index.map((e) => {
8400
- seenIndexPaths.add(e.path);
8401
- return new IndexGitlinkItem(e, byPath.has(e.path));
8402
- });
8403
- for (const m of modules) {
8404
- if (!seenIndexPaths.has(m.path))
8405
- indexItems.push(new GhostItem(m.path, "missing from index"));
8406
- }
8407
- const workingTreeItems = state.workingTree.map((e) => new WorkingTreeItem(e));
8408
- return { gitmodulesItems, configItems, moduleDirItems, indexItems, workingTreeItems };
8409
- }
8410
8506
  async function main() {
8411
8507
  const content = await readFile5(GITMODULES_PATH, "utf8");
8412
8508
  const modules = parse(content);
@@ -8418,20 +8514,14 @@ async function main() {
8418
8514
  process.exit(1);
8419
8515
  }
8420
8516
  const submodulePaths = modules.map((m) => m.path);
8421
- console.log("=== fixing ===");
8422
- await fixGitConfig(REPO_ROOT, modules);
8423
- const preFixState = await readGitState(REPO_ROOT, submodulePaths);
8424
- await fixModuleDirs(REPO_ROOT, modules, preFixState.moduleDirs);
8425
- await fixIndexGitlinks(REPO_ROOT, modules);
8426
8517
  const state = await readGitState(REPO_ROOT, submodulePaths);
8427
- const { gitmodulesItems, configItems, moduleDirItems, indexItems, workingTreeItems } = buildSections(modules, state);
8428
- console.log("=== .gitmodules ===");
8429
- for (const item of gitmodulesItems)
8430
- item.print();
8431
- PrintableItem.printGroup(".git/config submodule entries", configItems);
8432
- PrintableItem.printGroup(".git/modules/ git dirs", moduleDirItems);
8433
- PrintableItem.printGroup("index gitlinks", indexItems);
8434
- PrintableItem.printGroup("working tree .git entries", workingTreeItems);
8518
+ printSyncState(modules, state);
8519
+ const appliedFixes = [
8520
+ ...await fixGitConfig(REPO_ROOT, modules),
8521
+ ...await fixModuleDirs(REPO_ROOT, modules, state.moduleDirs, state.orphanModuleDirs),
8522
+ ...await fixIndexGitlinks(REPO_ROOT, modules, state.index)
8523
+ ];
8524
+ printFixes(appliedFixes);
8435
8525
  const result = serialize(sorted(modules));
8436
8526
  if (result !== content) {
8437
8527
  await writeFile2(GITMODULES_PATH, result, "utf8");
@@ -8876,9 +8966,89 @@ var npm = new Command("npm").summary("npm package management utilities");
8876
8966
  npm.addCommand(cmd);
8877
8967
  });
8878
8968
 
8969
+ // src/commands/upgrade.ts
8970
+ import { existsSync as existsSync5 } from "fs";
8971
+ import { dirname as dirname3, join as join4 } from "path";
8972
+ function findLockFile(dir) {
8973
+ let current = dir;
8974
+ while (true) {
8975
+ if (existsSync5(join4(current, "bun.lock")) || existsSync5(join4(current, "bun.lockb")))
8976
+ return "bun";
8977
+ if (existsSync5(join4(current, "pnpm-lock.yaml")))
8978
+ return "pnpm";
8979
+ if (existsSync5(join4(current, "package-lock.json")))
8980
+ return "npm";
8981
+ const parent = dirname3(current);
8982
+ if (parent === current)
8983
+ return;
8984
+ current = parent;
8985
+ }
8986
+ }
8987
+ function detectInstall(binaryPath) {
8988
+ if (binaryPath.includes("/.bun/bin/")) {
8989
+ return { pm: "bun", scope: "global" };
8990
+ }
8991
+ if (binaryPath.includes("/pnpm/")) {
8992
+ return { pm: "pnpm", scope: "global" };
8993
+ }
8994
+ const localMatch = binaryPath.match(/^(.+\/node_modules)\/.bin\//);
8995
+ if (localMatch) {
8996
+ const projectRoot = dirname3(localMatch[1]);
8997
+ const pm = findLockFile(projectRoot) ?? "npm";
8998
+ return { pm, scope: "local", cwd: projectRoot };
8999
+ }
9000
+ return;
9001
+ }
9002
+ function buildUpgradeCommand(info) {
9003
+ const pkg = "@ozyman42/ozy-cli@latest";
9004
+ if (info.scope === "global") {
9005
+ if (info.pm === "bun")
9006
+ return ["bun", "add", "--no-cache", "-g", pkg];
9007
+ if (info.pm === "pnpm")
9008
+ return ["pnpm", "add", "-g", pkg];
9009
+ return ["npm", "install", "-g", pkg];
9010
+ }
9011
+ if (info.pm === "bun")
9012
+ return ["bun", "add", "--no-cache", pkg];
9013
+ if (info.pm === "pnpm")
9014
+ return ["pnpm", "add", pkg];
9015
+ return ["npm", "install", pkg];
9016
+ }
9017
+ async function whichOzy() {
9018
+ const proc = Bun.spawn(["which", "ozy"], { stdout: "pipe", stderr: "pipe" });
9019
+ const code = await proc.exited;
9020
+ if (code !== 0)
9021
+ return;
9022
+ return (await new Response(proc.stdout).text()).trim();
9023
+ }
9024
+ var upgrade = makeCommand("upgrade", "upgrade ozy-cli to the latest version", async () => {
9025
+ const binaryPath = await whichOzy();
9026
+ if (!binaryPath) {
9027
+ return Err("not-installed", "ozy binary not found \u2014 install with: bun add -g @ozyman42/ozy-cli");
9028
+ }
9029
+ const info = detectInstall(binaryPath);
9030
+ if (!info) {
9031
+ return Err("unknown-install", `could not detect package manager from binary path: ${binaryPath}
9032
+ Upgrade manually with your package manager.`);
9033
+ }
9034
+ const cmd = buildUpgradeCommand(info);
9035
+ console.log(`Detected: ${info.pm} (${info.scope})`);
9036
+ console.log(`Running: ${cmd.join(" ")}`);
9037
+ const proc = Bun.spawn(cmd, {
9038
+ cwd: info.cwd ?? process.cwd(),
9039
+ stdout: "inherit",
9040
+ stderr: "inherit"
9041
+ });
9042
+ const code = await proc.exited;
9043
+ if (code !== 0) {
9044
+ return Err("install-failed", `${cmd[0]} exited with code ${code}`);
9045
+ }
9046
+ return Ok(true);
9047
+ });
9048
+
8879
9049
  // src/commands/index.ts
8880
9050
  program.name("ozy").version(version);
8881
- [git, npm].forEach((cmd) => {
9051
+ [git, npm, upgrade].forEach((cmd) => {
8882
9052
  program.addCommand(cmd);
8883
9053
  });
8884
9054
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ozyman42/ozy-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Ozymandias' personal tools",
5
5
  "type": "module",
6
6
  "bin": {