@solaqua/gji 0.9.0 → 0.11.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 +93 -20
- package/dist/back.d.ts +3 -0
- package/dist/back.js +19 -7
- package/dist/bootstrap-output.d.ts +3 -0
- package/dist/bootstrap-output.js +27 -0
- package/dist/bootstrap-preview.d.ts +10 -0
- package/dist/bootstrap-preview.js +21 -0
- package/dist/clean.js +25 -3
- package/dist/cli.js +105 -19
- package/dist/clone-failure-store.d.ts +18 -0
- package/dist/clone-failure-store.js +230 -0
- package/dist/command-runner.d.ts +6 -0
- package/dist/command-runner.js +38 -0
- package/dist/completion.js +7 -2
- package/dist/config-command.d.ts +1 -0
- package/dist/config-command.js +7 -1
- package/dist/config.d.ts +26 -1
- package/dist/config.js +110 -6
- package/dist/dependency-bootstrap-prompt.d.ts +24 -0
- package/dist/dependency-bootstrap-prompt.js +103 -0
- package/dist/dependency-bootstrap.d.ts +129 -0
- package/dist/dependency-bootstrap.js +566 -0
- package/dist/dir-clone.d.ts +33 -0
- package/dist/dir-clone.js +505 -0
- package/dist/done.d.ts +10 -0
- package/dist/done.js +208 -0
- package/dist/file-sync.js +71 -22
- package/dist/format-bytes.d.ts +1 -0
- package/dist/format-bytes.js +14 -0
- package/dist/fs-utils.d.ts +4 -0
- package/dist/fs-utils.js +26 -0
- package/dist/git.d.ts +5 -0
- package/dist/git.js +50 -0
- package/dist/gji-bundle.mjs +5489 -1206
- package/dist/go-resolver.d.ts +35 -0
- package/dist/go-resolver.js +185 -0
- package/dist/go.d.ts +5 -2
- package/dist/go.js +262 -62
- package/dist/hooks.d.ts +1 -1
- package/dist/hooks.js +13 -7
- package/dist/init.js +30 -22
- package/dist/install-prompt.d.ts +1 -0
- package/dist/install-prompt.js +3 -25
- package/dist/navigation-output.d.ts +11 -0
- package/dist/navigation-output.js +6 -0
- package/dist/new.d.ts +14 -2
- package/dist/new.js +434 -36
- package/dist/open.d.ts +1 -0
- package/dist/open.js +14 -2
- package/dist/pr.d.ts +6 -2
- package/dist/pr.js +135 -30
- package/dist/remove.js +20 -0
- package/dist/repo.d.ts +3 -0
- package/dist/repo.js +57 -6
- package/dist/safe-destination.d.ts +15 -0
- package/dist/safe-destination.js +166 -0
- package/dist/shell-completion.js +47 -14
- package/dist/sync-directories.d.ts +33 -0
- package/dist/sync-directories.js +97 -0
- package/dist/sync-plan.d.ts +16 -0
- package/dist/sync-plan.js +128 -0
- package/dist/sync.js +7 -13
- package/dist/undo.d.ts +36 -0
- package/dist/undo.js +392 -0
- package/dist/warp.d.ts +3 -1
- package/dist/warp.js +32 -125
- package/dist/worktree-bootstrap.d.ts +35 -0
- package/dist/worktree-bootstrap.js +93 -0
- package/dist/worktree-management.d.ts +1 -0
- package/dist/worktree-management.js +23 -4
- package/dist/worktree-picker.d.ts +21 -8
- package/dist/worktree-picker.js +107 -15
- package/dist/worktree-prompts.js +1 -1
- package/dist/worktree-source.d.ts +6 -0
- package/dist/worktree-source.js +1 -0
- package/dist/worktree-sources.d.ts +3 -0
- package/dist/worktree-sources.js +31 -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-doctor.1 +1 -1
- package/man/man1/gji-done.1 +19 -0
- package/man/man1/gji-go.1 +9 -3
- 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 +21 -3
- package/man/man1/gji-open.1 +4 -1
- package/man/man1/gji-pr.1 +7 -1
- package/man/man1/gji-remove.1 +3 -3
- 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-undo.1 +16 -0
- package/man/man1/gji-warp.1 +3 -6
- package/man/man1/gji.1 +13 -5
- package/package.json +3 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type RepositoryContext } from "./repo.js";
|
|
2
|
+
import type { WorktreeSource } from "./worktree-source.js";
|
|
3
|
+
export type GoBranchResolution = {
|
|
4
|
+
kind: "existing";
|
|
5
|
+
source: WorktreeSource;
|
|
6
|
+
} | {
|
|
7
|
+
kind: "ambiguous";
|
|
8
|
+
matches: WorktreeSource[];
|
|
9
|
+
} | {
|
|
10
|
+
kind: "create";
|
|
11
|
+
repository: RepositoryContext;
|
|
12
|
+
branch: string;
|
|
13
|
+
mode: "checkout" | "track";
|
|
14
|
+
remote?: string;
|
|
15
|
+
} | {
|
|
16
|
+
kind: "pull-request";
|
|
17
|
+
repository: RepositoryContext;
|
|
18
|
+
input: string;
|
|
19
|
+
} | {
|
|
20
|
+
kind: "no-repository";
|
|
21
|
+
staleRegisteredRepos: boolean;
|
|
22
|
+
} | {
|
|
23
|
+
kind: "no-match";
|
|
24
|
+
staleRegisteredRepos: boolean;
|
|
25
|
+
} | {
|
|
26
|
+
kind: "error";
|
|
27
|
+
message: string;
|
|
28
|
+
};
|
|
29
|
+
export declare function resolveGoBranch(options: {
|
|
30
|
+
branch: string;
|
|
31
|
+
configStderr?: (chunk: string) => void;
|
|
32
|
+
cwd: string;
|
|
33
|
+
currentSources: WorktreeSource[];
|
|
34
|
+
repository: RepositoryContext | null;
|
|
35
|
+
}): Promise<GoBranchResolution>;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { loadEffectiveConfig, resolveConfigString } from "./config.js";
|
|
2
|
+
import { parsePrInput } from "./pr.js";
|
|
3
|
+
import { getRepositoryRemoteUrl, hasLocalBranch, hasRemoteBranch, } from "./repo.js";
|
|
4
|
+
import { resolveExactWorktreeQueryMatches, resolveWorktreeQuery, resolveWorktreeQueryMatches, } from "./worktree-picker.js";
|
|
5
|
+
import { listRegisteredWorktreeSources } from "./worktree-sources.js";
|
|
6
|
+
export async function resolveGoBranch(options) {
|
|
7
|
+
const { branch, configStderr, cwd, currentSources, repository } = options;
|
|
8
|
+
const pullRequestNumber = parsePrInput(branch);
|
|
9
|
+
const exactCurrentMatches = resolveExistingExactWorktreeMatches(currentSources, branch, false);
|
|
10
|
+
if (exactCurrentMatches.length === 1) {
|
|
11
|
+
return { kind: "existing", source: exactCurrentMatches[0] };
|
|
12
|
+
}
|
|
13
|
+
const localBranch = repository
|
|
14
|
+
? await hasLocalBranch(repository.repoRoot, branch)
|
|
15
|
+
: false;
|
|
16
|
+
if (!repository && pullRequestNumber !== null) {
|
|
17
|
+
return {
|
|
18
|
+
kind: "error",
|
|
19
|
+
message: "PR references must be resolved from inside a git repository",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
let remote;
|
|
23
|
+
let remoteBranch = false;
|
|
24
|
+
let configError = null;
|
|
25
|
+
const configWarnings = [];
|
|
26
|
+
if (repository) {
|
|
27
|
+
try {
|
|
28
|
+
const config = await loadEffectiveConfig(repository.repoRoot, undefined, configStderr === undefined
|
|
29
|
+
? undefined
|
|
30
|
+
: (warning) => configWarnings.push(warning));
|
|
31
|
+
remote = resolveConfigString(config, "syncRemote") ?? "origin";
|
|
32
|
+
remoteBranch = await hasRemoteBranch(repository.repoRoot, remote, branch);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
configError = {
|
|
36
|
+
kind: "error",
|
|
37
|
+
message: `could not load repository config: ${error instanceof Error ? error.message : String(error)}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const flushConfigWarnings = () => {
|
|
42
|
+
if (configStderr === undefined)
|
|
43
|
+
return;
|
|
44
|
+
for (const warning of configWarnings)
|
|
45
|
+
configStderr(warning);
|
|
46
|
+
};
|
|
47
|
+
const pullRequestBelongsToRepository = repository && pullRequestNumber !== null
|
|
48
|
+
? await isPullRequestForRepository(repository.repoRoot, branch)
|
|
49
|
+
: true;
|
|
50
|
+
if (pullRequestNumber !== null &&
|
|
51
|
+
!localBranch &&
|
|
52
|
+
!remoteBranch &&
|
|
53
|
+
pullRequestBelongsToRepository) {
|
|
54
|
+
const pullRequestMatches = resolveExistingExactWorktreeMatches(currentSources, branch, true);
|
|
55
|
+
if (pullRequestMatches.length === 1) {
|
|
56
|
+
return { kind: "existing", source: pullRequestMatches[0] };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
let skippedRegisteredRepos = 0;
|
|
60
|
+
const registeredSources = await listRegisteredWorktreeSources(cwd, () => {
|
|
61
|
+
skippedRegisteredRepos++;
|
|
62
|
+
});
|
|
63
|
+
const crossRepoSources = registeredSources.filter((source) => source.repoRoot !== repository?.repoRoot);
|
|
64
|
+
const crossMatches = await resolveExistingWorktreeMatches(crossRepoSources, branch, (!localBranch && !remoteBranch) || isPullRequestUrl(branch));
|
|
65
|
+
if (crossMatches.length === 1) {
|
|
66
|
+
return { kind: "existing", source: crossMatches[0] };
|
|
67
|
+
}
|
|
68
|
+
if (crossMatches.length > 1) {
|
|
69
|
+
return { kind: "ambiguous", matches: crossMatches };
|
|
70
|
+
}
|
|
71
|
+
if (!repository) {
|
|
72
|
+
return registeredSources.length === 0
|
|
73
|
+
? {
|
|
74
|
+
kind: "no-repository",
|
|
75
|
+
staleRegisteredRepos: skippedRegisteredRepos > 0,
|
|
76
|
+
}
|
|
77
|
+
: { kind: "no-match", staleRegisteredRepos: skippedRegisteredRepos > 0 };
|
|
78
|
+
}
|
|
79
|
+
if (localBranch) {
|
|
80
|
+
if (configError)
|
|
81
|
+
return configError;
|
|
82
|
+
flushConfigWarnings();
|
|
83
|
+
return { kind: "create", repository, branch, mode: "checkout" };
|
|
84
|
+
}
|
|
85
|
+
if (remoteBranch) {
|
|
86
|
+
flushConfigWarnings();
|
|
87
|
+
return { kind: "create", repository, branch, mode: "track", remote };
|
|
88
|
+
}
|
|
89
|
+
const localMatch = !pullRequestNumber || !isPullRequestUrl(branch)
|
|
90
|
+
? resolveWorktreeQuery(currentSources, branch)
|
|
91
|
+
: null;
|
|
92
|
+
if (localMatch) {
|
|
93
|
+
return { kind: "existing", source: localMatch };
|
|
94
|
+
}
|
|
95
|
+
if (configError)
|
|
96
|
+
return configError;
|
|
97
|
+
if (pullRequestNumber !== null && !pullRequestBelongsToRepository) {
|
|
98
|
+
flushConfigWarnings();
|
|
99
|
+
return {
|
|
100
|
+
kind: "error",
|
|
101
|
+
message: "PR URL does not belong to this repository; run gji go from the matching checkout",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (pullRequestNumber !== null) {
|
|
105
|
+
flushConfigWarnings();
|
|
106
|
+
return { kind: "pull-request", repository, input: branch };
|
|
107
|
+
}
|
|
108
|
+
flushConfigWarnings();
|
|
109
|
+
return { kind: "no-match", staleRegisteredRepos: skippedRegisteredRepos > 0 };
|
|
110
|
+
}
|
|
111
|
+
function resolveExistingExactWorktreeMatches(sources, query, allowPullRequestFallback) {
|
|
112
|
+
const exactMatches = resolveExactWorktreeQueryMatches(sources, query);
|
|
113
|
+
if (exactMatches.length > 0 || !allowPullRequestFallback)
|
|
114
|
+
return exactMatches;
|
|
115
|
+
const pullRequestNumber = parsePrInput(query);
|
|
116
|
+
return pullRequestNumber === null
|
|
117
|
+
? []
|
|
118
|
+
: resolveExactWorktreeQueryMatches(sources, `pr/${pullRequestNumber}`);
|
|
119
|
+
}
|
|
120
|
+
async function resolveExistingWorktreeMatches(sources, query, allowPullRequestFallback) {
|
|
121
|
+
const exactMatches = resolveExactWorktreeQueryMatches(sources, query);
|
|
122
|
+
if (exactMatches.length > 0)
|
|
123
|
+
return exactMatches;
|
|
124
|
+
const pullRequestNumber = parsePrInput(query);
|
|
125
|
+
if (pullRequestNumber === null) {
|
|
126
|
+
return resolveWorktreeQueryMatches(sources, query);
|
|
127
|
+
}
|
|
128
|
+
if (!allowPullRequestFallback)
|
|
129
|
+
return [];
|
|
130
|
+
const ownershipByRepository = new Map();
|
|
131
|
+
const eligibleSources = await Promise.all(sources.map(async (source) => {
|
|
132
|
+
const repoRoot = source.repoRoot ?? source.worktree.path;
|
|
133
|
+
let ownership = ownershipByRepository.get(repoRoot);
|
|
134
|
+
if (ownership === undefined) {
|
|
135
|
+
ownership = isPullRequestForRepository(repoRoot, query);
|
|
136
|
+
ownershipByRepository.set(repoRoot, ownership);
|
|
137
|
+
}
|
|
138
|
+
return (await ownership) ? source : null;
|
|
139
|
+
}));
|
|
140
|
+
const pullRequestMatches = resolveExactWorktreeQueryMatches(eligibleSources.filter((source) => source !== null), `pr/${pullRequestNumber}`);
|
|
141
|
+
if (pullRequestMatches.length > 0 || isPullRequestUrl(query)) {
|
|
142
|
+
return pullRequestMatches;
|
|
143
|
+
}
|
|
144
|
+
return resolveWorktreeQueryMatches(sources, query);
|
|
145
|
+
}
|
|
146
|
+
function isPullRequestUrl(input) {
|
|
147
|
+
return /^[a-z][a-z\d+.-]*:\/\//i.test(input);
|
|
148
|
+
}
|
|
149
|
+
async function isPullRequestForRepository(repoRoot, input) {
|
|
150
|
+
if (/^\d+$/.test(input) || /^#\d+$/.test(input))
|
|
151
|
+
return true;
|
|
152
|
+
let pullRequestUrl;
|
|
153
|
+
try {
|
|
154
|
+
pullRequestUrl = new URL(input);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
const remoteUrl = await getRepositoryRemoteUrl(repoRoot, "origin");
|
|
160
|
+
if (!remoteUrl)
|
|
161
|
+
return true;
|
|
162
|
+
return (normalizeRepositoryUrl(remoteUrl) ===
|
|
163
|
+
normalizeRepositoryUrl(pullRequestUrl.href));
|
|
164
|
+
}
|
|
165
|
+
function normalizeRepositoryUrl(value) {
|
|
166
|
+
return normalizeRemoteUrl(value).replace(/\/(?:pull|pull-requests|(?:-\/)?merge_requests)\/\d+(?:\/.*)?$/, "");
|
|
167
|
+
}
|
|
168
|
+
function normalizeRemoteUrl(value) {
|
|
169
|
+
const trimmed = value.trim();
|
|
170
|
+
const sshMatch = trimmed.match(/^(?:[^@]+@)?([^:]+):(.+)$/);
|
|
171
|
+
if (sshMatch && !trimmed.includes("://")) {
|
|
172
|
+
return `${sshMatch[1]}/${sshMatch[2]}`
|
|
173
|
+
.replace(/\.git$/, "")
|
|
174
|
+
.replace(/\/$/, "");
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
const url = new URL(trimmed);
|
|
178
|
+
return `${url.host}${url.pathname}`
|
|
179
|
+
.replace(/\.git$/, "")
|
|
180
|
+
.replace(/\/$/, "");
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return trimmed.replace(/\.git$/, "").replace(/\/$/, "");
|
|
184
|
+
}
|
|
185
|
+
}
|
package/dist/go.d.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import { type QueryWorktreePullRequests, type WorktreePromptEntry } from "./worktree-picker.js";
|
|
1
|
+
import { type QueryWorktreePullRequests, type WorktreePromptEntry, type WorktreePromptScope } from "./worktree-picker.js";
|
|
2
2
|
export interface GoCommandOptions {
|
|
3
3
|
branch?: string;
|
|
4
4
|
cwd: string;
|
|
5
|
+
json?: boolean;
|
|
5
6
|
print?: boolean;
|
|
7
|
+
root?: boolean;
|
|
6
8
|
stderr: (chunk: string) => void;
|
|
7
9
|
stdout: (chunk: string) => void;
|
|
8
10
|
}
|
|
9
11
|
export interface GoCommandDependencies {
|
|
10
|
-
|
|
12
|
+
confirmBranchCreation: (branch: string) => Promise<boolean>;
|
|
13
|
+
promptForWorktree: (worktrees: WorktreePromptEntry[], scope?: WorktreePromptScope) => Promise<string | null>;
|
|
11
14
|
queryPullRequests: QueryWorktreePullRequests;
|
|
12
15
|
}
|
|
13
16
|
export declare function createGoCommand(dependencies?: Partial<GoCommandDependencies>): (options: GoCommandOptions) => Promise<number>;
|
package/dist/go.js
CHANGED
|
@@ -1,85 +1,285 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
|
+
import { confirm, isCancel } from "@clack/prompts";
|
|
3
|
+
import { runBackCommand } from "./back.js";
|
|
2
4
|
import { loadEffectiveConfig } from "./config.js";
|
|
5
|
+
import { resolveGoBranch } from "./go-resolver.js";
|
|
3
6
|
import { isHeadless } from "./headless.js";
|
|
4
7
|
import { recordWorktreeUsage } from "./history.js";
|
|
5
8
|
import { extractHooks, runHook } from "./hooks.js";
|
|
6
|
-
import {
|
|
9
|
+
import { createNavigationRepository, createNavigationTarget, } from "./navigation-output.js";
|
|
10
|
+
import { runNewCommand } from "./new.js";
|
|
11
|
+
import { runPrCommand } from "./pr.js";
|
|
12
|
+
import { detectRepository, listWorktrees, } from "./repo.js";
|
|
7
13
|
import { writeShellOutput } from "./shell-handoff.js";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
14
|
+
import { buildWorktreePromptEntries, promptForSingleWorktree, } from "./worktree-picker.js";
|
|
15
|
+
import { listRegisteredWorktreeSources } from "./worktree-sources.js";
|
|
10
16
|
const GO_OUTPUT_FILE_ENV = "GJI_GO_OUTPUT_FILE";
|
|
11
17
|
export function createGoCommand(dependencies = {}) {
|
|
18
|
+
const confirmBranchCreation = dependencies.confirmBranchCreation ?? defaultConfirmBranchCreation;
|
|
12
19
|
const prompt = dependencies.promptForWorktree ?? promptForWorktree;
|
|
13
20
|
return async function runGoCommand(options) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
[worktrees, repository] = await Promise.all([
|
|
18
|
-
listWorktrees(options.cwd),
|
|
19
|
-
detectRepository(options.cwd),
|
|
20
|
-
]);
|
|
21
|
-
}
|
|
22
|
-
catch {
|
|
23
|
-
// Not inside a git repo — fall back to cross-repo navigation.
|
|
24
|
-
if (isHeadless() && !options.branch) {
|
|
25
|
-
options.stderr("gji go: branch argument is required in non-interactive mode (GJI_NO_TUI=1)\n");
|
|
26
|
-
return 1;
|
|
21
|
+
if (options.root) {
|
|
22
|
+
if (options.branch !== undefined) {
|
|
23
|
+
return emitError(options, "--root cannot be combined with a branch");
|
|
27
24
|
}
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
return navigateToRepositoryRoot(options);
|
|
26
|
+
}
|
|
27
|
+
if (options.branch === "-") {
|
|
28
|
+
return runBackCommand({
|
|
30
29
|
commandName: "gji go",
|
|
30
|
+
cwd: options.cwd,
|
|
31
|
+
json: options.json,
|
|
32
|
+
outputEnv: GO_OUTPUT_FILE_ENV,
|
|
33
|
+
stderr: options.stderr,
|
|
34
|
+
stdout: options.stdout,
|
|
31
35
|
});
|
|
32
|
-
if (!target)
|
|
33
|
-
return 1;
|
|
34
|
-
await recordWorktreeUsage(target.path, target.branch);
|
|
35
|
-
await writeShellOutput(GO_OUTPUT_FILE_ENV, target.path, options.stdout);
|
|
36
|
-
return 0;
|
|
37
|
-
}
|
|
38
|
-
if (!options.branch && isHeadless()) {
|
|
39
|
-
options.stderr("gji go: branch argument is required in non-interactive mode (GJI_NO_TUI=1)\n");
|
|
40
|
-
return 1;
|
|
41
36
|
}
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
37
|
+
const [repository, currentWorktrees] = await readCurrentRepository(options.cwd);
|
|
38
|
+
const currentSources = repository
|
|
39
|
+
? toPromptSources(repository.repoRoot, repository.repoName, currentWorktrees)
|
|
40
|
+
: [];
|
|
41
|
+
if (!options.branch) {
|
|
42
|
+
if (options.json || isHeadless()) {
|
|
43
|
+
return emitError(options, "branch argument is required in non-interactive mode (GJI_NO_TUI=1)");
|
|
44
|
+
}
|
|
45
|
+
let skippedRegisteredRepos = 0;
|
|
46
|
+
let registeredSources = null;
|
|
47
|
+
let promptSources = currentSources;
|
|
48
|
+
const loadAllSources = async () => {
|
|
49
|
+
if (registeredSources === null) {
|
|
50
|
+
registeredSources = await listRegisteredWorktreeSources(options.cwd, () => {
|
|
51
|
+
skippedRegisteredRepos++;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return deduplicateSources([...currentSources, ...registeredSources]);
|
|
55
|
+
};
|
|
56
|
+
if (!repository) {
|
|
57
|
+
promptSources = await loadAllSources();
|
|
58
|
+
}
|
|
59
|
+
if (promptSources.length === 0 && !repository) {
|
|
60
|
+
return emitNoRepositoryError(options, skippedRegisteredRepos > 0);
|
|
61
|
+
}
|
|
62
|
+
let currentRepositoryScope = true;
|
|
63
|
+
let scope;
|
|
64
|
+
if (repository) {
|
|
65
|
+
scope = {
|
|
66
|
+
label: "current repository",
|
|
67
|
+
toggleLabel: "all repositories",
|
|
68
|
+
toggle: async () => {
|
|
69
|
+
const nextCurrentRepositoryScope = !currentRepositoryScope;
|
|
70
|
+
const nextSources = nextCurrentRepositoryScope
|
|
71
|
+
? currentSources
|
|
72
|
+
: await loadAllSources();
|
|
73
|
+
currentRepositoryScope = nextCurrentRepositoryScope;
|
|
74
|
+
promptSources = nextSources;
|
|
75
|
+
return {
|
|
76
|
+
entries: await buildWorktreePromptEntries(promptSources, {
|
|
77
|
+
metadata: currentRepositoryScope ? "full" : "fast",
|
|
78
|
+
queryPullRequests: dependencies.queryPullRequests,
|
|
79
|
+
}),
|
|
80
|
+
label: currentRepositoryScope
|
|
81
|
+
? "current repository"
|
|
82
|
+
: "all repositories",
|
|
83
|
+
toggleLabel: currentRepositoryScope
|
|
84
|
+
? "all repositories"
|
|
85
|
+
: "current repository",
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const promptEntries = await buildWorktreePromptEntries(promptSources, {
|
|
91
|
+
metadata: repository ? "full" : "fast",
|
|
50
92
|
queryPullRequests: dependencies.queryPullRequests,
|
|
51
93
|
});
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
: null;
|
|
55
|
-
const prompted = options.branch ? null : await prompt(promptEntries);
|
|
56
|
-
const resolvedPath = options.branch
|
|
57
|
-
? queried?.worktree.path
|
|
58
|
-
: (prompted ?? undefined);
|
|
59
|
-
if (!resolvedPath) {
|
|
60
|
-
if (options.branch) {
|
|
61
|
-
options.stderr(`No worktree found for branch: ${options.branch}\n`);
|
|
62
|
-
options.stderr(`Hint: Use 'gji ls' to see available worktrees\n`);
|
|
63
|
-
}
|
|
64
|
-
else {
|
|
94
|
+
const selectedPath = await prompt(promptEntries, scope);
|
|
95
|
+
if (!selectedPath) {
|
|
65
96
|
options.stderr("Aborted\n");
|
|
97
|
+
return 1;
|
|
66
98
|
}
|
|
67
|
-
|
|
99
|
+
const selected = promptSources.find((source) => source.worktree.path === selectedPath);
|
|
100
|
+
return navigateToExistingWorktree(options, selectedPath, selected?.worktree);
|
|
68
101
|
}
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
await recordWorktreeUsage(resolvedPath, chosenWorktree?.branch ?? null);
|
|
78
|
-
await writeShellOutput(GO_OUTPUT_FILE_ENV, resolvedPath, options.stdout);
|
|
79
|
-
return 0;
|
|
102
|
+
const resolution = await resolveGoBranch({
|
|
103
|
+
branch: options.branch,
|
|
104
|
+
configStderr: options.json ? undefined : options.stderr,
|
|
105
|
+
cwd: options.cwd,
|
|
106
|
+
currentSources,
|
|
107
|
+
repository,
|
|
108
|
+
});
|
|
109
|
+
return handleGoBranchResolution(options, resolution, confirmBranchCreation, prompt, dependencies.queryPullRequests);
|
|
80
110
|
};
|
|
81
111
|
}
|
|
82
112
|
export const runGoCommand = createGoCommand();
|
|
83
|
-
async function
|
|
84
|
-
|
|
113
|
+
async function handleGoBranchResolution(options, resolution, confirmBranchCreation, prompt, queryPullRequests) {
|
|
114
|
+
switch (resolution.kind) {
|
|
115
|
+
case "existing":
|
|
116
|
+
return navigateToExistingWorktree(options, resolution.source.worktree.path, resolution.source.worktree);
|
|
117
|
+
case "ambiguous": {
|
|
118
|
+
if (options.json || isHeadless() || options.print) {
|
|
119
|
+
return emitAmbiguousCrossRepoError(options, resolution.matches);
|
|
120
|
+
}
|
|
121
|
+
const candidates = await buildWorktreePromptEntries(resolution.matches, {
|
|
122
|
+
metadata: "fast",
|
|
123
|
+
queryPullRequests,
|
|
124
|
+
});
|
|
125
|
+
const selectedPath = await prompt(candidates);
|
|
126
|
+
if (!selectedPath) {
|
|
127
|
+
options.stderr("Aborted\n");
|
|
128
|
+
return 1;
|
|
129
|
+
}
|
|
130
|
+
const selected = resolution.matches.find((source) => source.worktree.path === selectedPath);
|
|
131
|
+
return navigateToExistingWorktree(options, selectedPath, selected?.worktree);
|
|
132
|
+
}
|
|
133
|
+
case "create":
|
|
134
|
+
return createExistingBranchWorktree(options, confirmBranchCreation, resolution.repository, resolution.branch, resolution.mode, resolution.remote);
|
|
135
|
+
case "pull-request":
|
|
136
|
+
if (options.json || isHeadless() || options.print) {
|
|
137
|
+
return emitError(options, "PR navigation creates a worktree; use `gji pr <ref>` in an interactive shell");
|
|
138
|
+
}
|
|
139
|
+
return runPrCommand({
|
|
140
|
+
cwd: resolution.repository.repoRoot,
|
|
141
|
+
number: resolution.input,
|
|
142
|
+
outputEnv: GO_OUTPUT_FILE_ENV,
|
|
143
|
+
stderr: options.stderr,
|
|
144
|
+
stdout: options.stdout,
|
|
145
|
+
});
|
|
146
|
+
case "no-repository":
|
|
147
|
+
return emitNoRepositoryError(options, resolution.staleRegisteredRepos);
|
|
148
|
+
case "no-match":
|
|
149
|
+
return emitNoMatchError(options, resolution.staleRegisteredRepos);
|
|
150
|
+
case "error":
|
|
151
|
+
return emitError(options, resolution.message);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async function readCurrentRepository(cwd) {
|
|
155
|
+
try {
|
|
156
|
+
const [repository, worktrees] = await Promise.all([
|
|
157
|
+
detectRepository(cwd),
|
|
158
|
+
listWorktrees(cwd),
|
|
159
|
+
]);
|
|
160
|
+
return [repository, worktrees];
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return [null, []];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function toPromptSources(repoRoot, repoName, worktrees) {
|
|
167
|
+
return worktrees.map((worktree) => ({ repoName, repoRoot, worktree }));
|
|
168
|
+
}
|
|
169
|
+
function deduplicateSources(sources) {
|
|
170
|
+
const seen = new Set();
|
|
171
|
+
const deduplicated = [];
|
|
172
|
+
for (const source of sources) {
|
|
173
|
+
if (seen.has(source.worktree.path))
|
|
174
|
+
continue;
|
|
175
|
+
seen.add(source.worktree.path);
|
|
176
|
+
deduplicated.push(source);
|
|
177
|
+
}
|
|
178
|
+
return deduplicated;
|
|
179
|
+
}
|
|
180
|
+
async function createExistingBranchWorktree(options, confirmBranchCreation, repository, branch, mode, remote) {
|
|
181
|
+
if (options.json || isHeadless() || options.print) {
|
|
182
|
+
return emitError(options, `branch "${branch}" exists but has no worktree; use interactive gji go ${branch} to create one`);
|
|
183
|
+
}
|
|
184
|
+
if (!(await confirmBranchCreation(branch))) {
|
|
185
|
+
options.stderr("Aborted\n");
|
|
186
|
+
return 1;
|
|
187
|
+
}
|
|
188
|
+
return runNewCommand({
|
|
189
|
+
branch,
|
|
190
|
+
cwd: repository.repoRoot,
|
|
191
|
+
mode,
|
|
192
|
+
outputEnv: GO_OUTPUT_FILE_ENV,
|
|
193
|
+
remote,
|
|
194
|
+
stderr: options.stderr,
|
|
195
|
+
stdout: options.stdout,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
async function navigateToExistingWorktree(options, path, worktree) {
|
|
199
|
+
const repository = await detectRepository(path);
|
|
200
|
+
if (options.json) {
|
|
201
|
+
options.stdout(`${JSON.stringify(createNavigationTarget(createNavigationRepository(repository.repoName, repository.repoRoot), path, worktree?.branch ?? null), null, 2)}\n`);
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
|
|
205
|
+
const hooks = extractHooks(config);
|
|
206
|
+
await runHook(hooks["after-enter"], path, {
|
|
207
|
+
branch: worktree?.branch ?? undefined,
|
|
208
|
+
path,
|
|
209
|
+
repo: basename(repository.repoRoot),
|
|
210
|
+
}, options.stderr);
|
|
211
|
+
await recordWorktreeUsage(path, worktree?.branch ?? null);
|
|
212
|
+
await writeShellOutput(GO_OUTPUT_FILE_ENV, path, options.stdout);
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
async function navigateToRepositoryRoot(options) {
|
|
216
|
+
let repository;
|
|
217
|
+
try {
|
|
218
|
+
repository = await detectRepository(options.cwd);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return emitError(options, "not inside a git repository");
|
|
222
|
+
}
|
|
223
|
+
if (options.json) {
|
|
224
|
+
const rootWorktree = (await listWorktrees(repository.repoRoot)).find((worktree) => worktree.path === repository.repoRoot);
|
|
225
|
+
options.stdout(`${JSON.stringify(createNavigationTarget(createNavigationRepository(repository.repoName, repository.repoRoot), repository.repoRoot, rootWorktree?.branch ?? null), null, 2)}\n`);
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
if (options.print) {
|
|
229
|
+
options.stdout(`${repository.repoRoot}\n`);
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
await writeShellOutput(GO_OUTPUT_FILE_ENV, repository.repoRoot, options.stdout);
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
function emitAmbiguousCrossRepoError(options, matches) {
|
|
236
|
+
const candidates = matches
|
|
237
|
+
.map((match) => `${match.repoName}/${match.worktree.branch ?? "(detached)"}`)
|
|
238
|
+
.join(", ");
|
|
239
|
+
return emitError(options, `multiple worktrees match "${options.branch}": ${candidates}`);
|
|
240
|
+
}
|
|
241
|
+
function emitNoRepositoryError(options, staleRegisteredRepos) {
|
|
242
|
+
if (options.json) {
|
|
243
|
+
return emitError(options, "not in a git repository and no accessible worktrees are registered");
|
|
244
|
+
}
|
|
245
|
+
options.stderr("gji go: not in a git repository and no repos registered yet.\n");
|
|
246
|
+
options.stderr("Use any gji command inside a repository to register it.\n");
|
|
247
|
+
if (staleRegisteredRepos) {
|
|
248
|
+
options.stderr("Hint: Run 'gji doctor' to inspect stale repository entries.\n");
|
|
249
|
+
}
|
|
250
|
+
return 1;
|
|
251
|
+
}
|
|
252
|
+
function emitNoMatchError(options, staleRegisteredRepos = false) {
|
|
253
|
+
if (options.json) {
|
|
254
|
+
return emitError(options, `nothing matched "${options.branch}"`);
|
|
255
|
+
}
|
|
256
|
+
options.stderr(`No worktree found for branch: ${options.branch}\n`);
|
|
257
|
+
options.stderr(`gji go: nothing matched "${options.branch}"\n`);
|
|
258
|
+
options.stderr("Hint: Use 'gji ls' to see available worktrees\n");
|
|
259
|
+
options.stderr(" · no worktree or branch named that query in this repository\n" +
|
|
260
|
+
" · no matching worktree in registered repositories\n" +
|
|
261
|
+
` · create it: gji new ${options.branch}\n`);
|
|
262
|
+
if (staleRegisteredRepos) {
|
|
263
|
+
options.stderr("Hint: Run 'gji doctor' to inspect stale repository entries.\n");
|
|
264
|
+
}
|
|
265
|
+
return 1;
|
|
266
|
+
}
|
|
267
|
+
function emitError(options, message) {
|
|
268
|
+
if (options.json) {
|
|
269
|
+
options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
options.stderr(`gji go: ${message}\n`);
|
|
273
|
+
}
|
|
274
|
+
return 1;
|
|
275
|
+
}
|
|
276
|
+
async function defaultConfirmBranchCreation(branch) {
|
|
277
|
+
const choice = await confirm({
|
|
278
|
+
message: `branch "${branch}" exists but has no worktree. Create one?`,
|
|
279
|
+
initialValue: true,
|
|
280
|
+
});
|
|
281
|
+
return !isCancel(choice) && choice;
|
|
282
|
+
}
|
|
283
|
+
async function promptForWorktree(worktrees, scope) {
|
|
284
|
+
return promptForSingleWorktree("Choose a worktree", worktrees, { scope });
|
|
85
285
|
}
|
package/dist/hooks.d.ts
CHANGED
|
@@ -9,6 +9,6 @@ export interface HookContext {
|
|
|
9
9
|
path: string;
|
|
10
10
|
repo: string;
|
|
11
11
|
}
|
|
12
|
-
export declare function runHook(hookCmd: GjiHookCommand | undefined, cwd: string, context: HookContext, stderr: (chunk: string) => void): Promise<void>;
|
|
12
|
+
export declare function runHook(hookCmd: GjiHookCommand | undefined, cwd: string, context: HookContext, stderr: (chunk: string) => void, stdout?: (chunk: string) => void): Promise<void>;
|
|
13
13
|
export declare function interpolate(template: string, context: HookContext): string;
|
|
14
14
|
export declare function extractHooks(config: Record<string, unknown>): GjiHooks;
|
package/dist/hooks.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
export async function runHook(hookCmd, cwd, context, stderr) {
|
|
2
|
+
export async function runHook(hookCmd, cwd, context, stderr, stdout = (chunk) => process.stdout.write(chunk)) {
|
|
3
3
|
if (!hookCmd)
|
|
4
4
|
return;
|
|
5
5
|
if (Array.isArray(hookCmd)) {
|
|
6
|
-
await runArgvHook(hookCmd, cwd, context, stderr);
|
|
6
|
+
await runArgvHook(hookCmd, cwd, context, stderr, stdout);
|
|
7
7
|
return;
|
|
8
8
|
}
|
|
9
|
-
await runShellHook(hookCmd, cwd, context, stderr);
|
|
9
|
+
await runShellHook(hookCmd, cwd, context, stderr, stdout);
|
|
10
10
|
}
|
|
11
|
-
async function runArgvHook(hookCmd, cwd, context, stderr) {
|
|
11
|
+
async function runArgvHook(hookCmd, cwd, context, stderr, stdout) {
|
|
12
12
|
const [command, ...args] = hookCmd.map((arg) => interpolate(arg, context));
|
|
13
13
|
if (!command) {
|
|
14
14
|
stderr("gji: hook argv command must include a non-empty command\n");
|
|
@@ -18,9 +18,12 @@ async function runArgvHook(hookCmd, cwd, context, stderr) {
|
|
|
18
18
|
const child = spawn(command, args, {
|
|
19
19
|
cwd,
|
|
20
20
|
shell: false,
|
|
21
|
-
stdio: ["ignore", "
|
|
21
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
22
22
|
env: hookEnvironment(context),
|
|
23
23
|
});
|
|
24
|
+
child.stdout.on("data", (chunk) => {
|
|
25
|
+
stdout(chunk.toString());
|
|
26
|
+
});
|
|
24
27
|
child.stderr.on("data", (chunk) => {
|
|
25
28
|
stderr(chunk.toString());
|
|
26
29
|
});
|
|
@@ -36,15 +39,18 @@ async function runArgvHook(hookCmd, cwd, context, stderr) {
|
|
|
36
39
|
});
|
|
37
40
|
});
|
|
38
41
|
}
|
|
39
|
-
async function runShellHook(hookCmd, cwd, context, stderr) {
|
|
42
|
+
async function runShellHook(hookCmd, cwd, context, stderr, stdout) {
|
|
40
43
|
const interpolated = interpolate(hookCmd, context);
|
|
41
44
|
await new Promise((resolve) => {
|
|
42
45
|
const child = spawn(interpolated, {
|
|
43
46
|
cwd,
|
|
44
47
|
shell: true,
|
|
45
|
-
stdio: ["ignore", "
|
|
48
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
46
49
|
env: hookEnvironment(context),
|
|
47
50
|
});
|
|
51
|
+
child.stdout.on("data", (chunk) => {
|
|
52
|
+
stdout(chunk.toString());
|
|
53
|
+
});
|
|
48
54
|
child.stderr.on("data", (chunk) => {
|
|
49
55
|
stderr(chunk.toString());
|
|
50
56
|
});
|