happyskills 0.22.1 → 0.24.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 +10 -0
- package/package.json +1 -1
- package/src/api/push.js +10 -8
- package/src/commands/publish.js +11 -3
- package/src/commands/pull.js +14 -1
- package/src/merge/comparator.js +47 -4
- package/src/merge/comparator.test.js +93 -0
- package/src/merge/report.js +8 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.24.0] - 2026-03-30
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Add merge commit support to `pull` and `publish` — when `pull` auto-merges local and remote changes without conflicts, the lock file stores `merge_parents` (the old base and remote head). On `publish`, these are sent as `parent_shas` to create a two-parent merge commit, preserving DAG history. Conflicts fall back to rebase semantics (single-parent commit). `merge_parents` is cleared after publish, on fast-forward, or when conflicts exist.
|
|
14
|
+
|
|
15
|
+
## [0.23.0] - 2026-03-30
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- Add renamed file detection to `pull` merge — files renamed on one side (same content, different path) are now classified as `remote_renamed`/`local_renamed` instead of delete + add, and applied as renames on disk. Only 1-to-1 SHA matches are detected; ambiguous many-to-many cases are safely skipped.
|
|
19
|
+
|
|
10
20
|
## [0.22.1] - 2026-03-30
|
|
11
21
|
|
|
12
22
|
### Fixed
|
package/package.json
CHANGED
package/src/api/push.js
CHANGED
|
@@ -12,13 +12,15 @@ const estimate_payload_size = (files) => {
|
|
|
12
12
|
return size
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
const smart_push = (owner, repo, { version, message, files, visibility, base_commit, force }, on_progress) =>
|
|
15
|
+
const smart_push = (owner, repo, { version, message, files, visibility, base_commit, force, parent_shas }, on_progress) =>
|
|
16
16
|
catch_errors('Smart push failed', async () => {
|
|
17
17
|
const payload_size = estimate_payload_size(files)
|
|
18
18
|
|
|
19
19
|
if (payload_size < DIRECT_PUSH_THRESHOLD) {
|
|
20
20
|
// Small payload — use direct push
|
|
21
|
-
const
|
|
21
|
+
const body = { version, message, files, visibility, base_commit, force }
|
|
22
|
+
if (parent_shas) body.parent_shas = parent_shas
|
|
23
|
+
const [err, data] = await repos_api.push(owner, repo, body)
|
|
22
24
|
if (err) throw e('Direct push failed', err)
|
|
23
25
|
return data
|
|
24
26
|
}
|
|
@@ -27,9 +29,9 @@ const smart_push = (owner, repo, { version, message, files, visibility, base_com
|
|
|
27
29
|
const file_meta = files.map(f => ({ path: f.path, sha: f.sha, size: f.size }))
|
|
28
30
|
|
|
29
31
|
// Step 1: Initiate
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
const init_body = { version, message, files: file_meta, visibility, base_commit, force }
|
|
33
|
+
if (parent_shas) init_body.parent_shas = parent_shas
|
|
34
|
+
const [init_err, init_data] = await initiate_upload(owner, repo, init_body)
|
|
33
35
|
if (init_err) throw e('Upload initiation failed', init_err)
|
|
34
36
|
|
|
35
37
|
const { upload_id, presigned_urls } = init_data
|
|
@@ -52,9 +54,9 @@ const smart_push = (owner, repo, { version, message, files, visibility, base_com
|
|
|
52
54
|
if (upload_err) throw e('File uploads failed', upload_err)
|
|
53
55
|
|
|
54
56
|
// Step 3: Complete
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
const complete_body = { upload_id, version, message, files: file_meta, base_commit, force }
|
|
58
|
+
if (parent_shas) complete_body.parent_shas = parent_shas
|
|
59
|
+
const [complete_err, complete_data] = await complete_upload(owner, repo, complete_body)
|
|
58
60
|
if (complete_err) throw e('Upload completion failed', complete_err)
|
|
59
61
|
|
|
60
62
|
return complete_data
|
package/src/commands/publish.js
CHANGED
|
@@ -158,17 +158,19 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
// Read base_commit from lock file for divergence check
|
|
161
|
+
// Read base_commit and merge_parents from lock file for divergence check
|
|
162
162
|
const full_name_pre = `${workspace.slug}/${manifest.name}`
|
|
163
163
|
const project_root = find_project_root()
|
|
164
164
|
const [lock_err, lock_data] = await read_lock(project_root)
|
|
165
165
|
let base_commit = null
|
|
166
|
+
let merge_parents = null
|
|
166
167
|
if (!lock_err && lock_data) {
|
|
167
168
|
const all_skills = get_all_locked_skills(lock_data)
|
|
168
169
|
const suffix = `/${skill_name}`
|
|
169
170
|
const lock_key = Object.keys(all_skills).find(k => k.endsWith(suffix))
|
|
170
171
|
if (lock_key && all_skills[lock_key]) {
|
|
171
172
|
base_commit = all_skills[lock_key].base_commit || null
|
|
173
|
+
merge_parents = all_skills[lock_key].merge_parents || null
|
|
172
174
|
}
|
|
173
175
|
}
|
|
174
176
|
|
|
@@ -187,14 +189,18 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
187
189
|
spinner.update(`Uploading files (${completed}/${total})...`)
|
|
188
190
|
}
|
|
189
191
|
const visibility = args.flags.public ? 'public' : 'private'
|
|
190
|
-
const
|
|
192
|
+
const push_options = {
|
|
191
193
|
version: manifest.version,
|
|
192
194
|
message: `Release ${manifest.version}`,
|
|
193
195
|
files: skill_files,
|
|
194
196
|
visibility,
|
|
195
197
|
base_commit: force ? null : base_commit,
|
|
196
198
|
force
|
|
197
|
-
}
|
|
199
|
+
}
|
|
200
|
+
if (merge_parents && !force) {
|
|
201
|
+
push_options.parent_shas = merge_parents
|
|
202
|
+
}
|
|
203
|
+
const [push_err, push_data] = await smart_push(workspace.slug, manifest.name, push_options, on_progress)
|
|
198
204
|
if (push_err) {
|
|
199
205
|
const last = push_err[push_err.length - 1]
|
|
200
206
|
if (last?.message?.includes('diverged') || last?.message?.includes('DIVERGED')) {
|
|
@@ -226,6 +232,8 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
226
232
|
base_integrity: (!hash_err && integrity) ? integrity : null
|
|
227
233
|
}
|
|
228
234
|
if (!hash_err && integrity) updated_entry.integrity = integrity
|
|
235
|
+
delete updated_entry.merge_parents
|
|
236
|
+
delete updated_entry.conflict_files
|
|
229
237
|
const updated_skills = update_lock_skills(lock_data, { [lock_key]: updated_entry })
|
|
230
238
|
await write_lock(project_root, updated_skills)
|
|
231
239
|
} else {
|
package/src/commands/pull.js
CHANGED
|
@@ -205,6 +205,8 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
205
205
|
version: cmp_data.head_version || lock_entry.version,
|
|
206
206
|
ref: clone_data.ref || lock_entry.ref
|
|
207
207
|
}
|
|
208
|
+
delete updated_entry.merge_parents
|
|
209
|
+
delete updated_entry.conflict_files
|
|
208
210
|
const merged_skills = update_lock_skills(lock_data, { [skill_name]: updated_entry })
|
|
209
211
|
const [wl_err] = await write_lock(lock_root(is_global, project_root), merged_skills)
|
|
210
212
|
if (wl_err) { spinner.fail('Failed to write lock file'); throw wl_err[0] }
|
|
@@ -360,7 +362,16 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
360
362
|
const [del_err] = await remove_files(skill_dir, classified.remote_only_deleted.map(f => f.path))
|
|
361
363
|
if (del_err) { spinner.fail('Failed to remove deleted files'); throw del_err[0] }
|
|
362
364
|
|
|
363
|
-
//
|
|
365
|
+
// Remote-only renamed → rename local files
|
|
366
|
+
for (const entry of classified.remote_renamed) {
|
|
367
|
+
const old_full = path.join(skill_dir, entry.old_path)
|
|
368
|
+
const new_full = path.join(skill_dir, entry.new_path)
|
|
369
|
+
const [dir_err] = await ensure_dir(path.dirname(new_full))
|
|
370
|
+
if (dir_err) { spinner.fail(`Failed to create dir for ${entry.new_path}`); throw dir_err[0] }
|
|
371
|
+
await fs.promises.rename(old_full, new_full)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Local-only modified/added/renamed → keep (no action needed)
|
|
364
375
|
// Local-only deleted → keep deleted (no action needed)
|
|
365
376
|
if (full_report) {
|
|
366
377
|
for (const entry of [...classified.local_only_modified, ...classified.local_only_added]) {
|
|
@@ -431,8 +442,10 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
431
442
|
}
|
|
432
443
|
if (conflict_files.length > 0) {
|
|
433
444
|
updated_entry.conflict_files = conflict_files
|
|
445
|
+
delete updated_entry.merge_parents // Conflicts → rebase fallback
|
|
434
446
|
} else {
|
|
435
447
|
delete updated_entry.conflict_files
|
|
448
|
+
updated_entry.merge_parents = [lock_entry.base_commit, cmp_data.head_commit]
|
|
436
449
|
}
|
|
437
450
|
const merged_skills = update_lock_skills(lock_data, { [skill_name]: updated_entry })
|
|
438
451
|
const [wl_err] = await write_lock(lock_root(is_global, project_root), merged_skills)
|
package/src/merge/comparator.js
CHANGED
|
@@ -7,6 +7,37 @@
|
|
|
7
7
|
* All inputs are arrays of { path, sha } entries.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Detect 1-to-1 renames by matching SHAs between deleted and added files.
|
|
12
|
+
* Ambiguous matches (multiple deletes or adds sharing a SHA) are skipped.
|
|
13
|
+
*/
|
|
14
|
+
const detect_renames = (deleted, added, get_del_sha, get_add_sha) => {
|
|
15
|
+
const del_by_sha = new Map()
|
|
16
|
+
for (const entry of deleted) {
|
|
17
|
+
const sha = get_del_sha(entry)
|
|
18
|
+
if (!sha) continue
|
|
19
|
+
del_by_sha.set(sha, del_by_sha.has(sha) ? null : entry)
|
|
20
|
+
}
|
|
21
|
+
const add_by_sha = new Map()
|
|
22
|
+
for (const entry of added) {
|
|
23
|
+
const sha = get_add_sha(entry)
|
|
24
|
+
if (!sha) continue
|
|
25
|
+
add_by_sha.set(sha, add_by_sha.has(sha) ? null : entry)
|
|
26
|
+
}
|
|
27
|
+
const renames = []
|
|
28
|
+
const del_matched = new Set()
|
|
29
|
+
const add_matched = new Set()
|
|
30
|
+
for (const [sha, del_entry] of del_by_sha) {
|
|
31
|
+
if (!del_entry) continue
|
|
32
|
+
const add_entry = add_by_sha.get(sha)
|
|
33
|
+
if (!add_entry) continue
|
|
34
|
+
renames.push({ old_path: del_entry.path, new_path: add_entry.path, sha })
|
|
35
|
+
del_matched.add(del_entry.path)
|
|
36
|
+
add_matched.add(add_entry.path)
|
|
37
|
+
}
|
|
38
|
+
return { renames, del_matched, add_matched }
|
|
39
|
+
}
|
|
40
|
+
|
|
10
41
|
const classify_changes = (base_files, local_files, remote_files) => {
|
|
11
42
|
const base_map = new Map(base_files.map(e => [e.path, e.sha]))
|
|
12
43
|
const local_map = new Map(local_files.map(e => [e.path, e.sha]))
|
|
@@ -83,14 +114,26 @@ const classify_changes = (base_files, local_files, remote_files) => {
|
|
|
83
114
|
}
|
|
84
115
|
}
|
|
85
116
|
|
|
117
|
+
// Detect renames: 1-to-1 SHA matches between deleted and added on the same side
|
|
118
|
+
const r = detect_renames(
|
|
119
|
+
remote_only_deleted, remote_only_added,
|
|
120
|
+
e => base_map.get(e.path), e => e.remote_sha
|
|
121
|
+
)
|
|
122
|
+
const l = detect_renames(
|
|
123
|
+
local_only_deleted, local_only_added,
|
|
124
|
+
e => base_map.get(e.path), e => e.local_sha
|
|
125
|
+
)
|
|
126
|
+
|
|
86
127
|
return {
|
|
87
128
|
remote_only_modified,
|
|
88
129
|
local_only_modified,
|
|
89
130
|
both_modified,
|
|
90
|
-
remote_only_added,
|
|
91
|
-
local_only_added,
|
|
92
|
-
remote_only_deleted,
|
|
93
|
-
local_only_deleted,
|
|
131
|
+
remote_only_added: remote_only_added.filter(e => !r.add_matched.has(e.path)),
|
|
132
|
+
local_only_added: local_only_added.filter(e => !l.add_matched.has(e.path)),
|
|
133
|
+
remote_only_deleted: remote_only_deleted.filter(e => !r.del_matched.has(e.path)),
|
|
134
|
+
local_only_deleted: local_only_deleted.filter(e => !l.del_matched.has(e.path)),
|
|
135
|
+
remote_renamed: r.renames,
|
|
136
|
+
local_renamed: l.renames,
|
|
94
137
|
unchanged
|
|
95
138
|
}
|
|
96
139
|
}
|
|
@@ -158,4 +158,97 @@ describe('classify_changes', () => {
|
|
|
158
158
|
assert.strictEqual(r.both_modified.length, 1)
|
|
159
159
|
assert.deepStrictEqual(r.both_modified[0], { path: 'a.md', base_sha: '111', local_sha: '222', remote_sha: null })
|
|
160
160
|
})
|
|
161
|
+
|
|
162
|
+
it('detects remote rename (1-to-1 SHA match)', () => {
|
|
163
|
+
const base = [{ path: 'old.md', sha: 'aaa' }]
|
|
164
|
+
const local = [{ path: 'old.md', sha: 'aaa' }]
|
|
165
|
+
const remote = [{ path: 'new.md', sha: 'aaa' }]
|
|
166
|
+
const r = classify_changes(base, local, remote)
|
|
167
|
+
assert.strictEqual(r.remote_renamed.length, 1)
|
|
168
|
+
assert.deepStrictEqual(r.remote_renamed[0], { old_path: 'old.md', new_path: 'new.md', sha: 'aaa' })
|
|
169
|
+
assert.strictEqual(r.remote_only_deleted.length, 0)
|
|
170
|
+
assert.strictEqual(r.remote_only_added.length, 0)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('detects local rename (1-to-1 SHA match)', () => {
|
|
174
|
+
const base = [{ path: 'old.md', sha: 'bbb' }]
|
|
175
|
+
const local = [{ path: 'new.md', sha: 'bbb' }]
|
|
176
|
+
const remote = [{ path: 'old.md', sha: 'bbb' }]
|
|
177
|
+
const r = classify_changes(base, local, remote)
|
|
178
|
+
assert.strictEqual(r.local_renamed.length, 1)
|
|
179
|
+
assert.deepStrictEqual(r.local_renamed[0], { old_path: 'old.md', new_path: 'new.md', sha: 'bbb' })
|
|
180
|
+
assert.strictEqual(r.local_only_deleted.length, 0)
|
|
181
|
+
assert.strictEqual(r.local_only_added.length, 0)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('skips rename detection for many-to-many SHA matches', () => {
|
|
185
|
+
// Two deleted files with same SHA + two added files with same SHA → ambiguous
|
|
186
|
+
const base = [
|
|
187
|
+
{ path: 'a.md', sha: 'xxx' },
|
|
188
|
+
{ path: 'b.md', sha: 'xxx' },
|
|
189
|
+
]
|
|
190
|
+
const local = [
|
|
191
|
+
{ path: 'a.md', sha: 'xxx' },
|
|
192
|
+
{ path: 'b.md', sha: 'xxx' },
|
|
193
|
+
]
|
|
194
|
+
const remote = [
|
|
195
|
+
{ path: 'c.md', sha: 'xxx' },
|
|
196
|
+
{ path: 'd.md', sha: 'xxx' },
|
|
197
|
+
]
|
|
198
|
+
const r = classify_changes(base, local, remote)
|
|
199
|
+
assert.strictEqual(r.remote_renamed.length, 0)
|
|
200
|
+
assert.strictEqual(r.remote_only_deleted.length, 2)
|
|
201
|
+
assert.strictEqual(r.remote_only_added.length, 2)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('skips rename when only one side is ambiguous (two deletes, one add)', () => {
|
|
205
|
+
const base = [
|
|
206
|
+
{ path: 'a.md', sha: 'xxx' },
|
|
207
|
+
{ path: 'b.md', sha: 'xxx' },
|
|
208
|
+
]
|
|
209
|
+
const local = [
|
|
210
|
+
{ path: 'a.md', sha: 'xxx' },
|
|
211
|
+
{ path: 'b.md', sha: 'xxx' },
|
|
212
|
+
]
|
|
213
|
+
const remote = [{ path: 'c.md', sha: 'xxx' }]
|
|
214
|
+
const r = classify_changes(base, local, remote)
|
|
215
|
+
assert.strictEqual(r.remote_renamed.length, 0)
|
|
216
|
+
assert.strictEqual(r.remote_only_deleted.length, 2)
|
|
217
|
+
assert.strictEqual(r.remote_only_added.length, 1)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('does not detect rename when content changed (different SHAs)', () => {
|
|
221
|
+
const base = [{ path: 'old.md', sha: 'aaa' }]
|
|
222
|
+
const local = [{ path: 'old.md', sha: 'aaa' }]
|
|
223
|
+
const remote = [{ path: 'new.md', sha: 'bbb' }]
|
|
224
|
+
const r = classify_changes(base, local, remote)
|
|
225
|
+
assert.strictEqual(r.remote_renamed.length, 0)
|
|
226
|
+
assert.strictEqual(r.remote_only_deleted.length, 1)
|
|
227
|
+
assert.strictEqual(r.remote_only_added.length, 1)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('handles mixed scenario with renames alongside other changes', () => {
|
|
231
|
+
const base = [
|
|
232
|
+
{ path: 'unchanged.md', sha: '000' },
|
|
233
|
+
{ path: 'old_name.md', sha: 'rrr' },
|
|
234
|
+
{ path: 'remote_mod.md', sha: '111' },
|
|
235
|
+
]
|
|
236
|
+
const local = [
|
|
237
|
+
{ path: 'unchanged.md', sha: '000' },
|
|
238
|
+
{ path: 'old_name.md', sha: 'rrr' },
|
|
239
|
+
{ path: 'remote_mod.md', sha: '111' },
|
|
240
|
+
]
|
|
241
|
+
const remote = [
|
|
242
|
+
{ path: 'unchanged.md', sha: '000' },
|
|
243
|
+
{ path: 'new_name.md', sha: 'rrr' },
|
|
244
|
+
{ path: 'remote_mod.md', sha: 'R11' },
|
|
245
|
+
]
|
|
246
|
+
const r = classify_changes(base, local, remote)
|
|
247
|
+
assert.strictEqual(r.unchanged.length, 1)
|
|
248
|
+
assert.strictEqual(r.remote_renamed.length, 1)
|
|
249
|
+
assert.deepStrictEqual(r.remote_renamed[0], { old_path: 'old_name.md', new_path: 'new_name.md', sha: 'rrr' })
|
|
250
|
+
assert.strictEqual(r.remote_only_modified.length, 1)
|
|
251
|
+
assert.strictEqual(r.remote_only_deleted.length, 0)
|
|
252
|
+
assert.strictEqual(r.remote_only_added.length, 0)
|
|
253
|
+
})
|
|
161
254
|
})
|
package/src/merge/report.js
CHANGED
|
@@ -14,6 +14,7 @@ const CLASSIFICATIONS = [
|
|
|
14
14
|
'remote_only_modified', 'local_only_modified', 'both_modified',
|
|
15
15
|
'remote_only_added', 'local_only_added',
|
|
16
16
|
'remote_only_deleted', 'local_only_deleted',
|
|
17
|
+
'remote_renamed', 'local_renamed',
|
|
17
18
|
'unchanged'
|
|
18
19
|
]
|
|
19
20
|
|
|
@@ -41,14 +42,17 @@ const build_report = (skill, base_version, remote_version, classified) => {
|
|
|
41
42
|
if (is_conflict) conflicted++
|
|
42
43
|
else auto_merged++
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
const is_rename = classification === 'remote_renamed' || classification === 'local_renamed'
|
|
46
|
+
const file_entry = {
|
|
47
|
+
path: is_rename ? entry.new_path : entry.path,
|
|
46
48
|
classification,
|
|
47
49
|
conflict_written: false,
|
|
48
|
-
base_sha: entry.base_sha || null,
|
|
50
|
+
base_sha: entry.base_sha || entry.sha || null,
|
|
49
51
|
local_sha: entry.local_sha || null,
|
|
50
52
|
remote_sha: entry.remote_sha || null
|
|
51
|
-
}
|
|
53
|
+
}
|
|
54
|
+
if (is_rename) file_entry.old_path = entry.old_path
|
|
55
|
+
files.push(file_entry)
|
|
52
56
|
}
|
|
53
57
|
}
|
|
54
58
|
|