Skip to content
Open
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
127 changes: 120 additions & 7 deletions apps/server/src/project/RepositoryIdentityResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,14 +135,15 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
run: (input) =>
Effect.sync(() => {
calls.push(input.args);
const rootLookup = input.args.includes("rev-parse");
const failed = rootLookup && rootAttempts++ === 0;
const bareProbe = input.args.includes("--is-bare-repository");
const rootLookup = input.args.includes("--show-toplevel");
const failed = bareProbe || (rootLookup && rootAttempts++ === 0);
return {
stdout: rootLookup
? failed
? ""
: "/repo\n"
: "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n",
stdout: failed
? ""
: rootLookup
? "/repo\n"
: "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n",
stderr: failed ? "temporary Git failure" : "",
code: ChildProcessSpawner.ExitCode(failed ? 1 : 0),
timedOut: false,
Expand All @@ -168,12 +169,54 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
expect(recovered?.rootPath).toBe("/repo");
expect(calls).toEqual([
["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"],
["-C", "/repo/packages/web", "rev-parse", "--is-bare-repository", "--absolute-git-dir"],
["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"],
["-C", "/repo", "remote", "-v"],
]);
}).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) => {
const calls: Array<ReadonlyArray<string>> = [];
const layerProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, {
run: (input) =>
Effect.suspend(() => {
calls.push(input.args);
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(calls).toEqual([["-C", "/repo", "rev-parse", "--show-toplevel"]]);
}).pipe(Effect.provide(layerResolver));
},
);

it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down Expand Up @@ -229,6 +272,70 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
}).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
);

it.effect.each(["bare clone", "dot-bare"] as const)(
"groups a %s repository with checkouts of the same remote",
(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(bareGitDir, [
"remote",
"add",
"origin",
"https://github.com/T3Tools/t3code.git",
]);

const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const checkoutIdentity = yield* resolver.resolve(checkout);
const resolvedBareGitDir = NodeFS.realpathSync.native(bareGitDir);
// Users open a dot-bare layout from its container or from .bare itself.
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),
);
}
}).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
);

it.effect("returns null for the .git folder of a checkout", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const cwd = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-repository-identity-dot-git-",
});

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

const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
expect(yield* resolver.resolve(cwd)).not.toBeNull();
expect(yield* resolver.resolve(path.join(cwd, ".git"))).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 All @@ -238,15 +345,21 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
const gitDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-repository-identity-no-remote-",
});
const bareGitDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-repository-identity-bare-no-remote-",
});

yield* git(gitDir, ["init"]);
yield* git(bareGitDir, ["init", "--bare"]);

const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const nonGitIdentity = yield* resolver.resolve(nonGitDir);
const noRemoteIdentity = yield* resolver.resolve(gitDir);
const bareNoRemoteIdentity = yield* resolver.resolve(bareGitDir);

expect(nonGitIdentity).toBeNull();
expect(noRemoteIdentity).toBeNull();
expect(bareNoRemoteIdentity).toBeNull();
}).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
);

Expand Down
35 changes: 25 additions & 10 deletions apps/server/src/project/RepositoryIdentityResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,19 +121,34 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.

// 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"],
timeoutBehavior: "timedOutResult",
})
.pipe(Effect.option);
if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) {
const revParse = (...flags: ReadonlyArray<string>) =>
processRunner
.run({
command: "git",
args: ["-C", cwd, "rev-parse", ...flags],
timeoutBehavior: "timedOutResult",
})
.pipe(Effect.option);

const topLevelResult = yield* revParse("--show-toplevel");
// Skip the bare probe after a timeout or spawn failure; it would likely fail the same way.
if (topLevelResult._tag === "None" || topLevelResult.value.timedOut) {
return null;
}
if (topLevelResult.value.code === 0) {
const candidate = topLevelResult.value.stdout.trim();
return candidate.length > 0 ? candidate : null;
}

const candidate = topLevelResult.value.stdout.trim();
return candidate.length > 0 ? candidate : null;
// A bare repository has no work tree, so its git dir is the key; `git remote
// -v` works there. The "true" check keeps a checkout's .git folder null.
// Output: "true\n/home/me/t3code/.bare\n" -> "/home/me/t3code/.bare"
const bareResult = yield* revParse("--is-bare-repository", "--absolute-git-dir");
if (bareResult._tag === "None" || bareResult.value.code !== 0) {
return null;
}
const [isBare, gitDir = ""] = bareResult.value.stdout.split("\n").map((line) => line.trim());
return isBare === "true" && gitDir.length > 0 ? gitDir : null;
},
);

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 @@ -250,6 +250,7 @@ export const RepositoryIdentity = Schema.Struct({
locator: RepositoryIdentityLocator,
/** Repository browser URL resolved from the server's configured hosting account. */
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