dsh-ide-git 0.5.5 → 0.6.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/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-ide-git",
3
- "version": "0.5.5",
3
+ "version": "0.6.0",
4
4
  "main": "./src/index.js",
5
5
  "description": "IDE 级 Git 操作面板:分支树 + 提交图谱 + 变更/暂存/提交 + 提交详情。装了 dsh-better-sidebar 时挂成它的原生 tab(右侧栏 + 底部工作台);没装时直接挂 DSH 原生右侧栏,单独安装即可用。JetBrains 风格 Git 工具窗口 for DeepSeek Harness.",
6
6
  "engines": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ide-git",
3
- "version": "0.5.5",
3
+ "version": "0.6.0",
4
4
  "description": "DSH plugin: an IDE-grade Git tool window as a native dsh-better-sidebar tab — branch tree, commit graph, changes, commit details, JetBrains-style actions, in the right sidebar and the bottom panel. | DSH 插件:IDE 级 Git 工具窗口,以 dsh-better-sidebar 原生 Tab 挂载——分支树 / 提交图谱 / 变更与提交详情 / JetBrains 风格操作,右侧栏与底部面板双布局自适应。",
5
5
  "type": "module",
6
6
  "repository": {
@@ -13,7 +13,8 @@
13
13
  "main": "src/index.js",
14
14
  "exports": {
15
15
  ".": "./src/index.js",
16
- "./client": "./src/client.js"
16
+ "./client": "./src/client.js",
17
+ "./package.json": "./package.json"
17
18
  },
18
19
  "engines": {
19
20
  "node": ">=18",
@@ -44,7 +45,7 @@
44
45
  "LICENSE"
45
46
  ],
46
47
  "scripts": {
47
- "test": "node --test tests/smoke.mjs tests/api.test.mjs tests/graph.test.mjs",
48
+ "test": "node --test tests/smoke.mjs tests/api.test.mjs tests/graph.test.mjs tests/repos.test.mjs",
48
49
  "version": "node -e \"const fs=require('fs');const m=JSON.parse(fs.readFileSync('dsh.plugin.json','utf8'));const v=JSON.parse(fs.readFileSync('package.json','utf8')).version;m.version=v;fs.writeFileSync('dsh.plugin.json',JSON.stringify(m,null,2)+'\\n')\" && git add dsh.plugin.json"
49
50
  },
50
51
  "keywords": [
package/src/index.js CHANGED
@@ -1050,6 +1050,138 @@ async function compare(payload) {
1050
1050
  /** Directories never descended into while looking for repositories. */
1051
1051
  const REPO_SCAN_SKIP = new Set(['node_modules', 'dist', 'build', 'out', 'target', 'vendor', 'venv', '.venv', '__pycache__', 'coverage', 'tmp', 'temp'])
1052
1052
 
1053
+ /* The submodule walk spawns one short `git submodule status` per level, so the
1054
+ depth is capped the same way the directory scan caps its own, and every list
1055
+ stays bounded so a pathological tree cannot flood the picker. */
1056
+ const SUBMODULE_MAX_DEPTH = 2
1057
+ const SUBMODULE_LIST_LIMIT = 400
1058
+ const REPO_LIST_LIMIT = 60
1059
+
1060
+ /** Slash-normalized path, for comparing paths that came from different
1061
+ * producers (git prints forward slashes; `path.join` prints backslashes on
1062
+ * Windows). Case-insensitive on Windows so the same checkout reached two ways
1063
+ * collapses to one row. */
1064
+ function pathIdentity(value) {
1065
+ const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '')
1066
+ return process.platform === 'win32' ? normalized.toLowerCase() : normalized
1067
+ }
1068
+
1069
+ /** One repository row shipped to the client (`kind` drives the picker badge). */
1070
+ function repoRow(root, submodulePath, kind) {
1071
+ return {
1072
+ path: root.replace(/\\/g, '/'),
1073
+ /** Full checkout name; a submodule carries its repo-relative path so two
1074
+ * same-named checkouts stay apart in a long list. */
1075
+ name: submodulePath === undefined ? path.basename(root) : submodulePath,
1076
+ label: submodulePath === undefined ? path.basename(root) : submodulePath.split('/').pop(),
1077
+ branch: null,
1078
+ kind,
1079
+ ...(submodulePath === undefined ? {} : { submodulePath }),
1080
+ }
1081
+ }
1082
+
1083
+ /**
1084
+ * Every checkout registered as a submodule under `root`, at any depth.
1085
+ *
1086
+ * Git itself is the source of truth: `git submodule status --recursive` lists
1087
+ * the registered paths (relative to the parent repository) even for submodules
1088
+ * that were never initialised, and nested ones are reached by recursing into
1089
+ * each listed path. A path that is present on disk and passes
1090
+ * `rev-parse --show-toplevel` is a real, independent working tree — the parent
1091
+ * only ever sees it as one gitlink line, so this is what makes it selectable
1092
+ * as a repository of its own (the SourceTree behavior).
1093
+ *
1094
+ * The per-level `submodule status` calls are serial (a level's paths are only
1095
+ * known once its parent answered), but the working-tree checks are not: they
1096
+ * all run at once, because a repository with a dozen submodules otherwise
1097
+ * costs a dozen sequential processes on every scan.
1098
+ */
1099
+ async function collectSubmoduleRepos(root, depth = 1, prefix = '') {
1100
+ const found = []
1101
+ const listing = await runGit(root, ['submodule', 'status', '--recursive'])
1102
+ if (listing.code !== 0) return found
1103
+ const candidates = []
1104
+ for (const raw of listing.stdout.split('\n')) {
1105
+ if (candidates.length >= SUBMODULE_LIST_LIMIT) break
1106
+ const line = raw.replace(/\r$/, '')
1107
+ if (line.trim() === '') continue
1108
+ /* `<state><sha> <path> (<describe>)`: the state prefix is ' ', '+', '-' or 'U'. */
1109
+ const match = /^[\s+\-U]([0-9a-f]{4,64})\s+(.+?)(?:\s+\(.*\))?$/.exec(line)
1110
+ const rel = (match === null ? line.trim().replace(/^[\s+\-U]\S*\s+/, '') : match[2]).trim().replace(/\\/g, '/')
1111
+ if (rel === '') continue
1112
+ const absolute = path.resolve(root, rel)
1113
+ /* An uninitialised submodule has no `.git` yet: git still registers it, but
1114
+ there is no working tree to bind the panel to. */
1115
+ if (!existsSync(path.join(absolute, '.git'))) continue
1116
+ candidates.push({ absolute, rel: `${prefix}${rel}` })
1117
+ }
1118
+ const tops = await Promise.all(candidates.map((entry) => runGit(entry.absolute, ['rev-parse', '--show-toplevel'])))
1119
+ const next = []
1120
+ for (let index = 0; index < candidates.length; index += 1) {
1121
+ const top = tops[index]
1122
+ if (top.code !== 0) continue
1123
+ const reported = top.stdout.trim().replace(/\\/g, '/').replace(/\/+$/, '')
1124
+ found.push({ root: reported === '' ? candidates[index].absolute : reported, rel: candidates[index].rel })
1125
+ if (depth < SUBMODULE_MAX_DEPTH) next.push(candidates[index])
1126
+ }
1127
+ if (depth < SUBMODULE_MAX_DEPTH) {
1128
+ const deeper = await Promise.all(next.map((entry) => collectSubmoduleRepos(entry.absolute, depth + 1, `${entry.rel}/`)))
1129
+ for (const list of deeper) {
1130
+ for (const entry of list) {
1131
+ if (found.length >= SUBMODULE_LIST_LIMIT) break
1132
+ found.push({ root: entry.root, rel: entry.rel })
1133
+ }
1134
+ }
1135
+ }
1136
+ return found
1137
+ }
1138
+
1139
+ /** The whole discovery pass, cached briefly per cwd: it walks the tree and
1140
+ * spawns a process per checkout, while the panel polls several times a minute.
1141
+ * A submodule appearing or disappearing is a deliberate reconfiguration, so a
1142
+ * one-minute staleness is the right trade. */
1143
+ const repoScanCache = new Map()
1144
+ const REPO_SCAN_TTL_MS = 60_000
1145
+
1146
+ async function scanRepos(cwd) {
1147
+ const cached = repoScanCache.get(cwd)
1148
+ /* Hand back fresh row objects: the cache is shared by every caller, and a
1149
+ consumer that decorates or reorders what it received must not write
1150
+ through into the next request's answer. */
1151
+ if (cached !== undefined && cached.expires > Date.now()) return cached.rows.map((row) => ({ ...row }))
1152
+ const list = []
1153
+ const self = await runGit(cwd, ['rev-parse', '--show-toplevel'])
1154
+ const isRepo = self.code === 0
1155
+ if (isRepo) {
1156
+ const top = self.stdout.trim()
1157
+ list.push(repoRow(top === '' ? cwd : top, undefined, 'workspace'))
1158
+ }
1159
+ for (const dir of findNestedRepos(cwd, 2)) list.push(repoRow(dir, undefined, 'nested'))
1160
+ /* Submodules of EVERY discovered checkout, not only a workspace that is one:
1161
+ a container workspace (a folder holding several checkouts — the usual
1162
+ shape of a multi-repo project) reaches its repositories through the rows
1163
+ above, and each of those may carry submodules of its own. Without this
1164
+ pass the picker lists `eis` but none of the eight checkouts under it.
1165
+ These rows are built LAST and the map below lets them win: the directory
1166
+ scan can reach the same checkout through a plain path (a submodule whose
1167
+ parent directory is not itself a repository), and the submodule row is the
1168
+ better one — its name carries the repo-relative path. */
1169
+ const roots = list.map((row) => row.path)
1170
+ const submoduleLists = await Promise.all(roots.map((root) => collectSubmoduleRepos(root)))
1171
+ for (const entries of submoduleLists) {
1172
+ for (const entry of entries) list.push(repoRow(entry.root, entry.rel, 'nested'))
1173
+ }
1174
+ const byIdentity = new Map()
1175
+ for (const row of list) byIdentity.set(pathIdentity(row.path), row)
1176
+ const rows = [...byIdentity.values()]
1177
+ rows.sort((a, b) => (a.kind === b.kind ? a.path.localeCompare(b.path) : a.kind === 'workspace' ? -1 : 1))
1178
+ const capped = rows.slice(0, REPO_LIST_LIMIT).map((row) => ({ ...row }))
1179
+ const branches = await Promise.all(capped.map((row) => branchOrNull(row.path)))
1180
+ for (let index = 0; index < capped.length; index += 1) capped[index].branch = branches[index]
1181
+ repoScanCache.set(cwd, { rows: capped, expires: Date.now() + REPO_SCAN_TTL_MS })
1182
+ return capped.map((row) => ({ ...row }))
1183
+ }
1184
+
1053
1185
  /** Depth-capped search for repositories nested in a workspace. */
1054
1186
  function findNestedRepos(root, maxDepth) {
1055
1187
  const found = []
@@ -1077,25 +1209,18 @@ async function branchOrNull(root) {
1077
1209
 
1078
1210
  /**
1079
1211
  * Repositories this panel may bind to: the session workspace itself when it is
1080
- * one, plus repositories nested inside it (a sandbox holding many checkouts).
1081
- * The client turns this into the repo picker — an empty workspace is answered
1082
- * with the picker, not with an error.
1212
+ * one, repositories nested inside it (a sandbox holding many checkouts), and
1213
+ * the submodules of every checkout discovered that way — git records each
1214
+ * submodule as its own working tree with its own HEAD, index and branches, so
1215
+ * it is a repository the panel can bind to like any other. The client turns
1216
+ * this into the repo picker, and picking a row is all it takes: every later
1217
+ * call carries that path as `repoRoot`, which is the working directory the git
1218
+ * commands run in.
1083
1219
  */
1084
1220
  async function repos(payload) {
1085
1221
  const cwd = cwdOf(payload)
1086
- const list = []
1087
- const self = await runGit(cwd, ['rev-parse', '--show-toplevel'])
1088
- const isRepo = self.code === 0
1089
- if (isRepo) {
1090
- const top = self.stdout.trim()
1091
- list.push({ path: top, name: path.basename(top), branch: await branchOrNull(top), kind: 'workspace' })
1092
- }
1093
- for (const dir of findNestedRepos(cwd, 2)) {
1094
- if (list.some((entry) => entry.path === dir)) continue
1095
- list.push({ path: dir, name: path.basename(dir), branch: await branchOrNull(dir), kind: 'nested' })
1096
- }
1097
- list.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'workspace' ? -1 : 1))
1098
- return { cwd, isRepo, repos: list }
1222
+ const rows = await scanRepos(cwd)
1223
+ return { cwd, isRepo: rows.some((row) => row.kind === 'workspace'), repos: rows }
1099
1224
  }
1100
1225
 
1101
1226
  async function version() {