@ohzw/worktree-command-tui 0.1.4 → 0.1.6

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.
@@ -1,6 +1,55 @@
1
- import { closeSync, mkdirSync, openSync } from 'node:fs';
1
+ import { appendFileSync, closeSync, copyFileSync, mkdirSync, openSync, realpathSync } from 'node:fs';
2
2
  import { spawn } from 'node:child_process';
3
3
  import path from 'node:path';
4
+ function writeLogLine(logPath, text) {
5
+ appendFileSync(logPath, text, 'utf8');
6
+ }
7
+ function isContainedPath(root, target, allowEqual) {
8
+ const relativePath = path.relative(root, target);
9
+ return (allowEqual && relativePath === '') || (relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath));
10
+ }
11
+ function resolveSourcePath(root, inputPath) {
12
+ const realRoot = realpathSync(root);
13
+ const source = path.resolve(realRoot, inputPath);
14
+ if (!isContainedPath(realRoot, source, false)) {
15
+ throw new Error(`source must stay under ${realRoot}`);
16
+ }
17
+ const realSource = realpathSync(source);
18
+ if (!isContainedPath(realRoot, realSource, false)) {
19
+ throw new Error(`source must stay under ${realRoot}`);
20
+ }
21
+ return realSource;
22
+ }
23
+ function resolveDestinationPath(root, inputPath) {
24
+ const realRoot = realpathSync(root);
25
+ const destination = path.resolve(realRoot, inputPath);
26
+ const basename = path.basename(destination);
27
+ if (basename === '' || basename === '.' || basename === '..') {
28
+ throw new Error(`destination must stay under ${realRoot}`);
29
+ }
30
+ mkdirSync(path.dirname(destination), { recursive: true });
31
+ const realParent = realpathSync(path.dirname(destination));
32
+ if (!isContainedPath(realRoot, realParent, true)) {
33
+ throw new Error(`destination must stay under ${realRoot}`);
34
+ }
35
+ return path.join(realParent, basename);
36
+ }
37
+ function runBuiltInCommand(input) {
38
+ if (input.command[0] !== 'copy-root-file') {
39
+ return false;
40
+ }
41
+ if (input.command.length !== 3) {
42
+ throw new Error(`${input.errorLabel} requires arguments: source and destination path`);
43
+ }
44
+ if (input.workspaceRoot === undefined) {
45
+ throw new Error(`${input.errorLabel} copy-root-file requires workspaceRoot`);
46
+ }
47
+ const source = resolveSourcePath(input.workspaceRoot, input.command[1] ?? '');
48
+ const destination = resolveDestinationPath(input.cwd, input.command[2] ?? '');
49
+ copyFileSync(source, destination);
50
+ writeLogLine(input.logPath, `copied ${source} -> ${destination}\n`);
51
+ return true;
52
+ }
4
53
  function getLogEnvironment() {
5
54
  return {
6
55
  ...process.env,
@@ -8,7 +57,7 @@ function getLogEnvironment() {
8
57
  CLICOLOR_FORCE: process.env.CLICOLOR_FORCE ?? '1',
9
58
  };
10
59
  }
11
- export function runCommandToLog({ command, cwd, logsDir, logFileBase, errorLabel = 'setup command', }) {
60
+ export function runCommandToLog({ command, cwd, logsDir, logFileBase, errorLabel = 'setup command', workspaceRoot, }) {
12
61
  mkdirSync(logsDir, { recursive: true });
13
62
  const logPath = path.join(logsDir, `${logFileBase}.log`);
14
63
  const fd = openSync(logPath, 'a');
@@ -22,6 +71,20 @@ export function runCommandToLog({ command, cwd, logsDir, logFileBase, errorLabel
22
71
  closeSync(fd);
23
72
  return true;
24
73
  };
74
+ try {
75
+ if (runBuiltInCommand({ command, cwd, logPath, workspaceRoot, errorLabel })) {
76
+ if (finalize()) {
77
+ resolve({ logPath });
78
+ }
79
+ return promise;
80
+ }
81
+ }
82
+ catch (error) {
83
+ if (finalize()) {
84
+ reject(new Error(`${errorLabel}: ${error instanceof Error ? error.message : String(error)}; see ${logPath}`));
85
+ }
86
+ return promise;
87
+ }
25
88
  const child = spawn(command[0], command.slice(1), {
26
89
  cwd,
27
90
  env: getLogEnvironment(),
@@ -17,9 +17,9 @@ export declare function isSafeNamespace(value: unknown): value is string;
17
17
  export declare function toSafeNamespace(value: string): string;
18
18
  export declare function validateToolConfig(raw: unknown): ToolConfig;
19
19
  export declare function createDefaultToolConfig(options: DefaultToolConfigOptions): ToolConfig;
20
+ export declare function renderConfigJsonc(config: ToolConfig): string;
20
21
  export declare function loadToolConfig({ repoRoot }: {
21
22
  repoRoot: string;
22
23
  }): Promise<ToolConfig>;
23
- export declare function renderConfigJsonc(config: ToolConfig): string;
24
24
  export declare function findExistingConfigPath(workspaceRoot: string, fileExists: (filePath: string) => Promise<boolean>): Promise<string | null>;
25
25
  export declare function writeToolConfigForRepo({ workspaceRoot, force, config }: ConfigInitOptions, fileExists: (filePath: string) => Promise<boolean>): Promise<ConfigInitResult>;
@@ -37,6 +37,28 @@ function readOrphanMatchers(value) {
37
37
  }
38
38
  return matchers;
39
39
  }
40
+ function isNonEmptyStringArray(value) {
41
+ return Array.isArray(value) && value.length > 0 && value.every(part => isNonEmptyString(part));
42
+ }
43
+ function readOptionalSetupCommands(value) {
44
+ if (value === undefined) {
45
+ return undefined;
46
+ }
47
+ if (!Array.isArray(value) || value.length === 0) {
48
+ throw new Error('setupCommand must be a non-empty string array or an array of non-empty string arrays when set');
49
+ }
50
+ const looksLikeSingleCommand = value.every(part => isNonEmptyString(part));
51
+ if (looksLikeSingleCommand) {
52
+ return [value];
53
+ }
54
+ if (!value.every(part => Array.isArray(part))) {
55
+ throw new Error('setupCommand must be a non-empty string array or an array of non-empty string arrays when set');
56
+ }
57
+ if (!value.every(part => isNonEmptyStringArray(part))) {
58
+ throw new Error('setupCommand must be a non-empty string array or an array of non-empty string arrays when set');
59
+ }
60
+ return value;
61
+ }
40
62
  function readRequiredCommand(value, fieldName) {
41
63
  if (!Array.isArray(value) || value.length === 0 || value.some(part => !isNonEmptyString(part))) {
42
64
  throw new Error(`${fieldName} must be a non-empty string array`);
@@ -52,24 +74,51 @@ function readOptionalCommand(value, fieldName) {
52
74
  }
53
75
  return value;
54
76
  }
55
- function readPort(value) {
77
+ function readPort(value, fieldName) {
56
78
  if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535) {
57
- throw new Error('port must be an integer between 1 and 65535');
79
+ throw new Error(`${fieldName} must be an integer between 1 and 65535`);
80
+ }
81
+ return value;
82
+ }
83
+ function readPorts(value) {
84
+ if (value === undefined) {
85
+ return [];
86
+ }
87
+ if (!Array.isArray(value) || value.length === 0 || value.some(port => typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535)) {
88
+ throw new Error('ports must be a non-empty array of integers between 1 and 65535');
58
89
  }
59
90
  return value;
60
91
  }
92
+ function uniquePorts(ports) {
93
+ return [...new Set(ports)];
94
+ }
95
+ function mergeConfiguredPorts(legacyPort, configuredPorts) {
96
+ const merged = configuredPorts.length > 0 ? configuredPorts : legacyPort === undefined ? [] : [legacyPort];
97
+ const resolved = uniquePorts(merged);
98
+ if (resolved.length === 0) {
99
+ throw new Error('port or ports must be configured');
100
+ }
101
+ return resolved;
102
+ }
103
+ function readPortList(port, ports) {
104
+ const legacyPort = port === undefined ? undefined : readPort(port, 'port');
105
+ const configuredPorts = readPorts(ports);
106
+ return mergeConfiguredPorts(legacyPort, configuredPorts);
107
+ }
61
108
  export function validateToolConfig(raw) {
62
109
  const config = (raw ?? {});
63
110
  const command = readRequiredCommand(config.command, 'command');
64
111
  if (!isSafeNamespace(config.namespace)) {
65
112
  throw new Error(`namespace must match ${SAFE_NAMESPACE_DESCRIPTION}`);
66
113
  }
114
+ const ports = readPortList(config.port, config.ports);
67
115
  return {
68
116
  namespace: config.namespace,
69
117
  command,
70
- setupCommand: readOptionalCommand(config.setupCommand, 'setupCommand'),
118
+ setupCommand: readOptionalSetupCommands(config.setupCommand),
71
119
  editorCommand: readOptionalCommand(config.editorCommand, 'editorCommand'),
72
- port: readPort(config.port),
120
+ port: ports[0] ?? 0,
121
+ ports,
73
122
  requiredFiles: readStringList(config.requiredFiles, 'requiredFiles'),
74
123
  orphanMatchers: readOrphanMatchers(config.orphanMatchers),
75
124
  };
@@ -78,38 +127,20 @@ export function createDefaultToolConfig(options) {
78
127
  return validateToolConfig({
79
128
  namespace: toSafeNamespace(options.namespaceSeed),
80
129
  command: [options.packageManager, 'run', options.script],
81
- setupCommand: [options.packageManager, 'install'],
130
+ setupCommand: [[options.packageManager, 'install']],
82
131
  editorCommand: ['code'],
83
- port: 3000,
132
+ ports: [3000],
84
133
  requiredFiles: ['package.json'],
85
134
  orphanMatchers: [],
86
135
  });
87
136
  }
88
- async function readConfigFile(configPath) {
89
- if ((await stat(configPath)).size > MAX_CONFIG_BYTES) {
90
- throw new Error('config file is too large');
91
- }
92
- return readFile(configPath, 'utf8');
93
- }
94
- async function readFirstConfig(repoRoot) {
95
- let firstError;
96
- for (const fileName of CONFIG_FILE_NAMES) {
97
- try {
98
- return await readConfigFile(path.join(repoRoot, fileName));
99
- }
100
- catch (error) {
101
- firstError ??= error;
102
- }
103
- }
104
- throw firstError;
105
- }
106
- export async function loadToolConfig({ repoRoot }) {
107
- return validateToolConfig(parseJsonc(await readFirstConfig(repoRoot)));
108
- }
109
137
  export function renderConfigJsonc(config) {
110
138
  const setupCommandSection = config.setupCommand === undefined ? '' : `
111
- // Optional command run manually with the setup key in the selected worktree.
139
+ // Optional command(s) run manually with the setup key in the selected worktree.
112
140
  // Review before running in untrusted worktrees; package installs may run lifecycle scripts.
141
+ // When this is an array of arrays, each entry is executed in order.
142
+ // Built-in helper: ["copy-root-file", ".env", ".env"]
143
+ // copies .env from the repository root to the selected worktree.
113
144
  "setupCommand": ${JSON.stringify(config.setupCommand)},
114
145
  `;
115
146
  const editorCommandSection = config.editorCommand === undefined ? '' : `
@@ -126,8 +157,9 @@ export function renderConfigJsonc(config) {
126
157
  // Treat this config as trusted code. argv form avoids shell metacharacter expansion.
127
158
  "command": ${JSON.stringify(config.command)},
128
159
  ${setupCommandSection}${editorCommandSection}
129
- // TCP port owned by the command, used when stopping stale/orphaned processes.
130
- "port": ${JSON.stringify(config.port)},
160
+ // TCP ports owned by the command, used when stopping stale/orphaned processes.
161
+ // Include all ports your command may bind.
162
+ "ports": ${JSON.stringify(config.ports)},
131
163
 
132
164
  // Files that must exist in a worktree before the command can be started there.
133
165
  "requiredFiles": ${JSON.stringify(config.requiredFiles)},
@@ -139,6 +171,27 @@ ${setupCommandSection}${editorCommandSection}
139
171
  }
140
172
  `;
141
173
  }
174
+ async function readConfigFile(configPath) {
175
+ if ((await stat(configPath)).size > MAX_CONFIG_BYTES) {
176
+ throw new Error('config file is too large');
177
+ }
178
+ return readFile(configPath, 'utf8');
179
+ }
180
+ async function readFirstConfig(repoRoot) {
181
+ let firstError;
182
+ for (const fileName of CONFIG_FILE_NAMES) {
183
+ try {
184
+ return await readConfigFile(path.join(repoRoot, fileName));
185
+ }
186
+ catch (error) {
187
+ firstError ??= error;
188
+ }
189
+ }
190
+ throw firstError;
191
+ }
192
+ export async function loadToolConfig({ repoRoot }) {
193
+ return validateToolConfig(parseJsonc(await readFirstConfig(repoRoot)));
194
+ }
142
195
  export async function findExistingConfigPath(workspaceRoot, fileExists) {
143
196
  for (const fileName of CONFIG_FILE_NAMES) {
144
197
  const configPath = path.join(workspaceRoot, fileName);
@@ -4,9 +4,10 @@ export declare const CONFIG_FILE_NAMES: readonly [".worktree-command-tui.jsonc",
4
4
  export interface ToolConfig {
5
5
  namespace: string;
6
6
  command: string[];
7
- setupCommand?: string[];
7
+ setupCommand?: string[][];
8
8
  editorCommand?: string[];
9
9
  port: number;
10
+ ports: number[];
10
11
  requiredFiles: string[];
11
12
  orphanMatchers: string[];
12
13
  }
@@ -0,0 +1,7 @@
1
+ type ResizeListener = (...args: unknown[]) => void;
2
+ interface ResizeEventSource {
3
+ on(event: string | symbol, listener: ResizeListener): ResizeEventSource;
4
+ off(event: string | symbol, listener: ResizeListener): ResizeEventSource;
5
+ }
6
+ export declare function debounceResizeListeners(source: ResizeEventSource, debounceMs: number): () => void;
7
+ export {};
@@ -0,0 +1,39 @@
1
+ export function debounceResizeListeners(source, debounceMs) {
2
+ const originalOn = source.on.bind(source);
3
+ const originalOff = source.off.bind(source);
4
+ const resizeListeners = new Set();
5
+ let resizeTimer;
6
+ let latestArgs = [];
7
+ const emitDebouncedResize = (...args) => {
8
+ latestArgs = args;
9
+ clearTimeout(resizeTimer);
10
+ resizeTimer = setTimeout(() => {
11
+ resizeTimer = undefined;
12
+ const listeners = [...resizeListeners];
13
+ for (const listener of listeners) {
14
+ listener(...latestArgs);
15
+ }
16
+ }, debounceMs);
17
+ };
18
+ originalOn('resize', emitDebouncedResize);
19
+ source.on = ((event, listener) => {
20
+ if (event === 'resize') {
21
+ resizeListeners.add(listener);
22
+ return source;
23
+ }
24
+ return originalOn(event, listener);
25
+ });
26
+ source.off = ((event, listener) => {
27
+ if (event === 'resize') {
28
+ resizeListeners.delete(listener);
29
+ return source;
30
+ }
31
+ return originalOff(event, listener);
32
+ });
33
+ return () => {
34
+ clearTimeout(resizeTimer);
35
+ originalOff('resize', emitDebouncedResize);
36
+ source.on = originalOn;
37
+ source.off = originalOff;
38
+ };
39
+ }
@@ -14,6 +14,9 @@ export interface GitStatusSummary {
14
14
  upstreamUnavailable: boolean;
15
15
  workingTree?: WorkingTreeInfo;
16
16
  }
17
+ export interface HeadCommitInfo {
18
+ message: string;
19
+ }
17
20
  export interface RepoContext {
18
21
  workspaceRoot: string;
19
22
  mainWorktreePath: string;
@@ -22,4 +25,5 @@ export interface RepoContext {
22
25
  export declare function parseGitStatusSummary(output: string): GitStatusSummary;
23
26
  export declare function readGitStatusSummary(cwd: string): Promise<GitStatusSummary>;
24
27
  export declare function readBranchCreatedAtMs(cwd: string, branch: string): Promise<number | null>;
28
+ export declare function readHeadCommitInfo(cwd: string): Promise<HeadCommitInfo | null>;
25
29
  export declare function resolveRepoContext(cwd: string): Promise<RepoContext>;
@@ -72,6 +72,15 @@ export async function readBranchCreatedAtMs(cwd, branch) {
72
72
  return null;
73
73
  }
74
74
  }
75
+ export async function readHeadCommitInfo(cwd) {
76
+ try {
77
+ const { stdout } = await execFileAsync('git', ['log', '-1', '--format=%s'], { cwd });
78
+ return { message: stdout.trim() };
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
75
84
  export async function resolveRepoContext(cwd) {
76
85
  const [{ stdout: workspaceRootRaw }, { stdout: gitCommonDirRaw }] = await Promise.all([
77
86
  execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd }),
@@ -6,6 +6,6 @@ export interface CleanupDeps {
6
6
  }
7
7
  export declare function stopSessionWithFallback(input: {
8
8
  pgid: number;
9
- port: number;
9
+ ports: number[];
10
10
  orphanMatchers: string[];
11
11
  }, deps: CleanupDeps): Promise<boolean>;
@@ -6,7 +6,9 @@ export async function stopSessionWithFallback(input, deps) {
6
6
  if (!(await deps.isSessionAlive(input.pgid))) {
7
7
  return true;
8
8
  }
9
- await deps.killPortOwner(input.port, input.pgid);
9
+ for (const port of input.ports) {
10
+ await deps.killPortOwner(port, input.pgid);
11
+ }
10
12
  for (const matcher of input.orphanMatchers) {
11
13
  await deps.killOrphans(matcher, input.pgid);
12
14
  }
@@ -17,6 +17,7 @@ export interface RuntimeStateAdapter {
17
17
  cwd: string;
18
18
  logsDir: string;
19
19
  logFileBase: string;
20
+ workspaceRoot: string | undefined;
20
21
  }) => Promise<{
21
22
  logPath: string;
22
23
  }>;
@@ -37,6 +38,7 @@ export interface RuntimeStateAdapter {
37
38
  export interface RuntimeStateOptions {
38
39
  config: ToolConfig;
39
40
  paths: SessionPaths;
41
+ workspaceRoot?: string;
40
42
  adapter: RuntimeStateAdapter;
41
43
  }
42
- export declare function createRuntimeStateActions({ config, paths, adapter }: RuntimeStateOptions): AppActions;
44
+ export declare function createRuntimeStateActions({ config, paths, workspaceRoot, adapter }: RuntimeStateOptions): AppActions;
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  function toLogFileBase(branch) {
3
3
  return branch.replace(/[\\/]/g, '-');
4
4
  }
5
- export function createRuntimeStateActions({ config, paths, adapter }) {
5
+ export function createRuntimeStateActions({ config, paths, workspaceRoot, adapter }) {
6
6
  const refreshLogs = async () => {
7
7
  const active = await adapter.readActive();
8
8
  return {
@@ -40,8 +40,8 @@ export function createRuntimeStateActions({ config, paths, adapter }) {
40
40
  };
41
41
  };
42
42
  const setup = async (worktreePath) => {
43
- const setupCommand = config.setupCommand;
44
- if (setupCommand === undefined) {
43
+ const setupCommands = config.setupCommand;
44
+ if (setupCommands === undefined) {
45
45
  const model = await adapter.refresh();
46
46
  return {
47
47
  ...model,
@@ -51,21 +51,34 @@ export function createRuntimeStateActions({ config, paths, adapter }) {
51
51
  const branch = await adapter.readWorktreeBranch(worktreePath);
52
52
  const logFileBase = `${toLogFileBase(branch)}.setup`;
53
53
  const setupLogPath = path.join(paths.logsDir, `${logFileBase}.log`);
54
- try {
55
- await adapter.runSetup({
56
- command: setupCommand,
57
- cwd: worktreePath,
58
- logsDir: paths.logsDir,
59
- logFileBase,
60
- });
61
- }
62
- catch (error) {
63
- const model = await adapter.refresh();
64
- return {
65
- ...model,
66
- status: { kind: 'error', message: error instanceof Error ? error.message : String(error) },
67
- logs: await adapter.readLogs(setupLogPath),
68
- };
54
+ for (let index = 0; index < setupCommands.length; index += 1) {
55
+ const setupCommand = setupCommands[index];
56
+ if (setupCommand === undefined) {
57
+ const model = await adapter.refresh();
58
+ return {
59
+ ...model,
60
+ status: { kind: 'error', message: `setup command ${index + 1} is not configured` },
61
+ logs: await adapter.readLogs(setupLogPath),
62
+ };
63
+ }
64
+ try {
65
+ await adapter.runSetup({
66
+ command: setupCommand,
67
+ cwd: worktreePath,
68
+ logsDir: paths.logsDir,
69
+ logFileBase,
70
+ workspaceRoot,
71
+ });
72
+ }
73
+ catch (error) {
74
+ const model = await adapter.refresh();
75
+ const commandLabel = setupCommands.length > 1 ? `setup command ${index + 1}/${setupCommands.length} ` : '';
76
+ return {
77
+ ...model,
78
+ status: { kind: 'error', message: `${commandLabel}${error instanceof Error ? error.message : String(error)}`.trim() },
79
+ logs: await adapter.readLogs(setupLogPath),
80
+ };
81
+ }
69
82
  }
70
83
  const model = await adapter.refresh();
71
84
  return {
@@ -99,6 +112,7 @@ export function createRuntimeStateActions({ config, paths, adapter }) {
99
112
  pid: started.pid,
100
113
  pgid: started.pgid,
101
114
  port: config.port,
115
+ ports: config.ports,
102
116
  logPath: started.logPath,
103
117
  startedAt: adapter.nowIso(),
104
118
  });
@@ -1,5 +1,5 @@
1
1
  import { type WorktreeRow } from './git-worktrees.js';
2
- import { type UpstreamInfo, type WorkingTreeInfo } from './git-metadata.js';
2
+ import { type UpstreamInfo, type WorkingTreeInfo, type HeadCommitInfo } from './git-metadata.js';
3
3
  import { type PullRequestInfo } from './github-metadata.js';
4
4
  import { type LogEntry } from './log-reader.js';
5
5
  export type RowTag = 'main' | 'active' | 'invalid' | 'external' | 'legacy';
@@ -8,6 +8,7 @@ export interface AppRow {
8
8
  shortPath: string;
9
9
  branch: string;
10
10
  headSha?: string;
11
+ headCommit?: HeadCommitInfo;
11
12
  tags: RowTag[];
12
13
  upstream?: UpstreamInfo;
13
14
  upstreamUnavailable?: boolean;
@@ -47,6 +48,6 @@ export interface AppActions {
47
48
  openPullRequest: (worktreePath: string) => Promise<AppModel>;
48
49
  deleteWorktree: (worktreePath: string) => Promise<AppModel>;
49
50
  }
50
- export declare function toAppRow(mainWorktreePath: string, worktree: WorktreeRow, activePath: string | null, invalidReason: string | null, metadata: Pick<AppRow, 'upstream' | 'upstreamUnavailable' | 'workingTree' | 'pullRequest' | 'branchCreatedAtMs'>): AppRow;
51
+ export declare function toAppRow(mainWorktreePath: string, worktree: WorktreeRow, activePath: string | null, invalidReason: string | null, metadata: Pick<AppRow, 'upstream' | 'upstreamUnavailable' | 'workingTree' | 'pullRequest' | 'branchCreatedAtMs' | 'headCommit'>): AppRow;
51
52
  export declare function buildInitialModel(cwd: string): Promise<AppModel>;
52
53
  export declare function buildActions(cwd: string): Promise<AppActions>;
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { promisify } from 'node:util';
4
4
  import { loadToolConfig } from './config-lifecycle.js';
5
5
  import { readWorktrees, sortWorktrees, toShortPath } from './git-worktrees.js';
6
- import { readGitStatusSummary, readBranchCreatedAtMs, resolveRepoContext } from './git-metadata.js';
6
+ import { readGitStatusSummary, readBranchCreatedAtMs, readHeadCommitInfo, resolveRepoContext } from './git-metadata.js';
7
7
  import { readPullRequestInfo } from './github-metadata.js';
8
8
  import { readLogs } from './log-reader.js';
9
9
  import { getInvalidReason } from './validation.js';
@@ -18,10 +18,11 @@ function shortenSha(headSha) {
18
18
  return headSha.slice(0, SHORT_SHA_LENGTH);
19
19
  }
20
20
  async function readRowMetadata(worktreePath, branch) {
21
- const [statusSummary, pullRequest, branchCreatedAtMs] = await Promise.all([
21
+ const [statusSummary, pullRequest, branchCreatedAtMs, headCommit] = await Promise.all([
22
22
  readGitStatusSummary(worktreePath),
23
23
  readPullRequestInfo(worktreePath, branch),
24
24
  readBranchCreatedAtMs(worktreePath, branch),
25
+ readHeadCommitInfo(worktreePath),
25
26
  ]);
26
27
  return {
27
28
  upstream: statusSummary.upstream,
@@ -29,6 +30,7 @@ async function readRowMetadata(worktreePath, branch) {
29
30
  workingTree: statusSummary.workingTree,
30
31
  pullRequest,
31
32
  branchCreatedAtMs: branchCreatedAtMs ?? undefined,
33
+ headCommit: headCommit ?? undefined,
32
34
  };
33
35
  }
34
36
  export function toAppRow(mainWorktreePath, worktree, activePath, invalidReason, metadata) {
@@ -56,6 +58,7 @@ export function toAppRow(mainWorktreePath, worktree, activePath, invalidReason,
56
58
  workingTree: metadata.workingTree,
57
59
  pullRequest: metadata.pullRequest,
58
60
  branchCreatedAtMs: metadata.branchCreatedAtMs,
61
+ headCommit: metadata.headCommit,
59
62
  invalidReason: invalidReason ?? undefined,
60
63
  };
61
64
  }
@@ -70,8 +73,8 @@ async function buildRows(mainWorktreePath, workspaceRoot, activePath, requiredFi
70
73
  }));
71
74
  return rows;
72
75
  }
73
- async function stopRecordedSession(pgid, port, orphanMatchers) {
74
- const stopped = await stopSessionWithFallback({ pgid, port, orphanMatchers }, {
76
+ async function stopRecordedSession(pgid, ports, orphanMatchers) {
77
+ const stopped = await stopSessionWithFallback({ pgid, ports, orphanMatchers }, {
75
78
  killProcessGroup,
76
79
  killPortOwner,
77
80
  killOrphans,
@@ -81,6 +84,9 @@ async function stopRecordedSession(pgid, port, orphanMatchers) {
81
84
  throw new Error(`Failed to stop existing session pgid=${pgid}`);
82
85
  }
83
86
  }
87
+ function sessionCleanupPorts(active, configuredPorts) {
88
+ return [...new Set([...(active.ports ?? [active.port]), ...configuredPorts])];
89
+ }
84
90
  async function launchDetachedCommand(command, cwd) {
85
91
  const { promise, resolve, reject } = Promise.withResolvers();
86
92
  let settled = false;
@@ -141,6 +147,7 @@ export async function buildActions(cwd) {
141
147
  return createRuntimeStateActions({
142
148
  config,
143
149
  paths,
150
+ workspaceRoot,
144
151
  adapter: {
145
152
  refresh: async () => buildInitialModel(cwd),
146
153
  readActive: async () => readSessionRecord(paths, { isSessionAlive: isProcessGroupAlive }),
@@ -153,9 +160,9 @@ export async function buildActions(cwd) {
153
160
  return selected.branch;
154
161
  },
155
162
  getInvalidReason: async (worktreePath) => getInvalidReason(worktreePath, config.requiredFiles),
156
- runSetup: runCommandToLog,
163
+ runSetup: async (input) => runCommandToLog({ ...input, workspaceRoot }),
157
164
  startCommand: startDetachedCommand,
158
- stopSession: async (active) => stopRecordedSession(active.pgid, active.port, config.orphanMatchers),
165
+ stopSession: async (active) => stopRecordedSession(active.pgid, sessionCleanupPorts(active, config.ports), config.orphanMatchers),
159
166
  clearSession: async () => clearSessionRecord(paths),
160
167
  writeSession: async (record) => writeSessionRecord(paths, record),
161
168
  openEditor: async (worktreePath) => {
@@ -179,7 +186,7 @@ export async function buildActions(cwd) {
179
186
  return { kind: 'idle', message: `no pull request found for ${selected.branch}` };
180
187
  }
181
188
  if (pullRequest.kind === 'unavailable') {
182
- return { kind: 'error', message: `pull request metadata is unavailable for ${selected.branch}` };
189
+ return { kind: 'idle', message: `pull request metadata unavailable for ${selected.branch}` };
183
190
  }
184
191
  await launchDetachedCommand(getBrowserOpenCommand(pullRequest.url), worktreePath);
185
192
  return { kind: 'idle', message: `opened pull request #${pullRequest.number} for ${selected.branch}` };
@@ -194,7 +201,7 @@ export async function buildActions(cwd) {
194
201
  }
195
202
  const active = await readSessionRecord(paths, { isSessionAlive: isProcessGroupAlive });
196
203
  if (active?.worktreePath === worktreePath) {
197
- await stopRecordedSession(active.pgid, active.port, config.orphanMatchers);
204
+ await stopRecordedSession(active.pgid, sessionCleanupPorts(active, config.ports), config.orphanMatchers);
198
205
  await clearSessionRecord(paths);
199
206
  }
200
207
  await execFileAsync('git', ['worktree', 'remove', worktreePath], { cwd: workspaceRoot });
@@ -5,6 +5,7 @@ export interface SessionRecord {
5
5
  pid: number;
6
6
  pgid: number;
7
7
  port: number;
8
+ ports?: number[];
8
9
  logPath: string;
9
10
  startedAt: string;
10
11
  }