Adds FileStatEntry type and two new primitives to the BranchManager port and SimpleGitBranchManager adapter, enabling split diff procedures in the tRPC layer without returning raw multi-megabyte diffs. - FileStatEntry captures path, status, additions/deletions, oldPath (renames), and optional projectId for multi-project routing - diffBranchesStat uses --name-status + --numstat, detects binary files (shown as - / - in numstat), handles spaces in filenames - diffFileSingle returns raw unified diff for a single file path
163 lines
6.6 KiB
Markdown
163 lines
6.6 KiB
Markdown
# Git, Process, and Logging Modules
|
|
|
|
Three infrastructure modules supporting agent execution.
|
|
|
|
## Git Module (`apps/server/git/`)
|
|
|
|
Manages git worktrees for isolated agent workspaces.
|
|
|
|
### Architecture
|
|
- **Port**: `WorktreeManager` interface
|
|
- **Adapter**: `SimpleGitWorktreeManager` using simple-git library
|
|
|
|
### WorktreeManager Methods
|
|
|
|
| Method | Purpose |
|
|
|--------|---------|
|
|
| `create(id, branch, baseBranch?)` | Create worktree with new branch (default base: 'main') |
|
|
| `remove(id)` | Clean up worktree directory |
|
|
| `list()` | All worktrees including main |
|
|
| `get(id)` | Specific worktree by ID |
|
|
| `diff(id)` | Changed files vs HEAD |
|
|
| `merge(id, targetBranch)` | Merge worktree branch into target |
|
|
|
|
### Worktree Storage
|
|
Worktrees stored in `.cw-worktrees/` subdirectory of the repo. Each agent gets a worktree at `.cw-worktrees/agent/<alias>/`.
|
|
|
|
### Merge Flow
|
|
1. Check out target branch
|
|
2. `git merge <source> --no-edit`
|
|
3. On success: emit `worktree:merged`
|
|
4. On conflict: `git merge --abort`, emit `worktree:conflict` with conflicting file list
|
|
5. Restore original branch
|
|
|
|
### BranchManager (`apps/server/git/branch-manager.ts`)
|
|
- **Port**: `BranchManager` interface
|
|
- **Adapter**: `SimpleGitBranchManager` using simple-git
|
|
|
|
| Method | Purpose |
|
|
|--------|---------|
|
|
| `ensureBranch(repoPath, branch, baseBranch)` | Create branch from base if it doesn't exist (idempotent) |
|
|
| `mergeBranch(repoPath, source, target)` | Merge via ephemeral worktree, returns conflict info |
|
|
| `diffBranches(repoPath, base, head)` | Three-dot diff between branches |
|
|
| `diffBranchesStat(repoPath, base, head)` | Per-file metadata (path, status, additions, deletions) — no hunk content. Binary files included with `status: 'binary'` and counts of 0. Returns `FileStatEntry[]`. |
|
|
| `diffFileSingle(repoPath, base, head, filePath)` | Raw unified diff for a single file (three-dot diff). `filePath` must be URL-decoded. Returns empty string for binary files. |
|
|
| `deleteBranch(repoPath, branch)` | Delete local branch (no-op if missing) |
|
|
| `branchExists(repoPath, branch)` | Check local branches |
|
|
| `remoteBranchExists(repoPath, branch)` | Check remote tracking branches (`origin/<branch>`) |
|
|
| `listCommits(repoPath, base, head)` | List commits head has that base doesn't (with stats) |
|
|
| `diffCommit(repoPath, commitHash)` | Get unified diff for a single commit |
|
|
| `getMergeBase(repoPath, branch1, branch2)` | Get common ancestor commit hash |
|
|
| `pushBranch(repoPath, branch, remote?)` | Push branch to remote (default: 'origin') |
|
|
| `checkMergeability(repoPath, source, target)` | Dry-run merge check via `git merge-tree --write-tree` (git 2.38+). Returns `{ mergeable, conflicts? }` with no side effects |
|
|
|
|
`remoteBranchExists` is used by `registerProject` and `updateProject` to validate that a project's default branch actually exists in the cloned repository before saving.
|
|
|
|
### Project Clones
|
|
- `cloneProject(url, destPath)` — Simple git clone wrapper
|
|
- `ensureProjectClone(project, workspaceRoot)` — Idempotent: checks if clone exists, clones if not
|
|
- `getProjectCloneDir(name, id)` — Canonical path: `repos/<sanitized-name>-<id>/`
|
|
|
|
### ProjectSyncManager (`apps/server/git/remote-sync.ts`)
|
|
|
|
Fetches remote changes for project clones and fast-forwards the local `defaultBranch`. Safe to run with active worktrees — only updates remote-tracking refs and the base branch (never checked out directly by any worktree).
|
|
|
|
**Constructor**: `ProjectSyncManager(projectRepository, workspaceRoot, eventBus?)`
|
|
|
|
| Method | Purpose |
|
|
|--------|---------|
|
|
| `syncProject(projectId)` | `git fetch origin` + `git merge --ff-only origin/<defaultBranch>`, updates `lastFetchedAt` |
|
|
| `syncAllProjects()` | Sync all registered projects sequentially |
|
|
| `getSyncStatus(projectId)` | Returns `{ ahead, behind, lastFetchedAt }` via `rev-list --left-right --count` |
|
|
| `getInitiativeDivergence(projectId, branch)` | Ahead/behind between `defaultBranch` and an initiative branch |
|
|
|
|
**Sync flow during phase dispatch**: `dispatchNextPhase()` syncs all linked project clones *before* creating initiative/phase branches, so branches fork from up-to-date remote state. Sync is best-effort — failures are logged but don't block dispatch.
|
|
|
|
**CLI**: `cw project sync [name] --all`, `cw project status [name]`
|
|
|
|
**tRPC**: `syncProject`, `syncAllProjects`, `getProjectSyncStatus`
|
|
|
|
### Events Emitted
|
|
`worktree:created`, `worktree:removed`, `worktree:merged`, `worktree:conflict`, `project:synced`, `project:sync_failed`
|
|
|
|
---
|
|
|
|
## Process Module (`apps/server/process/`)
|
|
|
|
Spawns, tracks, and controls child processes.
|
|
|
|
### Classes
|
|
|
|
**ProcessRegistry** — In-memory metadata store:
|
|
- `register(info)`, `unregister(id)`, `get(id)`, `getAll()`, `getByPid(pid)`, `updateStatus(id, status)`
|
|
|
|
**ProcessManager** — Lifecycle management:
|
|
|
|
| Method | Purpose |
|
|
|--------|---------|
|
|
| `spawn(options)` | Spawn detached process (survives parent exit) |
|
|
| `stop(id)` | SIGTERM → wait 5s → SIGKILL |
|
|
| `stopAll()` | Stop all running processes in parallel |
|
|
| `restart(id)` | Stop + re-spawn with same options |
|
|
| `isRunning(id)` | Check with `process.kill(pid, 0)` |
|
|
|
|
### Spawn Details
|
|
- Uses `execa` with `detached: true`, `stdio: 'ignore'`
|
|
- Calls `subprocess.unref()` so parent can exit
|
|
- Exit handler updates registry and emits events
|
|
|
|
### Events Emitted
|
|
`process:spawned`, `process:stopped`, `process:crashed`
|
|
|
|
---
|
|
|
|
## Logger Module (`apps/server/logger/`)
|
|
|
|
Structured logging via **pino**.
|
|
|
|
### Usage
|
|
```typescript
|
|
import { createModuleLogger } from '../logger/index.js';
|
|
const log = createModuleLogger('my-module');
|
|
log.info({ key: 'value' }, 'message');
|
|
```
|
|
|
|
### Configuration
|
|
| Env Var | Effect |
|
|
|---------|--------|
|
|
| `CW_LOG_LEVEL` | Override log level |
|
|
| `CW_LOG_PRETTY` | Set to `'1'` for human-readable output |
|
|
| `NODE_ENV=development` | Default to 'debug' level |
|
|
|
|
### Output
|
|
- Default: JSON to stderr (fd 2)
|
|
- Pretty mode: pino-pretty to stdout with colors and timestamps
|
|
|
|
---
|
|
|
|
## Logging Module (`apps/server/logging/`)
|
|
|
|
File-based per-process output capture (separate from pino).
|
|
|
|
### Classes
|
|
|
|
**LogManager** — Directory management:
|
|
- Base dir: `~/.cw/logs/`
|
|
- Structure: `{processId}/stdout.log`, `{processId}/stderr.log`
|
|
- `cleanOldLogs(retainDays)` — removes old directories by mtime
|
|
|
|
**ProcessLogWriter** — File I/O with timestamps:
|
|
- `open()` — create directories and append-mode WriteStreams
|
|
- `writeStdout(data)` / `writeStderr(data)` — prefix each line with `[YYYY-MM-DD HH:mm:ss.SSS]`
|
|
- Handles backpressure (waits for drain event)
|
|
- Emits `log:entry` event via EventBus
|
|
|
|
### Factory
|
|
```typescript
|
|
import { createLogger } from './logging/index.js';
|
|
const writer = createLogger(processId, eventBus);
|
|
await writer.open();
|
|
await writer.writeStdout('output data');
|
|
await writer.close();
|
|
```
|