@hallaxius/forge 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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +159 -0
  3. package/bin/forge.js +2 -0
  4. package/dist/cli.js +35641 -0
  5. package/package.json +65 -0
  6. package/src/cli.ts +78 -0
  7. package/src/commands/alias.ts +66 -0
  8. package/src/commands/archive.ts +35 -0
  9. package/src/commands/bisect.ts +102 -0
  10. package/src/commands/branch.ts +46 -0
  11. package/src/commands/cherry-pick.ts +57 -0
  12. package/src/commands/clean.ts +76 -0
  13. package/src/commands/clone.ts +100 -0
  14. package/src/commands/commit.ts +93 -0
  15. package/src/commands/config.ts +48 -0
  16. package/src/commands/diff.ts +26 -0
  17. package/src/commands/fetch.ts +20 -0
  18. package/src/commands/help.ts +58 -0
  19. package/src/commands/init.ts +37 -0
  20. package/src/commands/log.ts +29 -0
  21. package/src/commands/merge.ts +37 -0
  22. package/src/commands/push.ts +35 -0
  23. package/src/commands/remote.ts +107 -0
  24. package/src/commands/reset.ts +30 -0
  25. package/src/commands/setup.ts +95 -0
  26. package/src/commands/stash.ts +44 -0
  27. package/src/commands/status.ts +74 -0
  28. package/src/commands/sync.ts +20 -0
  29. package/src/commands/tag.ts +41 -0
  30. package/src/commands/undo.ts +27 -0
  31. package/src/commands/version.ts +19 -0
  32. package/src/commands/worktree.ts +92 -0
  33. package/src/constants/colors.ts +7 -0
  34. package/src/constants/commit-types.ts +24 -0
  35. package/src/constants/messages.ts +23 -0
  36. package/src/lib/auth.ts +95 -0
  37. package/src/lib/config.ts +99 -0
  38. package/src/lib/git.ts +382 -0
  39. package/src/lib/logger.ts +31 -0
  40. package/src/lib/ui.ts +162 -0
  41. package/src/lib/validators.ts +27 -0
  42. package/src/templates/commit-types.json +9 -0
  43. package/src/utils/files.ts +21 -0
  44. package/src/utils/strings.ts +19 -0
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@hallaxius/forge",
3
+ "version": "0.1.0",
4
+ "description": "A modern Git CLI with professional UX",
5
+ "author": "hallaxius",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "bin": {
9
+ "fg": "./dist/cli.js"
10
+ },
11
+ "main": "./dist/cli.js",
12
+ "types": "./dist/cli.d.ts",
13
+ "scripts": {
14
+ "dev": "bun --watch src/cli.ts",
15
+ "build": "bun build src/cli.ts --outdir dist --target node",
16
+ "start": "bun bin/forge.js",
17
+ "test": "bun test",
18
+ "lint": "biome check",
19
+ "format": "biome format --write",
20
+ "lint:fix": "biome check --write --unsafe",
21
+ "prepublishOnly": "bun test && bun run build",
22
+ "prepack": "bun run build"
23
+ },
24
+ "dependencies": {
25
+ "boxen": "^7.1.0",
26
+ "chalk": "^5.3.0",
27
+ "commander": "^12.0.0",
28
+ "conf": "^12.0.0",
29
+ "figures": "^5.0.0",
30
+ "inquirer": "^14.0.2",
31
+ "ora": "^8.0.0",
32
+ "simple-git": "^3.22.0"
33
+ },
34
+ "devDependencies": {
35
+ "@biomejs/biome": "2.5.1",
36
+ "typescript": "^5.3.0"
37
+ },
38
+ "files": [
39
+ "bin",
40
+ "dist",
41
+ "src"
42
+ ],
43
+ "engines": {
44
+ "node": ">=18.0.0",
45
+ "bun": ">=1.0.0"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/hallaxius/forge.git"
50
+ },
51
+ "bugs": {
52
+ "url": "https://github.com/hallaxius/forge/issues"
53
+ },
54
+ "homepage": "https://github.com/hallaxius/forge#readme",
55
+ "keywords": [
56
+ "git",
57
+ "cli",
58
+ "forge",
59
+ "git-cli",
60
+ "developer-tools",
61
+ "productivity",
62
+ "bun",
63
+ "command-line"
64
+ ]
65
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,78 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { Command } from "commander";
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = dirname(__filename);
8
+
9
+ const pkg = JSON.parse(
10
+ readFileSync(resolve(__dirname, "../package.json"), "utf-8"),
11
+ );
12
+
13
+ const program = new Command();
14
+
15
+ program
16
+ .name("fg")
17
+ .description("Modern Git CLI for professional workflows")
18
+ .version(pkg.version);
19
+
20
+ import registerAlias from "./commands/alias.js";
21
+ import registerArchive from "./commands/archive.js";
22
+ import registerBisect from "./commands/bisect.js";
23
+ import registerBranch from "./commands/branch.js";
24
+ import registerCherryPick from "./commands/cherry-pick.js";
25
+ import registerClean from "./commands/clean.js";
26
+ import registerClone from "./commands/clone.js";
27
+ import registerCommit from "./commands/commit.js";
28
+ import registerConfig from "./commands/config.js";
29
+ import registerDiff from "./commands/diff.js";
30
+ import registerFetch from "./commands/fetch.js";
31
+ import registerHelp from "./commands/help.js";
32
+ import registerInit from "./commands/init.js";
33
+ import registerLog from "./commands/log.js";
34
+ import registerMerge from "./commands/merge.js";
35
+ import registerPush from "./commands/push.js";
36
+ import registerRemote from "./commands/remote.js";
37
+ import registerReset from "./commands/reset.js";
38
+ import registerSetup from "./commands/setup.js";
39
+ import registerStash from "./commands/stash.js";
40
+ import registerStatus from "./commands/status.js";
41
+ import registerSync from "./commands/sync.js";
42
+ import registerTag from "./commands/tag.js";
43
+ import registerUndo from "./commands/undo.js";
44
+ import registerVersion from "./commands/version.js";
45
+ import registerWorktree from "./commands/worktree.js";
46
+
47
+ registerSetup(program);
48
+ registerCommit(program);
49
+ registerPush(program);
50
+ registerStatus(program);
51
+ registerSync(program);
52
+ registerFetch(program);
53
+ registerBranch(program);
54
+ registerLog(program);
55
+ registerDiff(program);
56
+ registerStash(program);
57
+ registerTag(program);
58
+ registerAlias(program);
59
+ registerConfig(program);
60
+ registerUndo(program);
61
+ registerReset(program);
62
+ registerHelp(program);
63
+ registerVersion(program);
64
+ registerClone(program);
65
+ registerInit(program);
66
+ registerRemote(program);
67
+ registerWorktree(program);
68
+ registerMerge(program);
69
+ registerCherryPick(program);
70
+ registerClean(program);
71
+ registerArchive(program);
72
+ registerBisect(program);
73
+
74
+ if (process.argv.length <= 2) {
75
+ program.outputHelp();
76
+ } else {
77
+ program.parse(process.argv);
78
+ }
@@ -0,0 +1,66 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import type { Command } from "commander";
4
+ import Conf from "conf";
5
+ import { error, info, success } from "../lib/logger.js";
6
+ import { createTable } from "../lib/ui.js";
7
+
8
+ const aliasConfig = new Conf({
9
+ configName: "forge-aliases",
10
+ cwd: join(homedir(), ".forge"),
11
+ });
12
+
13
+ export default function register(program: Command): void {
14
+ const alias = program.command("alias").description("Manage aliases");
15
+
16
+ alias
17
+ .command("add <name> <command>")
18
+ .description("Create an alias")
19
+ .action(async (name, command) => {
20
+ try {
21
+ aliasConfig.set(name, command);
22
+ success(`Alias '${name}' created.`);
23
+ } catch (err) {
24
+ error(
25
+ `Failed to create alias: ${err instanceof Error ? err.message : String(err)}`,
26
+ );
27
+ }
28
+ });
29
+
30
+ alias
31
+ .command("list")
32
+ .description("List all aliases")
33
+ .action(async () => {
34
+ try {
35
+ const store = aliasConfig.store as Record<string, string>;
36
+ const entries = Object.entries(store);
37
+
38
+ if (entries.length === 0) {
39
+ info("No aliases configured.");
40
+ return;
41
+ }
42
+
43
+ const rows = entries.map(([name, cmd]) => [name, cmd]);
44
+ info("Aliases:");
45
+ console.log(createTable(["Name", "Command"], rows));
46
+ } catch (err) {
47
+ error(
48
+ `Failed to list aliases: ${err instanceof Error ? err.message : String(err)}`,
49
+ );
50
+ }
51
+ });
52
+
53
+ alias
54
+ .command("remove <name>")
55
+ .description("Delete an alias")
56
+ .action(async (name) => {
57
+ try {
58
+ aliasConfig.delete(name);
59
+ success(`Alias '${name}' removed.`);
60
+ } catch (err) {
61
+ error(
62
+ `Failed to remove alias: ${err instanceof Error ? err.message : String(err)}`,
63
+ );
64
+ }
65
+ });
66
+ }
@@ -0,0 +1,35 @@
1
+ import type { Command } from "commander";
2
+ import * as git from "../lib/git.js";
3
+ import { error, success } from "../lib/logger.js";
4
+
5
+ export default function register(program: Command): void {
6
+ program
7
+ .command("archive")
8
+ .description("Create an archive of the repository")
9
+ .argument("<format>", "Archive format (tar, tar.gz, zip)")
10
+ .option("--prefix <dir>", "Prepend prefix to archive paths")
11
+ .option("--output <file>", "Output file path")
12
+ .option("--tree-ish <ref>", "Tree or commit to archive")
13
+ .action(
14
+ async (
15
+ format: string,
16
+ options: { prefix?: string; output?: string; treeIsh?: string },
17
+ ) => {
18
+ try {
19
+ const opts: { prefix?: string; output?: string; treeIsh?: string } =
20
+ {};
21
+ if (options.prefix) opts.prefix = options.prefix;
22
+ if (options.output) opts.output = options.output;
23
+ if (options.treeIsh) opts.treeIsh = options.treeIsh;
24
+
25
+ const _result = await git.archive(format, opts);
26
+ const outputPath = options.output || `archive.${format}`;
27
+ success(`Archive created: ${outputPath}`);
28
+ } catch (err) {
29
+ error(
30
+ `Archive failed: ${err instanceof Error ? err.message : String(err)}`,
31
+ );
32
+ }
33
+ },
34
+ );
35
+ }
@@ -0,0 +1,102 @@
1
+ import type { Command } from "commander";
2
+ import * as git from "../lib/git.js";
3
+ import { error, info, success, text } from "../lib/logger.js";
4
+
5
+ export default function register(program: Command): void {
6
+ const cmd = program
7
+ .command("bisect")
8
+ .description("Bisect to find the commit that introduced a bug");
9
+
10
+ cmd
11
+ .command("start")
12
+ .description("Start a bisect session")
13
+ .argument("[bad]", "Bad commit (default: HEAD)")
14
+ .argument("[good...]", "Good commits")
15
+ .action(async (bad: string | undefined, good: string[]) => {
16
+ try {
17
+ await git.bisectStart(bad, good);
18
+ success("Bisect started.");
19
+ } catch (err) {
20
+ error(
21
+ `Bisect start failed: ${err instanceof Error ? err.message : String(err)}`,
22
+ );
23
+ }
24
+ });
25
+
26
+ cmd
27
+ .command("bad")
28
+ .description("Mark a commit as bad")
29
+ .argument("[commit]", "Commit hash (default: HEAD)")
30
+ .action(async (commit: string | undefined) => {
31
+ try {
32
+ await git.bisectBad(commit);
33
+ success(`Commit ${commit || "HEAD"} marked as bad.`);
34
+ } catch (err) {
35
+ error(
36
+ `Bisect bad failed: ${err instanceof Error ? err.message : String(err)}`,
37
+ );
38
+ }
39
+ });
40
+
41
+ cmd
42
+ .command("good")
43
+ .description("Mark commits as good")
44
+ .argument("[commits...]", "Commits to mark as good")
45
+ .action(async (commits: string[]) => {
46
+ try {
47
+ if (commits.length === 0) {
48
+ info("Provide at least one commit to mark as good.");
49
+ return;
50
+ }
51
+ await git.bisectGood(commits);
52
+ success(`Marked ${commits.join(", ")} as good.`);
53
+ } catch (err) {
54
+ error(
55
+ `Bisect good failed: ${err instanceof Error ? err.message : String(err)}`,
56
+ );
57
+ }
58
+ });
59
+
60
+ cmd
61
+ .command("reset")
62
+ .description("Reset bisect state")
63
+ .action(async () => {
64
+ try {
65
+ await git.bisectReset();
66
+ success("Bisect reset.");
67
+ } catch (err) {
68
+ error(
69
+ `Bisect reset failed: ${err instanceof Error ? err.message : String(err)}`,
70
+ );
71
+ }
72
+ });
73
+
74
+ cmd
75
+ .command("log")
76
+ .description("Show bisect log")
77
+ .action(async () => {
78
+ try {
79
+ const log = await git.bisectLog();
80
+ text(log);
81
+ } catch (err) {
82
+ error(
83
+ `Bisect log failed: ${err instanceof Error ? err.message : String(err)}`,
84
+ );
85
+ }
86
+ });
87
+
88
+ cmd
89
+ .command("run")
90
+ .description("Run a script for bisect")
91
+ .argument("<cmd>", "Command to run")
92
+ .action(async (cmd: string) => {
93
+ try {
94
+ await git.bisectRun(cmd);
95
+ success("Bisect run completed.");
96
+ } catch (err) {
97
+ error(
98
+ `Bisect run failed: ${err instanceof Error ? err.message : String(err)}`,
99
+ );
100
+ }
101
+ });
102
+ }
@@ -0,0 +1,46 @@
1
+ import type { Command } from "commander";
2
+ import * as git from "../lib/git.js";
3
+ import { error, info, success } from "../lib/logger.js";
4
+ import { createTable } from "../lib/ui.js";
5
+
6
+ export default function register(program: Command): void {
7
+ program
8
+ .command("branch")
9
+ .description("Manage branches")
10
+ .option("-n, --new <name>", "Create new branch")
11
+ .option("-d, --delete <name>", "Delete branch")
12
+ .option("-s, --switch <name>", "Switch to branch")
13
+ .action(async (options) => {
14
+ try {
15
+ if (options.new) {
16
+ await git.createBranch(options.new);
17
+ success(`Branch '${options.new}' created.`);
18
+ return;
19
+ }
20
+
21
+ if (options.delete) {
22
+ await git.deleteBranch(options.delete);
23
+ success(`Branch '${options.delete}' deleted.`);
24
+ return;
25
+ }
26
+
27
+ if (options.switch) {
28
+ await git.switchBranch(options.switch);
29
+ success(`Switched to '${options.switch}'.`);
30
+ return;
31
+ }
32
+
33
+ const branches = await git.getBranches();
34
+ const rows = branches.all.map((b) => [
35
+ b === branches.current ? "*" : " ",
36
+ b,
37
+ ]);
38
+ info("Branches:");
39
+ console.log(createTable(["", "Name"], rows));
40
+ } catch (err) {
41
+ error(
42
+ `Branch operation failed: ${err instanceof Error ? err.message : String(err)}`,
43
+ );
44
+ }
45
+ });
46
+ }
@@ -0,0 +1,57 @@
1
+ import type { Command } from "commander";
2
+ import * as git from "../lib/git.js";
3
+ import { error, info, success } from "../lib/logger.js";
4
+
5
+ export default function register(program: Command): void {
6
+ program
7
+ .command("cherry-pick")
8
+ .description("Cherry-pick commits")
9
+ .argument("[commits...]", "Commits to cherry-pick")
10
+ .option("--no-commit", "Apply changes without committing")
11
+ .option(
12
+ "--mainline <number>",
13
+ "Mainline parent for cherry-pick merge commits",
14
+ )
15
+ .option("--continue", "Continue after resolving conflicts")
16
+ .option("--abort", "Abort cherry-pick in progress")
17
+ .action(
18
+ async (
19
+ commits: string[],
20
+ options: {
21
+ noCommit?: boolean;
22
+ mainline?: number;
23
+ continue?: boolean;
24
+ abort?: boolean;
25
+ },
26
+ ) => {
27
+ try {
28
+ if (options.continue) {
29
+ await git.cherryPickContinue();
30
+ success("Cherry-pick continued.");
31
+ return;
32
+ }
33
+
34
+ if (options.abort) {
35
+ await git.cherryPickAbort();
36
+ success("Cherry-pick aborted.");
37
+ return;
38
+ }
39
+
40
+ if (commits.length === 0) {
41
+ info("Provide at least one commit to cherry-pick.");
42
+ return;
43
+ }
44
+
45
+ await git.cherryPick(commits, {
46
+ noCommit: options.noCommit ?? undefined,
47
+ mainline: options.mainline ?? undefined,
48
+ });
49
+ success(`Cherry-pick applied: ${commits.join(", ")}`);
50
+ } catch (err) {
51
+ error(
52
+ `Cherry-pick failed: ${err instanceof Error ? err.message : String(err)}`,
53
+ );
54
+ }
55
+ },
56
+ );
57
+ }
@@ -0,0 +1,76 @@
1
+ import type { Command } from "commander";
2
+ import * as git from "../lib/git.js";
3
+ import { error, info, success, warning } from "../lib/logger.js";
4
+ import { confirm } from "../lib/ui.js";
5
+
6
+ export default function register(program: Command): void {
7
+ program
8
+ .command("clean")
9
+ .description("Clean untracked files")
10
+ .argument("[paths...]", "Paths to clean")
11
+ .option("--dry-run", "Show what would be removed")
12
+ .option("--force", "Skip confirmation prompt")
13
+ .option("--exclude <pattern>", "Exclude files matching pattern")
14
+ .action(
15
+ async (
16
+ _paths: string[],
17
+ options: { dryRun?: boolean; force?: boolean; exclude?: string },
18
+ ) => {
19
+ try {
20
+ const opts: { dryRun?: boolean; force?: boolean; exclude?: string } =
21
+ {};
22
+ if (options.dryRun) opts.dryRun = true;
23
+ if (options.force) opts.force = true;
24
+ if (options.exclude) opts.exclude = options.exclude;
25
+
26
+ const wouldRemove = await git.clean(opts);
27
+
28
+ if (options.dryRun) {
29
+ if (wouldRemove.length === 0) {
30
+ info("Nothing would be removed.");
31
+ return;
32
+ }
33
+ info("Would remove:");
34
+ for (const line of wouldRemove) {
35
+ warning(line);
36
+ }
37
+ return;
38
+ }
39
+
40
+ if (wouldRemove.length === 0) {
41
+ info("Nothing to clean.");
42
+ return;
43
+ }
44
+
45
+ if (!options.force) {
46
+ const ok = await confirm(
47
+ `Remove ${wouldRemove.length} untracked file(s)?`,
48
+ );
49
+ if (!ok) {
50
+ info("Clean cancelled.");
51
+ return;
52
+ }
53
+ }
54
+
55
+ const result = await git.clean({
56
+ force: true,
57
+ ...(options.exclude ? { exclude: options.exclude } : {}),
58
+ });
59
+
60
+ if (result.length === 0) {
61
+ info("Nothing cleaned.");
62
+ return;
63
+ }
64
+
65
+ for (const line of result) {
66
+ warning(line);
67
+ }
68
+ success(`Cleaned ${result.length} file(s).`);
69
+ } catch (err) {
70
+ error(
71
+ `Clean failed: ${err instanceof Error ? err.message : String(err)}`,
72
+ );
73
+ }
74
+ },
75
+ );
76
+ }
@@ -0,0 +1,100 @@
1
+ import type { Command } from "commander";
2
+ import { ConfigManager } from "../lib/config.js";
3
+ import * as git from "../lib/git.js";
4
+ import { error, highlight, info } from "../lib/logger.js";
5
+ import { createTable, showBox, withSpinner } from "../lib/ui.js";
6
+
7
+ export default function register(program: Command): void {
8
+ program
9
+ .command("clone")
10
+ .description("Clone a repository")
11
+ .argument("[url]", "Repository URL or org/repo shorthand")
12
+ .argument("[dir]", "Target directory")
13
+ .option("--ssh", "Use SSH URL")
14
+ .option(
15
+ "--depth <n>",
16
+ "Create a shallow clone with history truncated to n commits",
17
+ )
18
+ .option("--branch <name>", "Clone a specific branch")
19
+ .option(
20
+ "--recurse-submodules",
21
+ "Initialize and clone submodules recursively",
22
+ )
23
+ .option("--cd", "Print cd command for eval")
24
+ .option("--list", "List recent clones")
25
+ .action(async (url, dir, options) => {
26
+ try {
27
+ if (options.list) {
28
+ const config = new ConfigManager();
29
+ const clones = config.getClones();
30
+
31
+ if (clones.length === 0) {
32
+ info("No recent clones found.");
33
+ return;
34
+ }
35
+
36
+ const rows = clones.map((c) => {
37
+ const [u, d] = c.split("|");
38
+ return [u || "", d || ""];
39
+ });
40
+
41
+ info("Recent clones:");
42
+ console.log(createTable(["URL", "Directory"], rows));
43
+ return;
44
+ }
45
+
46
+ if (!url) {
47
+ error("Repository URL or org/repo shorthand is required.");
48
+ return;
49
+ }
50
+
51
+ let resolvedUrl = url;
52
+
53
+ if (url.includes("/") && !url.includes(".") && !url.includes("://")) {
54
+ resolvedUrl = `https://github.com/${url}.git`;
55
+ }
56
+
57
+ if (options.ssh) {
58
+ const match = resolvedUrl.match(/https:\/\/github\.com\/(.+)\.git$/);
59
+ if (match) {
60
+ resolvedUrl = `git@github.com:${match[1]}.git`;
61
+ } else if (resolvedUrl.startsWith("https://")) {
62
+ const rest = resolvedUrl.replace("https://", "");
63
+ const slashIndex = rest.indexOf("/");
64
+ if (slashIndex !== -1) {
65
+ const host = rest.substring(0, slashIndex);
66
+ const path = rest.substring(slashIndex + 1).replace(/\.git$/, "");
67
+ resolvedUrl = `git@${host}:${path}.git`;
68
+ }
69
+ }
70
+ }
71
+
72
+ const cloneDir = await withSpinner("Cloning repository...", () =>
73
+ git.clone(resolvedUrl, dir, {
74
+ depth: options.depth ? Number(options.depth) : undefined,
75
+ branch: options.branch,
76
+ recurseSubmodules: options.recurseSubmodules,
77
+ }),
78
+ );
79
+
80
+ const content = [
81
+ `URL: ${resolvedUrl}`,
82
+ `Directory: ${cloneDir}`,
83
+ `Branch: ${options.branch || "default"}`,
84
+ ].join("\n");
85
+
86
+ showBox("Clone Complete", content);
87
+
88
+ if (options.cd) {
89
+ highlight(`cd ${cloneDir}`);
90
+ }
91
+
92
+ const config = new ConfigManager();
93
+ config.addClone(`${resolvedUrl}|${cloneDir}`);
94
+ } catch (err) {
95
+ error(
96
+ `Clone failed: ${err instanceof Error ? err.message : String(err)}`,
97
+ );
98
+ }
99
+ });
100
+ }