@quonfig/cli 0.0.15 → 0.0.16

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.
@@ -0,0 +1,370 @@
1
+ import { execFile as execFileCb } from 'node:child_process';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import * as util from 'node:util';
5
+ import { Flags } from '@oclif/core';
6
+ import { BaseCommand } from '../index.js';
7
+ import { mintGiteaToken } from '../util/gitea-api.js';
8
+ import { getRemoteUrl, gitFetch, gitSetRemote, isGitRepo, } from '../util/git-ops.js';
9
+ import { PushFatalError, runPush } from '../push/run-push.js';
10
+ import { computeBarePathDiff } from '../push/bare-path-diff.js';
11
+ import { getActiveProfile, loadAuthConfig } from '../util/token-storage.js';
12
+ const execFile = util.promisify(execFileCb);
13
+ export default class Push extends BaseCommand {
14
+ static description = `Push local config changes up to your workspace on Quonfig cloud.
15
+
16
+ Enforces three guards before touching the remote:
17
+ 1. The dir's quonfig.json "workspace" pin must match the backend (if set).
18
+ 2. The dir's git origin must match the backend repo URL (if set).
19
+ 3. A diff summary is shown; destructive changes (10+ deletes, >=25% of
20
+ files, or an unpinned dir) require typing the workspace slug to confirm.
21
+
22
+ qfg push # uses QUONFIG_DIR + profile workspace
23
+ qfg push --dir ./our-config
24
+ qfg push --dir ./our-config --workspace acme-prod
25
+ qfg push --dir ./our-config --yes # skip normal Y/N (never skips typed-slug)
26
+ qfg push --dir ./our-config --skip-validate`;
27
+ static examples = [
28
+ '<%= config.bin %> push --dir ./our-config',
29
+ '<%= config.bin %> push --workspace acme-prod --dir ./our-config',
30
+ '<%= config.bin %> push --dir ./our-config --yes',
31
+ ];
32
+ static flags = {
33
+ dir: Flags.string({
34
+ description: 'Local directory to push (defaults to QUONFIG_DIR env var)',
35
+ }),
36
+ workspace: Flags.string({
37
+ description: 'Workspace slug or UUID (defaults to active profile)',
38
+ }),
39
+ yes: Flags.boolean({
40
+ default: false,
41
+ description: 'Skip the standard Y/N confirm. Never skips typed-slug prompts.',
42
+ }),
43
+ 'skip-validate': Flags.boolean({
44
+ default: false,
45
+ description: 'Skip `qfg validate` preflight',
46
+ }),
47
+ 'no-pin-write': Flags.boolean({
48
+ default: false,
49
+ description: 'Do not offer to write workspace slug into quonfig.json on success',
50
+ }),
51
+ message: Flags.string({
52
+ char: 'm',
53
+ description: 'Commit message for bare-path pushes (ignored on clone-path; push what is already committed)',
54
+ }),
55
+ };
56
+ async run() {
57
+ const { flags } = await this.parse(Push);
58
+ const dir = flags.dir || process.env.QUONFIG_DIR;
59
+ if (!dir) {
60
+ return this.err('No directory specified. Use --dir <path> or set QUONFIG_DIR.');
61
+ }
62
+ const resolvedDir = path.resolve(dir);
63
+ // Resolve requested target: --workspace overrides the active profile.
64
+ let requestedTarget = flags.workspace;
65
+ if (!requestedTarget) {
66
+ const authConfig = await loadAuthConfig();
67
+ if (!authConfig) {
68
+ return this.err('No active profile workspace; pass --workspace or run `qfg login`.');
69
+ }
70
+ const activeProfile = getActiveProfile();
71
+ const profile = authConfig.profiles[activeProfile] || authConfig.profiles[authConfig.defaultProfile || 'default'];
72
+ if (!profile || !profile.workspace) {
73
+ return this.err('No active profile workspace; pass --workspace or run `qfg login`.');
74
+ }
75
+ requestedTarget = profile.workspace;
76
+ }
77
+ const { deps, cleanup } = buildRealDeps(this);
78
+ try {
79
+ const result = await runPush({
80
+ dir: resolvedDir,
81
+ requestedTarget,
82
+ yes: flags.yes,
83
+ skipValidate: flags['skip-validate'],
84
+ noPinWrite: flags['no-pin-write'],
85
+ message: flags.message,
86
+ }, deps);
87
+ if (result.kind === 'aborted') {
88
+ this.log(`Aborted: ${result.reason}`);
89
+ return { aborted: true, reason: result.reason };
90
+ }
91
+ if (result.kind === 'no-op') {
92
+ this.log(result.reason);
93
+ return { noop: true, reason: result.reason };
94
+ }
95
+ this.log(`Pushed via ${result.dispatchedAs}.`);
96
+ return { pushed: true, via: result.dispatchedAs, commitSha: result.commitSha ?? null };
97
+ }
98
+ catch (error) {
99
+ if (error instanceof PushFatalError) {
100
+ return this.err(error.message);
101
+ }
102
+ throw error;
103
+ }
104
+ finally {
105
+ await cleanup();
106
+ }
107
+ }
108
+ }
109
+ /**
110
+ * Map a raw `gitea.token` API response into the `GiteaTokenMintResult` shape
111
+ * the push core (`runPush`) consumes. Kept as a pure function so we can unit
112
+ * test the wiring without a network — specifically, to regression-test that
113
+ * `workspaceId` on the result is the UUID the server returned, not the slug.
114
+ *
115
+ * The bug this guards against: before the backend started returning
116
+ * `workspaceId`, this module used `resp.workspaceSlug` as the `workspaceId`
117
+ * field, which caused `checkIdentity` to fail when the user passed
118
+ * `--workspace <UUID>` (requested=UUID, backend.workspaceId=slug → mismatch).
119
+ */
120
+ export function giteaResponseToMintResult(resp) {
121
+ return {
122
+ token: resp.token,
123
+ repoUrl: resp.repoUrl,
124
+ expiresAt: resp.expiresAt,
125
+ workspaceSlug: resp.workspaceSlug,
126
+ // Canonical workspace UUID from the server. checkIdentity compares this
127
+ // against `requestedTarget` — so if the user passed --workspace as a
128
+ // UUID, the UUID path matches backend.workspaceId; if they passed a
129
+ // slug, the slug path matches backend.workspaceSlug. Either way the
130
+ // backend row is the same workspace.
131
+ workspaceId: resp.workspaceId,
132
+ };
133
+ }
134
+ /**
135
+ * Wire the real implementations of every dependency runPush needs. Kept in
136
+ * the command file so the pure core stays free of oclif / git shell imports.
137
+ *
138
+ * Returns a `{deps, cleanup}` pair. The caller MUST call `cleanup()` in a
139
+ * finally block so the bare-path probe clone is removed even on errors.
140
+ */
141
+ export function buildRealDeps(cmd) {
142
+ const log = (line) => cmd.log(line);
143
+ const errLog = (line) => cmd.logToStderr(line);
144
+ // State captured across calls so a bare-path diff can share its probe clone
145
+ // between `diffHeadVsOrigin` and `countFilesInRemote`. Populated lazily on
146
+ // the first call that needs it.
147
+ //
148
+ // `authenticatedRepoUrl` is set by the wrapped `mintWriteToken` below —
149
+ // runPush always calls mint before it calls any gitOps method that needs
150
+ // the probe clone, so by the time we read it here it is populated.
151
+ let authenticatedRepoUrl;
152
+ let barePathProbe;
153
+ const ensureBarePathProbe = async (dir) => {
154
+ if (barePathProbe)
155
+ return barePathProbe;
156
+ if (!authenticatedRepoUrl) {
157
+ throw new Error('buildRealDeps: bare-path diff requested before mintWriteToken ran. This is a bug in runPush ordering.');
158
+ }
159
+ barePathProbe = await computeBarePathDiff(dir, authenticatedRepoUrl);
160
+ return barePathProbe;
161
+ };
162
+ const gitOps = {
163
+ async isGitRepo(dir) {
164
+ return isGitRepo(dir);
165
+ },
166
+ async getRemoteOriginUrl(dir) {
167
+ const url = await getRemoteUrl(dir);
168
+ return url ?? undefined;
169
+ },
170
+ async setRemoteOrigin(dir, url) {
171
+ await gitSetRemote(dir, url);
172
+ },
173
+ async fetch(dir) {
174
+ // fetch is only meaningful for the clone path. For a bare-path dir it
175
+ // is still called by runPush but the dir has no origin, so we swallow
176
+ // the failure here rather than in runPush. The probe-clone strategy
177
+ // handles the remote-state read.
178
+ try {
179
+ await gitFetch(dir);
180
+ }
181
+ catch {
182
+ /* bare path: no origin to fetch from — intentional no-op */
183
+ }
184
+ },
185
+ async diffHeadVsOrigin(dir) {
186
+ // Clone path: .git/ exists AND origin matches backend. Prefer the native
187
+ // git diff; it is fast and doesn't require a second clone.
188
+ if (await isGitRepo(dir)) {
189
+ try {
190
+ return await diffHeadVsOrigin(dir);
191
+ }
192
+ catch {
193
+ // Fall through to the probe-clone path. This can happen if the dir
194
+ // is a git repo but origin doesn't have a main branch fetched, etc.
195
+ }
196
+ }
197
+ const probe = await ensureBarePathProbe(dir);
198
+ return probe.deltas;
199
+ },
200
+ async push(dir) {
201
+ await execFile('git', ['-C', dir, 'push', 'origin', 'main']);
202
+ },
203
+ async getLocalAuthor(dir) {
204
+ return readLocalAuthor(dir);
205
+ },
206
+ async countFilesInRemote(dir) {
207
+ if (await isGitRepo(dir)) {
208
+ const fromGit = await countTrackedFilesAtRef(dir, 'origin/main');
209
+ // If origin/main is present we get a positive count; if the dir is a
210
+ // bare path with a stale .git we get 0 and fall through.
211
+ if (fromGit > 0)
212
+ return fromGit;
213
+ }
214
+ const probe = await ensureBarePathProbe(dir);
215
+ return probe.totalRemoteFiles;
216
+ },
217
+ };
218
+ const deps = {
219
+ async mintWriteToken(requestedTarget) {
220
+ const resp = await mintGiteaToken(requestedTarget, 'write', 'push');
221
+ // Stash the authenticated URL so the bare-path gitOps methods can
222
+ // probe-clone it without a second mint.
223
+ authenticatedRepoUrl = resp.repoUrl;
224
+ return giteaResponseToMintResult(resp);
225
+ },
226
+ async validate(dir) {
227
+ const { validateWorkspace } = await import('../verify/validate.js');
228
+ const result = validateWorkspace(dir);
229
+ const errors = result.issues.filter((i) => i.severity === 'error').map((i) => i.message);
230
+ return { errors };
231
+ },
232
+ gitOps,
233
+ copyDirMirror,
234
+ log,
235
+ errLog,
236
+ };
237
+ const cleanup = async () => {
238
+ if (!barePathProbe)
239
+ return;
240
+ try {
241
+ fs.rmSync(barePathProbe.scratchDir, { force: true, recursive: true });
242
+ }
243
+ catch {
244
+ /* ignore — cleanup is best-effort */
245
+ }
246
+ };
247
+ return { deps, cleanup };
248
+ }
249
+ /**
250
+ * Diff the working tree (what we are about to push) against origin/main. We
251
+ * use `git diff --name-status HEAD..origin/main` with inverted semantics:
252
+ * Added here = present locally (in HEAD), absent in origin/main.
253
+ * Deleted = absent locally, present in origin/main.
254
+ * Modified = content differs.
255
+ *
256
+ * `git diff A..B` reports B relative to A. So `HEAD..origin/main` tells us
257
+ * what origin has that we don't. We want "local vs remote" with local as the
258
+ * baseline — i.e. origin/main..HEAD.
259
+ */
260
+ async function diffHeadVsOrigin(dir) {
261
+ // origin/main..HEAD reports changes HEAD has relative to origin/main.
262
+ // 'A' -> added locally, 'D' -> deleted locally, 'M' -> modified locally.
263
+ const { stdout } = await execFile('git', [
264
+ '-C',
265
+ dir,
266
+ 'diff',
267
+ '--name-status',
268
+ 'origin/main..HEAD',
269
+ ]);
270
+ const deltas = [];
271
+ for (const raw of stdout.split('\n')) {
272
+ const line = raw.trim();
273
+ if (!line)
274
+ continue;
275
+ const [status, ...rest] = line.split(/\s+/);
276
+ const pathStr = rest.join(' ');
277
+ if (!pathStr)
278
+ continue;
279
+ if (status.startsWith('A'))
280
+ deltas.push({ kind: 'added', path: pathStr });
281
+ else if (status.startsWith('D'))
282
+ deltas.push({ kind: 'deleted', path: pathStr });
283
+ else if (status.startsWith('M') || status.startsWith('R') || status.startsWith('C')) {
284
+ // Treat renames / copies as modifications for diff-summary purposes.
285
+ deltas.push({ kind: 'modified', path: pathStr });
286
+ }
287
+ }
288
+ return deltas;
289
+ }
290
+ async function countTrackedFilesAtRef(dir, ref) {
291
+ try {
292
+ const { stdout } = await execFile('git', ['-C', dir, 'ls-tree', '-r', '--name-only', ref]);
293
+ return stdout.split('\n').filter((l) => l.trim().length > 0).length;
294
+ }
295
+ catch {
296
+ return 0;
297
+ }
298
+ }
299
+ async function readLocalAuthor(dir) {
300
+ try {
301
+ const { stdout: name } = await execFile('git', ['-C', dir, 'config', 'user.name']);
302
+ const { stdout: email } = await execFile('git', ['-C', dir, 'config', 'user.email']);
303
+ const n = name.trim();
304
+ const e = email.trim();
305
+ if (!n || !e)
306
+ return undefined;
307
+ return { name: n, email: e };
308
+ }
309
+ catch {
310
+ return undefined;
311
+ }
312
+ }
313
+ /**
314
+ * Copy the contents of `sourceDir` into `destDir`, skipping `.git/` and any
315
+ * temp push-clone scratch directories. Used as the `applyDelta` callback for
316
+ * bare-path pushes — we mirror local files into a fresh clone so that
317
+ * `cloneAndStackPush`'s `git add --all` + diff will reflect the union of
318
+ * (deletes, modifies, adds) we intend.
319
+ *
320
+ * IMPORTANT: this deletes files in destDir that are not in sourceDir so that
321
+ * deletions make it into the push. Without this the clone-and-stack produces
322
+ * an "adds-only" delta and destructive heuristics would never fire in the UI.
323
+ */
324
+ export async function copyDirMirror(sourceDir, destDir) {
325
+ // Collect source entries (except .git, .quonfig-push-clone-*)
326
+ const sourceRel = collectRelPaths(sourceDir);
327
+ const destRel = collectRelPaths(destDir);
328
+ // Delete anything in dest that isn't in source
329
+ for (const rel of destRel) {
330
+ if (!sourceRel.has(rel)) {
331
+ const full = path.join(destDir, rel);
332
+ try {
333
+ fs.rmSync(full, { force: true });
334
+ }
335
+ catch {
336
+ /* ignore */
337
+ }
338
+ }
339
+ }
340
+ // Copy / overwrite everything from source into dest
341
+ for (const rel of sourceRel) {
342
+ const src = path.join(sourceDir, rel);
343
+ const dst = path.join(destDir, rel);
344
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
345
+ fs.copyFileSync(src, dst);
346
+ }
347
+ }
348
+ function collectRelPaths(root) {
349
+ const out = new Set();
350
+ if (!fs.existsSync(root))
351
+ return out;
352
+ const walk = (dir, prefix) => {
353
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
354
+ if (entry.name === '.git')
355
+ continue;
356
+ if (entry.name.startsWith('.quonfig-push-clone-'))
357
+ continue;
358
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
359
+ const full = path.join(dir, entry.name);
360
+ if (entry.isDirectory()) {
361
+ walk(full, rel);
362
+ }
363
+ else if (entry.isFile() || entry.isSymbolicLink()) {
364
+ out.add(rel);
365
+ }
366
+ }
367
+ };
368
+ walk(root, '');
369
+ return out;
370
+ }
@@ -9,5 +9,12 @@ export default class WorkspaceBootstrap extends BaseCommand {
9
9
  'skip-validate': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
10
10
  };
11
11
  run(): Promise<JsonObj | void>;
12
+ /**
13
+ * Stage, commit, and push the `quonfig.json` pin. Runs after the main
14
+ * bootstrap push so the pin lands as a committed+pushed change rather than
15
+ * an uncommitted local edit. We stage only `quonfig.json` (not `-A`) so we
16
+ * don't accidentally sweep in unrelated user edits.
17
+ */
18
+ private commitAndPushPin;
12
19
  private remoteHasCommits;
13
20
  }
@@ -4,6 +4,7 @@ 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 { readWorkspaceSlug, writeWorkspaceSlug } from '../../util/quonfig-json.js';
7
8
  import { isGitRepo, hasAtLeastOneCommit, gitSetRemote, gitPushForceLease, gitPushForce, getRemoteUrl, displayUrl, } from '../../util/git-ops.js';
8
9
  export default class WorkspaceBootstrap extends BaseCommand {
9
10
  static description = "Push a local git repo to Gitea as this workspace's config repository";
@@ -102,8 +103,8 @@ export default class WorkspaceBootstrap extends BaseCommand {
102
103
  catch (error) {
103
104
  return this.err(`Could not get Gitea credentials: ${String(error)}\n\nMake sure the workspace is fully provisioned in the Quonfig app before bootstrapping.`);
104
105
  }
105
- const { repoUrl } = tokenData;
106
- this.verboseLog('WorkspaceBootstrap', { repoUrl: displayUrl(repoUrl) });
106
+ const { repoUrl, workspaceSlug: backendSlug } = tokenData;
107
+ this.verboseLog('WorkspaceBootstrap', { repoUrl: displayUrl(repoUrl), backendSlug });
107
108
  // Idempotency: check if remote already has commits
108
109
  if (!flags.force) {
109
110
  const remoteHasCommits = await this.remoteHasCommits(repoUrl);
@@ -135,6 +136,35 @@ export default class WorkspaceBootstrap extends BaseCommand {
135
136
  catch (error) {
136
137
  return this.err(`Push failed: ${String(error)}`);
137
138
  }
139
+ // Write the workspace pin into `quonfig.json` (Guard 1 in
140
+ // project/plans/cli-git-sync.md). If the pin already exists and disagrees,
141
+ // we keep what is there — bootstrap is for fresh cases, not re-pinning.
142
+ // If we do write it, commit and push so the bootstrap leaves no
143
+ // uncommitted changes behind.
144
+ try {
145
+ const existingPin = await readWorkspaceSlug(resolvedDir);
146
+ if (existingPin && existingPin !== backendSlug) {
147
+ this.log('');
148
+ this.log(`Warning: quonfig.json already pins workspace "${existingPin}", but the backend says this workspace is "${backendSlug}".`);
149
+ this.log('Leaving the existing pin in place. If this is wrong, edit quonfig.json manually and re-run.');
150
+ }
151
+ else if (existingPin === backendSlug) {
152
+ this.verboseLog('WorkspaceBootstrap', `quonfig.json already pinned to ${backendSlug}; no-op.`);
153
+ }
154
+ else {
155
+ this.log('');
156
+ this.log(`Pinning quonfig.json to workspace "${backendSlug}"...`);
157
+ await writeWorkspaceSlug(resolvedDir, backendSlug);
158
+ await this.commitAndPushPin(resolvedDir, backendSlug, flags.force);
159
+ }
160
+ }
161
+ catch (error) {
162
+ // The main push has already succeeded, so don't fail the whole command.
163
+ // Surface the issue clearly so the user knows to follow up.
164
+ this.log('');
165
+ this.log(`Warning: could not write the workspace pin to quonfig.json: ${String(error)}`);
166
+ this.log('The workspace is connected, but you should re-run `qfg pull` to backfill the pin.');
167
+ }
138
168
  this.log(`\nBootstrap complete.`);
139
169
  this.log(`Workspace "${workspaceName}" is now connected to your local directory.`);
140
170
  this.log(`\nTo keep it in sync locally, run:`);
@@ -145,6 +175,32 @@ export default class WorkspaceBootstrap extends BaseCommand {
145
175
  workspaceId,
146
176
  };
147
177
  }
178
+ /**
179
+ * Stage, commit, and push the `quonfig.json` pin. Runs after the main
180
+ * bootstrap push so the pin lands as a committed+pushed change rather than
181
+ * an uncommitted local edit. We stage only `quonfig.json` (not `-A`) so we
182
+ * don't accidentally sweep in unrelated user edits.
183
+ */
184
+ async commitAndPushPin(dir, slug, force) {
185
+ const { execFile: execFileCb } = await import('node:child_process');
186
+ const util = await import('node:util');
187
+ const execFile = util.promisify(execFileCb);
188
+ // Stage just quonfig.json so we don't accidentally commit other dirty files.
189
+ await execFile('git', ['-C', dir, 'add', 'quonfig.json']);
190
+ // If `git add` produced no staged change (e.g. content matched an earlier
191
+ // version on disk), `git commit` would fail. Check the index first.
192
+ const { stdout: diffStat } = await execFile('git', ['-C', dir, 'diff', '--cached', '--name-only']);
193
+ if (!diffStat.trim()) {
194
+ this.verboseLog('WorkspaceBootstrap', 'quonfig.json pin matches HEAD; no commit needed.');
195
+ return;
196
+ }
197
+ await execFile('git', ['-C', dir, 'commit', '-m', `chore: pin quonfig.json to workspace "${slug}"`]);
198
+ // Push the new commit using the same force semantics as the main push.
199
+ const pushArgs = ['-C', dir, 'push', 'origin', 'main'];
200
+ pushArgs.push(force ? '--force' : '--force-with-lease');
201
+ await execFile('git', pushArgs);
202
+ this.log(`Pushed pin commit.`);
203
+ }
148
204
  async remoteHasCommits(repoUrl) {
149
205
  const { execFile: execFileCb } = await import('node:child_process');
150
206
  const util = await import('node:util');
@@ -1,6 +1,6 @@
1
1
  import { type ImportState } from './import-state.js';
2
2
  import { type MigrationReportData } from './migration-report.js';
3
- import { type PushIdentity } from './push-strategy.js';
3
+ import { type PushIdentity } from '../util/clone-and-stack-push.js';
4
4
  import type { LegacyChange, MigrationSource } from './source.js';
5
5
  export interface ApplyLocalMigrationOptions {
6
6
  /** Commit author. Defaults to the migrator identity. */
@@ -4,7 +4,7 @@ import * as path from 'node:path';
4
4
  import * as util from 'node:util';
5
5
  import { removeQfFromGitignore, writeImportState } from './import-state.js';
6
6
  import { writeMigrationReport } from './migration-report.js';
7
- import { MIGRATOR_IDENTITY } from './push-strategy.js';
7
+ import { MIGRATOR_IDENTITY } from '../util/clone-and-stack-push.js';
8
8
  const execFile = util.promisify(execFileCb);
9
9
  const isGitRepo = async (dir) => {
10
10
  try {
@@ -1,6 +1,6 @@
1
1
  import { type ImportState } from './import-state.js';
2
2
  import { type MigrationReportData } from './migration-report.js';
3
- import { type CloneAndStackPushResult } from './push-strategy.js';
3
+ import { type CloneAndStackPushResult } from '../util/clone-and-stack-push.js';
4
4
  import type { LegacyChange, MigrationSource } from './source.js';
5
5
  export interface PushMigrationToCloudOptions {
6
6
  branch?: string;
@@ -2,7 +2,7 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { removeQfFromGitignore, writeImportState } from './import-state.js';
4
4
  import { writeMigrationReport } from './migration-report.js';
5
- import { cloneAndStackPush } from './push-strategy.js';
5
+ import { cloneAndStackPush } from '../util/clone-and-stack-push.js';
6
6
  const writeQuonfigFiles = (dir, changes, source) => {
7
7
  for (const change of changes) {
8
8
  const files = source.translate(change);
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Bare-path diff helper for `qfg push`.
3
+ *
4
+ * When the user's local dir is NOT a clone of the cloud repo (no `.git/`, or
5
+ * a mismatched origin) we still need to show a Guard 3 diff summary BEFORE
6
+ * asking them to confirm the push. The clone-path implementation uses
7
+ * `git log origin/main..HEAD` — that doesn't work here because there is no
8
+ * origin in the local dir.
9
+ *
10
+ * Strategy: probe-clone the cloud repo into an OS tmpdir, then walk both
11
+ * trees file-by-file and classify each as added / modified / deleted.
12
+ *
13
+ * The caller owns cleanup of `scratchDir`. The real caller (buildRealDeps in
14
+ * `commands/push.ts`) stashes the scratch clone path on a closure so it can
15
+ * delete it in a finally-block after runPush resolves — matching the bare
16
+ * path's existing scratch-clone lifecycle in `run-push.ts`.
17
+ *
18
+ * Intentional non-goals:
19
+ * - No token handling. Caller passes a fully-authenticated `remoteUrl`.
20
+ * - No filtering to "known" workspace dirs (configs/, feature-flags/, ...).
21
+ * We exclude dotfiles/dotdirs (`.git`, `.DS_Store`, `.quonfig-*`) so the
22
+ * scratch clone's own `.git/` does not leak into the diff, but otherwise
23
+ * diff whatever is on disk. This keeps the diff surface consistent with
24
+ * `copyDirMirror` in `commands/push.ts`, which mirrors every non-dotfile
25
+ * into the real push clone.
26
+ */
27
+ import { FileDelta } from './diff-summary.js';
28
+ export interface BarePathDiffResult {
29
+ deltas: FileDelta[];
30
+ /** Tmp dir holding the probe clone. Caller must rm -rf in a finally block. */
31
+ scratchDir: string;
32
+ /** Total file count in the scratch clone (under the same prefix rules as `deltas`). */
33
+ totalRemoteFiles: number;
34
+ }
35
+ /**
36
+ * Clone `remoteUrl` into an OS tmp dir, then diff the working tree of `localDir`
37
+ * against the scratch clone's working tree. Returns `FileDelta[]` plus the
38
+ * scratch clone's file count.
39
+ */
40
+ export declare function computeBarePathDiff(localDir: string, remoteUrl: string): Promise<BarePathDiffResult>;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Bare-path diff helper for `qfg push`.
3
+ *
4
+ * When the user's local dir is NOT a clone of the cloud repo (no `.git/`, or
5
+ * a mismatched origin) we still need to show a Guard 3 diff summary BEFORE
6
+ * asking them to confirm the push. The clone-path implementation uses
7
+ * `git log origin/main..HEAD` — that doesn't work here because there is no
8
+ * origin in the local dir.
9
+ *
10
+ * Strategy: probe-clone the cloud repo into an OS tmpdir, then walk both
11
+ * trees file-by-file and classify each as added / modified / deleted.
12
+ *
13
+ * The caller owns cleanup of `scratchDir`. The real caller (buildRealDeps in
14
+ * `commands/push.ts`) stashes the scratch clone path on a closure so it can
15
+ * delete it in a finally-block after runPush resolves — matching the bare
16
+ * path's existing scratch-clone lifecycle in `run-push.ts`.
17
+ *
18
+ * Intentional non-goals:
19
+ * - No token handling. Caller passes a fully-authenticated `remoteUrl`.
20
+ * - No filtering to "known" workspace dirs (configs/, feature-flags/, ...).
21
+ * We exclude dotfiles/dotdirs (`.git`, `.DS_Store`, `.quonfig-*`) so the
22
+ * scratch clone's own `.git/` does not leak into the diff, but otherwise
23
+ * diff whatever is on disk. This keeps the diff surface consistent with
24
+ * `copyDirMirror` in `commands/push.ts`, which mirrors every non-dotfile
25
+ * into the real push clone.
26
+ */
27
+ import { execFile as execFileCb } from 'node:child_process';
28
+ import { createHash } from 'node:crypto';
29
+ import * as fs from 'node:fs';
30
+ import * as os from 'node:os';
31
+ import * as path from 'node:path';
32
+ import * as util from 'node:util';
33
+ const execFile = util.promisify(execFileCb);
34
+ /**
35
+ * Clone `remoteUrl` into an OS tmp dir, then diff the working tree of `localDir`
36
+ * against the scratch clone's working tree. Returns `FileDelta[]` plus the
37
+ * scratch clone's file count.
38
+ */
39
+ export async function computeBarePathDiff(localDir, remoteUrl) {
40
+ const scratchDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'qfg-push-probe-'));
41
+ try {
42
+ // --depth 1 would miss information needed for later identity checks, but
43
+ // for the diff we only need the tip — shallow is fine and a lot faster on
44
+ // workspaces with 2 years of history.
45
+ await execFile('git', ['clone', '--branch', 'main', '--depth', '1', remoteUrl, scratchDir]);
46
+ }
47
+ catch (error) {
48
+ // Clean up before re-throwing so we never leak a scratch dir on failure.
49
+ try {
50
+ fs.rmSync(scratchDir, { force: true, recursive: true });
51
+ }
52
+ catch {
53
+ /* ignore */
54
+ }
55
+ throw error;
56
+ }
57
+ const localFiles = collectFilesWithHash(localDir);
58
+ const remoteFiles = collectFilesWithHash(scratchDir);
59
+ const deltas = [];
60
+ for (const [rel, localHash] of localFiles) {
61
+ const remoteHash = remoteFiles.get(rel);
62
+ if (remoteHash === undefined) {
63
+ deltas.push({ kind: 'added', path: rel });
64
+ }
65
+ else if (remoteHash !== localHash) {
66
+ deltas.push({ kind: 'modified', path: rel });
67
+ }
68
+ }
69
+ for (const rel of remoteFiles.keys()) {
70
+ if (!localFiles.has(rel)) {
71
+ deltas.push({ kind: 'deleted', path: rel });
72
+ }
73
+ }
74
+ return { deltas, scratchDir, totalRemoteFiles: remoteFiles.size };
75
+ }
76
+ /**
77
+ * Walk `root` and return a map of relative-path -> content-hash for every
78
+ * non-dotfile file under it.
79
+ *
80
+ * We skip any entry whose name starts with a `.`, which covers `.git/`,
81
+ * `.DS_Store`, and the transient `.quonfig-push-clone-*` scratch dirs that
82
+ * `run-push.ts` creates next to the local workspace. Keeping the rule simple
83
+ * and symmetric on both sides prevents one-off false positives.
84
+ */
85
+ function collectFilesWithHash(root) {
86
+ const out = new Map();
87
+ if (!fs.existsSync(root))
88
+ return out;
89
+ const walk = (dir, prefix) => {
90
+ let entries;
91
+ try {
92
+ entries = fs.readdirSync(dir, { withFileTypes: true });
93
+ }
94
+ catch {
95
+ return;
96
+ }
97
+ for (const entry of entries) {
98
+ if (entry.name.startsWith('.'))
99
+ continue;
100
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
101
+ const full = path.join(dir, entry.name);
102
+ if (entry.isDirectory()) {
103
+ walk(full, rel);
104
+ }
105
+ else if (entry.isFile() || entry.isSymbolicLink()) {
106
+ try {
107
+ const buf = fs.readFileSync(full);
108
+ out.set(rel, hashBytes(buf));
109
+ }
110
+ catch {
111
+ /* ignore unreadable files — they won't appear on either side */
112
+ }
113
+ }
114
+ }
115
+ };
116
+ walk(root, '');
117
+ return out;
118
+ }
119
+ /**
120
+ * Content hash. We don't need cryptographic strength — just a stable
121
+ * fingerprint so we can say "file A on local matches file A on remote". The
122
+ * contents are small JSON in practice, so sha1 is plenty fast.
123
+ */
124
+ function hashBytes(buf) {
125
+ return createHash('sha1').update(buf).digest('hex');
126
+ }