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
package/package.json
CHANGED
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
|
|
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
|
+
}
|