Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0f538624f |
@@ -6,6 +6,7 @@ Commands for creating and managing multiple tasks from batch files.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from ui import highlight, print_status
|
||||
@@ -174,17 +175,23 @@ def handle_batch_status_command(project_dir: str) -> bool:
|
||||
|
||||
def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool:
|
||||
"""
|
||||
Clean up completed specs and worktrees.
|
||||
|
||||
Args:
|
||||
project_dir: Project directory
|
||||
dry_run: If True, show what would be deleted
|
||||
|
||||
Clean up completed spec directories and their associated worktree paths.
|
||||
|
||||
Finds spec directories under <project_dir>/.auto-claude/specs that contain a `qa_report.md` (treated as completed),
|
||||
and, when run in dry-run mode, prints the specs and corresponding worktree paths that would be removed.
|
||||
|
||||
Parameters:
|
||||
project_dir (str): Path to the project root.
|
||||
dry_run (bool): If True, print what would be removed instead of performing deletions.
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
True if the command completed.
|
||||
"""
|
||||
from core.config import get_worktree_base_path
|
||||
|
||||
specs_dir = Path(project_dir) / ".auto-claude" / "specs"
|
||||
worktrees_dir = Path(project_dir) / ".worktrees"
|
||||
worktree_base_path = get_worktree_base_path(Path(project_dir))
|
||||
worktrees_dir = Path(project_dir) / worktree_base_path
|
||||
|
||||
if not specs_dir.exists():
|
||||
print_status("No specs directory found", "info")
|
||||
@@ -209,8 +216,8 @@ def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool
|
||||
print(f" - {spec_name}")
|
||||
wt_path = worktrees_dir / spec_name
|
||||
if wt_path.exists():
|
||||
print(f" └─ .worktrees/{spec_name}/")
|
||||
print(f" └─ {worktree_base_path}/{spec_name}/")
|
||||
print()
|
||||
print("Run with --no-dry-run to actually delete")
|
||||
|
||||
return True
|
||||
return True
|
||||
@@ -15,6 +15,8 @@ _PARENT_DIR = Path(__file__).parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
from .batch_commands import (
|
||||
handle_batch_cleanup_command,
|
||||
@@ -259,7 +261,11 @@ Environment Variables:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main CLI entry point."""
|
||||
"""
|
||||
Entry point for the CLI: sets up the environment, parses arguments, and dispatches the requested command.
|
||||
|
||||
This function initializes runtime environment and debugging, resolves the project directory (and loads a project-specific .auto-claude/.env file if present), determines the model choice from the CLI or the AUTO_BUILD_MODEL environment variable, and routes control to the appropriate handler based on parsed CLI flags (examples include listing specs, worktree management, batch operations, merge/preview/review/discard flows, QA and follow-up commands, or the normal build flow). Exits the process with a non-zero status when required by invalid input or failing command outcomes.
|
||||
"""
|
||||
# Set up environment first
|
||||
setup_environment()
|
||||
|
||||
@@ -276,6 +282,12 @@ def main() -> None:
|
||||
project_dir = get_project_dir(args.project_dir)
|
||||
debug("run.py", f"Using project directory: {project_dir}")
|
||||
|
||||
# Load project-specific .env file (overrides backend .env)
|
||||
project_env = project_dir / ".auto-claude" / ".env"
|
||||
if project_env.exists():
|
||||
load_dotenv(project_env, override=True)
|
||||
debug("run.py", f"Loaded project .env from: {project_env}")
|
||||
|
||||
# Get model from CLI arg or env var (None if not explicitly set)
|
||||
# This allows get_phase_model() to fall back to task_metadata.json
|
||||
model = args.model or os.environ.get("AUTO_BUILD_MODEL")
|
||||
@@ -410,4 +422,4 @@ def main() -> None:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Core configuration for Auto Claude.
|
||||
|
||||
This module provides centralized configuration management for Auto Claude,
|
||||
including worktree path resolution and validation. It ensures consistent
|
||||
configuration access across the entire backend codebase.
|
||||
|
||||
Constants:
|
||||
WORKTREE_BASE_PATH_VAR (str): Environment variable name for custom worktree base path.
|
||||
DEFAULT_WORKTREE_PATH (str): Default worktree directory name relative to project root.
|
||||
|
||||
Example:
|
||||
>>> from core.config import get_worktree_base_path
|
||||
>>> from pathlib import Path
|
||||
>>>
|
||||
>>> # Get worktree path with validation
|
||||
>>> project_dir = Path("/path/to/project")
|
||||
>>> worktree_path = get_worktree_base_path(project_dir)
|
||||
>>> full_path = project_dir / worktree_path
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Environment variable names
|
||||
WORKTREE_BASE_PATH_VAR = "WORKTREE_BASE_PATH"
|
||||
"""str: Environment variable name for configuring custom worktree base path.
|
||||
|
||||
Users can set this environment variable in their project's .env file to specify
|
||||
a custom location for worktree directories, supporting both relative and absolute paths.
|
||||
"""
|
||||
|
||||
# Default values
|
||||
DEFAULT_WORKTREE_PATH = ".worktrees"
|
||||
"""str: Default worktree directory name.
|
||||
|
||||
This is the fallback value used when WORKTREE_BASE_PATH is not set or when
|
||||
validation fails (e.g., path points to .auto-claude/ or .git/ directories).
|
||||
"""
|
||||
|
||||
|
||||
def get_worktree_base_path(project_dir: Path | None = None) -> str:
|
||||
"""
|
||||
Determine the validated worktree base path from the WORKTREE_BASE_PATH environment variable or the default.
|
||||
|
||||
Parameters:
|
||||
project_dir (Path | None): Optional project root used to resolve relative paths and perform stricter validation. If omitted, only basic pattern checks are applied.
|
||||
|
||||
Returns:
|
||||
str: The configured worktree base path string, or DEFAULT_WORKTREE_PATH ('.worktrees') if the configured value is invalid or points inside the project's `.auto-claude` or `.git` directories.
|
||||
"""
|
||||
worktree_base_path = os.getenv(WORKTREE_BASE_PATH_VAR, DEFAULT_WORKTREE_PATH)
|
||||
|
||||
# If no project_dir provided, return as-is (basic validation only)
|
||||
if not project_dir:
|
||||
# Check for obviously dangerous patterns
|
||||
normalized = Path(worktree_base_path).as_posix()
|
||||
if ".auto-claude" in normalized or ".git" in normalized:
|
||||
return DEFAULT_WORKTREE_PATH
|
||||
return worktree_base_path
|
||||
|
||||
# Resolve the absolute path
|
||||
if Path(worktree_base_path).is_absolute():
|
||||
resolved = Path(worktree_base_path).resolve()
|
||||
else:
|
||||
resolved = (project_dir / worktree_base_path).resolve()
|
||||
|
||||
# Prevent paths inside .auto-claude/ or .git/
|
||||
auto_claude_dir = (project_dir / ".auto-claude").resolve()
|
||||
git_dir = (project_dir / ".git").resolve()
|
||||
|
||||
resolved_str = str(resolved)
|
||||
if resolved_str.startswith(str(auto_claude_dir)) or resolved_str.startswith(
|
||||
str(git_dir)
|
||||
):
|
||||
return DEFAULT_WORKTREE_PATH
|
||||
|
||||
return worktree_base_path
|
||||
@@ -6,6 +6,7 @@ Workspace Models
|
||||
Data classes and enums for workspace management.
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
@@ -227,12 +228,15 @@ class SpecNumberLock:
|
||||
|
||||
def get_next_spec_number(self) -> int:
|
||||
"""
|
||||
Scan all spec locations and return the next available spec number.
|
||||
|
||||
Must be called while lock is held.
|
||||
|
||||
Compute the next global spec number by scanning the project's specs and all worktree specs.
|
||||
|
||||
Requires the spec-numbering lock to be held; caches the computed maximum for subsequent calls.
|
||||
|
||||
Returns:
|
||||
Next available spec number (global max + 1)
|
||||
int: The next available spec number (highest existing spec number + 1).
|
||||
|
||||
Raises:
|
||||
SpecNumberLockError: If the lock has not been acquired when called.
|
||||
"""
|
||||
if not self.acquired:
|
||||
raise SpecNumberLockError(
|
||||
@@ -249,7 +253,10 @@ class SpecNumberLock:
|
||||
max_number = max(max_number, self._scan_specs_dir(main_specs_dir))
|
||||
|
||||
# 2. Scan all worktree specs
|
||||
worktrees_dir = self.project_dir / ".worktrees"
|
||||
from core.config import get_worktree_base_path
|
||||
|
||||
worktree_base_path = get_worktree_base_path(self.project_dir)
|
||||
worktrees_dir = self.project_dir / worktree_base_path
|
||||
if worktrees_dir.exists():
|
||||
for worktree in worktrees_dir.iterdir():
|
||||
if worktree.is_dir():
|
||||
@@ -272,4 +279,4 @@ class SpecNumberLock:
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return max_num
|
||||
return max_num
|
||||
@@ -53,9 +53,22 @@ class WorktreeManager:
|
||||
"""
|
||||
|
||||
def __init__(self, project_dir: Path, base_branch: str | None = None):
|
||||
"""
|
||||
Initialize the WorktreeManager for a repository, determining base branch and worktrees directory.
|
||||
|
||||
Parameters:
|
||||
project_dir (Path): Root path of the repository managed by this instance.
|
||||
base_branch (str | None): Optional explicit base branch to use; if omitted, the base branch is auto-detected.
|
||||
"""
|
||||
from core.config import get_worktree_base_path
|
||||
|
||||
self.project_dir = project_dir
|
||||
self.base_branch = base_branch or self._detect_base_branch()
|
||||
self.worktrees_dir = project_dir / ".worktrees"
|
||||
|
||||
# Use custom worktree path from environment variable with validation
|
||||
worktree_base_path = get_worktree_base_path(project_dir)
|
||||
self.worktrees_dir = project_dir / worktree_base_path
|
||||
|
||||
self._merge_lock = asyncio.Lock()
|
||||
|
||||
def _detect_base_branch(self) -> str:
|
||||
@@ -664,4 +677,4 @@ class WorktreeManager:
|
||||
|
||||
|
||||
# Keep STAGING_WORKTREE_NAME for backward compatibility in imports
|
||||
STAGING_WORKTREE_NAME = "auto-claude"
|
||||
STAGING_WORKTREE_NAME = "auto-claude"
|
||||
@@ -21,8 +21,10 @@ import { AVAILABLE_MODELS } from '../../../shared/constants';
|
||||
import type {
|
||||
Project,
|
||||
ProjectSettings as ProjectSettingsType,
|
||||
AutoBuildVersionInfo
|
||||
AutoBuildVersionInfo,
|
||||
ProjectEnvConfig
|
||||
} from '../../../shared/types';
|
||||
import { WorktreeSettings } from './WorktreeSettings';
|
||||
|
||||
interface GeneralSettingsProps {
|
||||
project: Project;
|
||||
@@ -32,8 +34,24 @@ interface GeneralSettingsProps {
|
||||
isCheckingVersion: boolean;
|
||||
isUpdating: boolean;
|
||||
handleInitialize: () => Promise<void>;
|
||||
envConfig: ProjectEnvConfig | null;
|
||||
updateEnvConfig: (updates: Partial<ProjectEnvConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render general project settings, including auto-build integration, agent configuration, worktree location, and notification toggles.
|
||||
*
|
||||
* @param project - Project data used to determine auto-build state and provide paths.
|
||||
* @param settings - Current project settings that drive form controls and toggles.
|
||||
* @param setSettings - State updater used to replace the project settings object.
|
||||
* @param versionInfo - Optional auto-build version and initialization status displayed when available.
|
||||
* @param isCheckingVersion - When true, displays a loading indicator while checking auto-build status.
|
||||
* @param isUpdating - When true, disables initialization controls and shows an initializing state.
|
||||
* @param handleInitialize - Called to initialize the auto-build integration.
|
||||
* @param envConfig - Optional environment configuration passed into worktree settings.
|
||||
* @param updateEnvConfig - Function to apply partial updates to the environment configuration.
|
||||
* @returns The rendered settings UI as JSX.
|
||||
*/
|
||||
export function GeneralSettings({
|
||||
project,
|
||||
settings,
|
||||
@@ -41,7 +59,9 @@ export function GeneralSettings({
|
||||
versionInfo,
|
||||
isCheckingVersion,
|
||||
isUpdating,
|
||||
handleInitialize
|
||||
handleInitialize,
|
||||
envConfig,
|
||||
updateEnvConfig
|
||||
}: GeneralSettingsProps) {
|
||||
const { t } = useTranslation(['settings']);
|
||||
|
||||
@@ -150,6 +170,18 @@ export function GeneralSettings({
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Worktree Location */}
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">{t('projectSections.worktree.title')}</h3>
|
||||
<WorktreeSettings
|
||||
envConfig={envConfig}
|
||||
updateEnvConfig={updateEnvConfig}
|
||||
projectPath={project.path}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Notifications */}
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">Notifications</h3>
|
||||
@@ -220,4 +252,4 @@ export function GeneralSettings({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { FolderOpen, Info } from 'lucide-react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Label } from '../ui/label';
|
||||
import { Input } from '../ui/input';
|
||||
import { Button } from '../ui/button';
|
||||
import type { ProjectEnvConfig } from '../../../shared/types';
|
||||
import { DEFAULT_WORKTREE_PATH } from '../../../shared/constants';
|
||||
|
||||
// Browser-compatible path utilities
|
||||
const isAbsolutePath = (p: string): boolean => {
|
||||
// Unix absolute path starts with /
|
||||
// Windows absolute path starts with drive letter (C:\) or UNC path (\\)
|
||||
return p.startsWith('/') || /^[a-zA-Z]:[/\\]/.test(p) || p.startsWith('\\\\');
|
||||
};
|
||||
|
||||
const joinPath = (...parts: string[]): string => {
|
||||
// Simple path join that works in browser
|
||||
return parts.join('/').replace(/\/+/g, '/');
|
||||
};
|
||||
|
||||
const getRelativePath = (from: string, to: string): string => {
|
||||
// Normalize paths
|
||||
const fromParts = from.split(/[/\\]/).filter(Boolean);
|
||||
const toParts = to.split(/[/\\]/).filter(Boolean);
|
||||
|
||||
// Find common base
|
||||
let commonLength = 0;
|
||||
const minLength = Math.min(fromParts.length, toParts.length);
|
||||
for (let i = 0; i < minLength; i++) {
|
||||
if (fromParts[i] === toParts[i]) {
|
||||
commonLength = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no common base, paths are on different roots
|
||||
if (commonLength === 0) {
|
||||
return to; // Return absolute path
|
||||
}
|
||||
|
||||
// Build relative path
|
||||
const upCount = fromParts.length - commonLength;
|
||||
const downParts = toParts.slice(commonLength);
|
||||
|
||||
if (upCount === 0 && downParts.length === 0) {
|
||||
return '.';
|
||||
}
|
||||
|
||||
const ups = Array(upCount).fill('..');
|
||||
return [...ups, ...downParts].join('/');
|
||||
};
|
||||
|
||||
interface WorktreeSettingsProps {
|
||||
envConfig: ProjectEnvConfig | null;
|
||||
updateEnvConfig: (updates: Partial<ProjectEnvConfig>) => void;
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the worktree settings UI that lets the user view and edit the project's worktree base path.
|
||||
*
|
||||
* The component displays an input for the worktree base path, a browse button that opens a directory picker,
|
||||
* and a resolved-path readout. When a directory is chosen via browse, it updates the env config with a path
|
||||
* relative to the project root if the selection is inside the project, otherwise with the absolute path.
|
||||
* The resolved path is computed as:
|
||||
* - the absolute `worktreePath` when it is an absolute path;
|
||||
* - `joinPath(projectPath, worktreePath)` when `worktreePath` is relative;
|
||||
* - `joinPath(projectPath, DEFAULT_WORKTREE_PATH)` when `worktreePath` is empty.
|
||||
*
|
||||
* @param envConfig - The current environment configuration for the project, or `null` when unset
|
||||
* @param updateEnvConfig - Callback to apply a partial update to the project's environment configuration
|
||||
* @param projectPath - Absolute filesystem path to the project root
|
||||
* @returns A React element rendering controls and information for configuring the project's worktree path
|
||||
*/
|
||||
export function WorktreeSettings({
|
||||
envConfig,
|
||||
updateEnvConfig,
|
||||
projectPath
|
||||
}: WorktreeSettingsProps) {
|
||||
const { t } = useTranslation(['settings']);
|
||||
const worktreePath = envConfig?.worktreePath || '';
|
||||
|
||||
// Resolve the actual path that will be used
|
||||
const resolvedPath = worktreePath
|
||||
? (isAbsolutePath(worktreePath)
|
||||
? worktreePath
|
||||
: joinPath(projectPath, worktreePath))
|
||||
: joinPath(projectPath, DEFAULT_WORKTREE_PATH);
|
||||
|
||||
const handleBrowse = async () => {
|
||||
const result = await window.electronAPI.dialog.showOpenDialog({
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
defaultPath: projectPath,
|
||||
title: t('settings:worktree.selectLocation')
|
||||
});
|
||||
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
const selectedPath = result.filePaths[0];
|
||||
// Convert to relative path if inside project
|
||||
const relativePath = getRelativePath(projectPath, selectedPath);
|
||||
const finalPath = relativePath.startsWith('..')
|
||||
? selectedPath // Absolute if outside project
|
||||
: relativePath; // Relative if inside project
|
||||
|
||||
updateEnvConfig({ worktreePath: finalPath });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/50 p-3">
|
||||
<Info className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>{t('settings:worktree.description')}</p>
|
||||
<p className="font-medium">
|
||||
<Trans i18nKey="settings:worktree.defaultInfo">
|
||||
Default: <code>.worktrees/</code> in your project root
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="worktreePath">{t('settings:worktree.basePathLabel')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="worktreePath"
|
||||
placeholder={t('settings:worktree.basePathPlaceholder')}
|
||||
value={worktreePath}
|
||||
onChange={(e) => updateEnvConfig({ worktreePath: e.target.value })}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleBrowse}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans i18nKey="settings:worktree.pathTypeDescription">
|
||||
Supports relative paths (e.g., <code>worktrees</code>) or absolute paths (e.g., <code>/tmp/worktrees</code>)
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-muted/50 p-3">
|
||||
<p className="text-xs font-medium text-foreground">{t('settings:worktree.resolvedPath')}</p>
|
||||
<code className="text-xs text-muted-foreground break-all">{resolvedPath}</code>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p className="font-medium">{t('settings:worktree.commonUseCases')}</p>
|
||||
<ul className="list-disc list-inside space-y-0.5 ml-2">
|
||||
<li>
|
||||
<Trans i18nKey="settings:worktree.externalDrive">
|
||||
External drive: <code>/Volumes/FastSSD/worktrees</code>
|
||||
</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans i18nKey="settings:worktree.tempDirectory">
|
||||
Temp directory: <code>/tmp/my-project-worktrees</code>
|
||||
</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans i18nKey="settings:worktree.sharedBuilds">
|
||||
Shared builds: <code>../shared-worktrees</code>
|
||||
</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -46,8 +46,9 @@ interface SectionRouterProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes to the appropriate settings section based on activeSection.
|
||||
* Handles initialization guards and section-specific configurations.
|
||||
* Selects and renders the settings subsection corresponding to `activeSection`.
|
||||
*
|
||||
* @returns The React element for the selected settings subsection, or `null` if `activeSection` does not match a known section.
|
||||
*/
|
||||
export function SectionRouter({
|
||||
activeSection,
|
||||
@@ -100,6 +101,8 @@ export function SectionRouter({
|
||||
isCheckingVersion={isCheckingVersion}
|
||||
isUpdating={isUpdating}
|
||||
handleInitialize={handleInitialize}
|
||||
envConfig={envConfig}
|
||||
updateEnvConfig={updateEnvConfig}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
@@ -232,4 +235,4 @@ export function SectionRouter({
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user