@@ -0,0 +1,154 @@ |
| | 1 | +# Repo lifecycle |
| | 2 | + |
| | 3 | +Post-creation a repo can be **renamed**, **transferred** to another |
| | 4 | +user, **archived** (read-only), have its **visibility** flipped, |
| | 5 | +**soft-deleted** (with a 7-day grace), and finally **hard-deleted** by |
| | 6 | +a worker. Every transition is a function in `internal/repos/lifecycle/`; |
| | 7 | +the corresponding handler in `internal/web/handlers/repo/lifecycle.go` |
| | 8 | +runs the policy gate and dispatches. |
| | 9 | + |
| | 10 | +Each operation is described below with: who can do it, what state |
| | 11 | +changes, what the recovery semantics are if a step partway through |
| | 12 | +fails, and what audit row gets emitted. |
| | 13 | + |
| | 14 | +## Rename |
| | 15 | + |
| | 16 | +`POST /{owner}/{repo}/settings/rename` → `lifecycle.Rename`. |
| | 17 | + |
| | 18 | +* **Auth**: `repo:admin` action via S15 policy. Owner-only in V1 |
| | 19 | + (collaborator admins can call it via the same gate; nothing in the |
| | 20 | + policy table is owner-specific). |
| | 21 | +* **Rate limit**: 5 renames per 30 days. Tracked by counting redirect |
| | 22 | + rows for this repo within the window. Exceeding returns |
| | 23 | + `ErrRenameRateLimited` → HTTP 429. |
| | 24 | +* **Steps**: |
| | 25 | + 1. Validate the new name (regex, reserved list, lowercase). |
| | 26 | + 2. Open a transaction. |
| | 27 | + 3. INSERT `repo_redirects(old_owner_user_id, old_name, repo_id)`. |
| | 28 | + 4. UPDATE `repos.name`. A unique-violation rolls back both → user |
| | 29 | + sees `ErrNameTaken` (409). |
| | 30 | + 5. COMMIT. |
| | 31 | + 6. `RepoFS.Move(/data/repos/sh/shithub/old.git, /data/repos/sh/shithub/new.git)`. |
| | 32 | +* **FS-failure recovery**: a compensating UPDATE reverts `repos.name` |
| | 33 | + and DELETEs the redirect row, so persistent state remains |
| | 34 | + consistent. The user-facing error reflects the FS failure (which is |
| | 35 | + the truth — the move didn't happen). |
| | 36 | +* **Audit**: `repo_renamed` with `{old_name, new_name}`. |
| | 37 | + |
| | 38 | +## Transfer |
| | 39 | + |
| | 40 | +* **Initiate**: `POST /{owner}/{repo}/settings/transfer` → |
| | 41 | + `lifecycle.RequestTransfer`. Sender confirms by typing the current |
| | 42 | + `owner/repo` slug. Inserts a `repo_transfer_requests` row with |
| | 43 | + `expires_at = now() + 7 days`, status `pending`. |
| | 44 | +* **Accept**: `POST /transfers/{id}/accept` → `lifecycle.AcceptTransfer`. |
| | 45 | + The recipient is the actor. Atomic transaction: |
| | 46 | + 1. `SELECT … FOR UPDATE` on the transfer row to serialize against |
| | 47 | + concurrent decline / cancel. |
| | 48 | + 2. INSERT redirect from old owner+name. |
| | 49 | + 3. UPDATE `repos.owner_user_id` to the recipient (name unchanged). |
| | 50 | + 4. UPDATE the transfer row to status `accepted` with timestamp. |
| | 51 | + 5. COMMIT. |
| | 52 | + 6. `RepoFS.Move` from old owner's shard to recipient's shard. FS |
| | 53 | + failure here is logged but doesn't roll back — the DB is the |
| | 54 | + source of truth. |
| | 55 | +* **Decline**: `POST /transfers/{id}/decline`. Recipient flips status |
| | 56 | + to `declined`. Repo state unchanged. |
| | 57 | +* **Cancel**: `POST /transfers/{id}/cancel`. Repo admin (sender) |
| | 58 | + flips status to `canceled`. Recipient's accept attempts return |
| | 59 | + `ErrTransferTerminal` (409). |
| | 60 | +* **Expire**: pending transfers past `expires_at` are flipped to |
| | 61 | + `expired` by the `lifecycle:sweep` worker job. Bulk operation; one |
| | 62 | + audit row would be noisy, so the row's terminal status is the audit. |
| | 63 | + |
| | 64 | +## Archive / Unarchive |
| | 65 | + |
| | 66 | +* `POST /{owner}/{repo}/settings/archive` → `is_archived=true`, |
| | 67 | + `archived_at=now()`. Does **not** move FS or change visibility. |
| | 68 | +* `POST /{owner}/{repo}/settings/unarchive` → reverse. |
| | 69 | +* **Push behavior**: enforced by S15's archived-repo deny in |
| | 70 | + `policy.Can` (`DenyArchived`). HTTP / SSH git transports both |
| | 71 | + surface the friendly "repository is archived; pushes are disabled" |
| | 72 | + message. |
| | 73 | + |
| | 74 | +## Visibility |
| | 75 | + |
| | 76 | +`POST /{owner}/{repo}/settings/visibility` → |
| | 77 | +`lifecycle.SetVisibility(public|private)`. Idempotent on the same value. |
| | 78 | +Changing public → private only stops *future* anonymous reads — already- |
| | 79 | +fetched clones aren't recallable, that's git's nature. The UI surfaces |
| | 80 | +this caveat explicitly. |
| | 81 | + |
| | 82 | +Existing **forks remain independent** — they're separate repos at the |
| | 83 | +data layer; visibility flips don't cascade. |
| | 84 | + |
| | 85 | +## Soft delete + restore + hard delete |
| | 86 | + |
| | 87 | +* **Soft delete**: `POST /{owner}/{repo}/settings/delete` → |
| | 88 | + `lifecycle.SoftDelete`. Sets `deleted_at = now()`. Repo disappears |
| | 89 | + from listings, profile pinned slot, search. `/{owner}/{repo}` 404s |
| | 90 | + for non-owners (auth-aware via S15 policy's `DenyRepoDeleted`). |
| | 91 | +* **Restore**: owner sees the soft-deleted repo at |
| | 92 | + `/settings/repositories`. POST to |
| | 93 | + `/settings/repositories/restore/{id}` clears `deleted_at`. Past the |
| | 94 | + 7-day grace, restore returns `ErrPastGrace` (410). |
| | 95 | +* **Hard delete**: `lifecycle:sweep` worker job (registered in |
| | 96 | + `cmd/shithubd/worker.go`) runs periodically. The handler: |
| | 97 | + 1. `ListRepoIDsPastSoftDeleteGrace` — finds rows past 7 days. |
| | 98 | + 2. For each, `lifecycle.HardDelete` runs: |
| | 99 | + * `OrphanForksOf(repoID)` — children's `fork_of_repo_id` set NULL. |
| | 100 | + * `DELETE FROM repos WHERE id=$1`. FK ON DELETE CASCADE handles |
| | 101 | + `push_events`, `repo_collaborators`, `repo_redirects` (rows |
| | 102 | + pointing at this repo), `repo_transfer_requests`, and |
| | 103 | + `webhook_events_pending`. |
| | 104 | + * `RemoveAll` the bare repo on disk. |
| | 105 | + * Audit `repo_hard_deleted` with the row snapshot in `meta`, |
| | 106 | + since the repo_id no longer resolves. |
| | 107 | + 3. The same job also flips pending transfers past their TTL via |
| | 108 | + `ExpirePending`. |
| | 109 | + |
| | 110 | +## Redirect lookup on `/{owner}/{repo}` |
| | 111 | + |
| | 112 | +The `repoHome` handler now consults `repo_redirects` when |
| | 113 | +`lookupRepoForViewer` returns ErrNoRows: |
| | 114 | + |
| | 115 | +* Hit → 301 to the canonical `/{currentOwner}/{currentName}` (with |
| | 116 | + any path tail preserved for future `/blob/x` and similar). |
| | 117 | +* Miss → 404 as before. |
| | 118 | + |
| | 119 | +A redirect row pointing at a soft-deleted repo is treated as a miss |
| | 120 | +to avoid 301 chains into 404s. When the hard-delete worker runs, the |
| | 121 | +redirect row is removed by FK cascade and the URL transitions cleanly |
| | 122 | +from "redirected" to "never existed." |
| | 123 | + |
| | 124 | +## Operational pointers |
| | 125 | + |
| | 126 | +* The `lifecycle:sweep` worker is enqueued ad-hoc today; S26 ships |
| | 127 | + cron scheduling. To run it manually: |
| | 128 | + ```sql |
| | 129 | + INSERT INTO jobs (kind, payload) VALUES ('lifecycle:sweep', '{}'::jsonb); |
| | 130 | + NOTIFY shithub_jobs; |
| | 131 | + ``` |
| | 132 | +* Soft-deleted repos take their disk path with them — the bare repo |
| | 133 | + on disk stays for the full grace window. If disk pressure becomes |
| | 134 | + an issue, configure the grace window down (it's a constant in |
| | 135 | + `lifecycle.go::softDeleteGrace`; promote to config in S37 if needed). |
| | 136 | +* Renaming a repo doesn't require restarting any worker or hook. |
| | 137 | + The atomic FS move + DB update means the next hook invocation will |
| | 138 | + resolve the new path. |
| | 139 | + |
| | 140 | +## Audit actions emitted |
| | 141 | + |
| | 142 | +| Action | When | |
| | 143 | +| ------------------------------- | --------------------------------- | |
| | 144 | +| `repo_renamed` | rename completes | |
| | 145 | +| `repo_archived` | archive | |
| | 146 | +| `repo_unarchived` | unarchive | |
| | 147 | +| `repo_visibility_changed` | flip | |
| | 148 | +| `repo_soft_deleted` | soft delete | |
| | 149 | +| `repo_restored` | restore | |
| | 150 | +| `repo_hard_deleted` | hard-delete worker — meta carries snapshot | |
| | 151 | +| `repo_transfer_requested` | offer created | |
| | 152 | +| `repo_transfer_accepted` | recipient accepts | |
| | 153 | +| `repo_transfer_declined` | recipient declines | |
| | 154 | +| `repo_transfer_canceled` | sender cancels | |