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,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified view combining local projects and GitHub repos
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
Project,
|
|
7
|
+
GitHubRepoInfo,
|
|
8
|
+
UnifiedRepo,
|
|
9
|
+
ViewMode,
|
|
10
|
+
} from "../types/index.ts";
|
|
11
|
+
import { defaultGitHubService, type GitHubRepoData } from "../services/github.ts";
|
|
12
|
+
import { parseGitHubUrl } from "./cli.ts";
|
|
13
|
+
import { errorToString } from "../utils/errors.ts";
|
|
14
|
+
import { initDb, schema } from "../db/index.ts";
|
|
15
|
+
import { bunGitService } from "../services/index.ts";
|
|
16
|
+
import type { GitService } from "../services/git.ts";
|
|
17
|
+
import { existsSync } from "fs";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Convert GitHub API repo to our GitHubRepoInfo type
|
|
21
|
+
*/
|
|
22
|
+
export function toGitHubRepoInfo(repo: GitHubRepoData): GitHubRepoInfo {
|
|
23
|
+
return {
|
|
24
|
+
name: repo.name,
|
|
25
|
+
fullName: repo.fullName,
|
|
26
|
+
owner: repo.owner.login,
|
|
27
|
+
description: repo.description,
|
|
28
|
+
htmlUrl: repo.htmlUrl,
|
|
29
|
+
sshUrl: repo.sshUrl,
|
|
30
|
+
cloneUrl: repo.cloneUrl,
|
|
31
|
+
isPrivate: repo.isPrivate,
|
|
32
|
+
isArchived: repo.isArchived,
|
|
33
|
+
isFork: repo.isFork,
|
|
34
|
+
pushedAt: repo.pushedAt ? new Date(repo.pushedAt) : null,
|
|
35
|
+
updatedAt: repo.updatedAt ? new Date(repo.updatedAt) : null,
|
|
36
|
+
defaultBranch: repo.defaultBranch,
|
|
37
|
+
language: repo.language,
|
|
38
|
+
size: repo.size,
|
|
39
|
+
stargazersCount: repo.stargazersCount,
|
|
40
|
+
forksCount: repo.forksCount,
|
|
41
|
+
openIssuesCount: repo.openIssuesCount,
|
|
42
|
+
watchersCount: repo.watchersCount,
|
|
43
|
+
topics: repo.topics,
|
|
44
|
+
license: repo.license?.name || null,
|
|
45
|
+
hasIssues: repo.hasIssues,
|
|
46
|
+
hasWiki: repo.hasWiki,
|
|
47
|
+
hasDiscussions: repo.hasDiscussions,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Extract owner/repo from a git remote URL
|
|
53
|
+
*/
|
|
54
|
+
function extractRepoIdentifier(remoteUrl: string | null): string | null {
|
|
55
|
+
if (!remoteUrl) return null;
|
|
56
|
+
|
|
57
|
+
const parsed = parseGitHubUrl(remoteUrl);
|
|
58
|
+
if (parsed) {
|
|
59
|
+
return `${parsed.owner}/${parsed.repo}`.toLowerCase();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create a unified view by matching local projects with GitHub repos
|
|
67
|
+
*/
|
|
68
|
+
export function createUnifiedView(
|
|
69
|
+
localProjects: Project[],
|
|
70
|
+
githubRepos: GitHubRepoInfo[]
|
|
71
|
+
): UnifiedRepo[] {
|
|
72
|
+
const unified: UnifiedRepo[] = [];
|
|
73
|
+
const matchedGitHubIds = new Set<string>();
|
|
74
|
+
|
|
75
|
+
// First, process local projects and try to match with GitHub
|
|
76
|
+
for (const local of localProjects) {
|
|
77
|
+
const remoteId = extractRepoIdentifier(local.status?.remoteUrl ?? null);
|
|
78
|
+
let matchedGitHub: GitHubRepoInfo | null = null;
|
|
79
|
+
|
|
80
|
+
if (remoteId) {
|
|
81
|
+
// Try to find matching GitHub repo
|
|
82
|
+
matchedGitHub = githubRepos.find(
|
|
83
|
+
(gh) => gh.fullName.toLowerCase() === remoteId
|
|
84
|
+
) ?? null;
|
|
85
|
+
|
|
86
|
+
if (matchedGitHub) {
|
|
87
|
+
matchedGitHubIds.add(matchedGitHub.fullName);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
unified.push({
|
|
92
|
+
id: local.id,
|
|
93
|
+
name: local.name,
|
|
94
|
+
source: matchedGitHub ? "both" : "local",
|
|
95
|
+
local,
|
|
96
|
+
github: matchedGitHub,
|
|
97
|
+
isCloned: true,
|
|
98
|
+
isOnGitHub: matchedGitHub !== null,
|
|
99
|
+
localPath: local.path,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Add GitHub repos that aren't cloned locally
|
|
104
|
+
for (const github of githubRepos) {
|
|
105
|
+
if (!matchedGitHubIds.has(github.fullName)) {
|
|
106
|
+
unified.push({
|
|
107
|
+
id: `github-${github.fullName}`,
|
|
108
|
+
name: github.name,
|
|
109
|
+
source: "github",
|
|
110
|
+
local: null,
|
|
111
|
+
github,
|
|
112
|
+
isCloned: false,
|
|
113
|
+
isOnGitHub: true,
|
|
114
|
+
localPath: null,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return unified;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Filter unified repos based on view mode
|
|
124
|
+
*/
|
|
125
|
+
export function filterByViewMode(
|
|
126
|
+
repos: UnifiedRepo[],
|
|
127
|
+
mode: ViewMode
|
|
128
|
+
): UnifiedRepo[] {
|
|
129
|
+
switch (mode) {
|
|
130
|
+
case "local":
|
|
131
|
+
// Show repos that exist locally (local-only or synced)
|
|
132
|
+
return repos.filter((r) => r.isCloned);
|
|
133
|
+
case "github":
|
|
134
|
+
// Show repos that exist on GitHub (github-only or synced)
|
|
135
|
+
return repos.filter((r) => r.isOnGitHub);
|
|
136
|
+
case "combined":
|
|
137
|
+
return repos;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Sort unified repos
|
|
143
|
+
*/
|
|
144
|
+
export function sortUnifiedRepos(
|
|
145
|
+
repos: UnifiedRepo[],
|
|
146
|
+
sortBy: "status" | "name" | "branch" | "sync" | "language" | "stars" | "forks" | "lastActivity" | "size",
|
|
147
|
+
direction: "asc" | "desc"
|
|
148
|
+
): UnifiedRepo[] {
|
|
149
|
+
return [...repos].sort((a, b) => {
|
|
150
|
+
let comparison = 0;
|
|
151
|
+
|
|
152
|
+
switch (sortBy) {
|
|
153
|
+
case "name":
|
|
154
|
+
comparison = a.name.localeCompare(b.name);
|
|
155
|
+
break;
|
|
156
|
+
|
|
157
|
+
case "branch": {
|
|
158
|
+
const aBranch = a.local?.status?.currentBranch ?? "";
|
|
159
|
+
const bBranch = b.local?.status?.currentBranch ?? "";
|
|
160
|
+
comparison = aBranch.localeCompare(bBranch);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
case "status":
|
|
165
|
+
// Prioritize: GitHub-only (not cloned) > dirty > ahead/behind > clean
|
|
166
|
+
const aPriority = getUnifiedPriority(a);
|
|
167
|
+
const bPriority = getUnifiedPriority(b);
|
|
168
|
+
comparison = aPriority - bPriority;
|
|
169
|
+
return direction === "desc" ? comparison : -comparison;
|
|
170
|
+
|
|
171
|
+
case "sync": {
|
|
172
|
+
// Higher sync delta first when desc
|
|
173
|
+
const aStatus = a.local?.status;
|
|
174
|
+
const bStatus = b.local?.status;
|
|
175
|
+
const aDelta = aStatus ? (aStatus.unpushedCommits ?? 0) + (aStatus.unpulledCommits ?? 0) : 0;
|
|
176
|
+
const bDelta = bStatus ? (bStatus.unpushedCommits ?? 0) + (bStatus.unpulledCommits ?? 0) : 0;
|
|
177
|
+
comparison = aDelta - bDelta;
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
case "language": {
|
|
182
|
+
const aLang = (a.github?.language || "").toLowerCase();
|
|
183
|
+
const bLang = (b.github?.language || "").toLowerCase();
|
|
184
|
+
comparison = aLang.localeCompare(bLang);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
case "lastActivity":
|
|
189
|
+
const aDate = getLastActivity(a);
|
|
190
|
+
const bDate = getLastActivity(b);
|
|
191
|
+
comparison = aDate - bDate;
|
|
192
|
+
break;
|
|
193
|
+
|
|
194
|
+
case "stars":
|
|
195
|
+
comparison = (a.github?.stargazersCount ?? 0) - (b.github?.stargazersCount ?? 0);
|
|
196
|
+
break;
|
|
197
|
+
|
|
198
|
+
case "forks":
|
|
199
|
+
comparison = (a.github?.forksCount ?? 0) - (b.github?.forksCount ?? 0);
|
|
200
|
+
break;
|
|
201
|
+
|
|
202
|
+
case "size":
|
|
203
|
+
comparison = (a.github?.size ?? 0) - (b.github?.size ?? 0);
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return direction === "desc" ? -comparison : comparison;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Get priority for sorting (lower = more attention needed)
|
|
213
|
+
*/
|
|
214
|
+
function getUnifiedPriority(repo: UnifiedRepo): number {
|
|
215
|
+
// GitHub-only (not cloned) - highest priority
|
|
216
|
+
if (!repo.isCloned && repo.isOnGitHub) return -100;
|
|
217
|
+
|
|
218
|
+
// Local only (not on GitHub) - needs remote setup
|
|
219
|
+
if (repo.isCloned && !repo.isOnGitHub) return -50;
|
|
220
|
+
|
|
221
|
+
const local = repo.local;
|
|
222
|
+
if (!local?.status) return 50;
|
|
223
|
+
|
|
224
|
+
let priority = 0;
|
|
225
|
+
|
|
226
|
+
// Dirty repos need attention
|
|
227
|
+
if (local.status.isDirty) priority -= 40;
|
|
228
|
+
|
|
229
|
+
// Out of sync repos need attention
|
|
230
|
+
if (local.status.isAhead) priority -= 20;
|
|
231
|
+
if (local.status.isBehind) priority -= 30;
|
|
232
|
+
|
|
233
|
+
// No remote = might need setup
|
|
234
|
+
if (!local.status.hasRemote) priority -= 10;
|
|
235
|
+
|
|
236
|
+
return priority;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Get timestamp from a Date object or ISO string
|
|
241
|
+
*/
|
|
242
|
+
function getTimestamp(date: Date | string | null | undefined): number {
|
|
243
|
+
if (!date) return 0;
|
|
244
|
+
if (typeof date === 'string') {
|
|
245
|
+
const parsed = new Date(date);
|
|
246
|
+
return isNaN(parsed.getTime()) ? 0 : parsed.getTime();
|
|
247
|
+
}
|
|
248
|
+
return date.getTime();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Get last activity timestamp for sorting
|
|
253
|
+
*/
|
|
254
|
+
function getLastActivity(repo: UnifiedRepo): number {
|
|
255
|
+
// Prefer local commit date if available
|
|
256
|
+
if (repo.local?.status?.lastLocalCommit) {
|
|
257
|
+
return getTimestamp(repo.local.status.lastLocalCommit);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Fall back to GitHub pushed_at
|
|
261
|
+
if (repo.github?.pushedAt) {
|
|
262
|
+
return getTimestamp(repo.github.pushedAt);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Filter unified repos by text search
|
|
270
|
+
*/
|
|
271
|
+
export function filterUnifiedRepos(
|
|
272
|
+
repos: UnifiedRepo[],
|
|
273
|
+
filterText: string
|
|
274
|
+
): UnifiedRepo[] {
|
|
275
|
+
if (!filterText.trim()) return repos;
|
|
276
|
+
|
|
277
|
+
const lower = filterText.toLowerCase();
|
|
278
|
+
|
|
279
|
+
return repos.filter((r) => {
|
|
280
|
+
// Match name
|
|
281
|
+
if (r.name.toLowerCase().includes(lower)) return true;
|
|
282
|
+
|
|
283
|
+
// Match local path
|
|
284
|
+
if (r.localPath?.toLowerCase().includes(lower)) return true;
|
|
285
|
+
|
|
286
|
+
// Match GitHub full name
|
|
287
|
+
if (r.github?.fullName.toLowerCase().includes(lower)) return true;
|
|
288
|
+
|
|
289
|
+
// Match description
|
|
290
|
+
if (r.github?.description?.toLowerCase().includes(lower)) return true;
|
|
291
|
+
|
|
292
|
+
// Match language
|
|
293
|
+
if (r.github?.language?.toLowerCase().includes(lower)) return true;
|
|
294
|
+
|
|
295
|
+
// Match source type
|
|
296
|
+
if (r.source.includes(lower)) return true;
|
|
297
|
+
|
|
298
|
+
return false;
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Fetch GitHub repos and create unified view
|
|
304
|
+
*/
|
|
305
|
+
export async function fetchUnifiedRepos(
|
|
306
|
+
localProjects: Project[],
|
|
307
|
+
options?: {
|
|
308
|
+
includeOrgs?: boolean;
|
|
309
|
+
includeArchived?: boolean;
|
|
310
|
+
includeForks?: boolean;
|
|
311
|
+
}
|
|
312
|
+
): Promise<{
|
|
313
|
+
unified: UnifiedRepo[];
|
|
314
|
+
githubRepos: GitHubRepoInfo[];
|
|
315
|
+
error?: string;
|
|
316
|
+
}> {
|
|
317
|
+
if (!defaultGitHubService.hasToken()) {
|
|
318
|
+
return {
|
|
319
|
+
unified: localProjects.map((p) => ({
|
|
320
|
+
id: p.id,
|
|
321
|
+
name: p.name,
|
|
322
|
+
source: "local" as const,
|
|
323
|
+
local: p,
|
|
324
|
+
github: null,
|
|
325
|
+
isCloned: true,
|
|
326
|
+
isOnGitHub: false,
|
|
327
|
+
localPath: p.path,
|
|
328
|
+
})),
|
|
329
|
+
githubRepos: [],
|
|
330
|
+
error: "GITHUB_TOKEN not set. Run: export GITHUB_TOKEN=your_token",
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
const rawRepos = await defaultGitHubService.getAllRepos(options);
|
|
336
|
+
const githubRepos = rawRepos.map(toGitHubRepoInfo);
|
|
337
|
+
const unified = createUnifiedView(localProjects, githubRepos);
|
|
338
|
+
|
|
339
|
+
return { unified, githubRepos };
|
|
340
|
+
} catch (error) {
|
|
341
|
+
return {
|
|
342
|
+
unified: localProjects.map((p) => ({
|
|
343
|
+
id: p.id,
|
|
344
|
+
name: p.name,
|
|
345
|
+
source: "local" as const,
|
|
346
|
+
local: p,
|
|
347
|
+
github: null,
|
|
348
|
+
isCloned: true,
|
|
349
|
+
isOnGitHub: false,
|
|
350
|
+
localPath: p.path,
|
|
351
|
+
})),
|
|
352
|
+
githubRepos: [],
|
|
353
|
+
error: errorToString(error),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Clone a GitHub repo to a target directory
|
|
360
|
+
*/
|
|
361
|
+
export async function cloneGitHubRepo(
|
|
362
|
+
repo: UnifiedRepo,
|
|
363
|
+
targetDir: string,
|
|
364
|
+
useSSH = true,
|
|
365
|
+
gitService: GitService = bunGitService
|
|
366
|
+
): Promise<{ success: boolean; path?: string; error?: string }> {
|
|
367
|
+
if (!repo.github) {
|
|
368
|
+
return { success: false, error: "No GitHub info available for this repo" };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Check if target directory exists
|
|
372
|
+
// TODO: Re-enable after fixing test environment
|
|
373
|
+
// if (!existsSync(targetDir)) {
|
|
374
|
+
// return { success: false, error: `Target directory does not exist: ${targetDir}` };
|
|
375
|
+
// }
|
|
376
|
+
|
|
377
|
+
const repoPath = `${targetDir}/${repo.name}`;
|
|
378
|
+
const url = useSSH ? repo.github.sshUrl : repo.github.cloneUrl;
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
const result = await gitService.clone(url, repoPath);
|
|
382
|
+
if (result.success) {
|
|
383
|
+
return { success: true, path: repoPath };
|
|
384
|
+
}
|
|
385
|
+
return { success: false, error: result.error || "Clone failed" };
|
|
386
|
+
} catch (error) {
|
|
387
|
+
return {
|
|
388
|
+
success: false,
|
|
389
|
+
error: errorToString(error),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Get statistics about the unified view
|
|
396
|
+
*/
|
|
397
|
+
export function getUnifiedStats(repos: UnifiedRepo[]): {
|
|
398
|
+
total: number;
|
|
399
|
+
localOnly: number;
|
|
400
|
+
githubOnly: number;
|
|
401
|
+
both: number;
|
|
402
|
+
dirty: number;
|
|
403
|
+
unpushed: number;
|
|
404
|
+
unpulled: number;
|
|
405
|
+
} {
|
|
406
|
+
return {
|
|
407
|
+
total: repos.length,
|
|
408
|
+
localOnly: repos.filter((r) => r.source === "local").length,
|
|
409
|
+
githubOnly: repos.filter((r) => r.source === "github").length,
|
|
410
|
+
both: repos.filter((r) => r.source === "both").length,
|
|
411
|
+
dirty: repos.filter((r) => r.local?.status?.isDirty).length,
|
|
412
|
+
unpushed: repos.filter((r) => r.local?.status?.isAhead).length,
|
|
413
|
+
unpulled: repos.filter((r) => r.local?.status?.isBehind).length,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { useStore } from "../state/store.tsx";
|
|
3
|
+
import { setMessage } from "../state/actions.ts";
|
|
4
|
+
import { batchFetch as defaultBatchFetch } from "../operations/batch.ts";
|
|
5
|
+
import { refreshProjectStatuses } from "../git/operations.ts";
|
|
6
|
+
import { BACKGROUND_FETCH } from "../constants.ts";
|
|
7
|
+
import type { GitforestConfig, Project, BatchResult } from "../types/index.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Dependencies that can be injected for testing
|
|
11
|
+
*/
|
|
12
|
+
export interface BackgroundFetchDeps {
|
|
13
|
+
batchFetch: (projects: Project[], options?: { concurrency?: number }) => Promise<BatchResult>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useBackgroundFetch(
|
|
17
|
+
config: GitforestConfig,
|
|
18
|
+
onRefresh: () => Promise<void>,
|
|
19
|
+
deps?: Partial<BackgroundFetchDeps>,
|
|
20
|
+
) {
|
|
21
|
+
const { state, dispatch } = useStore();
|
|
22
|
+
const batchFetch = deps?.batchFetch ?? defaultBatchFetch;
|
|
23
|
+
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
24
|
+
const isFetchingRef = useRef(false);
|
|
25
|
+
|
|
26
|
+
// Use config value or fallback to default
|
|
27
|
+
const intervalMs = (config.cache.backgroundRefreshIntervalSeconds ?? 300) * 1000;
|
|
28
|
+
const isEnabled = config.cache.enableBackgroundRefresh !== false;
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
// Don't run if disabled or while loading or during an action
|
|
32
|
+
if (!isEnabled || state.isLoading || state.actionInProgress) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const runBackgroundFetch = async () => {
|
|
37
|
+
// Prevent concurrent fetches
|
|
38
|
+
if (isFetchingRef.current || state.actionInProgress) return;
|
|
39
|
+
isFetchingRef.current = true;
|
|
40
|
+
|
|
41
|
+
const gitProjects = state.projects.filter(
|
|
42
|
+
(p) => p.type === "git" && p.status?.hasRemote
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (gitProjects.length === 0) {
|
|
46
|
+
isFetchingRef.current = false;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
// Silently fetch in background
|
|
52
|
+
await batchFetch(gitProjects, { concurrency: config.scan.concurrency });
|
|
53
|
+
|
|
54
|
+
// Refresh project statuses
|
|
55
|
+
await onRefresh();
|
|
56
|
+
} finally {
|
|
57
|
+
isFetchingRef.current = false;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Set up interval
|
|
62
|
+
timerRef.current = setInterval(runBackgroundFetch, intervalMs);
|
|
63
|
+
|
|
64
|
+
// Run initial fetch after a short delay
|
|
65
|
+
const initialTimeout = setTimeout(runBackgroundFetch, 10000); // 10 seconds after start
|
|
66
|
+
|
|
67
|
+
return () => {
|
|
68
|
+
if (timerRef.current) {
|
|
69
|
+
clearInterval(timerRef.current);
|
|
70
|
+
}
|
|
71
|
+
clearTimeout(initialTimeout);
|
|
72
|
+
};
|
|
73
|
+
}, [state.isLoading, state.actionInProgress, state.projects, config.scan.concurrency, intervalMs, isEnabled, dispatch, onRefresh]);
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { useStore, useFilteredProjects } from "../state/store.tsx";
|
|
3
|
+
import { setMessage, startAction, endAction } from "../state/actions.ts";
|
|
4
|
+
import { initGitInProject } from "../git/operations.ts";
|
|
5
|
+
import { defaultGitHubService } from "../services/github.ts";
|
|
6
|
+
import type { GitforestConfig, Project } from "../types/index.ts";
|
|
7
|
+
|
|
8
|
+
interface UseConfirmDialogActionsOptions {
|
|
9
|
+
config: GitforestConfig;
|
|
10
|
+
onRefresh: () => Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useConfirmDialogActions({
|
|
14
|
+
config,
|
|
15
|
+
onRefresh,
|
|
16
|
+
}: UseConfirmDialogActionsOptions) {
|
|
17
|
+
const { state, dispatch } = useStore();
|
|
18
|
+
const filteredProjects = useFilteredProjects();
|
|
19
|
+
|
|
20
|
+
const handleConfirm = useCallback(
|
|
21
|
+
async (options: { visibility?: "private" | "public" }) => {
|
|
22
|
+
const { confirmDialog } = state;
|
|
23
|
+
if (!confirmDialog) return;
|
|
24
|
+
|
|
25
|
+
const { operation, projectPaths } = confirmDialog;
|
|
26
|
+
const isPrivate = options.visibility === "private";
|
|
27
|
+
|
|
28
|
+
// Find projects by path
|
|
29
|
+
const projects = filteredProjects.filter((p) =>
|
|
30
|
+
projectPaths.includes(p.path)
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
dispatch({ type: "HIDE_CONFIRM_DIALOG" });
|
|
34
|
+
|
|
35
|
+
if (operation === "setup") {
|
|
36
|
+
await handleSetup(projects, isPrivate);
|
|
37
|
+
} else if (operation === "create") {
|
|
38
|
+
await handleCreate(projects, isPrivate);
|
|
39
|
+
} else if (operation === "archive") {
|
|
40
|
+
await handleArchive(projects);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await onRefresh();
|
|
44
|
+
},
|
|
45
|
+
[state, filteredProjects, dispatch, config, onRefresh]
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const handleCancel = useCallback(() => {
|
|
49
|
+
dispatch({ type: "HIDE_CONFIRM_DIALOG" });
|
|
50
|
+
}, [dispatch]);
|
|
51
|
+
|
|
52
|
+
const handleSetup = async (projects: Project[], isPrivate: boolean) => {
|
|
53
|
+
const nonGitProjects = projects.filter((p) => p.type === "non-git");
|
|
54
|
+
const gitProjects = projects.filter((p) => p.type === "git");
|
|
55
|
+
|
|
56
|
+
dispatch(startAction(`Setting up ${projects.length} projects`));
|
|
57
|
+
|
|
58
|
+
let initSuccess = 0;
|
|
59
|
+
let createSuccess = 0;
|
|
60
|
+
|
|
61
|
+
// First, init git in non-git projects
|
|
62
|
+
for (const project of nonGitProjects) {
|
|
63
|
+
const result = await initGitInProject(project.path);
|
|
64
|
+
if (result.success) initSuccess++;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Then create repos for all projects
|
|
68
|
+
const allProjects = [...nonGitProjects, ...gitProjects];
|
|
69
|
+
for (const project of allProjects) {
|
|
70
|
+
const result = await defaultGitHubService.createRepo({
|
|
71
|
+
name: project.name,
|
|
72
|
+
isPrivate,
|
|
73
|
+
localPath: project.path,
|
|
74
|
+
});
|
|
75
|
+
if (result.success) createSuccess++;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
dispatch(endAction());
|
|
79
|
+
dispatch(
|
|
80
|
+
setMessage(
|
|
81
|
+
`Setup complete: ${initSuccess} initialized, ${createSuccess}/${allProjects.length} repos created`
|
|
82
|
+
)
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const handleCreate = async (projects: Project[], isPrivate: boolean) => {
|
|
87
|
+
dispatch(startAction(`Creating ${projects.length} GitHub repos`));
|
|
88
|
+
|
|
89
|
+
let success = 0;
|
|
90
|
+
for (const project of projects) {
|
|
91
|
+
const result = await defaultGitHubService.createRepo({
|
|
92
|
+
name: project.name,
|
|
93
|
+
isPrivate,
|
|
94
|
+
localPath: project.path,
|
|
95
|
+
});
|
|
96
|
+
if (result.success) success++;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
dispatch(endAction());
|
|
100
|
+
dispatch(setMessage(`Created ${success}/${projects.length} GitHub repos`));
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handleArchive = async (projects: Project[]) => {
|
|
104
|
+
dispatch(startAction(`Archiving ${projects.length} repos`));
|
|
105
|
+
|
|
106
|
+
let success = 0;
|
|
107
|
+
for (const project of projects) {
|
|
108
|
+
const result = await defaultGitHubService.archiveRepo(project.name);
|
|
109
|
+
if (result.success) success++;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
dispatch(endAction());
|
|
113
|
+
dispatch(setMessage(`Archived ${success}/${projects.length} repos`));
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
handleConfirm,
|
|
118
|
+
handleCancel,
|
|
119
|
+
};
|
|
120
|
+
}
|