forgemap 0.4.1-dev.66-903324b → 0.4.1-dev.68-66824fb

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 CHANGED
@@ -83,7 +83,8 @@ forgemap pick kirch # picker pre-filtered by fuzzy query
83
83
  ### Open the folder in the OS file manager
84
84
 
85
85
  ```bash
86
- forgemap open kirchDev/laravel-pbac
86
+ forgemap open kirchDev/laravel-pbac # exact slug
87
+ forgemap open laravel # fuzzy single match → same folder
87
88
  ```
88
89
 
89
90
  - **WSL** → launches `explorer.exe` against `\\wsl$\<distro>\…`, Explorer opens the folder
@@ -123,10 +124,10 @@ For each repo `import` compares the folder's `<owner>/<repo>` against the git `o
123
124
  forgemap cleanup # list deletable clones, then type "yes" to confirm
124
125
  forgemap cleanup --dry-run # show candidates + why every other idle repo is kept
125
126
  forgemap cleanup --days 540 # idle threshold in days (default 365)
126
- forgemap cleanup --include-dirty --include-unpushed # also delete repos with local-only work (lost!)
127
+ forgemap cleanup --include-dirty --include-unpushed --include-stashed # also delete repos with local-only work (lost!)
127
128
  ```
128
129
 
129
- A repo is only deleted when it is idle for `--days`+ days (by last **local** commit), has a clean working tree, has nothing unpushed, **and** its remote still exists — so everything removed is provably backed up. Repos without a remote (or with a gone/unreachable one) are never touched; empty owner directories left behind are pruned automatically. Deletion needs an explicit typed `yes` (or `--yes`).
130
+ A repo is only deleted when it is idle for `--days`+ days (by last **local** commit), has a clean working tree, has nothing unpushed, has no stashed work, **and** its remote still exists — so everything removed is provably backed up. Repos without a remote (or with a gone/unreachable one) are never touched; empty owner directories left behind are pruned automatically. Deletion needs an explicit typed `yes` (or `--yes`).
130
131
 
131
132
  ### Preflight your config
132
133
 
@@ -568,6 +568,7 @@ async function getRepoStatus(localPath) {
568
568
  dirty: false,
569
569
  ahead: 0,
570
570
  behind: 0,
571
+ stashes: 0,
571
572
  lastCommit: null
572
573
  };
573
574
  const branchResult = await gitIn(localPath, ["branch", "--show-current"]);
@@ -575,6 +576,7 @@ async function getRepoStatus(localPath) {
575
576
  status.detached = !status.branch || status.branch === "HEAD";
576
577
  const porcelain = await gitIn(localPath, ["status", "--porcelain"]);
577
578
  status.dirty = porcelain.stdout.trim().length > 0;
579
+ status.stashes = await countStashes(localPath);
578
580
  const aheadBehind = await gitIn(localPath, [
579
581
  "rev-list",
580
582
  "--left-right",
@@ -659,12 +661,20 @@ async function hasUnpushedCommits(localPath) {
659
661
  if (result.code !== 0) return true;
660
662
  return result.stdout.trim().length > 0;
661
663
  }
664
+ async function countStashes(localPath) {
665
+ const result = await gitIn(localPath, ["stash", "list", "--format=%gd"]);
666
+ if (result.code !== 0) return 0;
667
+ return result.stdout.split("\n").filter((line) => line.trim().length > 0).length;
668
+ }
662
669
  const SHORT_RE = /^([\w.-]+)\/([\w.-]+)$/;
663
670
  const NAMED_RE = /^([\w.-]+):([\w.-]+)\/([\w.-]+)$/;
664
671
  const SSH_RE = /^git@([\w.-]+):([\w.-]+)\/([\w.-]+?)(?:\.git)?$/;
665
672
  function stripGitSuffix(repo) {
666
673
  return repo.endsWith(".git") ? repo.slice(0, -4) : repo;
667
674
  }
675
+ function looksLikeSlug(input) {
676
+ return input.trim().includes("/");
677
+ }
668
678
  function parseSlug(input) {
669
679
  const trimmed = input.trim();
670
680
  if (!trimmed) {
@@ -723,6 +733,7 @@ async function evaluate(repo, cutoffUnix) {
723
733
  if (lastCommitUnix === null || lastCommitUnix > cutoffUnix) return null;
724
734
  const status = await getRepoStatus(repo.localPath);
725
735
  const dirty = status.dirty;
736
+ const stashes = status.stashes;
726
737
  const unpushed = await hasUnpushedCommits(repo.localPath);
727
738
  let owner = repo.owner;
728
739
  let name = repo.repo;
@@ -732,7 +743,16 @@ async function evaluate(repo, cutoffUnix) {
732
743
  name = parsed.repo;
733
744
  } catch {
734
745
  }
735
- return { repo, origin, owner, name, lastCommitUnix, dirty, unpushed };
746
+ return {
747
+ repo,
748
+ origin,
749
+ owner,
750
+ name,
751
+ lastCommitUnix,
752
+ dirty,
753
+ unpushed,
754
+ stashes
755
+ };
736
756
  }
737
757
  async function classifyRemotes(candidates) {
738
758
  const byType = /* @__PURE__ */ new Map();
@@ -832,6 +852,11 @@ const cleanupCommand = defineCommand({
832
852
  description: "Also delete repos with unpushed commits (those commits are lost)",
833
853
  default: false
834
854
  },
855
+ "include-stashed": {
856
+ type: "boolean",
857
+ description: "Also delete repos with stashed work (that stash is lost)",
858
+ default: false
859
+ },
835
860
  "no-cache": {
836
861
  type: "boolean",
837
862
  description: "Skip the scanned-repos cache",
@@ -865,7 +890,8 @@ const cleanupCommand = defineCommand({
865
890
  )).filter((c) => c !== null);
866
891
  const includeDirty = Boolean(args["include-dirty"]);
867
892
  const includeUnpushed = Boolean(args["include-unpushed"]);
868
- const localOk = (c) => (!c.dirty || includeDirty) && (!c.unpushed || includeUnpushed);
893
+ const includeStashed = Boolean(args["include-stashed"]);
894
+ const localOk = (c) => (!c.dirty || includeDirty) && (!c.unpushed || includeUnpushed) && (c.stashes === 0 || includeStashed);
869
895
  const remoteStates = await classifyRemotes(stale.filter(localOk));
870
896
  const candidates = [];
871
897
  const kept = [];
@@ -874,6 +900,11 @@ const cleanupCommand = defineCommand({
874
900
  kept.push({ repo: c, reason: "uncommitted changes" });
875
901
  } else if (c.unpushed && !includeUnpushed) {
876
902
  kept.push({ repo: c, reason: "unpushed commits" });
903
+ } else if (c.stashes > 0 && !includeStashed) {
904
+ kept.push({
905
+ repo: c,
906
+ reason: `stashed work (${c.stashes} stash${c.stashes === 1 ? "" : "es"})`
907
+ });
877
908
  } else {
878
909
  const state = remoteStates.get(c.repo.localPath)?.state;
879
910
  if (state === "exists" || state === "moved") candidates.push(c);
@@ -896,7 +927,8 @@ const cleanupCommand = defineCommand({
896
927
  for (const c of candidates) {
897
928
  const flags = [
898
929
  c.dirty ? colors.red("dirty") : "",
899
- c.unpushed ? colors.red("unpushed") : ""
930
+ c.unpushed ? colors.red("unpushed") : "",
931
+ c.stashes > 0 ? colors.red(`stashed:${c.stashes}`) : ""
900
932
  ].filter(Boolean).join(" ");
901
933
  process.stdout.write(
902
934
  ` ${colors.cyan(`${c.repo.forgeName}:${c.repo.slug}`)} ${colors.dim(`${ageDays(c.lastCommitUnix)}d idle`)}${flags ? ` ${flags}` : ""} ${colors.dim(c.repo.localPath)}
@@ -938,10 +970,12 @@ const cleanupCommand = defineCommand({
938
970
  return;
939
971
  }
940
972
  if (candidates.length > 0) {
941
- const losing = candidates.filter((c) => c.dirty || c.unpushed).length;
973
+ const losing = candidates.filter(
974
+ (c) => c.dirty || c.unpushed || c.stashes > 0
975
+ ).length;
942
976
  if (losing > 0) {
943
977
  consola.warn(
944
- `${losing} of these have uncommitted/unpushed work that will be permanently lost.`
978
+ `${losing} of these have uncommitted/unpushed/stashed work that will be permanently lost.`
945
979
  );
946
980
  }
947
981
  let confirmed = args.yes;
@@ -2019,6 +2053,111 @@ const importCommand = defineCommand({
2019
2053
  });
2020
2054
  }
2021
2055
  });
2056
+ const REPO_FUSE_OPTIONS = {
2057
+ keys: ["slug", "owner", "repo"],
2058
+ threshold: 0.3,
2059
+ ignoreLocation: true
2060
+ };
2061
+ function createRepoFuse(repos) {
2062
+ return new Fuse(repos, REPO_FUSE_OPTIONS);
2063
+ }
2064
+ function matchRepos(repos, query, limit) {
2065
+ const fuse = createRepoFuse(repos);
2066
+ const results = fuse.search(query, limit ? { limit } : void 0);
2067
+ return results.map((r) => r.item);
2068
+ }
2069
+ async function promptRepoChoice(candidates) {
2070
+ const out = process.stdout;
2071
+ const realWrite = out.write;
2072
+ const saved = {
2073
+ rows: Object.getOwnPropertyDescriptor(out, "rows"),
2074
+ columns: Object.getOwnPropertyDescriptor(out, "columns"),
2075
+ isTTY: Object.getOwnPropertyDescriptor(out, "isTTY")
2076
+ };
2077
+ const fake = (key, value) => {
2078
+ Object.defineProperty(out, key, { configurable: true, value });
2079
+ };
2080
+ const restore = (key) => {
2081
+ if (saved[key]) Object.defineProperty(out, key, saved[key]);
2082
+ else delete out[key];
2083
+ };
2084
+ out.write = process.stderr.write.bind(process.stderr);
2085
+ fake("rows", process.stderr.rows ?? 24);
2086
+ fake("columns", process.stderr.columns ?? 80);
2087
+ fake("isTTY", true);
2088
+ let choice;
2089
+ try {
2090
+ choice = await consola.prompt("Select a repo", {
2091
+ type: "select",
2092
+ options: candidates.map((r) => ({
2093
+ label: `${colors.gray(`${r.forgeName}:`)}${r.slug}`,
2094
+ value: r.localPath,
2095
+ hint: r.localPath
2096
+ }))
2097
+ });
2098
+ } finally {
2099
+ out.write = realWrite;
2100
+ restore("rows");
2101
+ restore("columns");
2102
+ restore("isTTY");
2103
+ }
2104
+ return typeof choice === "string" && choice ? choice : void 0;
2105
+ }
2106
+ function canPrompt() {
2107
+ return Boolean(process.stdin.isTTY);
2108
+ }
2109
+ async function locateRepo(input, options) {
2110
+ const { config, configDir } = options;
2111
+ if (!input.trim() || looksLikeSlug(input)) {
2112
+ const resolved = resolveSlug(parseSlug(input), { config, configDir });
2113
+ return { kind: "slug", localPath: resolved.localPath };
2114
+ }
2115
+ const repos = options.repos ?? await scanRepos({ config, configDir });
2116
+ const candidates = matchRepos(repos, input);
2117
+ if (candidates.length === 0) return { kind: "none", query: input };
2118
+ if (candidates.length === 1) {
2119
+ return {
2120
+ kind: "match",
2121
+ localPath: candidates[0].localPath,
2122
+ repo: candidates[0]
2123
+ };
2124
+ }
2125
+ return { kind: "ambiguous", query: input, candidates };
2126
+ }
2127
+ async function resolveRepoPath(input, options) {
2128
+ const outcome = await locateRepo(input, options);
2129
+ switch (outcome.kind) {
2130
+ case "slug":
2131
+ case "match":
2132
+ return outcome.localPath;
2133
+ case "none":
2134
+ consola.error(`No cloned repo matches "${outcome.query}".`);
2135
+ process.stderr.write(
2136
+ `${colors.dim("Pass an explicit <owner>/<repo> for a repo that is not cloned yet.")}
2137
+ `
2138
+ );
2139
+ return null;
2140
+ case "ambiguous": {
2141
+ if (canPrompt()) {
2142
+ return await promptRepoChoice(outcome.candidates) ?? null;
2143
+ }
2144
+ consola.error(
2145
+ `"${outcome.query}" matches ${outcome.candidates.length} cloned repos:`
2146
+ );
2147
+ for (const c of outcome.candidates) {
2148
+ process.stderr.write(
2149
+ ` ${colors.cyan(`${c.forgeName}:${c.slug}`)} ${colors.dim(c.localPath)}
2150
+ `
2151
+ );
2152
+ }
2153
+ process.stderr.write(
2154
+ `${colors.dim("Narrow the query, pass an explicit <owner>/<repo>, or run `forgemap pick` to choose interactively.")}
2155
+ `
2156
+ );
2157
+ return null;
2158
+ }
2159
+ }
2160
+ }
2022
2161
  function platformOpen(localPath) {
2023
2162
  const distro = process.env.WSL_DISTRO_NAME;
2024
2163
  if (distro) {
@@ -2038,7 +2177,7 @@ const openCommand = defineCommand({
2038
2177
  args: {
2039
2178
  slug: {
2040
2179
  type: "positional",
2041
- description: "owner/repo, forge:owner/repo, or full URL",
2180
+ description: "owner/repo, forge:owner/repo, full URL, or a fuzzy query matched against cloned repos",
2042
2181
  required: true
2043
2182
  },
2044
2183
  config: {
@@ -2048,14 +2187,17 @@ const openCommand = defineCommand({
2048
2187
  },
2049
2188
  async run({ args }) {
2050
2189
  const loaded = await loadForgeMapConfig({ configFile: args.config });
2051
- const parsed = parseSlug(args.slug);
2052
2190
  const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
2053
- const resolved = resolveSlug(parsed, {
2191
+ const localPath = await resolveRepoPath(args.slug, {
2054
2192
  config: loaded.config,
2055
2193
  configDir
2056
2194
  });
2057
- const { cmd, args: cmdArgs } = platformOpen(resolved.localPath);
2058
- consola.info(`Opening ${resolved.localPath}`);
2195
+ if (!localPath) {
2196
+ process.exitCode = 1;
2197
+ return;
2198
+ }
2199
+ const { cmd, args: cmdArgs } = platformOpen(localPath);
2200
+ consola.info(`Opening ${localPath}`);
2059
2201
  const child = spawn(cmd, cmdArgs, {
2060
2202
  stdio: "ignore",
2061
2203
  detached: true
@@ -2082,7 +2224,7 @@ const pathCommand = defineCommand({
2082
2224
  args: {
2083
2225
  slug: {
2084
2226
  type: "positional",
2085
- description: "owner/repo, forge:owner/repo, or full URL",
2227
+ description: "owner/repo, forge:owner/repo, full URL, or a fuzzy query matched against cloned repos",
2086
2228
  required: true
2087
2229
  },
2088
2230
  config: {
@@ -2092,13 +2234,16 @@ const pathCommand = defineCommand({
2092
2234
  },
2093
2235
  async run({ args }) {
2094
2236
  const loaded = await loadForgeMapConfig({ configFile: args.config });
2095
- const parsed = parseSlug(args.slug);
2096
2237
  const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
2097
- const resolved = resolveSlug(parsed, {
2238
+ const localPath = await resolveRepoPath(args.slug, {
2098
2239
  config: loaded.config,
2099
2240
  configDir
2100
2241
  });
2101
- process.stdout.write(`${resolved.localPath}
2242
+ if (!localPath) {
2243
+ process.exitCode = 1;
2244
+ return;
2245
+ }
2246
+ process.stdout.write(`${localPath}
2102
2247
  `);
2103
2248
  }
2104
2249
  });
@@ -2122,17 +2267,7 @@ const pickCommand = defineCommand({
2122
2267
  const loaded = await loadForgeMapConfig({ configFile: args.config });
2123
2268
  const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
2124
2269
  const all = await scanRepos({ config: loaded.config, configDir });
2125
- let candidates;
2126
- if (args.query) {
2127
- const fuse = new Fuse(all, {
2128
- keys: ["slug", "owner", "repo"],
2129
- threshold: 0.3,
2130
- ignoreLocation: true
2131
- });
2132
- candidates = fuse.search(args.query).map((r) => r.item);
2133
- } else {
2134
- candidates = all;
2135
- }
2270
+ const candidates = args.query ? matchRepos(all, args.query) : all;
2136
2271
  if (candidates.length === 0) {
2137
2272
  consola.error(
2138
2273
  args.query ? `No repos match "${args.query}".` : "No repos found under the configured root."
@@ -2145,49 +2280,16 @@ const pickCommand = defineCommand({
2145
2280
  `);
2146
2281
  return;
2147
2282
  }
2148
- if (!process.stdin.isTTY) {
2283
+ if (!canPrompt()) {
2149
2284
  consola.error(
2150
2285
  "pick requires an interactive terminal. Use `forgemap search` for non-interactive output."
2151
2286
  );
2152
2287
  process.exitCode = 1;
2153
2288
  return;
2154
2289
  }
2155
- const out = process.stdout;
2156
- const realWrite = out.write;
2157
- const saved = {
2158
- rows: Object.getOwnPropertyDescriptor(out, "rows"),
2159
- columns: Object.getOwnPropertyDescriptor(out, "columns"),
2160
- isTTY: Object.getOwnPropertyDescriptor(out, "isTTY")
2161
- };
2162
- const fake = (key, value) => {
2163
- Object.defineProperty(out, key, { configurable: true, value });
2164
- };
2165
- const restore = (key) => {
2166
- if (saved[key]) Object.defineProperty(out, key, saved[key]);
2167
- else delete out[key];
2168
- };
2169
- out.write = process.stderr.write.bind(process.stderr);
2170
- fake("rows", process.stderr.rows ?? 24);
2171
- fake("columns", process.stderr.columns ?? 80);
2172
- fake("isTTY", true);
2173
- let choice;
2174
- try {
2175
- choice = await consola.prompt("Select a repo", {
2176
- type: "select",
2177
- options: candidates.map((r) => ({
2178
- label: `${colors.gray(`${r.forgeName}:`)}${r.slug}`,
2179
- value: r.localPath,
2180
- hint: r.localPath
2181
- }))
2182
- });
2183
- } finally {
2184
- out.write = realWrite;
2185
- restore("rows");
2186
- restore("columns");
2187
- restore("isTTY");
2188
- }
2189
- if (typeof choice === "string" && choice) {
2190
- realWrite.call(out, `${choice}
2290
+ const choice = await promptRepoChoice(candidates);
2291
+ if (choice) {
2292
+ process.stdout.write(`${choice}
2191
2293
  `);
2192
2294
  }
2193
2295
  }
@@ -2245,15 +2347,8 @@ const searchCommand = defineCommand({
2245
2347
  const loaded = await loadForgeMapConfig({ configFile: args.config });
2246
2348
  const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
2247
2349
  const repos = await scanRepos({ config: loaded.config, configDir });
2248
- const fuse = new Fuse(repos, {
2249
- keys: ["slug", "owner", "repo"],
2250
- threshold: 0.3,
2251
- ignoreLocation: true,
2252
- includeScore: true
2253
- });
2254
2350
  const limit = args.limit ? Number.parseInt(args.limit, 10) : void 0;
2255
- const results = fuse.search(args.query, limit ? { limit } : void 0);
2256
- const items = results.map((r) => r.item);
2351
+ const items = matchRepos(repos, args.query, limit);
2257
2352
  const allowed = ["auto", "pretty", "path", "slug"];
2258
2353
  if (!allowed.includes(args.format)) {
2259
2354
  consola.error(
@@ -2419,6 +2514,7 @@ function statusLine(row) {
2419
2514
  if (s.behind > 0) aheadBehind.push(colors.yellow(`↓${s.behind}`));
2420
2515
  if (aheadBehind.length > 0) parts.push(aheadBehind.join(" "));
2421
2516
  parts.push(s.dirty ? colors.red("●") : colors.green("✓"));
2517
+ if (s.stashes > 0) parts.push(colors.yellow(`⚑${s.stashes}`));
2422
2518
  parts.push(colors.gray(s.branch));
2423
2519
  if (s.lastCommit) {
2424
2520
  parts.push(colors.dim(`${s.lastCommit.sha} ${s.lastCommit.relativeDate}`));