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 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)
@@ -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()