@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.
Files changed (55) hide show
  1. package/README.ja.md +1 -1
  2. package/README.md +2 -2
  3. package/dist/cli/ui/components/screens/BranchListScreen.d.ts.map +1 -1
  4. package/dist/cli/ui/components/screens/BranchListScreen.js +5 -1
  5. package/dist/cli/ui/components/screens/BranchListScreen.js.map +1 -1
  6. package/dist/cli/ui/hooks/useGitData.d.ts.map +1 -1
  7. package/dist/cli/ui/hooks/useGitData.js +27 -7
  8. package/dist/cli/ui/hooks/useGitData.js.map +1 -1
  9. package/dist/cli/ui/types.d.ts +4 -0
  10. package/dist/cli/ui/types.d.ts.map +1 -1
  11. package/dist/cli/ui/utils/branchFormatter.d.ts.map +1 -1
  12. package/dist/cli/ui/utils/branchFormatter.js +47 -0
  13. package/dist/cli/ui/utils/branchFormatter.js.map +1 -1
  14. package/dist/client/assets/{index-DxHGLTNq.js → index-CNWntAlF.js} +15 -15
  15. package/dist/client/index.html +1 -1
  16. package/dist/config/index.d.ts +17 -0
  17. package/dist/config/index.d.ts.map +1 -1
  18. package/dist/config/index.js +66 -1
  19. package/dist/config/index.js.map +1 -1
  20. package/dist/git.d.ts.map +1 -1
  21. package/dist/git.js +114 -30
  22. package/dist/git.js.map +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +3 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/types/api.d.ts +11 -0
  27. package/dist/types/api.d.ts.map +1 -1
  28. package/dist/web/client/src/pages/BranchDetailPage.d.ts.map +1 -1
  29. package/dist/web/client/src/pages/BranchDetailPage.js +55 -0
  30. package/dist/web/client/src/pages/BranchDetailPage.js.map +1 -1
  31. package/dist/web/server/routes/sessions.d.ts.map +1 -1
  32. package/dist/web/server/routes/sessions.js +35 -0
  33. package/dist/web/server/routes/sessions.js.map +1 -1
  34. package/dist/web/server/services/branches.d.ts.map +1 -1
  35. package/dist/web/server/services/branches.js +13 -2
  36. package/dist/web/server/services/branches.js.map +1 -1
  37. package/dist/worktree.d.ts.map +1 -1
  38. package/dist/worktree.js +3 -1
  39. package/dist/worktree.js.map +1 -1
  40. package/package.json +1 -8
  41. package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +53 -0
  42. package/src/cli/ui/__tests__/integration/navigation.test.tsx +5 -0
  43. package/src/cli/ui/__tests__/utils/branchFormatter.test.ts +37 -0
  44. package/src/cli/ui/components/screens/BranchListScreen.tsx +6 -1
  45. package/src/cli/ui/hooks/useGitData.ts +27 -7
  46. package/src/cli/ui/types.ts +5 -0
  47. package/src/cli/ui/utils/branchFormatter.ts +47 -0
  48. package/src/config/index.ts +87 -1
  49. package/src/git.ts +114 -30
  50. package/src/index.ts +3 -0
  51. package/src/types/api.ts +12 -0
  52. package/src/web/client/src/pages/BranchDetailPage.tsx +69 -1
  53. package/src/web/server/routes/sessions.ts +39 -0
  54. package/src/web/server/services/branches.ts +18 -2
  55. 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 timestampText = formatLatestCommit(item.latestCommitTimestamp);
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 [branchesData, worktreesData] = await Promise.all([
44
- getAllBranches(),
45
- listAdditionalWorktrees(),
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
  );
@@ -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
 
@@ -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
- await writeFile(sessionPath, JSON.stringify(sessionData, null, 2), "utf-8");
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
- // .gitディレクトリの親ディレクトリがリポジトリルート
70
- const path = await import("node:path");
71
- const repoRoot = path.dirname(gitDir);
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
- return repoRoot;
79
- } catch (error) {
80
- throw new GitError("Failed to get repository root", error);
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 { stdout } = await execa("git", ["branch", "--show-current"]);
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
- return null;
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 } = await execa("git", [
139
- "branch",
140
- "--format=%(refname:short)",
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 (error) {
160
- throw new GitError("Failed to get local branches", error);
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 } = await execa("git", [
168
- "branch",
169
- "-r",
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 (error) {
191
- throw new GitError("Failed to get remote branches", error);
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
  */