Skip to content

Commit 13f1e66

Browse files
authored
🤖 fix: clean stale git index.lock before worktree operations (#1003)
## Problem When using Mux, git operations (worktree create/delete/rename) can get interrupted (user cancel, crash, terminal closed), leaving orphaned `.git/index.lock` files in the main repository. This causes subsequent git operations to fail with: ``` fatal: Unable to create '/Users/.../mux/.git/index.lock': File exists. Another git process seems to be running in this repository... ``` ## Solution Added `cleanStaleLock()` helper that removes lock files older than 5 seconds before git operations that touch the main repository's index: - `createWorktree` - `removeWorktree` - `pruneWorktrees` - `WorktreeRuntime.createWorkspace` - `WorktreeRuntime.renameWorkspace` - `WorktreeRuntime.deleteWorkspace` The 5-second threshold ensures we only remove orphaned locks, not locks from legitimately running processes. ## Testing Added unit tests for the `cleanStaleLock` function: - Removes lock files older than threshold - Does not remove recent lock files - Handles missing lock files gracefully --- _Generated with `mux`_
1 parent 5cc443d commit 13f1e66

File tree

3 files changed

+96
-2
lines changed

3 files changed

+96
-2
lines changed

src/node/git.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { describe, test, expect, beforeAll, afterAll } from "@jest/globals";
2-
import { createWorktree, listLocalBranches, detectDefaultTrunkBranch } from "./git";
2+
import { createWorktree, listLocalBranches, detectDefaultTrunkBranch, cleanStaleLock } from "./git";
33
import { Config } from "./config";
44
import * as path from "path";
55
import * as os from "os";
66
import * as fs from "fs/promises";
7+
import * as fsSync from "fs";
78
import { exec } from "child_process";
89
import { promisify } from "util";
910

@@ -103,3 +104,52 @@ describe("createWorktree", () => {
103104
}
104105
});
105106
});
107+
108+
describe("cleanStaleLock", () => {
109+
let tempDir: string;
110+
111+
beforeAll(async () => {
112+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-lock-test-"));
113+
await fs.mkdir(path.join(tempDir, ".git"));
114+
});
115+
116+
afterAll(async () => {
117+
try {
118+
await fs.rm(tempDir, { recursive: true, force: true });
119+
} catch {
120+
// Ignore cleanup errors
121+
}
122+
});
123+
124+
test("removes lock file older than threshold", async () => {
125+
const lockPath = path.join(tempDir, ".git", "index.lock");
126+
// Create a lock file with old mtime
127+
await fs.writeFile(lockPath, "lock");
128+
const oldTime = Date.now() - 10000; // 10 seconds ago
129+
fsSync.utimesSync(lockPath, oldTime / 1000, oldTime / 1000);
130+
131+
cleanStaleLock(tempDir);
132+
133+
// Lock should be removed
134+
expect(fsSync.existsSync(lockPath)).toBe(false);
135+
});
136+
137+
test("does not remove recent lock file", async () => {
138+
const lockPath = path.join(tempDir, ".git", "index.lock");
139+
// Create a fresh lock file (now)
140+
await fs.writeFile(lockPath, "lock");
141+
142+
cleanStaleLock(tempDir);
143+
144+
// Lock should still exist (it's too recent)
145+
expect(fsSync.existsSync(lockPath)).toBe(true);
146+
147+
// Cleanup
148+
await fs.unlink(lockPath);
149+
});
150+
151+
test("does nothing when no lock exists", () => {
152+
// Should not throw
153+
cleanStaleLock(tempDir);
154+
});
155+
});

src/node/git.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,33 @@ import type { Config } from "@/node/config";
44
import type { RuntimeConfig } from "@/common/types/runtime";
55
import { execAsync } from "@/node/utils/disposableExec";
66
import { createRuntime } from "./runtime/runtimeFactory";
7+
import { log } from "./services/log";
8+
9+
/**
10+
* Remove stale .git/index.lock file if it exists and is old.
11+
*
12+
* Git creates index.lock during operations that modify the index. If a process
13+
* is killed mid-operation (user cancel, crash, terminal closed), the lock file
14+
* gets orphaned. This is common in Mux when git operations are interrupted.
15+
*
16+
* We only remove locks older than STALE_LOCK_AGE_MS to avoid removing locks
17+
* from legitimately running processes.
18+
*/
19+
const STALE_LOCK_AGE_MS = 5000; // 5 seconds
20+
21+
export function cleanStaleLock(repoPath: string): void {
22+
const lockPath = path.join(repoPath, ".git", "index.lock");
23+
try {
24+
const stat = fs.statSync(lockPath);
25+
const ageMs = Date.now() - stat.mtimeMs;
26+
if (ageMs > STALE_LOCK_AGE_MS) {
27+
fs.unlinkSync(lockPath);
28+
log.info(`Removed stale git index.lock (age: ${Math.round(ageMs / 1000)}s) at ${lockPath}`);
29+
}
30+
} catch {
31+
// Lock doesn't exist or can't be accessed - this is fine
32+
}
33+
}
734

835
export interface WorktreeResult {
936
success: boolean;
@@ -79,6 +106,9 @@ export async function createWorktree(
79106
branchName: string,
80107
options: CreateWorktreeOptions
81108
): Promise<WorktreeResult> {
109+
// Clean up stale lock before git operations on main repo
110+
cleanStaleLock(projectPath);
111+
82112
try {
83113
// Use directoryName if provided, otherwise fall back to branchName (legacy)
84114
const dirName = options.directoryName ?? branchName;
@@ -192,6 +222,9 @@ export async function removeWorktree(
192222
workspacePath: string,
193223
options: { force: boolean } = { force: false }
194224
): Promise<WorktreeResult> {
225+
// Clean up stale lock before git operations on main repo
226+
cleanStaleLock(projectPath);
227+
195228
try {
196229
// Remove the worktree (from the main repository context)
197230
using proc = execAsync(
@@ -206,6 +239,9 @@ export async function removeWorktree(
206239
}
207240

208241
export async function pruneWorktrees(projectPath: string): Promise<WorktreeResult> {
242+
// Clean up stale lock before git operations on main repo
243+
cleanStaleLock(projectPath);
244+
209245
try {
210246
using proc = execAsync(`git -C "${projectPath}" worktree prune`);
211247
await proc.result;

src/node/runtime/WorktreeRuntime.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type {
99
WorkspaceForkResult,
1010
InitLogger,
1111
} from "./Runtime";
12-
import { listLocalBranches } from "@/node/git";
12+
import { listLocalBranches, cleanStaleLock } from "@/node/git";
1313
import { checkInitHookExists, getMuxEnv } from "./initHook";
1414
import { execAsync } from "@/node/utils/disposableExec";
1515
import { getBashPath } from "@/node/utils/main/bashPath";
@@ -44,6 +44,9 @@ export class WorktreeRuntime extends LocalBaseRuntime {
4444
async createWorkspace(params: WorkspaceCreationParams): Promise<WorkspaceCreationResult> {
4545
const { projectPath, branchName, trunkBranch, initLogger } = params;
4646

47+
// Clean up stale lock before git operations on main repo
48+
cleanStaleLock(projectPath);
49+
4750
try {
4851
// Compute workspace path using the canonical method
4952
const workspacePath = this.getWorkspacePath(projectPath, branchName);
@@ -175,6 +178,9 @@ export class WorktreeRuntime extends LocalBaseRuntime {
175178
{ success: true; oldPath: string; newPath: string } | { success: false; error: string }
176179
> {
177180
// Note: _abortSignal ignored for local operations (fast, no need for cancellation)
181+
// Clean up stale lock before git operations on main repo
182+
cleanStaleLock(projectPath);
183+
178184
// Compute workspace paths using canonical method
179185
const oldPath = this.getWorkspacePath(projectPath, oldName);
180186
const newPath = this.getWorkspacePath(projectPath, newName);
@@ -209,6 +215,8 @@ export class WorktreeRuntime extends LocalBaseRuntime {
209215
_abortSignal?: AbortSignal
210216
): Promise<{ success: true; deletedPath: string } | { success: false; error: string }> {
211217
// Note: _abortSignal ignored for local operations (fast, no need for cancellation)
218+
// Clean up stale lock before git operations on main repo
219+
cleanStaleLock(projectPath);
212220

213221
// In-place workspaces are identified by projectPath === workspaceName
214222
// These are direct workspace directories (e.g., CLI/benchmark sessions), not git worktrees

0 commit comments

Comments
 (0)