@akiojin/gwt 2.6.1 → 2.8.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/README.ja.md +1 -1
- package/README.md +2 -2
- package/dist/cli/ui/components/screens/BranchListScreen.d.ts.map +1 -1
- package/dist/cli/ui/components/screens/BranchListScreen.js +5 -1
- package/dist/cli/ui/components/screens/BranchListScreen.js.map +1 -1
- package/dist/cli/ui/hooks/useGitData.d.ts.map +1 -1
- package/dist/cli/ui/hooks/useGitData.js +27 -7
- package/dist/cli/ui/hooks/useGitData.js.map +1 -1
- package/dist/cli/ui/types.d.ts +4 -0
- package/dist/cli/ui/types.d.ts.map +1 -1
- package/dist/cli/ui/utils/branchFormatter.d.ts.map +1 -1
- package/dist/cli/ui/utils/branchFormatter.js +47 -0
- package/dist/cli/ui/utils/branchFormatter.js.map +1 -1
- package/dist/client/assets/{index-DxHGLTNq.js → index-CNWntAlF.js} +15 -15
- package/dist/client/index.html +1 -1
- package/dist/config/index.d.ts +17 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +66 -1
- package/dist/config/index.js.map +1 -1
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +114 -30
- package/dist/git.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/types/api.d.ts +11 -0
- package/dist/types/api.d.ts.map +1 -1
- package/dist/web/client/src/pages/BranchDetailPage.d.ts.map +1 -1
- package/dist/web/client/src/pages/BranchDetailPage.js +55 -0
- package/dist/web/client/src/pages/BranchDetailPage.js.map +1 -1
- package/dist/web/server/routes/sessions.d.ts.map +1 -1
- package/dist/web/server/routes/sessions.js +35 -0
- package/dist/web/server/routes/sessions.js.map +1 -1
- package/dist/web/server/services/branches.d.ts.map +1 -1
- package/dist/web/server/services/branches.js +13 -2
- package/dist/web/server/services/branches.js.map +1 -1
- package/dist/worktree.d.ts.map +1 -1
- package/dist/worktree.js +3 -1
- package/dist/worktree.js.map +1 -1
- package/package.json +1 -8
- package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +53 -0
- package/src/cli/ui/__tests__/integration/navigation.test.tsx +5 -0
- package/src/cli/ui/__tests__/utils/branchFormatter.test.ts +37 -0
- package/src/cli/ui/components/screens/BranchListScreen.tsx +6 -1
- package/src/cli/ui/hooks/useGitData.ts +27 -7
- package/src/cli/ui/types.ts +5 -0
- package/src/cli/ui/utils/branchFormatter.ts +47 -0
- package/src/config/index.ts +87 -1
- package/src/git.ts +114 -30
- package/src/index.ts +3 -0
- package/src/types/api.ts +12 -0
- package/src/web/client/src/pages/BranchDetailPage.tsx +69 -1
- package/src/web/server/routes/sessions.ts +39 -0
- package/src/web/server/services/branches.ts +18 -2
- package/src/worktree.ts +7 -1
|
@@ -25,6 +25,7 @@ vi.mock("../../../../git.ts", () => ({
|
|
|
25
25
|
getAllBranches: vi.fn(),
|
|
26
26
|
getRepositoryRoot: vi.fn(async () => "/repo"),
|
|
27
27
|
deleteBranch: vi.fn(async () => undefined),
|
|
28
|
+
fetchAllRemotes: vi.fn(async () => undefined),
|
|
28
29
|
}));
|
|
29
30
|
|
|
30
31
|
const { mockIsProtectedBranchName, mockSwitchToProtectedBranch } = vi.hoisted(
|
|
@@ -60,6 +61,7 @@ import {
|
|
|
60
61
|
getAllBranches,
|
|
61
62
|
getRepositoryRoot,
|
|
62
63
|
deleteBranch,
|
|
64
|
+
fetchAllRemotes,
|
|
63
65
|
} from "../../../../git.ts";
|
|
64
66
|
import {
|
|
65
67
|
listAdditionalWorktrees,
|
|
@@ -72,6 +74,7 @@ import {
|
|
|
72
74
|
const mockedGetAllBranches = getAllBranches as Mock;
|
|
73
75
|
const mockedGetRepositoryRoot = getRepositoryRoot as Mock;
|
|
74
76
|
const mockedDeleteBranch = deleteBranch as Mock;
|
|
77
|
+
const mockedFetchAllRemotes = fetchAllRemotes as Mock;
|
|
75
78
|
const mockedListAdditionalWorktrees = listAdditionalWorktrees as Mock;
|
|
76
79
|
const mockedCreateWorktree = createWorktree as Mock;
|
|
77
80
|
const mockedGenerateWorktreePath = generateWorktreePath as Mock;
|
|
@@ -95,6 +98,7 @@ describe("Navigation Integration Tests", () => {
|
|
|
95
98
|
mockedListAdditionalWorktrees.mockReset();
|
|
96
99
|
mockedGetRepositoryRoot.mockReset();
|
|
97
100
|
mockedDeleteBranch.mockReset();
|
|
101
|
+
mockedFetchAllRemotes.mockReset();
|
|
98
102
|
mockedCreateWorktree.mockReset();
|
|
99
103
|
mockedGenerateWorktreePath.mockReset();
|
|
100
104
|
mockedGetMergedPRWorktrees.mockReset();
|
|
@@ -267,6 +271,7 @@ describe("Protected Branch Navigation (T103)", () => {
|
|
|
267
271
|
mockedListAdditionalWorktrees.mockReset();
|
|
268
272
|
mockedGetRepositoryRoot.mockReset();
|
|
269
273
|
mockedDeleteBranch.mockReset();
|
|
274
|
+
mockedFetchAllRemotes.mockReset();
|
|
270
275
|
mockedCreateWorktree.mockReset();
|
|
271
276
|
mockedGenerateWorktreePath.mockReset();
|
|
272
277
|
mockedGetMergedPRWorktrees.mockReset();
|
|
@@ -45,6 +45,30 @@ describe("branchFormatter", () => {
|
|
|
45
45
|
expect(result.value).toBe("feature/new-ui");
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
it("should include last tool usage label when present", () => {
|
|
49
|
+
const branchInfo: BranchInfo = {
|
|
50
|
+
name: "feature/tool",
|
|
51
|
+
type: "local",
|
|
52
|
+
branchType: "feature",
|
|
53
|
+
isCurrent: false,
|
|
54
|
+
lastToolUsage: {
|
|
55
|
+
branch: "feature/tool",
|
|
56
|
+
worktreePath: "/tmp/wt",
|
|
57
|
+
toolId: "codex-cli",
|
|
58
|
+
toolLabel: "Codex",
|
|
59
|
+
mode: "normal",
|
|
60
|
+
timestamp: Date.UTC(2025, 10, 26, 14, 3), // 2025-11-26 14:03 UTC
|
|
61
|
+
model: "gpt-5.1-codex",
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const result = formatBranchItem(branchInfo);
|
|
66
|
+
|
|
67
|
+
expect(result.lastToolUsageLabel).toContain("Codex");
|
|
68
|
+
expect(result.lastToolUsageLabel).toContain("New");
|
|
69
|
+
expect(result.lastToolUsageLabel).toContain("2025-11-26");
|
|
70
|
+
});
|
|
71
|
+
|
|
48
72
|
it("should format a bugfix branch", () => {
|
|
49
73
|
const branchInfo: BranchInfo = {
|
|
50
74
|
name: "bugfix/security-issue",
|
|
@@ -59,6 +83,19 @@ describe("branchFormatter", () => {
|
|
|
59
83
|
expect(result.label).toContain("bugfix/security-issue");
|
|
60
84
|
});
|
|
61
85
|
|
|
86
|
+
it("should set lastToolUsageLabel to null when no usage exists", () => {
|
|
87
|
+
const branchInfo: BranchInfo = {
|
|
88
|
+
name: "feature/no-usage",
|
|
89
|
+
type: "local",
|
|
90
|
+
branchType: "feature",
|
|
91
|
+
isCurrent: false,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const result = formatBranchItem(branchInfo);
|
|
95
|
+
|
|
96
|
+
expect(result.lastToolUsageLabel).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
62
99
|
it("should format a hotfix branch", () => {
|
|
63
100
|
const branchInfo: BranchInfo = {
|
|
64
101
|
name: "hotfix/critical-bug",
|
|
@@ -279,7 +279,12 @@ export function BranchListScreen({
|
|
|
279
279
|
// Use a small safety margin to avoid terminal-dependent wrapping
|
|
280
280
|
const columns = Math.max(20, context.columns - 1);
|
|
281
281
|
const arrow = isSelected ? ">" : " ";
|
|
282
|
-
const
|
|
282
|
+
const commitText = formatLatestCommit(item.latestCommitTimestamp);
|
|
283
|
+
const infoText =
|
|
284
|
+
item.lastToolUsage && item.lastToolUsageLabel
|
|
285
|
+
? item.lastToolUsageLabel
|
|
286
|
+
: `${chalk.gray("Unknown")}${commitText !== "---" ? ` | ${commitText}` : ""}`;
|
|
287
|
+
const timestampText = infoText;
|
|
283
288
|
const timestampWidth = stringWidth(timestampText);
|
|
284
289
|
|
|
285
290
|
const indicatorInfo = cleanupUI?.indicators?.[item.name];
|
|
@@ -3,11 +3,13 @@ import {
|
|
|
3
3
|
getAllBranches,
|
|
4
4
|
hasUnpushedCommitsInRepo,
|
|
5
5
|
getRepositoryRoot,
|
|
6
|
+
fetchAllRemotes,
|
|
6
7
|
} from "../../../git.js";
|
|
7
8
|
import { listAdditionalWorktrees } from "../../../worktree.js";
|
|
8
9
|
import { getPullRequestByBranch } from "../../../github.js";
|
|
9
10
|
import type { BranchInfo, WorktreeInfo } from "../types.js";
|
|
10
11
|
import type { WorktreeInfo as GitWorktreeInfo } from "../../../worktree.js";
|
|
12
|
+
import { getLastToolUsageMap } from "../../../config/index.js";
|
|
11
13
|
|
|
12
14
|
export interface UseGitDataOptions {
|
|
13
15
|
enableAutoRefresh?: boolean;
|
|
@@ -40,10 +42,28 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
|
|
|
40
42
|
setError(null);
|
|
41
43
|
|
|
42
44
|
try {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
const repoRoot = await getRepositoryRoot();
|
|
46
|
+
|
|
47
|
+
// リモートブランチの最新情報を取得(失敗してもローカル表示は継続)
|
|
48
|
+
try {
|
|
49
|
+
await fetchAllRemotes({ cwd: repoRoot });
|
|
50
|
+
} catch (fetchError) {
|
|
51
|
+
if (process.env.DEBUG) {
|
|
52
|
+
console.warn("Failed to fetch remote branches", fetchError);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const branchesData = await getAllBranches();
|
|
57
|
+
let worktreesData: GitWorktreeInfo[] = [];
|
|
58
|
+
try {
|
|
59
|
+
worktreesData = await listAdditionalWorktrees();
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (process.env.DEBUG) {
|
|
62
|
+
console.error("Failed to list additional worktrees:", err);
|
|
63
|
+
}
|
|
64
|
+
worktreesData = [];
|
|
65
|
+
}
|
|
66
|
+
const lastToolUsageMap = await getLastToolUsageMap(repoRoot);
|
|
47
67
|
|
|
48
68
|
// Store worktrees separately
|
|
49
69
|
setWorktrees(worktreesData);
|
|
@@ -61,9 +81,6 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
|
|
|
61
81
|
worktreeMap.set(worktree.branch, uiWorktreeInfo);
|
|
62
82
|
}
|
|
63
83
|
|
|
64
|
-
// Get repository root for unpushed commits check
|
|
65
|
-
const repoRoot = await getRepositoryRoot();
|
|
66
|
-
|
|
67
84
|
// Attach worktree info and check unpushed/PR status for local branches
|
|
68
85
|
const enrichedBranches = await Promise.all(
|
|
69
86
|
branchesData.map(async (branch) => {
|
|
@@ -108,6 +125,9 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
|
|
|
108
125
|
},
|
|
109
126
|
}
|
|
110
127
|
: {}),
|
|
128
|
+
...(lastToolUsageMap.get(branch.name)
|
|
129
|
+
? { lastToolUsage: lastToolUsageMap.get(branch.name) ?? null }
|
|
130
|
+
: {}),
|
|
111
131
|
};
|
|
112
132
|
}),
|
|
113
133
|
);
|
package/src/cli/ui/types.ts
CHANGED
|
@@ -5,8 +5,11 @@ export interface WorktreeInfo {
|
|
|
5
5
|
isAccessible?: boolean;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
import type { LastToolUsage } from "../../types/api.js";
|
|
9
|
+
|
|
8
10
|
export type AITool = string;
|
|
9
11
|
export type InferenceLevel = "low" | "medium" | "high" | "xhigh";
|
|
12
|
+
export type { LastToolUsage } from "../../types/api.js";
|
|
10
13
|
|
|
11
14
|
export interface ModelOption {
|
|
12
15
|
id: string;
|
|
@@ -35,6 +38,7 @@ export interface BranchInfo {
|
|
|
35
38
|
openPR?: { number: number; title: string };
|
|
36
39
|
mergedPR?: { number: number; mergedAt: string };
|
|
37
40
|
latestCommitTimestamp?: number;
|
|
41
|
+
lastToolUsage?: LastToolUsage | null;
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
export interface BranchChoice {
|
|
@@ -204,6 +208,7 @@ export interface BranchItem extends BranchInfo {
|
|
|
204
208
|
hasChanges: boolean;
|
|
205
209
|
label: string;
|
|
206
210
|
value: string;
|
|
211
|
+
lastToolUsageLabel?: string | null;
|
|
207
212
|
}
|
|
208
213
|
|
|
209
214
|
/**
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
WorktreeInfo,
|
|
7
7
|
} from "../types.js";
|
|
8
8
|
import stringWidth from "string-width";
|
|
9
|
+
import chalk from "chalk";
|
|
9
10
|
|
|
10
11
|
// Icon mappings
|
|
11
12
|
const branchIcons: Record<BranchType, string> = {
|
|
@@ -69,6 +70,51 @@ export interface FormatOptions {
|
|
|
69
70
|
hasChanges?: boolean;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
function mapToolLabel(toolId: string, toolLabel?: string): string {
|
|
74
|
+
if (toolId === "claude-code") return "Claude";
|
|
75
|
+
if (toolId === "codex-cli") return "Codex";
|
|
76
|
+
if (toolId === "gemini-cli") return "Gemini";
|
|
77
|
+
if (toolId === "qwen-cli") return "Qwen";
|
|
78
|
+
if (toolLabel) return toolLabel;
|
|
79
|
+
return "Custom";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function mapModeLabel(
|
|
83
|
+
mode?: "normal" | "continue" | "resume" | null,
|
|
84
|
+
): string | null {
|
|
85
|
+
if (mode === "normal") return "New";
|
|
86
|
+
if (mode === "continue") return "Continue";
|
|
87
|
+
if (mode === "resume") return "Resume";
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function formatTimestamp(ts: number): string {
|
|
92
|
+
const date = new Date(ts);
|
|
93
|
+
const year = date.getFullYear();
|
|
94
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
95
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
96
|
+
const hours = String(date.getHours()).padStart(2, "0");
|
|
97
|
+
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
98
|
+
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildLastToolUsageLabel(
|
|
102
|
+
usage?: BranchInfo["lastToolUsage"] | null,
|
|
103
|
+
): string | null {
|
|
104
|
+
if (!usage) return null;
|
|
105
|
+
const toolText = mapToolLabel(usage.toolId, usage.toolLabel);
|
|
106
|
+
const modeText = mapModeLabel(usage.mode);
|
|
107
|
+
const timestamp = usage.timestamp ? formatTimestamp(usage.timestamp) : null;
|
|
108
|
+
const parts = [toolText];
|
|
109
|
+
if (modeText) {
|
|
110
|
+
parts.push(modeText);
|
|
111
|
+
}
|
|
112
|
+
if (timestamp) {
|
|
113
|
+
parts.push(timestamp);
|
|
114
|
+
}
|
|
115
|
+
return parts.join(" | ");
|
|
116
|
+
}
|
|
117
|
+
|
|
72
118
|
/**
|
|
73
119
|
* Converts BranchInfo to BranchItem with display properties
|
|
74
120
|
*/
|
|
@@ -171,6 +217,7 @@ export function formatBranchItem(
|
|
|
171
217
|
hasChanges,
|
|
172
218
|
label,
|
|
173
219
|
value: branch.name,
|
|
220
|
+
lastToolUsageLabel: buildLastToolUsageLabel(branch.lastToolUsage),
|
|
174
221
|
};
|
|
175
222
|
}
|
|
176
223
|
|
package/src/config/index.ts
CHANGED
|
@@ -16,6 +16,20 @@ export interface SessionData {
|
|
|
16
16
|
lastUsedTool?: string;
|
|
17
17
|
timestamp: number;
|
|
18
18
|
repositoryRoot: string;
|
|
19
|
+
mode?: "normal" | "continue" | "resume";
|
|
20
|
+
model?: string | null;
|
|
21
|
+
toolLabel?: string | null;
|
|
22
|
+
history?: ToolSessionEntry[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ToolSessionEntry {
|
|
26
|
+
branch: string;
|
|
27
|
+
worktreePath: string | null;
|
|
28
|
+
toolId: string;
|
|
29
|
+
toolLabel: string;
|
|
30
|
+
mode?: "normal" | "continue" | "resume" | null;
|
|
31
|
+
model?: string | null;
|
|
32
|
+
timestamp: number;
|
|
19
33
|
}
|
|
20
34
|
|
|
21
35
|
const DEFAULT_CONFIG: AppConfig = {
|
|
@@ -104,7 +118,38 @@ export async function saveSession(sessionData: SessionData): Promise<void> {
|
|
|
104
118
|
// ディレクトリを作成
|
|
105
119
|
await mkdir(sessionDir, { recursive: true });
|
|
106
120
|
|
|
107
|
-
|
|
121
|
+
// 既存履歴を読み込み(後方互換のため失敗は無視)
|
|
122
|
+
let existingHistory: ToolSessionEntry[] = [];
|
|
123
|
+
try {
|
|
124
|
+
const currentContent = await readFile(sessionPath, "utf-8");
|
|
125
|
+
const parsed = JSON.parse(currentContent) as SessionData;
|
|
126
|
+
if (Array.isArray(parsed.history)) {
|
|
127
|
+
existingHistory = parsed.history;
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
// ignore
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 新しい履歴エントリを追加(branch/worktree/toolが揃っている場合のみ)
|
|
134
|
+
if (sessionData.lastBranch && sessionData.lastWorktreePath) {
|
|
135
|
+
const entry: ToolSessionEntry = {
|
|
136
|
+
branch: sessionData.lastBranch,
|
|
137
|
+
worktreePath: sessionData.lastWorktreePath,
|
|
138
|
+
toolId: sessionData.lastUsedTool ?? "unknown",
|
|
139
|
+
toolLabel: sessionData.toolLabel ?? sessionData.lastUsedTool ?? "Custom",
|
|
140
|
+
mode: sessionData.mode ?? null,
|
|
141
|
+
model: sessionData.model ?? null,
|
|
142
|
+
timestamp: sessionData.timestamp,
|
|
143
|
+
};
|
|
144
|
+
existingHistory = [...existingHistory, entry].slice(-100); // keep latest 100
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const payload: SessionData = {
|
|
148
|
+
...sessionData,
|
|
149
|
+
history: existingHistory,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
await writeFile(sessionPath, JSON.stringify(payload, null, 2), "utf-8");
|
|
108
153
|
} catch (error) {
|
|
109
154
|
// セッション保存の失敗は致命的ではないため、エラーをログに出力するのみ
|
|
110
155
|
if (process.env.DEBUG_SESSION) {
|
|
@@ -192,3 +237,44 @@ export async function getAllSessions(): Promise<SessionData[]> {
|
|
|
192
237
|
return [];
|
|
193
238
|
}
|
|
194
239
|
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 各ブランチの最新ツール利用履歴を取得
|
|
243
|
+
*/
|
|
244
|
+
export async function getLastToolUsageMap(
|
|
245
|
+
repositoryRoot: string,
|
|
246
|
+
): Promise<Map<string, ToolSessionEntry>> {
|
|
247
|
+
const map = new Map<string, ToolSessionEntry>();
|
|
248
|
+
try {
|
|
249
|
+
const sessionPath = getSessionFilePath(repositoryRoot);
|
|
250
|
+
const content = await readFile(sessionPath, "utf-8");
|
|
251
|
+
const parsed = JSON.parse(content) as SessionData;
|
|
252
|
+
|
|
253
|
+
const history: ToolSessionEntry[] = Array.isArray(parsed.history)
|
|
254
|
+
? parsed.history
|
|
255
|
+
: [];
|
|
256
|
+
|
|
257
|
+
// 後方互換: historyが無い場合はlastUsedToolを1件扱い
|
|
258
|
+
if (!history.length && parsed.lastBranch && parsed.lastWorktreePath) {
|
|
259
|
+
history.push({
|
|
260
|
+
branch: parsed.lastBranch,
|
|
261
|
+
worktreePath: parsed.lastWorktreePath,
|
|
262
|
+
toolId: parsed.lastUsedTool ?? "unknown",
|
|
263
|
+
toolLabel: parsed.toolLabel ?? parsed.lastUsedTool ?? "Custom",
|
|
264
|
+
mode: parsed.mode ?? null,
|
|
265
|
+
model: parsed.model ?? null,
|
|
266
|
+
timestamp: parsed.timestamp ?? Date.now(),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
for (const entry of history) {
|
|
271
|
+
const existing = map.get(entry.branch);
|
|
272
|
+
if (!existing || existing.timestamp < entry.timestamp) {
|
|
273
|
+
map.set(entry.branch, entry);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
} catch {
|
|
277
|
+
// セッションファイルが無い/壊れている場合は空のMapを返す
|
|
278
|
+
}
|
|
279
|
+
return map;
|
|
280
|
+
}
|
package/src/git.ts
CHANGED
|
@@ -58,27 +58,103 @@ export async function isGitRepository(): Promise<boolean> {
|
|
|
58
58
|
* @throws {GitError} リポジトリルートの取得に失敗した場合
|
|
59
59
|
*/
|
|
60
60
|
export async function getRepositoryRoot(): Promise<string> {
|
|
61
|
+
const fs = await import("node:fs");
|
|
62
|
+
const path = await import("node:path");
|
|
63
|
+
|
|
64
|
+
const toExistingDir = (p: string): string => {
|
|
65
|
+
let current = path.resolve(p);
|
|
66
|
+
while (!fs.existsSync(current) && path.dirname(current) !== current) {
|
|
67
|
+
current = path.dirname(current);
|
|
68
|
+
}
|
|
69
|
+
if (!fs.existsSync(current)) {
|
|
70
|
+
return current;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const stat = fs.statSync(current);
|
|
74
|
+
return stat.isDirectory() ? current : path.dirname(current);
|
|
75
|
+
} catch {
|
|
76
|
+
return current;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// 1) show-toplevel を最優先
|
|
81
|
+
try {
|
|
82
|
+
const { stdout } = await execa("git", ["rev-parse", "--show-toplevel"]);
|
|
83
|
+
const top = stdout.trim();
|
|
84
|
+
if (top) {
|
|
85
|
+
const marker = `${path.sep}.worktrees${path.sep}`;
|
|
86
|
+
const idx = top.indexOf(marker);
|
|
87
|
+
if (idx >= 0) {
|
|
88
|
+
// /repo/.worktrees/<name> → repo root = /repo
|
|
89
|
+
const parent = top.slice(0, idx);
|
|
90
|
+
const upTwo = path.resolve(top, "..", "..");
|
|
91
|
+
if (fs.existsSync(parent)) return toExistingDir(parent);
|
|
92
|
+
if (fs.existsSync(upTwo)) return toExistingDir(upTwo);
|
|
93
|
+
}
|
|
94
|
+
if (fs.existsSync(top)) {
|
|
95
|
+
return toExistingDir(top);
|
|
96
|
+
}
|
|
97
|
+
return toExistingDir(path.resolve(top, ".."));
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// fallback
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 2) git-common-dir 経由
|
|
61
104
|
try {
|
|
62
|
-
// git rev-parse --git-common-dirを使用してメインリポジトリの.gitディレクトリを取得
|
|
63
105
|
const { stdout: gitCommonDir } = await execa("git", [
|
|
64
106
|
"rev-parse",
|
|
65
107
|
"--git-common-dir",
|
|
66
108
|
]);
|
|
67
|
-
const gitDir = gitCommonDir.trim();
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
// 相対パスが返された場合(.gitなど)は、現在のディレクトリからの相対パスとして解決
|
|
74
|
-
if (!path.isAbsolute(repoRoot)) {
|
|
75
|
-
return path.resolve(repoRoot);
|
|
109
|
+
const gitDir = path.resolve(gitCommonDir.trim());
|
|
110
|
+
const parts = gitDir.split(path.sep);
|
|
111
|
+
const idxWorktrees = parts.lastIndexOf("worktrees");
|
|
112
|
+
if (idxWorktrees > 0) {
|
|
113
|
+
const repo = parts.slice(0, idxWorktrees).join(path.sep);
|
|
114
|
+
if (fs.existsSync(repo)) return toExistingDir(repo);
|
|
76
115
|
}
|
|
116
|
+
const idxGit = parts.lastIndexOf(".git");
|
|
117
|
+
if (idxGit > 0) {
|
|
118
|
+
const repo = parts.slice(0, idxGit).join(path.sep);
|
|
119
|
+
if (fs.existsSync(repo)) return toExistingDir(repo);
|
|
120
|
+
}
|
|
121
|
+
return toExistingDir(path.dirname(gitDir));
|
|
122
|
+
} catch {
|
|
123
|
+
// fallback
|
|
124
|
+
}
|
|
77
125
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
126
|
+
// 3) .git を上に辿って探す
|
|
127
|
+
let current = process.cwd();
|
|
128
|
+
const root = path.parse(current).root;
|
|
129
|
+
while (true) {
|
|
130
|
+
const candidate = path.join(current, ".git");
|
|
131
|
+
if (fs.existsSync(candidate)) {
|
|
132
|
+
if (fs.statSync(candidate).isDirectory()) {
|
|
133
|
+
return toExistingDir(current);
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const content = fs.readFileSync(candidate, "utf-8");
|
|
137
|
+
const match = content.match(/gitdir:\s*(.+)\s*/i);
|
|
138
|
+
const gitDirMatch = match?.[1];
|
|
139
|
+
if (gitDirMatch) {
|
|
140
|
+
const gitDirPath = path.resolve(current, gitDirMatch.trim());
|
|
141
|
+
const parts = gitDirPath.split(path.sep);
|
|
142
|
+
const idxWT = parts.lastIndexOf("worktrees");
|
|
143
|
+
if (idxWT > 0) {
|
|
144
|
+
const repo = parts.slice(0, idxWT).join(path.sep);
|
|
145
|
+
return toExistingDir(repo);
|
|
146
|
+
}
|
|
147
|
+
return toExistingDir(path.dirname(gitDirPath));
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// ignore and continue
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (current === root) break;
|
|
154
|
+
current = path.dirname(current);
|
|
81
155
|
}
|
|
156
|
+
|
|
157
|
+
throw new GitError("Failed to get repository root");
|
|
82
158
|
}
|
|
83
159
|
|
|
84
160
|
/**
|
|
@@ -97,22 +173,33 @@ export async function getWorktreeRoot(): Promise<string> {
|
|
|
97
173
|
|
|
98
174
|
export async function getCurrentBranch(): Promise<string | null> {
|
|
99
175
|
try {
|
|
100
|
-
const
|
|
176
|
+
const repoRoot = await getRepositoryRoot();
|
|
177
|
+
const { stdout } = await execa(
|
|
178
|
+
"git",
|
|
179
|
+
["branch", "--show-current"],
|
|
180
|
+
{ cwd: repoRoot },
|
|
181
|
+
);
|
|
101
182
|
return stdout.trim() || null;
|
|
102
183
|
} catch {
|
|
103
|
-
|
|
184
|
+
try {
|
|
185
|
+
const { stdout } = await execa("git", ["branch", "--show-current"]);
|
|
186
|
+
return stdout.trim() || null;
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
104
190
|
}
|
|
105
191
|
}
|
|
106
192
|
|
|
107
193
|
async function getBranchCommitTimestamps(
|
|
108
194
|
refs: string[],
|
|
195
|
+
cwd?: string,
|
|
109
196
|
): Promise<Map<string, number>> {
|
|
110
197
|
try {
|
|
111
|
-
const { stdout } = await execa("git", [
|
|
198
|
+
const { stdout = "" } = (await execa("git", [
|
|
112
199
|
"for-each-ref",
|
|
113
200
|
"--format=%(refname:short)%00%(committerdate:unix)",
|
|
114
201
|
...refs,
|
|
115
|
-
]);
|
|
202
|
+
], cwd ? { cwd } : undefined)) ?? { stdout: "" };
|
|
116
203
|
|
|
117
204
|
const map = new Map<string, number>();
|
|
118
205
|
|
|
@@ -135,10 +222,9 @@ async function getBranchCommitTimestamps(
|
|
|
135
222
|
export async function getLocalBranches(): Promise<BranchInfo[]> {
|
|
136
223
|
try {
|
|
137
224
|
const commitMap = await getBranchCommitTimestamps(["refs/heads"]);
|
|
138
|
-
const { stdout
|
|
139
|
-
"branch",
|
|
140
|
-
|
|
141
|
-
]);
|
|
225
|
+
const { stdout = "" } =
|
|
226
|
+
(await execa("git", ["branch", "--format=%(refname:short)"])) ??
|
|
227
|
+
{ stdout: "" };
|
|
142
228
|
return stdout
|
|
143
229
|
.split("\n")
|
|
144
230
|
.filter((line) => line.trim())
|
|
@@ -156,19 +242,17 @@ export async function getLocalBranches(): Promise<BranchInfo[]> {
|
|
|
156
242
|
: {}),
|
|
157
243
|
} satisfies BranchInfo;
|
|
158
244
|
});
|
|
159
|
-
} catch (
|
|
160
|
-
throw new GitError("Failed to get local branches",
|
|
245
|
+
} catch (primaryError) {
|
|
246
|
+
throw new GitError("Failed to get local branches", primaryError);
|
|
161
247
|
}
|
|
162
248
|
}
|
|
163
249
|
|
|
164
250
|
export async function getRemoteBranches(): Promise<BranchInfo[]> {
|
|
165
251
|
try {
|
|
166
252
|
const commitMap = await getBranchCommitTimestamps(["refs/remotes"]);
|
|
167
|
-
const { stdout
|
|
168
|
-
"branch",
|
|
169
|
-
"
|
|
170
|
-
"--format=%(refname:short)",
|
|
171
|
-
]);
|
|
253
|
+
const { stdout = "" } =
|
|
254
|
+
(await execa("git", ["branch", "-r", "--format=%(refname:short)"])) ??
|
|
255
|
+
{ stdout: "" };
|
|
172
256
|
return stdout
|
|
173
257
|
.split("\n")
|
|
174
258
|
.filter((line) => line.trim() && !line.includes("HEAD"))
|
|
@@ -187,8 +271,8 @@ export async function getRemoteBranches(): Promise<BranchInfo[]> {
|
|
|
187
271
|
: {}),
|
|
188
272
|
} satisfies BranchInfo;
|
|
189
273
|
});
|
|
190
|
-
} catch (
|
|
191
|
-
throw new GitError("Failed to get remote branches",
|
|
274
|
+
} catch (primaryError) {
|
|
275
|
+
throw new GitError("Failed to get remote branches", primaryError);
|
|
192
276
|
}
|
|
193
277
|
}
|
|
194
278
|
|
package/src/index.ts
CHANGED
|
@@ -670,6 +670,9 @@ export async function handleAIToolWorkflow(
|
|
|
670
670
|
lastWorktreePath: worktreePath,
|
|
671
671
|
lastBranch: branch,
|
|
672
672
|
lastUsedTool: tool,
|
|
673
|
+
toolLabel: toolConfig.displayName ?? tool,
|
|
674
|
+
mode,
|
|
675
|
+
model: model ?? null,
|
|
673
676
|
timestamp: Date.now(),
|
|
674
677
|
repositoryRoot: repoRoot,
|
|
675
678
|
});
|
package/src/types/api.ts
CHANGED
|
@@ -30,6 +30,7 @@ export interface Branch {
|
|
|
30
30
|
state: "open" | "merged" | "closed";
|
|
31
31
|
mergedAt?: string | null;
|
|
32
32
|
} | null;
|
|
33
|
+
lastToolUsage?: LastToolUsage | null;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
/**
|
|
@@ -46,6 +47,7 @@ export interface Worktree {
|
|
|
46
47
|
lastAccessedAt?: string | null; // ISO8601
|
|
47
48
|
divergence?: Branch["divergence"];
|
|
48
49
|
prInfo?: Branch["prInfo"];
|
|
50
|
+
lastToolUsage?: LastToolUsage | null;
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
/**
|
|
@@ -102,6 +104,16 @@ export interface CustomAITool {
|
|
|
102
104
|
updatedAt?: string | null; // ISO8601
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
export interface LastToolUsage {
|
|
108
|
+
branch: string;
|
|
109
|
+
worktreePath: string | null;
|
|
110
|
+
toolId: string;
|
|
111
|
+
toolLabel: string;
|
|
112
|
+
mode?: "normal" | "continue" | "resume" | null;
|
|
113
|
+
model?: string | null;
|
|
114
|
+
timestamp: number; // epoch millis
|
|
115
|
+
}
|
|
116
|
+
|
|
105
117
|
/**
|
|
106
118
|
* REST API Response wrappers
|
|
107
119
|
*/
|