@lancc/cmark 0.0.0-stage → 1.1.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 (197) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +185 -2
  4. package/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  5. package/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  6. package/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  7. package/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  8. package/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  9. package/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  10. package/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  11. package/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  12. package/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  13. package/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  14. package/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  15. package/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  16. package/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  17. package/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  18. package/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  19. package/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  20. package/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  21. package/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  22. package/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  23. package/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  24. package/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  25. package/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  26. package/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  27. package/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  28. package/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  29. package/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  30. package/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  31. package/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  32. package/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  33. package/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  34. package/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  35. package/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  36. package/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  37. package/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  38. package/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  39. package/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  40. package/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  41. package/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  42. package/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  43. package/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  44. package/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  45. package/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  46. package/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  47. package/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  48. package/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  49. package/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  50. package/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  51. package/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  52. package/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  53. package/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  54. package/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  55. package/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  56. package/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  57. package/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  58. package/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  59. package/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  60. package/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  61. package/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  62. package/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  63. package/assets/abnfDiagram-WEYOIA3C-BgXsTF7a.js +1 -0
  64. package/assets/architectureDiagram-ZHVYKT53-BsCWa_qC.js +36 -0
  65. package/assets/blockDiagram-RJAE7QA3-wbIxEiYN.js +140 -0
  66. package/assets/c4Diagram-XDPPPE54-5zdyxFvA.js +38 -0
  67. package/assets/chunk-2E7WHVMD-F85Lca-i.js +88 -0
  68. package/assets/chunk-2Q5K7J3B-CjpAZ0OI.js +1 -0
  69. package/assets/chunk-4EMNKR2T-C8Kbh83c.js +1 -0
  70. package/assets/chunk-55XCU76K-BINcFiLN.js +1 -0
  71. package/assets/chunk-5VM5RSS4-Diy5h2LS.js +15 -0
  72. package/assets/chunk-JWPE2WC7-BXnjDdh1.js +1 -0
  73. package/assets/chunk-VQ5W62QI-BM7ZZo1I.js +1 -0
  74. package/assets/chunk-XXDRQBXY-CW8CjB1w.js +1 -0
  75. package/assets/chunk-ZCHMQSOR-C7ATz1L1.js +2 -0
  76. package/assets/classDiagram-v2-4MYWR3UA-CMoqNk-b.js +217 -0
  77. package/assets/cose-bilkent-JH36ORCC-6rqR5iJk.js +1 -0
  78. package/assets/cynefin-EF2NZ3EQ-DypLIJxl.js +141 -0
  79. package/assets/cynefinDiagram-WF24UDRP-8b2CEBlR.js +62 -0
  80. package/assets/cytoscape.esm-CjX6Hyah.js +331 -0
  81. package/assets/dagre-NK4GIZVX-Dv6vuUbR.js +4 -0
  82. package/assets/diagram-CBK3MWTC-DJgMDeYd.js +24 -0
  83. package/assets/diagram-I5XIIV2D-BB9cMoRW.js +24 -0
  84. package/assets/diagram-ODIL2UCW-BVcRtsdy.js +30 -0
  85. package/assets/diagram-PFBEJUXE-BbGSOtJx.js +3 -0
  86. package/assets/diagram-RJA3I4VV-CzkY_8Fo.js +41 -0
  87. package/assets/diagram-TYUNFSNR-YTl5QdfL.js +200 -0
  88. package/assets/ebnfDiagram-V4P3TTPT-COVjPAjE.js +1 -0
  89. package/assets/echarts-BAPDkhe3.js +61 -0
  90. package/assets/elk-QONTBCTQ-DHsjY8Z_.js +27 -0
  91. package/assets/erDiagram-KYQIGQAA-CUAbTUl1.js +99 -0
  92. package/assets/flowDiagram-IUGLLWBU-CWsiqTQC.js +213 -0
  93. package/assets/ganttDiagram-R7GPNTJY-DetBW0Wm.js +292 -0
  94. package/assets/gitGraphDiagram-PDYL7XHY-ByrdgJmw.js +106 -0
  95. package/assets/graph-D8UmR1vK.js +1 -0
  96. package/assets/index-BLONSItV.css +1 -0
  97. package/assets/index-Cz6qaZbV.js +744 -0
  98. package/assets/infoDiagram-MVDRKAX5-BM48fjhU.js +2 -0
  99. package/assets/ishikawaDiagram-HPEHV2GE-DtHYywHZ.js +70 -0
  100. package/assets/journeyDiagram-BOSHW7O7-DJ1LiRh3.js +139 -0
  101. package/assets/kanban-definition-FNSA4HC5-DPPt8pwJ.js +89 -0
  102. package/assets/katex-CQL9J12G.js +257 -0
  103. package/assets/markdown-DVehI1Sg.js +314 -0
  104. package/assets/mermaid-C_q9pug0.js +312 -0
  105. package/assets/mindmap-definition-6OY57UG3-zbqYOEY6.js +98 -0
  106. package/assets/pegDiagram-ECIUJNBX-e7QG7TMq.js +1 -0
  107. package/assets/pieDiagram-GNMPY74T-Duf6YP0N.js +39 -0
  108. package/assets/quadrantDiagram-D57K4MC3-B2eYoUUy.js +7 -0
  109. package/assets/railroadDiagram-Y72NRBL7-BWCzPcI_.js +1 -0
  110. package/assets/requirementDiagram-EQYSW5RA-HhPMEqB1.js +84 -0
  111. package/assets/sankeyDiagram-SY24JHO5-CMUTwFBB.js +40 -0
  112. package/assets/sequenceDiagram-BLEHNHQU-C9wR2ll7.js +169 -0
  113. package/assets/sizeCapture-INFHLROL-DXfz37S6.js +1 -0
  114. package/assets/stateDiagram-v2-A6IESKWZ-D_gtonhE.js +291 -0
  115. package/assets/swimlanes-B3ENRKFQ-DdJOuuMV.js +1 -0
  116. package/assets/swimlanesDiagram-AYQRV4AK-BWt3HxHV.js +8 -0
  117. package/assets/timeline-definition-JXYKGLGB-DQsdwJe-.js +120 -0
  118. package/assets/usecaseDiagram-WPOLBNNX-DTRyFq7d.js +370 -0
  119. package/assets/vennDiagram-Q3Y3IDCW-dihYszm0.js +34 -0
  120. package/assets/wardleyDiagram-IVAIDKWA-BoPpgYfK.js +78 -0
  121. package/assets/xychartDiagram-4URNHDU4-DoLjYxeB.js +7 -0
  122. package/export-file/github-markdown.css +550 -0
  123. package/export-file/index.html +33 -0
  124. package/fonts/KaTeX_AMS-Regular.ttf +0 -0
  125. package/fonts/KaTeX_AMS-Regular.woff +0 -0
  126. package/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  127. package/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  128. package/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  129. package/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  130. package/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  131. package/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  132. package/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  133. package/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  134. package/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  135. package/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  136. package/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  137. package/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  138. package/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  139. package/fonts/KaTeX_Main-Bold.ttf +0 -0
  140. package/fonts/KaTeX_Main-Bold.woff +0 -0
  141. package/fonts/KaTeX_Main-Bold.woff2 +0 -0
  142. package/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  143. package/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  144. package/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  145. package/fonts/KaTeX_Main-Italic.ttf +0 -0
  146. package/fonts/KaTeX_Main-Italic.woff +0 -0
  147. package/fonts/KaTeX_Main-Italic.woff2 +0 -0
  148. package/fonts/KaTeX_Main-Regular.ttf +0 -0
  149. package/fonts/KaTeX_Main-Regular.woff +0 -0
  150. package/fonts/KaTeX_Main-Regular.woff2 +0 -0
  151. package/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  152. package/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  153. package/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  154. package/fonts/KaTeX_Math-Italic.ttf +0 -0
  155. package/fonts/KaTeX_Math-Italic.woff +0 -0
  156. package/fonts/KaTeX_Math-Italic.woff2 +0 -0
  157. package/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  158. package/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  159. package/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  160. package/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  161. package/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  162. package/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  163. package/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  164. package/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  165. package/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  166. package/fonts/KaTeX_Script-Regular.ttf +0 -0
  167. package/fonts/KaTeX_Script-Regular.woff +0 -0
  168. package/fonts/KaTeX_Script-Regular.woff2 +0 -0
  169. package/fonts/KaTeX_Size1-Regular.ttf +0 -0
  170. package/fonts/KaTeX_Size1-Regular.woff +0 -0
  171. package/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  172. package/fonts/KaTeX_Size2-Regular.ttf +0 -0
  173. package/fonts/KaTeX_Size2-Regular.woff +0 -0
  174. package/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  175. package/fonts/KaTeX_Size3-Regular.ttf +0 -0
  176. package/fonts/KaTeX_Size3-Regular.woff +0 -0
  177. package/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  178. package/fonts/KaTeX_Size4-Regular.ttf +0 -0
  179. package/fonts/KaTeX_Size4-Regular.woff +0 -0
  180. package/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  181. package/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  182. package/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  183. package/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  184. package/index.html +15 -0
  185. package/katex.min.css +1 -0
  186. package/logo.png +0 -0
  187. package/package.json +31 -4
  188. package/plugin.json +241 -0
  189. package/preload/branch-bar.js +159 -0
  190. package/preload/git-panel.js +1311 -0
  191. package/preload/git-service.js +1403 -0
  192. package/preload/ndb.js +1407 -0
  193. package/preload/package.json +3 -0
  194. package/preload/rename-note.js +151 -0
  195. package/preload/services.js +432 -0
  196. package/preload/settings-panel.js +847 -0
  197. package/preload.js +1615 -0
@@ -0,0 +1,1403 @@
1
+ /**
2
+ * git-service.js —— 笔记插件的 Git 服务层(v2,按代码评审结论加固)
3
+ *
4
+ * 相对 v1 的关键修正:
5
+ * P0-1 备份失败必须中止:backupFile 返回结构化结果,复制后校验字节数
6
+ * P0-2 路径按字面量处理:全局 GIT_LITERAL_PATHSPECS=1,杜绝 n[1].md 命中 n1.md
7
+ * P1-3 非 ASCII 路径:含路径的输出加 -z + core.quotepath=false
8
+ * P1-4 porcelain v2 rename 记录正确取 path(slice(9))并保留 oldPath
9
+ * P1-5 回溯/丢弃用 cat-file 直接写字节,绕开 .gitattributes 与 autocrlf
10
+ * P1-6 提交范围限定在笔记目录子树,不再 add -A 整个仓库
11
+ * P1-7 拉取默认 ff-only,绝不把冲突标记写进用户笔记
12
+ * P1-8 检测本地/远端分叉,避免 rebase 重写已发布历史
13
+ * P1-10 正确检测游离 HEAD,remote 不硬编码
14
+ * P2 日志脱敏、错误分类补 rejected/diverged、版本号校验、配置缓存
15
+ *
16
+ * 铁律:不改用户文件内容;备份/日志只写仓库外;破坏性操作先备份,失败即中止。
17
+ */
18
+ const { execFile } = require('child_process')
19
+ const fs = require('fs')
20
+ const path = require('path')
21
+ const os = require('os')
22
+ const crypto = require('crypto')
23
+
24
+ const DEFAULTS = {
25
+ autoSync: false,
26
+ // 两种触发方式互斥:interval=固定间隔,stop-edit=停止编辑后
27
+ autoMode: 'interval',
28
+ intervalMs: 5 * 60 * 1000,
29
+ onStopEditMinutes: 0,
30
+ pullBeforePush: true,
31
+ pushEnabled: true,
32
+ commitScope: 'notes',
33
+ commitMessage: 'notes: 同步 {{datetime}}',
34
+ lastRunAt: 0,
35
+ failCount: 0
36
+ }
37
+
38
+ module.exports = function installGitService(win, argvPath) {
39
+
40
+ // 传进来的可能是配置文件路径而不是目录,这里做一次纠正
41
+ let WS = argvPath
42
+ try {
43
+ const isDir = WS && fs.existsSync(WS) && fs.statSync(WS).isDirectory()
44
+ if (!isDir) {
45
+ let fromCfg = null
46
+ try {
47
+ const c = JSON.parse(fs.readFileSync(WS, 'utf8'))
48
+ if (c && typeof c.workspace === 'string') fromCfg = c.workspace
49
+ } catch (e) {}
50
+ if (!fromCfg && win && win.__ZNOTES_WS) fromCfg = win.__ZNOTES_WS
51
+ if (fromCfg) WS = fromCfg
52
+ }
53
+ } catch (e) {}
54
+ if (!WS) WS = '.'
55
+
56
+ // ======================= 路径 =======================
57
+ const hash = crypto.createHash('md5').update(String(WS).toLowerCase()).digest('hex').slice(0, 12)
58
+ const baseDir = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
59
+ const dataDir = path.join(baseDir, 'znotes-data', hash)
60
+ const backupDir = path.join(dataDir, 'backup')
61
+ const configFile = path.join(dataDir, 'git-config.json')
62
+ const logFile = path.join(dataDir, 'git.log')
63
+
64
+ function mkdirp(d) { try { fs.mkdirSync(d, { recursive: true }) } catch (e) {} }
65
+ mkdirp(backupDir)
66
+
67
+ // ---------- 配置(内存缓存 + 白名单) ----------
68
+ let cfgCache = null
69
+ function readConfig() {
70
+ if (cfgCache) return cfgCache
71
+ try {
72
+ cfgCache = Object.assign({}, DEFAULTS, JSON.parse(fs.readFileSync(configFile, 'utf8')))
73
+ } catch (e) {
74
+ cfgCache = Object.assign({}, DEFAULTS)
75
+ }
76
+ return cfgCache
77
+ }
78
+ function writeConfig(patch) {
79
+ const next = Object.assign({}, readConfig())
80
+ const ALLOW = ['autoSync', 'autoMode', 'intervalMs', 'onStopEditMinutes', 'pullBeforePush', 'pushEnabled', 'commitScope', 'commitMessage', 'lastRunAt', 'lastAutoResult', 'failCount']
81
+ Object.keys(patch || {}).forEach((k) => { if (ALLOW.indexOf(k) >= 0) next[k] = patch[k] })
82
+ if (next.autoMode != null && next.autoMode !== 'interval' && next.autoMode !== 'stop-edit') delete next.autoMode
83
+ if (next.intervalMs) next.intervalMs = Math.max(60000, parseInt(next.intervalMs, 10) || DEFAULTS.intervalMs)
84
+ if (next.onStopEditMinutes != null) next.onStopEditMinutes = Math.max(0, parseInt(next.onStopEditMinutes, 10) || 0)
85
+ if (typeof next.commitMessage === 'string') next.commitMessage = next.commitMessage.trim().slice(0, 200) || DEFAULTS.commitMessage
86
+ if (next.commitScope !== 'repo') next.commitScope = 'notes'
87
+ cfgCache = next
88
+ try {
89
+ fs.writeFileSync(configFile, JSON.stringify(next, null, 2), 'utf8')
90
+ return { ok: true }
91
+ } catch (e) {
92
+ return { ok: false, message: '配置保存失败:' + e.message }
93
+ }
94
+ }
95
+
96
+ // ---------- 日志(脱敏 + 按行滚动) ----------
97
+ function redact(text) {
98
+ return String(text || '')
99
+ .replace(/(\/\/)[^@\/\s]+@/g, '$1***@')
100
+ .replace(/(token|password|passwd|secret|authorization)([=:]\s*)\S+/gi, '$1$2***')
101
+ }
102
+ function log(level, text) {
103
+ try {
104
+ fs.appendFileSync(logFile, '[' + new Date().toISOString() + '] [' + level + '] ' + redact(text).slice(0, 800) + '\n', 'utf8')
105
+ if (fs.statSync(logFile).size > 400 * 1024) {
106
+ const lines = fs.readFileSync(logFile, 'utf8').split('\n').filter(Boolean)
107
+ fs.writeFileSync(logFile, lines.slice(-1500).join('\n') + '\n', 'utf8')
108
+ }
109
+ } catch (e) {}
110
+ }
111
+ function logTail(n) {
112
+ try {
113
+ return fs.readFileSync(logFile, 'utf8').split('\n').filter(Boolean).slice(-(n || 80)).map(redact).join('\n')
114
+ } catch (e) { return '' }
115
+ }
116
+
117
+ // ======================= 仓库定位 =======================
118
+ let repoRoot = null
119
+ let scopeRel = ''
120
+
121
+ function locateRepo() {
122
+ let cur = WS
123
+ repoRoot = null
124
+ let depth = 0
125
+ // 最多向上 4 层:既支持"仓库根在笔记目录上一级",又不会误认到家目录或盘根的仓库
126
+ while (cur && cur !== path.dirname(cur) && depth < 4) {
127
+ depth++
128
+ try {
129
+ if (fs.existsSync(path.join(cur, '.git'))) { repoRoot = cur; break }
130
+ } catch (e) {}
131
+ cur = path.dirname(cur)
132
+ }
133
+ if (repoRoot) {
134
+ const r = path.relative(repoRoot, WS)
135
+ scopeRel = (r && !r.startsWith('..')) ? r.split(path.sep).join('/') : ''
136
+ } else {
137
+ scopeRel = ''
138
+ }
139
+ return repoRoot
140
+ }
141
+ locateRepo()
142
+
143
+ // 工作区元数据目录名(固定放在仓库根)
144
+ const META_DIR = '.cmark'
145
+
146
+ // 仓库根的版本控制配置文件:不属于笔记内容,但由插件自己维护(initRepo 会写 .gitignore),
147
+ // 而它们住在仓库根。工作区是仓库的子目录时,若不显式纳入提交范围,
148
+ // 这些文件的改动永远进不了暂存区 —— 表现就是「改动面板里一直挂着、怎么提交都提交不掉」。
149
+ const ROOT_VCS = ['.gitignore', '.gitattributes']
150
+ function rootVcsPresent () {
151
+ if (!repoRoot) return []
152
+ return ROOT_VCS.filter((f) => {
153
+ try { return fs.existsSync(path.join(repoRoot, f)) } catch (e) { return false }
154
+ })
155
+ }
156
+
157
+ function gitDir() {
158
+ if (!repoRoot) return null
159
+ const g = path.join(repoRoot, '.git')
160
+ try {
161
+ if (fs.statSync(g).isFile()) {
162
+ const m = fs.readFileSync(g, 'utf8').trim().match(/^gitdir:\s*(.+)$/i)
163
+ if (m) return path.resolve(repoRoot, m[1].trim())
164
+ }
165
+ } catch (e) {}
166
+ return g
167
+ }
168
+
169
+ function inspectRepoState() {
170
+ // 之前只在模块加载时 locateRepo 一次,结果永久缓存:
171
+ // 用户先打开插件、后来才把仓库 clone 进来(或刚删掉 .git),
172
+ // 面板就会一直显示"尚未初始化 git 仓库"。这里每次检查都复核一次。
173
+ try {
174
+ if (repoRoot && !fs.existsSync(path.join(repoRoot, '.git'))) repoRoot = null
175
+ if (!repoRoot) locateRepo()
176
+ } catch (e) {}
177
+ const gd = gitDir()
178
+ const out = { isRepo: !!repoRoot, repoRoot, scopeRel, locked: false, lockFile: null, lockAgeMs: 0, inProgress: null }
179
+ if (!gd) return out
180
+ const lock = path.join(gd, 'index.lock')
181
+ try {
182
+ if (fs.existsSync(lock)) {
183
+ out.locked = true
184
+ out.lockFile = lock
185
+ out.lockAgeMs = Date.now() - fs.statSync(lock).mtimeMs
186
+ }
187
+ } catch (e) {}
188
+ const marks = [['rebase-merge', 'rebase'], ['rebase-apply', 'rebase'], ['MERGE_HEAD', 'merge'], ['CHERRY_PICK_HEAD', 'cherry-pick'], ['REVERT_HEAD', 'revert']]
189
+ for (const [f, name] of marks) {
190
+ if (fs.existsSync(path.join(gd, f))) { out.inProgress = name; break }
191
+ }
192
+ return out
193
+ }
194
+
195
+ // ======================= git 执行 =======================
196
+ const ERR_RULES = [
197
+ { kind: 'rejected', re: /non-fast-forward|fetch first|Updates were rejected|\[rejected\]/i },
198
+ { kind: 'conflict', re: /CONFLICT|unmerged|fix conflicts|would be overwritten|Please commit your changes or stash/i },
199
+ { kind: 'lock', re: /index\.lock|Unable to create.*\.lock|another git process/i },
200
+ { kind: 'badrev', re: /not a full refname|unknown revision|bad revision|invalid reference/i },
201
+ { kind: 'detached', re: /detached HEAD|not currently on a branch/i },
202
+ { kind: 'norepo', re: /not a git repository/i },
203
+ { kind: 'noUpstream', re: /no upstream configured|has no upstream branch|couldn't find remote ref/i },
204
+ { kind: 'auth', re: /(Authentication failed|Permission denied \(publickey|invalid username or password|could not read Username|could not read Password|No such identity)/i },
205
+ { kind: 'network', re: /could not resolve host|Unable to access|Connection (refused|reset|timed out)|Failed to connect|network is unreachable|Operation timed out/i },
206
+ { kind: 'perm', re: /Permission denied|EACCES|Read-only file system|No space left/i },
207
+ { kind: 'dirty', re: /local changes would be lost|Your local changes|uncommitted changes/i }
208
+ ]
209
+
210
+ const ERR_TEXT = {
211
+ network: '网络不通或远端不可达',
212
+ auth: '认证失败(检查 SSH key 或凭据)',
213
+ conflict: '存在冲突,需要先解决',
214
+ lock: 'git 被其他进程占用(index.lock)',
215
+ detached: 'HEAD 处于游离状态,请先切回分支',
216
+ norepo: '当前目录不是 git 仓库',
217
+ noUpstream: '当前分支没有配置上游分支',
218
+ dirty: '本地有未提交改动,阻止了本次操作',
219
+ busy: '上一个 git 操作还在进行中',
220
+ timeout: 'git 命令超时',
221
+ rejected: '远端有你没有的提交,请先拉取再推送',
222
+ diverged: '本地与远端已分叉,无法自动快进',
223
+ badrev: '版本号或分支名无效',
224
+ perm: '文件或目录权限不足(可能被杀毒软件占用)',
225
+ backup: '备份失败,操作已中止',
226
+ nothing: '没有需要提交的改动',
227
+ noremote: '未配置远程仓库,请先在设置中填写地址。',
228
+ unknown: 'git 执行失败'
229
+ }
230
+
231
+ function classify(text) {
232
+ for (const r of ERR_RULES) if (r.re.test(text)) return r.kind
233
+ return 'unknown'
234
+ }
235
+
236
+ let busy = false
237
+ let currentOp = null
238
+
239
+ // credential.helper 用空值先清空叠加的列表,再指定 manager:
240
+ // 否则 system 级的 helper-selector(便携版 git 自带)会和 global 的 manager 叠加,
241
+ // git 会弹出「选择凭据助手」的 GUI 窗口,在插件里就是卡死。
242
+ const BASE_ARGS = ['-c', 'core.quotepath=false', '-c', 'credential.helper=', '-c', 'credential.helper=manager']
243
+
244
+ function exec(args, opt) {
245
+ const o = opt || {}
246
+ return new Promise((resolve) => {
247
+ execFile('git', BASE_ARGS.concat(args), {
248
+ cwd: repoRoot || WS,
249
+ encoding: o.buffer ? 'buffer' : 'utf8',
250
+ maxBuffer: 32 * 1024 * 1024,
251
+ windowsHide: true,
252
+ timeout: o.timeout || 30000,
253
+ env: Object.assign({}, process.env, {
254
+ GCM_INTERACTIVE: 'never',
255
+ GIT_TERMINAL_PROMPT: '0',
256
+ GIT_LITERAL_PATHSPECS: '1',
257
+ LC_ALL: 'C'
258
+ })
259
+ }, (err, stdout, stderr) => {
260
+ if (err) {
261
+ const killed = err.killed || err.signal === 'SIGTERM'
262
+ const text = String(stderr || '') + String(stdout || '') + ' ' + String(err.message || '')
263
+ const kind = killed ? 'timeout' : classify(text)
264
+ resolve({
265
+ ok: false,
266
+ kind,
267
+ code: err.code,
268
+ message: (killed ? ERR_TEXT.timeout : redact(String(stderr || stdout || err.message || '').trim())) || ERR_TEXT[kind],
269
+ raw: redact(text.trim()).slice(0, 3000)
270
+ })
271
+ } else {
272
+ resolve({ ok: true, data: stdout })
273
+ }
274
+ })
275
+ })
276
+ }
277
+
278
+ function runGit(args, opt) {
279
+ const o = opt || {}
280
+ return exec(args, o).then((r) => {
281
+ log(r.ok ? 'OK' : 'ERR', (o.label || args[0]) + ' ' + args.slice(1).join(' ').slice(0, 160) + (r.ok ? '' : ' -> ' + r.kind + ': ' + String(r.message).slice(0, 200)))
282
+ return r
283
+ })
284
+ }
285
+
286
+ function enqueue(label, fn) {
287
+ if (busy) return Promise.resolve({ ok: false, kind: 'busy', message: ERR_TEXT.busy })
288
+ busy = true
289
+ currentOp = label
290
+ return Promise.resolve().then(fn).catch((e) => {
291
+ log('ERR', label + ' 异常: ' + ((e && e.message) || e))
292
+ return { ok: false, kind: 'unknown', message: (e && e.message) || String(e) }
293
+ }).then((r) => { busy = false; currentOp = null; return r })
294
+ }
295
+
296
+ function precheck(opts) {
297
+ const o = opts || {}
298
+ const st = inspectRepoState()
299
+ if (!st.isRepo) return { ok: false, kind: 'norepo', message: ERR_TEXT.norepo }
300
+ if (st.locked) {
301
+ const stale = st.lockAgeMs > 10 * 60 * 1000
302
+ return {
303
+ ok: false, kind: 'lock',
304
+ message: ERR_TEXT.lock + (stale ? '(已存在超过 10 分钟,可能是陈旧锁,可尝试清理)' : ',请稍后重试'),
305
+ staleLock: stale, lockFile: st.lockFile
306
+ }
307
+ }
308
+ if (st.inProgress && !o.allowInProgress) {
309
+ return { ok: false, kind: 'conflict', message: '仓库正处于 ' + st.inProgress + ' 过程,请先中止或完成', inProgress: st.inProgress }
310
+ }
311
+ return { ok: true }
312
+ }
313
+
314
+ // ======================= 路径 =======================
315
+ function relOf(absPath) {
316
+ if (!repoRoot) return null
317
+ const r = path.relative(repoRoot, absPath)
318
+ if (!r || r.startsWith('..')) return null
319
+ return r.split(path.sep).join('/')
320
+ }
321
+ function absOf(rel) {
322
+ if (!repoRoot || !rel) return null
323
+ return path.join(repoRoot, rel.split('/').join(path.sep))
324
+ }
325
+ // rel 必须是规范相对路径,且回算一致(防 ../ 与绝对路径)
326
+ function normRel(rel) {
327
+ if (!rel || typeof rel !== 'string' || rel.indexOf('\0') >= 0) return null
328
+ if (/^[\/\\]/.test(rel) || /^[a-zA-Z]:/.test(rel)) return null
329
+ const segs = rel.split('/')
330
+ if (segs.some((x) => x === '..' || x === '.' || x === '')) return null
331
+ if (segs.indexOf('.git') >= 0) return null
332
+ const abs = absOf(rel)
333
+ if (!abs) return null
334
+ return relOf(abs) === rel ? rel : null
335
+ }
336
+
337
+ // ======================= 状态 =======================
338
+ async function getState() {
339
+ const st = inspectRepoState()
340
+ if (!st.isRepo) {
341
+ return { ok: true, data: Object.assign({}, st, { files: [], dirty: 0, branch: '', ahead: 0, behind: 0, busy, currentOp }) }
342
+ }
343
+ const r = await runGit(['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], { timeout: 20000, label: 'status' })
344
+ if (!r.ok) {
345
+ return { ok: false, kind: r.kind, message: r.message, data: Object.assign({}, st, { files: null, dirty: -1, busy, currentOp }) }
346
+ }
347
+ const files = []
348
+ let branch = ''
349
+ let ahead = 0
350
+ let behind = 0
351
+ let detached = false
352
+ const parts = r.data.split('\0')
353
+ for (let i = 0; i < parts.length; i++) {
354
+ const line = parts[i]
355
+ if (!line) continue
356
+ if (line[0] === '#') {
357
+ if (line.startsWith('# branch.head ')) {
358
+ const v = line.slice(14).trim()
359
+ if (v === '(detached)') detached = true
360
+ else branch = v
361
+ } else if (line.startsWith('# branch.ab ')) {
362
+ const m = line.match(/\+(\d+)\s+-(\d+)/)
363
+ if (m) { ahead = parseInt(m[1], 10); behind = parseInt(m[2], 10) }
364
+ }
365
+ continue
366
+ }
367
+ const code = line[0]
368
+ if (code === '1' || code === '2') {
369
+ const seg = line.split(' ')
370
+ const xy = seg[1] || '..'
371
+ // type 2(rename/copy):索引 8 是 <X><score>,path 从 9 开始
372
+ const p = (code === '2' ? seg.slice(9) : seg.slice(8)).join(' ')
373
+ const item = { path: p, x: xy[0], y: xy[1], kind: 'tracked' }
374
+ if (code === '2') {
375
+ i++
376
+ if (parts[i]) item.oldPath = parts[i]
377
+ }
378
+ if (seg[2] && seg[2].charAt(0) === 'S') item.submodule = true
379
+ files.push(item)
380
+ } else if (code === '?') {
381
+ files.push({ path: line.slice(2), x: '?', y: '?', kind: 'untracked' })
382
+ } else if (code === 'u') {
383
+ const seg = line.split(' ')
384
+ files.push({ path: seg.slice(10).join(' '), x: 'U', y: 'U', kind: 'conflict' })
385
+ }
386
+ }
387
+ return {
388
+ ok: true,
389
+ data: {
390
+ isRepo: true, repoRoot, scopeRel, branch, detached, ahead, behind,
391
+ files, dirty: files.length,
392
+ locked: st.locked, lockAgeMs: st.lockAgeMs, inProgress: st.inProgress,
393
+ busy, currentOp
394
+ }
395
+ }
396
+ }
397
+
398
+ // ======================= 读 =======================
399
+ async function fileLog(rel, limit, skip) {
400
+ const safe = normRel(rel)
401
+ if (!safe) return { ok: false, kind: 'badrev', message: '路径不合法' }
402
+ const lim = Math.max(1, parseInt(limit, 10) || 10)
403
+ const sk = Math.max(0, parseInt(skip, 10) || 0)
404
+ // --follow 与 --skip 组合时 git 的结果会错位(实测会重复某条),
405
+ // 因此先取 skip+limit 条,再在内存里切片。
406
+ const r = await runGit(['log', '-' + String(sk + lim), '--follow',
407
+ '--date=format:%Y-%m-%d %H:%M',
408
+ '--pretty=format:%H%x1f%h%x1f%ad%x1f%an%x1f%s', '--', safe], { label: 'log' })
409
+ if (!r.ok) return r
410
+ const all = r.data.split('\n').filter(Boolean).map((line) => {
411
+ const p = line.split('\x1f')
412
+ return { hash: p[0], short: p[1], date: p[2], author: p[3], subject: p[4] || '' }
413
+ })
414
+ return { ok: true, data: sk ? all.slice(sk) : all }
415
+ }
416
+
417
+ // 该文件被改过多少次(用于"修订次数"与分页)
418
+ async function repoLogCount() {
419
+ const r = await runGit(['rev-list', '--count', 'HEAD'], { label: 'rev-count-all' })
420
+ if (!r.ok) return r
421
+ return { ok: true, data: parseInt(r.data.trim(), 10) || 0 }
422
+ }
423
+
424
+ async function fileLogCount(rel) {
425
+ const safe = normRel(rel)
426
+ if (!safe) return { ok: false, kind: 'badrev', message: '路径不合法' }
427
+ const r = await runGit(['rev-list', '--count', 'HEAD', '--', safe], { label: 'rev-count' })
428
+ if (!r.ok) return r
429
+ return { ok: true, data: parseInt(r.data.trim(), 10) || 0 }
430
+ }
431
+
432
+ async function repoLog(limit, skip) {
433
+ const args = ['log', '-' + String(limit || 60)]
434
+ if (skip) args.push('--skip=' + String(skip))
435
+ args.push('--date=format:%Y-%m-%d %H:%M',
436
+ '--pretty=format:%H%x1f%h%x1f%ad%x1f%an%x1f%s', '--')
437
+ const r = await runGit(args, { label: 'log' })
438
+ if (!r.ok) return r
439
+ return { ok: true, data: r.data.split('\n').filter(Boolean).map((line) => {
440
+ const p = line.split('\x1f')
441
+ return { hash: p[0], short: p[1], date: p[2], author: p[3], subject: p[4] || '' }
442
+ }) }
443
+ }
444
+
445
+ async function verifyRev(rev) {
446
+ if (!rev || typeof rev !== 'string') return null
447
+ if (!/^[0-9a-zA-Z\/_\.\-\^~@{}]{1,120}$/.test(rev)) return null
448
+ if (rev.charAt(0) === '-') return null
449
+ const r = await runGit(['rev-parse', '--verify', '--quiet', rev + '^{commit}'], { label: 'rev-parse' })
450
+ return r.ok ? r.data.trim() : null
451
+ }
452
+
453
+ async function showAt(commit, rel) {
454
+ const sha = await verifyRev(commit)
455
+ if (!sha) return { ok: false, kind: 'badrev', message: ERR_TEXT.badrev }
456
+ const safe = rel ? normRel(rel) : null
457
+ if (rel && !safe) return { ok: false, kind: 'badrev', message: '路径不合法' }
458
+ const r = await runGit(['show', sha + (safe ? ':' + safe : '')], { label: 'show' })
459
+ return r.ok ? { ok: true, data: r.data } : r
460
+ }
461
+
462
+ async function commitFiles(commit) {
463
+ const sha = await verifyRev(commit)
464
+ if (!sha) return { ok: false, kind: 'badrev', message: ERR_TEXT.badrev }
465
+ // -z:路径按 NUL 分隔且不转义,中文名不会被 C-quote
466
+ const r = await runGit(['show', '-z', '--name-status', '--format=', '--no-renames', sha], { label: 'show-files' })
467
+ if (!r.ok) return r
468
+ const toks = r.data.split('\0').filter((x) => x !== '')
469
+ const out = []
470
+ for (let i = 0; i + 1 < toks.length; i += 2) {
471
+ const status = (toks[i] || '').trim()
472
+ const p = toks[i + 1] || ''
473
+ if (status && p) out.push({ status: status.charAt(0), path: p })
474
+ }
475
+ return { ok: true, data: out }
476
+ }
477
+
478
+ async function diffOf(commit, rel) {
479
+ const safe = rel ? normRel(rel) : null
480
+ if (rel && !safe) return { ok: false, kind: 'badrev', message: '路径不合法' }
481
+ if (commit) {
482
+ const sha = await verifyRev(commit)
483
+ if (!sha) return { ok: false, kind: 'badrev', message: ERR_TEXT.badrev }
484
+ return runGit(['show', '--format=', '-U999999', '--no-color', '--no-ext-diff', sha, '--'].concat(safe ? [safe] : []), { label: 'diff' })
485
+ }
486
+ // 未跟踪文件(新建笔记)diff 恒为空,这里直接合成一份新增 diff
487
+ if (safe) {
488
+ const st = await runGit(['status', '--porcelain=v1', '-z', '--', safe], { label: 'status-one' })
489
+ if (st.ok && st.data.indexOf('??') === 0) {
490
+ try {
491
+ const content = fs.readFileSync(absOf(safe), 'utf8')
492
+ const lines = content.split('\n')
493
+ return {
494
+ ok: true,
495
+ data: '--- /dev/null\n+++ b/' + safe + '\n@@ 新文件,共 ' + lines.length + ' 行 @@\n' + lines.map((l) => '+' + l).join('\n')
496
+ }
497
+ } catch (e) {
498
+ return { ok: true, data: '(无法读取文件:' + e.message + ')' }
499
+ }
500
+ }
501
+ }
502
+ const r = await runGit(['diff', '-U999999', '--no-color', '--no-ext-diff', '--'].concat(safe ? [safe] : []), { label: 'diff' })
503
+ if (!r.ok) return r
504
+ if (r.data) return { ok: true, data: r.data }
505
+ const r2 = await runGit(['diff', '--cached', '-U999999', '--no-color', '--'].concat(safe ? [safe] : []), { label: 'diff-staged' })
506
+ return { ok: true, data: r2.ok ? r2.data : '' }
507
+ }
508
+
509
+ // 工作区 vs 指定提交:看"那次提交到现在改了什么"
510
+ async function diffWork(commit, rel) {
511
+ const safe = normRel(rel)
512
+ if (!safe) return { ok: false, kind: 'badrev', message: '路径不合法' }
513
+ const sha = await verifyRev(commit)
514
+ if (!sha) return { ok: false, kind: 'badrev', message: ERR_TEXT.badrev }
515
+ const r = await runGit(['diff', '-U999999', '--no-color', '--no-ext-diff', sha, '--', safe], { label: 'diff-work' })
516
+ return r.ok ? { ok: true, data: r.data } : r
517
+ }
518
+
519
+ // ======================= 备份 =======================
520
+ function pruneBackups(keep) {
521
+ try {
522
+ const list = fs.readdirSync(backupDir).map((f) => ({ n: f, t: fs.statSync(path.join(backupDir, f)).mtimeMs }))
523
+ .sort((a, b) => b.t - a.t)
524
+ list.slice(keep || 300).forEach((f) => { try { fs.unlinkSync(path.join(backupDir, f.n)) } catch (e) {} })
525
+ } catch (e) {}
526
+ }
527
+
528
+ // { ok, path?, skip?, reason? };!ok 时调用方必须中止操作
529
+ function backupFile(rel, tag) {
530
+ const safe = normRel(rel)
531
+ if (!safe) return { ok: false, reason: '路径不合法' }
532
+ const abs = absOf(safe)
533
+ if (!fs.existsSync(abs)) return { ok: true, skip: true, reason: '源文件不存在' }
534
+ try {
535
+ const before = fs.statSync(abs).size
536
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
537
+ const dest = path.join(backupDir, stamp + '_' + (tag || 'bak') + '_' + safe.replace(/[\/\\]/g, '__'))
538
+ mkdirp(backupDir)
539
+ fs.copyFileSync(abs, dest)
540
+ const after = fs.statSync(dest).size
541
+ if (after !== before) return { ok: false, reason: '备份大小不一致(' + after + '≠' + before + ')' }
542
+ pruneBackups(300)
543
+ return { ok: true, path: dest, size: after }
544
+ } catch (e) {
545
+ return { ok: false, reason: e.message }
546
+ }
547
+ }
548
+
549
+ // ======================= 写操作 =======================
550
+ async function stage(rel) {
551
+ const pre = precheck()
552
+ if (!pre.ok) return pre
553
+ const safe = rel ? normRel(rel) : null
554
+ if (rel && !safe) return { ok: false, kind: 'badrev', message: '路径不合法' }
555
+ return enqueue('stage', () => runGit(['add', '-A', '--'].concat(safe ? [safe] : (scopeRel ? [scopeRel, META_DIR].concat(rootVcsPresent()) : ['.'])), { label: 'add' }))
556
+ }
557
+
558
+ async function unstage(rel) {
559
+ const pre = precheck()
560
+ if (!pre.ok) return pre
561
+ const safe = rel ? normRel(rel) : null
562
+ if (rel && !safe) return { ok: false, kind: 'badrev', message: '路径不合法' }
563
+ return enqueue('unstage', async () => {
564
+ const r = await runGit(['restore', '--staged', '--'].concat(safe ? [safe] : (scopeRel ? [scopeRel, META_DIR].concat(rootVcsPresent()) : ['.'])), { label: 'unstage' })
565
+ if (r.ok) return r
566
+ return runGit(['reset', 'HEAD', '--'].concat(safe ? [safe] : (scopeRel ? [scopeRel, META_DIR].concat(rootVcsPresent()) : ['.'])), { label: 'unstage-reset' })
567
+ })
568
+ }
569
+
570
+ // 该文件是否存在未提交改动(含暂存/未跟踪);只有这种情况回溯才会丢数据
571
+ async function isDirty(safe) {
572
+ const st = await runGit(['status', '--porcelain=v1', '-z', '--', safe], { timeout: 15000, label: 'status-one' })
573
+ return st.ok && !!st.data.trim()
574
+ }
575
+
576
+ // 用 cat-file 取字节直接落盘:绕开 .gitattributes 与 autocrlf,保证字节级一致
577
+ // 直接写字节;是否覆盖未提交改动由调用方(restoreTo/discard)在确认后决定
578
+ async function writeBlob(revPath, rel) {
579
+ const safe = normRel(rel)
580
+ if (!safe) return { ok: false, kind: 'badrev', message: '路径不合法' }
581
+ const blob = await runGit(['cat-file', 'blob', revPath + ':' + safe], { label: 'cat-file', buffer: true })
582
+ if (!blob.ok) return { ok: false, kind: blob.kind, message: blob.message }
583
+ try {
584
+ const abs = absOf(safe)
585
+ mkdirp(path.dirname(abs))
586
+ fs.writeFileSync(abs, blob.data)
587
+ } catch (e) {
588
+ return { ok: false, kind: 'perm', message: '写入失败:' + e.message }
589
+ }
590
+ return { ok: true, data: { path: safe } }
591
+ }
592
+
593
+ async function discard(rel, opts) {
594
+ const o = opts || {}
595
+ const pre = precheck()
596
+ if (!pre.ok) return pre
597
+ const safe = normRel(rel)
598
+ if (!safe) return { ok: false, kind: 'badrev', message: '路径不合法' }
599
+ const tracked = await runGit(['ls-files', '--error-unmatch', '--', safe], { label: 'ls-files' })
600
+
601
+ // 已跟踪文件:回退到 HEAD。丢弃的内容本身来自工作区,不做备份,但必须先确认
602
+ if (tracked.ok) {
603
+ if (!o.force) {
604
+ return {
605
+ ok: false, kind: 'confirm-needed', dirty: true, path: safe,
606
+ message: '撤销会丢弃这篇笔记的未提交改动,无法恢复。'
607
+ }
608
+ }
609
+ return enqueue('discard', async () => {
610
+ // 先把该文件从暂存区撤出(index 回到 HEAD),否则工作区恢复了、
611
+ // git status 仍会因为它有暂存内容而继续列出这个文件,看起来像"丢弃没生效"
612
+ await runGit(['reset', 'HEAD', '--', safe], { label: 'reset-one' })
613
+ const r = await writeBlob('HEAD', safe)
614
+ if (r.ok) r.data.action = 'restored'
615
+ return r
616
+ })
617
+ }
618
+
619
+ // 未跟踪文件:删除后 git 里没有任何副本,保留一份备份(唯一例外)
620
+ return enqueue('discard', async () => {
621
+ const bk = backupFile(safe, 'before-discard-untracked')
622
+ if (!bk.ok) return { ok: false, kind: 'backup', message: ERR_TEXT.backup + ':' + bk.reason }
623
+ try {
624
+ const abs = absOf(safe)
625
+ if (fs.existsSync(abs)) fs.unlinkSync(abs)
626
+ return { ok: true, data: { backup: bk.path || null, skippedBackup: !!bk.skip, action: 'removed', path: safe } }
627
+ } catch (e) {
628
+ return { ok: false, kind: 'perm', message: '删除失败:' + e.message, backup: bk.path || null }
629
+ }
630
+ })
631
+ }
632
+
633
+ async function restoreTo(commit, rel, opts) {
634
+ const o = opts || {}
635
+ const pre = precheck()
636
+ if (!pre.ok) return pre
637
+ const sha = await verifyRev(commit)
638
+ if (!sha) return { ok: false, kind: 'badrev', message: ERR_TEXT.badrev }
639
+ const safe = normRel(rel)
640
+ if (!safe) return { ok: false, kind: 'badrev', message: '路径不合法' }
641
+ // 工作区有未提交改动时,回溯会直接覆盖它们;不做备份,但必须先让用户确认
642
+ const dirty = await isDirty(safe)
643
+ if (dirty && !o.force) {
644
+ return {
645
+ ok: false, kind: 'confirm-needed', dirty: true, path: safe, commit: sha,
646
+ message: '这篇笔记有未提交的改动,回溯会直接覆盖它们,且无法恢复。'
647
+ }
648
+ }
649
+ return enqueue('restore', async () => {
650
+ const r = await writeBlob(sha, safe)
651
+ if (r.ok) { r.data.commit = sha; r.data.hadLocalChanges = dirty }
652
+ return r
653
+ })
654
+ }
655
+
656
+ // ======================= 提交 =======================
657
+ function formatMessage(tpl, extra) {
658
+ const now = new Date()
659
+ const pad = (n) => String(n).padStart(2, '0')
660
+ const date = now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate())
661
+ const time = pad(now.getHours()) + ':' + pad(now.getMinutes()) + ':' + pad(now.getSeconds())
662
+ return String(tpl || DEFAULTS.commitMessage)
663
+ .replace(/\{\{date\}\}/g, date)
664
+ .replace(/\{\{time\}\}/g, time)
665
+ .replace(/\{\{datetime\}\}/g, date + ' ' + time)
666
+ .replace(/\{\{count\}\}/g, String((extra && extra.count) || 0))
667
+ .trim()
668
+ }
669
+
670
+ // 实际提交逻辑(不占 enqueue 队列)。push/sync 内部已经在队列里,必须用这个版本,
671
+ // 否则 commitAll 会再次 enqueue → 被 busy 挡掉 → 推送永远返回"上一个操作还在进行中"。
672
+ async function commitAllCore(message) {
673
+ const cfg = readConfig()
674
+ // 提交范围固定为工作区:scopeRel 为空说明工作区就是仓库根,此时等价于整个仓库;
675
+ // 非空则只提交工作区内的改动,避免把仓库根部的无关文件一起带上。
676
+ // 例外:.cmark 是工作区元数据(排序/只读状态/附件),固定住在仓库根,
677
+ // 工作区是仓库的子目录时必须显式带上,否则 tree-order.json 这类改动永远提交不进去。
678
+ // 例外二:仓库根的 .gitignore / .gitattributes(插件维护的版本控制配置)同理,
679
+ // 不带上的话它们会永远留在「未提交改动」里。
680
+ const scope = scopeRel ? [scopeRel, META_DIR].concat(rootVcsPresent()) : []
681
+ const scopeArgs = scope.length ? ['--'].concat(scope) : []
682
+ const addArgs = ['add', '-A'].concat(scopeArgs)
683
+ const add = await runGit(addArgs, { label: 'add' })
684
+ if (!add.ok) return add
685
+
686
+ const staged = await runGit(['diff', '--cached', '--name-only', '-z'].concat(scopeArgs), { label: 'diff-cached' })
687
+ if (!staged.ok) return staged
688
+ const names = staged.data.split('\0').filter(Boolean)
689
+ if (!names.length) return { ok: false, kind: 'nothing', message: ERR_TEXT.nothing }
690
+
691
+ const msg = formatMessage(message || cfg.commitMessage, { count: names.length })
692
+ const r = await runGit(['commit', '-m', msg], { label: 'commit', timeout: 90000 })
693
+ if (!r.ok) return r
694
+ return { ok: true, data: { message: msg, files: names.length } }
695
+ }
696
+
697
+ async function commitAll(message) {
698
+ const pre = precheck()
699
+ if (!pre.ok) return pre
700
+ return enqueue('commit', () => commitAllCore(message))
701
+ }
702
+
703
+ // 远端列表:空数组表示这是纯本地仓库,pull/push 无从下手
704
+ async function remoteNames() {
705
+ const r = await runGit(['remote'], { label: 'remote', timeout: 15000 })
706
+ if (!r.ok) return []
707
+ return String(r.data || '').split('\n').map((x) => x.trim()).filter(Boolean)
708
+ }
709
+
710
+ // fetch 后先判断能否快进;默认绝不 rebase,避免把冲突标记写进笔记
711
+ async function pull() {
712
+ const pre = precheck()
713
+ if (!pre.ok) return pre
714
+ if (!(await remoteNames()).length) return { ok: false, kind: 'noremote', message: ERR_TEXT.noremote }
715
+ return enqueue('pull', async () => {
716
+ const f = await runGit(['fetch', '--prune'], { label: 'fetch', timeout: 120000 })
717
+ if (!f.ok) return f
718
+ const up = await runGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], { label: 'upstream' })
719
+ if (!up.ok) return { ok: false, kind: 'noUpstream', message: ERR_TEXT.noUpstream }
720
+
721
+ const behindR = await runGit(['rev-list', '--count', 'HEAD..@{u}'], { label: 'count-behind' })
722
+ const aheadR = await runGit(['rev-list', '--count', '@{u}..HEAD'], { label: 'count-ahead' })
723
+ const behind = behindR.ok ? (parseInt(behindR.data.trim(), 10) || 0) : 0
724
+ const ahead = aheadR.ok ? (parseInt(aheadR.data.trim(), 10) || 0) : 0
725
+
726
+ if (behind === 0) return { ok: true, data: { behind: 0, ahead, note: '已是最新' } }
727
+ if (ahead > 0) {
728
+ return {
729
+ ok: false, kind: 'diverged',
730
+ message: '本地 ' + ahead + ' 个提交、远端 ' + behind + ' 个提交,无法自动合并。请先推送本地提交或手动处理。',
731
+ localAhead: ahead, remoteBehind: behind
732
+ }
733
+ }
734
+ const m = await runGit(['merge', '--ff-only', '@{u}'], { label: 'merge-ff', timeout: 120000 })
735
+ if (!m.ok) return m
736
+ return { ok: true, data: { behind, ahead: 0, output: String(m.data || '').trim() } }
737
+ })
738
+ }
739
+
740
+ async function push() {
741
+ const pre = precheck()
742
+ if (!pre.ok) return pre
743
+ if (!(await remoteNames()).length) return { ok: false, kind: 'noremote', message: ERR_TEXT.noremote }
744
+ return enqueue('push', async () => {
745
+ // 规范:推送 = 提交 + 推送。先把工作区改动落成提交,否则它们推不上去。
746
+ // 用 commitAllCore:已经在 enqueue 内部,不能再嵌套 enqueue。
747
+ const c = await commitAllCore()
748
+ if (!c.ok && c.kind !== 'nothing') return c
749
+ const br = await runGit(['symbolic-ref', '--short', '-q', 'HEAD'], { label: 'branch' })
750
+ if (!br.ok) return { ok: false, kind: 'detached', message: ERR_TEXT.detached }
751
+ const branch = br.data.trim()
752
+ if (!branch || branch === 'HEAD') return { ok: false, kind: 'detached', message: ERR_TEXT.detached }
753
+
754
+ const up = await runGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], { label: 'upstream' })
755
+ if (!up.ok) {
756
+ const remotes = await runGit(['remote'], { label: 'remote' })
757
+ const first = remotes.ok ? ((remotes.data.split('\n').map((x) => x.trim()).filter(Boolean))[0] || 'origin') : 'origin'
758
+ const r = await runGit(['push', '-u', first, branch], { label: 'push-setup', timeout: 180000 })
759
+ if (!r.ok) return r
760
+ return { ok: true, data: { branch, remote: first, setUpstream: true, committed: c.ok, output: String(r.data || '').trim() } }
761
+ }
762
+ const r = await runGit(['push'], { label: 'push', timeout: 180000 })
763
+ if (!r.ok) return r
764
+ return { ok: true, data: { branch, committed: c.ok, output: String(r.data || '').trim() } }
765
+ })
766
+ }
767
+
768
+ async function sync(message, opt) {
769
+ const o = Object.assign({ pull: true, push: true }, opt || {})
770
+ const st = inspectRepoState()
771
+ if (!st.isRepo) return { ok: false, kind: 'norepo', message: ERR_TEXT.norepo }
772
+ if (st.locked) return { ok: false, kind: 'lock', message: ERR_TEXT.lock }
773
+ if (st.inProgress) return { ok: false, kind: 'conflict', message: '仓库处于 ' + st.inProgress + ' 过程,请先中止或完成' }
774
+
775
+ const cfg = readConfig()
776
+ const steps = []
777
+ const c = await commitAll(message)
778
+ if (c.ok) steps.push('commit')
779
+ else if (c.kind !== 'nothing') return { ok: false, kind: c.kind, message: c.message, steps }
780
+
781
+ // 纯本地仓库:提交就是全部工作,没有远端可拉可推,直接算成功
782
+ if (!(await remoteNames()).length) {
783
+ writeConfig({ lastRunAt: Date.now(), failCount: 0 })
784
+ log('INFO', 'sync 完成(本地仓库,无远程地址): ' + (steps.join(',') || '无改动'))
785
+ return {
786
+ ok: true,
787
+ data: {
788
+ steps, local: true,
789
+ message: c.ok ? c.data.message : '',
790
+ note: '已提交到本机(未配置远程仓库)'
791
+ }
792
+ }
793
+ }
794
+
795
+ // 规范:同步 = 提交 + 拉取 + 推送。三步固定执行,不受配置开关影响。
796
+ if (o.pull) {
797
+ const p = await pull()
798
+ if (!p.ok) return { ok: false, kind: p.kind, message: p.message, steps }
799
+ steps.push('pull')
800
+ }
801
+ if (o.push) {
802
+ const ps = await push()
803
+ if (!ps.ok) return { ok: false, kind: ps.kind, message: ps.message, steps }
804
+ steps.push('push')
805
+ }
806
+ writeConfig({ lastRunAt: Date.now(), failCount: 0 })
807
+ log('INFO', 'sync 完成: ' + (steps.join(',') || '无改动'))
808
+ return { ok: true, data: { steps, message: c.ok ? c.data.message : '' } }
809
+ }
810
+
811
+ // ======================= 自动同步 =======================
812
+ let intervalTimer = null
813
+ let stopEditTimer = null
814
+ let catchupTimer = null
815
+ // 用于给 UI 展示"下一次自动同步还有多久"
816
+ let intervalStartedAt = 0
817
+ let stopEditDeadline = 0
818
+
819
+ function clearTimers() {
820
+ if (intervalTimer) { clearInterval(intervalTimer); intervalTimer = null }
821
+ if (stopEditTimer) { clearTimeout(stopEditTimer); stopEditTimer = null }
822
+ if (catchupTimer) { clearTimeout(catchupTimer); catchupTimer = null }
823
+ }
824
+
825
+ function scheduleAuto() {
826
+ clearTimers()
827
+ intervalStartedAt = 0
828
+ stopEditDeadline = 0
829
+ const cfg = readConfig()
830
+ if (!cfg.autoSync) return
831
+ // 两种触发方式互斥:停止编辑模式下不装固定间隔定时器,
832
+ // 但启动时要给出一个截止时间,UI 才能显示秒级倒计时
833
+ if (cfg.autoMode === 'stop-edit') {
834
+ const wait = Math.max(60000, (cfg.onStopEditMinutes || 1) * 60000)
835
+ if (!stopEditDeadline) stopEditDeadline = Date.now() + wait
836
+ return
837
+ }
838
+ const iv = Math.max(60000, cfg.intervalMs || DEFAULTS.intervalMs)
839
+ intervalStartedAt = Date.now()
840
+ intervalTimer = setInterval(() => {
841
+ intervalStartedAt = Date.now()
842
+ autoRun('interval')
843
+ }, iv)
844
+ if (intervalTimer.unref) intervalTimer.unref()
845
+ const lastOk = cfg.lastRunAt || 0
846
+ if (lastOk && Date.now() - lastOk >= iv) {
847
+ catchupTimer = setTimeout(() => { catchupTimer = null; autoRun('catchup') }, 8000)
848
+ if (catchupTimer.unref) catchupTimer.unref()
849
+ }
850
+ }
851
+
852
+ function noteEditActivity() {
853
+ const cfg = readConfig()
854
+ if (!cfg.autoSync || cfg.autoMode !== 'stop-edit' || !cfg.onStopEditMinutes) return
855
+ if (stopEditTimer) clearTimeout(stopEditTimer)
856
+ const wait = Math.max(60000, cfg.onStopEditMinutes * 60000)
857
+ stopEditDeadline = Date.now() + wait
858
+ stopEditTimer = setTimeout(() => { stopEditTimer = null; stopEditDeadline = 0; autoRun('stop-edit') }, wait)
859
+ if (stopEditTimer.unref) stopEditTimer.unref()
860
+ }
861
+
862
+ // 供 UI 显示倒计时:返回 { at, mode } 或 null
863
+ function nextAutoAt() {
864
+ const cfg = readConfig()
865
+ if (!cfg.autoSync) return null
866
+ if (cfg.autoMode === 'stop-edit') {
867
+ return (stopEditDeadline && stopEditDeadline > Date.now())
868
+ ? { at: stopEditDeadline, mode: 'stop-edit' } : null
869
+ }
870
+ const iv = Math.max(60000, cfg.intervalMs || DEFAULTS.intervalMs)
871
+ if (!intervalStartedAt) return null
872
+ let at = intervalStartedAt + iv
873
+ const now = Date.now()
874
+ while (at <= now) at += iv
875
+ return { at, mode: 'interval' }
876
+ }
877
+
878
+ let autoBusy = false
879
+ async function autoRun(reason) {
880
+ if (autoBusy) return { ok: false, kind: 'busy', message: '自动同步已在执行' }
881
+ const cfg = readConfig()
882
+ if (!cfg.autoSync && reason !== 'manual') return { ok: false, kind: 'off', message: '自动同步未开启' }
883
+ autoBusy = true
884
+ try {
885
+ const st = await getState()
886
+ if (!st.ok) return st
887
+ if (!st.data.isRepo) return { ok: false, kind: 'norepo', message: ERR_TEXT.norepo }
888
+ if (!st.data.dirty) return { ok: true, data: { steps: [], note: '无改动' } }
889
+
890
+ const r = await sync(null, { pull: true, push: cfg.pushEnabled })
891
+ if (r.ok) {
892
+ writeConfig({ lastRunAt: Date.now(), failCount: 0, lastAutoResult: { at: new Date().toISOString(), ok: true, steps: r.data.steps } })
893
+ log('INFO', 'auto(' + reason + ') 完成: ' + r.data.steps.join(','))
894
+ } else {
895
+ const fc = (readConfig().failCount || 0) + 1
896
+ writeConfig({ failCount: fc, lastAutoResult: { at: new Date().toISOString(), ok: false, kind: r.kind, message: r.message } })
897
+ log('ERR', 'auto(' + reason + ') 失败(' + fc + '): ' + r.kind + ' ' + r.message)
898
+ // 连续失败 5 次后暂停自动同步,避免无限重试
899
+ if (fc >= 5) {
900
+ writeConfig({ autoSync: false })
901
+ clearTimers()
902
+ log('ERR', '连续失败 ' + fc + ' 次,已暂停自动同步')
903
+ }
904
+ }
905
+ return r
906
+ } catch (e) {
907
+ log('ERR', 'auto 异常: ' + ((e && e.message) || e))
908
+ return { ok: false, kind: 'unknown', message: (e && e.message) || String(e) }
909
+ } finally {
910
+ autoBusy = false
911
+ // 停止编辑模式:一次自动触发结束后清掉截止时间,等下次编辑再重新计时,
912
+ // 否则倒计时会停在 0 秒
913
+ try {
914
+ const c2 = readConfig()
915
+ if (c2.autoMode === 'stop-edit' && reason !== 'manual') stopEditDeadline = 0
916
+ } catch (e) {}
917
+ }
918
+ }
919
+
920
+ // ======================= 仓库初始化 =======================
921
+ // mode: 'local' 完全本地 | 'remote' 绑定远程
922
+ // 只做安全操作:init、写 .gitignore、add、首次 commit、可选 push -u
923
+ // 绝不 clone 覆盖已有文件,绝不 reset --hard
924
+ async function initRepo(opts) {
925
+ const o = opts || {}
926
+ if (repoRoot) return { ok: false, kind: 'exists', message: '当前目录已经在 git 仓库中:' + repoRoot }
927
+ if (!fs.existsSync(WS) || !fs.statSync(WS).isDirectory()) {
928
+ return { ok: false, kind: 'norepo', message: '笔记目录不存在:' + WS }
929
+ }
930
+ const branch = String(o.branch || 'main').replace(/[^A-Za-z0-9._\/-]/g, '') || 'main'
931
+
932
+ // 先把参数全部校验完,再动仓库;否则失败会留下半初始化的 .git
933
+ let url = ''
934
+ if (o.mode === 'remote') {
935
+ url = String(o.remoteUrl || '').trim()
936
+ if (!url) return { ok: false, kind: 'badrev', message: '请填写远程仓库地址' }
937
+ if (!/^(https?:\/\/|git@|ssh:\/\/|file:\/\/|[A-Za-z]:[\\/]|\\\\|\/)/.test(url)) {
938
+ return { ok: false, kind: 'badrev', message: '远程地址格式不正确:' + url }
939
+ }
940
+ // 备份到"空仓库":远端已经有分支就别推了,直接提示改用「接入已有仓库」,
941
+ // 否则会 push 被拒(non-fast-forward),只留下一个半初始化的本地仓库
942
+ try {
943
+ const probe = await runGit(['ls-remote', '--heads', url], { label: 'ls-remote', timeout: 60000 })
944
+ if (probe.ok && String(probe.data || '').trim()) {
945
+ return {
946
+ ok: false, kind: 'badrev',
947
+ message: '这个远程仓库里已经有内容了。想把它的笔记下载下来,请改用「接入已有仓库」。'
948
+ }
949
+ }
950
+ } catch (e) {}
951
+ }
952
+
953
+ const init = await runGit(['init', '-b', branch], { label: 'init' })
954
+ if (!init.ok) {
955
+ // 老版本 git 不支持 -b
956
+ const init2 = await runGit(['init'], { label: 'init-plain' })
957
+ if (!init2.ok) return init2
958
+ }
959
+ locateRepo()
960
+ if (!repoRoot) return { ok: false, kind: 'unknown', message: 'git init 之后仍未识别到仓库' }
961
+
962
+ const steps = ['init']
963
+
964
+ // .gitignore:忽略编辑器状态,但保留 attachments
965
+ try {
966
+ const gi = path.join(repoRoot, '.gitignore')
967
+ if (!fs.existsSync(gi)) {
968
+ fs.writeFileSync(gi, [
969
+ '.obsidian/workspace.json',
970
+ '.obsidian/workspace-mobile.json',
971
+ '.obsidian/cache',
972
+ '.DS_Store',
973
+ 'Thumbs.db',
974
+ 'desktop.ini',
975
+ ''
976
+ ].join('\n'), 'utf8')
977
+ steps.push('gitignore')
978
+ }
979
+ } catch (e) {}
980
+
981
+ if (o.userName || o.userEmail) {
982
+ if (o.userName) await runGit(['config', 'user.name', String(o.userName)], { label: 'cfg-name' })
983
+ if (o.userEmail) await runGit(['config', 'user.email', String(o.userEmail)], { label: 'cfg-email' })
984
+ steps.push('identity')
985
+ }
986
+
987
+ if (o.mode === 'remote' && url) {
988
+ const add = await runGit(['remote', 'add', 'origin', url], { label: 'remote-add' })
989
+ if (!add.ok && !/already exists/i.test(add.message || '')) return Object.assign(add, { steps })
990
+ steps.push('remote')
991
+ }
992
+
993
+ // 首次提交(目录里已有笔记时)
994
+ const st0 = await runGit(['status', '--porcelain'], { label: 'status' })
995
+ if (st0.ok && st0.data.trim()) {
996
+ const add = await runGit(['add', '-A'], { label: 'add' })
997
+ if (!add.ok) return Object.assign(add, { steps })
998
+ const c = await runGit(['commit', '-m', 'notes: 初始化仓库'], { label: 'commit', timeout: 120000 })
999
+ if (!c.ok) {
1000
+ // 首次提交失败(常见于未配置 user.name/email):回滚本次新建的 .git,避免留下半初始化状态
1001
+ try {
1002
+ const gd = gitDir()
1003
+ if (gd && fs.existsSync(gd)) fs.rmSync(gd, { recursive: true, force: true })
1004
+ repoRoot = null
1005
+ scopeRel = ''
1006
+ } catch (e) {}
1007
+ return {
1008
+ ok: false, kind: c.kind,
1009
+ message: '首次提交失败:' + c.message + '(已回滚,可填写提交者姓名与邮箱后重试)',
1010
+ steps
1011
+ }
1012
+ }
1013
+ steps.push('commit')
1014
+ }
1015
+
1016
+ // 绑定远程后尝试首次推送
1017
+ if (o.mode === 'remote' && o.remoteUrl && o.push !== false) {
1018
+ const ps = await runGit(['push', '-u', 'origin', branch], { label: 'push-first', timeout: 180000 })
1019
+ if (ps.ok) steps.push('push')
1020
+ else {
1021
+ return {
1022
+ ok: true,
1023
+ data: {
1024
+ steps, warning: '推送未成功:' + ps.message + '。本地仓库已建好,可稍后在面板里点「推送」。',
1025
+ repoRoot, branch
1026
+ }
1027
+ }
1028
+ }
1029
+ }
1030
+
1031
+ log('INFO', '初始化仓库完成 mode=' + (o.mode || 'local') + ' branch=' + branch + ' steps=' + steps.join(','))
1032
+ return { ok: true, data: { steps, repoRoot, branch, scopeRel } }
1033
+ }
1034
+
1035
+ /**
1036
+ * 接入已有远程仓库(云 → 本地)。
1037
+ * 安全约束:只允许空文件夹(最多存在自动生成的示例笔记与 .attachments)。
1038
+ * 只要检测到用户文件或已有 .git 就直接拒绝,绝不覆盖任何本地内容。
1039
+ */
1040
+ // 校验远端地址是否可用:能 ls-remote 通才算有效。
1041
+ // 空仓库(一个分支都没有)也算有效——那是合法的"新建仓库"。
1042
+ async function checkRemote (url) {
1043
+ const u = String(url || '').trim()
1044
+ if (!u) return { ok: false, message: '请填写远端地址' }
1045
+ const r = await runGit(['ls-remote', '--heads', u], { label: 'checkRemote', timeout: 25000 })
1046
+ if (!r.ok) {
1047
+ const msg = String(r.message || '无法连接').replace(/\s+/g, ' ').trim().slice(0, 200)
1048
+ return { ok: false, kind: r.kind, message: msg }
1049
+ }
1050
+ const branches = String(r.data || '').split('\n')
1051
+ .map(function (line) {
1052
+ const m = /refs\/heads\/(.+)$/.exec(line.trim())
1053
+ return m ? m[1] : ''
1054
+ })
1055
+ .filter(Boolean)
1056
+ return { ok: true, branches: branches }
1057
+ }
1058
+
1059
+ // 接入前预检:把 adoptRepo 里的空目录判断独立出来,
1060
+ // 让 UI 能在弹确认框之前就发现"目录非空",而不是让用户点两次才看到错误。
1061
+ function adoptPrecheck () {
1062
+ const out = { ok: true, blocking: [], keepable: [], reason: '' }
1063
+ try {
1064
+ if (!fs.existsSync(WS) || !fs.statSync(WS).isDirectory()) {
1065
+ out.ok = false
1066
+ out.reason = '文件夹不存在:' + WS
1067
+ return out
1068
+ }
1069
+ const entries = fs.readdirSync(WS, { withFileTypes: true })
1070
+ const PLUGIN_OWNED = ['.cmark', '.attachments', 'backup']
1071
+ for (const e of entries) {
1072
+ if (e.name === '.git') { out.blocking.push('.git'); continue }
1073
+ if (PLUGIN_OWNED.indexOf(e.name) >= 0) { out.keepable.push(e.name); continue }
1074
+ if (!e.isDirectory() && /^CMark 快速上手\.(md|markdown)$/i.test(e.name)) { out.keepable.push(e.name); continue }
1075
+ out.blocking.push(e.name + (e.isDirectory() ? '/' : ''))
1076
+ }
1077
+ out.ok = out.blocking.length === 0
1078
+ if (!out.ok) out.reason = '工作区里有 ' + out.blocking.length + ' 项内容'
1079
+ } catch (e) {
1080
+ out.ok = false
1081
+ out.reason = '无法读取文件夹:' + e.message
1082
+ }
1083
+ return out
1084
+ }
1085
+
1086
+ async function adoptRepo(opts) {
1087
+ const o = opts || {}
1088
+ const url = String(o.remoteUrl || '').trim()
1089
+ const branch = String(o.branch || 'main').replace(/[^A-Za-z0-9._\-\/]/g, '') || 'main'
1090
+ if (!url) return { ok: false, kind: 'badrev', message: '请填写远程仓库地址' }
1091
+ try { if (!fs.existsSync(WS)) fs.mkdirSync(WS, { recursive: true }) } catch (e) {}
1092
+ if (!fs.existsSync(WS) || !fs.statSync(WS).isDirectory()) {
1093
+ return { ok: false, kind: 'norepo', message: '文件夹不存在:' + WS }
1094
+ }
1095
+
1096
+ // ---------- 防御性检查:目录必须为空 ----------
1097
+ let entries = []
1098
+ try { entries = fs.readdirSync(WS, { withFileTypes: true }) } catch (e) {
1099
+ return { ok: false, kind: 'perm', message: '无法读取文件夹:' + e.message }
1100
+ }
1101
+ const keepable = [] // 自动生成的示例笔记:备份后清掉
1102
+ const blocking = [] // 用户内容:直接拒绝
1103
+ // 插件自己生成的目录不算用户内容:.cmark(排序/只读/附件)、.attachments(旧版附件)、
1104
+ // backup(改动前的自动备份)。选文件夹时插件就会建 .cmark,
1105
+ // 若不剔除,用户刚选完目录再想接入远程仓库就永远被判定"非空"。
1106
+ const PLUGIN_OWNED = ['.cmark', '.attachments', 'backup']
1107
+ for (const e of entries) {
1108
+ if (e.name === '.git') { blocking.push('.git'); continue }
1109
+ if (PLUGIN_OWNED.indexOf(e.name) >= 0) { keepable.push(e.name); continue }
1110
+ if (!e.isDirectory() && /^CMark 快速上手\.(md|markdown)$/i.test(e.name)) { keepable.push(e.name); continue }
1111
+ blocking.push(e.name + (e.isDirectory() ? '/' : ''))
1112
+ }
1113
+ if (blocking.length) {
1114
+ return {
1115
+ ok: false, kind: 'dirty',
1116
+ message: '这个文件夹里已经有 ' + blocking.length + ' 项内容(' + blocking.slice(0, 3).join('、') +
1117
+ (blocking.length > 3 ? ' 等' : '') + ')。接入远程 git 仓库需要一个新的空文件夹。'
1118
+ }
1119
+ }
1120
+
1121
+ // ---------- 备份示例笔记再清掉 ----------
1122
+ let backupPath = ''
1123
+ if (keepable.length) {
1124
+ try {
1125
+ backupPath = path.join(backupDir, 'adopt-pre-' + Date.now())
1126
+ fs.mkdirSync(backupPath, { recursive: true })
1127
+ keepable.forEach(function (n) {
1128
+ try { fs.cpSync(path.join(WS, n), path.join(backupPath, n), { recursive: true }) } catch (e) {}
1129
+ })
1130
+ keepable.forEach(function (n) { try { fs.rmSync(path.join(WS, n), { recursive: true, force: true }) } catch (e) {} })
1131
+ } catch (e) {}
1132
+ }
1133
+
1134
+ // ---------- init + remote + fetch + reset ----------
1135
+ const steps = []
1136
+ const init = await runGit(['init', '-b', branch], { label: 'init' })
1137
+ if (!init.ok) {
1138
+ const init2 = await runGit(['init'], { label: 'init-plain' })
1139
+ if (!init2.ok) return init2
1140
+ }
1141
+ locateRepo()
1142
+ if (!repoRoot) return { ok: false, kind: 'unknown', message: 'git init 之后仍未识别到仓库' }
1143
+ steps.push('init')
1144
+
1145
+ const add = await runGit(['remote', 'add', 'origin', url], { label: 'remote-add' })
1146
+ if (!add.ok && !/already exists/i.test(add.message || '')) return Object.assign(add, { steps })
1147
+ steps.push('remote')
1148
+
1149
+ const fetch = await runGit(['fetch', 'origin', '--prune'], { label: 'fetch-adopt', timeout: 300000 })
1150
+ if (!fetch.ok) {
1151
+ return { ok: false, kind: fetch.kind, steps, message: '拉取远端失败:' + fetch.message + '(请检查仓库地址与访问权限)' }
1152
+ }
1153
+ steps.push('fetch')
1154
+
1155
+ const rev = await runGit(['rev-parse', '--verify', 'origin/' + branch], { label: 'rev-adopt' })
1156
+ if (!rev.ok) {
1157
+ return {
1158
+ ok: false, kind: 'badrev', steps,
1159
+ message: '远端没有分支 ' + branch + '。如果这是一个空仓库,请改用「备份到新的空仓库」。'
1160
+ }
1161
+ }
1162
+
1163
+ const reset = await runGit(['reset', '--hard', 'origin/' + branch], { label: 'reset-adopt' })
1164
+ if (!reset.ok) return Object.assign(reset, { steps })
1165
+ steps.push('reset')
1166
+
1167
+ await runGit(['branch', '--set-upstream-to=origin/' + branch, branch], { label: 'upstream' })
1168
+ steps.push('upstream')
1169
+
1170
+ log('INFO', '接入远程仓库完成 url=' + url + ' branch=' + branch + ' steps=' + steps.join(',') + (backupPath ? ' backup=' + backupPath : ''))
1171
+ return { ok: true, data: { steps, repoRoot, branch, scopeRel, backup: backupPath } }
1172
+ }
1173
+
1174
+ // ======================= 分支 =======================
1175
+ // 本地 + 远程分支列表(只读,不排队,打开面板要即时)
1176
+ async function branches() {
1177
+ if (!repoRoot) return { ok: false, kind: 'norepo', message: ERR_TEXT.norepo }
1178
+ const r = await runGit(['for-each-ref', '--format=%(refname:short)|%(refname)|%(HEAD)|%(upstream:short)', 'refs/heads', 'refs/remotes'], { label: 'for-each-ref', timeout: 30000 })
1179
+ if (!r.ok) return r
1180
+ const local = []
1181
+ const remote = []
1182
+ let cur = ''
1183
+ String(r.data || '').split('\n').forEach(function (line) {
1184
+ const p = String(line).split('|')
1185
+ if (p.length < 3) return
1186
+ const short = p[0]
1187
+ const full = p[1]
1188
+ const head = p[2]
1189
+ const up = p[3] || ''
1190
+ if (!short || !full) return
1191
+ if (full.indexOf('refs/heads/') === 0) {
1192
+ local.push({ name: short, upstream: up })
1193
+ if (head === '*') cur = short
1194
+ } else if (full.indexOf('refs/remotes/') === 0) {
1195
+ if (/\/HEAD$/.test(short)) return
1196
+ remote.push({ name: short })
1197
+ }
1198
+ })
1199
+ return { ok: true, data: { local: local, remote: remote, current: cur } }
1200
+ }
1201
+
1202
+ // 切换分支:有未提交改动时直接拒绝,避免把改动带到别的分支上
1203
+ async function checkoutBranch(name) {
1204
+ const n = String(name || '').trim()
1205
+ if (!n) return { ok: false, kind: 'badrev', message: '分支名不能为空' }
1206
+ return enqueue('checkout', async () => {
1207
+ const st = await getState()
1208
+ if (st && st.ok && st.data && typeof st.data.dirty === 'number' && st.data.dirty > 0) {
1209
+ return { ok: false, kind: 'dirty', message: '有 ' + st.data.dirty + ' 个文件未提交,先提交或撤销改动再切换分支' }
1210
+ }
1211
+ const r = await runGit(['checkout', n], { label: 'checkout', timeout: 180000 })
1212
+ if (!r.ok && n.indexOf('/') > 0) {
1213
+ // 远程分支(origin/xxx):建一个同名本地分支跟踪它
1214
+ const localName = n.split('/').slice(1).join('-')
1215
+ const r2 = await runGit(['checkout', '-b', localName, '--track', n], { label: 'checkout-track', timeout: 180000 })
1216
+ if (r2.ok) { locateRepo(); return { ok: true, data: { branch: localName } } }
1217
+ return r2
1218
+ }
1219
+ if (!r.ok) return r
1220
+ locateRepo()
1221
+ return { ok: true, data: { branch: n } }
1222
+ })
1223
+ }
1224
+
1225
+ // 新建分支并切过去(基于当前 HEAD 或指定分支)
1226
+ async function createBranch(name, from) {
1227
+ const n = String(name || '').trim().replace(/[\\/:*?"<>|\s]/g, '-')
1228
+ if (!n) return { ok: false, kind: 'badrev', message: '请填写分支名' }
1229
+ return enqueue('branch-new', async () => {
1230
+ const args = ['checkout', '-b', n]
1231
+ if (from) args.push(String(from))
1232
+ const r = await runGit(args, { label: 'branch-new', timeout: 180000 })
1233
+ if (!r.ok) return r
1234
+ locateRepo()
1235
+ return { ok: true, data: { branch: n } }
1236
+ })
1237
+ }
1238
+
1239
+ // ======================= 中断恢复 =======================
1240
+ async function abortInProgress() {
1241
+ const st = inspectRepoState()
1242
+ if (!st.inProgress) return { ok: true, data: { note: '当前没有进行中的操作' } }
1243
+ return enqueue('abort', async () => {
1244
+ const cmd = st.inProgress === 'rebase' ? ['rebase', '--abort']
1245
+ : st.inProgress === 'merge' ? ['merge', '--abort']
1246
+ : st.inProgress === 'cherry-pick' ? ['cherry-pick', '--abort']
1247
+ : ['revert', '--abort']
1248
+ const r = await runGit(cmd, { label: 'abort-' + st.inProgress })
1249
+ return r.ok ? { ok: true, data: { aborted: st.inProgress } } : r
1250
+ })
1251
+ }
1252
+
1253
+ async function clearStaleLock() {
1254
+ const st = inspectRepoState()
1255
+ if (!st.locked) return { ok: true, data: { note: '没有 index.lock' } }
1256
+ if (st.lockAgeMs < 10 * 60 * 1000) {
1257
+ return { ok: false, kind: 'lock', message: '锁文件创建仅 ' + Math.round(st.lockAgeMs / 1000) + ' 秒,可能有 git 操作正在进行' }
1258
+ }
1259
+ try {
1260
+ const dest = path.join(backupDir, 'stale-index-lock-' + Date.now() + '.lock')
1261
+ fs.copyFileSync(st.lockFile, dest)
1262
+ fs.unlinkSync(st.lockFile)
1263
+ log('INFO', '已移除陈旧 index.lock,备份 ' + dest)
1264
+ return { ok: true, data: { removed: st.lockFile, backup: dest } }
1265
+ } catch (e) {
1266
+ return { ok: false, kind: 'perm', message: '移除锁文件失败:' + e.message }
1267
+ }
1268
+ }
1269
+
1270
+ // ======================= 对外 =======================
1271
+ // 远端地址:读取 / 设置(空字符串 = 移除远端,回到纯本地仓库)
1272
+ async function getRemoteInfo() {
1273
+ try {
1274
+ const names = await remoteNames()
1275
+ if (!names.length) return { ok: true, data: { hasRemote: false, name: '', url: '' } }
1276
+ const r = await runGit(['remote', 'get-url', names[0]], { label: 'remote-url' })
1277
+ return { ok: true, data: { hasRemote: true, name: names[0], url: r.ok ? String(r.data || '').trim() : '' } }
1278
+ } catch (e) {
1279
+ return { ok: false, message: (e && e.message) || String(e) }
1280
+ }
1281
+ }
1282
+
1283
+ async function setRemote(url) {
1284
+ const pre = precheck()
1285
+ if (!pre.ok) return pre
1286
+ const u = String(url || '').trim()
1287
+ return enqueue('set-remote', async () => {
1288
+ const names = await remoteNames()
1289
+ if (!u) {
1290
+ if (!names.length) return { ok: true, data: { removed: false, hasRemote: false } }
1291
+ const r = await runGit(['remote', 'remove', names[0]], { label: 'remote-remove' })
1292
+ if (!r.ok) return r
1293
+ return { ok: true, data: { removed: true, hasRemote: false } }
1294
+ }
1295
+ if (!names.length) {
1296
+ const r = await runGit(['remote', 'add', 'origin', u], { label: 'remote-add' })
1297
+ if (!r.ok) return r
1298
+ return { ok: true, data: { added: true, hasRemote: true, url: u } }
1299
+ }
1300
+ const r = await runGit(['remote', 'set-url', names[0], u], { label: 'remote-set-url' })
1301
+ if (!r.ok) return r
1302
+ return { ok: true, data: { updated: true, hasRemote: true, url: u } }
1303
+ })
1304
+ }
1305
+
1306
+ // 提交者身份:读全局(user.name/email)与本仓库局部配置,供设置面板预填
1307
+ async function getIdentity() {
1308
+ try {
1309
+ const gN = await runGit(['config', '--get', 'user.name'], { label: 'id-g-name' })
1310
+ const gE = await runGit(['config', '--get', 'user.email'], { label: 'id-g-email' })
1311
+ const lN = await runGit(['config', '--local', '--get', 'user.name'], { label: 'id-l-name' })
1312
+ const lE = await runGit(['config', '--local', '--get', 'user.email'], { label: 'id-l-email' })
1313
+ const t = (r) => (r && r.ok ? String(r.data || '').trim() : '')
1314
+ return { ok: true, data: { globalName: t(gN), globalEmail: t(gE), localName: t(lN), localEmail: t(lE) } }
1315
+ } catch (e) {
1316
+ return { ok: false, message: (e && e.message) || String(e) }
1317
+ }
1318
+ }
1319
+
1320
+ // 写入本仓库身份;传空则清除局部配置(回落到全局)
1321
+ async function setIdentity(name, email) {
1322
+ const pre = precheck()
1323
+ if (!pre.ok) return pre
1324
+ return enqueue('identity', async () => {
1325
+ const nn = String(name == null ? '' : name).trim()
1326
+ const ee = String(email == null ? '' : email).trim()
1327
+ if (nn) {
1328
+ const r = await runGit(['config', '--local', 'user.name', nn], { label: 'id-set-name' })
1329
+ if (!r.ok) return r
1330
+ } else {
1331
+ await runGit(['config', '--local', '--unset', 'user.name'], { label: 'id-unset-name' })
1332
+ }
1333
+ if (ee) {
1334
+ const r = await runGit(['config', '--local', 'user.email', ee], { label: 'id-set-email' })
1335
+ if (!r.ok) return r
1336
+ } else {
1337
+ await runGit(['config', '--local', '--unset', 'user.email'], { label: 'id-unset-email' })
1338
+ }
1339
+ return { ok: true, data: { name: nn, email: ee } }
1340
+ })
1341
+ }
1342
+
1343
+ const api = {
1344
+ get repoRoot() { return repoRoot },
1345
+ get scopeRel() { return scopeRel },
1346
+ state: getState,
1347
+ inspect: inspectRepoState,
1348
+ rediscover: () => { cfgCache = null; return locateRepo() },
1349
+ log: fileLog,
1350
+ logCount: fileLogCount,
1351
+ repoLog,
1352
+ repoLogCount,
1353
+ show: showAt,
1354
+ commitFiles,
1355
+ diff: diffOf,
1356
+ diffWork,
1357
+ stage, unstage, discard, restoreTo,
1358
+ commitAll, push, pull, sync,
1359
+ remoteInfo: getRemoteInfo, setRemote,
1360
+ identity: getIdentity, setIdentity,
1361
+ abortInProgress,
1362
+ clearStaleLock,
1363
+ initRepo,
1364
+ adoptRepo,
1365
+ adoptPrecheck,
1366
+ checkRemote,
1367
+ branches,
1368
+ checkoutBranch,
1369
+ createBranch,
1370
+ config: () => {
1371
+ const c = readConfig()
1372
+ return {
1373
+ ok: true,
1374
+ data: {
1375
+ autoSync: !!c.autoSync, autoMode: c.autoMode || 'interval',
1376
+ intervalMs: c.intervalMs, onStopEditMinutes: c.onStopEditMinutes || 0,
1377
+ pullBeforePush: !!c.pullBeforePush, pushEnabled: !!c.pushEnabled, commitScope: c.commitScope || 'notes',
1378
+ commitMessage: c.commitMessage, lastRunAt: c.lastRunAt || 0, failCount: c.failCount || 0,
1379
+ lastAutoResult: c.lastAutoResult || null
1380
+ }
1381
+ }
1382
+ },
1383
+ setConfig: (patch) => {
1384
+ const r = writeConfig(patch)
1385
+ if (!r.ok) return r
1386
+ scheduleAuto()
1387
+ log('INFO', 'config: ' + JSON.stringify(patch))
1388
+ return api.config()
1389
+ },
1390
+ autoRun,
1391
+ noteEditActivity,
1392
+ nextAutoAt,
1393
+ logTail,
1394
+ relOf, absOf,
1395
+ formatMessage,
1396
+ isBusy: () => busy
1397
+ }
1398
+
1399
+ win.__znotesGit = api
1400
+ scheduleAuto()
1401
+ log('INFO', 'git service v2 启动 repo=' + (repoRoot || '(无)') + ' scope=' + (scopeRel || '(整仓)') + ' ws=' + WS)
1402
+ return api
1403
+ }