heyhank 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.
- package/README.md +40 -0
- package/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
- package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
- package/dist/assets/CronManager-DDbz-yiT.js +1 -0
- package/dist/assets/HelpPage-DMfkzERp.js +1 -0
- package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
- package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
- package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
- package/dist/assets/Playground-Fc5cdc5p.js +109 -0
- package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
- package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
- package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
- package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
- package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
- package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
- package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
- package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
- package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
- package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
- package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
- package/dist/assets/index-C8M_PUmX.css +32 -0
- package/dist/assets/index-CEqZnThB.js +204 -0
- package/dist/assets/sw-register-LSSpj6RU.js +1 -0
- package/dist/assets/time-ago-B6r_l9u1.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon-32-original.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/heyhank-mascot-poster.png +0 -0
- package/dist/heyhank-mascot.mp4 +0 -0
- package/dist/heyhank-mascot.webm +0 -0
- package/dist/icon-192-original.png +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512-original.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +21 -0
- package/dist/logo-192.png +0 -0
- package/dist/logo-512.png +0 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo-original.png +0 -0
- package/dist/logo.png +0 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/push-sw.js +34 -0
- package/dist/sw.js +1 -0
- package/dist/workbox-d2a0910a.js +1 -0
- package/package.json +109 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.ts +357 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-timeout.ts +107 -0
- package/server/agent-types.ts +122 -0
- package/server/ai-validation-settings.ts +37 -0
- package/server/ai-validator.ts +181 -0
- package/server/anthropic-provider-migration.ts +48 -0
- package/server/assistant-store.ts +272 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-approve.ts +153 -0
- package/server/auto-namer.ts +36 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.ts +61 -0
- package/server/calendar-service.ts +434 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.ts +1303 -0
- package/server/codex-adapter.ts +3027 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.ts +27 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.ts +1053 -0
- package/server/cost-tracker.ts +222 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/email-service.ts +354 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +75 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.ts +170 -0
- package/server/federation/node-connection.ts +190 -0
- package/server/federation/node-manager.ts +366 -0
- package/server/federation/node-store.ts +86 -0
- package/server/federation/node-types.ts +121 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.ts +379 -0
- package/server/google-media.ts +342 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +491 -0
- package/server/internal-ai.ts +237 -0
- package/server/kill-switch.ts +99 -0
- package/server/llm-providers.ts +342 -0
- package/server/logger.ts +259 -0
- package/server/mcp-registry.ts +401 -0
- package/server/message-bus.ts +271 -0
- package/server/message-delivery.ts +128 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.ts +13 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/provider-manager.ts +111 -0
- package/server/provider-registry.ts +393 -0
- package/server/push-notifications.ts +221 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.ts +320 -0
- package/server/reminder-scheduler.ts +38 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.ts +264 -0
- package/server/routes/assistant-routes.ts +90 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/federation-routes.ts +76 -0
- package/server/routes/fs-routes.ts +622 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/llm-routes.ts +166 -0
- package/server/routes/media-routes.ts +135 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/platform-routes.ts +1379 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/provider-routes.ts +109 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +285 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/socialmedia-routes.ts +208 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes/telephony-routes.ts +259 -0
- package/server/routes.ts +1379 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.ts +457 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.ts +824 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +511 -0
- package/server/settings-manager.ts +149 -0
- package/server/shared-context.ts +157 -0
- package/server/socialmedia/adapter.ts +15 -0
- package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
- package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
- package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
- package/server/socialmedia/manager.ts +227 -0
- package/server/socialmedia/store.ts +98 -0
- package/server/socialmedia/types.ts +89 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/telephony/audio-bridge.ts +331 -0
- package/server/telephony/call-manager.ts +457 -0
- package/server/telephony/call-types.ts +108 -0
- package/server/telephony/telephony-store.ts +119 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.ts +192 -0
- package/server/usage-limits.ts +225 -0
- package/server/web-push.d.ts +51 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +121 -0
- package/server/ws-bridge.ts +1240 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { join, basename } from "node:path";
|
|
4
|
+
import { HEYHANK_HOME } from "./paths.js";
|
|
5
|
+
|
|
6
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface GitRepoInfo {
|
|
9
|
+
repoRoot: string;
|
|
10
|
+
repoName: string;
|
|
11
|
+
currentBranch: string;
|
|
12
|
+
defaultBranch: string;
|
|
13
|
+
isWorktree: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface GitBranchInfo {
|
|
17
|
+
name: string;
|
|
18
|
+
isCurrent: boolean;
|
|
19
|
+
isRemote: boolean;
|
|
20
|
+
worktreePath: string | null;
|
|
21
|
+
ahead: number;
|
|
22
|
+
behind: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GitWorktreeInfo {
|
|
26
|
+
path: string;
|
|
27
|
+
branch: string;
|
|
28
|
+
head: string;
|
|
29
|
+
isMainWorktree: boolean;
|
|
30
|
+
isDirty: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface WorktreeCreateResult {
|
|
34
|
+
worktreePath: string;
|
|
35
|
+
/** The conceptual branch the user selected */
|
|
36
|
+
branch: string;
|
|
37
|
+
/** The actual git branch in the worktree (may be e.g. `main-wt-2` for duplicate sessions) */
|
|
38
|
+
actualBranch: string;
|
|
39
|
+
isNew: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── Paths ──────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const WORKTREES_BASE = join(HEYHANK_HOME, "worktrees");
|
|
45
|
+
|
|
46
|
+
function sanitizeBranch(branch: string): string {
|
|
47
|
+
return branch.replace(/\//g, "--");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function worktreeDir(repoName: string, branch: string): string {
|
|
51
|
+
return join(WORKTREES_BASE, repoName, sanitizeBranch(branch));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
function git(cmd: string, cwd: string): string {
|
|
57
|
+
return execSync(`git ${cmd}`, {
|
|
58
|
+
cwd,
|
|
59
|
+
encoding: "utf-8",
|
|
60
|
+
timeout: 10_000,
|
|
61
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
62
|
+
}).trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function gitSafe(cmd: string, cwd: string): string | null {
|
|
66
|
+
try {
|
|
67
|
+
return git(cmd, cwd);
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Functions ──────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
export function getRepoInfo(cwd: string): GitRepoInfo | null {
|
|
76
|
+
const repoRoot = gitSafe("rev-parse --show-toplevel", cwd);
|
|
77
|
+
if (!repoRoot) return null;
|
|
78
|
+
|
|
79
|
+
const currentBranch = gitSafe("rev-parse --abbrev-ref HEAD", cwd) || "HEAD";
|
|
80
|
+
const gitDir = gitSafe("rev-parse --git-dir", cwd) || "";
|
|
81
|
+
// A linked worktree's .git dir is inside the main repo's .git/worktrees/
|
|
82
|
+
const isWorktree = gitDir.includes("/worktrees/");
|
|
83
|
+
|
|
84
|
+
const defaultBranch = resolveDefaultBranch(repoRoot);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
repoRoot,
|
|
88
|
+
repoName: basename(repoRoot),
|
|
89
|
+
currentBranch,
|
|
90
|
+
defaultBranch,
|
|
91
|
+
isWorktree,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function resolveDefaultBranch(repoRoot: string): string {
|
|
96
|
+
// Try origin HEAD
|
|
97
|
+
const originRef = gitSafe("symbolic-ref refs/remotes/origin/HEAD", repoRoot);
|
|
98
|
+
if (originRef) {
|
|
99
|
+
return originRef.replace("refs/remotes/origin/", "");
|
|
100
|
+
}
|
|
101
|
+
// Fallback: check if main or master exists
|
|
102
|
+
const branches = gitSafe("branch --list main master", repoRoot) || "";
|
|
103
|
+
if (branches.includes("main")) return "main";
|
|
104
|
+
if (branches.includes("master")) return "master";
|
|
105
|
+
// Last resort
|
|
106
|
+
return "main";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function listBranches(repoRoot: string): GitBranchInfo[] {
|
|
110
|
+
// Get worktree mappings first
|
|
111
|
+
const worktrees = listWorktrees(repoRoot);
|
|
112
|
+
const worktreeByBranch = new Map<string, string>();
|
|
113
|
+
for (const wt of worktrees) {
|
|
114
|
+
if (wt.branch) worktreeByBranch.set(wt.branch, wt.path);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const result: GitBranchInfo[] = [];
|
|
118
|
+
|
|
119
|
+
// Local branches
|
|
120
|
+
const localRaw = gitSafe(
|
|
121
|
+
"for-each-ref '--format=%(refname:short)%09%(HEAD)' refs/heads/",
|
|
122
|
+
repoRoot,
|
|
123
|
+
);
|
|
124
|
+
if (localRaw) {
|
|
125
|
+
for (const line of localRaw.split("\n")) {
|
|
126
|
+
if (!line.trim()) continue;
|
|
127
|
+
const [name, head] = line.split("\t");
|
|
128
|
+
const isCurrent = head?.trim() === "*";
|
|
129
|
+
const { ahead, behind } = getBranchStatus(repoRoot, name);
|
|
130
|
+
result.push({
|
|
131
|
+
name,
|
|
132
|
+
isCurrent,
|
|
133
|
+
isRemote: false,
|
|
134
|
+
worktreePath: worktreeByBranch.get(name) || null,
|
|
135
|
+
ahead,
|
|
136
|
+
behind,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Remote branches (only those without a local counterpart)
|
|
142
|
+
const localNames = new Set(result.map((b) => b.name));
|
|
143
|
+
const remoteRaw = gitSafe(
|
|
144
|
+
"for-each-ref '--format=%(refname:short)' refs/remotes/origin/",
|
|
145
|
+
repoRoot,
|
|
146
|
+
);
|
|
147
|
+
if (remoteRaw) {
|
|
148
|
+
for (const line of remoteRaw.split("\n")) {
|
|
149
|
+
const full = line.trim();
|
|
150
|
+
if (!full || full === "origin/HEAD") continue;
|
|
151
|
+
const name = full.replace("origin/", "");
|
|
152
|
+
if (localNames.has(name)) continue;
|
|
153
|
+
result.push({
|
|
154
|
+
name,
|
|
155
|
+
isCurrent: false,
|
|
156
|
+
isRemote: true,
|
|
157
|
+
worktreePath: null,
|
|
158
|
+
ahead: 0,
|
|
159
|
+
behind: 0,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function listWorktrees(repoRoot: string): GitWorktreeInfo[] {
|
|
168
|
+
const raw = gitSafe("worktree list --porcelain", repoRoot);
|
|
169
|
+
if (!raw) return [];
|
|
170
|
+
|
|
171
|
+
const worktrees: GitWorktreeInfo[] = [];
|
|
172
|
+
let current: Partial<GitWorktreeInfo> = {};
|
|
173
|
+
|
|
174
|
+
for (const line of raw.split("\n")) {
|
|
175
|
+
if (line.startsWith("worktree ")) {
|
|
176
|
+
if (current.path) {
|
|
177
|
+
worktrees.push(current as GitWorktreeInfo);
|
|
178
|
+
}
|
|
179
|
+
current = { path: line.slice(9), isDirty: false, isMainWorktree: false };
|
|
180
|
+
} else if (line.startsWith("HEAD ")) {
|
|
181
|
+
current.head = line.slice(5);
|
|
182
|
+
} else if (line.startsWith("branch ")) {
|
|
183
|
+
current.branch = line.slice(7).replace("refs/heads/", "");
|
|
184
|
+
} else if (line === "bare") {
|
|
185
|
+
current.isMainWorktree = true;
|
|
186
|
+
} else if (line === "") {
|
|
187
|
+
// End of entry — check if main worktree (first one is always main)
|
|
188
|
+
if (worktrees.length === 0 && current.path) {
|
|
189
|
+
current.isMainWorktree = true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Push last entry
|
|
194
|
+
if (current.path) {
|
|
195
|
+
if (worktrees.length === 0) current.isMainWorktree = true;
|
|
196
|
+
worktrees.push(current as GitWorktreeInfo);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Check dirty status for each worktree
|
|
200
|
+
for (const wt of worktrees) {
|
|
201
|
+
wt.isDirty = isWorktreeDirty(wt.path);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return worktrees;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function ensureWorktree(
|
|
208
|
+
repoRoot: string,
|
|
209
|
+
branchName: string,
|
|
210
|
+
options?: { baseBranch?: string; createBranch?: boolean; forceNew?: boolean },
|
|
211
|
+
): WorktreeCreateResult {
|
|
212
|
+
const repoName = basename(repoRoot);
|
|
213
|
+
|
|
214
|
+
// Check if a worktree already exists for this branch
|
|
215
|
+
const existing = listWorktrees(repoRoot);
|
|
216
|
+
const found = existing.find((wt) => wt.branch === branchName);
|
|
217
|
+
|
|
218
|
+
if (found && !options?.forceNew) {
|
|
219
|
+
// Don't reuse the main worktree — it's the original repo checkout
|
|
220
|
+
if (!found.isMainWorktree) {
|
|
221
|
+
return { worktreePath: found.path, branch: branchName, actualBranch: branchName, isNew: false };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Find a unique path: append random 4-digit suffix if the base path is taken
|
|
226
|
+
const basePath = worktreeDir(repoName, branchName);
|
|
227
|
+
let targetPath = basePath;
|
|
228
|
+
for (let attempt = 0; attempt < 100 && existsSync(targetPath); attempt++) {
|
|
229
|
+
const suffix = Math.floor(1000 + Math.random() * 9000);
|
|
230
|
+
targetPath = `${basePath}-${suffix}`;
|
|
231
|
+
}
|
|
232
|
+
if (existsSync(targetPath)) {
|
|
233
|
+
targetPath = `${basePath}-${Date.now()}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Ensure parent directory exists
|
|
237
|
+
mkdirSync(join(WORKTREES_BASE, repoName), { recursive: true });
|
|
238
|
+
|
|
239
|
+
// A worktree already exists for this branch — create a new uniquely-named
|
|
240
|
+
// branch so multiple sessions can work on the same branch independently.
|
|
241
|
+
if (found) {
|
|
242
|
+
const commitHash = git("rev-parse HEAD", found.path);
|
|
243
|
+
const uniqueBranch = generateUniqueWorktreeBranch(repoRoot, branchName);
|
|
244
|
+
git(`worktree add -b ${uniqueBranch} "${targetPath}" ${commitHash}`, repoRoot);
|
|
245
|
+
return { worktreePath: targetPath, branch: branchName, actualBranch: uniqueBranch, isNew: false };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Check if branch already exists locally or on remote
|
|
249
|
+
const branchExists =
|
|
250
|
+
gitSafe(`rev-parse --verify refs/heads/${branchName}`, repoRoot) !== null;
|
|
251
|
+
const remoteBranchExists =
|
|
252
|
+
gitSafe(`rev-parse --verify refs/remotes/origin/${branchName}`, repoRoot) !== null;
|
|
253
|
+
|
|
254
|
+
if (branchExists) {
|
|
255
|
+
if (options?.forceNew) {
|
|
256
|
+
// Create a uniquely-named branch so multiple sessions can work independently
|
|
257
|
+
const commitHash = git(`rev-parse refs/heads/${branchName}`, repoRoot);
|
|
258
|
+
const uniqueBranch = generateUniqueWorktreeBranch(repoRoot, branchName);
|
|
259
|
+
git(`worktree add -b ${uniqueBranch} "${targetPath}" ${commitHash}`, repoRoot);
|
|
260
|
+
return { worktreePath: targetPath, branch: branchName, actualBranch: uniqueBranch, isNew: false };
|
|
261
|
+
}
|
|
262
|
+
// Worktree add with existing local branch
|
|
263
|
+
git(`worktree add "${targetPath}" ${branchName}`, repoRoot);
|
|
264
|
+
return { worktreePath: targetPath, branch: branchName, actualBranch: branchName, isNew: false };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (remoteBranchExists) {
|
|
268
|
+
if (options?.forceNew) {
|
|
269
|
+
const uniqueBranch = generateUniqueWorktreeBranch(repoRoot, branchName);
|
|
270
|
+
git(`worktree add -b ${uniqueBranch} "${targetPath}" origin/${branchName}`, repoRoot);
|
|
271
|
+
return { worktreePath: targetPath, branch: branchName, actualBranch: uniqueBranch, isNew: false };
|
|
272
|
+
}
|
|
273
|
+
// Create local tracking branch from remote
|
|
274
|
+
git(`worktree add -b ${branchName} "${targetPath}" origin/${branchName}`, repoRoot);
|
|
275
|
+
return { worktreePath: targetPath, branch: branchName, actualBranch: branchName, isNew: false };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (options?.createBranch !== false) {
|
|
279
|
+
// Create new branch from base — prefer remote ref (up-to-date after fetch)
|
|
280
|
+
// over the potentially stale local ref
|
|
281
|
+
const base = options?.baseBranch || resolveDefaultBranch(repoRoot);
|
|
282
|
+
const remoteRef = `origin/${base}`;
|
|
283
|
+
const startPoint =
|
|
284
|
+
gitSafe(`rev-parse --verify refs/remotes/${remoteRef}`, repoRoot) !== null
|
|
285
|
+
? remoteRef
|
|
286
|
+
: base;
|
|
287
|
+
git(`worktree add -b ${branchName} "${targetPath}" ${startPoint}`, repoRoot);
|
|
288
|
+
return { worktreePath: targetPath, branch: branchName, actualBranch: branchName, isNew: true };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
throw new Error(`Branch "${branchName}" does not exist and createBranch is false`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Generate a unique branch name for a HeyHank-managed worktree.
|
|
296
|
+
* Pattern: `{branch}-wt-{random4digit}` (e.g. `main-wt-8374`).
|
|
297
|
+
* Uses random suffixes to avoid collisions with leftover branches.
|
|
298
|
+
*/
|
|
299
|
+
export function generateUniqueWorktreeBranch(repoRoot: string, baseBranch: string): string {
|
|
300
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
301
|
+
const suffix = Math.floor(1000 + Math.random() * 9000);
|
|
302
|
+
const candidate = `${baseBranch}-wt-${suffix}`;
|
|
303
|
+
if (gitSafe(`rev-parse --verify refs/heads/${candidate}`, repoRoot) === null) {
|
|
304
|
+
return candidate;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// Fallback: use timestamp if all random attempts collide (extremely unlikely)
|
|
308
|
+
return `${baseBranch}-wt-${Date.now()}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function removeWorktree(
|
|
312
|
+
repoRoot: string,
|
|
313
|
+
worktreePath: string,
|
|
314
|
+
options?: { force?: boolean; branchToDelete?: string },
|
|
315
|
+
): { removed: boolean; reason?: string } {
|
|
316
|
+
if (!existsSync(worktreePath)) {
|
|
317
|
+
// Already gone, clean up git's reference
|
|
318
|
+
gitSafe("worktree prune", repoRoot);
|
|
319
|
+
if (options?.branchToDelete) {
|
|
320
|
+
gitSafe(`branch -D ${options.branchToDelete}`, repoRoot);
|
|
321
|
+
}
|
|
322
|
+
return { removed: true };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!options?.force && isWorktreeDirty(worktreePath)) {
|
|
326
|
+
return {
|
|
327
|
+
removed: false,
|
|
328
|
+
reason: "Worktree has uncommitted changes. Use force to remove anyway.",
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
const forceFlag = options?.force ? " --force" : "";
|
|
334
|
+
git(`worktree remove "${worktreePath}"${forceFlag}`, repoRoot);
|
|
335
|
+
// Clean up the HeyHank-managed branch after worktree removal
|
|
336
|
+
if (options?.branchToDelete) {
|
|
337
|
+
gitSafe(`branch -D ${options.branchToDelete}`, repoRoot);
|
|
338
|
+
}
|
|
339
|
+
return { removed: true };
|
|
340
|
+
} catch (e: unknown) {
|
|
341
|
+
return {
|
|
342
|
+
removed: false,
|
|
343
|
+
reason: e instanceof Error ? e.message : String(e),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function isWorktreeDirty(worktreePath: string): boolean {
|
|
349
|
+
if (!existsSync(worktreePath)) return false;
|
|
350
|
+
const status = gitSafe("status --porcelain", worktreePath);
|
|
351
|
+
return status !== null && status.length > 0;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function gitFetch(cwd: string): { success: boolean; output: string } {
|
|
355
|
+
try {
|
|
356
|
+
const output = git("fetch --prune", cwd);
|
|
357
|
+
return { success: true, output };
|
|
358
|
+
} catch (e: unknown) {
|
|
359
|
+
return { success: false, output: e instanceof Error ? e.message : String(e) };
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function gitPull(
|
|
364
|
+
cwd: string,
|
|
365
|
+
): { success: boolean; output: string } {
|
|
366
|
+
try {
|
|
367
|
+
const output = git("pull", cwd);
|
|
368
|
+
return { success: true, output };
|
|
369
|
+
} catch (e: unknown) {
|
|
370
|
+
return { success: false, output: e instanceof Error ? e.message : String(e) };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
export function checkoutBranch(cwd: string, branchName: string): void {
|
|
376
|
+
git(`checkout ${branchName}`, cwd);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Checkout an existing branch, or create a new one from origin/{defaultBranch}
|
|
381
|
+
* (falling back to local defaultBranch if no remote ref exists).
|
|
382
|
+
*/
|
|
383
|
+
export function checkoutOrCreateBranch(
|
|
384
|
+
cwd: string,
|
|
385
|
+
branchName: string,
|
|
386
|
+
options?: { createBranch?: boolean; defaultBranch?: string },
|
|
387
|
+
): { created: boolean } {
|
|
388
|
+
// Try regular checkout first (works for existing local and remote-tracking branches)
|
|
389
|
+
const checkoutResult = gitSafe(`checkout ${branchName}`, cwd);
|
|
390
|
+
if (checkoutResult !== null) {
|
|
391
|
+
return { created: false };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Branch doesn't exist — create it if allowed
|
|
395
|
+
if (!options?.createBranch) {
|
|
396
|
+
throw new Error(`Branch "${branchName}" does not exist. Pass createBranch to create it.`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const base = options.defaultBranch || resolveDefaultBranch(cwd);
|
|
400
|
+
// Prefer remote ref (up-to-date after fetch) over potentially stale local ref
|
|
401
|
+
const remoteRef = `origin/${base}`;
|
|
402
|
+
const startPoint =
|
|
403
|
+
gitSafe(`rev-parse --verify refs/remotes/${remoteRef}`, cwd) !== null
|
|
404
|
+
? remoteRef
|
|
405
|
+
: base;
|
|
406
|
+
git(`checkout -b ${branchName} ${startPoint}`, cwd);
|
|
407
|
+
return { created: true };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function getBranchStatus(
|
|
411
|
+
repoRoot: string,
|
|
412
|
+
branchName: string,
|
|
413
|
+
): { ahead: number; behind: number } {
|
|
414
|
+
const raw = gitSafe(
|
|
415
|
+
`rev-list --left-right --count origin/${branchName}...${branchName}`,
|
|
416
|
+
repoRoot,
|
|
417
|
+
);
|
|
418
|
+
if (!raw) return { ahead: 0, behind: 0 };
|
|
419
|
+
const [behind, ahead] = raw.split(/\s+/).map(Number);
|
|
420
|
+
return { ahead: ahead || 0, behind: behind || 0 };
|
|
421
|
+
}
|