Skip to content

Commit 335268e

Browse files
k11kirkyclaude
andauthored
feat: add git integration with smart actions and status tracking (#254)
Co-authored-by: Claude <[email protected]>
1 parent 9e71f7f commit 335268e

File tree

11 files changed

+909
-25
lines changed

11 files changed

+909
-25
lines changed

apps/array/src/main/preload.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,33 @@ contextBridge.exposeInMainWorld("electronAPI", {
297297
fileStatus: string,
298298
): Promise<void> =>
299299
ipcRenderer.invoke("discard-file-changes", repoPath, filePath, fileStatus),
300+
getGitSyncStatus: (
301+
repoPath: string,
302+
): Promise<{
303+
ahead: number;
304+
behind: number;
305+
hasRemote: boolean;
306+
currentBranch: string | null;
307+
isFeatureBranch: boolean;
308+
}> => ipcRenderer.invoke("get-git-sync-status", repoPath),
309+
getLatestCommit: (
310+
repoPath: string,
311+
): Promise<{
312+
sha: string;
313+
shortSha: string;
314+
message: string;
315+
author: string;
316+
date: string;
317+
} | null> => ipcRenderer.invoke("get-latest-commit", repoPath),
318+
getGitRepoInfo: (
319+
repoPath: string,
320+
): Promise<{
321+
organization: string;
322+
repository: string;
323+
currentBranch: string | null;
324+
defaultBranch: string;
325+
compareUrl: string | null;
326+
} | null> => ipcRenderer.invoke("get-git-repo-info", repoPath),
300327
listDirectory: (
301328
dirPath: string,
302329
): Promise<

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

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,158 @@ export interface DiffStats {
383383
linesRemoved: number;
384384
}
385385

386+
export interface GitSyncStatus {
387+
ahead: number;
388+
behind: number;
389+
hasRemote: boolean;
390+
currentBranch: string | null;
391+
isFeatureBranch: boolean;
392+
}
393+
394+
export interface GitCommitInfo {
395+
sha: string;
396+
shortSha: string;
397+
message: string;
398+
author: string;
399+
date: string;
400+
}
401+
402+
export interface GitRepoInfo {
403+
organization: string;
404+
repository: string;
405+
currentBranch: string | null;
406+
defaultBranch: string;
407+
compareUrl: string | null;
408+
}
409+
410+
const getLatestCommit = async (
411+
directoryPath: string,
412+
): Promise<GitCommitInfo | null> => {
413+
try {
414+
const { stdout } = await execAsync(
415+
'git log -1 --format="%H|%h|%s|%an|%aI"',
416+
{ cwd: directoryPath },
417+
);
418+
419+
const [sha, shortSha, message, author, date] = stdout.trim().split("|");
420+
if (!sha) return null;
421+
422+
return { sha, shortSha, message, author, date };
423+
} catch {
424+
return null;
425+
}
426+
};
427+
428+
const getGitRepoInfo = async (
429+
directoryPath: string,
430+
): Promise<GitRepoInfo | null> => {
431+
try {
432+
const remoteUrl = await getRemoteUrl(directoryPath);
433+
if (!remoteUrl) return null;
434+
435+
const parsed = parseGitHubUrl(remoteUrl);
436+
if (!parsed) return null;
437+
438+
const currentBranch = await getCurrentBranch(directoryPath);
439+
const defaultBranch = await getDefaultBranch(directoryPath);
440+
441+
let compareUrl: string | null = null;
442+
if (currentBranch && currentBranch !== defaultBranch) {
443+
compareUrl = `https://github.com/${parsed.organization}/${parsed.repository}/compare/${defaultBranch}...${currentBranch}?expand=1`;
444+
}
445+
446+
return {
447+
organization: parsed.organization,
448+
repository: parsed.repository,
449+
currentBranch: currentBranch ?? null,
450+
defaultBranch,
451+
compareUrl,
452+
};
453+
} catch {
454+
return null;
455+
}
456+
};
457+
458+
const getGitSyncStatus = async (
459+
directoryPath: string,
460+
): Promise<GitSyncStatus> => {
461+
try {
462+
const currentBranch = await getCurrentBranch(directoryPath);
463+
if (!currentBranch) {
464+
return {
465+
ahead: 0,
466+
behind: 0,
467+
hasRemote: false,
468+
currentBranch: null,
469+
isFeatureBranch: false,
470+
};
471+
}
472+
473+
const defaultBranch = await getDefaultBranch(directoryPath);
474+
const isFeatureBranch = currentBranch !== defaultBranch;
475+
476+
try {
477+
const { stdout: upstream } = await execAsync(
478+
`git rev-parse --abbrev-ref ${currentBranch}@{upstream}`,
479+
{ cwd: directoryPath },
480+
);
481+
482+
const upstreamBranch = upstream.trim();
483+
if (!upstreamBranch) {
484+
return {
485+
ahead: 0,
486+
behind: 0,
487+
hasRemote: false,
488+
currentBranch,
489+
isFeatureBranch,
490+
};
491+
}
492+
493+
// Use --quiet to suppress output, ignore errors (network may be unavailable)
494+
try {
495+
await execAsync("git fetch --quiet", {
496+
cwd: directoryPath,
497+
timeout: 10000,
498+
});
499+
} catch {
500+
// Fetch failed (likely offline), continue with stale data
501+
}
502+
503+
const { stdout: revList } = await execAsync(
504+
`git rev-list --left-right --count ${currentBranch}...${upstreamBranch}`,
505+
{ cwd: directoryPath },
506+
);
507+
508+
const [ahead, behind] = revList.trim().split("\t").map(Number);
509+
510+
return {
511+
ahead: ahead || 0,
512+
behind: behind || 0,
513+
hasRemote: true,
514+
currentBranch,
515+
isFeatureBranch,
516+
};
517+
} catch {
518+
return {
519+
ahead: 0,
520+
behind: 0,
521+
hasRemote: false,
522+
currentBranch,
523+
isFeatureBranch,
524+
};
525+
}
526+
} catch (error) {
527+
log.error("Error getting git sync status:", error);
528+
return {
529+
ahead: 0,
530+
behind: 0,
531+
hasRemote: false,
532+
currentBranch: null,
533+
isFeatureBranch: false,
534+
};
535+
}
536+
};
537+
386538
const discardFileChanges = async (
387539
directoryPath: string,
388540
filePath: string,
@@ -857,4 +1009,34 @@ export function registerGitIpc(
8571009
return discardFileChanges(directoryPath, filePath, fileStatus);
8581010
},
8591011
);
1012+
1013+
ipcMain.handle(
1014+
"get-git-sync-status",
1015+
async (
1016+
_event: IpcMainInvokeEvent,
1017+
directoryPath: string,
1018+
): Promise<GitSyncStatus> => {
1019+
return getGitSyncStatus(directoryPath);
1020+
},
1021+
);
1022+
1023+
ipcMain.handle(
1024+
"get-latest-commit",
1025+
async (
1026+
_event: IpcMainInvokeEvent,
1027+
directoryPath: string,
1028+
): Promise<GitCommitInfo | null> => {
1029+
return getLatestCommit(directoryPath);
1030+
},
1031+
);
1032+
1033+
ipcMain.handle(
1034+
"get-git-repo-info",
1035+
async (
1036+
_event: IpcMainInvokeEvent,
1037+
directoryPath: string,
1038+
): Promise<GitRepoInfo | null> => {
1039+
return getGitRepoInfo(directoryPath);
1040+
},
1041+
);
8601042
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import {
2+
ArrowsClockwise,
3+
CloudArrowUp,
4+
GitBranch,
5+
GitPullRequest,
6+
} from "@phosphor-icons/react";
7+
import { Badge, Box, Flex, Text } from "@radix-ui/themes";
8+
import type { ReactNode } from "react";
9+
10+
export type GitActionType =
11+
| "commit-push"
12+
| "publish"
13+
| "push"
14+
| "pull"
15+
| "sync"
16+
| "create-pr";
17+
18+
export const GIT_ACTION_MARKER_PREFIX = "<!-- GIT_ACTION:";
19+
export const GIT_ACTION_MARKER_SUFFIX = " -->";
20+
21+
export function createGitActionMessage(
22+
actionType: GitActionType,
23+
prompt: string,
24+
): string {
25+
return `${GIT_ACTION_MARKER_PREFIX}${actionType}${GIT_ACTION_MARKER_SUFFIX}\n${prompt}`;
26+
}
27+
28+
export function parseGitActionMessage(content: string): {
29+
isGitAction: boolean;
30+
actionType: GitActionType | null;
31+
prompt: string;
32+
} {
33+
if (!content.startsWith(GIT_ACTION_MARKER_PREFIX)) {
34+
return { isGitAction: false, actionType: null, prompt: content };
35+
}
36+
37+
const markerEnd = content.indexOf(GIT_ACTION_MARKER_SUFFIX);
38+
if (markerEnd === -1) {
39+
return { isGitAction: false, actionType: null, prompt: content };
40+
}
41+
42+
const actionType = content.slice(
43+
GIT_ACTION_MARKER_PREFIX.length,
44+
markerEnd,
45+
) as GitActionType;
46+
47+
const prompt = content.slice(markerEnd + GIT_ACTION_MARKER_SUFFIX.length + 1); // +1 for newline
48+
49+
return { isGitAction: true, actionType, prompt };
50+
}
51+
52+
function getActionIcon(actionType: GitActionType): ReactNode {
53+
switch (actionType) {
54+
case "commit-push":
55+
return <CloudArrowUp size={14} weight="bold" />;
56+
case "publish":
57+
return <GitBranch size={14} weight="bold" />;
58+
case "push":
59+
return <CloudArrowUp size={14} weight="bold" />;
60+
case "pull":
61+
return <ArrowsClockwise size={14} weight="bold" />;
62+
case "sync":
63+
return <ArrowsClockwise size={14} weight="bold" />;
64+
case "create-pr":
65+
return <GitPullRequest size={14} weight="bold" />;
66+
default:
67+
return <CloudArrowUp size={14} weight="bold" />;
68+
}
69+
}
70+
71+
function getActionLabel(actionType: GitActionType): string {
72+
switch (actionType) {
73+
case "commit-push":
74+
return "Commit & Push";
75+
case "publish":
76+
return "Publish Branch";
77+
case "push":
78+
return "Push";
79+
case "pull":
80+
return "Pull";
81+
case "sync":
82+
return "Sync";
83+
case "create-pr":
84+
return "Create PR";
85+
default:
86+
return "Git Action";
87+
}
88+
}
89+
90+
interface GitActionMessageProps {
91+
actionType: GitActionType;
92+
}
93+
94+
export function GitActionMessage({ actionType }: GitActionMessageProps) {
95+
return (
96+
<Box className="mt-4 max-w-[95%] xl:max-w-[60%]">
97+
<Flex
98+
align="center"
99+
gap="2"
100+
className="rounded-lg border border-accent-6 bg-accent-3 px-3 py-2"
101+
>
102+
<Flex
103+
align="center"
104+
justify="center"
105+
className="rounded bg-accent-9 p-1"
106+
style={{ color: "white" }}
107+
>
108+
{getActionIcon(actionType)}
109+
</Flex>
110+
<Text size="2" weight="medium">
111+
{getActionLabel(actionType)}
112+
</Text>
113+
<Badge size="1" color="gray" variant="soft">
114+
Git Action
115+
</Badge>
116+
</Flex>
117+
</Box>
118+
);
119+
}

0 commit comments

Comments
 (0)