@paleo/worktree-env 0.1.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/dist/slots.js ADDED
@@ -0,0 +1,132 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { allPorts, isValidPort } from "./ports.js";
5
+ export const WORKTREES_DIR = ".local/worktrees";
6
+ export const SLOTS_FILE = ".local/worktrees/slots.json";
7
+ export function readSlots(mainWorktree) {
8
+ const filePath = join(mainWorktree, SLOTS_FILE);
9
+ if (!existsSync(filePath))
10
+ return { slots: {} };
11
+ return JSON.parse(readFileSync(filePath, "utf-8"));
12
+ }
13
+ export function writeSlots(mainWorktree, registry) {
14
+ const filePath = join(mainWorktree, SLOTS_FILE);
15
+ mkdirSync(join(mainWorktree, WORKTREES_DIR), { recursive: true });
16
+ writeFileSync(filePath, `${JSON.stringify(registry, undefined, 2)}\n`);
17
+ }
18
+ export function pickSlotPort(args, registry) {
19
+ const resolvedCurrent = resolve(args.currentWorktree);
20
+ if (args.slot !== undefined) {
21
+ const port = Number(args.slot);
22
+ if (!isValidPort(port, args.scheme)) {
23
+ console.error(`Error: Slot must be a valid port: ${allPorts(args.scheme).join(", ")}.`);
24
+ process.exit(1);
25
+ }
26
+ const existing = registry.slots[String(port)];
27
+ if (existing && resolve(existing.worktree) !== resolvedCurrent) {
28
+ console.error(`Error: Slot ${port} is already taken by ${existing.worktree} (branch: ${existing.branch}).`);
29
+ process.exit(1);
30
+ }
31
+ return port;
32
+ }
33
+ const existingEntry = Object.entries(registry.slots).find(([, v]) => resolve(v.worktree) === resolvedCurrent);
34
+ if (existingEntry)
35
+ return Number(existingEntry[0]);
36
+ for (const port of allPorts(args.scheme)) {
37
+ if (!registry.slots[String(port)])
38
+ return port;
39
+ }
40
+ console.error("Error: All slots are taken. Remove a worktree with --remove first.");
41
+ process.exit(1);
42
+ }
43
+ export function resolveAndRegisterSlot(input) {
44
+ const registry = readSlots(input.mainWorktree);
45
+ const port = pickSlotPort(input, registry);
46
+ const existing = registry.slots[String(port)];
47
+ let owner;
48
+ if (input.requestedOwner !== undefined) {
49
+ owner = input.requestedOwner;
50
+ }
51
+ else if (existing && existing.owner !== undefined) {
52
+ owner = existing.owner;
53
+ }
54
+ else {
55
+ owner = "default";
56
+ }
57
+ registry.slots[String(port)] = {
58
+ worktree: input.currentWorktree,
59
+ branch: input.branch,
60
+ owner,
61
+ };
62
+ writeSlots(input.mainWorktree, registry);
63
+ return { port, owner };
64
+ }
65
+ export function validateSlotAvailability(slotArg, ctx) {
66
+ if (slotArg === undefined)
67
+ return;
68
+ const port = Number(slotArg);
69
+ if (!isValidPort(port, ctx.scheme)) {
70
+ console.error(`Error: Slot must be a valid port: ${allPorts(ctx.scheme).join(", ")}.`);
71
+ process.exit(1);
72
+ }
73
+ const registry = readSlots(ctx.mainWorktree);
74
+ const existing = registry.slots[String(port)];
75
+ if (existing && resolve(existing.worktree) !== resolve(ctx.currentWorktree)) {
76
+ console.error(`Error: Slot ${port} is already taken by ${existing.worktree} (branch: ${existing.branch}).`);
77
+ process.exit(1);
78
+ }
79
+ }
80
+ export function lookupSlotForCwd() {
81
+ const cwd = resolve(process.cwd());
82
+ // Reads slots.json relative to cwd's `.local` symlink (so works in linked worktrees too).
83
+ const filePath = SLOTS_FILE;
84
+ if (!existsSync(filePath))
85
+ return undefined;
86
+ const registry = JSON.parse(readFileSync(filePath, "utf-8"));
87
+ for (const [port, entry] of Object.entries(registry.slots)) {
88
+ if (resolve(entry.worktree) === cwd) {
89
+ return {
90
+ slot: Number(port),
91
+ worktree: entry.worktree,
92
+ branch: entry.branch,
93
+ owner: entry.owner ?? "default",
94
+ };
95
+ }
96
+ }
97
+ return undefined;
98
+ }
99
+ export function synthesizeMainSlot(basePort) {
100
+ const gitCommonDir = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf-8" }).trim();
101
+ const mainWorktree = dirname(gitCommonDir);
102
+ const cwd = resolve(process.cwd());
103
+ if (resolve(mainWorktree) !== cwd)
104
+ return undefined;
105
+ const branch = execFileSync("git", ["branch", "--show-current"], { encoding: "utf-8" }).trim();
106
+ return { slot: basePort, worktree: cwd, branch, owner: "default" };
107
+ }
108
+ export function resolveCurrentSlot(basePort) {
109
+ const slot = lookupSlotForCwd() ?? synthesizeMainSlot(basePort);
110
+ if (!slot) {
111
+ console.error("Error: No slot found for this worktree. Run setup-worktree first.");
112
+ process.exit(1);
113
+ }
114
+ return slot;
115
+ }
116
+ export function handleSetOwner(input) {
117
+ if (input.isMainWorktree) {
118
+ console.error("Error: --set-owner must be run from a linked worktree.");
119
+ process.exit(1);
120
+ }
121
+ const registry = readSlots(input.mainWorktree);
122
+ const resolvedCurrent = resolve(input.currentWorktree);
123
+ const entry = Object.entries(registry.slots).find(([, v]) => resolve(v.worktree) === resolvedCurrent);
124
+ if (!entry) {
125
+ console.error("Error: No slot found for this worktree in the registry.");
126
+ process.exit(1);
127
+ }
128
+ const [slotPort, slotData] = entry;
129
+ registry.slots[slotPort] = { ...slotData, owner: input.newOwner };
130
+ writeSlots(input.mainWorktree, registry);
131
+ return { slotPort, owner: input.newOwner };
132
+ }
@@ -0,0 +1,21 @@
1
+ export interface WorktreeContext {
2
+ currentWorktree: string;
3
+ mainWorktree: string;
4
+ isMainWorktree: boolean;
5
+ }
6
+ export interface RunCtx {
7
+ verbose: boolean;
8
+ }
9
+ export declare function detectWorktree(): WorktreeContext;
10
+ export declare function computeWorktreePath(mainWorktree: string, branch: string): string;
11
+ export declare function branchExists(branch: string): boolean;
12
+ export declare function useExistingBranch(branch: string, ctx: WorktreeContext, run: RunCtx): WorktreeContext;
13
+ export declare function createBranch(requestedBranch: string, ctx: WorktreeContext, run: RunCtx): WorktreeContext;
14
+ export declare function verifyBranchAbsentFromRemote(branch: string, run: RunCtx): void;
15
+ export declare function getCurrentBranch(worktreePath: string): string;
16
+ export declare function enforceWorktreeMode(args: {
17
+ use?: string;
18
+ create?: string;
19
+ self?: boolean;
20
+ }, ctx: WorktreeContext): void;
21
+ export declare function removeWorktree(worktreePath: string, run: RunCtx): void;
@@ -0,0 +1,92 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ function stdioFor(ctx) {
4
+ return ctx.verbose ? "inherit" : "pipe";
5
+ }
6
+ export function detectWorktree() {
7
+ const currentWorktree = execFileSync("git", ["rev-parse", "--show-toplevel"], {
8
+ encoding: "utf-8",
9
+ }).trim();
10
+ const gitCommonDir = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf-8" }).trim();
11
+ const mainWorktree = dirname(gitCommonDir);
12
+ const isMainWorktree = resolve(currentWorktree) === resolve(mainWorktree);
13
+ return { currentWorktree, mainWorktree, isMainWorktree };
14
+ }
15
+ export function computeWorktreePath(mainWorktree, branch) {
16
+ const repoName = basename(mainWorktree);
17
+ const sanitized = branch.replaceAll("/", "-");
18
+ return join(dirname(mainWorktree), `${repoName}-${sanitized}`);
19
+ }
20
+ export function branchExists(branch) {
21
+ try {
22
+ execFileSync("git", ["rev-parse", "--verify", branch], { stdio: "pipe" });
23
+ return true;
24
+ }
25
+ catch {
26
+ try {
27
+ execFileSync("git", ["rev-parse", "--verify", `origin/${branch}`], { stdio: "pipe" });
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ }
35
+ export function useExistingBranch(branch, ctx, run) {
36
+ if (!branchExists(branch)) {
37
+ console.error(`Error: Branch "${branch}" does not exist locally or on the remote.`);
38
+ process.exit(1);
39
+ }
40
+ const worktreePath = computeWorktreePath(ctx.mainWorktree, branch);
41
+ execFileSync("git", ["worktree", "add", worktreePath, branch], { stdio: stdioFor(run) });
42
+ return { ...ctx, currentWorktree: worktreePath, isMainWorktree: false };
43
+ }
44
+ export function createBranch(requestedBranch, ctx, run) {
45
+ let finalBranch = requestedBranch;
46
+ if (branchExists(finalBranch)) {
47
+ let suffix = 2;
48
+ while (branchExists(`${requestedBranch}-${suffix}`)) {
49
+ ++suffix;
50
+ }
51
+ finalBranch = `${requestedBranch}-${suffix}`;
52
+ }
53
+ const worktreePath = computeWorktreePath(ctx.mainWorktree, finalBranch);
54
+ execFileSync("git", ["worktree", "add", "-b", finalBranch, worktreePath], {
55
+ stdio: stdioFor(run),
56
+ });
57
+ console.log(`Branch: ${finalBranch}`);
58
+ return { ...ctx, currentWorktree: worktreePath, isMainWorktree: false };
59
+ }
60
+ export function verifyBranchAbsentFromRemote(branch, run) {
61
+ execFileSync("git", ["fetch"], { stdio: stdioFor(run) });
62
+ const remoteBranches = execFileSync("git", ["branch", "-r", "--list", `origin/${branch}`], {
63
+ encoding: "utf-8",
64
+ }).trim();
65
+ if (remoteBranches.length > 0) {
66
+ console.error(`Error: Branch "${branch}" still exists on the remote. Use --no-remote-check to skip this verification.`);
67
+ process.exit(1);
68
+ }
69
+ }
70
+ export function getCurrentBranch(worktreePath) {
71
+ return execFileSync("git", ["branch", "--show-current"], {
72
+ encoding: "utf-8",
73
+ cwd: worktreePath,
74
+ }).trim();
75
+ }
76
+ export function enforceWorktreeMode(args, ctx) {
77
+ if (args.use || args.create) {
78
+ if (!ctx.isMainWorktree) {
79
+ console.error("Error: --use and --create must be run from the main worktree.");
80
+ process.exit(1);
81
+ }
82
+ }
83
+ else if (args.self) {
84
+ if (ctx.isMainWorktree) {
85
+ console.error("Error: --self must be run from a linked worktree, not from the main worktree.");
86
+ process.exit(1);
87
+ }
88
+ }
89
+ }
90
+ export function removeWorktree(worktreePath, run) {
91
+ execFileSync("git", ["worktree", "remove", "--force", worktreePath], { stdio: stdioFor(run) });
92
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@paleo/worktree-env",
3
+ "version": "0.1.0",
4
+ "description": "Worktree-based concurrent local environment kernel.",
5
+ "keywords": [
6
+ "worktree",
7
+ "environment",
8
+ "concurrent",
9
+ "local"
10
+ ],
11
+ "license": "CC0-1.0",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/paleo/alignfirst.git",
15
+ "directory": "packages/worktree-env"
16
+ },
17
+ "engines": {
18
+ "node": ">=22.12.0"
19
+ },
20
+ "packageManager": "npm@11.11.0",
21
+ "type": "module",
22
+ "main": "dist/index.js",
23
+ "types": "dist/index.d.ts",
24
+ "exports": {
25
+ ".": {
26
+ "import": "./dist/index.js",
27
+ "types": "./dist/index.d.ts"
28
+ }
29
+ },
30
+ "files": [
31
+ "dist"
32
+ ],
33
+ "scripts": {
34
+ "build": "tsc",
35
+ "clear": "rimraf dist/*",
36
+ "test": "vitest run",
37
+ "test:watch": "vitest"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "~24.12.3",
41
+ "rimraf": "~6.1.3",
42
+ "typescript": "~6.0.3",
43
+ "vitest": "~4.1.5"
44
+ }
45
+ }