@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 +265 -0
- package/dist/aliases.js +32 -0
- package/dist/cli.js +5 -0
- package/dist/config.js +127 -0
- package/dist/errors.js +19 -0
- package/dist/git.js +293 -0
- package/dist/interactive.js +206 -0
- package/dist/metadata.js +83 -0
- package/dist/output.js +211 -0
- package/dist/process.js +46 -0
- package/dist/program.js +293 -0
- package/dist/projects.js +308 -0
- package/dist/tmux.js +146 -0
- package/dist/types.js +2 -0
- package/package.json +67 -0
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.
|
package/dist/aliases.js
ADDED
|
@@ -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
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
|
+
}
|