Skip to content
Draft
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
2 changes: 2 additions & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ const clientSettings: ClientSettings = {
"environment-1:/tmp/project-a": "separate",
},
sidebarProjectSortOrder: "manual",
sidebarThreadGroupingMode: "worktree",
sidebarThreadSortOrder: "created_at",
sidebarThreadPreviewCount: 6,
sidebarWorktreePreviewCount: 4,
timestampFormat: "24-hour",
};

Expand Down
118 changes: 104 additions & 14 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3181,38 +3181,128 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
}),
);

it.effect("rejects worktree prep when the PR head branch is checked out in the main repo", () =>
it.effect("reuses the launch worktree when T3 starts inside the PR head worktree", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
yield* runGit(repoDir, ["checkout", "-b", "feature/pr-root-only"]);
yield* runGit(repoDir, ["checkout", "-b", "feature/pr-launch-worktree"]);
fs.writeFileSync(path.join(repoDir, "launch-worktree.txt"), "launch worktree\n");
yield* runGit(repoDir, ["add", "launch-worktree.txt"]);
yield* runGit(repoDir, ["commit", "-m", "Launch worktree PR branch"]);
yield* runGit(repoDir, ["checkout", "main"]);
const worktreePath = path.join(repoDir, "..", `pr-launch-${path.basename(repoDir)}`);
yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-launch-worktree"]);

const { manager } = yield* makeManager({
ghScenario: {
pullRequest: {
number: 79,
title: "Root-only PR",
url: "https://github.com/pingdotgg/codething-mvp/pull/79",
number: 179,
title: "Launch Worktree PR",
url: "https://github.com/pingdotgg/codething-mvp/pull/179",
baseRefName: "main",
headRefName: "feature/pr-root-only",
headRefName: "feature/pr-launch-worktree",
state: "open",
},
},
});

const errorMessage = yield* preparePullRequestThread(manager, {
cwd: repoDir,
reference: "79",
const result = yield* preparePullRequestThread(manager, {
cwd: worktreePath,
reference: "179",
mode: "worktree",
}).pipe(
Effect.flip,
Effect.map((error) => error.message),
);
});

expect(errorMessage).toContain("already checked out in the main repo");
expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe(
fs.realpathSync.native(worktreePath),
);
expect(result.branch).toBe("feature/pr-launch-worktree");
}),
);

it.effect(
"rejects worktree prep when launched from a linked worktree but the PR head is in the main checkout",
() =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
yield* runGit(repoDir, ["checkout", "-b", "feature/pr-main-checkout"]);
fs.writeFileSync(path.join(repoDir, "main-checkout.txt"), "main checkout\n");
yield* runGit(repoDir, ["add", "main-checkout.txt"]);
yield* runGit(repoDir, ["commit", "-m", "Main checkout PR branch"]);
const launchWorktreePath = path.join(
repoDir,
"..",
`pr-launcher-${path.basename(repoDir)}`,
);
yield* runGit(repoDir, [
"worktree",
"add",
"-b",
"feature/launcher-worktree",
launchWorktreePath,
"HEAD",
]);

const { manager } = yield* makeManager({
ghScenario: {
pullRequest: {
number: 181,
title: "Main Checkout PR",
url: "https://github.com/pingdotgg/codething-mvp/pull/181",
baseRefName: "main",
headRefName: "feature/pr-main-checkout",
state: "open",
},
},
});

const errorMessage = yield* preparePullRequestThread(manager, {
cwd: launchWorktreePath,
reference: "181",
mode: "worktree",
}).pipe(
Effect.flip,
Effect.map((error) => error.message),
);

expect(errorMessage).toContain("already checked out in the main checkout");
}),
);

it.effect(
"rejects worktree prep when the PR head branch is checked out in the main checkout",
() =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
yield* runGit(repoDir, ["checkout", "-b", "feature/pr-root-only"]);

const { manager } = yield* makeManager({
ghScenario: {
pullRequest: {
number: 79,
title: "Root-only PR",
url: "https://github.com/pingdotgg/codething-mvp/pull/79",
baseRefName: "main",
headRefName: "feature/pr-root-only",
state: "open",
},
},
});

const errorMessage = yield* preparePullRequestThread(manager, {
cwd: repoDir,
reference: "79",
mode: "worktree",
}).pipe(
Effect.flip,
Effect.map((error) => error.message),
);

expect(errorMessage).toContain("already checked out in the main checkout");
}),
);

it.effect("emits ordered progress events for commit hooks", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down
53 changes: 45 additions & 8 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,19 @@ function isNotGitRepositoryError(error: GitCommandError): boolean {
return error.message.toLowerCase().includes("not a git repository");
}

function parsePrimaryWorktreePath(worktreeListPorcelain: string): string | null {
for (const line of worktreeListPorcelain.split("\n")) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low git/GitManager.ts:108

On Windows, Git outputs CRLF line endings (\r\n), so split("\n") leaves a trailing \r on each line. This causes parsePrimaryWorktreePath to return paths like "/path\r", which breaks fileSystem.realPath() and canonicalizeExistingPath. Consider trimming whitespace or using a regex split that handles both line ending types.

-  for (const line of worktreeListPorcelain.split("\n")) {
+  for (const line of worktreeListPorcelain.split(/\r?\n/)) {
🤖 Copy this AI Prompt to have your agent fix this:
In file apps/server/src/git/GitManager.ts around line 108:

On Windows, Git outputs CRLF line endings (`\r\n`), so `split("\n")` leaves a trailing `\r` on each line. This causes `parsePrimaryWorktreePath` to return paths like `"/path\r"`, which breaks `fileSystem.realPath()` and `canonicalizeExistingPath`. Consider trimming whitespace or using a regex split that handles both line ending types.

Evidence trail:
apps/server/src/git/GitManager.ts lines 107-118 (parsePrimaryWorktreePath with split("\n") and no \r handling), apps/server/src/git/GitManager.ts lines 732-735 (caller passes stdout directly, result goes to canonicalizeExistingPath), apps/server/src/vcs/GitVcsDriverCore.ts lines 534-605 (collectOutput: text field accumulates raw decoded output with no CRLF normalization; only the onLine callback strips \r at line 550)

if (!line.startsWith("worktree ")) {
continue;
}

const worktreePath = line.slice("worktree ".length);
return worktreePath.length > 0 ? worktreePath : null;
}

return null;
}

interface OpenPrInfo {
number: number;
title: string;
Expand Down Expand Up @@ -698,6 +711,29 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
const canonicalizeExistingPath = (value: string) =>
fileSystem.realPath(value).pipe(Effect.catch(() => Effect.succeed(value)));
const normalizeStatusCacheKey = canonicalizeExistingPath;
const resolvePrimaryWorktreePath = Effect.fn("resolvePrimaryWorktreePath")(function* (
cwd: string,
) {
const fallbackPath = yield* canonicalizeExistingPath(cwd);
const worktreeList = yield* gitCore
.execute({
operation: "GitManager.resolvePrimaryWorktreePath",
cwd,
args: ["worktree", "list", "--porcelain"],
allowNonZeroExit: true,
timeoutMs: 5_000,
})
.pipe(Effect.catch(() => Effect.succeed(null)));

if (!worktreeList || worktreeList.exitCode !== 0) {
return fallbackPath;
}

const primaryWorktreePath = parsePrimaryWorktreePath(worktreeList.stdout);
return primaryWorktreePath
? yield* canonicalizeExistingPath(primaryWorktreePath)
: fallbackPath;
});
const nonRepositoryStatusDetails = {
isRepo: false,
hasOriginRemote: false,
Expand Down Expand Up @@ -1411,7 +1447,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
};
return yield* Effect.gen(function* () {
const normalizedReference = normalizePullRequestReference(input.reference);
const rootWorktreePath = yield* canonicalizeExistingPath(input.cwd);
const pullRequestSummary = yield* (yield* sourceControlProvider(input.cwd)).getChangeRequest({
cwd: input.cwd,
reference: normalizedReference,
Expand Down Expand Up @@ -1440,6 +1475,8 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
};
}

const primaryWorktreePath = yield* resolvePrimaryWorktreePath(input.cwd);

const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* (
worktreePath: string,
) {
Expand Down Expand Up @@ -1479,7 +1516,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
}

const worktreePath = yield* canonicalizeExistingPath(branch.worktreePath);
if (worktreePath !== rootWorktreePath) {
if (worktreePath !== primaryWorktreePath) {
return branch;
}
}
Expand All @@ -1493,7 +1530,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
: null;
if (
existingBranchBeforeFetch?.worktreePath &&
existingBranchBeforeFetchPath !== rootWorktreePath
existingBranchBeforeFetchPath !== primaryWorktreePath
) {
yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath);
return {
Expand All @@ -1502,10 +1539,10 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
worktreePath: existingBranchBeforeFetch.worktreePath,
};
}
if (existingBranchBeforeFetchPath === rootWorktreePath) {
if (existingBranchBeforeFetchPath === primaryWorktreePath) {
return yield* gitManagerError(
"preparePullRequestThread",
"This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.",
"This PR branch is already checked out in the main checkout. Use Local, or switch the main checkout off that branch before creating a worktree thread.",
);
}

Expand All @@ -1521,7 +1558,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
: null;
if (
existingBranchAfterFetch?.worktreePath &&
existingBranchAfterFetchPath !== rootWorktreePath
existingBranchAfterFetchPath !== primaryWorktreePath
) {
yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath);
return {
Expand All @@ -1530,10 +1567,10 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
worktreePath: existingBranchAfterFetch.worktreePath,
};
}
if (existingBranchAfterFetchPath === rootWorktreePath) {
if (existingBranchAfterFetchPath === primaryWorktreePath) {
return yield* gitManagerError(
"preparePullRequestThread",
"This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.",
"This PR branch is already checked out in the main checkout. Use Local, or switch the main checkout off that branch before creating a worktree thread.",
);
}

Expand Down
Loading
Loading