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.
- package/.bunignore +7 -0
- package/.github/workflows/ci.yml +73 -0
- package/CLAUDE.md +111 -0
- package/CONTRIBUTING.md +145 -0
- package/README.md +168 -0
- package/bun.lock +267 -0
- package/bunfig.toml +15 -0
- package/cli +0 -0
- package/config/gitforest.example.yaml +94 -0
- package/docs/ai/IMPROVEMENT_PLAN.md +341 -0
- package/docs/ai/VERIFICATION_REPORT.md +87 -0
- package/docs/ai/architecture.md +169 -0
- package/docs/ai/checks/check-2025-12-02-tests.md +40 -0
- package/docs/ai/checks/check-2025-12-02.md +55 -0
- package/docs/ai/checks/test-verification-report.md +85 -0
- package/docs/ai/implementation-guide.md +776 -0
- package/docs/ai/research/gitty-codebase-analysis.md +221 -0
- package/docs/ai/tickets/GENERAL-sitrep.md +30 -0
- package/docs/ai/tickets/TASK-database-tests-sitrep.md +25 -0
- package/docs/ai/tickets/TASK-deprecated-functions-sitrep.md +28 -0
- package/docs/ai/tickets/TASK-detail-modal-sitrep.md +28 -0
- package/docs/ai/tickets/TASK-filter-overlay-sitrep.md +24 -0
- package/docs/ai/tickets/TASK-github-service-sitrep.md +32 -0
- package/docs/ai/tickets/TASK-github-token-sitrep.md +51 -0
- package/docs/ai/tickets/TASK-hascommits-sitrep.md +35 -0
- package/docs/ai/tickets/TASK-keybindings-sitrep.md +26 -0
- package/docs/ai/tickets/TASK-layout-sitrep.md +25 -0
- package/docs/ai/tickets/TASK-markdown-sitrep.md +28 -0
- package/docs/ai/tickets/TASK-project-item-sitrep.md +79 -0
- package/docs/ai/tickets/TASK-sitrep.md +28 -0
- package/docs/ai/tickets/TASK-state-sitrep.md +26 -0
- package/docs/ai/tickets/TASK-types-sitrep.md +25 -0
- package/docs/ai/tickets/TASK-unified-item-fix-sitrep.md +26 -0
- package/docs/ai/tickets/TKT-001-sitrep.md +24 -0
- package/docs/ai/tickets/TKT-002-sitrep.md +25 -0
- package/docs/ai/tickets/TKT-003-git-service-refactoring-complete.md +46 -0
- package/docs/ai/tickets/TKT-003-git-service-refactoring-plan.md +135 -0
- package/docs/ai/tickets/TKT-003-sitrep.md +26 -0
- package/docs/ai/tickets/TKT-004-sitrep.md +27 -0
- package/docs/ai/tickets/TKT-005-sitrep.md +25 -0
- package/docs/ai/tickets/TKT-006-sitrep.md +26 -0
- package/docs/ai/tickets/TKT-007-sitrep.md +30 -0
- package/docs/ai/tickets/TKT-008-sitrep.md +32 -0
- package/docs/ai/tickets/TKT-009-sitrep.md +27 -0
- package/docs/ai/tickets/TKT-010-sitrep.md +27 -0
- package/docs/ai/tickets/TKT-011-sitrep.md +26 -0
- package/docs/ai/tickets/TKT-012-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-actions-sitrep.md +28 -0
- package/docs/ai/tickets/sitreps/TASK-actions-test-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-app-integration-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-background-fetch-sitrep.md +24 -0
- package/docs/ai/tickets/sitreps/TASK-background-fetch-test-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-batch-tests-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-bun-test-sitrep.md +26 -0
- package/docs/ai/tickets/sitreps/TASK-cache-tests-sitrep.md +30 -0
- package/docs/ai/tickets/sitreps/TASK-cli-tests-sitrep.md +28 -0
- package/docs/ai/tickets/sitreps/TASK-clone-error-handling-sitrep.md +26 -0
- package/docs/ai/tickets/sitreps/TASK-commands-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-component-tests-1-sitrep.md +30 -0
- package/docs/ai/tickets/sitreps/TASK-configloader-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-confirm-dialog-test-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-coverage-sitrep.md +95 -0
- package/docs/ai/tickets/sitreps/TASK-database-tests-summary.md +61 -0
- package/docs/ai/tickets/sitreps/TASK-error-boundary-sitrep.md +30 -0
- package/docs/ai/tickets/sitreps/TASK-error-tests-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-errors-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-extract-reducer-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-filter-overlay-test-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-final-verification-sitrep.md +28 -0
- package/docs/ai/tickets/sitreps/TASK-fix-all-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-fix-hooks-sitrep.md +26 -0
- package/docs/ai/tickets/sitreps/TASK-fix-remaining-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-fix-test-failures-sitrep.md +26 -0
- package/docs/ai/tickets/sitreps/TASK-fix-tests-sitrep.md +24 -0
- package/docs/ai/tickets/sitreps/TASK-formatters-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-git-timeouts-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-github-cache-test-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-githubcli-tests-sitrep.md +24 -0
- package/docs/ai/tickets/sitreps/TASK-gitstatus-tests-sitrep.md +24 -0
- package/docs/ai/tickets/sitreps/TASK-hooks-isolation-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-keybindings-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-layout-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-mock-factories-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-modal-tests-sitrep.md +32 -0
- package/docs/ai/tickets/sitreps/TASK-processbatch-fix-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-projectlist-tests-sitrep.md +30 -0
- package/docs/ai/tickets/sitreps/TASK-projectutils-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-scanner-tests-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-select-all-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-shell-error-handling-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-store-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-test-fixes-sitrep.md +26 -0
- package/docs/ai/tickets/sitreps/TASK-test-summary-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-test-verification-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-testsuite-sitrep.md +75 -0
- package/docs/ai/tickets/sitreps/TASK-unified-reducer-tests-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-unified-repos-test-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-unified-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-useprojects-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-utility-tests-sitrep.md +32 -0
- package/docs/ai/tickets/sitreps/TKT-003-git-service-refactoring-sitrep.md +64 -0
- package/docs/ai/tkt-001-fix-database-error.md +217 -0
- package/docs/ai/ui-enhancement-plan.md +562 -0
- package/package.json +50 -0
- package/src/app.tsx +43 -0
- package/src/cli/config.ts +94 -0
- package/src/cli/formatters.ts +632 -0
- package/src/cli/index.ts +583 -0
- package/src/components/CloneDialog.tsx +137 -0
- package/src/components/ColumnHeader.tsx +128 -0
- package/src/components/CommandPalette.tsx +120 -0
- package/src/components/ConfirmDialog.tsx +105 -0
- package/src/components/ErrorBoundary.tsx +128 -0
- package/src/components/FilterBar.tsx +71 -0
- package/src/components/FilterOptionsOverlay.tsx +131 -0
- package/src/components/HelpOverlay.tsx +120 -0
- package/src/components/Layout.tsx +379 -0
- package/src/components/MarkdownRenderer.tsx +127 -0
- package/src/components/ProgressBar.tsx +53 -0
- package/src/components/ProjectItem.tsx +143 -0
- package/src/components/ProjectList.tsx +90 -0
- package/src/components/RepoDetailModal.tsx +367 -0
- package/src/components/StatusBar.tsx +188 -0
- package/src/components/UnifiedProjectItem.tsx +436 -0
- package/src/components/ViewModeIndicator.tsx +37 -0
- package/src/components/onboarding/CompleteStep.tsx +82 -0
- package/src/components/onboarding/DirectoriesStep.test.tsx +52 -0
- package/src/components/onboarding/DirectoriesStep.tsx +847 -0
- package/src/components/onboarding/DirectoriesStep.unit.test.ts +345 -0
- package/src/components/onboarding/GitHubAuthStep.tsx +268 -0
- package/src/components/onboarding/OnboardingWizard.tsx +130 -0
- package/src/components/onboarding/WelcomeStep.tsx +69 -0
- package/src/config/loader.ts +263 -0
- package/src/config/onboarding.ts +67 -0
- package/src/constants.ts +96 -0
- package/src/db/index.ts +147 -0
- package/src/db/schema.ts +70 -0
- package/src/git/commands.ts +283 -0
- package/src/git/index.ts +2 -0
- package/src/git/operations.ts +93 -0
- package/src/git/service.ts +539 -0
- package/src/git/status.ts +84 -0
- package/src/git/types.ts +5 -0
- package/src/github/auth.ts +311 -0
- package/src/github/cache.ts +231 -0
- package/src/github/cli.ts +22 -0
- package/src/github/unified.ts +415 -0
- package/src/hooks/useBackgroundFetch.ts +76 -0
- package/src/hooks/useConfirmDialogActions.ts +120 -0
- package/src/hooks/useKeyBindings.ts +656 -0
- package/src/hooks/useProjects.ts +47 -0
- package/src/hooks/useUnifiedRepos.ts +317 -0
- package/src/index.tsx +494 -0
- package/src/operations/batch.ts +280 -0
- package/src/operations/commands.ts +140 -0
- package/src/operations/index.ts +37 -0
- package/src/scanner/index.ts +424 -0
- package/src/scanner/markers.ts +43 -0
- package/src/scanner/submodules.ts +61 -0
- package/src/services/git.ts +484 -0
- package/src/services/github.ts +676 -0
- package/src/services/index.ts +28 -0
- package/src/services/types.ts +99 -0
- package/src/state/actions.ts +175 -0
- package/src/state/reducer.ts +294 -0
- package/src/state/store.tsx +216 -0
- package/src/state/types.ts +8 -0
- package/src/types/index.ts +383 -0
- package/src/ui/theme.ts +44 -0
- package/src/utils/array.ts +14 -0
- package/src/utils/debug.ts +38 -0
- package/src/utils/errors.ts +17 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/markdown.ts +230 -0
- package/src/utils/project-utils.ts +129 -0
- package/src/utils/rate-limiter.ts +134 -0
- package/src/utils/retry.ts +147 -0
- package/src/utils/timeout.ts +56 -0
- package/test/integration/app.isolated.tsx +240 -0
- package/test/integration/cli-commands.test.ts +287 -0
- package/test/integration/cli-validation.test.ts +264 -0
- package/test/integration/git-operations.test.ts +218 -0
- package/test/integration/scanner.test.ts +228 -0
- package/test/preload.ts +18 -0
- package/test/unit/cli/commands.test.ts +13 -0
- package/test/unit/cli/formatters.test.ts +1116 -0
- package/test/unit/cli/github-commands.test.ts +12 -0
- package/test/unit/components/CloneDialog.test.tsx +240 -0
- package/test/unit/components/ColumnHeader.test.tsx +128 -0
- package/test/unit/components/CommandPalette.test.tsx +355 -0
- package/test/unit/components/ConfirmDialog.test.tsx +111 -0
- package/test/unit/components/ErrorBoundary.test.tsx +139 -0
- package/test/unit/components/FilterBar.test.tsx +43 -0
- package/test/unit/components/FilterOptionsOverlay.test.tsx +197 -0
- package/test/unit/components/HelpOverlay.test.tsx +90 -0
- package/test/unit/components/Layout.test.tsx +328 -0
- package/test/unit/components/MarkdownRenderer.test.tsx +45 -0
- package/test/unit/components/ProgressBar.test.tsx +138 -0
- package/test/unit/components/ProjectItem.test.tsx +182 -0
- package/test/unit/components/ProjectList.test.tsx +311 -0
- package/test/unit/components/RepoDetailModal.test.tsx +445 -0
- package/test/unit/components/StatusBar.test.tsx +112 -0
- package/test/unit/components/UnifiedProjectItem.test.tsx +618 -0
- package/test/unit/components/ViewModeIndicator.test.tsx +137 -0
- package/test/unit/components/test-utils.tsx +63 -0
- package/test/unit/config/loader.test.ts +692 -0
- package/test/unit/db/database.test.ts +978 -0
- package/test/unit/db/index.test.ts +314 -0
- package/test/unit/fixtures/setup.ts +186 -0
- package/test/unit/git/commands-untested.test.ts +205 -0
- package/test/unit/git/commands.test.ts +269 -0
- package/test/unit/git/operations.test.ts +322 -0
- package/test/unit/git/status.test.ts +219 -0
- package/test/unit/github/auth.test.ts +317 -0
- package/test/unit/github/cache.test.ts +1028 -0
- package/test/unit/github/cli.test.ts +135 -0
- package/test/unit/github/unified.test.ts +1201 -0
- package/test/unit/graceful-shutdown.test.ts +83 -0
- package/test/unit/hooks/useBackgroundFetch.test.tsx +239 -0
- package/test/unit/hooks/useConfirmDialogActions.test.tsx +81 -0
- package/test/unit/hooks/useKeyBindings.isolated.ts +715 -0
- package/test/unit/hooks/useProjects.test.tsx +186 -0
- package/test/unit/hooks/useUnifiedRepos-simple.test.tsx +115 -0
- package/test/unit/hooks/useUnifiedRepos.test.tsx +177 -0
- package/test/unit/mocks/config.ts +109 -0
- package/test/unit/mocks/git-service.ts +274 -0
- package/test/unit/mocks/github-service.ts +250 -0
- package/test/unit/mocks/index.ts +72 -0
- package/test/unit/mocks/project.ts +148 -0
- package/test/unit/mocks/state-mocks.ts +187 -0
- package/test/unit/mocks/unified.ts +169 -0
- package/test/unit/operations/batch.test.ts +216 -0
- package/test/unit/operations/commands.test.ts +550 -0
- package/test/unit/scanner/errors.test.ts +297 -0
- package/test/unit/scanner/index.test.ts +1011 -0
- package/test/unit/scanner/markers.test.ts +150 -0
- package/test/unit/scanner/submodules.test.ts +99 -0
- package/test/unit/services/git-errors.test.ts +190 -0
- package/test/unit/services/git.test.ts +442 -0
- package/test/unit/services/github-errors.test.ts +293 -0
- package/test/unit/services/github.test.ts +200 -0
- package/test/unit/state/actions.test.ts +217 -0
- package/test/unit/state/reducer.test.ts +745 -0
- package/test/unit/state/store.test.tsx +711 -0
- package/test/unit/types/commands.test.ts +220 -0
- package/test/unit/types/schema.test.ts +179 -0
- package/test/unit/utils/array.test.ts +73 -0
- package/test/unit/utils/debug.test.ts +23 -0
- package/test/unit/utils/errors.test.ts +295 -0
- package/test/unit/utils/markdown.test.ts +163 -0
- package/test/unit/utils/project-utils.test.ts +756 -0
- package/test/unit/utils/rate-limiter.test.ts +256 -0
- package/test/unit/utils/retry.test.ts +165 -0
- package/test/unit/utils/strip-ansi.ts +13 -0
- package/test/unit/utils/timeout.test.ts +93 -0
- 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;
|