skillwiki 0.9.63 → 0.10.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.
Files changed (36) hide show
  1. package/dist/chunk-5TBIMLTZ.js +343 -0
  2. package/dist/chunk-C5OLZRRM.js +357 -0
  3. package/dist/{chunk-TUFQZ5K4.js → chunk-DR7KFHNH.js} +792 -1983
  4. package/dist/chunk-IZABIE44.js +647 -0
  5. package/dist/chunk-S5ABQCXQ.js +580 -0
  6. package/dist/cli.js +1540 -550
  7. package/dist/index-projection-ERFX76U5.js +10 -0
  8. package/dist/managed-write-preflight-SILEUQEV.js +11 -0
  9. package/dist/skillwiki-mcp.js +4 -1
  10. package/dist/vault-sync/scripts/lib/conflict-markers.sh +69 -0
  11. package/dist/vault-sync/scripts/lib/delete-intent.sh +74 -0
  12. package/dist/vault-sync/scripts/lib/fleet.sh +103 -0
  13. package/dist/vault-sync/scripts/lib/git-case.sh +71 -0
  14. package/dist/vault-sync/scripts/lib/git-materialization.sh +264 -0
  15. package/dist/vault-sync/scripts/lib/git-operation-journal.sh +469 -0
  16. package/dist/vault-sync/scripts/lib/git-rebase-state.sh +180 -0
  17. package/dist/vault-sync/scripts/lib/lockfile.sh +70 -0
  18. package/dist/vault-sync/scripts/lib/managed-write-lock.sh +80 -0
  19. package/dist/vault-sync/scripts/lib/platform.sh +184 -0
  20. package/dist/vault-sync/scripts/lib/runtime-manifest.sh +223 -0
  21. package/dist/vault-sync/scripts/wiki-fetch-notify.sh +207 -0
  22. package/dist/vault-sync/scripts/wiki-fuse-refresh.sh +405 -0
  23. package/dist/vault-sync/scripts/wiki-pull-with-auto-resolve.sh +631 -0
  24. package/dist/vault-sync/scripts/wiki-push.sh +364 -0
  25. package/dist/vault-sync/scripts/wiki-snapshot.sh +587 -0
  26. package/package.json +2 -2
  27. package/skills/.claude-plugin/plugin.json +1 -1
  28. package/skills/.codex-plugin/plugin.json +1 -1
  29. package/skills/README.md +13 -0
  30. package/skills/package.json +1 -1
  31. package/skills/proj-work/SKILL.md +3 -0
  32. package/skills/skills/proj-work/SKILL.md +3 -0
  33. package/skills/skills/using-skillwiki/SKILL.md +13 -0
  34. package/skills/skills/wiki-crystallize/SKILL.md +3 -0
  35. package/skills/using-skillwiki/SKILL.md +13 -0
  36. package/skills/wiki-crystallize/SKILL.md +3 -0
@@ -0,0 +1,469 @@
1
+ #!/bin/bash
2
+ # git-operation-journal.sh — helper-owned vault-sync operation journal.
3
+ # Bash 3.2 compatible. Source from vault-sync scripts.
4
+
5
+ vault_sync_op_journal_dir() {
6
+ local repo="${1:-.}"
7
+ local path
8
+ path="$(git -C "$repo" rev-parse --git-path vault-sync/operations 2>/dev/null)" || return 1
9
+ case "$path" in
10
+ /*) printf '%s\n' "$path" ;;
11
+ *) printf '%s\n' "$repo/$path" ;;
12
+ esac
13
+ }
14
+
15
+ vault_sync_op_journal_path() {
16
+ local repo="$1" op_id="$2"
17
+ local dir
18
+ dir="$(vault_sync_op_journal_dir "$repo")" || return 1
19
+ printf '%s/%s.env\n' "$dir" "$op_id"
20
+ }
21
+
22
+ vault_sync_op_get_field() {
23
+ local repo="$1" op_id="$2" key="$3"
24
+ local file
25
+ file="$(vault_sync_op_journal_path "$repo" "$op_id")" || return 1
26
+ [ -f "$file" ] || return 1
27
+ # Line-oriented key=value; print value after first '=' (values may contain '=').
28
+ awk -F= -v k="$key" '$1==k {print substr($0, index($0,"=")+1); exit}' "$file"
29
+ }
30
+
31
+ vault_sync_op_set_field() {
32
+ local repo="$1" op_id="$2" key="$3" value="$4"
33
+ vault_sync_op_set_fields "$repo" "$op_id" "$key" "$value"
34
+ }
35
+
36
+ # Batch journal field updates in one rewrite.
37
+ # Usage: vault_sync_op_set_fields <repo> <op_id> <key> <value> [<key> <value> ...]
38
+ vault_sync_op_set_fields() {
39
+ local repo="$1" op_id="$2"
40
+ shift 2
41
+ local file tmp
42
+ file="$(vault_sync_op_journal_path "$repo" "$op_id")" || return 1
43
+ tmp="$(mktemp)" || return 1
44
+ if [ -f "$file" ]; then
45
+ cp "$file" "$tmp" || { rm -f "$tmp"; return 1; }
46
+ else
47
+ : >"$tmp"
48
+ fi
49
+ while [ "$#" -ge 2 ]; do
50
+ awk -F= -v k="$1" -v v="$2" '
51
+ BEGIN{found=0}
52
+ $1==k {print k"="v; found=1; next}
53
+ {print}
54
+ END{if(!found) print k"="v}
55
+ ' "$tmp" >"${tmp}.new" || { rm -f "$tmp" "${tmp}.new"; return 1; }
56
+ mv "${tmp}.new" "$tmp"
57
+ shift 2
58
+ done
59
+ mv "$tmp" "$file"
60
+ }
61
+
62
+ vault_sync_op_set_phase() {
63
+ vault_sync_op_set_field "$1" "$2" "phase" "$3"
64
+ }
65
+
66
+ vault_sync_op_create_recovery_refs() {
67
+ local repo="$1" op_id="$2" original_head="$3" target_oid="$4"
68
+
69
+ # Prefer transactional create (CAS: fail if ref already exists).
70
+ if git -C "$repo" update-ref --stdin --create-reflog -m "vault-sync operation begin" >/dev/null 2>&1 <<EOF
71
+ start
72
+ create refs/vault-sync/recovery/${op_id}/original-head ${original_head}
73
+ create refs/vault-sync/recovery/${op_id}/target ${target_oid}
74
+ prepare
75
+ commit
76
+ EOF
77
+ then
78
+ return 0
79
+ fi
80
+
81
+ # Fallback: create-only update-ref (empty oldoid means must not exist).
82
+ if ! git -C "$repo" update-ref \
83
+ "refs/vault-sync/recovery/${op_id}/original-head" "$original_head" "" 2>/dev/null; then
84
+ return 1
85
+ fi
86
+ if ! git -C "$repo" update-ref \
87
+ "refs/vault-sync/recovery/${op_id}/target" "$target_oid" "" 2>/dev/null; then
88
+ # Best-effort rollback of the first ref if second create fails.
89
+ git -C "$repo" update-ref -d "refs/vault-sync/recovery/${op_id}/original-head" 2>/dev/null || true
90
+ return 1
91
+ fi
92
+ return 0
93
+ }
94
+
95
+ # CAS-update the recovery target ref: only succeeds if current value is expected_old.
96
+ vault_sync_op_cas_recovery_target() {
97
+ local repo="$1" op_id="$2" new_oid="$3" expected_old="$4"
98
+ if git -C "$repo" update-ref \
99
+ "refs/vault-sync/recovery/${op_id}/target" "$new_oid" "$expected_old" 2>/dev/null; then
100
+ return 0
101
+ fi
102
+ return 1
103
+ }
104
+
105
+
106
+ vault_sync_op_begin() {
107
+ local repo="$1" op_id="$2" branch="$3" original_head="$4" target_oid="$5"
108
+ local lock_identity="$6" helper_version="$7" runtime_hash="$8"
109
+ local dir file worktree_path worktree_git_dir
110
+ dir="$(vault_sync_op_journal_dir "$repo")" || return 1
111
+ mkdir -p "$dir" || return 1
112
+ file="$(vault_sync_op_journal_path "$repo" "$op_id")" || return 1
113
+ if [ -f "$file" ]; then
114
+ return 1
115
+ fi
116
+ if ! vault_sync_op_create_recovery_refs "$repo" "$op_id" "$original_head" "$target_oid"; then
117
+ return 1
118
+ fi
119
+ worktree_path="$(cd "$repo" && pwd -P)" || return 1
120
+ worktree_git_dir="$(git -C "$repo" rev-parse --absolute-git-dir 2>/dev/null)" || return 1
121
+ cat >"$file" <<EOF
122
+ operation_id=${op_id}
123
+ phase=prepared
124
+ retry_count=0
125
+ original_branch=${branch}
126
+ original_head=${original_head}
127
+ target_oid=${target_oid}
128
+ owned_stash_oid=
129
+ preservation_scope=none
130
+ lock_identity=${lock_identity}
131
+ helper_version=${helper_version}
132
+ deployed_runtime_hash=${runtime_hash}
133
+ conflict_identity=
134
+ handoff=0
135
+ reason=
136
+ worktree_path=${worktree_path}
137
+ worktree_git_dir=${worktree_git_dir}
138
+ EOF
139
+ return 0
140
+ }
141
+
142
+ vault_sync_op_find_review_required() {
143
+ local repo="$1" current_git_dir jdir jf op_id phase handoff journal_git_dir
144
+ current_git_dir="$(git -C "$repo" rev-parse --absolute-git-dir 2>/dev/null)" || return 1
145
+ jdir="$(vault_sync_op_journal_dir "$repo")" || return 1
146
+ [ -d "$jdir" ] || return 1
147
+
148
+ for jf in "$jdir"/*.env; do
149
+ [ -f "$jf" ] || continue
150
+ op_id="$(basename "$jf" .env)"
151
+ phase="$(vault_sync_op_get_field "$repo" "$op_id" phase 2>/dev/null || true)"
152
+ handoff="$(vault_sync_op_get_field "$repo" "$op_id" handoff 2>/dev/null || true)"
153
+ [ "$phase" = "review-required" ] && [ "$handoff" = "1" ] || continue
154
+ journal_git_dir="$(vault_sync_op_get_field "$repo" "$op_id" worktree_git_dir 2>/dev/null || true)"
155
+ if [ -z "$journal_git_dir" ] || [ "$journal_git_dir" = "$current_git_dir" ]; then
156
+ printf '%s\n' "$op_id"
157
+ return 0
158
+ fi
159
+ done
160
+ return 1
161
+ }
162
+
163
+ vault_sync_op_preflight_blocker() {
164
+ local repo="$1" unmerged op_id git_dir
165
+ unmerged="$(git -C "$repo" diff --name-only --diff-filter=U 2>/dev/null || true)"
166
+ if [ -n "$unmerged" ]; then
167
+ op_id="$(vault_sync_op_find_review_required "$repo" 2>/dev/null || true)"
168
+ printf 'unmerged-paths\t%s\n' "$op_id"
169
+ return 0
170
+ fi
171
+
172
+ git_dir="$(git -C "$repo" rev-parse --absolute-git-dir 2>/dev/null)" || return 1
173
+ if [ -f "$git_dir/MERGE_HEAD" ] || [ -f "$git_dir/CHERRY_PICK_HEAD" ] || [ -f "$git_dir/REVERT_HEAD" ]; then
174
+ printf 'git-operation-in-progress\t\n'
175
+ return 0
176
+ fi
177
+
178
+ op_id="$(vault_sync_op_find_review_required "$repo" 2>/dev/null || true)"
179
+ if [ -n "$op_id" ]; then
180
+ printf 'review-required\t%s\n' "$op_id"
181
+ return 0
182
+ fi
183
+ return 1
184
+ }
185
+
186
+ vault_sync_op_record_stash() {
187
+ vault_sync_op_set_fields "$1" "$2" \
188
+ "owned_stash_oid" "$3" \
189
+ "preservation_scope" "$4"
190
+ }
191
+
192
+ vault_sync_op_record_inventory() {
193
+ local repo="$1" op_id="$2" inv="$3"
194
+ local dir dest
195
+ dir="$(vault_sync_op_journal_dir "$repo")" || return 1
196
+ dest="$dir/${op_id}.inventory"
197
+ cp "$inv" "$dest" || return 1
198
+ vault_sync_op_set_field "$repo" "$op_id" "inventory_path" "$dest"
199
+ }
200
+
201
+ vault_sync_op_fingerprint_repo_state() {
202
+ local repo="$1"
203
+ local git_dir rebase_dir onto orig stopped head_name rebase_head index_tree porcelain
204
+ local unmerged_paths unmerged_content path blob
205
+
206
+ git_dir="$(git -C "$repo" rev-parse --git-dir)" || return 1
207
+ case "$git_dir" in
208
+ /*) ;;
209
+ *) git_dir="$repo/$git_dir" ;;
210
+ esac
211
+
212
+ rebase_dir=""
213
+ if [ -d "$git_dir/rebase-merge" ]; then
214
+ rebase_dir="$git_dir/rebase-merge"
215
+ elif [ -d "$git_dir/rebase-apply" ]; then
216
+ rebase_dir="$git_dir/rebase-apply"
217
+ fi
218
+
219
+ onto=""
220
+ orig=""
221
+ stopped=""
222
+ head_name=""
223
+ if [ -n "$rebase_dir" ]; then
224
+ [ -f "$rebase_dir/onto" ] && onto="$(tr -d '[:space:]' <"$rebase_dir/onto")"
225
+ if [ -f "$rebase_dir/orig-head" ]; then
226
+ orig="$(tr -d '[:space:]' <"$rebase_dir/orig-head")"
227
+ elif [ -f "$rebase_dir/orig_head" ]; then
228
+ orig="$(tr -d '[:space:]' <"$rebase_dir/orig_head")"
229
+ fi
230
+ [ -f "$rebase_dir/stopped-sha" ] && stopped="$(tr -d '[:space:]' <"$rebase_dir/stopped-sha")"
231
+ [ -f "$rebase_dir/head-name" ] && head_name="$(tr -d '[:space:]' <"$rebase_dir/head-name")"
232
+ fi
233
+
234
+ rebase_head="$(git -C "$repo" rev-parse -q --verify REBASE_HEAD 2>/dev/null || true)"
235
+ index_tree="$(git -C "$repo" write-tree 2>/dev/null || echo none)"
236
+ porcelain="$(git -C "$repo" status --porcelain=v1 --untracked-files=all 2>/dev/null | shasum -a 256 | awk '{print $1}')"
237
+
238
+ # Include worktree content of unmerged paths so human edits to conflicted
239
+ # files change the fingerprint (porcelain alone does not hash file bodies).
240
+ unmerged_paths="$(git -C "$repo" diff --name-only --diff-filter=U 2>/dev/null || true)"
241
+ unmerged_content=""
242
+ if [ -n "$unmerged_paths" ]; then
243
+ while IFS= read -r path; do
244
+ [ -n "$path" ] || continue
245
+ if [ -f "$repo/$path" ]; then
246
+ # hash-object needs a filesystem path; -C does not re-root relative paths.
247
+ blob="$(git -C "$repo" hash-object -- "$repo/$path" 2>/dev/null || echo missing)"
248
+ else
249
+ blob="missing"
250
+ fi
251
+ unmerged_content="${unmerged_content}${path}=${blob};"
252
+ done <<EOF
253
+ $unmerged_paths
254
+ EOF
255
+ fi
256
+ unmerged_content="$(printf '%s' "$unmerged_content" | shasum -a 256 | awk '{print $1}')"
257
+
258
+ printf 'onto=%s;orig=%s;stopped=%s;head_name=%s;rebase_head=%s;index_tree=%s;porcelain=%s;unmerged=%s\n' \
259
+ "$onto" "$orig" "$stopped" "$head_name" "$rebase_head" "$index_tree" "$porcelain" "$unmerged_content"
260
+ }
261
+
262
+ vault_sync_op_record_conflict_identity() {
263
+ local repo="$1" op_id="$2"
264
+ local fp
265
+ fp="$(vault_sync_op_fingerprint_repo_state "$repo")" || return 1
266
+ vault_sync_op_set_field "$repo" "$op_id" "conflict_identity" "$fp"
267
+ }
268
+
269
+ vault_sync_op_conflict_identity_unchanged() {
270
+ local repo="$1" op_id="$2"
271
+ local expected actual
272
+ expected="$(vault_sync_op_get_field "$repo" "$op_id" "conflict_identity")" || return 1
273
+ [ -n "$expected" ] || return 1
274
+ actual="$(vault_sync_op_fingerprint_repo_state "$repo")" || return 1
275
+ [ "$expected" = "$actual" ]
276
+ }
277
+
278
+ # Load common may_retry fields from one journal file read.
279
+ vault_sync_op_load_retry_fields() {
280
+ local repo="$1" op_id="$2"
281
+ local file
282
+ file="$(vault_sync_op_journal_path "$repo" "$op_id")" || return 1
283
+ [ -f "$file" ] || return 1
284
+ # shellcheck disable=SC2034
285
+ eval "$(awk -F= '
286
+ $1=="phase" || $1=="retry_count" || $1=="handoff" || $1=="target_oid" || $1=="conflict_identity" {
287
+ key=$1
288
+ val=substr($0, index($0,"=")+1)
289
+ gsub(/'\''/, "'\''\\'\'''\''", val)
290
+ printf "_vs_jf_%s='\''%s'\''\n", key, val
291
+ }
292
+ ' "$file")"
293
+ }
294
+
295
+ vault_sync_op_may_retry() {
296
+ local repo="$1" op_id="$2" live_remote_oid="$3"
297
+ local phase retry handoff target fp onto actual
298
+
299
+ _vs_jf_phase=""
300
+ _vs_jf_retry_count=""
301
+ _vs_jf_handoff=""
302
+ _vs_jf_target_oid=""
303
+ _vs_jf_conflict_identity=""
304
+ vault_sync_op_load_retry_fields "$repo" "$op_id" || return 1
305
+ phase="${_vs_jf_phase}"
306
+ retry="${_vs_jf_retry_count}"
307
+ handoff="${_vs_jf_handoff}"
308
+ target="${_vs_jf_target_oid}"
309
+ fp="${_vs_jf_conflict_identity}"
310
+
311
+ # Fail closed: only one retry, never after handoff.
312
+ [ "$handoff" = "0" ] || return 1
313
+ [ "$retry" = "0" ] || return 1
314
+ case "$phase" in
315
+ rebasing|retrying) ;;
316
+ *) return 1 ;;
317
+ esac
318
+ [ -n "$live_remote_oid" ] && [ "$live_remote_oid" != "$target" ] || return 1
319
+
320
+ # Require stable conflict identity (empty / no sequencer → fail closed).
321
+ [ -n "$fp" ] || return 1
322
+ actual="$(vault_sync_op_fingerprint_repo_state "$repo")" || return 1
323
+ [ "$fp" = "$actual" ] || return 1
324
+
325
+ # Journal ownership of sequencer: onto must match journaled target.
326
+ onto="$(printf '%s' "$fp" | sed -n 's/.*onto=\([^;]*\).*/\1/p')"
327
+ [ -n "$onto" ] && [ "$onto" = "$target" ] || return 1
328
+ return 0
329
+ }
330
+
331
+ vault_sync_op_mark_review_required() {
332
+ vault_sync_op_set_fields "$1" "$2" \
333
+ "phase" "review-required" \
334
+ "handoff" "1" \
335
+ "reason" "$3"
336
+ }
337
+
338
+ vault_sync_op_close_complete() {
339
+ vault_sync_op_set_fields "$1" "$2" \
340
+ "phase" "complete" \
341
+ "handoff" "1"
342
+ }
343
+
344
+ # Dirty-state helpers used by pull integration
345
+ vault_sync_op_write_inventory() {
346
+ local repo="$1" out="$2"
347
+ {
348
+ echo "# staged"
349
+ git -C "$repo" diff --cached --name-only
350
+ echo "# tracked"
351
+ git -C "$repo" diff --name-only
352
+ echo "# untracked"
353
+ git -C "$repo" ls-files --others --exclude-standard
354
+ } >"$out"
355
+ }
356
+
357
+ # Verify inventory after owned-stash apply.
358
+ # - tracked/staged paths that are blobs in the stash tree must reappear.
359
+ # - When skip_content_paths is non-empty (newline-separated), those paths only
360
+ # require presence (post-conflict auto-resolve may legitimately change content).
361
+ # - Other tracked paths must content-match the stash blob.
362
+ # - untracked inventory paths must reappear (and content-match untracked tree when present).
363
+ # Fail closed on any mismatch.
364
+ vault_sync_op_verify_inventory() {
365
+ local repo="$1" op_id="$2" stash_oid="${3:-}" skip_content_paths="${4:-}"
366
+ local inv section path expected_hash actual_hash unmerged skip has_u3
367
+
368
+ inv="$(vault_sync_op_get_field "$repo" "$op_id" inventory_path)" || return 1
369
+ [ -f "$inv" ] || return 1
370
+
371
+ unmerged="$(git -C "$repo" diff --name-only --diff-filter=U 2>/dev/null || true)"
372
+ has_u3=0
373
+ if [ -n "$stash_oid" ] && git -C "$repo" rev-parse -q --verify "${stash_oid}^3" >/dev/null 2>&1; then
374
+ has_u3=1
375
+ fi
376
+ section=""
377
+ while IFS= read -r path || [ -n "$path" ]; do
378
+ case "$path" in
379
+ "# staged") section="staged"; continue ;;
380
+ "# tracked") section="tracked"; continue ;;
381
+ "# untracked") section="untracked"; continue ;;
382
+ ""|\#*) continue ;;
383
+ esac
384
+ [ -n "$path" ] || continue
385
+ [ -n "$section" ] || continue
386
+
387
+ # Skip content checks for currently-unmerged conflict paths.
388
+ if printf '%s\n' "$unmerged" | grep -Fxq -- "$path"; then
389
+ continue
390
+ fi
391
+
392
+ skip=0
393
+ if [ -n "$skip_content_paths" ] && printf '%s\n' "$skip_content_paths" | grep -Fxq -- "$path"; then
394
+ skip=1
395
+ fi
396
+
397
+ case "$section" in
398
+ staged|tracked)
399
+ if [ -n "$stash_oid" ] && git -C "$repo" cat-file -e "$stash_oid:$path" 2>/dev/null; then
400
+ if [ ! -f "$repo/$path" ] && [ ! -L "$repo/$path" ]; then
401
+ return 1
402
+ fi
403
+ if [ "$skip" -eq 0 ]; then
404
+ expected_hash="$(git -C "$repo" rev-parse "$stash_oid:$path" 2>/dev/null || echo missing)"
405
+ actual_hash="$(git -C "$repo" hash-object -- "$repo/$path" 2>/dev/null || echo missing)"
406
+ [ "$expected_hash" = "$actual_hash" ] || return 1
407
+ fi
408
+ fi
409
+ ;;
410
+ untracked)
411
+ if [ ! -e "$repo/$path" ] && [ ! -L "$repo/$path" ]; then
412
+ return 1
413
+ fi
414
+ if [ "$skip" -eq 0 ] && [ "$has_u3" -eq 1 ]; then
415
+ if git -C "$repo" cat-file -e "${stash_oid}^3:$path" 2>/dev/null; then
416
+ expected_hash="$(git -C "$repo" rev-parse "${stash_oid}^3:$path" 2>/dev/null || echo missing)"
417
+ if [ -f "$repo/$path" ]; then
418
+ actual_hash="$(git -C "$repo" hash-object -- "$repo/$path" 2>/dev/null || echo missing)"
419
+ [ "$expected_hash" = "$actual_hash" ] || return 1
420
+ fi
421
+ fi
422
+ fi
423
+ ;;
424
+ esac
425
+ done <"$inv"
426
+ return 0
427
+ }
428
+
429
+ vault_sync_op_stash_push_owned() {
430
+ # Usage: vault_sync_op_stash_push_owned <repo> <message> <include_untracked:0|1>
431
+ # Prints stash OID on stdout; returns 0 on success.
432
+ local repo="$1" msg="$2" include_u="$3"
433
+ local after
434
+
435
+ if [ "$include_u" = "1" ]; then
436
+ git -C "$repo" stash push -u -m "$msg" >/dev/null 2>&1 || return 1
437
+ else
438
+ git -C "$repo" stash push -m "$msg" >/dev/null 2>&1 || return 1
439
+ fi
440
+ after="$(git -C "$repo" rev-parse -q --verify refs/stash 2>/dev/null || true)"
441
+ [ -n "$after" ] || return 1
442
+ printf '%s\n' "$after"
443
+ }
444
+
445
+ vault_sync_op_stash_apply_owned() {
446
+ local repo="$1" oid="$2"
447
+ git -C "$repo" stash apply "$oid"
448
+ }
449
+
450
+ vault_sync_op_stash_drop_owned() {
451
+ # Drop only the stash list entry that still resolves to oid.
452
+ # Bash 3.2-safe: avoid process substitution.
453
+ local repo="$1" oid="$2"
454
+ local entry sha list
455
+
456
+ list="$(git -C "$repo" stash list --format='%gd' 2>/dev/null || true)"
457
+ [ -n "$list" ] || return 1
458
+ while IFS= read -r entry; do
459
+ [ -n "$entry" ] || continue
460
+ sha="$(git -C "$repo" rev-parse "$entry" 2>/dev/null || true)"
461
+ if [ "$sha" = "$oid" ]; then
462
+ git -C "$repo" stash drop "$entry"
463
+ return $?
464
+ fi
465
+ done <<EOF
466
+ $list
467
+ EOF
468
+ return 1
469
+ }
@@ -0,0 +1,180 @@
1
+ #!/bin/bash
2
+ # git-rebase-state.sh — classify and safely clear Git rebase sequencer state.
3
+ #
4
+ # Bash 3.2 compatible. Source from vault-sync scripts.
5
+ #
6
+ # Classification (stdout):
7
+ # none — no rebase sequencer directory
8
+ # active — real in-progress rebase (must not auto-clear)
9
+ # stale-clean — sequencer dir exists, worktree clean of unmerged paths,
10
+ # live tip differs from recorded orig-head
11
+ #
12
+ # Cleanup for stale-clean:
13
+ # 1. Create refs/vault-sync/recovery/<UTC timestamp> at current HEAD
14
+ # 2. git rebase --quit (never --abort, never raw rm -rf of sequencer)
15
+ # 3. Verify HEAD unchanged
16
+
17
+ # Returns 0 and prints classification on stdout.
18
+ vault_sync_rebase_state() {
19
+ local repo="${1:-.}"
20
+ local git_dir rebase_dir orig_head live_head unmerged rebase_head
21
+
22
+ if ! git_dir="$(git -C "$repo" rev-parse --git-dir 2>/dev/null)"; then
23
+ printf 'active\n'
24
+ return 0
25
+ fi
26
+ # Make absolute if relative
27
+ case "$git_dir" in
28
+ /*) ;;
29
+ *) git_dir="$repo/$git_dir" ;;
30
+ esac
31
+
32
+ rebase_dir=""
33
+ if [ -d "$git_dir/rebase-merge" ]; then
34
+ rebase_dir="$git_dir/rebase-merge"
35
+ elif [ -d "$git_dir/rebase-apply" ]; then
36
+ rebase_dir="$git_dir/rebase-apply"
37
+ else
38
+ printf 'none\n'
39
+ return 0
40
+ fi
41
+
42
+ # Unmerged paths → always active
43
+ unmerged="$(git -C "$repo" diff --name-only --diff-filter=U 2>/dev/null || true)"
44
+ if [ -n "$unmerged" ]; then
45
+ printf 'active\n'
46
+ return 0
47
+ fi
48
+
49
+ # REBASE_HEAD present with matching in-progress context → active
50
+ if git -C "$repo" rev-parse -q --verify REBASE_HEAD >/dev/null 2>&1; then
51
+ rebase_head="$(git -C "$repo" rev-parse REBASE_HEAD 2>/dev/null || true)"
52
+ live_head="$(git -C "$repo" rev-parse HEAD 2>/dev/null || true)"
53
+ # If REBASE_HEAD resolves and sequencer has stopped-sha / head-name, treat as active
54
+ # unless we can prove stale-clean (tip advanced past orig-head with clean tree).
55
+ if [ -f "$rebase_dir/orig-head" ] || [ -f "$rebase_dir/orig_head" ]; then
56
+ :
57
+ else
58
+ printf 'active\n'
59
+ return 0
60
+ fi
61
+ # REBASE_HEAD + clean tree but tip still at orig-head context → active
62
+ orig_head=""
63
+ if [ -f "$rebase_dir/orig-head" ]; then
64
+ orig_head="$(tr -d '[:space:]' < "$rebase_dir/orig-head" 2>/dev/null || true)"
65
+ elif [ -f "$rebase_dir/orig_head" ]; then
66
+ orig_head="$(tr -d '[:space:]' < "$rebase_dir/orig_head" 2>/dev/null || true)"
67
+ fi
68
+ if [ -n "$orig_head" ] && [ -n "$live_head" ]; then
69
+ if [ "$live_head" = "$orig_head" ] || [ "$live_head" = "$rebase_head" ]; then
70
+ printf 'active\n'
71
+ return 0
72
+ fi
73
+ # Tip advanced past orig-head with no unmerged paths → stale-clean
74
+ if git -C "$repo" merge-base --is-ancestor "$orig_head" "$live_head" 2>/dev/null \
75
+ && [ "$live_head" != "$orig_head" ]; then
76
+ # Also require clean index/worktree (no unstaged/staged beyond rebase)
77
+ if git -C "$repo" diff --quiet 2>/dev/null \
78
+ && git -C "$repo" diff --cached --quiet 2>/dev/null; then
79
+ printf 'stale-clean\n'
80
+ return 0
81
+ fi
82
+ fi
83
+ fi
84
+ # Fail closed: unknown REBASE_HEAD context is active
85
+ printf 'active\n'
86
+ return 0
87
+ fi
88
+
89
+ # No REBASE_HEAD. Sequencer dir may be empty/corrupt or left after tip advance.
90
+ orig_head=""
91
+ if [ -f "$rebase_dir/orig-head" ]; then
92
+ orig_head="$(tr -d '[:space:]' < "$rebase_dir/orig-head" 2>/dev/null || true)"
93
+ elif [ -f "$rebase_dir/orig_head" ]; then
94
+ orig_head="$(tr -d '[:space:]' < "$rebase_dir/orig_head" 2>/dev/null || true)"
95
+ fi
96
+ live_head="$(git -C "$repo" rev-parse HEAD 2>/dev/null || true)"
97
+
98
+ if [ -n "$orig_head" ] && [ -n "$live_head" ] && [ "$live_head" != "$orig_head" ]; then
99
+ if git -C "$repo" diff --quiet 2>/dev/null \
100
+ && git -C "$repo" diff --cached --quiet 2>/dev/null; then
101
+ printf 'stale-clean\n'
102
+ return 0
103
+ fi
104
+ fi
105
+
106
+ # Empty/corrupt sequencer with no REBASE_HEAD and tip == orig-head or no orig-head:
107
+ # treat as stale-clean only when tree is clean (legacy empty rebase-merge cleanup).
108
+ if [ -z "$(ls -A "$rebase_dir" 2>/dev/null || true)" ] \
109
+ || { [ -z "$orig_head" ] || [ "$live_head" = "$orig_head" ]; }; then
110
+ if git -C "$repo" diff --quiet 2>/dev/null \
111
+ && git -C "$repo" diff --cached --quiet 2>/dev/null; then
112
+ # Empty dir or tip still at orig-head with no REBASE_HEAD → safe quit
113
+ printf 'stale-clean\n'
114
+ return 0
115
+ fi
116
+ fi
117
+
118
+ # Fail closed
119
+ printf 'active\n'
120
+ return 0
121
+ }
122
+
123
+ # Clear stale-clean rebase state without moving HEAD.
124
+ # Returns 0 on success, 1 if active / fail-closed / HEAD moved.
125
+ vault_sync_clear_stale_rebase() {
126
+ local repo="${1:-.}"
127
+ local state pre_head post_head recovery_ref ts
128
+
129
+ state="$(vault_sync_rebase_state "$repo")"
130
+ case "$state" in
131
+ none)
132
+ return 0
133
+ ;;
134
+ active)
135
+ return 1
136
+ ;;
137
+ stale-clean)
138
+ ;;
139
+ *)
140
+ return 1
141
+ ;;
142
+ esac
143
+
144
+ pre_head="$(git -C "$repo" rev-parse HEAD 2>/dev/null)" || return 1
145
+ ts="$(date -u +%Y%m%dT%H%M%SZ)"
146
+ recovery_ref="refs/vault-sync/recovery/${ts}"
147
+ if ! git -C "$repo" update-ref "$recovery_ref" "$pre_head" 2>/dev/null; then
148
+ return 1
149
+ fi
150
+
151
+ # Prefer git rebase --quit (Git 2.12+). Never abort (would reset to orig-head).
152
+ if ! git -C "$repo" rebase --quit 2>/dev/null; then
153
+ # If quit fails but sequencer remains, fail closed — do not rm -rf.
154
+ return 1
155
+ fi
156
+
157
+ post_head="$(git -C "$repo" rev-parse HEAD 2>/dev/null)" || return 1
158
+ if [ "$pre_head" != "$post_head" ]; then
159
+ # Attempt to restore tip if quit somehow moved HEAD (should not happen).
160
+ git -C "$repo" update-ref HEAD "$pre_head" 2>/dev/null || true
161
+ return 1
162
+ fi
163
+
164
+ # Confirm sequencer gone; if still present after quit, fail closed.
165
+ if [ -d "$(git -C "$repo" rev-parse --git-dir 2>/dev/null)/rebase-merge" ] \
166
+ || [ -d "$(git -C "$repo" rev-parse --git-dir 2>/dev/null)/rebase-apply" ]; then
167
+ # Re-resolve absolute git dir
168
+ local git_dir
169
+ git_dir="$(git -C "$repo" rev-parse --git-dir 2>/dev/null)"
170
+ case "$git_dir" in
171
+ /*) ;;
172
+ *) git_dir="$repo/$git_dir" ;;
173
+ esac
174
+ if [ -d "$git_dir/rebase-merge" ] || [ -d "$git_dir/rebase-apply" ]; then
175
+ return 1
176
+ fi
177
+ fi
178
+
179
+ return 0
180
+ }