gitforest 0.1.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 (256) hide show
  1. package/.bunignore +7 -0
  2. package/.github/workflows/ci.yml +73 -0
  3. package/CLAUDE.md +111 -0
  4. package/CONTRIBUTING.md +145 -0
  5. package/README.md +168 -0
  6. package/bun.lock +267 -0
  7. package/bunfig.toml +15 -0
  8. package/cli +0 -0
  9. package/config/gitforest.example.yaml +94 -0
  10. package/docs/ai/IMPROVEMENT_PLAN.md +341 -0
  11. package/docs/ai/VERIFICATION_REPORT.md +87 -0
  12. package/docs/ai/architecture.md +169 -0
  13. package/docs/ai/checks/check-2025-12-02-tests.md +40 -0
  14. package/docs/ai/checks/check-2025-12-02.md +55 -0
  15. package/docs/ai/checks/test-verification-report.md +85 -0
  16. package/docs/ai/implementation-guide.md +776 -0
  17. package/docs/ai/research/gitty-codebase-analysis.md +221 -0
  18. package/docs/ai/tickets/GENERAL-sitrep.md +30 -0
  19. package/docs/ai/tickets/TASK-database-tests-sitrep.md +25 -0
  20. package/docs/ai/tickets/TASK-deprecated-functions-sitrep.md +28 -0
  21. package/docs/ai/tickets/TASK-detail-modal-sitrep.md +28 -0
  22. package/docs/ai/tickets/TASK-filter-overlay-sitrep.md +24 -0
  23. package/docs/ai/tickets/TASK-github-service-sitrep.md +32 -0
  24. package/docs/ai/tickets/TASK-github-token-sitrep.md +51 -0
  25. package/docs/ai/tickets/TASK-hascommits-sitrep.md +35 -0
  26. package/docs/ai/tickets/TASK-keybindings-sitrep.md +26 -0
  27. package/docs/ai/tickets/TASK-layout-sitrep.md +25 -0
  28. package/docs/ai/tickets/TASK-markdown-sitrep.md +28 -0
  29. package/docs/ai/tickets/TASK-project-item-sitrep.md +79 -0
  30. package/docs/ai/tickets/TASK-sitrep.md +28 -0
  31. package/docs/ai/tickets/TASK-state-sitrep.md +26 -0
  32. package/docs/ai/tickets/TASK-types-sitrep.md +25 -0
  33. package/docs/ai/tickets/TASK-unified-item-fix-sitrep.md +26 -0
  34. package/docs/ai/tickets/TKT-001-sitrep.md +24 -0
  35. package/docs/ai/tickets/TKT-002-sitrep.md +25 -0
  36. package/docs/ai/tickets/TKT-003-git-service-refactoring-complete.md +46 -0
  37. package/docs/ai/tickets/TKT-003-git-service-refactoring-plan.md +135 -0
  38. package/docs/ai/tickets/TKT-003-sitrep.md +26 -0
  39. package/docs/ai/tickets/TKT-004-sitrep.md +27 -0
  40. package/docs/ai/tickets/TKT-005-sitrep.md +25 -0
  41. package/docs/ai/tickets/TKT-006-sitrep.md +26 -0
  42. package/docs/ai/tickets/TKT-007-sitrep.md +30 -0
  43. package/docs/ai/tickets/TKT-008-sitrep.md +32 -0
  44. package/docs/ai/tickets/TKT-009-sitrep.md +27 -0
  45. package/docs/ai/tickets/TKT-010-sitrep.md +27 -0
  46. package/docs/ai/tickets/TKT-011-sitrep.md +26 -0
  47. package/docs/ai/tickets/TKT-012-sitrep.md +25 -0
  48. package/docs/ai/tickets/sitreps/TASK-actions-sitrep.md +28 -0
  49. package/docs/ai/tickets/sitreps/TASK-actions-test-sitrep.md +25 -0
  50. package/docs/ai/tickets/sitreps/TASK-app-integration-sitrep.md +25 -0
  51. package/docs/ai/tickets/sitreps/TASK-background-fetch-sitrep.md +24 -0
  52. package/docs/ai/tickets/sitreps/TASK-background-fetch-test-sitrep.md +29 -0
  53. package/docs/ai/tickets/sitreps/TASK-batch-tests-sitrep.md +29 -0
  54. package/docs/ai/tickets/sitreps/TASK-bun-test-sitrep.md +26 -0
  55. package/docs/ai/tickets/sitreps/TASK-cache-tests-sitrep.md +30 -0
  56. package/docs/ai/tickets/sitreps/TASK-cli-tests-sitrep.md +28 -0
  57. package/docs/ai/tickets/sitreps/TASK-clone-error-handling-sitrep.md +26 -0
  58. package/docs/ai/tickets/sitreps/TASK-commands-tests-sitrep.md +25 -0
  59. package/docs/ai/tickets/sitreps/TASK-component-tests-1-sitrep.md +30 -0
  60. package/docs/ai/tickets/sitreps/TASK-configloader-tests-sitrep.md +25 -0
  61. package/docs/ai/tickets/sitreps/TASK-confirm-dialog-test-sitrep.md +29 -0
  62. package/docs/ai/tickets/sitreps/TASK-coverage-sitrep.md +95 -0
  63. package/docs/ai/tickets/sitreps/TASK-database-tests-summary.md +61 -0
  64. package/docs/ai/tickets/sitreps/TASK-error-boundary-sitrep.md +30 -0
  65. package/docs/ai/tickets/sitreps/TASK-error-tests-sitrep.md +27 -0
  66. package/docs/ai/tickets/sitreps/TASK-errors-tests-sitrep.md +25 -0
  67. package/docs/ai/tickets/sitreps/TASK-extract-reducer-sitrep.md +27 -0
  68. package/docs/ai/tickets/sitreps/TASK-filter-overlay-test-sitrep.md +25 -0
  69. package/docs/ai/tickets/sitreps/TASK-final-verification-sitrep.md +28 -0
  70. package/docs/ai/tickets/sitreps/TASK-fix-all-tests-sitrep.md +25 -0
  71. package/docs/ai/tickets/sitreps/TASK-fix-hooks-sitrep.md +26 -0
  72. package/docs/ai/tickets/sitreps/TASK-fix-remaining-tests-sitrep.md +25 -0
  73. package/docs/ai/tickets/sitreps/TASK-fix-test-failures-sitrep.md +26 -0
  74. package/docs/ai/tickets/sitreps/TASK-fix-tests-sitrep.md +24 -0
  75. package/docs/ai/tickets/sitreps/TASK-formatters-tests-sitrep.md +25 -0
  76. package/docs/ai/tickets/sitreps/TASK-git-timeouts-sitrep.md +29 -0
  77. package/docs/ai/tickets/sitreps/TASK-github-cache-test-sitrep.md +25 -0
  78. package/docs/ai/tickets/sitreps/TASK-githubcli-tests-sitrep.md +24 -0
  79. package/docs/ai/tickets/sitreps/TASK-gitstatus-tests-sitrep.md +24 -0
  80. package/docs/ai/tickets/sitreps/TASK-hooks-isolation-sitrep.md +27 -0
  81. package/docs/ai/tickets/sitreps/TASK-keybindings-tests-sitrep.md +25 -0
  82. package/docs/ai/tickets/sitreps/TASK-layout-tests-sitrep.md +25 -0
  83. package/docs/ai/tickets/sitreps/TASK-mock-factories-sitrep.md +27 -0
  84. package/docs/ai/tickets/sitreps/TASK-modal-tests-sitrep.md +32 -0
  85. package/docs/ai/tickets/sitreps/TASK-processbatch-fix-sitrep.md +27 -0
  86. package/docs/ai/tickets/sitreps/TASK-projectlist-tests-sitrep.md +30 -0
  87. package/docs/ai/tickets/sitreps/TASK-projectutils-tests-sitrep.md +25 -0
  88. package/docs/ai/tickets/sitreps/TASK-scanner-tests-sitrep.md +29 -0
  89. package/docs/ai/tickets/sitreps/TASK-select-all-sitrep.md +25 -0
  90. package/docs/ai/tickets/sitreps/TASK-shell-error-handling-sitrep.md +27 -0
  91. package/docs/ai/tickets/sitreps/TASK-store-tests-sitrep.md +25 -0
  92. package/docs/ai/tickets/sitreps/TASK-test-fixes-sitrep.md +26 -0
  93. package/docs/ai/tickets/sitreps/TASK-test-summary-sitrep.md +25 -0
  94. package/docs/ai/tickets/sitreps/TASK-test-verification-sitrep.md +27 -0
  95. package/docs/ai/tickets/sitreps/TASK-testsuite-sitrep.md +75 -0
  96. package/docs/ai/tickets/sitreps/TASK-unified-reducer-tests-sitrep.md +29 -0
  97. package/docs/ai/tickets/sitreps/TASK-unified-repos-test-sitrep.md +29 -0
  98. package/docs/ai/tickets/sitreps/TASK-unified-tests-sitrep.md +25 -0
  99. package/docs/ai/tickets/sitreps/TASK-useprojects-tests-sitrep.md +25 -0
  100. package/docs/ai/tickets/sitreps/TASK-utility-tests-sitrep.md +32 -0
  101. package/docs/ai/tickets/sitreps/TKT-003-git-service-refactoring-sitrep.md +64 -0
  102. package/docs/ai/tkt-001-fix-database-error.md +217 -0
  103. package/docs/ai/ui-enhancement-plan.md +562 -0
  104. package/package.json +50 -0
  105. package/src/app.tsx +43 -0
  106. package/src/cli/config.ts +94 -0
  107. package/src/cli/formatters.ts +632 -0
  108. package/src/cli/index.ts +583 -0
  109. package/src/components/CloneDialog.tsx +137 -0
  110. package/src/components/ColumnHeader.tsx +128 -0
  111. package/src/components/CommandPalette.tsx +120 -0
  112. package/src/components/ConfirmDialog.tsx +105 -0
  113. package/src/components/ErrorBoundary.tsx +128 -0
  114. package/src/components/FilterBar.tsx +71 -0
  115. package/src/components/FilterOptionsOverlay.tsx +131 -0
  116. package/src/components/HelpOverlay.tsx +120 -0
  117. package/src/components/Layout.tsx +379 -0
  118. package/src/components/MarkdownRenderer.tsx +127 -0
  119. package/src/components/ProgressBar.tsx +53 -0
  120. package/src/components/ProjectItem.tsx +143 -0
  121. package/src/components/ProjectList.tsx +90 -0
  122. package/src/components/RepoDetailModal.tsx +367 -0
  123. package/src/components/StatusBar.tsx +188 -0
  124. package/src/components/UnifiedProjectItem.tsx +436 -0
  125. package/src/components/ViewModeIndicator.tsx +37 -0
  126. package/src/components/onboarding/CompleteStep.tsx +82 -0
  127. package/src/components/onboarding/DirectoriesStep.test.tsx +52 -0
  128. package/src/components/onboarding/DirectoriesStep.tsx +847 -0
  129. package/src/components/onboarding/DirectoriesStep.unit.test.ts +345 -0
  130. package/src/components/onboarding/GitHubAuthStep.tsx +268 -0
  131. package/src/components/onboarding/OnboardingWizard.tsx +130 -0
  132. package/src/components/onboarding/WelcomeStep.tsx +69 -0
  133. package/src/config/loader.ts +263 -0
  134. package/src/config/onboarding.ts +67 -0
  135. package/src/constants.ts +96 -0
  136. package/src/db/index.ts +147 -0
  137. package/src/db/schema.ts +70 -0
  138. package/src/git/commands.ts +283 -0
  139. package/src/git/index.ts +2 -0
  140. package/src/git/operations.ts +93 -0
  141. package/src/git/service.ts +539 -0
  142. package/src/git/status.ts +84 -0
  143. package/src/git/types.ts +5 -0
  144. package/src/github/auth.ts +311 -0
  145. package/src/github/cache.ts +231 -0
  146. package/src/github/cli.ts +22 -0
  147. package/src/github/unified.ts +415 -0
  148. package/src/hooks/useBackgroundFetch.ts +76 -0
  149. package/src/hooks/useConfirmDialogActions.ts +120 -0
  150. package/src/hooks/useKeyBindings.ts +656 -0
  151. package/src/hooks/useProjects.ts +47 -0
  152. package/src/hooks/useUnifiedRepos.ts +317 -0
  153. package/src/index.tsx +494 -0
  154. package/src/operations/batch.ts +280 -0
  155. package/src/operations/commands.ts +140 -0
  156. package/src/operations/index.ts +37 -0
  157. package/src/scanner/index.ts +424 -0
  158. package/src/scanner/markers.ts +43 -0
  159. package/src/scanner/submodules.ts +61 -0
  160. package/src/services/git.ts +484 -0
  161. package/src/services/github.ts +676 -0
  162. package/src/services/index.ts +28 -0
  163. package/src/services/types.ts +99 -0
  164. package/src/state/actions.ts +175 -0
  165. package/src/state/reducer.ts +294 -0
  166. package/src/state/store.tsx +216 -0
  167. package/src/state/types.ts +8 -0
  168. package/src/types/index.ts +383 -0
  169. package/src/ui/theme.ts +44 -0
  170. package/src/utils/array.ts +14 -0
  171. package/src/utils/debug.ts +38 -0
  172. package/src/utils/errors.ts +17 -0
  173. package/src/utils/index.ts +8 -0
  174. package/src/utils/markdown.ts +230 -0
  175. package/src/utils/project-utils.ts +129 -0
  176. package/src/utils/rate-limiter.ts +134 -0
  177. package/src/utils/retry.ts +147 -0
  178. package/src/utils/timeout.ts +56 -0
  179. package/test/integration/app.isolated.tsx +240 -0
  180. package/test/integration/cli-commands.test.ts +287 -0
  181. package/test/integration/cli-validation.test.ts +264 -0
  182. package/test/integration/git-operations.test.ts +218 -0
  183. package/test/integration/scanner.test.ts +228 -0
  184. package/test/preload.ts +18 -0
  185. package/test/unit/cli/commands.test.ts +13 -0
  186. package/test/unit/cli/formatters.test.ts +1116 -0
  187. package/test/unit/cli/github-commands.test.ts +12 -0
  188. package/test/unit/components/CloneDialog.test.tsx +240 -0
  189. package/test/unit/components/ColumnHeader.test.tsx +128 -0
  190. package/test/unit/components/CommandPalette.test.tsx +355 -0
  191. package/test/unit/components/ConfirmDialog.test.tsx +111 -0
  192. package/test/unit/components/ErrorBoundary.test.tsx +139 -0
  193. package/test/unit/components/FilterBar.test.tsx +43 -0
  194. package/test/unit/components/FilterOptionsOverlay.test.tsx +197 -0
  195. package/test/unit/components/HelpOverlay.test.tsx +90 -0
  196. package/test/unit/components/Layout.test.tsx +328 -0
  197. package/test/unit/components/MarkdownRenderer.test.tsx +45 -0
  198. package/test/unit/components/ProgressBar.test.tsx +138 -0
  199. package/test/unit/components/ProjectItem.test.tsx +182 -0
  200. package/test/unit/components/ProjectList.test.tsx +311 -0
  201. package/test/unit/components/RepoDetailModal.test.tsx +445 -0
  202. package/test/unit/components/StatusBar.test.tsx +112 -0
  203. package/test/unit/components/UnifiedProjectItem.test.tsx +618 -0
  204. package/test/unit/components/ViewModeIndicator.test.tsx +137 -0
  205. package/test/unit/components/test-utils.tsx +63 -0
  206. package/test/unit/config/loader.test.ts +692 -0
  207. package/test/unit/db/database.test.ts +978 -0
  208. package/test/unit/db/index.test.ts +314 -0
  209. package/test/unit/fixtures/setup.ts +186 -0
  210. package/test/unit/git/commands-untested.test.ts +205 -0
  211. package/test/unit/git/commands.test.ts +269 -0
  212. package/test/unit/git/operations.test.ts +322 -0
  213. package/test/unit/git/status.test.ts +219 -0
  214. package/test/unit/github/auth.test.ts +317 -0
  215. package/test/unit/github/cache.test.ts +1028 -0
  216. package/test/unit/github/cli.test.ts +135 -0
  217. package/test/unit/github/unified.test.ts +1201 -0
  218. package/test/unit/graceful-shutdown.test.ts +83 -0
  219. package/test/unit/hooks/useBackgroundFetch.test.tsx +239 -0
  220. package/test/unit/hooks/useConfirmDialogActions.test.tsx +81 -0
  221. package/test/unit/hooks/useKeyBindings.isolated.ts +715 -0
  222. package/test/unit/hooks/useProjects.test.tsx +186 -0
  223. package/test/unit/hooks/useUnifiedRepos-simple.test.tsx +115 -0
  224. package/test/unit/hooks/useUnifiedRepos.test.tsx +177 -0
  225. package/test/unit/mocks/config.ts +109 -0
  226. package/test/unit/mocks/git-service.ts +274 -0
  227. package/test/unit/mocks/github-service.ts +250 -0
  228. package/test/unit/mocks/index.ts +72 -0
  229. package/test/unit/mocks/project.ts +148 -0
  230. package/test/unit/mocks/state-mocks.ts +187 -0
  231. package/test/unit/mocks/unified.ts +169 -0
  232. package/test/unit/operations/batch.test.ts +216 -0
  233. package/test/unit/operations/commands.test.ts +550 -0
  234. package/test/unit/scanner/errors.test.ts +297 -0
  235. package/test/unit/scanner/index.test.ts +1011 -0
  236. package/test/unit/scanner/markers.test.ts +150 -0
  237. package/test/unit/scanner/submodules.test.ts +99 -0
  238. package/test/unit/services/git-errors.test.ts +190 -0
  239. package/test/unit/services/git.test.ts +442 -0
  240. package/test/unit/services/github-errors.test.ts +293 -0
  241. package/test/unit/services/github.test.ts +200 -0
  242. package/test/unit/state/actions.test.ts +217 -0
  243. package/test/unit/state/reducer.test.ts +745 -0
  244. package/test/unit/state/store.test.tsx +711 -0
  245. package/test/unit/types/commands.test.ts +220 -0
  246. package/test/unit/types/schema.test.ts +179 -0
  247. package/test/unit/utils/array.test.ts +73 -0
  248. package/test/unit/utils/debug.test.ts +23 -0
  249. package/test/unit/utils/errors.test.ts +295 -0
  250. package/test/unit/utils/markdown.test.ts +163 -0
  251. package/test/unit/utils/project-utils.test.ts +756 -0
  252. package/test/unit/utils/rate-limiter.test.ts +256 -0
  253. package/test/unit/utils/retry.test.ts +165 -0
  254. package/test/unit/utils/strip-ansi.ts +13 -0
  255. package/test/unit/utils/timeout.test.ts +93 -0
  256. package/tsconfig.json +29 -0
@@ -0,0 +1,484 @@
1
+ /**
2
+ * GitService interface and Bun implementation
3
+ * Abstracts git operations for testability
4
+ */
5
+
6
+ import { $ } from "bun";
7
+ import type { GitStatus, OperationResult } from "../types/index.ts";
8
+ import type { GitCommandResult, SubmoduleEntry } from "./types.ts";
9
+ import { errorToString } from "../utils/errors.ts";
10
+ import { withTimeout, TimeoutError } from "../utils/timeout.ts";
11
+ import { GIT_TIMEOUTS } from "../constants.ts";
12
+
13
+ // ============================================================================
14
+ // GitService Interface
15
+ // ============================================================================
16
+
17
+ export interface GitService {
18
+ // Repository info
19
+ isGitRepo(path: string): Promise<boolean>;
20
+ getGitRoot(path: string): Promise<string | null>;
21
+ isSubmodule(path: string): Promise<boolean>;
22
+ getSubmoduleParent(path: string): Promise<string | null>;
23
+
24
+ // Status
25
+ getStatus(path: string): Promise<GitStatus>;
26
+ getStatusPorcelain(path: string): Promise<string>;
27
+ getCurrentBranch(path: string): Promise<string>;
28
+ getTrackingBranch(path: string): Promise<string | null>;
29
+ countUnpushedCommits(path: string): Promise<number>;
30
+ countUnpulledCommits(path: string): Promise<number>;
31
+ getRemoteUrl(path: string, remote?: string): Promise<string | null>;
32
+ getLastCommitDate(path: string): Promise<Date | null>;
33
+ getRemoteLastCommitDate(path: string, remoteBranch?: string): Promise<Date | null>;
34
+ listSubmodules(path: string): Promise<SubmoduleEntry[]>;
35
+
36
+ // Operations
37
+ init(path: string): Promise<OperationResult>;
38
+ pull(path: string): Promise<OperationResult>;
39
+ push(path: string, setUpstream?: boolean): Promise<OperationResult>;
40
+ fetch(path: string): Promise<OperationResult>;
41
+ fetchAll(path: string): Promise<OperationResult>;
42
+ addRemote(path: string, url: string, name?: string): Promise<OperationResult>;
43
+ clone(url: string, targetDir: string): Promise<OperationResult>;
44
+ }
45
+
46
+ // ============================================================================
47
+ // Bun Implementation
48
+ // ============================================================================
49
+
50
+ export const bunGitService: GitService = {
51
+ // Repository info
52
+ async isGitRepo(path: string): Promise<boolean> {
53
+ try {
54
+ await $`git -C ${path} rev-parse --is-inside-work-tree`.quiet();
55
+ return true;
56
+ } catch {
57
+ return false;
58
+ }
59
+ },
60
+
61
+ async getGitRoot(path: string): Promise<string | null> {
62
+ try {
63
+ const result = await $`git -C ${path} rev-parse --show-toplevel`.quiet().text();
64
+ return result.trim() || null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ },
69
+
70
+ async isSubmodule(path: string): Promise<boolean> {
71
+ try {
72
+ const result = await $`git -C ${path} rev-parse --show-superproject-working-tree`.quiet().text();
73
+ return result.trim().length > 0;
74
+ } catch {
75
+ return false;
76
+ }
77
+ },
78
+
79
+ async getSubmoduleParent(path: string): Promise<string | null> {
80
+ try {
81
+ const result = await $`git -C ${path} rev-parse --show-superproject-working-tree`.quiet().text();
82
+ return result.trim() || null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ },
87
+
88
+ // Status
89
+ async getStatus(path: string): Promise<GitStatus> {
90
+ // Get porcelain status
91
+ const statusOutput = await this.getStatusPorcelain(path);
92
+ const lines = statusOutput.split("\n").filter(Boolean);
93
+
94
+ let modifiedCount = 0;
95
+ let stagedCount = 0;
96
+ let untrackedCount = 0;
97
+
98
+ for (const line of lines) {
99
+ const indexStatus = line[0];
100
+ const workingStatus = line[1];
101
+
102
+ if (indexStatus === "?" && workingStatus === "?") {
103
+ untrackedCount++;
104
+ } else {
105
+ if (indexStatus && indexStatus !== " " && indexStatus !== "?") {
106
+ stagedCount++;
107
+ }
108
+ if (workingStatus && workingStatus !== " " && workingStatus !== "?") {
109
+ modifiedCount++;
110
+ }
111
+ }
112
+ }
113
+
114
+ // Get branch info
115
+ const currentBranch = await this.getCurrentBranch(path);
116
+ const trackingBranch = await this.getTrackingBranch(path);
117
+ const remoteUrl = await this.getRemoteUrl(path);
118
+ const hasRemote = remoteUrl !== null;
119
+
120
+ // Get sync status
121
+ let unpushedCommits = 0;
122
+ let unpulledCommits = 0;
123
+
124
+ if (hasRemote && trackingBranch) {
125
+ unpushedCommits = await this.countUnpushedCommits(path);
126
+ unpulledCommits = await this.countUnpulledCommits(path);
127
+ }
128
+
129
+ // Get activity
130
+ const lastLocalCommit = await this.getLastCommitDate(path);
131
+ const lastRemoteActivity = trackingBranch
132
+ ? await this.getRemoteLastCommitDate(path, trackingBranch)
133
+ : null;
134
+
135
+ // Check if repo has commits
136
+ const hasCommits = lastLocalCommit !== null;
137
+
138
+ // Compute flags
139
+ const hasUnstagedChanges = modifiedCount > 0;
140
+ const hasStagedChanges = stagedCount > 0;
141
+ const hasUntrackedFiles = untrackedCount > 0;
142
+ const isDirty = hasUnstagedChanges || hasStagedChanges || hasUntrackedFiles;
143
+ const isAhead = unpushedCommits > 0;
144
+ const isBehind = unpulledCommits > 0;
145
+ const isOutOfSync = isAhead || isBehind;
146
+
147
+ return {
148
+ hasUnstagedChanges,
149
+ hasStagedChanges,
150
+ hasUntrackedFiles,
151
+ modifiedCount,
152
+ stagedCount,
153
+ untrackedCount,
154
+ currentBranch,
155
+ trackingBranch,
156
+ unpushedCommits,
157
+ unpulledCommits,
158
+ hasRemote,
159
+ remoteUrl,
160
+ lastLocalCommit,
161
+ lastRemoteActivity,
162
+ hasCommits,
163
+ isDirty,
164
+ isAhead,
165
+ isBehind,
166
+ isOutOfSync,
167
+ };
168
+ },
169
+
170
+ async getStatusPorcelain(path: string): Promise<string> {
171
+ try {
172
+ return await withTimeout(
173
+ $`git -C ${path} status --porcelain`.quiet().text(),
174
+ GIT_TIMEOUTS.STATUS,
175
+ `git status --porcelain for ${path}`
176
+ );
177
+ } catch (error) {
178
+ if (error instanceof TimeoutError) {
179
+ console.error(`Timeout getting git status for ${path}: ${error.message}`);
180
+ }
181
+ return "";
182
+ }
183
+ },
184
+
185
+ async getCurrentBranch(path: string): Promise<string> {
186
+ try {
187
+ const result = await $`git -C ${path} rev-parse --abbrev-ref HEAD`.quiet().text();
188
+ return result.trim() || "unknown";
189
+ } catch {
190
+ return "unknown";
191
+ }
192
+ },
193
+
194
+ async getTrackingBranch(path: string): Promise<string | null> {
195
+ try {
196
+ const result = await $`git -C ${path} rev-parse --abbrev-ref @{u}`.quiet().text();
197
+ return result.trim() || null;
198
+ } catch {
199
+ return null;
200
+ }
201
+ },
202
+
203
+ async countUnpushedCommits(path: string): Promise<number> {
204
+ try {
205
+ const result = await $`git -C ${path} rev-list --count @{u}..HEAD`.quiet().text();
206
+ return parseInt(result.trim(), 10) || 0;
207
+ } catch {
208
+ return 0;
209
+ }
210
+ },
211
+
212
+ async countUnpulledCommits(path: string): Promise<number> {
213
+ try {
214
+ const result = await $`git -C ${path} rev-list --count HEAD..@{u}`.quiet().text();
215
+ return parseInt(result.trim(), 10) || 0;
216
+ } catch {
217
+ return 0;
218
+ }
219
+ },
220
+
221
+ async getRemoteUrl(path: string, remote = "origin"): Promise<string | null> {
222
+ try {
223
+ const result = await $`git -C ${path} config --get remote.${remote}.url`.quiet().text();
224
+ return result.trim() || null;
225
+ } catch {
226
+ return null;
227
+ }
228
+ },
229
+
230
+ async getLastCommitDate(path: string): Promise<Date | null> {
231
+ try {
232
+ const result = await $`git -C ${path} log -1 --format=%ai`.quiet().text();
233
+ const dateStr = result.trim();
234
+ if (!dateStr) return null;
235
+ return new Date(dateStr);
236
+ } catch {
237
+ return null;
238
+ }
239
+ },
240
+
241
+ async getRemoteLastCommitDate(path: string, remoteBranch = "origin/main"): Promise<Date | null> {
242
+ try {
243
+ const result = await $`git -C ${path} log -1 --format=%ai ${remoteBranch}`.quiet().text();
244
+ const dateStr = result.trim();
245
+ if (!dateStr) return null;
246
+ return new Date(dateStr);
247
+ } catch {
248
+ return null;
249
+ }
250
+ },
251
+
252
+ async listSubmodules(path: string): Promise<SubmoduleEntry[]> {
253
+ try {
254
+ const result = await $`git -C ${path} submodule status`.quiet().text();
255
+ const lines = result.trim().split("\n").filter(Boolean);
256
+
257
+ return lines.map((line) => {
258
+ const statusChar = line[0] as "-" | "+" | " " | "U";
259
+ const rest = line.slice(1).trim();
260
+ const parts = rest.split(" ");
261
+ const commit = parts[0] || "";
262
+ const subPath = parts[1] || "";
263
+
264
+ return {
265
+ path: subPath,
266
+ commit: commit,
267
+ status: statusChar,
268
+ };
269
+ });
270
+ } catch {
271
+ return [];
272
+ }
273
+ },
274
+
275
+ // Operations
276
+ async init(path: string): Promise<OperationResult> {
277
+ const start = Date.now();
278
+ try {
279
+ await $`git -C ${path} init`.quiet();
280
+ return {
281
+ success: true,
282
+ projectPath: path,
283
+ operation: "init",
284
+ message: "Git repository initialized",
285
+ duration: Date.now() - start,
286
+ };
287
+ } catch (error) {
288
+ return {
289
+ success: false,
290
+ projectPath: path,
291
+ operation: "init",
292
+ error: errorToString(error),
293
+ duration: Date.now() - start,
294
+ };
295
+ }
296
+ },
297
+
298
+ async pull(path: string): Promise<OperationResult> {
299
+ const start = Date.now();
300
+ try {
301
+ await withTimeout(
302
+ $`git -C ${path} pull --ff-only`.quiet(),
303
+ GIT_TIMEOUTS.PULL,
304
+ `git pull for ${path}`
305
+ );
306
+ return {
307
+ success: true,
308
+ projectPath: path,
309
+ operation: "pull",
310
+ message: "Pull successful",
311
+ duration: Date.now() - start,
312
+ };
313
+ } catch (error) {
314
+ let errorMessage = errorToString(error);
315
+ if (error instanceof TimeoutError) {
316
+ errorMessage = `Pull operation timed out after ${GIT_TIMEOUTS.PULL / 1000} seconds`;
317
+ }
318
+ return {
319
+ success: false,
320
+ projectPath: path,
321
+ operation: "pull",
322
+ error: errorMessage,
323
+ duration: Date.now() - start,
324
+ };
325
+ }
326
+ },
327
+
328
+ async push(path: string, setUpstream = false): Promise<OperationResult> {
329
+ const start = Date.now();
330
+ try {
331
+ if (setUpstream) {
332
+ const branch = await this.getCurrentBranch(path);
333
+ await withTimeout(
334
+ $`git -C ${path} push -u origin ${branch}`.quiet(),
335
+ GIT_TIMEOUTS.PUSH,
336
+ `git push -u origin ${branch} for ${path}`
337
+ );
338
+ } else {
339
+ await withTimeout(
340
+ $`git -C ${path} push`.quiet(),
341
+ GIT_TIMEOUTS.PUSH,
342
+ `git push for ${path}`
343
+ );
344
+ }
345
+ return {
346
+ success: true,
347
+ projectPath: path,
348
+ operation: "push",
349
+ message: "Push successful",
350
+ duration: Date.now() - start,
351
+ };
352
+ } catch (error) {
353
+ let errorMessage = errorToString(error);
354
+ if (error instanceof TimeoutError) {
355
+ errorMessage = `Push operation timed out after ${GIT_TIMEOUTS.PUSH / 1000} seconds`;
356
+ }
357
+ return {
358
+ success: false,
359
+ projectPath: path,
360
+ operation: "push",
361
+ error: errorMessage,
362
+ duration: Date.now() - start,
363
+ };
364
+ }
365
+ },
366
+
367
+ async fetch(path: string): Promise<OperationResult> {
368
+ const start = Date.now();
369
+ try {
370
+ await withTimeout(
371
+ $`git -C ${path} fetch origin`.quiet(),
372
+ GIT_TIMEOUTS.FETCH,
373
+ `git fetch origin for ${path}`
374
+ );
375
+ return {
376
+ success: true,
377
+ projectPath: path,
378
+ operation: "fetch",
379
+ message: "Fetch successful",
380
+ duration: Date.now() - start,
381
+ };
382
+ } catch (error) {
383
+ let errorMessage = errorToString(error);
384
+ if (error instanceof TimeoutError) {
385
+ errorMessage = `Fetch operation timed out after ${GIT_TIMEOUTS.FETCH / 1000} seconds`;
386
+ }
387
+ return {
388
+ success: false,
389
+ projectPath: path,
390
+ operation: "fetch",
391
+ error: errorMessage,
392
+ duration: Date.now() - start,
393
+ };
394
+ }
395
+ },
396
+
397
+ async fetchAll(path: string): Promise<OperationResult> {
398
+ const start = Date.now();
399
+ try {
400
+ await withTimeout(
401
+ $`git -C ${path} fetch --all`.quiet(),
402
+ GIT_TIMEOUTS.FETCH,
403
+ `git fetch --all for ${path}`
404
+ );
405
+ return {
406
+ success: true,
407
+ projectPath: path,
408
+ operation: "fetch-all",
409
+ message: "Fetch all successful",
410
+ duration: Date.now() - start,
411
+ };
412
+ } catch (error) {
413
+ let errorMessage = errorToString(error);
414
+ if (error instanceof TimeoutError) {
415
+ errorMessage = `Fetch all operation timed out after ${GIT_TIMEOUTS.FETCH / 1000} seconds`;
416
+ }
417
+ return {
418
+ success: false,
419
+ projectPath: path,
420
+ operation: "fetch-all",
421
+ error: errorMessage,
422
+ duration: Date.now() - start,
423
+ };
424
+ }
425
+ },
426
+
427
+ async addRemote(path: string, url: string, name = "origin"): Promise<OperationResult> {
428
+ const start = Date.now();
429
+ try {
430
+ await $`git -C ${path} remote add ${name} ${url}`.quiet();
431
+ return {
432
+ success: true,
433
+ projectPath: path,
434
+ operation: "add-remote",
435
+ message: `Remote '${name}' added`,
436
+ duration: Date.now() - start,
437
+ };
438
+ } catch (error) {
439
+ return {
440
+ success: false,
441
+ projectPath: path,
442
+ operation: "add-remote",
443
+ error: errorToString(error),
444
+ duration: Date.now() - start,
445
+ };
446
+ }
447
+ },
448
+
449
+ async clone(url: string, targetDir: string): Promise<OperationResult> {
450
+ const start = Date.now();
451
+ try {
452
+ await withTimeout(
453
+ $`git clone ${url} ${targetDir}`.quiet(),
454
+ GIT_TIMEOUTS.CLONE,
455
+ `git clone ${url} to ${targetDir}`
456
+ );
457
+ return {
458
+ success: true,
459
+ projectPath: targetDir,
460
+ operation: "clone",
461
+ message: "Clone successful",
462
+ duration: Date.now() - start,
463
+ };
464
+ } catch (error) {
465
+ let errorMessage = errorToString(error);
466
+ if (error instanceof TimeoutError) {
467
+ errorMessage = `Clone operation timed out after ${GIT_TIMEOUTS.CLONE / 1000} seconds`;
468
+ }
469
+ return {
470
+ success: false,
471
+ projectPath: targetDir,
472
+ operation: "clone",
473
+ error: errorMessage,
474
+ duration: Date.now() - start,
475
+ };
476
+ }
477
+ },
478
+ };
479
+
480
+ // ============================================================================
481
+ // Default Export
482
+ // ============================================================================
483
+
484
+ export const defaultGitService = bunGitService;