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,539 @@
|
|
|
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.js";
|
|
8
|
+
import type { SubmoduleEntry } from "../services/types.js";
|
|
9
|
+
import { errorToString } from "../utils/errors.js";
|
|
10
|
+
import { withTimeout, TimeoutError } from "../utils/timeout.js";
|
|
11
|
+
import { GIT_TIMEOUTS } from "../constants.js";
|
|
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 porcelain = await withTimeout(
|
|
92
|
+
$`git -C ${path} status --porcelain`.quiet().text(),
|
|
93
|
+
GIT_TIMEOUTS.STATUS,
|
|
94
|
+
"git status"
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// Get current branch
|
|
98
|
+
const currentBranch = await withTimeout(
|
|
99
|
+
$`git -C ${path} rev-parse --abbrev-ref HEAD`.quiet().text(),
|
|
100
|
+
GIT_TIMEOUTS.STATUS,
|
|
101
|
+
"git rev-parse"
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// Get tracking branch
|
|
105
|
+
const trackingBranch = await withTimeout(
|
|
106
|
+
$`git -C ${path} rev-parse --abbrev-ref @{u}`.quiet().text(),
|
|
107
|
+
GIT_TIMEOUTS.STATUS,
|
|
108
|
+
"git rev-parse"
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// Get remote URL
|
|
112
|
+
const remoteUrl = await withTimeout(
|
|
113
|
+
$`git -C ${path} config --get remote.origin.url`.quiet().text(),
|
|
114
|
+
GIT_TIMEOUTS.STATUS,
|
|
115
|
+
"git config"
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Get last commit date
|
|
119
|
+
const lastLocalCommit = await withTimeout(
|
|
120
|
+
$`git -C ${path} log -1 --format=%ct`.quiet().text(),
|
|
121
|
+
GIT_TIMEOUTS.STATUS,
|
|
122
|
+
"git log"
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// Get remote last commit date
|
|
126
|
+
const lastRemoteCommit = trackingBranch
|
|
127
|
+
? await withTimeout(
|
|
128
|
+
$`git -C ${path} log -1 --format=%ct ${trackingBranch}`.quiet().text(),
|
|
129
|
+
GIT_TIMEOUTS.STATUS,
|
|
130
|
+
"git log"
|
|
131
|
+
)
|
|
132
|
+
: null;
|
|
133
|
+
|
|
134
|
+
// Count unpushed commits
|
|
135
|
+
const unpushedCommits = trackingBranch
|
|
136
|
+
? await withTimeout(
|
|
137
|
+
$`git -C ${path} log --oneline --count ${trackingBranch}..HEAD`.quiet().text(),
|
|
138
|
+
GIT_TIMEOUTS.STATUS,
|
|
139
|
+
"git log"
|
|
140
|
+
)
|
|
141
|
+
: 0;
|
|
142
|
+
|
|
143
|
+
// Count unpulled commits
|
|
144
|
+
const unpulledCommits = trackingBranch
|
|
145
|
+
? await withTimeout(
|
|
146
|
+
$`git -C ${path} log --oneline --count HEAD..${trackingBranch}`.quiet().text(),
|
|
147
|
+
GIT_TIMEOUTS.STATUS,
|
|
148
|
+
"git log"
|
|
149
|
+
)
|
|
150
|
+
: 0;
|
|
151
|
+
|
|
152
|
+
// Parse porcelain status
|
|
153
|
+
const lines = porcelain.trim().split("\n");
|
|
154
|
+
let modifiedCount = 0;
|
|
155
|
+
let stagedCount = 0;
|
|
156
|
+
let untrackedCount = 0;
|
|
157
|
+
|
|
158
|
+
for (const line of lines) {
|
|
159
|
+
if (!line) continue;
|
|
160
|
+
const statusCode = line[0];
|
|
161
|
+
const filename = line.slice(3);
|
|
162
|
+
|
|
163
|
+
switch (statusCode) {
|
|
164
|
+
case "M":
|
|
165
|
+
modifiedCount++;
|
|
166
|
+
break;
|
|
167
|
+
case "A":
|
|
168
|
+
stagedCount++;
|
|
169
|
+
break;
|
|
170
|
+
case "??":
|
|
171
|
+
untrackedCount++;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
hasUnstagedChanges: modifiedCount > 0,
|
|
178
|
+
hasStagedChanges: stagedCount > 0,
|
|
179
|
+
hasUntrackedFiles: untrackedCount > 0,
|
|
180
|
+
modifiedCount,
|
|
181
|
+
stagedCount,
|
|
182
|
+
untrackedCount,
|
|
183
|
+
currentBranch: (currentBranch as string).trim() || "unknown",
|
|
184
|
+
trackingBranch: (trackingBranch as string | null)?.trim() || null,
|
|
185
|
+
unpushedCommits: parseInt(unpushedCommits as string) || 0,
|
|
186
|
+
unpulledCommits: parseInt(unpulledCommits as string) || 0,
|
|
187
|
+
hasRemote: !!remoteUrl,
|
|
188
|
+
remoteUrl: (remoteUrl as string | null)?.trim() || null,
|
|
189
|
+
lastLocalCommit: lastLocalCommit ? new Date(parseInt(lastLocalCommit as string) * 1000) : null,
|
|
190
|
+
lastRemoteActivity: lastRemoteCommit ? new Date(parseInt(lastRemoteCommit as string) * 1000) : null,
|
|
191
|
+
hasCommits: true, // If we can run git status, we have commits
|
|
192
|
+
isDirty: modifiedCount > 0 || stagedCount > 0 || untrackedCount > 0,
|
|
193
|
+
isAhead: (parseInt(unpushedCommits as string) || 0) > 0,
|
|
194
|
+
isBehind: (parseInt(unpulledCommits as string) || 0) > 0,
|
|
195
|
+
isOutOfSync: (parseInt(unpushedCommits as string) || 0) > 0 || (parseInt(unpulledCommits as string) || 0) > 0,
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
async getStatusPorcelain(path: string): Promise<string> {
|
|
200
|
+
try {
|
|
201
|
+
return await $`git -C ${path} status --porcelain`.quiet().text();
|
|
202
|
+
} catch {
|
|
203
|
+
return "";
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
async getCurrentBranch(path: string): Promise<string> {
|
|
208
|
+
try {
|
|
209
|
+
const result = await $`git -C ${path} rev-parse --abbrev-ref HEAD`.quiet().text();
|
|
210
|
+
return result.trim();
|
|
211
|
+
} catch {
|
|
212
|
+
return "unknown";
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
async getTrackingBranch(path: string): Promise<string | null> {
|
|
217
|
+
try {
|
|
218
|
+
const result = await $`git -C ${path} rev-parse --abbrev-ref @{u}`.quiet().text();
|
|
219
|
+
return result.trim() || null;
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
async countUnpushedCommits(path: string): Promise<number> {
|
|
226
|
+
try {
|
|
227
|
+
const trackingBranch = await this.getTrackingBranch(path);
|
|
228
|
+
if (!trackingBranch) return 0;
|
|
229
|
+
|
|
230
|
+
const result = await $`git -C ${path} log --oneline --count ${trackingBranch}..HEAD`.quiet().text();
|
|
231
|
+
return parseInt(result) || 0;
|
|
232
|
+
} catch {
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
async countUnpulledCommits(path: string): Promise<number> {
|
|
238
|
+
try {
|
|
239
|
+
const trackingBranch = await this.getTrackingBranch(path);
|
|
240
|
+
if (!trackingBranch) return 0;
|
|
241
|
+
|
|
242
|
+
const result = await $`git -C ${path} log --oneline --count HEAD..${trackingBranch}`.quiet().text();
|
|
243
|
+
return parseInt(result) || 0;
|
|
244
|
+
} catch {
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
async getRemoteUrl(path: string, remoteName?: string): Promise<string | null> {
|
|
250
|
+
try {
|
|
251
|
+
const remote = remoteName || "origin";
|
|
252
|
+
const result = await $`git -C ${path} config --get remote.${remote}.url`.quiet().text();
|
|
253
|
+
return result.trim() || null;
|
|
254
|
+
} catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
async getLastCommitDate(path: string): Promise<Date | null> {
|
|
260
|
+
try {
|
|
261
|
+
const result = await $`git -C ${path} log -1 --format=%ct`.quiet().text();
|
|
262
|
+
return new Date(parseInt(result) * 1000);
|
|
263
|
+
} catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
async getRemoteLastCommitDate(path: string, remoteBranch?: string): Promise<Date | null> {
|
|
269
|
+
try {
|
|
270
|
+
if (!remoteBranch) return null;
|
|
271
|
+
|
|
272
|
+
const result = await $`git -C ${path} log -1 --format=%ct ${remoteBranch}`.quiet().text();
|
|
273
|
+
return new Date(parseInt(result) * 1000);
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
async listSubmodules(path: string): Promise<SubmoduleEntry[]> {
|
|
280
|
+
try {
|
|
281
|
+
const result = await $`git -C ${path} submodule status`.quiet().text();
|
|
282
|
+
const lines = result.trim().split("\n");
|
|
283
|
+
|
|
284
|
+
const submodules: SubmoduleEntry[] = [];
|
|
285
|
+
for (const line of lines) {
|
|
286
|
+
if (!line) continue;
|
|
287
|
+
|
|
288
|
+
const parts = line.split(" ");
|
|
289
|
+
if (parts.length >= 4) {
|
|
290
|
+
const statusChar = parts[0];
|
|
291
|
+
const commit = parts[1] || "";
|
|
292
|
+
const submodulePath = parts.slice(3).join(" ");
|
|
293
|
+
|
|
294
|
+
submodules.push({
|
|
295
|
+
path: submodulePath,
|
|
296
|
+
commit,
|
|
297
|
+
status: (statusChar?.[0] as "-" | "+" | " " | "U") || " ",
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return submodules;
|
|
303
|
+
} catch {
|
|
304
|
+
return [];
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
// Operations
|
|
309
|
+
async init(path: string): Promise<OperationResult> {
|
|
310
|
+
const start = Date.now();
|
|
311
|
+
try {
|
|
312
|
+
await $`git -C ${path} init`.quiet();
|
|
313
|
+
await $`git -C ${path} config user.email "test@test.com"`.quiet();
|
|
314
|
+
await $`git -C ${path} config user.name "Test User"`.quiet();
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
success: true,
|
|
318
|
+
projectPath: path,
|
|
319
|
+
operation: "init",
|
|
320
|
+
message: "Git repository initialized",
|
|
321
|
+
duration: Date.now() - start,
|
|
322
|
+
};
|
|
323
|
+
} catch (error) {
|
|
324
|
+
return {
|
|
325
|
+
success: false,
|
|
326
|
+
projectPath: path,
|
|
327
|
+
operation: "init",
|
|
328
|
+
error: errorToString(error),
|
|
329
|
+
duration: Date.now() - start,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
async pull(path: string): Promise<OperationResult> {
|
|
335
|
+
const start = Date.now();
|
|
336
|
+
try {
|
|
337
|
+
await withTimeout(
|
|
338
|
+
$`git -C ${path} pull --ff-only`.quiet(),
|
|
339
|
+
GIT_TIMEOUTS.PULL,
|
|
340
|
+
"git pull"
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
success: true,
|
|
345
|
+
projectPath: path,
|
|
346
|
+
operation: "pull",
|
|
347
|
+
message: "Pull successful",
|
|
348
|
+
duration: Date.now() - start,
|
|
349
|
+
};
|
|
350
|
+
} catch (error) {
|
|
351
|
+
if (error instanceof TimeoutError) {
|
|
352
|
+
return {
|
|
353
|
+
success: false,
|
|
354
|
+
projectPath: path,
|
|
355
|
+
operation: "pull",
|
|
356
|
+
error: `pull timeout after ${GIT_TIMEOUTS.PULL}ms`,
|
|
357
|
+
duration: Date.now() - start,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
success: false,
|
|
363
|
+
projectPath: path,
|
|
364
|
+
operation: "pull",
|
|
365
|
+
error: errorToString(error),
|
|
366
|
+
duration: Date.now() - start,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
async push(path: string, setUpstream?: boolean): Promise<OperationResult> {
|
|
372
|
+
const start = Date.now();
|
|
373
|
+
try {
|
|
374
|
+
const upstreamFlag = setUpstream ? "-u" : "";
|
|
375
|
+
await withTimeout(
|
|
376
|
+
$`git -C ${path} push ${upstreamFlag} origin`.quiet(),
|
|
377
|
+
GIT_TIMEOUTS.PUSH,
|
|
378
|
+
"git push"
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
success: true,
|
|
383
|
+
projectPath: path,
|
|
384
|
+
operation: "push",
|
|
385
|
+
message: "Push successful",
|
|
386
|
+
duration: Date.now() - start,
|
|
387
|
+
};
|
|
388
|
+
} catch (error) {
|
|
389
|
+
if (error instanceof TimeoutError) {
|
|
390
|
+
return {
|
|
391
|
+
success: false,
|
|
392
|
+
projectPath: path,
|
|
393
|
+
operation: "push",
|
|
394
|
+
error: `push timeout after ${GIT_TIMEOUTS.PUSH}ms`,
|
|
395
|
+
duration: Date.now() - start,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
success: false,
|
|
401
|
+
projectPath: path,
|
|
402
|
+
operation: "push",
|
|
403
|
+
error: errorToString(error),
|
|
404
|
+
duration: Date.now() - start,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
async fetch(path: string): Promise<OperationResult> {
|
|
410
|
+
const start = Date.now();
|
|
411
|
+
try {
|
|
412
|
+
await withTimeout(
|
|
413
|
+
$`git -C ${path} fetch origin`.quiet(),
|
|
414
|
+
GIT_TIMEOUTS.FETCH,
|
|
415
|
+
"git fetch"
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
success: true,
|
|
420
|
+
projectPath: path,
|
|
421
|
+
operation: "fetch",
|
|
422
|
+
message: "Fetch successful",
|
|
423
|
+
duration: Date.now() - start,
|
|
424
|
+
};
|
|
425
|
+
} catch (error) {
|
|
426
|
+
if (error instanceof TimeoutError) {
|
|
427
|
+
return {
|
|
428
|
+
success: false,
|
|
429
|
+
projectPath: path,
|
|
430
|
+
operation: "fetch",
|
|
431
|
+
error: `fetch timeout after ${GIT_TIMEOUTS.FETCH}ms`,
|
|
432
|
+
duration: Date.now() - start,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
success: false,
|
|
438
|
+
projectPath: path,
|
|
439
|
+
operation: "fetch",
|
|
440
|
+
error: errorToString(error),
|
|
441
|
+
duration: Date.now() - start,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
|
|
446
|
+
async fetchAll(path: string): Promise<OperationResult> {
|
|
447
|
+
const start = Date.now();
|
|
448
|
+
try {
|
|
449
|
+
await withTimeout(
|
|
450
|
+
$`git -C ${path} fetch --all`.quiet(),
|
|
451
|
+
GIT_TIMEOUTS.FETCH,
|
|
452
|
+
"git fetch --all"
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
success: true,
|
|
457
|
+
projectPath: path,
|
|
458
|
+
operation: "fetch-all",
|
|
459
|
+
message: "Fetch all successful",
|
|
460
|
+
duration: Date.now() - start,
|
|
461
|
+
};
|
|
462
|
+
} catch (error) {
|
|
463
|
+
if (error instanceof TimeoutError) {
|
|
464
|
+
return {
|
|
465
|
+
success: false,
|
|
466
|
+
projectPath: path,
|
|
467
|
+
operation: "fetch-all",
|
|
468
|
+
error: `fetch-all timeout after ${GIT_TIMEOUTS.FETCH}ms`,
|
|
469
|
+
duration: Date.now() - start,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
success: false,
|
|
475
|
+
projectPath: path,
|
|
476
|
+
operation: "fetch-all",
|
|
477
|
+
error: errorToString(error),
|
|
478
|
+
duration: Date.now() - start,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
|
|
483
|
+
async addRemote(path: string, url: string, name?: string): Promise<OperationResult> {
|
|
484
|
+
const start = Date.now();
|
|
485
|
+
try {
|
|
486
|
+
const remoteName = name || "origin";
|
|
487
|
+
await $`git -C ${path} remote add ${remoteName} ${url}`.quiet();
|
|
488
|
+
|
|
489
|
+
return {
|
|
490
|
+
success: true,
|
|
491
|
+
projectPath: path,
|
|
492
|
+
operation: "add-remote",
|
|
493
|
+
message: `Remote '${remoteName}' added`,
|
|
494
|
+
duration: Date.now() - start,
|
|
495
|
+
};
|
|
496
|
+
} catch (error) {
|
|
497
|
+
return {
|
|
498
|
+
success: false,
|
|
499
|
+
projectPath: path,
|
|
500
|
+
operation: "add-remote",
|
|
501
|
+
error: errorToString(error),
|
|
502
|
+
duration: Date.now() - start,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
|
|
507
|
+
async clone(url: string, targetDir: string): Promise<OperationResult> {
|
|
508
|
+
const start = Date.now();
|
|
509
|
+
try {
|
|
510
|
+
await $`git clone ${url} ${targetDir}`.quiet();
|
|
511
|
+
|
|
512
|
+
return {
|
|
513
|
+
success: true,
|
|
514
|
+
projectPath: targetDir,
|
|
515
|
+
operation: "clone",
|
|
516
|
+
message: "Clone successful",
|
|
517
|
+
duration: Date.now() - start,
|
|
518
|
+
};
|
|
519
|
+
} catch (error) {
|
|
520
|
+
if (error instanceof TimeoutError) {
|
|
521
|
+
return {
|
|
522
|
+
success: false,
|
|
523
|
+
projectPath: targetDir,
|
|
524
|
+
operation: "clone",
|
|
525
|
+
error: `clone timeout after ${GIT_TIMEOUTS.CLONE}ms`,
|
|
526
|
+
duration: Date.now() - start,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
success: false,
|
|
532
|
+
projectPath: targetDir,
|
|
533
|
+
operation: "clone",
|
|
534
|
+
error: errorToString(error),
|
|
535
|
+
duration: Date.now() - start,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
},
|
|
539
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { GitStatus } from "../types/index.ts";
|
|
2
|
+
import type { GitService } from "../services/git.ts";
|
|
3
|
+
import { bunGitService } from "../services/git.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse git status porcelain output
|
|
7
|
+
*/
|
|
8
|
+
export function parseGitStatusPorcelain(output: string): {
|
|
9
|
+
modifiedCount: number;
|
|
10
|
+
stagedCount: number;
|
|
11
|
+
untrackedCount: number;
|
|
12
|
+
hasUnstagedChanges: boolean;
|
|
13
|
+
hasStagedChanges: boolean;
|
|
14
|
+
hasUntrackedFiles: boolean;
|
|
15
|
+
} {
|
|
16
|
+
// Split on newlines, preserving leading whitespace (important for porcelain format)
|
|
17
|
+
// Only trim trailing whitespace and filter empty lines
|
|
18
|
+
const lines = output.split("\n").map(line => line.trimEnd()).filter(line => line.length >= 2);
|
|
19
|
+
|
|
20
|
+
let modifiedCount = 0;
|
|
21
|
+
let stagedCount = 0;
|
|
22
|
+
let untrackedCount = 0;
|
|
23
|
+
|
|
24
|
+
for (const line of lines) {
|
|
25
|
+
const indexStatus = line[0];
|
|
26
|
+
const worktreeStatus = line[1];
|
|
27
|
+
|
|
28
|
+
// Untracked files
|
|
29
|
+
if (indexStatus === "?" && worktreeStatus === "?") {
|
|
30
|
+
untrackedCount++;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Staged changes (index status is not empty or ?)
|
|
35
|
+
if (indexStatus && indexStatus !== " " && indexStatus !== "?") {
|
|
36
|
+
stagedCount++;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Unstaged changes (worktree status is not empty)
|
|
40
|
+
if (worktreeStatus && worktreeStatus !== " ") {
|
|
41
|
+
modifiedCount++;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
modifiedCount,
|
|
47
|
+
stagedCount,
|
|
48
|
+
untrackedCount,
|
|
49
|
+
hasUnstagedChanges: modifiedCount > 0,
|
|
50
|
+
hasStagedChanges: stagedCount > 0,
|
|
51
|
+
hasUntrackedFiles: untrackedCount > 0,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get comprehensive git status for a repository
|
|
57
|
+
*/
|
|
58
|
+
export async function getGitStatus(
|
|
59
|
+
path: string,
|
|
60
|
+
gitService: GitService = bunGitService
|
|
61
|
+
): Promise<GitStatus> {
|
|
62
|
+
return await gitService.getStatus(path);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Quick status check (faster, less comprehensive)
|
|
67
|
+
*/
|
|
68
|
+
export async function getQuickStatus(
|
|
69
|
+
path: string,
|
|
70
|
+
gitService: GitService = bunGitService
|
|
71
|
+
): Promise<{
|
|
72
|
+
isDirty: boolean;
|
|
73
|
+
hasRemote: boolean;
|
|
74
|
+
}> {
|
|
75
|
+
const [porcelainOutput, remoteUrl] = await Promise.all([
|
|
76
|
+
gitService.getStatusPorcelain(path),
|
|
77
|
+
gitService.getRemoteUrl(path),
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
isDirty: porcelainOutput.trim().length > 0,
|
|
82
|
+
hasRemote: remoteUrl !== null,
|
|
83
|
+
};
|
|
84
|
+
}
|