@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,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DGS Tools Tests - Sync Workflow Orchestration
|
|
3
|
+
*
|
|
4
|
+
* Tests for workflowPull, workflowPush, checkStaleState,
|
|
5
|
+
* suppression tracking, and first-run hint functions from sync.cjs.
|
|
6
|
+
* Uses isolated git fixture repos (not mocks) for realistic testing.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
10
|
+
const assert = require('node:assert/strict');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const { execSync } = require('child_process');
|
|
15
|
+
|
|
16
|
+
const sync = require('../bin/lib/sync.cjs');
|
|
17
|
+
|
|
18
|
+
// ─── Test Helpers ────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create a bare remote and a cloned local repo for realistic push/pull testing.
|
|
22
|
+
*/
|
|
23
|
+
function createGitFixture(tmpBase, name) {
|
|
24
|
+
const remotePath = path.join(tmpBase, `${name}-remote.git`);
|
|
25
|
+
fs.mkdirSync(remotePath, { recursive: true });
|
|
26
|
+
execSync('git init --bare', { cwd: remotePath, stdio: 'pipe' });
|
|
27
|
+
|
|
28
|
+
const localPath = path.join(tmpBase, name);
|
|
29
|
+
execSync(`git clone "${remotePath}" "${localPath}"`, { stdio: 'pipe' });
|
|
30
|
+
|
|
31
|
+
execSync('git config user.email "test@test.com"', { cwd: localPath, stdio: 'pipe' });
|
|
32
|
+
execSync('git config user.name "Test"', { cwd: localPath, stdio: 'pipe' });
|
|
33
|
+
|
|
34
|
+
fs.writeFileSync(path.join(localPath, 'README.md'), '# Test');
|
|
35
|
+
execSync('git add -A && git commit -m "Initial commit"', { cwd: localPath, stdio: 'pipe' });
|
|
36
|
+
|
|
37
|
+
const branch = execSync('git branch --show-current', { cwd: localPath, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
38
|
+
execSync(`git push -u origin ${branch}`, { cwd: localPath, stdio: 'pipe' });
|
|
39
|
+
|
|
40
|
+
return { localPath, remotePath, branch };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Create a product directory (planning repo) with minimal DGS structure.
|
|
45
|
+
*/
|
|
46
|
+
function createProductFixture(tmpBase, configOverrides = {}) {
|
|
47
|
+
const { localPath, remotePath, branch } = createGitFixture(tmpBase, 'product');
|
|
48
|
+
|
|
49
|
+
fs.mkdirSync(path.join(localPath, '.planning'), { recursive: true });
|
|
50
|
+
|
|
51
|
+
const defaultConfig = { git: { sync_push: 'auto', sync_pull: 'auto' } };
|
|
52
|
+
const config = { ...defaultConfig, ...configOverrides };
|
|
53
|
+
if (configOverrides.git) {
|
|
54
|
+
config.git = { ...defaultConfig.git, ...configOverrides.git };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
fs.writeFileSync(
|
|
58
|
+
path.join(localPath, '.planning', 'config.json'),
|
|
59
|
+
JSON.stringify(config, null, 2)
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
execSync('git add -A && git commit -m "Add planning structure"', { cwd: localPath, stdio: 'pipe' });
|
|
63
|
+
execSync(`git push origin ${branch}`, { cwd: localPath, stdio: 'pipe' });
|
|
64
|
+
|
|
65
|
+
return { productPath: localPath, remotePath, branch };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Push a new commit to a bare remote via a temporary second clone.
|
|
70
|
+
*/
|
|
71
|
+
function pushCommitToRemote(remotePath, branch, tmpBase, fileName, message) {
|
|
72
|
+
const clonePath = path.join(tmpBase, 'remote-push-clone-' + Date.now());
|
|
73
|
+
execSync(`git clone "${remotePath}" "${clonePath}"`, { stdio: 'pipe' });
|
|
74
|
+
execSync('git config user.email "test@test.com"', { cwd: clonePath, stdio: 'pipe' });
|
|
75
|
+
execSync('git config user.name "Test"', { cwd: clonePath, stdio: 'pipe' });
|
|
76
|
+
fs.writeFileSync(path.join(clonePath, fileName), `Content: ${message}`);
|
|
77
|
+
execSync(`git add -A && git commit -m "${message}"`, { cwd: clonePath, stdio: 'pipe' });
|
|
78
|
+
execSync(`git push origin ${branch}`, { cwd: clonePath, stdio: 'pipe' });
|
|
79
|
+
fs.rmSync(clonePath, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Create a mock IO object with configurable prompt answers.
|
|
84
|
+
*/
|
|
85
|
+
function createMockIO(promptAnswers = []) {
|
|
86
|
+
const stderrLog = [];
|
|
87
|
+
const promptLog = [];
|
|
88
|
+
const answers = [...promptAnswers]; // copy to avoid mutation issues
|
|
89
|
+
return {
|
|
90
|
+
prompt: async (msg) => {
|
|
91
|
+
promptLog.push(msg);
|
|
92
|
+
return answers.shift() || '';
|
|
93
|
+
},
|
|
94
|
+
stderr: (msg) => stderrLog.push(msg),
|
|
95
|
+
stderrLog,
|
|
96
|
+
promptLog,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Clean up suppression temp files for a given direction.
|
|
102
|
+
*/
|
|
103
|
+
function cleanSuppression(direction) {
|
|
104
|
+
try { fs.unlinkSync(sync.getSuppressionFile(direction)); } catch {}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── workflowPull ────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
describe('workflowPull', () => {
|
|
110
|
+
let tmpDir;
|
|
111
|
+
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dgs-wfpull-'));
|
|
114
|
+
cleanSuppression('pull');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
119
|
+
cleanSuppression('pull');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('returns skipped when cadencePull is false', async () => {
|
|
123
|
+
const { productPath } = createProductFixture(tmpDir);
|
|
124
|
+
const result = await sync.workflowPull(productPath, {
|
|
125
|
+
syncMode: 'auto',
|
|
126
|
+
cadencePull: false,
|
|
127
|
+
io: createMockIO(),
|
|
128
|
+
});
|
|
129
|
+
assert.strictEqual(result.action, 'skipped');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('returns skipped when syncMode is off', async () => {
|
|
133
|
+
const { productPath } = createProductFixture(tmpDir);
|
|
134
|
+
const result = await sync.workflowPull(productPath, {
|
|
135
|
+
syncMode: 'off',
|
|
136
|
+
cadencePull: true,
|
|
137
|
+
io: createMockIO(),
|
|
138
|
+
});
|
|
139
|
+
assert.strictEqual(result.action, 'skipped');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('pulls silently in auto mode', async () => {
|
|
143
|
+
const { productPath, remotePath, branch } = createProductFixture(tmpDir);
|
|
144
|
+
pushCommitToRemote(remotePath, branch, tmpDir, 'auto-pull.txt', 'Auto pull commit');
|
|
145
|
+
|
|
146
|
+
const io = createMockIO();
|
|
147
|
+
const result = await sync.workflowPull(productPath, {
|
|
148
|
+
syncMode: 'auto',
|
|
149
|
+
cadencePull: true,
|
|
150
|
+
io,
|
|
151
|
+
});
|
|
152
|
+
assert.strictEqual(result.action, 'pulled');
|
|
153
|
+
assert.strictEqual(io.promptLog.length, 0, 'Should not prompt in auto mode');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('returns pulled with summary when remote has new commits', async () => {
|
|
157
|
+
const { productPath, remotePath, branch } = createProductFixture(tmpDir);
|
|
158
|
+
pushCommitToRemote(remotePath, branch, tmpDir, 'new-commit.txt', 'New commit');
|
|
159
|
+
|
|
160
|
+
const result = await sync.workflowPull(productPath, {
|
|
161
|
+
syncMode: 'auto',
|
|
162
|
+
cadencePull: true,
|
|
163
|
+
io: createMockIO(),
|
|
164
|
+
});
|
|
165
|
+
assert.strictEqual(result.action, 'pulled');
|
|
166
|
+
assert.ok(result.message, 'Should have a summary message');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('returns pulled with Already up to date when current', async () => {
|
|
170
|
+
const { productPath } = createProductFixture(tmpDir);
|
|
171
|
+
// No new commits pushed to remote -- already current
|
|
172
|
+
|
|
173
|
+
const result = await sync.workflowPull(productPath, {
|
|
174
|
+
syncMode: 'auto',
|
|
175
|
+
cadencePull: true,
|
|
176
|
+
io: createMockIO(),
|
|
177
|
+
});
|
|
178
|
+
assert.strictEqual(result.action, 'pulled');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('prompts in prompt mode and pulls on Yes', async () => {
|
|
182
|
+
const { productPath, remotePath, branch } = createProductFixture(tmpDir);
|
|
183
|
+
pushCommitToRemote(remotePath, branch, tmpDir, 'prompt-yes.txt', 'Prompt yes commit');
|
|
184
|
+
|
|
185
|
+
const io = createMockIO(['Y']);
|
|
186
|
+
const result = await sync.workflowPull(productPath, {
|
|
187
|
+
syncMode: 'prompt',
|
|
188
|
+
cadencePull: true,
|
|
189
|
+
io,
|
|
190
|
+
});
|
|
191
|
+
assert.strictEqual(result.action, 'pulled');
|
|
192
|
+
assert.ok(io.promptLog.length > 0, 'Should have prompted');
|
|
193
|
+
assert.ok(io.promptLog[0].includes('[Y/n]'), 'Prompt should include [Y/n]');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('skips pull on No answer in prompt mode', async () => {
|
|
197
|
+
const { productPath } = createProductFixture(tmpDir);
|
|
198
|
+
|
|
199
|
+
const io = createMockIO(['n']);
|
|
200
|
+
const result = await sync.workflowPull(productPath, {
|
|
201
|
+
syncMode: 'prompt',
|
|
202
|
+
cadencePull: true,
|
|
203
|
+
io,
|
|
204
|
+
});
|
|
205
|
+
assert.strictEqual(result.action, 'skipped');
|
|
206
|
+
assert.ok(result.message.includes('declined'), `Expected "declined" in message, got: ${result.message}`);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('aborts on pull failure with diagnosis in auto mode', async () => {
|
|
210
|
+
const { productPath, remotePath, branch } = createProductFixture(tmpDir);
|
|
211
|
+
|
|
212
|
+
// Create diverged history: push a commit to remote AND create a different local commit
|
|
213
|
+
pushCommitToRemote(remotePath, branch, tmpDir, 'diverge-remote.txt', 'Remote diverge');
|
|
214
|
+
|
|
215
|
+
// Create a local commit that diverges from remote
|
|
216
|
+
fs.writeFileSync(path.join(productPath, 'diverge-local.txt'), 'local diverge');
|
|
217
|
+
execSync('git add -A && git commit -m "Local diverge"', { cwd: productPath, stdio: 'pipe' });
|
|
218
|
+
|
|
219
|
+
// Now fetch so origin/branch is known, then try to ff-only merge which will fail
|
|
220
|
+
const io = createMockIO();
|
|
221
|
+
const result = await sync.workflowPull(productPath, {
|
|
222
|
+
syncMode: 'auto',
|
|
223
|
+
cadencePull: true,
|
|
224
|
+
io,
|
|
225
|
+
});
|
|
226
|
+
assert.strictEqual(result.action, 'aborted');
|
|
227
|
+
assert.ok(io.stderrLog.length > 0, 'Should have written diagnosis to stderr');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('offers override on pull failure in prompt mode and user accepts', async () => {
|
|
231
|
+
const { productPath, remotePath, branch } = createProductFixture(tmpDir);
|
|
232
|
+
|
|
233
|
+
// Diverge history
|
|
234
|
+
pushCommitToRemote(remotePath, branch, tmpDir, 'diverge-remote2.txt', 'Remote diverge 2');
|
|
235
|
+
fs.writeFileSync(path.join(productPath, 'diverge-local2.txt'), 'local diverge 2');
|
|
236
|
+
execSync('git add -A && git commit -m "Local diverge 2"', { cwd: productPath, stdio: 'pipe' });
|
|
237
|
+
|
|
238
|
+
// First answer: 'Y' to pull, second answer: 'y' to override (continue without pulling)
|
|
239
|
+
const io = createMockIO(['Y', 'y']);
|
|
240
|
+
const result = await sync.workflowPull(productPath, {
|
|
241
|
+
syncMode: 'prompt',
|
|
242
|
+
cadencePull: true,
|
|
243
|
+
io,
|
|
244
|
+
});
|
|
245
|
+
assert.strictEqual(result.action, 'skipped');
|
|
246
|
+
assert.ok(result.message.includes('continue'), `Expected "continue" in message, got: ${result.message}`);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('override declined returns aborted', async () => {
|
|
250
|
+
const { productPath, remotePath, branch } = createProductFixture(tmpDir);
|
|
251
|
+
|
|
252
|
+
// Diverge history
|
|
253
|
+
pushCommitToRemote(remotePath, branch, tmpDir, 'diverge-remote3.txt', 'Remote diverge 3');
|
|
254
|
+
fs.writeFileSync(path.join(productPath, 'diverge-local3.txt'), 'local diverge 3');
|
|
255
|
+
execSync('git add -A && git commit -m "Local diverge 3"', { cwd: productPath, stdio: 'pipe' });
|
|
256
|
+
|
|
257
|
+
// First answer: 'Y' to pull, second answer: 'n' to decline override
|
|
258
|
+
const io = createMockIO(['Y', 'n']);
|
|
259
|
+
const result = await sync.workflowPull(productPath, {
|
|
260
|
+
syncMode: 'prompt',
|
|
261
|
+
cadencePull: true,
|
|
262
|
+
io,
|
|
263
|
+
});
|
|
264
|
+
assert.strictEqual(result.action, 'aborted');
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ─── workflowPush ────────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
describe('workflowPush', () => {
|
|
271
|
+
let tmpDir;
|
|
272
|
+
|
|
273
|
+
beforeEach(() => {
|
|
274
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dgs-wfpush-'));
|
|
275
|
+
cleanSuppression('push');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
afterEach(() => {
|
|
279
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
280
|
+
cleanSuppression('push');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('returns skipped when cadencePush is false', async () => {
|
|
284
|
+
const { productPath } = createProductFixture(tmpDir);
|
|
285
|
+
const result = await sync.workflowPush(productPath, {
|
|
286
|
+
syncMode: 'auto',
|
|
287
|
+
cadencePush: false,
|
|
288
|
+
io: createMockIO(),
|
|
289
|
+
});
|
|
290
|
+
assert.strictEqual(result.action, 'skipped');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('returns skipped when syncMode is off', async () => {
|
|
294
|
+
const { productPath } = createProductFixture(tmpDir);
|
|
295
|
+
const result = await sync.workflowPush(productPath, {
|
|
296
|
+
syncMode: 'off',
|
|
297
|
+
cadencePush: true,
|
|
298
|
+
io: createMockIO(),
|
|
299
|
+
});
|
|
300
|
+
assert.strictEqual(result.action, 'skipped');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('pushes silently in auto mode', async () => {
|
|
304
|
+
const { productPath } = createProductFixture(tmpDir);
|
|
305
|
+
|
|
306
|
+
// Create a local commit to push
|
|
307
|
+
fs.writeFileSync(path.join(productPath, 'auto-push.txt'), 'auto push');
|
|
308
|
+
execSync('git add -A && git commit -m "Auto push commit"', { cwd: productPath, stdio: 'pipe' });
|
|
309
|
+
|
|
310
|
+
const io = createMockIO();
|
|
311
|
+
const result = await sync.workflowPush(productPath, {
|
|
312
|
+
syncMode: 'auto',
|
|
313
|
+
cadencePush: true,
|
|
314
|
+
io,
|
|
315
|
+
});
|
|
316
|
+
assert.strictEqual(result.action, 'pushed');
|
|
317
|
+
assert.strictEqual(io.promptLog.length, 0, 'Should not prompt in auto mode');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('prompts in prompt mode and pushes on Yes', async () => {
|
|
321
|
+
const { productPath } = createProductFixture(tmpDir);
|
|
322
|
+
|
|
323
|
+
fs.writeFileSync(path.join(productPath, 'prompt-push.txt'), 'prompt push');
|
|
324
|
+
execSync('git add -A && git commit -m "Prompt push commit"', { cwd: productPath, stdio: 'pipe' });
|
|
325
|
+
|
|
326
|
+
const io = createMockIO(['Y']);
|
|
327
|
+
const result = await sync.workflowPush(productPath, {
|
|
328
|
+
syncMode: 'prompt',
|
|
329
|
+
cadencePush: true,
|
|
330
|
+
io,
|
|
331
|
+
});
|
|
332
|
+
assert.strictEqual(result.action, 'pushed');
|
|
333
|
+
assert.ok(io.promptLog.length > 0, 'Should have prompted');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('skips push on No answer in prompt mode', async () => {
|
|
337
|
+
const { productPath } = createProductFixture(tmpDir);
|
|
338
|
+
|
|
339
|
+
const io = createMockIO(['n']);
|
|
340
|
+
const result = await sync.workflowPush(productPath, {
|
|
341
|
+
syncMode: 'prompt',
|
|
342
|
+
cadencePush: true,
|
|
343
|
+
io,
|
|
344
|
+
});
|
|
345
|
+
assert.strictEqual(result.action, 'skipped');
|
|
346
|
+
assert.ok(result.message.includes('declined'), `Expected "declined" in message, got: ${result.message}`);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test('returns warning on push failure without aborting', async () => {
|
|
350
|
+
const { productPath } = createProductFixture(tmpDir);
|
|
351
|
+
|
|
352
|
+
// Create a local commit
|
|
353
|
+
fs.writeFileSync(path.join(productPath, 'fail-push.txt'), 'fail push');
|
|
354
|
+
execSync('git add -A && git commit -m "Fail push commit"', { cwd: productPath, stdio: 'pipe' });
|
|
355
|
+
|
|
356
|
+
// Break the remote by removing it and adding a non-existent URL
|
|
357
|
+
execSync('git remote remove origin', { cwd: productPath, stdio: 'pipe' });
|
|
358
|
+
execSync('git remote add origin /tmp/nonexistent-remote-' + Date.now(), { cwd: productPath, stdio: 'pipe' });
|
|
359
|
+
|
|
360
|
+
const io = createMockIO();
|
|
361
|
+
const result = await sync.workflowPush(productPath, {
|
|
362
|
+
syncMode: 'auto',
|
|
363
|
+
cadencePush: true,
|
|
364
|
+
io,
|
|
365
|
+
});
|
|
366
|
+
assert.strictEqual(result.action, 'warning');
|
|
367
|
+
assert.ok(result.message.includes('Warning'), `Expected "Warning" in message, got: ${result.message}`);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test('mid-workflow push is silent (no prompt)', async () => {
|
|
371
|
+
const { productPath } = createProductFixture(tmpDir);
|
|
372
|
+
|
|
373
|
+
fs.writeFileSync(path.join(productPath, 'mid-push.txt'), 'mid push');
|
|
374
|
+
execSync('git add -A && git commit -m "Mid push commit"', { cwd: productPath, stdio: 'pipe' });
|
|
375
|
+
|
|
376
|
+
const io = createMockIO();
|
|
377
|
+
const result = await sync.workflowPush(productPath, {
|
|
378
|
+
syncMode: 'prompt',
|
|
379
|
+
cadencePush: true,
|
|
380
|
+
midWorkflow: true,
|
|
381
|
+
io,
|
|
382
|
+
});
|
|
383
|
+
assert.strictEqual(result.action, 'pushed');
|
|
384
|
+
assert.strictEqual(io.promptLog.length, 0, 'Mid-workflow push should not prompt');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test('push summary includes commit count', async () => {
|
|
388
|
+
const { productPath } = createProductFixture(tmpDir);
|
|
389
|
+
|
|
390
|
+
fs.writeFileSync(path.join(productPath, 'summary-push.txt'), 'summary push');
|
|
391
|
+
execSync('git add -A && git commit -m "Summary push commit"', { cwd: productPath, stdio: 'pipe' });
|
|
392
|
+
|
|
393
|
+
const io = createMockIO();
|
|
394
|
+
const result = await sync.workflowPush(productPath, {
|
|
395
|
+
syncMode: 'auto',
|
|
396
|
+
cadencePush: true,
|
|
397
|
+
io,
|
|
398
|
+
});
|
|
399
|
+
assert.strictEqual(result.action, 'pushed');
|
|
400
|
+
assert.ok(result.message.includes('commit'), `Expected "commit" in summary message, got: ${result.message}`);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// ─── Suppression Window ──────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
describe('Suppression window', () => {
|
|
407
|
+
beforeEach(() => {
|
|
408
|
+
cleanSuppression('pull');
|
|
409
|
+
cleanSuppression('push');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
afterEach(() => {
|
|
413
|
+
cleanSuppression('pull');
|
|
414
|
+
cleanSuppression('push');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test('isWithinSuppressionWindow returns false initially', () => {
|
|
418
|
+
assert.strictEqual(sync.isWithinSuppressionWindow('pull'), false);
|
|
419
|
+
assert.strictEqual(sync.isWithinSuppressionWindow('push'), false);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test('recordPromptYes then isWithinSuppressionWindow returns true', () => {
|
|
423
|
+
sync.recordPromptYes('pull');
|
|
424
|
+
assert.strictEqual(sync.isWithinSuppressionWindow('pull'), true);
|
|
425
|
+
// Push should still be false
|
|
426
|
+
assert.strictEqual(sync.isWithinSuppressionWindow('push'), false);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('suppression expires after window', () => {
|
|
430
|
+
// Write a timestamp that is older than the suppression window
|
|
431
|
+
const expiredTs = Date.now() - sync.SUPPRESSION_WINDOW_MS - 1000;
|
|
432
|
+
const dir = path.dirname(sync.getSuppressionFile('pull'));
|
|
433
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
434
|
+
fs.writeFileSync(sync.getSuppressionFile('pull'), String(expiredTs));
|
|
435
|
+
|
|
436
|
+
assert.strictEqual(sync.isWithinSuppressionWindow('pull'), false);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test('workflowPull auto-pulls when suppressed in prompt mode', async () => {
|
|
440
|
+
let tmpDir;
|
|
441
|
+
try {
|
|
442
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dgs-wfsup-'));
|
|
443
|
+
const { productPath, remotePath, branch } = createProductFixture(tmpDir);
|
|
444
|
+
pushCommitToRemote(remotePath, branch, tmpDir, 'suppressed.txt', 'Suppressed commit');
|
|
445
|
+
|
|
446
|
+
// Record a recent Yes answer to suppress the prompt
|
|
447
|
+
sync.recordPromptYes('pull');
|
|
448
|
+
|
|
449
|
+
const io = createMockIO();
|
|
450
|
+
const result = await sync.workflowPull(productPath, {
|
|
451
|
+
syncMode: 'prompt',
|
|
452
|
+
cadencePull: true,
|
|
453
|
+
io,
|
|
454
|
+
});
|
|
455
|
+
assert.strictEqual(result.action, 'pulled');
|
|
456
|
+
assert.strictEqual(io.promptLog.length, 0, 'Should not prompt when suppressed');
|
|
457
|
+
} finally {
|
|
458
|
+
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// ─── Stale-State Detection ───────────────────────────────────────────────────
|
|
464
|
+
|
|
465
|
+
describe('checkStaleState', () => {
|
|
466
|
+
let tmpDir;
|
|
467
|
+
|
|
468
|
+
beforeEach(() => {
|
|
469
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dgs-stale-'));
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
afterEach(() => {
|
|
473
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test('returns stale=false when up to date', () => {
|
|
477
|
+
const { productPath } = createProductFixture(tmpDir);
|
|
478
|
+
|
|
479
|
+
const result = sync.checkStaleState(productPath);
|
|
480
|
+
assert.strictEqual(result.stale, false);
|
|
481
|
+
assert.strictEqual(result.commitsBehind, 0);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test('returns stale=true with commitsBehind count', () => {
|
|
485
|
+
const { productPath, remotePath, branch } = createProductFixture(tmpDir);
|
|
486
|
+
|
|
487
|
+
// Push commits to remote from another clone
|
|
488
|
+
pushCommitToRemote(remotePath, branch, tmpDir, 'stale1.txt', 'Stale commit 1');
|
|
489
|
+
pushCommitToRemote(remotePath, branch, tmpDir, 'stale2.txt', 'Stale commit 2');
|
|
490
|
+
|
|
491
|
+
const result = sync.checkStaleState(productPath);
|
|
492
|
+
assert.strictEqual(result.stale, true);
|
|
493
|
+
assert.strictEqual(result.commitsBehind, 2);
|
|
494
|
+
assert.ok(result.branch, 'Should include branch name');
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test('returns stale=false when no remote', () => {
|
|
498
|
+
// Create a product without a remote
|
|
499
|
+
const productPath = path.join(tmpDir, 'no-remote-product');
|
|
500
|
+
fs.mkdirSync(path.join(productPath, '.planning'), { recursive: true });
|
|
501
|
+
execSync('git init', { cwd: productPath, stdio: 'pipe' });
|
|
502
|
+
execSync('git config user.email "test@test.com"', { cwd: productPath, stdio: 'pipe' });
|
|
503
|
+
execSync('git config user.name "Test"', { cwd: productPath, stdio: 'pipe' });
|
|
504
|
+
fs.writeFileSync(path.join(productPath, 'README.md'), '# No Remote');
|
|
505
|
+
fs.writeFileSync(
|
|
506
|
+
path.join(productPath, '.planning', 'config.json'),
|
|
507
|
+
JSON.stringify({ git: { sync_push: 'off', sync_pull: 'off' } }, null, 2)
|
|
508
|
+
);
|
|
509
|
+
execSync('git add -A && git commit -m "Init"', { cwd: productPath, stdio: 'pipe' });
|
|
510
|
+
|
|
511
|
+
const result = sync.checkStaleState(productPath);
|
|
512
|
+
assert.strictEqual(result.stale, false);
|
|
513
|
+
assert.strictEqual(result.commitsBehind, 0);
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// ─── First-Run Hint ──────────────────────────────────────────────────────────
|
|
518
|
+
|
|
519
|
+
describe('First-run hint', () => {
|
|
520
|
+
let tmpDir;
|
|
521
|
+
|
|
522
|
+
beforeEach(() => {
|
|
523
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dgs-hint-'));
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
afterEach(() => {
|
|
527
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test('shouldShowFirstRunHint returns true when remote exists and sync is off', () => {
|
|
531
|
+
const { productPath } = createProductFixture(tmpDir, {
|
|
532
|
+
git: { sync_push: 'off', sync_pull: 'off' },
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
const result = sync.shouldShowFirstRunHint(productPath);
|
|
536
|
+
assert.strictEqual(result, true);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test('shouldShowFirstRunHint returns false after markFirstRunHintShown', () => {
|
|
540
|
+
const { productPath } = createProductFixture(tmpDir, {
|
|
541
|
+
git: { sync_push: 'off', sync_pull: 'off' },
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// Initially should show
|
|
545
|
+
assert.strictEqual(sync.shouldShowFirstRunHint(productPath), true);
|
|
546
|
+
|
|
547
|
+
// Mark shown
|
|
548
|
+
sync.markFirstRunHintShown(productPath);
|
|
549
|
+
|
|
550
|
+
// Now should not show
|
|
551
|
+
assert.strictEqual(sync.shouldShowFirstRunHint(productPath), false);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test('shouldShowFirstRunHint returns false when sync is configured', () => {
|
|
555
|
+
const { productPath } = createProductFixture(tmpDir, {
|
|
556
|
+
git: { sync_push: 'prompt', sync_pull: 'prompt' },
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
const result = sync.shouldShowFirstRunHint(productPath);
|
|
560
|
+
assert.strictEqual(result, false);
|
|
561
|
+
});
|
|
562
|
+
});
|