@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,152 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export const CONFIG_FILE_NAME = '.worktree-command-tui.jsonc';
4
+ export const LEGACY_CONFIG_FILE_NAME = '.worktree-command-tui.json';
5
+ export const CONFIG_FILE_NAMES = [CONFIG_FILE_NAME, LEGACY_CONFIG_FILE_NAME];
6
+ function isNonEmptyString(value) {
7
+ return typeof value === 'string' && value.length > 0;
8
+ }
9
+ function isSafeNamespace(value) {
10
+ return isNonEmptyString(value) && /^[A-Za-z0-9._-]+$/u.test(value);
11
+ }
12
+ function readStringList(value, fieldName) {
13
+ if (value === undefined) {
14
+ return [];
15
+ }
16
+ if (!Array.isArray(value) || value.some(item => !isNonEmptyString(item))) {
17
+ throw new Error(`${fieldName} must be a string array`);
18
+ }
19
+ return value;
20
+ }
21
+ function stripJsoncComments(source) {
22
+ let result = '';
23
+ let inString = false;
24
+ let escaping = false;
25
+ let inLineComment = false;
26
+ let inBlockComment = false;
27
+ for (let index = 0; index < source.length; index += 1) {
28
+ const current = source[index];
29
+ const next = source[index + 1];
30
+ if (inLineComment) {
31
+ if (current === '\n' || current === '\r') {
32
+ inLineComment = false;
33
+ result += current;
34
+ }
35
+ continue;
36
+ }
37
+ if (inBlockComment) {
38
+ if (current === '*' && next === '/') {
39
+ inBlockComment = false;
40
+ index += 1;
41
+ continue;
42
+ }
43
+ result += current === '\n' || current === '\r' ? current : ' ';
44
+ continue;
45
+ }
46
+ if (inString) {
47
+ result += current;
48
+ if (escaping) {
49
+ escaping = false;
50
+ continue;
51
+ }
52
+ if (current === '\\') {
53
+ escaping = true;
54
+ continue;
55
+ }
56
+ if (current === '"') {
57
+ inString = false;
58
+ }
59
+ continue;
60
+ }
61
+ if (current === '"') {
62
+ inString = true;
63
+ result += current;
64
+ continue;
65
+ }
66
+ if (current === '/' && next === '/') {
67
+ inLineComment = true;
68
+ index += 1;
69
+ continue;
70
+ }
71
+ if (current === '/' && next === '*') {
72
+ inBlockComment = true;
73
+ index += 1;
74
+ continue;
75
+ }
76
+ result += current;
77
+ }
78
+ return result;
79
+ }
80
+ function stripTrailingCommas(source) {
81
+ let result = '';
82
+ let inString = false;
83
+ let escaping = false;
84
+ for (let index = 0; index < source.length; index += 1) {
85
+ const current = source[index];
86
+ if (inString) {
87
+ result += current;
88
+ if (escaping) {
89
+ escaping = false;
90
+ continue;
91
+ }
92
+ if (current === '\\') {
93
+ escaping = true;
94
+ continue;
95
+ }
96
+ if (current === '"') {
97
+ inString = false;
98
+ }
99
+ continue;
100
+ }
101
+ if (current === '"') {
102
+ inString = true;
103
+ result += current;
104
+ continue;
105
+ }
106
+ if (current === ',') {
107
+ let lookahead = index + 1;
108
+ while (lookahead < source.length && /\s/u.test(source[lookahead] ?? '')) {
109
+ lookahead += 1;
110
+ }
111
+ if (source[lookahead] === '}' || source[lookahead] === ']') {
112
+ continue;
113
+ }
114
+ }
115
+ result += current;
116
+ }
117
+ return result;
118
+ }
119
+ export function parseJsonc(source) {
120
+ return JSON.parse(stripTrailingCommas(stripJsoncComments(source)));
121
+ }
122
+ async function readFirstConfig(repoRoot) {
123
+ let firstError;
124
+ for (const fileName of CONFIG_FILE_NAMES) {
125
+ try {
126
+ return await readFile(path.join(repoRoot, fileName), 'utf8');
127
+ }
128
+ catch (error) {
129
+ firstError ??= error;
130
+ }
131
+ }
132
+ throw firstError;
133
+ }
134
+ export async function loadToolConfig({ repoRoot }) {
135
+ const raw = parseJsonc(await readFirstConfig(repoRoot));
136
+ if (!Array.isArray(raw.command) || raw.command.length === 0 || raw.command.some(part => !isNonEmptyString(part))) {
137
+ throw new Error('command must be a non-empty string array');
138
+ }
139
+ if (!isSafeNamespace(raw.namespace)) {
140
+ throw new Error('namespace must match [A-Za-z0-9._-]+');
141
+ }
142
+ if (typeof raw.port !== 'number' || !Number.isInteger(raw.port) || raw.port < 1 || raw.port > 65535) {
143
+ throw new Error('port must be an integer between 1 and 65535');
144
+ }
145
+ return {
146
+ namespace: raw.namespace,
147
+ command: raw.command,
148
+ port: raw.port,
149
+ requiredFiles: readStringList(raw.requiredFiles, 'requiredFiles'),
150
+ orphanMatchers: readStringList(raw.orphanMatchers, 'orphanMatchers'),
151
+ };
152
+ }
@@ -0,0 +1,11 @@
1
+ export interface WorktreeRow {
2
+ path: string;
3
+ branch: string;
4
+ headSha: string;
5
+ isMain: boolean;
6
+ isExternal: boolean;
7
+ }
8
+ export declare function parseWorktreeListPorcelain(input: string, mainWorktreePath: string): WorktreeRow[];
9
+ export declare function sortWorktrees(rows: WorktreeRow[], activePath: string | null): WorktreeRow[];
10
+ export declare function readWorktrees(cwd: string, mainWorktreePath: string): Promise<WorktreeRow[]>;
11
+ export declare function toShortPath(mainWorktreePath: string, worktreePath: string): string;
@@ -0,0 +1,60 @@
1
+ import { execFile } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { promisify } from 'node:util';
4
+ const execFileAsync = promisify(execFile);
5
+ function isExternalWorktree(mainWorktreePath, worktreePath) {
6
+ const relativePath = path.relative(mainWorktreePath, worktreePath);
7
+ return relativePath !== '' && (relativePath === '..' || relativePath.startsWith(`..${path.sep}`));
8
+ }
9
+ export function parseWorktreeListPorcelain(input, mainWorktreePath) {
10
+ const trimmed = input.trim();
11
+ if (trimmed.length === 0) {
12
+ return [];
13
+ }
14
+ return trimmed
15
+ .split(/\n\s*\n/)
16
+ .filter(Boolean)
17
+ .map(block => {
18
+ const lines = block.split('\n');
19
+ const pathLine = lines.find(line => line.startsWith('worktree '));
20
+ const headLine = lines.find(line => line.startsWith('HEAD '));
21
+ const branchLine = lines.find(line => line.startsWith('branch '));
22
+ const detached = lines.includes('detached');
23
+ const worktreePath = pathLine?.slice('worktree '.length) ?? '';
24
+ const fullRef = branchLine?.slice('branch '.length) ?? '';
25
+ return {
26
+ path: worktreePath,
27
+ branch: branchLine ? fullRef.replace('refs/heads/', '') : detached ? '(detached)' : '(unknown)',
28
+ headSha: headLine?.slice('HEAD '.length) ?? '',
29
+ isMain: worktreePath === mainWorktreePath,
30
+ isExternal: isExternalWorktree(mainWorktreePath, worktreePath),
31
+ };
32
+ });
33
+ }
34
+ export function sortWorktrees(rows, activePath) {
35
+ return [...rows].sort((left, right) => {
36
+ if (left.isMain !== right.isMain) {
37
+ return left.isMain ? -1 : 1;
38
+ }
39
+ const leftActive = activePath !== null && left.path === activePath;
40
+ const rightActive = activePath !== null && right.path === activePath;
41
+ if (leftActive !== rightActive) {
42
+ return leftActive ? -1 : 1;
43
+ }
44
+ const branchCompare = left.branch.localeCompare(right.branch);
45
+ return branchCompare !== 0 ? branchCompare : left.path.localeCompare(right.path);
46
+ });
47
+ }
48
+ export async function readWorktrees(cwd, mainWorktreePath) {
49
+ const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], { cwd });
50
+ return parseWorktreeListPorcelain(stdout, mainWorktreePath);
51
+ }
52
+ export function toShortPath(mainWorktreePath, worktreePath) {
53
+ if (worktreePath === mainWorktreePath) {
54
+ return '.';
55
+ }
56
+ if (worktreePath.startsWith(`${mainWorktreePath}${path.sep}`)) {
57
+ return path.relative(mainWorktreePath, worktreePath);
58
+ }
59
+ return worktreePath;
60
+ }
@@ -0,0 +1,22 @@
1
+ import { type ToolConfig } from './config.js';
2
+ export declare function buildDefaultConfig(repoRoot: string): Promise<ToolConfig>;
3
+ export declare function renderConfigJsonc(config: ToolConfig): string;
4
+ export interface InitResult {
5
+ path: string;
6
+ config: ToolConfig;
7
+ }
8
+ export interface InitOptions {
9
+ workspaceRoot: string;
10
+ force: boolean;
11
+ }
12
+ export declare function runInit(options: InitOptions): Promise<InitResult>;
13
+ export interface CliInitOptions {
14
+ cwd: string;
15
+ force: boolean;
16
+ }
17
+ export declare function createConfigForRepo(options: CliInitOptions): Promise<InitResult>;
18
+ export interface InitArgParseResult {
19
+ force: boolean;
20
+ help: boolean;
21
+ }
22
+ export declare function parseInitArgs(args: string[]): InitArgParseResult;
@@ -0,0 +1,157 @@
1
+ import { access, readFile, writeFile } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ import { CONFIG_FILE_NAME, CONFIG_FILE_NAMES } from './config.js';
7
+ const execFileAsync = promisify(execFile);
8
+ function isSafeNamespace(value) {
9
+ return /^[A-Za-z0-9._-]+$/u.test(value);
10
+ }
11
+ function toSafeNamespace(value) {
12
+ const replaced = value.replace(/[^A-Za-z0-9._-]+/gu, '-');
13
+ const trimmed = replaced.replace(/^-+/, '').replace(/-+$/, '');
14
+ return trimmed.length > 0 && isSafeNamespace(trimmed) ? trimmed : 'worktree-command-tui';
15
+ }
16
+ async function resolveRepositoryRoot(cwd) {
17
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd });
18
+ return stdout.trim();
19
+ }
20
+ async function fileExists(filePath) {
21
+ try {
22
+ await access(filePath, constants.F_OK);
23
+ return true;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ async function readPackageJson(root) {
30
+ try {
31
+ const source = await readFile(path.join(root, 'package.json'), 'utf8');
32
+ return JSON.parse(source);
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ function getPackageManagerFromField(packageManagerValue) {
39
+ if (!packageManagerValue) {
40
+ return null;
41
+ }
42
+ if (packageManagerValue.startsWith('bun@')) {
43
+ return 'bun';
44
+ }
45
+ if (packageManagerValue.startsWith('pnpm@')) {
46
+ return 'pnpm';
47
+ }
48
+ if (packageManagerValue.startsWith('yarn@')) {
49
+ return 'yarn';
50
+ }
51
+ if (packageManagerValue.startsWith('npm@')) {
52
+ return 'npm';
53
+ }
54
+ return null;
55
+ }
56
+ async function detectPackageManager(root, packageJson) {
57
+ const declared = getPackageManagerFromField(packageJson?.packageManager);
58
+ if (declared) {
59
+ return declared;
60
+ }
61
+ if (await fileExists(path.join(root, 'bun.lockb')) || await fileExists(path.join(root, 'bun.lock'))) {
62
+ return 'bun';
63
+ }
64
+ if (await fileExists(path.join(root, 'pnpm-lock.yaml'))) {
65
+ return 'pnpm';
66
+ }
67
+ if (await fileExists(path.join(root, 'yarn.lock'))) {
68
+ return 'yarn';
69
+ }
70
+ return 'npm';
71
+ }
72
+ function selectDefaultScript(scripts) {
73
+ if (typeof scripts?.dev === 'string' && scripts.dev.length > 0) {
74
+ return 'dev';
75
+ }
76
+ if (typeof scripts?.start === 'string' && scripts.start.length > 0) {
77
+ return 'start';
78
+ }
79
+ if (typeof scripts?.serve === 'string' && scripts.serve.length > 0) {
80
+ return 'serve';
81
+ }
82
+ const fallback = Object.entries(scripts ?? {}).find(([, script]) => typeof script === 'string' && script.length > 0)?.[0];
83
+ return fallback ?? 'start';
84
+ }
85
+ export async function buildDefaultConfig(repoRoot) {
86
+ const packageJson = await readPackageJson(repoRoot);
87
+ const packageManager = await detectPackageManager(repoRoot, packageJson);
88
+ const command = [packageManager, 'run', selectDefaultScript(packageJson?.scripts)];
89
+ const namespaceSeed = packageJson?.name ?? path.basename(repoRoot);
90
+ return {
91
+ namespace: toSafeNamespace(namespaceSeed),
92
+ command,
93
+ port: 3000,
94
+ requiredFiles: ['package.json'],
95
+ orphanMatchers: [],
96
+ };
97
+ }
98
+ export function renderConfigJsonc(config) {
99
+ return `{
100
+ // Session namespace used for git-common-dir state files and logs.
101
+ // Keep this filesystem-safe: letters, numbers, dots, underscores, and hyphens only.
102
+ "namespace": ${JSON.stringify(config.namespace)},
103
+
104
+ // Command launched in the selected worktree.
105
+ // Use argv form so spaces and shell metacharacters are passed safely.
106
+ "command": ${JSON.stringify(config.command)},
107
+
108
+ // TCP port owned by the command, used when stopping stale/orphaned processes.
109
+ "port": ${JSON.stringify(config.port)},
110
+
111
+ // Files that must exist in a worktree before the command can be started there.
112
+ "requiredFiles": ${JSON.stringify(config.requiredFiles)},
113
+
114
+ // Extra process command-line substrings treated as orphans for cleanup.
115
+ // Example: ["node --watch", "vite --host 0.0.0.0"]
116
+ "orphanMatchers": ${JSON.stringify(config.orphanMatchers)},
117
+ }
118
+ `;
119
+ }
120
+ async function findExistingConfigPath(workspaceRoot) {
121
+ for (const fileName of CONFIG_FILE_NAMES) {
122
+ const configPath = path.join(workspaceRoot, fileName);
123
+ if (await fileExists(configPath)) {
124
+ return configPath;
125
+ }
126
+ }
127
+ return null;
128
+ }
129
+ export async function runInit(options) {
130
+ const configPath = path.join(options.workspaceRoot, CONFIG_FILE_NAME);
131
+ const existingConfigPath = await findExistingConfigPath(options.workspaceRoot);
132
+ if (!options.force && existingConfigPath) {
133
+ throw new Error(`Config file already exists: ${existingConfigPath}`);
134
+ }
135
+ const config = await buildDefaultConfig(options.workspaceRoot);
136
+ await writeFile(configPath, renderConfigJsonc(config), 'utf8');
137
+ return { path: configPath, config };
138
+ }
139
+ export async function createConfigForRepo(options) {
140
+ const workspaceRoot = await resolveRepositoryRoot(options.cwd);
141
+ return runInit({ workspaceRoot, force: options.force });
142
+ }
143
+ export function parseInitArgs(args) {
144
+ const result = { force: false, help: false };
145
+ for (const arg of args) {
146
+ if (arg === '--force') {
147
+ result.force = true;
148
+ continue;
149
+ }
150
+ if (arg === '--help' || arg === '-h') {
151
+ result.help = true;
152
+ continue;
153
+ }
154
+ throw new Error(`Unknown argument: ${arg}`);
155
+ }
156
+ return result;
157
+ }
@@ -0,0 +1,4 @@
1
+ export declare function isProcessGroupAlive(pgid: number): Promise<boolean>;
2
+ export declare function killProcessGroup(pgid: number, signal?: NodeJS.Signals): Promise<void>;
3
+ export declare function killPortOwner(port: number): Promise<void>;
4
+ export declare function killOrphans(matcher: string): Promise<void>;
@@ -0,0 +1,42 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ export async function isProcessGroupAlive(pgid) {
5
+ try {
6
+ process.kill(-pgid, 0);
7
+ return true;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ export async function killProcessGroup(pgid, signal = 'SIGTERM') {
14
+ try {
15
+ process.kill(-pgid, signal);
16
+ }
17
+ catch {
18
+ // Process group already gone.
19
+ }
20
+ }
21
+ export async function killPortOwner(port) {
22
+ try {
23
+ const { stdout } = await execFileAsync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t']);
24
+ for (const pid of stdout
25
+ .split('\n')
26
+ .map(line => line.trim())
27
+ .filter(Boolean)) {
28
+ await execFileAsync('kill', [pid]);
29
+ }
30
+ }
31
+ catch {
32
+ // Port not owned or lsof found nothing.
33
+ }
34
+ }
35
+ export async function killOrphans(matcher) {
36
+ try {
37
+ await execFileAsync('pkill', ['-f', matcher]);
38
+ }
39
+ catch {
40
+ // No matching orphan process.
41
+ }
42
+ }
@@ -0,0 +1,11 @@
1
+ export interface CleanupDeps {
2
+ killProcessGroup: (pgid: number, signal?: NodeJS.Signals) => Promise<void>;
3
+ killPortOwner: (port: number) => Promise<void>;
4
+ killOrphans: (matcher: string) => Promise<void>;
5
+ isSessionAlive: (pgid: number) => Promise<boolean>;
6
+ }
7
+ export declare function stopSessionWithFallback(input: {
8
+ pgid: number;
9
+ port: number;
10
+ orphanMatchers: string[];
11
+ }, deps: CleanupDeps): Promise<boolean>;
@@ -0,0 +1,15 @@
1
+ export async function stopSessionWithFallback(input, deps) {
2
+ await deps.killProcessGroup(input.pgid, 'SIGTERM');
3
+ if (!(await deps.isSessionAlive(input.pgid))) {
4
+ return true;
5
+ }
6
+ await deps.killPortOwner(input.port);
7
+ for (const matcher of input.orphanMatchers) {
8
+ await deps.killOrphans(matcher);
9
+ }
10
+ if (!(await deps.isSessionAlive(input.pgid))) {
11
+ return true;
12
+ }
13
+ await deps.killProcessGroup(input.pgid, 'SIGKILL');
14
+ return !(await deps.isSessionAlive(input.pgid));
15
+ }
@@ -0,0 +1,65 @@
1
+ import { type WorktreeRow } from './git-worktrees.js';
2
+ export type RowTag = 'main' | 'active' | 'invalid' | 'external' | 'legacy';
3
+ export interface UpstreamInfo {
4
+ branch: string;
5
+ ahead: number;
6
+ behind: number;
7
+ }
8
+ export interface WorkingTreeInfo {
9
+ staged: number;
10
+ unstaged: number;
11
+ untracked: number;
12
+ conflicts: number;
13
+ }
14
+ export type PullRequestInfo = {
15
+ kind: 'found';
16
+ number: number;
17
+ title: string;
18
+ url: string;
19
+ state: 'OPEN' | 'CLOSED' | 'MERGED';
20
+ isDraft: boolean;
21
+ baseBranch: string;
22
+ } | {
23
+ kind: 'none';
24
+ } | {
25
+ kind: 'unavailable';
26
+ };
27
+ export interface AppRow {
28
+ path: string;
29
+ shortPath: string;
30
+ branch: string;
31
+ headSha?: string;
32
+ tags: RowTag[];
33
+ upstream?: UpstreamInfo;
34
+ upstreamUnavailable?: boolean;
35
+ workingTree?: WorkingTreeInfo;
36
+ pullRequest?: PullRequestInfo;
37
+ invalidReason?: string;
38
+ }
39
+ export interface AppStatus {
40
+ kind: 'idle' | 'starting' | 'running' | 'stopping' | 'error';
41
+ message: string;
42
+ }
43
+ export interface AppModel {
44
+ repoName: string;
45
+ namespace: string;
46
+ rows: AppRow[];
47
+ activePath: string | null;
48
+ activeBranch: string | null;
49
+ status: AppStatus;
50
+ }
51
+ export interface AppActions {
52
+ start: (worktreePath: string) => Promise<AppModel>;
53
+ stop: () => Promise<AppModel>;
54
+ refresh: () => Promise<AppModel>;
55
+ }
56
+ interface GitStatusSummary {
57
+ upstream?: UpstreamInfo;
58
+ upstreamUnavailable: boolean;
59
+ workingTree?: WorkingTreeInfo;
60
+ }
61
+ export declare function parseGitStatusSummary(output: string): GitStatusSummary;
62
+ export declare function toAppRow(mainWorktreePath: string, worktree: WorktreeRow, activePath: string | null, invalidReason: string | null, metadata: Pick<AppRow, 'upstream' | 'upstreamUnavailable' | 'workingTree' | 'pullRequest'>): AppRow;
63
+ export declare function buildInitialModel(cwd: string): Promise<AppModel>;
64
+ export declare function buildActions(cwd: string): Promise<AppActions>;
65
+ export {};