Skip to content

Commit da5d8be

Browse files
authored
feat: Refactor repository service (#284)
1 parent dbedfce commit da5d8be

File tree

9 files changed

+214
-73
lines changed

9 files changed

+214
-73
lines changed

apps/array/src/main/preload.ts

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -51,22 +51,6 @@ interface AgentStartParams {
5151
}
5252

5353
contextBridge.exposeInMainWorld("electronAPI", {
54-
// Repo API
55-
validateRepo: (directoryPath: string): Promise<boolean> =>
56-
ipcRenderer.invoke("validate-repo", directoryPath),
57-
cloneRepository: (
58-
repoUrl: string,
59-
targetPath: string,
60-
cloneId: string,
61-
): Promise<{ cloneId: string }> =>
62-
ipcRenderer.invoke("clone-repository", repoUrl, targetPath, cloneId),
63-
onCloneProgress: (
64-
cloneId: string,
65-
listener: (event: {
66-
status: "cloning" | "complete" | "error";
67-
message: string;
68-
}) => void,
69-
): (() => void) => createIpcListener(`clone-progress:${cloneId}`, listener),
7054
// Agent API
7155
agentStart: async (
7256
params: AgentStartParams,

apps/array/src/main/services/git.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -662,17 +662,6 @@ export const detectSSHError = (output: string): string | undefined => {
662662
};
663663

664664
export function registerGitIpc(): void {
665-
ipcMain.handle(
666-
"validate-repo",
667-
async (
668-
_event: IpcMainInvokeEvent,
669-
directoryPath: string,
670-
): Promise<boolean> => {
671-
if (!directoryPath) return false;
672-
return isGitRepository(directoryPath);
673-
},
674-
);
675-
676665
ipcMain.handle(
677666
"get-changed-files-head",
678667
async (

apps/array/src/main/services/git/schemas.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,31 @@ export const detectRepoOutput = z
1515

1616
export type DetectRepoInput = z.infer<typeof detectRepoInput>;
1717
export type DetectRepoResult = z.infer<typeof detectRepoOutput>;
18+
19+
// validateRepo schemas
20+
export const validateRepoInput = z.object({
21+
directoryPath: z.string(),
22+
});
23+
24+
export const validateRepoOutput = z.boolean();
25+
26+
// cloneRepository schemas
27+
export const cloneRepositoryInput = z.object({
28+
repoUrl: z.string(),
29+
targetPath: z.string(),
30+
cloneId: z.string(),
31+
});
32+
33+
export const cloneRepositoryOutput = z.object({
34+
cloneId: z.string(),
35+
});
36+
37+
export const cloneProgressStatus = z.enum(["cloning", "complete", "error"]);
38+
39+
export const cloneProgressPayload = z.object({
40+
cloneId: z.string(),
41+
status: cloneProgressStatus,
42+
message: z.string(),
43+
});
44+
45+
export type CloneProgressPayload = z.infer<typeof cloneProgressPayload>;

apps/array/src/main/services/git/service.ts

Lines changed: 91 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
1-
import { execFile } from "node:child_process";
1+
import { exec, execFile, spawn } from "node:child_process";
22
import { promisify } from "node:util";
33
import { injectable } from "inversify";
4-
import type { DetectRepoResult } from "./schemas.js";
4+
import { TypedEventEmitter } from "../../lib/typed-event-emitter.js";
5+
import type { CloneProgressPayload, DetectRepoResult } from "./schemas.js";
56
import { parseGitHubUrl } from "./utils.js";
67

8+
const execAsync = promisify(exec);
79
const execFileAsync = promisify(execFile);
810

11+
export const GitServiceEvent = {
12+
CloneProgress: "cloneProgress",
13+
} as const;
14+
15+
export interface GitServiceEvents {
16+
[GitServiceEvent.CloneProgress]: CloneProgressPayload;
17+
}
18+
919
@injectable()
10-
export class GitService {
20+
export class GitService extends TypedEventEmitter<GitServiceEvents> {
1121
public async detectRepo(
1222
directoryPath: string,
1323
): Promise<DetectRepoResult | null> {
@@ -30,11 +40,80 @@ export class GitService {
3040
};
3141
}
3242

33-
public async getRemoteUrl(directoryPath: string): Promise<string | null> {
43+
public async validateRepo(directoryPath: string): Promise<boolean> {
44+
if (!directoryPath) return false;
45+
3446
try {
35-
const { stdout } = await execFileAsync("git remote get-url origin", {
47+
await execAsync("git rev-parse --is-inside-work-tree", {
3648
cwd: directoryPath,
3749
});
50+
return true;
51+
} catch {
52+
return false;
53+
}
54+
}
55+
56+
public async cloneRepository(
57+
repoUrl: string,
58+
targetPath: string,
59+
cloneId: string,
60+
): Promise<{ cloneId: string }> {
61+
const emitProgress = (
62+
status: CloneProgressPayload["status"],
63+
message: string,
64+
) => {
65+
this.emit(GitServiceEvent.CloneProgress, { cloneId, status, message });
66+
};
67+
68+
emitProgress("cloning", `Starting clone of ${repoUrl}...`);
69+
70+
const gitProcess = spawn(
71+
"git",
72+
["clone", "--progress", repoUrl, targetPath],
73+
{
74+
stdio: ["ignore", "pipe", "pipe"],
75+
},
76+
);
77+
78+
gitProcess.stderr.on("data", (data: Buffer) => {
79+
const output = data.toString();
80+
emitProgress("cloning", output.trim());
81+
});
82+
83+
gitProcess.stdout.on("data", (data: Buffer) => {
84+
const output = data.toString();
85+
emitProgress("cloning", output.trim());
86+
});
87+
88+
return new Promise((resolve, reject) => {
89+
gitProcess.on("close", (code) => {
90+
if (code === 0) {
91+
emitProgress("complete", "Clone completed successfully");
92+
resolve({ cloneId });
93+
} else {
94+
const errorMsg = `Clone failed with exit code ${code}`;
95+
emitProgress("error", errorMsg);
96+
reject(new Error(errorMsg));
97+
}
98+
});
99+
100+
gitProcess.on("error", (err) => {
101+
const errorMsg = `Clone failed: ${err.message}`;
102+
emitProgress("error", errorMsg);
103+
reject(err);
104+
});
105+
});
106+
}
107+
108+
public async getRemoteUrl(directoryPath: string): Promise<string | null> {
109+
try {
110+
const { stdout } = await execFileAsync(
111+
"git",
112+
["remote", "get-url", "origin"],
113+
{
114+
cwd: directoryPath,
115+
},
116+
);
38117
return stdout.trim();
39118
} catch {
40119
return null;
@@ -43,9 +122,13 @@ export class GitService {
43122

44123
public async getCurrentBranch(directoryPath: string): Promise<string | null> {
45124
try {
46-
const { stdout } = await execFileAsync("git branch --show-current", {
47-
cwd: directoryPath,
48-
});
125+
const { stdout } = await execFileAsync(
126+
"git",
127+
["branch", "--show-current"],
128+
{
129+
cwd: directoryPath,
130+
},
131+
);
49132
return stdout.trim();
50133
} catch {
51134
return null;

apps/array/src/main/trpc/routers/git.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1+
import { on } from "node:events";
12
import { container } from "../../di/container.js";
23
import { MAIN_TOKENS } from "../../di/tokens.js";
34
import {
5+
type CloneProgressPayload,
6+
cloneRepositoryInput,
7+
cloneRepositoryOutput,
48
detectRepoInput,
59
detectRepoOutput,
10+
validateRepoInput,
11+
validateRepoOutput,
612
} from "../../services/git/schemas.js";
7-
import type { GitService } from "../../services/git/service.js";
13+
import {
14+
type GitService,
15+
GitServiceEvent,
16+
} from "../../services/git/service.js";
817
import { publicProcedure, router } from "../trpc.js";
918

1019
const getService = () => container.get<GitService>(MAIN_TOKENS.GitService);
@@ -14,4 +23,32 @@ export const gitRouter = router({
1423
.input(detectRepoInput)
1524
.output(detectRepoOutput)
1625
.query(({ input }) => getService().detectRepo(input.directoryPath)),
26+
27+
validateRepo: publicProcedure
28+
.input(validateRepoInput)
29+
.output(validateRepoOutput)
30+
.query(({ input }) => getService().validateRepo(input.directoryPath)),
31+
32+
cloneRepository: publicProcedure
33+
.input(cloneRepositoryInput)
34+
.output(cloneRepositoryOutput)
35+
.mutation(({ input }) =>
36+
getService().cloneRepository(
37+
input.repoUrl,
38+
input.targetPath,
39+
input.cloneId,
40+
),
41+
),
42+
43+
onCloneProgress: publicProcedure.subscription(async function* (opts) {
44+
const service = getService();
45+
const options = opts.signal ? { signal: opts.signal } : undefined;
46+
for await (const [payload] of on(
47+
service,
48+
GitServiceEvent.CloneProgress,
49+
options,
50+
)) {
51+
yield payload as CloneProgressPayload;
52+
}
53+
}),
1754
});

apps/array/src/renderer/features/task-detail/stores/taskExecutionStore.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useSettingsStore } from "@features/settings/stores/settingsStore";
2+
import { trpcVanilla } from "@renderer/trpc/client";
23
import type { Task, WorkspaceMode } from "@shared/types";
34
import { repositoryWorkspaceStore } from "@stores/repositoryWorkspaceStore";
45
import { useTaskDirectoryStore } from "@stores/taskDirectoryStore";
@@ -114,8 +115,8 @@ export const useTaskExecutionStore = create<TaskExecutionStore>()(
114115
repositoryWorkspaceStore.getState().selectRepository(repository);
115116
}
116117

117-
window.electronAPI
118-
?.validateRepo(storedDirectory)
118+
trpcVanilla.git.validateRepo
119+
.query({ directoryPath: storedDirectory })
119120
.then((exists) => {
120121
store.updateTaskState(taskId, { repoExists: exists });
121122
})
@@ -132,9 +133,9 @@ export const useTaskExecutionStore = create<TaskExecutionStore>()(
132133
if (!taskState.repoPath) return;
133134

134135
try {
135-
const exists = await window.electronAPI?.validateRepo(
136-
taskState.repoPath,
137-
);
136+
const exists = await trpcVanilla.git.validateRepo.query({
137+
directoryPath: taskState.repoPath,
138+
});
138139
store.updateTaskState(taskId, { repoExists: exists });
139140
} catch {
140141
store.updateTaskState(taskId, { repoExists: false });

apps/array/src/renderer/stores/cloneStore.ts

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useTaskExecutionStore } from "@features/task-detail/stores/taskExecutionStore";
2+
import { trpcVanilla } from "@renderer/trpc/client";
23
import { useTaskDirectoryStore } from "@stores/taskDirectoryStore";
34
import { create } from "zustand";
45

@@ -26,6 +27,33 @@ interface CloneStore {
2627
const REMOVE_DELAY_SUCCESS_MS = 3000;
2728
const REMOVE_DELAY_ERROR_MS = 5000;
2829

30+
// Global subscription to clone progress events
31+
let globalSubscription: { unsubscribe: () => void } | null = null;
32+
let subscriptionRefCount = 0;
33+
34+
const ensureGlobalSubscription = (store: CloneStore) => {
35+
if (globalSubscription) {
36+
subscriptionRefCount++;
37+
return;
38+
}
39+
40+
subscriptionRefCount = 1;
41+
globalSubscription = trpcVanilla.git.onCloneProgress.subscribe(undefined, {
42+
onData: (event) => {
43+
store.updateClone(event.cloneId, event.status, event.message);
44+
},
45+
});
46+
};
47+
48+
const releaseGlobalSubscription = () => {
49+
subscriptionRefCount--;
50+
if (subscriptionRefCount <= 0 && globalSubscription) {
51+
globalSubscription.unsubscribe();
52+
globalSubscription = null;
53+
subscriptionRefCount = 0;
54+
}
55+
};
56+
2957
export const cloneStore = create<CloneStore>((set, get) => {
3058
const updateTaskRepoExists = (targetPath: string, exists: boolean) => {
3159
const taskStore = useTaskExecutionStore.getState();
@@ -66,26 +94,14 @@ export const cloneStore = create<CloneStore>((set, get) => {
6694
window.setTimeout(() => get().removeClone(cloneId), REMOVE_DELAY_ERROR_MS);
6795
};
6896

69-
return {
97+
const store: CloneStore = {
7098
operations: {},
7199

72100
startClone: (cloneId, repository, targetPath) => {
73-
const unsubscribe = window.electronAPI.onCloneProgress(
74-
cloneId,
75-
(event) => {
76-
get().updateClone(cloneId, event.status, event.message);
77-
78-
const operation = get().operations[cloneId];
79-
if (!operation) return;
80-
81-
if (event.status === "complete") {
82-
handleComplete(cloneId, repository);
83-
} else if (event.status === "error") {
84-
handleError(cloneId, repository, event.message);
85-
}
86-
},
87-
);
101+
// Ensure global subscription is active
102+
ensureGlobalSubscription(store);
88103

104+
// Set up clone operation with progress handler
89105
set((state) => ({
90106
operations: {
91107
...state.operations,
@@ -95,10 +111,22 @@ export const cloneStore = create<CloneStore>((set, get) => {
95111
targetPath,
96112
status: "cloning",
97113
latestMessage: `Cloning ${repository}...`,
98-
unsubscribe,
114+
unsubscribe: releaseGlobalSubscription,
99115
},
100116
},
101117
}));
118+
119+
// Start the clone operation via tRPC mutation
120+
trpcVanilla.git.cloneRepository
121+
.mutate({ repoUrl: repository, targetPath, cloneId })
122+
.then(() => {
123+
handleComplete(cloneId, repository);
124+
})
125+
.catch((err) => {
126+
const message = err instanceof Error ? err.message : "Clone failed";
127+
get().updateClone(cloneId, "error", message);
128+
handleError(cloneId, repository, message);
129+
});
102130
},
103131

104132
updateClone: (cloneId, status, message) => {
@@ -140,4 +168,6 @@ export const cloneStore = create<CloneStore>((set, get) => {
140168
(op) => op.repository === repository,
141169
) ?? null,
142170
};
171+
172+
return store;
143173
});

0 commit comments

Comments
 (0)