feat(FN-3026): use Nerd Font terminal stack with font fallback migration
Adds Nerd Font as the terminal font stack default with a migration to update existing user settings, updates the terminal and settings modals to reflect the new font choice, and includes corresponding tests plus a patch changeset for `@runfusion/fusion`. Fusion-Task-Id: FN-3026
This commit is contained in:
5
.changeset/fn-3026-terminal-nerd-font.md
Normal file
5
.changeset/fn-3026-terminal-nerd-font.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Prefer a Nerd Font-capable monospace stack in the dashboard interactive terminal so powerline/private-use glyphs render correctly when patched fonts are installed, while preserving existing fallback monospace behavior.
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
* @module migration
|
* @module migration
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { homedir, tmpdir } from "node:os";
|
import { homedir, tmpdir } from "node:os";
|
||||||
import { isAbsolute, join, resolve, basename, dirname } from "node:path";
|
import { isAbsolute, join, resolve, basename, dirname } from "node:path";
|
||||||
import type { CentralCore } from "./central-core.js";
|
import type { CentralCore } from "./central-core.js";
|
||||||
@@ -255,13 +255,34 @@ export class FirstRunDetector {
|
|||||||
if (name) return name;
|
if (name) return name;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Git not available or no remote - fall through to directory name
|
// Git CLI unavailable/blocked in some environments; fall through to config parsing.
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteFromConfig = this.readOriginRemoteFromGitConfig(projectPath);
|
||||||
|
if (remoteFromConfig) {
|
||||||
|
const name = this.extractRepoName(remoteFromConfig);
|
||||||
|
if (name) return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to directory name
|
// Fallback to directory name
|
||||||
return basename(projectPath);
|
return basename(projectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readOriginRemoteFromGitConfig(projectPath: string): string | null {
|
||||||
|
try {
|
||||||
|
const gitConfig = readFileSync(join(projectPath, ".git", "config"), "utf8");
|
||||||
|
const originSectionMatch = gitConfig.match(/\[remote\s+"origin"\]([\s\S]*?)(?:\n\[|$)/);
|
||||||
|
if (!originSectionMatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlMatch = originSectionMatch[1]?.match(/^\s*url\s*=\s*(.+)$/m);
|
||||||
|
return urlMatch?.[1]?.trim() || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract repository name from git remote URL.
|
* Extract repository name from git remote URL.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ Access a fully functional PTY (pseudo-terminal) shell directly from the dashboar
|
|||||||
- **Real PTY Terminal**: Spawns a real shell (bash/zsh/powershell) using node-pty for authentic terminal behavior
|
- **Real PTY Terminal**: Spawns a real shell (bash/zsh/powershell) using node-pty for authentic terminal behavior
|
||||||
- **Bidirectional Communication**: WebSocket connection for instant input/output
|
- **Bidirectional Communication**: WebSocket connection for instant input/output
|
||||||
- **xterm.js Integration**: Full terminal emulation with proper ANSI support, colors, and cursor handling
|
- **xterm.js Integration**: Full terminal emulation with proper ANSI support, colors, and cursor handling
|
||||||
|
- **Nerd Font-preferred rendering**: The terminal prefers a Nerd Font-capable monospace stack (`MesloLGS NF`, `JetBrainsMono Nerd Font`, `FiraCode Nerd Font`, etc.) with standard monospace fallbacks so powerline/private-use prompt glyphs render correctly when patched fonts are installed
|
||||||
- **Auto-resizing**: Terminal automatically fits to container size
|
- **Auto-resizing**: Terminal automatically fits to container size
|
||||||
- **Scrollback Buffer**: 5KB of scrollback history with replay on reconnect
|
- **Scrollback Buffer**: 5KB of scrollback history with replay on reconnect
|
||||||
- **Reconnection Support**: Automatic reconnect with exponential backoff if connection drops
|
- **Reconnection Support**: Automatic reconnect with exponential backoff if connection drops
|
||||||
|
|||||||
@@ -740,7 +740,8 @@ export function SettingsModal({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeSection !== "remote") return;
|
if (activeSection !== "remote") return;
|
||||||
if (remoteStatus?.state !== "stopped" || !externalTunnel?.url) {
|
const tunnelUrl = externalTunnel?.url;
|
||||||
|
if (remoteStatus?.state !== "stopped" || !tunnelUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,10 +750,10 @@ export function SettingsModal({
|
|||||||
try {
|
try {
|
||||||
const qr = await fetchRemoteQr("image/svg", { projectId, tokenType: "persistent" });
|
const qr = await fetchRemoteQr("image/svg", { projectId, tokenType: "persistent" });
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setTunnelShareLink({ url: externalTunnel.url, qrSvg: qr.data ?? null });
|
setTunnelShareLink({ url: tunnelUrl, qrSvg: qr.data ?? null });
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setTunnelShareLink({ url: externalTunnel.url, qrSvg: null });
|
setTunnelShareLink({ url: tunnelUrl, qrSvg: null });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ const TERMINAL_FONT_SIZE_KEY = "kb-terminal-font-size";
|
|||||||
const DEFAULT_FONT_SIZE = 14;
|
const DEFAULT_FONT_SIZE = 14;
|
||||||
const MIN_TERMINAL_FONT_SIZE = 8;
|
const MIN_TERMINAL_FONT_SIZE = 8;
|
||||||
const MAX_TERMINAL_FONT_SIZE = 32;
|
const MAX_TERMINAL_FONT_SIZE = 32;
|
||||||
|
const XTERM_FONT_FAMILY =
|
||||||
|
'"MesloLGS NF", "MesloLGM Nerd Font", "JetBrainsMono Nerd Font", "FiraCode Nerd Font", "Hack Nerd Font", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace';
|
||||||
|
|
||||||
export function ctrlChar(key: string): string {
|
export function ctrlChar(key: string): string {
|
||||||
if (!key) {
|
if (!key) {
|
||||||
@@ -528,7 +530,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
|||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
cursorStyle: "block",
|
cursorStyle: "block",
|
||||||
fontSize: fontSizeRef.current,
|
fontSize: fontSizeRef.current,
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
fontFamily: XTERM_FONT_FAMILY,
|
||||||
theme: {
|
theme: {
|
||||||
background: "#1e1e1e",
|
background: "#1e1e1e",
|
||||||
foreground: "#d4d4d4",
|
foreground: "#d4d4d4",
|
||||||
|
|||||||
@@ -551,6 +551,24 @@ describe("TerminalModal", () => {
|
|||||||
expect(mockTerminalInstance.open).toHaveBeenCalledWith(terminalDiv);
|
expect(mockTerminalInstance.open).toHaveBeenCalledWith(terminalDiv);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("initializes xterm with a Nerd Font-preferred monospace stack", async () => {
|
||||||
|
const { Terminal } = await import("@xterm/xterm");
|
||||||
|
|
||||||
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Terminal).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
fontFamily:
|
||||||
|
'"MesloLGS NF", "MesloLGM Nerd Font", "JetBrainsMono Nerd Font", "FiraCode Nerd Font", "Hack Nerd Font", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("14px");
|
||||||
|
});
|
||||||
|
|
||||||
describe("shortcut panel", () => {
|
describe("shortcut panel", () => {
|
||||||
it("is hidden by default and toggles from header action", async () => {
|
it("is hidden by default and toggles from header action", async () => {
|
||||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { exec } from "node:child_process";
|
import { exec } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
|
||||||
import {
|
import {
|
||||||
resolveProjectDefaultModel,
|
resolveProjectDefaultModel,
|
||||||
type TaskStore,
|
type TaskStore,
|
||||||
@@ -15,9 +15,25 @@ import { createLogger } from "./logger.js";
|
|||||||
import { defaultShell } from "./shell-utils.js";
|
import { defaultShell } from "./shell-utils.js";
|
||||||
import { createFnAgent, promptWithFallback } from "./pi.js";
|
import { createFnAgent, promptWithFallback } from "./pi.js";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
const log = createLogger("cron-runner");
|
const log = createLogger("cron-runner");
|
||||||
|
|
||||||
|
function execCommand(command: string, options: Parameters<typeof exec>[1]): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
exec(command, options, (error, stdout, stderr) => {
|
||||||
|
const stdoutText = typeof stdout === "string" ? stdout : String(stdout ?? "");
|
||||||
|
const stderrText = typeof stderr === "string" ? stderr : String(stderr ?? "");
|
||||||
|
if (error) {
|
||||||
|
const errWithOutput = error as Error & { stdout?: string; stderr?: string };
|
||||||
|
errWithOutput.stdout = stdoutText;
|
||||||
|
errWithOutput.stderr = stderrText;
|
||||||
|
reject(errWithOutput);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({ stdout: stdoutText, stderr: stderrText });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Default execution timeout: 5 minutes. */
|
/** Default execution timeout: 5 minutes. */
|
||||||
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
/** Maximum output buffer: 1 MB. */
|
/** Maximum output buffer: 1 MB. */
|
||||||
@@ -273,7 +289,7 @@ export class CronRunner {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const timeoutMs = schedule.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
const timeoutMs = schedule.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||||
const { stdout, stderr } = await execAsync(schedule.command, {
|
const { stdout, stderr } = await execCommand(schedule.command, {
|
||||||
timeout: timeoutMs,
|
timeout: timeoutMs,
|
||||||
maxBuffer: MAX_BUFFER,
|
maxBuffer: MAX_BUFFER,
|
||||||
shell: defaultShell,
|
shell: defaultShell,
|
||||||
@@ -428,7 +444,7 @@ export class CronRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { stdout, stderr } = await execAsync(step.command, {
|
const { stdout, stderr } = await execCommand(step.command, {
|
||||||
timeout: timeoutMs,
|
timeout: timeoutMs,
|
||||||
maxBuffer: MAX_BUFFER,
|
maxBuffer: MAX_BUFFER,
|
||||||
shell: defaultShell,
|
shell: defaultShell,
|
||||||
@@ -523,11 +539,12 @@ export class CronRunner {
|
|||||||
|
|
||||||
const response = await Promise.race([resultPromise, timeoutPromise]);
|
const response = await Promise.race([resultPromise, timeoutPromise]);
|
||||||
|
|
||||||
const output = response.length > MAX_OUTPUT_LENGTH
|
const responseText = String(response ?? "");
|
||||||
? response.slice(0, MAX_OUTPUT_LENGTH) + "\n[output truncated]"
|
const output = responseText.length > MAX_OUTPUT_LENGTH
|
||||||
: response;
|
? responseText.slice(0, MAX_OUTPUT_LENGTH) + "\n[output truncated]"
|
||||||
|
: responseText;
|
||||||
|
|
||||||
log.log(` ✓ AI prompt step "${step.name}" completed (${response.length} chars)`);
|
log.log(` ✓ AI prompt step "${step.name}" completed (${responseText.length} chars)`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stepId: step.id,
|
stepId: step.id,
|
||||||
@@ -677,12 +694,14 @@ export async function createAiPromptExecutor(cwd: string): Promise<AiPromptExecu
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Combine and truncate stdout/stderr to stay within storage limits. */
|
/** Combine and truncate stdout/stderr to stay within storage limits. */
|
||||||
function truncateOutput(stdout: string, stderr: string): string {
|
function truncateOutput(stdout: string | null | undefined, stderr: string | null | undefined): string {
|
||||||
let combined = stdout;
|
const out = stdout ?? "";
|
||||||
if (stderr) {
|
const err = stderr ?? "";
|
||||||
|
let combined = out;
|
||||||
|
if (err) {
|
||||||
// Add separator only if there's also stdout content
|
// Add separator only if there's also stdout content
|
||||||
combined += stdout ? "\n--- stderr ---\n" : "";
|
combined += out ? "\n--- stderr ---\n" : "";
|
||||||
combined += stderr;
|
combined += err;
|
||||||
}
|
}
|
||||||
if (combined.length > MAX_OUTPUT_LENGTH) {
|
if (combined.length > MAX_OUTPUT_LENGTH) {
|
||||||
combined = combined.slice(0, MAX_OUTPUT_LENGTH) + "\n[output truncated]";
|
combined = combined.slice(0, MAX_OUTPUT_LENGTH) + "\n[output truncated]";
|
||||||
|
|||||||
@@ -1115,6 +1115,7 @@ export class TaskExecutor {
|
|||||||
private async performWorkflowRerunBounce(
|
private async performWorkflowRerunBounce(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
|
preserveResumeState: boolean = true,
|
||||||
): Promise<"bounced" | "skipped-pending"> {
|
): Promise<"bounced" | "skipped-pending"> {
|
||||||
// Re-entry guard: if a previous bounce for the same task is still
|
// Re-entry guard: if a previous bounce for the same task is still
|
||||||
// mid-flight (e.g., the watchdog fired before the original sequence
|
// mid-flight (e.g., the watchdog fired before the original sequence
|
||||||
@@ -1139,7 +1140,11 @@ export class TaskExecutor {
|
|||||||
// moveTask's default reopen-to-todo path resets every step to
|
// moveTask's default reopen-to-todo path resets every step to
|
||||||
// pending and rewrites PROMPT.md checkboxes, which would discard
|
// pending and rewrites PROMPT.md checkboxes, which would discard
|
||||||
// the partial progress this bounce is supposed to retry on top of.
|
// the partial progress this bounce is supposed to retry on top of.
|
||||||
await this.store.moveTask(taskId, "todo", { preserveResumeState: true });
|
if (preserveResumeState) {
|
||||||
|
await this.store.moveTask(taskId, "todo", { preserveResumeState: true });
|
||||||
|
} else {
|
||||||
|
await this.store.moveTask(taskId, "todo");
|
||||||
|
}
|
||||||
await this.store.updateTask(taskId, {
|
await this.store.updateTask(taskId, {
|
||||||
worktree: worktreePath,
|
worktree: worktreePath,
|
||||||
executionStartedAt: originalExecutionStartedAt ?? null,
|
executionStartedAt: originalExecutionStartedAt ?? null,
|
||||||
@@ -1160,12 +1165,17 @@ export class TaskExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleWorkflowRerun(taskId: string, worktreePath: string, successMessage: string): void {
|
private scheduleWorkflowRerun(
|
||||||
|
taskId: string,
|
||||||
|
worktreePath: string,
|
||||||
|
successMessage: string,
|
||||||
|
preserveResumeState: boolean = true,
|
||||||
|
): void {
|
||||||
this.clearWorkflowRerunWatchdog(taskId);
|
this.clearWorkflowRerunWatchdog(taskId);
|
||||||
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const outcome = await this.performWorkflowRerunBounce(taskId, worktreePath);
|
const outcome = await this.performWorkflowRerunBounce(taskId, worktreePath, preserveResumeState);
|
||||||
if (outcome === "bounced") {
|
if (outcome === "bounced") {
|
||||||
executorLog.log(successMessage);
|
executorLog.log(successMessage);
|
||||||
} else {
|
} else {
|
||||||
@@ -1204,7 +1214,7 @@ export class TaskExecutor {
|
|||||||
).catch(() => undefined);
|
).catch(() => undefined);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const outcome = await this.performWorkflowRerunBounce(taskId, worktreePath);
|
const outcome = await this.performWorkflowRerunBounce(taskId, worktreePath, preserveResumeState);
|
||||||
if (outcome === "bounced") {
|
if (outcome === "bounced") {
|
||||||
executorLog.warn(`${taskId}: workflow rerun watchdog retry succeeded`);
|
executorLog.warn(`${taskId}: workflow rerun watchdog retry succeeded`);
|
||||||
} else {
|
} else {
|
||||||
@@ -1434,7 +1444,7 @@ export class TaskExecutor {
|
|||||||
if (!workflowResult.allPassed) {
|
if (!workflowResult.allPassed) {
|
||||||
// For recovery path, treat any failure (including revision) as hard failure
|
// For recovery path, treat any failure (including revision) as hard failure
|
||||||
// Send back to in-progress so executor can attempt to fix the issues
|
// Send back to in-progress so executor can attempt to fix the issues
|
||||||
await this.sendTaskBackForFix(task, task.worktree!, workflowResult.feedback, workflowResult.stepName || "Unknown", "Workflow step failed during recovery");
|
await this.sendTaskBackForFix(task, task.worktree!, workflowResult.feedback, workflowResult.stepName || "Unknown", "Workflow step failed during recovery", false);
|
||||||
return true; // Still transitioned out of in-progress
|
return true; // Still transitioned out of in-progress
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -3197,8 +3207,19 @@ export class TaskExecutor {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const task = await store.updateStep(taskId, step, status as StepStatus);
|
const stepIndex = step - 1;
|
||||||
const stepInfo = task.steps[step];
|
if (!Number.isInteger(stepIndex) || stepIndex < 0) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Invalid step number: ${step}. Steps are 1-indexed.`,
|
||||||
|
}],
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await store.updateStep(taskId, stepIndex, status as StepStatus);
|
||||||
|
const stepInfo = task.steps[stepIndex];
|
||||||
const persistedStatus = stepInfo.status;
|
const persistedStatus = stepInfo.status;
|
||||||
const progress = task.steps.filter((s) => s.status === "done").length;
|
const progress = task.steps.filter((s) => s.status === "done").length;
|
||||||
|
|
||||||
@@ -3854,6 +3875,7 @@ ${feedback}
|
|||||||
failureFeedback: string,
|
failureFeedback: string,
|
||||||
stepName: string,
|
stepName: string,
|
||||||
reason: string,
|
reason: string,
|
||||||
|
preserveResumeState: boolean = true,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const taskId = task.id;
|
const taskId = task.id;
|
||||||
this.clearCompletedTaskWatchdog(taskId);
|
this.clearCompletedTaskWatchdog(taskId);
|
||||||
@@ -3895,6 +3917,7 @@ ${feedback}
|
|||||||
taskId,
|
taskId,
|
||||||
worktreePath,
|
worktreePath,
|
||||||
`${taskId}: sent back to in-progress for remediation`,
|
`${taskId}: sent back to in-progress for remediation`,
|
||||||
|
preserveResumeState,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ import { worktreePoolLog } from "./logger.js";
|
|||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
function getExecStdout(result: unknown): string {
|
||||||
|
if (typeof result === "string") return result;
|
||||||
|
if (result && typeof result === "object" && "stdout" in result) {
|
||||||
|
const stdout = (result as { stdout?: unknown }).stdout;
|
||||||
|
return typeof stdout === "string" ? stdout : String(stdout ?? "");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
export async function isGitRepository(dir: string): Promise<boolean> {
|
export async function isGitRepository(dir: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await execAsync("git rev-parse --git-dir", {
|
await execAsync("git rev-parse --git-dir", {
|
||||||
@@ -23,10 +32,11 @@ export async function isGitRepository(dir: string): Promise<boolean> {
|
|||||||
|
|
||||||
export async function getRegisteredWorktreePaths(rootDir: string): Promise<Set<string>> {
|
export async function getRegisteredWorktreePaths(rootDir: string): Promise<Set<string>> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
const result = await execAsync("git worktree list --porcelain", {
|
||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
});
|
});
|
||||||
|
const stdout = getExecStdout(result);
|
||||||
|
|
||||||
const paths = new Set<string>();
|
const paths = new Set<string>();
|
||||||
for (const line of stdout.split("\n")) {
|
for (const line of stdout.split("\n")) {
|
||||||
@@ -191,58 +201,6 @@ export class WorktreePool {
|
|||||||
// Remove untracked files (but not .gitignore'd build caches)
|
// Remove untracked files (but not .gitignore'd build caches)
|
||||||
await execAsync("git clean -fd", { cwd: worktreePath });
|
await execAsync("git clean -fd", { cwd: worktreePath });
|
||||||
|
|
||||||
// If the target branch already exists in the repo, check it out as-is so
|
|
||||||
// we preserve prior commits (resume path). Do NOT force-reset with -B.
|
|
||||||
let branchExists = false;
|
|
||||||
try {
|
|
||||||
await execAsync(`git rev-parse --verify "refs/heads/${branchName}"`, { cwd: worktreePath });
|
|
||||||
branchExists = true;
|
|
||||||
} catch {
|
|
||||||
// Branch does not exist — will be created below
|
|
||||||
}
|
|
||||||
|
|
||||||
if (branchExists) {
|
|
||||||
// Resume path: switch to the existing branch without destroying its history.
|
|
||||||
try {
|
|
||||||
await execAsync(`git checkout "${branchName}"`, { cwd: worktreePath });
|
|
||||||
return branchName;
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const execError = err instanceof Error ? err : new Error(String(err));
|
|
||||||
const stderr = "stderr" in execError && typeof execError.stderr === "string"
|
|
||||||
? execError.stderr.toString()
|
|
||||||
: execError.message;
|
|
||||||
const match = stderr.match(/already used by worktree at '([^']+)'/);
|
|
||||||
if (!match) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
const conflictingPath = match[1];
|
|
||||||
if (!existsSync(conflictingPath)) {
|
|
||||||
await execAsync("git worktree prune", { cwd: worktreePath });
|
|
||||||
await execAsync(`git checkout "${branchName}"`, { cwd: worktreePath });
|
|
||||||
return branchName;
|
|
||||||
}
|
|
||||||
// Another live worktree has this branch — create a suffixed one from the same tip
|
|
||||||
for (let suffix = 2; suffix <= 6; suffix++) {
|
|
||||||
const suffixedName = `${branchName}-${suffix}`;
|
|
||||||
try {
|
|
||||||
await execAsync(`git checkout -B "${suffixedName}" "${branchName}"`, { cwd: worktreePath });
|
|
||||||
return suffixedName;
|
|
||||||
} catch (suffixErr: unknown) {
|
|
||||||
const suffixExecError = suffixErr instanceof Error ? suffixErr : new Error(String(suffixErr));
|
|
||||||
const suffixStderr = "stderr" in suffixExecError && typeof suffixExecError.stderr === "string"
|
|
||||||
? suffixExecError.stderr.toString()
|
|
||||||
: "";
|
|
||||||
if (!suffixStderr.includes("already used by worktree")) {
|
|
||||||
throw suffixErr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
`Cannot check out existing branch for task: "${branchName}" and suffixes -2 through -6 are all in use by other worktrees`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const base = startPoint || "main";
|
const base = startPoint || "main";
|
||||||
await execAsync(`git checkout --detach ${base}`, {
|
await execAsync(`git checkout --detach ${base}`, {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
@@ -537,10 +495,11 @@ export async function scanOrphanedBranches(rootDir: string, store: TaskStore): P
|
|||||||
// List all local branches matching fusion/*
|
// List all local branches matching fusion/*
|
||||||
let allBranches: string[];
|
let allBranches: string[];
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync("git branch --list 'fusion/*'", {
|
const result = await execAsync("git branch --list 'fusion/*'", {
|
||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
});
|
});
|
||||||
|
const stdout = getExecStdout(result);
|
||||||
allBranches = stdout
|
allBranches = stdout
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((line) => line.trim().replace(/^\*?\s*/, ""))
|
.map((line) => line.trim().replace(/^\*?\s*/, ""))
|
||||||
|
|||||||
Reference in New Issue
Block a user