happyskills 0.22.0 → 0.23.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/commands/pull.js +10 -1
- package/src/commands/refresh.js +8 -6
- package/src/commands/status.js +11 -11
- package/src/commands/update.js +16 -8
- 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.23.0] - 2026-03-30
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
15
|
+
## [0.22.1] - 2026-03-30
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- Fix sequential `detect_status` bottleneck in `status`, `refresh`, and `update` — local modification detection now runs in parallel via `Promise.all` instead of awaiting each skill sequentially
|
|
19
|
+
|
|
10
20
|
## [0.22.0] - 2026-03-30
|
|
11
21
|
|
|
12
22
|
### Added
|
package/package.json
CHANGED
package/src/commands/pull.js
CHANGED
|
@@ -360,7 +360,16 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
360
360
|
const [del_err] = await remove_files(skill_dir, classified.remote_only_deleted.map(f => f.path))
|
|
361
361
|
if (del_err) { spinner.fail('Failed to remove deleted files'); throw del_err[0] }
|
|
362
362
|
|
|
363
|
-
//
|
|
363
|
+
// Remote-only renamed → rename local files
|
|
364
|
+
for (const entry of classified.remote_renamed) {
|
|
365
|
+
const old_full = path.join(skill_dir, entry.old_path)
|
|
366
|
+
const new_full = path.join(skill_dir, entry.new_path)
|
|
367
|
+
const [dir_err] = await ensure_dir(path.dirname(new_full))
|
|
368
|
+
if (dir_err) { spinner.fail(`Failed to create dir for ${entry.new_path}`); throw dir_err[0] }
|
|
369
|
+
await fs.promises.rename(old_full, new_full)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Local-only modified/added/renamed → keep (no action needed)
|
|
364
373
|
// Local-only deleted → keep deleted (no action needed)
|
|
365
374
|
if (full_report) {
|
|
366
375
|
for (const entry of [...classified.local_only_modified, ...classified.local_only_added]) {
|
package/src/commands/refresh.js
CHANGED
|
@@ -126,16 +126,18 @@ const run = (args) => catch_errors('Refresh failed', async () => {
|
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
// 5. Detect local modifications
|
|
129
|
+
// 5. Detect local modifications for all outdated skills in parallel
|
|
130
130
|
const base_dir = skills_dir(is_global, project_root)
|
|
131
|
-
const
|
|
132
|
-
const safe_to_update = []
|
|
133
|
-
|
|
134
|
-
for (const r of outdated) {
|
|
131
|
+
const detections = await Promise.all(outdated.map(r => {
|
|
135
132
|
const lock_entry = skills[r.skill]
|
|
136
133
|
const short_name = r.skill.split('/')[1] || r.skill
|
|
137
134
|
const dir = skill_install_dir(base_dir, short_name)
|
|
138
|
-
|
|
135
|
+
return detect_status(lock_entry, dir).then(([, det]) => ({ r, det }))
|
|
136
|
+
}))
|
|
137
|
+
|
|
138
|
+
const skipped = []
|
|
139
|
+
const safe_to_update = []
|
|
140
|
+
for (const { r, det } of detections) {
|
|
139
141
|
if (det?.local_modified) {
|
|
140
142
|
skipped.push({ skill: r.skill, reason: 'local_modifications', suggestion: `happyskills pull ${r.skill}` })
|
|
141
143
|
} else {
|
package/src/commands/status.js
CHANGED
|
@@ -69,18 +69,18 @@ const run = (args) => catch_errors('Status failed', async () => {
|
|
|
69
69
|
|
|
70
70
|
const base_dir = skills_dir(is_global, project_root)
|
|
71
71
|
|
|
72
|
-
// Detect local modifications for
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
if (!data) {
|
|
76
|
-
results.push({ skill: name, status: 'not_found', local_modified: false, remote_updated: false })
|
|
77
|
-
continue
|
|
78
|
-
}
|
|
72
|
+
// Detect local modifications for all skills in parallel
|
|
73
|
+
const detections = await Promise.all(entries.map(([name, data]) => {
|
|
74
|
+
if (!data) return { name, data, det: null }
|
|
79
75
|
const short_name = name.split('/')[1] || name
|
|
80
76
|
const dir = skill_install_dir(base_dir, short_name)
|
|
81
|
-
|
|
77
|
+
return detect_status(data, dir).then(([, det]) => ({ name, data, det }))
|
|
78
|
+
}))
|
|
79
|
+
|
|
80
|
+
const results = detections.map(({ name, data, det }) => {
|
|
81
|
+
if (!data) return { skill: name, status: 'not_found', local_modified: false, remote_updated: false }
|
|
82
82
|
const has_conflicts = (data.conflict_files || []).length > 0
|
|
83
|
-
|
|
83
|
+
return {
|
|
84
84
|
skill: name,
|
|
85
85
|
base_version: data.version || null,
|
|
86
86
|
base_commit: data.base_commit || null,
|
|
@@ -91,8 +91,8 @@ const run = (args) => catch_errors('Status failed', async () => {
|
|
|
91
91
|
remote_commit: null,
|
|
92
92
|
conflict_files: data.conflict_files || [],
|
|
93
93
|
status: has_conflicts ? 'conflicts' : 'clean'
|
|
94
|
-
}
|
|
95
|
-
}
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
96
|
|
|
97
97
|
// Check remote for updates
|
|
98
98
|
const skill_names = results.filter(r => r.status !== 'not_found').map(r => r.skill)
|
package/src/commands/update.js
CHANGED
|
@@ -72,16 +72,24 @@ const run = (args) => catch_errors('Update failed', async () => {
|
|
|
72
72
|
const base_dir = skills_dir(is_global, project_root)
|
|
73
73
|
const force = args.flags.force || false
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
// Detect local modifications for all skills in parallel
|
|
76
|
+
const modified_set = new Set()
|
|
77
|
+
if (!force) {
|
|
78
|
+
const detections = await Promise.all(to_update.map(([name, data]) => {
|
|
79
|
+
if (!data) return { name, modified: false }
|
|
78
80
|
const short_name = name.split('/')[1] || name
|
|
79
81
|
const dir = skill_install_dir(base_dir, short_name)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
return detect_status(data, dir).then(([, det]) => ({ name, modified: det?.local_modified || false }))
|
|
83
|
+
}))
|
|
84
|
+
for (const { name, modified } of detections) {
|
|
85
|
+
if (modified) modified_set.add(name)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const [name, data] of to_update) {
|
|
90
|
+
if (modified_set.has(name)) {
|
|
91
|
+
print_warn(`${name} has local modifications. Use --force to discard, or 'happyskills pull' to merge.`)
|
|
92
|
+
continue
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
const before_version = data?.version || null
|
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
|
|