Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions apps/array/src/main/services/git/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,91 @@ export const getLatestCommitOutput = gitCommitInfoSchema.nullable();
// getGitRepoInfo schemas
export const getGitRepoInfoInput = directoryPathInput;
export const getGitRepoInfoOutput = gitRepoInfoSchema.nullable();

// Push operation
export const pushInput = z.object({
directoryPath: z.string(),
remote: z.string().default("origin"),
branch: z.string().optional(),
setUpstream: z.boolean().default(false),
});

export const pushOutput = z.object({
success: z.boolean(),
message: z.string(),
});

export type PushInput = z.infer<typeof pushInput>;
export type PushOutput = z.infer<typeof pushOutput>;

// Pull operation
export const pullInput = z.object({
directoryPath: z.string(),
remote: z.string().default("origin"),
branch: z.string().optional(),
});

export const pullOutput = z.object({
success: z.boolean(),
message: z.string(),
updatedFiles: z.number().optional(),
});

export type PullInput = z.infer<typeof pullInput>;
export type PullOutput = z.infer<typeof pullOutput>;

// Publish (push with upstream) operation
export const publishInput = z.object({
directoryPath: z.string(),
remote: z.string().default("origin"),
});

export const publishOutput = z.object({
success: z.boolean(),
message: z.string(),
branch: z.string(),
});

export type PublishInput = z.infer<typeof publishInput>;
export type PublishOutput = z.infer<typeof publishOutput>;

// Sync (pull then push) operation
export const syncInput = z.object({
directoryPath: z.string(),
remote: z.string().default("origin"),
});

export const syncOutput = z.object({
success: z.boolean(),
pullMessage: z.string(),
pushMessage: z.string(),
});

export type SyncInput = z.infer<typeof syncInput>;
export type SyncOutput = z.infer<typeof syncOutput>;

// PR Template lookup
export const getPrTemplateInput = directoryPathInput;

export const getPrTemplateOutput = z.object({
template: z.string().nullable(),
templatePath: z.string().nullable(),
});

export type GetPrTemplateOutput = z.infer<typeof getPrTemplateOutput>;

// Commit conventions analysis
export const getCommitConventionsInput = z.object({
directoryPath: z.string(),
sampleSize: z.number().default(20),
});

export const getCommitConventionsOutput = z.object({
conventionalCommits: z.boolean(),
commonPrefixes: z.array(z.string()),
sampleMessages: z.array(z.string()),
});

export type GetCommitConventionsOutput = z.infer<
typeof getCommitConventionsOutput
>;
180 changes: 180 additions & 0 deletions apps/array/src/main/services/git/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@ import type {
CloneProgressPayload,
DetectRepoResult,
DiffStats,
GetCommitConventionsOutput,
GetPrTemplateOutput,
GitCommitInfo,
GitFileStatus,
GitRepoInfo,
GitSyncStatus,
PublishOutput,
PullOutput,
PushOutput,
SyncOutput,
} from "./schemas.js";
import { parseGitHubUrl } from "./utils.js";

Expand Down Expand Up @@ -560,6 +566,180 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
}
}

public async push(
directoryPath: string,
remote = "origin",
branch?: string,
setUpstream = false,
): Promise<PushOutput> {
try {
const targetBranch =
branch || (await this.getCurrentBranch(directoryPath));
if (!targetBranch) {
return { success: false, message: "No branch to push" };
}

const args = ["push"];
if (setUpstream) {
args.push("-u");
}
args.push(remote, targetBranch);

const { stdout, stderr } = await execFileAsync("git", args, {
cwd: directoryPath,
});

return {
success: true,
message: stdout || stderr || "Push successful",
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, message };
}
}

public async pull(
directoryPath: string,
remote = "origin",
branch?: string,
): Promise<PullOutput> {
try {
const targetBranch =
branch || (await this.getCurrentBranch(directoryPath));
const args = ["pull", remote];
if (targetBranch) {
args.push(targetBranch);
}

const { stdout, stderr } = await execFileAsync("git", args, {
cwd: directoryPath,
});

// Parse number of files changed from output
const output = stdout || stderr || "";
const filesMatch = output.match(/(\d+) files? changed/);
const updatedFiles = filesMatch ? parseInt(filesMatch[1], 10) : undefined;

return {
success: true,
message: output || "Pull successful",
updatedFiles,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, message };
}
}

public async publish(
directoryPath: string,
remote = "origin",
): Promise<PublishOutput> {
const currentBranch = await this.getCurrentBranch(directoryPath);
if (!currentBranch) {
return { success: false, message: "No branch to publish", branch: "" };
}

const result = await this.push(directoryPath, remote, currentBranch, true);
return { ...result, branch: currentBranch };
}

public async sync(
directoryPath: string,
remote = "origin",
): Promise<SyncOutput> {
const pullResult = await this.pull(directoryPath, remote);
if (!pullResult.success) {
return {
success: false,
pullMessage: pullResult.message,
pushMessage: "Skipped due to pull failure",
};
}

const pushResult = await this.push(directoryPath, remote);
return {
success: pushResult.success,
pullMessage: pullResult.message,
pushMessage: pushResult.message,
};
}

public async getPrTemplate(
directoryPath: string,
): Promise<GetPrTemplateOutput> {
const templatePaths = [
".github/PULL_REQUEST_TEMPLATE.md",
".github/pull_request_template.md",
"PULL_REQUEST_TEMPLATE.md",
"pull_request_template.md",
"docs/PULL_REQUEST_TEMPLATE.md",
];

for (const relativePath of templatePaths) {
const fullPath = path.join(directoryPath, relativePath);
try {
const content = await fsPromises.readFile(fullPath, "utf-8");
return { template: content, templatePath: relativePath };
} catch {
// Template not found at this path, continue
}
}

return { template: null, templatePath: null };
}

public async getCommitConventions(
directoryPath: string,
sampleSize = 20,
): Promise<GetCommitConventionsOutput> {
try {
const { stdout } = await execAsync(
`git log --oneline -n ${sampleSize} --format="%s"`,
{ cwd: directoryPath },
);

const messages = stdout.trim().split("\n").filter(Boolean);

// Check for conventional commit pattern: type(scope): message or type: message
const conventionalPattern =
/^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\(.+\))?:/;
const conventionalCount = messages.filter((m) =>
conventionalPattern.test(m),
).length;
const conventionalCommits = conventionalCount > messages.length * 0.5;

// Extract common prefixes
const prefixes = messages
.map((m) => m.match(/^([a-z]+)(\(.+\))?:/)?.[1])
.filter((p): p is string => Boolean(p));
const prefixCounts = prefixes.reduce(
(acc, p) => {
acc[p] = (acc[p] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
const commonPrefixes = Object.entries(prefixCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([prefix]) => prefix);

return {
conventionalCommits,
commonPrefixes,
sampleMessages: messages.slice(0, 5),
};
} catch {
return {
conventionalCommits: false,
commonPrefixes: [],
sampleMessages: [],
};
}
}

// Private helper methods

private async countFileLines(filePath: string): Promise<number> {
Expand Down
57 changes: 57 additions & 0 deletions apps/array/src/main/trpc/routers/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
getAllBranchesOutput,
getChangedFilesHeadInput,
getChangedFilesHeadOutput,
getCommitConventionsInput,
getCommitConventionsOutput,
getCurrentBranchInput,
getCurrentBranchOutput,
getDefaultBranchInput,
Expand All @@ -27,6 +29,16 @@ import {
getGitSyncStatusOutput,
getLatestCommitInput,
getLatestCommitOutput,
getPrTemplateInput,
getPrTemplateOutput,
publishInput,
publishOutput,
pullInput,
pullOutput,
pushInput,
pushOutput,
syncInput,
syncOutput,
validateRepoInput,
validateRepoOutput,
} from "../../services/git/schemas.js";
Expand Down Expand Up @@ -140,4 +152,49 @@ export const gitRouter = router({
.input(getGitRepoInfoInput)
.output(getGitRepoInfoOutput)
.query(({ input }) => getService().getGitRepoInfo(input.directoryPath)),

push: publicProcedure
.input(pushInput)
.output(pushOutput)
.mutation(({ input }) =>
getService().push(
input.directoryPath,
input.remote,
input.branch,
input.setUpstream,
),
),

pull: publicProcedure
.input(pullInput)
.output(pullOutput)
.mutation(({ input }) =>
getService().pull(input.directoryPath, input.remote, input.branch),
),

publish: publicProcedure
.input(publishInput)
.output(publishOutput)
.mutation(({ input }) =>
getService().publish(input.directoryPath, input.remote),
),

sync: publicProcedure
.input(syncInput)
.output(syncOutput)
.mutation(({ input }) =>
getService().sync(input.directoryPath, input.remote),
),

getPrTemplate: publicProcedure
.input(getPrTemplateInput)
.output(getPrTemplateOutput)
.query(({ input }) => getService().getPrTemplate(input.directoryPath)),

getCommitConventions: publicProcedure
.input(getCommitConventionsInput)
.output(getCommitConventionsOutput)
.query(({ input }) =>
getService().getCommitConventions(input.directoryPath, input.sampleSize),
),
});
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,6 @@ export function ChangesPanel({ taskId, task }: ChangesPanelProps) {
}),
enabled: !!repoPath,
refetchOnMount: "always",
refetchInterval: 10000,
});

const getActiveIndex = useCallback((): number => {
Expand Down
Loading