gitforest 1.1.2 → 1.2.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/package.json +1 -1
- package/src/app.tsx +57 -1
- package/src/cli/commands/auth.ts +75 -0
- package/src/cli/commands/batch.ts +141 -0
- package/src/cli/commands/dir.ts +285 -0
- package/src/cli/commands/github.ts +238 -0
- package/src/cli/commands/helpers.ts +15 -0
- package/src/cli/commands/list.ts +76 -0
- package/src/cli/commands/setup.ts +86 -0
- package/src/cli/index.ts +7 -582
- package/src/components/AddDirectoryDialog.tsx +151 -0
- package/src/components/HelpOverlay.tsx +1 -0
- package/src/components/Layout.tsx +44 -99
- package/src/components/UnifiedProjectItem.tsx +1 -1
- package/src/components/onboarding/DirectoriesStep.unit.test.ts +34 -34
- package/src/constants.ts +2 -0
- package/src/git/index.ts +1 -2
- package/src/github/unified.ts +3 -4
- package/src/hooks/useBackgroundFetch.ts +1 -1
- package/src/hooks/useConfirmDialogActions.ts +1 -1
- package/src/hooks/useKeyBindings.ts +181 -131
- package/src/hooks/useUnifiedRepos.ts +54 -44
- package/src/index.tsx +48 -7
- package/src/scanner/index.ts +34 -20
- package/src/services/editor.ts +121 -0
- package/src/state/actions.ts +23 -2
- package/src/state/reducer.ts +23 -0
- package/src/types/index.ts +14 -2
- package/src/git/service.ts +0 -539
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type { ViewMode } from "../../types/index.ts";
|
|
2
|
+
import type { CLIOptions } from "./list.ts";
|
|
3
|
+
import { showStatus } from "./list.ts";
|
|
4
|
+
import { scanAllDirectories, filterProjects } from "../../scanner/index.ts";
|
|
5
|
+
import { defaultGitHubService } from "../../services/github.ts";
|
|
6
|
+
import {
|
|
7
|
+
fetchUnifiedRepos,
|
|
8
|
+
filterByViewMode,
|
|
9
|
+
sortUnifiedRepos,
|
|
10
|
+
filterUnifiedRepos,
|
|
11
|
+
cloneGitHubRepo,
|
|
12
|
+
getUnifiedStats,
|
|
13
|
+
} from "../../github/unified.ts";
|
|
14
|
+
import { ensureAuthenticated } from "../../github/auth.ts";
|
|
15
|
+
import {
|
|
16
|
+
formatUnifiedRepoList,
|
|
17
|
+
formatWarning,
|
|
18
|
+
formatError,
|
|
19
|
+
formatInfo,
|
|
20
|
+
formatScanning,
|
|
21
|
+
formatOperationItem,
|
|
22
|
+
formatOperationSummary,
|
|
23
|
+
formatUnifiedStatusJson,
|
|
24
|
+
formatUnifiedStatusDisplay,
|
|
25
|
+
formatCloneItem,
|
|
26
|
+
} from "../formatters.ts";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create GitHub repos for projects without remotes
|
|
30
|
+
*/
|
|
31
|
+
export async function createGitHubRepos(options: CLIOptions & { isPrivate?: boolean }): Promise<void> {
|
|
32
|
+
const { config, filter, isPrivate = true } = options;
|
|
33
|
+
|
|
34
|
+
// Check GitHub auth - auto-login if not set
|
|
35
|
+
const token = await ensureAuthenticated();
|
|
36
|
+
if (!token) {
|
|
37
|
+
console.log(formatError("GitHub authentication required."));
|
|
38
|
+
console.log(formatInfo("Run 'gitforest login' to authenticate."));
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log(formatScanning());
|
|
43
|
+
const projects = await scanAllDirectories(config);
|
|
44
|
+
let noRemoteProjects = projects.filter((p) => p.type === "git" && !p.status?.hasRemote);
|
|
45
|
+
|
|
46
|
+
if (filter) {
|
|
47
|
+
noRemoteProjects = filterProjects(noRemoteProjects, filter);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (noRemoteProjects.length === 0) {
|
|
51
|
+
console.log(formatWarning("No projects without remotes found."));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log(formatInfo(`\nCreating ${isPrivate ? "private" : "public"} repos for ${noRemoteProjects.length} projects...\n`));
|
|
56
|
+
|
|
57
|
+
let success = 0;
|
|
58
|
+
for (const project of noRemoteProjects) {
|
|
59
|
+
const result = await defaultGitHubService.createRepo({
|
|
60
|
+
name: project.name,
|
|
61
|
+
isPrivate,
|
|
62
|
+
localPath: project.path,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
console.log(formatOperationItem(project.name, result.success, result.error));
|
|
66
|
+
if (result.success) success++;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
console.log(`\n${formatOperationSummary("Created", success, noRemoteProjects.length)}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Archive GitHub repositories
|
|
74
|
+
*/
|
|
75
|
+
export async function archiveRepos(options: CLIOptions & { repos: string[] }): Promise<void> {
|
|
76
|
+
const { repos } = options;
|
|
77
|
+
|
|
78
|
+
// Check GitHub auth - auto-login if not set
|
|
79
|
+
const token = await ensureAuthenticated();
|
|
80
|
+
if (!token) {
|
|
81
|
+
console.log(formatError("GitHub authentication required."));
|
|
82
|
+
console.log(formatInfo("Run 'gitforest login' to authenticate."));
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log(formatInfo(`\nArchiving ${repos.length} repositories...\n`));
|
|
87
|
+
|
|
88
|
+
let success = 0;
|
|
89
|
+
for (const repo of repos) {
|
|
90
|
+
const result = await defaultGitHubService.archiveRepo(repo);
|
|
91
|
+
console.log(formatOperationItem(repo, result.success, result.error));
|
|
92
|
+
if (result.success) success++;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log(`\n${formatOperationSummary("Archived", success, repos.length)}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* List GitHub repositories (not cloned locally)
|
|
100
|
+
*/
|
|
101
|
+
export async function listGitHubRepos(options: CLIOptions & { view?: ViewMode }): Promise<void> {
|
|
102
|
+
const { config, filter, json, verbose, view = "github" } = options;
|
|
103
|
+
|
|
104
|
+
// Check for GitHub token - auto-login if not set
|
|
105
|
+
const token = await ensureAuthenticated();
|
|
106
|
+
if (!token) {
|
|
107
|
+
console.log(formatError("GitHub authentication required."));
|
|
108
|
+
console.log(formatInfo("Run 'gitforest login' to authenticate."));
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
console.log(formatScanning("Scanning local directories..."));
|
|
113
|
+
const localProjects = await scanAllDirectories(config);
|
|
114
|
+
|
|
115
|
+
console.log(formatScanning("Fetching GitHub repositories..."));
|
|
116
|
+
const { unified, error } = await fetchUnifiedRepos(localProjects);
|
|
117
|
+
|
|
118
|
+
if (error) {
|
|
119
|
+
console.log(formatWarning(error));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Apply view filter
|
|
123
|
+
let result = filterByViewMode(unified, view);
|
|
124
|
+
|
|
125
|
+
// Apply text filter
|
|
126
|
+
if (filter) {
|
|
127
|
+
result = filterUnifiedRepos(result, filter);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Sort
|
|
131
|
+
result = sortUnifiedRepos(result, config.display.sortBy, config.display.sortDirection);
|
|
132
|
+
|
|
133
|
+
const stats = getUnifiedStats(unified);
|
|
134
|
+
console.log(formatUnifiedRepoList(result, stats, view, { json, verbose }));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Show unified status (local + GitHub)
|
|
139
|
+
*/
|
|
140
|
+
export async function showUnifiedStatus(options: CLIOptions): Promise<void> {
|
|
141
|
+
const { config, json } = options;
|
|
142
|
+
|
|
143
|
+
if (!defaultGitHubService.hasToken()) {
|
|
144
|
+
console.log(formatWarning("GITHUB_TOKEN not set - showing local status only"));
|
|
145
|
+
await showStatus(options);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
console.log(formatScanning("Scanning local directories..."));
|
|
150
|
+
const localProjects = await scanAllDirectories(config);
|
|
151
|
+
|
|
152
|
+
console.log(formatScanning("Fetching GitHub repositories..."));
|
|
153
|
+
const { unified, error } = await fetchUnifiedRepos(localProjects);
|
|
154
|
+
|
|
155
|
+
if (error) {
|
|
156
|
+
console.log(formatWarning(error));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const stats = getUnifiedStats(unified);
|
|
160
|
+
const statusData = {
|
|
161
|
+
stats,
|
|
162
|
+
githubOnly: unified.filter((r) => r.source === "github"),
|
|
163
|
+
localOnly: unified.filter((r) => r.source === "local"),
|
|
164
|
+
dirty: unified.filter((r) => r.local?.status?.isDirty),
|
|
165
|
+
unpushed: unified.filter((r) => r.local?.status?.isAhead),
|
|
166
|
+
unpulled: unified.filter((r) => r.local?.status?.isBehind),
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
if (json) {
|
|
170
|
+
console.log(formatUnifiedStatusJson(statusData));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
console.log(formatUnifiedStatusDisplay(statusData));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Clone GitHub repositories that aren't local
|
|
179
|
+
*/
|
|
180
|
+
export async function cloneGitHubRepos(options: CLIOptions & {
|
|
181
|
+
repos?: string[];
|
|
182
|
+
targetDir?: string;
|
|
183
|
+
useHTTPS?: boolean;
|
|
184
|
+
}): Promise<void> {
|
|
185
|
+
const { config, filter, repos: specificRepos, targetDir, useHTTPS = false } = options;
|
|
186
|
+
|
|
187
|
+
// Check GitHub auth - auto-login if not set
|
|
188
|
+
const token = await ensureAuthenticated();
|
|
189
|
+
if (!token) {
|
|
190
|
+
console.log(formatError("GitHub authentication required."));
|
|
191
|
+
console.log(formatInfo("Run 'gitforest login' to authenticate."));
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Determine target directory
|
|
196
|
+
const cloneDir = targetDir ?? config.directories[0]?.path ?? process.cwd();
|
|
197
|
+
|
|
198
|
+
console.log(formatScanning("Scanning local directories..."));
|
|
199
|
+
const localProjects = await scanAllDirectories(config);
|
|
200
|
+
|
|
201
|
+
console.log(formatScanning("Fetching GitHub repositories..."));
|
|
202
|
+
const { unified, error } = await fetchUnifiedRepos(localProjects);
|
|
203
|
+
|
|
204
|
+
if (error) {
|
|
205
|
+
console.log(formatWarning(error));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Get repos to clone
|
|
209
|
+
let toClone = unified.filter((r) => r.source === "github");
|
|
210
|
+
|
|
211
|
+
// Filter by specific repos if provided
|
|
212
|
+
if (specificRepos && specificRepos.length > 0) {
|
|
213
|
+
toClone = toClone.filter((r) =>
|
|
214
|
+
specificRepos.some((name) =>
|
|
215
|
+
r.name.toLowerCase() === name.toLowerCase() ||
|
|
216
|
+
r.github?.fullName.toLowerCase() === name.toLowerCase()
|
|
217
|
+
)
|
|
218
|
+
);
|
|
219
|
+
} else if (filter) {
|
|
220
|
+
toClone = filterUnifiedRepos(toClone, filter);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (toClone.length === 0) {
|
|
224
|
+
console.log(formatWarning("No repositories to clone."));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
console.log(formatInfo(`\nCloning ${toClone.length} repositories to ${cloneDir}...\n`));
|
|
229
|
+
|
|
230
|
+
let success = 0;
|
|
231
|
+
for (const repo of toClone) {
|
|
232
|
+
const result = await cloneGitHubRepo(repo, cloneDir, !useHTTPS);
|
|
233
|
+
console.log(formatCloneItem(repo.github?.fullName, result.success, result.path, result.error));
|
|
234
|
+
if (result.success) success++;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
console.log(`\n${formatOperationSummary("Cloned", success, toClone.length)}`);
|
|
238
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { GitforestConfig, Project } from "../../types/index.ts";
|
|
2
|
+
import { scanAllDirectories, filterProjects } from "../../scanner/index.ts";
|
|
3
|
+
import { formatScanning } from "../formatters.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Scan all directories and optionally filter results
|
|
7
|
+
*/
|
|
8
|
+
export async function scanAndFilter(config: GitforestConfig, filter?: string): Promise<Project[]> {
|
|
9
|
+
console.log(formatScanning());
|
|
10
|
+
const projects = await scanAllDirectories(config);
|
|
11
|
+
if (filter) {
|
|
12
|
+
return filterProjects(projects, filter);
|
|
13
|
+
}
|
|
14
|
+
return projects;
|
|
15
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { GitforestConfig } from "../../types/index.ts";
|
|
2
|
+
import { scanAllDirectories, sortProjects, filterProjects } from "../../scanner/index.ts";
|
|
3
|
+
import {
|
|
4
|
+
formatProjectList,
|
|
5
|
+
formatStatusSummary,
|
|
6
|
+
formatStatusSummaryJson,
|
|
7
|
+
formatDirtyRepos,
|
|
8
|
+
formatScanning,
|
|
9
|
+
calculateStatusSummary,
|
|
10
|
+
} from "../formatters.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* CLI command handlers for non-TUI usage
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface CLIOptions {
|
|
17
|
+
config: GitforestConfig;
|
|
18
|
+
filter?: string;
|
|
19
|
+
json?: boolean;
|
|
20
|
+
verbose?: boolean;
|
|
21
|
+
maxDepth?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* List all projects
|
|
26
|
+
*/
|
|
27
|
+
export async function listProjects(options: CLIOptions): Promise<void> {
|
|
28
|
+
const { config, filter, json, verbose } = options;
|
|
29
|
+
|
|
30
|
+
console.log(formatScanning());
|
|
31
|
+
const projects = await scanAllDirectories(config);
|
|
32
|
+
let result = sortProjects(projects, config.display.sortBy, config.display.sortDirection);
|
|
33
|
+
|
|
34
|
+
if (filter) {
|
|
35
|
+
result = filterProjects(result, filter);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log(formatProjectList(result, { json, verbose }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Show status summary
|
|
43
|
+
*/
|
|
44
|
+
export async function showStatus(options: CLIOptions): Promise<void> {
|
|
45
|
+
const { config, filter, json } = options;
|
|
46
|
+
|
|
47
|
+
console.log(formatScanning());
|
|
48
|
+
const projects = await scanAllDirectories(config);
|
|
49
|
+
let result = sortProjects(projects, "status", "desc");
|
|
50
|
+
|
|
51
|
+
if (filter) {
|
|
52
|
+
result = filterProjects(result, filter);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const summary = calculateStatusSummary(result);
|
|
56
|
+
|
|
57
|
+
if (json) {
|
|
58
|
+
console.log(formatStatusSummaryJson(summary));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log(formatStatusSummary(summary));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Show dirty repositories
|
|
67
|
+
*/
|
|
68
|
+
export async function showDirty(options: CLIOptions): Promise<void> {
|
|
69
|
+
const { config, json } = options;
|
|
70
|
+
|
|
71
|
+
console.log(formatScanning());
|
|
72
|
+
const projects = await scanAllDirectories(config);
|
|
73
|
+
const dirty = projects.filter((p) => p.status?.isDirty);
|
|
74
|
+
|
|
75
|
+
console.log(formatDirtyRepos(dirty, json));
|
|
76
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { CLIOptions } from "./list.ts";
|
|
2
|
+
import { scanAllDirectories, filterProjects } from "../../scanner/index.ts";
|
|
3
|
+
import { initGitInProject } from "../../git/operations.ts";
|
|
4
|
+
import { defaultGitHubService } from "../../services/github.ts";
|
|
5
|
+
import { ensureAuthenticated } from "../../github/auth.ts";
|
|
6
|
+
import {
|
|
7
|
+
formatWarning,
|
|
8
|
+
formatError,
|
|
9
|
+
formatInfo,
|
|
10
|
+
formatScanning,
|
|
11
|
+
formatOperationItem,
|
|
12
|
+
formatOperationSummary,
|
|
13
|
+
} from "../formatters.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Setup projects: init git + create GitHub repo + push
|
|
17
|
+
* For non-git projects and git projects without remotes
|
|
18
|
+
*/
|
|
19
|
+
export async function setupProjects(options: CLIOptions & { isPrivate?: boolean }): Promise<void> {
|
|
20
|
+
const { config, filter, isPrivate = true } = options;
|
|
21
|
+
|
|
22
|
+
// Check GitHub auth - auto-login if not set
|
|
23
|
+
const token = await ensureAuthenticated();
|
|
24
|
+
if (!token) {
|
|
25
|
+
console.log(formatError("GitHub authentication required."));
|
|
26
|
+
console.log(formatInfo("Run 'gitforest login' to authenticate."));
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
console.log(formatScanning());
|
|
31
|
+
const projects = await scanAllDirectories(config);
|
|
32
|
+
|
|
33
|
+
// Find eligible projects: non-git OR git without remote
|
|
34
|
+
let eligibleProjects = projects.filter(
|
|
35
|
+
(p) => p.type === "non-git" || (p.type === "git" && !p.status?.hasRemote)
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
if (filter) {
|
|
39
|
+
eligibleProjects = filterProjects(eligibleProjects, filter);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (eligibleProjects.length === 0) {
|
|
43
|
+
console.log(formatWarning("No projects need setup (all have remotes)."));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const nonGitCount = eligibleProjects.filter((p) => p.type === "non-git").length;
|
|
48
|
+
const noRemoteCount = eligibleProjects.filter((p) => p.type === "git").length;
|
|
49
|
+
|
|
50
|
+
console.log(formatInfo(`\nSetting up ${eligibleProjects.length} projects:`));
|
|
51
|
+
if (nonGitCount > 0) console.log(formatInfo(` - ${nonGitCount} non-git projects (will init)`));
|
|
52
|
+
if (noRemoteCount > 0) console.log(formatInfo(` - ${noRemoteCount} git projects without remote`));
|
|
53
|
+
console.log(formatInfo(` - Creating ${isPrivate ? "private" : "public"} repos\n`));
|
|
54
|
+
|
|
55
|
+
let initSuccess = 0;
|
|
56
|
+
let createSuccess = 0;
|
|
57
|
+
|
|
58
|
+
for (const project of eligibleProjects) {
|
|
59
|
+
// Step 1: Init git if needed
|
|
60
|
+
if (project.type === "non-git") {
|
|
61
|
+
const initResult = await initGitInProject(project.path);
|
|
62
|
+
if (initResult.success) {
|
|
63
|
+
initSuccess++;
|
|
64
|
+
console.log(formatOperationItem(`${project.name} (init)`, true));
|
|
65
|
+
} else {
|
|
66
|
+
console.log(formatOperationItem(`${project.name} (init)`, false, initResult.error));
|
|
67
|
+
continue; // Skip create if init failed
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Step 2: Create GitHub repo (this also adds remote and pushes)
|
|
72
|
+
const createResult = await defaultGitHubService.createRepo({
|
|
73
|
+
name: project.name,
|
|
74
|
+
isPrivate,
|
|
75
|
+
localPath: project.path,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
console.log(formatOperationItem(`${project.name} (create + push)`, createResult.success, createResult.error));
|
|
79
|
+
if (createResult.success) createSuccess++;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log(`\n${formatOperationSummary("Set up", createSuccess, eligibleProjects.length)}`);
|
|
83
|
+
if (nonGitCount > 0) {
|
|
84
|
+
console.log(formatInfo(` Git initialized: ${initSuccess}/${nonGitCount}`));
|
|
85
|
+
}
|
|
86
|
+
}
|