source-code-mgmt 1.1.1 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -22
- package/lib/client.js +969 -132
- package/lib/index.js +706 -59
- package/package.json +23 -7
package/lib/index.js
CHANGED
|
@@ -187,14 +187,47 @@ function sshDir() {
|
|
|
187
187
|
return join(homedir(), '.ssh')
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
/**
|
|
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(),
|
|
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(), '
|
|
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/
|
|
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
|
|
728
|
+
const ev = repoExistenceAndVisibility(defaultRepoName, provider)
|
|
624
729
|
return {
|
|
625
730
|
...base,
|
|
626
731
|
defaultRepoName,
|
|
627
|
-
repoExists:
|
|
628
|
-
repoOwner:
|
|
629
|
-
visibility:
|
|
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
|
-
|
|
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
|
|
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
|
|
696
|
-
//
|
|
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 ? '
|
|
825
|
+
const upstream = hasRemote && branchName ? remoteName + '/' + branchName : undefined
|
|
700
826
|
if (upstream) {
|
|
701
|
-
if (
|
|
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
|
|
706
|
-
ahead = parseInt(m[1], 10) || 0
|
|
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:
|
|
732
|
-
repoOwner:
|
|
869
|
+
repoExists: ev.checked ? ev.exists : undefined,
|
|
870
|
+
repoOwner: ev.owner,
|
|
733
871
|
provider,
|
|
734
872
|
branch: branchName,
|
|
735
873
|
hasRemote,
|
|
736
|
-
remoteUrl
|
|
737
|
-
|
|
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:
|
|
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,355 @@ 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
|
+
* Push STAGED changes only: commit whatever is currently staged (using the
|
|
1407
|
+
* given message, or an autogenerated one when empty), then push to origin.
|
|
1408
|
+
* Unlike {@link pushFlow} it does NOT go on to stage untracked/unstaged files
|
|
1409
|
+
* itself and does NOT overwrite the user's own commit message. When nothing is
|
|
1410
|
+
* newly staged yet (e.g. the local branch already has commits ahead), it simply
|
|
1411
|
+
* pushes those existing commits.
|
|
1412
|
+
* @param {string} dir - local folder.
|
|
1413
|
+
* @param {string} [message] - commit message used only when something is staged
|
|
1414
|
+
* and the caller supplies one; empty falls back to an autogenerated message.
|
|
1415
|
+
*/
|
|
1416
|
+
function pushStagedFlow(dir, message) {
|
|
1417
|
+
const guard = requireGitRepo(dir)
|
|
1418
|
+
if (guard) return { ok: false, error: guard.error }
|
|
1419
|
+
|
|
1420
|
+
// Remote must exist before we can push.
|
|
1421
|
+
const hasRemote = run(GIT, ['-C', dir, 'remote', 'get-url', 'origin']).ok
|
|
1422
|
+
if (!hasRemote) {
|
|
1423
|
+
return { ok: false, needsRemote: true, error: '尚未配置远程仓库 origin,请使用「新建仓库」创建远程仓库。' }
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
const msg = String(message || '').trim()
|
|
1427
|
+
let committed = false
|
|
1428
|
+
let commitHash
|
|
1429
|
+
// Commit staged content only if there is anything staged right now.
|
|
1430
|
+
const hasStaged = run(GIT, ['-C', dir, 'diff', '--cached', '--quiet']).code !== 0
|
|
1431
|
+
if (hasStaged) {
|
|
1432
|
+
const ident = run(GIT, ['-C', dir, 'config', 'user.email'])
|
|
1433
|
+
if (!ident.ok) {
|
|
1434
|
+
run(GIT, ['-C', dir, 'config', 'user.name', 'DSH User'])
|
|
1435
|
+
run(GIT, ['-C', dir, 'config', 'user.email', 'dsh@localhost'])
|
|
1436
|
+
}
|
|
1437
|
+
const useMsg = msg || ('chore: update ' + baseName(dir))
|
|
1438
|
+
const commit = run(GIT, ['-C', dir, 'commit', '-m', useMsg])
|
|
1439
|
+
if (!commit.ok) return { ok: false, error: (commit.stderr || commit.stdout).trim() || 'git commit 失败' }
|
|
1440
|
+
committed = true
|
|
1441
|
+
const rev = run(GIT, ['-C', dir, 'rev-parse', '--short', 'HEAD'])
|
|
1442
|
+
commitHash = rev.ok ? rev.stdout.trim() : undefined
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
const curBranch = run(GIT, ['-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD'])
|
|
1446
|
+
const branchName = curBranch.ok ? curBranch.stdout.trim() : 'main'
|
|
1447
|
+
const push = run(GIT, ['-C', dir, 'push', '-u', 'origin', branchName], { timeout: 120_000 })
|
|
1448
|
+
const pushed = push.ok
|
|
1449
|
+
return {
|
|
1450
|
+
ok: pushed,
|
|
1451
|
+
needsRemote: false,
|
|
1452
|
+
committed,
|
|
1453
|
+
commitHash,
|
|
1454
|
+
message: msg || (committed ? ('chore: update ' + baseName(dir)) : undefined),
|
|
1455
|
+
branch: branchName,
|
|
1456
|
+
pushed,
|
|
1457
|
+
pushError: pushed ? undefined : (push.stderr || push.stdout).trim(),
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* List branches (current first) using `git for-each-ref`.
|
|
1463
|
+
* @param {string} dir - local folder.
|
|
1464
|
+
*/
|
|
1465
|
+
function branchesFlow(dir) {
|
|
1466
|
+
const guard = requireGitRepo(dir)
|
|
1467
|
+
if (guard) return { ok: false, error: guard.error }
|
|
1468
|
+
const cur = run(GIT, ['-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD'])
|
|
1469
|
+
const current = cur.ok && cur.stdout.trim() !== 'HEAD' ? cur.stdout.trim() : cur.ok ? cur.stdout.trim() : 'HEAD'
|
|
1470
|
+
const r = run(GIT, ['-C', dir, 'for-each-ref', '--format=%(refname:short)', 'refs/heads'])
|
|
1471
|
+
const names = r.ok ? r.stdout.split(/\r?\n/).map((l) => l.trim()).filter((l) => l !== '') : []
|
|
1472
|
+
if (!names.includes(current)) names.unshift(current)
|
|
1473
|
+
return { ok: true, current, branches: names }
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/**
|
|
1477
|
+
* Checkout an existing branch.
|
|
1478
|
+
* @param {string} dir - local folder.
|
|
1479
|
+
* @param {string} branch - branch name to switch to.
|
|
1480
|
+
*/
|
|
1481
|
+
function checkoutFlow(dir, branch) {
|
|
1482
|
+
const guard = requireGitRepo(dir)
|
|
1483
|
+
if (guard) return { ok: false, error: guard.error }
|
|
1484
|
+
const b = String(branch || '').trim()
|
|
1485
|
+
if (b === '') return { ok: false, error: '缺少分支名' }
|
|
1486
|
+
const r = run(GIT, ['-C', dir, 'checkout', b])
|
|
1487
|
+
if (!r.ok) return { ok: false, error: (r.stderr || r.stdout).trim() || '切换分支失败' }
|
|
1488
|
+
return { ok: true, branch: b }
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
/**
|
|
1492
|
+
* Recent commit history (newest first). Each row carries the short hash,
|
|
1493
|
+
* full hash, subject, author and date.
|
|
1494
|
+
* @param {string} dir - local folder.
|
|
1495
|
+
* @param {number} [count] - how many commits (default 30).
|
|
1496
|
+
*/
|
|
1497
|
+
function logFlow(dir, count) {
|
|
1498
|
+
const guard = requireGitRepo(dir)
|
|
1499
|
+
if (guard) return { ok: false, error: guard.error }
|
|
1500
|
+
const n = Math.max(1, Math.min(500, parseInt(count, 10) || 30))
|
|
1501
|
+
const r = run(GIT, ['-C', dir, '--no-pager', 'log', '-n', String(n), '--decorate=short',
|
|
1502
|
+
'--pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D'])
|
|
1503
|
+
if (!r.ok) {
|
|
1504
|
+
// 空仓库(无提交)时 git log 报错——视为空历史,而不是失败。
|
|
1505
|
+
if (/does not have any commits|bad revision|your current branch/i.test((r.stderr || r.stdout))) {
|
|
1506
|
+
return { ok: true, commits: [] }
|
|
1507
|
+
}
|
|
1508
|
+
return { ok: false, error: (r.stderr || r.stdout).trim() || 'git log 失败' }
|
|
1509
|
+
}
|
|
1510
|
+
const commits = r.stdout.split(/\r?\n/).filter((l) => l.trim() !== '').map((line) => {
|
|
1511
|
+
const [short, subject, author, date, full, refs] = line.split('\x1f')
|
|
1512
|
+
return {
|
|
1513
|
+
hash: short || '',
|
|
1514
|
+
hashFull: full || short || '',
|
|
1515
|
+
subject: subject || '',
|
|
1516
|
+
author: author || '',
|
|
1517
|
+
date: date || '',
|
|
1518
|
+
refs: refs || '',
|
|
1519
|
+
}
|
|
1520
|
+
})
|
|
1521
|
+
return { ok: true, commits }
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* Revert a commit onto the current branch (auto-generated message, no editor).
|
|
1526
|
+
* @param {string} dir - local folder.
|
|
1527
|
+
* @param {string} hash - commit hash to revert.
|
|
1528
|
+
*/
|
|
1529
|
+
function revertFlow(dir, hash) {
|
|
1530
|
+
const guard = requireGitRepo(dir)
|
|
1531
|
+
if (guard) return { ok: false, error: guard.error }
|
|
1532
|
+
const h = String(hash || '').trim()
|
|
1533
|
+
if (h === '') return { ok: false, error: '缺少 commit hash' }
|
|
1534
|
+
const r = run(GIT, ['-C', dir, 'revert', '--no-edit', h], { timeout: 60_000 })
|
|
1535
|
+
if (!r.ok) return { ok: false, error: (r.stderr || r.stdout).trim() || 'revert 失败(可能有冲突,请手动处理)' }
|
|
1536
|
+
return { ok: true, hash: h }
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
/**
|
|
1540
|
+
* Cherry-pick a commit onto the current branch.
|
|
1541
|
+
* @param {string} dir - local folder.
|
|
1542
|
+
* @param {string} hash - commit hash to cherry-pick.
|
|
1543
|
+
*/
|
|
1544
|
+
function cherryPickFlow(dir, hash) {
|
|
1545
|
+
const guard = requireGitRepo(dir)
|
|
1546
|
+
if (guard) return { ok: false, error: guard.error }
|
|
1547
|
+
const h = String(hash || '').trim()
|
|
1548
|
+
if (h === '') return { ok: false, error: '缺少 commit hash' }
|
|
1549
|
+
const r = run(GIT, ['-C', dir, 'cherry-pick', h], { timeout: 60_000 })
|
|
1550
|
+
if (!r.ok) return { ok: false, error: (r.stderr || r.stdout).trim() || 'cherry-pick 失败(可能有冲突,请手动处理)' }
|
|
1551
|
+
return { ok: true, hash: h }
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/**
|
|
1555
|
+
* Full patch text of one commit (header suppressed). Merge commits diff
|
|
1556
|
+
* against the first parent so a history click always has content.
|
|
1557
|
+
* @param {string} dir - local folder.
|
|
1558
|
+
* @param {string} hash - commit hash.
|
|
1559
|
+
*/
|
|
1560
|
+
function commitDiffFlow(dir, hash) {
|
|
1561
|
+
const guard = requireGitRepo(dir)
|
|
1562
|
+
if (guard) return { ok: false, error: guard.error }
|
|
1563
|
+
const h = String(hash || '').trim()
|
|
1564
|
+
if (h === '') return { ok: false, error: '缺少 commit hash' }
|
|
1565
|
+
const r = run(GIT, ['-C', dir, 'show', '--no-ext-diff', '--no-color', '--format=', '-m', '--first-parent', h])
|
|
1566
|
+
if (!r.ok) return { ok: false, error: (r.stderr || r.stdout).trim() || '读取提交 diff 失败' }
|
|
1567
|
+
return { ok: true, hash: h, diff: r.stdout }
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// ---------------------------------------------------------------------------
|
|
1571
|
+
// 工具安装(① 环境检查缺工具时的一键安装)——best-effort:按可用包管理器自动选取命令。
|
|
1572
|
+
// ---------------------------------------------------------------------------
|
|
1573
|
+
|
|
1574
|
+
/**
|
|
1575
|
+
* Whether a command exists (probe `--version`; for shell scripts use `command -v`).
|
|
1576
|
+
* @param {string} bin - bare command name.
|
|
1577
|
+
*/
|
|
1578
|
+
function binExists(bin) {
|
|
1579
|
+
if (bin === 'apt-get' || bin === 'dnf' || bin === 'yum') {
|
|
1580
|
+
return run('sh', ['-c', 'command -v ' + bin]).ok
|
|
1581
|
+
}
|
|
1582
|
+
return run(bin, ['--version']).ok
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
/**
|
|
1586
|
+
* Build the install command to run for `tool` on this machine, or null when no
|
|
1587
|
+
* usable package manager is available. Returns arrays usable with `run`.
|
|
1588
|
+
* @param {'git'|'gh'|'ssh'} tool
|
|
1589
|
+
*/
|
|
1590
|
+
function installCommand(tool) {
|
|
1591
|
+
if (IS_WIN) {
|
|
1592
|
+
// SSH 客户端:Windows 内置可选功能(需管理员),走 powershell。
|
|
1593
|
+
if (tool === 'ssh') {
|
|
1594
|
+
return { bin: 'powershell', args: ['-NoProfile', '-Command', 'Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0'], label: 'Add-WindowsCapability -Online -Name OpenSSH.Client' }
|
|
1595
|
+
}
|
|
1596
|
+
const pkg = tool === 'git' ? 'Git.Git' : 'GitHub.cli'
|
|
1597
|
+
if (binExists('winget')) {
|
|
1598
|
+
return { bin: 'winget', args: ['install', '--id', pkg, '-e', '--source', 'winget', '--accept-package-agreements', '--accept-source-agreements'], label: 'winget install --id ' + pkg + ' -e' }
|
|
1599
|
+
}
|
|
1600
|
+
if (binExists('choco')) {
|
|
1601
|
+
return { bin: 'choco', args: ['install', tool === 'git' ? 'git' : 'gh', '-y'], label: 'choco install ' + (tool === 'git' ? 'git' : 'gh') + ' -y' }
|
|
1602
|
+
}
|
|
1603
|
+
if (binExists('scoop')) {
|
|
1604
|
+
return { bin: 'scoop', args: ['install', tool === 'git' ? 'git' : 'gh'], label: 'scoop install ' + (tool === 'git' ? 'git' : 'gh') }
|
|
1605
|
+
}
|
|
1606
|
+
return null
|
|
1607
|
+
}
|
|
1608
|
+
if (process.platform === 'darwin') {
|
|
1609
|
+
const pkg = tool === 'git' ? 'git' : tool === 'gh' ? 'gh' : 'openssh'
|
|
1610
|
+
if (binExists('brew')) return { bin: 'brew', args: ['install', pkg], label: 'brew install ' + pkg }
|
|
1611
|
+
return null
|
|
1612
|
+
}
|
|
1613
|
+
// Linux:按可用包管理器选择。安装需 root,用 sudo(非交互 -n,避免挂起等待密码;已是 root 则直接跑)。
|
|
1614
|
+
const pkg = tool === 'git' ? 'git' : tool === 'gh' ? 'gh' : 'openssh-client'
|
|
1615
|
+
const isRoot = typeof process.getuid === 'function' && process.getuid() === 0
|
|
1616
|
+
const sudo = !isRoot && binExists('sudo') ? 'sudo -n ' : ''
|
|
1617
|
+
if (binExists('apt-get')) {
|
|
1618
|
+
return { bin: 'sh', args: ['-c', sudo + 'apt-get install -y ' + pkg], label: 'sudo apt-get install -y ' + pkg }
|
|
1619
|
+
}
|
|
1620
|
+
if (binExists('dnf')) {
|
|
1621
|
+
return { bin: 'sh', args: ['-c', sudo + 'dnf install -y ' + pkg], label: 'sudo dnf install -y ' + pkg }
|
|
1622
|
+
}
|
|
1623
|
+
if (binExists('pacman')) {
|
|
1624
|
+
return { bin: 'sh', args: ['-c', sudo + 'pacman -S --noconfirm ' + pkg], label: 'sudo pacman -S ' + pkg }
|
|
1625
|
+
}
|
|
1626
|
+
return null
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/**
|
|
1630
|
+
* Install a missing tool (git / gh / ssh) using the best available package
|
|
1631
|
+
* manager. Best-effort: reports the command it ran and its output.
|
|
1632
|
+
* @param {string} tool - 'git' | 'gh' | 'ssh'.
|
|
1633
|
+
*/
|
|
1634
|
+
function installToolFlow(tool) {
|
|
1635
|
+
const t = String(tool || '').trim().toLowerCase()
|
|
1636
|
+
if (t !== 'git' && t !== 'gh' && t !== 'ssh') return { ok: false, error: '未知工具:' + tool }
|
|
1637
|
+
// 已安装就直接返回,不重复安装。
|
|
1638
|
+
const present = t === 'git' ? (GIT && GIT !== 'git')
|
|
1639
|
+
: t === 'gh' ? (GH && GH !== 'gh')
|
|
1640
|
+
: (SSH && SSH !== 'ssh' && SSH !== 'ssh.exe')
|
|
1641
|
+
if (present) return { ok: true, alreadyInstalled: true, tool: t }
|
|
1642
|
+
|
|
1643
|
+
const cmd = installCommand(t)
|
|
1644
|
+
if (!cmd) {
|
|
1645
|
+
return { ok: false, error: '未找到可用的包管理器(' + (IS_WIN ? 'winget / choco / scoop' : process.platform === 'darwin' ? 'brew' : 'apt-get / dnf / pacman') + ')。请手动安装。', tool: t }
|
|
1646
|
+
}
|
|
1647
|
+
const r = run(cmd.bin, cmd.args, { timeout: 300_000 })
|
|
1648
|
+
const output = (r.stdout + '\n' + r.stderr).trim()
|
|
1649
|
+
// 安装后复查是否已装上。
|
|
1650
|
+
const ok = r.ok && (t === 'git' ? (GIT && GIT !== 'git') : t === 'gh' ? (GH && GH !== 'gh') : !(SSH === 'ssh' || SSH === 'ssh.exe'))
|
|
1651
|
+
return {
|
|
1652
|
+
ok,
|
|
1653
|
+
tool: t,
|
|
1654
|
+
command: cmd.label,
|
|
1655
|
+
output: output || (ok ? '安装成功' : '安装失败'),
|
|
1656
|
+
needElevation: IS_WIN && t === 'ssh',
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1145
1660
|
// ---------- route handlers ----------
|
|
1146
1661
|
|
|
1147
1662
|
function json(res, status, payload) {
|
|
@@ -1304,19 +1819,25 @@ function pickDirDialog(initialDir) {
|
|
|
1304
1819
|
return outLine !== '' && existsSync(outLine) ? outLine : undefined
|
|
1305
1820
|
}
|
|
1306
1821
|
|
|
1307
|
-
/** Decode a
|
|
1308
|
-
*
|
|
1309
|
-
*
|
|
1822
|
+
/** Decode a session dir name back to a filesystem path (best-effort).
|
|
1823
|
+
* Windows names look like `--C-Users-27775-workspace--` (drive + '-' joined
|
|
1824
|
+
* segments → `\`); POSIX names like `--Users-name-workspace--` (segments → `/`).
|
|
1825
|
+
* Ambiguous when a folder name itself contains '-', which is why
|
|
1826
|
+
* workspace.json is preferred as the source of truth. */
|
|
1310
1827
|
function decodeSessionDir(name) {
|
|
1311
1828
|
if (typeof name !== 'string') return null
|
|
1312
1829
|
const inner = name.replace(/^--/, '').replace(/--$/, '')
|
|
1313
1830
|
if (inner === '') return null
|
|
1314
|
-
// Split on '-'; first token is the drive letter.
|
|
1315
1831
|
const parts = inner.split('-')
|
|
1316
1832
|
if (parts.length === 0) return null
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1833
|
+
if (IS_WIN) {
|
|
1834
|
+
// Windows: first token is the drive letter, rest joined with '\'.
|
|
1835
|
+
const drive = parts[0]
|
|
1836
|
+
const rest = parts.slice(1).join('\\')
|
|
1837
|
+
return drive + ':\\' + rest
|
|
1838
|
+
}
|
|
1839
|
+
// POSIX (macOS / Linux): absolute path, segments joined with '/'.
|
|
1840
|
+
return '/' + parts.join('/')
|
|
1320
1841
|
}
|
|
1321
1842
|
|
|
1322
1843
|
/**
|
|
@@ -1325,18 +1846,18 @@ function decodeSessionDir(name) {
|
|
|
1325
1846
|
* @param {string} name - repo name (without owner).
|
|
1326
1847
|
* @param {string} [provider] - 'github' (default) | 'gitee'.
|
|
1327
1848
|
*/
|
|
1328
|
-
function repoExists(name, provider = 'github') {
|
|
1849
|
+
function repoExists(name, provider = 'github', owner) {
|
|
1329
1850
|
if (provider === 'gitee') {
|
|
1330
|
-
const
|
|
1331
|
-
if (!
|
|
1332
|
-
const api = giteeApi('GET', 'repos/' +
|
|
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
|
|
1337
|
-
if (!
|
|
1338
|
-
const r = run(GH, ['repo', 'view',
|
|
1339
|
-
return { owner, checked: true, exists: r.ok, provider: 'github' }
|
|
1851
|
+
const o = owner || giteeOwner()
|
|
1852
|
+
if (!o || !name) return { owner: o, checked: false, provider: 'gitee' }
|
|
1853
|
+
const api = giteeApi('GET', 'repos/' + o + '/' + name)
|
|
1854
|
+
if (!api.ok) return { owner: o, checked: false, provider: 'gitee', error: api.error }
|
|
1855
|
+
return { owner: o, checked: true, exists: !giteeApiFailed(api.data), provider: 'gitee' }
|
|
1856
|
+
}
|
|
1857
|
+
const o = owner || ghOwner()
|
|
1858
|
+
if (!o || !name) return { owner: o, checked: false, provider: 'github' }
|
|
1859
|
+
const r = run(GH, ['repo', 'view', o + '/' + name, '--json', 'name'], { timeout: 20_000 })
|
|
1860
|
+
return { owner: o, checked: true, exists: r.ok, provider: 'github' }
|
|
1340
1861
|
}
|
|
1341
1862
|
|
|
1342
1863
|
/**
|
|
@@ -1345,18 +1866,19 @@ function repoExists(name, provider = 'github') {
|
|
|
1345
1866
|
* callers treat "unknown" as "no visibility info".
|
|
1346
1867
|
* @param {string} name - repo name (without owner).
|
|
1347
1868
|
* @param {string} [provider] - 'github' (default) | 'gitee'.
|
|
1869
|
+
* @param {string} [owner] - pre-resolved owner (skips a network lookup).
|
|
1348
1870
|
*/
|
|
1349
|
-
function repoVisibility(name, provider = 'github') {
|
|
1871
|
+
function repoVisibility(name, provider = 'github', owner) {
|
|
1350
1872
|
if (provider === 'gitee') {
|
|
1351
|
-
const
|
|
1352
|
-
if (!
|
|
1353
|
-
const api = giteeApi('GET', 'repos/' +
|
|
1873
|
+
const o = owner || giteeOwner()
|
|
1874
|
+
if (!o || !name) return undefined
|
|
1875
|
+
const api = giteeApi('GET', 'repos/' + o + '/' + name)
|
|
1354
1876
|
if (!api.ok || !api.data || giteeApiFailed(api.data)) return undefined
|
|
1355
1877
|
return api.data.private === true ? 'private' : (api.data.private === false ? 'public' : undefined)
|
|
1356
1878
|
}
|
|
1357
|
-
const
|
|
1358
|
-
if (!
|
|
1359
|
-
const r = run(GH, ['repo', 'view',
|
|
1879
|
+
const o = owner || ghOwner()
|
|
1880
|
+
if (!o || !name) return undefined
|
|
1881
|
+
const r = run(GH, ['repo', 'view', o + '/' + name, '--json', 'visibility'], { timeout: 20_000 })
|
|
1360
1882
|
if (!r.ok) return undefined
|
|
1361
1883
|
try {
|
|
1362
1884
|
const parsed = JSON.parse(r.stdout)
|
|
@@ -1367,6 +1889,44 @@ function repoVisibility(name, provider = 'github') {
|
|
|
1367
1889
|
}
|
|
1368
1890
|
}
|
|
1369
1891
|
|
|
1892
|
+
/**
|
|
1893
|
+
* One-shot check that returns BOTH whether a same-name repo exists AND its
|
|
1894
|
+
* current visibility, using a SINGLE network call (GitHub `gh repo view` with
|
|
1895
|
+
* both fields; Gitee `GET /repos/{owner}/{name}`). This collapses the two
|
|
1896
|
+
* separate `repoExists` + `repoVisibility` calls that `repoStatus` used to make
|
|
1897
|
+
* back-to-back, halving the remote round-trips for the common case.
|
|
1898
|
+
* @param {string} name - repo name (without owner).
|
|
1899
|
+
* @param {string} [provider] - 'github' (default) | 'gitee'.
|
|
1900
|
+
* @param {string} [owner] - pre-resolved owner (skips a network lookup).
|
|
1901
|
+
*/
|
|
1902
|
+
function repoExistenceAndVisibility(name, provider = 'github', owner) {
|
|
1903
|
+
if (provider === 'gitee') {
|
|
1904
|
+
const o = owner || giteeOwner()
|
|
1905
|
+
if (!o || !name) return { owner: o, checked: false, provider: 'gitee' }
|
|
1906
|
+
const api = giteeApi('GET', 'repos/' + o + '/' + name)
|
|
1907
|
+
if (!api.ok) return { owner: o, checked: false, provider: 'gitee', error: api.error }
|
|
1908
|
+
const exists = !giteeApiFailed(api.data)
|
|
1909
|
+
const visibility = exists && api.data
|
|
1910
|
+
? (api.data.private === true ? 'private' : (api.data.private === false ? 'public' : undefined))
|
|
1911
|
+
: undefined
|
|
1912
|
+
return { owner: o, checked: true, exists, visibility, provider: 'gitee' }
|
|
1913
|
+
}
|
|
1914
|
+
const o = owner || ghOwner()
|
|
1915
|
+
if (!o || !name) return { owner: o, checked: false, provider: 'github' }
|
|
1916
|
+
const r = run(GH, ['repo', 'view', o + '/' + name, '--json', 'name,visibility'], { timeout: 20_000 })
|
|
1917
|
+
if (!r.ok) return { owner: o, checked: true, exists: false, visibility: undefined, provider: 'github' }
|
|
1918
|
+
try {
|
|
1919
|
+
const parsed = JSON.parse(r.stdout)
|
|
1920
|
+
const v = String(parsed.visibility || '').toLowerCase()
|
|
1921
|
+
return {
|
|
1922
|
+
owner: o, checked: true, exists: true,
|
|
1923
|
+
visibility: v === 'public' || v === 'private' ? v : undefined, provider: 'github',
|
|
1924
|
+
}
|
|
1925
|
+
} catch {
|
|
1926
|
+
return { owner: o, checked: true, exists: true, visibility: undefined, provider: 'github' }
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1370
1930
|
/**
|
|
1371
1931
|
* Change a repo's visibility. GitHub mode uses gh; Gitee mode uses the Gitee
|
|
1372
1932
|
* OpenAPI. Returns the new visibility on success.
|
|
@@ -1418,6 +1978,13 @@ export function apply(ctx) {
|
|
|
1418
1978
|
|
|
1419
1979
|
const routes = {
|
|
1420
1980
|
'/env': (req, res) => handle(req, res, async () => ({ ok: true, ...checkEnv() })),
|
|
1981
|
+
'/install-tool': async (req, res) => {
|
|
1982
|
+
if (req.method !== 'POST') return methodNotAllowed(res, req.method)
|
|
1983
|
+
await handle(req, res, async (req) => {
|
|
1984
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
1985
|
+
return installToolFlow(body.tool)
|
|
1986
|
+
})
|
|
1987
|
+
},
|
|
1421
1988
|
'/ssh': (req, res) => handle(req, res, async () => ({ ok: true, ...checkSsh() })),
|
|
1422
1989
|
'/gen-key': async (req, res) => {
|
|
1423
1990
|
if (req.method !== 'POST') return methodNotAllowed(res, req.method)
|
|
@@ -1502,12 +2069,85 @@ export function apply(ctx) {
|
|
|
1502
2069
|
try {
|
|
1503
2070
|
const q = new URL(req.url ?? '', 'http://localhost')
|
|
1504
2071
|
const provider = q.searchParams.get('provider') || 'github'
|
|
1505
|
-
|
|
2072
|
+
const full = q.searchParams.get('full') === '1'
|
|
2073
|
+
return repoStatus(queryDir(req, fallbackDir), provider, full)
|
|
1506
2074
|
} catch {
|
|
1507
|
-
return repoStatus(queryDir(req, fallbackDir), 'github')
|
|
2075
|
+
return repoStatus(queryDir(req, fallbackDir), 'github', false)
|
|
1508
2076
|
}
|
|
1509
2077
|
})
|
|
1510
2078
|
},
|
|
2079
|
+
'/repo-diff': async (req, res) => {
|
|
2080
|
+
if (req.method !== 'GET') return methodNotAllowed(res, req.method)
|
|
2081
|
+
await handle(req, res, async (req) => {
|
|
2082
|
+
const q = new URL(req.url ?? '', 'http://localhost')
|
|
2083
|
+
const dir = q.searchParams.get('dir') || fallbackDir
|
|
2084
|
+
const path = q.searchParams.get('path') || ''
|
|
2085
|
+
return repoDiffFlow(dir, path)
|
|
2086
|
+
})
|
|
2087
|
+
},
|
|
2088
|
+
'/stage': 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 stageFlow(body.dir || fallbackDir, body.path)
|
|
2093
|
+
})
|
|
2094
|
+
},
|
|
2095
|
+
'/unstage': async (req, res) => {
|
|
2096
|
+
if (req.method !== 'POST') return methodNotAllowed(res, req.method)
|
|
2097
|
+
await handle(req, res, async (req) => {
|
|
2098
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
2099
|
+
return unstageFlow(body.dir || fallbackDir, body.path)
|
|
2100
|
+
})
|
|
2101
|
+
},
|
|
2102
|
+
'/commit': async (req, res) => {
|
|
2103
|
+
if (req.method !== 'POST') return methodNotAllowed(res, req.method)
|
|
2104
|
+
await handle(req, res, async (req) => {
|
|
2105
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
2106
|
+
return commitFlow(body.dir || fallbackDir, body.message, body.paths)
|
|
2107
|
+
})
|
|
2108
|
+
},
|
|
2109
|
+
'/branches': async (req, res) => {
|
|
2110
|
+
if (req.method !== 'POST') return methodNotAllowed(res, req.method)
|
|
2111
|
+
await handle(req, res, async (req) => {
|
|
2112
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
2113
|
+
return branchesFlow(body.dir || fallbackDir)
|
|
2114
|
+
})
|
|
2115
|
+
},
|
|
2116
|
+
'/checkout': async (req, res) => {
|
|
2117
|
+
if (req.method !== 'POST') return methodNotAllowed(res, req.method)
|
|
2118
|
+
await handle(req, res, async (req) => {
|
|
2119
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
2120
|
+
return checkoutFlow(body.dir || fallbackDir, body.branch)
|
|
2121
|
+
})
|
|
2122
|
+
},
|
|
2123
|
+
'/log': async (req, res) => {
|
|
2124
|
+
if (req.method !== 'POST') return methodNotAllowed(res, req.method)
|
|
2125
|
+
await handle(req, res, async (req) => {
|
|
2126
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
2127
|
+
return logFlow(body.dir || fallbackDir, body.count)
|
|
2128
|
+
})
|
|
2129
|
+
},
|
|
2130
|
+
'/revert': async (req, res) => {
|
|
2131
|
+
if (req.method !== 'POST') return methodNotAllowed(res, req.method)
|
|
2132
|
+
await handle(req, res, async (req) => {
|
|
2133
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
2134
|
+
return revertFlow(body.dir || fallbackDir, body.hash)
|
|
2135
|
+
})
|
|
2136
|
+
},
|
|
2137
|
+
'/cherrypick': async (req, res) => {
|
|
2138
|
+
if (req.method !== 'POST') return methodNotAllowed(res, req.method)
|
|
2139
|
+
await handle(req, res, async (req) => {
|
|
2140
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
2141
|
+
return cherryPickFlow(body.dir || fallbackDir, body.hash)
|
|
2142
|
+
})
|
|
2143
|
+
},
|
|
2144
|
+
'/commit-diff': async (req, res) => {
|
|
2145
|
+
if (req.method !== 'POST') return methodNotAllowed(res, req.method)
|
|
2146
|
+
await handle(req, res, async (req) => {
|
|
2147
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
2148
|
+
return commitDiffFlow(body.dir || fallbackDir, body.hash)
|
|
2149
|
+
})
|
|
2150
|
+
},
|
|
1511
2151
|
'/push': async (req, res) => {
|
|
1512
2152
|
if (req.method !== 'POST') return methodNotAllowed(res, req.method)
|
|
1513
2153
|
await handle(req, res, async (req) => {
|
|
@@ -1515,6 +2155,13 @@ export function apply(ctx) {
|
|
|
1515
2155
|
return pushFlow(body.dir || fallbackDir)
|
|
1516
2156
|
})
|
|
1517
2157
|
},
|
|
2158
|
+
'/push-staged': async (req, res) => {
|
|
2159
|
+
if (req.method !== 'POST') return methodNotAllowed(res, req.method)
|
|
2160
|
+
await handle(req, res, async (req) => {
|
|
2161
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
2162
|
+
return pushStagedFlow(body.dir || fallbackDir, body.message)
|
|
2163
|
+
})
|
|
2164
|
+
},
|
|
1518
2165
|
'/pull': async (req, res) => {
|
|
1519
2166
|
if (req.method !== 'POST') return methodNotAllowed(res, req.method)
|
|
1520
2167
|
await handle(req, res, async (req) => {
|