fix: preserve saved snapshot during source replacement

This commit is contained in:
Thomas Durieux committed 2026-11-02 22:10:28 +00:00
1 parent e96d235cc7
commit abfe153cdc
2 files changed
+54 -8

No files matched your search

+13 -8
View File
@@ -529,11 +529,14 @@ router.post(
const sourceChanged = reconnecting || hasRepositorySourceChanged(repo.model, repoUpdate);
const previousAccessRevision = repo.model.githubAccess?.revision;
updateRepoModel(repo.model, repoUpdate);
const reactivating = shouldReactivateInactiveRepository(repo.model);
// Cleanup must use the persisted snapshot. Keep proposed settings separate
// until removal has finished under the original source and access fences.
const proposed = new AnonymizedRepositoryModel(repo.model.toObject());
updateRepoModel(proposed, repoUpdate);
const reactivating = shouldReactivateInactiveRepository(proposed);
if (reactivating) {
repo.model.anonymizeDate = new Date();
proposed.anonymizeDate = new Date();
}
if (sourceChanged) {
@@ -570,15 +573,17 @@ router.post(
const commit = await repository.getCommitInfo(repoUpdate.source.commit, {
accessToken: sourceAccess.token,
});
if (reconnecting) repo.model.source.commitDate = commit.commit.committer?.date
if (reconnecting) proposed.source.commitDate = commit.commit.committer?.date
? new Date(commit.commit.committer.date) : undefined;
repo.model.githubAccess = { ...sourceAccess.binding, revision: randomUUID() };
repo.model.source.repositoryId = repository.model.id;
repo.model.source.repositoryName =
proposed.githubAccess = { ...sourceAccess.binding, revision: randomUUID() };
proposed.source.repositoryId = repository.model.id;
proposed.source.repositoryName =
repository.fullName || repoUpdate.fullName;
repo.model.anonymizeDate = new Date();
proposed.anonymizeDate = new Date();
await repo.remove({ accessRevision: previousAccessRevision });
}
repo.model.set({ options: proposed.options, source: proposed.source,
githubAccess: proposed.githubAccess, anonymizeDate: proposed.anonymizeDate });
const removeRepoFromConference = async (conferenceID: string) => {
const conf = await ConferenceModel.findOne({
+41
View File
@@ -293,6 +293,47 @@ const uri = process.env.PERFORMANCE_MONGO_URI;
expect(await File.countDocuments({ repoId: row.repoId })).to.equal(0);
});
for (const reconnecting of [false, true]) it(`saves a ${reconnecting ? "reconnected repository" : "changed source"} through real removal`, async () => {
const row = await repository({ status: "ready", treeGeneration: "old", pathIndexKey: "old-key",
githubAccess: { kind: "oauth", revision: "old-access" }, anonymizeDate: new Date(1),
options: { terms: ["before"], expirationMode: "never" } }), repo = new Repository(row);
await rows(2); await Path.collection.insertOne({ repoId: row.repoId, key: "old-key", anonymousPath: "old.txt" });
const utils = require("../src/server/routes/route-utils"), app = require("../src/core/github-app");
const source = require("../src/core/source/GitHubRepository"), queue = require("../src/queue");
const router = require("../src/server/routes/repository-private").default;
const handler = router.stack.find(layer => layer.route?.path === "/:repoId/" && layer.route.methods.post).route.stack[0].handle;
const restores = [], jobs = []; let response, cleanupSnapshot, removals = 0;
const stub = (object, key, value) => { const original = object[key]; restores.push(() => { object[key] = original; }); object[key] = value; };
stub(utils, "getRepo", async () => repo);
stub(utils, "getUser", async () => ({ id: repo.owner.id, model: { id: repo.owner.id } }));
stub(utils, "handleError", error => { throw error; });
stub(app, "selectRepositoryAccess", async () => ({ token: "fixture", binding: { kind: "oauth" } }));
stub(source, "getRepositoryFromGitHub", async () => ({ id: "gh_123", model: { id: "new-source" }, fullName: "fixture/repo",
branches: async () => [{ name: "replacement" }], getCommitInfo: async () => ({ commit: { committer: { date: "2026-11-01" } } }) }));
stub(queue, "downloadQueue", { add: async (...args) => { jobs.push(args); } });
const remove = repo.remove;
stub(repo, "remove", async function (...args) {
removals++;
cleanupSnapshot = { commit: this.model.source.commit, accessRevision: this.model.githubAccess.revision,
anonymizeDate: this.model.anonymizeDate };
return remove.apply(this, args);
});
try {
await handler({ body: { repoId: row.repoId, fullName: "fixture/repo", terms: ["after"],
source: { branch: "replacement", commit: "b".repeat(40) }, options: { expirationMode: "never" },
...(reconnecting ? { reconnectRepositoryId: "gh_123" } : {}) } }, { json: value => { response = value; } });
} finally { while (restores.length) restores.pop()(); }
expect(removals).to.equal(1); expect(response).to.deep.equal({ status: "preparing" });
expect(cleanupSnapshot).to.deep.equal({ commit: "a".repeat(40), accessRevision: "old-access", anonymizeDate: new Date(1) });
const saved = await Repo.findById(row._id);
expect(saved.status).to.equal("preparing"); expect(saved.source.commit).to.equal("b".repeat(40));
expect(saved.source.branch).to.equal("replacement"); expect(saved.source.repositoryId).to.equal("new-source");
expect(saved.githubAccess.revision).not.to.equal("old-access"); expect(saved.anonymizeDate.getTime()).to.be.greaterThan(1);
expect(saved.options.terms).to.deep.equal(["after"]);
expect(await File.countDocuments({ repoId: row.repoId })).to.equal(0); expect(await Path.countDocuments({ repoId: row.repoId })).to.equal(0);
expect(jobs).to.have.length(1); expect(jobs[0][1]).to.deep.equal({ repoId: row.repoId });
});
it("retains retirement markers when foreground cleanup reaches its batch budget", async () => {
const row = await repository({ treeGeneration: "active", retiredTreeGenerations: ["old"] }), repo = new Repository(row);
await rows(501);