@brimveyn/aimux 1.14.15 → 1.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.14.15",
3
+ "version": "1.15.0",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -60,7 +60,7 @@
60
60
  "bump": "bun run scripts/bump.ts"
61
61
  },
62
62
  "dependencies": {
63
- "@brimveyn/aimux-config": "0.6.9",
63
+ "@brimveyn/aimux-config": "0.6.11",
64
64
  "@opentui/core": "^0.1.90",
65
65
  "@opentui/react": "^0.1.90",
66
66
  "@resvg/resvg-wasm": "^2.6.2",
@@ -19,13 +19,16 @@ import type { ThemeId } from '../ui/themes'
19
19
  import { loadConfig, saveConfig, type WorktreeTemplate, type WorktreeTemplatePane } from '../config'
20
20
  import { logInputDebug } from '../debug/input-log'
21
21
  import { enqueueGitOp } from '../git/command-queue'
22
- import { moveWorktree } from '../git/move-worktree'
22
+ import { countDirtyFiles, moveWorktree } from '../git/move-worktree'
23
23
  import {
24
24
  createGitWorktree,
25
+ deleteGitBranch,
25
26
  getCurrentBranch,
26
27
  getHeadSha,
27
28
  getMainWorktreeRoot,
28
29
  listGitWorktrees,
30
+ listLocalBranches,
31
+ pruneGitWorktrees,
29
32
  removeGitWorktree,
30
33
  } from '../git/worktree'
31
34
  import { createPrefixedId } from '../platform/id'
@@ -419,6 +422,7 @@ async function launchAssistantInNewWorktree(
419
422
  worktreeName: string,
420
423
  branchName?: string,
421
424
  sourceWorktreeId?: string,
425
+ baseRef?: string,
422
426
  templateId?: string
423
427
  ): Promise<void> {
424
428
  const sessionId = ctx.state.currentSessionId
@@ -428,7 +432,7 @@ async function launchAssistantInNewWorktree(
428
432
  sessionId,
429
433
  worktreeName,
430
434
  branchName,
431
- undefined,
435
+ baseRef,
432
436
  sourceWorktreeId
433
437
  )
434
438
  if (!worktree) return
@@ -665,6 +669,7 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
665
669
  if (state.modal.type === 'new-tab' && state.modal.createWorktree) {
666
670
  const worktreeName = state.modal.worktreeName
667
671
  const branchName = state.modal.branchName
672
+ const baseRef = state.modal.baseRef
668
673
  const sourceWorktreeId = getNewTabTargetWorktreeId(state)
669
674
  let templateId: string | undefined
670
675
  if (state.modal.step === 'template') {
@@ -683,6 +688,7 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
683
688
  worktreeName,
684
689
  branchName,
685
690
  sourceWorktreeId,
691
+ baseRef !== '' ? baseRef : undefined,
686
692
  templateId
687
693
  )
688
694
  )
@@ -700,6 +706,17 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
700
706
  dispatch({ assistantId: option.id, type: 'open-edit-custom-command' })
701
707
  return
702
708
  }
709
+ case 'load-new-tab-base-branches': {
710
+ void (async () => {
711
+ const session = state.sessions.find((entry) => entry.id === state.currentSessionId)
712
+ const sourcePath = getActiveWorktree(session)?.path ?? getSessionProjectPath(session)
713
+ if (!(sourcePath != null && sourcePath !== '')) return
714
+ const branches = await listLocalBranches(sourcePath)
715
+ if (ctx.getState().modal.type !== 'new-tab') return
716
+ ctx.dispatch({ branches, type: 'set-new-tab-base-branches' })
717
+ })()
718
+ return
719
+ }
703
720
  case 'confirm-selected-session': {
704
721
  handleSessionSelection(ctx)
705
722
  return
@@ -720,27 +737,40 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
720
737
  { ...ctx, state: ctx.getState() },
721
738
  effect.sessionId,
722
739
  effect.worktreeId,
723
- !!(effect.force === true)
740
+ !!(effect.force === true),
741
+ !!(effect.closeTabs === true)
724
742
  )
725
743
  )
726
744
  } catch (error) {
727
745
  const message = error instanceof Error ? error.message : String(error)
728
- const forceable = isForceableWorktreeDeleteError(message)
746
+ // Real errors surface as a toast. Recoverable failures (dirty tree,
747
+ // active tabs, …) open a confirmation so the user can opt into a
748
+ // force-delete: in-place inside the new-tab worktree picker (preserving
749
+ // it), or as a standalone modal elsewhere (e.g. the sidebar's "Remove
750
+ // worktree").
751
+ if (!isForceableWorktreeDeleteError(message)) {
752
+ toast.error(`Could not delete worktree: ${message}`)
753
+ return
754
+ }
729
755
  const latest = ctx.getState()
730
756
  if (latest.modal.type === 'new-tab' && latest.modal.step === 'worktree') {
731
- const session = latest.sessions.find((entry) => entry.id === effect.sessionId)
732
- const selected = session?.worktrees?.[latest.modal.selectedIndex]
733
- if (selected && selected.id !== effect.worktreeId) {
734
- ctx.dispatch({ message, type: 'git-mode-set-message' })
735
- return
736
- }
757
+ ctx.dispatch({
758
+ prompt: { reason: message, worktreeId: effect.worktreeId },
759
+ type: 'set-new-tab-worktree-delete-prompt',
760
+ })
761
+ return
737
762
  }
763
+ const session = latest.sessions.find((entry) => entry.id === effect.sessionId)
764
+ const worktree = session?.worktrees?.find((entry) => entry.id === effect.worktreeId)
738
765
  ctx.dispatch({
739
- confirmWorktreeId: forceable ? effect.worktreeId : null,
740
- message: forceable ? message : `Could not delete worktree: ${message}`,
741
- type: 'set-new-tab-worktree-delete-state',
766
+ closeTabs: effect.closeTabs === true,
767
+ force: true,
768
+ reason: message,
769
+ sessionId: effect.sessionId,
770
+ type: 'open-worktree-delete-confirm',
771
+ worktreeId: effect.worktreeId,
772
+ worktreeLabel: worktree?.branch ?? worktree?.name ?? 'this worktree',
742
773
  })
743
- ctx.dispatch({ message, type: 'git-mode-set-message' })
744
774
  }
745
775
  })()
746
776
  return
@@ -754,7 +784,9 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
754
784
  effect.sessionId,
755
785
  effect.sourceWorktreeId,
756
786
  effect.targetWorktreeId,
757
- effect.deleteSource === true
787
+ effect.deleteSource === true,
788
+ effect.stashTarget === true,
789
+ effect.keepConflicts === true
758
790
  )
759
791
  )
760
792
  } catch (error) {
@@ -763,6 +795,22 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
763
795
  })()
764
796
  return
765
797
  }
798
+ case 'load-worktree-move-stats': {
799
+ void (async () => {
800
+ const session = state.sessions.find((entry) => entry.id === state.currentSessionId)
801
+ const worktrees = session?.worktrees ?? []
802
+ if (worktrees.length === 0) return
803
+ const counts = await Promise.all(
804
+ worktrees.map(async (worktree) => [worktree.id, await countDirtyFiles(worktree.path)])
805
+ )
806
+ if (ctx.getState().modal.type !== 'worktree-move') return
807
+ ctx.dispatch({
808
+ dirtyFiles: Object.fromEntries(counts),
809
+ type: 'set-worktree-move-stats',
810
+ })
811
+ })()
812
+ return
813
+ }
766
814
  case 'open-rename-selected-session': {
767
815
  openSelectedSessionRename(ctx)
768
816
  return
@@ -1544,7 +1592,8 @@ async function runDeleteWorktree(
1544
1592
  ctx: SideEffectContext,
1545
1593
  sessionId: string,
1546
1594
  worktreeId: string,
1547
- force: boolean
1595
+ force: boolean,
1596
+ closeTabs = false
1548
1597
  ): Promise<void> {
1549
1598
  const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
1550
1599
  const worktree = session?.worktrees?.find((entry) => entry.id === worktreeId)
@@ -1554,36 +1603,43 @@ async function runDeleteWorktree(
1554
1603
  if (worktree.source === 'primary') throw new Error('root worktree cannot be deleted')
1555
1604
 
1556
1605
  const tabsInWorktree = ctx.state.tabs.filter((tab) => tab.worktreeId === worktreeId)
1557
- if (tabsInWorktree.length > 0 && !force) {
1606
+ // The active-tabs guard asks the modal user to confirm before closing tabs.
1607
+ // `closeTabs` (the sidebar's "Remove worktree") opts into closing them
1608
+ // directly without forcing the git removal, so dirty temp worktrees are still
1609
+ // protected by the non-force `git worktree remove`.
1610
+ if (tabsInWorktree.length > 0 && !force && !closeTabs) {
1558
1611
  throw new ActiveWorktreeTabsError(tabsInWorktree.length)
1559
1612
  }
1560
1613
  disposeWorktreeTabs(ctx, worktreeId)
1561
1614
 
1562
- if (
1563
- worktree.source === 'aimux-temp' &&
1564
- worktree.createdByAimux &&
1565
- isInsideAimuxWorktreeRoot(worktree.path) &&
1566
- !existsSync(worktree.path)
1567
- ) {
1615
+ const repoPath = resolveWorktreeGitDir(session, worktree)
1616
+ const isAimuxTemp = worktree.source === 'aimux-temp' && worktree.createdByAimux
1617
+ // Drop the throwaway aimux branch alongside the worktree so deleted temp
1618
+ // worktrees don't accumulate in the repo or haunt the base picker. Scoped to
1619
+ // the `aimux/` namespace (matches the picker filter); best-effort.
1620
+ const cleanupAimuxBranch = async (): Promise<void> => {
1621
+ const branch = worktree.branch
1622
+ if (isAimuxTemp && branch != null && branch !== '' && branch.startsWith('aimux/')) {
1623
+ await deleteGitBranch(repoPath, branch)
1624
+ }
1625
+ }
1626
+
1627
+ if (isAimuxTemp && isInsideAimuxWorktreeRoot(worktree.path) && !existsSync(worktree.path)) {
1628
+ // The dir vanished but git may still pin the branch to a stale worktree entry.
1629
+ await pruneGitWorktrees(repoPath)
1630
+ await cleanupAimuxBranch()
1568
1631
  removeWorktreeRecordFromSession(ctx, sessionId, session, worktreeId)
1569
1632
  return
1570
1633
  }
1571
1634
 
1572
- if (
1573
- worktree.source === 'aimux-temp' &&
1574
- worktree.createdByAimux &&
1575
- isInsideAimuxWorktreeRoot(worktree.path)
1576
- ) {
1635
+ if (isAimuxTemp && isInsideAimuxWorktreeRoot(worktree.path)) {
1577
1636
  await assertSafeAimuxWorktreePath(worktree.path)
1578
- await removeGitWorktree({
1579
- force,
1580
- repoPath: resolveWorktreeGitDir(session, worktree),
1581
- targetPath: worktree.path,
1582
- })
1637
+ await removeGitWorktree({ force, repoPath, targetPath: worktree.path })
1583
1638
  } else if (worktree.source === 'aimux-temp' || worktree.createdByAimux) {
1584
1639
  throw new Error(`refusing unsafe worktree delete: ${worktree.path}`)
1585
1640
  }
1586
1641
 
1642
+ await cleanupAimuxBranch()
1587
1643
  removeWorktreeRecordFromSession(ctx, sessionId, session, worktreeId)
1588
1644
  }
1589
1645
 
@@ -1610,7 +1666,9 @@ async function runMoveWorktree(
1610
1666
  sessionId: string,
1611
1667
  sourceWorktreeId: string,
1612
1668
  targetWorktreeId: string,
1613
- deleteSource: boolean
1669
+ deleteSource: boolean,
1670
+ stashTarget: boolean,
1671
+ keepConflicts: boolean
1614
1672
  ): Promise<void> {
1615
1673
  const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
1616
1674
  const source = session?.worktrees?.find((entry) => entry.id === sourceWorktreeId)
@@ -1628,20 +1686,38 @@ async function runMoveWorktree(
1628
1686
  const sourceLabel = source.branch ?? source.name
1629
1687
  const targetLabel = target.branch ?? target.name
1630
1688
  const result = await moveWorktree({
1689
+ keepConflicts,
1631
1690
  sourceBranch: source.branch,
1632
1691
  sourcePath: source.path,
1692
+ stashTarget,
1633
1693
  targetPath: target.path,
1634
1694
  })
1635
1695
 
1636
- // Toasts surface the outcome everywhere the picker can be opened outside git
1637
- // mode (from a tab menu), where the git-pane message would never be seen.
1638
- if (result.kind === 'dirty-target') {
1639
- toast.warning(`Target ${targetLabel} has uncommitted changes commit or stash it first`)
1696
+ // Recoverable failures open a confirm dialog carrying retry params; both
1697
+ // worktrees are already back in their original state, so confirming simply
1698
+ // re-dispatches move-worktree with the matching flag.
1699
+ if (result.kind === 'needs-stash' || result.kind === 'conflict') {
1700
+ ctx.dispatch({
1701
+ deleteSource,
1702
+ files: result.files,
1703
+ sessionId,
1704
+ sourceLabel,
1705
+ sourceWorktreeId,
1706
+ targetLabel,
1707
+ targetWorktreeId,
1708
+ type: 'open-worktree-move-confirm',
1709
+ variant: result.kind === 'needs-stash' ? 'stash-target' : 'keep-conflicts',
1710
+ })
1640
1711
  return
1641
1712
  }
1642
- if (result.kind === 'conflict') {
1713
+ if (result.kind === 'conflict-kept') {
1714
+ // Never delete the source here — its work only landed half-resolved. The
1715
+ // auto-commit driver is safe against this state: git refuses to commit
1716
+ // with unmerged index entries, so it fails loudly instead of committing
1717
+ // conflict markers.
1718
+ handleSwitchWorktree({ ...ctx, state: ctx.getState() }, sessionId, targetWorktreeId)
1643
1719
  toast.warning(
1644
- `Move hit conflicts in ${result.files.length} file(s) — left ${sourceLabel} untouched`
1720
+ `Left conflict markers in ${targetLabel} (${result.files.length} file(s))resolve & commit there; ${sourceLabel} kept`
1645
1721
  )
1646
1722
  return
1647
1723
  }
@@ -1664,8 +1740,11 @@ async function runMoveWorktree(
1664
1740
  } else {
1665
1741
  handleSwitchWorktree({ ...ctx, state: ctx.getState() }, sessionId, targetWorktreeId)
1666
1742
  }
1743
+ const stashNote = result.stashedTarget
1744
+ ? ` · target's previous changes stashed (recover with git stash pop)`
1745
+ : ''
1667
1746
  toast.success(
1668
- `Moved ${sourceLabel} → ${targetLabel} · ${result.filesChanged} file(s) staged — review & commit`
1747
+ `Moved ${sourceLabel} → ${targetLabel} · ${result.filesChanged} file(s) staged — review & commit${stashNote}`
1669
1748
  )
1670
1749
  }
1671
1750
 
@@ -1694,7 +1773,7 @@ function removeWorktreeRecordFromSession(
1694
1773
  type: 'set-modal-selection-index',
1695
1774
  })
1696
1775
  }
1697
- ctx.dispatch({ message: null, type: 'set-new-tab-worktree-delete-state' })
1776
+ ctx.dispatch({ prompt: null, type: 'set-new-tab-worktree-delete-prompt' })
1698
1777
  }
1699
1778
 
1700
1779
  function isForceableWorktreeDeleteError(message: string): boolean {
@@ -1706,7 +1785,7 @@ function isForceableWorktreeDeleteError(message: string): boolean {
1706
1785
  class ActiveWorktreeTabsError extends Error {
1707
1786
  constructor(tabCount: number) {
1708
1787
  super(
1709
- `active assistant tabs are using this worktree (${tabCount}). Click [del] again to close them and delete the worktree.`
1788
+ `active assistant tabs are using this worktree (${tabCount}) they will be closed if you confirm.`
1710
1789
  )
1711
1790
  }
1712
1791
  }
package/src/app.tsx CHANGED
@@ -22,7 +22,9 @@ import { useMouseHandlers } from './app-runtime/use-mouse-handlers'
22
22
  import { useRendererBindings } from './app-runtime/use-renderer-bindings'
23
23
  import { useTerminalResize } from './app-runtime/use-terminal-resize'
24
24
  import { useWorkspaceAutosave } from './app-runtime/use-workspace-autosave'
25
- import { loadConfig } from './config'
25
+ import { loadConfig, saveConfig } from './config'
26
+ import { enqueueGitOp } from './git/command-queue'
27
+ import { pruneOrphanAimuxBranches } from './git/worktree'
26
28
  import { setActiveKeymap } from './input/keymap/keymap-ref'
27
29
  import { deriveModeId } from './input/modes/bridge'
28
30
  import { registerAllModes } from './input/modes/handlers'
@@ -39,6 +41,7 @@ import { findMostRecentSession, loadSessionCatalog } from './state/session-catal
39
41
  import { getSessionProjectPath } from './state/session-worktrees'
40
42
  import { loadSnippetCatalog, mergeConfigSnippets } from './state/snippet-catalog'
41
43
  import { createInitialState } from './state/store'
44
+ import { toast } from './state/toast-store'
42
45
  import { KeymapContext } from './ui/keymap-context'
43
46
  import { RootView } from './ui/root'
44
47
  import {
@@ -250,6 +253,28 @@ export function App({
250
253
  // eslint-disable-next-line react-hooks/exhaustive-deps
251
254
  }, [])
252
255
 
256
+ useEffect(() => {
257
+ // One-shot cleanup: prune `aimux/` branches orphaned by temp worktrees that
258
+ // were deleted before delete-time branch cleanup existed. Gated by a config
259
+ // flag so it runs once per machine. All git calls are best-effort (nothrow)
260
+ // and git protects branches still checked out in a live worktree.
261
+ if (loadConfig().prunedOrphanAimuxBranches === true) return
262
+ void (async () => {
263
+ const repoRoots = new Set<string>()
264
+ for (const session of loadSessionCatalog()) {
265
+ for (const worktree of session.worktrees ?? []) {
266
+ if (worktree.repoRoot !== '') repoRoots.add(worktree.repoRoot)
267
+ }
268
+ }
269
+ let removed = 0
270
+ for (const repoRoot of repoRoots) {
271
+ removed += await enqueueGitOp(() => pruneOrphanAimuxBranches(repoRoot))
272
+ }
273
+ saveConfig({ ...loadConfig(), prunedOrphanAimuxBranches: true })
274
+ if (removed > 0) toast.success(`Cleaned ${removed} orphaned aimux branch(es)`)
275
+ })()
276
+ }, [])
277
+
253
278
  const resizingRef = useRef(false)
254
279
  // Seeded with state.layout's DEFAULT 80x24; reassigned below once
255
280
  // useTerminalResize has produced the open-loop estimate from real dimensions.
package/src/config.ts CHANGED
@@ -64,6 +64,8 @@ export interface AimuxConfig {
64
64
  sessionBarVisible?: boolean
65
65
  workspaceSnapshot?: WorkspaceSnapshotV1
66
66
  skippedUpdateVersion?: string
67
+ /** One-shot guard: orphan `aimux/` branches were pruned from existing repos. */
68
+ prunedOrphanAimuxBranches?: boolean
67
69
  worktreeTemplates?: WorktreeTemplate[]
68
70
  }
69
71
 
@@ -255,6 +257,7 @@ export function loadConfigResult(): ConfigLoadResult {
255
257
  sessionBarVisible?: unknown
256
258
  workspaceSnapshot?: unknown
257
259
  skippedUpdateVersion?: unknown
260
+ prunedOrphanAimuxBranches?: unknown
258
261
  worktreeTemplates?: unknown
259
262
  }
260
263
 
@@ -350,6 +353,7 @@ export function loadConfigResult(): ConfigLoadResult {
350
353
  config: {
351
354
  customCommands: isCustomCommandsRecord(parsed.customCommands) ? parsed.customCommands : {},
352
355
  gitPane: validGitPane,
356
+ prunedOrphanAimuxBranches: parsed.prunedOrphanAimuxBranches === true ? true : undefined,
353
357
  sessionBarVisible: validSessionBarVisible,
354
358
  sidebar: validSidebar,
355
359
  skippedUpdateVersion: validSkippedUpdateVersion,
@@ -1,15 +1,36 @@
1
1
  import { $ } from 'bun'
2
2
 
3
+ export interface MoveWorktreeOptions {
4
+ sourcePath: string
5
+ sourceBranch: string
6
+ targetPath: string
7
+ /** Stash the target's uncommitted (incl. untracked) changes before merging. The stash is kept. */
8
+ stashTarget?: boolean
9
+ /** On conflict, leave the conflicted squash state in the target for manual resolution. */
10
+ keepConflicts?: boolean
11
+ }
12
+
3
13
  export type MoveWorktreeResult =
4
- | { kind: 'ok'; filesChanged: number }
5
- | { kind: 'dirty-target' }
14
+ | { kind: 'ok'; filesChanged: number; stashedTarget: boolean }
15
+ /** Target dirty/untracked files would be overwritten by the move; nothing was touched. */
16
+ | { kind: 'needs-stash'; files: string[] }
17
+ /** Conflict; target and source fully restored. */
6
18
  | { kind: 'conflict'; files: string[] }
19
+ /** Conflict markers left in the target on purpose; source restored. */
20
+ | { kind: 'conflict-kept'; files: string[] }
7
21
  | { kind: 'error'; message: string }
8
22
 
9
- async function workingTreeDirty(repoPath: string): Promise<boolean> {
23
+ export async function countDirtyFiles(repoPath: string): Promise<number> {
10
24
  const result = await $`git -C ${repoPath} status --porcelain`.quiet().nothrow()
11
- if (result.exitCode !== 0) return false
12
- return result.text().trim() !== ''
25
+ if (result.exitCode !== 0) return 0
26
+ return result
27
+ .text()
28
+ .split('\n')
29
+ .filter((line) => line.trim() !== '').length
30
+ }
31
+
32
+ async function workingTreeDirty(repoPath: string): Promise<boolean> {
33
+ return (await countDirtyFiles(repoPath)) > 0
13
34
  }
14
35
 
15
36
  // Files left with conflict markers by a failed squash (unmerged index entries).
@@ -32,27 +53,63 @@ async function countStaged(repoPath: string): Promise<number> {
32
53
  .filter((line) => line.trim() !== '').length
33
54
  }
34
55
 
56
+ // Git refuses a merge up front (working tree untouched) when local changes
57
+ // overlap the incoming ones, listing the files tab-indented under either
58
+ // "Your local changes to the following files would be overwritten by merge:"
59
+ // or "The following untracked working tree files would be overwritten by merge:".
60
+ function parseOverwrittenFiles(output: string): string[] {
61
+ const files: string[] = []
62
+ let collecting = false
63
+ for (const line of output.split('\n')) {
64
+ if (line.includes('would be overwritten by merge')) {
65
+ collecting = true
66
+ continue
67
+ }
68
+ if (!collecting) continue
69
+ if (line.startsWith('\t')) {
70
+ const file = line.trim()
71
+ if (file !== '') files.push(file)
72
+ } else {
73
+ collecting = false
74
+ }
75
+ }
76
+ return files
77
+ }
78
+
35
79
  // Squashes everything a source worktree changed since its fork point into the
36
80
  // target worktree's working tree (staged, uncommitted) — committed work plus
37
81
  // any uncommitted/untracked changes. The source is left exactly as it was; the
38
- // caller decides whether to delete it. The target must be clean so two change
39
- // sets are never silently fused. On conflict nothing is kept: the target is
40
- // reset and the source restored.
41
- export async function moveWorktree(opts: {
42
- sourcePath: string
43
- sourceBranch: string
44
- targetPath: string
45
- }): Promise<MoveWorktreeResult> {
82
+ // caller decides whether to delete it. The target may be dirty as long as its
83
+ // local changes don't overlap the incoming ones: on overlap the move stops
84
+ // before touching anything (needs-stash) unless stashTarget is set. On
85
+ // conflict nothing is kept — target reset, source restored — unless
86
+ // keepConflicts is set, in which case the conflicted squash state is left in
87
+ // the target for manual resolution.
88
+ export async function moveWorktree(opts: MoveWorktreeOptions): Promise<MoveWorktreeResult> {
46
89
  const { sourceBranch, sourcePath, targetPath } = opts
47
90
  try {
48
- if (await workingTreeDirty(targetPath)) return { kind: 'dirty-target' }
49
-
50
91
  const headResult = await $`git -C ${sourcePath} rev-parse HEAD`.quiet().nothrow()
51
92
  if (headResult.exitCode !== 0) {
52
93
  return { kind: 'error', message: 'could not resolve source HEAD' }
53
94
  }
54
95
  const sourceHead = headResult.text().trim()
55
96
 
97
+ let stashedTarget = false
98
+ if (opts.stashTarget === true && (await workingTreeDirty(targetPath))) {
99
+ const stashMessage = `aimux: backup before move from ${sourceBranch}`
100
+ const stash = await $`git -C ${targetPath} stash push --include-untracked -m ${stashMessage}`
101
+ .quiet()
102
+ .nothrow()
103
+ if (stash.exitCode !== 0) {
104
+ const message = stash.stderr.toString().trim()
105
+ return {
106
+ kind: 'error',
107
+ message: message !== '' ? message : 'failed to stash target changes',
108
+ }
109
+ }
110
+ stashedTarget = true
111
+ }
112
+
56
113
  // Capture uncommitted + untracked work in a throwaway commit so the squash
57
114
  // includes everything; undone again before we return.
58
115
  const tempCommitted = await workingTreeDirty(sourcePath)
@@ -79,8 +136,21 @@ export async function moveWorktree(opts: {
79
136
  const merge = await $`git -C ${targetPath} merge --squash ${sourceBranch}`.quiet().nothrow()
80
137
  const conflicts = await conflictedFiles(targetPath)
81
138
  if (merge.exitCode !== 0 || conflicts.length > 0) {
139
+ if (conflicts.length > 0 && opts.keepConflicts === true) {
140
+ await restoreSource()
141
+ return { files: conflicts, kind: 'conflict-kept' }
142
+ }
143
+ if (conflicts.length === 0) {
144
+ const overwritten = parseOverwrittenFiles(merge.stderr.toString() + merge.stdout.toString())
145
+ if (overwritten.length > 0) {
146
+ // Git refused up front — the target working tree was never touched.
147
+ await restoreSource()
148
+ return { files: overwritten, kind: 'needs-stash' }
149
+ }
150
+ }
82
151
  await $`git -C ${targetPath} merge --abort`.quiet().nothrow()
83
- await $`git -C ${targetPath} reset --hard HEAD`.quiet().nothrow()
152
+ // --merge (not --hard): unrelated dirty files in the target must survive.
153
+ await $`git -C ${targetPath} reset --merge HEAD`.quiet().nothrow()
84
154
  await restoreSource()
85
155
  if (conflicts.length > 0) return { files: conflicts, kind: 'conflict' }
86
156
  const message = merge.stderr.toString().trim()
@@ -89,7 +159,7 @@ export async function moveWorktree(opts: {
89
159
 
90
160
  const filesChanged = await countStaged(targetPath)
91
161
  await restoreSource()
92
- return { filesChanged, kind: 'ok' }
162
+ return { filesChanged, kind: 'ok', stashedTarget }
93
163
  } catch (error) {
94
164
  return { kind: 'error', message: error instanceof Error ? error.message : String(error) }
95
165
  }
@@ -33,6 +33,24 @@ export async function getHeadSha(cwd: string): Promise<string | undefined> {
33
33
  return result.text().trim() || undefined
34
34
  }
35
35
 
36
+ // Local branch names, ordered most-recently-committed first so the likely base
37
+ // surfaces near the top of the picker.
38
+ export async function listLocalBranches(cwd: string): Promise<string[]> {
39
+ // The format must be interpolated, not inlined: Bun's shell parses a bare
40
+ // `%(refname:short)` and chokes on the parentheses.
41
+ const format = '%(refname:short)'
42
+ const result =
43
+ await $`git -C ${cwd} for-each-ref --sort=-committerdate refs/heads --format=${format}`
44
+ .quiet()
45
+ .nothrow()
46
+ if (result.exitCode !== 0) return []
47
+ return result
48
+ .text()
49
+ .split('\n')
50
+ .map((line) => line.trim())
51
+ .filter((line) => line !== '')
52
+ }
53
+
36
54
  export async function createGitWorktree({
37
55
  baseRef,
38
56
  branchName,
@@ -52,6 +70,33 @@ export async function createGitWorktree({
52
70
  }
53
71
  }
54
72
 
73
+ // Force-delete a local branch. Returns false (without throwing) when git
74
+ // refuses — notably when the branch is still checked out in a live worktree,
75
+ // which is exactly how orphan-pruning stays safe.
76
+ export async function deleteGitBranch(repoPath: string, branch: string): Promise<boolean> {
77
+ const result = await $`git -C ${repoPath} branch -D ${branch}`.quiet().nothrow()
78
+ return result.exitCode === 0
79
+ }
80
+
81
+ export async function pruneGitWorktrees(repoPath: string): Promise<void> {
82
+ await $`git -C ${repoPath} worktree prune`.quiet().nothrow()
83
+ }
84
+
85
+ // Drop every `aimux/` branch left behind by deleted temp worktrees. git refuses
86
+ // to delete branches still checked out in a live worktree, so this only removes
87
+ // true orphans. Returns the number of branches removed.
88
+ export async function pruneOrphanAimuxBranches(repoPath: string): Promise<number> {
89
+ await pruneGitWorktrees(repoPath)
90
+ const branches = (await listLocalBranches(repoPath)).filter((branch) =>
91
+ branch.startsWith('aimux/')
92
+ )
93
+ let removed = 0
94
+ for (const branch of branches) {
95
+ if (await deleteGitBranch(repoPath, branch)) removed++
96
+ }
97
+ return removed
98
+ }
99
+
55
100
  export async function removeGitWorktree({
56
101
  force,
57
102
  repoPath,
@@ -26,7 +26,9 @@ const COMMAND_EDIT_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
26
26
  const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
27
27
  'ai-usage': 'modal.ai-usage',
28
28
  'update-available': 'modal.update-available',
29
+ 'worktree-delete-confirm': 'modal.worktree-delete-confirm',
29
30
  'worktree-move': 'modal.worktree-move',
31
+ 'worktree-move-confirm': 'modal.worktree-move-confirm',
30
32
  }
31
33
 
32
34
  export function deriveModeId(state: AppState): ModeId {
@@ -51,6 +53,9 @@ export function deriveModeId(state: AppState): ModeId {
51
53
  if (state.modal.type === 'new-tab' && state.modal.editingCommand !== null) {
52
54
  return 'modal.new-tab.editing-command'
53
55
  }
56
+ if (state.modal.type === 'new-tab' && state.modal.worktreeDeletePrompt !== null) {
57
+ return 'modal.new-tab.worktree-delete-confirm'
58
+ }
54
59
  if (state.modal.type === 'git-commit' && state.modal.stage === 'confirm') {
55
60
  return 'modal.git-commit.confirm'
56
61
  }
@@ -8,8 +8,13 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
8
8
  'modal.git-commit.confirm': ['modal.git-commit', 'git-mode'],
9
9
  'modal.git-commit.generating': ['modal.git-commit', 'modal.git-commit.confirm', 'git-mode'],
10
10
  'modal.help.filtering': ['navigation'],
11
- 'modal.new-tab.command-edit': ['navigation', 'modal.new-tab.editing-command'],
11
+ 'modal.new-tab.command-edit': [
12
+ 'navigation',
13
+ 'modal.new-tab.editing-command',
14
+ 'modal.new-tab.worktree-delete-confirm',
15
+ ],
12
16
  'modal.new-tab.editing-command': ['navigation', 'modal.new-tab.command-edit'],
17
+ 'modal.new-tab.worktree-delete-confirm': ['navigation', 'modal.new-tab.command-edit'],
13
18
  'modal.rename-tab': ['navigation'],
14
19
  'modal.session-name': ['modal.session-picker.filtering', 'navigation'],
15
20
  'modal.session-picker.filtering': ['navigation', 'modal.session-name', 'modal.create-session'],
@@ -18,7 +23,9 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
18
23
  'modal.split-picker': ['navigation', 'terminal-input'],
19
24
  'modal.theme-picker.filtering': ['navigation'],
20
25
  'modal.update-available': ['navigation'],
26
+ 'modal.worktree-delete-confirm': ['navigation'],
21
27
  'modal.worktree-move': ['git-mode', 'navigation'],
28
+ 'modal.worktree-move-confirm': ['navigation'],
22
29
  'navigation': [
23
30
  'terminal-input',
24
31
  'modal.new-tab.command-edit',
@@ -30,6 +37,8 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
30
37
  'modal.rename-tab',
31
38
  'modal.update-available',
32
39
  'modal.ai-usage',
40
+ 'modal.worktree-delete-confirm',
41
+ 'modal.worktree-move-confirm',
33
42
  'git-mode',
34
43
  ],
35
44
  'terminal-input': ['navigation', 'modal.split-picker', 'modal.ai-usage'],