mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
- Introduced a new `package-lock.json` to manage dependencies.
- Removed obsolete `.automaker/feature_list.json` and replaced it with a new structure under `.automaker/features/{id}/feature.json` for better organization.
- Updated various components to utilize the new features API for managing features, including creation, updates, and deletions.
- Enhanced the UI to reflect changes in feature management, including updates to the sidebar and board view.
- Improved documentation and comments throughout the codebase to clarify the new feature management process.
570 lines
19 KiB
JavaScript
570 lines
19 KiB
JavaScript
const path = require("path");
|
|
const fs = require("fs/promises");
|
|
const { exec, spawn } = require("child_process");
|
|
const { promisify } = require("util");
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
/**
|
|
* Worktree Manager - Handles git worktrees for feature isolation
|
|
*
|
|
* This service creates isolated git worktrees for each feature, allowing:
|
|
* - Features to be worked on in isolation without affecting the main branch
|
|
* - Easy rollback/revert by simply deleting the worktree
|
|
* - Checkpointing - user can see changes in the worktree before merging
|
|
*/
|
|
class WorktreeManager {
|
|
constructor() {
|
|
// Cache for worktree info
|
|
this.worktreeCache = new Map();
|
|
}
|
|
|
|
/**
|
|
* Get the base worktree directory path
|
|
*/
|
|
getWorktreeBasePath(projectPath) {
|
|
return path.join(projectPath, ".automaker", "worktrees");
|
|
}
|
|
|
|
/**
|
|
* Generate a safe branch name from feature description
|
|
*/
|
|
generateBranchName(feature) {
|
|
// Create a slug from the description
|
|
const slug = feature.description
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\s-]/g, "") // Remove special chars
|
|
.replace(/\s+/g, "-") // Replace spaces with hyphens
|
|
.substring(0, 40); // Limit length
|
|
|
|
// Add feature ID for uniqueness
|
|
const shortId = feature.id.replace("feature-", "").substring(0, 12);
|
|
return `feature/${shortId}-${slug}`;
|
|
}
|
|
|
|
/**
|
|
* Check if the project is a git repository
|
|
*/
|
|
async isGitRepo(projectPath) {
|
|
try {
|
|
await execAsync("git rev-parse --is-inside-work-tree", { cwd: projectPath });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the current branch name
|
|
*/
|
|
async getCurrentBranch(projectPath) {
|
|
try {
|
|
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: projectPath });
|
|
return stdout.trim();
|
|
} catch (error) {
|
|
console.error("[WorktreeManager] Failed to get current branch:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a branch exists (local or remote)
|
|
*/
|
|
async branchExists(projectPath, branchName) {
|
|
try {
|
|
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List all existing worktrees
|
|
*/
|
|
async listWorktrees(projectPath) {
|
|
try {
|
|
const { stdout } = await execAsync("git worktree list --porcelain", { cwd: projectPath });
|
|
const worktrees = [];
|
|
const lines = stdout.split("\n");
|
|
|
|
let currentWorktree = null;
|
|
for (const line of lines) {
|
|
if (line.startsWith("worktree ")) {
|
|
if (currentWorktree) {
|
|
worktrees.push(currentWorktree);
|
|
}
|
|
currentWorktree = { path: line.replace("worktree ", "") };
|
|
} else if (line.startsWith("branch ") && currentWorktree) {
|
|
currentWorktree.branch = line.replace("branch refs/heads/", "");
|
|
} else if (line.startsWith("HEAD ") && currentWorktree) {
|
|
currentWorktree.head = line.replace("HEAD ", "");
|
|
}
|
|
}
|
|
if (currentWorktree) {
|
|
worktrees.push(currentWorktree);
|
|
}
|
|
|
|
return worktrees;
|
|
} catch (error) {
|
|
console.error("[WorktreeManager] Failed to list worktrees:", error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a worktree for a feature
|
|
* @param {string} projectPath - Path to the main project
|
|
* @param {object} feature - Feature object with id and description
|
|
* @returns {object} - { success, worktreePath, branchName, error }
|
|
*/
|
|
async createWorktree(projectPath, feature) {
|
|
console.log(`[WorktreeManager] Creating worktree for feature: ${feature.id}`);
|
|
|
|
// Check if project is a git repo
|
|
if (!await this.isGitRepo(projectPath)) {
|
|
return { success: false, error: "Project is not a git repository" };
|
|
}
|
|
|
|
const branchName = this.generateBranchName(feature);
|
|
const worktreeBasePath = this.getWorktreeBasePath(projectPath);
|
|
const worktreePath = path.join(worktreeBasePath, branchName.replace("feature/", ""));
|
|
|
|
try {
|
|
// Ensure worktree directory exists
|
|
await fs.mkdir(worktreeBasePath, { recursive: true });
|
|
|
|
// Check if worktree already exists
|
|
const worktrees = await this.listWorktrees(projectPath);
|
|
const existingWorktree = worktrees.find(
|
|
w => w.path === worktreePath || w.branch === branchName
|
|
);
|
|
|
|
if (existingWorktree) {
|
|
console.log(`[WorktreeManager] Worktree already exists for feature: ${feature.id}`);
|
|
return {
|
|
success: true,
|
|
worktreePath: existingWorktree.path,
|
|
branchName: existingWorktree.branch,
|
|
existed: true,
|
|
};
|
|
}
|
|
|
|
// Get current branch to base the new branch on
|
|
const baseBranch = await this.getCurrentBranch(projectPath);
|
|
if (!baseBranch) {
|
|
return { success: false, error: "Could not determine current branch" };
|
|
}
|
|
|
|
// Check if branch already exists
|
|
const branchExists = await this.branchExists(projectPath, branchName);
|
|
|
|
if (branchExists) {
|
|
// Use existing branch
|
|
console.log(`[WorktreeManager] Using existing branch: ${branchName}`);
|
|
await execAsync(`git worktree add "${worktreePath}" ${branchName}`, { cwd: projectPath });
|
|
} else {
|
|
// Create new worktree with new branch
|
|
console.log(`[WorktreeManager] Creating new branch: ${branchName} based on ${baseBranch}`);
|
|
await execAsync(`git worktree add -b ${branchName} "${worktreePath}" ${baseBranch}`, { cwd: projectPath });
|
|
}
|
|
|
|
// Copy .automaker directory to worktree (except worktrees directory itself to avoid recursion)
|
|
const automakerSrc = path.join(projectPath, ".automaker");
|
|
const automakerDst = path.join(worktreePath, ".automaker");
|
|
|
|
try {
|
|
await fs.mkdir(automakerDst, { recursive: true });
|
|
|
|
// Note: Features are stored in .automaker/features/{id}/feature.json
|
|
// These are managed by the main project, not copied to worktrees
|
|
|
|
// Copy app_spec.txt if it exists
|
|
const appSpecSrc = path.join(automakerSrc, "app_spec.txt");
|
|
const appSpecDst = path.join(automakerDst, "app_spec.txt");
|
|
try {
|
|
const content = await fs.readFile(appSpecSrc, "utf-8");
|
|
await fs.writeFile(appSpecDst, content, "utf-8");
|
|
} catch {
|
|
// App spec might not exist yet
|
|
}
|
|
|
|
// Copy categories.json if it exists
|
|
const categoriesSrc = path.join(automakerSrc, "categories.json");
|
|
const categoriesDst = path.join(automakerDst, "categories.json");
|
|
try {
|
|
const content = await fs.readFile(categoriesSrc, "utf-8");
|
|
await fs.writeFile(categoriesDst, content, "utf-8");
|
|
} catch {
|
|
// Categories might not exist yet
|
|
}
|
|
} catch (error) {
|
|
console.warn("[WorktreeManager] Failed to copy .automaker directory:", error);
|
|
}
|
|
|
|
// Store worktree info in cache
|
|
this.worktreeCache.set(feature.id, {
|
|
worktreePath,
|
|
branchName,
|
|
createdAt: new Date().toISOString(),
|
|
baseBranch,
|
|
});
|
|
|
|
console.log(`[WorktreeManager] Worktree created at: ${worktreePath}`);
|
|
return {
|
|
success: true,
|
|
worktreePath,
|
|
branchName,
|
|
baseBranch,
|
|
existed: false,
|
|
};
|
|
} catch (error) {
|
|
console.error("[WorktreeManager] Failed to create worktree:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get worktree info for a feature
|
|
*/
|
|
async getWorktreeInfo(projectPath, featureId) {
|
|
// Check cache first
|
|
if (this.worktreeCache.has(featureId)) {
|
|
return { success: true, ...this.worktreeCache.get(featureId) };
|
|
}
|
|
|
|
// Scan worktrees to find matching one
|
|
const worktrees = await this.listWorktrees(projectPath);
|
|
const worktreeBasePath = this.getWorktreeBasePath(projectPath);
|
|
|
|
for (const worktree of worktrees) {
|
|
// Check if this worktree is in our worktree directory
|
|
if (worktree.path.startsWith(worktreeBasePath)) {
|
|
// Check if the feature ID is in the branch name
|
|
const shortId = featureId.replace("feature-", "").substring(0, 12);
|
|
if (worktree.branch && worktree.branch.includes(shortId)) {
|
|
const info = {
|
|
worktreePath: worktree.path,
|
|
branchName: worktree.branch,
|
|
head: worktree.head,
|
|
};
|
|
this.worktreeCache.set(featureId, info);
|
|
return { success: true, ...info };
|
|
}
|
|
}
|
|
}
|
|
|
|
return { success: false, error: "Worktree not found" };
|
|
}
|
|
|
|
/**
|
|
* Remove a worktree for a feature
|
|
* This effectively reverts all changes made by the agent
|
|
*/
|
|
async removeWorktree(projectPath, featureId, deleteBranch = false) {
|
|
console.log(`[WorktreeManager] Removing worktree for feature: ${featureId}`);
|
|
|
|
const worktreeInfo = await this.getWorktreeInfo(projectPath, featureId);
|
|
if (!worktreeInfo.success) {
|
|
console.log(`[WorktreeManager] No worktree found for feature: ${featureId}`);
|
|
return { success: true, message: "No worktree to remove" };
|
|
}
|
|
|
|
const { worktreePath, branchName } = worktreeInfo;
|
|
|
|
try {
|
|
// Remove the worktree
|
|
await execAsync(`git worktree remove "${worktreePath}" --force`, { cwd: projectPath });
|
|
console.log(`[WorktreeManager] Worktree removed: ${worktreePath}`);
|
|
|
|
// Optionally delete the branch too
|
|
if (deleteBranch && branchName) {
|
|
try {
|
|
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
|
|
console.log(`[WorktreeManager] Branch deleted: ${branchName}`);
|
|
} catch (error) {
|
|
console.warn(`[WorktreeManager] Could not delete branch ${branchName}:`, error.message);
|
|
}
|
|
}
|
|
|
|
// Remove from cache
|
|
this.worktreeCache.delete(featureId);
|
|
|
|
return { success: true, removedPath: worktreePath, removedBranch: deleteBranch ? branchName : null };
|
|
} catch (error) {
|
|
console.error("[WorktreeManager] Failed to remove worktree:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get status of changes in a worktree
|
|
*/
|
|
async getWorktreeStatus(worktreePath) {
|
|
try {
|
|
const { stdout: statusOutput } = await execAsync("git status --porcelain", { cwd: worktreePath });
|
|
const { stdout: diffStat } = await execAsync("git diff --stat", { cwd: worktreePath });
|
|
const { stdout: commitLog } = await execAsync("git log --oneline -10", { cwd: worktreePath });
|
|
|
|
const files = statusOutput.trim().split("\n").filter(Boolean);
|
|
const commits = commitLog.trim().split("\n").filter(Boolean);
|
|
|
|
return {
|
|
success: true,
|
|
modifiedFiles: files.length,
|
|
files: files.slice(0, 20), // Limit to 20 files
|
|
diffStat: diffStat.trim(),
|
|
recentCommits: commits.slice(0, 5), // Last 5 commits
|
|
};
|
|
} catch (error) {
|
|
console.error("[WorktreeManager] Failed to get worktree status:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get detailed file diff content for a worktree
|
|
* Returns unified diff format for all changes
|
|
*/
|
|
async getFileDiffs(worktreePath) {
|
|
try {
|
|
// Get both staged and unstaged diffs
|
|
const { stdout: unstagedDiff } = await execAsync("git diff --no-color", {
|
|
cwd: worktreePath,
|
|
maxBuffer: 10 * 1024 * 1024 // 10MB buffer for large diffs
|
|
});
|
|
const { stdout: stagedDiff } = await execAsync("git diff --cached --no-color", {
|
|
cwd: worktreePath,
|
|
maxBuffer: 10 * 1024 * 1024
|
|
});
|
|
|
|
// Get list of files with their status
|
|
const { stdout: statusOutput } = await execAsync("git status --porcelain", { cwd: worktreePath });
|
|
const files = statusOutput.trim().split("\n").filter(Boolean);
|
|
|
|
// Parse file statuses
|
|
const fileStatuses = files.map(line => {
|
|
const status = line.substring(0, 2);
|
|
const filePath = line.substring(3);
|
|
return {
|
|
status: status.trim() || 'M',
|
|
path: filePath,
|
|
statusText: this.getStatusText(status)
|
|
};
|
|
});
|
|
|
|
// Combine diffs
|
|
const combinedDiff = [stagedDiff, unstagedDiff].filter(Boolean).join("\n");
|
|
|
|
return {
|
|
success: true,
|
|
diff: combinedDiff,
|
|
files: fileStatuses,
|
|
hasChanges: files.length > 0
|
|
};
|
|
} catch (error) {
|
|
console.error("[WorktreeManager] Failed to get file diffs:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get human-readable status text from git status code
|
|
*/
|
|
getStatusText(status) {
|
|
const statusMap = {
|
|
'M': 'Modified',
|
|
'A': 'Added',
|
|
'D': 'Deleted',
|
|
'R': 'Renamed',
|
|
'C': 'Copied',
|
|
'U': 'Updated',
|
|
'?': 'Untracked',
|
|
'!': 'Ignored'
|
|
};
|
|
const firstChar = status.charAt(0);
|
|
const secondChar = status.charAt(1);
|
|
return statusMap[firstChar] || statusMap[secondChar] || 'Changed';
|
|
}
|
|
|
|
/**
|
|
* Get diff for a specific file in a worktree
|
|
*/
|
|
async getFileDiff(worktreePath, filePath) {
|
|
try {
|
|
// Try to get unstaged diff first, then staged if no unstaged changes
|
|
let diff = '';
|
|
try {
|
|
const { stdout } = await execAsync(`git diff --no-color -- "${filePath}"`, {
|
|
cwd: worktreePath,
|
|
maxBuffer: 5 * 1024 * 1024
|
|
});
|
|
diff = stdout;
|
|
} catch {
|
|
// File might be staged
|
|
}
|
|
|
|
if (!diff) {
|
|
try {
|
|
const { stdout } = await execAsync(`git diff --cached --no-color -- "${filePath}"`, {
|
|
cwd: worktreePath,
|
|
maxBuffer: 5 * 1024 * 1024
|
|
});
|
|
diff = stdout;
|
|
} catch {
|
|
// File might be untracked, show the content
|
|
}
|
|
}
|
|
|
|
// If still no diff, might be an untracked file - show the content
|
|
if (!diff) {
|
|
try {
|
|
const fullPath = path.join(worktreePath, filePath);
|
|
const content = await fs.readFile(fullPath, 'utf-8');
|
|
diff = `+++ ${filePath} (new file)\n${content.split('\n').map(l => '+' + l).join('\n')}`;
|
|
} catch {
|
|
diff = '(Unable to read file content)';
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
diff,
|
|
filePath
|
|
};
|
|
} catch (error) {
|
|
console.error(`[WorktreeManager] Failed to get diff for ${filePath}:`, error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Merge worktree changes back to the main branch
|
|
*/
|
|
async mergeWorktree(projectPath, featureId, options = {}) {
|
|
console.log(`[WorktreeManager] Merging worktree for feature: ${featureId}`);
|
|
|
|
const worktreeInfo = await this.getWorktreeInfo(projectPath, featureId);
|
|
if (!worktreeInfo.success) {
|
|
return { success: false, error: "Worktree not found" };
|
|
}
|
|
|
|
const { branchName, worktreePath } = worktreeInfo;
|
|
const baseBranch = await this.getCurrentBranch(projectPath);
|
|
|
|
try {
|
|
// First commit any uncommitted changes in the worktree
|
|
const { stdout: status } = await execAsync("git status --porcelain", { cwd: worktreePath });
|
|
if (status.trim()) {
|
|
// There are uncommitted changes - commit them
|
|
await execAsync("git add -A", { cwd: worktreePath });
|
|
const commitMsg = options.commitMessage || `feat: complete ${featureId}`;
|
|
await execAsync(`git commit -m "${commitMsg}"`, { cwd: worktreePath });
|
|
}
|
|
|
|
// Merge the feature branch into the current branch in the main repo
|
|
if (options.squash) {
|
|
await execAsync(`git merge --squash ${branchName}`, { cwd: projectPath });
|
|
const squashMsg = options.squashMessage || `feat: ${featureId} - squashed merge`;
|
|
await execAsync(`git commit -m "${squashMsg}"`, { cwd: projectPath });
|
|
} else {
|
|
await execAsync(`git merge ${branchName} --no-ff -m "Merge ${branchName}"`, { cwd: projectPath });
|
|
}
|
|
|
|
console.log(`[WorktreeManager] Successfully merged ${branchName} into ${baseBranch}`);
|
|
|
|
// Optionally cleanup worktree after merge
|
|
if (options.cleanup) {
|
|
await this.removeWorktree(projectPath, featureId, true);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
mergedBranch: branchName,
|
|
intoBranch: baseBranch,
|
|
};
|
|
} catch (error) {
|
|
console.error("[WorktreeManager] Failed to merge worktree:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sync changes from main branch to worktree (rebase or merge)
|
|
*/
|
|
async syncWorktree(projectPath, featureId, method = "rebase") {
|
|
console.log(`[WorktreeManager] Syncing worktree for feature: ${featureId}`);
|
|
|
|
const worktreeInfo = await this.getWorktreeInfo(projectPath, featureId);
|
|
if (!worktreeInfo.success) {
|
|
return { success: false, error: "Worktree not found" };
|
|
}
|
|
|
|
const { worktreePath, baseBranch } = worktreeInfo;
|
|
|
|
try {
|
|
if (method === "rebase") {
|
|
await execAsync(`git rebase ${baseBranch}`, { cwd: worktreePath });
|
|
} else {
|
|
await execAsync(`git merge ${baseBranch}`, { cwd: worktreePath });
|
|
}
|
|
|
|
return { success: true, method };
|
|
} catch (error) {
|
|
console.error("[WorktreeManager] Failed to sync worktree:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get list of all feature worktrees
|
|
*/
|
|
async getAllFeatureWorktrees(projectPath) {
|
|
const worktrees = await this.listWorktrees(projectPath);
|
|
const worktreeBasePath = this.getWorktreeBasePath(projectPath);
|
|
|
|
return worktrees.filter(w =>
|
|
w.path.startsWith(worktreeBasePath) &&
|
|
w.branch &&
|
|
w.branch.startsWith("feature/")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Cleanup orphaned worktrees (worktrees without matching features)
|
|
*/
|
|
async cleanupOrphanedWorktrees(projectPath, activeFeatureIds) {
|
|
console.log("[WorktreeManager] Cleaning up orphaned worktrees...");
|
|
|
|
const worktrees = await this.getAllFeatureWorktrees(projectPath);
|
|
const cleaned = [];
|
|
|
|
for (const worktree of worktrees) {
|
|
// Extract feature ID from branch name
|
|
const branchParts = worktree.branch.replace("feature/", "").split("-");
|
|
const shortId = branchParts[0];
|
|
|
|
// Check if any active feature has this short ID
|
|
const hasMatchingFeature = activeFeatureIds.some(id => {
|
|
const featureShortId = id.replace("feature-", "").substring(0, 12);
|
|
return featureShortId === shortId;
|
|
});
|
|
|
|
if (!hasMatchingFeature) {
|
|
console.log(`[WorktreeManager] Removing orphaned worktree: ${worktree.path}`);
|
|
try {
|
|
await execAsync(`git worktree remove "${worktree.path}" --force`, { cwd: projectPath });
|
|
await execAsync(`git branch -D ${worktree.branch}`, { cwd: projectPath });
|
|
cleaned.push(worktree.path);
|
|
} catch (error) {
|
|
console.warn(`[WorktreeManager] Failed to cleanup worktree ${worktree.path}:`, error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { success: true, cleaned };
|
|
}
|
|
}
|
|
|
|
module.exports = new WorktreeManager();
|