Skip to content
Closed
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
9 changes: 7 additions & 2 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
DEFAULT_RUNTIME_MODE,
DEFAULT_SERVER_SETTINGS,
MessageId,
repositoryGroupingKeyOf,
T3_PROJECT_FILE_NAME,
ThreadId,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -393,7 +394,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
// whatever unrelated project happens to be first on the other machine. Repository
// identity is the primary signal; projects that haven't reported one yet (still
// indexing) fall back to workspace basename / title so a valid host isn't hidden.
const selectedRepositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null;
const selectedRepositoryKey = selectedProject?.repositoryIdentity
? repositoryGroupingKeyOf(selectedProject.repositoryIdentity)
: null;
// `|| null` (not `??`): a pending-task placeholder project can have an empty
// workspaceRoot, and an "" basename would reject every real host below.
const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null;
Expand All @@ -414,7 +417,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) {
return true;
}
const projectKey = project.repositoryIdentity?.canonicalKey ?? null;
const projectKey = project.repositoryIdentity
? repositoryGroupingKeyOf(project.repositoryIdentity)
: null;
if (selectedRepositoryKey !== null && projectKey !== null) {
return projectKey === selectedRepositoryKey;
}
Expand Down
15 changes: 11 additions & 4 deletions apps/mobile/src/features/threads/new-task-project-selection.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import type { EnvironmentId } from "@t3tools/contracts";
import { repositoryGroupingKeyOf, type EnvironmentId } from "@t3tools/contracts";

import { scopedProjectKey } from "../../lib/scopedEntities";
import type { HomeProjectScope } from "../home/homeThreadList";
Expand Down Expand Up @@ -56,21 +56,28 @@ export function resolveEnvironmentProjectMatch(
projectsOnTarget: ReadonlyArray<EnvironmentProject>,
selectedProject: EnvironmentProject | null,
): EnvironmentProject | null {
const repositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null;
const repositoryKey = selectedProject?.repositoryIdentity
? repositoryGroupingKeyOf(selectedProject.repositoryIdentity)
: null;
// `|| null` (not `??`): a pending-task placeholder project can have an empty
// workspaceRoot, and an "" basename would match nothing meaningful.
const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null;
// The weaker signals only apply where identity is unknown on at least one
// side; two known, different repositories never match on a shared basename
// or title (mirrors the environment list filter in the new-task flow).
const isKnownMismatch = (project: EnvironmentProject) => {
const projectKey = project.repositoryIdentity?.canonicalKey ?? null;
const projectKey = project.repositoryIdentity
? repositoryGroupingKeyOf(project.repositoryIdentity)
: null;
return repositoryKey !== null && projectKey !== null && projectKey !== repositoryKey;
};
return (
(repositoryKey !== null
? projectsOnTarget.find(
(project) => (project.repositoryIdentity?.canonicalKey ?? null) === repositoryKey,
(project) =>
(project.repositoryIdentity
? repositoryGroupingKeyOf(project.repositoryIdentity)
: null) === repositoryKey,
)
: undefined) ??
(workspaceBasename !== null
Expand Down
23 changes: 23 additions & 0 deletions apps/server/src/project/RepositoryIdentityResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,29 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
}).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
);

it.effect("reports a fork's own remote as origin next to the upstream identity", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const cwd = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-repository-identity-fork-test-",
});

yield* git(cwd, ["init"]);
yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code-fork.git"]);
yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]);

const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const identity = yield* resolver.resolve(cwd);

expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
expect(identity?.displayName).toBe("t3tools/t3code");
expect(identity?.origin).toEqual({
canonicalKey: "github.com/julius/t3code-fork",
displayName: "julius/t3code-fork",
});
}).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
);

it.effect("uses the last remote path segment as the repository name for nested groups", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
27 changes: 24 additions & 3 deletions apps/server/src/project/RepositoryIdentityResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,34 @@ function pickPrimaryRemote(
return remoteName && remoteUrl ? { remoteName, remoteUrl } : null;
}

function repositoryPathOf(canonicalKey: string): string {
return canonicalKey.split("/").slice(1).join("/");
}

function buildRepositoryOrigin(
originUrl: string | undefined,
canonicalKey: string,
): RepositoryIdentity["origin"] {
if (!originUrl) return undefined;
const originKey = normalizeGitRemoteUrl(originUrl);
if (originKey === canonicalKey) return undefined;
const displayName = repositoryPathOf(originKey);
return { canonicalKey: originKey, ...(displayName ? { displayName } : {}) };
}

function buildRepositoryIdentity(input: {
readonly remoteName: string;
readonly remoteUrl: string;
readonly originUrl: string | undefined;
readonly rootPath: string;
}): RepositoryIdentity {
const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl);
const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl);
const repositoryPath = canonicalKey.split("/").slice(1).join("/");
const repositoryPath = repositoryPathOf(canonicalKey);
const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0);
const [owner] = repositoryPathSegments;
const repositoryName = repositoryPathSegments.at(-1);
const origin = buildRepositoryOrigin(input.originUrl, canonicalKey);

return {
canonicalKey,
Expand All @@ -94,6 +111,7 @@ function buildRepositoryIdentity(input: {
...(sourceControlProvider ? { provider: sourceControlProvider.kind } : {}),
...(owner ? { owner } : {}),
...(repositoryName ? { name: repositoryName } : {}),
...(origin ? { origin } : {}),
};
}

Expand Down Expand Up @@ -136,8 +154,11 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn(
return null;
}

const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout));
return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null;
const remotes = parseRemoteFetchUrls(remoteResult.value.stdout);
const remote = pickPrimaryRemote(remotes);
return remote
? buildRepositoryIdentity({ ...remote, originUrl: remotes.get("origin"), rootPath: cacheKey })
: null;
});

export const make = Effect.fn("RepositoryIdentityResolver.make")(function* (
Expand Down
4 changes: 4 additions & 0 deletions docs/internals/remote.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ An environment keeps its ID across server restarts and endpoint changes. Saved
connections are local to a client profile; the server's identity and state are
not. A repository identity can correlate clones across environments, but never
routes work between them. A project and its threads belong to one environment.
The canonical key follows the `upstream` remote when one exists, so pull request
features target the repository a fork tracks. A fork also reports its own
`origin`, and clients group and label by that, so a fork never collapses into a
checkout of its upstream.

[Environment ID initialization](../../apps/server/src/environment/ServerEnvironment.ts)
must publish a complete ID atomically. Repair of an empty ID file retains a
Expand Down
39 changes: 39 additions & 0 deletions packages/client-runtime/src/state/projectGrouping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,45 @@ describe("buildProjectGroups", () => {
);
});

it("keeps a fork apart from its upstream checkout and labels it by its own remote", () => {
const fork = makeProject("fork", "/work/t3code-fork", {
repositoryIdentity: {
...repositoryIdentity,
origin: {
canonicalKey: "github.com/julius/t3code-fork",
displayName: "julius/t3code-fork",
},
},
});
const forkWorktree = makeProject("fork-2", "/work/t3code-fork-2", {
repositoryIdentity: fork.repositoryIdentity,
});
const projects = [makeProject("t3code", "/work/t3code"), fork, forkWorktree];

const groups = buildProjectGroups({ projects, settings: settings("repository") });
expect(groups.map((group) => group.key)).toEqual([
"github.com/t3tools/t3code",
"github.com/julius/t3code-fork",
]);
expect(groups[1]?.members.map((member) => member.project.id)).toEqual(["fork", "fork-2"]);
expect(groups[1]?.label).toBe("julius/t3code-fork");
});

it("labels a fork by its canonical key when its origin has no display name", () => {
const identity = {
...repositoryIdentity,
origin: { canonicalKey: "internal-host" },
};
const projects = [
makeProject("fork", "/work/fork", { repositoryIdentity: identity }),
makeProject("fork-2", "/work/fork-2", { repositoryIdentity: identity }),
];

const groups = buildProjectGroups({ projects, settings: settings("repository") });
expect(groups).toHaveLength(1);
expect(groups[0]?.label).toBe("internal-host");
});

it("keeps physical clones in separate groups when requested", () => {
const projects = [
makeProject("t3code", "/work/t3code"),
Expand Down
18 changes: 12 additions & 6 deletions packages/client-runtime/src/state/projectGrouping.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { scopedProjectKey, scopeProjectRef } from "../environment/scoped.ts";
import type {
EnvironmentId,
ScopedProjectRef,
SidebarProjectGroupingMode,
import {
repositoryGroupingDisplayNameOf,
repositoryGroupingKeyOf,
type EnvironmentId,
type ScopedProjectRef,
type SidebarProjectGroupingMode,
} from "@t3tools/contracts";
import type { ClientSettings } from "@t3tools/contracts/settings";

Expand Down Expand Up @@ -100,7 +102,9 @@ function deriveRepositoryScopedKey(
project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">,
groupingMode: SidebarProjectGroupingMode,
): string | null {
const canonicalKey = project.repositoryIdentity?.canonicalKey;
const canonicalKey = project.repositoryIdentity
? repositoryGroupingKeyOf(project.repositoryIdentity)
: null;
if (!canonicalKey) {
return null;
}
Expand Down Expand Up @@ -158,7 +162,9 @@ export function deriveProjectGroupLabel(input: {
}): string {
const sharedTitles = uniqueNonEmptyValues(input.members.map((member) => member.title));
const sharedDisplayNames = uniqueNonEmptyValues(
input.members.map((member) => member.repositoryIdentity?.displayName),
input.members.map((member) =>
member.repositoryIdentity ? repositoryGroupingDisplayNameOf(member.repositoryIdentity) : null,
),
);
const sharedRepositoryNames = uniqueNonEmptyValues(
input.members.map((member) => member.repositoryIdentity?.name),
Expand Down
24 changes: 24 additions & 0 deletions packages/contracts/src/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,17 @@ export const RepositoryIdentityLocator = Schema.Struct({
});
export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type;

/**
* The checkout's own remote when it names a different repository than the canonical one, such as
* a fork that tracks its upstream. Clients group and label by it so a fork stays distinct from the
* repository it forked, while pull request features keep the canonical identity.
*/
export const RepositoryOrigin = Schema.Struct({
canonicalKey: TrimmedNonEmptyString,
displayName: Schema.optionalKey(TrimmedNonEmptyString),
});
export type RepositoryOrigin = typeof RepositoryOrigin.Type;

export const RepositoryIdentity = Schema.Struct({
canonicalKey: TrimmedNonEmptyString,
locator: RepositoryIdentityLocator,
Expand All @@ -239,9 +250,22 @@ export const RepositoryIdentity = Schema.Struct({
provider: Schema.optionalKey(TrimmedNonEmptyString),
owner: Schema.optionalKey(TrimmedNonEmptyString),
name: Schema.optionalKey(TrimmedNonEmptyString),
origin: Schema.optionalKey(RepositoryOrigin),
});
export type RepositoryIdentity = typeof RepositoryIdentity.Type;

/** Key clients group checkouts by: a fork's own remote, otherwise the canonical repository. */
export function repositoryGroupingKeyOf(identity: RepositoryIdentity): string {
return identity.origin?.canonicalKey ?? identity.canonicalKey;
}

/** Label clients show for a checkout's repository, matching `repositoryGroupingKeyOf`. */
export function repositoryGroupingDisplayNameOf(identity: RepositoryIdentity): string | undefined {
return identity.origin
? (identity.origin.displayName ?? identity.origin.canonicalKey)
: identity.displayName;
}

export const ScopedProjectRef = Schema.Struct({
environmentId: EnvironmentId,
projectId: ProjectId,
Expand Down
Loading