diff --git a/.changeset/add-worktree-flag.md b/.changeset/add-worktree-flag.md new file mode 100644 index 0000000000..e814d5a2d7 --- /dev/null +++ b/.changeset/add-worktree-flag.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code": minor +--- + +Add `-w, --worktree [name]` flag to create a new git worktree for the session, and surface the worktree metadata in the agent system prompt. diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 32a65eb0e0..b45ae511a0 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -84,7 +84,13 @@ export function createProgram( ) .addOption(new Option('--yes').hideHelp().default(false)) .addOption(new Option('--auto-approve').hideHelp().default(false)) - .option('--plan', 'Start in plan mode.', false); + .option('--plan', 'Start in plan mode.', false) + .addOption( + new Option( + '-w, --worktree [name]', + 'Create a new git worktree for this session (optionally specify a name).', + ).argParser((val: string | boolean) => (val === true ? '' : (val as string))), + ); registerExportCommand(program); registerProviderCommand(program); @@ -123,6 +129,9 @@ export function createProgram( const yoloValue = raw['yolo'] === true || raw['yes'] === true || raw['autoApprove'] === true; const autoValue = raw['auto'] === true; + const rawWorktree = raw['worktree']; + const worktreeValue = rawWorktree === true ? '' : (rawWorktree as string | undefined); + const opts: CLIOptions = { session: sessionValue, continue: raw['continue'] === true || raw['C'] === true, @@ -133,6 +142,7 @@ export function createProgram( outputFormat: raw['outputFormat'] as CLIOptions['outputFormat'], prompt: raw['prompt'] as string | undefined, skillsDirs: raw['skillsDir'] as string[], + worktree: worktreeValue, addDirs: raw['addDir'] as string[], }; diff --git a/apps/kimi-code/src/cli/options.ts b/apps/kimi-code/src/cli/options.ts index 2e0c33a421..7d1169e089 100644 --- a/apps/kimi-code/src/cli/options.ts +++ b/apps/kimi-code/src/cli/options.ts @@ -11,6 +11,7 @@ export interface CLIOptions { outputFormat: PromptOutputFormat | undefined; prompt: string | undefined; skillsDirs: string[]; + worktree?: string; addDirs?: string[]; } @@ -56,5 +57,11 @@ export function validateOptions(opts: CLIOptions): ValidatedOptions { if (opts.yolo && opts.auto) { throw new OptionConflictError('Cannot combine --yolo with --auto.'); } + if (opts.worktree !== undefined && opts.session !== undefined) { + throw new OptionConflictError('Cannot combine --worktree with --session.'); + } + if (opts.worktree !== undefined && opts.continue) { + throw new OptionConflictError('Cannot combine --worktree with --continue.'); + } return { options: opts, uiMode: promptMode ? 'print' : 'shell' }; } diff --git a/apps/kimi-code/src/cli/resume-hint.ts b/apps/kimi-code/src/cli/resume-hint.ts new file mode 100644 index 0000000000..2c90260f43 --- /dev/null +++ b/apps/kimi-code/src/cli/resume-hint.ts @@ -0,0 +1,19 @@ +import { quoteShellArg } from '#/utils/shell-quote'; + +/** + * Formats the `kimi -r ` command shown to resume a session. + * + * When the session ran in a worktree, the command is prefixed with a `cd` into + * that directory so resuming lands in the same checkout. Centralized here so + * the prompt and shell runners format resume output identically and future + * resume-output work touches one place. + */ +export function formatResumeCommand( + sessionId: string, + options: { readonly cwd?: string } = {}, +): string { + const { cwd } = options; + return cwd !== undefined + ? `cd ${quoteShellArg(cwd)} && kimi -r ${sessionId}` + : `kimi -r ${sessionId}`; +} diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index 100368203b..10aebe730f 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -31,6 +31,8 @@ import { } from './goal-prompt'; import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry'; import { createKimiCodeHostIdentity } from './version'; +import { formatResumeCommand } from './resume-hint'; +import { metadataFromWorktree, type WorktreeRuntime } from './worktree-runtime'; /** * Await `promise`, but stop waiting after `timeoutMs`. @@ -82,6 +84,14 @@ interface PromptRunIO { readonly stdout?: PromptOutput; readonly stderr?: PromptOutput; readonly process?: PromptProcess; + /** The git worktree this prompt session runs in, when launched with --worktree. */ + readonly worktree?: WorktreeRuntime; + /** + * Called once the prompt session has been created or resumed. Lets the caller + * distinguish a startup failure (before any session exists) from a later turn + * failure, e.g. to decide whether an empty worktree should be cleaned up. + */ + readonly onSessionCreated?: () => void; } interface PromptProcess { @@ -104,6 +114,7 @@ export async function runPrompt( const stdout = io.stdout ?? process.stdout; const stderr = io.stderr ?? process.stderr; const promptProcess = io.process ?? process; + const worktree = io.worktree; const workDir = process.cwd(); const telemetryBootstrap = createCliTelemetryBootstrap(); const telemetryClient: TelemetryClient = { @@ -169,11 +180,14 @@ export async function runPrompt( workDir, config.defaultModel, stderr, + worktree, (restorePermission) => { restorePromptSessionPermission = restorePermission; }, ); restorePromptSessionPermission = restorePermission; + // A session now exists; a later turn failure must not delete its worktree. + io.onSessionCreated?.(); initializeCliTelemetry({ harness, @@ -197,7 +211,7 @@ export async function runPrompt( } else { await runPromptTurn(session, opts.prompt!, outputFormat, stdout, stderr); } - writeResumeHint(session.id, outputFormat, stdout, stderr); + writeResumeHint(session.id, outputFormat, stdout, stderr, worktree !== undefined ? workDir : undefined); withTelemetryContext({ sessionId: session.id }).track('exit', { duration_ms: Date.now() - startedAt, @@ -265,6 +279,7 @@ async function resolvePromptSession( workDir: string, defaultModel: string | undefined, stderr: PromptOutput, + worktree: WorktreeRuntime | undefined, setRestorePermission: (restorePermission: () => Promise) => void, ): Promise { if (opts.session !== undefined) { @@ -337,10 +352,17 @@ async function resolvePromptSession( } const model = requireConfiguredModel(opts.model, defaultModel); + const metadata = metadataFromWorktree(worktree); + // Note: --prompt mode intentionally does not auto-remove the worktree on + // exit. Unlike the TUI, a prompt session always produces at least a user + // prompt and assistant response, so the "empty session" cleanup rule does + // not apply; leaving the worktree makes the non-interactive output + // inspectable after the fact. const session = await harness.createSession({ workDir, model, permission: 'auto', + metadata, additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, drainAgentTasksOnStop: true, }); @@ -654,8 +676,9 @@ function writeResumeHint( outputFormat: PromptOutputFormat, stdout: PromptOutput, stderr: PromptOutput, + workDir?: string, ): void { - const command = `kimi -r ${sessionId}`; + const command = formatResumeCommand(sessionId, { cwd: workDir }); const content = `To resume this session: ${command}`; if (outputFormat === 'stream-json') { const message: PromptJsonResumeMetaMessage = { diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index caeae907ca..a1e4e7098d 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -30,11 +30,17 @@ import { restoreTerminalModes } from '#/utils/terminal-restore'; import type { CLIOptions } from './options'; import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry'; import { createKimiCodeHostIdentity } from './version'; +import { formatResumeCommand } from './resume-hint'; +import { + cleanupEmptyWorktree, + metadataFromWorktree, + type WorktreeRuntime, +} from './worktree-runtime'; export async function runShell( opts: CLIOptions, version: string, - runOptions: { readonly migrateOnly?: boolean } = {}, + runOptions: { readonly migrateOnly?: boolean; readonly worktree?: WorktreeRuntime } = {}, ): Promise { const startedAt = Date.now(); const configStartedAt = startedAt; @@ -100,6 +106,7 @@ export async function runShell( configWarning = combineStartupNotice(configWarning, warning); } const configMs = Date.now() - configStartedAt; + const worktree = runOptions.worktree; const tui = new KimiTUI(harness, { cliOptions: opts, additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, @@ -109,6 +116,7 @@ export async function runShell( startupNotice: configWarning, migrationPlan, migrateOnly: runOptions.migrateOnly, + sessionMetadata: metadataFromWorktree(worktree), }); initializeCliTelemetry({ @@ -190,15 +198,26 @@ export async function runShell( tui.onExit = async (exitCode = 0) => { const sessionId = tui.getCurrentSessionId(); - const hasContent = tui.hasSessionContent(); + const hasContent = tui.hasEverHadSessionContent(); setCrashPhase('shutdown'); trackLifecycle('exit', { duration_ms: Date.now() - startedAt }); + + // Clean up the git worktree for empty sessions so abandoned worktrees + // do not accumulate. Use the lifetime flag so `/new` does not delete a + // worktree that held an earlier session. + if (!hasContent) { + cleanupEmptyWorktree(worktree); + } + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); const gutter = ' '.repeat(CHROME_GUTTER); process.stdout.write(`${gutter}Bye!\n`); const hints: string[] = []; if (sessionId !== '' && hasContent) { - hints.push(`${gutter}To resume this session: kimi -r ${sessionId}`); + const resumeCommand = formatResumeCommand(sessionId, { + cwd: worktree !== undefined ? process.cwd() : undefined, + }); + hints.push(`${gutter}To resume this session: ${resumeCommand}`); } if (tui.exitOpenUrl !== undefined) { hints.push(`${gutter}open ${toTerminalHyperlink(tui.exitOpenUrl, tui.exitOpenUrl)}`); diff --git a/apps/kimi-code/src/cli/worktree-runtime.ts b/apps/kimi-code/src/cli/worktree-runtime.ts new file mode 100644 index 0000000000..12101fab98 --- /dev/null +++ b/apps/kimi-code/src/cli/worktree-runtime.ts @@ -0,0 +1,98 @@ +/** + * Runtime lifecycle for the `--worktree` flag. + * + * Keeps worktree creation, cleanup, and session-metadata derivation in one + * feature module so the central CLI files (`main.ts`, `run-prompt.ts`, + * `run-shell.ts`) only carry one-line integration hooks. This narrows the + * surface that collides when those churn-heavy files change for unrelated + * reasons. + */ + +import { existsSync } from 'node:fs'; +import { relative, resolve } from 'node:path'; + +import type { JsonObject } from '@moonshot-ai/kimi-code-sdk'; +import { log } from '@moonshot-ai/kimi-code-sdk'; + +import { createWorktree, findGitRoot, removeWorktree, WorktreeError } from '#/utils/git/worktree'; + +/** + * The live worktree a session runs in. Unlike the parsed CLI options, this is + * runtime state produced after git has registered the worktree and the process + * has entered it, so it is threaded as an explicit execution context rather + * than stored back on `CLIOptions`. + */ +export interface WorktreeRuntime { + readonly worktreePath: string; + readonly parentRepoPath: string; + /** The directory the process changed into (the worktree-relative cwd). */ + readonly effectiveCwd: string; +} + +/** + * Creates the git worktree for `--worktree [name]` and enters it. + * + * Throws {@link WorktreeError} when the cwd is not inside a git repository or + * when entering the created worktree fails (in which case the worktree is + * removed again before throwing). On success the process cwd has been changed + * to {@link WorktreeRuntime.effectiveCwd}. + */ +export function prepareWorktreeRuntime(worktreeName: string): WorktreeRuntime { + const cwd = process.cwd(); + const repoRoot = findGitRoot(cwd); + if (repoRoot === null) { + throw new WorktreeError('--worktree requires the working directory to be inside a git repository.'); + } + const worktreePath = createWorktree(repoRoot, worktreeName || undefined); + const relativeCwd = relative(repoRoot, cwd); + const targetCwd = + relativeCwd.length > 0 && relativeCwd !== '.' + ? resolve(worktreePath, relativeCwd) + : worktreePath; + + // If the caller was inside an ignored or untracked subdirectory, the + // mirrored path may not exist in the detached worktree. Fall back to the + // worktree root rather than fail after git has already registered the + // worktree. + const effectiveCwd = existsSync(targetCwd) ? targetCwd : worktreePath; + try { + process.chdir(effectiveCwd); + } catch { + removeWorktree(repoRoot, worktreePath); + throw new WorktreeError( + `Failed to enter worktree directory: ${effectiveCwd}. The worktree has been removed.`, + ); + } + return { worktreePath, parentRepoPath: repoRoot, effectiveCwd }; +} + +/** + * Removes a worktree that never held session content. Best-effort: a cleanup + * failure is logged but never surfaced, so it cannot mask the original error + * path that triggered the cleanup. A no-op when no worktree is active. + */ +export function cleanupEmptyWorktree(runtime: WorktreeRuntime | undefined): void { + if (runtime === undefined) { + return; + } + try { + removeWorktree(runtime.parentRepoPath, runtime.worktreePath); + } catch (cleanupError) { + log.warn('Failed to clean up git worktree', cleanupError); + } +} + +/** + * The flat session metadata persisted for a worktree session, or `undefined` + * when no worktree is active. Mirrors the shape recovered from existing + * sessions so a `/new` replacement stays in the same worktree context. + */ +export function metadataFromWorktree(runtime: WorktreeRuntime | undefined): JsonObject | undefined { + if (runtime === undefined) { + return undefined; + } + return { + worktreePath: runtime.worktreePath, + parentRepoPath: runtime.parentRepoPath, + }; +} diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index 860c4423d8..8aa56bf99e 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -28,6 +28,12 @@ import type { CLIOptions } from './cli/options'; import { OptionConflictError, validateOptions } from './cli/options'; import { runPrompt } from './cli/run-prompt'; import { runShell } from './cli/run-shell'; +import { + cleanupEmptyWorktree, + prepareWorktreeRuntime, + type WorktreeRuntime, +} from './cli/worktree-runtime'; +import { WorktreeError } from './utils/git/worktree'; import { formatStartupError } from './cli/startup-error'; import { runPluginNodeEntry } from './cli/sub/plugin-run-node'; import { handleUpgrade } from './cli/sub/upgrade'; @@ -74,13 +80,45 @@ export async function handleMainCommand( process.exit(0); } - if (validated.uiMode === 'print') { - await runPrompt(validated.options, version); - return { headlessCompleted: true }; + let worktree: WorktreeRuntime | undefined; + if (opts.worktree !== undefined) { + try { + worktree = prepareWorktreeRuntime(opts.worktree); + } catch (error) { + if (error instanceof WorktreeError) { + process.stderr.write(`error: ${error.message}\n`); + process.exit(1); + } + throw error; + } } - await runShell(validated.options, version); - return { headlessCompleted: false }; + let promptSessionCreated = false; + try { + if (validated.uiMode === 'print') { + await runPrompt(validated.options, version, { + worktree, + onSessionCreated: () => { + promptSessionCreated = true; + }, + }); + return { headlessCompleted: true }; + } + + await runShell(validated.options, version, { worktree }); + return { headlessCompleted: false }; + } catch (error) { + // Clean up a worktree that never carried a session so failed startups do + // not leak empty worktrees. The shell runner only fails before a session + // has content, so any failure there is safe to clean up. Print mode is + // different: once a prompt session exists it is documented to leave the + // worktree inspectable, so only clean up when the failure happened before + // session creation. + if (validated.uiMode !== 'print' || !promptSessionCreated) { + cleanupEmptyWorktree(worktree); + } + throw error; + } } /** `kimi migrate`: launch the migration screen only, then exit. */ diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 62d1b2370b..7b9dcb6498 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -7,6 +7,7 @@ import type { ApprovalResponse, BackgroundTaskInfo, CreateSessionOptions, + JsonObject, KimiHarness, PermissionMode, PromptPart, @@ -32,6 +33,7 @@ import { detectFdPath, ensureFdPath } from '#/utils/process/fd-detect'; import { quoteShellArg } from '#/utils/shell-quote'; import { restoreTerminalModes } from '#/utils/terminal-restore'; +import { resolveSessionMetadata } from './session-worktree'; import { BannerProvider } from './banner/banner-provider'; import { readBannerDisplayState, writeBannerDisplayState } from './banner/state'; import { @@ -175,6 +177,11 @@ export interface KimiTUIStartupInput { readonly migrationPlan?: MigrationPlan | null; /** When true, run only the migration screen, then exit (the `kimi migrate` command). */ readonly migrateOnly?: boolean; + /** + * Session metadata to persist for new sessions; set when launched with + * `--worktree` so the worktree paths are carried on the session. + */ + readonly sessionMetadata?: JsonObject; } type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session'; @@ -273,6 +280,7 @@ export class KimiTUI { private currentLoadingTip: { kind: LoadingTipKind; tip: string | undefined } | undefined = undefined; private lastHistoryContent: string | undefined; + private everHadSessionContent = false; // Live `!` shell output entries, keyed by commandId so concurrent commands // each update their own card and stale events are dropped. Mutated in place // as `shell.output` events arrive; removed when the command completes. @@ -327,6 +335,7 @@ export class KimiTUI { plan: startupInput.cliOptions.plan, model: startupInput.cliOptions.model, startupNotice: startupInput.startupNotice, + metadata: startupInput.sessionMetadata, }, }; this.options = tuiOptions; @@ -685,6 +694,7 @@ export class KimiTUI { model: startup.model, permission: startup.auto ? 'auto' : startup.yolo ? 'yolo' : undefined, planMode: startup.plan ? true : undefined, + metadata: startup.metadata, }; if (this.state.appState.additionalDirs.length > 0) { createSessionOptions.additionalDirs = [...this.state.appState.additionalDirs]; @@ -1326,6 +1336,7 @@ export class KimiTUI { pushTranscriptEntry(entry: TranscriptEntry): void { this.state.transcriptEntries.push(entry); + this.recordSessionContent(); } setExternalEditorRunning(running: boolean): void { @@ -1349,7 +1360,27 @@ export class KimiTUI { } hasSessionContent(): boolean { - return this.state.transcriptEntries.length > 0; + const hasContent = this.state.transcriptEntries.length > 0; + if (hasContent) { + this.recordSessionContent(); + } + return hasContent; + } + + private recordSessionContent(): void { + this.everHadSessionContent = true; + } + + /** + * Whether any session in this TUI lifetime has had transcript content. + * + * Used for worktree cleanup: after `/new` the current session may be empty, + * but we must not delete a worktree that held an earlier session. + */ + hasEverHadSessionContent(): boolean { + // Refresh the flag in case content was added since the last call. + this.hasSessionContent(); + return this.everHadSessionContent; } setExitOpenUrl(url: string): void { @@ -1426,6 +1457,7 @@ export class KimiTUI { thinking: this.session === undefined ? undefined : this.state.appState.thinkingEffort, permission: this.state.appState.permissionMode, planMode: this.state.appState.planMode ? true : undefined, + metadata: resolveSessionMetadata(this.options.startup.metadata, this.session), }; if (this.state.appState.additionalDirs.length > 0) { options.additionalDirs = [...this.state.appState.additionalDirs]; @@ -1825,6 +1857,7 @@ export class KimiTUI { appendTranscriptEntry(entry: TranscriptEntry): void { this.state.transcriptEntries.push(entry); + this.recordSessionContent(); const component = this.createTranscriptComponent(entry); if (component) { markTranscriptComponent(component, entry); @@ -1898,6 +1931,7 @@ export class KimiTUI { } private clearTranscriptAndRedraw(): void { + this.recordSessionContent(); this.streamingUI.discardPending(); this.state.transcriptEntries = []; this.streamingUI.disposeActiveCompactionBlock(); diff --git a/apps/kimi-code/src/tui/session-worktree.ts b/apps/kimi-code/src/tui/session-worktree.ts new file mode 100644 index 0000000000..71e5522686 --- /dev/null +++ b/apps/kimi-code/src/tui/session-worktree.ts @@ -0,0 +1,43 @@ +import type { JsonObject, Session } from '@moonshot-ai/kimi-code-sdk'; + +/** + * Worktree session-metadata helpers for the TUI. + * + * Kept out of the high-churn `kimi-tui.ts` so the worktree wiring stays a + * one-line call site and future TUI edits are less likely to collide with the + * worktree feature. + */ + +/** + * Recovers the worktree metadata carried by an existing session so a + * replacement session (e.g. from `/new`) stays in the same worktree context. + * + * Resuming a worktree session via `-r ` carries no `--worktree` CLI flags, + * so the startup metadata is undefined; without this the new session would lose + * its `worktreePath` / `parentRepoPath` and agents/subagents would drop the + * worktree system-prompt context. Mirrors the flat shape persisted at launch. + */ +export function worktreeMetadataFromSession(session: Session | undefined): JsonObject | undefined { + const metadata = session?.summary?.metadata; + if (metadata === undefined) { + return undefined; + } + const worktreePath = metadata['worktreePath']; + const parentRepoPath = metadata['parentRepoPath']; + if (typeof worktreePath !== 'string' || typeof parentRepoPath !== 'string') { + return undefined; + } + return { worktreePath, parentRepoPath }; +} + +/** + * The session metadata a `/new` replacement should carry: the metadata supplied + * at startup (set when launched with `--worktree`), falling back to whatever the + * current session recorded so resumed worktree sessions are preserved. + */ +export function resolveSessionMetadata( + startupMetadata: JsonObject | undefined, + session: Session | undefined, +): JsonObject | undefined { + return startupMetadata ?? worktreeMetadataFromSession(session); +} diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 6dcdccdd1a..44802931f6 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -1,6 +1,7 @@ import type { GoalChange, GoalSnapshot, + JsonObject, ModelAlias, PermissionMode, ProviderConfig, @@ -224,6 +225,7 @@ export interface TUIStartupOptions { readonly plan: boolean; readonly model?: string; readonly startupNotice?: string; + readonly metadata?: JsonObject; } export type TUIStartupState = 'pending' | 'ready' | 'picker'; diff --git a/apps/kimi-code/src/utils/git/worktree-adjectives.txt b/apps/kimi-code/src/utils/git/worktree-adjectives.txt new file mode 100644 index 0000000000..b523402ee8 --- /dev/null +++ b/apps/kimi-code/src/utils/git/worktree-adjectives.txt @@ -0,0 +1,476 @@ +amber +ancient +autumn +azure +bailan +bengbuzhule +bixie +blazing +blissful +blue +bold +bonny +bouncy +brave +breezy +bright +brisk +broad +bronze +calm +carefree +careful +cedar +charming +cheerful +chigua +chilly +chocolate +cinnamon +circular +citrus +civil +classic +clean +clear +clever +cloudy +cobalt +cold +colorful +colossal +cool +copper +coral +cosmic +cozy +crimson +crystal +cuipi +curious +curly +cyan +dainty +damp +dapper +dappled +daring +dark +dawn +dear +deep +delicate +delightful +dewy +different +digital +dim +direct +distant +docile +dormant +double +dramatic +dry +dusky +dutiful +eager +early +earnest +earthy +easy +ebony +elegant +elemental +emerald +emo +empty +enchanted +endless +energetic +enormous +equal +ethereal +even +evergreen +exact +exotic +fabulous +facai +faint +fair +faithful +familiar +fancy +far +fast +fearless +feathered +fern +fierce +fiery +fine +firm +first +flaming +flat +flawless +fleeting +floral +flowery +fluffy +fluid +foggy +foreign +formal +foxi +fragile +fragrant +free +fresh +friendly +frosted +frosty +fuchsia +full +fuzzy +gallant +gaoqian +gentle +genuine +ghostly +giant +giddy +gifted +gilded +glad +glassy +gleaming +glorious +glossy +golden +good +graceful +gracious +grand +grassy +grateful +gray +great +green +happy +harmonic +hasty +hearty +heather +heavy +hidden +high +hollow +holy +honest +honey +hopeful +humble +humid +hushed +icy +idle +indigo +infinite +innocent +iron +ivory +jade +jasper +jinli +jolly +joyful +joyous +juejuezi +kaibai +keen +kind +lacy +lake +late +lazy +leafy +lean +level +light +lilac +limber +lime +lipu +little +lively +livid +lone +long +loopy +loose +loud +lovely +low +lucid +lucky +lunar +lustrous +magenta +magic +magnetic +majestic +male +maopao +marble +marine +maroon +massive +mauve +meadow +meek +mellow +melodic +merry +mild +milky +mint +misty +modern +modest +moist +mossy +moyu +murmuring +mysterious +mythic +nafu +napping +narrow +natural +navy +neat +neon +new +nice +nimble +noble +noisy +noon +normal +nutmeg +oaken +ocean +odd +olive +opal +open +orange +ordinary +ouqi +oval +pale +palm +paper +parallel +pastel +patient +peach +pearl +perfect +petite +pine +pink +placid +plain +platinum +plump +pofang +polar +polished +polite +poppy +posh +pretty +prim +proud +public +puffy +purple +qianshui +quiet +rad +rapid +rare +rational +ready +real +regal +remote +rich +ripe +rising +roaming +robust +rocky +rolling +rosy +royal +ruby +rustic +sable +sacred +saffron +sage +sandy +sapphire +satin +scarlet +serene +shady +shangan +shangtou +sharp +shekong +sheniu +shesi +shimmering +shining +shiny +short +shy +silent +silky +silver +simple +sincere +sleek +sleepy +slender +slight +slim +slow +small +smart +smiling +smooth +snappy +snowy +soft +solar +solemn +solid +songchigan +spare +sparkling +sparse +spicy +spiral +splendid +spongy +spring +square +stable +starry +static +steadfast +steady +steely +still +stocky +stone +stormy +stout +straight +strange +strong +sturdy +subtle +sudden +sugar +sunny +super +superb +supreme +sure +sweet +swift +tall +tame +tangled +tangping +tart +teal +tender +thankful +thirsty +tidy +tiny +titanic +topaz +tranquil +trim +triple +true +trusting +tutou +twilight +twin +unique +upbeat +urban +usual +valiant +valid +vanilla +vast +velvet +verdant +viable +vibrant +violet +virtual +visible +vital +vivid +wang +warm +wary +watery +wavy +waxen +weak +wealthy +weekly +weighty +western +wet +wheat +whimsical +white +whole +wide +wild +willing +winding +windy +wintry +wise +witty +wonderful +wooden +woody +woolen +woven +xianyanbao +xiao +xiuxian +yearly +yellow +young +yyds +zany +zen +zhenxiang +zhenzhai diff --git a/apps/kimi-code/src/utils/git/worktree-nouns.txt b/apps/kimi-code/src/utils/git/worktree-nouns.txt new file mode 100644 index 0000000000..2dff800a94 --- /dev/null +++ b/apps/kimi-code/src/utils/git/worktree-nouns.txt @@ -0,0 +1,1666 @@ +acorn +air +alarm +alley +almond +amber +anchor +angel +angle +animal +ant +anthem +apple +apricot +arch +arena +aria +ark +arm +arrow +ash +atom +audio +aurora +autumn +avenue +avocado +axle +azalea +badge +bagel +baker +ball +ballad +bamboo +banana +band +bank +banner +baozaifan +baozi +bar +bark +barn +baron +basil +basin +basket +bat +bath +beacon +bead +beaker +beam +bean +bear +beard +beast +beat +beauty +beaver +bed +bee +beech +beet +beetle +bell +belt +bench +berry +bihuan +bike +billow +bin +binggan +bingqilin +birch +bird +biscuit +bit +blade +blazer +blimp +blizzard +block +bloom +blossom +blue +bluebell +blur +boar +board +boat +bog +bolt +bone +book +boom +boot +bottle +boulder +bouquet +bow +bowl +box +boy +bracken +braid +brake +branch +brand +brass +bread +breeze +brick +bridge +brook +broom +brush +bubble +bud +buding +buffalo +bug +bugle +building +bull +bump +bunch +bunny +burger +burn +bush +butter +butterfly +button +buzz +cabin +cable +cactus +cake +calf +camel +cameo +camera +camp +can +canal +candle +candy +cane +canoe +canvas +canyon +cape +caper +car +card +cardinal +cargo +carp +carpet +carrot +cart +cascade +case +cash +castle +cat +caterpillar +cavern +cedar +cello +chain +chaiquan +chair +chalk +chamber +champion +chance +changfen +channel +chant +chaos +chapter +charcoal +charger +charm +chart +chasm +cheek +cheese +cherry +chest +chestnut +chick +chief +child +chill +chimney +chin +chip +chive +chocolate +choir +chord +chorus +choudoufu +chrome +chronicle +cider +cinder +circle +circus +city +clam +clan +clap +clarinet +clasp +clay +cliff +climb +clock +cloud +clover +club +clue +coach +coal +coast +coat +cobra +cocoa +code +coffee +coin +cola +cold +color +comet +compass +cone +cook +cookie +cool +copper +coral +cord +cork +corn +corridor +cottage +cotton +couch +council +count +country +court +cousin +cover +cow +crab +crack +cradle +craft +crane +crate +crayon +cream +creature +creek +crest +crew +cricket +crimson +crocus +crook +crop +cross +crow +crown +crumb +crystal +cube +cuckoo +cuff +cup +curb +cure +curl +currant +current +curry +curve +cushion +custard +cyclone +cypress +daei +daffodil +daisy +dale +dam +dance +danchaofan +dandelion +dangao +danta +dark +dart +data +date +dawn +day +deal +deer +degree +den +depth +desert +desk +dew +diamond +dice +dill +dimple +diner +dinner +dinosaur +dirt +dish +disk +ditch +dive +dock +dodge +doe +dog +doll +dollar +dolphin +domain +donkey +door +douhua +dove +dragon +dragonfly +drain +drama +dream +dress +drift +drill +drink +drip +drive +drone +drop +drum +duck +dune +dust +eagle +ear +earth +ease +east +echo +eclipse +edge +eel +egg +elbow +elder +electric +elephant +elf +elm +ember +emerald +emo +engine +era +erha +error +essay +eve +evening +event +evergreen +ewer +eye +fable +fabric +face +fact +falcon +fall +family +fan +fang +farm +fawn +feast +feather +feature +fern +ferry +festival +field +fig +film +fin +finger +fir +fire +firefly +fish +fist +flag +flame +flare +flash +flask +flea +fleece +fleet +flint +flock +flood +floor +flora +flour +flower +flute +fly +foam +focus +fog +foil +font +food +foot +force +forest +fork +form +fort +fossil +fountain +fox +frame +freckle +freeze +fresco +fridge +friend +fringe +frog +front +frost +fruit +fry +fuel +funeng +fur +furnace +future +gadget +gale +gallery +game +gap +garden +garlic +gas +gate +gear +gecko +gem +genius +ghost +giant +gift +gill +ginger +giraffe +girl +glacier +glade +glass +glen +globe +glove +glue +gnat +goat +gold +gongwei +goose +gopher +gourd +grain +granite +grape +grass +gravel +gravy +green +grief +griffin +grill +grin +grip +grizzly +ground +group +grove +growth +guard +guest +guide +gulf +gull +gum +gust +gym +habit +hail +hair +hall +halo +ham +hammer +hammock +hand +harbor +hare +harp +hat +hatch +hawk +hay +haze +head +heart +heat +heath +heaven +hedge +heel +heir +helium +helmet +help +hen +herb +herd +hero +heron +hickory +hill +hive +hoe +hog +hole +holly +home +honey +hongguo +hood +hoof +hook +hope +horn +horse +hose +hotel +hound +hour +house +hue +hug +hull +hum +hummingbird +hump +hunter +huntun +huoguo +hurdle +hurricane +husk +hyacinth +hydrant +hyena +hymn +ice +icing +icon +idea +igloo +image +inch +ink +inlet +insect +iris +iron +island +item +ivory +ivy +jackal +jacket +jade +jaguar +jam +jar +jasmine +jaw +jay +jazz +jeans +jelly +jest +jet +jewel +jianbing +jiaozi +jingle +jinli +job +jockey +joke +journey +joy +judge +juejuezi +jug +jumao +jungle +juniper +junk +jury +kale +kangaroo +kaochuan +kapibala +kayak +keel +keeper +kelidu +kernel +kettle +key +kick +kid +king +kiosk +kiss +kitchen +kite +kitten +kiwi +knee +knife +knight +knob +knot +koala +label +labor +lace +ladder +lady +lagoon +lake +lamb +lamian +lamp +lance +land +lane +language +lap +lark +laser +laugh +laundry +lava +lawn +layer +lead +leaf +league +leap +leather +leek +leg +lemon +lens +leopard +lesson +letter +lettuce +level +lever +liangpi +library +lichen +lid +life +lift +light +lighthouse +lihua +lilac +lily +limb +lime +line +linen +lion +lip +liquid +list +liter +litter +lizard +loaf +lobby +lobster +lock +locust +log +lollipop +longmao +loop +lord +lotus +love +low +luck +lump +lunch +luosifen +lynx +lyre +machine +magnet +maiden +mail +maize +maker +malatang +mallow +mammoth +man +mango +mantou +map +maple +marble +march +mare +margin +mark +market +marlin +marmot +marsh +mashi +mast +master +mat +match +mate +matrix +matter +maze +meadow +meadowlark +meal +meat +medal +melon +melt +member +memory +menu +mercury +merit +mess +metal +meteor +meter +method +mianbao +mice +middle +midnight +might +mile +milk +mill +mimosa +mind +mine +mint +mirror +mist +mite +mitt +mix +mixian +moat +mock +mode +model +moment +monarch +monkey +month +moon +moor +morning +moss +moth +mother +motion +mound +mountain +mouse +mouth +move +movie +mud +muffin +mug +mule +murk +muscle +muse +mushroom +music +mustard +myth +naicha +nail +name +nap +napkin +nation +nature +navy +neck +nectar +needle +neighbor +nerve +nest +net +network +newt +nexus +nick +niece +night +nine +ninja +noble +node +noise +nook +noon +north +nose +note +notion +nova +novel +now +nugget +number +nun +nurse +nut +oak +oar +oasis +oat +oboe +ocean +octave +octopus +odor +offer +office +ogre +oil +olive +omen +onion +opal +opera +orbit +orchard +orchid +order +ore +organ +origami +ornament +otter +ounce +oven +owl +owner +ox +oxygen +oyster +pace +pack +pact +page +pail +paint +pair +palace +palm +pan +panda +pane +paopao +paper +parade +parcel +parchment +parent +park +parrot +part +party +pass +past +pasture +patch +path +patio +pattern +paw +peak +peanut +pear +pearl +pebble +pedal +peek +peel +peg +pelican +pelt +pen +pencil +penguin +people +pepper +perch +period +person +pest +pet +petal +phase +pheasant +phone +photo +piano +pick +pickle +picture +pie +piece +pier +pig +pigeon +pile +pill +pillow +pilot +pimento +pin +pine +pineapple +pink +pipe +pirate +pit +pitch +pizza +place +plain +plan +plane +planet +plant +plate +play +plaza +pleat +plot +plow +pluck +plum +plume +plush +pocket +pod +poem +poet +point +poke +polar +pole +polish +pollen +pond +pony +pool +pop +poppy +porch +porcupine +port +pose +post +pot +potato +pouch +pound +powder +power +practice +prairie +prawn +prayer +present +prey +price +pride +priest +prince +print +prism +prize +probe +problem +process +prose +prow +prune +puddle +puff +pug +pulp +pulse +pump +pumpkin +punch +pup +pupil +puppet +puppy +purple +purse +push +puzzle +qianshui +qingtuan +quail +quake +quality +quartz +queen +quest +quill +quilt +quince +quiver +quote +rabbit +race +rack +radar +raft +rag +rail +rain +rainbow +rake +rally +ram +ranch +range +rank +raptor +rat +raven +ray +razor +realm +reason +record +red +reed +reef +reflex +region +reign +relay +relic +remedy +rest +result +rhyme +ribbon +rice +ridge +rift +right +ring +rink +riot +rise +risk +river +road +roar +roast +robe +robin +rock +rocket +rod +rogue +roll +roof +room +root +rope +rose +rosette +rouge +roujiamo +round +route +row +ruby +rug +ruin +rule +rune +rung +rush +rust +rut +sack +saddle +safety +saffron +sage +sail +salad +salmon +salon +salt +samoye +sand +sandal +sap +sash +satin +sauce +sausage +savanna +save +saw +scale +scallop +scar +scarf +scene +scent +school +science +scion +scoop +score +scout +scrap +screen +scroll +scrub +sea +seal +seashell +season +seat +secret +seed +segment +self +sense +shadow +shale +shallot +shampoo +shangan +shape +shark +shawl +sheaf +shear +sheep +sheet +shelf +shell +shelter +shepherd +shield +shift +shine +ship +shirt +shoe +shoot +shop +shore +short +shot +shoulder +shout +show +shower +shrub +side +sight +sign +signal +silence +silk +silver +sink +siren +sister +site +six +size +skate +ski +skill +skin +skirt +skull +sky +slate +sled +sleep +sleet +slice +slope +slot +slug +smile +smoke +snack +snail +snake +snap +snow +soap +sock +soda +sofa +soil +soldier +sole +solid +son +song +soot +soul +sound +soup +source +south +space +spade +spark +sparrow +spear +speech +speed +spell +sphere +spice +spider +spike +spin +spire +spirit +sponge +spoon +sport +spot +spray +spring +spruce +spur +spy +square +squash +squid +squirrel +stable +stack +staff +stage +stain +stair +stake +stalk +stall +stallion +stamp +stand +star +starch +start +state +statue +steam +steel +stem +step +stern +stew +stick +sting +stock +stone +stool +stop +store +storm +story +stove +strand +stranger +strap +straw +strawberry +stream +street +stress +string +stripe +stroke +strut +student +study +stuff +stump +style +suanlafen +suburb +success +sugar +suit +sulfur +summer +sun +sunbeam +sunset +surf +surge +swamp +swan +sweat +sweet +swift +swim +swing +switch +symbol +symphony +sync +syrup +system +table +tackle +tail +tale +talk +talon +tangerine +tank +tap +tape +tar +target +taste +tavern +tax +tea +team +tear +teeth +tell +tempest +temple +tempo +tendril +tennis +tent +term +terrace +test +text +texture +theater +theme +theory +thorn +thread +threat +throne +thunder +tick +tide +tie +tiger +tile +timber +time +tin +tint +tip +tissue +toad +toast +toe +token +tomato +tone +tongdian +tongue +tool +tooth +top +topic +torch +tornado +torrent +tortoise +touch +tour +towel +tower +town +toy +track +trade +trail +train +trait +tram +trap +trash +travel +tray +treasure +tree +trellis +trend +trial +tribe +trick +trim +trio +trip +trolley +troop +trouble +trout +truck +trumpet +trunk +trust +truth +tube +tucao +tulip +tuna +tune +tunnel +turf +turkey +turn +turnip +turtle +twig +twin +twist +type +umbrella +uncle +unit +universe +urn +use +usher +valley +value +valve +vane +vanilla +vapor +vase +vault +veil +vein +velvet +vendor +vent +verb +verse +vessel +vest +vial +vibe +vicar +victim +victory +video +view +village +vine +violet +violin +virus +visit +voice +void +volcano +volume +vortex +vote +voyage +wage +wagon +waist +wait +wake +walk +wall +walnut +walrus +warbler +ward +warmth +warp +warrior +wasp +waste +watch +water +wave +wax +way +wealth +weapon +weasel +weather +weave +web +wedge +weed +week +well +west +whale +wheat +wheel +whip +whirl +whisker +whisper +whistle +white +whole +wick +widow +width +wife +wild +willow +win +wind +window +wine +wing +wink +winter +wire +wish +wit +witch +wolf +woman +wonder +wood +wool +word +work +world +worm +worry +worth +wound +wraith +wreath +wreck +wren +wrinkle +wrist +writer +xiangcai +xiaolongbao +xiongmao +yard +yarn +yawn +year +yeast +yellow +yew +yield +yolk +young +youtiao +yuanyuan +yyds +zeal +zebra +zenith +zero +zest +zigzag +zinc +zone +zongzi +zoo diff --git a/apps/kimi-code/src/utils/git/worktree-verbs.txt b/apps/kimi-code/src/utils/git/worktree-verbs.txt new file mode 100644 index 0000000000..9c92955312 --- /dev/null +++ b/apps/kimi-code/src/utils/git/worktree-verbs.txt @@ -0,0 +1,1356 @@ +anli +aoye +arching +arising +ascending +asking +awakening +bacao +baking +balancing +beaming +becoming +bending +bihuan +binding +blazing +blessing +blinking +blooming +blowing +blushing +boiling +bounding +bracing +breathing +breezing +brewing +brimming +bristling +bubbling +building +busting +buzzing +caching +calling +calming +capping +careening +caring +carrying +carving +casting +catching +causing +chaining +changing +chanting +charging +chasing +chendian +chilling +chiming +choosing +churning +clamping +clapping +clashing +climbing +clinging +closing +clutching +coasting +coding +coiling +colliding +coming +composing +consuming +cooking +cooling +coping +copying +coring +coursing +cracking +cradling +crawling +creeping +cresting +crimping +crossing +crowning +cruising +crumbling +crunching +curling +cutting +dacall +dakai +dancing +daring +darting +datong +dawning +dealing +decking +decoding +deepening +defying +delaying +delving +descending +desiring +digging +dimming +dining +dipping +directing +diving +divining +docking +dodging +doing +doubling +doutu +drafting +dragging +draining +drawing +dreaming +drenching +dressing +dribbling +drifting +drilling +drinking +dripping +driving +droning +dropping +drying +ducking +dueling +duiqi +duoshou +dusting +dwelling +earning +eating +echoing +edging +editing +eking +eloping +emerging +ending +engaging +enjoying +entering +erasing +erupting +escaping +evading +examining +exiting +expanding +facai +fading +failing +falling +fangong +fanning +faring +farming +fastening +feasting +feeding +feeling +fencing +fetching +finding +firing +fishing +fitting +fixing +flaming +flapping +flaring +flashing +flattening +flavoring +fleeing +flickering +flicking +flinging +flipping +floating +flooding +flopping +flowering +flowing +fluctuating +flushing +flying +focusing +folding +following +fooling +footing +forcing +forecasting +forgetting +forging +forking +forming +fostering +founding +framing +fretting +frying +fueling +fumbling +fuming +funding +funeng +fupan +fusing +gaining +galloping +gambling +gaoqian +gaoshi +gardening +gasping +gathering +gazing +gearing +getting +gilding +giving +glancing +glaring +gliding +glimmering +glistening +glittering +glowing +gnawing +going +governing +grading +granting +grasping +grating +grazing +greening +greeting +grinding +gripping +groaning +growing +grumbling +guarding +guessing +guiding +gushing +hacking +hailing +halting +hammering +handling +hanging +happening +hardening +harping +harvesting +hastening +hatching +hauling +healing +heaping +hearing +heating +heaving +helping +hesitating +hiding +hiking +hindering +hitching +hoarding +holding +homing +honing +hooking +hopping +hovering +huashui +hugging +humming +hurling +hurrying +hushing +hustling +igniting +illuminating +imagining +immersing +incoming +increasing +inducing +inflating +informing +inhabiting +inheriting +initiating +injecting +inking +inquiring +inserting +inspecting +inspiring +installing +intending +interesting +intersecting +inventing +investing +inviting +ironing +itching +jaunting +jazzing +jesting +jingling +jogging +joining +joking +jolting +jostling +judging +juggling +jumping +justifying +kaibai +keening +keeping +keying +kidding +kissing +kneading +kneeling +knitting +knocking +knotting +knowing +kongchang +labeling +laboring +lacing +lacking +lagging +landing +lapping +laqi +lashing +lasting +latching +laughing +launching +leaning +leaping +learning +leaving +lecturing +lending +lengthening +letting +leveling +liandong +lifting +lighting +liking +limping +linking +listening +living +loading +loafing +locking +lodging +logging +looking +looming +looping +loosening +losing +loving +lowering +lucking +lumbering +luodi +lurching +lurking +making +managing +maopao +marching +marking +matching +mating +mattering +melding +melting +memorizing +mending +merging +messing +migrating +mimicking +mingling +mining +minting +mirroring +mixing +moaning +modeling +moderating +modifying +molding +molting +monitoring +mooring +morphing +moseying +mounting +moving +mowing +moyu +muddling +muffling +multiplying +mumbling +murmuring +musing +mutating +muttering +nagging +nailing +naming +napping +narrating +narrowing +navigating +needing +nesting +nestling +netting +nodding +nominating +normalizing +notching +noticing +noting +nourishing +nuancing +nudging +nullifying +numbing +nursing +obeying +objecting +obscuring +observing +obtaining +occupying +occurring +offering +officiating +oiling +omitting +opening +operating +opposing +opting +orbiting +ordering +organizing +orienting +originating +ornamenting +ousting +outlining +outpacing +overcoming +overlapping +overriding +overseeing +owing +owning +pacing +packing +paddling +paging +painting +palming +parading +paralleling +parking +parsing +parting +passing +pasting +patrolling +pausing +paving +paying +peaking +pecking +pedaling +peeking +peeling +peering +pelting +pending +penetrating +perceiving +perfecting +performing +perking +permitting +persisting +persuading +perturbing +perusing +picking +picturing +piercing +piling +piloting +pinching +pinging +pinning +pioneering +pirouetting +pivoting +placing +planning +planting +plastering +playing +pleading +pleasing +plodding +plotting +plowing +plucking +plugging +plumbing +plummeting +plunging +pointing +poising +poking +polishing +polling +pondering +popping +poring +porting +posing +positioning +possessing +posting +pounding +pouring +pouting +prancing +praying +preaching +preceding +predicting +preferring +preparing +prescribing +presenting +preserving +pressing +pretending +prevailing +preventing +probing +proceeding +processing +proclaiming +producing +professing +programming +progressing +projecting +promising +promoting +prompting +propping +protesting +providing +prowling +pruning +prying +puffing +pulling +pulsating +pulsing +pumping +punching +puncturing +purchasing +purring +pursuing +pushing +putting +puzzling +qianshui +quaking +qualifying +quartering +questioning +quieting +quilting +quivering +qujing +quoting +racing +radiating +rafting +raging +raining +raising +raking +rallying +rambling +ramping +ranking +ranting +rapping +rasping +rating +rattling +raving +reaching +reacting +reading +reaping +rearing +reasoning +reassuring +rebounding +rebuilding +recalling +receiving +reciting +reckoning +reclaiming +recoiling +recording +recovering +recruiting +recycling +reddening +redeeming +reducing +reeling +referencing +refining +reflecting +reforming +refracting +refreshing +refunding +refusing +regaining +regaling +regarding +registering +regressing +regretting +reigning +reining +rejecting +rejoicing +relaxing +releasing +relying +remaining +remarking +reminding +removing +rendering +renewing +rensong +renting +repairing +repeating +repelling +replacing +replying +reporting +reposing +representing +requesting +requiring +rescuing +researching +resembling +reserving +resetting +residing +resigning +resisting +resolving +resonating +resorting +respecting +responding +resting +restoring +restraining +resulting +resuming +retaining +retiring +retreating +retrieving +returning +reusing +revealing +reveling +revering +reversing +reverting +reviewing +reviving +revolving +rewarding +rewinding +rhyming +riding +rifting +ringing +rinsing +ripening +rising +risking +roaming +roaring +roasting +rocking +rolling +romping +rooting +roping +rounding +rousing +routing +roving +rowing +rubbing +ruffling +ruining +ruling +rumbling +rummaging +rumpling +running +rushing +rustling +sailing +saluting +sampling +sanding +sapping +satisfying +saving +savoring +sawing +saying +scaling +scampering +scanning +scaring +scattering +scenting +scheduling +scheming +schooling +scoffing +scolding +scooping +scooting +scorching +scoring +scouring +scowling +scrambling +scraping +scratching +screening +scribbling +scrubbing +scuffing +sculpting +scurrying +sealing +searching +seasoning +seating +seconding +seeing +seeking +seeming +seeping +selecting +selling +sending +sensing +separating +serving +setting +settling +severing +sewing +shading +shaidan +shaking +shaming +shangan +shaping +sharing +sharpening +shattering +shaving +shearing +shedding +shelling +sheltering +shielding +shifting +shimmering +shining +shipping +shivering +shocking +shooting +shopping +shortening +shouting +shoveling +showering +showing +shredding +shrilling +shrinking +shrouding +shrugging +shuffling +shuiqun +shunning +shutting +siding +sifting +sighing +signaling +signing +silencing +simmering +simplifying +singing +sinking +sipping +sitting +sizing +skating +sketching +skiing +skimming +skipping +skirting +skulking +slacking +slamming +slanting +slapping +slashing +sledding +sleeping +slicing +sliding +slinging +slinking +slipping +slitting +slogging +sloping +sloshing +slowing +slumbering +slumping +slurping +smacking +smearing +smelling +smiling +smirking +smoking +smoldering +smoothing +smothering +smudging +snaking +snapping +snaring +snarling +sneaking +sneering +sneezing +snickering +sniffing +sniping +snooping +snoozing +snoring +snorting +snowing +soaking +soaring +sobbing +socializing +softening +soiling +soldering +solving +soothing +soring +sorting +sounding +souring +sowing +spacing +spanking +sparking +sparkling +sparring +spattering +spawning +speaking +spearing +specializing +speeding +spelling +spending +spilling +spinning +spiraling +spitting +splashing +splaying +splicing +splitting +spoiling +sponging +spooking +spooling +spooning +sporting +spotting +spouting +sprawling +spraying +spreading +springing +sprinkling +sprinting +sprouting +spurring +spying +squalling +squaring +squashing +squeaking +squealing +squeezing +squinting +squirming +squishing +stacking +staffing +staggering +staining +staking +stalking +stalling +stamping +standing +staring +starting +starving +stating +staying +stealing +steaming +steering +stemming +stepping +sticking +stiffening +stifling +stilling +stimulating +stinging +stinking +stirring +stitching +stocking +stomping +stoning +stooping +stopping +storming +straddling +straggling +straightening +straining +stranding +strapping +straying +streaking +streaming +strengthening +stressing +stretching +striking +stringing +stripping +striving +stroking +strolling +struggling +studying +stuffing +stumbling +stumping +stunning +stunting +stuttering +subduing +submitting +subtracting +subverting +succeeding +succumbing +sucking +suggesting +suiting +sulfuring +sulking +summoning +sunning +supervising +supplying +supporting +supposing +suppressing +surfacing +surfing +surging +surmising +surpassing +surprising +surrendering +surrounding +surveying +surviving +suspecting +suspending +sustaining +swallowing +swamping +swapping +swarming +swathing +swaying +swearing +sweating +sweeping +swelling +swerving +swimming +swinging +swiping +swirling +swishing +switching +swiveling +swooning +swooping +synthesizing +tacking +tagging +tailing +taking +talking +tallying +taming +tangling +tangping +tapping +tarrying +tasting +taunting +taxiing +teaching +tearing +teasing +teeming +telling +tempering +tending +tensing +tenting +terminating +terming +terrifying +testing +tethering +thanking +thawing +thickening +thieving +thinning +thirsting +throbbing +thronging +throwing +thrusting +thudding +thumping +thundering +ticking +tickling +tidying +tieing +tightening +tilting +timing +tingling +tinkering +tinkling +tipping +tiptoeing +tiring +toasting +toiling +tolerating +tolling +toning +tooling +tooting +toppling +tossing +tottering +touching +touring +towing +tracing +tracking +trading +trailing +training +traipsing +tramping +trampling +transcending +transferring +transforming +transitioning +translating +transmitting +transporting +trapping +traveling +treading +treasuring +treating +trembling +trending +tricking +trickling +trimming +tripping +trotting +troubling +trouncing +trudging +trumping +truncating +trussing +trusting +trying +tucao +tucking +tugging +tumbling +tuning +turning +tutoring +twanging +tweaking +tweeting +twinkling +twirling +twisting +twitching +twittering +typing +unbinding +uncovering +undergoing +underlining +understanding +undertaking +undulating +unfolding +unifying +uniting +unloading +unlocking +unpacking +unraveling +unrolling +untying +upbraiding +upending +upgrading +upholding +uplifting +uprising +upsetting +urging +ushering +using +utilizing +uttering +vacating +varying +vaulting +veering +vending +venting +venturing +verifying +vexing +vibrating +viewing +vindicating +violating +visiting +visualizing +voicing +voiding +volleying +voting +vowing +waddling +wading +wafting +wagering +wagging +wailing +waiting +waking +walking +wallowing +wandering +waning +wanting +warming +warning +warping +washing +wasting +watching +watering +wavering +waving +waxing +weakening +wearying +weaving +wedding +wedging +weeping +weighing +weighting +welcoming +welling +welting +wetting +whacking +wheeling +wheezing +whimpering +whining +whipping +whirling +whispering +whistling +whittling +whooping +widening +wielding +wiggling +wilting +winning +wintering +wiping +wishing +withering +withstanding +wobbling +wondering +wooding +wooing +working +worrying +worshiping +wounding +wrangling +wrapping +wrecking +wrenching +wrestling +wriggling +wringing +wrinkling +writing +wronging +xiuxian +yammering +yangyuan +yanking +yawning +yearning +yelling +yelping +yielding +yingye +yingyuan +yodeling +zapping +zeroing +zhenghuo +zhongcao +zhuashou +zigzagging +zipping +zooming diff --git a/apps/kimi-code/src/utils/git/worktree.ts b/apps/kimi-code/src/utils/git/worktree.ts new file mode 100644 index 0000000000..e39f519d2b --- /dev/null +++ b/apps/kimi-code/src/utils/git/worktree.ts @@ -0,0 +1,317 @@ +/** + * Git worktree management for isolated agent sessions. + * + * Mirrors the upstream kimi-cli worktree feature: + * - Worktrees are created under /.kimi/worktrees/ + * - Default name is a random three-word slug from the worktree name database + * (e.g. amber-drifting-cloud, moyu-qianshui-xiongmao) + * - Default checkout is detached HEAD at current HEAD + */ + +import { randomInt } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import ADJECTIVES_RAW from './worktree-adjectives.txt?raw'; +import VERBS_RAW from './worktree-verbs.txt?raw'; +import NOUNS_RAW from './worktree-nouns.txt?raw'; + +const GIT_TIMEOUT_MS = 30_000; +const WORKTREE_SUBDIR = '.kimi/worktrees'; +const MAX_SLUG_LENGTH = 64; +const VALID_SLUG_SEGMENT = /^[A-Za-z0-9._-]+$/; +const PR_REF_PREFIX = /^#(\d+)$/; +const NAME_RETRY_ATTEMPTS = 10; + +export class WorktreeError extends Error { + constructor( + message: string, + readonly stderr?: string, + ) { + super(message); + this.name = 'WorktreeError'; + } +} + +export interface WorktreeInfo { + readonly path: string; + readonly branch?: string; +} + +function runGit(cwd: string, args: readonly string[]): { stdout: string; stderr: string; status: number | null } { + const result = spawnSync('git', ['-C', cwd, ...args], { + encoding: 'utf8', + timeout: GIT_TIMEOUT_MS, + }); + return { + stdout: result.stdout?.trim() ?? '', + stderr: result.stderr?.trim() ?? '', + status: result.status, + }; +} + +export function findGitRoot(cwd: string): string | null { + const { stdout, status } = runGit(cwd, ['rev-parse', '--show-toplevel']); + if (status !== 0 || stdout.length === 0) { + return null; + } + return resolve(stdout); +} + +function isInsideGitRepo(cwd: string): boolean { + const { stdout, status } = runGit(cwd, ['rev-parse', '--is-inside-work-tree']); + return status === 0 && stdout === 'true'; +} + +function parseWordList(raw: string): readonly string[] { + return raw + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')); +} + +const ADJECTIVES = parseWordList(ADJECTIVES_RAW); +const VERBS = parseWordList(VERBS_RAW); +const NOUNS = parseWordList(NOUNS_RAW); + +function pick(list: readonly T[]): T { + if (list.length === 0) { + throw new WorktreeError('Worktree name word list is empty.'); + } + return list[randomInt(list.length)]!; +} + +function generateDefaultWorktreeName(): string { + return `${pick(ADJECTIVES)}-${pick(VERBS)}-${pick(NOUNS)}`; +} + +/** + * Validates and normalizes a user-supplied worktree name. + * + * Rules: + * - Non-empty after trimming. + * - At most 64 characters. + * - No forward slashes. + * - May contain only letters, digits, '.', '_', and '-'. + * - The names '.' and '..' are rejected. + * - A leading '#' followed by digits is normalized to "pr-". + */ +export function normalizeWorktreeName(input: string): string { + const trimmed = input.trim(); + + if (trimmed.length === 0) { + throw new WorktreeError('Worktree name cannot be empty.'); + } + + const prMatch = PR_REF_PREFIX.exec(trimmed); + const name = prMatch !== null ? `pr-${prMatch[1]}` : trimmed; + + if (name.length > MAX_SLUG_LENGTH) { + throw new WorktreeError(`Worktree name must be ${MAX_SLUG_LENGTH} characters or fewer.`); + } + + if (name === '.' || name === '..') { + throw new WorktreeError(`Worktree name cannot be "." or "..": ${name}`); + } + + if (name.includes('/')) { + throw new WorktreeError(`Worktree name cannot contain "/": ${name}`); + } + + if (!VALID_SLUG_SEGMENT.test(name)) { + throw new WorktreeError( + `Worktree name contains invalid characters (allowed: letters, digits, '.', '_', '-'): ${name}`, + ); + } + + return name; +} + +function generateUniqueWorktreeName(worktreesDir: string): string { + for (let attempt = 0; attempt < NAME_RETRY_ATTEMPTS; attempt++) { + const name = generateDefaultWorktreeName(); + const worktreePath = resolve(worktreesDir, name); + if (!existsSync(worktreePath)) { + return name; + } + } + throw new WorktreeError( + `Failed to generate a unique worktree name after ${NAME_RETRY_ATTEMPTS} attempts.`, + ); +} + +function realpathOrNull(filePath: string): string | null { + try { + return realpathSync(filePath); + } catch { + return null; + } +} + +function isRegisteredWorktree(repoRoot: string, worktreePath: string): boolean | null { + const worktrees = listWorktrees(repoRoot); + if (worktrees === null) { + return null; + } + const target = realpathOrNull(worktreePath); + if (target === null) { + return false; + } + return worktrees.some((info) => { + const registeredPath = realpathOrNull(info.path); + return registeredPath !== null && registeredPath === target; + }); +} + +function ensureWorktreeStorageIgnored(worktreesDir: string): void { + const gitignorePath = resolve(worktreesDir, '.gitignore'); + if (existsSync(gitignorePath)) { + return; + } + writeFileSync(gitignorePath, '*\n', { encoding: 'utf8' }); +} + +/** + * Ensures the worktree storage dir does not dirty the repository checkout. + * + * We add `.kimi/worktrees/` to `.git/info/exclude` (local to this clone) rather + * than modifying any tracked `.gitignore` file. This keeps the worktree storage + * out of `git status` without polluting the repo with untracked ignore rules. + * + * The marker is scoped to `.kimi/worktrees/` rather than the whole `.kimi/` + * directory so unrelated untracked content another tool may keep under `.kimi/` + * still shows up in `git status` instead of being silently hidden. + */ +function ensureWorktreeStorageExcluded(repoRoot: string): void { + // Resolve the exclude file via `--git-path` so that when `repoRoot` is itself + // a linked worktree we target the common git dir's `info/exclude` (where Git + // actually reads it from) rather than `.git/worktrees//info/exclude`, + // which Git would never consult. + const excludePathResult = runGit(repoRoot, ['rev-parse', '--git-path', 'info/exclude']); + if (excludePathResult.status !== 0 || excludePathResult.stdout.length === 0) { + return; + } + const excludePath = resolve(repoRoot, excludePathResult.stdout); + const marker = `${WORKTREE_SUBDIR}/`; + + let existing = ''; + if (existsSync(excludePath)) { + try { + existing = readFileSync(excludePath, { encoding: 'utf8' }); + const lines = existing.split('\n'); + if (lines.some((line) => line.trim() === marker)) { + return; + } + } catch { + // Fall through to best-effort append. + } + } + + mkdirSync(resolve(excludePath, '..'), { recursive: true }); + writeFileSync(excludePath, `${existing}${existing.length > 0 && !existing.endsWith('\n') ? '\n' : ''}${marker}\n`, { + encoding: 'utf8', + }); +} + +export function createWorktree(repoRoot: string, name?: string): string { + if (!isInsideGitRepo(repoRoot)) { + throw new WorktreeError(`Not a git repository: ${repoRoot}`); + } + + const worktreesDir = resolve(repoRoot, WORKTREE_SUBDIR); + const worktreeName = + name !== undefined && name.trim().length > 0 + ? normalizeWorktreeName(name) + : generateUniqueWorktreeName(worktreesDir); + const worktreePath = resolve(worktreesDir, worktreeName); + + if (resolve(worktreePath) === resolve(repoRoot)) { + throw new WorktreeError(`Worktree path cannot be the repository root: ${worktreePath}`); + } + + // git worktree add will fail if the path already exists, but check early + // to give a clearer error and avoid partial git state. + if (existsSync(worktreePath)) { + throw new WorktreeError( + `Worktree directory already exists: ${worktreePath}\n` + + 'Use --worktree to choose a different name, or remove the existing directory.', + ); + } + + // Ensure parent directory exists; git does not create nested parent dirs. + mkdirSync(worktreesDir, { recursive: true }); + ensureWorktreeStorageIgnored(worktreesDir); + ensureWorktreeStorageExcluded(repoRoot); + + const { stderr, status } = runGit(repoRoot, ['worktree', 'add', '--detach', worktreePath]); + if (status !== 0) { + // Clean up partial directory if git created it + if (existsSync(worktreePath)) { + rmSync(worktreePath, { recursive: true, force: true }); + } + throw new WorktreeError( + `Failed to create git worktree at ${worktreePath}${stderr ? `\n${stderr}` : ''}`, + stderr, + ); + } + + return worktreePath; +} + +export function removeWorktree(repoRoot: string, worktreePath: string): void { + const canonicalRepoRoot = findGitRoot(repoRoot); + if (canonicalRepoRoot === null) { + // Repository is gone; best-effort remove the directory itself. + rmSync(worktreePath, { recursive: true, force: true }); + return; + } + + const registered = isRegisteredWorktree(canonicalRepoRoot, worktreePath); + + // Only fall back to rm for worktrees that are proven not to be registered + // with git. If registration status is unknown (list failed) or the worktree + // is registered, run git worktree remove, which fails safe on dirty/locked + // worktrees instead of bypassing the safety check with force-rm. + if (registered === false) { + rmSync(worktreePath, { recursive: true, force: true }); + runGit(canonicalRepoRoot, ['worktree', 'prune']); + return; + } + + const { stderr, status } = runGit(canonicalRepoRoot, ['worktree', 'remove', worktreePath]); + if (status !== 0) { + throw new WorktreeError( + `Failed to remove worktree at ${worktreePath}${stderr ? `\n${stderr}` : ''}`, + stderr, + ); + } + + // Prune stale worktree metadata (best-effort). + runGit(canonicalRepoRoot, ['worktree', 'prune']); +} + +export function listWorktrees(repoRoot: string): WorktreeInfo[] | null { + const { stdout, status } = runGit(repoRoot, ['worktree', 'list', '--porcelain']); + if (status !== 0) { + return null; + } + + const worktrees: WorktreeInfo[] = []; + let current: { path?: string; branch?: string } = {}; + for (const line of stdout.split('\n')) { + if (line.startsWith('worktree ')) { + if (current.path !== undefined) { + worktrees.push({ path: current.path, branch: current.branch }); + } + current = { path: line.slice('worktree '.length).trim() }; + } else if (line.startsWith('branch ')) { + current.branch = line.slice('branch '.length).trim(); + } else if (line === 'detached') { + current.branch = '(detached HEAD)'; + } + } + if (current.path !== undefined) { + worktrees.push({ path: current.path, branch: current.branch }); + } + return worktrees; +} diff --git a/apps/kimi-code/test/cli/main.test.ts b/apps/kimi-code/test/cli/main.test.ts index 31411e8b19..8604849607 100644 --- a/apps/kimi-code/test/cli/main.test.ts +++ b/apps/kimi-code/test/cli/main.test.ts @@ -32,6 +32,8 @@ const mocks = vi.hoisted(() => { })), initializeCliTelemetry: vi.fn(), handleUpgrade: vi.fn(), + prepareWorktreeRuntime: vi.fn(), + cleanupEmptyWorktree: vi.fn(), flushDiagnosticLogs: vi.fn(), finalizeHeadlessRun: vi.fn(), log: { @@ -130,6 +132,11 @@ vi.mock('../../src/cli/run-prompt', () => ({ runPrompt: mocks.runPrompt, })); +vi.mock('../../src/cli/worktree-runtime', () => ({ + prepareWorktreeRuntime: mocks.prepareWorktreeRuntime, + cleanupEmptyWorktree: mocks.cleanupEmptyWorktree, +})); + vi.mock('../../src/cli/headless-exit', () => ({ finalizeHeadlessRun: mocks.finalizeHeadlessRun, })); @@ -151,6 +158,7 @@ function defaultOpts(): CLIOptions { outputFormat: undefined, prompt: undefined, skillsDirs: [], + worktree: undefined, }; } @@ -234,7 +242,7 @@ describe('main entry command handling', () => { expect(mocks.runUpdatePreflight.mock.invocationCallOrder[0]).toBeLessThan( mocks.runShell.mock.invocationCallOrder[0]!, ); - expect(runShell).toHaveBeenCalledWith(opts, '0.0.1-alpha.2'); + expect(runShell).toHaveBeenCalledWith(opts, '0.0.1-alpha.2', { worktree: undefined }); }); it('runs prompt mode without interactive update preflight', async () => { @@ -253,7 +261,10 @@ describe('main entry command handling', () => { track: expect.any(Function), isTTY: false, }); - expect(runPrompt).toHaveBeenCalledWith(opts, '0.0.1-alpha.2'); + expect(runPrompt).toHaveBeenCalledWith(opts, '0.0.1-alpha.2', { + worktree: undefined, + onSessionCreated: expect.any(Function), + }); expect(runShell).not.toHaveBeenCalled(); }); @@ -344,7 +355,7 @@ describe('main entry command handling', () => { expect(runUpdatePreflight).toHaveBeenCalledWith('0.0.1-alpha.2', { track: expect.any(Function), }); - expect(runShell).toHaveBeenCalledWith(opts, '0.0.1-alpha.2'); + expect(runShell).toHaveBeenCalledWith(opts, '0.0.1-alpha.2', { worktree: undefined }); }); it('installs crash handlers before parsing CLI arguments', () => { @@ -381,6 +392,63 @@ describe('main entry command handling', () => { expect(runShell).not.toHaveBeenCalled(); }); + it('cleans up the worktree when the shell runner fails at startup', async () => { + const opts: CLIOptions = { ...defaultOpts(), worktree: 'wt' }; + const worktree = { worktreePath: '/repo/.kimi/worktrees/wt', parentRepoPath: '/repo', effectiveCwd: '/repo/.kimi/worktrees/wt' }; + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' }); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.prepareWorktreeRuntime.mockReturnValue(worktree); + mocks.runShell.mockRejectedValue(new Error('startup boom')); + + await expect(runHandleMainCommand(opts)).rejects.toThrow('startup boom'); + + expect(mocks.cleanupEmptyWorktree).toHaveBeenCalledWith(worktree); + }); + + it('cleans up the worktree when prompt mode fails before a session is created', async () => { + const opts: CLIOptions = { ...defaultOpts(), prompt: 'hi', worktree: 'wt' }; + const worktree = { worktreePath: '/repo/.kimi/worktrees/wt', parentRepoPath: '/repo', effectiveCwd: '/repo/.kimi/worktrees/wt' }; + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'print' }); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.prepareWorktreeRuntime.mockReturnValue(worktree); + // Fail without ever invoking onSessionCreated -> pre-session failure. + mocks.runPrompt.mockRejectedValue(new Error('no model configured')); + + await expect(runHandleMainCommand(opts)).rejects.toThrow('no model configured'); + + expect(mocks.cleanupEmptyWorktree).toHaveBeenCalledWith(worktree); + }); + + it('preserves the worktree when prompt mode fails after a session is created', async () => { + const opts: CLIOptions = { ...defaultOpts(), prompt: 'hi', worktree: 'wt' }; + const worktree = { worktreePath: '/repo/.kimi/worktrees/wt', parentRepoPath: '/repo', effectiveCwd: '/repo/.kimi/worktrees/wt' }; + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'print' }); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.prepareWorktreeRuntime.mockReturnValue(worktree); + // Report a session was created, then fail the turn -> must not clean up. + mocks.runPrompt.mockImplementation(async (_opts, _version, io) => { + io?.onSessionCreated?.(); + throw new Error('turn failed'); + }); + + await expect(runHandleMainCommand(opts)).rejects.toThrow('turn failed'); + + expect(mocks.cleanupEmptyWorktree).not.toHaveBeenCalled(); + }); + + it('does not clean up when no worktree was requested', async () => { + const opts: CLIOptions = { ...defaultOpts(), prompt: 'hi' }; + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'print' }); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.runPrompt.mockRejectedValue(new Error('no model configured')); + + await expect(runHandleMainCommand(opts)).rejects.toThrow('no model configured'); + + // cleanupEmptyWorktree is a no-op for undefined, but assert main passed nothing. + expect(mocks.cleanupEmptyWorktree).toHaveBeenCalledWith(undefined); + expect(mocks.prepareWorktreeRuntime).not.toHaveBeenCalled(); + }); + it('initializes and flushes telemetry around the upgrade command', async () => { const exitCode = await runHandleUpgradeCommand(); diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 73d7598417..5034a1ba19 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -41,6 +41,7 @@ describe('CLI options parsing', () => { expect(opts.outputFormat).toBeUndefined(); expect(opts.prompt).toBeUndefined(); expect(opts.skillsDirs).toEqual([]); + expect(opts.worktree).toBeUndefined(); expect(opts.addDirs).toEqual([]); }); }); @@ -172,6 +173,45 @@ describe('CLI options parsing', () => { }); }); + describe('--worktree', () => { + it('parses --worktree with an explicit name', () => { + const opts = parse(['--worktree', 'my-fix']); + expect(opts.worktree).toBe('my-fix'); + }); + + it('parses --worktree=value with an explicit name', () => { + const opts = parse(['--worktree=my-fix']); + expect(opts.worktree).toBe('my-fix'); + }); + + it('parses -w with an explicit name', () => { + const opts = parse(['-w', 'my-fix']); + expect(opts.worktree).toBe('my-fix'); + }); + + it('bare --worktree yields empty string for auto-naming', () => { + const opts = parse(['--worktree']); + expect(opts.worktree).toBe(''); + }); + + it('bare -w yields empty string for auto-naming', () => { + const opts = parse(['-w']); + expect(opts.worktree).toBe(''); + }); + + it('rejects --worktree combined with --session', () => { + const opts = parse(['--worktree', 'my-fix', '--session', 'ses_123']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow('Cannot combine --worktree with --session.'); + }); + + it('rejects --worktree combined with --continue', () => { + const opts = parse(['--worktree', 'my-fix', '--continue']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow('Cannot combine --worktree with --continue.'); + }); + }); + describe('--auto / --yolo / --plan with --session / --continue', () => { it('allows --auto with --continue', () => { const opts = parse(['--auto', '--continue']); diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index aafb99af90..56a08113b3 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -224,6 +224,23 @@ describe('runPrompt', () => { expect(mocks.harnessClose).toHaveBeenCalled(); }); + it('includes cd in the resume hint for worktree sessions', async () => { + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts(), '1.2.3-test', { + stdout, + stderr, + worktree: { + worktreePath: '/repo/.kimi/worktrees/wt', + parentRepoPath: '/repo', + effectiveCwd: process.cwd(), + }, + }); + + expect(stderr.text()).toBe(`To resume this session: cd '${process.cwd()}' && kimi -r ses_prompt\n`); + }); + it('completes even if harness.close() never resolves (cleanup is time-bounded)', async () => { vi.useFakeTimers(); try { diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index eafc4a9e0e..58e3355f23 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -47,6 +47,7 @@ const mocks = vi.hoisted(() => { tuiGetStartupMcpMs: vi.fn(async () => 0), tuiGetCurrentSessionId: vi.fn(() => ''), tuiHasSessionContent: vi.fn(() => false), + tuiHasEverHadSessionContent: vi.fn(() => false), createKimiDeviceId: vi.fn(() => 'device-1'), initializeTelemetry: vi.fn(), setCrashPhase: vi.fn(), @@ -128,6 +129,7 @@ vi.mock('../../src/tui/index', () => ({ getStartupMcpMs = mocks.tuiGetStartupMcpMs; getCurrentSessionId = mocks.tuiGetCurrentSessionId; hasSessionContent = mocks.tuiHasSessionContent; + hasEverHadSessionContent = mocks.tuiHasEverHadSessionContent; }, })); @@ -154,6 +156,7 @@ describe('runShell', () => { mocks.tuiGetStartupMcpMs.mockResolvedValue(0); mocks.tuiGetCurrentSessionId.mockReturnValue(''); mocks.tuiHasSessionContent.mockReturnValue(false); + mocks.tuiHasEverHadSessionContent.mockReturnValue(false); mocks.createKimiDeviceId.mockImplementation(() => 'device-1'); mocks.resolveKimiHome.mockImplementation( (homeDir?: string) => homeDir ?? '/tmp/kimi-code-test-home', @@ -548,6 +551,7 @@ describe('runShell', () => { mocks.tuiStart.mockResolvedValue(undefined); mocks.tuiGetCurrentSessionId.mockReturnValue('ses-1'); mocks.tuiHasSessionContent.mockReturnValue(true); + mocks.tuiHasEverHadSessionContent.mockReturnValue(true); const stdout = captureProcessWrite('stdout'); const stderr = captureProcessWrite('stderr'); @@ -602,6 +606,7 @@ describe('runShell', () => { mocks.tuiStart.mockResolvedValue(undefined); mocks.tuiGetCurrentSessionId.mockReturnValue('ses-1'); mocks.tuiHasSessionContent.mockReturnValue(true); + mocks.tuiHasEverHadSessionContent.mockReturnValue(true); const stdout = captureProcessWrite('stdout'); const stderr = captureProcessWrite('stderr'); @@ -640,6 +645,59 @@ describe('runShell', () => { } }); + it('includes cd in the resume hint for worktree sessions', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + mocks.tuiGetCurrentSessionId.mockReturnValue('ses-wt'); + mocks.tuiHasSessionContent.mockReturnValue(true); + mocks.tuiHasEverHadSessionContent.mockReturnValue(true); + + const stdout = captureProcessWrite('stdout'); + const stderr = captureProcessWrite('stderr'); + const exitSpy = mockProcessExit(); + + try { + await runShell( + { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + '1.2.3-test', + { + worktree: { + worktreePath: '/repo/.kimi/worktrees/wt', + parentRepoPath: '/repo', + effectiveCwd: process.cwd(), + }, + }, + ); + const [tui] = mocks.kimiTuiConstructor.mock.calls[0]!; + + await expect((tui as { onExit: () => Promise }).onExit()).rejects.toBeInstanceOf( + ExitCalled, + ); + + expect(stderr.text()).toContain( + ` To resume this session: cd '${process.cwd()}' && kimi -r ses-wt`, + ); + } finally { + exitSpy.mockRestore(); + stdout.restore(); + stderr.restore(); + } + }); + it('surfaces an invalid target config as an error for kimi migrate, not silently', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 3503e42715..d51fa60214 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -113,7 +113,7 @@ interface ModelSelectorDriver extends MessageDriver { ): Promise<{ alias: string; thinking: boolean } | undefined>; } -function makeStartupInput(): KimiTUIStartupInput { +function makeStartupInput(overrides: Partial = {}): KimiTUIStartupInput { return { cliOptions: { session: undefined, @@ -135,6 +135,7 @@ function makeStartupInput(): KimiTUIStartupInput { }, version: '0.0.0-test', workDir: '/tmp/proj-a', + ...overrides, }; } @@ -278,13 +279,14 @@ function makeHarness(session = makeSession(), overrides: Record async function makeDriver( session = makeSession(), harnessOverrides: Record = {}, + startupInputOverrides: Partial = {}, ): Promise<{ driver: MessageDriver; session: ReturnType; harness: ReturnType; }> { const harness = makeHarness(session, harnessOverrides); - const driver = new KimiTUI(harness as never, makeStartupInput()) as unknown as MessageDriver; + const driver = new KimiTUI(harness as never, makeStartupInput(startupInputOverrides)) as unknown as MessageDriver; vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {}); vi.spyOn(driver.state.terminal, 'setProgress').mockImplementation(() => {}); driver.persistInputHistory = vi.fn(async () => {}); @@ -943,6 +945,66 @@ command = "vim" expect(stripSgr(renderTranscript(driver))).not.toContain('Post-create setup failed'); }); + it('preserves worktree metadata when /new creates a replacement session', async () => { + const session = makeSession({ id: 'ses-1' }); + const nextSession = makeSession({ id: 'ses-2' }); + const { driver, harness } = await makeDriver(session, {}, { + sessionMetadata: { + worktreePath: '/repo/.kimi/worktrees/wt', + parentRepoPath: '/repo', + }, + }); + harness.createSession.mockResolvedValueOnce(nextSession); + harness.createSession.mockClear(); + + driver.handleUserInput('/new'); + + await vi.waitFor(() => { + expect(harness.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: { + worktreePath: '/repo/.kimi/worktrees/wt', + parentRepoPath: '/repo', + }, + }), + ); + }); + }); + + it('carries forward worktree metadata from a resumed session when /new has no --worktree flags', async () => { + // Resuming a worktree session via `-r ` passes no --worktree CLI flags, + // so startup.metadata is undefined. The worktree paths must still be + // recovered from the resumed session's metadata so the replacement session + // stays in the same worktree checkout. + const session = makeSession({ + id: 'ses-1', + summary: { + title: null, + metadata: { + worktreePath: '/repo/.kimi/worktrees/wt', + parentRepoPath: '/repo', + }, + }, + }); + const nextSession = makeSession({ id: 'ses-2' }); + const { driver, harness } = await makeDriver(session); + harness.createSession.mockResolvedValueOnce(nextSession); + harness.createSession.mockClear(); + + driver.handleUserInput('/new'); + + await vi.waitFor(() => { + expect(harness.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: { + worktreePath: '/repo/.kimi/worktrees/wt', + parentRepoPath: '/repo', + }, + }), + ); + }); + }); + it('keeps the new session subscribed when post-create setup fails', async () => { const initialSession = makeSession({ id: 'ses-initial' }); const failedSession = makeSession({ diff --git a/apps/kimi-code/test/utils/git/worktree.test.ts b/apps/kimi-code/test/utils/git/worktree.test.ts new file mode 100644 index 0000000000..e0fe1740bd --- /dev/null +++ b/apps/kimi-code/test/utils/git/worktree.test.ts @@ -0,0 +1,294 @@ +import { execSync } from 'node:child_process'; +import { existsSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + createWorktree, + findGitRoot, + listWorktrees, + normalizeWorktreeName, + removeWorktree, + WorktreeError, +} from '#/utils/git/worktree'; + +function initRepo(path: string): void { + execSync('git init', { cwd: path, stdio: 'ignore' }); + execSync('git config user.email "test@example.com"', { cwd: path, stdio: 'ignore' }); + execSync('git config user.name "Test"', { cwd: path, stdio: 'ignore' }); + execSync('git commit --allow-empty -m "initial"', { cwd: path, stdio: 'ignore' }); +} + +function makeTempDir(prefix: string): string { + return realpathSync(mkdtempSync(join(tmpdir(), prefix))); +} + +describe('findGitRoot', () => { + it('returns null outside a git repository', () => { + const dir = makeTempDir('kimi-not-git-'); + expect(findGitRoot(dir)).toBeNull(); + }); + + it('finds the repo root from the repo root', () => { + const dir = makeTempDir('kimi-git-root-'); + initRepo(dir); + expect(findGitRoot(dir)).toBe(dir); + }); + + it('finds the repo root from a subdirectory', () => { + const dir = makeTempDir('kimi-git-sub-'); + initRepo(dir); + const subdir = join(dir, 'a', 'b'); + execSync('mkdir -p a/b', { cwd: dir, stdio: 'ignore' }); + expect(findGitRoot(subdir)).toBe(dir); + }); +}); + +describe('createWorktree', () => { + it('creates a detached worktree with the given name', () => { + const dir = makeTempDir('kimi-create-wt-'); + initRepo(dir); + + const wt = createWorktree(dir, 'feature-x'); + + expect(existsSync(wt)).toBe(true); + expect(wt).toContain(join('.kimi', 'worktrees', 'feature-x')); + const branch = execSync('git branch --show-current', { cwd: wt, encoding: 'utf8', stdio: 'pipe' }); + expect(branch.trim()).toBe(''); + }); + + it('auto-generates a three-word slug when none is given', () => { + const dir = makeTempDir('kimi-auto-wt-'); + initRepo(dir); + + const wt = createWorktree(dir); + + expect(existsSync(wt)).toBe(true); + const baseName = wt.split('/').pop(); + expect(baseName).toMatch(/^[a-z0-9]+-[a-z0-9]+-[a-z0-9]+$/); + }); + + it('raises when the worktree directory already exists', () => { + const dir = makeTempDir('kimi-dup-wt-'); + initRepo(dir); + createWorktree(dir, 'dup'); + + expect(() => createWorktree(dir, 'dup')).toThrow(WorktreeError); + expect(() => createWorktree(dir, 'dup')).toThrow('already exists'); + }); + + it('raises outside a git repository', () => { + const dir = makeTempDir('kimi-no-git-'); + expect(() => createWorktree(dir, 'x')).toThrow(WorktreeError); + }); + + it('rejects names with invalid characters', () => { + const dir = makeTempDir('kimi-invalid-wt-'); + initRepo(dir); + + expect(() => createWorktree(dir, 'hello world')).toThrow(WorktreeError); + expect(() => createWorktree(dir, 'foo:bar')).toThrow(WorktreeError); + expect(() => createWorktree(dir, 'foo@bar')).toThrow(WorktreeError); + }); + + it('rejects names with path separators', () => { + const dir = makeTempDir('kimi-sep-wt-'); + initRepo(dir); + + expect(() => createWorktree(dir, 'foo/bar')).toThrow(WorktreeError); + expect(() => createWorktree(dir, '/foo')).toThrow(WorktreeError); + }); + + it('rejects names with dot segments', () => { + const dir = makeTempDir('kimi-dot-wt-'); + initRepo(dir); + + expect(() => createWorktree(dir, '.')).toThrow(WorktreeError); + expect(() => createWorktree(dir, '..')).toThrow(WorktreeError); + expect(() => createWorktree(dir, 'foo/./bar')).toThrow(WorktreeError); + }); + + it('rejects names longer than 64 characters', () => { + const dir = makeTempDir('kimi-long-wt-'); + initRepo(dir); + + const longName = 'a'.repeat(65); + expect(() => createWorktree(dir, longName)).toThrow(WorktreeError); + expect(() => createWorktree(dir, longName)).toThrow('64 characters'); + }); + + it('can create multiple auto-generated worktrees in the same repo', () => { + const dir = makeTempDir('kimi-multi-wt-'); + initRepo(dir); + + const wt1 = createWorktree(dir); + const wt2 = createWorktree(dir); + + expect(existsSync(wt1)).toBe(true); + expect(existsSync(wt2)).toBe(true); + expect(wt1).not.toBe(wt2); + }); + + it('keeps the worktree storage out of the parent git index', () => { + const dir = makeTempDir('kimi-clean-wt-'); + initRepo(dir); + + createWorktree(dir, 'feature-x'); + + expect(existsSync(join(dir, '.kimi', 'worktrees', '.gitignore'))).toBe(true); + const status = execSync('git status --short', { cwd: dir, encoding: 'utf8', stdio: 'pipe' }); + expect(status.trim()).toBe(''); + }); + + it('adds .kimi/worktrees/ to .git/info/exclude so the parent checkout stays clean', () => { + const dir = makeTempDir('kimi-exclude-wt-'); + initRepo(dir); + + createWorktree(dir, 'feature-x'); + + const excludePath = join(dir, '.git', 'info', 'exclude'); + expect(existsSync(excludePath)).toBe(true); + const exclude = execSync('git check-ignore -v .kimi/worktrees/', { + cwd: dir, + encoding: 'utf8', + stdio: 'pipe', + }); + expect(exclude).toContain('.git/info/exclude'); + expect(exclude).toContain('.kimi/worktrees/'); + }); + + it('leaves unrelated .kimi/ content visible to git status', () => { + const dir = makeTempDir('kimi-exclude-unrelated-'); + initRepo(dir); + + createWorktree(dir, 'feature-x'); + + // Another tool's untracked file under .kimi/ must NOT be hidden: the + // exclude marker is scoped to the worktree storage dir, not all of .kimi/. + // (-uall reports files individually rather than collapsing the dir.) + writeFileSync(join(dir, '.kimi', 'other-tool-data'), 'keep me visible\n'); + const status = execSync('git status --porcelain -uall', { + cwd: dir, + encoding: 'utf8', + stdio: 'pipe', + }); + expect(status).toContain('.kimi/other-tool-data'); + expect(status).not.toContain('.kimi/worktrees/'); + }); + + it('excludes .kimi/worktrees/ via the common git dir when repoRoot is a linked worktree', () => { + const dir = makeTempDir('kimi-exclude-mainwt-'); + initRepo(dir); + // From a linked worktree, `git rev-parse --git-dir` points at + // `.git/worktrees/`, but Git reads info/exclude from the common dir. + const linked = join(makeTempDir('kimi-linkedwt-'), 'linked'); + execSync(`git worktree add ${linked}`, { cwd: dir, stdio: 'ignore' }); + + createWorktree(linked, 'feature-x'); + + // check-ignore from inside the linked worktree only matches if + // .kimi/worktrees/ was written to the common info/exclude (not the + // per-worktree git dir). + const exclude = execSync('git check-ignore -v .kimi/worktrees/', { + cwd: linked, + encoding: 'utf8', + stdio: 'pipe', + }); + expect(exclude).toContain('info/exclude'); + expect(exclude).toContain('.kimi/worktrees/'); + }); +}); + +describe('normalizeWorktreeName', () => { + it('trims whitespace', () => { + expect(normalizeWorktreeName(' feature-x ')).toBe('feature-x'); + }); + + it('normalizes #123 to pr-123', () => { + expect(normalizeWorktreeName('#123')).toBe('pr-123'); + expect(normalizeWorktreeName(' #42 ')).toBe('pr-42'); + }); + + it('accepts letters, digits, dots, underscores, and hyphens', () => { + expect(normalizeWorktreeName('feature_2.1-x')).toBe('feature_2.1-x'); + }); + + it('rejects empty names', () => { + expect(() => normalizeWorktreeName('')).toThrow(WorktreeError); + expect(() => normalizeWorktreeName(' ')).toThrow(WorktreeError); + }); + + it('rejects names with slashes', () => { + expect(() => normalizeWorktreeName('foo/bar')).toThrow(WorktreeError); + }); + + it('rejects dot segments', () => { + expect(() => normalizeWorktreeName('.')).toThrow(WorktreeError); + expect(() => normalizeWorktreeName('..')).toThrow(WorktreeError); + }); + + it('rejects invalid characters', () => { + expect(() => normalizeWorktreeName('foo bar')).toThrow(WorktreeError); + expect(() => normalizeWorktreeName('foo:bar')).toThrow(WorktreeError); + expect(() => normalizeWorktreeName('foo@bar')).toThrow(WorktreeError); + }); + + it('rejects names longer than 64 characters', () => { + expect(() => normalizeWorktreeName('a'.repeat(65))).toThrow(WorktreeError); + }); +}); + +describe('removeWorktree', () => { + it('removes a created worktree', () => { + const dir = makeTempDir('kimi-rm-wt-'); + initRepo(dir); + const wt = createWorktree(dir, 'to-remove'); + expect(existsSync(wt)).toBe(true); + + removeWorktree(dir, wt); + + expect(existsSync(wt)).toBe(false); + }); + + it('does not throw for a missing worktree path', () => { + const dir = makeTempDir('kimi-rm-missing-'); + initRepo(dir); + const missing = join(dir, '.kimi', 'worktrees', 'ghost'); + + expect(() => { + removeWorktree(dir, missing); + }).not.toThrow(); + }); + + it('does not delete a dirty registered worktree', () => { + const dir = makeTempDir('kimi-rm-dirty-'); + initRepo(dir); + const wt = createWorktree(dir, 'dirty'); + const dirtyFile = join(wt, 'dirty-file.txt'); + execSync('touch dirty-file.txt', { cwd: wt, stdio: 'ignore' }); + + expect(() => { + removeWorktree(dir, wt); + }).toThrow(WorktreeError); + expect(existsSync(wt)).toBe(true); + expect(existsSync(dirtyFile)).toBe(true); + }); +}); + +describe('listWorktrees', () => { + it('lists created worktrees', () => { + const dir = makeTempDir('kimi-list-wt-'); + initRepo(dir); + const wt1 = createWorktree(dir, 'wt1'); + const wt2 = createWorktree(dir, 'wt2'); + + const list = listWorktrees(dir); + expect(list).not.toBeNull(); + const paths = list!.map((w) => w.path); + + expect(paths).toContain(wt1); + expect(paths).toContain(wt2); + }); +}); diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index b6688148fe..ef5f51c477 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -23,6 +23,7 @@ All flags are optional — run `kimi` directly to enter an interactive session: | `--yolo` | `-y` | Auto-approve regular tool calls, skipping approval requests | | `--auto` | | Start with auto permission mode; tool approvals are handled automatically and the Agent will not ask the user questions | | `--plan` | | Start a new session in Plan mode — the AI will prioritize read-only tools for exploration and planning | +| `--worktree [name]` | `-w` | Create a new git worktree for this session. When no name is given, a three-word slug is generated from the bundled name database (e.g. `amber-drifting-cloud`). The worktree is created in detached HEAD at the current commit | | `--skills-dir ` | | Load Skills from the specified directory, replacing the automatically discovered user and project directories. Can be repeated | | `--add-dir ` | | Add an extra workspace directory for this session. Relative paths resolve against the current working directory. Can be repeated | @@ -39,6 +40,7 @@ The following combinations are rejected at startup: - `--continue` and `--session` are mutually exclusive — both mean "resume a previous session" - `--yolo` and `--auto` are mutually exclusive — the two permission modes cannot be combined - `--prompt` cannot be used with `--yolo`, `--auto`, or `--plan` — non-interactive mode uses `auto` permission by default +- `--worktree` cannot be used with `--session` or `--continue` — a worktree is only created for a new session - `--output-format` can only be used together with `--prompt` When resuming a session, you can override its saved permission or plan mode by adding `--auto`, `--yolo`, or `--plan`. For example, `kimi --continue --auto` resumes the latest session and switches it to auto permission mode. @@ -82,6 +84,24 @@ Read the code and produce an implementation plan before making any file changes: kimi --plan ``` +### Isolated Worktree Sessions + +Start a session in a fresh git worktree to avoid interfering with the main working tree or other active sessions: + +```sh +# Auto-generate a three-word slug like amber-drifting-cloud +kimi -w + +# Specify a custom worktree name (use = to avoid the next token being consumed) +kimi --worktree=refactor-auth +``` + +The worktree is created under `/.kimi/worktrees/` in a detached HEAD checkout at the current commit. Empty worktree sessions are cleaned up automatically on exit; sessions with content are left in place so work is preserved. + +Worktree names are validated slugs: at most 64 characters, no `/`, and each part may contain only letters, digits, `.`, `_`, and `-`. The names `.` and `..` are rejected. A leading `#123` is normalized to `pr-123`. + +Because `--worktree` accepts an optional name, a trailing positional argument can be misread as the worktree name. Prefer `--worktree=` or `--worktree ` with the name immediately after the flag. + ### Custom Skills Directories There are two ways to specify Skills directories, with different semantics: diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 0234f4bc54..474c1a807d 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -23,6 +23,7 @@ kimi [options] | `--yolo` | `-y` | 自动批准普通工具调用,跳过审批请求 | | `--auto` | | 以 auto 权限模式启动;工具审批自动处理,Agent 不会向用户提问 | | `--plan` | | 以 Plan 模式启动新会话,AI 会优先使用只读工具进行探索和规划 | +| `--worktree [name]` | `-w` | 为本次会话创建一个新的 git worktree。省略名称时从内置名称库自动生成一个三词 slug(如 `amber-drifting-cloud`);工作区以 detached HEAD 方式基于当前 commit 创建 | | `--skills-dir ` | | 从指定目录加载 Skills,替换自动发现的用户和项目目录。可重复传入 | | `--add-dir ` | | 为本次会话添加额外的工作目录。相对路径按当前工作目录解析。可重复传入 | @@ -39,6 +40,7 @@ kimi [options] - `--continue` 与 `--session` 互斥——两者都表示"恢复历史会话" - `--yolo` 和 `--auto` 互斥——两种权限模式互斥 - `--prompt` 不能与 `--yolo`、`--auto` 或 `--plan` 同时使用——非交互模式固定使用 `auto` 权限 +- `--worktree` 不能与 `--session` 或 `--continue` 同时使用——worktree 仅用于新建会话 - `--output-format` 只能与 `--prompt` 一起使用 恢复会话时,可以通过 `--auto`、`--yolo` 或 `--plan` 覆盖原会话保存的权限或计划模式。例如,`kimi --continue --auto` 会恢复最近会话并切换到 auto 权限模式。 @@ -82,6 +84,24 @@ kimi --auto kimi --plan ``` +### 隔离的 Worktree 会话 + +在全新的 git worktree 中启动会话,避免干扰主工作区或其他正在运行的会话: + +```sh +# 自动生成类似 amber-drifting-cloud 的三词 slug +kimi -w + +# 指定自定义 worktree 名称(建议用 = 避免下一个参数被误读为名称) +kimi --worktree=refactor-auth +``` + +工作区创建在 `/.kimi/worktrees/`,以 detached HEAD 方式基于当前 commit 检出。空的 worktree 会话在退出时会自动清理;包含内容的会话会保留,以免丢失工作成果。 + +Worktree 名称是受限的 slug:最多 64 个字符,不能包含 `/`,每个分段只能包含字母、数字、`.`、`_` 和 `-`。`.` 和 `..` 被禁用。以 `#123` 开头的名称会被规范化为 `pr-123`。 + +由于 `--worktree` 的名称是可选的,末尾的位置参数可能会被误解析为 worktree 名称。建议优先使用 `--worktree=`,或在 flag 后紧跟名称。 + ### 自定义 Skills 目录 有两种方式指定 Skills 目录,语义不同: diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 6b9d7cf19f..f97b632214 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -357,6 +357,7 @@ export class Agent { skills: this.skills?.registry, cwdListing: context?.cwdListing, agentsMd: context?.agentsMd, + worktreeInfo: context?.worktreeInfo, additionalDirsInfo: context?.additionalDirsInfo, }); this.config.update({ profileName: profile.name, systemPrompt }); diff --git a/packages/agent-core/src/profile/context.ts b/packages/agent-core/src/profile/context.ts index a701b8b735..d9f129c6b7 100644 --- a/packages/agent-core/src/profile/context.ts +++ b/packages/agent-core/src/profile/context.ts @@ -17,7 +17,10 @@ const S_IFMT = 0o170000; const S_IFREG = 0o100000; export interface PreparedSystemPromptContext - extends Pick { + extends Pick< + SystemPromptContext, + 'cwdListing' | 'agentsMd' | 'worktreeInfo' | 'additionalDirsInfo' + > { /** Present when the combined AGENTS.md content exceeds the recommended size. */ readonly agentsMdWarning?: string; } diff --git a/packages/agent-core/src/profile/default/system.md b/packages/agent-core/src/profile/default/system.md index 0671cd1084..defc8ec66a 100644 --- a/packages/agent-core/src/profile/default/system.md +++ b/packages/agent-core/src/profile/default/system.md @@ -92,6 +92,10 @@ The current date and time in ISO format is `{{ KIMI_NOW }}`. This was captured w The current working directory is `{{ KIMI_WORK_DIR }}`. This should be considered as the project root if you are instructed to perform tasks on the project. Tools may require absolute paths for some parameters, IF SO, YOU MUST use absolute paths for these parameters. +{% if KIMI_WORKTREE_INFO %} +{{ KIMI_WORKTREE_INFO }} + +{% endif %} Use this as your basic understanding of the project structure. The tree only shows the first two levels for normal directories; entries marked "... and N more" indicate additional contents. Hidden directories are shown as entries only; their contents are intentionally omitted to reduce noise. To inspect hidden paths the tree leaves out, prefer the dedicated tools over `ls -A`. `Glob` matches dotfiles by default — use `.*` for top-level dotfiles, or anchor on a directory such as `.github/**` or `.agents/**` to walk it; avoid bare `node_modules/**`-style dependency walks, which can flood the result cap; `.git/**` returns nothing at all — `Glob`, like `Grep`, always skips VCS metadata. Use `Read` for a known hidden file and `Grep` to search hidden file contents. `Grep` searches hidden files by default but skips VCS metadata (`.git` and the like) and filters secrets out of its results; `Read`, `Write`, and `Edit` refuse a fixed set of well-known secret files — `.env`, SSH private keys, and a few credential files — by design; that guard does not recognize every secret format, so judge other credential-bearing files yourself. `Bash` enforces none of these path or secret guards — it runs whatever command you give it — so the same discipline is on you there: do not use shell commands (`cat`, `cp`, `curl`, and the like) to read, copy, or transmit secret files, and stay inside the working directory unless the user has explicitly directed otherwise. diff --git a/packages/agent-core/src/profile/index.ts b/packages/agent-core/src/profile/index.ts index 80e303814b..ad80859025 100644 --- a/packages/agent-core/src/profile/index.ts +++ b/packages/agent-core/src/profile/index.ts @@ -1,5 +1,6 @@ export * from './types'; export * from './context'; +export * from './worktree-info'; export * from './load'; export * from './resolve'; export * from './default'; diff --git a/packages/agent-core/src/profile/resolve.ts b/packages/agent-core/src/profile/resolve.ts index e73b7b4fcf..823486f5d0 100644 --- a/packages/agent-core/src/profile/resolve.ts +++ b/packages/agent-core/src/profile/resolve.ts @@ -5,6 +5,7 @@ import type { ResolvedAgentProfile, SystemPromptContext, SystemPromptRenderer, + WorktreeInfo, } from './types'; interface MergedAgentProfile { @@ -161,11 +162,24 @@ function buildTemplateVars( KIMI_AGENTS_MD: context.agentsMd ?? '', KIMI_SKILLS: tools.includes('Skill') ? skills : '', KIMI_ADDITIONAL_DIRS_INFO: context.additionalDirsInfo ?? '', + KIMI_WORKTREE_INFO: renderWorktreeInfo(context.worktreeInfo), ROLE_ADDITIONAL: context.roleAdditional ?? promptVars['ROLE_ADDITIONAL'] ?? promptVars['roleAdditional'] ?? '', }; } +function renderWorktreeInfo(worktreeInfo?: WorktreeInfo): string { + if (worktreeInfo === undefined) { + return ''; + } + return [ + 'You are running inside a git worktree that was created for this session.', + `Worktree path: ${worktreeInfo.worktreePath}`, + `Parent repository: ${worktreeInfo.parentRepoPath}`, + 'Treat the worktree as the active project workspace; all relative paths and shell commands run from this directory unless the user explicitly changes scope.', + ].join('\n'); +} + function applySubagentDescriptions( mergedProfiles: Map, resolvedProfiles: Map, diff --git a/packages/agent-core/src/profile/types.ts b/packages/agent-core/src/profile/types.ts index 27d407c3b3..537c1aad61 100644 --- a/packages/agent-core/src/profile/types.ts +++ b/packages/agent-core/src/profile/types.ts @@ -25,6 +25,18 @@ export const RawAgentProfileSchema = z.object({ export type RawAgentProfile = z.infer; +/** + * Information about a git worktree the agent is running in. + * + * Populated when the session was launched with `--worktree`. The agent sees + * this in its system prompt so it knows it is in an isolated checkout and + * where the parent repository lives. + */ +export interface WorktreeInfo { + readonly worktreePath: string; + readonly parentRepoPath: string; +} + /** * Runtime context supplied to a system prompt renderer. * @@ -42,6 +54,7 @@ export interface SystemPromptContext { readonly skills?: SkillRegistry | string; readonly additionalDirsInfo?: string; readonly roleAdditional?: string; + readonly worktreeInfo?: WorktreeInfo; } export type SystemPromptRenderer = (context: SystemPromptContext) => string; diff --git a/packages/agent-core/src/profile/worktree-info.ts b/packages/agent-core/src/profile/worktree-info.ts new file mode 100644 index 0000000000..36b6a6336e --- /dev/null +++ b/packages/agent-core/src/profile/worktree-info.ts @@ -0,0 +1,22 @@ +import type { WorktreeInfo } from './types'; + +/** + * Extracts git worktree metadata from a session's custom metadata bag. + * + * Populated when a session was launched with `--worktree`; the flat + * `worktreePath` / `parentRepoPath` pair is persisted on the session so that + * resumed sessions and spawned subagents keep the worktree system-prompt + * context. Kept separate from prepared-prompt-context loading so changes to + * AGENTS.md handling and worktree parsing do not collide. + */ +export function getWorktreeInfoFromSessionMetadata(metadata: { + readonly custom: Record; +}): WorktreeInfo | undefined { + const custom = metadata.custom; + const worktreePath = custom?.['worktreePath']; + const parentRepoPath = custom?.['parentRepoPath']; + if (typeof worktreePath === 'string' && typeof parentRepoPath === 'string') { + return { worktreePath, parentRepoPath }; + } + return undefined; +} diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 75f1a302ed..ee0b694652 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -34,9 +34,11 @@ import type { EnabledPluginSessionStart, PluginCommandDef } from '../plugin'; import { DEFAULT_AGENT_PROFILES, DEFAULT_INIT_PROMPT, + getWorktreeInfoFromSessionMetadata, loadAgentsMd, prepareSystemPromptContext, type ResolvedAgentProfile, + type WorktreeInfo, } from '../profile'; import type { ProviderManager } from './provider-manager'; import { @@ -548,6 +550,15 @@ export class Session { return (await this.resumeAgent(id)).agent; } + /** + * Returns the git worktree metadata stored on the session, if any. + * + * Populated when the session was launched with `--worktree`. + */ + getWorktreeInfo(): WorktreeInfo | undefined { + return getWorktreeInfoFromSessionMetadata(this.metadata); + } + /** * Applies a profile's derived config — cwd, system prompt, active tools — to * an agent. Fresh creation and resume-of-an-incomplete-wire both route @@ -562,7 +573,7 @@ export class Session { this.options.kimiHomeDir, { additionalDirs: this.additionalDirs }, ); - agent.useProfile(profile, context, this.options.kimiHomeDir); + agent.useProfile(profile, { ...context, worktreeInfo: this.getWorktreeInfo() }, this.options.kimiHomeDir); const { agentsMdWarning } = context; if (agentsMdWarning !== undefined) { this.agentsMdWarning = agentsMdWarning; diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 7aa81fdf3a..704b3bb0ee 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -12,6 +12,7 @@ import { InMemoryAgentRecordPersistence } from '../agent/records'; import { isAbortError } from '../loop/errors'; import { DEFAULT_AGENT_PROFILES, + getWorktreeInfoFromSessionMetadata, prepareSystemPromptContext, type ResolvedAgentProfile, } from '../profile'; @@ -374,7 +375,7 @@ export class SessionSubagentHost { this.session.options.kimiHomeDir, { additionalDirs: child.getAdditionalDirs() }, ); - child.useProfile(profile, context, this.session.options.kimiHomeDir); + child.useProfile(profile, { ...context, worktreeInfo: getWorktreeInfoFromSessionMetadata(this.session.metadata) }, this.session.options.kimiHomeDir); child.tools.inheritUserTools(parent.tools); } diff --git a/packages/agent-core/test/profile/agent-profile-loader.test.ts b/packages/agent-core/test/profile/agent-profile-loader.test.ts index bf744bacd7..c018e873e9 100644 --- a/packages/agent-core/test/profile/agent-profile-loader.test.ts +++ b/packages/agent-core/test/profile/agent-profile-loader.test.ts @@ -234,6 +234,27 @@ describe('default agent profiles', () => { expect(second).toContain('/workspace/two'); expect(second).not.toContain('/workspace/one'); }); + + it('renders worktree info in the default prompt when provided', () => { + const prompt = DEFAULT_AGENT_PROFILES['agent']?.systemPrompt({ + ...promptContext, + worktreeInfo: { + worktreePath: '/repo/.kimi/worktrees/test-worktree', + parentRepoPath: '/repo', + }, + }); + + expect(prompt).toContain('You are running inside a git worktree'); + expect(prompt).toContain('Worktree path: /repo/.kimi/worktrees/test-worktree'); + expect(prompt).toContain('Parent repository: /repo'); + }); + + it('omits worktree info from the default prompt when not provided', () => { + const prompt = DEFAULT_AGENT_PROFILES['agent']?.systemPrompt(promptContext); + + expect(prompt).not.toContain('You are running inside a git worktree'); + expect(prompt).not.toContain('Worktree path:'); + }); }); async function write(fileName: string, content: string): Promise {