claudedesk 1.0.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/LICENSE +21 -0
- package/README.md +431 -0
- package/config/repos.example.json +128 -0
- package/config/settings.example.json +64 -0
- package/config/skills/code-review.md +76 -0
- package/config/skills/full-check.md +26 -0
- package/config/skills/lint-fix.md +23 -0
- package/dist/api/agent-routes.d.ts +2 -0
- package/dist/api/agent-routes.d.ts.map +1 -0
- package/dist/api/agent-routes.js +251 -0
- package/dist/api/agent-routes.js.map +1 -0
- package/dist/api/app-routes.d.ts +2 -0
- package/dist/api/app-routes.d.ts.map +1 -0
- package/dist/api/app-routes.js +150 -0
- package/dist/api/app-routes.js.map +1 -0
- package/dist/api/docker-routes.d.ts +2 -0
- package/dist/api/docker-routes.d.ts.map +1 -0
- package/dist/api/docker-routes.js +167 -0
- package/dist/api/docker-routes.js.map +1 -0
- package/dist/api/middleware.d.ts +6 -0
- package/dist/api/middleware.d.ts.map +1 -0
- package/dist/api/middleware.js +293 -0
- package/dist/api/middleware.js.map +1 -0
- package/dist/api/pin-auth.d.ts +65 -0
- package/dist/api/pin-auth.d.ts.map +1 -0
- package/dist/api/pin-auth.js +218 -0
- package/dist/api/pin-auth.js.map +1 -0
- package/dist/api/routes.d.ts +2 -0
- package/dist/api/routes.d.ts.map +1 -0
- package/dist/api/routes.js +473 -0
- package/dist/api/routes.js.map +1 -0
- package/dist/api/settings-routes.d.ts +2 -0
- package/dist/api/settings-routes.d.ts.map +1 -0
- package/dist/api/settings-routes.js +570 -0
- package/dist/api/settings-routes.js.map +1 -0
- package/dist/api/skill-routes.d.ts +2 -0
- package/dist/api/skill-routes.d.ts.map +1 -0
- package/dist/api/skill-routes.js +88 -0
- package/dist/api/skill-routes.js.map +1 -0
- package/dist/api/terminal-routes.d.ts +2 -0
- package/dist/api/terminal-routes.d.ts.map +1 -0
- package/dist/api/terminal-routes.js +3524 -0
- package/dist/api/terminal-routes.js.map +1 -0
- package/dist/api/tunnel-routes.d.ts +2 -0
- package/dist/api/tunnel-routes.d.ts.map +1 -0
- package/dist/api/tunnel-routes.js +196 -0
- package/dist/api/tunnel-routes.js.map +1 -0
- package/dist/api/workspace-routes.d.ts +3 -0
- package/dist/api/workspace-routes.d.ts.map +1 -0
- package/dist/api/workspace-routes.js +649 -0
- package/dist/api/workspace-routes.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +276 -0
- package/dist/cli.js.map +1 -0
- package/dist/client/assets/index-B4r0njGe.js +780 -0
- package/dist/client/assets/index-CY_9MyE0.css +1 -0
- package/dist/client/favicon.svg +5 -0
- package/dist/client/icons/icon-192.svg +5 -0
- package/dist/client/icons/icon-512.svg +5 -0
- package/dist/client/icons/logo-with-message.png +0 -0
- package/dist/client/icons/logo.png +0 -0
- package/dist/client/index.html +25 -0
- package/dist/client/manifest.json +62 -0
- package/dist/client/sw.js +243 -0
- package/dist/config/agent-usage.d.ts +34 -0
- package/dist/config/agent-usage.d.ts.map +1 -0
- package/dist/config/agent-usage.js +87 -0
- package/dist/config/agent-usage.js.map +1 -0
- package/dist/config/repos.d.ts +34 -0
- package/dist/config/repos.d.ts.map +1 -0
- package/dist/config/repos.js +412 -0
- package/dist/config/repos.js.map +1 -0
- package/dist/config/settings.d.ts +634 -0
- package/dist/config/settings.d.ts.map +1 -0
- package/dist/config/settings.js +459 -0
- package/dist/config/settings.js.map +1 -0
- package/dist/config/skills.d.ts +18 -0
- package/dist/config/skills.d.ts.map +1 -0
- package/dist/config/skills.js +174 -0
- package/dist/config/skills.js.map +1 -0
- package/dist/config/workspaces.d.ts +961 -0
- package/dist/config/workspaces.d.ts.map +1 -0
- package/dist/config/workspaces.js +482 -0
- package/dist/config/workspaces.js.map +1 -0
- package/dist/core/app-manager.d.ts +85 -0
- package/dist/core/app-manager.d.ts.map +1 -0
- package/dist/core/app-manager.js +447 -0
- package/dist/core/app-manager.js.map +1 -0
- package/dist/core/claude-invoker.d.ts +49 -0
- package/dist/core/claude-invoker.d.ts.map +1 -0
- package/dist/core/claude-invoker.js +583 -0
- package/dist/core/claude-invoker.js.map +1 -0
- package/dist/core/claude-session-reader.d.ts +25 -0
- package/dist/core/claude-session-reader.d.ts.map +1 -0
- package/dist/core/claude-session-reader.js +184 -0
- package/dist/core/claude-session-reader.js.map +1 -0
- package/dist/core/claude-usage-query.d.ts +78 -0
- package/dist/core/claude-usage-query.d.ts.map +1 -0
- package/dist/core/claude-usage-query.js +294 -0
- package/dist/core/claude-usage-query.js.map +1 -0
- package/dist/core/git-credential-helper.d.ts +57 -0
- package/dist/core/git-credential-helper.d.ts.map +1 -0
- package/dist/core/git-credential-helper.js +176 -0
- package/dist/core/git-credential-helper.js.map +1 -0
- package/dist/core/git-sandbox.d.ts +135 -0
- package/dist/core/git-sandbox.d.ts.map +1 -0
- package/dist/core/git-sandbox.js +907 -0
- package/dist/core/git-sandbox.js.map +1 -0
- package/dist/core/github-integration.d.ts +66 -0
- package/dist/core/github-integration.d.ts.map +1 -0
- package/dist/core/github-integration.js +350 -0
- package/dist/core/github-integration.js.map +1 -0
- package/dist/core/github-oauth.d.ts +88 -0
- package/dist/core/github-oauth.d.ts.map +1 -0
- package/dist/core/github-oauth.js +244 -0
- package/dist/core/github-oauth.js.map +1 -0
- package/dist/core/gitlab-integration.d.ts +66 -0
- package/dist/core/gitlab-integration.d.ts.map +1 -0
- package/dist/core/gitlab-integration.js +353 -0
- package/dist/core/gitlab-integration.js.map +1 -0
- package/dist/core/gitlab-oauth.d.ts +100 -0
- package/dist/core/gitlab-oauth.d.ts.map +1 -0
- package/dist/core/gitlab-oauth.js +366 -0
- package/dist/core/gitlab-oauth.js.map +1 -0
- package/dist/core/insights-extractor.d.ts +68 -0
- package/dist/core/insights-extractor.d.ts.map +1 -0
- package/dist/core/insights-extractor.js +402 -0
- package/dist/core/insights-extractor.js.map +1 -0
- package/dist/core/logger.d.ts +27 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +70 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/process-runner.d.ts +27 -0
- package/dist/core/process-runner.d.ts.map +1 -0
- package/dist/core/process-runner.js +147 -0
- package/dist/core/process-runner.js.map +1 -0
- package/dist/core/project-detector.d.ts +30 -0
- package/dist/core/project-detector.d.ts.map +1 -0
- package/dist/core/project-detector.js +482 -0
- package/dist/core/project-detector.js.map +1 -0
- package/dist/core/qr-generator.d.ts +18 -0
- package/dist/core/qr-generator.d.ts.map +1 -0
- package/dist/core/qr-generator.js +61 -0
- package/dist/core/qr-generator.js.map +1 -0
- package/dist/core/remote-tunnel-manager.d.ts +59 -0
- package/dist/core/remote-tunnel-manager.d.ts.map +1 -0
- package/dist/core/remote-tunnel-manager.js +235 -0
- package/dist/core/remote-tunnel-manager.js.map +1 -0
- package/dist/core/shared-docker-manager.d.ts +41 -0
- package/dist/core/shared-docker-manager.d.ts.map +1 -0
- package/dist/core/shared-docker-manager.js +409 -0
- package/dist/core/shared-docker-manager.js.map +1 -0
- package/dist/core/skill-executor.d.ts +25 -0
- package/dist/core/skill-executor.d.ts.map +1 -0
- package/dist/core/skill-executor.js +171 -0
- package/dist/core/skill-executor.js.map +1 -0
- package/dist/core/terminal-session.d.ts +149 -0
- package/dist/core/terminal-session.d.ts.map +1 -0
- package/dist/core/terminal-session.js +2340 -0
- package/dist/core/terminal-session.js.map +1 -0
- package/dist/core/tunnel-manager.d.ts +35 -0
- package/dist/core/tunnel-manager.d.ts.map +1 -0
- package/dist/core/tunnel-manager.js +137 -0
- package/dist/core/tunnel-manager.js.map +1 -0
- package/dist/core/usage-manager.d.ts +57 -0
- package/dist/core/usage-manager.d.ts.map +1 -0
- package/dist/core/usage-manager.js +363 -0
- package/dist/core/usage-manager.js.map +1 -0
- package/dist/core/ws-manager.d.ts +39 -0
- package/dist/core/ws-manager.d.ts.map +1 -0
- package/dist/core/ws-manager.js +190 -0
- package/dist/core/ws-manager.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +229 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +868 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +119 -0
- package/dist/types.js.map +1 -0
- package/package.json +96 -0
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { writeFileSync, mkdirSync, existsSync, unlinkSync, readdirSync, statSync, rmSync, readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
// Windows reserved device names that cannot be used as filenames
|
|
5
|
+
// These cause git add to fail on Windows
|
|
6
|
+
const WINDOWS_RESERVED_NAMES = new Set([
|
|
7
|
+
'con', 'prn', 'aux', 'nul',
|
|
8
|
+
'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9',
|
|
9
|
+
'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9',
|
|
10
|
+
]);
|
|
11
|
+
/**
|
|
12
|
+
* Remove Windows reserved device name files from a directory (recursively)
|
|
13
|
+
* These files cannot be added to git on Windows
|
|
14
|
+
*/
|
|
15
|
+
function removeWindowsReservedFiles(dirPath) {
|
|
16
|
+
if (!existsSync(dirPath))
|
|
17
|
+
return;
|
|
18
|
+
try {
|
|
19
|
+
const entries = readdirSync(dirPath);
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
const fullPath = join(dirPath, entry);
|
|
22
|
+
// Check if this is a reserved name (case-insensitive, with or without extension)
|
|
23
|
+
const baseName = entry.split('.')[0].toLowerCase();
|
|
24
|
+
if (WINDOWS_RESERVED_NAMES.has(baseName)) {
|
|
25
|
+
console.log(`[GitSandbox] Removing Windows reserved file: ${fullPath}`);
|
|
26
|
+
try {
|
|
27
|
+
unlinkSync(fullPath);
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
console.warn(`[GitSandbox] Could not remove ${fullPath}: ${e instanceof Error ? e.message : e}`);
|
|
31
|
+
}
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
// Recurse into directories (but skip .git)
|
|
35
|
+
try {
|
|
36
|
+
const stat = statSync(fullPath);
|
|
37
|
+
if (stat.isDirectory() && entry !== '.git') {
|
|
38
|
+
removeWindowsReservedFiles(fullPath);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// statSync might fail on special files, skip them
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
console.warn(`[GitSandbox] Could not scan directory ${dirPath}: ${e instanceof Error ? e.message : e}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Common filler words to remove for more concise branch names
|
|
51
|
+
const FILLER_WORDS = new Set([
|
|
52
|
+
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
|
|
53
|
+
'of', 'with', 'by', 'from', 'as', 'is', 'was', 'are', 'were', 'been',
|
|
54
|
+
'be', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
|
|
55
|
+
'could', 'should', 'may', 'might', 'must', 'shall', 'can', 'need',
|
|
56
|
+
'that', 'this', 'these', 'those', 'i', 'you', 'we', 'they', 'it',
|
|
57
|
+
'please', 'make', 'sure', 'also', 'just', 'only', 'into', 'onto',
|
|
58
|
+
]);
|
|
59
|
+
/**
|
|
60
|
+
* Generate a slug from text for use in branch names
|
|
61
|
+
* e.g., "Add a login feature to the app" -> "add-login-feature-app"
|
|
62
|
+
* Removes filler words and limits to ~30 chars / 5 words max
|
|
63
|
+
*/
|
|
64
|
+
function slugify(text, maxWords = 5, maxLength = 30) {
|
|
65
|
+
const words = text
|
|
66
|
+
.toLowerCase()
|
|
67
|
+
.replace(/[^a-z0-9\s]/g, '') // Remove special chars
|
|
68
|
+
.split(/\s+/) // Split into words
|
|
69
|
+
.filter(word => word.length > 0 && !FILLER_WORDS.has(word));
|
|
70
|
+
// Take first N meaningful words
|
|
71
|
+
const slug = words
|
|
72
|
+
.slice(0, maxWords)
|
|
73
|
+
.join('-');
|
|
74
|
+
// Limit total length and clean up
|
|
75
|
+
return slug
|
|
76
|
+
.slice(0, maxLength)
|
|
77
|
+
.replace(/-$/, ''); // Remove trailing dash after slice
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Generate a branch name based on options
|
|
81
|
+
* Format: <prefix>/<summary> (e.g., claudedesk/fix-login-error)
|
|
82
|
+
* Falls back to <prefix>/<jobId-short> if no summary provided
|
|
83
|
+
*/
|
|
84
|
+
export function generateBranchName(options) {
|
|
85
|
+
const { prefix, summary, jobId } = options;
|
|
86
|
+
// Generate summary slug or use short job ID as fallback
|
|
87
|
+
const summarySlug = summary ? slugify(summary) : jobId.slice(0, 8);
|
|
88
|
+
// Use configured prefix, or default to 'claudedesk'
|
|
89
|
+
const branchPrefix = prefix || 'claudedesk';
|
|
90
|
+
return `${branchPrefix}/${summarySlug}`;
|
|
91
|
+
}
|
|
92
|
+
export class GitSandbox {
|
|
93
|
+
exec(command, cwd, timeoutMs = 30000) {
|
|
94
|
+
try {
|
|
95
|
+
return execSync(command, {
|
|
96
|
+
cwd,
|
|
97
|
+
encoding: 'utf-8',
|
|
98
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
99
|
+
timeout: timeoutMs,
|
|
100
|
+
}).trim();
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
const err = error;
|
|
104
|
+
if (err.killed) {
|
|
105
|
+
throw new Error(`Git command timed out: ${command}`);
|
|
106
|
+
}
|
|
107
|
+
throw new Error(`Git command failed: ${command}\n${err.stderr?.toString() || err.message}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Check if working directory is clean (ignoring claudedesk-specific files)
|
|
111
|
+
isClean(repoPath) {
|
|
112
|
+
const status = this.exec('git status --porcelain', repoPath);
|
|
113
|
+
if (status.length === 0)
|
|
114
|
+
return true;
|
|
115
|
+
// Files that ClaudeDesk can safely ignore
|
|
116
|
+
const ignoredFiles = ['CLAUDE.md', 'claude.md', '.claude', '.claudedesk'];
|
|
117
|
+
const lines = status.split('\n').filter(Boolean);
|
|
118
|
+
const significantChanges = lines.filter((line) => {
|
|
119
|
+
// Extract filename from status line (format: "XY filename" or "XY -> filename")
|
|
120
|
+
const filename = line.slice(3).split(' -> ').pop()?.trim() || '';
|
|
121
|
+
return !ignoredFiles.some((ignored) => filename === ignored || filename.endsWith(`/${ignored}`));
|
|
122
|
+
});
|
|
123
|
+
return significantChanges.length === 0;
|
|
124
|
+
}
|
|
125
|
+
// Get current branch name
|
|
126
|
+
getCurrentBranch(repoPath) {
|
|
127
|
+
return this.exec('git branch --show-current', repoPath);
|
|
128
|
+
}
|
|
129
|
+
// Create a sandbox branch for the job
|
|
130
|
+
// skipCleanCheck: set to true for retries to preserve partial work from failed jobs
|
|
131
|
+
createSandbox(repoPath, branchName, skipCleanCheck = false) {
|
|
132
|
+
console.log(`[GitSandbox] Creating sandbox branch "${branchName}" in ${repoPath}`);
|
|
133
|
+
// Check if repo is clean (ignoring claudedesk files like CLAUDE.md)
|
|
134
|
+
// Skip this check for retries to preserve partial work
|
|
135
|
+
if (!skipCleanCheck) {
|
|
136
|
+
console.log('[GitSandbox] Checking if repo is clean...');
|
|
137
|
+
if (!this.isClean(repoPath)) {
|
|
138
|
+
throw new Error('Working directory is not clean. Please commit or stash changes before running a job.');
|
|
139
|
+
}
|
|
140
|
+
console.log('[GitSandbox] Repo is clean');
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
console.log('[GitSandbox] Skipping clean check (retry mode - preserving partial work)');
|
|
144
|
+
}
|
|
145
|
+
console.log('[GitSandbox] Getting current branch...');
|
|
146
|
+
const previousBranch = this.getCurrentBranch(repoPath);
|
|
147
|
+
// Checkout main/master branch first
|
|
148
|
+
let mainBranch = 'main';
|
|
149
|
+
console.log('[GitSandbox] Checking out main branch...');
|
|
150
|
+
try {
|
|
151
|
+
this.exec('git checkout main', repoPath);
|
|
152
|
+
console.log('[GitSandbox] Checked out main');
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// Try master if main doesn't exist
|
|
156
|
+
console.log('[GitSandbox] main not found, trying master...');
|
|
157
|
+
try {
|
|
158
|
+
this.exec('git checkout master', repoPath);
|
|
159
|
+
mainBranch = 'master';
|
|
160
|
+
console.log('[GitSandbox] Checked out master');
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// Stay on current branch if neither exists
|
|
164
|
+
mainBranch = previousBranch;
|
|
165
|
+
console.log(`[GitSandbox] Staying on ${previousBranch}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Pull latest changes from remote (if remote exists)
|
|
169
|
+
// Use short timeout - if it hangs (e.g., waiting for credentials), skip it
|
|
170
|
+
console.log(`[GitSandbox] Pulling from origin ${mainBranch} (10s timeout)...`);
|
|
171
|
+
try {
|
|
172
|
+
this.exec(`git pull origin ${mainBranch}`, repoPath, 10000);
|
|
173
|
+
console.log('[GitSandbox] Pull completed');
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
// Ignore pull errors (might not have remote, or timed out waiting for credentials)
|
|
177
|
+
console.log(`[GitSandbox] Pull skipped: ${e instanceof Error ? e.message : e}`);
|
|
178
|
+
}
|
|
179
|
+
// Create new sandbox branch
|
|
180
|
+
console.log(`[GitSandbox] Creating branch ${branchName}...`);
|
|
181
|
+
this.exec(`git checkout -b ${branchName}`, repoPath);
|
|
182
|
+
console.log(`[GitSandbox] Branch created`);
|
|
183
|
+
return {
|
|
184
|
+
branch: branchName,
|
|
185
|
+
previousBranch,
|
|
186
|
+
clean: true,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
// Checkout an existing branch (for job retry - doesn't create new or pull from main)
|
|
190
|
+
checkoutExistingBranch(repoPath, branch) {
|
|
191
|
+
console.log(`[GitSandbox] Checking out existing branch "${branch}" in ${repoPath}`);
|
|
192
|
+
this.exec(`git checkout ${branch}`, repoPath);
|
|
193
|
+
console.log(`[GitSandbox] Checked out ${branch}`);
|
|
194
|
+
}
|
|
195
|
+
// Check if a branch exists in the repo
|
|
196
|
+
branchExists(repoPath, branch) {
|
|
197
|
+
try {
|
|
198
|
+
this.exec(`git rev-parse --verify ${branch}`, repoPath);
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Reset working directory and remove untracked files
|
|
206
|
+
resetAndClean(repoPath) {
|
|
207
|
+
console.log(`[GitSandbox] Resetting and cleaning working directory`);
|
|
208
|
+
this.exec('git reset --hard', repoPath);
|
|
209
|
+
try {
|
|
210
|
+
this.exec('git clean -fd', repoPath);
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
// git clean can fail on Windows with reserved device names (nul, con, prn, etc.)
|
|
214
|
+
console.warn(`[GitSandbox] git clean failed (non-fatal): ${e instanceof Error ? e.message : e}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// Check if there are any uncommitted changes (for detecting if Claude made changes)
|
|
218
|
+
hasChanges(repoPath) {
|
|
219
|
+
const status = this.exec('git status --porcelain', repoPath);
|
|
220
|
+
return status.length > 0;
|
|
221
|
+
}
|
|
222
|
+
// Get list of changed files
|
|
223
|
+
getChangedFiles(repoPath) {
|
|
224
|
+
const status = this.exec('git status --porcelain', repoPath);
|
|
225
|
+
if (!status)
|
|
226
|
+
return [];
|
|
227
|
+
return status.split('\n').filter(Boolean).map(line => {
|
|
228
|
+
// Extract filename from status line (format: "XY filename" or "XY -> filename")
|
|
229
|
+
return line.slice(3).split(' -> ').pop()?.trim() || '';
|
|
230
|
+
}).filter(Boolean);
|
|
231
|
+
}
|
|
232
|
+
// Capture diff and changed files
|
|
233
|
+
captureDiff(repoPath, artifactsPath) {
|
|
234
|
+
const patch = this.exec('git diff', repoPath);
|
|
235
|
+
const stagedPatch = this.exec('git diff --cached', repoPath);
|
|
236
|
+
const fullPatch = patch + (stagedPatch ? '\n' + stagedPatch : '');
|
|
237
|
+
const changedFilesOutput = this.exec('git diff --name-only', repoPath);
|
|
238
|
+
const stagedFilesOutput = this.exec('git diff --cached --name-only', repoPath);
|
|
239
|
+
const changedFiles = [...new Set([
|
|
240
|
+
...changedFilesOutput.split('\n').filter(Boolean),
|
|
241
|
+
...stagedFilesOutput.split('\n').filter(Boolean),
|
|
242
|
+
])];
|
|
243
|
+
// Save to artifacts
|
|
244
|
+
const gitDir = join(artifactsPath, 'git');
|
|
245
|
+
if (!existsSync(gitDir)) {
|
|
246
|
+
mkdirSync(gitDir, { recursive: true });
|
|
247
|
+
}
|
|
248
|
+
writeFileSync(join(gitDir, 'diff.patch'), fullPatch);
|
|
249
|
+
writeFileSync(join(gitDir, 'changed-files.json'), JSON.stringify(changedFiles, null, 2));
|
|
250
|
+
return { patch: fullPatch, changedFiles };
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Generate a patch file for all changes on a branch compared to main
|
|
254
|
+
* This includes both committed and uncommitted changes
|
|
255
|
+
*/
|
|
256
|
+
generatePatch(repoPath, branch) {
|
|
257
|
+
const mainBranch = this.getMainBranch(repoPath);
|
|
258
|
+
const targetBranch = branch || this.getCurrentBranch(repoPath);
|
|
259
|
+
// Get all committed changes on the branch compared to main
|
|
260
|
+
let patch = '';
|
|
261
|
+
try {
|
|
262
|
+
patch = this.exec(`git diff ${mainBranch}...${targetBranch}`, repoPath);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
// If branch comparison fails, just get the current diff
|
|
266
|
+
patch = this.exec('git diff HEAD', repoPath);
|
|
267
|
+
}
|
|
268
|
+
// Also include any uncommitted changes
|
|
269
|
+
const uncommitted = this.exec('git diff', repoPath);
|
|
270
|
+
const staged = this.exec('git diff --cached', repoPath);
|
|
271
|
+
if (uncommitted) {
|
|
272
|
+
patch += '\n# Uncommitted changes:\n' + uncommitted;
|
|
273
|
+
}
|
|
274
|
+
if (staged) {
|
|
275
|
+
patch += '\n# Staged changes:\n' + staged;
|
|
276
|
+
}
|
|
277
|
+
return patch;
|
|
278
|
+
}
|
|
279
|
+
// Check if a remote exists
|
|
280
|
+
hasRemote(repoPath, remoteName = 'origin') {
|
|
281
|
+
try {
|
|
282
|
+
const remotes = this.exec('git remote', repoPath);
|
|
283
|
+
return remotes.split('\n').includes(remoteName);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// Get the main branch name (main or master)
|
|
290
|
+
getMainBranch(repoPath) {
|
|
291
|
+
try {
|
|
292
|
+
this.exec('git rev-parse --verify main', repoPath);
|
|
293
|
+
return 'main';
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return 'master';
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// Check if a branch can be merged cleanly into main (no conflicts)
|
|
300
|
+
canMergeCleanly(repoPath, branch) {
|
|
301
|
+
const mainBranch = this.getMainBranch(repoPath);
|
|
302
|
+
const currentBranch = this.getCurrentBranch(repoPath);
|
|
303
|
+
try {
|
|
304
|
+
// Make sure we're on main branch
|
|
305
|
+
if (currentBranch !== mainBranch) {
|
|
306
|
+
this.exec(`git checkout ${mainBranch}`, repoPath);
|
|
307
|
+
}
|
|
308
|
+
// Try a dry-run merge
|
|
309
|
+
this.exec(`git merge --no-commit --no-ff ${branch}`, repoPath);
|
|
310
|
+
// If we get here, merge is clean - abort it
|
|
311
|
+
this.exec('git merge --abort', repoPath);
|
|
312
|
+
return { canMerge: true };
|
|
313
|
+
}
|
|
314
|
+
catch (e) {
|
|
315
|
+
// Merge failed - there are conflicts
|
|
316
|
+
// Try to get the list of conflicting files
|
|
317
|
+
let conflictFiles = [];
|
|
318
|
+
try {
|
|
319
|
+
const status = this.exec('git diff --name-only --diff-filter=U', repoPath);
|
|
320
|
+
conflictFiles = status.split('\n').filter(Boolean);
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// Ignore - we already know there's a conflict
|
|
324
|
+
}
|
|
325
|
+
// Abort the failed merge
|
|
326
|
+
try {
|
|
327
|
+
this.exec('git merge --abort', repoPath);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
// Reset if abort fails
|
|
331
|
+
try {
|
|
332
|
+
this.exec('git reset --hard HEAD', repoPath);
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
// Ignore
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// Restore original branch
|
|
339
|
+
if (currentBranch !== mainBranch) {
|
|
340
|
+
try {
|
|
341
|
+
this.exec(`git checkout ${currentBranch}`, repoPath);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
// Ignore
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return { canMerge: false, conflictFiles };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// Finalize the sandbox branch - commit changes and optionally push to origin
|
|
351
|
+
// Returns { pushed: boolean } indicating whether changes were pushed to remote
|
|
352
|
+
push(repoPath, branch, commitMessage) {
|
|
353
|
+
const currentBranch = this.getCurrentBranch(repoPath);
|
|
354
|
+
// Check if target branch exists - for create-repo workflows, the branch may not exist
|
|
355
|
+
// In that case, push the current branch instead
|
|
356
|
+
let branchToPush = branch;
|
|
357
|
+
if (currentBranch !== branch) {
|
|
358
|
+
if (this.branchExists(repoPath, branch)) {
|
|
359
|
+
this.exec(`git checkout ${branch}`, repoPath);
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
console.log(`[GitSandbox] Branch ${branch} does not exist, pushing current branch ${currentBranch} instead`);
|
|
363
|
+
branchToPush = currentBranch;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// Remove Windows reserved device name files before staging (they cause git add to fail)
|
|
367
|
+
removeWindowsReservedFiles(repoPath);
|
|
368
|
+
// Stage all changes
|
|
369
|
+
this.exec('git add -A', repoPath);
|
|
370
|
+
// Check if there are changes to commit
|
|
371
|
+
if (!this.isClean(repoPath)) {
|
|
372
|
+
const message = commitMessage || 'ClaudeDesk: automated changes';
|
|
373
|
+
// Escape double quotes in commit message
|
|
374
|
+
const escapedMessage = message.replace(/"/g, '\\"');
|
|
375
|
+
this.exec(`git commit -m "${escapedMessage}"`, repoPath);
|
|
376
|
+
console.log(`[GitSandbox] Changes committed to branch ${branchToPush}`);
|
|
377
|
+
}
|
|
378
|
+
// Check if origin remote exists before pushing
|
|
379
|
+
if (this.hasRemote(repoPath, 'origin')) {
|
|
380
|
+
console.log(`[GitSandbox] Pushing branch ${branchToPush} to origin...`);
|
|
381
|
+
this.exec(`git push -u origin ${branchToPush}`, repoPath, 60000); // 60s timeout for push
|
|
382
|
+
console.log(`[GitSandbox] Branch pushed successfully`);
|
|
383
|
+
return { pushed: true };
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
console.log(`[GitSandbox] No origin remote configured, skipping push`);
|
|
387
|
+
return { pushed: false };
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// Discard the sandbox branch
|
|
391
|
+
discard(repoPath, branch, previousBranch) {
|
|
392
|
+
// Reset any changes
|
|
393
|
+
this.exec('git reset --hard', repoPath);
|
|
394
|
+
try {
|
|
395
|
+
this.exec('git clean -fd', repoPath);
|
|
396
|
+
}
|
|
397
|
+
catch (e) {
|
|
398
|
+
// git clean can fail on Windows with reserved device names (nul, con, prn, etc.)
|
|
399
|
+
console.warn(`[GitSandbox] git clean failed (non-fatal): ${e instanceof Error ? e.message : e}`);
|
|
400
|
+
}
|
|
401
|
+
// Checkout previous branch (non-fatal - just need to not be on the branch we're deleting)
|
|
402
|
+
try {
|
|
403
|
+
if (previousBranch && previousBranch !== branch) {
|
|
404
|
+
this.exec(`git checkout ${previousBranch}`, repoPath);
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
try {
|
|
408
|
+
this.exec('git checkout main', repoPath);
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
this.exec('git checkout master', repoPath);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
catch (e) {
|
|
416
|
+
// If we can't checkout another branch, that's okay - branch deletion might still work
|
|
417
|
+
console.warn(`[GitSandbox] checkout failed (non-fatal): ${e instanceof Error ? e.message : e}`);
|
|
418
|
+
}
|
|
419
|
+
// Delete the sandbox branch
|
|
420
|
+
try {
|
|
421
|
+
this.exec(`git branch -D ${branch}`, repoPath);
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
// Branch might not exist, ignore
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
// Initialize a new repo
|
|
428
|
+
init(repoPath) {
|
|
429
|
+
if (!existsSync(repoPath)) {
|
|
430
|
+
mkdirSync(repoPath, { recursive: true });
|
|
431
|
+
}
|
|
432
|
+
this.exec('git init', repoPath);
|
|
433
|
+
this.exec('git checkout -b main', repoPath);
|
|
434
|
+
}
|
|
435
|
+
// =========== WORKTREE METHODS ===========
|
|
436
|
+
/**
|
|
437
|
+
* Create a new worktree for isolated job execution
|
|
438
|
+
* @param repoPath - Path to the main repository
|
|
439
|
+
* @param worktreePath - Path where the worktree should be created
|
|
440
|
+
* @param branchName - Name of the branch to create
|
|
441
|
+
* @returns WorktreeResult with paths and branch info
|
|
442
|
+
*/
|
|
443
|
+
createWorktree(repoPath, worktreePath, branchName) {
|
|
444
|
+
console.log(`[GitSandbox] Creating worktree at "${worktreePath}" from ${repoPath}`);
|
|
445
|
+
// First, ensure we're on main/master and up to date in the main repo
|
|
446
|
+
let mainBranch = 'main';
|
|
447
|
+
try {
|
|
448
|
+
this.exec('git checkout main', repoPath);
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
try {
|
|
452
|
+
this.exec('git checkout master', repoPath);
|
|
453
|
+
mainBranch = 'master';
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
// Stay on current branch if neither exists
|
|
457
|
+
mainBranch = this.getCurrentBranch(repoPath);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
// Pull latest changes from remote (if remote exists)
|
|
461
|
+
// Use short timeout - if it hangs (e.g., waiting for credentials), skip it
|
|
462
|
+
console.log(`[GitSandbox] Pulling from origin ${mainBranch} (10s timeout)...`);
|
|
463
|
+
try {
|
|
464
|
+
this.exec(`git pull origin ${mainBranch}`, repoPath, 10000);
|
|
465
|
+
console.log('[GitSandbox] Pull completed');
|
|
466
|
+
}
|
|
467
|
+
catch (e) {
|
|
468
|
+
console.log(`[GitSandbox] Pull skipped: ${e instanceof Error ? e.message : e}`);
|
|
469
|
+
}
|
|
470
|
+
// Clean up stale worktree entries (directories that no longer exist)
|
|
471
|
+
// This is necessary before deleting branches that were checked out in those worktrees
|
|
472
|
+
try {
|
|
473
|
+
this.exec('git worktree prune', repoPath);
|
|
474
|
+
}
|
|
475
|
+
catch (e) {
|
|
476
|
+
console.warn(`[GitSandbox] Worktree prune failed: ${e instanceof Error ? e.message : e}`);
|
|
477
|
+
}
|
|
478
|
+
// Check if worktree directory already exists and remove it
|
|
479
|
+
if (existsSync(worktreePath)) {
|
|
480
|
+
console.log(`[GitSandbox] Worktree path ${worktreePath} already exists, removing...`);
|
|
481
|
+
try {
|
|
482
|
+
this.exec(`git worktree remove "${worktreePath}" --force`, repoPath);
|
|
483
|
+
}
|
|
484
|
+
catch (e) {
|
|
485
|
+
console.warn(`[GitSandbox] Failed to remove existing worktree via git: ${e instanceof Error ? e.message : e}`);
|
|
486
|
+
}
|
|
487
|
+
// Prune again after removal
|
|
488
|
+
try {
|
|
489
|
+
this.exec('git worktree prune', repoPath);
|
|
490
|
+
}
|
|
491
|
+
catch (e) {
|
|
492
|
+
console.warn(`[GitSandbox] Worktree prune failed: ${e instanceof Error ? e.message : e}`);
|
|
493
|
+
}
|
|
494
|
+
// If directory still exists, force remove it manually
|
|
495
|
+
if (existsSync(worktreePath)) {
|
|
496
|
+
console.log(`[GitSandbox] Directory still exists, removing manually...`);
|
|
497
|
+
try {
|
|
498
|
+
rmSync(worktreePath, { recursive: true, force: true });
|
|
499
|
+
}
|
|
500
|
+
catch (e) {
|
|
501
|
+
console.warn(`[GitSandbox] Manual removal failed: ${e instanceof Error ? e.message : e}`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// Clean up orphaned branch from previous failed attempt if it exists
|
|
506
|
+
if (this.branchExists(repoPath, branchName)) {
|
|
507
|
+
console.log(`[GitSandbox] Branch ${branchName} already exists (orphaned from previous attempt), deleting it`);
|
|
508
|
+
try {
|
|
509
|
+
this.exec(`git branch -D "${branchName}"`, repoPath);
|
|
510
|
+
}
|
|
511
|
+
catch (e) {
|
|
512
|
+
// If branch deletion fails, it might still be locked by a worktree reference
|
|
513
|
+
// Try a more aggressive cleanup
|
|
514
|
+
console.warn(`[GitSandbox] Failed to delete orphaned branch, trying aggressive cleanup: ${e instanceof Error ? e.message : e}`);
|
|
515
|
+
try {
|
|
516
|
+
// Force prune all worktrees
|
|
517
|
+
this.exec('git worktree prune --verbose', repoPath);
|
|
518
|
+
// Try deletion again
|
|
519
|
+
this.exec(`git branch -D "${branchName}"`, repoPath);
|
|
520
|
+
console.log(`[GitSandbox] Branch ${branchName} deleted after aggressive cleanup`);
|
|
521
|
+
}
|
|
522
|
+
catch (e2) {
|
|
523
|
+
console.warn(`[GitSandbox] Aggressive cleanup failed: ${e2 instanceof Error ? e2.message : e2}`);
|
|
524
|
+
// As a last resort, create worktree without -b flag if branch exists
|
|
525
|
+
// This will use the existing branch instead of creating a new one
|
|
526
|
+
console.log(`[GitSandbox] Will attempt to use existing branch...`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
// Create worktree with new branch
|
|
531
|
+
// git worktree add -b <branch> <path>
|
|
532
|
+
console.log(`[GitSandbox] Creating worktree with branch ${branchName}...`);
|
|
533
|
+
// Check if branch still exists (cleanup might have failed)
|
|
534
|
+
if (this.branchExists(repoPath, branchName)) {
|
|
535
|
+
// Branch exists, create worktree using existing branch (without -b flag)
|
|
536
|
+
console.log(`[GitSandbox] Branch ${branchName} still exists, creating worktree with existing branch`);
|
|
537
|
+
this.exec(`git worktree add "${worktreePath}" "${branchName}"`, repoPath);
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
// Normal case: create worktree with new branch
|
|
541
|
+
this.exec(`git worktree add -b "${branchName}" "${worktreePath}"`, repoPath);
|
|
542
|
+
}
|
|
543
|
+
console.log(`[GitSandbox] Worktree created at ${worktreePath}`);
|
|
544
|
+
return {
|
|
545
|
+
worktreePath,
|
|
546
|
+
branch: branchName,
|
|
547
|
+
repoPath,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Remove a worktree and optionally its branch
|
|
552
|
+
* @param repoPath - Path to the main repository
|
|
553
|
+
* @param worktreePath - Path to the worktree to remove
|
|
554
|
+
* @param deleteBranch - Branch name to delete (optional)
|
|
555
|
+
*/
|
|
556
|
+
removeWorktree(repoPath, worktreePath, deleteBranch) {
|
|
557
|
+
console.log(`[GitSandbox] Removing worktree at "${worktreePath}"`);
|
|
558
|
+
// Remove the worktree
|
|
559
|
+
try {
|
|
560
|
+
this.exec(`git worktree remove "${worktreePath}" --force`, repoPath);
|
|
561
|
+
console.log('[GitSandbox] Worktree removed');
|
|
562
|
+
}
|
|
563
|
+
catch (e) {
|
|
564
|
+
// If worktree remove fails, try manual cleanup with prune
|
|
565
|
+
console.log(`[GitSandbox] Worktree remove failed, pruning: ${e instanceof Error ? e.message : e}`);
|
|
566
|
+
try {
|
|
567
|
+
this.exec('git worktree prune', repoPath);
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
// Prune might fail too, that's okay
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
// Delete the branch if requested
|
|
574
|
+
if (deleteBranch) {
|
|
575
|
+
console.log(`[GitSandbox] Deleting branch ${deleteBranch}`);
|
|
576
|
+
try {
|
|
577
|
+
this.exec(`git branch -D "${deleteBranch}"`, repoPath);
|
|
578
|
+
console.log('[GitSandbox] Branch deleted');
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
// Branch might not exist, that's okay
|
|
582
|
+
console.log('[GitSandbox] Branch deletion skipped (may not exist)');
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Check if a worktree exists at the given path
|
|
588
|
+
*/
|
|
589
|
+
worktreeExists(repoPath, worktreePath) {
|
|
590
|
+
try {
|
|
591
|
+
const output = this.exec('git worktree list --porcelain', repoPath);
|
|
592
|
+
// Normalize path separators for comparison
|
|
593
|
+
const normalizedWorktreePath = worktreePath.replace(/\\/g, '/');
|
|
594
|
+
return output.split('\n').some(line => {
|
|
595
|
+
if (line.startsWith('worktree ')) {
|
|
596
|
+
const path = line.substring(9).replace(/\\/g, '/');
|
|
597
|
+
return path === normalizedWorktreePath;
|
|
598
|
+
}
|
|
599
|
+
return false;
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Check if a directory is a valid git worktree
|
|
608
|
+
* A valid worktree has a .git file (not directory) that points to the main repo's .git
|
|
609
|
+
* This is useful for detecting corrupted worktrees that exist as directories but aren't valid
|
|
610
|
+
*/
|
|
611
|
+
isValidWorktree(worktreePath) {
|
|
612
|
+
try {
|
|
613
|
+
// Worktrees have a .git FILE (not directory) that contains "gitdir: ..."
|
|
614
|
+
const gitPath = join(worktreePath, '.git');
|
|
615
|
+
if (!existsSync(gitPath)) {
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
// Check if .git is a file (worktree) not a directory (regular repo)
|
|
619
|
+
const stat = statSync(gitPath);
|
|
620
|
+
if (stat.isDirectory()) {
|
|
621
|
+
// This is a regular git repo, not a worktree
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
// Verify it's a valid git worktree by running a git command
|
|
625
|
+
this.exec('git rev-parse --git-dir', worktreePath);
|
|
626
|
+
return true;
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* List all worktrees for a repository
|
|
634
|
+
*/
|
|
635
|
+
listWorktrees(repoPath) {
|
|
636
|
+
try {
|
|
637
|
+
const output = this.exec('git worktree list --porcelain', repoPath);
|
|
638
|
+
const paths = [];
|
|
639
|
+
for (const line of output.split('\n')) {
|
|
640
|
+
if (line.startsWith('worktree ')) {
|
|
641
|
+
paths.push(line.substring(9));
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return paths;
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
return [];
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Push changes from a worktree
|
|
652
|
+
* @param worktreePath - Path to the worktree
|
|
653
|
+
* @param repoPath - Path to the main repository (for remote check)
|
|
654
|
+
* @param branch - Branch name to push
|
|
655
|
+
* @param commitMessage - Commit message
|
|
656
|
+
* @returns Object indicating what action was taken and any conflicts
|
|
657
|
+
*/
|
|
658
|
+
pushWorktree(worktreePath, repoPath, branch, commitMessage) {
|
|
659
|
+
console.log(`[GitSandbox] Pushing from worktree at ${worktreePath}`);
|
|
660
|
+
// Remove Windows reserved device name files before staging (they cause git add to fail)
|
|
661
|
+
removeWindowsReservedFiles(worktreePath);
|
|
662
|
+
// Stage all changes
|
|
663
|
+
this.exec('git add -A', worktreePath);
|
|
664
|
+
// Check if there are changes to commit
|
|
665
|
+
if (!this.isClean(worktreePath)) {
|
|
666
|
+
const message = commitMessage || 'ClaudeDesk: automated changes';
|
|
667
|
+
const escapedMessage = message.replace(/"/g, '\\"');
|
|
668
|
+
this.exec(`git commit -m "${escapedMessage}"`, worktreePath);
|
|
669
|
+
console.log(`[GitSandbox] Changes committed to branch ${branch}`);
|
|
670
|
+
}
|
|
671
|
+
// Check if origin remote exists
|
|
672
|
+
if (this.hasRemote(repoPath, 'origin')) {
|
|
673
|
+
// HAS REMOTE: Push feature branch to origin (for PR workflow)
|
|
674
|
+
console.log(`[GitSandbox] Pushing branch ${branch} to origin...`);
|
|
675
|
+
try {
|
|
676
|
+
this.exec(`git push -u origin ${branch}`, worktreePath, 60000); // 60s timeout for push
|
|
677
|
+
console.log(`[GitSandbox] Branch pushed successfully`);
|
|
678
|
+
return { pushed: true, merged: false, conflict: false };
|
|
679
|
+
}
|
|
680
|
+
catch (e) {
|
|
681
|
+
console.log(`[GitSandbox] Push failed: ${e instanceof Error ? e.message : e}`);
|
|
682
|
+
return { pushed: false, merged: false, conflict: false };
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
// NO REMOTE: Merge feature branch into main locally
|
|
687
|
+
console.log(`[GitSandbox] No origin remote configured, merging to main locally...`);
|
|
688
|
+
// First, check if merge will be clean
|
|
689
|
+
console.log(`[GitSandbox] Checking for merge conflicts...`);
|
|
690
|
+
const mergeCheck = this.canMergeCleanly(repoPath, branch);
|
|
691
|
+
if (!mergeCheck.canMerge) {
|
|
692
|
+
console.log(`[GitSandbox] Merge would have conflicts: ${mergeCheck.conflictFiles?.join(', ')}`);
|
|
693
|
+
return {
|
|
694
|
+
pushed: false,
|
|
695
|
+
merged: false,
|
|
696
|
+
conflict: true,
|
|
697
|
+
conflictFiles: mergeCheck.conflictFiles,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
try {
|
|
701
|
+
const mainBranch = this.getMainBranch(repoPath);
|
|
702
|
+
console.log(`[GitSandbox] Checking out ${mainBranch} in main repo...`);
|
|
703
|
+
this.exec(`git checkout ${mainBranch}`, repoPath);
|
|
704
|
+
console.log(`[GitSandbox] Merging ${branch} into ${mainBranch}...`);
|
|
705
|
+
this.exec(`git merge ${branch}`, repoPath);
|
|
706
|
+
console.log(`[GitSandbox] Successfully merged ${branch} into ${mainBranch}`);
|
|
707
|
+
return { pushed: false, merged: true, conflict: false };
|
|
708
|
+
}
|
|
709
|
+
catch (e) {
|
|
710
|
+
console.log(`[GitSandbox] Merge failed: ${e instanceof Error ? e.message : e}`);
|
|
711
|
+
return { pushed: false, merged: false, conflict: false };
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
// =========== CONFLICT RESOLUTION METHODS ===========
|
|
716
|
+
/**
|
|
717
|
+
* Check if the worktree is currently in a merge conflict state
|
|
718
|
+
*/
|
|
719
|
+
isInMergeConflict(worktreePath) {
|
|
720
|
+
try {
|
|
721
|
+
// Check for MERGE_HEAD which indicates an ongoing merge
|
|
722
|
+
const mergeHead = join(worktreePath, '.git', 'MERGE_HEAD');
|
|
723
|
+
if (existsSync(mergeHead)) {
|
|
724
|
+
return true;
|
|
725
|
+
}
|
|
726
|
+
// For worktrees, check in the git dir reference
|
|
727
|
+
const gitFile = join(worktreePath, '.git');
|
|
728
|
+
if (existsSync(gitFile)) {
|
|
729
|
+
const content = readFileSync(gitFile, 'utf-8').trim();
|
|
730
|
+
if (content.startsWith('gitdir: ')) {
|
|
731
|
+
const gitDir = content.substring(8);
|
|
732
|
+
if (existsSync(join(gitDir, 'MERGE_HEAD'))) {
|
|
733
|
+
return true;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return false;
|
|
738
|
+
}
|
|
739
|
+
catch {
|
|
740
|
+
return false;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Get list of files that have unresolved conflicts
|
|
745
|
+
*/
|
|
746
|
+
getConflictingFiles(worktreePath) {
|
|
747
|
+
try {
|
|
748
|
+
// --diff-filter=U shows only unmerged (conflicted) files
|
|
749
|
+
const output = this.exec('git diff --name-only --diff-filter=U', worktreePath);
|
|
750
|
+
return output.split('\n').filter(Boolean);
|
|
751
|
+
}
|
|
752
|
+
catch {
|
|
753
|
+
return [];
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Start a merge in the worktree to get into conflict state for resolution
|
|
758
|
+
* This is used when we know there will be conflicts and want to let user resolve them
|
|
759
|
+
*/
|
|
760
|
+
startMergeForResolution(worktreePath, repoPath) {
|
|
761
|
+
const mainBranch = this.getMainBranch(repoPath);
|
|
762
|
+
try {
|
|
763
|
+
// Try to merge main into the worktree's branch
|
|
764
|
+
// This will leave files in conflict state for resolution
|
|
765
|
+
this.exec(`git merge ${mainBranch}`, worktreePath);
|
|
766
|
+
// If merge succeeded, no conflicts
|
|
767
|
+
return { success: true, conflictFiles: [] };
|
|
768
|
+
}
|
|
769
|
+
catch {
|
|
770
|
+
// Merge failed - get the conflicting files
|
|
771
|
+
const conflictFiles = this.getConflictingFiles(worktreePath);
|
|
772
|
+
return { success: false, conflictFiles };
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Get detailed information about conflicting files
|
|
777
|
+
* Must be called when worktree is in merge conflict state
|
|
778
|
+
*/
|
|
779
|
+
getConflictDetails(worktreePath) {
|
|
780
|
+
const conflictFiles = this.getConflictingFiles(worktreePath);
|
|
781
|
+
const results = [];
|
|
782
|
+
// Get the branch labels from MERGE_MSG or default names
|
|
783
|
+
let oursLabel = 'HEAD (current)';
|
|
784
|
+
let theirsLabel = 'incoming';
|
|
785
|
+
try {
|
|
786
|
+
const mergeMsgPath = join(worktreePath, '.git', 'MERGE_MSG');
|
|
787
|
+
const mergeMsg = existsSync(mergeMsgPath) ? readFileSync(mergeMsgPath, 'utf-8') : '';
|
|
788
|
+
const match = mergeMsg.match(/Merge branch '([^']+)'/);
|
|
789
|
+
if (match) {
|
|
790
|
+
theirsLabel = match[1];
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
catch {
|
|
794
|
+
// Use defaults
|
|
795
|
+
}
|
|
796
|
+
for (const filePath of conflictFiles) {
|
|
797
|
+
try {
|
|
798
|
+
const fullPath = join(worktreePath, filePath);
|
|
799
|
+
if (!existsSync(fullPath))
|
|
800
|
+
continue;
|
|
801
|
+
// Read file content to get conflict preview
|
|
802
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
803
|
+
const lines = content.split('\n');
|
|
804
|
+
// Find first conflict marker and extract preview
|
|
805
|
+
let preview = '';
|
|
806
|
+
let conflictCount = 0;
|
|
807
|
+
let inConflict = false;
|
|
808
|
+
let previewLines = [];
|
|
809
|
+
for (let i = 0; i < lines.length && previewLines.length < 30; i++) {
|
|
810
|
+
const line = lines[i];
|
|
811
|
+
if (line.startsWith('<<<<<<<')) {
|
|
812
|
+
conflictCount++;
|
|
813
|
+
inConflict = true;
|
|
814
|
+
if (conflictCount === 1) {
|
|
815
|
+
// Start capturing preview from first conflict
|
|
816
|
+
previewLines.push(line);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
else if (line.startsWith('>>>>>>>')) {
|
|
820
|
+
inConflict = false;
|
|
821
|
+
if (conflictCount === 1) {
|
|
822
|
+
previewLines.push(line);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
else if (inConflict && conflictCount === 1) {
|
|
826
|
+
previewLines.push(line);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
preview = previewLines.join('\n');
|
|
830
|
+
if (conflictCount > 1) {
|
|
831
|
+
preview += `\n\n... and ${conflictCount - 1} more conflict(s)`;
|
|
832
|
+
}
|
|
833
|
+
results.push({
|
|
834
|
+
filePath,
|
|
835
|
+
preview,
|
|
836
|
+
oursLabel,
|
|
837
|
+
theirsLabel,
|
|
838
|
+
conflictCount,
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
catch (e) {
|
|
842
|
+
// Skip files we can't read
|
|
843
|
+
console.warn(`[GitSandbox] Could not read conflict file ${filePath}: ${e}`);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return results;
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Resolve a single conflicting file using specified strategy
|
|
850
|
+
*/
|
|
851
|
+
resolveConflict(worktreePath, filePath, strategy) {
|
|
852
|
+
console.log(`[GitSandbox] Resolving conflict for ${filePath} using ${strategy}`);
|
|
853
|
+
// Use git checkout with --ours or --theirs to pick one side
|
|
854
|
+
this.exec(`git checkout --${strategy} -- "${filePath}"`, worktreePath);
|
|
855
|
+
// Stage the resolved file
|
|
856
|
+
this.exec(`git add "${filePath}"`, worktreePath);
|
|
857
|
+
console.log(`[GitSandbox] Resolved ${filePath} using ${strategy} strategy`);
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Resolve all conflicting files using specified strategy
|
|
861
|
+
*/
|
|
862
|
+
resolveAllConflicts(worktreePath, strategy) {
|
|
863
|
+
const conflictFiles = this.getConflictingFiles(worktreePath);
|
|
864
|
+
console.log(`[GitSandbox] Resolving ${conflictFiles.length} conflicts using ${strategy}`);
|
|
865
|
+
for (const filePath of conflictFiles) {
|
|
866
|
+
this.resolveConflict(worktreePath, filePath, strategy);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Abort an ongoing merge and reset to pre-merge state
|
|
871
|
+
*/
|
|
872
|
+
abortMerge(worktreePath) {
|
|
873
|
+
console.log(`[GitSandbox] Aborting merge in ${worktreePath}`);
|
|
874
|
+
try {
|
|
875
|
+
this.exec('git merge --abort', worktreePath);
|
|
876
|
+
console.log('[GitSandbox] Merge aborted successfully');
|
|
877
|
+
}
|
|
878
|
+
catch (e) {
|
|
879
|
+
// If merge abort fails, try hard reset
|
|
880
|
+
console.warn(`[GitSandbox] Merge abort failed, trying reset: ${e}`);
|
|
881
|
+
this.exec('git reset --hard HEAD', worktreePath);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Complete a merge after all conflicts are resolved
|
|
886
|
+
*/
|
|
887
|
+
completeMerge(worktreePath, commitMessage) {
|
|
888
|
+
// Check if there are still unresolved conflicts
|
|
889
|
+
const conflictFiles = this.getConflictingFiles(worktreePath);
|
|
890
|
+
if (conflictFiles.length > 0) {
|
|
891
|
+
throw new Error(`Cannot complete merge - ${conflictFiles.length} files still have conflicts: ${conflictFiles.join(', ')}`);
|
|
892
|
+
}
|
|
893
|
+
// Commit the merge
|
|
894
|
+
const message = commitMessage || 'Merge conflict resolved';
|
|
895
|
+
const escapedMessage = message.replace(/"/g, '\\"');
|
|
896
|
+
try {
|
|
897
|
+
this.exec(`git commit -m "${escapedMessage}"`, worktreePath);
|
|
898
|
+
console.log('[GitSandbox] Merge completed successfully');
|
|
899
|
+
}
|
|
900
|
+
catch (e) {
|
|
901
|
+
// Might already be committed
|
|
902
|
+
console.warn(`[GitSandbox] Commit failed (may already be committed): ${e}`);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
export const gitSandbox = new GitSandbox();
|
|
907
|
+
//# sourceMappingURL=git-sandbox.js.map
|