happyskills 0.48.0 → 0.49.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/CHANGELOG.md +48 -0
- package/package.json +1 -1
- package/src/commands/bump.js +9 -29
- package/src/commands/check.js +36 -4
- package/src/commands/install.js +96 -11
- package/src/commands/list.js +39 -9
- package/src/commands/publish.js +43 -36
- package/src/commands/pull.js +40 -0
- package/src/commands/reconcile.js +229 -0
- package/src/commands/release.js +451 -0
- package/src/commands/release.test.js +209 -0
- package/src/commands/snapshot.js +252 -0
- package/src/commands/status.js +45 -23
- package/src/config/limits.js +11 -0
- package/src/constants.js +4 -1
- package/src/index.js +3 -0
- package/src/integration/bump.test.js +170 -0
- package/src/integration/drift.test.js +63 -8
- package/src/integration/install_fresh.test.js +167 -0
- package/src/integration/reconcile.test.js +188 -0
- package/src/integration/release.test.js +183 -0
- package/src/lock/verify.js +151 -16
- package/src/lock/verify.test.js +182 -10
- package/src/merge/rebase.js +322 -0
- package/src/merge/rebase.test.js +110 -0
- package/src/snapshot/paths.js +31 -0
- package/src/snapshot/storage.js +237 -0
- package/src/snapshot/storage.test.js +298 -0
- package/src/validation/file_size_rules.js +20 -9
- package/src/validation/file_size_rules.test.js +23 -18
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
4
|
+
const repos_api = require('../api/repos')
|
|
5
|
+
const snapshot_storage = require('../snapshot/storage')
|
|
6
|
+
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
7
|
+
const { write_lock, update_lock_skills } = require('../lock/writer')
|
|
8
|
+
const { hash_directory } = require('../lock/integrity')
|
|
9
|
+
const { skills_dir, skill_install_dir, lock_root, find_project_root } = require('../config/paths')
|
|
10
|
+
const { ensure_dir, read_file } = require('../utils/fs')
|
|
11
|
+
const { unified_diff } = require('../utils/text_diff')
|
|
12
|
+
|
|
13
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
14
|
+
// Rebase-style pull (§ 8.3)
|
|
15
|
+
//
|
|
16
|
+
// Differs from the existing 3-way merge: instead of writing conflict markers
|
|
17
|
+
// into files, we capture local edits as patches, fast-forward to the registry
|
|
18
|
+
// HEAD, and reapply the patches. On rejection (the rebased file's expected
|
|
19
|
+
// context no longer matches), emit a structured envelope that gives the
|
|
20
|
+
// operator (LLM or human) enough information to produce a corrected patch
|
|
21
|
+
// without re-reading entire files.
|
|
22
|
+
//
|
|
23
|
+
// Snapshot-first invariant: every rebase begins with a snapshot. On any
|
|
24
|
+
// failure path the operator can restore — the snapshot_id is in every
|
|
25
|
+
// rejection envelope.
|
|
26
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const decode_b64 = (file) => file ? Buffer.from(file.content, 'base64').toString('utf-8') : null
|
|
29
|
+
|
|
30
|
+
// Read every file in skill_dir relative to skill_dir, returning a map of
|
|
31
|
+
// path → utf-8 content. Skips dotfiles/dotdirs, matching the rest of the
|
|
32
|
+
// codebase's collect_files semantics.
|
|
33
|
+
const read_dir_files = (dir) => catch_errors('Failed to read dir files', async () => {
|
|
34
|
+
const out = new Map()
|
|
35
|
+
const walk = async (cur, prefix) => {
|
|
36
|
+
const items = await fs.promises.readdir(cur, { withFileTypes: true })
|
|
37
|
+
for (const item of items) {
|
|
38
|
+
if (item.name.startsWith('.')) continue
|
|
39
|
+
const rel = prefix ? `${prefix}/${item.name}` : item.name
|
|
40
|
+
const full = path.join(cur, item.name)
|
|
41
|
+
if (item.isDirectory()) {
|
|
42
|
+
await walk(full, rel)
|
|
43
|
+
} else if (item.isFile()) {
|
|
44
|
+
const content = await fs.promises.readFile(full, 'utf-8')
|
|
45
|
+
out.set(rel, content)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
await walk(dir, '')
|
|
50
|
+
return out
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Compare base content vs current disk content → patches for files that
|
|
54
|
+
// differ. The result is a list of { path, base_content, local_content,
|
|
55
|
+
// patch } — we keep the raw contents around because the reapply step needs
|
|
56
|
+
// them to detect three-way conflicts cleanly.
|
|
57
|
+
const capture_local_patches = ({ base_files_map, local_files_map }) => {
|
|
58
|
+
const patches = []
|
|
59
|
+
const all_paths = new Set([...base_files_map.keys(), ...local_files_map.keys()])
|
|
60
|
+
for (const p of all_paths) {
|
|
61
|
+
const base = base_files_map.get(p)
|
|
62
|
+
const local = local_files_map.get(p)
|
|
63
|
+
if (base === local) continue
|
|
64
|
+
if (base === undefined && local !== undefined) {
|
|
65
|
+
patches.push({ path: p, type: 'added', base_content: null, local_content: local, patch: unified_diff('', local, `base/${p}`, `local/${p}`) })
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
if (local === undefined && base !== undefined) {
|
|
69
|
+
patches.push({ path: p, type: 'deleted', base_content: base, local_content: null, patch: unified_diff(base, '', `base/${p}`, `local/${p}`) })
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
patches.push({ path: p, type: 'modified', base_content: base, local_content: local, patch: unified_diff(base, local, `base/${p}`, `local/${p}`) })
|
|
73
|
+
}
|
|
74
|
+
return patches
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Reapply a captured patch onto a new base file (post-fast-forward content).
|
|
78
|
+
// Strategy:
|
|
79
|
+
// - If the new base content equals the captured base_content → patch
|
|
80
|
+
// trivially applies (we just write local_content back).
|
|
81
|
+
// - If the new base content equals the captured local_content → patch
|
|
82
|
+
// is already a no-op (remote already had our change). Skip.
|
|
83
|
+
// - Otherwise → rejection. Emit a structured rejection entry with both
|
|
84
|
+
// contexts so the operator can re-aim the patch.
|
|
85
|
+
const reapply_patch = (patch, remote_file_content) => {
|
|
86
|
+
if (patch.type === 'added') {
|
|
87
|
+
// File was added locally. If remote also added it, prefer local
|
|
88
|
+
// (this is rebase — local wins on add/add); if remote didn't,
|
|
89
|
+
// just write our addition.
|
|
90
|
+
return { kind: 'apply_local', content: patch.local_content }
|
|
91
|
+
}
|
|
92
|
+
if (patch.type === 'deleted') {
|
|
93
|
+
// File was deleted locally. If remote also deleted it, no-op.
|
|
94
|
+
// If remote modified it, that's a conflict.
|
|
95
|
+
if (remote_file_content === undefined || remote_file_content === null) {
|
|
96
|
+
return { kind: 'apply_delete' }
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
kind: 'reject',
|
|
100
|
+
reason: 'delete_vs_modify',
|
|
101
|
+
expected_context_before: patch.base_content,
|
|
102
|
+
actual_context_before: remote_file_content,
|
|
103
|
+
intended_change_summary: 'Local deleted; remote modified the same file.'
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Modified locally.
|
|
107
|
+
if (remote_file_content === patch.base_content) {
|
|
108
|
+
return { kind: 'apply_local', content: patch.local_content }
|
|
109
|
+
}
|
|
110
|
+
if (remote_file_content === patch.local_content) {
|
|
111
|
+
// Remote already has the same change we made — patch is a no-op.
|
|
112
|
+
return { kind: 'noop' }
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
kind: 'reject',
|
|
116
|
+
reason: 'context_changed',
|
|
117
|
+
expected_context_before: patch.base_content,
|
|
118
|
+
actual_context_before: remote_file_content,
|
|
119
|
+
intended_change_summary: 'Local modified a file whose remote content has also changed.'
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const rebase_pull = (skill_name, options = {}) => catch_errors('Pull --rebase failed', async () => {
|
|
124
|
+
if (!skill_name || !skill_name.includes('/')) {
|
|
125
|
+
throw new Error('skill_name must be in owner/name format')
|
|
126
|
+
}
|
|
127
|
+
const project_root = options.project_root || find_project_root()
|
|
128
|
+
const is_global = !!options.is_global
|
|
129
|
+
|
|
130
|
+
const [lock_err, lock_data] = await read_lock(lock_root(is_global, project_root))
|
|
131
|
+
if (lock_err || !lock_data) throw new Error('No lock file found')
|
|
132
|
+
const all = get_all_locked_skills(lock_data)
|
|
133
|
+
const lock_entry = all[skill_name]
|
|
134
|
+
if (!lock_entry) throw new Error(`${skill_name} is not installed`)
|
|
135
|
+
if (!lock_entry.base_commit) throw new Error(`${skill_name} has no base_commit`)
|
|
136
|
+
|
|
137
|
+
const [owner, repo] = skill_name.split('/')
|
|
138
|
+
const base_dir = skills_dir(is_global, project_root)
|
|
139
|
+
const skill_dir = skill_install_dir(base_dir, repo)
|
|
140
|
+
|
|
141
|
+
// Snapshot first — always.
|
|
142
|
+
const [snap_err, snap] = await snapshot_storage.create({
|
|
143
|
+
skill_dir,
|
|
144
|
+
workspace: owner,
|
|
145
|
+
skill: repo,
|
|
146
|
+
lock_entry,
|
|
147
|
+
note: `pre-rebase-pull`,
|
|
148
|
+
is_global,
|
|
149
|
+
project_root
|
|
150
|
+
})
|
|
151
|
+
if (snap_err) throw e('Snapshot failed', snap_err)
|
|
152
|
+
|
|
153
|
+
const restore_and = async (envelope) => {
|
|
154
|
+
await snapshot_storage.restore(snap.snapshot_id, { skill_dir, is_global, project_root })
|
|
155
|
+
return { ...envelope, snapshot_id: snap.snapshot_id }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Compare with remote.
|
|
159
|
+
const [cmp_err, cmp_data] = await repos_api.compare(owner, repo, lock_entry.base_commit)
|
|
160
|
+
if (cmp_err) {
|
|
161
|
+
return await restore_and({
|
|
162
|
+
error: { code: 'COMPARE_FAILED', message: 'Failed to compare with remote' },
|
|
163
|
+
next_step: { action: 'retry_or_abandon' }
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (cmp_data.added.length === 0 && cmp_data.removed.length === 0 && cmp_data.modified.length === 0) {
|
|
168
|
+
// Up to date — drop the snapshot (nothing happened).
|
|
169
|
+
await snapshot_storage.remove(snap.snapshot_id, { is_global, project_root })
|
|
170
|
+
return {
|
|
171
|
+
data: { status: 'up_to_date', skill: skill_name },
|
|
172
|
+
next_step: null
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Pull base and remote content.
|
|
177
|
+
const [base_clone_err, base_clone] = await repos_api.clone(owner, repo, null, { commit: lock_entry.base_commit })
|
|
178
|
+
if (base_clone_err) {
|
|
179
|
+
return await restore_and({
|
|
180
|
+
error: { code: 'CLONE_BASE_FAILED', message: 'Failed to clone base commit' },
|
|
181
|
+
next_step: { action: 'retry_or_abandon' }
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
const [remote_clone_err, remote_clone] = await repos_api.clone(owner, repo, null, { commit: cmp_data.head_commit })
|
|
185
|
+
if (remote_clone_err) {
|
|
186
|
+
return await restore_and({
|
|
187
|
+
error: { code: 'CLONE_REMOTE_FAILED', message: 'Failed to clone remote head' },
|
|
188
|
+
next_step: { action: 'retry_or_abandon' }
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const base_files_map = new Map((base_clone.files || []).map(f => [f.path, decode_b64(f)]))
|
|
193
|
+
const remote_files_map = new Map((remote_clone.files || []).map(f => [f.path, decode_b64(f)]))
|
|
194
|
+
|
|
195
|
+
// Capture local patches relative to the base commit.
|
|
196
|
+
const [local_err, local_files_map] = await read_dir_files(skill_dir)
|
|
197
|
+
if (local_err) {
|
|
198
|
+
return await restore_and({
|
|
199
|
+
error: { code: 'READ_LOCAL_FAILED', message: 'Failed to read local files' },
|
|
200
|
+
next_step: { action: 'retry_or_abandon' }
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
const patches = capture_local_patches({ base_files_map, local_files_map })
|
|
204
|
+
|
|
205
|
+
// Fast-forward: write remote files into skill_dir, deleting anything not in remote.
|
|
206
|
+
// We do this in a staging directory and atomically swap.
|
|
207
|
+
const staging = `${skill_dir}.rebase-staging-${process.pid}-${Date.now()}`
|
|
208
|
+
await fs.promises.mkdir(staging, { recursive: true })
|
|
209
|
+
for (const [p, content] of remote_files_map) {
|
|
210
|
+
const dest = path.join(staging, p)
|
|
211
|
+
await ensure_dir(path.dirname(dest))
|
|
212
|
+
await fs.promises.writeFile(dest, content, 'utf-8')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Reapply each patch.
|
|
216
|
+
const applied = []
|
|
217
|
+
const rejected = []
|
|
218
|
+
for (const patch of patches) {
|
|
219
|
+
const remote_content = remote_files_map.get(patch.path)
|
|
220
|
+
const decision = reapply_patch(patch, remote_content)
|
|
221
|
+
if (decision.kind === 'apply_local') {
|
|
222
|
+
const dest = path.join(staging, patch.path)
|
|
223
|
+
await ensure_dir(path.dirname(dest))
|
|
224
|
+
await fs.promises.writeFile(dest, decision.content, 'utf-8')
|
|
225
|
+
applied.push({ path: patch.path, type: patch.type })
|
|
226
|
+
} else if (decision.kind === 'apply_delete') {
|
|
227
|
+
const dest = path.join(staging, patch.path)
|
|
228
|
+
try { await fs.promises.unlink(dest) } catch { /* not present, fine */ }
|
|
229
|
+
applied.push({ path: patch.path, type: 'deleted' })
|
|
230
|
+
} else if (decision.kind === 'noop') {
|
|
231
|
+
applied.push({ path: patch.path, type: patch.type, noop: true })
|
|
232
|
+
} else if (decision.kind === 'reject') {
|
|
233
|
+
rejected.push({
|
|
234
|
+
file: patch.path,
|
|
235
|
+
patch_type: patch.type,
|
|
236
|
+
reason: decision.reason,
|
|
237
|
+
expected_context_before: decision.expected_context_before,
|
|
238
|
+
actual_context_before: decision.actual_context_before,
|
|
239
|
+
patch: patch.patch,
|
|
240
|
+
intended_change_summary: decision.intended_change_summary
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (rejected.length > 0) {
|
|
246
|
+
// Discard staging directory; the snapshot is the canonical safety net
|
|
247
|
+
// and the operator decides what to do next.
|
|
248
|
+
await fs.promises.rm(staging, { recursive: true, force: true })
|
|
249
|
+
// Note: we do NOT restore the snapshot here — the operator may want
|
|
250
|
+
// to inspect the captured patches and rejection context before
|
|
251
|
+
// deciding. Restoration is one explicit `snapshot restore` away.
|
|
252
|
+
return {
|
|
253
|
+
data: {
|
|
254
|
+
operation: 'pull_rebase',
|
|
255
|
+
skill: skill_name,
|
|
256
|
+
fast_forward_to: cmp_data.head_commit,
|
|
257
|
+
patches_applied: applied,
|
|
258
|
+
patches_rejected: rejected,
|
|
259
|
+
snapshot_id: snap.snapshot_id
|
|
260
|
+
},
|
|
261
|
+
next_step: {
|
|
262
|
+
action: 'resolve_patch_rejections',
|
|
263
|
+
context: {
|
|
264
|
+
rejection_count: rejected.length,
|
|
265
|
+
options: ['resolve_and_continue', 'abandon_and_restore'],
|
|
266
|
+
instructions: 'Either resolve each rejection by re-applying the corrected patches yourself, or restore the pre-rebase state with `npx happyskills snapshot restore <snapshot_id>`.'
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
error: null
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// All patches applied cleanly — swap staging into place.
|
|
274
|
+
const trash = `${skill_dir}.rebase-trash-${process.pid}-${Date.now()}`
|
|
275
|
+
await fs.promises.rename(skill_dir, trash)
|
|
276
|
+
try {
|
|
277
|
+
await fs.promises.rename(staging, skill_dir)
|
|
278
|
+
} catch (err) {
|
|
279
|
+
// Roll back.
|
|
280
|
+
await fs.promises.rename(trash, skill_dir).catch(() => {})
|
|
281
|
+
await fs.promises.rm(staging, { recursive: true, force: true })
|
|
282
|
+
return await restore_and({
|
|
283
|
+
error: { code: 'SWAP_FAILED', message: 'Failed to swap rebased content into place' },
|
|
284
|
+
next_step: { action: 'retry_or_abandon' }
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
await fs.promises.rm(trash, { recursive: true, force: true }).catch(() => {})
|
|
288
|
+
|
|
289
|
+
// Update lock.
|
|
290
|
+
const [, new_integrity] = await hash_directory(skill_dir)
|
|
291
|
+
const updated_entry = {
|
|
292
|
+
...lock_entry,
|
|
293
|
+
commit: cmp_data.head_commit,
|
|
294
|
+
base_commit: cmp_data.head_commit,
|
|
295
|
+
integrity: new_integrity || lock_entry.integrity,
|
|
296
|
+
base_integrity: new_integrity || lock_entry.base_integrity,
|
|
297
|
+
version: cmp_data.head_version || lock_entry.version
|
|
298
|
+
}
|
|
299
|
+
delete updated_entry.merge_parents
|
|
300
|
+
delete updated_entry.conflict_files
|
|
301
|
+
const merged = update_lock_skills(lock_data, { [skill_name]: updated_entry })
|
|
302
|
+
await write_lock(lock_root(is_global, project_root), merged)
|
|
303
|
+
|
|
304
|
+
// Success — drop the snapshot.
|
|
305
|
+
await snapshot_storage.remove(snap.snapshot_id, { is_global, project_root })
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
data: {
|
|
309
|
+
operation: 'pull_rebase',
|
|
310
|
+
status: applied.length > 0 ? 'rebased' : 'fast_forward',
|
|
311
|
+
skill: skill_name,
|
|
312
|
+
fast_forward_to: cmp_data.head_commit,
|
|
313
|
+
patches_applied: applied,
|
|
314
|
+
patches_rejected: [],
|
|
315
|
+
version: cmp_data.head_version || lock_entry.version
|
|
316
|
+
},
|
|
317
|
+
next_step: null,
|
|
318
|
+
error: null
|
|
319
|
+
}
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
module.exports = { rebase_pull, capture_local_patches, reapply_patch, read_dir_files }
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { describe, it } = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const os = require('os')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
|
|
8
|
+
const { capture_local_patches, reapply_patch, read_dir_files } = require('./rebase')
|
|
9
|
+
|
|
10
|
+
describe('rebase.capture_local_patches', () => {
|
|
11
|
+
it('identifies modified files and produces a patch', () => {
|
|
12
|
+
const base = new Map([['SKILL.md', 'one\ntwo\n'], ['skill.json', '{"v":1}']])
|
|
13
|
+
const local = new Map([['SKILL.md', 'one\ntwo\nthree\n'], ['skill.json', '{"v":1}']])
|
|
14
|
+
const patches = capture_local_patches({ base_files_map: base, local_files_map: local })
|
|
15
|
+
assert.strictEqual(patches.length, 1)
|
|
16
|
+
assert.strictEqual(patches[0].path, 'SKILL.md')
|
|
17
|
+
assert.strictEqual(patches[0].type, 'modified')
|
|
18
|
+
assert.ok(patches[0].patch.includes('+three'), 'unified diff should show the added line')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('identifies added files', () => {
|
|
22
|
+
const base = new Map([['SKILL.md', 'body']])
|
|
23
|
+
const local = new Map([['SKILL.md', 'body'], ['references/new.md', 'new']])
|
|
24
|
+
const patches = capture_local_patches({ base_files_map: base, local_files_map: local })
|
|
25
|
+
assert.strictEqual(patches.length, 1)
|
|
26
|
+
assert.strictEqual(patches[0].path, 'references/new.md')
|
|
27
|
+
assert.strictEqual(patches[0].type, 'added')
|
|
28
|
+
assert.strictEqual(patches[0].local_content, 'new')
|
|
29
|
+
assert.strictEqual(patches[0].base_content, null)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('identifies deleted files', () => {
|
|
33
|
+
const base = new Map([['SKILL.md', 'body'], ['gone.md', 'goodbye']])
|
|
34
|
+
const local = new Map([['SKILL.md', 'body']])
|
|
35
|
+
const patches = capture_local_patches({ base_files_map: base, local_files_map: local })
|
|
36
|
+
assert.strictEqual(patches.length, 1)
|
|
37
|
+
assert.strictEqual(patches[0].path, 'gone.md')
|
|
38
|
+
assert.strictEqual(patches[0].type, 'deleted')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('returns no patches when base and local are identical', () => {
|
|
42
|
+
const same = new Map([['SKILL.md', 'body'], ['skill.json', '{}']])
|
|
43
|
+
const patches = capture_local_patches({ base_files_map: same, local_files_map: same })
|
|
44
|
+
assert.deepEqual(patches, [])
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('rebase.reapply_patch', () => {
|
|
49
|
+
it('applies a modified patch when remote content equals base (clean apply)', () => {
|
|
50
|
+
const patch = { path: 'SKILL.md', type: 'modified', base_content: 'a\nb\nc\n', local_content: 'a\nb\nc\nd\n' }
|
|
51
|
+
const remote = 'a\nb\nc\n'
|
|
52
|
+
const decision = reapply_patch(patch, remote)
|
|
53
|
+
assert.strictEqual(decision.kind, 'apply_local')
|
|
54
|
+
assert.strictEqual(decision.content, 'a\nb\nc\nd\n')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('returns noop when remote already has the same change', () => {
|
|
58
|
+
const patch = { path: 'SKILL.md', type: 'modified', base_content: 'a\n', local_content: 'a\nb\n' }
|
|
59
|
+
const remote = 'a\nb\n'
|
|
60
|
+
const decision = reapply_patch(patch, remote)
|
|
61
|
+
assert.strictEqual(decision.kind, 'noop')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('rejects when remote content differs from both base and local (context change)', () => {
|
|
65
|
+
const patch = { path: 'SKILL.md', type: 'modified', base_content: 'a\n', local_content: 'a\nb\n' }
|
|
66
|
+
const remote = 'a\nx\ny\n'
|
|
67
|
+
const decision = reapply_patch(patch, remote)
|
|
68
|
+
assert.strictEqual(decision.kind, 'reject')
|
|
69
|
+
assert.strictEqual(decision.reason, 'context_changed')
|
|
70
|
+
assert.strictEqual(decision.expected_context_before, 'a\n')
|
|
71
|
+
assert.strictEqual(decision.actual_context_before, 'a\nx\ny\n')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('writes added file unconditionally for add patches', () => {
|
|
75
|
+
const patch = { path: 'new.md', type: 'added', base_content: null, local_content: 'fresh\n' }
|
|
76
|
+
const decision = reapply_patch(patch, undefined)
|
|
77
|
+
assert.strictEqual(decision.kind, 'apply_local')
|
|
78
|
+
assert.strictEqual(decision.content, 'fresh\n')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('deletes the file when both sides deleted it', () => {
|
|
82
|
+
const patch = { path: 'gone.md', type: 'deleted', base_content: 'x', local_content: null }
|
|
83
|
+
const decision = reapply_patch(patch, undefined)
|
|
84
|
+
assert.strictEqual(decision.kind, 'apply_delete')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('rejects delete-vs-modify (local deleted; remote modified)', () => {
|
|
88
|
+
const patch = { path: 'gone.md', type: 'deleted', base_content: 'x', local_content: null }
|
|
89
|
+
const decision = reapply_patch(patch, 'modified-on-remote')
|
|
90
|
+
assert.strictEqual(decision.kind, 'reject')
|
|
91
|
+
assert.strictEqual(decision.reason, 'delete_vs_modify')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('rebase.read_dir_files', () => {
|
|
96
|
+
it('reads all non-dot files recursively', async () => {
|
|
97
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-rebase-readdir-'))
|
|
98
|
+
try {
|
|
99
|
+
fs.mkdirSync(path.join(root, 'references'), { recursive: true })
|
|
100
|
+
fs.writeFileSync(path.join(root, 'SKILL.md'), 'top')
|
|
101
|
+
fs.writeFileSync(path.join(root, 'references', 'foo.md'), 'nested')
|
|
102
|
+
fs.mkdirSync(path.join(root, '.tmp'), { recursive: true })
|
|
103
|
+
fs.writeFileSync(path.join(root, '.tmp', 'ignore.txt'), 'should be skipped')
|
|
104
|
+
const [, files] = await read_dir_files(root)
|
|
105
|
+
assert.strictEqual(files.size, 2)
|
|
106
|
+
assert.strictEqual(files.get('SKILL.md'), 'top')
|
|
107
|
+
assert.strictEqual(files.get('references/foo.md'), 'nested')
|
|
108
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const { home_dir } = require('../config/paths')
|
|
3
|
+
|
|
4
|
+
// Snapshots are local-only artifacts. Project snapshots live under the project
|
|
5
|
+
// root; global snapshots live under the user's home so global-installed skills
|
|
6
|
+
// have a parallel safety net. Workspace is encoded into the path so a skill
|
|
7
|
+
// short-name collision across workspaces is impossible.
|
|
8
|
+
const snapshots_root = (is_global, project_root) => {
|
|
9
|
+
if (is_global) return path.join(home_dir, '.happyskills', 'snapshots')
|
|
10
|
+
return path.join(project_root || process.cwd(), '.happyskills', 'snapshots')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const skill_snapshots_dir = (is_global, project_root, workspace, skill) => {
|
|
14
|
+
return path.join(snapshots_root(is_global, project_root), workspace, skill)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const snapshot_dir = (is_global, project_root, workspace, skill, snapshot_id) => {
|
|
18
|
+
return path.join(skill_snapshots_dir(is_global, project_root, workspace, skill), snapshot_id)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const snapshot_manifest_path = (snap_dir) => path.join(snap_dir, 'manifest.json')
|
|
22
|
+
|
|
23
|
+
const snapshot_content_dir = (snap_dir) => path.join(snap_dir, 'content')
|
|
24
|
+
|
|
25
|
+
module.exports = {
|
|
26
|
+
snapshots_root,
|
|
27
|
+
skill_snapshots_dir,
|
|
28
|
+
snapshot_dir,
|
|
29
|
+
snapshot_manifest_path,
|
|
30
|
+
snapshot_content_dir
|
|
31
|
+
}
|