feat(project): add workspace directory configuration and management (#1)

- Add projectDir field to Project model for workspace directory management
- Implement workspace directory creation, validation and Git initialization
- Add workspace status query endpoint with directory info and Git status
- Create GitManager for Git repository operations (clone, branch, commit info)
- Add PathValidator for secure path validation and traversal attack prevention
- Implement execution queue with concurrency control for build tasks

- Refactor project list UI to remove edit/delete actions from cards
- Add project settings tab in detail page with edit/delete functionality
- Add icons to all tabs (History, Code, Settings)
- Implement time formatting with dayjs in YYYY-MM-DD HH:mm:ss format
- Display all timestamps using browser's local timezone

- Update PipelineRunner to use workspace directory for command execution
- Add workspace status card showing directory path, size, Git info
- Enhance CreateProjectModal with repository URL validation

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-01-03 00:55:55 +08:00
parent 9897bd04c2
commit c40532c757
23 changed files with 1859 additions and 229 deletions

View File

@@ -0,0 +1,280 @@
/**
* Git 管理器
* 封装 Git 操作:克隆、更新、分支切换等
*/
import { $ } from 'zx';
import fs from 'node:fs/promises';
import path from 'node:path';
import { log } from './logger';
/**
* 工作目录状态
*/
export const WorkspaceDirStatus = {
NOT_CREATED: 'not_created', // 目录不存在
EMPTY: 'empty', // 目录存在但为空
NO_GIT: 'no_git', // 目录存在但不是 Git 仓库
READY: 'ready', // 目录存在且包含 Git 仓库
} as const;
export type WorkspaceDirStatus =
(typeof WorkspaceDirStatus)[keyof typeof WorkspaceDirStatus];
/**
* 工作目录状态信息
*/
export interface WorkspaceStatus {
status: WorkspaceDirStatus;
exists: boolean;
isEmpty?: boolean;
hasGit?: boolean;
}
/**
* Git仓库信息
*/
export interface GitInfo {
branch?: string;
lastCommit?: string;
lastCommitMessage?: string;
}
/**
* Git管理器类
*/
export class GitManager {
static readonly TAG = 'GitManager';
/**
* 检查工作目录状态
*/
static async checkWorkspaceStatus(dirPath: string): Promise<WorkspaceStatus> {
try {
// 检查目录是否存在
const stats = await fs.stat(dirPath);
if (!stats.isDirectory()) {
return {
status: WorkspaceDirStatus.NOT_CREATED,
exists: false,
};
}
// 检查目录是否为空
const files = await fs.readdir(dirPath);
if (files.length === 0) {
return {
status: WorkspaceDirStatus.EMPTY,
exists: true,
isEmpty: true,
};
}
// 检查是否包含 .git 目录
const gitDir = path.join(dirPath, '.git');
try {
const gitStats = await fs.stat(gitDir);
if (gitStats.isDirectory()) {
return {
status: WorkspaceDirStatus.READY,
exists: true,
isEmpty: false,
hasGit: true,
};
}
} catch {
return {
status: WorkspaceDirStatus.NO_GIT,
exists: true,
isEmpty: false,
hasGit: false,
};
}
return {
status: WorkspaceDirStatus.NO_GIT,
exists: true,
isEmpty: false,
hasGit: false,
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return {
status: WorkspaceDirStatus.NOT_CREATED,
exists: false,
};
}
throw error;
}
}
/**
* 克隆仓库到指定目录
* @param repoUrl 仓库URL
* @param dirPath 目标目录
* @param branch 分支名
* @param token Gitea access token可选
*/
static async cloneRepository(
repoUrl: string,
dirPath: string,
branch: string,
token?: string,
): Promise<void> {
try {
log.info(
GitManager.TAG,
'Cloning repository: %s to %s (branch: %s)',
repoUrl,
dirPath,
branch,
);
// 如果提供了token嵌入到URL中
let cloneUrl = repoUrl;
if (token) {
const url = new URL(repoUrl);
url.username = token;
cloneUrl = url.toString();
}
// 使用 zx 执行 git clone浅克隆
$.verbose = false; // 禁止打印敏感信息
await $`git clone --depth 1 --branch ${branch} ${cloneUrl} ${dirPath}`;
$.verbose = true;
log.info(GitManager.TAG, 'Repository cloned successfully: %s', dirPath);
} catch (error) {
log.error(
GitManager.TAG,
'Failed to clone repository: %s to %s, error: %s',
repoUrl,
dirPath,
(error as Error).message,
);
throw new Error(`克隆仓库失败: ${(error as Error).message}`);
}
}
/**
* 更新已存在的仓库
* @param dirPath 仓库目录
* @param branch 目标分支
*/
static async updateRepository(
dirPath: string,
branch: string,
): Promise<void> {
try {
log.info(
GitManager.TAG,
'Updating repository: %s (branch: %s)',
dirPath,
branch,
);
$.verbose = false;
// 切换到仓库目录
const originalCwd = process.cwd();
process.chdir(dirPath);
try {
// 获取最新代码
await $`git fetch --depth 1 origin ${branch}`;
// 切换到目标分支
await $`git checkout ${branch}`;
// 拉取最新代码
await $`git pull origin ${branch}`;
log.info(
GitManager.TAG,
'Repository updated successfully: %s (branch: %s)',
dirPath,
branch,
);
} finally {
process.chdir(originalCwd);
$.verbose = true;
}
} catch (error) {
log.error(
GitManager.TAG,
'Failed to update repository: %s (branch: %s), error: %s',
dirPath,
branch,
(error as Error).message,
);
throw new Error(`更新仓库失败: ${(error as Error).message}`);
}
}
/**
* 获取Git仓库信息
*/
static async getGitInfo(dirPath: string): Promise<GitInfo> {
try {
const originalCwd = process.cwd();
process.chdir(dirPath);
try {
$.verbose = false;
const branchResult = await $`git branch --show-current`;
const commitResult = await $`git rev-parse --short HEAD`;
const messageResult = await $`git log -1 --pretty=%B`;
$.verbose = true;
return {
branch: branchResult.stdout.trim(),
lastCommit: commitResult.stdout.trim(),
lastCommitMessage: messageResult.stdout.trim(),
};
} finally {
process.chdir(originalCwd);
}
} catch (error) {
log.error(
GitManager.TAG,
'Failed to get git info: %s, error: %s',
dirPath,
(error as Error).message,
);
return {};
}
}
/**
* 创建目录(递归)
*/
static async ensureDirectory(dirPath: string): Promise<void> {
try {
await fs.mkdir(dirPath, { recursive: true });
log.info(GitManager.TAG, 'Directory created: %s', dirPath);
} catch (error) {
log.error(
GitManager.TAG,
'Failed to create directory: %s, error: %s',
dirPath,
(error as Error).message,
);
throw new Error(`创建目录失败: ${(error as Error).message}`);
}
}
/**
* 获取目录大小
*/
static async getDirectorySize(dirPath: string): Promise<number> {
try {
const { stdout } = await $`du -sb ${dirPath}`;
const size = Number.parseInt(stdout.split('\t')[0], 10);
return size;
} catch (error) {
log.error(
GitManager.TAG,
'Failed to get directory size: %s, error: %s',
dirPath,
(error as Error).message,
);
return 0;
}
}
}