mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +00:00
feat: add git worktree session support with improved UI
Implement comprehensive worktree session support allowing users to spawn sessions in temporary git worktrees. Includes backend worktree management, full-stack integration, and refined UI for session type selection. Backend: - Add worktree creation/removal utilities with branch management - Track worktree metadata (basePath, branch, name, path) in session metadata - Automatic cleanup of worktrees when sessions fail or exit - Enhanced error handling with stderr tail logging UI improvements: - Redesign session type toggle with improved alignment and spacing - Move worktree description inline with label for cleaner layout - Add branch name input field that appears when worktree mode selected - Auto-focus on worktree input when switching modes - Reduce gap between radio options from gap-3 to gap-1.5 - Update descriptive text and placeholders for clarity Integration: - Thread worktree parameters through API client, RPC handlers, and daemon - Add worktreeEnv utility to read worktree info from environment - Update session spawning to support both simple and worktree modes
This commit is contained in:
@@ -20,6 +20,7 @@ import { initialMachineMetadata } from '@/daemon/run';
|
||||
import { startHappyServer } from '@/claude/utils/startHappyServer';
|
||||
import { getHappyCliCommand } from '@/utils/spawnHappyCLI';
|
||||
import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler';
|
||||
import { readWorktreeEnv } from '@/utils/worktreeEnv';
|
||||
|
||||
function emitReadyIfIdle(props: {
|
||||
queueSize: () => number;
|
||||
@@ -56,6 +57,7 @@ export async function runAgentSession(opts: {
|
||||
controlledByUser: false
|
||||
};
|
||||
|
||||
const worktreeInfo = readWorktreeEnv();
|
||||
const metadata: Metadata = {
|
||||
path: process.cwd(),
|
||||
host: os.hostname(),
|
||||
@@ -71,7 +73,8 @@ export async function runAgentSession(opts: {
|
||||
startedBy: opts.startedBy || 'terminal',
|
||||
lifecycleState: 'running',
|
||||
lifecycleStateSince: Date.now(),
|
||||
flavor: opts.agentType
|
||||
flavor: opts.agentType,
|
||||
worktree: worktreeInfo ?? undefined
|
||||
};
|
||||
|
||||
const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state });
|
||||
|
||||
@@ -70,7 +70,7 @@ export class ApiMachineClient {
|
||||
|
||||
setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void {
|
||||
this.rpcHandlerManager.registerHandler('spawn-happy-session', async (params: any) => {
|
||||
const { directory, sessionId, machineId, approvedNewDirectoryCreation, agent, yolo, token } = params || {}
|
||||
const { directory, sessionId, machineId, approvedNewDirectoryCreation, agent, yolo, token, sessionType, worktreeName } = params || {}
|
||||
|
||||
if (!directory) {
|
||||
throw new Error('Directory is required')
|
||||
@@ -83,7 +83,9 @@ export class ApiMachineClient {
|
||||
approvedNewDirectoryCreation,
|
||||
agent,
|
||||
yolo,
|
||||
token
|
||||
token,
|
||||
sessionType,
|
||||
worktreeName
|
||||
})
|
||||
|
||||
switch (result.type) {
|
||||
|
||||
@@ -51,7 +51,10 @@ export class RpcHandlerManager {
|
||||
const result = await handler(params as any)
|
||||
return JSON.stringify(result)
|
||||
} catch (error) {
|
||||
this.logger('[RPC] [ERROR] Error handling request', { error })
|
||||
const details = error instanceof Error
|
||||
? { message: error.message, stack: error.stack }
|
||||
: { error: String(error) }
|
||||
this.logger('[RPC] [ERROR] Error handling request', details)
|
||||
return JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
@@ -91,4 +94,3 @@ export class RpcHandlerManager {
|
||||
export function createRpcHandlerManager(config: RpcHandlerConfig): RpcHandlerManager {
|
||||
return new RpcHandlerManager(config)
|
||||
}
|
||||
|
||||
|
||||
+15
-1
@@ -41,6 +41,13 @@ export type Metadata = {
|
||||
archivedBy?: string
|
||||
archiveReason?: string
|
||||
flavor?: string
|
||||
worktree?: {
|
||||
basePath: string
|
||||
branch: string
|
||||
name: string
|
||||
worktreePath?: string
|
||||
createdAt?: number
|
||||
}
|
||||
}
|
||||
|
||||
export const MetadataSchema = z.object({
|
||||
@@ -69,7 +76,14 @@ export const MetadataSchema = z.object({
|
||||
lifecycleStateSince: z.number().optional(),
|
||||
archivedBy: z.string().optional(),
|
||||
archiveReason: z.string().optional(),
|
||||
flavor: z.string().optional()
|
||||
flavor: z.string().optional(),
|
||||
worktree: z.object({
|
||||
basePath: z.string(),
|
||||
branch: z.string(),
|
||||
name: z.string(),
|
||||
worktreePath: z.string().optional(),
|
||||
createdAt: z.number().optional()
|
||||
}).optional()
|
||||
}).passthrough()
|
||||
|
||||
export type AgentState = {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { registerKillSessionHandler } from './registerKillSessionHandler';
|
||||
import { runtimePath } from '../projectPath';
|
||||
import { resolve } from 'node:path';
|
||||
import type { Session } from './session';
|
||||
import { readWorktreeEnv } from '@/utils/worktreeEnv';
|
||||
|
||||
export interface StartOptions {
|
||||
model?: string
|
||||
@@ -73,6 +74,7 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
|
||||
metadata: initialMachineMetadata
|
||||
});
|
||||
|
||||
const worktreeInfo = readWorktreeEnv();
|
||||
let metadata: Metadata = {
|
||||
path: workingDirectory,
|
||||
host: os.hostname(),
|
||||
@@ -89,7 +91,8 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
|
||||
// Initialize lifecycle state
|
||||
lifecycleState: 'running',
|
||||
lifecycleStateSince: Date.now(),
|
||||
flavor: 'claude'
|
||||
flavor: 'claude',
|
||||
worktree: worktreeInfo ?? undefined
|
||||
};
|
||||
const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state });
|
||||
logger.debug(`Session created: ${response.id}`);
|
||||
|
||||
@@ -18,6 +18,7 @@ import packageJson from '../../package.json';
|
||||
import { runtimePath } from '@/projectPath';
|
||||
import type { CodexSession } from './session';
|
||||
import { parseCodexCliOverrides } from './utils/codexCliOverrides';
|
||||
import { readWorktreeEnv } from '@/utils/worktreeEnv';
|
||||
|
||||
export { emitReadyIfIdle } from './utils/emitReadyIfIdle';
|
||||
|
||||
@@ -50,6 +51,7 @@ export async function runCodex(opts: {
|
||||
controlledByUser: false
|
||||
};
|
||||
|
||||
const worktreeInfo = readWorktreeEnv();
|
||||
const metadata: Metadata = {
|
||||
path: workingDirectory,
|
||||
host: os.hostname(),
|
||||
@@ -65,7 +67,8 @@ export async function runCodex(opts: {
|
||||
startedBy,
|
||||
lifecycleState: 'running',
|
||||
lifecycleStateSince: Date.now(),
|
||||
flavor: 'codex'
|
||||
flavor: 'codex',
|
||||
worktree: worktreeInfo ?? undefined
|
||||
};
|
||||
|
||||
const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state });
|
||||
|
||||
@@ -108,7 +108,9 @@ export function startDaemonControlServer({
|
||||
schema: {
|
||||
body: z.object({
|
||||
directory: z.string(),
|
||||
sessionId: z.string().optional()
|
||||
sessionId: z.string().optional(),
|
||||
sessionType: z.enum(['simple', 'worktree']).optional(),
|
||||
worktreeName: z.string().optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@@ -129,10 +131,10 @@ export function startDaemonControlServer({
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
const { directory, sessionId } = request.body;
|
||||
const { directory, sessionId, sessionType, worktreeName } = request.body;
|
||||
|
||||
logger.debug(`[CONTROL SERVER] Spawn session request: dir=${directory}, sessionId=${sessionId || 'new'}`);
|
||||
const result = await spawnSession({ directory, sessionId });
|
||||
const result = await spawnSession({ directory, sessionId, sessionType, worktreeName });
|
||||
|
||||
switch (result.type) {
|
||||
case 'success':
|
||||
@@ -208,4 +210,4 @@ export function startDaemonControlServer({
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+141
-45
@@ -16,6 +16,7 @@ import { isProcessAlive, isWindows, killProcess, killProcessByChildProcess } fro
|
||||
|
||||
import { cleanupDaemonState, getInstalledCliMtimeMs, isDaemonRunningCurrentlyInstalledHappyVersion, stopDaemon } from './controlClient';
|
||||
import { startDaemonControlServer } from './controlServer';
|
||||
import { createWorktree, removeWorktree, type WorktreeInfo } from './worktree';
|
||||
import { join } from 'path';
|
||||
import { runtimePath } from '@/projectPath';
|
||||
|
||||
@@ -188,51 +189,113 @@ export async function startDaemon(): Promise<void> {
|
||||
const { directory, sessionId, machineId, approvedNewDirectoryCreation = true } = options;
|
||||
const agent = options.agent ?? 'claude';
|
||||
const yolo = options.yolo === true;
|
||||
const sessionType = options.sessionType ?? 'simple';
|
||||
const worktreeName = options.worktreeName;
|
||||
let directoryCreated = false;
|
||||
let spawnDirectory = directory;
|
||||
let worktreeInfo: WorktreeInfo | null = null;
|
||||
let happyProcess: ReturnType<typeof spawnHappyCLI> | null = null;
|
||||
|
||||
try {
|
||||
await fs.access(directory);
|
||||
logger.debug(`[DAEMON RUN] Directory exists: ${directory}`);
|
||||
} catch (error) {
|
||||
logger.debug(`[DAEMON RUN] Directory doesn't exist, creating: ${directory}`);
|
||||
|
||||
// Check if directory creation is approved
|
||||
if (!approvedNewDirectoryCreation) {
|
||||
logger.debug(`[DAEMON RUN] Directory creation not approved for: ${directory}`);
|
||||
return {
|
||||
type: 'requestToApproveDirectoryCreation',
|
||||
directory
|
||||
};
|
||||
}
|
||||
|
||||
if (sessionType === 'simple') {
|
||||
try {
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
logger.debug(`[DAEMON RUN] Successfully created directory: ${directory}`);
|
||||
directoryCreated = true;
|
||||
} catch (mkdirError: any) {
|
||||
let errorMessage = `Unable to create directory at '${directory}'. `;
|
||||
await fs.access(directory);
|
||||
logger.debug(`[DAEMON RUN] Directory exists: ${directory}`);
|
||||
} catch (error) {
|
||||
logger.debug(`[DAEMON RUN] Directory doesn't exist, creating: ${directory}`);
|
||||
|
||||
// Provide more helpful error messages based on the error code
|
||||
if (mkdirError.code === 'EACCES') {
|
||||
errorMessage += `Permission denied. You don't have write access to create a folder at this location. Try using a different path or check your permissions.`;
|
||||
} else if (mkdirError.code === 'ENOTDIR') {
|
||||
errorMessage += `A file already exists at this path or in the parent path. Cannot create a directory here. Please choose a different location.`;
|
||||
} else if (mkdirError.code === 'ENOSPC') {
|
||||
errorMessage += `No space left on device. Your disk is full. Please free up some space and try again.`;
|
||||
} else if (mkdirError.code === 'EROFS') {
|
||||
errorMessage += `The file system is read-only. Cannot create directories here. Please choose a writable location.`;
|
||||
} else {
|
||||
errorMessage += `System error: ${mkdirError.message || mkdirError}. Please verify the path is valid and you have the necessary permissions.`;
|
||||
// Check if directory creation is approved
|
||||
if (!approvedNewDirectoryCreation) {
|
||||
logger.debug(`[DAEMON RUN] Directory creation not approved for: ${directory}`);
|
||||
return {
|
||||
type: 'requestToApproveDirectoryCreation',
|
||||
directory
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug(`[DAEMON RUN] Directory creation failed: ${errorMessage}`);
|
||||
try {
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
logger.debug(`[DAEMON RUN] Successfully created directory: ${directory}`);
|
||||
directoryCreated = true;
|
||||
} catch (mkdirError: any) {
|
||||
let errorMessage = `Unable to create directory at '${directory}'. `;
|
||||
|
||||
// Provide more helpful error messages based on the error code
|
||||
if (mkdirError.code === 'EACCES') {
|
||||
errorMessage += `Permission denied. You don't have write access to create a folder at this location. Try using a different path or check your permissions.`;
|
||||
} else if (mkdirError.code === 'ENOTDIR') {
|
||||
errorMessage += `A file already exists at this path or in the parent path. Cannot create a directory here. Please choose a different location.`;
|
||||
} else if (mkdirError.code === 'ENOSPC') {
|
||||
errorMessage += `No space left on device. Your disk is full. Please free up some space and try again.`;
|
||||
} else if (mkdirError.code === 'EROFS') {
|
||||
errorMessage += `The file system is read-only. Cannot create directories here. Please choose a writable location.`;
|
||||
} else {
|
||||
errorMessage += `System error: ${mkdirError.message || mkdirError}. Please verify the path is valid and you have the necessary permissions.`;
|
||||
}
|
||||
|
||||
logger.debug(`[DAEMON RUN] Directory creation failed: ${errorMessage}`);
|
||||
return {
|
||||
type: 'error',
|
||||
errorMessage
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await fs.access(directory);
|
||||
logger.debug(`[DAEMON RUN] Worktree base directory exists: ${directory}`);
|
||||
} catch (error) {
|
||||
logger.debug(`[DAEMON RUN] Worktree base directory missing: ${directory}`);
|
||||
return {
|
||||
type: 'error',
|
||||
errorMessage
|
||||
errorMessage: `Worktree sessions require an existing Git repository. Directory not found: ${directory}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionType === 'worktree') {
|
||||
const worktreeResult = await createWorktree({
|
||||
basePath: directory,
|
||||
nameHint: worktreeName
|
||||
});
|
||||
if (!worktreeResult.ok) {
|
||||
logger.debug(`[DAEMON RUN] Worktree creation failed: ${worktreeResult.error}`);
|
||||
return {
|
||||
type: 'error',
|
||||
errorMessage: worktreeResult.error
|
||||
};
|
||||
}
|
||||
worktreeInfo = worktreeResult.info;
|
||||
spawnDirectory = worktreeInfo.worktreePath;
|
||||
logger.debug(`[DAEMON RUN] Created worktree ${worktreeInfo.worktreePath} (branch ${worktreeInfo.branch})`);
|
||||
}
|
||||
|
||||
const cleanupWorktree = async () => {
|
||||
if (!worktreeInfo) {
|
||||
return;
|
||||
}
|
||||
const result = await removeWorktree({
|
||||
repoRoot: worktreeInfo.basePath,
|
||||
worktreePath: worktreeInfo.worktreePath
|
||||
});
|
||||
if (!result.ok) {
|
||||
logger.debug(`[DAEMON RUN] Failed to remove worktree ${worktreeInfo.worktreePath}: ${result.error}`);
|
||||
}
|
||||
};
|
||||
const maybeCleanupWorktree = async (reason: string) => {
|
||||
if (!worktreeInfo) {
|
||||
return;
|
||||
}
|
||||
const pid = happyProcess?.pid;
|
||||
if (pid && isProcessAlive(pid)) {
|
||||
logger.debug(`[DAEMON RUN] Skipping worktree cleanup after ${reason}; child still running`, {
|
||||
pid,
|
||||
worktreePath: worktreeInfo.worktreePath
|
||||
});
|
||||
return;
|
||||
}
|
||||
await cleanupWorktree();
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
// Resolve authentication token if provided
|
||||
@@ -257,6 +320,17 @@ export async function startDaemon(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (worktreeInfo) {
|
||||
extraEnv = {
|
||||
...extraEnv,
|
||||
HAPI_WORKTREE_BASE_PATH: worktreeInfo.basePath,
|
||||
HAPI_WORKTREE_BRANCH: worktreeInfo.branch,
|
||||
HAPI_WORKTREE_NAME: worktreeInfo.name,
|
||||
HAPI_WORKTREE_PATH: worktreeInfo.worktreePath,
|
||||
HAPI_WORKTREE_CREATED_AT: String(worktreeInfo.createdAt)
|
||||
};
|
||||
}
|
||||
|
||||
// Construct arguments for the CLI
|
||||
const agentCommand = agent === 'codex'
|
||||
? 'codex'
|
||||
@@ -274,8 +348,26 @@ export async function startDaemon(): Promise<void> {
|
||||
|
||||
// TODO: In future, sessionId could be used with --resume to continue existing sessions
|
||||
// For now, we ignore it - each spawn creates a new session
|
||||
const happyProcess = spawnHappyCLI(args, {
|
||||
cwd: directory,
|
||||
const MAX_TAIL_CHARS = 4000;
|
||||
let stderrTail = '';
|
||||
const appendTail = (current: string, chunk: Buffer | string): string => {
|
||||
const text = chunk.toString();
|
||||
if (!text) {
|
||||
return current;
|
||||
}
|
||||
const combined = current + text;
|
||||
return combined.length > MAX_TAIL_CHARS ? combined.slice(-MAX_TAIL_CHARS) : combined;
|
||||
};
|
||||
const logStderrTail = () => {
|
||||
const trimmed = stderrTail.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
logger.debug('[DAEMON RUN] Child stderr tail', trimmed);
|
||||
};
|
||||
|
||||
happyProcess = spawnHappyCLI(args, {
|
||||
cwd: spawnDirectory,
|
||||
detached: true, // Sessions stay alive when daemon stops
|
||||
stdio: ['ignore', 'pipe', 'pipe'], // Capture stdout/stderr for debugging
|
||||
env: {
|
||||
@@ -284,18 +376,13 @@ export async function startDaemon(): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
// Log output for debugging
|
||||
if (process.env.DEBUG) {
|
||||
happyProcess.stdout?.on('data', (data) => {
|
||||
logger.debug(`[DAEMON RUN] Child stdout: ${data.toString()}`);
|
||||
});
|
||||
happyProcess.stderr?.on('data', (data) => {
|
||||
logger.debug(`[DAEMON RUN] Child stderr: ${data.toString()}`);
|
||||
});
|
||||
}
|
||||
happyProcess.stderr?.on('data', (data) => {
|
||||
stderrTail = appendTail(stderrTail, data);
|
||||
});
|
||||
|
||||
if (!happyProcess.pid) {
|
||||
logger.debug('[DAEMON RUN] Failed to spawn process - no PID returned');
|
||||
await maybeCleanupWorktree('no-pid');
|
||||
return {
|
||||
type: 'error',
|
||||
errorMessage: 'Failed to spawn HAPI process - no PID returned'
|
||||
@@ -316,6 +403,9 @@ export async function startDaemon(): Promise<void> {
|
||||
|
||||
happyProcess.on('exit', (code, signal) => {
|
||||
logger.debug(`[DAEMON RUN] Child PID ${happyProcess.pid} exited with code ${code}, signal ${signal}`);
|
||||
if (code !== 0 || signal) {
|
||||
logStderrTail();
|
||||
}
|
||||
if (happyProcess.pid) {
|
||||
onChildExited(happyProcess.pid);
|
||||
}
|
||||
@@ -331,11 +421,12 @@ export async function startDaemon(): Promise<void> {
|
||||
// Wait for webhook to populate session with happySessionId
|
||||
logger.debug(`[DAEMON RUN] Waiting for session webhook for PID ${happyProcess.pid}`);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const spawnResult = await new Promise<SpawnSessionResult>((resolve) => {
|
||||
// Set timeout for webhook
|
||||
const timeout = setTimeout(() => {
|
||||
pidToAwaiter.delete(happyProcess.pid!);
|
||||
logger.debug(`[DAEMON RUN] Session webhook timeout for PID ${happyProcess.pid}`);
|
||||
logStderrTail();
|
||||
resolve({
|
||||
type: 'error',
|
||||
errorMessage: `Session webhook timeout for PID ${happyProcess.pid}`
|
||||
@@ -354,9 +445,14 @@ export async function startDaemon(): Promise<void> {
|
||||
});
|
||||
});
|
||||
});
|
||||
if (spawnResult.type !== 'success') {
|
||||
await maybeCleanupWorktree('spawn-error');
|
||||
}
|
||||
return spawnResult;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.debug('[DAEMON RUN] Failed to spawn session:', error);
|
||||
await maybeCleanupWorktree('exception');
|
||||
return {
|
||||
type: 'error',
|
||||
errorMessage: `Failed to spawn session: ${errorMessage}`
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { access, mkdir } from 'node:fs/promises';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export type WorktreeInfo = {
|
||||
basePath: string;
|
||||
worktreePath: string;
|
||||
branch: string;
|
||||
name: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
type WorktreeResult =
|
||||
| { ok: true; info: WorktreeInfo }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type RemoveWorktreeResult =
|
||||
| { ok: true }
|
||||
| { ok: false; error: string };
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
|
||||
async function runGit(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> {
|
||||
try {
|
||||
const result = await execFileAsync('git', args, { cwd });
|
||||
return {
|
||||
stdout: result.stdout ? result.stdout.toString() : '',
|
||||
stderr: result.stderr ? result.stderr.toString() : ''
|
||||
};
|
||||
} catch (error) {
|
||||
const execError = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string };
|
||||
const stderr = execError.stderr ? execError.stderr.toString() : '';
|
||||
const stdout = execError.stdout ? execError.stdout.toString() : '';
|
||||
const message = stderr.trim() || stdout.trim() || execError.message || 'Git command failed';
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRepoRoot(basePath: string): Promise<string> {
|
||||
const result = await runGit(['rev-parse', '--show-toplevel'], basePath);
|
||||
const root = result.stdout.trim();
|
||||
if (!root) {
|
||||
throw new Error('Unable to resolve Git repository root.');
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function toSlug(value: string): string {
|
||||
const cleaned = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function formatDatePrefix(date: Date = new Date()): string {
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${month}${day}`;
|
||||
}
|
||||
|
||||
function normalizeNameHint(nameHint?: string): string | null {
|
||||
if (!nameHint) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = nameHint.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const slug = toSlug(trimmed);
|
||||
return slug ? slug : null;
|
||||
}
|
||||
|
||||
function makeDefaultBaseName(): string {
|
||||
const suffix = randomBytes(2).toString('hex');
|
||||
return `${formatDatePrefix()}-${suffix}`;
|
||||
}
|
||||
|
||||
async function pathExists(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function branchExists(repoRoot: string, branch: string): Promise<boolean> {
|
||||
try {
|
||||
await runGit(['show-ref', '--verify', `refs/heads/${branch}`], repoRoot);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWorktree(options: {
|
||||
basePath: string;
|
||||
nameHint?: string;
|
||||
}): Promise<WorktreeResult> {
|
||||
const { basePath, nameHint } = options;
|
||||
let repoRoot: string;
|
||||
|
||||
try {
|
||||
repoRoot = await resolveRepoRoot(basePath);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
ok: false,
|
||||
error: `Path is not a Git repository: ${message}`
|
||||
};
|
||||
}
|
||||
|
||||
const repoParent = dirname(repoRoot);
|
||||
const repoName = basename(repoRoot);
|
||||
const repoWorktreesRoot = join(repoParent, `${repoName}-worktrees`);
|
||||
await mkdir(repoWorktreesRoot, { recursive: true });
|
||||
|
||||
const baseName = normalizeNameHint(nameHint) ?? makeDefaultBaseName();
|
||||
|
||||
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
|
||||
const name = attempt === 0 ? baseName : `${baseName}-${randomBytes(2).toString('hex')}`;
|
||||
const branch = `hapi-${name}`;
|
||||
const worktreePath = join(repoWorktreesRoot, name);
|
||||
|
||||
if (await pathExists(worktreePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await branchExists(repoRoot, branch)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await runGit(['worktree', 'add', '-b', branch, worktreePath], repoRoot);
|
||||
return {
|
||||
ok: true,
|
||||
info: {
|
||||
basePath: repoRoot,
|
||||
worktreePath,
|
||||
branch,
|
||||
name,
|
||||
createdAt: Date.now()
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
ok: false,
|
||||
error: `Failed to create worktree: ${message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Failed to create worktree after multiple attempts. Try again.'
|
||||
};
|
||||
}
|
||||
|
||||
export async function removeWorktree(options: {
|
||||
repoRoot: string;
|
||||
worktreePath: string;
|
||||
}): Promise<RemoveWorktreeResult> {
|
||||
try {
|
||||
await runGit(['worktree', 'remove', '--force', options.worktreePath], options.repoRoot);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,8 @@ export interface SpawnSessionOptions {
|
||||
agent?: 'claude' | 'codex' | 'gemini';
|
||||
yolo?: boolean;
|
||||
token?: string;
|
||||
sessionType?: 'simple' | 'worktree';
|
||||
worktreeName?: string;
|
||||
}
|
||||
|
||||
export type SpawnSessionResult =
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { WorktreeInfo } from '@/daemon/worktree';
|
||||
|
||||
export function readWorktreeEnv(): WorktreeInfo | null {
|
||||
const basePath = process.env.HAPI_WORKTREE_BASE_PATH?.trim();
|
||||
const branch = process.env.HAPI_WORKTREE_BRANCH?.trim();
|
||||
const name = process.env.HAPI_WORKTREE_NAME?.trim();
|
||||
const worktreePath = process.env.HAPI_WORKTREE_PATH?.trim();
|
||||
const createdAtRaw = process.env.HAPI_WORKTREE_CREATED_AT?.trim();
|
||||
|
||||
if (!basePath || !branch || !name || !worktreePath || !createdAtRaw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createdAt = Number(createdAtRaw);
|
||||
if (!Number.isFinite(createdAt)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
basePath,
|
||||
branch,
|
||||
name,
|
||||
worktreePath,
|
||||
createdAt
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user