workon 3.7.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -219,6 +219,9 @@ workon worktree status
219
219
  # Merge this worktree's branch into main/master
220
220
  workon worktree merge
221
221
 
222
+ # Recycle worktree for the next task (ff to latest main)
223
+ workon worktree recycle
224
+
222
225
  # Remove this worktree (shows instructions to exit first)
223
226
  workon worktree remove
224
227
  ```
@@ -359,6 +362,8 @@ workon myproject::feat-x # all events in worktree
359
362
  | `worktree merge` | `-i, --into <branch>` | Sets target branch explicitly |
360
363
  | `worktree merge` | `-s, --squash` | Uses squash merge (default: regular merge) |
361
364
  | `worktree merge` | `--delete-branch` | Deletes merged branch (avoids prompt) |
365
+ | `worktree recycle` | `[branch]` | Remote branch to fast-forward to (auto-detects main/master/develop/dev) |
366
+ | `worktree recycle` | `-y, --yes` | Skips confirmation prompt |
362
367
  | `worktree remove` | `-y, --yes` | Skips confirmation prompt |
363
368
  | `worktree remove` | `-f, --force` | Force-removes with uncommitted changes |
364
369
 
@@ -386,8 +391,11 @@ workon worktrees add my-feature
386
391
 
387
392
  # 4. Push, create a PR, get it reviewed and merged
388
393
 
389
- # 5. Tear down the worktree
394
+ # 5a. Tear down the worktree
390
395
  workon worktrees remove my-feature
396
+
397
+ # 5b. Or recycle for the next task (ff to latest main, keep worktree)
398
+ workon worktree recycle
391
399
  ```
392
400
 
393
401
  **Automated workflow (non-interactive, agent-friendly):**
@@ -407,8 +415,11 @@ workon worktrees add my-feature -b main -y -o
407
415
  workon worktrees branch my-feature pr-branch -y -p
408
416
  # ... PR merged via gh/API ...
409
417
 
410
- # 5. Tear down: remove worktree and delete the branch
418
+ # 5a. Tear down: remove worktree and delete the branch
411
419
  workon worktrees remove my-feature -y
420
+
421
+ # 5b. Or recycle: ff to latest main and reuse for the next task
422
+ workon worktree recycle -y
412
423
  ```
413
424
 
414
425
  The key difference is that every step in the automated version completes without waiting for user input, making it safe to chain together in scripts or have an AI agent drive the entire flow.
package/dist/cli.js CHANGED
@@ -1275,7 +1275,7 @@ var init_tmux = __esm({
1275
1275
  getWorktreeSessionName(projectName, worktreeName) {
1276
1276
  const sanitizedProject = sanitizeForShell(projectName);
1277
1277
  const sanitizedWorktree = sanitizeForShell(worktreeName);
1278
- return `${this.sessionPrefix}${sanitizedProject}-${sanitizedWorktree}`;
1278
+ return `${this.sessionPrefix}${sanitizedProject}_wt_${sanitizedWorktree}`;
1279
1279
  }
1280
1280
  async killSession(sessionName) {
1281
1281
  try {
@@ -3942,6 +3942,13 @@ async function processProject(projectParam, options, ctx) {
3942
3942
  const projects = config.getProjects();
3943
3943
  const environment = await EnvironmentRecognizer.recognize(File5.cwd());
3944
3944
  if (environment.$isProjectEnvironment && (projectName === "this" || projectName === ".")) {
3945
+ if (worktreeName) {
3946
+ log.error(
3947
+ `Worktree syntax is not supported with '${projectName}'. Use the full project name instead:`
3948
+ );
3949
+ log.info(` workon ${environment.project.name}::${worktreeName}`);
3950
+ process.exit(1);
3951
+ }
3945
3952
  log.info(`Opening current project: ${environment.project.name}`);
3946
3953
  await switchTo(environment, requestedCommands, options, ctx);
3947
3954
  return;
@@ -3969,7 +3976,7 @@ async function processProject(projectParam, options, ctx) {
3969
3976
  log.debug(`Using worktree path: ${worktree.path}`);
3970
3977
  projectEnv.project.overridePath(worktree.path);
3971
3978
  }
3972
- await switchTo(projectEnv, requestedCommands, options, ctx);
3979
+ await switchTo(projectEnv, requestedCommands, options, ctx, worktreeName);
3973
3980
  } else {
3974
3981
  log.error(`Project '${projectName}' not found.`);
3975
3982
  log.info(`Run 'workon' without arguments to see available projects or create a new one.`);
@@ -3987,7 +3994,7 @@ Available commands: ${availableCommands}`
3987
3994
  );
3988
3995
  }
3989
3996
  }
3990
- async function switchTo(environment, requestedCommands, options, ctx) {
3997
+ async function switchTo(environment, requestedCommands, options, ctx, worktreeName) {
3991
3998
  const { log } = ctx;
3992
3999
  const project = environment.project;
3993
4000
  let events;
@@ -4011,11 +4018,35 @@ async function switchTo(environment, requestedCommands, options, ctx) {
4011
4018
  const hasNpmEvent = events.includes("npm");
4012
4019
  const dryRun = options.dryRun || false;
4013
4020
  if (hasCwd && hasClaudeEvent && hasNpmEvent) {
4014
- await handleThreePaneLayout(project, isShellMode, dryRun, shellCommands, events, ctx);
4021
+ await handleThreePaneLayout(
4022
+ project,
4023
+ isShellMode,
4024
+ dryRun,
4025
+ shellCommands,
4026
+ events,
4027
+ ctx,
4028
+ worktreeName
4029
+ );
4015
4030
  } else if (hasCwd && hasNpmEvent) {
4016
- await handleTwoPaneNpmLayout(project, isShellMode, dryRun, shellCommands, events, ctx);
4031
+ await handleTwoPaneNpmLayout(
4032
+ project,
4033
+ isShellMode,
4034
+ dryRun,
4035
+ shellCommands,
4036
+ events,
4037
+ ctx,
4038
+ worktreeName
4039
+ );
4017
4040
  } else if (hasCwd && hasClaudeEvent) {
4018
- await handleSplitTerminal(project, isShellMode, dryRun, shellCommands, events, ctx);
4041
+ await handleSplitTerminal(
4042
+ project,
4043
+ isShellMode,
4044
+ dryRun,
4045
+ shellCommands,
4046
+ events,
4047
+ ctx,
4048
+ worktreeName
4049
+ );
4019
4050
  } else {
4020
4051
  for (const event of events) {
4021
4052
  if (!dryRun) {
@@ -4049,10 +4080,11 @@ async function getNpmCommand2(project) {
4049
4080
  const { NpmEvent: NpmEvent2 } = await Promise.resolve().then(() => (init_npm(), npm_exports));
4050
4081
  return NpmEvent2.getNpmCommand(npmConfig);
4051
4082
  }
4052
- async function handleTmuxLayout(project, layout, options, shellCommands, events, ctx) {
4083
+ async function handleTmuxLayout(project, layout, options, shellCommands, events, ctx, worktreeName) {
4053
4084
  const { log } = ctx;
4054
4085
  const tmux = new TmuxManager();
4055
4086
  const { isShellMode, dryRun } = options;
4087
+ const tmuxSessionId = worktreeName ? `${project.name}_wt_${worktreeName}` : project.name;
4056
4088
  let tmuxHandled = false;
4057
4089
  if (isShellMode) {
4058
4090
  if (await tmux.isTmuxAvailable()) {
@@ -4068,7 +4100,7 @@ async function handleTmuxLayout(project, layout, options, shellCommands, events,
4068
4100
  shellCommands.push(...cmds);
4069
4101
  }
4070
4102
  }
4071
- const commands = buildLayoutShellCommands(tmux, project, layout);
4103
+ const commands = buildLayoutShellCommands(tmux, tmuxSessionId, project, layout);
4072
4104
  shellCommands.push(...commands);
4073
4105
  tmuxHandled = true;
4074
4106
  } else {
@@ -4080,7 +4112,7 @@ async function handleTmuxLayout(project, layout, options, shellCommands, events,
4080
4112
  } else if (!dryRun) {
4081
4113
  if (await tmux.isTmuxAvailable()) {
4082
4114
  try {
4083
- const sessionName = await createTmuxSession(tmux, project, layout);
4115
+ const sessionName = await createTmuxSession(tmux, tmuxSessionId, project, layout);
4084
4116
  await tmux.attachToSession(sessionName);
4085
4117
  tmuxHandled = true;
4086
4118
  } catch (error) {
@@ -4104,19 +4136,19 @@ async function handleTmuxLayout(project, layout, options, shellCommands, events,
4104
4136
  }
4105
4137
  }
4106
4138
  }
4107
- function buildLayoutShellCommands(tmux, project, layout) {
4139
+ function buildLayoutShellCommands(tmux, sessionId, project, layout) {
4108
4140
  switch (layout.type) {
4109
4141
  case "split-claude":
4110
- return tmux.buildShellCommands(project.name, project.path.path, layout.claudeArgs);
4142
+ return tmux.buildShellCommands(sessionId, project.path.path, layout.claudeArgs);
4111
4143
  case "three-pane":
4112
4144
  return tmux.buildThreePaneShellCommands(
4113
- project.name,
4145
+ sessionId,
4114
4146
  project.path.path,
4115
4147
  layout.claudeArgs,
4116
4148
  layout.npmCommand
4117
4149
  );
4118
4150
  case "two-pane-npm":
4119
- return tmux.buildTwoPaneNpmShellCommands(project.name, project.path.path, layout.npmCommand);
4151
+ return tmux.buildTwoPaneNpmShellCommands(sessionId, project.path.path, layout.npmCommand);
4120
4152
  }
4121
4153
  }
4122
4154
  function buildFallbackCommandsWithEvents(shellCommands, project, layout, remainingEvents, _ctx) {
@@ -4134,50 +4166,77 @@ function buildFallbackCommandsWithEvents(shellCommands, project, layout, remaini
4134
4166
  }
4135
4167
  }
4136
4168
  }
4137
- async function createTmuxSession(tmux, project, layout) {
4169
+ async function createTmuxSession(tmux, sessionId, project, layout) {
4138
4170
  switch (layout.type) {
4139
4171
  case "split-claude":
4140
- return tmux.createSplitSession(project.name, project.path.path, layout.claudeArgs);
4172
+ return tmux.createSplitSession(sessionId, project.path.path, layout.claudeArgs);
4141
4173
  case "three-pane":
4142
4174
  return tmux.createThreePaneSession(
4143
- project.name,
4175
+ sessionId,
4144
4176
  project.path.path,
4145
4177
  layout.claudeArgs,
4146
4178
  layout.npmCommand
4147
4179
  );
4148
4180
  case "two-pane-npm":
4149
- return tmux.createTwoPaneNpmSession(project.name, project.path.path, layout.npmCommand);
4181
+ return tmux.createTwoPaneNpmSession(sessionId, project.path.path, layout.npmCommand);
4150
4182
  }
4151
4183
  }
4152
- async function handleSplitTerminal(project, isShellMode, dryRun, shellCommands, events, ctx) {
4184
+ async function handleSplitTerminal(project, isShellMode, dryRun, shellCommands, events, ctx, worktreeName) {
4185
+ const sessionLabel = worktreeName ? `${project.name}::${worktreeName}` : project.name;
4153
4186
  const layout = {
4154
4187
  type: "split-claude",
4155
4188
  handledEvents: ["cwd", "claude"],
4156
- dryRunMessage: `Would create split tmux session '${project.name}' with Claude`,
4189
+ dryRunMessage: `Would create split tmux session '${sessionLabel}' with Claude`,
4157
4190
  claudeArgs: getClaudeArgs2(project),
4158
4191
  npmCommand: null
4159
4192
  };
4160
- await handleTmuxLayout(project, layout, { isShellMode, dryRun }, shellCommands, events, ctx);
4193
+ await handleTmuxLayout(
4194
+ project,
4195
+ layout,
4196
+ { isShellMode, dryRun },
4197
+ shellCommands,
4198
+ events,
4199
+ ctx,
4200
+ worktreeName
4201
+ );
4161
4202
  }
4162
- async function handleThreePaneLayout(project, isShellMode, dryRun, shellCommands, events, ctx) {
4203
+ async function handleThreePaneLayout(project, isShellMode, dryRun, shellCommands, events, ctx, worktreeName) {
4204
+ const sessionLabel = worktreeName ? `${project.name}::${worktreeName}` : project.name;
4163
4205
  const layout = {
4164
4206
  type: "three-pane",
4165
4207
  handledEvents: ["cwd", "claude", "npm"],
4166
- dryRunMessage: `Would create three-pane tmux session '${project.name}' with Claude and NPM`,
4208
+ dryRunMessage: `Would create three-pane tmux session '${sessionLabel}' with Claude and NPM`,
4167
4209
  claudeArgs: getClaudeArgs2(project),
4168
4210
  npmCommand: await getNpmCommand2(project)
4169
4211
  };
4170
- await handleTmuxLayout(project, layout, { isShellMode, dryRun }, shellCommands, events, ctx);
4212
+ await handleTmuxLayout(
4213
+ project,
4214
+ layout,
4215
+ { isShellMode, dryRun },
4216
+ shellCommands,
4217
+ events,
4218
+ ctx,
4219
+ worktreeName
4220
+ );
4171
4221
  }
4172
- async function handleTwoPaneNpmLayout(project, isShellMode, dryRun, shellCommands, events, ctx) {
4222
+ async function handleTwoPaneNpmLayout(project, isShellMode, dryRun, shellCommands, events, ctx, worktreeName) {
4223
+ const sessionLabel = worktreeName ? `${project.name}::${worktreeName}` : project.name;
4173
4224
  const layout = {
4174
4225
  type: "two-pane-npm",
4175
4226
  handledEvents: ["cwd", "npm"],
4176
- dryRunMessage: `Would create two-pane tmux session '${project.name}' with NPM`,
4227
+ dryRunMessage: `Would create two-pane tmux session '${sessionLabel}' with NPM`,
4177
4228
  claudeArgs: [],
4178
4229
  npmCommand: await getNpmCommand2(project)
4179
4230
  };
4180
- await handleTmuxLayout(project, layout, { isShellMode, dryRun }, shellCommands, events, ctx);
4231
+ await handleTmuxLayout(
4232
+ project,
4233
+ layout,
4234
+ { isShellMode, dryRun },
4235
+ shellCommands,
4236
+ events,
4237
+ ctx,
4238
+ worktreeName
4239
+ );
4181
4240
  }
4182
4241
  async function processEvent(event, context, ctx) {
4183
4242
  const { log } = ctx;
@@ -4840,6 +4899,20 @@ function createWorktreeCommand(ctx) {
4840
4899
  }
4841
4900
  await removeCurrentWorktree(worktreeInfo, options, ctx);
4842
4901
  });
4902
+ command.command("recycle").description(
4903
+ "Recycle this worktree for the next task: switch to its original branch and fast-forward to a remote branch"
4904
+ ).argument("[branch]", "Remote branch to fast-forward to (auto-detects default)").option("-y, --yes", "Skip confirmation prompt").action(async (branch, options) => {
4905
+ const worktreeInfo = await detectWorktreeContext();
4906
+ if (!worktreeInfo) {
4907
+ log.error("Not in a git repository.");
4908
+ process.exit(1);
4909
+ }
4910
+ if (!worktreeInfo.isWorktree) {
4911
+ log.error("You're in the main repository, not a worktree.");
4912
+ process.exit(1);
4913
+ }
4914
+ await recycleCurrentWorktree(worktreeInfo, branch, options, ctx);
4915
+ });
4843
4916
  return command;
4844
4917
  }
4845
4918
  async function showWorktreeStatus(worktreeInfo, log) {
@@ -4880,6 +4953,7 @@ ${chalk8.bold("Changes:")}`);
4880
4953
  console.log(`
4881
4954
  ${chalk8.bold("Commands:")}`);
4882
4955
  console.log(` workon worktree merge - Merge this branch and remove worktree`);
4956
+ console.log(` workon worktree recycle - Reset worktree for the next task`);
4883
4957
  console.log(` workon worktree remove - Remove this worktree`);
4884
4958
  console.log();
4885
4959
  }
@@ -5062,6 +5136,127 @@ To remove this worktree:`);
5062
5136
  )
5063
5137
  );
5064
5138
  }
5139
+ async function getWorktreeOriginalBranch(mainRepoPath, worktreeName) {
5140
+ try {
5141
+ const manager = new WorktreeManager(mainRepoPath);
5142
+ const worktree = await manager.get(worktreeName);
5143
+ if (worktree && worktree.branch && worktree.branch !== "(detached)") {
5144
+ return worktree.branch;
5145
+ }
5146
+ const git = simpleGit5(mainRepoPath);
5147
+ const branches = await git.branchLocal();
5148
+ for (const branchName of branches.all) {
5149
+ if (branchName.replace(/\//g, "-") === worktreeName) {
5150
+ return branchName;
5151
+ }
5152
+ }
5153
+ return null;
5154
+ } catch {
5155
+ return null;
5156
+ }
5157
+ }
5158
+ async function detectDefaultRemoteBranch(git) {
5159
+ const candidates = ["main", "master", "develop", "dev"];
5160
+ try {
5161
+ const remoteRefs = await git.raw(["ls-remote", "--heads", "origin"]);
5162
+ const refNames = new Set(
5163
+ remoteRefs.trim().split("\n").filter((line) => line.includes("refs/heads/")).map((line) => line.replace(/.*refs\/heads\//, ""))
5164
+ );
5165
+ for (const candidate of candidates) {
5166
+ if (refNames.has(candidate)) {
5167
+ return candidate;
5168
+ }
5169
+ }
5170
+ return null;
5171
+ } catch {
5172
+ return null;
5173
+ }
5174
+ }
5175
+ async function recycleCurrentWorktree(worktreeInfo, targetBranch, options, ctx) {
5176
+ const { log } = ctx;
5177
+ if (!worktreeInfo.worktreePath || !worktreeInfo.worktreeName) {
5178
+ log.error("Unable to determine worktree info.");
5179
+ process.exit(1);
5180
+ }
5181
+ if (worktreeInfo.branch === "(detached)") {
5182
+ log.error("Worktree is in detached HEAD state.");
5183
+ log.info("Use 'git checkout <branch>' to switch to a branch first.");
5184
+ process.exit(1);
5185
+ }
5186
+ const git = simpleGit5(worktreeInfo.worktreePath);
5187
+ const originalBranch = await getWorktreeOriginalBranch(
5188
+ worktreeInfo.mainRepoPath,
5189
+ worktreeInfo.worktreeName
5190
+ );
5191
+ if (!originalBranch) {
5192
+ log.error("Unable to determine the branch associated with this worktree.");
5193
+ process.exit(1);
5194
+ }
5195
+ if (!targetBranch) {
5196
+ const detected = await detectDefaultRemoteBranch(git);
5197
+ if (!detected) {
5198
+ log.error("Could not detect default remote branch (tried main, master, develop, dev).");
5199
+ log.info("Specify the branch explicitly: workon worktree recycle <branch>");
5200
+ process.exit(1);
5201
+ }
5202
+ targetBranch = detected;
5203
+ }
5204
+ const status = await git.status();
5205
+ const hasChanges = status.modified.length > 0 || status.not_added.length > 0 || status.deleted.length > 0 || status.staged.length > 0 || status.created.length > 0 || status.renamed.length > 0 || status.conflicted.length > 0;
5206
+ if (hasChanges) {
5207
+ log.error("This worktree has uncommitted changes.");
5208
+ log.info("Please commit, stash, or discard your changes before recycling.");
5209
+ process.exit(1);
5210
+ }
5211
+ const alreadyOnBranch = worktreeInfo.branch === originalBranch;
5212
+ if (!options.yes) {
5213
+ console.log(`
5214
+ ${chalk8.bold("Recycle worktree:")}`);
5215
+ console.log(` Worktree: ${chalk8.cyan(worktreeInfo.worktreeName)}`);
5216
+ if (alreadyOnBranch) {
5217
+ console.log(` Branch: ${chalk8.green(originalBranch)} (already on it)`);
5218
+ } else {
5219
+ console.log(` Current: ${chalk8.yellow(worktreeInfo.branch)}`);
5220
+ console.log(` Switch to: ${chalk8.green(originalBranch)}`);
5221
+ }
5222
+ console.log(` FF from: ${chalk8.green(`origin/${targetBranch}`)}`);
5223
+ console.log();
5224
+ const shouldProceed = await confirm11({
5225
+ message: "Proceed?",
5226
+ default: true
5227
+ });
5228
+ if (!shouldProceed) {
5229
+ log.info("Recycle cancelled.");
5230
+ return;
5231
+ }
5232
+ }
5233
+ const spinner = ora5("Fetching latest from remote...").start();
5234
+ try {
5235
+ await git.fetch("origin", targetBranch);
5236
+ if (!alreadyOnBranch) {
5237
+ spinner.text = `Switching to branch '${originalBranch}'...`;
5238
+ await git.checkout(originalBranch);
5239
+ }
5240
+ spinner.text = `Fast-forwarding '${originalBranch}' to origin/${targetBranch}...`;
5241
+ await git.merge([`origin/${targetBranch}`, "--ff-only"]);
5242
+ spinner.succeed(`Recycled! '${originalBranch}' is now at the tip of origin/${targetBranch}`);
5243
+ console.log(chalk8.green("\nWorktree is ready for the next task."));
5244
+ } catch (error) {
5245
+ const message = error.message;
5246
+ spinner.fail("Recycle failed.");
5247
+ if (message.includes("ff-only")) {
5248
+ log.error(
5249
+ `Cannot fast-forward '${originalBranch}' to origin/${targetBranch}. The branch has diverged.`
5250
+ );
5251
+ log.info("You may need to reset or rebase manually.");
5252
+ } else if (message.includes("did not match any")) {
5253
+ log.error(`Remote branch 'origin/${targetBranch}' not found.`);
5254
+ } else {
5255
+ log.error(message);
5256
+ }
5257
+ process.exit(1);
5258
+ }
5259
+ }
5065
5260
 
5066
5261
  // src/commands/index.ts
5067
5262
  var __filename = fileURLToPath(import.meta.url);