Skip to content

Commit c167eab

Browse files
committed
feat(code): add .worktreeinclude support
1 parent 9adc809 commit c167eab

File tree

6 files changed

+384
-12
lines changed

6 files changed

+384
-12
lines changed

apps/code/src/main/services/file-watcher/service.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616

1717
const log = logger.scope("file-watcher");
1818

19-
const IGNORE_PATTERNS = ["**/node_modules/**", "**/.git/**", "**/.jj/**"];
19+
const IGNORE_PATTERNS = ["node_modules", ".git", ".jj", ".posthog-code"];
2020
const DEBOUNCE_MS = 500;
2121
const BULK_THRESHOLD = 100;
2222

@@ -71,7 +71,37 @@ export class FileWatcherService extends TypedEventEmitter<FileWatcherEvents> {
7171
if (this.watchers.has(repoPath)) return;
7272

7373
await fs.mkdir(this.snapshotsDir, { recursive: true });
74-
await this.emitChangesSinceSnapshot(repoPath);
74+
75+
const MAX_RETRIES = 3;
76+
const RETRY_DELAY_MS = 2000;
77+
78+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
79+
try {
80+
await this.initWatcher(repoPath);
81+
return;
82+
} catch (error) {
83+
const message = error instanceof Error ? error.message : String(error);
84+
if (attempt < MAX_RETRIES && message.includes("Events were dropped")) {
85+
log.warn(
86+
`File watcher failed (attempt ${attempt}/${MAX_RETRIES}), retrying in ${RETRY_DELAY_MS}ms: ${message}`,
87+
);
88+
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
89+
} else {
90+
log.error(
91+
`File watcher failed after ${attempt} attempt(s): ${message}`,
92+
);
93+
throw error;
94+
}
95+
}
96+
}
97+
}
98+
99+
private async initWatcher(repoPath: string): Promise<void> {
100+
try {
101+
await this.emitChangesSinceSnapshot(repoPath);
102+
} catch (error) {
103+
log.warn("Failed to emit changes since snapshot, skipping:", error);
104+
}
75105

76106
const pending: PendingChanges = {
77107
dirs: new Set(),

apps/code/src/renderer/features/environments/components/EnvironmentSelector.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { HardDrives, Plus } from "@phosphor-icons/react";
44
import { Flex, Tooltip } from "@radix-ui/themes";
55
import { useTRPC } from "@renderer/trpc/client";
66
import { useQuery } from "@tanstack/react-query";
7-
import { useState } from "react";
7+
import { useEffect, useState } from "react";
88

99
interface EnvironmentSelectorProps {
1010
repoPath: string | null;
@@ -29,6 +29,12 @@ export function EnvironmentSelector({
2929
enabled: !!repoPath,
3030
});
3131

32+
useEffect(() => {
33+
if (value === null && environments.length > 0) {
34+
onChange(environments[0].id);
35+
}
36+
}, [value, environments, onChange]);
37+
3238
const selectedEnvironment = environments.find((env) => env.id === value);
3339
const displayText = selectedEnvironment?.name ?? "No environment";
3440

packages/agent/src/adapters/claude/claude-agent.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ import type {
8787
ToolUseCache,
8888
} from "./types";
8989

90-
const SESSION_VALIDATION_TIMEOUT_MS = 10_000;
90+
const SESSION_VALIDATION_TIMEOUT_MS = 30_000;
9191
const MAX_TITLE_LENGTH = 256;
9292
const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
9393

packages/git/src/sagas/worktree.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as path from "node:path";
33
import { GitSaga, type GitSagaInput } from "../git-saga";
44
import { addToLocalExclude, branchExists, getDefaultBranch } from "../queries";
55
import { safeSymlink } from "../utils";
6+
import { processWorktreeInclude, runPostCheckoutHook } from "../worktree";
67

78
export interface CreateWorktreeInput extends GitSagaInput {
89
worktreePath: string;
@@ -35,7 +36,16 @@ export class CreateWorktreeSaga extends GitSaga<
3536
await this.step({
3637
name: "create-worktree",
3738
execute: () =>
38-
this.git.raw(["worktree", "add", "-b", branchName, worktreePath, base]),
39+
this.git.raw([
40+
"-c",
41+
"core.hooksPath=/dev/null",
42+
"worktree",
43+
"add",
44+
"-b",
45+
branchName,
46+
worktreePath,
47+
base,
48+
]),
3949
rollback: async () => {
4050
try {
4151
await this.git.raw(["worktree", "remove", worktreePath, "--force"]);
@@ -86,6 +96,18 @@ export class CreateWorktreeSaga extends GitSaga<
8696
},
8797
});
8898

99+
await this.step({
100+
name: "process-worktree-include",
101+
execute: () => processWorktreeInclude(baseDir, worktreePath),
102+
rollback: async () => {},
103+
});
104+
105+
await this.step({
106+
name: "run-post-checkout-hook",
107+
execute: () => runPostCheckoutHook(baseDir, worktreePath),
108+
rollback: async () => {},
109+
});
110+
89111
return { worktreePath, branchName, baseBranch: base };
90112
}
91113
}
@@ -123,7 +145,14 @@ export class CreateWorktreeForBranchSaga extends GitSaga<
123145
await this.step({
124146
name: "create-worktree",
125147
execute: () =>
126-
this.git.raw(["worktree", "add", worktreePath, branchName]),
148+
this.git.raw([
149+
"-c",
150+
"core.hooksPath=/dev/null",
151+
"worktree",
152+
"add",
153+
worktreePath,
154+
branchName,
155+
]),
127156
rollback: async () => {
128157
try {
129158
await this.git.raw(["worktree", "remove", worktreePath, "--force"]);
@@ -171,6 +200,18 @@ export class CreateWorktreeForBranchSaga extends GitSaga<
171200
},
172201
});
173202

203+
await this.step({
204+
name: "process-worktree-include",
205+
execute: () => processWorktreeInclude(baseDir, worktreePath),
206+
rollback: async () => {},
207+
});
208+
209+
await this.step({
210+
name: "run-post-checkout-hook",
211+
execute: () => runPostCheckoutHook(baseDir, worktreePath),
212+
rollback: async () => {},
213+
});
214+
174215
return { worktreePath, branchName };
175216
}
176217
}

packages/git/src/utils.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { execFile } from "node:child_process";
12
import * as fs from "node:fs/promises";
23
import * as os from "node:os";
34
import * as path from "node:path";
@@ -58,6 +59,54 @@ export async function safeSymlink(
5859
}
5960
}
6061

62+
/**
63+
* copy file or directory, use copy-on-write, fall back to cp
64+
*/
65+
export async function clonePath(
66+
source: string,
67+
destination: string,
68+
): Promise<boolean> {
69+
try {
70+
await fs.access(source);
71+
} catch {
72+
return false;
73+
}
74+
75+
const parentDir = path.dirname(destination);
76+
await fs.mkdir(parentDir, { recursive: true });
77+
78+
const platform = os.platform();
79+
80+
try {
81+
if (platform === "darwin") {
82+
await execFileAsync("cp", ["-c", "-a", source, destination]);
83+
} else {
84+
await execFileAsync("cp", ["--reflink=auto", "-a", source, destination]);
85+
}
86+
return true;
87+
} catch {
88+
// CoW not supported, fall back to regular copy
89+
}
90+
91+
await fs.cp(source, destination, { recursive: true });
92+
return true;
93+
}
94+
95+
function execFileAsync(
96+
command: string,
97+
args: string[],
98+
): Promise<{ stdout: string; stderr: string }> {
99+
return new Promise((resolve, reject) => {
100+
execFile(command, args, (error, stdout, stderr) => {
101+
if (error) {
102+
reject(error);
103+
return;
104+
}
105+
resolve({ stdout, stderr });
106+
});
107+
});
108+
}
109+
61110
export function parseGitHubUrl(url: string): GitHubRepo | null {
62111
// Trim whitespace/newlines that git commands may include
63112
const trimmedUrl = url.trim();

0 commit comments

Comments
 (0)