@solaqua/gji 0.6.1 → 0.7.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 +26 -1
- package/dist/back.d.ts +1 -1
- package/dist/back.js +23 -17
- package/dist/clean.d.ts +1 -1
- package/dist/clean.js +44 -35
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +264 -164
- package/dist/completion.js +3 -3
- package/dist/config-command.js +5 -5
- package/dist/config.js +41 -35
- package/dist/conflict.d.ts +1 -1
- package/dist/conflict.js +14 -6
- package/dist/editor.js +29 -9
- package/dist/file-sync.d.ts +1 -0
- package/dist/file-sync.js +15 -11
- package/dist/git.d.ts +1 -1
- package/dist/git.js +21 -19
- package/dist/gji-bundle.mjs +1709 -850
- package/dist/go.d.ts +2 -2
- package/dist/go.js +39 -26
- package/dist/headless.js +1 -1
- package/dist/history-command.js +3 -3
- package/dist/history.js +12 -12
- package/dist/hooks.js +16 -16
- package/dist/index.js +13 -9
- package/dist/init.d.ts +2 -2
- package/dist/init.js +106 -94
- package/dist/install-prompt.d.ts +3 -3
- package/dist/install-prompt.js +46 -28
- package/dist/ls.d.ts +2 -2
- package/dist/ls.js +29 -29
- package/dist/new.d.ts +2 -2
- package/dist/new.js +96 -81
- package/dist/open.d.ts +2 -2
- package/dist/open.js +24 -21
- package/dist/package-manager.js +96 -45
- package/dist/pr.d.ts +2 -2
- package/dist/pr.js +47 -34
- package/dist/remove.d.ts +1 -1
- package/dist/remove.js +39 -27
- package/dist/repo-registry.js +45 -19
- package/dist/repo.js +29 -28
- package/dist/root.js +3 -3
- package/dist/shell-completion.d.ts +1 -1
- package/dist/shell-completion.js +65 -37
- package/dist/shell-handoff.js +2 -2
- package/dist/shell.d.ts +1 -1
- package/dist/shell.js +4 -4
- package/dist/status.d.ts +5 -5
- package/dist/status.js +23 -23
- package/dist/sync-files-command.d.ts +10 -0
- package/dist/sync-files-command.js +137 -0
- package/dist/sync.js +23 -15
- package/dist/trigger-hook.js +9 -5
- package/dist/warp.js +66 -34
- package/dist/worktree-info.d.ts +9 -9
- package/dist/worktree-info.js +31 -29
- package/dist/worktree-management.d.ts +1 -1
- package/dist/worktree-management.js +26 -11
- package/dist/worktree-prompts.js +5 -5
- package/man/man1/gji-back.1 +1 -1
- package/man/man1/gji-clean.1 +1 -1
- package/man/man1/gji-completion.1 +1 -1
- package/man/man1/gji-config.1 +1 -1
- package/man/man1/gji-go.1 +1 -1
- package/man/man1/gji-history.1 +1 -1
- package/man/man1/gji-init.1 +1 -1
- package/man/man1/gji-ls.1 +1 -1
- package/man/man1/gji-new.1 +1 -1
- package/man/man1/gji-open.1 +1 -1
- package/man/man1/gji-pr.1 +1 -1
- package/man/man1/gji-remove.1 +1 -1
- package/man/man1/gji-root.1 +1 -1
- package/man/man1/gji-status.1 +1 -1
- package/man/man1/gji-sync-files.1 +23 -0
- package/man/man1/gji-sync.1 +1 -1
- package/man/man1/gji-trigger-hook.1 +1 -1
- package/man/man1/gji-warp.1 +1 -1
- package/man/man1/gji.1 +5 -1
- package/package.json +8 -2
package/dist/pr.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { promisify } from
|
|
5
|
-
import { loadEffectiveConfig, resolveConfigString } from
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { appendHistory } from
|
|
10
|
-
import {
|
|
11
|
-
import { maybeRunInstallPrompt } from
|
|
12
|
-
import { detectRepository, resolveWorktreePath } from
|
|
13
|
-
import { writeShellOutput } from
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { loadEffectiveConfig, resolveConfigString } from "./config.js";
|
|
6
|
+
import { pathExists, promptForPathConflict, } from "./conflict.js";
|
|
7
|
+
import { syncFiles } from "./file-sync.js";
|
|
8
|
+
import { isHeadless } from "./headless.js";
|
|
9
|
+
import { appendHistory } from "./history.js";
|
|
10
|
+
import { extractHooks, runHook } from "./hooks.js";
|
|
11
|
+
import { maybeRunInstallPrompt, } from "./install-prompt.js";
|
|
12
|
+
import { detectRepository, resolveWorktreePath } from "./repo.js";
|
|
13
|
+
import { writeShellOutput } from "./shell-handoff.js";
|
|
14
14
|
const execFileAsync = promisify(execFile);
|
|
15
|
-
const PR_OUTPUT_FILE_ENV =
|
|
15
|
+
const PR_OUTPUT_FILE_ENV = "GJI_PR_OUTPUT_FILE";
|
|
16
16
|
export function parsePrInput(input) {
|
|
17
17
|
if (/^\d+$/.test(input))
|
|
18
18
|
return input;
|
|
@@ -42,8 +42,10 @@ export function createPrCommand(dependencies = {}) {
|
|
|
42
42
|
const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
|
|
43
43
|
const branchName = `pr/${prNumber}`;
|
|
44
44
|
const remoteRef = `refs/remotes/origin/pull/${prNumber}/head`;
|
|
45
|
-
const rawBasePath = resolveConfigString(config,
|
|
46
|
-
const configuredBasePath = rawBasePath?.startsWith(
|
|
45
|
+
const rawBasePath = resolveConfigString(config, "worktreePath");
|
|
46
|
+
const configuredBasePath = rawBasePath?.startsWith("/") || rawBasePath?.startsWith("~")
|
|
47
|
+
? rawBasePath
|
|
48
|
+
: undefined;
|
|
47
49
|
const worktreePath = resolveWorktreePath(repository.repoRoot, branchName, configuredBasePath);
|
|
48
50
|
if (await pathExists(worktreePath)) {
|
|
49
51
|
if (options.json || isHeadless()) {
|
|
@@ -58,7 +60,7 @@ export function createPrCommand(dependencies = {}) {
|
|
|
58
60
|
return 1;
|
|
59
61
|
}
|
|
60
62
|
const choice = await prompt(worktreePath);
|
|
61
|
-
if (choice ===
|
|
63
|
+
if (choice === "reuse") {
|
|
62
64
|
appendHistory(worktreePath, branchName).catch(() => undefined);
|
|
63
65
|
await writeOutput(worktreePath, options.stdout);
|
|
64
66
|
return 0;
|
|
@@ -92,12 +94,12 @@ export function createPrCommand(dependencies = {}) {
|
|
|
92
94
|
await mkdir(dirname(worktreePath), { recursive: true });
|
|
93
95
|
const branchAlreadyExists = await localBranchExists(repository.repoRoot, branchName);
|
|
94
96
|
const worktreeArgs = branchAlreadyExists
|
|
95
|
-
? [
|
|
96
|
-
: [
|
|
97
|
-
await execFileAsync(
|
|
97
|
+
? ["worktree", "add", worktreePath, branchName]
|
|
98
|
+
: ["worktree", "add", "-b", branchName, worktreePath, remoteRef];
|
|
99
|
+
await execFileAsync("git", worktreeArgs, { cwd: repository.repoRoot });
|
|
98
100
|
// Sync files from main worktree before afterCreate so synced files are available to install scripts.
|
|
99
101
|
const syncPatterns = Array.isArray(config.syncFiles)
|
|
100
|
-
? config.syncFiles.filter((p) => typeof p ===
|
|
102
|
+
? config.syncFiles.filter((p) => typeof p === "string")
|
|
101
103
|
: [];
|
|
102
104
|
for (const pattern of syncPatterns) {
|
|
103
105
|
try {
|
|
@@ -109,7 +111,11 @@ export function createPrCommand(dependencies = {}) {
|
|
|
109
111
|
}
|
|
110
112
|
await maybeRunInstallPrompt(worktreePath, repository.repoRoot, config, options.stderr, dependencies, !!options.json);
|
|
111
113
|
const hooks = extractHooks(config);
|
|
112
|
-
await runHook(hooks.afterCreate, worktreePath, {
|
|
114
|
+
await runHook(hooks.afterCreate, worktreePath, {
|
|
115
|
+
branch: branchName,
|
|
116
|
+
path: worktreePath,
|
|
117
|
+
repo: basename(repository.repoRoot),
|
|
118
|
+
}, options.stderr);
|
|
113
119
|
if (options.json) {
|
|
114
120
|
options.stdout(`${JSON.stringify({ branch: branchName, path: worktreePath }, null, 2)}\n`);
|
|
115
121
|
}
|
|
@@ -122,7 +128,7 @@ export function createPrCommand(dependencies = {}) {
|
|
|
122
128
|
}
|
|
123
129
|
async function localBranchExists(repoRoot, branchName) {
|
|
124
130
|
try {
|
|
125
|
-
await execFileAsync(
|
|
131
|
+
await execFileAsync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], { cwd: repoRoot });
|
|
126
132
|
return true;
|
|
127
133
|
}
|
|
128
134
|
catch {
|
|
@@ -133,7 +139,7 @@ export const runPrCommand = createPrCommand();
|
|
|
133
139
|
async function fetchPullRequestRef(repoRoot, input, prNumber, remoteRef) {
|
|
134
140
|
for (const sourceRef of listPullRequestSourceRefs(input, prNumber)) {
|
|
135
141
|
try {
|
|
136
|
-
await execFileAsync(
|
|
142
|
+
await execFileAsync("git", ["fetch", "origin", `${sourceRef}:${remoteRef}`], { cwd: repoRoot });
|
|
137
143
|
return;
|
|
138
144
|
}
|
|
139
145
|
catch {
|
|
@@ -143,32 +149,39 @@ async function fetchPullRequestRef(repoRoot, input, prNumber, remoteRef) {
|
|
|
143
149
|
throw new Error(`No pull request ref found for #${prNumber}`);
|
|
144
150
|
}
|
|
145
151
|
function listPullRequestSourceRefs(input, prNumber) {
|
|
146
|
-
const allForges = [
|
|
152
|
+
const allForges = [
|
|
153
|
+
"github",
|
|
154
|
+
"gitlab",
|
|
155
|
+
"bitbucket",
|
|
156
|
+
];
|
|
147
157
|
const preferredForge = detectPullRequestForge(input);
|
|
148
|
-
const orderedForges = preferredForge ===
|
|
158
|
+
const orderedForges = preferredForge === "unknown"
|
|
149
159
|
? allForges
|
|
150
|
-
: [
|
|
160
|
+
: [
|
|
161
|
+
preferredForge,
|
|
162
|
+
...allForges.filter((forge) => forge !== preferredForge),
|
|
163
|
+
];
|
|
151
164
|
return orderedForges.map((forge) => sourceRefForForge(forge, prNumber));
|
|
152
165
|
}
|
|
153
166
|
function detectPullRequestForge(input) {
|
|
154
167
|
if (/\/pull-requests\/\d+/.test(input)) {
|
|
155
|
-
return
|
|
168
|
+
return "bitbucket";
|
|
156
169
|
}
|
|
157
170
|
if (/\/merge_requests\/\d+/.test(input)) {
|
|
158
|
-
return
|
|
171
|
+
return "gitlab";
|
|
159
172
|
}
|
|
160
173
|
if (/\/pull\/\d+/.test(input)) {
|
|
161
|
-
return
|
|
174
|
+
return "github";
|
|
162
175
|
}
|
|
163
|
-
return
|
|
176
|
+
return "unknown";
|
|
164
177
|
}
|
|
165
178
|
function sourceRefForForge(forge, prNumber) {
|
|
166
179
|
switch (forge) {
|
|
167
|
-
case
|
|
180
|
+
case "bitbucket":
|
|
168
181
|
return `refs/pull-requests/${prNumber}/from`;
|
|
169
|
-
case
|
|
182
|
+
case "github":
|
|
170
183
|
return `refs/pull/${prNumber}/head`;
|
|
171
|
-
case
|
|
184
|
+
case "gitlab":
|
|
172
185
|
return `refs/merge-requests/${prNumber}/head`;
|
|
173
186
|
}
|
|
174
187
|
}
|
package/dist/remove.d.ts
CHANGED
package/dist/remove.js
CHANGED
|
@@ -1,26 +1,27 @@
|
|
|
1
|
-
import { basename } from
|
|
2
|
-
import { confirm, isCancel, select } from
|
|
3
|
-
import { loadEffectiveConfig } from
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { sortByCurrentFirst } from
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
const REMOVE_OUTPUT_FILE_ENV =
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { confirm, isCancel, select } from "@clack/prompts";
|
|
3
|
+
import { loadEffectiveConfig } from "./config.js";
|
|
4
|
+
import { isHeadless } from "./headless.js";
|
|
5
|
+
import { extractHooks, runHook } from "./hooks.js";
|
|
6
|
+
import { sortByCurrentFirst } from "./repo.js";
|
|
7
|
+
import { writeShellOutput } from "./shell-handoff.js";
|
|
8
|
+
import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isWorktreeDirtyError, loadLinkedWorktrees, removeWorktree, } from "./worktree-management.js";
|
|
9
|
+
import { defaultConfirmForceDeleteBranch, defaultConfirmForceRemoveWorktree, } from "./worktree-prompts.js";
|
|
10
|
+
const REMOVE_OUTPUT_FILE_ENV = "GJI_REMOVE_OUTPUT_FILE";
|
|
11
11
|
export function createRemoveCommand(dependencies = {}) {
|
|
12
12
|
const promptForWorktree = dependencies.promptForWorktree ?? defaultPromptForWorktree;
|
|
13
13
|
const confirmRemoval = dependencies.confirmRemoval ?? defaultConfirmRemoval;
|
|
14
|
-
const confirmForceRemoveWorktree = dependencies.confirmForceRemoveWorktree ??
|
|
14
|
+
const confirmForceRemoveWorktree = dependencies.confirmForceRemoveWorktree ??
|
|
15
|
+
defaultConfirmForceRemoveWorktree;
|
|
15
16
|
const confirmForceDeleteBranch = dependencies.confirmForceDeleteBranch ?? defaultConfirmForceDeleteBranch;
|
|
16
17
|
return async function runRemoveCommand(options) {
|
|
17
18
|
const { linkedWorktrees, repository } = await loadLinkedWorktrees(options.cwd);
|
|
18
19
|
if (linkedWorktrees.length === 0) {
|
|
19
|
-
emitError(options,
|
|
20
|
+
emitError(options, "No linked worktrees to finish");
|
|
20
21
|
return 1;
|
|
21
22
|
}
|
|
22
23
|
if (!options.branch && (options.json || isHeadless())) {
|
|
23
|
-
const message =
|
|
24
|
+
const message = "branch argument is required";
|
|
24
25
|
if (options.json) {
|
|
25
26
|
emitError(options, message);
|
|
26
27
|
}
|
|
@@ -29,9 +30,10 @@ export function createRemoveCommand(dependencies = {}) {
|
|
|
29
30
|
}
|
|
30
31
|
return 1;
|
|
31
32
|
}
|
|
32
|
-
const selection = options.branch ??
|
|
33
|
+
const selection = options.branch ??
|
|
34
|
+
(await promptForWorktree(sortByCurrentFirst(linkedWorktrees)));
|
|
33
35
|
if (!selection) {
|
|
34
|
-
options.stderr(
|
|
36
|
+
options.stderr("Aborted\n");
|
|
35
37
|
return 1;
|
|
36
38
|
}
|
|
37
39
|
const worktree = linkedWorktrees.find((entry) => entry.branch === selection || entry.path === selection);
|
|
@@ -40,7 +42,7 @@ export function createRemoveCommand(dependencies = {}) {
|
|
|
40
42
|
return 1;
|
|
41
43
|
}
|
|
42
44
|
if (!options.dryRun && !options.force && (options.json || isHeadless())) {
|
|
43
|
-
const message =
|
|
45
|
+
const message = "--force is required";
|
|
44
46
|
if (options.json) {
|
|
45
47
|
emitError(options, message);
|
|
46
48
|
}
|
|
@@ -49,8 +51,10 @@ export function createRemoveCommand(dependencies = {}) {
|
|
|
49
51
|
}
|
|
50
52
|
return 1;
|
|
51
53
|
}
|
|
52
|
-
if (!options.dryRun &&
|
|
53
|
-
options.
|
|
54
|
+
if (!options.dryRun &&
|
|
55
|
+
!options.force &&
|
|
56
|
+
!(await confirmRemoval(worktree))) {
|
|
57
|
+
options.stderr("Aborted\n");
|
|
54
58
|
return 1;
|
|
55
59
|
}
|
|
56
60
|
if (options.dryRun) {
|
|
@@ -58,14 +62,20 @@ export function createRemoveCommand(dependencies = {}) {
|
|
|
58
62
|
options.stdout(`${JSON.stringify({ branch: worktree.branch, path: worktree.path, dryRun: true }, null, 2)}\n`);
|
|
59
63
|
}
|
|
60
64
|
else {
|
|
61
|
-
const desc = worktree.branch
|
|
65
|
+
const desc = worktree.branch
|
|
66
|
+
? `branch: ${worktree.branch}`
|
|
67
|
+
: "detached";
|
|
62
68
|
options.stdout(`Would remove worktree at ${worktree.path} (${desc})\n`);
|
|
63
69
|
}
|
|
64
70
|
return 0;
|
|
65
71
|
}
|
|
66
72
|
const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
|
|
67
73
|
const hooks = extractHooks(config);
|
|
68
|
-
await runHook(hooks.beforeRemove, worktree.path, {
|
|
74
|
+
await runHook(hooks.beforeRemove, worktree.path, {
|
|
75
|
+
branch: worktree.branch ?? undefined,
|
|
76
|
+
path: worktree.path,
|
|
77
|
+
repo: basename(repository.repoRoot),
|
|
78
|
+
}, options.stderr);
|
|
69
79
|
try {
|
|
70
80
|
await removeWorktree(repository.repoRoot, worktree.path);
|
|
71
81
|
}
|
|
@@ -73,8 +83,9 @@ export function createRemoveCommand(dependencies = {}) {
|
|
|
73
83
|
if (!isWorktreeDirtyError(error)) {
|
|
74
84
|
throw error;
|
|
75
85
|
}
|
|
76
|
-
if (!options.force &&
|
|
77
|
-
|
|
86
|
+
if (!options.force &&
|
|
87
|
+
!(await confirmForceRemoveWorktree(worktree.path))) {
|
|
88
|
+
options.stderr("Aborted\n");
|
|
78
89
|
return 1;
|
|
79
90
|
}
|
|
80
91
|
try {
|
|
@@ -93,7 +104,8 @@ export function createRemoveCommand(dependencies = {}) {
|
|
|
93
104
|
if (!isBranchUnmergedError(error)) {
|
|
94
105
|
throw error;
|
|
95
106
|
}
|
|
96
|
-
if (options.force ||
|
|
107
|
+
if (options.force ||
|
|
108
|
+
(await confirmForceDeleteBranch(worktree.branch))) {
|
|
97
109
|
try {
|
|
98
110
|
await forceDeleteBranch(repository.repoRoot, worktree.branch);
|
|
99
111
|
}
|
|
@@ -118,10 +130,10 @@ export function createRemoveCommand(dependencies = {}) {
|
|
|
118
130
|
export const runRemoveCommand = createRemoveCommand();
|
|
119
131
|
async function defaultPromptForWorktree(worktrees) {
|
|
120
132
|
const choice = await select({
|
|
121
|
-
message:
|
|
133
|
+
message: "Choose a worktree to finish",
|
|
122
134
|
options: worktrees.map((worktree) => ({
|
|
123
135
|
hint: worktree.isCurrent ? `${worktree.path} (current)` : worktree.path,
|
|
124
|
-
label: worktree.branch ??
|
|
136
|
+
label: worktree.branch ?? "(detached)",
|
|
125
137
|
value: worktree.path,
|
|
126
138
|
})),
|
|
127
139
|
});
|
|
@@ -132,8 +144,8 @@ async function defaultConfirmRemoval(worktree) {
|
|
|
132
144
|
message: worktree.branch
|
|
133
145
|
? `Remove worktree and delete branch ${worktree.branch}?`
|
|
134
146
|
: `Remove detached worktree ${worktree.path}?`,
|
|
135
|
-
active:
|
|
136
|
-
inactive:
|
|
147
|
+
active: "Yes",
|
|
148
|
+
inactive: "No",
|
|
137
149
|
initialValue: true,
|
|
138
150
|
});
|
|
139
151
|
return !isCancel(choice) && choice;
|
package/dist/repo-registry.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from
|
|
2
|
-
import { homedir } from
|
|
3
|
-
import { basename, dirname, join, resolve } from
|
|
4
|
-
import { GLOBAL_CONFIG_DIRECTORY } from
|
|
5
|
-
const REGISTRY_FILE_NAME =
|
|
1
|
+
import { mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { GLOBAL_CONFIG_DIRECTORY } from "./config.js";
|
|
5
|
+
const REGISTRY_FILE_NAME = "repos.json";
|
|
6
6
|
const MAX_REGISTRY_ENTRIES = 100;
|
|
7
7
|
export function REGISTRY_FILE_PATH(home = homedir()) {
|
|
8
8
|
const configDir = process.env.GJI_CONFIG_DIR;
|
|
@@ -14,7 +14,7 @@ export function REGISTRY_FILE_PATH(home = homedir()) {
|
|
|
14
14
|
export async function loadRegistry(home = homedir()) {
|
|
15
15
|
const path = REGISTRY_FILE_PATH(home);
|
|
16
16
|
try {
|
|
17
|
-
const raw = await readFile(path,
|
|
17
|
+
const raw = await readFile(path, "utf8");
|
|
18
18
|
const parsed = JSON.parse(raw);
|
|
19
19
|
if (!Array.isArray(parsed))
|
|
20
20
|
return [];
|
|
@@ -24,29 +24,55 @@ export async function loadRegistry(home = homedir()) {
|
|
|
24
24
|
return [];
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
+
async function canonicalizeRepoPath(repoPath) {
|
|
28
|
+
try {
|
|
29
|
+
return await realpath(repoPath);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return resolve(repoPath);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
27
35
|
export async function registerRepo(repoPath, home = homedir()) {
|
|
28
36
|
const registryPath = REGISTRY_FILE_PATH(home);
|
|
29
|
-
const existing = await loadRegistry(home);
|
|
37
|
+
const existing = await normalizeRegistryForWrite(await loadRegistry(home));
|
|
38
|
+
const canonicalRepoPath = await canonicalizeRepoPath(repoPath);
|
|
30
39
|
// Skip write if this repo is already the most-recently-used entry (common case).
|
|
31
|
-
if (existing.length > 0 && existing[0].path ===
|
|
40
|
+
if (existing.length > 0 && existing[0].path === canonicalRepoPath)
|
|
32
41
|
return;
|
|
33
42
|
const entry = {
|
|
34
43
|
lastUsed: Date.now(),
|
|
35
|
-
name: basename(
|
|
36
|
-
path:
|
|
44
|
+
name: basename(canonicalRepoPath),
|
|
45
|
+
path: canonicalRepoPath,
|
|
37
46
|
};
|
|
38
|
-
const filtered = existing.filter((e) => e.path !==
|
|
47
|
+
const filtered = existing.filter((e) => e.path !== canonicalRepoPath);
|
|
39
48
|
const next = [entry, ...filtered].slice(0, MAX_REGISTRY_ENTRIES);
|
|
40
49
|
await mkdir(dirname(registryPath), { recursive: true });
|
|
41
|
-
await writeFile(registryPath, `${JSON.stringify(next, null, 2)}\n`,
|
|
50
|
+
await writeFile(registryPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
51
|
+
}
|
|
52
|
+
async function normalizeRegistryForWrite(entries) {
|
|
53
|
+
const normalized = [];
|
|
54
|
+
const seenPaths = new Set();
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
const canonicalPath = await canonicalizeRepoPath(entry.path);
|
|
57
|
+
if (seenPaths.has(canonicalPath)) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
seenPaths.add(canonicalPath);
|
|
61
|
+
normalized.push({
|
|
62
|
+
...entry,
|
|
63
|
+
name: basename(canonicalPath),
|
|
64
|
+
path: canonicalPath,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return normalized;
|
|
42
68
|
}
|
|
43
69
|
function isRegistryEntry(value) {
|
|
44
|
-
return (typeof value ===
|
|
70
|
+
return (typeof value === "object" &&
|
|
45
71
|
value !== null &&
|
|
46
|
-
|
|
47
|
-
typeof value.path ===
|
|
48
|
-
|
|
49
|
-
typeof value.name ===
|
|
50
|
-
|
|
51
|
-
typeof value.lastUsed ===
|
|
72
|
+
"path" in value &&
|
|
73
|
+
typeof value.path === "string" &&
|
|
74
|
+
"name" in value &&
|
|
75
|
+
typeof value.name === "string" &&
|
|
76
|
+
"lastUsed" in value &&
|
|
77
|
+
typeof value.lastUsed === "number");
|
|
52
78
|
}
|
package/dist/repo.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { runGit } from
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { runGit } from "./git.js";
|
|
4
4
|
export async function detectRepository(cwd) {
|
|
5
|
-
const currentRoot = await runGit(cwd, [
|
|
6
|
-
const rawCommonDir = await runGit(cwd, [
|
|
5
|
+
const currentRoot = await runGit(cwd, ["rev-parse", "--show-toplevel"]);
|
|
6
|
+
const rawCommonDir = await runGit(cwd, ["rev-parse", "--git-common-dir"]);
|
|
7
7
|
const gitCommonDir = isAbsolute(rawCommonDir)
|
|
8
8
|
? rawCommonDir
|
|
9
9
|
: resolve(currentRoot, rawCommonDir);
|
|
@@ -17,71 +17,72 @@ export async function detectRepository(cwd) {
|
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
19
|
export function resolveWorktreePath(repoRoot, branch, basePath) {
|
|
20
|
-
const segments = branch.split(
|
|
20
|
+
const segments = branch.split("/").filter(Boolean);
|
|
21
21
|
if (segments.length === 0) {
|
|
22
|
-
throw new Error(
|
|
22
|
+
throw new Error("Branch name must not be empty.");
|
|
23
23
|
}
|
|
24
|
-
if (segments.some((segment) => segment ===
|
|
24
|
+
if (segments.some((segment) => segment === "." || segment === "..")) {
|
|
25
25
|
throw new Error(`Branch name '${branch}' contains an invalid path segment.`);
|
|
26
26
|
}
|
|
27
27
|
const base = basePath
|
|
28
28
|
? expandTildeInPath(basePath)
|
|
29
|
-
: join(dirname(repoRoot),
|
|
29
|
+
: join(dirname(repoRoot), "worktrees", basename(repoRoot));
|
|
30
30
|
return join(base, ...segments);
|
|
31
31
|
}
|
|
32
32
|
export function validateBranchName(name) {
|
|
33
33
|
if (name.length === 0) {
|
|
34
|
-
return
|
|
34
|
+
return "Branch name must not be empty.";
|
|
35
35
|
}
|
|
36
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional control chars to reject in git branch names
|
|
36
37
|
if (/[\x00-\x1f\x7f ~^:?*[\\\s]/.test(name)) {
|
|
37
38
|
return `Branch name '${name}' contains an invalid character.`;
|
|
38
39
|
}
|
|
39
|
-
if (name.startsWith(
|
|
40
|
+
if (name.startsWith("-")) {
|
|
40
41
|
return `Branch name '${name}' must not start with a dash.`;
|
|
41
42
|
}
|
|
42
|
-
if (name.startsWith(
|
|
43
|
+
if (name.startsWith("/") || name.endsWith("/") || name.includes("//")) {
|
|
43
44
|
return `Branch name '${name}' has invalid slash placement.`;
|
|
44
45
|
}
|
|
45
|
-
if (name.includes(
|
|
46
|
+
if (name.includes("..")) {
|
|
46
47
|
return `Branch name '${name}' must not contain '..'.`;
|
|
47
48
|
}
|
|
48
|
-
if (name.endsWith(
|
|
49
|
+
if (name.endsWith(".")) {
|
|
49
50
|
return `Branch name '${name}' must not end with '.'.`;
|
|
50
51
|
}
|
|
51
|
-
if (name.includes(
|
|
52
|
+
if (name.includes("@{")) {
|
|
52
53
|
return `Branch name '${name}' must not contain '@{'.`;
|
|
53
54
|
}
|
|
54
|
-
if (name ===
|
|
55
|
+
if (name === "@") {
|
|
55
56
|
return "Branch name cannot be '@'.";
|
|
56
57
|
}
|
|
57
|
-
for (const segment of name.split(
|
|
58
|
-
if (segment.startsWith(
|
|
58
|
+
for (const segment of name.split("/")) {
|
|
59
|
+
if (segment.startsWith(".")) {
|
|
59
60
|
return `Branch name '${name}' contains a path component starting with '.'.`;
|
|
60
61
|
}
|
|
61
|
-
if (segment.endsWith(
|
|
62
|
+
if (segment.endsWith(".lock")) {
|
|
62
63
|
return `Branch name '${name}' contains a path component ending with '.lock'.`;
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
return null;
|
|
66
67
|
}
|
|
67
68
|
function expandTildeInPath(p) {
|
|
68
|
-
if (p ===
|
|
69
|
+
if (p === "~")
|
|
69
70
|
return homedir();
|
|
70
|
-
if (p.startsWith(
|
|
71
|
+
if (p.startsWith("~/"))
|
|
71
72
|
return join(homedir(), p.slice(2));
|
|
72
73
|
return p;
|
|
73
74
|
}
|
|
74
75
|
export async function listWorktrees(cwd) {
|
|
75
76
|
const [output, currentRoot] = await Promise.all([
|
|
76
|
-
runGit(cwd, [
|
|
77
|
-
runGit(cwd, [
|
|
77
|
+
runGit(cwd, ["worktree", "list", "--porcelain"]),
|
|
78
|
+
runGit(cwd, ["rev-parse", "--show-toplevel"]),
|
|
78
79
|
]);
|
|
79
|
-
const entries = output.split(
|
|
80
|
+
const entries = output.split("\n\n").filter(Boolean);
|
|
80
81
|
return entries.map((entry) => {
|
|
81
|
-
const path = findPorcelainValue(entry,
|
|
82
|
-
const branchRef = findOptionalPorcelainValue(entry,
|
|
82
|
+
const path = findPorcelainValue(entry, "worktree");
|
|
83
|
+
const branchRef = findOptionalPorcelainValue(entry, "branch");
|
|
83
84
|
return {
|
|
84
|
-
branch: branchRef ? branchRef.replace(
|
|
85
|
+
branch: branchRef ? branchRef.replace("refs/heads/", "") : null,
|
|
85
86
|
isCurrent: path === currentRoot,
|
|
86
87
|
path,
|
|
87
88
|
};
|
|
@@ -105,7 +106,7 @@ function findPorcelainValue(block, key) {
|
|
|
105
106
|
}
|
|
106
107
|
function findOptionalPorcelainValue(block, key) {
|
|
107
108
|
const line = block
|
|
108
|
-
.split(
|
|
109
|
+
.split("\n")
|
|
109
110
|
.find((candidate) => candidate.startsWith(`${key} `));
|
|
110
111
|
if (!line) {
|
|
111
112
|
return null;
|
package/dist/root.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { detectRepository } from
|
|
2
|
-
import { writeShellOutput } from
|
|
3
|
-
const ROOT_OUTPUT_FILE_ENV =
|
|
1
|
+
import { detectRepository } from "./repo.js";
|
|
2
|
+
import { writeShellOutput } from "./shell-handoff.js";
|
|
3
|
+
const ROOT_OUTPUT_FILE_ENV = "GJI_ROOT_OUTPUT_FILE";
|
|
4
4
|
export async function runRootCommand(options) {
|
|
5
5
|
const repository = await detectRepository(options.cwd);
|
|
6
6
|
if (!options.print && process.env[ROOT_OUTPUT_FILE_ENV]) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function renderShellCompletion(shell:
|
|
1
|
+
export declare function renderShellCompletion(shell: "bash" | "fish" | "zsh"): string;
|