diff --git a/CHANGELOG.md b/CHANGELOG.md index 304515f59..cd85338da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixed + +- Start chat and file work in ordinary folders without initializing Git or creating an ignore file. Existing repositories still show their branch; initialize Git only when you choose to use it. + ## 0.9.10 - 2026-11-04 ### Added diff --git a/docs/adr/2026-11-09-explicit-git-initialization.md b/docs/adr/2026-11-09-explicit-git-initialization.md new file mode 100644 index 000000000..fc3deb66a --- /dev/null +++ b/docs/adr/2026-11-09-explicit-git-initialization.md @@ -0,0 +1,57 @@ +# Startup observes Git; repository creation is explicit + +## Context + +Squad reported failed Git initialization when a conversation used an empty +default folder. Not every workspace is a source repository. Startup checks +considered hidden files insignificant and ran `git init` for an otherwise empty +directory, also creating a macOS `.gitignore`. That happened before the composer on normal terminal/direct-CLI startup. +The first cross-repository diagnosis overgeneralized this to RPC: current +protocol launches route before these checks, so the branch alone is not evidence +that RPC startup initializes Git. Disabling all startup with bare mode would also +disable unrelated instructions, hooks and integrations. + +The previous tests explicitly expected automatic initialization. Updated tests +failed in five cases before the source change: empty and hidden-file-only +folders, repeated startup, Git being optional, and a folder containing only an +existing ignore file. + +## Decision + +Remove the empty-directory initialization branch. Startup still checks access, +detects existing Git metadata and reads branches; it never creates `.git` or +`.gitignore`. Git is optional for general chat and file work. Git operations +still require the executable and an explicitly chosen repository. Keep the +legacy `workspace.initialized` result field as false for callers that read it. + +Repository creation is a separate explicit user request, through Git itself or +the agent's normal tool approval path. No new startup flag or blanket bare-mode +override is introduced. + +## Evidence + +The updated startup suite and branding suite pass together (26 tests). The +built CLI Tuistory case reaches the actual composer in an empty non-Git folder, +confirms no `.git` or `.gitignore` was created, and exits interactively. This +test does not call a live model provider. Build succeeds. Aggregate lint/proof +results are recorded with the change's validation receipt. + +The complete `bun run proof` ran on Node 26.7.0: lint/type checking and the +unit stage passed (963 files, 13,779 tests; 6 files and 80 tests skipped). +The terminal stage finished with 106 files passing and three failures among +536 tests: mouse/scroll exit, slash-command browsing and Console connectors. +A focused retry passed the first two (27 tests across those files); the +connector case still timed out waiting for tools/list. The same connector +test also fails at that assertion in an unmodified worktree at base commit +9d10d59d51442bd60ca35ee04234da63ae83370e. This establishes that connector +failure separately from the startup patch; it does not make aggregate proof +green. CI uses its own pinned runtime and remains an independent check. + +## Consequences + +New empty folders no longer acquire version control automatically. People who +want Git must explicitly initialize it. Existing repositories keep their branch +display; non-Git folders keep file work and chat. Features that require Git may +report that a repository is needed when invoked. This does not prohibit Git +tools or erase existing repositories, and it does not make unrelated runtime +startup read-only: the ordinary workspace/session infrastructure still runs. diff --git a/docs/config-reference.md b/docs/config-reference.md index 4f0a679a5..128183b9a 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -174,6 +174,15 @@ The flag skips trace-daemon reconciliation, settings sync, pings, account pollin `/new` archives the current session and starts a new session ID with fresh conversation and accounting state. It does not wait for an inference or memory-extraction call. +## Project folders and Git + +A workspace can be an ordinary folder. Starting Autohand does not initialize +Git or create `.gitignore`, including in empty folders. Existing repositories +are detected for branch information. Git is optional for chat and file work; +Git operations require Git to be installed and a repository chosen by you. +Initialize a repository only when you want version control, for example by +explicitly asking the agent to run `git init` or running it yourself. + ## Bare Mode Bare mode starts Autohand with only explicitly requested context and runtime integrations. Enable it with either: diff --git a/src/index.ts b/src/index.ts index 764141cda..f741e3339 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2001,7 +2001,7 @@ async function runCLI(options: InternalCLIOptions): Promise { } // Run startup checks synchronously before prompt to prevent output racing. - // git init, tool checks etc. must finish printing BEFORE the prompt renders. + // Read-only repository and tool checks finish before the prompt renders. if (!options.bare) { try { const checkResults = await awaitCliLifecycleStep( diff --git a/src/startup/checks.ts b/src/startup/checks.ts index 316e45249..645293852 100644 --- a/src/startup/checks.ts +++ b/src/startup/checks.ts @@ -13,7 +13,6 @@ import fs from 'fs-extra'; import { resolveRipgrepCommand } from '../utils/ripgrep.js'; const GIT_COMMAND_TIMEOUT_MS = 5_000; -const GIT_INIT_TIMEOUT_MS = 15_000; let toolCheckResultsPromise: Promise | undefined; function getCurrentBunVersion(): string | undefined { @@ -51,8 +50,8 @@ const REQUIRED_TOOLS: ToolCheck[] = [ name: 'git', command: 'git', versionFlag: '--version', - required: true, - description: 'Version control (required for file tracking, undo, and git operations)', + required: false, + description: 'Version control (optional; needed for Git operations)', installHints: { darwin: 'brew install git', linux: 'sudo apt install git # or: sudo dnf install git', @@ -245,20 +244,6 @@ export async function validateWorkspacePath( } } -/** - * Check if a directory is empty (no significant files) - * Hidden files like .DS_Store are ignored, but .git counts as significant - */ -function isEmptyDirectory(dir: string): boolean { - try { - const entries = fs.readdirSync(dir); - const significantEntries = entries.filter(e => !e.startsWith('.') || e === '.git'); - return significantEntries.length === 0; - } catch { - return false; - } -} - /** * Run a git command and return trimmed stdout, or undefined on failure */ @@ -314,7 +299,7 @@ function readGitHeadBranch(workspaceRoot: string): string | undefined { /** * Check if inside a git repository - * If directory is empty and not a git repo, auto-initialize git + * Repository detection never initializes or edits the workspace. */ async function checkGitRepo(workspaceRoot: string): Promise<{ isGitRepo: boolean; branch?: string; initialized?: boolean }> { // First check if .git directory exists (works even with no commits) @@ -333,34 +318,6 @@ async function checkGitRepo(workspaceRoot: string): Promise<{ isGitRepo: boolean }; } - // Not a git repo - check if empty and auto-init - if (isEmptyDirectory(workspaceRoot)) { - const initResult = await runGitCommand(['init'], workspaceRoot, GIT_INIT_TIMEOUT_MS); - - if (initResult !== undefined || fs.existsSync(`${workspaceRoot}/.git/HEAD`)) { - // On macOS, create .gitignore with .DS_Store - if (os.platform() === 'darwin') { - try { - const gitignorePath = `${workspaceRoot}/.gitignore`; - if (!fs.existsSync(gitignorePath)) { - fs.writeFileSync(gitignorePath, '.DS_Store\n'); - } - } catch { - // Ignore errors creating .gitignore - } - } - - // Get the default branch name - const branch = await getGitBranch(workspaceRoot) || 'main'; - - return { - isGitRepo: true, - branch, - initialized: true - }; - } - } - return { isGitRepo: false }; } @@ -389,7 +346,7 @@ export interface StartupCheckResults { writable: boolean; isGitRepo: boolean; branch?: string; - /** True if git was auto-initialized for an empty directory */ + /** Legacy result field; startup no longer initializes repositories. */ initialized?: boolean; error?: string; }; @@ -433,7 +390,7 @@ export async function runStartupChecks(workspaceRoot: string): Promise t.required && !t.installed); const missingOptional = results.tools.filter(t => !t.required && !t.installed); - // Show git initialization message if it happened - if (results.workspace.initialized) { - console.log(chalk.green('✓ Initialized git repository')); - console.log(); - } - // Only print tool issues if there are problems or verbose mode if (missingRequired.length === 0 && !verbose) { return; diff --git a/tests/startupGitInit.spec.ts b/tests/startupGitInit.spec.ts index 3ecf684d2..04ef0113c 100644 --- a/tests/startupGitInit.spec.ts +++ b/tests/startupGitInit.spec.ts @@ -7,9 +7,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'fs-extra'; import path from 'path'; import os from 'os'; +import { execFileSync } from 'node:child_process'; import { runStartupChecks, validateWorkspacePath } from '../src/startup/checks.js'; -describe('Git Auto-Init for Empty Directories', () => { +describe('Optional Git at startup', () => { let tempDir: string; beforeEach(async () => { @@ -27,25 +28,25 @@ describe('Git Auto-Init for Empty Directories', () => { } }); - describe('isEmptyDirectory detection', () => { - it('treats directory with no files as empty', async () => { + describe('read-only repository detection', () => { + it('starts an empty workspace without initializing Git', async () => { // tempDir is empty by default const result = await runStartupChecks(tempDir); - // Should have auto-initialized git - expect(result.workspace.isGitRepo).toBe(true); - expect(result.workspace.initialized).toBe(true); + expect(result.workspace.isGitRepo).toBe(false); + expect(result.workspace.initialized).toBeFalsy(); + expect(await fs.readdir(tempDir)).toEqual([]); }); - it('treats directory with only hidden files (like .DS_Store) as empty', async () => { + it('preserves hidden files without creating a repository', async () => { // Add a hidden file that should be ignored await fs.writeFile(path.join(tempDir, '.DS_Store'), 'test'); const result = await runStartupChecks(tempDir); - // Should still auto-initialize git - expect(result.workspace.isGitRepo).toBe(true); - expect(result.workspace.initialized).toBe(true); + expect(result.workspace.isGitRepo).toBe(false); + expect(result.workspace.initialized).toBeFalsy(); + expect(await fs.readdir(tempDir)).toEqual(['.DS_Store']); }); it('does NOT treat directory with .git as empty', async () => { @@ -95,44 +96,35 @@ describe('Git Auto-Init for Empty Directories', () => { }); }); - describe('git initialization behavior', () => { - it('creates .git directory when initializing', async () => { - const result = await runStartupChecks(tempDir); - - expect(result.workspace.initialized).toBe(true); - expect(await fs.pathExists(path.join(tempDir, '.git'))).toBe(true); + describe('Git is an explicit project choice', () => { + it('does not create a Git directory or ignore file after repeated startup checks', async () => { + await runStartupChecks(tempDir); + await runStartupChecks(tempDir); + expect(await fs.pathExists(path.join(tempDir, '.git'))).toBe(false); + expect(await fs.pathExists(path.join(tempDir, '.gitignore'))).toBe(false); }); - it('sets branch name after initialization', async () => { + it('keeps a non-Git project writable and Git tooling optional', async () => { const result = await runStartupChecks(tempDir); - - expect(result.workspace.initialized).toBe(true); - expect(result.workspace.branch).toBeDefined(); - // Branch should be 'main' or 'master' depending on git config - expect(['main', 'master']).toContain(result.workspace.branch); + expect(result.workspace.writable).toBe(true); + expect(result.workspace.isGitRepo).toBe(false); + expect(result.workspace.branch).toBeUndefined(); + expect(result.tools.find(tool => tool.name === 'git')?.required).toBe(false); }); - it('creates .gitignore with .DS_Store on macOS', async () => { - const result = await runStartupChecks(tempDir); - - expect(result.workspace.initialized).toBe(true); - - // Only check for .gitignore on macOS - if (process.platform === 'darwin') { - const gitignorePath = path.join(tempDir, '.gitignore'); - expect(await fs.pathExists(gitignorePath)).toBe(true); - - const content = await fs.readFile(gitignorePath, 'utf8'); - expect(content).toContain('.DS_Store'); - } + it('preserves an existing ignore file', async () => { + await fs.writeFile(path.join(tempDir, '.gitignore'), 'private-files/\n'); + await runStartupChecks(tempDir); + expect(await fs.readFile(path.join(tempDir, '.gitignore'), 'utf8')).toBe('private-files/\n'); + expect(await fs.pathExists(path.join(tempDir, '.git'))).toBe(false); }); }); describe('existing git repo detection', () => { it('detects existing git repo and does NOT re-initialize', async () => { - // First call initializes git + execFileSync('git', ['init', '-b', 'chosen-branch'], { cwd: tempDir, stdio: 'ignore' }); const firstResult = await runStartupChecks(tempDir); - expect(firstResult.workspace.initialized).toBe(true); + expect(firstResult.workspace.initialized).toBeFalsy(); // Second call should detect existing repo, not initialize again const secondResult = await runStartupChecks(tempDir); @@ -141,12 +133,11 @@ describe('Git Auto-Init for Empty Directories', () => { }); it('detects branch in existing repo', async () => { - // Initialize git - await runStartupChecks(tempDir); + execFileSync('git', ['init', '-b', 'chosen-branch'], { cwd: tempDir, stdio: 'ignore' }); // Run again and check branch detection const result = await runStartupChecks(tempDir); - expect(result.workspace.branch).toBeDefined(); + expect(result.workspace.branch).toBe('chosen-branch'); }); it('detects branch directly from git HEAD when git commands are unavailable', async () => { diff --git a/tests/tuistory/built-cli-commands.tuistory.test.ts b/tests/tuistory/built-cli-commands.tuistory.test.ts index cc0596ec6..70a4ce48f 100644 --- a/tests/tuistory/built-cli-commands.tuistory.test.ts +++ b/tests/tuistory/built-cli-commands.tuistory.test.ts @@ -482,7 +482,7 @@ describe('interactive built CLI Tuistory tests: steering, caret, slash commands, expect(rawOutput.lastIndexOf('\u001b[1u')); }); - it('auto-initializes git for an empty workspace before rendering the composer', async () => { + it('starts an empty non-Git workspace without initializing Git', async () => { const state = await createTempAutohandHome({ initializeGit: false, writePackageJson: false, @@ -498,7 +498,9 @@ describe('interactive built CLI Tuistory tests: steering, caret, slash commands, await waitForComposer(session); - expect(await fs.pathExists(path.join(state.workspaceRoot, '.git'))).toBe(true); + expect(await fs.pathExists(path.join(state.workspaceRoot, '.git'))).toBe(false); + expect(await fs.pathExists(path.join(state.workspaceRoot, '.gitignore'))).toBe(false); + expect(session.readAll()).not.toContain('Initialized git repository'); await exitInteractive(session); });