@@ -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 | |