@n-seiji/nuthatch 0.1.5 → 0.1.7

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.
@@ -0,0 +1,208 @@
1
+ import {
2
+ messageOf
3
+ } from "./cli-qrxgkskr.js";
4
+ import {
5
+ upstreamStateOf
6
+ } from "./cli-pqd2qe7v.js";
7
+ import {
8
+ withRepoLock
9
+ } from "./cli-rcw5adqd.js";
10
+ import {
11
+ loadRepoContext,
12
+ nestedWorktrees,
13
+ worktreeDirtyState
14
+ } from "./cli-7jg5qqs9.js";
15
+ import {
16
+ EXIT_USAGE_ERROR,
17
+ ok,
18
+ fail
19
+ } from "./cli-2bhb9p0x.js";
20
+
21
+ // src/domain/garbage.ts
22
+ var classifyGarbage = (input) => {
23
+ if (input.prunable) {
24
+ return "prunable";
25
+ }
26
+ if (!input.clean) {
27
+ return null;
28
+ }
29
+ if (input.mergedIntoDefault === true) {
30
+ return "merged";
31
+ }
32
+ if (input.upstreamGone && input.allCommitsReachableFromDefault === true) {
33
+ return "gone";
34
+ }
35
+ return null;
36
+ };
37
+
38
+ // src/commands/clean-candidates.ts
39
+ var buildCleanCandidates = async (git, fs, context, ext) => {
40
+ const [defaultRef, upstreams] = await Promise.all([
41
+ git.resolveDefaultBranchRef(context.rootPath),
42
+ git.upstreamStates(context.rootPath)
43
+ ]);
44
+ const targets = context.worktrees.filter((wt) => wt.kind === "managed" || ext && wt.kind === "external");
45
+ const results = await Promise.all(targets.map(async (wt) => {
46
+ const reason = await classifyWorktree(wt, { git, fs, defaultRef, upstreams, context });
47
+ if (reason === null) {
48
+ return null;
49
+ }
50
+ return { branch: wt.branch ?? "", path: wt.path, reason };
51
+ }));
52
+ return results.filter((candidate) => candidate !== null);
53
+ };
54
+ var classifyWorktree = async (wt, { git, fs, defaultRef, upstreams, context }) => {
55
+ if (wt.prunable) {
56
+ return classifyGarbage({
57
+ prunable: true,
58
+ clean: false,
59
+ mergedIntoDefault: "unknown",
60
+ upstreamGone: false,
61
+ allCommitsReachableFromDefault: "unknown"
62
+ });
63
+ }
64
+ if (wt.branch === null) {
65
+ return null;
66
+ }
67
+ const { rootPath, worktrees } = context;
68
+ const upstreamGone = upstreamStateOf(upstreams, wt.branch).kind === "gone";
69
+ const mergedIntoDefault = defaultRef === null ? "unknown" : await git.isAncestor(rootPath, wt.branch, defaultRef);
70
+ const allCommitsReachableFromDefault = defaultRef === null || mergedIntoDefault === true || !upstreamGone ? "unknown" : await git.hasEquivalentCommits(rootPath, wt.branch, defaultRef);
71
+ const facts = {
72
+ prunable: false,
73
+ mergedIntoDefault,
74
+ upstreamGone,
75
+ allCommitsReachableFromDefault
76
+ };
77
+ if (classifyGarbage({ ...facts, clean: true }) === null) {
78
+ return null;
79
+ }
80
+ const dirtyState = await worktreeDirtyState(git, fs, worktrees, wt);
81
+ const input = { ...facts, clean: dirtyState === false };
82
+ return classifyGarbage(input);
83
+ };
84
+
85
+ // src/commands/clean.ts
86
+ var clean = async (git, fs, term, options) => {
87
+ const context = await loadRepoContext(git, fs, options.cwd);
88
+ const candidates = await buildCleanCandidates(git, fs, context, options.ext);
89
+ if (options.dryRun) {
90
+ return ok({ data: { candidates } });
91
+ }
92
+ if (candidates.length === 0) {
93
+ return ok({ data: { candidates, removed: [] } });
94
+ }
95
+ if (!options.yes) {
96
+ if (!term.isTTY()) {
97
+ return fail(EXIT_USAGE_ERROR, "hop clean would remove worktrees; re-run with --yes (non-interactive) or --dry-run to only list candidates.");
98
+ }
99
+ for (const candidate of candidates) {
100
+ term.logStderr(`${candidate.reason} ${candidate.branch} ${candidate.path}`);
101
+ }
102
+ const confirmed = await term.confirm(`Remove ${candidates.length} worktree(s) above?`);
103
+ if (!confirmed) {
104
+ return ok({ data: { candidates, removed: [] } });
105
+ }
106
+ }
107
+ return executeClean({
108
+ git,
109
+ fs,
110
+ context,
111
+ candidates,
112
+ cleanOptions: options
113
+ });
114
+ };
115
+ var removeCandidate = async ({
116
+ git,
117
+ rootPath,
118
+ candidate,
119
+ deleteBranch,
120
+ worktrees
121
+ }) => {
122
+ if (nestedWorktrees(worktrees, candidate.path).length > 0) {
123
+ return "nested";
124
+ }
125
+ try {
126
+ await git.removeWorktree(rootPath, candidate.path, false);
127
+ if (deleteBranch && candidate.branch.length > 0) {
128
+ await git.deleteBranch(rootPath, candidate.branch);
129
+ }
130
+ return "removed";
131
+ } catch {
132
+ return "failed";
133
+ }
134
+ };
135
+ var canDeletePrunableBranch = async (git, rootPath, candidate, { defaultRef, upstreams }) => {
136
+ if (candidate.reason !== "prunable") {
137
+ return true;
138
+ }
139
+ if (candidate.branch.length === 0 || defaultRef === null) {
140
+ return false;
141
+ }
142
+ try {
143
+ const mergedIntoDefault = await git.isAncestor(rootPath, candidate.branch, defaultRef);
144
+ if (mergedIntoDefault === true) {
145
+ return true;
146
+ }
147
+ if (upstreamStateOf(upstreams, candidate.branch).kind === "gone") {
148
+ return await git.hasEquivalentCommits(rootPath, candidate.branch, defaultRef) === true;
149
+ }
150
+ } catch {}
151
+ return false;
152
+ };
153
+ var executeClean = ({
154
+ git,
155
+ fs,
156
+ context,
157
+ candidates,
158
+ cleanOptions
159
+ }) => withRepoLock(context.commonDir, async () => {
160
+ try {
161
+ const fresh = await loadRepoContext(git, fs, context.rootPath);
162
+ const freshCandidates = await buildCleanCandidates(git, fs, fresh, cleanOptions.ext);
163
+ const freshByPath = new Map(freshCandidates.map((candidate) => [candidate.path, candidate]));
164
+ const stillValid = candidates.flatMap((candidate) => {
165
+ const freshCandidate = freshByPath.get(candidate.path);
166
+ return freshCandidate === undefined ? [] : [freshCandidate];
167
+ });
168
+ const [defaultRef, upstreams] = await Promise.all([
169
+ git.resolveDefaultBranchRef(fresh.rootPath),
170
+ git.upstreamStates(fresh.rootPath)
171
+ ]);
172
+ const removed = [];
173
+ const warnings = [];
174
+ for (const candidate of stillValid) {
175
+ const branchSafe = await canDeletePrunableBranch(git, fresh.rootPath, candidate, {
176
+ defaultRef,
177
+ upstreams
178
+ });
179
+ const deleteBranch = cleanOptions.withBranch && branchSafe;
180
+ if (cleanOptions.withBranch && candidate.reason === "prunable" && candidate.branch.length > 0 && !branchSafe) {
181
+ warnings.push(`Keeping branch "${candidate.branch}": prunable worktree is not confirmed merged or gone.`);
182
+ }
183
+ const outcome = await removeCandidate({
184
+ git,
185
+ rootPath: fresh.rootPath,
186
+ candidate,
187
+ deleteBranch,
188
+ worktrees: fresh.worktrees
189
+ });
190
+ if (outcome === "removed") {
191
+ removed.push(candidate.branch);
192
+ } else if (outcome === "nested") {
193
+ warnings.push(`Skipped ${candidate.path}: still contains a registered worktree.`);
194
+ }
195
+ }
196
+ return ok({
197
+ data: { candidates: [...candidates], removed },
198
+ ...warnings.length === 0 ? {} : { warnings }
199
+ });
200
+ } catch (error) {
201
+ throw new Error(`Failed to clean worktrees: ${messageOf(error)}`, {
202
+ cause: error
203
+ });
204
+ }
205
+ });
206
+ export {
207
+ clean
208
+ };
@@ -0,0 +1,116 @@
1
+ import {
2
+ messageOf
3
+ } from "./cli-qrxgkskr.js";
4
+ import {
5
+ withRepoLock
6
+ } from "./cli-rcw5adqd.js";
7
+ import {
8
+ loadRepoContext,
9
+ nestedWorktrees,
10
+ worktreeDirtyState
11
+ } from "./cli-7jg5qqs9.js";
12
+ import {
13
+ EXIT_GENERAL_ERROR,
14
+ EXIT_USAGE_ERROR,
15
+ EXIT_SAFE_REJECTION,
16
+ ok,
17
+ fail
18
+ } from "./cli-2bhb9p0x.js";
19
+
20
+ // src/commands/rm.ts
21
+ var EXT_DEPRECATION_WARNING = "--ext is deprecated and no longer required: hop rm can now remove external worktrees without it.";
22
+ var lockedRejection = (branch, lockReason) => fail(EXIT_SAFE_REJECTION, `Worktree for "${branch}" is locked by git${lockReason === null ? "" : ` (${lockReason})`}. hop never unlocks worktrees automatically — run "git worktree unlock" yourself first if you're sure.`);
23
+ var turnedPrunableRejection = (branch) => fail(EXIT_SAFE_REJECTION, `The selected worktree for "${branch}" changed before removal (git now reports it prunable). Refresh the picker and retry.`);
24
+ var stalePrunableWarning = ({ path, prunableReason }) => `Worktree at ${path} no longer exists${prunableReason === null ? "" : ` (${prunableReason})`}; removed its stale registration — if it had been moved by hand, the moved copy is no longer linked to this repository.`;
25
+ var resolveTarget = (worktrees, branch, expectedPath) => {
26
+ const matches = worktrees.filter((wt) => wt.branch === branch);
27
+ if (expectedPath !== undefined) {
28
+ const target = matches.find((wt) => wt.path === expectedPath);
29
+ return target === undefined ? {
30
+ rejection: fail(EXIT_SAFE_REJECTION, `The selected worktree for "${branch}" changed before removal. Refresh the picker and retry.`)
31
+ } : { target };
32
+ }
33
+ if (matches.length > 1) {
34
+ return {
35
+ rejection: fail(EXIT_SAFE_REJECTION, `Branch "${branch}" is checked out at ${matches.length} worktrees (${matches.map((wt) => wt.path).join(", ")}). Refusing to guess which one to remove.`)
36
+ };
37
+ }
38
+ const [target] = matches;
39
+ return target === undefined ? {} : { target };
40
+ };
41
+ var nestedWorktreeRejection = (branch, targetPath, worktrees) => {
42
+ const nested = nestedWorktrees(worktrees, targetPath);
43
+ if (nested.length === 0) {
44
+ return null;
45
+ }
46
+ const listing = nested.map((wt) => ` ${wt.path}${wt.branch === null ? "" : ` (${wt.branch})`}`).join(`
47
+ `);
48
+ return fail(EXIT_SAFE_REJECTION, `Worktree for "${branch}" still contains ${nested.length} registered worktree(s) — refusing to remove it, since that would destroy their files too. Remove these first, then retry:
49
+ ${listing}`);
50
+ };
51
+ var targetSafetyRejection = async ({ git, fs, context, options }, target) => {
52
+ const nestedRejection = nestedWorktreeRejection(options.branch, target.path, context.worktrees);
53
+ if (nestedRejection !== null) {
54
+ return nestedRejection;
55
+ }
56
+ if (target.locked) {
57
+ return lockedRejection(options.branch, target.lockReason);
58
+ }
59
+ if (target.prunable && options.allowPrunable === false) {
60
+ return turnedPrunableRejection(options.branch);
61
+ }
62
+ if (!options.force && await worktreeDirtyState(git, fs, context.worktrees, target) === true) {
63
+ return fail(EXIT_SAFE_REJECTION, `Worktree for "${options.branch}" has uncommitted or untracked changes. Use --force to remove anyway.`);
64
+ }
65
+ return null;
66
+ };
67
+ var validateRemoval = async (check) => {
68
+ const { context, options } = check;
69
+ const resolved = resolveTarget(context.worktrees, options.branch, options.expectedPath);
70
+ if (resolved.rejection !== undefined) {
71
+ return { ok: false, rejection: resolved.rejection };
72
+ }
73
+ const { target } = resolved;
74
+ if (target === undefined) {
75
+ return {
76
+ ok: false,
77
+ rejection: fail(EXIT_GENERAL_ERROR, `No worktree found for branch "${options.branch}".`)
78
+ };
79
+ }
80
+ if (target.kind === "root") {
81
+ return { ok: false, rejection: fail(EXIT_USAGE_ERROR, "Cannot remove the root clone.") };
82
+ }
83
+ const rejection = await targetSafetyRejection(check, target);
84
+ return rejection === null ? { ok: true, target } : { ok: false, rejection };
85
+ };
86
+ var withExtWarning = (ext, result) => ext ? {
87
+ ...result,
88
+ warnings: [...result.warnings ?? [], EXT_DEPRECATION_WARNING]
89
+ } : result;
90
+ var removeWorktree = async (git, fs, options) => {
91
+ const context = await loadRepoContext(git, fs, options.cwd);
92
+ const validation = await validateRemoval({ git, fs, context, options });
93
+ if (!validation.ok) {
94
+ return validation.rejection;
95
+ }
96
+ return withRepoLock(context.commonDir, async () => {
97
+ try {
98
+ const fresh = await loadRepoContext(git, fs, options.cwd);
99
+ const freshValidation = await validateRemoval({ git, fs, context: fresh, options });
100
+ if (!freshValidation.ok) {
101
+ return freshValidation.rejection;
102
+ }
103
+ const { target } = freshValidation;
104
+ await git.removeWorktree(context.rootPath, target.path, options.force);
105
+ return ok({
106
+ data: { branch: options.branch, path: target.path },
107
+ ...target.prunable ? { warnings: [stalePrunableWarning(target)] } : {}
108
+ });
109
+ } catch (error) {
110
+ return fail(EXIT_SAFE_REJECTION, `Failed to remove worktree: ${messageOf(error)}`);
111
+ }
112
+ });
113
+ };
114
+ var rm2 = async (git, fs, options) => withExtWarning(options.ext, await removeWorktree(git, fs, options));
115
+
116
+ export { rm2 };
@@ -0,0 +1,19 @@
1
+ // src/domain/result.ts
2
+ var EXIT_SUCCESS = 0;
3
+ var EXIT_GENERAL_ERROR = 1;
4
+ var EXIT_USAGE_ERROR = 2;
5
+ var EXIT_SAFE_REJECTION = 3;
6
+ var EXIT_CANCELLED = 130;
7
+ var ok = (fields = {}) => ({
8
+ ok: true,
9
+ exitCode: EXIT_SUCCESS,
10
+ ...fields
11
+ });
12
+ var fail = (exitCode, errorMessage, warnings) => ({
13
+ ok: false,
14
+ exitCode,
15
+ errorMessage,
16
+ ...warnings === undefined || warnings.length === 0 ? {} : { warnings }
17
+ });
18
+
19
+ export { EXIT_SUCCESS, EXIT_GENERAL_ERROR, EXIT_USAGE_ERROR, EXIT_SAFE_REJECTION, EXIT_CANCELLED, ok, fail };
@@ -0,0 +1,114 @@
1
+ import {
2
+ describeFatalError
3
+ } from "./cli-qrxgkskr.js";
4
+ import {
5
+ reportResult
6
+ } from "./cli-dbzx9nzg.js";
7
+ import {
8
+ fail
9
+ } from "./cli-2bhb9p0x.js";
10
+
11
+ // src/cli-dispatch.ts
12
+ var normalizeCliArgs = (rawArgs, argv0, isCompiledBinary) => isCompiledBinary && rawArgs.length === 1 && rawArgs[0] === argv0 ? [] : rawArgs;
13
+ var isRunningAsCompiledBinary = () => typeof Bun !== "undefined" && Bun.main.startsWith("/$bunfs/");
14
+ var isUpdateRequest = (rawArgs) => rawArgs[0] === "--update";
15
+ var HELP_FLAGS = new Set(["--help", "-h", "help"]);
16
+ var UPDATE_HELP_FLAGS = new Set(["--help", "-h"]);
17
+ var isHelpRequest = (rawArgs) => rawArgs.length > 0 && (HELP_FLAGS.has(rawArgs[0] ?? "") || isUpdateRequest(rawArgs) && rawArgs.slice(1).some((token) => UPDATE_HELP_FLAGS.has(token)));
18
+ var isVersionRequest = (rawArgs) => rawArgs[0] === "--version";
19
+ var dispatchCliArgs = (rawArgs, reservedNames) => {
20
+ const [first, ...rest] = rawArgs;
21
+ if (first === "--") {
22
+ return { kind: "jump", args: rest };
23
+ }
24
+ if (first !== undefined && reservedNames.includes(first)) {
25
+ return { kind: "reserved", name: first, args: rest };
26
+ }
27
+ return { kind: "jump", args: rawArgs };
28
+ };
29
+
30
+ // src/cli-fatal.ts
31
+ var wantsJson = (args) => args.some((arg) => arg === "--json" || arg.startsWith("--json="));
32
+ var reportFatalError = (command, json, error) => {
33
+ const fatal = describeFatalError(error);
34
+ const result = fail(fatal.exitCode, `hop: ${fatal.message}`);
35
+ reportResult(command, result, json);
36
+ };
37
+ // package.json
38
+ var package_default = {
39
+ name: "@n-seiji/nuthatch",
40
+ version: "0.1.7",
41
+ description: "git worktree manager (hop)",
42
+ homepage: "https://github.com/n-seiji/nuthatch#readme",
43
+ bugs: {
44
+ url: "https://github.com/n-seiji/nuthatch/issues"
45
+ },
46
+ license: "GPL-3.0-only",
47
+ repository: {
48
+ type: "git",
49
+ url: "git+https://github.com/n-seiji/nuthatch.git"
50
+ },
51
+ bin: {
52
+ hop: "dist/cli.js"
53
+ },
54
+ files: [
55
+ "dist",
56
+ "shell"
57
+ ],
58
+ type: "module",
59
+ publishConfig: {
60
+ access: "public"
61
+ },
62
+ scripts: {
63
+ build: "bun build ./src/cli.ts --outdir dist --target node --format esm --splitting",
64
+ typecheck: "tsc --noEmit",
65
+ lint: "oxlint .",
66
+ "lint:fix": "oxlint . --fix",
67
+ format: "oxfmt --write .",
68
+ "format:check": "oxfmt --check .",
69
+ test: "bun test"
70
+ },
71
+ dependencies: {
72
+ valibot: "^1.4.2"
73
+ },
74
+ devDependencies: {
75
+ "@types/bun": "latest",
76
+ oxfmt: "^0.66.0",
77
+ oxlint: "^1.81.0",
78
+ typescript: "^5.6.0"
79
+ },
80
+ engines: {
81
+ node: ">=22"
82
+ }
83
+ };
84
+
85
+ // src/version.ts
86
+ var VERSION = package_default.version;
87
+
88
+ // src/infra/executable.ts
89
+ import { constants } from "node:fs";
90
+ import { access, stat } from "node:fs/promises";
91
+ var isExecutableFile = async (path) => {
92
+ try {
93
+ const stats = await stat(path);
94
+ if (!stats.isFile()) {
95
+ return false;
96
+ }
97
+ await access(path, constants.X_OK);
98
+ return true;
99
+ } catch {
100
+ return false;
101
+ }
102
+ };
103
+ var firstExecutable = async (candidates) => {
104
+ const probes = candidates.map(async (candidate) => await isExecutableFile(candidate) ? candidate : null);
105
+ for (const probe of probes) {
106
+ const found = await probe;
107
+ if (found !== null) {
108
+ return found;
109
+ }
110
+ }
111
+ return null;
112
+ };
113
+
114
+ export { normalizeCliArgs, isRunningAsCompiledBinary, isUpdateRequest, isHelpRequest, isVersionRequest, dispatchCliArgs, wantsJson, reportFatalError, firstExecutable, VERSION };
@@ -0,0 +1,112 @@
1
+ // src/domain/display-width.ts
2
+ var WIDTH_ZERO = 0;
3
+ var WIDTH_NARROW = 1;
4
+ var WIDTH_WIDE = 2;
5
+ var segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
6
+ var ZERO_WIDTH_JOINER = 8205;
7
+ var VARIATION_SELECTOR_TEXT = 65038;
8
+ var VARIATION_SELECTOR_EMOJI = 65039;
9
+ var ZERO_WIDTH_CODE_POINTS = new Set([
10
+ ZERO_WIDTH_JOINER,
11
+ VARIATION_SELECTOR_TEXT,
12
+ VARIATION_SELECTOR_EMOJI
13
+ ]);
14
+ var isCombiningMark = (codePoint) => /\p{M}/u.test(String.fromCodePoint(codePoint));
15
+ var HANGUL_JAMO_START = 4352;
16
+ var HANGUL_JAMO_END = 4447;
17
+ var CJK_RADICALS_START = 11904;
18
+ var CJK_SYMBOLS_END = 12350;
19
+ var HIRAGANA_START = 12353;
20
+ var CJK_COMPATIBILITY_END = 13311;
21
+ var CJK_EXT_A_START = 13312;
22
+ var CJK_EXT_A_END = 19903;
23
+ var CJK_UNIFIED_START = 19968;
24
+ var CJK_UNIFIED_END = 40959;
25
+ var YI_SYLLABLES_START = 40960;
26
+ var YI_SYLLABLES_END = 42191;
27
+ var HANGUL_SYLLABLES_START = 44032;
28
+ var HANGUL_SYLLABLES_END = 55203;
29
+ var CJK_COMPAT_IDEOGRAPHS_START = 63744;
30
+ var CJK_COMPAT_IDEOGRAPHS_END = 64255;
31
+ var CJK_COMPAT_FORMS_START = 65072;
32
+ var CJK_COMPAT_FORMS_END = 65103;
33
+ var FULLWIDTH_FORMS_START = 65280;
34
+ var FULLWIDTH_FORMS_END = 65376;
35
+ var FULLWIDTH_SIGNS_START = 65504;
36
+ var FULLWIDTH_SIGNS_END = 65510;
37
+ var EMOJI_MISC_START = 127744;
38
+ var EMOJI_MISC_END = 128591;
39
+ var EMOJI_TRANSPORT_START = 128640;
40
+ var EMOJI_TRANSPORT_END = 129535;
41
+ var CJK_EXT_B_START = 131072;
42
+ var CJK_EXT_B_END = 262141;
43
+ var WIDE_RANGES = [
44
+ [HANGUL_JAMO_START, HANGUL_JAMO_END],
45
+ [CJK_RADICALS_START, CJK_SYMBOLS_END],
46
+ [HIRAGANA_START, CJK_COMPATIBILITY_END],
47
+ [CJK_EXT_A_START, CJK_EXT_A_END],
48
+ [CJK_UNIFIED_START, CJK_UNIFIED_END],
49
+ [YI_SYLLABLES_START, YI_SYLLABLES_END],
50
+ [HANGUL_SYLLABLES_START, HANGUL_SYLLABLES_END],
51
+ [CJK_COMPAT_IDEOGRAPHS_START, CJK_COMPAT_IDEOGRAPHS_END],
52
+ [CJK_COMPAT_FORMS_START, CJK_COMPAT_FORMS_END],
53
+ [FULLWIDTH_FORMS_START, FULLWIDTH_FORMS_END],
54
+ [FULLWIDTH_SIGNS_START, FULLWIDTH_SIGNS_END],
55
+ [EMOJI_MISC_START, EMOJI_MISC_END],
56
+ [EMOJI_TRANSPORT_START, EMOJI_TRANSPORT_END],
57
+ [CJK_EXT_B_START, CJK_EXT_B_END]
58
+ ];
59
+ var isWide = (codePoint) => WIDE_RANGES.some(([start, end]) => codePoint >= start && codePoint <= end);
60
+ var codePointWidth = (codePoint) => {
61
+ if (ZERO_WIDTH_CODE_POINTS.has(codePoint) || isCombiningMark(codePoint)) {
62
+ return WIDTH_ZERO;
63
+ }
64
+ return isWide(codePoint) ? WIDTH_WIDE : WIDTH_NARROW;
65
+ };
66
+ var graphemeWidth = (grapheme) => {
67
+ const codePoints = Array.from(grapheme, (char) => char.codePointAt(0)).filter((codePoint) => codePoint !== undefined);
68
+ return codePoints.reduce((max, codePoint) => Math.max(max, codePointWidth(codePoint)), WIDTH_ZERO);
69
+ };
70
+ var graphemes = (value) => Array.from(segmenter.segment(value), (entry) => entry.segment);
71
+ var displayWidth = (value) => graphemes(value).reduce((total, grapheme) => total + graphemeWidth(grapheme), WIDTH_ZERO);
72
+ var padToWidth = (value, width) => {
73
+ const current = displayWidth(value);
74
+ return current >= width ? value : value + " ".repeat(width - current);
75
+ };
76
+ var DEFAULT_ELLIPSIS = "…";
77
+ var takeWithinWidth = (clusters, budget) => {
78
+ const taken = [];
79
+ let usedWidth = WIDTH_ZERO;
80
+ for (const grapheme of clusters) {
81
+ const nextWidth = usedWidth + graphemeWidth(grapheme);
82
+ if (nextWidth > budget) {
83
+ break;
84
+ }
85
+ taken.push(grapheme);
86
+ usedWidth = nextWidth;
87
+ }
88
+ return taken;
89
+ };
90
+ var truncateToWidth = (value, width, ellipsis = DEFAULT_ELLIPSIS) => {
91
+ if (displayWidth(value) <= width) {
92
+ return value;
93
+ }
94
+ const budget = width - displayWidth(ellipsis);
95
+ if (budget < WIDTH_ZERO) {
96
+ return "";
97
+ }
98
+ return takeWithinWidth(graphemes(value), budget).join("") + ellipsis;
99
+ };
100
+ var truncateToWidthKeepingTail = (value, width, ellipsis = DEFAULT_ELLIPSIS) => {
101
+ if (displayWidth(value) <= width) {
102
+ return value;
103
+ }
104
+ const budget = width - displayWidth(ellipsis);
105
+ if (budget < WIDTH_ZERO) {
106
+ return "";
107
+ }
108
+ const keptFromEnd = takeWithinWidth(graphemes(value).toReversed(), budget);
109
+ return ellipsis + keptFromEnd.toReversed().join("");
110
+ };
111
+
112
+ export { graphemes, displayWidth, padToWidth, truncateToWidth, truncateToWidthKeepingTail };
@@ -0,0 +1,88 @@
1
+ import {
2
+ isWithin,
3
+ classifyWorktreePath,
4
+ parsePorcelain
5
+ } from "./cli-g72ra4e5.js";
6
+ import {
7
+ EXIT_USAGE_ERROR,
8
+ fail
9
+ } from "./cli-2bhb9p0x.js";
10
+
11
+ // src/infra/repo.ts
12
+ import { basename, dirname, join } from "node:path";
13
+
14
+ // src/domain/tracking.ts
15
+ var resolveTrackingRef = (branch, remotes) => {
16
+ if (remotes.includes("origin")) {
17
+ return { kind: "track", ref: `origin/${branch}` };
18
+ }
19
+ const [first, second] = remotes;
20
+ if (first === undefined) {
21
+ return { kind: "none" };
22
+ }
23
+ if (second !== undefined) {
24
+ return { kind: "ambiguous" };
25
+ }
26
+ return { kind: "track", ref: `${first}/${branch}` };
27
+ };
28
+
29
+ // src/infra/repo.ts
30
+ var loadRepoContext = async (git, fs, cwd) => {
31
+ const [commonDir, porcelainOut] = await Promise.all([
32
+ git.commonDir(cwd),
33
+ git.listWorktreesPorcelain(cwd)
34
+ ]);
35
+ const parsed = parsePorcelain(porcelainOut);
36
+ const [firstEntry] = parsed;
37
+ if (firstEntry === undefined) {
38
+ throw new Error("git worktree list returned no entries");
39
+ }
40
+ const rootPath = await realpathOrRaw(fs, firstEntry.path);
41
+ const managedRoot = join(dirname(rootPath), "_worktree", basename(rootPath));
42
+ const worktrees = await Promise.all(parsed.map(async (entry) => {
43
+ const resolvedPath = await realpathOrRaw(fs, entry.path);
44
+ return {
45
+ ...entry,
46
+ path: resolvedPath,
47
+ kind: classifyWorktreePath(resolvedPath, rootPath, managedRoot)
48
+ };
49
+ }));
50
+ return { rootPath, managedRoot, commonDir, worktrees };
51
+ };
52
+ var nestedWorktreePaths = (worktrees, path) => nestedWorktrees(worktrees, path).map((wt) => wt.path);
53
+ var nestedWorktrees = (worktrees, path) => worktrees.filter((wt) => wt.path !== path && !wt.prunable && isWithin(path, wt.path));
54
+ var isWorktreeDirty = (git, worktrees, path) => git.isDirty(path, nestedWorktreePaths(worktrees, path));
55
+ var worktreeDirtyState = async (git, fs, worktrees, wt) => {
56
+ if (wt.bare || wt.prunable || !await fs.exists(wt.path)) {
57
+ return null;
58
+ }
59
+ return isWorktreeDirty(git, worktrees, wt.path);
60
+ };
61
+ var resolveBranchCheckout = async (git, rootPath, branch, explicitTrack) => {
62
+ const localBranches = await git.listBranches(rootPath);
63
+ const createBranch = !localBranches.includes(branch);
64
+ let track = explicitTrack;
65
+ if (createBranch && track === undefined) {
66
+ const remotes = await git.remotesWithBranch(rootPath, branch);
67
+ const resolution = resolveTrackingRef(branch, remotes);
68
+ if (resolution.kind === "ambiguous") {
69
+ return {
70
+ ok: false,
71
+ rejection: fail(EXIT_USAGE_ERROR, `Branch "${branch}" exists on multiple remotes (${remotes.join(", ")}). Use --track to disambiguate.`)
72
+ };
73
+ }
74
+ if (resolution.kind === "track") {
75
+ track = resolution.ref;
76
+ }
77
+ }
78
+ return { ok: true, options: { createBranch, ...track === undefined ? {} : { track } } };
79
+ };
80
+ var realpathOrRaw = async (fs, path) => {
81
+ try {
82
+ return await fs.realpath(path);
83
+ } catch {
84
+ return path;
85
+ }
86
+ };
87
+
88
+ export { loadRepoContext, nestedWorktrees, isWorktreeDirty, worktreeDirtyState, resolveBranchCheckout };
@@ -0,0 +1,40 @@
1
+ // src/render.ts
2
+ var PLAIN_JSON_INDENT = 2;
3
+ var render = (command, result, json) => {
4
+ if (json) {
5
+ const envelope = {
6
+ schemaVersion: 1,
7
+ command,
8
+ data: result.ok ? result.data : undefined,
9
+ warnings: result.warnings ?? []
10
+ };
11
+ process.stdout.write(`${JSON.stringify(envelope)}
12
+ `);
13
+ if (!result.ok && result.errorMessage !== undefined) {
14
+ process.stderr.write(`${result.errorMessage}
15
+ `);
16
+ }
17
+ return;
18
+ }
19
+ if (result.path !== undefined) {
20
+ process.stdout.write(`${result.path}
21
+ `);
22
+ } else if (result.ok && result.data !== undefined) {
23
+ process.stdout.write(`${JSON.stringify(result.data, null, PLAIN_JSON_INDENT)}
24
+ `);
25
+ }
26
+ for (const warning of result.warnings ?? []) {
27
+ process.stderr.write(`warning: ${warning}
28
+ `);
29
+ }
30
+ if (!result.ok && result.errorMessage !== undefined) {
31
+ process.stderr.write(`${result.errorMessage}
32
+ `);
33
+ }
34
+ };
35
+ var reportResult = (command, result, json) => {
36
+ render(command, result, json);
37
+ process.exitCode = result.exitCode;
38
+ };
39
+
40
+ export { render, reportResult };