@lakakala/kgit 0.1.3 → 0.2.1
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 +133 -1
- package/dist/commands/remove.js +33 -9
- package/dist/git.d.ts +1 -0
- package/dist/git.js +42 -3
- package/dist/index.js +10 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1 +1,133 @@
|
|
|
1
|
-
# kgit
|
|
1
|
+
# kgit
|
|
2
|
+
|
|
3
|
+
A CLI tool for managing multi-repo workspaces using [git worktree](https://git-scm.com/docs/git-worktree).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @lakakala/kgit
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Configuration
|
|
12
|
+
|
|
13
|
+
Create `~/.config/kgit/config.json`:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"workspace": "$HOME/projects",
|
|
18
|
+
"projects": [
|
|
19
|
+
{
|
|
20
|
+
"name": "form",
|
|
21
|
+
"path": "$HOME/repos/my-form-repo"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"name": "auth",
|
|
25
|
+
"path": "$HOME/repos/my-auth-repo"
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
| Field | Description |
|
|
32
|
+
|-------|-------------|
|
|
33
|
+
| `workspace` | Root directory where workspaces are created |
|
|
34
|
+
| `projects[].name` | Project alias used in `-p` options |
|
|
35
|
+
| `projects[].path` | Absolute path to the git repository |
|
|
36
|
+
|
|
37
|
+
Environment variables (`$HOME`, `$VAR`, `${VAR}`) are expanded in both `workspace` and `path`.
|
|
38
|
+
|
|
39
|
+
## Commands
|
|
40
|
+
|
|
41
|
+
### `kgit new`
|
|
42
|
+
|
|
43
|
+
Create a new workspace with git worktrees.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
kgit new <workspace> [-b <branch>] -p <project[:base]> [-p <project[:base]> ...]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The new branch name defaults to `<workspace>` unless overridden with `-b`.
|
|
50
|
+
`<base>` is the base branch used when creating a new branch (default: `master`).
|
|
51
|
+
|
|
52
|
+
For each `-p`, kgit resolves the branch using this order:
|
|
53
|
+
|
|
54
|
+
1. Branch already exists **locally** → check it out directly
|
|
55
|
+
2. Branch exists **on remote** → create a local tracking branch
|
|
56
|
+
3. Branch does not exist → create a new branch from `<base>`
|
|
57
|
+
|
|
58
|
+
**Examples:**
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# New branch "feat-login" from master in each project
|
|
62
|
+
kgit new feat-login -p form -p auth
|
|
63
|
+
|
|
64
|
+
# Specify a different base branch
|
|
65
|
+
kgit new feat-login -p form:develop -p auth:develop
|
|
66
|
+
|
|
67
|
+
# Explicit branch name (reuses existing or creates from base)
|
|
68
|
+
kgit new feat-login -b my-feature -p form:develop
|
|
69
|
+
|
|
70
|
+
# Mix projects with different base branches
|
|
71
|
+
kgit new feat-login -p form:master -p auth:develop
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Result layout:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
$workspace/
|
|
78
|
+
└── feat-login/
|
|
79
|
+
├── form/ ← worktree on branch "feat-login"
|
|
80
|
+
└── auth/ ← worktree on branch "feat-login"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
### `kgit append`
|
|
86
|
+
|
|
87
|
+
Add more projects to an existing workspace. Accepts the same options as `new`.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
kgit append <workspace> [-b <branch>] -p <project[:base]> [-p <project[:base]> ...]
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Example:**
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
kgit append feat-login -p gateway:master
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
### `kgit remove`
|
|
102
|
+
|
|
103
|
+
Remove an entire workspace or specific projects from it.
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
kgit remove <workspace> # remove entire workspace
|
|
107
|
+
kgit remove <workspace> -p <project> [-p ...] # remove specific projects
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**Examples:**
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
kgit remove feat-login # removes all worktrees and the directory
|
|
114
|
+
kgit remove feat-login -p form # removes only the "form" worktree
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
### `kgit list`
|
|
120
|
+
|
|
121
|
+
List all workspaces and their projects with the current branch.
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
kgit list
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Output:**
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
feat-login
|
|
131
|
+
└─ form (feat-login)
|
|
132
|
+
└─ auth (feat-login)
|
|
133
|
+
```
|
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/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
|
@@ -1,11 +1,50 @@
|
|
|
1
1
|
import { execa } from 'execa';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
async function localBranchExists(repoPath, branch) {
|
|
5
|
+
const { stdout } = await execa('git', ['-C', repoPath, 'branch', '--list', branch], { stdio: 'pipe' });
|
|
6
|
+
return stdout.trim().length > 0;
|
|
7
|
+
}
|
|
8
|
+
async function remoteBranchExists(repoPath, branch) {
|
|
9
|
+
try {
|
|
10
|
+
const { stdout } = await execa('git', ['-C', repoPath, 'branch', '-r', '--list', `*/${branch}`], { stdio: 'pipe' });
|
|
11
|
+
const match = stdout.trim().split('\n').find(l => l.trim().length > 0);
|
|
12
|
+
return match ? match.trim() : null;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
4
18
|
export async function addWorktree(repoPath, worktreePath, newBranch, baseBranch) {
|
|
5
19
|
await fs.promises.mkdir(path.dirname(worktreePath), { recursive: true });
|
|
6
|
-
await
|
|
7
|
-
|
|
8
|
-
|
|
20
|
+
if (await localBranchExists(repoPath, newBranch)) {
|
|
21
|
+
// Branch exists locally — use it directly
|
|
22
|
+
console.log(` Branch "${newBranch}" exists locally, using it directly.`);
|
|
23
|
+
await execa('git', ['-C', repoPath, 'worktree', 'add', worktreePath, newBranch], { stdio: 'inherit' });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const remoteBranch = await remoteBranchExists(repoPath, newBranch);
|
|
27
|
+
if (remoteBranch) {
|
|
28
|
+
// Branch exists on remote — create a tracking local branch
|
|
29
|
+
console.log(` Branch "${newBranch}" found on remote (${remoteBranch}), creating tracking branch.`);
|
|
30
|
+
await execa('git', ['-C', repoPath, 'worktree', 'add', '--track', '-b', newBranch, worktreePath, remoteBranch], { stdio: 'inherit' });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
// Branch does not exist — create from base branch
|
|
34
|
+
await execa('git', ['-C', repoPath, 'worktree', 'add', '-b', newBranch, worktreePath, baseBranch], { stdio: 'inherit' });
|
|
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;
|
|
9
48
|
}
|
|
10
49
|
export async function removeWorktree(repoPath, worktreePath) {
|
|
11
50
|
await execa('git', ['-C', repoPath, 'worktree', 'remove', worktreePath, '--force'], {
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
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
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const { version } = require('../package.json');
|
|
7
10
|
function collectProject(value, previous) {
|
|
8
11
|
const colonIdx = value.indexOf(':');
|
|
9
12
|
const name = colonIdx === -1 ? value : value.slice(0, colonIdx);
|
|
@@ -14,7 +17,7 @@ const program = new Command();
|
|
|
14
17
|
program
|
|
15
18
|
.name('kgit')
|
|
16
19
|
.description('Git worktree workspace manager')
|
|
17
|
-
.version(
|
|
20
|
+
.version(version);
|
|
18
21
|
program
|
|
19
22
|
.command('new <workspace>')
|
|
20
23
|
.description('Create a new workspace with git worktrees')
|
|
@@ -52,6 +55,12 @@ program
|
|
|
52
55
|
.action(async () => {
|
|
53
56
|
await listCommand();
|
|
54
57
|
});
|
|
58
|
+
program
|
|
59
|
+
.command('version')
|
|
60
|
+
.description('Print the current version')
|
|
61
|
+
.action(() => {
|
|
62
|
+
console.log(`kgit v${version}`);
|
|
63
|
+
});
|
|
55
64
|
program.parseAsync(process.argv, { from: 'node' }).catch((err) => {
|
|
56
65
|
console.error(`Error: ${err.message}`);
|
|
57
66
|
process.exit(1);
|