@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.
- package/README.md +54 -49
- package/dist/commands/migrate.d.ts +1 -0
- package/dist/commands/migrate.js +25 -1
- package/dist/commands/pull.js +45 -12
- package/dist/commands/push.js +86 -9
- package/dist/migrate/local-write.d.ts +61 -1
- package/dist/migrate/local-write.js +134 -1
- package/dist/migrate/push-to-cloud.d.ts +11 -2
- package/dist/migrate/push-to-cloud.js +78 -42
- package/dist/migrate/source.d.ts +24 -0
- package/dist/migrate/sources/launch.js +21 -0
- package/dist/push/config-push-client.d.ts +15 -1
- package/dist/push/config-push-client.js +102 -0
- package/dist/push/git-pack.d.ts +70 -0
- package/dist/push/git-pack.js +127 -0
- package/dist/push/identity-check.d.ts +27 -6
- package/dist/push/identity-check.js +74 -19
- package/dist/push/run-push.d.ts +133 -1
- package/dist/push/run-push.js +183 -46
- package/dist/util/clone-and-stack-push.d.ts +40 -9
- package/dist/util/clone-and-stack-push.js +59 -20
- package/dist/util/git-ops.d.ts +9 -0
- package/dist/util/git-ops.js +38 -0
- package/dist/util/resolve-workspace-dir.d.ts +44 -0
- package/dist/util/resolve-workspace-dir.js +61 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +10 -4
- package/package.json +7 -7
package/dist/push/run-push.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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>;
|
package/dist/push/run-push.js
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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.
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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:
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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:
|
|
170
|
+
return { action, committed: true, commitSha: commitShas.at(-1) ?? null, commitShas };
|
|
132
171
|
};
|