Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions apps/mobile/src/lib/projectThreadStartTurn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe
baseBranch: spec.branch!,
branch: spec.worktreeBranchName,
...(spec.startFromOrigin ? { startFromOrigin: true } : {}),
continueRemoteBranch: true,
},
runSetupScript: true,
}
Expand Down
30 changes: 30 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4308,6 +4308,36 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
}),
);

it.effect(
"continues a remote branch as a tracking branch without recording it as its base",
() =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
yield* initRepoWithCommit(cwd);
const remote = yield* makeTmpDir("git-remote-");
yield* git(remote, ["init", "--bare"]);
yield* git(cwd, ["remote", "add", "origin", remote]);
yield* git(cwd, ["push", "origin", "HEAD:refs/heads/feature/x"]);
yield* git(cwd, ["config", "branch.autoSetupMerge", "false"]);
const path = yield* Path.Path;
const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "continued");
const driver = yield* GitVcsDriver.GitVcsDriver;
yield* driver.createWorktree({
cwd,
path: worktreePath,
refName: "origin/feature/x",
newRefName: "feature/x",
baseRefName: "origin/feature/x",
});

assert.equal(
yield* git(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"]),
"origin/feature/x",
);
assert.equal(yield* driver.readConfigValue(cwd, "branch.feature/x.gh-merge-base"), null);
}),
);

it("parses checkout progress lines from git's stderr", () => {
assert.deepStrictEqual(parseGitCheckoutProgressLine("Updating files: 78% (2104/2700)"), {
percent: 78,
Expand Down
14 changes: 13 additions & 1 deletion apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3814,7 +3814,19 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*

if (input.newRefName && input.baseRefName) {
const base = yield* resolveBranchBase(input.cwd, input.baseRefName);
if (base) {
if (base?.branchName === input.newRefName) {
// A local copy of a remote branch continues it rather than branching off
// it. Track it explicitly, since `branch.autoSetupMerge=false` skips that.
if (input.baseRefName !== base.branchName) {
yield* runGit("GitVcsDriver.createWorktree.trackBaseRef", input.cwd, [
"branch",
"--set-upstream-to",
input.baseRefName,
"--",
input.newRefName,
]);
}
} else if (base) {
yield* Effect.gen(function* () {
yield* runGit("GitVcsDriver.createWorktree.configureBaseRef", input.cwd, [
"config",
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6899,6 +6899,8 @@ export default function ChatView(props: ChatViewProps) {
worktreePath: null,
createdAt: messageCreatedAt,
},
// No continueRemoteBranch: a branch can be checked out in only one
// worktree, so each fan-out thread needs its own.
prepareWorktree: {
projectCwd: activeProject.workspaceRoot,
baseBranch: activeThreadBranch!,
Expand Down Expand Up @@ -7183,6 +7185,7 @@ export default function ChatView(props: ChatViewProps) {
projectCwd: activeProject.workspaceRoot,
baseBranch: baseBranchForWorktree,
...(startFromOrigin ? { startFromOrigin: true } : {}),
continueRemoteBranch: true,
},
runSetupScript: true,
}
Expand Down
4 changes: 3 additions & 1 deletion docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# Working with threads

Use a new thread for a separate task. Choose **New worktree** when its code changes
need a separate branch and working directory.
need a separate branch and working directory. Basing it on a branch that exists only
on the remote, such as `origin/feature-x`, continues that branch: the worktree checks
out a local `feature-x` that tracks it, instead of a new branch.

## Start a thread

Expand Down
64 changes: 64 additions & 0 deletions packages/client-runtime/src/operations/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
type OrchestrationV2ThreadLaunchInput,
type OrchestrationV2ThreadProjection,
type ProjectMutation,
type VcsListRefsInput,
type VcsRef,
} from "@t3tools/contracts";
import { describe, expect, it } from "@effect/vitest";
import * as Crypto from "effect/Crypto";
Expand Down Expand Up @@ -80,8 +82,17 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct
readonly projectionRequests?: ThreadId[];
readonly advertiseServerResolvedCommandContext?: boolean;
readonly advertiseThreadStop?: boolean;
readonly refs?: ReadonlyArray<VcsRef>;
}) {
const client = {
[WS_METHODS.vcsListRefs]: (listInput: VcsListRefsInput) =>
Effect.succeed({
refs: (input.refs ?? []).filter((ref) => ref.name.includes(listInput.query ?? "")),
isRepo: true,
hasPrimaryRemote: true,
nextCursor: null,
totalCount: input.refs?.length ?? 0,
}),
[ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) =>
Effect.sync(() => {
input.commands.push(command);
Expand Down Expand Up @@ -404,6 +415,59 @@ describe("V2 environment commands", () => {
}).pipe(Effect.provide(layerTestCrypto)),
);

it.effect("continues a remote-only base branch instead of branching off it", () =>
Effect.gen(function* () {
const remoteRef = (name: string): VcsRef => ({
name,
current: false,
isRemote: true,
isDefault: false,
worktreePath: null,
remoteName: "origin",
});
const localRef = (name: string): VcsRef => ({
name,
current: false,
isRemote: false,
isDefault: false,
worktreePath: null,
});
const launchWith = Effect.fn(function* (refs: ReadonlyArray<VcsRef>) {
const launches: OrchestrationV2ThreadLaunchInput[] = [];
const supervisor = yield* makeSupervisor({ commands: [], projects: [], launches, refs });
yield* startThreadTurn({
commandId: CommandId.make("launch-remote-base"),
threadId: v2ThreadId,
message: {
messageId: MessageId.make("message-remote-base"),
role: "user",
text: "Continue the feature",
attachments: [],
},
runtimeMode: "full-access",
interactionMode: "default",
bootstrap: {
prepareWorktree: {
projectCwd: "/workspace/project",
baseBranch: "origin/feature-x",
branch: "t3/deadbeef",
continueRemoteBranch: true,
},
},
}).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
return launches[0]?.workspaceStrategy;
});

expect(yield* launchWith([remoteRef("origin/feature-x")])).toMatchObject({
baseRef: "origin/feature-x",
branch: "feature-x",
});
expect(
yield* launchWith([localRef("feature-x"), remoteRef("origin/feature-x")]),
).toMatchObject({ baseRef: "origin/feature-x", branch: "t3/deadbeef" });
}).pipe(Effect.provide(layerTestCrypto)),
);

it.effect("uses server-resolved delivery intent without fetching the full projection", () =>
Effect.gen(function* () {
const commands: OrchestrationV2Command[] = [];
Expand Down
29 changes: 28 additions & 1 deletion packages/client-runtime/src/operations/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ import {
type ThreadEnvMode,
type UploadChatAttachment,
} from "@t3tools/contracts";
import {
deriveLocalBranchNameFromRemoteRef,
resolveRemoteBaseWorktreeBranch,
} from "@t3tools/shared/git";
import { modelSelectionCommandType } from "@t3tools/shared/model";
import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";
Expand Down Expand Up @@ -154,10 +158,26 @@ interface StartThreadBootstrap {
readonly baseBranch: string;
readonly branch?: string;
readonly startFromOrigin?: boolean;
/** A remote-only base branch is continued under its local name, taking precedence over `branch`. */
readonly continueRemoteBranch?: boolean;
};
readonly runSetupScript?: boolean;
}

const resolveContinuedRemoteBranch = (cwd: string, baseRef: string) => {
const query = deriveLocalBranchNameFromRemoteRef(baseRef);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (query === baseRef) return Effect.succeed(null);
return request(WS_METHODS.vcsListRefs, {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
cwd,
query,
includeMatchingRemoteRefs: true,
limit: 100,
}).pipe(
Effect.map((result) => resolveRemoteBaseWorktreeBranch(baseRef, result.refs)),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Effect.orElseSucceed(() => null),
);
};

export interface StartThreadTurnInput extends ThreadCommandInput {
readonly manualContinuationOfRunId?: RunId;
readonly message: {
Expand Down Expand Up @@ -637,12 +657,19 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")(
const existingProjection =
bootstrap === undefined ? yield* getProjection(input.threadId) : null;
const thread = bootstrap ?? existingProjection!.thread;
const worktreeBranch =
prepareWorktree?.continueRemoteBranch === true
? ((yield* resolveContinuedRemoteBranch(
prepareWorktree.projectCwd,
prepareWorktree.baseBranch,
)) ?? prepareWorktree.branch)
: prepareWorktree?.branch;
const workspaceStrategy =
prepareWorktree !== undefined
? {
type: "worktree" as const,
baseRef: prepareWorktree.baseBranch,
...(prepareWorktree.branch === undefined ? {} : { branch: prepareWorktree.branch }),
...(worktreeBranch === undefined ? {} : { branch: worktreeBranch }),
...(prepareWorktree.startFromOrigin === undefined
? {}
: { startFromOrigin: prepareWorktree.startFromOrigin }),
Expand Down
56 changes: 56 additions & 0 deletions packages/shared/src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,19 @@ import {
parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
parseOriginUrlFromGitConfig,
resolveAutoFeatureBranchName,
resolveRemoteBaseWorktreeBranch,
sanitizeFeatureBranchName,
WORKTREE_BRANCH_PREFIX,
} from "./git.ts";

const localRef = (name: string, isDefault = false) => ({ name, isRemote: false, isDefault });
const remoteRef = (name: string, remoteName = "origin", isDefault = false) => ({
name,
isRemote: true,
isDefault,
remoteName,
});

describe("normalizeGitRemoteUrl", () => {
it("canonicalizes equivalent GitHub remotes across protocol variants", () => {
expect(normalizeGitRemoteUrl("git@github.com:T3Tools/T3Code.git")).toBe(
Expand Down Expand Up @@ -236,6 +245,53 @@ describe("isTemporaryWorktreeBranch", () => {
});
});

describe("resolveRemoteBaseWorktreeBranch", () => {
it("continues a remote-only branch under its local name", () => {
expect(
resolveRemoteBaseWorktreeBranch("origin/feature/x", [
localRef("main", true),
remoteRef("origin/feature/x"),
]),
).toBe("feature/x");
expect(
resolveRemoteBaseWorktreeBranch("team/fork/feature-x", [
remoteRef("team/fork/feature-x", "team/fork"),
]),
).toBe("feature-x");
});

it("branches off a local base so stacking still works", () => {
expect(resolveRemoteBaseWorktreeBranch("feature-x", [localRef("feature-x")])).toBeNull();
});

it("branches off when the local name already exists", () => {
expect(
resolveRemoteBaseWorktreeBranch("upstream/feature-x", [
localRef("feature-x"),
remoteRef("upstream/feature-x", "upstream"),
]),
).toBeNull();
});

it("branches off the default branch", () => {
expect(
resolveRemoteBaseWorktreeBranch("origin/main", [remoteRef("origin/main", "origin", true)]),
).toBeNull();
expect(
resolveRemoteBaseWorktreeBranch("upstream/main", [
remoteRef("origin/main", "origin", true),
remoteRef("upstream/main", "upstream"),
]),
).toBeNull();
});

it("keeps the fresh branch when the base is not among the refs", () => {
expect(
resolveRemoteBaseWorktreeBranch("origin/feature-x", [localRef("main", true)]),
).toBeNull();
});
});

describe("applyGitStatusStreamEvent", () => {
it("treats a remote-only update as a repository when local state is missing", () => {
const remote: VcsStatusRemoteResult = {
Expand Down
28 changes: 28 additions & 0 deletions packages/shared/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,34 @@ export function dedupeRemoteBranchesWithLocalMatches(
});
}

function localBranchNameOfRemoteRef(ref: Pick<VcsRef, "name" | "remoteName">): string {
const remotePrefix = ref.remoteName ? `${ref.remoteName}/` : null;
return remotePrefix && ref.name.startsWith(remotePrefix) && ref.name.length > remotePrefix.length
? ref.name.slice(remotePrefix.length)
: deriveLocalBranchNameFromRemoteRef(ref.name);
}

/**
* A new worktree based on a remote-only branch continues that branch under its
* local name. Null keeps a fresh branch off the base: the base is local (so
* stacking works), the local name is taken, or it names the default branch.
*/
export function resolveRemoteBaseWorktreeBranch(
baseRefName: string,
refs: ReadonlyArray<Pick<VcsRef, "name" | "isRemote" | "isDefault" | "remoteName">>,
): string | null {
const baseRef = refs.find((ref) => ref.name === baseRefName);
if (!baseRef?.isRemote) return null;
const localName = localBranchNameOfRemoteRef(baseRef);
if (localName === baseRef.name) return null;
const taken = refs.some((ref) =>
ref.isRemote
? ref.isDefault && localBranchNameOfRemoteRef(ref) === localName
: ref.name === localName,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
);
return taken ? null : localName;
}

export function detectSourceControlProviderFromGitRemoteUrl(
remoteUrl: string,
): SourceControlProviderInfo | null {
Expand Down
Loading