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,632 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure formatting functions for CLI output
|
|
3
|
+
* These functions are side-effect free and return strings for display
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import chalk from "chalk";
|
|
7
|
+
import type { Project, UnifiedRepo, BatchResult, ViewMode } from "../types/index.ts";
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// Types
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
export interface FormatOptions {
|
|
14
|
+
verbose?: boolean;
|
|
15
|
+
json?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ProjectStats {
|
|
19
|
+
total: number;
|
|
20
|
+
gitCount: number;
|
|
21
|
+
submoduleCount: number;
|
|
22
|
+
nonGitCount: number;
|
|
23
|
+
dirtyCount: number;
|
|
24
|
+
unpushedCount: number;
|
|
25
|
+
unpulledCount: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface StatusSummary {
|
|
29
|
+
total: number;
|
|
30
|
+
dirty: Project[];
|
|
31
|
+
unpushed: Project[];
|
|
32
|
+
unpulled: Project[];
|
|
33
|
+
noRemote: Project[];
|
|
34
|
+
nonGit: Project[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface UnifiedStats {
|
|
38
|
+
total: number;
|
|
39
|
+
both: number;
|
|
40
|
+
localOnly: number;
|
|
41
|
+
githubOnly: number;
|
|
42
|
+
dirty: number;
|
|
43
|
+
unpushed: number;
|
|
44
|
+
unpulled: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ============================================================================
|
|
48
|
+
// Project Formatting
|
|
49
|
+
// ============================================================================
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get the type icon for a project
|
|
53
|
+
*/
|
|
54
|
+
export function getProjectTypeIcon(type: Project["type"]): string {
|
|
55
|
+
const icons = {
|
|
56
|
+
git: chalk.green("●"),
|
|
57
|
+
"git-submodule": chalk.magenta("○"),
|
|
58
|
+
"non-git": chalk.gray("-"),
|
|
59
|
+
};
|
|
60
|
+
return icons[type];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Format project status as colored string parts
|
|
65
|
+
*/
|
|
66
|
+
export function formatProjectStatus(project: Project): string {
|
|
67
|
+
const statusParts: string[] = [];
|
|
68
|
+
|
|
69
|
+
if (project.status) {
|
|
70
|
+
if (project.status.isDirty) {
|
|
71
|
+
statusParts.push(chalk.yellow(`${project.status.modifiedCount}M`));
|
|
72
|
+
}
|
|
73
|
+
if (project.status.untrackedCount > 0) {
|
|
74
|
+
statusParts.push(chalk.gray(`${project.status.untrackedCount}?`));
|
|
75
|
+
}
|
|
76
|
+
if (project.status.isAhead) {
|
|
77
|
+
statusParts.push(chalk.blue(`↑${project.status.unpushedCommits}`));
|
|
78
|
+
}
|
|
79
|
+
if (project.status.isBehind) {
|
|
80
|
+
statusParts.push(chalk.magenta(`↓${project.status.unpulledCommits}`));
|
|
81
|
+
}
|
|
82
|
+
if (!project.status.hasRemote) {
|
|
83
|
+
statusParts.push(chalk.gray("no-remote"));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return statusParts.length > 0 ? statusParts.join(" ") : chalk.green("clean");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Format a single project for display
|
|
92
|
+
*/
|
|
93
|
+
export function formatProject(project: Project, verbose = false): string {
|
|
94
|
+
const typeIcon = getProjectTypeIcon(project.type);
|
|
95
|
+
const status = formatProjectStatus(project);
|
|
96
|
+
|
|
97
|
+
if (verbose) {
|
|
98
|
+
return [
|
|
99
|
+
`${typeIcon} ${chalk.bold(project.name)}`,
|
|
100
|
+
` Path: ${project.path}`,
|
|
101
|
+
` Status: ${status}`,
|
|
102
|
+
` Branch: ${project.status?.currentBranch ?? "N/A"}`,
|
|
103
|
+
].join("\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return `${typeIcon} ${project.name.padEnd(30)} ${status}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Calculate stats from a list of projects
|
|
111
|
+
*/
|
|
112
|
+
export function calculateProjectStats(projects: Project[]): ProjectStats {
|
|
113
|
+
return {
|
|
114
|
+
total: projects.length,
|
|
115
|
+
gitCount: projects.filter((p) => p.type === "git").length,
|
|
116
|
+
submoduleCount: projects.filter((p) => p.type === "git-submodule").length,
|
|
117
|
+
nonGitCount: projects.filter((p) => p.type === "non-git").length,
|
|
118
|
+
dirtyCount: projects.filter((p) => p.status?.isDirty).length,
|
|
119
|
+
unpushedCount: projects.filter((p) => p.status?.isAhead).length,
|
|
120
|
+
unpulledCount: projects.filter((p) => p.status?.isBehind).length,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Format project stats as a summary line
|
|
126
|
+
*/
|
|
127
|
+
export function formatProjectStats(stats: ProjectStats): string {
|
|
128
|
+
return [
|
|
129
|
+
`${stats.gitCount} git`,
|
|
130
|
+
`${stats.submoduleCount} submodules`,
|
|
131
|
+
`${stats.nonGitCount} non-git`,
|
|
132
|
+
chalk.yellow(`${stats.dirtyCount} dirty`),
|
|
133
|
+
chalk.blue(`${stats.unpushedCount} unpushed`),
|
|
134
|
+
].join(" | ");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Format full project list output
|
|
139
|
+
*/
|
|
140
|
+
export function formatProjectList(
|
|
141
|
+
projects: Project[],
|
|
142
|
+
options: FormatOptions = {}
|
|
143
|
+
): string {
|
|
144
|
+
if (options.json) {
|
|
145
|
+
return JSON.stringify(projects, null, 2);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const lines: string[] = [];
|
|
149
|
+
lines.push(chalk.cyan(`\nFound ${projects.length} projects:\n`));
|
|
150
|
+
|
|
151
|
+
for (const project of projects) {
|
|
152
|
+
lines.push(formatProject(project, options.verbose));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const stats = calculateProjectStats(projects);
|
|
156
|
+
lines.push(chalk.gray("\n---"));
|
|
157
|
+
lines.push(formatProjectStats(stats));
|
|
158
|
+
|
|
159
|
+
return lines.join("\n");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ============================================================================
|
|
163
|
+
// Status Summary Formatting
|
|
164
|
+
// ============================================================================
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Calculate status summary from projects
|
|
168
|
+
*/
|
|
169
|
+
export function calculateStatusSummary(projects: Project[]): StatusSummary {
|
|
170
|
+
return {
|
|
171
|
+
total: projects.length,
|
|
172
|
+
dirty: projects.filter((p) => p.status?.isDirty),
|
|
173
|
+
unpushed: projects.filter((p) => p.status?.isAhead),
|
|
174
|
+
unpulled: projects.filter((p) => p.status?.isBehind),
|
|
175
|
+
noRemote: projects.filter((p) => p.type === "git" && !p.status?.hasRemote),
|
|
176
|
+
nonGit: projects.filter((p) => p.type === "non-git"),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Format status summary as JSON
|
|
182
|
+
*/
|
|
183
|
+
export function formatStatusSummaryJson(summary: StatusSummary): string {
|
|
184
|
+
return JSON.stringify(
|
|
185
|
+
{
|
|
186
|
+
total: summary.total,
|
|
187
|
+
dirty: summary.dirty.map((p) => p.name),
|
|
188
|
+
unpushed: summary.unpushed.map((p) => p.name),
|
|
189
|
+
unpulled: summary.unpulled.map((p) => p.name),
|
|
190
|
+
noRemote: summary.noRemote.map((p) => p.name),
|
|
191
|
+
nonGit: summary.nonGit.map((p) => p.name),
|
|
192
|
+
},
|
|
193
|
+
null,
|
|
194
|
+
2
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Format status summary for display
|
|
200
|
+
*/
|
|
201
|
+
export function formatStatusSummary(summary: StatusSummary): string {
|
|
202
|
+
const lines: string[] = [];
|
|
203
|
+
lines.push(chalk.cyan(`\n=== Status Summary (${summary.total} projects) ===\n`));
|
|
204
|
+
|
|
205
|
+
if (summary.dirty.length > 0) {
|
|
206
|
+
lines.push(chalk.yellow(`Dirty (${summary.dirty.length}):`));
|
|
207
|
+
summary.dirty.forEach((p) => lines.push(` ${p.name}`));
|
|
208
|
+
lines.push("");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (summary.unpushed.length > 0) {
|
|
212
|
+
lines.push(chalk.blue(`Unpushed (${summary.unpushed.length}):`));
|
|
213
|
+
summary.unpushed.forEach((p) =>
|
|
214
|
+
lines.push(` ${p.name} (↑${p.status?.unpushedCommits})`)
|
|
215
|
+
);
|
|
216
|
+
lines.push("");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (summary.unpulled.length > 0) {
|
|
220
|
+
lines.push(chalk.magenta(`Unpulled (${summary.unpulled.length}):`));
|
|
221
|
+
summary.unpulled.forEach((p) =>
|
|
222
|
+
lines.push(` ${p.name} (↓${p.status?.unpulledCommits})`)
|
|
223
|
+
);
|
|
224
|
+
lines.push("");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (summary.noRemote.length > 0) {
|
|
228
|
+
lines.push(chalk.gray(`No Remote (${summary.noRemote.length}):`));
|
|
229
|
+
summary.noRemote.forEach((p) => lines.push(` ${p.name}`));
|
|
230
|
+
lines.push("");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (
|
|
234
|
+
summary.dirty.length === 0 &&
|
|
235
|
+
summary.unpushed.length === 0 &&
|
|
236
|
+
summary.unpulled.length === 0
|
|
237
|
+
) {
|
|
238
|
+
lines.push(chalk.green("✓ All repositories are clean and in sync!"));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return lines.join("\n");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ============================================================================
|
|
245
|
+
// Batch Operation Result Formatting
|
|
246
|
+
// ============================================================================
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Format batch operation result
|
|
250
|
+
*/
|
|
251
|
+
export function formatBatchResult(
|
|
252
|
+
result: BatchResult,
|
|
253
|
+
operationName: string
|
|
254
|
+
): string {
|
|
255
|
+
const lines: string[] = [];
|
|
256
|
+
|
|
257
|
+
lines.push(
|
|
258
|
+
chalk.green(`✓ ${operationName} ${result.successful}/${result.total} repositories`)
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
if (result.failed > 0) {
|
|
262
|
+
lines.push(chalk.red(`✗ ${result.failed} failed:`));
|
|
263
|
+
result.results
|
|
264
|
+
.filter((r) => !r.success)
|
|
265
|
+
.forEach((r) => lines.push(` ${r.projectPath}: ${r.error}`));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
lines.push(chalk.gray(`Duration: ${result.duration}ms`));
|
|
269
|
+
|
|
270
|
+
return lines.join("\n");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Format progress indicator (for stdout.write)
|
|
275
|
+
*/
|
|
276
|
+
export function formatProgress(completed: number, total: number): string {
|
|
277
|
+
return `\r Progress: ${completed}/${total}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ============================================================================
|
|
281
|
+
// Dirty Repos Formatting
|
|
282
|
+
// ============================================================================
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Format dirty repos list
|
|
286
|
+
*/
|
|
287
|
+
export function formatDirtyRepos(dirty: Project[], json = false): string {
|
|
288
|
+
if (json) {
|
|
289
|
+
return JSON.stringify(dirty, null, 2);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (dirty.length === 0) {
|
|
293
|
+
return chalk.green("\n✓ All repositories are clean!");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const lines: string[] = [];
|
|
297
|
+
lines.push(chalk.yellow(`\n${dirty.length} dirty repositories:\n`));
|
|
298
|
+
|
|
299
|
+
dirty.forEach((p) => {
|
|
300
|
+
const changes: string[] = [];
|
|
301
|
+
if (p.status?.modifiedCount) changes.push(`${p.status.modifiedCount} modified`);
|
|
302
|
+
if (p.status?.stagedCount) changes.push(`${p.status.stagedCount} staged`);
|
|
303
|
+
if (p.status?.untrackedCount) changes.push(`${p.status.untrackedCount} untracked`);
|
|
304
|
+
lines.push(` ${chalk.bold(p.name)}: ${changes.join(", ")}`);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
return lines.join("\n");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ============================================================================
|
|
311
|
+
// Unified Repo Formatting
|
|
312
|
+
// ============================================================================
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Get source icon for unified repo
|
|
316
|
+
*/
|
|
317
|
+
export function getSourceIcon(source: UnifiedRepo["source"]): string {
|
|
318
|
+
const icons = {
|
|
319
|
+
local: chalk.blue("L"),
|
|
320
|
+
github: chalk.magenta("G"),
|
|
321
|
+
both: chalk.green("✓"),
|
|
322
|
+
};
|
|
323
|
+
return icons[source];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Format unified repo status
|
|
328
|
+
*/
|
|
329
|
+
export function formatUnifiedRepoStatus(repo: UnifiedRepo): string {
|
|
330
|
+
const statusParts: string[] = [];
|
|
331
|
+
|
|
332
|
+
if (repo.source === "github") {
|
|
333
|
+
statusParts.push(chalk.yellow("not cloned"));
|
|
334
|
+
} else if (repo.local?.status) {
|
|
335
|
+
if (repo.local.status.isDirty) {
|
|
336
|
+
statusParts.push(chalk.yellow(`${repo.local.status.modifiedCount}M`));
|
|
337
|
+
}
|
|
338
|
+
if (repo.local.status.isAhead) {
|
|
339
|
+
statusParts.push(chalk.blue(`↑${repo.local.status.unpushedCommits}`));
|
|
340
|
+
}
|
|
341
|
+
if (repo.local.status.isBehind) {
|
|
342
|
+
statusParts.push(chalk.magenta(`↓${repo.local.status.unpulledCommits}`));
|
|
343
|
+
}
|
|
344
|
+
if (!repo.isOnGitHub) {
|
|
345
|
+
statusParts.push(chalk.gray("local-only"));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return statusParts.length > 0 ? statusParts.join(" ") : chalk.green("synced");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Format a unified repo for display
|
|
354
|
+
*/
|
|
355
|
+
export function formatUnifiedRepo(repo: UnifiedRepo, verbose = false): string {
|
|
356
|
+
const sourceIcon = getSourceIcon(repo.source);
|
|
357
|
+
const status = formatUnifiedRepoStatus(repo);
|
|
358
|
+
const visibility = repo.github?.isPrivate ? chalk.gray("(private)") : "";
|
|
359
|
+
|
|
360
|
+
if (verbose) {
|
|
361
|
+
const lines = [
|
|
362
|
+
`${sourceIcon} ${chalk.bold(repo.name)} ${visibility}`,
|
|
363
|
+
repo.localPath ? ` Local: ${repo.localPath}` : null,
|
|
364
|
+
repo.github ? ` GitHub: ${repo.github.fullName}` : null,
|
|
365
|
+
repo.github?.description ? ` Desc: ${repo.github.description}` : null,
|
|
366
|
+
` Status: ${status}`,
|
|
367
|
+
].filter(Boolean);
|
|
368
|
+
return lines.join("\n");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return `${sourceIcon} ${repo.name.padEnd(35)} ${status} ${visibility}`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Format unified stats line
|
|
376
|
+
*/
|
|
377
|
+
export function formatUnifiedStats(stats: UnifiedStats): string {
|
|
378
|
+
return [
|
|
379
|
+
chalk.green(`${stats.both} synced`),
|
|
380
|
+
chalk.blue(`${stats.localOnly} local-only`),
|
|
381
|
+
chalk.magenta(`${stats.githubOnly} github-only`),
|
|
382
|
+
chalk.yellow(`${stats.dirty} dirty`),
|
|
383
|
+
chalk.blue(`${stats.unpushed} unpushed`),
|
|
384
|
+
].join(" | ");
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Get view mode label
|
|
389
|
+
*/
|
|
390
|
+
export function getViewModeLabel(mode: ViewMode): string {
|
|
391
|
+
const labels: Record<ViewMode, string> = {
|
|
392
|
+
local: "Local Only",
|
|
393
|
+
github: "GitHub Only (Not Cloned)",
|
|
394
|
+
combined: "All Repositories",
|
|
395
|
+
};
|
|
396
|
+
return labels[mode];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Format unified repo list
|
|
401
|
+
*/
|
|
402
|
+
export function formatUnifiedRepoList(
|
|
403
|
+
repos: UnifiedRepo[],
|
|
404
|
+
stats: UnifiedStats,
|
|
405
|
+
viewMode: ViewMode,
|
|
406
|
+
options: FormatOptions = {}
|
|
407
|
+
): string {
|
|
408
|
+
if (options.json) {
|
|
409
|
+
return JSON.stringify(repos, null, 2);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const lines: string[] = [];
|
|
413
|
+
lines.push(chalk.cyan(`\n=== ${getViewModeLabel(viewMode)} (${repos.length}) ===\n`));
|
|
414
|
+
|
|
415
|
+
for (const repo of repos) {
|
|
416
|
+
lines.push(formatUnifiedRepo(repo, options.verbose));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
lines.push(chalk.gray("\n---"));
|
|
420
|
+
lines.push(formatUnifiedStats(stats));
|
|
421
|
+
|
|
422
|
+
return lines.join("\n");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ============================================================================
|
|
426
|
+
// GitHub Auth Formatting
|
|
427
|
+
// ============================================================================
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Format GitHub auth success
|
|
431
|
+
*/
|
|
432
|
+
export function formatAuthSuccess(login: string, name?: string): string {
|
|
433
|
+
const lines = [chalk.green(`✓ Authenticated as ${login}`)];
|
|
434
|
+
if (name) {
|
|
435
|
+
lines.push(chalk.gray(` Name: ${name}`));
|
|
436
|
+
}
|
|
437
|
+
return lines.join("\n");
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Format GitHub auth failure
|
|
442
|
+
*/
|
|
443
|
+
export function formatAuthFailure(error?: string): string {
|
|
444
|
+
const lines = [chalk.red(`✗ Authentication failed${error ? `: ${error}` : ""}`)];
|
|
445
|
+
return lines.join("\n");
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Format GitHub token not set message
|
|
450
|
+
*/
|
|
451
|
+
export function formatNoToken(): string {
|
|
452
|
+
return [
|
|
453
|
+
chalk.red("✗ GITHUB_TOKEN not set"),
|
|
454
|
+
chalk.gray("\nTo authenticate:"),
|
|
455
|
+
chalk.white(" gitforest login"),
|
|
456
|
+
chalk.gray(" (Opens browser for GitHub OAuth)"),
|
|
457
|
+
chalk.gray("\nOr set token manually:"),
|
|
458
|
+
chalk.gray(" export GITHUB_TOKEN=your_token"),
|
|
459
|
+
].join("\n");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ============================================================================
|
|
463
|
+
// Operation Messages
|
|
464
|
+
// ============================================================================
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Format scanning message
|
|
468
|
+
*/
|
|
469
|
+
export function formatScanning(message = "Scanning directories..."): string {
|
|
470
|
+
return chalk.cyan(message);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Format warning message
|
|
475
|
+
*/
|
|
476
|
+
export function formatWarning(message: string): string {
|
|
477
|
+
return chalk.yellow(`Warning: ${message}`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Format error message
|
|
482
|
+
*/
|
|
483
|
+
export function formatError(message: string): string {
|
|
484
|
+
return chalk.red(message);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Format success message
|
|
489
|
+
*/
|
|
490
|
+
export function formatSuccess(message: string): string {
|
|
491
|
+
return chalk.green(message);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Format info message
|
|
496
|
+
*/
|
|
497
|
+
export function formatInfo(message: string): string {
|
|
498
|
+
return chalk.cyan(message);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Format a simple operation result (success/failure)
|
|
503
|
+
*/
|
|
504
|
+
export function formatOperationItem(
|
|
505
|
+
name: string,
|
|
506
|
+
success: boolean,
|
|
507
|
+
error?: string
|
|
508
|
+
): string {
|
|
509
|
+
if (success) {
|
|
510
|
+
return chalk.green(` ✓ ${name}`);
|
|
511
|
+
}
|
|
512
|
+
return chalk.red(` ✗ ${name}${error ? `: ${error}` : ""}`);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Format operation summary line
|
|
517
|
+
*/
|
|
518
|
+
export function formatOperationSummary(
|
|
519
|
+
operation: string,
|
|
520
|
+
success: number,
|
|
521
|
+
total: number
|
|
522
|
+
): string {
|
|
523
|
+
return chalk.green(`${operation} ${success}/${total} ${success === 1 ? "item" : "items"}`);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ============================================================================
|
|
527
|
+
// Unified Status Formatting
|
|
528
|
+
// ============================================================================
|
|
529
|
+
|
|
530
|
+
export interface UnifiedStatusData {
|
|
531
|
+
stats: UnifiedStats;
|
|
532
|
+
githubOnly: UnifiedRepo[];
|
|
533
|
+
localOnly: UnifiedRepo[];
|
|
534
|
+
dirty: UnifiedRepo[];
|
|
535
|
+
unpushed: UnifiedRepo[];
|
|
536
|
+
unpulled: UnifiedRepo[];
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Format unified status as JSON
|
|
541
|
+
*/
|
|
542
|
+
export function formatUnifiedStatusJson(data: UnifiedStatusData): string {
|
|
543
|
+
return JSON.stringify(
|
|
544
|
+
{
|
|
545
|
+
stats: data.stats,
|
|
546
|
+
githubOnly: data.githubOnly.map((r) => r.github?.fullName),
|
|
547
|
+
localOnly: data.localOnly.map((r) => r.name),
|
|
548
|
+
dirty: data.dirty.map((r) => r.name),
|
|
549
|
+
unpushed: data.unpushed.map((r) => r.name),
|
|
550
|
+
unpulled: data.unpulled.map((r) => r.name),
|
|
551
|
+
},
|
|
552
|
+
null,
|
|
553
|
+
2
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Format unified status for display
|
|
559
|
+
*/
|
|
560
|
+
export function formatUnifiedStatusDisplay(data: UnifiedStatusData): string {
|
|
561
|
+
const { stats, githubOnly, dirty, unpushed, unpulled } = data;
|
|
562
|
+
const lines: string[] = [];
|
|
563
|
+
|
|
564
|
+
lines.push(chalk.cyan(`\n=== Unified Status ===\n`));
|
|
565
|
+
|
|
566
|
+
lines.push(`Total: ${stats.total} repositories`);
|
|
567
|
+
lines.push(` ${chalk.green(`${stats.both} synced`)} (local + GitHub)`);
|
|
568
|
+
lines.push(` ${chalk.blue(`${stats.localOnly} local-only`)} (not on GitHub)`);
|
|
569
|
+
lines.push(` ${chalk.magenta(`${stats.githubOnly} github-only`)} (not cloned)\n`);
|
|
570
|
+
|
|
571
|
+
if (githubOnly.length > 0) {
|
|
572
|
+
lines.push(chalk.magenta(`Not Cloned (${githubOnly.length}):`));
|
|
573
|
+
githubOnly.slice(0, 10).forEach((r) => {
|
|
574
|
+
const desc = r.github?.description
|
|
575
|
+
? chalk.gray(` - ${r.github.description.slice(0, 40)}`)
|
|
576
|
+
: "";
|
|
577
|
+
lines.push(` ${r.github?.fullName}${desc}`);
|
|
578
|
+
});
|
|
579
|
+
if (githubOnly.length > 10) {
|
|
580
|
+
lines.push(chalk.gray(` ... and ${githubOnly.length - 10} more`));
|
|
581
|
+
}
|
|
582
|
+
lines.push("");
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (dirty.length > 0) {
|
|
586
|
+
lines.push(chalk.yellow(`Dirty (${dirty.length}):`));
|
|
587
|
+
dirty.forEach((r) => lines.push(` ${r.name}`));
|
|
588
|
+
lines.push("");
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (unpushed.length > 0) {
|
|
592
|
+
lines.push(chalk.blue(`Unpushed (${unpushed.length}):`));
|
|
593
|
+
unpushed.forEach((r) =>
|
|
594
|
+
lines.push(` ${r.name} (↑${r.local?.status?.unpushedCommits})`)
|
|
595
|
+
);
|
|
596
|
+
lines.push("");
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (unpulled.length > 0) {
|
|
600
|
+
lines.push(chalk.magenta(`Unpulled (${unpulled.length}):`));
|
|
601
|
+
unpulled.forEach((r) =>
|
|
602
|
+
lines.push(` ${r.name} (↓${r.local?.status?.unpulledCommits})`)
|
|
603
|
+
);
|
|
604
|
+
lines.push("");
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (
|
|
608
|
+
dirty.length === 0 &&
|
|
609
|
+
unpushed.length === 0 &&
|
|
610
|
+
unpulled.length === 0 &&
|
|
611
|
+
githubOnly.length === 0
|
|
612
|
+
) {
|
|
613
|
+
lines.push(chalk.green("✓ All repositories are synced!"));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return lines.join("\n");
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Format clone result item
|
|
621
|
+
*/
|
|
622
|
+
export function formatCloneItem(
|
|
623
|
+
fullName: string | undefined,
|
|
624
|
+
success: boolean,
|
|
625
|
+
path?: string,
|
|
626
|
+
error?: string
|
|
627
|
+
): string {
|
|
628
|
+
if (success) {
|
|
629
|
+
return chalk.green(` ✓ ${fullName} → ${path}`);
|
|
630
|
+
}
|
|
631
|
+
return chalk.red(` ✗ ${fullName}: ${error}`);
|
|
632
|
+
}
|