@mbluemer_2/gittyup 0.1.2

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 ADDED
@@ -0,0 +1,265 @@
1
+ # gittyup
2
+
3
+ `gittyup` is a filesystem-driven CLI for managing bare Git repositories and linked worktrees under a shared root such as `~/src`.
4
+
5
+ ## Install
6
+
7
+ From npm:
8
+
9
+ ```bash
10
+ npm install -g @mbluemer_2/gittyup
11
+ ```
12
+
13
+ Or with `pnpm`:
14
+
15
+ ```bash
16
+ pnpm add -g @mbluemer_2/gittyup
17
+ ```
18
+
19
+ From a GitHub release tarball:
20
+
21
+ ```bash
22
+ npm install -g "https://github.com/mbluemer/gittyup/releases/download/vX.Y.Z/gittyup-X.Y.Z.tgz"
23
+ ```
24
+
25
+ Or with `pnpm`:
26
+
27
+ ```bash
28
+ pnpm add -g "https://github.com/mbluemer/gittyup/releases/download/vX.Y.Z/gittyup-X.Y.Z.tgz"
29
+ ```
30
+
31
+ That installs the `gu` binary from the packaged `dist/` output attached to each GitHub release.
32
+ The npm package exposes the same `gu` binary.
33
+
34
+ ## Requirements
35
+
36
+ - `git` 2.17+
37
+ - `tmux` for `gittyup tmux ...`
38
+ - `fzf` for interactive worktree and tmux pickers
39
+
40
+ ## Root directory
41
+
42
+ By default, `gittyup` manages projects under `~/src`.
43
+
44
+ - Override it per command with `--root ~/somewhere`
45
+ - Or set `GITTYUP_ROOT=~/somewhere` to make a different default
46
+
47
+ ## Layout
48
+
49
+ Each project is stored as:
50
+
51
+ ```text
52
+ ~/src/myrepo/
53
+ repo.git/
54
+ main/
55
+ feature-login/
56
+ ```
57
+
58
+ `repo.git` is the canonical bare repository. Linked worktrees live beside it.
59
+
60
+ ## Commands
61
+
62
+ ```bash
63
+ gittyup project list
64
+ gu pl
65
+ gittyup project doctor
66
+ gu pd
67
+ gittyup project init get myrepo
68
+ gu pg myrepo
69
+ gittyup project init set myrepo "pnpm install"
70
+ gu ps myrepo "pnpm install"
71
+ gittyup project init clear myrepo
72
+ gu px myrepo
73
+ gittyup project create myrepo
74
+ gu pc myrepo
75
+ gittyup project clone git@github.com:owner/myrepo.git
76
+ gu pcl git@github.com:owner/myrepo.git
77
+ gittyup project import ~/code/existing-repo existing-repo-managed
78
+ gu pi ~/code/existing-repo existing-repo-managed
79
+ gittyup project rename some-repo-managed some-repo
80
+ gu pr some-repo-managed some-repo
81
+
82
+ gittyup worktree list
83
+ gu wl
84
+ gittyup worktree list myrepo
85
+ gittyup worktree add
86
+ gu wa
87
+ gittyup worktree add myrepo feature/login --alias feature-login
88
+ gittyup worktree add myrepo fix/bug --from main
89
+ gittyup worktree remove
90
+ gu wr
91
+ gittyup worktree remove myrepo feature-login
92
+
93
+ gittyup status
94
+ gu st
95
+ gittyup status myrepo
96
+ gu s myrepo
97
+
98
+ gittyup tmux launch myrepo
99
+ gu tl myrepo
100
+ gittyup tmux launch
101
+ gu tl
102
+ gittyup tmux switch
103
+ gu ts
104
+ ```
105
+
106
+ ## Shortcuts
107
+
108
+ `gu` supports flattened shortcuts for common commands:
109
+
110
+ ```text
111
+ p -> project
112
+ pl -> project list
113
+ pd -> project doctor
114
+ pg -> project init get
115
+ ps -> project init set
116
+ px -> project init clear
117
+ pc -> project create
118
+ pcl -> project clone
119
+ pi -> project import
120
+ pr -> project rename
121
+ w -> worktree
122
+ wl -> worktree list
123
+ wa -> worktree add
124
+ wr -> worktree remove
125
+ s/st -> status
126
+ t -> tmux
127
+ tl -> tmux launch
128
+ ts -> tmux switch
129
+ ```
130
+
131
+ ## Options
132
+
133
+ ```bash
134
+ gittyup --root ~/src
135
+ gittyup --json
136
+ ```
137
+
138
+ ## Project diagnostics
139
+
140
+ `gittyup project doctor` inspects every directory under the managed root and
141
+ reports both healthy projects and broken entries that are missing `repo.git` or
142
+ have invalid worktree metadata.
143
+
144
+ ## Project init commands
145
+
146
+ You can configure a per-project shell command to run whenever `gittyup worktree add`
147
+ creates a new linked worktree.
148
+
149
+ Example:
150
+
151
+ ```bash
152
+ gittyup project init set gittyup "pnpm install"
153
+ gittyup project init get gittyup
154
+ gittyup project init clear gittyup
155
+ ```
156
+
157
+ - The command runs with the new worktree as its working directory.
158
+ - If the init command fails, the worktree is kept and `gittyup` reports the error.
159
+ - The command is stored in `.gittyup.json` inside the managed project root.
160
+
161
+ ## Interactive worktree add
162
+
163
+ `gittyup worktree add` can run interactively when arguments are omitted.
164
+
165
+ - With no arguments, it opens `fzf` to select a project and then prompts for a branch name.
166
+ - With only a project name, it prompts for the branch name.
167
+ - The worktree alias is still derived automatically unless you pass `--alias`.
168
+
169
+ Example:
170
+
171
+ ```bash
172
+ gittyup worktree add
173
+ gittyup worktree add myrepo
174
+ ```
175
+
176
+ ## Interactive worktree remove
177
+
178
+ `gittyup worktree remove` can also run interactively when arguments are omitted.
179
+
180
+ - With no arguments, it opens a single `fzf` picker showing worktrees as `<project>:<worktree>`.
181
+ - With only a project name, it opens `fzf` for that project's worktrees using the same `<project>:<worktree>` format.
182
+ - The canonical `main` worktree is protected and cannot be removed.
183
+
184
+ Example:
185
+
186
+ ```bash
187
+ gittyup worktree remove
188
+ gittyup worktree remove myrepo
189
+ ```
190
+
191
+ ## Interactive tmux launch
192
+
193
+ `gittyup tmux launch` can also run interactively when arguments are omitted.
194
+
195
+ - With no arguments, it opens `fzf` for all linked worktrees as `<project>:<worktree>`.
196
+ - With only a project name, it opens `fzf` for that project's worktrees using the same format.
197
+
198
+ Example:
199
+
200
+ ```bash
201
+ gittyup tmux launch
202
+ gittyup tmux launch myrepo
203
+ gu tl
204
+ gu tl myrepo
205
+ ```
206
+
207
+ ## Development
208
+
209
+ ```bash
210
+ pnpm install
211
+ pnpm build
212
+ pnpm test
213
+ pnpm lint
214
+ pnpm dev --help
215
+ pnpm dev project list
216
+ pnpm dev -- project list
217
+ ```
218
+
219
+ Both `pnpm dev project list` and `pnpm dev -- project list` are supported.
220
+
221
+ ## Releases
222
+
223
+ Updating `package.json` to a new version and merging that change to `main`
224
+ automatically runs the release workflow.
225
+
226
+ - CI runs lint, tests, and build
227
+ - The workflow creates and pushes the matching `vX.Y.Z` tag
228
+ - `pnpm pack` creates an installable tarball
229
+ - GitHub Releases gets the tarball and `SHA256SUMS.txt` attached automatically
230
+ - The same workflow publishes `@mbluemer_2/gittyup` to npm
231
+
232
+ You can still create a release manually by pushing a tag like `v0.1.0` yourself.
233
+
234
+ Version bump policy for agents is documented in `AGENTS.md`.
235
+
236
+ ## Local project bootstrap
237
+
238
+ `gittyup project create <name>` bootstraps a new local project by:
239
+
240
+ ```text
241
+ 1. Creating repo.git
242
+ 2. Seeding an initial empty commit on main
243
+ 3. Creating the linked main/ worktree
244
+ ```
245
+
246
+ That makes new local projects immediately usable with additional worktrees.
247
+
248
+ ## Import existing repos
249
+
250
+ `gittyup project import <source> [name]` creates a new managed project from an
251
+ existing local non-bare Git repository.
252
+
253
+ Example:
254
+
255
+ ```bash
256
+ gittyup project import ~/src/some-repo some-repo-managed
257
+ ```
258
+
259
+ This does not rewrite the source repository in place. It creates a new managed
260
+ project directory under your configured root with `repo.git` and `main/`.
261
+
262
+ ## Rename managed projects
263
+
264
+ `gittyup project rename <project> <name>` renames the managed project folder and
265
+ repairs the linked worktree metadata for the new paths.
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeArgv = normalizeArgv;
4
+ const COMMAND_ALIASES = {
5
+ p: ['project'],
6
+ pl: ['project', 'list'],
7
+ pd: ['project', 'doctor'],
8
+ pg: ['project', 'init', 'get'],
9
+ ps: ['project', 'init', 'set'],
10
+ px: ['project', 'init', 'clear'],
11
+ pc: ['project', 'create'],
12
+ pcl: ['project', 'clone'],
13
+ pi: ['project', 'import'],
14
+ pr: ['project', 'rename'],
15
+ w: ['worktree'],
16
+ wl: ['worktree', 'list'],
17
+ wa: ['worktree', 'add'],
18
+ wr: ['worktree', 'remove'],
19
+ s: ['status'],
20
+ st: ['status'],
21
+ t: ['tmux'],
22
+ tl: ['tmux', 'launch'],
23
+ ts: ['tmux', 'switch'],
24
+ };
25
+ function normalizeArgv(argv) {
26
+ const normalized = argv[2] === '--' ? [argv[0], argv[1], ...argv.slice(3)] : [...argv];
27
+ const alias = normalized[2] ? COMMAND_ALIASES[normalized[2]] : undefined;
28
+ if (!alias) {
29
+ return normalized;
30
+ }
31
+ return [normalized[0], normalized[1], ...alias, ...normalized.slice(3)];
32
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const program_1 = require("./program");
5
+ void (0, program_1.runCli)();
package/dist/config.js ADDED
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.PROJECT_METADATA_FILENAME = exports.MAIN_WORKTREE_ALIAS = exports.BARE_REPO_DIRNAME = exports.DEFAULT_ROOT_DIR = void 0;
7
+ exports.expandHome = expandHome;
8
+ exports.resolveRootDir = resolveRootDir;
9
+ exports.validateProjectName = validateProjectName;
10
+ exports.validateAlias = validateAlias;
11
+ exports.deriveAlias = deriveAlias;
12
+ exports.inferProjectName = inferProjectName;
13
+ exports.validateBranchName = validateBranchName;
14
+ exports.validateRemoteUrl = validateRemoteUrl;
15
+ exports.buildProjectPaths = buildProjectPaths;
16
+ const node_os_1 = __importDefault(require("node:os"));
17
+ const node_path_1 = __importDefault(require("node:path"));
18
+ const errors_1 = require("./errors");
19
+ exports.DEFAULT_ROOT_DIR = node_path_1.default.join(node_os_1.default.homedir(), 'src');
20
+ exports.BARE_REPO_DIRNAME = 'repo.git';
21
+ exports.MAIN_WORKTREE_ALIAS = 'main';
22
+ exports.PROJECT_METADATA_FILENAME = '.gittyup.json';
23
+ function expandHome(value) {
24
+ if (value === '~') {
25
+ return node_os_1.default.homedir();
26
+ }
27
+ if (value.startsWith('~/')) {
28
+ return node_path_1.default.join(node_os_1.default.homedir(), value.slice(2));
29
+ }
30
+ if (value.startsWith('$HOME/')) {
31
+ return node_path_1.default.join(node_os_1.default.homedir(), value.slice(6));
32
+ }
33
+ return value;
34
+ }
35
+ function resolveRootDir(input) {
36
+ return node_path_1.default.resolve(expandHome(input ?? process.env.GITTYUP_ROOT ?? exports.DEFAULT_ROOT_DIR));
37
+ }
38
+ function validateProjectName(name) {
39
+ if (name.trim().length === 0) {
40
+ throw new errors_1.CliError('Project name cannot be empty.');
41
+ }
42
+ if (name !== name.trim()) {
43
+ throw new errors_1.CliError('Project name cannot start or end with whitespace.');
44
+ }
45
+ if (/[/\\]/.test(name)) {
46
+ throw new errors_1.CliError('Project name must be a single directory name.');
47
+ }
48
+ if (name === '.' || name === '..') {
49
+ throw new errors_1.CliError("Project name must not be '.' or '..'.");
50
+ }
51
+ return name;
52
+ }
53
+ function validateAlias(alias) {
54
+ if (alias.trim().length === 0) {
55
+ throw new errors_1.CliError('Worktree alias cannot be empty.');
56
+ }
57
+ if (alias !== alias.trim()) {
58
+ throw new errors_1.CliError('Worktree alias cannot start or end with whitespace.');
59
+ }
60
+ if (/[/\\]/.test(alias)) {
61
+ throw new errors_1.CliError('Worktree alias must be a single directory name.');
62
+ }
63
+ if (alias === '.' || alias === '..') {
64
+ throw new errors_1.CliError("Worktree alias must not be '.' or '..'.");
65
+ }
66
+ return alias;
67
+ }
68
+ function deriveAlias(branch) {
69
+ const alias = branch
70
+ .replace(/[/\\]+/g, '-')
71
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
72
+ .replace(/-+/g, '-')
73
+ .replace(/^-|-$/g, '');
74
+ return validateAlias(alias || 'worktree');
75
+ }
76
+ function inferProjectName(remote) {
77
+ const trimmed = remote.trim().replace(/\/$/, '');
78
+ const scpTail = trimmed.includes(':') && !trimmed.includes('://')
79
+ ? trimmed.slice(trimmed.lastIndexOf(':') + 1)
80
+ : trimmed;
81
+ const tail = scpTail.split('/').pop() ?? scpTail;
82
+ const name = tail.endsWith('.git') ? tail.slice(0, -4) : tail;
83
+ return validateProjectName(name);
84
+ }
85
+ // eslint-disable-next-line no-control-regex
86
+ const INVALID_BRANCH_CHARS = /[\x00-\x1f\x7f~^:?*[\\]/;
87
+ const INVALID_BRANCH_START = /^[./]/;
88
+ const INVALID_BRANCH_END = /[./]$/;
89
+ const INVALID_BRANCH_SEQUENCE = /(\.\.)|(\/\/)|(@\{)/;
90
+ function validateBranchName(branch) {
91
+ const trimmed = branch.trim();
92
+ if (trimmed.length === 0) {
93
+ throw new errors_1.CliError('Branch name cannot be empty.');
94
+ }
95
+ if (INVALID_BRANCH_CHARS.test(trimmed)) {
96
+ throw new errors_1.CliError('Branch name contains invalid characters (control chars, ~, ^, :, ?, *, [, \\).');
97
+ }
98
+ if (INVALID_BRANCH_START.test(trimmed)) {
99
+ throw new errors_1.CliError('Branch name cannot start with . or /.');
100
+ }
101
+ if (INVALID_BRANCH_END.test(trimmed)) {
102
+ throw new errors_1.CliError('Branch name cannot end with . or /.');
103
+ }
104
+ if (INVALID_BRANCH_SEQUENCE.test(trimmed)) {
105
+ throw new errors_1.CliError('Branch name cannot contain "..", "//", or "@{".');
106
+ }
107
+ return trimmed;
108
+ }
109
+ function validateRemoteUrl(remote) {
110
+ const trimmed = remote.trim();
111
+ if (trimmed.length === 0) {
112
+ throw new errors_1.CliError('Remote URL cannot be empty.');
113
+ }
114
+ return trimmed;
115
+ }
116
+ function buildProjectPaths(rootDir, projectName) {
117
+ const name = validateProjectName(projectName);
118
+ const rootPath = node_path_1.default.join(rootDir, name);
119
+ const gitDir = node_path_1.default.join(rootPath, exports.BARE_REPO_DIRNAME);
120
+ const metadataPath = node_path_1.default.join(rootPath, exports.PROJECT_METADATA_FILENAME);
121
+ return {
122
+ name,
123
+ rootPath,
124
+ gitDir,
125
+ metadataPath,
126
+ };
127
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CliError = void 0;
4
+ exports.toErrorMessage = toErrorMessage;
5
+ class CliError extends Error {
6
+ exitCode;
7
+ constructor(message, exitCode = 1) {
8
+ super(message);
9
+ this.name = 'CliError';
10
+ this.exitCode = exitCode;
11
+ }
12
+ }
13
+ exports.CliError = CliError;
14
+ function toErrorMessage(error) {
15
+ if (error instanceof Error) {
16
+ return error.message;
17
+ }
18
+ return String(error);
19
+ }