diff --git a/src/node/git.test.ts b/src/node/git.test.ts index 92be2576d2..3bf359fbb3 100644 --- a/src/node/git.test.ts +++ b/src/node/git.test.ts @@ -1,9 +1,10 @@ import { describe, test, expect, beforeAll, afterAll } from "@jest/globals"; -import { createWorktree, listLocalBranches, detectDefaultTrunkBranch } from "./git"; +import { createWorktree, listLocalBranches, detectDefaultTrunkBranch, cleanStaleLock } from "./git"; import { Config } from "./config"; import * as path from "path"; import * as os from "os"; import * as fs from "fs/promises"; +import * as fsSync from "fs"; import { exec } from "child_process"; import { promisify } from "util"; @@ -103,3 +104,52 @@ describe("createWorktree", () => { } }); }); + +describe("cleanStaleLock", () => { + let tempDir: string; + + beforeAll(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-lock-test-")); + await fs.mkdir(path.join(tempDir, ".git")); + }); + + afterAll(async () => { + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + test("removes lock file older than threshold", async () => { + const lockPath = path.join(tempDir, ".git", "index.lock"); + // Create a lock file with old mtime + await fs.writeFile(lockPath, "lock"); + const oldTime = Date.now() - 10000; // 10 seconds ago + fsSync.utimesSync(lockPath, oldTime / 1000, oldTime / 1000); + + cleanStaleLock(tempDir); + + // Lock should be removed + expect(fsSync.existsSync(lockPath)).toBe(false); + }); + + test("does not remove recent lock file", async () => { + const lockPath = path.join(tempDir, ".git", "index.lock"); + // Create a fresh lock file (now) + await fs.writeFile(lockPath, "lock"); + + cleanStaleLock(tempDir); + + // Lock should still exist (it's too recent) + expect(fsSync.existsSync(lockPath)).toBe(true); + + // Cleanup + await fs.unlink(lockPath); + }); + + test("does nothing when no lock exists", () => { + // Should not throw + cleanStaleLock(tempDir); + }); +}); diff --git a/src/node/git.ts b/src/node/git.ts index 6d15b6563e..73a34f47cd 100644 --- a/src/node/git.ts +++ b/src/node/git.ts @@ -4,6 +4,33 @@ import type { Config } from "@/node/config"; import type { RuntimeConfig } from "@/common/types/runtime"; import { execAsync } from "@/node/utils/disposableExec"; import { createRuntime } from "./runtime/runtimeFactory"; +import { log } from "./services/log"; + +/** + * Remove stale .git/index.lock file if it exists and is old. + * + * Git creates index.lock during operations that modify the index. If a process + * is killed mid-operation (user cancel, crash, terminal closed), the lock file + * gets orphaned. This is common in Mux when git operations are interrupted. + * + * We only remove locks older than STALE_LOCK_AGE_MS to avoid removing locks + * from legitimately running processes. + */ +const STALE_LOCK_AGE_MS = 5000; // 5 seconds + +export function cleanStaleLock(repoPath: string): void { + const lockPath = path.join(repoPath, ".git", "index.lock"); + try { + const stat = fs.statSync(lockPath); + const ageMs = Date.now() - stat.mtimeMs; + if (ageMs > STALE_LOCK_AGE_MS) { + fs.unlinkSync(lockPath); + log.info(`Removed stale git index.lock (age: ${Math.round(ageMs / 1000)}s) at ${lockPath}`); + } + } catch { + // Lock doesn't exist or can't be accessed - this is fine + } +} export interface WorktreeResult { success: boolean; @@ -79,6 +106,9 @@ export async function createWorktree( branchName: string, options: CreateWorktreeOptions ): Promise { + // Clean up stale lock before git operations on main repo + cleanStaleLock(projectPath); + try { // Use directoryName if provided, otherwise fall back to branchName (legacy) const dirName = options.directoryName ?? branchName; @@ -192,6 +222,9 @@ export async function removeWorktree( workspacePath: string, options: { force: boolean } = { force: false } ): Promise { + // Clean up stale lock before git operations on main repo + cleanStaleLock(projectPath); + try { // Remove the worktree (from the main repository context) using proc = execAsync( @@ -206,6 +239,9 @@ export async function removeWorktree( } export async function pruneWorktrees(projectPath: string): Promise { + // Clean up stale lock before git operations on main repo + cleanStaleLock(projectPath); + try { using proc = execAsync(`git -C "${projectPath}" worktree prune`); await proc.result; diff --git a/src/node/runtime/WorktreeRuntime.ts b/src/node/runtime/WorktreeRuntime.ts index 2bebef126c..b8559aa267 100644 --- a/src/node/runtime/WorktreeRuntime.ts +++ b/src/node/runtime/WorktreeRuntime.ts @@ -9,7 +9,7 @@ import type { WorkspaceForkResult, InitLogger, } from "./Runtime"; -import { listLocalBranches } from "@/node/git"; +import { listLocalBranches, cleanStaleLock } from "@/node/git"; import { checkInitHookExists, getMuxEnv } from "./initHook"; import { execAsync } from "@/node/utils/disposableExec"; import { getBashPath } from "@/node/utils/main/bashPath"; @@ -44,6 +44,9 @@ export class WorktreeRuntime extends LocalBaseRuntime { async createWorkspace(params: WorkspaceCreationParams): Promise { const { projectPath, branchName, trunkBranch, initLogger } = params; + // Clean up stale lock before git operations on main repo + cleanStaleLock(projectPath); + try { // Compute workspace path using the canonical method const workspacePath = this.getWorkspacePath(projectPath, branchName); @@ -175,6 +178,9 @@ export class WorktreeRuntime extends LocalBaseRuntime { { success: true; oldPath: string; newPath: string } | { success: false; error: string } > { // Note: _abortSignal ignored for local operations (fast, no need for cancellation) + // Clean up stale lock before git operations on main repo + cleanStaleLock(projectPath); + // Compute workspace paths using canonical method const oldPath = this.getWorkspacePath(projectPath, oldName); const newPath = this.getWorkspacePath(projectPath, newName); @@ -209,6 +215,8 @@ export class WorktreeRuntime extends LocalBaseRuntime { _abortSignal?: AbortSignal ): Promise<{ success: true; deletedPath: string } | { success: false; error: string }> { // Note: _abortSignal ignored for local operations (fast, no need for cancellation) + // Clean up stale lock before git operations on main repo + cleanStaleLock(projectPath); // In-place workspaces are identified by projectPath === workspaceName // These are direct workspace directories (e.g., CLI/benchmark sessions), not git worktrees