aws-runtime-bridge 1.7.25 → 1.7.26

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.
@@ -6,6 +6,32 @@
6
6
  import multer from 'multer';
7
7
  export declare const fileBrowserRouter: import("express-serve-static-core").Router;
8
8
  export declare const WORKSPACE_UPLOAD_FILE_LIMIT = 2000;
9
+ interface FileBrowserItem {
10
+ name: string;
11
+ path: string;
12
+ isDirectory: boolean;
13
+ }
14
+ interface FileBrowserResponse {
15
+ isWindows: boolean;
16
+ currentPath: string;
17
+ items: FileBrowserItem[];
18
+ }
19
+ interface DirectoryTreeItem extends FileBrowserItem {
20
+ children?: DirectoryTreeItem[];
21
+ truncated?: boolean;
22
+ }
23
+ interface DirectoryTreeResponse extends FileBrowserResponse {
24
+ maxDepth: number;
25
+ maxItemsPerDirectory: number;
26
+ tree: DirectoryTreeItem[];
27
+ }
28
+ interface GitIgnoreRule {
29
+ pattern: string;
30
+ negated: boolean;
31
+ directoryOnly: boolean;
32
+ anchored: boolean;
33
+ hasSlash: boolean;
34
+ }
9
35
  export declare function createWorkspaceUploadLimitResponse(error: multer.MulterError): {
10
36
  status: number;
11
37
  body: {
@@ -14,4 +40,11 @@ export declare function createWorkspaceUploadLimitResponse(error: multer.MulterE
14
40
  };
15
41
  };
16
42
  export declare function parseWorkspaceUploadRelativePaths(body: Record<string, unknown>): string[];
43
+ /**
44
+ * 主流程:从选中的工作目录生成受限目录树,最多 10 层且每个目录只返回前 20 个子目录。
45
+ */
46
+ export declare function listHostDirectoryTree(requestPath?: string): Promise<DirectoryTreeResponse>;
47
+ export declare function parseGitIgnoreRules(content: string): GitIgnoreRule[];
48
+ export declare function isIgnoredByGitIgnore(relativePath: string, isDirectory: boolean, rules: GitIgnoreRule[]): boolean;
49
+ export {};
17
50
  //# sourceMappingURL=file-browser.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"file-browser.d.ts","sourceRoot":"","sources":["../../src/routes/file-browser.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAOH,OAAO,MAAM,MAAM,QAAQ,CAAC;AAuB5B,eAAO,MAAM,iBAAiB,4CAAW,CAAC;AAE1C,eAAO,MAAM,2BAA2B,OAAO,CAAC;AAqChD,wBAAgB,kCAAkC,CAAC,KAAK,EAAE,MAAM,CAAC,WAAW,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAUvI;AAED,wBAAgB,iCAAiC,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,EAAE,CAgBzF"}
1
+ {"version":3,"file":"file-browser.d.ts","sourceRoot":"","sources":["../../src/routes/file-browser.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAOH,OAAO,MAAM,MAAM,QAAQ,CAAC;AAuB5B,eAAO,MAAM,iBAAiB,4CAAW,CAAC;AAE1C,eAAO,MAAM,2BAA2B,OAAO,CAAC;AAUhD,UAAU,eAAe;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,UAAU,mBAAmB;IAC3B,SAAS,EAAE,OAAO,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,eAAe,EAAE,CAAC;CAC1B;AAKD,UAAU,iBAAkB,SAAQ,eAAe;IACjD,QAAQ,CAAC,EAAE,iBAAiB,EAAE,CAAC;IAC/B,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,UAAU,qBAAsB,SAAQ,mBAAmB;IACzD,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,IAAI,EAAE,iBAAiB,EAAE,CAAC;CAC3B;AAED,UAAU,aAAa;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,OAAO,CAAC;IACjB,aAAa,EAAE,OAAO,CAAC;IACvB,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,OAAO,CAAC;CACnB;AAiBD,wBAAgB,kCAAkC,CAAC,KAAK,EAAE,MAAM,CAAC,WAAW,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAUvI;AAED,wBAAgB,iCAAiC,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,EAAE,CAgBzF;AAyFD;;GAEG;AACH,wBAAsB,qBAAqB,CAAC,WAAW,SAAK,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAkB5F;AAoCD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,aAAa,EAAE,CAuBpE;AAWD,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,KAAK,EAAE,aAAa,EAAE,GAAG,OAAO,CAWhH"}
@@ -23,6 +23,8 @@ const upload = multer({
23
23
  fileSize: 512 * 1024 * 1024
24
24
  }
25
25
  });
26
+ const DIRECTORY_TREE_MAX_DEPTH = 10;
27
+ const DIRECTORY_TREE_MAX_ITEMS_PER_DIRECTORY = 20;
26
28
  function toBoolean(value) {
27
29
  return value === true || value === 'true' || value === '1';
28
30
  }
@@ -141,6 +143,147 @@ async function listHostDirectories(requestPath = '') {
141
143
  items
142
144
  };
143
145
  }
146
+ /**
147
+ * 主流程:从选中的工作目录生成受限目录树,最多 10 层且每个目录只返回前 20 个子目录。
148
+ */
149
+ export async function listHostDirectoryTree(requestPath = '') {
150
+ const root = await listHostDirectories(requestPath);
151
+ const ignoreRules = root.currentPath ? await loadGitIgnoreRules(root.currentPath) : [];
152
+ if (!root.currentPath) {
153
+ return {
154
+ ...root,
155
+ maxDepth: DIRECTORY_TREE_MAX_DEPTH,
156
+ maxItemsPerDirectory: DIRECTORY_TREE_MAX_ITEMS_PER_DIRECTORY,
157
+ tree: root.items.slice(0, DIRECTORY_TREE_MAX_ITEMS_PER_DIRECTORY),
158
+ };
159
+ }
160
+ return {
161
+ ...root,
162
+ maxDepth: DIRECTORY_TREE_MAX_DEPTH,
163
+ maxItemsPerDirectory: DIRECTORY_TREE_MAX_ITEMS_PER_DIRECTORY,
164
+ tree: await buildDirectoryTree(root.currentPath, root.currentPath, 1, ignoreRules),
165
+ };
166
+ }
167
+ async function buildDirectoryTree(rootPath, directoryPath, depth, ignoreRules) {
168
+ if (depth > DIRECTORY_TREE_MAX_DEPTH) {
169
+ return [];
170
+ }
171
+ const entries = await fs.readdir(directoryPath, { withFileTypes: true });
172
+ const directories = entries
173
+ .filter((entry) => entry.isDirectory())
174
+ .filter((entry) => !isIgnoredDirectory(rootPath, path.join(directoryPath, entry.name), ignoreRules))
175
+ .sort((left, right) => left.name.localeCompare(right.name))
176
+ .slice(0, DIRECTORY_TREE_MAX_ITEMS_PER_DIRECTORY);
177
+ const nodes = [];
178
+ for (const entry of directories) {
179
+ const childPath = path.join(directoryPath, entry.name);
180
+ const node = {
181
+ name: entry.name,
182
+ path: childPath.replace(/\\/g, '/'),
183
+ isDirectory: true,
184
+ };
185
+ if (depth < DIRECTORY_TREE_MAX_DEPTH) {
186
+ try {
187
+ node.children = await buildDirectoryTree(rootPath, childPath, depth + 1, ignoreRules);
188
+ }
189
+ catch {
190
+ node.children = [];
191
+ }
192
+ }
193
+ else {
194
+ node.truncated = true;
195
+ }
196
+ nodes.push(node);
197
+ }
198
+ return nodes;
199
+ }
200
+ export function parseGitIgnoreRules(content) {
201
+ return String(content || '')
202
+ .split(/\r?\n/)
203
+ .map((line) => line.trim())
204
+ .filter((line) => line && !line.startsWith('#'))
205
+ .map((line) => {
206
+ const negated = line.startsWith('!');
207
+ const rawPattern = negated ? line.slice(1).trim() : line;
208
+ const anchored = rawPattern.startsWith('/');
209
+ const directoryOnly = rawPattern.endsWith('/');
210
+ const pattern = rawPattern
211
+ .replace(/^\/+/, '')
212
+ .replace(/\/+$/, '')
213
+ .replace(/\\/g, '/');
214
+ return {
215
+ pattern,
216
+ negated,
217
+ directoryOnly,
218
+ anchored,
219
+ hasSlash: pattern.includes('/'),
220
+ };
221
+ })
222
+ .filter((rule) => rule.pattern.length > 0);
223
+ }
224
+ async function loadGitIgnoreRules(rootPath) {
225
+ try {
226
+ const content = await fs.readFile(path.join(rootPath, '.gitignore'), 'utf8');
227
+ return parseGitIgnoreRules(content);
228
+ }
229
+ catch {
230
+ return [];
231
+ }
232
+ }
233
+ export function isIgnoredByGitIgnore(relativePath, isDirectory, rules) {
234
+ const normalizedPath = normalizeRelativePath(relativePath);
235
+ if (!normalizedPath)
236
+ return false;
237
+ let ignored = false;
238
+ for (const rule of rules) {
239
+ if (rule.directoryOnly && !isDirectory)
240
+ continue;
241
+ if (matchesGitIgnoreRule(normalizedPath, rule)) {
242
+ ignored = !rule.negated;
243
+ }
244
+ }
245
+ return ignored;
246
+ }
247
+ function isIgnoredDirectory(rootPath, directoryPath, ignoreRules) {
248
+ if (ignoreRules.length === 0)
249
+ return false;
250
+ const relativePath = path.relative(rootPath, directoryPath).replace(/\\/g, '/');
251
+ return isIgnoredByGitIgnore(relativePath, true, ignoreRules);
252
+ }
253
+ function matchesGitIgnoreRule(relativePath, rule) {
254
+ if (!rule.hasSlash && !rule.anchored) {
255
+ return relativePath.split('/').some((segment) => matchesGlobSegment(segment, rule.pattern));
256
+ }
257
+ const pattern = rule.pattern;
258
+ if (rule.anchored) {
259
+ return matchesGlobPath(relativePath, pattern) || relativePath.startsWith(pattern + '/');
260
+ }
261
+ return matchesGlobPath(relativePath, pattern)
262
+ || relativePath.endsWith('/' + pattern)
263
+ || relativePath.includes('/' + pattern + '/');
264
+ }
265
+ function matchesGlobPath(relativePath, pattern) {
266
+ const regex = new RegExp('^' + globToRegex(pattern) + '(?:/.*)?$');
267
+ return regex.test(relativePath);
268
+ }
269
+ function matchesGlobSegment(segment, pattern) {
270
+ const regex = new RegExp('^' + globToRegex(pattern) + '$');
271
+ return regex.test(segment);
272
+ }
273
+ function globToRegex(pattern) {
274
+ return pattern
275
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
276
+ .replace(/\*\*/g, '.*')
277
+ .replace(/\*/g, '[^/]*')
278
+ .replace(/\?/g, '[^/]');
279
+ }
280
+ function normalizeRelativePath(relativePath) {
281
+ return String(relativePath || '')
282
+ .replace(/\\/g, '/')
283
+ .replace(/^\.\//, '')
284
+ .replace(/^\/+/, '')
285
+ .replace(/\/+$/, '');
286
+ }
144
287
  async function resolveHostDirectoryPath(targetPath) {
145
288
  const normalizedPath = path.normalize(String(targetPath || ''));
146
289
  const stat = await fs.stat(normalizedPath);
@@ -218,6 +361,16 @@ fileBrowserRouter.post('/directory-picker/list', validateToken, async (req, res)
218
361
  res.status(400).json({ error: err.message });
219
362
  }
220
363
  });
364
+ fileBrowserRouter.post('/directory-picker/tree', validateToken, async (req, res) => {
365
+ try {
366
+ const { path: requestPath = '' } = req.body || {};
367
+ res.json(await listHostDirectoryTree(String(requestPath || '')));
368
+ }
369
+ catch (error) {
370
+ const err = error;
371
+ res.status(400).json({ error: err.message });
372
+ }
373
+ });
221
374
  /**
222
375
  * 列出工作区目录内容
223
376
  * POST /api/file-browser/workspace/list
@@ -2,8 +2,11 @@
2
2
  * File Browser 路由单元测试
3
3
  */
4
4
  import { describe, it, expect } from 'vitest';
5
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import path from 'node:path';
5
8
  import multer from 'multer';
6
- import { createWorkspaceUploadLimitResponse, parseWorkspaceUploadRelativePaths, WORKSPACE_UPLOAD_FILE_LIMIT } from './file-browser.js';
9
+ import { createWorkspaceUploadLimitResponse, isIgnoredByGitIgnore, listHostDirectoryTree, parseGitIgnoreRules, parseWorkspaceUploadRelativePaths, WORKSPACE_UPLOAD_FILE_LIMIT } from './file-browser.js';
7
10
  describe('file-browser route validation', () => {
8
11
  it('returns root drives on Windows when path is empty', () => {
9
12
  const getRootForWindows = () => {
@@ -117,3 +120,36 @@ describe('file-browser response building', () => {
117
120
  expect(relativePaths).toEqual(['a/b/c.txt', 'a/d/e.txt']);
118
121
  });
119
122
  });
123
+ describe('directory tree gitignore filtering', () => {
124
+ it('matches ignored directories from .gitignore rules', () => {
125
+ const rules = parseGitIgnoreRules(`
126
+ node_modules/
127
+ /dist/
128
+ logs*
129
+ !logs-keep/
130
+ `);
131
+ expect(isIgnoredByGitIgnore('node_modules', true, rules)).toBe(true);
132
+ expect(isIgnoredByGitIgnore('packages/app/node_modules', true, rules)).toBe(true);
133
+ expect(isIgnoredByGitIgnore('dist', true, rules)).toBe(true);
134
+ expect(isIgnoredByGitIgnore('packages/app/dist', true, rules)).toBe(false);
135
+ expect(isIgnoredByGitIgnore('logs-temp', true, rules)).toBe(true);
136
+ expect(isIgnoredByGitIgnore('logs-keep', true, rules)).toBe(false);
137
+ });
138
+ it('filters actual directory tree entries using root .gitignore', async () => {
139
+ const root = await mkdtemp(path.join(tmpdir(), 'aws-tree-ignore-'));
140
+ try {
141
+ await mkdir(path.join(root, 'src'));
142
+ await mkdir(path.join(root, 'node_modules'));
143
+ await mkdir(path.join(root, 'dist'));
144
+ await writeFile(path.join(root, '.gitignore'), 'node_modules/\n/dist/\n');
145
+ const result = await listHostDirectoryTree(root);
146
+ const names = result.tree.map((item) => item.name);
147
+ expect(names).toContain('src');
148
+ expect(names).not.toContain('node_modules');
149
+ expect(names).not.toContain('dist');
150
+ }
151
+ finally {
152
+ await rm(root, { recursive: true, force: true });
153
+ }
154
+ });
155
+ });
@@ -11,5 +11,25 @@ type GitDiffFileStatus = 'modified' | 'added' | 'deleted' | 'renamed';
11
11
  * 普通 modified 且文本增删均为 0 通常是 filemode/元数据噪声,避免显示为“无差异内容”。
12
12
  */
13
13
  export declare function shouldIncludeGitDiffSummaryFile(status: GitDiffFileStatus, additions: number, deletions: number, isBinaryDiff: boolean): boolean;
14
+ /**
15
+ * 判断 git stash 是否因仓库尚无初始提交而失败。
16
+ * 主流程:兼容 Git 不同版本输出,将底层英文错误归一成可面向用户的诊断。
17
+ */
18
+ export declare function isGitStashMissingInitialCommitError(output: string): boolean;
19
+ export declare function createMissingInitialCommitSnapshotError(): string;
20
+ /**
21
+ * 判断 git stash push 是否明确表示没有本地变更。
22
+ * 主流程:兼容不同 Git 版本把 no-change 信息输出到 stdout 或 stderr,且可能返回 0 或非 0。
23
+ */
24
+ export declare function isGitStashNoChangesOutput(output: string): boolean;
25
+ interface LatestGitStash {
26
+ ref: string;
27
+ message: string;
28
+ }
29
+ /**
30
+ * 从 git stash list -n 1 输出解析最近一次 stash。
31
+ * 主流程:提取 stash 引用并保留后续描述,供无变更快照复用最近快照引用。
32
+ */
33
+ export declare function parseLatestGitStash(output: string): LatestGitStash | null;
14
34
  export {};
15
35
  //# sourceMappingURL=git.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"git.d.ts","sourceRoot":"","sources":["../../src/routes/git.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAQH,eAAO,MAAM,SAAS,4CAAW,CAAC;AAWlC,wBAAsB,gCAAgC,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAe3F;AASD,KAAK,iBAAiB,GAAG,UAAU,GAAG,OAAO,GAAG,SAAS,GAAG,SAAS,CAAC;AAEtE;;;GAGG;AACH,wBAAgB,+BAA+B,CAC7C,MAAM,EAAE,iBAAiB,EACzB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,OAAO,GACpB,OAAO,CAMT"}
1
+ {"version":3,"file":"git.d.ts","sourceRoot":"","sources":["../../src/routes/git.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAQH,eAAO,MAAM,SAAS,4CAAW,CAAC;AAWlC,wBAAsB,gCAAgC,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAe3F;AASD,KAAK,iBAAiB,GAAG,UAAU,GAAG,OAAO,GAAG,SAAS,GAAG,SAAS,CAAC;AAEtE;;;GAGG;AACH,wBAAgB,+BAA+B,CAC7C,MAAM,EAAE,iBAAiB,EACzB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,OAAO,GACpB,OAAO,CAMT;AAED;;;GAGG;AACH,wBAAgB,mCAAmC,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAK3E;AAED,wBAAgB,uCAAuC,IAAI,MAAM,CAEhE;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAKjE;AAED,UAAU,cAAc;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI,CAWzE"}
@@ -35,6 +35,45 @@ export function shouldIncludeGitDiffSummaryFile(status, additions, deletions, is
35
35
  }
36
36
  return isBinaryDiff || additions > 0 || deletions > 0;
37
37
  }
38
+ /**
39
+ * 判断 git stash 是否因仓库尚无初始提交而失败。
40
+ * 主流程:兼容 Git 不同版本输出,将底层英文错误归一成可面向用户的诊断。
41
+ */
42
+ export function isGitStashMissingInitialCommitError(output) {
43
+ const normalized = String(output || '').toLowerCase();
44
+ return normalized.includes('you do not have the initial commit yet')
45
+ || (normalized.includes('bad revision') && normalized.includes('head'))
46
+ || (normalized.includes('ambiguous argument') && normalized.includes('head'));
47
+ }
48
+ export function createMissingInitialCommitSnapshotError() {
49
+ return '当前 Git 仓库还没有初始提交,无法创建快照。请先提交一次初始提交后再创建快照。';
50
+ }
51
+ /**
52
+ * 判断 git stash push 是否明确表示没有本地变更。
53
+ * 主流程:兼容不同 Git 版本把 no-change 信息输出到 stdout 或 stderr,且可能返回 0 或非 0。
54
+ */
55
+ export function isGitStashNoChangesOutput(output) {
56
+ const normalized = String(output || '').toLowerCase();
57
+ return normalized.includes('no local changes to save')
58
+ || normalized.includes('no changes to save')
59
+ || normalized.includes('没有本地变更需要保存');
60
+ }
61
+ /**
62
+ * 从 git stash list -n 1 输出解析最近一次 stash。
63
+ * 主流程:提取 stash 引用并保留后续描述,供无变更快照复用最近快照引用。
64
+ */
65
+ export function parseLatestGitStash(output) {
66
+ const firstLine = String(output || '').split('\n').find(line => line.trim());
67
+ if (!firstLine)
68
+ return null;
69
+ const match = firstLine.match(/^(stash@\{\d+\})(?::\s*)?(.*)$/);
70
+ if (!match)
71
+ return null;
72
+ return {
73
+ ref: match[1],
74
+ message: match[2]?.trim() || match[1],
75
+ };
76
+ }
38
77
  /**
39
78
  * 执行 git 命令并返回输出
40
79
  */
@@ -209,15 +248,24 @@ gitRouter.post('/git/stash/create', validateToken, async (req, res) => {
209
248
  ? `快照: ${String(message).trim()}`
210
249
  : defaultMessage;
211
250
  const result = await execGitCommand(normalizedPath, ['stash', 'push', '-m', stashMessage]);
251
+ if (isGitStashNoChangesOutput(`${result.stdout}\n${result.stderr}`)) {
252
+ const latestResult = await execGitCommand(normalizedPath, ['stash', 'list', '-n', '1']);
253
+ const latestStash = latestResult.exitCode === 0 ? parseLatestGitStash(latestResult.stdout) : null;
254
+ res.json({
255
+ ok: true,
256
+ stashRef: latestStash?.ref ?? null,
257
+ message: latestStash
258
+ ? `没有本地变更需要保存,已复用最近快照 ${latestStash.ref}`
259
+ : '没有本地变更需要保存',
260
+ noChanges: true,
261
+ reusedLatestStash: Boolean(latestStash),
262
+ workspacePath: normalizedPath
263
+ });
264
+ return;
265
+ }
212
266
  if (result.exitCode !== 0) {
213
- if (result.stderr.includes('No local changes to save')) {
214
- res.json({
215
- ok: true,
216
- stashRef: null,
217
- message: '没有本地变更需要保存',
218
- noChanges: true,
219
- workspacePath: normalizedPath
220
- });
267
+ if (isGitStashMissingInitialCommitError(`${result.stdout}\n${result.stderr}`)) {
268
+ res.status(400).json({ error: createMissingInitialCommitSnapshotError() });
221
269
  return;
222
270
  }
223
271
  res.status(400).json({ error: result.stderr || 'git stash push failed' });
@@ -292,10 +340,10 @@ gitRouter.post('/git/stash/list', validateToken, async (req, res) => {
292
340
  }
293
341
  });
294
342
  /**
295
- * 恢复 git stash 快照
296
- * POST /runtime/git/stash/pop
343
+ * 应用或弹出 git stash 快照。
344
+ * 主流程:恢复前先清理工作区未提交变更,再执行 apply/pop,并统一处理冲突和引用不存在错误。
297
345
  */
298
- gitRouter.post('/git/stash/pop', validateToken, async (req, res) => {
346
+ async function restoreGitStash(req, res, mode) {
299
347
  const { workspacePath, ref } = req.body || {};
300
348
  if (!workspacePath || !String(workspacePath).trim()) {
301
349
  res.status(400).json({ error: 'workspacePath is required' });
@@ -305,7 +353,7 @@ gitRouter.post('/git/stash/pop', validateToken, async (req, res) => {
305
353
  const normalizedPath = path.resolve(String(workspacePath).trim());
306
354
  const stashRef = ref && String(ref).trim() ? String(ref).trim() : 'stash@{0}';
307
355
  await execGitCommand(normalizedPath, ['checkout', '--', '.']);
308
- const result = await execGitCommand(normalizedPath, ['stash', 'pop', stashRef]);
356
+ const result = await execGitCommand(normalizedPath, ['stash', mode, stashRef]);
309
357
  if (result.exitCode !== 0) {
310
358
  if (result.stdout.includes('CONFLICT') || result.stderr.includes('CONFLICT')) {
311
359
  res.json({
@@ -321,20 +369,35 @@ gitRouter.post('/git/stash/pop', validateToken, async (req, res) => {
321
369
  res.status(404).json({ error: `快照 ${stashRef} 不存在` });
322
370
  return;
323
371
  }
324
- res.status(400).json({ error: result.stderr || 'git stash pop failed' });
372
+ res.status(400).json({ error: result.stderr || `git stash ${mode} failed` });
325
373
  return;
326
374
  }
327
375
  res.json({
328
376
  ok: true,
329
377
  stashRef,
330
- message: `已恢复 ${stashRef}`,
378
+ message: mode === 'apply' ? `已应用 ${stashRef}` : `已恢复 ${stashRef}`,
379
+ preservedStash: mode === 'apply',
331
380
  workspacePath: normalizedPath
332
381
  });
333
382
  }
334
383
  catch (error) {
335
384
  const err = error;
336
- res.status(500).json({ error: err.message || 'pop stash failed' });
385
+ res.status(500).json({ error: err.message || `${mode} stash failed` });
337
386
  }
387
+ }
388
+ /**
389
+ * 应用 git stash 快照但不删除快照
390
+ * POST /runtime/git/stash/apply
391
+ */
392
+ gitRouter.post('/git/stash/apply', validateToken, async (req, res) => {
393
+ await restoreGitStash(req, res, 'apply');
394
+ });
395
+ /**
396
+ * 恢复 git stash 快照并删除快照
397
+ * POST /runtime/git/stash/pop
398
+ */
399
+ gitRouter.post('/git/stash/pop', validateToken, async (req, res) => {
400
+ await restoreGitStash(req, res, 'pop');
338
401
  });
339
402
  /**
340
403
  * 删除指定的 git stash
@@ -5,7 +5,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
5
5
  import { tmpdir } from 'node:os';
6
6
  import path from 'node:path';
7
7
  import { describe, expect, it } from 'vitest';
8
- import { assertGitWorkspacePathAccessible, shouldIncludeGitDiffSummaryFile } from './git.js';
8
+ import { assertGitWorkspacePathAccessible, createMissingInitialCommitSnapshotError, isGitStashNoChangesOutput, isGitStashMissingInitialCommitError, parseLatestGitStash, shouldIncludeGitDiffSummaryFile, } from './git.js';
9
9
  describe('git stash operations', () => {
10
10
  function parseStashList(output) {
11
11
  const stashes = [];
@@ -53,6 +53,17 @@ describe('git stash operations', () => {
53
53
  it('returns empty array for empty output', () => {
54
54
  expect(parseStashList('')).toEqual([]);
55
55
  });
56
+ it('parses latest stash for no-change snapshot reuse', () => {
57
+ const result = parseLatestGitStash('stash@{0}: On master: 快照: 快照: t1\n');
58
+ expect(result).toEqual({
59
+ ref: 'stash@{0}',
60
+ message: 'On master: 快照: 快照: t1',
61
+ });
62
+ });
63
+ it('returns null when there is no latest stash to reuse', () => {
64
+ expect(parseLatestGitStash('')).toBeNull();
65
+ expect(parseLatestGitStash('not a stash line')).toBeNull();
66
+ });
56
67
  });
57
68
  describe('git command validation', () => {
58
69
  it('validates workspacePath requirement', () => {
@@ -115,15 +126,28 @@ describe('git diff summary visibility', () => {
115
126
  });
116
127
  describe('error handling', () => {
117
128
  it('detects "no local changes" error', () => {
118
- const isNoChangesError = (stderr) => stderr.includes('No local changes to save');
119
- expect(isNoChangesError('error: No local changes to save')).toBe(true);
120
- expect(isNoChangesError('other error')).toBe(false);
129
+ expect(isGitStashNoChangesOutput('No local changes to save')).toBe(true);
130
+ expect(isGitStashNoChangesOutput('stdout: No changes to save')).toBe(true);
131
+ expect(isGitStashNoChangesOutput('没有本地变更需要保存')).toBe(true);
132
+ expect(isGitStashNoChangesOutput('other error')).toBe(false);
133
+ });
134
+ it('detects stash failure before initial commit and returns an actionable message', () => {
135
+ expect(isGitStashMissingInitialCommitError('You do not have the initial commit yet\n')).toBe(true);
136
+ expect(isGitStashMissingInitialCommitError("fatal: bad revision 'HEAD'\n")).toBe(true);
137
+ expect(isGitStashMissingInitialCommitError("fatal: ambiguous argument 'HEAD': unknown revision\n")).toBe(true);
138
+ expect(isGitStashMissingInitialCommitError('No local changes to save')).toBe(false);
139
+ expect(createMissingInitialCommitSnapshotError()).toContain('请先提交一次初始提交');
121
140
  });
122
141
  it('detects conflict during pop', () => {
123
142
  const hasConflict = (stdout, stderr) => stdout.includes('CONFLICT') || stderr.includes('CONFLICT');
124
143
  expect(hasConflict('CONFLICT (content): Merge conflict', '')).toBe(true);
125
144
  expect(hasConflict('clean merge', '')).toBe(false);
126
145
  });
146
+ it('uses apply as the non-consuming restore command', () => {
147
+ const buildRestoreCommand = (mode, ref) => ['stash', mode, ref];
148
+ expect(buildRestoreCommand('apply', 'stash@{0}')).toEqual(['stash', 'apply', 'stash@{0}']);
149
+ expect(buildRestoreCommand('pop', 'stash@{0}')).toEqual(['stash', 'pop', 'stash@{0}']);
150
+ });
127
151
  it('detects invalid stash reference', () => {
128
152
  const isInvalidRef = (stderr) => stderr.includes('is not a valid reference');
129
153
  expect(isInvalidRef('stash@{99} is not a valid reference')).toBe(true);
@@ -17,14 +17,14 @@ describe('properties route validation', () => {
17
17
  describe('properties file operations', () => {
18
18
  it('resolves relative propertiesPath', () => {
19
19
  const resolvePath = (workspacePath, propertiesPath) => {
20
- const defaultPath = 'vibe-coding/my.properties';
20
+ const defaultPath = '.agentswork/my.properties';
21
21
  const resolved = propertiesPath || defaultPath;
22
22
  if (workspacePath && !resolved.startsWith('/'))
23
23
  return `${workspacePath}/${resolved}`;
24
24
  return resolved;
25
25
  };
26
26
  expect(resolvePath('/project', 'config/app.properties')).toBe('/project/config/app.properties');
27
- expect(resolvePath('/project')).toBe('/project/vibe-coding/my.properties');
27
+ expect(resolvePath('/project')).toBe('/project/.agentswork/my.properties');
28
28
  });
29
29
  it('parses properties content', () => {
30
30
  const parseProperties = (content) => {
@@ -19,8 +19,8 @@ export const ymlRouter = Router();
19
19
  ymlRouter.post('/read-yml', validateToken, async (req, res) => {
20
20
  const { workspacePath, ymlPath } = req.body || {};
21
21
  try {
22
- const { normalizedWorkspace, resolvedTarget } = resolveWorkspaceFilePath(workspacePath, ymlPath, 'vibe-coding/my.yml');
23
- const providedPathStr = ymlPath ? String(ymlPath).trim() || 'vibe-coding/my.yml' : 'vibe-coding/my.yml';
22
+ const { normalizedWorkspace, resolvedTarget } = resolveWorkspaceFilePath(workspacePath, ymlPath, '.agentswork/my.yml');
23
+ const providedPathStr = ymlPath ? String(ymlPath).trim() || '.agentswork/my.yml' : '.agentswork/my.yml';
24
24
  if (path.isAbsolute(providedPathStr)) {
25
25
  let content = '';
26
26
  let exists = true;
@@ -117,8 +117,8 @@ ymlRouter.post('/read-yml', validateToken, async (req, res) => {
117
117
  ymlRouter.post('/load-yml', validateToken, async (req, res) => {
118
118
  const { workspacePath, ymlPath } = req.body || {};
119
119
  try {
120
- const { normalizedWorkspace, resolvedTarget } = resolveWorkspaceFilePath(workspacePath, ymlPath, 'vibe-coding/my.yml');
121
- const providedPathStr = ymlPath ? String(ymlPath).trim() || 'vibe-coding/my.yml' : 'vibe-coding/my.yml';
120
+ const { normalizedWorkspace, resolvedTarget } = resolveWorkspaceFilePath(workspacePath, ymlPath, '.agentswork/my.yml');
121
+ const providedPathStr = ymlPath ? String(ymlPath).trim() || '.agentswork/my.yml' : '.agentswork/my.yml';
122
122
  if (path.isAbsolute(providedPathStr)) {
123
123
  let content = null;
124
124
  let exists = true;
@@ -172,8 +172,8 @@ ymlRouter.post('/write-yml', validateToken, async (req, res) => {
172
172
  return;
173
173
  }
174
174
  try {
175
- const { normalizedWorkspace, resolvedTarget: absoluteOrDefaultTarget } = resolveWorkspaceFilePath(workspacePath, ymlPath, 'vibe-coding/my.yml');
176
- const providedPathStr = ymlPath ? String(ymlPath).trim() || 'vibe-coding/my.yml' : 'vibe-coding/my.yml';
175
+ const { normalizedWorkspace, resolvedTarget: absoluteOrDefaultTarget } = resolveWorkspaceFilePath(workspacePath, ymlPath, '.agentswork/my.yml');
176
+ const providedPathStr = ymlPath ? String(ymlPath).trim() || '.agentswork/my.yml' : '.agentswork/my.yml';
177
177
  if (path.isAbsolute(providedPathStr)) {
178
178
  const resolvedTarget = absoluteOrDefaultTarget;
179
179
  const configFileDir = path.dirname(resolvedTarget);
@@ -27,9 +27,9 @@ describe('yml route validation', () => {
27
27
  });
28
28
  it('uses default ymlPath when not provided', () => {
29
29
  const resolveYmlPath = (ymlPath) => {
30
- return ymlPath ? String(ymlPath).trim() || 'vibe-coding/my.yml' : 'vibe-coding/my.yml';
30
+ return ymlPath ? String(ymlPath).trim() || '.agentswork/my.yml' : '.agentswork/my.yml';
31
31
  };
32
- expect(resolveYmlPath()).toBe('vibe-coding/my.yml');
32
+ expect(resolveYmlPath()).toBe('.agentswork/my.yml');
33
33
  expect(resolveYmlPath('custom/path.yml')).toBe('custom/path.yml');
34
34
  });
35
35
  });
@@ -38,7 +38,7 @@ describe('response building', () => {
38
38
  const response = {
39
39
  ok: true,
40
40
  workspacePath: '/project/workspace',
41
- filePath: '/project/vibe-coding/my.yml',
41
+ filePath: '/project/.agentswork/my.yml',
42
42
  exists: true,
43
43
  content: 'agent:\n - name: test',
44
44
  };
@@ -40,5 +40,5 @@ export function resolveWorkspaceFilePath(workspacePath, relativeOrAbsolutePath,
40
40
  * @returns 解析后的路径结果
41
41
  */
42
42
  export function resolvePropertiesTargetPath(workspacePath, relativeOrAbsolutePath) {
43
- return resolveWorkspaceFilePath(workspacePath, relativeOrAbsolutePath, 'vibe-coding/my.properties');
43
+ return resolveWorkspaceFilePath(workspacePath, relativeOrAbsolutePath, '.agentswork/my.properties');
44
44
  }
@@ -63,10 +63,10 @@ export declare function autoFillPathInYml(ymlContent: string, workspaceRelativeP
63
63
  * 3. 找到匹配的配置 → 使用该配置
64
64
  * 4. 所有层级都没有匹配的配置 → 使用最后一个找到的配置(或在工作目录创建)
65
65
  *
66
- * 例如:工作目录 D:\a\b\c\d,相对路径 vibe-coding/my.yml
67
- * 第1次查找:D:\a\b\c\d\vibe-coding\my.yml,匹配 path="."
68
- * 第2次查找:D:\a\b\c\vibe-coding\my.yml,匹配 path="d"
69
- * 第3次查找:D:\a\b\vibe-coding\my.yml,匹配 path="c/d"
66
+ * 例如:工作目录 D:\a\b\c\d,相对路径 .agentswork/my.yml
67
+ * 第1次查找:D:\a\b\c\d\.agentswork\my.yml,匹配 path="."
68
+ * 第2次查找:D:\a\b\c\.agentswork\my.yml,匹配 path="d"
69
+ * 第3次查找:D:\a\b\.agentswork\my.yml,匹配 path="c/d"
70
70
  * ...
71
71
  */
72
72
  export declare function loadYmlWithUpwardSearch(workspacePath: string, relativePath: string): Promise<YmlLoadResult>;
@@ -224,10 +224,10 @@ export function autoFillPathInYml(ymlContent, workspaceRelativePath) {
224
224
  * 3. 找到匹配的配置 → 使用该配置
225
225
  * 4. 所有层级都没有匹配的配置 → 使用最后一个找到的配置(或在工作目录创建)
226
226
  *
227
- * 例如:工作目录 D:\a\b\c\d,相对路径 vibe-coding/my.yml
228
- * 第1次查找:D:\a\b\c\d\vibe-coding\my.yml,匹配 path="."
229
- * 第2次查找:D:\a\b\c\vibe-coding\my.yml,匹配 path="d"
230
- * 第3次查找:D:\a\b\vibe-coding\my.yml,匹配 path="c/d"
227
+ * 例如:工作目录 D:\a\b\c\d,相对路径 .agentswork/my.yml
228
+ * 第1次查找:D:\a\b\c\d\.agentswork\my.yml,匹配 path="."
229
+ * 第2次查找:D:\a\b\c\.agentswork\my.yml,匹配 path="d"
230
+ * 第3次查找:D:\a\b\.agentswork\my.yml,匹配 path="c/d"
231
231
  * ...
232
232
  */
233
233
  export async function loadYmlWithUpwardSearch(workspacePath, relativePath) {
@@ -272,7 +272,7 @@ agent:
272
272
  describe('loadYmlWithUpwardSearch', () => {
273
273
  const tempRoot = path.join(os.tmpdir(), `aws-yaml-test-${Date.now()}`);
274
274
  const workspacePath = path.join(tempRoot, 'project', 'module', 'src');
275
- const relativePath = 'vibe-coding/my.yml';
275
+ const relativePath = '.agentswork/my.yml';
276
276
  beforeEach(async () => {
277
277
  // 创建目录结构
278
278
  await fs.mkdir(workspacePath, { recursive: true });
@@ -32,15 +32,10 @@ export declare class McpServer {
32
32
  private static readonly MEMORY_TYPES;
33
33
  private static readonly MEMORY_TOOL_NAMES;
34
34
  /**
35
- * 解析本地记忆文件路径。
36
- * 主流程:显式配置优先 -> 长期记忆按角色分目录 -> task_list/recently_memory Agent 实例分目录。
35
+ * 解析记忆存储路径。
36
+ * 主流程:task_list/recently_memory server 数据库;长期 memory 默认写入项目 .agentswork/memory/memory/<domain>.json。
37
37
  */
38
38
  private static resolveMemoryStorePaths;
39
- /**
40
- * 生成可用于目录名的作用域片段。
41
- * 主流程:裁剪输入 -> 替换路径危险字符 -> 空值落到明确 fallback。
42
- */
43
- private static toSafePathSegment;
44
39
  private getRequiredString;
45
40
  private getOptionalString;
46
41
  private getOptionalNumber;