@straiffi/archon 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/README.md +224 -0
- package/dist/cli.js +216 -0
- package/dist/client/assets/index-8_-boBBA.css +2 -0
- package/dist/client/assets/index-s_jjeqha.js +176 -0
- package/dist/client/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist/client/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist/client/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/client/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/client/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist/client/favicon.svg +62 -0
- package/dist/client/icons.svg +24 -0
- package/dist/client/index.html +14 -0
- package/dist/server/db.js +764 -0
- package/dist/server/db.js.map +1 -0
- package/dist/server/index.js +5134 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/lib/agent.js +1302 -0
- package/dist/server/lib/agent.js.map +1 -0
- package/dist/server/lib/buildChains.js +2 -0
- package/dist/server/lib/buildChains.js.map +1 -0
- package/dist/server/lib/buildFlow.js +59 -0
- package/dist/server/lib/buildFlow.js.map +1 -0
- package/dist/server/lib/buildSequences.js +599 -0
- package/dist/server/lib/buildSequences.js.map +1 -0
- package/dist/server/lib/bundleActivity.js +95 -0
- package/dist/server/lib/bundleActivity.js.map +1 -0
- package/dist/server/lib/bundlePullRequests.js +126 -0
- package/dist/server/lib/bundlePullRequests.js.map +1 -0
- package/dist/server/lib/chatMessages.js +60 -0
- package/dist/server/lib/chatMessages.js.map +1 -0
- package/dist/server/lib/chatTargets.js +123 -0
- package/dist/server/lib/chatTargets.js.map +1 -0
- package/dist/server/lib/chatTicketProposals.js +180 -0
- package/dist/server/lib/chatTicketProposals.js.map +1 -0
- package/dist/server/lib/chats.js +279 -0
- package/dist/server/lib/chats.js.map +1 -0
- package/dist/server/lib/config.js +3 -0
- package/dist/server/lib/config.js.map +1 -0
- package/dist/server/lib/cors.js +30 -0
- package/dist/server/lib/cors.js.map +1 -0
- package/dist/server/lib/directoryPicker.js +174 -0
- package/dist/server/lib/directoryPicker.js.map +1 -0
- package/dist/server/lib/git.js +1284 -0
- package/dist/server/lib/git.js.map +1 -0
- package/dist/server/lib/integrations/github.js +511 -0
- package/dist/server/lib/integrations/github.js.map +1 -0
- package/dist/server/lib/integrations/index.js +162 -0
- package/dist/server/lib/integrations/index.js.map +1 -0
- package/dist/server/lib/integrations/jira.js +283 -0
- package/dist/server/lib/integrations/jira.js.map +1 -0
- package/dist/server/lib/integrations/planning.js +27 -0
- package/dist/server/lib/integrations/planning.js.map +1 -0
- package/dist/server/lib/integrations/types.js +2 -0
- package/dist/server/lib/integrations/types.js.map +1 -0
- package/dist/server/lib/lightweightPrompt.js +88 -0
- package/dist/server/lib/lightweightPrompt.js.map +1 -0
- package/dist/server/lib/models.js +219 -0
- package/dist/server/lib/models.js.map +1 -0
- package/dist/server/lib/preview.js +377 -0
- package/dist/server/lib/preview.js.map +1 -0
- package/dist/server/lib/previewProxy.js +659 -0
- package/dist/server/lib/previewProxy.js.map +1 -0
- package/dist/server/lib/projectAutoConfig.js +682 -0
- package/dist/server/lib/projectAutoConfig.js.map +1 -0
- package/dist/server/lib/projectFileSuggestions.js +133 -0
- package/dist/server/lib/projectFileSuggestions.js.map +1 -0
- package/dist/server/lib/projectMemory.js +1519 -0
- package/dist/server/lib/projectMemory.js.map +1 -0
- package/dist/server/lib/projectMemoryPrompt.js +390 -0
- package/dist/server/lib/projectMemoryPrompt.js.map +1 -0
- package/dist/server/lib/projectMemoryScan.js +681 -0
- package/dist/server/lib/projectMemoryScan.js.map +1 -0
- package/dist/server/lib/projectMemorySuggestions.js +166 -0
- package/dist/server/lib/projectMemorySuggestions.js.map +1 -0
- package/dist/server/lib/projectMemoryTransfer.js +958 -0
- package/dist/server/lib/projectMemoryTransfer.js.map +1 -0
- package/dist/server/lib/projects.js +569 -0
- package/dist/server/lib/projects.js.map +1 -0
- package/dist/server/lib/promptSkills.js +28 -0
- package/dist/server/lib/promptSkills.js.map +1 -0
- package/dist/server/lib/queue.js +15 -0
- package/dist/server/lib/queue.js.map +1 -0
- package/dist/server/lib/reviewFindings.js +390 -0
- package/dist/server/lib/reviewFindings.js.map +1 -0
- package/dist/server/lib/run.js +416 -0
- package/dist/server/lib/run.js.map +1 -0
- package/dist/server/lib/runtimePaths.js +93 -0
- package/dist/server/lib/runtimePaths.js.map +1 -0
- package/dist/server/lib/shell.js +27 -0
- package/dist/server/lib/shell.js.map +1 -0
- package/dist/server/lib/skills.js +124 -0
- package/dist/server/lib/skills.js.map +1 -0
- package/dist/server/lib/startDev.js +18 -0
- package/dist/server/lib/startDev.js.map +1 -0
- package/dist/server/lib/staticClient.js +80 -0
- package/dist/server/lib/staticClient.js.map +1 -0
- package/dist/server/lib/terminal.js +366 -0
- package/dist/server/lib/terminal.js.map +1 -0
- package/dist/server/lib/ticketDependencies.js +174 -0
- package/dist/server/lib/ticketDependencies.js.map +1 -0
- package/dist/server/lib/ticketMessages.js +65 -0
- package/dist/server/lib/ticketMessages.js.map +1 -0
- package/dist/server/lib/ticketOpenQuestions.js +128 -0
- package/dist/server/lib/ticketOpenQuestions.js.map +1 -0
- package/dist/server/lib/ticketUndo.js +549 -0
- package/dist/server/lib/ticketUndo.js.map +1 -0
- package/dist/server/lib/tickets.js +981 -0
- package/dist/server/lib/tickets.js.map +1 -0
- package/dist/server/lib/types.js +2 -0
- package/dist/server/lib/types.js.map +1 -0
- package/dist/server/package.json +3 -0
- package/dist/server/workers/build.js +229 -0
- package/dist/server/workers/build.js.map +1 -0
- package/dist/server/workers/chat.js +190 -0
- package/dist/server/workers/chat.js.map +1 -0
- package/dist/server/workers/followUp.js +204 -0
- package/dist/server/workers/followUp.js.map +1 -0
- package/dist/server/workers/plan.js +1130 -0
- package/dist/server/workers/plan.js.map +1 -0
- package/dist/server/workers/planFollowUp.js +360 -0
- package/dist/server/workers/planFollowUp.js.map +1 -0
- package/dist/server/workers/review.js +167 -0
- package/dist/server/workers/review.js.map +1 -0
- package/package.json +40 -0
|
@@ -0,0 +1,1284 @@
|
|
|
1
|
+
import { execFile, execFileSync } from 'child_process';
|
|
2
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, statSync, symlinkSync } from 'fs';
|
|
3
|
+
import { cp as cpAsync, lstat as lstatAsync, mkdir as mkdirAsync, symlink as symlinkAsync } from 'fs/promises';
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'path';
|
|
5
|
+
import db from '../db.js';
|
|
6
|
+
import { getProjectWorktreeSync } from './projects.js';
|
|
7
|
+
const PROJECT_BRANCH_REMOTE_REFRESH_TTL_MS = 30_000;
|
|
8
|
+
const lastProjectBranchRemoteRefreshAt = new Map();
|
|
9
|
+
const resolveCurrentBranchName = (cwd) => {
|
|
10
|
+
try {
|
|
11
|
+
const branch = runGitText(['branch', '--show-current'], cwd).trim();
|
|
12
|
+
return branch && branch !== 'HEAD' ? branch : null;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const getProjectBranchRefreshCacheKey = (cwd, branch) => `${cwd}:${branch ?? ''}`;
|
|
19
|
+
const toText = (value) => {
|
|
20
|
+
if (typeof value === 'string') {
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
return value ? value.toString('utf8') : '';
|
|
24
|
+
};
|
|
25
|
+
const createTextSignature = (value) => {
|
|
26
|
+
let hash = 5381;
|
|
27
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
28
|
+
hash = ((hash << 5) + hash) ^ value.charCodeAt(index);
|
|
29
|
+
}
|
|
30
|
+
return `${value.length}:${hash >>> 0}`;
|
|
31
|
+
};
|
|
32
|
+
const getRepoPath = (project) => {
|
|
33
|
+
if (!project?.repo_path) {
|
|
34
|
+
throw new Error('Project repo path is required');
|
|
35
|
+
}
|
|
36
|
+
return resolve(project.repo_path);
|
|
37
|
+
};
|
|
38
|
+
const resolveRepoKey = (project) => getRepoPath(project);
|
|
39
|
+
const runGitText = (args, cwd) => {
|
|
40
|
+
return execFileSync('git', args, {
|
|
41
|
+
cwd,
|
|
42
|
+
encoding: 'utf8',
|
|
43
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
const runGitDiffText = (args, cwd) => {
|
|
47
|
+
try {
|
|
48
|
+
return runGitText(args, cwd);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
const failed = error;
|
|
52
|
+
if (failed.status === 1) {
|
|
53
|
+
const stdout = failed.stdout;
|
|
54
|
+
if (typeof stdout === 'string') {
|
|
55
|
+
return stdout;
|
|
56
|
+
}
|
|
57
|
+
return stdout?.toString('utf8') ?? '';
|
|
58
|
+
}
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const runGitTextAsync = (args, cwd) => new Promise((resolvePromise, reject) => {
|
|
63
|
+
execFile('git', args, {
|
|
64
|
+
cwd,
|
|
65
|
+
encoding: 'utf8',
|
|
66
|
+
}, (error, stdout, stderr) => {
|
|
67
|
+
if (error) {
|
|
68
|
+
const failed = error;
|
|
69
|
+
const nextError = new Error(toText(failed.stderr).trim()
|
|
70
|
+
|| stderr.trim()
|
|
71
|
+
|| failed.message);
|
|
72
|
+
nextError.stdout = failed.stdout ?? stdout;
|
|
73
|
+
nextError.stderr = failed.stderr ?? stderr;
|
|
74
|
+
nextError.status = failed.status ?? (typeof failed.code === 'number' ? failed.code : undefined);
|
|
75
|
+
reject(nextError);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
resolvePromise({
|
|
79
|
+
stdout,
|
|
80
|
+
stderr,
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
const runGitDiffTextAsync = async (args, cwd) => {
|
|
85
|
+
try {
|
|
86
|
+
return (await runGitTextAsync(args, cwd)).stdout;
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
const failed = error;
|
|
90
|
+
const exitCode = failed.status ?? (typeof failed.code === 'number' ? failed.code : null);
|
|
91
|
+
if (exitCode === 1) {
|
|
92
|
+
const stdout = failed.stdout;
|
|
93
|
+
if (typeof stdout === 'string') {
|
|
94
|
+
return stdout;
|
|
95
|
+
}
|
|
96
|
+
return stdout?.toString('utf8') ?? '';
|
|
97
|
+
}
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const getWorktreeBase = (project) => join(getRepoPath(project), '..', 'worktrees');
|
|
102
|
+
const parseWorktreeRecords = (output) => {
|
|
103
|
+
const records = [];
|
|
104
|
+
const lines = output.split('\n');
|
|
105
|
+
let currentPath = null;
|
|
106
|
+
let currentBranch = null;
|
|
107
|
+
const pushCurrent = () => {
|
|
108
|
+
if (!currentPath) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
records.push({ path: currentPath, branch: currentBranch });
|
|
112
|
+
currentPath = null;
|
|
113
|
+
currentBranch = null;
|
|
114
|
+
};
|
|
115
|
+
for (const line of lines) {
|
|
116
|
+
if (line === '') {
|
|
117
|
+
pushCurrent();
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (line.startsWith('worktree ')) {
|
|
121
|
+
pushCurrent();
|
|
122
|
+
currentPath = line.slice('worktree '.length);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (line.startsWith('branch ')) {
|
|
126
|
+
currentBranch = line.slice('branch '.length).replace(/^refs\/heads\//, '');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
pushCurrent();
|
|
130
|
+
return records;
|
|
131
|
+
};
|
|
132
|
+
const listWorktreeRecords = (project, worktreeRecordsCache) => {
|
|
133
|
+
const repoPath = getRepoPath(project);
|
|
134
|
+
if (worktreeRecordsCache?.has(repoPath)) {
|
|
135
|
+
return worktreeRecordsCache.get(repoPath) ?? [];
|
|
136
|
+
}
|
|
137
|
+
const output = runGitText(['worktree', 'list', '--porcelain'], repoPath);
|
|
138
|
+
const records = parseWorktreeRecords(output);
|
|
139
|
+
worktreeRecordsCache?.set(repoPath, records);
|
|
140
|
+
return records;
|
|
141
|
+
};
|
|
142
|
+
const parseNumstatOutput = (output) => {
|
|
143
|
+
const lines = output
|
|
144
|
+
.split('\n')
|
|
145
|
+
.map(line => line.trimEnd())
|
|
146
|
+
.filter(Boolean);
|
|
147
|
+
return lines.flatMap(line => {
|
|
148
|
+
const firstTabIndex = line.indexOf('\t');
|
|
149
|
+
const secondTabIndex = firstTabIndex === -1 ? -1 : line.indexOf('\t', firstTabIndex + 1);
|
|
150
|
+
if (firstTabIndex === -1 || secondTabIndex === -1) {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
const additionsToken = line.slice(0, firstTabIndex);
|
|
154
|
+
const deletionsToken = line.slice(firstTabIndex + 1, secondTabIndex);
|
|
155
|
+
const path = line.slice(secondTabIndex + 1);
|
|
156
|
+
const additions = additionsToken === '-' ? 0 : Number.parseInt(additionsToken, 10);
|
|
157
|
+
const deletions = deletionsToken === '-' ? 0 : Number.parseInt(deletionsToken, 10);
|
|
158
|
+
if (!path) {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
return [{
|
|
162
|
+
path,
|
|
163
|
+
additions: Number.isFinite(additions) ? additions : 0,
|
|
164
|
+
deletions: Number.isFinite(deletions) ? deletions : 0,
|
|
165
|
+
}];
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
const normalizeNoIndexPath = (path) => {
|
|
169
|
+
const renamedPrefix = '/dev/null => ';
|
|
170
|
+
if (path.startsWith(renamedPrefix)) {
|
|
171
|
+
return path.slice(renamedPrefix.length);
|
|
172
|
+
}
|
|
173
|
+
return path;
|
|
174
|
+
};
|
|
175
|
+
const parseBranchHeader = (line, current) => {
|
|
176
|
+
if (line.startsWith('# branch.head ')) {
|
|
177
|
+
const branch = line.slice('# branch.head '.length);
|
|
178
|
+
return {
|
|
179
|
+
...current,
|
|
180
|
+
branch: branch === '(detached)' ? null : branch,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
if (!line.startsWith('# branch.ab ')) {
|
|
184
|
+
return current;
|
|
185
|
+
}
|
|
186
|
+
const aheadMatch = line.match(/\+(\d+)/);
|
|
187
|
+
const behindMatch = line.match(/-(\d+)/);
|
|
188
|
+
return {
|
|
189
|
+
...current,
|
|
190
|
+
ahead: aheadMatch ? Number.parseInt(aheadMatch[1] ?? '0', 10) : 0,
|
|
191
|
+
behind: behindMatch ? Number.parseInt(behindMatch[1] ?? '0', 10) : 0,
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
const getRecordedWorktreeBase = (branch, project) => {
|
|
195
|
+
const row = db.prepare('SELECT repo_path, branch, base_branch, base_commit FROM worktree_bases WHERE repo_path = ? AND branch = ?').get(resolveRepoKey(project), branch);
|
|
196
|
+
if (!row) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
baseBranch: row.base_branch,
|
|
201
|
+
baseCommit: row.base_commit,
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
const saveWorktreeBase = (branch, project, base) => {
|
|
205
|
+
db.prepare(`
|
|
206
|
+
INSERT INTO worktree_bases (repo_path, branch, base_branch, base_commit, created_at, updated_at)
|
|
207
|
+
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
208
|
+
ON CONFLICT(repo_path, branch) DO UPDATE SET
|
|
209
|
+
base_branch = excluded.base_branch,
|
|
210
|
+
base_commit = excluded.base_commit,
|
|
211
|
+
updated_at = CURRENT_TIMESTAMP
|
|
212
|
+
`).run(resolveRepoKey(project), branch, base.baseBranch, base.baseCommit);
|
|
213
|
+
};
|
|
214
|
+
const deleteWorktreeBase = (branch, project) => {
|
|
215
|
+
db.prepare('DELETE FROM worktree_bases WHERE repo_path = ? AND branch = ?').run(resolveRepoKey(project), branch);
|
|
216
|
+
};
|
|
217
|
+
const resolveHeadCommit = (cwd) => {
|
|
218
|
+
const output = runGitText(['rev-parse', 'HEAD'], cwd).trim();
|
|
219
|
+
return output || null;
|
|
220
|
+
};
|
|
221
|
+
const countCommitRange = (range, cwd) => {
|
|
222
|
+
const output = runGitText(['rev-list', '--count', range], cwd).trim();
|
|
223
|
+
const count = Number.parseInt(output || '0', 10);
|
|
224
|
+
return Number.isFinite(count) ? count : 0;
|
|
225
|
+
};
|
|
226
|
+
const resolveRecordedBaseAhead = (cwd, baseBranch, baseCommit) => {
|
|
227
|
+
const range = resolveRecordedBaseRange(cwd, baseBranch, baseCommit);
|
|
228
|
+
if (!range) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
return countCommitRange(range, cwd);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
const resolveFallbackBase = (branch, project) => {
|
|
239
|
+
const repoPath = getRepoPath(project);
|
|
240
|
+
const rootBranch = resolveCurrentBranchName(repoPath);
|
|
241
|
+
if (!rootBranch || rootBranch === branch) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
baseBranch: rootBranch,
|
|
246
|
+
baseCommit: resolveBranchMergeBase(rootBranch, branch, repoPath),
|
|
247
|
+
};
|
|
248
|
+
};
|
|
249
|
+
const resolveBranchMergeBase = (baseBranch, branch, cwd) => {
|
|
250
|
+
if (!baseBranch || baseBranch === branch) {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
const mergeBase = runGitText(['merge-base', baseBranch, branch], cwd).trim();
|
|
255
|
+
return mergeBase || null;
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
const resolveBranchUpstreamRef = (branch, cwd) => {
|
|
262
|
+
try {
|
|
263
|
+
const upstreamRef = runGitText(['rev-parse', '--symbolic-full-name', `${branch}@{upstream}`], cwd).trim();
|
|
264
|
+
return upstreamRef || null;
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
const getBranchTrackingState = (branch, cwd) => {
|
|
271
|
+
if (!resolveBranchUpstreamRef(branch, cwd)) {
|
|
272
|
+
try {
|
|
273
|
+
const aheadOutput = runGitText(['rev-list', '--count', 'HEAD', '--not', '--remotes'], cwd).trim();
|
|
274
|
+
const ahead = Number.parseInt(aheadOutput || '0', 10);
|
|
275
|
+
return {
|
|
276
|
+
hasUpstream: false,
|
|
277
|
+
ahead: Number.isFinite(ahead) ? ahead : 0,
|
|
278
|
+
behind: 0,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return {
|
|
283
|
+
hasUpstream: false,
|
|
284
|
+
ahead: 0,
|
|
285
|
+
behind: 0,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
hasUpstream: true,
|
|
291
|
+
ahead: 0,
|
|
292
|
+
behind: 0,
|
|
293
|
+
};
|
|
294
|
+
};
|
|
295
|
+
const getBranchSyncState = (entries, cwd, recordedBase = null) => {
|
|
296
|
+
const branchState = entries
|
|
297
|
+
.filter(entry => entry.startsWith('# '))
|
|
298
|
+
.reduce((current, entry) => parseBranchHeader(entry, current), {
|
|
299
|
+
branch: null,
|
|
300
|
+
ahead: 0,
|
|
301
|
+
behind: 0,
|
|
302
|
+
});
|
|
303
|
+
const trackingState = branchState.branch ? getBranchTrackingState(branchState.branch, cwd) : null;
|
|
304
|
+
const recordedAhead = trackingState?.hasUpstream === false
|
|
305
|
+
? resolveRecordedBaseAhead(cwd, recordedBase?.baseBranch ?? null, recordedBase?.baseCommit ?? null)
|
|
306
|
+
: null;
|
|
307
|
+
const ahead = branchState.ahead > 0 || branchState.behind > 0
|
|
308
|
+
? branchState.ahead
|
|
309
|
+
: (recordedAhead ?? trackingState?.ahead ?? 0);
|
|
310
|
+
const behind = branchState.ahead > 0 || branchState.behind > 0
|
|
311
|
+
? branchState.behind
|
|
312
|
+
: (trackingState?.behind ?? 0);
|
|
313
|
+
return {
|
|
314
|
+
branch: branchState.branch,
|
|
315
|
+
ahead,
|
|
316
|
+
behind,
|
|
317
|
+
hasUpstream: trackingState?.hasUpstream ?? false,
|
|
318
|
+
canPush: Boolean(branchState.branch) && ((trackingState?.hasUpstream ?? true) || ahead > 0),
|
|
319
|
+
};
|
|
320
|
+
};
|
|
321
|
+
const getEmptyProjectBranchResponse = (status = 'unknown') => ({
|
|
322
|
+
branch: null,
|
|
323
|
+
status,
|
|
324
|
+
ahead: 0,
|
|
325
|
+
behind: 0,
|
|
326
|
+
has_upstream: false,
|
|
327
|
+
can_pull: false,
|
|
328
|
+
diff_stats: null,
|
|
329
|
+
});
|
|
330
|
+
const refreshProjectBranchTracking = (repoPath, branch) => {
|
|
331
|
+
if (!branch) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
runGitText(['fetch', '--quiet', 'origin', branch], repoPath);
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
// Keep the last known tracking state when fetch is unavailable.
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
const shouldRefreshProjectBranchTracking = (cacheKey, force) => {
|
|
342
|
+
if (force) {
|
|
343
|
+
lastProjectBranchRemoteRefreshAt.set(cacheKey, Date.now());
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
const lastRefreshedAt = lastProjectBranchRemoteRefreshAt.get(cacheKey) ?? 0;
|
|
347
|
+
if (Date.now() - lastRefreshedAt < PROJECT_BRANCH_REMOTE_REFRESH_TTL_MS) {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
lastProjectBranchRemoteRefreshAt.set(cacheKey, Date.now());
|
|
351
|
+
return true;
|
|
352
|
+
};
|
|
353
|
+
const parseUnpushedCommitsOutput = (output) => {
|
|
354
|
+
const entries = output.split('\0').filter(Boolean);
|
|
355
|
+
const commits = [];
|
|
356
|
+
for (let index = 0; index + 3 < entries.length; index += 4) {
|
|
357
|
+
commits.push({
|
|
358
|
+
hash: entries[index] ?? '',
|
|
359
|
+
short_hash: entries[index + 1] ?? '',
|
|
360
|
+
subject: entries[index + 2] ?? '',
|
|
361
|
+
committed_at: entries[index + 3] ?? '',
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
return commits.filter(commit => commit.hash && commit.short_hash && commit.subject);
|
|
365
|
+
};
|
|
366
|
+
const resolveDefaultBranchRef = (cwd) => {
|
|
367
|
+
const candidates = ['refs/remotes/origin/HEAD', 'refs/remotes/origin/main', 'refs/remotes/origin/master', 'refs/heads/main', 'refs/heads/master'];
|
|
368
|
+
for (const candidate of candidates) {
|
|
369
|
+
try {
|
|
370
|
+
const resolved = runGitText(['rev-parse', '--symbolic-full-name', candidate], cwd).trim();
|
|
371
|
+
if (resolved) {
|
|
372
|
+
return resolved;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
// Try the next candidate.
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return null;
|
|
380
|
+
};
|
|
381
|
+
const resolveNamedBranchRef = (branch, cwd) => {
|
|
382
|
+
if (!branch) {
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
const candidates = [`refs/heads/${branch}`, `refs/remotes/origin/${branch}`];
|
|
386
|
+
for (const candidate of candidates) {
|
|
387
|
+
try {
|
|
388
|
+
const resolved = runGitText(['rev-parse', '--symbolic-full-name', '--verify', candidate], cwd).trim();
|
|
389
|
+
if (resolved) {
|
|
390
|
+
return resolved;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
// Try the next candidate.
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return null;
|
|
398
|
+
};
|
|
399
|
+
const resolveCommitBaseFromBaseRef = (baseRef, cwd) => {
|
|
400
|
+
if (!baseRef) {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
const forkPoint = runGitText(['merge-base', '--fork-point', baseRef, 'HEAD'], cwd).trim();
|
|
405
|
+
if (forkPoint) {
|
|
406
|
+
return forkPoint;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
// Fall back to a plain merge-base below.
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
const mergeBase = runGitText(['merge-base', baseRef, 'HEAD'], cwd).trim();
|
|
414
|
+
if (mergeBase) {
|
|
415
|
+
return mergeBase;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
// Fall back to the broader local-only query below.
|
|
420
|
+
}
|
|
421
|
+
return null;
|
|
422
|
+
};
|
|
423
|
+
const resolveCommitRangeFromBaseRef = (baseRef, cwd) => {
|
|
424
|
+
const baseCommit = resolveCommitBaseFromBaseRef(baseRef, cwd);
|
|
425
|
+
return baseCommit ? `${baseCommit}..HEAD` : null;
|
|
426
|
+
};
|
|
427
|
+
const resolveBranchUniqueRange = (cwd) => {
|
|
428
|
+
return resolveCommitRangeFromBaseRef(resolveDefaultBranchRef(cwd), cwd);
|
|
429
|
+
};
|
|
430
|
+
const resolveRecordedBaseRange = (cwd, baseBranch = null, baseCommit = null) => {
|
|
431
|
+
const baseBranchRange = resolveCommitRangeFromBaseRef(resolveNamedBranchRef(baseBranch, cwd), cwd);
|
|
432
|
+
if (baseBranchRange) {
|
|
433
|
+
return baseBranchRange;
|
|
434
|
+
}
|
|
435
|
+
if (baseCommit) {
|
|
436
|
+
return `${baseCommit}..HEAD`;
|
|
437
|
+
}
|
|
438
|
+
return null;
|
|
439
|
+
};
|
|
440
|
+
const resolveUnpushedCommitRange = (cwd, branch, baseBranch = null, baseCommit = null) => {
|
|
441
|
+
const upstreamRef = resolveBranchUpstreamRef(branch, cwd);
|
|
442
|
+
if (upstreamRef) {
|
|
443
|
+
return `${upstreamRef}..HEAD`;
|
|
444
|
+
}
|
|
445
|
+
const recordedBaseRange = resolveRecordedBaseRange(cwd, baseBranch, baseCommit);
|
|
446
|
+
if (recordedBaseRange) {
|
|
447
|
+
return recordedBaseRange;
|
|
448
|
+
}
|
|
449
|
+
return resolveBranchUniqueRange(cwd);
|
|
450
|
+
};
|
|
451
|
+
export const resolveBundleReviewBase = ({ cwd, branch, project, pullRequestBaseBranch = null, }) => {
|
|
452
|
+
const normalizedPullRequestBaseBranch = pullRequestBaseBranch?.trim() || null;
|
|
453
|
+
if (normalizedPullRequestBaseBranch) {
|
|
454
|
+
const baseCommit = resolveCommitBaseFromBaseRef(resolveNamedBranchRef(normalizedPullRequestBaseBranch, cwd), cwd);
|
|
455
|
+
if (baseCommit) {
|
|
456
|
+
return {
|
|
457
|
+
baseBranch: normalizedPullRequestBaseBranch,
|
|
458
|
+
baseCommit,
|
|
459
|
+
source: 'pull_request',
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const recordedBase = project ? resolveRecordedBaseForCwd(cwd, branch, project) : null;
|
|
464
|
+
if (!recordedBase) {
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
const baseBranch = recordedBase.baseBranch?.trim() || null;
|
|
468
|
+
if (baseBranch) {
|
|
469
|
+
const baseCommit = resolveCommitBaseFromBaseRef(resolveNamedBranchRef(baseBranch, cwd), cwd);
|
|
470
|
+
if (baseCommit) {
|
|
471
|
+
return {
|
|
472
|
+
baseBranch,
|
|
473
|
+
baseCommit,
|
|
474
|
+
source: 'worktree_base',
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const baseCommit = recordedBase.baseCommit?.trim() || null;
|
|
479
|
+
if (!baseCommit) {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
baseBranch,
|
|
484
|
+
baseCommit,
|
|
485
|
+
source: 'worktree_base',
|
|
486
|
+
};
|
|
487
|
+
};
|
|
488
|
+
const getDiffDataForBaseRef = (cwd, baseRef) => {
|
|
489
|
+
try {
|
|
490
|
+
const trackedNumstat = parseNumstatOutput(runGitText(['diff', '--numstat', '--find-renames', '--no-ext-diff', baseRef], cwd));
|
|
491
|
+
const untrackedPaths = listUntrackedFiles(cwd);
|
|
492
|
+
const untrackedNumstat = untrackedPaths
|
|
493
|
+
.map(path => getUntrackedNumstatEntry(path, cwd))
|
|
494
|
+
.filter((entry) => entry !== null);
|
|
495
|
+
return {
|
|
496
|
+
files: [...trackedNumstat, ...untrackedNumstat].map(entry => ({
|
|
497
|
+
path: entry.path,
|
|
498
|
+
additions: entry.additions,
|
|
499
|
+
deletions: entry.deletions,
|
|
500
|
+
})),
|
|
501
|
+
diff: [
|
|
502
|
+
runGitText(['diff', '-U3', '--find-renames', '--no-ext-diff', baseRef], cwd),
|
|
503
|
+
...untrackedPaths.map(path => getUntrackedUnifiedDiff(path, cwd)),
|
|
504
|
+
].join(''),
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
return {
|
|
509
|
+
files: [],
|
|
510
|
+
diff: '',
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
const listUnpushedCommitRecords = (cwd, branch, baseBranch = null, baseCommit = null) => {
|
|
515
|
+
try {
|
|
516
|
+
const branchUniqueRange = resolveUnpushedCommitRange(cwd, branch, baseBranch, baseCommit);
|
|
517
|
+
const output = runGitText([
|
|
518
|
+
'log',
|
|
519
|
+
'--format=%H%x00%h%x00%s%x00%cI%x00',
|
|
520
|
+
'--no-show-signature',
|
|
521
|
+
'-n',
|
|
522
|
+
'20',
|
|
523
|
+
...(branchUniqueRange ? [branchUniqueRange] : ['HEAD', '--not', '--remotes']),
|
|
524
|
+
], cwd);
|
|
525
|
+
return parseUnpushedCommitsOutput(output);
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
return [];
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
const isConflictCode = (code) => code.includes('U') || code === 'AA' || code === 'DD';
|
|
532
|
+
const toFileStatus = (code) => {
|
|
533
|
+
if (code.includes('R')) {
|
|
534
|
+
return 'renamed';
|
|
535
|
+
}
|
|
536
|
+
if (code.includes('A') || code === '??') {
|
|
537
|
+
return 'added';
|
|
538
|
+
}
|
|
539
|
+
if (code.includes('D')) {
|
|
540
|
+
return 'deleted';
|
|
541
|
+
}
|
|
542
|
+
if (code.includes('T')) {
|
|
543
|
+
return 'typechanged';
|
|
544
|
+
}
|
|
545
|
+
if (code === '??') {
|
|
546
|
+
return 'untracked';
|
|
547
|
+
}
|
|
548
|
+
return 'modified';
|
|
549
|
+
};
|
|
550
|
+
const parsePorcelainEntry = (entry, originalPath = null) => {
|
|
551
|
+
if (!entry) {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
if (entry.startsWith('? ')) {
|
|
555
|
+
const path = entry.slice(2);
|
|
556
|
+
return path
|
|
557
|
+
? {
|
|
558
|
+
path,
|
|
559
|
+
originalPath: null,
|
|
560
|
+
status: 'untracked',
|
|
561
|
+
staged: false,
|
|
562
|
+
unstaged: true,
|
|
563
|
+
conflicted: false,
|
|
564
|
+
}
|
|
565
|
+
: null;
|
|
566
|
+
}
|
|
567
|
+
if (entry.startsWith('! ')) {
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
const kind = entry[0];
|
|
571
|
+
if (kind !== '1' && kind !== '2' && kind !== 'u') {
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
const parts = entry.split(' ');
|
|
575
|
+
if (parts.length < 9) {
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
const xy = parts[1] ?? '..';
|
|
579
|
+
const path = kind === '2' ? (parts[9] ?? '') : (parts[8] ?? '');
|
|
580
|
+
if (!path) {
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
const stagedCode = xy[0] ?? '.';
|
|
584
|
+
const unstagedCode = xy[1] ?? '.';
|
|
585
|
+
const conflicted = kind === 'u' || isConflictCode(xy);
|
|
586
|
+
return {
|
|
587
|
+
path,
|
|
588
|
+
originalPath,
|
|
589
|
+
status: conflicted ? 'modified' : toFileStatus(kind === '2' ? `R${xy}` : xy),
|
|
590
|
+
staged: conflicted ? false : stagedCode !== '.',
|
|
591
|
+
unstaged: conflicted ? false : unstagedCode !== '.',
|
|
592
|
+
conflicted,
|
|
593
|
+
};
|
|
594
|
+
};
|
|
595
|
+
const getCommitBlockedReason = (files) => {
|
|
596
|
+
if (files.some(file => file.conflicted)) {
|
|
597
|
+
return 'Resolve conflicted files in the terminal before committing from the UI.';
|
|
598
|
+
}
|
|
599
|
+
if (files.some(file => file.staged && file.unstaged)) {
|
|
600
|
+
return 'Files with both staged and unstaged changes must be committed from the terminal.';
|
|
601
|
+
}
|
|
602
|
+
return null;
|
|
603
|
+
};
|
|
604
|
+
const getStatusSelectionBlockedReason = (files, selectedPaths) => {
|
|
605
|
+
for (const file of files) {
|
|
606
|
+
if (!selectedPaths.has(file.path) && file.staged) {
|
|
607
|
+
return `"${file.path}" already has staged changes. Use the terminal to finish that commit first.`;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return null;
|
|
611
|
+
};
|
|
612
|
+
const getWorkspaceEntryFingerprint = (cwd, entry) => {
|
|
613
|
+
try {
|
|
614
|
+
const entryPath = resolve(cwd, entry.path);
|
|
615
|
+
const stat = statSync(entryPath);
|
|
616
|
+
const kind = stat.isDirectory() ? 'dir' : 'file';
|
|
617
|
+
return `${entry.path}:${kind}:${stat.size}:${Math.trunc(stat.mtimeMs)}`;
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
return `${entry.path}:missing`;
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
const getWorkspaceSignature = (output, cwd, entries) => {
|
|
624
|
+
const fileFingerprints = [...entries]
|
|
625
|
+
.sort((left, right) => left.path.localeCompare(right.path) || (left.originalPath ?? '').localeCompare(right.originalPath ?? ''))
|
|
626
|
+
.map(entry => {
|
|
627
|
+
return [
|
|
628
|
+
entry.path,
|
|
629
|
+
entry.originalPath ?? '',
|
|
630
|
+
entry.status,
|
|
631
|
+
entry.staged ? '1' : '0',
|
|
632
|
+
entry.unstaged ? '1' : '0',
|
|
633
|
+
entry.conflicted ? '1' : '0',
|
|
634
|
+
getWorkspaceEntryFingerprint(cwd, entry),
|
|
635
|
+
].join('\u0001');
|
|
636
|
+
})
|
|
637
|
+
.join('\0');
|
|
638
|
+
return createTextSignature(`${output}\0${fileFingerprints}`);
|
|
639
|
+
};
|
|
640
|
+
const parseGitStatusOutput = (output) => {
|
|
641
|
+
const entries = output.split('\0').filter(Boolean);
|
|
642
|
+
const parsedEntries = [];
|
|
643
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
644
|
+
const entry = entries[index];
|
|
645
|
+
if (entry.startsWith('# ')) {
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
const kind = entry[0];
|
|
649
|
+
const nextOriginalPath = kind === '2' ? (entries[index + 1] ?? null) : null;
|
|
650
|
+
const parsed = parsePorcelainEntry(entry, nextOriginalPath);
|
|
651
|
+
if (parsed) {
|
|
652
|
+
parsedEntries.push(parsed);
|
|
653
|
+
}
|
|
654
|
+
if (kind === '2') {
|
|
655
|
+
index += 1;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
return {
|
|
659
|
+
entries,
|
|
660
|
+
parsedEntries,
|
|
661
|
+
};
|
|
662
|
+
};
|
|
663
|
+
const buildTicketGitStatus = (output, cwd, { entries, parsedEntries }, recordedBase = null, options = {}) => {
|
|
664
|
+
const includeFiles = options.includeFiles ?? true;
|
|
665
|
+
const includeWorkspaceSignature = options.includeWorkspaceSignature ?? true;
|
|
666
|
+
const files = includeFiles
|
|
667
|
+
? parsedEntries.map(entry => ({
|
|
668
|
+
path: entry.path,
|
|
669
|
+
original_path: entry.originalPath,
|
|
670
|
+
status: entry.status,
|
|
671
|
+
staged: entry.staged,
|
|
672
|
+
unstaged: entry.unstaged,
|
|
673
|
+
conflicted: entry.conflicted,
|
|
674
|
+
}))
|
|
675
|
+
: [];
|
|
676
|
+
const blockedReason = includeFiles ? getCommitBlockedReason(files) : null;
|
|
677
|
+
const hasStagedChanges = parsedEntries.some(entry => entry.staged);
|
|
678
|
+
const hasUnstagedChanges = parsedEntries.some(entry => entry.unstaged || entry.status === 'untracked');
|
|
679
|
+
const branchSyncState = getBranchSyncState(entries, cwd, recordedBase);
|
|
680
|
+
const workspaceSignature = includeWorkspaceSignature
|
|
681
|
+
? getWorkspaceSignature(output, cwd, parsedEntries)
|
|
682
|
+
: null;
|
|
683
|
+
return {
|
|
684
|
+
branch: branchSyncState.branch,
|
|
685
|
+
ahead: branchSyncState.ahead,
|
|
686
|
+
behind: branchSyncState.behind,
|
|
687
|
+
has_upstream: branchSyncState.hasUpstream,
|
|
688
|
+
workspace_signature: workspaceSignature,
|
|
689
|
+
is_dirty: parsedEntries.length > 0,
|
|
690
|
+
has_staged_changes: hasStagedChanges,
|
|
691
|
+
has_unstaged_changes: hasUnstagedChanges,
|
|
692
|
+
files,
|
|
693
|
+
can_commit: includeFiles && files.length > 0 && blockedReason === null,
|
|
694
|
+
can_push: branchSyncState.canPush,
|
|
695
|
+
blocked_reason: blockedReason,
|
|
696
|
+
};
|
|
697
|
+
};
|
|
698
|
+
const listUntrackedFiles = (cwd) => {
|
|
699
|
+
return runGitText(['ls-files', '--others', '--exclude-standard'], cwd)
|
|
700
|
+
.split('\n')
|
|
701
|
+
.map(line => line.trim())
|
|
702
|
+
.filter(Boolean);
|
|
703
|
+
};
|
|
704
|
+
const listUntrackedFilesAsync = async (cwd) => {
|
|
705
|
+
return (await runGitTextAsync(['ls-files', '--others', '--exclude-standard'], cwd)).stdout
|
|
706
|
+
.split('\n')
|
|
707
|
+
.map(line => line.trim())
|
|
708
|
+
.filter(Boolean);
|
|
709
|
+
};
|
|
710
|
+
const getUntrackedNumstatEntry = (path, cwd) => {
|
|
711
|
+
const output = runGitDiffText(['diff', '--no-index', '--numstat', '--', '/dev/null', path], cwd);
|
|
712
|
+
const entry = parseNumstatOutput(output)[0];
|
|
713
|
+
if (!entry) {
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
return {
|
|
717
|
+
...entry,
|
|
718
|
+
path: normalizeNoIndexPath(entry.path),
|
|
719
|
+
};
|
|
720
|
+
};
|
|
721
|
+
const getUntrackedNumstatEntryAsync = async (path, cwd) => {
|
|
722
|
+
const output = await runGitDiffTextAsync(['diff', '--no-index', '--numstat', '--', '/dev/null', path], cwd);
|
|
723
|
+
const entry = parseNumstatOutput(output)[0];
|
|
724
|
+
if (!entry) {
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
return {
|
|
728
|
+
...entry,
|
|
729
|
+
path: normalizeNoIndexPath(entry.path),
|
|
730
|
+
};
|
|
731
|
+
};
|
|
732
|
+
const getUntrackedUnifiedDiff = (path, cwd) => {
|
|
733
|
+
return runGitDiffText(['diff', '--no-index', '-U3', '--', '/dev/null', path], cwd);
|
|
734
|
+
};
|
|
735
|
+
const getUntrackedUnifiedDiffAsync = (path, cwd) => {
|
|
736
|
+
return runGitDiffTextAsync(['diff', '--no-index', '-U3', '--', '/dev/null', path], cwd);
|
|
737
|
+
};
|
|
738
|
+
const getSelectedDiffDataForCwd = (cwd, selectedPaths) => {
|
|
739
|
+
const normalizedPaths = [...new Set(selectedPaths.map(path => path.trim()).filter(Boolean))];
|
|
740
|
+
if (normalizedPaths.length === 0) {
|
|
741
|
+
return {
|
|
742
|
+
files: [],
|
|
743
|
+
diff: '',
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
try {
|
|
747
|
+
const untrackedPathSet = new Set(listUntrackedFiles(cwd));
|
|
748
|
+
const trackedPaths = normalizedPaths.filter(path => !untrackedPathSet.has(path));
|
|
749
|
+
const untrackedPaths = normalizedPaths.filter(path => untrackedPathSet.has(path));
|
|
750
|
+
const trackedNumstat = trackedPaths.length > 0
|
|
751
|
+
? parseNumstatOutput(runGitText(['diff', '--numstat', '--find-renames', '--no-ext-diff', 'HEAD', '--', ...trackedPaths], cwd))
|
|
752
|
+
: [];
|
|
753
|
+
const untrackedNumstat = untrackedPaths
|
|
754
|
+
.map(path => getUntrackedNumstatEntry(path, cwd))
|
|
755
|
+
.filter((entry) => entry !== null);
|
|
756
|
+
return {
|
|
757
|
+
files: [...trackedNumstat, ...untrackedNumstat].map(entry => ({
|
|
758
|
+
path: entry.path,
|
|
759
|
+
additions: entry.additions,
|
|
760
|
+
deletions: entry.deletions,
|
|
761
|
+
})),
|
|
762
|
+
diff: [
|
|
763
|
+
trackedPaths.length > 0
|
|
764
|
+
? runGitText(['diff', '-U3', '--find-renames', '--no-ext-diff', 'HEAD', '--', ...trackedPaths], cwd)
|
|
765
|
+
: '',
|
|
766
|
+
...untrackedPaths.map(path => getUntrackedUnifiedDiff(path, cwd)),
|
|
767
|
+
].join(''),
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
catch {
|
|
771
|
+
return {
|
|
772
|
+
files: [],
|
|
773
|
+
diff: '',
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
const getWorktreeDiffData = (branch, project = null) => {
|
|
778
|
+
const cwd = resolveWorktreePath(branch, project);
|
|
779
|
+
return getDiffDataForCwd(cwd);
|
|
780
|
+
};
|
|
781
|
+
const buildDiffFilesForCwd = (cwd, untrackedPaths) => {
|
|
782
|
+
const trackedNumstat = parseNumstatOutput(runGitText(['diff', '--numstat', '--find-renames', '--no-ext-diff', 'HEAD'], cwd));
|
|
783
|
+
const untrackedNumstat = untrackedPaths
|
|
784
|
+
.map(path => getUntrackedNumstatEntry(path, cwd))
|
|
785
|
+
.filter((entry) => entry !== null);
|
|
786
|
+
return [...trackedNumstat, ...untrackedNumstat].map(entry => ({
|
|
787
|
+
path: entry.path,
|
|
788
|
+
additions: entry.additions,
|
|
789
|
+
deletions: entry.deletions,
|
|
790
|
+
}));
|
|
791
|
+
};
|
|
792
|
+
const getDiffFilesForCwd = (cwd) => {
|
|
793
|
+
return buildDiffFilesForCwd(cwd, listUntrackedFiles(cwd));
|
|
794
|
+
};
|
|
795
|
+
const getDiffDataForCwd = (cwd) => {
|
|
796
|
+
try {
|
|
797
|
+
const untrackedPaths = listUntrackedFiles(cwd);
|
|
798
|
+
const files = buildDiffFilesForCwd(cwd, untrackedPaths);
|
|
799
|
+
return {
|
|
800
|
+
files,
|
|
801
|
+
diff: [
|
|
802
|
+
runGitText(['diff', '-U3', '--find-renames', '--no-ext-diff', 'HEAD'], cwd),
|
|
803
|
+
...untrackedPaths.map(path => getUntrackedUnifiedDiff(path, cwd)),
|
|
804
|
+
].join(''),
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
catch {
|
|
808
|
+
return {
|
|
809
|
+
files: [],
|
|
810
|
+
diff: '',
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
const getDiffDataForCwdAsync = async (cwd) => {
|
|
815
|
+
try {
|
|
816
|
+
const trackedNumstat = parseNumstatOutput((await runGitTextAsync(['diff', '--numstat', '--find-renames', '--no-ext-diff', 'HEAD'], cwd)).stdout);
|
|
817
|
+
const untrackedPaths = await listUntrackedFilesAsync(cwd);
|
|
818
|
+
const untrackedNumstat = (await Promise.all(untrackedPaths.map(path => getUntrackedNumstatEntryAsync(path, cwd))))
|
|
819
|
+
.filter((entry) => entry !== null);
|
|
820
|
+
const trackedDiff = (await runGitTextAsync(['diff', '-U3', '--find-renames', '--no-ext-diff', 'HEAD'], cwd)).stdout;
|
|
821
|
+
const untrackedDiffs = await Promise.all(untrackedPaths.map(path => getUntrackedUnifiedDiffAsync(path, cwd)));
|
|
822
|
+
return {
|
|
823
|
+
files: [...trackedNumstat, ...untrackedNumstat].map(entry => ({
|
|
824
|
+
path: entry.path,
|
|
825
|
+
additions: entry.additions,
|
|
826
|
+
deletions: entry.deletions,
|
|
827
|
+
})),
|
|
828
|
+
diff: [trackedDiff, ...untrackedDiffs].join(''),
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
return {
|
|
833
|
+
files: [],
|
|
834
|
+
diff: '',
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
export const summarizeTicketDiffFiles = (files) => {
|
|
839
|
+
const additions = files.reduce((sum, entry) => sum + entry.additions, 0);
|
|
840
|
+
const deletions = files.reduce((sum, entry) => sum + entry.deletions, 0);
|
|
841
|
+
return {
|
|
842
|
+
files_changed: files.length,
|
|
843
|
+
additions,
|
|
844
|
+
deletions,
|
|
845
|
+
is_dirty: files.length > 0,
|
|
846
|
+
};
|
|
847
|
+
};
|
|
848
|
+
const getRegisteredWorktreePath = (branch, project, worktreeRecordsCache) => {
|
|
849
|
+
const record = listWorktreeRecords(project, worktreeRecordsCache).find(worktree => worktree.branch === branch);
|
|
850
|
+
return record?.path ?? null;
|
|
851
|
+
};
|
|
852
|
+
export const branchExistsLocally = (branch, project) => {
|
|
853
|
+
const repoPath = getRepoPath(project);
|
|
854
|
+
try {
|
|
855
|
+
execFileSync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], {
|
|
856
|
+
cwd: repoPath,
|
|
857
|
+
stdio: 'ignore',
|
|
858
|
+
});
|
|
859
|
+
return true;
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
return false;
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
export const listLocalBranches = (project = null) => {
|
|
866
|
+
const repoPath = getRepoPath(project);
|
|
867
|
+
const currentBranch = getProjectBranch(project, { includeDiffStats: false }).branch;
|
|
868
|
+
const output = runGitText(['for-each-ref', '--format=%(refname:short)', 'refs/heads'], repoPath);
|
|
869
|
+
const branches = output
|
|
870
|
+
.split('\n')
|
|
871
|
+
.map(line => line.trim())
|
|
872
|
+
.filter(Boolean);
|
|
873
|
+
const deduped = [...new Set(branches)];
|
|
874
|
+
deduped.sort((left, right) => {
|
|
875
|
+
if (currentBranch && left === currentBranch) {
|
|
876
|
+
return -1;
|
|
877
|
+
}
|
|
878
|
+
if (currentBranch && right === currentBranch) {
|
|
879
|
+
return 1;
|
|
880
|
+
}
|
|
881
|
+
return left.localeCompare(right);
|
|
882
|
+
});
|
|
883
|
+
return deduped;
|
|
884
|
+
};
|
|
885
|
+
const readOptionalGitText = (args, cwd) => {
|
|
886
|
+
try {
|
|
887
|
+
return runGitText(args, cwd).trim() || null;
|
|
888
|
+
}
|
|
889
|
+
catch {
|
|
890
|
+
return null;
|
|
891
|
+
}
|
|
892
|
+
};
|
|
893
|
+
export const getProjectBranchSuggestions = (project = null) => {
|
|
894
|
+
const repoPath = getRepoPath(project);
|
|
895
|
+
const currentBranch = getProjectBranch(project, { includeDiffStats: false }).branch;
|
|
896
|
+
const defaultRemoteHead = readOptionalGitText(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], repoPath);
|
|
897
|
+
const defaultBranchFromRemote = defaultRemoteHead?.startsWith('origin/')
|
|
898
|
+
? defaultRemoteHead.slice('origin/'.length)
|
|
899
|
+
: defaultRemoteHead;
|
|
900
|
+
const defaultBranch = defaultBranchFromRemote
|
|
901
|
+
?? (branchExistsLocally('main', project) ? 'main' : null)
|
|
902
|
+
?? (branchExistsLocally('master', project) ? 'master' : null)
|
|
903
|
+
?? currentBranch
|
|
904
|
+
?? null;
|
|
905
|
+
const previousBranch = readOptionalGitText(['rev-parse', '--abbrev-ref', '@{-1}'], repoPath);
|
|
906
|
+
return {
|
|
907
|
+
currentBranch,
|
|
908
|
+
defaultBranch,
|
|
909
|
+
previousBranch: previousBranch === 'HEAD' ? null : previousBranch,
|
|
910
|
+
};
|
|
911
|
+
};
|
|
912
|
+
export const switchProjectBranch = (branch, project = null, options = {}) => {
|
|
913
|
+
const trimmedBranch = branch.trim();
|
|
914
|
+
if (!trimmedBranch) {
|
|
915
|
+
throw new Error('Branch name is required');
|
|
916
|
+
}
|
|
917
|
+
const repoPath = getRepoPath(project);
|
|
918
|
+
const currentBranch = resolveCurrentBranchName(repoPath);
|
|
919
|
+
if (currentBranch === trimmedBranch) {
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
const status = getGitStatus(repoPath, null, project);
|
|
923
|
+
if (status.is_dirty && !options.allowDirty) {
|
|
924
|
+
throw new Error('Project root has uncommitted changes. Confirm branch switch to continue.');
|
|
925
|
+
}
|
|
926
|
+
const branchExists = branchExistsLocally(trimmedBranch, project);
|
|
927
|
+
if (!branchExists && !options.create) {
|
|
928
|
+
throw new Error(`Branch "${trimmedBranch}" does not exist locally.`);
|
|
929
|
+
}
|
|
930
|
+
const args = branchExists
|
|
931
|
+
? ['checkout', trimmedBranch]
|
|
932
|
+
: ['checkout', '-b', trimmedBranch];
|
|
933
|
+
runGitText(args, repoPath);
|
|
934
|
+
};
|
|
935
|
+
const isPathInsideRoot = (rootPath, candidatePath) => {
|
|
936
|
+
const relativePath = relative(rootPath, candidatePath);
|
|
937
|
+
return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath));
|
|
938
|
+
};
|
|
939
|
+
const resolvePathInsideRoot = (rootPath, relativePath, label) => {
|
|
940
|
+
const resolvedPath = resolve(rootPath, relativePath);
|
|
941
|
+
if (!isPathInsideRoot(rootPath, resolvedPath)) {
|
|
942
|
+
throw new Error(`${label} must stay inside ${rootPath}`);
|
|
943
|
+
}
|
|
944
|
+
return resolvedPath;
|
|
945
|
+
};
|
|
946
|
+
export const worktreePath = (branch, project = null) => join(getWorktreeBase(project), branch);
|
|
947
|
+
export const resolveExistingWorktreePath = (branch, project = null, options = {}) => {
|
|
948
|
+
const registeredPath = getRegisteredWorktreePath(branch, project, options.worktreeRecordsCache);
|
|
949
|
+
if (registeredPath) {
|
|
950
|
+
return registeredPath;
|
|
951
|
+
}
|
|
952
|
+
const candidatePath = worktreePath(branch, project);
|
|
953
|
+
return existsSync(candidatePath) ? candidatePath : null;
|
|
954
|
+
};
|
|
955
|
+
export const worktreeExists = (branch, project = null) => {
|
|
956
|
+
return resolveExistingWorktreePath(branch, project) !== null;
|
|
957
|
+
};
|
|
958
|
+
export const resolveWorktreePath = (branch, project = null) => {
|
|
959
|
+
return resolveExistingWorktreePath(branch, project) ?? worktreePath(branch, project);
|
|
960
|
+
};
|
|
961
|
+
export const createWorktree = (branch, project = null, options = {}) => {
|
|
962
|
+
const repoPath = getRepoPath(project);
|
|
963
|
+
const baseBranch = resolveCurrentBranchName(repoPath);
|
|
964
|
+
const requestedBaseBranch = options.baseBranch?.trim() || null;
|
|
965
|
+
const sourceBranch = requestedBaseBranch ?? baseBranch;
|
|
966
|
+
const targetPath = worktreePath(branch, project);
|
|
967
|
+
const branchAlreadyExists = branchExistsLocally(branch, project);
|
|
968
|
+
const args = branchAlreadyExists
|
|
969
|
+
? ['worktree', 'add', targetPath, branch]
|
|
970
|
+
: sourceBranch
|
|
971
|
+
? ['worktree', 'add', targetPath, '-b', branch, sourceBranch]
|
|
972
|
+
: ['worktree', 'add', targetPath, '-b', branch];
|
|
973
|
+
execFileSync('git', args, { cwd: repoPath });
|
|
974
|
+
const existingBase = branchAlreadyExists ? getRecordedWorktreeBase(branch, project) : null;
|
|
975
|
+
const baseCommit = branchAlreadyExists
|
|
976
|
+
? (existingBase?.baseCommit ?? resolveBranchMergeBase(baseBranch, branch, repoPath))
|
|
977
|
+
: resolveHeadCommit(repoPath);
|
|
978
|
+
saveWorktreeBase(branch, project, {
|
|
979
|
+
baseBranch: existingBase?.baseBranch ?? sourceBranch,
|
|
980
|
+
baseCommit,
|
|
981
|
+
});
|
|
982
|
+
};
|
|
983
|
+
export const createWorktreeAsync = async (branch, project = null, options = {}) => {
|
|
984
|
+
const repoPath = getRepoPath(project);
|
|
985
|
+
const baseBranch = resolveCurrentBranchName(repoPath);
|
|
986
|
+
const requestedBaseBranch = options.baseBranch?.trim() || null;
|
|
987
|
+
const sourceBranch = requestedBaseBranch ?? baseBranch;
|
|
988
|
+
const targetPath = worktreePath(branch, project);
|
|
989
|
+
const branchAlreadyExists = branchExistsLocally(branch, project);
|
|
990
|
+
const args = branchAlreadyExists
|
|
991
|
+
? ['worktree', 'add', targetPath, branch]
|
|
992
|
+
: sourceBranch
|
|
993
|
+
? ['worktree', 'add', targetPath, '-b', branch, sourceBranch]
|
|
994
|
+
: ['worktree', 'add', targetPath, '-b', branch];
|
|
995
|
+
await runGitTextAsync(args, repoPath);
|
|
996
|
+
const existingBase = branchAlreadyExists ? getRecordedWorktreeBase(branch, project) : null;
|
|
997
|
+
const baseCommit = branchAlreadyExists
|
|
998
|
+
? (existingBase?.baseCommit ?? resolveBranchMergeBase(baseBranch, branch, repoPath))
|
|
999
|
+
: resolveHeadCommit(repoPath);
|
|
1000
|
+
saveWorktreeBase(branch, project, {
|
|
1001
|
+
baseBranch: existingBase?.baseBranch ?? sourceBranch,
|
|
1002
|
+
baseCommit,
|
|
1003
|
+
});
|
|
1004
|
+
};
|
|
1005
|
+
export const deleteWorktree = (branch, project = null) => {
|
|
1006
|
+
const repoPath = getRepoPath(project);
|
|
1007
|
+
const registeredPath = getRegisteredWorktreePath(branch, project);
|
|
1008
|
+
const targetPath = registeredPath ?? worktreePath(branch, project);
|
|
1009
|
+
if (!registeredPath && !existsSync(targetPath)) {
|
|
1010
|
+
return false;
|
|
1011
|
+
}
|
|
1012
|
+
execFileSync('git', ['worktree', 'remove', '--force', targetPath], {
|
|
1013
|
+
cwd: repoPath,
|
|
1014
|
+
});
|
|
1015
|
+
deleteWorktreeBase(branch, project);
|
|
1016
|
+
return true;
|
|
1017
|
+
};
|
|
1018
|
+
export const getProjectBranch = (project = null, options = {}) => {
|
|
1019
|
+
const repoPath = getRepoPath(project);
|
|
1020
|
+
const includeDiffStats = options.includeDiffStats ?? true;
|
|
1021
|
+
let output = runGitText(['status', '--porcelain=v2', '--branch', '-z'], repoPath);
|
|
1022
|
+
let entries = output.split('\0').filter(Boolean);
|
|
1023
|
+
let branchState = getBranchSyncState(entries, repoPath);
|
|
1024
|
+
if (branchState.branch && shouldRefreshProjectBranchTracking(getProjectBranchRefreshCacheKey(repoPath, branchState.branch), options.refreshRemote ?? false)) {
|
|
1025
|
+
refreshProjectBranchTracking(repoPath, branchState.branch);
|
|
1026
|
+
output = runGitText(['status', '--porcelain=v2', '--branch', '-z'], repoPath);
|
|
1027
|
+
entries = output.split('\0').filter(Boolean);
|
|
1028
|
+
branchState = getBranchSyncState(entries, repoPath);
|
|
1029
|
+
}
|
|
1030
|
+
if (!branchState.branch) {
|
|
1031
|
+
const statusLine = output.split('\0').find(entry => entry.startsWith('# branch.head ')) ?? '';
|
|
1032
|
+
if (statusLine.endsWith('(detached)')) {
|
|
1033
|
+
return getEmptyProjectBranchResponse('detached');
|
|
1034
|
+
}
|
|
1035
|
+
return getEmptyProjectBranchResponse('unknown');
|
|
1036
|
+
}
|
|
1037
|
+
return {
|
|
1038
|
+
branch: branchState.branch,
|
|
1039
|
+
status: 'ready',
|
|
1040
|
+
ahead: branchState.ahead,
|
|
1041
|
+
behind: branchState.behind,
|
|
1042
|
+
has_upstream: branchState.hasUpstream,
|
|
1043
|
+
can_pull: branchState.behind > 0,
|
|
1044
|
+
diff_stats: includeDiffStats ? summarizeTicketDiffFiles(getGitDiffFiles(repoPath)) : null,
|
|
1045
|
+
};
|
|
1046
|
+
};
|
|
1047
|
+
export const pullGitBranch = async (cwd, branch = null, project = null) => {
|
|
1048
|
+
const gitStatus = getGitStatus(cwd, branch, project, { refreshRemote: true });
|
|
1049
|
+
if (!gitStatus.branch || gitStatus.behind === 0) {
|
|
1050
|
+
throw new Error('This branch is already up to date.');
|
|
1051
|
+
}
|
|
1052
|
+
await runGitTextAsync(['pull', '--ff-only'], cwd);
|
|
1053
|
+
};
|
|
1054
|
+
export const pullProjectBranch = async (project = null) => {
|
|
1055
|
+
const repoPath = getRepoPath(project);
|
|
1056
|
+
const branchState = getProjectBranch(project, { refreshRemote: true });
|
|
1057
|
+
await pullGitBranch(repoPath, branchState.branch, project);
|
|
1058
|
+
};
|
|
1059
|
+
export const getWorktreeDiffFiles = (branch, project = null) => {
|
|
1060
|
+
const cwd = resolveWorktreePath(branch, project);
|
|
1061
|
+
try {
|
|
1062
|
+
return getDiffFilesForCwd(cwd);
|
|
1063
|
+
}
|
|
1064
|
+
catch {
|
|
1065
|
+
return [];
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
export const getGitDiffFiles = (cwd) => {
|
|
1069
|
+
return getDiffDataForCwd(cwd).files;
|
|
1070
|
+
};
|
|
1071
|
+
export const getGitDiffData = (cwd) => {
|
|
1072
|
+
return getDiffDataForCwd(cwd);
|
|
1073
|
+
};
|
|
1074
|
+
export const getGitDiffFilesAsync = async (cwd) => {
|
|
1075
|
+
return (await getDiffDataForCwdAsync(cwd)).files;
|
|
1076
|
+
};
|
|
1077
|
+
export const getWorktreeDiffStats = (branch, project = null) => {
|
|
1078
|
+
return summarizeTicketDiffFiles(getWorktreeDiffFiles(branch, project));
|
|
1079
|
+
};
|
|
1080
|
+
export const getWorktreeUnifiedDiff = (branch, project = null) => {
|
|
1081
|
+
return getWorktreeDiffData(branch, project).diff;
|
|
1082
|
+
};
|
|
1083
|
+
export const getGitUnifiedDiff = (cwd) => {
|
|
1084
|
+
return getDiffDataForCwd(cwd).diff;
|
|
1085
|
+
};
|
|
1086
|
+
export const getGitUnifiedDiffAsync = async (cwd) => {
|
|
1087
|
+
return (await getDiffDataForCwdAsync(cwd)).diff;
|
|
1088
|
+
};
|
|
1089
|
+
export const getSelectedGitUnifiedDiff = (cwd, selectedPaths) => {
|
|
1090
|
+
return getSelectedDiffDataForCwd(cwd, selectedPaths).diff;
|
|
1091
|
+
};
|
|
1092
|
+
export const getBundleReviewDiffForCwd = ({ cwd, branch, project, pullRequestBaseBranch = null, }) => {
|
|
1093
|
+
const reviewBase = resolveBundleReviewBase({
|
|
1094
|
+
cwd,
|
|
1095
|
+
branch,
|
|
1096
|
+
project,
|
|
1097
|
+
pullRequestBaseBranch,
|
|
1098
|
+
});
|
|
1099
|
+
if (!reviewBase?.baseCommit) {
|
|
1100
|
+
return null;
|
|
1101
|
+
}
|
|
1102
|
+
const diffData = getDiffDataForBaseRef(cwd, reviewBase.baseCommit);
|
|
1103
|
+
const worktreeStatus = getGitStatus(cwd, branch, project);
|
|
1104
|
+
return {
|
|
1105
|
+
stats: {
|
|
1106
|
+
...summarizeTicketDiffFiles(diffData.files),
|
|
1107
|
+
is_dirty: worktreeStatus.is_dirty,
|
|
1108
|
+
},
|
|
1109
|
+
files: diffData.files,
|
|
1110
|
+
diff: diffData.diff,
|
|
1111
|
+
head_branch: branch,
|
|
1112
|
+
base_branch: reviewBase.baseBranch,
|
|
1113
|
+
base_commit: reviewBase.baseCommit,
|
|
1114
|
+
source: reviewBase.source,
|
|
1115
|
+
};
|
|
1116
|
+
};
|
|
1117
|
+
const resolveRecordedBaseForCwd = (cwd, branch, project) => {
|
|
1118
|
+
const repoPath = getRepoPath(project);
|
|
1119
|
+
return resolve(cwd) === repoPath ? null : getRecordedWorktreeBase(branch, project);
|
|
1120
|
+
};
|
|
1121
|
+
export const getGitStatus = (cwd, branch = null, project = null, options = {}) => {
|
|
1122
|
+
const recordedBase = branch && project ? resolveRecordedBaseForCwd(cwd, branch, project) : null;
|
|
1123
|
+
const branchName = branch ?? resolveCurrentBranchName(cwd);
|
|
1124
|
+
if (branchName && (options.refreshRemote ?? true) && shouldRefreshProjectBranchTracking(getProjectBranchRefreshCacheKey(cwd, branchName), options.refreshRemote ?? false)) {
|
|
1125
|
+
refreshProjectBranchTracking(cwd, branchName);
|
|
1126
|
+
}
|
|
1127
|
+
try {
|
|
1128
|
+
const output = runGitText(['status', '--porcelain=v2', '--branch', '--untracked-files=all', '-z'], cwd);
|
|
1129
|
+
const parsedOutput = parseGitStatusOutput(output);
|
|
1130
|
+
const status = buildTicketGitStatus(output, cwd, parsedOutput, recordedBase, options);
|
|
1131
|
+
const resolvedBranchName = status.branch ?? branchName;
|
|
1132
|
+
if (!recordedBase && resolvedBranchName && project) {
|
|
1133
|
+
const fallbackBase = resolveFallbackBase(resolvedBranchName, project);
|
|
1134
|
+
if (fallbackBase) {
|
|
1135
|
+
return buildTicketGitStatus(output, cwd, parsedOutput, fallbackBase, options);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
return status;
|
|
1139
|
+
}
|
|
1140
|
+
catch {
|
|
1141
|
+
return {
|
|
1142
|
+
branch: null,
|
|
1143
|
+
ahead: 0,
|
|
1144
|
+
behind: 0,
|
|
1145
|
+
has_upstream: false,
|
|
1146
|
+
workspace_signature: null,
|
|
1147
|
+
is_dirty: false,
|
|
1148
|
+
has_staged_changes: false,
|
|
1149
|
+
has_unstaged_changes: false,
|
|
1150
|
+
files: [],
|
|
1151
|
+
can_commit: false,
|
|
1152
|
+
can_push: false,
|
|
1153
|
+
blocked_reason: 'Unable to read git status for this worktree.',
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
};
|
|
1157
|
+
export const getWorktreeGitStatus = (branch, project = null) => {
|
|
1158
|
+
const cwd = resolveWorktreePath(branch, project);
|
|
1159
|
+
return getGitStatus(cwd, branch, project);
|
|
1160
|
+
};
|
|
1161
|
+
export const getBoardGitStatus = (cwd, branch = null, project = null) => {
|
|
1162
|
+
return getGitStatus(cwd, branch, project, {
|
|
1163
|
+
refreshRemote: false,
|
|
1164
|
+
includeFiles: false,
|
|
1165
|
+
includeWorkspaceSignature: false,
|
|
1166
|
+
});
|
|
1167
|
+
};
|
|
1168
|
+
export const getBoardWorktreeGitStatus = (branch, project = null) => {
|
|
1169
|
+
const cwd = resolveWorktreePath(branch, project);
|
|
1170
|
+
return getBoardGitStatus(cwd, branch, project);
|
|
1171
|
+
};
|
|
1172
|
+
export const commitGitChanges = async (cwd, branch, project, message, selectedPaths) => {
|
|
1173
|
+
const normalizedMessage = message.trim();
|
|
1174
|
+
if (!normalizedMessage) {
|
|
1175
|
+
throw new Error('Commit message is required.');
|
|
1176
|
+
}
|
|
1177
|
+
if (selectedPaths.length === 0) {
|
|
1178
|
+
throw new Error('Select at least one file to commit.');
|
|
1179
|
+
}
|
|
1180
|
+
const status = getGitStatus(cwd, branch, project);
|
|
1181
|
+
const selectedPathSet = new Set(selectedPaths);
|
|
1182
|
+
const blockedReason = getStatusSelectionBlockedReason(status.files, selectedPathSet) ?? status.blocked_reason;
|
|
1183
|
+
if (blockedReason) {
|
|
1184
|
+
throw new Error(blockedReason);
|
|
1185
|
+
}
|
|
1186
|
+
const availablePaths = new Set(status.files.map(file => file.path));
|
|
1187
|
+
for (const path of selectedPaths) {
|
|
1188
|
+
if (!availablePaths.has(path)) {
|
|
1189
|
+
throw new Error(`"${path}" is no longer available to commit.`);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
await runGitTextAsync(['add', '-A', '--', ...selectedPaths], cwd);
|
|
1193
|
+
await runGitTextAsync(['commit', '-m', normalizedMessage], cwd);
|
|
1194
|
+
};
|
|
1195
|
+
export const commitWorktreeChanges = async (branch, project, message, selectedPaths) => {
|
|
1196
|
+
const cwd = resolveWorktreePath(branch, project);
|
|
1197
|
+
await commitGitChanges(cwd, branch, project, message, selectedPaths);
|
|
1198
|
+
};
|
|
1199
|
+
export const pushCurrentBranch = async (cwd, branch) => {
|
|
1200
|
+
await runGitTextAsync(['push', '-u', 'origin', branch], cwd);
|
|
1201
|
+
};
|
|
1202
|
+
export const pushWorktreeBranch = async (branch, project = null) => {
|
|
1203
|
+
const cwd = resolveWorktreePath(branch, project);
|
|
1204
|
+
await pushCurrentBranch(cwd, branch);
|
|
1205
|
+
};
|
|
1206
|
+
export const listUnpushedCommitsForCwd = (cwd, branch, project = null) => {
|
|
1207
|
+
const recordedBase = project ? resolveRecordedBaseForCwd(cwd, branch, project) : null;
|
|
1208
|
+
const fallbackBase = recordedBase ?? (project ? resolveFallbackBase(branch, project) : null);
|
|
1209
|
+
return listUnpushedCommitRecords(cwd, branch, fallbackBase?.baseBranch ?? null, fallbackBase?.baseCommit ?? null);
|
|
1210
|
+
};
|
|
1211
|
+
export const listUnpushedCommits = (branch, project = null) => {
|
|
1212
|
+
const cwd = resolveWorktreePath(branch, project);
|
|
1213
|
+
return listUnpushedCommitsForCwd(cwd, branch, project);
|
|
1214
|
+
};
|
|
1215
|
+
export const undoLatestUnpushedCommitForCwd = async (cwd, branch, project = null) => {
|
|
1216
|
+
const commits = listUnpushedCommitsForCwd(cwd, branch, project);
|
|
1217
|
+
if (commits.length === 0) {
|
|
1218
|
+
throw new Error('There are no local commits to undo.');
|
|
1219
|
+
}
|
|
1220
|
+
await runGitTextAsync(['reset', '--mixed', 'HEAD~1'], cwd);
|
|
1221
|
+
};
|
|
1222
|
+
export const undoLatestUnpushedCommit = async (branch, project = null) => {
|
|
1223
|
+
const cwd = resolveWorktreePath(branch, project);
|
|
1224
|
+
await undoLatestUnpushedCommitForCwd(cwd, branch, project);
|
|
1225
|
+
};
|
|
1226
|
+
export const syncWorktreeFiles = (branch, project = null) => {
|
|
1227
|
+
if (!project) {
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
const syncItems = getProjectWorktreeSync(project);
|
|
1231
|
+
if (syncItems.length === 0) {
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
const repoPath = getRepoPath(project);
|
|
1235
|
+
const targetRoot = resolveWorktreePath(branch, project);
|
|
1236
|
+
for (const item of syncItems) {
|
|
1237
|
+
const sourcePath = resolvePathInsideRoot(repoPath, item.source, 'Worktree sync source');
|
|
1238
|
+
if (!existsSync(sourcePath)) {
|
|
1239
|
+
throw new Error(`Worktree sync source does not exist: ${item.source}`);
|
|
1240
|
+
}
|
|
1241
|
+
const targetRelativePath = item.target ?? item.source;
|
|
1242
|
+
const targetPath = resolvePathInsideRoot(targetRoot, targetRelativePath, 'Worktree sync target');
|
|
1243
|
+
if (existsSync(targetPath)) {
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
1247
|
+
if (item.mode === 'copy') {
|
|
1248
|
+
cpSync(sourcePath, targetPath, { recursive: true, force: false, errorOnExist: false });
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
const sourceStat = lstatSync(sourcePath);
|
|
1252
|
+
symlinkSync(sourcePath, targetPath, sourceStat.isDirectory() ? 'dir' : 'file');
|
|
1253
|
+
}
|
|
1254
|
+
};
|
|
1255
|
+
export const syncWorktreeFilesAsync = async (branch, project = null) => {
|
|
1256
|
+
if (!project) {
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
const syncItems = getProjectWorktreeSync(project);
|
|
1260
|
+
if (syncItems.length === 0) {
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
const repoPath = getRepoPath(project);
|
|
1264
|
+
const targetRoot = resolveWorktreePath(branch, project);
|
|
1265
|
+
for (const item of syncItems) {
|
|
1266
|
+
const sourcePath = resolvePathInsideRoot(repoPath, item.source, 'Worktree sync source');
|
|
1267
|
+
if (!existsSync(sourcePath)) {
|
|
1268
|
+
throw new Error(`Worktree sync source does not exist: ${item.source}`);
|
|
1269
|
+
}
|
|
1270
|
+
const targetRelativePath = item.target ?? item.source;
|
|
1271
|
+
const targetPath = resolvePathInsideRoot(targetRoot, targetRelativePath, 'Worktree sync target');
|
|
1272
|
+
if (existsSync(targetPath)) {
|
|
1273
|
+
continue;
|
|
1274
|
+
}
|
|
1275
|
+
await mkdirAsync(dirname(targetPath), { recursive: true });
|
|
1276
|
+
if (item.mode === 'copy') {
|
|
1277
|
+
await cpAsync(sourcePath, targetPath, { recursive: true, force: false, errorOnExist: false });
|
|
1278
|
+
continue;
|
|
1279
|
+
}
|
|
1280
|
+
const sourceStat = await lstatAsync(sourcePath);
|
|
1281
|
+
await symlinkAsync(sourcePath, targetPath, sourceStat.isDirectory() ? 'dir' : 'file');
|
|
1282
|
+
}
|
|
1283
|
+
};
|
|
1284
|
+
//# sourceMappingURL=git.js.map
|