zapdev 0.6.0 → 0.7.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 (3) hide show
  1. package/README.md +11 -8
  2. package/dist/cli.js +322 -389
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -45,22 +45,25 @@ zapdev commit -t feat # force the type; the model still writes scope + desc
45
45
 
46
46
  Without a TTY (piped / CI), it commits automatically and only pushes when `--push` is set.
47
47
 
48
- ### `zapdev cleanup`
48
+ ### `zapdev reset`
49
49
 
50
- Walks a directory for git repos, and for each one: `git fetch --prune`, lets you switch to the default branch (from `origin/HEAD`) or stay on the current one, interactively deletes stale local branches, then optionally pulls.
50
+ Operates on a git repo, or — when the path is a plain directory — on its **direct child** repos (level 1, non-recursive). For each one: `git fetch --prune`, switch to a branch (chosen interactively, or forced with `--principal` / `--target`), then permanently delete every other local branch and every linked worktree. The principal branch (from `origin/HEAD`) and the branch you switched to are always kept. Ends with an optional `pull`.
51
51
 
52
52
  ```bash
53
- zapdev cleanup # scan the current directory
54
- zapdev cleanup ~/dev # scan a specific directory
53
+ zapdev reset # the current repo, or the child repos of the current directory
54
+ zapdev reset ~/dev # a repo, or the child repos of a directory
55
+ zapdev reset -p # switch each repo to its principal branch, no prompt
56
+ zapdev reset -t dev # switch each repo to `dev` (or principal if absent)
55
57
  ```
56
58
 
57
59
  | Flag | Description |
58
60
  | --- | --- |
59
- | `-s, --stay` | Stay on the current branch instead of switching to the default |
60
- | `-p, --pull` | Pull the checked-out branch after cleanup without asking |
61
- | `-y, --yes` | Non-interactive: switch to default and delete every other local branch |
61
+ | `-p, --principal` | Switch every repo to its resolved principal branch (`origin/HEAD`) |
62
+ | `-t, --target <branch>` | Switch every repo to `<branch>`, falling back to the principal one |
63
+ | `--pull` | Pull the checked-out branch after reset without asking |
64
+ | `-y, --yes` | Non-interactive: switch and delete without confirmation |
62
65
 
63
- Deletion uses `git branch -D` (force). Without a TTY (or with `--yes`), every non-default branch is deleted automatically. The walk prunes at each repo, so submodules and `node_modules` are never scanned.
66
+ Deletion is permanent: branches via `git branch -D` (force), worktrees via `git worktree remove --force`. Worktrees are removed first so the branches they held become deletable. Without a TTY, pass `--yes` — otherwise the destructive step is refused. `node_modules` is never scanned.
64
67
 
65
68
  ## Configuration
66
69
 
package/dist/cli.js CHANGED
@@ -529,8 +529,8 @@ function block({
529
529
  return;
530
530
  }
531
531
  if (!o2) return;
532
- const i3 = a3 === "return" ? 0 : -1, m3 = a3 === "return" ? -1 : 0;
533
- l.moveCursor(r3, i3, m3, () => {
532
+ const i3 = a3 === "return" ? 0 : -1, m2 = a3 === "return" ? -1 : 0;
533
+ l.moveCursor(r3, i3, m2, () => {
534
534
  l.clearLine(r3, 1, () => {
535
535
  e2.once("keypress", n4);
536
536
  });
@@ -548,9 +548,9 @@ function wrapTextWithPrefix(e2, r3, o2, t3 = o2, s2 = o2, n4) {
548
548
  hard: true,
549
549
  trim: false
550
550
  }).split(`
551
- `).map((c3, i3, m3) => {
551
+ `).map((c3, i3, m2) => {
552
552
  const d2 = n4 ? n4(c3, i3) : c3;
553
- return i3 === 0 ? `${t3}${d2}` : i3 === m3.length - 1 ? `${s2}${d2}` : `${o2}${d2}`;
553
+ return i3 === 0 ? `${t3}${d2}` : i3 === m2.length - 1 ? `${s2}${d2}` : `${o2}${d2}`;
554
554
  }).join(`
555
555
  `);
556
556
  }
@@ -747,56 +747,6 @@ var r = class extends V {
747
747
  });
748
748
  }
749
749
  };
750
- var a = class extends V {
751
- options;
752
- cursor = 0;
753
- get _value() {
754
- return this.options[this.cursor]?.value;
755
- }
756
- get _enabledOptions() {
757
- return this.options.filter((e2) => e2.disabled !== true);
758
- }
759
- toggleAll() {
760
- const e2 = this._enabledOptions, i3 = this.value !== void 0 && this.value.length === e2.length;
761
- this.value = i3 ? [] : e2.map((t3) => t3.value);
762
- }
763
- toggleInvert() {
764
- const e2 = this.value;
765
- if (!e2)
766
- return;
767
- const i3 = this._enabledOptions.filter((t3) => !e2.includes(t3.value));
768
- this.value = i3.map((t3) => t3.value);
769
- }
770
- toggleValue() {
771
- this.value === void 0 && (this.value = []);
772
- const e2 = this.value.includes(this._value);
773
- this.value = e2 ? this.value.filter((i3) => i3 !== this._value) : [...this.value, this._value];
774
- }
775
- constructor(e2) {
776
- super(e2, false), this.options = e2.options, this.value = [...e2.initialValues ?? []];
777
- const i3 = Math.max(
778
- this.options.findIndex(({ value: t3 }) => t3 === e2.cursorAt),
779
- 0
780
- );
781
- this.cursor = this.options[i3]?.disabled ? findCursor(i3, 1, this.options) : i3, this.on("key", (t3, l3) => {
782
- l3.name === "a" && this.toggleAll(), l3.name === "i" && this.toggleInvert();
783
- }), this.on("cursor", (t3) => {
784
- switch (t3) {
785
- case "left":
786
- case "up":
787
- this.cursor = findCursor(this.cursor, -1, this.options);
788
- break;
789
- case "down":
790
- case "right":
791
- this.cursor = findCursor(this.cursor, 1, this.options);
792
- break;
793
- case "space":
794
- this.toggleValue();
795
- break;
796
- }
797
- });
798
- }
799
- };
800
750
  var n$1 = class n extends V {
801
751
  options;
802
752
  cursor = 0;
@@ -922,13 +872,13 @@ var I = (l3, e2, w2, p2, b2, C2 = false) => {
922
872
  let r3 = e2, O2 = 0;
923
873
  if (C2)
924
874
  for (let i3 = p2 - 1; i3 >= w2; i3--) {
925
- const m3 = l3[i3];
926
- if (m3 && (r3 -= m3.length), O2++, r3 <= b2) break;
875
+ const m2 = l3[i3];
876
+ if (m2 && (r3 -= m2.length), O2++, r3 <= b2) break;
927
877
  }
928
878
  else
929
879
  for (let i3 = w2; i3 < p2; i3++) {
930
- const m3 = l3[i3];
931
- if (m3 && (r3 -= m3.length), O2++, r3 <= b2) break;
880
+ const m2 = l3[i3];
881
+ if (m2 && (r3 -= m2.length), O2++, r3 <= b2) break;
932
882
  }
933
883
  return { lineCount: r3, removals: O2 };
934
884
  };
@@ -941,7 +891,7 @@ var limitOptions = ({
941
891
  columnPadding: C2 = 0,
942
892
  rowPadding: r3 = 4
943
893
  }) => {
944
- const i3 = getColumns(p2) - C2, m3 = getRows(p2), M = styleText2("dim", "..."), v2 = Math.max(m3 - r3, 0), a3 = Math.max(Math.min(b2, v2), 5);
894
+ const i3 = getColumns(p2) - C2, m2 = getRows(p2), M = styleText2("dim", "..."), v2 = Math.max(m2 - r3, 0), a3 = Math.max(Math.min(b2, v2), 5);
945
895
  let f2 = 0;
946
896
  l3 >= a3 - 3 && (f2 = Math.max(
947
897
  Math.min(l3 - a3 + 3, e2.length - a3),
@@ -1029,118 +979,11 @@ var MULTISELECT_INSTRUCTIONS = [
1029
979
  `${styleText2("dim", "Space:")} select`,
1030
980
  `${styleText2("dim", "Enter:")} confirm`
1031
981
  ];
1032
- var m = (i3, u4) => i3.split(`
1033
- `).map((d2) => u4(d2)).join(`
1034
- `);
1035
- var multiselect = (i3) => {
1036
- const u4 = (t3, a3) => {
1037
- const r3 = t3.label ?? String(t3.value);
1038
- return a3 === "disabled" ? `${styleText2("gray", S_CHECKBOX_INACTIVE)} ${m(r3, (o2) => styleText2(["strikethrough", "gray"], o2))}${t3.hint ? ` ${styleText2("dim", `(${t3.hint ?? "disabled"})`)}` : ""}` : a3 === "active" ? `${styleText2("cyan", S_CHECKBOX_ACTIVE)} ${r3}${t3.hint ? ` ${styleText2("dim", `(${t3.hint})`)}` : ""}` : a3 === "selected" ? `${styleText2("green", S_CHECKBOX_SELECTED)} ${m(r3, (o2) => styleText2("dim", o2))}${t3.hint ? ` ${styleText2("dim", `(${t3.hint})`)}` : ""}` : a3 === "cancelled" ? `${m(r3, (o2) => styleText2(["strikethrough", "dim"], o2))}` : a3 === "active-selected" ? `${styleText2("green", S_CHECKBOX_SELECTED)} ${r3}${t3.hint ? ` ${styleText2("dim", `(${t3.hint})`)}` : ""}` : a3 === "submitted" ? `${m(r3, (o2) => styleText2("dim", o2))}` : `${styleText2("dim", S_CHECKBOX_INACTIVE)} ${m(r3, (o2) => styleText2("dim", o2))}`;
1039
- }, d2 = i3.required ?? true, v2 = i3.showInstructions ?? true;
1040
- return new a({
1041
- options: i3.options,
1042
- signal: i3.signal,
1043
- input: i3.input,
1044
- output: i3.output,
1045
- initialValues: i3.initialValues,
1046
- required: d2,
1047
- cursorAt: i3.cursorAt,
1048
- validate(t3) {
1049
- if (d2 && (t3 === void 0 || t3.length === 0))
1050
- return `Please select at least one option.
1051
- ${styleText2(
1052
- "reset",
1053
- styleText2(
1054
- "dim",
1055
- `Press ${styleText2(["gray", "bgWhite", "inverse"], " space ")} to select, ${styleText2(
1056
- "gray",
1057
- styleText2("bgWhite", styleText2("inverse", " enter "))
1058
- )} to submit`
1059
- )
1060
- )}`;
1061
- },
1062
- render() {
1063
- const t3 = i3.withGuide ?? settings.withGuide, a3 = wrapTextWithPrefix(
1064
- i3.output,
1065
- i3.message,
1066
- t3 ? `${symbolBar(this.state)} ` : "",
1067
- `${symbol(this.state)} `
1068
- ), r3 = `${t3 ? `${styleText2("gray", S_BAR)}
1069
- ` : ""}${a3}
1070
- `, o2 = this.value ?? [], p2 = (n4, l3) => {
1071
- if (n4.disabled)
1072
- return u4(n4, "disabled");
1073
- const s2 = o2.includes(n4.value);
1074
- return l3 && s2 ? u4(n4, "active-selected") : s2 ? u4(n4, "selected") : u4(n4, l3 ? "active" : "inactive");
1075
- };
1076
- switch (this.state) {
1077
- case "submit": {
1078
- const n4 = this.options.filter(({ value: s2 }) => o2.includes(s2)).map((s2) => u4(s2, "submitted")).join(styleText2("dim", ", ")) || styleText2("dim", "none"), l3 = wrapTextWithPrefix(
1079
- i3.output,
1080
- n4,
1081
- t3 ? `${styleText2("gray", S_BAR)} ` : ""
1082
- );
1083
- return `${r3}${l3}`;
1084
- }
1085
- case "cancel": {
1086
- const n4 = this.options.filter(({ value: s2 }) => o2.includes(s2)).map((s2) => u4(s2, "cancelled")).join(styleText2("dim", ", "));
1087
- if (n4.trim() === "")
1088
- return `${r3}${styleText2("gray", S_BAR)}`;
1089
- const l3 = wrapTextWithPrefix(
1090
- i3.output,
1091
- n4,
1092
- t3 ? `${styleText2("gray", S_BAR)} ` : ""
1093
- );
1094
- return `${r3}${l3}${t3 ? `
1095
- ${styleText2("gray", S_BAR)}` : ""}`;
1096
- }
1097
- case "error": {
1098
- const n4 = t3 ? `${styleText2("yellow", S_BAR)} ` : "", l3 = this.error.split(`
1099
- `).map(
1100
- ($, C2) => C2 === 0 ? `${t3 ? `${styleText2("yellow", S_BAR_END)} ` : ""}${styleText2("yellow", $)}` : ` ${$}`
1101
- ).join(`
1102
- `), s2 = r3.split(`
1103
- `).length, h3 = l3.split(`
1104
- `).length + 1;
1105
- return `${r3}${n4}${limitOptions({
1106
- output: i3.output,
1107
- options: this.options,
1108
- cursor: this.cursor,
1109
- maxItems: i3.maxItems,
1110
- columnPadding: n4.length,
1111
- rowPadding: s2 + h3,
1112
- style: p2
1113
- }).join(`
1114
- ${n4}`)}
1115
- ${l3}
1116
- `;
1117
- }
1118
- default: {
1119
- const n4 = t3 ? `${styleText2("cyan", S_BAR)} ` : "", l3 = r3.split(`
1120
- `).length, s2 = v2 ? formatInstructionFooter(MULTISELECT_INSTRUCTIONS, t3) : t3 ? [styleText2("cyan", S_BAR_END)] : [], h3 = s2.join(`
1121
- `), $ = s2.length + 1;
1122
- return `${r3}${n4}${limitOptions({
1123
- output: i3.output,
1124
- options: this.options,
1125
- cursor: this.cursor,
1126
- maxItems: i3.maxItems,
1127
- columnPadding: n4.length,
1128
- rowPadding: l3 + $,
1129
- style: p2
1130
- }).join(`
1131
- ${n4}`)}
1132
- ${h3}
1133
- `;
1134
- }
1135
- }
1136
- }
1137
- }).prompt();
1138
- };
1139
982
  var log = {
1140
983
  message: (s2 = [], {
1141
984
  symbol: e2 = styleText2("gray", S_BAR),
1142
985
  secondarySymbol: r3 = styleText2("gray", S_BAR),
1143
- output: m3 = process.stdout,
986
+ output: m2 = process.stdout,
1144
987
  spacing: l3 = 1,
1145
988
  withGuide: c3
1146
989
  } = {}) => {
@@ -1155,7 +998,7 @@ var log = {
1155
998
  for (const p2 of y2)
1156
999
  p2.length > 0 ? t3.push(`${u4}${p2}`) : t3.push(o2 ? r3 : "");
1157
1000
  }
1158
- m3.write(`${t3.join(`
1001
+ m2.write(`${t3.join(`
1159
1002
  `)}
1160
1003
  `);
1161
1004
  },
@@ -1206,7 +1049,7 @@ var spinner = ({
1206
1049
  errorMessage: O2,
1207
1050
  frames: E2 = unicode ? ["\u25D2", "\u25D0", "\u25D3", "\u25D1"] : ["\u2022", "o", "O", "0"],
1208
1051
  delay: F2 = unicode ? 80 : 120,
1209
- signal: m3,
1052
+ signal: m2,
1210
1053
  ...I3
1211
1054
  } = {}) => {
1212
1055
  const u4 = isCI();
@@ -1215,9 +1058,9 @@ var spinner = ({
1215
1058
  const r3 = e2 > 1 ? O2 ?? settings.messages.error : G ?? settings.messages.cancel;
1216
1059
  S2 = e2 === 1, d2 && (a3(r3, e2), S2 && typeof h3 == "function" && h3());
1217
1060
  }, f2 = () => g2(2), i3 = () => g2(1), A = () => {
1218
- process.on("uncaughtExceptionMonitor", f2), process.on("unhandledRejection", f2), process.on("SIGINT", i3), process.on("SIGTERM", i3), process.on("exit", g2), m3 && m3.addEventListener("abort", i3);
1061
+ process.on("uncaughtExceptionMonitor", f2), process.on("unhandledRejection", f2), process.on("SIGINT", i3), process.on("SIGTERM", i3), process.on("exit", g2), m2 && m2.addEventListener("abort", i3);
1219
1062
  }, H = () => {
1220
- process.removeListener("uncaughtExceptionMonitor", f2), process.removeListener("unhandledRejection", f2), process.removeListener("SIGINT", i3), process.removeListener("SIGTERM", i3), process.removeListener("exit", g2), m3 && m3.removeEventListener("abort", i3);
1063
+ process.removeListener("uncaughtExceptionMonitor", f2), process.removeListener("unhandledRejection", f2), process.removeListener("SIGINT", i3), process.removeListener("SIGTERM", i3), process.removeListener("exit", g2), m2 && m2.removeEventListener("abort", i3);
1221
1064
  }, y2 = () => {
1222
1065
  if (p2 === void 0) return;
1223
1066
  u4 && n4.write(`
@@ -1291,11 +1134,11 @@ var c = (t3, o2) => t3.includes(`
1291
1134
  `).map((d2) => o2(d2)).join(`
1292
1135
  `) : o2(t3);
1293
1136
  var select = (t3) => {
1294
- const o2 = (n4, m3) => {
1137
+ const o2 = (n4, m2) => {
1295
1138
  if (n4 === void 0)
1296
1139
  return "";
1297
1140
  const s2 = n4.label ?? String(n4.value);
1298
- switch (m3) {
1141
+ switch (m2) {
1299
1142
  case "disabled":
1300
1143
  return `${styleText2("gray", S_RADIO_INACTIVE)} ${c(s2, (i3) => styleText2("gray", i3))}${n4.hint ? ` ${styleText2("dim", `(${n4.hint ?? "disabled"})`)}` : ""}`;
1301
1144
  case "selected":
@@ -1315,11 +1158,11 @@ var select = (t3) => {
1315
1158
  output: t3.output,
1316
1159
  initialValue: t3.initialValue,
1317
1160
  render() {
1318
- const n4 = t3.withGuide ?? settings.withGuide, m3 = `${symbol(this.state)} `, s2 = `${symbolBar(this.state)} `, i3 = wrapTextWithPrefix(
1161
+ const n4 = t3.withGuide ?? settings.withGuide, m2 = `${symbol(this.state)} `, s2 = `${symbolBar(this.state)} `, i3 = wrapTextWithPrefix(
1319
1162
  t3.output,
1320
1163
  t3.message,
1321
1164
  s2,
1322
- m3
1165
+ m2
1323
1166
  ), u4 = `${n4 ? `${styleText2("gray", S_BAR)}
1324
1167
  ` : ""}${i3}
1325
1168
  `;
@@ -1875,7 +1718,7 @@ function _getBuiltinFlags(long, short, userNames, userAliases) {
1875
1718
  // package.json
1876
1719
  var package_default = {
1877
1720
  name: "zapdev",
1878
- version: "0.6.0",
1721
+ version: "0.7.1",
1879
1722
  description: "Fast, performant and lightweight git chores and dev tools using your terminal and your own local LLM.",
1880
1723
  type: "module",
1881
1724
  bin: {
@@ -1934,23 +1777,52 @@ var package_default = {
1934
1777
  }
1935
1778
  };
1936
1779
 
1937
- // src/lib/branches.ts
1938
- function parseDefaultBranch(originHead) {
1939
- const trimmed = originHead.trim();
1940
- if (!trimmed) return null;
1941
- return trimmed.replace(/^origin\//, "");
1780
+ // src/types/commit.ts
1781
+ var COMMIT_TYPES = [
1782
+ "feat",
1783
+ "fix",
1784
+ "chore",
1785
+ "docs",
1786
+ "style",
1787
+ "refactor",
1788
+ "perf",
1789
+ "test",
1790
+ "build",
1791
+ "ci",
1792
+ "revert"
1793
+ ];
1794
+
1795
+ // src/lib/commit-message.ts
1796
+ function normalizeCommitType(input) {
1797
+ const normalized = input.trim().toLowerCase();
1798
+ return COMMIT_TYPES.includes(normalized) ? normalized : null;
1942
1799
  }
1943
- function parseBranches(raw) {
1944
- return raw.split("\n").map((branch) => branch.trim()).filter(Boolean);
1800
+ function applyCommitType(systemPrompt, type) {
1801
+ return `${systemPrompt}
1802
+
1803
+ The type MUST be exactly "${type}".`;
1945
1804
  }
1946
- function deletableBranches(all, protectedBranches) {
1947
- const kept = new Set(protectedBranches.filter(Boolean));
1948
- return all.filter((branch) => !kept.has(branch));
1805
+ var MAX_DIFF_CHARS = 12e3;
1806
+ function truncateDiff(diff, max = MAX_DIFF_CHARS) {
1807
+ return diff.length > max ? diff.slice(0, max) : diff;
1808
+ }
1809
+ function sanitizeCommitMessage(raw) {
1810
+ const firstLine = raw.split("\n", 1)[0] ?? "";
1811
+ return firstLine.replace(/[`"]/g, "").trim();
1949
1812
  }
1950
1813
 
1951
- // src/lib/errors.ts
1952
- function errorMessage(error) {
1953
- return error instanceof Error ? error.message : String(error);
1814
+ // src/lib/config.ts
1815
+ var DEFAULT_OLLAMA_URL = "http://localhost:11434";
1816
+ var DEFAULT_MODEL = "deepseek-v4-flash:cloud";
1817
+ function resolveConfig(env = process.env, overrides = {}) {
1818
+ return {
1819
+ ollamaUrl: normalizeBaseUrl(overrides.ollamaUrl ?? env.OLLAMA_URL ?? DEFAULT_OLLAMA_URL),
1820
+ model: overrides.model ?? env.OLLAMA_MODEL ?? DEFAULT_MODEL
1821
+ };
1822
+ }
1823
+ function normalizeBaseUrl(raw) {
1824
+ const trimmed = raw.trim().replace(/\/+$/, "");
1825
+ return /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`;
1954
1826
  }
1955
1827
 
1956
1828
  // src/lib/git.ts
@@ -1964,7 +1836,7 @@ import { basename as r2, delimiter as i2, dirname as a2, normalize as o, resolve
1964
1836
  import { pipeline as c2 } from "node:stream/promises";
1965
1837
  import { PassThrough as l2 } from "node:stream";
1966
1838
  import u3 from "node:readline";
1967
- import { closeSync as d, openSync as f, readSync as p, statSync as m2 } from "node:fs";
1839
+ import { closeSync as d, openSync as f, readSync as p, statSync as m } from "node:fs";
1968
1840
  var h2 = /^path$/i;
1969
1841
  var g = {
1970
1842
  key: "PATH",
@@ -2105,7 +1977,7 @@ function O(e2, t3) {
2105
1977
  for (const e3 of l3) {
2106
1978
  const t5 = n4 + e3;
2107
1979
  try {
2108
- if (m2(t5).isFile()) return t5;
1980
+ if (m(t5).isFile()) return t5;
2109
1981
  } catch {
2110
1982
  }
2111
1983
  }
@@ -2283,6 +2155,37 @@ var R2 = (e2, t3, n4) => {
2283
2155
  };
2284
2156
  var z = R2;
2285
2157
 
2158
+ // src/lib/branches.ts
2159
+ function parseDefaultBranch(originHead) {
2160
+ const trimmed = originHead.trim();
2161
+ if (!trimmed) return null;
2162
+ return trimmed.replace(/^origin\//, "");
2163
+ }
2164
+ function parseBranches(raw) {
2165
+ return raw.split("\n").map((branch) => branch.trim()).filter(Boolean);
2166
+ }
2167
+ function deletableBranches(all, protectedBranches) {
2168
+ const kept = new Set(protectedBranches.filter(Boolean));
2169
+ return all.filter((branch) => !kept.has(branch));
2170
+ }
2171
+
2172
+ // src/lib/worktrees.ts
2173
+ var WORKTREE_PREFIX = "worktree ";
2174
+ var BRANCH_PREFIX = "branch ";
2175
+ function parseWorktrees(porcelain) {
2176
+ const worktrees = [];
2177
+ let current = null;
2178
+ for (const line of porcelain.split("\n")) {
2179
+ if (line.startsWith(WORKTREE_PREFIX)) {
2180
+ current = { path: line.slice(WORKTREE_PREFIX.length).trim(), branch: null };
2181
+ worktrees.push(current);
2182
+ } else if (current && line.startsWith(BRANCH_PREFIX)) {
2183
+ current.branch = line.slice(BRANCH_PREFIX.length).trim().replace(/^refs\/heads\//, "");
2184
+ }
2185
+ }
2186
+ return worktrees;
2187
+ }
2188
+
2286
2189
  // src/lib/git.ts
2287
2190
  var UPSTREAM_REF = "@{upstream}";
2288
2191
  var BRANCH_FORMAT = "%(refname:short)";
@@ -2319,22 +2222,22 @@ async function push() {
2319
2222
  async function pushSetUpstream(branch) {
2320
2223
  await git(["push", "-u", "origin", branch]);
2321
2224
  }
2322
- async function findGitRepos(start) {
2323
- const repos = [];
2324
- await collectRepos(start, repos);
2325
- return repos.sort();
2225
+ async function findRepos(path) {
2226
+ if (await isGitRepo(path)) return [path];
2227
+ const entries = await readdir(path, { withFileTypes: true }).catch(() => null);
2228
+ if (!entries) return [];
2229
+ const dirs = entries.filter((entry) => entry.isDirectory() && entry.name !== "node_modules");
2230
+ const repos = await Promise.all(
2231
+ dirs.map(async (entry) => {
2232
+ const dir = join(path, entry.name);
2233
+ return await isGitRepo(dir) ? dir : null;
2234
+ })
2235
+ );
2236
+ return repos.filter((dir) => dir !== null).sort();
2326
2237
  }
2327
- async function collectRepos(dir, repos) {
2238
+ async function isGitRepo(dir) {
2328
2239
  const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
2329
- if (!entries) return;
2330
- if (entries.some((entry) => entry.name === ".git")) {
2331
- repos.push(dir);
2332
- return;
2333
- }
2334
- const subdirs = entries.filter(
2335
- (entry) => entry.isDirectory() && entry.name !== "node_modules"
2336
- );
2337
- await Promise.all(subdirs.map((entry) => collectRepos(join(dir, entry.name), repos)));
2240
+ return entries?.some((entry) => entry.name === ".git" && entry.isDirectory()) ?? false;
2338
2241
  }
2339
2242
  async function fetchPrune(repo) {
2340
2243
  await git(["fetch", "--prune"], repo);
@@ -2353,6 +2256,21 @@ async function currentBranchAt(repo) {
2353
2256
  async function localBranches(repo) {
2354
2257
  return parseBranches(await git(["branch", `--format=${BRANCH_FORMAT}`], repo));
2355
2258
  }
2259
+ async function branchExists(repo, branch) {
2260
+ const local = await tryGit(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], repo);
2261
+ if (local !== null) return true;
2262
+ const remote = await tryGit(
2263
+ ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branch}`],
2264
+ repo
2265
+ );
2266
+ return remote !== null;
2267
+ }
2268
+ async function listWorktrees(repo) {
2269
+ return parseWorktrees(await git(["worktree", "list", "--porcelain"], repo));
2270
+ }
2271
+ async function removeWorktree(repo, path) {
2272
+ await git(["worktree", "remove", "--force", path], repo);
2273
+ }
2356
2274
  async function switchBranch(repo, branch) {
2357
2275
  await git(["switch", branch], repo);
2358
2276
  }
@@ -2363,188 +2281,9 @@ async function pull(repo) {
2363
2281
  await git(["pull"], repo);
2364
2282
  }
2365
2283
 
2366
- // src/commands/cleanup.ts
2367
- var cleanupCommand = defineCommand({
2368
- meta: {
2369
- name: "cleanup",
2370
- description: "Prune every git repo under a path, then delete stale local branches."
2371
- },
2372
- args: {
2373
- path: {
2374
- type: "positional",
2375
- required: false,
2376
- default: ".",
2377
- description: "Directory to scan for git repos (recursively)."
2378
- },
2379
- stay: {
2380
- type: "boolean",
2381
- alias: "s",
2382
- description: "Stay on the current branch instead of switching to the default."
2383
- },
2384
- pull: {
2385
- type: "boolean",
2386
- alias: "p",
2387
- description: "Pull the checked-out branch after cleanup without asking."
2388
- },
2389
- yes: {
2390
- type: "boolean",
2391
- alias: "y",
2392
- description: "Non-interactive: switch to default and delete every other local branch."
2393
- }
2394
- },
2395
- async run({ args }) {
2396
- const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !args.yes;
2397
- const options = { stay: Boolean(args.stay), pull: Boolean(args.pull) };
2398
- const path = args.path || ".";
2399
- if (interactive) intro("zapdev cleanup");
2400
- const repos = await findGitRepos(path);
2401
- if (repos.length === 0) {
2402
- log.warn(`No git repository found under ${path}.`);
2403
- if (interactive) outro("Nothing to do.");
2404
- return;
2405
- }
2406
- for (const repo of repos) {
2407
- const cancelled = await cleanupRepo(repo, options, interactive);
2408
- if (cancelled) {
2409
- cancel("Cancelled.");
2410
- return;
2411
- }
2412
- }
2413
- if (interactive) outro(`Done (${repos.length} repo${repos.length > 1 ? "s" : ""}).`);
2414
- }
2415
- });
2416
- async function cleanupRepo(repo, options, interactive) {
2417
- log.step(`Cleaning ${repo}`);
2418
- const fetchLoader = interactive ? spinner() : void 0;
2419
- fetchLoader?.start("Fetching + pruning\u2026");
2420
- try {
2421
- await fetchPrune(repo);
2422
- fetchLoader?.stop("Fetched + pruned");
2423
- } catch (error) {
2424
- fetchLoader?.error("Fetch failed");
2425
- log.warn(` Fetch failed, continuing: ${errorMessage(error)}`);
2426
- }
2427
- const [base, current] = await Promise.all([defaultBranch(repo), currentBranchAt(repo)]);
2428
- if (!base) {
2429
- log.warn(" Skipped: no origin/HEAD.");
2430
- return false;
2431
- }
2432
- let endBranch = base;
2433
- if (current && current !== base) {
2434
- if (options.stay) {
2435
- endBranch = current;
2436
- } else if (interactive) {
2437
- const choice = await select({
2438
- message: "After cleanup, be on:",
2439
- initialValue: base,
2440
- options: [
2441
- { value: base, label: `Switch to ${base}` },
2442
- { value: current, label: `Stay on ${current} (keep it)` }
2443
- ]
2444
- });
2445
- if (isCancel(choice)) return true;
2446
- endBranch = choice;
2447
- }
2448
- }
2449
- if (current !== endBranch) {
2450
- try {
2451
- await switchBranch(repo, endBranch);
2452
- } catch (error) {
2453
- log.error(` Could not switch to ${endBranch}: ${errorMessage(error)}`);
2454
- return false;
2455
- }
2456
- }
2457
- const candidates = deletableBranches(await localBranches(repo), [base, endBranch]);
2458
- if (candidates.length === 0) {
2459
- log.info(" No branch to delete.");
2460
- } else {
2461
- let toDelete = candidates;
2462
- if (interactive) {
2463
- const selected = await multiselect({
2464
- message: "Branches to delete",
2465
- options: candidates.map((branch) => ({ value: branch, label: branch })),
2466
- required: false
2467
- });
2468
- if (isCancel(selected)) return true;
2469
- toDelete = selected;
2470
- }
2471
- let deleted = 0;
2472
- for (const branch of toDelete) {
2473
- try {
2474
- await deleteBranch(repo, branch);
2475
- deleted += 1;
2476
- } catch (error) {
2477
- log.error(` Could not delete ${branch}: ${errorMessage(error)}`);
2478
- }
2479
- }
2480
- if (deleted > 0) log.success(` Deleted ${deleted} branch${deleted > 1 ? "es" : ""}.`);
2481
- }
2482
- let shouldPull = options.pull;
2483
- if (!shouldPull && interactive) {
2484
- const answer = await confirm({ message: `Pull ${endBranch}?`, initialValue: false });
2485
- if (isCancel(answer)) return true;
2486
- shouldPull = answer;
2487
- }
2488
- if (shouldPull) {
2489
- const pullLoader = interactive ? spinner() : void 0;
2490
- pullLoader?.start(`Pulling ${endBranch}\u2026`);
2491
- try {
2492
- await pull(repo);
2493
- pullLoader?.stop(`\u2713 Pulled ${endBranch}`);
2494
- } catch (error) {
2495
- pullLoader?.error("Pull failed");
2496
- log.error(` Pull failed: ${errorMessage(error)}`);
2497
- }
2498
- }
2499
- return false;
2500
- }
2501
-
2502
- // src/types/commit.ts
2503
- var COMMIT_TYPES = [
2504
- "feat",
2505
- "fix",
2506
- "chore",
2507
- "docs",
2508
- "style",
2509
- "refactor",
2510
- "perf",
2511
- "test",
2512
- "build",
2513
- "ci",
2514
- "revert"
2515
- ];
2516
-
2517
- // src/lib/commit-message.ts
2518
- function normalizeCommitType(input) {
2519
- const normalized = input.trim().toLowerCase();
2520
- return COMMIT_TYPES.includes(normalized) ? normalized : null;
2521
- }
2522
- function applyCommitType(systemPrompt, type) {
2523
- return `${systemPrompt}
2524
-
2525
- The type MUST be exactly "${type}".`;
2526
- }
2527
- var MAX_DIFF_CHARS = 12e3;
2528
- function truncateDiff(diff, max = MAX_DIFF_CHARS) {
2529
- return diff.length > max ? diff.slice(0, max) : diff;
2530
- }
2531
- function sanitizeCommitMessage(raw) {
2532
- const firstLine = raw.split("\n", 1)[0] ?? "";
2533
- return firstLine.replace(/[`"]/g, "").trim();
2534
- }
2535
-
2536
- // src/lib/config.ts
2537
- var DEFAULT_OLLAMA_URL = "http://localhost:11434";
2538
- var DEFAULT_MODEL = "deepseek-v4-flash:cloud";
2539
- function resolveConfig(env = process.env, overrides = {}) {
2540
- return {
2541
- ollamaUrl: normalizeBaseUrl(overrides.ollamaUrl ?? env.OLLAMA_URL ?? DEFAULT_OLLAMA_URL),
2542
- model: overrides.model ?? env.OLLAMA_MODEL ?? DEFAULT_MODEL
2543
- };
2544
- }
2545
- function normalizeBaseUrl(raw) {
2546
- const trimmed = raw.trim().replace(/\/+$/, "");
2547
- return /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`;
2284
+ // src/lib/errors.ts
2285
+ function errorMessage(error) {
2286
+ return error instanceof Error ? error.message : String(error);
2548
2287
  }
2549
2288
 
2550
2289
  // src/prompts/commit-message.md
@@ -2746,14 +2485,208 @@ var commitCommand = defineCommand({
2746
2485
  }
2747
2486
  });
2748
2487
 
2488
+ // src/commands/reset.ts
2489
+ var CANCEL = /* @__PURE__ */ Symbol("cancel");
2490
+ var resetCommand = defineCommand({
2491
+ meta: {
2492
+ name: "reset",
2493
+ description: "Switch each repo to a branch, then delete all other local branches and worktrees."
2494
+ },
2495
+ args: {
2496
+ path: {
2497
+ type: "positional",
2498
+ required: false,
2499
+ default: ".",
2500
+ description: "A git repo to reset, or a directory whose direct child repos are reset."
2501
+ },
2502
+ principal: {
2503
+ type: "boolean",
2504
+ alias: "p",
2505
+ description: "Switch every repo to its resolved principal branch (origin/HEAD)."
2506
+ },
2507
+ target: {
2508
+ type: "string",
2509
+ alias: "t",
2510
+ description: "Switch every repo to this branch, falling back to the principal one."
2511
+ },
2512
+ pull: {
2513
+ type: "boolean",
2514
+ description: "Pull the checked-out branch after reset without asking."
2515
+ },
2516
+ yes: {
2517
+ type: "boolean",
2518
+ alias: "y",
2519
+ description: "Non-interactive: switch and delete without confirmation."
2520
+ }
2521
+ },
2522
+ async run({ args }) {
2523
+ const assumeYes = Boolean(args.yes);
2524
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !assumeYes;
2525
+ const options = {
2526
+ principal: Boolean(args.principal),
2527
+ target: typeof args.target === "string" ? args.target.trim() : "",
2528
+ pull: Boolean(args.pull)
2529
+ };
2530
+ const path = args.path || ".";
2531
+ if (interactive) intro("zapdev reset");
2532
+ const repos = await findRepos(path);
2533
+ if (repos.length === 0) {
2534
+ log.warn(`No git repository found at or under ${path}.`);
2535
+ if (interactive) outro("Nothing to do.");
2536
+ return;
2537
+ }
2538
+ for (const repo of repos) {
2539
+ try {
2540
+ const cancelled = await resetRepo(repo, options, interactive, assumeYes);
2541
+ if (cancelled) {
2542
+ cancel("Cancelled.");
2543
+ return;
2544
+ }
2545
+ } catch (error) {
2546
+ log.error(`Skipped ${repo}: ${errorMessage(error)}`);
2547
+ }
2548
+ }
2549
+ if (interactive) outro(`Done (${repos.length} repo${repos.length > 1 ? "s" : ""}).`);
2550
+ }
2551
+ });
2552
+ async function resetRepo(repo, options, interactive, assumeYes) {
2553
+ log.step(`Resetting ${repo}`);
2554
+ const fetchLoader = interactive ? spinner() : void 0;
2555
+ fetchLoader?.start("Fetching + pruning\u2026");
2556
+ try {
2557
+ await fetchPrune(repo);
2558
+ fetchLoader?.stop("Fetched + pruned");
2559
+ } catch (error) {
2560
+ fetchLoader?.error("Fetch failed");
2561
+ log.warn(` Fetch failed, continuing: ${errorMessage(error)}`);
2562
+ }
2563
+ const [principal, current] = await Promise.all([defaultBranch(repo), currentBranchAt(repo)]);
2564
+ const endBranch = await resolveEndBranch(repo, principal, current, options, interactive);
2565
+ if (endBranch === CANCEL) return true;
2566
+ if (endBranch === null) return false;
2567
+ const [branches, worktrees] = await Promise.all([localBranches(repo), listWorktrees(repo)]);
2568
+ const branchesToDelete = deletableBranches(branches, [principal ?? "", endBranch]);
2569
+ const worktreesToRemove = worktrees.slice(1);
2570
+ if (branchesToDelete.length > 0 || worktreesToRemove.length > 0) {
2571
+ if (!assumeYes) {
2572
+ if (!interactive) {
2573
+ log.warn(" Refusing to delete non-interactively; pass --yes.");
2574
+ return false;
2575
+ }
2576
+ announcePlan(endBranch, branchesToDelete, worktreesToRemove);
2577
+ const ok = await confirm({ message: "Delete permanently?", initialValue: false });
2578
+ if (isCancel(ok)) return true;
2579
+ if (!ok) {
2580
+ log.info(" Skipped.");
2581
+ return false;
2582
+ }
2583
+ }
2584
+ await removeWorktrees(repo, worktreesToRemove);
2585
+ }
2586
+ if (!await switchTo(repo, current, endBranch)) return false;
2587
+ if (branchesToDelete.length > 0) await deleteBranches(repo, branchesToDelete);
2588
+ return maybePull(repo, endBranch, options, interactive);
2589
+ }
2590
+ async function resolveEndBranch(repo, principal, current, options, interactive) {
2591
+ if (options.target) {
2592
+ if (await branchExists(repo, options.target)) return options.target;
2593
+ if (principal) return principal;
2594
+ log.warn(` Target "${options.target}" not found and no origin/HEAD; skipped.`);
2595
+ return null;
2596
+ }
2597
+ if (options.principal || !interactive) {
2598
+ if (principal) return principal;
2599
+ log.warn(" No origin/HEAD to resolve the principal branch; skipped.");
2600
+ return null;
2601
+ }
2602
+ const branches = await localBranches(repo);
2603
+ if (branches.length === 0) return principal;
2604
+ const choice = await select({
2605
+ message: "Switch to:",
2606
+ initialValue: principal ?? current ?? branches[0],
2607
+ options: branches.map((branch) => ({
2608
+ value: branch,
2609
+ label: branch === principal ? `${branch} (principal)` : branch
2610
+ }))
2611
+ });
2612
+ if (isCancel(choice)) return CANCEL;
2613
+ return choice;
2614
+ }
2615
+ function announcePlan(endBranch, branches, worktrees) {
2616
+ log.info(` Switch to ${endBranch}`);
2617
+ if (branches.length > 0) {
2618
+ const label = branches.length > 1 ? "branches" : "branch";
2619
+ log.warn(` Delete ${branches.length} ${label}: ${branches.join(", ")}`);
2620
+ }
2621
+ if (worktrees.length > 0) {
2622
+ const label = worktrees.length > 1 ? "worktrees" : "worktree";
2623
+ const paths = worktrees.map((worktree) => worktree.path).join(", ");
2624
+ log.warn(` Remove ${worktrees.length} ${label}: ${paths}`);
2625
+ }
2626
+ }
2627
+ async function removeWorktrees(repo, worktrees) {
2628
+ let removed = 0;
2629
+ for (const worktree of worktrees) {
2630
+ try {
2631
+ await removeWorktree(repo, worktree.path);
2632
+ removed += 1;
2633
+ } catch (error) {
2634
+ log.error(` Could not remove worktree ${worktree.path}: ${errorMessage(error)}`);
2635
+ }
2636
+ }
2637
+ if (removed > 0) log.success(` Removed ${removed} worktree${removed > 1 ? "s" : ""}.`);
2638
+ }
2639
+ async function switchTo(repo, current, endBranch) {
2640
+ if (current === endBranch) return true;
2641
+ try {
2642
+ await switchBranch(repo, endBranch);
2643
+ return true;
2644
+ } catch (error) {
2645
+ log.error(` Could not switch to ${endBranch}: ${errorMessage(error)}`);
2646
+ return false;
2647
+ }
2648
+ }
2649
+ async function deleteBranches(repo, branches) {
2650
+ let deleted = 0;
2651
+ for (const branch of branches) {
2652
+ try {
2653
+ await deleteBranch(repo, branch);
2654
+ deleted += 1;
2655
+ } catch (error) {
2656
+ log.error(` Could not delete ${branch}: ${errorMessage(error)}`);
2657
+ }
2658
+ }
2659
+ if (deleted > 0) log.success(` Deleted ${deleted} branch${deleted > 1 ? "es" : ""}.`);
2660
+ }
2661
+ async function maybePull(repo, endBranch, options, interactive) {
2662
+ let shouldPull = options.pull;
2663
+ if (!shouldPull && interactive) {
2664
+ const answer = await confirm({ message: `Pull ${endBranch}?`, initialValue: false });
2665
+ if (isCancel(answer)) return true;
2666
+ shouldPull = answer;
2667
+ }
2668
+ if (shouldPull) {
2669
+ const loader = interactive ? spinner() : void 0;
2670
+ loader?.start(`Pulling ${endBranch}\u2026`);
2671
+ try {
2672
+ await pull(repo);
2673
+ loader?.stop(`\u2713 Pulled ${endBranch}`);
2674
+ } catch (error) {
2675
+ loader?.error("Pull failed");
2676
+ log.error(` Pull failed: ${errorMessage(error)}`);
2677
+ }
2678
+ }
2679
+ return false;
2680
+ }
2681
+
2749
2682
  // src/cli.ts
2750
2683
  var commands = {
2751
2684
  commit: commitCommand,
2752
- cleanup: cleanupCommand
2685
+ reset: resetCommand
2753
2686
  };
2754
2687
  var MENU = {
2755
2688
  commit: "Stage all changes and commit with an AI-generated message",
2756
- cleanup: "Prune repos and delete stale local branches"
2689
+ reset: "Switch repos to a branch and delete the other branches and worktrees"
2757
2690
  };
2758
2691
  var menuOptions = Object.keys(MENU).map((value) => ({
2759
2692
  value,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zapdev",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Fast, performant and lightweight git chores and dev tools using your terminal and your own local LLM.",
5
5
  "type": "module",
6
6
  "bin": {