Initial commit: restructure to flat layout with ui/ and web/ at root

This commit is contained in:
2026-03-12 21:33:50 +08:00
commit decba25a08
1708 changed files with 199890 additions and 0 deletions

View File

@@ -0,0 +1,145 @@
# Git Module Documentation
## Purpose
This module provides Git repository operations for the web server runtime, including repository management, branch/worktree operations, status/diff queries, commit handling, and merge/rebase workflows.
## Entrypoints and structure
- `packages/web/server/lib/git/`: Git module directory containing all Git-related functionality.
- `index.js`: Public API entry point imported by `packages/web/server/index.js`.
- `service.js`: Core Git operations (repository, branch, worktree, commit, merge/rebase, status/diff, log).
- `credentials.js`: Git credentials management.
- `identity-storage.js`: Git identity (user.name, user.email) storage.
## Public API
The following functions are exported and used by the web server:
### Repository Operations
- `isGitRepository(directory)`: Check if a directory is a Git repository.
- `getGlobalIdentity()`: Get global Git user.name, user.email, and core.sshCommand.
- `getCurrentIdentity(directory)`: Get local Git identity (fallback to global if not set locally).
- `hasLocalIdentity(directory)`: Check if local Git identity is configured.
- `setLocalIdentity(directory, profile)`: Set local Git identity (userName, userEmail, authType, sshKey/host).
- `getRemoteUrl(directory, remoteName)`: Get URL for a specific remote.
### Status and Diff Operations
- `getStatus(directory)`: Get comprehensive Git status including current branch, tracking, ahead/behind, file changes, diff stats, merge/rebase state.
- `getDiff(directory, { path, staged, contextLines })`: Get diff output for files or entire working tree.
- `getRangeDiff(directory, { base, head, path, contextLines })`: Get diff between two refs.
- `getRangeFiles(directory, { base, head })`: Get list of changed files between two refs.
- `getFileDiff(directory, { path, staged })`: Get original and modified file contents for a single file (handles images as data URLs).
- `collectDiffs(directory, files)`: Collect diff output for multiple files.
- `revertFile(directory, filePath)`: Revert a file to HEAD state.
### Branch Operations
- `getBranches(directory)`: Get list of local and remote branches (filtered to active remote branches).
- `createBranch(directory, branchName, options)`: Create and checkout a new branch.
- `checkoutBranch(directory, branchName)`: Checkout an existing branch.
- `deleteBranch(directory, branch, options)`: Delete a branch (supports force flag).
- `renameBranch(directory, oldName, newName)`: Rename a branch and preserve upstream tracking.
- `getRemotes(directory)`: Get list of configured remotes.
### Worktree Operations
- `getWorktrees(directory)`: List all git worktrees for a repository.
- `validateWorktreeCreate(directory, input)`: Validate worktree creation parameters (mode, branchName, startRef, upstream config).
- `createWorktree(directory, input)`: Create a new worktree (supports 'new' and 'existing' modes, upstream setup).
- `removeWorktree(directory, input)`: Remove a worktree (optionally delete local branch).
- `isLinkedWorktree(directory)`: Check if directory is a linked worktree (not primary).
### Commit and Remote Operations
- `commit(directory, message, options)`: Create a commit (supports addAll or specific files).
- `pull(directory, options)`: Pull changes from remote.
- `push(directory, options)`: Push changes to remote (auto-sets upstream if needed).
- `fetch(directory, options)`: Fetch changes from remote.
- `deleteRemoteBranch(directory, options)`: Delete a remote branch.
### Log Operations
- `getLog(directory, options)`: Get commit history with stats (supports maxCount, from, to, file filters).
- `getCommitFiles(directory, commitHash)`: Get file changes for a specific commit.
### Merge and Rebase Operations
- `rebase(directory, options)`: Start a rebase onto a target branch.
- `abortRebase(directory)`: Abort an in-progress rebase.
- `continueRebase(directory)`: Continue a rebase after conflict resolution.
- `merge(directory, options)`: Merge a branch into current branch.
- `abortMerge(directory)`: Abort an in-progress merge.
- `continueMerge(directory)`: Continue a merge after conflict resolution.
- `getConflictDetails(directory)`: Get detailed conflict information including operation type, unmerged files, and diff.
### Stash Operations
- `stash(directory, options)`: Stash changes (supports message and includeUntracked options).
- `stashPop(directory)`: Pop and apply the most recent stash.
## Internal Helpers
The following functions are internal helpers used by exported functions:
- `buildSshCommand(sshKeyPath)`: Build SSH command string for git config.
- `buildGitEnv()`: Build Git environment with SSH_AUTH_SOCK resolution.
- `createGit(directory)`: Create simple-git instance with environment.
- `normalizeDirectoryPath(value)`: Normalize directory paths (supports ~ expansion).
- `cleanBranchName(branch)`: Remove refs/heads/ or refs/ prefixes.
- `parseWorktreePorcelain(raw)`: Parse `git worktree list --porcelain` output.
- `resolveWorktreeProjectContext(directory)`: Resolve project context (projectID, primaryWorktree, worktreeRoot).
- `resolveCandidateDirectory(...)`: Generate unique worktree directory candidates.
- `resolveBranchForExistingMode(...)`: Resolve branch for existing-mode worktree creation.
- `applyUpstreamConfiguration(...)`: Set upstream tracking for new branches.
- And various other internal helpers for Git command execution and parsing.
## Response Contracts
### Status Response
- `current`: Current branch name.
- `tracking`: Upstream branch (e.g., 'origin/main').
- `ahead`: Number of commits ahead of upstream.
- `behind`: Number of commits behind upstream.
- `files`: Array of file objects with `path`, `index`, `working_dir` status codes.
- `isClean`: Boolean indicating if working tree is clean.
- `diffStats`: Object mapping file paths to `{ insertions, deletions }`.
- `mergeInProgress`: Object with `{ head, message }` if merge in progress.
- `rebaseInProgress`: Object with `{ headName, onto }` if rebase in progress.
### Worktree Create/Remove Response
- `head`: HEAD commit SHA.
- `name`: Worktree name.
- `branch`: Local branch name.
- `path`: Absolute path to worktree directory.
### Log Response
- `all`: Array of commit objects with hash, date, message, author info, stats.
- `latest`: Latest commit object or null.
- `total`: Total number of commits.
## Notes for Contributors
### Adding a New Git Operation
1. Add the function to `packages/web/server/lib/git/service.js`.
2. Export the function if it's part of the public API.
3. Use `createGit(directory)` to get a simple-git instance with the correct environment.
4. Use `runGitCommand(cwd, args)` for direct git command execution with better error handling.
5. Use `runGitCommandOrThrow(cwd, args, fallbackMessage)` for commands that must succeed.
6. Return consistent error messages; use `parseGitErrorText(error)` to extract meaningful git errors.
7. Update this file with the new function in the appropriate API section.
### SSH Key Handling
- SSH keys are escaped and validated via `escapeSshKeyPath` to prevent command injection.
- On Windows, paths are converted to MSYS format (`C:/path``/c/path`).
- SSH_AUTH_SOCK is automatically resolved via `resolveSshAuthSock` (checks GPG agent, gpgconf).
### Worktree Naming
- Worktree names are slugified via `slugWorktreeName`.
- Random names use adjectives/nouns from `OPENCODE_ADJECTIVES` and `OPENCODE_NOUNS` lists.
- Branches created for new worktrees use `openchamber/<worktree-name>` pattern.
### Cross-Platform Considerations
- Use `normalizeDirectoryPath` for all directory inputs to handle `~` and path separators.
- Use `canonicalPath` for path comparisons to handle case-insensitive filesystems (Windows).
- Windows Git commands use MSYS/MinGW paths; avoid direct Windows paths in git commands.
### Error Handling
- All exported functions should throw errors with descriptive messages.
- Use `console.error` for logging Git operation failures.
- Return structured objects for operations that need partial success reporting (e.g., merge/rebase conflicts).
### Testing
- Run `bun run type-check`, `bun run lint`, and `bun run build` before finalizing changes.
- Consider edge cases: non-Git directories, missing remotes, conflict states, concurrent worktree operations.

View File

@@ -0,0 +1,74 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
const GIT_CREDENTIALS_PATH = path.join(os.homedir(), '.git-credentials');
export function discoverGitCredentials() {
const credentials = [];
if (!fs.existsSync(GIT_CREDENTIALS_PATH)) {
return credentials;
}
try {
const content = fs.readFileSync(GIT_CREDENTIALS_PATH, 'utf8');
const lines = content.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const url = new URL(line.trim());
const hostname = url.hostname;
const pathname = url.pathname && url.pathname !== '/' ? url.pathname : '';
const host = hostname + pathname;
const username = url.username || '';
if (host && username) {
const exists = credentials.some(c => c.host === host && c.username === username);
if (!exists) {
credentials.push({ host, username });
}
}
} catch {
continue;
}
}
} catch (error) {
console.error('Failed to read .git-credentials:', error);
}
return credentials;
}
export function getCredentialForHost(host) {
if (!fs.existsSync(GIT_CREDENTIALS_PATH)) {
return null;
}
try {
const content = fs.readFileSync(GIT_CREDENTIALS_PATH, 'utf8');
const lines = content.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const url = new URL(line.trim());
const hostname = url.hostname;
const pathname = url.pathname && url.pathname !== '/' ? url.pathname : '';
const credHost = hostname + pathname;
if (credHost === host) {
return {
username: url.username || '',
token: url.password || ''
};
}
} catch {
continue;
}
}
} catch (error) {
console.error('Failed to read .git-credentials for host lookup:', error);
}
return null;
}

View File

@@ -0,0 +1,110 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
const STORAGE_DIR = path.join(os.homedir(), '.config', 'openchamber');
const STORAGE_FILE = path.join(STORAGE_DIR, 'git-identities.json');
function ensureStorageDir() {
if (!fs.existsSync(STORAGE_DIR)) {
fs.mkdirSync(STORAGE_DIR, { recursive: true });
}
}
export function loadProfiles() {
ensureStorageDir();
if (!fs.existsSync(STORAGE_FILE)) {
return { profiles: [] };
}
try {
const content = fs.readFileSync(STORAGE_FILE, 'utf8');
const data = JSON.parse(content);
return data;
} catch (error) {
console.error('Failed to load git identity profiles:', error);
return { profiles: [] };
}
}
export function saveProfiles(data) {
ensureStorageDir();
try {
fs.writeFileSync(STORAGE_FILE, JSON.stringify(data, null, 2), 'utf8');
return true;
} catch (error) {
console.error('Failed to save git identity profiles:', error);
throw error;
}
}
export function getProfiles() {
const data = loadProfiles();
return data.profiles || [];
}
export function getProfile(id) {
const profiles = getProfiles();
return profiles.find(p => p.id === id) || null;
}
export function createProfile(profileData) {
const profiles = getProfiles();
if (profiles.some(p => p.id === profileData.id)) {
throw new Error(`Profile with ID "${profileData.id}" already exists`);
}
if (!profileData.id || !profileData.userName || !profileData.userEmail) {
throw new Error('Profile must have id, userName, and userEmail');
}
const newProfile = {
id: profileData.id,
name: profileData.name || profileData.userName,
userName: profileData.userName,
userEmail: profileData.userEmail,
authType: profileData.authType || 'ssh',
sshKey: profileData.sshKey || null,
host: profileData.host || null,
color: profileData.color || 'keyword',
icon: profileData.icon || 'branch'
};
profiles.push(newProfile);
saveProfiles({ profiles });
return newProfile;
}
export function updateProfile(id, updates) {
const profiles = getProfiles();
const index = profiles.findIndex(p => p.id === id);
if (index === -1) {
throw new Error(`Profile with ID "${id}" not found`);
}
profiles[index] = {
...profiles[index],
...updates,
id: profiles[index].id
};
saveProfiles({ profiles });
return profiles[index];
}
export function deleteProfile(id) {
const profiles = getProfiles();
const filteredProfiles = profiles.filter(p => p.id !== id);
if (filteredProfiles.length === profiles.length) {
throw new Error(`Profile with ID "${id}" not found`);
}
saveProfiles({ profiles: filteredProfiles });
return true;
}

View File

@@ -0,0 +1,6 @@
// Git library public entrypoint
// Re-exports all Git operations, credentials, and identity storage functions
export * from './service.js';
export * from './credentials.js';
export * from './identity-storage.js';

File diff suppressed because it is too large Load Diff