cc-viewer 1.6.249 → 1.6.251
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/dist/assets/App-CgxrAuLr.js +1 -0
- package/dist/assets/{MdxEditorPanel-6LZbpGlJ.js → MdxEditorPanel-FxElmtOM.js} +1 -1
- package/dist/assets/{Mobile-DPKvAzpB.js → Mobile-CPiyaXyL.js} +1 -1
- package/dist/assets/ProxyModal-DWuRfY6i.js +2 -0
- package/dist/assets/{ProxyModal-BE9yXkhe.css → ProxyModal-zmu3kldf.css} +2 -2
- package/dist/assets/index-nCqmU6FA.js +2 -0
- package/dist/index.html +1 -1
- package/interceptor.js +46 -15
- package/lib/git-diff.js +206 -22
- package/package.json +1 -1
- package/server.js +26 -2
- package/dist/assets/App-DKy6_SMN.js +0 -1
- package/dist/assets/ProxyModal-BdlASICk.js +0 -2
- package/dist/assets/index-etQ0_qi5.js +0 -2
package/dist/index.html
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<title>Claude Code Viewer</title>
|
|
7
7
|
<link rel="icon" href="/favicon.ico?v=1">
|
|
8
8
|
<link rel="shortcut icon" href="/favicon.ico?v=1">
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-nCqmU6FA.js"></script>
|
|
10
10
|
<link rel="modulepreload" crossorigin href="/assets/vendor-antd-BeN8xqGk.js">
|
|
11
11
|
<link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-2nbmPewy.js">
|
|
12
12
|
<link rel="modulepreload" crossorigin href="/assets/vendor-mdxeditor-C7DYEBoH.js">
|
package/interceptor.js
CHANGED
|
@@ -237,17 +237,32 @@ function resolveResumeChoice(choice) {
|
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
// Delta storage: 增量存储开关和状态(默认开启,设置 CCV_DISABLE_DELTA=1 关闭)
|
|
240
|
-
// 注意:delta
|
|
240
|
+
// 注意:delta 计算原本依赖 mainAgent 请求串行假设。实证发现 teammate 终止 / 多 SSE 通道
|
|
241
|
+
// 注入等情况会让两条 mainAgent 请求 30ms 内连续到达(前一条流式响应未完成时后一条已发起),
|
|
242
|
+
// 导致仅在 completed 时更新 _lastMessagesCount/_lastTailFp 出现状态滞后 → Plan C 漏检。
|
|
241
243
|
const _deltaStorageEnabled = process.env.CCV_DISABLE_DELTA !== '1';
|
|
242
244
|
// In-place last-msg replace 检测开关(默认开启,设置 CCV_DISABLE_TAIL_FP_CHECKPOINT=1 关闭)。
|
|
243
245
|
// 关闭后回退到旧行为(仅按长度算 delta,遇到末位原地替换会丢失"末位换内容"信息)。
|
|
244
246
|
const _tailFpCheckEnabled = process.env.CCV_DISABLE_TAIL_FP_CHECKPOINT !== '1';
|
|
245
|
-
|
|
246
|
-
|
|
247
|
+
// 这两个变量代表"截至本次请求开始前的最新已知态"。请求开始处理时即同步更新(eager),
|
|
248
|
+
// 不再等到 _commitDeltaState(completed 时执行)。Plan C 检测使用进入函数前的快照。
|
|
249
|
+
// 异常分支(请求失败 / 服务端不发送)不会回滚——下一个成功请求覆盖即可,状态不会永久错位。
|
|
250
|
+
// 命名说明:变量名保留 `_last` 前缀(历史命名),但语义已由"上次 commit 后"变为"上次见到的最新态"。
|
|
251
|
+
// 三路并发场景下,连续 3 个请求若 length 非单调(如 257→259→258),_lastMessagesCount 会跟随
|
|
252
|
+
// 最新一次 startRequest,可能让早到的更大值被覆盖;这种情况 Plan C 走 length 不等支路最终
|
|
253
|
+
// 命中 needsCheckpoint 写完整快照,client 拿到正确数据,不破坏正确性。
|
|
254
|
+
let _lastMessagesCount = 0; // 截至最近一次 startRequest 的完整 messages 数量(eager-updated)
|
|
255
|
+
let _lastTailFp = ''; // 截至最近一次 startRequest 的末位 message 指纹(eager-updated)
|
|
247
256
|
let _mainAgentDeltaCount = 0; // mainAgent 请求计数器(用于触发定期 checkpoint)
|
|
248
257
|
const CHECKPOINT_INTERVAL = 10; // 每 N 条 mainAgent 请求写一个 checkpoint
|
|
249
258
|
|
|
250
|
-
/**
|
|
259
|
+
/**
|
|
260
|
+
* Delta storage: completed 写入成功后更新状态。
|
|
261
|
+
* eager-update 已在请求开始时把 _lastMessagesCount/_lastTailFp 推到本次值,本函数对同
|
|
262
|
+
* originalLength 的 commit 实际是 no-op(`originalLength > _lastMessagesCount` 判等返回 false)。
|
|
263
|
+
* 保留为防御层:处理 eager 块未跑到的早期 return / throw(例如 _deltaStorageEnabled=false
|
|
264
|
+
* 或 messages 不是数组时不会进入 eager 块),让 commit 路径仍能在 completed 时把状态推上去。
|
|
265
|
+
*/
|
|
251
266
|
function _commitDeltaState(originalLength, originalTailFp) {
|
|
252
267
|
if (_deltaStorageEnabled && originalLength > 0) {
|
|
253
268
|
_lastMessagesCount = originalLength;
|
|
@@ -615,23 +630,35 @@ export function setupInterceptor() {
|
|
|
615
630
|
_deltaOriginalTailFp = messages.length > 0 ? fingerprintMsg(messages[messages.length - 1]) : '';
|
|
616
631
|
_mainAgentDeltaCount++;
|
|
617
632
|
|
|
633
|
+
// 并发竞态修复(详见模块顶部注释 + history.md Unreleased 段 fix(interceptor) 条目):
|
|
634
|
+
// snapshot 上一请求处理时的 count/fp 给 Plan C 用,然后 eager 把模块级状态推到本次值
|
|
635
|
+
// (不等 _commitDeltaState)。BUG 来源:teammate 终止快速串行让 mainAgent 30ms 内连续
|
|
636
|
+
// firing,旧 commit 时序使 Plan C 拿陈旧 prev 漏检 → client doubled-history。
|
|
637
|
+
const _prevMessagesCount = _lastMessagesCount;
|
|
638
|
+
const _prevTailFp = _lastTailFp;
|
|
639
|
+
if (_deltaOriginalMessagesLength > 0) {
|
|
640
|
+
_lastMessagesCount = _deltaOriginalMessagesLength;
|
|
641
|
+
if (_deltaOriginalTailFp !== '') _lastTailFp = _deltaOriginalTailFp;
|
|
642
|
+
}
|
|
643
|
+
|
|
618
644
|
// In-place last-msg replace 检测:messages.length 不变但末位 fp 不同。
|
|
619
645
|
// 触发场景:CLI 在 mainAgent 末位"原地替换"user msg(SUGGESTION MODE → 用户真实输入;
|
|
620
|
-
// synthetic recap
|
|
621
|
-
// 算出 delta=[]
|
|
646
|
+
// synthetic recap 通道注入;teammate 终止快速串行 → SUGGESTION MODE 多次替换;等),
|
|
647
|
+
// wire 上长度未变内容变了。旧逻辑 messages.slice(_lastMessagesCount) 算出 delta=[],
|
|
648
|
+
// 丢失了"末位换内容"信息 → 客户端重建拿到错误的"前态末位"。
|
|
622
649
|
// 检测命中即强制写 checkpoint,让客户端拿到完整 wire 真实内容。
|
|
623
650
|
const _sameLenInPlaceReplace =
|
|
624
651
|
_tailFpCheckEnabled &&
|
|
625
|
-
messages.length ===
|
|
626
|
-
|
|
627
|
-
|
|
652
|
+
messages.length === _prevMessagesCount &&
|
|
653
|
+
_prevMessagesCount > 0 &&
|
|
654
|
+
_prevTailFp !== '' &&
|
|
628
655
|
_deltaOriginalTailFp !== '' &&
|
|
629
|
-
_deltaOriginalTailFp !==
|
|
656
|
+
_deltaOriginalTailFp !== _prevTailFp;
|
|
630
657
|
|
|
631
658
|
// 判断是否需要写 checkpoint
|
|
632
659
|
const needsCheckpoint =
|
|
633
|
-
|
|
634
|
-
messages.length <
|
|
660
|
+
_prevMessagesCount === 0 || // 进程重启 / 首次请求
|
|
661
|
+
messages.length < _prevMessagesCount || // messages 缩短(/clear、context 压缩)
|
|
635
662
|
(_mainAgentDeltaCount % CHECKPOINT_INTERVAL === 0) || // 定期 checkpoint
|
|
636
663
|
_sameLenInPlaceReplace; // in-place last-msg replace 检测
|
|
637
664
|
|
|
@@ -643,12 +670,16 @@ export function setupInterceptor() {
|
|
|
643
670
|
requestEntry._isCheckpoint = true;
|
|
644
671
|
if (_sameLenInPlaceReplace) {
|
|
645
672
|
// 诊断字段:标记此 checkpoint 是被 in-place replace 检测触发的(频率约 1-2%,
|
|
646
|
-
// 用于在生产 jsonl
|
|
673
|
+
// 用于在生产 jsonl 里事后核对触发率,不影响重建逻辑)。
|
|
674
|
+
// 双方协议(KEEP IN SYNC: src/utils/sessionManager.js applyInPlaceLastMsgReplace):
|
|
675
|
+
// 客户端 helper 看到此字段=true(与 _isCheckpoint:true 同时存在)时直接 in-place 替换
|
|
676
|
+
// lastSession.messages 末位,跳过 sessionMerge prefix-overlap 算法(避开 doubled-history)。
|
|
677
|
+
// 字段重命名 / 删除前需同步两端 + 重跑双向回归测试。
|
|
647
678
|
requestEntry._inPlaceReplaceDetected = true;
|
|
648
679
|
}
|
|
649
680
|
} else {
|
|
650
|
-
// delta:只保留新增的 messages
|
|
651
|
-
const delta = messages.slice(
|
|
681
|
+
// delta:只保留新增的 messages(必须用 _prevMessagesCount,不是 eager 已更新的 _lastMessagesCount)
|
|
682
|
+
const delta = messages.slice(_prevMessagesCount);
|
|
652
683
|
requestEntry._deltaFormat = 1;
|
|
653
684
|
requestEntry._totalMessageCount = messages.length;
|
|
654
685
|
requestEntry._conversationId = 'mainAgent';
|
package/lib/git-diff.js
CHANGED
|
@@ -8,6 +8,15 @@ const execFileAsync = promisify(execFile);
|
|
|
8
8
|
const UNTRACKED_MAX_BYTES = 5 * 1024 * 1024;
|
|
9
9
|
const BINARY_PROBE_BYTES = 8192;
|
|
10
10
|
|
|
11
|
+
// Reject only `..` as a path *segment* (start, between slashes, or end).
|
|
12
|
+
// Substring match (`'node..modules'.includes('..')`) was rejecting valid paths.
|
|
13
|
+
const PATH_TRAVERSAL = /(?:^|\/)\.\.(?:\/|$)/;
|
|
14
|
+
|
|
15
|
+
// Whitelist for git ref names returned by `git rev-parse --abbrev-ref @{u}`.
|
|
16
|
+
// Defense in depth: git-rev-parse output should never contain shell metacharacters,
|
|
17
|
+
// but we validate before passing to `git log <upstream>..HEAD`.
|
|
18
|
+
const SAFE_REF = /^[A-Za-z0-9_./\-]+$/;
|
|
19
|
+
|
|
11
20
|
/**
|
|
12
21
|
* Count inserted lines for an untracked file, matching `git diff --numstat`
|
|
13
22
|
* semantics (line = `\n`-terminated run; an unterminated trailing run still
|
|
@@ -27,7 +36,7 @@ const BINARY_PROBE_BYTES = 8192;
|
|
|
27
36
|
* @returns {number} inserted line count; 0 on any error, binary, or size cap
|
|
28
37
|
*/
|
|
29
38
|
export function countUntrackedLines(cwd, file) {
|
|
30
|
-
if (!file ||
|
|
39
|
+
if (!file || PATH_TRAVERSAL.test(file) || file.startsWith('/')) return 0;
|
|
31
40
|
try {
|
|
32
41
|
const fp = join(cwd, file);
|
|
33
42
|
// Reject when the final component is a symlink (lstat doesn't follow).
|
|
@@ -51,32 +60,193 @@ export function countUntrackedLines(cwd, file) {
|
|
|
51
60
|
}
|
|
52
61
|
}
|
|
53
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Get commits between upstream and HEAD (i.e. local commits not yet pushed).
|
|
65
|
+
* Returns an empty list when:
|
|
66
|
+
* - HEAD is detached (rev-parse --abbrev-ref HEAD prints "HEAD")
|
|
67
|
+
* - Branch has no upstream (@{u} resolution fails)
|
|
68
|
+
* - Working tree is at upstream (no commits ahead)
|
|
69
|
+
*
|
|
70
|
+
* Each commit includes its changed files via a single `git log --name-status` call,
|
|
71
|
+
* to avoid one git invocation per commit.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} cwd
|
|
74
|
+
* @param {object} [opts]
|
|
75
|
+
* @param {number} [opts.maxCommits=100] hard cap to keep payload bounded
|
|
76
|
+
* @returns {Promise<{ commits: Array, hasUpstream: boolean, branch: string|null, upstream: string|null, truncated?: boolean, totalCount?: number }>}
|
|
77
|
+
*/
|
|
78
|
+
export async function getUnpushedCommits(cwd, { maxCommits = 100 } = {}) {
|
|
79
|
+
let branch = null;
|
|
80
|
+
try {
|
|
81
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, encoding: 'utf-8', timeout: 3000 });
|
|
82
|
+
branch = stdout.trim();
|
|
83
|
+
} catch {
|
|
84
|
+
return { commits: [], hasUpstream: false, branch: null, upstream: null };
|
|
85
|
+
}
|
|
86
|
+
if (!branch || branch === 'HEAD') {
|
|
87
|
+
return { commits: [], hasUpstream: false, branch, upstream: null };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let upstream = null;
|
|
91
|
+
try {
|
|
92
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], { cwd, encoding: 'utf-8', timeout: 3000 });
|
|
93
|
+
upstream = stdout.trim();
|
|
94
|
+
} catch {
|
|
95
|
+
return { commits: [], hasUpstream: false, branch, upstream: null };
|
|
96
|
+
}
|
|
97
|
+
if (!upstream || !SAFE_REF.test(upstream)) {
|
|
98
|
+
return { commits: [], hasUpstream: false, branch, upstream: null };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Use NUL separators between fields and a sentinel between commits to avoid
|
|
102
|
+
// getting fooled by tabs/newlines inside commit subjects.
|
|
103
|
+
// Format: <hash>\x1f<author>\x1f<date>\x1f<subject>\n
|
|
104
|
+
// Followed by one `<status>\t<path>` line per file (from --name-status).
|
|
105
|
+
// Commits separated by `\x1e` (record separator).
|
|
106
|
+
const COMMIT_SEP = '\x1e';
|
|
107
|
+
const FIELD_SEP = '\x1f';
|
|
108
|
+
let stdout = '';
|
|
109
|
+
try {
|
|
110
|
+
const r = await execFileAsync(
|
|
111
|
+
'git',
|
|
112
|
+
[
|
|
113
|
+
'log',
|
|
114
|
+
`--max-count=${maxCommits}`,
|
|
115
|
+
`--pretty=format:${COMMIT_SEP}%H${FIELD_SEP}%an${FIELD_SEP}%aI${FIELD_SEP}%s`,
|
|
116
|
+
'--name-status',
|
|
117
|
+
`${upstream}..HEAD`,
|
|
118
|
+
],
|
|
119
|
+
{ cwd, encoding: 'utf-8', timeout: 8000, maxBuffer: 10 * 1024 * 1024 }
|
|
120
|
+
);
|
|
121
|
+
stdout = r.stdout;
|
|
122
|
+
} catch {
|
|
123
|
+
return { commits: [], hasUpstream: true, branch, upstream };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const commits = [];
|
|
127
|
+
const blocks = stdout.split(COMMIT_SEP).filter(Boolean);
|
|
128
|
+
for (const block of blocks) {
|
|
129
|
+
const lines = block.split('\n');
|
|
130
|
+
const header = lines[0] || '';
|
|
131
|
+
const parts = header.split(FIELD_SEP);
|
|
132
|
+
if (parts.length < 4) continue;
|
|
133
|
+
const [hash, author, date, subject] = parts;
|
|
134
|
+
const files = [];
|
|
135
|
+
for (let i = 1; i < lines.length; i++) {
|
|
136
|
+
const line = lines[i];
|
|
137
|
+
if (!line.trim()) continue;
|
|
138
|
+
const tab = line.indexOf('\t');
|
|
139
|
+
if (tab < 0) continue;
|
|
140
|
+
const st = line.substring(0, tab).trim();
|
|
141
|
+
const fp = line.substring(tab + 1);
|
|
142
|
+
if (!fp) continue;
|
|
143
|
+
files.push({ status: st[0] || 'M', file: fp });
|
|
144
|
+
}
|
|
145
|
+
commits.push({
|
|
146
|
+
hash,
|
|
147
|
+
shortHash: hash.substring(0, 7),
|
|
148
|
+
author,
|
|
149
|
+
date,
|
|
150
|
+
subject,
|
|
151
|
+
files,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Detect truncation. 默认 truncated = (commits.length === maxCommits),因为命中 cap 大概率
|
|
156
|
+
// 是被截了;rev-list --count 成功才用真实数据覆盖。这样如果 rev-list 失败/超时(大 repo
|
|
157
|
+
// 可能 >3s),用户至少能看到截断标记,不会被静默隐藏 200 条未推送 commit。
|
|
158
|
+
let totalCount = commits.length;
|
|
159
|
+
let truncated = commits.length === maxCommits;
|
|
160
|
+
if (truncated) {
|
|
161
|
+
try {
|
|
162
|
+
const r = await execFileAsync('git', ['rev-list', '--count', `${upstream}..HEAD`], { cwd, encoding: 'utf-8', timeout: 3000 });
|
|
163
|
+
const parsed = parseInt(r.stdout.trim(), 10);
|
|
164
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
165
|
+
totalCount = parsed;
|
|
166
|
+
truncated = totalCount > commits.length;
|
|
167
|
+
}
|
|
168
|
+
} catch {}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { commits, hasUpstream: true, branch, upstream, truncated, totalCount };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Validate git commit hash. Accept 7..40 hex chars only.
|
|
176
|
+
* Rejects refs like "HEAD~1", branch names, anything with shell metacharacters.
|
|
177
|
+
* @param {string} hash
|
|
178
|
+
* @returns {boolean}
|
|
179
|
+
*/
|
|
180
|
+
export function isValidCommitHash(hash) {
|
|
181
|
+
return typeof hash === 'string' && /^[0-9a-f]{7,40}$/i.test(hash);
|
|
182
|
+
}
|
|
183
|
+
|
|
54
184
|
/**
|
|
55
185
|
* Get git diffs for a list of files.
|
|
186
|
+
* When commitHash is provided, diffs come from `git show <hash>` (parent vs hash);
|
|
187
|
+
* otherwise from working tree vs HEAD (default behavior).
|
|
188
|
+
*
|
|
56
189
|
* @param {string} cwd - working directory (git repo root)
|
|
57
190
|
* @param {string[]} files - relative file paths
|
|
191
|
+
* @param {string} [commitHash] - optional commit SHA to diff against its first parent
|
|
58
192
|
* @returns {Promise<Array>} diffs array
|
|
59
193
|
*/
|
|
60
|
-
export async function getGitDiffs(cwd, files) {
|
|
194
|
+
export async function getGitDiffs(cwd, files, commitHash) {
|
|
195
|
+
const useCommit = commitHash && isValidCommitHash(commitHash);
|
|
61
196
|
const diffs = [];
|
|
62
197
|
|
|
198
|
+
// For commit-context diffs, get the per-file status table once instead of per file.
|
|
199
|
+
let commitStatusMap = null;
|
|
200
|
+
if (useCommit) {
|
|
201
|
+
try {
|
|
202
|
+
const { stdout } = await execFileAsync(
|
|
203
|
+
'git',
|
|
204
|
+
['diff-tree', '-r', '--no-commit-id', '--name-status', '--root', commitHash],
|
|
205
|
+
{ cwd, encoding: 'utf-8', timeout: 5000, maxBuffer: 5 * 1024 * 1024 }
|
|
206
|
+
);
|
|
207
|
+
commitStatusMap = new Map();
|
|
208
|
+
for (const line of stdout.split('\n')) {
|
|
209
|
+
if (!line.trim()) continue;
|
|
210
|
+
const tab = line.indexOf('\t');
|
|
211
|
+
if (tab < 0) continue;
|
|
212
|
+
const st = line.substring(0, tab).trim();
|
|
213
|
+
const fp = line.substring(tab + 1);
|
|
214
|
+
commitStatusMap.set(fp, st[0] || 'M');
|
|
215
|
+
}
|
|
216
|
+
} catch {
|
|
217
|
+
commitStatusMap = new Map();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
63
221
|
for (const file of files) {
|
|
64
222
|
// 安全检查:防止路径穿越
|
|
65
|
-
if (
|
|
223
|
+
if (PATH_TRAVERSAL.test(file) || file.startsWith('/')) continue;
|
|
66
224
|
|
|
67
225
|
try {
|
|
68
|
-
|
|
69
|
-
|
|
226
|
+
let status;
|
|
227
|
+
let is_new;
|
|
228
|
+
let is_deleted;
|
|
70
229
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
230
|
+
if (useCommit) {
|
|
231
|
+
status = commitStatusMap.get(file) || 'M';
|
|
232
|
+
is_new = status === 'A';
|
|
233
|
+
is_deleted = status === 'D';
|
|
234
|
+
} else {
|
|
235
|
+
const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain', '--', file], { cwd, encoding: 'utf-8', timeout: 3000 });
|
|
236
|
+
if (!statusOutput.trim()) continue;
|
|
237
|
+
status = statusOutput.substring(0, 2).trim();
|
|
238
|
+
is_new = status === 'A' || status === '??';
|
|
239
|
+
is_deleted = status === 'D';
|
|
240
|
+
}
|
|
74
241
|
|
|
75
242
|
// 检查是否为二进制文件(已删除文件跳过)
|
|
76
243
|
let is_binary = false;
|
|
77
244
|
if (!is_deleted) {
|
|
78
245
|
try {
|
|
79
|
-
const
|
|
246
|
+
const numstatArgs = useCommit
|
|
247
|
+
? ['diff-tree', '-r', '--no-commit-id', '--numstat', '--root', commitHash, '--', file]
|
|
248
|
+
: ['diff', '--numstat', 'HEAD', '--', file];
|
|
249
|
+
const { stdout: diffCheck } = await execFileAsync('git', numstatArgs, { cwd, encoding: 'utf-8', timeout: 3000 });
|
|
80
250
|
if (diffCheck.includes('-\t-\t')) {
|
|
81
251
|
is_binary = true;
|
|
82
252
|
}
|
|
@@ -87,31 +257,45 @@ export async function getGitDiffs(cwd, files) {
|
|
|
87
257
|
let new_content = '';
|
|
88
258
|
|
|
89
259
|
if (!is_binary) {
|
|
90
|
-
//
|
|
260
|
+
// 获取旧内容
|
|
91
261
|
if (!is_new) {
|
|
92
262
|
try {
|
|
93
|
-
const
|
|
263
|
+
const oldRef = useCommit ? `${commitHash}^:${file}` : `HEAD:${file}`;
|
|
264
|
+
const { stdout } = await execFileAsync('git', ['show', oldRef], { cwd, encoding: 'utf-8', timeout: 5000, maxBuffer: 5 * 1024 * 1024 });
|
|
94
265
|
old_content = stdout;
|
|
95
266
|
} catch {
|
|
96
267
|
old_content = '';
|
|
97
268
|
}
|
|
98
269
|
}
|
|
99
270
|
|
|
100
|
-
//
|
|
271
|
+
// 获取新内容
|
|
101
272
|
if (!is_deleted) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
diffs.push({ file, status, is_large: true, size: stat.size });
|
|
273
|
+
if (useCommit) {
|
|
274
|
+
try {
|
|
275
|
+
const { stdout } = await execFileAsync('git', ['show', `${commitHash}:${file}`], { cwd, encoding: 'utf-8', timeout: 5000, maxBuffer: 5 * 1024 * 1024 });
|
|
276
|
+
new_content = stdout;
|
|
277
|
+
if (Buffer.byteLength(new_content, 'utf-8') > 5 * 1024 * 1024) {
|
|
278
|
+
diffs.push({ file, status, is_large: true, size: Buffer.byteLength(new_content, 'utf-8') });
|
|
109
279
|
continue;
|
|
110
280
|
}
|
|
111
|
-
|
|
281
|
+
} catch {
|
|
282
|
+
new_content = '';
|
|
283
|
+
}
|
|
284
|
+
} else {
|
|
285
|
+
try {
|
|
286
|
+
const filePath = join(cwd, file);
|
|
287
|
+
if (existsSync(filePath)) {
|
|
288
|
+
const stat = statSync(filePath);
|
|
289
|
+
if (stat.size > 5 * 1024 * 1024) {
|
|
290
|
+
// 文件过大
|
|
291
|
+
diffs.push({ file, status, is_large: true, size: stat.size });
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
new_content = readFileSync(filePath, 'utf-8');
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
new_content = '';
|
|
112
298
|
}
|
|
113
|
-
} catch {
|
|
114
|
-
new_content = '';
|
|
115
299
|
}
|
|
116
300
|
}
|
|
117
301
|
}
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -42,7 +42,7 @@ import { checkAndUpdate } from './lib/updater.js';
|
|
|
42
42
|
import { loadPlugins, runWaterfallHook, runParallelHook, getPluginsInfo, getPluginsDir } from './lib/plugin-loader.js';
|
|
43
43
|
import { uploadPlugins, installPluginFromUrl } from './lib/plugin-manager.js';
|
|
44
44
|
import { getUserProfile } from './lib/user-profile.js';
|
|
45
|
-
import { getGitDiffs, countUntrackedLines } from './lib/git-diff.js';
|
|
45
|
+
import { getGitDiffs, countUntrackedLines, getUnpushedCommits, isValidCommitHash } from './lib/git-diff.js';
|
|
46
46
|
import { CONTEXT_WINDOW_FILE, readModelContextSize, buildContextWindowEvent, getContextSizeForModel } from './lib/context-watcher.js';
|
|
47
47
|
import { watchLogFile, startWatching, getWatchedFiles, sendEventToClients, sendToClients } from './lib/log-watcher.js';
|
|
48
48
|
import { isMainAgentEntry, extractCachedContent } from './lib/kv-cache-analyzer.js';
|
|
@@ -3147,7 +3147,10 @@ async function handleRequest(req, res) {
|
|
|
3147
3147
|
}
|
|
3148
3148
|
|
|
3149
3149
|
const files = filesParam.split(',').map(f => f.trim()).filter(Boolean);
|
|
3150
|
-
const
|
|
3150
|
+
const commitParam = parsedUrl.searchParams.get('commit');
|
|
3151
|
+
// Reject malformed commit hashes; falsy = working-tree mode
|
|
3152
|
+
const commitHash = commitParam && isValidCommitHash(commitParam) ? commitParam : undefined;
|
|
3153
|
+
const diffs = await getGitDiffs(cwd, files, commitHash);
|
|
3151
3154
|
|
|
3152
3155
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3153
3156
|
res.end(JSON.stringify({ diffs }));
|
|
@@ -3158,6 +3161,27 @@ async function handleRequest(req, res) {
|
|
|
3158
3161
|
return;
|
|
3159
3162
|
}
|
|
3160
3163
|
|
|
3164
|
+
// Local commits ahead of upstream (origin/<branch>..HEAD).
|
|
3165
|
+
// Silent empty result when no upstream / detached HEAD — by design.
|
|
3166
|
+
if (url.startsWith('/api/git-log-unpushed') && method === 'GET') {
|
|
3167
|
+
try {
|
|
3168
|
+
const repoParam = parsedUrl.searchParams.get('repo');
|
|
3169
|
+
const cwd = resolveRepoCwd(repoParam);
|
|
3170
|
+
if (!cwd) {
|
|
3171
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
3172
|
+
res.end(JSON.stringify({ error: 'Invalid repo parameter', commits: [], hasUpstream: false }));
|
|
3173
|
+
return;
|
|
3174
|
+
}
|
|
3175
|
+
const result = await getUnpushedCommits(cwd);
|
|
3176
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3177
|
+
res.end(JSON.stringify(result));
|
|
3178
|
+
} catch (err) {
|
|
3179
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
3180
|
+
res.end(JSON.stringify({ error: err.message, commits: [], hasUpstream: false }));
|
|
3181
|
+
}
|
|
3182
|
+
return;
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3161
3185
|
// 插件管理 API
|
|
3162
3186
|
if (url === '/api/plugins' && method === 'GET') {
|
|
3163
3187
|
const plugins = getPluginsInfo();
|