@quonfig/cli 0.0.74 → 0.1.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 +51 -51
- package/dist/commands/workspace/bootstrap.d.ts +0 -8
- package/dist/commands/workspace/bootstrap.js +87 -95
- package/dist/util/bootstrap-target.d.ts +40 -0
- package/dist/util/bootstrap-target.js +46 -0
- package/dist/util/clone-and-stack-push.d.ts +1 -4
- package/dist/util/clone-and-stack-push.js +2 -5
- package/dist/util/git-ops.d.ts +60 -2
- package/dist/util/git-ops.js +303 -5
- package/dist/verify/standalone.d.ts +31 -0
- package/dist/verify/standalone.js +156 -11
- package/dist/verify/validate.d.ts +2 -0
- package/dist/verify/validate.js +3 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +2 -2
- package/package.json +1 -1
|
@@ -4,9 +4,16 @@ import { Flags } from '@oclif/core';
|
|
|
4
4
|
import { BaseCommand } from '../../index.js';
|
|
5
5
|
import { getActiveProfile, loadAuthConfig } from '../../util/token-storage.js';
|
|
6
6
|
import { mintGiteaToken } from '../../util/gitea-api.js';
|
|
7
|
-
import {
|
|
7
|
+
import { bootstrapPinMismatch, describeBootstrapTarget } from '../../util/bootstrap-target.js';
|
|
8
|
+
import { readWorkspaceSlug } from '../../util/quonfig-json.js';
|
|
8
9
|
import { resolveWorkspaceUuid } from '../../util/resolve-workspace.js';
|
|
9
|
-
import { isGitRepo, hasAtLeastOneCommit, gitSetRemote,
|
|
10
|
+
import { isGitRepo, hasAtLeastOneCommit, gitSetRemote, gitFetch, workspaceDocumentsAtRef, rebaseOntoOriginAndPush, getRemoteUrl, getOriginMainSha, displayUrl, } from '../../util/git-ops.js';
|
|
11
|
+
/**
|
|
12
|
+
* How the customer points their clone at the workspace after bootstrap.
|
|
13
|
+
* `pre-bootstrap` keeps their original history; the reset is safe because the
|
|
14
|
+
* pushed tree was proven equal to their tree before the push.
|
|
15
|
+
*/
|
|
16
|
+
const LOCAL_RESET_RECIPE = 'git branch pre-bootstrap && git fetch origin && git reset --hard origin/main';
|
|
10
17
|
export default class WorkspaceBootstrap extends BaseCommand {
|
|
11
18
|
static description = "Push a local git repo to Gitea as this workspace's config repository";
|
|
12
19
|
static examples = [
|
|
@@ -21,7 +28,7 @@ export default class WorkspaceBootstrap extends BaseCommand {
|
|
|
21
28
|
}),
|
|
22
29
|
force: Flags.boolean({
|
|
23
30
|
default: false,
|
|
24
|
-
description: '
|
|
31
|
+
description: 'Accepted and ignored (no-op). Bootstrap never rewrites the workspace history.',
|
|
25
32
|
}),
|
|
26
33
|
'skip-validate': Flags.boolean({
|
|
27
34
|
default: false,
|
|
@@ -33,13 +40,34 @@ export default class WorkspaceBootstrap extends BaseCommand {
|
|
|
33
40
|
// Resolve target directory
|
|
34
41
|
const dir = flags.dir || process.env.QUONFIG_DIR || process.cwd();
|
|
35
42
|
const resolvedDir = path.resolve(dir);
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
|
|
43
|
+
// `--force` is kept so pinned scripts don't die on an unknown flag, but a
|
|
44
|
+
// workspace's `main` is append-only: nothing here ever rewrites it.
|
|
45
|
+
if (flags.force) {
|
|
46
|
+
this.log('Note: --force is accepted for compatibility and ignored — bootstrap never rewrites the workspace.\n');
|
|
47
|
+
}
|
|
48
|
+
// The target is resolved like push/pull/sync resolve theirs: QUONFIG_WORKSPACE,
|
|
49
|
+
// then the directory's quonfig.json pin, then the active profile.
|
|
50
|
+
const { workspaceId, orgSlug } = await resolveWorkspaceUuid(this, undefined, resolvedDir);
|
|
51
|
+
// The pin is read again here for the prompt and for the mismatch guard
|
|
52
|
+
// below. A legacy bare-slug pin reads as "no pin", as it does for push.
|
|
53
|
+
let pin;
|
|
54
|
+
try {
|
|
55
|
+
pin = await readWorkspaceSlug(resolvedDir);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
pin = undefined;
|
|
59
|
+
}
|
|
60
|
+
// The prompt names whichever source chose the target; the profile only
|
|
61
|
+
// when it IS the resolved workspace (qfg-8p8i).
|
|
39
62
|
const authConfig = await loadAuthConfig();
|
|
40
63
|
const activeProfile = getActiveProfile();
|
|
41
64
|
const profile = authConfig?.profiles[activeProfile] || authConfig?.profiles[authConfig?.defaultProfile || 'default'];
|
|
42
|
-
const workspaceName =
|
|
65
|
+
const workspaceName = describeBootstrapTarget({
|
|
66
|
+
envOverride: process.env.QUONFIG_WORKSPACE,
|
|
67
|
+
pin,
|
|
68
|
+
profile,
|
|
69
|
+
workspaceId,
|
|
70
|
+
});
|
|
43
71
|
this.verboseLog('WorkspaceBootstrap', { workspaceId, orgSlug, dir: resolvedDir });
|
|
44
72
|
// Validate: must be a git repo with at least one commit
|
|
45
73
|
const isRepo = await isGitRepo(resolvedDir);
|
|
@@ -101,16 +129,11 @@ export default class WorkspaceBootstrap extends BaseCommand {
|
|
|
101
129
|
}
|
|
102
130
|
const { repoUrl, workspaceSlug: backendSlug } = tokenData;
|
|
103
131
|
this.verboseLog('WorkspaceBootstrap', { repoUrl: displayUrl(repoUrl), backendSlug });
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
this.log(`Run \`qfg pull --dir ${resolvedDir}\` to sync, then re-run bootstrap.`);
|
|
110
|
-
this.log(`Or run with --force to overwrite the remote.\n`);
|
|
111
|
-
return this.err('Remote is not empty.');
|
|
112
|
-
}
|
|
113
|
-
}
|
|
132
|
+
// Guard, as `qfg push` has: a directory pinned to one workspace is not
|
|
133
|
+
// pushed to another. Checked before the remote is touched.
|
|
134
|
+
const mismatch = bootstrapPinMismatch(pin, backendSlug);
|
|
135
|
+
if (mismatch)
|
|
136
|
+
return this.err(mismatch);
|
|
114
137
|
// Set remote
|
|
115
138
|
if (existingRemote) {
|
|
116
139
|
this.log(`Updating remote origin...`);
|
|
@@ -119,96 +142,65 @@ export default class WorkspaceBootstrap extends BaseCommand {
|
|
|
119
142
|
this.log(`Setting remote origin...`);
|
|
120
143
|
}
|
|
121
144
|
await gitSetRemote(resolvedDir, repoUrl);
|
|
122
|
-
|
|
145
|
+
await gitFetch(resolvedDir);
|
|
146
|
+
// Every workspace repo is provisioned with `main`; no `main` means the
|
|
147
|
+
// workspace is half-created, and the pre-receive hook rejects creating it
|
|
148
|
+
// by push (plan 5.6 item 1).
|
|
149
|
+
if ((await getOriginMainSha(resolvedDir)) === undefined) {
|
|
150
|
+
return this.err('The workspace repository is not provisioned (it has no `main` branch).\nFinish creating the workspace in the Quonfig app, then run bootstrap again.');
|
|
151
|
+
}
|
|
152
|
+
// Bootstrap is for a FRESH workspace only. "Fresh" is "holds no documents",
|
|
153
|
+
// NOT "has no commits": provisioning seeds README.md and quonfig.json, so
|
|
154
|
+
// every real workspace arrives with two commits (plan
|
|
155
|
+
// 2026-09-17-tree-derived-cache.md 5.6, 13.2 risk 5).
|
|
156
|
+
const documents = await workspaceDocumentsAtRef(resolvedDir, 'origin/main');
|
|
157
|
+
if (documents.length > 0) {
|
|
158
|
+
const sample = documents.slice(0, 3).join(', ') + (documents.length > 3 ? ', ...' : '');
|
|
159
|
+
this.log(`\nThis workspace already holds ${documents.length} document(s): ${sample}`);
|
|
160
|
+
this.log(`Bootstrap lands your local history UNDER what is already there, so it is for fresh workspaces only.`);
|
|
161
|
+
this.log(`To send local changes to a workspace that is already in use, run:`);
|
|
162
|
+
this.log(` qfg push --dir ${resolvedDir}\n`);
|
|
163
|
+
return this.err('Workspace is not empty.');
|
|
164
|
+
}
|
|
165
|
+
// Push. The history is replayed onto the workspace's own head on a
|
|
166
|
+
// temporary worktree and pushed plainly: `main` is append-only, and a
|
|
167
|
+
// force-push silently wedges config delivery (plan 5.6).
|
|
123
168
|
this.log('Pushing to Gitea...');
|
|
169
|
+
let pushResult;
|
|
124
170
|
try {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
await gitPushForceLease(resolvedDir);
|
|
130
|
-
}
|
|
171
|
+
// Validation runs again against the tree that is actually pushed (which
|
|
172
|
+
// carries the WORKSPACE's quonfig.json, not the local one), so content
|
|
173
|
+
// the server would reject fails here.
|
|
174
|
+
pushResult = await rebaseOntoOriginAndPush(resolvedDir, { validate: !flags['skip-validate'] });
|
|
131
175
|
}
|
|
132
176
|
catch (error) {
|
|
133
177
|
return this.err(`Push failed: ${String(error)}`);
|
|
134
178
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
// If we do write it, commit and push so the bootstrap leaves no
|
|
139
|
-
// uncommitted changes behind.
|
|
140
|
-
//
|
|
141
|
-
// The pin is stored as `<org-slug>/<workspace-slug>`. The backend's
|
|
142
|
-
// `workspaceSlug` is just the workspace component today; until the
|
|
143
|
-
// server returns the slash form, we skip the write rather than
|
|
144
|
-
// emitting a bare slug.
|
|
145
|
-
const backendPin = tryParseWorkspacePin(backendSlug);
|
|
146
|
-
try {
|
|
147
|
-
const existingPin = await readWorkspaceSlug(resolvedDir);
|
|
148
|
-
const existingFormatted = existingPin ? `${existingPin.orgSlug}/${existingPin.workspaceSlug}` : undefined;
|
|
149
|
-
if (!backendPin) {
|
|
150
|
-
this.verboseLog('WorkspaceBootstrap', `Backend workspaceSlug "${backendSlug}" is not in <org>/<ws> form; skipping pin write.`);
|
|
151
|
-
}
|
|
152
|
-
else if (existingPin &&
|
|
153
|
-
(existingPin.orgSlug !== backendPin.orgSlug || existingPin.workspaceSlug !== backendPin.workspaceSlug)) {
|
|
154
|
-
const backendFormatted = `${backendPin.orgSlug}/${backendPin.workspaceSlug}`;
|
|
155
|
-
this.log('');
|
|
156
|
-
this.log(`Warning: quonfig.json already pins workspace "${existingFormatted}", but the backend says this workspace is "${backendFormatted}".`);
|
|
157
|
-
this.log('Leaving the existing pin in place. If this is wrong, edit quonfig.json manually and re-run.');
|
|
158
|
-
}
|
|
159
|
-
else if (existingPin) {
|
|
160
|
-
this.verboseLog('WorkspaceBootstrap', `quonfig.json already pinned to ${existingFormatted}; no-op.`);
|
|
161
|
-
}
|
|
162
|
-
else {
|
|
163
|
-
const backendFormatted = `${backendPin.orgSlug}/${backendPin.workspaceSlug}`;
|
|
164
|
-
this.log('');
|
|
165
|
-
this.log(`Pinning quonfig.json to workspace "${backendFormatted}"...`);
|
|
166
|
-
await writeWorkspaceSlug(resolvedDir, backendPin);
|
|
167
|
-
await this.commitAndPushPin(resolvedDir, backendFormatted, flags.force);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
catch (error) {
|
|
171
|
-
// The main push has already succeeded, so don't fail the whole command.
|
|
172
|
-
// Surface the issue clearly so the user knows to follow up.
|
|
173
|
-
this.log('');
|
|
174
|
-
this.log(`Warning: could not write the workspace pin to quonfig.json: ${String(error)}`);
|
|
175
|
-
this.log('The workspace is connected, but you should re-run `qfg pull` to backfill the pin.');
|
|
179
|
+
this.log(`Landed ${pushResult.commitsRebased} commit(s) on the workspace's history.`);
|
|
180
|
+
if (pushResult.reconcileSubject !== null) {
|
|
181
|
+
this.log(`Added one commit ("${pushResult.reconcileSubject}") so the workspace tree matches your local files.`);
|
|
176
182
|
}
|
|
177
183
|
this.log(`\nBootstrap complete.`);
|
|
178
|
-
this.log(`Workspace "${workspaceName}"
|
|
179
|
-
|
|
180
|
-
|
|
184
|
+
this.log(`Workspace "${workspaceName}" now holds the history from ${resolvedDir}.`);
|
|
185
|
+
// The customer's branch is never touched, on success or failure, so it
|
|
186
|
+
// still points at the pre-bootstrap history — which shares no commit with
|
|
187
|
+
// what was just pushed (the replayed commits have new ids). `qfg pull` and
|
|
188
|
+
// `qfg sync` both refuse that state ("diverge" / STALE_HEAD), so the only
|
|
189
|
+
// honest advice is a reset onto the workspace.
|
|
190
|
+
//
|
|
191
|
+
// If we ever move the local branch ourselves (option A: save
|
|
192
|
+
// refs/quonfig/pre-bootstrap and fast-forward `main` to pushedSha once
|
|
193
|
+
// tree equality is proven), this block is what it replaces.
|
|
194
|
+
this.log(`\nYour local branch was not moved. The workspace has the same content under new commit ids.`);
|
|
195
|
+
this.log(`To point this clone at the workspace, keeping your old history on a branch:`);
|
|
196
|
+
this.log(` ${LOCAL_RESET_RECIPE}`);
|
|
181
197
|
return {
|
|
198
|
+
commitsRebased: pushResult.commitsRebased,
|
|
182
199
|
dir: resolvedDir,
|
|
200
|
+
localResetRecipe: LOCAL_RESET_RECIPE,
|
|
201
|
+
reconciled: pushResult.reconcileSubject !== null,
|
|
183
202
|
repoUrl: displayUrl(repoUrl),
|
|
184
203
|
workspaceId,
|
|
185
204
|
};
|
|
186
205
|
}
|
|
187
|
-
/**
|
|
188
|
-
* Stage, commit, and push the `quonfig.json` pin. Runs after the main
|
|
189
|
-
* bootstrap push so the pin lands as a committed+pushed change rather than
|
|
190
|
-
* an uncommitted local edit. We stage only `quonfig.json` (not `-A`) so we
|
|
191
|
-
* don't accidentally sweep in unrelated user edits.
|
|
192
|
-
*/
|
|
193
|
-
async commitAndPushPin(dir, slug, force) {
|
|
194
|
-
// Stage just quonfig.json so we don't accidentally commit other dirty files.
|
|
195
|
-
await runGit(['-C', dir, 'add', 'quonfig.json']);
|
|
196
|
-
// If `git add` produced no staged change (e.g. content matched an earlier
|
|
197
|
-
// version on disk), `git commit` would fail. Check the index first.
|
|
198
|
-
const { stdout: diffStat } = await runGit(['-C', dir, 'diff', '--cached', '--name-only']);
|
|
199
|
-
if (!diffStat.trim()) {
|
|
200
|
-
this.verboseLog('WorkspaceBootstrap', 'quonfig.json pin matches HEAD; no commit needed.');
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
await runGit(['-C', dir, 'commit', '-m', `chore: pin quonfig.json to workspace "${slug}"`]);
|
|
204
|
-
// Push the new commit using the same force semantics as the main push.
|
|
205
|
-
const pushArgs = ['-C', dir, 'push', 'origin', 'main'];
|
|
206
|
-
pushArgs.push(force ? '--force' : '--force-with-lease');
|
|
207
|
-
await runGit(pushArgs);
|
|
208
|
-
this.log(`Pushed pin commit.`);
|
|
209
|
-
}
|
|
210
|
-
async remoteHasCommits(repoUrl) {
|
|
211
|
-
const { stdout } = await runGit(['ls-remote', '--heads', repoUrl]);
|
|
212
|
-
return stdout.trim().length > 0;
|
|
213
|
-
}
|
|
214
206
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `qfg workspace bootstrap` tells the user about the workspace it is
|
|
3
|
+
* about to push to, and the one check that stops it landing content in the
|
|
4
|
+
* wrong place.
|
|
5
|
+
*
|
|
6
|
+
* The target is resolved the same way `qfg push` / `qfg pull` / `qfg sync`
|
|
7
|
+
* resolve theirs: `QUONFIG_WORKSPACE`, then the directory's `quonfig.json`
|
|
8
|
+
* pin, then the active profile. The prompt must name whichever of those won;
|
|
9
|
+
* naming the profile when the env var chose another workspace is how a
|
|
10
|
+
* customer confirms a push to the wrong place (qfg-8p8i).
|
|
11
|
+
*/
|
|
12
|
+
import type { WorkspacePin } from './quonfig-json.js';
|
|
13
|
+
export interface BootstrapProfile {
|
|
14
|
+
organizationSlug?: string;
|
|
15
|
+
workspace: string;
|
|
16
|
+
workspaceName?: string;
|
|
17
|
+
workspaceSlug?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface BootstrapTargetInput {
|
|
20
|
+
/** `QUONFIG_WORKSPACE`, when set: it outranks everything else. */
|
|
21
|
+
envOverride: string | undefined;
|
|
22
|
+
/** The `--dir` directory's `quonfig.json` pin, when readable. */
|
|
23
|
+
pin: WorkspacePin | undefined;
|
|
24
|
+
/** The active profile, if any. Only trusted when it IS the resolved workspace. */
|
|
25
|
+
profile: BootstrapProfile | undefined;
|
|
26
|
+
/** What the resolver actually returned. */
|
|
27
|
+
workspaceId: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The name to show for the resolved workspace, from whichever source chose
|
|
31
|
+
* it. Falls back to the id rather than to a profile that points elsewhere.
|
|
32
|
+
*/
|
|
33
|
+
export declare const describeBootstrapTarget: (input: BootstrapTargetInput) => string;
|
|
34
|
+
/**
|
|
35
|
+
* `qfg push`'s Guard 1 for bootstrap: a directory pinned to one workspace is
|
|
36
|
+
* not pushed to another. Compares the workspace component only, as push does
|
|
37
|
+
* (backend responses do not carry the org slug yet). Returns the refusal
|
|
38
|
+
* message, or nothing when there is no pin or it matches.
|
|
39
|
+
*/
|
|
40
|
+
export declare const bootstrapPinMismatch: (pin: WorkspacePin | undefined, backendWorkspaceSlug: string) => string | undefined;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `qfg workspace bootstrap` tells the user about the workspace it is
|
|
3
|
+
* about to push to, and the one check that stops it landing content in the
|
|
4
|
+
* wrong place.
|
|
5
|
+
*
|
|
6
|
+
* The target is resolved the same way `qfg push` / `qfg pull` / `qfg sync`
|
|
7
|
+
* resolve theirs: `QUONFIG_WORKSPACE`, then the directory's `quonfig.json`
|
|
8
|
+
* pin, then the active profile. The prompt must name whichever of those won;
|
|
9
|
+
* naming the profile when the env var chose another workspace is how a
|
|
10
|
+
* customer confirms a push to the wrong place (qfg-8p8i).
|
|
11
|
+
*/
|
|
12
|
+
/** The pin form of a workspace, `<org>/<workspace>`. */
|
|
13
|
+
const pinForm = (pin) => `${pin.orgSlug}/${pin.workspaceSlug}`;
|
|
14
|
+
/**
|
|
15
|
+
* The name to show for the resolved workspace, from whichever source chose
|
|
16
|
+
* it. Falls back to the id rather than to a profile that points elsewhere.
|
|
17
|
+
*/
|
|
18
|
+
export const describeBootstrapTarget = (input) => {
|
|
19
|
+
if (input.envOverride)
|
|
20
|
+
return input.envOverride;
|
|
21
|
+
if (input.pin)
|
|
22
|
+
return pinForm(input.pin);
|
|
23
|
+
const { profile } = input;
|
|
24
|
+
if (profile && profile.workspace === input.workspaceId) {
|
|
25
|
+
if (profile.workspaceSlug) {
|
|
26
|
+
return profile.organizationSlug ? `${profile.organizationSlug}/${profile.workspaceSlug}` : profile.workspaceSlug;
|
|
27
|
+
}
|
|
28
|
+
if (profile.workspaceName)
|
|
29
|
+
return profile.workspaceName;
|
|
30
|
+
}
|
|
31
|
+
return input.workspaceId;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* `qfg push`'s Guard 1 for bootstrap: a directory pinned to one workspace is
|
|
35
|
+
* not pushed to another. Compares the workspace component only, as push does
|
|
36
|
+
* (backend responses do not carry the org slug yet). Returns the refusal
|
|
37
|
+
* message, or nothing when there is no pin or it matches.
|
|
38
|
+
*/
|
|
39
|
+
export const bootstrapPinMismatch = (pin, backendWorkspaceSlug) => {
|
|
40
|
+
if (!pin || pin.workspaceSlug === backendWorkspaceSlug)
|
|
41
|
+
return undefined;
|
|
42
|
+
return (`This directory's quonfig.json is pinned to workspace "${pinForm(pin)}", ` +
|
|
43
|
+
`but the target workspace is "${backendWorkspaceSlug}".\n` +
|
|
44
|
+
`Nothing was pushed. Unset QUONFIG_WORKSPACE to bootstrap the pinned workspace, ` +
|
|
45
|
+
`or change the pin in quonfig.json if "${backendWorkspaceSlug}" is really the target.`);
|
|
46
|
+
};
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
-
import { runGit as runGitSafe, spawnGit } from './git-ops.js';
|
|
4
|
-
export
|
|
5
|
-
name: 'quonfig migrator',
|
|
6
|
-
email: 'migrator@quonfig.com',
|
|
7
|
-
};
|
|
3
|
+
import { MIGRATOR_IDENTITY, runGit as runGitSafe, spawnGit } from './git-ops.js';
|
|
4
|
+
export { MIGRATOR_IDENTITY } from './git-ops.js';
|
|
8
5
|
const redactToken = (s) => s.replaceAll(/:([^\s/@]+)@/g, ':***@');
|
|
9
6
|
export class PushConflictError extends Error {
|
|
10
7
|
constructor(message) {
|
package/dist/util/git-ops.d.ts
CHANGED
|
@@ -142,8 +142,66 @@ export declare const gitMergeFfOnly: (dir: string) => Promise<string[]>;
|
|
|
142
142
|
* Adds or updates the origin remote for a repo.
|
|
143
143
|
*/
|
|
144
144
|
export declare const gitSetRemote: (dir: string, url: string) => Promise<void>;
|
|
145
|
-
|
|
146
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Every file the validator owns at `ref` — config documents (`configs/`,
|
|
147
|
+
* `feature-flags/`, `segments/`, `log-levels/`) AND schemas (`schemas/`,
|
|
148
|
+
* `schemas-protected/`) — sorted.
|
|
149
|
+
*
|
|
150
|
+
* A workspace repo is FRESH exactly when this list is empty. It is NOT "the
|
|
151
|
+
* remote holds only the auto-init commit": provisioning commits `README.md`
|
|
152
|
+
* AND `quonfig.json`, so a commit-count test refuses every real workspace
|
|
153
|
+
* (plan project/plans/2026-09-17-tree-derived-cache.md 5.6, 13.2 risk 5).
|
|
154
|
+
* Schemas count because a UI-authored schema is content a local-wins rebase
|
|
155
|
+
* would silently overwrite, exactly like a flag.
|
|
156
|
+
*
|
|
157
|
+
* FAILS CLOSED: if the ref cannot be listed this throws. "I could not look"
|
|
158
|
+
* must never read as "the workspace is empty".
|
|
159
|
+
*/
|
|
160
|
+
export declare const workspaceDocumentsAtRef: (dir: string, ref: string) => Promise<string[]>;
|
|
161
|
+
export interface RebaseOntoOriginResult {
|
|
162
|
+
/** How many of the customer's commits were replayed onto the remote head. */
|
|
163
|
+
commitsRebased: number;
|
|
164
|
+
/** The sha now at `origin/main`. */
|
|
165
|
+
pushedSha: string;
|
|
166
|
+
/** Subject of the extra commit that put the tree right, or null if none was needed. */
|
|
167
|
+
reconcileSubject: null | string;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The identity the CLI commits as when nothing better is known: `qfg push`
|
|
171
|
+
* and `qfg migrate` stack their commits under it, and bootstrap's replayed
|
|
172
|
+
* commits get it as COMMITTER when the machine has no git identity at all.
|
|
173
|
+
*/
|
|
174
|
+
export declare const MIGRATOR_IDENTITY: {
|
|
175
|
+
readonly name: "quonfig migrator";
|
|
176
|
+
readonly email: "migrator@quonfig.com";
|
|
177
|
+
};
|
|
178
|
+
/**
|
|
179
|
+
* Land the local repo's history on `origin/main` WITHOUT ever force-pushing
|
|
180
|
+
* (plan 5.6 "Bootstrap keeps its history without force"; `main` is
|
|
181
|
+
* append-only, so a rewrite is not just forbidden, it silently wedges config
|
|
182
|
+
* delivery).
|
|
183
|
+
*
|
|
184
|
+
* `git fetch origin` is the caller's job. Then:
|
|
185
|
+
*
|
|
186
|
+
* 1. Replay the whole local history onto the remote head on a TEMPORARY
|
|
187
|
+
* worktree — never on the customer's branch. Whatever happens, their
|
|
188
|
+
* local refs and working tree are untouched.
|
|
189
|
+
* 2. Require the replayed tip's tree to equal the local tip's tree, ignoring
|
|
190
|
+
* files the workspace seeded that the local repo does not have. Rebasing
|
|
191
|
+
* linearises merges and `-X theirs` silently picks a side, so a
|
|
192
|
+
* hand-resolved merge conflict can otherwise ship the wrong content with
|
|
193
|
+
* exit 0 (13.2 risk 4).
|
|
194
|
+
* 3. If it differs, add ONE commit that sets the tree to the local tip's
|
|
195
|
+
* tree, keeping the seeded files and the workspace's `quonfig.json`. If
|
|
196
|
+
* that cannot be done cleanly, abort with nothing pushed.
|
|
197
|
+
* 4. Validate the tree that is ACTUALLY about to be pushed (unless
|
|
198
|
+
* `validate: false`), so content the workspace would reject fails here
|
|
199
|
+
* instead of at the server hook.
|
|
200
|
+
* 5. Plain `git push` of the result to `main`.
|
|
201
|
+
*/
|
|
202
|
+
export declare const rebaseOntoOriginAndPush: (dir: string, opts?: {
|
|
203
|
+
validate?: boolean;
|
|
204
|
+
}) => Promise<RebaseOntoOriginResult>;
|
|
147
205
|
export declare const hasAtLeastOneCommit: (dir: string) => Promise<boolean>;
|
|
148
206
|
/**
|
|
149
207
|
* Get the URL stripped of credentials for display purposes.
|