@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.
Files changed (67) hide show
  1. package/README.md +82 -496
  2. package/dist/clean.js +2 -0
  3. package/dist/cli.d.ts +4 -0
  4. package/dist/cli.js +40 -4
  5. package/dist/context-card.d.ts +1 -0
  6. package/dist/context-card.js +25 -0
  7. package/dist/doctor.js +21 -0
  8. package/dist/done.js +2 -0
  9. package/dist/gji-bundle.mjs +988 -350
  10. package/dist/go.d.ts +1 -0
  11. package/dist/go.js +8 -0
  12. package/dist/hooks.d.ts +1 -0
  13. package/dist/hooks.js +7 -1
  14. package/dist/hub.d.ts +31 -0
  15. package/dist/hub.js +287 -0
  16. package/dist/ls.js +9 -1
  17. package/dist/new.d.ts +1 -0
  18. package/dist/new.js +9 -0
  19. package/dist/pr-open.js +2 -10
  20. package/dist/pull-requests.d.ts +1 -0
  21. package/dist/pull-requests.js +32 -3
  22. package/dist/remove.js +2 -0
  23. package/dist/repo.d.ts +1 -0
  24. package/dist/repo.js +4 -0
  25. package/dist/run-hook.js +2 -0
  26. package/dist/shell-completion.js +11 -3
  27. package/dist/slots.d.ts +8 -0
  28. package/dist/slots.js +61 -0
  29. package/dist/status.d.ts +2 -0
  30. package/dist/status.js +9 -1
  31. package/dist/task-command.d.ts +9 -0
  32. package/dist/task-command.js +29 -0
  33. package/dist/task.d.ts +9 -0
  34. package/dist/task.js +37 -0
  35. package/dist/terminal-text.d.ts +4 -0
  36. package/dist/terminal-text.js +72 -0
  37. package/dist/worktree-bootstrap.js +3 -0
  38. package/dist/worktree-info.d.ts +6 -1
  39. package/dist/worktree-info.js +14 -4
  40. package/dist/worktree-picker.d.ts +3 -3
  41. package/dist/worktree-picker.js +71 -82
  42. package/dist/worktree-sources.d.ts +1 -0
  43. package/dist/worktree-sources.js +57 -11
  44. package/man/man1/gji-back.1 +1 -1
  45. package/man/man1/gji-clean.1 +1 -1
  46. package/man/man1/gji-completion.1 +1 -1
  47. package/man/man1/gji-config.1 +1 -1
  48. package/man/man1/gji-doctor.1 +1 -1
  49. package/man/man1/gji-done.1 +1 -1
  50. package/man/man1/gji-go.1 +4 -1
  51. package/man/man1/gji-history.1 +1 -1
  52. package/man/man1/gji-init.1 +1 -1
  53. package/man/man1/gji-ls.1 +1 -1
  54. package/man/man1/gji-new.1 +4 -1
  55. package/man/man1/gji-open.1 +1 -1
  56. package/man/man1/gji-pr.1 +1 -1
  57. package/man/man1/gji-remove.1 +1 -1
  58. package/man/man1/gji-root.1 +1 -1
  59. package/man/man1/gji-run-hook.1 +1 -1
  60. package/man/man1/gji-status.1 +1 -1
  61. package/man/man1/gji-sync-files.1 +1 -1
  62. package/man/man1/gji-sync.1 +1 -1
  63. package/man/man1/gji-task.1 +16 -0
  64. package/man/man1/gji-undo.1 +1 -1
  65. package/man/man1/gji-warp.1 +1 -1
  66. package/man/man1/gji.1 +5 -1
  67. package/package.json +1 -1
package/dist/go.d.ts CHANGED
@@ -5,6 +5,7 @@ export interface GoCommandOptions {
5
5
  json?: boolean;
6
6
  print?: boolean;
7
7
  root?: boolean;
8
+ quiet?: boolean;
8
9
  stderr: (chunk: string) => void;
9
10
  stdout: (chunk: string) => void;
10
11
  }
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.pullRequestNumbers?.length ?? 0) > 0);
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 numbers = entry.pullRequestNumbers ?? [];
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) {
@@ -2,6 +2,7 @@ export type PullRequestForge = "bitbucket" | "github" | "gitlab";
2
2
  export interface PullRequestInfo {
3
3
  number: number;
4
4
  sourceBranch: string;
5
+ title?: string;
5
6
  url: string;
6
7
  }
7
8
  export interface PullRequestRemote {
@@ -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: (repoRoot) => listOpenPullRequestsForRepository(repoRoot, runCommand, fetcher),
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
  }