diff --git a/.changeset/add-worktree-flag.md b/.changeset/add-worktree-flag.md new file mode 100644 index 000000000..e814d5a2d --- /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 bdc886c4d..2290e3908 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -74,7 +74,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); @@ -111,6 +117,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'] as boolean, @@ -121,6 +130,7 @@ export function createProgram( outputFormat: raw['outputFormat'] as CLIOptions['outputFormat'], prompt: raw['prompt'] as string | undefined, skillsDirs: raw['skillsDir'] as string[], + worktree: worktreeValue, }; onMain(opts); diff --git a/apps/kimi-code/src/cli/options.ts b/apps/kimi-code/src/cli/options.ts index 98f4cb196..320ef5232 100644 --- a/apps/kimi-code/src/cli/options.ts +++ b/apps/kimi-code/src/cli/options.ts @@ -11,6 +11,11 @@ export interface CLIOptions { outputFormat: PromptOutputFormat | undefined; prompt: string | undefined; skillsDirs: string[]; + worktree?: string; + /** Populated during startup when --worktree is used. */ + worktreePath?: string; + /** Populated during startup when --worktree is used. */ + parentRepoPath?: string; } export interface ValidatedOptions { @@ -55,5 +60,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/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index f7cef067d..9131a6794 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -31,6 +31,7 @@ import { } from './goal-prompt'; import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry'; import { createKimiCodeHostIdentity } from './version'; +import { quoteShellArg } from '#/utils/shell-quote'; interface PromptOutput { readonly columns?: number | undefined; @@ -156,7 +157,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, opts.worktreePath !== undefined ? workDir : undefined); withTelemetryContext({ sessionId: session.id }).track('exit', { duration_s: (Date.now() - startedAt) / 1000, @@ -290,7 +291,16 @@ async function resolvePromptSession( } const model = requireConfiguredModel(opts.model, defaultModel); - const session = await harness.createSession({ workDir, model, permission: 'auto' }); + const metadata = + opts.worktreePath !== undefined && opts.parentRepoPath !== undefined + ? { worktreePath: opts.worktreePath, parentRepoPath: opts.parentRepoPath } + : undefined; + // 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 }); installHeadlessHandlers(session); return { session, @@ -589,8 +599,12 @@ function writeResumeHint( outputFormat: PromptOutputFormat, stdout: PromptOutput, stderr: PromptOutput, + workDir?: string, ): void { - const command = `kimi -r ${sessionId}`; + const command = + workDir !== undefined + ? `cd ${quoteShellArg(workDir)} && kimi -r ${sessionId}` + : `kimi -r ${sessionId}`; 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 e5bdfef24..b03a58d04 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -2,6 +2,9 @@ import { execSync } from 'node:child_process'; import { homedir } from 'node:os'; import { join } from 'node:path'; +import { removeWorktree } from '#/utils/git/worktree'; +import { quoteShellArg } from '#/utils/shell-quote'; + import { setCrashPhase, setTelemetryContext, @@ -133,14 +136,31 @@ 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_s: (Date.now() - startedAt) / 1000 }); + + // 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 && opts.worktreePath !== undefined && opts.parentRepoPath !== undefined) { + try { + removeWorktree(opts.parentRepoPath, opts.worktreePath); + } catch (cleanupError) { + // Best-effort cleanup only; do not let cleanup failures prevent a clean exit. + log.warn('Failed to clean up git worktree on exit', cleanupError); + } + } + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); const gutter = ' '.repeat(CHROME_GUTTER); process.stdout.write(`${gutter}Bye!\n`); if (sessionId !== '' && hasContent) { - process.stderr.write(`\n${gutter}To resume this session: kimi -r ${sessionId}\n`); + const resumeCommand = + opts.worktreePath !== undefined + ? `cd ${quoteShellArg(process.cwd())} && kimi -r ${sessionId}` + : `kimi -r ${sessionId}`; + process.stderr.write(`\n${gutter}To resume this session: ${resumeCommand}\n`); } process.exit(exitCode); }; diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index e94472590..3ea188c71 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -27,6 +27,9 @@ 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 { existsSync } from 'node:fs'; +import { relative, resolve } from 'node:path'; +import { createWorktree, findGitRoot, removeWorktree, 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'; @@ -38,6 +41,35 @@ import { cleanupStaleNativeCacheForCurrent } from './native/native-assets'; import { installNativeModuleHook } from './native/module-hook'; import { runNativeAssetSmokeIfRequested } from './native/smoke'; +function prepareWorktree(worktreeName: string): { worktreePath: string; parentRepoPath: string } { + 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 (error) { + removeWorktree(repoRoot, worktreePath); + throw new WorktreeError( + `Failed to enter worktree directory: ${effectiveCwd}. The worktree has been removed.`, + ); + } + return { worktreePath, parentRepoPath: repoRoot }; +} + export async function handleMainCommand(opts: CLIOptions, version: string): Promise { let validated: ReturnType; try { @@ -58,12 +90,46 @@ export async function handleMainCommand(opts: CLIOptions, version: string): Prom process.exit(0); } - if (validated.uiMode === 'print') { - await runPrompt(validated.options, version); - return; + if (opts.worktree !== undefined) { + try { + const { worktreePath, parentRepoPath } = await prepareWorktree(opts.worktree); + opts.worktreePath = worktreePath; + opts.parentRepoPath = parentRepoPath; + } catch (error) { + if (error instanceof WorktreeError) { + process.stderr.write(`error: ${error.message}\n`); + process.exit(1); + } + throw error; + } } - await runShell(validated.options, version); + try { + if (validated.uiMode === 'print') { + await runPrompt(validated.options, version); + return; + } + + await runShell(validated.options, version); + } catch (error) { + // If the shell runner failed during startup after we created a worktree, + // the worktree is still empty (no session ran), so clean it up to avoid + // leaks. Print mode intentionally leaves the worktree inspectable even on + // failure, so we do not clean it up here. + if ( + validated.uiMode !== 'print' && + opts.worktreePath !== undefined && + opts.parentRepoPath !== undefined + ) { + try { + removeWorktree(opts.parentRepoPath, opts.worktreePath); + } catch (cleanupError) { + // Best-effort cleanup only; do not let cleanup failures mask the original error. + log.warn('Failed to clean up git worktree after runner startup failed', cleanupError); + } + } + 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 330f9c7f1..e2ce08327 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -14,6 +14,7 @@ import type { ApprovalResponse, BackgroundTaskInfo, CreateSessionOptions, + JsonObject, KimiHarness, PermissionMode, PromptPart, @@ -195,6 +196,35 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { }; } +function buildSessionMetadata(cliOptions: CLIOptions): JsonObject | undefined { + if (cliOptions.worktreePath === undefined || cliOptions.parentRepoPath === undefined) { + return undefined; + } + return { + worktreePath: cliOptions.worktreePath, + parentRepoPath: cliOptions.parentRepoPath, + }; +} + +// Recover 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 +// 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 buildSessionMetadata produces. +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 }; +} + interface SendMessageOptions { readonly parts?: readonly PromptPart[]; readonly imageAttachmentIds?: readonly number[]; @@ -228,6 +258,7 @@ export class KimiTUI { private startupNotice: string | undefined; private lastActivityMode: string | undefined; private lastHistoryContent: string | undefined; + private everHadSessionContent = false; readonly streamingUI: StreamingUIController; readonly authFlow: AuthFlowController; readonly btwPanelController: BtwPanelController; @@ -268,6 +299,7 @@ export class KimiTUI { plan: startupInput.cliOptions.plan, model: startupInput.cliOptions.model, startupNotice: startupInput.startupNotice, + metadata: buildSessionMetadata(startupInput.cliOptions), }, }; this.options = tuiOptions; @@ -562,6 +594,7 @@ export class KimiTUI { model: startup.model, permission: startup.auto ? 'auto' : startup.yolo ? 'yolo' : undefined, planMode: startup.plan ? true : undefined, + metadata: startup.metadata, }; try { @@ -1000,6 +1033,7 @@ export class KimiTUI { pushTranscriptEntry(entry: TranscriptEntry): void { this.state.transcriptEntries.push(entry); + this.recordSessionContent(); } setExternalEditorRunning(running: boolean): void { @@ -1023,7 +1057,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; } async getStartupMcpMs(): Promise { @@ -1087,6 +1141,7 @@ export class KimiTUI { this.session === undefined ? undefined : this.state.appState.thinking ? 'on' : 'off', permission: this.state.appState.permissionMode, planMode: this.state.appState.planMode ? true : undefined, + metadata: this.options.startup.metadata ?? worktreeMetadataFromSession(this.session), }); } @@ -1465,6 +1520,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); @@ -1519,6 +1575,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/types.ts b/apps/kimi-code/src/tui/types.ts index 6b407f777..47e3b4679 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, @@ -199,6 +200,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 000000000..b523402ee --- /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 000000000..2dff800a9 --- /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 000000000..9c9295531 --- /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 000000000..9e63766d0 --- /dev/null +++ b/apps/kimi-code/src/utils/git/worktree.ts @@ -0,0 +1,314 @@ +/** + * 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 parent `.kimi/` directory does not dirty the repository checkout. + * + * We add `.kimi/` to `.git/info/exclude` (local to this clone) rather than + * modifying any tracked `.gitignore` file. This keeps the worktree storage + * entirely out of `git status` without polluting the repo with untracked + * ignore rules. + */ +function ensureKimiDirIgnored(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 = '.kimi/'; + + 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); + ensureKimiDirIgnored(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 52aba94b1..0f34b71a6 100644 --- a/apps/kimi-code/test/cli/main.test.ts +++ b/apps/kimi-code/test/cli/main.test.ts @@ -144,6 +144,7 @@ function defaultOpts(): CLIOptions { outputFormat: undefined, prompt: undefined, skillsDirs: [], + worktree: undefined, }; } diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index f4fb7d7e9..2d91d8daa 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(); }); }); @@ -167,6 +168,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 a3620aa35..af8ea68bd 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -216,6 +216,19 @@ 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({ worktreePath: '/repo/.kimi/worktrees/wt', parentRepoPath: '/repo' }), + '1.2.3-test', + { stdout, stderr }, + ); + + expect(stderr.text()).toBe(`To resume this session: cd '${process.cwd()}' && kimi -r ses_prompt\n`); + }); + it('stops prompt startup when session creation fails', async () => { const stdout = writer(); const stderr = writer(); diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index bab4fb152..5ebfe3a90 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', @@ -557,6 +560,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,54 @@ 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: [], + worktreePath: '/repo/.kimi/worktrees/wt', + parentRepoPath: '/repo', + }, + '1.2.3-test', + ); + 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 da8df93ce..9f1afb9b3 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 @@ -87,7 +87,7 @@ interface ModelSelectorDriver extends MessageDriver { ): Promise<{ alias: string; thinking: boolean } | undefined>; } -function makeStartupInput(): KimiTUIStartupInput { +function makeStartupInput(overrides: Partial = {}): KimiTUIStartupInput { return { cliOptions: { session: undefined, @@ -108,6 +108,7 @@ function makeStartupInput(): KimiTUIStartupInput { }, version: '0.0.0-test', workDir: '/tmp/proj-a', + ...overrides, }; } @@ -240,13 +241,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 () => {}); @@ -594,6 +596,75 @@ 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, {}, { + cliOptions: { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + 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 000000000..bcedf40a1 --- /dev/null +++ b/apps/kimi-code/test/utils/git/worktree.test.ts @@ -0,0 +1,270 @@ +import { execSync } from 'node:child_process'; +import { existsSync, mkdtempSync, realpathSync } 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/ 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/', { cwd: dir, encoding: 'utf8', stdio: 'pipe' }); + expect(exclude).toContain('.git/info/exclude'); + expect(exclude).toContain('.kimi/'); + }); + + it('excludes .kimi/ 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/ was + // written to the common info/exclude (not the per-worktree git dir). + const exclude = execSync('git check-ignore -v .kimi/', { + cwd: linked, + encoding: 'utf8', + stdio: 'pipe', + }); + expect(exclude).toContain('info/exclude'); + expect(exclude).toContain('.kimi/'); + }); +}); + +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 a0623445b..aa87cb3e1 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 | `-r` / `--resume` is a hidden alias for `--session`; `--yes` and `--auto-approve` are hidden aliases for `--yolo` and are not shown in help output. @@ -38,6 +39,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. @@ -81,6 +83,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 9e8c9180b..261bf2f7a 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,替换自动发现的用户和项目目录。可重复传入 | `-r` / `--resume` 是 `--session` 的隐藏别名;`--yes` 和 `--auto-approve` 是 `--yolo` 的隐藏别名,在帮助信息中不显示。 @@ -38,6 +39,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 权限模式。 @@ -81,6 +83,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 392c1d4ea..e3439bd6a 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -288,6 +288,7 @@ export class Agent { skills: this.skills?.registry, cwdListing: context?.cwdListing, agentsMd: context?.agentsMd, + worktreeInfo: context?.worktreeInfo, }); this.config.update({ profileName: profile.name, systemPrompt }); this.tools.setActiveTools(profile.tools); diff --git a/packages/agent-core/src/profile/context.ts b/packages/agent-core/src/profile/context.ts index 49d8d8105..1c79a3c3b 100644 --- a/packages/agent-core/src/profile/context.ts +++ b/packages/agent-core/src/profile/context.ts @@ -3,7 +3,9 @@ import { dirname, join } from 'pathe'; import type { Kaos } from '@moonshot-ai/kaos'; import { listDirectory } from '../tools/support/list-directory'; -import type { SystemPromptContext } from './types'; +import type { SystemPromptContext, WorktreeInfo } from './types'; + +export type { WorktreeInfo }; const AGENTS_MD_MAX_BYTES = 32 * 1024; const AGENTS_MD_TRUNCATION_MARKER = @@ -11,7 +13,10 @@ const AGENTS_MD_TRUNCATION_MARKER = const S_IFMT = 0o170000; const S_IFREG = 0o100000; -export type PreparedSystemPromptContext = Pick; +export type PreparedSystemPromptContext = Pick< + SystemPromptContext, + 'cwdListing' | 'agentsMd' | 'worktreeInfo' +>; export async function prepareSystemPromptContext( kaos: Kaos, @@ -24,6 +29,18 @@ export async function prepareSystemPromptContext( return { cwdListing, agentsMd }; } +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; +} + export async function loadAgentsMd(kaos: Kaos, brandHome?: string): Promise { const workDir = kaos.getcwd(); const projectRoot = await findProjectRoot(kaos, workDir); diff --git a/packages/agent-core/src/profile/default/system.md b/packages/agent-core/src/profile/default/system.md index d3b0084cc..1e3a6e2fa 100644 --- a/packages/agent-core/src/profile/default/system.md +++ b/packages/agent-core/src/profile/default/system.md @@ -85,6 +85,10 @@ The current date and time in ISO format is `{{ KIMI_NOW }}`. This is only a refe 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. Every file system operation will be relative to the working directory if you do not explicitly specify the absolute path. 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. If the task requires inspecting hidden paths, use `Glob` to discover them (for example `.*`, `.github/**`, `.agents/**`, or `.git/**`), use `Read` for known non-sensitive hidden files, and use `Grep` to search hidden file contents. `Grep` searches hidden files by default but excludes VCS metadata and sensitive files such as `.env`, credential stores, and SSH keys. Use `Bash` only for raw listings like `ls -A` when a dedicated tool is not appropriate. diff --git a/packages/agent-core/src/profile/resolve.ts b/packages/agent-core/src/profile/resolve.ts index 001f7d19f..ac285ebc5 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 { @@ -160,11 +161,24 @@ function buildTemplateVars( KIMI_AGENTS_MD: context.agentsMd ?? '', KIMI_SKILLS: 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 27d407c3b..537c1aad6 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/session/index.ts b/packages/agent-core/src/session/index.ts index ca8c531a9..038d33715 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -23,9 +23,11 @@ import type { EnabledPluginSessionStart } 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 { @@ -395,6 +397,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 @@ -408,7 +419,7 @@ export class Session { this.systemContextKaos(agent.kaos.getcwd()), this.options.kimiHomeDir, ); - agent.useProfile(profile, context); + agent.useProfile(profile, { ...context, worktreeInfo: this.getWorktreeInfo() }); } async generateAgentsMd(): Promise { diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index b47e1cd68..2272d9229 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'; @@ -366,7 +367,10 @@ export class SessionSubagentHost { this.session.systemContextKaos(child.kaos.getcwd()), this.session.options.kimiHomeDir, ); - child.useProfile(profile, context); + child.useProfile(profile, { + ...context, + worktreeInfo: getWorktreeInfoFromSessionMetadata(this.session.metadata), + }); 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 6f305a6a9..dc1c8f0ef 100644 --- a/packages/agent-core/test/profile/agent-profile-loader.test.ts +++ b/packages/agent-core/test/profile/agent-profile-loader.test.ts @@ -233,6 +233,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 {