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 {
1616 DEFAULT_RUNTIME_MODE,
1717 DEFAULT_SERVER_SETTINGS,
1818 MessageId,
19 repositoryGroupingKeyOf,
1920 T3_PROJECT_FILE_NAME,
2021 ThreadId,
2122} from "@t3tools/contracts";
......@@ -393,7 +394,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
393394 // whatever unrelated project happens to be first on the other machine. Repository
394395 // identity is the primary signal; projects that haven't reported one yet (still
395396 // 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;
397400 // `|| null` (not `??`): a pending-task placeholder project can have an empty
398401 // workspaceRoot, and an "" basename would reject every real host below.
399402 const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null;
......@@ -414,7 +417,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
414417 if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) {
415418 return true;
416419 }
417 const projectKey = project.repositoryIdentity?.canonicalKey ?? null;
420 const projectKey = project.repositoryIdentity
421 ? repositoryGroupingKeyOf(project.repositoryIdentity)
422 : null;
418423 if (selectedRepositoryKey !== null && projectKey !== null) {
419424 return projectKey === selectedRepositoryKey;
420425 }
apps/mobile/src/features/threads/new-task-project-selection.ts+11-4
......@@ -1,5 +1,5 @@
11import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
2import type { EnvironmentId } from "@t3tools/contracts";
2import { repositoryGroupingKeyOf, type EnvironmentId } from "@t3tools/contracts";
33
44import { scopedProjectKey } from "../../lib/scopedEntities";
55import type { HomeProjectScope } from "../home/homeThreadList";
......@@ -56,7 +56,9 @@ export function resolveEnvironmentProjectMatch(
5656 projectsOnTarget: ReadonlyArray<EnvironmentProject>,
5757 selectedProject: EnvironmentProject | null,
5858): EnvironmentProject | null {
59 const repositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null;
59 const repositoryKey = selectedProject?.repositoryIdentity
60 ? repositoryGroupingKeyOf(selectedProject.repositoryIdentity)
61 : null;
6062 // `|| null` (not `??`): a pending-task placeholder project can have an empty
6163 // workspaceRoot, and an "" basename would match nothing meaningful.
6264 const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null;
......@@ -64,13 +66,18 @@ export function resolveEnvironmentProjectMatch(
6466 // side; two known, different repositories never match on a shared basename
6567 // or title (mirrors the environment list filter in the new-task flow).
6668 const isKnownMismatch = (project: EnvironmentProject) => {
67 const projectKey = project.repositoryIdentity?.canonicalKey ?? null;
69 const projectKey = project.repositoryIdentity
70 ? repositoryGroupingKeyOf(project.repositoryIdentity)
71 : null;
6872 return repositoryKey !== null && projectKey !== null && projectKey !== repositoryKey;
6973 };
7074 return (
7175 (repositoryKey !== null
7276 ? projectsOnTarget.find(
73 (project) => (project.repositoryIdentity?.canonicalKey ?? null) === repositoryKey,
77 (project) =>
78 (project.repositoryIdentity
79 ? repositoryGroupingKeyOf(project.repositoryIdentity)
80 : null) === repositoryKey,
7481 )
7582 : undefined) ??
7683 (workspaceBasename !== null
apps/server/src/project/RepositoryIdentityResolver.test.ts+23
......@@ -288,6 +288,29 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
288288 }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
289289 );
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
291314 it.effect("uses the last remote path segment as the repository name for nested groups", () =>
292315 Effect.gen(function* () {
293316 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/project/RepositoryIdentityResolver.ts+24-3
......@@ -70,17 +70,34 @@ function pickPrimaryRemote(
7070 return remoteName && remoteUrl ? { remoteName, remoteUrl } : null;
7171}
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
7388function buildRepositoryIdentity(input: {
7489 readonly remoteName: string;
7590 readonly remoteUrl: string;
91 readonly originUrl: string | undefined;
7692 readonly rootPath: string;
7793}): RepositoryIdentity {
7894 const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl);
7995 const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl);
80 const repositoryPath = canonicalKey.split("/").slice(1).join("/");
96 const repositoryPath = repositoryPathOf(canonicalKey);
8197 const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0);
8298 const [owner] = repositoryPathSegments;
8399 const repositoryName = repositoryPathSegments.at(-1);
100 const origin = buildRepositoryOrigin(input.originUrl, canonicalKey);
84101
85102 return {
86103 canonicalKey,
......@@ -94,6 +111,7 @@ function buildRepositoryIdentity(input: {
94111 ...(sourceControlProvider ? { provider: sourceControlProvider.kind } : {}),
95112 ...(owner ? { owner } : {}),
96113 ...(repositoryName ? { name: repositoryName } : {}),
114 ...(origin ? { origin } : {}),
97115 };
98116}
99117
......@@ -136,8 +154,11 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn(
136154 return null;
137155 }
138156
139 const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout));
140 return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null;
157 const remotes = parseRemoteFetchUrls(remoteResult.value.stdout);
158 const remote = pickPrimaryRemote(remotes);
159 return remote
160 ? buildRepositoryIdentity({ ...remote, originUrl: remotes.get("origin"), rootPath: cacheKey })
161 : null;
141162});
142163
143164export 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
1212connections are local to a client profile; the server's identity and state are
1313not. A repository identity can correlate clones across environments, but never
1414routes 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
1620[Environment ID initialization](../../apps/server/src/environment/ServerEnvironment.ts)
1721must 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", () => {
158158 );
159159 });
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
161200 it("keeps physical clones in separate groups when requested", () => {
162201 const projects = [
163202 makeProject("t3code", "/work/t3code"),
packages/client-runtime/src/state/projectGrouping.ts+12-6
......@@ -1,8 +1,10 @@
11import { scopedProjectKey, scopeProjectRef } from "../environment/scoped.ts";
2import type {
3 EnvironmentId,
4 ScopedProjectRef,
5 SidebarProjectGroupingMode,
2import {
3 repositoryGroupingDisplayNameOf,
4 repositoryGroupingKeyOf,
5 type EnvironmentId,
6 type ScopedProjectRef,
7 type SidebarProjectGroupingMode,
68} from "@t3tools/contracts";
79import type { ClientSettings } from "@t3tools/contracts/settings";
810
......@@ -100,7 +102,9 @@ function deriveRepositoryScopedKey(
100102 project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">,
101103 groupingMode: SidebarProjectGroupingMode,
102104): string | null {
103 const canonicalKey = project.repositoryIdentity?.canonicalKey;
105 const canonicalKey = project.repositoryIdentity
106 ? repositoryGroupingKeyOf(project.repositoryIdentity)
107 : null;
104108 if (!canonicalKey) {
105109 return null;
106110 }
......@@ -158,7 +162,9 @@ export function deriveProjectGroupLabel(input: {
158162}): string {
159163 const sharedTitles = uniqueNonEmptyValues(input.members.map((member) => member.title));
160164 const sharedDisplayNames = uniqueNonEmptyValues(
161 input.members.map((member) => member.repositoryIdentity?.displayName),
165 input.members.map((member) =>
166 member.repositoryIdentity ? repositoryGroupingDisplayNameOf(member.repositoryIdentity) : null,
167 ),
162168 );
163169 const sharedRepositoryNames = uniqueNonEmptyValues(
164170 input.members.map((member) => member.repositoryIdentity?.name),
packages/contracts/src/environment.ts+24
......@@ -229,6 +229,17 @@ export const RepositoryIdentityLocator = Schema.Struct({
229229});
230230export 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
232243export const RepositoryIdentity = Schema.Struct({
233244 canonicalKey: TrimmedNonEmptyString,
234245 locator: RepositoryIdentityLocator,
......@@ -239,9 +250,22 @@ export const RepositoryIdentity = Schema.Struct({
239250 provider: Schema.optionalKey(TrimmedNonEmptyString),
240251 owner: Schema.optionalKey(TrimmedNonEmptyString),
241252 name: Schema.optionalKey(TrimmedNonEmptyString),
253 origin: Schema.optionalKey(RepositoryOrigin),
242254});
243255export 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
245269export const ScopedProjectRef = Schema.Struct({
246270 environmentId: EnvironmentId,
247271 projectId: ProjectId,