stage-tui 1.0.7
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.
- package/README.md +112 -0
- package/bin/stage +51 -0
- package/index.ts +22 -0
- package/package.json +46 -0
- package/src/ai-commit.ts +706 -0
- package/src/app.tsx +127 -0
- package/src/config-file.ts +40 -0
- package/src/config.ts +283 -0
- package/src/git-branch-name.ts +13 -0
- package/src/git-process.ts +25 -0
- package/src/git-status-parser.ts +103 -0
- package/src/git.ts +298 -0
- package/src/hooks/use-branch-dialog-controller.ts +188 -0
- package/src/hooks/use-commit-history-controller.ts +130 -0
- package/src/hooks/use-git-tui-controller.ts +310 -0
- package/src/hooks/use-git-tui-effects.ts +168 -0
- package/src/hooks/use-git-tui-keyboard.ts +293 -0
- package/src/ui/components/branch-dialog.tsx +107 -0
- package/src/ui/components/commit-dialog.tsx +68 -0
- package/src/ui/components/commit-history-dialog.tsx +87 -0
- package/src/ui/components/diff-workspace.tsx +108 -0
- package/src/ui/components/footer-bar.tsx +65 -0
- package/src/ui/components/shortcuts-dialog.tsx +53 -0
- package/src/ui/components/top-bar.tsx +36 -0
- package/src/ui/diff-style.ts +33 -0
- package/src/ui/theme.ts +151 -0
- package/src/ui/types.ts +21 -0
- package/src/ui/utils.ts +99 -0
package/src/app.tsx
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
2
|
+
|
|
3
|
+
import type { StageConfig } from "./config"
|
|
4
|
+
import { BranchDialog } from "./ui/components/branch-dialog"
|
|
5
|
+
import { CommitHistoryDialog } from "./ui/components/commit-history-dialog"
|
|
6
|
+
import { useGitTuiController } from "./hooks/use-git-tui-controller"
|
|
7
|
+
import { CommitDialog } from "./ui/components/commit-dialog"
|
|
8
|
+
import { DiffWorkspace } from "./ui/components/diff-workspace"
|
|
9
|
+
import { FooterBar } from "./ui/components/footer-bar"
|
|
10
|
+
import { ShortcutsDialog } from "./ui/components/shortcuts-dialog"
|
|
11
|
+
import { TopBar } from "./ui/components/top-bar"
|
|
12
|
+
import { resolveUiTheme } from "./ui/theme"
|
|
13
|
+
|
|
14
|
+
type AppProps = {
|
|
15
|
+
config: StageConfig
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function App({ config }: AppProps) {
|
|
19
|
+
const renderer = useRenderer()
|
|
20
|
+
const { width: terminalWidth = 0, height: terminalHeight = 0 } = useTerminalDimensions()
|
|
21
|
+
const theme = resolveUiTheme(config.ui.theme)
|
|
22
|
+
const controller = useGitTuiController(renderer, config)
|
|
23
|
+
const activeScreen = controller.branchDialogOpen
|
|
24
|
+
? "branch"
|
|
25
|
+
: controller.commitDialogOpen
|
|
26
|
+
? "commit"
|
|
27
|
+
: controller.historyDialogOpen
|
|
28
|
+
? "history"
|
|
29
|
+
: controller.shortcutsDialogOpen
|
|
30
|
+
? "shortcuts"
|
|
31
|
+
: "main"
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<box
|
|
35
|
+
style={{
|
|
36
|
+
width: "100%",
|
|
37
|
+
height: "100%",
|
|
38
|
+
flexDirection: "column",
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
<TopBar
|
|
42
|
+
currentBranch={controller.currentBranch}
|
|
43
|
+
tracking={controller.tracking}
|
|
44
|
+
theme={theme}
|
|
45
|
+
/>
|
|
46
|
+
|
|
47
|
+
{activeScreen === "main" ? (
|
|
48
|
+
<DiffWorkspace
|
|
49
|
+
fileRows={controller.fileRows}
|
|
50
|
+
fileIndex={controller.fileIndex}
|
|
51
|
+
selectedFilePath={controller.selectedFilePath}
|
|
52
|
+
focus={controller.focus}
|
|
53
|
+
terminalWidth={terminalWidth}
|
|
54
|
+
terminalHeight={terminalHeight}
|
|
55
|
+
diffText={controller.diffText}
|
|
56
|
+
diffMessage={controller.diffMessage}
|
|
57
|
+
diffFiletype={controller.diffFiletype}
|
|
58
|
+
diffView={config.ui.diffView}
|
|
59
|
+
theme={theme}
|
|
60
|
+
/>
|
|
61
|
+
) : null}
|
|
62
|
+
|
|
63
|
+
{activeScreen === "branch" ? (
|
|
64
|
+
<BranchDialog
|
|
65
|
+
open={true}
|
|
66
|
+
mode={controller.branchDialogMode}
|
|
67
|
+
focus={controller.focus}
|
|
68
|
+
currentBranch={controller.currentBranch}
|
|
69
|
+
branchOptions={controller.branchOptions}
|
|
70
|
+
branchOptionsKey={controller.branchOptionsKey}
|
|
71
|
+
branchIndex={controller.branchIndex}
|
|
72
|
+
onBranchChange={controller.onBranchDialogChange}
|
|
73
|
+
branchStrategyOptions={controller.branchStrategyOptions}
|
|
74
|
+
branchStrategyIndex={controller.branchStrategyIndex}
|
|
75
|
+
onBranchStrategyChange={controller.onBranchStrategyChange}
|
|
76
|
+
branchName={controller.newBranchName}
|
|
77
|
+
branchNameRef={controller.branchNameRef}
|
|
78
|
+
onBranchNameInput={controller.onBranchNameInput}
|
|
79
|
+
theme={theme}
|
|
80
|
+
/>
|
|
81
|
+
) : null}
|
|
82
|
+
|
|
83
|
+
{activeScreen === "commit" ? (
|
|
84
|
+
<CommitDialog
|
|
85
|
+
open={true}
|
|
86
|
+
focus={controller.focus}
|
|
87
|
+
summary={controller.summary}
|
|
88
|
+
descriptionRenderKey={controller.descriptionRenderKey}
|
|
89
|
+
summaryRef={controller.summaryRef}
|
|
90
|
+
descriptionRef={controller.descriptionRef}
|
|
91
|
+
onSummaryInput={controller.onSummaryInput}
|
|
92
|
+
theme={theme}
|
|
93
|
+
/>
|
|
94
|
+
) : null}
|
|
95
|
+
|
|
96
|
+
{activeScreen === "history" ? (
|
|
97
|
+
<CommitHistoryDialog
|
|
98
|
+
open={true}
|
|
99
|
+
mode={controller.historyMode}
|
|
100
|
+
focus={controller.focus}
|
|
101
|
+
currentBranch={controller.currentBranch}
|
|
102
|
+
commitOptions={controller.commitOptions}
|
|
103
|
+
commitIndex={controller.commitIndex}
|
|
104
|
+
onCommitChange={controller.onCommitIndexChange}
|
|
105
|
+
actionOptions={controller.actionOptions}
|
|
106
|
+
actionIndex={controller.actionIndex}
|
|
107
|
+
onActionChange={controller.onActionIndexChange}
|
|
108
|
+
selectedCommitTitle={controller.selectedCommitTitle}
|
|
109
|
+
theme={theme}
|
|
110
|
+
/>
|
|
111
|
+
) : null}
|
|
112
|
+
|
|
113
|
+
{activeScreen === "shortcuts" ? (
|
|
114
|
+
<ShortcutsDialog open={true} aiCommitEnabled={config.ai.enabled} theme={theme} />
|
|
115
|
+
) : null}
|
|
116
|
+
|
|
117
|
+
<FooterBar
|
|
118
|
+
statusMessage={controller.statusMessage}
|
|
119
|
+
showShortcutsHint={config.ui.showShortcutsHint}
|
|
120
|
+
terminalWidth={terminalWidth}
|
|
121
|
+
fatalError={controller.fatalError}
|
|
122
|
+
isBusy={controller.isBusy}
|
|
123
|
+
theme={theme}
|
|
124
|
+
/>
|
|
125
|
+
</box>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises"
|
|
2
|
+
import { dirname } from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { StageConfig } from "./config"
|
|
5
|
+
|
|
6
|
+
export async function ensureUserConfigFile(configPath: string, defaults: StageConfig): Promise<void> {
|
|
7
|
+
await mkdir(dirname(configPath), { recursive: true })
|
|
8
|
+
try {
|
|
9
|
+
await writeFile(configPath, createDefaultConfigToml(defaults), { flag: "wx" })
|
|
10
|
+
} catch (error) {
|
|
11
|
+
const err = error as NodeJS.ErrnoException
|
|
12
|
+
if (err.code !== "EEXIST") throw error
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createDefaultConfigToml(defaults: StageConfig): string {
|
|
17
|
+
return [
|
|
18
|
+
"[ui]",
|
|
19
|
+
`diff_view = "${defaults.ui.diffView}"`,
|
|
20
|
+
`theme = "${defaults.ui.theme}"`,
|
|
21
|
+
`hide_whitespace_changes = ${defaults.ui.hideWhitespaceChanges}`,
|
|
22
|
+
`show_shortcuts_hint = ${defaults.ui.showShortcutsHint}`,
|
|
23
|
+
"",
|
|
24
|
+
"[history]",
|
|
25
|
+
`limit = ${defaults.history.limit}`,
|
|
26
|
+
"",
|
|
27
|
+
"[git]",
|
|
28
|
+
`auto_stage_on_commit = ${defaults.git.autoStageOnCommit}`,
|
|
29
|
+
"",
|
|
30
|
+
"[ai]",
|
|
31
|
+
`enabled = ${defaults.ai.enabled}`,
|
|
32
|
+
`provider = "${defaults.ai.provider}"`,
|
|
33
|
+
'api_key = ""',
|
|
34
|
+
`model = "${defaults.ai.model}"`,
|
|
35
|
+
`reasoning_effort = "${defaults.ai.reasoningEffort}"`,
|
|
36
|
+
`max_files = ${defaults.ai.maxFiles}`,
|
|
37
|
+
`max_chars_per_file = ${defaults.ai.maxCharsPerFile}`,
|
|
38
|
+
"",
|
|
39
|
+
].join("\n")
|
|
40
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { join, resolve } from "node:path"
|
|
3
|
+
|
|
4
|
+
import { ensureUserConfigFile } from "./config-file"
|
|
5
|
+
|
|
6
|
+
export type StageConfig = {
|
|
7
|
+
ui: {
|
|
8
|
+
diffView: "unified" | "split"
|
|
9
|
+
theme: "auto" | "dark" | "light"
|
|
10
|
+
hideWhitespaceChanges: boolean
|
|
11
|
+
showShortcutsHint: boolean
|
|
12
|
+
}
|
|
13
|
+
history: {
|
|
14
|
+
limit: number
|
|
15
|
+
}
|
|
16
|
+
git: {
|
|
17
|
+
autoStageOnCommit: boolean
|
|
18
|
+
}
|
|
19
|
+
ai: {
|
|
20
|
+
enabled: boolean
|
|
21
|
+
provider: "cerebras"
|
|
22
|
+
apiKey: string
|
|
23
|
+
model: string
|
|
24
|
+
reasoningEffort: "low" | "medium" | "high"
|
|
25
|
+
maxFiles: number
|
|
26
|
+
maxCharsPerFile: number
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ResolvedStageConfig = {
|
|
31
|
+
config: StageConfig
|
|
32
|
+
source: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_STAGE_CONFIG: StageConfig = {
|
|
36
|
+
ui: {
|
|
37
|
+
diffView: "unified",
|
|
38
|
+
theme: "auto",
|
|
39
|
+
hideWhitespaceChanges: true,
|
|
40
|
+
showShortcutsHint: true,
|
|
41
|
+
},
|
|
42
|
+
history: {
|
|
43
|
+
limit: 200,
|
|
44
|
+
},
|
|
45
|
+
git: {
|
|
46
|
+
autoStageOnCommit: true,
|
|
47
|
+
},
|
|
48
|
+
ai: {
|
|
49
|
+
enabled: false,
|
|
50
|
+
provider: "cerebras",
|
|
51
|
+
apiKey: "",
|
|
52
|
+
model: "gpt-oss-120b",
|
|
53
|
+
reasoningEffort: "low",
|
|
54
|
+
maxFiles: 32,
|
|
55
|
+
maxCharsPerFile: 4000,
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function loadStageConfig(cwd: string): Promise<ResolvedStageConfig> {
|
|
60
|
+
const envPath = process.env.STAGE_CONFIG?.trim()
|
|
61
|
+
if (envPath) {
|
|
62
|
+
const explicitPath = resolve(cwd, envPath)
|
|
63
|
+
const file = Bun.file(explicitPath)
|
|
64
|
+
if (!(await file.exists())) {
|
|
65
|
+
throw new Error(`Config file from STAGE_CONFIG was not found: ${explicitPath}`)
|
|
66
|
+
}
|
|
67
|
+
const raw = await file.text()
|
|
68
|
+
return {
|
|
69
|
+
config: parseStageConfigToml(raw, explicitPath),
|
|
70
|
+
source: explicitPath,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const localPath = resolve(cwd, ".stage-manager.toml")
|
|
75
|
+
const localFile = Bun.file(localPath)
|
|
76
|
+
if (await localFile.exists()) {
|
|
77
|
+
const raw = await localFile.text()
|
|
78
|
+
return {
|
|
79
|
+
config: parseStageConfigToml(raw, localPath),
|
|
80
|
+
source: localPath,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const userPath = getUserConfigPath()
|
|
85
|
+
const userFile = Bun.file(userPath)
|
|
86
|
+
if (await userFile.exists()) {
|
|
87
|
+
const raw = await userFile.text()
|
|
88
|
+
return {
|
|
89
|
+
config: parseStageConfigToml(raw, userPath),
|
|
90
|
+
source: userPath,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await ensureUserConfigFile(userPath, DEFAULT_STAGE_CONFIG)
|
|
95
|
+
const createdRaw = await Bun.file(userPath).text()
|
|
96
|
+
return {
|
|
97
|
+
config: parseStageConfigToml(createdRaw, userPath),
|
|
98
|
+
source: userPath,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getUserConfigPath(): string {
|
|
103
|
+
const xdg = process.env.XDG_CONFIG_HOME?.trim()
|
|
104
|
+
const configRoot = xdg ? xdg : join(homedir(), ".config")
|
|
105
|
+
return join(configRoot, "stage-manager", "config.toml")
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseStageConfigToml(raw: string, sourcePath: string): StageConfig {
|
|
109
|
+
let parsed: unknown
|
|
110
|
+
try {
|
|
111
|
+
parsed = Bun.TOML.parse(raw)
|
|
112
|
+
} catch (error) {
|
|
113
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
114
|
+
throw new Error(`Invalid TOML in ${sourcePath}: ${message}`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const root = asRecord(parsed, `Config root in ${sourcePath}`)
|
|
118
|
+
assertNoUnknownKeys(root, ["ui", "history", "git", "ai"], `Config root in ${sourcePath}`)
|
|
119
|
+
|
|
120
|
+
const config = cloneConfig(DEFAULT_STAGE_CONFIG)
|
|
121
|
+
|
|
122
|
+
if (root.ui !== undefined) {
|
|
123
|
+
const ui = asRecord(root.ui, `[ui] in ${sourcePath}`)
|
|
124
|
+
assertNoUnknownKeys(ui, ["diff_view", "theme", "hide_whitespace_changes", "show_shortcuts_hint"], `[ui] in ${sourcePath}`)
|
|
125
|
+
|
|
126
|
+
if (ui.diff_view !== undefined) {
|
|
127
|
+
if (ui.diff_view !== "unified" && ui.diff_view !== "split") {
|
|
128
|
+
throw new Error(`Invalid value for ui.diff_view in ${sourcePath}. Expected "unified" or "split".`)
|
|
129
|
+
}
|
|
130
|
+
config.ui.diffView = ui.diff_view
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (ui.theme !== undefined) {
|
|
134
|
+
if (ui.theme !== "auto" && ui.theme !== "dark" && ui.theme !== "light") {
|
|
135
|
+
throw new Error(`Invalid value for ui.theme in ${sourcePath}. Expected "auto", "dark", or "light".`)
|
|
136
|
+
}
|
|
137
|
+
config.ui.theme = ui.theme
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (ui.hide_whitespace_changes !== undefined) {
|
|
141
|
+
config.ui.hideWhitespaceChanges = asBoolean(ui.hide_whitespace_changes, `ui.hide_whitespace_changes in ${sourcePath}`)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (ui.show_shortcuts_hint !== undefined) {
|
|
145
|
+
config.ui.showShortcutsHint = asBoolean(ui.show_shortcuts_hint, `ui.show_shortcuts_hint in ${sourcePath}`)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (root.history !== undefined) {
|
|
150
|
+
const history = asRecord(root.history, `[history] in ${sourcePath}`)
|
|
151
|
+
assertNoUnknownKeys(history, ["limit"], `[history] in ${sourcePath}`)
|
|
152
|
+
|
|
153
|
+
if (history.limit !== undefined) {
|
|
154
|
+
const limit = asInteger(history.limit, `history.limit in ${sourcePath}`)
|
|
155
|
+
if (limit <= 0) {
|
|
156
|
+
throw new Error(`history.limit in ${sourcePath} must be greater than 0.`)
|
|
157
|
+
}
|
|
158
|
+
config.history.limit = limit
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (root.git !== undefined) {
|
|
163
|
+
const git = asRecord(root.git, `[git] in ${sourcePath}`)
|
|
164
|
+
assertNoUnknownKeys(git, ["auto_stage_on_commit"], `[git] in ${sourcePath}`)
|
|
165
|
+
|
|
166
|
+
if (git.auto_stage_on_commit !== undefined) {
|
|
167
|
+
config.git.autoStageOnCommit = asBoolean(git.auto_stage_on_commit, `git.auto_stage_on_commit in ${sourcePath}`)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (root.ai !== undefined) {
|
|
172
|
+
const ai = asRecord(root.ai, `[ai] in ${sourcePath}`)
|
|
173
|
+
assertNoUnknownKeys(ai, ["enabled", "provider", "api_key", "model", "reasoning_effort", "max_files", "max_chars_per_file"], `[ai] in ${sourcePath}`)
|
|
174
|
+
|
|
175
|
+
if (ai.enabled !== undefined) {
|
|
176
|
+
config.ai.enabled = asBoolean(ai.enabled, `ai.enabled in ${sourcePath}`)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (ai.provider !== undefined) {
|
|
180
|
+
if (ai.provider !== "cerebras") {
|
|
181
|
+
throw new Error(`Invalid value for ai.provider in ${sourcePath}. Expected "cerebras".`)
|
|
182
|
+
}
|
|
183
|
+
config.ai.provider = ai.provider
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (ai.api_key !== undefined) {
|
|
187
|
+
if (typeof ai.api_key !== "string") {
|
|
188
|
+
throw new Error(`ai.api_key in ${sourcePath} must be a string.`)
|
|
189
|
+
}
|
|
190
|
+
config.ai.apiKey = ai.api_key.trim()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (ai.model !== undefined) {
|
|
194
|
+
if (typeof ai.model !== "string" || !ai.model.trim()) {
|
|
195
|
+
throw new Error(`ai.model in ${sourcePath} must be a non-empty string.`)
|
|
196
|
+
}
|
|
197
|
+
config.ai.model = ai.model.trim()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (ai.reasoning_effort !== undefined) {
|
|
201
|
+
if (ai.reasoning_effort !== "low" && ai.reasoning_effort !== "medium" && ai.reasoning_effort !== "high") {
|
|
202
|
+
throw new Error(`Invalid value for ai.reasoning_effort in ${sourcePath}. Expected "low", "medium", or "high".`)
|
|
203
|
+
}
|
|
204
|
+
config.ai.reasoningEffort = ai.reasoning_effort
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (ai.max_files !== undefined) {
|
|
208
|
+
const maxFiles = asInteger(ai.max_files, `ai.max_files in ${sourcePath}`)
|
|
209
|
+
if (maxFiles <= 0) {
|
|
210
|
+
throw new Error(`ai.max_files in ${sourcePath} must be greater than 0.`)
|
|
211
|
+
}
|
|
212
|
+
config.ai.maxFiles = maxFiles
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (ai.max_chars_per_file !== undefined) {
|
|
216
|
+
const maxCharsPerFile = asInteger(ai.max_chars_per_file, `ai.max_chars_per_file in ${sourcePath}`)
|
|
217
|
+
if (maxCharsPerFile <= 0) {
|
|
218
|
+
throw new Error(`ai.max_chars_per_file in ${sourcePath} must be greater than 0.`)
|
|
219
|
+
}
|
|
220
|
+
config.ai.maxCharsPerFile = maxCharsPerFile
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (config.ai.enabled && !config.ai.apiKey) {
|
|
225
|
+
throw new Error(`ai.enabled is true in ${sourcePath}, but ai.api_key is empty.`)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return config
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function cloneConfig(config: StageConfig): StageConfig {
|
|
232
|
+
return {
|
|
233
|
+
ui: {
|
|
234
|
+
diffView: config.ui.diffView,
|
|
235
|
+
theme: config.ui.theme,
|
|
236
|
+
hideWhitespaceChanges: config.ui.hideWhitespaceChanges,
|
|
237
|
+
showShortcutsHint: config.ui.showShortcutsHint,
|
|
238
|
+
},
|
|
239
|
+
history: {
|
|
240
|
+
limit: config.history.limit,
|
|
241
|
+
},
|
|
242
|
+
git: {
|
|
243
|
+
autoStageOnCommit: config.git.autoStageOnCommit,
|
|
244
|
+
},
|
|
245
|
+
ai: {
|
|
246
|
+
enabled: config.ai.enabled,
|
|
247
|
+
provider: config.ai.provider,
|
|
248
|
+
apiKey: config.ai.apiKey,
|
|
249
|
+
model: config.ai.model,
|
|
250
|
+
reasoningEffort: config.ai.reasoningEffort,
|
|
251
|
+
maxFiles: config.ai.maxFiles,
|
|
252
|
+
maxCharsPerFile: config.ai.maxCharsPerFile,
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function asRecord(value: unknown, label: string): Record<string, unknown> {
|
|
258
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
259
|
+
throw new Error(`${label} must be a table/object.`)
|
|
260
|
+
}
|
|
261
|
+
return value as Record<string, unknown>
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function asBoolean(value: unknown, label: string): boolean {
|
|
265
|
+
if (typeof value !== "boolean") {
|
|
266
|
+
throw new Error(`${label} must be a boolean.`)
|
|
267
|
+
}
|
|
268
|
+
return value
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function asInteger(value: unknown, label: string): number {
|
|
272
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
273
|
+
throw new Error(`${label} must be an integer.`)
|
|
274
|
+
}
|
|
275
|
+
return value
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function assertNoUnknownKeys(record: Record<string, unknown>, knownKeys: string[], label: string): void {
|
|
279
|
+
const unknown = Object.keys(record).filter((key) => !knownKeys.includes(key))
|
|
280
|
+
if (unknown.length > 0) {
|
|
281
|
+
throw new Error(`${label} has unsupported keys: ${unknown.join(", ")}`)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function normalizeBranchName(input: string): string {
|
|
2
|
+
const normalized = input
|
|
3
|
+
.trim()
|
|
4
|
+
.toLowerCase()
|
|
5
|
+
.replace(/[^a-z0-9/]+/g, "-")
|
|
6
|
+
.replace(/-+/g, "-")
|
|
7
|
+
.replace(/\/+/g, "/")
|
|
8
|
+
.replace(/\/-/g, "/")
|
|
9
|
+
.replace(/-\//g, "/")
|
|
10
|
+
.replace(/^[-/]+|[-/]+$/g, "")
|
|
11
|
+
|
|
12
|
+
return normalized
|
|
13
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type GitCommandResult = {
|
|
2
|
+
code: number
|
|
3
|
+
stdout: string
|
|
4
|
+
stderr: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function runGitRaw(cwd: string, args: string[]): Promise<GitCommandResult> {
|
|
8
|
+
const proc = Bun.spawn(["git", ...args], {
|
|
9
|
+
cwd,
|
|
10
|
+
stdout: "pipe",
|
|
11
|
+
stderr: "pipe",
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
15
|
+
new Response(proc.stdout).text(),
|
|
16
|
+
new Response(proc.stderr).text(),
|
|
17
|
+
proc.exited,
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
code,
|
|
22
|
+
stdout: stdout.trimEnd(),
|
|
23
|
+
stderr: stderr.trimEnd(),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ChangedFile } from "./git"
|
|
2
|
+
|
|
3
|
+
const STATUS_NAME: Record<string, string> = {
|
|
4
|
+
" ": "clean",
|
|
5
|
+
M: "modified",
|
|
6
|
+
A: "added",
|
|
7
|
+
D: "deleted",
|
|
8
|
+
R: "renamed",
|
|
9
|
+
C: "copied",
|
|
10
|
+
U: "unmerged",
|
|
11
|
+
"?": "untracked",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parseBranchLine(line: string): {
|
|
15
|
+
branch: string
|
|
16
|
+
upstream: string | null
|
|
17
|
+
ahead: number
|
|
18
|
+
behind: number
|
|
19
|
+
} {
|
|
20
|
+
if (!line.startsWith("##")) {
|
|
21
|
+
return { branch: "unknown", upstream: null, ahead: 0, behind: 0 }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const raw = line.slice(2).trim()
|
|
25
|
+
if (raw.startsWith("No commits yet on ")) {
|
|
26
|
+
return {
|
|
27
|
+
branch: raw.replace("No commits yet on ", "").trim(),
|
|
28
|
+
upstream: null,
|
|
29
|
+
ahead: 0,
|
|
30
|
+
behind: 0,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (raw.startsWith("HEAD")) {
|
|
35
|
+
return { branch: "detached", upstream: null, ahead: 0, behind: 0 }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const [localPart, trackingPart] = raw.split("...")
|
|
39
|
+
const branch = (localPart ?? "unknown").trim()
|
|
40
|
+
let upstream: string | null = null
|
|
41
|
+
let ahead = 0
|
|
42
|
+
let behind = 0
|
|
43
|
+
|
|
44
|
+
if (trackingPart) {
|
|
45
|
+
const match = trackingPart.match(/^([^\s]+)(?: \[(.+)\])?$/)
|
|
46
|
+
if (match) {
|
|
47
|
+
upstream = match[1] ?? null
|
|
48
|
+
const tracking = match[2] ?? ""
|
|
49
|
+
for (const token of tracking.split(",").map((item) => item.trim())) {
|
|
50
|
+
if (token.startsWith("ahead ")) {
|
|
51
|
+
ahead = Number(token.slice("ahead ".length)) || 0
|
|
52
|
+
} else if (token.startsWith("behind ")) {
|
|
53
|
+
behind = Number(token.slice("behind ".length)) || 0
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
upstream = trackingPart.trim() || null
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { branch, upstream, ahead, behind }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function parseChangedFiles(lines: string[]): ChangedFile[] {
|
|
65
|
+
return lines
|
|
66
|
+
.map((line) => {
|
|
67
|
+
const indexStatus = line[0] ?? " "
|
|
68
|
+
const worktreeStatus = line[1] ?? " "
|
|
69
|
+
const pathPart = line.slice(3).trim()
|
|
70
|
+
const path = pathPart.includes(" -> ") ? (pathPart.split(" -> ").pop() ?? "").trim() : pathPart
|
|
71
|
+
if (!path) return null
|
|
72
|
+
|
|
73
|
+
const untracked = indexStatus === "?" && worktreeStatus === "?"
|
|
74
|
+
const staged = !untracked && indexStatus !== " "
|
|
75
|
+
const unstaged = !untracked && worktreeStatus !== " "
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
path,
|
|
79
|
+
indexStatus,
|
|
80
|
+
worktreeStatus,
|
|
81
|
+
staged,
|
|
82
|
+
unstaged,
|
|
83
|
+
untracked,
|
|
84
|
+
statusLabel: buildStatusLabel(indexStatus, worktreeStatus),
|
|
85
|
+
} satisfies ChangedFile
|
|
86
|
+
})
|
|
87
|
+
.filter((file): file is ChangedFile => Boolean(file))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildStatusLabel(indexStatus: string, worktreeStatus: string): string {
|
|
91
|
+
if (indexStatus === "?" && worktreeStatus === "?") {
|
|
92
|
+
return "untracked"
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const parts: string[] = []
|
|
96
|
+
if (indexStatus !== " ") {
|
|
97
|
+
parts.push(`staged ${STATUS_NAME[indexStatus] ?? indexStatus}`)
|
|
98
|
+
}
|
|
99
|
+
if (worktreeStatus !== " ") {
|
|
100
|
+
parts.push(`unstaged ${STATUS_NAME[worktreeStatus] ?? worktreeStatus}`)
|
|
101
|
+
}
|
|
102
|
+
return parts.join(", ") || "clean"
|
|
103
|
+
}
|