Skip to content

Commit 00ff51c

Browse files
authored
refactor: move git functionality to package, improve git rollbacks (#712)
1 parent 43a0901 commit 00ff51c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+3721
-2400
lines changed

.github/workflows/build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ jobs:
3636
- name: Build shared
3737
run: pnpm --filter @posthog/shared build
3838

39+
- name: Build git
40+
run: pnpm --filter @twig/git build
41+
3942
- name: Build agent
4043
run: pnpm --filter agent build
4144

.github/workflows/test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ jobs:
7979
run: |
8080
pnpm --filter @posthog/electron-trpc build &
8181
pnpm --filter @posthog/shared build
82+
pnpm --filter @twig/git build
8283
pnpm --filter agent build &
8384
wait
8485

apps/twig/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@
109109
"@posthog/agent": "workspace:*",
110110
"@posthog/electron-trpc": "workspace:*",
111111
"@posthog/shared": "workspace:*",
112+
"@twig/git": "workspace:*",
112113
"@radix-ui/react-collapsible": "^1.1.12",
113114
"@radix-ui/react-icons": "^1.3.2",
114115
"@radix-ui/themes": "^3.2.1",

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

Lines changed: 85 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
1-
import { execFile } from "node:child_process";
21
import * as fs from "node:fs/promises";
32
import * as path from "node:path";
4-
import { promisify } from "node:util";
53
import * as watcher from "@parcel/watcher";
4+
import {
5+
getHeadSha,
6+
branchExists as gitBranchExists,
7+
getCurrentBranch as gitGetCurrentBranch,
8+
hasChanges,
9+
} from "@twig/git/queries";
10+
import { SwitchBranchSaga } from "@twig/git/sagas/branch";
11+
import { CleanWorkingTreeSaga } from "@twig/git/sagas/clean";
12+
import { DetachHeadSaga, ReattachBranchSaga } from "@twig/git/sagas/head";
13+
import {
14+
StashApplySaga,
15+
StashPopSaga,
16+
StashPushSaga,
17+
} from "@twig/git/sagas/stash";
618
import { injectable, preDestroy } from "inversify";
719
import { logger } from "../../lib/logger";
820
import { TypedEventEmitter } from "../../lib/typed-event-emitter";
921
import { type FocusSession, focusStore } from "../../utils/store.js";
1022
import { getWorktreeLocation } from "../settingsStore";
1123
import type { FocusResult, StashResult } from "./schemas.js";
1224

13-
const execFileAsync = promisify(execFile);
14-
1525
const log = logger.scope("focus");
1626

1727
export const FocusServiceEvent = {
@@ -34,47 +44,6 @@ export interface FocusServiceEvents {
3444
};
3545
}
3646

37-
function getErrorMessage(error: unknown): string {
38-
return error instanceof Error ? error.message : String(error);
39-
}
40-
41-
let gitMutex: Promise<void> = Promise.resolve();
42-
43-
export async function withGitLock<T>(fn: () => Promise<T>): Promise<T> {
44-
const prev = gitMutex;
45-
let resolve: () => void = () => {};
46-
gitMutex = new Promise((r) => {
47-
resolve = r;
48-
});
49-
50-
try {
51-
await prev;
52-
return await fn();
53-
} finally {
54-
resolve();
55-
}
56-
}
57-
58-
export async function git(cwd: string, ...args: string[]): Promise<string> {
59-
return withGitLock(async () => {
60-
const { stdout } = await execFileAsync("git", args, { cwd });
61-
return stdout.trim();
62-
});
63-
}
64-
65-
async function gitOp<T extends FocusResult>(
66-
operation: string,
67-
fn: () => Promise<T>,
68-
): Promise<T> {
69-
try {
70-
return await fn();
71-
} catch (error) {
72-
const message = getErrorMessage(error);
73-
log.error(`${operation}:`, message);
74-
return { success: false, error: `${operation}: ${message}` } as T;
75-
}
76-
}
77-
7847
@injectable()
7948
export class FocusService extends TypedEventEmitter<FocusServiceEvents> {
8049
private mainRepoWatcher: watcher.AsyncSubscription | null = null;
@@ -162,16 +131,11 @@ export class FocusService extends TypedEventEmitter<FocusServiceEvents> {
162131
repoPath: string,
163132
branch: string,
164133
): Promise<boolean> {
165-
try {
166-
await git(repoPath, "rev-parse", "--verify", `refs/heads/${branch}`);
167-
return true;
168-
} catch {
169-
return false;
170-
}
134+
return gitBranchExists(repoPath, branch);
171135
}
172136

173137
async getCommitSha(repoPath: string): Promise<string> {
174-
return git(repoPath, "rev-parse", "HEAD");
138+
return getHeadSha(repoPath);
175139
}
176140

177141
/**
@@ -243,58 +207,46 @@ export class FocusService extends TypedEventEmitter<FocusServiceEvents> {
243207
}
244208

245209
async cleanWorkingTree(repoPath: string): Promise<void> {
246-
await this.cleanStaleLockFile(repoPath);
247-
await git(repoPath, "reset");
248-
await git(repoPath, "restore", ".");
249-
await git(repoPath, "clean", "-fd");
250-
await this.forceRemoveLockFile(repoPath);
251-
}
252-
253-
private async cleanStaleLockFile(repoPath: string): Promise<void> {
254-
const lockPath = path.join(repoPath, ".git", "index.lock");
255-
try {
256-
const stat = await fs.stat(lockPath);
257-
const ageMs = Date.now() - stat.mtimeMs;
258-
if (ageMs > 2000) {
259-
await fs.rm(lockPath);
260-
log.info(
261-
`Removed stale index.lock (age: ${Math.round(ageMs / 1000)}s)`,
262-
);
263-
}
264-
} catch {}
265-
}
266-
267-
private async forceRemoveLockFile(repoPath: string): Promise<void> {
268-
const lockPath = path.join(repoPath, ".git", "index.lock");
269-
try {
270-
await fs.rm(lockPath);
271-
log.info("Removed index.lock after cleaning working tree");
272-
} catch {}
210+
const saga = new CleanWorkingTreeSaga();
211+
const result = await saga.run({ baseDir: repoPath });
212+
if (!result.success) {
213+
throw new Error(`Failed to clean working tree: ${result.error}`);
214+
}
273215
}
274216

275217
async detachWorktree(worktreePath: string): Promise<FocusResult> {
276-
return gitOp("Failed to detach worktree", async () => {
277-
await git(worktreePath, "checkout", "--detach");
278-
log.info(`Detached worktree at ${worktreePath}`);
279-
return { success: true };
280-
});
218+
const saga = new DetachHeadSaga();
219+
const result = await saga.run({ baseDir: worktreePath });
220+
if (!result.success) {
221+
log.error("Failed to detach worktree:", result.error);
222+
return {
223+
success: false,
224+
error: `Failed to detach worktree: ${result.error}`,
225+
};
226+
}
227+
log.info(`Detached worktree at ${worktreePath}`);
228+
return { success: true };
281229
}
282230

283231
async reattachWorktree(
284232
worktreePath: string,
285233
branchName: string,
286234
): Promise<FocusResult> {
287-
return gitOp("Failed to reattach worktree", async () => {
288-
await git(worktreePath, "checkout", "-B", branchName);
289-
log.info(
290-
`Reattached worktree at ${worktreePath} to branch ${branchName}`,
291-
);
292-
return { success: true };
293-
});
235+
const saga = new ReattachBranchSaga();
236+
const result = await saga.run({ baseDir: worktreePath, branchName });
237+
if (!result.success) {
238+
log.error("Failed to reattach worktree:", result.error);
239+
return {
240+
success: false,
241+
error: `Failed to reattach worktree: ${result.error}`,
242+
};
243+
}
244+
log.info(`Reattached worktree at ${worktreePath} to branch ${branchName}`);
245+
return { success: true };
294246
}
295247

296248
async getCurrentBranch(repoPath: string): Promise<string | null> {
297-
const branch = await git(repoPath, "branch", "--show-current");
249+
const branch = await gitGetCurrentBranch(repoPath);
298250
if (!branch) {
299251
log.warn("getCurrentBranch returned empty (detached HEAD?)");
300252
return null;
@@ -303,80 +255,59 @@ export class FocusService extends TypedEventEmitter<FocusServiceEvents> {
303255
}
304256

305257
async isDirty(repoPath: string): Promise<boolean> {
306-
const stdout = await git(repoPath, "status", "--porcelain");
307-
return stdout.length > 0;
258+
return hasChanges(repoPath);
308259
}
309260

310261
async stash(repoPath: string, message: string): Promise<StashResult> {
311-
return gitOp("Failed to stash", async () => {
312-
await this.cleanStaleLockFile(repoPath);
313-
const beforeList = await git(repoPath, "stash", "list");
314-
const beforeCount = beforeList.split("\n").filter(Boolean).length;
315-
316-
await git(repoPath, "add", "-A");
317-
await git(
318-
repoPath,
319-
"stash",
320-
"push",
321-
"--include-untracked",
322-
"-m",
323-
message,
324-
);
325-
326-
const afterList = await git(repoPath, "stash", "list");
327-
const afterCount = afterList.split("\n").filter(Boolean).length;
328-
329-
if (afterCount > beforeCount) {
330-
// Get the SHA of the stash commit (survives other stashes being added)
331-
const stashSha = await git(repoPath, "rev-parse", "stash@{0}");
332-
return { success: true, stashRef: stashSha };
333-
}
334-
return { success: true };
335-
});
262+
const saga = new StashPushSaga();
263+
const result = await saga.run({ baseDir: repoPath, message });
264+
if (!result.success) {
265+
log.error("Failed to stash:", result.error);
266+
return { success: false, error: `Failed to stash: ${result.error}` };
267+
}
268+
if (result.data.stashSha) {
269+
return { success: true, stashRef: result.data.stashSha };
270+
}
271+
return { success: true };
336272
}
337273

338274
async stashApply(repoPath: string, stashRef: string): Promise<FocusResult> {
339-
return gitOp("Failed to apply stash", async () => {
340-
await this.cleanStaleLockFile(repoPath);
341-
await git(repoPath, "stash", "apply", stashRef);
342-
343-
// Find the stash reference that matches this SHA
344-
// Format: "<sha> stash@{N}"
345-
const reflog = await git(
346-
repoPath,
347-
"reflog",
348-
"show",
349-
"--format=%H %gd",
350-
"refs/stash",
351-
);
352-
const match = reflog
353-
.split("\n")
354-
.find((line) => line.startsWith(stashRef));
355-
356-
if (match) {
357-
const stashIndex = match.split(" ")[1]; // e.g., "stash@{0}"
358-
await git(repoPath, "stash", "drop", stashIndex);
359-
} else {
360-
log.warn(`Stash SHA ${stashRef} not found in reflog, skipping drop`);
361-
}
362-
363-
return { success: true };
364-
});
275+
const saga = new StashApplySaga();
276+
const result = await saga.run({ baseDir: repoPath, stashSha: stashRef });
277+
if (!result.success) {
278+
log.error("Failed to apply stash:", result.error);
279+
return {
280+
success: false,
281+
error: `Failed to apply stash: ${result.error}`,
282+
};
283+
}
284+
if (!result.data.dropped) {
285+
log.warn(`Stash SHA ${stashRef} not found in reflog, skipping drop`);
286+
}
287+
return { success: true };
365288
}
366289

367290
async stashPop(repoPath: string): Promise<FocusResult> {
368-
return gitOp("Failed to pop stash", async () => {
369-
await git(repoPath, "stash", "pop");
370-
return { success: true };
371-
});
291+
const saga = new StashPopSaga();
292+
const result = await saga.run({ baseDir: repoPath });
293+
if (!result.success) {
294+
log.error("Failed to pop stash:", result.error);
295+
return { success: false, error: `Failed to pop stash: ${result.error}` };
296+
}
297+
return { success: true };
372298
}
373299

374300
async checkout(repoPath: string, branch: string): Promise<FocusResult> {
375-
return gitOp(`Failed to checkout ${branch}`, async () => {
376-
await this.cleanStaleLockFile(repoPath);
377-
await git(repoPath, "checkout", branch);
378-
return { success: true };
379-
});
301+
const saga = new SwitchBranchSaga();
302+
const result = await saga.run({ baseDir: repoPath, branchName: branch });
303+
if (!result.success) {
304+
log.error(`Failed to checkout ${branch}:`, result.error);
305+
return {
306+
success: false,
307+
error: `Failed to checkout ${branch}: ${result.error}`,
308+
};
309+
}
310+
return { success: true };
380311
}
381312

382313
getSession(mainRepoPath: string): FocusSession | null {

0 commit comments

Comments
 (0)