arbors 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/.claude-plugin/plugin.json +7 -0
- package/.oxlintrc.json +9 -0
- package/README.ja.md +131 -0
- package/README.ko.md +131 -0
- package/README.md +131 -0
- package/bin/arbors.ts +278 -0
- package/dist/arbors.js +1094 -0
- package/dist/arbors.js.map +1 -0
- package/dist/bun-EMN2NS2M.js +48 -0
- package/dist/bun-EMN2NS2M.js.map +1 -0
- package/dist/ja-F4DBSAAZ.js +38 -0
- package/dist/ja-F4DBSAAZ.js.map +1 -0
- package/dist/ko-MTIAHJOR.js +38 -0
- package/dist/ko-MTIAHJOR.js.map +1 -0
- package/dist/node-LCODN3HC.js +56 -0
- package/dist/node-LCODN3HC.js.map +1 -0
- package/package.json +54 -0
- package/pnpm-workspace.yaml +1 -0
- package/shell/arbors-wrapper.sh +21 -0
- package/shell/arbors-wrapper.zsh +21 -0
- package/skills/arbors-usage/SKILL.md +129 -0
- package/src/config.ts +66 -0
- package/src/git/exclude.ts +63 -0
- package/src/git/safety.ts +40 -0
- package/src/git/worktree.ts +171 -0
- package/src/i18n/en.ts +63 -0
- package/src/i18n/index.ts +37 -0
- package/src/i18n/ja.ts +40 -0
- package/src/i18n/ko.ts +40 -0
- package/src/project/registry.ts +108 -0
- package/src/project/setup.ts +74 -0
- package/src/runtime/adapter.ts +16 -0
- package/src/runtime/bun.ts +49 -0
- package/src/runtime/index.ts +17 -0
- package/src/runtime/node.ts +58 -0
- package/src/tui/App.tsx +87 -0
- package/src/tui/FuzzyList.tsx +111 -0
- package/src/tui/ProjectSelector.tsx +48 -0
- package/src/tui/WorktreeSelector.tsx +46 -0
- package/tests/config.test.ts +108 -0
- package/tests/exclude.test.ts +120 -0
- package/tests/i18n.test.ts +75 -0
- package/tests/registry.test.ts +136 -0
- package/tests/safety.test.ts +58 -0
- package/tests/setup-detection.test.ts +105 -0
- package/tests/setup.test.ts +87 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +14 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: arbors-usage
|
|
3
|
+
description: This skill should be used when the user asks to "create a worktree", "switch worktree", "manage worktrees", "use arbors", "set up arbors", "install arbors", "configure arbors", "remove worktree", "list worktrees", "delete worktree", or mentions git worktree management with arbors. Also trigger when the user asks about arbors's project structure, how arbors works internally, how to develop or contribute to arbors, or troubleshoot arbors issues.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# arbors — Git Worktree Manager
|
|
7
|
+
|
|
8
|
+
arbors is a CLI/TUI tool for managing git worktrees. It handles worktree creation, `.git/info/exclude` file copying, package manager auto-detection, dependency installation, and project registry tracking.
|
|
9
|
+
|
|
10
|
+
## Quick Reference
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
arbors add <branch> # Checkout existing branch (local → remote auto)
|
|
14
|
+
arbors add -c <branch> [--base <base>] # Create new branch + worktree
|
|
15
|
+
arbors remove <branch> # Remove worktree (safety checks first)
|
|
16
|
+
arbors list [--plain] # List arbors-managed worktrees
|
|
17
|
+
arbors excluded # Show .git/info/exclude patterns
|
|
18
|
+
arbors config # Show current configuration
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Installation & Setup
|
|
22
|
+
|
|
23
|
+
Build and link globally:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
cd <arbors-repo>
|
|
27
|
+
pnpm install && pnpm build
|
|
28
|
+
npm link
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Shell integration is required for auto-cd — a child process (node) cannot change the parent shell's cwd, so the wrapper script captures arbors's `__ARBORS_CD__:<path>` protocol output and runs `cd` in the parent shell.
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
# ~/.zshrc
|
|
35
|
+
source /path/to/arbors/shell/arbors-wrapper.zsh
|
|
36
|
+
|
|
37
|
+
# ~/.bashrc
|
|
38
|
+
source /path/to/arbors/shell/arbors-wrapper.sh
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## How `arbors add` Works
|
|
42
|
+
|
|
43
|
+
The `add` command handles both new and existing branches via the `-c` flag:
|
|
44
|
+
|
|
45
|
+
### With `-c` (create new branch)
|
|
46
|
+
|
|
47
|
+
`arbors add -c <branch> [--base <base>]`
|
|
48
|
+
|
|
49
|
+
1. Validate branch name against `/^[a-zA-Z0-9][a-zA-Z0-9._\/-]*$/` (slashes allowed, no `..`)
|
|
50
|
+
2. Check if branch already exists — error if so
|
|
51
|
+
3. Run `git fetch origin <base>` then `git worktree add -b <branch> ~/arbors/{repo}/<dir> origin/<base>` (dir = branch with `/` → `-`)
|
|
52
|
+
4. Copy files matching `.git/info/exclude` patterns (if `copyExcludes: true`)
|
|
53
|
+
5. Detect runtime manager (mise.toml → `mise install`, .nvmrc → `nvm install`)
|
|
54
|
+
6. Detect package manager (pnpm-lock.yaml → pnpm, yarn.lock → yarn, package-lock.json → npm) and run install
|
|
55
|
+
7. Register in `~/.arbors/db.json` (project + worktree tracking)
|
|
56
|
+
|
|
57
|
+
### Without `-c` (checkout existing branch)
|
|
58
|
+
|
|
59
|
+
`arbors add <branch>`
|
|
60
|
+
|
|
61
|
+
1. Validate branch name
|
|
62
|
+
2. If local branch exists → `git worktree add ~/arbors/{repo}/<dir> <branch>`
|
|
63
|
+
3. Else if remote branch exists → `git fetch origin <branch>`, then create worktree from `origin/<branch>`
|
|
64
|
+
4. Else → error with hint to use `arbors add -c`
|
|
65
|
+
5. Copy excluded files, install deps, register in db (same as above)
|
|
66
|
+
|
|
67
|
+
## Safety
|
|
68
|
+
|
|
69
|
+
- `arbors remove` refuses to delete worktrees with uncommitted changes (`git status --porcelain`)
|
|
70
|
+
- Cannot remove the main worktree
|
|
71
|
+
- Name validation allows slashes (`feature/login`) but prevents path traversal (`..`) and unsafe characters
|
|
72
|
+
- Branch deletion (`git branch -D <branch>`) happens after worktree removal
|
|
73
|
+
- Branch existence is checked before creation to prevent overwriting
|
|
74
|
+
|
|
75
|
+
## Configuration
|
|
76
|
+
|
|
77
|
+
Global: `~/.arbors/config.json` — Project override: `.arbors/config.json` (in repo root, takes precedence)
|
|
78
|
+
|
|
79
|
+
| Key | Values | Default |
|
|
80
|
+
| ---------------- | -------------------------------------- | ------------------- |
|
|
81
|
+
| `runtime` | `"node"`, `"bun"` | `"node"` |
|
|
82
|
+
| `language` | `"en"`, `"ko"`, `"ja"` | `"en"` |
|
|
83
|
+
| `packageManager` | `"auto"`, `"pnpm"`, `"yarn"`, `"npm"` | `"auto"` |
|
|
84
|
+
| `copyExcludes` | `true`, `false` | `true` |
|
|
85
|
+
| `copySkip` | `string[]` | `["node_modules"]` |
|
|
86
|
+
| `worktreeDir` | string with `{repo}` placeholder | `"~/arbors/{repo}"` |
|
|
87
|
+
|
|
88
|
+
## Data Files
|
|
89
|
+
|
|
90
|
+
- `~/.arbors/config.json` — Global configuration
|
|
91
|
+
- `~/.arbors/db.json` — Project registry + worktree tracking (projects and worktrees per project)
|
|
92
|
+
- `.arbors/config.json` — Per-project config override
|
|
93
|
+
- `.git/info/exclude` — Patterns for files to copy into new worktrees
|
|
94
|
+
|
|
95
|
+
## Project Architecture
|
|
96
|
+
|
|
97
|
+
For development and contribution context:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
src/
|
|
101
|
+
├── config.ts # Config loading (global → project merge)
|
|
102
|
+
├── git/
|
|
103
|
+
│ ├── worktree.ts # Core: create/remove/list worktrees, detect default branch
|
|
104
|
+
│ ├── safety.ts # Name validation, uncommitted changes check, main worktree guard
|
|
105
|
+
│ └── exclude.ts # Parse .git/info/exclude, find matching files, copy to worktree
|
|
106
|
+
├── project/
|
|
107
|
+
│ ├── registry.ts # ~/.arbors/db.json read/write, project + worktree CRUD
|
|
108
|
+
│ └── setup.ts # Package manager & runtime manager detection and install
|
|
109
|
+
├── runtime/
|
|
110
|
+
│ ├── adapter.ts # RuntimeAdapter interface (exec, glob, readFile, etc.)
|
|
111
|
+
│ ├── node.ts # Node.js implementation
|
|
112
|
+
│ ├── bun.ts # Bun implementation
|
|
113
|
+
│ └── index.ts # Factory: createAdapter(runtime)
|
|
114
|
+
└── i18n/ # en, ko, ja message catalogs
|
|
115
|
+
bin/arbors.ts # CLI entry point (parseArgs, command dispatch)
|
|
116
|
+
shell/arbors-wrapper.{zsh,sh} # Shell wrappers for auto-cd
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Key pattern: all file/process operations go through `RuntimeAdapter`, enabling both Node and Bun runtimes.
|
|
120
|
+
|
|
121
|
+
### Development Commands
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
pnpm test # vitest
|
|
125
|
+
pnpm lint # oxlint
|
|
126
|
+
pnpm format # oxfmt
|
|
127
|
+
pnpm build # tsup
|
|
128
|
+
pnpm typecheck # tsc --noEmit
|
|
129
|
+
```
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface ArborConfig {
|
|
5
|
+
runtime: "bun" | "node";
|
|
6
|
+
language: "ko" | "en" | "ja";
|
|
7
|
+
packageManager: "auto" | "pnpm" | "yarn" | "npm";
|
|
8
|
+
copyExcludes: boolean;
|
|
9
|
+
copySkip: string[];
|
|
10
|
+
worktreeDir: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_CONFIG: ArborConfig = {
|
|
14
|
+
runtime: "node",
|
|
15
|
+
language: "en",
|
|
16
|
+
packageManager: "auto",
|
|
17
|
+
copyExcludes: true,
|
|
18
|
+
copySkip: ["node_modules"],
|
|
19
|
+
worktreeDir: "~/arbors/{repo}",
|
|
20
|
+
} satisfies ArborConfig;
|
|
21
|
+
|
|
22
|
+
const GLOBAL_CONFIG_PATH = join(homedir(), ".arbors", "config.json");
|
|
23
|
+
const PROJECT_CONFIG_DIR = ".arbors";
|
|
24
|
+
const PROJECT_CONFIG_FILE = "config.json";
|
|
25
|
+
|
|
26
|
+
const mergeConfig = (base: ArborConfig, override: Partial<ArborConfig>): ArborConfig => {
|
|
27
|
+
return { ...base, ...override };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const readJsonFile = async (
|
|
31
|
+
readFile: (path: string) => Promise<string>,
|
|
32
|
+
exists: (path: string) => Promise<boolean>,
|
|
33
|
+
path: string,
|
|
34
|
+
): Promise<Partial<ArborConfig>> => {
|
|
35
|
+
if (!(await exists(path))) return {};
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const content = await readFile(path);
|
|
39
|
+
return JSON.parse(content) as Partial<ArborConfig>;
|
|
40
|
+
} catch {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const loadConfig = async (
|
|
46
|
+
readFile: (path: string) => Promise<string>,
|
|
47
|
+
exists: (path: string) => Promise<boolean>,
|
|
48
|
+
projectRoot?: string,
|
|
49
|
+
): Promise<ArborConfig> => {
|
|
50
|
+
const globalOverride = await readJsonFile(readFile, exists, GLOBAL_CONFIG_PATH);
|
|
51
|
+
const merged = mergeConfig(DEFAULT_CONFIG, globalOverride);
|
|
52
|
+
|
|
53
|
+
if (!projectRoot) return merged;
|
|
54
|
+
|
|
55
|
+
const projectConfigPath = resolve(projectRoot, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILE);
|
|
56
|
+
const projectOverride = await readJsonFile(readFile, exists, projectConfigPath);
|
|
57
|
+
|
|
58
|
+
return mergeConfig(merged, projectOverride);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const getGlobalConfigPath = (): string => GLOBAL_CONFIG_PATH;
|
|
62
|
+
|
|
63
|
+
export const getProjectConfigPath = (projectRoot: string): string =>
|
|
64
|
+
resolve(projectRoot, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILE);
|
|
65
|
+
|
|
66
|
+
export { DEFAULT_CONFIG, mergeConfig };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { RuntimeAdapter } from "../runtime/adapter.js";
|
|
3
|
+
import { getRepoRoot } from "./worktree.js";
|
|
4
|
+
|
|
5
|
+
export const getExcludePatterns = async (adapter: RuntimeAdapter): Promise<string[]> => {
|
|
6
|
+
const repoRoot = await getRepoRoot(adapter);
|
|
7
|
+
const excludePath = join(repoRoot, ".git", "info", "exclude");
|
|
8
|
+
|
|
9
|
+
if (!(await adapter.exists(excludePath))) return [];
|
|
10
|
+
|
|
11
|
+
const content = await adapter.readFile(excludePath);
|
|
12
|
+
|
|
13
|
+
return content
|
|
14
|
+
.split("\n")
|
|
15
|
+
.map((line) => line.trim())
|
|
16
|
+
.filter((line) => line !== "" && !line.startsWith("#"));
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const findExcludedEntries = async (
|
|
20
|
+
adapter: RuntimeAdapter,
|
|
21
|
+
patterns: string[],
|
|
22
|
+
): Promise<string[]> => {
|
|
23
|
+
const repoRoot = await getRepoRoot(adapter);
|
|
24
|
+
const cleaned = patterns.map((p) => p.replace(/^\//, ""));
|
|
25
|
+
|
|
26
|
+
const groups = await Promise.all(
|
|
27
|
+
cleaned.map((pattern) => adapter.glob(pattern, repoRoot)),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
return [...new Set(groups.flat())].sort();
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const copyExcludedFiles = async (
|
|
34
|
+
adapter: RuntimeAdapter,
|
|
35
|
+
worktreePath: string,
|
|
36
|
+
skipPatterns: string[] = [],
|
|
37
|
+
): Promise<string[]> => {
|
|
38
|
+
const patterns = await getExcludePatterns(adapter);
|
|
39
|
+
if (patterns.length === 0) return [];
|
|
40
|
+
|
|
41
|
+
const repoRoot = await getRepoRoot(adapter);
|
|
42
|
+
const allEntries = await findExcludedEntries(adapter, patterns);
|
|
43
|
+
const entries = allEntries.filter(
|
|
44
|
+
(e) => !skipPatterns.some((s) => e === s || e.startsWith(`${s}/`)),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const copied: string[] = [];
|
|
48
|
+
|
|
49
|
+
await Promise.all(
|
|
50
|
+
entries.map(async (entry) => {
|
|
51
|
+
const src = join(repoRoot, entry);
|
|
52
|
+
const dest = join(worktreePath, entry);
|
|
53
|
+
try {
|
|
54
|
+
await adapter.copy(src, dest);
|
|
55
|
+
copied.push(entry);
|
|
56
|
+
} catch {
|
|
57
|
+
// Skip non-copyable entries (sockets, pipes, etc.)
|
|
58
|
+
}
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return copied;
|
|
63
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { RuntimeAdapter } from "../runtime/adapter.js";
|
|
2
|
+
import { listWorktrees } from "./worktree.js";
|
|
3
|
+
|
|
4
|
+
const VALID_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._\/-]*$/;
|
|
5
|
+
|
|
6
|
+
export const validateWorktreeName = (name: string): boolean =>
|
|
7
|
+
VALID_NAME_PATTERN.test(name) && !name.includes("..");
|
|
8
|
+
|
|
9
|
+
export const hasUncommittedChanges = async (
|
|
10
|
+
adapter: RuntimeAdapter,
|
|
11
|
+
worktreePath: string,
|
|
12
|
+
): Promise<boolean> => {
|
|
13
|
+
const result = await adapter.exec("git", ["-C", worktreePath, "status", "--porcelain"]);
|
|
14
|
+
|
|
15
|
+
return result.exitCode === 0 && result.stdout.length > 0;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const isMainWorktree = async (
|
|
19
|
+
adapter: RuntimeAdapter,
|
|
20
|
+
worktreePath: string,
|
|
21
|
+
): Promise<boolean> => {
|
|
22
|
+
const worktrees = await listWorktrees(adapter);
|
|
23
|
+
const target = worktrees.find((wt) => wt.path === worktreePath);
|
|
24
|
+
return target?.isMain ?? false;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const canSafelyRemove = async (
|
|
28
|
+
adapter: RuntimeAdapter,
|
|
29
|
+
worktreePath: string,
|
|
30
|
+
): Promise<{ safe: boolean; reason?: string }> => {
|
|
31
|
+
if (await isMainWorktree(adapter, worktreePath)) {
|
|
32
|
+
return { safe: false, reason: "cannotDeleteMain" };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (await hasUncommittedChanges(adapter, worktreePath)) {
|
|
36
|
+
return { safe: false, reason: "uncommittedChanges" };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { safe: true };
|
|
40
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { basename, resolve } from "node:path";
|
|
3
|
+
import type { RuntimeAdapter } from "../runtime/adapter.js";
|
|
4
|
+
|
|
5
|
+
export interface WorktreeInfo {
|
|
6
|
+
path: string;
|
|
7
|
+
branch: string;
|
|
8
|
+
isMain: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const getRepoRoot = async (adapter: RuntimeAdapter): Promise<string> => {
|
|
12
|
+
const result = await adapter.exec("git", ["rev-parse", "--show-toplevel"]);
|
|
13
|
+
if (result.exitCode !== 0) throw new Error("Not a git repository");
|
|
14
|
+
return result.stdout;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const getRepoName = async (adapter: RuntimeAdapter): Promise<string> => {
|
|
18
|
+
const root = await getRepoRoot(adapter);
|
|
19
|
+
return basename(root);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const getDefaultBranch = async (adapter: RuntimeAdapter): Promise<string> => {
|
|
23
|
+
const result = await adapter.exec("git", ["symbolic-ref", "refs/remotes/origin/HEAD"]);
|
|
24
|
+
|
|
25
|
+
if (result.exitCode === 0) {
|
|
26
|
+
return result.stdout.replace("refs/remotes/origin/", "");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const mainCheck = await adapter.exec("git", ["rev-parse", "--verify", "main"]);
|
|
30
|
+
return mainCheck.exitCode === 0 ? "main" : "master";
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const parseWorktreeBlock = (block: string, isFirst: boolean): WorktreeInfo | null => {
|
|
34
|
+
const lines = block.split("\n");
|
|
35
|
+
const pathLine = lines.find((l) => l.startsWith("worktree "));
|
|
36
|
+
const branchLine = lines.find((l) => l.startsWith("branch "));
|
|
37
|
+
|
|
38
|
+
if (!pathLine) return null;
|
|
39
|
+
|
|
40
|
+
const path = pathLine.slice(9);
|
|
41
|
+
const branch = branchLine?.slice(7).replace("refs/heads/", "") ?? "";
|
|
42
|
+
|
|
43
|
+
return { path, branch, isMain: isFirst };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const listWorktrees = async (adapter: RuntimeAdapter): Promise<WorktreeInfo[]> => {
|
|
47
|
+
const result = await adapter.exec("git", ["worktree", "list", "--porcelain"]);
|
|
48
|
+
if (result.exitCode !== 0) return [];
|
|
49
|
+
|
|
50
|
+
return result.stdout
|
|
51
|
+
.split("\n\n")
|
|
52
|
+
.map((block: string, index: number) => parseWorktreeBlock(block, index === 0))
|
|
53
|
+
.filter((wt): wt is WorktreeInfo => wt !== null);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const branchExists = async (adapter: RuntimeAdapter, branchName: string): Promise<boolean> => {
|
|
57
|
+
const result = await adapter.exec("git", ["rev-parse", "--verify", branchName]);
|
|
58
|
+
return result.exitCode === 0;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const remoteBranchExists = async (adapter: RuntimeAdapter, branchName: string): Promise<boolean> => {
|
|
62
|
+
const result = await adapter.exec("git", ["ls-remote", "--heads", "origin", branchName]);
|
|
63
|
+
return result.exitCode === 0 && result.stdout.length > 0;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const branchToDir = (branch: string): string => branch.replaceAll("/", "-");
|
|
67
|
+
|
|
68
|
+
const resolveWorktreeDir = (worktreeDir: string, repoName: string): string => {
|
|
69
|
+
const expanded = worktreeDir.replace(/^~/, homedir()).replace("{repo}", repoName);
|
|
70
|
+
return resolve(expanded);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const createWorktree = async (
|
|
74
|
+
adapter: RuntimeAdapter,
|
|
75
|
+
branch: string,
|
|
76
|
+
worktreeDir: string,
|
|
77
|
+
baseBranch?: string,
|
|
78
|
+
): Promise<string> => {
|
|
79
|
+
const repoName = await getRepoName(adapter);
|
|
80
|
+
const base = baseBranch ?? (await getDefaultBranch(adapter));
|
|
81
|
+
const worktreePath = resolve(resolveWorktreeDir(worktreeDir, repoName), branchToDir(branch));
|
|
82
|
+
|
|
83
|
+
if (await branchExists(adapter, branch)) {
|
|
84
|
+
throw new Error(`Branch '${branch}' already exists`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await adapter.exec("git", ["fetch", "origin", base]);
|
|
88
|
+
|
|
89
|
+
const result = await adapter.exec("git", [
|
|
90
|
+
"worktree",
|
|
91
|
+
"add",
|
|
92
|
+
"-b",
|
|
93
|
+
branch,
|
|
94
|
+
worktreePath,
|
|
95
|
+
`origin/${base}`,
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
if (result.exitCode !== 0) {
|
|
99
|
+
throw new Error(result.stderr || "Failed to create worktree");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return worktreePath;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export interface CheckoutResult {
|
|
106
|
+
path: string;
|
|
107
|
+
created: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const checkoutWorktree = async (
|
|
111
|
+
adapter: RuntimeAdapter,
|
|
112
|
+
branch: string,
|
|
113
|
+
worktreeDir: string,
|
|
114
|
+
): Promise<CheckoutResult> => {
|
|
115
|
+
const existing = (await listWorktrees(adapter)).find((wt) => wt.branch === branch);
|
|
116
|
+
if (existing) return { path: existing.path, created: false };
|
|
117
|
+
|
|
118
|
+
const repoName = await getRepoName(adapter);
|
|
119
|
+
const worktreePath = resolve(resolveWorktreeDir(worktreeDir, repoName), branchToDir(branch));
|
|
120
|
+
|
|
121
|
+
const result = await adapter.exec("git", ["worktree", "add", worktreePath, branch]);
|
|
122
|
+
|
|
123
|
+
if (result.exitCode !== 0) {
|
|
124
|
+
throw new Error(result.stderr || "Failed to checkout worktree");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { path: worktreePath, created: true };
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const checkoutRemoteWorktree = async (
|
|
131
|
+
adapter: RuntimeAdapter,
|
|
132
|
+
branch: string,
|
|
133
|
+
worktreeDir: string,
|
|
134
|
+
): Promise<CheckoutResult> => {
|
|
135
|
+
const existing = (await listWorktrees(adapter)).find((wt) => wt.branch === branch);
|
|
136
|
+
if (existing) return { path: existing.path, created: false };
|
|
137
|
+
|
|
138
|
+
await adapter.exec("git", ["fetch", "origin", branch]);
|
|
139
|
+
|
|
140
|
+
const repoName = await getRepoName(adapter);
|
|
141
|
+
const worktreePath = resolve(resolveWorktreeDir(worktreeDir, repoName), branchToDir(branch));
|
|
142
|
+
|
|
143
|
+
const result = await adapter.exec("git", [
|
|
144
|
+
"worktree",
|
|
145
|
+
"add",
|
|
146
|
+
"-b",
|
|
147
|
+
branch,
|
|
148
|
+
worktreePath,
|
|
149
|
+
`origin/${branch}`,
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
if (result.exitCode !== 0) {
|
|
153
|
+
throw new Error(result.stderr || "Failed to checkout remote worktree");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { path: worktreePath, created: true };
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const removeWorktree = async (
|
|
160
|
+
adapter: RuntimeAdapter,
|
|
161
|
+
worktreePath: string,
|
|
162
|
+
branch: string,
|
|
163
|
+
): Promise<void> => {
|
|
164
|
+
const removeResult = await adapter.exec("git", ["worktree", "remove", "--force", worktreePath]);
|
|
165
|
+
|
|
166
|
+
if (removeResult.exitCode !== 0) {
|
|
167
|
+
throw new Error(removeResult.stderr || "Failed to remove worktree");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
await adapter.exec("git", ["branch", "-D", branch]);
|
|
171
|
+
};
|
package/src/i18n/en.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export interface Messages {
|
|
2
|
+
selectProject: string;
|
|
3
|
+
noProjects: string;
|
|
4
|
+
recentProjects: string;
|
|
5
|
+
selectWorktree: string;
|
|
6
|
+
noWorktrees: string;
|
|
7
|
+
createNew: string;
|
|
8
|
+
creating: string;
|
|
9
|
+
removing: string;
|
|
10
|
+
copying: string;
|
|
11
|
+
installing: string;
|
|
12
|
+
created: string;
|
|
13
|
+
removed: string;
|
|
14
|
+
copied: string;
|
|
15
|
+
installed: string;
|
|
16
|
+
resultsFound: (count: number) => string;
|
|
17
|
+
notGitRepo: string;
|
|
18
|
+
worktreeExists: string;
|
|
19
|
+
worktreeNotFound: string;
|
|
20
|
+
uncommittedChanges: string;
|
|
21
|
+
cannotDeleteMain: string;
|
|
22
|
+
invalidName: string;
|
|
23
|
+
helpFooter: string;
|
|
24
|
+
helpWorktree: string;
|
|
25
|
+
configSaved: string;
|
|
26
|
+
configCurrent: string;
|
|
27
|
+
version: string;
|
|
28
|
+
usage: string;
|
|
29
|
+
commands: string;
|
|
30
|
+
options: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const en: Messages = {
|
|
34
|
+
selectProject: "Select a project:",
|
|
35
|
+
noProjects: "No projects registered. Run arbors in a git repository first.",
|
|
36
|
+
recentProjects: "Recent projects",
|
|
37
|
+
selectWorktree: "Select a worktree:",
|
|
38
|
+
noWorktrees: "No worktrees found.",
|
|
39
|
+
createNew: "Create new worktree",
|
|
40
|
+
creating: "Creating worktree...",
|
|
41
|
+
removing: "Removing worktree...",
|
|
42
|
+
copying: "Copying excluded files...",
|
|
43
|
+
installing: "Installing dependencies...",
|
|
44
|
+
created: "Worktree created",
|
|
45
|
+
removed: "Worktree removed",
|
|
46
|
+
copied: "Excluded files copied",
|
|
47
|
+
installed: "Dependencies installed",
|
|
48
|
+
resultsFound: (count) => `${count} result${count === 1 ? "" : "s"} found`,
|
|
49
|
+
notGitRepo: "Not a git repository.",
|
|
50
|
+
worktreeExists: "Worktree already exists.",
|
|
51
|
+
worktreeNotFound: "Worktree not found.",
|
|
52
|
+
uncommittedChanges: "Worktree has uncommitted changes. Commit or stash them first.",
|
|
53
|
+
cannotDeleteMain: "Cannot delete the main worktree.",
|
|
54
|
+
invalidName: "Invalid worktree name.",
|
|
55
|
+
helpFooter: "Tab: autocomplete | Enter: select | Esc: cancel",
|
|
56
|
+
helpWorktree: "Ctrl+B: new branch | Ctrl+X: delete | Esc: back",
|
|
57
|
+
configSaved: "Configuration saved.",
|
|
58
|
+
configCurrent: "Current configuration:",
|
|
59
|
+
version: "arbors v0.1.0",
|
|
60
|
+
usage: "Usage: arbors [command] [options]",
|
|
61
|
+
commands: "Commands:",
|
|
62
|
+
options: "Options:",
|
|
63
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Messages } from "./en.js";
|
|
2
|
+
import { en } from "./en.js";
|
|
3
|
+
|
|
4
|
+
export type { Messages };
|
|
5
|
+
|
|
6
|
+
type SupportedLanguage = "ko" | "en" | "ja";
|
|
7
|
+
|
|
8
|
+
const LANG_MAP: Record<string, SupportedLanguage> = {
|
|
9
|
+
ko: "ko",
|
|
10
|
+
"ko-kr": "ko",
|
|
11
|
+
ko_kr: "ko",
|
|
12
|
+
en: "en",
|
|
13
|
+
"en-us": "en",
|
|
14
|
+
en_us: "en",
|
|
15
|
+
"en-gb": "en",
|
|
16
|
+
en_gb: "en",
|
|
17
|
+
ja: "ja",
|
|
18
|
+
"ja-jp": "ja",
|
|
19
|
+
ja_jp: "ja",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const detectLanguage = (): SupportedLanguage => {
|
|
23
|
+
const lang = (process.env.LANG ?? process.env.LANGUAGE ?? "en").split(".")[0].toLowerCase();
|
|
24
|
+
|
|
25
|
+
return LANG_MAP[lang] ?? LANG_MAP[lang.split(/[-_]/)[0]] ?? "en";
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const loaders: Record<SupportedLanguage, () => Promise<Messages>> = {
|
|
29
|
+
en: async () => en,
|
|
30
|
+
ko: async () => (await import("./ko.js")).ko,
|
|
31
|
+
ja: async () => (await import("./ja.js")).ja,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const loadMessages = async (configLanguage?: SupportedLanguage): Promise<Messages> => {
|
|
35
|
+
const lang = configLanguage ?? detectLanguage();
|
|
36
|
+
return loaders[lang]();
|
|
37
|
+
};
|
package/src/i18n/ja.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Messages } from "./en.js";
|
|
2
|
+
|
|
3
|
+
export const ja: Messages = {
|
|
4
|
+
selectProject: "プロジェクトを選択してください:",
|
|
5
|
+
noProjects: "登録されたプロジェクトがありません。gitリポジトリでarborsを先に実行してください。",
|
|
6
|
+
recentProjects: "最近のプロジェクト",
|
|
7
|
+
|
|
8
|
+
selectWorktree: "ワークツリーを選択してください:",
|
|
9
|
+
noWorktrees: "ワークツリーがありません。",
|
|
10
|
+
createNew: "新しいワークツリーを作成",
|
|
11
|
+
|
|
12
|
+
creating: "ワークツリーを作成中...",
|
|
13
|
+
removing: "ワークツリーを削除中...",
|
|
14
|
+
copying: "除外ファイルをコピー中...",
|
|
15
|
+
installing: "依存関係をインストール中...",
|
|
16
|
+
|
|
17
|
+
created: "ワークツリーを作成しました",
|
|
18
|
+
removed: "ワークツリーを削除しました",
|
|
19
|
+
copied: "除外ファイルをコピーしました",
|
|
20
|
+
installed: "依存関係をインストールしました",
|
|
21
|
+
resultsFound: (count: number) => `${count}件の結果`,
|
|
22
|
+
|
|
23
|
+
notGitRepo: "gitリポジトリではありません。",
|
|
24
|
+
worktreeExists: "ワークツリーはすでに存在します。",
|
|
25
|
+
worktreeNotFound: "ワークツリーが見つかりません。",
|
|
26
|
+
uncommittedChanges: "コミットされていない変更があります。コミットまたはスタッシュしてください。",
|
|
27
|
+
cannotDeleteMain: "メインワークツリーは削除できません。",
|
|
28
|
+
invalidName: "無効なワークツリー名です。",
|
|
29
|
+
|
|
30
|
+
helpFooter: "Tab: 補完 | Enter: 選択 | Esc: キャンセル",
|
|
31
|
+
helpWorktree: "Ctrl+B: 新ブランチ | Ctrl+X: 削除 | Esc: 戻る",
|
|
32
|
+
|
|
33
|
+
configSaved: "設定を保存しました。",
|
|
34
|
+
configCurrent: "現在の設定:",
|
|
35
|
+
|
|
36
|
+
version: "arbors v0.1.0",
|
|
37
|
+
usage: "使い方: arbors [コマンド] [オプション]",
|
|
38
|
+
commands: "コマンド:",
|
|
39
|
+
options: "オプション:",
|
|
40
|
+
} as const;
|
package/src/i18n/ko.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Messages } from "./en.js";
|
|
2
|
+
|
|
3
|
+
export const ko: Messages = {
|
|
4
|
+
selectProject: "프로젝트를 선택하세요:",
|
|
5
|
+
noProjects: "등록된 프로젝트가 없습니다. git 저장소에서 arbors를 먼저 실행하세요.",
|
|
6
|
+
recentProjects: "최근 프로젝트",
|
|
7
|
+
|
|
8
|
+
selectWorktree: "워크트리를 선택하세요:",
|
|
9
|
+
noWorktrees: "워크트리가 없습니다.",
|
|
10
|
+
createNew: "새 워크트리 생성",
|
|
11
|
+
|
|
12
|
+
creating: "워크트리 생성 중...",
|
|
13
|
+
removing: "워크트리 삭제 중...",
|
|
14
|
+
copying: "제외 파일 복사 중...",
|
|
15
|
+
installing: "의존성 설치 중...",
|
|
16
|
+
|
|
17
|
+
created: "워크트리 생성 완료",
|
|
18
|
+
removed: "워크트리 삭제 완료",
|
|
19
|
+
copied: "제외 파일 복사 완료",
|
|
20
|
+
installed: "의존성 설치 완료",
|
|
21
|
+
resultsFound: (count: number) => `${count}개 결과`,
|
|
22
|
+
|
|
23
|
+
notGitRepo: "git 저장소가 아닙니다.",
|
|
24
|
+
worktreeExists: "워크트리가 이미 존재합니다.",
|
|
25
|
+
worktreeNotFound: "워크트리를 찾을 수 없습니다.",
|
|
26
|
+
uncommittedChanges: "커밋되지 않은 변경사항이 있습니다. 커밋하거나 스태시하세요.",
|
|
27
|
+
cannotDeleteMain: "메인 워크트리는 삭제할 수 없습니다.",
|
|
28
|
+
invalidName: "올바르지 않은 워크트리 이름입니다.",
|
|
29
|
+
|
|
30
|
+
helpFooter: "Tab: 자동완성 | Enter: 선택 | Esc: 취소",
|
|
31
|
+
helpWorktree: "Ctrl+B: 새 브랜치 | Ctrl+X: 삭제 | Esc: 뒤로",
|
|
32
|
+
|
|
33
|
+
configSaved: "설정이 저장되었습니다.",
|
|
34
|
+
configCurrent: "현재 설정:",
|
|
35
|
+
|
|
36
|
+
version: "arbors v0.1.0",
|
|
37
|
+
usage: "사용법: arbors [명령어] [옵션]",
|
|
38
|
+
commands: "명령어:",
|
|
39
|
+
options: "옵션:",
|
|
40
|
+
} as const;
|