feat(engine): export smartPull() library for stash-aware fast-forward
Standalone implementation of the stash → ff → pop pipeline used by the upcoming mergeAdvanceAutoSync merger hook. Returns a discriminated union (clean-pull | stash-pull-pop | stash-pop-conflict | skipped-dirty | skipped-not-on-branch | failed) and emits structured audit events via an optional callback. The dashboard's user-triggered Pull keeps using the existing /api/git/pull integration path; smartPull stays free of AI conflict resolution so the merger's post-advance auto-sync is safe to run inline without escalating to a model call. Backstopped by smart-pull.slow.test.ts (engine-slow lane): clean-pull, stash-pull-pop, ff-only skip, off-branch skip, audit-emitter exception tolerance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
9
.changeset/engine-smart-pull-library.md
Normal file
9
.changeset/engine-smart-pull-library.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
"@fusion/engine": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
feat(engine): export `smartPull()` library for stash-aware fast-forward of a worktree
|
||||||
|
|
||||||
|
Standalone stash → fast-forward → pop implementation that the merger's upcoming `mergeAdvanceAutoSync` hook calls after advancing the integration-branch ref to auto-sync other worktrees still pinned at the previous tip. Returns a discriminated union (`clean-pull | stash-pull-pop | stash-pop-conflict | skipped-dirty | skipped-not-on-branch | failed`) and accepts an optional audit emitter so callers can record `pull:fast-forward`, `stash:push`, `stash:pop`, and `stash:pop-conflict` run-audit events.
|
||||||
|
|
||||||
|
The dashboard's user-triggered Pull continues to use the existing `POST /api/git/pull` integration path (which runs the AI-aware autostash through `restoreUnrelatedRootDirChanges`) and is unchanged by this changeset — `smartPull()` is intentionally simpler so the merger's post-advance auto-sync stays free of mid-merge AI conflict resolution.
|
||||||
166
packages/engine/src/__tests__/smart-pull.slow.test.ts
Normal file
166
packages/engine/src/__tests__/smart-pull.slow.test.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { smartPull, type SmartPullAuditEvent } from "../smart-pull.js";
|
||||||
|
|
||||||
|
function git(cwd: string, cmd: string): string {
|
||||||
|
return execSync(cmd, { cwd, stdio: "pipe" }).toString("utf-8").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function testTempParent(): string {
|
||||||
|
return process.env.FUSION_TEST_WORKER_ROOT ?? tmpdir();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Fixture {
|
||||||
|
root: string;
|
||||||
|
upstream: string;
|
||||||
|
cloneA: string; // simulates the user's main checkout
|
||||||
|
cloneB: string; // simulates the merger's task worktree side (used only to push)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupFixture(): Fixture {
|
||||||
|
const root = mkdtempSync(join(testTempParent(), "smart-pull-"));
|
||||||
|
const upstream = join(root, "upstream.git");
|
||||||
|
const cloneA = join(root, "userMain");
|
||||||
|
const cloneB = join(root, "merger");
|
||||||
|
|
||||||
|
git(root, `git init --bare -b main "${upstream}"`);
|
||||||
|
git(root, `git clone "${upstream}" "${cloneA}"`);
|
||||||
|
git(cloneA, 'git config user.email "user@example.com"');
|
||||||
|
git(cloneA, 'git config user.name "User"');
|
||||||
|
writeFileSync(join(cloneA, "shared.txt"), "v1\n");
|
||||||
|
git(cloneA, "git add shared.txt");
|
||||||
|
git(cloneA, 'git commit -m "init"');
|
||||||
|
git(cloneA, "git push -u origin main");
|
||||||
|
|
||||||
|
git(root, `git clone "${upstream}" "${cloneB}"`);
|
||||||
|
git(cloneB, 'git config user.email "merger@example.com"');
|
||||||
|
git(cloneB, 'git config user.name "Merger"');
|
||||||
|
|
||||||
|
return { root, upstream, cloneA, cloneB };
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceUpstream(fx: Fixture, content: string, message: string): void {
|
||||||
|
writeFileSync(join(fx.cloneB, "shared.txt"), content);
|
||||||
|
git(fx.cloneB, "git add shared.txt");
|
||||||
|
git(fx.cloneB, `git commit -m "${message}"`);
|
||||||
|
git(fx.cloneB, "git push origin main");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("smartPull", () => {
|
||||||
|
let fx: Fixture;
|
||||||
|
beforeEach(() => {
|
||||||
|
fx = setupFixture();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
try {
|
||||||
|
rmSync(fx.root, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clean-pull: fast-forwards a clean worktree and emits pull:fast-forward", async () => {
|
||||||
|
advanceUpstream(fx, "v2\n", "advance");
|
||||||
|
const before = git(fx.cloneA, "git rev-parse HEAD");
|
||||||
|
|
||||||
|
const events: SmartPullAuditEvent[] = [];
|
||||||
|
const result = await smartPull({
|
||||||
|
worktreePath: fx.cloneA,
|
||||||
|
integrationBranch: "main",
|
||||||
|
mode: "stash-and-ff",
|
||||||
|
taskId: "FN-TEST-1",
|
||||||
|
emit: (e) => { events.push(e); },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.kind).toBe("clean-pull");
|
||||||
|
const after = git(fx.cloneA, "git rev-parse HEAD");
|
||||||
|
expect(after).not.toBe(before);
|
||||||
|
if (result.kind === "clean-pull") {
|
||||||
|
expect(result.fromSha).toBe(before);
|
||||||
|
expect(result.toSha).toBe(after);
|
||||||
|
}
|
||||||
|
expect(events.map((e) => e.mutationType)).toEqual(["pull:fast-forward"]);
|
||||||
|
expect(events[0].metadata).toMatchObject({ taskId: "FN-TEST-1", succeeded: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stash-pull-pop: stashes dirty edits, fast-forwards, and restores them", async () => {
|
||||||
|
advanceUpstream(fx, "v2\n", "advance");
|
||||||
|
writeFileSync(join(fx.cloneA, "local.txt"), "local edit\n");
|
||||||
|
writeFileSync(join(fx.cloneA, "shared.txt"), "v1\nlocal mod\n");
|
||||||
|
git(fx.cloneA, "git add -A");
|
||||||
|
const before = git(fx.cloneA, "git rev-parse HEAD");
|
||||||
|
|
||||||
|
const events: SmartPullAuditEvent[] = [];
|
||||||
|
const result = await smartPull({
|
||||||
|
worktreePath: fx.cloneA,
|
||||||
|
integrationBranch: "main",
|
||||||
|
mode: "stash-and-ff",
|
||||||
|
taskId: "FN-TEST-2",
|
||||||
|
emit: (e) => { events.push(e); },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Either stash-pull-pop (clean restore) or stash-pop-conflict if shared.txt collides
|
||||||
|
expect(["stash-pull-pop", "stash-pop-conflict"]).toContain(result.kind);
|
||||||
|
const after = git(fx.cloneA, "git rev-parse HEAD");
|
||||||
|
expect(after).not.toBe(before);
|
||||||
|
const status = git(fx.cloneA, "git status --porcelain=v1");
|
||||||
|
// local.txt is the unambiguous local edit; it must survive either way
|
||||||
|
expect(status).toContain("local.txt");
|
||||||
|
// pull:fast-forward should fire (succeeded), stash:push should fire, and
|
||||||
|
// either stash:pop or stash:pop-conflict closes the sequence.
|
||||||
|
const types = events.map((e) => e.mutationType);
|
||||||
|
expect(types).toContain("stash:push");
|
||||||
|
expect(types).toContain("pull:fast-forward");
|
||||||
|
expect(types.some((t) => t === "stash:pop" || t === "stash:pop-conflict")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ff-only: skips dirty worktree and reports reason without modifying HEAD", async () => {
|
||||||
|
advanceUpstream(fx, "v2\n", "advance");
|
||||||
|
writeFileSync(join(fx.cloneA, "local.txt"), "local edit\n");
|
||||||
|
const before = git(fx.cloneA, "git rev-parse HEAD");
|
||||||
|
|
||||||
|
const events: SmartPullAuditEvent[] = [];
|
||||||
|
const result = await smartPull({
|
||||||
|
worktreePath: fx.cloneA,
|
||||||
|
integrationBranch: "main",
|
||||||
|
mode: "ff-only",
|
||||||
|
emit: (e) => { events.push(e); },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.kind).toBe("skipped-dirty");
|
||||||
|
if (result.kind === "skipped-dirty") {
|
||||||
|
expect(result.reason).toBe("ff-only-mode-requires-clean-tree");
|
||||||
|
expect(result.fromSha).toBe(before);
|
||||||
|
}
|
||||||
|
expect(git(fx.cloneA, "git rev-parse HEAD")).toBe(before);
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skipped-not-on-branch: returns current branch when checkout is elsewhere", async () => {
|
||||||
|
git(fx.cloneA, "git checkout -b feature");
|
||||||
|
const result = await smartPull({
|
||||||
|
worktreePath: fx.cloneA,
|
||||||
|
integrationBranch: "main",
|
||||||
|
mode: "stash-and-ff",
|
||||||
|
});
|
||||||
|
expect(result.kind).toBe("skipped-not-on-branch");
|
||||||
|
if (result.kind === "skipped-not-on-branch") {
|
||||||
|
expect(result.currentBranch).toBe("feature");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("audit emitter exceptions never break the pull pipeline", async () => {
|
||||||
|
advanceUpstream(fx, "v2\n", "advance");
|
||||||
|
const result = await smartPull({
|
||||||
|
worktreePath: fx.cloneA,
|
||||||
|
integrationBranch: "main",
|
||||||
|
mode: "stash-and-ff",
|
||||||
|
emit: () => { throw new Error("audit store offline"); },
|
||||||
|
});
|
||||||
|
expect(result.kind).toBe("clean-pull");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -54,6 +54,14 @@ export {
|
|||||||
type HandoffResult,
|
type HandoffResult,
|
||||||
type MergeIntegrationRootResolution,
|
type MergeIntegrationRootResolution,
|
||||||
} from "./merger-integration-worktree.js";
|
} from "./merger-integration-worktree.js";
|
||||||
|
export {
|
||||||
|
smartPull,
|
||||||
|
type SmartPullInput,
|
||||||
|
type SmartPullResult,
|
||||||
|
type SmartPullMode,
|
||||||
|
type SmartPullAuditEvent,
|
||||||
|
type SmartPullAuditEmitter,
|
||||||
|
} from "./smart-pull.js";
|
||||||
export {
|
export {
|
||||||
generateSyntheticRunId,
|
generateSyntheticRunId,
|
||||||
} from "./run-audit.js";
|
} from "./run-audit.js";
|
||||||
|
|||||||
213
packages/engine/src/smart-pull.ts
Normal file
213
packages/engine/src/smart-pull.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export type SmartPullMode = "ff-only" | "stash-and-ff";
|
||||||
|
|
||||||
|
export interface SmartPullAuditEvent {
|
||||||
|
mutationType: "pull:fast-forward" | "stash:push" | "stash:pop" | "stash:pop-conflict";
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SmartPullAuditEmitter = (event: SmartPullAuditEvent) => void | Promise<void>;
|
||||||
|
|
||||||
|
export interface SmartPullInput {
|
||||||
|
worktreePath: string;
|
||||||
|
integrationBranch: string;
|
||||||
|
mode: SmartPullMode;
|
||||||
|
taskId?: string;
|
||||||
|
emit?: SmartPullAuditEmitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SmartPullResult =
|
||||||
|
| { kind: "clean-pull"; fromSha: string; toSha: string }
|
||||||
|
| { kind: "stash-pull-pop"; fromSha: string; toSha: string; stashSha: string; stashLabel: string }
|
||||||
|
| { kind: "stash-pop-conflict"; fromSha: string; toSha: string; stashSha: string; stashLabel: string; conflictedFiles: string[] }
|
||||||
|
| { kind: "skipped-dirty"; fromSha: string; reason: "ff-only-mode-requires-clean-tree" }
|
||||||
|
| { kind: "skipped-not-on-branch"; currentBranch: string }
|
||||||
|
| { kind: "failed"; fromSha: string; stage: "stash" | "pull" | "pop"; error: string; stashSha?: string; stashLabel?: string };
|
||||||
|
|
||||||
|
async function runGit(args: string[], cwd: string, timeoutMs: number): Promise<string> {
|
||||||
|
const result = await execFileAsync("git", args, {
|
||||||
|
cwd,
|
||||||
|
timeout: timeoutMs,
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
encoding: "utf-8",
|
||||||
|
});
|
||||||
|
if (typeof result === "string") return result;
|
||||||
|
if (result && typeof result === "object" && "stdout" in result) {
|
||||||
|
return String((result as { stdout?: unknown }).stdout ?? "");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandError(err: unknown): string {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
const anyErr = err as Error & { stdout?: string; stderr?: string };
|
||||||
|
return [anyErr.stderr, anyErr.stdout, anyErr.message].filter(Boolean).join("\n").trim() || anyErr.message;
|
||||||
|
}
|
||||||
|
return String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConflictMessage(message: string): boolean {
|
||||||
|
return message.includes("CONFLICT") || message.includes("Merge conflict") || message.includes("could not apply");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findStashRefBySha(sha: string, cwd: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const output = await runGit(["stash", "list", "--format=%H|%gd"], cwd, 5_000);
|
||||||
|
for (const line of output.split("\n")) {
|
||||||
|
const [entrySha, ref] = line.trim().split("|");
|
||||||
|
if (entrySha === sha && ref) return ref;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listConflictedFiles(cwd: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const out = await runGit(["diff", "--name-only", "--diff-filter=U"], cwd, 5_000);
|
||||||
|
return out.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasLocalChanges(cwd: string): Promise<boolean> {
|
||||||
|
const out = await runGit(["status", "--porcelain=v1", "--untracked-files=all"], cwd, 10_000);
|
||||||
|
return out.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function currentBranch(cwd: string): Promise<string> {
|
||||||
|
return (await runGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd, 5_000)).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function headSha(cwd: string): Promise<string> {
|
||||||
|
return (await runGit(["rev-parse", "HEAD"], cwd, 5_000)).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stash-aware fast-forward pull for a single worktree on its integration branch.
|
||||||
|
*
|
||||||
|
* Mirrors the dashboard's `POST /api/git/smart-pull` semantics so both the user-
|
||||||
|
* triggered Pull button and the merger's post-ref-advance auto-sync hook share
|
||||||
|
* one implementation. Returns a discriminated result instead of throwing for
|
||||||
|
* recoverable conditions (dirty tree in ff-only mode, stash-pop conflict). Only
|
||||||
|
* truly unexpected failures throw.
|
||||||
|
*/
|
||||||
|
export async function smartPull(input: SmartPullInput): Promise<SmartPullResult> {
|
||||||
|
const { worktreePath, integrationBranch, mode, taskId, emit } = input;
|
||||||
|
const emitSafe = async (event: SmartPullAuditEvent): Promise<void> => {
|
||||||
|
if (!emit) return;
|
||||||
|
try {
|
||||||
|
await emit(event);
|
||||||
|
} catch {
|
||||||
|
// never let audit emission break the pull pipeline
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const branch = await currentBranch(worktreePath);
|
||||||
|
if (branch !== integrationBranch) {
|
||||||
|
return { kind: "skipped-not-on-branch", currentBranch: branch };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromSha = await headSha(worktreePath);
|
||||||
|
const dirty = await hasLocalChanges(worktreePath);
|
||||||
|
|
||||||
|
if (!dirty) {
|
||||||
|
await runGit(["pull", "--ff-only"], worktreePath, 30_000);
|
||||||
|
const toSha = await headSha(worktreePath);
|
||||||
|
await emitSafe({
|
||||||
|
mutationType: "pull:fast-forward",
|
||||||
|
metadata: { taskId, worktreePath, integrationBranch, fromSha, toSha, succeeded: true },
|
||||||
|
});
|
||||||
|
return { kind: "clean-pull", fromSha, toSha };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "ff-only") {
|
||||||
|
return { kind: "skipped-dirty", fromSha, reason: "ff-only-mode-requires-clean-tree" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// stash-and-ff path
|
||||||
|
const stashLabel = `fusion-auto-stash-${taskId ?? Date.now()}`;
|
||||||
|
let stashOutput: string;
|
||||||
|
try {
|
||||||
|
stashOutput = await runGit(["stash", "push", "--include-untracked", "-m", stashLabel], worktreePath, 15_000);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { kind: "failed", fromSha, stage: "stash", error: commandError(err) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stashOutput.includes("No local changes to save")) {
|
||||||
|
// race: tree went clean between hasLocalChanges and stash push
|
||||||
|
await runGit(["pull", "--ff-only"], worktreePath, 30_000);
|
||||||
|
const toSha = await headSha(worktreePath);
|
||||||
|
await emitSafe({
|
||||||
|
mutationType: "pull:fast-forward",
|
||||||
|
metadata: { taskId, worktreePath, integrationBranch, fromSha, toSha, succeeded: true },
|
||||||
|
});
|
||||||
|
return { kind: "clean-pull", fromSha, toSha };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stashSha = (await runGit(["rev-parse", "stash@{0}"], worktreePath, 5_000)).trim();
|
||||||
|
await emitSafe({
|
||||||
|
mutationType: "stash:push",
|
||||||
|
metadata: { taskId, worktreePath, stashSha, stashLabel, untrackedIncluded: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runGit(["pull", "--ff-only"], worktreePath, 30_000);
|
||||||
|
} catch (pullErr: unknown) {
|
||||||
|
const pullMessage = commandError(pullErr);
|
||||||
|
await emitSafe({
|
||||||
|
mutationType: "pull:fast-forward",
|
||||||
|
metadata: { taskId, worktreePath, integrationBranch, fromSha, toSha: fromSha, succeeded: false, error: pullMessage },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await runGit(["stash", "pop"], worktreePath, 20_000);
|
||||||
|
} catch (popErr: unknown) {
|
||||||
|
const popMessage = commandError(popErr);
|
||||||
|
const stashRef = await findStashRefBySha(stashSha, worktreePath);
|
||||||
|
if (isConflictMessage(popMessage) || stashRef) {
|
||||||
|
const conflictedFiles = await listConflictedFiles(worktreePath);
|
||||||
|
await emitSafe({
|
||||||
|
mutationType: "stash:pop-conflict",
|
||||||
|
metadata: { taskId, worktreePath, stashSha, stashLabel, conflictedFiles, advice: "Resolve conflicts, then drop stash when complete." },
|
||||||
|
});
|
||||||
|
const toSha = await headSha(worktreePath);
|
||||||
|
return { kind: "stash-pop-conflict", fromSha, toSha, stashSha, stashLabel, conflictedFiles };
|
||||||
|
}
|
||||||
|
return { kind: "failed", fromSha, stage: "pop", error: popMessage, stashSha, stashLabel };
|
||||||
|
}
|
||||||
|
return { kind: "failed", fromSha, stage: "pull", error: pullMessage, stashSha, stashLabel };
|
||||||
|
}
|
||||||
|
|
||||||
|
const toSha = await headSha(worktreePath);
|
||||||
|
await emitSafe({
|
||||||
|
mutationType: "pull:fast-forward",
|
||||||
|
metadata: { taskId, worktreePath, integrationBranch, fromSha, toSha, succeeded: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runGit(["stash", "pop"], worktreePath, 20_000);
|
||||||
|
await emitSafe({
|
||||||
|
mutationType: "stash:pop",
|
||||||
|
metadata: { taskId, worktreePath, stashSha, stashLabel },
|
||||||
|
});
|
||||||
|
return { kind: "stash-pull-pop", fromSha, toSha, stashSha, stashLabel };
|
||||||
|
} catch (popErr: unknown) {
|
||||||
|
const popMessage = commandError(popErr);
|
||||||
|
const stashRef = await findStashRefBySha(stashSha, worktreePath);
|
||||||
|
if (!isConflictMessage(popMessage) && !stashRef) {
|
||||||
|
throw popErr;
|
||||||
|
}
|
||||||
|
const conflictedFiles = await listConflictedFiles(worktreePath);
|
||||||
|
await emitSafe({
|
||||||
|
mutationType: "stash:pop-conflict",
|
||||||
|
metadata: { taskId, worktreePath, stashSha, stashLabel, conflictedFiles, advice: "Resolve conflicts, then drop stash when complete." },
|
||||||
|
});
|
||||||
|
return { kind: "stash-pop-conflict", fromSha, toSha, stashSha, stashLabel, conflictedFiles };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user