authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-05 21:02:16-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 21:02:16-07:00
log017c9a0eb53b2c100b5be2d366d2374038da9241
tree567dffe34707e0742bf284561ba594bb9f17cf8e
parent99db70cf1a1f24deca80e40a94a6cdbbaf511da6
signature Signed by PGP key B5690EEEBB952194

fix(server): forks no longer merge into their upstream repo's project group (#16353)

Fixes #4880. Originally #14639 by @Project516. Co-authored-by: Project516 <138796702+Project516@users.noreply.github.com>

8 files changed, 144 insertions(+), 15 deletions(-)

apps/mobile/src/features/threads/new-task-flow-provider.tsx+7-2
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16 DEFAULT_RUNTIME_MODE,16 DEFAULT_RUNTIME_MODE,
17 DEFAULT_SERVER_SETTINGS,17 DEFAULT_SERVER_SETTINGS,
18 MessageId,18 MessageId,
19 repositoryGroupingKeyOf,
19 T3_PROJECT_FILE_NAME,20 T3_PROJECT_FILE_NAME,
20 ThreadId,21 ThreadId,
21} from "@t3tools/contracts";22} from "@t3tools/contracts";
...@@ -393,7 +394,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -393,7 +394,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
393 // whatever unrelated project happens to be first on the other machine. Repository394 // whatever unrelated project happens to be first on the other machine. Repository
394 // identity is the primary signal; projects that haven't reported one yet (still395 // identity is the primary signal; projects that haven't reported one yet (still
395 // indexing) fall back to workspace basename / title so a valid host isn't hidden.396 // indexing) fall back to workspace basename / title so a valid host isn't hidden.
396 const selectedRepositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null;397 const selectedRepositoryKey = selectedProject?.repositoryIdentity
398 ? repositoryGroupingKeyOf(selectedProject.repositoryIdentity)
399 : null;
397 // `|| null` (not `??`): a pending-task placeholder project can have an empty400 // `|| null` (not `??`): a pending-task placeholder project can have an empty
398 // workspaceRoot, and an "" basename would reject every real host below.401 // workspaceRoot, and an "" basename would reject every real host below.
399 const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null;402 const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null;
...@@ -414,7 +417,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -414,7 +417,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
414 if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) {417 if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) {
415 return true;418 return true;
416 }419 }
417 const projectKey = project.repositoryIdentity?.canonicalKey ?? null;420 const projectKey = project.repositoryIdentity
421 ? repositoryGroupingKeyOf(project.repositoryIdentity)
422 : null;
418 if (selectedRepositoryKey !== null && projectKey !== null) {423 if (selectedRepositoryKey !== null && projectKey !== null) {
419 return projectKey === selectedRepositoryKey;424 return projectKey === selectedRepositoryKey;
420 }425 }
apps/mobile/src/features/threads/new-task-project-selection.ts+11-4
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";1import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
2import type { EnvironmentId } from "@t3tools/contracts";2import { repositoryGroupingKeyOf, type EnvironmentId } from "@t3tools/contracts";
33
4import { scopedProjectKey } from "../../lib/scopedEntities";4import { scopedProjectKey } from "../../lib/scopedEntities";
5import type { HomeProjectScope } from "../home/homeThreadList";5import type { HomeProjectScope } from "../home/homeThreadList";
...@@ -56,7 +56,9 @@ export function resolveEnvironmentProjectMatch(...@@ -56,7 +56,9 @@ export function resolveEnvironmentProjectMatch(
56 projectsOnTarget: ReadonlyArray<EnvironmentProject>,56 projectsOnTarget: ReadonlyArray<EnvironmentProject>,
57 selectedProject: EnvironmentProject | null,57 selectedProject: EnvironmentProject | null,
58): EnvironmentProject | null {58): EnvironmentProject | null {
59 const repositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null;59 const repositoryKey = selectedProject?.repositoryIdentity
60 ? repositoryGroupingKeyOf(selectedProject.repositoryIdentity)
61 : null;
60 // `|| null` (not `??`): a pending-task placeholder project can have an empty62 // `|| null` (not `??`): a pending-task placeholder project can have an empty
61 // workspaceRoot, and an "" basename would match nothing meaningful.63 // workspaceRoot, and an "" basename would match nothing meaningful.
62 const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null;64 const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null;
...@@ -64,13 +66,18 @@ export function resolveEnvironmentProjectMatch(...@@ -64,13 +66,18 @@ export function resolveEnvironmentProjectMatch(
64 // side; two known, different repositories never match on a shared basename66 // side; two known, different repositories never match on a shared basename
65 // or title (mirrors the environment list filter in the new-task flow).67 // or title (mirrors the environment list filter in the new-task flow).
66 const isKnownMismatch = (project: EnvironmentProject) => {68 const isKnownMismatch = (project: EnvironmentProject) => {
67 const projectKey = project.repositoryIdentity?.canonicalKey ?? null;69 const projectKey = project.repositoryIdentity
70 ? repositoryGroupingKeyOf(project.repositoryIdentity)
71 : null;
68 return repositoryKey !== null && projectKey !== null && projectKey !== repositoryKey;72 return repositoryKey !== null && projectKey !== null && projectKey !== repositoryKey;
69 };73 };
70 return (74 return (
71 (repositoryKey !== null75 (repositoryKey !== null
72 ? projectsOnTarget.find(76 ? projectsOnTarget.find(
73 (project) => (project.repositoryIdentity?.canonicalKey ?? null) === repositoryKey,77 (project) =>
78 (project.repositoryIdentity
79 ? repositoryGroupingKeyOf(project.repositoryIdentity)
80 : null) === repositoryKey,
74 )81 )
75 : undefined) ??82 : undefined) ??
76 (workspaceBasename !== null83 (workspaceBasename !== null
apps/server/src/project/RepositoryIdentityResolver.test.ts+23
...@@ -288,6 +288,29 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {...@@ -288,6 +288,29 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
288 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),288 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
289 );289 );
290290
291 it.effect("reports a fork's own remote as origin next to the upstream identity", () =>
292 Effect.gen(function* () {
293 const fileSystem = yield* FileSystem.FileSystem;
294 const cwd = yield* fileSystem.makeTempDirectoryScoped({
295 prefix: "t3-repository-identity-fork-test-",
296 });
297
298 yield* git(cwd, ["init"]);
299 yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code-fork.git"]);
300 yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]);
301
302 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
303 const identity = yield* resolver.resolve(cwd);
304
305 expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
306 expect(identity?.displayName).toBe("t3tools/t3code");
307 expect(identity?.origin).toEqual({
308 canonicalKey: "github.com/julius/t3code-fork",
309 displayName: "julius/t3code-fork",
310 });
311 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
312 );
313
291 it.effect("uses the last remote path segment as the repository name for nested groups", () =>314 it.effect("uses the last remote path segment as the repository name for nested groups", () =>
292 Effect.gen(function* () {315 Effect.gen(function* () {
293 const fileSystem = yield* FileSystem.FileSystem;316 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/project/RepositoryIdentityResolver.ts+24-3
...@@ -70,17 +70,34 @@ function pickPrimaryRemote(...@@ -70,17 +70,34 @@ function pickPrimaryRemote(
70 return remoteName && remoteUrl ? { remoteName, remoteUrl } : null;70 return remoteName && remoteUrl ? { remoteName, remoteUrl } : null;
71}71}
7272
73function repositoryPathOf(canonicalKey: string): string {
74 return canonicalKey.split("/").slice(1).join("/");
75}
76
77function buildRepositoryOrigin(
78 originUrl: string | undefined,
79 canonicalKey: string,
80): RepositoryIdentity["origin"] {
81 if (!originUrl) return undefined;
82 const originKey = normalizeGitRemoteUrl(originUrl);
83 if (originKey === canonicalKey) return undefined;
84 const displayName = repositoryPathOf(originKey);
85 return { canonicalKey: originKey, ...(displayName ? { displayName } : {}) };
86}
87
73function buildRepositoryIdentity(input: {88function buildRepositoryIdentity(input: {
74 readonly remoteName: string;89 readonly remoteName: string;
75 readonly remoteUrl: string;90 readonly remoteUrl: string;
91 readonly originUrl: string | undefined;
76 readonly rootPath: string;92 readonly rootPath: string;
77}): RepositoryIdentity {93}): RepositoryIdentity {
78 const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl);94 const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl);
79 const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl);95 const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl);
80 const repositoryPath = canonicalKey.split("/").slice(1).join("/");96 const repositoryPath = repositoryPathOf(canonicalKey);
81 const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0);97 const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0);
82 const [owner] = repositoryPathSegments;98 const [owner] = repositoryPathSegments;
83 const repositoryName = repositoryPathSegments.at(-1);99 const repositoryName = repositoryPathSegments.at(-1);
100 const origin = buildRepositoryOrigin(input.originUrl, canonicalKey);
84101
85 return {102 return {
86 canonicalKey,103 canonicalKey,
...@@ -94,6 +111,7 @@ function buildRepositoryIdentity(input: {...@@ -94,6 +111,7 @@ function buildRepositoryIdentity(input: {
94 ...(sourceControlProvider ? { provider: sourceControlProvider.kind } : {}),111 ...(sourceControlProvider ? { provider: sourceControlProvider.kind } : {}),
95 ...(owner ? { owner } : {}),112 ...(owner ? { owner } : {}),
96 ...(repositoryName ? { name: repositoryName } : {}),113 ...(repositoryName ? { name: repositoryName } : {}),
114 ...(origin ? { origin } : {}),
97 };115 };
98}116}
99117
...@@ -136,8 +154,11 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn(...@@ -136,8 +154,11 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn(
136 return null;154 return null;
137 }155 }
138156
139 const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout));157 const remotes = parseRemoteFetchUrls(remoteResult.value.stdout);
140 return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null;158 const remote = pickPrimaryRemote(remotes);
159 return remote
160 ? buildRepositoryIdentity({ ...remote, originUrl: remotes.get("origin"), rootPath: cacheKey })
161 : null;
141});162});
142163
143export const make = Effect.fn("RepositoryIdentityResolver.make")(function* (164export const make = Effect.fn("RepositoryIdentityResolver.make")(function* (
docs/internals/remote.md+4
...@@ -12,6 +12,10 @@ An environment keeps its ID across server restarts and endpoint changes. Saved...@@ -12,6 +12,10 @@ An environment keeps its ID across server restarts and endpoint changes. Saved
12connections are local to a client profile; the server's identity and state are12connections are local to a client profile; the server's identity and state are
13not. A repository identity can correlate clones across environments, but never13not. A repository identity can correlate clones across environments, but never
14routes work between them. A project and its threads belong to one environment.14routes work between them. A project and its threads belong to one environment.
15The canonical key follows the `upstream` remote when one exists, so pull request
16features target the repository a fork tracks. A fork also reports its own
17`origin`, and clients group and label by that, so a fork never collapses into a
18checkout of its upstream.
1519
16[Environment ID initialization](../../apps/server/src/environment/ServerEnvironment.ts)20[Environment ID initialization](../../apps/server/src/environment/ServerEnvironment.ts)
17must publish a complete ID atomically. Repair of an empty ID file retains a21must publish a complete ID atomically. Repair of an empty ID file retains a
packages/client-runtime/src/state/projectGrouping.test.ts+39
...@@ -158,6 +158,45 @@ describe("buildProjectGroups", () => {...@@ -158,6 +158,45 @@ describe("buildProjectGroups", () => {
158 );158 );
159 });159 });
160160
161 it("keeps a fork apart from its upstream checkout and labels it by its own remote", () => {
162 const fork = makeProject("fork", "/work/t3code-fork", {
163 repositoryIdentity: {
164 ...repositoryIdentity,
165 origin: {
166 canonicalKey: "github.com/julius/t3code-fork",
167 displayName: "julius/t3code-fork",
168 },
169 },
170 });
171 const forkWorktree = makeProject("fork-2", "/work/t3code-fork-2", {
172 repositoryIdentity: fork.repositoryIdentity,
173 });
174 const projects = [makeProject("t3code", "/work/t3code"), fork, forkWorktree];
175
176 const groups = buildProjectGroups({ projects, settings: settings("repository") });
177 expect(groups.map((group) => group.key)).toEqual([
178 "github.com/t3tools/t3code",
179 "github.com/julius/t3code-fork",
180 ]);
181 expect(groups[1]?.members.map((member) => member.project.id)).toEqual(["fork", "fork-2"]);
182 expect(groups[1]?.label).toBe("julius/t3code-fork");
183 });
184
185 it("labels a fork by its canonical key when its origin has no display name", () => {
186 const identity = {
187 ...repositoryIdentity,
188 origin: { canonicalKey: "internal-host" },
189 };
190 const projects = [
191 makeProject("fork", "/work/fork", { repositoryIdentity: identity }),
192 makeProject("fork-2", "/work/fork-2", { repositoryIdentity: identity }),
193 ];
194
195 const groups = buildProjectGroups({ projects, settings: settings("repository") });
196 expect(groups).toHaveLength(1);
197 expect(groups[0]?.label).toBe("internal-host");
198 });
199
161 it("keeps physical clones in separate groups when requested", () => {200 it("keeps physical clones in separate groups when requested", () => {
162 const projects = [201 const projects = [
163 makeProject("t3code", "/work/t3code"),202 makeProject("t3code", "/work/t3code"),
packages/client-runtime/src/state/projectGrouping.ts+12-6
...@@ -1,8 +1,10 @@...@@ -1,8 +1,10 @@
1import { scopedProjectKey, scopeProjectRef } from "../environment/scoped.ts";1import { scopedProjectKey, scopeProjectRef } from "../environment/scoped.ts";
2import type {2import {
3 EnvironmentId,3 repositoryGroupingDisplayNameOf,
4 ScopedProjectRef,4 repositoryGroupingKeyOf,
5 SidebarProjectGroupingMode,5 type EnvironmentId,
6 type ScopedProjectRef,
7 type SidebarProjectGroupingMode,
6} from "@t3tools/contracts";8} from "@t3tools/contracts";
7import type { ClientSettings } from "@t3tools/contracts/settings";9import type { ClientSettings } from "@t3tools/contracts/settings";
810
...@@ -100,7 +102,9 @@ function deriveRepositoryScopedKey(...@@ -100,7 +102,9 @@ function deriveRepositoryScopedKey(
100 project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">,102 project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">,
101 groupingMode: SidebarProjectGroupingMode,103 groupingMode: SidebarProjectGroupingMode,
102): string | null {104): string | null {
103 const canonicalKey = project.repositoryIdentity?.canonicalKey;105 const canonicalKey = project.repositoryIdentity
106 ? repositoryGroupingKeyOf(project.repositoryIdentity)
107 : null;
104 if (!canonicalKey) {108 if (!canonicalKey) {
105 return null;109 return null;
106 }110 }
...@@ -158,7 +162,9 @@ export function deriveProjectGroupLabel(input: {...@@ -158,7 +162,9 @@ export function deriveProjectGroupLabel(input: {
158}): string {162}): string {
159 const sharedTitles = uniqueNonEmptyValues(input.members.map((member) => member.title));163 const sharedTitles = uniqueNonEmptyValues(input.members.map((member) => member.title));
160 const sharedDisplayNames = uniqueNonEmptyValues(164 const sharedDisplayNames = uniqueNonEmptyValues(
161 input.members.map((member) => member.repositoryIdentity?.displayName),165 input.members.map((member) =>
166 member.repositoryIdentity ? repositoryGroupingDisplayNameOf(member.repositoryIdentity) : null,
167 ),
162 );168 );
163 const sharedRepositoryNames = uniqueNonEmptyValues(169 const sharedRepositoryNames = uniqueNonEmptyValues(
164 input.members.map((member) => member.repositoryIdentity?.name),170 input.members.map((member) => member.repositoryIdentity?.name),
packages/contracts/src/environment.ts+24
...@@ -229,6 +229,17 @@ export const RepositoryIdentityLocator = Schema.Struct({...@@ -229,6 +229,17 @@ export const RepositoryIdentityLocator = Schema.Struct({
229});229});
230export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type;230export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type;
231231
232/**
233 * The checkout's own remote when it names a different repository than the canonical one, such as
234 * a fork that tracks its upstream. Clients group and label by it so a fork stays distinct from the
235 * repository it forked, while pull request features keep the canonical identity.
236 */
237export const RepositoryOrigin = Schema.Struct({
238 canonicalKey: TrimmedNonEmptyString,
239 displayName: Schema.optionalKey(TrimmedNonEmptyString),
240});
241export type RepositoryOrigin = typeof RepositoryOrigin.Type;
242
232export const RepositoryIdentity = Schema.Struct({243export const RepositoryIdentity = Schema.Struct({
233 canonicalKey: TrimmedNonEmptyString,244 canonicalKey: TrimmedNonEmptyString,
234 locator: RepositoryIdentityLocator,245 locator: RepositoryIdentityLocator,
...@@ -239,9 +250,22 @@ export const RepositoryIdentity = Schema.Struct({...@@ -239,9 +250,22 @@ export const RepositoryIdentity = Schema.Struct({
239 provider: Schema.optionalKey(TrimmedNonEmptyString),250 provider: Schema.optionalKey(TrimmedNonEmptyString),
240 owner: Schema.optionalKey(TrimmedNonEmptyString),251 owner: Schema.optionalKey(TrimmedNonEmptyString),
241 name: Schema.optionalKey(TrimmedNonEmptyString),252 name: Schema.optionalKey(TrimmedNonEmptyString),
253 origin: Schema.optionalKey(RepositoryOrigin),
242});254});
243export type RepositoryIdentity = typeof RepositoryIdentity.Type;255export type RepositoryIdentity = typeof RepositoryIdentity.Type;
244256
257/** Key clients group checkouts by: a fork's own remote, otherwise the canonical repository. */
258export function repositoryGroupingKeyOf(identity: RepositoryIdentity): string {
259 return identity.origin?.canonicalKey ?? identity.canonicalKey;
260}
261
262/** Label clients show for a checkout's repository, matching `repositoryGroupingKeyOf`. */
263export function repositoryGroupingDisplayNameOf(identity: RepositoryIdentity): string | undefined {
264 return identity.origin
265 ? (identity.origin.displayName ?? identity.origin.canonicalKey)
266 : identity.displayName;
267}
268
245export const ScopedProjectRef = Schema.Struct({269export const ScopedProjectRef = Schema.Struct({
246 environmentId: EnvironmentId,270 environmentId: EnvironmentId,
247 projectId: ProjectId,271 projectId: ProjectId,