@quonfig/cli 0.0.45 → 0.0.47

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.
@@ -19,6 +19,7 @@
19
19
  * confirmation — that prompt is the destructive-change brake.
20
20
  */
21
21
  import { FileDelta } from './diff-summary.js';
22
+ import type { CurrentBranchResult } from './git-pack.js';
22
23
  export interface GiteaTokenMintResult {
23
24
  expiresAt: string | null;
24
25
  repoUrl: string;
@@ -27,6 +28,20 @@ export interface GiteaTokenMintResult {
27
28
  workspaceSlug: string;
28
29
  }
29
30
  export interface GitOps {
31
+ /**
32
+ * Pack-push — produce a packfile of `<expectedSha>..<newSha>` (or an
33
+ * empty buffer when expectedSha === newSha). Capped at 25 MiB per §7
34
+ * open Q #7; throws on overflow so callers see a concrete error
35
+ * before any HTTP traffic.
36
+ */
37
+ buildPack(dir: string, expectedSha: string, newSha: string): Promise<Uint8Array>;
38
+ /**
39
+ * Pack-push — count of commits in `<expectedSha>..<newSha>`. Drives
40
+ * the final "Pushed N commit(s)" log line. Returns 0 for a no-op
41
+ * push (already at the tip) and best-effort for new branches with
42
+ * a zero-OID base.
43
+ */
44
+ countCommitsBetween(dir: string, expectedSha: string, newSha: string): Promise<number>;
30
45
  /** Number of tracked files on origin/main (used for the destructive-ratio heuristic). */
31
46
  countFilesInRemote(dir: string): Promise<number>;
32
47
  /**
@@ -45,6 +60,32 @@ export interface GitOps {
45
60
  dirtyTrackedFiles(dir: string): Promise<string[]>;
46
61
  /** `git fetch origin` in the given dir. */
47
62
  fetch(dir: string): Promise<void>;
63
+ /**
64
+ * Returns every configured remote URL on the repo (i.e. `git remote -v`
65
+ * URLs, deduped per remote name). Used by the identity check to support
66
+ * multi-remote workspaces where `origin` points at a customer's PR-review
67
+ * remote (GitHub) and a secondary remote points at Quonfig (qfg-glrd.3).
68
+ *
69
+ * Returns an empty array when the dir isn't a git repo or has no remotes
70
+ * configured.
71
+ */
72
+ getAllRemoteUrls(dir: string): Promise<string[]>;
73
+ /**
74
+ * Run `git log -1 --pretty=oneline <sha>` and return the resulting line
75
+ * (`<short-sha> <subject>`) or an empty string when the commit is not
76
+ * present locally. Used by the qfg-7429.5 denial renderer to print the
77
+ * offending commit's identity alongside the server's recovery message.
78
+ */
79
+ getCommitOneline(dir: string, sha: string): Promise<string>;
80
+ /**
81
+ * Pack-push (qfg-7429.4) — resolve HEAD as a branch name or refuse
82
+ * cleanly. Returns the documented refusal text for detached HEAD and
83
+ * the local default branch being `master`. The result drives the
84
+ * `targetRef` the server sees on success.
85
+ */
86
+ getCurrentBranch(dir: string): Promise<CurrentBranchResult>;
87
+ /** Pack-push — local HEAD SHA. Forwarded as `newSha` on the wire. */
88
+ getHeadSha(dir: string): Promise<string>;
48
89
  /**
49
90
  * Returns the local SHA of `origin/main` after fetch — the remote tip
50
91
  * the diff was computed against — or undefined if no `origin/main` ref
@@ -55,8 +96,24 @@ export interface GitOps {
55
96
  * origin moved between fetch and push.
56
97
  */
57
98
  getOriginMainSha(dir: string): Promise<string | undefined>;
99
+ /**
100
+ * Pack-push — local SHA for `origin/<branchName>`, or undefined when
101
+ * the remote-tracking ref doesn't exist yet (brand-new branch).
102
+ * Drives `expectedSha`: defined → SHA, undefined → zero-OID.
103
+ */
104
+ getRemoteBranchSha(dir: string, branchName: string): Promise<string | undefined>;
58
105
  /** Returns the `remote.origin.url` for the repo, or undefined if unset / not a repo. */
59
106
  getRemoteOriginUrl(dir: string): Promise<string | undefined>;
107
+ /**
108
+ * qfg-7429.6 — return the tree SHA for `<ref>^{tree}`, or undefined when
109
+ * the ref can't be resolved. Used by the legacy-divergence detector in
110
+ * the pack-push conflict handler: workspaces created before pack-push
111
+ * shipped have local commit SHAs that diverge from origin even when the
112
+ * tree content is identical (the old server fabricated commits). If
113
+ * `HEAD^{tree}` matches `origin/<branch>^{tree}`, the apparent conflict
114
+ * is actually that one-shot legacy state, not a real concurrent push.
115
+ */
116
+ getTreeShaForRef(dir: string, ref: string): Promise<string | undefined>;
60
117
  /** Returns true if the dir has a `.git/` (worktree or repo). */
61
118
  isGitRepo(dir: string): Promise<boolean>;
62
119
  /**
@@ -104,6 +161,65 @@ export interface PushDenial {
104
161
  reason: string;
105
162
  requiredPermission: string;
106
163
  }
164
+ /**
165
+ * Pack-push wire input for `configs.gitPush` (qfg-7429.4). The pack
166
+ * itself stays raw on the CLI side; the HTTP client base64-encodes it
167
+ * so the JSON envelope stays text-safe.
168
+ */
169
+ export interface GitPushInput {
170
+ /** Remote tip the CLI saw (zero-OID for a brand-new branch). */
171
+ expectedSha: string;
172
+ /**
173
+ * qfg-7429.5 / §6: true when at least one configured git remote on
174
+ * the local clone does not normalize to the backend's Quonfig repo
175
+ * URL. The server uses this to decide whether to attach the
176
+ * GitHub-fork dead-end `suggestedRecovery` block to a 403 response.
177
+ */
178
+ hasUpstreamRemote: boolean;
179
+ /** Local HEAD being published. Server returns this back as commitSha. */
180
+ newSha: string;
181
+ pack: Uint8Array;
182
+ /** `refs/heads/main` or `refs/heads/<branch>`. */
183
+ targetRef: string;
184
+ workspaceId: string;
185
+ }
186
+ /**
187
+ * Pack-push denial. Carries `commitSha` so the CLI can name which
188
+ * commit failed authz — the §6 GitHub-fork dead-end UX (rendered
189
+ * fully in qfg-7429.5).
190
+ */
191
+ export interface GitPushDenial {
192
+ commitSha: string;
193
+ path: string;
194
+ reason: string;
195
+ requiredPermission: string;
196
+ }
197
+ /**
198
+ * Server-emitted recovery hint for the GitHub-fork dead-end (§6 of
199
+ * the design plan). Currently the only `kind` is `revert-upstream`;
200
+ * future kinds are forward-compatible because the discriminator is
201
+ * tagged.
202
+ */
203
+ export type SuggestedRecovery = {
204
+ kind: 'revert-upstream';
205
+ offendingCommitSha: string;
206
+ message: string;
207
+ };
208
+ export type GitPushResult = {
209
+ kind: 'success';
210
+ commitSha: string;
211
+ ref: string;
212
+ } | {
213
+ kind: 'conflict';
214
+ message: string;
215
+ } | {
216
+ kind: 'bad-request';
217
+ message: string;
218
+ } | {
219
+ kind: 'denied';
220
+ denials: GitPushDenial[];
221
+ suggestedRecovery?: SuggestedRecovery;
222
+ };
107
223
  export type ConfirmIO = {
108
224
  input?: NodeJS.ReadableStream;
109
225
  output?: NodeJS.WritableStream;
@@ -160,6 +276,12 @@ export interface RunPushDeps {
160
276
  * stable across the qfg-azk.13 transition.
161
277
  */
162
278
  mintWriteToken(requestedTarget: string): Promise<GiteaTokenMintResult>;
279
+ /**
280
+ * Pack-push — call the server-side `configs.gitPush` oRPC procedure
281
+ * (qfg-7429.4). Used by the clone-path dispatch; bare-path still
282
+ * routes through `pushToServer` above.
283
+ */
284
+ pushPackToServer(input: GitPushInput): Promise<GitPushResult>;
163
285
  /**
164
286
  * Call the server-side `configs.push` oRPC procedure. Returns a tagged
165
287
  * union so callers can distinguish success / per-file denials / a stale
@@ -187,6 +309,16 @@ export declare class PushFatalError extends Error {
187
309
  code: string;
188
310
  constructor(message: string, code?: string);
189
311
  }
190
- /** Append the Pushed-Via trailer to a commit message if it isn't already present. */
312
+ /**
313
+ * qfg-glrd.6: the `Pushed-Via: cli` trailer was retired. Audit data
314
+ * now lives in the server-side `push_events` table (qfg-glrd.4), so a
315
+ * CLI-appended trailer would be redundant — and CLI trailers also
316
+ * break SHA identity end-to-end once pack-push lands (the message the
317
+ * user committed wouldn't match the message that travels to the server).
318
+ *
319
+ * Kept as a no-op shim instead of deleted so callers and tests don't
320
+ * need a coordinated cross-repo rename. Will be removed once every
321
+ * caller has been updated to drop the call entirely.
322
+ */
191
323
  export declare const withPushedViaTrailer: (message: string) => string;
192
324
  export declare function runPush(input: RunPushInput, deps: RunPushDeps): Promise<RunPushResult>;
@@ -24,6 +24,8 @@ import { readWorkspaceSlug, tryParseWorkspacePin, writeWorkspaceSlug } from '../
24
24
  import { checkIdentity } from './identity-check.js';
25
25
  import { confirmTypedSlug, confirmYesNo } from './confirm.js';
26
26
  import { summarizeDiff } from './diff-summary.js';
27
+ /** All-zeros object id, the git-protocol convention for "no such ref". */
28
+ const ZERO_OID = '0000000000000000000000000000000000000000';
27
29
  export class PushFatalError extends Error {
28
30
  code;
29
31
  constructor(message, code = 'PUSH_FATAL') {
@@ -50,14 +52,18 @@ const normalizeForCompare = (url) => {
50
52
  }
51
53
  };
52
54
  const sameRepo = (a, b) => normalizeForCompare(a) === normalizeForCompare(b);
53
- const PUSHED_VIA_TRAILER = 'Pushed-Via: cli';
54
- /** Append the Pushed-Via trailer to a commit message if it isn't already present. */
55
- export const withPushedViaTrailer = (message) => {
56
- if (message.includes(PUSHED_VIA_TRAILER))
57
- return message;
58
- const trimmed = message.replace(/\s+$/, '');
59
- return `${trimmed}\n\n${PUSHED_VIA_TRAILER}\n`;
60
- };
55
+ /**
56
+ * qfg-glrd.6: the `Pushed-Via: cli` trailer was retired. Audit data
57
+ * now lives in the server-side `push_events` table (qfg-glrd.4), so a
58
+ * CLI-appended trailer would be redundant — and CLI trailers also
59
+ * break SHA identity end-to-end once pack-push lands (the message the
60
+ * user committed wouldn't match the message that travels to the server).
61
+ *
62
+ * Kept as a no-op shim instead of deleted so callers and tests don't
63
+ * need a coordinated cross-repo rename. Will be removed once every
64
+ * caller has been updated to drop the call entirely.
65
+ */
66
+ export const withPushedViaTrailer = (message) => message;
61
67
  export async function runPush(input, deps) {
62
68
  const log = deps.log ?? ((s) => console.log(s));
63
69
  const errLog = deps.errLog ?? ((s) => console.error(s));
@@ -83,6 +89,16 @@ export async function runPush(input, deps) {
83
89
  }
84
90
  const repoPinSlug = repoPin?.workspaceSlug;
85
91
  const hasGit = await deps.gitOps.isGitRepo(input.dir);
92
+ // Multi-remote support (qfg-glrd.3): walk EVERY configured remote, not
93
+ // just `origin`. Customers commonly run `origin=github` for PR review and
94
+ // a secondary remote against Quonfig. Identity check accepts as long as
95
+ // any one of them matches the backend.
96
+ const remoteUrls = hasGit ? await deps.gitOps.getAllRemoteUrls(input.dir) : [];
97
+ // Clone-path dispatch still keys off `origin` specifically — the
98
+ // implementation reads `origin/main` and pushes via origin. With a
99
+ // non-matching origin (e.g. origin=github), the bare-path probe clone is
100
+ // used instead. Identity-check already accepted the push via a different
101
+ // remote, so this is a dispatch detail, not a refusal.
86
102
  const remoteOriginUrl = hasGit ? await deps.gitOps.getRemoteOriginUrl(input.dir) : undefined;
87
103
  // Mint the write token. The backend accepts a slug OR a UUID for workspaceId
88
104
  // and resolves to the same row — we trust whatever `requestedTarget` is.
@@ -91,7 +107,7 @@ export async function runPush(input, deps) {
91
107
  const identity = checkIdentity({
92
108
  requestedTarget: input.requestedTarget,
93
109
  repoPinSlug,
94
- remoteOriginUrl,
110
+ remoteUrls,
95
111
  backend: {
96
112
  workspaceSlug: backend.workspaceSlug,
97
113
  workspaceId: backend.workspaceId,
@@ -132,17 +148,29 @@ export async function runPush(input, deps) {
132
148
  // Clone path: align origin with the backend so the gitOps.fetch + diff
133
149
  // walk authenticates against the read URL we minted. The bare path's
134
150
  // probe-clone path bypasses local origin entirely.
151
+ //
152
+ // Pack-push (qfg-7429.4): the §4.1 step-1 refusals run BEFORE any
153
+ // network work in clone-path so a detached-HEAD or `master` checkout
154
+ // gets a clear error message without us pinging Gitea or the backend.
155
+ let currentBranch;
135
156
  if (isClonePath) {
157
+ currentBranch = await deps.gitOps.getCurrentBranch(input.dir);
158
+ if (currentBranch.kind === 'detached') {
159
+ throw new PushFatalError(currentBranch.message, 'DETACHED_HEAD');
160
+ }
161
+ if (currentBranch.kind === 'master') {
162
+ throw new PushFatalError(currentBranch.message, 'MASTER_BRANCH');
163
+ }
136
164
  await deps.gitOps.setRemoteOrigin(input.dir, backend.repoUrl);
137
165
  log('Fetching from remote...');
138
166
  await deps.gitOps.fetch(input.dir);
139
167
  // Guard 4 (qfg-fboj): refuse to push when local HEAD is behind or has
140
- // diverged from origin/main. Without this guard the HEAD-vs-origin
141
- // diff produces REVERSAL deltas that silently undo whatever was
142
- // committed to the cloud since the user last pulled. Tell the user
143
- // to run `qfg pull` first; the standalone `--ff-only` step keeps
144
- // push from doing surprising merges on the user's behalf.
145
- if (await deps.gitOps.isLocalBehindRemote(input.dir)) {
168
+ // diverged from origin/main. The §4.1 server-side fast-forward check
169
+ // already covers correctness; this client-side guard turns a 409 round
170
+ // trip into a fast local error with a `qfg pull` hint. Only meaningful
171
+ // on `main` — branch pushes are evaluated against origin/<branch>, not
172
+ // origin/main, and may legitimately diverge.
173
+ if (currentBranch.name === 'main' && (await deps.gitOps.isLocalBehindRemote(input.dir))) {
146
174
  throw new PushFatalError('Local HEAD is behind origin/main. Run `qfg pull` first to merge remote changes, then re-run `qfg push`.', 'STALE_HEAD');
147
175
  }
148
176
  // Migration cleanup (qfg-0fn): older `qfg pull` runs wrote the
@@ -211,38 +239,147 @@ export async function runPush(input, deps) {
211
239
  log('Aborted, nothing pushed.');
212
240
  return { kind: 'aborted', reason: decision.reason };
213
241
  }
214
- const baseMessage = input.message ?? `qfg push: ${summary.totals.filesTouched} file change(s)`;
215
- const commitMessage = withPushedViaTrailer(baseMessage);
216
- const serverFiles = deltas.map((d) => ({
217
- path: d.path,
218
- kind: toServerKind(d.kind),
219
- ...(d.beforeJson === undefined ? {} : { beforeJson: d.beforeJson }),
220
- ...(d.afterJson === undefined ? {} : { afterJson: d.afterJson }),
221
- }));
222
- // qfg-gj3i: pass origin/main SHA as expectedSha so the server-side
223
- // optimistic lock can reject the push if origin moved between fetch
224
- // and now. Undefined on bare-path; the server treats absence as
225
- // "non-clone client" and applies its bare-path policy.
226
- const expectedSha = await deps.gitOps.getOriginMainSha(input.dir);
227
- log('Sending push to Quonfig cloud...');
228
- const result = await deps.pushToServer({
229
- workspaceId: backend.workspaceId,
230
- files: serverFiles,
231
- message: commitMessage,
232
- ...(expectedSha === undefined ? {} : { expectedSha }),
233
- });
234
- if (result.kind === 'denied') {
235
- errLog(`Push denied for ${result.denials.length} file(s):`);
236
- for (const d of result.denials) {
237
- errLog(` ${d.path}: missing permission ${d.requiredPermission}`);
242
+ // Pack-push branch (qfg-7429.4): clone-path now ships actual git
243
+ // commit objects via `configs.gitPush`. The bare-path branch below is
244
+ // the unchanged file-delta wire shape used by the UI and CI pushes
245
+ // that don't carry a `.git/`.
246
+ let pushedCommitSha;
247
+ if (isClonePath && currentBranch && currentBranch.kind === 'branch') {
248
+ const branchName = currentBranch.name;
249
+ const targetRef = `refs/heads/${branchName}`;
250
+ // expectedSha: for `main`, the origin/main SHA we already know; for
251
+ // branches, look up `origin/<branch>` (zero-OID for brand-new branches).
252
+ const expectedShaRaw = branchName === 'main'
253
+ ? await deps.gitOps.getOriginMainSha(input.dir)
254
+ : await deps.gitOps.getRemoteBranchSha(input.dir, branchName);
255
+ const expectedSha = expectedShaRaw ?? ZERO_OID;
256
+ const newSha = await deps.gitOps.getHeadSha(input.dir);
257
+ log('Building pack...');
258
+ const pack = await deps.gitOps.buildPack(input.dir, expectedSha, newSha);
259
+ // §6 / qfg-7429.5: tell the server whether the local clone has a
260
+ // non-Quonfig upstream so it can decide whether to attach a
261
+ // GitHub-fork dead-end recovery hint to a 403.
262
+ const hasUpstreamRemote = remoteUrls.some((u) => !sameRepo(u, backend.repoUrl));
263
+ log('Sending push to Quonfig cloud...');
264
+ const packResult = await deps.pushPackToServer({
265
+ workspaceId: backend.workspaceId,
266
+ targetRef,
267
+ expectedSha,
268
+ newSha,
269
+ pack,
270
+ hasUpstreamRemote,
271
+ });
272
+ if (packResult.kind === 'denied') {
273
+ errLog(`Push denied for ${packResult.denials.length} commit(s):`);
274
+ for (const d of packResult.denials) {
275
+ // qfg-7429.5 denial line: `<short-sha> <path> -- <reason>, requires <requiredPermission>`
276
+ errLog(` ${d.commitSha.slice(0, 8)} ${d.path} -- ${d.reason}, requires ${d.requiredPermission}`);
277
+ }
278
+ if (packResult.suggestedRecovery) {
279
+ errLog('');
280
+ errLog(packResult.suggestedRecovery.message);
281
+ try {
282
+ const oneline = await deps.gitOps.getCommitOneline(input.dir, packResult.suggestedRecovery.offendingCommitSha);
283
+ if (oneline.length > 0) {
284
+ errLog(` ${oneline}`);
285
+ }
286
+ }
287
+ catch {
288
+ /* non-fatal — the recovery message is the load-bearing part */
289
+ }
290
+ }
291
+ throw new PushFatalError(`Push denied for ${packResult.denials.length} commit(s). See errors above.`, 'PUSH_DENIED');
238
292
  }
239
- throw new PushFatalError(`Push denied for ${result.denials.length} file(s). See errors above.`, 'PUSH_DENIED');
240
- }
241
- if (result.kind === 'conflict') {
242
- throw new PushFatalError(`Remote moved while preparing this push (${result.message}). Run \`qfg pull\` and retry.`, 'CONFLICT');
293
+ if (packResult.kind === 'conflict') {
294
+ // qfg-7429.6: every workspace ever pushed under the pre-pack-push
295
+ // fabricate-commit handler has local commit SHAs that diverge from
296
+ // origin even when content is identical. The first pack push from
297
+ // such a dir trips the server's fast-forward check. Distinguish
298
+ // that one-shot legacy state from a real conflict by comparing
299
+ // HEAD's tree to origin/<branch>'s tree: equal trees → migration
300
+ // hint (`git reset --hard origin/<branch>`); different trees →
301
+ // existing qfg-pull message. Rebase would produce phantom replay
302
+ // commits with new SHAs forever, so it is NOT the recommended path.
303
+ let headTree;
304
+ let originTree;
305
+ try {
306
+ headTree = await deps.gitOps.getTreeShaForRef(input.dir, 'HEAD');
307
+ }
308
+ catch {
309
+ /* fall through — treat unknown tree as "real conflict" */
310
+ }
311
+ try {
312
+ originTree = await deps.gitOps.getTreeShaForRef(input.dir, `origin/${branchName}`);
313
+ }
314
+ catch {
315
+ /* fall through */
316
+ }
317
+ if (headTree && originTree && headTree === originTree) {
318
+ errLog('');
319
+ errLog(`Your local ${branchName} and origin/${branchName} have diverged from an older push using the`);
320
+ errLog('fabricate-commit handler. Your local content matches origin (no real conflict).');
321
+ errLog('');
322
+ errLog(`To align: git reset --hard origin/${branchName}`);
323
+ errLog('Then re-run qfg push.');
324
+ errLog('');
325
+ errLog('This is a one-time cutover. Subsequent pushes from this dir will preserve your');
326
+ errLog('commit SHAs end-to-end.');
327
+ throw new PushFatalError(`Legacy orphan-commit divergence with origin/${branchName}. Run \`git reset --hard origin/${branchName}\` and retry.`, 'CONFLICT_LEGACY_DIVERGE');
328
+ }
329
+ throw new PushFatalError(`Remote moved while preparing this push (${packResult.message}). Run \`qfg pull\` and retry.`, 'CONFLICT');
330
+ }
331
+ if (packResult.kind === 'bad-request') {
332
+ throw new PushFatalError(`Push rejected by server: ${packResult.message}`, 'BAD_REQUEST');
333
+ }
334
+ pushedCommitSha = packResult.commitSha;
335
+ // §4.1 step 4: post-push refresh — `git fetch origin` (NOT `git
336
+ // reset`) brings the local origin/<ref> tracking ref in line with
337
+ // the SHA the server published, so subsequent `qfg pull` runs are
338
+ // a no-op. SHAs already match by construction (pack-push round-trip).
339
+ try {
340
+ await deps.gitOps.fetch(input.dir);
341
+ }
342
+ catch {
343
+ /* non-fatal — the push itself succeeded */
344
+ }
345
+ const commitCount = await deps.gitOps.countCommitsBetween(input.dir, expectedSha, newSha);
346
+ log(`Pushed ${commitCount} commit(s) to origin/${targetRef} as ${newSha.slice(0, 7)}. Local repo in sync.`);
243
347
  }
244
- if (result.kind === 'bad-request') {
245
- throw new PushFatalError(`Push rejected by server: ${result.message}`, 'BAD_REQUEST');
348
+ else {
349
+ const baseMessage = input.message ?? `qfg push: ${summary.totals.filesTouched} file change(s)`;
350
+ const commitMessage = withPushedViaTrailer(baseMessage);
351
+ const serverFiles = deltas.map((d) => ({
352
+ path: d.path,
353
+ kind: toServerKind(d.kind),
354
+ ...(d.beforeJson === undefined ? {} : { beforeJson: d.beforeJson }),
355
+ ...(d.afterJson === undefined ? {} : { afterJson: d.afterJson }),
356
+ }));
357
+ // qfg-gj3i: pass origin/main SHA as expectedSha so the server-side
358
+ // optimistic lock can reject the push if origin moved between fetch
359
+ // and now. Undefined on bare-path; the server treats absence as
360
+ // "non-clone client" and applies its bare-path policy.
361
+ const expectedSha = await deps.gitOps.getOriginMainSha(input.dir);
362
+ log('Sending push to Quonfig cloud...');
363
+ const result = await deps.pushToServer({
364
+ workspaceId: backend.workspaceId,
365
+ files: serverFiles,
366
+ message: commitMessage,
367
+ ...(expectedSha === undefined ? {} : { expectedSha }),
368
+ });
369
+ if (result.kind === 'denied') {
370
+ errLog(`Push denied for ${result.denials.length} file(s):`);
371
+ for (const d of result.denials) {
372
+ errLog(` ${d.path}: missing permission ${d.requiredPermission}`);
373
+ }
374
+ throw new PushFatalError(`Push denied for ${result.denials.length} file(s). See errors above.`, 'PUSH_DENIED');
375
+ }
376
+ if (result.kind === 'conflict') {
377
+ throw new PushFatalError(`Remote moved while preparing this push (${result.message}). Run \`qfg pull\` and retry.`, 'CONFLICT');
378
+ }
379
+ if (result.kind === 'bad-request') {
380
+ throw new PushFatalError(`Push rejected by server: ${result.message}`, 'BAD_REQUEST');
381
+ }
382
+ pushedCommitSha = result.commitSha;
246
383
  }
247
384
  if (unpinned && !input.noPinWrite) {
248
385
  // The backend's mint-token still returns the bare workspace component;
@@ -262,7 +399,7 @@ export async function runPush(input, deps) {
262
399
  }
263
400
  }
264
401
  }
265
- return { kind: 'pushed', dispatchedAs, commitSha: result.commitSha };
402
+ return { kind: 'pushed', dispatchedAs, commitSha: pushedCommitSha };
266
403
  }
267
404
  const toServerKind = (kind) => {
268
405
  switch (kind) {
@@ -6,19 +6,37 @@ export interface PushIdentity {
6
6
  email: string;
7
7
  name: string;
8
8
  }
9
- export interface CloneAndStackPushOptions {
10
- /** Called once `localDir` is a clone synced with origin/main. Must write/delete files under `localDir` — do NOT commit. */
11
- applyDelta: (localDir: string) => Promise<void> | void;
9
+ /**
10
+ * A single commit to apply on top of the cloned local tree. `apply` writes/deletes
11
+ * files; it must NOT commit. cloneAndStackPush stages and commits with the given
12
+ * author + optional author date.
13
+ */
14
+ export interface CommitSpec {
15
+ /** File operations to perform in `localDir` before this commit is staged. */
16
+ apply: (localDir: string) => Promise<void> | void;
12
17
  /** Commit author. Defaults to the migrator identity. */
13
18
  author?: PushIdentity;
19
+ /**
20
+ * Optional author/committer date. Passed via GIT_AUTHOR_DATE +
21
+ * GIT_COMMITTER_DATE. Any value `new Date(...)` accepts. Omit to use "now".
22
+ */
23
+ authorDate?: Date | number | string;
24
+ /**
25
+ * Commit message. May be a function when the message depends on counts that
26
+ * are only known after `apply` runs (qfg-7eig).
27
+ */
28
+ message: string | (() => string);
29
+ }
30
+ export interface CloneAndStackPushOptions {
14
31
  /** Branch to track and push. Defaults to `main`. */
15
32
  branch?: string;
16
33
  /**
17
- * Commit message for the delta. Pass a function when the message depends on
18
- * counts that are only known after `applyDelta` runs (qfg-7eig). The function
19
- * is invoked after applyDelta but before commit.
34
+ * One or more commits to layer onto the clone. Commits are applied + committed
35
+ * in order; commits whose `apply` produces no staged changes are silently
36
+ * skipped (no empty commits). All non-empty commits are pushed in a single
37
+ * `git push`.
20
38
  */
21
- commitMessage: string | (() => string);
39
+ commits: CommitSpec[];
22
40
  /** Where to clone into, or an existing clone to reuse. */
23
41
  localDir: string;
24
42
  /** Gitea repo URL (may include token). */
@@ -27,9 +45,11 @@ export interface CloneAndStackPushOptions {
27
45
  export interface CloneAndStackPushResult {
28
46
  /** `'cloned'` if we had to clone fresh, `'reused'` if the local dir was already a clone of the remote. */
29
47
  action: 'cloned' | 'reused';
30
- /** SHA of the new commit, if one was made. */
48
+ /** SHA of the LAST commit landed (back-compat alias for the final entry in commitShas). Null when no commit landed. */
31
49
  commitSha: string | null;
32
- /** `false` if `applyDelta` produced no changes — nothing was committed or pushed. */
50
+ /** SHAs of every commit landed, in order. Empty when no commit landed. */
51
+ commitShas: string[];
52
+ /** `false` if every commit's `apply` produced no changes — nothing was committed or pushed. */
33
53
  committed: boolean;
34
54
  }
35
55
  export declare class PushConflictError extends Error {
@@ -38,4 +58,15 @@ export declare class PushConflictError extends Error {
38
58
  export declare class PushHookRejectedError extends Error {
39
59
  constructor(message: string);
40
60
  }
61
+ /**
62
+ * Apply + commit a sequence of CommitSpecs against an existing repo at `localDir`.
63
+ * Per-commit author/date come from each spec (defaults to migrator identity, "now").
64
+ * Empty commits — where `apply` produces no staged changes — are silently skipped.
65
+ * Does NOT clone, fetch, or push: callers handle network I/O. Used by
66
+ * `cloneAndStackPush` for cloud pushes and by the local migrator for `--full-summary`
67
+ * imports into a `git init`-ed dir.
68
+ */
69
+ export declare const stackCommits: (localDir: string, commits: CommitSpec[]) => Promise<{
70
+ commitShas: string[];
71
+ }>;
41
72
  export declare const cloneAndStackPush: (opts: CloneAndStackPushOptions) => Promise<CloneAndStackPushResult>;
@@ -84,29 +84,68 @@ const hasStagedChanges = async (dir) => {
84
84
  const { stdout } = await runGitSafe(['-C', dir, 'status', '--porcelain']);
85
85
  return stdout.trim().length > 0;
86
86
  };
87
- export const cloneAndStackPush = async (opts) => {
88
- const branch = opts.branch ?? 'main';
89
- const author = opts.author ?? MIGRATOR_IDENTITY;
90
- const action = await ensureCloneOrReuse(opts.remoteUrl, opts.localDir, branch);
91
- // Pin committer identity on the repo so commits are reproducible regardless of host git config.
92
- await runGit(opts.localDir, ['config', 'user.name', author.name]);
93
- await runGit(opts.localDir, ['config', 'user.email', author.email]);
94
- await opts.applyDelta(opts.localDir);
95
- await runGit(opts.localDir, ['add', '--all']);
96
- if (!(await hasStagedChanges(opts.localDir))) {
97
- return { action, committed: false, commitSha: null };
98
- }
99
- const commitMessage = typeof opts.commitMessage === 'function' ? opts.commitMessage() : opts.commitMessage;
100
- await spawnGit(['-C', opts.localDir, 'commit', '-F', '-'], {
101
- stdin: commitMessage,
102
- env: {
87
+ const toGitDate = (value) => {
88
+ // Normalize via Date so any of the three accepted shapes round-trips through
89
+ // git's ISO-8601 parser cleanly.
90
+ const d = value instanceof Date ? value : new Date(value);
91
+ return d.toISOString();
92
+ };
93
+ /**
94
+ * Apply + commit a sequence of CommitSpecs against an existing repo at `localDir`.
95
+ * Per-commit author/date come from each spec (defaults to migrator identity, "now").
96
+ * Empty commits — where `apply` produces no staged changes — are silently skipped.
97
+ * Does NOT clone, fetch, or push: callers handle network I/O. Used by
98
+ * `cloneAndStackPush` for cloud pushes and by the local migrator for `--full-summary`
99
+ * imports into a `git init`-ed dir.
100
+ */
101
+ export const stackCommits = async (localDir, commits) => {
102
+ const commitShas = [];
103
+ for (const spec of commits) {
104
+ const author = spec.author ?? MIGRATOR_IDENTITY;
105
+ // eslint-disable-next-line no-await-in-loop
106
+ await spec.apply(localDir);
107
+ // eslint-disable-next-line no-await-in-loop
108
+ await runGitSafe(['-C', localDir, 'add', '--all']);
109
+ // eslint-disable-next-line no-await-in-loop
110
+ if (!(await hasStagedChanges(localDir))) {
111
+ // No-op commits are silently skipped (qfg-3uks: no empty commits, ever).
112
+ continue;
113
+ }
114
+ const message = typeof spec.message === 'function' ? spec.message() : spec.message;
115
+ const env = {
103
116
  GIT_AUTHOR_NAME: author.name,
104
117
  GIT_AUTHOR_EMAIL: author.email,
105
118
  GIT_COMMITTER_NAME: author.name,
106
119
  GIT_COMMITTER_EMAIL: author.email,
107
- },
108
- });
109
- const { stdout: sha } = await runGitSafe(['-C', opts.localDir, 'rev-parse', 'HEAD']);
120
+ };
121
+ if (spec.authorDate !== undefined) {
122
+ const iso = toGitDate(spec.authorDate);
123
+ env.GIT_AUTHOR_DATE = iso;
124
+ env.GIT_COMMITTER_DATE = iso;
125
+ }
126
+ // eslint-disable-next-line no-await-in-loop
127
+ await spawnGit(['-C', localDir, 'commit', '-F', '-'], { stdin: message, env });
128
+ // eslint-disable-next-line no-await-in-loop
129
+ const { stdout: sha } = await runGitSafe(['-C', localDir, 'rev-parse', 'HEAD']);
130
+ commitShas.push(sha.trim());
131
+ }
132
+ return { commitShas };
133
+ };
134
+ export const cloneAndStackPush = async (opts) => {
135
+ const branch = opts.branch ?? 'main';
136
+ if (opts.commits.length === 0) {
137
+ throw new Error('cloneAndStackPush: commits array must contain at least one entry');
138
+ }
139
+ const action = await ensureCloneOrReuse(opts.remoteUrl, opts.localDir, branch);
140
+ // Pin a stable committer identity on the repo. Per-commit author overrides
141
+ // happen via env vars in stackCommits; this default catches the case where a
142
+ // CommitSpec omits `author` entirely.
143
+ await runGit(opts.localDir, ['config', 'user.name', MIGRATOR_IDENTITY.name]);
144
+ await runGit(opts.localDir, ['config', 'user.email', MIGRATOR_IDENTITY.email]);
145
+ const { commitShas } = await stackCommits(opts.localDir, opts.commits);
146
+ if (commitShas.length === 0) {
147
+ return { action, committed: false, commitSha: null, commitShas: [] };
148
+ }
110
149
  try {
111
150
  // Plain push — no force, no force-with-lease. If the remote has advanced, we fail
112
151
  // and surface a conflict; we do NOT clobber UI edits.
@@ -128,5 +167,5 @@ export const cloneAndStackPush = async (opts) => {
128
167
  }
129
168
  throw error;
130
169
  }
131
- return { action, committed: true, commitSha: sha.trim() };
170
+ return { action, committed: true, commitSha: commitShas.at(-1) ?? null, commitShas };
132
171
  };