Merge pull request #1267 from gsd-build/feat/multi-project-workspaces-1241
feat: add multi-project workspace commands
This commit is contained in:
@@ -651,8 +651,17 @@ async function main() {
|
||||
case 'progress':
|
||||
init.cmdInitProgress(cwd, raw);
|
||||
break;
|
||||
case 'new-workspace':
|
||||
init.cmdInitNewWorkspace(cwd, raw);
|
||||
break;
|
||||
case 'list-workspaces':
|
||||
init.cmdInitListWorkspaces(cwd, raw);
|
||||
break;
|
||||
case 'remove-workspace':
|
||||
init.cmdInitRemoveWorkspace(cwd, args[2], raw);
|
||||
break;
|
||||
default:
|
||||
error(`Unknown init workflow: ${workflow}\nAvailable: execute-phase, plan-phase, new-project, new-milestone, quick, resume, verify-work, phase-op, todos, milestone-op, map-codebase, progress`);
|
||||
error(`Unknown init workflow: ${workflow}\nAvailable: execute-phase, plan-phase, new-project, new-milestone, quick, resume, verify-work, phase-op, todos, milestone-op, map-codebase, progress, new-workspace, list-workspaces, remove-workspace`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -896,6 +896,165 @@ function cmdInitProgress(cwd, raw) {
|
||||
output(withProjectRoot(cwd, result), raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect child git repos in a directory (one level deep).
|
||||
* Returns array of { name, path, has_uncommitted } objects.
|
||||
*/
|
||||
function detectChildRepos(dir) {
|
||||
const repos = [];
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return repos; }
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
const gitDir = path.join(fullPath, '.git');
|
||||
if (fs.existsSync(gitDir)) {
|
||||
let hasUncommitted = false;
|
||||
try {
|
||||
const status = execSync('git status --porcelain', { cwd: fullPath, encoding: 'utf8', timeout: 5000 });
|
||||
hasUncommitted = status.trim().length > 0;
|
||||
} catch { /* best-effort */ }
|
||||
repos.push({ name: entry.name, path: fullPath, has_uncommitted: hasUncommitted });
|
||||
}
|
||||
}
|
||||
return repos;
|
||||
}
|
||||
|
||||
function cmdInitNewWorkspace(cwd, raw) {
|
||||
const homedir = process.env.HOME || require('os').homedir();
|
||||
const defaultBase = path.join(homedir, 'gsd-workspaces');
|
||||
|
||||
// Detect child git repos for interactive selection
|
||||
const childRepos = detectChildRepos(cwd);
|
||||
|
||||
// Check if git worktree is available
|
||||
let worktreeAvailable = false;
|
||||
try {
|
||||
execSync('git --version', { encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
|
||||
worktreeAvailable = true;
|
||||
} catch { /* no git at all */ }
|
||||
|
||||
const result = {
|
||||
default_workspace_base: defaultBase,
|
||||
child_repos: childRepos,
|
||||
child_repo_count: childRepos.length,
|
||||
worktree_available: worktreeAvailable,
|
||||
is_git_repo: pathExistsInternal(cwd, '.git'),
|
||||
cwd_repo_name: path.basename(cwd),
|
||||
};
|
||||
|
||||
output(withProjectRoot(cwd, result), raw);
|
||||
}
|
||||
|
||||
function cmdInitListWorkspaces(cwd, raw) {
|
||||
const homedir = process.env.HOME || require('os').homedir();
|
||||
const defaultBase = path.join(homedir, 'gsd-workspaces');
|
||||
|
||||
const workspaces = [];
|
||||
if (fs.existsSync(defaultBase)) {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(defaultBase, { withFileTypes: true }); } catch { entries = []; }
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const wsPath = path.join(defaultBase, entry.name);
|
||||
const manifestPath = path.join(wsPath, 'WORKSPACE.md');
|
||||
if (!fs.existsSync(manifestPath)) continue;
|
||||
|
||||
let repoCount = 0;
|
||||
let hasProject = false;
|
||||
let strategy = 'unknown';
|
||||
try {
|
||||
const manifest = fs.readFileSync(manifestPath, 'utf8');
|
||||
const strategyMatch = manifest.match(/^Strategy:\s*(.+)$/m);
|
||||
if (strategyMatch) strategy = strategyMatch[1].trim();
|
||||
// Count table rows (lines starting with |, excluding header and separator)
|
||||
const tableRows = manifest.split('\n').filter(l => l.match(/^\|\s*\w/) && !l.includes('Repo') && !l.includes('---'));
|
||||
repoCount = tableRows.length;
|
||||
} catch { /* best-effort */ }
|
||||
hasProject = fs.existsSync(path.join(wsPath, '.planning', 'PROJECT.md'));
|
||||
|
||||
workspaces.push({
|
||||
name: entry.name,
|
||||
path: wsPath,
|
||||
repo_count: repoCount,
|
||||
strategy,
|
||||
has_project: hasProject,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
workspace_base: defaultBase,
|
||||
workspaces,
|
||||
workspace_count: workspaces.length,
|
||||
};
|
||||
|
||||
output(result, raw);
|
||||
}
|
||||
|
||||
function cmdInitRemoveWorkspace(cwd, name, raw) {
|
||||
const homedir = process.env.HOME || require('os').homedir();
|
||||
const defaultBase = path.join(homedir, 'gsd-workspaces');
|
||||
|
||||
if (!name) {
|
||||
error('workspace name required for init remove-workspace');
|
||||
}
|
||||
|
||||
const wsPath = path.join(defaultBase, name);
|
||||
const manifestPath = path.join(wsPath, 'WORKSPACE.md');
|
||||
|
||||
if (!fs.existsSync(wsPath)) {
|
||||
error(`Workspace not found: ${wsPath}`);
|
||||
}
|
||||
|
||||
// Parse manifest for repo info
|
||||
const repos = [];
|
||||
let strategy = 'unknown';
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
try {
|
||||
const manifest = fs.readFileSync(manifestPath, 'utf8');
|
||||
const strategyMatch = manifest.match(/^Strategy:\s*(.+)$/m);
|
||||
if (strategyMatch) strategy = strategyMatch[1].trim();
|
||||
|
||||
// Parse table rows for repo names and source paths
|
||||
const lines = manifest.split('\n');
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\|\s*(\S+)\s*\|\s*(\S+)\s*\|\s*(\S+)\s*\|\s*(\S+)\s*\|$/);
|
||||
if (match && match[1] !== 'Repo' && !match[1].includes('---')) {
|
||||
repos.push({ name: match[1], source: match[2], branch: match[3], strategy: match[4] });
|
||||
}
|
||||
}
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
// Check for uncommitted changes in workspace repos
|
||||
const dirtyRepos = [];
|
||||
for (const repo of repos) {
|
||||
const repoPath = path.join(wsPath, repo.name);
|
||||
if (!fs.existsSync(repoPath)) continue;
|
||||
try {
|
||||
const status = execSync('git status --porcelain', { cwd: repoPath, encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
|
||||
if (status.trim().length > 0) {
|
||||
dirtyRepos.push(repo.name);
|
||||
}
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
const result = {
|
||||
workspace_name: name,
|
||||
workspace_path: wsPath,
|
||||
has_manifest: fs.existsSync(manifestPath),
|
||||
strategy,
|
||||
repos,
|
||||
repo_count: repos.length,
|
||||
dirty_repos: dirtyRepos,
|
||||
has_dirty_repos: dirtyRepos.length > 0,
|
||||
};
|
||||
|
||||
output(result, raw);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cmdInitExecutePhase,
|
||||
cmdInitPlanPhase,
|
||||
@@ -909,4 +1068,8 @@ module.exports = {
|
||||
cmdInitMilestoneOp,
|
||||
cmdInitMapCodebase,
|
||||
cmdInitProgress,
|
||||
cmdInitNewWorkspace,
|
||||
cmdInitListWorkspaces,
|
||||
cmdInitRemoveWorkspace,
|
||||
detectChildRepos,
|
||||
};
|
||||
|
||||
56
get-shit-done/workflows/list-workspaces.md
Normal file
56
get-shit-done/workflows/list-workspaces.md
Normal file
@@ -0,0 +1,56 @@
|
||||
<purpose>
|
||||
List all GSD workspaces found in ~/gsd-workspaces/ with their status.
|
||||
</purpose>
|
||||
|
||||
<required_reading>
|
||||
Read all files referenced by the invoking prompt's execution_context before starting.
|
||||
</required_reading>
|
||||
|
||||
<process>
|
||||
|
||||
## 1. Setup
|
||||
|
||||
```bash
|
||||
INIT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init list-workspaces)
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
```
|
||||
|
||||
Parse JSON for: `workspace_base`, `workspaces`, `workspace_count`.
|
||||
|
||||
## 2. Display
|
||||
|
||||
**If `workspace_count` is 0:**
|
||||
|
||||
```
|
||||
No workspaces found in ~/gsd-workspaces/
|
||||
|
||||
Create one with:
|
||||
/gsd:new-workspace --name my-workspace --repos repo1,repo2
|
||||
```
|
||||
|
||||
Done.
|
||||
|
||||
**If workspaces exist:**
|
||||
|
||||
Display a table:
|
||||
|
||||
```
|
||||
GSD Workspaces (~/gsd-workspaces/)
|
||||
|
||||
| Name | Repos | Strategy | GSD Project |
|
||||
|------|-------|----------|-------------|
|
||||
| feature-a | 3 | worktree | Yes |
|
||||
| feature-b | 2 | clone | No |
|
||||
|
||||
Manage:
|
||||
cd ~/gsd-workspaces/<name> # Enter a workspace
|
||||
/gsd:remove-workspace <name> # Remove a workspace
|
||||
```
|
||||
|
||||
For each workspace, show:
|
||||
- **Name** — directory name
|
||||
- **Repos** — count from init data
|
||||
- **Strategy** — from WORKSPACE.md
|
||||
- **GSD Project** — whether `.planning/PROJECT.md` exists (Yes/No)
|
||||
|
||||
</process>
|
||||
237
get-shit-done/workflows/new-workspace.md
Normal file
237
get-shit-done/workflows/new-workspace.md
Normal file
@@ -0,0 +1,237 @@
|
||||
<purpose>
|
||||
Create an isolated workspace directory with git repo copies (worktrees or clones) and an independent `.planning/` directory. Supports multi-repo orchestration and single-repo feature branch isolation.
|
||||
</purpose>
|
||||
|
||||
<required_reading>
|
||||
Read all files referenced by the invoking prompt's execution_context before starting.
|
||||
</required_reading>
|
||||
|
||||
<process>
|
||||
|
||||
## 1. Setup
|
||||
|
||||
**MANDATORY FIRST STEP — Execute init command:**
|
||||
|
||||
```bash
|
||||
INIT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init new-workspace)
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
```
|
||||
|
||||
Parse JSON for: `default_workspace_base`, `child_repos`, `child_repo_count`, `worktree_available`, `is_git_repo`, `cwd_repo_name`, `project_root`.
|
||||
|
||||
## 2. Parse Arguments
|
||||
|
||||
Extract from $ARGUMENTS:
|
||||
- `--name` → `WORKSPACE_NAME` (required)
|
||||
- `--repos` → `REPO_LIST` (comma-separated paths or names)
|
||||
- `--path` → `TARGET_PATH` (defaults to `$default_workspace_base/$WORKSPACE_NAME`)
|
||||
- `--strategy` → `STRATEGY` (defaults to `worktree`)
|
||||
- `--branch` → `BRANCH_NAME` (defaults to `workspace/$WORKSPACE_NAME`)
|
||||
- `--auto` → skip interactive questions
|
||||
|
||||
**If `--name` is missing and not `--auto`:**
|
||||
|
||||
Use AskUserQuestion:
|
||||
- header: "Workspace Name"
|
||||
- question: "What should this workspace be called?"
|
||||
- requireAnswer: true
|
||||
|
||||
## 3. Select Repos
|
||||
|
||||
**If `--repos` is provided:** Parse comma-separated values. For each value:
|
||||
- If it's an absolute path, use it directly
|
||||
- If it's a relative path or name, resolve against `$project_root`
|
||||
- Special case: `.` means current repo (use `$project_root`, name it `$cwd_repo_name`)
|
||||
|
||||
**If `--repos` is NOT provided and not `--auto`:**
|
||||
|
||||
**If `child_repo_count` > 0:**
|
||||
|
||||
Present child repos for selection:
|
||||
|
||||
Use AskUserQuestion:
|
||||
- header: "Select Repos"
|
||||
- question: "Which repos should be included in the workspace?"
|
||||
- options: List each child repo from `child_repos` array by name
|
||||
- multiSelect: true
|
||||
|
||||
**If `child_repo_count` is 0 and `is_git_repo` is true:**
|
||||
|
||||
Use AskUserQuestion:
|
||||
- header: "Current Repo"
|
||||
- question: "No child repos found. Create a workspace with the current repo?"
|
||||
- options:
|
||||
- "Yes — create workspace with current repo" → use current repo
|
||||
- "Cancel" → exit
|
||||
|
||||
**If `child_repo_count` is 0 and `is_git_repo` is false:**
|
||||
|
||||
Error:
|
||||
```
|
||||
No git repos found in the current directory and this is not a git repo.
|
||||
|
||||
Run this command from a directory containing git repos, or specify repos explicitly:
|
||||
/gsd:new-workspace --name my-workspace --repos /path/to/repo1,/path/to/repo2
|
||||
```
|
||||
Exit.
|
||||
|
||||
**If `--auto` and `--repos` is NOT provided:**
|
||||
|
||||
Error:
|
||||
```
|
||||
Error: --auto requires --repos to specify which repos to include.
|
||||
|
||||
Usage:
|
||||
/gsd:new-workspace --name my-workspace --repos repo1,repo2 --auto
|
||||
```
|
||||
Exit.
|
||||
|
||||
## 4. Select Strategy
|
||||
|
||||
**If `--strategy` is provided:** Use it (validate: must be `worktree` or `clone`).
|
||||
|
||||
**If `--strategy` is NOT provided and not `--auto`:**
|
||||
|
||||
Use AskUserQuestion:
|
||||
- header: "Strategy"
|
||||
- question: "How should repos be copied into the workspace?"
|
||||
- options:
|
||||
- "Worktree (recommended) — lightweight, shares .git objects with source repo" → `worktree`
|
||||
- "Clone — fully independent copy, no connection to source repo" → `clone`
|
||||
|
||||
**If `--auto`:** Default to `worktree`.
|
||||
|
||||
## 5. Validate
|
||||
|
||||
Before creating anything, validate:
|
||||
|
||||
1. **Target path** — must not exist or must be empty:
|
||||
```bash
|
||||
if [ -d "$TARGET_PATH" ] && [ "$(ls -A "$TARGET_PATH" 2>/dev/null)" ]; then
|
||||
echo "Error: Target path already exists and is not empty: $TARGET_PATH"
|
||||
echo "Choose a different --name or --path."
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
2. **Source repos exist and are git repos** — for each repo path:
|
||||
```bash
|
||||
if [ ! -d "$REPO_PATH/.git" ]; then
|
||||
echo "Error: Not a git repo: $REPO_PATH"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
3. **Worktree availability** — if strategy is `worktree` and `worktree_available` is false:
|
||||
```
|
||||
Error: git is not available. Install git or use --strategy clone.
|
||||
```
|
||||
|
||||
Report all validation errors at once, not one at a time.
|
||||
|
||||
## 6. Create Workspace
|
||||
|
||||
```bash
|
||||
mkdir -p "$TARGET_PATH"
|
||||
```
|
||||
|
||||
### For each repo:
|
||||
|
||||
**Worktree strategy:**
|
||||
```bash
|
||||
cd "$SOURCE_REPO_PATH"
|
||||
git worktree add "$TARGET_PATH/$REPO_NAME" -b "$BRANCH_NAME" 2>&1
|
||||
```
|
||||
|
||||
If `git worktree add` fails because the branch already exists, try with a timestamped branch:
|
||||
```bash
|
||||
TIMESTAMP=$(date +%Y%m%d%H%M%S)
|
||||
git worktree add "$TARGET_PATH/$REPO_NAME" -b "${BRANCH_NAME}-${TIMESTAMP}" 2>&1
|
||||
```
|
||||
|
||||
If that also fails, report the error and continue with remaining repos.
|
||||
|
||||
**Clone strategy:**
|
||||
```bash
|
||||
git clone "$SOURCE_REPO_PATH" "$TARGET_PATH/$REPO_NAME" 2>&1
|
||||
cd "$TARGET_PATH/$REPO_NAME"
|
||||
git checkout -b "$BRANCH_NAME" 2>&1
|
||||
```
|
||||
|
||||
Track results: which repos succeeded, which failed, what branch was used.
|
||||
|
||||
## 7. Write WORKSPACE.md
|
||||
|
||||
Write the workspace manifest at `$TARGET_PATH/WORKSPACE.md`:
|
||||
|
||||
```markdown
|
||||
# Workspace: $WORKSPACE_NAME
|
||||
|
||||
Created: $DATE
|
||||
Strategy: $STRATEGY
|
||||
|
||||
## Member Repos
|
||||
|
||||
| Repo | Source | Branch | Strategy |
|
||||
|------|--------|--------|----------|
|
||||
| $REPO_NAME | $SOURCE_PATH | $BRANCH | $STRATEGY |
|
||||
...for each repo...
|
||||
|
||||
## Notes
|
||||
|
||||
[Add context about what this workspace is for]
|
||||
```
|
||||
|
||||
## 8. Initialize .planning/
|
||||
|
||||
```bash
|
||||
mkdir -p "$TARGET_PATH/.planning"
|
||||
```
|
||||
|
||||
## 9. Report and Next Steps
|
||||
|
||||
**If all repos succeeded:**
|
||||
|
||||
```
|
||||
Workspace created: $TARGET_PATH
|
||||
|
||||
Repos: $REPO_COUNT
|
||||
Strategy: $STRATEGY
|
||||
Branch: $BRANCH_NAME
|
||||
|
||||
Next steps:
|
||||
cd $TARGET_PATH
|
||||
/gsd:new-project # Initialize GSD in the workspace
|
||||
```
|
||||
|
||||
**If some repos failed:**
|
||||
|
||||
```
|
||||
Workspace created with $SUCCESS_COUNT of $TOTAL_COUNT repos: $TARGET_PATH
|
||||
|
||||
Succeeded: repo1, repo2
|
||||
Failed: repo3 (branch already exists), repo4 (not a git repo)
|
||||
|
||||
Next steps:
|
||||
cd $TARGET_PATH
|
||||
/gsd:new-project # Initialize GSD in the workspace
|
||||
```
|
||||
|
||||
**Offer to initialize GSD (if not `--auto`):**
|
||||
|
||||
Use AskUserQuestion:
|
||||
- header: "Initialize GSD"
|
||||
- question: "Would you like to initialize a GSD project in the new workspace?"
|
||||
- options:
|
||||
- "Yes — run /gsd:new-project" → tell user to `cd $TARGET_PATH` first, then run `/gsd:new-project`
|
||||
- "No — I'll set it up later" → done
|
||||
|
||||
</process>
|
||||
|
||||
<success_criteria>
|
||||
- [ ] Workspace directory created at target path
|
||||
- [ ] All specified repos copied (worktree or clone) into workspace
|
||||
- [ ] WORKSPACE.md manifest written with correct repo table
|
||||
- [ ] `.planning/` directory initialized at workspace root
|
||||
- [ ] User informed of workspace path and next steps
|
||||
</success_criteria>
|
||||
90
get-shit-done/workflows/remove-workspace.md
Normal file
90
get-shit-done/workflows/remove-workspace.md
Normal file
@@ -0,0 +1,90 @@
|
||||
<purpose>
|
||||
Remove a GSD workspace, cleaning up git worktrees and deleting the workspace directory.
|
||||
</purpose>
|
||||
|
||||
<required_reading>
|
||||
Read all files referenced by the invoking prompt's execution_context before starting.
|
||||
</required_reading>
|
||||
|
||||
<process>
|
||||
|
||||
## 1. Setup
|
||||
|
||||
Extract workspace name from $ARGUMENTS.
|
||||
|
||||
```bash
|
||||
INIT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init remove-workspace "$WORKSPACE_NAME")
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
```
|
||||
|
||||
Parse JSON for: `workspace_name`, `workspace_path`, `has_manifest`, `strategy`, `repos`, `repo_count`, `dirty_repos`, `has_dirty_repos`.
|
||||
|
||||
**If no workspace name provided:**
|
||||
|
||||
First run `/gsd:list-workspaces` to show available workspaces, then ask:
|
||||
|
||||
Use AskUserQuestion:
|
||||
- header: "Remove Workspace"
|
||||
- question: "Which workspace do you want to remove?"
|
||||
- requireAnswer: true
|
||||
|
||||
Re-run init with the provided name.
|
||||
|
||||
## 2. Safety Checks
|
||||
|
||||
**If `has_dirty_repos` is true:**
|
||||
|
||||
```
|
||||
Cannot remove workspace "$WORKSPACE_NAME" — the following repos have uncommitted changes:
|
||||
|
||||
- repo1
|
||||
- repo2
|
||||
|
||||
Commit or stash changes in these repos before removing the workspace:
|
||||
cd $WORKSPACE_PATH/repo1
|
||||
git stash # or git commit
|
||||
```
|
||||
|
||||
Exit. Do NOT proceed.
|
||||
|
||||
## 3. Confirm Removal
|
||||
|
||||
Use AskUserQuestion:
|
||||
- header: "Confirm Removal"
|
||||
- question: "Remove workspace '$WORKSPACE_NAME' at $WORKSPACE_PATH? This will delete all files in the workspace directory. Type the workspace name to confirm:"
|
||||
- requireAnswer: true
|
||||
|
||||
**If answer does not match `$WORKSPACE_NAME`:** Exit with "Removal cancelled."
|
||||
|
||||
## 4. Clean Up Worktrees
|
||||
|
||||
**If strategy is `worktree`:**
|
||||
|
||||
For each repo in the workspace:
|
||||
|
||||
```bash
|
||||
cd "$SOURCE_REPO_PATH"
|
||||
git worktree remove "$WORKSPACE_PATH/$REPO_NAME" 2>&1 || true
|
||||
```
|
||||
|
||||
If `git worktree remove` fails, warn but continue:
|
||||
```
|
||||
Warning: Could not remove worktree for $REPO_NAME — source repo may have been moved or deleted.
|
||||
```
|
||||
|
||||
## 5. Delete Workspace Directory
|
||||
|
||||
```bash
|
||||
rm -rf "$WORKSPACE_PATH"
|
||||
```
|
||||
|
||||
## 6. Report
|
||||
|
||||
```
|
||||
Workspace "$WORKSPACE_NAME" removed.
|
||||
|
||||
Path: $WORKSPACE_PATH (deleted)
|
||||
Repos: $REPO_COUNT worktrees cleaned up
|
||||
```
|
||||
|
||||
</process>
|
||||
Reference in New Issue
Block a user