Corral API Documentation

Reference manual covering all modules & packages

Package github

import "github.com/sebastienrousseau/corral/internal/github"

Package github provides functionality to interact with the GitHub API.

Functions

func Token

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

func acquirePageSlot(ctx context.Context, sem chan struct{}) error

func envToken

func envToken() string

func isRetryableNetworkError

func isRetryableNetworkError(err error) bool

func matchesFilters

func matchesFilters(repo Repo, includeLang, excludeLang map[string]struct{}, opts FetchOptions) bool

func orgTypeForVisibility

func orgTypeForVisibility(v string) string

func rateLimitResetDuration

func rateLimitResetDuration(resp *http.Response) (time.Duration, bool)

func resolveToken

func resolveToken(ctx context.Context, authMode AuthMode) (string, error)

func retryAfterDuration

func retryAfterDuration(resp *http.Response) (time.Duration, bool)

func shouldRetry

func shouldRetry(resp *http.Response, err error, attempt, maxRetries int) (bool, time.Duration)

func toLookupSet

func toLookupSet(values []string) map[string]struct{}

Types

type AuthMode

type AuthMode string

AuthMode controls how GitHub API credentials are resolved.

type FetchOptions

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

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

type retryTransport struct {
	base		http.RoundTripper
	maxRetries	int
	minBackoff	time.Duration
	maxBackoff	time.Duration
}

Methods

func RoundTrip

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 backoff

func (t *retryTransport) backoff(attempt int) time.Duration

Package git

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.

Functions

func CanonicalRemote

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

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

func CurrentBranch(targetDir string) (string, error)

CurrentBranch retrieves the name of the currently checked-out branch.

func HasLocalChanges

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

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

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

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

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

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

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

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

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

func gitOutput(ctx context.Context, targetDir string, args ...string) (string, error)

func nonInteractiveEnv

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

func refMap(ctx context.Context, targetDir string, args ...string) (map[string]string, error)

func resolveGitDir

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

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

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.

Types

type CloneOptions

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

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.

Package engine

import "github.com/sebastienrousseau/corral/internal/engine"

Package engine provides the core concurrency and execution logic for Corral.

Functions

func Run

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

func applyFinderTags(path string, repo github.Repo, result RepoResult) error

func applyJobFinderTags

func applyJobFinderTags(opts RunOptions, job Job, result RepoResult)

func canonicalCollectionName

func canonicalCollectionName(name string) string

func canonicalLanguage

func canonicalLanguage(lang string) string

func canonicalVisibility

func canonicalVisibility(visibility string) string

func cleanupEmptyFolders

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

func detectOrphans(owner, baseDir string, repos []github.Repo)

func discoverExistingRepos

func discoverExistingRepos(baseDir string) map[string][]string

func effectiveLayout

func effectiveLayout(opts RunOptions, repo github.Repo) string

func emitCancellation

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

func emitOrphans(owner string, orphans []string)

func ensureAppleCollections

func ensureAppleCollections(baseDir string) error

func evaluateLayout

func evaluateLayout(layoutTpl string, repo github.Repo, owner string) (string, error)

func executeLayout

func executeLayout(tmpl *template.Template, repo github.Repo, owner string) (string, error)

func findOrphans

func findOrphans(owner, baseDir string, repos []github.Repo) []string

func finderTag

func finderTag(name string, color int) string

func firstNonEmpty

func firstNonEmpty(values ...string) string

func firstPath

func firstPath(paths []string) string

func isManagedFinderTag

func isManagedFinderTag(tag string) bool

func isSearchOwner

func isSearchOwner(owner string) bool

func managedFinderTags

func managedFinderTags(repo github.Repo, result RepoResult, now time.Time) []string

func mergeFinderTags

func mergeFinderTags(existing, managed []string) []string

func migrateLegacy

func migrateLegacy(baseDir string, repos []github.Repo)

func normalizeLanguage

func normalizeLanguage(lang string) string

func normalizeLayoutDirCase

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

func parseLayoutTemplate(layout string) (*template.Template, error)

func platformReadFinderTags

func platformReadFinderTags(string) ([]string, error)

func platformWriteFinderTags

func platformWriteFinderTags(string, []string) error

func renameCaseOnly

func renameCaseOnly(src, dst string) bool

func repoNameFromURL

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

func repoRemoteIdentity(repo github.Repo) string

func repositoryBucket

func repositoryBucket(repo github.Repo) string

func repositoryCollection

func repositoryCollection(repo github.Repo) string

func skipDiscoveryDirectory

func skipDiscoveryDirectory(name string) bool

func stampCloneState

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

func toLogMsg(msg RepoResult) tui.LogMsg

func usesClassicLayout

func usesClassicLayout(opts RunOptions) bool

func writeCloneState

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.

Types

type Job

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

type OutputFormat string

OutputFormat controls how operation results are emitted.

type RepoResult

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

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

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.

Methods

func add

func (s *Summary) add(msg RepoResult)

type SyncOptions

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

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

type stateTempFile interface {
	Write([]byte) (int, error)
	Close() error
	Name() string
}

Package tui

import "github.com/sebastienrousseau/corral/internal/tui"

Package tui provides a Bubble Tea terminal user interface for Corral.

Functions

func GetStyledLogo

func GetStyledLogo() string

GetStyledLogo returns the colored ASCII logo art as a string.

func NewModel

func NewModel(total int) tea.Model

NewModel initializes a new TUI model with the expected total number of items.

func RunSelector

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

func repoDisplayName(repo github.Repo) string

func repoSelectionKey

func repoSelectionKey(repo github.Repo) string

Types

type FetchFunc

type FetchFunc func() ([]github.Repo, error)

FetchFunc represents the function signature used by the selector to fetch repositories.

type LogMsg

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

type fetchedReposMsg struct {
	repos	[]github.Repo
	err	error
}

type model

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.

Methods

func Init

func (m model) Init() tea.Cmd

Init initializes the Bubble Tea application (no-op).

func Update

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 View

func (m model) View() string

View renders the current progress bar, recent log lines, and, once finished, the final summary of the run.

func processLogMsg

func (m *model) processLogMsg(msg LogMsg)

type selectorModel

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
}

Methods

func Init

func (m *selectorModel) Init() tea.Cmd

func Update

func (m *selectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd)

func View

func (m *selectorModel) View() string

func applyFilter

func (m *selectorModel) applyFilter()

func executeSlashCommand

func (m *selectorModel) executeSlashCommand(cmdStr string) tea.Cmd

func renderCustomTable

func (m *selectorModel) renderCustomTable() string

func renderFooter

func (m *selectorModel) renderFooter() string

func renderHelpPanel

func (m *selectorModel) renderHelpPanel() string

func updateTableRows

func (m *selectorModel) updateTableRows()

Package mcp

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.

Functions

func DefaultAuditLogPath

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

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

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

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

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

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

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

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

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

func mutationID() string

func ownerMatchesURL

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

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

func redactCloneURL(raw string) string

func sortedLangCounts

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.

Types

type AuditRecord

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

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.

Methods

func Path

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 Write

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

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.

Methods

func Find

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 SafePath

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

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

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.

Methods

func AuditLogPath

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 MutationsEnabled

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 Root

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 ServeStdio

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 audit

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 auditRefusal

func (s *Server) auditRefusal(rec AuditRecord, message string) error

func beginMutation

func (s *Server) beginMutation(rec AuditRecord) (AuditRecord, error)

func cloneRepoTool

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 completeMutation

func (s *Server) completeMutation(rec AuditRecord, result, message string) error

func deleteRepoTool

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 explainWorkspacePrompt

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 findRepoTool

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 identifyStaleReposPrompt

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 invalidateScanCache

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 listReposTool

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 registerDestructiveTools

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 registerMutationTools

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 registerPrompts

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 registerResources

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 registerTools

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 repoFileResource

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 repoMetadataTool

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 repoStateResource

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 repoTreeResource

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 resolveURIRepo

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 scan

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 statusSummaryTool

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 syncRepoTool

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 workspaceIndexResource

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 workspaceIndexTool

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

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

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

type auditFile interface {
	Write([]byte) (int, error)
	Close() error
}

type syncTempFile

type syncTempFile interface {
	Write([]byte) (int, error)
	Close() error
	Name() string
}