@akiojin/gwt 4.6.0 → 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +38 -17
- package/dist/claude.js.map +1 -1
- package/dist/cli/ui/components/App.d.ts.map +1 -1
- package/dist/cli/ui/components/App.js +102 -1
- package/dist/cli/ui/components/App.js.map +1 -1
- package/dist/cli/ui/components/screens/BranchListScreen.d.ts +1 -0
- package/dist/cli/ui/components/screens/BranchListScreen.d.ts.map +1 -1
- package/dist/cli/ui/components/screens/BranchListScreen.js +6 -2
- package/dist/cli/ui/components/screens/BranchListScreen.js.map +1 -1
- package/dist/cli/ui/components/screens/LogDatePickerScreen.d.ts +10 -0
- package/dist/cli/ui/components/screens/LogDatePickerScreen.d.ts.map +1 -0
- package/dist/cli/ui/components/screens/LogDatePickerScreen.js +44 -0
- package/dist/cli/ui/components/screens/LogDatePickerScreen.js.map +1 -0
- package/dist/cli/ui/components/screens/LogDetailScreen.d.ts +14 -0
- package/dist/cli/ui/components/screens/LogDetailScreen.d.ts.map +1 -0
- package/dist/cli/ui/components/screens/LogDetailScreen.js +34 -0
- package/dist/cli/ui/components/screens/LogDetailScreen.js.map +1 -0
- package/dist/cli/ui/components/screens/LogListScreen.d.ts +19 -0
- package/dist/cli/ui/components/screens/LogListScreen.d.ts.map +1 -0
- package/dist/cli/ui/components/screens/LogListScreen.js +107 -0
- package/dist/cli/ui/components/screens/LogListScreen.js.map +1 -0
- package/dist/cli/ui/hooks/useGitData.d.ts.map +1 -1
- package/dist/cli/ui/hooks/useGitData.js +10 -3
- package/dist/cli/ui/hooks/useGitData.js.map +1 -1
- package/dist/cli/ui/types.d.ts +1 -1
- package/dist/cli/ui/types.d.ts.map +1 -1
- package/dist/cli/ui/utils/clipboard.d.ts +7 -0
- package/dist/cli/ui/utils/clipboard.d.ts.map +1 -0
- package/dist/cli/ui/utils/clipboard.js +21 -0
- package/dist/cli/ui/utils/clipboard.js.map +1 -0
- package/dist/gemini.d.ts.map +1 -1
- package/dist/gemini.js +12 -6
- package/dist/gemini.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/dist/logging/formatter.d.ts +15 -0
- package/dist/logging/formatter.d.ts.map +1 -0
- package/dist/logging/formatter.js +81 -0
- package/dist/logging/formatter.js.map +1 -0
- package/dist/logging/reader.d.ts +12 -0
- package/dist/logging/reader.d.ts.map +1 -0
- package/dist/logging/reader.js +63 -0
- package/dist/logging/reader.js.map +1 -0
- package/dist/worktree.d.ts.map +1 -1
- package/dist/worktree.js +57 -0
- package/dist/worktree.js.map +1 -1
- package/package.json +1 -1
- package/src/claude.ts +51 -22
- package/src/cli/ui/__tests__/components/App.shortcuts.test.tsx +7 -3
- package/src/cli/ui/__tests__/components/common/Select.test.tsx +17 -11
- package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +28 -2
- package/src/cli/ui/__tests__/components/screens/LogDetailScreen.test.tsx +57 -0
- package/src/cli/ui/__tests__/components/screens/LogListScreen.test.tsx +102 -0
- package/src/cli/ui/__tests__/hooks/useGitData.test.ts +197 -0
- package/src/cli/ui/__tests__/integration/edgeCases.test.tsx +19 -9
- package/src/cli/ui/__tests__/utils/clipboard.test.ts +65 -0
- package/src/cli/ui/components/App.tsx +177 -0
- package/src/cli/ui/components/screens/BranchListScreen.tsx +6 -1
- package/src/cli/ui/components/screens/LogDatePickerScreen.tsx +83 -0
- package/src/cli/ui/components/screens/LogDetailScreen.tsx +67 -0
- package/src/cli/ui/components/screens/LogListScreen.tsx +192 -0
- package/src/cli/ui/hooks/useGitData.ts +12 -3
- package/src/cli/ui/types.ts +3 -0
- package/src/cli/ui/utils/clipboard.ts +31 -0
- package/src/gemini.ts +14 -6
- package/src/index.ts +11 -2
- package/src/logging/formatter.ts +106 -0
- package/src/logging/reader.ts +76 -0
- package/src/worktree.ts +77 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import React, { useCallback, useMemo, useState } from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import stringWidth from "string-width";
|
|
4
|
+
import { Header } from "../parts/Header.js";
|
|
5
|
+
import { Footer } from "../parts/Footer.js";
|
|
6
|
+
import { Select } from "../common/Select.js";
|
|
7
|
+
import { useAppInput } from "../../hooks/useAppInput.js";
|
|
8
|
+
import { useTerminalSize } from "../../hooks/useTerminalSize.js";
|
|
9
|
+
import type { FormattedLogEntry } from "../../../../logging/formatter.js";
|
|
10
|
+
|
|
11
|
+
interface LogListItem {
|
|
12
|
+
label: string;
|
|
13
|
+
value: string;
|
|
14
|
+
entry: FormattedLogEntry;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LogListScreenProps {
|
|
18
|
+
entries: FormattedLogEntry[];
|
|
19
|
+
loading?: boolean;
|
|
20
|
+
error?: string | null;
|
|
21
|
+
onBack: () => void;
|
|
22
|
+
onSelect: (entry: FormattedLogEntry) => void;
|
|
23
|
+
onCopy: (entry: FormattedLogEntry) => void;
|
|
24
|
+
onPickDate?: () => void;
|
|
25
|
+
notification?: { message: string; tone: "success" | "error" } | null;
|
|
26
|
+
version?: string | null;
|
|
27
|
+
selectedDate?: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const truncateToWidth = (value: string, maxWidth: number): string => {
|
|
31
|
+
if (maxWidth <= 0) return "";
|
|
32
|
+
if (stringWidth(value) <= maxWidth) return value;
|
|
33
|
+
const ellipsis = "…";
|
|
34
|
+
const ellipsisWidth = stringWidth(ellipsis);
|
|
35
|
+
if (ellipsisWidth >= maxWidth) return ellipsis;
|
|
36
|
+
|
|
37
|
+
let result = "";
|
|
38
|
+
for (const char of Array.from(value)) {
|
|
39
|
+
if (stringWidth(result + char) + ellipsisWidth > maxWidth) {
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
result += char;
|
|
43
|
+
}
|
|
44
|
+
return result + ellipsis;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const padToWidth = (value: string, width: number): string => {
|
|
48
|
+
if (width <= 0) return "";
|
|
49
|
+
if (stringWidth(value) >= width) return value;
|
|
50
|
+
return value + " ".repeat(width - stringWidth(value));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function LogListScreen({
|
|
54
|
+
entries,
|
|
55
|
+
loading = false,
|
|
56
|
+
error = null,
|
|
57
|
+
onBack,
|
|
58
|
+
onSelect,
|
|
59
|
+
onCopy,
|
|
60
|
+
onPickDate,
|
|
61
|
+
notification,
|
|
62
|
+
version,
|
|
63
|
+
selectedDate,
|
|
64
|
+
}: LogListScreenProps) {
|
|
65
|
+
const { rows, columns } = useTerminalSize();
|
|
66
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
67
|
+
|
|
68
|
+
const maxLabelWidth = Math.max(10, columns - 2);
|
|
69
|
+
|
|
70
|
+
const items = useMemo<LogListItem[]>(
|
|
71
|
+
() =>
|
|
72
|
+
entries.map((entry) => ({
|
|
73
|
+
label: truncateToWidth(entry.summary, maxLabelWidth),
|
|
74
|
+
value: entry.id,
|
|
75
|
+
entry,
|
|
76
|
+
})),
|
|
77
|
+
[entries, maxLabelWidth],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const handleSelect = useCallback(
|
|
81
|
+
(item: LogListItem) => {
|
|
82
|
+
onSelect(item.entry);
|
|
83
|
+
},
|
|
84
|
+
[onSelect],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const handleCopy = useCallback(() => {
|
|
88
|
+
const entry = entries[selectedIndex];
|
|
89
|
+
if (entry) {
|
|
90
|
+
onCopy(entry);
|
|
91
|
+
}
|
|
92
|
+
}, [entries, selectedIndex, onCopy]);
|
|
93
|
+
|
|
94
|
+
useAppInput((input, key) => {
|
|
95
|
+
if (key.escape || input === "q") {
|
|
96
|
+
onBack();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (input === "c") {
|
|
101
|
+
handleCopy();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (input === "d" && onPickDate) {
|
|
106
|
+
onPickDate();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const renderItem = useCallback(
|
|
111
|
+
(item: LogListItem, isSelected: boolean) => {
|
|
112
|
+
if (!isSelected) {
|
|
113
|
+
return <Text>{item.label}</Text>;
|
|
114
|
+
}
|
|
115
|
+
const padded = padToWidth(item.label, maxLabelWidth);
|
|
116
|
+
const output = `\u001b[46m\u001b[30m${padded}\u001b[0m`;
|
|
117
|
+
return <Text>{output}</Text>;
|
|
118
|
+
},
|
|
119
|
+
[maxLabelWidth],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const headerLines = 2;
|
|
123
|
+
const statsLines = 1;
|
|
124
|
+
const emptyLine = 1;
|
|
125
|
+
const footerLines = 1;
|
|
126
|
+
const fixedLines = headerLines + statsLines + emptyLine + footerLines;
|
|
127
|
+
const contentHeight = rows - fixedLines;
|
|
128
|
+
const limit = Math.max(5, contentHeight);
|
|
129
|
+
|
|
130
|
+
const footerActions = [
|
|
131
|
+
{ key: "enter", description: "Detail" },
|
|
132
|
+
{ key: "c", description: "Copy" },
|
|
133
|
+
{ key: "d", description: "Date" },
|
|
134
|
+
{ key: "esc", description: "Back" },
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<Box flexDirection="column" height={rows}>
|
|
139
|
+
<Header title="gwt - Log Viewer" titleColor="cyan" version={version} />
|
|
140
|
+
|
|
141
|
+
{notification ? (
|
|
142
|
+
<Box marginTop={1}>
|
|
143
|
+
<Text color={notification.tone === "error" ? "red" : "green"}>
|
|
144
|
+
{notification.message}
|
|
145
|
+
</Text>
|
|
146
|
+
</Box>
|
|
147
|
+
) : null}
|
|
148
|
+
|
|
149
|
+
<Box marginTop={1}>
|
|
150
|
+
<Box marginRight={2}>
|
|
151
|
+
<Text>
|
|
152
|
+
Date: <Text bold>{selectedDate ?? "---"}</Text>
|
|
153
|
+
</Text>
|
|
154
|
+
</Box>
|
|
155
|
+
<Text>
|
|
156
|
+
Total: <Text bold>{entries.length}</Text>
|
|
157
|
+
</Text>
|
|
158
|
+
</Box>
|
|
159
|
+
|
|
160
|
+
<Box height={1} />
|
|
161
|
+
|
|
162
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
163
|
+
{loading ? (
|
|
164
|
+
<Box>
|
|
165
|
+
<Text dimColor>Loading logs...</Text>
|
|
166
|
+
</Box>
|
|
167
|
+
) : entries.length === 0 ? (
|
|
168
|
+
<Box>
|
|
169
|
+
<Text dimColor>ログがありません</Text>
|
|
170
|
+
</Box>
|
|
171
|
+
) : (
|
|
172
|
+
<Select
|
|
173
|
+
items={items}
|
|
174
|
+
onSelect={handleSelect}
|
|
175
|
+
limit={limit}
|
|
176
|
+
selectedIndex={selectedIndex}
|
|
177
|
+
onSelectedIndexChange={setSelectedIndex}
|
|
178
|
+
renderItem={renderItem}
|
|
179
|
+
/>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
{error ? (
|
|
183
|
+
<Box marginTop={1}>
|
|
184
|
+
<Text color="red">{error}</Text>
|
|
185
|
+
</Box>
|
|
186
|
+
) : null}
|
|
187
|
+
</Box>
|
|
188
|
+
|
|
189
|
+
<Footer actions={footerActions} />
|
|
190
|
+
</Box>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback } from "react";
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
2
|
import {
|
|
3
3
|
getAllBranches,
|
|
4
4
|
hasUnpushedCommitsInRepo,
|
|
@@ -80,7 +80,15 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
|
|
|
80
80
|
const [error, setError] = useState<Error | null>(null);
|
|
81
81
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
// キャッシュ機構: 初回ロード完了を追跡(useRef で再レンダリングを防ぐ)
|
|
84
|
+
const hasLoadedOnceRef = useRef(false);
|
|
85
|
+
|
|
86
|
+
const loadData = useCallback(async (forceRefresh = false) => {
|
|
87
|
+
// キャッシュがあり、強制リフレッシュでなければスキップ
|
|
88
|
+
if (hasLoadedOnceRef.current && !forceRefresh) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
84
92
|
setLoading(true);
|
|
85
93
|
setError(null);
|
|
86
94
|
|
|
@@ -295,6 +303,7 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
|
|
|
295
303
|
|
|
296
304
|
setBranches(enrichedBranches);
|
|
297
305
|
setLastUpdated(new Date());
|
|
306
|
+
hasLoadedOnceRef.current = true; // 初回ロード完了をマーク
|
|
298
307
|
} catch (err) {
|
|
299
308
|
setError(err instanceof Error ? err : new Error(String(err)));
|
|
300
309
|
setBranches([]);
|
|
@@ -305,7 +314,7 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
|
|
|
305
314
|
}, []);
|
|
306
315
|
|
|
307
316
|
const refresh = useCallback(() => {
|
|
308
|
-
loadData();
|
|
317
|
+
loadData(true); // forceRefresh = true で強制的にデータを再取得
|
|
309
318
|
}, [loadData]);
|
|
310
319
|
|
|
311
320
|
useEffect(() => {
|
package/src/cli/ui/types.ts
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { execa } from "execa";
|
|
2
|
+
|
|
3
|
+
export interface ClipboardOptions {
|
|
4
|
+
platform?: NodeJS.Platform;
|
|
5
|
+
execa?: typeof execa;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function copyToClipboard(
|
|
9
|
+
text: string,
|
|
10
|
+
options: ClipboardOptions = {},
|
|
11
|
+
): Promise<void> {
|
|
12
|
+
const runner = options.execa ?? execa;
|
|
13
|
+
const platform = options.platform ?? process.platform;
|
|
14
|
+
|
|
15
|
+
if (platform === "win32") {
|
|
16
|
+
await runner("cmd", ["/c", "clip"], { input: text, windowsHide: true });
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (platform === "darwin") {
|
|
21
|
+
await runner("pbcopy", [], { input: text });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
await runner("xclip", ["-selection", "clipboard"], { input: text });
|
|
27
|
+
return;
|
|
28
|
+
} catch {
|
|
29
|
+
await runner("xsel", ["--clipboard", "--input"], { input: text });
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/gemini.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
getTerminalStreams,
|
|
7
7
|
resetTerminalModes,
|
|
8
8
|
} from "./utils/terminal.js";
|
|
9
|
-
import {
|
|
9
|
+
import { findCommand } from "./utils/command.js";
|
|
10
10
|
import { findLatestGeminiSessionId } from "./utils/session.js";
|
|
11
11
|
|
|
12
12
|
const GEMINI_CLI_PACKAGE = "@google/gemini-cli@latest";
|
|
@@ -152,7 +152,7 @@ export async function launchGeminiCLI(
|
|
|
152
152
|
const childStdio = createChildStdio();
|
|
153
153
|
|
|
154
154
|
// Auto-detect locally installed gemini command
|
|
155
|
-
const
|
|
155
|
+
const geminiLookup = await findCommand("gemini");
|
|
156
156
|
|
|
157
157
|
// Preserve TTY for interactive UI (colors/width) by inheriting stdout/stderr.
|
|
158
158
|
// Session ID is determined via file-based detection after exit.
|
|
@@ -184,11 +184,12 @@ export async function launchGeminiCLI(
|
|
|
184
184
|
await execChild(child);
|
|
185
185
|
};
|
|
186
186
|
|
|
187
|
-
if (
|
|
187
|
+
if (geminiLookup.source === "installed" && geminiLookup.path) {
|
|
188
|
+
// Use the full path to avoid PATH issues in non-interactive shells
|
|
188
189
|
console.log(
|
|
189
190
|
chalk.green(" ✨ Using locally installed gemini command"),
|
|
190
191
|
);
|
|
191
|
-
return await run(
|
|
192
|
+
return await run(geminiLookup.path, runArgs);
|
|
192
193
|
}
|
|
193
194
|
console.log(
|
|
194
195
|
chalk.cyan(" 🔄 Falling back to bunx @google/gemini-cli@latest"),
|
|
@@ -200,7 +201,12 @@ export async function launchGeminiCLI(
|
|
|
200
201
|
);
|
|
201
202
|
console.log(chalk.yellow(" npm install -g @google/gemini-cli"));
|
|
202
203
|
console.log("");
|
|
203
|
-
|
|
204
|
+
const shouldSkipDelay =
|
|
205
|
+
typeof process !== "undefined" &&
|
|
206
|
+
(process.env?.NODE_ENV === "test" || Boolean(process.env?.VITEST));
|
|
207
|
+
if (!shouldSkipDelay) {
|
|
208
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
209
|
+
}
|
|
204
210
|
return await run("bunx", [GEMINI_CLI_PACKAGE, ...runArgs]);
|
|
205
211
|
};
|
|
206
212
|
|
|
@@ -263,7 +269,9 @@ export async function launchGeminiCLI(
|
|
|
263
269
|
|
|
264
270
|
return capturedSessionId ? { sessionId: capturedSessionId } : {};
|
|
265
271
|
} catch (error: unknown) {
|
|
266
|
-
const
|
|
272
|
+
const geminiCheck = await findCommand("gemini");
|
|
273
|
+
const hasLocalGemini =
|
|
274
|
+
geminiCheck.source === "installed" && geminiCheck.path !== null;
|
|
267
275
|
let errorMessage: string;
|
|
268
276
|
const err = error as NodeJS.ErrnoException;
|
|
269
277
|
|
package/src/index.ts
CHANGED
|
@@ -768,11 +768,13 @@ export async function handleAIToolWorkflow(
|
|
|
768
768
|
lastSessionId: finalSessionId,
|
|
769
769
|
});
|
|
770
770
|
|
|
771
|
+
let uncommittedExists = false;
|
|
771
772
|
try {
|
|
772
773
|
const [hasUncommitted, hasUnpushed] = await Promise.all([
|
|
773
774
|
hasUncommittedChanges(worktreePath),
|
|
774
775
|
hasUnpushedCommits(worktreePath, branch),
|
|
775
776
|
]);
|
|
777
|
+
uncommittedExists = hasUncommitted;
|
|
776
778
|
|
|
777
779
|
if (hasUncommitted) {
|
|
778
780
|
const uncommittedCount = await getUncommittedChangesCount(worktreePath);
|
|
@@ -807,8 +809,15 @@ export async function handleAIToolWorkflow(
|
|
|
807
809
|
const details = error instanceof Error ? error.message : String(error);
|
|
808
810
|
printWarning(`Failed to check git status after session: ${details}`);
|
|
809
811
|
}
|
|
810
|
-
|
|
811
|
-
|
|
812
|
+
|
|
813
|
+
if (uncommittedExists) {
|
|
814
|
+
await waitForEnter("Press Enter to return to the main menu...");
|
|
815
|
+
} else {
|
|
816
|
+
// Small buffer before returning to branch list to avoid abrupt screen swap
|
|
817
|
+
await new Promise((resolve) =>
|
|
818
|
+
setTimeout(resolve, POST_SESSION_DELAY_MS),
|
|
819
|
+
);
|
|
820
|
+
}
|
|
812
821
|
printInfo("Session completed successfully. Returning to main menu...");
|
|
813
822
|
return;
|
|
814
823
|
} catch (error) {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export interface FormattedLogEntry {
|
|
2
|
+
id: string;
|
|
3
|
+
raw: Record<string, unknown>;
|
|
4
|
+
timestamp: number | null;
|
|
5
|
+
timeLabel: string;
|
|
6
|
+
levelLabel: string;
|
|
7
|
+
category: string;
|
|
8
|
+
message: string;
|
|
9
|
+
summary: string;
|
|
10
|
+
json: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const LEVEL_LABELS: Record<number, string> = {
|
|
14
|
+
10: "TRACE",
|
|
15
|
+
20: "DEBUG",
|
|
16
|
+
30: "INFO",
|
|
17
|
+
40: "WARN",
|
|
18
|
+
50: "ERROR",
|
|
19
|
+
60: "FATAL",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const formatTimeLabel = (
|
|
23
|
+
value: unknown,
|
|
24
|
+
): { label: string; timestamp: number | null } => {
|
|
25
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
26
|
+
const date = new Date(value);
|
|
27
|
+
if (!Number.isNaN(date.getTime())) {
|
|
28
|
+
const hours = String(date.getHours()).padStart(2, "0");
|
|
29
|
+
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
30
|
+
const seconds = String(date.getSeconds()).padStart(2, "0");
|
|
31
|
+
return {
|
|
32
|
+
label: `${hours}:${minutes}:${seconds}`,
|
|
33
|
+
timestamp: date.getTime(),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { label: "--:--:--", timestamp: null };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const formatLevelLabel = (value: unknown): string => {
|
|
42
|
+
if (typeof value === "number") {
|
|
43
|
+
return LEVEL_LABELS[value] ?? `LEVEL-${value}`;
|
|
44
|
+
}
|
|
45
|
+
if (typeof value === "string") {
|
|
46
|
+
return value.toUpperCase();
|
|
47
|
+
}
|
|
48
|
+
return "UNKNOWN";
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const resolveMessage = (entry: Record<string, unknown>): string => {
|
|
52
|
+
if (typeof entry.msg === "string") {
|
|
53
|
+
return entry.msg;
|
|
54
|
+
}
|
|
55
|
+
if (typeof entry.message === "string") {
|
|
56
|
+
return entry.message;
|
|
57
|
+
}
|
|
58
|
+
if (entry.msg !== undefined) {
|
|
59
|
+
return String(entry.msg);
|
|
60
|
+
}
|
|
61
|
+
return "";
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function parseLogLines(
|
|
65
|
+
lines: string[],
|
|
66
|
+
options: { limit?: number } = {},
|
|
67
|
+
): FormattedLogEntry[] {
|
|
68
|
+
const entries: FormattedLogEntry[] = [];
|
|
69
|
+
|
|
70
|
+
lines.forEach((line, index) => {
|
|
71
|
+
if (!line.trim()) return;
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(line) as Record<string, unknown>;
|
|
74
|
+
const { label: timeLabel, timestamp } = formatTimeLabel(parsed.time);
|
|
75
|
+
const levelLabel = formatLevelLabel(parsed.level);
|
|
76
|
+
const category =
|
|
77
|
+
typeof parsed.category === "string" ? parsed.category : "unknown";
|
|
78
|
+
const message = resolveMessage(parsed);
|
|
79
|
+
const summary =
|
|
80
|
+
`[${timeLabel}] [${levelLabel}] [${category}] ${message}`.trim();
|
|
81
|
+
const json = JSON.stringify(parsed, null, 2);
|
|
82
|
+
const id = `${timestamp ?? "unknown"}-${index}`;
|
|
83
|
+
|
|
84
|
+
entries.push({
|
|
85
|
+
id,
|
|
86
|
+
raw: parsed,
|
|
87
|
+
timestamp,
|
|
88
|
+
timeLabel,
|
|
89
|
+
levelLabel,
|
|
90
|
+
category,
|
|
91
|
+
message,
|
|
92
|
+
summary,
|
|
93
|
+
json,
|
|
94
|
+
});
|
|
95
|
+
} catch {
|
|
96
|
+
// Skip malformed JSON lines
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const limit = options.limit ?? 100;
|
|
101
|
+
if (entries.length <= limit) {
|
|
102
|
+
return [...entries].reverse();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return entries.slice(-limit).reverse();
|
|
106
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { formatDate } from "./logger.js";
|
|
5
|
+
|
|
6
|
+
export interface LogFileInfo {
|
|
7
|
+
date: string;
|
|
8
|
+
path: string;
|
|
9
|
+
mtimeMs: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const LOG_FILENAME_PATTERN = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
13
|
+
|
|
14
|
+
export function resolveLogDir(cwd: string = process.cwd()): string {
|
|
15
|
+
const cwdBase = path.basename(cwd) || "workspace";
|
|
16
|
+
return path.join(os.homedir(), ".gwt", "logs", cwdBase);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildLogFilePath(logDir: string, date: string): string {
|
|
20
|
+
return path.join(logDir, `${date}.jsonl`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getTodayLogDate(): string {
|
|
24
|
+
return formatDate(new Date());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function readLogFileLines(filePath: string): Promise<string[]> {
|
|
28
|
+
try {
|
|
29
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
30
|
+
return content.split("\n").filter(Boolean);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
const err = error as NodeJS.ErrnoException;
|
|
33
|
+
if (err.code === "ENOENT") {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function listLogFiles(logDir: string): Promise<LogFileInfo[]> {
|
|
41
|
+
try {
|
|
42
|
+
const entries = await fs.readdir(logDir, { withFileTypes: true });
|
|
43
|
+
const files: LogFileInfo[] = [];
|
|
44
|
+
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
if (!entry.isFile()) continue;
|
|
47
|
+
if (!LOG_FILENAME_PATTERN.test(entry.name)) continue;
|
|
48
|
+
|
|
49
|
+
const date = entry.name.replace(/\.jsonl$/, "");
|
|
50
|
+
const fullPath = path.join(logDir, entry.name);
|
|
51
|
+
try {
|
|
52
|
+
const stat = await fs.stat(fullPath);
|
|
53
|
+
files.push({ date, path: fullPath, mtimeMs: stat.mtimeMs });
|
|
54
|
+
} catch {
|
|
55
|
+
// Ignore stat errors per-file
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return files.sort((a, b) => b.date.localeCompare(a.date));
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const err = error as NodeJS.ErrnoException;
|
|
62
|
+
if (err.code === "ENOENT") {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function listRecentLogFiles(
|
|
70
|
+
logDir: string,
|
|
71
|
+
days = 7,
|
|
72
|
+
): Promise<LogFileInfo[]> {
|
|
73
|
+
const files = await listLogFiles(logDir);
|
|
74
|
+
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
75
|
+
return files.filter((file) => file.mtimeMs >= cutoff);
|
|
76
|
+
}
|
package/src/worktree.ts
CHANGED
|
@@ -289,6 +289,73 @@ export async function generateAlternativeWorktreePath(
|
|
|
289
289
|
return alternativePath;
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
+
type StaleWorktreeAssessment = {
|
|
293
|
+
status: "absent" | "registered" | "stale" | "unknown";
|
|
294
|
+
reason?: string;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
async function assessStaleWorktreeDirectory(
|
|
298
|
+
targetPath: string,
|
|
299
|
+
): Promise<StaleWorktreeAssessment> {
|
|
300
|
+
const fsSync = await import("node:fs");
|
|
301
|
+
|
|
302
|
+
if (!fsSync.existsSync(targetPath)) {
|
|
303
|
+
return { status: "absent" };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const registered = await checkWorktreePathConflict(targetPath);
|
|
307
|
+
if (registered) {
|
|
308
|
+
return { status: "registered" };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const gitMetaPath = path.join(targetPath, ".git");
|
|
312
|
+
if (!fsSync.existsSync(gitMetaPath)) {
|
|
313
|
+
return { status: "stale", reason: "missing .git" };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let gitMetaStat: Awaited<ReturnType<typeof fs.lstat>>;
|
|
317
|
+
try {
|
|
318
|
+
gitMetaStat = await fs.lstat(gitMetaPath);
|
|
319
|
+
} catch {
|
|
320
|
+
return { status: "unknown", reason: "unable to stat .git" };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (gitMetaStat.isDirectory()) {
|
|
324
|
+
return { status: "unknown", reason: ".git is a directory" };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (!gitMetaStat.isFile()) {
|
|
328
|
+
return { status: "unknown", reason: ".git is not a file" };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let gitMetaContents = "";
|
|
332
|
+
try {
|
|
333
|
+
gitMetaContents = await fs.readFile(gitMetaPath, "utf8");
|
|
334
|
+
} catch {
|
|
335
|
+
return { status: "unknown", reason: "unable to read .git" };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const gitdirMatch = gitMetaContents.match(/^\s*gitdir:\s*(.+)\s*$/m);
|
|
339
|
+
if (!gitdirMatch) {
|
|
340
|
+
return { status: "unknown", reason: "missing gitdir entry" };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const rawGitdir = gitdirMatch[1]?.trim();
|
|
344
|
+
if (!rawGitdir) {
|
|
345
|
+
return { status: "unknown", reason: "empty gitdir entry" };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const gitdirPath = path.isAbsolute(rawGitdir)
|
|
349
|
+
? rawGitdir
|
|
350
|
+
: path.resolve(targetPath, rawGitdir);
|
|
351
|
+
|
|
352
|
+
if (!fsSync.existsSync(gitdirPath)) {
|
|
353
|
+
return { status: "stale", reason: "missing gitdir path" };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return { status: "unknown", reason: "gitdir exists" };
|
|
357
|
+
}
|
|
358
|
+
|
|
292
359
|
/**
|
|
293
360
|
* 新しいworktreeを作成
|
|
294
361
|
* @param {WorktreeConfig} config - worktreeの設定
|
|
@@ -302,6 +369,16 @@ export async function createWorktree(config: WorktreeConfig): Promise<void> {
|
|
|
302
369
|
}
|
|
303
370
|
|
|
304
371
|
try {
|
|
372
|
+
const staleness = await assessStaleWorktreeDirectory(config.worktreePath);
|
|
373
|
+
if (staleness.status === "stale") {
|
|
374
|
+
await fs.rm(config.worktreePath, { recursive: true, force: true });
|
|
375
|
+
} else if (staleness.status === "unknown") {
|
|
376
|
+
const reason = staleness.reason ? ` (${staleness.reason})` : "";
|
|
377
|
+
throw new WorktreeError(
|
|
378
|
+
`Worktree path already exists but is not registered as a git worktree, and stale status could not be confirmed${reason}. Remove the directory manually and retry: ${config.worktreePath}`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
305
382
|
const worktreeParentDir = path.dirname(config.worktreePath);
|
|
306
383
|
|
|
307
384
|
try {
|