happyskills 1.10.0 → 1.11.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 +16 -0
- package/package.json +1 -1
- package/src/commands/pull.js +95 -3
- package/src/commands/pull.test.js +199 -0
- package/src/engine/uninstaller.js +33 -2
- package/src/engine/uninstaller.test.js +120 -1
- package/src/merge/marker_resolve.js +68 -0
- package/src/merge/marker_resolve.test.js +69 -0
- package/src/merge/rebase.js +73 -12
- package/src/merge/rebase.test.js +102 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.11.0] - 2026-06-12
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Add `--full-report` support to `pull --rebase`. A clean rebase now attaches the same review enrichment as the 3-way merge mode — per-file inline content (`base_content`, `local_content`, `remote_content`, `merged_content`) for every changed file plus a `resolution_steps` array — to the rebase result, so an agent can run a post-merge semantic-coherence review without extra file reads. Previously `--full-report` was accepted but silently ignored on the rebase path, so the LLM-preferred mode produced no review payload.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Fix re-pull conflict resolution being a silent no-op. After a conflicted `pull` wrote conflict markers, re-running `pull <skill> --theirs` / `--ours` / `--force` returned `up_to_date` and left the markers in place, so the documented conflict-resolution recipes could not work: a conflicted pull advances the lock's `base_commit` to the remote head, after which every re-pull sees no remote changes and short-circuits before the strategy logic runs. Re-pull now resolves pending conflicts before that short-circuit — `--theirs` / `--ours` (global or per-file) keep the chosen side, strip the markers, recompute integrity, and clear the resolved files from `conflict_files`; `--force` falls through to a fast-forward that overwrites even with conflicts pending; and a bare re-pull reports the pending conflict state (`status: conflicts` with the `conflict_files` list) instead of a misleading `up_to_date`.
|
|
19
|
+
|
|
20
|
+
## [1.10.1] - 2026-06-09
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Fix `uninstall` leaving deep transitive dependencies stranded. Orphan pruning ran a single pass, so removing a skill (or kit / constellation core) pruned its direct dependencies but left behind any *grandchild* dependency whose only parent was one of those now-pruned dependencies — it accumulated as unused cruft in `.agents/skills/` and the lock file. Pruning now cascades to a fixed point, removing the full chain of newly-orphaned dependencies. The data-loss guard is preserved: a dependency that any surviving skill still declares (e.g. a shared satellite like `happyskills-design`) is never pruned, and directly-installed (`__root__`) skills are never swept up.
|
|
25
|
+
|
|
10
26
|
## [1.10.0] - 2026-06-06
|
|
11
27
|
|
|
12
28
|
### Added
|
package/package.json
CHANGED
package/src/commands/pull.js
CHANGED
|
@@ -7,6 +7,7 @@ const { detect_status } = require('../merge/detector')
|
|
|
7
7
|
const { classify_changes } = require('../merge/comparator')
|
|
8
8
|
const { build_report, enrich_file_content, build_resolution_steps } = require('../merge/report')
|
|
9
9
|
const { three_way_merge } = require('../merge/text_merge')
|
|
10
|
+
const { resolve_markers } = require('../merge/marker_resolve')
|
|
10
11
|
const { merge_skill_json } = require('../merge/json_merge')
|
|
11
12
|
const { merge_changelog } = require('../merge/changelog_merge')
|
|
12
13
|
const { hash_blob } = require('../utils/git_hash')
|
|
@@ -117,7 +118,7 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
117
118
|
// first capture/fast-forward/reapply flow with structured rejection envelopes.
|
|
118
119
|
if (args.flags.rebase) {
|
|
119
120
|
const { rebase_pull } = require('../merge/rebase')
|
|
120
|
-
const [err, result] = await rebase_pull(skill_name, { project_root: find_project_root(), is_global })
|
|
121
|
+
const [err, result] = await rebase_pull(skill_name, { project_root: find_project_root(), is_global, full_report })
|
|
121
122
|
if (err) throw err
|
|
122
123
|
if (args.flags.json) {
|
|
123
124
|
print_json({
|
|
@@ -194,6 +195,35 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
194
195
|
const base_dir = skills_dir(is_global, project_root)
|
|
195
196
|
const skill_dir = skill_install_dir(base_dir, repo)
|
|
196
197
|
|
|
198
|
+
// Resolve pending conflict markers from a prior pull — local, no registry.
|
|
199
|
+
// A conflicted pull advanced base_commit to the remote head, so a re-pull
|
|
200
|
+
// sees no remote changes and would short-circuit to up_to_date, stranding
|
|
201
|
+
// the markers in place. When the operator supplies a side (--theirs/--ours,
|
|
202
|
+
// global or per-file), resolve those files here, before the remote
|
|
203
|
+
// comparison runs. --force is excluded — it means "re-clone the head", which
|
|
204
|
+
// the fast-forward block below handles.
|
|
205
|
+
const pending_conflicts = Array.isArray(lock_entry.conflict_files) ? lock_entry.conflict_files : []
|
|
206
|
+
const has_pending_resolution = pending_conflicts.length > 0 && strategy !== 'force' &&
|
|
207
|
+
pending_conflicts.some(f => { const s = file_strategy(f); return s === 'theirs' || s === 'ours' })
|
|
208
|
+
if (has_pending_resolution) {
|
|
209
|
+
const [res_err, result] = await resolve_pending_conflicts({
|
|
210
|
+
skill_dir, lock_entry, lock_data, skill_name, file_strategy,
|
|
211
|
+
is_global, project_root, conflict_files: pending_conflicts
|
|
212
|
+
})
|
|
213
|
+
if (res_err) throw res_err[0]
|
|
214
|
+
if (args.flags.json) {
|
|
215
|
+
print_json({ data: result })
|
|
216
|
+
} else {
|
|
217
|
+
print_success(`Resolved ${result.resolved.length} conflicted file(s) in ${skill_name}`)
|
|
218
|
+
if (result.conflict_files.length > 0) {
|
|
219
|
+
print_warn(`${result.conflict_files.length} file(s) still have unresolved conflicts:`)
|
|
220
|
+
for (const f of result.conflict_files) console.error(` - ${f}`)
|
|
221
|
+
print_hint(`Supply a side for them with ${code('--theirs')}/${code('--ours')}, or resolve the markers by hand.`)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
197
227
|
const spinner = create_spinner(`Pulling ${skill_name}...`)
|
|
198
228
|
|
|
199
229
|
// 2. Detect local modifications
|
|
@@ -208,8 +238,24 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
208
238
|
const no_remote_changes = cmp_data.added.length === 0 && cmp_data.removed.length === 0 && cmp_data.modified.length === 0
|
|
209
239
|
|
|
210
240
|
// 4. Already up to date?
|
|
211
|
-
|
|
241
|
+
// When --force is set with conflicts still pending, do NOT short-circuit —
|
|
242
|
+
// fall through to the fast-forward block below, which re-clones the head and
|
|
243
|
+
// overwrites (the true meaning of --force).
|
|
244
|
+
if (no_remote_changes && !(strategy === 'force' && pending_conflicts.length > 0)) {
|
|
212
245
|
spinner.stop()
|
|
246
|
+
if (pending_conflicts.length > 0) {
|
|
247
|
+
// Markers from a prior pull are still pending and no per-file
|
|
248
|
+
// resolution strategy was supplied — surface that instead of a
|
|
249
|
+
// misleading up_to_date so the operator knows resolution is pending.
|
|
250
|
+
if (args.flags.json) {
|
|
251
|
+
print_json({ data: { status: 'conflicts', skill: skill_name, conflict_files: pending_conflicts } })
|
|
252
|
+
} else {
|
|
253
|
+
print_warn(`${skill_name} has unresolved conflict markers in ${pending_conflicts.length} file(s):`)
|
|
254
|
+
for (const f of pending_conflicts) console.error(` - ${f}`)
|
|
255
|
+
print_hint(`Re-pull with ${code('--theirs')}/${code('--ours')} to pick a side, or resolve the markers by hand.`)
|
|
256
|
+
}
|
|
257
|
+
return
|
|
258
|
+
}
|
|
213
259
|
if (args.flags.json) {
|
|
214
260
|
print_json({ data: { status: 'up_to_date', skill: skill_name } })
|
|
215
261
|
} else {
|
|
@@ -611,6 +657,52 @@ const reconcile_dependencies = (skill_dir, skill_name, lock_data, is_global, pro
|
|
|
611
657
|
}
|
|
612
658
|
})
|
|
613
659
|
|
|
660
|
+
// ─── Pending-conflict resolution (marker-based re-pull) ─────────────────────────
|
|
661
|
+
|
|
662
|
+
// Resolve pending conflict markers locally by picking a side per file. Pure
|
|
663
|
+
// disk + lock work — no registry interaction, because the conflicted pull that
|
|
664
|
+
// wrote the markers already advanced base_commit to the remote head. Mirrors
|
|
665
|
+
// the 3-way path's lock-update: recompute integrity, then clear resolved files
|
|
666
|
+
// from conflict_files (deleting the key when none remain → status returns to
|
|
667
|
+
// clean, matching post-merge behavior). Files listed in conflict_files but
|
|
668
|
+
// missing on disk, or without a theirs/ours strategy, stay in conflict_files.
|
|
669
|
+
const resolve_pending_conflicts = ({ skill_dir, lock_entry, lock_data, skill_name, file_strategy, is_global, project_root, conflict_files }) =>
|
|
670
|
+
catch_errors('Failed to resolve pending conflicts', async () => {
|
|
671
|
+
const resolved = []
|
|
672
|
+
const unresolved = []
|
|
673
|
+
for (const rel of conflict_files) {
|
|
674
|
+
const strat = file_strategy(rel)
|
|
675
|
+
if (strat !== 'theirs' && strat !== 'ours') { unresolved.push(rel); continue }
|
|
676
|
+
const full = path.join(skill_dir, rel)
|
|
677
|
+
let text
|
|
678
|
+
try {
|
|
679
|
+
text = await fs.promises.readFile(full, 'utf-8')
|
|
680
|
+
} catch {
|
|
681
|
+
// Listed in conflict_files but missing on disk — cannot resolve here.
|
|
682
|
+
unresolved.push(rel)
|
|
683
|
+
continue
|
|
684
|
+
}
|
|
685
|
+
const side = strat === 'theirs' ? 'remote' : 'local'
|
|
686
|
+
await fs.promises.writeFile(full, resolve_markers(text, side))
|
|
687
|
+
resolved.push(rel)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const [, new_integrity] = await hash_directory(skill_dir)
|
|
691
|
+
const updated_entry = {
|
|
692
|
+
...lock_entry,
|
|
693
|
+
integrity: new_integrity || lock_entry.integrity,
|
|
694
|
+
base_integrity: new_integrity || lock_entry.base_integrity
|
|
695
|
+
}
|
|
696
|
+
if (unresolved.length > 0) updated_entry.conflict_files = unresolved
|
|
697
|
+
else delete updated_entry.conflict_files
|
|
698
|
+
const merged_skills = update_lock_skills(lock_data, { [skill_name]: updated_entry })
|
|
699
|
+
const [wl_err] = await write_lock(lock_root(is_global, project_root), merged_skills)
|
|
700
|
+
if (wl_err) throw wl_err[0]
|
|
701
|
+
|
|
702
|
+
const status = unresolved.length > 0 ? 'conflicts' : 'merged'
|
|
703
|
+
return { status, skill: skill_name, resolved, conflict_files: unresolved }
|
|
704
|
+
})
|
|
705
|
+
|
|
614
706
|
const schema = {
|
|
615
707
|
name: 'pull',
|
|
616
708
|
audience: 'consumer',
|
|
@@ -657,4 +749,4 @@ const schema = {
|
|
|
657
749
|
]
|
|
658
750
|
}
|
|
659
751
|
|
|
660
|
-
module.exports = { run, schema }
|
|
752
|
+
module.exports = { run, schema, resolve_pending_conflicts }
|
|
@@ -0,0 +1,199 @@
|
|
|
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 repos_api = require('../api/repos')
|
|
9
|
+
const pull = require('./pull')
|
|
10
|
+
|
|
11
|
+
// ─── Harness ────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const MARKERED_SKILL_MD = [
|
|
14
|
+
'# Deploy AWS',
|
|
15
|
+
'',
|
|
16
|
+
'<<<<<<< LOCAL',
|
|
17
|
+
'local instructions',
|
|
18
|
+
'=======',
|
|
19
|
+
'remote instructions',
|
|
20
|
+
'>>>>>>> REMOTE',
|
|
21
|
+
'',
|
|
22
|
+
'trailing section',
|
|
23
|
+
''
|
|
24
|
+
].join('\n')
|
|
25
|
+
|
|
26
|
+
// Stub the registry surface used by `run`. compare reports no remote changes
|
|
27
|
+
// (base_commit == head), which is the post-conflict state: a prior conflicted
|
|
28
|
+
// pull already advanced base_commit to head. clone returns base files so the
|
|
29
|
+
// pre-fix detect_status fallback (find_modified_files) stays hermetic.
|
|
30
|
+
const with_stubbed_registry = async (head_commit, fn) => {
|
|
31
|
+
const orig = { compare: repos_api.compare, clone: repos_api.clone, get_blob: repos_api.get_blob }
|
|
32
|
+
repos_api.compare = async () => [null, { added: [], removed: [], modified: [], head_commit, head_version: '1.0.1' }]
|
|
33
|
+
repos_api.clone = async () => [null, { ref: 'refs/tags/v1.0.0', commit: head_commit, files: [
|
|
34
|
+
{ path: 'SKILL.md', content: Buffer.from('# Deploy AWS\n').toString('base64'), sha: 'sha-base-skillmd' },
|
|
35
|
+
{ path: 'skill.json', content: Buffer.from('{"name":"deploy-aws","version":"1.0.0"}').toString('base64'), sha: 'sha-base-json' }
|
|
36
|
+
] }]
|
|
37
|
+
repos_api.get_blob = async () => [null, { content: Buffer.from('').toString('base64') }]
|
|
38
|
+
try { return await fn() } finally { Object.assign(repos_api, orig) }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const capture_stdout = async (fn) => {
|
|
42
|
+
const orig = console.log
|
|
43
|
+
const lines = []
|
|
44
|
+
console.log = (...a) => lines.push(a.map(String).join(' '))
|
|
45
|
+
try { await fn() } finally { console.log = orig }
|
|
46
|
+
return lines.join('\n')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const setup_conflicted_skill = (head_commit, opts = {}) => {
|
|
50
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-pull-test-'))
|
|
51
|
+
const skill_dir = path.join(root, '.agents', 'skills', 'deploy-aws')
|
|
52
|
+
fs.mkdirSync(skill_dir, { recursive: true })
|
|
53
|
+
fs.writeFileSync(path.join(skill_dir, 'SKILL.md'), opts.skill_md || MARKERED_SKILL_MD)
|
|
54
|
+
fs.writeFileSync(path.join(skill_dir, 'skill.json'), JSON.stringify({ name: 'deploy-aws', version: '1.0.0' }, null, '\t'))
|
|
55
|
+
const lock = {
|
|
56
|
+
lockVersion: 2,
|
|
57
|
+
generatedAt: '2026-06-10T00:00:00.000Z',
|
|
58
|
+
skills: {
|
|
59
|
+
'acme/deploy-aws': {
|
|
60
|
+
version: '1.0.0',
|
|
61
|
+
ref: 'refs/tags/v1.0.0',
|
|
62
|
+
commit: head_commit,
|
|
63
|
+
integrity: 'sha256-stale',
|
|
64
|
+
base_commit: head_commit,
|
|
65
|
+
base_integrity: 'sha256-stale',
|
|
66
|
+
conflict_files: opts.conflict_files || ['SKILL.md'],
|
|
67
|
+
requested_by: ['__root__'],
|
|
68
|
+
dependencies: {}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
fs.writeFileSync(path.join(root, 'skills-lock.json'), JSON.stringify(lock, null, 2))
|
|
73
|
+
return { root, skill_dir }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const read_lock_entry = (root) =>
|
|
77
|
+
JSON.parse(fs.readFileSync(path.join(root, 'skills-lock.json'), 'utf-8')).skills['acme/deploy-aws']
|
|
78
|
+
|
|
79
|
+
const run_pull = (root, head, flags) => with_stubbed_registry(head, () => capture_stdout(async () => {
|
|
80
|
+
const orig_cwd = process.cwd()
|
|
81
|
+
process.chdir(root)
|
|
82
|
+
try { await pull.run({ _: ['acme/deploy-aws'], flags }) } finally { process.chdir(orig_cwd) }
|
|
83
|
+
}))
|
|
84
|
+
|
|
85
|
+
// ─── §4.1 repro: dead re-pull paths ──────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
describe('pull §4.1 — re-pull resolves pending conflict markers', () => {
|
|
88
|
+
it('--theirs resolves markers to the remote side, clears conflict_files, recomputes integrity', async () => {
|
|
89
|
+
const head = 'head_commit_abc'
|
|
90
|
+
const { root, skill_dir } = setup_conflicted_skill(head)
|
|
91
|
+
try {
|
|
92
|
+
const out = await run_pull(root, head, { theirs: true, json: true })
|
|
93
|
+
const env = JSON.parse(out)
|
|
94
|
+
assert.strictEqual(env.data.status, 'merged', 'status should be merged after full resolution')
|
|
95
|
+
|
|
96
|
+
const skill_md = fs.readFileSync(path.join(skill_dir, 'SKILL.md'), 'utf-8')
|
|
97
|
+
assert.ok(!skill_md.includes('<<<<<<<'), 'LOCAL marker must be gone')
|
|
98
|
+
assert.ok(!skill_md.includes('>>>>>>>'), 'REMOTE marker must be gone')
|
|
99
|
+
assert.ok(!skill_md.includes('======='), 'separator must be gone')
|
|
100
|
+
assert.ok(skill_md.includes('remote instructions'), 'remote side kept')
|
|
101
|
+
assert.ok(!skill_md.includes('local instructions'), 'local side dropped')
|
|
102
|
+
|
|
103
|
+
const entry = read_lock_entry(root)
|
|
104
|
+
assert.ok(!entry.conflict_files, 'conflict_files cleared from lock')
|
|
105
|
+
assert.notStrictEqual(entry.integrity, 'sha256-stale', 'integrity recalculated')
|
|
106
|
+
assert.strictEqual(entry.integrity, entry.base_integrity, 'integrity and base_integrity match (clean)')
|
|
107
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('--ours resolves markers to the local side', async () => {
|
|
111
|
+
const head = 'head_commit_def'
|
|
112
|
+
const { root, skill_dir } = setup_conflicted_skill(head)
|
|
113
|
+
try {
|
|
114
|
+
const out = await run_pull(root, head, { ours: true, json: true })
|
|
115
|
+
const env = JSON.parse(out)
|
|
116
|
+
assert.strictEqual(env.data.status, 'merged')
|
|
117
|
+
const skill_md = fs.readFileSync(path.join(skill_dir, 'SKILL.md'), 'utf-8')
|
|
118
|
+
assert.ok(skill_md.includes('local instructions'), 'local side kept')
|
|
119
|
+
assert.ok(!skill_md.includes('remote instructions'), 'remote side dropped')
|
|
120
|
+
assert.ok(!skill_md.includes('<<<<<<<'))
|
|
121
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('bare re-pull surfaces the pending conflict state instead of up_to_date', async () => {
|
|
125
|
+
const head = 'head_commit_ghi'
|
|
126
|
+
const { root, skill_dir } = setup_conflicted_skill(head)
|
|
127
|
+
try {
|
|
128
|
+
const out = await run_pull(root, head, { json: true })
|
|
129
|
+
const env = JSON.parse(out)
|
|
130
|
+
assert.strictEqual(env.data.status, 'conflicts', 'bare re-pull must not say up_to_date when conflicts pending')
|
|
131
|
+
assert.deepEqual(env.data.conflict_files, ['SKILL.md'])
|
|
132
|
+
// Markers untouched — nothing was resolved.
|
|
133
|
+
const skill_md = fs.readFileSync(path.join(skill_dir, 'SKILL.md'), 'utf-8')
|
|
134
|
+
assert.ok(skill_md.includes('<<<<<<<'), 'markers remain on a bare re-pull')
|
|
135
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// ─── §4.1 wiring: resolve_pending_conflicts ──────────────────────────────────
|
|
140
|
+
|
|
141
|
+
describe('pull.resolve_pending_conflicts', () => {
|
|
142
|
+
const make_dir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-resolve-test-'))
|
|
143
|
+
const block = (l, r) => `<<<<<<< LOCAL\n${l}\n=======\n${r}\n>>>>>>> REMOTE\n`
|
|
144
|
+
|
|
145
|
+
it('applies a per-file strategy mix (theirs for one, ours for another)', async () => {
|
|
146
|
+
const root = make_dir()
|
|
147
|
+
try {
|
|
148
|
+
const skill_dir = path.join(root, '.agents', 'skills', 'deploy-aws')
|
|
149
|
+
fs.mkdirSync(path.join(skill_dir, 'references'), { recursive: true })
|
|
150
|
+
fs.writeFileSync(path.join(skill_dir, 'SKILL.md'), block('skill-local', 'skill-remote'))
|
|
151
|
+
fs.writeFileSync(path.join(skill_dir, 'references', 'foo.md'), block('foo-local', 'foo-remote'))
|
|
152
|
+
fs.writeFileSync(path.join(skill_dir, 'skill.json'), '{"name":"deploy-aws","version":"1.0.0"}')
|
|
153
|
+
|
|
154
|
+
const lock_entry = { version: '1.0.0', base_commit: 'h', integrity: 'sha256-stale', base_integrity: 'sha256-stale', conflict_files: ['SKILL.md', 'references/foo.md'] }
|
|
155
|
+
const lock_data = { lockVersion: 2, skills: { 'acme/deploy-aws': lock_entry } }
|
|
156
|
+
const file_strategy = (f) => f === 'SKILL.md' ? 'theirs' : f === 'references/foo.md' ? 'ours' : null
|
|
157
|
+
|
|
158
|
+
const [err, result] = await pull.resolve_pending_conflicts({
|
|
159
|
+
skill_dir, lock_entry, lock_data, skill_name: 'acme/deploy-aws',
|
|
160
|
+
file_strategy, is_global: false, project_root: root, conflict_files: ['SKILL.md', 'references/foo.md']
|
|
161
|
+
})
|
|
162
|
+
assert.ok(!err, 'no error')
|
|
163
|
+
assert.strictEqual(result.status, 'merged')
|
|
164
|
+
assert.deepEqual(result.resolved.sort(), ['SKILL.md', 'references/foo.md'])
|
|
165
|
+
|
|
166
|
+
assert.strictEqual(fs.readFileSync(path.join(skill_dir, 'SKILL.md'), 'utf-8'), 'skill-remote\n', 'theirs → remote')
|
|
167
|
+
assert.strictEqual(fs.readFileSync(path.join(skill_dir, 'references', 'foo.md'), 'utf-8'), 'foo-local\n', 'ours → local')
|
|
168
|
+
|
|
169
|
+
const entry = JSON.parse(fs.readFileSync(path.join(root, 'skills-lock.json'), 'utf-8')).skills['acme/deploy-aws']
|
|
170
|
+
assert.ok(!entry.conflict_files, 'conflict_files cleared')
|
|
171
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('leaves a file listed in conflict_files but missing on disk unresolved', async () => {
|
|
175
|
+
const root = make_dir()
|
|
176
|
+
try {
|
|
177
|
+
const skill_dir = path.join(root, '.agents', 'skills', 'deploy-aws')
|
|
178
|
+
fs.mkdirSync(skill_dir, { recursive: true })
|
|
179
|
+
fs.writeFileSync(path.join(skill_dir, 'SKILL.md'), block('a', 'b'))
|
|
180
|
+
fs.writeFileSync(path.join(skill_dir, 'skill.json'), '{"name":"deploy-aws","version":"1.0.0"}')
|
|
181
|
+
|
|
182
|
+
const lock_entry = { version: '1.0.0', base_commit: 'h', integrity: 'sha256-stale', base_integrity: 'sha256-stale', conflict_files: ['SKILL.md', 'references/gone.md'] }
|
|
183
|
+
const lock_data = { lockVersion: 2, skills: { 'acme/deploy-aws': lock_entry } }
|
|
184
|
+
const file_strategy = () => 'theirs'
|
|
185
|
+
|
|
186
|
+
const [err, result] = await pull.resolve_pending_conflicts({
|
|
187
|
+
skill_dir, lock_entry, lock_data, skill_name: 'acme/deploy-aws',
|
|
188
|
+
file_strategy, is_global: false, project_root: root, conflict_files: ['SKILL.md', 'references/gone.md']
|
|
189
|
+
})
|
|
190
|
+
assert.ok(!err)
|
|
191
|
+
assert.strictEqual(result.status, 'conflicts', 'unresolved file keeps status conflicts')
|
|
192
|
+
assert.deepEqual(result.resolved, ['SKILL.md'])
|
|
193
|
+
assert.deepEqual(result.conflict_files, ['references/gone.md'])
|
|
194
|
+
|
|
195
|
+
const entry = JSON.parse(fs.readFileSync(path.join(root, 'skills-lock.json'), 'utf-8')).skills['acme/deploy-aws']
|
|
196
|
+
assert.deepEqual(entry.conflict_files, ['references/gone.md'], 'missing file stays in conflict_files')
|
|
197
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
198
|
+
})
|
|
199
|
+
})
|
|
@@ -36,6 +36,37 @@ const find_orphans = (skills, removed_skill) => {
|
|
|
36
36
|
return orphans
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Cascading orphan detection. `find_orphans` is single-pass by design (its unit
|
|
40
|
+
// contract): it only reports the DIRECT orphans of `removed_skill`. But pruning a
|
|
41
|
+
// direct orphan A can in turn strand A's own private sub-dependency B — a grandchild
|
|
42
|
+
// that nothing else needs once A is gone. A single pass leaves B behind because A is
|
|
43
|
+
// still in the map (so A both counts as a live requester of B and, via its
|
|
44
|
+
// `dependencies`, as a "surviving declarer" of B).
|
|
45
|
+
//
|
|
46
|
+
// This wrapper closes that gap WITHOUT weakening the data-loss guard. It runs
|
|
47
|
+
// `find_orphans` to a fixed point over a shrinking working copy: each round, the
|
|
48
|
+
// orphans found are physically removed from the map, so the next round sees them as
|
|
49
|
+
// gone — both as requesters (`skills[r]` is now false) and as declarers
|
|
50
|
+
// (`declared_by_survivor` no longer finds them). A skill that ANY surviving skill
|
|
51
|
+
// still declares is never pruned, exactly as before — only skills whose entire
|
|
52
|
+
// requirement chain has been removed cascade out. Terminates because every round
|
|
53
|
+
// deletes at least one entry from a finite map.
|
|
54
|
+
const find_orphans_cascading = (skills, removed_skill) => {
|
|
55
|
+
const working = { ...skills }
|
|
56
|
+
delete working[removed_skill]
|
|
57
|
+
|
|
58
|
+
const all_orphans = []
|
|
59
|
+
let round = find_orphans(working, removed_skill)
|
|
60
|
+
while (round.length > 0) {
|
|
61
|
+
for (const name of round) {
|
|
62
|
+
all_orphans.push(name)
|
|
63
|
+
delete working[name]
|
|
64
|
+
}
|
|
65
|
+
round = find_orphans(working, removed_skill)
|
|
66
|
+
}
|
|
67
|
+
return all_orphans
|
|
68
|
+
}
|
|
69
|
+
|
|
39
70
|
const uninstall = (skill, options = {}) => catch_errors('Uninstall failed', async () => {
|
|
40
71
|
const { global: is_global = false, project_root, agents: agents_flag } = options
|
|
41
72
|
|
|
@@ -64,7 +95,7 @@ const uninstall = (skill, options = {}) => catch_errors('Uninstall failed', asyn
|
|
|
64
95
|
}
|
|
65
96
|
}
|
|
66
97
|
|
|
67
|
-
const orphans =
|
|
98
|
+
const orphans = find_orphans_cascading(all_skills, skill)
|
|
68
99
|
const to_remove = [skill, ...orphans]
|
|
69
100
|
|
|
70
101
|
for (const name of to_remove) {
|
|
@@ -97,4 +128,4 @@ const uninstall = (skill, options = {}) => catch_errors('Uninstall failed', asyn
|
|
|
97
128
|
return { removed: to_remove, orphans_pruned: orphans }
|
|
98
129
|
})
|
|
99
130
|
|
|
100
|
-
module.exports = { uninstall, find_orphans }
|
|
131
|
+
module.exports = { uninstall, find_orphans, find_orphans_cascading }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { describe, it } = require('node:test')
|
|
2
2
|
const assert = require('node:assert')
|
|
3
|
-
const { find_orphans } = require('./uninstaller')
|
|
3
|
+
const { find_orphans, find_orphans_cascading } = require('./uninstaller')
|
|
4
4
|
|
|
5
5
|
describe('find_orphans', () => {
|
|
6
6
|
it('returns empty when all skills have requesters in the skills map', () => {
|
|
@@ -134,3 +134,122 @@ describe('find_orphans', () => {
|
|
|
134
134
|
assert.ok(result.includes('happyskillsai/happyskills-design'))
|
|
135
135
|
})
|
|
136
136
|
})
|
|
137
|
+
|
|
138
|
+
describe('find_orphans_cascading', () => {
|
|
139
|
+
it('cascades: prunes a grandchild stranded once its only parent is orphaned', () => {
|
|
140
|
+
// Kit K -> A -> B. B is A's private sub-dependency (not listed directly by the
|
|
141
|
+
// kit). Uninstalling K must prune A (direct orphan) AND B (cascaded orphan).
|
|
142
|
+
// This is the exact case single-pass find_orphans leaves stranded.
|
|
143
|
+
const skills = {
|
|
144
|
+
'acme/_kit-x': { requested_by: ['__root__'], dependencies: { 'acme/a': '^1.0.0' } },
|
|
145
|
+
'acme/a': { requested_by: ['acme/_kit-x'], dependencies: { 'acme/b': '^1.0.0' } },
|
|
146
|
+
'acme/b': { requested_by: ['acme/_kit-x'], dependencies: {} }
|
|
147
|
+
}
|
|
148
|
+
const result = find_orphans_cascading(skills, 'acme/_kit-x')
|
|
149
|
+
assert.ok(result.includes('acme/a'), 'direct orphan A should be pruned')
|
|
150
|
+
assert.ok(result.includes('acme/b'), 'cascaded orphan B should be pruned')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('still protects a shared dependency a surviving skill declares (no over-pruning under cascade)', () => {
|
|
154
|
+
// The data-loss guard must survive the cascade: design is declared by the
|
|
155
|
+
// surviving happyskills, so uninstalling create-release-skill must NOT prune it
|
|
156
|
+
// even though we now iterate to a fixed point.
|
|
157
|
+
const skills = {
|
|
158
|
+
'happyskillsai/happyskills': {
|
|
159
|
+
requested_by: ['__root__'],
|
|
160
|
+
dependencies: { 'happyskillsai/happyskills-design': '^0.1.0' }
|
|
161
|
+
},
|
|
162
|
+
'nicolasdao/create-release-skill': {
|
|
163
|
+
requested_by: ['__root__'],
|
|
164
|
+
dependencies: { 'happyskillsai/happyskills-design': '^0.9.0' }
|
|
165
|
+
},
|
|
166
|
+
'happyskillsai/happyskills-design': {
|
|
167
|
+
requested_by: ['nicolasdao/create-release-skill']
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const result = find_orphans_cascading(skills, 'nicolasdao/create-release-skill')
|
|
171
|
+
assert.ok(!result.includes('happyskillsai/happyskills-design'))
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('never prunes a user-installed (__root__) skill, even mid-cascade', () => {
|
|
175
|
+
// K -> A, but A is ALSO directly installed by the user. Removing K orphans
|
|
176
|
+
// nothing the user still wants: A is __root__-anchored and stays.
|
|
177
|
+
const skills = {
|
|
178
|
+
'acme/_kit-x': { requested_by: ['__root__'], dependencies: { 'acme/a': '^1.0.0' } },
|
|
179
|
+
'acme/a': { requested_by: ['__root__', 'acme/_kit-x'], dependencies: {} }
|
|
180
|
+
}
|
|
181
|
+
const result = find_orphans_cascading(skills, 'acme/_kit-x')
|
|
182
|
+
assert.deepStrictEqual(result, [])
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('halts the cascade at a still-shared mid-chain dependency', () => {
|
|
186
|
+
// K -> A -> B, but B is ALSO required by an unrelated surviving skill S.
|
|
187
|
+
// Removing K prunes A, but B must stay because S still needs it.
|
|
188
|
+
const skills = {
|
|
189
|
+
'acme/_kit-x': { requested_by: ['__root__'], dependencies: { 'acme/a': '^1.0.0' } },
|
|
190
|
+
'acme/a': { requested_by: ['acme/_kit-x'], dependencies: { 'acme/b': '^1.0.0' } },
|
|
191
|
+
'acme/s': { requested_by: ['__root__'], dependencies: { 'acme/b': '^1.0.0' } },
|
|
192
|
+
'acme/b': { requested_by: ['acme/_kit-x', 'acme/s'], dependencies: {} }
|
|
193
|
+
}
|
|
194
|
+
const result = find_orphans_cascading(skills, 'acme/_kit-x')
|
|
195
|
+
assert.ok(result.includes('acme/a'), 'A is orphaned')
|
|
196
|
+
assert.ok(!result.includes('acme/b'), 'B is still needed by surviving acme/s')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('constellation: uninstalling the core prunes bundled satellites, keeps shared + opt-in ones', () => {
|
|
200
|
+
// Models the real happyskills constellation: the core declares 5 bundled
|
|
201
|
+
// satellites as dependencies. happyskills-design is ALSO declared by a surviving
|
|
202
|
+
// skill (create-release-skill). collab + stats are OPT-IN — installed directly
|
|
203
|
+
// (__root__), never dependencies of the core.
|
|
204
|
+
const skills = {
|
|
205
|
+
'happyskillsai/happyskills': {
|
|
206
|
+
requested_by: ['__root__'],
|
|
207
|
+
dependencies: {
|
|
208
|
+
'happyskillsai/happyskills-design': '^0.1.0',
|
|
209
|
+
'happyskillsai/happyskills-publish': '^0.1.0',
|
|
210
|
+
'happyskillsai/happyskills-sync': '^0.1.0',
|
|
211
|
+
'happyskillsai/happyskills-search': '^0.1.0',
|
|
212
|
+
'happyskillsai/happyskills-help': '^0.1.0'
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
'nicolasdao/create-release-skill': {
|
|
216
|
+
requested_by: ['__root__'],
|
|
217
|
+
dependencies: { 'happyskillsai/happyskills-design': '^0.9.0' }
|
|
218
|
+
},
|
|
219
|
+
'happyskillsai/happyskills-design': { requested_by: ['happyskillsai/happyskills', 'nicolasdao/create-release-skill'], dependencies: {} },
|
|
220
|
+
'happyskillsai/happyskills-publish': { requested_by: ['happyskillsai/happyskills'], dependencies: {} },
|
|
221
|
+
'happyskillsai/happyskills-sync': { requested_by: ['happyskillsai/happyskills'], dependencies: {} },
|
|
222
|
+
'happyskillsai/happyskills-search': { requested_by: ['happyskillsai/happyskills'], dependencies: {} },
|
|
223
|
+
'happyskillsai/happyskills-help': { requested_by: ['happyskillsai/happyskills'], dependencies: {} },
|
|
224
|
+
'happyskillsai/happyskills-collab': { requested_by: ['__root__'], dependencies: {} },
|
|
225
|
+
'happyskillsai/happyskills-stats': { requested_by: ['__root__'], dependencies: {} }
|
|
226
|
+
}
|
|
227
|
+
const result = find_orphans_cascading(skills, 'happyskillsai/happyskills').sort()
|
|
228
|
+
assert.deepStrictEqual(result, [
|
|
229
|
+
'happyskillsai/happyskills-help',
|
|
230
|
+
'happyskillsai/happyskills-publish',
|
|
231
|
+
'happyskillsai/happyskills-search',
|
|
232
|
+
'happyskillsai/happyskills-sync'
|
|
233
|
+
])
|
|
234
|
+
// design is shared (create-release-skill still declares it) → kept
|
|
235
|
+
assert.ok(!result.includes('happyskillsai/happyskills-design'))
|
|
236
|
+
// collab + stats are opt-in (__root__) → never swept up by a core uninstall
|
|
237
|
+
assert.ok(!result.includes('happyskillsai/happyskills-collab'))
|
|
238
|
+
assert.ok(!result.includes('happyskillsai/happyskills-stats'))
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('matches find_orphans for a flat kit (no nested sub-dependencies)', () => {
|
|
242
|
+
// The common curated-kit case: K directly lists A, B, C and none depend on each
|
|
243
|
+
// other. Single-pass and cascading must agree — cascade changes nothing here.
|
|
244
|
+
const skills = {
|
|
245
|
+
'acme/_kit-x': { requested_by: ['__root__'], dependencies: { 'acme/a': '^1', 'acme/b': '^1', 'acme/c': '^1' } },
|
|
246
|
+
'acme/a': { requested_by: ['acme/_kit-x'], dependencies: {} },
|
|
247
|
+
'acme/b': { requested_by: ['acme/_kit-x'], dependencies: {} },
|
|
248
|
+
'acme/c': { requested_by: ['acme/_kit-x'], dependencies: {} }
|
|
249
|
+
}
|
|
250
|
+
const single = find_orphans(skills, 'acme/_kit-x').sort()
|
|
251
|
+
const cascaded = find_orphans_cascading(skills, 'acme/_kit-x').sort()
|
|
252
|
+
assert.deepStrictEqual(cascaded, ['acme/a', 'acme/b', 'acme/c'])
|
|
253
|
+
assert.deepStrictEqual(cascaded, single)
|
|
254
|
+
})
|
|
255
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves conflict markers in a file by keeping one side and dropping the
|
|
3
|
+
* three marker lines. This is the post-conflict semantic of
|
|
4
|
+
* `git checkout --ours/--theirs`: the markers were already written into the
|
|
5
|
+
* file by a previous 3-way `pull`, and the operator is now picking a side.
|
|
6
|
+
*
|
|
7
|
+
* Marker block (canonical labels from text_merge.js LABELS):
|
|
8
|
+
*
|
|
9
|
+
* <<<<<<< LOCAL
|
|
10
|
+
* ...local lines...
|
|
11
|
+
* =======
|
|
12
|
+
* ...remote lines...
|
|
13
|
+
* >>>>>>> REMOTE
|
|
14
|
+
*
|
|
15
|
+
* `side` is 'local' (keep the LOCAL section — the `--ours` choice) or
|
|
16
|
+
* 'remote' (keep the REMOTE section — the `--theirs` choice). Non-marker
|
|
17
|
+
* content passes through untouched, and multiple blocks in one file are all
|
|
18
|
+
* resolved. Parsing is stateful, so a bare `=======` outside a block (e.g. a
|
|
19
|
+
* Markdown setext heading underline) is treated as ordinary content.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// Canonical 7-char markers. Matched structurally (prefix + label) so the exact
|
|
23
|
+
// label text is not load-bearing, while a standalone separator line must be
|
|
24
|
+
// exactly seven `=`.
|
|
25
|
+
const START_MARKER = /^<{7}(\s|$)/ // <<<<<<< LOCAL
|
|
26
|
+
const SEP_MARKER = /^={7}$/ // =======
|
|
27
|
+
const END_MARKER = /^>{7}(\s|$)/ // >>>>>>> REMOTE
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} text - File content, possibly containing conflict markers.
|
|
31
|
+
* @param {'local'|'remote'} side - Which side to keep.
|
|
32
|
+
* @returns {string} The resolved text with the chosen side and no markers.
|
|
33
|
+
*/
|
|
34
|
+
const resolve_markers = (text, side) => {
|
|
35
|
+
if (side !== 'local' && side !== 'remote')
|
|
36
|
+
throw new Error(`Invalid side "${side}" — expected 'local' or 'remote'`)
|
|
37
|
+
|
|
38
|
+
const lines = String(text == null ? '' : text).split('\n')
|
|
39
|
+
const out = []
|
|
40
|
+
let state = 'outside' // 'outside' | 'local' | 'remote'
|
|
41
|
+
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
if (state === 'outside') {
|
|
44
|
+
if (START_MARKER.test(line)) { state = 'local'; continue }
|
|
45
|
+
out.push(line)
|
|
46
|
+
} else if (state === 'local') {
|
|
47
|
+
if (SEP_MARKER.test(line)) { state = 'remote'; continue }
|
|
48
|
+
// Malformed block end without a separator — close it and drop the marker.
|
|
49
|
+
if (END_MARKER.test(line)) { state = 'outside'; continue }
|
|
50
|
+
if (side === 'local') out.push(line)
|
|
51
|
+
} else { // state === 'remote'
|
|
52
|
+
if (END_MARKER.test(line)) { state = 'outside'; continue }
|
|
53
|
+
if (side === 'remote') out.push(line)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return out.join('\n')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether the text contains at least one conflict-start marker.
|
|
62
|
+
* @param {string} text
|
|
63
|
+
* @returns {boolean}
|
|
64
|
+
*/
|
|
65
|
+
const has_markers = (text) =>
|
|
66
|
+
String(text == null ? '' : text).split('\n').some(line => START_MARKER.test(line))
|
|
67
|
+
|
|
68
|
+
module.exports = { resolve_markers, has_markers }
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { describe, it } = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
|
|
5
|
+
const { resolve_markers, has_markers } = require('./marker_resolve')
|
|
6
|
+
|
|
7
|
+
const block = (local, remote) =>
|
|
8
|
+
`<<<<<<< LOCAL\n${local}\n=======\n${remote}\n>>>>>>> REMOTE`
|
|
9
|
+
|
|
10
|
+
describe('marker_resolve.resolve_markers', () => {
|
|
11
|
+
it('keeps the remote side for --theirs (side=remote)', () => {
|
|
12
|
+
const text = `intro\n${block('local line', 'remote line')}\noutro`
|
|
13
|
+
const out = resolve_markers(text, 'remote')
|
|
14
|
+
assert.strictEqual(out, 'intro\nremote line\noutro')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('keeps the local side for --ours (side=local)', () => {
|
|
18
|
+
const text = `intro\n${block('local line', 'remote line')}\noutro`
|
|
19
|
+
const out = resolve_markers(text, 'local')
|
|
20
|
+
assert.strictEqual(out, 'intro\nlocal line\noutro')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('resolves multiple marker blocks in one file', () => {
|
|
24
|
+
const text = [
|
|
25
|
+
'a',
|
|
26
|
+
block('L1', 'R1'),
|
|
27
|
+
'b',
|
|
28
|
+
block('L2', 'R2'),
|
|
29
|
+
'c'
|
|
30
|
+
].join('\n')
|
|
31
|
+
assert.strictEqual(resolve_markers(text, 'remote'), 'a\nR1\nb\nR2\nc')
|
|
32
|
+
assert.strictEqual(resolve_markers(text, 'local'), 'a\nL1\nb\nL2\nc')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('handles multi-line sections on each side', () => {
|
|
36
|
+
const text = block('l1\nl2', 'r1\nr2\nr3')
|
|
37
|
+
assert.strictEqual(resolve_markers(text, 'remote'), 'r1\nr2\nr3')
|
|
38
|
+
assert.strictEqual(resolve_markers(text, 'local'), 'l1\nl2')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('passes non-marker content through untouched', () => {
|
|
42
|
+
const text = 'just\nplain\ntext\n'
|
|
43
|
+
assert.strictEqual(resolve_markers(text, 'remote'), text)
|
|
44
|
+
assert.strictEqual(resolve_markers(text, 'local'), text)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('does not treat a standalone ======= (setext heading) outside a block as a separator', () => {
|
|
48
|
+
const text = 'Title\n=======\nbody'
|
|
49
|
+
assert.strictEqual(resolve_markers(text, 'remote'), text)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('preserves a trailing newline', () => {
|
|
53
|
+
const text = `${block('L', 'R')}\n`
|
|
54
|
+
assert.strictEqual(resolve_markers(text, 'remote'), 'R\n')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('rejects an invalid side', () => {
|
|
58
|
+
assert.throws(() => resolve_markers('x', 'theirs'), /Invalid side/)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('marker_resolve.has_markers', () => {
|
|
63
|
+
it('detects a conflict-start marker', () => {
|
|
64
|
+
assert.strictEqual(has_markers(block('a', 'b')), true)
|
|
65
|
+
})
|
|
66
|
+
it('returns false for clean text', () => {
|
|
67
|
+
assert.strictEqual(has_markers('no markers here\nTitle\n=======\n'), false)
|
|
68
|
+
})
|
|
69
|
+
})
|
package/src/merge/rebase.js
CHANGED
|
@@ -9,6 +9,7 @@ const { hash_directory } = require('../lock/integrity')
|
|
|
9
9
|
const { skills_dir, skill_install_dir, lock_root, find_project_root } = require('../config/paths')
|
|
10
10
|
const { ensure_dir, read_file } = require('../utils/fs')
|
|
11
11
|
const { unified_diff } = require('../utils/text_diff')
|
|
12
|
+
const { enrich_file_content, build_resolution_steps } = require('./report')
|
|
12
13
|
|
|
13
14
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
14
15
|
// Rebase-style pull (§ 8.3)
|
|
@@ -120,12 +121,56 @@ const reapply_patch = (patch, remote_file_content) => {
|
|
|
120
121
|
}
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
// Build the --full-report payload for a successful rebase. Every changed file
|
|
125
|
+
// (remote-vs-base differences ∪ reapplied local patches) gets inline content
|
|
126
|
+
// fields (base/local/remote/merged) so a consuming LLM can review coherence in
|
|
127
|
+
// one pass, plus a deterministic resolution_steps array. Reuses the 3-way
|
|
128
|
+
// report helpers so the shape and step wording match the default mode. There
|
|
129
|
+
// are no conflict markers and no json_conflicts on a clean rebase, so
|
|
130
|
+
// build_resolution_steps emits semantic_review (for changed files) + verify.
|
|
131
|
+
const build_rebase_report = ({ skill_name, version, base_files_map, local_files_map, remote_files_map, merged_files_map, patches }) => {
|
|
132
|
+
const patch_paths = new Set(patches.map(p => p.path))
|
|
133
|
+
const changed = new Set(patch_paths)
|
|
134
|
+
for (const p of new Set([...base_files_map.keys(), ...remote_files_map.keys()])) {
|
|
135
|
+
if (base_files_map.get(p) !== remote_files_map.get(p)) changed.add(p)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const files = []
|
|
139
|
+
for (const p of changed) {
|
|
140
|
+
const base = base_files_map.get(p)
|
|
141
|
+
const local = local_files_map.get(p)
|
|
142
|
+
const remote = remote_files_map.get(p)
|
|
143
|
+
const merged = merged_files_map.get(p)
|
|
144
|
+
const local_changed = patch_paths.has(p)
|
|
145
|
+
const remote_changed = base !== remote
|
|
146
|
+
const added = base === undefined
|
|
147
|
+
let classification
|
|
148
|
+
if (local_changed && remote_changed) classification = 'both_modified'
|
|
149
|
+
else if (local_changed) classification = added ? 'local_only_added' : 'local_only_modified'
|
|
150
|
+
else classification = added ? 'remote_only_added' : 'remote_only_modified'
|
|
151
|
+
|
|
152
|
+
const file_entry = { path: p, classification, conflict_written: false, base_sha: null, local_sha: null, remote_sha: null }
|
|
153
|
+
enrich_file_content(file_entry, { base, local, remote, merged })
|
|
154
|
+
files.push(file_entry)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const report = {
|
|
158
|
+
skill: skill_name,
|
|
159
|
+
base_version: null,
|
|
160
|
+
remote_version: version || null,
|
|
161
|
+
files,
|
|
162
|
+
summary: { total: files.length, clean: 0, auto_merged: files.length, conflicted: 0 }
|
|
163
|
+
}
|
|
164
|
+
return { report, resolution_steps: build_resolution_steps(report, []) }
|
|
165
|
+
}
|
|
166
|
+
|
|
123
167
|
const rebase_pull = (skill_name, options = {}) => catch_errors('Pull --rebase failed', async () => {
|
|
124
168
|
if (!skill_name || !skill_name.includes('/')) {
|
|
125
169
|
throw new Error('skill_name must be in owner/name format')
|
|
126
170
|
}
|
|
127
171
|
const project_root = options.project_root || find_project_root()
|
|
128
172
|
const is_global = !!options.is_global
|
|
173
|
+
const full_report = !!options.full_report
|
|
129
174
|
|
|
130
175
|
const [lock_err, lock_data] = await read_lock(lock_root(is_global, project_root))
|
|
131
176
|
if (lock_err || !lock_data) throw new Error('No lock file found')
|
|
@@ -304,19 +349,35 @@ const rebase_pull = (skill_name, options = {}) => catch_errors('Pull --rebase fa
|
|
|
304
349
|
// Success — drop the snapshot.
|
|
305
350
|
await snapshot_storage.remove(snap.snapshot_id, { is_global, project_root })
|
|
306
351
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
version: cmp_data.head_version || lock_entry.version
|
|
316
|
-
},
|
|
317
|
-
next_step: null,
|
|
318
|
-
error: null
|
|
352
|
+
const data = {
|
|
353
|
+
operation: 'pull_rebase',
|
|
354
|
+
status: applied.length > 0 ? 'rebased' : 'fast_forward',
|
|
355
|
+
skill: skill_name,
|
|
356
|
+
fast_forward_to: cmp_data.head_commit,
|
|
357
|
+
patches_applied: applied,
|
|
358
|
+
patches_rejected: [],
|
|
359
|
+
version: cmp_data.head_version || lock_entry.version
|
|
319
360
|
}
|
|
361
|
+
|
|
362
|
+
// --full-report: enrich with inline per-file content + resolution steps for
|
|
363
|
+
// the operator's semantic-coherence review (Layer 2). The merged result is
|
|
364
|
+
// now live at skill_dir (post-swap), so read it back as the merged side.
|
|
365
|
+
if (full_report) {
|
|
366
|
+
const [, merged_files_map] = await read_dir_files(skill_dir)
|
|
367
|
+
const { report, resolution_steps } = build_rebase_report({
|
|
368
|
+
skill_name,
|
|
369
|
+
version: cmp_data.head_version || lock_entry.version,
|
|
370
|
+
base_files_map,
|
|
371
|
+
local_files_map,
|
|
372
|
+
remote_files_map,
|
|
373
|
+
merged_files_map: merged_files_map || new Map(),
|
|
374
|
+
patches
|
|
375
|
+
})
|
|
376
|
+
data.report = report
|
|
377
|
+
data.resolution_steps = resolution_steps
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return { data, next_step: null, error: null }
|
|
320
381
|
})
|
|
321
382
|
|
|
322
383
|
module.exports = { rebase_pull, capture_local_patches, reapply_patch, read_dir_files }
|
package/src/merge/rebase.test.js
CHANGED
|
@@ -5,7 +5,9 @@ const fs = require('fs')
|
|
|
5
5
|
const os = require('os')
|
|
6
6
|
const path = require('path')
|
|
7
7
|
|
|
8
|
-
const { capture_local_patches, reapply_patch, read_dir_files } = require('./rebase')
|
|
8
|
+
const { capture_local_patches, reapply_patch, read_dir_files, rebase_pull } = require('./rebase')
|
|
9
|
+
const repos_api = require('../api/repos')
|
|
10
|
+
const snapshot_storage = require('../snapshot/storage')
|
|
9
11
|
|
|
10
12
|
describe('rebase.capture_local_patches', () => {
|
|
11
13
|
it('identifies modified files and produces a patch', () => {
|
|
@@ -92,6 +94,105 @@ describe('rebase.reapply_patch', () => {
|
|
|
92
94
|
})
|
|
93
95
|
})
|
|
94
96
|
|
|
97
|
+
describe('rebase.rebase_pull --full-report (§4.2)', () => {
|
|
98
|
+
const b64 = (s) => Buffer.from(s).toString('base64')
|
|
99
|
+
|
|
100
|
+
it('includes inline per-file content and resolution_steps on a clean rebase', async () => {
|
|
101
|
+
const BASE = 'base123'
|
|
102
|
+
const HEAD = 'head456'
|
|
103
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-rebase-fr-'))
|
|
104
|
+
const skill_dir = path.join(root, '.agents', 'skills', 'deploy-aws')
|
|
105
|
+
fs.mkdirSync(skill_dir, { recursive: true })
|
|
106
|
+
// Local: A modified, B untouched.
|
|
107
|
+
fs.writeFileSync(path.join(skill_dir, 'A.md'), 'a\nlocal\n')
|
|
108
|
+
fs.writeFileSync(path.join(skill_dir, 'B.md'), 'b\n')
|
|
109
|
+
fs.writeFileSync(path.join(skill_dir, 'skill.json'), '{"name":"deploy-aws","version":"1.0.0"}')
|
|
110
|
+
fs.writeFileSync(path.join(root, 'skills-lock.json'), JSON.stringify({
|
|
111
|
+
lockVersion: 2,
|
|
112
|
+
skills: { 'acme/deploy-aws': { version: '1.0.0', base_commit: BASE, integrity: 's', base_integrity: 's', dependencies: {} } }
|
|
113
|
+
}, null, 2))
|
|
114
|
+
|
|
115
|
+
const orig = {
|
|
116
|
+
compare: repos_api.compare, clone: repos_api.clone,
|
|
117
|
+
create: snapshot_storage.create, remove: snapshot_storage.remove, restore: snapshot_storage.restore
|
|
118
|
+
}
|
|
119
|
+
repos_api.compare = async () => [null, { added: [], removed: [], modified: ['B.md'], head_commit: HEAD, head_version: '1.1.0' }]
|
|
120
|
+
repos_api.clone = async (owner, repo, ref, opts = {}) => {
|
|
121
|
+
// Remote modified B, left A and skill.json unchanged from base.
|
|
122
|
+
const files = opts.commit === BASE
|
|
123
|
+
? [{ path: 'A.md', content: b64('a\n'), sha: 'sa' }, { path: 'B.md', content: b64('b\n'), sha: 'sb' }, { path: 'skill.json', content: b64('{"name":"deploy-aws","version":"1.0.0"}'), sha: 'sj' }]
|
|
124
|
+
: [{ path: 'A.md', content: b64('a\n'), sha: 'sa' }, { path: 'B.md', content: b64('b\nremote\n'), sha: 'sb2' }, { path: 'skill.json', content: b64('{"name":"deploy-aws","version":"1.0.0"}'), sha: 'sj' }]
|
|
125
|
+
return [null, { ref: 'refs/tags/v1.1.0', commit: opts.commit, files }]
|
|
126
|
+
}
|
|
127
|
+
snapshot_storage.create = async () => [null, { snapshot_id: 'snap_test' }]
|
|
128
|
+
snapshot_storage.remove = async () => [null]
|
|
129
|
+
snapshot_storage.restore = async () => [null]
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const [err, result] = await rebase_pull('acme/deploy-aws', { project_root: root, is_global: false, full_report: true })
|
|
133
|
+
assert.ok(!err, 'rebase_pull should not error')
|
|
134
|
+
assert.ok(result.data.report, 'report attached to data')
|
|
135
|
+
assert.ok(Array.isArray(result.data.resolution_steps), 'resolution_steps attached')
|
|
136
|
+
|
|
137
|
+
const by_path = Object.fromEntries(result.data.report.files.map(f => [f.path, f]))
|
|
138
|
+
// A.md — local-only modified; merged keeps the local change.
|
|
139
|
+
assert.strictEqual(by_path['A.md'].local_content, 'a\nlocal\n')
|
|
140
|
+
assert.strictEqual(by_path['A.md'].merged_content, 'a\nlocal\n')
|
|
141
|
+
assert.strictEqual(by_path['A.md'].base_content, 'a\n')
|
|
142
|
+
// B.md — remote-only modified; merged takes the remote change.
|
|
143
|
+
assert.strictEqual(by_path['B.md'].remote_content, 'b\nremote\n')
|
|
144
|
+
assert.strictEqual(by_path['B.md'].merged_content, 'b\nremote\n')
|
|
145
|
+
|
|
146
|
+
const actions = result.data.resolution_steps.map(s => s.action)
|
|
147
|
+
assert.ok(actions.includes('semantic_review'), 'semantic_review step present')
|
|
148
|
+
assert.ok(actions.includes('verify'), 'verify step present')
|
|
149
|
+
const sem = result.data.resolution_steps.find(s => s.action === 'semantic_review')
|
|
150
|
+
assert.ok(sem.files.includes('A.md') && sem.files.includes('B.md'), 'both changed files queued for semantic review')
|
|
151
|
+
} finally {
|
|
152
|
+
Object.assign(repos_api, { compare: orig.compare, clone: orig.clone })
|
|
153
|
+
Object.assign(snapshot_storage, { create: orig.create, remove: orig.remove, restore: orig.restore })
|
|
154
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('omits report fields when full_report is false', async () => {
|
|
159
|
+
const BASE = 'base123'
|
|
160
|
+
const HEAD = 'head456'
|
|
161
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-rebase-nofr-'))
|
|
162
|
+
const skill_dir = path.join(root, '.agents', 'skills', 'deploy-aws')
|
|
163
|
+
fs.mkdirSync(skill_dir, { recursive: true })
|
|
164
|
+
fs.writeFileSync(path.join(skill_dir, 'A.md'), 'a\nlocal\n')
|
|
165
|
+
fs.writeFileSync(path.join(skill_dir, 'skill.json'), '{"name":"deploy-aws","version":"1.0.0"}')
|
|
166
|
+
fs.writeFileSync(path.join(root, 'skills-lock.json'), JSON.stringify({
|
|
167
|
+
lockVersion: 2,
|
|
168
|
+
skills: { 'acme/deploy-aws': { version: '1.0.0', base_commit: BASE, integrity: 's', base_integrity: 's', dependencies: {} } }
|
|
169
|
+
}, null, 2))
|
|
170
|
+
|
|
171
|
+
const orig = {
|
|
172
|
+
compare: repos_api.compare, clone: repos_api.clone,
|
|
173
|
+
create: snapshot_storage.create, remove: snapshot_storage.remove, restore: snapshot_storage.restore
|
|
174
|
+
}
|
|
175
|
+
repos_api.compare = async () => [null, { added: [], removed: [], modified: ['B.md'], head_commit: HEAD, head_version: '1.1.0' }]
|
|
176
|
+
repos_api.clone = async (owner, repo, ref, opts = {}) => [null, { ref: 'refs/tags/v1.1.0', commit: opts.commit, files: [
|
|
177
|
+
{ path: 'A.md', content: b64('a\n'), sha: 'sa' }, { path: 'skill.json', content: b64('{"name":"deploy-aws","version":"1.0.0"}'), sha: 'sj' }
|
|
178
|
+
] }]
|
|
179
|
+
snapshot_storage.create = async () => [null, { snapshot_id: 'snap_test' }]
|
|
180
|
+
snapshot_storage.remove = async () => [null]
|
|
181
|
+
snapshot_storage.restore = async () => [null]
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const [err, result] = await rebase_pull('acme/deploy-aws', { project_root: root, is_global: false, full_report: false })
|
|
185
|
+
assert.ok(!err)
|
|
186
|
+
assert.strictEqual(result.data.report, undefined, 'no report when full_report is false')
|
|
187
|
+
assert.strictEqual(result.data.resolution_steps, undefined, 'no resolution_steps when full_report is false')
|
|
188
|
+
} finally {
|
|
189
|
+
Object.assign(repos_api, { compare: orig.compare, clone: orig.clone })
|
|
190
|
+
Object.assign(snapshot_storage, { create: orig.create, remove: orig.remove, restore: orig.restore })
|
|
191
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
95
196
|
describe('rebase.read_dir_files', () => {
|
|
96
197
|
it('reads all non-dot files recursively', async () => {
|
|
97
198
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-rebase-readdir-'))
|