dsh-easygit-plugin 0.2.3 → 0.3.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/lib/index.js CHANGED
@@ -424,6 +424,14 @@ async function dispatchRepositoryAction(action, sessionId, body, context, depend
424
424
  if (!context) return { ok: false, code: "SESSION_NOT_FOUND", message: "\u65E0\u6CD5\u786E\u5B9A\u5F53\u524D\u4F1A\u8BDD\u7684\u4ED3\u5E93\u76EE\u5F55" };
425
425
  const repository = dependencies.repository;
426
426
  const base = { sessionId, workdir: context.workdir, operationId: body.operationId, sandboxPolicy: context.policy };
427
+ if (action === "get-commit-edit-state") return repository.conflictAction(action, context.workdir, body, void 0, context.policy);
428
+ if (action === "amend-message" || action === "amend-commit" || action === "undo-commit" || action === "revert-commit") {
429
+ return repository.conflictAction(action, context.workdir, body, base);
430
+ }
431
+ if (action === "get-conflicts" || action === "get-conflict" || action === "get-merge-preview") return repository.conflictAction(action, context.workdir, body, void 0, context.policy);
432
+ if (action === "save-conflict" || action === "resolve-conflict" || action === "start-operation" || action === "finish-operation" || action === "merge-branch") {
433
+ return repository.conflictAction(action, context.workdir, body, base);
434
+ }
427
435
  if (action === "get-summary") return repository.getSummary(context.workdir, void 0, context.policy);
428
436
  if (action === "get-diff") return repository.getDiff(context.workdir, body.path, body.staged === true, void 0, context.policy);
429
437
  if (action === "get-branches") return repository.getBranches(context.workdir, void 0, context.policy);
@@ -431,6 +439,12 @@ async function dispatchRepositoryAction(action, sessionId, body, context, depend
431
439
  if (action === "get-commit-detail") return repository.getCommitDetail(context.workdir, body.hash, void 0, context.policy);
432
440
  if (action === "get-commit-diff") return repository.getCommitDiff(context.workdir, body.hash, void 0, context.policy);
433
441
  if (action === "get-stashes") return repository.getStashes(context.workdir, void 0, context.policy);
442
+ if (action === "get-stash-detail") return repository.getStashDetail(context.workdir, body.selector, body.hash, void 0, context.policy);
443
+ if (action === "get-stash-diff") return repository.getStashDiff(context.workdir, body.selector, body.hash, body.path, body.untracked === true, void 0, context.policy);
444
+ if (action === "create-stash") return repository.createStash(base, body.message, body.paths, body.includeUntracked === true);
445
+ if (action === "apply-stash" || action === "pop-stash" || action === "drop-stash" || action === "branch-stash") {
446
+ return repository.mutateStash(base, action, body.selector, body.hash, body.name, body.confirmRisk === true);
447
+ }
434
448
  if (action === "get-sync-state") return repository.getSyncState(context.workdir, void 0, context.policy);
435
449
  if (action === "stage-paths") return repository.stagePaths(base, body.paths);
436
450
  if (action === "unstage-paths") return repository.unstagePaths(base, body.paths);
@@ -517,7 +531,7 @@ async function dispatchProposalAction(action, sessionId, body, dependencies) {
517
531
  const result = await dependencies.executeProposal(
518
532
  dependencies.shell,
519
533
  proposal,
520
- dependencies.resolveExecutionPolicy(sessionId),
534
+ await dependencies.resolveExecutionPolicy(sessionId),
521
535
  () => dependencies.flushProposal(sessionId)
522
536
  );
523
537
  console.log("easygit HTTP execute", proposal.proposalId, "ok=", result.ok);
@@ -525,12 +539,21 @@ async function dispatchProposalAction(action, sessionId, body, dependencies) {
525
539
  }
526
540
  return { status: 400, data: { ok: false, error: "unknown action: " + action } };
527
541
  }
528
- function registerEasyGitActions(webServer, dependencies) {
542
+ function registerEasyGitActions(webServer, dependencies, connection) {
529
543
  if (!webServer) return void 0;
530
544
  return webServer.register({
531
545
  kind: "prefix",
532
546
  path: "/easygit",
533
547
  handler: async (req, res) => {
548
+ if (!connection) {
549
+ sendJson(res, 503, { ok: false, error: "connection service unavailable" });
550
+ return;
551
+ }
552
+ const rejection = connection.requestRejection(req);
553
+ if (rejection !== void 0) {
554
+ sendJson(res, rejection, { ok: false, error: rejection === 401 ? "unauthorized" : "forbidden" });
555
+ return;
556
+ }
534
557
  if (req.method !== "POST") {
535
558
  sendJson(res, 405, { ok: false, error: "method not allowed" });
536
559
  return;
@@ -565,7 +588,7 @@ function registerEasyGitActions(webServer, dependencies) {
565
588
  try {
566
589
  await dependencies.proposalStorageReady;
567
590
  if (isRepositoryAction(action)) {
568
- const context = dependencies.repositoryContext(sessionId);
591
+ const context = await dependencies.repositoryContext(sessionId);
569
592
  const result = await dispatchRepositoryAction(action, sessionId, body, context, dependencies);
570
593
  sendJson(res, 200, await attachRepositoryRecovery(action, sessionId, body, result, context, dependencies));
571
594
  return;
@@ -585,6 +608,19 @@ var init_actions = __esm({
585
608
  "use strict";
586
609
  init_command_policy();
587
610
  REPOSITORY_ACTIONS = [
611
+ "get-commit-edit-state",
612
+ "amend-message",
613
+ "amend-commit",
614
+ "undo-commit",
615
+ "revert-commit",
616
+ "get-merge-preview",
617
+ "merge-branch",
618
+ "get-conflicts",
619
+ "get-conflict",
620
+ "save-conflict",
621
+ "resolve-conflict",
622
+ "start-operation",
623
+ "finish-operation",
588
624
  "get-summary",
589
625
  "get-diff",
590
626
  "get-branches",
@@ -592,6 +628,13 @@ var init_actions = __esm({
592
628
  "get-commit-detail",
593
629
  "get-commit-diff",
594
630
  "get-stashes",
631
+ "get-stash-detail",
632
+ "get-stash-diff",
633
+ "create-stash",
634
+ "apply-stash",
635
+ "pop-stash",
636
+ "drop-stash",
637
+ "branch-stash",
595
638
  "get-sync-state",
596
639
  "stage-paths",
597
640
  "unstage-paths",
@@ -772,7 +815,416 @@ var init_proposal_service = __esm({
772
815
  }
773
816
  });
774
817
 
818
+ // src/host/conflict-worker.ts
819
+ function conflictWorkerCommand(action, payload) {
820
+ const data = { ...payload };
821
+ if (typeof data.content === "string") {
822
+ data.contentBase64 = Buffer.from(data.content).toString("base64");
823
+ delete data.content;
824
+ }
825
+ return quoteShellArg(process.execPath) + " -e " + quoteShellArg(worker) + " " + quoteShellArg(Buffer.from(JSON.stringify({ ...data, action })).toString("base64"));
826
+ }
827
+ var worker;
828
+ var init_conflict_worker = __esm({
829
+ "src/host/conflict-worker.ts"() {
830
+ "use strict";
831
+ init_command_policy();
832
+ worker = String.raw`
833
+ const fs = require('node:fs');
834
+ const path = require('node:path');
835
+ const crypto = require('node:crypto');
836
+ const { execFileSync } = require('node:child_process');
837
+ const input = JSON.parse(Buffer.from(process.argv[1], 'base64').toString('utf8'));
838
+ const LIMIT = 48 * 1024;
839
+ const fail = (message, code = 'STATE_CONFLICT') => { throw Object.assign(new Error(message), { code }); };
840
+ const git = (...args) => execFileSync('git', args, { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_EDITOR: 'true', GIT_SEQUENCE_EDITOR: 'true' } });
841
+ const hash = value => crypto.createHash('sha256').update(value).digest('hex');
842
+ const exists = file => fs.existsSync(file);
843
+ const gitPath = name => git('rev-parse', '--git-path', name).trim();
844
+ function squashState() {
845
+ const file = gitPath('easygit-squash.json');
846
+ if (!exists(file) || !exists(gitPath('SQUASH_MSG'))) return null;
847
+ const saved = JSON.parse(fs.readFileSync(file, 'utf8'));
848
+ // A commit or branch switch outside the workbench ends our ownership.
849
+ if (saved.head !== git('rev-parse', 'HEAD').trim() || saved.branch !== 'refs/heads/' + git('branch', '--show-current').trim()) return null;
850
+ return saved;
851
+ }
852
+ function operationState() {
853
+ const dir = name => git('rev-parse', '--git-path', name).trim();
854
+ const names = ['rebase-merge', 'rebase-apply', 'MERGE_HEAD', 'CHERRY_PICK_HEAD', 'sequencer', 'REVERT_HEAD'];
855
+ const paths = names.map(dir);
856
+ const squash = squashState();
857
+ const todo = exists(path.join(paths[4], 'todo')) ? fs.readFileSync(path.join(paths[4], 'todo'), 'utf8') : '';
858
+ const operation = exists(paths[0]) || exists(paths[1]) ? 'rebase' : exists(paths[2]) ? 'merge' : exists(paths[3]) || /^pick /m.test(todo) ? 'cherry-pick' : exists(paths[5]) || /^revert /m.test(todo) ? 'revert' : squash ? 'merge' : null;
859
+ const meta = ['HEAD', 'MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'rebase-merge/head-name', 'rebase-merge/onto', 'rebase-merge/msgnum', 'rebase-merge/stopped-sha', 'rebase-apply/next', 'rebase-apply/orig-head', 'sequencer/todo'].map(name => {
860
+ const file = dir(name); return exists(file) ? fs.readFileSync(file).toString('base64') : '';
861
+ });
862
+ let head;
863
+ try { head = git('rev-parse', '--verify', 'HEAD').trim(); }
864
+ catch (error) {
865
+ const branch = git('symbolic-ref', '--quiet', 'HEAD').trim();
866
+ if (exists(dir(branch))) throw error;
867
+ head = 'unborn:' + branch;
868
+ }
869
+ return { operation, ...(squash && operation === 'merge' ? { mergeMode: 'squash' } : {}), operationToken: hash(JSON.stringify([operation, meta, head, squash])) };
870
+ }
871
+ function mergeSnapshot() {
872
+ if (typeof input.target !== 'string' || !/^refs\/(heads|remotes)\/.+/.test(input.target) || /[\0\r\n]/.test(input.target)) fail('请选择本地或远程源分支', 'INVALID_ARGUMENT');
873
+ git('check-ref-format', input.target);
874
+ const name = git('branch', '--show-current').trim();
875
+ if (!name) fail('分离 HEAD 状态不能合并分支,请先切换到本地分支');
876
+ const branch = 'refs/heads/' + name;
877
+ if (branch === input.target) fail('不能将当前分支合并到自身');
878
+ const head = git('rev-parse', '--verify', 'HEAD').trim();
879
+ const sourceHead = git('rev-parse', '--verify', '--end-of-options', input.target + '^{commit}').trim();
880
+ return { branch: branch.slice('refs/heads/'.length), target: input.target, head, sourceHead, token: hash(JSON.stringify([branch, input.target, head, sourceHead])) };
881
+ }
882
+ function ancestor(a, b) {
883
+ try { git('merge-base', '--is-ancestor', a, b); return true; }
884
+ catch (error) { if (error.status === 1) return false; throw error; }
885
+ }
886
+ function previewMerge() {
887
+ const snapshot = mergeSnapshot();
888
+ const { head, sourceHead } = snapshot;
889
+ let base;
890
+ try { base = git('merge-base', head, sourceHead).trim(); }
891
+ catch (error) { if (error.status === 1) fail('两个分支没有共同祖先,不支持合并无关历史'); throw error; }
892
+ const fields = git('log', '--no-color', '--max-count=201', '--format=%H%x00%s%x00%an%x00%aI%x00', head + '..' + sourceHead).split('\0');
893
+ const commits = [];
894
+ for (let i = 0; i + 3 < fields.length; i += 4) commits.push({ hash: fields[i].trim(), subject: fields[i + 1], author: fields[i + 2], date: fields[i + 3] });
895
+ const files = git('diff', '--name-only', '-z', base, sourceHead, '--').split('\0').filter(Boolean);
896
+ let diff, overflow = false;
897
+ try { diff = git('-c', 'core.quotePath=false', 'diff', '--no-ext-diff', '--no-textconv', '--no-color', base, sourceHead, '--'); }
898
+ catch (error) { if (error.code !== 'ENOBUFS') throw error; diff = String(error.stdout || ''); overflow = true; }
899
+ return { ...snapshot, base, canFastForward: ancestor(head, sourceHead), alreadyMerged: ancestor(sourceHead, head), commits: commits.slice(0, 200), commitsTruncated: commits.length > 200, files: files.slice(0, 500), filesTruncated: files.length > 500, diff: diff.slice(0, 180000), diffTruncated: overflow || diff.length > 180000 };
900
+ }
901
+ function entries() {
902
+ return git('ls-files', '--unmerged', '-z').split('\0').filter(Boolean).map(row => {
903
+ const match = /^(\d+) ([0-9a-f]+) ([123])\t([\s\S]*)$/.exec(row);
904
+ if (!match) fail('无法读取完整冲突索引');
905
+ return { mode: match[1], oid: match[2], stage: Number(match[3]), path: match[4] };
906
+ });
907
+ }
908
+ function state() {
909
+ const grouped = new Map();
910
+ for (const entry of entries()) {
911
+ if (!grouped.has(entry.path)) grouped.set(entry.path, []);
912
+ grouped.get(entry.path).push(entry.stage);
913
+ }
914
+ return { ...operationState(), files: [...grouped].map(([file, stages]) => ({ path: file, stages, kind: !stages.includes(1) ? '双方新增' : !stages.includes(2) || !stages.includes(3) ? '删除 / 修改' : '双方修改' })) };
915
+ }
916
+ function safePath(file) {
917
+ if (typeof file !== 'string' || !file || file.includes('\0') || path.isAbsolute(file) || file.split(/[\\/]/).some(part => part === '..' || part.toLowerCase() === '.git')) fail('无效的仓库相对路径', 'INVALID_ARGUMENT');
918
+ let current = process.cwd();
919
+ const parts = file.split('/');
920
+ for (let i = 0; i < parts.length; i++) {
921
+ current = path.join(current, parts[i]);
922
+ try {
923
+ const stat = fs.lstatSync(current);
924
+ if (stat.isSymbolicLink()) fail('符号链接冲突请在外部工具中处理');
925
+ if (i < parts.length - 1 && !stat.isDirectory()) fail('路径包含非目录节点');
926
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
927
+ }
928
+ return current;
929
+ }
930
+ function version(buffer, mode = null) {
931
+ if (buffer === null) return { exists: false, text: null, mode, reason: null };
932
+ const text = buffer.toString('utf8');
933
+ const reason = !['100644', '100755', null].includes(mode) ? '符号链接或子模块,请使用外部工具处理' : buffer.length > LIMIT ? '文件超过 48 KiB,请使用外部工具处理' : buffer.includes(0) || !Buffer.from(text).equals(buffer) ? '二进制或非 UTF-8 文件,可选择整份一方版本' : null;
934
+ return { exists: true, text: reason ? null : text, mode, reason };
935
+ }
936
+ function detail(file) {
937
+ const rows = entries().filter(row => row.path === file);
938
+ if (!rows.length) fail('该文件已不在冲突列表中,请刷新');
939
+ const full = safePath(file);
940
+ let working = null;
941
+ try {
942
+ const stat = fs.lstatSync(full);
943
+ if (!stat.isFile()) fail('目录、符号链接或子模块冲突请在外部工具中处理');
944
+ if (stat.size > 8 * 1024 * 1024) fail('文件过大,请使用外部工具处理');
945
+ working = fs.readFileSync(full);
946
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
947
+ const versions = [1, 2, 3].map(stage => {
948
+ const row = rows.find(row => row.stage === stage);
949
+ if (!row) return version(null);
950
+ if (!['100644', '100755'].includes(row.mode)) return { exists: true, text: null, mode: row.mode, reason: '符号链接或子模块,请使用外部工具处理' };
951
+ const size = Number(git('cat-file', '-s', row.oid).trim());
952
+ if (size > LIMIT) return { exists: true, text: null, mode: row.mode, reason: '文件超过 48 KiB,请使用外部工具处理' };
953
+ return version(execFileSync('git', ['cat-file', 'blob', row.oid], { maxBuffer: LIMIT + 1024 }), row.mode);
954
+ });
955
+ const op = operationState();
956
+ const squash = op.mergeMode === 'squash' ? squashState() : null;
957
+ for (const [index, ref] of [[1, 'HEAD'], [2, op.operation === 'merge' ? 'MERGE_HEAD' : op.operation === 'rebase' ? 'REBASE_HEAD' : op.operation === 'cherry-pick' ? 'CHERRY_PICK_HEAD' : null]]) {
958
+ const row = rows.find(row => row.stage === index + 1);
959
+ versions[index].source = row ? '索引 stage ' + (index + 1) + ' · blob ' + row.oid.slice(0, 12) : '该方无文件';
960
+ if (ref && op.operation) {
961
+ try { versions[index].source = ref + ' · ' + git('rev-parse', '--verify', ref + '^{commit}').trim().slice(0, 12); } catch {}
962
+ }
963
+ if (index === 2 && squash) versions[index].source = '压缩合并源 · ' + squash.sourceHead.slice(0, 12);
964
+ }
965
+ const result = version(working);
966
+ const attr = git('check-attr', '-z', 'conflict-marker-size', '--', file).split('\0')[2];
967
+ const markerSize = /^\d+$/.test(attr || '') ? Number(attr) : 7;
968
+ if (markerSize < 1 || markerSize > 1024) fail('冲突标记长度不受支持,请使用外部工具处理');
969
+ return { path: file, operation: op.operation, token: hash(JSON.stringify([rows, op.operationToken, working === null ? null : working.toString('base64')])), base: versions[0], ours: versions[1], theirs: versions[2], result, editable: versions.every(v => !v.reason) && !result.reason, special: !versions[1].exists || !versions[2].exists || versions.some(v => !!v.reason) || !!result.reason, markerSize };
970
+ }
971
+ function markers(text, size) {
972
+ return text.split(/\r?\n/).some(line => ['<', '|', '=', '>'].some(c =>
973
+ line.startsWith(c.repeat(size)) && (line.length === size || /^[ \t]/.test(line.slice(size)))
974
+ ));
975
+ }
976
+ function commitEditState() {
977
+ const head = git('rev-parse', '--verify', 'HEAD').trim();
978
+ const branch = git('branch', '--show-current').trim();
979
+ const op = operationState();
980
+ const index = git('ls-files', '--stage', '-z');
981
+ return {
982
+ head, branch, message: git('show', '-s', '--format=%B', head).replace(/\n$/, ''),
983
+ parents: git('show', '-s', '--format=%P', head).trim().split(' ').filter(Boolean),
984
+ staged: !!git('diff', '--cached', '--name-only', '-z'),
985
+ dirty: !!git('status', '--porcelain', '--untracked-files=all'),
986
+ blocked: !!op.operation || !!entries().length,
987
+ token: hash(JSON.stringify([head, branch, index, op.operationToken])),
988
+ };
989
+ }
990
+ function editCommit() {
991
+ if (input.confirmRisk !== true) fail('请先确认提交操作的影响', 'PERMISSION_DENIED');
992
+ const snapshot = commitEditState();
993
+ if (snapshot.blocked) fail('请先完成或中止当前 Git 操作并解决冲突');
994
+ if (!snapshot.branch) fail('请先切换到本地分支');
995
+ if (input.token !== snapshot.token) fail('分支、最近提交或暂存区已变化,请刷新后重新确认');
996
+ if (input.action === 'amend-message') {
997
+ if (typeof input.message !== 'string' || !input.message.trim() || Buffer.byteLength(input.message) > LIMIT || /[\0\r]/.test(input.message)) fail('提交说明必须是 48 KiB 以内的非空文本,不得包含 NUL 或回车', 'INVALID_ARGUMENT');
998
+ // --only without paths amends the message while preserving the real index.
999
+ git('commit', '--amend', '--only', '--allow-empty', '--cleanup=verbatim', '-m', input.message);
1000
+ } else if (input.action === 'amend-commit') {
1001
+ if (!snapshot.staged) fail('没有已暂存的改动,请先暂存需要补充的文件');
1002
+ git('commit', '--amend', '--no-edit', '--cleanup=verbatim');
1003
+ } else if (input.action === 'undo-commit') {
1004
+ if (!snapshot.parents.length) fail('根提交没有父提交,无法执行保留暂存区的撤销');
1005
+ git('reset', '--soft', snapshot.parents[0]);
1006
+ } else {
1007
+ if (snapshot.dirty) fail('Revert 前请先提交或贮藏工作区改动');
1008
+ if (typeof input.hash !== 'string' || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(input.hash)) fail('请选择完整的提交 SHA', 'INVALID_ARGUMENT');
1009
+ if (!ancestor(input.hash, snapshot.head)) fail('目标提交不在当前分支历史中');
1010
+ const parents = git('show', '-s', '--format=%P', input.hash).trim().split(' ').filter(Boolean);
1011
+ if (parents.length > 1) {
1012
+ if (!Number.isInteger(input.mainline) || input.mainline < 1 || input.mainline > parents.length) fail('Revert 合并提交前必须选择主线父提交', 'INVALID_ARGUMENT');
1013
+ } else if (input.mainline !== undefined) fail('普通提交无需选择主线父提交', 'INVALID_ARGUMENT');
1014
+ git('revert', '--no-edit', ...(parents.length > 1 ? ['-m', String(input.mainline)] : []), input.hash);
1015
+ }
1016
+ return state();
1017
+ }
1018
+ function main() {
1019
+ process.chdir(git('rev-parse', '--show-toplevel').trim());
1020
+ if (input.action === 'get-commit-edit-state') return commitEditState();
1021
+ if (['amend-message', 'amend-commit', 'undo-commit', 'revert-commit'].includes(input.action)) return editCommit();
1022
+ if (input.action === 'get-merge-preview') return previewMerge();
1023
+ if (input.action === 'merge-branch') {
1024
+ if (!['normal', 'ff-only', 'squash'].includes(input.mode)) fail('无效的合并方式', 'INVALID_ARGUMENT');
1025
+ if (state().operation || entries().length) fail('请先完成或中止当前 Git 操作');
1026
+ if (git('status', '--porcelain', '--untracked-files=all').trim()) fail('合并前请先提交或贮藏工作区改动');
1027
+ const snapshot = mergeSnapshot();
1028
+ if (snapshot.token !== input.token) fail('分支已变化,请重新预览后合并');
1029
+ if (ancestor(snapshot.sourceHead, snapshot.head)) return state();
1030
+ if (input.mode === 'squash') {
1031
+ fs.writeFileSync(gitPath('easygit-squash.json'), JSON.stringify({ head: snapshot.head, branch: 'refs/heads/' + snapshot.branch, sourceHead: snapshot.sourceHead }));
1032
+ }
1033
+ // Explicit flags keep user merge.ff / branch mergeOptions from changing the selected mode.
1034
+ try {
1035
+ git('-c', 'merge.autoStash=false', 'merge', '--no-edit', '--no-autostash', '--no-overwrite-ignore', ...(input.mode === 'squash' ? ['--squash', '--ff', '--no-commit'] : input.mode === 'ff-only' ? ['--ff-only', '--no-squash', '--commit'] : ['--ff', '--no-squash', '--commit']), snapshot.sourceHead);
1036
+ } catch (error) {
1037
+ if (input.mode === 'squash' && !entries().length && !git('status', '--porcelain').trim()) fs.unlinkSync(gitPath('easygit-squash.json'));
1038
+ throw error;
1039
+ }
1040
+ return state();
1041
+ }
1042
+ if (input.action === 'get-conflicts') return state();
1043
+ if (input.action === 'get-conflict') return detail(input.path);
1044
+ if (input.action === 'save-conflict' || input.action === 'resolve-conflict') {
1045
+ const before = detail(input.path);
1046
+ if (typeof input.token !== 'string' || input.token !== before.token) fail('文件或冲突状态已被外部修改,请刷新后重新处理');
1047
+ const full = safePath(input.path);
1048
+ if (input.action === 'save-conflict') {
1049
+ if (!before.editable) fail('该文件不支持文本编辑');
1050
+ const content = Buffer.from(input.contentBase64, 'base64');
1051
+ if (content.length > LIMIT || content.includes(0)) fail('结果必须是 48 KiB 以内的 UTF-8 文本', 'INVALID_ARGUMENT');
1052
+ const parent = path.dirname(full);
1053
+ fs.mkdirSync(parent, { recursive: true });
1054
+ const mode = exists(full) ? fs.statSync(full).mode : parseInt(before.ours.mode || before.theirs.mode || '100644', 8);
1055
+ const temp = path.join(parent, '.easygit-' + crypto.randomUUID());
1056
+ try {
1057
+ fs.writeFileSync(temp, content, { flag: 'wx', mode });
1058
+ if (detail(input.path).token !== before.token) fail('保存前文件发生变化,请刷新');
1059
+ fs.renameSync(temp, full);
1060
+ } finally { if (exists(temp)) fs.unlinkSync(temp); }
1061
+ return detail(input.path);
1062
+ }
1063
+ if (!['result', 'ours', 'theirs', 'delete'].includes(input.choice)) fail('无效的解决方式', 'INVALID_ARGUMENT');
1064
+ if (['ours', 'theirs'].includes(input.choice)) {
1065
+ const selected = before[input.choice];
1066
+ if (!selected.exists) fail('该方版本不存在,请明确选择删除文件');
1067
+ if (selected.reason && !selected.reason.startsWith('二进制')) fail(selected.reason);
1068
+ if (selected.text !== null && markers(selected.text, before.markerSize)) fail('所选版本仍包含冲突标记,请手动编辑');
1069
+ git('checkout', '--' + input.choice, '--', ':(literal)' + input.path);
1070
+ } else if (input.choice === 'delete') {
1071
+ if (exists(full)) fs.unlinkSync(full);
1072
+ } else {
1073
+ if (before.result.text === null) fail('请明确选择一方、删除文件或在外部工具中处理');
1074
+ if (markers(before.result.text, before.markerSize)) fail('结果中仍有冲突标记,请先逐块解决并保存');
1075
+ }
1076
+ git('add', '--', ':(literal)' + input.path);
1077
+ return state();
1078
+ }
1079
+ if (input.action === 'start-operation') {
1080
+ if (!['merge', 'rebase', 'cherry-pick'].includes(input.kind)) fail('不支持的 Git 操作', 'INVALID_ARGUMENT');
1081
+ if (input.confirmRisk !== true) fail('请先确认 Git 操作风险', 'PERMISSION_DENIED');
1082
+ if (state().operation || entries().length) fail('请先完成或中止当前 Git 操作');
1083
+ if (git('status', '--porcelain').trim()) fail('请先提交或贮藏工作区改动');
1084
+ if (typeof input.target !== 'string' || !input.target || input.target.startsWith('-') || /[\0\r\n]/.test(input.target)) fail('无效的目标引用', 'INVALID_ARGUMENT');
1085
+ const oid = git('rev-parse', '--verify', '--end-of-options', input.target + '^{commit}').trim();
1086
+ git(...(input.kind === 'merge' ? ['merge', '--no-edit', oid] : [input.kind, oid]));
1087
+ return state();
1088
+ }
1089
+ if (input.action === 'finish-operation') {
1090
+ const current = state();
1091
+ if (input.confirmRisk !== true) fail('请先确认继续、中止或跳过的风险', 'PERMISSION_DENIED');
1092
+ if (!current.operation || current.operation !== input.kind || current.operationToken !== input.token) fail('Git 操作状态已改变,请刷新');
1093
+ if (!['continue', 'abort', 'skip'].includes(input.mode) || (input.mode === 'skip' && input.kind === 'merge')) fail('不支持的后续操作', 'INVALID_ARGUMENT');
1094
+ if (input.mode === 'continue' && current.files.length) fail('仍有未标记解决的冲突文件');
1095
+ if (current.mergeMode === 'squash') {
1096
+ const saved = squashState();
1097
+ if (input.mode === 'abort') git('reset', '--merge', saved.head);
1098
+ else git('commit', '--no-edit', '-F', gitPath('SQUASH_MSG'));
1099
+ fs.unlinkSync(gitPath('easygit-squash.json'));
1100
+ if (exists(gitPath('SQUASH_MSG'))) fs.unlinkSync(gitPath('SQUASH_MSG'));
1101
+ } else git(input.kind, '--' + input.mode);
1102
+ return state();
1103
+ }
1104
+ fail('无效操作', 'INVALID_ARGUMENT');
1105
+ }
1106
+ try { console.log(JSON.stringify({ ok: true, data: main() })); }
1107
+ catch (error) {
1108
+ let pending = null;
1109
+ try { pending = state(); } catch {}
1110
+ // A new conflict is an expected stop in a multi-step Git operation.
1111
+ if ((input.action === 'start-operation' || input.action === 'merge-branch' || input.action === 'revert-commit' || (input.action === 'finish-operation' && input.mode !== 'abort')) && error.status && pending && pending.operation === (input.action === 'merge-branch' ? 'merge' : input.action === 'revert-commit' ? 'revert' : input.kind) && (pending.files.length || input.action === 'revert-commit')) {
1112
+ console.log(JSON.stringify({ ok: true, data: pending }));
1113
+ } else console.log(JSON.stringify({ ok: false, code: ['STATE_CONFLICT', 'INVALID_ARGUMENT', 'PERMISSION_DENIED'].includes(error.code) ? error.code : 'GIT_FAILED', message: error.status ? 'Git 操作未完成,请查看详情' : error.message, diagnostics: [error.stdout, error.stderr].filter(Boolean).map(value => value.toString()).join('\n') || undefined }));
1114
+ }
1115
+ `;
1116
+ }
1117
+ });
1118
+
1119
+ // src/host/stash-worker.ts
1120
+ function createStashCommand(message, paths, includeUntracked) {
1121
+ const payload = Buffer.from(JSON.stringify({ message, paths, includeUntracked })).toString("base64");
1122
+ return "node -e " + quoteShellArg(worker2) + " " + quoteShellArg(payload);
1123
+ }
1124
+ var worker2;
1125
+ var init_stash_worker = __esm({
1126
+ "src/host/stash-worker.ts"() {
1127
+ "use strict";
1128
+ init_command_policy();
1129
+ worker2 = String.raw`
1130
+ const fs = require('node:fs');
1131
+ const os = require('node:os');
1132
+ const path = require('node:path');
1133
+ const { execFileSync } = require('node:child_process');
1134
+ const input = JSON.parse(Buffer.from(process.argv[1], 'base64').toString('utf8'));
1135
+ const env = { ...process.env };
1136
+ const literal = names => names.map(name => ':(literal)' + name);
1137
+ const git = (args, extra = {}) => execFileSync('git', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'], env, ...extra });
1138
+ let temporary;
1139
+ try {
1140
+ process.chdir(git(['rev-parse', '--show-toplevel']).trim());
1141
+ const records = git(['status', '--porcelain=v1', '--untracked-files=all', '-z']).split('\0');
1142
+ const changes = [];
1143
+ for (let i = 0; i < records.length - 1; i++) {
1144
+ const row = records[i];
1145
+ const status = row.slice(0, 2);
1146
+ const file = { path: row.slice(3), status };
1147
+ if (/[RC]/.test(status)) file.originalPath = records[++i];
1148
+ if (status.includes('U') || status === 'AA' || status === 'DD') throw new Error('请先解决冲突,再创建贮藏');
1149
+ changes.push(file);
1150
+ }
1151
+ const eligible = changes.filter(file => input.includeUntracked || file.status !== '??');
1152
+ const selected = input.paths ? [...new Set(input.paths)].flatMap(name => {
1153
+ const files = eligible.filter(file => file.path === name);
1154
+ if (!files.length) throw new Error('所选文件已变化或未启用包含未跟踪文件,请刷新:' + name);
1155
+ return files;
1156
+ }) : eligible;
1157
+ if (!selected.length) throw new Error('没有可贮藏的改动');
1158
+ const args = ['stash', 'push'];
1159
+ if (input.includeUntracked) args.push('--include-untracked');
1160
+ if (input.message.trim()) args.push('--message', input.message.trim());
1161
+ if (!input.paths) {
1162
+ git(args);
1163
+ } else {
1164
+ // Native path-limited stash also captures unrelated staged files, and
1165
+ // staged deletions can fail during its cleanup. Build the standard stash
1166
+ // trees in a temporary index, store them, then restore only selected paths.
1167
+ const tracked = selected.filter(file => file.status !== '??');
1168
+ const untracked = selected.filter(file => file.status === '??');
1169
+ const paths = [...new Set(tracked.flatMap(file => file.originalPath && file.status.includes('R') ? [file.path, file.originalPath] : [file.path]))];
1170
+ if (paths.some(name => changes.some(file => file.path === name && file.status === '??') && !untracked.some(file => file.path === name))) {
1171
+ throw new Error('已删除或重命名的路径上存在未跟踪文件,请同时选择该文件并包含未跟踪文件');
1172
+ }
1173
+ temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'easygit-stash-'));
1174
+ const isolated = { ...env, GIT_INDEX_FILE: path.join(temporary, 'index') };
1175
+ const base = git(['rev-parse', 'HEAD']).trim();
1176
+ git(['read-tree', base], { env: isolated });
1177
+ const intentPaths = tracked.filter(file => file.status === ' A').map(file => file.path);
1178
+ if (paths.length) {
1179
+ // Copy object IDs/modes directly; a textual diff can be altered by user
1180
+ // color, prefix or whitespace settings. Missing entries represent deletes.
1181
+ const entries = git(['ls-files', '--stage', '-z', '--', ...literal(paths)]).split('\0')
1182
+ .filter(row => row && !intentPaths.includes(row.slice(row.indexOf('\t') + 1)));
1183
+ const removed = paths.map(name => '0 ' + '0'.repeat(base.length) + '\t' + name);
1184
+ git(['update-index', '-z', '--index-info'], { env: isolated, input: [...removed, ...entries].join('\0') + '\0' });
1185
+ }
1186
+ const indexTree = git(['write-tree'], { env: isolated }).trim();
1187
+ if (intentPaths.length) git(['add', '--', ...literal(intentPaths)], { env: isolated });
1188
+ // -u records tracked worktree edits/deletions without absorbing untracked files.
1189
+ const indexed = git(['ls-files', '-z'], { env: isolated }).split('\0');
1190
+ const workPaths = paths.filter(name => indexed.includes(name));
1191
+ if (workPaths.length) git(['add', '-u', '--', ...literal(workPaths)], { env: isolated });
1192
+ const workTree = git(['write-tree'], { env: isolated }).trim();
1193
+ const indexCommit = git(['commit-tree', indexTree, '-p', base], { input: 'index for selected stash\n' }).trim();
1194
+ const parents = ['-p', base, '-p', indexCommit];
1195
+ if (untracked.length) {
1196
+ git(['read-tree', '--empty'], { env: isolated });
1197
+ git(['add', '--', ...literal(untracked.map(file => file.path))], { env: isolated });
1198
+ const tree = git(['write-tree'], { env: isolated }).trim();
1199
+ parents.push('-p', git(['commit-tree', tree], { input: 'untracked files for selected stash\n' }).trim());
1200
+ }
1201
+ const branch = git(['branch', '--show-current']).trim() || '(detached HEAD)';
1202
+ const message = 'On ' + branch + ': ' + (input.message.trim() || 'selected changes');
1203
+ const stash = git(['commit-tree', workTree, ...parents], { input: message + '\n' }).trim();
1204
+ git(['stash', 'store', '--message', message, stash]);
1205
+ // All selected versions are durable before changing the working files/index.
1206
+ if (paths.length) git(['restore', '--source=' + base, '--staged', '--worktree', '--', ...literal(paths)]);
1207
+ for (const file of untracked) {
1208
+ // A recreated, previously tracked path has just been restored from HEAD.
1209
+ if (!paths.includes(file.path)) fs.unlinkSync(file.path);
1210
+ }
1211
+ }
1212
+ process.stdout.write(JSON.stringify({ ok: true }));
1213
+ } catch (error) {
1214
+ process.stdout.write(JSON.stringify({ ok: false, code: 'GIT_FAILED', message: '创建贮藏失败;请刷新确认当前状态', diagnostics: String(error.stderr || error.message) }));
1215
+ } finally {
1216
+ if (temporary) fs.rmSync(temporary, { recursive: true, force: true });
1217
+ }
1218
+ `;
1219
+ }
1220
+ });
1221
+
775
1222
  // src/host/git-repository-service.ts
1223
+ async function runShell(shell, specification) {
1224
+ if (shell.execute) return (await shell.execute(specification)).result();
1225
+ if (shell.run) return shell.run(specification);
1226
+ throw new Error("shell \u670D\u52A1\u4E0D\u652F\u6301\u6267\u884C\u547D\u4EE4");
1227
+ }
776
1228
  function errorResult(code, message, diagnostics, reason) {
777
1229
  return {
778
1230
  ok: false,
@@ -791,13 +1243,17 @@ function mutationErrorCode(result) {
791
1243
  }
792
1244
  function parseStatus(status) {
793
1245
  const files = [];
794
- for (const line of status.split("\n")) {
795
- if (!line || line.startsWith("## ") || line.length < 4) continue;
796
- const indexStatus = line[0] ?? " ";
797
- const workTreeStatus = line[1] ?? " ";
798
- const rawPath = line.slice(3);
799
- const renameAt = rawPath.indexOf(" -> ");
800
- files.push(renameAt >= 0 ? { indexStatus, workTreeStatus, path: rawPath.slice(renameAt + 4), originalPath: rawPath.slice(0, renameAt) } : { indexStatus, workTreeStatus, path: rawPath });
1246
+ const records = status.split("\0");
1247
+ for (let index = 0; index < records.length - 1; index++) {
1248
+ const record = records[index];
1249
+ if (record.startsWith("## ") || record.length < 4) continue;
1250
+ const indexStatus = record[0];
1251
+ const workTreeStatus = record[1];
1252
+ const path = record.slice(3);
1253
+ if (/[RC]/.test(indexStatus + workTreeStatus)) {
1254
+ if (index + 1 >= records.length - 1) break;
1255
+ files.push({ indexStatus, workTreeStatus, path, originalPath: records[++index] });
1256
+ } else files.push({ indexStatus, workTreeStatus, path });
801
1257
  }
802
1258
  return files;
803
1259
  }
@@ -821,6 +1277,9 @@ function conflictCount(files) {
821
1277
  function validCommitHash(hash) {
822
1278
  return typeof hash === "string" && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(hash);
823
1279
  }
1280
+ function validStashPath(path) {
1281
+ return typeof path === "string" && path.length > 0 && path.length <= 4096 && !path.includes("\0") && !path.startsWith("/") && !/^[A-Za-z]:[\\/]/.test(path) && !path.split(/[\\/]/).some((part) => part === "..");
1282
+ }
824
1283
  function parseCommitFiles(nameStatus, numstat) {
825
1284
  const statusRows = nameStatus.split("\n").filter(Boolean);
826
1285
  const statRows = numstat.split("\n").filter(Boolean);
@@ -859,10 +1318,12 @@ function parseCommitFiles(nameStatus, numstat) {
859
1318
  totals: { files: statusRows.length, additions, deletions, binary }
860
1319
  };
861
1320
  }
862
- var MUTATION_OUTPUT_MAX_CHARS, DIFF_MAX_CHARS, COMMIT_DETAIL_MAX_CHARS, COMMIT_DIFF_MAX_CHARS, COMMIT_FILE_MAX, STASH_MAX, MAX_PATHS, OPERATION_TTL_MS, GitRepositoryService;
1321
+ var MUTATION_OUTPUT_MAX_CHARS, DIFF_MAX_CHARS, COMMIT_DETAIL_MAX_CHARS, COMMIT_DIFF_MAX_CHARS, COMMIT_FILE_MAX, STASH_MAX, MAX_PATHS, OPERATION_TTL_MS, repositoryLocks, GitRepositoryService;
863
1322
  var init_git_repository_service = __esm({
864
1323
  "src/host/git-repository-service.ts"() {
865
1324
  "use strict";
1325
+ init_conflict_worker();
1326
+ init_stash_worker();
866
1327
  init_command_policy();
867
1328
  MUTATION_OUTPUT_MAX_CHARS = 1e5;
868
1329
  DIFF_MAX_CHARS = 2e5;
@@ -872,17 +1333,18 @@ var init_git_repository_service = __esm({
872
1333
  STASH_MAX = 100;
873
1334
  MAX_PATHS = 100;
874
1335
  OPERATION_TTL_MS = 24 * 60 * 60 * 1e3;
1336
+ repositoryLocks = /* @__PURE__ */ new Map();
875
1337
  GitRepositoryService = class {
876
1338
  constructor(shell) {
877
1339
  this.shell = shell;
878
1340
  }
879
- locks = /* @__PURE__ */ new Map();
1341
+ locks = repositoryLocks;
880
1342
  operations = /* @__PURE__ */ new Map();
881
1343
  async run(workdir, command, timeoutMs = 2e4, stdoutMaxBytes = 3e4, signal, sandboxPolicy) {
882
1344
  if (!this.shell) return { exitCode: -1, stderr: { text: "shell \u670D\u52A1\u4E0D\u53EF\u7528" } };
883
1345
  try {
884
1346
  const specification = this.shell.resolve({ command, workdir, timeoutMs, stdoutMaxBytes, signal, ...sandboxPolicy ? { sandboxPolicy } : {} });
885
- return await this.shell.run(specification);
1347
+ return await runShell(this.shell, specification);
886
1348
  } catch (error) {
887
1349
  return { exitCode: -1, stderr: { text: error instanceof Error ? error.message : String(error) } };
888
1350
  }
@@ -901,13 +1363,14 @@ var init_git_repository_service = __esm({
901
1363
  const [branchResult, headResult, statusResult] = await Promise.all([
902
1364
  this.run(workdir, "git branch --show-current", 15e3, 4096, signal, sandboxPolicy),
903
1365
  this.run(workdir, "git rev-parse --short HEAD", 15e3, 4096, signal, sandboxPolicy),
904
- this.run(workdir, "git status --porcelain=v1 --branch --untracked-files=all", 15e3, 5e4, signal, sandboxPolicy)
1366
+ this.run(workdir, "git status --porcelain=v1 --branch --untracked-files=all -z", 15e3, 5e4, signal, sandboxPolicy)
905
1367
  ]);
906
1368
  if (branchResult.exitCode !== 0 || headResult.exitCode !== 0 || statusResult.exitCode !== 0) {
907
1369
  return errorResult("GIT_FAILED", "\u65E0\u6CD5\u8BFB\u53D6 Git \u4ED3\u5E93\u6458\u8981", redactAndLimit(outputOf(branchResult) + "\n" + outputOf(headResult) + "\n" + outputOf(statusResult), 8192));
908
1370
  }
909
- const status = redactAndLimit(statusResult.stdout?.text ?? "", 5e4);
910
- const files = parseStatus(status);
1371
+ const rawStatus = statusResult.stdout?.text ?? "";
1372
+ const files = parseStatus(rawStatus);
1373
+ const status = redactAndLimit(rawStatus.replace(/\0/g, "\n"), 5e4);
911
1374
  return {
912
1375
  ok: true,
913
1376
  data: {
@@ -944,7 +1407,7 @@ var init_git_repository_service = __esm({
944
1407
  async getBranches(workdir, signal, sandboxPolicy) {
945
1408
  const topLevel = await this.getTopLevel(workdir, signal, sandboxPolicy);
946
1409
  if (!topLevel.ok) return topLevel;
947
- const branchFormat = "%(HEAD)%09%(refname:short)%09%(upstream:short)";
1410
+ const branchFormat = "%(HEAD)%09%(refname:lstrip=2)%09%(upstream:short)";
948
1411
  const referenceFormat = "%(refname)%09%(refname:short)%09%(objectname:short)%09%(*objectname:short)%09%(subject)";
949
1412
  const [branchResult, referenceResult] = await Promise.all([
950
1413
  this.run(workdir, "git branch --format=" + quoteShellArg(branchFormat), 15e3, 3e4, signal, sandboxPolicy),
@@ -962,7 +1425,9 @@ var init_git_repository_service = __esm({
962
1425
  for (const line of (referenceResult.stdout?.text ?? "").split("\n").filter(Boolean)) {
963
1426
  const [fullName = "", shortName = "", objectHash = "", peeledHash = "", subject = ""] = line.split(" ");
964
1427
  const entry = {
965
- name: redactAndLimit(shortName, 512),
1428
+ // Git's :short adds heads/ or remotes/ when references collide.
1429
+ // The UI already distinguishes namespaces, so keep the actual name.
1430
+ name: redactAndLimit(fullName.startsWith("refs/remotes/") ? fullName.slice(13) : shortName, 512),
966
1431
  hash: redactAndLimit(peeledHash || objectHash, 128),
967
1432
  subject: redactAndLimit(subject, 4096)
968
1433
  };
@@ -1101,6 +1566,102 @@ var init_git_repository_service = __esm({
1101
1566
  });
1102
1567
  return { ok: true, data: stashes };
1103
1568
  }
1569
+ async resolveStash(workdir, selector, hash, signal, sandboxPolicy) {
1570
+ if (typeof selector !== "string" || !/^stash@\{\d{1,9}\}$/.test(selector) || !validCommitHash(hash)) {
1571
+ return errorResult("INVALID_ARGUMENT", "\u5FC5\u987B\u63D0\u4F9B\u6709\u6548\u7684\u8D2E\u85CF\u7F16\u53F7\u548C\u5B8C\u6574\u54C8\u5E0C");
1572
+ }
1573
+ const root = await this.getTopLevel(workdir, signal, sandboxPolicy);
1574
+ if (!root.ok) return root;
1575
+ const resolved = await this.run(root.data.topLevel, "git rev-parse --verify " + quoteShellArg(selector), 15e3, 4096, signal, sandboxPolicy);
1576
+ if (resolved.exitCode !== 0 || (resolved.stdout?.text ?? "").trim() !== hash) {
1577
+ return errorResult("STATE_CONFLICT", "\u8D2E\u85CF\u5217\u8868\u5DF2\u7ECF\u53D8\u5316\uFF0C\u8BF7\u5237\u65B0\u540E\u91CD\u65B0\u9009\u62E9");
1578
+ }
1579
+ const metadata = await this.run(root.data.topLevel, "git show -s --format=%P " + quoteShellArg(hash), 15e3, 4096, signal, sandboxPolicy);
1580
+ const parents = (metadata.stdout?.text ?? "").trim().split(" ").filter(validCommitHash);
1581
+ if (metadata.exitCode !== 0 || parents.length < 2) return errorResult("GIT_FAILED", "\u65E0\u6CD5\u8BFB\u53D6\u8D2E\u85CF\u7236\u8282\u70B9");
1582
+ return { ok: true, data: { topLevel: root.data.topLevel, hash, parents } };
1583
+ }
1584
+ async getStashDetail(workdir, selector, hash, signal, sandboxPolicy) {
1585
+ const stash = await this.resolveStash(workdir, selector, hash, signal, sandboxPolicy);
1586
+ if (!stash.ok) return stash;
1587
+ const { topLevel, parents } = stash.data;
1588
+ const commands = ["git diff --no-ext-diff --no-textconv --no-renames --name-status -z " + quoteShellArg(parents[0]) + " " + quoteShellArg(stash.data.hash) + " --"];
1589
+ if (parents[2]) commands.push("git diff-tree --root --no-commit-id --no-renames --name-status -r -z " + quoteShellArg(parents[2]) + " --");
1590
+ const results = await Promise.all(commands.map((command) => this.run(topLevel, command, 2e4, COMMIT_DETAIL_MAX_CHARS, signal, sandboxPolicy)));
1591
+ const failure = results.find((result) => result.exitCode !== 0);
1592
+ if (failure) return errorResult("GIT_FAILED", "\u65E0\u6CD5\u8BFB\u53D6\u8D2E\u85CF\u6587\u4EF6", redactAndLimit(outputOf(failure), 8192));
1593
+ const files = [];
1594
+ let filesTruncated = false;
1595
+ results.forEach((result, index) => {
1596
+ const raw = result.stdout?.text ?? "";
1597
+ const parts = raw.split("\0");
1598
+ if (raw.length >= COMMIT_DETAIL_MAX_CHARS || raw && !raw.endsWith("\0")) filesTruncated = true;
1599
+ for (let i = 0; i + 2 < parts.length; i += 2) {
1600
+ if (files.length >= COMMIT_FILE_MAX) {
1601
+ filesTruncated = true;
1602
+ break;
1603
+ }
1604
+ files.push({ status: parts[i], path: parts[i + 1], untracked: index === 1 });
1605
+ }
1606
+ });
1607
+ return { ok: true, data: { hash: stash.data.hash, files, filesTruncated } };
1608
+ }
1609
+ async getStashDiff(workdir, selector, hash, path, untracked, signal, sandboxPolicy) {
1610
+ if (!validStashPath(path)) return errorResult("INVALID_ARGUMENT", "\u8BF7\u9009\u62E9\u4ED3\u5E93\u5185\u7684\u6587\u4EF6\u8DEF\u5F84");
1611
+ const stash = await this.resolveStash(workdir, selector, hash, signal, sandboxPolicy);
1612
+ if (!stash.ok) return stash;
1613
+ const { topLevel, parents } = stash.data;
1614
+ if (untracked && !parents[2]) return errorResult("INVALID_ARGUMENT", "\u8BE5\u8D2E\u85CF\u4E0D\u5305\u542B\u672A\u8DDF\u8E2A\u6587\u4EF6");
1615
+ const common = "--no-color --no-ext-diff --no-textconv --no-renames --patch ";
1616
+ const command = untracked ? "git --literal-pathspecs diff-tree --root --no-commit-id -r " + common + quoteShellArg(parents[2]) : "git --literal-pathspecs diff " + common + quoteShellArg(parents[0]) + " " + quoteShellArg(stash.data.hash);
1617
+ const result = await this.run(topLevel, command + " -- " + quoteShellArg(path), 3e4, DIFF_MAX_CHARS + 1024, signal, sandboxPolicy);
1618
+ if (result.exitCode !== 0) return errorResult("GIT_FAILED", "\u65E0\u6CD5\u8BFB\u53D6\u8D2E\u85CF Diff", redactAndLimit(outputOf(result), 8192));
1619
+ const raw = redactSecrets(result.stdout?.text ?? "");
1620
+ return { ok: true, data: { path, staged: false, diff: raw.slice(0, DIFF_MAX_CHARS), truncated: raw.length > DIFF_MAX_CHARS } };
1621
+ }
1622
+ async createStash(request, message, paths, includeUntracked) {
1623
+ if (typeof message !== "string" || message.length > 4096 || message.includes("\0")) return errorResult("INVALID_ARGUMENT", "\u8D2E\u85CF\u8BF4\u660E\u4E0D\u80FD\u8D85\u8FC7 4096 \u5B57\u7B26\u6216\u5305\u542B\u7A7A\u5B57\u7B26");
1624
+ if (paths !== void 0 && (!Array.isArray(paths) || !paths.length || paths.length > 500 || !paths.every(validStashPath))) {
1625
+ return errorResult("INVALID_ARGUMENT", "\u8BF7\u9009\u62E9 1\u2013500 \u4E2A\u6587\u4EF6\uFF0C\u6216\u8D2E\u85CF\u5168\u90E8\u6587\u4EF6");
1626
+ }
1627
+ return this.withMutation(request, async () => {
1628
+ const result = await this.run(request.workdir, createStashCommand(message, paths, includeUntracked), 12e4, MUTATION_OUTPUT_MAX_CHARS, request.signal, request.sandboxPolicy);
1629
+ if (result.exitCode !== 0) return errorResult(mutationErrorCode(result), "\u521B\u5EFA\u8D2E\u85CF\u5931\u8D25\uFF0C\u8BF7\u5237\u65B0\u786E\u8BA4\u5F53\u524D\u72B6\u6001", redactAndLimit(outputOf(result), 8192));
1630
+ try {
1631
+ const response = JSON.parse(result.stdout?.text ?? "");
1632
+ if (!response.ok) return errorResult(response.code, response.message, redactAndLimit(response.diagnostics ?? "", 8192));
1633
+ } catch {
1634
+ return errorResult("GIT_FAILED", "\u65E0\u6CD5\u786E\u8BA4\u8D2E\u85CF\u521B\u5EFA\u7ED3\u679C\uFF0C\u8BF7\u5237\u65B0");
1635
+ }
1636
+ const summary = await this.getSummary(request.workdir, request.signal, request.sandboxPolicy);
1637
+ return summary.ok ? { ...summary, operationId: String(request.operationId) } : summary;
1638
+ });
1639
+ }
1640
+ async mutateStash(request, action, selector, hash, name, confirmRisk = false) {
1641
+ if (action === "drop-stash" && !confirmRisk) return errorResult("PERMISSION_DENIED", "\u5220\u9664\u8D2E\u85CF\u524D\u5FC5\u987B\u786E\u8BA4\u5176\u4E2D\u7684\u6539\u52A8\u53EF\u80FD\u4E22\u5931");
1642
+ if (action === "branch-stash" && !validBranchName(name)) return errorResult("INVALID_ARGUMENT", "\u8BF7\u8F93\u5165\u6709\u6548\u7684\u65B0\u5206\u652F\u540D");
1643
+ return this.withMutation(request, async () => {
1644
+ const stash = await this.resolveStash(request.workdir, selector, hash, request.signal, request.sandboxPolicy);
1645
+ if (!stash.ok) return stash;
1646
+ const root = stash.data.topLevel;
1647
+ if (action !== "drop-stash") {
1648
+ const state = await this.conflictAction("get-conflicts", root, {}, void 0, request.sandboxPolicy);
1649
+ if (!state.ok) return state;
1650
+ const data = state.data;
1651
+ if (data.operation || data.files.length) return errorResult("STATE_CONFLICT", "\u8BF7\u5148\u5B8C\u6210\u5F53\u524D Git \u64CD\u4F5C\u5E76\u89E3\u51B3\u51B2\u7A81");
1652
+ if (action === "branch-stash") {
1653
+ const summary2 = await this.getSummary(root, request.signal, request.sandboxPolicy);
1654
+ if (!summary2.ok) return summary2;
1655
+ if (summary2.data.files.length) return errorResult("STATE_CONFLICT", "\u4ECE\u8D2E\u85CF\u521B\u5EFA\u5206\u652F\u524D\uFF0C\u8BF7\u5148\u63D0\u4EA4\u6216\u8D2E\u85CF\u5F53\u524D\u6539\u52A8", void 0, "DIRTY_WORKTREE");
1656
+ }
1657
+ }
1658
+ const command = action === "branch-stash" ? "git -c apply.whitespace=nowarn stash branch " + quoteShellArg(name) + " " + quoteShellArg(selector) : "git stash " + { "apply-stash": "apply", "pop-stash": "pop", "drop-stash": "drop" }[action] + " " + quoteShellArg(action === "apply-stash" ? stash.data.hash : selector);
1659
+ const result = await this.run(root, command, 12e4, MUTATION_OUTPUT_MAX_CHARS, request.signal, request.sandboxPolicy);
1660
+ if (result.exitCode !== 0) return errorResult(mutationErrorCode(result), "\u8D2E\u85CF\u64CD\u4F5C\u672A\u5B8C\u6210\uFF1B\u5982\u6709\u51B2\u7A81\uFF0C\u8BF7\u5728\u201C\u51B2\u7A81\u89E3\u51B3\u201D\u4E2D\u5904\u7406\u3002\u5931\u8D25\u7684\u5F39\u51FA\u4F1A\u4FDD\u7559\u8D2E\u85CF\u3002", redactAndLimit(outputOf(result), 8192));
1661
+ const summary = await this.getSummary(root, request.signal, request.sandboxPolicy);
1662
+ return summary.ok ? { ...summary, operationId: String(request.operationId) } : summary;
1663
+ });
1664
+ }
1104
1665
  async getSyncState(workdir, signal, sandboxPolicy) {
1105
1666
  const summary = await this.getSummary(workdir, signal, sandboxPolicy);
1106
1667
  if (!summary.ok) return summary;
@@ -1276,6 +1837,40 @@ var init_git_repository_service = __esm({
1276
1837
  return this.mutateAndRead(request, command, failureMessage, false, () => this.getSyncState(request.workdir, request.signal, request.sandboxPolicy));
1277
1838
  }
1278
1839
  async mutateAndRead(request, command, failureMessage, requiresStagedContent, readResult) {
1840
+ return this.withMutation(request, async () => {
1841
+ if (requiresStagedContent) {
1842
+ const staged = await this.run(request.workdir, "git diff --cached --quiet", 15e3, 4096, request.signal, request.sandboxPolicy);
1843
+ if (staged.exitCode === 0) return errorResult("STATE_CONFLICT", "\u6CA1\u6709\u5DF2\u6682\u5B58\u7684\u6539\u52A8\uFF0C\u65E0\u6CD5\u63D0\u4EA4");
1844
+ if (staged.exitCode !== 1) return errorResult("GIT_FAILED", "\u65E0\u6CD5\u68C0\u67E5\u6682\u5B58\u533A", redactAndLimit(outputOf(staged), 8192));
1845
+ }
1846
+ const executed = await this.run(request.workdir, command, 12e4, MUTATION_OUTPUT_MAX_CHARS, request.signal, request.sandboxPolicy);
1847
+ if (executed.exitCode !== 0) return errorResult(mutationErrorCode(executed), failureMessage, redactAndLimit(outputOf(executed), 8192));
1848
+ const result = await readResult();
1849
+ return result.ok ? { ...result, operationId: String(request.operationId) } : result;
1850
+ });
1851
+ }
1852
+ async conflictAction(action, workdir, payload, request, sandboxPolicy) {
1853
+ if (action === "amend-message" && (typeof payload.message !== "string" || Buffer.byteLength(payload.message) > 48 * 1024)) {
1854
+ return errorResult("INVALID_ARGUMENT", "\u63D0\u4EA4\u8BF4\u660E\u5FC5\u987B\u662F 48 KiB \u4EE5\u5185\u7684\u6587\u672C");
1855
+ }
1856
+ if (action === "save-conflict" && (typeof payload.content !== "string" || Buffer.byteLength(payload.content) > 48 * 1024)) {
1857
+ return errorResult("INVALID_ARGUMENT", "\u7ED3\u679C\u5FC5\u987B\u662F 48 KiB \u4EE5\u5185\u7684\u6587\u672C");
1858
+ }
1859
+ const run = async () => {
1860
+ const result = await this.run(workdir, conflictWorkerCommand(action, payload), 12e4, 2 * 1024 * 1024, request?.signal, request?.sandboxPolicy ?? sandboxPolicy);
1861
+ if (result.exitCode !== 0) return errorResult(mutationErrorCode(result), "\u51B2\u7A81\u64CD\u4F5C\u5931\u8D25", redactAndLimit(outputOf(result), 8192));
1862
+ try {
1863
+ const response = JSON.parse(result.stdout?.text ?? "");
1864
+ if (!response.ok && response.diagnostics) response.diagnostics = redactAndLimit(response.diagnostics, 8192);
1865
+ if (!response.ok) response.message = redactAndLimit(response.message, 8192);
1866
+ return response;
1867
+ } catch {
1868
+ return errorResult("GIT_FAILED", "\u65E0\u6CD5\u8BFB\u53D6\u5B8C\u6574\u7684\u51B2\u7A81\u6570\u636E");
1869
+ }
1870
+ };
1871
+ return request ? this.withMutation(request, run) : run();
1872
+ }
1873
+ async withMutation(request, task) {
1279
1874
  if (!request.sessionId || !request.workdir) return errorResult("SESSION_NOT_FOUND", "\u65E0\u6CD5\u786E\u5B9A\u5F53\u524D\u4F1A\u8BDD\u7684\u4ED3\u5E93\u76EE\u5F55");
1280
1875
  if (typeof request.operationId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(request.operationId)) {
1281
1876
  return errorResult("INVALID_ARGUMENT", "operationId \u5FC5\u987B\u662F 1\u2013128 \u4E2A\u5B89\u5168\u5B57\u7B26");
@@ -1286,7 +1881,7 @@ var init_git_repository_service = __esm({
1286
1881
  const key = request.sessionId + "\0" + repository.data.topLevel + "\0" + request.operationId;
1287
1882
  const existing = this.operations.get(key);
1288
1883
  if (existing) return existing.result;
1289
- const lockKey = request.sessionId + "\0" + repository.data.topLevel;
1884
+ const lockKey = repository.data.topLevel;
1290
1885
  const previous = this.locks.get(lockKey) ?? Promise.resolve();
1291
1886
  let release = () => {
1292
1887
  };
@@ -1297,15 +1892,7 @@ var init_git_repository_service = __esm({
1297
1892
  this.locks.set(lockKey, queued);
1298
1893
  const result = previous.then(async () => {
1299
1894
  try {
1300
- if (requiresStagedContent) {
1301
- const staged = await this.run(request.workdir, "git diff --cached --quiet", 15e3, 4096, request.signal, request.sandboxPolicy);
1302
- if (staged.exitCode === 0) return errorResult("STATE_CONFLICT", "\u6CA1\u6709\u5DF2\u6682\u5B58\u7684\u6539\u52A8\uFF0C\u65E0\u6CD5\u63D0\u4EA4");
1303
- if (staged.exitCode !== 1) return errorResult("GIT_FAILED", "\u65E0\u6CD5\u68C0\u67E5\u6682\u5B58\u533A", redactAndLimit(outputOf(staged), 8192));
1304
- }
1305
- const executed = await this.run(request.workdir, command, 12e4, MUTATION_OUTPUT_MAX_CHARS, request.signal, request.sandboxPolicy);
1306
- if (executed.exitCode !== 0) return errorResult(mutationErrorCode(executed), failureMessage, redactAndLimit(outputOf(executed), 8192));
1307
- const result2 = await readResult();
1308
- return result2.ok ? { ...result2, operationId: String(request.operationId) } : result2;
1895
+ return await task();
1309
1896
  } finally {
1310
1897
  release();
1311
1898
  if (this.locks.get(lockKey) === queued) this.locks.delete(lockKey);
@@ -1358,23 +1945,36 @@ var require_plugin = __commonJS({
1358
1945
  }
1359
1946
  return void 0;
1360
1947
  }
1361
- function repositoryContextForSession(ctx, sandboxPolicy, sessionId) {
1948
+ async function repositoryContextForSession(ctx, sandboxPolicy, sessionId) {
1362
1949
  try {
1363
1950
  const agents = ctx.get("agents");
1364
1951
  const agent = agents && agents.get(sessionId);
1365
- if (!agent) return null;
1366
- const workdir = sessionWorkdir({ agent }, {}, ctx);
1367
- if (!workdir) return null;
1368
- const policy = sandboxPolicy ? sandboxPolicy.resolve({ session: agent.session }) : void 0;
1369
- return { workdir, policy };
1370
- } catch (e) {
1952
+ const session = agent?.session ?? ctx.get("sessions")?.get(sessionId);
1953
+ if (session) {
1954
+ const workdir = sessionWorkdir({ agent: { session } }, {}, ctx);
1955
+ if (!workdir) return null;
1956
+ return { workdir, policy: sandboxPolicy?.resolve({ session }) };
1957
+ }
1958
+ const query = ctx.get("sessionQuery");
1959
+ if (!query) return null;
1960
+ const observation = await query.observeSession(sessionId);
1961
+ try {
1962
+ const workdir = observation.header.cwd;
1963
+ if (!workdir || !observation.projections) return null;
1964
+ const mode = observation.projections.values.sandboxMode ?? void 0;
1965
+ const policy = sandboxPolicy ? { ...sandboxPolicy.resolve({ mode }), workspaceRoot: workdir, sessionId } : void 0;
1966
+ return { workdir, policy };
1967
+ } finally {
1968
+ observation[Symbol.dispose]();
1969
+ }
1970
+ } catch (error) {
1371
1971
  return null;
1372
1972
  }
1373
1973
  }
1374
1974
  async function runGit(shell, workdir, command, timeoutMs, stdoutMaxBytes, signal, policy) {
1375
1975
  try {
1376
1976
  const spec = shell.resolve({ command, workdir, timeoutMs, stdoutMaxBytes, signal, ...policy ? { sandboxPolicy: policy } : {} });
1377
- return await shell.run(spec);
1977
+ return await runShell(shell, spec);
1378
1978
  } catch (err) {
1379
1979
  return { exitCode: -1, signal: null, timedOut: false, aborted: false, stdout: { text: "" }, stderr: { text: errorMessage2(err) } };
1380
1980
  }
@@ -1962,7 +2562,7 @@ var require_plugin = __commonJS({
1962
2562
  }
1963
2563
  });
1964
2564
  }
1965
- const registerWebServer = (webServer) => registerEasyGitActions(webServer, {
2565
+ const registerWebServer = (webServer, connection) => registerEasyGitActions(webServer, {
1966
2566
  repository,
1967
2567
  proposalStorageReady,
1968
2568
  shell,
@@ -1987,16 +2587,16 @@ var require_plugin = __commonJS({
1987
2587
  errorCode,
1988
2588
  reason
1989
2589
  ),
1990
- resolveExecutionPolicy: (sessionId) => {
1991
- const agents = ctx.get("agents");
1992
- const agent = agents ? agents.get(sessionId) : void 0;
1993
- return sandboxPolicy ? sandboxPolicy.resolve({ session: agent?.session }) : void 0;
2590
+ resolveExecutionPolicy: async (sessionId) => {
2591
+ const context = await repositoryContextForSession(ctx, sandboxPolicy, sessionId);
2592
+ if (!context) throw new Error("\u65E0\u6CD5\u786E\u5B9A\u5F53\u524D\u4F1A\u8BDD\u7684\u4ED3\u5E93\u76EE\u5F55");
2593
+ return context.policy;
1994
2594
  }
1995
- });
2595
+ }, connection);
1996
2596
  if (typeof ctx.inject === "function") {
1997
- ctx.inject(["webServer"], (webCtx) => registerWebServer(webCtx.get("webServer")));
2597
+ ctx.inject(["webServer", "connection"], (webCtx) => registerWebServer(webCtx.get("webServer"), webCtx.get("connection")));
1998
2598
  } else {
1999
- registerWebServer(ctx.get("webServer"));
2599
+ registerWebServer(ctx.get("webServer"), ctx.get("connection"));
2000
2600
  }
2001
2601
  }
2002
2602
  };