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
4 changes: 2 additions & 2 deletions apps/server/src/cli/invocation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ it.layer(NodeServices.layer)("manual server installation ownership", (it) => {
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped();
const root = yield* fs.makeTempDirectoryScoped().pipe(Effect.flatMap(fs.realPath));
const prefix = path.join(root, "bunx-tools");
const packageRoot = path.join(prefix, "lib/node_modules/t3");
const entry = path.join(packageRoot, "dist/bin.mjs");
Expand Down Expand Up @@ -158,7 +158,7 @@ it.layer(NodeServices.layer)("manual server installation ownership", (it) => {
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped();
const root = yield* fs.makeTempDirectoryScoped().pipe(Effect.flatMap(fs.realPath));
const prefix = path.join(root, "bunx-tools");
const packageRoot = path.join(prefix, "lib/node_modules/t3");
const launcher = path.join(packageRoot, "bin/t3.js");
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/environment/ServerEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
it.effect("publishes proven install ownership only for manually updated servers", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const baseDir = yield* fs.makeTempDirectoryScoped();
const baseDir = yield* fs.makeTempDirectoryScoped().pipe(Effect.flatMap(fs.realPath));
const prefix = `${baseDir}/node`;
const entry = `${prefix}/lib/node_modules/t3/dist/bin.mjs`;
yield* fs.makeDirectory(`${prefix}/lib/node_modules/t3/dist`, { recursive: true });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ layer("055_OrchestrationV2", (it) => {
Effect.sync(() => {
assert.deepStrictEqual(
migrationEntries.map(([id]) => id),
Array.from({ length: 57 }, (_, index) => index + 1),
Array.from({ length: 56 }, (_, index) => index + 1),
);
}),
);
Expand All @@ -28,7 +28,6 @@ layer("055_OrchestrationV2", (it) => {
[54, "ProjectionThreadsAutoSettleDisabledAt"],
[55, "OrchestrationV2"],
[56, "RemoveRedundantProjectionIndexes"],
[57, "ThreadCreatorEvidenceIndex"],
]);
assert.deepStrictEqual(yield* runMigrations(), []);

Expand All @@ -51,7 +50,6 @@ layer("055_OrchestrationV2", (it) => {
{ migration_id: 54, name: "ProjectionThreadsAutoSettleDisabledAt" },
{ migration_id: 55, name: "OrchestrationV2" },
{ migration_id: 56, name: "RemoveRedundantProjectionIndexes" },
{ migration_id: 57, name: "ThreadCreatorEvidenceIndex" },
]);

const tables = yield* sql<{ readonly name: string }>`
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/persistence/initializeV2Database.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ it.effect(
const seed = Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* runMigrations({ toMigrationInclusive: 52 });
yield* runLastCodeMigrations();
yield* runLastCodeMigrations({ toMigrationInclusive: 11 });
yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at)
VALUES ('project', 'Project', '/tmp/project', '[]', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')`;
yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, created_at, updated_at)
Expand Down Expand Up @@ -110,6 +110,10 @@ it.effect(
v1.prepare("SELECT MAX(migration_id) AS id FROM effect_sql_migrations").get()?.id,
52,
);
assert.equal(
v1.prepare("SELECT MAX(migration_id) AS id FROM lastcode_sql_migrations").get()?.id,
11,
);
assert.equal(
v1
.prepare(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ describe("V2 preview upgrade", () => {
[53, "PullRequestFilesViewed"],
[54, "ProjectionThreadsAutoSettleDisabledAt"],
[56, "RemoveRedundantProjectionIndexes"],
[57, "ThreadCreatorEvidenceIndex"],
]);
assert.deepStrictEqual(yield* runMigrations(), []);
assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports);
Expand Down Expand Up @@ -117,7 +116,6 @@ describe("V2 preview upgrade", () => {
[53, "PullRequestFilesViewed"],
[54, "ProjectionThreadsAutoSettleDisabledAt"],
[56, "RemoveRedundantProjectionIndexes"],
[57, "ThreadCreatorEvidenceIndex"],
]);
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
);
Expand Down
6 changes: 3 additions & 3 deletions docs/lastcode/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,9 @@ Local CI has three independent Git-safety boundaries:
shared Git config and snapshots its repository-wide settings. It checks the
value, protected settings, and common Git directory again on every exit,
including failed CI runs. Any protected change fails the gate with the config
path to inspect. Existing per-branch settings are preserved too, while new
branch keys are allowed because T3 and GitHub CLI legitimately add
branch/worktree bookkeeping while CI runs in another worktree.
path to inspect. Settings for existing branches stay protected. New
branch keys, and removed keys after a branch is renamed or deleted, are allowed
because other worktrees can legitimately change branch bookkeeping during CI.

These guards protect the primary checkout and every linked worktree, which all
share the same Git config. They specifically prevent temporary-repository tests
Expand Down
104 changes: 96 additions & 8 deletions scripts/lastcode-local-ci.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,33 @@ import {
writeVerifiedQuickCiReceipt,
} from "./lastcode-local-ci.ts";

function createIntegrityRepository(): string {
const repository = NodeFS.mkdtempSync(
NodePath.join(NodeOS.tmpdir(), "lastcode-integrity-branch-test-"),
);
NodeChildProcess.execFileSync("git", ["init", "--quiet", "--initial-branch", "main"], {
cwd: repository,
});
NodeChildProcess.execFileSync(
"git",
[
"-c",
"user.name=Integrity Test",
"-c",
"user.email=integrity-test@example.invalid",
"-c",
"commit.gpgsign=false",
"commit",
"--quiet",
"--allow-empty",
"-m",
"initial commit",
],
{ cwd: repository },
);
return repository;
}

describe("lastcode-local-ci", () => {
it("formats concise final summaries for resumable output", () => {
expect(formatLocalCiSummary("full", "abc123")).toBe(
Expand Down Expand Up @@ -317,14 +344,10 @@ describe("lastcode-local-ci", () => {
});

it("rejects changes to branch settings that existed when CI started", () => {
const repository = NodeFS.mkdtempSync(
NodePath.join(NodeOS.tmpdir(), "lastcode-integrity-existing-branch-test-"),
);
NodeChildProcess.execFileSync(
"git",
["init", "--quiet", "--initial-branch", "lastcode/userland-build"],
{ cwd: repository },
);
const repository = createIntegrityRepository();
NodeChildProcess.execFileSync("git", ["branch", "lastcode/userland-build"], {
cwd: repository,
});
NodeChildProcess.execFileSync(
"git",
["config", "branch.lastcode/userland-build.remote", "origin"],
Expand All @@ -344,6 +367,71 @@ describe("lastcode-local-ci", () => {
NodeFS.rmSync(repository, { recursive: true, force: true });
});

it.each(["rename", "delete"] as const)(
"allows sibling branch config removal after a real branch %s",
(operation) => {
const repository = createIntegrityRepository();
const branch = "feature/sibling.v1";
NodeChildProcess.execFileSync("git", ["branch", branch], { cwd: repository });
NodeChildProcess.execFileSync("git", ["config", `branch.${branch}.gh-merge-base`, "main"], {
cwd: repository,
});
NodeChildProcess.execFileSync("git", ["config", `branch.${branch}.remote`, "origin"], {
cwd: repository,
});
const snapshot = captureRepositoryIntegrity(repository);

NodeChildProcess.execFileSync(
"git",
operation === "rename"
? ["branch", "-m", branch, "feature/renamed.v1"]
: ["branch", "-D", branch],
{ cwd: repository },
);

expect(() => assertRepositoryIntegrity(repository, snapshot)).not.toThrow();
NodeFS.rmSync(repository, { recursive: true, force: true });
},
);

it.each(["existing", "orphan"] as const)("rejects removal of an %s branch config key", (kind) => {
const repository = createIntegrityRepository();
const branch = "feature/protected.v1";
if (kind === "existing") {
NodeChildProcess.execFileSync("git", ["branch", branch], { cwd: repository });
}
const key = `branch.${branch}.gh-merge-base`;
NodeChildProcess.execFileSync("git", ["config", key, "main"], { cwd: repository });
const snapshot = captureRepositoryIntegrity(repository);

NodeChildProcess.execFileSync("git", ["config", "--unset", key], { cwd: repository });

expect(() => assertRepositoryIntegrity(repository, snapshot)).toThrow(
`existing branch setting ${key}`,
);
NodeFS.rmSync(repository, { recursive: true, force: true });
});

it("rejects changed config values even after their local branch disappears", () => {
const repository = createIntegrityRepository();
const branch = "feature/removed.v1";
const key = `branch.${branch}.gh-merge-base`;
NodeChildProcess.execFileSync("git", ["branch", branch], { cwd: repository });
NodeChildProcess.execFileSync("git", ["config", key, "main"], { cwd: repository });
const snapshot = captureRepositoryIntegrity(repository);

NodeChildProcess.execFileSync("git", ["update-ref", "-d", `refs/heads/${branch}`], {
cwd: repository,
});
expect(() => assertRepositoryIntegrity(repository, snapshot)).not.toThrow();
NodeChildProcess.execFileSync("git", ["config", key, "other-base"], { cwd: repository });

expect(() => assertRepositoryIntegrity(repository, snapshot)).toThrow(
`existing branch setting ${key}`,
);
NodeFS.rmSync(repository, { recursive: true, force: true });
});

it("rejects reordering protected multivalue settings", () => {
const repository = NodeFS.mkdtempSync(
NodePath.join(NodeOS.tmpdir(), "lastcode-integrity-config-order-test-"),
Expand Down
20 changes: 20 additions & 0 deletions scripts/lastcode-local-ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export interface RepositoryIntegritySnapshot {
readonly branchConfig: Readonly<Record<string, ReadonlyArray<string>>>;
readonly commonGitDir: string;
readonly configPath: string;
readonly localBranches: ReadonlySet<string>;
readonly protectedConfig: string;
}

Expand Down Expand Up @@ -537,6 +538,14 @@ function readBranchConfig(
return config;
}

function readLocalBranches(repoRoot: string): ReadonlySet<string> {
return new Set(
runGit(repoRoot, ["for-each-ref", "--format=%(refname:strip=2)", "refs/heads/"])
.split("\n")
.filter((branch) => branch.length > 0),
);
}

export function captureRepositoryIntegrity(repoRoot: string): RepositoryIntegritySnapshot {
const commonGitDir = resolveCommonGitDir(repoRoot);
const configPath = NodePath.join(commonGitDir, "config");
Expand All @@ -551,6 +560,7 @@ export function captureRepositoryIntegrity(repoRoot: string): RepositoryIntegrit
branchConfig: readBranchConfig(configEntries),
commonGitDir,
configPath,
localBranches: readLocalBranches(repoRoot),
protectedConfig: readProtectedConfig(configEntries),
};
}
Expand Down Expand Up @@ -580,8 +590,18 @@ export function assertRepositoryIntegrity(
);
}
const branchConfig = readBranchConfig(configEntries);
const localBranches = readLocalBranches(repoRoot);
for (const [key, values] of Object.entries(before.branchConfig)) {
if (JSON.stringify(branchConfig[key]) !== JSON.stringify(values)) {
const branch = key.slice("branch.".length, key.lastIndexOf("."));
// Renaming or deleting a sibling branch removes its shared config keys.
if (
branchConfig[key] === undefined &&
before.localBranches.has(branch) &&
!localBranches.has(branch)
) {
continue;
}
throw new Error(
`Shared repository integrity changed during local CI: existing branch setting ${key} in ${before.configPath} was modified. Stop and inspect the config before continuing.`,
);
Expand Down
3 changes: 3 additions & 0 deletions scripts/lastcode-migration-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ export function validateInstallableMigrations(input: {
"test",
"run",
"apps/server/src/persistence/DatabaseMigrations.test.ts",
"apps/server/src/persistence/initializeV2Database.test.ts",
"apps/server/src/persistence/reconcileV2PreviewMigration.test.ts",
"apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts",
]);
if (
run(checkout, "git", ["rev-parse", "HEAD"]) !== candidateRef ||
Expand Down