@ohzw/worktree-command-tui 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.
@@ -0,0 +1,263 @@
1
+ import path from 'node:path';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { loadToolConfig } from './config.js';
5
+ import { readWorktrees, sortWorktrees, toShortPath } from './git-worktrees.js';
6
+ import { getInvalidReason } from './validation.js';
7
+ import { getSessionPaths, readSessionRecord, writeSessionRecord, clearSessionRecord } from './session-store.js';
8
+ import { startDetachedCommand } from './command-runner.js';
9
+ import { stopSessionWithFallback } from './process-control.js';
10
+ import { isProcessGroupAlive, killProcessGroup, killPortOwner, killOrphans } from './posix-process.js';
11
+ const execFileAsync = promisify(execFile);
12
+ const SHORT_SHA_LENGTH = 8;
13
+ const GH_TIMEOUT_MS = 2500;
14
+ function shortenSha(headSha) {
15
+ return headSha.slice(0, SHORT_SHA_LENGTH);
16
+ }
17
+ function createEmptyWorkingTree() {
18
+ return { staged: 0, unstaged: 0, untracked: 0, conflicts: 0 };
19
+ }
20
+ export function parseGitStatusSummary(output) {
21
+ const workingTree = createEmptyWorkingTree();
22
+ let upstreamBranch;
23
+ let ahead = 0;
24
+ let behind = 0;
25
+ for (const line of output.split('\n')) {
26
+ if (line.startsWith('# branch.upstream ')) {
27
+ upstreamBranch = line.slice('# branch.upstream '.length).trim();
28
+ continue;
29
+ }
30
+ if (line.startsWith('# branch.ab ')) {
31
+ const match = /# branch\.ab \+(\d+) -(\d+)/.exec(line);
32
+ ahead = Number(match?.[1] ?? 0);
33
+ behind = Number(match?.[2] ?? 0);
34
+ continue;
35
+ }
36
+ if (line.startsWith('1 ') || line.startsWith('2 ')) {
37
+ const [, xy = '..'] = line.split(' ', 3);
38
+ if (xy[0] !== '.') {
39
+ workingTree.staged += 1;
40
+ }
41
+ if (xy[1] !== '.') {
42
+ workingTree.unstaged += 1;
43
+ }
44
+ continue;
45
+ }
46
+ if (line.startsWith('u ')) {
47
+ workingTree.conflicts += 1;
48
+ continue;
49
+ }
50
+ if (line.startsWith('? ')) {
51
+ workingTree.untracked += 1;
52
+ }
53
+ }
54
+ return {
55
+ upstream: upstreamBranch ? { branch: upstreamBranch, ahead, behind } : undefined,
56
+ upstreamUnavailable: false,
57
+ workingTree,
58
+ };
59
+ }
60
+ async function readGitStatusSummary(cwd) {
61
+ try {
62
+ const { stdout } = await execFileAsync('git', ['status', '--branch', '--porcelain=v2'], { cwd });
63
+ return parseGitStatusSummary(stdout);
64
+ }
65
+ catch {
66
+ return { upstreamUnavailable: true };
67
+ }
68
+ }
69
+ async function readPullRequestList(cwd, branch, state) {
70
+ const { stdout } = await execFileAsync('gh', [
71
+ 'pr',
72
+ 'list',
73
+ '--head',
74
+ branch,
75
+ '--state',
76
+ state,
77
+ '--limit',
78
+ '1',
79
+ '--json',
80
+ 'number,title,url,state,isDraft,baseRefName',
81
+ ], { cwd, timeout: GH_TIMEOUT_MS });
82
+ return JSON.parse(stdout);
83
+ }
84
+ async function readPullRequestInfo(cwd, branch) {
85
+ if (branch.startsWith('(')) {
86
+ return { kind: 'none' };
87
+ }
88
+ try {
89
+ const openPullRequests = await readPullRequestList(cwd, branch, 'open');
90
+ const parsed = openPullRequests.length > 0 ? openPullRequests : await readPullRequestList(cwd, branch, 'all');
91
+ const pr = parsed[0];
92
+ if (!pr) {
93
+ return { kind: 'none' };
94
+ }
95
+ return {
96
+ kind: 'found',
97
+ number: pr.number,
98
+ title: pr.title,
99
+ url: pr.url,
100
+ state: pr.state,
101
+ isDraft: pr.isDraft,
102
+ baseBranch: pr.baseRefName,
103
+ };
104
+ }
105
+ catch {
106
+ return { kind: 'unavailable' };
107
+ }
108
+ }
109
+ async function readRowMetadata(worktreePath, branch) {
110
+ const [statusSummary, pullRequest] = await Promise.all([
111
+ readGitStatusSummary(worktreePath),
112
+ readPullRequestInfo(worktreePath, branch),
113
+ ]);
114
+ return {
115
+ upstream: statusSummary.upstream,
116
+ upstreamUnavailable: statusSummary.upstreamUnavailable,
117
+ workingTree: statusSummary.workingTree,
118
+ pullRequest,
119
+ };
120
+ }
121
+ async function resolveRepoContext(cwd) {
122
+ const [{ stdout: workspaceRootRaw }, { stdout: gitCommonDirRaw }] = await Promise.all([
123
+ execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd }),
124
+ execFileAsync('git', ['rev-parse', '--git-common-dir'], { cwd }),
125
+ ]);
126
+ const workspaceRoot = workspaceRootRaw.trim();
127
+ const gitCommonDir = path.resolve(workspaceRoot, gitCommonDirRaw.trim());
128
+ const mainWorktreePath = path.dirname(gitCommonDir);
129
+ return { workspaceRoot, mainWorktreePath, gitCommonDir };
130
+ }
131
+ export function toAppRow(mainWorktreePath, worktree, activePath, invalidReason, metadata) {
132
+ const tags = [];
133
+ if (worktree.isMain) {
134
+ tags.push('main');
135
+ }
136
+ if (activePath === worktree.path) {
137
+ tags.push('active');
138
+ }
139
+ if (worktree.isExternal) {
140
+ tags.push('external');
141
+ }
142
+ if (invalidReason) {
143
+ tags.push('invalid');
144
+ }
145
+ return {
146
+ path: worktree.path,
147
+ shortPath: toShortPath(mainWorktreePath, worktree.path),
148
+ branch: worktree.branch,
149
+ headSha: shortenSha(worktree.headSha),
150
+ tags,
151
+ upstream: metadata.upstream,
152
+ upstreamUnavailable: metadata.upstreamUnavailable,
153
+ workingTree: metadata.workingTree,
154
+ pullRequest: metadata.pullRequest,
155
+ invalidReason: invalidReason ?? undefined,
156
+ };
157
+ }
158
+ async function buildRows(mainWorktreePath, workspaceRoot, activePath, requiredFiles) {
159
+ const worktrees = sortWorktrees(await readWorktrees(workspaceRoot, mainWorktreePath), activePath);
160
+ const rows = await Promise.all(worktrees.map(async (worktree) => {
161
+ const [invalidReason, metadata] = await Promise.all([
162
+ getInvalidReason(worktree.path, requiredFiles),
163
+ readRowMetadata(worktree.path, worktree.branch),
164
+ ]);
165
+ return toAppRow(mainWorktreePath, worktree, activePath, invalidReason, metadata);
166
+ }));
167
+ return rows;
168
+ }
169
+ async function stopRecordedSession(pgid, port, orphanMatchers) {
170
+ const stopped = await stopSessionWithFallback({ pgid, port, orphanMatchers }, {
171
+ killProcessGroup,
172
+ killPortOwner,
173
+ killOrphans,
174
+ isSessionAlive: isProcessGroupAlive,
175
+ });
176
+ if (!stopped) {
177
+ throw new Error(`Failed to stop existing session pgid=${pgid}`);
178
+ }
179
+ }
180
+ export async function buildInitialModel(cwd) {
181
+ const { workspaceRoot, mainWorktreePath, gitCommonDir } = await resolveRepoContext(cwd);
182
+ const config = await loadToolConfig({ repoRoot: workspaceRoot });
183
+ const paths = getSessionPaths(gitCommonDir, config.namespace);
184
+ const active = await readSessionRecord(paths, { isSessionAlive: isProcessGroupAlive });
185
+ return {
186
+ repoName: path.basename(mainWorktreePath),
187
+ namespace: config.namespace,
188
+ rows: await buildRows(mainWorktreePath, workspaceRoot, active?.worktreePath ?? null, config.requiredFiles),
189
+ activePath: active?.worktreePath ?? null,
190
+ activeBranch: active?.branch ?? null,
191
+ status: active ? { kind: 'running', message: `Active: ${active.branch}` } : { kind: 'idle', message: 'ready' },
192
+ };
193
+ }
194
+ export async function buildActions(cwd) {
195
+ const { workspaceRoot, gitCommonDir } = await resolveRepoContext(cwd);
196
+ const config = await loadToolConfig({ repoRoot: workspaceRoot });
197
+ const paths = getSessionPaths(gitCommonDir, config.namespace);
198
+ const mainWorktreePath = path.dirname(gitCommonDir);
199
+ const refresh = async () => buildInitialModel(cwd);
200
+ const stop = async () => {
201
+ const active = await readSessionRecord(paths, { isSessionAlive: isProcessGroupAlive });
202
+ if (active) {
203
+ await stopRecordedSession(active.pgid, active.port, config.orphanMatchers);
204
+ await clearSessionRecord(paths);
205
+ }
206
+ const model = await refresh();
207
+ return {
208
+ ...model,
209
+ activePath: null,
210
+ activeBranch: null,
211
+ status: { kind: 'idle', message: active ? 'stopped' : 'already stopped' },
212
+ };
213
+ };
214
+ const start = async (worktreePath) => {
215
+ const current = await readSessionRecord(paths, { isSessionAlive: isProcessGroupAlive });
216
+ if (current?.worktreePath === worktreePath) {
217
+ const model = await refresh();
218
+ return {
219
+ ...model,
220
+ activePath: current.worktreePath,
221
+ activeBranch: current.branch,
222
+ status: { kind: 'idle', message: 'already active' },
223
+ };
224
+ }
225
+ const invalidReason = await getInvalidReason(worktreePath, config.requiredFiles);
226
+ if (invalidReason) {
227
+ throw new Error(invalidReason);
228
+ }
229
+ if (current) {
230
+ await stopRecordedSession(current.pgid, current.port, config.orphanMatchers);
231
+ await clearSessionRecord(paths);
232
+ }
233
+ const rows = await readWorktrees(workspaceRoot, mainWorktreePath);
234
+ const selected = rows.find(row => row.path === worktreePath);
235
+ if (!selected) {
236
+ throw new Error(`Worktree disappeared: ${worktreePath}`);
237
+ }
238
+ const started = await startDetachedCommand({
239
+ command: config.command,
240
+ cwd: worktreePath,
241
+ logsDir: paths.logsDir,
242
+ logFileBase: selected.branch.replace(/[\\/]/g, '-'),
243
+ });
244
+ await writeSessionRecord(paths, {
245
+ namespace: config.namespace,
246
+ worktreePath,
247
+ branch: selected.branch,
248
+ pid: started.pid,
249
+ pgid: started.pgid,
250
+ port: config.port,
251
+ logPath: started.logPath,
252
+ startedAt: new Date().toISOString(),
253
+ });
254
+ const model = await refresh();
255
+ return {
256
+ ...model,
257
+ activePath: worktreePath,
258
+ activeBranch: selected.branch,
259
+ status: { kind: 'running', message: `started ${selected.branch}` },
260
+ };
261
+ };
262
+ return { start, stop, refresh };
263
+ }
@@ -0,0 +1,21 @@
1
+ export interface SessionRecord {
2
+ namespace: string;
3
+ worktreePath: string;
4
+ branch: string;
5
+ pid: number;
6
+ pgid: number;
7
+ port: number;
8
+ logPath: string;
9
+ startedAt: string;
10
+ }
11
+ export interface SessionPaths {
12
+ baseDir: string;
13
+ logsDir: string;
14
+ sessionFile: string;
15
+ }
16
+ export declare function getSessionPaths(gitCommonDir: string, namespace: string): SessionPaths;
17
+ export declare function readSessionRecord(paths: Pick<SessionPaths, 'sessionFile'>, { isSessionAlive }: {
18
+ isSessionAlive: (pgid: number) => Promise<boolean>;
19
+ }): Promise<SessionRecord | null>;
20
+ export declare function writeSessionRecord(paths: Pick<SessionPaths, 'baseDir' | 'sessionFile'>, record: SessionRecord): Promise<void>;
21
+ export declare function clearSessionRecord(paths: Pick<SessionPaths, 'sessionFile'>): Promise<void>;
@@ -0,0 +1,51 @@
1
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ function isPositiveInteger(value) {
4
+ return typeof value === 'number' && Number.isInteger(value) && value > 0;
5
+ }
6
+ function isSessionRecord(value) {
7
+ if (typeof value !== 'object' || value === null) {
8
+ return false;
9
+ }
10
+ const record = value;
11
+ return (typeof record.namespace === 'string' &&
12
+ typeof record.worktreePath === 'string' &&
13
+ typeof record.branch === 'string' &&
14
+ isPositiveInteger(record.pid) &&
15
+ isPositiveInteger(record.pgid) &&
16
+ isPositiveInteger(record.port) &&
17
+ typeof record.logPath === 'string' &&
18
+ typeof record.startedAt === 'string');
19
+ }
20
+ export function getSessionPaths(gitCommonDir, namespace) {
21
+ const baseDir = path.join(gitCommonDir, 'worktree-command-tui');
22
+ return {
23
+ baseDir,
24
+ logsDir: path.join(baseDir, 'logs'),
25
+ sessionFile: path.join(baseDir, `${namespace}.json`),
26
+ };
27
+ }
28
+ export async function readSessionRecord(paths, { isSessionAlive }) {
29
+ try {
30
+ const parsed = JSON.parse(await readFile(paths.sessionFile, 'utf8'));
31
+ if (!isSessionRecord(parsed)) {
32
+ await rm(paths.sessionFile, { force: true });
33
+ return null;
34
+ }
35
+ if (!(await isSessionAlive(parsed.pgid))) {
36
+ await rm(paths.sessionFile, { force: true });
37
+ return null;
38
+ }
39
+ return parsed;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ export async function writeSessionRecord(paths, record) {
46
+ await mkdir(paths.baseDir, { recursive: true });
47
+ await writeFile(paths.sessionFile, JSON.stringify(record, null, 2));
48
+ }
49
+ export async function clearSessionRecord(paths) {
50
+ await rm(paths.sessionFile, { force: true });
51
+ }
@@ -0,0 +1 @@
1
+ export declare function getInvalidReason(worktreePath: string, requiredFiles: string[]): Promise<string | null>;
@@ -0,0 +1,14 @@
1
+ import { access } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export async function getInvalidReason(worktreePath, requiredFiles) {
4
+ const missing = new Set();
5
+ for (const relativePath of requiredFiles) {
6
+ try {
7
+ await access(path.join(worktreePath, relativePath));
8
+ }
9
+ catch {
10
+ missing.add(relativePath);
11
+ }
12
+ }
13
+ return missing.size === 0 ? null : `Missing required files: ${[...missing].join(', ')}`;
14
+ }
package/dist/main.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/main.js ADDED
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { createConfigForRepo, parseInitArgs } from './core/init.js';
4
+ import { CONFIG_FILE_NAME, CONFIG_FILE_NAMES } from './core/config.js';
5
+ import { render } from 'ink';
6
+ import { App } from './app.js';
7
+ import { buildActions, buildInitialModel } from './core/runtime.js';
8
+ import { APP_RENDER_OPTIONS } from './render-options.js';
9
+ const cwd = process.cwd();
10
+ const args = process.argv.slice(2);
11
+ const [, , subcommand] = process.argv;
12
+ function printUsage() {
13
+ console.log('Usage:');
14
+ console.log(' wctui [args...]');
15
+ console.log(' wctui init [--force]');
16
+ console.log(' (alias: worktree-command-tui)');
17
+ }
18
+ function isConfigMissingError(error) {
19
+ const err = error;
20
+ return err.code === 'ENOENT' && typeof err.path === 'string' && CONFIG_FILE_NAMES.some(fileName => err.path?.endsWith(fileName));
21
+ }
22
+ function describeError(error) {
23
+ if (error instanceof Error) {
24
+ if (isConfigMissingError(error)) {
25
+ return `${error.message}
26
+ Run "wctui init" to generate ${CONFIG_FILE_NAME} before starting the TUI.`;
27
+ }
28
+ return error.message;
29
+ }
30
+ return 'An unexpected error occurred';
31
+ }
32
+ async function handleInitCommand() {
33
+ let parsed;
34
+ try {
35
+ parsed = parseInitArgs(args.slice(1));
36
+ }
37
+ catch (error) {
38
+ console.error(error.message);
39
+ process.exit(1);
40
+ }
41
+ if (parsed.help) {
42
+ printUsage();
43
+ return;
44
+ }
45
+ try {
46
+ const result = await createConfigForRepo({ cwd, force: parsed.force });
47
+ console.log(`Created ${result.path}`);
48
+ }
49
+ catch (error) {
50
+ console.error(error.message);
51
+ process.exit(1);
52
+ }
53
+ }
54
+ if (subcommand === 'init') {
55
+ await handleInitCommand();
56
+ process.exit(0);
57
+ }
58
+ if (args.includes('-h') || args.includes('--help')) {
59
+ printUsage();
60
+ process.exit(0);
61
+ }
62
+ if (subcommand !== undefined) {
63
+ console.error(`Unknown command: ${subcommand}`);
64
+ process.exit(1);
65
+ }
66
+ try {
67
+ const [initialModel, actions] = await Promise.all([buildInitialModel(cwd), buildActions(cwd)]);
68
+ render(_jsx(App, { initialModel: initialModel, actions: actions }), APP_RENDER_OPTIONS);
69
+ }
70
+ catch (error) {
71
+ console.error(describeError(error));
72
+ process.exit(1);
73
+ }
@@ -0,0 +1,4 @@
1
+ export declare const APP_RENDER_OPTIONS: {
2
+ readonly alternateScreen: true;
3
+ readonly exitOnCtrlC: true;
4
+ };
@@ -0,0 +1,4 @@
1
+ export const APP_RENDER_OPTIONS = {
2
+ alternateScreen: true,
3
+ exitOnCtrlC: true,
4
+ };
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@ohzw/worktree-command-tui",
3
+ "version": "0.1.0",
4
+ "description": "A TUI for managing git worktrees",
5
+ "private": false,
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "author": "ohzw",
9
+ "homepage": "https://github.com/ohzw/worktree-command-tui#readme",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/ohzw/worktree-command-tui.git"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/ohzw/worktree-command-tui/issues"
16
+ },
17
+ "keywords": [
18
+ "cli",
19
+ "git",
20
+ "worktree",
21
+ "tui",
22
+ "terminal"
23
+ ],
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "scripts": {
28
+ "build": "tsc --project tsconfig.build.json",
29
+ "init": "tsx src/main.tsx init",
30
+ "start": "tsx src/main.tsx",
31
+ "start:built": "node dist/main.js",
32
+ "test": "vitest run",
33
+ "typecheck": "tsc --noEmit",
34
+ "prepare": "npm run build",
35
+ "prepublishOnly": "npm run build"
36
+ },
37
+ "bin": {
38
+ "wctui": "./dist/main.js",
39
+ "worktree-command-tui": "./dist/main.js"
40
+ },
41
+ "main": "./dist/main.js",
42
+ "types": "./dist/main.d.ts",
43
+ "exports": {
44
+ ".": {
45
+ "import": "./dist/main.js",
46
+ "default": "./dist/main.js"
47
+ },
48
+ "./package.json": "./package.json"
49
+ },
50
+ "files": [
51
+ "dist"
52
+ ],
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "dependencies": {
57
+ "ink": "^7.0.4",
58
+ "react": "^19.2.0"
59
+ },
60
+ "devDependencies": {
61
+ "@types/node": "^24.0.0",
62
+ "@types/react": "^19.2.0",
63
+ "ink-testing-library": "^4.0.0",
64
+ "tsx": "^4.20.0",
65
+ "typescript": "^5.9.3",
66
+ "vitest": "^4.0.18"
67
+ }
68
+ }