Skip to content
Merged
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
128 changes: 111 additions & 17 deletions apps/server/src/project/RepositoryIdentityResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,22 +130,20 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
});

it.effect("retries Git root discovery after the negative TTL", () => {
const calls: Array<ReadonlyArray<string>> = [];
let rootAttempts = 0;
let attempts = 0;
let unavailable = true;
const layerProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, {
run: (input) =>
Effect.sync(() => {
calls.push(input.args);
const rootLookup = input.args.includes("rev-parse");
const failed = rootLookup && rootAttempts++ === 0;
attempts++;
return {
stdout: rootLookup
? failed
? ""
: "/repo\n"
: "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n",
stderr: failed ? "temporary Git failure" : "",
code: ChildProcessSpawner.ExitCode(failed ? 1 : 0),
stdout: unavailable
? ""
: input.args.includes("remote")
? "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n"
: "/repo\n",
stderr: unavailable ? "temporary Git failure" : "",
code: ChildProcessSpawner.ExitCode(unavailable ? 1 : 0),
timedOut: false,
stdoutTruncated: false,
stderrTruncated: false,
Expand All @@ -162,22 +160,63 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
return Effect.gen(function* () {
const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
expect(yield* resolver.resolve("/repo/packages/web")).toBeNull();
const failedAttempts = attempts;
unavailable = false;
expect(yield* resolver.resolve("/repo/packages/web")).toBeNull();
expect(attempts).toBe(failedAttempts);

yield* TestClock.adjust(Duration.minutes(1));
const recovered = yield* resolver.resolve("/repo/packages/web");
expect(recovered?.rootPath).toBe("/repo");
expect(calls).toEqual([
["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"],
["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"],
["-C", "/repo", "remote", "-v"],
]);
expect(recovered?.canonicalKey).toBe("github.com/t3tools/t3code");
}).pipe(Effect.provide(Layer.merge(TestClock.layer(), layerResolver)));
});

it.effect.each(["times out", "cannot start"] as const)(
"skips the bare repository probe when Git root discovery %s",
(failure) => {
let attempts = 0;
const layerProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, {
run: (input) =>
Effect.suspend(() => {
attempts++;
return failure === "times out"
? Effect.succeed({
stdout: "",
stderr: "",
code: null,
timedOut: true,
stdoutTruncated: false,
stderrTruncated: false,
stdoutInvalidUtf8: false,
stderrInvalidUtf8: false,
})
: Effect.fail(
new ProcessRunner.ProcessSpawnError({
command: input.command,
argumentCount: input.args.length,
cause: new Error("git is not installed"),
}),
);
}),
});
const layerResolver = Layer.effect(
RepositoryIdentityResolver.RepositoryIdentityResolver,
RepositoryIdentityResolver.make(),
).pipe(Layer.provide(layerProcessRunner));

return Effect.gen(function* () {
const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
expect(yield* resolver.resolve("/repo")).toBeNull();
expect(attempts).toBe(1);
}).pipe(Effect.provide(layerResolver));
},
);

it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const cwd = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-repository-identity-test-",
});
Expand All @@ -200,6 +239,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
expect(identity?.provider).toBe("github");
expect(identity?.owner).toBe("T3Tools");
expect(identity?.name).toBe("t3code");
expect(yield* resolver.resolve(path.join(cwd, ".git"))).toBeNull();
}).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
);

Expand Down Expand Up @@ -254,6 +294,60 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
}).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
);

it.effect.each(["bare clone", "dot-bare"] as const)(
"resolves a %s to its Git directory and the same identity as a checkout",
(layout) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const checkout = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-repository-identity-checkout-",
});
const root = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-repository-identity-bare-",
});
const bareGitDir = layout === "dot-bare" ? path.join(root, ".bare") : root;

yield* git(checkout, ["init"]);
yield* git(checkout, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]);
yield* git(root, ["init", "--bare", bareGitDir]);
if (layout === "dot-bare") {
yield* fileSystem.writeFileString(path.join(root, ".git"), "gitdir: ./.bare\n");
}
yield* git(root, [
"--git-dir",
bareGitDir,
"remote",
"add",
"origin",
"https://github.com/T3Tools/t3code.git",
]);

const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const checkoutIdentity = yield* resolver.resolve(checkout);
expect(checkoutIdentity?.canonicalKey).toBe("github.com/t3tools/t3code");
const resolvedBareGitDir = NodeFS.realpathSync.native(bareGitDir);
const projectRoots = layout === "dot-bare" ? [root, bareGitDir] : [root];

for (const projectRoot of projectRoots) {
const identity = yield* resolver.resolve(projectRoot);
const resolvedIdentityRoot =
identity?.rootPath === undefined ? "" : NodeFS.realpathSync.native(identity.rootPath);

expect(identity).not.toBeNull();
expect(identity?.canonicalKey).toBe(checkoutIdentity?.canonicalKey);
expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe(
normalizeResolvedPath(resolvedBareGitDir),
);
}

yield* git(root, ["--git-dir", bareGitDir, "remote", "remove", "origin"]);
for (const projectRoot of projectRoots) {
expect(yield* resolver.resolve(projectRoot, { refresh: true })).toBeNull();
}
}).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
);

it.effect("returns null for non-git folders and repos without remotes", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
65 changes: 56 additions & 9 deletions apps/server/src/project/RepositoryIdentityResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "@t3tools/shared/git";
import * as Cache from "effect/Cache";
import * as Context from "effect/Context";
import * as Data from "effect/Data";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
Expand Down Expand Up @@ -137,39 +138,78 @@ function buildRepositoryIdentity(input: {
};
}

class RepositoryIdentityCacheKey extends Data.Class<{
readonly rootPath: string;
readonly isBare: boolean;
}> {}

const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")(
function* (cwd: string) {
const processRunner = yield* ProcessRunner.ProcessRunner;
const env = explicitBareRepositoryEnv(process.env, yield* HostProcess.Platform);

// git is a real executable on every platform — no cmd.exe shell mode, which
// would split paths containing spaces during cmd's re-tokenization.
const topLevelResult = yield* processRunner
.run({
command: "git",
args: ["-C", cwd, "rev-parse", "--show-toplevel"],
env: explicitBareRepositoryEnv(process.env, yield* HostProcess.Platform),
env,
timeoutBehavior: "timedOutResult",
})
.pipe(Effect.option);
if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) {
if (topLevelResult._tag === "None" || topLevelResult.value.timedOut) {
return null;
}
if (topLevelResult.value.code === 0) {
const rootPath = topLevelResult.value.stdout.trim();
return rootPath.length > 0
? new RepositoryIdentityCacheKey({ rootPath, isBare: false })
: null;
}

// Explicit Git directories preserve safe.bareRepository=explicit for both layouts.
for (const gitDir of [".", ".git"]) {
const bareResult = yield* processRunner
.run({
command: "git",
args: [
"-C",
cwd,
`--git-dir=${gitDir}`,
"rev-parse",
"--is-bare-repository",
"--absolute-git-dir",
],
env,
timeoutBehavior: "timedOutResult",
})
.pipe(Effect.option);
if (bareResult._tag === "None" || bareResult.value.timedOut) return null;
if (bareResult.value.code !== 0) continue;

const candidate = topLevelResult.value.stdout.trim();
return candidate.length > 0 ? candidate : null;
const [isBare, rootPath = ""] = bareResult.value.stdout
.split("\n")
.map((line) => line.trim());
// A checkout's .git directory contains remotes but is not a project root.
return isBare === "true" && rootPath.length > 0
? new RepositoryIdentityCacheKey({ rootPath, isBare: true })
: null;
}
return null;
},
);

const resolveRepositoryIdentityFromCacheKey = Effect.fn(
"RepositoryIdentityResolver.resolveFromCacheKey",
)(function* (
cacheKey: string,
cacheKey: RepositoryIdentityCacheKey,
): Effect.fn.Return<RepositoryIdentity | null, never, ProcessRunner.ProcessRunner> {
const processRunner = yield* ProcessRunner.ProcessRunner;
const remoteResult = yield* processRunner
.run({
command: "git",
args: ["-C", cacheKey, "remote", "-v"],
args: ["-C", cacheKey.rootPath, ...(cacheKey.isBare ? ["--git-dir=."] : []), "remote", "-v"],
env: explicitBareRepositoryEnv(process.env, yield* HostProcess.Platform),
timeoutBehavior: "timedOutResult",
})
Expand All @@ -181,7 +221,11 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn(
const remotes = parseRemoteFetchUrls(remoteResult.value.stdout);
const remote = pickPrimaryRemote(remotes);
return remote
? buildRepositoryIdentity({ ...remote, originUrl: remotes.get("origin"), rootPath: cacheKey })
? buildRepositoryIdentity({
...remote,
originUrl: remotes.get("origin"),
rootPath: cacheKey.rootPath,
})
: null;
});

Expand All @@ -200,15 +244,18 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* (
onFailure: () => Duration.zero,
});

const repositoryRootCache = yield* Cache.makeWith<string, string | null>(
const repositoryRootCache = yield* Cache.makeWith<string, RepositoryIdentityCacheKey | null>(
(cwd) =>
resolveRepositoryIdentityCacheKey(cwd).pipe(
Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
),
{ capacity: cacheCapacity, timeToLive },
);

const repositoryIdentityCache = yield* Cache.makeWith<string, RepositoryIdentity | null>(
const repositoryIdentityCache = yield* Cache.makeWith<
RepositoryIdentityCacheKey,
RepositoryIdentity | null
>(
(cacheKey) =>
resolveRepositoryIdentityFromCacheKey(cacheKey).pipe(
Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ export const RepositoryIdentity = Schema.Struct({
canonicalKey: TrimmedNonEmptyString,
locator: RepositoryIdentityLocator,
webUrl: Schema.optionalKey(TrimmedNonEmptyString),
/** Work tree root, or the git dir for a bare repository. */
rootPath: Schema.optionalKey(TrimmedNonEmptyString),
displayName: Schema.optionalKey(TrimmedNonEmptyString),
provider: Schema.optionalKey(TrimmedNonEmptyString),
Expand Down
Loading