Repo lifecycle
Post-creation a repo can be renamed, transferred to another
user, archived (read-only), have its visibility flipped,
soft-deleted (with a 7-day grace), and finally hard-deleted by
a worker. Every transition is a function in internal/repos/lifecycle/;
the corresponding handler in internal/web/handlers/repo/lifecycle.go
runs the policy gate and dispatches.
Each operation is described below with: who can do it, what state changes, what the recovery semantics are if a step partway through fails, and what audit row gets emitted.
Rename
POST /{owner}/{repo}/settings/rename → lifecycle.Rename.
- Auth:
repo:adminaction via S15 policy. Owner-only in V1 (collaborator admins can call it via the same gate; nothing in the policy table is owner-specific). - Rate limit: 5 renames per 30 days. Tracked by counting redirect
rows for this repo within the window. Exceeding returns
ErrRenameRateLimited→ HTTP 429. - Steps:
- Validate the new name (regex, reserved list, lowercase).
- Open a transaction.
- INSERT
repo_redirects(old_owner_user_id, old_name, repo_id). - UPDATE
repos.name. A unique-violation rolls back both → user seesErrNameTaken(409). - COMMIT.
RepoFS.Move(/data/repos/sh/shithub/old.git, /data/repos/sh/shithub/new.git).
- FS-failure recovery: a compensating UPDATE reverts
repos.nameand DELETEs the redirect row, so persistent state remains consistent. The user-facing error reflects the FS failure (which is the truth — the move didn't happen). - Audit:
repo_renamedwith{old_name, new_name}.
Transfer
- Initiate:
POST /{owner}/{repo}/settings/transfer→lifecycle.RequestTransfer. Sender confirms by typing the currentowner/reposlug. Inserts arepo_transfer_requestsrow withexpires_at = now() + 7 days, statuspending. - Accept:
POST /transfers/{id}/accept→lifecycle.AcceptTransfer. The recipient is the actor. Atomic transaction:SELECT … FOR UPDATEon the transfer row to serialize against concurrent decline / cancel.- INSERT redirect from old owner+name.
- UPDATE
repos.owner_user_idto the recipient (name unchanged). - UPDATE the transfer row to status
acceptedwith timestamp. - COMMIT.
RepoFS.Movefrom old owner's shard to recipient's shard. FS failure here is logged but doesn't roll back — the DB is the source of truth.
- Decline:
POST /transfers/{id}/decline. Recipient flips status todeclined. Repo state unchanged. - Cancel:
POST /transfers/{id}/cancel. Repo admin (sender) flips status tocanceled. Recipient's accept attempts returnErrTransferTerminal(409). - Expire: pending transfers past
expires_atare flipped toexpiredby thelifecycle:sweepworker job. Bulk operation; one audit row would be noisy, so the row's terminal status is the audit.
Archive / Unarchive
POST /{owner}/{repo}/settings/archive→is_archived=true,archived_at=now(). Does not move FS or change visibility.POST /{owner}/{repo}/settings/unarchive→ reverse.- Push behavior: enforced by S15's archived-repo deny in
policy.Can(DenyArchived). HTTP / SSH git transports both surface the friendly "repository is archived; pushes are disabled" message.
Visibility
POST /{owner}/{repo}/settings/visibility →
lifecycle.SetVisibility(public|private). Idempotent on the same value.
Changing public → private only stops future anonymous reads — already-
fetched clones aren't recallable, that's git's nature. The UI surfaces
this caveat explicitly.
Existing forks remain independent — they're separate repos at the data layer; visibility flips don't cascade.
Soft delete + restore + hard delete
- Soft delete:
POST /{owner}/{repo}/settings/delete→lifecycle.SoftDelete. Setsdeleted_at = now(). Repo disappears from listings, profile pinned slot, search. The bare repo is moved from the canonical<owner>/<name>.gitpath to an internal.deleted/<repo-id>.gittombstone so a fresh repo can reuse the same owner/name during the grace window./{owner}/{repo}404s for non-owners (auth-aware via S15 policy'sDenyRepoDeleted). - Restore: owner sees the soft-deleted repo at
/settings/repositories. POST to/settings/repositories/restore/{id}moves the tombstone back to the canonical path and clearsdeleted_at. If the owner/name was reused, restore returnsErrNameTakenand leaves the deleted repo in the restore list. Past the 7-day grace, restore returnsErrPastGrace(410). - Hard delete:
lifecycle:sweepworker job (registered incmd/shithubd/worker.go) runs periodically. The handler:ListRepoIDsPastSoftDeleteGrace— finds rows past 7 days.- For each,
lifecycle.HardDeleteruns:OrphanForksOf(repoID)— children'sfork_of_repo_idset NULL.DELETE FROM repos WHERE id=$1. FK ON DELETE CASCADE handlespush_events,repo_collaborators,repo_redirects(rows pointing at this repo),repo_transfer_requests, andwebhook_events_pending.- Remove the tombstoned bare repo on disk. Legacy soft-deleted repos whose bare data still sits at the canonical path are removed only when no active repo has reused the owner/name.
- Audit
repo_hard_deletedwith the row snapshot inmeta, since the repo_id no longer resolves.
- The same job also flips pending transfers past their TTL via
ExpirePending.
Redirect lookup on /{owner}/{repo}
The repoHome handler now consults repo_redirects when
lookupRepoForViewer returns ErrNoRows:
- Hit → 301 to the canonical
/{currentOwner}/{currentName}(with any path tail preserved for future/blob/xand similar). - Miss → 404 as before.
A redirect row pointing at a soft-deleted repo is treated as a miss to avoid 301 chains into 404s. When the hard-delete worker runs, the redirect row is removed by FK cascade and the URL transitions cleanly from "redirected" to "never existed."
Operational pointers
- The
lifecycle:sweepworker is enqueued ad-hoc today; S37 ships cron scheduling. To run it manually:INSERT INTO jobs (kind, payload) VALUES ('lifecycle:sweep', '{}'::jsonb); NOTIFY shithub_jobs; - Soft-deleted repos keep their bare data for the full grace window,
but under the owner-local
.deleted/<repo-id>.gittombstone path rather than the active canonical path. If disk pressure becomes an issue, configure the grace window down (it's a constant inlifecycle.go::softDeleteGrace; promote to config in S37 if needed). - Renaming a repo doesn't require restarting any worker or hook. The atomic FS move + DB update means the next hook invocation will resolve the new path.
Audit actions emitted
| Action | When |
|---|---|
repo_renamed |
rename completes |
repo_archived |
archive |
repo_unarchived |
unarchive |
repo_visibility_changed |
flip |
repo_soft_deleted |
soft delete |
repo_restored |
restore |
repo_hard_deleted |
hard-delete worker — meta carries snapshot |
repo_transfer_requested |
offer created |
repo_transfer_accepted |
recipient accepts |
repo_transfer_declined |
recipient declines |
repo_transfer_canceled |
sender cancels |
Deferred work (S16 → later sprints)
Two pieces of S16's spec are intentionally deferred; the receiving sprints have explicit bullets so the work doesn't fall off:
- Email notifications for archive / unarchive / visibility flip /
soft-delete / restore / transfer requested / accepted / declined /
canceled / expired → tracked in S29 (notifications). The audit rows
already exist; S29's
domain_eventstable is the consumer. - Periodic enqueue of
lifecycle:sweep→ tracked in S37 (deploy automation), undershithubd-cron.timer. Today operators kick the job manually with the SQL above.
View source
| 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. The bare repo is moved |
| 90 | from the canonical `<owner>/<name>.git` path to an internal |
| 91 | `.deleted/<repo-id>.git` tombstone so a fresh repo can reuse the same |
| 92 | owner/name during the grace window. `/{owner}/{repo}` 404s for |
| 93 | non-owners (auth-aware via S15 policy's `DenyRepoDeleted`). |
| 94 | * **Restore**: owner sees the soft-deleted repo at |
| 95 | `/settings/repositories`. POST to |
| 96 | `/settings/repositories/restore/{id}` moves the tombstone back to the |
| 97 | canonical path and clears `deleted_at`. If the owner/name was reused, |
| 98 | restore returns `ErrNameTaken` and leaves the deleted repo in the |
| 99 | restore list. Past the 7-day grace, restore returns `ErrPastGrace` |
| 100 | (410). |
| 101 | * **Hard delete**: `lifecycle:sweep` worker job (registered in |
| 102 | `cmd/shithubd/worker.go`) runs periodically. The handler: |
| 103 | 1. `ListRepoIDsPastSoftDeleteGrace` — finds rows past 7 days. |
| 104 | 2. For each, `lifecycle.HardDelete` runs: |
| 105 | * `OrphanForksOf(repoID)` — children's `fork_of_repo_id` set NULL. |
| 106 | * `DELETE FROM repos WHERE id=$1`. FK ON DELETE CASCADE handles |
| 107 | `push_events`, `repo_collaborators`, `repo_redirects` (rows |
| 108 | pointing at this repo), `repo_transfer_requests`, and |
| 109 | `webhook_events_pending`. |
| 110 | * Remove the tombstoned bare repo on disk. Legacy soft-deleted |
| 111 | repos whose bare data still sits at the canonical path are |
| 112 | removed only when no active repo has reused the owner/name. |
| 113 | * Audit `repo_hard_deleted` with the row snapshot in `meta`, |
| 114 | since the repo_id no longer resolves. |
| 115 | 3. The same job also flips pending transfers past their TTL via |
| 116 | `ExpirePending`. |
| 117 | |
| 118 | ## Redirect lookup on `/{owner}/{repo}` |
| 119 | |
| 120 | The `repoHome` handler now consults `repo_redirects` when |
| 121 | `lookupRepoForViewer` returns ErrNoRows: |
| 122 | |
| 123 | * Hit → 301 to the canonical `/{currentOwner}/{currentName}` (with |
| 124 | any path tail preserved for future `/blob/x` and similar). |
| 125 | * Miss → 404 as before. |
| 126 | |
| 127 | A redirect row pointing at a soft-deleted repo is treated as a miss |
| 128 | to avoid 301 chains into 404s. When the hard-delete worker runs, the |
| 129 | redirect row is removed by FK cascade and the URL transitions cleanly |
| 130 | from "redirected" to "never existed." |
| 131 | |
| 132 | ## Operational pointers |
| 133 | |
| 134 | * The `lifecycle:sweep` worker is enqueued ad-hoc today; S37 ships |
| 135 | cron scheduling. To run it manually: |
| 136 | ```sql |
| 137 | INSERT INTO jobs (kind, payload) VALUES ('lifecycle:sweep', '{}'::jsonb); |
| 138 | NOTIFY shithub_jobs; |
| 139 | ``` |
| 140 | * Soft-deleted repos keep their bare data for the full grace window, |
| 141 | but under the owner-local `.deleted/<repo-id>.git` tombstone path |
| 142 | rather than the active canonical path. If disk pressure becomes an |
| 143 | issue, configure the grace window down (it's a constant in |
| 144 | `lifecycle.go::softDeleteGrace`; promote to config in S37 if needed). |
| 145 | * Renaming a repo doesn't require restarting any worker or hook. |
| 146 | The atomic FS move + DB update means the next hook invocation will |
| 147 | resolve the new path. |
| 148 | |
| 149 | ## Audit actions emitted |
| 150 | |
| 151 | | Action | When | |
| 152 | | ------------------------------- | --------------------------------- | |
| 153 | | `repo_renamed` | rename completes | |
| 154 | | `repo_archived` | archive | |
| 155 | | `repo_unarchived` | unarchive | |
| 156 | | `repo_visibility_changed` | flip | |
| 157 | | `repo_soft_deleted` | soft delete | |
| 158 | | `repo_restored` | restore | |
| 159 | | `repo_hard_deleted` | hard-delete worker — meta carries snapshot | |
| 160 | | `repo_transfer_requested` | offer created | |
| 161 | | `repo_transfer_accepted` | recipient accepts | |
| 162 | | `repo_transfer_declined` | recipient declines | |
| 163 | | `repo_transfer_canceled` | sender cancels | |
| 164 | |
| 165 | ## Deferred work (S16 → later sprints) |
| 166 | |
| 167 | Two pieces of S16's spec are intentionally deferred; the receiving |
| 168 | sprints have explicit bullets so the work doesn't fall off: |
| 169 | |
| 170 | * **Email notifications** for archive / unarchive / visibility flip / |
| 171 | soft-delete / restore / transfer requested / accepted / declined / |
| 172 | canceled / expired → tracked in S29 (notifications). The audit rows |
| 173 | already exist; S29's `domain_events` table is the consumer. |
| 174 | * **Periodic enqueue of `lifecycle:sweep`** → tracked in S37 (deploy |
| 175 | automation), under `shithubd-cron.timer`. Today operators kick the |
| 176 | job manually with the SQL above. |