import "github.com/sebastienrousseau/corral/internal/github"
Package github provides functionality to interact with the GitHub API.
func Token(ctx context.Context, authMode AuthMode) string
Token resolves a GitHub token for the given auth mode, returning an empty string when none can be obtained. It lets the git package authenticate HTTPS clones and pulls of private repositories with the same credential as the API.
func acquirePageSlot(ctx context.Context, sem chan struct{}) error
func envToken() string
func isRetryableNetworkError(err error) bool
func matchesFilters(repo Repo, includeLang, excludeLang map[string]struct{}, opts FetchOptions) bool
func orgTypeForVisibility(v string) string
func rateLimitResetDuration(resp *http.Response) (time.Duration, bool)
func resolveToken(ctx context.Context, authMode AuthMode) (string, error)
func retryAfterDuration(resp *http.Response) (time.Duration, bool)
func shouldRetry(resp *http.Response, err error, attempt, maxRetries int) (bool, time.Duration)
func toLookupSet(values []string) map[string]struct{}
type AuthMode string
AuthMode controls how GitHub API credentials are resolved.
type FetchOptions struct {
// Limit caps the number of repositories returned; 0 means no limit.
Limit int
// Visibility filters repositories by visibility ("all", "public", or "private").
Visibility string
// IncludeForks includes forked repositories when true.
IncludeForks bool
// IncludeArchived includes archived repositories when true.
IncludeArchived bool
// IncludeLanguages, when non-empty, keeps only repositories matching these languages.
IncludeLanguages []string
// ExcludeLanguages removes repositories matching these languages.
ExcludeLanguages []string
// AuthMode selects how the GitHub token is resolved.
AuthMode AuthMode
// Type filters repositories by specific category (e.g. "sources", "forks", "archived", "mirrors", etc.).
Type string
// Sort specifies how the returned repositories list should be ordered.
Sort string
// RetryMax is the maximum number of retry attempts for transient failures.
RetryMax int
// RetryMinBackoff is the minimum delay between retry attempts.
RetryMinBackoff time.Duration
// RetryMaxBackoff is the maximum delay between retry attempts.
RetryMaxBackoff time.Duration
// Timeout bounds the complete GitHub API operation and each HTTP request.
Timeout time.Duration
}
FetchOptions configures repository fetch behavior.
type Repo struct {
// ID is GitHub's immutable repository identifier.
ID int64
// Owner is the repository owner's login.
Owner string
// FullName is the canonical owner/name identity.
FullName string
// Name is the repository name (without the owner prefix).
Name string
// Language is the primary programming language, or "Other" when unknown.
Language string
// Visibility is the normalized visibility, either "Public" or "Private".
Visibility string
// DefaultBranch is the repository's default branch name.
DefaultBranch string
// CloneURL is the HTTPS clone URL for the repository.
CloneURL string
// SSHURL is the SSH clone URL for the repository.
SSHURL string
// Fork reports whether the repository is a fork.
Fork bool
// Archived reports whether the repository is archived.
Archived bool
// PushedAt is the timestamp of the last push to any branch. The engine
// compares this against the cached value in <repo>/.corral-state.json to
// skip a `git pull` when nothing has changed upstream.
PushedAt time.Time
// Stars reports the stargazers count for the repository.
Stars int
// IsTemplate reports whether the repository is a template.
IsTemplate bool
// IsMirror reports whether the repository is a mirror.
IsMirror bool
// CanBeSponsored reports whether the repository has sponsorships enabled.
CanBeSponsored bool
}
Repo represents a simplified repository structure returned by the GitHub API.
type retryTransport struct {
base http.RoundTripper
maxRetries int
minBackoff time.Duration
maxBackoff time.Duration
}
func (t *retryTransport) RoundTrip(req *http.Request) (*http.Response, error)
RoundTrip implements http.RoundTripper, retrying transient failures and rate-limit responses with backoff until the request succeeds, becomes non-retryable, the retry budget is exhausted, or the context is cancelled.
func (t *retryTransport) backoff(attempt int) time.Duration
import "github.com/sebastienrousseau/corral/internal/git"
Package git provides helper functions to execute common Git commands by wrapping the system's git binary using os/exec.
func CanonicalRemote(raw string) string
CanonicalRemote normalizes common HTTPS, SSH, and scp-like Git remote URLs into a host/path identity suitable for equality checks.
func Clone(ctx context.Context, url, targetDir string, opts CloneOptions) error
Clone executes a git clone command for the given URL into the target directory.
func CurrentBranch(targetDir string) (string, error)
CurrentBranch retrieves the name of the currently checked-out branch.
func HasLocalChanges(ctx context.Context, targetDir string) (bool, string)
HasLocalChanges reports tracked, staged, or untracked working-tree changes. Git errors are treated as unsafe so destructive callers fail closed.
func HasUnpublishedWork(ctx context.Context, targetDir string) (bool, string)
HasUnpublishedWork reports whether deleting targetDir could discard Git objects or state that are not represented by its remotes. It checks commits reachable from every local branch, working-tree changes, stashes, and local-only or divergent tags. Verification errors fail closed and are returned in the detail string.
func IsEmpty(targetDir string) bool
IsEmpty reports whether the repo at targetDir has no commits. This is the local mirror of "an empty GitHub repository" — one that was created upstream but never pushed to. Its .git/refs/heads is empty and HEAD is unborn, so `git pull` fails with "no such ref was fetched". Detecting the state locally lets corral treat it as SKIP-with-reason instead of surfacing that git error to the user. `git rev-parse --verify HEAD^{commit} -q` returns 0 exactly when HEAD resolves to a commit. On any failure — unborn HEAD (empty repo), corrupted refs, or the target not being a git repo at all — this returns true. Callers should have already established that targetDir *is* a git repo (via a .git-directory check) before calling; the "not a git repo" case is defence-in-depth.
func IsRepository(targetDir string) bool
IsRepository reports whether targetDir is a Git repository. It accepts both the usual .git directory and the .git indirection file used by worktrees.
func Pull(ctx context.Context, targetDir string, opts PullOptions) error
Pull executes a `git pull --rebase --autostash` in the target directory. Signature verification (merge.verifySignatures / rebase.verifySignatures) and commit signing (commit.gpgsign) are explicitly disabled for this invocation so an unattended sync never aborts on unsigned commits or blocks on a GPG/SSH passphrase prompt for users who sign commits globally. When opts.RecurseSubmodules is true: - if opts.IgnoreSubmoduleFailures is false, --recurse-submodules is appended to the pull so failures abort the whole operation (existing pre-v0.0.7 behaviour); - if opts.IgnoreSubmoduleFailures is true, the pull runs without --recurse-submodules and submodule updates are attempted in a separate `git submodule update --init --recursive` step whose error is logged but not returned.
func RemoteOrigin(targetDir string) (string, error)
RemoteOrigin retrieves the remote origin URL of the target directory by invoking `git remote get-url origin`. Prefer RemoteOriginFromConfig on hot paths (e.g. orphan detection over hundreds of clones) to avoid the per-call cost of spawning a subprocess.
func RemoteOriginFromConfig(targetDir string) (string, error)
RemoteOriginFromConfig parses the `url =` entry under [remote "origin"] directly from <targetDir>/.git/config, avoiding the ~5-15ms per-call cost of spawning `git remote get-url origin`. Returns the wrapped os.ErrNotExist when the config file is absent, and a clear error when the section or key is missing. Tolerates blank lines, `#` / `;` comments, indented entries, and CRLF line endings.
func ResolveGitBinary() error
ResolveGitBinary looks up the absolute path to the git executable on PATH and caches it for the rest of the process. Returns a clear error when git is not installed.
func authEnv() []string
authEnv returns the environment variables that inject an Authorization header for github.com HTTPS requests, or nil when no token is available. The header is scoped to https://github.com/, so it is harmless for SSH remotes.
func gitOutput(ctx context.Context, targetDir string, args ...string) (string, error)
func nonInteractiveEnv() []string
nonInteractiveEnv returns the environment variables that force git into strict non-interactive mode. They are applied unconditionally to every git invocation so unattended runs (cron, CI) never hang on a missing credential, askpass helper, or GPG pinentry.
func refMap(ctx context.Context, targetDir string, args ...string) (map[string]string, error)
func resolveGitDir(targetDir string) (string, error)
resolveGitDir resolves targetDir/.git to the actual Git metadata directory. Worktrees store a `gitdir: ...` pointer in a regular .git file.
func updateSubmodules(ctx context.Context, targetDir string) error
updateSubmodules runs `git submodule update --init --recursive` in targetDir as a separate subprocess. Exposed indirectly via Pull's IgnoreSubmoduleFailures branch.
func withGitEnv(cmd *exec.Cmd)
withGitEnv attaches the credentials header (when available) plus the non-interactive env vars to cmd, replacing any prior cmd.Env. It always sets cmd.Env so the non-interactive guards apply even on anonymous clones.
type CloneOptions struct {
// RecurseSubmodules, when true, clones submodules recursively by adding
// the --recurse-submodules flag.
RecurseSubmodules bool
// SingleBranch, when true, clones only the history of the default branch
// by adding the --single-branch flag.
SingleBranch bool
// Blobless, when true, performs a blobless partial clone by adding the
// --filter=blob:none flag, deferring blob downloads until needed.
Blobless bool
// Depth, when greater than zero, creates a shallow clone truncated to the
// given number of commits by adding the --depth flag.
Depth int
}
CloneOptions configures optional clone-time performance and layout flags.
type PullOptions struct {
// RecurseSubmodules, when true, also updates submodules after the pull.
// When IgnoreSubmoduleFailures is set, the submodule update runs as a
// separate step so its failure does not abort the parent pull.
RecurseSubmodules bool
// IgnoreSubmoduleFailures, when true, logs (but does not propagate)
// errors from the post-pull submodule update step. Useful when a
// submodule has been deleted upstream or access has been revoked but
// the parent repository's history should still update.
IgnoreSubmoduleFailures bool
}
PullOptions configures a `git pull` invocation.
import "github.com/sebastienrousseau/corral/internal/engine"
Package engine provides the core concurrency and execution logic for Corral.
func Run(ctx context.Context, opts RunOptions)
Run executes the core Corral workflow, orchestrating GitHub API fetches, legacy layout migrations, concurrent Git operations, and orphaned repository detection.
func applyFinderTags(path string, repo github.Repo, result RepoResult) error
func applyJobFinderTags(opts RunOptions, job Job, result RepoResult)
func canonicalCollectionName(name string) string
func canonicalLanguage(lang string) string
func canonicalVisibility(visibility string) string
func cleanupEmptyFolders(baseDir string, repos []github.Repo)
cleanupEmptyFolders removes the now-empty legacy top-level language directories left behind by migrateLegacy. It only targets directories whose names match a repository language, and os.Remove deletes a directory only when it is empty, so unrelated entries under baseDir (e.g. .claude, other projects) are never touched.
func detectOrphans(owner, baseDir string, repos []github.Repo)
func discoverExistingRepos(baseDir string) map[string][]string
func effectiveLayout(opts RunOptions, repo github.Repo) string
func emitCancellation(output OutputFormat, isTTY bool, encoder *json.Encoder, cause error)
emitCancellation writes a final cancellation marker to the active output channel so non-interactive consumers learn the run was interrupted. The interactive TUI path (text + TTY) stays silent — the TUI already redraws on SIGINT and the user knows they pressed Ctrl-C.
func emitOrphans(owner string, orphans []string)
func ensureAppleCollections(baseDir string) error
func evaluateLayout(layoutTpl string, repo github.Repo, owner string) (string, error)
func executeLayout(tmpl *template.Template, repo github.Repo, owner string) (string, error)
func findOrphans(owner, baseDir string, repos []github.Repo) []string
func finderTag(name string, color int) string
func firstNonEmpty(values ...string) string
func firstPath(paths []string) string
func isManagedFinderTag(tag string) bool
func isSearchOwner(owner string) bool
func managedFinderTags(repo github.Repo, result RepoResult, now time.Time) []string
func mergeFinderTags(existing, managed []string) []string
func migrateLegacy(baseDir string, repos []github.Repo)
func normalizeLanguage(lang string) string
func normalizeLayoutDirCase(baseDir string, repos []github.Repo)
normalizeLayoutDirCase applies Finder-facing capitalization to collection and ecosystem directories. APFS/HFS+ need an intermediate name for a case-only rename.
func parseLayoutTemplate(layout string) (*template.Template, error)
func platformReadFinderTags(string) ([]string, error)
func platformWriteFinderTags(string, []string) error
func renameCaseOnly(src, dst string) bool
func repoNameFromURL(url string) string
repoNameFromURL extracts the repository name from a git remote URL, stripping any trailing ".git" suffix. It returns an empty string when no segment exists.
func repoRemoteIdentity(repo github.Repo) string
func repositoryBucket(repo github.Repo) string
func repositoryCollection(repo github.Repo) string
func skipDiscoveryDirectory(name string) bool
func stampCloneState(targetDir string, repo github.Repo)
stampCloneState records the upstream pushed_at in the per-clone state sidecar so the next run can skip a no-op git pull. Best-effort: a write failure is logged but does not fail the operation, since the sidecar is purely an optimization (a missing or stale file falls through to the pre-sidecar behaviour of always pulling).
func toLogMsg(msg RepoResult) tui.LogMsg
func usesClassicLayout(opts RunOptions) bool
func writeCloneState(repoDir string, s cloneState) error
writeCloneState serialises s to repoDir/.corral-state.json atomically by writing to a sibling temp file and renaming it into place. A crash mid-write therefore leaves the previous valid state on disk rather than a half-written file that would fail to parse on the next run.
type Job struct {
// Repo is the GitHub repository to be processed.
Repo github.Repo
// Target is the destination directory for the repository under the new layout.
Target string
// Legacy is the directory where the repository may exist under the old layout.
Legacy string
// Existing is an identity-matched clone at a previous layout path.
Existing string
}
Job encapsulates a repository to be processed along with its target directories.
type OutputFormat string
OutputFormat controls how operation results are emitted.
type RepoResult struct {
// RepoName is the name of the processed repository.
RepoName string `json:"repo"`
// Action is the outcome verb, such as CLONE, SYNC, SKIP, ERROR, or DRY-RUN.
Action string `json:"action"`
// Message is a human-readable description of the outcome.
Message string `json:"message"`
// Target is the destination directory for the repository.
Target string `json:"target"`
// Visibility is the repository visibility (e.g. Public or Private).
Visibility string `json:"visibility"`
// Language is the normalized primary language directory name.
Language string `json:"language"`
// DryRun indicates whether the run was performed in dry-run mode.
DryRun bool `json:"dry_run"`
// Protocol is the clone transport used (https or ssh).
Protocol string `json:"protocol"`
// ClonedURL is the URL used for cloning, if a clone was attempted.
ClonedURL string `json:"clone_url,omitempty"`
// SyncAttempt indicates whether a sync (pull) was attempted.
SyncAttempt bool `json:"sync_attempt"`
// Moved indicates that an identity-matched clone was relocated.
Moved bool `json:"moved,omitempty"`
}
RepoResult represents the final status of processing a repository.
type RunOptions struct {
// Owner is the GitHub user or organization whose repositories are processed.
Owner string
// BaseDir is the root directory under which repositories are laid out.
BaseDir string
// Concurrency is the number of worker goroutines processing repositories; must be >= 1.
Concurrency int
// DryRun, when true, reports intended actions without performing clone or pull operations.
DryRun bool
// Orphans, when true, enables detection of local repositories no longer present upstream.
Orphans bool
// Protocol selects the clone transport and must be either "https" or "ssh".
Protocol string
// DoSync, when true, pulls updates into existing repositories.
DoSync bool
// Output selects the result emission format (text, json, or ndjson).
Output OutputFormat
// Interactive, when true, displays an interactive selector before processing.
Interactive bool
// Fetch holds the options passed to the GitHub repository listing call.
Fetch github.FetchOptions
// Clone holds the options passed to each Git clone operation.
Clone git.CloneOptions
// Sync controls when an already-cloned repository is actually pulled.
Sync SyncOptions
// Layout specifies the templated path structure for repositories. Custom
// layouts may use Collection and Bucket in addition to the legacy fields.
Layout string
// FinderTags enables managed macOS Finder metadata on repository folders.
FinderTags bool
// Version is the build version of Corral.
Version string
}
RunOptions contains all execution controls for a run.
type Summary struct {
// Total is the number of repositories scheduled for processing.
Total int `json:"total"`
// Cloned is the number of repositories successfully cloned.
Cloned int `json:"cloned"`
// Synced is the number of repositories successfully synced.
Synced int `json:"synced"`
// Moved is the number of repositories relocated to their desired layout.
Moved int `json:"moved"`
// Skipped is the number of repositories skipped.
Skipped int `json:"skipped"`
// Failed is the number of repositories that failed to process.
Failed int `json:"failed"`
// Canceled is true when the run was interrupted by ctx cancellation
// (typically SIGINT/SIGTERM). The result set in that case is partial:
// a scripted consumer reading json/ndjson output should treat it as
// "not all repositories were processed" rather than a clean run.
Canceled bool `json:"canceled,omitempty"`
}
Summary tracks aggregate run outcomes.
func (s *Summary) add(msg RepoResult)
type SyncOptions struct {
// Force, when true, runs `git pull` even when the cached state shows
// the upstream pushed_at is unchanged.
Force bool
// IgnoreSubmoduleFailures, when true with Clone.RecurseSubmodules, allows
// the parent repository to update even when a submodule sync fails (e.g.
// the submodule repo was deleted upstream or its access revoked). The
// failure is logged as a WARN but not propagated.
IgnoreSubmoduleFailures bool
}
SyncOptions configures the engine's per-repo sync decision. Kept separate from git.CloneOptions because forcing a sync is a corral-level policy choice, not a clone-time git flag.
type cloneState struct {
// LastSyncedPushedAt is the upstream PushedAt value at the time of the
// last successful clone or sync.
LastSyncedPushedAt time.Time `json:"last_synced_pushed_at"`
// LastSyncedAt is the local wall-clock time of the last sync attempt
// that touched the working tree (clone or successful pull). Used for
// human display only — never for sync-skip decisions.
LastSyncedAt time.Time `json:"last_synced_at"`
}
cloneState is the JSON shape of <repo>/.corral-state.json. New fields must be added with omitempty so older sidecars continue to round-trip.
type stateTempFile interface {
Write([]byte) (int, error)
Close() error
Name() string
}
import "github.com/sebastienrousseau/corral/internal/tui"
Package tui provides a Bubble Tea terminal user interface for Corral.
func GetStyledLogo() string
GetStyledLogo returns the colored ASCII logo art as a string.
func NewModel(total int) tea.Model
NewModel initializes a new TUI model with the expected total number of items.
func RunSelector(ctx context.Context, owner string, fetchOpts github.FetchOptions, fetchFn FetchFunc) ([]github.Repo, bool, error)
RunSelector launches the interactive terminal selector program to choose repositories.
func repoDisplayName(repo github.Repo) string
func repoSelectionKey(repo github.Repo) string
type FetchFunc func() ([]github.Repo, error)
FetchFunc represents the function signature used by the selector to fetch repositories.
type LogMsg struct {
// RepoName is the name of the repository the entry refers to.
RepoName string
// Action is the operation performed (for example CLONE, SYNC, SKIP, ERROR).
Action string
// Message is a human-readable description of the outcome.
Message string
}
LogMsg represents a log entry to be displayed in the TUI.
type fetchedReposMsg struct {
repos []github.Repo
err error
}
type model struct {
total int
done int
logs []LogMsg
prog progress.Model
quitting bool
cloned int
synced int
failed int
existing int
}
model represents the state of the Bubble Tea application.
func (m model) Init() tea.Cmd
Init initializes the Bubble Tea application (no-op).
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd)
Update handles incoming Bubble Tea messages, advancing progress and stats as repository results arrive and quitting when the run completes or is cancelled.
func (m model) View() string
View renders the current progress bar, recent log lines, and, once finished, the final summary of the run.
func (m *model) processLogMsg(msg LogMsg)
type selectorModel struct {
repos []github.Repo
filteredRepos []github.Repo
filter string
selected map[string]bool // key is owner/name when available
table table.Model
spinner spinner.Model
loading bool
loadingErr error
confirmed bool
quitting bool
fetchFn FetchFunc
showHelp bool
cmdErr string
}
func (m *selectorModel) Init() tea.Cmd
func (m *selectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd)
func (m *selectorModel) View() string
func (m *selectorModel) applyFilter()
func (m *selectorModel) executeSlashCommand(cmdStr string) tea.Cmd
func (m *selectorModel) renderCustomTable() string
func (m *selectorModel) renderFooter() string
func (m *selectorModel) renderHelpPanel() string
func (m *selectorModel) updateTableRows()
import "github.com/sebastienrousseau/corral/internal/mcp"
Package mcp implements the corral Model Context Protocol server: a stdio-based JSON-RPC server that exposes the local Corral-organised workspace (cloned repositories under ~/Code) to AI coding agents via the read-only tools and resources defined in this package. The server is a wedge for the "local index for AI" positioning described in the v0.0.8 design doc: GitHub's own MCP server already covers the remote API surface with 50+ tools, so corral-mcp focuses on the dimension only it can serve — a developer's already-cloned local mirror, organised by visibility and language, queryable without a network round-trip.
func DefaultAuditLogPath() string
DefaultAuditLogPath returns the platform-default audit log location. Follows the XDG Base Directory spec: $XDG_STATE_HOME/corral/mutations.log with the documented fallback to ~/.local/state/corral/mutations.log when XDG_STATE_HOME is unset. Rooted at HOME so a system-wide deployment doesn't accidentally share audit logs across users.
func canonicalizeExistingPrefix(abs string) string
canonicalizeExistingPrefix returns abs with its longest existing prefix canonicalised via EvalSymlinks and the non-existing tail re-appended. If abs itself exists, EvalSymlinks handles it directly; otherwise we walk up looking for an existing ancestor whose canonical form we can use. Falls back to the raw path when no ancestor resolves (shouldn't happen on a normal POSIX root).
func describeRepo(r RepoEntry) string
describeRepo is a small formatter used by several tool handlers to render a RepoEntry as a human-readable bullet for the text-content fallback in CallToolResult. JSON content carries the full structure; the text is for clients that surface tool output verbatim.
func extractFilePath(uri string) (string, error)
extractFilePath pulls the {path} portion out of a corral://repo/{owner}/{name}/file/{path} URI. mcp-go's URI template matcher doesn't expose captured groups to the handler, so we re-parse.
func firstSegment(rel string) string
firstSegment returns the first path component, used as a fallback match when a custom layout doesn't carry an explicit Visibility.
func guessMIME(path string) string
guessMIME picks a content type from the file extension. Mostly to help clients render code with syntax highlighting; not security- relevant. Defaults to text/plain for anything unrecognised.
func isAbsolutePath(p string) bool
isAbsolutePath checks for an absolute filesystem path without importing path/filepath into a hot accessor — the cmd layer also validates upstream, so this is belt-and-braces.
func jsonResult(payload any) *mcp.CallToolResult
jsonResult marshals payload as the structured content block of a tool result, falling back to NewToolResultError if marshalling fails. Returns a result + nil error — mcp-go's convention is that tool failures travel through the result's IsError flag, not via a Go error.
func markStateSynced(repoPath string) error
markStateSynced records a successful MCP-triggered pull while preserving the last upstream pushed_at value observed by the GitHub-backed sync engine.
func mutationID() string
func ownerMatchesURL(remoteURL, owner string) bool
ownerMatchesURL reports whether owner equals ANY namespace segment preceding the repository name in remoteURL. This matters for GitLab-style / Gitea-style / self-hosted layouts with nested groups where an origin URL like https://git.example.com/parent/team/repo.git should match agent queries against both "parent" and "team". For standard GitHub URLs (https://github.com/owner/repo) the namespace list is a single element and behaviour is unchanged.
func parseOwnerFromURL(remoteURL string) []string
parseOwnerFromURL returns every namespace segment that precedes the final repository segment in remoteURL, in order (root-most first). Empty slice when the URL can't be parsed. Handles both the HTTPS scheme://host/A/B/…/repo form and the SSH user@host:A/B/…/repo form. Returned as []string (rather than the previous single-segment form) so callers can match against deep hierarchies without losing the intermediate names.
func redactCloneURL(raw string) string
func sortedLangCounts(m map[string]int) []map[string]any
sortedLangCounts converts a language-count map into a stable descending-by-count, then alphabetical-by-name list so the JSON output is deterministic across calls. Agents that diff successive snapshots benefit from the stability.
type AuditRecord struct {
// OperationID correlates the durable intent and completion records emitted
// for a mutation.
OperationID string `json:"operation_id,omitempty"`
// Phase is either "intent" or "completion".
Phase string `json:"phase,omitempty"`
// Timestamp is the moment the audited operation completed, RFC 3339 UTC.
Timestamp string `json:"ts"`
// Tool is the MCP tool name that triggered the mutation
// (e.g. "corral_clone_repo").
Tool string `json:"tool"`
// Target is the repo path or clone URL the operation acted on. The
// exact meaning depends on the tool — sync/delete write a path,
// clone writes the source URL — but each record documents its scope.
Target string `json:"target"`
// Args captures the tool's structured input so a reviewer can replay
// the mutation. Kept as a raw map to avoid pinning a specific schema
// per tool at this layer.
Args map[string]any `json:"args,omitempty"`
// Result is "ok" on success or a short human-readable reason on
// refusal/failure. The full error message goes to Message.
Result string `json:"result"`
// Message is a human-readable outcome; blank on clean success.
Message string `json:"message,omitempty"`
}
AuditRecord is one entry in the mutation audit log. It captures enough to reconstruct what an agent did after the fact — what tool was invoked, what arguments it received, which repository was affected, what the outcome was, and when. The fields are deliberately flat and JSON-line encoded so `jq` and grep-style tools work naturally.
type Auditor struct {
path string
mu sync.Mutex
}
Auditor writes AuditRecord entries to an append-only JSONL log. The log path defaults to $XDG_STATE_HOME/corral/mutations.log (falling back to ~/.local/state/corral/mutations.log per the XDG spec). Concurrent Write calls are serialised by an internal mutex — the mutation tools are called at most a few times per second in practice, so the lock contention is negligible.
func (a *Auditor) Path() string
Path returns the log path the Auditor is writing to. Exposed for the server startup banner and for tests that need to read the log back.
func (a *Auditor) Write(r AuditRecord) error
Write appends one AuditRecord to the log. Any error is returned so callers can decide whether to fail the tool call or continue — the mutation tools in this package treat audit failures as fatal because a mutation without a durable record defeats the purpose of the audit mechanism.
type Index struct {
// Root is the absolute path the index was built against.
Root string
// Repos is the discovered set of clones, sorted deterministically
// by RelPath for stable agent output.
Repos []RepoEntry
// Truncated reports that the configured repository cap was reached.
Truncated bool
}
Index is an in-memory snapshot of the workspace beneath a root directory. It is intentionally cheap to rebuild — every tool call triggers a fresh Scan — so the server stays correct as the user clones, syncs, and removes repos out-of-band without us having to implement filesystem watching.
func (i *Index) Find(query string) (*RepoEntry, error)
Find returns the entry whose Name, RelPath, or RemoteURL repo segment equals or has the supplied query as a suffix. It is the primitive behind the corral_find_repo tool. Returns ErrRepoNotFound when no candidate matches and ErrAmbiguous when multiple do — the caller should surface both for the agent to disambiguate.
func (i *Index) SafePath(path string) (string, error)
SafePath validates that path resolves to a file or directory beneath the index root, blocking directory-traversal attempts via the corral_get_file tool and the corral://repo/{org}/{name}/file/{path} resource. Returns the cleaned absolute path on success. Both the root and the candidate's existing ancestors are canonicalised via EvalSymlinks. This matters on macOS where /tmp is a symlink to /private/tmp: without canonicalising both sides of the rel-prefix check, every lookup spuriously "escapes" the root. When the candidate itself doesn't exist, the canonicalisation walks up to the deepest existing ancestor and reconstructs the path, so would-be lookups (e.g. for a file the caller is about to create) still get the same security checks as existing-file lookups.
type RepoEntry struct {
// Name is the repository's basename (e.g. "corral").
Name string `json:"name"`
// Visibility is the visibility-directory the clone sits under
// (typically "Public" or "Private"); empty when the layout does not
// include a visibility segment.
Visibility string `json:"visibility,omitempty"`
// Language is the language-directory segment (lowercase, normalised
// by corral on clone). Empty when not present in the layout.
Language string `json:"language,omitempty"`
// Path is the absolute on-disk path to the repository root.
Path string `json:"path"`
// RelPath is the path relative to the index root, joinable across
// hosts (forward-slash separators).
RelPath string `json:"rel_path"`
// RemoteURL is the URL of the `origin` remote parsed from
// .git/config; empty when unreadable.
RemoteURL string `json:"remote_url,omitempty"`
// State is the parsed contents of .corral-state.json when present.
// nil when the sidecar is absent or unreadable.
State *StateRecord `json:"state,omitempty"`
}
RepoEntry is one row in the workspace index. It captures the information agents most often want about a local clone without needing to spawn a `git` subprocess per repo.
type Server struct {
mcp *server.MCPServer
opts ServerOptions
auditor *Auditor
// scanMu guards the in-memory workspace-index cache below.
// Every tool and resource handler goes through Server.scan(),
// which walks the filesystem at most once every scanTTL and
// returns the cached snapshot in between. This trades a small
// amount of staleness (see scanTTL) for O(1) amortised cost on
// bursty client sessions where an agent fires 5-10 tool calls
// in quick succession.
scanMu sync.Mutex
scanIndex *Index
scanExpires time.Time
}
Server wraps an mcp-go MCPServer with the corral-specific configuration. Exposed as a struct (rather than handing back the bare *server.MCPServer) so future phases can attach per-server state (search backends, audit logger, etc.) without breaking the cmd-layer call site.
func (s *Server) AuditLogPath() string
AuditLogPath returns the audit log path when mutations are enabled; empty otherwise. Exposed for the cmd-layer startup banner.
func (s *Server) MutationsEnabled() bool
MutationsEnabled reports whether write-tools are unlocked. Read by the tool registry at construction time; surfaced via this accessor so future phases can also gate behaviour outside the registry path.
func (s *Server) Root() string
Root returns the sandbox root the server was configured with. Useful for the cmd layer's startup-log line and for tests.
func (s *Server) ServeStdio() error
ServeStdio runs the server on the stdio transport (the MCP standard for local servers). Blocks until stdin closes or the server errors. Stdout is reserved for the JSON-RPC protocol stream — any debug logging the cmd layer wants to emit must go to stderr.
func (s *Server) audit(rec AuditRecord) error
audit writes a mutation record. Failure to audit is a fatal error for the surrounding tool call: an unlogged mutation defeats the mechanism, so callers should propagate this back to the agent as an IsError result.
func (s *Server) auditRefusal(rec AuditRecord, message string) error
func (s *Server) beginMutation(rec AuditRecord) (AuditRecord, error)
func (s *Server) cloneRepoTool() (mcp.Tool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error))
cloneRepoTool returns corral_clone_repo. Wraps git.Clone into the layout-templated target directory. Refuses if the target already exists (never silently overwrites) or if the destination would escape the sandbox root.
func (s *Server) completeMutation(rec AuditRecord, result, message string) error
func (s *Server) deleteRepoTool() (mcp.Tool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error))
deleteRepoTool returns corral_delete_repo. This is the highest-risk operation the MCP server exposes: it removes a local clone from disk. The safeguards are deliberately paranoid: 1. Requires BOTH EnableMutations and EnableDestructiveMutations to be registered at all. 2. Resolves the target via SafePath so path traversal cannot escape the sandbox. 3. Refuses if the working tree has uncommitted changes. 4. Refuses if there are unpushed commits on any branch. 5. Refuses if the target isn't a git repository at all (defence against typos deleting an unrelated directory). 6. Always writes an audit record before removing anything, so a race between the check and the removal is still logged.
func (s *Server) explainWorkspacePrompt() (mcp.Prompt, func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error))
explainWorkspacePrompt returns explain_workspace. Instructs the agent to survey the workspace via the corral_status_summary and corral_workspace_index tools and summarise the layout for the user — how many repos, what languages, which orgs, which are freshly synced vs stale.
func (s *Server) findRepoTool() (mcp.Tool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error))
findRepoTool returns corral_find_repo: a single-string lookup that resolves a fuzzy name to a unique RepoEntry. Returns a structured error result (IsError=true) on no-match or ambiguous match, listing the candidate paths so the agent can re-call with a more specific query. This is the workhorse for "open repo X" style intents.
func (s *Server) identifyStaleReposPrompt() (mcp.Prompt, func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error))
identifyStaleReposPrompt returns identify_stale_repos. Directs the agent to scan .corral-state.json state via the workspace_index tool and flag clones whose upstream has moved but whose local state has not. Intended as the "which of my forks/mirrors need attention" question every developer periodically wants answered.
func (s *Server) invalidateScanCache()
invalidateScanCache drops the cached workspace index so the next call to scan() re-walks the filesystem. Used by tests to make consecutive assertions against different tree states deterministic without waiting scanTTL between them.
func (s *Server) listReposTool() (mcp.Tool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error))
listReposTool returns the tool definition + handler for corral_list_repos. The tool answers the most common opening question an agent asks ("what's in this workspace?") without requiring a full index dump up front. All filters are optional and intersected; the result is the structured RepoEntry list serialised as JSON.
func (s *Server) registerDestructiveTools()
registerDestructiveTools attaches corral_delete_repo. Kept in its own function so a future grep for "destructive tool registration" finds one and only one call site.
func (s *Server) registerMutationTools()
registerMutationTools attaches the non-destructive write tools (corral_sync_repo, corral_clone_repo) to the underlying MCP server. corral_delete_repo lives in registerDestructiveTools so callers can grant "may pull and clone" without also granting "may delete."
func (s *Server) registerPrompts()
registerPrompts attaches Corral's MCP prompt templates to the underlying server. Prompts are structured invocations an MCP client (Claude Code, Cursor, Cline) surfaces to the user as pre-canned options — the user picks one from a menu, the client fills it into the conversation, and the agent uses the resulting instructions. The prompts here don't call tools directly. They tell the agent which tools/resources to consult to answer the user's intent, which makes them useful even before Corral ships write tools: an agent can still explain the workspace or find stale clones using only the read-only surface. Prompt-capability advertising is enabled unconditionally at NewServer time; the prompts themselves are free to register whether or not mutations are enabled.
func (s *Server) registerResources()
registerResources attaches the v0 resource set (one static index + three URI templates) to the underlying MCP server. URI scheme is `corral://` per the design doc; templated paths use RFC 6570 expansion (handled by mcp-go via the github.com/yosida95/uritemplate dependency it already pulls in).
func (s *Server) registerTools()
registerTools attaches the v0 read-only tool set to the underlying MCP server. Tool names follow the snake_case `corral_<verb>_<noun>` convention recommended by the 2025-11-25 spec and shipped by github/github-mcp-server, so an agent that has loaded both servers can rank tools by prefix without confusion.
func (s *Server) repoFileResource() (mcp.ResourceTemplate, func(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error))
repoFileResource reads one file inside a clone. This is the highest- security-impact resource in v0: a path-traversal bug here would let an agent escape the workspace root. The handler validates the resolved path is still under the configured Root via Index.SafePath before opening the file, and bounds the read at maxFileBytes.
func (s *Server) repoMetadataTool() (mcp.Tool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error))
repoMetadataTool returns corral_get_repo_metadata: deep info about one repo, including current branch (resolved via git rev-parse), remote origin URL, and parsed sidecar state. Separate from corral_find_repo because the metadata fetch involves a subprocess call per request (CurrentBranch) and isn't free; list_repos and find_repo keep their per-call cost predictable.
func (s *Server) repoStateResource() (mcp.ResourceTemplate, func(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error))
repoStateResource exposes the on-disk .corral-state.json sidecar for a single clone via a URI template. Returns 404-equivalent (error) when the repo or sidecar isn't found, so clients can distinguish "no such repo" from "no sync yet" by the error text.
func (s *Server) repoTreeResource() (mcp.ResourceTemplate, func(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error))
repoTreeResource returns a top-level file/directory listing for one clone, scoped to two-deep entries (the agent's first orientation pass rarely needs more than that, and a deep listing of a large repo would blow the response budget). Bigger walks go through follow-up tool calls in later phases.
func (s *Server) resolveURIRepo(uri string) (*RepoEntry, error)
resolveURIRepo parses owner+name out of a corral:// URI and returns the matching RepoEntry. Returns an error when the URI is malformed or the repo isn't in the index — both are surfaced to the agent. URIs are expected to look like: corral://repo/{owner}/{name}/state corral://repo/{owner}/{name}/tree corral://repo/{owner}/{name}/file/{path} url.Parse treats "repo" as the Host and the rest as Path, so this concatenates them via path.Join semantics to avoid the double-slash pitfall that broke the first naive implementation.
func (s *Server) scan() (*Index, error)
scan returns a cached workspace Index, walking the filesystem only when the previous snapshot has expired. Safe for concurrent callers (single mutex; the walk itself is not parallel so there is no gain from a RWMutex here). On error the cache is not populated and the error is propagated so the caller can surface it to the agent.
func (s *Server) statusSummaryTool() (mcp.Tool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error))
statusSummaryTool returns corral_status_summary: a workspace-wide summary intended as the agent's opening read on a large workspace — "how many repos, broken down how, and how many are stale?". Cheap to compute because it only touches the in-memory index, no subprocesses.
func (s *Server) syncRepoTool() (mcp.Tool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error))
syncRepoTool returns corral_sync_repo. Wraps git.Pull for a resolved repo. Preserves the same smart-sync sidecar semantics as the classic `corralctl <owner>` sync path — no separate write-through cache to maintain. Refuses when the repo is not in the workspace index or when the operation cannot be sandboxed to the configured Root.
func (s *Server) workspaceIndexResource() (mcp.Resource, func(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error))
workspaceIndexResource is the only static resource. It mirrors the corral_workspace_index tool, but exposed as a resource so clients that prefer to subscribe (rather than call tools) get the same data.
func (s *Server) workspaceIndexTool() (mcp.Tool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error))
workspaceIndexTool returns corral_workspace_index: the raw index dump for clients that prefer one round-trip to many. Useful for an agent priming its context at session start, then querying the in-memory copy without further tool calls. Capped result size is the agent's problem (rounds-trip cost matters more than bytes-on-the-wire here).
type ServerOptions struct {
// Root is the absolute path the server treats as the workspace.
// All tools and resources reject paths outside this root.
Root string
// Version is injected at build time (see cmd.Version); surfaced to
// MCP clients in the server-info handshake.
Version string
// EnableMutations, when true, registers the write-side tools
// (corral_sync_repo, corral_clone_repo). The read-only tool set is
// always registered; this flag only unlocks the ones that touch
// the filesystem or the network.
EnableMutations bool
// EnableDestructiveMutations gates corral_delete_repo specifically.
// A misfiring agent that could delete workspace repos is a class of
// harm distinct from clone/sync mistakes, so it earns its own opt-in.
// Ignored unless EnableMutations is also true.
EnableDestructiveMutations bool
// AuditLogPath is where the JSONL audit log for every mutation is
// appended. Empty means use the XDG default
// ($XDG_STATE_HOME/corral/mutations.log). Only consulted when at
// least one mutation gate is enabled.
AuditLogPath string
}
ServerOptions configures a Server. All zero values are valid except Root, which must be a non-empty absolute path the server will sandbox itself to.
type StateRecord struct {
// LastSyncedPushedAt is the upstream pushed_at timestamp the engine
// observed on the previous successful sync, formatted per RFC 3339.
LastSyncedPushedAt string `json:"last_synced_pushed_at,omitempty"`
// LastSyncedAt is when the engine last touched this clone, RFC 3339.
LastSyncedAt string `json:"last_synced_at,omitempty"`
}
StateRecord mirrors the on-disk .corral-state.json sidecar without importing internal/engine (which would create a dependency cycle — internal/engine already imports internal/git, and corral-mcp will need to import internal/engine in later phases for sync operations).
type auditFile interface {
Write([]byte) (int, error)
Close() error
}
type syncTempFile interface {
Write([]byte) (int, error)
Close() error
Name() string
}