@lakakala/kgit 0.2.0 → 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 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
+ ```
@@ -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/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,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('0.1.0');
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lakakala/kgit",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Git worktree workspace manager",
5
5
  "type": "module",
6
6
  "bin": {