ccdock 0.1.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.
@@ -0,0 +1,32 @@
1
+ import {
2
+ focusAndPositionEditor,
3
+ getSidebarBounds,
4
+ positionEditorWindow,
5
+ editorWindowExists,
6
+ } from "./window.ts";
7
+
8
+ export async function openEditor(worktreePath: string, editor: string): Promise<void> {
9
+ Bun.spawn([editor, "--new-window", worktreePath], {
10
+ stdout: "ignore",
11
+ stderr: "ignore",
12
+ });
13
+
14
+ const basename = worktreePath.split("/").pop() ?? "";
15
+
16
+ // Wait for the VS Code window to actually appear (up to 10 seconds)
17
+ for (let i = 0; i < 20; i++) {
18
+ await Bun.sleep(500);
19
+ if (await editorWindowExists(basename)) break;
20
+ }
21
+
22
+ // Position the new window next to the sidebar
23
+ const sidebar = await getSidebarBounds();
24
+ if (sidebar) {
25
+ await positionEditorWindow(basename, sidebar);
26
+ }
27
+ }
28
+
29
+ export async function focusEditor(worktreePath: string, _editor: string): Promise<boolean> {
30
+ const basename = worktreePath.split("/").pop() ?? "";
31
+ return focusAndPositionEditor(basename);
32
+ }
@@ -0,0 +1,150 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ unlinkSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { join } from "node:path";
10
+ import type { AgentState, WorkspaceSession } from "../types.ts";
11
+
12
+ const STATE_DIR_NAME = "ccdock";
13
+ const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
14
+
15
+ export function getStateDir(): string {
16
+ const home = process.env.HOME ?? "";
17
+ const stateBase = process.env.XDG_STATE_HOME ?? join(home, ".local", "state");
18
+ return join(stateBase, STATE_DIR_NAME);
19
+ }
20
+
21
+ function getSessionsDir(): string {
22
+ return join(getStateDir(), "sessions");
23
+ }
24
+
25
+ function getAgentsDir(): string {
26
+ return join(getStateDir(), "agents");
27
+ }
28
+
29
+ let dirsEnsured = false;
30
+
31
+ function ensureDirs(): void {
32
+ if (dirsEnsured) return;
33
+ const sessionsDir = getSessionsDir();
34
+ const agentsDir = getAgentsDir();
35
+ if (!existsSync(sessionsDir)) {
36
+ mkdirSync(sessionsDir, { recursive: true });
37
+ }
38
+ if (!existsSync(agentsDir)) {
39
+ mkdirSync(agentsDir, { recursive: true });
40
+ }
41
+ dirsEnsured = true;
42
+ }
43
+
44
+ export function saveSession(session: WorkspaceSession): void {
45
+ ensureDirs();
46
+ const filePath = join(getSessionsDir(), `${session.id}.json`);
47
+ const { agents: _agents, editorState: _editorState, ...serializable } = session;
48
+ writeFileSync(filePath, JSON.stringify(serializable, null, 2));
49
+ }
50
+
51
+ export function loadSessions(): WorkspaceSession[] {
52
+ ensureDirs();
53
+ const sessionsDir = getSessionsDir();
54
+ const files = readdirSync(sessionsDir).filter((f) => f.endsWith(".json"));
55
+ const sessions: WorkspaceSession[] = [];
56
+
57
+ for (const file of files) {
58
+ try {
59
+ const raw = readFileSync(join(sessionsDir, file), "utf-8");
60
+ const parsed = JSON.parse(raw) as WorkspaceSession;
61
+ parsed.agents = [];
62
+ parsed.editorState = parsed.editorState ?? "closed";
63
+ sessions.push(parsed);
64
+ } catch {
65
+ // Skip malformed files
66
+ }
67
+ }
68
+
69
+ return sessions.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
70
+ }
71
+
72
+ export function deleteSession(id: string): void {
73
+ const filePath = join(getSessionsDir(), `${id}.json`);
74
+ if (existsSync(filePath)) {
75
+ unlinkSync(filePath);
76
+ }
77
+ }
78
+
79
+ export function loadAgentStates(): AgentState[] {
80
+ ensureDirs();
81
+ const agentsDir = getAgentsDir();
82
+ const files = readdirSync(agentsDir).filter((f) => f.endsWith(".json"));
83
+ const states: AgentState[] = [];
84
+
85
+ for (const file of files) {
86
+ try {
87
+ const raw = readFileSync(join(agentsDir, file), "utf-8");
88
+ const parsed = JSON.parse(raw) as AgentState;
89
+ states.push(parsed);
90
+ } catch {
91
+ // Skip malformed files
92
+ }
93
+ }
94
+
95
+ return states;
96
+ }
97
+
98
+ export function cleanStaleAgents(): void {
99
+ ensureDirs();
100
+ const agentsDir = getAgentsDir();
101
+ const files = readdirSync(agentsDir).filter((f) => f.endsWith(".json"));
102
+ const now = Date.now();
103
+
104
+ for (const file of files) {
105
+ try {
106
+ const filePath = join(agentsDir, file);
107
+ const raw = readFileSync(filePath, "utf-8");
108
+ const parsed = JSON.parse(raw) as AgentState;
109
+ if (now - parsed.updatedAt > STALE_THRESHOLD_MS) {
110
+ unlinkSync(filePath);
111
+ }
112
+ } catch {
113
+ // Skip
114
+ }
115
+ }
116
+ }
117
+
118
+ export function findSessionByPath(cwd: string): string {
119
+ const sessions = loadSessions();
120
+ for (const session of sessions) {
121
+ if (cwd.startsWith(session.worktreePath)) {
122
+ return session.id;
123
+ }
124
+ }
125
+ return "";
126
+ }
127
+
128
+ export function readAgentState(filename: string): AgentState | null {
129
+ const filePath = join(getAgentsDir(), filename);
130
+ if (!existsSync(filePath)) return null;
131
+ try {
132
+ const raw = readFileSync(filePath, "utf-8");
133
+ return JSON.parse(raw) as AgentState;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ export function writeAgentState(state: AgentState, filename: string): void {
140
+ ensureDirs();
141
+ const filePath = join(getAgentsDir(), filename);
142
+ writeFileSync(filePath, JSON.stringify(state, null, 2));
143
+ }
144
+
145
+ export function removeAgentFile(filename: string): void {
146
+ const filePath = join(getAgentsDir(), filename);
147
+ if (existsSync(filePath)) {
148
+ unlinkSync(filePath);
149
+ }
150
+ }
@@ -0,0 +1,323 @@
1
+ /**
2
+ * macOS native window management via AppleScript/osascript.
3
+ * macOS native window management via AppleScript/System Events.
4
+ */
5
+
6
+ interface WindowBounds {
7
+ x: number;
8
+ y: number;
9
+ width: number;
10
+ height: number;
11
+ }
12
+
13
+ async function runOsascript(script: string): Promise<string> {
14
+ const proc = Bun.spawn(["osascript", "-e", script], {
15
+ stdout: "pipe",
16
+ stderr: "pipe",
17
+ });
18
+ const out = await new Response(proc.stdout).text();
19
+ await proc.exited;
20
+ return out.trim();
21
+ }
22
+
23
+ /**
24
+ * Get the bounds of the sidebar terminal window (the frontmost Ghostty window).
25
+ */
26
+ export async function getSidebarBounds(): Promise<WindowBounds | null> {
27
+ try {
28
+ const result = await runOsascript(`
29
+ tell application "System Events"
30
+ tell process "Ghostty"
31
+ set w to front window
32
+ set p to position of w
33
+ set s to size of w
34
+ return "" & (item 1 of p) & "," & (item 2 of p) & "," & (item 1 of s) & "," & (item 2 of s)
35
+ end tell
36
+ end tell
37
+ `);
38
+ const parts = result.split(",").map((s) => Number.parseInt(s.trim(), 10));
39
+ if (parts.length < 4) return null;
40
+ return { x: parts[0]!, y: parts[1]!, width: parts[2]!, height: parts[3]! };
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Get the screen bounds that the sidebar is on.
48
+ * Returns the full screen dimensions for the monitor containing the sidebar.
49
+ */
50
+ export async function getScreenBounds(): Promise<WindowBounds | null> {
51
+ try {
52
+ const result = await runOsascript(`
53
+ tell application "Finder"
54
+ set db to bounds of window of desktop
55
+ return "" & (item 1 of db) & "," & (item 2 of db) & "," & (item 3 of db) & "," & (item 4 of db)
56
+ end tell
57
+ `);
58
+ const parts = result.split(",").map((s) => Number.parseInt(s.trim(), 10));
59
+ if (parts.length < 4) return null;
60
+ return {
61
+ x: parts[0]!,
62
+ y: parts[1]!,
63
+ width: parts[2]! - parts[0]!,
64
+ height: parts[3]! - parts[1]!,
65
+ };
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Move and resize a VS Code window to fill the area right of the sidebar.
73
+ * The VS Code window is brought to the current space and positioned
74
+ * adjacent to the sidebar terminal.
75
+ */
76
+ export async function positionEditorWindow(
77
+ windowTitle: string,
78
+ sidebarBounds: WindowBounds,
79
+ ): Promise<boolean> {
80
+ // Calculate editor position: right of sidebar, same height
81
+ const editorX = sidebarBounds.x + sidebarBounds.width + 4; // 4px gap
82
+ const editorY = sidebarBounds.y;
83
+ // Editor width: fill remaining screen width (estimate with a large number)
84
+ // We'll get the actual screen width for precision
85
+ const screen = await getScreenBounds();
86
+ const screenRight = screen ? screen.x + screen.width : 5120; // fallback
87
+ const editorWidth = screenRight - editorX;
88
+ const editorHeight = sidebarBounds.height;
89
+
90
+ try {
91
+ // Escape single quotes in title for AppleScript
92
+ const escapedTitle = windowTitle.replace(/'/g, "'\"'\"'");
93
+
94
+ await runOsascript(`
95
+ tell application "System Events"
96
+ tell process "Code"
97
+ set targetWindow to missing value
98
+ repeat with w in every window
99
+ if name of w contains "${escapedTitle}" then
100
+ set targetWindow to w
101
+ exit repeat
102
+ end if
103
+ end repeat
104
+ if targetWindow is not missing value then
105
+ set position of targetWindow to {${editorX}, ${editorY}}
106
+ set size of targetWindow to {${editorWidth}, ${editorHeight}}
107
+ end if
108
+ end tell
109
+ end tell
110
+ `);
111
+ return true;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Check if a VS Code window with the given title exists.
119
+ */
120
+ export async function editorWindowExists(windowTitle: string): Promise<boolean> {
121
+ const windows = await listEditorWindows();
122
+ return windows.some((w) => w.includes(windowTitle));
123
+ }
124
+
125
+ /**
126
+ * Bring a VS Code window to front and position it next to the sidebar.
127
+ * Returns false if the window doesn't exist.
128
+ */
129
+ export async function focusAndPositionEditor(windowTitle: string): Promise<boolean> {
130
+ // First check if the window actually exists
131
+ if (!(await editorWindowExists(windowTitle))) {
132
+ return false;
133
+ }
134
+
135
+ const sidebar = await getSidebarBounds();
136
+ if (!sidebar) return false;
137
+
138
+ try {
139
+ const escapedTitle = windowTitle.replace(/'/g, "'\"'\"'");
140
+
141
+ // Activate VS Code and raise the specific window
142
+ await runOsascript(`
143
+ tell application "System Events"
144
+ tell process "Code"
145
+ set frontmost to true
146
+ repeat with w in every window
147
+ if name of w contains "${escapedTitle}" then
148
+ perform action "AXRaise" of w
149
+ exit repeat
150
+ end if
151
+ end repeat
152
+ end tell
153
+ end tell
154
+ `);
155
+
156
+ // Position it next to sidebar
157
+ await positionEditorWindow(windowTitle, sidebar);
158
+ return true;
159
+ } catch {
160
+ return false;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Check if VS Code process is running.
166
+ */
167
+ async function isEditorRunning(): Promise<boolean> {
168
+ try {
169
+ const proc = Bun.spawn(["pgrep", "-x", "Code"], { stdout: "ignore", stderr: "ignore" });
170
+ await proc.exited;
171
+ return proc.exitCode === 0;
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * List all VS Code window titles.
179
+ */
180
+ export async function listEditorWindows(): Promise<string[]> {
181
+ if (!(await isEditorRunning())) return [];
182
+ try {
183
+ const result = await runOsascript(`
184
+ tell application "System Events"
185
+ tell process "Code"
186
+ set titles to {}
187
+ repeat with w in every window
188
+ set end of titles to name of w
189
+ end repeat
190
+ set AppleScript's text item delimiters to "|||"
191
+ return titles as text
192
+ end tell
193
+ end tell
194
+ `);
195
+ if (!result) return [];
196
+ return result.split("|||").filter((t) => t.length > 0);
197
+ } catch {
198
+ return [];
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Get the focused (front) VS Code window title, and whether VS Code is frontmost app.
204
+ */
205
+ export async function getFocusedEditorWindow(): Promise<{
206
+ frontWindow: string;
207
+ isFrontmost: boolean;
208
+ }> {
209
+ if (!(await isEditorRunning())) return { frontWindow: "", isFrontmost: false };
210
+ try {
211
+ const result = await runOsascript(`
212
+ tell application "System Events"
213
+ tell process "Code"
214
+ set isFront to frontmost
215
+ set wName to name of front window
216
+ return (isFront as text) & "|||" & wName
217
+ end tell
218
+ end tell
219
+ `);
220
+ const [isFrontStr, windowName] = result.split("|||");
221
+ return {
222
+ frontWindow: windowName ?? "",
223
+ isFrontmost: isFrontStr === "true",
224
+ };
225
+ } catch {
226
+ return { frontWindow: "", isFrontmost: false };
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Focus the sidebar terminal (bring Ghostty to front).
232
+ */
233
+ export async function focusSidebar(): Promise<void> {
234
+ try {
235
+ await runOsascript(`
236
+ tell application "Ghostty" to activate
237
+ `);
238
+ } catch {
239
+ // Ghostty not available
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Close a specific VS Code window by title match.
245
+ * Raises the window then sends Cmd+W to close it.
246
+ */
247
+ export async function closeEditorWindow(windowTitle: string): Promise<void> {
248
+ if (!(await isEditorRunning())) return;
249
+ try {
250
+ const escapedTitle = windowTitle.replace(/'/g, "'\"'\"'");
251
+ await runOsascript(`
252
+ tell application "System Events"
253
+ tell process "Code"
254
+ set frontmost to true
255
+ repeat with w in every window
256
+ if name of w contains "${escapedTitle}" then
257
+ perform action "AXRaise" of w
258
+ delay 0.2
259
+ keystroke "w" using {command down, shift down}
260
+ exit repeat
261
+ end if
262
+ end repeat
263
+ end tell
264
+ end tell
265
+ `);
266
+ } catch {
267
+ // Window not found or already closed
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Close all VS Code windows (without quitting the app).
273
+ */
274
+ export async function closeAllEditors(): Promise<void> {
275
+ if (!(await isEditorRunning())) return;
276
+ try {
277
+ // Close each window one by one with Cmd+Shift+W (close window, not tab)
278
+ await runOsascript(`
279
+ tell application "System Events"
280
+ tell process "Code"
281
+ set frontmost to true
282
+ repeat while (count of windows) > 0
283
+ keystroke "w" using {command down, shift down}
284
+ delay 0.3
285
+ end repeat
286
+ end tell
287
+ end tell
288
+ `);
289
+ } catch {
290
+ // VS Code not running
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Reposition ALL VS Code windows to fill the area right of the sidebar.
296
+ * Called when the sidebar terminal is resized.
297
+ */
298
+ export async function repositionAllEditors(): Promise<void> {
299
+ if (!(await isEditorRunning())) return;
300
+ const [sidebar, screen] = await Promise.all([getSidebarBounds(), getScreenBounds()]);
301
+ if (!sidebar) return;
302
+
303
+ try {
304
+ const editorX = sidebar.x + sidebar.width + 4;
305
+ const editorY = sidebar.y;
306
+ const screenRight = screen ? screen.x + screen.width : 5120;
307
+ const editorWidth = screenRight - editorX;
308
+ const editorHeight = sidebar.height;
309
+
310
+ await runOsascript(`
311
+ tell application "System Events"
312
+ tell process "Code"
313
+ repeat with w in every window
314
+ set position of w to {${editorX}, ${editorY}}
315
+ set size of w to {${editorWidth}, ${editorHeight}}
316
+ end repeat
317
+ end tell
318
+ end tell
319
+ `);
320
+ } catch {
321
+ // VS Code not running or no windows
322
+ }
323
+ }
@@ -0,0 +1,120 @@
1
+ import { existsSync } from "node:fs";
2
+ import type { WorktreeEntry } from "../types.ts";
3
+
4
+ export async function createWorktree(repoPath: string, branchName: string): Promise<string> {
5
+ const proc = Bun.spawn(["git", "wt", branchName], {
6
+ cwd: repoPath,
7
+ stdout: "pipe",
8
+ stderr: "pipe",
9
+ });
10
+ const [stdout, stderr] = await Promise.all([
11
+ new Response(proc.stdout).text(),
12
+ new Response(proc.stderr).text(),
13
+ ]);
14
+ const code = await proc.exited;
15
+ if (code !== 0) {
16
+ throw new Error(stderr.trim() || `git wt exited with code ${code}`);
17
+ }
18
+
19
+ // Parse worktree path from output - look for absolute paths
20
+ const output = stdout.trim();
21
+ const lines = output.split("\n");
22
+ for (const line of [...lines].reverse()) {
23
+ const trimmed = line.trim();
24
+ if (trimmed.startsWith("/")) return trimmed;
25
+ }
26
+
27
+ // Fallback: find worktree via git worktree list
28
+ const listProc = Bun.spawn(["git", "-C", repoPath, "worktree", "list", "--porcelain"], {
29
+ stdout: "pipe",
30
+ stderr: "pipe",
31
+ });
32
+ const listOutput = await new Response(listProc.stdout).text();
33
+ await listProc.exited;
34
+
35
+ let currentPath = "";
36
+ for (const line of listOutput.split("\n")) {
37
+ if (line.startsWith("worktree ")) currentPath = line.slice(9);
38
+ else if (line.startsWith("branch ")) {
39
+ const branch = line.slice(7).replace("refs/heads/", "");
40
+ if (branch === branchName) return currentPath;
41
+ }
42
+ }
43
+
44
+ throw new Error(`Could not determine worktree path from git wt output:\n${output}`);
45
+ }
46
+
47
+ export async function removeWorktree(worktreePath: string): Promise<void> {
48
+ if (!existsSync(worktreePath)) return;
49
+
50
+ // Find the main repo for this worktree
51
+ const proc = Bun.spawn(["git", "-C", worktreePath, "worktree", "list", "--porcelain"], {
52
+ stdout: "pipe",
53
+ stderr: "pipe",
54
+ });
55
+ const output = await new Response(proc.stdout).text();
56
+ await proc.exited;
57
+
58
+ // First entry in worktree list is the main worktree
59
+ const lines = output.split("\n");
60
+ const mainWorktreeLine = lines.find((l) => l.startsWith("worktree "));
61
+ if (!mainWorktreeLine) {
62
+ throw new Error("Could not determine main worktree");
63
+ }
64
+ const mainPath = mainWorktreeLine.replace("worktree ", "");
65
+
66
+ // Remove the worktree
67
+ const removeProc = Bun.spawn(
68
+ ["git", "-C", mainPath, "worktree", "remove", worktreePath, "--force"],
69
+ {
70
+ stdout: "pipe",
71
+ stderr: "pipe",
72
+ },
73
+ );
74
+ const stderr = await new Response(removeProc.stderr).text();
75
+ await removeProc.exited;
76
+
77
+ if (removeProc.exitCode !== 0) {
78
+ throw new Error(`Failed to remove worktree: ${stderr}`);
79
+ }
80
+ }
81
+
82
+ export async function listWorktrees(repoPath: string): Promise<WorktreeEntry[]> {
83
+ const proc = Bun.spawn(["git", "-C", repoPath, "worktree", "list", "--porcelain"], {
84
+ stdout: "pipe",
85
+ stderr: "pipe",
86
+ });
87
+ const output = await new Response(proc.stdout).text();
88
+ await proc.exited;
89
+
90
+ if (proc.exitCode !== 0) return [];
91
+
92
+ const entries: WorktreeEntry[] = [];
93
+ const blocks = output.split("\n\n").filter((b) => b.trim());
94
+
95
+ for (const block of blocks) {
96
+ const lines = block.split("\n");
97
+ let path = "";
98
+ let branch = "";
99
+ let isBare = false;
100
+
101
+ for (const line of lines) {
102
+ if (line.startsWith("worktree ")) {
103
+ path = line.replace("worktree ", "");
104
+ } else if (line.startsWith("branch ")) {
105
+ // refs/heads/main -> main
106
+ const ref = line.replace("branch ", "");
107
+ const parts = ref.split("/");
108
+ branch = parts.slice(2).join("/");
109
+ } else if (line === "bare") {
110
+ isBare = true;
111
+ }
112
+ }
113
+
114
+ if (path && !isBare) {
115
+ entries.push({ path, branch });
116
+ }
117
+ }
118
+
119
+ return entries;
120
+ }
@@ -0,0 +1,82 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { RepoInfo } from "../types.ts";
4
+
5
+ async function getDefaultBranch(repoPath: string): Promise<string> {
6
+ try {
7
+ const proc = Bun.spawn(["git", "-C", repoPath, "symbolic-ref", "refs/remotes/origin/HEAD"], {
8
+ stdout: "pipe",
9
+ stderr: "pipe",
10
+ });
11
+ const output = await new Response(proc.stdout).text();
12
+ await proc.exited;
13
+ if (proc.exitCode === 0) {
14
+ // refs/remotes/origin/main -> main
15
+ const parts = output.trim().split("/");
16
+ return parts[parts.length - 1] ?? "main";
17
+ }
18
+ } catch {
19
+ // Fall through
20
+ }
21
+
22
+ // Fallback: check if main or master exists
23
+ try {
24
+ const proc = Bun.spawn(["git", "-C", repoPath, "branch", "--list", "main", "master"], {
25
+ stdout: "pipe",
26
+ stderr: "pipe",
27
+ });
28
+ const output = await new Response(proc.stdout).text();
29
+ await proc.exited;
30
+ const branches = output
31
+ .trim()
32
+ .split("\n")
33
+ .map((b) => b.trim().replace("* ", ""));
34
+ if (branches.includes("main")) return "main";
35
+ if (branches.includes("master")) return "master";
36
+ } catch {
37
+ // Fall through
38
+ }
39
+
40
+ return "main";
41
+ }
42
+
43
+ export async function scanRepos(workspaceDirs: string[]): Promise<RepoInfo[]> {
44
+ // Collect all repo paths first
45
+ const repoPaths: { name: string; path: string }[] = [];
46
+
47
+ for (const dir of workspaceDirs) {
48
+ if (!existsSync(dir)) continue;
49
+
50
+ let entries: string[];
51
+ try {
52
+ entries = readdirSync(dir);
53
+ } catch {
54
+ continue;
55
+ }
56
+
57
+ for (const entry of entries) {
58
+ const fullPath = join(dir, entry);
59
+ try {
60
+ const stat = statSync(fullPath);
61
+ if (!stat.isDirectory()) continue;
62
+
63
+ const gitDir = join(fullPath, ".git");
64
+ if (!existsSync(gitDir)) continue;
65
+
66
+ repoPaths.push({ name: entry, path: fullPath });
67
+ } catch {
68
+ // Skip entries that can't be processed
69
+ }
70
+ }
71
+ }
72
+
73
+ // Resolve default branches in parallel
74
+ const repos = await Promise.all(
75
+ repoPaths.map(async ({ name, path }) => {
76
+ const defaultBranch = await getDefaultBranch(path);
77
+ return { name, path, defaultBranch };
78
+ }),
79
+ );
80
+
81
+ return repos.sort((a, b) => a.name.localeCompare(b.name));
82
+ }