git-coco 0.41.0 → 0.41.2
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 +209 -59
- package/dist/index.js +209 -59
- 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.2";
|
|
57
57
|
|
|
58
58
|
const isInteractive = (config) => {
|
|
59
59
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -6750,13 +6750,35 @@ async function getCommitLogRangeDetails(from, to, { noMerges, git }) {
|
|
|
6750
6750
|
}
|
|
6751
6751
|
|
|
6752
6752
|
/**
|
|
6753
|
-
*
|
|
6753
|
+
* Retrieve the name of the current branch.
|
|
6754
6754
|
*
|
|
6755
|
-
*
|
|
6756
|
-
*
|
|
6755
|
+
* The first-choice path uses `git rev-parse --abbrev-ref HEAD`, which
|
|
6756
|
+
* returns the active branch on a normal repo. On an initial-commit
|
|
6757
|
+
* repo (fresh `git init` with no commits yet) HEAD does not resolve
|
|
6758
|
+
* and rev-parse fails fatally — but `git symbolic-ref --short HEAD`
|
|
6759
|
+
* still reports the configured initial branch name, so we fall
|
|
6760
|
+
* through to that. Final fallback is an empty string for genuinely
|
|
6761
|
+
* detached / corrupt states; every caller treats that as "no branch
|
|
6762
|
+
* context", which is the right semantics for a no-HEAD repo.
|
|
6763
|
+
*
|
|
6764
|
+
* Without this resilience, every command that depends on the branch
|
|
6765
|
+
* name (e.g. the post-summary step in `coco commit`) would crash
|
|
6766
|
+
* with `fatal: ambiguous argument 'HEAD'` after the entire diff
|
|
6767
|
+
* pipeline already ran (#844).
|
|
6757
6768
|
*/
|
|
6758
6769
|
async function getCurrentBranchName({ git }) {
|
|
6759
|
-
|
|
6770
|
+
try {
|
|
6771
|
+
return await git.revparse(['--abbrev-ref', 'HEAD']);
|
|
6772
|
+
}
|
|
6773
|
+
catch {
|
|
6774
|
+
try {
|
|
6775
|
+
const ref = await git.raw(['symbolic-ref', '--short', 'HEAD']);
|
|
6776
|
+
return ref.trim();
|
|
6777
|
+
}
|
|
6778
|
+
catch {
|
|
6779
|
+
return '';
|
|
6780
|
+
}
|
|
6781
|
+
}
|
|
6760
6782
|
}
|
|
6761
6783
|
|
|
6762
6784
|
/**
|
|
@@ -14951,6 +14973,19 @@ function getLogInkWorkflowActions() {
|
|
|
14951
14973
|
kind: 'destructive',
|
|
14952
14974
|
requiresConfirmation: true,
|
|
14953
14975
|
},
|
|
14976
|
+
{
|
|
14977
|
+
// Per-view-only — the inkInput handler scopes this to the
|
|
14978
|
+
// worktrees surface so the global `D` keystroke (delete-branch)
|
|
14979
|
+
// keeps working from elsewhere. The empty `key` keeps the
|
|
14980
|
+
// workflow palette-discoverable but does not register a global
|
|
14981
|
+
// hotkey that would collide with delete-branch.
|
|
14982
|
+
id: 'remove-worktree-and-branch',
|
|
14983
|
+
key: '',
|
|
14984
|
+
label: 'Remove worktree + delete branch',
|
|
14985
|
+
description: 'Remove the selected worktree and delete the branch it was tracking after confirmation.',
|
|
14986
|
+
kind: 'destructive',
|
|
14987
|
+
requiresConfirmation: true,
|
|
14988
|
+
},
|
|
14954
14989
|
{
|
|
14955
14990
|
id: 'abort-operation',
|
|
14956
14991
|
key: 'A',
|
|
@@ -18415,6 +18450,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18415
18450
|
if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
|
|
18416
18451
|
return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
|
|
18417
18452
|
}
|
|
18453
|
+
// Per-view worktree action: `D` removes the worktree AND deletes
|
|
18454
|
+
// the branch it was tracking (#838). Scoped to the worktrees
|
|
18455
|
+
// surface so it intercepts BEFORE the global workflow-by-key
|
|
18456
|
+
// dispatcher would otherwise route `D` to delete-branch (which
|
|
18457
|
+
// would silently target whatever was last cursored on the branches
|
|
18458
|
+
// surface instead of acting on the worktree under the cursor here).
|
|
18459
|
+
// `W` keeps its existing "remove worktree only" semantics.
|
|
18460
|
+
if (inputValue === 'D' && isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
18461
|
+
return [action({ type: 'setPendingConfirmation', value: 'remove-worktree-and-branch' })];
|
|
18462
|
+
}
|
|
18418
18463
|
// #783 — full PR action panel keys, scoped to the pull-request view.
|
|
18419
18464
|
// All five wrap a `gh pr <verb>` invocation; merge / request-changes /
|
|
18420
18465
|
// comment open prompts first, the rest route through the y-confirm
|
|
@@ -21485,6 +21530,56 @@ function worktreePathAction(worktree) {
|
|
|
21485
21530
|
message: `Worktree path: ${worktree.path}`,
|
|
21486
21531
|
};
|
|
21487
21532
|
}
|
|
21533
|
+
/**
|
|
21534
|
+
* Remove a worktree AND delete the branch it was tracking (#838). The
|
|
21535
|
+
* canonical "I'm done with this side branch" wind-down: removes the
|
|
21536
|
+
* worktree directory, then runs `git branch -d` on the previously
|
|
21537
|
+
* checked-out branch.
|
|
21538
|
+
*
|
|
21539
|
+
* Both pre-flight guards inherit from the underlying helpers:
|
|
21540
|
+
* - removeWorktree refuses the current worktree and dirty worktrees
|
|
21541
|
+
* - deleteBranch refuses the current branch and uses `-d` (safe
|
|
21542
|
+
* delete, refuses unmerged commits)
|
|
21543
|
+
*
|
|
21544
|
+
* Aborts cleanly at any failure point and surfaces a message that
|
|
21545
|
+
* names which step broke. When the worktree had no branch (detached
|
|
21546
|
+
* HEAD) the branch step is silently skipped — there's nothing to
|
|
21547
|
+
* delete and the worktree removal alone counts as success.
|
|
21548
|
+
*/
|
|
21549
|
+
async function removeWorktreeAndBranch(git, worktree, branchRefs) {
|
|
21550
|
+
const removeResult = await removeWorktree(git, worktree);
|
|
21551
|
+
if (!removeResult.ok) {
|
|
21552
|
+
return removeResult;
|
|
21553
|
+
}
|
|
21554
|
+
const branchName = worktree.branch;
|
|
21555
|
+
if (!branchName) {
|
|
21556
|
+
return {
|
|
21557
|
+
ok: true,
|
|
21558
|
+
message: `Removed worktree ${worktree.path} (no branch to delete)`,
|
|
21559
|
+
};
|
|
21560
|
+
}
|
|
21561
|
+
// Look up the local BranchRef for the branch this worktree was on.
|
|
21562
|
+
// deleteBranch needs the full ref (not just the name) so its
|
|
21563
|
+
// current-branch and local-only guards apply correctly.
|
|
21564
|
+
const branch = branchRefs.find((entry) => entry.type === 'local' && entry.shortName === branchName);
|
|
21565
|
+
if (!branch) {
|
|
21566
|
+
return {
|
|
21567
|
+
ok: true,
|
|
21568
|
+
message: `Removed worktree ${worktree.path} (branch ${branchName} not found in local branches)`,
|
|
21569
|
+
};
|
|
21570
|
+
}
|
|
21571
|
+
const deleteResult = await deleteBranch(git, branch);
|
|
21572
|
+
if (!deleteResult.ok) {
|
|
21573
|
+
return {
|
|
21574
|
+
ok: false,
|
|
21575
|
+
message: `Removed worktree ${worktree.path}, but branch delete failed: ${deleteResult.message}`,
|
|
21576
|
+
};
|
|
21577
|
+
}
|
|
21578
|
+
return {
|
|
21579
|
+
ok: true,
|
|
21580
|
+
message: `Removed worktree ${worktree.path} and deleted branch ${branchName}`,
|
|
21581
|
+
};
|
|
21582
|
+
}
|
|
21488
21583
|
|
|
21489
21584
|
function shortBranch(branch) {
|
|
21490
21585
|
return branch?.replace(/^refs\/heads\//, '');
|
|
@@ -24536,12 +24631,27 @@ function LogInkApp(deps) {
|
|
|
24536
24631
|
}, [git, selected?.hash]);
|
|
24537
24632
|
// #806 follow-up — auto-jump the history view to whichever branch /
|
|
24538
24633
|
// tag the user is currently cursoring in the sidebar (or the
|
|
24539
|
-
// dedicated branches / tags view).
|
|
24540
|
-
//
|
|
24634
|
+
// dedicated branches / tags view).
|
|
24635
|
+
//
|
|
24636
|
+
// Originally this fired on a 150ms trailing-edge debounce. The user
|
|
24637
|
+
// reported the sync feeling inconsistent (#839) — the trailing
|
|
24638
|
+
// pattern means a fast scroll through a long branch list cancels
|
|
24639
|
+
// the timer on every keystroke and only fires once on release; the
|
|
24640
|
+
// user never sees the cursor follow their navigation. Switched to
|
|
24641
|
+
// synchronous fire-on-effect so each cursor move snaps the history
|
|
24642
|
+
// graph immediately. The dispatch is cheap (O(n) findIndex on the
|
|
24643
|
+
// filtered commits + a state spread); React batches the re-renders
|
|
24644
|
+
// so even rapid scroll only paints the final position. Tracks the
|
|
24645
|
+
// last-dispatched hash via a ref so we don't fire setStatus
|
|
24646
|
+
// repeatedly when several adjacent branches all point at the same
|
|
24647
|
+
// commit (very common with squash-merged feature branches that all
|
|
24648
|
+
// converge on `main`'s tip).
|
|
24649
|
+
//
|
|
24541
24650
|
// No-op when the cursored ref's tip isn't in the loaded commit
|
|
24542
24651
|
// window (under compact mode the cursored branch's tip may not be
|
|
24543
24652
|
// fetched yet); a status hint surfaces in that case so the user
|
|
24544
24653
|
// knows to toggle full graph or load older commits.
|
|
24654
|
+
const lastSyncedHashRef = React.useRef(undefined);
|
|
24545
24655
|
React.useEffect(() => {
|
|
24546
24656
|
const onBranchTab = state.activeView === 'branches' ||
|
|
24547
24657
|
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
@@ -24549,58 +24659,58 @@ function LogInkApp(deps) {
|
|
|
24549
24659
|
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
24550
24660
|
if (!onBranchTab && !onTagTab)
|
|
24551
24661
|
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
|
-
});
|
|
24662
|
+
let targetHash;
|
|
24663
|
+
let targetLabel;
|
|
24664
|
+
if (onBranchTab) {
|
|
24665
|
+
const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
|
|
24666
|
+
const visible = state.filter
|
|
24667
|
+
? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
|
|
24668
|
+
: all;
|
|
24669
|
+
const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
|
|
24670
|
+
if (branch) {
|
|
24671
|
+
targetHash = branch.hash;
|
|
24672
|
+
targetLabel = `branch ${branch.shortName}`;
|
|
24592
24673
|
}
|
|
24593
|
-
|
|
24594
|
-
|
|
24595
|
-
|
|
24596
|
-
|
|
24597
|
-
|
|
24674
|
+
}
|
|
24675
|
+
else if (onTagTab) {
|
|
24676
|
+
const all = sortTags(context.tags?.tags || [], state.tagSort);
|
|
24677
|
+
const visible = state.filter
|
|
24678
|
+
? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
|
|
24679
|
+
: all;
|
|
24680
|
+
const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
|
|
24681
|
+
if (tag) {
|
|
24682
|
+
targetHash = tag.hash;
|
|
24683
|
+
targetLabel = `tag ${tag.name}`;
|
|
24598
24684
|
}
|
|
24599
|
-
}
|
|
24600
|
-
|
|
24601
|
-
|
|
24602
|
-
|
|
24603
|
-
|
|
24685
|
+
}
|
|
24686
|
+
if (!targetHash)
|
|
24687
|
+
return;
|
|
24688
|
+
// Skip the dispatch + status churn when the cursor hasn't
|
|
24689
|
+
// actually changed which commit it's targeting (the case for
|
|
24690
|
+
// rapid navigation through a cluster of branches that all point
|
|
24691
|
+
// at the same commit). Without this guard the user sees a stream
|
|
24692
|
+
// of "Synced history to <branch> tip" status messages even
|
|
24693
|
+
// though the history cursor never moved.
|
|
24694
|
+
if (targetHash === lastSyncedHashRef.current)
|
|
24695
|
+
return;
|
|
24696
|
+
const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
|
|
24697
|
+
if (loaded) {
|
|
24698
|
+
lastSyncedHashRef.current = targetHash;
|
|
24699
|
+
dispatch({ type: 'selectCommitByHash', hash: targetHash });
|
|
24700
|
+
// Confirmation status message so the user gets feedback even
|
|
24701
|
+
// when the dedicated branches / tags view is occupying the
|
|
24702
|
+
// main panel and the history cursor moves invisibly behind it.
|
|
24703
|
+
dispatch({
|
|
24704
|
+
type: 'setStatus',
|
|
24705
|
+
value: `Synced history to ${targetLabel} tip`,
|
|
24706
|
+
});
|
|
24707
|
+
}
|
|
24708
|
+
else {
|
|
24709
|
+
dispatch({
|
|
24710
|
+
type: 'setStatus',
|
|
24711
|
+
value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
|
|
24712
|
+
});
|
|
24713
|
+
}
|
|
24604
24714
|
}, [
|
|
24605
24715
|
dispatch, context.branches, context.tags,
|
|
24606
24716
|
state.activeView, state.focus, state.sidebarTab,
|
|
@@ -24608,6 +24718,18 @@ function LogInkApp(deps) {
|
|
|
24608
24718
|
state.branchSort, state.tagSort, state.filter,
|
|
24609
24719
|
state.filteredCommits,
|
|
24610
24720
|
]);
|
|
24721
|
+
// Reset the dedup ref when the user moves focus away from the
|
|
24722
|
+
// sidebar branches / tags tab so re-entering re-fires the sync
|
|
24723
|
+
// even if the cursored branch is the same as before.
|
|
24724
|
+
React.useEffect(() => {
|
|
24725
|
+
const onBranchTab = state.activeView === 'branches' ||
|
|
24726
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
24727
|
+
const onTagTab = state.activeView === 'tags' ||
|
|
24728
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
24729
|
+
if (!onBranchTab && !onTagTab) {
|
|
24730
|
+
lastSyncedHashRef.current = undefined;
|
|
24731
|
+
}
|
|
24732
|
+
}, [state.activeView, state.focus, state.sidebarTab]);
|
|
24611
24733
|
React.useEffect(() => {
|
|
24612
24734
|
let active = true;
|
|
24613
24735
|
async function loadWorktreeDiff() {
|
|
@@ -25047,6 +25169,29 @@ function LogInkApp(deps) {
|
|
|
25047
25169
|
}
|
|
25048
25170
|
return removeWorktree(git, cursorTarget);
|
|
25049
25171
|
},
|
|
25172
|
+
'remove-worktree-and-branch': async () => {
|
|
25173
|
+
const all = context.worktreeList?.worktrees || [];
|
|
25174
|
+
const visible = state.filter
|
|
25175
|
+
? all.filter((w) => matchesPromotedFilter([w.path, w.branch || ''], state.filter))
|
|
25176
|
+
: all;
|
|
25177
|
+
const cursorTarget = visible.length
|
|
25178
|
+
? visible[Math.min(state.selectedWorktreeListIndex, visible.length - 1)]
|
|
25179
|
+
: all[Math.min(state.selectedWorktreeListIndex, Math.max(0, all.length - 1))];
|
|
25180
|
+
if (!cursorTarget)
|
|
25181
|
+
return { ok: false, message: 'No worktree selected' };
|
|
25182
|
+
if (cursorTarget.current) {
|
|
25183
|
+
return {
|
|
25184
|
+
ok: false,
|
|
25185
|
+
message: 'Cannot remove the current worktree — switch to another worktree first.',
|
|
25186
|
+
};
|
|
25187
|
+
}
|
|
25188
|
+
// The chained helper handles the worktree removal AND the
|
|
25189
|
+
// safe branch delete in one call. Branch refs come from the
|
|
25190
|
+
// live context so the underlying deleteBranch helper sees
|
|
25191
|
+
// the current/local flags it needs to refuse the destructive
|
|
25192
|
+
// path on the wrong target.
|
|
25193
|
+
return removeWorktreeAndBranch(git, cursorTarget, context.branches?.localBranches || []);
|
|
25194
|
+
},
|
|
25050
25195
|
'abort-operation': async () => {
|
|
25051
25196
|
const operation = context.operation?.operation;
|
|
25052
25197
|
if (!operation) {
|
|
@@ -27225,7 +27370,12 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
|
|
|
27225
27370
|
}, truncate$1(theme.ascii ? '[...] AI draft in progress' : '⏳ AI draft in progress', width - 4))]
|
|
27226
27371
|
: []), ...(stagedFiles.length
|
|
27227
27372
|
? [
|
|
27228
|
-
|
|
27373
|
+
// Section header carries the total count to match the status
|
|
27374
|
+
// surface's "▾ Staged (n)" treatment (#840). The visible
|
|
27375
|
+
// file list is sliced at 12 rows; using `worktree.stagedCount`
|
|
27376
|
+
// (the total) avoids a misleading "Staged (12)" label when
|
|
27377
|
+
// there are actually more staged files below the slice.
|
|
27378
|
+
h(Text, { key: 'compose-context-staged-title', bold: true }, `Staged (${worktree?.stagedCount ?? stagedFiles.length})`),
|
|
27229
27379
|
...stagedFiles.map((file, index) => h(Text, {
|
|
27230
27380
|
key: `compose-context-staged-${index}`,
|
|
27231
27381
|
color: theme.noColor ? undefined : theme.colors.gitAdded,
|
|
@@ -27234,7 +27384,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
|
|
|
27234
27384
|
]
|
|
27235
27385
|
: []), ...(unstagedFiles.length
|
|
27236
27386
|
? [
|
|
27237
|
-
h(Text, { key: 'compose-context-unstaged-title', bold: true },
|
|
27387
|
+
h(Text, { key: 'compose-context-unstaged-title', bold: true }, `Unstaged (${worktree?.unstagedCount ?? unstagedFiles.length})`),
|
|
27238
27388
|
...unstagedFiles.map((file, index) => h(Text, {
|
|
27239
27389
|
key: `compose-context-unstaged-${index}`,
|
|
27240
27390
|
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.2";
|
|
82
82
|
|
|
83
83
|
const isInteractive = (config) => {
|
|
84
84
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -6775,13 +6775,35 @@ async function getCommitLogRangeDetails(from, to, { noMerges, git }) {
|
|
|
6775
6775
|
}
|
|
6776
6776
|
|
|
6777
6777
|
/**
|
|
6778
|
-
*
|
|
6778
|
+
* Retrieve the name of the current branch.
|
|
6779
6779
|
*
|
|
6780
|
-
*
|
|
6781
|
-
*
|
|
6780
|
+
* The first-choice path uses `git rev-parse --abbrev-ref HEAD`, which
|
|
6781
|
+
* returns the active branch on a normal repo. On an initial-commit
|
|
6782
|
+
* repo (fresh `git init` with no commits yet) HEAD does not resolve
|
|
6783
|
+
* and rev-parse fails fatally — but `git symbolic-ref --short HEAD`
|
|
6784
|
+
* still reports the configured initial branch name, so we fall
|
|
6785
|
+
* through to that. Final fallback is an empty string for genuinely
|
|
6786
|
+
* detached / corrupt states; every caller treats that as "no branch
|
|
6787
|
+
* context", which is the right semantics for a no-HEAD repo.
|
|
6788
|
+
*
|
|
6789
|
+
* Without this resilience, every command that depends on the branch
|
|
6790
|
+
* name (e.g. the post-summary step in `coco commit`) would crash
|
|
6791
|
+
* with `fatal: ambiguous argument 'HEAD'` after the entire diff
|
|
6792
|
+
* pipeline already ran (#844).
|
|
6782
6793
|
*/
|
|
6783
6794
|
async function getCurrentBranchName({ git }) {
|
|
6784
|
-
|
|
6795
|
+
try {
|
|
6796
|
+
return await git.revparse(['--abbrev-ref', 'HEAD']);
|
|
6797
|
+
}
|
|
6798
|
+
catch {
|
|
6799
|
+
try {
|
|
6800
|
+
const ref = await git.raw(['symbolic-ref', '--short', 'HEAD']);
|
|
6801
|
+
return ref.trim();
|
|
6802
|
+
}
|
|
6803
|
+
catch {
|
|
6804
|
+
return '';
|
|
6805
|
+
}
|
|
6806
|
+
}
|
|
6785
6807
|
}
|
|
6786
6808
|
|
|
6787
6809
|
/**
|
|
@@ -14976,6 +14998,19 @@ function getLogInkWorkflowActions() {
|
|
|
14976
14998
|
kind: 'destructive',
|
|
14977
14999
|
requiresConfirmation: true,
|
|
14978
15000
|
},
|
|
15001
|
+
{
|
|
15002
|
+
// Per-view-only — the inkInput handler scopes this to the
|
|
15003
|
+
// worktrees surface so the global `D` keystroke (delete-branch)
|
|
15004
|
+
// keeps working from elsewhere. The empty `key` keeps the
|
|
15005
|
+
// workflow palette-discoverable but does not register a global
|
|
15006
|
+
// hotkey that would collide with delete-branch.
|
|
15007
|
+
id: 'remove-worktree-and-branch',
|
|
15008
|
+
key: '',
|
|
15009
|
+
label: 'Remove worktree + delete branch',
|
|
15010
|
+
description: 'Remove the selected worktree and delete the branch it was tracking after confirmation.',
|
|
15011
|
+
kind: 'destructive',
|
|
15012
|
+
requiresConfirmation: true,
|
|
15013
|
+
},
|
|
14979
15014
|
{
|
|
14980
15015
|
id: 'abort-operation',
|
|
14981
15016
|
key: 'A',
|
|
@@ -18440,6 +18475,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18440
18475
|
if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
|
|
18441
18476
|
return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
|
|
18442
18477
|
}
|
|
18478
|
+
// Per-view worktree action: `D` removes the worktree AND deletes
|
|
18479
|
+
// the branch it was tracking (#838). Scoped to the worktrees
|
|
18480
|
+
// surface so it intercepts BEFORE the global workflow-by-key
|
|
18481
|
+
// dispatcher would otherwise route `D` to delete-branch (which
|
|
18482
|
+
// would silently target whatever was last cursored on the branches
|
|
18483
|
+
// surface instead of acting on the worktree under the cursor here).
|
|
18484
|
+
// `W` keeps its existing "remove worktree only" semantics.
|
|
18485
|
+
if (inputValue === 'D' && isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
18486
|
+
return [action({ type: 'setPendingConfirmation', value: 'remove-worktree-and-branch' })];
|
|
18487
|
+
}
|
|
18443
18488
|
// #783 — full PR action panel keys, scoped to the pull-request view.
|
|
18444
18489
|
// All five wrap a `gh pr <verb>` invocation; merge / request-changes /
|
|
18445
18490
|
// comment open prompts first, the rest route through the y-confirm
|
|
@@ -21510,6 +21555,56 @@ function worktreePathAction(worktree) {
|
|
|
21510
21555
|
message: `Worktree path: ${worktree.path}`,
|
|
21511
21556
|
};
|
|
21512
21557
|
}
|
|
21558
|
+
/**
|
|
21559
|
+
* Remove a worktree AND delete the branch it was tracking (#838). The
|
|
21560
|
+
* canonical "I'm done with this side branch" wind-down: removes the
|
|
21561
|
+
* worktree directory, then runs `git branch -d` on the previously
|
|
21562
|
+
* checked-out branch.
|
|
21563
|
+
*
|
|
21564
|
+
* Both pre-flight guards inherit from the underlying helpers:
|
|
21565
|
+
* - removeWorktree refuses the current worktree and dirty worktrees
|
|
21566
|
+
* - deleteBranch refuses the current branch and uses `-d` (safe
|
|
21567
|
+
* delete, refuses unmerged commits)
|
|
21568
|
+
*
|
|
21569
|
+
* Aborts cleanly at any failure point and surfaces a message that
|
|
21570
|
+
* names which step broke. When the worktree had no branch (detached
|
|
21571
|
+
* HEAD) the branch step is silently skipped — there's nothing to
|
|
21572
|
+
* delete and the worktree removal alone counts as success.
|
|
21573
|
+
*/
|
|
21574
|
+
async function removeWorktreeAndBranch(git, worktree, branchRefs) {
|
|
21575
|
+
const removeResult = await removeWorktree(git, worktree);
|
|
21576
|
+
if (!removeResult.ok) {
|
|
21577
|
+
return removeResult;
|
|
21578
|
+
}
|
|
21579
|
+
const branchName = worktree.branch;
|
|
21580
|
+
if (!branchName) {
|
|
21581
|
+
return {
|
|
21582
|
+
ok: true,
|
|
21583
|
+
message: `Removed worktree ${worktree.path} (no branch to delete)`,
|
|
21584
|
+
};
|
|
21585
|
+
}
|
|
21586
|
+
// Look up the local BranchRef for the branch this worktree was on.
|
|
21587
|
+
// deleteBranch needs the full ref (not just the name) so its
|
|
21588
|
+
// current-branch and local-only guards apply correctly.
|
|
21589
|
+
const branch = branchRefs.find((entry) => entry.type === 'local' && entry.shortName === branchName);
|
|
21590
|
+
if (!branch) {
|
|
21591
|
+
return {
|
|
21592
|
+
ok: true,
|
|
21593
|
+
message: `Removed worktree ${worktree.path} (branch ${branchName} not found in local branches)`,
|
|
21594
|
+
};
|
|
21595
|
+
}
|
|
21596
|
+
const deleteResult = await deleteBranch(git, branch);
|
|
21597
|
+
if (!deleteResult.ok) {
|
|
21598
|
+
return {
|
|
21599
|
+
ok: false,
|
|
21600
|
+
message: `Removed worktree ${worktree.path}, but branch delete failed: ${deleteResult.message}`,
|
|
21601
|
+
};
|
|
21602
|
+
}
|
|
21603
|
+
return {
|
|
21604
|
+
ok: true,
|
|
21605
|
+
message: `Removed worktree ${worktree.path} and deleted branch ${branchName}`,
|
|
21606
|
+
};
|
|
21607
|
+
}
|
|
21513
21608
|
|
|
21514
21609
|
function shortBranch(branch) {
|
|
21515
21610
|
return branch?.replace(/^refs\/heads\//, '');
|
|
@@ -24561,12 +24656,27 @@ function LogInkApp(deps) {
|
|
|
24561
24656
|
}, [git, selected?.hash]);
|
|
24562
24657
|
// #806 follow-up — auto-jump the history view to whichever branch /
|
|
24563
24658
|
// tag the user is currently cursoring in the sidebar (or the
|
|
24564
|
-
// dedicated branches / tags view).
|
|
24565
|
-
//
|
|
24659
|
+
// dedicated branches / tags view).
|
|
24660
|
+
//
|
|
24661
|
+
// Originally this fired on a 150ms trailing-edge debounce. The user
|
|
24662
|
+
// reported the sync feeling inconsistent (#839) — the trailing
|
|
24663
|
+
// pattern means a fast scroll through a long branch list cancels
|
|
24664
|
+
// the timer on every keystroke and only fires once on release; the
|
|
24665
|
+
// user never sees the cursor follow their navigation. Switched to
|
|
24666
|
+
// synchronous fire-on-effect so each cursor move snaps the history
|
|
24667
|
+
// graph immediately. The dispatch is cheap (O(n) findIndex on the
|
|
24668
|
+
// filtered commits + a state spread); React batches the re-renders
|
|
24669
|
+
// so even rapid scroll only paints the final position. Tracks the
|
|
24670
|
+
// last-dispatched hash via a ref so we don't fire setStatus
|
|
24671
|
+
// repeatedly when several adjacent branches all point at the same
|
|
24672
|
+
// commit (very common with squash-merged feature branches that all
|
|
24673
|
+
// converge on `main`'s tip).
|
|
24674
|
+
//
|
|
24566
24675
|
// No-op when the cursored ref's tip isn't in the loaded commit
|
|
24567
24676
|
// window (under compact mode the cursored branch's tip may not be
|
|
24568
24677
|
// fetched yet); a status hint surfaces in that case so the user
|
|
24569
24678
|
// knows to toggle full graph or load older commits.
|
|
24679
|
+
const lastSyncedHashRef = React.useRef(undefined);
|
|
24570
24680
|
React.useEffect(() => {
|
|
24571
24681
|
const onBranchTab = state.activeView === 'branches' ||
|
|
24572
24682
|
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
@@ -24574,58 +24684,58 @@ function LogInkApp(deps) {
|
|
|
24574
24684
|
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
24575
24685
|
if (!onBranchTab && !onTagTab)
|
|
24576
24686
|
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
|
-
});
|
|
24687
|
+
let targetHash;
|
|
24688
|
+
let targetLabel;
|
|
24689
|
+
if (onBranchTab) {
|
|
24690
|
+
const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
|
|
24691
|
+
const visible = state.filter
|
|
24692
|
+
? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
|
|
24693
|
+
: all;
|
|
24694
|
+
const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
|
|
24695
|
+
if (branch) {
|
|
24696
|
+
targetHash = branch.hash;
|
|
24697
|
+
targetLabel = `branch ${branch.shortName}`;
|
|
24617
24698
|
}
|
|
24618
|
-
|
|
24619
|
-
|
|
24620
|
-
|
|
24621
|
-
|
|
24622
|
-
|
|
24699
|
+
}
|
|
24700
|
+
else if (onTagTab) {
|
|
24701
|
+
const all = sortTags(context.tags?.tags || [], state.tagSort);
|
|
24702
|
+
const visible = state.filter
|
|
24703
|
+
? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
|
|
24704
|
+
: all;
|
|
24705
|
+
const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
|
|
24706
|
+
if (tag) {
|
|
24707
|
+
targetHash = tag.hash;
|
|
24708
|
+
targetLabel = `tag ${tag.name}`;
|
|
24623
24709
|
}
|
|
24624
|
-
}
|
|
24625
|
-
|
|
24626
|
-
|
|
24627
|
-
|
|
24628
|
-
|
|
24710
|
+
}
|
|
24711
|
+
if (!targetHash)
|
|
24712
|
+
return;
|
|
24713
|
+
// Skip the dispatch + status churn when the cursor hasn't
|
|
24714
|
+
// actually changed which commit it's targeting (the case for
|
|
24715
|
+
// rapid navigation through a cluster of branches that all point
|
|
24716
|
+
// at the same commit). Without this guard the user sees a stream
|
|
24717
|
+
// of "Synced history to <branch> tip" status messages even
|
|
24718
|
+
// though the history cursor never moved.
|
|
24719
|
+
if (targetHash === lastSyncedHashRef.current)
|
|
24720
|
+
return;
|
|
24721
|
+
const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
|
|
24722
|
+
if (loaded) {
|
|
24723
|
+
lastSyncedHashRef.current = targetHash;
|
|
24724
|
+
dispatch({ type: 'selectCommitByHash', hash: targetHash });
|
|
24725
|
+
// Confirmation status message so the user gets feedback even
|
|
24726
|
+
// when the dedicated branches / tags view is occupying the
|
|
24727
|
+
// main panel and the history cursor moves invisibly behind it.
|
|
24728
|
+
dispatch({
|
|
24729
|
+
type: 'setStatus',
|
|
24730
|
+
value: `Synced history to ${targetLabel} tip`,
|
|
24731
|
+
});
|
|
24732
|
+
}
|
|
24733
|
+
else {
|
|
24734
|
+
dispatch({
|
|
24735
|
+
type: 'setStatus',
|
|
24736
|
+
value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
|
|
24737
|
+
});
|
|
24738
|
+
}
|
|
24629
24739
|
}, [
|
|
24630
24740
|
dispatch, context.branches, context.tags,
|
|
24631
24741
|
state.activeView, state.focus, state.sidebarTab,
|
|
@@ -24633,6 +24743,18 @@ function LogInkApp(deps) {
|
|
|
24633
24743
|
state.branchSort, state.tagSort, state.filter,
|
|
24634
24744
|
state.filteredCommits,
|
|
24635
24745
|
]);
|
|
24746
|
+
// Reset the dedup ref when the user moves focus away from the
|
|
24747
|
+
// sidebar branches / tags tab so re-entering re-fires the sync
|
|
24748
|
+
// even if the cursored branch is the same as before.
|
|
24749
|
+
React.useEffect(() => {
|
|
24750
|
+
const onBranchTab = state.activeView === 'branches' ||
|
|
24751
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
24752
|
+
const onTagTab = state.activeView === 'tags' ||
|
|
24753
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
24754
|
+
if (!onBranchTab && !onTagTab) {
|
|
24755
|
+
lastSyncedHashRef.current = undefined;
|
|
24756
|
+
}
|
|
24757
|
+
}, [state.activeView, state.focus, state.sidebarTab]);
|
|
24636
24758
|
React.useEffect(() => {
|
|
24637
24759
|
let active = true;
|
|
24638
24760
|
async function loadWorktreeDiff() {
|
|
@@ -25072,6 +25194,29 @@ function LogInkApp(deps) {
|
|
|
25072
25194
|
}
|
|
25073
25195
|
return removeWorktree(git, cursorTarget);
|
|
25074
25196
|
},
|
|
25197
|
+
'remove-worktree-and-branch': async () => {
|
|
25198
|
+
const all = context.worktreeList?.worktrees || [];
|
|
25199
|
+
const visible = state.filter
|
|
25200
|
+
? all.filter((w) => matchesPromotedFilter([w.path, w.branch || ''], state.filter))
|
|
25201
|
+
: all;
|
|
25202
|
+
const cursorTarget = visible.length
|
|
25203
|
+
? visible[Math.min(state.selectedWorktreeListIndex, visible.length - 1)]
|
|
25204
|
+
: all[Math.min(state.selectedWorktreeListIndex, Math.max(0, all.length - 1))];
|
|
25205
|
+
if (!cursorTarget)
|
|
25206
|
+
return { ok: false, message: 'No worktree selected' };
|
|
25207
|
+
if (cursorTarget.current) {
|
|
25208
|
+
return {
|
|
25209
|
+
ok: false,
|
|
25210
|
+
message: 'Cannot remove the current worktree — switch to another worktree first.',
|
|
25211
|
+
};
|
|
25212
|
+
}
|
|
25213
|
+
// The chained helper handles the worktree removal AND the
|
|
25214
|
+
// safe branch delete in one call. Branch refs come from the
|
|
25215
|
+
// live context so the underlying deleteBranch helper sees
|
|
25216
|
+
// the current/local flags it needs to refuse the destructive
|
|
25217
|
+
// path on the wrong target.
|
|
25218
|
+
return removeWorktreeAndBranch(git, cursorTarget, context.branches?.localBranches || []);
|
|
25219
|
+
},
|
|
25075
25220
|
'abort-operation': async () => {
|
|
25076
25221
|
const operation = context.operation?.operation;
|
|
25077
25222
|
if (!operation) {
|
|
@@ -27250,7 +27395,12 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
|
|
|
27250
27395
|
}, truncate$1(theme.ascii ? '[...] AI draft in progress' : '⏳ AI draft in progress', width - 4))]
|
|
27251
27396
|
: []), ...(stagedFiles.length
|
|
27252
27397
|
? [
|
|
27253
|
-
|
|
27398
|
+
// Section header carries the total count to match the status
|
|
27399
|
+
// surface's "▾ Staged (n)" treatment (#840). The visible
|
|
27400
|
+
// file list is sliced at 12 rows; using `worktree.stagedCount`
|
|
27401
|
+
// (the total) avoids a misleading "Staged (12)" label when
|
|
27402
|
+
// there are actually more staged files below the slice.
|
|
27403
|
+
h(Text, { key: 'compose-context-staged-title', bold: true }, `Staged (${worktree?.stagedCount ?? stagedFiles.length})`),
|
|
27254
27404
|
...stagedFiles.map((file, index) => h(Text, {
|
|
27255
27405
|
key: `compose-context-staged-${index}`,
|
|
27256
27406
|
color: theme.noColor ? undefined : theme.colors.gitAdded,
|
|
@@ -27259,7 +27409,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
|
|
|
27259
27409
|
]
|
|
27260
27410
|
: []), ...(unstagedFiles.length
|
|
27261
27411
|
? [
|
|
27262
|
-
h(Text, { key: 'compose-context-unstaged-title', bold: true },
|
|
27412
|
+
h(Text, { key: 'compose-context-unstaged-title', bold: true }, `Unstaged (${worktree?.unstagedCount ?? unstagedFiles.length})`),
|
|
27263
27413
|
...unstagedFiles.map((file, index) => h(Text, {
|
|
27264
27414
|
key: `compose-context-unstaged-${index}`,
|
|
27265
27415
|
color: theme.noColor ? undefined : theme.colors.gitModified,
|