@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.
Files changed (71) hide show
  1. package/dist/claude.d.ts.map +1 -1
  2. package/dist/claude.js +38 -17
  3. package/dist/claude.js.map +1 -1
  4. package/dist/cli/ui/components/App.d.ts.map +1 -1
  5. package/dist/cli/ui/components/App.js +102 -1
  6. package/dist/cli/ui/components/App.js.map +1 -1
  7. package/dist/cli/ui/components/screens/BranchListScreen.d.ts +1 -0
  8. package/dist/cli/ui/components/screens/BranchListScreen.d.ts.map +1 -1
  9. package/dist/cli/ui/components/screens/BranchListScreen.js +6 -2
  10. package/dist/cli/ui/components/screens/BranchListScreen.js.map +1 -1
  11. package/dist/cli/ui/components/screens/LogDatePickerScreen.d.ts +10 -0
  12. package/dist/cli/ui/components/screens/LogDatePickerScreen.d.ts.map +1 -0
  13. package/dist/cli/ui/components/screens/LogDatePickerScreen.js +44 -0
  14. package/dist/cli/ui/components/screens/LogDatePickerScreen.js.map +1 -0
  15. package/dist/cli/ui/components/screens/LogDetailScreen.d.ts +14 -0
  16. package/dist/cli/ui/components/screens/LogDetailScreen.d.ts.map +1 -0
  17. package/dist/cli/ui/components/screens/LogDetailScreen.js +34 -0
  18. package/dist/cli/ui/components/screens/LogDetailScreen.js.map +1 -0
  19. package/dist/cli/ui/components/screens/LogListScreen.d.ts +19 -0
  20. package/dist/cli/ui/components/screens/LogListScreen.d.ts.map +1 -0
  21. package/dist/cli/ui/components/screens/LogListScreen.js +107 -0
  22. package/dist/cli/ui/components/screens/LogListScreen.js.map +1 -0
  23. package/dist/cli/ui/hooks/useGitData.d.ts.map +1 -1
  24. package/dist/cli/ui/hooks/useGitData.js +10 -3
  25. package/dist/cli/ui/hooks/useGitData.js.map +1 -1
  26. package/dist/cli/ui/types.d.ts +1 -1
  27. package/dist/cli/ui/types.d.ts.map +1 -1
  28. package/dist/cli/ui/utils/clipboard.d.ts +7 -0
  29. package/dist/cli/ui/utils/clipboard.d.ts.map +1 -0
  30. package/dist/cli/ui/utils/clipboard.js +21 -0
  31. package/dist/cli/ui/utils/clipboard.js.map +1 -0
  32. package/dist/gemini.d.ts.map +1 -1
  33. package/dist/gemini.js +12 -6
  34. package/dist/gemini.js.map +1 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +9 -2
  37. package/dist/index.js.map +1 -1
  38. package/dist/logging/formatter.d.ts +15 -0
  39. package/dist/logging/formatter.d.ts.map +1 -0
  40. package/dist/logging/formatter.js +81 -0
  41. package/dist/logging/formatter.js.map +1 -0
  42. package/dist/logging/reader.d.ts +12 -0
  43. package/dist/logging/reader.d.ts.map +1 -0
  44. package/dist/logging/reader.js +63 -0
  45. package/dist/logging/reader.js.map +1 -0
  46. package/dist/worktree.d.ts.map +1 -1
  47. package/dist/worktree.js +57 -0
  48. package/dist/worktree.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/claude.ts +51 -22
  51. package/src/cli/ui/__tests__/components/App.shortcuts.test.tsx +7 -3
  52. package/src/cli/ui/__tests__/components/common/Select.test.tsx +17 -11
  53. package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +28 -2
  54. package/src/cli/ui/__tests__/components/screens/LogDetailScreen.test.tsx +57 -0
  55. package/src/cli/ui/__tests__/components/screens/LogListScreen.test.tsx +102 -0
  56. package/src/cli/ui/__tests__/hooks/useGitData.test.ts +197 -0
  57. package/src/cli/ui/__tests__/integration/edgeCases.test.tsx +19 -9
  58. package/src/cli/ui/__tests__/utils/clipboard.test.ts +65 -0
  59. package/src/cli/ui/components/App.tsx +177 -0
  60. package/src/cli/ui/components/screens/BranchListScreen.tsx +6 -1
  61. package/src/cli/ui/components/screens/LogDatePickerScreen.tsx +83 -0
  62. package/src/cli/ui/components/screens/LogDetailScreen.tsx +67 -0
  63. package/src/cli/ui/components/screens/LogListScreen.tsx +192 -0
  64. package/src/cli/ui/hooks/useGitData.ts +12 -3
  65. package/src/cli/ui/types.ts +3 -0
  66. package/src/cli/ui/utils/clipboard.ts +31 -0
  67. package/src/gemini.ts +14 -6
  68. package/src/index.ts +11 -2
  69. package/src/logging/formatter.ts +106 -0
  70. package/src/logging/reader.ts +76 -0
  71. 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
- const loadData = useCallback(async () => {
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(() => {
@@ -188,6 +188,9 @@ export interface GitHubPRResponse {
188
188
  */
189
189
  export type ScreenType =
190
190
  | "branch-list"
191
+ | "log-list"
192
+ | "log-detail"
193
+ | "log-date-picker"
191
194
  | "branch-creator"
192
195
  | "branch-action-selector"
193
196
  | "branch-quick-start"
@@ -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 { isCommandAvailable } from "./utils/command.js";
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 hasLocalGemini = await isCommandAvailable("gemini");
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 (hasLocalGemini) {
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("gemini", runArgs);
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
- await new Promise((resolve) => setTimeout(resolve, 2000));
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 hasLocalGemini = await isCommandAvailable("gemini");
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
- // Small buffer before returning to branch list to avoid abrupt screen swap
811
- await new Promise((resolve) => setTimeout(resolve, POST_SESSION_DELAY_MS));
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 {