vercel/next.js · #99232

[ai-upgrade] Ask user whether to start upgrade in worktree

devjiwonchoi · merged Sep 28, 20265 files · 138 + / 17 −
packages/next/src/cli/next-upgrade.ts4 + / 2 −
@@ -448,11 +448,13 @@ export async function spawnNextUpgrade(       const taskSummary = needsVersionUpdate         ? `We're upgrading the app in ${JSON.stringify(baseDir)} from Next.js ${result.installedVersion} to ${result.targetVersion} because ${reason}.`         : `We're adopting the Future Defaults available to the app in ${JSON.stringify(baseDir)}, which already uses Next.js ${result.installedVersion}.`-      const prompt = `Read and follow ${JSON.stringify(sharedGuidePath)} first. Attempt its applicable duplicate checks before changing files. If a check is unavailable, report it and continue. Stop only if you find equivalent work. Then read and follow every applicable instruction in ${JSON.stringify(guidePath)}.+      const prompt = (+        useWorktree: boolean | null+      ) => `Read and follow ${JSON.stringify(sharedGuidePath)} first. Attempt its applicable duplicate checks before changing files. If a check is unavailable, report it and continue. Stop only if you find equivalent work. Then read and follow every applicable instruction in ${JSON.stringify(guidePath)}.  ${taskSummary} -If the app is in a Git repository, perform the upgrade in a separate Git worktree unless the user explicitly requests otherwise. Run upgrade commands from this app's corresponding directory in that worktree. If the app is not in a Git repository, upgrade it in place.+${useWorktree === null ? "Follow the user's worktree choice. If they do not specify, use a separate Git worktree when the app is in a Git repository. Run upgrade commands from this app's corresponding directory in that worktree. If the app is not in a Git repository, upgrade it in place." : useWorktree ? "If the app is in a Git repository, perform the upgrade in a separate Git worktree. Run upgrade commands from this app's corresponding directory in that worktree. If the app is not in a Git repository, upgrade it in place." : 'Perform the upgrade in the current checkout.'}  Set \`experimental.agenticAutoUpgrade\` to ${JSON.stringify(upgradeType)} in the app's Next.js config as part of this upgrade. Preserve unrelated configuration. If the target Next.js version does not support this option, skip the setting and report why. 
packages/next/src/lib/upgrade/harness.ts42 + / 6 −
@@ -21,6 +21,27 @@ type UpgradeHarness = {   path: string } +type UpgradePrompt = string | ((useWorktree: boolean | null) => string)++function resolvePrompt(+  prompt: UpgradePrompt,+  useWorktree: boolean | null+): string {+  return typeof prompt === 'string' ? prompt : prompt(useWorktree)+}++async function chooseWorktree(): Promise<boolean> {+  Log.bootstrap('  Open the upgrade in a separate Git worktree?')+  const { id } = await cliSelect({+    values: { yes: 'Yes', no: 'No' },+    defaultValue: 0,+    selected: cyan('❯'),+    unselected: ' ',+    indentation: 2,+  })+  return id === 'yes'+}+ function getHarnessDisplayName(name: UpgradeHarness['name']): string {   return name === 'codex' ? 'Codex' : 'Claude Code' }@@ -164,34 +185,34 @@ function launchHarness( }  export async function handoffUpgrade(-  prompt: string,+  prompt: UpgradePrompt,   directory: string ): Promise<void> {   // Existing agents keep their session, model and permissions.   if (await getAgentName()) {-    Log.bootstrap(prompt)+    Log.bootstrap(resolvePrompt(prompt, null))     return   }    if (!process.stdin.isTTY || !process.stdout.isTTY) {     Log.info('Copy this upgrade prompt into your coding agent:')-    Log.bootstrap(prompt)+    Log.bootstrap(resolvePrompt(prompt, null))     return   }    Log.info(dim('Looking for coding agents...'))   const installed = await findHarnesses()    if (installed.length === 0) {-    copyUpgradePrompt(prompt, true)+    copyUpgradePrompt(resolvePrompt(prompt, null), true)     return   }    // Let the selected agent take over the terminal with its existing permissions.   const harness = await chooseHarness(installed)    if (harness === 'copy') {-    copyUpgradePrompt(prompt)+    copyUpgradePrompt(resolvePrompt(prompt, null))     return   } @@ -201,11 +222,26 @@ export async function handoffUpgrade(     return   } +  let useWorktree: boolean+  try {+    useWorktree = await chooseWorktree()+  } catch (error) {+    if (error) {+      throw error+    }+    process.exitCode = 1+    return+  }+   Log.bootstrap(     `  Continuing with ${cyan(bold(getHarnessDisplayName(harness.name)))}...\n`   )   try {-    process.exitCode = await launchHarness(harness, prompt, directory)+    process.exitCode = await launchHarness(+      harness,+      resolvePrompt(prompt, useWorktree),+      directory+    )   } catch {     Log.error(`Could not start ${getHarnessDisplayName(harness.name)}.`)     process.exitCode = 1
packages/next/src/lib/upgrade/nudge.ts1 + / 1 −
@@ -278,7 +278,7 @@ This command stopped so the reminder you configured is not missed. Retry the sam  When you report back to the user, include this upgrade recommendation even if the retry succeeds. -At the end of the task, ask the user whether they want you to run the upgrade using the configured '${reminder.policy}' policy in the background. If the app is in a Git repository, use a separate Git worktree unless they request otherwise. If the app is not in a Git repository, upgrade it in place. Run this command from this app's directory:+At the end of the task, ask the user once: "Would you like me to run the '${reminder.policy}' upgrade in the background in a separate Git worktree? I can use the current checkout if you prefer." If the app is not in Git, say it will run in place. Use their worktree choice and continue in this agent session. Run this command from this app's directory:  \`\`\` ${command}
test/unit/agentic-upgrade-prompts.test.ts89 + / 6 −
@@ -1,4 +1,5 @@ import { EventEmitter } from 'events'+import { resolve } from 'path' import {   access,   cp,@@ -84,6 +85,16 @@ function normalizedBootstrapCalls(): string[][] {     .mock.calls.map(([message]) => [String(message).replace(/\\+/g, '/')]) } +function expectedHarnessPath(name: string): string {+  const extension =+    process.platform === 'win32'+      ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')+          .split(';')+          .filter(Boolean)[0]+      : ''+  return resolve('/agents', `${name}${extension}`)+}+ function normalizedFileWriteCalls() {   return jest     .mocked(writeFile)@@ -105,7 +116,10 @@ function normalizedWriteFileCalls() {   ) } -function overrideTTY(target: NodeJS.ReadStream | NodeJS.WriteStream): void {+function overrideTTY(+  target: NodeJS.ReadStream | NodeJS.WriteStream,+  value: boolean = true+): void {   const descriptor = Object.getOwnPropertyDescriptor(target, 'isTTY')   restoreDescriptors.push(() => {     if (descriptor) {@@ -116,7 +130,7 @@ function overrideTTY(target: NodeJS.ReadStream | NodeJS.WriteStream): void {   })   Object.defineProperty(target, 'isTTY', {     configurable: true,-    value: true,+    value,   }) } @@ -437,6 +451,75 @@ describe('agentic upgrade prompts', () => {     )   }) +  it.each([+    ['yes', true, 'Worktree prompt'],+    ['no', false, 'Current checkout prompt'],+  ])(+    'passes the %s worktree choice to the upgrade prompt',+    async (choice, selected, expectedPrompt) => {+      process.env.PATH = '/agents'+      overrideTTY(process.stdin)+      overrideTTY(process.stdout)+      jest.mocked(getAgentName).mockResolvedValue(null)+      jest.mocked(access).mockResolvedValue(undefined)+      jest.mocked(stat).mockResolvedValue({ isFile: () => true } as never)+      jest+        .mocked(cliSelect)+        .mockResolvedValueOnce({ id: 'codex' } as never)+        .mockResolvedValueOnce({ id: choice } as never)+      crossSpawn.mockImplementation(() => {+        const child = new EventEmitter()+        process.nextTick(() => child.emit('close', 0, null))+        return child+      })+      const prompt = jest.fn((useWorktree: boolean | null) =>+        useWorktree ? 'Worktree prompt' : 'Current checkout prompt'+      )++      await handoffUpgrade(prompt, '/workspace/app')++      expect(prompt).toHaveBeenCalledWith(selected)+      expect(crossSpawn).toHaveBeenCalledWith(+        expectedHarnessPath('codex'),+        ['--model', 'gpt-5.6-terra', expectedPrompt],+        { cwd: '/workspace/app', stdio: 'inherit' }+      )+      expect(jest.mocked(cliSelect).mock.calls[1][0].values).toEqual({+        yes: 'Yes',+        no: 'No',+      })+    }+  )++  it('keeps the existing agent in its session', async () => {+    const prompt = jest.fn(() => 'Prepared upgrade prompt.')++    await handoffUpgrade(prompt, '/workspace/app')++    expect(prompt).toHaveBeenCalledWith(null)+    expect(Log.bootstrap).toHaveBeenCalledWith('Prepared upgrade prompt.')+    expect(cliSelect).not.toHaveBeenCalled()+    expect(crossSpawn).not.toHaveBeenCalled()+  })++  it('leaves the worktree choice open outside a TTY', async () => {+    overrideTTY(process.stdin, false)+    overrideTTY(process.stdout, false)+    jest.mocked(getAgentName).mockResolvedValue(null)+    const prompt = jest.fn((useWorktree: boolean | null) =>+      useWorktree === null ? 'Choice pending prompt' : 'Selected prompt'+    )++    await handoffUpgrade(prompt, '/workspace/app')++    expect(prompt).toHaveBeenCalledWith(null)+    expect(Log.bootstrap).toHaveBeenCalledWith(+      expect.stringContaining('Choice pending prompt')+    )+    expect(cliSelect).not.toHaveBeenCalled()+    expect(crossSpawn).not.toHaveBeenCalled()+  })+   it('passes the complete migration prompt to an existing agent', async () => {     await spawnNextUpgrade('/workspace/app', {       revision: 'latest',@@ -474,7 +557,7 @@ describe('agentic upgrade prompts', () => {       We're upgrading the app in "/workspace/app" from Next.js 14.1.1 to 16.3.5 because the installed version is affected by a published security advisory. -     If the app is in a Git repository, perform the upgrade in a separate Git worktree unless the user explicitly requests otherwise. Run upgrade commands from this app's corresponding directory in that worktree. If the app is not in a Git repository, upgrade it in place.+     Follow the user's worktree choice. If they do not specify, use a separate Git worktree when the app is in a Git repository. Run upgrade commands from this app's corresponding directory in that worktree. If the app is not in a Git repository, upgrade it in place.       Set \`experimental.agenticAutoUpgrade\` to "security" in the app's Next.js config as part of this upgrade. Preserve unrelated configuration. If the target Next.js version does not support this option, skip the setting and report why. @@ -571,7 +654,7 @@ describe('agentic upgrade prompts', () => {       We're upgrading the app in "/workspace/app" from Next.js 16.2.12 to 16.3.5 because a newer stable Next.js release is available. -     If the app is in a Git repository, perform the upgrade in a separate Git worktree unless the user explicitly requests otherwise. Run upgrade commands from this app's corresponding directory in that worktree. If the app is not in a Git repository, upgrade it in place.+     Follow the user's worktree choice. If they do not specify, use a separate Git worktree when the app is in a Git repository. Run upgrade commands from this app's corresponding directory in that worktree. If the app is not in a Git repository, upgrade it in place.       Set \`experimental.agenticAutoUpgrade\` to "latest" in the app's Next.js config as part of this upgrade. Preserve unrelated configuration. If the target Next.js version does not support this option, skip the setting and report why. @@ -780,7 +863,7 @@ describe('agentic upgrade prompts', () => {       We're upgrading the app in "/workspace/app" from Next.js 16.2.0 to 16.4.0 because the Future policy applies the latest stable release and adopts its Future Defaults. -     If the app is in a Git repository, perform the upgrade in a separate Git worktree unless the user explicitly requests otherwise. Run upgrade commands from this app's corresponding directory in that worktree. If the app is not in a Git repository, upgrade it in place.+     Follow the user's worktree choice. If they do not specify, use a separate Git worktree when the app is in a Git repository. Run upgrade commands from this app's corresponding directory in that worktree. If the app is not in a Git repository, upgrade it in place.       Set \`experimental.agenticAutoUpgrade\` to "future" in the app's Next.js config as part of this upgrade. Preserve unrelated configuration. If the target Next.js version does not support this option, skip the setting and report why. @@ -879,7 +962,7 @@ describe('agentic upgrade prompts', () => {       We're adopting the Future Defaults available to the app in "/workspace/app", which already uses Next.js 16.4.0. -     If the app is in a Git repository, perform the upgrade in a separate Git worktree unless the user explicitly requests otherwise. Run upgrade commands from this app's corresponding directory in that worktree. If the app is not in a Git repository, upgrade it in place.+     Follow the user's worktree choice. If they do not specify, use a separate Git worktree when the app is in a Git repository. Run upgrade commands from this app's corresponding directory in that worktree. If the app is not in a Git repository, upgrade it in place.       Set \`experimental.agenticAutoUpgrade\` to "future" in the app's Next.js config as part of this upgrade. Preserve unrelated configuration. If the target Next.js version does not support this option, skip the setting and report why. 
test/unit/security-upgrade-nudge.test.ts2 + / 2 −
@@ -162,7 +162,7 @@ describe('security upgrade nudge', () => {       name: 'SecurityFatalError',       exitCode: 1,       message: expect.stringMatching(-        /affected by a known security vulnerability[\s\S]*\*\*We strongly recommend you upgrade Next\.js\.\*\*[\s\S]*reminder you configured is not missed\. Retry the same command to continue the original task\.[\s\S]*include this upgrade recommendation even if the retry succeeds\.[\s\S]*If the app is in a Git repository, use a separate Git worktree unless they request otherwise\.[\s\S]*If the app is not in a Git repository, upgrade it in place\. Run this command from this app's directory:[\s\S]*```\nnext upgrade --ai=(?:security|latest|future)\n```/+        /affected by a known security vulnerability[\s\S]*\*\*We strongly recommend you upgrade Next\.js\.\*\*[\s\S]*reminder you configured is not missed\. Retry the same command to continue the original task\.[\s\S]*include this upgrade recommendation even if the retry succeeds\.[\s\S]*ask the user once:[\s\S]*in a separate Git worktree\?[\s\S]*Use their worktree choice and continue in this agent session[\s\S]*Run this command from this app's directory:[\s\S]*```\nnext upgrade --ai=(?:security|latest|future)\n```/       ),     })     expect(warn).not.toHaveBeenCalled()@@ -413,7 +413,7 @@ describe('latest upgrade nudge', () => {       name: 'UpgradeNudgeError',       exitCode: 1,       message: expect.stringMatching(-        /Next\.js 17\.0\.0 is available\.[\s\S]*\*\*We recommend you upgrade Next\.js\.\*\*[\s\S]*reminder you configured is not missed\. Retry the same command to continue the original task\.[\s\S]*include this upgrade recommendation even if the retry succeeds\.[\s\S]*If the app is in a Git repository, use a separate Git worktree unless they request otherwise\.[\s\S]*If the app is not in a Git repository, upgrade it in place\. Run this command from this app's directory:[\s\S]*```\nnext upgrade --ai=(?:security|latest|future)\n```[\s\S]*registry\.npmjs\.org[\s\S]*agenticAutoUpgrade: 'latest'/+        /Next\.js 17\.0\.0 is available\.[\s\S]*\*\*We recommend you upgrade Next\.js\.\*\*[\s\S]*reminder you configured is not missed\. Retry the same command to continue the original task\.[\s\S]*include this upgrade recommendation even if the retry succeeds\.[\s\S]*ask the user once:[\s\S]*in a separate Git worktree\?[\s\S]*Use their worktree choice and continue in this agent session[\s\S]*Run this command from this app's directory:[\s\S]*```\nnext upgrade --ai=(?:security|latest|future)\n```[\s\S]*registry\.npmjs\.org[\s\S]*agenticAutoUpgrade: 'latest'/       ),     })     await expect(