@avantmedia/af 0.0.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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +539 -0
  3. package/af +2 -0
  4. package/bun-upgrade.ts +130 -0
  5. package/commands/bun.ts +55 -0
  6. package/commands/changes.ts +35 -0
  7. package/commands/e2e.ts +12 -0
  8. package/commands/help.ts +236 -0
  9. package/commands/install-extension.ts +133 -0
  10. package/commands/jira.ts +577 -0
  11. package/commands/licenses.ts +32 -0
  12. package/commands/npm.ts +55 -0
  13. package/commands/scaffold.ts +105 -0
  14. package/commands/setup.tsx +156 -0
  15. package/commands/spec.ts +405 -0
  16. package/commands/stop-hook.ts +90 -0
  17. package/commands/todo.ts +208 -0
  18. package/commands/versions.ts +150 -0
  19. package/commands/watch.ts +344 -0
  20. package/commands/worktree.ts +424 -0
  21. package/components/change-select.tsx +71 -0
  22. package/components/confirm.tsx +41 -0
  23. package/components/file-conflict.tsx +52 -0
  24. package/components/input.tsx +53 -0
  25. package/components/layout.tsx +70 -0
  26. package/components/messages.tsx +48 -0
  27. package/components/progress.tsx +71 -0
  28. package/components/select.tsx +90 -0
  29. package/components/status-display.tsx +74 -0
  30. package/components/table.tsx +79 -0
  31. package/generated/setup-manifest.ts +67 -0
  32. package/git-worktree.ts +184 -0
  33. package/main.ts +12 -0
  34. package/npm-upgrade.ts +117 -0
  35. package/package.json +83 -0
  36. package/resources/copy-prompt-reporter.ts +443 -0
  37. package/router.ts +220 -0
  38. package/setup/.claude/commands/commit-work.md +47 -0
  39. package/setup/.claude/commands/complete-work.md +34 -0
  40. package/setup/.claude/commands/e2e.md +29 -0
  41. package/setup/.claude/commands/start-work.md +51 -0
  42. package/setup/.claude/skills/pm/SKILL.md +294 -0
  43. package/setup/.claude/skills/pm/templates/api-endpoint.md +69 -0
  44. package/setup/.claude/skills/pm/templates/bug-fix.md +77 -0
  45. package/setup/.claude/skills/pm/templates/feature.md +87 -0
  46. package/setup/.claude/skills/pm/templates/ui-component.md +78 -0
  47. package/utils/change-select-render.tsx +44 -0
  48. package/utils/claude.ts +9 -0
  49. package/utils/config.ts +58 -0
  50. package/utils/env.ts +53 -0
  51. package/utils/git.ts +120 -0
  52. package/utils/ink-render.tsx +50 -0
  53. package/utils/openspec.ts +54 -0
  54. package/utils/output.ts +104 -0
  55. package/utils/proposal.ts +160 -0
  56. package/utils/resources.ts +64 -0
  57. package/utils/setup-files.ts +230 -0
@@ -0,0 +1,70 @@
1
+ import { createElement } from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ /**
5
+ * Props for Header component
6
+ */
7
+ export interface HeaderProps {
8
+ children: string;
9
+ }
10
+
11
+ /**
12
+ * Header component (blue with newline before)
13
+ * Displays a section header in blue text with spacing
14
+ */
15
+ export function Header({ children }: HeaderProps) {
16
+ return (
17
+ <Box flexDirection="column">
18
+ <Text>{''}</Text>
19
+ <Text color="blue">{children}</Text>
20
+ </Box>
21
+ );
22
+ }
23
+
24
+ /**
25
+ * Props for Section component
26
+ */
27
+ export interface SectionProps {
28
+ children: string;
29
+ }
30
+
31
+ /**
32
+ * Section component (cyan with newline before)
33
+ * Displays a sub-section header in cyan text with spacing
34
+ */
35
+ export function Section({ children }: SectionProps) {
36
+ return (
37
+ <Box flexDirection="column">
38
+ <Text>{''}</Text>
39
+ <Text color="cyan">{children}</Text>
40
+ </Box>
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Props for ListItem component
46
+ */
47
+ export interface ListItemProps {
48
+ children: string;
49
+ symbol?: string;
50
+ }
51
+
52
+ /**
53
+ * ListItem component (gray symbol with indentation)
54
+ * Displays a list item with a symbol prefix and indentation
55
+ */
56
+ export function ListItem({ children, symbol = '•' }: ListItemProps) {
57
+ return (
58
+ <Box>
59
+ <Text> </Text>
60
+ <Text color="gray">{symbol}</Text>
61
+ <Text> {children}</Text>
62
+ </Box>
63
+ );
64
+ }
65
+
66
+ // Helper functions to create elements without JSX (for use in .ts files)
67
+ export const createHeader = (children: string) => createElement(Header, { children });
68
+ export const createSection = (children: string) => createElement(Section, { children });
69
+ export const createListItem = (children: string, symbol?: string) =>
70
+ createElement(ListItem, { children, symbol });
@@ -0,0 +1,48 @@
1
+ import { createElement } from 'react';
2
+ import { Text } from 'ink';
3
+
4
+ /**
5
+ * Props for message components
6
+ */
7
+ export interface MessageProps {
8
+ message: string;
9
+ children?: never;
10
+ }
11
+
12
+ /**
13
+ * Success message component (green)
14
+ * Displays a success message in green text
15
+ */
16
+ export function Success({ message }: MessageProps) {
17
+ return <Text color="green">{message}</Text>;
18
+ }
19
+
20
+ /**
21
+ * Error message component (red)
22
+ * Displays an error message in red text
23
+ */
24
+ export function Error({ message }: MessageProps) {
25
+ return <Text color="red">{message}</Text>;
26
+ }
27
+
28
+ /**
29
+ * Info message component (cyan)
30
+ * Displays an informational message in cyan text
31
+ */
32
+ export function Info({ message }: MessageProps) {
33
+ return <Text color="cyan">{message}</Text>;
34
+ }
35
+
36
+ /**
37
+ * Warning message component (yellow)
38
+ * Displays a warning message in yellow text
39
+ */
40
+ export function Warn({ message }: MessageProps) {
41
+ return <Text color="yellow">{message}</Text>;
42
+ }
43
+
44
+ // Helper functions to create elements without JSX (for use in .ts files)
45
+ export const createSuccess = (message: string) => createElement(Success, { message });
46
+ export const createError = (message: string) => createElement(Error, { message });
47
+ export const createInfo = (message: string) => createElement(Info, { message });
48
+ export const createWarn = (message: string) => createElement(Warn, { message });
@@ -0,0 +1,71 @@
1
+ import { Box, Text } from 'ink';
2
+ import { useEffect, useState } from 'react';
3
+
4
+ /**
5
+ * Props for Spinner component
6
+ */
7
+ export interface SpinnerProps {
8
+ label?: string;
9
+ }
10
+
11
+ /**
12
+ * Spinner component with rotating animation
13
+ * Displays an animated spinner with optional label
14
+ */
15
+ export function Spinner({ label }: SpinnerProps) {
16
+ const [frame, setFrame] = useState(0);
17
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
18
+
19
+ useEffect(() => {
20
+ const timer = setInterval(() => {
21
+ setFrame(prevFrame => (prevFrame + 1) % frames.length);
22
+ }, 80);
23
+
24
+ return () => clearInterval(timer);
25
+ }, [frames.length]);
26
+
27
+ return (
28
+ <Box>
29
+ <Text color="cyan">{frames[frame]}</Text>
30
+ {label && <Text> {label}</Text>}
31
+ </Box>
32
+ );
33
+ }
34
+
35
+ /**
36
+ * Props for ProgressBar component
37
+ */
38
+ export interface ProgressBarProps {
39
+ /** Current progress value (0-100) */
40
+ value: number;
41
+ /** Width of the progress bar in characters (default: 40) */
42
+ width?: number;
43
+ /** Optional label to display */
44
+ label?: string;
45
+ /** Show percentage (default: true) */
46
+ showPercentage?: boolean;
47
+ }
48
+
49
+ /**
50
+ * ProgressBar component with visual bar display
51
+ * Displays a progress bar with percentage and optional label
52
+ */
53
+ export function ProgressBar({ value, width = 40, label, showPercentage = true }: ProgressBarProps) {
54
+ const percentage = Math.min(100, Math.max(0, value));
55
+ const filledWidth = Math.round((percentage / 100) * width);
56
+ const emptyWidth = width - filledWidth;
57
+
58
+ const filledBar = '█'.repeat(filledWidth);
59
+ const emptyBar = '░'.repeat(emptyWidth);
60
+
61
+ return (
62
+ <Box flexDirection="column">
63
+ {label && <Text>{label}</Text>}
64
+ <Box>
65
+ <Text color="green">{filledBar}</Text>
66
+ <Text color="gray">{emptyBar}</Text>
67
+ {showPercentage && <Text> {percentage.toFixed(0)}%</Text>}
68
+ </Box>
69
+ </Box>
70
+ );
71
+ }
@@ -0,0 +1,90 @@
1
+ import { Box, Text, useInput } from 'ink';
2
+ import { useState } from 'react';
3
+
4
+ /**
5
+ * Option for Select component
6
+ */
7
+ export interface SelectOption {
8
+ label: string;
9
+ value: string;
10
+ }
11
+
12
+ /**
13
+ * Props for Select component
14
+ */
15
+ export interface SelectProps {
16
+ /** Array of options to choose from */
17
+ options: SelectOption[];
18
+ /** Callback when an option is selected */
19
+ onSelect?: (value: string) => void;
20
+ /** Enable multi-select mode (default: false) */
21
+ multiSelect?: boolean;
22
+ /** Callback for multi-select completion */
23
+ onSubmit?: (values: string[]) => void;
24
+ }
25
+
26
+ /**
27
+ * Select component with keyboard navigation
28
+ * Provides option selection with up/down arrow keys and Enter to confirm
29
+ */
30
+ export function Select({ options, onSelect, multiSelect = false, onSubmit }: SelectProps) {
31
+ const [selectedIndex, setSelectedIndex] = useState(0);
32
+ const [selectedValues, setSelectedValues] = useState<Set<string>>(new Set());
33
+
34
+ useInput((input, key) => {
35
+ if (key.upArrow) {
36
+ setSelectedIndex(prev => (prev > 0 ? prev - 1 : options.length - 1));
37
+ } else if (key.downArrow) {
38
+ setSelectedIndex(prev => (prev < options.length - 1 ? prev + 1 : 0));
39
+ } else if (key.return) {
40
+ const selectedOption = options[selectedIndex];
41
+ if (multiSelect) {
42
+ // Toggle selection in multi-select mode
43
+ setSelectedValues(prev => {
44
+ const next = new Set(prev);
45
+ if (next.has(selectedOption.value)) {
46
+ next.delete(selectedOption.value);
47
+ } else {
48
+ next.add(selectedOption.value);
49
+ }
50
+ return next;
51
+ });
52
+ } else {
53
+ // Single select mode
54
+ onSelect?.(selectedOption.value);
55
+ }
56
+ } else if (input === ' ' && multiSelect) {
57
+ // Space bar submits in multi-select mode
58
+ onSubmit?.(Array.from(selectedValues));
59
+ }
60
+ });
61
+
62
+ return (
63
+ <Box flexDirection="column">
64
+ {options.map((option, index) => {
65
+ const isHighlighted = index === selectedIndex;
66
+ const isSelected = selectedValues.has(option.value);
67
+ const indicator = multiSelect
68
+ ? isSelected
69
+ ? '[✓]'
70
+ : '[ ]'
71
+ : isHighlighted
72
+ ? '›'
73
+ : ' ';
74
+
75
+ return (
76
+ <Box key={option.value}>
77
+ <Text color={isHighlighted ? 'cyan' : undefined}>
78
+ {indicator} {option.label}
79
+ </Text>
80
+ </Box>
81
+ );
82
+ })}
83
+ {multiSelect && (
84
+ <Box marginTop={1}>
85
+ <Text color="gray">Press Enter to toggle, Space to submit</Text>
86
+ </Box>
87
+ )}
88
+ </Box>
89
+ );
90
+ }
@@ -0,0 +1,74 @@
1
+ import { Box, Text } from 'ink';
2
+
3
+ /**
4
+ * Status type for display
5
+ */
6
+ export type StatusType = 'pending' | 'running' | 'success' | 'error';
7
+
8
+ /**
9
+ * Single status line item
10
+ */
11
+ export interface StatusLine {
12
+ id: string;
13
+ message: string;
14
+ status: StatusType;
15
+ }
16
+
17
+ /**
18
+ * Props for StatusDisplay component
19
+ */
20
+ export interface StatusDisplayProps {
21
+ /** Array of status lines to display */
22
+ statuses: StatusLine[];
23
+ }
24
+
25
+ /**
26
+ * Get the appropriate icon for a status type
27
+ */
28
+ function getStatusIcon(status: StatusType): string {
29
+ switch (status) {
30
+ case 'pending':
31
+ return '○';
32
+ case 'running':
33
+ return '⟳';
34
+ case 'success':
35
+ return '✓';
36
+ case 'error':
37
+ return '✗';
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Get the appropriate color for a status type
43
+ */
44
+ function getStatusColor(status: StatusType): string {
45
+ switch (status) {
46
+ case 'pending':
47
+ return 'gray';
48
+ case 'running':
49
+ return 'cyan';
50
+ case 'success':
51
+ return 'green';
52
+ case 'error':
53
+ return 'red';
54
+ }
55
+ }
56
+
57
+ /**
58
+ * StatusDisplay component for tracking multiple operations
59
+ * Displays multiple status lines that can be independently updated
60
+ */
61
+ export function StatusDisplay({ statuses }: StatusDisplayProps) {
62
+ return (
63
+ <Box flexDirection="column">
64
+ {statuses.map(statusLine => (
65
+ <Box key={statusLine.id}>
66
+ <Text color={getStatusColor(statusLine.status)}>
67
+ {getStatusIcon(statusLine.status)}
68
+ </Text>
69
+ <Text> {statusLine.message}</Text>
70
+ </Box>
71
+ ))}
72
+ </Box>
73
+ );
74
+ }
@@ -0,0 +1,79 @@
1
+ import { Box, Text } from 'ink';
2
+
3
+ /**
4
+ * Column definition for Table component
5
+ */
6
+ export interface TableColumn<T> {
7
+ /** Column header text */
8
+ header: string;
9
+ /** Key or accessor function to get the cell value */
10
+ accessor: keyof T | ((row: T) => string | number);
11
+ /** Optional width (auto-calculated if not provided) */
12
+ width?: number;
13
+ }
14
+
15
+ /**
16
+ * Props for Table component
17
+ */
18
+ export interface TableProps<T> {
19
+ /** Array of data rows */
20
+ data: T[];
21
+ /** Column definitions */
22
+ columns: TableColumn<T>[];
23
+ }
24
+
25
+ /**
26
+ * Table component with flexible column layout
27
+ * Uses Flexbox for column layout with automatic width calculation
28
+ */
29
+ export function Table<T>({ data, columns }: TableProps<T>) {
30
+ // Helper function to get cell value
31
+ const getCellValue = (row: T, column: TableColumn<T>): string => {
32
+ if (typeof column.accessor === 'function') {
33
+ return String(column.accessor(row));
34
+ }
35
+ return String(row[column.accessor]);
36
+ };
37
+
38
+ // Calculate column widths based on content if not specified
39
+ const columnWidths = columns.map(column => {
40
+ if (column.width) {
41
+ return column.width;
42
+ }
43
+
44
+ // Calculate based on header and data content
45
+ const headerLength = column.header.length;
46
+ const maxDataLength = Math.max(...data.map(row => getCellValue(row, column).length), 0);
47
+
48
+ return Math.max(headerLength, maxDataLength);
49
+ });
50
+
51
+ return (
52
+ <Box flexDirection="column">
53
+ {/* Header row */}
54
+ <Box>
55
+ {columns.map((column, index) => (
56
+ <Box key={column.header} width={columnWidths[index] + 2}>
57
+ <Text bold color="cyan">
58
+ {column.header.padEnd(columnWidths[index])}
59
+ </Text>
60
+ </Box>
61
+ ))}
62
+ </Box>
63
+
64
+ {/* Data rows */}
65
+ {data.map((row, rowIndex) => (
66
+ <Box key={rowIndex}>
67
+ {columns.map((column, colIndex) => {
68
+ const cellValue = getCellValue(row, column);
69
+ return (
70
+ <Box key={colIndex} width={columnWidths[colIndex] + 2}>
71
+ <Text>{cellValue.padEnd(columnWidths[colIndex])}</Text>
72
+ </Box>
73
+ );
74
+ })}
75
+ </Box>
76
+ ))}
77
+ </Box>
78
+ );
79
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * AUTO-GENERATED FILE - DO NOT EDIT
3
+ * Generated by scripts/generate-setup-manifest.ts
4
+ *
5
+ * This file embeds all setup files and resources into the compiled binary using
6
+ * Bun's file import attribute. Regenerate after modifying setup/ or resources/ contents.
7
+ */
8
+
9
+ import file_claude_commands_start_work_md from '../setup/.claude/commands/start-work.md' with { type: 'file' };
10
+ import file_claude_commands_complete_work_md from '../setup/.claude/commands/complete-work.md' with { type: 'file' };
11
+ import file_claude_commands_e2e_md from '../setup/.claude/commands/e2e.md' with { type: 'file' };
12
+ import file_claude_commands_commit_work_md from '../setup/.claude/commands/commit-work.md' with { type: 'file' };
13
+ import file_claude_skills_pm_SKILL_md from '../setup/.claude/skills/pm/SKILL.md' with { type: 'file' };
14
+ import file_claude_skills_pm_templates_bug_fix_md from '../setup/.claude/skills/pm/templates/bug-fix.md' with { type: 'file' };
15
+ import file_claude_skills_pm_templates_ui_component_md from '../setup/.claude/skills/pm/templates/ui-component.md' with { type: 'file' };
16
+ import file_claude_skills_pm_templates_api_endpoint_md from '../setup/.claude/skills/pm/templates/api-endpoint.md' with { type: 'file' };
17
+ import file_claude_skills_pm_templates_feature_md from '../setup/.claude/skills/pm/templates/feature.md' with { type: 'file' };
18
+
19
+ import resource_copy_prompt_reporter_ts from '../resources/copy-prompt-reporter.ts' with { type: 'file' };
20
+
21
+ import extension_avantmedia_openspec_0_1_0_vsix from '../vscode-extension/avantmedia-openspec-0.1.0.vsix' with { type: 'file' };
22
+
23
+ export interface SetupFile {
24
+ /** Path relative to setup/ folder (e.g., ".claude/settings.local.json") */
25
+ relativePath: string;
26
+ /** Path to embedded file (bun internal path or dev file path) */
27
+ embeddedPath: string;
28
+ }
29
+
30
+ export interface ResourceFile {
31
+ /** File name (e.g., "copy-prompt-reporter.ts") */
32
+ name: string;
33
+ /** Path to embedded file (bun internal path or dev file path) */
34
+ embeddedPath: string;
35
+ }
36
+
37
+ export const SETUP_FILES: SetupFile[] = [
38
+ { relativePath: '.claude/commands/start-work.md', embeddedPath: file_claude_commands_start_work_md },
39
+ { relativePath: '.claude/commands/complete-work.md', embeddedPath: file_claude_commands_complete_work_md },
40
+ { relativePath: '.claude/commands/e2e.md', embeddedPath: file_claude_commands_e2e_md },
41
+ { relativePath: '.claude/commands/commit-work.md', embeddedPath: file_claude_commands_commit_work_md },
42
+ { relativePath: '.claude/skills/pm/SKILL.md', embeddedPath: file_claude_skills_pm_SKILL_md },
43
+ { relativePath: '.claude/skills/pm/templates/bug-fix.md', embeddedPath: file_claude_skills_pm_templates_bug_fix_md },
44
+ { relativePath: '.claude/skills/pm/templates/ui-component.md', embeddedPath: file_claude_skills_pm_templates_ui_component_md },
45
+ { relativePath: '.claude/skills/pm/templates/api-endpoint.md', embeddedPath: file_claude_skills_pm_templates_api_endpoint_md },
46
+ { relativePath: '.claude/skills/pm/templates/feature.md', embeddedPath: file_claude_skills_pm_templates_feature_md },
47
+ ];
48
+
49
+ export const RESOURCE_FILES: ResourceFile[] = [
50
+ { name: 'copy-prompt-reporter.ts', embeddedPath: resource_copy_prompt_reporter_ts },
51
+ ];
52
+
53
+ export interface ExtensionFile {
54
+ /** File name (e.g., "openspec-0.1.0.vsix") */
55
+ name: string;
56
+ /** Path to embedded file (bun internal path or dev file path) */
57
+ embeddedPath: string;
58
+ }
59
+
60
+ export const EXTENSION_FILE: ExtensionFile | null = { name: 'avantmedia-openspec-0.1.0.vsix', embeddedPath: extension_avantmedia_openspec_0_1_0_vsix };
61
+
62
+ /**
63
+ * Check if running from compiled binary (embedded files contain $bunfs in path)
64
+ */
65
+ export function isCompiled(): boolean {
66
+ return SETUP_FILES.length > 0 && SETUP_FILES[0].embeddedPath.includes('$bunfs');
67
+ }
@@ -0,0 +1,184 @@
1
+ import { execSync } from 'node:child_process';
2
+
3
+ export interface Worktree {
4
+ path: string;
5
+ branch: string;
6
+ }
7
+
8
+ /**
9
+ * Checks if the current directory is inside a git repository.
10
+ *
11
+ * @returns true if inside a git repository, false otherwise
12
+ */
13
+ export function isGitRepository(): boolean {
14
+ try {
15
+ execSync('git rev-parse --git-dir', { stdio: 'ignore' });
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Gets the current HEAD commit hash.
24
+ *
25
+ * @returns The current HEAD commit hash
26
+ * @throws Error if unable to get HEAD commit
27
+ */
28
+ export function getCurrentHeadCommit(): string {
29
+ try {
30
+ const commit = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
31
+ return commit;
32
+ } catch (error) {
33
+ if (error instanceof Error) {
34
+ throw new Error(`Failed to get current HEAD commit: ${error.message}`);
35
+ }
36
+ throw new Error('Failed to get current HEAD commit');
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Lists all git worktrees in the repository.
42
+ *
43
+ * Parses the output of `git worktree list --porcelain` to extract
44
+ * worktree paths and their associated branches.
45
+ *
46
+ * @returns Array of worktree objects with path and branch information
47
+ * @throws Error if unable to list worktrees
48
+ */
49
+ export function listWorktrees(): Worktree[] {
50
+ try {
51
+ const output = execSync('git worktree list --porcelain', { encoding: 'utf-8' });
52
+ return parseWorktreeList(output);
53
+ } catch (error) {
54
+ if (error instanceof Error) {
55
+ throw new Error(`Failed to list worktrees: ${error.message}`);
56
+ }
57
+ throw new Error('Failed to list worktrees');
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Parses the porcelain output from `git worktree list --porcelain`.
63
+ *
64
+ * The porcelain format outputs each worktree as a block separated by blank lines:
65
+ * worktree <path>
66
+ * HEAD <sha>
67
+ * branch <branch-ref>
68
+ *
69
+ * @param output The raw output from git worktree list --porcelain
70
+ * @returns Array of parsed worktree objects
71
+ */
72
+ function parseWorktreeList(output: string): Worktree[] {
73
+ const worktrees: Worktree[] = [];
74
+ const lines = output.trim().split('\n');
75
+
76
+ let currentPath: string | null = null;
77
+ let currentBranch: string | null = null;
78
+
79
+ for (const line of lines) {
80
+ if (line.startsWith('worktree ')) {
81
+ currentPath = line.substring('worktree '.length);
82
+ } else if (line.startsWith('branch ')) {
83
+ const branchRef = line.substring('branch '.length);
84
+ currentBranch = getWorktreeBranch(branchRef);
85
+ } else if (line === '') {
86
+ // End of worktree block
87
+ if (currentPath && currentBranch) {
88
+ worktrees.push({ path: currentPath, branch: currentBranch });
89
+ }
90
+ currentPath = null;
91
+ currentBranch = null;
92
+ }
93
+ }
94
+
95
+ // Handle last worktree if file doesn't end with blank line
96
+ if (currentPath && currentBranch) {
97
+ worktrees.push({ path: currentPath, branch: currentBranch });
98
+ }
99
+
100
+ return worktrees;
101
+ }
102
+
103
+ /**
104
+ * Extracts the branch name from a git branch reference.
105
+ *
106
+ * Converts refs/heads/branch-name to branch-name
107
+ *
108
+ * @param branchRef The full branch reference (e.g., "refs/heads/main")
109
+ * @returns The branch name without the refs/heads/ prefix
110
+ */
111
+ export function getWorktreeBranch(branchRef: string): string {
112
+ if (branchRef.startsWith('refs/heads/')) {
113
+ return branchRef.substring('refs/heads/'.length);
114
+ }
115
+ return branchRef;
116
+ }
117
+
118
+ /**
119
+ * Checks if a worktree has uncommitted changes.
120
+ *
121
+ * Uses `git status --porcelain` which outputs a blank string when clean,
122
+ * or non-empty output when there are changes.
123
+ *
124
+ * @param worktreePath The path to the worktree to check
125
+ * @returns true if the worktree has uncommitted changes, false if clean
126
+ * @throws Error if unable to check git status
127
+ */
128
+ export function hasUncommittedChanges(worktreePath: string): boolean {
129
+ try {
130
+ const output = execSync('git status --porcelain', {
131
+ cwd: worktreePath,
132
+ encoding: 'utf-8',
133
+ });
134
+ return output.trim().length > 0;
135
+ } catch (error) {
136
+ if (error instanceof Error) {
137
+ throw new Error(
138
+ `Failed to check git status in worktree '${worktreePath}': ${error.message}`,
139
+ );
140
+ }
141
+ throw new Error(`Failed to check git status in worktree '${worktreePath}'`);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Resets a worktree to a specific revision using `git reset --hard`.
147
+ *
148
+ * @param worktreePath The path to the worktree to reset
149
+ * @param revision The git revision (commit hash, branch, tag) to reset to
150
+ * @throws Error if the reset operation fails
151
+ */
152
+ export function resetWorktree(worktreePath: string, revision: string): void {
153
+ try {
154
+ execSync(`git reset --hard ${revision}`, {
155
+ cwd: worktreePath,
156
+ stdio: 'ignore',
157
+ });
158
+ } catch (error) {
159
+ if (error instanceof Error) {
160
+ throw new Error(`Failed to reset worktree '${worktreePath}': ${error.message}`);
161
+ }
162
+ throw new Error(`Failed to reset worktree '${worktreePath}'`);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Force-pushes a worktree to its remote repository.
168
+ *
169
+ * @param worktreePath The path to the worktree to push
170
+ * @throws Error if the push operation fails
171
+ */
172
+ export function pushWorktree(worktreePath: string): void {
173
+ try {
174
+ execSync('git push --force', {
175
+ cwd: worktreePath,
176
+ stdio: 'ignore',
177
+ });
178
+ } catch (error) {
179
+ if (error instanceof Error) {
180
+ throw new Error(`Failed to push worktree '${worktreePath}': ${error.message}`);
181
+ }
182
+ throw new Error(`Failed to push worktree '${worktreePath}'`);
183
+ }
184
+ }