zapdev 1.8.0 → 1.10.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 +47 -23
  2. package/dist/cli.js +251 -222
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -113,7 +113,7 @@ Stages all changes, scans them with Gitleaks when installed, generates Conventio
113
113
  - **Unified review:** generates messages in parallel, then presents every message with its repository name and branch. Repositories with pending changes are shown in bold and underlined when terminal styling is enabled.
114
114
  - **Actions:** commit all, commit only a named repository, edit a named repository's message, or cancel. Editing returns to the review menu.
115
115
 
116
- Repositories with nothing to commit are skipped. Unselected or cancelled changes remain staged. Push confirmation is asked once for the successfully committed repositories.
116
+ Repositories with no new changes can still send existing commits. Unselected or cancelled changes remain staged; failed or unselected commit drafts are not pushed. Push confirmation is asked once, followed by a destination input for each eligible repository or subtree.
117
117
 
118
118
  ```bash
119
119
  zapdev commit
@@ -124,61 +124,85 @@ zapdev commit
124
124
  | `--url <url>` | Override the complete Chat Completions endpoint |
125
125
  | `--model <model>` | Override the model |
126
126
  | `--effort <effort>` | Override the reasoning effort |
127
- | `-t, --type <type>` | Force the Conventional Commit type (`feat`, `fix`, `chore`, etc.) |
128
- | `-p, --push` | Push or publish subtrees after committing without asking |
129
- | `--publish-subtree` | Publish subtrees instead of using the classic push |
130
- | `--publish-subtree=false` | Force classic push, overriding `zapdev.json` |
127
+ | `-t, --type <type>` | Force an exact lowercase Conventional Commit type (`feat`, `fix`, `chore`, etc.) |
128
+ | `-p, --push` | Skip push confirmation; still ask for each destination branch |
131
129
  | `-s, --staged` | Commit only changes that are already staged |
132
- | `-r, --rebase` | Rebase on upstream if the push is rejected |
133
- | `-m, --merge` | Merge upstream if the push is rejected |
134
130
  | `-y, --yes` | Skip commit review; still confirm push unless `--push` is set |
135
131
 
136
132
  ```bash
137
133
  zapdev commit -t feat # force the type
138
134
  zapdev commit --staged # leave unstaged changes untouched
139
135
  zapdev commit -y # commit automatically, then ask before pushing
140
- zapdev commit -yp # commit and push without confirmation
136
+ zapdev commit -yp # commit automatically, then enter each push destination
141
137
  ```
142
138
 
143
139
  Before contacting the LLM endpoint, zapdev runs `gitleaks git --staged --verbose` in each changed repository when Gitleaks is installed. A failed scan skips that repository without sending its diff; when Gitleaks is absent, the scan is skipped. The staged diff is sent to the configured endpoint, which may be remote.
144
140
 
145
- Failures are reported per repository while the others continue. Any failure produces a nonzero exit code.
141
+ Preparation and commit failures are reported per repository while the others continue. A push failure stops all remaining sends. Any failure produces a nonzero exit code.
146
142
 
147
143
  In a terminal, Git and its hooks display live logs and support native prompts, even with `--yes` (which skips commit review, not native Git prompts). Git controls hook stdin; interactive hooks should read from the terminal, for example `/dev/tty`. Each hook has a 60-second deadline, including time spent answering prompts; override it with `ZD_HOOK_TIMEOUT=120 zapdev commit`.
148
144
 
149
- Pushing is optimistic, with no preliminary fetch. If the branch is behind upstream, `--rebase` runs `git pull --rebase`, while `--merge` runs `git pull --no-rebase --no-edit`; zapdev then retries once. Without either flag, interactive runs ask whether to rebase, merge, or quit. Runs using `--yes` or without a TTY must provide one of the flags.
145
+ Every push asks for a destination branch, even with `--yes --push`. In both modes, prompts start empty and then reuse the previous input across repositories in this run. Press Enter to accept the prefilled name; an empty or cleared input uses the current branch and leaves the next prompt empty. Pushing to `main` is allowed in both modes. Classic pushes use the current branch's upstream remote, or the only configured remote if there is no upstream, and run `git push <remote> HEAD:refs/heads/<input>` without changing local branches or their upstreams. The destination need not exist locally. When no remote can be selected unambiguously, the repository's push is skipped with a warning, without a branch prompt. Git rejections stop the command: no rebase, merge, retry, or force-push.
150
146
 
151
- Without a TTY, zapdev commits all prepared repositories automatically and only pushes when `--push` is set.
147
+ Before each destination input, both modes check the presumed remote branch: the previous input, or the current branch when empty. If that branch is missing, the check falls back to the remote's default branch (`HEAD`). Only unpublished local commits trigger a destination input; if neither reference exists, zapdev also asks. Classic mode compares HEAD; subtree mode compares its extracted history. This check cannot anticipate a different destination you would type next. Repositories without new changes do not trigger an LLM call or a new commit.
148
+
149
+ Without a TTY, zapdev commits all prepared repositories automatically but cannot push; `--push` reports an error and leaves commits local.
152
150
 
153
151
  #### Subtree publication
154
152
 
155
153
  Place an optional `zapdev.json` in the directory where you launch zapdev:
156
154
 
157
155
  ```json
158
- { "isSubtree": true }
156
+ {
157
+ "subtrees": {
158
+ "projet-front": "front",
159
+ "projet-back": "back"
160
+ }
161
+ }
159
162
  ```
160
163
 
161
164
  | `zapdev.json` option | Default | Effect |
162
165
  | ------------------- | ------- | ------ |
163
- | `isSubtree` | `false` | Replace classic push with subtree publication when `true`; must be a boolean. |
166
+ | `subtrees` | `{}` | Map repository-relative folders to Git remote names. A nonempty mapping replaces classic push with subtree publication. |
164
167
 
165
- - **Scope:** only the launch directory's file is read, not parents or child repositories. Its setting applies to all successfully committed repositories.
166
- - **Priority:** explicit `--publish-subtree` / `--publish-subtree=false` overrides the file. Without a flag, invalid configuration stops the command before staging.
167
- - **Destinations:** `projet-front` → remote `front`, `projet-back` → remote `back`, both using the current branch name. `main` and detached HEAD are refused.
168
- - **Publication:** fetch each remote's `main`, split committed contents, skip contents identical to `main`, then push without force. Uncommitted changes are excluded.
169
- - **Failures:** stop that repository's publication, leaving earlier publications intact; other repositories continue. No automatic rebase/merge recovery.
168
+ - **Scope:** only the launch directory's file is read, not parents or child repositories. Its mapping applies to newly committed repositories and repositories without new changes.
169
+ - **Configuration:** only `zapdev.json` selects subtree mode; no subtree CLI flag. Missing or empty `subtrees` uses classic push. Invalid configuration stops the command before staging. Replace the removed `isSubtree` option with an explicit mapping.
170
+ - **Destinations:** only mapped folders are published, using their configured remotes; folder and remote names need not match. Each changed subtree asks for its own destination branch, initially empty, then prefilled with the previous input across repositories in this run. Press Enter to reuse it, or leave the input empty to use the current branch. `main` is allowed; detached HEAD is refused.
171
+ - **Publication:** check the presumed destination branch using the shared unpublished-commit rule above, then run `git subtree push --prefix=<folder> <remote> <input>`. Already published history is skipped without prompting; uncommitted changes are excluded.
172
+ - **Failures:** stop all remaining publications, leaving earlier publications intact. No automatic recovery.
170
173
 
171
- Interactive runs still ask for confirmation, including with `--yes`; `--push` publishes without asking. Without a TTY, `--yes` alone does not publish.
174
+ Interactive runs still ask for confirmation, including with `--yes`; `--push` skips only that confirmation, never the destination inputs. Publication requires a TTY.
172
175
 
173
176
  ```bash
174
- zapdev commit --publish-subtree # ask to publish after committing
175
- zapdev commit --publish-subtree -yp # publish without zapdev prompts
176
- zapdev commit --publish-subtree=false -yp # force classic push
177
+ zapdev commit # with subtrees configured, ask to publish after committing
178
+ zapdev commit -yp # enter a destination for each changed subtree
177
179
  ```
178
180
 
181
+ ### `zapdev subtree-init`
182
+
183
+ Create or extend a repository with squashed subtrees, named remotes, and the matching `zapdev.json`. No gum or LLM configuration required.
184
+
185
+ ```bash
186
+ zapdev subtree-init my-project \
187
+ front=git@github.com:org/front.git \
188
+ back=git@github.com:org/back.git \
189
+ --origin git@github.com:org/project.git
190
+ ```
191
+
192
+ | Parameter | Effect |
193
+ | --------- | ------ |
194
+ | `<directory>` | Repository to create or extend. New repositories start on `main`; existing branches are kept. |
195
+ | `<name=url>...` | One or more sources. Each name is both the subtree folder and its Git remote name. |
196
+ | `--origin <url>` | Optional parent repository remote; replaces the shell helper's `remote=url` argument. |
197
+
198
+ - Requires a clean working tree, including untracked files. Existing folders, conflicting mappings or remote URLs, and a symlinked `zapdev.json` are refused before importing any subtree.
199
+ - Detects each source's default branch, falling back to `main` with a warning when unavailable; imports with `git subtree add --squash`.
200
+ - Commits the explicit folder-to-remote mapping before imports, preserving existing mappings and other JSON settings. Example: `{"subtrees":{"front":"front","back":"back"}}`.
201
+ - Does not push. Git failures stop immediately without rollback; configuration, remotes, and earlier imports remain in place.
202
+
179
203
  ### Zed IDE
180
204
 
181
- For a faster review and commit workflow, review and stage changes from Zed's Git panel, then run `zapdev commit -syp` from a task. The command commits only staged changes, skips prompts, and pushes the commit.
205
+ For a faster review and commit workflow, review and stage changes from Zed's Git panel, then run `zapdev commit -syp` from a task. The command commits only staged changes, skips commit review and push confirmation, then asks for the push destination.
182
206
 
183
207
  Add the following tasks to `.zed/tasks.json`:
184
208
 
package/dist/cli.js CHANGED
@@ -554,7 +554,7 @@ function _getBuiltinFlags(long, short, userNames, userAliases) {
554
554
  // package.json
555
555
  var package_default = {
556
556
  name: "zapdev",
557
- version: "1.8.0",
557
+ version: "1.10.0",
558
558
  description: "Fast, performant and lightweight git chores and dev tools using your terminal and an OpenAI-compatible LLM endpoint.",
559
559
  type: "module",
560
560
  bin: {
@@ -614,7 +614,7 @@ var package_default = {
614
614
 
615
615
  // src/commands/commit.ts
616
616
  import { basename } from "node:path";
617
- import { styleText as styleText3 } from "node:util";
617
+ import { styleText as styleText4 } from "node:util";
618
618
 
619
619
  // node_modules/@clack/core/dist/index.mjs
620
620
  import { styleText } from "node:util";
@@ -1780,43 +1780,9 @@ ${r3}
1780
1780
  }
1781
1781
  }).prompt();
1782
1782
 
1783
- // src/types/commit.ts
1784
- var COMMIT_TYPES = [
1785
- "feat",
1786
- "fix",
1787
- "chore",
1788
- "docs",
1789
- "style",
1790
- "refactor",
1791
- "perf",
1792
- "test",
1793
- "build",
1794
- "ci",
1795
- "revert"
1796
- ];
1797
-
1798
- // src/lib/commit-message.ts
1799
- function normalizeCommitType(input) {
1800
- const normalized = input.trim().toLowerCase();
1801
- return COMMIT_TYPES.includes(normalized) ? normalized : null;
1802
- }
1803
- function applyCommitType(systemPrompt, type) {
1804
- return `${systemPrompt}
1805
-
1806
- The type MUST be exactly "${type}".`;
1807
- }
1808
- var MAX_DIFF_CHARS = 12e3;
1809
- function truncateDiff(diff, max = MAX_DIFF_CHARS) {
1810
- return diff.length > max ? diff.slice(0, max) : diff;
1811
- }
1812
- function sanitizeCommitMessage(raw) {
1813
- const firstLine = raw.split("\n", 1)[0] ?? "";
1814
- return firstLine.replace(/[`"]/g, "").trim();
1815
- }
1816
-
1817
1783
  // src/lib/config.ts
1818
1784
  import { readFile } from "node:fs/promises";
1819
- import { join } from "node:path";
1785
+ import { isAbsolute, join } from "node:path";
1820
1786
 
1821
1787
  // src/lib/errors.ts
1822
1788
  var GitOutputError = class extends Error {
@@ -1831,13 +1797,12 @@ function errorMessage(error) {
1831
1797
  }
1832
1798
 
1833
1799
  // src/lib/config.ts
1834
- async function resolvePublishSubtree(directory, override) {
1835
- if (override !== void 0) return override;
1800
+ async function readProjectConfig(directory) {
1836
1801
  let content;
1837
1802
  try {
1838
1803
  content = await readFile(join(directory, "zapdev.json"), "utf8");
1839
1804
  } catch (error) {
1840
- if (error.code === "ENOENT") return false;
1805
+ if (error.code === "ENOENT") return {};
1841
1806
  throw new Error(`Unable to read zapdev.json: ${errorMessage(error)}`, { cause: error });
1842
1807
  }
1843
1808
  let config;
@@ -1849,11 +1814,27 @@ async function resolvePublishSubtree(directory, override) {
1849
1814
  if (typeof config !== "object" || config === null || Array.isArray(config)) {
1850
1815
  throw new Error("zapdev.json must contain an object.");
1851
1816
  }
1852
- const isSubtree = config.isSubtree;
1853
- if (isSubtree !== void 0 && typeof isSubtree !== "boolean") {
1854
- throw new Error("zapdev.json isSubtree must be a boolean.");
1817
+ const settings2 = config;
1818
+ if (settings2.isSubtree !== void 0) {
1819
+ throw new Error("zapdev.json isSubtree is no longer supported; use a subtrees folder-to-remote mapping.");
1820
+ }
1821
+ const subtrees = settings2.subtrees;
1822
+ if (subtrees === void 0) return settings2;
1823
+ if (typeof subtrees !== "object" || subtrees === null || Array.isArray(subtrees)) {
1824
+ throw new Error("zapdev.json subtrees must be a folder-to-remote object.");
1825
+ }
1826
+ for (const [prefix, remote] of Object.entries(subtrees)) {
1827
+ if (prefix !== prefix.trim() || isAbsolute(prefix) || prefix.includes("\\") || prefix.split("/").some((part) => ["", ".", "..", ".git"].includes(part))) {
1828
+ throw new Error(`zapdev.json subtree folder "${prefix}" must be a repository-relative directory.`);
1829
+ }
1830
+ if (typeof remote !== "string" || !remote || /\s/.test(remote) || remote.startsWith("-")) {
1831
+ throw new Error(`zapdev.json subtree "${prefix}" must map to a nonempty Git remote name.`);
1832
+ }
1855
1833
  }
1856
- return isSubtree === true;
1834
+ return settings2;
1835
+ }
1836
+ async function resolveSubtrees(directory) {
1837
+ return (await readProjectConfig(directory)).subtrees ?? {};
1857
1838
  }
1858
1839
  function resolveHookTimeout(env = process.env) {
1859
1840
  const timeout = Number(env.ZD_HOOK_TIMEOUT ?? 60) * 1e3;
@@ -2252,17 +2233,17 @@ function createHookReporter(onHook, onTimeout, timeout) {
2252
2233
  }
2253
2234
 
2254
2235
  // src/lib/git.ts
2255
- var UPSTREAM_REF = "@{upstream}";
2256
- async function git(args, cwd, onHook) {
2236
+ async function git(args, cwd, onHook, onNativeOutput) {
2257
2237
  const timeout = resolveHookTimeout();
2258
- const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY && ["commit", "push", "pull", "fetch"].includes(args[0] ?? ""));
2238
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY && ["commit", "push", "subtree", "fetch", "ls-remote"].includes(args[0] ?? "") && !(args[0] === "subtree" && args[1] === "split"));
2259
2239
  const terminalState = interactive && process.platform !== "win32" ? execFileSync("stty", ["-g"], { stdio: ["inherit", "pipe", "ignore"], encoding: "utf8" }).trim() : void 0;
2240
+ if (interactive) onNativeOutput?.("start");
2260
2241
  const child = R2("git", args, {
2261
2242
  nodeOptions: {
2262
2243
  cwd,
2263
2244
  detached: !interactive && process.platform !== "win32",
2264
2245
  env: { ...process.env, GIT_TRACE2_EVENT: "3" },
2265
- stdio: interactive ? ["inherit", "inherit", "inherit", "pipe"] : ["pipe", "pipe", "pipe", "pipe"]
2246
+ stdio: interactive ? ["inherit", args[0] === "ls-remote" ? "pipe" : "inherit", "inherit", "pipe"] : ["pipe", "pipe", "pipe", "pipe"]
2266
2247
  }
2267
2248
  });
2268
2249
  let timeoutError;
@@ -2299,7 +2280,11 @@ async function git(args, cwd, onHook) {
2299
2280
  } finally {
2300
2281
  reader?.close();
2301
2282
  close();
2302
- if (terminalState) execFileSync("stty", [terminalState], { stdio: ["inherit", "ignore", "ignore"] });
2283
+ try {
2284
+ if (terminalState) execFileSync("stty", [terminalState], { stdio: ["inherit", "ignore", "ignore"] });
2285
+ } finally {
2286
+ if (interactive) onNativeOutput?.("exit");
2287
+ }
2303
2288
  }
2304
2289
  }
2305
2290
  function killProcessTree(pid) {
@@ -2332,8 +2317,8 @@ async function stageAll(repo) {
2332
2317
  async function getStagedDiff(repo) {
2333
2318
  return git(["diff", "--cached"], repo);
2334
2319
  }
2335
- async function commit(repo, message, onHook) {
2336
- await git(["commit", "--quiet", "-m", message], repo, onHook);
2320
+ async function commit(repo, message, onHook, onNativeOutput) {
2321
+ await git(["commit", "--quiet", "-m", message], repo, onHook, onNativeOutput);
2337
2322
  }
2338
2323
  async function currentBranch(repo) {
2339
2324
  return (await git(["symbolic-ref", "--short", "HEAD"], repo)).trim();
@@ -2346,38 +2331,26 @@ async function getRepoStatus(repo) {
2346
2331
  hasChanges: lines.some((line) => !line.startsWith("#"))
2347
2332
  };
2348
2333
  }
2349
- async function hasUpstream(repo) {
2350
- const result = await tryGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", UPSTREAM_REF], repo);
2351
- return result !== null;
2334
+ async function getPushRemote(repo, branch) {
2335
+ const remotes = (await git(["remote"], repo)).trim().split("\n").filter(Boolean);
2336
+ const upstream = (await git(["for-each-ref", "--format=%(upstream:remotename)", `refs/heads/${branch}`], repo)).trim();
2337
+ if (upstream) return remotes.includes(upstream) ? upstream : null;
2338
+ return remotes.length === 1 ? remotes[0] : null;
2352
2339
  }
2353
- async function push(repo, onHook) {
2354
- await git(["push", "--quiet"], repo, onHook);
2355
- }
2356
- async function pushSetUpstream(repo, branch, onHook) {
2357
- await git(["push", "--quiet", "-u", "origin", branch], repo, onHook);
2358
- }
2359
- async function publishSubtree(repo, remote, branch, onHook) {
2360
- if (branch === "main") throw new Error("Refusing to publish directly to main.");
2340
+ async function push(repo, remote, branch, onHook, prefix, onNativeOutput) {
2361
2341
  await git(["check-ref-format", "--branch", branch], repo);
2362
- await git(["fetch", "--quiet", remote, "main"], repo, onHook);
2363
- const split = (await git(["subtree", "split", "--quiet", `--prefix=projet-${remote}`], repo)).trim();
2364
- const changes = await git(["diff", "--name-only", "FETCH_HEAD", split, "--"], repo);
2365
- if (!changes.trim()) return false;
2366
- await git(["push", "--quiet", remote, `${split}:refs/heads/${branch}`], repo, onHook);
2367
- return true;
2368
- }
2369
- async function pullRebase(repo, onHook) {
2370
- await git(["pull", "--quiet", "--rebase"], repo, onHook);
2371
- }
2372
- async function pullMerge(repo, onHook) {
2373
- await git(["pull", "--quiet", "--no-rebase", "--no-edit"], repo, onHook);
2342
+ await git(prefix ? ["subtree", "push", `--prefix=${prefix}`, remote, branch] : ["push", "--", remote, `HEAD:refs/heads/${branch}`], repo, onHook, onNativeOutput);
2374
2343
  }
2375
- async function fetchRemote(repo, onHook) {
2376
- await git(["fetch", "--quiet"], repo, onHook);
2377
- }
2378
- async function behindCount(repo) {
2379
- const out = await tryGit(["rev-list", "--count", `HEAD..${UPSTREAM_REF}`], repo);
2380
- return out === null ? 0 : Number(out.trim()) || 0;
2344
+ async function hasUnpushedCommits(repo, remote, branch, onHook, prefix, onNativeOutput) {
2345
+ if (await tryGit(["rev-parse", "--verify", "HEAD"], repo) === null) return false;
2346
+ await git(["check-ref-format", "--branch", branch], repo);
2347
+ const source = prefix ? (await git(["subtree", "split", `--prefix=${prefix}`, "--quiet", "HEAD"], repo)).trim() : "HEAD";
2348
+ const ref = `refs/heads/${branch}`;
2349
+ const refs = new Set((await git(["ls-remote", "--", remote, ref, "HEAD"], repo, onHook, onNativeOutput)).trim().split("\n").map((line) => line.split(" ")[1]));
2350
+ const target = refs.has(ref) ? ref : refs.has("HEAD") ? "HEAD" : null;
2351
+ if (!target) return true;
2352
+ await git(["fetch", "--quiet", "--", remote, target], repo, onHook, onNativeOutput);
2353
+ return Boolean((await git(["rev-list", "--max-count=1", `FETCH_HEAD..${source}`], repo)).trim());
2381
2354
  }
2382
2355
  async function findRepos(path) {
2383
2356
  const root = await repoRoot(path);
@@ -2420,6 +2393,21 @@ var commit_message_default = "You are zapdev, a CLI tool that creates commits qu
2420
2393
  // src/prompts/index.ts
2421
2394
  var COMMIT_SYSTEM_PROMPT = commit_message_default.trim();
2422
2395
 
2396
+ // src/lib/commit-message.ts
2397
+ function applyCommitType(systemPrompt, type) {
2398
+ return `${systemPrompt}
2399
+
2400
+ The type MUST be exactly "${type}".`;
2401
+ }
2402
+ var MAX_DIFF_CHARS = 12e3;
2403
+ function truncateDiff(diff, max = MAX_DIFF_CHARS) {
2404
+ return diff.length > max ? diff.slice(0, max) : diff;
2405
+ }
2406
+ function sanitizeCommitMessage(raw) {
2407
+ const firstLine = raw.split("\n", 1)[0] ?? "";
2408
+ return firstLine.replace(/[`"]/g, "").trim();
2409
+ }
2410
+
2423
2411
  // src/lib/llm.ts
2424
2412
  var REQUEST_TIMEOUT_MS = 25e3;
2425
2413
  async function generateCommitMessage(diff, config, type) {
@@ -2459,17 +2447,11 @@ async function generateCommitMessage(diff, config, type) {
2459
2447
  return sanitizeCommitMessage(content);
2460
2448
  }
2461
2449
  function describeRequestError(error) {
2462
- if (errorName(error) === "TimeoutError") {
2450
+ if (error instanceof Error && error.name === "TimeoutError") {
2463
2451
  return `LLM did not answer within ${REQUEST_TIMEOUT_MS / 1e3}s.`;
2464
2452
  }
2465
2453
  return "Could not complete the LLM request. Check URL and endpoint availability.";
2466
2454
  }
2467
- function errorName(error) {
2468
- if (error && typeof error === "object" && "name" in error && typeof error.name === "string") {
2469
- return error.name;
2470
- }
2471
- return null;
2472
- }
2473
2455
  function serverError(body) {
2474
2456
  if (!body || typeof body !== "object" || !("error" in body)) return null;
2475
2457
  const error = body.error;
@@ -2487,6 +2469,28 @@ function messageContent(body) {
2487
2469
  return typeof message.content === "string" ? message.content : null;
2488
2470
  }
2489
2471
 
2472
+ // src/types/commit.ts
2473
+ var COMMIT_TYPES = [
2474
+ "feat",
2475
+ "fix",
2476
+ "chore",
2477
+ "docs",
2478
+ "style",
2479
+ "refactor",
2480
+ "perf",
2481
+ "test",
2482
+ "build",
2483
+ "ci",
2484
+ "revert"
2485
+ ];
2486
+
2487
+ // src/commands/native-output.ts
2488
+ import { styleText as styleText3 } from "node:util";
2489
+ var reportNativeOutput = (phase) => {
2490
+ const symbol2 = styleText3("gray", phase === "start" ? S_BAR_END : S_BAR_START);
2491
+ log.message(phase === "start" ? symbol2 : ["", symbol2], { withGuide: false, spacing: 0 });
2492
+ };
2493
+
2490
2494
  // src/commands/commit.ts
2491
2495
  var commitCommand = defineCommand({
2492
2496
  meta: {
@@ -2507,34 +2511,22 @@ var commitCommand = defineCommand({
2507
2511
  description: "Override $ZD_EFFORT, sent as reasoning_effort."
2508
2512
  },
2509
2513
  type: {
2510
- type: "string",
2514
+ type: "enum",
2515
+ options: [...COMMIT_TYPES],
2516
+ required: false,
2511
2517
  alias: "t",
2512
2518
  description: `Force the Conventional Commits type (${COMMIT_TYPES.join(", ")}).`
2513
2519
  },
2514
2520
  push: {
2515
2521
  type: "boolean",
2516
2522
  alias: "p",
2517
- description: "Push or publish subtrees after committing without asking."
2518
- },
2519
- "publish-subtree": {
2520
- type: "boolean",
2521
- description: "Publish projet-front and projet-back; use --publish-subtree=false to force classic push."
2523
+ description: "Skip push confirmation; still ask for each destination branch."
2522
2524
  },
2523
2525
  staged: {
2524
2526
  type: "boolean",
2525
2527
  alias: "s",
2526
2528
  description: "Commit only changes that are already staged."
2527
2529
  },
2528
- rebase: {
2529
- type: "boolean",
2530
- alias: "r",
2531
- description: "Rebase on the upstream branch if the push is rejected."
2532
- },
2533
- merge: {
2534
- type: "boolean",
2535
- alias: "m",
2536
- description: "Merge the upstream branch if the push is rejected."
2537
- },
2538
2530
  yes: {
2539
2531
  type: "boolean",
2540
2532
  alias: "y",
@@ -2543,22 +2535,8 @@ var commitCommand = defineCommand({
2543
2535
  },
2544
2536
  async run({ args }) {
2545
2537
  const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
2546
- if (args.rebase && args.merge) {
2547
- log.error("Choose either --rebase or --merge, not both.");
2548
- process.exitCode = 1;
2549
- return;
2550
- }
2551
- const syncStrategy = args.rebase ? "rebase" : args.merge ? "merge" : void 0;
2552
- const type = args.type ? normalizeCommitType(args.type) : void 0;
2553
- if (type === null) {
2554
- log.error(
2555
- `Invalid type "${args.type}". Valid types: ${COMMIT_TYPES.join(", ")}.`
2556
- );
2557
- process.exitCode = 1;
2558
- return;
2559
- }
2560
2538
  let config;
2561
- let publishSubtreeMode;
2539
+ let subtrees;
2562
2540
  try {
2563
2541
  resolveHookTimeout();
2564
2542
  config = resolveConfig(process.env, {
@@ -2566,12 +2544,13 @@ var commitCommand = defineCommand({
2566
2544
  model: args.model,
2567
2545
  effort: args.effort
2568
2546
  });
2569
- publishSubtreeMode = await resolvePublishSubtree(process.cwd(), args["publish-subtree"]);
2547
+ subtrees = await resolveSubtrees(process.cwd());
2570
2548
  } catch (error) {
2571
2549
  log.error(errorMessage(error));
2572
2550
  process.exitCode = 1;
2573
2551
  return;
2574
2552
  }
2553
+ const publishSubtreeMode = Object.keys(subtrees).length > 0;
2575
2554
  if (interactive) intro("zapdev commit");
2576
2555
  const repos = await findRepos(process.cwd());
2577
2556
  if (repos.length === 0) {
@@ -2595,17 +2574,18 @@ var commitCommand = defineCommand({
2595
2574
  const { repo } = repository;
2596
2575
  const { branch, hasChanges } = await getRepoStatus(repo);
2597
2576
  repository.label = `${basename(repo)} (${branch})`;
2598
- repository.pendingLabel = hasChanges ? styleText3(["bold", "underline"], repository.label) : repository.label;
2577
+ repository.pendingLabel = hasChanges ? styleText4(["bold", "underline"], repository.label) : repository.label;
2599
2578
  if (!args.staged) await stageAll(repo);
2600
2579
  const diff = await getStagedDiff(repo);
2601
2580
  if (!diff.trim()) return null;
2602
2581
  if (scan) await scanStagedChanges(repo);
2603
- const message = await generateCommitMessage(diff, config, type);
2582
+ const message = await generateCommitMessage(diff, config, args.type);
2604
2583
  if (!message) throw new Error("The model returned an empty message.");
2605
2584
  return { ...repository, message };
2606
2585
  }));
2607
2586
  loader?.stop("Repositories prepared");
2608
2587
  const drafts = [];
2588
+ const unchanged = [];
2609
2589
  for (const [index, result] of results.entries()) {
2610
2590
  if (result.status === "rejected") {
2611
2591
  log.error(`${repositories[index].pendingLabel}: ${errorMessage(result.reason)}`);
@@ -2614,34 +2594,32 @@ var commitCommand = defineCommand({
2614
2594
  drafts.push(result.value);
2615
2595
  } else {
2616
2596
  log.info(`${repositories[index].pendingLabel}: nothing to commit.`);
2597
+ unchanged.push(repositories[index]);
2617
2598
  }
2618
2599
  }
2619
- if (drafts.length === 0) {
2620
- if (interactive) outro("No commits prepared.");
2621
- return;
2622
- }
2623
- const selected = await reviewMessages(drafts, interactive && !args.yes);
2600
+ const selected = drafts.length ? await reviewMessages(drafts, interactive && !args.yes) : [];
2624
2601
  if (!selected) {
2625
2602
  cancel("Cancelled (changes left staged).");
2626
2603
  return;
2627
2604
  }
2628
- const committed = [];
2605
+ const toSend = [];
2629
2606
  for (const draft of selected) {
2630
2607
  log.info(`${draft.label}: committing`);
2631
2608
  try {
2632
- await commit(draft.repo, draft.message, reportHooks(draft.label));
2633
- committed.push(draft);
2609
+ await commit(draft.repo, draft.message, reportHooks(draft.label), reportNativeOutput);
2610
+ toSend.push(draft);
2634
2611
  log.success(`${draft.label}: committed ${draft.message}`);
2635
2612
  } catch (error) {
2636
2613
  reportGitFailure(`${draft.pendingLabel}: commit failed`, error);
2637
2614
  process.exitCode = 1;
2638
2615
  }
2639
2616
  }
2640
- if (committed.length === 0) return;
2617
+ toSend.push(...unchanged);
2618
+ if (toSend.length === 0) return;
2641
2619
  let shouldPush = Boolean(args.push);
2642
2620
  if (!shouldPush && interactive) {
2643
2621
  const answer = await confirm({
2644
- message: `${publishSubtreeMode ? "Publish subtrees in" : "Push"} ${committed.map(({ label }) => label).join(", ")}?`,
2622
+ message: `${publishSubtreeMode ? "Publish subtrees in" : "Push"} ${toSend.map(({ label }) => label).join(", ")}?`,
2645
2623
  initialValue: false
2646
2624
  });
2647
2625
  if (isCancel(answer)) {
@@ -2651,33 +2629,61 @@ var commitCommand = defineCommand({
2651
2629
  shouldPush = answer;
2652
2630
  }
2653
2631
  if (shouldPush) {
2654
- for (const repository of committed) {
2632
+ if (!interactive) {
2633
+ log.error("Pushing requires a terminal to choose each destination branch. Commits remain local.");
2634
+ process.exitCode = 1;
2635
+ return;
2636
+ }
2637
+ let previousBranchInput = "";
2638
+ for (const { repo, label } of toSend) {
2655
2639
  try {
2656
- const pushed = publishSubtreeMode ? await publishSubtrees(repository) : await pushOptimistic(
2657
- repository,
2658
- interactive && !args.yes,
2659
- syncStrategy
2660
- );
2661
- if (!pushed) process.exitCode = 1;
2640
+ const current = await currentBranch(repo);
2641
+ const destinations = publishSubtreeMode ? Object.entries(subtrees) : [["", await getPushRemote(repo, current)]];
2642
+ for (const [prefix, remote] of destinations) {
2643
+ if (!remote) {
2644
+ log.warn(`${label}: no remote or ambiguous remote choice. Skipping push; commit remains local.`);
2645
+ continue;
2646
+ }
2647
+ log.info(`${label}: checking unpublished commits${prefix ? ` in ${prefix}` : ""} (${remote}/${previousBranchInput || current}).`);
2648
+ if (!await hasUnpushedCommits(repo, remote, previousBranchInput || current, reportHooks(label), prefix || void 0, reportNativeOutput)) {
2649
+ log.info(`${label}: no unpushed commits${prefix ? ` in ${prefix}` : ""}. Skipping.`);
2650
+ continue;
2651
+ }
2652
+ const answer = await text({
2653
+ message: `${label}: branch to push ${publishSubtreeMode ? `${prefix} to ` : "to "}${remote}`,
2654
+ initialValue: previousBranchInput
2655
+ });
2656
+ if (isCancel(answer)) {
2657
+ outro("Sending cancelled. Remaining commits stay local.");
2658
+ return;
2659
+ }
2660
+ previousBranchInput = answer.trim();
2661
+ const branch = previousBranchInput || current;
2662
+ log.info(`${label}: pushing ${publishSubtreeMode ? `${prefix} \u2192 ` : ""}${remote}/${branch}`);
2663
+ await push(repo, remote, branch, reportHooks(label), prefix || void 0, reportNativeOutput);
2664
+ log.success(`${label}: pushed to ${remote}/${branch}`);
2665
+ }
2662
2666
  } catch (error) {
2663
- reportGitFailure(`${repository.label}: ${publishSubtreeMode ? "publication" : "push"} failed`, error);
2667
+ reportGitFailure(`${label}: ${publishSubtreeMode ? "publication" : "push"} failed`, error);
2664
2668
  process.exitCode = 1;
2669
+ outro("Sending stopped after a Git failure.");
2670
+ return;
2665
2671
  }
2666
2672
  }
2667
2673
  }
2668
2674
  if (interactive) outro(process.exitCode ? "Finished with errors." : "Done.");
2669
2675
  }
2670
2676
  });
2671
- function reportGitFailure(message, error, level = "error") {
2677
+ function reportGitFailure(message, error) {
2672
2678
  if (error instanceof GitOutputError && error.hookFailureReported) return;
2673
- log[level](error instanceof GitOutputError ? message : `${message}: ${errorMessage(error)}`);
2679
+ log.error(error instanceof GitOutputError ? message : `${message}: ${errorMessage(error)}`);
2674
2680
  }
2675
2681
  function reportHooks(label) {
2676
- const options = { secondarySymbol: "" };
2682
+ const options = { secondarySymbol: "", withGuide: false };
2677
2683
  return (event) => {
2678
2684
  const hook = `${label}: ${event.name}`;
2679
2685
  if (event.phase === "start") {
2680
- log.step(styleText3("bold", hook), options);
2686
+ log.step(styleText4("bold", hook), options);
2681
2687
  } else {
2682
2688
  if (event.exitCode === 0) log.success(`${hook}: completed`, options);
2683
2689
  else log.error(`${hook}: failed (exit ${event.exitCode})`, options);
@@ -2716,91 +2722,114 @@ async function reviewMessages(drafts, canPrompt) {
2716
2722
  action.draft.message = edited.trim();
2717
2723
  }
2718
2724
  }
2719
- async function syncWithUpstream({ repo, label }, strategy) {
2720
- const actionLabel = strategy === "rebase" ? "Rebase" : "Merge";
2721
- log.info(`${label}: pulling --${strategy === "rebase" ? "rebase" : "no-rebase"}`);
2722
- try {
2723
- await (strategy === "rebase" ? pullRebase : pullMerge)(repo, reportHooks(label));
2724
- log.success(`${label}: ${strategy === "rebase" ? "rebased on upstream" : "merged upstream"}`);
2725
- return true;
2726
- } catch (error) {
2727
- reportGitFailure(`${label}: ${actionLabel} failed (resolve conflicts, then push)`, error);
2728
- return false;
2729
- }
2725
+
2726
+ // src/lib/subtree-init.ts
2727
+ import { lstat, mkdir, writeFile } from "node:fs/promises";
2728
+ import { join as join3, resolve } from "node:path";
2729
+ function parseSubtreeSources(entries) {
2730
+ if (entries.length === 0) throw new Error("Provide at least one subtree as name=url.");
2731
+ const names = /* @__PURE__ */ new Set();
2732
+ return entries.map((entry) => {
2733
+ const separator = entry.indexOf("=");
2734
+ const name = entry.slice(0, separator);
2735
+ const url = entry.slice(separator + 1);
2736
+ if (separator < 1 || !/^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/.test(name) || name === "zapdev.json" || name.includes("..") || name.endsWith(".") || name.endsWith(".lock") || !url.trim() || url.startsWith("-") || url.includes("\0")) {
2737
+ throw new Error(`Invalid source "${entry}": expected name=url with a simple folder and remote name.`);
2738
+ }
2739
+ if (names.has(name)) throw new Error(`Duplicate subtree name "${name}".`);
2740
+ names.add(name);
2741
+ return { name, url };
2742
+ });
2730
2743
  }
2731
- async function publishSubtrees({ repo, label }) {
2732
- const branch = await currentBranch(repo);
2733
- for (const remote of ["front", "back"]) {
2734
- log.info(`${label}: publishing projet-${remote} \u2192 ${remote}/${branch}`);
2735
- try {
2736
- const published = await publishSubtree(repo, remote, branch, reportHooks(label));
2737
- if (published) log.success(`${label}: published projet-${remote} \u2192 ${remote}/${branch}`);
2738
- else log.info(`${label}: no changes in projet-${remote}. Skipping.`);
2739
- } catch (error) {
2740
- reportGitFailure(`${label}: publication to ${remote}/${branch} failed`, error);
2741
- return false;
2742
- }
2744
+ async function initializeSubtrees(directory, sources, options = {}) {
2745
+ if (!directory.trim()) throw new Error("Directory cannot be empty.");
2746
+ if (options.origin !== void 0 && (!options.origin.trim() || options.origin.startsWith("-") || options.origin.includes("\0"))) {
2747
+ throw new Error("--origin must be a nonempty repository URL.");
2748
+ }
2749
+ const repo = resolve(directory);
2750
+ await mkdir(repo, { recursive: true });
2751
+ if (!await exists(join3(repo, ".git"))) await git(["init", "--quiet", "-b", "main"], repo);
2752
+ if ((await getRepoStatus(repo)).hasChanges) {
2753
+ throw new Error("Working tree must be clean before adding subtrees (including untracked files).");
2754
+ }
2755
+ const configPath = join3(repo, "zapdev.json");
2756
+ if (await exists(configPath) && (await lstat(configPath)).isSymbolicLink()) {
2757
+ throw new Error("Refusing to overwrite zapdev.json through a symbolic link.");
2758
+ }
2759
+ const settings2 = await readProjectConfig(repo);
2760
+ const mapping = new Map(Object.entries(settings2.subtrees ?? {}));
2761
+ const remotes = /* @__PURE__ */ new Map();
2762
+ if (options.origin !== void 0) remotes.set("origin", options.origin);
2763
+ for (const { name, url } of sources) {
2764
+ if (await exists(join3(repo, name))) throw new Error(`"${name}" already exists.`);
2765
+ const mappedRemote = mapping.get(name);
2766
+ if (mappedRemote && mappedRemote !== name) throw new Error(`"${name}" is already mapped to remote "${mappedRemote}".`);
2767
+ const plannedURL = remotes.get(name);
2768
+ if (plannedURL && plannedURL !== url) throw new Error(`Remote "${name}" was requested with another URL: ${plannedURL}`);
2769
+ remotes.set(name, url);
2770
+ }
2771
+ const missingRemotes = [];
2772
+ for (const [name, url] of remotes) {
2773
+ const existingURL = await tryGit(["remote", "get-url", name], repo);
2774
+ if (existingURL === null) missingRemotes.push([name, url]);
2775
+ else if (existingURL.trim() !== url) throw new Error(`Remote "${name}" already exists with another URL: ${existingURL.trim()}`);
2776
+ }
2777
+ const imports = [];
2778
+ for (const source of sources) {
2779
+ options.onProgress?.(`Detecting default branch for ${source.name}`);
2780
+ const refs = await git(["ls-remote", "--symref", "--", source.url, "HEAD"], repo, void 0, options.onNativeOutput);
2781
+ const branch = /^ref: refs\/heads\/(.+)\tHEAD$/m.exec(refs)?.[1];
2782
+ if (!branch) options.onWarning?.(`Could not detect default branch for ${source.name}; using main.`);
2783
+ imports.push({ ...source, branch: branch ?? "main" });
2784
+ }
2785
+ const initial = await tryGit(["rev-parse", "--verify", "HEAD"], repo) === null;
2786
+ for (const { name } of sources) mapping.set(name, name);
2787
+ await writeFile(configPath, `${JSON.stringify({ ...settings2, subtrees: Object.fromEntries(mapping) }, null, 2)}
2788
+ `);
2789
+ await git(["add", "--", "zapdev.json"], repo);
2790
+ if (initial || (await getStagedDiff(repo)).trim()) {
2791
+ await commit(repo, initial ? "chore: init" : "chore: configure subtrees", void 0, options.onNativeOutput);
2743
2792
  }
2744
- return true;
2745
- }
2746
- async function pushOptimistic(repository, canPrompt, strategy) {
2747
- const { repo, label } = repository;
2748
- const [upstream, branch] = await Promise.all([hasUpstream(repo), currentBranch(repo)]);
2749
- const doPush = (onHook) => upstream ? push(repo, onHook) : pushSetUpstream(repo, branch, onHook);
2750
- const first = await tryPush(repository, doPush);
2751
- if (first.ok) return true;
2752
- if (upstream && await isBehind(repository)) {
2753
- const syncStrategy = strategy ?? await chooseSyncStrategy(repository, canPrompt);
2754
- if (!syncStrategy || !await syncWithUpstream(repository, syncStrategy)) return false;
2755
- const retry = await tryPush(repository, doPush);
2756
- if (retry.ok) return true;
2757
- reportGitFailure(`${label}: push failed`, retry.error);
2758
- return false;
2793
+ for (const [name, url] of missingRemotes) await git(["remote", "add", name, url], repo);
2794
+ for (const { name, branch } of imports) {
2795
+ options.onProgress?.(`Adding ${name} (${branch})`);
2796
+ await git(["subtree", "add", `--prefix=${name}`, name, branch, "--squash"], repo, void 0, options.onNativeOutput);
2759
2797
  }
2760
- reportGitFailure(`${label}: push failed`, first.error);
2761
- return false;
2762
2798
  }
2763
- async function chooseSyncStrategy({ label }, interactive) {
2764
- if (!interactive) {
2765
- log.error(`${label}: branch is behind upstream. Re-run with --rebase or --merge.`);
2766
- return null;
2767
- }
2768
- const action = await select({
2769
- message: `${label}: branch is behind upstream. How should zapdev sync it?`,
2770
- options: [
2771
- { value: "rebase", label: "Rebase" },
2772
- { value: "merge", label: "Merge" },
2773
- { value: "quit", label: "Quit" }
2774
- ]
2799
+ async function exists(path) {
2800
+ return lstat(path).then(() => true, (error) => {
2801
+ if (error.code === "ENOENT") return false;
2802
+ throw error;
2775
2803
  });
2776
- if (isCancel(action) || action === "quit") {
2777
- log.warn("Push cancelled. Commit remains local.");
2778
- return null;
2779
- }
2780
- return action;
2781
2804
  }
2782
- async function tryPush({ label }, doPush) {
2783
- log.info(`${label}: pushing`);
2784
- try {
2785
- await doPush(reportHooks(label));
2786
- log.success(`${label}: pushed`);
2787
- return { ok: true };
2788
- } catch (error) {
2789
- return { ok: false, error };
2790
- }
2791
- }
2792
- async function isBehind({ repo, label }) {
2793
- log.info(`${label}: checking upstream`);
2794
- try {
2795
- await fetchRemote(repo, reportHooks(label));
2796
- const behind = await behindCount(repo);
2797
- log.info(`${label}: ${behind > 0 ? `Behind upstream by ${behind} commit${behind > 1 ? "s" : ""}` : "Up to date with upstream"}`);
2798
- return behind > 0;
2799
- } catch (error) {
2800
- reportGitFailure(`${label}: could not check upstream`, error, "warn");
2801
- return false;
2805
+
2806
+ // src/commands/subtree-init.ts
2807
+ var subtreeInitCommand = defineCommand({
2808
+ meta: { name: "subtree-init", description: "Initialize a repository with squashed subtrees and their named remotes." },
2809
+ args: {
2810
+ directory: { type: "positional", required: true, description: "Repository directory to create or extend." },
2811
+ subtree: { type: "positional", required: true, description: "name=url; add more positional sources for additional subtrees." },
2812
+ origin: { type: "string", description: "Optional URL for the parent repository's origin remote." }
2813
+ },
2814
+ async run({ args }) {
2815
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
2816
+ try {
2817
+ const sources = parseSubtreeSources(args._.slice(1));
2818
+ if (interactive) intro("zapdev subtree-init");
2819
+ await initializeSubtrees(args.directory, sources, {
2820
+ origin: args.origin,
2821
+ onProgress: log.info,
2822
+ onWarning: log.warn,
2823
+ onNativeOutput: reportNativeOutput
2824
+ });
2825
+ if (interactive) outro("Subtrees ready.");
2826
+ else log.info("Subtrees ready.");
2827
+ } catch (error) {
2828
+ log.error(error instanceof GitOutputError ? "Subtree initialization failed." : errorMessage(error));
2829
+ process.exitCode = 1;
2830
+ }
2802
2831
  }
2803
- }
2832
+ });
2804
2833
 
2805
2834
  // src/cli.ts
2806
2835
  var main = defineCommand({
@@ -2810,7 +2839,7 @@ var main = defineCommand({
2810
2839
  description: "Fast, precise git chores from your terminal."
2811
2840
  },
2812
2841
  args: commitCommand.args,
2813
- subCommands: { commit: commitCommand },
2842
+ subCommands: { commit: commitCommand, "subtree-init": subtreeInitCommand },
2814
2843
  default: "commit"
2815
2844
  });
2816
2845
  await runMain(main);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zapdev",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "Fast, performant and lightweight git chores and dev tools using your terminal and an OpenAI-compatible LLM endpoint.",
5
5
  "type": "module",
6
6
  "bin": {