@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 +2 -2
- package/src/app-runtime/side-effects.ts +122 -43
- package/src/app.tsx +26 -1
- package/src/config.ts +4 -0
- package/src/git/move-worktree.ts +87 -17
- package/src/git/worktree.ts +45 -0
- package/src/input/modes/bridge.ts +5 -0
- package/src/input/modes/transitions.ts +10 -1
- package/src/input/modes/types.ts +20 -1
- package/src/ipc/manager-protocol.ts +9 -1
- package/src/ipc/protocol.ts +9 -1
- package/src/pty/ghostty-shell-integration.ts +34 -0
- package/src/pty/pty-manager.ts +60 -6
- package/src/pty/terminal-snapshot.ts +29 -13
- package/src/state/reducers/modal-state.ts +166 -19
- package/src/state/reducers/session-state.ts +11 -19
- package/src/state/reducers/tab-state.ts +17 -0
- package/src/state/selectors.ts +45 -1
- package/src/state/session-persistence.ts +23 -1
- package/src/state/types.ts +83 -7
- package/src/state/validation.ts +9 -1
- package/src/ui/components/layout/sidebar/tab-item.tsx +4 -2
- package/src/ui/components/layout/sidebar/worktree-row.tsx +12 -2
- package/src/ui/components/layout/terminal-pane.tsx +112 -8
- package/src/ui/components/layout/top-tab-bar.tsx +146 -12
- package/src/ui/components/modals/shared/worktree-delete-confirm.tsx +39 -0
- package/src/ui/components/modals/tabs/new-tab-modal.tsx +70 -23
- package/src/ui/components/modals/worktree/worktree-move-confirm-modal.tsx +65 -0
- package/src/ui/components/modals/worktree/worktree-move-modal.tsx +18 -1
- package/src/ui/root.tsx +25 -2
- package/src/ui/status-bar-model.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
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
|
-
//
|
|
1637
|
-
//
|
|
1638
|
-
|
|
1639
|
-
|
|
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
|
-
`
|
|
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({
|
|
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})
|
|
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,
|
package/src/git/move-worktree.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
12
|
-
return result
|
|
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
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/git/worktree.ts
CHANGED
|
@@ -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': [
|
|
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'],
|