@paleo/workspace 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,124 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ export function detectWorktree() {
5
+ const currentWorktree = execFileSync("git", ["rev-parse", "--show-toplevel"], {
6
+ encoding: "utf-8",
7
+ }).trim();
8
+ const gitCommonDir = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf-8" }).trim();
9
+ const mainWorktree = dirname(gitCommonDir);
10
+ const isMainWorktree = resolve(currentWorktree) === resolve(mainWorktree);
11
+ return { currentWorktree, mainWorktree, isMainWorktree };
12
+ }
13
+ export function enforceWorktreeMode(command, ctx) {
14
+ // Adding a worktree for a branch must happen from the main worktree. A branch-less
15
+ // `workspace setup` runs anywhere: linked worktree (retry path) or main (initial bootstrap).
16
+ if (command.kind === "setup" && command.branch !== undefined && !ctx.isMainWorktree) {
17
+ console.error("Error: Adding a workspace for a branch must be run from the main worktree.");
18
+ process.exit(1);
19
+ }
20
+ }
21
+ export function useExistingBranch(branch, ctx, run, dirNameFn = defaultWorktreeDirName) {
22
+ if (!branchExists(branch)) {
23
+ console.error(`Error: Branch "${branch}" does not exist locally or on the remote.`);
24
+ process.exit(1);
25
+ }
26
+ const worktreePath = dedupeWorktreePath(computeWorktreePath(ctx.mainWorktree, branch, dirNameFn));
27
+ execFileSync("git", ["worktree", "add", worktreePath, branch], { stdio: stdioFor(run) });
28
+ return { ...ctx, currentWorktree: worktreePath, isMainWorktree: false };
29
+ }
30
+ export function createBranch(requestedBranch, ctx, run, dirNameFn = defaultWorktreeDirName) {
31
+ let finalBranch = requestedBranch;
32
+ if (branchExists(finalBranch)) {
33
+ let suffix = 2;
34
+ while (branchExists(`${requestedBranch}-${suffix}`)) {
35
+ ++suffix;
36
+ }
37
+ finalBranch = `${requestedBranch}-${suffix}`;
38
+ console.warn(`Warning: Branch "${requestedBranch}" already exists; using "${finalBranch}" instead.`);
39
+ }
40
+ const worktreePath = dedupeWorktreePath(computeWorktreePath(ctx.mainWorktree, finalBranch, dirNameFn));
41
+ execFileSync("git", ["worktree", "add", "-b", finalBranch, worktreePath], {
42
+ stdio: stdioFor(run),
43
+ });
44
+ return { ...ctx, currentWorktree: worktreePath, isMainWorktree: false };
45
+ }
46
+ export function verifyBranchAbsentFromRemote(branch, run) {
47
+ execFileSync("git", ["fetch"], { stdio: stdioFor(run) });
48
+ const remoteBranches = execFileSync("git", ["branch", "-r", "--list", `origin/${branch}`], {
49
+ encoding: "utf-8",
50
+ }).trim();
51
+ if (remoteBranches.length > 0) {
52
+ console.error(`Error: Branch "${branch}" still exists on the remote. Use --no-remote-check to skip this verification.`);
53
+ process.exit(1);
54
+ }
55
+ }
56
+ export function getWorktreeBranch(worktreePath) {
57
+ try {
58
+ const out = execFileSync("git", ["branch", "--show-current"], {
59
+ stdio: "pipe",
60
+ cwd: worktreePath,
61
+ })
62
+ .toString("utf-8")
63
+ .trim();
64
+ return out.length > 0 ? out : undefined;
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ }
70
+ export function removeWorktree(worktreePath, run) {
71
+ execFileSync("git", ["worktree", "remove", "--force", worktreePath], { stdio: stdioFor(run) });
72
+ }
73
+ /**
74
+ * Default {@link WorktreeDirNameFn}. Strips a recognizable ticket suffix from the last branch
75
+ * segment (`feat/ABC-123-extra` → `feat-ABC-123`), caps the result at 22 chars, and strips
76
+ * trailing dashes. Falls back to the full sanitized branch when no ticket pattern is found.
77
+ */
78
+ export const defaultWorktreeDirName = ({ branch, repoName }) => {
79
+ return `${repoName}-${shortenBranchSegment(branch)}`;
80
+ };
81
+ function shortenBranchSegment(branch) {
82
+ const parts = branch.split("/");
83
+ const last = parts[parts.length - 1] ?? "";
84
+ const match = last.match(/^([A-Za-z]+-\d+|\d+)/);
85
+ if (match) {
86
+ parts[parts.length - 1] = match[1];
87
+ }
88
+ let result = parts.join("-");
89
+ if (result.length > 22) {
90
+ result = result.slice(0, 22);
91
+ }
92
+ return result.replace(/-+$/, "");
93
+ }
94
+ export function computeWorktreePath(mainWorktree, branch, dirNameFn = defaultWorktreeDirName) {
95
+ const repoName = basename(mainWorktree);
96
+ return join(dirname(mainWorktree), dirNameFn({ branch, repoName }));
97
+ }
98
+ function dedupeWorktreePath(candidate) {
99
+ if (!existsSync(candidate))
100
+ return candidate;
101
+ let suffix = 2;
102
+ while (existsSync(`${candidate}-${suffix}`)) {
103
+ ++suffix;
104
+ }
105
+ return `${candidate}-${suffix}`;
106
+ }
107
+ function branchExists(branch) {
108
+ try {
109
+ execFileSync("git", ["rev-parse", "--verify", branch], { stdio: "pipe" });
110
+ return true;
111
+ }
112
+ catch {
113
+ try {
114
+ execFileSync("git", ["rev-parse", "--verify", `origin/${branch}`], { stdio: "pipe" });
115
+ return true;
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
121
+ }
122
+ function stdioFor(ctx) {
123
+ return ctx.verbose ? "inherit" : "pipe";
124
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@paleo/workspace",
3
+ "version": "0.11.0",
4
+ "description": "Run multiple git-worktree dev environments side by side.",
5
+ "keywords": [
6
+ "workspace",
7
+ "worktree",
8
+ "environment",
9
+ "concurrent",
10
+ "local"
11
+ ],
12
+ "license": "CC0-1.0",
13
+ "author": "Thomas MUR",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/paleo/alignfirst.git",
17
+ "directory": "packages/workspace"
18
+ },
19
+ "engines": {
20
+ "node": ">=22.11.0"
21
+ },
22
+ "packageManager": "npm@11.11.0",
23
+ "type": "module",
24
+ "main": "dist/index.js",
25
+ "types": "dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "import": "./dist/index.js",
29
+ "types": "./dist/index.d.ts"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "build": "tsc -p tsconfig.build.json",
40
+ "clear": "rimraf dist/*",
41
+ "lint": "biome check",
42
+ "test": "vitest run",
43
+ "test:watch": "vitest"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "~24.12.4",
47
+ "rimraf": "~6.1.3",
48
+ "typescript": "~6.0.3",
49
+ "vitest": "~4.1.7"
50
+ }
51
+ }