@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 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,3 @@
1
+ export declare function editCommand(workspaceName: string, projectName: string, options: {
2
+ ide?: string;
3
+ }): Promise<void>;
@@ -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
+ }
@@ -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
- console.log(`Removing worktree "${entry}" from ${project.path}`);
19
- await removeWorktree(project.path, worktreePath);
40
+ await checkAndRemove(project.path, worktreePath, entry);
20
41
  }
21
42
  }
22
- fs.rmSync(targetDir, { recursive: true, force: true });
23
- console.log(`Workspace "${workspaceName}" removed.`);
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
- console.log(`Removing worktree "${project.name}" from ${project.path}`);
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('0.1.0');
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lakakala/kgit",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Git worktree workspace manager",
5
5
  "type": "module",
6
6
  "bin": {