zapdev 1.8.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.
- package/README.md +46 -22
- package/dist/cli.js +193 -158
- 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
|
|
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,60 +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` |
|
|
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
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
{
|
|
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
|
-
| `
|
|
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
|
|
166
|
-
- **
|
|
167
|
-
- **Destinations:**
|
|
168
|
-
- **Publication:**
|
|
169
|
-
- **Failures:** stop
|
|
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`
|
|
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
|
|
175
|
-
zapdev commit
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
1853
|
-
if (isSubtree !== void 0
|
|
1854
|
-
throw new Error("zapdev.json isSubtree
|
|
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
|
|
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", "
|
|
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
|
|
2350
|
-
const
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
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
|
|
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(["
|
|
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
|
|
2373
|
-
await
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
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
|
|
2379
|
-
|
|
2380
|
-
|
|
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,27 +2518,13 @@ var commitCommand = defineCommand({
|
|
|
2514
2518
|
push: {
|
|
2515
2519
|
type: "boolean",
|
|
2516
2520
|
alias: "p",
|
|
2517
|
-
description: "
|
|
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",
|
|
@@ -2543,12 +2533,6 @@ var commitCommand = defineCommand({
|
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2623
|
+
const toSend = [...committed, ...unchanged];
|
|
2624
|
+
if (toSend.length === 0) return;
|
|
2641
2625
|
let shouldPush = Boolean(args.push);
|
|
2642
2626
|
if (!shouldPush && interactive) {
|
|
2643
2627
|
const answer = await confirm({
|
|
2644
|
-
message: `${publishSubtreeMode ? "Publish subtrees in" : "Push"} ${
|
|
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
|
-
|
|
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
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
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(`${
|
|
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
|
|
2684
|
+
function reportGitFailure(message, error) {
|
|
2672
2685
|
if (error instanceof GitOutputError && error.hookFailureReported) return;
|
|
2673
|
-
log
|
|
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
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
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
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
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
|
-
|
|
2745
|
-
}
|
|
2746
|
-
|
|
2747
|
-
|
|
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
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
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
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
}
|
|
2791
|
-
}
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
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);
|