feat(FN-2622): merge fusion/fn-2622
This commit is contained in:
5
.changeset/auto-git-init.md
Normal file
5
.changeset/auto-git-init.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Add a `--git` flag to `fn init` to auto-initialize a git repository (including an initial commit) when the target directory is not already a git repo.
|
||||
@@ -220,7 +220,7 @@ fn — AI-orchestrated task board
|
||||
|
||||
Usage:
|
||||
fn Launch the dashboard (same as fn dashboard)
|
||||
fn init [opts] Initialize a new fn project in the current directory
|
||||
fn init [opts] Initialize a new fn project (--name, --path, --git)
|
||||
fn dashboard Start the board web UI
|
||||
fn dashboard --paused Start with automation paused
|
||||
fn dashboard --dev Start web UI only (no AI engine)
|
||||
@@ -495,8 +495,9 @@ async function main() {
|
||||
const name = nameIdx !== -1 && nameIdx + 1 < args.length ? args[nameIdx + 1] : undefined;
|
||||
const pathIdx = args.indexOf("--path");
|
||||
const path = pathIdx !== -1 && pathIdx + 1 < args.length ? args[pathIdx + 1] : undefined;
|
||||
const git = args.includes("--git");
|
||||
|
||||
await runInit({ name, path });
|
||||
await runInit({ name, path, git });
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ import { mkdtempSync, existsSync, rmSync, writeFileSync, mkdirSync, readFileSync
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { runInit } from "../init.js";
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const mockCentralInit = vi.fn();
|
||||
const mockCentralClose = vi.fn();
|
||||
@@ -31,6 +35,11 @@ function tempDir(prefix: string): string {
|
||||
return mkdtempSync(join(tmpdir(), prefix));
|
||||
}
|
||||
|
||||
async function git(command: string, cwd: string): Promise<string> {
|
||||
const { stdout } = await execAsync(command, { cwd, timeout: 10_000 });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
describe("init command", () => {
|
||||
let tempProjectDir: string;
|
||||
let tempHomeDir: string;
|
||||
@@ -247,4 +256,51 @@ describe("init command", () => {
|
||||
expect(fusionMatches).toHaveLength(1);
|
||||
expect(piMatches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("initializes git when --git is enabled in a non-git directory", async () => {
|
||||
expect(existsSync(join(tempProjectDir, ".git"))).toBe(false);
|
||||
|
||||
await runInit({ path: tempProjectDir, git: true });
|
||||
|
||||
expect(existsSync(join(tempProjectDir, ".git"))).toBe(true);
|
||||
});
|
||||
|
||||
it("creates an initial commit when --git initializes a repository", async () => {
|
||||
await runInit({ path: tempProjectDir, git: true });
|
||||
|
||||
const commitCount = await git("git rev-list --count HEAD", tempProjectDir);
|
||||
expect(Number(commitCount)).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("does not reinitialize git when repository already exists", async () => {
|
||||
await git("git init", tempProjectDir);
|
||||
await git("git checkout -b main", tempProjectDir);
|
||||
await git('git config user.name "Existing User"', tempProjectDir);
|
||||
await git('git config user.email "existing@example.com"', tempProjectDir);
|
||||
writeFileSync(join(tempProjectDir, "README.md"), "# Existing Repo\n");
|
||||
await git("git add README.md", tempProjectDir);
|
||||
await git('git commit -m "existing commit"', tempProjectDir);
|
||||
|
||||
await runInit({ path: tempProjectDir, git: true });
|
||||
|
||||
const commitCount = await git("git rev-list --count HEAD", tempProjectDir);
|
||||
expect(Number(commitCount)).toBe(1);
|
||||
});
|
||||
|
||||
it("does not create git repository without --git and logs a hint", async () => {
|
||||
const originalLog = console.log;
|
||||
const logs: string[] = [];
|
||||
console.log = (...args: unknown[]) => {
|
||||
logs.push(args.join(" "));
|
||||
};
|
||||
|
||||
try {
|
||||
await runInit({ path: tempProjectDir });
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
}
|
||||
|
||||
expect(existsSync(join(tempProjectDir, ".git"))).toBe(false);
|
||||
expect(logs.join("\n")).toContain("Not a git repository. Run 'fn init --git' to auto-initialize one.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { promisify } from "node:util";
|
||||
const execAsync = promisify(exec);
|
||||
import { CentralCore, QMD_INSTALL_COMMAND, isQmdAvailable } from "@fusion/core";
|
||||
import { maybeInstallClaudeSkillForNewProject } from "./claude-skills-runner.js";
|
||||
import { isGitRepo } from "./git.js";
|
||||
import {
|
||||
installBundledFusionSkill,
|
||||
type SkillInstallResult,
|
||||
@@ -26,6 +27,8 @@ export interface InitOptions {
|
||||
name?: string;
|
||||
/** Path to initialize (defaults to cwd) */
|
||||
path?: string;
|
||||
/** Initialize a git repository if one does not exist */
|
||||
git?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,6 +81,14 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
|
||||
console.log(` ✓ Created .fusion/ directory`);
|
||||
}
|
||||
|
||||
const hasGitRepo = await isGitRepo(cwd);
|
||||
if (!hasGitRepo && options.git) {
|
||||
await initializeGitRepo(cwd);
|
||||
console.log(` ✓ Initialized git repository`);
|
||||
} else if (!hasGitRepo) {
|
||||
console.log(` ⚠ Not a git repository. Run 'fn init --git' to auto-initialize one.`);
|
||||
}
|
||||
|
||||
// Add local Fusion/Pi storage directories to .gitignore
|
||||
await addLocalStorageToGitignore(cwd);
|
||||
await warnIfQmdMissing();
|
||||
@@ -214,6 +225,55 @@ async function addLocalStorageToGitignore(cwd: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeGitRepo(cwd: string): Promise<void> {
|
||||
await execAsync("git init", { cwd, timeout: 10_000 });
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync("git symbolic-ref --quiet --short HEAD", {
|
||||
cwd,
|
||||
timeout: 10_000,
|
||||
});
|
||||
if (stdout.trim() !== "main") {
|
||||
await execAsync("git checkout -b main", { cwd, timeout: 10_000 });
|
||||
}
|
||||
} catch {
|
||||
// Older git versions or detached/unborn states may fail symbolic-ref.
|
||||
// Best-effort: create/switch to main.
|
||||
try {
|
||||
await execAsync("git checkout -b main", { cwd, timeout: 10_000 });
|
||||
} catch {
|
||||
await execAsync("git checkout main", { cwd, timeout: 10_000 });
|
||||
}
|
||||
}
|
||||
|
||||
await ensureGitConfig(cwd, "user.name", "Fusion");
|
||||
await ensureGitConfig(cwd, "user.email", "noreply@runfusion.ai");
|
||||
|
||||
const gitkeepPath = join(cwd, ".gitkeep");
|
||||
if (!existsSync(gitkeepPath)) {
|
||||
writeFileSync(gitkeepPath, "\n");
|
||||
}
|
||||
|
||||
await execAsync("git add .gitkeep", { cwd, timeout: 10_000 });
|
||||
await execAsync('git commit --allow-empty -m "chore: initial commit"', {
|
||||
cwd,
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureGitConfig(cwd: string, key: string, value: string): Promise<void> {
|
||||
try {
|
||||
const { stdout } = await execAsync(`git config --get ${key}`, { cwd, timeout: 10_000 });
|
||||
if (stdout.trim().length > 0) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Missing config; set a local default.
|
||||
}
|
||||
|
||||
await execAsync(`git config ${key} "${value}"`, { cwd, timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function warnIfQmdMissing(): Promise<void> {
|
||||
if (await isQmdAvailable()) {
|
||||
console.log(` ✓ qmd available for memory search`);
|
||||
|
||||
Reference in New Issue
Block a user