@ktpartners/dgs-platform 2.6.3 → 2.7.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/commands/dgs/sync.md +70 -0
- package/deliver-great-systems/bin/dgs-tools.cjs +290 -4
- package/deliver-great-systems/bin/lib/config.cjs +259 -67
- package/deliver-great-systems/bin/lib/core.cjs +49 -8
- package/deliver-great-systems/bin/lib/core.test.cjs +35 -14
- package/deliver-great-systems/bin/lib/init.cjs +61 -6
- package/deliver-great-systems/bin/lib/init.test.cjs +5 -5
- package/deliver-great-systems/bin/lib/migration.test.cjs +4 -3
- package/deliver-great-systems/bin/lib/paths.cjs +32 -22
- package/deliver-great-systems/bin/lib/paths.test.cjs +16 -6
- package/deliver-great-systems/bin/lib/repos.cjs +29 -10
- package/deliver-great-systems/bin/lib/sync.cjs +878 -0
- package/deliver-great-systems/bin/lib/test-helpers.cjs +42 -10
- package/deliver-great-systems/references/git-integration.md +81 -0
- package/deliver-great-systems/references/planning-config.md +154 -31
- package/deliver-great-systems/references/sync-cadence.md +191 -0
- package/deliver-great-systems/references/sync-hooks.md +96 -0
- package/deliver-great-systems/test/cadence.test.cjs +160 -0
- package/deliver-great-systems/test/sync-workflow.test.cjs +562 -0
- package/deliver-great-systems/workflows/execute-phase.md +111 -4
- package/deliver-great-systems/workflows/init-product.md +6 -2
- package/deliver-great-systems/workflows/run-job.md +77 -2
- package/deliver-great-systems/workflows/settings.md +82 -1
- package/package.json +1 -1
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync — Pull/push operations across all registered repos
|
|
3
|
+
*
|
|
4
|
+
* Provides pullAll and pushAll that iterate planning repo + REPOS.md code repos,
|
|
5
|
+
* with pre-flight checks, error handling, and structured result reporting.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { execSync } = require('child_process');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const { execGit, safeReadFile, loadConfig } = require('./core.cjs');
|
|
12
|
+
const { parseReposMd } = require('./repos.cjs');
|
|
13
|
+
const { getPlanningRoot } = require('./paths.cjs');
|
|
14
|
+
const { getLocalConfigPath } = require('./config.cjs');
|
|
15
|
+
|
|
16
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Execute a git command with a timeout.
|
|
20
|
+
*
|
|
21
|
+
* Wrapper around execSync (NOT execGit) that adds a timeout option.
|
|
22
|
+
* The existing execGit in core.cjs does not support timeouts and we
|
|
23
|
+
* should not modify it (other callers don't need timeouts).
|
|
24
|
+
*
|
|
25
|
+
* @param {string} cwd - Working directory
|
|
26
|
+
* @param {string[]} args - Git command arguments
|
|
27
|
+
* @param {number} timeoutMs - Timeout in milliseconds (default 30000)
|
|
28
|
+
* @returns {{ exitCode: number, stdout: string, stderr: string, isTimeout?: boolean, isAuth?: boolean }}
|
|
29
|
+
*/
|
|
30
|
+
function execGitWithTimeout(cwd, args, timeoutMs = 30000) {
|
|
31
|
+
try {
|
|
32
|
+
const escaped = args.map(a => {
|
|
33
|
+
if (/^[a-zA-Z0-9._\-/=:@]+$/.test(a)) return a;
|
|
34
|
+
return "'" + a.replace(/'/g, "'\\''") + "'";
|
|
35
|
+
});
|
|
36
|
+
const stdout = execSync('git ' + escaped.join(' '), {
|
|
37
|
+
cwd,
|
|
38
|
+
stdio: 'pipe',
|
|
39
|
+
encoding: 'utf-8',
|
|
40
|
+
timeout: timeoutMs,
|
|
41
|
+
});
|
|
42
|
+
return { exitCode: 0, stdout: stdout.trim(), stderr: '' };
|
|
43
|
+
} catch (err) {
|
|
44
|
+
// Classify error
|
|
45
|
+
const stderr = (err.stderr ?? '').toString().trim();
|
|
46
|
+
const isTimeout = err.killed || (err.signal === 'SIGTERM');
|
|
47
|
+
const isAuth = /authentication|permission denied|could not read.*credentials|fatal: unable to access/i.test(stderr);
|
|
48
|
+
return {
|
|
49
|
+
exitCode: err.status ?? 1,
|
|
50
|
+
stdout: (err.stdout ?? '').toString().trim(),
|
|
51
|
+
stderr,
|
|
52
|
+
isTimeout,
|
|
53
|
+
isAuth,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if a repo has a remote configured.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} repoPath - Absolute path to the repo
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
function hasRemote(repoPath) {
|
|
65
|
+
const result = execGitWithTimeout(repoPath, ['remote'], 5000);
|
|
66
|
+
return result.exitCode === 0 && result.stdout.trim().length > 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get the current branch name for a repo.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} repoPath - Absolute path to the repo
|
|
73
|
+
* @returns {string|null} Branch name, or null on error
|
|
74
|
+
*/
|
|
75
|
+
function getCurrentBranch(repoPath) {
|
|
76
|
+
const result = execGitWithTimeout(repoPath, ['rev-parse', '--abbrev-ref', 'HEAD'], 5000);
|
|
77
|
+
if (result.exitCode !== 0) return null;
|
|
78
|
+
return result.stdout.trim();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Check if a branch has an upstream tracking branch configured.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} repoPath - Absolute path to the repo
|
|
85
|
+
* @param {string} branch - Branch name
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
function hasUpstreamTracking(repoPath, branch) {
|
|
89
|
+
const result = execGitWithTimeout(repoPath, ['config', '--get', `branch.${branch}.remote`], 5000);
|
|
90
|
+
return result.exitCode === 0 && result.stdout.trim().length > 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Translate raw git errors into user-friendly messages.
|
|
95
|
+
*
|
|
96
|
+
* @param {Object} result - execGitWithTimeout result
|
|
97
|
+
* @param {string} operation - 'fetch', 'pull', or 'push'
|
|
98
|
+
* @param {string} repoName - Repo display name
|
|
99
|
+
* @param {string} repoPath - Absolute path to the repo
|
|
100
|
+
* @returns {string} User-friendly error message
|
|
101
|
+
*/
|
|
102
|
+
function classifyError(result, operation, repoName, repoPath) {
|
|
103
|
+
if (result.isTimeout) {
|
|
104
|
+
return 'Network timeout after 30s -- check connection or try again';
|
|
105
|
+
}
|
|
106
|
+
if (result.isAuth) {
|
|
107
|
+
return 'Authentication failed -- check credentials for remote';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const stderr = result.stderr || '';
|
|
111
|
+
|
|
112
|
+
// Non-fast-forward on pull/fetch+merge
|
|
113
|
+
if ((operation === 'pull' || operation === 'fetch') && /not possible to fast-forward|non-fast-forward/i.test(stderr)) {
|
|
114
|
+
const branch = getCurrentBranch(repoPath) || 'main';
|
|
115
|
+
return `Cannot fast-forward -- history has diverged. Fix: cd ${repoPath} && git pull --rebase origin ${branch}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Rejected push
|
|
119
|
+
if (operation === 'push' && /rejected|failed to push/i.test(stderr)) {
|
|
120
|
+
const branch = getCurrentBranch(repoPath) || 'main';
|
|
121
|
+
return `Push rejected -- remote has new commits. Fix: cd ${repoPath} && git pull --rebase origin ${branch} && git push`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Default: first line of stderr
|
|
125
|
+
const firstLine = stderr.split('\n')[0];
|
|
126
|
+
return firstLine || `Git ${operation} failed (exit code ${result.exitCode})`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── Repo Collection ─────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build the list of repos to sync.
|
|
133
|
+
*
|
|
134
|
+
* Returns an array of { name, path, isPlanning } objects.
|
|
135
|
+
* First entry is the planning repo, remaining entries from REPOS.md.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} cwd - Working directory (product root)
|
|
138
|
+
* @returns {Array<{ name: string, path: string, isPlanning: boolean }>}
|
|
139
|
+
*/
|
|
140
|
+
function collectSyncRepos(cwd) {
|
|
141
|
+
const repos = [];
|
|
142
|
+
|
|
143
|
+
// First entry: the planning repo itself (cwd is the product root containing .planning/)
|
|
144
|
+
const resolvedCwd = path.resolve(cwd);
|
|
145
|
+
const basename = path.basename(resolvedCwd);
|
|
146
|
+
|
|
147
|
+
// Check if cwd is a git repo
|
|
148
|
+
const isGitRepo = fs.existsSync(path.join(resolvedCwd, '.git'));
|
|
149
|
+
if (isGitRepo) {
|
|
150
|
+
repos.push({
|
|
151
|
+
name: basename,
|
|
152
|
+
path: resolvedCwd,
|
|
153
|
+
isPlanning: true,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Remaining entries: parse REPOS.md
|
|
158
|
+
const parsed = parseReposMd(cwd);
|
|
159
|
+
if (parsed && parsed.repos) {
|
|
160
|
+
for (const repo of parsed.repos) {
|
|
161
|
+
if (!repo.name || !repo.path) continue;
|
|
162
|
+
const absPath = path.isAbsolute(repo.path)
|
|
163
|
+
? repo.path
|
|
164
|
+
: path.resolve(cwd, repo.path);
|
|
165
|
+
repos.push({
|
|
166
|
+
name: repo.name,
|
|
167
|
+
path: absPath,
|
|
168
|
+
isPlanning: false,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return repos;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── Pre-flight Checks ──────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Check ALL repos before blocking -- reports all problems at once.
|
|
180
|
+
*
|
|
181
|
+
* @param {Array<{ name: string, path: string, isPlanning: boolean }>} repos
|
|
182
|
+
* @returns {{ ok: boolean, problems: Array<{ repo: string, path: string, issue: string, fix: string }> }}
|
|
183
|
+
*/
|
|
184
|
+
function preFlightCheck(repos) {
|
|
185
|
+
const problems = [];
|
|
186
|
+
|
|
187
|
+
for (const repo of repos) {
|
|
188
|
+
const label = repo.isPlanning ? `${repo.name} (planning)` : repo.name;
|
|
189
|
+
|
|
190
|
+
// Find the actual .git directory (handles worktrees and submodules)
|
|
191
|
+
const gitDirResult = execGitWithTimeout(repo.path, ['rev-parse', '--git-dir'], 5000);
|
|
192
|
+
if (gitDirResult.exitCode !== 0) {
|
|
193
|
+
problems.push({
|
|
194
|
+
repo: label,
|
|
195
|
+
path: repo.path,
|
|
196
|
+
issue: 'not a git repository',
|
|
197
|
+
fix: `cd ${repo.path} && git init`,
|
|
198
|
+
});
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const gitDir = path.isAbsolute(gitDirResult.stdout)
|
|
203
|
+
? gitDirResult.stdout
|
|
204
|
+
: path.resolve(repo.path, gitDirResult.stdout);
|
|
205
|
+
|
|
206
|
+
// Dirty state: git status --porcelain
|
|
207
|
+
const statusResult = execGitWithTimeout(repo.path, ['status', '--porcelain'], 5000);
|
|
208
|
+
if (statusResult.exitCode === 0 && statusResult.stdout.trim().length > 0) {
|
|
209
|
+
problems.push({
|
|
210
|
+
repo: label,
|
|
211
|
+
path: repo.path,
|
|
212
|
+
issue: 'uncommitted changes',
|
|
213
|
+
fix: `cd ${repo.path} && git add -A && git commit -m "WIP" or git stash`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Mid-merge: check for MERGE_HEAD
|
|
218
|
+
if (fs.existsSync(path.join(gitDir, 'MERGE_HEAD'))) {
|
|
219
|
+
problems.push({
|
|
220
|
+
repo: label,
|
|
221
|
+
path: repo.path,
|
|
222
|
+
issue: 'merge in progress',
|
|
223
|
+
fix: `cd ${repo.path} && git merge --abort or git merge --continue`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Mid-rebase: check for rebase-merge/ or rebase-apply/
|
|
228
|
+
if (fs.existsSync(path.join(gitDir, 'rebase-merge')) || fs.existsSync(path.join(gitDir, 'rebase-apply'))) {
|
|
229
|
+
problems.push({
|
|
230
|
+
repo: label,
|
|
231
|
+
path: repo.path,
|
|
232
|
+
issue: 'rebase in progress',
|
|
233
|
+
fix: `cd ${repo.path} && git rebase --abort or git rebase --continue`,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Mid-cherry-pick: check for CHERRY_PICK_HEAD
|
|
238
|
+
if (fs.existsSync(path.join(gitDir, 'CHERRY_PICK_HEAD'))) {
|
|
239
|
+
problems.push({
|
|
240
|
+
repo: label,
|
|
241
|
+
path: repo.path,
|
|
242
|
+
issue: 'cherry-pick in progress',
|
|
243
|
+
fix: `cd ${repo.path} && git cherry-pick --abort`,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Detached HEAD: git symbolic-ref HEAD
|
|
248
|
+
const headResult = execGitWithTimeout(repo.path, ['symbolic-ref', 'HEAD'], 5000);
|
|
249
|
+
if (headResult.exitCode !== 0) {
|
|
250
|
+
problems.push({
|
|
251
|
+
repo: label,
|
|
252
|
+
path: repo.path,
|
|
253
|
+
issue: 'detached HEAD',
|
|
254
|
+
fix: `cd ${repo.path} && git checkout <branch>`,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
ok: problems.length === 0,
|
|
261
|
+
problems,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ─── Pull ────────────────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Pull from all registered repos (planning + REPOS.md code repos).
|
|
269
|
+
*
|
|
270
|
+
* Uses fetch + merge --ff-only (not git pull) to get accurate commit counts
|
|
271
|
+
* before merging and to handle missing origin/<branch> gracefully.
|
|
272
|
+
*
|
|
273
|
+
* Continues processing all repos on failure rather than aborting on first failure.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} cwd - Working directory (product root)
|
|
276
|
+
* @param {Object} [options]
|
|
277
|
+
* @param {boolean} [options.dryRun=false] - Report what would happen without modifying repos
|
|
278
|
+
* @param {boolean} [options.force=false] - Bypass pre-flight checks
|
|
279
|
+
* @returns {{ ok: boolean, results: Array<{ repo: string, path: string, status: string, commits: number|null, message: string }>, problems?: Array, summary: string }}
|
|
280
|
+
*/
|
|
281
|
+
function pullAll(cwd, options = {}) {
|
|
282
|
+
const { dryRun = false, force = false } = options;
|
|
283
|
+
const repos = collectSyncRepos(cwd);
|
|
284
|
+
const results = [];
|
|
285
|
+
|
|
286
|
+
// Pre-flight (unless forced or dry-run)
|
|
287
|
+
if (!force && !dryRun) {
|
|
288
|
+
const preflight = preFlightCheck(repos);
|
|
289
|
+
if (!preflight.ok) {
|
|
290
|
+
return {
|
|
291
|
+
ok: false,
|
|
292
|
+
results: [],
|
|
293
|
+
problems: preflight.problems,
|
|
294
|
+
summary: `Pre-flight failed: ${preflight.problems.length} repo(s) have issues`,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
for (const repo of repos) {
|
|
300
|
+
const label = repo.isPlanning ? `${repo.name} (planning)` : repo.name;
|
|
301
|
+
|
|
302
|
+
// Skip repos with no remote
|
|
303
|
+
if (!hasRemote(repo.path)) {
|
|
304
|
+
results.push({ repo: label, path: repo.path, status: 'skipped', commits: null, message: 'No remote configured' });
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (dryRun) {
|
|
309
|
+
results.push({ repo: label, path: repo.path, status: 'current', commits: null, message: 'Would pull' });
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const branch = getCurrentBranch(repo.path);
|
|
314
|
+
if (!branch || branch === 'HEAD') {
|
|
315
|
+
results.push({ repo: label, path: repo.path, status: 'skipped', commits: null, message: 'Detached HEAD' });
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Fetch first to check what would change
|
|
320
|
+
const fetchResult = execGitWithTimeout(repo.path, ['fetch', 'origin', branch], 30000);
|
|
321
|
+
if (fetchResult.exitCode !== 0) {
|
|
322
|
+
const msg = classifyError(fetchResult, 'fetch', repo.name, repo.path);
|
|
323
|
+
results.push({ repo: label, path: repo.path, status: 'failed', commits: null, message: msg });
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Check if there are new commits to pull
|
|
328
|
+
const logResult = execGitWithTimeout(repo.path, ['rev-list', '--count', `HEAD..origin/${branch}`], 5000);
|
|
329
|
+
const commitCount = logResult.exitCode === 0 ? parseInt(logResult.stdout, 10) : 0;
|
|
330
|
+
|
|
331
|
+
if (commitCount === 0) {
|
|
332
|
+
results.push({ repo: label, path: repo.path, status: 'current', commits: 0, message: 'Already up to date' });
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Pull with --ff-only (via merge)
|
|
337
|
+
const pullResult = execGitWithTimeout(repo.path, ['merge', '--ff-only', `origin/${branch}`], 30000);
|
|
338
|
+
if (pullResult.exitCode !== 0) {
|
|
339
|
+
const msg = classifyError(pullResult, 'pull', repo.name, repo.path);
|
|
340
|
+
results.push({ repo: label, path: repo.path, status: 'failed', commits: null, message: msg });
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
results.push({ repo: label, path: repo.path, status: 'updated', commits: commitCount, message: `Pulled ${commitCount} commit${commitCount !== 1 ? 's' : ''}` });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Build summary
|
|
348
|
+
const updated = results.filter(r => r.status === 'updated').length;
|
|
349
|
+
const current = results.filter(r => r.status === 'current').length;
|
|
350
|
+
const skipped = results.filter(r => r.status === 'skipped').length;
|
|
351
|
+
const failed = results.filter(r => r.status === 'failed').length;
|
|
352
|
+
const parts = [];
|
|
353
|
+
if (updated) parts.push(`${updated} updated`);
|
|
354
|
+
if (current) parts.push(`${current} current`);
|
|
355
|
+
if (skipped) parts.push(`${skipped} skipped`);
|
|
356
|
+
if (failed) parts.push(`${failed} failed`);
|
|
357
|
+
const summary = dryRun ? `Dry run: ${repos.length} repo(s) checked` : parts.join(', ');
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
ok: failed === 0,
|
|
361
|
+
results,
|
|
362
|
+
summary,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ─── Push ────────────────────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Push to all registered repos (planning + REPOS.md code repos).
|
|
370
|
+
*
|
|
371
|
+
* Uses -u origin <branch> on first push when no upstream tracking exists.
|
|
372
|
+
* Continues past failures and reports all results at end.
|
|
373
|
+
*
|
|
374
|
+
* @param {string} cwd - Working directory (product root)
|
|
375
|
+
* @param {Object} [options]
|
|
376
|
+
* @param {boolean} [options.dryRun=false] - Report what would happen without modifying repos
|
|
377
|
+
* @param {boolean} [options.force=false] - Bypass pre-flight checks
|
|
378
|
+
* @param {string[]|null} [options.repos=null] - Filter to push only specific repos by name
|
|
379
|
+
* @returns {{ ok: boolean, results: Array<{ repo: string, path: string, status: string, commits: number|null, message: string }>, problems?: Array, summary: string }}
|
|
380
|
+
*/
|
|
381
|
+
function pushAll(cwd, options = {}) {
|
|
382
|
+
const { dryRun = false, force = false, repos: repoFilter = null } = options;
|
|
383
|
+
let allRepos = collectSyncRepos(cwd);
|
|
384
|
+
|
|
385
|
+
// Apply --repos filter if specified
|
|
386
|
+
if (repoFilter && repoFilter.length > 0) {
|
|
387
|
+
const filterSet = new Set(repoFilter.map(r => r.toLowerCase()));
|
|
388
|
+
allRepos = allRepos.filter(r => filterSet.has(r.name.toLowerCase()));
|
|
389
|
+
if (allRepos.length === 0) {
|
|
390
|
+
return { ok: false, results: [], summary: 'No matching repos found for filter' };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const results = [];
|
|
395
|
+
|
|
396
|
+
// Pre-flight (unless forced or dry-run)
|
|
397
|
+
if (!force && !dryRun) {
|
|
398
|
+
const preflight = preFlightCheck(allRepos);
|
|
399
|
+
if (!preflight.ok) {
|
|
400
|
+
return {
|
|
401
|
+
ok: false,
|
|
402
|
+
results: [],
|
|
403
|
+
problems: preflight.problems,
|
|
404
|
+
summary: `Pre-flight failed: ${preflight.problems.length} repo(s) have issues`,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
for (const repo of allRepos) {
|
|
410
|
+
const label = repo.isPlanning ? `${repo.name} (planning)` : repo.name;
|
|
411
|
+
|
|
412
|
+
// Skip repos with no remote
|
|
413
|
+
if (!hasRemote(repo.path)) {
|
|
414
|
+
results.push({ repo: label, path: repo.path, status: 'skipped', commits: null, message: 'No remote configured' });
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const branch = getCurrentBranch(repo.path);
|
|
419
|
+
if (!branch || branch === 'HEAD') {
|
|
420
|
+
results.push({ repo: label, path: repo.path, status: 'skipped', commits: null, message: 'Detached HEAD' });
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (dryRun) {
|
|
425
|
+
// Check if there are unpushed commits
|
|
426
|
+
const upstream = hasUpstreamTracking(repo.path, branch);
|
|
427
|
+
if (upstream) {
|
|
428
|
+
const logResult = execGitWithTimeout(repo.path, ['rev-list', '--count', `origin/${branch}..HEAD`], 5000);
|
|
429
|
+
const count = logResult.exitCode === 0 ? parseInt(logResult.stdout, 10) : 0;
|
|
430
|
+
if (count > 0) {
|
|
431
|
+
results.push({ repo: label, path: repo.path, status: 'pushed', commits: count, message: `Would push ${count} commit${count !== 1 ? 's' : ''}` });
|
|
432
|
+
} else {
|
|
433
|
+
results.push({ repo: label, path: repo.path, status: 'current', commits: 0, message: 'Nothing to push' });
|
|
434
|
+
}
|
|
435
|
+
} else {
|
|
436
|
+
results.push({ repo: label, path: repo.path, status: 'pushed', commits: null, message: 'Would push (first push, sets upstream)' });
|
|
437
|
+
}
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Check if there are commits to push
|
|
442
|
+
const upstream = hasUpstreamTracking(repo.path, branch);
|
|
443
|
+
let commitCount = null;
|
|
444
|
+
|
|
445
|
+
if (upstream) {
|
|
446
|
+
const logResult = execGitWithTimeout(repo.path, ['rev-list', '--count', `origin/${branch}..HEAD`], 5000);
|
|
447
|
+
commitCount = logResult.exitCode === 0 ? parseInt(logResult.stdout, 10) : null;
|
|
448
|
+
|
|
449
|
+
if (commitCount === 0) {
|
|
450
|
+
results.push({ repo: label, path: repo.path, status: 'current', commits: 0, message: 'Nothing to push' });
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Push -- use -u origin <branch> if no upstream tracking
|
|
456
|
+
const pushArgs = upstream
|
|
457
|
+
? ['push', 'origin', branch]
|
|
458
|
+
: ['push', '-u', 'origin', branch];
|
|
459
|
+
|
|
460
|
+
const pushResult = execGitWithTimeout(repo.path, pushArgs, 30000);
|
|
461
|
+
if (pushResult.exitCode !== 0) {
|
|
462
|
+
const msg = classifyError(pushResult, 'push', repo.name, repo.path);
|
|
463
|
+
results.push({ repo: label, path: repo.path, status: 'failed', commits: null, message: msg });
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const commitLabel = commitCount != null ? `${commitCount} commit${commitCount !== 1 ? 's' : ''}` : 'branch';
|
|
468
|
+
results.push({ repo: label, path: repo.path, status: 'pushed', commits: commitCount, message: `Pushed ${commitLabel}` });
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Build summary
|
|
472
|
+
const pushed = results.filter(r => r.status === 'pushed').length;
|
|
473
|
+
const current = results.filter(r => r.status === 'current').length;
|
|
474
|
+
const skipped = results.filter(r => r.status === 'skipped').length;
|
|
475
|
+
const failed = results.filter(r => r.status === 'failed').length;
|
|
476
|
+
const totalCommits = results.reduce((sum, r) => sum + (r.commits || 0), 0);
|
|
477
|
+
const parts = [];
|
|
478
|
+
if (pushed) parts.push(`${pushed} pushed`);
|
|
479
|
+
if (current) parts.push(`${current} current`);
|
|
480
|
+
if (skipped) parts.push(`${skipped} skipped`);
|
|
481
|
+
if (failed) parts.push(`${failed} failed`);
|
|
482
|
+
let summary = dryRun ? `Dry run: ${allRepos.length} repo(s) checked` : parts.join(', ');
|
|
483
|
+
if (!dryRun && totalCommits > 0) {
|
|
484
|
+
summary += ` (${totalCommits} commit${totalCommits !== 1 ? 's' : ''} total)`;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
ok: failed === 0,
|
|
489
|
+
results,
|
|
490
|
+
summary,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ─── Suppression Tracking ─────────────────────────────────────────────────────
|
|
495
|
+
|
|
496
|
+
// Suppression window: 5 minutes. Persists across CLI invocations via temp files.
|
|
497
|
+
const SUPPRESSION_WINDOW_MS = 5 * 60 * 1000;
|
|
498
|
+
const SUPPRESSION_DIR = path.join(require('os').tmpdir(), 'dgs-sync');
|
|
499
|
+
|
|
500
|
+
function getSuppressionFile(direction) {
|
|
501
|
+
return path.join(SUPPRESSION_DIR, `${direction}.ts`);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function isWithinSuppressionWindow(direction) {
|
|
505
|
+
try {
|
|
506
|
+
const ts = parseInt(fs.readFileSync(getSuppressionFile(direction), 'utf-8'), 10);
|
|
507
|
+
return (Date.now() - ts) < SUPPRESSION_WINDOW_MS;
|
|
508
|
+
} catch { return false; }
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function recordPromptYes(direction) {
|
|
512
|
+
fs.mkdirSync(SUPPRESSION_DIR, { recursive: true });
|
|
513
|
+
fs.writeFileSync(getSuppressionFile(direction), String(Date.now()));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ─── First-Run Hint Tracking ─────────────────────────────────────────────────
|
|
517
|
+
|
|
518
|
+
function shouldShowFirstRunHint(cwd) {
|
|
519
|
+
// Show hint when: remote exists, sync mode is 'off' or 'manual', hint not already shown
|
|
520
|
+
const config = loadConfig(cwd);
|
|
521
|
+
const syncPush = config.sync_push || 'off';
|
|
522
|
+
const syncPull = config.sync_pull || 'off';
|
|
523
|
+
if (syncPush !== 'off' || syncPull !== 'off') return false; // sync is configured, no hint needed
|
|
524
|
+
if (config.sync_hint_shown) return false; // already shown
|
|
525
|
+
// Check if any repo has a remote
|
|
526
|
+
const repos = collectSyncRepos(cwd);
|
|
527
|
+
return repos.some(r => hasRemote(r.path));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function markFirstRunHintShown(cwd) {
|
|
531
|
+
// Write sync_hint_shown: true to config.local.json (top-level key, not nested under git.)
|
|
532
|
+
const configPath = getLocalConfigPath(cwd);
|
|
533
|
+
let raw = {};
|
|
534
|
+
try {
|
|
535
|
+
if (fs.existsSync(configPath)) {
|
|
536
|
+
raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
537
|
+
}
|
|
538
|
+
} catch { /* start fresh */ }
|
|
539
|
+
raw.sync_hint_shown = true;
|
|
540
|
+
const dir = path.dirname(configPath);
|
|
541
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
542
|
+
fs.writeFileSync(configPath, JSON.stringify(raw, null, 2) + '\n');
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ─── Stale-State Detection ───────────────────────────────────────────────────
|
|
546
|
+
|
|
547
|
+
function checkStaleState(cwd) {
|
|
548
|
+
// Check if planning repo's remote is ahead. Returns { stale, commitsBehind, branch }.
|
|
549
|
+
const repos = collectSyncRepos(cwd);
|
|
550
|
+
const planningRepo = repos.find(r => r.isPlanning);
|
|
551
|
+
if (!planningRepo || !hasRemote(planningRepo.path)) return { stale: false, commitsBehind: 0, branch: null };
|
|
552
|
+
|
|
553
|
+
const branch = getCurrentBranch(planningRepo.path);
|
|
554
|
+
if (!branch) return { stale: false, commitsBehind: 0, branch: null };
|
|
555
|
+
|
|
556
|
+
// Fetch without merging (silent)
|
|
557
|
+
const fetchResult = execGitWithTimeout(planningRepo.path, ['fetch', 'origin', branch], 10000);
|
|
558
|
+
if (fetchResult.exitCode !== 0) return { stale: false, commitsBehind: 0, branch }; // can't tell, don't warn
|
|
559
|
+
|
|
560
|
+
// Count commits behind
|
|
561
|
+
const logResult = execGitWithTimeout(planningRepo.path, ['rev-list', '--count', `HEAD..origin/${branch}`], 5000);
|
|
562
|
+
const commitsBehind = logResult.exitCode === 0 ? parseInt(logResult.stdout, 10) : 0;
|
|
563
|
+
|
|
564
|
+
return { stale: commitsBehind > 0, commitsBehind, branch };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ─── Workflow Pull ───────────────────────────────────────────────────────────
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Pull before workflow starts, respecting sync mode.
|
|
571
|
+
*
|
|
572
|
+
* @param {string} cwd - Working directory
|
|
573
|
+
* @param {Object} options
|
|
574
|
+
* @param {string} options.syncMode - 'off'|'prompt'|'auto' (from init sync_pull)
|
|
575
|
+
* @param {boolean} options.cadencePull - Whether this workflow should pull (from init cadence_pull)
|
|
576
|
+
* @param {Object} [options.io] - I/O callbacks for prompt mode { prompt(msg) => Promise<string>, stderr(msg) => void }
|
|
577
|
+
* @returns {Promise<{ action: 'pulled'|'skipped'|'aborted', result?: Object, message: string }>}
|
|
578
|
+
*/
|
|
579
|
+
async function workflowPull(cwd, options) {
|
|
580
|
+
const { syncMode, cadencePull, io } = options;
|
|
581
|
+
|
|
582
|
+
// No pull: cadence says no, or mode is off
|
|
583
|
+
if (!cadencePull || syncMode === 'off') {
|
|
584
|
+
// In manual/off mode with cadence_pull, show stale-state warning (WFL-08)
|
|
585
|
+
if (cadencePull && syncMode === 'off') {
|
|
586
|
+
const stale = checkStaleState(cwd);
|
|
587
|
+
if (stale.stale && io?.stderr) {
|
|
588
|
+
io.stderr(`Warning: origin/${stale.branch} is ${stale.commitsBehind} commit${stale.commitsBehind !== 1 ? 's' : ''} ahead. Consider pulling.`);
|
|
589
|
+
}
|
|
590
|
+
// First-run hint (WFL-12)
|
|
591
|
+
if (shouldShowFirstRunHint(cwd) && io?.stderr) {
|
|
592
|
+
io.stderr('Tip: Remote detected. Enable sync with /dgs:settings');
|
|
593
|
+
markFirstRunHintShown(cwd);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return { action: 'skipped', message: 'Sync pull not configured' };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Prompt mode
|
|
600
|
+
if (syncMode === 'prompt') {
|
|
601
|
+
// Check suppression window (WFL-11)
|
|
602
|
+
if (isWithinSuppressionWindow('pull')) {
|
|
603
|
+
// Auto-pull silently (recent Yes answer)
|
|
604
|
+
const result = pullAll(cwd, { force: false });
|
|
605
|
+
if (!result.ok) {
|
|
606
|
+
// Even suppressed, report failures
|
|
607
|
+
if (io?.stderr) io.stderr(`Pull failed: ${result.summary}`);
|
|
608
|
+
return { action: 'aborted', result, message: result.summary };
|
|
609
|
+
}
|
|
610
|
+
return { action: 'pulled', result, message: result.summary };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Prompt user
|
|
614
|
+
if (io?.prompt) {
|
|
615
|
+
const answer = await io.prompt('Pull from remote before starting? [Y/n]');
|
|
616
|
+
const no = /^n/i.test((answer || '').trim());
|
|
617
|
+
if (no) {
|
|
618
|
+
return { action: 'skipped', message: 'Pull declined by user' };
|
|
619
|
+
}
|
|
620
|
+
recordPromptYes('pull');
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Auto mode or prompt-accepted: execute pull
|
|
625
|
+
const result = pullAll(cwd, { force: false });
|
|
626
|
+
|
|
627
|
+
if (!result.ok) {
|
|
628
|
+
// Pull failure: detailed diagnosis (WFL-04)
|
|
629
|
+
const failedRepos = result.results?.filter(r => r.status === 'failed') || [];
|
|
630
|
+
const diagnosis = failedRepos.map(r => ` ${r.repo}: ${r.message}`).join('\n');
|
|
631
|
+
const preflightProblems = result.problems?.map(p => ` ${p.repo}: ${p.issue} -- ${p.fix}`).join('\n');
|
|
632
|
+
const detail = preflightProblems || diagnosis || result.summary;
|
|
633
|
+
|
|
634
|
+
if (io?.stderr) {
|
|
635
|
+
io.stderr(`Pull failed:\n${detail}`);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Offer override in prompt mode (WFL-04)
|
|
639
|
+
if (syncMode === 'prompt' && io?.prompt) {
|
|
640
|
+
const override = await io.prompt('Continue without pulling? [y/N]');
|
|
641
|
+
const yes = /^y/i.test((override || '').trim());
|
|
642
|
+
if (yes) {
|
|
643
|
+
return { action: 'skipped', message: 'Pull failed, user chose to continue' };
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return { action: 'aborted', result, message: detail };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return { action: 'pulled', result, message: result.summary };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ─── Workflow Push ───────────────────────────────────────────────────────────
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Push after workflow completes, respecting sync mode.
|
|
657
|
+
*
|
|
658
|
+
* @param {string} cwd - Working directory
|
|
659
|
+
* @param {Object} options
|
|
660
|
+
* @param {string} options.syncMode - 'off'|'prompt'|'auto' (from init sync_push)
|
|
661
|
+
* @param {boolean} options.cadencePush - Whether this workflow should push (from init cadence_push)
|
|
662
|
+
* @param {boolean} [options.midWorkflow=false] - True for mid-workflow pushes (no prompt, silent)
|
|
663
|
+
* @param {number} [options.maxRetries=1] - Max retries on failure (1 for prompt, 3 for auto)
|
|
664
|
+
* @param {Object} [options.io] - I/O callbacks { stderr(msg) => void, prompt(msg) => Promise<string> }
|
|
665
|
+
* @returns {Promise<{ action: 'pushed'|'skipped'|'warning', result?: Object, message: string }>}
|
|
666
|
+
*/
|
|
667
|
+
async function workflowPush(cwd, options) {
|
|
668
|
+
const { syncMode, cadencePush, midWorkflow = false, maxRetries = 1, io } = options;
|
|
669
|
+
|
|
670
|
+
// No push: cadence says no, or mode is off
|
|
671
|
+
if (!cadencePush || syncMode === 'off') {
|
|
672
|
+
return { action: 'skipped', message: 'Sync push not configured' };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Prompt mode (non-mid-workflow): ask user
|
|
676
|
+
if (syncMode === 'prompt' && !midWorkflow) {
|
|
677
|
+
// Check suppression window (WFL-11)
|
|
678
|
+
if (!isWithinSuppressionWindow('push')) {
|
|
679
|
+
if (io?.prompt) {
|
|
680
|
+
const answer = await io.prompt('Push to remote? [Y/n]');
|
|
681
|
+
const no = /^n/i.test((answer || '').trim());
|
|
682
|
+
if (no) {
|
|
683
|
+
return { action: 'skipped', message: 'Push declined by user' };
|
|
684
|
+
}
|
|
685
|
+
recordPromptYes('push');
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Execute push with retries
|
|
691
|
+
let lastResult;
|
|
692
|
+
const retries = midWorkflow ? maxRetries : 0; // Only retry mid-workflow pushes
|
|
693
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
694
|
+
lastResult = pushAll(cwd, { force: true }); // force: bypass preflight for push (already committed)
|
|
695
|
+
if (lastResult.ok) break;
|
|
696
|
+
// Don't retry if not mid-workflow
|
|
697
|
+
if (!midWorkflow) break;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (!lastResult.ok) {
|
|
701
|
+
// Push failure: warning only, do not abort (WFL-05)
|
|
702
|
+
const failedRepos = lastResult.results?.filter(r => r.status === 'failed') || [];
|
|
703
|
+
const failSummary = failedRepos.map(r => `${r.repo}: ${r.message}`).join(', ');
|
|
704
|
+
const msg = `Warning: push failed (${failSummary}). Work saved locally.`;
|
|
705
|
+
if (io?.stderr) io.stderr(msg);
|
|
706
|
+
return { action: 'warning', result: lastResult, message: msg };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Build push summary (WFL-09)
|
|
710
|
+
const totalCommits = lastResult.results.reduce((sum, r) => sum + (r.commits || 0), 0);
|
|
711
|
+
const repoCount = lastResult.results.filter(r => r.status === 'pushed').length;
|
|
712
|
+
let summaryMsg;
|
|
713
|
+
|
|
714
|
+
const failedCount = lastResult.results.filter(r => r.status === 'failed').length;
|
|
715
|
+
if (failedCount > 0) {
|
|
716
|
+
const unpushed = lastResult.results.filter(r => r.status === 'failed').reduce((s, r) => s + (r.commits || 0), 0);
|
|
717
|
+
summaryMsg = `Pushed ${totalCommits} commit${totalCommits !== 1 ? 's' : ''} across ${repoCount} repo${repoCount !== 1 ? 's' : ''} (${unpushed} commits unpushed -- see warnings above)`;
|
|
718
|
+
} else if (repoCount > 1) {
|
|
719
|
+
summaryMsg = `Pushed ${totalCommits} commit${totalCommits !== 1 ? 's' : ''} across ${repoCount} repos`;
|
|
720
|
+
} else if (totalCommits > 0) {
|
|
721
|
+
const branch = getCurrentBranch(collectSyncRepos(cwd)[0]?.path || cwd) || 'main';
|
|
722
|
+
summaryMsg = `Pushed ${totalCommits} commit${totalCommits !== 1 ? 's' : ''} to origin/${branch}`;
|
|
723
|
+
} else {
|
|
724
|
+
summaryMsg = 'Nothing to push';
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (!midWorkflow && io?.stderr) {
|
|
728
|
+
io.stderr(summaryMsg);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Log remote branch creation for mid-workflow (WFL-10)
|
|
732
|
+
if (midWorkflow) {
|
|
733
|
+
const newBranches = lastResult.results.filter(r => r.message?.includes('first push') || r.message?.includes('branch'));
|
|
734
|
+
for (const nb of newBranches) {
|
|
735
|
+
if (io?.stderr) {
|
|
736
|
+
const branch = getCurrentBranch(nb.path) || 'unknown';
|
|
737
|
+
io.stderr(`Created remote branch: ${branch}`);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
return { action: 'pushed', result: lastResult, message: summaryMsg };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// ─── Cadence Table ───────────────────────────────────────────────────────────
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Cadence lookup table mapping every DGS workflow to its sync classification.
|
|
749
|
+
*
|
|
750
|
+
* Each entry is keyed by the workflow name (slash command name without the
|
|
751
|
+
* `dgs:` prefix). Values are `{ pull: boolean, push: boolean }`.
|
|
752
|
+
*
|
|
753
|
+
* This is the source of truth for cadence classification. The documentation
|
|
754
|
+
* in sync-cadence.md (Plan 02) mirrors this table but is not parsed.
|
|
755
|
+
*
|
|
756
|
+
* Groups:
|
|
757
|
+
* Pull + Push (35): Workflows that read shared state AND produce artifacts
|
|
758
|
+
* Push only (17): Workflows that produce artifacts but don't need fresh state
|
|
759
|
+
* Pull only (4): Workflows that read shared state but don't produce artifacts
|
|
760
|
+
* No sync (14): Informational or diagnostic workflows
|
|
761
|
+
*/
|
|
762
|
+
const CADENCE_TABLE = {
|
|
763
|
+
// ── Pull + Push (35) ──────────────────────────────────────────────────────
|
|
764
|
+
'execute-phase': { pull: true, push: true },
|
|
765
|
+
'plan-phase': { pull: true, push: true },
|
|
766
|
+
'discuss-phase': { pull: true, push: true },
|
|
767
|
+
'research-phase': { pull: true, push: true },
|
|
768
|
+
'run-job': { pull: true, push: true },
|
|
769
|
+
'create-milestone-job': { pull: true, push: true },
|
|
770
|
+
'cancel-job': { pull: true, push: true },
|
|
771
|
+
'rollback-job': { pull: true, push: true },
|
|
772
|
+
'new-milestone': { pull: true, push: true },
|
|
773
|
+
'complete-milestone': { pull: true, push: true },
|
|
774
|
+
'audit-milestone': { pull: true, push: true },
|
|
775
|
+
'plan-milestone-gaps': { pull: true, push: true },
|
|
776
|
+
'cleanup': { pull: true, push: true },
|
|
777
|
+
'discuss-idea': { pull: true, push: true },
|
|
778
|
+
'develop-idea': { pull: true, push: true },
|
|
779
|
+
'research-idea': { pull: true, push: true },
|
|
780
|
+
'consolidate-ideas': { pull: true, push: true },
|
|
781
|
+
'undo-consolidation': { pull: true, push: true },
|
|
782
|
+
'write-spec': { pull: true, push: true },
|
|
783
|
+
'refine-spec': { pull: true, push: true },
|
|
784
|
+
'approve-spec': { pull: true, push: true },
|
|
785
|
+
'quick': { pull: true, push: true },
|
|
786
|
+
'fast': { pull: true, push: true },
|
|
787
|
+
'add-todo': { pull: true, push: true },
|
|
788
|
+
'check-todos': { pull: true, push: true },
|
|
789
|
+
'new-project': { pull: true, push: true },
|
|
790
|
+
'init-product': { pull: true, push: true },
|
|
791
|
+
'add-phase': { pull: true, push: true },
|
|
792
|
+
'insert-phase': { pull: true, push: true },
|
|
793
|
+
'remove-phase': { pull: true, push: true },
|
|
794
|
+
'audit-phase': { pull: true, push: true },
|
|
795
|
+
'validate-phase': { pull: true, push: true },
|
|
796
|
+
'verify-work': { pull: true, push: true },
|
|
797
|
+
'add-tests': { pull: true, push: true },
|
|
798
|
+
'settings': { pull: true, push: true },
|
|
799
|
+
|
|
800
|
+
// ── Push only (17) ────────────────────────────────────────────────────────
|
|
801
|
+
'pause-work': { pull: false, push: true },
|
|
802
|
+
'add-idea': { pull: false, push: true },
|
|
803
|
+
'import-spec': { pull: false, push: true },
|
|
804
|
+
'reject-idea': { pull: false, push: true },
|
|
805
|
+
'restore-idea': { pull: false, push: true },
|
|
806
|
+
'update-idea': { pull: false, push: true },
|
|
807
|
+
'add-doc': { pull: false, push: true },
|
|
808
|
+
'remove-doc': { pull: false, push: true },
|
|
809
|
+
'add-repo': { pull: false, push: true },
|
|
810
|
+
'remove-repo': { pull: false, push: true },
|
|
811
|
+
'capture-principle': { pull: false, push: true },
|
|
812
|
+
'complete-project': { pull: false, push: true },
|
|
813
|
+
'reactivate-project': { pull: false, push: true },
|
|
814
|
+
'switch-project': { pull: false, push: true },
|
|
815
|
+
'set-profile': { pull: false, push: true },
|
|
816
|
+
'map-codebase': { pull: false, push: true },
|
|
817
|
+
'reapply-patches': { pull: false, push: true },
|
|
818
|
+
|
|
819
|
+
// ── Pull only (4) ─────────────────────────────────────────────────────────
|
|
820
|
+
'resume-work': { pull: true, push: false },
|
|
821
|
+
'progress': { pull: true, push: false },
|
|
822
|
+
'find-related-ideas': { pull: true, push: false },
|
|
823
|
+
'list-phase-assumptions': { pull: true, push: false },
|
|
824
|
+
|
|
825
|
+
// ── No sync (14) ──────────────────────────────────────────────────────────
|
|
826
|
+
'help': { pull: false, push: false },
|
|
827
|
+
'join-discord': { pull: false, push: false },
|
|
828
|
+
'list-ideas': { pull: false, push: false },
|
|
829
|
+
'list-specs': { pull: false, push: false },
|
|
830
|
+
'list-docs': { pull: false, push: false },
|
|
831
|
+
'list-jobs': { pull: false, push: false },
|
|
832
|
+
'list-projects': { pull: false, push: false },
|
|
833
|
+
'search': { pull: false, push: false },
|
|
834
|
+
'overlap-check': { pull: false, push: false },
|
|
835
|
+
'health': { pull: false, push: false },
|
|
836
|
+
'update': { pull: false, push: false },
|
|
837
|
+
'debug': { pull: false, push: false },
|
|
838
|
+
'node-repair': { pull: false, push: false },
|
|
839
|
+
'sync-upstream': { pull: false, push: false },
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Get the cadence classification for a workflow.
|
|
844
|
+
*
|
|
845
|
+
* @param {string} workflowName - Workflow name (e.g., 'execute-phase', 'pause-work')
|
|
846
|
+
* @returns {{ pull: boolean, push: boolean }} Cadence flags. Unknown workflows default to no-sync.
|
|
847
|
+
*/
|
|
848
|
+
function getCadence(workflowName) {
|
|
849
|
+
return CADENCE_TABLE[workflowName] || { pull: false, push: false };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
853
|
+
|
|
854
|
+
module.exports = {
|
|
855
|
+
collectSyncRepos,
|
|
856
|
+
preFlightCheck,
|
|
857
|
+
pullAll,
|
|
858
|
+
pushAll,
|
|
859
|
+
// Workflow orchestration
|
|
860
|
+
workflowPull,
|
|
861
|
+
workflowPush,
|
|
862
|
+
checkStaleState,
|
|
863
|
+
shouldShowFirstRunHint,
|
|
864
|
+
markFirstRunHintShown,
|
|
865
|
+
// Cadence
|
|
866
|
+
CADENCE_TABLE,
|
|
867
|
+
getCadence,
|
|
868
|
+
// Exported for testing
|
|
869
|
+
execGitWithTimeout,
|
|
870
|
+
hasRemote,
|
|
871
|
+
getCurrentBranch,
|
|
872
|
+
hasUpstreamTracking,
|
|
873
|
+
classifyError,
|
|
874
|
+
isWithinSuppressionWindow,
|
|
875
|
+
recordPromptYes,
|
|
876
|
+
getSuppressionFile,
|
|
877
|
+
SUPPRESSION_WINDOW_MS,
|
|
878
|
+
};
|