Skip to content

Commit d9a7994

Browse files
authored
feat: improve the git service - move to trpc and remove polling (#332)
1 parent 411089f commit d9a7994

File tree

10 files changed

+422
-39
lines changed

10 files changed

+422
-39
lines changed

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

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,91 @@ export const getLatestCommitOutput = gitCommitInfoSchema.nullable();
159159
// getGitRepoInfo schemas
160160
export const getGitRepoInfoInput = directoryPathInput;
161161
export const getGitRepoInfoOutput = gitRepoInfoSchema.nullable();
162+
163+
// Push operation
164+
export const pushInput = z.object({
165+
directoryPath: z.string(),
166+
remote: z.string().default("origin"),
167+
branch: z.string().optional(),
168+
setUpstream: z.boolean().default(false),
169+
});
170+
171+
export const pushOutput = z.object({
172+
success: z.boolean(),
173+
message: z.string(),
174+
});
175+
176+
export type PushInput = z.infer<typeof pushInput>;
177+
export type PushOutput = z.infer<typeof pushOutput>;
178+
179+
// Pull operation
180+
export const pullInput = z.object({
181+
directoryPath: z.string(),
182+
remote: z.string().default("origin"),
183+
branch: z.string().optional(),
184+
});
185+
186+
export const pullOutput = z.object({
187+
success: z.boolean(),
188+
message: z.string(),
189+
updatedFiles: z.number().optional(),
190+
});
191+
192+
export type PullInput = z.infer<typeof pullInput>;
193+
export type PullOutput = z.infer<typeof pullOutput>;
194+
195+
// Publish (push with upstream) operation
196+
export const publishInput = z.object({
197+
directoryPath: z.string(),
198+
remote: z.string().default("origin"),
199+
});
200+
201+
export const publishOutput = z.object({
202+
success: z.boolean(),
203+
message: z.string(),
204+
branch: z.string(),
205+
});
206+
207+
export type PublishInput = z.infer<typeof publishInput>;
208+
export type PublishOutput = z.infer<typeof publishOutput>;
209+
210+
// Sync (pull then push) operation
211+
export const syncInput = z.object({
212+
directoryPath: z.string(),
213+
remote: z.string().default("origin"),
214+
});
215+
216+
export const syncOutput = z.object({
217+
success: z.boolean(),
218+
pullMessage: z.string(),
219+
pushMessage: z.string(),
220+
});
221+
222+
export type SyncInput = z.infer<typeof syncInput>;
223+
export type SyncOutput = z.infer<typeof syncOutput>;
224+
225+
// PR Template lookup
226+
export const getPrTemplateInput = directoryPathInput;
227+
228+
export const getPrTemplateOutput = z.object({
229+
template: z.string().nullable(),
230+
templatePath: z.string().nullable(),
231+
});
232+
233+
export type GetPrTemplateOutput = z.infer<typeof getPrTemplateOutput>;
234+
235+
// Commit conventions analysis
236+
export const getCommitConventionsInput = z.object({
237+
directoryPath: z.string(),
238+
sampleSize: z.number().default(20),
239+
});
240+
241+
export const getCommitConventionsOutput = z.object({
242+
conventionalCommits: z.boolean(),
243+
commonPrefixes: z.array(z.string()),
244+
sampleMessages: z.array(z.string()),
245+
});
246+
247+
export type GetCommitConventionsOutput = z.infer<
248+
typeof getCommitConventionsOutput
249+
>;

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

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,16 @@ import type {
99
CloneProgressPayload,
1010
DetectRepoResult,
1111
DiffStats,
12+
GetCommitConventionsOutput,
13+
GetPrTemplateOutput,
1214
GitCommitInfo,
1315
GitFileStatus,
1416
GitRepoInfo,
1517
GitSyncStatus,
18+
PublishOutput,
19+
PullOutput,
20+
PushOutput,
21+
SyncOutput,
1622
} from "./schemas.js";
1723
import { parseGitHubUrl } from "./utils.js";
1824

@@ -560,6 +566,180 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
560566
}
561567
}
562568

569+
public async push(
570+
directoryPath: string,
571+
remote = "origin",
572+
branch?: string,
573+
setUpstream = false,
574+
): Promise<PushOutput> {
575+
try {
576+
const targetBranch =
577+
branch || (await this.getCurrentBranch(directoryPath));
578+
if (!targetBranch) {
579+
return { success: false, message: "No branch to push" };
580+
}
581+
582+
const args = ["push"];
583+
if (setUpstream) {
584+
args.push("-u");
585+
}
586+
args.push(remote, targetBranch);
587+
588+
const { stdout, stderr } = await execFileAsync("git", args, {
589+
cwd: directoryPath,
590+
});
591+
592+
return {
593+
success: true,
594+
message: stdout || stderr || "Push successful",
595+
};
596+
} catch (error) {
597+
const message = error instanceof Error ? error.message : String(error);
598+
return { success: false, message };
599+
}
600+
}
601+
602+
public async pull(
603+
directoryPath: string,
604+
remote = "origin",
605+
branch?: string,
606+
): Promise<PullOutput> {
607+
try {
608+
const targetBranch =
609+
branch || (await this.getCurrentBranch(directoryPath));
610+
const args = ["pull", remote];
611+
if (targetBranch) {
612+
args.push(targetBranch);
613+
}
614+
615+
const { stdout, stderr } = await execFileAsync("git", args, {
616+
cwd: directoryPath,
617+
});
618+
619+
// Parse number of files changed from output
620+
const output = stdout || stderr || "";
621+
const filesMatch = output.match(/(\d+) files? changed/);
622+
const updatedFiles = filesMatch ? parseInt(filesMatch[1], 10) : undefined;
623+
624+
return {
625+
success: true,
626+
message: output || "Pull successful",
627+
updatedFiles,
628+
};
629+
} catch (error) {
630+
const message = error instanceof Error ? error.message : String(error);
631+
return { success: false, message };
632+
}
633+
}
634+
635+
public async publish(
636+
directoryPath: string,
637+
remote = "origin",
638+
): Promise<PublishOutput> {
639+
const currentBranch = await this.getCurrentBranch(directoryPath);
640+
if (!currentBranch) {
641+
return { success: false, message: "No branch to publish", branch: "" };
642+
}
643+
644+
const result = await this.push(directoryPath, remote, currentBranch, true);
645+
return { ...result, branch: currentBranch };
646+
}
647+
648+
public async sync(
649+
directoryPath: string,
650+
remote = "origin",
651+
): Promise<SyncOutput> {
652+
const pullResult = await this.pull(directoryPath, remote);
653+
if (!pullResult.success) {
654+
return {
655+
success: false,
656+
pullMessage: pullResult.message,
657+
pushMessage: "Skipped due to pull failure",
658+
};
659+
}
660+
661+
const pushResult = await this.push(directoryPath, remote);
662+
return {
663+
success: pushResult.success,
664+
pullMessage: pullResult.message,
665+
pushMessage: pushResult.message,
666+
};
667+
}
668+
669+
public async getPrTemplate(
670+
directoryPath: string,
671+
): Promise<GetPrTemplateOutput> {
672+
const templatePaths = [
673+
".github/PULL_REQUEST_TEMPLATE.md",
674+
".github/pull_request_template.md",
675+
"PULL_REQUEST_TEMPLATE.md",
676+
"pull_request_template.md",
677+
"docs/PULL_REQUEST_TEMPLATE.md",
678+
];
679+
680+
for (const relativePath of templatePaths) {
681+
const fullPath = path.join(directoryPath, relativePath);
682+
try {
683+
const content = await fsPromises.readFile(fullPath, "utf-8");
684+
return { template: content, templatePath: relativePath };
685+
} catch {
686+
// Template not found at this path, continue
687+
}
688+
}
689+
690+
return { template: null, templatePath: null };
691+
}
692+
693+
public async getCommitConventions(
694+
directoryPath: string,
695+
sampleSize = 20,
696+
): Promise<GetCommitConventionsOutput> {
697+
try {
698+
const { stdout } = await execAsync(
699+
`git log --oneline -n ${sampleSize} --format="%s"`,
700+
{ cwd: directoryPath },
701+
);
702+
703+
const messages = stdout.trim().split("\n").filter(Boolean);
704+
705+
// Check for conventional commit pattern: type(scope): message or type: message
706+
const conventionalPattern =
707+
/^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\(.+\))?:/;
708+
const conventionalCount = messages.filter((m) =>
709+
conventionalPattern.test(m),
710+
).length;
711+
const conventionalCommits = conventionalCount > messages.length * 0.5;
712+
713+
// Extract common prefixes
714+
const prefixes = messages
715+
.map((m) => m.match(/^([a-z]+)(\(.+\))?:/)?.[1])
716+
.filter((p): p is string => Boolean(p));
717+
const prefixCounts = prefixes.reduce(
718+
(acc, p) => {
719+
acc[p] = (acc[p] || 0) + 1;
720+
return acc;
721+
},
722+
{} as Record<string, number>,
723+
);
724+
const commonPrefixes = Object.entries(prefixCounts)
725+
.sort((a, b) => b[1] - a[1])
726+
.slice(0, 5)
727+
.map(([prefix]) => prefix);
728+
729+
return {
730+
conventionalCommits,
731+
commonPrefixes,
732+
sampleMessages: messages.slice(0, 5),
733+
};
734+
} catch {
735+
return {
736+
conventionalCommits: false,
737+
commonPrefixes: [],
738+
sampleMessages: [],
739+
};
740+
}
741+
}
742+
563743
// Private helper methods
564744

565745
private async countFileLines(filePath: string): Promise<number> {

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

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
getAllBranchesOutput,
1414
getChangedFilesHeadInput,
1515
getChangedFilesHeadOutput,
16+
getCommitConventionsInput,
17+
getCommitConventionsOutput,
1618
getCurrentBranchInput,
1719
getCurrentBranchOutput,
1820
getDefaultBranchInput,
@@ -27,6 +29,16 @@ import {
2729
getGitSyncStatusOutput,
2830
getLatestCommitInput,
2931
getLatestCommitOutput,
32+
getPrTemplateInput,
33+
getPrTemplateOutput,
34+
publishInput,
35+
publishOutput,
36+
pullInput,
37+
pullOutput,
38+
pushInput,
39+
pushOutput,
40+
syncInput,
41+
syncOutput,
3042
validateRepoInput,
3143
validateRepoOutput,
3244
} from "../../services/git/schemas.js";
@@ -140,4 +152,49 @@ export const gitRouter = router({
140152
.input(getGitRepoInfoInput)
141153
.output(getGitRepoInfoOutput)
142154
.query(({ input }) => getService().getGitRepoInfo(input.directoryPath)),
155+
156+
push: publicProcedure
157+
.input(pushInput)
158+
.output(pushOutput)
159+
.mutation(({ input }) =>
160+
getService().push(
161+
input.directoryPath,
162+
input.remote,
163+
input.branch,
164+
input.setUpstream,
165+
),
166+
),
167+
168+
pull: publicProcedure
169+
.input(pullInput)
170+
.output(pullOutput)
171+
.mutation(({ input }) =>
172+
getService().pull(input.directoryPath, input.remote, input.branch),
173+
),
174+
175+
publish: publicProcedure
176+
.input(publishInput)
177+
.output(publishOutput)
178+
.mutation(({ input }) =>
179+
getService().publish(input.directoryPath, input.remote),
180+
),
181+
182+
sync: publicProcedure
183+
.input(syncInput)
184+
.output(syncOutput)
185+
.mutation(({ input }) =>
186+
getService().sync(input.directoryPath, input.remote),
187+
),
188+
189+
getPrTemplate: publicProcedure
190+
.input(getPrTemplateInput)
191+
.output(getPrTemplateOutput)
192+
.query(({ input }) => getService().getPrTemplate(input.directoryPath)),
193+
194+
getCommitConventions: publicProcedure
195+
.input(getCommitConventionsInput)
196+
.output(getCommitConventionsOutput)
197+
.query(({ input }) =>
198+
getService().getCommitConventions(input.directoryPath, input.sampleSize),
199+
),
143200
});

apps/array/src/renderer/features/task-detail/components/ChangesPanel.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,6 @@ export function ChangesPanel({ taskId, task }: ChangesPanelProps) {
363363
}),
364364
enabled: !!repoPath,
365365
refetchOnMount: "always",
366-
refetchInterval: 10000,
367366
});
368367

369368
const getActiveIndex = useCallback((): number => {

0 commit comments

Comments
 (0)