/** * Core logic for `qfg push`. Pure-ish, dependency-injected, so tests can drive * it without hitting the network, touching git, or running validation. * * The oclif command (`src/commands/push.ts`) is a thin wrapper that fills in * real implementations of every dependency and calls `runPush`. * * The three guards from `project/plans/cli-git-sync.md` are all enforced here: * - Guards 1 + 2: `checkIdentity` cross-checks the requested target, repo * pin, and git origin against the backend-resolved workspace. * - Guard 3: `summarizeDiff` + `confirmYesNo`/`confirmTypedSlug` force the * user to acknowledge destructive changes. * * Dispatch between the "clone path" (the local dir is a clone of the cloud * repo) and the "bare path" (anything else — no .git, or a mismatched origin) * is decided after identity passes. * * `--yes` skips the normal Y/N confirmation. It NEVER skips a typed-slug * confirmation — that prompt is the destructive-change brake. */ import { FileDelta } from './diff-summary.js'; import type { CurrentBranchResult } from './git-pack.js'; export interface GiteaTokenMintResult { expiresAt: string | null; repoUrl: string; token: string; workspaceId: string; workspaceSlug: string; } export interface GitOps { /** * Pack-push — produce a packfile of `..` (or an * empty buffer when expectedSha === newSha). Capped at 25 MiB per §7 * open Q #7; throws on overflow so callers see a concrete error * before any HTTP traffic. */ buildPack(dir: string, expectedSha: string, newSha: string): Promise; /** * Pack-push — count of commits in `..`. Drives * the final "Pushed N commit(s)" log line. Returns 0 for a no-op * push (already at the tip) and best-effort for new branches with * a zero-OID base. */ countCommitsBetween(dir: string, expectedSha: string, newSha: string): Promise; /** Number of tracked files on origin/main (used for the destructive-ratio heuristic). */ countFilesInRemote(dir: string): Promise; /** * Produce the list of file deltas to render in the Guard 3 summary and * send to the server. Each delta carries before/after JSON content per * the `configs.push` wire shape (qfg-azk.13). For the clone path this is * HEAD vs origin/main; for the bare path it's local vs probe-clone. */ diffHeadVsOrigin(dir: string): Promise; /** * Tracked files (relative paths) with working-tree or staged changes. * Used by the clone-path dirty-tree warning to surface uncommitted * edits the user may believe are being pushed (qfg-fboj). Untracked * files are excluded — same rule as `dirtyTrackedFiles` in git-ops.ts. */ dirtyTrackedFiles(dir: string): Promise; /** `git fetch origin` in the given dir. */ fetch(dir: string): Promise; /** * Returns every configured remote URL on the repo (i.e. `git remote -v` * URLs, deduped per remote name). Used by the identity check to support * multi-remote workspaces where `origin` points at a customer's PR-review * remote (GitHub) and a secondary remote points at Quonfig (qfg-glrd.3). * * Returns an empty array when the dir isn't a git repo or has no remotes * configured. */ getAllRemoteUrls(dir: string): Promise; /** * Run `git log -1 --pretty=oneline ` and return the resulting line * (` `) or an empty string when the commit is not * present locally. Used by the qfg-7429.5 denial renderer to print the * offending commit's identity alongside the server's recovery message. */ getCommitOneline(dir: string, sha: string): Promise; /** * Pack-push (qfg-7429.4) — resolve HEAD as a branch name or refuse * cleanly. Returns the documented refusal text for detached HEAD and * the local default branch being `master`. The result drives the * `targetRef` the server sees on success. */ getCurrentBranch(dir: string): Promise; /** Pack-push — local HEAD SHA. Forwarded as `newSha` on the wire. */ getHeadSha(dir: string): Promise; /** * Returns the workspace-HEAD sha that the diff was computed against — * threaded into `configs.push` as `expectedSha` for the server-side * optimistic lock (qfg-gj3i). Clone path: the local `origin/main` SHA * after fetch. Bare path: the probe clone's HEAD. Undefined only when * neither is available; the server rejects a missing value with a 426. */ getOriginMainSha(dir: string): Promise; /** * Pack-push — local SHA for `origin/`, or undefined when * the remote-tracking ref doesn't exist yet (brand-new branch). * Drives `expectedSha`: defined → SHA, undefined → zero-OID. */ getRemoteBranchSha(dir: string, branchName: string): Promise; /** Returns the `remote.origin.url` for the repo, or undefined if unset / not a repo. */ getRemoteOriginUrl(dir: string): Promise; /** * qfg-7429.6 — return the tree SHA for `^{tree}`, or undefined when * the ref can't be resolved. Used by the legacy-divergence detector in * the pack-push conflict handler: workspaces created before pack-push * shipped have local commit SHAs that diverge from origin even when the * tree content is identical (the old server fabricated commits). If * `HEAD^{tree}` matches `origin/^{tree}`, the apparent conflict * is actually that one-shot legacy state, not a real concurrent push. */ getTreeShaForRef(dir: string, ref: string): Promise; /** Returns true if the dir has a `.git/` (worktree or repo). */ isGitRepo(dir: string): Promise; /** * Returns true if origin/main has commits the local HEAD does not have * (local strictly behind, or diverged). False when local is up-to-date * or strictly ahead of origin/main, or when origin/main is unknown. * * The clone-path stale-HEAD guard (qfg-fboj) refuses to push in either * "behind" or "diverged" state because both produce a diff that ships * REVERSAL deltas to the server, silently undoing remote-newer commits. */ isLocalBehindRemote(dir: string): Promise; /** Set origin to `url` (add if missing, set-url if present). */ setRemoteOrigin(dir: string, url: string): Promise; } /** Server-side `kind` enum (matches `FileDeltaSchema` in app-quonfig). */ export type ServerFileKind = 'add' | 'delete' | 'modify'; export interface ServerFileDelta { afterJson?: string; beforeJson?: string; kind: ServerFileKind; path: string; } export interface ConfigPushInput { expectedSha?: string; files: ServerFileDelta[]; message?: string; workspaceId: string; } export type ConfigPushResult = { kind: 'bad-request'; message: string; } | { kind: 'conflict'; message: string; } | { commitSha: string; kind: 'success'; } | { denials: PushDenial[]; kind: 'denied'; }; export interface PushDenial { path: string; reason: string; requiredPermission: string; } /** * Pack-push wire input for `configs.gitPush` (qfg-7429.4). The pack * itself stays raw on the CLI side; the HTTP client base64-encodes it * so the JSON envelope stays text-safe. */ export interface GitPushInput { /** Remote tip the CLI saw (zero-OID for a brand-new branch). */ expectedSha: string; /** * qfg-7429.5 / §6: true when at least one configured git remote on * the local clone does not normalize to the backend's Quonfig repo * URL. The server uses this to decide whether to attach the * GitHub-fork dead-end `suggestedRecovery` block to a 403 response. */ hasUpstreamRemote: boolean; /** Local HEAD being published. Server returns this back as commitSha. */ newSha: string; pack: Uint8Array; /** `refs/heads/main` or `refs/heads/`. */ targetRef: string; workspaceId: string; } /** * Pack-push denial. Carries `commitSha` so the CLI can name which * commit failed authz — the §6 GitHub-fork dead-end UX (rendered * fully in qfg-7429.5). */ export interface GitPushDenial { commitSha: string; path: string; reason: string; requiredPermission: string; } /** * Server-emitted recovery hint for the GitHub-fork dead-end (§6 of * the design plan). Currently the only `kind` is `revert-upstream`; * future kinds are forward-compatible because the discriminator is * tagged. */ export type SuggestedRecovery = { kind: 'revert-upstream'; offendingCommitSha: string; message: string; }; export type GitPushResult = { kind: 'success'; commitSha: string; ref: string; } | { kind: 'conflict'; message: string; } | { kind: 'bad-request'; message: string; } | { kind: 'denied'; denials: GitPushDenial[]; suggestedRecovery?: SuggestedRecovery; }; export type ConfirmIO = { input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream; }; export interface RunPushInput { /** Absolute path to the local dir the user is pushing. */ dir: string; /** * Global `--interactive` / `--no-interactive` flag (defaults to `true` * upstream). When explicitly false, runPush refuses to invoke any prompt * and instead aborts with a message that points the user at `--yes` (for * the standard Y/N) or explains that destructive pushes always require * interactive typed-slug confirmation. qfg-3uks Item B: the previous * behaviour was to fall through to the prompt, which immediately resolved * to a decline against a non-TTY stdin. */ interactive?: boolean; /** `--message` — optional commit message override (bare path only). */ message?: string; /** `--no-pin-write` — do not offer to write the slug pin into quonfig.json. */ noPinWrite: boolean; /** * Caller's resolved org slug for this workspace. Used to construct the * `/` pin when the backend's mint-token returns just the bare * workspace component (current backend behavior). Optional in tests; if * omitted, pin backfill only runs when the backend already returns the * slash form. */ orgSlug?: string; /** `--workspace` flag, or the active profile's workspace UUID. Must be non-empty. */ requestedTarget: string; /** `--skip-validate` — suppress the validate step. */ skipValidate: boolean; /** `--yes` — skip standard Y/N confirm. Never skips typed-slug. */ yes: boolean; } export interface RunPushDeps { /** Optional io streams for confirmation prompts. Defaults to process stdin/stdout. */ confirmIO?: ConfirmIO; /** Error logger; defaults to console.error. */ errLog?: (line: string) => void; gitOps: GitOps; /** Logger; defaults to console.log. */ log?: (line: string) => void; /** * Resolve the backend's identity for this workspace. Returns repoUrl, * workspaceSlug, workspaceId — used by the identity check and (for * read-only auth) by the bare-path probe-clone and clone-path fetch. * * Named `mintWriteToken` for historical reasons; as of qfg-azk.13 the * push code path no longer mints a write-scoped Gitea token (server-side * commit via `configs.push`), so the real implementation now mints a * read-scope token. The name is preserved to keep the test harness shape * stable across the qfg-azk.13 transition. */ mintWriteToken(requestedTarget: string): Promise; /** * Pack-push — call the server-side `configs.gitPush` oRPC procedure * (qfg-7429.4). Used by the clone-path dispatch; bare-path still * routes through `pushToServer` above. */ pushPackToServer(input: GitPushInput): Promise; /** * Call the server-side `configs.push` oRPC procedure. Returns a tagged * union so callers can distinguish success / per-file denials / a stale * expectedSha conflict / a path allow-list violation without parsing * HTTP status codes. */ pushToServer(input: ConfigPushInput): Promise; /** Run `qfg validate` semantics. Should throw on error. */ validate(dir: string): Promise<{ errors: string[]; }>; } export type RunPushResult = { kind: 'aborted'; reason: string; } | { kind: 'pushed'; dispatchedAs: 'clone-path' | 'bare-path'; commitSha?: string | null; } | { kind: 'no-op'; reason: string; }; export declare class PushFatalError extends Error { code: string; constructor(message: string, code?: string); } /** * qfg-glrd.6: the `Pushed-Via: cli` trailer was retired. Audit data * now lives in the server-side `push_events` table (qfg-glrd.4), so a * CLI-appended trailer would be redundant — and CLI trailers also * break SHA identity end-to-end once pack-push lands (the message the * user committed wouldn't match the message that travels to the server). * * Kept as a no-op shim instead of deleted so callers and tests don't * need a coordinated cross-repo rename. Will be removed once every * caller has been updated to drop the call entirely. */ export declare const withPushedViaTrailer: (message: string) => string; /** * Render the detail half of one push-denial line (qfg-szte). * * The server's `reason` is already a complete sentence: every family in * app-quonfig `authorize-push-files.ts` renders * `Missing required permission to edit : …`, so it * names both the path and the slug. The clone path used to append * `, requires ` on top of that (slug and path twice), and * the bare path used to print the slug alone and throw the reason away — so * the "why" (which environment is protected, which rule reaches the default) * never reached the user. * * Both now render the reason, and add back only the field it does not * already carry. That keeps the line honest against a server that sends a * terser reason than today's without ever duplicating anything. */ export declare function formatDenialDetail(d: { path: string; reason: string; requiredPermission: string; }): string; export declare function runPush(input: RunPushInput, deps: RunPushDeps): Promise;