zapdev 1.7.0 → 1.9.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 +49 -23
  2. package/dist/cli.js +195 -160
  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
@@ -125,58 +125,84 @@ zapdev commit
125
125
  | `--model <model>` | Override the model |
126
126
  | `--effort <effort>` | Override the reasoning effort |
127
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` |
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
- | `-y, --yes` | Skip prompts and commit all prepared repositories |
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
135
+ zapdev commit -y # commit automatically, then ask before pushing
136
+ zapdev commit -yp # commit automatically, then enter each push destination
139
137
  ```
140
138
 
141
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.
142
140
 
143
- 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.
144
142
 
145
- In a terminal, Git and its hooks display live logs and support native prompts, even with `--yes` (which only skips zapdev 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`.
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`.
146
144
 
147
- 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.
148
146
 
149
- 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 the remote branch is missing or local commits remain unpublished, zapdev asks for a destination; otherwise it skips the send. 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.
150
150
 
151
151
  #### Subtree publication
152
152
 
153
153
  Place an optional `zapdev.json` in the directory where you launch zapdev:
154
154
 
155
155
  ```json
156
- { "isSubtree": true }
156
+ {
157
+ "subtrees": {
158
+ "projet-front": "front",
159
+ "projet-back": "back"
160
+ }
161
+ }
157
162
  ```
158
163
 
159
164
  | `zapdev.json` option | Default | Effect |
160
165
  | ------------------- | ------- | ------ |
161
- | `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. |
162
167
 
163
- - **Scope:** only the launch directory's file is read, not parents or child repositories. Its setting applies to all successfully committed repositories.
164
- - **Priority:** explicit `--publish-subtree` / `--publish-subtree=false` overrides the file. Without a flag, invalid configuration stops the command before staging.
165
- - **Destinations:** `projet-front` → remote `front`, `projet-back` → remote `back`, both using the current branch name. `main` and detached HEAD are refused.
166
- - **Publication:** fetch each remote's `main`, split committed contents, skip contents identical to `main`, then push without force. Uncommitted changes are excluded.
167
- - **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.
168
173
 
169
- Interactive runs still ask for confirmation; `--push` publishes without asking. `--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.
170
175
 
171
176
  ```bash
172
- zapdev commit --publish-subtree # ask to publish after committing
173
- zapdev commit --publish-subtree -yp # publish without zapdev prompts
174
- 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
175
179
  ```
176
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
+
177
203
  ### Zed IDE
178
204
 
179
- 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.
180
206
 
181
207
  Add the following tasks to `.zed/tasks.json`:
182
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.7.0",
557
+ version: "1.9.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: {
@@ -1816,7 +1816,7 @@ function sanitizeCommitMessage(raw) {
1816
1816
 
1817
1817
  // src/lib/config.ts
1818
1818
  import { readFile } from "node:fs/promises";
1819
- import { join } from "node:path";
1819
+ import { isAbsolute, join } from "node:path";
1820
1820
 
1821
1821
  // src/lib/errors.ts
1822
1822
  var GitOutputError = class extends Error {
@@ -1831,13 +1831,12 @@ function errorMessage(error) {
1831
1831
  }
1832
1832
 
1833
1833
  // src/lib/config.ts
1834
- async function resolvePublishSubtree(directory, override) {
1835
- if (override !== void 0) return override;
1834
+ async function readProjectConfig(directory) {
1836
1835
  let content;
1837
1836
  try {
1838
1837
  content = await readFile(join(directory, "zapdev.json"), "utf8");
1839
1838
  } catch (error) {
1840
- if (error.code === "ENOENT") return false;
1839
+ if (error.code === "ENOENT") return {};
1841
1840
  throw new Error(`Unable to read zapdev.json: ${errorMessage(error)}`, { cause: error });
1842
1841
  }
1843
1842
  let config;
@@ -1849,11 +1848,27 @@ async function resolvePublishSubtree(directory, override) {
1849
1848
  if (typeof config !== "object" || config === null || Array.isArray(config)) {
1850
1849
  throw new Error("zapdev.json must contain an object.");
1851
1850
  }
1852
- const isSubtree = config.isSubtree;
1853
- if (isSubtree !== void 0 && typeof isSubtree !== "boolean") {
1854
- throw new Error("zapdev.json isSubtree must be a boolean.");
1851
+ const settings2 = config;
1852
+ if (settings2.isSubtree !== void 0) {
1853
+ throw new Error("zapdev.json isSubtree is no longer supported; use a subtrees folder-to-remote mapping.");
1854
+ }
1855
+ const subtrees = settings2.subtrees;
1856
+ if (subtrees === void 0) return settings2;
1857
+ if (typeof subtrees !== "object" || subtrees === null || Array.isArray(subtrees)) {
1858
+ throw new Error("zapdev.json subtrees must be a folder-to-remote object.");
1859
+ }
1860
+ for (const [prefix, remote] of Object.entries(subtrees)) {
1861
+ if (prefix !== prefix.trim() || isAbsolute(prefix) || prefix.includes("\\") || prefix.split("/").some((part) => ["", ".", "..", ".git"].includes(part))) {
1862
+ throw new Error(`zapdev.json subtree folder "${prefix}" must be a repository-relative directory.`);
1863
+ }
1864
+ if (typeof remote !== "string" || !remote || /\s/.test(remote) || remote.startsWith("-")) {
1865
+ throw new Error(`zapdev.json subtree "${prefix}" must map to a nonempty Git remote name.`);
1866
+ }
1855
1867
  }
1856
- return isSubtree === true;
1868
+ return settings2;
1869
+ }
1870
+ async function resolveSubtrees(directory) {
1871
+ return (await readProjectConfig(directory)).subtrees ?? {};
1857
1872
  }
1858
1873
  function resolveHookTimeout(env = process.env) {
1859
1874
  const timeout = Number(env.ZD_HOOK_TIMEOUT ?? 60) * 1e3;
@@ -2252,10 +2267,9 @@ function createHookReporter(onHook, onTimeout, timeout) {
2252
2267
  }
2253
2268
 
2254
2269
  // src/lib/git.ts
2255
- var UPSTREAM_REF = "@{upstream}";
2256
2270
  async function git(args, cwd, onHook) {
2257
2271
  const timeout = resolveHookTimeout();
2258
- const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY && ["commit", "push", "pull", "fetch"].includes(args[0] ?? ""));
2272
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY && ["commit", "push", "subtree", "fetch"].includes(args[0] ?? "") && !(args[0] === "subtree" && args[1] === "split"));
2259
2273
  const terminalState = interactive && process.platform !== "win32" ? execFileSync("stty", ["-g"], { stdio: ["inherit", "pipe", "ignore"], encoding: "utf8" }).trim() : void 0;
2260
2274
  const child = R2("git", args, {
2261
2275
  nodeOptions: {
@@ -2346,38 +2360,28 @@ async function getRepoStatus(repo) {
2346
2360
  hasChanges: lines.some((line) => !line.startsWith("#"))
2347
2361
  };
2348
2362
  }
2349
- async function hasUpstream(repo) {
2350
- const result = await tryGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", UPSTREAM_REF], repo);
2351
- return result !== null;
2352
- }
2353
- async function push(repo, onHook) {
2354
- await git(["push", "--quiet"], repo, onHook);
2363
+ async function getPushRemote(repo, branch) {
2364
+ const remotes = (await git(["remote"], repo)).trim().split("\n").filter(Boolean);
2365
+ const upstream = (await git(["for-each-ref", "--format=%(upstream:remotename)", `refs/heads/${branch}`], repo)).trim();
2366
+ if (upstream) return remotes.includes(upstream) ? upstream : null;
2367
+ return remotes.length === 1 ? remotes[0] : null;
2355
2368
  }
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.");
2369
+ async function push(repo, remote, branch, onHook) {
2361
2370
  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
+ await git(["push", "--", remote, `HEAD:refs/heads/${branch}`], repo, onHook);
2371
2372
  }
2372
- async function pullMerge(repo, onHook) {
2373
- await git(["pull", "--quiet", "--no-rebase", "--no-edit"], repo, onHook);
2374
- }
2375
- async function fetchRemote(repo, onHook) {
2376
- await git(["fetch", "--quiet"], repo, onHook);
2373
+ async function hasUnpushedCommits(repo, remote, branch, onHook, prefix) {
2374
+ if (await tryGit(["rev-parse", "--verify", "HEAD"], repo) === null) return false;
2375
+ await git(["check-ref-format", "--branch", branch], repo);
2376
+ const source = prefix ? (await git(["subtree", "split", `--prefix=${prefix}`, "--quiet", "HEAD"], repo)).trim() : "HEAD";
2377
+ const ref = `refs/heads/${branch}`;
2378
+ if (!(await git(["ls-remote", "--heads", "--", remote, ref], repo)).trim()) return true;
2379
+ await git(["fetch", "--quiet", "--", remote, ref], repo, onHook);
2380
+ return Boolean((await git(["rev-list", "--max-count=1", `FETCH_HEAD..${source}`], repo)).trim());
2377
2381
  }
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;
2382
+ async function publishSubtree(repo, prefix, remote, branch, onHook) {
2383
+ await git(["check-ref-format", "--branch", branch], repo);
2384
+ await git(["subtree", "push", `--prefix=${prefix}`, remote, branch], repo, onHook);
2381
2385
  }
2382
2386
  async function findRepos(path) {
2383
2387
  const root = await repoRoot(path);
@@ -2514,41 +2518,21 @@ var commitCommand = defineCommand({
2514
2518
  push: {
2515
2519
  type: "boolean",
2516
2520
  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."
2521
+ description: "Skip push confirmation; still ask for each destination branch."
2522
2522
  },
2523
2523
  staged: {
2524
2524
  type: "boolean",
2525
2525
  alias: "s",
2526
2526
  description: "Commit only changes that are already staged."
2527
2527
  },
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
2528
  yes: {
2539
2529
  type: "boolean",
2540
2530
  alias: "y",
2541
- description: "Skip prompts and commit directly."
2531
+ description: "Skip commit review; still confirm push unless --push is set."
2542
2532
  }
2543
2533
  },
2544
2534
  async run({ args }) {
2545
2535
  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
2536
  const type = args.type ? normalizeCommitType(args.type) : void 0;
2553
2537
  if (type === null) {
2554
2538
  log.error(
@@ -2558,7 +2542,7 @@ var commitCommand = defineCommand({
2558
2542
  return;
2559
2543
  }
2560
2544
  let config;
2561
- let publishSubtreeMode;
2545
+ let subtrees;
2562
2546
  try {
2563
2547
  resolveHookTimeout();
2564
2548
  config = resolveConfig(process.env, {
@@ -2566,12 +2550,13 @@ var commitCommand = defineCommand({
2566
2550
  model: args.model,
2567
2551
  effort: args.effort
2568
2552
  });
2569
- publishSubtreeMode = await resolvePublishSubtree(process.cwd(), args["publish-subtree"]);
2553
+ subtrees = await resolveSubtrees(process.cwd());
2570
2554
  } catch (error) {
2571
2555
  log.error(errorMessage(error));
2572
2556
  process.exitCode = 1;
2573
2557
  return;
2574
2558
  }
2559
+ const publishSubtreeMode = Object.keys(subtrees).length > 0;
2575
2560
  if (interactive) intro("zapdev commit");
2576
2561
  const repos = await findRepos(process.cwd());
2577
2562
  if (repos.length === 0) {
@@ -2606,6 +2591,7 @@ var commitCommand = defineCommand({
2606
2591
  }));
2607
2592
  loader?.stop("Repositories prepared");
2608
2593
  const drafts = [];
2594
+ const unchanged = [];
2609
2595
  for (const [index, result] of results.entries()) {
2610
2596
  if (result.status === "rejected") {
2611
2597
  log.error(`${repositories[index].pendingLabel}: ${errorMessage(result.reason)}`);
@@ -2614,13 +2600,10 @@ var commitCommand = defineCommand({
2614
2600
  drafts.push(result.value);
2615
2601
  } else {
2616
2602
  log.info(`${repositories[index].pendingLabel}: nothing to commit.`);
2603
+ unchanged.push(repositories[index]);
2617
2604
  }
2618
2605
  }
2619
- if (drafts.length === 0) {
2620
- if (interactive) outro("No commits prepared.");
2621
- return;
2622
- }
2623
- const selected = await reviewMessages(drafts, interactive && !args.yes);
2606
+ const selected = drafts.length ? await reviewMessages(drafts, interactive && !args.yes) : [];
2624
2607
  if (!selected) {
2625
2608
  cancel("Cancelled (changes left staged).");
2626
2609
  return;
@@ -2637,11 +2620,12 @@ var commitCommand = defineCommand({
2637
2620
  process.exitCode = 1;
2638
2621
  }
2639
2622
  }
2640
- if (committed.length === 0) return;
2623
+ const toSend = [...committed, ...unchanged];
2624
+ if (toSend.length === 0) return;
2641
2625
  let shouldPush = Boolean(args.push);
2642
- if (!shouldPush && interactive && !args.yes) {
2626
+ if (!shouldPush && interactive) {
2643
2627
  const answer = await confirm({
2644
- message: `${publishSubtreeMode ? "Publish subtrees in" : "Push"} ${committed.map(({ label }) => label).join(", ")}?`,
2628
+ message: `${publishSubtreeMode ? "Publish subtrees in" : "Push"} ${toSend.map(({ label }) => label).join(", ")}?`,
2645
2629
  initialValue: false
2646
2630
  });
2647
2631
  if (isCancel(answer)) {
@@ -2651,26 +2635,55 @@ var commitCommand = defineCommand({
2651
2635
  shouldPush = answer;
2652
2636
  }
2653
2637
  if (shouldPush) {
2654
- for (const repository of committed) {
2638
+ if (!interactive) {
2639
+ log.error("Pushing requires a terminal to choose each destination branch. Commits remain local.");
2640
+ process.exitCode = 1;
2641
+ return;
2642
+ }
2643
+ let previousBranchInput = "";
2644
+ for (const repository of toSend) {
2645
+ const { repo, label } = repository;
2655
2646
  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;
2647
+ const current = await currentBranch(repo);
2648
+ const destinations = publishSubtreeMode ? Object.entries(subtrees) : [["", await getPushRemote(repo, current)]];
2649
+ for (const [prefix, remote] of destinations) {
2650
+ if (!remote) {
2651
+ log.warn(`${label}: no remote or ambiguous remote choice. Skipping push; commit remains local.`);
2652
+ continue;
2653
+ }
2654
+ if (!await hasUnpushedCommits(repo, remote, previousBranchInput || current, reportHooks(label), prefix || void 0)) {
2655
+ log.info(`${label}: no unpushed commits${prefix ? ` in ${prefix}` : ""}. Skipping.`);
2656
+ continue;
2657
+ }
2658
+ const answer = await text({
2659
+ message: `${label}: branch to push ${publishSubtreeMode ? `${prefix} to ` : "to "}${remote}`,
2660
+ initialValue: previousBranchInput
2661
+ });
2662
+ if (isCancel(answer)) {
2663
+ outro("Sending cancelled. Remaining commits stay local.");
2664
+ return;
2665
+ }
2666
+ previousBranchInput = answer.trim();
2667
+ const branch = previousBranchInput || current;
2668
+ log.info(`${label}: pushing ${publishSubtreeMode ? `${prefix} \u2192 ` : ""}${remote}/${branch}`);
2669
+ if (publishSubtreeMode) await publishSubtree(repo, prefix, remote, branch, reportHooks(label));
2670
+ else await push(repo, remote, branch, reportHooks(label));
2671
+ log.success(`${label}: pushed to ${remote}/${branch}`);
2672
+ }
2662
2673
  } catch (error) {
2663
- reportGitFailure(`${repository.label}: ${publishSubtreeMode ? "publication" : "push"} failed`, error);
2674
+ reportGitFailure(`${label}: ${publishSubtreeMode ? "publication" : "push"} failed`, error);
2664
2675
  process.exitCode = 1;
2676
+ outro("Sending stopped after a Git failure.");
2677
+ return;
2665
2678
  }
2666
2679
  }
2667
2680
  }
2668
2681
  if (interactive) outro(process.exitCode ? "Finished with errors." : "Done.");
2669
2682
  }
2670
2683
  });
2671
- function reportGitFailure(message, error, level = "error") {
2684
+ function reportGitFailure(message, error) {
2672
2685
  if (error instanceof GitOutputError && error.hookFailureReported) return;
2673
- log[level](error instanceof GitOutputError ? message : `${message}: ${errorMessage(error)}`);
2686
+ log.error(error instanceof GitOutputError ? message : `${message}: ${errorMessage(error)}`);
2674
2687
  }
2675
2688
  function reportHooks(label) {
2676
2689
  const options = { secondarySymbol: "" };
@@ -2716,91 +2729,113 @@ async function reviewMessages(drafts, canPrompt) {
2716
2729
  action.draft.message = edited.trim();
2717
2730
  }
2718
2731
  }
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
- }
2732
+
2733
+ // src/lib/subtree-init.ts
2734
+ import { lstat, mkdir, writeFile } from "node:fs/promises";
2735
+ import { join as join3, resolve } from "node:path";
2736
+ function parseSubtreeSources(entries) {
2737
+ if (entries.length === 0) throw new Error("Provide at least one subtree as name=url.");
2738
+ const names = /* @__PURE__ */ new Set();
2739
+ return entries.map((entry) => {
2740
+ const separator = entry.indexOf("=");
2741
+ const name = entry.slice(0, separator);
2742
+ const url = entry.slice(separator + 1);
2743
+ 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")) {
2744
+ throw new Error(`Invalid source "${entry}": expected name=url with a simple folder and remote name.`);
2745
+ }
2746
+ if (names.has(name)) throw new Error(`Duplicate subtree name "${name}".`);
2747
+ names.add(name);
2748
+ return { name, url };
2749
+ });
2730
2750
  }
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
- }
2751
+ async function initializeSubtrees(directory, sources, options = {}) {
2752
+ if (!directory.trim()) throw new Error("Directory cannot be empty.");
2753
+ if (options.origin !== void 0 && (!options.origin.trim() || options.origin.startsWith("-") || options.origin.includes("\0"))) {
2754
+ throw new Error("--origin must be a nonempty repository URL.");
2755
+ }
2756
+ const repo = resolve(directory);
2757
+ await mkdir(repo, { recursive: true });
2758
+ if (!await exists(join3(repo, ".git"))) await git(["init", "--quiet", "-b", "main"], repo);
2759
+ if ((await getRepoStatus(repo)).hasChanges) {
2760
+ throw new Error("Working tree must be clean before adding subtrees (including untracked files).");
2761
+ }
2762
+ const configPath = join3(repo, "zapdev.json");
2763
+ if (await exists(configPath) && (await lstat(configPath)).isSymbolicLink()) {
2764
+ throw new Error("Refusing to overwrite zapdev.json through a symbolic link.");
2765
+ }
2766
+ const settings2 = await readProjectConfig(repo);
2767
+ const mapping = new Map(Object.entries(settings2.subtrees ?? {}));
2768
+ const remotes = /* @__PURE__ */ new Map();
2769
+ if (options.origin !== void 0) remotes.set("origin", options.origin);
2770
+ for (const { name, url } of sources) {
2771
+ if (await exists(join3(repo, name))) throw new Error(`"${name}" already exists.`);
2772
+ const mappedRemote = mapping.get(name);
2773
+ if (mappedRemote && mappedRemote !== name) throw new Error(`"${name}" is already mapped to remote "${mappedRemote}".`);
2774
+ const plannedURL = remotes.get(name);
2775
+ if (plannedURL && plannedURL !== url) throw new Error(`Remote "${name}" was requested with another URL: ${plannedURL}`);
2776
+ remotes.set(name, url);
2777
+ }
2778
+ const missingRemotes = [];
2779
+ for (const [name, url] of remotes) {
2780
+ const existingURL = await tryGit(["remote", "get-url", name], repo);
2781
+ if (existingURL === null) missingRemotes.push([name, url]);
2782
+ else if (existingURL.trim() !== url) throw new Error(`Remote "${name}" already exists with another URL: ${existingURL.trim()}`);
2783
+ }
2784
+ const imports = [];
2785
+ for (const source of sources) {
2786
+ options.onProgress?.(`Detecting default branch for ${source.name}`);
2787
+ const refs = await git(["ls-remote", "--symref", "--", source.url, "HEAD"], repo);
2788
+ const branch = /^ref: refs\/heads\/(.+)\tHEAD$/m.exec(refs)?.[1];
2789
+ if (!branch) options.onWarning?.(`Could not detect default branch for ${source.name}; using main.`);
2790
+ imports.push({ ...source, branch: branch ?? "main" });
2791
+ }
2792
+ const initial = await tryGit(["rev-parse", "--verify", "HEAD"], repo) === null;
2793
+ for (const { name } of sources) mapping.set(name, name);
2794
+ await writeFile(configPath, `${JSON.stringify({ ...settings2, subtrees: Object.fromEntries(mapping) }, null, 2)}
2795
+ `);
2796
+ await git(["add", "--", "zapdev.json"], repo);
2797
+ if (initial || (await getStagedDiff(repo)).trim()) {
2798
+ await commit(repo, initial ? "chore: init" : "chore: configure subtrees");
2743
2799
  }
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;
2800
+ for (const [name, url] of missingRemotes) await git(["remote", "add", name, url], repo);
2801
+ for (const { name, branch } of imports) {
2802
+ options.onProgress?.(`Adding ${name} (${branch})`);
2803
+ await git(["subtree", "add", `--prefix=${name}`, name, branch, "--squash"], repo);
2759
2804
  }
2760
- reportGitFailure(`${label}: push failed`, first.error);
2761
- return false;
2762
2805
  }
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
- ]
2806
+ async function exists(path) {
2807
+ return lstat(path).then(() => true, (error) => {
2808
+ if (error.code === "ENOENT") return false;
2809
+ throw error;
2775
2810
  });
2776
- if (isCancel(action) || action === "quit") {
2777
- log.warn("Push cancelled. Commit remains local.");
2778
- return null;
2779
- }
2780
- return action;
2781
2811
  }
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;
2812
+
2813
+ // src/commands/subtree-init.ts
2814
+ var subtreeInitCommand = defineCommand({
2815
+ meta: { name: "subtree-init", description: "Initialize a repository with squashed subtrees and their named remotes." },
2816
+ args: {
2817
+ directory: { type: "positional", required: true, description: "Repository directory to create or extend." },
2818
+ subtree: { type: "positional", required: true, description: "name=url; add more positional sources for additional subtrees." },
2819
+ origin: { type: "string", description: "Optional URL for the parent repository's origin remote." }
2820
+ },
2821
+ async run({ args }) {
2822
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
2823
+ try {
2824
+ const sources = parseSubtreeSources(args._.slice(1));
2825
+ if (interactive) intro("zapdev subtree-init");
2826
+ await initializeSubtrees(args.directory, sources, {
2827
+ origin: args.origin,
2828
+ onProgress: log.info,
2829
+ onWarning: log.warn
2830
+ });
2831
+ if (interactive) outro("Subtrees ready.");
2832
+ else log.info("Subtrees ready.");
2833
+ } catch (error) {
2834
+ log.error(error instanceof GitOutputError ? "Subtree initialization failed." : errorMessage(error));
2835
+ process.exitCode = 1;
2836
+ }
2802
2837
  }
2803
- }
2838
+ });
2804
2839
 
2805
2840
  // src/cli.ts
2806
2841
  var main = defineCommand({
@@ -2810,7 +2845,7 @@ var main = defineCommand({
2810
2845
  description: "Fast, precise git chores from your terminal."
2811
2846
  },
2812
2847
  args: commitCommand.args,
2813
- subCommands: { commit: commitCommand },
2848
+ subCommands: { commit: commitCommand, "subtree-init": subtreeInitCommand },
2814
2849
  default: "commit"
2815
2850
  });
2816
2851
  await runMain(main);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zapdev",
3
- "version": "1.7.0",
3
+ "version": "1.9.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": {