@lakakala/kgit 0.2.0 → 0.3.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/README.md +183 -1
- package/dist/commands/edit.d.ts +3 -0
- package/dist/commands/edit.js +53 -0
- package/dist/commands/remove.js +33 -9
- package/dist/config.d.ts +3 -0
- package/dist/config.js +1 -0
- package/dist/git.d.ts +1 -0
- package/dist/git.js +13 -0
- package/dist/index.js +18 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1 +1,183 @@
|
|
|
1
|
-
# kgit
|
|
1
|
+
# kgit
|
|
2
|
+
|
|
3
|
+
使用 [git worktree](https://git-scm.com/docs/git-worktree) 管理多仓库工作区的 CLI 工具。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @lakakala/kgit
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 配置
|
|
12
|
+
|
|
13
|
+
在 `~/.config/kgit/config.json` 中创建配置文件:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"workspace": "$HOME/projects",
|
|
18
|
+
"ide": "trae",
|
|
19
|
+
"projects": [
|
|
20
|
+
{
|
|
21
|
+
"name": "form",
|
|
22
|
+
"path": "$HOME/repos/ens_bpm_form"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "auth",
|
|
26
|
+
"path": "$HOME/repos/ens_bpm_auth"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
| 字段 | 说明 |
|
|
33
|
+
|------|------|
|
|
34
|
+
| `workspace` | 工作区根目录,所有工程都创建在此目录下 |
|
|
35
|
+
| `ide` | 默认编辑器(可选),支持 `code`、`trae`、`cursor`、`nvim`、`vim` 等 |
|
|
36
|
+
| `projects[].name` | 项目别名,在 `-p` 参数中使用 |
|
|
37
|
+
| `projects[].path` | 项目 git 仓库的绝对路径 |
|
|
38
|
+
|
|
39
|
+
`workspace` 和 `path` 中均支持环境变量(`$HOME`、`$VAR`、`${VAR}`)。
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## 命令
|
|
44
|
+
|
|
45
|
+
### `kgit new`
|
|
46
|
+
|
|
47
|
+
创建新工程,为每个项目建立 git worktree。
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
kgit new <工程名称> [-b <分支名>] -p <项目[:基准分支]> [-p ...]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**选项**
|
|
54
|
+
|
|
55
|
+
| 选项 | 说明 |
|
|
56
|
+
|------|------|
|
|
57
|
+
| `-b <branch>` | 新分支名,默认使用工程名称 |
|
|
58
|
+
| `-p <project[:base]>` | 添加项目,可选指定基准分支(默认 `master`),可重复使用 |
|
|
59
|
+
|
|
60
|
+
**分支解析逻辑**(按优先级):
|
|
61
|
+
|
|
62
|
+
1. 本地已存在该分支 → 直接使用
|
|
63
|
+
2. 远程存在该分支 → 创建本地追踪分支
|
|
64
|
+
3. 分支不存在 → 基于 `<基准分支>` 新建
|
|
65
|
+
|
|
66
|
+
**示例**
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# 基于 master 创建工程 feat-login,新分支名为 feat-login
|
|
70
|
+
kgit new feat-login -p form -p auth
|
|
71
|
+
|
|
72
|
+
# 指定基准分支
|
|
73
|
+
kgit new feat-login -p form:develop -p auth:develop
|
|
74
|
+
|
|
75
|
+
# 使用 -b 指定新分支名(可复用已有本地/远程分支)
|
|
76
|
+
kgit new feat-login -b my-feature -p form:develop
|
|
77
|
+
|
|
78
|
+
# 多项目使用不同基准分支
|
|
79
|
+
kgit new feat-login -p form:master -p auth:develop
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**目录结构**
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
$workspace/
|
|
86
|
+
└── feat-login/
|
|
87
|
+
├── form/ ← worktree,分支 feat-login
|
|
88
|
+
└── auth/ ← worktree,分支 feat-login
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
### `kgit append`
|
|
94
|
+
|
|
95
|
+
向已有工程追加项目,选项与 `new` 相同。
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
kgit append <工程名称> [-b <分支名>] -p <项目[:基准分支]> [-p ...]
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**示例**
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
kgit append feat-login -p gateway:master
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
### `kgit remove`
|
|
110
|
+
|
|
111
|
+
删除工程或工程中的指定项目。
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
kgit remove <工程名称> # 删除整个工程
|
|
115
|
+
kgit remove <工程名称> -p <项目> [-p ...] # 删除指定项目
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
删除前会检测分支是否已推送到远程,若存在未同步的提交则需二次确认。
|
|
119
|
+
|
|
120
|
+
**示例**
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
kgit remove feat-login # 删除所有 worktree 及工程目录
|
|
124
|
+
kgit remove feat-login -p form # 仅删除 form 的 worktree
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
### `kgit list`
|
|
130
|
+
|
|
131
|
+
列出所有已创建的工程及其项目和当前分支。
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
kgit list
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
**输出示例**
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
feat-login
|
|
141
|
+
└─ form (feat-login)
|
|
142
|
+
└─ auth (feat-login)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
### `kgit edit`
|
|
148
|
+
|
|
149
|
+
用指定编辑器打开项目目录。
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
kgit edit <工程名称> <项目名称> [--ide <编辑器>]
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**选项**
|
|
156
|
+
|
|
157
|
+
| 选项 | 说明 |
|
|
158
|
+
|------|------|
|
|
159
|
+
| `--ide <name>` | 指定编辑器,未传则依次读取 config.json 中的 `ide` 字段,再自动检测 |
|
|
160
|
+
|
|
161
|
+
**编辑器优先级**:`--ide` 参数 → config.json `ide` 字段 → 自动检测(`code → trae → cursor → nvim → vim`)
|
|
162
|
+
|
|
163
|
+
| 类型 | 支持的编辑器 |
|
|
164
|
+
|------|------------|
|
|
165
|
+
| GUI(后台启动) | `code`、`trae`、`cursor`、`idea`、`webstorm` |
|
|
166
|
+
| 终端(前台运行) | `nvim`、`vim`、`vi`、`nano` |
|
|
167
|
+
|
|
168
|
+
**示例**
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
kgit edit feat-login form
|
|
172
|
+
kgit edit feat-login form --ide nvim
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
### `kgit version`
|
|
178
|
+
|
|
179
|
+
打印当前版本号。
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
kgit version
|
|
183
|
+
```
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { execa } from 'execa';
|
|
4
|
+
import { loadConfig } from '../config.js';
|
|
5
|
+
const IDE_COMMANDS = {
|
|
6
|
+
code: 'code',
|
|
7
|
+
trae: 'trae',
|
|
8
|
+
cursor: 'cursor',
|
|
9
|
+
nvim: 'nvim',
|
|
10
|
+
vim: 'vim',
|
|
11
|
+
vi: 'vi',
|
|
12
|
+
nano: 'nano',
|
|
13
|
+
idea: 'idea',
|
|
14
|
+
webstorm: 'webstorm',
|
|
15
|
+
};
|
|
16
|
+
const TERMINAL_IDES = new Set(['nvim', 'vim', 'vi', 'nano']);
|
|
17
|
+
async function detectDefaultIde() {
|
|
18
|
+
for (const cmd of ['code', 'trae', 'cursor', 'nvim', 'vim']) {
|
|
19
|
+
try {
|
|
20
|
+
await execa('which', [cmd], { stdio: 'pipe' });
|
|
21
|
+
return cmd;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// not found, try next
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
export async function editCommand(workspaceName, projectName, options) {
|
|
30
|
+
const config = loadConfig();
|
|
31
|
+
const worktreePath = path.join(config.workspace, workspaceName, projectName);
|
|
32
|
+
if (!fs.existsSync(worktreePath)) {
|
|
33
|
+
throw new Error(`Project path does not exist: ${worktreePath}`);
|
|
34
|
+
}
|
|
35
|
+
let ideName = options.ide?.toLowerCase()
|
|
36
|
+
?? config.ide?.toLowerCase();
|
|
37
|
+
if (!ideName) {
|
|
38
|
+
const detected = await detectDefaultIde();
|
|
39
|
+
if (!detected) {
|
|
40
|
+
throw new Error('No IDE detected. Please specify one with --ide or set "ide" in config.');
|
|
41
|
+
}
|
|
42
|
+
ideName = detected;
|
|
43
|
+
console.log(`Using detected IDE: ${ideName}`);
|
|
44
|
+
}
|
|
45
|
+
const command = IDE_COMMANDS[ideName] ?? ideName;
|
|
46
|
+
console.log(`Opening ${worktreePath} with ${command}...`);
|
|
47
|
+
if (TERMINAL_IDES.has(ideName)) {
|
|
48
|
+
await execa(command, [worktreePath], { stdio: 'inherit' });
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
execa(command, [worktreePath], { detached: true, stdio: 'ignore' }).unref();
|
|
52
|
+
}
|
|
53
|
+
}
|
package/dist/commands/remove.js
CHANGED
|
@@ -1,7 +1,30 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import readline from 'node:readline';
|
|
3
4
|
import { loadConfig, findProject } from '../config.js';
|
|
4
|
-
import { removeWorktree } from '../git.js';
|
|
5
|
+
import { removeWorktree, isBranchSyncedToRemote } from '../git.js';
|
|
6
|
+
function confirm(question) {
|
|
7
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
8
|
+
return new Promise(resolve => {
|
|
9
|
+
rl.question(`${question} (y/N) `, answer => {
|
|
10
|
+
rl.close();
|
|
11
|
+
resolve(answer.trim().toLowerCase() === 'y');
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
async function checkAndRemove(repoPath, worktreePath, projectName) {
|
|
16
|
+
const synced = await isBranchSyncedToRemote(worktreePath);
|
|
17
|
+
if (!synced) {
|
|
18
|
+
console.warn(` Warning: "${projectName}" has unpushed commits or no remote tracking branch.`);
|
|
19
|
+
const ok = await confirm(` Remove worktree "${projectName}" anyway?`);
|
|
20
|
+
if (!ok) {
|
|
21
|
+
console.log(` Skipped "${projectName}".`);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
console.log(`Removing worktree "${projectName}" from ${repoPath}`);
|
|
26
|
+
await removeWorktree(repoPath, worktreePath);
|
|
27
|
+
}
|
|
5
28
|
export async function removeCommand(workspaceName, projectEntries) {
|
|
6
29
|
const config = loadConfig();
|
|
7
30
|
const targetDir = path.join(config.workspace, workspaceName);
|
|
@@ -9,18 +32,21 @@ export async function removeCommand(workspaceName, projectEntries) {
|
|
|
9
32
|
throw new Error(`Workspace directory does not exist: ${targetDir}`);
|
|
10
33
|
}
|
|
11
34
|
if (projectEntries.length === 0) {
|
|
12
|
-
// Remove entire workspace: prune all worktrees then delete folder
|
|
13
35
|
const entries = fs.readdirSync(targetDir);
|
|
14
36
|
for (const entry of entries) {
|
|
15
37
|
const worktreePath = path.join(targetDir, entry);
|
|
16
38
|
const project = config.projects.find(p => p.name === entry);
|
|
17
39
|
if (project && fs.statSync(worktreePath).isDirectory()) {
|
|
18
|
-
|
|
19
|
-
await removeWorktree(project.path, worktreePath);
|
|
40
|
+
await checkAndRemove(project.path, worktreePath, entry);
|
|
20
41
|
}
|
|
21
42
|
}
|
|
22
|
-
fs.
|
|
23
|
-
|
|
43
|
+
if (fs.readdirSync(targetDir).length === 0) {
|
|
44
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
45
|
+
console.log(`Workspace "${workspaceName}" removed.`);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
console.log(`Workspace "${workspaceName}" partially removed (some projects were skipped).`);
|
|
49
|
+
}
|
|
24
50
|
}
|
|
25
51
|
else {
|
|
26
52
|
for (const entry of projectEntries) {
|
|
@@ -30,10 +56,8 @@ export async function removeCommand(workspaceName, projectEntries) {
|
|
|
30
56
|
console.warn(`Worktree not found, skipping: ${worktreePath}`);
|
|
31
57
|
continue;
|
|
32
58
|
}
|
|
33
|
-
|
|
34
|
-
await removeWorktree(project.path, worktreePath);
|
|
59
|
+
await checkAndRemove(project.path, worktreePath, project.name);
|
|
35
60
|
}
|
|
36
|
-
// Remove workspace dir if now empty
|
|
37
61
|
if (fs.readdirSync(targetDir).length === 0) {
|
|
38
62
|
fs.rmdirSync(targetDir);
|
|
39
63
|
console.log(`Workspace directory is now empty and has been removed.`);
|
package/dist/config.d.ts
CHANGED
|
@@ -21,18 +21,21 @@ declare const ConfigSchema: z.ZodObject<{
|
|
|
21
21
|
name: string;
|
|
22
22
|
path: string;
|
|
23
23
|
}>, "many">;
|
|
24
|
+
ide: z.ZodOptional<z.ZodString>;
|
|
24
25
|
}, "strip", z.ZodTypeAny, {
|
|
25
26
|
workspace: string;
|
|
26
27
|
projects: {
|
|
27
28
|
name: string;
|
|
28
29
|
path: string;
|
|
29
30
|
}[];
|
|
31
|
+
ide?: string | undefined;
|
|
30
32
|
}, {
|
|
31
33
|
workspace: string;
|
|
32
34
|
projects: {
|
|
33
35
|
name: string;
|
|
34
36
|
path: string;
|
|
35
37
|
}[];
|
|
38
|
+
ide?: string | undefined;
|
|
36
39
|
}>;
|
|
37
40
|
export type Project = z.infer<typeof ProjectSchema>;
|
|
38
41
|
export type Config = z.infer<typeof ConfigSchema>;
|
package/dist/config.js
CHANGED
|
@@ -10,6 +10,7 @@ const ProjectSchema = z.object({
|
|
|
10
10
|
const ConfigSchema = z.object({
|
|
11
11
|
workspace: z.string(),
|
|
12
12
|
projects: z.array(ProjectSchema),
|
|
13
|
+
ide: z.string().optional(),
|
|
13
14
|
});
|
|
14
15
|
function expandEnvVars(value) {
|
|
15
16
|
return value.replace(/\$([A-Z_][A-Z0-9_]*)/g, (_, name) => {
|
package/dist/git.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export declare function addWorktree(repoPath: string, worktreePath: string, newBranch: string, baseBranch: string): Promise<void>;
|
|
2
|
+
export declare function isBranchSyncedToRemote(worktreePath: string): Promise<boolean>;
|
|
2
3
|
export declare function removeWorktree(repoPath: string, worktreePath: string): Promise<void>;
|
|
3
4
|
export declare function isGitRepo(dirPath: string): Promise<boolean>;
|
package/dist/git.js
CHANGED
|
@@ -33,6 +33,19 @@ export async function addWorktree(repoPath, worktreePath, newBranch, baseBranch)
|
|
|
33
33
|
// Branch does not exist — create from base branch
|
|
34
34
|
await execa('git', ['-C', repoPath, 'worktree', 'add', '-b', newBranch, worktreePath, baseBranch], { stdio: 'inherit' });
|
|
35
35
|
}
|
|
36
|
+
export async function isBranchSyncedToRemote(worktreePath) {
|
|
37
|
+
try {
|
|
38
|
+
// Check if there is an upstream tracking branch
|
|
39
|
+
await execa('git', ['-C', worktreePath, 'rev-parse', '--abbrev-ref', '@{u}'], { stdio: 'pipe' });
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// No upstream configured — treat as unsynced
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
// Check for commits not yet pushed to upstream
|
|
46
|
+
const { stdout } = await execa('git', ['-C', worktreePath, 'rev-list', '--count', '@{u}..HEAD'], { stdio: 'pipe' });
|
|
47
|
+
return parseInt(stdout.trim(), 10) === 0;
|
|
48
|
+
}
|
|
36
49
|
export async function removeWorktree(repoPath, worktreePath) {
|
|
37
50
|
await execa('git', ['-C', repoPath, 'worktree', 'remove', worktreePath, '--force'], {
|
|
38
51
|
stdio: 'inherit',
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
3
4
|
import { newCommand } from './commands/new.js';
|
|
4
5
|
import { appendCommand } from './commands/append.js';
|
|
5
6
|
import { removeCommand } from './commands/remove.js';
|
|
6
7
|
import { listCommand } from './commands/list.js';
|
|
8
|
+
import { editCommand } from './commands/edit.js';
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const { version } = require('../package.json');
|
|
7
11
|
function collectProject(value, previous) {
|
|
8
12
|
const colonIdx = value.indexOf(':');
|
|
9
13
|
const name = colonIdx === -1 ? value : value.slice(0, colonIdx);
|
|
@@ -14,7 +18,7 @@ const program = new Command();
|
|
|
14
18
|
program
|
|
15
19
|
.name('kgit')
|
|
16
20
|
.description('Git worktree workspace manager')
|
|
17
|
-
.version(
|
|
21
|
+
.version(version);
|
|
18
22
|
program
|
|
19
23
|
.command('new <workspace>')
|
|
20
24
|
.description('Create a new workspace with git worktrees')
|
|
@@ -52,6 +56,19 @@ program
|
|
|
52
56
|
.action(async () => {
|
|
53
57
|
await listCommand();
|
|
54
58
|
});
|
|
59
|
+
program
|
|
60
|
+
.command('edit <workspace> <project>')
|
|
61
|
+
.description('Open a project worktree in an IDE or editor')
|
|
62
|
+
.option('--ide <name>', 'Editor to use: code, trae, cursor, nvim, vim, etc.')
|
|
63
|
+
.action(async (workspace, project, options) => {
|
|
64
|
+
await editCommand(workspace, project, options);
|
|
65
|
+
});
|
|
66
|
+
program
|
|
67
|
+
.command('version')
|
|
68
|
+
.description('Print the current version')
|
|
69
|
+
.action(() => {
|
|
70
|
+
console.log(`kgit v${version}`);
|
|
71
|
+
});
|
|
55
72
|
program.parseAsync(process.argv, { from: 'node' }).catch((err) => {
|
|
56
73
|
console.error(`Error: ${err.message}`);
|
|
57
74
|
process.exit(1);
|