From 37fb5fd418fff28383f069228020c2dfe5287667 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sat, 8 Aug 2026 12:37:55 -0300 Subject: [PATCH] fix: title a worktree group with its whole name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A worktree lives at //, and the name is whatever git check-ref-format accepts for a branch — slashes included. The group divider was titled with baseName() of the checkout path, so a worktree created as feat/x drew the header "x". Two worktrees named the way most repositories name branches, feat/x and fix/x, therefore drew the identical header and the divider stopped identifying the checkout it labels. The project id is the one part of that root the frontend already knows: it names the directory the worktrees sit under, and it is what the sidebar already routes on. Everything past it is the name the worktree was created with, so checkoutLabel slices there and no new field, call or prop is needed. The name is read off the directory and not off the branch the checkout currently holds, which is the other thing ListCheckouts could have supplied. The two agree only at creation. Branching inside a worktree is the ordinary way to work in one — lich's own worktrees open on a generated name and pick up a real branch minutes later — so a header following the branch would rename a group under the user, move on every window focus, and repeat what every card in the group already says. The directory never moves. A checkout outside that root — a worktree added by hand elsewhere, then opened here — has no name to read and keeps its last segment, which is exactly what it shows today. So does a worktree whose name holds no slash. The label is a pure function in lib/ rather than a line inside the JSX: the frontend gate runs in node and never renders, so anything decided in a component is untestable. --- CHANGELOG.md | 8 ++++ .../src/components/sidebar/SessionGroup.tsx | 16 ++++---- frontend/src/lib/git/checkout-label.test.ts | 40 +++++++++++++++++++ frontend/src/lib/git/checkout-label.ts | 36 +++++++++++++++++ 4 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 frontend/src/lib/git/checkout-label.test.ts create mode 100644 frontend/src/lib/git/checkout-label.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2628fcca5..10863d932 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 with a single group has no header and is unchanged. A pinned session still carries its group to the top, which is the one order a drag cannot beat. +- **A worktree group's header spells the whole worktree name.** Worktrees named + the way most repositories name branches — `feat/x`, `fix/x` — came out of the + divider as `x`, because the header showed only the last folder of the + checkout's path. Two worktrees off the same ticket therefore drew the identical + header, and the divider stopped telling you which sessions were whose. The + header now reads the name the worktree was created with, slashes and all. A + worktree without a slash in it looks exactly as it did. + ## [0.27.0] - 2026-08-07 ### Added diff --git a/frontend/src/components/sidebar/SessionGroup.tsx b/frontend/src/components/sidebar/SessionGroup.tsx index ad7221719..c5747bc7c 100644 --- a/frontend/src/components/sidebar/SessionGroup.tsx +++ b/frontend/src/components/sidebar/SessionGroup.tsx @@ -5,7 +5,7 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd- import { CSS } from "@dnd-kit/utilities" import { useSortableList, verticalAxis } from "@/lib/use-sortable-list" import { cn } from "@/lib/utils" -import { baseName } from "@/lib/paths" +import { checkoutLabel } from "@/lib/git/checkout-label" import { groupKey, type Session } from "@/lib/session/sessions" import { useProjects } from "@/providers/projects" import { SessionCard } from "./SessionCard" @@ -40,12 +40,12 @@ interface SessionGroupProps { } // SessionGroup renders one worktree's sessions under a static divider titled -// with the worktree folder name; the branch stays on each card. The title reads -// the group's own checkout path, never a session's live cwd, so a `cd` deeper -// into the tree never re-buckets the group. The isolated DndContext is what -// confines a card drag to reordering within the group; the group itself is a -// sortable of the sidebar's outer context, dragged by its header alone so the -// two never contend for the same pointer. +// with the worktree's name; the branch stays on each card. Both the title and +// the bucketing read the group's own checkout path, never a session's live cwd, +// so a `cd` deeper into the tree never re-buckets the group or renames it. The +// isolated DndContext is what confines a card drag to reordering within the +// group; the group itself is a sortable of the sidebar's outer context, dragged +// by its header alone so the two never contend for the same pointer. // // The card actions needing nothing but a session id — select, rename, open a // terminal beside it — are wired here rather than threaded down from the @@ -67,7 +67,7 @@ export function SessionGroup({ const navigate = useNavigate() const ids = sessions.map((session) => session.id) const { sensors, onDragEnd } = useSortableList(ids, onReorder) - const name = baseName(path || projectPath) + const name = checkoutLabel(path, projectPath, projectId) const group = useSortable({ id: groupKey(path), disabled: !showHeader }) // The PR card keys off the group's real checkout — the project root for the // root group (empty path), else the worktree — so a root project on a feature diff --git a/frontend/src/lib/git/checkout-label.test.ts b/frontend/src/lib/git/checkout-label.test.ts new file mode 100644 index 000000000..20d3c9a15 --- /dev/null +++ b/frontend/src/lib/git/checkout-label.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest" +import { checkoutLabel } from "./checkout-label" + +const PROJECT = "/home/me/code/lich" +const ID = "e48f04f46a4a" +const ROOT = `/home/me/.local/share/lich/worktrees/${ID}` + +describe("checkoutLabel", () => { + it("titles the root group with the project folder", () => { + expect(checkoutLabel("", PROJECT, ID)).toBe("lich") + }) + + it("keeps the whole worktree name when it holds a slash", () => { + expect(checkoutLabel(`${ROOT}/feat/x`, PROJECT, ID)).toBe("feat/x") + }) + + it("tells apart two worktrees sharing a last segment", () => { + expect(checkoutLabel(`${ROOT}/feat/x`, PROJECT, ID)).not.toBe( + checkoutLabel(`${ROOT}/fix/x`, PROJECT, ID), + ) + }) + + it("names a slashless worktree the same as before", () => { + expect(checkoutLabel(`${ROOT}/eager-willow`, PROJECT, ID)).toBe("eager-willow") + }) + + it("reads a Windows checkout, which lich spells with backslashes", () => { + expect( + checkoutLabel(`C:\\Users\\me\\AppData\\lich\\worktrees\\${ID}\\feat\\x`, PROJECT, ID), + ).toBe("feat/x") + }) + + it("cuts at the root, not at a worktree named after the project id", () => { + expect(checkoutLabel(`${ROOT}/${ID}/x`, PROJECT, ID)).toBe(`${ID}/x`) + }) + + it("falls back to the last segment for a checkout outside the worktree root", () => { + expect(checkoutLabel("/home/me/code/lich-elsewhere", PROJECT, ID)).toBe("lich-elsewhere") + }) +}) diff --git a/frontend/src/lib/git/checkout-label.ts b/frontend/src/lib/git/checkout-label.ts new file mode 100644 index 000000000..aef2d296a --- /dev/null +++ b/frontend/src/lib/git/checkout-label.ts @@ -0,0 +1,36 @@ +import { baseName } from "@/lib/paths" + +// checkoutLabel titles a sidebar group with the checkout it holds: the project's +// own folder for the root group (an empty path), else the worktree's name. +// +// A worktree's name is not its last path segment. It lives at +// // and git accepts a slash in a branch name, +// so one created as "feat/x" sits at ".../feat/x" and its final segment is "x" — +// the same header "fix/x" would draw, leaving the divider naming neither +// checkout. The project id is the one part of that root the frontend knows, and +// it is enough: everything past it is the name the worktree was made with. +// +// The name is deliberately read off the directory rather than off the branch the +// checkout currently holds. They agree only at creation; an agent that branches +// inside a worktree — the ordinary way to work in one — moves the branch and +// leaves the directory, and a header that followed it would rename a group under +// the user and repeat what every card in it already says. +// +// A checkout outside that root (a worktree added by hand elsewhere, then opened +// here) has no name to read and keeps its last segment. +export function checkoutLabel(path: string, projectPath: string, projectId: string): string { + if (!path) { + return baseName(projectPath) + } + // git reports a Windows checkout with forward slashes while lich builds one + // with backslashes; the same path reaches this in either spelling. + const unix = path.replace(/\\/g, "/") + const root = `/${projectId}/` + // The first match, not the last: a worktree may itself be named after the + // project id, and the root is the one that comes first. + const at = unix.indexOf(root) + if (at === -1) { + return baseName(path) + } + return unix.slice(at + root.length) +}