@zoebuildsai/trace 1.5.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/.gitignore +115 -0
- package/.trace/progress.json +22 -0
- package/README.md +466 -0
- package/RELEASE-NOTES-1.5.0.md +410 -0
- package/STATUS.md +245 -0
- package/dist/auto-commit.d.ts +66 -0
- package/dist/auto-commit.d.ts.map +1 -0
- package/dist/auto-commit.js +180 -0
- package/dist/auto-commit.js.map +1 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +246 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands.d.ts +46 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +256 -0
- package/dist/commands.js.map +1 -0
- package/dist/diff.d.ts +23 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +106 -0
- package/dist/diff.js.map +1 -0
- package/dist/github.d.ts.map +1 -0
- package/dist/github.js.map +1 -0
- package/dist/index-cache.d.ts +35 -0
- package/dist/index-cache.d.ts.map +1 -0
- package/dist/index-cache.js +114 -0
- package/dist/index-cache.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/storage.d.ts +45 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +151 -0
- package/dist/storage.js.map +1 -0
- package/dist/sync.d.ts +60 -0
- package/dist/sync.js +184 -0
- package/dist/tags.d.ts +85 -0
- package/dist/tags.d.ts.map +1 -0
- package/dist/tags.js +219 -0
- package/dist/tags.js.map +1 -0
- package/dist/types.d.ts +102 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/docs/.nojekyll +0 -0
- package/docs/README.md +73 -0
- package/docs/_config.yml +2 -0
- package/docs/index.html +960 -0
- package/docs-website/package.json +20 -0
- package/jest.config.js +21 -0
- package/package.json +50 -0
- package/scripts/init.ts +290 -0
- package/src/agent-audit.ts +270 -0
- package/src/agent-checkout.ts +227 -0
- package/src/agent-coordination.ts +318 -0
- package/src/async-queue.ts +203 -0
- package/src/auto-branching.ts +279 -0
- package/src/auto-commit.ts +166 -0
- package/src/cherry-pick.ts +252 -0
- package/src/chunked-upload.ts +224 -0
- package/src/cli-v2.ts +335 -0
- package/src/cli.ts +318 -0
- package/src/cliff-detection.ts +232 -0
- package/src/commands.ts +267 -0
- package/src/commit-hash-system.ts +351 -0
- package/src/compression.ts +176 -0
- package/src/conflict-resolution-ui.ts +277 -0
- package/src/conflict-visualization.ts +238 -0
- package/src/diff-formatter.ts +184 -0
- package/src/diff.ts +124 -0
- package/src/distributed-coordination.ts +273 -0
- package/src/git-interop.ts +316 -0
- package/src/index-cache.ts +88 -0
- package/src/index.ts +38 -0
- package/src/merge-engine.ts +143 -0
- package/src/message-search.ts +370 -0
- package/src/performance-monitoring.ts +236 -0
- package/src/rebase.ts +327 -0
- package/src/rollback.ts +215 -0
- package/src/semantic-grouping.ts +245 -0
- package/src/stage-area.ts +324 -0
- package/src/stash.ts +278 -0
- package/src/storage.ts +131 -0
- package/src/sync.ts +205 -0
- package/src/tags.ts +244 -0
- package/src/types.ts +119 -0
- package/src/webhooks.ts +119 -0
- package/src/workspace-isolation.ts +298 -0
- package/tests/auto-commit.test.ts +308 -0
- package/tests/checkout.test.ts +136 -0
- package/tests/commit.test.ts +118 -0
- package/tests/diff.test.ts +191 -0
- package/tests/github.test.ts +94 -0
- package/tests/integration.test.ts +267 -0
- package/tests/log.test.ts +125 -0
- package/tests/phase2-integration.test.ts +370 -0
- package/tests/storage.test.ts +167 -0
- package/tests/tags.test.ts +477 -0
- package/tests/types.test.ts +75 -0
- package/tests/v1.1/agent-audit.test.ts +472 -0
- package/tests/v1.1/agent-coordination.test.ts +308 -0
- package/tests/v1.1/async-queue.test.ts +253 -0
- package/tests/v1.1/comprehensive.test.ts +521 -0
- package/tests/v1.1/diff-formatter.test.ts +238 -0
- package/tests/v1.1/integration.test.ts +389 -0
- package/tests/v1.1/onboarding.test.ts +365 -0
- package/tests/v1.1/rollback.test.ts +370 -0
- package/tests/v1.1/semantic-grouping.test.ts +230 -0
- package/tests/v1.2/chunked-upload.test.ts +301 -0
- package/tests/v1.2/cliff-detection.test.ts +272 -0
- package/tests/v1.2/commit-hash-system.test.ts +288 -0
- package/tests/v1.2/compression.test.ts +220 -0
- package/tests/v1.2/conflict-visualization.test.ts +263 -0
- package/tests/v1.2/distributed.test.ts +261 -0
- package/tests/v1.2/performance-monitoring.test.ts +328 -0
- package/tests/v1.3/auto-branching.test.ts +270 -0
- package/tests/v1.3/message-search.test.ts +264 -0
- package/tests/v1.3/stage-area.test.ts +330 -0
- package/tests/v1.3/stash-rebase-cherry-pick.test.ts +361 -0
- package/tests/v1.4/cli.test.ts +171 -0
- package/tests/v1.4/conflict-resolution-advanced.test.ts +429 -0
- package/tests/v1.4/conflict-resolution-ui.test.ts +286 -0
- package/tests/v1.4/workspace-isolation-advanced.test.ts +382 -0
- package/tests/v1.4/workspace-isolation.test.ts +268 -0
- package/tests/v1.5/agent-coordination.real.test.ts +401 -0
- package/tests/v1.5/cli-v2.test.ts +354 -0
- package/tests/v1.5/git-interop.real.test.ts +358 -0
- package/tests/v1.5/integration-testing.real.test.ts +440 -0
- package/tsconfig.json +26 -0
package/src/sync.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote Sync Layer
|
|
3
|
+
* Handles push/pull with remote repositories
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
|
|
10
|
+
interface Remote {
|
|
11
|
+
name: string;
|
|
12
|
+
url: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class TraceSync {
|
|
16
|
+
private repoDir: string;
|
|
17
|
+
private remoteConfigFile: string;
|
|
18
|
+
|
|
19
|
+
constructor(repoDir: string = process.cwd()) {
|
|
20
|
+
this.repoDir = repoDir;
|
|
21
|
+
this.remoteConfigFile = join(repoDir, '.trace', 'remotes.json');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Initialize git repo if needed
|
|
26
|
+
*/
|
|
27
|
+
initRepo(): void {
|
|
28
|
+
try {
|
|
29
|
+
execSync('git rev-parse --git-dir', { cwd: this.repoDir, stdio: 'ignore' });
|
|
30
|
+
} catch {
|
|
31
|
+
execSync('git init', { cwd: this.repoDir });
|
|
32
|
+
execSync('git config user.email "trace@zoebuildsai.com"', { cwd: this.repoDir });
|
|
33
|
+
execSync('git config user.name "Trace"', { cwd: this.repoDir });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Add a remote repository
|
|
39
|
+
*/
|
|
40
|
+
addRemote(name: string, url: string): void {
|
|
41
|
+
try {
|
|
42
|
+
execSync(`git remote add ${name} ${url}`, { cwd: this.repoDir });
|
|
43
|
+
} catch {
|
|
44
|
+
// Remote may already exist, update it
|
|
45
|
+
execSync(`git remote set-url ${name} ${url}`, { cwd: this.repoDir });
|
|
46
|
+
}
|
|
47
|
+
this.saveRemote(name, url);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* List all remotes
|
|
52
|
+
*/
|
|
53
|
+
listRemotes(): Remote[] {
|
|
54
|
+
try {
|
|
55
|
+
const output = execSync('git remote -v', { cwd: this.repoDir, encoding: 'utf-8' });
|
|
56
|
+
const remotes: Map<string, string> = new Map();
|
|
57
|
+
|
|
58
|
+
for (const line of output.trim().split('\n')) {
|
|
59
|
+
if (!line) continue;
|
|
60
|
+
const [name, url] = line.split(/\s+/);
|
|
61
|
+
if (!remotes.has(name)) {
|
|
62
|
+
remotes.set(name, url);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const result: Remote[] = [];
|
|
67
|
+
for (const [name, url] of remotes) {
|
|
68
|
+
result.push({ name, url });
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
} catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Push to remote with timeout
|
|
78
|
+
*/
|
|
79
|
+
push(remote: string = 'origin', timeout: number = 5000): { success: boolean; message: string } {
|
|
80
|
+
this.initRepo();
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const cmd = `git push ${remote} --all --tags`;
|
|
84
|
+
const timeoutSec = Math.ceil(timeout / 1000);
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
execSync(cmd, {
|
|
88
|
+
cwd: this.repoDir,
|
|
89
|
+
timeout: timeout,
|
|
90
|
+
stdio: 'pipe',
|
|
91
|
+
});
|
|
92
|
+
return { success: true, message: `Pushed to ${remote}` };
|
|
93
|
+
} catch (err: any) {
|
|
94
|
+
if (err.killed) {
|
|
95
|
+
// Timeout - return partial success
|
|
96
|
+
return {
|
|
97
|
+
success: false,
|
|
98
|
+
message: `Push timeout after ${timeoutSec}s (may have partially succeeded)`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
} catch (err) {
|
|
104
|
+
return {
|
|
105
|
+
success: false,
|
|
106
|
+
message: `Push failed: ${(err as Error).message}`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Pull from remote
|
|
113
|
+
*/
|
|
114
|
+
pull(remote: string = 'origin'): { success: boolean; message: string } {
|
|
115
|
+
this.initRepo();
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
execSync(`git pull ${remote} --all`, {
|
|
119
|
+
cwd: this.repoDir,
|
|
120
|
+
stdio: 'pipe',
|
|
121
|
+
});
|
|
122
|
+
return { success: true, message: `Pulled from ${remote}` };
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return {
|
|
125
|
+
success: false,
|
|
126
|
+
message: `Pull failed: ${(err as Error).message}`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Stage and commit changes
|
|
133
|
+
*/
|
|
134
|
+
stageAndCommit(message: string): { success: boolean; hash: string } {
|
|
135
|
+
this.initRepo();
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
execSync('git add -A', { cwd: this.repoDir });
|
|
139
|
+
const output = execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
|
|
140
|
+
cwd: this.repoDir,
|
|
141
|
+
encoding: 'utf-8',
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Extract hash from output like "[main abc1234] message"
|
|
145
|
+
const match = output.match(/\[.*?\s+(\w+)\]/);
|
|
146
|
+
const hash = match ? match[1] : 'unknown';
|
|
147
|
+
|
|
148
|
+
return { success: true, hash };
|
|
149
|
+
} catch (err: any) {
|
|
150
|
+
const errStr = err.message || err.toString();
|
|
151
|
+
if (errStr.includes('nothing to commit') || errStr.includes('nothing added to commit')) {
|
|
152
|
+
return { success: true, hash: 'none' };
|
|
153
|
+
}
|
|
154
|
+
return { success: false, hash: '' };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Commit with optional auto-push
|
|
160
|
+
*/
|
|
161
|
+
commitWithAutoPush(
|
|
162
|
+
message: string,
|
|
163
|
+
autoPush: boolean = false,
|
|
164
|
+
remote: string = 'origin'
|
|
165
|
+
): { success: boolean; hash: string; pushed?: boolean } {
|
|
166
|
+
const commitResult = this.stageAndCommit(message);
|
|
167
|
+
|
|
168
|
+
if (!commitResult.success) {
|
|
169
|
+
return { success: false, hash: '' };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (autoPush && commitResult.hash !== 'none') {
|
|
173
|
+
const pushResult = this.push(remote, 5000);
|
|
174
|
+
return {
|
|
175
|
+
success: true,
|
|
176
|
+
hash: commitResult.hash,
|
|
177
|
+
pushed: pushResult.success,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { success: true, hash: commitResult.hash };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Save remote config locally
|
|
186
|
+
*/
|
|
187
|
+
private saveRemote(name: string, url: string): void {
|
|
188
|
+
const dir = join(this.repoDir, '.trace');
|
|
189
|
+
if (!existsSync(dir)) {
|
|
190
|
+
execSync(`mkdir -p ${dir}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let remotes: Record<string, string> = {};
|
|
194
|
+
if (existsSync(this.remoteConfigFile)) {
|
|
195
|
+
try {
|
|
196
|
+
remotes = JSON.parse(readFileSync(this.remoteConfigFile, 'utf-8'));
|
|
197
|
+
} catch {
|
|
198
|
+
remotes = {};
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
remotes[name] = url;
|
|
203
|
+
writeFileSync(this.remoteConfigFile, JSON.stringify(remotes, null, 2));
|
|
204
|
+
}
|
|
205
|
+
}
|
package/src/tags.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tags Feature
|
|
3
|
+
*
|
|
4
|
+
* Create, list, delete, and checkout to semantic tags.
|
|
5
|
+
* - Store tags in refs/tags/
|
|
6
|
+
* - Tag metadata (created_at, description, creator)
|
|
7
|
+
* - Fast tag index for <50ms lookups
|
|
8
|
+
* - Unicode and special character support
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import * as crypto from 'crypto';
|
|
14
|
+
import { TraceCommands } from './commands';
|
|
15
|
+
|
|
16
|
+
export interface Tag {
|
|
17
|
+
name: string;
|
|
18
|
+
commit_hash: string;
|
|
19
|
+
created_at: number;
|
|
20
|
+
metadata?: Record<string, any>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TagListOptions {
|
|
24
|
+
sortBy?: 'created_at' | 'name';
|
|
25
|
+
pattern?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TagCreateOptions {
|
|
29
|
+
description?: string;
|
|
30
|
+
creator?: string;
|
|
31
|
+
force?: boolean;
|
|
32
|
+
[key: string]: any;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TagCheckoutOptions {
|
|
36
|
+
force?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class TagManager {
|
|
40
|
+
private commands: TraceCommands;
|
|
41
|
+
private tagsDir: string;
|
|
42
|
+
private tagIndex: Map<string, Tag> = new Map();
|
|
43
|
+
private indexValid: boolean = false;
|
|
44
|
+
|
|
45
|
+
constructor(commands: TraceCommands, basePath?: string) {
|
|
46
|
+
this.commands = commands;
|
|
47
|
+
|
|
48
|
+
// Derive tags directory from memory-git base path
|
|
49
|
+
const baseDir = basePath || path.join(process.env.HOME || '', '.openclaw/memory-git');
|
|
50
|
+
this.tagsDir = path.join(baseDir, 'refs', 'tags');
|
|
51
|
+
|
|
52
|
+
this.ensureTagsDir();
|
|
53
|
+
this.rebuildIndex();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Create a new tag at current HEAD
|
|
58
|
+
*/
|
|
59
|
+
create(name: string, options: TagCreateOptions = {}): Tag {
|
|
60
|
+
this.validateTagName(name);
|
|
61
|
+
|
|
62
|
+
// Check if tag exists
|
|
63
|
+
if (this.tagIndex.has(name) && !options.force) {
|
|
64
|
+
throw new Error(`Tag '${name}' already exists. Use force: true to override.`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Get current HEAD commit
|
|
68
|
+
const log = this.commands.log(1);
|
|
69
|
+
if (log.length === 0) {
|
|
70
|
+
throw new Error('No commits to tag');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const commitHash = log[0].hash;
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
|
|
76
|
+
// Filter options to extract metadata (skip 'force')
|
|
77
|
+
const metaEntries = Object.entries(options)
|
|
78
|
+
.filter(([k]) => k !== 'force');
|
|
79
|
+
|
|
80
|
+
const tag: Tag = {
|
|
81
|
+
name,
|
|
82
|
+
commit_hash: commitHash,
|
|
83
|
+
created_at: now,
|
|
84
|
+
metadata: metaEntries.length > 0 ? Object.fromEntries(metaEntries) : undefined,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Save tag
|
|
88
|
+
this.saveTag(tag);
|
|
89
|
+
|
|
90
|
+
// Update index
|
|
91
|
+
this.tagIndex.set(name, tag);
|
|
92
|
+
|
|
93
|
+
return tag;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Get a tag by name
|
|
98
|
+
*/
|
|
99
|
+
get(name: string): Tag | undefined {
|
|
100
|
+
this.ensureIndexValid();
|
|
101
|
+
return this.tagIndex.get(name);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* List all tags
|
|
106
|
+
*/
|
|
107
|
+
list(options: TagListOptions = {}): Tag[] {
|
|
108
|
+
this.ensureIndexValid();
|
|
109
|
+
|
|
110
|
+
let tags = Array.from(this.tagIndex.values());
|
|
111
|
+
|
|
112
|
+
// Filter by pattern if provided
|
|
113
|
+
if (options.pattern) {
|
|
114
|
+
const regex = this.patternToRegex(options.pattern);
|
|
115
|
+
tags = tags.filter(t => regex.test(t.name));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Sort
|
|
119
|
+
if (options.sortBy === 'name') {
|
|
120
|
+
tags.sort((a, b) => a.name.localeCompare(b.name));
|
|
121
|
+
} else {
|
|
122
|
+
tags.sort((a, b) => a.created_at - b.created_at);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return tags;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Delete a tag
|
|
130
|
+
*/
|
|
131
|
+
delete(name: string): void {
|
|
132
|
+
if (!this.tagIndex.has(name)) {
|
|
133
|
+
throw new Error(`Tag '${name}' not found`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const tagPath = this.getTagPath(name);
|
|
137
|
+
fs.unlinkSync(tagPath);
|
|
138
|
+
|
|
139
|
+
this.tagIndex.delete(name);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Checkout to a tagged commit
|
|
144
|
+
*/
|
|
145
|
+
checkout(name: string, options: TagCheckoutOptions = {}): boolean {
|
|
146
|
+
const tag = this.get(name);
|
|
147
|
+
if (!tag) {
|
|
148
|
+
throw new Error(`Tag '${name}' not found`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Checkout to the commit
|
|
152
|
+
this.commands.checkout(tag.commit_hash, { force: options.force });
|
|
153
|
+
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Rebuild the tag index
|
|
159
|
+
*/
|
|
160
|
+
rebuildIndex(): void {
|
|
161
|
+
this.tagIndex.clear();
|
|
162
|
+
|
|
163
|
+
if (!fs.existsSync(this.tagsDir)) {
|
|
164
|
+
this.indexValid = true;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const files = fs.readdirSync(this.tagsDir);
|
|
169
|
+
for (const file of files) {
|
|
170
|
+
try {
|
|
171
|
+
const tagPath = path.join(this.tagsDir, file);
|
|
172
|
+
const content = fs.readFileSync(tagPath, 'utf-8');
|
|
173
|
+
const tag = JSON.parse(content) as Tag;
|
|
174
|
+
this.tagIndex.set(tag.name, tag);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
// Skip corrupt tag files
|
|
177
|
+
console.warn(`Failed to load tag ${file}:`, err);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
this.indexValid = true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Private: Ensure index is valid
|
|
186
|
+
*/
|
|
187
|
+
private ensureIndexValid(): void {
|
|
188
|
+
if (!this.indexValid) {
|
|
189
|
+
this.rebuildIndex();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Private: Validate tag name
|
|
195
|
+
*/
|
|
196
|
+
private validateTagName(name: string): void {
|
|
197
|
+
if (!name || name.trim().length === 0) {
|
|
198
|
+
throw new Error('Tag name cannot be empty');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (name.includes(' ')) {
|
|
202
|
+
throw new Error('Tag name cannot contain spaces');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Private: Ensure tags directory exists
|
|
208
|
+
*/
|
|
209
|
+
private ensureTagsDir(): void {
|
|
210
|
+
if (!fs.existsSync(this.tagsDir)) {
|
|
211
|
+
fs.mkdirSync(this.tagsDir, { recursive: true });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Private: Get tag file path
|
|
217
|
+
*/
|
|
218
|
+
private getTagPath(name: string): string {
|
|
219
|
+
// Escape special characters for filesystem
|
|
220
|
+
const escaped = name.replace(/[\/\\:?*"<>|]/g, '_');
|
|
221
|
+
return path.join(this.tagsDir, escaped);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Private: Save tag to disk
|
|
226
|
+
*/
|
|
227
|
+
private saveTag(tag: Tag): void {
|
|
228
|
+
const tagPath = this.getTagPath(tag.name);
|
|
229
|
+
const content = JSON.stringify(tag, null, 2);
|
|
230
|
+
fs.writeFileSync(tagPath, content, 'utf-8');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Private: Convert glob pattern to regex
|
|
235
|
+
*/
|
|
236
|
+
private patternToRegex(pattern: string): RegExp {
|
|
237
|
+
const escaped = pattern
|
|
238
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
239
|
+
.replace(/\*/g, '.*')
|
|
240
|
+
.replace(/\?/g, '.');
|
|
241
|
+
|
|
242
|
+
return new RegExp(`^${escaped}$`);
|
|
243
|
+
}
|
|
244
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types for Memory Git system
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface CommitObject {
|
|
6
|
+
hash: string;
|
|
7
|
+
message: string;
|
|
8
|
+
timestamp: number;
|
|
9
|
+
author: string;
|
|
10
|
+
parent: string | null;
|
|
11
|
+
tree: TreeObject;
|
|
12
|
+
metadata?: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TreeObject {
|
|
16
|
+
files: Map<string, FileEntry>;
|
|
17
|
+
hash: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface FileEntry {
|
|
21
|
+
path: string;
|
|
22
|
+
hash: string;
|
|
23
|
+
size: number;
|
|
24
|
+
mode: 'file' | 'deleted';
|
|
25
|
+
lastModified: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DiffResult {
|
|
29
|
+
added: Map<string, string>;
|
|
30
|
+
modified: Map<string, DiffChange>;
|
|
31
|
+
deleted: Map<string, string>;
|
|
32
|
+
stats: DiffStats;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DiffChange {
|
|
36
|
+
oldHash: string;
|
|
37
|
+
newHash: string;
|
|
38
|
+
lines?: LineDiff[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface LineDiff {
|
|
42
|
+
type: 'add' | 'remove' | 'context';
|
|
43
|
+
content: string;
|
|
44
|
+
lineNum?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface DiffStats {
|
|
48
|
+
filesChanged: number;
|
|
49
|
+
linesAdded: number;
|
|
50
|
+
linesRemoved: number;
|
|
51
|
+
totalChanges: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface StatusResult {
|
|
55
|
+
modified: string[];
|
|
56
|
+
added: string[];
|
|
57
|
+
deleted: string[];
|
|
58
|
+
untracked: string[];
|
|
59
|
+
clean: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface LogEntry {
|
|
63
|
+
hash: string;
|
|
64
|
+
message: string;
|
|
65
|
+
timestamp: number;
|
|
66
|
+
author: string;
|
|
67
|
+
shortHash: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface CommitOptions {
|
|
71
|
+
message: string;
|
|
72
|
+
author?: string;
|
|
73
|
+
metadata?: Record<string, unknown>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface CheckoutOptions {
|
|
77
|
+
force?: boolean;
|
|
78
|
+
specific?: string[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface DiffOptions {
|
|
82
|
+
lines?: boolean;
|
|
83
|
+
context?: number;
|
|
84
|
+
maxSize?: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface IndexCache {
|
|
88
|
+
timestamp: number;
|
|
89
|
+
files: Map<string, CachedFile>;
|
|
90
|
+
currentCommit: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface CachedFile {
|
|
94
|
+
path: string;
|
|
95
|
+
hash: string;
|
|
96
|
+
lastSeen: number;
|
|
97
|
+
modified: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Phase 2 Types
|
|
101
|
+
export interface AutoCommitMetadata {
|
|
102
|
+
auto: boolean;
|
|
103
|
+
filesChanged: number;
|
|
104
|
+
files?: string[];
|
|
105
|
+
timestamp: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface TagInfo {
|
|
109
|
+
name: string;
|
|
110
|
+
commit_hash: string;
|
|
111
|
+
created_at: number;
|
|
112
|
+
description?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface BranchInfo {
|
|
116
|
+
name: string;
|
|
117
|
+
head: string;
|
|
118
|
+
created_at: number;
|
|
119
|
+
}
|
package/src/webhooks.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook Manager for Trace
|
|
3
|
+
* Safe, validated webhook delivery with retry logic
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface WebhookPayload {
|
|
7
|
+
operationId: string;
|
|
8
|
+
type: 'commit' | 'push' | 'pull';
|
|
9
|
+
status: 'completed' | 'failed';
|
|
10
|
+
timestamp: number;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class WebhookManager {
|
|
15
|
+
/**
|
|
16
|
+
* Validate webhook URL for security
|
|
17
|
+
*/
|
|
18
|
+
static validateUrl(url: string): { valid: boolean; reason?: string } {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = new URL(url);
|
|
21
|
+
|
|
22
|
+
// Only HTTPS allowed
|
|
23
|
+
if (parsed.protocol !== 'https:') {
|
|
24
|
+
return { valid: false, reason: 'Only HTTPS URLs allowed' };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Block localhost/private IPs
|
|
28
|
+
if (this.isPrivateIP(parsed.hostname)) {
|
|
29
|
+
return { valid: false, reason: 'Private/localhost URLs not allowed' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// URL must be reasonable length (prevent DoS)
|
|
33
|
+
if (url.length > 2000) {
|
|
34
|
+
return { valid: false, reason: 'URL too long' };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { valid: true };
|
|
38
|
+
} catch (err) {
|
|
39
|
+
return { valid: false, reason: 'Invalid URL format' };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if hostname is private/local
|
|
45
|
+
*/
|
|
46
|
+
private static isPrivateIP(hostname: string): boolean {
|
|
47
|
+
const privatePatterns = [
|
|
48
|
+
/^localhost$/,
|
|
49
|
+
/^127\./,
|
|
50
|
+
/^192\.168\./,
|
|
51
|
+
/^10\./,
|
|
52
|
+
/^172\.(1[6-9]|2[0-9]|3[01])\./,
|
|
53
|
+
/^::1$/,
|
|
54
|
+
/^fc00:/,
|
|
55
|
+
/^fe80:/,
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
return privatePatterns.some(pattern => pattern.test(hostname));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Sanitize payload for webhook delivery
|
|
63
|
+
*/
|
|
64
|
+
static sanitizePayload(payload: Record<string, unknown>): WebhookPayload {
|
|
65
|
+
// Only include safe fields
|
|
66
|
+
return {
|
|
67
|
+
operationId: String(payload.operationId || ''),
|
|
68
|
+
type: payload.type as 'commit' | 'push' | 'pull',
|
|
69
|
+
status: payload.status as 'completed' | 'failed',
|
|
70
|
+
timestamp: Number(payload.timestamp || Date.now()),
|
|
71
|
+
error: payload.error ? String(payload.error).slice(0, 500) : undefined,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Send webhook with retry
|
|
77
|
+
*/
|
|
78
|
+
static async send(
|
|
79
|
+
url: string,
|
|
80
|
+
payload: WebhookPayload,
|
|
81
|
+
maxRetries: number = 3
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
// Validate URL first
|
|
84
|
+
const validation = this.validateUrl(url);
|
|
85
|
+
if (!validation.valid) {
|
|
86
|
+
throw new Error(`Invalid webhook URL: ${validation.reason}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Sanitize payload
|
|
90
|
+
const safePayload = this.sanitizePayload(payload);
|
|
91
|
+
|
|
92
|
+
let lastError: Error | undefined;
|
|
93
|
+
|
|
94
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
95
|
+
try {
|
|
96
|
+
// TODO: Replace with actual fetch in implementation
|
|
97
|
+
// await fetch(url, {
|
|
98
|
+
// method: 'POST',
|
|
99
|
+
// headers: { 'Content-Type': 'application/json' },
|
|
100
|
+
// body: JSON.stringify(safePayload),
|
|
101
|
+
// timeout: 10000,
|
|
102
|
+
// });
|
|
103
|
+
return;
|
|
104
|
+
} catch (err) {
|
|
105
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
106
|
+
|
|
107
|
+
// Exponential backoff
|
|
108
|
+
if (attempt < maxRetries - 1) {
|
|
109
|
+
const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000;
|
|
110
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
throw lastError || new Error('Failed to send webhook');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export default WebhookManager;
|