@solaqua/gji 0.7.0 → 0.7.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/back.js +1 -1
- package/dist/clean.d.ts +2 -1
- package/dist/clean.js +8 -16
- package/dist/cli.js +7 -6
- package/dist/gji-bundle.mjs +568 -273
- package/dist/go.d.ts +2 -4
- package/dist/go.js +19 -48
- package/dist/history.d.ts +1 -0
- package/dist/history.js +12 -5
- package/dist/hooks.d.ts +3 -3
- package/dist/hooks.js +3 -3
- package/dist/init.d.ts +3 -3
- package/dist/init.js +12 -12
- package/dist/install-prompt.js +22 -21
- package/dist/new.js +72 -10
- package/dist/open.d.ts +2 -2
- package/dist/open.js +22 -18
- package/dist/pr.js +4 -4
- package/dist/remove.d.ts +3 -2
- package/dist/remove.js +8 -13
- package/dist/repo.d.ts +0 -1
- package/dist/repo.js +0 -9
- package/dist/run-hook.d.ts +6 -0
- package/dist/{trigger-hook.js → run-hook.js} +13 -7
- package/dist/shell-completion.js +5 -5
- package/dist/warp.js +24 -53
- package/dist/worktree-info.d.ts +0 -1
- package/dist/worktree-info.js +17 -11
- package/dist/worktree-picker.d.ts +14 -0
- package/dist/worktree-picker.js +228 -0
- package/man/man1/gji-back.1 +1 -1
- package/man/man1/gji-clean.1 +1 -1
- package/man/man1/gji-completion.1 +1 -1
- package/man/man1/gji-config.1 +1 -1
- package/man/man1/gji-go.1 +1 -1
- package/man/man1/gji-history.1 +1 -1
- package/man/man1/gji-init.1 +1 -1
- package/man/man1/gji-ls.1 +1 -1
- package/man/man1/gji-new.1 +1 -1
- package/man/man1/gji-open.1 +1 -1
- package/man/man1/gji-pr.1 +1 -1
- package/man/man1/gji-remove.1 +1 -1
- package/man/man1/gji-root.1 +1 -1
- package/man/man1/gji-run-hook.1 +9 -0
- package/man/man1/gji-status.1 +1 -1
- package/man/man1/gji-sync-files.1 +1 -1
- package/man/man1/gji-sync.1 +1 -1
- package/man/man1/gji-warp.1 +1 -1
- package/man/man1/gji.1 +6 -4
- package/package.json +3 -7
- package/dist/trigger-hook.d.ts +0 -6
- package/man/man1/gji-trigger-hook.1 +0 -9
package/dist/remove.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
|
-
import { confirm, isCancel
|
|
2
|
+
import { confirm, isCancel } from "@clack/prompts";
|
|
3
3
|
import { loadEffectiveConfig } from "./config.js";
|
|
4
4
|
import { isHeadless } from "./headless.js";
|
|
5
5
|
import { extractHooks, runHook } from "./hooks.js";
|
|
6
|
-
import { sortByCurrentFirst } from "./repo.js";
|
|
7
6
|
import { writeShellOutput } from "./shell-handoff.js";
|
|
8
7
|
import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isWorktreeDirtyError, loadLinkedWorktrees, removeWorktree, } from "./worktree-management.js";
|
|
8
|
+
import { buildWorktreePromptEntries, promptForSingleWorktree, } from "./worktree-picker.js";
|
|
9
9
|
import { defaultConfirmForceDeleteBranch, defaultConfirmForceRemoveWorktree, } from "./worktree-prompts.js";
|
|
10
10
|
const REMOVE_OUTPUT_FILE_ENV = "GJI_REMOVE_OUTPUT_FILE";
|
|
11
11
|
export function createRemoveCommand(dependencies = {}) {
|
|
@@ -31,7 +31,10 @@ export function createRemoveCommand(dependencies = {}) {
|
|
|
31
31
|
return 1;
|
|
32
32
|
}
|
|
33
33
|
const selection = options.branch ??
|
|
34
|
-
(await promptForWorktree(
|
|
34
|
+
(await promptForWorktree(await buildWorktreePromptEntries(linkedWorktrees.map((worktree) => ({
|
|
35
|
+
repoName: repository.repoName,
|
|
36
|
+
worktree,
|
|
37
|
+
})))));
|
|
35
38
|
if (!selection) {
|
|
36
39
|
options.stderr("Aborted\n");
|
|
37
40
|
return 1;
|
|
@@ -71,7 +74,7 @@ export function createRemoveCommand(dependencies = {}) {
|
|
|
71
74
|
}
|
|
72
75
|
const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
|
|
73
76
|
const hooks = extractHooks(config);
|
|
74
|
-
await runHook(hooks
|
|
77
|
+
await runHook(hooks["before-remove"], worktree.path, {
|
|
75
78
|
branch: worktree.branch ?? undefined,
|
|
76
79
|
path: worktree.path,
|
|
77
80
|
repo: basename(repository.repoRoot),
|
|
@@ -129,15 +132,7 @@ export function createRemoveCommand(dependencies = {}) {
|
|
|
129
132
|
}
|
|
130
133
|
export const runRemoveCommand = createRemoveCommand();
|
|
131
134
|
async function defaultPromptForWorktree(worktrees) {
|
|
132
|
-
|
|
133
|
-
message: "Choose a worktree to finish",
|
|
134
|
-
options: worktrees.map((worktree) => ({
|
|
135
|
-
hint: worktree.isCurrent ? `${worktree.path} (current)` : worktree.path,
|
|
136
|
-
label: worktree.branch ?? "(detached)",
|
|
137
|
-
value: worktree.path,
|
|
138
|
-
})),
|
|
139
|
-
});
|
|
140
|
-
return isCancel(choice) ? null : choice;
|
|
135
|
+
return promptForSingleWorktree("Choose a worktree to finish", worktrees);
|
|
141
136
|
}
|
|
142
137
|
async function defaultConfirmRemoval(worktree) {
|
|
143
138
|
const choice = await confirm({
|
package/dist/repo.d.ts
CHANGED
|
@@ -14,4 +14,3 @@ export declare function detectRepository(cwd: string): Promise<RepositoryContext
|
|
|
14
14
|
export declare function resolveWorktreePath(repoRoot: string, branch: string, basePath?: string): string;
|
|
15
15
|
export declare function validateBranchName(name: string): string | null;
|
|
16
16
|
export declare function listWorktrees(cwd: string): Promise<WorktreeEntry[]>;
|
|
17
|
-
export declare function sortByCurrentFirst(worktrees: WorktreeEntry[]): WorktreeEntry[];
|
package/dist/repo.js
CHANGED
|
@@ -88,15 +88,6 @@ export async function listWorktrees(cwd) {
|
|
|
88
88
|
};
|
|
89
89
|
});
|
|
90
90
|
}
|
|
91
|
-
export function sortByCurrentFirst(worktrees) {
|
|
92
|
-
return [...worktrees].sort((a, b) => {
|
|
93
|
-
if (a.isCurrent && !b.isCurrent)
|
|
94
|
-
return -1;
|
|
95
|
-
if (!a.isCurrent && b.isCurrent)
|
|
96
|
-
return 1;
|
|
97
|
-
return 0;
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
91
|
function findPorcelainValue(block, key) {
|
|
101
92
|
const value = findOptionalPorcelainValue(block, key);
|
|
102
93
|
if (!value) {
|
|
@@ -2,19 +2,25 @@ import { loadEffectiveConfig } from "./config.js";
|
|
|
2
2
|
import { extractHooks, runHook } from "./hooks.js";
|
|
3
3
|
import { detectRepository, listWorktrees } from "./repo.js";
|
|
4
4
|
const VALID_HOOKS = [
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
5
|
+
"after-create",
|
|
6
|
+
"after-enter",
|
|
7
|
+
"before-remove",
|
|
8
8
|
];
|
|
9
|
+
const CAMEL_ALIASES = {
|
|
10
|
+
afterCreate: "after-create",
|
|
11
|
+
afterEnter: "after-enter",
|
|
12
|
+
beforeRemove: "before-remove",
|
|
13
|
+
};
|
|
9
14
|
function isValidHook(hook) {
|
|
10
15
|
return VALID_HOOKS.includes(hook);
|
|
11
16
|
}
|
|
12
|
-
export async function
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
export async function runHookCommand(options) {
|
|
18
|
+
const normalized = CAMEL_ALIASES[options.hook] ?? options.hook;
|
|
19
|
+
if (!isValidHook(normalized)) {
|
|
20
|
+
options.stderr(`gji run-hook: unknown hook '${options.hook}'. Valid hooks: ${VALID_HOOKS.join(", ")}\n`);
|
|
15
21
|
return 1;
|
|
16
22
|
}
|
|
17
|
-
const hookName =
|
|
23
|
+
const hookName = normalized;
|
|
18
24
|
const repository = await detectRepository(options.cwd);
|
|
19
25
|
const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
|
|
20
26
|
const hooks = extractHooks(config);
|
package/dist/shell-completion.js
CHANGED
|
@@ -27,14 +27,14 @@ const TOP_LEVEL_COMMANDS = [
|
|
|
27
27
|
},
|
|
28
28
|
{ name: "rm", description: "alias of remove" },
|
|
29
29
|
{
|
|
30
|
-
name: "
|
|
30
|
+
name: "run-hook",
|
|
31
31
|
description: "run a named hook in the current worktree",
|
|
32
32
|
},
|
|
33
33
|
{ name: "warp", description: "jump to any worktree across all known repos" },
|
|
34
34
|
{ name: "config", description: "manage global config defaults" },
|
|
35
35
|
];
|
|
36
36
|
const SHELL_NAMES = ["bash", "fish", "zsh"];
|
|
37
|
-
const HOOK_NAMES = ["
|
|
37
|
+
const HOOK_NAMES = ["after-create", "after-enter", "before-remove"];
|
|
38
38
|
const CONFIG_KEYS = Array.from(KNOWN_GLOBAL_CONFIG_KEYS);
|
|
39
39
|
export function renderShellCompletion(shell) {
|
|
40
40
|
switch (shell) {
|
|
@@ -116,7 +116,7 @@ _gji_completion() {
|
|
|
116
116
|
remove|rm)
|
|
117
117
|
COMPREPLY=( $(compgen -W "$(__gji_worktree_branches) -f --force --dry-run --json --help" -- "$cur") )
|
|
118
118
|
;;
|
|
119
|
-
|
|
119
|
+
run-hook)
|
|
120
120
|
COMPREPLY=( $(compgen -W "${hooks} --help" -- "$cur") )
|
|
121
121
|
;;
|
|
122
122
|
warp)
|
|
@@ -149,7 +149,7 @@ complete -F _gji_completion gji`;
|
|
|
149
149
|
function renderFishCompletion() {
|
|
150
150
|
const commandLines = TOP_LEVEL_COMMANDS.map((command) => `complete -c gji -n '__fish_use_subcommand' -a '${command.name}' -d '${escapeSingleQuotes(command.description)}'`).join("\n");
|
|
151
151
|
const shellLines = SHELL_NAMES.map((shell) => `complete -c gji -n '__fish_seen_subcommand_from init' -a '${shell}' -d 'shell'`).join("\n");
|
|
152
|
-
const hookLines = HOOK_NAMES.map((hook) => `complete -c gji -n '__fish_seen_subcommand_from
|
|
152
|
+
const hookLines = HOOK_NAMES.map((hook) => `complete -c gji -n '__fish_seen_subcommand_from run-hook' -a '${hook}' -d 'hook'`).join("\n");
|
|
153
153
|
const configKeyLines = CONFIG_KEYS.map((key) => `complete -c gji -n '__gji_should_complete_config_key' -a '${key}' -d 'config key'`).join("\n");
|
|
154
154
|
return `function __gji_worktree_branches
|
|
155
155
|
command gji ls --compact 2>/dev/null | awk 'NR > 1 { branch = ($1 == "*" ? $2 : $1); if (branch != "(detached)") print branch }'
|
|
@@ -309,7 +309,7 @@ case "\${words[2]}" in
|
|
|
309
309
|
remove|rm)
|
|
310
310
|
_arguments '(-f --force)'{-f,--force}'[bypass prompts, force-remove a dirty worktree, and force-delete an unmerged branch]' '--dry-run[show what would be deleted without removing anything]' '--json[emit JSON on success or error instead of human-readable output]' '2:branch:->worktrees'
|
|
311
311
|
;;
|
|
312
|
-
|
|
312
|
+
run-hook)
|
|
313
313
|
_arguments "2:hook:(${hooks})"
|
|
314
314
|
;;
|
|
315
315
|
warp)
|
package/dist/warp.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { realpath } from "node:fs/promises";
|
|
2
2
|
import { basename, resolve } from "node:path";
|
|
3
3
|
import { isCancel, select } from "@clack/prompts";
|
|
4
|
-
import { readWorktreeHealth } from "./git.js";
|
|
5
4
|
import { isHeadless } from "./headless.js";
|
|
6
|
-
import {
|
|
5
|
+
import { recordWorktreeUsage } from "./history.js";
|
|
7
6
|
import { runNewCommand } from "./new.js";
|
|
8
|
-
import { listWorktrees } from "./repo.js";
|
|
7
|
+
import { detectRepository, listWorktrees } from "./repo.js";
|
|
9
8
|
import { loadRegistry } from "./repo-registry.js";
|
|
10
9
|
import { writeShellOutput } from "./shell-handoff.js";
|
|
10
|
+
import { buildWorktreePromptEntries, promptForSingleWorktree, resolveWorktreeQuery, } from "./worktree-picker.js";
|
|
11
11
|
const WARP_OUTPUT_FILE_ENV = "GJI_WARP_OUTPUT_FILE";
|
|
12
12
|
export async function runWarpCommand(options) {
|
|
13
13
|
if (options.newWorktree) {
|
|
@@ -44,7 +44,7 @@ async function runWarpNavigate(options) {
|
|
|
44
44
|
options.stdout(`${JSON.stringify({ branch: target.branch, path: target.path }, null, 2)}\n`);
|
|
45
45
|
return 0;
|
|
46
46
|
}
|
|
47
|
-
|
|
47
|
+
await recordWorktreeUsage(target.path, target.branch);
|
|
48
48
|
await writeShellOutput(WARP_OUTPUT_FILE_ENV, target.path, options.stdout);
|
|
49
49
|
return 0;
|
|
50
50
|
}
|
|
@@ -130,17 +130,6 @@ async function canonicalizeRepoPath(repoPath) {
|
|
|
130
130
|
return resolve(repoPath);
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
|
-
function findByQuery(items, query) {
|
|
134
|
-
const slashIdx = query.indexOf("/");
|
|
135
|
-
if (slashIdx !== -1) {
|
|
136
|
-
const repoQuery = query.slice(0, slashIdx);
|
|
137
|
-
const branchQuery = query.slice(slashIdx + 1);
|
|
138
|
-
const match = items.find((item) => item.repoName === repoQuery && item.worktree.branch === branchQuery);
|
|
139
|
-
if (match)
|
|
140
|
-
return match;
|
|
141
|
-
}
|
|
142
|
-
return items.find((item) => item.worktree.branch === query) ?? null;
|
|
143
|
-
}
|
|
144
133
|
export async function resolveWarpTarget(options) {
|
|
145
134
|
const cmd = options.commandName ?? "gji";
|
|
146
135
|
const emitError = (message, hint) => {
|
|
@@ -154,6 +143,9 @@ export async function resolveWarpTarget(options) {
|
|
|
154
143
|
}
|
|
155
144
|
};
|
|
156
145
|
const registry = await loadRegistry();
|
|
146
|
+
const currentRoot = await detectRepository(options.cwd)
|
|
147
|
+
.then((repository) => repository.currentRoot)
|
|
148
|
+
.catch(() => null);
|
|
157
149
|
if (registry.length === 0) {
|
|
158
150
|
emitError("not in a git repository and no repos registered yet.", "Use any gji command inside a repository to register it.\n");
|
|
159
151
|
return null;
|
|
@@ -168,61 +160,40 @@ export async function resolveWarpTarget(options) {
|
|
|
168
160
|
continue;
|
|
169
161
|
const { repoName, worktrees } = result.value;
|
|
170
162
|
for (const worktree of worktrees) {
|
|
171
|
-
allItems.push({
|
|
163
|
+
allItems.push({
|
|
164
|
+
repoName,
|
|
165
|
+
worktree: {
|
|
166
|
+
...worktree,
|
|
167
|
+
isCurrent: currentRoot !== null && worktree.path === currentRoot,
|
|
168
|
+
},
|
|
169
|
+
});
|
|
172
170
|
}
|
|
173
171
|
}
|
|
174
172
|
if (allItems.length === 0) {
|
|
175
173
|
emitError("no accessible worktrees found in any registered repo.");
|
|
176
174
|
return null;
|
|
177
175
|
}
|
|
176
|
+
const promptSources = allItems.map((item) => ({
|
|
177
|
+
repoName: item.repoName,
|
|
178
|
+
worktree: item.worktree,
|
|
179
|
+
}));
|
|
178
180
|
if (options.branch) {
|
|
179
|
-
const match =
|
|
181
|
+
const match = resolveWorktreeQuery(promptSources, options.branch);
|
|
180
182
|
if (!match) {
|
|
181
183
|
emitError(`no worktree found matching: ${options.branch}`);
|
|
182
184
|
return null;
|
|
183
185
|
}
|
|
184
186
|
return { branch: match.worktree.branch, path: match.worktree.path };
|
|
185
187
|
}
|
|
186
|
-
const
|
|
188
|
+
const promptEntries = await buildWorktreePromptEntries(promptSources);
|
|
189
|
+
const path = await promptForWarpTarget(promptEntries);
|
|
187
190
|
if (!path) {
|
|
188
191
|
options.stderr("Aborted\n");
|
|
189
192
|
return null;
|
|
190
193
|
}
|
|
191
|
-
const chosen =
|
|
192
|
-
return { branch: chosen?.
|
|
194
|
+
const chosen = promptEntries.find((item) => item.path === path);
|
|
195
|
+
return { branch: chosen?.branch ?? null, path };
|
|
193
196
|
}
|
|
194
197
|
async function promptForWarpTarget(items) {
|
|
195
|
-
|
|
196
|
-
const choice = await select({
|
|
197
|
-
message: "Warp to a worktree",
|
|
198
|
-
options: items.map((item, i) => {
|
|
199
|
-
const health = healthResults[i].status === "fulfilled" ? healthResults[i].value : null;
|
|
200
|
-
const upstream = health ? formatHint(item.worktree.branch, health) : null;
|
|
201
|
-
const label = `${item.repoName} › ${item.worktree.branch ?? "(detached)"}`;
|
|
202
|
-
const pathHint = item.worktree.isCurrent
|
|
203
|
-
? `${item.worktree.path} (current)`
|
|
204
|
-
: item.worktree.path;
|
|
205
|
-
const hint = upstream ? `${upstream} · ${pathHint}` : pathHint;
|
|
206
|
-
return { hint, label, value: item.worktree.path };
|
|
207
|
-
}),
|
|
208
|
-
});
|
|
209
|
-
if (isCancel(choice)) {
|
|
210
|
-
return null;
|
|
211
|
-
}
|
|
212
|
-
return choice;
|
|
213
|
-
}
|
|
214
|
-
function formatHint(branch, health) {
|
|
215
|
-
if (branch === null)
|
|
216
|
-
return null;
|
|
217
|
-
if (!health.hasUpstream)
|
|
218
|
-
return "no upstream";
|
|
219
|
-
if (health.upstreamGone)
|
|
220
|
-
return "upstream gone";
|
|
221
|
-
if (health.ahead === 0 && health.behind === 0)
|
|
222
|
-
return "up to date";
|
|
223
|
-
if (health.ahead === 0)
|
|
224
|
-
return `behind ${health.behind}`;
|
|
225
|
-
if (health.behind === 0)
|
|
226
|
-
return `ahead ${health.ahead}`;
|
|
227
|
-
return `ahead ${health.ahead}, behind ${health.behind}`;
|
|
198
|
+
return promptForSingleWorktree("Warp to a worktree", items);
|
|
228
199
|
}
|
package/dist/worktree-info.d.ts
CHANGED
|
@@ -27,7 +27,6 @@ export type UpstreamState = {
|
|
|
27
27
|
};
|
|
28
28
|
export declare function readWorktreeInfos(worktrees: WorktreeEntry[]): Promise<WorktreeInfo[]>;
|
|
29
29
|
export declare function serializeWorktreeInfo(info: WorktreeInfo): SerializedWorktreeInfo;
|
|
30
|
-
export declare function formatWorktreeHint(info: WorktreeInfo): string;
|
|
31
30
|
export declare function formatUpstreamState(upstream: UpstreamState): string;
|
|
32
31
|
export declare function formatLastCommit(timestampSeconds: number | null): string;
|
|
33
32
|
export declare function formatRelativeAge(timestampSeconds: number): string;
|
package/dist/worktree-info.js
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
import { readBranchLastCommitTimestamp, readWorktreeHealth, } from "./git.js";
|
|
2
|
+
const MAX_WORKTREE_INFO_READ_CONCURRENCY = 8;
|
|
2
3
|
export async function readWorktreeInfos(worktrees) {
|
|
3
|
-
return
|
|
4
|
+
return mapWithConcurrency(worktrees, MAX_WORKTREE_INFO_READ_CONCURRENCY, readWorktreeInfo);
|
|
5
|
+
}
|
|
6
|
+
async function mapWithConcurrency(items, limit, mapper) {
|
|
7
|
+
const results = new Array(items.length);
|
|
8
|
+
let nextIndex = 0;
|
|
9
|
+
async function readNext() {
|
|
10
|
+
for (;;) {
|
|
11
|
+
const index = nextIndex;
|
|
12
|
+
nextIndex += 1;
|
|
13
|
+
if (index >= items.length)
|
|
14
|
+
return;
|
|
15
|
+
results[index] = await mapper(items[index]);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => readNext()));
|
|
19
|
+
return results;
|
|
4
20
|
}
|
|
5
21
|
async function readWorktreeInfo(worktree) {
|
|
6
22
|
const [healthResult, lastCommitResult] = await Promise.allSettled([
|
|
@@ -46,16 +62,6 @@ export function serializeWorktreeInfo(info) {
|
|
|
46
62
|
upstream: info.upstream,
|
|
47
63
|
};
|
|
48
64
|
}
|
|
49
|
-
export function formatWorktreeHint(info) {
|
|
50
|
-
const details = [
|
|
51
|
-
`status: ${info.status}`,
|
|
52
|
-
`upstream: ${formatUpstreamState(info.upstream)}`,
|
|
53
|
-
];
|
|
54
|
-
if (info.lastCommitTimestamp !== null) {
|
|
55
|
-
details.push(`last: ${formatRelativeAge(info.lastCommitTimestamp)}`);
|
|
56
|
-
}
|
|
57
|
-
return `${info.path} (${details.join(", ")})`;
|
|
58
|
-
}
|
|
59
65
|
export function formatUpstreamState(upstream) {
|
|
60
66
|
if (upstream.kind === "detached") {
|
|
61
67
|
return "n/a";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { WorktreeEntry } from "./repo.js";
|
|
2
|
+
export interface WorktreePromptSource {
|
|
3
|
+
repoName: string;
|
|
4
|
+
worktree: WorktreeEntry;
|
|
5
|
+
}
|
|
6
|
+
export interface WorktreePromptEntry extends WorktreeEntry {
|
|
7
|
+
group: "recent" | "other";
|
|
8
|
+
label: string;
|
|
9
|
+
repoName: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function buildWorktreePromptEntries(sources: WorktreePromptSource[]): Promise<WorktreePromptEntry[]>;
|
|
12
|
+
export declare function resolveWorktreeQuery(sources: WorktreePromptSource[], query: string): WorktreePromptSource | null;
|
|
13
|
+
export declare function promptForSingleWorktree(message: string, worktrees: WorktreePromptEntry[]): Promise<string | null>;
|
|
14
|
+
export declare function promptForMultipleWorktrees(message: string, worktrees: WorktreePromptEntry[]): Promise<string[] | null>;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { groupMultiselect, isCancel, select } from "@clack/prompts";
|
|
2
|
+
import { loadHistory } from "./history.js";
|
|
3
|
+
import { readWorktreeInfos, } from "./worktree-info.js";
|
|
4
|
+
export async function buildWorktreePromptEntries(sources) {
|
|
5
|
+
const [history, infos] = await Promise.all([
|
|
6
|
+
loadHistory(),
|
|
7
|
+
readWorktreeInfos(sources.map((source) => source.worktree)),
|
|
8
|
+
]);
|
|
9
|
+
const historyByPath = new Map(history.map((entry) => [entry.path, entry]));
|
|
10
|
+
const entries = sources.map((source, index) => buildWorktreePromptEntry(source, infos[index], historyByPath.get(source.worktree.path)?.timestamp ?? null, Date.now()));
|
|
11
|
+
return entries
|
|
12
|
+
.sort(comparePromptEntries)
|
|
13
|
+
.map(({ lastActivityTimestamp: _lastActivityTimestamp, ...entry }) => entry);
|
|
14
|
+
}
|
|
15
|
+
export function resolveWorktreeQuery(sources, query) {
|
|
16
|
+
const normalizedQuery = normalizeQuery(query);
|
|
17
|
+
if (normalizedQuery === null)
|
|
18
|
+
return null;
|
|
19
|
+
const matches = findWorktreePromptSourceMatches(sources, normalizedQuery);
|
|
20
|
+
if (isAmbiguousRepoOnlyQuery(matches, normalizedQuery))
|
|
21
|
+
return null;
|
|
22
|
+
return matches[0]?.source ?? null;
|
|
23
|
+
}
|
|
24
|
+
function findWorktreePromptSourceMatches(sources, normalizedQuery) {
|
|
25
|
+
return sources
|
|
26
|
+
.flatMap((source) => {
|
|
27
|
+
const matchScore = scoreWorktreeMatch({
|
|
28
|
+
...source.worktree,
|
|
29
|
+
repoName: source.repoName,
|
|
30
|
+
}, normalizedQuery);
|
|
31
|
+
return matchScore === null ? [] : [{ matchScore, source }];
|
|
32
|
+
})
|
|
33
|
+
.sort(compareQueryMatches);
|
|
34
|
+
}
|
|
35
|
+
function isAmbiguousRepoOnlyQuery(matches, query) {
|
|
36
|
+
if (matches[0]?.matchScore === 1000)
|
|
37
|
+
return false;
|
|
38
|
+
return (matches.filter((match) => match.source.repoName.toLowerCase() === query)
|
|
39
|
+
.length > 1);
|
|
40
|
+
}
|
|
41
|
+
export async function promptForSingleWorktree(message, worktrees) {
|
|
42
|
+
const choice = await select({
|
|
43
|
+
message,
|
|
44
|
+
options: worktrees.map((worktree) => ({
|
|
45
|
+
label: worktree.label,
|
|
46
|
+
value: worktree.path,
|
|
47
|
+
})),
|
|
48
|
+
maxItems: 12,
|
|
49
|
+
});
|
|
50
|
+
return isCancel(choice) ? null : choice;
|
|
51
|
+
}
|
|
52
|
+
export async function promptForMultipleWorktrees(message, worktrees) {
|
|
53
|
+
const choice = await groupMultiselect({
|
|
54
|
+
message,
|
|
55
|
+
options: groupPromptEntries(worktrees),
|
|
56
|
+
required: true,
|
|
57
|
+
selectableGroups: false,
|
|
58
|
+
});
|
|
59
|
+
return isCancel(choice) ? null : choice;
|
|
60
|
+
}
|
|
61
|
+
function compareQueryMatches(a, b) {
|
|
62
|
+
if (a.matchScore !== b.matchScore) {
|
|
63
|
+
return b.matchScore - a.matchScore;
|
|
64
|
+
}
|
|
65
|
+
if (a.source.worktree.isCurrent && !b.source.worktree.isCurrent)
|
|
66
|
+
return -1;
|
|
67
|
+
if (!a.source.worktree.isCurrent && b.source.worktree.isCurrent)
|
|
68
|
+
return 1;
|
|
69
|
+
return (a.source.repoName.localeCompare(b.source.repoName) ||
|
|
70
|
+
(a.source.worktree.branch ?? "").localeCompare(b.source.worktree.branch ?? "") ||
|
|
71
|
+
a.source.worktree.path.localeCompare(b.source.worktree.path));
|
|
72
|
+
}
|
|
73
|
+
function groupPromptEntries(worktrees) {
|
|
74
|
+
const groups = {};
|
|
75
|
+
for (const worktree of worktrees) {
|
|
76
|
+
const group = worktree.group === "recent" ? "Recent worktrees" : "Other worktrees";
|
|
77
|
+
groups[group] ??= [];
|
|
78
|
+
groups[group].push({
|
|
79
|
+
label: worktree.label,
|
|
80
|
+
value: worktree.path,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return groups;
|
|
84
|
+
}
|
|
85
|
+
function buildWorktreePromptEntry(source, info, lastUsedTimestamp, now) {
|
|
86
|
+
const lastWorkedTimestamp = info.lastCommitTimestamp === null ? null : info.lastCommitTimestamp * 1000;
|
|
87
|
+
const lastActivityTimestamp = lastUsedTimestamp ?? lastWorkedTimestamp;
|
|
88
|
+
const lastActivityType = lastUsedTimestamp !== null
|
|
89
|
+
? "used"
|
|
90
|
+
: lastWorkedTimestamp !== null
|
|
91
|
+
? "worked"
|
|
92
|
+
: null;
|
|
93
|
+
const branch = source.worktree.branch ?? "(detached)";
|
|
94
|
+
const badges = buildStatusBadges(info);
|
|
95
|
+
const recency = formatPromptRecency(lastActivityTimestamp, lastActivityType, now);
|
|
96
|
+
const status = badges.length > 0 ? badges.map((badge) => `[${badge}]`).join(" ") : null;
|
|
97
|
+
const path = middleEllipsize(source.worktree.path, 76);
|
|
98
|
+
const label = [
|
|
99
|
+
middleEllipsize(source.repoName, 22),
|
|
100
|
+
middleEllipsize(branch, 34),
|
|
101
|
+
status,
|
|
102
|
+
recency,
|
|
103
|
+
path,
|
|
104
|
+
]
|
|
105
|
+
.filter((part) => part !== null && part.length > 0)
|
|
106
|
+
.join(" · ");
|
|
107
|
+
return {
|
|
108
|
+
...source.worktree,
|
|
109
|
+
group: lastUsedTimestamp !== null ? "recent" : "other",
|
|
110
|
+
label,
|
|
111
|
+
lastActivityTimestamp,
|
|
112
|
+
repoName: source.repoName,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function buildStatusBadges(info) {
|
|
116
|
+
const badges = [];
|
|
117
|
+
if (info.isCurrent) {
|
|
118
|
+
badges.push("current");
|
|
119
|
+
}
|
|
120
|
+
if (info.branch === null) {
|
|
121
|
+
badges.push("detached");
|
|
122
|
+
}
|
|
123
|
+
if (info.status === "dirty") {
|
|
124
|
+
badges.push("dirty");
|
|
125
|
+
}
|
|
126
|
+
if (info.upstream.kind === "stale") {
|
|
127
|
+
badges.push("stale", "gone");
|
|
128
|
+
}
|
|
129
|
+
if (isUpToDate(info.upstream)) {
|
|
130
|
+
badges.push("up to date");
|
|
131
|
+
}
|
|
132
|
+
return badges;
|
|
133
|
+
}
|
|
134
|
+
function isUpToDate(upstream) {
|
|
135
|
+
return (upstream.kind === "tracked" && upstream.ahead === 0 && upstream.behind === 0);
|
|
136
|
+
}
|
|
137
|
+
function formatPromptRecency(timestamp, type, now) {
|
|
138
|
+
if (timestamp === null || type === null) {
|
|
139
|
+
return "last used: never";
|
|
140
|
+
}
|
|
141
|
+
const label = type === "used" ? "last used" : "last worked";
|
|
142
|
+
return `${label}: ${formatPickerAge(timestamp, now)}`;
|
|
143
|
+
}
|
|
144
|
+
function formatPickerAge(timestamp, now) {
|
|
145
|
+
const ageSeconds = Math.max(0, Math.floor((now - timestamp) / 1000));
|
|
146
|
+
if (ageSeconds < 60) {
|
|
147
|
+
return "now";
|
|
148
|
+
}
|
|
149
|
+
if (ageSeconds < 60 * 60) {
|
|
150
|
+
return `${Math.floor(ageSeconds / 60)}m ago`;
|
|
151
|
+
}
|
|
152
|
+
if (ageSeconds < 24 * 60 * 60) {
|
|
153
|
+
return `${Math.floor(ageSeconds / (60 * 60))}h ago`;
|
|
154
|
+
}
|
|
155
|
+
if (isYesterday(timestamp, now)) {
|
|
156
|
+
return "yesterday";
|
|
157
|
+
}
|
|
158
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
159
|
+
day: "numeric",
|
|
160
|
+
month: "short",
|
|
161
|
+
}).format(new Date(timestamp));
|
|
162
|
+
}
|
|
163
|
+
function isYesterday(timestamp, now) {
|
|
164
|
+
const date = new Date(timestamp);
|
|
165
|
+
const yesterday = new Date(now);
|
|
166
|
+
yesterday.setDate(yesterday.getDate() - 1);
|
|
167
|
+
return (date.getFullYear() === yesterday.getFullYear() &&
|
|
168
|
+
date.getMonth() === yesterday.getMonth() &&
|
|
169
|
+
date.getDate() === yesterday.getDate());
|
|
170
|
+
}
|
|
171
|
+
function buildSearchText(repoName, worktree) {
|
|
172
|
+
return [
|
|
173
|
+
repoName,
|
|
174
|
+
worktree.branch ?? "detached",
|
|
175
|
+
worktree.path,
|
|
176
|
+
`${repoName}/${worktree.branch ?? "detached"}`,
|
|
177
|
+
]
|
|
178
|
+
.join(" ")
|
|
179
|
+
.toLowerCase();
|
|
180
|
+
}
|
|
181
|
+
function normalizeQuery(query) {
|
|
182
|
+
const normalized = query?.trim().toLowerCase();
|
|
183
|
+
return normalized && normalized.length > 0 ? normalized : null;
|
|
184
|
+
}
|
|
185
|
+
function comparePromptEntries(a, b) {
|
|
186
|
+
if (a.isCurrent && !b.isCurrent)
|
|
187
|
+
return -1;
|
|
188
|
+
if (!a.isCurrent && b.isCurrent)
|
|
189
|
+
return 1;
|
|
190
|
+
if (a.group !== b.group) {
|
|
191
|
+
return groupRank(a.group) - groupRank(b.group);
|
|
192
|
+
}
|
|
193
|
+
const aRecent = a.lastActivityTimestamp ?? 0;
|
|
194
|
+
const bRecent = b.lastActivityTimestamp ?? 0;
|
|
195
|
+
if (aRecent !== bRecent) {
|
|
196
|
+
return bRecent - aRecent;
|
|
197
|
+
}
|
|
198
|
+
return (a.repoName.localeCompare(b.repoName) ||
|
|
199
|
+
(a.branch ?? "").localeCompare(b.branch ?? "") ||
|
|
200
|
+
a.path.localeCompare(b.path));
|
|
201
|
+
}
|
|
202
|
+
function groupRank(group) {
|
|
203
|
+
return group === "recent" ? 0 : 1;
|
|
204
|
+
}
|
|
205
|
+
function scoreWorktreeMatch(entry, query) {
|
|
206
|
+
const branch = entry.branch ?? "detached";
|
|
207
|
+
const exactCandidates = [
|
|
208
|
+
branch,
|
|
209
|
+
entry.path,
|
|
210
|
+
`${entry.repoName}/${branch}`,
|
|
211
|
+
].map((candidate) => candidate.toLowerCase());
|
|
212
|
+
if (exactCandidates.includes(query)) {
|
|
213
|
+
return 1000;
|
|
214
|
+
}
|
|
215
|
+
return buildSearchText(entry.repoName, entry).includes(query) ? 1 : null;
|
|
216
|
+
}
|
|
217
|
+
function middleEllipsize(value, maxLength) {
|
|
218
|
+
if (value.length <= maxLength) {
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
if (maxLength <= 1) {
|
|
222
|
+
return "…";
|
|
223
|
+
}
|
|
224
|
+
const keep = maxLength - 1;
|
|
225
|
+
const start = Math.ceil(keep / 2);
|
|
226
|
+
const end = Math.floor(keep / 2);
|
|
227
|
+
return `${value.slice(0, start)}…${value.slice(value.length - end)}`;
|
|
228
|
+
}
|
package/man/man1/gji-back.1
CHANGED
package/man/man1/gji-clean.1
CHANGED
package/man/man1/gji-config.1
CHANGED
package/man/man1/gji-go.1
CHANGED
package/man/man1/gji-history.1
CHANGED
package/man/man1/gji-init.1
CHANGED
package/man/man1/gji-ls.1
CHANGED
package/man/man1/gji-new.1
CHANGED
package/man/man1/gji-open.1
CHANGED
package/man/man1/gji-pr.1
CHANGED