@quonfig/cli 0.0.33 → 0.0.35

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.
@@ -32,11 +32,54 @@ const ensureLocalRepo = async (localDir, branch) => {
32
32
  await runGit(['init', `--initial-branch=${branch}`, localDir]);
33
33
  return 'initialized';
34
34
  };
35
- const writeQuonfigFiles = (dir, changes, source) => {
35
+ export class MigratorKeyCollisionError extends Error {
36
+ destPath;
37
+ firstSourceKey;
38
+ secondSourceKey;
39
+ constructor(destPath, firstSourceKey, secondSourceKey) {
40
+ super(`Two source keys collide on destination ${destPath} after key normalization: ` +
41
+ `"${firstSourceKey}" and "${secondSourceKey}". Quonfig keys must be globally unique within a type tree. ` +
42
+ `Rename one in the source system and re-run.`);
43
+ this.destPath = destPath;
44
+ this.firstSourceKey = firstSourceKey;
45
+ this.secondSourceKey = secondSourceKey;
46
+ this.name = 'MigratorKeyCollisionError';
47
+ }
48
+ }
49
+ const ensureNoNestedPath = (relativePath) => {
50
+ // qfg-qhk1: every output must be exactly `<type-dir>/<flat-key>.json` —
51
+ // never nested. If we ever emit a nested path, refuse to write so we surface
52
+ // it loudly rather than silently producing dirs that lose the flat-file
53
+ // contract (and break tombstone cleanup on subsequent runs).
54
+ const parts = relativePath.split('/');
55
+ if (parts.length !== 2) {
56
+ throw new Error(`Migrator refusing to write nested path "${relativePath}" — outputs must be flat <type-dir>/<key>.json. ` +
57
+ `This is a bug in the source's translate() — it should normalize key separators before computing the path.`);
58
+ }
59
+ };
60
+ const rmdirIfEmpty = (dir) => {
61
+ try {
62
+ fs.rmdirSync(dir);
63
+ }
64
+ catch (error) {
65
+ const e = error;
66
+ // ENOTEMPTY: still has contents (expected, leave alone). ENOENT: already gone.
67
+ if (e.code !== 'ENOTEMPTY' && e.code !== 'ENOENT')
68
+ throw error;
69
+ }
70
+ };
71
+ export const writeQuonfigFiles = (dir, changes, source) => {
36
72
  const livePaths = new Map();
73
+ // qfg-qhk1: track which source key wrote each destination path. If a second,
74
+ // different source key tries to write to the same destination, that is a
75
+ // post-normalization collision (e.g. source had keys `foo/bar` and `foo.bar`,
76
+ // both normalize to `foo.bar`). Throw with a clear message.
77
+ const pathOwners = new Map();
37
78
  for (const change of changes) {
38
79
  const files = source.translate(change);
80
+ const sourceKey = change.key ?? '(unknown)';
39
81
  for (const file of files) {
82
+ ensureNoNestedPath(file.path);
40
83
  const full = path.join(dir, file.path);
41
84
  if (file.deleted) {
42
85
  try {
@@ -47,14 +90,25 @@ const writeQuonfigFiles = (dir, changes, source) => {
47
90
  throw error;
48
91
  }
49
92
  livePaths.delete(file.path);
93
+ pathOwners.delete(file.path);
94
+ // qfg-qhk1: clean up any empty parent dirs left over from prior runs
95
+ // that wrote under a nested layout. (New runs never create nested
96
+ // dirs because of normalizeKey + ensureNoNestedPath, but stale dirs
97
+ // from older versions on disk would otherwise persist.)
98
+ rmdirIfEmpty(path.dirname(full));
50
99
  }
51
100
  else {
52
101
  if (file.contents === undefined) {
53
102
  throw new Error(`translate() emitted a write op for ${file.path} without contents`);
54
103
  }
104
+ const existingOwner = pathOwners.get(file.path);
105
+ if (existingOwner !== undefined && existingOwner !== sourceKey) {
106
+ throw new MigratorKeyCollisionError(file.path, existingOwner, sourceKey);
107
+ }
55
108
  fs.mkdirSync(path.dirname(full), { recursive: true });
56
109
  fs.writeFileSync(full, file.contents);
57
110
  livePaths.set(file.path, true);
111
+ pathOwners.set(file.path, sourceKey);
58
112
  }
59
113
  }
60
114
  }
@@ -71,7 +125,47 @@ const writeQuonfigFiles = (dir, changes, source) => {
71
125
  livePaths.delete(toDelete);
72
126
  }
73
127
  }
74
- return resolutions;
128
+ return { livePaths: [...livePaths.keys()], resolutions };
129
+ };
130
+ const countsFromLivePaths = (livePaths) => {
131
+ let flagsMigrated = 0;
132
+ let configsMigrated = 0;
133
+ let segmentsMigrated = 0;
134
+ let schemasMigrated = 0;
135
+ let logLevelsMigrated = 0;
136
+ for (const p of livePaths) {
137
+ if (p.startsWith('feature-flags/'))
138
+ flagsMigrated++;
139
+ else if (p.startsWith('configs/'))
140
+ configsMigrated++;
141
+ else if (p.startsWith('segments/'))
142
+ segmentsMigrated++;
143
+ else if (p.startsWith('schemas/'))
144
+ schemasMigrated++;
145
+ else if (p.startsWith('log-levels/'))
146
+ logLevelsMigrated++;
147
+ }
148
+ return { configsMigrated, flagsMigrated, logLevelsMigrated, schemasMigrated, segmentsMigrated };
149
+ };
150
+ export const buildMigrationCounts = (livePaths, environmentsMapped, itemsSkipped) => ({
151
+ ...countsFromLivePaths(livePaths),
152
+ environmentsMapped,
153
+ itemsSkipped,
154
+ });
155
+ export const buildMigrationCommitMessage = (source, counts) => {
156
+ const parts = [];
157
+ if (counts.flagsMigrated > 0)
158
+ parts.push(`${counts.flagsMigrated} flag(s)`);
159
+ if (counts.configsMigrated > 0)
160
+ parts.push(`${counts.configsMigrated} config(s)`);
161
+ if (counts.segmentsMigrated > 0)
162
+ parts.push(`${counts.segmentsMigrated} segment(s)`);
163
+ if (counts.schemasMigrated > 0)
164
+ parts.push(`${counts.schemasMigrated} schema(s)`);
165
+ if (counts.logLevelsMigrated > 0)
166
+ parts.push(`${counts.logLevelsMigrated} log-level(s)`);
167
+ const summary = parts.length === 0 ? 'no objects' : parts.join(', ');
168
+ return `migrator: imported ${summary} from ${source}`;
75
169
  };
76
170
  export const applyLocalMigration = async (opts) => {
77
171
  const branch = opts.branch ?? 'main';
@@ -80,15 +174,18 @@ export const applyLocalMigration = async (opts) => {
80
174
  await runGit(['-C', opts.localDir, 'config', 'user.name', author.name]);
81
175
  await runGit(['-C', opts.localDir, 'config', 'user.email', author.email]);
82
176
  ensureQuonfigJson(opts.localDir, opts.environments);
83
- const resolutionEntries = writeQuonfigFiles(opts.localDir, opts.changes, opts.source);
177
+ const { livePaths, resolutions: resolutionEntries } = writeQuonfigFiles(opts.localDir, opts.changes, opts.source);
84
178
  removeQfFromGitignore(opts.localDir);
85
179
  writeImportState(opts.localDir, opts.importState);
86
180
  const droppedOverrides = opts.source.getDroppedOverrides?.() ?? null;
87
181
  const skippedConfigs = opts.source.getSkippedConfigs?.() ?? null;
88
182
  const coercedSentinels = opts.source.getCoercedSentinels?.() ?? null;
89
183
  const duplicateResolutions = resolutionEntries.length > 0 ? { entries: resolutionEntries, total: resolutionEntries.length } : null;
184
+ const skippedTotal = (skippedConfigs?.total ?? 0) + resolutionEntries.reduce((sum, r) => sum + r.deleted.length, 0);
185
+ const counts = buildMigrationCounts(livePaths, opts.environments.length, skippedTotal);
90
186
  const reportData = {
91
187
  ...opts.reportData,
188
+ counts,
92
189
  ...(coercedSentinels ? { coercedSentinels } : {}),
93
190
  ...(droppedOverrides ? { droppedOverrides } : {}),
94
191
  ...(duplicateResolutions ? { duplicateResolutions } : {}),
@@ -107,8 +204,9 @@ export const applyLocalMigration = async (opts) => {
107
204
  skippedConfigs,
108
205
  };
109
206
  }
207
+ const commitMessage = buildMigrationCommitMessage(opts.source.name, counts);
110
208
  await spawnGit(['-C', opts.localDir, 'commit', '-F', '-'], {
111
- stdin: opts.commitMessage,
209
+ stdin: commitMessage,
112
210
  env: {
113
211
  GIT_AUTHOR_EMAIL: author.email,
114
212
  GIT_AUTHOR_NAME: author.name,
@@ -1,9 +1,12 @@
1
1
  import type { IdentifierMap } from './identifier-map.js';
2
2
  import type { CoercedSentinelSummary, DroppedOverrideSummary, DuplicateResolutionSummary, SkippedConfigSummary } from './source.js';
3
3
  export interface MigrationReportCounts {
4
+ configsMigrated: number;
4
5
  environmentsMapped: number;
5
6
  flagsMigrated: number;
6
7
  itemsSkipped: number;
8
+ logLevelsMigrated: number;
9
+ schemasMigrated: number;
7
10
  segmentsMigrated: number;
8
11
  }
9
12
  export interface CleanMapping {
@@ -5,7 +5,10 @@ const renderCounts = (counts) => [
5
5
  '## Counts',
6
6
  '',
7
7
  `- Flags migrated: ${counts.flagsMigrated}`,
8
+ `- Configs migrated: ${counts.configsMigrated}`,
8
9
  `- Segments migrated: ${counts.segmentsMigrated}`,
10
+ `- Schemas migrated: ${counts.schemasMigrated}`,
11
+ `- Log levels migrated: ${counts.logLevelsMigrated}`,
9
12
  `- Environments mapped: ${counts.environmentsMapped}`,
10
13
  `- Items skipped: ${counts.itemsSkipped}`,
11
14
  ].join('\n');
@@ -17,7 +17,6 @@ export declare class MigratorVerifyError extends Error {
17
17
  export interface PushMigrationToCloudOptions {
18
18
  branch?: string;
19
19
  changes: LegacyChange[];
20
- commitMessage: string;
21
20
  /**
22
21
  * Source-side environments (slugified). Additively merged into the target
23
22
  * workspace's quonfig.json so flag files that reference these envs verify
@@ -27,6 +26,10 @@ export interface PushMigrationToCloudOptions {
27
26
  importState: ImportState;
28
27
  localDir: string;
29
28
  remoteUrl: string;
29
+ /**
30
+ * Base report data. The migrator overrides `counts` after writing files,
31
+ * since counts must reflect what was actually written to disk (qfg-7eig).
32
+ */
30
33
  reportData: MigrationReportData;
31
34
  source: MigrationSource;
32
35
  }
@@ -1,8 +1,8 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { removeQfFromGitignore, writeImportState } from './import-state.js';
4
+ import { buildMigrationCommitMessage, buildMigrationCounts, writeQuonfigFiles } from './local-write.js';
4
5
  import { writeMigrationReport } from './migration-report.js';
5
- import { detectDuplicateKeys } from './sources/launch/translate.js';
6
6
  import { cloneAndStackPush, } from '../util/clone-and-stack-push.js';
7
7
  import { formatResult, validateWorkspace } from '../verify/validate.js';
8
8
  /**
@@ -23,47 +23,6 @@ export class MigratorVerifyError extends Error {
23
23
  this.result = result;
24
24
  }
25
25
  }
26
- const writeQuonfigFiles = (dir, changes, source) => {
27
- const livePaths = new Map();
28
- for (const change of changes) {
29
- const files = source.translate(change);
30
- for (const file of files) {
31
- const full = path.join(dir, file.path);
32
- if (file.deleted) {
33
- try {
34
- fs.unlinkSync(full);
35
- }
36
- catch (error) {
37
- if (error.code !== 'ENOENT')
38
- throw error;
39
- }
40
- livePaths.delete(file.path);
41
- }
42
- else {
43
- if (file.contents === undefined) {
44
- throw new Error(`translate() emitted a write op for ${file.path} without contents`);
45
- }
46
- fs.mkdirSync(path.dirname(full), { recursive: true });
47
- fs.writeFileSync(full, file.contents);
48
- livePaths.set(file.path, true);
49
- }
50
- }
51
- }
52
- const resolutions = detectDuplicateKeys([...livePaths.keys()].map((p) => ({ path: p })));
53
- for (const resolution of resolutions) {
54
- for (const toDelete of resolution.deleted) {
55
- try {
56
- fs.unlinkSync(path.join(dir, toDelete));
57
- }
58
- catch (error) {
59
- if (error.code !== 'ENOENT')
60
- throw error;
61
- }
62
- livePaths.delete(toDelete);
63
- }
64
- }
65
- return resolutions;
66
- };
67
26
  const QUONFIG_JSON_FILENAME = 'quonfig.json';
68
27
  const mergeEnvironmentsIntoQuonfigJson = (dir, sourceEnvs) => {
69
28
  const quonfigPath = path.join(dir, QUONFIG_JSON_FILENAME);
@@ -88,12 +47,13 @@ export const pushMigrationToCloud = async (opts) => {
88
47
  let droppedOverrides = null;
89
48
  let duplicateResolutions = null;
90
49
  let skippedConfigs = null;
50
+ let computedCommitMessage = `migrator: imported 0 objects from ${opts.source.name}`;
91
51
  const cloneOpts = {
92
52
  applyDelta(dir) {
93
53
  if (opts.environments && opts.environments.length > 0) {
94
54
  mergeEnvironmentsIntoQuonfigJson(dir, opts.environments);
95
55
  }
96
- const resolutionEntries = writeQuonfigFiles(dir, opts.changes, opts.source);
56
+ const { livePaths, resolutions: resolutionEntries } = writeQuonfigFiles(dir, opts.changes, opts.source);
97
57
  removeQfFromGitignore(dir);
98
58
  writeImportState(dir, opts.importState);
99
59
  droppedOverrides = opts.source.getDroppedOverrides?.() ?? null;
@@ -101,8 +61,13 @@ export const pushMigrationToCloud = async (opts) => {
101
61
  coercedSentinels = opts.source.getCoercedSentinels?.() ?? null;
102
62
  duplicateResolutions =
103
63
  resolutionEntries.length > 0 ? { entries: resolutionEntries, total: resolutionEntries.length } : null;
64
+ const skippedTotal = (skippedConfigs?.total ?? 0) + resolutionEntries.reduce((sum, r) => sum + r.deleted.length, 0);
65
+ const environmentsCount = opts.environments?.length ?? 0;
66
+ const counts = buildMigrationCounts(livePaths, environmentsCount, skippedTotal);
67
+ computedCommitMessage = buildMigrationCommitMessage(opts.source.name, counts);
104
68
  const reportData = {
105
69
  ...opts.reportData,
70
+ counts,
106
71
  ...(coercedSentinels ? { coercedSentinels } : {}),
107
72
  ...(droppedOverrides ? { droppedOverrides } : {}),
108
73
  ...(duplicateResolutions ? { duplicateResolutions } : {}),
@@ -117,7 +82,7 @@ export const pushMigrationToCloud = async (opts) => {
117
82
  if (!verifyResult.valid)
118
83
  throw new MigratorVerifyError(verifyResult);
119
84
  },
120
- commitMessage: opts.commitMessage,
85
+ commitMessage: () => computedCommitMessage,
121
86
  localDir: opts.localDir,
122
87
  remoteUrl: opts.remoteUrl,
123
88
  };
@@ -11,6 +11,22 @@ export declare class InvalidSourceConfigError extends Error {
11
11
  export declare function slugify(value: string): string;
12
12
  export declare function isLegacyLogLevel(type: string): boolean;
13
13
  export declare function normalizeLogLevelKey(key: string): string;
14
+ /**
15
+ * qfg-qhk1: Quonfig keys cannot contain `/` — keys are stored as flat dotted
16
+ * filenames, and a `/` in a source key would create a nested directory tree
17
+ * (data-loss-on-tombstone, cross-key collisions). Normalize `/` to `.` so the
18
+ * destination is always flat.
19
+ */
20
+ export declare function normalizeKey(key: string): string;
21
+ /**
22
+ * qfg-l18w: Operators Quonfig accepts in rule criteria. Mirrors the
23
+ * `OperatorSchema` enum in cli/src/verify/validate.ts (and app-quonfig's
24
+ * config-schemas.ts). If we emit an operator outside this set, qfg verify
25
+ * fails — which is what was happening for `PROP_SEMVER_*` in the FormHealth
26
+ * dry-run before this check existed. Keep this list in lockstep with the
27
+ * verify schema; a CI test in this file enforces parity.
28
+ */
29
+ export declare const QUONFIG_SUPPORTED_OPERATORS: Set<string>;
14
30
  export declare function transformConfig(config: LaunchConfig, envIdMap: Record<string, string>, onDroppedEnv?: (envId: string) => void, onCoercedSentinel?: (envId: string, valueType: string) => void): Record<string, unknown>;
15
31
  export declare function getOutputPath(type: string, key: string): string;
16
32
  /**
@@ -23,6 +23,106 @@ export function isLegacyLogLevel(type) {
23
23
  export function normalizeLogLevelKey(key) {
24
24
  return key.startsWith('log-level.') ? key : `log-level.${key}`;
25
25
  }
26
+ /**
27
+ * qfg-qhk1: Quonfig keys cannot contain `/` — keys are stored as flat dotted
28
+ * filenames, and a `/` in a source key would create a nested directory tree
29
+ * (data-loss-on-tombstone, cross-key collisions). Normalize `/` to `.` so the
30
+ * destination is always flat.
31
+ */
32
+ export function normalizeKey(key) {
33
+ return key.replaceAll('/', '.');
34
+ }
35
+ /**
36
+ * qfg-l18w: Operators Quonfig accepts in rule criteria. Mirrors the
37
+ * `OperatorSchema` enum in cli/src/verify/validate.ts (and app-quonfig's
38
+ * config-schemas.ts). If we emit an operator outside this set, qfg verify
39
+ * fails — which is what was happening for `PROP_SEMVER_*` in the FormHealth
40
+ * dry-run before this check existed. Keep this list in lockstep with the
41
+ * verify schema; a CI test in this file enforces parity.
42
+ */
43
+ export const QUONFIG_SUPPORTED_OPERATORS = new Set([
44
+ 'ALWAYS_TRUE',
45
+ 'PROP_IS_ONE_OF',
46
+ 'PROP_IS_NOT_ONE_OF',
47
+ 'PROP_STARTS_WITH_ONE_OF',
48
+ 'PROP_DOES_NOT_START_WITH_ONE_OF',
49
+ 'PROP_ENDS_WITH_ONE_OF',
50
+ 'PROP_DOES_NOT_END_WITH_ONE_OF',
51
+ 'PROP_CONTAINS_ONE_OF',
52
+ 'PROP_DOES_NOT_CONTAIN_ONE_OF',
53
+ 'PROP_LESS_THAN',
54
+ 'PROP_LESS_THAN_OR_EQUAL',
55
+ 'PROP_GREATER_THAN',
56
+ 'PROP_GREATER_THAN_OR_EQUAL',
57
+ 'PROP_BEFORE',
58
+ 'PROP_AFTER',
59
+ 'PROP_MATCHES',
60
+ 'PROP_DOES_NOT_MATCH',
61
+ 'IN_SEG',
62
+ 'NOT_IN_SEG',
63
+ 'IN_INT_RANGE',
64
+ 'LOOKUP_KEY_IN',
65
+ 'LOOKUP_KEY_NOT_IN',
66
+ ]);
67
+ /**
68
+ * qfg-l18w: Walk every rule criterion in a transformed config and fail-fast at
69
+ * migrate time if it carries an operator Quonfig does not understand. The
70
+ * primary offender today is `PROP_SEMVER_*` (Launch supports semver
71
+ * comparisons; Quonfig does not have a semver operator at all). Without this
72
+ * check the migrator silently writes invalid JSON and the failure surfaces
73
+ * later at qfg-verify — the customer's local file is broken and re-runs
74
+ * re-clobber any manual fix.
75
+ *
76
+ * Routes through `InvalidSourceConfigError` so the failure is per-config (the
77
+ * rest of the run still produces a usable workspace, and the offending key
78
+ * lands in MIGRATION_REPORT.md's "Skipped invalid configs" section with a
79
+ * specific reason — exactly what the customer needs to take to support).
80
+ */
81
+ function collectUnsupportedOperators(rules) {
82
+ if (!Array.isArray(rules))
83
+ return [];
84
+ const found = [];
85
+ for (const rule of rules) {
86
+ if (!rule || typeof rule !== 'object')
87
+ continue;
88
+ const r = rule;
89
+ if (!Array.isArray(r.criteria))
90
+ continue;
91
+ for (const crit of r.criteria) {
92
+ if (!crit || typeof crit !== 'object')
93
+ continue;
94
+ const c = crit;
95
+ if (typeof c.operator !== 'string')
96
+ continue;
97
+ if (!QUONFIG_SUPPORTED_OPERATORS.has(c.operator)) {
98
+ found.push(c.operator);
99
+ }
100
+ }
101
+ }
102
+ return found;
103
+ }
104
+ function validateOperators(out) {
105
+ const offenders = new Set();
106
+ const defaultSection = out.default;
107
+ if (defaultSection) {
108
+ for (const op of collectUnsupportedOperators(defaultSection.rules))
109
+ offenders.add(op);
110
+ }
111
+ if (Array.isArray(out.environments)) {
112
+ for (const env of out.environments) {
113
+ for (const op of collectUnsupportedOperators(env.rules))
114
+ offenders.add(op);
115
+ }
116
+ }
117
+ if (offenders.size === 0)
118
+ return;
119
+ const key = String(out.key ?? 'unknown');
120
+ const sorted = [...offenders].sort();
121
+ throw new InvalidSourceConfigError(`Unsupported operator(s) for "${key}": ${sorted.join(', ')}. ` +
122
+ `Quonfig does not currently translate these from the source schema. ` +
123
+ `Recreate the rule in the source system using a supported operator (see qfg config-schema), ` +
124
+ `or contact support@quonfig.com if you need this operator added to Quonfig.`);
125
+ }
26
126
  function zeroValue(valueType) {
27
127
  switch (valueType) {
28
128
  case 'bool': {
@@ -148,6 +248,11 @@ function normalizeJsonValuesInConfig(out) {
148
248
  }
149
249
  export function transformConfig(config, envIdMap, onDroppedEnv, onCoercedSentinel) {
150
250
  const out = JSON.parse(JSON.stringify(config));
251
+ // qfg-qhk1: normalize `/` to `.` in the key so the JSON `key` field matches
252
+ // the flat dotted filename the migrator will write.
253
+ if (typeof out.key === 'string') {
254
+ out.key = normalizeKey(out.key);
255
+ }
151
256
  delete out.changedBy;
152
257
  delete out.modifiedAt;
153
258
  delete out.createdAt;
@@ -244,25 +349,27 @@ export function transformConfig(config, envIdMap, onDroppedEnv, onCoercedSentine
244
349
  }
245
350
  }
246
351
  normalizeJsonValuesInConfig(out);
352
+ validateOperators(out);
247
353
  return out;
248
354
  }
249
355
  export function getOutputPath(type, key) {
250
356
  const normalizedType = type.toUpperCase();
357
+ const k = normalizeKey(key);
251
358
  switch (normalizedType) {
252
359
  case 'FEATURE_FLAG': {
253
- return `feature-flags/${key}.json`;
360
+ return `feature-flags/${k}.json`;
254
361
  }
255
362
  case 'LOG_LEVEL_V2': {
256
- return `log-levels/${normalizeLogLevelKey(key)}.json`;
363
+ return `log-levels/${normalizeLogLevelKey(k)}.json`;
257
364
  }
258
365
  case 'SCHEMA': {
259
- return `schemas/${key}.json`;
366
+ return `schemas/${k}.json`;
260
367
  }
261
368
  case 'SEGMENT': {
262
- return `segments/${key}.json`;
369
+ return `segments/${k}.json`;
263
370
  }
264
371
  default: {
265
- return `configs/${key}.json`;
372
+ return `configs/${k}.json`;
266
373
  }
267
374
  }
268
375
  }
@@ -42,6 +42,23 @@ export interface GitOps {
42
42
  getRemoteOriginUrl(dir: string): Promise<string | undefined>;
43
43
  /** Returns true if the dir has a `.git/` (worktree or repo). */
44
44
  isGitRepo(dir: string): Promise<boolean>;
45
+ /**
46
+ * Returns true if origin/main has commits the local HEAD does not have
47
+ * (local strictly behind, or diverged). False when local is up-to-date
48
+ * or strictly ahead of origin/main, or when origin/main is unknown.
49
+ *
50
+ * The clone-path stale-HEAD guard (qfg-fboj) refuses to push in either
51
+ * "behind" or "diverged" state because both produce a diff that ships
52
+ * REVERSAL deltas to the server, silently undoing remote-newer commits.
53
+ */
54
+ isLocalBehindRemote(dir: string): Promise<boolean>;
55
+ /**
56
+ * Tracked files (relative paths) with working-tree or staged changes.
57
+ * Used by the clone-path dirty-tree warning to surface uncommitted
58
+ * edits the user may believe are being pushed (qfg-fboj). Untracked
59
+ * files are excluded — same rule as `dirtyTrackedFiles` in git-ops.ts.
60
+ */
61
+ dirtyTrackedFiles(dir: string): Promise<string[]>;
45
62
  /** Set origin to `url` (add if missing, set-url if present). */
46
63
  setRemoteOrigin(dir: string, url: string): Promise<void>;
47
64
  }
@@ -136,6 +136,15 @@ export async function runPush(input, deps) {
136
136
  await deps.gitOps.setRemoteOrigin(input.dir, backend.repoUrl);
137
137
  log('Fetching from remote...');
138
138
  await deps.gitOps.fetch(input.dir);
139
+ // 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)) {
146
+ 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
+ }
139
148
  // Migration cleanup (qfg-0fn): older `qfg pull` runs wrote the
140
149
  // workspace pin to the working tree without committing. The next
141
150
  // diff would miss it. Sweep up that legacy state IF the only dirty
@@ -153,6 +162,27 @@ export async function runPush(input, deps) {
153
162
  catch {
154
163
  /* non-fatal */
155
164
  }
165
+ // Guard 5 (qfg-fboj): warn loudly about any tracked file with
166
+ // uncommitted edits beyond the workspace pin. The clone path pushes
167
+ // HEAD content, not the working tree, so these edits are silently
168
+ // dropped from the push. Listing them in the log gives the user a
169
+ // chance to ctrl-C, commit, and retry instead of believing they
170
+ // shipped what's on disk. Untracked files are intentionally not
171
+ // included — same rule as dirtyTrackedFiles in git-ops.ts.
172
+ try {
173
+ const dirty = await deps.gitOps.dirtyTrackedFiles(input.dir);
174
+ const noisyDirty = dirty.filter((f) => f !== 'quonfig.json');
175
+ if (noisyDirty.length > 0) {
176
+ log('');
177
+ log('Warning: working tree has uncommitted changes that will NOT be included in this push:');
178
+ for (const f of noisyDirty)
179
+ log(` ${f}`);
180
+ log('Commit them with `git add` + `git commit` and re-run `qfg push` if you meant to ship them.');
181
+ }
182
+ }
183
+ catch {
184
+ /* non-fatal */
185
+ }
156
186
  }
157
187
  const deltas = await deps.gitOps.diffHeadVsOrigin(input.dir);
158
188
  const totalFilesInRemote = await deps.gitOps.countFilesInRemote(input.dir);
@@ -13,8 +13,12 @@ export interface CloneAndStackPushOptions {
13
13
  author?: PushIdentity;
14
14
  /** Branch to track and push. Defaults to `main`. */
15
15
  branch?: string;
16
- /** Single commit message for the delta. */
17
- commitMessage: string;
16
+ /**
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.
20
+ */
21
+ commitMessage: string | (() => string);
18
22
  /** Where to clone into, or an existing clone to reuse. */
19
23
  localDir: string;
20
24
  /** Gitea repo URL (may include token). */
@@ -62,7 +62,13 @@ const ensureCloneOrReuse = async (remoteUrl, localDir, branch) => {
62
62
  await runGit(localDir, ['merge', '--ff-only', `origin/${branch}`]);
63
63
  return 'reused';
64
64
  }
65
- throw new Error(`Local dir ${localDir} is a git repo but its origin (${origin ? redactToken(origin) : 'unset'}) does not match the target remote. Refuse to clobber — pass an empty/non-existent path or a matching clone.`);
65
+ throw new Error(`Local dir ${localDir} is a git repo but its origin (${origin ? redactToken(origin) : 'unset'}) does not match the target remote.\n` +
66
+ `Refuse to clobber.\n\n` +
67
+ `Most likely cause: you ran \`qfg migrate --dir ${localDir}\` first (which \`git init\`s a fresh repo with no remote) and are now trying to \`--push\` from the same dir.\n\n` +
68
+ `Pick one of:\n` +
69
+ ` - Re-run with \`--push\` against a NEW empty/non-existent path (the canonical flow).\n` +
70
+ ` - Run \`qfg pull --workspace <org/slug> --dir ${localDir}-clone\` to seed a fresh clone, then \`qfg migrate --dir ${localDir}-clone --workspace <org/slug> --push\`.\n` +
71
+ `Docs: https://docs.quonfig.com/docs/migrating/from-launch (Flow B).`);
66
72
  }
67
73
  if (fs.existsSync(localDir)) {
68
74
  const entries = fs.readdirSync(localDir);
@@ -90,8 +96,9 @@ export const cloneAndStackPush = async (opts) => {
90
96
  if (!(await hasStagedChanges(opts.localDir))) {
91
97
  return { action, committed: false, commitSha: null };
92
98
  }
99
+ const commitMessage = typeof opts.commitMessage === 'function' ? opts.commitMessage() : opts.commitMessage;
93
100
  await spawnGit(['-C', opts.localDir, 'commit', '-F', '-'], {
94
- stdin: opts.commitMessage,
101
+ stdin: commitMessage,
95
102
  env: {
96
103
  GIT_AUTHOR_NAME: author.name,
97
104
  GIT_AUTHOR_EMAIL: author.email,
@@ -101,6 +101,20 @@ export declare const gitFetch: (dir: string) => Promise<void>;
101
101
  * Returns true if origin/main has commits that can be fast-forwarded into the local branch.
102
102
  */
103
103
  export declare const canFastForward: (dir: string) => Promise<boolean>;
104
+ /**
105
+ * Returns true iff origin/main is NOT an ancestor of the local HEAD —
106
+ * covering both "local strictly behind" and "diverged" in one boolean.
107
+ *
108
+ * Used by the clone-path stale-HEAD guard in `qfg push` (qfg-fboj):
109
+ * either of those two states would otherwise produce a `HEAD..origin/main`
110
+ * diff that ships REVERSAL deltas to the server, silently undoing
111
+ * remote-newer commits. Both must refuse.
112
+ *
113
+ * Returns false on any git failure (no `origin/main` ref yet, no `.git/`,
114
+ * etc.) so the caller falls through to its other guards rather than
115
+ * aborting on an opaque error.
116
+ */
117
+ export declare const isLocalBehindOrDivergedFromRemote: (dir: string) => Promise<boolean>;
104
118
  /**
105
119
  * Returns true if local has commits not reachable from origin/main (diverged).
106
120
  */
@@ -120,3 +134,29 @@ export declare const hasAtLeastOneCommit: (dir: string) => Promise<boolean>;
120
134
  * Get the URL stripped of credentials for display purposes.
121
135
  */
122
136
  export declare const displayUrl: (url: string) => string;
137
+ /**
138
+ * Outcome of `gitPullRebase`. Three branches the caller MUST distinguish
139
+ * — silent failure here is what made qfg-4tey a P1 (qfg pull exit 0 with
140
+ * no recovery path).
141
+ */
142
+ export type GitPullRebaseResult = {
143
+ kind: 'clean';
144
+ commitsRebased: number;
145
+ } | {
146
+ kind: 'conflicts';
147
+ conflictedFiles: string[];
148
+ } | {
149
+ kind: 'failed';
150
+ reason: string;
151
+ };
152
+ /**
153
+ * `git pull --rebase origin main`. Replays local commits on top of the
154
+ * remote tip. On conflicts, leaves the repo in rebase-in-progress state
155
+ * with `<<<<<<<` / `=======` / `>>>>>>>` markers planted by git so the
156
+ * user can resolve via standard git tools (`git rebase --continue` /
157
+ * `git rebase --abort`).
158
+ *
159
+ * Caller is responsible for surfacing recovery instructions; this function
160
+ * only reports the outcome.
161
+ */
162
+ export declare const gitPullRebase: (dir: string) => Promise<GitPullRebaseResult>;