source-code-mgmt 1.1.1 → 1.5.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 (4) hide show
  1. package/README.md +84 -19
  2. package/lib/client.js +873 -110
  3. package/lib/index.js +643 -59
  4. package/package.json +38 -22
package/lib/index.js CHANGED
@@ -187,14 +187,47 @@ function sshDir() {
187
187
  return join(homedir(), '.ssh')
188
188
  }
189
189
 
190
- /** Full path to the ed25519 private key. */
190
+ /**
191
+ * Scan ~/.ssh for ed25519 PUBLIC keys (`ssh-ed25519 ...`). Returns the base
192
+ * names (file name without the `.pub` suffix), sorted alphabetically.
193
+ */
194
+ function findEd25519Keys() {
195
+ const dir = sshDir()
196
+ const names = []
197
+ let entries = []
198
+ try { entries = readdirSync(dir) } catch { return names }
199
+ for (const e of entries) {
200
+ if (!e.endsWith('.pub')) continue
201
+ try {
202
+ const content = readFileSync(join(dir, e), 'utf8')
203
+ if (/^\s*ssh-ed25519\s+/.test(content)) names.push(e.slice(0, -4))
204
+ } catch { /* unreadable pub — skip */ }
205
+ }
206
+ return names.sort()
207
+ }
208
+
209
+ /**
210
+ * Resolve the SSH key base name to use (file name without `.pub`):
211
+ * - prefer the conventional `id_ed25519` when it exists;
212
+ * - otherwise use the FIRST detected ed25519 key (supports a custom-named key);
213
+ * - if none exists, return the default `id_ed25519` (the name a newly generated
214
+ * key will get — the well-known standard name that SSH/Git tooling expect).
215
+ */
216
+ function resolveKeyBase() {
217
+ const names = findEd25519Keys()
218
+ if (names.includes('id_ed25519')) return 'id_ed25519'
219
+ if (names.length > 0) return names[0]
220
+ return 'id_ed25519'
221
+ }
222
+
223
+ /** Full path to the ed25519 private key (base resolved from existing keys). */
191
224
  function privateKeyPath() {
192
- return join(sshDir(), 'id_ed25519')
225
+ return join(sshDir(), resolveKeyBase())
193
226
  }
194
227
 
195
- /** Full path to the ed25519 public key. */
228
+ /** Full path to the ed25519 public key (base resolved from existing keys). */
196
229
  function publicKeyPath() {
197
- return join(sshDir(), 'id_ed25519.pub')
230
+ return join(sshDir(), resolveKeyBase() + '.pub')
198
231
  }
199
232
 
200
233
  /** Full path to ssh config. */
@@ -327,6 +360,7 @@ function providerCfg(provider) {
327
360
 
328
361
  /** SSH key & config & gh auth summary. */
329
362
  function checkSsh() {
363
+ const keyBase = resolveKeyBase()
330
364
  const hasKey = existsSync(privateKeyPath())
331
365
  const hasPub = existsSync(publicKeyPath())
332
366
  const hasConfig = existsSync(configPath())
@@ -354,6 +388,9 @@ function checkSsh() {
354
388
  pubContent,
355
389
  configContent,
356
390
  sshDir: sshDir(),
391
+ // 当前使用的密钥文件名(不含 .pub):优先 id_ed25519,否则自动探测到的第一个 ed25519 密钥名。
392
+ keyBase,
393
+ keyName: hasPub ? keyBase : null,
357
394
  ghLoggedIn,
358
395
  ghAccount,
359
396
  // Per-provider SSH config presence (used by the panel's provider selector).
@@ -362,10 +399,12 @@ function checkSsh() {
362
399
  }
363
400
  }
364
401
 
365
- /** Generate ed25519 key with no passphrase (non-interactive). */
402
+ /** Generate ed25519 key with no passphrase (non-interactive). Note: the
403
+ * generated name is the conventional `id_ed25519` (the default when no key
404
+ * exists). If an existing ed25519 key (any name) is present, it is reused. */
366
405
  function generateKey() {
367
406
  if (existsSync(privateKeyPath())) {
368
- return { ok: true, alreadyExists: true, path: privateKeyPath() }
407
+ return { ok: true, alreadyExists: true, path: privateKeyPath(), keyBase: resolveKeyBase() }
369
408
  }
370
409
  try {
371
410
  mkdirSync(sshDir(), { recursive: true })
@@ -374,10 +413,11 @@ function generateKey() {
374
413
  ok: r.ok,
375
414
  alreadyExists: false,
376
415
  path: privateKeyPath(),
416
+ keyBase: resolveKeyBase(),
377
417
  error: r.ok ? undefined : (r.stderr || r.stdout).trim(),
378
418
  }
379
419
  } catch (error) {
380
- return { ok: false, alreadyExists: false, path: privateKeyPath(), error: String(error) }
420
+ return { ok: false, alreadyExists: false, path: privateKeyPath(), keyBase: resolveKeyBase(), error: String(error) }
381
421
  }
382
422
  }
383
423
 
@@ -403,7 +443,7 @@ function writeSshConfig(provider) {
403
443
  ' Hostname ' + cfg.hostname,
404
444
  ' Port ' + cfg.port,
405
445
  ' User ' + cfg.gitUser,
406
- ' IdentityFile ~/.ssh/id_ed25519',
446
+ ' IdentityFile ~/.ssh/' + resolveKeyBase(),
407
447
  '',
408
448
  ].join('\n')
409
449
  writeFileSync(configPath(), current + block, 'utf8')
@@ -610,23 +650,88 @@ function ignorePlan(dir) {
610
650
  return { large, entries }
611
651
  }
612
652
 
653
+ /**
654
+ * Extract the repository owner (org/user) from a git remote URL, regardless of
655
+ * form (`https://host/owner/repo.git` or `git@host:owner/repo.git`). Returns
656
+ * the owner (lowercased) or undefined when it cannot be determined.
657
+ */
658
+ function remoteOwner(url) {
659
+ if (typeof url !== 'string' || url === '') return undefined
660
+ // git@host:owner/repo.git
661
+ let m = /git@[^:]+:(.+?)\/([^/]+?)(?:\.git)?\/?$/.exec(url)
662
+ if (m) return m[1].toLowerCase()
663
+ // https://host/owner/repo.git 或 ssh://git@host/owner/repo.git
664
+ m = /(?:https?|ssh):\/\/[^\/]+\/(.+?)\/([^/]+?)(?:\.git)?\/?$/.exec(url)
665
+ if (m) return m[1].toLowerCase()
666
+ return undefined
667
+ }
668
+
669
+ /**
670
+ * The current logged-in owner for a platform: 'github' -> gh owner (Zhucy123),
671
+ * 'gitee' -> the Gitee token account (Zhucy2100). Returns undefined when the
672
+ * account isn't available.
673
+ *
674
+ * A short-lived (5s) in-process cache is layered on top so a single /repo
675
+ * request (which calls this up to 3 times via repoExists / providerRemoteName /
676
+ * repoVisibility) only hits the network ONCE, and rapid sequential refreshes
677
+ * stay instant.
678
+ */
679
+ let ownerCache = { provider: undefined, value: undefined, ts: 0 }
680
+ function providerOwner(provider) {
681
+ const p = provider === 'gitee' ? 'gitee' : 'github'
682
+ const now = Date.now()
683
+ if (ownerCache.provider === p && now - ownerCache.ts < 5000) return ownerCache.value
684
+ const value = p === 'gitee' ? giteeOwner() : ghOwner()
685
+ ownerCache = { provider: p, value, ts: now }
686
+ return value
687
+ }
688
+
689
+ /**
690
+ * Pick the git remote name that belongs to the given code-hosting platform AND
691
+ * to the CURRENT logged-in account (owner). This way switching to Gitee reads
692
+ * only gitee.com remotes, and a GitHub remote belonging to another user/org
693
+ * (e.g. deepseek-ai/deepseek-harness) is NOT treated as the user's own remote.
694
+ * Returns the remote name, or undefined when the folder has no remote for the
695
+ * current platform + account.
696
+ * @param {string} dir - local folder.
697
+ * @param {string} [provider] - 'github' (default) | 'gitee'.
698
+ */
699
+ function providerRemoteName(dir, provider) {
700
+ const want = provider === 'gitee' ? /gitee\.com/i : /github\.com/i
701
+ const owner = providerOwner(provider)
702
+ if (owner === undefined) return undefined
703
+ const wantOwner = owner.toLowerCase()
704
+ const r = run(GIT, ['-C', dir, 'remote'])
705
+ if (!r.ok) return undefined
706
+ let matched = undefined
707
+ for (const name of r.stdout.split(/\r?\n/).filter((l) => l.trim() !== '')) {
708
+ const u = run(GIT, ['-C', dir, 'remote', 'get-url', name])
709
+ // 远程需属于所选平台 且 owner 等于当前账号,才视为「用户自己的远程」。
710
+ if (u.ok && want.test(u.stdout) && remoteOwner(u.stdout) === wantOwner) {
711
+ if (name === 'origin') return 'origin'
712
+ if (matched === undefined) matched = name
713
+ }
714
+ }
715
+ return matched
716
+ }
717
+
613
718
  /**
614
719
  * Repo status for a selected folder: whether it is a git repo, remote,
615
720
  * branch, dirty count, tracked-over-100MB files, and new >100MB candidates.
616
721
  */
617
- function repoStatus(dir, provider = 'github') {
722
+ function repoStatus(dir, provider = 'github', full = false) {
618
723
  const base = { ok: false, dir, isGitRepo: false, provider }
619
724
  if (!dir || !existsSync(dir)) return { ...base, error: 'folder does not exist' }
620
725
  if (!existsSync(join(dir, '.git'))) {
621
726
  // 不是 git 仓库:仍返回文件夹名和同名仓库检测,便于「新建仓库」直接使用。
622
727
  const defaultRepoName = baseName(dir)
623
- const existence = repoExists(defaultRepoName, provider)
728
+ const ev = repoExistenceAndVisibility(defaultRepoName, provider)
624
729
  return {
625
730
  ...base,
626
731
  defaultRepoName,
627
- repoExists: existence.checked ? existence.exists : undefined,
628
- repoOwner: existence.owner,
629
- visibility: existence.checked && existence.exists ? repoVisibility(defaultRepoName, provider) : undefined,
732
+ repoExists: ev.checked ? ev.exists : undefined,
733
+ repoOwner: ev.owner,
734
+ visibility: ev.checked && ev.exists ? ev.visibility : undefined,
630
735
  provider,
631
736
  error: 'not a git repository(尚未 git init,可用「新建仓库并推送」初始化为 git 仓库并上传)',
632
737
  }
@@ -643,6 +748,7 @@ function repoStatus(dir, provider = 'github') {
643
748
  // readable list, e.g. { "M " -> modified, "??" -> untracked, "A " -> added,
644
749
  // "D " -> deleted, "R " -> renamed }. Only the file/folder NAME is shown in
645
750
  // the panel, never the diff content.
751
+ // 两字母 XY:X(第 1 位)非空格且非 '?' = 已暂存(index),Y(第 2 位)非空格 = 未暂存(worktree)。
646
752
  const changedFiles = statusLines.map((line) => {
647
753
  const code = line.slice(0, 2)
648
754
  let path = line.slice(3)
@@ -660,7 +766,11 @@ function repoStatus(dir, provider = 'github') {
660
766
  : /^D|^AD/.test(code) ? 'deleted'
661
767
  : /^R/.test(code) ? 'renamed'
662
768
  : 'modified'
663
- return { type, path }
769
+ // code[0] index/staged 状态列(' ' 或 '?' 表示未暂存/未跟踪)。
770
+ const staged = code[0] !== ' ' && code[0] !== '?'
771
+ // diff 内容不再在 /repo 里逐个生成(改动多时会跑 N 次 git diff,拖慢加载);
772
+ // 改为点开「查看改动」弹窗里的文件行时,由 /repo-diff 单独按需请求。
773
+ return { type, path, staged }
664
774
  })
665
775
 
666
776
  // Tracked files that exceed 100MB (GitHub would reject a push of these).
@@ -684,32 +794,60 @@ function repoStatus(dir, provider = 'github') {
684
794
  (e) => !e.ignored && !trackedOverLimit.some((t) => t.path === e.source)
685
795
  )
686
796
 
687
- // Default repo name = folder basename; and whether the same-name repo exists.
797
+ // Default repo name = folder basename; and whether the same-name repo exists
798
+ // (single network call for both existence + visibility).
688
799
  const defaultRepoName = baseName(dir)
689
- const existence = repoExists(defaultRepoName, provider)
800
+ const ev = repoExistenceAndVisibility(defaultRepoName, provider)
801
+
802
+ // 按当前平台选择远程:Gitee 平台只看 gitee.com 的远程,GitHub 平台只看 github.com,
803
+ // 避免切到 Gitee 时仍显示 GitHub 远程、并把 ahead/behind 算到错误的远程上。
804
+ const remoteName = providerRemoteName(dir, provider)
805
+ const hasRemote = remoteName !== undefined
806
+ const remoteUrl = remoteName !== undefined
807
+ ? (() => {
808
+ const r = run(GIT, ['-C', dir, 'remote', 'get-url', remoteName])
809
+ return r.ok ? r.stdout.trim() : undefined
810
+ })()
811
+ : undefined
690
812
 
691
813
  const branchName = branch.ok ? branch.stdout.trim() : undefined
692
- const hasRemote = run(GIT, ['-C', dir, 'remote', 'get-url', 'origin']).ok
693
814
 
694
815
  // ahead (unpushed local commits) / behind (remote commits not yet pulled),
695
- // measured against origin/<branch>. A lightweight fetch keeps this fresh;
696
- // a failed fetch is tolerated (falls back to the last known tracking ref).
816
+ // measured against <providerRemote>/<branch>.
817
+ //
818
+ // `full` mode performs a `git fetch` so ahead/behind reflect the LIVE remote
819
+ // state (used by the explicit "刷新状态" button). The default (fast) mode
820
+ // SKIPS the network fetch entirely — this is what makes switching workspaces
821
+ // and post-push refreshes feel instant; ahead/behind then fall back to the
822
+ // last known tracking ref (no fetch = no freshness, but no 1-20s stall).
697
823
  let ahead = 0
698
824
  let behind = 0
699
- const upstream = hasRemote && branchName ? 'origin/' + branchName : undefined
825
+ const upstream = hasRemote && branchName ? remoteName + '/' + branchName : undefined
700
826
  if (upstream) {
701
- if (run(GIT, ['-C', dir, 'fetch', 'origin', branchName, '--quiet'], { timeout: 20_000 }).ok) {
827
+ if (full) {
828
+ if (run(GIT, ['-C', dir, 'fetch', remoteName, branchName, '--quiet'], { timeout: 20_000 }).ok) {
829
+ const rb = run(GIT, ['-C', dir, 'rev-list', '--left-right', '--count', upstream + '...HEAD'])
830
+ if (rb.ok) {
831
+ const m = rb.stdout.trim().split(/\s+/)
832
+ behind = parseInt(m[0], 10) || 0 // left side = remote-only commits
833
+ ahead = parseInt(m[1], 10) || 0 // right side = HEAD-only commits
834
+ }
835
+ }
836
+ } else {
837
+ // Fast path: trust the existing local tracking ref (origin/<branch>) which
838
+ // was populated by any prior fetch. Avoids a blocking network call.
702
839
  const rb = run(GIT, ['-C', dir, 'rev-list', '--left-right', '--count', upstream + '...HEAD'])
703
840
  if (rb.ok) {
704
841
  const m = rb.stdout.trim().split(/\s+/)
705
- behind = parseInt(m[0], 10) || 0 // left side = origin-only commits
706
- ahead = parseInt(m[1], 10) || 0 // right side = HEAD-only commits
842
+ behind = parseInt(m[0], 10) || 0
843
+ ahead = parseInt(m[1], 10) || 0
707
844
  }
708
845
  }
709
846
  }
710
847
 
711
848
  // Short commit lists for the "同步" detail view: commits we have locally but
712
849
  // not on origin (ahead), and commits origin has that we don't yet (behind).
850
+ // Only computed in full mode (the detail view is a deliberate user action).
713
851
  const fmtLogLine = (l) => {
714
852
  // porcelain <hash> <subject>
715
853
  const sp = l.indexOf(' ')
@@ -720,23 +858,21 @@ function repoStatus(dir, provider = 'github') {
720
858
  const r = run(GIT, ['-C', dir, 'log', '--oneline', '-20', range])
721
859
  return r.ok ? r.stdout.split(/\r?\n/).filter((l) => l.trim() !== '').map(fmtLogLine) : []
722
860
  }
723
- const aheadCommits = ahead > 0 ? logRange(upstream + '..HEAD') : []
724
- const behindCommits = behind > 0 ? logRange('HEAD..' + upstream) : []
861
+ const aheadCommits = (full && ahead > 0) ? logRange(upstream + '..HEAD') : []
862
+ const behindCommits = (full && behind > 0) ? logRange('HEAD..' + upstream) : []
725
863
 
726
864
  return {
727
865
  ok: true,
728
866
  isGitRepo: true,
729
867
  dir,
730
868
  defaultRepoName,
731
- repoExists: existence.checked ? existence.exists : undefined,
732
- repoOwner: existence.owner,
869
+ repoExists: ev.checked ? ev.exists : undefined,
870
+ repoOwner: ev.owner,
733
871
  provider,
734
872
  branch: branchName,
735
873
  hasRemote,
736
- remoteUrl: (() => {
737
- const r = run(GIT, ['-C', dir, 'remote', 'get-url', 'origin'])
738
- return r.ok ? r.stdout.trim() : undefined
739
- })(),
874
+ remoteUrl,
875
+ remoteName,
740
876
  dirty: dirtyFiles > 0,
741
877
  dirtyCount: dirtyFiles,
742
878
  // Changed/added/deleted/renamed file names (with their status), empty when clean.
@@ -748,7 +884,7 @@ function repoStatus(dir, provider = 'github') {
748
884
  behindCommits,
749
885
  // Actual remote visibility (private/public) when the repo exists and the
750
886
  // account can view it; undefined when unknown (not token/logged in or repo absent).
751
- visibility: existence.checked && existence.exists ? repoVisibility(defaultRepoName, provider) : undefined,
887
+ visibility: ev.checked && ev.exists ? ev.visibility : undefined,
752
888
  trackedOverLimit,
753
889
  ignoredLarge: ignoredLarge.slice(0, 200),
754
890
  }
@@ -1090,6 +1226,36 @@ function fmtMB(bytes) {
1090
1226
  return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
1091
1227
  }
1092
1228
 
1229
+ /**
1230
+ * Force-align the local folder to the remote branch: fetch origin then
1231
+ * `git reset --hard origin/<branch>`. Any local-only commits / working-tree
1232
+ * changes are discarded so local becomes an exact copy of the remote branch.
1233
+ /**
1234
+ * Return the diff of a single changed file, on demand (the "查看改动" panel
1235
+ * expands a file row and fetches this instead of bundling every diff into
1236
+ * /repo, which would spawn N git processes and stall the load on large
1237
+ * change sets). untracked files have no diff (git doesn't track them) and
1238
+ * return an empty string.
1239
+ * @param {string} dir - local folder.
1240
+ * @param {string} path - repo-relative file path.
1241
+ */
1242
+ function repoDiffFlow(dir, path) {
1243
+ if (!dir || !existsSync(dir) || !existsSync(join(dir, '.git'))) {
1244
+ return { ok: false, error: 'not a git repository' }
1245
+ }
1246
+ const p = String(path || '').trim()
1247
+ if (!p) return { ok: false, error: '缺少文件路径' }
1248
+ // Untracked files have no tracked diff.
1249
+ const status = run(GIT, ['-C', dir, 'status', '--porcelain', '--', p])
1250
+ const isUntracked = status.ok && /^\?\?/.test(status.stdout.trim().slice(0, 2))
1251
+ if (isUntracked) return { ok: true, path: p, diff: '' }
1252
+ let r = run(GIT, ['-C', dir, 'diff', '--', p])
1253
+ if (r.ok && !r.stdout.trim()) {
1254
+ r = run(GIT, ['-C', dir, 'diff', '--cached', '--', p])
1255
+ }
1256
+ return { ok: true, path: p, diff: r.ok ? r.stdout : '' }
1257
+ }
1258
+
1093
1259
  /**
1094
1260
  * Force-align the local folder to the remote branch: fetch origin then
1095
1261
  * `git reset --hard origin/<branch>`. Any local-only commits / working-tree
@@ -1142,6 +1308,299 @@ function initGitFlow(dir) {
1142
1308
  return { ok: true, alreadyRepo: false, dir, branch: 'main' }
1143
1309
  }
1144
1310
 
1311
+ // ---------------------------------------------------------------------------
1312
+ // 本地 Git 工作流 —— 选择性暂存 / 提交 / 分支 / 历史 / revert / cherry-pick / 提交 diff。
1313
+ // 全部用本插件的 run() + GIT_SSH 注入实现(与 pushFlow 一致),不依赖第三方库。
1314
+ // ---------------------------------------------------------------------------
1315
+
1316
+ /** Basic guard: the folder must exist and be a git repo; returns an error object or null. */
1317
+ function requireGitRepo(dir) {
1318
+ if (!dir || !existsSync(dir)) return { error: 'folder does not exist' }
1319
+ if (!existsSync(join(dir, '.git'))) return { error: 'not a git repository(尚未 git init)' }
1320
+ return null
1321
+ }
1322
+
1323
+ /**
1324
+ * Stage paths (`git add`). `path` empty/null = stage everything (`git add -A`).
1325
+ * @param {string} dir - local folder.
1326
+ * @param {string} [path] - repo-relative path; empty = all.
1327
+ */
1328
+ function stageFlow(dir, path) {
1329
+ const guard = requireGitRepo(dir)
1330
+ if (guard) return { ok: false, error: guard.error }
1331
+ const args = ['-C', dir, 'add', '-A']
1332
+ if (path && String(path).trim() !== '') args.push('--', String(path).trim())
1333
+ const r = run(GIT, args)
1334
+ if (!r.ok) return { ok: false, error: (r.stderr || r.stdout).trim() || 'git add 失败' }
1335
+ return { ok: true, path: path || null }
1336
+ }
1337
+
1338
+ /**
1339
+ * Unstage paths (`git reset`). `path` empty/null = unstage everything.
1340
+ * @param {string} dir - local folder.
1341
+ * @param {string} [path] - repo-relative path; empty = all.
1342
+ */
1343
+ function unstageFlow(dir, path) {
1344
+ const guard = requireGitRepo(dir)
1345
+ if (guard) return { ok: false, error: guard.error }
1346
+ const args = ['-C', dir, 'reset', '-q']
1347
+ if (path && String(path).trim() !== '') args.push('--', String(path).trim())
1348
+ const r = run(GIT, args)
1349
+ if (!r.ok) return { ok: false, error: (r.stderr || r.stdout).trim() || 'git reset 失败' }
1350
+ return { ok: true, path: path || null }
1351
+ }
1352
+
1353
+ /**
1354
+ * Commit staged changes with the given message. `paths` (array) restricts the
1355
+ * commit to those files (they are staged first via `git add -- <paths>`);
1356
+ * when absent, commits whatever is already staged. If the repo has no local
1357
+ * identity configured, it uses the same DSH User / dsh@localhost fallback that
1358
+ * `pushFlow` uses, keeping behaviour consistent.
1359
+ * @param {string} dir - local folder.
1360
+ * @param {string} message - commit message.
1361
+ * @param {string[]} [paths] - optional files to stage & commit.
1362
+ */
1363
+ function commitFlow(dir, message, paths) {
1364
+ const guard = requireGitRepo(dir)
1365
+ if (guard) return { ok: false, error: guard.error }
1366
+ const msg = String(message || '').trim()
1367
+ if (msg === '') return { ok: false, error: '提交信息不能为空' }
1368
+
1369
+ const ident = run(GIT, ['-C', dir, 'config', 'user.email'])
1370
+ if (!ident.ok) {
1371
+ run(GIT, ['-C', dir, 'config', 'user.name', 'DSH User'])
1372
+ run(GIT, ['-C', dir, 'config', 'user.email', 'dsh@localhost'])
1373
+ }
1374
+
1375
+ const list = Array.isArray(paths) ? paths.map((p) => String(p).trim()).filter(Boolean) : []
1376
+ let stagedPaths = []
1377
+ if (list.length > 0) {
1378
+ // 只提交指定文件:先把这些文件 add 进暂存区。
1379
+ const add = run(GIT, ['-C', dir, 'add', '--', ...list])
1380
+ if (!add.ok) return { ok: false, error: (add.stderr || add.stdout).trim() || 'git add 失败' }
1381
+ stagedPaths = list
1382
+ } else {
1383
+ // 提交整个暂存区:列出已暂存文件用于提示。
1384
+ const st = run(GIT, ['-C', dir, 'diff', '--cached', '--name-only'])
1385
+ stagedPaths = st.ok ? st.stdout.split(/\r?\n/).filter(Boolean) : []
1386
+ }
1387
+
1388
+ // 无可提交内容时避免报错。
1389
+ const hasChanges = run(GIT, ['-C', dir, 'diff', '--cached', '--quiet']).code !== 0
1390
+ if (!hasChanges) return { ok: false, error: '没有已暂存(staged)的改动可提交', staged: 0 }
1391
+
1392
+ const commit = run(GIT, ['-C', dir, 'commit', '-m', msg])
1393
+ if (!commit.ok) return { ok: false, error: (commit.stderr || commit.stdout).trim() || 'git commit 失败', staged: stagedPaths.length }
1394
+
1395
+ const rev = run(GIT, ['-C', dir, 'rev-parse', '--short', 'HEAD'])
1396
+ return {
1397
+ ok: true,
1398
+ message: msg,
1399
+ hash: rev.ok ? rev.stdout.trim() : undefined,
1400
+ staged: stagedPaths.length,
1401
+ paths: stagedPaths,
1402
+ }
1403
+ }
1404
+
1405
+ /**
1406
+ * List branches (current first) using `git for-each-ref`.
1407
+ * @param {string} dir - local folder.
1408
+ */
1409
+ function branchesFlow(dir) {
1410
+ const guard = requireGitRepo(dir)
1411
+ if (guard) return { ok: false, error: guard.error }
1412
+ const cur = run(GIT, ['-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD'])
1413
+ const current = cur.ok && cur.stdout.trim() !== 'HEAD' ? cur.stdout.trim() : cur.ok ? cur.stdout.trim() : 'HEAD'
1414
+ const r = run(GIT, ['-C', dir, 'for-each-ref', '--format=%(refname:short)', 'refs/heads'])
1415
+ const names = r.ok ? r.stdout.split(/\r?\n/).map((l) => l.trim()).filter((l) => l !== '') : []
1416
+ if (!names.includes(current)) names.unshift(current)
1417
+ return { ok: true, current, branches: names }
1418
+ }
1419
+
1420
+ /**
1421
+ * Checkout an existing branch.
1422
+ * @param {string} dir - local folder.
1423
+ * @param {string} branch - branch name to switch to.
1424
+ */
1425
+ function checkoutFlow(dir, branch) {
1426
+ const guard = requireGitRepo(dir)
1427
+ if (guard) return { ok: false, error: guard.error }
1428
+ const b = String(branch || '').trim()
1429
+ if (b === '') return { ok: false, error: '缺少分支名' }
1430
+ const r = run(GIT, ['-C', dir, 'checkout', b])
1431
+ if (!r.ok) return { ok: false, error: (r.stderr || r.stdout).trim() || '切换分支失败' }
1432
+ return { ok: true, branch: b }
1433
+ }
1434
+
1435
+ /**
1436
+ * Recent commit history (newest first). Each row carries the short hash,
1437
+ * full hash, subject, author and date.
1438
+ * @param {string} dir - local folder.
1439
+ * @param {number} [count] - how many commits (default 30).
1440
+ */
1441
+ function logFlow(dir, count) {
1442
+ const guard = requireGitRepo(dir)
1443
+ if (guard) return { ok: false, error: guard.error }
1444
+ const n = Math.max(1, Math.min(500, parseInt(count, 10) || 30))
1445
+ const r = run(GIT, ['-C', dir, '--no-pager', 'log', '-n', String(n), '--decorate=short',
1446
+ '--pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D'])
1447
+ if (!r.ok) {
1448
+ // 空仓库(无提交)时 git log 报错——视为空历史,而不是失败。
1449
+ if (/does not have any commits|bad revision|your current branch/i.test((r.stderr || r.stdout))) {
1450
+ return { ok: true, commits: [] }
1451
+ }
1452
+ return { ok: false, error: (r.stderr || r.stdout).trim() || 'git log 失败' }
1453
+ }
1454
+ const commits = r.stdout.split(/\r?\n/).filter((l) => l.trim() !== '').map((line) => {
1455
+ const [short, subject, author, date, full, refs] = line.split('\x1f')
1456
+ return {
1457
+ hash: short || '',
1458
+ hashFull: full || short || '',
1459
+ subject: subject || '',
1460
+ author: author || '',
1461
+ date: date || '',
1462
+ refs: refs || '',
1463
+ }
1464
+ })
1465
+ return { ok: true, commits }
1466
+ }
1467
+
1468
+ /**
1469
+ * Revert a commit onto the current branch (auto-generated message, no editor).
1470
+ * @param {string} dir - local folder.
1471
+ * @param {string} hash - commit hash to revert.
1472
+ */
1473
+ function revertFlow(dir, hash) {
1474
+ const guard = requireGitRepo(dir)
1475
+ if (guard) return { ok: false, error: guard.error }
1476
+ const h = String(hash || '').trim()
1477
+ if (h === '') return { ok: false, error: '缺少 commit hash' }
1478
+ const r = run(GIT, ['-C', dir, 'revert', '--no-edit', h], { timeout: 60_000 })
1479
+ if (!r.ok) return { ok: false, error: (r.stderr || r.stdout).trim() || 'revert 失败(可能有冲突,请手动处理)' }
1480
+ return { ok: true, hash: h }
1481
+ }
1482
+
1483
+ /**
1484
+ * Cherry-pick a commit onto the current branch.
1485
+ * @param {string} dir - local folder.
1486
+ * @param {string} hash - commit hash to cherry-pick.
1487
+ */
1488
+ function cherryPickFlow(dir, hash) {
1489
+ const guard = requireGitRepo(dir)
1490
+ if (guard) return { ok: false, error: guard.error }
1491
+ const h = String(hash || '').trim()
1492
+ if (h === '') return { ok: false, error: '缺少 commit hash' }
1493
+ const r = run(GIT, ['-C', dir, 'cherry-pick', h], { timeout: 60_000 })
1494
+ if (!r.ok) return { ok: false, error: (r.stderr || r.stdout).trim() || 'cherry-pick 失败(可能有冲突,请手动处理)' }
1495
+ return { ok: true, hash: h }
1496
+ }
1497
+
1498
+ /**
1499
+ * Full patch text of one commit (header suppressed). Merge commits diff
1500
+ * against the first parent so a history click always has content.
1501
+ * @param {string} dir - local folder.
1502
+ * @param {string} hash - commit hash.
1503
+ */
1504
+ function commitDiffFlow(dir, hash) {
1505
+ const guard = requireGitRepo(dir)
1506
+ if (guard) return { ok: false, error: guard.error }
1507
+ const h = String(hash || '').trim()
1508
+ if (h === '') return { ok: false, error: '缺少 commit hash' }
1509
+ const r = run(GIT, ['-C', dir, 'show', '--no-ext-diff', '--no-color', '--format=', '-m', '--first-parent', h])
1510
+ if (!r.ok) return { ok: false, error: (r.stderr || r.stdout).trim() || '读取提交 diff 失败' }
1511
+ return { ok: true, hash: h, diff: r.stdout }
1512
+ }
1513
+
1514
+ // ---------------------------------------------------------------------------
1515
+ // 工具安装(① 环境检查缺工具时的一键安装)——best-effort:按可用包管理器自动选取命令。
1516
+ // ---------------------------------------------------------------------------
1517
+
1518
+ /**
1519
+ * Whether a command exists (probe `--version`; for shell scripts use `command -v`).
1520
+ * @param {string} bin - bare command name.
1521
+ */
1522
+ function binExists(bin) {
1523
+ if (bin === 'apt-get' || bin === 'dnf' || bin === 'yum') {
1524
+ return run('sh', ['-c', 'command -v ' + bin]).ok
1525
+ }
1526
+ return run(bin, ['--version']).ok
1527
+ }
1528
+
1529
+ /**
1530
+ * Build the install command to run for `tool` on this machine, or null when no
1531
+ * usable package manager is available. Returns arrays usable with `run`.
1532
+ * @param {'git'|'gh'|'ssh'} tool
1533
+ */
1534
+ function installCommand(tool) {
1535
+ if (IS_WIN) {
1536
+ // SSH 客户端:Windows 内置可选功能(需管理员),走 powershell。
1537
+ if (tool === 'ssh') {
1538
+ return { bin: 'powershell', args: ['-NoProfile', '-Command', 'Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0'], label: 'Add-WindowsCapability -Online -Name OpenSSH.Client' }
1539
+ }
1540
+ const pkg = tool === 'git' ? 'Git.Git' : 'GitHub.cli'
1541
+ if (binExists('winget')) {
1542
+ return { bin: 'winget', args: ['install', '--id', pkg, '-e', '--source', 'winget', '--accept-package-agreements', '--accept-source-agreements'], label: 'winget install --id ' + pkg + ' -e' }
1543
+ }
1544
+ if (binExists('choco')) {
1545
+ return { bin: 'choco', args: ['install', tool === 'git' ? 'git' : 'gh', '-y'], label: 'choco install ' + (tool === 'git' ? 'git' : 'gh') + ' -y' }
1546
+ }
1547
+ if (binExists('scoop')) {
1548
+ return { bin: 'scoop', args: ['install', tool === 'git' ? 'git' : 'gh'], label: 'scoop install ' + (tool === 'git' ? 'git' : 'gh') }
1549
+ }
1550
+ return null
1551
+ }
1552
+ if (process.platform === 'darwin') {
1553
+ const pkg = tool === 'git' ? 'git' : tool === 'gh' ? 'gh' : 'openssh'
1554
+ if (binExists('brew')) return { bin: 'brew', args: ['install', pkg], label: 'brew install ' + pkg }
1555
+ return null
1556
+ }
1557
+ // Linux:按可用包管理器选择。安装需 root,用 sudo(非交互 -n,避免挂起等待密码;已是 root 则直接跑)。
1558
+ const pkg = tool === 'git' ? 'git' : tool === 'gh' ? 'gh' : 'openssh-client'
1559
+ const isRoot = typeof process.getuid === 'function' && process.getuid() === 0
1560
+ const sudo = !isRoot && binExists('sudo') ? 'sudo -n ' : ''
1561
+ if (binExists('apt-get')) {
1562
+ return { bin: 'sh', args: ['-c', sudo + 'apt-get install -y ' + pkg], label: 'sudo apt-get install -y ' + pkg }
1563
+ }
1564
+ if (binExists('dnf')) {
1565
+ return { bin: 'sh', args: ['-c', sudo + 'dnf install -y ' + pkg], label: 'sudo dnf install -y ' + pkg }
1566
+ }
1567
+ if (binExists('pacman')) {
1568
+ return { bin: 'sh', args: ['-c', sudo + 'pacman -S --noconfirm ' + pkg], label: 'sudo pacman -S ' + pkg }
1569
+ }
1570
+ return null
1571
+ }
1572
+
1573
+ /**
1574
+ * Install a missing tool (git / gh / ssh) using the best available package
1575
+ * manager. Best-effort: reports the command it ran and its output.
1576
+ * @param {string} tool - 'git' | 'gh' | 'ssh'.
1577
+ */
1578
+ function installToolFlow(tool) {
1579
+ const t = String(tool || '').trim().toLowerCase()
1580
+ if (t !== 'git' && t !== 'gh' && t !== 'ssh') return { ok: false, error: '未知工具:' + tool }
1581
+ // 已安装就直接返回,不重复安装。
1582
+ const present = t === 'git' ? (GIT && GIT !== 'git')
1583
+ : t === 'gh' ? (GH && GH !== 'gh')
1584
+ : (SSH && SSH !== 'ssh' && SSH !== 'ssh.exe')
1585
+ if (present) return { ok: true, alreadyInstalled: true, tool: t }
1586
+
1587
+ const cmd = installCommand(t)
1588
+ if (!cmd) {
1589
+ return { ok: false, error: '未找到可用的包管理器(' + (IS_WIN ? 'winget / choco / scoop' : process.platform === 'darwin' ? 'brew' : 'apt-get / dnf / pacman') + ')。请手动安装。', tool: t }
1590
+ }
1591
+ const r = run(cmd.bin, cmd.args, { timeout: 300_000 })
1592
+ const output = (r.stdout + '\n' + r.stderr).trim()
1593
+ // 安装后复查是否已装上。
1594
+ const ok = r.ok && (t === 'git' ? (GIT && GIT !== 'git') : t === 'gh' ? (GH && GH !== 'gh') : !(SSH === 'ssh' || SSH === 'ssh.exe'))
1595
+ return {
1596
+ ok,
1597
+ tool: t,
1598
+ command: cmd.label,
1599
+ output: output || (ok ? '安装成功' : '安装失败'),
1600
+ needElevation: IS_WIN && t === 'ssh',
1601
+ }
1602
+ }
1603
+
1145
1604
  // ---------- route handlers ----------
1146
1605
 
1147
1606
  function json(res, status, payload) {
@@ -1304,19 +1763,25 @@ function pickDirDialog(initialDir) {
1304
1763
  return outLine !== '' && existsSync(outLine) ? outLine : undefined
1305
1764
  }
1306
1765
 
1307
- /** Decode a `--C-Users-27775-workspace--` session dir name back to a Windows
1308
- * path (best-effort; segments are joined by '\'). Ambiguous when a folder
1309
- * name itself contains '-', which is why workspace.json is preferred. */
1766
+ /** Decode a session dir name back to a filesystem path (best-effort).
1767
+ * Windows names look like `--C-Users-27775-workspace--` (drive + '-' joined
1768
+ * segments `\`); POSIX names like `--Users-name-workspace--` (segments → `/`).
1769
+ * Ambiguous when a folder name itself contains '-', which is why
1770
+ * workspace.json is preferred as the source of truth. */
1310
1771
  function decodeSessionDir(name) {
1311
1772
  if (typeof name !== 'string') return null
1312
1773
  const inner = name.replace(/^--/, '').replace(/--$/, '')
1313
1774
  if (inner === '') return null
1314
- // Split on '-'; first token is the drive letter.
1315
1775
  const parts = inner.split('-')
1316
1776
  if (parts.length === 0) return null
1317
- const drive = parts[0]
1318
- const rest = parts.slice(1).join('\\')
1319
- return drive + ':\\' + rest
1777
+ if (IS_WIN) {
1778
+ // Windows: first token is the drive letter, rest joined with '\'.
1779
+ const drive = parts[0]
1780
+ const rest = parts.slice(1).join('\\')
1781
+ return drive + ':\\' + rest
1782
+ }
1783
+ // POSIX (macOS / Linux): absolute path, segments joined with '/'.
1784
+ return '/' + parts.join('/')
1320
1785
  }
1321
1786
 
1322
1787
  /**
@@ -1325,18 +1790,18 @@ function decodeSessionDir(name) {
1325
1790
  * @param {string} name - repo name (without owner).
1326
1791
  * @param {string} [provider] - 'github' (default) | 'gitee'.
1327
1792
  */
1328
- function repoExists(name, provider = 'github') {
1793
+ function repoExists(name, provider = 'github', owner) {
1329
1794
  if (provider === 'gitee') {
1330
- const owner = giteeOwner()
1331
- if (!owner || !name) return { owner, checked: false, provider: 'gitee' }
1332
- const api = giteeApi('GET', 'repos/' + owner + '/' + name)
1333
- if (!api.ok) return { owner, checked: false, provider: 'gitee', error: api.error }
1334
- return { owner, checked: true, exists: !giteeApiFailed(api.data), provider: 'gitee' }
1335
- }
1336
- const owner = ghOwner()
1337
- if (!owner || !name) return { owner, checked: false, provider: 'github' }
1338
- const r = run(GH, ['repo', 'view', owner + '/' + name, '--json', 'name'], { timeout: 20_000 })
1339
- return { owner, checked: true, exists: r.ok, provider: 'github' }
1795
+ const o = owner || giteeOwner()
1796
+ if (!o || !name) return { owner: o, checked: false, provider: 'gitee' }
1797
+ const api = giteeApi('GET', 'repos/' + o + '/' + name)
1798
+ if (!api.ok) return { owner: o, checked: false, provider: 'gitee', error: api.error }
1799
+ return { owner: o, checked: true, exists: !giteeApiFailed(api.data), provider: 'gitee' }
1800
+ }
1801
+ const o = owner || ghOwner()
1802
+ if (!o || !name) return { owner: o, checked: false, provider: 'github' }
1803
+ const r = run(GH, ['repo', 'view', o + '/' + name, '--json', 'name'], { timeout: 20_000 })
1804
+ return { owner: o, checked: true, exists: r.ok, provider: 'github' }
1340
1805
  }
1341
1806
 
1342
1807
  /**
@@ -1345,18 +1810,19 @@ function repoExists(name, provider = 'github') {
1345
1810
  * callers treat "unknown" as "no visibility info".
1346
1811
  * @param {string} name - repo name (without owner).
1347
1812
  * @param {string} [provider] - 'github' (default) | 'gitee'.
1813
+ * @param {string} [owner] - pre-resolved owner (skips a network lookup).
1348
1814
  */
1349
- function repoVisibility(name, provider = 'github') {
1815
+ function repoVisibility(name, provider = 'github', owner) {
1350
1816
  if (provider === 'gitee') {
1351
- const owner = giteeOwner()
1352
- if (!owner || !name) return undefined
1353
- const api = giteeApi('GET', 'repos/' + owner + '/' + name)
1817
+ const o = owner || giteeOwner()
1818
+ if (!o || !name) return undefined
1819
+ const api = giteeApi('GET', 'repos/' + o + '/' + name)
1354
1820
  if (!api.ok || !api.data || giteeApiFailed(api.data)) return undefined
1355
1821
  return api.data.private === true ? 'private' : (api.data.private === false ? 'public' : undefined)
1356
1822
  }
1357
- const owner = ghOwner()
1358
- if (!owner || !name) return undefined
1359
- const r = run(GH, ['repo', 'view', owner + '/' + name, '--json', 'visibility'], { timeout: 20_000 })
1823
+ const o = owner || ghOwner()
1824
+ if (!o || !name) return undefined
1825
+ const r = run(GH, ['repo', 'view', o + '/' + name, '--json', 'visibility'], { timeout: 20_000 })
1360
1826
  if (!r.ok) return undefined
1361
1827
  try {
1362
1828
  const parsed = JSON.parse(r.stdout)
@@ -1367,6 +1833,44 @@ function repoVisibility(name, provider = 'github') {
1367
1833
  }
1368
1834
  }
1369
1835
 
1836
+ /**
1837
+ * One-shot check that returns BOTH whether a same-name repo exists AND its
1838
+ * current visibility, using a SINGLE network call (GitHub `gh repo view` with
1839
+ * both fields; Gitee `GET /repos/{owner}/{name}`). This collapses the two
1840
+ * separate `repoExists` + `repoVisibility` calls that `repoStatus` used to make
1841
+ * back-to-back, halving the remote round-trips for the common case.
1842
+ * @param {string} name - repo name (without owner).
1843
+ * @param {string} [provider] - 'github' (default) | 'gitee'.
1844
+ * @param {string} [owner] - pre-resolved owner (skips a network lookup).
1845
+ */
1846
+ function repoExistenceAndVisibility(name, provider = 'github', owner) {
1847
+ if (provider === 'gitee') {
1848
+ const o = owner || giteeOwner()
1849
+ if (!o || !name) return { owner: o, checked: false, provider: 'gitee' }
1850
+ const api = giteeApi('GET', 'repos/' + o + '/' + name)
1851
+ if (!api.ok) return { owner: o, checked: false, provider: 'gitee', error: api.error }
1852
+ const exists = !giteeApiFailed(api.data)
1853
+ const visibility = exists && api.data
1854
+ ? (api.data.private === true ? 'private' : (api.data.private === false ? 'public' : undefined))
1855
+ : undefined
1856
+ return { owner: o, checked: true, exists, visibility, provider: 'gitee' }
1857
+ }
1858
+ const o = owner || ghOwner()
1859
+ if (!o || !name) return { owner: o, checked: false, provider: 'github' }
1860
+ const r = run(GH, ['repo', 'view', o + '/' + name, '--json', 'name,visibility'], { timeout: 20_000 })
1861
+ if (!r.ok) return { owner: o, checked: true, exists: false, visibility: undefined, provider: 'github' }
1862
+ try {
1863
+ const parsed = JSON.parse(r.stdout)
1864
+ const v = String(parsed.visibility || '').toLowerCase()
1865
+ return {
1866
+ owner: o, checked: true, exists: true,
1867
+ visibility: v === 'public' || v === 'private' ? v : undefined, provider: 'github',
1868
+ }
1869
+ } catch {
1870
+ return { owner: o, checked: true, exists: true, visibility: undefined, provider: 'github' }
1871
+ }
1872
+ }
1873
+
1370
1874
  /**
1371
1875
  * Change a repo's visibility. GitHub mode uses gh; Gitee mode uses the Gitee
1372
1876
  * OpenAPI. Returns the new visibility on success.
@@ -1418,6 +1922,13 @@ export function apply(ctx) {
1418
1922
 
1419
1923
  const routes = {
1420
1924
  '/env': (req, res) => handle(req, res, async () => ({ ok: true, ...checkEnv() })),
1925
+ '/install-tool': async (req, res) => {
1926
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
1927
+ await handle(req, res, async (req) => {
1928
+ const body = JSON.parse((await readBody(req)) || '{}')
1929
+ return installToolFlow(body.tool)
1930
+ })
1931
+ },
1421
1932
  '/ssh': (req, res) => handle(req, res, async () => ({ ok: true, ...checkSsh() })),
1422
1933
  '/gen-key': async (req, res) => {
1423
1934
  if (req.method !== 'POST') return methodNotAllowed(res, req.method)
@@ -1502,12 +2013,85 @@ export function apply(ctx) {
1502
2013
  try {
1503
2014
  const q = new URL(req.url ?? '', 'http://localhost')
1504
2015
  const provider = q.searchParams.get('provider') || 'github'
1505
- return repoStatus(queryDir(req, fallbackDir), provider)
2016
+ const full = q.searchParams.get('full') === '1'
2017
+ return repoStatus(queryDir(req, fallbackDir), provider, full)
1506
2018
  } catch {
1507
- return repoStatus(queryDir(req, fallbackDir), 'github')
2019
+ return repoStatus(queryDir(req, fallbackDir), 'github', false)
1508
2020
  }
1509
2021
  })
1510
2022
  },
2023
+ '/repo-diff': async (req, res) => {
2024
+ if (req.method !== 'GET') return methodNotAllowed(res, req.method)
2025
+ await handle(req, res, async (req) => {
2026
+ const q = new URL(req.url ?? '', 'http://localhost')
2027
+ const dir = q.searchParams.get('dir') || fallbackDir
2028
+ const path = q.searchParams.get('path') || ''
2029
+ return repoDiffFlow(dir, path)
2030
+ })
2031
+ },
2032
+ '/stage': async (req, res) => {
2033
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
2034
+ await handle(req, res, async (req) => {
2035
+ const body = JSON.parse((await readBody(req)) || '{}')
2036
+ return stageFlow(body.dir || fallbackDir, body.path)
2037
+ })
2038
+ },
2039
+ '/unstage': async (req, res) => {
2040
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
2041
+ await handle(req, res, async (req) => {
2042
+ const body = JSON.parse((await readBody(req)) || '{}')
2043
+ return unstageFlow(body.dir || fallbackDir, body.path)
2044
+ })
2045
+ },
2046
+ '/commit': async (req, res) => {
2047
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
2048
+ await handle(req, res, async (req) => {
2049
+ const body = JSON.parse((await readBody(req)) || '{}')
2050
+ return commitFlow(body.dir || fallbackDir, body.message, body.paths)
2051
+ })
2052
+ },
2053
+ '/branches': async (req, res) => {
2054
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
2055
+ await handle(req, res, async (req) => {
2056
+ const body = JSON.parse((await readBody(req)) || '{}')
2057
+ return branchesFlow(body.dir || fallbackDir)
2058
+ })
2059
+ },
2060
+ '/checkout': async (req, res) => {
2061
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
2062
+ await handle(req, res, async (req) => {
2063
+ const body = JSON.parse((await readBody(req)) || '{}')
2064
+ return checkoutFlow(body.dir || fallbackDir, body.branch)
2065
+ })
2066
+ },
2067
+ '/log': async (req, res) => {
2068
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
2069
+ await handle(req, res, async (req) => {
2070
+ const body = JSON.parse((await readBody(req)) || '{}')
2071
+ return logFlow(body.dir || fallbackDir, body.count)
2072
+ })
2073
+ },
2074
+ '/revert': async (req, res) => {
2075
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
2076
+ await handle(req, res, async (req) => {
2077
+ const body = JSON.parse((await readBody(req)) || '{}')
2078
+ return revertFlow(body.dir || fallbackDir, body.hash)
2079
+ })
2080
+ },
2081
+ '/cherrypick': async (req, res) => {
2082
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
2083
+ await handle(req, res, async (req) => {
2084
+ const body = JSON.parse((await readBody(req)) || '{}')
2085
+ return cherryPickFlow(body.dir || fallbackDir, body.hash)
2086
+ })
2087
+ },
2088
+ '/commit-diff': async (req, res) => {
2089
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
2090
+ await handle(req, res, async (req) => {
2091
+ const body = JSON.parse((await readBody(req)) || '{}')
2092
+ return commitDiffFlow(body.dir || fallbackDir, body.hash)
2093
+ })
2094
+ },
1511
2095
  '/push': async (req, res) => {
1512
2096
  if (req.method !== 'POST') return methodNotAllowed(res, req.method)
1513
2097
  await handle(req, res, async (req) => {