Skip to content

Commit d8408db

Browse files
authored
refactor: improve git lock handling (#790)
1 parent 55d0e74 commit d8408db

File tree

22 files changed

+1077
-855
lines changed

22 files changed

+1077
-855
lines changed

apps/twig/src/main/services/focus/sync-service.ts

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -227,8 +227,6 @@ export class FocusSyncService {
227227
`Syncing changes: staged=${hasStaged}, unstaged=${hasUnstaged}, untracked=${untrackedList.length} files`,
228228
);
229229

230-
await this.cleanStaleLockFile(dstPath);
231-
232230
if (hasStaged) {
233231
try {
234232
await this.applyPatch(dstPath, stagedPatch, true);
@@ -254,40 +252,6 @@ export class FocusSyncService {
254252
}
255253
}
256254

257-
private async cleanStaleLockFile(repoPath: string): Promise<void> {
258-
const possibleLockPaths = [
259-
path.join(repoPath, ".git", "index.lock"),
260-
path.join(repoPath, ".git", "worktrees"),
261-
];
262-
263-
for (const lockPath of possibleLockPaths) {
264-
if (lockPath.endsWith("worktrees")) {
265-
try {
266-
const entries = await fs.readdir(lockPath);
267-
for (const entry of entries) {
268-
const worktreeLock = path.join(lockPath, entry, "index.lock");
269-
await this.removeStaleLock(worktreeLock);
270-
}
271-
} catch {}
272-
} else {
273-
await this.removeStaleLock(lockPath);
274-
}
275-
}
276-
}
277-
278-
private async removeStaleLock(lockPath: string): Promise<void> {
279-
try {
280-
const stat = await fs.stat(lockPath);
281-
const ageMs = Date.now() - stat.mtimeMs;
282-
if (ageMs > 5000) {
283-
await fs.rm(lockPath);
284-
log.info(
285-
`Removed stale index.lock (age: ${Math.round(ageMs / 1000)}s)`,
286-
);
287-
}
288-
} catch {}
289-
}
290-
291255
private async applyPatch(
292256
repoPath: string,
293257
patch: string,

packages/git/src/git-saga.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Saga } from "@posthog/shared";
2+
import type { GitClient } from "./client.js";
3+
import { getGitOperationManager } from "./operation-manager.js";
4+
5+
export interface GitSagaInput {
6+
baseDir: string;
7+
signal?: AbortSignal;
8+
}
9+
10+
export abstract class GitSaga<
11+
TInput extends GitSagaInput,
12+
TOutput,
13+
> extends Saga<TInput, TOutput> {
14+
private _git: GitClient | null = null;
15+
16+
protected get git(): GitClient {
17+
if (!this._git) {
18+
throw new Error("git client accessed before execute() was called");
19+
}
20+
return this._git;
21+
}
22+
23+
protected async execute(input: TInput): Promise<TOutput> {
24+
const manager = getGitOperationManager();
25+
26+
return manager.executeWrite(
27+
input.baseDir,
28+
async (git) => {
29+
this._git = git;
30+
return this.executeGitOperations(input);
31+
},
32+
{ signal: input.signal },
33+
);
34+
}
35+
36+
protected abstract executeGitOperations(input: TInput): Promise<TOutput>;
37+
}

packages/git/src/lock-detector.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { execFile } from "node:child_process";
2+
import fs from "node:fs/promises";
3+
import path from "node:path";
4+
import { promisify } from "node:util";
5+
6+
const execFileAsync = promisify(execFile);
7+
8+
export interface LockInfo {
9+
path: string;
10+
ageMs: number;
11+
}
12+
13+
export async function getIndexLockPath(repoPath: string): Promise<string> {
14+
try {
15+
const { stdout } = await execFileAsync(
16+
"git",
17+
["rev-parse", "--git-path", "index.lock"],
18+
{ cwd: repoPath },
19+
);
20+
return path.resolve(repoPath, stdout.trim());
21+
} catch {
22+
return path.join(repoPath, ".git", "index.lock");
23+
}
24+
}
25+
26+
export async function getLockInfo(repoPath: string): Promise<LockInfo | null> {
27+
const lockPath = await getIndexLockPath(repoPath);
28+
try {
29+
const stat = await fs.stat(lockPath);
30+
return {
31+
path: lockPath,
32+
ageMs: Date.now() - stat.mtimeMs,
33+
};
34+
} catch {
35+
return null;
36+
}
37+
}
38+
39+
export async function removeLock(repoPath: string): Promise<void> {
40+
const lockPath = await getIndexLockPath(repoPath);
41+
await fs.rm(lockPath, { force: true });
42+
}
43+
44+
export async function isLocked(repoPath: string): Promise<boolean> {
45+
return (await getLockInfo(repoPath)) !== null;
46+
}
47+
48+
export async function waitForUnlock(
49+
repoPath: string,
50+
timeoutMs = 10000,
51+
intervalMs = 100,
52+
): Promise<boolean> {
53+
const start = Date.now();
54+
while (Date.now() - start < timeoutMs) {
55+
if (!(await isLocked(repoPath))) return true;
56+
await new Promise((r) => setTimeout(r, intervalMs));
57+
}
58+
return false;
59+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { createGitClient } from "./client.js";
2+
import { removeLock, waitForUnlock } from "./lock-detector.js";
3+
import { AsyncReaderWriterLock } from "./rw-lock.js";
4+
5+
interface RepoState {
6+
lock: AsyncReaderWriterLock;
7+
lastAccess: number;
8+
}
9+
10+
export interface ExecuteOptions {
11+
signal?: AbortSignal;
12+
timeoutMs?: number;
13+
waitForExternalLock?: boolean;
14+
}
15+
16+
class GitOperationManagerImpl {
17+
private repoStates = new Map<string, RepoState>();
18+
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
19+
private static readonly CLEANUP_INTERVAL_MS = 60000;
20+
private static readonly IDLE_TIMEOUT_MS = 300000;
21+
22+
constructor() {
23+
this.cleanupInterval = setInterval(
24+
() => this.cleanupIdleRepos(),
25+
GitOperationManagerImpl.CLEANUP_INTERVAL_MS,
26+
);
27+
}
28+
29+
private getRepoState(repoPath: string): RepoState {
30+
let state = this.repoStates.get(repoPath);
31+
if (!state) {
32+
state = { lock: new AsyncReaderWriterLock(), lastAccess: Date.now() };
33+
this.repoStates.set(repoPath, state);
34+
}
35+
state.lastAccess = Date.now();
36+
return state;
37+
}
38+
39+
private cleanupIdleRepos(): void {
40+
const now = Date.now();
41+
for (const [repoPath, state] of this.repoStates) {
42+
if (now - state.lastAccess > GitOperationManagerImpl.IDLE_TIMEOUT_MS) {
43+
this.repoStates.delete(repoPath);
44+
}
45+
}
46+
}
47+
48+
async executeRead<T>(
49+
repoPath: string,
50+
operation: (git: ReturnType<typeof createGitClient>) => Promise<T>,
51+
options?: ExecuteOptions,
52+
): Promise<T> {
53+
const git = createGitClient(repoPath, {
54+
abortSignal: options?.signal,
55+
}).env({ GIT_OPTIONAL_LOCKS: "0" });
56+
return operation(git);
57+
}
58+
59+
async executeWrite<T>(
60+
repoPath: string,
61+
operation: (git: ReturnType<typeof createGitClient>) => Promise<T>,
62+
options?: ExecuteOptions,
63+
): Promise<T> {
64+
const state = this.getRepoState(repoPath);
65+
66+
if (options?.waitForExternalLock !== false) {
67+
const unlocked = await waitForUnlock(
68+
repoPath,
69+
options?.timeoutMs ?? 10000,
70+
);
71+
if (!unlocked) {
72+
throw new Error(`Git repository is locked: ${repoPath}`);
73+
}
74+
}
75+
76+
await state.lock.acquireWrite();
77+
try {
78+
const git = createGitClient(repoPath, { abortSignal: options?.signal });
79+
return await operation(git);
80+
} catch (error) {
81+
if (options?.signal?.aborted) {
82+
await removeLock(repoPath).catch(() => {});
83+
}
84+
throw error;
85+
} finally {
86+
state.lock.releaseWrite();
87+
}
88+
}
89+
90+
destroy(): void {
91+
if (this.cleanupInterval) {
92+
clearInterval(this.cleanupInterval);
93+
this.cleanupInterval = null;
94+
}
95+
this.repoStates.clear();
96+
}
97+
}
98+
99+
let instance: GitOperationManagerImpl | null = null;
100+
101+
export function getGitOperationManager(): GitOperationManagerImpl {
102+
if (!instance) {
103+
instance = new GitOperationManagerImpl();
104+
}
105+
return instance;
106+
}
107+
108+
export function resetGitOperationManager(): void {
109+
if (instance) {
110+
instance.destroy();
111+
instance = null;
112+
}
113+
}
114+
115+
export type GitOperationManager = GitOperationManagerImpl;

0 commit comments

Comments
 (0)