dmux 5.6.1 → 5.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/DmuxApp.d.ts.map +1 -1
- package/dist/DmuxApp.js +38 -13
- package/dist/DmuxApp.js.map +1 -1
- package/dist/components/popups/projectSelectPopup.d.ts +6 -0
- package/dist/components/popups/projectSelectPopup.d.ts.map +1 -1
- package/dist/components/popups/projectSelectPopup.js +23 -4
- package/dist/components/popups/projectSelectPopup.js.map +1 -1
- package/dist/components/popups/reopenWorktreePopup.d.ts +26 -8
- package/dist/components/popups/reopenWorktreePopup.d.ts.map +1 -1
- package/dist/components/popups/reopenWorktreePopup.js +280 -50
- package/dist/components/popups/reopenWorktreePopup.js.map +1 -1
- package/dist/hooks/useInputHandling.d.ts +4 -1
- package/dist/hooks/useInputHandling.d.ts.map +1 -1
- package/dist/hooks/useInputHandling.js +65 -16
- package/dist/hooks/useInputHandling.js.map +1 -1
- package/dist/hooks/useNavigation.d.ts.map +1 -1
- package/dist/hooks/useNavigation.js +7 -8
- package/dist/hooks/useNavigation.js.map +1 -1
- package/dist/services/PaneAnalyzer.d.ts +1 -1
- package/dist/services/PaneAnalyzer.d.ts.map +1 -1
- package/dist/services/PaneAnalyzer.js +5 -2
- package/dist/services/PaneAnalyzer.js.map +1 -1
- package/dist/services/PopupManager.d.ts +11 -8
- package/dist/services/PopupManager.d.ts.map +1 -1
- package/dist/services/PopupManager.js +42 -6
- package/dist/services/PopupManager.js.map +1 -1
- package/dist/services/StatusDetector.d.ts.map +1 -1
- package/dist/services/StatusDetector.js +1 -1
- package/dist/services/StatusDetector.js.map +1 -1
- package/dist/services/WorktreeCleanupService.d.ts +2 -0
- package/dist/services/WorktreeCleanupService.d.ts.map +1 -1
- package/dist/services/WorktreeCleanupService.js +68 -6
- package/dist/services/WorktreeCleanupService.js.map +1 -1
- package/dist/utils/generated-agents-doc.d.ts +1 -1
- package/dist/utils/generated-agents-doc.js +1 -1
- package/dist/utils/paneAttentionHeuristics.d.ts +1 -0
- package/dist/utils/paneAttentionHeuristics.d.ts.map +1 -1
- package/dist/utils/paneAttentionHeuristics.js +89 -9
- package/dist/utils/paneAttentionHeuristics.js.map +1 -1
- package/dist/utils/paneCreation.d.ts +5 -0
- package/dist/utils/paneCreation.d.ts.map +1 -1
- package/dist/utils/paneCreation.js +88 -71
- package/dist/utils/paneCreation.js.map +1 -1
- package/dist/utils/projectRoot.d.ts +8 -0
- package/dist/utils/projectRoot.d.ts.map +1 -1
- package/dist/utils/projectRoot.js +73 -6
- package/dist/utils/projectRoot.js.map +1 -1
- package/dist/utils/reopenWorktree.d.ts +2 -0
- package/dist/utils/reopenWorktree.d.ts.map +1 -1
- package/dist/utils/reopenWorktree.js +5 -4
- package/dist/utils/reopenWorktree.js.map +1 -1
- package/dist/utils/resumeBranches.d.ts +29 -0
- package/dist/utils/resumeBranches.d.ts.map +1 -0
- package/dist/utils/resumeBranches.js +640 -0
- package/dist/utils/resumeBranches.js.map +1 -0
- package/dist/workers/PaneWorker.js +30 -17
- package/dist/workers/PaneWorker.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
import { exec, execSync } from 'child_process';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { triggerHook } from './hooks.js';
|
|
6
|
+
import { getOrphanedWorktrees, isValidBranchName } from './git.js';
|
|
7
|
+
import { createPane } from './paneCreation.js';
|
|
8
|
+
import { shellQuote } from './promptStore.js';
|
|
9
|
+
import { SettingsManager } from './settingsManager.js';
|
|
10
|
+
import { writeWorktreeMetadata } from './worktreeMetadata.js';
|
|
11
|
+
const REMOTE_FALLBACK = 'origin';
|
|
12
|
+
const RESUME_SCAN_EXCLUDED_DIRS = new Set([
|
|
13
|
+
'.dmux',
|
|
14
|
+
'.git',
|
|
15
|
+
'node_modules',
|
|
16
|
+
'vendor',
|
|
17
|
+
'.pnpm',
|
|
18
|
+
'.next',
|
|
19
|
+
'dist',
|
|
20
|
+
'build',
|
|
21
|
+
'coverage',
|
|
22
|
+
]);
|
|
23
|
+
function runGitText(cwd, args, options = {}) {
|
|
24
|
+
const command = `git ${args.map((arg) => shellQuote(arg)).join(' ')}`;
|
|
25
|
+
try {
|
|
26
|
+
return execSync(command, {
|
|
27
|
+
cwd,
|
|
28
|
+
encoding: 'utf-8',
|
|
29
|
+
stdio: 'pipe',
|
|
30
|
+
}).trim();
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (options.silent) {
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function runGitTextAsync(cwd, args, options = {}) {
|
|
40
|
+
const command = `git ${args.map((arg) => shellQuote(arg)).join(' ')}`;
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
exec(command, {
|
|
43
|
+
cwd,
|
|
44
|
+
encoding: 'utf-8',
|
|
45
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
46
|
+
}, (error, stdout) => {
|
|
47
|
+
if (error) {
|
|
48
|
+
if (options.silent) {
|
|
49
|
+
resolve('');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
reject(error);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
resolve(stdout.trim());
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function runGit(cwd, args, options = {}) {
|
|
60
|
+
const command = `git ${args.map((arg) => shellQuote(arg)).join(' ')}`;
|
|
61
|
+
try {
|
|
62
|
+
execSync(command, {
|
|
63
|
+
cwd,
|
|
64
|
+
stdio: 'pipe',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (!options.silent) {
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function runGitAsync(cwd, args, options = {}) {
|
|
74
|
+
await runGitTextAsync(cwd, args, options);
|
|
75
|
+
}
|
|
76
|
+
function isGitRepoRoot(dirPath) {
|
|
77
|
+
const gitPath = path.join(dirPath, '.git');
|
|
78
|
+
if (!fs.existsSync(gitPath)) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
return fs.statSync(gitPath).isDirectory() || fs.statSync(gitPath).isFile();
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function discoverWorkspaceRepos(projectRoot) {
|
|
89
|
+
const discovered = new Set([projectRoot]);
|
|
90
|
+
const walk = (dirPath) => {
|
|
91
|
+
let entries;
|
|
92
|
+
try {
|
|
93
|
+
entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
if (!entry.isDirectory()) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (RESUME_SCAN_EXCLUDED_DIRS.has(entry.name)) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
106
|
+
if (isGitRepoRoot(fullPath)) {
|
|
107
|
+
discovered.add(fullPath);
|
|
108
|
+
}
|
|
109
|
+
walk(fullPath);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
walk(projectRoot);
|
|
113
|
+
return Array.from(discovered).sort((left, right) => left.length - right.length);
|
|
114
|
+
}
|
|
115
|
+
function listLocalBranches(repoPath) {
|
|
116
|
+
const output = runGitText(repoPath, ['for-each-ref', '--format=%(refname:short)', 'refs/heads'], { silent: true });
|
|
117
|
+
return new Set(output
|
|
118
|
+
.split('\n')
|
|
119
|
+
.map((line) => line.trim())
|
|
120
|
+
.filter(Boolean));
|
|
121
|
+
}
|
|
122
|
+
function getCurrentBranchName(repoPath) {
|
|
123
|
+
return runGitText(repoPath, ['branch', '--show-current'], { silent: true }) || 'main';
|
|
124
|
+
}
|
|
125
|
+
function getPreferredRemoteName(repoPath) {
|
|
126
|
+
const upstream = runGitText(repoPath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], { silent: true });
|
|
127
|
+
if (upstream.includes('/')) {
|
|
128
|
+
return upstream.split('/')[0];
|
|
129
|
+
}
|
|
130
|
+
const currentBranch = getCurrentBranchName(repoPath);
|
|
131
|
+
if (currentBranch && currentBranch !== 'HEAD') {
|
|
132
|
+
const configuredRemote = runGitText(repoPath, ['config', `branch.${currentBranch}.remote`], { silent: true });
|
|
133
|
+
if (configuredRemote) {
|
|
134
|
+
return configuredRemote;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return REMOTE_FALLBACK;
|
|
138
|
+
}
|
|
139
|
+
function listRemoteBranches(repoPath, remoteName) {
|
|
140
|
+
const output = runGitText(repoPath, ['for-each-ref', '--format=%(refname:short)', `refs/remotes/${remoteName}`], { silent: true });
|
|
141
|
+
return new Set(output
|
|
142
|
+
.split('\n')
|
|
143
|
+
.map((line) => line.trim())
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
.filter((line) => line !== `${remoteName}/HEAD`)
|
|
146
|
+
.map((line) => (line.startsWith(`${remoteName}/`) ? line.slice(remoteName.length + 1) : line))
|
|
147
|
+
.filter(Boolean));
|
|
148
|
+
}
|
|
149
|
+
function compareCandidates(left, right) {
|
|
150
|
+
if (left.path && right.path) {
|
|
151
|
+
const leftTime = left.lastModified?.getTime() ?? 0;
|
|
152
|
+
const rightTime = right.lastModified?.getTime() ?? 0;
|
|
153
|
+
if (leftTime !== rightTime) {
|
|
154
|
+
return rightTime - leftTime;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else if (left.path) {
|
|
158
|
+
return -1;
|
|
159
|
+
}
|
|
160
|
+
else if (right.path) {
|
|
161
|
+
return 1;
|
|
162
|
+
}
|
|
163
|
+
return left.branchName.localeCompare(right.branchName);
|
|
164
|
+
}
|
|
165
|
+
export function getResumableBranches(projectRoot, activePaneSlugs, options = {}) {
|
|
166
|
+
const includeRemoteBranches = options.includeRemoteBranches ?? true;
|
|
167
|
+
const candidates = new Map();
|
|
168
|
+
for (const worktree of getOrphanedWorktrees(projectRoot, activePaneSlugs)) {
|
|
169
|
+
candidates.set(worktree.branch, {
|
|
170
|
+
branchName: worktree.branch,
|
|
171
|
+
slug: worktree.slug,
|
|
172
|
+
path: worktree.path,
|
|
173
|
+
lastModified: worktree.lastModified,
|
|
174
|
+
hasUncommittedChanges: worktree.hasUncommittedChanges,
|
|
175
|
+
hasWorktree: true,
|
|
176
|
+
isRemote: false,
|
|
177
|
+
hasLocalBranch: true,
|
|
178
|
+
hasRemoteBranch: false,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
for (const repoPath of discoverWorkspaceRepos(projectRoot)) {
|
|
182
|
+
const localBranches = listLocalBranches(repoPath);
|
|
183
|
+
const remoteBranches = includeRemoteBranches
|
|
184
|
+
? listRemoteBranches(repoPath, getPreferredRemoteName(repoPath))
|
|
185
|
+
: new Set();
|
|
186
|
+
const branchNames = new Set([
|
|
187
|
+
...Array.from(localBranches),
|
|
188
|
+
...Array.from(remoteBranches),
|
|
189
|
+
]);
|
|
190
|
+
for (const branchName of branchNames) {
|
|
191
|
+
const existing = candidates.get(branchName);
|
|
192
|
+
if (existing) {
|
|
193
|
+
existing.hasWorktree ||= false;
|
|
194
|
+
existing.hasLocalBranch ||= localBranches.has(branchName);
|
|
195
|
+
existing.hasRemoteBranch ||= remoteBranches.has(branchName);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
candidates.set(branchName, {
|
|
199
|
+
branchName,
|
|
200
|
+
hasUncommittedChanges: false,
|
|
201
|
+
hasWorktree: false,
|
|
202
|
+
isRemote: false,
|
|
203
|
+
hasLocalBranch: localBranches.has(branchName),
|
|
204
|
+
hasRemoteBranch: remoteBranches.has(branchName),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return Array.from(candidates.values())
|
|
209
|
+
.map((candidate) => ({
|
|
210
|
+
branchName: candidate.branchName,
|
|
211
|
+
slug: candidate.slug,
|
|
212
|
+
path: candidate.path,
|
|
213
|
+
lastModified: candidate.lastModified,
|
|
214
|
+
hasUncommittedChanges: candidate.hasUncommittedChanges,
|
|
215
|
+
hasWorktree: candidate.hasWorktree,
|
|
216
|
+
hasLocalBranch: candidate.hasLocalBranch,
|
|
217
|
+
hasRemoteBranch: candidate.hasRemoteBranch,
|
|
218
|
+
isRemote: !candidate.hasWorktree && !candidate.hasLocalBranch && candidate.hasRemoteBranch,
|
|
219
|
+
}))
|
|
220
|
+
.sort(compareCandidates);
|
|
221
|
+
}
|
|
222
|
+
function getWorkspaceBranchStates(projectRoot, branchName) {
|
|
223
|
+
return discoverWorkspaceRepos(projectRoot).map((repoPath) => {
|
|
224
|
+
const remoteName = getPreferredRemoteName(repoPath);
|
|
225
|
+
const localBranches = listLocalBranches(repoPath);
|
|
226
|
+
const remoteBranches = listRemoteBranches(repoPath, remoteName);
|
|
227
|
+
return {
|
|
228
|
+
repoPath,
|
|
229
|
+
relativePath: repoPath === projectRoot ? '' : path.relative(projectRoot, repoPath),
|
|
230
|
+
remoteName,
|
|
231
|
+
hasLocalBranch: localBranches.has(branchName),
|
|
232
|
+
hasRemoteBranch: remoteBranches.has(branchName),
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
async function getWorkspaceBranchStatesAsync(projectRoot, branchName) {
|
|
237
|
+
const repoStates = [];
|
|
238
|
+
for (const repoPath of discoverWorkspaceRepos(projectRoot)) {
|
|
239
|
+
const remoteName = await getPreferredRemoteNameAsync(repoPath);
|
|
240
|
+
const localBranches = await listLocalBranchesAsync(repoPath);
|
|
241
|
+
const remoteBranches = await listRemoteBranchesAsync(repoPath, remoteName);
|
|
242
|
+
repoStates.push({
|
|
243
|
+
repoPath,
|
|
244
|
+
relativePath: repoPath === projectRoot ? '' : path.relative(projectRoot, repoPath),
|
|
245
|
+
remoteName,
|
|
246
|
+
hasLocalBranch: localBranches.has(branchName),
|
|
247
|
+
hasRemoteBranch: remoteBranches.has(branchName),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return repoStates;
|
|
251
|
+
}
|
|
252
|
+
function refreshRemoteBranchState(state, branchName) {
|
|
253
|
+
if (!isValidBranchName(branchName)) {
|
|
254
|
+
throw new Error(`Invalid branch name: ${branchName}`);
|
|
255
|
+
}
|
|
256
|
+
runGit(state.repoPath, ['fetch', '--prune', state.remoteName], { silent: true });
|
|
257
|
+
state.hasRemoteBranch = listRemoteBranches(state.repoPath, state.remoteName).has(branchName);
|
|
258
|
+
}
|
|
259
|
+
async function refreshRemoteBranchStateAsync(state, branchName) {
|
|
260
|
+
if (!isValidBranchName(branchName)) {
|
|
261
|
+
throw new Error(`Invalid branch name: ${branchName}`);
|
|
262
|
+
}
|
|
263
|
+
await runGitAsync(state.repoPath, ['fetch', '--prune', state.remoteName], { silent: true });
|
|
264
|
+
state.hasRemoteBranch = (await listRemoteBranchesAsync(state.repoPath, state.remoteName)).has(branchName);
|
|
265
|
+
}
|
|
266
|
+
function deriveBaseSlug(branchName) {
|
|
267
|
+
const segment = branchName.split('/').pop() || branchName;
|
|
268
|
+
const normalized = segment
|
|
269
|
+
.toLowerCase()
|
|
270
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
271
|
+
.replace(/-+/g, '-')
|
|
272
|
+
.replace(/^-+|-+$/g, '');
|
|
273
|
+
return normalized || 'branch';
|
|
274
|
+
}
|
|
275
|
+
function shortHash(input) {
|
|
276
|
+
return createHash('sha1').update(input).digest('hex').slice(0, 6);
|
|
277
|
+
}
|
|
278
|
+
function hasReusableWorktreeForBranch(worktreeRootPath, branchName) {
|
|
279
|
+
const rootGitPath = path.join(worktreeRootPath, '.git');
|
|
280
|
+
if (fs.existsSync(rootGitPath)) {
|
|
281
|
+
return getCurrentBranchName(worktreeRootPath) === branchName;
|
|
282
|
+
}
|
|
283
|
+
const stack = [worktreeRootPath];
|
|
284
|
+
while (stack.length > 0) {
|
|
285
|
+
const currentPath = stack.pop();
|
|
286
|
+
if (!currentPath) {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
let entries;
|
|
290
|
+
try {
|
|
291
|
+
entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
for (const entry of entries) {
|
|
297
|
+
if (!entry.isDirectory()) {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (RESUME_SCAN_EXCLUDED_DIRS.has(entry.name)) {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const fullPath = path.join(currentPath, entry.name);
|
|
304
|
+
const gitPath = path.join(fullPath, '.git');
|
|
305
|
+
if (fs.existsSync(gitPath)) {
|
|
306
|
+
try {
|
|
307
|
+
const gitStat = fs.statSync(gitPath);
|
|
308
|
+
if (gitStat.isFile() && getCurrentBranchName(fullPath) === branchName) {
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
// Ignore unreadable nested worktrees and keep scanning.
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
stack.push(fullPath);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
function getAvailableSlug(branchName, projectRoot, existingPanes) {
|
|
322
|
+
const worktreesDir = path.join(projectRoot, '.dmux', 'worktrees');
|
|
323
|
+
const reserved = new Set(existingPanes.map((pane) => pane.slug));
|
|
324
|
+
const baseSlug = deriveBaseSlug(branchName);
|
|
325
|
+
let candidate = baseSlug;
|
|
326
|
+
let attempt = 0;
|
|
327
|
+
while (reserved.has(candidate)
|
|
328
|
+
|| (fs.existsSync(path.join(worktreesDir, candidate))
|
|
329
|
+
&& !hasReusableWorktreeForBranch(path.join(worktreesDir, candidate), branchName))) {
|
|
330
|
+
attempt += 1;
|
|
331
|
+
const hashSuffix = shortHash(`${branchName}:${attempt}`);
|
|
332
|
+
candidate = `${baseSlug}-${hashSuffix}`;
|
|
333
|
+
}
|
|
334
|
+
return candidate;
|
|
335
|
+
}
|
|
336
|
+
function getBranchUpstream(repoPath, branchName) {
|
|
337
|
+
return runGitText(repoPath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branchName}@{upstream}`], { silent: true });
|
|
338
|
+
}
|
|
339
|
+
async function getBranchUpstreamAsync(repoPath, branchName) {
|
|
340
|
+
return runGitTextAsync(repoPath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branchName}@{upstream}`], { silent: true });
|
|
341
|
+
}
|
|
342
|
+
function getBranchDivergence(repoPath, localRef, remoteRef) {
|
|
343
|
+
const output = runGitText(repoPath, ['rev-list', '--left-right', '--count', `${localRef}...${remoteRef}`], { silent: true });
|
|
344
|
+
const [aheadText = '0', behindText = '0'] = output.split(/\s+/);
|
|
345
|
+
const ahead = Number.parseInt(aheadText, 10);
|
|
346
|
+
const behind = Number.parseInt(behindText, 10);
|
|
347
|
+
return {
|
|
348
|
+
ahead: Number.isFinite(ahead) ? ahead : 0,
|
|
349
|
+
behind: Number.isFinite(behind) ? behind : 0,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
async function getBranchDivergenceAsync(repoPath, localRef, remoteRef) {
|
|
353
|
+
const output = await runGitTextAsync(repoPath, ['rev-list', '--left-right', '--count', `${localRef}...${remoteRef}`], { silent: true });
|
|
354
|
+
const [aheadText = '0', behindText = '0'] = output.split(/\s+/);
|
|
355
|
+
const ahead = Number.parseInt(aheadText, 10);
|
|
356
|
+
const behind = Number.parseInt(behindText, 10);
|
|
357
|
+
return {
|
|
358
|
+
ahead: Number.isFinite(ahead) ? ahead : 0,
|
|
359
|
+
behind: Number.isFinite(behind) ? behind : 0,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
function getCheckedOutWorktreePath(repoPath, branchName) {
|
|
363
|
+
const output = runGitText(repoPath, ['worktree', 'list', '--porcelain'], { silent: true });
|
|
364
|
+
if (!output) {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
let currentWorktree = null;
|
|
368
|
+
for (const line of output.split('\n')) {
|
|
369
|
+
if (line.startsWith('worktree ')) {
|
|
370
|
+
currentWorktree = line.slice('worktree '.length).trim();
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (line === `branch refs/heads/${branchName}`) {
|
|
374
|
+
return currentWorktree;
|
|
375
|
+
}
|
|
376
|
+
if (!line.trim()) {
|
|
377
|
+
currentWorktree = null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
async function getCheckedOutWorktreePathAsync(repoPath, branchName) {
|
|
383
|
+
const output = await runGitTextAsync(repoPath, ['worktree', 'list', '--porcelain'], { silent: true });
|
|
384
|
+
if (!output) {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
let currentWorktree = null;
|
|
388
|
+
for (const line of output.split('\n')) {
|
|
389
|
+
if (line.startsWith('worktree ')) {
|
|
390
|
+
currentWorktree = line.slice('worktree '.length).trim();
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (line === `branch refs/heads/${branchName}`) {
|
|
394
|
+
return currentWorktree;
|
|
395
|
+
}
|
|
396
|
+
if (!line.trim()) {
|
|
397
|
+
currentWorktree = null;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
function ensureLocalBranch(state, branchName, worktreePath) {
|
|
403
|
+
if (!isValidBranchName(branchName)) {
|
|
404
|
+
throw new Error(`Invalid branch name: ${branchName}`);
|
|
405
|
+
}
|
|
406
|
+
refreshRemoteBranchState(state, branchName);
|
|
407
|
+
runGit(state.repoPath, ['worktree', 'prune'], { silent: true });
|
|
408
|
+
state.hasLocalBranch = listLocalBranches(state.repoPath).has(branchName);
|
|
409
|
+
if (state.hasRemoteBranch) {
|
|
410
|
+
const remoteRef = `${state.remoteName}/${branchName}`;
|
|
411
|
+
if (state.hasLocalBranch) {
|
|
412
|
+
const upstream = getBranchUpstream(state.repoPath, branchName);
|
|
413
|
+
if (upstream !== remoteRef) {
|
|
414
|
+
runGit(state.repoPath, ['branch', `--set-upstream-to=${remoteRef}`, branchName], { silent: true });
|
|
415
|
+
}
|
|
416
|
+
const { ahead, behind } = getBranchDivergence(state.repoPath, branchName, remoteRef);
|
|
417
|
+
if (behind > 0 && ahead === 0) {
|
|
418
|
+
const checkedOutWorktreePath = getCheckedOutWorktreePath(state.repoPath, branchName);
|
|
419
|
+
if (checkedOutWorktreePath) {
|
|
420
|
+
if (path.resolve(checkedOutWorktreePath) === path.resolve(worktreePath)) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const repoLabel = state.relativePath || '.';
|
|
424
|
+
throw new Error(`Branch ${branchName} in ${repoLabel} is already checked out at ${checkedOutWorktreePath}; reopen that worktree instead of recreating it.`);
|
|
425
|
+
}
|
|
426
|
+
runGit(state.repoPath, ['branch', '-f', branchName, remoteRef]);
|
|
427
|
+
}
|
|
428
|
+
else if (ahead > 0 && behind > 0) {
|
|
429
|
+
const repoLabel = state.relativePath || '.';
|
|
430
|
+
throw new Error(`Branch ${branchName} in ${repoLabel} has diverged from ${remoteRef}; refusing to overwrite local commits while opening the workspace.`);
|
|
431
|
+
}
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
runGit(state.repoPath, ['branch', '--track', branchName, remoteRef]);
|
|
435
|
+
state.hasLocalBranch = true;
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (state.hasLocalBranch) {
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const defaultBranch = getMainBranchForRepo(state.repoPath);
|
|
442
|
+
runGit(state.repoPath, ['branch', branchName, defaultBranch]);
|
|
443
|
+
state.hasLocalBranch = true;
|
|
444
|
+
}
|
|
445
|
+
async function ensureLocalBranchAsync(state, branchName, worktreePath) {
|
|
446
|
+
if (!isValidBranchName(branchName)) {
|
|
447
|
+
throw new Error(`Invalid branch name: ${branchName}`);
|
|
448
|
+
}
|
|
449
|
+
await refreshRemoteBranchStateAsync(state, branchName);
|
|
450
|
+
await runGitAsync(state.repoPath, ['worktree', 'prune'], { silent: true });
|
|
451
|
+
state.hasLocalBranch = (await listLocalBranchesAsync(state.repoPath)).has(branchName);
|
|
452
|
+
if (state.hasRemoteBranch) {
|
|
453
|
+
const remoteRef = `${state.remoteName}/${branchName}`;
|
|
454
|
+
if (state.hasLocalBranch) {
|
|
455
|
+
const upstream = await getBranchUpstreamAsync(state.repoPath, branchName);
|
|
456
|
+
if (upstream !== remoteRef) {
|
|
457
|
+
await runGitAsync(state.repoPath, ['branch', `--set-upstream-to=${remoteRef}`, branchName], { silent: true });
|
|
458
|
+
}
|
|
459
|
+
const { ahead, behind } = await getBranchDivergenceAsync(state.repoPath, branchName, remoteRef);
|
|
460
|
+
if (behind > 0 && ahead === 0) {
|
|
461
|
+
const checkedOutWorktreePath = await getCheckedOutWorktreePathAsync(state.repoPath, branchName);
|
|
462
|
+
if (checkedOutWorktreePath) {
|
|
463
|
+
if (path.resolve(checkedOutWorktreePath) === path.resolve(worktreePath)) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const repoLabel = state.relativePath || '.';
|
|
467
|
+
throw new Error(`Branch ${branchName} in ${repoLabel} is already checked out at ${checkedOutWorktreePath}; reopen that worktree instead of recreating it.`);
|
|
468
|
+
}
|
|
469
|
+
await runGitAsync(state.repoPath, ['branch', '-f', branchName, remoteRef]);
|
|
470
|
+
}
|
|
471
|
+
else if (ahead > 0 && behind > 0) {
|
|
472
|
+
const repoLabel = state.relativePath || '.';
|
|
473
|
+
throw new Error(`Branch ${branchName} in ${repoLabel} has diverged from ${remoteRef}; refusing to overwrite local commits while opening the workspace.`);
|
|
474
|
+
}
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
await runGitAsync(state.repoPath, ['branch', '--track', branchName, remoteRef]);
|
|
478
|
+
state.hasLocalBranch = true;
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (state.hasLocalBranch) {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const defaultBranch = await getMainBranchForRepoAsync(state.repoPath);
|
|
485
|
+
await runGitAsync(state.repoPath, ['branch', branchName, defaultBranch]);
|
|
486
|
+
state.hasLocalBranch = true;
|
|
487
|
+
}
|
|
488
|
+
function getMainBranchForRepo(repoPath) {
|
|
489
|
+
const originHead = runGitText(repoPath, ['symbolic-ref', 'refs/remotes/origin/HEAD'], { silent: true });
|
|
490
|
+
if (originHead.startsWith('refs/remotes/origin/')) {
|
|
491
|
+
return originHead.slice('refs/remotes/origin/'.length);
|
|
492
|
+
}
|
|
493
|
+
try {
|
|
494
|
+
runGit(repoPath, ['show-ref', '--verify', '--quiet', 'refs/heads/main']);
|
|
495
|
+
return 'main';
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
// Continue to master fallback.
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
runGit(repoPath, ['show-ref', '--verify', '--quiet', 'refs/heads/master']);
|
|
502
|
+
return 'master';
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
return getCurrentBranchName(repoPath) || 'main';
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
async function getMainBranchForRepoAsync(repoPath) {
|
|
509
|
+
const originHead = await runGitTextAsync(repoPath, ['symbolic-ref', 'refs/remotes/origin/HEAD'], { silent: true });
|
|
510
|
+
if (originHead.startsWith('refs/remotes/origin/')) {
|
|
511
|
+
return originHead.slice('refs/remotes/origin/'.length);
|
|
512
|
+
}
|
|
513
|
+
try {
|
|
514
|
+
await runGitAsync(repoPath, ['show-ref', '--verify', '--quiet', 'refs/heads/main']);
|
|
515
|
+
return 'main';
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
// Continue to master fallback.
|
|
519
|
+
}
|
|
520
|
+
try {
|
|
521
|
+
await runGitAsync(repoPath, ['show-ref', '--verify', '--quiet', 'refs/heads/master']);
|
|
522
|
+
return 'master';
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
return (await getCurrentBranchNameAsync(repoPath)) || 'main';
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
function createWorktree(repoPath, worktreePath, branchName) {
|
|
529
|
+
if (fs.existsSync(path.join(worktreePath, '.git'))) {
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
|
|
533
|
+
runGit(repoPath, ['worktree', 'prune'], { silent: true });
|
|
534
|
+
runGit(repoPath, ['worktree', 'add', worktreePath, branchName]);
|
|
535
|
+
}
|
|
536
|
+
async function createWorktreeAsync(repoPath, worktreePath, branchName) {
|
|
537
|
+
if (fs.existsSync(path.join(worktreePath, '.git'))) {
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
|
|
541
|
+
await runGitAsync(repoPath, ['worktree', 'prune'], { silent: true });
|
|
542
|
+
await runGitAsync(repoPath, ['worktree', 'add', worktreePath, branchName]);
|
|
543
|
+
}
|
|
544
|
+
async function listLocalBranchesAsync(repoPath) {
|
|
545
|
+
const output = await runGitTextAsync(repoPath, ['for-each-ref', '--format=%(refname:short)', 'refs/heads'], { silent: true });
|
|
546
|
+
return new Set(output
|
|
547
|
+
.split('\n')
|
|
548
|
+
.map((line) => line.trim())
|
|
549
|
+
.filter(Boolean));
|
|
550
|
+
}
|
|
551
|
+
async function getCurrentBranchNameAsync(repoPath) {
|
|
552
|
+
return (await runGitTextAsync(repoPath, ['branch', '--show-current'], { silent: true })) || 'main';
|
|
553
|
+
}
|
|
554
|
+
async function getPreferredRemoteNameAsync(repoPath) {
|
|
555
|
+
const upstream = await runGitTextAsync(repoPath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], { silent: true });
|
|
556
|
+
if (upstream.includes('/')) {
|
|
557
|
+
return upstream.split('/')[0];
|
|
558
|
+
}
|
|
559
|
+
const currentBranch = await getCurrentBranchNameAsync(repoPath);
|
|
560
|
+
if (currentBranch && currentBranch !== 'HEAD') {
|
|
561
|
+
const configuredRemote = await runGitTextAsync(repoPath, ['config', `branch.${currentBranch}.remote`], { silent: true });
|
|
562
|
+
if (configuredRemote) {
|
|
563
|
+
return configuredRemote;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return REMOTE_FALLBACK;
|
|
567
|
+
}
|
|
568
|
+
async function listRemoteBranchesAsync(repoPath, remoteName) {
|
|
569
|
+
const output = await runGitTextAsync(repoPath, ['for-each-ref', '--format=%(refname:short)', `refs/remotes/${remoteName}`], { silent: true });
|
|
570
|
+
return new Set(output
|
|
571
|
+
.split('\n')
|
|
572
|
+
.map((line) => line.trim())
|
|
573
|
+
.filter(Boolean)
|
|
574
|
+
.filter((line) => line !== `${remoteName}/HEAD`)
|
|
575
|
+
.map((line) => (line.startsWith(`${remoteName}/`) ? line.slice(remoteName.length + 1) : line))
|
|
576
|
+
.filter(Boolean));
|
|
577
|
+
}
|
|
578
|
+
async function triggerChildWorktreeHook(projectRoot, branchName, slug, worktreePath, agent) {
|
|
579
|
+
await triggerHook('worktree_created', projectRoot, undefined, {
|
|
580
|
+
DMUX_SLUG: slug,
|
|
581
|
+
DMUX_PROMPT: 'No initial prompt',
|
|
582
|
+
DMUX_AGENT: agent,
|
|
583
|
+
DMUX_WORKTREE_PATH: worktreePath,
|
|
584
|
+
DMUX_BRANCH: branchName,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
export async function resumeBranchWorkspace(options) {
|
|
588
|
+
const { branchName, agent, projectRoot, existingPanes, sessionConfigPath, sessionProjectRoot, } = options;
|
|
589
|
+
const workspaceStates = await getWorkspaceBranchStatesAsync(projectRoot, branchName);
|
|
590
|
+
const slug = getAvailableSlug(branchName, projectRoot, existingPanes);
|
|
591
|
+
const rootWorktreePath = path.join(projectRoot, '.dmux', 'worktrees', slug);
|
|
592
|
+
const settings = new SettingsManager(projectRoot).getSettings();
|
|
593
|
+
if (!agent) {
|
|
594
|
+
throw new Error(`An agent must be selected before opening ${branchName}`);
|
|
595
|
+
}
|
|
596
|
+
for (const state of workspaceStates) {
|
|
597
|
+
const worktreePath = state.relativePath
|
|
598
|
+
? path.join(rootWorktreePath, state.relativePath)
|
|
599
|
+
: rootWorktreePath;
|
|
600
|
+
await ensureLocalBranchAsync(state, branchName, worktreePath);
|
|
601
|
+
}
|
|
602
|
+
for (const state of workspaceStates) {
|
|
603
|
+
const worktreePath = state.relativePath
|
|
604
|
+
? path.join(rootWorktreePath, state.relativePath)
|
|
605
|
+
: rootWorktreePath;
|
|
606
|
+
await createWorktreeAsync(state.repoPath, worktreePath, branchName);
|
|
607
|
+
writeWorktreeMetadata(worktreePath, {
|
|
608
|
+
...(agent && !state.relativePath ? { agent } : {}),
|
|
609
|
+
permissionMode: state.relativePath ? undefined : settings.permissionMode,
|
|
610
|
+
branchName: branchName !== slug ? branchName : undefined,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
const creation = await createPane({
|
|
614
|
+
prompt: '',
|
|
615
|
+
agent,
|
|
616
|
+
existingWorktree: {
|
|
617
|
+
slug,
|
|
618
|
+
worktreePath: rootWorktreePath,
|
|
619
|
+
branchName,
|
|
620
|
+
},
|
|
621
|
+
projectName: path.basename(projectRoot),
|
|
622
|
+
existingPanes,
|
|
623
|
+
projectRoot,
|
|
624
|
+
sessionConfigPath,
|
|
625
|
+
sessionProjectRoot,
|
|
626
|
+
}, [agent]);
|
|
627
|
+
if (creation.needsAgentChoice) {
|
|
628
|
+
throw new Error('Agent selection is required to resume this branch');
|
|
629
|
+
}
|
|
630
|
+
for (const state of workspaceStates) {
|
|
631
|
+
if (!state.relativePath) {
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
await triggerChildWorktreeHook(state.repoPath, branchName, slug, path.join(rootWorktreePath, state.relativePath), agent);
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
pane: creation.pane,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
//# sourceMappingURL=resumeBranches.js.map
|