runwork 0.2.0 → 0.2.5
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/LICENSE +10 -16
- package/dist/api/client.d.ts +22 -0
- package/dist/api/client.js +9 -0
- package/dist/commands/dev.js +20 -70
- package/dist/commands/integrations.d.ts +2 -0
- package/dist/commands/integrations.js +86 -0
- package/dist/commands/upgrade.d.ts +2 -0
- package/dist/commands/upgrade.js +165 -0
- package/dist/generated/bundled-types.js +32 -32
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/git/__tests__/auto-commit.test.d.ts +1 -0
- package/dist/git/__tests__/auto-commit.test.js +373 -0
- package/dist/git/__tests__/manifest.test.d.ts +1 -0
- package/dist/git/__tests__/manifest.test.js +377 -0
- package/dist/git/__tests__/sync.test.d.ts +1 -0
- package/dist/git/__tests__/sync.test.js +403 -0
- package/dist/git/auto-commit.d.ts +1 -0
- package/dist/git/auto-commit.js +15 -5
- package/dist/git/sync.d.ts +15 -0
- package/dist/git/sync.js +116 -0
- package/dist/index.js +4 -0
- package/dist/logs/tailer.js +5 -11
- package/dist/template/manifest.js +24 -4
- package/package.json +3 -3
- package/dist/auth/browser.d.ts +0 -2
- package/dist/auth/browser.js +0 -7
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { execFileSync } from 'child_process';
|
|
3
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { tmpdir } from 'os';
|
|
6
|
+
import { syncWithRemote, hasCommits, hasTrackedChanges } from '../sync.js';
|
|
7
|
+
const tempDirs = [];
|
|
8
|
+
function makeTempDir(prefix) {
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), `runwork-sync-test-${prefix}-`));
|
|
10
|
+
tempDirs.push(dir);
|
|
11
|
+
return dir;
|
|
12
|
+
}
|
|
13
|
+
function createTestRepo() {
|
|
14
|
+
const remote = makeTempDir('remote');
|
|
15
|
+
execFileSync('git', ['init', '--bare', remote]);
|
|
16
|
+
const local = makeTempDir('local');
|
|
17
|
+
execFileSync('git', ['init', local]);
|
|
18
|
+
execFileSync('git', ['remote', 'add', 'runwork', remote], { cwd: local });
|
|
19
|
+
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: local });
|
|
20
|
+
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: local });
|
|
21
|
+
return { local, remote };
|
|
22
|
+
}
|
|
23
|
+
/** Create a second clone of the same bare remote (for simulating remote-side commits). */
|
|
24
|
+
function cloneAsSecondary(remote) {
|
|
25
|
+
const secondary = makeTempDir('secondary');
|
|
26
|
+
execFileSync('git', ['clone', remote, secondary]);
|
|
27
|
+
execFileSync('git', ['config', 'user.email', 'other@test.com'], { cwd: secondary });
|
|
28
|
+
execFileSync('git', ['config', 'user.name', 'Other'], { cwd: secondary });
|
|
29
|
+
return secondary;
|
|
30
|
+
}
|
|
31
|
+
function commitFile(repoDir, filename, content, message) {
|
|
32
|
+
writeFileSync(join(repoDir, filename), content);
|
|
33
|
+
execFileSync('git', ['add', filename], { cwd: repoDir });
|
|
34
|
+
execFileSync('git', ['commit', '-m', message], { cwd: repoDir });
|
|
35
|
+
return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repoDir, encoding: 'utf-8' }).trim();
|
|
36
|
+
}
|
|
37
|
+
function getLog(repoDir) {
|
|
38
|
+
return execFileSync('git', ['log', '--oneline', '--all'], { cwd: repoDir, encoding: 'utf-8' })
|
|
39
|
+
.trim()
|
|
40
|
+
.split('\n')
|
|
41
|
+
.filter(Boolean);
|
|
42
|
+
}
|
|
43
|
+
function getHeadHash(repoDir) {
|
|
44
|
+
return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repoDir, encoding: 'utf-8' }).trim();
|
|
45
|
+
}
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
for (const dir of tempDirs) {
|
|
48
|
+
try {
|
|
49
|
+
rmSync(dir, { recursive: true, force: true });
|
|
50
|
+
}
|
|
51
|
+
catch { /* best effort */ }
|
|
52
|
+
}
|
|
53
|
+
tempDirs.length = 0;
|
|
54
|
+
});
|
|
55
|
+
describe('hasCommits()', () => {
|
|
56
|
+
it('returns false for repo with no commits', () => {
|
|
57
|
+
const dir = makeTempDir('empty');
|
|
58
|
+
execFileSync('git', ['init', dir]);
|
|
59
|
+
expect(hasCommits(dir)).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
it('returns true for repo with commits', () => {
|
|
62
|
+
const { local } = createTestRepo();
|
|
63
|
+
commitFile(local, 'a.txt', 'hello', 'init');
|
|
64
|
+
expect(hasCommits(local)).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe('hasTrackedChanges()', () => {
|
|
68
|
+
it('returns false for clean working tree', () => {
|
|
69
|
+
const { local } = createTestRepo();
|
|
70
|
+
commitFile(local, 'a.txt', 'hello', 'init');
|
|
71
|
+
expect(hasTrackedChanges(local)).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
it('returns true for modified tracked file', () => {
|
|
74
|
+
const { local } = createTestRepo();
|
|
75
|
+
commitFile(local, 'a.txt', 'hello', 'init');
|
|
76
|
+
writeFileSync(join(local, 'a.txt'), 'changed');
|
|
77
|
+
expect(hasTrackedChanges(local)).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
it('returns false when only untracked files exist', () => {
|
|
80
|
+
const { local } = createTestRepo();
|
|
81
|
+
commitFile(local, 'a.txt', 'hello', 'init');
|
|
82
|
+
writeFileSync(join(local, 'untracked.txt'), 'new');
|
|
83
|
+
expect(hasTrackedChanges(local)).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
describe('syncWithRemote()', () => {
|
|
87
|
+
it('returns skipped when there are no local commits', () => {
|
|
88
|
+
const { local } = createTestRepo();
|
|
89
|
+
const result = syncWithRemote(local);
|
|
90
|
+
expect(result).toEqual({ status: 'skipped', pushed: false });
|
|
91
|
+
});
|
|
92
|
+
it('syncs when local and remote are already in sync', () => {
|
|
93
|
+
const { local, remote } = createTestRepo();
|
|
94
|
+
commitFile(local, 'a.txt', 'hello', 'init');
|
|
95
|
+
execFileSync('git', ['push', 'runwork', 'main'], { cwd: local, stdio: 'pipe' });
|
|
96
|
+
const result = syncWithRemote(local);
|
|
97
|
+
expect(result.status).toBe('synced');
|
|
98
|
+
expect(result.pushed).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
it('pushes local commits when remote has no new changes', () => {
|
|
101
|
+
const { local, remote } = createTestRepo();
|
|
102
|
+
commitFile(local, 'a.txt', 'hello', 'init');
|
|
103
|
+
execFileSync('git', ['push', 'runwork', 'main'], { cwd: local, stdio: 'pipe' });
|
|
104
|
+
// Add a new local commit
|
|
105
|
+
commitFile(local, 'b.txt', 'world', 'second');
|
|
106
|
+
const localHead = getHeadHash(local);
|
|
107
|
+
const result = syncWithRemote(local);
|
|
108
|
+
expect(result.status).toBe('synced');
|
|
109
|
+
expect(result.pushed).toBe(true);
|
|
110
|
+
// Verify remote has the new commit
|
|
111
|
+
const remoteHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: remote, encoding: 'utf-8' }).trim();
|
|
112
|
+
expect(remoteHead).toBe(localHead);
|
|
113
|
+
});
|
|
114
|
+
it('rebases local on remote when remote has new commits', () => {
|
|
115
|
+
const { local, remote } = createTestRepo();
|
|
116
|
+
commitFile(local, 'a.txt', 'hello', 'init');
|
|
117
|
+
execFileSync('git', ['push', 'runwork', 'main'], { cwd: local, stdio: 'pipe' });
|
|
118
|
+
// Push a commit from another clone to simulate remote changes
|
|
119
|
+
const secondary = cloneAsSecondary(remote);
|
|
120
|
+
commitFile(secondary, 'remote-file.txt', 'from-remote', 'remote commit');
|
|
121
|
+
execFileSync('git', ['push', 'origin', 'main'], { cwd: secondary, stdio: 'pipe' });
|
|
122
|
+
const result = syncWithRemote(local);
|
|
123
|
+
expect(result.status).toBe('synced');
|
|
124
|
+
// Local should now have the remote file
|
|
125
|
+
expect(existsSync(join(local, 'remote-file.txt'))).toBe(true);
|
|
126
|
+
expect(readFileSync(join(local, 'remote-file.txt'), 'utf-8')).toBe('from-remote');
|
|
127
|
+
});
|
|
128
|
+
it('rebases when both sides have non-conflicting commits', () => {
|
|
129
|
+
const { local, remote } = createTestRepo();
|
|
130
|
+
commitFile(local, 'a.txt', 'hello', 'init');
|
|
131
|
+
execFileSync('git', ['push', 'runwork', 'main'], { cwd: local, stdio: 'pipe' });
|
|
132
|
+
// Remote adds a file
|
|
133
|
+
const secondary = cloneAsSecondary(remote);
|
|
134
|
+
commitFile(secondary, 'remote-file.txt', 'remote-content', 'remote commit');
|
|
135
|
+
execFileSync('git', ['push', 'origin', 'main'], { cwd: secondary, stdio: 'pipe' });
|
|
136
|
+
// Local adds a different file
|
|
137
|
+
commitFile(local, 'local-file.txt', 'local-content', 'local commit');
|
|
138
|
+
const result = syncWithRemote(local);
|
|
139
|
+
expect(result.status).toBe('synced');
|
|
140
|
+
expect(result.pushed).toBe(true);
|
|
141
|
+
// Both files should be present
|
|
142
|
+
expect(readFileSync(join(local, 'remote-file.txt'), 'utf-8')).toBe('remote-content');
|
|
143
|
+
expect(readFileSync(join(local, 'local-file.txt'), 'utf-8')).toBe('local-content');
|
|
144
|
+
// Verify remote also has both
|
|
145
|
+
const secondaryCheck = cloneAsSecondary(remote);
|
|
146
|
+
expect(existsSync(join(secondaryCheck, 'remote-file.txt'))).toBe(true);
|
|
147
|
+
expect(existsSync(join(secondaryCheck, 'local-file.txt'))).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
it('handles unrelated histories with non-overlapping files via rebase', () => {
|
|
150
|
+
const { local, remote } = createTestRepo();
|
|
151
|
+
// Local has its own history
|
|
152
|
+
commitFile(local, 'local-file.txt', 'local', 'local init');
|
|
153
|
+
// Create a completely separate history and force-push it to remote
|
|
154
|
+
const other = makeTempDir('other');
|
|
155
|
+
execFileSync('git', ['init', other]);
|
|
156
|
+
execFileSync('git', ['config', 'user.email', 'other@test.com'], { cwd: other });
|
|
157
|
+
execFileSync('git', ['config', 'user.name', 'Other'], { cwd: other });
|
|
158
|
+
commitFile(other, 'remote-only.txt', 'remote', 'remote init');
|
|
159
|
+
execFileSync('git', ['remote', 'add', 'origin', remote], { cwd: other });
|
|
160
|
+
execFileSync('git', ['push', 'origin', 'main'], { cwd: other, stdio: 'pipe' });
|
|
161
|
+
const result = syncWithRemote(local);
|
|
162
|
+
// Modern git can rebase non-conflicting unrelated histories
|
|
163
|
+
expect(['synced', 'merged']).toContain(result.status);
|
|
164
|
+
// Both files should be present regardless of strategy used
|
|
165
|
+
expect(existsSync(join(local, 'local-file.txt'))).toBe(true);
|
|
166
|
+
expect(existsSync(join(local, 'remote-only.txt'))).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
it('reports sync-failed when both rebase and merge have conflicts', () => {
|
|
169
|
+
const { local, remote } = createTestRepo();
|
|
170
|
+
// Local modifies a file
|
|
171
|
+
commitFile(local, 'shared.txt', 'local-line-1\nlocal-line-2\n', 'local init');
|
|
172
|
+
// Remote has completely different history touching the same file
|
|
173
|
+
const other = makeTempDir('other');
|
|
174
|
+
execFileSync('git', ['init', other]);
|
|
175
|
+
execFileSync('git', ['config', 'user.email', 'other@test.com'], { cwd: other });
|
|
176
|
+
execFileSync('git', ['config', 'user.name', 'Other'], { cwd: other });
|
|
177
|
+
commitFile(other, 'shared.txt', 'remote-line-1\nremote-line-2\n', 'remote init');
|
|
178
|
+
execFileSync('git', ['remote', 'add', 'origin', remote], { cwd: other });
|
|
179
|
+
execFileSync('git', ['push', 'origin', 'main'], { cwd: other, stdio: 'pipe' });
|
|
180
|
+
const result = syncWithRemote(local);
|
|
181
|
+
// Both rebase and merge will fail on conflicting unrelated histories
|
|
182
|
+
expect(result.status).toBe('sync-failed');
|
|
183
|
+
});
|
|
184
|
+
it('stashes and restores dirty working tree during sync', () => {
|
|
185
|
+
const { local, remote } = createTestRepo();
|
|
186
|
+
commitFile(local, 'a.txt', 'hello', 'init');
|
|
187
|
+
execFileSync('git', ['push', 'runwork', 'main'], { cwd: local, stdio: 'pipe' });
|
|
188
|
+
// Remote adds a commit
|
|
189
|
+
const secondary = cloneAsSecondary(remote);
|
|
190
|
+
commitFile(secondary, 'remote-file.txt', 'remote', 'remote commit');
|
|
191
|
+
execFileSync('git', ['push', 'origin', 'main'], { cwd: secondary, stdio: 'pipe' });
|
|
192
|
+
// Make local dirty (modify a tracked file without committing)
|
|
193
|
+
writeFileSync(join(local, 'a.txt'), 'dirty-change');
|
|
194
|
+
const result = syncWithRemote(local);
|
|
195
|
+
expect(result.status).toBe('synced');
|
|
196
|
+
// Dirty change should be preserved
|
|
197
|
+
expect(readFileSync(join(local, 'a.txt'), 'utf-8')).toBe('dirty-change');
|
|
198
|
+
// Remote file should also be present
|
|
199
|
+
expect(existsSync(join(local, 'remote-file.txt'))).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
it('reports stash conflict when stash pop fails', () => {
|
|
202
|
+
const { local, remote } = createTestRepo();
|
|
203
|
+
commitFile(local, 'shared.txt', 'original', 'init');
|
|
204
|
+
execFileSync('git', ['push', 'runwork', 'main'], { cwd: local, stdio: 'pipe' });
|
|
205
|
+
// Remote modifies the same file
|
|
206
|
+
const secondary = cloneAsSecondary(remote);
|
|
207
|
+
writeFileSync(join(secondary, 'shared.txt'), 'remote-version');
|
|
208
|
+
execFileSync('git', ['add', 'shared.txt'], { cwd: secondary });
|
|
209
|
+
execFileSync('git', ['commit', '-m', 'remote change to shared'], { cwd: secondary });
|
|
210
|
+
execFileSync('git', ['push', 'origin', 'main'], { cwd: secondary, stdio: 'pipe' });
|
|
211
|
+
// Local has uncommitted changes to the same file
|
|
212
|
+
writeFileSync(join(local, 'shared.txt'), 'local-dirty-version');
|
|
213
|
+
const result = syncWithRemote(local);
|
|
214
|
+
expect(result.error).toBe('stash-conflict');
|
|
215
|
+
expect(result.pushed).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
it('handles fetch failure gracefully (unreachable remote)', () => {
|
|
218
|
+
const { local } = createTestRepo();
|
|
219
|
+
commitFile(local, 'a.txt', 'hello', 'init');
|
|
220
|
+
// Point remote to a non-existent path
|
|
221
|
+
const badPath = join(tmpdir(), 'nonexistent-repo-' + Date.now());
|
|
222
|
+
execFileSync('git', ['remote', 'set-url', 'runwork', badPath], { cwd: local });
|
|
223
|
+
const result = syncWithRemote(local);
|
|
224
|
+
expect(result.status).toBe('sync-failed');
|
|
225
|
+
expect(result.pushed).toBe(false);
|
|
226
|
+
// Local repo should be untouched
|
|
227
|
+
expect(readFileSync(join(local, 'a.txt'), 'utf-8')).toBe('hello');
|
|
228
|
+
});
|
|
229
|
+
it('removes conflicting untracked files before rebase', () => {
|
|
230
|
+
const { local, remote } = createTestRepo();
|
|
231
|
+
commitFile(local, 'a.txt', 'hello', 'init');
|
|
232
|
+
execFileSync('git', ['push', 'runwork', 'main'], { cwd: local, stdio: 'pipe' });
|
|
233
|
+
// Remote adds a new file via secondary clone
|
|
234
|
+
const secondary = cloneAsSecondary(remote);
|
|
235
|
+
commitFile(secondary, 'skeleton.txt', 'from-remote', 'add skeleton');
|
|
236
|
+
execFileSync('git', ['push', 'origin', 'main'], { cwd: secondary, stdio: 'pipe' });
|
|
237
|
+
// Local has the same file as untracked (simulating template download)
|
|
238
|
+
writeFileSync(join(local, 'skeleton.txt'), 'local-untracked-version');
|
|
239
|
+
const result = syncWithRemote(local);
|
|
240
|
+
expect(result.status).toBe('synced');
|
|
241
|
+
// The file should now contain the remote version (committed)
|
|
242
|
+
expect(readFileSync(join(local, 'skeleton.txt'), 'utf-8')).toBe('from-remote');
|
|
243
|
+
});
|
|
244
|
+
it('preserves local state when fetch fails with dirty tree', () => {
|
|
245
|
+
const { local } = createTestRepo();
|
|
246
|
+
commitFile(local, 'a.txt', 'hello', 'init');
|
|
247
|
+
// Make working tree dirty
|
|
248
|
+
writeFileSync(join(local, 'a.txt'), 'dirty');
|
|
249
|
+
// Point remote to bad path
|
|
250
|
+
const badPath = join(tmpdir(), 'nonexistent-repo-' + Date.now());
|
|
251
|
+
execFileSync('git', ['remote', 'set-url', 'runwork', badPath], { cwd: local });
|
|
252
|
+
const result = syncWithRemote(local);
|
|
253
|
+
expect(result.status).toBe('sync-failed');
|
|
254
|
+
// Dirty changes should be restored from stash
|
|
255
|
+
expect(readFileSync(join(local, 'a.txt'), 'utf-8')).toBe('dirty');
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
// ============================================================
|
|
259
|
+
// EDGE CASE TESTS - probing for real bugs
|
|
260
|
+
// ============================================================
|
|
261
|
+
describe('hasTrackedChanges() edge cases', () => {
|
|
262
|
+
it('returns true for staged deletions (git rm)', () => {
|
|
263
|
+
const { local } = createTestRepo();
|
|
264
|
+
commitFile(local, 'a.txt', 'hello', 'init');
|
|
265
|
+
// Stage a deletion
|
|
266
|
+
execFileSync('git', ['rm', 'a.txt'], { cwd: local, stdio: 'pipe' });
|
|
267
|
+
// git status --porcelain shows "D a.txt" which does not start with "??"
|
|
268
|
+
expect(hasTrackedChanges(local)).toBe(true);
|
|
269
|
+
});
|
|
270
|
+
it('returns true for staged new file (git add of new file)', () => {
|
|
271
|
+
const { local } = createTestRepo();
|
|
272
|
+
commitFile(local, 'a.txt', 'hello', 'init');
|
|
273
|
+
// Create and stage a new file (status: "A new.txt")
|
|
274
|
+
writeFileSync(join(local, 'new.txt'), 'new content');
|
|
275
|
+
execFileSync('git', ['add', 'new.txt'], { cwd: local, stdio: 'pipe' });
|
|
276
|
+
expect(hasTrackedChanges(local)).toBe(true);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
describe('syncWithRemote() edge cases', () => {
|
|
280
|
+
it('stash/pop works correctly when only staged deletions exist', () => {
|
|
281
|
+
// Edge case: hasTrackedChanges returns true for staged deletions,
|
|
282
|
+
// but does git stash push actually stash a staged deletion?
|
|
283
|
+
// If stash doesn't capture it, stash pop could fail or restore wrong state.
|
|
284
|
+
const { local, remote } = createTestRepo();
|
|
285
|
+
commitFile(local, 'a.txt', 'hello', 'init');
|
|
286
|
+
commitFile(local, 'b.txt', 'world', 'second');
|
|
287
|
+
execFileSync('git', ['push', 'runwork', 'main'], { cwd: local, stdio: 'pipe' });
|
|
288
|
+
// Remote adds a new file
|
|
289
|
+
const secondary = cloneAsSecondary(remote);
|
|
290
|
+
commitFile(secondary, 'remote-file.txt', 'remote', 'remote commit');
|
|
291
|
+
execFileSync('git', ['push', 'origin', 'main'], { cwd: secondary, stdio: 'pipe' });
|
|
292
|
+
// Stage a deletion locally (without committing)
|
|
293
|
+
execFileSync('git', ['rm', 'b.txt'], { cwd: local, stdio: 'pipe' });
|
|
294
|
+
expect(hasTrackedChanges(local)).toBe(true);
|
|
295
|
+
expect(existsSync(join(local, 'b.txt'))).toBe(false);
|
|
296
|
+
const result = syncWithRemote(local);
|
|
297
|
+
expect(result.status).toBe('synced');
|
|
298
|
+
// After sync, the staged deletion should be restored
|
|
299
|
+
// (stash pop should bring back the staged rm state)
|
|
300
|
+
expect(existsSync(join(local, 'b.txt'))).toBe(false);
|
|
301
|
+
// Verify the deletion is still staged
|
|
302
|
+
const status = execFileSync('git', ['status', '--porcelain'], {
|
|
303
|
+
cwd: local, encoding: 'utf-8',
|
|
304
|
+
}).trim();
|
|
305
|
+
expect(status).toContain('b.txt');
|
|
306
|
+
});
|
|
307
|
+
it('handles sync when remote branch main does not exist yet (empty bare repo)', () => {
|
|
308
|
+
// User creates local repo and commits, but never pushed to remote.
|
|
309
|
+
// Remote is empty. fetch runwork main should fail.
|
|
310
|
+
const { local, remote } = createTestRepo();
|
|
311
|
+
commitFile(local, 'a.txt', 'hello', 'init');
|
|
312
|
+
// Do NOT push -- remote has no 'main' branch
|
|
313
|
+
const result = syncWithRemote(local);
|
|
314
|
+
// fetch should fail because remote has no main branch
|
|
315
|
+
expect(result.status).toBe('sync-failed');
|
|
316
|
+
// But local repo should be intact
|
|
317
|
+
expect(readFileSync(join(local, 'a.txt'), 'utf-8')).toBe('hello');
|
|
318
|
+
});
|
|
319
|
+
it('leaves repo clean (not in rebase/merge state) after sync-failed', () => {
|
|
320
|
+
const { local, remote } = createTestRepo();
|
|
321
|
+
commitFile(local, 'shared.txt', 'local version\n', 'local init');
|
|
322
|
+
// Create conflicting remote history
|
|
323
|
+
const other = makeTempDir('other');
|
|
324
|
+
execFileSync('git', ['init', other]);
|
|
325
|
+
execFileSync('git', ['config', 'user.email', 'other@test.com'], { cwd: other });
|
|
326
|
+
execFileSync('git', ['config', 'user.name', 'Other'], { cwd: other });
|
|
327
|
+
commitFile(other, 'shared.txt', 'remote version\n', 'remote init');
|
|
328
|
+
execFileSync('git', ['remote', 'add', 'origin', remote], { cwd: other });
|
|
329
|
+
execFileSync('git', ['push', 'origin', 'main'], { cwd: other, stdio: 'pipe' });
|
|
330
|
+
const result = syncWithRemote(local);
|
|
331
|
+
expect(result.status).toBe('sync-failed');
|
|
332
|
+
// Repo should NOT be in a rebase or merge state
|
|
333
|
+
expect(existsSync(join(local, '.git', 'MERGE_HEAD'))).toBe(false);
|
|
334
|
+
expect(existsSync(join(local, '.git', 'rebase-merge'))).toBe(false);
|
|
335
|
+
expect(existsSync(join(local, '.git', 'rebase-apply'))).toBe(false);
|
|
336
|
+
// Working tree should be clean (original file intact)
|
|
337
|
+
const fileContent = readFileSync(join(local, 'shared.txt'), 'utf-8');
|
|
338
|
+
expect(fileContent).toBe('local version\n');
|
|
339
|
+
});
|
|
340
|
+
it('handles merge conflict during rebase on related histories (same file edited both sides)', () => {
|
|
341
|
+
const { local, remote } = createTestRepo();
|
|
342
|
+
commitFile(local, 'shared.txt', 'original content\n', 'init');
|
|
343
|
+
execFileSync('git', ['push', 'runwork', 'main'], { cwd: local, stdio: 'pipe' });
|
|
344
|
+
// Remote modifies shared.txt
|
|
345
|
+
const secondary = cloneAsSecondary(remote);
|
|
346
|
+
writeFileSync(join(secondary, 'shared.txt'), 'remote edit line 1\nremote edit line 2\n');
|
|
347
|
+
execFileSync('git', ['add', 'shared.txt'], { cwd: secondary });
|
|
348
|
+
execFileSync('git', ['commit', '-m', 'remote edit'], { cwd: secondary });
|
|
349
|
+
execFileSync('git', ['push', 'origin', 'main'], { cwd: secondary, stdio: 'pipe' });
|
|
350
|
+
// Local also modifies shared.txt (same area -> conflict)
|
|
351
|
+
writeFileSync(join(local, 'shared.txt'), 'local edit line 1\nlocal edit line 2\n');
|
|
352
|
+
execFileSync('git', ['add', 'shared.txt'], { cwd: local });
|
|
353
|
+
execFileSync('git', ['commit', '-m', 'local edit'], { cwd: local });
|
|
354
|
+
const result = syncWithRemote(local);
|
|
355
|
+
// Rebase should fail due to conflict, merge fallback should also have conflicts
|
|
356
|
+
// OR merge with --allow-unrelated-histories might succeed (unlikely for related histories)
|
|
357
|
+
// The key assertion: repo must NOT be left in broken state
|
|
358
|
+
expect(existsSync(join(local, '.git', 'MERGE_HEAD'))).toBe(false);
|
|
359
|
+
expect(existsSync(join(local, '.git', 'rebase-merge'))).toBe(false);
|
|
360
|
+
// Either merged or sync-failed, but never left in limbo
|
|
361
|
+
expect(['synced', 'merged', 'sync-failed']).toContain(result.status);
|
|
362
|
+
});
|
|
363
|
+
it('stash pop after sync-failed still restores dirty state', () => {
|
|
364
|
+
// If sync fails (fetch fails), dirty state should still be restored.
|
|
365
|
+
// But what if the stash was created and then the repo state changed
|
|
366
|
+
// (e.g., aborted rebase left things in weird state)?
|
|
367
|
+
const { local } = createTestRepo();
|
|
368
|
+
commitFile(local, 'a.txt', 'committed', 'init');
|
|
369
|
+
// Make dirty
|
|
370
|
+
writeFileSync(join(local, 'a.txt'), 'uncommitted change');
|
|
371
|
+
// Point to bad remote
|
|
372
|
+
const badPath = join(tmpdir(), 'nonexistent-' + Date.now());
|
|
373
|
+
execFileSync('git', ['remote', 'set-url', 'runwork', badPath], { cwd: local });
|
|
374
|
+
const result = syncWithRemote(local);
|
|
375
|
+
expect(result.status).toBe('sync-failed');
|
|
376
|
+
// The stash should have been popped successfully
|
|
377
|
+
expect(result.error).not.toBe('stash-conflict');
|
|
378
|
+
expect(readFileSync(join(local, 'a.txt'), 'utf-8')).toBe('uncommitted change');
|
|
379
|
+
// Stash list should be empty (stash was popped)
|
|
380
|
+
const stashList = execFileSync('git', ['stash', 'list'], {
|
|
381
|
+
cwd: local, encoding: 'utf-8',
|
|
382
|
+
}).trim();
|
|
383
|
+
expect(stashList).toBe('');
|
|
384
|
+
});
|
|
385
|
+
it('does not leave orphan stash entries on successful sync', () => {
|
|
386
|
+
const { local, remote } = createTestRepo();
|
|
387
|
+
commitFile(local, 'a.txt', 'hello', 'init');
|
|
388
|
+
execFileSync('git', ['push', 'runwork', 'main'], { cwd: local, stdio: 'pipe' });
|
|
389
|
+
// Remote adds a file
|
|
390
|
+
const secondary = cloneAsSecondary(remote);
|
|
391
|
+
commitFile(secondary, 'remote.txt', 'remote', 'remote commit');
|
|
392
|
+
execFileSync('git', ['push', 'origin', 'main'], { cwd: secondary, stdio: 'pipe' });
|
|
393
|
+
// Make local dirty
|
|
394
|
+
writeFileSync(join(local, 'a.txt'), 'dirty');
|
|
395
|
+
const result = syncWithRemote(local);
|
|
396
|
+
expect(result.status).toBe('synced');
|
|
397
|
+
// Stash should be empty - no orphan stash entries
|
|
398
|
+
const stashList = execFileSync('git', ['stash', 'list'], {
|
|
399
|
+
cwd: local, encoding: 'utf-8',
|
|
400
|
+
}).trim();
|
|
401
|
+
expect(stashList).toBe('');
|
|
402
|
+
});
|
|
403
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { ApiClient } from '../api/client.js';
|
|
2
|
+
export declare function isIgnored(filePath: string): boolean;
|
|
2
3
|
export declare function watchAndAutoCommit(directory: string, client: ApiClient, appId: string): Promise<void>;
|
|
3
4
|
export declare function stopAutoCommit(): Promise<void>;
|
package/dist/git/auto-commit.js
CHANGED
|
@@ -12,7 +12,7 @@ const pendingFastSync = new Map();
|
|
|
12
12
|
// Track files the user actually touched during this session (for git)
|
|
13
13
|
const changedFiles = new Set();
|
|
14
14
|
const SKIP_DIRS = new Set(['node_modules', '.git', '.runwork']);
|
|
15
|
-
const SKIP_FILES = new Set(['.dev.vars']);
|
|
15
|
+
const SKIP_FILES = new Set(['.dev.vars', '.env']);
|
|
16
16
|
const SKIP_EXTENSIONS = new Set(['.log']);
|
|
17
17
|
// Binary extensions to skip in fast sync (git handles them fine)
|
|
18
18
|
const BINARY_EXTENSIONS = new Set([
|
|
@@ -22,7 +22,7 @@ const BINARY_EXTENSIONS = new Set([
|
|
|
22
22
|
'.zip', '.tar', '.gz', '.br',
|
|
23
23
|
'.pdf', '.wasm',
|
|
24
24
|
]);
|
|
25
|
-
function isIgnored(filePath) {
|
|
25
|
+
export function isIgnored(filePath) {
|
|
26
26
|
const name = basename(filePath);
|
|
27
27
|
if (SKIP_DIRS.has(name))
|
|
28
28
|
return true;
|
|
@@ -153,7 +153,7 @@ function commitAndPush() {
|
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
155
|
// Check if anything was actually staged
|
|
156
|
-
const staged = execFileSync('git', ['diff', '--cached', '--name-only'], { encoding: 'utf-8' });
|
|
156
|
+
const staged = execFileSync('git', ['-c', 'core.quotePath=false', 'diff', '--cached', '--name-only'], { encoding: 'utf-8' });
|
|
157
157
|
if (!staged.trim())
|
|
158
158
|
return;
|
|
159
159
|
const stagedFiles = staged.trim().split('\n').filter(Boolean);
|
|
@@ -169,8 +169,18 @@ function commitAndPush() {
|
|
|
169
169
|
execFileSync('git', ['rebase', '--abort'], { stdio: 'pipe' });
|
|
170
170
|
}
|
|
171
171
|
catch { /* no rebase in progress */ }
|
|
172
|
-
|
|
173
|
-
|
|
172
|
+
// Rebase fails when histories diverged (e.g. template update). Fall back to merge.
|
|
173
|
+
try {
|
|
174
|
+
execFileSync('git', ['merge', 'runwork/main', '--allow-unrelated-histories', '--no-edit'], { stdio: 'pipe' });
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
try {
|
|
178
|
+
execFileSync('git', ['merge', '--abort'], { stdio: 'pipe' });
|
|
179
|
+
}
|
|
180
|
+
catch { /* no merge in progress */ }
|
|
181
|
+
console.warn('Auto-sync: merge failed, skipping push. Run `git pull --rebase runwork main` manually.');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
174
184
|
}
|
|
175
185
|
try {
|
|
176
186
|
execFileSync('git', ['rev-parse', '--abbrev-ref', '@{u}'], { stdio: 'pipe' });
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface SyncResult {
|
|
2
|
+
status: 'synced' | 'merged' | 'sync-failed' | 'skipped';
|
|
3
|
+
pushed: boolean;
|
|
4
|
+
error?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function hasCommits(cwd: string): boolean;
|
|
7
|
+
export declare function hasTrackedChanges(cwd: string): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Sync local repository with the runwork remote.
|
|
10
|
+
*
|
|
11
|
+
* Strategy: fetch, then attempt rebase. If rebase fails (e.g. diverged
|
|
12
|
+
* histories after a template update), fall back to merge with
|
|
13
|
+
* --allow-unrelated-histories. Stash/pop around dirty working trees.
|
|
14
|
+
*/
|
|
15
|
+
export declare function syncWithRemote(cwd: string): SyncResult;
|
package/dist/git/sync.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { unlinkSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
export function hasCommits(cwd) {
|
|
5
|
+
try {
|
|
6
|
+
execFileSync('git', ['rev-parse', 'HEAD'], { cwd, stdio: 'pipe' });
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function hasTrackedChanges(cwd) {
|
|
14
|
+
try {
|
|
15
|
+
const output = execFileSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf-8' });
|
|
16
|
+
return output.trim().split('\n').some(line => line.length > 0 && !line.startsWith('??'));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Remove untracked local files that exist on the remote.
|
|
24
|
+
* Skeleton files are ephemeral (re-downloaded each session) so the
|
|
25
|
+
* server's versions take precedence. User-edited files are already
|
|
26
|
+
* tracked/committed at this point and won't be affected.
|
|
27
|
+
*/
|
|
28
|
+
function removeConflictingUntrackedFiles(cwd) {
|
|
29
|
+
try {
|
|
30
|
+
const remoteFiles = execFileSync('git', ['ls-tree', '-r', '--name-only', 'runwork/main'], {
|
|
31
|
+
cwd,
|
|
32
|
+
encoding: 'utf-8',
|
|
33
|
+
}).trim().split('\n');
|
|
34
|
+
const untrackedOutput = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], {
|
|
35
|
+
cwd,
|
|
36
|
+
encoding: 'utf-8',
|
|
37
|
+
}).trim();
|
|
38
|
+
const untracked = new Set(untrackedOutput.split('\n').filter(Boolean));
|
|
39
|
+
for (const file of remoteFiles) {
|
|
40
|
+
if (untracked.has(file)) {
|
|
41
|
+
try {
|
|
42
|
+
unlinkSync(join(cwd, file));
|
|
43
|
+
}
|
|
44
|
+
catch { /* already gone */ }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// best effort
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Sync local repository with the runwork remote.
|
|
54
|
+
*
|
|
55
|
+
* Strategy: fetch, then attempt rebase. If rebase fails (e.g. diverged
|
|
56
|
+
* histories after a template update), fall back to merge with
|
|
57
|
+
* --allow-unrelated-histories. Stash/pop around dirty working trees.
|
|
58
|
+
*/
|
|
59
|
+
export function syncWithRemote(cwd) {
|
|
60
|
+
if (!hasCommits(cwd)) {
|
|
61
|
+
return { status: 'skipped', pushed: false };
|
|
62
|
+
}
|
|
63
|
+
const dirty = hasTrackedChanges(cwd);
|
|
64
|
+
if (dirty) {
|
|
65
|
+
execFileSync('git', ['stash', 'push', '-m', 'runwork-dev-sync'], { cwd, stdio: 'pipe' });
|
|
66
|
+
}
|
|
67
|
+
let status = 'synced';
|
|
68
|
+
try {
|
|
69
|
+
execFileSync('git', ['fetch', 'runwork', 'main'], { cwd, stdio: 'pipe' });
|
|
70
|
+
removeConflictingUntrackedFiles(cwd);
|
|
71
|
+
try {
|
|
72
|
+
execFileSync('git', ['rebase', 'runwork/main'], { cwd, stdio: 'pipe' });
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
try {
|
|
76
|
+
execFileSync('git', ['rebase', '--abort'], { cwd, stdio: 'pipe' });
|
|
77
|
+
}
|
|
78
|
+
catch { /* no rebase in progress */ }
|
|
79
|
+
try {
|
|
80
|
+
execFileSync('git', ['merge', 'runwork/main', '--allow-unrelated-histories', '--no-edit'], { cwd, stdio: 'pipe' });
|
|
81
|
+
status = 'merged';
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
try {
|
|
85
|
+
execFileSync('git', ['merge', '--abort'], { cwd, stdio: 'pipe' });
|
|
86
|
+
}
|
|
87
|
+
catch { /* no merge in progress */ }
|
|
88
|
+
status = 'sync-failed';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// fetch failed — remote may be unreachable or have no commits
|
|
94
|
+
status = 'sync-failed';
|
|
95
|
+
}
|
|
96
|
+
if (dirty) {
|
|
97
|
+
try {
|
|
98
|
+
execFileSync('git', ['stash', 'pop'], { cwd, stdio: 'pipe' });
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return { status, pushed: false, error: 'stash-conflict' };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (status === 'sync-failed') {
|
|
105
|
+
return { status, pushed: false, error: 'Pull failed (remote may not have commits yet)' };
|
|
106
|
+
}
|
|
107
|
+
let pushed = false;
|
|
108
|
+
try {
|
|
109
|
+
execFileSync('git', ['push', 'runwork', 'main'], { cwd, stdio: 'pipe' });
|
|
110
|
+
pushed = true;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// push failed — continue with current state
|
|
114
|
+
}
|
|
115
|
+
return { status, pushed };
|
|
116
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,9 @@ import { cloneCommand } from './commands/clone.js';
|
|
|
5
5
|
import { devCommand } from './commands/dev.js';
|
|
6
6
|
import { deployCommand } from './commands/deploy.js';
|
|
7
7
|
import { logsCommand } from './commands/logs.js';
|
|
8
|
+
import { upgradeCommand } from './commands/upgrade.js';
|
|
8
9
|
import { logoutCommand } from './commands/logout.js';
|
|
10
|
+
import { integrationsCommand } from './commands/integrations.js';
|
|
9
11
|
import { handleGitCredentialRequest } from './git/credentials.js';
|
|
10
12
|
import { VERSION } from './generated/version.js';
|
|
11
13
|
const program = new Command();
|
|
@@ -19,7 +21,9 @@ program.addCommand(cloneCommand);
|
|
|
19
21
|
program.addCommand(devCommand);
|
|
20
22
|
program.addCommand(deployCommand);
|
|
21
23
|
program.addCommand(logsCommand);
|
|
24
|
+
program.addCommand(upgradeCommand);
|
|
22
25
|
program.addCommand(logoutCommand);
|
|
26
|
+
program.addCommand(integrationsCommand);
|
|
23
27
|
const credentialHelper = program
|
|
24
28
|
.command('git-credential-helper', { hidden: true })
|
|
25
29
|
.argument('<action>', 'Credential action (get/store/erase)')
|
package/dist/logs/tailer.js
CHANGED
|
@@ -22,7 +22,6 @@ export function startLogTailer(options) {
|
|
|
22
22
|
}
|
|
23
23
|
let lastStdoutLength = 0;
|
|
24
24
|
let lastStderrLength = 0;
|
|
25
|
-
let lastEventTimestamp;
|
|
26
25
|
let seenEventIds = new Set();
|
|
27
26
|
let stopped = false;
|
|
28
27
|
let timer;
|
|
@@ -81,13 +80,10 @@ export function startLogTailer(options) {
|
|
|
81
80
|
// Events are returned newest-first; process in chronological order
|
|
82
81
|
const sorted = [...result.events].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
83
82
|
for (const event of sorted) {
|
|
84
|
-
// Skip events we've already seen
|
|
83
|
+
// Skip events we've already seen by ID
|
|
85
84
|
if (seenEventIds.has(event.id)) {
|
|
86
85
|
continue;
|
|
87
86
|
}
|
|
88
|
-
if (lastEventTimestamp && event.timestamp < lastEventTimestamp) {
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
87
|
let detail = event.summary || event.content || event.type;
|
|
92
88
|
// Include structured metadata when available
|
|
93
89
|
if (event.metadata) {
|
|
@@ -105,12 +101,10 @@ export function startLogTailer(options) {
|
|
|
105
101
|
writeLine(formatLogLine('EVENT', `${event.type}: ${detail}`));
|
|
106
102
|
seenEventIds.add(event.id);
|
|
107
103
|
}
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
seenEventIds = new Set(sorted.filter(e => e.timestamp === latest.timestamp).map(e => e.id));
|
|
113
|
-
lastEventTimestamp = latest.timestamp;
|
|
104
|
+
// Cap the set size to prevent unbounded growth (keep last 200 IDs)
|
|
105
|
+
if (seenEventIds.size > 200) {
|
|
106
|
+
const ids = Array.from(seenEventIds);
|
|
107
|
+
seenEventIds = new Set(ids.slice(ids.length - 200));
|
|
114
108
|
}
|
|
115
109
|
}
|
|
116
110
|
}
|