Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Readable names for new worktree branches

Status: proposed
Translation: current

[中文](2026-09-27-readable-worktree-branches.zh.md)

## Abstract

Issue #289 reports that opaque worktree branch names make session work hard to
identify and publish. The proposed path keeps the initial ID branch until the
first accepted title arrives, then renames only a new, unchanged and unpublished
branch while reconciling Git with Session metadata. The reference implementation
now has a durable one-time rename intent and checks the current Git branch,
HEAD and remote publication before acting. The design remains proposed while
the issue's UX feedback and contribution handoff are outstanding.

## Decision and alternatives

Passing `SessionConfig.title` to the branch allocator alone is insufficient:
the title is optional, speculative worktree creation precedes the durable
Session, and Provider-generated titles arrive after initialization. Deriving a
name from initial prompt text would cross the preparation contract that keeps
draft text out of its RPC and could put sensitive text in a public branch name.
Renaming every existing `lody/<id>` branch on title change would affect old
Sessions and branches that users may have pushed. A one-time new-session marker
with an expected branch and HEAD provides a narrow eligibility boundary.

The proposal in [the draft Spec](../../../../specs/readable-worktree-branches.md)
defers naming until a title is accepted, then fails closed if the branch or
publication state has changed. It requires an actual Git ref and SessionMeta
reconciliation path; a slug helper by itself has no user-visible value.

## Evidence and status

- [Issue #289](https://github.com/LodyAI/Lody/issues/289) has no maintainer
discussion or alternate implementation at the time of this proposal.
- Current [worktree creation](../../../../apps/cli/src/session/worktree/worktree-manager.ts)
allocates before title generation; [ACP title Spec](../../../../specs/acp-session-titles.md)
documents the later Provider event.
- The reference branch now includes a bounded ASCII slug, a one-time intent in
`SessionMeta`, and a Git rename guarded by the repository lock. A focused
local Git test covers an untouched branch, moved HEAD, a published ref,
collision suffix, and an unusable title. A SessionManager test covers both
speculative adoption eligibility and recovery after Git rename. Title callback
ordering still needs integration coverage before this is ready for an upstream PR.
- No upstream PR exists. The fork PR context-handoff requirement remains a
separate entry gate; this note does not claim user publication or approval.
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# 新建 worktree 分支的可读名称

Status: proposed
Translation: current

[English](2026-09-27-readable-worktree-branches.md)

## 摘要

#289 指出 worktree 的随机分支名让会话工作难以辨认和发布。建议先保留 ID 分支,
待首个有效标题到达后,仅对新的、未变化且未发布的分支改名,并对账 Git 与会话元数据。
参考实现已记录持久的一次性改名意图,并在操作前核实 Git 分支、HEAD 和远端发布
状态。issue 的体验反馈与投稿来源交接尚未完成,设计仍标为 proposed。

## 决定与替代方案

只把 `SessionConfig.title` 传给分支分配器不足以解决问题:标题可缺省,推测性
worktree 在持久会话之前建立,而 Provider 标题在初始化后才到达。由初始提示词
直接派生名称会越过准备 RPC 不传草稿文本的约定,还可能把敏感信息放进公开分支名。
按标题变化改名所有现有 `lody/<id>` 分支则会影响旧会话和可能已经推送的分支。
记录初始分支与 HEAD 的新会话一次性标记,才有明确的适用边界。

[Spec 草案](../../../../specs/readable-worktree-branches.zh.md)建议等标题被接受后,
只在分支及发布状态未变化时改名;真实 Git 引用与 `SessionMeta` 必须有恢复对账
路径。单独的短名转换辅助函数不会产生用户可见价值。

## 证据与状态

- 撰写时 [需求 #289](https://github.com/LodyAI/Lody/issues/289)没有维护者讨论或
对应的其他实现。
- 当前 [worktree 创建](../../../../apps/cli/src/session/worktree/worktree-manager.ts)
早于标题生成;[ACP 标题 Spec](../../../../specs/acp-session-titles.zh.md)记录了较晚的
Provider 事件。
- 参考分支现有受限 ASCII 短名、`SessionMeta` 中的一次性意图和仓库锁保护的 Git
改名。本地 Git 定向测试覆盖未改动分支、HEAD 已移动、已发布引用、碰撞后缀及
无法转换的标题。SessionManager 测试覆盖推测性接管资格与 Git 改名后的恢复;
标题回调时序仍需集成验证,才能准备上游 PR。
- 没有上游 PR。fork PR 的公开会话交接是独立门槛;本文不声称用户已发布或批准。
1 change: 1 addition & 0 deletions apps/cli/src/lib/loro/doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ export interface LoroDocumentManagerOptions {

export type LoroRepoPersistReason =
| 'session-local-base-ref'
| 'session-title-branch-rename-intent'
| 'session-fork-prepare'
| 'session-fork-commit'
| 'session-fork-rollback'
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/lib/message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8705,6 +8705,7 @@ export class MessageHandler {
]);
if (applied) {
this.logger.debug(`[${sessionId}] Session title updated from agent: ${sanitized}`);
await this.sessionManager.maybeRenameWorktreeAfterTitle(sessionId, sanitized);
}
} catch (error) {
this.logger.debug(
Expand Down Expand Up @@ -8841,6 +8842,7 @@ export class MessageHandler {
);
} else {
this.logger.debug(`[${sessionId}] Session title stored in metadata: ${title}`);
await this.sessionManager.maybeRenameWorktreeAfterTitle(sessionId, title);
}
// The generated value is still safe to reuse for a branch name even when a
// concurrent user rename prevented it from being written as the session title.
Expand Down
77 changes: 76 additions & 1 deletion apps/cli/src/session/session-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const createTestCloudPort = () =>
createLocalCloudPort({ identity: { userId: 'user-1' }, workspaces: [] });

type FakeSessionDoc = {
roomId: string;
getMetaState: ReturnType<typeof vi.fn<() => Promise<SessionMeta | undefined>>>;
setRepoFullName: ReturnType<typeof vi.fn<(repoFullName: string) => Promise<void>>>;
setBaseBranch: ReturnType<typeof vi.fn<(baseBranch: string) => Promise<void>>>;
Expand All @@ -82,6 +83,7 @@ type FakeSessionDoc = {
};

const createSessionDoc = (meta?: SessionMeta): FakeSessionDoc => ({
roomId: 'session-test-room',
getMetaState: vi.fn(async () => meta),
setRepoFullName: vi.fn(async () => undefined),
setBaseBranch: vi.fn(async () => undefined),
Expand Down Expand Up @@ -761,12 +763,13 @@ describe('SessionManager worktree setup', () => {
const repoId = deriveRepoIdFromLocalProjectPath(originalRootPath);
const logger = createLogger();
const docs = new Map<SessionId, FakeSessionDoc>();
const workspaceDocument = createWorkspaceDocument(docs);
const manager = new SessionManager(
logger,
'token',
'machine-1' as MachineId,
'workspace-1' as WorkspaceId,
createWorkspaceDocument(docs),
workspaceDocument,
{
sessionSandboxFactory: async () => createNoopSessionSandbox(),
cloudPort: createTestCloudPort(),
Expand Down Expand Up @@ -818,10 +821,82 @@ describe('SessionManager worktree setup', () => {

expect(session.getWorkdir()).toBe(preparedWorktree.info.hostPath);
expect(runWorktreeSetup).toHaveBeenCalledTimes(1);
expect(workspaceDocument.repo.upsertDocMeta).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
titleBranchRename: expect.objectContaining({
initialBranch: preparedWorktree.info.branch,
initialHead: preparedWorktree.info.headSha,
state: 'pending',
}),
})
);
await preparedWorktree.dispose();
expect(existsSync(preparedWorktree.info.hostPath)).toBe(true);
});

it('reconciles a Git rename interrupted before branch metadata was published', async () => {
const sourceDir = createLocalRepo(tempHome);
const originalRootPath = normalizeLocalProjectRootPath(sourceDir);
const sessionId = 'recover-title-branch' as SessionId;
const repoId = deriveRepoIdFromLocalProjectPath(originalRootPath);
const logger = createLogger();
const worktreeManager = getWorktreeManager({
repoId,
source: { kind: 'local-shared', originalRootPath },
logger,
});
await worktreeManager.ensureRepo();
const worktree = await worktreeManager.createWorktree(sessionId, 'main');
const renamedBranch = 'lody/recovered-title-recovert';
runGit(worktree.hostPath, ['branch', '-m', worktree.branch, renamedBranch]);

let meta = {
branchName: worktree.branch,
titleBranchRename: {
initialBranch: worktree.branch,
initialHead: worktree.headSha!,
state: 'attempted',
targetBranch: renamedBranch,
},
} as SessionMeta;
const doc = createSessionDoc();
doc.getMetaState.mockImplementation(async () => meta);
doc.setBranchName.mockImplementation(async (branchName) => {
meta = { ...meta, branchName };
});
const docs = new Map([[sessionId, doc]]);
const workspaceDocument = createWorkspaceDocument(docs);
const upsert = workspaceDocument.repo.upsertDocMeta as ReturnType<typeof vi.fn>;
upsert.mockImplementation(async (_roomId: unknown, patch: Partial<SessionMeta>) => {
meta = { ...meta, ...patch };
});
const manager = new SessionManager(
logger,
'token',
'machine-1' as MachineId,
'workspace-1' as WorkspaceId,
workspaceDocument,
{
sessionSandboxFactory: async () => createNoopSessionSandbox(),
cloudPort: createTestCloudPort(),
}
);
const config = createSessionConfig({ sessionId, workdir: sourceDir });
(
manager as unknown as { titleWorktreeManagers: Map<SessionId, unknown> }
).titleWorktreeManagers.set(sessionId, {
manager: worktreeManager,
source: { kind: 'local-shared', originalRootPath },
config,
});

await manager.maybeRenameWorktreeAfterTitle(sessionId, 'Recovered title');
expect(meta.branchName).toBe(renamedBranch);
expect(meta.titleBranchRename?.state).toBe('finished');
expect(runGit(worktree.hostPath, ['branch', '--show-current'])).toBe(renamedBranch);
});

it('retries setup after a durable create fails with a prepared worktree', async () => {
const sourceDir = createLocalRepo(tempHome);
const originalRootPath = normalizeLocalProjectRootPath(sourceDir);
Expand Down
Loading
Loading