-
Notifications
You must be signed in to change notification settings - Fork 15
Expand file tree
/
Copy pathsupertag-git.el
More file actions
2094 lines (1945 loc) · 114 KB
/
Copy pathsupertag-git.el
File metadata and controls
2094 lines (1945 loc) · 114 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
;;; supertag-git.el --- Git integration for the supertag-db semantic merge driver -*- lexical-binding: t; -*-
;;; Commentary:
;;
;; S3b of .phrase/phases/phase-git-sync-20260713/PLAN.md ("S3 语义 merge
;; driver" -> everything except the pure merge core itself, which is
;; `supertag-merge.el', S3a). This file wires that pure core into a real git
;; checkout: `.gitattributes', **per-clone** `.git/config' merge-driver
;; configuration, `.gitignore' for local-only state, and the
;; `supertag-git-check' diagnostic plist consumed by both `supertag-git-setup'
;; itself and `supertag-doctor'.
;;
;; ## Scope: the full S4 "one URL" user journey, end-to-end
;;
;; This file implements the whole of the plan's "S4 用户旅程" (S3b's
;; original per-clone-plumbing scope, described just below, plus
;; everything S4 added on top of it): repo-layout migration (moving
;; `supertag-db-file' into a vault-rooted `.supertag/' directory --
;; `supertag-git--migrate-layout'), machine 1's `supertag-git-setup'
;; driving that migration through an initial commit, `remote add`/
;; `set-url', and `push -u`, machine N's `supertag-git-clone', and the
;; opt-in `supertag-git-sync-mode' auto commit/fetch/merge/push loop. See
;; the plan's "S4 用户旅程" section for the full user-facing narrative.
;;
;; ## V1 limitation: single-root vaults only
;;
;; Per the plan's explicit decision, git sync in this version only supports
;; a vault backed by a SINGLE `supertag-sync-directories' root (or none
;; configured at all, in which case the database's own directory is used).
;; Multiple configured roots are refused outright rather than guessed at —
;; seeing `.phrase/phases/phase-git-sync-20260713/PLAN.md' section "S4 用户
;; 旅程" / "V1 限制" is the documented escape hatch (consolidate to one root,
;; or do not enable git sync yet).
;;
;; ## Why the driver must be configured on EVERY clone
;;
;; `.gitattributes' (which path uses which merge driver) is a tracked file
;; and travels with the repository. `merge.<name>.driver' (what command that
;; driver actually runs) lives in `.git/config', which git NEVER syncs
;; between clones — and the command line this file writes there embeds an
;; absolute, machine-local path to wherever `supertag-merge.el' happens to
;; live on THIS machine (found via `locate-library', which walks
;; `load-path'). This is, per the plan, the single easiest step to forget;
;; `supertag-git-check' (and `supertag-doctor' section 8) exist specifically
;; to make the omission visible instead of silently degrading (see
;; "Degradation" below).
;;
;; ## Degradation when the driver is NOT configured
;;
;; This is not a bolted-on fallback; it falls directly out of S2's
;; canonical, one-entity-per-line database format (see the commentary above
;; `supertag--persistence-canonical-format-header' in
;; supertag-core-persistence.el). Even with `.gitattributes' pointing at an
;; undefined driver name (or no `.gitattributes' entry at all), git's own
;; default line-oriented 3-way text merge already converges cleanly whenever
;; two sides touched DIFFERENT entities/lines — only an edit to the SAME
;; line (the same entity) produces literal `<<<<<<<'/`=======' /`>>>>>>>'
;; markers inside the file. `supertag-core-persistence.el''s loader
;; (`supertag--persistence--try-read-store', via
;; `supertag--persistence--buffer-has-conflict-markers-p') detects that case
;; and refuses to load the file with a message pointing back at
;; `supertag-git-setup' and `git checkout --merge', rather than silently
;; treating a conflict-marked file as an empty store (see that file's
;; commentary for how this interacts with the candidate-fallback/
;; protective-skip load and save paths).
;;
;; ## Package-manager independence
;;
;; This file is intentionally NOT part of the same require chain as
;; `supertag.el' proper (no `org' integration, no UI) so that
;; `supertag-git-check' stays cheap to call from `supertag-doctor', and so
;; the driver command line this file writes only ever needs `-L
;; <package-dir>' (see `supertag-git--package-dir'), matching the
;; independently-documented invocation at the top of `supertag-merge.el'.
;;; Code:
(require 'cl-lib)
(require 'supertag-core-persistence)
(defgroup supertag-git nil
"Git integration for Supertag's semantic database merge driver."
:group 'supertag)
;; --- Free-variable declarations for symbols owned by supertag.el /
;; supertag-services-sync.el ---
;;
;; This file deliberately does NOT `require' `supertag' or
;; `supertag-services-sync' (see the Commentary, "Package-manager
;; independence"): every reference to a symbol owned by either of those
;; files below is guarded at runtime with `boundp'/`fboundp' and simply
;; degrades to a no-op when that fuller package has not been loaded (e.g. a
;; standalone batch test of just this file). A plain, value-less `defvar'
;; changes nothing about that behavior; it only tells the byte-compiler
;; these symbols are genuinely special (dynamically bound) variables, which
;; matters in exactly one place below: `supertag-git--apply-data-directory-wiring'
;; binds `supertag--config-guard-allow' with a plain `let' to replicate
;; `supertag-config-guard--with-allow' (supertag.el's own macro, which
;; this file cannot call directly without requiring that whole package —
;; see that function's docstring). Under `lexical-binding: t' (this file's
;; default), a `let' on an UNDECLARED symbol creates an ordinary lexical
;; binding instead of a dynamic one — invisible to supertag.el's own
;; `supertag-config-guard--watch', which reads the symbol dynamically —
;; silently defeating the bypass in compiled code specifically (interpreted
;; code would happen to work, which is exactly the kind of latent
;; compile/interpret divergence this declaration exists to prevent; see
;; test/git-integration-test.el's identical technique/rationale for
;; `supertag-sync-directories'). Plain reads/writes of the other
;; symbols below never need this treatment (proven empirically: a
;; `boundp'/`fboundp'-guarded read or `setq' of an undeclared symbol
;; compiles warning-free; only `let'-binding one does not).
(defvar supertag--config-guard-allow)
;; `supertag-load-store'/`supertag-save-store'/`supertag-store-get-collection'
;; are real functions already pulled in transitively by the top-level
;; `(require 'supertag-core-persistence)' above -- no declaration needed.
;; Everything below genuinely lives in files this file does not require.
(declare-function supertag-config-guard--capture "supertag")
(declare-function supertag-vault--vault-mode-p "supertag")
(declare-function supertag-reindex-org "supertag-services-sync")
(declare-function supertag-sync-check-now "supertag-services-sync")
(declare-function supertag-sync--process-single-file "supertag-services-sync")
(declare-function supertag-sync-save-state "supertag-services-sync")
;;; --- Small git process helpers ---
(defun supertag-git--executable ()
"Return the git executable path, or nil if not found on `exec-path'."
(executable-find "git"))
(defun supertag-git--run (dir &rest args)
"Run git ARGS with DIR as the repository/working directory (`git -C DIR').
Returns (EXIT-CODE . TRIMMED-OUTPUT). Signals an error if git itself cannot
be found (this is a hard prerequisite, not a soft/degrade-able condition —
unlike a missing merge driver, there is no meaningful fallback for
\"no git binary\")."
(let ((git (supertag-git--executable)))
(unless git
(error "supertag-git: `git' executable not found on `exec-path' -- install git first"))
(with-temp-buffer
(let ((exit (apply #'call-process git nil t nil "-C" (expand-file-name dir) args)))
(cons exit (string-trim (buffer-string)))))))
(defun supertag-git--ok-p (result)
"Return non-nil if RESULT (an (EXIT . OUTPUT) cons from `supertag-git--run')
succeeded."
(eq 0 (car result)))
(defun supertag-git--repo-toplevel (dir)
"Return the git worktree toplevel containing DIR, or nil if DIR is not
inside any git worktree (or git is unavailable, or DIR does not exist)."
(when (and (supertag-git--executable) (file-directory-p dir))
(let ((result (supertag-git--run dir "rev-parse" "--show-toplevel")))
(when (supertag-git--ok-p result)
(file-name-as-directory (expand-file-name (cdr result)))))))
(defun supertag-git--truename-dir (dir)
"Return DIR as an absolute, trailing-slash-terminated truename.
Using `file-truename' (not just `expand-file-name') matters on macOS in
particular, where temp directories often live under a `/var' that is
itself a symlink to `/private/var' -- a plain string-prefix ancestor check
without resolving that symlink would spuriously fail."
(file-name-as-directory (file-truename (expand-file-name dir))))
(defun supertag-git--ancestor-p (root path)
"Return non-nil if ROOT is PATH itself, or an ancestor directory of PATH."
(let ((r (supertag-git--truename-dir root))
(p (supertag-git--truename-dir path)))
(string-prefix-p r p)))
(defun supertag-git--set-config (dir key value)
"Set git config KEY to VALUE in DIR's repository (local, i.e. `.git/config' —
never `--global'). Signals an error on failure."
(let ((result (supertag-git--run dir "config" key value)))
(unless (supertag-git--ok-p result)
(error "supertag-git: `git config %s' in %s failed: %s" key dir (cdr result)))))
(defun supertag-git--get-config (dir key)
"Return git config KEY's value in DIR's repository, or nil if unset/on error."
(let ((result (supertag-git--run dir "config" "--get" key)))
(when (supertag-git--ok-p result) (cdr result))))
(defun supertag-git--init-repo (root)
"Run `git init' at ROOT (creating ROOT first if needed). Signals on failure."
(unless (file-directory-p root)
(make-directory root t))
(let ((result (supertag-git--run root "init")))
(unless (supertag-git--ok-p result)
(error "supertag-git: `git init' at %s failed: %s" root (cdr result)))))
;;; --- Sync-root configuration (V1 single-root check) ---
(defun supertag-git--sync-roots ()
"Return the configured, non-empty `supertag-sync-directories' entries.
Returns nil when the variable is unbound, nil, or contains no usable
strings -- all treated the same as \"no configured root\"."
(when (and (boundp 'supertag-sync-directories)
(listp supertag-sync-directories))
(delq nil (mapcar (lambda (d) (and (stringp d) (> (length d) 0) d))
supertag-sync-directories))))
(defun supertag-git--multiple-sync-roots-p ()
"Return non-nil if more than one sync root is configured.
This is the V1-unsupported case."
(> (length (supertag-git--sync-roots)) 1))
;;; --- Package directory / driver command (machine-local, per-clone) ---
(defun supertag-git--package-dir ()
"Return the absolute directory containing `supertag-merge.el' on THIS machine.
Found via `locate-library', which walks `load-path' without loading the
file. Recomputed fresh every call (never cached to a synced file) — the
whole reason each clone needs its own `.git/config' entry is that this
path is machine-local."
(let ((lib (locate-library "supertag-merge")))
(unless lib
(error "supertag-git: cannot locate supertag-merge.el on `load-path' -- is supertag properly installed?"))
(file-name-directory (expand-file-name lib))))
(defun supertag-git--driver-command (package-dir)
"Return the `merge.supertag-db.driver' command line for PACKAGE-DIR.
Built with `concat', not `format': the literal `%O'/`%A'/`%B' git
placeholders in the tail are not `format' directives, and passing them
through `format' would error (`%O' is not a recognized spec)."
(concat "emacs -Q --batch -L " (shell-quote-argument (directory-file-name package-dir))
" -l supertag-merge.el -f supertag-merge-driver-main %O %A %B"))
;;; --- .gitattributes / .gitignore idempotent line management ---
(defun supertag-git--file-lines (file)
"Return FILE's contents as a list of lines, or nil if FILE does not exist."
(when (file-exists-p file)
(with-temp-buffer
(insert-file-contents file)
(split-string (buffer-string) "\n" t))))
(defun supertag-git--ensure-lines (file lines)
"Ensure each of LINES appears verbatim somewhere in FILE, appending any
that are missing (creating FILE, and its parent directory, if absent).
Returns the sublist of LINES that were newly appended -- empty when FILE
already contained all of them, which is what makes callers of this
function (`supertag-git--configure-clone') idempotent: running setup twice
never duplicates a line."
(let* ((existing (supertag-git--file-lines file))
(missing (cl-remove-if (lambda (l) (member l existing)) lines)))
(when missing
(let ((dir (file-name-directory file)))
(unless (file-directory-p dir) (make-directory dir t)))
(with-temp-buffer
(when (file-exists-p file)
(insert-file-contents file))
(goto-char (point-max))
(unless (or (= (point-min) (point-max)) (bolp))
(insert "\n"))
(dolist (l missing) (insert l "\n"))
(write-region (point-min) (point-max) file nil 'silent)))
missing))
;;; --- Per-clone configuration core (shared by the command and tests) ---
(defun supertag-git--configure-clone (repo-root db-file)
"Idempotently configure THIS clone's repository at REPO-ROOT to
semantically merge DB-FILE via `supertag-merge.el'.
This is the single code path used by both the interactive
`supertag-git-setup' command and the git-integration test suite's E2E
harness (`supertag-git--configure-clone' factored out specifically so
tests exercise the exact same configuration logic setup does, per the
plan).
Only touches `.gitattributes' and `.gitignore' (working-tree files, safe
to `git add') plus THIS repo's local `.git/config' -- never runs `git add'
or `git commit' itself, and never requires DB-FILE to already exist.
Returns a plist:
(:repo-root ROOT :relative-path REL
:gitattributes-file FILE :gitattributes-added (LINE ...)
:driver-package-dir DIR :driver-command CMD
:gitignore-file FILE :gitignore-added (LINE ...))
The two *-added slots list only the lines that were newly appended by
THIS call -- empty on a re-run, which is how idempotence is verified.
REPO-ROOT and DB-FILE are both resolved through `file-truename' before
the relative `.gitattributes' path is computed: REPO-ROOT frequently
arrives as a truename already (everything derived from
`supertag-git--repo-toplevel' is -- git prints resolved paths), while
DB-FILE typically arrives as the user-configured, possibly
symlink-traversing spelling (`/tmp/...' vs `/private/tmp/...' on macOS
being the canonical example). Mixing the two produces a
repository-escaping `../../..' relative path, which `.gitattributes'
matches against NOTHING -- the semantic merge driver then silently never
applies. Found by a real-environment E2E run, not code review."
(let* ((root (supertag-git--truename-dir repo-root))
(rel (file-relative-name (file-truename (expand-file-name db-file)) root))
(attrs-file (expand-file-name ".gitattributes" root))
(attrs-line (format "%s merge=supertag-db" rel))
(attrs-added (supertag-git--ensure-lines attrs-file (list attrs-line)))
(package-dir (directory-file-name (supertag-git--package-dir)))
(driver-cmd (supertag-git--driver-command package-dir)))
(supertag-git--set-config root "merge.supertag-db.name"
"Supertag semantic 3-way database merge")
(supertag-git--set-config root "merge.supertag-db.driver" driver-cmd)
(let* ((db-dir (file-name-directory (expand-file-name db-file)))
(ignore-file (expand-file-name ".gitignore" db-dir))
(ignore-added (supertag-git--ensure-lines
ignore-file
(list "sync-state.el" "backups/" "supertag-presence.json" ".#*"))))
(list :repo-root root
:relative-path rel
:gitattributes-file attrs-file
:gitattributes-added attrs-added
:driver-package-dir package-dir
:driver-command driver-cmd
:gitignore-file ignore-file
:gitignore-added ignore-added))))
;;; --- S4 vault layout migration (DB must live INSIDE the repo) ---
;;
;; Per the plan's "S4 用户旅程" -> "仓库布局": the merge driver requires the
;; database file to be a tracked, in-repo path, but the pre-S4 common case
;; is a database living at `~/.emacs.d/supertag/' -- entirely outside
;; whatever git repository the user's `.org' vault lives in. Migration is:
;;
;; 0. if the LIVE in-memory store has unsaved changes (`supertag-dirty-p'),
;; flush them to the OLD file first via the normal `supertag-save-store'
;; path, then re-check `supertag-dirty-p' -- a save that is silently
;; skipped (guard violation, lock conflict, protective empty-store
;; skip, ...) still leaves the flag set, and that must ABORT the
;; migration rather than let step 1 below copy a database that does
;; not reflect what is actually in memory (see
;; `supertag--persistence-guard-violations' for the reasons named in
;; the abort message).
;; 1. write the NEW database from the CURRENT in-memory store directly,
;; via `supertag--persistence-write-store-atomically' -- never a
;; `copy-file' of whatever the OLD file happens to contain on disk,
;; which (even after step 0's flush) is one extra hop removed from
;; "what is actually in memory right now". `copy-file' is used ONLY
;; as a fallback for the case where no store has been loaded into
;; this session at all yet (so there is no in-memory store to
;; serialize from).
;; 2. verify the new file is loadable (never trust a bare `copy-file' or
;; a bare atomic write without reading it back)
;; 3. only once verified: flip this session's persistence wiring
;; (`supertag-data-directory'/`supertag-db-file'/etc, the same
;; config-guard-bypassed pattern `supertag-vault--apply' uses),
;; reload the live store from the new location, and confirm BOTH that
;; the reload's recorded origin status is `:ok' AND (when a store was
;; already loaded pre-migration) that its node count matches the
;; pre-migration count captured in step 0 -- any disagreement aborts
;; exactly like a failed verification would.
;; 4. only once step 3 has itself fully succeeded: rename (never delete)
;; the OLD database file to a `.migrated-<timestamp>' tombstone
;;
;; Any failure at or after the wiring flip in step 3 restores this
;; session's wiring to its pre-migration snapshot (`supertag-git--capture-wiring'
;; / `supertag-git--restore-wiring') and reloads the store from the
;; (now-restored) OLD file, via an `unwind-protect' state machine -- so a
;; failure never leaves the session pointed at a new file while the OLD
;; file (and whatever real data it holds) sits untouched and unreferenced.
;; Any failure BEFORE the wiring flip leaves the OLD file completely
;; untouched (no wiring changed, nothing renamed) -- see
;; `supertag-git--do-migrate-layout'.
(defun supertag-git--apply-data-directory-wiring (data-dir)
"Point this session's persistence variables at DATA-DIR (a directory).
Mirrors `supertag-vault--apply' in supertag.el (config-guard bypassed
via a dynamic `let' on `supertag--config-guard-allow', then the guard's
expected-state snapshot re-captured so it agrees with the new values) --
but computes DATA-DIR itself very differently: `supertag-vault--apply'
always derives a vault's data directory from a content hash of its sync
root under the shared base data directory (`vaults/<id>/'), which does not
match what git sync requires (`<repo-root>/.supertag/', a *predictable*,
human-inspectable, in-repo path). This function does not call
`supertag-vault--apply' or replicate its vault-registry bookkeeping at
all — see this file's Commentary for why (V1 is single-root only, and
`supertag.el' is deliberately not part of this file's require chain).
Safe to call whether or not `supertag.el' has been loaded at all: the
config-guard bypass and vault-mode bookkeeping are both best-effort no-ops
in that case (this matters for this file's own standalone batch tests)."
(let ((data-dir (file-name-as-directory (expand-file-name data-dir))))
(let ((supertag--config-guard-allow t))
(setq supertag-data-directory data-dir)
(setq supertag-db-file (expand-file-name "supertag-db.el" data-dir))
(setq supertag-db-backup-directory (expand-file-name "backups" data-dir))
(when (boundp 'supertag-sync-state-file)
(setq supertag-sync-state-file (expand-file-name "sync-state.el" data-dir))))
(when (fboundp 'supertag-config-guard--capture)
(funcall #'supertag-config-guard--capture))
;; Best-effort: keep the single-root "registry" (there is no other one
;; in this V1 -- see the Commentary on `supertag-sync-directories'
;; being the vault registry) pointed at the vault root this data
;; directory now belongs to, when vault mode happens to be active.
(when (and (fboundp 'supertag-vault--vault-mode-p)
(funcall #'supertag-vault--vault-mode-p)
(boundp 'supertag-active-sync-directory))
(let ((roots (supertag-git--sync-roots)))
(when (= (length roots) 1)
(let ((supertag--config-guard-allow t))
(setq supertag-active-sync-directory (car roots))))))))
(defun supertag-git--capture-wiring ()
"Return a plist snapshotting this session's persistence wiring variables,
so `supertag-git--restore-wiring' can put them back if a migration step
fails AFTER `supertag-git--apply-data-directory-wiring' has already
flipped them. Mirrors exactly the set of variables that function writes."
(list :data-directory (and (boundp 'supertag-data-directory) supertag-data-directory)
:db-file (and (boundp 'supertag-db-file) supertag-db-file)
:db-backup-directory (and (boundp 'supertag-db-backup-directory)
supertag-db-backup-directory)
:sync-state-file (and (boundp 'supertag-sync-state-file)
supertag-sync-state-file)
:active-sync-directory (and (boundp 'supertag-active-sync-directory)
supertag-active-sync-directory)))
(defun supertag-git--restore-wiring (snapshot)
"Restore persistence wiring variables from SNAPSHOT (as returned by
`supertag-git--capture-wiring'), bypassing the config guard the same way
`supertag-git--apply-data-directory-wiring' does -- this is the undo half
of that function, used when a migration fails after the wiring was
already flipped (see `supertag-git--do-migrate-layout')."
(let ((supertag--config-guard-allow t))
(when (boundp 'supertag-data-directory)
(setq supertag-data-directory (plist-get snapshot :data-directory)))
(when (boundp 'supertag-db-file)
(setq supertag-db-file (plist-get snapshot :db-file)))
(when (boundp 'supertag-db-backup-directory)
(setq supertag-db-backup-directory (plist-get snapshot :db-backup-directory)))
(when (boundp 'supertag-sync-state-file)
(setq supertag-sync-state-file (plist-get snapshot :sync-state-file)))
(when (boundp 'supertag-active-sync-directory)
(setq supertag-active-sync-directory (plist-get snapshot :active-sync-directory))))
(when (fboundp 'supertag-config-guard--capture)
(funcall #'supertag-config-guard--capture)))
(defun supertag-git--do-migrate-layout (old-db-file root)
"Perform the flush/write/verify/wire/reload/tombstone sequence, migrating
OLD-DB-FILE into `<ROOT>/.supertag/supertag-db.el'.
Only ever called by `supertag-git--migrate-layout' once ROOT has already
been confirmed to be the single configured sync root and NOT already an
ancestor directory of OLD-DB-FILE. Returns a plist; see that function's
docstring for the full set of possible `:status' values. Never signals --
every failure mode (including one deliberately injected by a test, e.g. an
unwritable target directory, an unsaveable dirty store, or a post-reload
node-count mismatch) is caught and reported as `:status :failed' plus a
human-readable `:error' string, with the OLD file provably untouched and
this session's wiring provably restored whenever it had already been
flipped (see this file's Commentary above this section for the full
sequence)."
(condition-case err
(let* ((target-dir (expand-file-name ".supertag/" root))
(target-db (expand-file-name "supertag-db.el" target-dir))
(old-exists (file-exists-p old-db-file))
(store-loaded-p (and (boundp 'supertag--store) (hash-table-p supertag--store)))
(pre-migration-node-count (and store-loaded-p (supertag--count-nodes))))
(when (file-exists-p target-db)
(error "migration target %s already exists -- resolve manually before retrying (are two migrations racing?)"
target-db))
(when (and old-exists store-loaded-p)
(let ((reasons (supertag--persistence-guard-violations old-db-file)))
(when reasons
(error "refusing to migrate the in-memory database: %s"
(mapconcat #'identity reasons "; ")))))
(unless (file-directory-p root)
(make-directory root t))
(unless (supertag-git--repo-toplevel root)
(supertag-git--init-repo root))
(let ((toplevel (or (supertag-git--repo-toplevel root)
(error "`git init' at %s did not result in a git worktree -- this should not happen"
root))))
(make-directory target-dir t)
(if (not old-exists)
;; Fresh vault: nothing on disk to copy/verify/tombstone yet --
;; just point the wiring at the new location so the rest of
;; `supertag-git-setup' (and the very next save) creates the
;; database there directly. Nothing destructive happens here
;; (no old file to lose, no wiring to restore on failure).
(progn
(supertag-git--apply-data-directory-wiring target-dir)
(list :status :migrated :old-db-file old-db-file :new-db-file target-db
:repo-root toplevel :tombstone nil :fresh-vault t))
;; Step 0: flush any unsaved in-memory changes to the OLD file
;; first, via the normal save path -- then verify the flush
;; actually cleared the dirty flag. A save can be silently
;; skipped (guard violation, lock conflict, the protective
;; empty-store skip, ...) without signaling anything, which is
;; why this checks `supertag-dirty-p' again afterward instead of
;; trusting `supertag-save-store' to have either raised or
;; succeeded.
(when (supertag-dirty-p)
(when (fboundp 'supertag-save-store)
(funcall #'supertag-save-store old-db-file))
(when (supertag-dirty-p)
(error "refusing to migrate: the in-memory database has unsaved changes that could not be saved first (%s) -- resolve the issue (see M-x supertag-doctor), save manually, and retry"
(let ((reasons (supertag--persistence-guard-violations old-db-file)))
(if reasons
(mapconcat #'identity reasons "; ")
"save was skipped for an unlogged reason -- see *Messages* for the most recent \"Supertag\" message")))))
;; Step 1: write the NEW file from the CURRENT in-memory store
;; directly when one is loaded (never a `copy-file' of the OLD
;; file, which even post-flush is one hop removed from "what is
;; actually in memory") -- `copy-file' is the fallback ONLY for
;; the no-store-loaded-yet case.
(if store-loaded-p
(supertag--persistence-write-store-atomically target-db)
(copy-file old-db-file target-db nil t))
;; Step 2: verify the new file is actually loadable BEFORE
;; touching any wiring or the old file -- a write/copy that
;; "succeeded" onto a filesystem that silently truncates, or a
;; target that was already subtly corrupt, must not be trusted
;; just because the syscall returned.
(let ((verify-error nil))
(condition-case verr
(unless (hash-table-p (supertag--persistence--try-read-store target-db))
(setq verify-error "migrated database did not parse into a store hash table"))
(error (setq verify-error (error-message-string verr))))
(when verify-error
(ignore-errors (delete-file target-db))
(error "migrated database at %s failed verification: %s" target-db verify-error)))
;; Verified loadable: only now flip the wiring. Everything from
;; here on is guarded by an `unwind-protect' that restores the
;; wiring (and reloads the store from the OLD file) unless the
;; whole sequence, including the tombstone rename, reaches
;; `:committed'.
(let ((wiring-snapshot (supertag-git--capture-wiring))
(committed nil))
(unwind-protect
(progn
(supertag-git--apply-data-directory-wiring target-dir)
;; Reload the live store from the new location (this
;; also re-acquires the multi-instance lock and
;; refreshes cross-machine presence for the new path,
;; via `supertag-load-store' itself).
(when (fboundp 'supertag-load-store)
(funcall #'supertag-load-store))
;; Step 3: the reload must report `:ok', and -- when a
;; store was already loaded pre-migration -- its node
;; count must match the pre-migration count captured
;; above. Either failing means the reload landed on
;; something other than what was actually migrated;
;; never tombstone the old file in that case.
(let ((origin-status (and (boundp 'supertag--store-origin)
(plist-get supertag--store-origin :status)))
(post-node-count (supertag--count-nodes)))
(unless (eq origin-status :ok)
(error "reload of the migrated database at %s reported status %s (expected :ok)"
target-db origin-status))
(when (and store-loaded-p
(numberp pre-migration-node-count)
(/= post-node-count pre-migration-node-count))
(error "node count mismatch after migration reload: had %d node(s) before, %d after (%s) -- refusing to tombstone the old database"
pre-migration-node-count post-node-count target-db)))
;; Step 4: only after a successful reload AND a matching
;; node count do we touch the OLD file at all -- renamed
;; to a tombstone, NEVER deleted.
(let* ((timestamp (format-time-string "%Y%m%d%H%M%S"))
(tombstone (concat old-db-file ".migrated-" timestamp)))
(condition-case terr
(rename-file old-db-file tombstone)
(error
(message "supertag-git: migration to %s succeeded, but renaming the old database (%s) to a tombstone failed: %s -- the old file is harmlessly left in place (it is no longer read by anything)."
target-db old-db-file (error-message-string terr))
(setq tombstone nil)))
(setq committed t)
(list :status :migrated :old-db-file old-db-file :new-db-file target-db
:repo-root toplevel :tombstone tombstone)))
(unless committed
;; Something after the wiring flip failed: restore the
;; wiring to its pre-migration snapshot and reload the
;; store from the (now-restored) OLD file, so this
;; session's in-memory state matches its wiring again --
;; never left pointed at a new file it just gave up on
;; while `supertag-db-file' itself already says otherwise.
;; Also remove the unverified/unused target so a retry
;; does not trip the "target already exists" guard above.
(supertag-git--restore-wiring wiring-snapshot)
(when (fboundp 'supertag-load-store)
(funcall #'supertag-load-store))
(ignore-errors (delete-file target-db))))))))
(error
(list :status :failed :old-db-file old-db-file :repo-root root
:error (error-message-string err)))))
(defun supertag-git--migrate-layout ()
"Migrate `supertag-db-file' into the git vault layout, if needed and
possible; a safe, idempotent no-op otherwise.
Returns a plist with at least `:status' and `:old-db-file'. `:status' is
one of:
:skipped-in-repo `supertag-db-file' is already inside SOME git
worktree (whether or not that happens to be
`<root>/.supertag/' specifically -- an
already-tracked-elsewhere-in-repo database is
left exactly where it is, matching
`supertag-git-setup''s pre-existing
behavior). Idempotent: this is what every
re-run after a successful migration reports.
:skipped-no-root no `supertag-sync-directories' root is
configured to migrate towards.
:skipped-multi-root more than one root is configured (V1
limitation; refused elsewhere by
`supertag-git-setup--pick-root' too).
:skipped-root-missing the single configured root does not exist as
a directory yet.
:skipped-already-under-root the single configured root already contains
`supertag-db-file' -- no relocation needed;
`supertag-git-setup--pick-root' will
find/init the repo at ROOT itself.
:migrated the migration (or, for a not-yet-existing
database, the wiring switch alone) succeeded;
see `:new-db-file'/`:tombstone'/`:repo-root'.
:failed see `:error' (a human-readable string); the
OLD file is guaranteed untouched (see
`supertag-git--do-migrate-layout')."
(let* ((old-db-file (expand-file-name supertag-db-file))
(old-db-dir (file-name-directory old-db-file)))
(if (supertag-git--repo-toplevel old-db-dir)
(list :status :skipped-in-repo :old-db-file old-db-file)
(let ((roots (supertag-git--sync-roots)))
(cond
((> (length roots) 1)
(list :status :skipped-multi-root :old-db-file old-db-file))
((null roots)
(list :status :skipped-no-root :old-db-file old-db-file))
(t
(let ((root (file-name-as-directory (expand-file-name (car roots)))))
(cond
((not (file-directory-p root))
(list :status :skipped-root-missing :old-db-file old-db-file :repo-root root))
((supertag-git--ancestor-p root old-db-dir)
(list :status :skipped-already-under-root :old-db-file old-db-file :repo-root root))
(t
(supertag-git--do-migrate-layout old-db-file root))))))))))
;;; --- supertag-git-check: non-interactive diagnostic ---
(defun supertag-git-check (&optional file)
"Return a plist describing this clone's git-sync configuration state.
FILE defaults to `supertag-db-file'. Used by both `supertag-git-setup'
\(to decide what needs doing) and `supertag-doctor' (to report status).
Keys:
:in-repo-p DB's directory is inside a git worktree
:repo-root that worktree's toplevel, or nil
:relative-path DB path relative to :repo-root, or nil
:driver-configured-p `merge.supertag-db.driver' set in THIS clone
:gitattributes-entry-present-p `.gitattributes' has the DB's merge= line
:db-tracked-p DB path is tracked by git (`git ls-files')
:remote-configured-p at least one `git remote' is configured
:multiple-sync-roots-p V1-unsupported multi-root vault configured"
(let* ((db-file (expand-file-name (or file supertag-db-file)))
(db-dir (file-name-directory db-file))
(multi (supertag-git--multiple-sync-roots-p))
(toplevel (supertag-git--repo-toplevel db-dir)))
(if (not toplevel)
(list :in-repo-p nil :repo-root nil :relative-path nil
:driver-configured-p nil :gitattributes-entry-present-p nil
:db-tracked-p nil :remote-configured-p nil
:multiple-sync-roots-p multi)
;; Truename BOTH sides of the relative-path computation, for the same
;; symlink-mismatch reason documented on `supertag-git--configure-clone'
;; (whose `.gitattributes' entry this :relative-path is compared
;; against): `toplevel' is already a truename (git resolves it), so a
;; non-truenamed DB-FILE under a symlinked prefix would yield a bogus
;; `../..'-escaping rel here and a spurious "entry missing" report.
(let* ((rel (file-relative-name (file-truename db-file) toplevel))
(driver (supertag-git--get-config toplevel "merge.supertag-db.driver"))
(attrs-lines (supertag-git--file-lines (expand-file-name ".gitattributes" toplevel)))
(attrs-present (cl-some
(lambda (l) (string-match-p
(concat "^" (regexp-quote rel) "[ \t]+merge=supertag-db\\'")
l))
attrs-lines))
(tracked (supertag-git--ok-p
(supertag-git--run toplevel "ls-files" "--error-unmatch" "--" rel)))
(remotes (supertag-git--run toplevel "remote")))
(list :in-repo-p t
:repo-root toplevel
:relative-path rel
:driver-configured-p (and (stringp driver) (> (length driver) 0) t)
:gitattributes-entry-present-p (and attrs-present t)
:db-tracked-p tracked
:remote-configured-p (and (supertag-git--ok-p remotes)
(> (length (cdr remotes)) 0))
:multiple-sync-roots-p multi)))))
;;; --- supertag-git-setup: interactive command ---
(defconst supertag-git--report-buffer-name "*Supertag Git Setup*"
"Name of the buffer used to render the `supertag-git-setup' report.")
(defun supertag-git-setup--pick-root (db-dir)
"Return the repo root to `git init' at for DB-DIR, or signal a clear error.
Refuses (rather than guessing) when: multiple sync roots are configured
\(V1 limitation); a single sync root is configured but does not actually
contain DB-DIR (the vault-layout requirement -- git sync needs the
database physically inside the chosen repo); or no root can be inferred
and we cannot prompt (`noninteractive')."
(when (supertag-git--multiple-sync-roots-p)
(user-error "supertag-git-setup: refusing -- `supertag-sync-directories' configures multiple roots (%s). Git sync (V1) only supports a single-root vault; see .phrase/phases/phase-git-sync-20260713/PLAN.md \"S4 用户旅程\" / \"V1 限制\" for how to consolidate."
(mapconcat #'identity (supertag-git--sync-roots) ", ")))
(let ((roots (supertag-git--sync-roots)))
(cond
((= (length roots) 1)
(let ((root (file-name-as-directory (expand-file-name (car roots)))))
(unless (supertag-git--ancestor-p root db-dir)
(user-error "supertag-git-setup: refusing -- the configured sync root %s does not contain the database directory %s. Git sync (V1) requires the database to live inside the single vault root; see .phrase/phases/phase-git-sync-20260713/PLAN.md \"S4 用户旅程\" / \"仓库布局\"."
root db-dir))
root))
(noninteractive
(error "supertag-git-setup: %s is not inside a git repository and no usable `supertag-sync-directories' root is configured to infer one; run this interactively to be prompted for a root, or `git init` the vault yourself first"
db-dir))
(t
(let ((root (file-name-as-directory
(expand-file-name
(read-directory-name
"No git repo found for the Supertag database; initialize one at: "
db-dir nil t)))))
(unless (supertag-git--ancestor-p root db-dir)
(user-error "supertag-git-setup: refusing -- %s does not contain the database directory %s. Git sync (V1) requires the database to live inside the chosen repo root."
root db-dir))
root)))))
;;; --- supertag-git-setup: remote / initial commit / push (S4 machine 1) ---
;;
;; The rest of the plan's "S4 用户旅程" -> "机器 1" flow, on top of the
;; per-clone `.gitattributes'/`.gitignore'/driver configuration above:
;; configure (or confirm) an `origin' remote, create the vault's initial
;; commit via the exact same scoped-staging pathspec logic
;; `supertag-git-sync-mode' uses for its own auto-commits (never a bare
;; `git add -A' -- see `supertag-git-sync--commit-pathspecs', defined
;; later in this file in the auto-sync section; calling it here from code
;; defined earlier in the file is an ordinary forward reference, resolved
;; at call time like any other Lisp symbol -- not a forward DECLARATION,
;; which is only needed for symbols owned by some OTHER file), and
;; `push -u origin <branch>'. Deliberately no retry loop here: an
;; auth/network failure gets one clear diagnostic message pointing at
;; manual `git push' / ssh-agent / credential-helper troubleshooting --
;; automatic retrying on reconnect is `supertag-git-sync-mode''s job, not
;; this one-shot setup command's.
(defun supertag-git-setup--existing-origin-url (root)
"Return ROOT's configured `remote.origin.url', or nil if `origin' is not
configured at all."
(supertag-git--get-config root "remote.origin.url"))
(defun supertag-git-setup--remote-add (root url)
"Run `git remote add origin URL' in ROOT. Signals a clear error on failure."
(let ((result (supertag-git--run root "remote" "add" "origin" url)))
(unless (supertag-git--ok-p result)
(error "supertag-git-setup: `git remote add origin %s' failed: %s" url (cdr result)))))
(defun supertag-git-setup--remote-set-url (root url)
"Run `git remote set-url origin URL' in ROOT. Signals a clear error on
failure."
(let ((result (supertag-git--run root "remote" "set-url" "origin" url)))
(unless (supertag-git--ok-p result)
(error "supertag-git-setup: `git remote set-url origin %s' failed: %s" url (cdr result)))))
(defun supertag-git-setup--configure-remote (root)
"Prompt for an `origin' remote URL for ROOT and configure it, per the
plan's machine-1 journey. The prompt is pre-filled with the CURRENTLY
configured `origin' URL, if any (so accepting it with RET is a no-op,
and the user can also see at a glance what is already set); clearing the
minibuffer to empty and pressing RET is an explicit, valid choice to skip
remote setup entirely (local-only vault -- git sync via the merge driver
still works fine for a single machine, or via manual `git remote add' /
`magit' later).
Returns the configured URL (a string) on success, or nil when the user
chose to skip (empty input) -- callers use nil to skip the initial
commit/push step too, exactly per the plan (\"若给出 URL\" gates all of
remote-add/commit/push together, not just the remote-add step alone)."
(let* ((existing (supertag-git-setup--existing-origin-url root))
(input (string-trim
(read-string "Git remote URL for `origin' (empty = local-only, skip for now): "
existing))))
(cond
((zerop (length input)) nil)
((not existing)
(supertag-git-setup--remote-add root input)
input)
((equal input existing) existing)
(t
(if (y-or-n-p (format "supertag-git-setup: `origin' is already set to %s -- change it to %s? "
existing input))
(progn (supertag-git-setup--remote-set-url root input) input)
existing)))))
(defun supertag-git-setup--commit-all (root message)
"Stage everything `supertag-git-sync--commit-pathspecs' currently allows
under ROOT (`.org' files, `supertag-db-file' itself, `.gitattributes',
`.gitignore' -- never a bare, unconditional `git add -A'; see that
function's docstring for the full rationale, shared verbatim with
`supertag-git-sync-mode''s own auto-commits) and commit with MESSAGE if
anything actually ends up staged. Returns non-nil if a commit was
created, nil if there was nothing new to commit (e.g. re-running setup
with nothing changed since the last commit)."
(let ((pathspecs (supertag-git-sync--commit-pathspecs root)))
(when pathspecs
(let ((add-result (apply #'supertag-git--run root "add" "-A" "--" pathspecs)))
(unless (supertag-git--ok-p add-result)
(error "supertag-git-setup: `git add' failed: %s" (cdr add-result))))
(let ((diff-result (supertag-git--run root "diff" "--cached" "--quiet")))
(cond
((supertag-git--ok-p diff-result) nil) ; exit 0 -- nothing staged
((/= 1 (car diff-result))
(error "supertag-git-setup: could not inspect the staged diff: %s" (cdr diff-result)))
(t
(let ((commit-result (supertag-git--run root "commit" "-q" "-m" message)))
(unless (supertag-git--ok-p commit-result)
(error "supertag-git-setup: `git commit' failed: %s" (cdr commit-result)))
t)))))))
(defun supertag-git-setup--current-branch (root)
"Return ROOT's current branch name (`git rev-parse --abbrev-ref HEAD'),
or nil on failure. Works even before ROOT's first commit -- HEAD is a
symbolic ref to the branch name from `git init'/`symbolic-ref' onward,
resolvable without any commit existing yet."
(let ((result (supertag-git--run root "rev-parse" "--abbrev-ref" "HEAD")))
(when (supertag-git--ok-p result) (cdr result))))
(defun supertag-git-setup--push (root)
"Push ROOT's current branch to `origin' with `-u' (so it starts tracking
it), per the plan's machine-1 journey. On success, returns
`(:status :ok :branch BRANCH)'. On failure (most commonly auth/network:
no ssh-agent, no credential helper configured, remote unreachable, ...),
returns `(:status :failed :branch BRANCH :error STRING)' -- this function
deliberately does NOT retry; a one-shot setup command retrying a push
that failed for a persistent reason (bad credentials, unreachable host)
would just hang or spam identically-failing attempts. `supertag-git-setup'
turns the `:failed' case into a diagnostic message pointing at manual
`git push' / ssh-agent / credential-helper troubleshooting; automatic
retry-on-reconnect is `supertag-git-sync-mode''s job, not this command's."
(let ((branch (supertag-git-setup--current-branch root)))
(unless branch
(error "supertag-git-setup: could not determine the current branch to push"))
(let ((result (supertag-git--run root "push" "-u" "origin" branch)))
(if (supertag-git--ok-p result)
(list :status :ok :branch branch)
(list :status :failed :branch branch :error (cdr result))))))
;;;###autoload
(defun supertag-git-setup ()
"Configure the current vault's git repository for semantic supertag-db merges.
Idempotent: safe to run again on this same clone (it will report what it
already had configured and skip re-adding duplicate lines), and this is
also the command every OTHER clone/machine must run once for itself --
`merge.supertag-db.driver' lives in `.git/config', which git never syncs
between clones (see the Commentary in this file for why).
Steps performed (each reported as it happens):
1. Locate `supertag-db-file''s directory; if it is not already inside a
git worktree, offer to `git init' one -- at the single configured
`supertag-sync-directories' root if there is exactly one and it
already contains the database, otherwise by prompting (refusing
outright, rather than guessing, when multiple sync roots are
configured or no in-repo root can be inferred; see
`supertag-git-setup--pick-root').
2. Write/append a `.gitattributes' entry marking the database's path for
the `supertag-db' merge driver.
3. Write THIS CLONE's `.git/config' `merge.supertag-db.*' entries,
pointing at wherever `supertag-merge.el' lives on THIS machine.
4. Append `.gitignore' entries (local-only state that must never be
synced) next to the database file.
5. Prompt for an `origin' remote URL (pre-filled with whatever is already
configured, if anything; empty input explicitly skips this and every
remaining step below, leaving a valid local-only vault -- see
`supertag-git-setup--configure-remote'). When a URL is given: configure
it (`git remote add`, or `set-url` with confirmation if `origin'
already pointed somewhere else), create the vault's initial commit via
the exact same scoped-staging pathspec logic `supertag-git-sync-mode'
uses for its own auto-commits (`supertag-git-setup--commit-all' /
`supertag-git-sync--commit-pathspecs' -- never a bare `git add -A'),
then `git push -u origin <current-branch>'. A push failure (almost
always auth/network: no ssh-agent, no credential helper, unreachable
host, ...) is reported as a diagnostic pointing at manual `git push'
troubleshooting -- this step never retries.
Also performs, as its new Step 0 (S4), a one-time vault layout migration
when `supertag-db-file' lives outside any git repository but a single
`supertag-sync-directories' root is configured: the database is
copied to `<root>/.supertag/supertag-db.el', verified loadable, this
session's persistence wiring is switched over and the store reloaded from
the new location, and only then is the OLD file renamed (never deleted) to
a `.migrated-<timestamp>' tombstone -- see `supertag-git--migrate-layout'
for the full decision table and `supertag-git--do-migrate-layout' for the
failure-safety chain. Idempotent like everything else here: once migrated,
subsequent runs see the database already inside the repo and skip this
step. Still refuses outright (unchanged from before S4) if, after that
migration step, the database turns out to live outside the git repo it
finds/creates -- e.g. multiple sync roots configured (V1 limitation) or no
root at all with a non-interactive caller."
(interactive)
(unless (supertag-git--executable)
(user-error "supertag-git-setup: `git' executable not found; install git first"))
(let (report)
(cl-flet ((note (fmt &rest args) (push (apply #'format fmt args) report)))
;; Step 0 (S4): layout migration. This may change `supertag-db-file'
;; out from under us, so everything below re-reads it fresh
;; afterward rather than reusing a value captured before this call.
(let ((migration (supertag-git--migrate-layout)))
(pcase (plist-get migration :status)
(:skipped-in-repo
(note "Database already inside a git repository; skipping the S4 layout migration."))
(:skipped-already-under-root
(note "Database already lives under the configured vault root %s; skipping layout migration (git init/configuration below will still run)."
(plist-get migration :repo-root)))
(:migrated
(note "Migrated database into the git vault layout: %s -> %s.%s"
(plist-get migration :old-db-file)
(plist-get migration :new-db-file)
(cond
((plist-get migration :tombstone)
(format " Old file renamed to %s (kept as a tombstone, not deleted)."
(plist-get migration :tombstone)))
((plist-get migration :fresh-vault) "")
(t " (old file could not be renamed to a tombstone; see the preceding message -- it is safely unused but still on disk)"))))
(:failed
(note "Layout migration to the git vault layout FAILED (old database left completely untouched): %s"
(plist-get migration :error)))
(_ nil))) ; :skipped-no-root / :skipped-multi-root / :skipped-root-missing: nothing new to report here; the existing steps below handle or refuse these exactly as before S4
(let* ((db-file (expand-file-name supertag-db-file))
(db-dir (file-name-directory db-file)))
(unless (file-directory-p db-dir)
(make-directory db-dir t))
(let ((toplevel (supertag-git--repo-toplevel db-dir)))
(if toplevel
(note "Already inside a git repository: %s" toplevel)
(let ((root (supertag-git-setup--pick-root db-dir)))
(note "No existing git repository found for %s." db-dir)
(supertag-git--init-repo root)
(note "Ran `git init` at %s." root)
(setq toplevel (supertag-git--repo-toplevel db-dir))
(unless toplevel
(error "supertag-git-setup: `git init' at %s did not result in %s being inside a git worktree -- this should not happen"
root db-dir))))
(let ((cfg (supertag-git--configure-clone toplevel db-file)))
(note "Relative DB path within repo: %s" (plist-get cfg :relative-path))
(note "%s"
(if (plist-get cfg :gitattributes-added)
(format ".gitattributes: added entry (%s)" (plist-get cfg :gitattributes-file))
".gitattributes: entry already present (skipped)"))
(note "merge.supertag-db.driver configured for THIS CLONE ONLY, using package dir %s. This setting lives in `.git/config' and is NEVER synced by git -- you must run `supertag-git-setup' again on every other clone/machine."
(plist-get cfg :driver-package-dir))
(note "%s"
(if (plist-get cfg :gitignore-added)
(format ".gitignore: added %s (in %s)"
(plist-get cfg :gitignore-added)
(file-name-directory (plist-get cfg :gitignore-file)))
".gitignore: entries already present (skipped)")))
;; Step 5 (S4 machine-1 journey): remote configuration + initial
;; commit + push. Gated together on a non-empty URL, per the
;; plan -- an explicit empty input skips ALL of this, leaving a
;; valid local-only vault (the merge driver above already works
;; for a single machine; nothing here is required for that).
(let ((remote-url (supertag-git-setup--configure-remote toplevel)))
(if (not remote-url)
(note "No git remote configured -- this vault is local-only for now. Run `supertag-git-setup' again later once you have a remote URL, or configure one yourself (`git remote add origin <url>`) and push manually.")
(note "Remote `origin' configured: %s" remote-url)
(let ((committed (supertag-git-setup--commit-all
toplevel (format "supertag-git-setup: initial commit (%s)" (system-name)))))
(note "%s" (if committed
"Created the initial commit (org files, database, .gitattributes/.gitignore)."
"Nothing new to commit (already up to date)."))
(let ((push-result (supertag-git-setup--push toplevel)))
(pcase (plist-get push-result :status)
(:ok
(note "Pushed branch `%s' to `origin' (now tracking it)."
(plist-get push-result :branch)))
(:failed
(note "`git push -u origin %s' FAILED: %s -- this is usually an auth or network problem. Test manually with `git push' in a shell at %s; check that your ssh-agent has the right key loaded (or your credential helper is configured) and that the remote is reachable. Setup will NOT retry automatically -- push manually once fixed, or re-run `supertag-git-setup', or enable `supertag-git-sync-mode' once a manual push works."
(plist-get push-result :branch)
(string-trim (plist-get push-result :error))
toplevel))))))))))
(setq report (nreverse report))
(dolist (line report) (message "supertag-git-setup: %s" line))
(unless noninteractive
(with-current-buffer (get-buffer-create supertag-git--report-buffer-name)
(let ((inhibit-read-only t))
(erase-buffer)
(insert "Supertag Git Setup Report\n" (make-string 26 ?=) "\n\n")
(dolist (line report) (insert "- " line "\n"))
(goto-char (point-min)))
(display-buffer (current-buffer))))
report))
;;; --- S4 vault registry (V1 single-root) ---
;;
;; There is no separate "registry" data structure in this codebase (see the
;; migration Commentary above): `supertag-sync-directories' -- a plain
;; list of vault root directories -- IS the registry. Registering a newly
;; cloned vault means appending its root to that list (if not already
;; there) and pointing this session's persistence wiring at
;; `<root>/.supertag/', reusing the exact same
;; `supertag-git--apply-data-directory-wiring' the layout migration above