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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitforest",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "TUI for managing multiple Git repositories and GitHub integration",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
package/src/app.tsx CHANGED
@@ -1,9 +1,13 @@
1
1
  import React from "react";
2
+ import { existsSync, statSync } from "fs";
3
+ import { resolve } from "path";
4
+ import { homedir } from "os";
2
5
  import { StoreProvider } from "./state/store.tsx";
3
6
  import { Layout } from "./components/Layout.tsx";
4
7
  import { useUnifiedRepos } from "./hooks/useUnifiedRepos.ts";
5
8
  import { useKeyBindings } from "./hooks/useKeyBindings.ts";
6
9
  import { useBackgroundFetch } from "./hooks/useBackgroundFetch.ts";
10
+ import { saveConfig, findConfigPath } from "./config/loader.ts";
7
11
  import type { GitforestConfig, UnifiedRepo } from "./types/index.ts";
8
12
 
9
13
  interface AppContentProps {
@@ -18,6 +22,51 @@ function AppContent({ config }: AppContentProps) {
18
22
  await batchClone(repos, targetDir, useSSH);
19
23
  };
20
24
 
25
+ // Add directory handler for Layout
26
+ const handleAddDirectory = async (
27
+ rawPath: string,
28
+ maxDepth: number,
29
+ label: string,
30
+ ): Promise<{ success: boolean; error?: string }> => {
31
+ const expanded = rawPath.replace(/^~/, homedir());
32
+ const absPath = resolve(expanded);
33
+
34
+ if (!existsSync(absPath)) {
35
+ return { success: false, error: `Directory does not exist: ${absPath}` };
36
+ }
37
+
38
+ if (!statSync(absPath).isDirectory()) {
39
+ return { success: false, error: `Not a directory: ${absPath}` };
40
+ }
41
+
42
+ // Check for duplicates
43
+ const exists = config.directories.some((d) => {
44
+ const dPath = d.path.replace(/^~/, homedir());
45
+ return resolve(dPath) === absPath;
46
+ });
47
+
48
+ if (exists) {
49
+ return { success: false, error: "Directory already configured" };
50
+ }
51
+
52
+ // Add to the config object (mutation - stays in memory for this session)
53
+ const newDir: { path: string; maxDepth: number; label?: string } = {
54
+ path: absPath,
55
+ maxDepth,
56
+ };
57
+ if (label) newDir.label = label;
58
+ config.directories.push(newDir);
59
+
60
+ // Persist to disk
61
+ try {
62
+ const configPath = findConfigPath() ?? undefined;
63
+ await saveConfig(config, configPath);
64
+ return { success: true };
65
+ } catch (error) {
66
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
67
+ }
68
+ };
69
+
21
70
  // Set up keyboard bindings
22
71
  useKeyBindings({
23
72
  config,
@@ -27,7 +76,14 @@ function AppContent({ config }: AppContentProps) {
27
76
  // Set up background fetch
28
77
  useBackgroundFetch(config, loadUnifiedRepos);
29
78
 
30
- return <Layout config={config} onRefresh={loadUnifiedRepos} onClone={handleClone} />;
79
+ return (
80
+ <Layout
81
+ config={config}
82
+ onRefresh={loadUnifiedRepos}
83
+ onClone={handleClone}
84
+ onAddDirectory={handleAddDirectory}
85
+ />
86
+ );
31
87
  }
32
88
 
33
89
  interface AppProps {
@@ -0,0 +1,75 @@
1
+ import {
2
+ login,
3
+ logout,
4
+ getAuthStatus,
5
+ isGhInstalled,
6
+ } from "../../github/auth.ts";
7
+ import {
8
+ formatAuthSuccess,
9
+ formatNoToken,
10
+ formatError,
11
+ formatInfo,
12
+ } from "../formatters.ts";
13
+
14
+ /**
15
+ * Show GitHub authentication status
16
+ */
17
+ export async function showGitHubAuth(): Promise<void> {
18
+ const status = await getAuthStatus();
19
+
20
+ if (!status.authenticated) {
21
+ console.log(formatNoToken());
22
+ console.log(formatInfo("\nRun 'gitforest login' to authenticate with GitHub."));
23
+ return;
24
+ }
25
+
26
+ console.log(formatAuthSuccess(status.user ?? "unknown", undefined));
27
+ console.log(formatInfo(` Auth source: ${status.source === "env" ? "environment variable" : "gh CLI"}`));
28
+ }
29
+
30
+ /**
31
+ * Login to GitHub using gh CLI
32
+ */
33
+ export async function loginGitHub(): Promise<void> {
34
+ // Check if gh is installed
35
+ const ghInstalled = await isGhInstalled();
36
+ if (!ghInstalled) {
37
+ console.log(formatError("GitHub CLI (gh) is not installed."));
38
+ console.log(formatInfo("Install it from: https://cli.github.com"));
39
+ console.log(formatInfo("\nOr set GITHUB_TOKEN environment variable manually."));
40
+ process.exit(1);
41
+ }
42
+
43
+ // Check if already authenticated
44
+ const status = await getAuthStatus();
45
+ if (status.authenticated) {
46
+ console.log(formatInfo(`Already logged in as ${status.user ?? "unknown"}`));
47
+ console.log(formatInfo("Run 'gitforest logout' first to login as a different user."));
48
+ return;
49
+ }
50
+
51
+ const result = await login();
52
+
53
+ if (result.success) {
54
+ console.log(`\n${formatAuthSuccess(result.user ?? "unknown", undefined)}`);
55
+ console.log(formatInfo("You can now use GitHub features."));
56
+ } else {
57
+ console.log(formatError(`Login failed: ${result.error}`));
58
+ process.exit(1);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Logout from GitHub
64
+ */
65
+ export async function logoutGitHub(): Promise<void> {
66
+ const status = await getAuthStatus();
67
+
68
+ if (!status.authenticated) {
69
+ console.log(formatInfo("Not logged in."));
70
+ return;
71
+ }
72
+
73
+ await logout();
74
+ console.log(formatInfo("Logged out successfully."));
75
+ }
@@ -0,0 +1,141 @@
1
+ import type { CLIOptions } from "./list.ts";
2
+ import { scanAndFilter } from "./helpers.ts";
3
+ import { filterProjects } from "../../scanner/index.ts";
4
+ import { initGitInProject } from "../../git/operations.ts";
5
+ import { batchPull, batchPush, batchFetch } from "../../operations/batch.ts";
6
+ import {
7
+ formatBatchResult,
8
+ formatProgress,
9
+ formatWarning,
10
+ formatInfo,
11
+ formatScanning,
12
+ formatOperationItem,
13
+ formatOperationSummary,
14
+ } from "../formatters.ts";
15
+ import { scanAllDirectories } from "../../scanner/index.ts";
16
+
17
+ /**
18
+ * Pull all repositories
19
+ */
20
+ export async function pullAll(options: CLIOptions): Promise<void> {
21
+ const { config, filter } = options;
22
+
23
+ console.log(formatScanning());
24
+ const projects = await scanAllDirectories(config);
25
+ let gitProjects = projects.filter((p) => p.type === "git" && p.status?.hasRemote);
26
+
27
+ if (filter) {
28
+ gitProjects = filterProjects(gitProjects, filter);
29
+ }
30
+
31
+ if (gitProjects.length === 0) {
32
+ console.log(formatWarning("No repositories to pull."));
33
+ return;
34
+ }
35
+
36
+ console.log(formatInfo(`\nPulling ${gitProjects.length} repositories...\n`));
37
+
38
+ const result = await batchPull(gitProjects, {
39
+ concurrency: config.scan.concurrency, onProgress: (completed, total) => {
40
+ process.stdout.write(formatProgress(completed, total));
41
+ }
42
+ });
43
+
44
+ console.log("\n");
45
+ console.log(formatBatchResult(result, "Pulled"));
46
+ }
47
+
48
+ /**
49
+ * Push repositories with unpushed commits
50
+ */
51
+ export async function pushAll(options: CLIOptions): Promise<void> {
52
+ const { config, filter } = options;
53
+
54
+ console.log(formatScanning());
55
+ const projects = await scanAllDirectories(config);
56
+ let unpushedProjects = projects.filter(
57
+ (p) => p.type === "git" && p.status?.hasRemote && p.status?.isAhead
58
+ );
59
+
60
+ if (filter) {
61
+ unpushedProjects = filterProjects(unpushedProjects, filter);
62
+ }
63
+
64
+ if (unpushedProjects.length === 0) {
65
+ console.log(formatWarning("No repositories with unpushed commits."));
66
+ return;
67
+ }
68
+
69
+ console.log(formatInfo(`\nPushing ${unpushedProjects.length} repositories...\n`));
70
+
71
+ const result = await batchPush(unpushedProjects, {
72
+ concurrency: config.scan.concurrency, onProgress: (completed, total) => {
73
+ process.stdout.write(formatProgress(completed, total));
74
+ }
75
+ });
76
+
77
+ console.log("\n");
78
+ console.log(formatBatchResult(result, "Pushed"));
79
+ }
80
+
81
+ /**
82
+ * Fetch all repositories
83
+ */
84
+ export async function fetchAll(options: CLIOptions): Promise<void> {
85
+ const { config, filter } = options;
86
+
87
+ console.log(formatScanning());
88
+ const projects = await scanAllDirectories(config);
89
+ let gitProjects = projects.filter((p) => p.type === "git" && p.status?.hasRemote);
90
+
91
+ if (filter) {
92
+ gitProjects = filterProjects(gitProjects, filter);
93
+ }
94
+
95
+ if (gitProjects.length === 0) {
96
+ console.log(formatWarning("No repositories to fetch."));
97
+ return;
98
+ }
99
+
100
+ console.log(formatInfo(`\nFetching ${gitProjects.length} repositories...\n`));
101
+
102
+ const result = await batchFetch(gitProjects, {
103
+ concurrency: config.scan.concurrency, onProgress: (completed, total) => {
104
+ process.stdout.write(formatProgress(completed, total));
105
+ }
106
+ });
107
+
108
+ console.log("\n");
109
+ console.log(formatBatchResult(result, "Fetched"));
110
+ }
111
+
112
+ /**
113
+ * Initialize git in non-git projects
114
+ */
115
+ export async function initNonGit(options: CLIOptions): Promise<void> {
116
+ const { config, filter } = options;
117
+
118
+ console.log(formatScanning());
119
+ const projects = await scanAllDirectories(config);
120
+ let nonGitProjects = projects.filter((p) => p.type === "non-git");
121
+
122
+ if (filter) {
123
+ nonGitProjects = filterProjects(nonGitProjects, filter);
124
+ }
125
+
126
+ if (nonGitProjects.length === 0) {
127
+ console.log(formatWarning("No non-git projects found."));
128
+ return;
129
+ }
130
+
131
+ console.log(formatInfo(`\nInitializing git in ${nonGitProjects.length} projects...\n`));
132
+
133
+ let success = 0;
134
+ for (const project of nonGitProjects) {
135
+ const result = await initGitInProject(project.path);
136
+ console.log(formatOperationItem(project.name, result.success, result.error));
137
+ if (result.success) success++;
138
+ }
139
+
140
+ console.log(`\n${formatOperationSummary("Initialized", success, nonGitProjects.length)}`);
141
+ }
@@ -0,0 +1,285 @@
1
+ import { resolve } from "path";
2
+ import { existsSync, statSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { saveConfig, findConfigPath } from "../../config/loader.ts";
5
+ import {
6
+ formatError,
7
+ formatInfo,
8
+ formatSuccess,
9
+ formatWarning,
10
+ } from "../formatters.ts";
11
+ import type { CLIOptions } from "./list.ts";
12
+ import type { DirectoryConfig, GitforestConfig } from "../../types/index.ts";
13
+
14
+ export interface DirOptions extends CLIOptions {
15
+ label?: string;
16
+ editor?: string;
17
+ }
18
+
19
+ /**
20
+ * Handle `gitforest dir <subcommand>` commands
21
+ */
22
+ export async function handleDirCommand(
23
+ subcommand: string,
24
+ args: string[],
25
+ options: DirOptions
26
+ ): Promise<void> {
27
+ switch (subcommand) {
28
+ case "list":
29
+ case "ls":
30
+ listDirs(options);
31
+ break;
32
+ case "add":
33
+ await addDir(args, options);
34
+ break;
35
+ case "remove":
36
+ case "rm":
37
+ await removeDir(args, options);
38
+ break;
39
+ case "set":
40
+ await setDir(args, options);
41
+ break;
42
+ default:
43
+ console.error(formatError(`Unknown dir subcommand: ${subcommand}`));
44
+ console.log(formatInfo("Available commands: list, add, remove, set"));
45
+ console.log(formatInfo("\nUsage:"));
46
+ console.log(formatInfo(" gitforest dir list List configured directories"));
47
+ console.log(formatInfo(" gitforest dir add <path> [--max-depth N] Add a directory"));
48
+ console.log(formatInfo(" gitforest dir remove <path> Remove a directory"));
49
+ console.log(formatInfo(" gitforest dir set <path> [options] Update directory settings"));
50
+ process.exit(1);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Resolve a user-provided path, expanding ~ and making absolute
56
+ */
57
+ function resolveDirPath(rawPath: string): string {
58
+ const expanded = rawPath.replace(/^~/, homedir());
59
+ return resolve(expanded);
60
+ }
61
+
62
+ /**
63
+ * Find a directory in the config by resolved path
64
+ */
65
+ function findDirIndex(config: GitforestConfig, absPath: string): number {
66
+ return config.directories.findIndex((d) => {
67
+ const dPath = d.path.replace(/^~/, homedir());
68
+ return resolve(dPath) === absPath;
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Format a single directory entry for display
74
+ */
75
+ function formatDirEntry(dir: DirectoryConfig, index: number): string {
76
+ const parts = [` ${index + 1}. ${dir.path}`];
77
+ const meta: string[] = [];
78
+ meta.push(`depth: ${dir.maxDepth}`);
79
+ if (dir.label) meta.push(`label: ${dir.label}`);
80
+ if (dir.editor) meta.push(`editor: ${dir.editor}`);
81
+ if (meta.length > 0) {
82
+ parts.push(` (${meta.join(", ")})`);
83
+ }
84
+ return parts.join("\n");
85
+ }
86
+
87
+ // ============================================================================
88
+ // Subcommands
89
+ // ============================================================================
90
+
91
+ /**
92
+ * List all configured directories
93
+ */
94
+ export function listDirs(options: CLIOptions): void {
95
+ const { config, json } = options;
96
+
97
+ if (json) {
98
+ console.log(JSON.stringify(config.directories, null, 2));
99
+ return;
100
+ }
101
+
102
+ if (config.directories.length === 0) {
103
+ console.log(formatWarning("No directories configured."));
104
+ console.log(formatInfo("Run 'gitforest dir add <path>' to add one."));
105
+ return;
106
+ }
107
+
108
+ console.log(formatInfo(`\nConfigured directories (${config.directories.length}):\n`));
109
+
110
+ for (let i = 0; i < config.directories.length; i++) {
111
+ const dir = config.directories[i]!;
112
+ console.log(formatDirEntry(dir, i));
113
+ }
114
+
115
+ const configPath = findConfigPath();
116
+ if (configPath) {
117
+ console.log(`\n Config: ${configPath}`);
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Add a directory to the config
123
+ */
124
+ export async function addDir(args: string[], options: DirOptions): Promise<void> {
125
+ const { config } = options;
126
+
127
+ if (args.length === 0) {
128
+ console.error(formatError("Missing directory path"));
129
+ console.log(formatInfo("Usage: gitforest dir add <path> [--max-depth N] [--label TEXT]"));
130
+ process.exit(1);
131
+ }
132
+
133
+ const rawPath = args[0]!;
134
+ const absPath = resolveDirPath(rawPath);
135
+
136
+ // Validate path exists and is a directory
137
+ if (!existsSync(absPath)) {
138
+ console.error(formatError(`Directory does not exist: ${absPath}`));
139
+ process.exit(1);
140
+ }
141
+
142
+ if (!statSync(absPath).isDirectory()) {
143
+ console.error(formatError(`Not a directory: ${absPath}`));
144
+ process.exit(1);
145
+ }
146
+
147
+ // Check if directory is already in config
148
+ const existingIndex = findDirIndex(config, absPath);
149
+
150
+ if (existingIndex >= 0) {
151
+ console.log(formatWarning(`Directory already configured: ${absPath}`));
152
+ console.log(formatInfo("Use 'gitforest dir set' to update its settings."));
153
+ return;
154
+ }
155
+
156
+ const newDir: DirectoryConfig = {
157
+ path: absPath,
158
+ maxDepth: options.maxDepth ?? 2,
159
+ };
160
+ if (options.label) newDir.label = options.label;
161
+
162
+ config.directories.push(newDir);
163
+
164
+ await persistConfig(config);
165
+ console.log(formatSuccess(`Added directory: ${absPath} (max-depth: ${newDir.maxDepth})`));
166
+ }
167
+
168
+ /**
169
+ * Remove a directory from the config
170
+ */
171
+ export async function removeDir(args: string[], options: DirOptions): Promise<void> {
172
+ const { config } = options;
173
+
174
+ if (args.length === 0) {
175
+ console.error(formatError("Missing directory path or index"));
176
+ console.log(formatInfo("Usage: gitforest dir remove <path|index>"));
177
+ console.log(formatInfo(" Use 'gitforest dir list' to see directories and their indices."));
178
+ process.exit(1);
179
+ }
180
+
181
+ const arg = args[0]!;
182
+
183
+ // Try to interpret as 1-based index
184
+ const indexNum = parseInt(arg, 10);
185
+ let removeIndex: number;
186
+
187
+ if (!isNaN(indexNum) && indexNum >= 1 && indexNum <= config.directories.length) {
188
+ removeIndex = indexNum - 1;
189
+ } else {
190
+ // Interpret as path
191
+ const absPath = resolveDirPath(arg);
192
+ removeIndex = findDirIndex(config, absPath);
193
+
194
+ if (removeIndex < 0) {
195
+ console.error(formatError(`Directory not found in config: ${absPath}`));
196
+ console.log(formatInfo("Run 'gitforest dir list' to see configured directories."));
197
+ process.exit(1);
198
+ }
199
+ }
200
+
201
+ // Prevent removing the last directory
202
+ if (config.directories.length === 1) {
203
+ console.error(formatError("Cannot remove the last directory. At least one directory is required."));
204
+ process.exit(1);
205
+ }
206
+
207
+ const removed = config.directories[removeIndex]!;
208
+ config.directories.splice(removeIndex, 1);
209
+
210
+ await persistConfig(config);
211
+ console.log(formatSuccess(`Removed directory: ${removed.path}`));
212
+ }
213
+
214
+ /**
215
+ * Update settings for an existing directory
216
+ */
217
+ export async function setDir(args: string[], options: DirOptions): Promise<void> {
218
+ const { config } = options;
219
+
220
+ if (args.length === 0) {
221
+ console.error(formatError("Missing directory path or index"));
222
+ console.log(formatInfo("Usage: gitforest dir set <path|index> [--max-depth N] [--label TEXT] [--editor CMD]"));
223
+ process.exit(1);
224
+ }
225
+
226
+ const arg = args[0]!;
227
+
228
+ // Try to interpret as 1-based index
229
+ const indexNum = parseInt(arg, 10);
230
+ let dirIndex: number;
231
+
232
+ if (!isNaN(indexNum) && indexNum >= 1 && indexNum <= config.directories.length) {
233
+ dirIndex = indexNum - 1;
234
+ } else {
235
+ const absPath = resolveDirPath(arg);
236
+ dirIndex = findDirIndex(config, absPath);
237
+
238
+ if (dirIndex < 0) {
239
+ console.error(formatError(`Directory not found in config: ${absPath}`));
240
+ console.log(formatInfo("Run 'gitforest dir list' to see configured directories."));
241
+ process.exit(1);
242
+ }
243
+ }
244
+
245
+ const dir = config.directories[dirIndex]!;
246
+
247
+ let updated = false;
248
+
249
+ if (options.maxDepth !== undefined) {
250
+ dir.maxDepth = options.maxDepth;
251
+ updated = true;
252
+ }
253
+
254
+ if (options.label !== undefined) {
255
+ dir.label = options.label;
256
+ updated = true;
257
+ }
258
+
259
+ if (options.editor !== undefined) {
260
+ dir.editor = options.editor;
261
+ updated = true;
262
+ }
263
+
264
+ if (!updated) {
265
+ console.log(formatWarning("No options specified. Nothing to update."));
266
+ console.log(formatInfo("Options: --max-depth N, --label TEXT, --editor CMD"));
267
+ return;
268
+ }
269
+
270
+ await persistConfig(config);
271
+ console.log(formatSuccess(`Updated directory: ${dir.path}`));
272
+ console.log(formatDirEntry(dir, dirIndex));
273
+ }
274
+
275
+ // ============================================================================
276
+ // Helpers
277
+ // ============================================================================
278
+
279
+ /**
280
+ * Save the config and print any errors
281
+ */
282
+ async function persistConfig(config: GitforestConfig): Promise<void> {
283
+ const configPath = findConfigPath() ?? undefined;
284
+ await saveConfig(config, configPath);
285
+ }