Skip to content
Merged
2 changes: 2 additions & 0 deletions apps/twig/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { FocusSyncService } from "../services/focus/sync-service.js";
import { FoldersService } from "../services/folders/service.js";
import { FsService } from "../services/fs/service.js";
import { GitService } from "../services/git/service.js";
import { LlmGatewayService } from "../services/llm-gateway/service.js";
import { NotificationService } from "../services/notification/service.js";
import { OAuthService } from "../services/oauth/service.js";
import { ProcessTrackingService } from "../services/process-tracking/service.js";
Expand All @@ -36,6 +37,7 @@ container.bind(MAIN_TOKENS.ContextMenuService).to(ContextMenuService);
container.bind(MAIN_TOKENS.DeepLinkService).to(DeepLinkService);

container.bind(MAIN_TOKENS.ExternalAppsService).to(ExternalAppsService);
container.bind(MAIN_TOKENS.LlmGatewayService).to(LlmGatewayService);
container.bind(MAIN_TOKENS.FileWatcherService).to(FileWatcherService);
container.bind(MAIN_TOKENS.FocusService).to(FocusService);
container.bind(MAIN_TOKENS.FocusSyncService).to(FocusSyncService);
Expand Down
1 change: 1 addition & 0 deletions apps/twig/src/main/di/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const MAIN_TOKENS = Object.freeze({
ContextMenuService: Symbol.for("Main.ContextMenuService"),

ExternalAppsService: Symbol.for("Main.ExternalAppsService"),
LlmGatewayService: Symbol.for("Main.LlmGatewayService"),
FileWatcherService: Symbol.for("Main.FileWatcherService"),
FocusService: Symbol.for("Main.FocusService"),
FocusSyncService: Symbol.for("Main.FocusSyncService"),
Expand Down
47 changes: 36 additions & 11 deletions apps/twig/src/main/services/file-watcher/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ interface PendingChanges {

interface RepoWatcher {
filesId: string;
gitId: string | null;
gitIds: string[];
pending: PendingChanges;
}

Expand Down Expand Up @@ -81,19 +81,23 @@ export class FileWatcherService extends TypedEventEmitter<FileWatcherEvents> {
};

const filesId = `file-watcher:files:${repoPath}`;
const gitId = `file-watcher:git:${repoPath}`;

const filesSub = await this.watchFiles(repoPath, pending);
this.watcherRegistry.register(filesId, filesSub);

const gitSub = await this.watchGit(repoPath);
if (gitSub) {
this.watcherRegistry.register(gitId, gitSub);
const gitIds: string[] = [];
const gitSubs = await this.watchGit(repoPath);
if (gitSubs) {
for (let i = 0; i < gitSubs.length; i++) {
const gitId = `file-watcher:git:${repoPath}:${i}`;
this.watcherRegistry.register(gitId, gitSubs[i]);
gitIds.push(gitId);
}
}

this.watchers.set(repoPath, {
filesId,
gitId: gitSub ? gitId : null,
gitIds,
pending,
});
}
Expand All @@ -105,8 +109,8 @@ export class FileWatcherService extends TypedEventEmitter<FileWatcherEvents> {
if (w.pending.timer) clearTimeout(w.pending.timer);
await this.saveSnapshot(repoPath);
await this.watcherRegistry.unregister(w.filesId);
if (w.gitId) {
await this.watcherRegistry.unregister(w.gitId);
for (const gitId of w.gitIds) {
await this.watcherRegistry.unregister(gitId);
}
this.watchers.delete(repoPath);
}
Expand Down Expand Up @@ -231,10 +235,12 @@ export class FileWatcherService extends TypedEventEmitter<FileWatcherEvents> {

private async watchGit(
repoPath: string,
): Promise<watcher.AsyncSubscription | null> {
): Promise<watcher.AsyncSubscription[] | null> {
try {
const gitDir = await this.resolveGitDir(repoPath);
return watcher.subscribe(gitDir, (err, events) => {
const subscriptions: watcher.AsyncSubscription[] = [];

const handleEvents = (err: Error | null, events: watcher.Event[]) => {
if (this.watcherRegistry.isShutdown) return;
if (err) {
log.error("Git watcher error:", err);
Expand All @@ -249,13 +255,32 @@ export class FileWatcherService extends TypedEventEmitter<FileWatcherEvents> {
if (isRelevant) {
this.emit(FileWatcherEvent.GitStateChanged, { repoPath });
}
});
};

subscriptions.push(await watcher.subscribe(gitDir, handleEvents));

const commonDir = await this.resolveCommonDir(gitDir);
if (commonDir && commonDir !== gitDir) {
subscriptions.push(await watcher.subscribe(commonDir, handleEvents));
}

return subscriptions;
} catch (error) {
log.warn("Failed to set up git watcher:", error);
return null;
}
}

private async resolveCommonDir(gitDir: string): Promise<string | null> {
try {
const commonDirFile = path.join(gitDir, "commondir");
const content = await fs.readFile(commonDirFile, "utf-8");
return path.resolve(gitDir, content.trim());
} catch {
return null;
}
}

private async resolveGitDir(repoPath: string): Promise<string> {
const gitPath = path.join(repoPath, ".git");
const stat = await fs.stat(gitPath);
Expand Down
178 changes: 154 additions & 24 deletions apps/twig/src/main/services/git/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,13 +168,7 @@ export const pushInput = z.object({
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({
Expand All @@ -183,44 +177,84 @@ export const pullInput = z.object({
branch: z.string().optional(),
});

export const pullOutput = z.object({
success: z.boolean(),
export type PullInput = z.infer<typeof pullInput>;

// Commit operation
export const commitInput = z.object({
directoryPath: z.string(),
message: z.string(),
updatedFiles: z.number().optional(),
paths: z.array(z.string()).optional(),
allowEmpty: z.boolean().optional(),
});

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

// Publish (push with upstream) operation
export const publishInput = z.object({
// GitHub CLI status
export const ghStatusOutput = z.object({
installed: z.boolean(),
version: z.string().nullable(),
authenticated: z.boolean(),
username: z.string().nullable(),
error: z.string().nullable(),
});

export type GhStatusOutput = z.infer<typeof ghStatusOutput>;

// Pull request status
export const prStatusInput = directoryPathInput;
export const prStatusOutput = z.object({
hasRemote: z.boolean(),
isGitHubRepo: z.boolean(),
currentBranch: z.string().nullable(),
defaultBranch: z.string().nullable(),
prExists: z.boolean(),
prUrl: z.string().nullable(),
prState: z.string().nullable(),
baseBranch: z.string().nullable(),
headBranch: z.string().nullable(),
isDraft: z.boolean().nullable(),
error: z.string().nullable(),
});

export type PrStatusInput = z.infer<typeof prStatusInput>;
export type PrStatusOutput = z.infer<typeof prStatusOutput>;

// Create PR operation
export const createPrInput = z.object({
directoryPath: z.string(),
remote: z.string().default("origin"),
title: z.string().optional(),
body: z.string().optional(),
draft: z.boolean().optional(),
});

export const publishOutput = z.object({
export type CreatePrInput = z.infer<typeof createPrInput>;

// Open PR operation
export const openPrInput = directoryPathInput;
export const openPrOutput = z.object({
success: z.boolean(),
message: z.string(),
branch: z.string(),
prUrl: z.string().nullable(),
});

export type OpenPrInput = z.infer<typeof openPrInput>;
export type OpenPrOutput = z.infer<typeof openPrOutput>;

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

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;
Expand All @@ -247,3 +281,99 @@ export const getCommitConventionsOutput = z.object({
export type GetCommitConventionsOutput = z.infer<
typeof getCommitConventionsOutput
>;

export const generateCommitMessageInput = z.object({
directoryPath: z.string(),
credentials: z.object({
apiKey: z.string(),
apiHost: z.string(),
}),
});

export const generateCommitMessageOutput = z.object({
message: z.string(),
});

export const generatePrTitleAndBodyInput = z.object({
directoryPath: z.string(),
credentials: z.object({
apiKey: z.string(),
apiHost: z.string(),
}),
});

export const generatePrTitleAndBodyOutput = z.object({
title: z.string(),
body: z.string(),
});

export const gitStateSnapshotSchema = z.object({
changedFiles: z.array(changedFileSchema).optional(),
diffStats: diffStatsSchema.optional(),
syncStatus: gitSyncStatusSchema.optional(),
latestCommit: gitCommitInfoSchema.nullable().optional(),
prStatus: prStatusOutput.optional(),
});

export type GitStateSnapshot = z.infer<typeof gitStateSnapshotSchema>;

export const commitOutput = z.object({
success: z.boolean(),
message: z.string(),
commitSha: z.string().nullable(),
branch: z.string().nullable(),
state: gitStateSnapshotSchema.optional(),
});

export type CommitOutput = z.infer<typeof commitOutput>;

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

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

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

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

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

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

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

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

export const createPrOutput = z.object({
success: z.boolean(),
message: z.string(),
prUrl: z.string().nullable(),
state: gitStateSnapshotSchema.optional(),
});

export type CreatePrOutput = z.infer<typeof createPrOutput>;

export const discardFileChangesOutput = z.object({
success: z.boolean(),
state: gitStateSnapshotSchema.optional(),
});

export type DiscardFileChangesOutput = z.infer<typeof discardFileChangesOutput>;
Loading
Loading