Skip to content

Commit 71eab6a

Browse files
authored
feat(code): add .worktreeinclude support (#1344)
## Problem tragically, i think we really should make worktrees work well <!-- Who is this for and what problem does it solve? --> <!-- Closes #ISSUE_ID --> ## Changes less tragically, i think this small change makes it all better :) - added support for `.worktreeinclude` and `.worktreelink` files - they do what they sound like; `include` copies files and `link` symlinks files - both are filtered to only include files that are gitignored, which [matches cc's implementation](https://code.claude.com/docs/en/common-workflows#copy-gitignored-files-to-worktrees) <!-- What did you change and why? --> <!-- If there are frontend changes, include screenshots. --> ## How did you test this? added these tiny pieces to the main repo, and it all works pretty smoothly: https://app.graphite.com/github/pr/PostHog/posthog/52566 <!-- Describe what you tested -- manual steps, automated tests, or both. --> <!-- If you're an agent, only list tests you actually ran. -->
1 parent fea81ba commit 71eab6a

6 files changed

Lines changed: 353 additions & 13 deletions

File tree

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
@@ -88,7 +88,7 @@ import type {
8888
ToolUseCache,
8989
} from "./types";
9090

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

packages/git/src/queries.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -971,8 +971,17 @@ export async function addToLocalExclude(
971971
pattern: string,
972972
options?: CreateGitClientOptions,
973973
): Promise<void> {
974-
const gitDir = await resolveGitDir(baseDir, options);
975-
const excludePath = path.join(gitDir, "info", "exclude");
974+
const manager = getGitOperationManager();
975+
const excludePath = await manager.executeRead(
976+
baseDir,
977+
async (git) => {
978+
// --git-path resolves to the correct location for both regular repos
979+
// and worktrees (where info/exclude is shared via the common dir)
980+
const rel = await git.revparse(["--git-path", "info/exclude"]);
981+
return path.resolve(baseDir, rel);
982+
},
983+
{ signal: options?.abortSignal },
984+
);
976985

977986
let content = "";
978987
try {
@@ -988,7 +997,7 @@ export async function addToLocalExclude(
988997
return;
989998
}
990999

991-
const infoDir = path.join(gitDir, "info");
1000+
const infoDir = path.dirname(excludePath);
9921001
await fs.mkdir(infoDir, { recursive: true });
9931002

9941003
const newContent = content.trimEnd()

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)