git-coco 0.41.0 → 0.41.1
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/dist/index.esm.mjs +183 -55
- package/dist/index.js +183 -55
- package/package.json +1 -1
package/dist/index.esm.mjs
CHANGED
|
@@ -53,7 +53,7 @@ import { pathToFileURL } from 'url';
|
|
|
53
53
|
/**
|
|
54
54
|
* Current build version from package.json
|
|
55
55
|
*/
|
|
56
|
-
const BUILD_VERSION = "0.41.
|
|
56
|
+
const BUILD_VERSION = "0.41.1";
|
|
57
57
|
|
|
58
58
|
const isInteractive = (config) => {
|
|
59
59
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -14951,6 +14951,19 @@ function getLogInkWorkflowActions() {
|
|
|
14951
14951
|
kind: 'destructive',
|
|
14952
14952
|
requiresConfirmation: true,
|
|
14953
14953
|
},
|
|
14954
|
+
{
|
|
14955
|
+
// Per-view-only — the inkInput handler scopes this to the
|
|
14956
|
+
// worktrees surface so the global `D` keystroke (delete-branch)
|
|
14957
|
+
// keeps working from elsewhere. The empty `key` keeps the
|
|
14958
|
+
// workflow palette-discoverable but does not register a global
|
|
14959
|
+
// hotkey that would collide with delete-branch.
|
|
14960
|
+
id: 'remove-worktree-and-branch',
|
|
14961
|
+
key: '',
|
|
14962
|
+
label: 'Remove worktree + delete branch',
|
|
14963
|
+
description: 'Remove the selected worktree and delete the branch it was tracking after confirmation.',
|
|
14964
|
+
kind: 'destructive',
|
|
14965
|
+
requiresConfirmation: true,
|
|
14966
|
+
},
|
|
14954
14967
|
{
|
|
14955
14968
|
id: 'abort-operation',
|
|
14956
14969
|
key: 'A',
|
|
@@ -18415,6 +18428,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18415
18428
|
if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
|
|
18416
18429
|
return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
|
|
18417
18430
|
}
|
|
18431
|
+
// Per-view worktree action: `D` removes the worktree AND deletes
|
|
18432
|
+
// the branch it was tracking (#838). Scoped to the worktrees
|
|
18433
|
+
// surface so it intercepts BEFORE the global workflow-by-key
|
|
18434
|
+
// dispatcher would otherwise route `D` to delete-branch (which
|
|
18435
|
+
// would silently target whatever was last cursored on the branches
|
|
18436
|
+
// surface instead of acting on the worktree under the cursor here).
|
|
18437
|
+
// `W` keeps its existing "remove worktree only" semantics.
|
|
18438
|
+
if (inputValue === 'D' && isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
18439
|
+
return [action({ type: 'setPendingConfirmation', value: 'remove-worktree-and-branch' })];
|
|
18440
|
+
}
|
|
18418
18441
|
// #783 — full PR action panel keys, scoped to the pull-request view.
|
|
18419
18442
|
// All five wrap a `gh pr <verb>` invocation; merge / request-changes /
|
|
18420
18443
|
// comment open prompts first, the rest route through the y-confirm
|
|
@@ -21485,6 +21508,56 @@ function worktreePathAction(worktree) {
|
|
|
21485
21508
|
message: `Worktree path: ${worktree.path}`,
|
|
21486
21509
|
};
|
|
21487
21510
|
}
|
|
21511
|
+
/**
|
|
21512
|
+
* Remove a worktree AND delete the branch it was tracking (#838). The
|
|
21513
|
+
* canonical "I'm done with this side branch" wind-down: removes the
|
|
21514
|
+
* worktree directory, then runs `git branch -d` on the previously
|
|
21515
|
+
* checked-out branch.
|
|
21516
|
+
*
|
|
21517
|
+
* Both pre-flight guards inherit from the underlying helpers:
|
|
21518
|
+
* - removeWorktree refuses the current worktree and dirty worktrees
|
|
21519
|
+
* - deleteBranch refuses the current branch and uses `-d` (safe
|
|
21520
|
+
* delete, refuses unmerged commits)
|
|
21521
|
+
*
|
|
21522
|
+
* Aborts cleanly at any failure point and surfaces a message that
|
|
21523
|
+
* names which step broke. When the worktree had no branch (detached
|
|
21524
|
+
* HEAD) the branch step is silently skipped — there's nothing to
|
|
21525
|
+
* delete and the worktree removal alone counts as success.
|
|
21526
|
+
*/
|
|
21527
|
+
async function removeWorktreeAndBranch(git, worktree, branchRefs) {
|
|
21528
|
+
const removeResult = await removeWorktree(git, worktree);
|
|
21529
|
+
if (!removeResult.ok) {
|
|
21530
|
+
return removeResult;
|
|
21531
|
+
}
|
|
21532
|
+
const branchName = worktree.branch;
|
|
21533
|
+
if (!branchName) {
|
|
21534
|
+
return {
|
|
21535
|
+
ok: true,
|
|
21536
|
+
message: `Removed worktree ${worktree.path} (no branch to delete)`,
|
|
21537
|
+
};
|
|
21538
|
+
}
|
|
21539
|
+
// Look up the local BranchRef for the branch this worktree was on.
|
|
21540
|
+
// deleteBranch needs the full ref (not just the name) so its
|
|
21541
|
+
// current-branch and local-only guards apply correctly.
|
|
21542
|
+
const branch = branchRefs.find((entry) => entry.type === 'local' && entry.shortName === branchName);
|
|
21543
|
+
if (!branch) {
|
|
21544
|
+
return {
|
|
21545
|
+
ok: true,
|
|
21546
|
+
message: `Removed worktree ${worktree.path} (branch ${branchName} not found in local branches)`,
|
|
21547
|
+
};
|
|
21548
|
+
}
|
|
21549
|
+
const deleteResult = await deleteBranch(git, branch);
|
|
21550
|
+
if (!deleteResult.ok) {
|
|
21551
|
+
return {
|
|
21552
|
+
ok: false,
|
|
21553
|
+
message: `Removed worktree ${worktree.path}, but branch delete failed: ${deleteResult.message}`,
|
|
21554
|
+
};
|
|
21555
|
+
}
|
|
21556
|
+
return {
|
|
21557
|
+
ok: true,
|
|
21558
|
+
message: `Removed worktree ${worktree.path} and deleted branch ${branchName}`,
|
|
21559
|
+
};
|
|
21560
|
+
}
|
|
21488
21561
|
|
|
21489
21562
|
function shortBranch(branch) {
|
|
21490
21563
|
return branch?.replace(/^refs\/heads\//, '');
|
|
@@ -24536,12 +24609,27 @@ function LogInkApp(deps) {
|
|
|
24536
24609
|
}, [git, selected?.hash]);
|
|
24537
24610
|
// #806 follow-up — auto-jump the history view to whichever branch /
|
|
24538
24611
|
// tag the user is currently cursoring in the sidebar (or the
|
|
24539
|
-
// dedicated branches / tags view).
|
|
24540
|
-
//
|
|
24612
|
+
// dedicated branches / tags view).
|
|
24613
|
+
//
|
|
24614
|
+
// Originally this fired on a 150ms trailing-edge debounce. The user
|
|
24615
|
+
// reported the sync feeling inconsistent (#839) — the trailing
|
|
24616
|
+
// pattern means a fast scroll through a long branch list cancels
|
|
24617
|
+
// the timer on every keystroke and only fires once on release; the
|
|
24618
|
+
// user never sees the cursor follow their navigation. Switched to
|
|
24619
|
+
// synchronous fire-on-effect so each cursor move snaps the history
|
|
24620
|
+
// graph immediately. The dispatch is cheap (O(n) findIndex on the
|
|
24621
|
+
// filtered commits + a state spread); React batches the re-renders
|
|
24622
|
+
// so even rapid scroll only paints the final position. Tracks the
|
|
24623
|
+
// last-dispatched hash via a ref so we don't fire setStatus
|
|
24624
|
+
// repeatedly when several adjacent branches all point at the same
|
|
24625
|
+
// commit (very common with squash-merged feature branches that all
|
|
24626
|
+
// converge on `main`'s tip).
|
|
24627
|
+
//
|
|
24541
24628
|
// No-op when the cursored ref's tip isn't in the loaded commit
|
|
24542
24629
|
// window (under compact mode the cursored branch's tip may not be
|
|
24543
24630
|
// fetched yet); a status hint surfaces in that case so the user
|
|
24544
24631
|
// knows to toggle full graph or load older commits.
|
|
24632
|
+
const lastSyncedHashRef = React.useRef(undefined);
|
|
24545
24633
|
React.useEffect(() => {
|
|
24546
24634
|
const onBranchTab = state.activeView === 'branches' ||
|
|
24547
24635
|
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
@@ -24549,58 +24637,58 @@ function LogInkApp(deps) {
|
|
|
24549
24637
|
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
24550
24638
|
if (!onBranchTab && !onTagTab)
|
|
24551
24639
|
return;
|
|
24552
|
-
let
|
|
24553
|
-
|
|
24554
|
-
|
|
24555
|
-
|
|
24556
|
-
|
|
24557
|
-
|
|
24558
|
-
|
|
24559
|
-
|
|
24560
|
-
|
|
24561
|
-
|
|
24562
|
-
|
|
24563
|
-
const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
|
|
24564
|
-
if (branch) {
|
|
24565
|
-
targetHash = branch.hash;
|
|
24566
|
-
targetLabel = `branch ${branch.shortName}`;
|
|
24567
|
-
}
|
|
24568
|
-
}
|
|
24569
|
-
else if (onTagTab) {
|
|
24570
|
-
const all = sortTags(context.tags?.tags || [], state.tagSort);
|
|
24571
|
-
const visible = state.filter
|
|
24572
|
-
? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
|
|
24573
|
-
: all;
|
|
24574
|
-
const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
|
|
24575
|
-
if (tag) {
|
|
24576
|
-
targetHash = tag.hash;
|
|
24577
|
-
targetLabel = `tag ${tag.name}`;
|
|
24578
|
-
}
|
|
24579
|
-
}
|
|
24580
|
-
if (!targetHash)
|
|
24581
|
-
return;
|
|
24582
|
-
const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
|
|
24583
|
-
if (loaded) {
|
|
24584
|
-
dispatch({ type: 'selectCommitByHash', hash: targetHash });
|
|
24585
|
-
// Confirmation status message so the user gets feedback even
|
|
24586
|
-
// when the dedicated branches / tags view is occupying the
|
|
24587
|
-
// main panel and the history cursor moves invisibly behind it.
|
|
24588
|
-
dispatch({
|
|
24589
|
-
type: 'setStatus',
|
|
24590
|
-
value: `Synced history to ${targetLabel} tip`,
|
|
24591
|
-
});
|
|
24640
|
+
let targetHash;
|
|
24641
|
+
let targetLabel;
|
|
24642
|
+
if (onBranchTab) {
|
|
24643
|
+
const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
|
|
24644
|
+
const visible = state.filter
|
|
24645
|
+
? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
|
|
24646
|
+
: all;
|
|
24647
|
+
const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
|
|
24648
|
+
if (branch) {
|
|
24649
|
+
targetHash = branch.hash;
|
|
24650
|
+
targetLabel = `branch ${branch.shortName}`;
|
|
24592
24651
|
}
|
|
24593
|
-
|
|
24594
|
-
|
|
24595
|
-
|
|
24596
|
-
|
|
24597
|
-
|
|
24652
|
+
}
|
|
24653
|
+
else if (onTagTab) {
|
|
24654
|
+
const all = sortTags(context.tags?.tags || [], state.tagSort);
|
|
24655
|
+
const visible = state.filter
|
|
24656
|
+
? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
|
|
24657
|
+
: all;
|
|
24658
|
+
const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
|
|
24659
|
+
if (tag) {
|
|
24660
|
+
targetHash = tag.hash;
|
|
24661
|
+
targetLabel = `tag ${tag.name}`;
|
|
24598
24662
|
}
|
|
24599
|
-
}
|
|
24600
|
-
|
|
24601
|
-
|
|
24602
|
-
|
|
24603
|
-
|
|
24663
|
+
}
|
|
24664
|
+
if (!targetHash)
|
|
24665
|
+
return;
|
|
24666
|
+
// Skip the dispatch + status churn when the cursor hasn't
|
|
24667
|
+
// actually changed which commit it's targeting (the case for
|
|
24668
|
+
// rapid navigation through a cluster of branches that all point
|
|
24669
|
+
// at the same commit). Without this guard the user sees a stream
|
|
24670
|
+
// of "Synced history to <branch> tip" status messages even
|
|
24671
|
+
// though the history cursor never moved.
|
|
24672
|
+
if (targetHash === lastSyncedHashRef.current)
|
|
24673
|
+
return;
|
|
24674
|
+
const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
|
|
24675
|
+
if (loaded) {
|
|
24676
|
+
lastSyncedHashRef.current = targetHash;
|
|
24677
|
+
dispatch({ type: 'selectCommitByHash', hash: targetHash });
|
|
24678
|
+
// Confirmation status message so the user gets feedback even
|
|
24679
|
+
// when the dedicated branches / tags view is occupying the
|
|
24680
|
+
// main panel and the history cursor moves invisibly behind it.
|
|
24681
|
+
dispatch({
|
|
24682
|
+
type: 'setStatus',
|
|
24683
|
+
value: `Synced history to ${targetLabel} tip`,
|
|
24684
|
+
});
|
|
24685
|
+
}
|
|
24686
|
+
else {
|
|
24687
|
+
dispatch({
|
|
24688
|
+
type: 'setStatus',
|
|
24689
|
+
value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
|
|
24690
|
+
});
|
|
24691
|
+
}
|
|
24604
24692
|
}, [
|
|
24605
24693
|
dispatch, context.branches, context.tags,
|
|
24606
24694
|
state.activeView, state.focus, state.sidebarTab,
|
|
@@ -24608,6 +24696,18 @@ function LogInkApp(deps) {
|
|
|
24608
24696
|
state.branchSort, state.tagSort, state.filter,
|
|
24609
24697
|
state.filteredCommits,
|
|
24610
24698
|
]);
|
|
24699
|
+
// Reset the dedup ref when the user moves focus away from the
|
|
24700
|
+
// sidebar branches / tags tab so re-entering re-fires the sync
|
|
24701
|
+
// even if the cursored branch is the same as before.
|
|
24702
|
+
React.useEffect(() => {
|
|
24703
|
+
const onBranchTab = state.activeView === 'branches' ||
|
|
24704
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
24705
|
+
const onTagTab = state.activeView === 'tags' ||
|
|
24706
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
24707
|
+
if (!onBranchTab && !onTagTab) {
|
|
24708
|
+
lastSyncedHashRef.current = undefined;
|
|
24709
|
+
}
|
|
24710
|
+
}, [state.activeView, state.focus, state.sidebarTab]);
|
|
24611
24711
|
React.useEffect(() => {
|
|
24612
24712
|
let active = true;
|
|
24613
24713
|
async function loadWorktreeDiff() {
|
|
@@ -25047,6 +25147,29 @@ function LogInkApp(deps) {
|
|
|
25047
25147
|
}
|
|
25048
25148
|
return removeWorktree(git, cursorTarget);
|
|
25049
25149
|
},
|
|
25150
|
+
'remove-worktree-and-branch': async () => {
|
|
25151
|
+
const all = context.worktreeList?.worktrees || [];
|
|
25152
|
+
const visible = state.filter
|
|
25153
|
+
? all.filter((w) => matchesPromotedFilter([w.path, w.branch || ''], state.filter))
|
|
25154
|
+
: all;
|
|
25155
|
+
const cursorTarget = visible.length
|
|
25156
|
+
? visible[Math.min(state.selectedWorktreeListIndex, visible.length - 1)]
|
|
25157
|
+
: all[Math.min(state.selectedWorktreeListIndex, Math.max(0, all.length - 1))];
|
|
25158
|
+
if (!cursorTarget)
|
|
25159
|
+
return { ok: false, message: 'No worktree selected' };
|
|
25160
|
+
if (cursorTarget.current) {
|
|
25161
|
+
return {
|
|
25162
|
+
ok: false,
|
|
25163
|
+
message: 'Cannot remove the current worktree — switch to another worktree first.',
|
|
25164
|
+
};
|
|
25165
|
+
}
|
|
25166
|
+
// The chained helper handles the worktree removal AND the
|
|
25167
|
+
// safe branch delete in one call. Branch refs come from the
|
|
25168
|
+
// live context so the underlying deleteBranch helper sees
|
|
25169
|
+
// the current/local flags it needs to refuse the destructive
|
|
25170
|
+
// path on the wrong target.
|
|
25171
|
+
return removeWorktreeAndBranch(git, cursorTarget, context.branches?.localBranches || []);
|
|
25172
|
+
},
|
|
25050
25173
|
'abort-operation': async () => {
|
|
25051
25174
|
const operation = context.operation?.operation;
|
|
25052
25175
|
if (!operation) {
|
|
@@ -27225,7 +27348,12 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
|
|
|
27225
27348
|
}, truncate$1(theme.ascii ? '[...] AI draft in progress' : '⏳ AI draft in progress', width - 4))]
|
|
27226
27349
|
: []), ...(stagedFiles.length
|
|
27227
27350
|
? [
|
|
27228
|
-
|
|
27351
|
+
// Section header carries the total count to match the status
|
|
27352
|
+
// surface's "▾ Staged (n)" treatment (#840). The visible
|
|
27353
|
+
// file list is sliced at 12 rows; using `worktree.stagedCount`
|
|
27354
|
+
// (the total) avoids a misleading "Staged (12)" label when
|
|
27355
|
+
// there are actually more staged files below the slice.
|
|
27356
|
+
h(Text, { key: 'compose-context-staged-title', bold: true }, `Staged (${worktree?.stagedCount ?? stagedFiles.length})`),
|
|
27229
27357
|
...stagedFiles.map((file, index) => h(Text, {
|
|
27230
27358
|
key: `compose-context-staged-${index}`,
|
|
27231
27359
|
color: theme.noColor ? undefined : theme.colors.gitAdded,
|
|
@@ -27234,7 +27362,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
|
|
|
27234
27362
|
]
|
|
27235
27363
|
: []), ...(unstagedFiles.length
|
|
27236
27364
|
? [
|
|
27237
|
-
h(Text, { key: 'compose-context-unstaged-title', bold: true },
|
|
27365
|
+
h(Text, { key: 'compose-context-unstaged-title', bold: true }, `Unstaged (${worktree?.unstagedCount ?? unstagedFiles.length})`),
|
|
27238
27366
|
...unstagedFiles.map((file, index) => h(Text, {
|
|
27239
27367
|
key: `compose-context-unstaged-${index}`,
|
|
27240
27368
|
color: theme.noColor ? undefined : theme.colors.gitModified,
|
package/dist/index.js
CHANGED
|
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
|
|
|
78
78
|
/**
|
|
79
79
|
* Current build version from package.json
|
|
80
80
|
*/
|
|
81
|
-
const BUILD_VERSION = "0.41.
|
|
81
|
+
const BUILD_VERSION = "0.41.1";
|
|
82
82
|
|
|
83
83
|
const isInteractive = (config) => {
|
|
84
84
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -14976,6 +14976,19 @@ function getLogInkWorkflowActions() {
|
|
|
14976
14976
|
kind: 'destructive',
|
|
14977
14977
|
requiresConfirmation: true,
|
|
14978
14978
|
},
|
|
14979
|
+
{
|
|
14980
|
+
// Per-view-only — the inkInput handler scopes this to the
|
|
14981
|
+
// worktrees surface so the global `D` keystroke (delete-branch)
|
|
14982
|
+
// keeps working from elsewhere. The empty `key` keeps the
|
|
14983
|
+
// workflow palette-discoverable but does not register a global
|
|
14984
|
+
// hotkey that would collide with delete-branch.
|
|
14985
|
+
id: 'remove-worktree-and-branch',
|
|
14986
|
+
key: '',
|
|
14987
|
+
label: 'Remove worktree + delete branch',
|
|
14988
|
+
description: 'Remove the selected worktree and delete the branch it was tracking after confirmation.',
|
|
14989
|
+
kind: 'destructive',
|
|
14990
|
+
requiresConfirmation: true,
|
|
14991
|
+
},
|
|
14979
14992
|
{
|
|
14980
14993
|
id: 'abort-operation',
|
|
14981
14994
|
key: 'A',
|
|
@@ -18440,6 +18453,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18440
18453
|
if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
|
|
18441
18454
|
return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
|
|
18442
18455
|
}
|
|
18456
|
+
// Per-view worktree action: `D` removes the worktree AND deletes
|
|
18457
|
+
// the branch it was tracking (#838). Scoped to the worktrees
|
|
18458
|
+
// surface so it intercepts BEFORE the global workflow-by-key
|
|
18459
|
+
// dispatcher would otherwise route `D` to delete-branch (which
|
|
18460
|
+
// would silently target whatever was last cursored on the branches
|
|
18461
|
+
// surface instead of acting on the worktree under the cursor here).
|
|
18462
|
+
// `W` keeps its existing "remove worktree only" semantics.
|
|
18463
|
+
if (inputValue === 'D' && isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
18464
|
+
return [action({ type: 'setPendingConfirmation', value: 'remove-worktree-and-branch' })];
|
|
18465
|
+
}
|
|
18443
18466
|
// #783 — full PR action panel keys, scoped to the pull-request view.
|
|
18444
18467
|
// All five wrap a `gh pr <verb>` invocation; merge / request-changes /
|
|
18445
18468
|
// comment open prompts first, the rest route through the y-confirm
|
|
@@ -21510,6 +21533,56 @@ function worktreePathAction(worktree) {
|
|
|
21510
21533
|
message: `Worktree path: ${worktree.path}`,
|
|
21511
21534
|
};
|
|
21512
21535
|
}
|
|
21536
|
+
/**
|
|
21537
|
+
* Remove a worktree AND delete the branch it was tracking (#838). The
|
|
21538
|
+
* canonical "I'm done with this side branch" wind-down: removes the
|
|
21539
|
+
* worktree directory, then runs `git branch -d` on the previously
|
|
21540
|
+
* checked-out branch.
|
|
21541
|
+
*
|
|
21542
|
+
* Both pre-flight guards inherit from the underlying helpers:
|
|
21543
|
+
* - removeWorktree refuses the current worktree and dirty worktrees
|
|
21544
|
+
* - deleteBranch refuses the current branch and uses `-d` (safe
|
|
21545
|
+
* delete, refuses unmerged commits)
|
|
21546
|
+
*
|
|
21547
|
+
* Aborts cleanly at any failure point and surfaces a message that
|
|
21548
|
+
* names which step broke. When the worktree had no branch (detached
|
|
21549
|
+
* HEAD) the branch step is silently skipped — there's nothing to
|
|
21550
|
+
* delete and the worktree removal alone counts as success.
|
|
21551
|
+
*/
|
|
21552
|
+
async function removeWorktreeAndBranch(git, worktree, branchRefs) {
|
|
21553
|
+
const removeResult = await removeWorktree(git, worktree);
|
|
21554
|
+
if (!removeResult.ok) {
|
|
21555
|
+
return removeResult;
|
|
21556
|
+
}
|
|
21557
|
+
const branchName = worktree.branch;
|
|
21558
|
+
if (!branchName) {
|
|
21559
|
+
return {
|
|
21560
|
+
ok: true,
|
|
21561
|
+
message: `Removed worktree ${worktree.path} (no branch to delete)`,
|
|
21562
|
+
};
|
|
21563
|
+
}
|
|
21564
|
+
// Look up the local BranchRef for the branch this worktree was on.
|
|
21565
|
+
// deleteBranch needs the full ref (not just the name) so its
|
|
21566
|
+
// current-branch and local-only guards apply correctly.
|
|
21567
|
+
const branch = branchRefs.find((entry) => entry.type === 'local' && entry.shortName === branchName);
|
|
21568
|
+
if (!branch) {
|
|
21569
|
+
return {
|
|
21570
|
+
ok: true,
|
|
21571
|
+
message: `Removed worktree ${worktree.path} (branch ${branchName} not found in local branches)`,
|
|
21572
|
+
};
|
|
21573
|
+
}
|
|
21574
|
+
const deleteResult = await deleteBranch(git, branch);
|
|
21575
|
+
if (!deleteResult.ok) {
|
|
21576
|
+
return {
|
|
21577
|
+
ok: false,
|
|
21578
|
+
message: `Removed worktree ${worktree.path}, but branch delete failed: ${deleteResult.message}`,
|
|
21579
|
+
};
|
|
21580
|
+
}
|
|
21581
|
+
return {
|
|
21582
|
+
ok: true,
|
|
21583
|
+
message: `Removed worktree ${worktree.path} and deleted branch ${branchName}`,
|
|
21584
|
+
};
|
|
21585
|
+
}
|
|
21513
21586
|
|
|
21514
21587
|
function shortBranch(branch) {
|
|
21515
21588
|
return branch?.replace(/^refs\/heads\//, '');
|
|
@@ -24561,12 +24634,27 @@ function LogInkApp(deps) {
|
|
|
24561
24634
|
}, [git, selected?.hash]);
|
|
24562
24635
|
// #806 follow-up — auto-jump the history view to whichever branch /
|
|
24563
24636
|
// tag the user is currently cursoring in the sidebar (or the
|
|
24564
|
-
// dedicated branches / tags view).
|
|
24565
|
-
//
|
|
24637
|
+
// dedicated branches / tags view).
|
|
24638
|
+
//
|
|
24639
|
+
// Originally this fired on a 150ms trailing-edge debounce. The user
|
|
24640
|
+
// reported the sync feeling inconsistent (#839) — the trailing
|
|
24641
|
+
// pattern means a fast scroll through a long branch list cancels
|
|
24642
|
+
// the timer on every keystroke and only fires once on release; the
|
|
24643
|
+
// user never sees the cursor follow their navigation. Switched to
|
|
24644
|
+
// synchronous fire-on-effect so each cursor move snaps the history
|
|
24645
|
+
// graph immediately. The dispatch is cheap (O(n) findIndex on the
|
|
24646
|
+
// filtered commits + a state spread); React batches the re-renders
|
|
24647
|
+
// so even rapid scroll only paints the final position. Tracks the
|
|
24648
|
+
// last-dispatched hash via a ref so we don't fire setStatus
|
|
24649
|
+
// repeatedly when several adjacent branches all point at the same
|
|
24650
|
+
// commit (very common with squash-merged feature branches that all
|
|
24651
|
+
// converge on `main`'s tip).
|
|
24652
|
+
//
|
|
24566
24653
|
// No-op when the cursored ref's tip isn't in the loaded commit
|
|
24567
24654
|
// window (under compact mode the cursored branch's tip may not be
|
|
24568
24655
|
// fetched yet); a status hint surfaces in that case so the user
|
|
24569
24656
|
// knows to toggle full graph or load older commits.
|
|
24657
|
+
const lastSyncedHashRef = React.useRef(undefined);
|
|
24570
24658
|
React.useEffect(() => {
|
|
24571
24659
|
const onBranchTab = state.activeView === 'branches' ||
|
|
24572
24660
|
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
@@ -24574,58 +24662,58 @@ function LogInkApp(deps) {
|
|
|
24574
24662
|
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
24575
24663
|
if (!onBranchTab && !onTagTab)
|
|
24576
24664
|
return;
|
|
24577
|
-
let
|
|
24578
|
-
|
|
24579
|
-
|
|
24580
|
-
|
|
24581
|
-
|
|
24582
|
-
|
|
24583
|
-
|
|
24584
|
-
|
|
24585
|
-
|
|
24586
|
-
|
|
24587
|
-
|
|
24588
|
-
const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
|
|
24589
|
-
if (branch) {
|
|
24590
|
-
targetHash = branch.hash;
|
|
24591
|
-
targetLabel = `branch ${branch.shortName}`;
|
|
24592
|
-
}
|
|
24593
|
-
}
|
|
24594
|
-
else if (onTagTab) {
|
|
24595
|
-
const all = sortTags(context.tags?.tags || [], state.tagSort);
|
|
24596
|
-
const visible = state.filter
|
|
24597
|
-
? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
|
|
24598
|
-
: all;
|
|
24599
|
-
const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
|
|
24600
|
-
if (tag) {
|
|
24601
|
-
targetHash = tag.hash;
|
|
24602
|
-
targetLabel = `tag ${tag.name}`;
|
|
24603
|
-
}
|
|
24604
|
-
}
|
|
24605
|
-
if (!targetHash)
|
|
24606
|
-
return;
|
|
24607
|
-
const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
|
|
24608
|
-
if (loaded) {
|
|
24609
|
-
dispatch({ type: 'selectCommitByHash', hash: targetHash });
|
|
24610
|
-
// Confirmation status message so the user gets feedback even
|
|
24611
|
-
// when the dedicated branches / tags view is occupying the
|
|
24612
|
-
// main panel and the history cursor moves invisibly behind it.
|
|
24613
|
-
dispatch({
|
|
24614
|
-
type: 'setStatus',
|
|
24615
|
-
value: `Synced history to ${targetLabel} tip`,
|
|
24616
|
-
});
|
|
24665
|
+
let targetHash;
|
|
24666
|
+
let targetLabel;
|
|
24667
|
+
if (onBranchTab) {
|
|
24668
|
+
const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
|
|
24669
|
+
const visible = state.filter
|
|
24670
|
+
? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
|
|
24671
|
+
: all;
|
|
24672
|
+
const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
|
|
24673
|
+
if (branch) {
|
|
24674
|
+
targetHash = branch.hash;
|
|
24675
|
+
targetLabel = `branch ${branch.shortName}`;
|
|
24617
24676
|
}
|
|
24618
|
-
|
|
24619
|
-
|
|
24620
|
-
|
|
24621
|
-
|
|
24622
|
-
|
|
24677
|
+
}
|
|
24678
|
+
else if (onTagTab) {
|
|
24679
|
+
const all = sortTags(context.tags?.tags || [], state.tagSort);
|
|
24680
|
+
const visible = state.filter
|
|
24681
|
+
? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
|
|
24682
|
+
: all;
|
|
24683
|
+
const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
|
|
24684
|
+
if (tag) {
|
|
24685
|
+
targetHash = tag.hash;
|
|
24686
|
+
targetLabel = `tag ${tag.name}`;
|
|
24623
24687
|
}
|
|
24624
|
-
}
|
|
24625
|
-
|
|
24626
|
-
|
|
24627
|
-
|
|
24628
|
-
|
|
24688
|
+
}
|
|
24689
|
+
if (!targetHash)
|
|
24690
|
+
return;
|
|
24691
|
+
// Skip the dispatch + status churn when the cursor hasn't
|
|
24692
|
+
// actually changed which commit it's targeting (the case for
|
|
24693
|
+
// rapid navigation through a cluster of branches that all point
|
|
24694
|
+
// at the same commit). Without this guard the user sees a stream
|
|
24695
|
+
// of "Synced history to <branch> tip" status messages even
|
|
24696
|
+
// though the history cursor never moved.
|
|
24697
|
+
if (targetHash === lastSyncedHashRef.current)
|
|
24698
|
+
return;
|
|
24699
|
+
const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
|
|
24700
|
+
if (loaded) {
|
|
24701
|
+
lastSyncedHashRef.current = targetHash;
|
|
24702
|
+
dispatch({ type: 'selectCommitByHash', hash: targetHash });
|
|
24703
|
+
// Confirmation status message so the user gets feedback even
|
|
24704
|
+
// when the dedicated branches / tags view is occupying the
|
|
24705
|
+
// main panel and the history cursor moves invisibly behind it.
|
|
24706
|
+
dispatch({
|
|
24707
|
+
type: 'setStatus',
|
|
24708
|
+
value: `Synced history to ${targetLabel} tip`,
|
|
24709
|
+
});
|
|
24710
|
+
}
|
|
24711
|
+
else {
|
|
24712
|
+
dispatch({
|
|
24713
|
+
type: 'setStatus',
|
|
24714
|
+
value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
|
|
24715
|
+
});
|
|
24716
|
+
}
|
|
24629
24717
|
}, [
|
|
24630
24718
|
dispatch, context.branches, context.tags,
|
|
24631
24719
|
state.activeView, state.focus, state.sidebarTab,
|
|
@@ -24633,6 +24721,18 @@ function LogInkApp(deps) {
|
|
|
24633
24721
|
state.branchSort, state.tagSort, state.filter,
|
|
24634
24722
|
state.filteredCommits,
|
|
24635
24723
|
]);
|
|
24724
|
+
// Reset the dedup ref when the user moves focus away from the
|
|
24725
|
+
// sidebar branches / tags tab so re-entering re-fires the sync
|
|
24726
|
+
// even if the cursored branch is the same as before.
|
|
24727
|
+
React.useEffect(() => {
|
|
24728
|
+
const onBranchTab = state.activeView === 'branches' ||
|
|
24729
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
24730
|
+
const onTagTab = state.activeView === 'tags' ||
|
|
24731
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
24732
|
+
if (!onBranchTab && !onTagTab) {
|
|
24733
|
+
lastSyncedHashRef.current = undefined;
|
|
24734
|
+
}
|
|
24735
|
+
}, [state.activeView, state.focus, state.sidebarTab]);
|
|
24636
24736
|
React.useEffect(() => {
|
|
24637
24737
|
let active = true;
|
|
24638
24738
|
async function loadWorktreeDiff() {
|
|
@@ -25072,6 +25172,29 @@ function LogInkApp(deps) {
|
|
|
25072
25172
|
}
|
|
25073
25173
|
return removeWorktree(git, cursorTarget);
|
|
25074
25174
|
},
|
|
25175
|
+
'remove-worktree-and-branch': async () => {
|
|
25176
|
+
const all = context.worktreeList?.worktrees || [];
|
|
25177
|
+
const visible = state.filter
|
|
25178
|
+
? all.filter((w) => matchesPromotedFilter([w.path, w.branch || ''], state.filter))
|
|
25179
|
+
: all;
|
|
25180
|
+
const cursorTarget = visible.length
|
|
25181
|
+
? visible[Math.min(state.selectedWorktreeListIndex, visible.length - 1)]
|
|
25182
|
+
: all[Math.min(state.selectedWorktreeListIndex, Math.max(0, all.length - 1))];
|
|
25183
|
+
if (!cursorTarget)
|
|
25184
|
+
return { ok: false, message: 'No worktree selected' };
|
|
25185
|
+
if (cursorTarget.current) {
|
|
25186
|
+
return {
|
|
25187
|
+
ok: false,
|
|
25188
|
+
message: 'Cannot remove the current worktree — switch to another worktree first.',
|
|
25189
|
+
};
|
|
25190
|
+
}
|
|
25191
|
+
// The chained helper handles the worktree removal AND the
|
|
25192
|
+
// safe branch delete in one call. Branch refs come from the
|
|
25193
|
+
// live context so the underlying deleteBranch helper sees
|
|
25194
|
+
// the current/local flags it needs to refuse the destructive
|
|
25195
|
+
// path on the wrong target.
|
|
25196
|
+
return removeWorktreeAndBranch(git, cursorTarget, context.branches?.localBranches || []);
|
|
25197
|
+
},
|
|
25075
25198
|
'abort-operation': async () => {
|
|
25076
25199
|
const operation = context.operation?.operation;
|
|
25077
25200
|
if (!operation) {
|
|
@@ -27250,7 +27373,12 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
|
|
|
27250
27373
|
}, truncate$1(theme.ascii ? '[...] AI draft in progress' : '⏳ AI draft in progress', width - 4))]
|
|
27251
27374
|
: []), ...(stagedFiles.length
|
|
27252
27375
|
? [
|
|
27253
|
-
|
|
27376
|
+
// Section header carries the total count to match the status
|
|
27377
|
+
// surface's "▾ Staged (n)" treatment (#840). The visible
|
|
27378
|
+
// file list is sliced at 12 rows; using `worktree.stagedCount`
|
|
27379
|
+
// (the total) avoids a misleading "Staged (12)" label when
|
|
27380
|
+
// there are actually more staged files below the slice.
|
|
27381
|
+
h(Text, { key: 'compose-context-staged-title', bold: true }, `Staged (${worktree?.stagedCount ?? stagedFiles.length})`),
|
|
27254
27382
|
...stagedFiles.map((file, index) => h(Text, {
|
|
27255
27383
|
key: `compose-context-staged-${index}`,
|
|
27256
27384
|
color: theme.noColor ? undefined : theme.colors.gitAdded,
|
|
@@ -27259,7 +27387,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
|
|
|
27259
27387
|
]
|
|
27260
27388
|
: []), ...(unstagedFiles.length
|
|
27261
27389
|
? [
|
|
27262
|
-
h(Text, { key: 'compose-context-unstaged-title', bold: true },
|
|
27390
|
+
h(Text, { key: 'compose-context-unstaged-title', bold: true }, `Unstaged (${worktree?.unstagedCount ?? unstagedFiles.length})`),
|
|
27263
27391
|
...unstagedFiles.map((file, index) => h(Text, {
|
|
27264
27392
|
key: `compose-context-unstaged-${index}`,
|
|
27265
27393
|
color: theme.noColor ? undefined : theme.colors.gitModified,
|