arborista 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/LICENSE +21 -0
- package/README.md +54 -0
- package/bin/arb.js +31 -0
- package/bin/arborista.js +31 -0
- package/docs/comparison-to-git.md +270 -0
- package/package.json +46 -0
- package/src/index.ts +653 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mark Jaquith
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# arborista
|
|
2
|
+
|
|
3
|
+
`arborista` is a Bun/TypeScript CLI for elegant git worktree oversight.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
`arborista` is published on npm, but it runs on Bun.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install -g arborista
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
If Bun is not already installed, install it first from `https://bun.sh`.
|
|
14
|
+
|
|
15
|
+
## Git Hooks
|
|
16
|
+
|
|
17
|
+
This project uses [hk](https://github.com/jdx/hk) for git hook management. The configuration is in `hk.pkl`.
|
|
18
|
+
|
|
19
|
+
To install the hooks in your local clone:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
hk install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The configured hooks run `bun run check` on `pre-commit` and `pre-push`.
|
|
26
|
+
|
|
27
|
+
## Config
|
|
28
|
+
|
|
29
|
+
Config lives at `~/.config/arborista/arborista.jsonc`.
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
|
|
33
|
+
```jsonc
|
|
34
|
+
{
|
|
35
|
+
"root": "~/Dev/.arborista",
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Only `root` is used in v1. Commands always act on repo found from current working directory.
|
|
40
|
+
|
|
41
|
+
## Commands
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
arborista list [--dir]
|
|
45
|
+
arborista ls [--dir]
|
|
46
|
+
arborista new <branch> [--from <start-point>]
|
|
47
|
+
arborista dir <branch>
|
|
48
|
+
arborista rm <branch> [--branch]
|
|
49
|
+
arborista move
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Comparison to Git
|
|
53
|
+
|
|
54
|
+
For a 1:1 mapping from each `arb` command to the exact underlying Git commands, see `docs/comparison-to-git.md`.
|
package/bin/arb.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "node:child_process"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { fileURLToPath } from "node:url"
|
|
6
|
+
|
|
7
|
+
const binDirectory = path.dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
const entrypoint = path.join(binDirectory, "..", "src", "index.ts")
|
|
9
|
+
|
|
10
|
+
const result = spawnSync(
|
|
11
|
+
"bun",
|
|
12
|
+
["run", "--bun", entrypoint, ...process.argv.slice(2)],
|
|
13
|
+
{
|
|
14
|
+
stdio: "inherit",
|
|
15
|
+
env: {
|
|
16
|
+
...process.env,
|
|
17
|
+
ARBORISTA_EXECUTABLE: "arb",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if (result.error && result.error.code === "ENOENT") {
|
|
23
|
+
console.error("arborista requires Bun. Install it from https://bun.sh")
|
|
24
|
+
process.exit(1)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (result.error) {
|
|
28
|
+
throw result.error
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
process.exit(result.status ?? 1)
|
package/bin/arborista.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "node:child_process"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { fileURLToPath } from "node:url"
|
|
6
|
+
|
|
7
|
+
const binDirectory = path.dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
const entrypoint = path.join(binDirectory, "..", "src", "index.ts")
|
|
9
|
+
|
|
10
|
+
const result = spawnSync(
|
|
11
|
+
"bun",
|
|
12
|
+
["run", "--bun", entrypoint, ...process.argv.slice(2)],
|
|
13
|
+
{
|
|
14
|
+
stdio: "inherit",
|
|
15
|
+
env: {
|
|
16
|
+
...process.env,
|
|
17
|
+
ARBORISTA_EXECUTABLE: "arborista",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if (result.error && result.error.code === "ENOENT") {
|
|
23
|
+
console.error("arborista requires Bun. Install it from https://bun.sh")
|
|
24
|
+
process.exit(1)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (result.error) {
|
|
28
|
+
throw result.error
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
process.exit(result.status ?? 1)
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# `arb` vs raw Git
|
|
2
|
+
|
|
3
|
+
This document maps each `arb` command to the exact Git commands it runs today.
|
|
4
|
+
|
|
5
|
+
Assumptions used below:
|
|
6
|
+
|
|
7
|
+
- `<repo>`: the primary repository checkout
|
|
8
|
+
- `<branch>`: the branch name you pass to `arb`
|
|
9
|
+
- `<base>`: either the explicit `--from` value or the default base branch `arb` resolves
|
|
10
|
+
- `<managed-root>`: the configured root, defaulting to `~/.arborista`
|
|
11
|
+
- `<worktree-path>`: `<managed-root>/<repo-name>/<branch-with-slashes-replaced-by-dashes>`
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
branch: feature/api-cleanup
|
|
17
|
+
worktree-path: ~/.arborista/arborista/feature-api-cleanup
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## `arb new <branch>`
|
|
21
|
+
|
|
22
|
+
`arb` always inspects existing worktrees first:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
git worktree list --porcelain
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
If any listed worktree path no longer exists on disk, `arb` also runs:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
git worktree prune --expire now
|
|
32
|
+
git worktree list --porcelain
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Then it checks whether the local branch already exists:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
git show-ref --verify --quiet refs/heads/<branch>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
If the branch already exists, `arb new <branch>` runs:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
git worktree add <worktree-path> <branch>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
If the branch does not already exist, `arb new <branch>` resolves a base branch and then runs:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
git symbolic-ref refs/remotes/origin/HEAD
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
If that fails, it tries:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
git rev-parse --verify --quiet origin/main^{commit}
|
|
57
|
+
git rev-parse --verify --quiet origin/master^{commit}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Then it creates both the branch and worktree with:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
git worktree add -b <branch> <worktree-path> <base>
|
|
64
|
+
git config branch.<branch>.remote origin
|
|
65
|
+
git config branch.<branch>.merge refs/heads/<branch>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
If you pass `--from <start-point>`, `arb` skips base-branch detection and uses exactly:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
git worktree add -b <branch> <worktree-path> <start-point>
|
|
72
|
+
git config branch.<branch>.remote origin
|
|
73
|
+
git config branch.<branch>.merge refs/heads/<branch>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## `arb dir <branch>`
|
|
77
|
+
|
|
78
|
+
`arb` does not have a single raw Git equivalent here. It computes the managed path and validates repo state with:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
git worktree list --porcelain
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
If any listed worktree path no longer exists on disk, `arb` also runs:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
git worktree prune --expire now
|
|
88
|
+
git worktree list --porcelain
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
If it needs to distinguish between "branch exists but is unmanaged" and "branch does not exist", it also runs:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
git show-ref --verify --quiet refs/heads/<branch>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
If the branch is already managed at the expected location, `arb` prints `<worktree-path>`.
|
|
98
|
+
|
|
99
|
+
## `arb list`
|
|
100
|
+
|
|
101
|
+
`arb list` runs:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
git worktree list --porcelain
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
If any listed worktree path no longer exists on disk, `arb` also runs:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
git worktree prune --expire now
|
|
111
|
+
git worktree list --porcelain
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Then `arb` filters that output to only worktrees inside its managed layout and prints branch names.
|
|
115
|
+
|
|
116
|
+
## `arb list --dir`
|
|
117
|
+
|
|
118
|
+
Same Git command sequence as `arb list`:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
git worktree list --porcelain
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Possibly followed by:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
git worktree prune --expire now
|
|
128
|
+
git worktree list --porcelain
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Then `arb` prints the matching managed worktree directories instead of branch names.
|
|
132
|
+
|
|
133
|
+
## `arb rm <branch>`
|
|
134
|
+
|
|
135
|
+
`arb` first inspects worktrees:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
git worktree list --porcelain
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
If any listed worktree path no longer exists on disk, `arb` also runs:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
git worktree prune --expire now
|
|
145
|
+
git worktree list --porcelain
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
If it finds the managed worktree for `<branch>`, it removes it with:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
git worktree remove <worktree-path>
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
That is the full raw Git equivalent for plain `arb rm <branch>`.
|
|
155
|
+
|
|
156
|
+
## `arb rm <branch> --branch`
|
|
157
|
+
|
|
158
|
+
`arb` first inspects worktrees:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
git worktree list --porcelain
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
If any listed worktree path no longer exists on disk, `arb` also runs:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
git worktree prune --expire now
|
|
168
|
+
git worktree list --porcelain
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Then it checks whether the local branch exists:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
git show-ref --verify --quiet refs/heads/<branch>
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
If the managed worktree exists, it runs:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
git worktree remove <worktree-path>
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
If the local branch exists, it also runs:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
git branch -d <branch>
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## `arb move`
|
|
190
|
+
|
|
191
|
+
`arb move` only works when run from inside a Git worktree on a branch with no uncommitted changes.
|
|
192
|
+
|
|
193
|
+
It first identifies the repo root and current checkout:
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
git rev-parse --show-toplevel
|
|
197
|
+
git rev-parse --show-toplevel
|
|
198
|
+
git symbolic-ref --quiet --short HEAD
|
|
199
|
+
git status --porcelain
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Then it inspects known worktrees:
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
git worktree list --porcelain
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
If any listed worktree path no longer exists on disk, `arb` also runs:
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
git worktree prune --expire now
|
|
212
|
+
git worktree list --porcelain
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Next it chooses a safe branch to switch the current checkout to before moving the current branch into a managed worktree.
|
|
216
|
+
|
|
217
|
+
To resolve the preferred fallback branch, it tries:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
git symbolic-ref refs/remotes/origin/HEAD
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
If that fails, it tries:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
git rev-parse --verify --quiet origin/main^{commit}
|
|
227
|
+
git rev-parse --verify --quiet origin/master^{commit}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Then it checks for a local branch to switch to. The first check is the local name derived from the resolved base branch, if that name is different from the current branch. For example, if the resolved base branch is `origin/main`, the first local-branch check is:
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
git show-ref --verify --quiet refs/heads/main
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
After that, it tries `main` and `master` as fallbacks, skipping duplicates and skipping the current branch. So depending on the repo state, it may also run:
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
git show-ref --verify --quiet refs/heads/main
|
|
240
|
+
git show-ref --verify --quiet refs/heads/master
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
If a safe local branch exists, `arb` switches away from the current branch with:
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
git switch <safe-branch>
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
If no safe local branch exists, `arb` detaches HEAD instead:
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
git switch --detach HEAD
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Finally it recreates the current branch as a managed worktree with:
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
git worktree add <worktree-path> <current-branch>
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## What `arb` adds beyond raw Git
|
|
262
|
+
|
|
263
|
+
The Git commands above are the real underlying operations. What `arb` adds is orchestration around them:
|
|
264
|
+
|
|
265
|
+
- A consistent managed path layout under `~/.arborista` by default
|
|
266
|
+
- Automatic branch-to-directory name normalization by replacing `/` with `-`
|
|
267
|
+
- Automatic stale-worktree pruning when `git worktree list --porcelain` references missing paths
|
|
268
|
+
- Default base-branch selection for `new`
|
|
269
|
+
- Safety checks and fallback switching logic for `move`
|
|
270
|
+
- A single command for "move this branch out of the main checkout and into the managed worktree layout"
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "arborista",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Bun CLI for managing git worktrees in a repo-specific layout.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bun",
|
|
7
|
+
"cli",
|
|
8
|
+
"git",
|
|
9
|
+
"worktree"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/markjaquith/arborista#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/markjaquith/arborista/issues"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/markjaquith/arborista.git"
|
|
19
|
+
},
|
|
20
|
+
"bin": {
|
|
21
|
+
"arb": "./bin/arb.js",
|
|
22
|
+
"arborista": "./bin/arborista.js"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"bin",
|
|
26
|
+
"docs",
|
|
27
|
+
"src",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"type": "module",
|
|
31
|
+
"scripts": {
|
|
32
|
+
"check": "bunx tsc --noEmit -p tsconfig.json",
|
|
33
|
+
"knip": "knip --treat-config-hints-as-errors",
|
|
34
|
+
"start": "bun run src/index.ts",
|
|
35
|
+
"prepublishOnly": "bun run check",
|
|
36
|
+
"pack:dry": "npm pack --dry-run"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"jsonc-parser": "^3.3.1"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"bun-types": "latest",
|
|
43
|
+
"knip": "^6.3.0",
|
|
44
|
+
"typescript": "^5.9.3"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"
|
|
4
|
+
import os from "node:os"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import { parse } from "jsonc-parser"
|
|
7
|
+
|
|
8
|
+
type Config = {
|
|
9
|
+
root?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type RepoContext = {
|
|
13
|
+
name: string
|
|
14
|
+
path: string
|
|
15
|
+
managedRoot: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Worktree = {
|
|
19
|
+
path: string
|
|
20
|
+
branch: string | null
|
|
21
|
+
head: string
|
|
22
|
+
detached: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type ParsedArgs = {
|
|
26
|
+
positionals: string[]
|
|
27
|
+
flags: Map<string, string | boolean>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const CONFIG_PATH = path.join(
|
|
31
|
+
os.homedir(),
|
|
32
|
+
".config",
|
|
33
|
+
"arborista",
|
|
34
|
+
"arborista.jsonc",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const EXECUTABLE_NAME = resolveExecutableName()
|
|
38
|
+
|
|
39
|
+
function main() {
|
|
40
|
+
try {
|
|
41
|
+
const config = loadConfig()
|
|
42
|
+
const [, , ...argv] = Bun.argv
|
|
43
|
+
const command = argv[0]
|
|
44
|
+
|
|
45
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
46
|
+
printHelp()
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
switch (command) {
|
|
51
|
+
case "new":
|
|
52
|
+
handleNew(parseArgs(argv.slice(1)), config)
|
|
53
|
+
return
|
|
54
|
+
case "dir":
|
|
55
|
+
handleDir(parseArgs(argv.slice(1)), config)
|
|
56
|
+
return
|
|
57
|
+
case "list":
|
|
58
|
+
case "ls":
|
|
59
|
+
handleList(parseArgs(argv.slice(1)), config)
|
|
60
|
+
return
|
|
61
|
+
case "rm":
|
|
62
|
+
handleRemove(parseArgs(argv.slice(1)), config)
|
|
63
|
+
return
|
|
64
|
+
case "move":
|
|
65
|
+
handleMove(parseArgs(argv.slice(1)), config)
|
|
66
|
+
return
|
|
67
|
+
default:
|
|
68
|
+
fail(`Unknown command: ${command}`)
|
|
69
|
+
}
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error instanceof ArboristaError) {
|
|
72
|
+
console.error(error.message)
|
|
73
|
+
process.exit(1)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
throw error
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class ArboristaError extends Error {}
|
|
81
|
+
|
|
82
|
+
function parseArgs(argv: string[]): ParsedArgs {
|
|
83
|
+
const positionals: string[] = []
|
|
84
|
+
const flags = new Map<string, string | boolean>()
|
|
85
|
+
|
|
86
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
87
|
+
const value = argv[index]
|
|
88
|
+
|
|
89
|
+
if (!value.startsWith("--")) {
|
|
90
|
+
positionals.push(value)
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const [rawName, inlineValue] = value.slice(2).split("=", 2)
|
|
95
|
+
|
|
96
|
+
if (inlineValue !== undefined) {
|
|
97
|
+
flags.set(rawName, inlineValue)
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const nextValue = argv[index + 1]
|
|
102
|
+
if (nextValue && !nextValue.startsWith("--")) {
|
|
103
|
+
flags.set(rawName, nextValue)
|
|
104
|
+
index += 1
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
flags.set(rawName, true)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { positionals, flags }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function loadConfig(): Config {
|
|
115
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
116
|
+
return {}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const rawConfig = readFileSync(CONFIG_PATH, "utf8")
|
|
120
|
+
const parsed = parse(rawConfig)
|
|
121
|
+
|
|
122
|
+
if (!parsed || typeof parsed !== "object") {
|
|
123
|
+
fail(`Invalid config file: ${CONFIG_PATH}`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return parsed as Config
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function handleNew(args: ParsedArgs, config: Config) {
|
|
130
|
+
const branch = args.positionals[0]
|
|
131
|
+
if (!branch) {
|
|
132
|
+
fail(`Usage: ${EXECUTABLE_NAME} new <branch> [--from <start-point>]`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
assertNoUnsupportedFlags(args, ["from"])
|
|
136
|
+
const repo = resolveRepoContext(config)
|
|
137
|
+
const worktreePath = managedWorktreePath(repo, branch)
|
|
138
|
+
const worktrees = getWorktrees(repo.path)
|
|
139
|
+
const matchingWorktree = worktrees.find((worktree) => worktree.branch === branch)
|
|
140
|
+
|
|
141
|
+
if (matchingWorktree) {
|
|
142
|
+
if (pathsEqual(matchingWorktree.path, worktreePath)) {
|
|
143
|
+
console.log(`Branch ${branch} is already managed at ${formatPath(matchingWorktree.path)}`)
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
fail(`Branch \"${branch}\" is already checked out at ${formatPath(matchingWorktree.path)}.`)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
assertPathAvailable(worktreePath, worktrees)
|
|
151
|
+
ensureDirectory(path.dirname(worktreePath))
|
|
152
|
+
|
|
153
|
+
if (localBranchExists(repo.path, branch)) {
|
|
154
|
+
console.log(`Creating worktree at ${formatPath(worktreePath)}`)
|
|
155
|
+
runGit(repo.path, ["worktree", "add", worktreePath, branch])
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const baseBranch = resolveBaseBranch(repo.path, getStringFlag(args, "from"))
|
|
160
|
+
console.log(`Creating branch ${branch} from ${baseBranch}`)
|
|
161
|
+
console.log(`Creating worktree at ${formatPath(worktreePath)}`)
|
|
162
|
+
runGit(repo.path, ["worktree", "add", "-b", branch, worktreePath, baseBranch])
|
|
163
|
+
configureBranchUpstream(repo.path, branch)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function handleDir(args: ParsedArgs, config: Config) {
|
|
167
|
+
const branch = args.positionals[0]
|
|
168
|
+
if (!branch) {
|
|
169
|
+
fail(`Usage: ${EXECUTABLE_NAME} dir <branch>`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
assertNoUnsupportedFlags(args, [])
|
|
173
|
+
const repo = resolveRepoContext(config)
|
|
174
|
+
const worktreePath = managedWorktreePath(repo, branch)
|
|
175
|
+
const worktrees = getWorktrees(repo.path)
|
|
176
|
+
const matchingWorktree = worktrees.find(
|
|
177
|
+
(worktree) => worktree.branch === branch && pathsEqual(worktree.path, worktreePath),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if (matchingWorktree) {
|
|
181
|
+
console.log(matchingWorktree.path)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const branchWorktree = worktrees.find((worktree) => worktree.branch === branch)
|
|
186
|
+
if (branchWorktree && pathsEqual(branchWorktree.path, repo.path)) {
|
|
187
|
+
fail(`No managed checkout found for branch \"${branch}\". Branch is checked out in primary repo at ${formatPath(repo.path)}. Run ${EXECUTABLE_NAME} move to move it into managed layout.`)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (branchWorktree) {
|
|
191
|
+
fail(`No managed checkout found for branch \"${branch}\". Branch is checked out at ${formatPath(branchWorktree.path)}.`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (localBranchExists(repo.path, branch)) {
|
|
195
|
+
fail(`No managed checkout found for branch \"${branch}\". Run ${EXECUTABLE_NAME} new ${branch}.`)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
fail(`Branch \"${branch}\" does not exist. Run ${EXECUTABLE_NAME} new ${branch}.`)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function handleList(args: ParsedArgs, config: Config) {
|
|
202
|
+
assertNoUnsupportedFlags(args, ["dir"])
|
|
203
|
+
const repo = resolveRepoContext(config)
|
|
204
|
+
const worktrees = getWorktrees(repo.path)
|
|
205
|
+
printRepoWorktrees(repo, worktrees, null, false, getBooleanFlag(args, "dir") ? "dir" : "branch")
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function handleRemove(args: ParsedArgs, config: Config) {
|
|
209
|
+
const branch = args.positionals[0]
|
|
210
|
+
if (!branch) {
|
|
211
|
+
fail(`Usage: ${EXECUTABLE_NAME} rm <branch> [--branch]`)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
assertNoUnsupportedFlags(args, ["branch"])
|
|
215
|
+
const repo = resolveRepoContext(config)
|
|
216
|
+
const worktreePath = managedWorktreePath(repo, branch)
|
|
217
|
+
const worktrees = getWorktrees(repo.path)
|
|
218
|
+
const worktree = worktrees.find(
|
|
219
|
+
(candidate) => candidate.branch === branch && pathsEqual(candidate.path, worktreePath),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
const shouldDeleteBranch = getBooleanFlag(args, "branch")
|
|
223
|
+
const branchExists = shouldDeleteBranch ? localBranchExists(repo.path, branch) : false
|
|
224
|
+
|
|
225
|
+
if (!worktree && !branchExists) {
|
|
226
|
+
fail(
|
|
227
|
+
`No managed worktree found for branch \"${branch}\" in repo \"${repo.name}\".`,
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (worktree) {
|
|
232
|
+
console.log(`Removing worktree ${formatPath(worktree.path)}`)
|
|
233
|
+
const removeArgs = ["worktree", "remove"]
|
|
234
|
+
removeArgs.push(worktree.path)
|
|
235
|
+
runGit(repo.path, removeArgs)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (branchExists) {
|
|
239
|
+
const branchArgs = ["branch", "-d", branch]
|
|
240
|
+
console.log(`Deleting branch ${branch}`)
|
|
241
|
+
runGit(repo.path, branchArgs)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function handleMove(args: ParsedArgs, config: Config) {
|
|
246
|
+
if (args.positionals.length > 0 || args.flags.size > 0) {
|
|
247
|
+
fail(`Usage: ${EXECUTABLE_NAME} move`)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const discoveredRepo = discoverGitRoot(process.cwd())
|
|
251
|
+
if (!discoveredRepo) {
|
|
252
|
+
fail("No repo context found.\nRun this inside a Git repository.")
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const repo = resolveRepoContext(config)
|
|
256
|
+
const currentWorktreePath = getCurrentWorktreePath(discoveredRepo)
|
|
257
|
+
const currentBranch = getCurrentBranch(discoveredRepo)
|
|
258
|
+
if (!currentBranch) {
|
|
259
|
+
fail("Cannot move a detached HEAD. Check out a branch first.")
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (isWorktreeDirty(discoveredRepo)) {
|
|
263
|
+
fail(
|
|
264
|
+
`Current worktree has uncommitted changes on \"${currentBranch}\". Commit or stash them before running ${EXECUTABLE_NAME} move.`,
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const targetPath = managedWorktreePath(repo, currentBranch)
|
|
269
|
+
if (pathsEqual(currentWorktreePath, targetPath)) {
|
|
270
|
+
console.log(`Branch ${currentBranch} is already managed at ${formatPath(targetPath)}`)
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const worktrees = getWorktrees(discoveredRepo)
|
|
275
|
+
assertPathAvailable(targetPath, worktrees)
|
|
276
|
+
ensureDirectory(path.dirname(targetPath))
|
|
277
|
+
|
|
278
|
+
const switchTarget = chooseSafeSwitchTarget(discoveredRepo, currentBranch)
|
|
279
|
+
if (switchTarget) {
|
|
280
|
+
console.log(`Switching current worktree to ${switchTarget}`)
|
|
281
|
+
runGit(discoveredRepo, ["switch", switchTarget])
|
|
282
|
+
} else {
|
|
283
|
+
console.log("Detaching current worktree HEAD")
|
|
284
|
+
runGit(discoveredRepo, ["switch", "--detach", "HEAD"])
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
console.log(`Creating worktree at ${formatPath(targetPath)}`)
|
|
288
|
+
console.log(`Moving branch ${currentBranch} into managed layout`)
|
|
289
|
+
runGit(discoveredRepo, ["worktree", "add", targetPath, currentBranch])
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function printRepoWorktrees(
|
|
293
|
+
repo: RepoContext,
|
|
294
|
+
worktrees: Worktree[],
|
|
295
|
+
currentWorktree: string | null,
|
|
296
|
+
includePrimary: boolean,
|
|
297
|
+
format: "branch" | "dir",
|
|
298
|
+
) {
|
|
299
|
+
const rows = worktrees
|
|
300
|
+
.filter((worktree) => includePrimary || isManagedWorktree(repo, worktree.path))
|
|
301
|
+
.sort((left, right) => sortWorktrees(left, right, repo))
|
|
302
|
+
|
|
303
|
+
if (!includePrimary && rows.length === 0) {
|
|
304
|
+
console.log("No managed worktrees found.")
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
for (const worktree of rows) {
|
|
309
|
+
if (format === "dir") {
|
|
310
|
+
console.log(formatPath(worktree.path))
|
|
311
|
+
continue
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
console.log(worktree.branch ?? "(detached HEAD)")
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function sortWorktrees(left: Worktree, right: Worktree, repo: RepoContext) {
|
|
319
|
+
const leftPrimary = pathsEqual(left.path, repo.path)
|
|
320
|
+
const rightPrimary = pathsEqual(right.path, repo.path)
|
|
321
|
+
|
|
322
|
+
if (leftPrimary && !rightPrimary) {
|
|
323
|
+
return -1
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (!leftPrimary && rightPrimary) {
|
|
327
|
+
return 1
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return (left.branch ?? "").localeCompare(right.branch ?? "")
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function resolveRepoContext(config: Config): RepoContext {
|
|
334
|
+
const managedRoot = configRoot(config)
|
|
335
|
+
|
|
336
|
+
const repoPath = discoverGitRoot(process.cwd())
|
|
337
|
+
if (!repoPath) {
|
|
338
|
+
fail("No repo context found.\nRun this inside a Git repository.")
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
name: path.basename(repoPath),
|
|
343
|
+
path: repoPath,
|
|
344
|
+
managedRoot,
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function configRoot(config: Config) {
|
|
349
|
+
return expandHome(config.root ?? "~/Dev/.arborista")
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function managedWorktreePath(repo: RepoContext, branch: string) {
|
|
353
|
+
return path.join(repo.managedRoot, repo.name, slugifyBranch(branch))
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function slugifyBranch(branch: string) {
|
|
357
|
+
return branch.replace(/[\\/]+/g, "-")
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function resolveBaseBranch(repoPath: string, explicitBase?: string) {
|
|
361
|
+
if (explicitBase) {
|
|
362
|
+
return explicitBase
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const remoteHead = runGit(repoPath, ["symbolic-ref", "refs/remotes/origin/HEAD"], {
|
|
366
|
+
allowFailure: true,
|
|
367
|
+
})
|
|
368
|
+
if (remoteHead.success) {
|
|
369
|
+
return remoteHead.stdout.replace(/^refs\/remotes\//, "")
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
for (const fallback of ["origin/main", "origin/master"]) {
|
|
373
|
+
if (refExists(repoPath, fallback)) {
|
|
374
|
+
return fallback
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const repoName = path.basename(repoPath)
|
|
379
|
+
fail(`Could not determine the default base branch for repo \"${repoName}\".
|
|
380
|
+
Tried:
|
|
381
|
+
- refs/remotes/origin/HEAD
|
|
382
|
+
- origin/main
|
|
383
|
+
- origin/master
|
|
384
|
+
|
|
385
|
+
Pass --from <branch>.`)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function chooseSafeSwitchTarget(repoPath: string, currentBranch: string) {
|
|
389
|
+
const candidates: string[] = []
|
|
390
|
+
let localBase: string | null = null
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
const resolvedBase = resolveBaseBranch(repoPath)
|
|
394
|
+
localBase = resolvedBase.includes("/")
|
|
395
|
+
? resolvedBase.slice(resolvedBase.lastIndexOf("/") + 1)
|
|
396
|
+
: resolvedBase
|
|
397
|
+
} catch (error) {
|
|
398
|
+
if (!(error instanceof ArboristaError)) {
|
|
399
|
+
throw error
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (localBase && localBase !== currentBranch) {
|
|
404
|
+
candidates.push(localBase)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
for (const fallback of ["main", "master"]) {
|
|
408
|
+
if (fallback !== currentBranch) {
|
|
409
|
+
candidates.push(fallback)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
for (const branch of candidates) {
|
|
414
|
+
if (localBranchExists(repoPath, branch)) {
|
|
415
|
+
return branch
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return null
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function getWorktrees(repoPath: string): Worktree[] {
|
|
423
|
+
let worktrees = parseWorktrees(runGit(repoPath, ["worktree", "list", "--porcelain"]).stdout)
|
|
424
|
+
|
|
425
|
+
if (worktrees.some((worktree) => !existsSync(worktree.path))) {
|
|
426
|
+
runGit(repoPath, ["worktree", "prune", "--expire", "now"])
|
|
427
|
+
worktrees = parseWorktrees(runGit(repoPath, ["worktree", "list", "--porcelain"]).stdout)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return worktrees
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function parseWorktrees(stdout: string) {
|
|
434
|
+
const blocks = stdout.trim().split("\n\n").filter(Boolean)
|
|
435
|
+
|
|
436
|
+
return blocks.map((block) => {
|
|
437
|
+
let worktreePath = ""
|
|
438
|
+
let branch: string | null = null
|
|
439
|
+
let head = ""
|
|
440
|
+
let detached = false
|
|
441
|
+
|
|
442
|
+
for (const line of block.split("\n")) {
|
|
443
|
+
if (line.startsWith("worktree ")) {
|
|
444
|
+
worktreePath = line.slice("worktree ".length)
|
|
445
|
+
continue
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (line.startsWith("branch ")) {
|
|
449
|
+
branch = line.slice("branch refs/heads/".length)
|
|
450
|
+
continue
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (line.startsWith("HEAD ")) {
|
|
454
|
+
head = line.slice("HEAD ".length)
|
|
455
|
+
continue
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (line === "detached") {
|
|
459
|
+
detached = true
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return { path: worktreePath, branch, head, detached }
|
|
464
|
+
})
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function getCurrentWorktreePath(repoPath: string) {
|
|
468
|
+
return runGit(repoPath, ["rev-parse", "--show-toplevel"]).stdout
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function getCurrentBranch(repoPath: string) {
|
|
472
|
+
const result = runGit(repoPath, ["symbolic-ref", "--quiet", "--short", "HEAD"], {
|
|
473
|
+
allowFailure: true,
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
if (!result.success) {
|
|
477
|
+
return null
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return result.stdout
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function isWorktreeDirty(repoPath: string) {
|
|
484
|
+
const result = runGit(repoPath, ["status", "--porcelain"], { allowFailure: true })
|
|
485
|
+
return result.stdout.length > 0
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function assertPathAvailable(worktreePath: string, worktrees: Worktree[]) {
|
|
489
|
+
if (!existsSync(worktreePath)) {
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const existingWorktree = worktrees.find((worktree) => pathsEqual(worktree.path, worktreePath))
|
|
494
|
+
if (existingWorktree) {
|
|
495
|
+
fail(`Managed worktree path already exists at ${formatPath(worktreePath)}.`)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
fail(
|
|
499
|
+
`Path ${formatPath(worktreePath)} already exists but is not a valid Git worktree. Remove it or choose a different branch name.`,
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function localBranchExists(repoPath: string, branch: string) {
|
|
504
|
+
return runGit(repoPath, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
|
|
505
|
+
allowFailure: true,
|
|
506
|
+
}).success
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function refExists(repoPath: string, ref: string) {
|
|
510
|
+
return runGit(repoPath, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
|
|
511
|
+
allowFailure: true,
|
|
512
|
+
}).success
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function configureBranchUpstream(repoPath: string, branch: string) {
|
|
516
|
+
runGit(repoPath, ["config", `branch.${branch}.remote`, "origin"])
|
|
517
|
+
runGit(repoPath, ["config", `branch.${branch}.merge`, `refs/heads/${branch}`])
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function isManagedWorktree(repo: RepoContext, worktreePath: string) {
|
|
521
|
+
const managedRepoPath = canonicalPath(path.join(repo.managedRoot, repo.name))
|
|
522
|
+
const resolvedWorktreePath = canonicalPath(worktreePath)
|
|
523
|
+
const managedPrefix = `${managedRepoPath}${path.sep}`
|
|
524
|
+
|
|
525
|
+
return resolvedWorktreePath === managedRepoPath
|
|
526
|
+
|| resolvedWorktreePath.startsWith(managedPrefix)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function discoverGitRoot(cwd: string) {
|
|
530
|
+
const result = runGit(cwd, ["rev-parse", "--show-toplevel"], { allowFailure: true })
|
|
531
|
+
if (!result.success) {
|
|
532
|
+
return null
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return result.stdout
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function runGit(
|
|
539
|
+
cwd: string,
|
|
540
|
+
args: string[],
|
|
541
|
+
options: { allowFailure?: boolean } = {},
|
|
542
|
+
) {
|
|
543
|
+
const result = Bun.spawnSync({
|
|
544
|
+
cmd: ["git", ...args],
|
|
545
|
+
cwd,
|
|
546
|
+
stdout: "pipe",
|
|
547
|
+
stderr: "pipe",
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
const stdout = result.stdout.toString().trim()
|
|
551
|
+
const stderr = result.stderr.toString().trim()
|
|
552
|
+
const success = result.exitCode === 0
|
|
553
|
+
|
|
554
|
+
if (!success && !options.allowFailure) {
|
|
555
|
+
fail(stderr || `git ${args.join(" ")} failed`)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return { success, stdout, stderr }
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function getStringFlag(args: ParsedArgs, name: string) {
|
|
562
|
+
const value = args.flags.get(name)
|
|
563
|
+
if (typeof value !== "string") {
|
|
564
|
+
return undefined
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return value
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function getBooleanFlag(args: ParsedArgs, name: string) {
|
|
571
|
+
return args.flags.get(name) === true
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function assertNoUnsupportedFlags(args: ParsedArgs, allowedFlags: string[]) {
|
|
575
|
+
for (const flagName of args.flags.keys()) {
|
|
576
|
+
if (!allowedFlags.includes(flagName)) {
|
|
577
|
+
fail(`Unknown flag: --${flagName}`)
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function ensureDirectory(directoryPath: string) {
|
|
583
|
+
mkdirSync(directoryPath, { recursive: true })
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function expandHome(value: string) {
|
|
587
|
+
if (!value.startsWith("~/")) {
|
|
588
|
+
return value
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return path.join(os.homedir(), value.slice(2))
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function formatPath(value: string) {
|
|
595
|
+
const homeDir = os.homedir()
|
|
596
|
+
if (value === homeDir) {
|
|
597
|
+
return "~"
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (value.startsWith(`${homeDir}/`)) {
|
|
601
|
+
return `~/${value.slice(homeDir.length + 1)}`
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return value
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function pathsEqual(left: string, right: string) {
|
|
608
|
+
return canonicalPath(left) === canonicalPath(right)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function canonicalPath(value: string) {
|
|
612
|
+
const resolvedPath = path.resolve(value)
|
|
613
|
+
|
|
614
|
+
if (!existsSync(resolvedPath)) {
|
|
615
|
+
return resolvedPath
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return realpathSync.native(resolvedPath)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function printHelp() {
|
|
622
|
+
console.log(`arborista: elegant git worktree oversight
|
|
623
|
+
|
|
624
|
+
Usage:
|
|
625
|
+
${EXECUTABLE_NAME} list [--dir]
|
|
626
|
+
${EXECUTABLE_NAME} ls [--dir]
|
|
627
|
+
${EXECUTABLE_NAME} new <branch> [--from <start-point>]
|
|
628
|
+
${EXECUTABLE_NAME} dir <branch>
|
|
629
|
+
${EXECUTABLE_NAME} rm <branch> [--branch]
|
|
630
|
+
${EXECUTABLE_NAME} move`)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function resolveExecutableName() {
|
|
634
|
+
const configuredName = process.env.ARBORISTA_EXECUTABLE
|
|
635
|
+
if (configuredName === "arb" || configuredName === "arborista") {
|
|
636
|
+
return configuredName
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const executablePath = Bun.argv[1]
|
|
640
|
+
const executableName = executablePath ? path.basename(executablePath) : "arborista"
|
|
641
|
+
|
|
642
|
+
if (executableName === "arb" || executableName === "arborista") {
|
|
643
|
+
return executableName
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return "arborista"
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function fail(message: string): never {
|
|
650
|
+
throw new ArboristaError(message)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
main()
|