clay-server 3.3.0-beta.1 → 3.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/git-cli.js +370 -0
- package/lib/git-session-attribution.js +219 -0
- package/lib/project-connection.js +1 -15
- package/lib/project-email.js +0 -61
- package/lib/project-http.js +104 -1
- package/lib/project-sessions.js +1 -11
- package/lib/project-user-message.js +2 -0
- package/lib/project.js +18 -5
- package/lib/public/app.js +14 -2
- package/lib/public/css/filebrowser.css +320 -0
- package/lib/public/css/git-panel.css +424 -0
- package/lib/public/css/input.css +0 -3
- package/lib/public/index.html +12 -15
- package/lib/public/modules/app-messages.js +1 -5
- package/lib/public/modules/context-sources.js +0 -90
- package/lib/public/modules/filebrowser.js +135 -21
- package/lib/public/modules/git-panel.js +456 -0
- package/lib/public/modules/input.js +8 -0
- package/lib/public/modules/markdown-slides.js +292 -0
- package/lib/public/modules/mate-sidebar.js +0 -7
- package/lib/public/modules/sidebar.js +42 -0
- package/lib/public/modules/tool-palette.js +1 -2
- package/lib/public/style.css +1 -0
- package/lib/sdk-bridge.js +46 -5
- package/lib/sdk-message-processor.js +26 -2
- package/lib/sessions.js +1 -1
- package/lib/yoke/adapters/claude.js +43 -17
- package/lib/yoke/index.js +19 -0
- package/package.json +1 -1
package/lib/git-cli.js
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// Git CLI integration for project status, safe common actions, and file diffs.
|
|
2
|
+
|
|
3
|
+
var fs = require("fs");
|
|
4
|
+
var path = require("path");
|
|
5
|
+
var { execFile, execFileSync } = require("child_process");
|
|
6
|
+
var { wrapSpawnAsUser } = require("./os-users");
|
|
7
|
+
|
|
8
|
+
var MAX_DIFF_BYTES = 2 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
function gitEnvironment(osUserInfo) {
|
|
11
|
+
var overrides = { GIT_TERMINAL_PROMPT: "0" };
|
|
12
|
+
if (osUserInfo && osUserInfo.home) overrides.HOME = osUserInfo.home;
|
|
13
|
+
if (osUserInfo && osUserInfo.user) {
|
|
14
|
+
overrides.USER = osUserInfo.user;
|
|
15
|
+
overrides.LOGNAME = osUserInfo.user;
|
|
16
|
+
}
|
|
17
|
+
return Object.assign({}, process.env, overrides);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function runGitSync(cwd, args, options, osUserInfo) {
|
|
21
|
+
var opts = Object.assign({
|
|
22
|
+
cwd: cwd,
|
|
23
|
+
encoding: "utf8",
|
|
24
|
+
timeout: 5000,
|
|
25
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
26
|
+
stdio: "pipe",
|
|
27
|
+
env: gitEnvironment(osUserInfo),
|
|
28
|
+
}, options || {});
|
|
29
|
+
if (osUserInfo) {
|
|
30
|
+
opts.uid = osUserInfo.uid;
|
|
31
|
+
opts.gid = osUserInfo.gid;
|
|
32
|
+
}
|
|
33
|
+
var wrapped = wrapSpawnAsUser("git", args, opts);
|
|
34
|
+
return execFileSync(wrapped.command, wrapped.args, wrapped.options);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function runGit(cwd, args, timeout, osUserInfo) {
|
|
38
|
+
return new Promise(function (resolve, reject) {
|
|
39
|
+
var opts = {
|
|
40
|
+
cwd: cwd,
|
|
41
|
+
encoding: "utf8",
|
|
42
|
+
timeout: timeout || 30000,
|
|
43
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
44
|
+
env: gitEnvironment(osUserInfo),
|
|
45
|
+
};
|
|
46
|
+
if (osUserInfo) {
|
|
47
|
+
opts.uid = osUserInfo.uid;
|
|
48
|
+
opts.gid = osUserInfo.gid;
|
|
49
|
+
}
|
|
50
|
+
var wrapped = wrapSpawnAsUser("git", args, opts);
|
|
51
|
+
execFile(wrapped.command, wrapped.args, wrapped.options, function (err, stdout, stderr) {
|
|
52
|
+
if (err) {
|
|
53
|
+
var detail = String(stderr || stdout || err.message || "Git command failed").trim();
|
|
54
|
+
err.userMessage = detail;
|
|
55
|
+
reject(err);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
resolve(String(stdout || stderr || "").trim());
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseBranchHeader(record, result) {
|
|
64
|
+
var space = record.indexOf(" ", 2);
|
|
65
|
+
if (space === -1) return;
|
|
66
|
+
var key = record.slice(2, space);
|
|
67
|
+
var value = record.slice(space + 1);
|
|
68
|
+
if (key === "branch.oid") result.oid = value;
|
|
69
|
+
else if (key === "branch.head") result.branch = value;
|
|
70
|
+
else if (key === "branch.upstream") result.upstream = value;
|
|
71
|
+
else if (key === "branch.ab") {
|
|
72
|
+
var match = value.match(/^\+(\d+) -(\d+)$/);
|
|
73
|
+
if (match) {
|
|
74
|
+
result.ahead = parseInt(match[1], 10);
|
|
75
|
+
result.behind = parseInt(match[2], 10);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseTrackedRecord(record, kind) {
|
|
81
|
+
var pattern = kind === "2"
|
|
82
|
+
? /^2 ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$/
|
|
83
|
+
: /^1 ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$/;
|
|
84
|
+
var match = record.match(pattern);
|
|
85
|
+
if (!match) return null;
|
|
86
|
+
var xy = match[1];
|
|
87
|
+
return {
|
|
88
|
+
path: match[match.length - 1],
|
|
89
|
+
originalPath: null,
|
|
90
|
+
code: xy,
|
|
91
|
+
staged: xy.charAt(0) !== ".",
|
|
92
|
+
unstaged: xy.charAt(1) !== ".",
|
|
93
|
+
untracked: false,
|
|
94
|
+
conflicted: false,
|
|
95
|
+
kind: kind === "2" ? "renamed" : "changed",
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseUnmergedRecord(record) {
|
|
100
|
+
var match = record.match(/^u ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$/);
|
|
101
|
+
if (!match) return null;
|
|
102
|
+
return {
|
|
103
|
+
path: match[match.length - 1],
|
|
104
|
+
originalPath: null,
|
|
105
|
+
code: match[1],
|
|
106
|
+
staged: true,
|
|
107
|
+
unstaged: true,
|
|
108
|
+
untracked: false,
|
|
109
|
+
conflicted: true,
|
|
110
|
+
kind: "conflicted",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parsePorcelainV2(raw) {
|
|
115
|
+
var result = {
|
|
116
|
+
oid: null,
|
|
117
|
+
branch: null,
|
|
118
|
+
upstream: null,
|
|
119
|
+
ahead: 0,
|
|
120
|
+
behind: 0,
|
|
121
|
+
files: [],
|
|
122
|
+
};
|
|
123
|
+
var records = String(raw || "").split("\0");
|
|
124
|
+
for (var i = 0; i < records.length; i++) {
|
|
125
|
+
var record = records[i];
|
|
126
|
+
if (!record) continue;
|
|
127
|
+
if (record.indexOf("# ") === 0) {
|
|
128
|
+
parseBranchHeader(record, result);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (record.indexOf("? ") === 0) {
|
|
132
|
+
result.files.push({
|
|
133
|
+
path: record.slice(2), originalPath: null, code: "??",
|
|
134
|
+
staged: false, unstaged: true, untracked: true,
|
|
135
|
+
conflicted: false, kind: "untracked",
|
|
136
|
+
});
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (record.indexOf("u ") === 0) {
|
|
140
|
+
var conflict = parseUnmergedRecord(record);
|
|
141
|
+
if (conflict) result.files.push(conflict);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (record.indexOf("1 ") === 0 || record.indexOf("2 ") === 0) {
|
|
145
|
+
var kind = record.charAt(0);
|
|
146
|
+
var file = parseTrackedRecord(record, kind);
|
|
147
|
+
if (file && kind === "2" && i + 1 < records.length) {
|
|
148
|
+
file.originalPath = records[++i] || null;
|
|
149
|
+
}
|
|
150
|
+
if (file) result.files.push(file);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function normalizeGitPath(cwd, value) {
|
|
157
|
+
return path.normalize(path.isAbsolute(value) ? value : path.resolve(cwd, value));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function parseWorktrees(raw) {
|
|
161
|
+
var blocks = String(raw || "").trim().split(/\n\n+/);
|
|
162
|
+
var result = [];
|
|
163
|
+
for (var i = 0; i < blocks.length; i++) {
|
|
164
|
+
if (!blocks[i]) continue;
|
|
165
|
+
var lines = blocks[i].split("\n");
|
|
166
|
+
var item = { path: null, branch: null, head: null, bare: false, detached: false };
|
|
167
|
+
for (var j = 0; j < lines.length; j++) {
|
|
168
|
+
var line = lines[j];
|
|
169
|
+
if (line.indexOf("worktree ") === 0) item.path = line.slice(9);
|
|
170
|
+
else if (line.indexOf("HEAD ") === 0) item.head = line.slice(5);
|
|
171
|
+
else if (line.indexOf("branch ") === 0) item.branch = line.slice(7).replace(/^refs\/heads\//, "");
|
|
172
|
+
else if (line === "bare") item.bare = true;
|
|
173
|
+
else if (line === "detached") item.detached = true;
|
|
174
|
+
}
|
|
175
|
+
if (item.path) result.push(item);
|
|
176
|
+
}
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function getStatus(cwd, osUserInfo) {
|
|
181
|
+
try {
|
|
182
|
+
var inside = runGitSync(cwd, ["rev-parse", "--is-inside-work-tree"], null, osUserInfo).trim();
|
|
183
|
+
if (inside !== "true") return { isRepository: false };
|
|
184
|
+
} catch (e) {
|
|
185
|
+
return { isRepository: false };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
var root = runGitSync(cwd, ["rev-parse", "--show-toplevel"], null, osUserInfo).trim();
|
|
189
|
+
var raw = runGitSync(root, ["status", "--porcelain=v2", "--branch", "-z"], null, osUserInfo);
|
|
190
|
+
var parsed = parsePorcelainV2(raw);
|
|
191
|
+
var origin = null;
|
|
192
|
+
var gitDir = null;
|
|
193
|
+
var commonDir = null;
|
|
194
|
+
var worktrees = [];
|
|
195
|
+
try { origin = runGitSync(cwd, ["remote", "get-url", "origin"], null, osUserInfo).trim() || null; } catch (e) {}
|
|
196
|
+
try { gitDir = normalizeGitPath(cwd, runGitSync(cwd, ["rev-parse", "--absolute-git-dir"], null, osUserInfo).trim()); } catch (e) {}
|
|
197
|
+
try { commonDir = normalizeGitPath(cwd, runGitSync(cwd, ["rev-parse", "--git-common-dir"], null, osUserInfo).trim()); } catch (e) {}
|
|
198
|
+
try { worktrees = parseWorktrees(runGitSync(cwd, ["worktree", "list", "--porcelain"], null, osUserInfo)); } catch (e) {}
|
|
199
|
+
|
|
200
|
+
var normalizedRoot = path.normalize(root);
|
|
201
|
+
var currentWorktree = null;
|
|
202
|
+
for (var i = 0; i < worktrees.length; i++) {
|
|
203
|
+
if (path.normalize(worktrees[i].path) === normalizedRoot) {
|
|
204
|
+
currentWorktree = worktrees[i];
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
var detached = parsed.branch === "(detached)" || !!(currentWorktree && currentWorktree.detached);
|
|
209
|
+
return {
|
|
210
|
+
isRepository: true,
|
|
211
|
+
name: path.basename(root),
|
|
212
|
+
root: root,
|
|
213
|
+
branch: detached ? null : parsed.branch,
|
|
214
|
+
detached: detached,
|
|
215
|
+
oid: parsed.oid === "(initial)" ? null : parsed.oid,
|
|
216
|
+
upstream: parsed.upstream,
|
|
217
|
+
ahead: parsed.ahead,
|
|
218
|
+
behind: parsed.behind,
|
|
219
|
+
origin: origin,
|
|
220
|
+
gitDir: gitDir,
|
|
221
|
+
commonDir: commonDir,
|
|
222
|
+
isWorktree: !!(gitDir && commonDir && gitDir !== commonDir),
|
|
223
|
+
mainWorktree: worktrees.length > 0 ? worktrees[0].path : root,
|
|
224
|
+
worktrees: worktrees,
|
|
225
|
+
files: parsed.files,
|
|
226
|
+
dirty: parsed.files.length > 0,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function resolveChangedPaths(status, requestedPaths) {
|
|
231
|
+
if (!Array.isArray(requestedPaths) || requestedPaths.length === 0 || requestedPaths.length > 200) {
|
|
232
|
+
throw new Error("Select at least one changed file");
|
|
233
|
+
}
|
|
234
|
+
var byPath = {};
|
|
235
|
+
for (var i = 0; i < status.files.length; i++) byPath[status.files[i].path] = status.files[i];
|
|
236
|
+
var result = [];
|
|
237
|
+
var seen = {};
|
|
238
|
+
for (var j = 0; j < requestedPaths.length; j++) {
|
|
239
|
+
var requested = requestedPaths[j];
|
|
240
|
+
var entry = typeof requested === "string" ? byPath[requested] : null;
|
|
241
|
+
if (!entry) throw new Error("File is no longer changed: " + String(requested));
|
|
242
|
+
if (!seen[entry.path]) { result.push(entry.path); seen[entry.path] = true; }
|
|
243
|
+
if (entry.originalPath && !seen[entry.originalPath]) {
|
|
244
|
+
result.push(entry.originalPath);
|
|
245
|
+
seen[entry.originalPath] = true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function runAction(cwd, body, osUserInfo) {
|
|
252
|
+
var action = body && body.action;
|
|
253
|
+
var status = getStatus(cwd, osUserInfo);
|
|
254
|
+
if (!status.isRepository) return Promise.reject(new Error("This project is not a Git repository"));
|
|
255
|
+
|
|
256
|
+
if (action === "stage" || action === "unstage") {
|
|
257
|
+
var paths = resolveChangedPaths(status, body.paths);
|
|
258
|
+
if (action === "stage") return runGit(status.root, ["add", "-A", "--"].concat(paths), null, osUserInfo);
|
|
259
|
+
if (status.oid) return runGit(status.root, ["restore", "--staged", "--"].concat(paths), null, osUserInfo);
|
|
260
|
+
return runGit(status.root, ["rm", "--cached", "-r", "--"].concat(paths), null, osUserInfo);
|
|
261
|
+
}
|
|
262
|
+
if (action === "stage_all") return runGit(status.root, ["add", "-A"], null, osUserInfo);
|
|
263
|
+
if (action === "unstage_all") {
|
|
264
|
+
if (status.oid) return runGit(status.root, ["reset"], null, osUserInfo);
|
|
265
|
+
return runGit(status.root, ["rm", "--cached", "-r", "."], null, osUserInfo);
|
|
266
|
+
}
|
|
267
|
+
if (action === "pull") {
|
|
268
|
+
if (!status.upstream) return Promise.reject(new Error("The current branch has no upstream"));
|
|
269
|
+
return runGit(status.root, ["pull", "--ff-only"], 60000, osUserInfo);
|
|
270
|
+
}
|
|
271
|
+
if (action === "push") {
|
|
272
|
+
if (status.upstream) return runGit(status.root, ["push"], 60000, osUserInfo);
|
|
273
|
+
if (!status.origin || !status.branch) return Promise.reject(new Error("Set an origin and check out a branch before pushing"));
|
|
274
|
+
return runGit(status.root, ["push", "--set-upstream", "origin", status.branch], 60000, osUserInfo);
|
|
275
|
+
}
|
|
276
|
+
return Promise.reject(new Error("Unsupported Git action"));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function bufferIsBinary(buffer) {
|
|
280
|
+
var limit = Math.min(buffer.length, 8000);
|
|
281
|
+
for (var i = 0; i < limit; i++) {
|
|
282
|
+
if (buffer[i] === 0) return true;
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function getHeadFile(cwd, filePath, osUserInfo) {
|
|
288
|
+
try {
|
|
289
|
+
return runGitSync(cwd, ["show", "HEAD:" + filePath], { encoding: null, maxBuffer: MAX_DIFF_BYTES }, osUserInfo);
|
|
290
|
+
} catch (e) {
|
|
291
|
+
return Buffer.alloc(0);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function fileBufferResult(buffer) {
|
|
296
|
+
var binary = bufferIsBinary(buffer);
|
|
297
|
+
return { content: binary ? "" : buffer.toString("utf8"), binary: binary };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function readWorkingTreeFile(cwd, filePath) {
|
|
301
|
+
var root = path.resolve(cwd);
|
|
302
|
+
var absolutePath = path.resolve(root, filePath);
|
|
303
|
+
if (absolutePath !== root && absolutePath.indexOf(root + path.sep) !== 0) throw new Error("Invalid file path");
|
|
304
|
+
try {
|
|
305
|
+
var stat = fs.lstatSync(absolutePath);
|
|
306
|
+
if (stat.isSymbolicLink()) return fileBufferResult(Buffer.from(fs.readlinkSync(absolutePath), "utf8"));
|
|
307
|
+
if (!stat.isFile()) return { content: "", binary: false };
|
|
308
|
+
if (stat.size > MAX_DIFF_BYTES) throw new Error("File is too large to preview");
|
|
309
|
+
return fileBufferResult(fs.readFileSync(absolutePath));
|
|
310
|
+
} catch (e) {
|
|
311
|
+
if (e.code === "ENOENT") return { content: "", binary: false };
|
|
312
|
+
throw e;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function getFileAtCommit(cwd, commit, filePath, osUserInfo) {
|
|
317
|
+
if (!commit) return { content: "", binary: false };
|
|
318
|
+
try {
|
|
319
|
+
var buffer = runGitSync(cwd, ["show", commit + ":" + filePath], { encoding: null, maxBuffer: MAX_DIFF_BYTES }, osUserInfo);
|
|
320
|
+
return fileBufferResult(buffer);
|
|
321
|
+
} catch (e) {
|
|
322
|
+
return { content: "", binary: false };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function getFileDiff(cwd, requestedPath, osUserInfo) {
|
|
327
|
+
var status = getStatus(cwd, osUserInfo);
|
|
328
|
+
if (!status.isRepository) throw new Error("This project is not a Git repository");
|
|
329
|
+
var entry = null;
|
|
330
|
+
for (var i = 0; i < status.files.length; i++) {
|
|
331
|
+
if (status.files[i].path === requestedPath) { entry = status.files[i]; break; }
|
|
332
|
+
}
|
|
333
|
+
if (!entry) throw new Error("File is no longer changed");
|
|
334
|
+
|
|
335
|
+
var oldPath = entry.originalPath || entry.path;
|
|
336
|
+
var oldBuffer = getHeadFile(status.root, oldPath, osUserInfo);
|
|
337
|
+
var newBuffer = Buffer.alloc(0);
|
|
338
|
+
var absolutePath = path.resolve(status.root, entry.path);
|
|
339
|
+
var rootPrefix = path.resolve(status.root) + path.sep;
|
|
340
|
+
if (absolutePath.indexOf(rootPrefix) !== 0) throw new Error("Invalid changed file path");
|
|
341
|
+
try {
|
|
342
|
+
var stat = fs.lstatSync(absolutePath);
|
|
343
|
+
if (stat.isSymbolicLink()) {
|
|
344
|
+
newBuffer = Buffer.from(fs.readlinkSync(absolutePath), "utf8");
|
|
345
|
+
} else if (stat.isFile()) {
|
|
346
|
+
if (stat.size > MAX_DIFF_BYTES) throw new Error("File is too large to preview");
|
|
347
|
+
newBuffer = fs.readFileSync(absolutePath);
|
|
348
|
+
}
|
|
349
|
+
} catch (e) {
|
|
350
|
+
if (e.message === "File is too large to preview") throw e;
|
|
351
|
+
}
|
|
352
|
+
var binary = bufferIsBinary(oldBuffer) || bufferIsBinary(newBuffer);
|
|
353
|
+
return {
|
|
354
|
+
path: entry.path,
|
|
355
|
+
oldPath: oldPath,
|
|
356
|
+
oldContent: binary ? "" : oldBuffer.toString("utf8"),
|
|
357
|
+
newContent: binary ? "" : newBuffer.toString("utf8"),
|
|
358
|
+
binary: binary,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
module.exports = {
|
|
363
|
+
getFileAtCommit: getFileAtCommit,
|
|
364
|
+
getFileDiff: getFileDiff,
|
|
365
|
+
getStatus: getStatus,
|
|
366
|
+
parsePorcelainV2: parsePorcelainV2,
|
|
367
|
+
parseWorktrees: parseWorktrees,
|
|
368
|
+
readWorkingTreeFile: readWorkingTreeFile,
|
|
369
|
+
runAction: runAction,
|
|
370
|
+
};
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// Connect working-tree changes to the Clay sessions that were active while they changed.
|
|
2
|
+
|
|
3
|
+
var crypto = require("crypto");
|
|
4
|
+
var fs = require("fs");
|
|
5
|
+
var path = require("path");
|
|
6
|
+
var config = require("./config");
|
|
7
|
+
var gitCli = require("./git-cli");
|
|
8
|
+
var utils = require("./utils");
|
|
9
|
+
|
|
10
|
+
var MAX_BASELINE_BYTES = 2 * 1024 * 1024;
|
|
11
|
+
var MAX_DIGEST_BYTES = 8 * 1024 * 1024;
|
|
12
|
+
var MAX_SESSION_RECORDS = 80;
|
|
13
|
+
|
|
14
|
+
function attachGitSessionAttribution(options) {
|
|
15
|
+
var cwd = options.cwd;
|
|
16
|
+
var getOsUserInfoForSession = options.getOsUserInfoForSession || function () { return null; };
|
|
17
|
+
var storageDir = options.storageDir || path.join(config.CONFIG_DIR, "git-attribution");
|
|
18
|
+
var storagePath = path.join(storageDir, utils.encodeCwd(cwd) + ".json");
|
|
19
|
+
var state = loadState();
|
|
20
|
+
|
|
21
|
+
function loadState() {
|
|
22
|
+
try {
|
|
23
|
+
var parsed = JSON.parse(fs.readFileSync(storagePath, "utf8"));
|
|
24
|
+
if (parsed && parsed.version === 1 && parsed.sessions) return parsed;
|
|
25
|
+
} catch (e) {}
|
|
26
|
+
return { version: 1, sessions: {} };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function saveState() {
|
|
30
|
+
try {
|
|
31
|
+
fs.mkdirSync(storageDir, { recursive: true });
|
|
32
|
+
var keys = Object.keys(state.sessions).sort(function (left, right) {
|
|
33
|
+
return (state.sessions[right].lastChangedAt || 0) - (state.sessions[left].lastChangedAt || 0);
|
|
34
|
+
});
|
|
35
|
+
for (var i = MAX_SESSION_RECORDS; i < keys.length; i++) delete state.sessions[keys[i]];
|
|
36
|
+
var temporaryPath = storagePath + ".tmp." + process.pid;
|
|
37
|
+
fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2) + "\n");
|
|
38
|
+
if (process.platform !== "win32") {
|
|
39
|
+
try { fs.chmodSync(temporaryPath, 0o600); } catch (chmodError) {}
|
|
40
|
+
}
|
|
41
|
+
fs.renameSync(temporaryPath, storagePath);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
console.error("[git-attribution] Unable to save session change data:", e.message);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sessionKey(session) {
|
|
48
|
+
return session.cliSessionId || ("local:" + session.localId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function digestPath(root, file) {
|
|
52
|
+
var absolutePath = path.resolve(root, file.path);
|
|
53
|
+
var rootPrefix = path.resolve(root) + path.sep;
|
|
54
|
+
if (absolutePath.indexOf(rootPrefix) !== 0) return "invalid:" + file.code;
|
|
55
|
+
try {
|
|
56
|
+
var stat = fs.lstatSync(absolutePath);
|
|
57
|
+
var hash = crypto.createHash("sha256");
|
|
58
|
+
hash.update(file.code || "");
|
|
59
|
+
if (stat.isSymbolicLink()) hash.update(fs.readlinkSync(absolutePath));
|
|
60
|
+
else if (stat.isFile() && stat.size <= MAX_DIGEST_BYTES) hash.update(fs.readFileSync(absolutePath));
|
|
61
|
+
else if (stat.isFile()) hash.update("large:" + stat.size + ":" + stat.mtimeMs);
|
|
62
|
+
else hash.update("non-file");
|
|
63
|
+
return hash.digest("hex");
|
|
64
|
+
} catch (e) {
|
|
65
|
+
return "missing:" + (file.code || "");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readBaseline(root, file) {
|
|
70
|
+
var absolutePath = path.resolve(root, file.path);
|
|
71
|
+
var rootPrefix = path.resolve(root) + path.sep;
|
|
72
|
+
if (absolutePath.indexOf(rootPrefix) !== 0) return null;
|
|
73
|
+
try {
|
|
74
|
+
var stat = fs.lstatSync(absolutePath);
|
|
75
|
+
var buffer;
|
|
76
|
+
if (stat.isSymbolicLink()) buffer = Buffer.from(fs.readlinkSync(absolutePath), "utf8");
|
|
77
|
+
else if (stat.isFile() && stat.size <= MAX_BASELINE_BYTES) buffer = fs.readFileSync(absolutePath);
|
|
78
|
+
else return null;
|
|
79
|
+
return { content: buffer.toString("base64"), binary: buffer.indexOf(0) !== -1 };
|
|
80
|
+
} catch (e) {
|
|
81
|
+
return { missing: true };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function captureSnapshot(includeBaseline, osUserInfo) {
|
|
86
|
+
var status = gitCli.getStatus(cwd, osUserInfo);
|
|
87
|
+
if (!status.isRepository) return null;
|
|
88
|
+
var files = {};
|
|
89
|
+
for (var i = 0; i < status.files.length; i++) {
|
|
90
|
+
var file = status.files[i];
|
|
91
|
+
files[file.path] = {
|
|
92
|
+
fingerprint: digestPath(status.root, file),
|
|
93
|
+
baseline: includeBaseline ? readBaseline(status.root, file) : undefined,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return { root: status.root, head: status.oid || null, files: files, capturedAt: Date.now() };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function beginTurn(session) {
|
|
100
|
+
if (!session || session._gitAttributionTurn) return;
|
|
101
|
+
try {
|
|
102
|
+
var osUserInfo = getOsUserInfoForSession(session);
|
|
103
|
+
var snapshot = captureSnapshot(false, osUserInfo);
|
|
104
|
+
if (!snapshot) return;
|
|
105
|
+
var key = sessionKey(session);
|
|
106
|
+
var oldLocalKey = "local:" + session.localId;
|
|
107
|
+
if (key !== oldLocalKey && state.sessions[oldLocalKey] && !state.sessions[key]) {
|
|
108
|
+
state.sessions[key] = state.sessions[oldLocalKey];
|
|
109
|
+
delete state.sessions[oldLocalKey];
|
|
110
|
+
}
|
|
111
|
+
if (!state.sessions[key]) {
|
|
112
|
+
var baseline = captureSnapshot(true, osUserInfo);
|
|
113
|
+
state.sessions[key] = {
|
|
114
|
+
key: key,
|
|
115
|
+
title: session.title || "Untitled session",
|
|
116
|
+
vendor: session.vendor || null,
|
|
117
|
+
startedAt: Date.now(),
|
|
118
|
+
baseline: baseline,
|
|
119
|
+
changedPaths: {},
|
|
120
|
+
};
|
|
121
|
+
saveState();
|
|
122
|
+
}
|
|
123
|
+
session._gitAttributionTurn = snapshot;
|
|
124
|
+
session._gitAttributionOsUserInfo = osUserInfo;
|
|
125
|
+
} catch (e) {
|
|
126
|
+
console.error("[git-attribution] Unable to capture turn start:", e.message);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function finishTurn(session) {
|
|
131
|
+
if (!session || !session._gitAttributionTurn) return;
|
|
132
|
+
var before = session._gitAttributionTurn;
|
|
133
|
+
session._gitAttributionTurn = null;
|
|
134
|
+
try {
|
|
135
|
+
var after = captureSnapshot(false, session._gitAttributionOsUserInfo);
|
|
136
|
+
session._gitAttributionOsUserInfo = null;
|
|
137
|
+
if (!after) return;
|
|
138
|
+
var key = sessionKey(session);
|
|
139
|
+
var record = state.sessions[key] || state.sessions["local:" + session.localId];
|
|
140
|
+
if (!record) return;
|
|
141
|
+
record.title = session.title || record.title;
|
|
142
|
+
record.vendor = session.vendor || record.vendor;
|
|
143
|
+
var paths = {};
|
|
144
|
+
Object.keys(before.files).forEach(function (filePath) { paths[filePath] = true; });
|
|
145
|
+
Object.keys(after.files).forEach(function (filePath) { paths[filePath] = true; });
|
|
146
|
+
var changedAt = Date.now();
|
|
147
|
+
Object.keys(paths).forEach(function (filePath) {
|
|
148
|
+
var oldFingerprint = before.files[filePath] ? before.files[filePath].fingerprint : null;
|
|
149
|
+
var newFingerprint = after.files[filePath] ? after.files[filePath].fingerprint : null;
|
|
150
|
+
if (oldFingerprint !== newFingerprint) record.changedPaths[filePath] = changedAt;
|
|
151
|
+
});
|
|
152
|
+
record.lastChangedAt = changedAt;
|
|
153
|
+
saveState();
|
|
154
|
+
} catch (e) {
|
|
155
|
+
console.error("[git-attribution] Unable to capture turn result:", e.message);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function decorateStatus(status, sessions) {
|
|
160
|
+
if (!status || !status.isRepository) return status;
|
|
161
|
+
var liveByKey = {};
|
|
162
|
+
sessions.forEach(function (session) {
|
|
163
|
+
liveByKey[sessionKey(session)] = session;
|
|
164
|
+
liveByKey["local:" + session.localId] = session;
|
|
165
|
+
});
|
|
166
|
+
for (var i = 0; i < status.files.length; i++) {
|
|
167
|
+
var file = status.files[i];
|
|
168
|
+
var matches = [];
|
|
169
|
+
Object.keys(state.sessions).forEach(function (key) {
|
|
170
|
+
var record = state.sessions[key];
|
|
171
|
+
if (!record.changedPaths || !record.changedPaths[file.path]) return;
|
|
172
|
+
var live = liveByKey[key];
|
|
173
|
+
if (!live) return;
|
|
174
|
+
matches.push({
|
|
175
|
+
key: key,
|
|
176
|
+
sessionId: live ? live.localId : null,
|
|
177
|
+
title: live ? (live.title || record.title) : record.title,
|
|
178
|
+
vendor: record.vendor || null,
|
|
179
|
+
changedAt: record.changedPaths[file.path],
|
|
180
|
+
preExisting: !!(record.baseline && record.baseline.files && record.baseline.files[file.path]),
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
matches.sort(function (left, right) { return right.changedAt - left.changedAt; });
|
|
184
|
+
file.sessions = matches.slice(0, 4);
|
|
185
|
+
}
|
|
186
|
+
return status;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function getSessionBaselineDiff(key, filePath, osUserInfo) {
|
|
190
|
+
var record = state.sessions[key];
|
|
191
|
+
if (!record || !record.baseline) throw new Error("Session baseline is unavailable");
|
|
192
|
+
var current = gitCli.readWorkingTreeFile(record.baseline.root || cwd, filePath);
|
|
193
|
+
var saved = record.baseline.files && record.baseline.files[filePath];
|
|
194
|
+
var oldFile;
|
|
195
|
+
if (saved && saved.baseline) {
|
|
196
|
+
oldFile = saved.baseline.missing
|
|
197
|
+
? { content: "", binary: false }
|
|
198
|
+
: { content: Buffer.from(saved.baseline.content || "", "base64").toString("utf8"), binary: !!saved.baseline.binary };
|
|
199
|
+
} else {
|
|
200
|
+
oldFile = gitCli.getFileAtCommit(record.baseline.root || cwd, record.baseline.head, filePath, osUserInfo);
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
path: filePath,
|
|
204
|
+
oldContent: oldFile.binary ? "" : oldFile.content,
|
|
205
|
+
newContent: current.binary ? "" : current.content,
|
|
206
|
+
binary: oldFile.binary || current.binary,
|
|
207
|
+
sessionTitle: record.title,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
beginTurn: beginTurn,
|
|
213
|
+
decorateStatus: decorateStatus,
|
|
214
|
+
finishTurn: finishTurn,
|
|
215
|
+
getSessionBaselineDiff: getSessionBaselineDiff,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = { attachGitSessionAttribution: attachGitSessionAttribution };
|
|
@@ -272,22 +272,8 @@ function attachConnection(ctx) {
|
|
|
272
272
|
|
|
273
273
|
if (active && !ws._clayPane) {
|
|
274
274
|
userPresence.setPresence(slug, presenceKey, active.localId, storedPresence ? storedPresence.mateDm : null);
|
|
275
|
-
// For auto-created sessions, apply project email defaults
|
|
276
275
|
if (autoCreated) {
|
|
277
|
-
|
|
278
|
-
var _saveCtx = ctx.saveContextSources;
|
|
279
|
-
if (_emailMod && _emailMod.getEmailDefaults && _saveCtx) {
|
|
280
|
-
var emailDefs = _emailMod.getEmailDefaults();
|
|
281
|
-
if (emailDefs.length > 0) {
|
|
282
|
-
var defSources = emailDefs.map(function (id) { return "email:" + id; });
|
|
283
|
-
_saveCtx(slug, active.localId, defSources);
|
|
284
|
-
sendTo(ws, { type: "context_sources_state", active: defSources });
|
|
285
|
-
} else {
|
|
286
|
-
sendTo(ws, { type: "context_sources_state", active: [] });
|
|
287
|
-
}
|
|
288
|
-
} else {
|
|
289
|
-
sendTo(ws, { type: "context_sources_state", active: [] });
|
|
290
|
-
}
|
|
276
|
+
sendTo(ws, { type: "context_sources_state", active: [] });
|
|
291
277
|
}
|
|
292
278
|
}
|
|
293
279
|
if (storedPresence && storedPresence.mateDm && !isMate) {
|