/** * GitConnector — git repository filesystem-face connector. * Mounts a git repo by cloning/pulling to a local directory. * Session-aware: auto-branching, lifecycle hooks (hibernate/close), periodic fetch/rebase. * * Everything in this file that touches `mountPath` is asynchronous and * deadline-bounded. Inside the agent container that path is the rclone FUSE * mount, and a synchronous `execSync` / `fs` call there parks the entire * single-threaded event loop in an uninterruptible kernel wait — a hang, not an * error, so `try/catch` never fires and the whole session dies (issue #454). */ import type { ConnectContext, ConnectorChangeEvent, ConnectorDeclaration, ConnectorHandle, FilesystemFace, ToolFace, WatchHandle, WatchOptions } from "@skaile/workspaces/connectors"; import { AbstractConnector } from "@skaile/workspaces/connectors"; interface GitSessionConfig { enabled: boolean; slug?: string; isMain: boolean; branchPrefix: string; /** * Starting point for a freshly-created session branch. When set and the * session branch does not already exist on origin, the driver fetches this * ref and branches from it instead of from the base branch tip. Used by * the platform's fork flow so a forked session branches off the source * session's tip rather than off `main`. May be a branch name (e.g. * `skaile/session-source-slug`) or a commit SHA. Ignored when the session * branch already exists on origin (existing branch wins). No-op when * `enabled = false` or `isMain = true` (only branch sessions cut new * branches). The value is validated against `isValidGitRefOrSha` before * use; refs with shell metacharacters are rejected and the driver falls * back to branching off base. */ fromRef?: string; } interface GitSyncConfig { fetchIntervalSec: number; autoRebase: boolean; autoPull: boolean; } interface GitLifecycleConfig { commitOnHibernate: boolean; pushOnHibernate: boolean; mergeOnClose: boolean; pushOnClose: boolean; /** Auto-commit after N ms of no file changes. 0 = disabled. Default: 30000 (30s). */ autoCommitIdleMs: number; /** Push to remote after each auto-commit. */ pushAfterCommit: boolean; /** Push freshly-created session branch to origin on connect. Best-effort — push * failures are swallowed; lifecycle push hooks remain the backstop. */ publishOnCreate: boolean; } /** * Resolve the session/sync/lifecycle config blocks for a git connector. `mode` * seeds per-block defaults (`agent` / `sync`); explicit `options.{session,sync, * lifecycle}` keys still override those defaults. Absent `mode` reproduces the * legacy literals exactly, so behavior is unchanged for declarations that set none. */ declare function resolveGitConfig(args: { mode?: "agent" | "sync"; options?: Record; }): { session: GitSessionConfig; sync: GitSyncConfig; lifecycle: GitLifecycleConfig; }; /** * Snapshot returned by the `sync_status` tool op. JSON-serialised over the * transport; the platform overlays `available`/`containerAwake`/`reason` * before handing it to the UI badge. */ export interface GitSyncStatusResult { /** Current branch name, or `null` (detached HEAD / unavailable). */ branch: string | null; /** Working tree has uncommitted changes (`git status --porcelain` non-empty). */ dirty: boolean; /** Commits on HEAD not on its upstream — i.e. unpushed. */ ahead: number; /** Commits on the upstream not on HEAD — i.e. unpulled (since last fetch). */ behind: number; /** * Whether the current branch has a configured upstream. `false` ⇒ the branch * has never been pushed; `ahead`/`behind` read as 0 (no remote-tracking ref * to diff against), so this distinguishes "never pushed" from "synced". */ hasUpstream: boolean; /** Commits on HEAD not on `origin/` — diverged ahead of base. */ mainAhead: number; /** Commits on `origin/` not on HEAD — diverged behind base. */ mainBehind: number; /** Current HEAD commit SHA, or `null` when unavailable. */ commitSha: string | null; } /** * Read git sync status for an in-container checkout. Pure and never throws — * every git call degrades to a safe default. No network fetch: * ahead/behind reflect locally-known remote-tracking refs ("since last sync"). * `baseBranch` is the mount's tracked base (e.g. "main"); base divergence is * measured against `origin/`. * * Asynchronous since v3: every `git` call it makes runs against a path that may * be a network mount, and the synchronous version blocked the event loop there. */ export declare function readGitSyncStatus(cwd: string, baseBranch: string): Promise; /** * Filesystem-face connector for git repositories — clones or pulls on connect, supports * session branching, periodic fetch/rebase, and lifecycle hooks (hibernate commit/push, * session close merge). Also supports Tier-2 managed credential injection via * `GIT_CONFIG_GLOBAL` for agent-facing `git` CLI access. * * Token acquisition for `auth: backend` connectors is mediated by the runner: the * connector calls the injected `tokenMediator` callback (which sends a * `request_access_token` command over the transport and awaits the matching * `access_token_response` event). Local provider minting (OAuth, GitHub App) * has been removed — the platform owns dispatch. * * @docLink packages/factory-assets/concepts#git-connector */ export declare class GitConnector extends AbstractConnector { readonly name = "git"; readonly filesystem: FilesystemFace; readonly tools: ToolFace; private _describeOperations; private _executeOp; connect(declaration: ConnectorDeclaration, ctx: ConnectContext): Promise; /** * Bring `targetDir` into a usable checkout state for `connect()`. * * Minimal-connect policy: `connect()` never auto-syncs with the remote. * The lifecycle hooks (`pushOnHibernate`, `pushAfterCommit`, * `sync.autoPull`) and the explicit `filesystem.sync()` method are the * only paths that touch the network with the existing checkout. Recreating * a container is therefore idempotent — the on-disk dir is preserved. * * Three cases: * - `.git` exists → use as-is. No pull, no reset. * - dir empty → fresh clone (only network fetch on connect), or * `bootstrapEmptyRemote` when the remote has no refs at all. * - dir populated, no `.git` → `git init` + `remote add origin` so * lifecycle hooks (commit/push) can do their job. No fetch, no * reset, no clean. Existing files become uncommitted-on-init; the * agent or `pushAfterCommit` reconciles with remote later. */ private prepareCheckout; /** * Create and check out the session branch for a non-main session, updating * `state.activeBranch`. Reuses an existing local branch (unpushed work); * else prefers `origin/`; otherwise branches off `fromRef` (platform * fork flow) when supplied and valid, else off the currently-checked-out * HEAD (== base branch). */ private checkoutSessionBranch; /** * Attempt to create `sessionBranch` anchored on `session.fromRef`. Returns * true when the branch was created from the anchor; false when no valid * `fromRef` was supplied or the fetch failed (caller falls back to base). */ private branchFromRef; /** * Fleet-managed connect: verify the host bind, register an explorer watcher, * no-op every git lifecycle hook. Spec: `_devlog/specs/2026-05-28-git-fleet-mode-design.md`. */ private connectFleetManaged; /** * Public refresh entry-point used by the runner's wake-mid-401 handler when * the credential-helper script touches the workspace refresh-flag. Calls * the same internal refresh path as the scheduled timer, but with * `reason: 'retry-401'` so the platform can distinguish proactive refreshes * from emergency ones in audit logs. * * No-op when: * - the handle was not minted with `exposeAccessToken: true` * (no `managedGitconfig` on state), or * - the connector was not provisioned with a `tokenMediator` (PAT path — * the script will keep serving the static token and the user must * rotate the PAT manually). * * Spec: `_devlog/specs/2026-05-07-unified-credential-mediation.md` Step 9a. */ refreshExposedCredential(connectorId: string, handle: ConnectorHandle): Promise; disconnect(handle: ConnectorHandle): Promise; private _sync; watch(handle: ConnectorHandle, callback: (event: ConnectorChangeEvent) => void, options?: WatchOptions): WatchHandle; onHibernate(handle: ConnectorHandle): Promise; onSessionClose(handle: ConnectorHandle): Promise; /** * Scope the credential helper to the sanitized full repository URL. * Userinfo, query, and fragment data must never enter managed gitconfig. */ private deriveCredentialScope; /** * Acquire the initial credential for a connector. * * `auth:` accepts the post-2026-05-06 grammar: * - `backend` → request a token from the platform mediator * (preferred for any platform-backed session). * - `pat:env:NAME` → static token from the secrets chain * (standalone CLI / non-platform contexts). * * Anonymous connectors (`auth` unset) succeed without a token — the * `git clone`/`git pull` calls still work for public repos. * * @returns The parsed auth ref, the resolved token (when any), and the * `expiresAt` timestamp surfaced by the mediator (used to schedule a * refresh tick when the credential is exposed to the agent CLI). */ private acquireInitialToken; /** * Register a credential helper that *reports* why there is no credential, * after this connector failed to connect. * * Without it git finds no helper for the host and asks the terminal for a * username, so the agent sees `fatal: could not read Username for * 'https://github.com'` — which names neither the mount nor the cause, and * which only ever appears on a write, since reads against a public remote * keep succeeding. The helper written here makes git stop with the mediator's * actual refusal text instead. * * Entirely best-effort: it runs on the failure path and must never mask the * connect error that is already on its way up. */ private registerUnavailableCredentialHelper; /** * Best-effort pre-creation of the helper's refresh-attempt stamp. A missing * stamp only costs the cooldown (the helper dances on every git op instead of * once per window), so a failure here is never worth surfacing. */ private ensureAttemptStamp; /** * Best-effort removal of the unavailable-mount blocks that would answer this * connector's git operations: its own from a previous failed session, and any * left under the same repository scope by a sibling mount that failed earlier * in this `connectAll` (git matches helpers by URL, not by mount id). */ private clearUnavailableCredentialHelper; private exposeManagedCredential; /** * Schedule the next refresh of the credential file. * * Cancels any existing timer first so re-connects (and the test harness) * do not double-fire. Uses a 5-minute lead per spec; if the lead is * negative (very short-lived test tokens) we fall back to a tiny delay * so the timer still fires in deterministic order. */ private scheduleRefresh; private refreshManagedCredential; private teardownManagedCredential; /** * Bring a populated, non-git directory under git tracking without * touching the working tree. Runs only on the "dir populated, no `.git`" * branch of `connect()` — exactly when a previous connect aborted before * `.git` was written, or when the bind-mounted host dir's `.git` FILE * pointed outside the container. * * The intent is to preserve whatever the agent last had on disk. We * therefore: * - `git init` — creates `.git/`; does not modify any existing file. * - `git remote add origin ` — wires up the remote so the * normal lifecycle hooks (`pushOnHibernate`, `pushAfterCommit`) * have a target. * * Deliberately omitted: * - `fetch` — would touch the network on every recreate. The lifecycle * handles sync explicitly (periodic `fetch`/`pull` via * `sync.autoPull`, push on hibernate, etc.). * - `checkout -f` / `reset --hard` / `clean -fd` — would discard the * user's last on-disk state. The agent or autocommit reconciles * with the remote later. * * Token persistence: the auth URL gets written into `.git/config` * exactly like the `git clone ` path does — token-rotation * handling is external to this method (managed-gitconfig credential * helper refresh in Tier-2 connectors). */ private initInPlace; /** * Count `refs/heads/*` on `authUrl`, optionally narrowed to one `branch`. * * Returns `null` when the probe itself fails (network, auth, bad URL). * "Cannot tell" must never collapse into "no refs": the empty-remote * fallback WRITES to origin, and every caller therefore treats `null` as * "assume populated" and falls through to the path that fails loudly. */ private countRemoteHeads; /** * Bootstrap a checkout against a remote that has no refs at all — the state * of a GitHub repo created without "Add a README". * * A plain `git clone` is NOT a substitute: cloning a ref-less remote leaves * local HEAD on git's `init.defaultBranch` (`master`), not the declared * branch, so the branch is created explicitly here. * * The root commit is created REGARDLESS of `access`, and only the push to * origin is gated on it. An unborn HEAD is not a stable resting state: the * next connect sees `.git` with no resolvable HEAD, routes to * `bootstrapFromOrigin`, and its `fetch origin ` dies on the still * ref-less remote — turning a fixed first connect into a broken second one. * A local commit costs a read-only connector nothing (it is never pushed) * and makes reconnect take the cheap "use as-is" path. * * With the push done, `origin/` exists for everything downstream — * notably `checkoutSessionBranch`, whose publish-on-create push needs a * commit to point at. * * Invariant: this method never throws on a failed push. The session must * come up with a usable checkout even when origin is unreachable — the * lifecycle hooks (`pushAfterCommit`, hibernate/close push) publish later. */ private bootstrapEmptyRemote; /** * True when `/.git` contains at least one resolvable commit * (i.e. `HEAD` points at a real object). False when `.git` is an empty * shell — e.g. just-initialised but never fetched, or all refs deleted. * * Used by `connect()` to tell the difference between a working agent * checkout (preserve, don't touch) and a half-initialised one * (bootstrap by fetching from origin). */ private isInitialisedCheckout; /** * One-shot bootstrap path for the "`.git` exists but is empty" state. * * Runs `fetch --depth=1 origin ` followed by `checkout -B * origin/` so the working tree gets the remote * content. Before the checkout, any untracked file whose path also * exists in the remote tree is renamed to `.local` so the user * (or previous agent run) does not lose data. Other untracked files * are left in place. * * Deliberately not destructive: we never `clean -fd` and never * `reset --hard`. Files that do not conflict with the remote stay * exactly where they are. * * Auth-URL handling matches `initInPlace`: a pre-existing `origin` * remote is replaced with the authed URL so the fetch works even * after token rotation. This persists the token in `.git/config` * the same way `git clone ` does — token-rotation handling * is external to this connector method. */ private bootstrapFromOrigin; /** * Walk the remote tree at `origin/` and rename any untracked * working-tree file whose path also exists there. Renames go to * `.local`; on `.local` collision a millisecond suffix is * appended so nothing is overwritten. * * This makes the subsequent `git checkout -B origin/` * safe: without it, `checkout` aborts with "untracked working tree * files would be overwritten" the moment any local file collides with * a tracked path on the remote. * * Files in the local tree whose paths are NOT in the remote tree are * left untouched — they are pure local additions and survive the * bootstrap. */ private preserveConflictingUntracked; private authUrl; /** * Align `origin` with the exact sanitized URL used by the Tier-2 helper. * Git does not normalize query/fragment data, path case, or `.git` aliases * when matching `credential.` sections, so the two strings must agree. * The declared mount source remains authoritative over an existing remote. */ private alignRemoteWithCredentialScope; /** * Tier-1 only: rewrite `origin` so it carries the token freshly minted on * this connect. The "use as-is" wake path leaves whatever token the first * connect baked in — long-dead for short-lived (GitHub App) tokens. Without * a Tier-2 helper this URL token is the sole credential, so it must be * refreshed here. No-op for anonymous (no token) or non-https remotes. */ private rebakeRemoteAuth; private startPeriodicFetch; /** One periodic-fetch tick. Bails out at each resumption point once disconnected. */ private runPeriodicFetch; /** * Attempt to resolve merge/rebase conflicts by keeping "ours" and copying * "theirs" to a .conflict file. If resolution fails entirely, abort. * Returns true if conflicts were resolved, false if aborted. */ private resolveConflictsOrAbort; /** * For a single conflicted file: keep "ours", save "theirs" as .conflict file. */ private resolveFileConflict; private stripConflictKeepOurs; private resetAutoCommitTimer; private performAutoCommit; private generateCommitMessage; } /** * Creates a new GitConnector instance. * @returns Configured GitConnector ready to be registered in the connector registry. * @docLink packages/factory-assets/api-reference#git-connector-factory */ export declare function createConnector(): GitConnector; /** Test-only surface — exposes the mode→config resolver for unit assertions. */ export declare const __test: { resolveGitConfig: typeof resolveGitConfig; }; export {}; //# sourceMappingURL=driver.d.ts.map