mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-17 22:13:08 +00:00
feat: Mobile improvements and Add selective file staging and improve branch switching
This commit is contained in:
@@ -15,9 +15,10 @@ const execAsync = promisify(exec);
|
||||
export function createCommitHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, message } = req.body as {
|
||||
const { worktreePath, message, files } = req.body as {
|
||||
worktreePath: string;
|
||||
message: string;
|
||||
files?: string[];
|
||||
};
|
||||
|
||||
if (!worktreePath || !message) {
|
||||
@@ -44,8 +45,19 @@ export function createCommitHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stage all changes
|
||||
await execAsync('git add -A', { cwd: worktreePath });
|
||||
// Stage changes - either specific files or all changes
|
||||
if (files && files.length > 0) {
|
||||
// Reset any previously staged changes first
|
||||
await execAsync('git reset HEAD', { cwd: worktreePath }).catch(() => {
|
||||
// Ignore errors from reset (e.g., if nothing is staged)
|
||||
});
|
||||
// Stage only the selected files
|
||||
const escapedFiles = files.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(' ');
|
||||
await execAsync(`git add ${escapedFiles}`, { cwd: worktreePath });
|
||||
} else {
|
||||
// Stage all changes (original behavior)
|
||||
await execAsync('git add -A', { cwd: worktreePath });
|
||||
}
|
||||
|
||||
// Create commit
|
||||
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
|
||||
|
||||
@@ -92,6 +92,9 @@ export function createListBranchesHandler() {
|
||||
// Skip HEAD pointers like "origin/HEAD"
|
||||
if (cleanName.includes('/HEAD')) return;
|
||||
|
||||
// Skip bare remote names without a branch (e.g. "origin" by itself)
|
||||
if (!cleanName.includes('/')) return;
|
||||
|
||||
// Only add remote branches if a branch with the exact same name isn't already
|
||||
// in the list. This avoids duplicates if a local branch is named like a remote one.
|
||||
// Note: We intentionally include remote branches even when a local branch with the
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
/**
|
||||
* POST /switch-branch endpoint - Switch to an existing branch
|
||||
*
|
||||
* Simple branch switching.
|
||||
* If there are uncommitted changes, the switch will fail and
|
||||
* the user should commit first.
|
||||
* Handles branch switching with automatic stash/reapply of local changes.
|
||||
* If there are uncommitted changes, they are stashed before switching and
|
||||
* reapplied after. If the stash pop results in merge conflicts, returns
|
||||
* a special response code so the UI can create a conflict resolution task.
|
||||
*
|
||||
* For remote branches (e.g., "origin/feature"), automatically creates a
|
||||
* local tracking branch and checks it out.
|
||||
*
|
||||
* Also fetches the latest remote refs after switching.
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidWorktree middleware in index.ts
|
||||
@@ -16,14 +22,14 @@ import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
function isUntrackedLine(line: string): boolean {
|
||||
return line.startsWith('?? ');
|
||||
}
|
||||
|
||||
function isExcludedWorktreeLine(line: string): boolean {
|
||||
return line.includes('.worktrees/') || line.endsWith('.worktrees');
|
||||
}
|
||||
|
||||
function isUntrackedLine(line: string): boolean {
|
||||
return line.startsWith('?? ');
|
||||
}
|
||||
|
||||
function isBlockingChangeLine(line: string): boolean {
|
||||
if (!line.trim()) return false;
|
||||
if (isExcludedWorktreeLine(line)) return false;
|
||||
@@ -46,18 +52,130 @@ async function hasUncommittedChanges(cwd: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary of uncommitted changes for user feedback
|
||||
* Excludes .worktrees/ directory
|
||||
* Check if there are any changes at all (including untracked) that should be stashed
|
||||
*/
|
||||
async function getChangesSummary(cwd: string): Promise<string> {
|
||||
async function hasAnyChanges(cwd: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git status --short', { cwd });
|
||||
const lines = stdout.trim().split('\n').filter(isBlockingChangeLine);
|
||||
if (lines.length === 0) return '';
|
||||
if (lines.length <= 5) return lines.join(', ');
|
||||
return `${lines.slice(0, 5).join(', ')} and ${lines.length - 5} more files`;
|
||||
const { stdout } = await execAsync('git status --porcelain', { cwd });
|
||||
const lines = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => {
|
||||
if (!line.trim()) return false;
|
||||
if (isExcludedWorktreeLine(line)) return false;
|
||||
return true;
|
||||
});
|
||||
return lines.length > 0;
|
||||
} catch {
|
||||
return 'unknown changes';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stash all local changes (including untracked files)
|
||||
* Returns true if a stash was created, false if there was nothing to stash
|
||||
*/
|
||||
async function stashChanges(cwd: string, message: string): Promise<boolean> {
|
||||
try {
|
||||
// Get stash count before
|
||||
const { stdout: beforeCount } = await execAsync('git stash list', { cwd });
|
||||
const countBefore = beforeCount
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((l) => l.trim()).length;
|
||||
|
||||
// Stash including untracked files
|
||||
await execAsync(`git stash push --include-untracked -m "${message}"`, { cwd });
|
||||
|
||||
// Get stash count after to verify something was stashed
|
||||
const { stdout: afterCount } = await execAsync('git stash list', { cwd });
|
||||
const countAfter = afterCount
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((l) => l.trim()).length;
|
||||
|
||||
return countAfter > countBefore;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop the most recent stash entry
|
||||
* Returns an object indicating success and whether there were conflicts
|
||||
*/
|
||||
async function popStash(
|
||||
cwd: string
|
||||
): Promise<{ success: boolean; hasConflicts: boolean; error?: string }> {
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync('git stash pop', { cwd });
|
||||
const output = `${stdout}\n${stderr}`;
|
||||
// Check for conflict markers in the output
|
||||
if (output.includes('CONFLICT') || output.includes('Merge conflict')) {
|
||||
return { success: false, hasConflicts: true };
|
||||
}
|
||||
return { success: true, hasConflicts: false };
|
||||
} catch (error) {
|
||||
const errorMsg = getErrorMessage(error);
|
||||
if (errorMsg.includes('CONFLICT') || errorMsg.includes('Merge conflict')) {
|
||||
return { success: false, hasConflicts: true, error: errorMsg };
|
||||
}
|
||||
return { success: false, hasConflicts: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch latest from all remotes (silently, with timeout)
|
||||
*/
|
||||
async function fetchRemotes(cwd: string): Promise<void> {
|
||||
try {
|
||||
await execAsync('git fetch --all --quiet', {
|
||||
cwd,
|
||||
timeout: 15000, // 15 second timeout
|
||||
});
|
||||
} catch {
|
||||
// Ignore fetch errors - we may be offline
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a remote branch name like "origin/feature-branch" into its parts
|
||||
*/
|
||||
function parseRemoteBranch(branchName: string): { remote: string; branch: string } | null {
|
||||
const slashIndex = branchName.indexOf('/');
|
||||
if (slashIndex === -1) return null;
|
||||
return {
|
||||
remote: branchName.substring(0, slashIndex),
|
||||
branch: branchName.substring(slashIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a branch name refers to a remote branch
|
||||
*/
|
||||
async function isRemoteBranch(cwd: string, branchName: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git branch -r --format="%(refname:short)"', { cwd });
|
||||
const remoteBranches = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((b) => b.trim().replace(/^['"]|['"]$/g, ''))
|
||||
.filter((b) => b);
|
||||
return remoteBranches.includes(branchName);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a local branch already exists
|
||||
*/
|
||||
async function localBranchExists(cwd: string, branchName: string): Promise<boolean> {
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify "refs/heads/${branchName}"`, { cwd });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,53 +209,133 @@ export function createSwitchBranchHandler() {
|
||||
});
|
||||
const previousBranch = currentBranchOutput.trim();
|
||||
|
||||
if (previousBranch === branchName) {
|
||||
// Determine the actual target branch name for checkout
|
||||
let targetBranch = branchName;
|
||||
let isRemote = false;
|
||||
|
||||
// Check if this is a remote branch (e.g., "origin/feature-branch")
|
||||
if (await isRemoteBranch(worktreePath, branchName)) {
|
||||
isRemote = true;
|
||||
const parsed = parseRemoteBranch(branchName);
|
||||
if (parsed) {
|
||||
// If a local branch with the same name already exists, just switch to it
|
||||
if (await localBranchExists(worktreePath, parsed.branch)) {
|
||||
targetBranch = parsed.branch;
|
||||
} else {
|
||||
// Will create a local tracking branch from the remote
|
||||
targetBranch = parsed.branch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previousBranch === targetBranch) {
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
previousBranch,
|
||||
currentBranch: branchName,
|
||||
message: `Already on branch '${branchName}'`,
|
||||
currentBranch: targetBranch,
|
||||
message: `Already on branch '${targetBranch}'`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if branch exists
|
||||
// Check if target branch exists (locally or as remote ref)
|
||||
if (!isRemote) {
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify "${branchName}"`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
} catch {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Branch '${branchName}' does not exist`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Stash local changes if any exist
|
||||
const hadChanges = await hasAnyChanges(worktreePath);
|
||||
let didStash = false;
|
||||
|
||||
if (hadChanges) {
|
||||
const stashMessage = `automaker-branch-switch: ${previousBranch} → ${targetBranch}`;
|
||||
didStash = await stashChanges(worktreePath, stashMessage);
|
||||
}
|
||||
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${branchName}`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
} catch {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Branch '${branchName}' does not exist`,
|
||||
});
|
||||
return;
|
||||
// Switch to the target branch
|
||||
if (isRemote) {
|
||||
const parsed = parseRemoteBranch(branchName);
|
||||
if (parsed) {
|
||||
if (await localBranchExists(worktreePath, parsed.branch)) {
|
||||
// Local branch exists, just checkout
|
||||
await execAsync(`git checkout "${parsed.branch}"`, { cwd: worktreePath });
|
||||
} else {
|
||||
// Create local tracking branch from remote
|
||||
await execAsync(`git checkout -b "${parsed.branch}" "${branchName}"`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await execAsync(`git checkout "${targetBranch}"`, { cwd: worktreePath });
|
||||
}
|
||||
|
||||
// Fetch latest from remotes after switching
|
||||
await fetchRemotes(worktreePath);
|
||||
|
||||
// Reapply stashed changes if we stashed earlier
|
||||
let hasConflicts = false;
|
||||
let conflictMessage = '';
|
||||
|
||||
if (didStash) {
|
||||
const popResult = await popStash(worktreePath);
|
||||
if (popResult.hasConflicts) {
|
||||
hasConflicts = true;
|
||||
conflictMessage = `Switched to branch '${targetBranch}' but merge conflicts occurred when reapplying your local changes. Please resolve the conflicts.`;
|
||||
} else if (!popResult.success) {
|
||||
// Stash pop failed for a non-conflict reason - the stash is still there
|
||||
conflictMessage = `Switched to branch '${targetBranch}' but failed to reapply stashed changes: ${popResult.error}. Your changes are still in the stash.`;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasConflicts) {
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
previousBranch,
|
||||
currentBranch: targetBranch,
|
||||
message: conflictMessage,
|
||||
hasConflicts: true,
|
||||
stashedChanges: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const stashNote = didStash ? ' (local changes stashed and reapplied)' : '';
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
previousBranch,
|
||||
currentBranch: targetBranch,
|
||||
message: `Switched to branch '${targetBranch}'${stashNote}`,
|
||||
hasConflicts: false,
|
||||
stashedChanges: didStash,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (checkoutError) {
|
||||
// If checkout failed and we stashed, try to restore the stash
|
||||
if (didStash) {
|
||||
try {
|
||||
await popStash(worktreePath);
|
||||
} catch {
|
||||
// Ignore errors restoring stash - it's still in the stash list
|
||||
}
|
||||
}
|
||||
throw checkoutError;
|
||||
}
|
||||
|
||||
// Check for uncommitted changes
|
||||
if (await hasUncommittedChanges(worktreePath)) {
|
||||
const summary = await getChangesSummary(worktreePath);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Cannot switch branches: you have uncommitted changes (${summary}). Please commit your changes first.`,
|
||||
code: 'UNCOMMITTED_CHANGES',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Switch to the target branch
|
||||
await execAsync(`git checkout "${branchName}"`, { cwd: worktreePath });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
previousBranch,
|
||||
currentBranch: branchName,
|
||||
message: `Switched to branch '${branchName}'`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Switch branch failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
|
||||
@@ -387,8 +387,10 @@ export class AutoLoopCoordinator {
|
||||
const projectId = settings.projects?.find((p) => p.path === projectPath)?.id;
|
||||
const autoModeByWorktree = settings.autoModeByWorktree;
|
||||
if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') {
|
||||
const normalizedBranch =
|
||||
branchName === null || branchName === 'main' ? '__main__' : branchName;
|
||||
// branchName is already normalized to null for the primary branch by callers
|
||||
// (e.g., checkWorktreeCapacity, startAutoLoopForProject), so we only
|
||||
// need to convert null to '__main__' for the worktree key lookup
|
||||
const normalizedBranch = branchName === null ? '__main__' : branchName;
|
||||
const worktreeId = `${projectId}::${normalizedBranch}`;
|
||||
if (
|
||||
worktreeId in autoModeByWorktree &&
|
||||
|
||||
Reference in New Issue
Block a user