diff --git a/.github/workflows/frontend-nightly.yml b/.github/workflows/frontend-nightly.yml index 7ad149c8f..35ec830fe 100644 --- a/.github/workflows/frontend-nightly.yml +++ b/.github/workflows/frontend-nightly.yml @@ -11,7 +11,7 @@ on: workflow_dispatch: inputs: important: - description: 'Mark this nightly as an important update (escalates the in-app restart prompt). Retro-flag an already-published nightly by re-running only the publish-feed job with this input set.' + description: "Mark this nightly as an important update (escalates the in-app restart prompt). Retro-flag an already-published nightly by re-running only the publish-feed job with this input set." type: boolean default: false diff --git a/backend/internal/service/project/service.go b/backend/internal/service/project/service.go index 041e75060..5a5c8d43e 100644 --- a/backend/internal/service/project/service.go +++ b/backend/internal/service/project/service.go @@ -2,6 +2,7 @@ package project import ( "context" + "errors" "os" "path/filepath" "regexp" @@ -252,6 +253,13 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { return m.projectFromRow(row), nil } +type repositorySetupTarget int + +const ( + repositorySetupPlainFolder repositorySetupTarget = iota + repositorySetupUnbornRepo +) + // InitializeRepository prepares a selected folder for project registration by ensuring it has an initial Git commit. func (m *Service) InitializeRepository(ctx context.Context, in InitializeRepositoryInput) (InitializeRepositoryResult, error) { path, err := normalizePath(in.Path) @@ -265,19 +273,63 @@ func (m *Service) InitializeRepository(ctx context.Context, in InitializeReposit m.addMu.Lock() defer m.addMu.Unlock() - if !isGitRepo(path) { + target, err := classifyRepositorySetupTarget(ctx, path) + if err != nil { + return InitializeRepositoryResult{}, err + } + + if target == repositorySetupPlainFolder { if _, err := gitOutput(ctx, path, "init", "-b", domain.DefaultBranchName); err != nil { return InitializeRepositoryResult{}, apierr.Invalid("GIT_INIT_FAILED", "Could not initialize a Git repository in this folder.", map[string]any{"error": err.Error()}) } - } else if repoHasCommit(ctx, path) { - return InitializeRepositoryResult{}, apierr.Conflict("PROJECT_ALREADY_INITIALIZED", "This repository already has commits.", map[string]any{"path": path}) } + if _, err := gitOutput(ctx, path, "add", "-A"); err != nil { + return InitializeRepositoryResult{}, apierr.Invalid("GIT_ADD_FAILED", "Could not stage files for the initial commit.", map[string]any{"error": err.Error()}) + } if _, err := gitOutput(ctx, path, "-c", "user.name=Agent Orchestrator", "-c", "user.email=ao@example.com", "commit", "--allow-empty", "-m", "initial commit"); err != nil { return InitializeRepositoryResult{}, apierr.Invalid("INITIAL_COMMIT_FAILED", "Could not create the initial commit.", map[string]any{"error": err.Error()}) } return InitializeRepositoryResult{Path: path}, nil } + +func classifyRepositorySetupTarget(ctx context.Context, path string) (repositorySetupTarget, error) { + if isBareGitRepository(ctx, path) { + return repositorySetupPlainFolder, apierr.Invalid("PROJECT_BARE_REPOSITORY", "Selected folder must be a non-bare Git repository or a plain folder.", map[string]any{ + "path": path, + "suggestedFix": "Use a normal checkout, or select a plain folder for AO to initialize.", + }) + } + + if isGitRepo(path) { + if repoHasCommit(ctx, path) { + return repositorySetupUnbornRepo, apierr.Conflict("PROJECT_ALREADY_INITIALIZED", "This repository already has commits.", map[string]any{"path": path}) + } + return repositorySetupUnbornRepo, nil + } + + if top, err := gitOutput(ctx, path, "rev-parse", "--show-toplevel"); err == nil { + root := normalizeGitReportedPath(path, strings.TrimSpace(top)) + selected := comparablePath(path) + if !samePath(root, selected) { + return repositorySetupPlainFolder, apierr.Invalid("PROJECT_PATH_NOT_REPO_ROOT", "Selected folder is inside a Git repository. Select the repository root instead.", map[string]any{ + "path": path, + "repoRoot": root, + "suggestedFix": "Select the repository root folder, then try again.", + }) + } + return repositorySetupPlainFolder, apierr.Invalid("UNSUPPORTED_GIT_REPO", "Selected folder contains an unsupported Git repository layout.", map[string]any{"path": path}) + } + + if hasGitMetadata(path) { + return repositorySetupPlainFolder, apierr.Invalid("UNSUPPORTED_GIT_REPO", "Selected folder contains Git metadata that AO could not inspect.", map[string]any{ + "path": path, + "suggestedFix": "Repair the Git repository or select a plain folder.", + }) + } + + return repositorySetupPlainFolder, nil +} func (m *Service) activeProjectCount(ctx context.Context) (int, error) { projects, err := m.store.ListProjects(ctx) if err != nil { @@ -483,6 +535,42 @@ func repoHasCommit(ctx context.Context, path string) bool { _, err := gitOutput(ctx, path, "rev-parse", "--verify", "HEAD") return err == nil } + +func isBareGitRepository(ctx context.Context, path string) bool { + out, err := gitOutput(ctx, path, "rev-parse", "--is-bare-repository") + return err == nil && strings.TrimSpace(out) == "true" +} + +func hasGitMetadata(path string) bool { + _, err := os.Lstat(filepath.Join(path, ".git")) + return err == nil || !errors.Is(err, os.ErrNotExist) +} + +func normalizeGitReportedPath(base, reported string) string { + if reported == "" { + return comparablePath(reported) + } + if !filepath.IsAbs(reported) { + reported = filepath.Join(base, reported) + } + return comparablePath(reported) +} + +func comparablePath(path string) string { + clean := filepath.Clean(path) + if resolved, err := filepath.EvalSymlinks(clean); err == nil { + clean = resolved + } + return filepath.Clean(clean) +} + +func samePath(a, b string) bool { + if strings.EqualFold(a, b) { + return true + } + return a == b +} + func isGitRepo(path string) bool { cmd := aoprocess.Command("git", "-C", path, "rev-parse", "--show-toplevel") out, err := cmd.Output() diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go index 8b1cf6e10..c8cbd5ee0 100644 --- a/backend/internal/service/project/service_test.go +++ b/backend/internal/service/project/service_test.go @@ -513,6 +513,13 @@ func TestManager_InitializeRepositoryRecovery(t *testing.T) { if _, err := exec.Command("git", "-C", dir, "rev-parse", "--verify", "HEAD").CombinedOutput(); err != nil { t.Fatalf("expected initial commit: %v", err) } + out, err := exec.Command("git", "-C", dir, "show", "HEAD:notes.txt").CombinedOutput() + if err != nil { + t.Fatalf("expected existing file in initial commit: %v (%s)", err, out) + } + if got := string(out); got != "keep me\n" { + t.Fatalf("HEAD:notes.txt = %q, want %q", got, "keep me\n") + } if _, err := m.Add(ctx, project.AddInput{Path: dir, ProjectID: ptr("plain")}); err != nil { t.Fatalf("Add after init: %v", err) } @@ -523,18 +530,59 @@ func TestManager_InitializeRepositoryRecovery(t *testing.T) { if out, err := exec.Command("git", "init", "-b", "main", dir).CombinedOutput(); err != nil { t.Fatalf("git init: %v (%s)", err, out) } + if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("unborn file\n"), 0o644); err != nil { + t.Fatal(err) + } if _, err := m.InitializeRepository(ctx, project.InitializeRepositoryInput{Path: dir}); err != nil { t.Fatalf("InitializeRepository unborn: %v", err) } if _, err := exec.Command("git", "-C", dir, "rev-parse", "--verify", "HEAD").CombinedOutput(); err != nil { t.Fatalf("expected initial commit: %v", err) } + out, err := exec.Command("git", "-C", dir, "show", "HEAD:notes.txt").CombinedOutput() + if err != nil { + t.Fatalf("expected unborn repo file in initial commit: %v (%s)", err, out) + } + if got := string(out); got != "unborn file\n" { + t.Fatalf("HEAD:notes.txt = %q, want %q", got, "unborn file\n") + } }) t.Run("already committed repo", func(t *testing.T) { _, err := m.InitializeRepository(ctx, project.InitializeRepositoryInput{Path: gitRepo(t)}) wantCode(t, err, "PROJECT_ALREADY_INITIALIZED") }) + + t.Run("repo subdirectory is rejected", func(t *testing.T) { + repo := gitRepo(t) + subdir := filepath.Join(repo, "nested") + if err := os.Mkdir(subdir, 0o755); err != nil { + t.Fatal(err) + } + _, err := m.InitializeRepository(ctx, project.InitializeRepositoryInput{Path: subdir}) + wantCode(t, err, "PROJECT_PATH_NOT_REPO_ROOT") + if _, statErr := os.Stat(filepath.Join(subdir, ".git")); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("unexpected nested .git after rejected init: %v", statErr) + } + }) + + t.Run("bare repo is rejected", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "bare.git") + if out, err := exec.Command("git", "init", "--bare", dir).CombinedOutput(); err != nil { + t.Fatalf("git init --bare: %v (%s)", err, out) + } + _, err := m.InitializeRepository(ctx, project.InitializeRepositoryInput{Path: dir}) + wantCode(t, err, "PROJECT_BARE_REPOSITORY") + }) + + t.Run("unsupported git metadata is rejected", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: missing\n"), 0o644); err != nil { + t.Fatal(err) + } + _, err := m.InitializeRepository(ctx, project.InitializeRepositoryInput{Path: dir}) + wantCode(t, err, "UNSUPPORTED_GIT_REPO") + }) } func TestManager_AddValidationAndConflicts(t *testing.T) { ctx := context.Background() diff --git a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx index 6691b724a..4f9db7ab8 100644 --- a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx +++ b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx @@ -20,36 +20,28 @@ export type CreateProjectAgentSelection = { trackerIntake?: TrackerIntakeConfig; }; -type RecoveryCode = "NOT_A_GIT_REPO" | "PROJECT_UNBORN"; - const EMPTY_INTAKE: IntakeForm = { enabled: false, repo: "", assignee: "" }; type CreateProjectAgentSheetProps = { error?: string | null; isCreating: boolean; isInitializing?: boolean; - onInitialize?: (selection: CreateProjectAgentSelection) => Promise; onOpenChange: (open: boolean) => void; onSubmit: (selection: CreateProjectAgentSelection) => Promise; open: boolean; path: string | null; - recoveryCode?: RecoveryCode | null; - recoveryError?: string | null; }; -const RECOVERY_SETUP_MESSAGE = "Let Agent Orchestrator set up this repository so agents can start?"; +const SETUP_NOTE = "If this folder needs Git setup, AO will initialize it and create the first commit before starting."; export function CreateProjectAgentSheet({ error, isCreating, isInitializing = false, - onInitialize, onOpenChange, onSubmit, open, path, - recoveryCode, - recoveryError, }: CreateProjectAgentSheetProps) { const queryClient = useQueryClient(); const agentsQuery = useQuery({ @@ -73,11 +65,9 @@ export function CreateProjectAgentSheet({ const [workerAgent, setWorkerAgent] = useState(""); const [orchestratorAgent, setOrchestratorAgent] = useState(""); const isBusy = isCreating || isInitializing; - const hasRecovery = Boolean(recoveryCode); const [intake, setIntake] = useState(EMPTY_INTAKE); const intakeIncomplete = intakeNeedsRule(intake); const canSubmit = workerAgent !== "" && orchestratorAgent !== "" && !intakeIncomplete && !isBusy && !isLoadingAgents; - const canInitialize = Boolean(canSubmit && recoveryCode && onInitialize); useEffect(() => { if (!open) { @@ -115,12 +105,6 @@ export function CreateProjectAgentSheet({ aria-busy={isBusy || undefined} onSubmit={(event) => { event.preventDefault(); - if (hasRecovery) { - if (canInitialize) { - void onInitialize?.({ workerAgent, orchestratorAgent, trackerIntake: buildIntake(intake) }); - } - return; - } if (!canSubmit) return; void onSubmit({ workerAgent, orchestratorAgent, trackerIntake: buildIntake(intake) }); }} @@ -189,16 +173,11 @@ export function CreateProjectAgentSheet({ setIntake((f) => ({ ...f, ...patch }))} compact /> - {hasRecovery ? ( -
-

{RECOVERY_SETUP_MESSAGE}

- {recoveryError ? ( -
- Setup failed: {recoveryError} -
- ) : null} -
- ) : error ? ( +
+ {SETUP_NOTE} +
+ + {error ? (
{error}
@@ -208,20 +187,9 @@ export function CreateProjectAgentSheet({ - {!hasRecovery ? ( - - ) : ( - - )} + diff --git a/frontend/src/renderer/components/OrchestratorReplacementDialog.tsx b/frontend/src/renderer/components/OrchestratorReplacementDialog.tsx index c9f3817e6..8c342a789 100644 --- a/frontend/src/renderer/components/OrchestratorReplacementDialog.tsx +++ b/frontend/src/renderer/components/OrchestratorReplacementDialog.tsx @@ -41,13 +41,18 @@ export function OrchestratorReplacementDialog({