Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions docs/adr/2026-11-09-explicit-git-initialization.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions docs/config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2001,7 +2001,7 @@ async function runCLI(options: InternalCLIOptions): Promise<void> {
}

// 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(
Expand Down
59 changes: 5 additions & 54 deletions src/startup/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CheckResult[]> | undefined;

function getCurrentBunVersion(): string | undefined {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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)
Expand All @@ -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 };
}

Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -433,7 +390,7 @@ export async function runStartupChecks(workspaceRoot: string): Promise<StartupCh
writable: workspaceCheck.writable,
isGitRepo: gitCheck.isGitRepo,
branch: gitCheck.branch,
initialized: gitCheck.initialized,
initialized: false,
error: workspaceCheck.error
},
allRequiredMet,
Expand All @@ -448,12 +405,6 @@ export function printStartupCheckResults(results: StartupCheckResults, verbose =
const missingRequired = results.tools.filter(t => 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;
Expand Down
71 changes: 31 additions & 40 deletions tests/startupGitInit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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);
Expand All @@ -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 () => {
Expand Down
6 changes: 4 additions & 2 deletions tests/tuistory/built-cli-commands.tuistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,7 @@ describe('interactive built CLI Tuistory tests: steering, caret, slash commands,
expect(rawOutput.lastIndexOf('\u001b[<u')).toBeGreaterThan(rawOutput.indexOf('\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,
Expand All @@ -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);
});
Expand Down
Loading