@solaqua/gji 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -496
- package/dist/clean.js +2 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +40 -4
- package/dist/context-card.d.ts +1 -0
- package/dist/context-card.js +25 -0
- package/dist/doctor.js +21 -0
- package/dist/done.js +2 -0
- package/dist/gji-bundle.mjs +988 -350
- package/dist/go.d.ts +1 -0
- package/dist/go.js +8 -0
- package/dist/hooks.d.ts +1 -0
- package/dist/hooks.js +7 -1
- package/dist/hub.d.ts +31 -0
- package/dist/hub.js +287 -0
- package/dist/ls.js +9 -1
- package/dist/new.d.ts +1 -0
- package/dist/new.js +9 -0
- package/dist/pr-open.js +2 -10
- package/dist/pull-requests.d.ts +1 -0
- package/dist/pull-requests.js +32 -3
- package/dist/remove.js +2 -0
- package/dist/repo.d.ts +1 -0
- package/dist/repo.js +4 -0
- package/dist/run-hook.js +2 -0
- package/dist/shell-completion.js +11 -3
- package/dist/slots.d.ts +8 -0
- package/dist/slots.js +61 -0
- package/dist/status.d.ts +2 -0
- package/dist/status.js +9 -1
- package/dist/task-command.d.ts +9 -0
- package/dist/task-command.js +29 -0
- package/dist/task.d.ts +9 -0
- package/dist/task.js +37 -0
- package/dist/terminal-text.d.ts +4 -0
- package/dist/terminal-text.js +72 -0
- package/dist/worktree-bootstrap.js +3 -0
- package/dist/worktree-info.d.ts +6 -1
- package/dist/worktree-info.js +14 -4
- package/dist/worktree-picker.d.ts +3 -3
- package/dist/worktree-picker.js +71 -82
- package/dist/worktree-sources.d.ts +1 -0
- package/dist/worktree-sources.js +57 -11
- 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-doctor.1 +1 -1
- package/man/man1/gji-done.1 +1 -1
- package/man/man1/gji-go.1 +4 -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 +4 -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 +1 -1
- 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-task.1 +16 -0
- package/man/man1/gji-undo.1 +1 -1
- package/man/man1/gji-warp.1 +1 -1
- package/man/man1/gji.1 +5 -1
- package/package.json +1 -1
package/dist/go.d.ts
CHANGED
package/dist/go.js
CHANGED
|
@@ -2,6 +2,7 @@ import { basename } from "node:path";
|
|
|
2
2
|
import { confirm, isCancel } from "@clack/prompts";
|
|
3
3
|
import { runBackCommand } from "./back.js";
|
|
4
4
|
import { loadEffectiveConfig } from "./config.js";
|
|
5
|
+
import { renderContextCard } from "./context-card.js";
|
|
5
6
|
import { resolveGoBranch } from "./go-resolver.js";
|
|
6
7
|
import { isHeadless } from "./headless.js";
|
|
7
8
|
import { recordWorktreeUsage } from "./history.js";
|
|
@@ -11,6 +12,7 @@ import { runNewCommand } from "./new.js";
|
|
|
11
12
|
import { runPrCommand } from "./pr.js";
|
|
12
13
|
import { detectRepository, listWorktrees, } from "./repo.js";
|
|
13
14
|
import { writeShellOutput } from "./shell-handoff.js";
|
|
15
|
+
import { getWorktreeSlot } from "./slots.js";
|
|
14
16
|
import { buildWorktreePromptEntries, promptForSingleWorktree, } from "./worktree-picker.js";
|
|
15
17
|
import { listRegisteredWorktreeSources } from "./worktree-sources.js";
|
|
16
18
|
const GO_OUTPUT_FILE_ENV = "GJI_GO_OUTPUT_FILE";
|
|
@@ -207,7 +209,13 @@ async function navigateToExistingWorktree(options, path, worktree) {
|
|
|
207
209
|
branch: worktree?.branch ?? undefined,
|
|
208
210
|
path,
|
|
209
211
|
repo: basename(repository.repoRoot),
|
|
212
|
+
slot: await getWorktreeSlot(path),
|
|
210
213
|
}, options.stderr);
|
|
214
|
+
if (!options.quiet) {
|
|
215
|
+
const card = await renderContextCard(path);
|
|
216
|
+
if (card)
|
|
217
|
+
options.stderr(`${card}\n`);
|
|
218
|
+
}
|
|
211
219
|
await recordWorktreeUsage(path, worktree?.branch ?? null);
|
|
212
220
|
await writeShellOutput(GO_OUTPUT_FILE_ENV, path, options.stdout);
|
|
213
221
|
return 0;
|
package/dist/hooks.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export interface HookContext {
|
|
|
8
8
|
branch?: string;
|
|
9
9
|
path: string;
|
|
10
10
|
repo: string;
|
|
11
|
+
slot?: number | null;
|
|
11
12
|
}
|
|
12
13
|
export declare function runHook(hookCmd: GjiHookCommand | undefined, cwd: string, context: HookContext, stderr: (chunk: string) => void, stdout?: (chunk: string) => void): Promise<void>;
|
|
13
14
|
export declare function interpolate(template: string, context: HookContext): string;
|
package/dist/hooks.js
CHANGED
|
@@ -72,6 +72,9 @@ function hookEnvironment(context) {
|
|
|
72
72
|
GJI_BRANCH: context.branch ?? "",
|
|
73
73
|
GJI_PATH: context.path,
|
|
74
74
|
GJI_REPO: context.repo,
|
|
75
|
+
...(context.slot === null || context.slot === undefined
|
|
76
|
+
? {}
|
|
77
|
+
: { GJI_SLOT: String(context.slot) }),
|
|
75
78
|
};
|
|
76
79
|
}
|
|
77
80
|
function formatArgvHook(command, args) {
|
|
@@ -81,7 +84,10 @@ export function interpolate(template, context) {
|
|
|
81
84
|
return template
|
|
82
85
|
.replace(/\{\{branch\}\}/g, context.branch ?? "")
|
|
83
86
|
.replace(/\{\{path\}\}/g, context.path)
|
|
84
|
-
.replace(/\{\{repo\}\}/g, context.repo)
|
|
87
|
+
.replace(/\{\{repo\}\}/g, context.repo)
|
|
88
|
+
.replace(/\{\{slot\}\}/g, context.slot === null || context.slot === undefined
|
|
89
|
+
? ""
|
|
90
|
+
: String(context.slot));
|
|
85
91
|
}
|
|
86
92
|
export function extractHooks(config) {
|
|
87
93
|
const raw = config.hooks;
|
package/dist/hub.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type PullRequestInfo } from "./pull-requests.js";
|
|
2
|
+
import { type WorktreeInfo } from "./worktree-info.js";
|
|
3
|
+
export interface HubCommandOptions {
|
|
4
|
+
cwd: string;
|
|
5
|
+
columns?: number;
|
|
6
|
+
json?: boolean;
|
|
7
|
+
now?: number;
|
|
8
|
+
stderr: (chunk: string) => void;
|
|
9
|
+
stdout: (chunk: string) => void;
|
|
10
|
+
}
|
|
11
|
+
export interface HubCommandDependencies {
|
|
12
|
+
queryRepositoryPullRequests: (repoRoot: string) => Promise<PullRequestInfo[]>;
|
|
13
|
+
}
|
|
14
|
+
export interface HubWorktree extends WorktreeInfo {
|
|
15
|
+
lastUsedTimestamp: number | null;
|
|
16
|
+
pullRequests: PullRequestInfo[];
|
|
17
|
+
}
|
|
18
|
+
export interface HubRepository {
|
|
19
|
+
current: boolean;
|
|
20
|
+
name: string;
|
|
21
|
+
pullRequests: PullRequestInfo[];
|
|
22
|
+
root: string;
|
|
23
|
+
worktrees: HubWorktree[];
|
|
24
|
+
}
|
|
25
|
+
export interface HubData {
|
|
26
|
+
currentRepository: string | null;
|
|
27
|
+
repositories: HubRepository[];
|
|
28
|
+
}
|
|
29
|
+
export declare function buildHubData(cwd: string, dependencies?: Partial<HubCommandDependencies>): Promise<HubData>;
|
|
30
|
+
export declare function runHubCommand(options: HubCommandOptions, dependencies?: Partial<HubCommandDependencies>): Promise<number>;
|
|
31
|
+
export declare function formatHubOutput(data: HubData, columns?: number, now?: number): string;
|
package/dist/hub.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { loadHistory } from "./history.js";
|
|
3
|
+
import { createPullRequestQuery, } from "./pull-requests.js";
|
|
4
|
+
import { detectRepository } from "./repo.js";
|
|
5
|
+
import { middleEllipsize, sanitizeTerminalText, terminalWidth, } from "./terminal-text.js";
|
|
6
|
+
import { formatRelativeAge, formatUpstreamState, readWorktreeInfos, } from "./worktree-info.js";
|
|
7
|
+
import { listDiscoverableWorktreeSources } from "./worktree-sources.js";
|
|
8
|
+
const MAX_HUB_REPOSITORY_CONCURRENCY = 4;
|
|
9
|
+
const defaultDependencies = {
|
|
10
|
+
queryRepositoryPullRequests: createPullRequestQuery().listOpenPullRequestsForRepository,
|
|
11
|
+
};
|
|
12
|
+
export async function buildHubData(cwd, dependencies = {}) {
|
|
13
|
+
const queryRepositoryPullRequests = dependencies.queryRepositoryPullRequests ??
|
|
14
|
+
defaultDependencies.queryRepositoryPullRequests;
|
|
15
|
+
const currentRepository = await detectRepository(cwd).catch(() => null);
|
|
16
|
+
const history = await loadHistory();
|
|
17
|
+
const lastUsedByPath = new Map(history.map((entry) => [entry.path, entry.timestamp]));
|
|
18
|
+
const sources = await listDiscoverableWorktreeSources(cwd);
|
|
19
|
+
const groups = new Map();
|
|
20
|
+
for (const source of sources) {
|
|
21
|
+
if (!source.repoRoot)
|
|
22
|
+
continue;
|
|
23
|
+
const repoRoot = source.repoRoot;
|
|
24
|
+
const existing = groups.get(repoRoot);
|
|
25
|
+
if (existing) {
|
|
26
|
+
existing.sources.push(source);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
groups.set(repoRoot, {
|
|
30
|
+
name: source.repoName || basename(repoRoot),
|
|
31
|
+
sources: [source],
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const repositories = await mapWithConcurrency([...groups.entries()], MAX_HUB_REPOSITORY_CONCURRENCY, async ([root, group]) => {
|
|
36
|
+
const infos = await readWorktreeInfos(group.sources.map((source) => source.worktree));
|
|
37
|
+
let pullRequests = [];
|
|
38
|
+
try {
|
|
39
|
+
pullRequests = await queryRepositoryPullRequests(root);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// PR metadata is optional. The hub remains useful when a provider is unavailable.
|
|
43
|
+
}
|
|
44
|
+
const worktrees = infos.map((info) => ({
|
|
45
|
+
...info,
|
|
46
|
+
lastUsedTimestamp: lastUsedByPath.get(info.path) ?? null,
|
|
47
|
+
pullRequests: pullRequests.filter((pullRequest) => pullRequest.sourceBranch === info.branch),
|
|
48
|
+
}));
|
|
49
|
+
return {
|
|
50
|
+
current: currentRepository?.repoRoot === root,
|
|
51
|
+
name: group.name,
|
|
52
|
+
pullRequests,
|
|
53
|
+
root,
|
|
54
|
+
worktrees,
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
repositories.sort((left, right) => {
|
|
58
|
+
if (left.current !== right.current)
|
|
59
|
+
return left.current ? -1 : 1;
|
|
60
|
+
return left.name.localeCompare(right.name);
|
|
61
|
+
});
|
|
62
|
+
return {
|
|
63
|
+
currentRepository: currentRepository?.repoRoot ?? null,
|
|
64
|
+
repositories,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export async function runHubCommand(options, dependencies = {}) {
|
|
68
|
+
const data = await buildHubData(options.cwd, dependencies);
|
|
69
|
+
if (options.json) {
|
|
70
|
+
options.stdout(`${JSON.stringify(data, null, 2)}\n`);
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
options.stdout(`${formatHubOutput(data, options.columns ?? process.stdout.columns ?? 80, options.now ?? Date.now())}\n`);
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
export function formatHubOutput(data, columns = process.stdout.columns ?? 80, now = Date.now()) {
|
|
77
|
+
if (data.repositories.length === 0) {
|
|
78
|
+
return "No registered repositories. Run gji from a repository to add one.";
|
|
79
|
+
}
|
|
80
|
+
const lineWidth = Math.max(1, columns);
|
|
81
|
+
const allWorktrees = data.repositories.flatMap((repository) => repository.worktrees.map((worktree) => ({ repository, worktree })));
|
|
82
|
+
const attention = allWorktrees.filter(({ worktree }) => isActionable(worktree));
|
|
83
|
+
const current = allWorktrees.find(({ worktree }) => worktree.isCurrent) ?? null;
|
|
84
|
+
const next = chooseNextWorktree(allWorktrees);
|
|
85
|
+
const remainingAttention = attention.filter((entry) => entry !== current && entry !== next);
|
|
86
|
+
const quiet = allWorktrees.filter((entry) => entry !== current && entry !== next && !attention.includes(entry));
|
|
87
|
+
const recentOther = [...quiet]
|
|
88
|
+
.sort((left, right) => (right.worktree.lastCommitTimestamp ?? 0) -
|
|
89
|
+
(left.worktree.lastCommitTimestamp ?? 0))
|
|
90
|
+
.slice(0, 5);
|
|
91
|
+
const hiddenCount = quiet.length - recentOther.length;
|
|
92
|
+
const lines = [
|
|
93
|
+
`GJI ${data.repositories.length} repositories · ${allWorktrees.length} worktrees${attention.length > 0 ? ` · ${attention.length} need attention` : ""}`,
|
|
94
|
+
"",
|
|
95
|
+
];
|
|
96
|
+
if (data.repositories.length > 1) {
|
|
97
|
+
lines.push("REPOSITORIES");
|
|
98
|
+
for (const repository of data.repositories) {
|
|
99
|
+
lines.push(formatRepositorySummary(repository, lineWidth));
|
|
100
|
+
}
|
|
101
|
+
lines.push("");
|
|
102
|
+
}
|
|
103
|
+
if (current !== null) {
|
|
104
|
+
lines.push("CURRENT");
|
|
105
|
+
lines.push(...formatHubWorktree(current, lineWidth, "@", now));
|
|
106
|
+
lines.push("");
|
|
107
|
+
}
|
|
108
|
+
if (next !== null && next !== current) {
|
|
109
|
+
lines.push("NEXT");
|
|
110
|
+
lines.push(...formatHubWorktree(next, lineWidth, "›", now));
|
|
111
|
+
lines.push("");
|
|
112
|
+
}
|
|
113
|
+
if (remainingAttention.length > 0) {
|
|
114
|
+
lines.push("ATTENTION");
|
|
115
|
+
for (const entry of remainingAttention) {
|
|
116
|
+
lines.push(...formatHubWorktree(entry, lineWidth, "!", now));
|
|
117
|
+
}
|
|
118
|
+
lines.push("");
|
|
119
|
+
}
|
|
120
|
+
if (recentOther.length > 0) {
|
|
121
|
+
lines.push("OTHER");
|
|
122
|
+
for (const entry of recentOther) {
|
|
123
|
+
lines.push(...formatHubWorktree(entry, lineWidth, " ", now));
|
|
124
|
+
}
|
|
125
|
+
if (hiddenCount > 0) {
|
|
126
|
+
lines.push(` ${hiddenCount} quiet worktree${hiddenCount === 1 ? "" : "s"} hidden`);
|
|
127
|
+
}
|
|
128
|
+
lines.push("");
|
|
129
|
+
}
|
|
130
|
+
if (next === null &&
|
|
131
|
+
remainingAttention.length === 0 &&
|
|
132
|
+
recentOther.length === 0) {
|
|
133
|
+
lines.push("No active worktrees.");
|
|
134
|
+
}
|
|
135
|
+
lines.push("Run `gji go <repo>/<branch>` to switch worktrees.");
|
|
136
|
+
return lines
|
|
137
|
+
.map((line) => terminalWidth(line) > lineWidth ? middleEllipsize(line, lineWidth) : line)
|
|
138
|
+
.join("\n")
|
|
139
|
+
.trimEnd();
|
|
140
|
+
}
|
|
141
|
+
function formatRepositorySummary(repository, lineWidth) {
|
|
142
|
+
const attentionCount = repository.worktrees.filter(isActionable).length;
|
|
143
|
+
const attention = attentionCount === 0 ? "healthy" : `${attentionCount} attention`;
|
|
144
|
+
const marker = repository.current ? "*" : " ";
|
|
145
|
+
return middleEllipsize(`${marker} ${repository.name} · ${repository.worktrees.length} worktree${repository.worktrees.length === 1 ? "" : "s"} · ${attention}`, lineWidth);
|
|
146
|
+
}
|
|
147
|
+
function chooseNextWorktree(entries) {
|
|
148
|
+
return ([...entries].sort((left, right) => {
|
|
149
|
+
const scoreDifference = nextWorktreeScore(right) - nextWorktreeScore(left);
|
|
150
|
+
if (scoreDifference !== 0)
|
|
151
|
+
return scoreDifference;
|
|
152
|
+
const lastUsedDifference = (right.worktree.lastUsedTimestamp ?? 0) -
|
|
153
|
+
(left.worktree.lastUsedTimestamp ?? 0);
|
|
154
|
+
if (lastUsedDifference !== 0)
|
|
155
|
+
return lastUsedDifference;
|
|
156
|
+
return ((right.worktree.lastCommitTimestamp ?? 0) -
|
|
157
|
+
(left.worktree.lastCommitTimestamp ?? 0));
|
|
158
|
+
})[0] ?? null);
|
|
159
|
+
}
|
|
160
|
+
function nextWorktreeScore(entry) {
|
|
161
|
+
const { worktree } = entry;
|
|
162
|
+
let score = worktree.isCurrent && (worktree.task !== null || isActionable(worktree))
|
|
163
|
+
? 100
|
|
164
|
+
: 0;
|
|
165
|
+
if (worktree.task !== null)
|
|
166
|
+
score += 40;
|
|
167
|
+
if (worktree.status === "dirty")
|
|
168
|
+
score += 30;
|
|
169
|
+
if (worktree.upstream.kind === "stale")
|
|
170
|
+
score += 25;
|
|
171
|
+
if (worktree.upstream.kind === "tracked" && worktree.upstream.behind > 0) {
|
|
172
|
+
score += 25;
|
|
173
|
+
}
|
|
174
|
+
if (worktree.pullRequests.length > 0)
|
|
175
|
+
score += 20;
|
|
176
|
+
if (worktree.lastUsedTimestamp !== null && worktree.lastUsedTimestamp > 0) {
|
|
177
|
+
score += 15;
|
|
178
|
+
}
|
|
179
|
+
return score;
|
|
180
|
+
}
|
|
181
|
+
function isActionable(worktree) {
|
|
182
|
+
return (worktree.status === "dirty" ||
|
|
183
|
+
worktree.upstream.kind === "stale" ||
|
|
184
|
+
(worktree.upstream.kind === "tracked" && worktree.upstream.behind > 0) ||
|
|
185
|
+
worktree.pullRequests.length > 0);
|
|
186
|
+
}
|
|
187
|
+
function formatHubWorktree(entry, lineWidth, marker, now) {
|
|
188
|
+
const { repository, worktree } = entry;
|
|
189
|
+
const branch = worktree.branch ?? "(detached)";
|
|
190
|
+
const status = [
|
|
191
|
+
formatWorktreeHealth(worktree),
|
|
192
|
+
formatWorktreeUpstream(worktree),
|
|
193
|
+
worktree.pullRequests.length > 0
|
|
194
|
+
? worktree.pullRequests
|
|
195
|
+
.map((pullRequest) => pullRequest.title
|
|
196
|
+
? `#${pullRequest.number} ${formatHumanText(pullRequest.title, 60)}`
|
|
197
|
+
: `#${pullRequest.number}`)
|
|
198
|
+
.join(", ")
|
|
199
|
+
: null,
|
|
200
|
+
formatWorktreeActivity(worktree, now),
|
|
201
|
+
]
|
|
202
|
+
.filter((value) => value !== null)
|
|
203
|
+
.join(" · ");
|
|
204
|
+
const task = worktree.task === null
|
|
205
|
+
? null
|
|
206
|
+
: `task: ${formatHumanText(worktree.task, 80)}`;
|
|
207
|
+
const target = `${repository.name}/${branch}`;
|
|
208
|
+
const lines = [`${marker} ${middleEllipsize(target, lineWidth - 2)}`];
|
|
209
|
+
if (status.length > 0) {
|
|
210
|
+
lines.push(` ${middleEllipsize(status, lineWidth - 2)}`);
|
|
211
|
+
}
|
|
212
|
+
if (task !== null) {
|
|
213
|
+
lines.push(` ${middleEllipsize(task, lineWidth - 2)}`);
|
|
214
|
+
}
|
|
215
|
+
if (marker === "@" || marker === "›" || marker === "!") {
|
|
216
|
+
lines.push(` ${middleEllipsize(nextAction(worktree), lineWidth - 2)}`);
|
|
217
|
+
}
|
|
218
|
+
return lines;
|
|
219
|
+
}
|
|
220
|
+
function formatWorktreeHealth(worktree) {
|
|
221
|
+
if (worktree.status === "dirty")
|
|
222
|
+
return "! dirty";
|
|
223
|
+
if (worktree.status === "unknown")
|
|
224
|
+
return "? health unknown";
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
function formatWorktreeUpstream(worktree) {
|
|
228
|
+
if (worktree.upstream.kind === "stale")
|
|
229
|
+
return "! stale upstream";
|
|
230
|
+
if (worktree.upstream.kind === "tracked" &&
|
|
231
|
+
(worktree.upstream.behind > 0 || worktree.upstream.ahead > 0)) {
|
|
232
|
+
return formatUpstreamState(worktree.upstream);
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
function formatWorktreeActivity(worktree, now) {
|
|
237
|
+
if (worktree.isCurrent)
|
|
238
|
+
return null;
|
|
239
|
+
const timestamps = [
|
|
240
|
+
worktree.lastUsedTimestamp ?? 0,
|
|
241
|
+
(worktree.lastCommitTimestamp ?? 0) * 1000,
|
|
242
|
+
].filter((timestamp) => timestamp > 0);
|
|
243
|
+
if (timestamps.length === 0)
|
|
244
|
+
return null;
|
|
245
|
+
const latest = Math.max(...timestamps);
|
|
246
|
+
const ageSeconds = Math.floor((now - latest) / 1000);
|
|
247
|
+
if (ageSeconds < 24 * 60 * 60)
|
|
248
|
+
return null;
|
|
249
|
+
return `inactive ${formatRelativeAge(Math.floor(latest / 1000), Math.floor(now / 1000))}`;
|
|
250
|
+
}
|
|
251
|
+
function nextAction(worktree) {
|
|
252
|
+
if (worktree.isCurrent && worktree.task !== null) {
|
|
253
|
+
return "next: continue task";
|
|
254
|
+
}
|
|
255
|
+
if (worktree.status === "dirty") {
|
|
256
|
+
return "next: switch → inspect changes";
|
|
257
|
+
}
|
|
258
|
+
if (worktree.upstream.kind === "stale" ||
|
|
259
|
+
(worktree.upstream.kind === "tracked" && worktree.upstream.behind > 0)) {
|
|
260
|
+
return "next: switch → sync";
|
|
261
|
+
}
|
|
262
|
+
const pullRequest = worktree.pullRequests[0];
|
|
263
|
+
if (pullRequest !== undefined) {
|
|
264
|
+
return `next: switch → open PR '#${pullRequest.number}'`;
|
|
265
|
+
}
|
|
266
|
+
return "next: switch worktree";
|
|
267
|
+
}
|
|
268
|
+
function formatHumanText(value, maxLength) {
|
|
269
|
+
const sanitized = sanitizeTerminalText(value);
|
|
270
|
+
return sanitized.length <= maxLength
|
|
271
|
+
? sanitized
|
|
272
|
+
: `${sanitized.slice(0, Math.max(0, maxLength - 1))}…`;
|
|
273
|
+
}
|
|
274
|
+
async function mapWithConcurrency(items, limit, mapper) {
|
|
275
|
+
const results = new Array(items.length);
|
|
276
|
+
let nextIndex = 0;
|
|
277
|
+
async function readNext() {
|
|
278
|
+
for (;;) {
|
|
279
|
+
const index = nextIndex++;
|
|
280
|
+
if (index >= items.length)
|
|
281
|
+
return;
|
|
282
|
+
results[index] = await mapper(items[index]);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => readNext()));
|
|
286
|
+
return results;
|
|
287
|
+
}
|
package/dist/ls.js
CHANGED
|
@@ -26,12 +26,15 @@ export function formatDetailedWorktreeTable(worktrees) {
|
|
|
26
26
|
lastCommit: formatLastCommit(worktree.lastCommitTimestamp),
|
|
27
27
|
path: worktree.path,
|
|
28
28
|
status: worktree.status,
|
|
29
|
+
task: worktree.task,
|
|
29
30
|
upstream: formatUpstreamState(worktree.upstream),
|
|
30
31
|
}));
|
|
31
32
|
const branchWidth = Math.max("BRANCH".length, ...rows.map((row) => row.branch.length));
|
|
32
33
|
const statusWidth = Math.max("STATUS".length, ...rows.map((row) => row.status.length));
|
|
33
34
|
const upstreamWidth = Math.max("UPSTREAM".length, ...rows.map((row) => row.upstream.length));
|
|
34
35
|
const lastCommitWidth = Math.max("LAST".length, ...rows.map((row) => row.lastCommit.length));
|
|
36
|
+
const hasTasks = rows.some((row) => row.task !== null);
|
|
37
|
+
const taskWidth = Math.max("TASK".length, ...rows.map((row) => row.task?.slice(0, 40).length ?? 0));
|
|
35
38
|
const lines = [
|
|
36
39
|
" " +
|
|
37
40
|
"BRANCH".padEnd(branchWidth, " ") +
|
|
@@ -41,6 +44,7 @@ export function formatDetailedWorktreeTable(worktrees) {
|
|
|
41
44
|
"UPSTREAM".padEnd(upstreamWidth, " ") +
|
|
42
45
|
" " +
|
|
43
46
|
"LAST".padEnd(lastCommitWidth, " ") +
|
|
47
|
+
(hasTasks ? " " + "TASK".padEnd(taskWidth, " ") : "") +
|
|
44
48
|
" PATH",
|
|
45
49
|
];
|
|
46
50
|
for (const row of rows) {
|
|
@@ -48,7 +52,11 @@ export function formatDetailedWorktreeTable(worktrees) {
|
|
|
48
52
|
`${row.branch.padEnd(branchWidth, " ")} ` +
|
|
49
53
|
`${row.status.padEnd(statusWidth, " ")} ` +
|
|
50
54
|
`${row.upstream.padEnd(upstreamWidth, " ")} ` +
|
|
51
|
-
`${row.lastCommit.padEnd(lastCommitWidth, " ")}
|
|
55
|
+
`${row.lastCommit.padEnd(lastCommitWidth, " ")}` +
|
|
56
|
+
(hasTasks
|
|
57
|
+
? ` ${(row.task?.slice(0, 40) ?? "").padEnd(taskWidth, " ")}`
|
|
58
|
+
: "") +
|
|
59
|
+
" " +
|
|
52
60
|
row.path);
|
|
53
61
|
}
|
|
54
62
|
return lines.join("\n");
|
package/dist/new.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export interface NewCommandOptions {
|
|
|
22
22
|
take?: boolean;
|
|
23
23
|
stderr: (chunk: string) => void;
|
|
24
24
|
stdout: (chunk: string) => void;
|
|
25
|
+
task?: string;
|
|
25
26
|
}
|
|
26
27
|
export interface NewCommandDependencies extends InstallPromptDependencies, DependencyBootstrapPromptDependencies {
|
|
27
28
|
cloneDir: CloneDirectory;
|
package/dist/new.js
CHANGED
|
@@ -18,6 +18,7 @@ import { createNavigationRepository, createNavigationTarget, } from "./navigatio
|
|
|
18
18
|
import { detectRepository, resolveWorktreePath, validateBranchName, } from "./repo.js";
|
|
19
19
|
import { writeShellOutput } from "./shell-handoff.js";
|
|
20
20
|
import { estimateSyncDirectories } from "./sync-plan.js";
|
|
21
|
+
import { writeTask } from "./task.js";
|
|
21
22
|
import { bootstrapWorktree } from "./worktree-bootstrap.js";
|
|
22
23
|
const execFileAsync = promisify(execFile);
|
|
23
24
|
const NEW_OUTPUT_FILE_ENV = "GJI_NEW_OUTPUT_FILE";
|
|
@@ -319,6 +320,14 @@ export function createNewCommand(dependencies = {}) {
|
|
|
319
320
|
}
|
|
320
321
|
return emitNewError(options, `could not apply taken changes; your changes are safe in stash: ${stashSha}`);
|
|
321
322
|
}
|
|
323
|
+
if (options.task !== undefined) {
|
|
324
|
+
try {
|
|
325
|
+
await writeTask(worktreePath, options.task);
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
options.stderr(`Warning: could not save task metadata: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
322
331
|
const bootstrap = await bootstrapWorktree({
|
|
323
332
|
branch: worktreeName,
|
|
324
333
|
cloneDirectory,
|
package/dist/pr-open.js
CHANGED
|
@@ -130,7 +130,7 @@ async function openFromWorktreeSelector(repoRoot, repoName, options, promptForWo
|
|
|
130
130
|
const entries = await buildWorktreePromptEntries(sources, {
|
|
131
131
|
queryPullRequests: async (_root, branch) => pullRequestsByBranch.get(branch) ?? [],
|
|
132
132
|
});
|
|
133
|
-
const connectedEntries = entries.filter((entry) => (entry.
|
|
133
|
+
const connectedEntries = entries.filter((entry) => (entry.pullRequests?.length ?? 0) > 0);
|
|
134
134
|
if (connectedEntries.length === 0) {
|
|
135
135
|
if (lookupError !== null) {
|
|
136
136
|
options.stderr(`gji pr open: failed to look up open PRs: ${formatError(lookupError)}\n`);
|
|
@@ -225,15 +225,7 @@ async function openForBranch(repoRoot, branch, options, queryPullRequests, promp
|
|
|
225
225
|
return openPullRequests(pullRequests, options, promptForPullRequest, openInBrowser);
|
|
226
226
|
}
|
|
227
227
|
async function openSelectedEntry(entry, options, promptForPullRequest, openInBrowser) {
|
|
228
|
-
const
|
|
229
|
-
const urls = entry.pullRequestUrls ?? [];
|
|
230
|
-
const pullRequests = numbers
|
|
231
|
-
.map((number, index) => ({
|
|
232
|
-
number,
|
|
233
|
-
sourceBranch: entry.branch ?? "",
|
|
234
|
-
url: urls[index] ?? "",
|
|
235
|
-
}))
|
|
236
|
-
.filter((pullRequest) => pullRequest.url.length > 0);
|
|
228
|
+
const pullRequests = entry.pullRequests ?? [];
|
|
237
229
|
return openPullRequests(pullRequests, options, promptForPullRequest, openInBrowser);
|
|
238
230
|
}
|
|
239
231
|
async function openPullRequests(pullRequests, options, promptForPullRequest, openInBrowser) {
|
package/dist/pull-requests.d.ts
CHANGED
package/dist/pull-requests.js
CHANGED
|
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
|
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
const execFileAsync = promisify(execFile);
|
|
4
4
|
const PULL_REQUEST_QUERY_TIMEOUT_MS = 2500;
|
|
5
|
+
const PULL_REQUEST_CACHE_TTL_MS = 30_000;
|
|
5
6
|
export function parsePullRequestRemote(remoteUrl) {
|
|
6
7
|
const parsed = parseRemoteUrl(remoteUrl.trim());
|
|
7
8
|
if (parsed === null)
|
|
@@ -46,10 +47,36 @@ export function parsePullRequestRemote(remoteUrl) {
|
|
|
46
47
|
export function createPullRequestQuery(dependencies = {}) {
|
|
47
48
|
const runCommand = dependencies.runCommand ?? defaultRunCommand;
|
|
48
49
|
const fetcher = dependencies.fetch ?? fetch;
|
|
50
|
+
const repositoryCache = new Map();
|
|
51
|
+
const repositoryInFlight = new Map();
|
|
52
|
+
const listRepositoryPullRequests = (repoRoot) => {
|
|
53
|
+
const cached = repositoryCache.get(repoRoot);
|
|
54
|
+
if (cached !== undefined && cached.expiresAt > Date.now()) {
|
|
55
|
+
return Promise.resolve(cached.value);
|
|
56
|
+
}
|
|
57
|
+
const inFlight = repositoryInFlight.get(repoRoot);
|
|
58
|
+
if (inFlight !== undefined)
|
|
59
|
+
return inFlight;
|
|
60
|
+
const request = listOpenPullRequestsForRepository(repoRoot, runCommand, fetcher)
|
|
61
|
+
.then((value) => {
|
|
62
|
+
repositoryCache.set(repoRoot, {
|
|
63
|
+
expiresAt: Date.now() + PULL_REQUEST_CACHE_TTL_MS,
|
|
64
|
+
value,
|
|
65
|
+
});
|
|
66
|
+
repositoryInFlight.delete(repoRoot);
|
|
67
|
+
return value;
|
|
68
|
+
})
|
|
69
|
+
.catch((error) => {
|
|
70
|
+
repositoryInFlight.delete(repoRoot);
|
|
71
|
+
throw error;
|
|
72
|
+
});
|
|
73
|
+
repositoryInFlight.set(repoRoot, request);
|
|
74
|
+
return request;
|
|
75
|
+
};
|
|
49
76
|
return {
|
|
50
77
|
findOpenPullRequest: (repoRoot, number) => findOpenPullRequest(repoRoot, number, runCommand, fetcher),
|
|
51
78
|
listOpenPullRequests: (repoRoot, sourceBranch) => listOpenPullRequests(repoRoot, sourceBranch, runCommand, fetcher),
|
|
52
|
-
listOpenPullRequestsForRepository:
|
|
79
|
+
listOpenPullRequestsForRepository: listRepositoryPullRequests,
|
|
53
80
|
};
|
|
54
81
|
}
|
|
55
82
|
async function listOpenPullRequests(repoRoot, sourceBranch, runCommand, fetcher) {
|
|
@@ -153,7 +180,7 @@ function providerCliCommand(remote, query) {
|
|
|
153
180
|
"view",
|
|
154
181
|
String(query.number),
|
|
155
182
|
"--json",
|
|
156
|
-
"number,url,headRefName,state",
|
|
183
|
+
"number,url,headRefName,title,state",
|
|
157
184
|
"--repo",
|
|
158
185
|
coordinate,
|
|
159
186
|
],
|
|
@@ -168,7 +195,7 @@ function providerCliCommand(remote, query) {
|
|
|
168
195
|
"open",
|
|
169
196
|
...(query.kind === "branch" ? ["--head", sourceBranch] : []),
|
|
170
197
|
"--json",
|
|
171
|
-
"number,url,headRefName,state",
|
|
198
|
+
"number,url,headRefName,title,state",
|
|
172
199
|
"--limit",
|
|
173
200
|
"100",
|
|
174
201
|
"--repo",
|
|
@@ -302,6 +329,7 @@ function normalizePullRequest(value, query) {
|
|
|
302
329
|
(isRecord(value.links) && isRecord(value.links.html)
|
|
303
330
|
? value.links.html.href
|
|
304
331
|
: undefined));
|
|
332
|
+
const title = stringValue(value.title);
|
|
305
333
|
const state = stringValue(value.state)?.toLowerCase();
|
|
306
334
|
if (number === null || url === null)
|
|
307
335
|
return null;
|
|
@@ -314,6 +342,7 @@ function normalizePullRequest(value, query) {
|
|
|
314
342
|
return {
|
|
315
343
|
number,
|
|
316
344
|
sourceBranch: sourceBranch ?? "",
|
|
345
|
+
...(title === null ? {} : { title }),
|
|
317
346
|
url,
|
|
318
347
|
};
|
|
319
348
|
}
|
package/dist/remove.js
CHANGED
|
@@ -4,6 +4,7 @@ import { loadEffectiveConfig } from "./config.js";
|
|
|
4
4
|
import { isHeadless } from "./headless.js";
|
|
5
5
|
import { extractHooks, runHook } from "./hooks.js";
|
|
6
6
|
import { writeShellOutput } from "./shell-handoff.js";
|
|
7
|
+
import { releaseWorktreeSlot } from "./slots.js";
|
|
7
8
|
import { finalizeUndoOperation, recordUndoOperation } from "./undo.js";
|
|
8
9
|
import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isWorktreeForceRemovalError, loadLinkedWorktrees, removeWorktree, } from "./worktree-management.js";
|
|
9
10
|
import { buildWorktreePromptEntries, promptForSingleWorktree, } from "./worktree-picker.js";
|
|
@@ -118,6 +119,7 @@ export function createRemoveCommand(dependencies = {}) {
|
|
|
118
119
|
}
|
|
119
120
|
}
|
|
120
121
|
await finalizeUndoOperation(journal, [worktree]);
|
|
122
|
+
await releaseWorktreeSlot(worktree.path);
|
|
121
123
|
if (worktree.branch) {
|
|
122
124
|
try {
|
|
123
125
|
await deleteBranch(repository.repoRoot, worktree.branch);
|
package/dist/repo.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface WorktreeEntry {
|
|
|
11
11
|
path: string;
|
|
12
12
|
}
|
|
13
13
|
export declare function detectRepository(cwd: string): Promise<RepositoryContext>;
|
|
14
|
+
export declare function worktreeGitDir(worktreePath: string): Promise<string>;
|
|
14
15
|
export declare function resolveWorktreePath(repoRoot: string, branch: string, basePath?: string): string;
|
|
15
16
|
export declare function validateBranchName(name: string): string | null;
|
|
16
17
|
export declare function hasLocalBranch(repoRoot: string, branch: string): Promise<boolean>;
|
package/dist/repo.js
CHANGED
|
@@ -17,6 +17,10 @@ export async function detectRepository(cwd) {
|
|
|
17
17
|
repoRoot,
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
|
+
export async function worktreeGitDir(worktreePath) {
|
|
21
|
+
const rawGitDir = await runGit(worktreePath, ["rev-parse", "--git-dir"]);
|
|
22
|
+
return isAbsolute(rawGitDir) ? rawGitDir : resolve(worktreePath, rawGitDir);
|
|
23
|
+
}
|
|
20
24
|
export function resolveWorktreePath(repoRoot, branch, basePath) {
|
|
21
25
|
const segments = branch.split("/").filter(Boolean);
|
|
22
26
|
if (segments.length === 0) {
|
package/dist/run-hook.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { loadEffectiveConfig } from "./config.js";
|
|
2
2
|
import { extractHooks, runHook } from "./hooks.js";
|
|
3
3
|
import { detectRepository, listWorktrees } from "./repo.js";
|
|
4
|
+
import { getWorktreeSlot } from "./slots.js";
|
|
4
5
|
const VALID_HOOKS = [
|
|
5
6
|
"after-create",
|
|
6
7
|
"after-enter",
|
|
@@ -31,6 +32,7 @@ export async function runHookCommand(options) {
|
|
|
31
32
|
branch: currentWorktree?.branch ?? undefined,
|
|
32
33
|
path: repository.currentRoot,
|
|
33
34
|
repo: repository.repoName,
|
|
35
|
+
slot: await getWorktreeSlot(repository.currentRoot),
|
|
34
36
|
}, options.stderr);
|
|
35
37
|
return 0;
|
|
36
38
|
}
|