Skip to content

Commit f993152

Browse files
committed
fix(core): give unborn git repos their own project id
A repository with an unborn HEAD has no remote or root commit, so Project.resolve fell back to the shared global project. Every fresh 'git init' directory then shared one session pool, and the desktop showed the first resolved directory instead of the project. Use the same directory-derived identity as markerless directories for unborn repos. When a remote or root commit appears later, expose the directory id as 'previous' so the session projector moves existing sessions onto the upgraded identity, then archive the placeholder project row so the directory is not listed twice.
1 parent 67139d6 commit f993152

3 files changed

Lines changed: 80 additions & 17 deletions

File tree

‎packages/core/src/project.ts‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ function fromRow(row: typeof ProjectTable.$inferSelect): Info {
126126
}
127127
}
128128

129+
function directoryIdentity(directory: AbsolutePath) {
130+
return ID.make(Hash.fast(`directory:${directory}`))
131+
}
132+
129133
const layer = Layer.effect(
130134
Service,
131135
Effect.gen(function* () {
@@ -242,6 +246,20 @@ const layer = Layer.effect(
242246
.pipe(Effect.orDie, Effect.asVoid),
243247
},
244248
)
249+
// An unborn repository starts with a directory-derived id. Once a remote or
250+
// root commit upgrades it, that placeholder would linger as a second project
251+
// for the same directory, so archive it after its sessions have moved.
252+
if (item.directory !== project.canonical) return
253+
if (project.previous === undefined || project.previous === project.id) return
254+
if (project.previous !== directoryIdentity(directory)) return
255+
const archived = yield* db
256+
.update(ProjectTable)
257+
.set({ time_archived: Date.now(), time_updated: sql`${ProjectTable.time_updated}` })
258+
.where(and(eq(ProjectTable.id, project.previous), isNull(ProjectTable.time_archived)))
259+
.returning()
260+
.get()
261+
.pipe(Effect.orDie)
262+
if (archived) yield* bus.publish(ProjectSchema.Event.Updated, fromRow(archived))
245263
}).pipe(Effect.ensuring(Effect.sync(() => announcing.delete(key))))
246264
}
247265
return project
@@ -361,7 +379,12 @@ const layer = Layer.effect(
361379
return value
362380
})
363381
const previous = yield* cached(repo.commonDirectory)
364-
const id =
382+
// An unborn repository has neither a remote nor a root commit. Give it a
383+
// directory-derived identity instead of the shared global project so unrelated
384+
// fresh repositories never share sessions. When an identity appears later, the
385+
// directory id becomes `previous`, moving existing sessions onto the new id.
386+
const directoryID = directoryIdentity(AbsolutePath.make(yield* fs.resolve(repo.worktree)))
387+
const identity =
365388
(yield* remote(repo).pipe(fallback(undefined))) ??
366389
previous ??
367390
(yield* rootCommit(repo).pipe(fallback(undefined)))
@@ -373,8 +396,8 @@ const layer = Layer.effect(
373396
fallback(repo.worktree),
374397
)
375398
const resolved: Resolved = {
376-
previous,
377-
id: id ?? ID.global,
399+
previous: previous ?? (identity ? directoryID : undefined),
400+
id: identity ?? directoryID,
378401
directory: repo.worktree,
379402
canonical,
380403
vcs: { type: "git" as const, store: repo.commonDirectory },
@@ -451,7 +474,7 @@ const layer = Layer.effect(
451474
}
452475

453476
return yield* save({
454-
id: ID.make(Hash.fast(`directory:${directory}`)),
477+
id: directoryIdentity(directory),
455478
directory,
456479
canonical: directory,
457480
vcs: undefined,

‎packages/core/test/project.test.ts‎

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -462,22 +462,61 @@ describe("Project.resolve", () => {
462462
}),
463463
)
464464

465-
it.live("returns git global for repo with no commits and no remote", () =>
465+
it.live("gives a repo with no commits and no remote a directory-derived id", () =>
466466
Effect.gen(function* () {
467467
const tmp = yield* tmpdirScoped()
468468
yield* Effect.promise(() => initRepo(tmp.path))
469469
const project = yield* Project.Service
470470

471471
const result = yield* project.resolve(abs(tmp.path))
472472

473-
expect(result.id).toBe(Project.ID.make("global"))
473+
expect(result.id).toBe(Project.ID.make(Hash.fast(`directory:${yield* real(tmp.path)}`)))
474+
expect(result.id).not.toBe(Project.ID.global)
474475
expect(result.directory).toBe(yield* real(tmp.path))
475476
expect(result.canonical).toBe(result.directory)
476477
expect(result.previous).toBeUndefined()
477478
expect(result.vcs?.type).toBe("git")
478479
}),
479480
)
480481

482+
it.live("gives unrelated repos with no commits distinct project ids", () =>
483+
Effect.gen(function* () {
484+
const first = yield* tmpdirScoped()
485+
const second = yield* tmpdirScoped()
486+
yield* Effect.promise(() => Promise.all([initRepo(first.path), initRepo(second.path)]))
487+
const project = yield* Project.Service
488+
489+
const a = yield* project.resolve(abs(first.path))
490+
const b = yield* project.resolve(abs(second.path))
491+
492+
expect(a.id).not.toBe(Project.ID.global)
493+
expect(b.id).not.toBe(Project.ID.global)
494+
expect(a.id).not.toBe(b.id)
495+
expect((yield* project.list()).map((item) => item.id)).toEqual(expect.arrayContaining([a.id, b.id]))
496+
}),
497+
)
498+
499+
it.live("archives the unborn project once a commit gives the repo its id", () =>
500+
Effect.gen(function* () {
501+
const tmp = yield* tmpdirScoped()
502+
yield* Effect.promise(() => initRepo(tmp.path))
503+
const project = yield* Project.Service
504+
const unborn = yield* project.resolve(abs(tmp.path))
505+
506+
yield* Effect.promise(() =>
507+
$`git -c user.name=Test -c [email protected] -c commit.gpgsign=false commit --allow-empty -qm root`
508+
.cwd(tmp.path)
509+
.quiet(),
510+
)
511+
const committed = yield* project.resolve(abs(tmp.path))
512+
513+
expect(committed.id).not.toBe(unborn.id)
514+
expect(committed.previous).toBe(unborn.id)
515+
expect((yield* project.list()).map((item) => item.id)).toEqual([committed.id])
516+
expect((yield* project.list({ archived: true })).find((item) => item.id === unborn.id)?.time.archived).toBeNumber()
517+
}),
518+
)
519+
481520
it.live("falls back to root commit when origin is missing", () =>
482521
Effect.gen(function* () {
483522
const tmp = yield* tmpdirScoped()
@@ -489,7 +528,7 @@ describe("Project.resolve", () => {
489528
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
490529
expect(result.directory).toBe(yield* real(tmp.path))
491530
expect(result.canonical).toBe(result.directory)
492-
expect(result.previous).toBeUndefined()
531+
expect(result.previous).toBe(Project.ID.make(Hash.fast(`directory:${yield* real(tmp.path)}`)))
493532
expect(result.vcs?.type).toBe("git")
494533
}),
495534
)
@@ -751,15 +790,16 @@ describe("Project.resolve", () => {
751790
if (!supported) return
752791
yield* Effect.gen(function* () {
753792
const project = yield* Project.Service
754-
expect((yield* project.resolve(abs(tmp.path))).id).toBe(Project.ID.global)
793+
const unborn = yield* project.resolve(abs(tmp.path))
794+
expect(unborn.id).toBe(Project.ID.make(Hash.fast(`directory:${yield* real(tmp.path)}`)))
755795
yield* Effect.promise(() =>
756796
$`git -c user.name=Test -c [email protected] -c commit.gpgsign=false commit --allow-empty -qm root`
757797
.cwd(tmp.path)
758798
.quiet(),
759799
)
760-
expect((yield* project.resolve(abs(tmp.path))).id).toBe(
761-
Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))),
762-
)
800+
const committed = yield* project.resolve(abs(tmp.path))
801+
expect(committed.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
802+
expect(committed.previous).toBe(unborn.id)
763803
}).pipe(Effect.provide(counted().layer))
764804
}),
765805
)

‎packages/core/test/session-create.test.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -218,18 +218,18 @@ describe("Session.create", () => {
218218
expect(created.projectID).not.toBe(Project.ID.global)
219219
expect(child.projectID).not.toBe(created.projectID)
220220
expect(alias.projectID).toBe(child.projectID)
221-
expect(unborn.id).toBe(Project.ID.global)
222-
expect(unbornRoot).toMatchObject({ projectID: Project.ID.global, subpath: undefined })
223-
expect(unbornNested).toMatchObject({ projectID: Project.ID.global, subpath: "packages/app" })
224-
expect(unbornAlias).toMatchObject({ projectID: Project.ID.global, subpath: "packages/app" })
221+
expect(unborn.id).toBe(created.projectID)
222+
expect(unbornRoot).toMatchObject({ projectID: created.projectID, subpath: undefined })
223+
expect(unbornNested).toMatchObject({ projectID: created.projectID, subpath: "packages/app" })
224+
expect(unbornAlias).toMatchObject({ projectID: created.projectID, subpath: "packages/app" })
225225
expect(
226226
yield* db
227227
.select({ data: EventTable.data })
228228
.from(EventTable)
229-
.where(eq(EventTable.aggregate_id, Project.ID.global))
229+
.where(eq(EventTable.aggregate_id, created.projectID))
230230
.get()
231231
.pipe(Effect.orDie),
232-
).toMatchObject({ data: { adopted: expect.arrayContaining([created.projectID, child.projectID]) } })
232+
).toMatchObject({ data: { adopted: expect.arrayContaining([child.projectID]) } })
233233
expect(project.id).toBe(Project.ID.make(Hash.fast("git-remote:github.com/owner/adopted")))
234234
expect(repeat.id).toBe(project.id)
235235
expect(page.data.map((item) => item.id)).toEqual(expect.arrayContaining([created.id, child.id]))

0 commit comments

Comments
 (0)