Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion src/node/git.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
});
});
36 changes: 36 additions & 0 deletions src/node/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -79,6 +106,9 @@ export async function createWorktree(
branchName: string,
options: CreateWorktreeOptions
): Promise<WorktreeResult> {
// 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;
Expand Down Expand Up @@ -192,6 +222,9 @@ export async function removeWorktree(
workspacePath: string,
options: { force: boolean } = { force: false }
): Promise<WorktreeResult> {
// Clean up stale lock before git operations on main repo
cleanStaleLock(projectPath);

try {
// Remove the worktree (from the main repository context)
using proc = execAsync(
Expand All @@ -206,6 +239,9 @@ export async function removeWorktree(
}

export async function pruneWorktrees(projectPath: string): Promise<WorktreeResult> {
// Clean up stale lock before git operations on main repo
cleanStaleLock(projectPath);

try {
using proc = execAsync(`git -C "${projectPath}" worktree prune`);
await proc.result;
Expand Down
10 changes: 9 additions & 1 deletion src/node/runtime/WorktreeRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -44,6 +44,9 @@ export class WorktreeRuntime extends LocalBaseRuntime {
async createWorkspace(params: WorkspaceCreationParams): Promise<WorkspaceCreationResult> {
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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down