@ohzw/worktree-command-tui 0.1.1 → 0.1.3

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.
Files changed (46) hide show
  1. package/README.md +91 -26
  2. package/dist/app.d.ts +1 -1
  3. package/dist/app.js +242 -114
  4. package/dist/components/ActionPanel.d.ts +4 -2
  5. package/dist/components/ActionPanel.js +87 -135
  6. package/dist/components/ContextBar.d.ts +4 -1
  7. package/dist/components/ContextBar.js +29 -3
  8. package/dist/components/Header.js +5 -1
  9. package/dist/components/HelpWindow.d.ts +7 -0
  10. package/dist/components/HelpWindow.js +29 -0
  11. package/dist/components/LogPanel.d.ts +10 -3
  12. package/dist/components/LogPanel.js +240 -33
  13. package/dist/components/WorktreeList.js +20 -40
  14. package/dist/core/command-runner.d.ts +11 -0
  15. package/dist/core/command-runner.js +59 -7
  16. package/dist/core/config-lifecycle.d.ts +25 -0
  17. package/dist/core/config-lifecycle.js +160 -0
  18. package/dist/core/config.d.ts +2 -3
  19. package/dist/core/config.js +0 -48
  20. package/dist/core/git-metadata.d.ts +25 -0
  21. package/dist/core/git-metadata.js +84 -0
  22. package/dist/core/git-worktrees.d.ts +2 -1
  23. package/dist/core/git-worktrees.js +30 -11
  24. package/dist/core/github-metadata.d.ts +21 -0
  25. package/dist/core/github-metadata.js +153 -0
  26. package/dist/core/init.d.ts +3 -2
  27. package/dist/core/init.js +9 -57
  28. package/dist/core/log-reader.d.ts +7 -0
  29. package/dist/core/log-reader.js +59 -0
  30. package/dist/core/posix-process.d.ts +2 -2
  31. package/dist/core/posix-process.js +19 -4
  32. package/dist/core/process-control.d.ts +2 -2
  33. package/dist/core/process-control.js +5 -2
  34. package/dist/core/runtime-state.d.ts +42 -0
  35. package/dist/core/runtime-state.js +125 -0
  36. package/dist/core/runtime.d.ts +19 -39
  37. package/dist/core/runtime.js +112 -216
  38. package/dist/core/session-store.js +22 -7
  39. package/dist/core/tui-interaction.d.ts +31 -0
  40. package/dist/core/tui-interaction.js +59 -0
  41. package/dist/core/worktree-projection.d.ts +76 -0
  42. package/dist/core/worktree-projection.js +132 -0
  43. package/dist/main.js +6 -5
  44. package/dist/terminal/viewport.d.ts +15 -0
  45. package/dist/terminal/viewport.js +49 -0
  46. package/package.json +1 -1
@@ -0,0 +1,160 @@
1
+ import { readFile, stat, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { CONFIG_FILE_NAME, CONFIG_FILE_NAMES, parseJsonc } from './config.js';
4
+ const SAFE_NAMESPACE_PATTERN = /^[A-Za-z0-9._-]+$/u;
5
+ const UNSAFE_NAMESPACE_RUN_PATTERN = /[^A-Za-z0-9._-]+/gu;
6
+ const LEADING_NAMESPACE_HYPHENS_PATTERN = /^-+/u;
7
+ const TRAILING_NAMESPACE_HYPHENS_PATTERN = /-+$/u;
8
+ const SAFE_NAMESPACE_DESCRIPTION = '[A-Za-z0-9._-]+';
9
+ const DEFAULT_NAMESPACE = 'worktree-command-tui';
10
+ const MAX_CONFIG_BYTES = 64 * 1024;
11
+ function isNonEmptyString(value) {
12
+ return typeof value === 'string' && value.length > 0;
13
+ }
14
+ export function isSafeNamespace(value) {
15
+ return isNonEmptyString(value) && SAFE_NAMESPACE_PATTERN.test(value);
16
+ }
17
+ export function toSafeNamespace(value) {
18
+ const replaced = value.replace(UNSAFE_NAMESPACE_RUN_PATTERN, '-');
19
+ const trimmed = replaced.replace(LEADING_NAMESPACE_HYPHENS_PATTERN, '').replace(TRAILING_NAMESPACE_HYPHENS_PATTERN, '');
20
+ return isSafeNamespace(trimmed) ? trimmed : DEFAULT_NAMESPACE;
21
+ }
22
+ function readStringList(value, fieldName) {
23
+ if (value === undefined) {
24
+ return [];
25
+ }
26
+ if (!Array.isArray(value) || value.some(item => !isNonEmptyString(item))) {
27
+ throw new Error(`${fieldName} must be a string array`);
28
+ }
29
+ return value;
30
+ }
31
+ function readOrphanMatchers(value) {
32
+ const matchers = readStringList(value, 'orphanMatchers');
33
+ for (const matcher of matchers) {
34
+ if (!/\S+\s+\S+/u.test(matcher)) {
35
+ throw new Error('orphanMatchers entries must include a command plus argument fragment');
36
+ }
37
+ }
38
+ return matchers;
39
+ }
40
+ function readRequiredCommand(value, fieldName) {
41
+ if (!Array.isArray(value) || value.length === 0 || value.some(part => !isNonEmptyString(part))) {
42
+ throw new Error(`${fieldName} must be a non-empty string array`);
43
+ }
44
+ return value;
45
+ }
46
+ function readOptionalCommand(value, fieldName) {
47
+ if (value === undefined) {
48
+ return undefined;
49
+ }
50
+ if (!Array.isArray(value) || value.length === 0 || value.some(part => !isNonEmptyString(part))) {
51
+ throw new Error(`${fieldName} must be a non-empty string array when set`);
52
+ }
53
+ return value;
54
+ }
55
+ function readPort(value) {
56
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535) {
57
+ throw new Error('port must be an integer between 1 and 65535');
58
+ }
59
+ return value;
60
+ }
61
+ export function validateToolConfig(raw) {
62
+ const config = (raw ?? {});
63
+ const command = readRequiredCommand(config.command, 'command');
64
+ if (!isSafeNamespace(config.namespace)) {
65
+ throw new Error(`namespace must match ${SAFE_NAMESPACE_DESCRIPTION}`);
66
+ }
67
+ return {
68
+ namespace: config.namespace,
69
+ command,
70
+ setupCommand: readOptionalCommand(config.setupCommand, 'setupCommand'),
71
+ editorCommand: readOptionalCommand(config.editorCommand, 'editorCommand'),
72
+ port: readPort(config.port),
73
+ requiredFiles: readStringList(config.requiredFiles, 'requiredFiles'),
74
+ orphanMatchers: readOrphanMatchers(config.orphanMatchers),
75
+ };
76
+ }
77
+ export function createDefaultToolConfig(options) {
78
+ return validateToolConfig({
79
+ namespace: toSafeNamespace(options.namespaceSeed),
80
+ command: [options.packageManager, 'run', options.script],
81
+ setupCommand: [options.packageManager, 'install'],
82
+ editorCommand: ['code'],
83
+ port: 3000,
84
+ requiredFiles: ['package.json'],
85
+ orphanMatchers: [],
86
+ });
87
+ }
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
+ export function renderConfigJsonc(config) {
110
+ const setupCommandSection = config.setupCommand === undefined ? '' : `
111
+ // Optional command run manually with the setup key in the selected worktree.
112
+ // Review before running in untrusted worktrees; package installs may run lifecycle scripts.
113
+ "setupCommand": ${JSON.stringify(config.setupCommand)},
114
+ `;
115
+ const editorCommandSection = config.editorCommand === undefined ? '' : `
116
+ // Optional command that opens the selected worktree path in an editor.
117
+ // The selected worktree path is appended as the final argv entry.
118
+ "editorCommand": ${JSON.stringify(config.editorCommand)},
119
+ `;
120
+ return `{
121
+ // Session namespace used for git-common-dir state files and logs.
122
+ // Keep this filesystem-safe: letters, numbers, dots, underscores, and hyphens only.
123
+ "namespace": ${JSON.stringify(config.namespace)},
124
+
125
+ // Command launched in the selected worktree when you press Enter.
126
+ // Treat this config as trusted code. argv form avoids shell metacharacter expansion.
127
+ "command": ${JSON.stringify(config.command)},
128
+ ${setupCommandSection}${editorCommandSection}
129
+ // TCP port owned by the command, used when stopping stale/orphaned processes.
130
+ "port": ${JSON.stringify(config.port)},
131
+
132
+ // Files that must exist in a worktree before the command can be started there.
133
+ "requiredFiles": ${JSON.stringify(config.requiredFiles)},
134
+
135
+ // Extra command-line substrings for cleanup within the recorded process group only.
136
+ // Include a command plus argument fragment; broad single-token matchers are rejected.
137
+ // Example: ["node --watch", "vite --host 0.0.0.0"]
138
+ "orphanMatchers": ${JSON.stringify(config.orphanMatchers)},
139
+ }
140
+ `;
141
+ }
142
+ export async function findExistingConfigPath(workspaceRoot, fileExists) {
143
+ for (const fileName of CONFIG_FILE_NAMES) {
144
+ const configPath = path.join(workspaceRoot, fileName);
145
+ if (await fileExists(configPath)) {
146
+ return configPath;
147
+ }
148
+ }
149
+ return null;
150
+ }
151
+ export async function writeToolConfigForRepo({ workspaceRoot, force, config }, fileExists) {
152
+ const configPath = path.join(workspaceRoot, CONFIG_FILE_NAME);
153
+ const existingConfigPath = await findExistingConfigPath(workspaceRoot, fileExists);
154
+ if (!force && existingConfigPath) {
155
+ throw new Error(`Config file already exists: ${existingConfigPath}`);
156
+ }
157
+ const validatedConfig = validateToolConfig(config);
158
+ await writeFile(configPath, renderConfigJsonc(validatedConfig), 'utf8');
159
+ return { path: configPath, config: validatedConfig };
160
+ }
@@ -4,11 +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[];
8
+ editorCommand?: string[];
7
9
  port: number;
8
10
  requiredFiles: string[];
9
11
  orphanMatchers: string[];
10
12
  }
11
13
  export declare function parseJsonc(source: string): unknown;
12
- export declare function loadToolConfig({ repoRoot }: {
13
- repoRoot: string;
14
- }): Promise<ToolConfig>;
@@ -1,23 +1,6 @@
1
- import { readFile } from 'node:fs/promises';
2
- import path from 'node:path';
3
1
  export const CONFIG_FILE_NAME = '.worktree-command-tui.jsonc';
4
2
  export const LEGACY_CONFIG_FILE_NAME = '.worktree-command-tui.json';
5
3
  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
4
  function stripJsoncComments(source) {
22
5
  let result = '';
23
6
  let inString = false;
@@ -119,34 +102,3 @@ function stripTrailingCommas(source) {
119
102
  export function parseJsonc(source) {
120
103
  return JSON.parse(stripTrailingCommas(stripJsoncComments(source)));
121
104
  }
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,25 @@
1
+ export interface UpstreamInfo {
2
+ branch: string;
3
+ ahead: number;
4
+ behind: number;
5
+ }
6
+ export interface WorkingTreeInfo {
7
+ staged: number;
8
+ unstaged: number;
9
+ untracked: number;
10
+ conflicts: number;
11
+ }
12
+ export interface GitStatusSummary {
13
+ upstream?: UpstreamInfo;
14
+ upstreamUnavailable: boolean;
15
+ workingTree?: WorkingTreeInfo;
16
+ }
17
+ export interface RepoContext {
18
+ workspaceRoot: string;
19
+ mainWorktreePath: string;
20
+ gitCommonDir: string;
21
+ }
22
+ export declare function parseGitStatusSummary(output: string): GitStatusSummary;
23
+ export declare function readGitStatusSummary(cwd: string): Promise<GitStatusSummary>;
24
+ export declare function readBranchCreatedAtMs(cwd: string, branch: string): Promise<number | null>;
25
+ export declare function resolveRepoContext(cwd: string): Promise<RepoContext>;
@@ -0,0 +1,84 @@
1
+ import path from 'node:path';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ const execFileAsync = promisify(execFile);
5
+ function createEmptyWorkingTree() {
6
+ return { staged: 0, unstaged: 0, untracked: 0, conflicts: 0 };
7
+ }
8
+ export function parseGitStatusSummary(output) {
9
+ const workingTree = createEmptyWorkingTree();
10
+ let upstreamBranch;
11
+ let ahead = 0;
12
+ let behind = 0;
13
+ for (const line of output.split('\n')) {
14
+ if (line.startsWith('# branch.upstream ')) {
15
+ upstreamBranch = line.slice('# branch.upstream '.length).trim();
16
+ continue;
17
+ }
18
+ if (line.startsWith('# branch.ab ')) {
19
+ const match = /# branch\.ab \+(\d+) -(\d+)/.exec(line);
20
+ ahead = Number(match?.[1] ?? 0);
21
+ behind = Number(match?.[2] ?? 0);
22
+ continue;
23
+ }
24
+ if (line.startsWith('1 ') || line.startsWith('2 ')) {
25
+ const [, xy = '..'] = line.split(' ', 3);
26
+ if (xy[0] !== '.') {
27
+ workingTree.staged += 1;
28
+ }
29
+ if (xy[1] !== '.') {
30
+ workingTree.unstaged += 1;
31
+ }
32
+ continue;
33
+ }
34
+ if (line.startsWith('u ')) {
35
+ workingTree.conflicts += 1;
36
+ continue;
37
+ }
38
+ if (line.startsWith('? ')) {
39
+ workingTree.untracked += 1;
40
+ }
41
+ }
42
+ return {
43
+ upstream: upstreamBranch ? { branch: upstreamBranch, ahead, behind } : undefined,
44
+ upstreamUnavailable: false,
45
+ workingTree,
46
+ };
47
+ }
48
+ export async function readGitStatusSummary(cwd) {
49
+ try {
50
+ const { stdout } = await execFileAsync('git', ['status', '--branch', '--porcelain=v2'], { cwd });
51
+ return parseGitStatusSummary(stdout);
52
+ }
53
+ catch {
54
+ return { upstreamUnavailable: true };
55
+ }
56
+ }
57
+ export async function readBranchCreatedAtMs(cwd, branch) {
58
+ if (branch.startsWith('(')) {
59
+ return null;
60
+ }
61
+ try {
62
+ const { stdout } = await execFileAsync('git', ['reflog', 'show', '--format=%ct', `refs/heads/${branch}`], { cwd });
63
+ const trimmed = stdout.trim();
64
+ if (trimmed.length === 0) {
65
+ return null;
66
+ }
67
+ const timestamps = trimmed.split('\n');
68
+ const firstTimestampSeconds = Number(timestamps.at(-1));
69
+ return Number.isFinite(firstTimestampSeconds) ? firstTimestampSeconds * 1000 : null;
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ export async function resolveRepoContext(cwd) {
76
+ const [{ stdout: workspaceRootRaw }, { stdout: gitCommonDirRaw }] = await Promise.all([
77
+ execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd }),
78
+ execFileAsync('git', ['rev-parse', '--git-common-dir'], { cwd }),
79
+ ]);
80
+ const workspaceRoot = workspaceRootRaw.trim();
81
+ const gitCommonDir = path.resolve(workspaceRoot, gitCommonDirRaw.trim());
82
+ const mainWorktreePath = path.dirname(gitCommonDir);
83
+ return { workspaceRoot, mainWorktreePath, gitCommonDir };
84
+ }
@@ -4,8 +4,9 @@ export interface WorktreeRow {
4
4
  headSha: string;
5
5
  isMain: boolean;
6
6
  isExternal: boolean;
7
+ createdAtMs: number | null;
7
8
  }
8
9
  export declare function parseWorktreeListPorcelain(input: string, mainWorktreePath: string): WorktreeRow[];
9
- export declare function sortWorktrees(rows: WorktreeRow[], activePath: string | null): WorktreeRow[];
10
+ export declare function sortWorktrees(rows: WorktreeRow[], _activePath: string | null): WorktreeRow[];
10
11
  export declare function readWorktrees(cwd: string, mainWorktreePath: string): Promise<WorktreeRow[]>;
11
12
  export declare function toShortPath(mainWorktreePath: string, worktreePath: string): string;
@@ -1,4 +1,5 @@
1
1
  import { execFile } from 'node:child_process';
2
+ import { stat } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import { promisify } from 'node:util';
4
5
  const execFileAsync = promisify(execFile);
@@ -28,26 +29,44 @@ export function parseWorktreeListPorcelain(input, mainWorktreePath) {
28
29
  headSha: headLine?.slice('HEAD '.length) ?? '',
29
30
  isMain: worktreePath === mainWorktreePath,
30
31
  isExternal: isExternalWorktree(mainWorktreePath, worktreePath),
32
+ createdAtMs: null,
31
33
  };
32
34
  });
33
35
  }
34
- export function sortWorktrees(rows, activePath) {
36
+ function compareDeterministic(left, right) {
37
+ if (left.isMain !== right.isMain) {
38
+ return left.isMain ? -1 : 1;
39
+ }
40
+ const branchCompare = left.branch.localeCompare(right.branch);
41
+ return branchCompare !== 0 ? branchCompare : left.path.localeCompare(right.path);
42
+ }
43
+ export function sortWorktrees(rows, _activePath) {
44
+ const hasMissingCreatedAt = rows.some(row => row.createdAtMs === null);
35
45
  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;
46
+ const leftCreated = left.createdAtMs;
47
+ const rightCreated = right.createdAtMs;
48
+ if (!hasMissingCreatedAt && leftCreated !== null && rightCreated !== null && leftCreated !== rightCreated) {
49
+ return leftCreated - rightCreated;
43
50
  }
44
- const branchCompare = left.branch.localeCompare(right.branch);
45
- return branchCompare !== 0 ? branchCompare : left.path.localeCompare(right.path);
51
+ return compareDeterministic(left, right);
46
52
  });
47
53
  }
54
+ async function readWorktreeCreatedAtMs(worktreePath) {
55
+ try {
56
+ const stats = await stat(worktreePath);
57
+ return stats.birthtimeMs;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
48
63
  export async function readWorktrees(cwd, mainWorktreePath) {
49
64
  const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], { cwd });
50
- return parseWorktreeListPorcelain(stdout, mainWorktreePath);
65
+ const rows = parseWorktreeListPorcelain(stdout, mainWorktreePath);
66
+ return Promise.all(rows.map(async (row) => ({
67
+ ...row,
68
+ createdAtMs: await readWorktreeCreatedAtMs(row.path),
69
+ })));
51
70
  }
52
71
  export function toShortPath(mainWorktreePath, worktreePath) {
53
72
  if (worktreePath === mainWorktreePath) {
@@ -0,0 +1,21 @@
1
+ export interface GitHubRepository {
2
+ host: string;
3
+ owner: string;
4
+ name: string;
5
+ }
6
+ export type PullRequestInfo = {
7
+ kind: 'found';
8
+ number: number;
9
+ title: string;
10
+ url: string;
11
+ state: 'OPEN' | 'CLOSED' | 'MERGED';
12
+ isDraft: boolean;
13
+ baseBranch: string;
14
+ } | {
15
+ kind: 'none';
16
+ } | {
17
+ kind: 'unavailable';
18
+ };
19
+ export declare function isGitHubMetadataHostAllowed(host: string): boolean;
20
+ export declare function normalizePullRequestUrlForRepository(url: string, repository: GitHubRepository, number: number): string | null;
21
+ export declare function readPullRequestInfo(cwd: string, branch: string): Promise<PullRequestInfo>;
@@ -0,0 +1,153 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ const GH_TIMEOUT_MS = 2500;
5
+ const SCP_REMOTE_URL_RE = /^(?:[^@]+@)?([^:]+):(.+)$/;
6
+ function splitRepositoryPath(pathInput) {
7
+ return pathInput
8
+ .replace(/\.git$/, '')
9
+ .replace(/^\/+/, '')
10
+ .replace(/\/+$/, '')
11
+ .split('/')
12
+ .filter(Boolean);
13
+ }
14
+ function parseGitHubRepositoryFromRemoteUrl(remoteUrl) {
15
+ const trimmedUrl = remoteUrl.trim();
16
+ if (!trimmedUrl) {
17
+ return null;
18
+ }
19
+ if (trimmedUrl.includes('://')) {
20
+ if (!URL.canParse(trimmedUrl)) {
21
+ return null;
22
+ }
23
+ const parsedUrl = new URL(trimmedUrl);
24
+ const segments = splitRepositoryPath(parsedUrl.pathname);
25
+ if (segments.length < 2) {
26
+ return null;
27
+ }
28
+ return { host: parsedUrl.hostname.toLowerCase(), owner: segments[0], name: segments[1] };
29
+ }
30
+ const scpMatch = SCP_REMOTE_URL_RE.exec(trimmedUrl);
31
+ if (!scpMatch) {
32
+ return null;
33
+ }
34
+ const segments = splitRepositoryPath(scpMatch[2]);
35
+ if (segments.length < 2) {
36
+ return null;
37
+ }
38
+ return { host: scpMatch[1].toLowerCase(), owner: segments[0], name: segments[1] };
39
+ }
40
+ export function isGitHubMetadataHostAllowed(host) {
41
+ return host.toLowerCase() === 'github.com';
42
+ }
43
+ export function normalizePullRequestUrlForRepository(url, repository, number) {
44
+ if (!URL.canParse(url)) {
45
+ return null;
46
+ }
47
+ const parsedUrl = new URL(url);
48
+ const expectedPath = `/${repository.owner}/${repository.name}/pull/${number}`;
49
+ if (parsedUrl.protocol !== 'https:'
50
+ || parsedUrl.hostname.toLowerCase() !== repository.host
51
+ || parsedUrl.pathname !== expectedPath
52
+ || parsedUrl.search !== ''
53
+ || parsedUrl.hash !== '') {
54
+ return null;
55
+ }
56
+ return `https://${repository.host}${expectedPath}`;
57
+ }
58
+ async function readGitHubRepository(cwd) {
59
+ const { stdout } = await execFileAsync('git', ['config', '--get', 'remote.origin.url'], { cwd });
60
+ const repository = parseGitHubRepositoryFromRemoteUrl(stdout);
61
+ if (!repository || !isGitHubMetadataHostAllowed(repository.host)) {
62
+ throw new Error('GitHub repository remote unavailable');
63
+ }
64
+ return repository;
65
+ }
66
+ function buildPullRequestListArgs(repository, branch, state) {
67
+ const args = [
68
+ 'api',
69
+ '-X',
70
+ 'GET',
71
+ `repos/${repository.owner}/${repository.name}/pulls`,
72
+ '-f',
73
+ `state=${state}`,
74
+ '-f',
75
+ `head=${repository.owner}:${branch}`,
76
+ '-F',
77
+ 'per_page=1',
78
+ ];
79
+ args.push('--hostname', repository.host);
80
+ return args;
81
+ }
82
+ function normalizePullRequestState(state, mergedAt) {
83
+ if (typeof state === 'string' && state.toUpperCase() === 'OPEN') {
84
+ return 'OPEN';
85
+ }
86
+ return typeof mergedAt === 'string' && mergedAt.length > 0 ? 'MERGED' : 'CLOSED';
87
+ }
88
+ function parsePullRequest(item, repository) {
89
+ if (typeof item !== 'object' || item === null) {
90
+ return null;
91
+ }
92
+ const pullRequest = item;
93
+ const number = typeof pullRequest.number === 'number' ? pullRequest.number : NaN;
94
+ const title = typeof pullRequest.title === 'string' ? pullRequest.title : '';
95
+ const url = typeof pullRequest.html_url === 'string' && Number.isFinite(number) ? normalizePullRequestUrlForRepository(pullRequest.html_url, repository, number) : null;
96
+ const isDraft = typeof pullRequest.draft === 'boolean' ? pullRequest.draft : false;
97
+ const baseRefName = typeof pullRequest.base?.ref === 'string' ? pullRequest.base.ref : '';
98
+ if (!Number.isFinite(number) || !title || url === null || !baseRefName) {
99
+ return null;
100
+ }
101
+ return {
102
+ number,
103
+ title,
104
+ url,
105
+ state: normalizePullRequestState(pullRequest.state, pullRequest.merged_at),
106
+ isDraft,
107
+ baseRefName,
108
+ };
109
+ }
110
+ async function readPullRequestList(cwd, branch, state) {
111
+ const repository = await readGitHubRepository(cwd);
112
+ const args = buildPullRequestListArgs(repository, branch, state);
113
+ const { stdout } = await execFileAsync('gh', args, { cwd, timeout: GH_TIMEOUT_MS });
114
+ const payload = JSON.parse(stdout);
115
+ if (!Array.isArray(payload)) {
116
+ throw new Error('GitHub REST API returned unexpected payload');
117
+ }
118
+ const pullRequests = [];
119
+ for (const item of payload) {
120
+ const parsed = parsePullRequest(item, repository);
121
+ if (parsed !== null) {
122
+ pullRequests.push(parsed);
123
+ }
124
+ }
125
+ return pullRequests;
126
+ }
127
+ export async function readPullRequestInfo(cwd, branch) {
128
+ if (branch.startsWith('(')) {
129
+ return { kind: 'none' };
130
+ }
131
+ try {
132
+ const openPullRequests = await readPullRequestList(cwd, branch, 'open');
133
+ const pullRequests = openPullRequests.length > 0
134
+ ? openPullRequests
135
+ : await readPullRequestList(cwd, branch, 'all');
136
+ const pullRequest = pullRequests[0];
137
+ if (!pullRequest) {
138
+ return { kind: 'none' };
139
+ }
140
+ return {
141
+ kind: 'found',
142
+ number: pullRequest.number,
143
+ title: pullRequest.title,
144
+ url: pullRequest.url,
145
+ state: pullRequest.state,
146
+ isDraft: pullRequest.isDraft,
147
+ baseBranch: pullRequest.baseRefName,
148
+ };
149
+ }
150
+ catch {
151
+ return { kind: 'unavailable' };
152
+ }
153
+ }
@@ -1,6 +1,7 @@
1
- import { type ToolConfig } from './config.js';
1
+ import type { ToolConfig } from './config.js';
2
+ import { renderConfigJsonc } from './config-lifecycle.js';
3
+ export { renderConfigJsonc };
2
4
  export declare function buildDefaultConfig(repoRoot: string): Promise<ToolConfig>;
3
- export declare function renderConfigJsonc(config: ToolConfig): string;
4
5
  export interface InitResult {
5
6
  path: string;
6
7
  config: ToolConfig;