Sync from monorepo 2026-07-17T10:59:31Z
Sync-Origin: forward/main/2026-07-17T10:59:31Z
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2.4.0
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **`josh-sync rename --subfolder` now moves the actual directory.** Previously a `--subfolder` rename only repointed `.josh-sync.yml` (and the `josh_filter`/state), leaving the monorepo working tree out of sync with the new path until a later sync's filter-change reconciliation happened to catch it. It now `git mv`s the subfolder in the working tree as part of the rename (staged, not committed — reviewed alongside the config edit like everything else `rename` writes). Idempotent: a re-run after a partial move (new path exists, old one doesn't) detects it and skips straight to updating state.
|
||||||
|
- **New validation before any subfolder move**: the old subfolder must exist and be tracked by git (`git ls-files` under that path non-empty — proof it's actually managed by josh-sync, not a stale/misconfigured path), and the new subfolder must not already exist. Neither check is bypassable with `--force`.
|
||||||
|
|
||||||
## 2.3.1
|
## 2.3.1
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|||||||
+5
-2
@@ -687,14 +687,17 @@ josh-sync rename billing --name payments --dry-run # preview only, no writes
|
|||||||
josh-sync rename billing --name payments --yes # skip the confirmation prompt
|
josh-sync rename billing --name payments --yes # skip the confirmation prompt
|
||||||
```
|
```
|
||||||
|
|
||||||
What it does, in order: resolves the target (and detects a resumed run if the config already shows the new name), validates the new name/URL, checks for recent sync activity on the target (best-effort concurrency heuristic — warns/aborts unless `--force`), refuses to proceed if state already exists under the new name (never forceable), edits `.josh-sync.yml` and re-validates it, then moves every file under `<target>/` on `josh-sync-state` to the new prefix in one commit.
|
What it does, in order: resolves the target (and detects a resumed run if the config already shows the new name), validates the new name/URL, checks for recent sync activity on the target (best-effort concurrency heuristic — warns/aborts unless `--force`), refuses to proceed if state already exists under the new name (never forceable), validates the subfolder move if `--subfolder` was given (see below), edits `.josh-sync.yml` and re-validates it, `git mv`s the subfolder in the monorepo working tree, then moves every file under `<target>/` on `josh-sync-state` to the new prefix in one commit.
|
||||||
|
|
||||||
|
**`--subfolder` moves the actual directory.** `josh-sync rename` must be run from inside the monorepo working tree (same as where `.josh-sync.yml` lives). Before writing anything, it checks that the old subfolder exists **and** is tracked by git (`git ls-files` under that path is non-empty — proof it's actually "managed by josh-sync", not a stale/misconfigured path) and that the new subfolder doesn't already exist. It then runs `git mv <old> <new>` — staged, like the config edit, but **not committed**; review the combined diff (moved files + `.josh-sync.yml`) and commit it yourself. This is idempotent: if you already moved the directory by hand (or a prior run got partway through), a re-run detects the new path already existing and the old one gone, and skips straight to updating state.
|
||||||
|
|
||||||
**What it does NOT do:**
|
**What it does NOT do:**
|
||||||
- Rename or move the repository on the git host — do that first (Gitea/GitHub UI or API), then run `josh-sync rename` to update josh-sync's own bookkeeping.
|
- Rename or move the repository on the git host — do that first (Gitea/GitHub UI or API), then run `josh-sync rename` to update josh-sync's own bookkeeping.
|
||||||
- Rename `auto-sync/mono-*`/`auto-sync/subrepo-*` conflict/staging branches — they never carry the target name, so there's nothing to rename. An abandoned onboarding's `auto-sync/import-<old-name>-*` branch (which does carry the name) is only flagged with a warning; merge or delete it manually.
|
- Rename `auto-sync/mono-*`/`auto-sync/subrepo-*` conflict/staging branches — they never carry the target name, so there's nothing to rename. An abandoned onboarding's `auto-sync/import-<old-name>-*` branch (which does carry the name) is only flagged with a warning; merge or delete it manually.
|
||||||
- Guarantee `.josh-sync.yml`'s comments survive byte-for-byte — the edit is a scoped `yq -i`, which may reflow surrounding formatting.
|
- Guarantee `.josh-sync.yml`'s comments survive byte-for-byte — the edit is a scoped `yq -i`, which may reflow surrounding formatting.
|
||||||
|
- Commit or push the config edit / directory move to the monorepo — both are left staged in your working tree for review, same as any other local change.
|
||||||
|
|
||||||
**Concurrency**: the heuristic checks for a `josh-sync-state` commit under the target within the last 5 minutes. It's best-effort — a sync that starts after the check but before rename's own push can still race. `--force` bypasses only this check; a destination-name conflict or unreachable new URL always hard-fails.
|
**Concurrency**: the heuristic checks for a `josh-sync-state` commit under the target within the last 5 minutes. It's best-effort — a sync that starts after the check but before rename's own push can still race. `--force` bypasses only this check; a destination-name conflict, an unreachable new URL, or a subfolder-move validation failure always hard-fails.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,43 @@ _rename_check_url_reachable() {
|
|||||||
return "$ok"
|
return "$ok"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ─── Subfolder Move ──────────────────────────────────────────────
|
||||||
|
# Renaming a subfolder must move the actual directory in the monorepo
|
||||||
|
# working tree via `git mv`, not just repoint the config — otherwise the
|
||||||
|
# tree stops matching `.josh-sync.yml` until a later sync's filter-change
|
||||||
|
# reconciliation happens to catch it. Both functions are idempotent against
|
||||||
|
# a resumed run: if new_subfolder already exists and old_subfolder doesn't,
|
||||||
|
# the move already happened, so there's nothing left to validate or do.
|
||||||
|
|
||||||
|
# Usage: _rename_validate_subfolder_move <old_subfolder> <new_subfolder>
|
||||||
|
_rename_validate_subfolder_move() {
|
||||||
|
local old_subfolder="$1" new_subfolder="$2"
|
||||||
|
|
||||||
|
if [ -e "$new_subfolder" ] && [ ! -e "$old_subfolder" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -e "$old_subfolder" ] || die "Old subfolder '${old_subfolder}' does not exist in the working tree"
|
||||||
|
[ -n "$(git ls-files -- "$old_subfolder")" ] \
|
||||||
|
|| die "Old subfolder '${old_subfolder}' has no files tracked by git — not managed by josh-sync"
|
||||||
|
[ ! -e "$new_subfolder" ] || die "New subfolder '${new_subfolder}' already exists — refusing to overwrite"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Usage: _rename_move_subfolder <old_subfolder> <new_subfolder>
|
||||||
|
_rename_move_subfolder() {
|
||||||
|
local old_subfolder="$1" new_subfolder="$2"
|
||||||
|
|
||||||
|
if [ -e "$new_subfolder" ] && [ ! -e "$old_subfolder" ]; then
|
||||||
|
log "INFO" "Subfolder already moved to '${new_subfolder}' — skipping git mv"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$new_subfolder")"
|
||||||
|
git mv "$old_subfolder" "$new_subfolder" \
|
||||||
|
|| die "Failed to git mv '${old_subfolder}' -> '${new_subfolder}'"
|
||||||
|
log "INFO" "Moved ${old_subfolder} -> ${new_subfolder} (git mv, uncommitted)"
|
||||||
|
}
|
||||||
|
|
||||||
# ─── Stale Import-Branch Warning (best-effort, never fails) ────────
|
# ─── Stale Import-Branch Warning (best-effort, never fails) ────────
|
||||||
# Onboarding's initial_import (lib/sync.sh) pushes staging branches named
|
# Onboarding's initial_import (lib/sync.sh) pushes staging branches named
|
||||||
# auto-sync/import-<target>-<timestamp> to the MONOREPO. These are normally
|
# auto-sync/import-<target>-<timestamp> to the MONOREPO. These are normally
|
||||||
@@ -223,6 +260,10 @@ rename_target() {
|
|||||||
[ -z "$existing" ] || die "State already exists for target '${new_name}' on ${STATE_BRANCH} — refusing to overwrite. Resolve manually (e.g. 'josh-sync state reset ${new_name} <branch>') before retrying."
|
[ -z "$existing" ] || die "State already exists for target '${new_name}' on ${STATE_BRANCH} — refusing to overwrite. Resolve manually (e.g. 'josh-sync state reset ${new_name} <branch>') before retrying."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$subfolder_changed" = true ]; then
|
||||||
|
_rename_validate_subfolder_move "$old_subfolder" "$new_subfolder"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$dry_run" = true ]; then
|
if [ "$dry_run" = true ]; then
|
||||||
log "INFO" "--dry-run: no changes written"
|
log "INFO" "--dry-run: no changes written"
|
||||||
return 0
|
return 0
|
||||||
@@ -235,6 +276,10 @@ rename_target() {
|
|||||||
[ "$confirm" = "y" ] || [ "$confirm" = "Y" ] || die "Aborted"
|
[ "$confirm" = "y" ] || [ "$confirm" = "Y" ] || die "Aborted"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$subfolder_changed" = true ]; then
|
||||||
|
_rename_move_subfolder "$old_subfolder" "$new_subfolder"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$already_configured" != true ]; then
|
if [ "$already_configured" != true ]; then
|
||||||
log "INFO" "Updating ${config_file}..."
|
log "INFO" "Updating ${config_file}..."
|
||||||
# mikefarah/yq (v4, Go) has no jq-style --arg; pass values via env vars and
|
# mikefarah/yq (v4, Go) has no jq-style --arg; pass values via env vars and
|
||||||
|
|||||||
+93
-6
@@ -2,13 +2,17 @@
|
|||||||
# tests/unit/rename.bats — Config-only rename tests (name/subfolder edits,
|
# tests/unit/rename.bats — Config-only rename tests (name/subfolder edits,
|
||||||
# validation, resumability field checks).
|
# validation, resumability field checks).
|
||||||
#
|
#
|
||||||
# No real git remote is touched here: `--subrepo-url` renames (which need a
|
# No real git REMOTE is touched here: `--subrepo-url` renames (which need a
|
||||||
# reachability check) and all actual state-branch migration behavior are
|
# reachability check) and all actual state-branch migration behavior are
|
||||||
# covered by tests/unit/rename_e2e.bats instead. Every call below either
|
# covered by tests/unit/rename_e2e.bats instead. Every call below either
|
||||||
# omits --subrepo-url or passes assume_yes=true with no state branch present,
|
# omits --subrepo-url or passes assume_yes=true with no state branch present,
|
||||||
# so the concurrency/state-conflict checks run against a non-git tmpdir and
|
# so the concurrency/state-conflict checks run against a repo with no
|
||||||
# harmlessly no-op (git commands fail closed, caught by `|| return 0`/`|| true`
|
# "origin" remote and harmlessly no-op (git commands fail closed, caught by
|
||||||
# in lib/state.sh and lib/rename.sh).
|
# `|| return 0`/`|| true` in lib/state.sh and lib/rename.sh).
|
||||||
|
#
|
||||||
|
# `--subfolder` renames DO need a real (local, remote-less) git repo, since
|
||||||
|
# _rename_validate_subfolder_move/_rename_move_subfolder use `git ls-files`
|
||||||
|
# and `git mv` against the actual working tree — see init_local_git_repo.
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
export JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
|
export JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
|
||||||
@@ -21,6 +25,14 @@ setup() {
|
|||||||
FIXTURES="$JOSH_SYNC_ROOT/tests/fixtures"
|
FIXTURES="$JOSH_SYNC_ROOT/tests/fixtures"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Init a plain local git repo in cwd — no remote, just enough for
|
||||||
|
# `git ls-files`/`git mv` to work. Use for any test touching --subfolder.
|
||||||
|
init_local_git_repo() {
|
||||||
|
git init -q .
|
||||||
|
git config user.name "Test User"
|
||||||
|
git config user.email "test@test.local"
|
||||||
|
}
|
||||||
|
|
||||||
# ─── _rename_validate_name ─────────────────────────────────────────
|
# ─── _rename_validate_name ─────────────────────────────────────────
|
||||||
|
|
||||||
@test "_rename_validate_name rejects a name with a slash" {
|
@test "_rename_validate_name rejects a name with a slash" {
|
||||||
@@ -82,9 +94,14 @@ setup() {
|
|||||||
[ "$app_b_subfolder" = "services/app-b" ]
|
[ "$app_b_subfolder" = "services/app-b" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "rename_target --subfolder re-derives josh_filter" {
|
@test "rename_target --subfolder re-derives josh_filter and git mv's the directory" {
|
||||||
cd "$(mktemp -d)"
|
cd "$(mktemp -d)"
|
||||||
cp "$FIXTURES/minimal.yml" .josh-sync.yml
|
cp "$FIXTURES/minimal.yml" .josh-sync.yml
|
||||||
|
init_local_git_repo
|
||||||
|
mkdir -p services/example
|
||||||
|
echo "content" > services/example/file.txt
|
||||||
|
git add -A
|
||||||
|
git commit -q -m "seed"
|
||||||
parse_config ".josh-sync.yml"
|
parse_config ".josh-sync.yml"
|
||||||
|
|
||||||
rename_target "example" "" "services/relocated" "" ".josh-sync.yml" false true false
|
rename_target "example" "" "services/relocated" "" ".josh-sync.yml" false true false
|
||||||
@@ -94,11 +111,22 @@ setup() {
|
|||||||
[ "$filter" = ":/services/relocated" ]
|
[ "$filter" = ":/services/relocated" ]
|
||||||
subfolder=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].subfolder')
|
subfolder=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].subfolder')
|
||||||
[ "$subfolder" = "services/relocated" ]
|
[ "$subfolder" = "services/relocated" ]
|
||||||
|
|
||||||
|
[ ! -e services/example ]
|
||||||
|
[ -f services/relocated/file.txt ]
|
||||||
|
# git mv stages the rename — should be staged, not left as an untracked file.
|
||||||
|
staged=$(git diff --cached --name-only)
|
||||||
|
[[ "$staged" == *"relocated/file.txt"* ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "rename_target combining --name and --subfolder updates both fields" {
|
@test "rename_target combining --name and --subfolder updates both fields and moves the directory" {
|
||||||
cd "$(mktemp -d)"
|
cd "$(mktemp -d)"
|
||||||
cp "$FIXTURES/minimal.yml" .josh-sync.yml
|
cp "$FIXTURES/minimal.yml" .josh-sync.yml
|
||||||
|
init_local_git_repo
|
||||||
|
mkdir -p services/example
|
||||||
|
echo "content" > services/example/file.txt
|
||||||
|
git add -A
|
||||||
|
git commit -q -m "seed"
|
||||||
parse_config ".josh-sync.yml"
|
parse_config ".josh-sync.yml"
|
||||||
|
|
||||||
rename_target "example" "relocated" "services/relocated" "" ".josh-sync.yml" false true false
|
rename_target "example" "relocated" "services/relocated" "" ".josh-sync.yml" false true false
|
||||||
@@ -110,6 +138,65 @@ setup() {
|
|||||||
[ "$name" = "relocated" ]
|
[ "$name" = "relocated" ]
|
||||||
[ "$subfolder" = "services/relocated" ]
|
[ "$subfolder" = "services/relocated" ]
|
||||||
[ "$filter" = ":/services/relocated" ]
|
[ "$filter" = ":/services/relocated" ]
|
||||||
|
|
||||||
|
[ ! -e services/example ]
|
||||||
|
[ -f services/relocated/file.txt ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── subfolder move validation/idempotency ─────────────────────────
|
||||||
|
|
||||||
|
@test "_rename_validate_subfolder_move dies when the old subfolder doesn't exist" {
|
||||||
|
cd "$(mktemp -d)"
|
||||||
|
init_local_git_repo
|
||||||
|
run _rename_validate_subfolder_move "services/ghost" "services/new"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"does not exist"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "_rename_validate_subfolder_move dies when the old subfolder isn't tracked by git" {
|
||||||
|
cd "$(mktemp -d)"
|
||||||
|
init_local_git_repo
|
||||||
|
mkdir -p services/untracked
|
||||||
|
echo "x" > services/untracked/file.txt
|
||||||
|
run _rename_validate_subfolder_move "services/untracked" "services/new"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"not managed by josh-sync"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "_rename_validate_subfolder_move dies when the new subfolder already exists" {
|
||||||
|
cd "$(mktemp -d)"
|
||||||
|
init_local_git_repo
|
||||||
|
mkdir -p services/old services/new
|
||||||
|
echo "x" > services/old/file.txt
|
||||||
|
echo "y" > services/new/file.txt
|
||||||
|
git add -A
|
||||||
|
git commit -q -m "seed"
|
||||||
|
run _rename_validate_subfolder_move "services/old" "services/new"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"already exists"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "_rename_validate_subfolder_move is a no-op when the move already happened" {
|
||||||
|
cd "$(mktemp -d)"
|
||||||
|
init_local_git_repo
|
||||||
|
mkdir -p services/new
|
||||||
|
echo "x" > services/new/file.txt
|
||||||
|
git add -A
|
||||||
|
git commit -q -m "seed"
|
||||||
|
run _rename_validate_subfolder_move "services/old" "services/new"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "_rename_move_subfolder is idempotent when re-run after the move already happened" {
|
||||||
|
cd "$(mktemp -d)"
|
||||||
|
init_local_git_repo
|
||||||
|
mkdir -p services/new
|
||||||
|
echo "x" > services/new/file.txt
|
||||||
|
git add -A
|
||||||
|
git commit -q -m "seed"
|
||||||
|
run _rename_move_subfolder "services/old" "services/new"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ -f services/new/file.txt ]
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "rename_target rejects a new name colliding with an existing target" {
|
@test "rename_target rejects a new name colliding with an existing target" {
|
||||||
|
|||||||
@@ -356,6 +356,11 @@ state_branch_commit_count() {
|
|||||||
write_billing_config "$subrepo_bare"
|
write_billing_config "$subrepo_bare"
|
||||||
parse_config ".josh-sync.yml"
|
parse_config ".josh-sync.yml"
|
||||||
|
|
||||||
|
mkdir -p services/billing
|
||||||
|
echo "content" > services/billing/file.txt
|
||||||
|
git add -A
|
||||||
|
git commit -q -m "seed billing subfolder"
|
||||||
|
|
||||||
seed_state_commit "" "billing/main.json" '{"last_forward":{"mono_sha":"abc","josh_filter":":/services/billing"}}'
|
seed_state_commit "" "billing/main.json" '{"last_forward":{"mono_sha":"abc","josh_filter":":/services/billing"}}'
|
||||||
|
|
||||||
# --force: the seed_state_commit above just wrote a "recent" commit under
|
# --force: the seed_state_commit above just wrote a "recent" commit under
|
||||||
@@ -379,4 +384,65 @@ state_branch_commit_count() {
|
|||||||
|
|
||||||
msg=$(git log "origin/${STATE_BRANCH}" -1 --format=%s)
|
msg=$(git log "origin/${STATE_BRANCH}" -1 --format=%s)
|
||||||
[ "$msg" = "state: rename billing -> payments" ]
|
[ "$msg" = "state: rename billing -> payments" ]
|
||||||
|
|
||||||
|
[ ! -e services/billing ]
|
||||||
|
[ -f services/payments/file.txt ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── rename_target: subfolder move (git mv in the monorepo tree) ──
|
||||||
|
|
||||||
|
@test "rename_target git mv's the subfolder and dies if the old path isn't tracked" {
|
||||||
|
setup_state_monorepo
|
||||||
|
local subrepo_bare
|
||||||
|
subrepo_bare=$(make_bare_repo "billing-sub")
|
||||||
|
write_billing_config "$subrepo_bare"
|
||||||
|
parse_config ".josh-sync.yml"
|
||||||
|
|
||||||
|
# services/billing was never created/tracked — not "managed by josh-sync".
|
||||||
|
run rename_target "billing" "" "services/relocated" "" ".josh-sync.yml" false true false
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"does not exist"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "rename_target dies when the new subfolder path already exists" {
|
||||||
|
setup_state_monorepo
|
||||||
|
local subrepo_bare
|
||||||
|
subrepo_bare=$(make_bare_repo "billing-sub")
|
||||||
|
write_billing_config "$subrepo_bare"
|
||||||
|
parse_config ".josh-sync.yml"
|
||||||
|
|
||||||
|
mkdir -p services/billing services/relocated
|
||||||
|
echo "old" > services/billing/file.txt
|
||||||
|
echo "existing" > services/relocated/other.txt
|
||||||
|
git add -A
|
||||||
|
git commit -q -m "seed"
|
||||||
|
|
||||||
|
run rename_target "billing" "" "services/relocated" "" ".josh-sync.yml" false true false
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"already exists"* ]]
|
||||||
|
# nothing should have moved
|
||||||
|
[ -f services/billing/file.txt ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "rename_target moves the subfolder via git mv, staged but not committed" {
|
||||||
|
setup_state_monorepo
|
||||||
|
local subrepo_bare
|
||||||
|
subrepo_bare=$(make_bare_repo "billing-sub")
|
||||||
|
write_billing_config "$subrepo_bare"
|
||||||
|
parse_config ".josh-sync.yml"
|
||||||
|
|
||||||
|
mkdir -p services/billing
|
||||||
|
echo "content" > services/billing/file.txt
|
||||||
|
git add -A
|
||||||
|
git commit -q -m "seed"
|
||||||
|
|
||||||
|
rename_target "billing" "" "services/relocated" "" ".josh-sync.yml" false true false
|
||||||
|
|
||||||
|
[ ! -e services/billing ]
|
||||||
|
[ -f services/relocated/file.txt ]
|
||||||
|
staged=$(git diff --cached --name-only)
|
||||||
|
[[ "$staged" == *"relocated/file.txt"* ]]
|
||||||
|
# Not committed — mirrors the config edit, left for the user to review.
|
||||||
|
head_files=$(git show --name-only --format= HEAD)
|
||||||
|
[[ "$head_files" != *"relocated"* ]]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user