tmux-ide 2.6.0 → 2.6.1
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/bin/cli.js +13 -5
- package/bin/cli.ts +1 -1
- package/bunfig.toml +4 -0
- package/package.json +11 -3
- package/packages/contracts/package.json +22 -0
- package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
- package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
- package/packages/contracts/src/actions-contract.ts +310 -0
- package/packages/contracts/src/actions-errors.ts +41 -0
- package/packages/contracts/src/domain.ts +36 -0
- package/packages/contracts/src/ide-config.ts +170 -0
- package/packages/contracts/src/index.ts +24 -0
- package/packages/contracts/src/lib-internal/auth.ts +13 -0
- package/packages/contracts/src/lib-internal/hq.ts +38 -0
- package/packages/contracts/src/terminals.ts +116 -0
- package/packages/contracts/src/tmux.ts +60 -0
- package/packages/contracts/src/workspace.ts +67 -0
- package/packages/daemon/src/agent-explain.ts +270 -0
- package/packages/daemon/src/attach.ts +20 -0
- package/packages/daemon/src/bin.ts +4 -0
- package/packages/daemon/src/canonical.ts +7 -0
- package/packages/daemon/src/cli.ts +499 -0
- package/packages/daemon/src/command-center/actions/contract.ts +2 -0
- package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
- package/packages/daemon/src/command-center/actions/errors.ts +105 -0
- package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
- package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
- package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
- package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
- package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
- package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
- package/packages/daemon/src/command-center/actions/registry.ts +149 -0
- package/packages/daemon/src/command-center/discovery.ts +96 -0
- package/packages/daemon/src/command-center/index.ts +31 -0
- package/packages/daemon/src/command-center/schemas.ts +85 -0
- package/packages/daemon/src/command-center/server.ts +1260 -0
- package/packages/daemon/src/command-center/ws-events.ts +316 -0
- package/packages/daemon/src/config.ts +549 -0
- package/packages/daemon/src/detect.ts +248 -0
- package/packages/daemon/src/doctor.ts +242 -0
- package/packages/daemon/src/embed.ts +5 -0
- package/packages/daemon/src/index.ts +12 -0
- package/packages/daemon/src/init.ts +211 -0
- package/packages/daemon/src/inspect.ts +178 -0
- package/packages/daemon/src/js-yaml.d.ts +10 -0
- package/packages/daemon/src/launch.ts +349 -0
- package/packages/daemon/src/lib/active-projects.ts +49 -0
- package/packages/daemon/src/lib/agent-discovery.ts +121 -0
- package/packages/daemon/src/lib/app-config.ts +336 -0
- package/packages/daemon/src/lib/app-settings.ts +53 -0
- package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
- package/packages/daemon/src/lib/auth/middleware.ts +56 -0
- package/packages/daemon/src/lib/auth/types.ts +2 -0
- package/packages/daemon/src/lib/auth-token.ts +5 -0
- package/packages/daemon/src/lib/authorship.ts +280 -0
- package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
- package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
- package/packages/daemon/src/lib/daemon-embed.ts +782 -0
- package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
- package/packages/daemon/src/lib/daemon.ts +79 -0
- package/packages/daemon/src/lib/dot-path.ts +17 -0
- package/packages/daemon/src/lib/errors.ts +67 -0
- package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
- package/packages/daemon/src/lib/launch-plan.ts +90 -0
- package/packages/daemon/src/lib/log.ts +134 -0
- package/packages/daemon/src/lib/output.ts +76 -0
- package/packages/daemon/src/lib/project-init-runner.ts +150 -0
- package/packages/daemon/src/lib/project-inspect.ts +80 -0
- package/packages/daemon/src/lib/project-onboard.ts +149 -0
- package/packages/daemon/src/lib/project-probe.ts +92 -0
- package/packages/daemon/src/lib/project-registry.ts +296 -0
- package/packages/daemon/src/lib/session-monitor.ts +122 -0
- package/packages/daemon/src/lib/session-options.ts +100 -0
- package/packages/daemon/src/lib/shell.ts +8 -0
- package/packages/daemon/src/lib/sizes.ts +36 -0
- package/packages/daemon/src/lib/skill-sync.ts +155 -0
- package/packages/daemon/src/lib/slugify.ts +10 -0
- package/packages/daemon/src/lib/terminals-store.ts +125 -0
- package/packages/daemon/src/lib/update-check.ts +298 -0
- package/packages/daemon/src/lib/update.ts +158 -0
- package/packages/daemon/src/lib/workspace-registry.ts +229 -0
- package/packages/daemon/src/lib/worktree.ts +289 -0
- package/packages/daemon/src/lib/yaml-io.ts +27 -0
- package/packages/daemon/src/ls.ts +40 -0
- package/packages/daemon/src/restart.ts +24 -0
- package/packages/daemon/src/restore.ts +514 -0
- package/packages/daemon/src/schemas/domain.ts +2 -0
- package/packages/daemon/src/schemas/filesystem.ts +34 -0
- package/packages/daemon/src/schemas/ide-config.ts +2 -0
- package/packages/daemon/src/schemas/index.ts +59 -0
- package/packages/daemon/src/schemas/inspect.ts +67 -0
- package/packages/daemon/src/schemas/registry.ts +55 -0
- package/packages/daemon/src/schemas/ws-events.ts +135 -0
- package/packages/daemon/src/send.ts +171 -0
- package/packages/daemon/src/server/README.md +15 -0
- package/packages/daemon/src/server/index.ts +74 -0
- package/packages/daemon/src/server/pty-bridge.ts +532 -0
- package/packages/daemon/src/server/standalone.ts +18 -0
- package/packages/daemon/src/server/ws-route.ts +483 -0
- package/packages/daemon/src/status.ts +59 -0
- package/packages/daemon/src/stop.ts +28 -0
- package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
- package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
- package/packages/daemon/src/terminal/README.md +92 -0
- package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
- package/packages/daemon/src/tui/chrome/chip.ts +30 -0
- package/packages/daemon/src/tui/chrome/events.ts +119 -0
- package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
- package/packages/daemon/src/tui/chrome/menu.ts +289 -0
- package/packages/daemon/src/tui/chrome/notify.ts +195 -0
- package/packages/daemon/src/tui/chrome/panels.ts +111 -0
- package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
- package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
- package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
- package/packages/daemon/src/tui/chrome/updater.ts +428 -0
- package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
- package/packages/daemon/src/tui/compiled.ts +113 -0
- package/packages/daemon/src/tui/detect/classify.ts +193 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +192 -0
- package/packages/daemon/src/tui/detect/manifest.ts +184 -0
- package/packages/daemon/src/tui/detect/manifests.ts +226 -0
- package/packages/daemon/src/tui/detect/process-tree.ts +197 -0
- package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
- package/packages/daemon/src/tui/integrations/claude.ts +176 -0
- package/packages/daemon/src/tui/integrations/offer.ts +145 -0
- package/packages/daemon/src/tui/main.ts +70 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +143 -0
- package/packages/daemon/src/tui/mirror/control.ts +97 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +81 -0
- package/packages/daemon/src/tui/mirror/viewer.tsx +166 -0
- package/packages/daemon/src/tui/team/CONTROL.md +50 -0
- package/packages/daemon/src/tui/team/entry.ts +11 -0
- package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
- package/packages/daemon/src/tui/team/home.ts +170 -0
- package/packages/daemon/src/tui/team/index.tsx +1521 -0
- package/packages/daemon/src/tui/team/input.ts +34 -0
- package/packages/daemon/src/tui/team/keymap.ts +127 -0
- package/packages/daemon/src/tui/team/mouse.ts +29 -0
- package/packages/daemon/src/tui/team/nav.ts +31 -0
- package/packages/daemon/src/tui/team/preview.ts +34 -0
- package/packages/daemon/src/tui/team/projects.ts +191 -0
- package/packages/daemon/src/tui/team/report.ts +73 -0
- package/packages/daemon/src/tui/team/sessions.ts +344 -0
- package/packages/daemon/src/tui/team/tree.ts +62 -0
- package/packages/daemon/src/types.ts +13 -0
- package/packages/daemon/src/ui/index.ts +32 -0
- package/packages/daemon/src/ui/terminal/index.ts +9 -0
- package/packages/daemon/src/ui/types.ts +91 -0
- package/packages/daemon/src/ui/web/base.css +80 -0
- package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
- package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
- package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
- package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
- package/packages/daemon/src/ui/web/hooks.ts +106 -0
- package/packages/daemon/src/ui/web/index.ts +27 -0
- package/packages/daemon/src/ui/web/render.ts +77 -0
- package/packages/daemon/src/ui/web/utils/color.ts +27 -0
- package/packages/daemon/src/validate.ts +217 -0
- package/packages/daemon/src/widgets/changes/README.md +3 -0
- package/packages/daemon/src/widgets/changes/index.tsx +691 -0
- package/packages/daemon/src/widgets/config/README.md +3 -0
- package/packages/daemon/src/widgets/config/index.tsx +481 -0
- package/packages/daemon/src/widgets/explorer/README.md +3 -0
- package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
- package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
- package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
- package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
- package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
- package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
- package/packages/daemon/src/widgets/lib/files.ts +88 -0
- package/packages/daemon/src/widgets/lib/git.ts +88 -0
- package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
- package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
- package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
- package/packages/daemon/src/widgets/lib/theme.ts +194 -0
- package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
- package/packages/daemon/src/widgets/preview/README.md +3 -0
- package/packages/daemon/src/widgets/preview/index.tsx +416 -0
- package/packages/daemon/src/widgets/resolve.ts +121 -0
- package/packages/daemon/src/widgets/setup/README.md +3 -0
- package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
- package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
- package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
- package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
- package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
- package/packages/daemon/src/widgets/setup/index.tsx +341 -0
- package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
- package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
- package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
- package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
- package/packages/tmux-bridge/package.json +22 -0
- package/packages/tmux-bridge/src/errors.ts +28 -0
- package/packages/tmux-bridge/src/index.ts +31 -0
- package/packages/tmux-bridge/src/monitor.ts +77 -0
- package/packages/tmux-bridge/src/panes.ts +136 -0
- package/packages/tmux-bridge/src/runner.test.ts +501 -0
- package/packages/tmux-bridge/src/runner.ts +91 -0
- package/packages/tmux-bridge/src/sessions.ts +126 -0
- package/packages/tmux-bridge/src/targeting.test.ts +107 -0
- package/packages/tmux-bridge/src/targeting.ts +90 -0
- package/scripts/postinstall.js +26 -2
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tmux-ide update [--dry-run]` — act on the update the dock surfaces.
|
|
3
|
+
*
|
|
4
|
+
* The dock's `⬆ v<latest>` chip (see {@link ./update-check.ts}) tells you an
|
|
5
|
+
* update is out; this closes the loop with the ONE command that installs it. The
|
|
6
|
+
* catch is that "the command" depends on how tmux-ide was installed — a cloned
|
|
7
|
+
* dev checkout updates with `git pull`, a global install with its package
|
|
8
|
+
* manager (npm/pnpm/bun) — so the command DETECTS the install method from the
|
|
9
|
+
* running CLI's real path before printing (or, without `--dry-run`, running) it.
|
|
10
|
+
*
|
|
11
|
+
* The detection + rendering are PURE ({@link detectPackageManager},
|
|
12
|
+
* {@link planUpdate}, {@link renderPlan}), so they're unit-tested without a live
|
|
13
|
+
* filesystem; {@link findGitCheckoutRoot} and {@link runUpdate} are the io — the
|
|
14
|
+
* latter is the only place a real global install is ever spawned, and only when
|
|
15
|
+
* `--dry-run` is absent.
|
|
16
|
+
*/
|
|
17
|
+
import { execSync } from "node:child_process";
|
|
18
|
+
import { existsSync } from "node:fs";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
import { getCurrentVersion, getUpdateStatus, isNewer } from "./update-check.ts";
|
|
21
|
+
|
|
22
|
+
/** The package managers a global install can come from. */
|
|
23
|
+
export type PackageManager = "npm" | "pnpm" | "bun";
|
|
24
|
+
|
|
25
|
+
/** The resolved way to update this install. */
|
|
26
|
+
export interface UpdatePlan {
|
|
27
|
+
/** `dev` = a git checkout (`git pull`); otherwise the global package manager. */
|
|
28
|
+
method: "dev" | PackageManager;
|
|
29
|
+
/** The shell command to run, or null for a dev checkout (manual `git pull`). */
|
|
30
|
+
command: string | null;
|
|
31
|
+
/** A short human note on WHY this method was chosen (the detection evidence). */
|
|
32
|
+
reason: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The exact global-install command per package manager. */
|
|
36
|
+
export const UPDATE_COMMANDS: Record<PackageManager, string> = {
|
|
37
|
+
npm: "npm install -g tmux-ide@latest",
|
|
38
|
+
pnpm: "pnpm add -g tmux-ide@latest",
|
|
39
|
+
bun: "bun add -g tmux-ide@latest",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Pure
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* PURE — infer the package manager from a global CLI path. The global bin lives
|
|
48
|
+
* under a manager-specific directory, so the path is the tell: a `bun`/`.bun`
|
|
49
|
+
* segment → bun, a `pnpm` segment → pnpm, else npm (the default global installer,
|
|
50
|
+
* incl. nvm's `.../node/vX/lib/node_modules/...`). Checked bun→pnpm→npm so the
|
|
51
|
+
* more specific segments win.
|
|
52
|
+
*/
|
|
53
|
+
export function detectPackageManager(cliPath: string): PackageManager {
|
|
54
|
+
const p = cliPath.toLowerCase();
|
|
55
|
+
if (/(^|\/)\.?bun(\/|$)/.test(p)) return "bun";
|
|
56
|
+
if (p.includes("pnpm")) return "pnpm";
|
|
57
|
+
return "npm";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* PURE — the {@link UpdatePlan} for a CLI at `cliPath`. When `gitRoot` is set (a
|
|
62
|
+
* `.git` was found at/above the CLI — a cloned checkout) the plan is a manual
|
|
63
|
+
* `git pull`; otherwise it's the detected package manager's global-install
|
|
64
|
+
* command.
|
|
65
|
+
*/
|
|
66
|
+
export function planUpdate(input: { cliPath: string; gitRoot: string | null }): UpdatePlan {
|
|
67
|
+
if (input.gitRoot) {
|
|
68
|
+
return { method: "dev", command: null, reason: `git checkout at ${input.gitRoot}` };
|
|
69
|
+
}
|
|
70
|
+
const pm = detectPackageManager(input.cliPath);
|
|
71
|
+
return {
|
|
72
|
+
method: pm,
|
|
73
|
+
command: UPDATE_COMMANDS[pm],
|
|
74
|
+
reason: `global ${pm} install (${input.cliPath})`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* PURE — the human-readable block the CLI prints for a plan. Leads with the
|
|
80
|
+
* version delta (or "up to date"/"unknown"), then the command to run — for a dev
|
|
81
|
+
* checkout that's the `git pull` hint, for a global install the exact package-
|
|
82
|
+
* manager line. `dryRun` only changes the framing ("would run" vs "run"); the
|
|
83
|
+
* command shown is identical either way. Ends with the re-adopt note: the running
|
|
84
|
+
* updater keeps the OLD code until `_tmux-ide-chrome` is killed and a session
|
|
85
|
+
* re-adopted, so a fresh dock reflects the new version.
|
|
86
|
+
*/
|
|
87
|
+
export function renderPlan(
|
|
88
|
+
plan: UpdatePlan,
|
|
89
|
+
{ current, latest, dryRun }: { current: string; latest: string | null; dryRun: boolean },
|
|
90
|
+
): string {
|
|
91
|
+
const lines: string[] = [];
|
|
92
|
+
// isNewer, not inequality — the registry can lag the checkout (a dev build
|
|
93
|
+
// is AHEAD of the published version), and that must not read as an update.
|
|
94
|
+
if (latest && isNewer(latest, current)) {
|
|
95
|
+
lines.push(`tmux-ide v${current} → v${latest} available`);
|
|
96
|
+
} else if (latest) {
|
|
97
|
+
lines.push(`tmux-ide v${current} is up to date (registry: v${latest})`);
|
|
98
|
+
} else {
|
|
99
|
+
lines.push(`tmux-ide v${current} (latest version unknown — run \`tmux-ide doctor\`)`);
|
|
100
|
+
}
|
|
101
|
+
lines.push("");
|
|
102
|
+
if (plan.method === "dev") {
|
|
103
|
+
lines.push("Detected a cloned checkout — update with git:");
|
|
104
|
+
lines.push(" git pull");
|
|
105
|
+
lines.push(` (${plan.reason})`);
|
|
106
|
+
} else {
|
|
107
|
+
const verb = dryRun ? "Would run" : "Running";
|
|
108
|
+
lines.push(`Detected a global ${plan.method} install — ${verb}:`);
|
|
109
|
+
lines.push(` ${plan.command}`);
|
|
110
|
+
}
|
|
111
|
+
lines.push("");
|
|
112
|
+
lines.push("After updating, refresh the dock so it runs the new code:");
|
|
113
|
+
lines.push(" tmux kill-session -t _tmux-ide-chrome # stop the old updater");
|
|
114
|
+
lines.push(" tmux-ide adopt <session> # re-adopt to relaunch it");
|
|
115
|
+
return lines.join("\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// io
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* io — walk up from `startDir` looking for a `.git` entry; return the first
|
|
124
|
+
* directory that has one (a cloned checkout root) or null at the filesystem root.
|
|
125
|
+
* A global install under `node_modules` has no `.git` above it, so this cleanly
|
|
126
|
+
* separates "dev checkout" from "installed package".
|
|
127
|
+
*/
|
|
128
|
+
export function findGitCheckoutRoot(startDir: string): string | null {
|
|
129
|
+
let dir = startDir;
|
|
130
|
+
for (;;) {
|
|
131
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
132
|
+
const parent = dirname(dir);
|
|
133
|
+
if (parent === dir) return null;
|
|
134
|
+
dir = parent;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* io — run (or, with `dryRun`, just print) the update.
|
|
140
|
+
*
|
|
141
|
+
* `cliDir` is the directory of the running CLI (`bin/`), the anchor for both the
|
|
142
|
+
* git-checkout probe and the package-manager path heuristic. The plan is printed
|
|
143
|
+
* either way; only a non-dry-run global install actually spawns the installer
|
|
144
|
+
* (`execSync`, output inherited). A dev checkout NEVER auto-runs `git pull` — it
|
|
145
|
+
* only prints the hint. Returns the plan so the CLI/tests can assert on it.
|
|
146
|
+
*/
|
|
147
|
+
export function runUpdate({ cliDir, dryRun }: { cliDir: string; dryRun: boolean }): UpdatePlan {
|
|
148
|
+
const current = getCurrentVersion();
|
|
149
|
+
const { latest } = getUpdateStatus({ currentVersion: current });
|
|
150
|
+
const gitRoot = findGitCheckoutRoot(cliDir);
|
|
151
|
+
const plan = planUpdate({ cliPath: cliDir, gitRoot });
|
|
152
|
+
console.log(renderPlan(plan, { current, latest, dryRun }));
|
|
153
|
+
if (!dryRun && plan.command) {
|
|
154
|
+
console.log("");
|
|
155
|
+
execSync(plan.command, { stdio: "inherit" });
|
|
156
|
+
}
|
|
157
|
+
return plan;
|
|
158
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace registry — runtime list of projects the daemon is serving.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the legacy single-session TMUX_IDE_SESSION env coupling so a
|
|
5
|
+
* single daemon can host N concurrent projects (goal 12, T065).
|
|
6
|
+
*
|
|
7
|
+
* Persisted at ~/.tmux-ide/workspaces.json via atomic temp+rename. On
|
|
8
|
+
* `load()` the registry reconciles itself against `tmux list-sessions`
|
|
9
|
+
* output and drops entries whose session no longer exists.
|
|
10
|
+
*
|
|
11
|
+
* Distinct from project-registry.ts — that one is a long-lived
|
|
12
|
+
* "bookmark list" of projects the user knows about (probed metadata).
|
|
13
|
+
* This one tracks live workspaces and is reconciled with tmux on boot.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { EventEmitter } from "node:events";
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { WorkspaceSchemaZ, type Workspace } from "@tmux-ide/contracts";
|
|
22
|
+
|
|
23
|
+
const REGISTRY_DIR_ENV = "TMUX_IDE_REGISTRY_DIR";
|
|
24
|
+
|
|
25
|
+
const RegistryFileSchemaZ = z.object({
|
|
26
|
+
version: z.literal(1),
|
|
27
|
+
workspaces: z.array(WorkspaceSchemaZ),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
type RegistryFile = z.infer<typeof RegistryFileSchemaZ>;
|
|
31
|
+
|
|
32
|
+
export type ListSessionsFn = () => readonly string[];
|
|
33
|
+
|
|
34
|
+
export interface WorkspaceRegistryOptions {
|
|
35
|
+
/** Override registry dir for tests. Defaults to ~/.tmux-ide. */
|
|
36
|
+
dir?: string;
|
|
37
|
+
/** Inject a tmux list-sessions implementation (defaults to bridge). */
|
|
38
|
+
listSessions?: ListSessionsFn;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AddWorkspaceInput {
|
|
42
|
+
name: string;
|
|
43
|
+
sessionName?: string;
|
|
44
|
+
projectDir: string;
|
|
45
|
+
ideConfigPath?: string | null;
|
|
46
|
+
/** Override Date.now() for deterministic tests. */
|
|
47
|
+
now?: () => Date;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class WorkspaceAlreadyExistsError extends Error {
|
|
51
|
+
readonly code = "ALREADY_EXISTS";
|
|
52
|
+
constructor(name: string) {
|
|
53
|
+
super(`Workspace "${name}" already exists`);
|
|
54
|
+
this.name = "WorkspaceAlreadyExistsError";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class WorkspaceNotFoundError extends Error {
|
|
59
|
+
readonly code = "NOT_FOUND";
|
|
60
|
+
constructor(name: string) {
|
|
61
|
+
super(`Workspace "${name}" not found`);
|
|
62
|
+
this.name = "WorkspaceNotFoundError";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Thin wrapper around the shared module emitter so consumers can subscribe
|
|
68
|
+
* with typed events without depending on EventEmitter directly.
|
|
69
|
+
*/
|
|
70
|
+
export type WorkspaceEvent =
|
|
71
|
+
| { type: "workspace.added"; workspace: Workspace }
|
|
72
|
+
| { type: "workspace.removed"; name: string };
|
|
73
|
+
|
|
74
|
+
export class WorkspaceRegistry {
|
|
75
|
+
private readonly dir: string;
|
|
76
|
+
private readonly listSessions: ListSessionsFn;
|
|
77
|
+
private readonly emitter = new EventEmitter();
|
|
78
|
+
private workspaces: Workspace[] = [];
|
|
79
|
+
private loaded = false;
|
|
80
|
+
|
|
81
|
+
constructor(options: WorkspaceRegistryOptions = {}) {
|
|
82
|
+
this.dir = options.dir ?? process.env[REGISTRY_DIR_ENV] ?? join(homedir(), ".tmux-ide");
|
|
83
|
+
this.listSessions = options.listSessions ?? defaultListSessions;
|
|
84
|
+
this.emitter.setMaxListeners(0);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Load workspaces from disk and reconcile against live tmux sessions.
|
|
89
|
+
* Drops entries whose tmux session is gone (silently — they were
|
|
90
|
+
* persisted by a prior daemon invocation that may have crashed).
|
|
91
|
+
*
|
|
92
|
+
* Safe to call repeatedly; subsequent calls re-reconcile.
|
|
93
|
+
*/
|
|
94
|
+
async load(): Promise<void> {
|
|
95
|
+
const fromDisk = this.readDisk();
|
|
96
|
+
let live: Set<string>;
|
|
97
|
+
try {
|
|
98
|
+
live = new Set(this.listSessions());
|
|
99
|
+
} catch {
|
|
100
|
+
// tmux is unavailable — keep all entries; reconcile when we can.
|
|
101
|
+
live = new Set(fromDisk.map((w) => w.sessionName));
|
|
102
|
+
}
|
|
103
|
+
const reconciled = fromDisk.filter((w) => live.has(w.sessionName));
|
|
104
|
+
this.workspaces = reconciled;
|
|
105
|
+
this.loaded = true;
|
|
106
|
+
if (reconciled.length !== fromDisk.length) {
|
|
107
|
+
this.writeDisk();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
list(): Workspace[] {
|
|
112
|
+
return [...this.workspaces];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
get(name: string): Workspace | null {
|
|
116
|
+
return this.workspaces.find((w) => w.name === name) ?? null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
has(name: string): boolean {
|
|
120
|
+
return this.workspaces.some((w) => w.name === name);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
add(input: AddWorkspaceInput): Workspace {
|
|
124
|
+
if (this.has(input.name)) {
|
|
125
|
+
throw new WorkspaceAlreadyExistsError(input.name);
|
|
126
|
+
}
|
|
127
|
+
const now = (input.now ?? (() => new Date()))();
|
|
128
|
+
const workspace: Workspace = {
|
|
129
|
+
name: input.name,
|
|
130
|
+
sessionName: input.sessionName ?? input.name,
|
|
131
|
+
projectDir: input.projectDir,
|
|
132
|
+
ideConfigPath: input.ideConfigPath ?? null,
|
|
133
|
+
addedAt: now.toISOString(),
|
|
134
|
+
};
|
|
135
|
+
this.workspaces = [...this.workspaces, workspace];
|
|
136
|
+
this.writeDisk();
|
|
137
|
+
this.emitter.emit("workspace.added", workspace);
|
|
138
|
+
return workspace;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
remove(name: string): void {
|
|
142
|
+
if (!this.has(name)) {
|
|
143
|
+
throw new WorkspaceNotFoundError(name);
|
|
144
|
+
}
|
|
145
|
+
this.workspaces = this.workspaces.filter((w) => w.name !== name);
|
|
146
|
+
this.writeDisk();
|
|
147
|
+
this.emitter.emit("workspace.removed", name);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Subscribe to workspace.added | workspace.removed events. */
|
|
151
|
+
on<E extends WorkspaceEvent["type"]>(
|
|
152
|
+
event: E,
|
|
153
|
+
handler: (
|
|
154
|
+
payload: Extract<WorkspaceEvent, { type: E }> extends { workspace: Workspace }
|
|
155
|
+
? Workspace
|
|
156
|
+
: string,
|
|
157
|
+
) => void,
|
|
158
|
+
): () => void {
|
|
159
|
+
this.emitter.on(event, handler as (...args: unknown[]) => void);
|
|
160
|
+
return () => this.emitter.off(event, handler as (...args: unknown[]) => void);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ----------------- io -----------------
|
|
164
|
+
|
|
165
|
+
private filePath(): string {
|
|
166
|
+
return join(this.dir, "workspaces.json");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private readDisk(): Workspace[] {
|
|
170
|
+
const path = this.filePath();
|
|
171
|
+
if (!existsSync(path)) return [];
|
|
172
|
+
let parsed: unknown;
|
|
173
|
+
try {
|
|
174
|
+
parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
175
|
+
} catch {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
const result = RegistryFileSchemaZ.safeParse(parsed);
|
|
179
|
+
if (!result.success) return [];
|
|
180
|
+
return result.data.workspaces;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private writeDisk(): void {
|
|
184
|
+
const path = this.filePath();
|
|
185
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
186
|
+
const file: RegistryFile = { version: 1, workspaces: this.workspaces };
|
|
187
|
+
const tmp = `${path}.tmp`;
|
|
188
|
+
writeFileSync(tmp, JSON.stringify(file, null, 2) + "\n");
|
|
189
|
+
renameSync(tmp, path);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** @internal Test-only: assert the registry is loaded. */
|
|
193
|
+
_isLoaded(): boolean {
|
|
194
|
+
return this.loaded;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Default singleton — the daemon process holds one registry; HTTP handlers
|
|
200
|
+
// import it via getDefaultWorkspaceRegistry().
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
let _default: WorkspaceRegistry | null = null;
|
|
204
|
+
|
|
205
|
+
export function getDefaultWorkspaceRegistry(): WorkspaceRegistry {
|
|
206
|
+
if (!_default) _default = new WorkspaceRegistry();
|
|
207
|
+
return _default;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** @internal Test hook: replace the singleton. */
|
|
211
|
+
export function _setDefaultWorkspaceRegistryForTests(registry: WorkspaceRegistry | null): void {
|
|
212
|
+
_default = registry;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function defaultListSessions(): string[] {
|
|
216
|
+
// Lazy import keeps tmux-bridge optional for test environments that
|
|
217
|
+
// don't have tmux available; tests inject a stub instead.
|
|
218
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
219
|
+
const { execFileSync } = require("node:child_process") as typeof import("node:child_process");
|
|
220
|
+
try {
|
|
221
|
+
const raw = execFileSync("tmux", ["list-sessions", "-F", "#{session_name}"], {
|
|
222
|
+
encoding: "utf-8",
|
|
223
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
224
|
+
}) as string;
|
|
225
|
+
return raw.split("\n").filter(Boolean);
|
|
226
|
+
} catch {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git worktree flow — one checkout per branch so parallel agents (or a fleet)
|
|
3
|
+
* each work on isolation. `tmux-ide worktree create <branch>` adds a git
|
|
4
|
+
* worktree in a SIBLING directory (never inside the repo) and opens a tmux
|
|
5
|
+
* session there; the CLI (`bin/cli.ts`, `worktree` case) wires the io.
|
|
6
|
+
*
|
|
7
|
+
* The pure helpers — {@link worktreeSessionName}, {@link worktreePath},
|
|
8
|
+
* {@link parseWorktreeList}, {@link mapWorktreeError} — carry no io so they're
|
|
9
|
+
* cheaply testable. The io wrappers ({@link createWorktree},
|
|
10
|
+
* {@link removeWorktree}, {@link listWorktrees}) shell out to `git` and map its
|
|
11
|
+
* stderr into a typed {@link WorktreeError} with an actionable message.
|
|
12
|
+
*/
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
15
|
+
import { IdeError } from "./errors.ts";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Typed error
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export type WorktreeErrorCode =
|
|
22
|
+
| "NOT_A_GIT_REPO"
|
|
23
|
+
| "BRANCH_EXISTS"
|
|
24
|
+
| "BRANCH_NOT_FOUND"
|
|
25
|
+
| "ALREADY_CHECKED_OUT"
|
|
26
|
+
| "WORKTREE_EXISTS"
|
|
27
|
+
| "WORKTREE_DIRTY"
|
|
28
|
+
| "WORKTREE_NOT_FOUND"
|
|
29
|
+
| "GIT_FAILED";
|
|
30
|
+
|
|
31
|
+
export class WorktreeError extends IdeError {
|
|
32
|
+
constructor(message: string, code: WorktreeErrorCode) {
|
|
33
|
+
super(message, { code, exitCode: 1 });
|
|
34
|
+
this.name = "WorktreeError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Pure helpers
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* PURE — sanitize a component for a tmux session name. tmux treats `:` as a
|
|
44
|
+
* target separator and silently rewrites `.` to `_`, so we normalize both (plus
|
|
45
|
+
* whitespace) to `-` up front, keeping the name stable and predictable. `/` is
|
|
46
|
+
* left intact — tmux accepts it in session names (verified live on tmux 3.6).
|
|
47
|
+
*/
|
|
48
|
+
function sanitizeForTmux(part: string): string {
|
|
49
|
+
// Slashes too: `feat/x` would parse as a path-ish target in tmux commands
|
|
50
|
+
// and can't appear in the status bar's `#[range=user|sw<name>]` mouse ranges.
|
|
51
|
+
return part.replace(/[.:/\s]+/g, "-");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* PURE — the tmux session name for a project's worktree on `branch`, e.g.
|
|
56
|
+
* `myapp@fix-auth`. Both sides are sanitized ({@link sanitizeForTmux}) so a
|
|
57
|
+
* dotted/colon'd branch (or project) stays a single inert session name; the
|
|
58
|
+
* `@` separator is preserved so the session reads as "project, this branch".
|
|
59
|
+
*/
|
|
60
|
+
export function worktreeSessionName(project: string, branch: string): string {
|
|
61
|
+
return `${sanitizeForTmux(project)}@${sanitizeForTmux(branch)}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* PURE — the default base directory for a repo's worktrees: a SIBLING of the
|
|
66
|
+
* repo named `<repo-name>-worktrees`. Kept outside the repo so a worktree is
|
|
67
|
+
* never nested inside its own parent checkout (which git rejects and which
|
|
68
|
+
* would pollute the project tree).
|
|
69
|
+
*/
|
|
70
|
+
export function defaultWorktreeBaseDir(repoDir: string): string {
|
|
71
|
+
const abs = resolve(repoDir);
|
|
72
|
+
return join(dirname(abs), `${basename(abs)}-worktrees`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* PURE — the on-disk path for a worktree of `branch`. Defaults to
|
|
77
|
+
* `<repo>/../<repo-name>-worktrees/<branch>`; a non-empty `configuredDir` (the
|
|
78
|
+
* app-config `worktrees.dir` override) replaces the base, resolved relative to
|
|
79
|
+
* the repo when it isn't absolute. The branch is the final path segment (a
|
|
80
|
+
* slashed branch nests, which `git worktree add` creates).
|
|
81
|
+
*/
|
|
82
|
+
export function worktreePath(
|
|
83
|
+
repoDir: string,
|
|
84
|
+
branch: string,
|
|
85
|
+
configuredDir?: string | null,
|
|
86
|
+
): string {
|
|
87
|
+
const base =
|
|
88
|
+
configuredDir && configuredDir.length > 0
|
|
89
|
+
? isAbsolute(configuredDir)
|
|
90
|
+
? configuredDir
|
|
91
|
+
: resolve(repoDir, configuredDir)
|
|
92
|
+
: defaultWorktreeBaseDir(repoDir);
|
|
93
|
+
return join(base, branch);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A single record from `git worktree list --porcelain`. */
|
|
97
|
+
export interface WorktreeEntry {
|
|
98
|
+
/** Absolute path of the worktree. */
|
|
99
|
+
path: string;
|
|
100
|
+
/** The checked-out commit sha, or null (bare repo). */
|
|
101
|
+
head: string | null;
|
|
102
|
+
/** Short branch name (no `refs/heads/`), or null when detached/bare. */
|
|
103
|
+
branch: string | null;
|
|
104
|
+
/** The bare repository entry. */
|
|
105
|
+
bare: boolean;
|
|
106
|
+
/** A detached-HEAD worktree (no branch). */
|
|
107
|
+
detached: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* PURE — parse `git worktree list --porcelain`. Records are separated by blank
|
|
112
|
+
* lines; each has a `worktree <path>` line, then optional `HEAD <sha>`,
|
|
113
|
+
* `branch refs/heads/<name>`, `bare`, `detached` attribute lines. Unknown
|
|
114
|
+
* attribute lines are ignored. Malformed/empty input yields `[]`.
|
|
115
|
+
*/
|
|
116
|
+
export function parseWorktreeList(porcelain: string): WorktreeEntry[] {
|
|
117
|
+
const entries: WorktreeEntry[] = [];
|
|
118
|
+
let current: WorktreeEntry | null = null;
|
|
119
|
+
|
|
120
|
+
const flush = () => {
|
|
121
|
+
if (current) entries.push(current);
|
|
122
|
+
current = null;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
for (const rawLine of porcelain.split("\n")) {
|
|
126
|
+
const line = rawLine.replace(/\r$/, "");
|
|
127
|
+
if (line.length === 0) {
|
|
128
|
+
flush();
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (line.startsWith("worktree ")) {
|
|
132
|
+
flush();
|
|
133
|
+
current = {
|
|
134
|
+
path: line.slice("worktree ".length),
|
|
135
|
+
head: null,
|
|
136
|
+
branch: null,
|
|
137
|
+
bare: false,
|
|
138
|
+
detached: false,
|
|
139
|
+
};
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (!current) continue;
|
|
143
|
+
if (line.startsWith("HEAD ")) {
|
|
144
|
+
current.head = line.slice("HEAD ".length);
|
|
145
|
+
} else if (line.startsWith("branch ")) {
|
|
146
|
+
current.branch = line.slice("branch ".length).replace(/^refs\/heads\//, "");
|
|
147
|
+
} else if (line === "bare") {
|
|
148
|
+
current.bare = true;
|
|
149
|
+
} else if (line === "detached") {
|
|
150
|
+
current.detached = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
flush();
|
|
154
|
+
return entries;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* PURE — map a failed `git` invocation's stderr to a typed
|
|
159
|
+
* {@link WorktreeError}. Recognizes the common worktree failures so the CLI can
|
|
160
|
+
* surface an actionable message instead of a raw git dump; anything unmatched
|
|
161
|
+
* falls back to `GIT_FAILED` carrying the trimmed stderr.
|
|
162
|
+
*/
|
|
163
|
+
export function mapWorktreeError(stderr: string, fallbackMessage: string): WorktreeError {
|
|
164
|
+
const text = stderr.trim();
|
|
165
|
+
const lower = text.toLowerCase();
|
|
166
|
+
|
|
167
|
+
if (lower.includes("not a git repository")) {
|
|
168
|
+
return new WorktreeError(
|
|
169
|
+
"Not a git repository. Run `tmux-ide worktree` from inside a git repo.",
|
|
170
|
+
"NOT_A_GIT_REPO",
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
if (lower.includes("already exists") && lower.includes("branch")) {
|
|
174
|
+
return new WorktreeError(
|
|
175
|
+
`${text}\nUse \`tmux-ide worktree create <branch>\` without --from to check out the existing branch, or pick a new name.`,
|
|
176
|
+
"BRANCH_EXISTS",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (lower.includes("is already checked out") || lower.includes("already used by worktree")) {
|
|
180
|
+
return new WorktreeError(text, "ALREADY_CHECKED_OUT");
|
|
181
|
+
}
|
|
182
|
+
if (lower.includes("already exists")) {
|
|
183
|
+
return new WorktreeError(text, "WORKTREE_EXISTS");
|
|
184
|
+
}
|
|
185
|
+
if (
|
|
186
|
+
(lower.includes("invalid reference") || lower.includes("not a valid ref")) &&
|
|
187
|
+
!lower.includes("already")
|
|
188
|
+
) {
|
|
189
|
+
return new WorktreeError(text, "BRANCH_NOT_FOUND");
|
|
190
|
+
}
|
|
191
|
+
if (
|
|
192
|
+
lower.includes("contains modified or untracked files") ||
|
|
193
|
+
lower.includes("use --force") ||
|
|
194
|
+
lower.includes("use 'remove -f'")
|
|
195
|
+
) {
|
|
196
|
+
return new WorktreeError(
|
|
197
|
+
`${text}\nRe-run with --force to discard those changes.`,
|
|
198
|
+
"WORKTREE_DIRTY",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
if (lower.includes("is not a working tree") || lower.includes("not a working tree")) {
|
|
202
|
+
return new WorktreeError(text, "WORKTREE_NOT_FOUND");
|
|
203
|
+
}
|
|
204
|
+
return new WorktreeError(text.length > 0 ? text : fallbackMessage, "GIT_FAILED");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// io — git shell-outs (map stderr → WorktreeError)
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
/** Injectable git runner so io wrappers stay unit-testable. */
|
|
212
|
+
export type GitRunner = (repoDir: string, args: string[]) => string;
|
|
213
|
+
|
|
214
|
+
let gitRunner: GitRunner = (repoDir, args) =>
|
|
215
|
+
execFileSync("git", args, {
|
|
216
|
+
cwd: repoDir,
|
|
217
|
+
encoding: "utf-8",
|
|
218
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
/** Swap the git runner (tests). Returns a restore fn. */
|
|
222
|
+
export function _setGitRunnerForTests(fn: GitRunner): () => void {
|
|
223
|
+
const prev = gitRunner;
|
|
224
|
+
gitRunner = fn;
|
|
225
|
+
return () => {
|
|
226
|
+
gitRunner = prev;
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
interface GitExecError {
|
|
231
|
+
stderr?: Buffer | string;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function runGit(repoDir: string, args: string[], fallbackMessage: string): string {
|
|
235
|
+
try {
|
|
236
|
+
return gitRunner(repoDir, args);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
const stderr = (error as GitExecError).stderr;
|
|
239
|
+
const text = stderr ? stderr.toString() : "";
|
|
240
|
+
throw mapWorktreeError(text, fallbackMessage);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface CreateWorktreeOptions {
|
|
245
|
+
/** Create a NEW branch (git worktree add -b). Off → check out an existing branch. */
|
|
246
|
+
newBranch?: boolean;
|
|
247
|
+
/** Base ref for the new branch (default: current HEAD). Only used with newBranch. */
|
|
248
|
+
from?: string | null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Add a git worktree for `branch` at `worktreeAbsPath` and return that path.
|
|
253
|
+
* With `newBranch`, creates the branch (optionally off `from`); otherwise
|
|
254
|
+
* checks out an existing branch. Failures throw a typed {@link WorktreeError}.
|
|
255
|
+
*/
|
|
256
|
+
export function createWorktree(
|
|
257
|
+
repoDir: string,
|
|
258
|
+
branch: string,
|
|
259
|
+
worktreeAbsPath: string,
|
|
260
|
+
options: CreateWorktreeOptions = {},
|
|
261
|
+
): string {
|
|
262
|
+
const args = ["worktree", "add"];
|
|
263
|
+
if (options.newBranch) {
|
|
264
|
+
args.push("-b", branch, worktreeAbsPath);
|
|
265
|
+
if (options.from && options.from.length > 0) args.push(options.from);
|
|
266
|
+
} else {
|
|
267
|
+
args.push(worktreeAbsPath, branch);
|
|
268
|
+
}
|
|
269
|
+
runGit(repoDir, args, `Failed to create worktree for ${branch}`);
|
|
270
|
+
return worktreeAbsPath;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Remove a git worktree by path. `force` discards uncommitted changes. */
|
|
274
|
+
export function removeWorktree(
|
|
275
|
+
repoDir: string,
|
|
276
|
+
worktreeAbsPath: string,
|
|
277
|
+
options: { force?: boolean } = {},
|
|
278
|
+
): void {
|
|
279
|
+
const args = ["worktree", "remove"];
|
|
280
|
+
if (options.force) args.push("--force");
|
|
281
|
+
args.push(worktreeAbsPath);
|
|
282
|
+
runGit(repoDir, args, `Failed to remove worktree ${worktreeAbsPath}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** List the repo's worktrees. Throws a typed {@link WorktreeError} on failure. */
|
|
286
|
+
export function listWorktrees(repoDir: string): WorktreeEntry[] {
|
|
287
|
+
const out = runGit(repoDir, ["worktree", "list", "--porcelain"], "Failed to list worktrees");
|
|
288
|
+
return parseWorktreeList(out);
|
|
289
|
+
}
|