markdown · 7920 bytes Raw Blame History

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/renamelifecycle.Rename.

  • Auth: repo:admin action 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:
    1. Validate the new name (regex, reserved list, lowercase).
    2. Open a transaction.
    3. INSERT repo_redirects(old_owner_user_id, old_name, repo_id).
    4. UPDATE repos.name. A unique-violation rolls back both → user sees ErrNameTaken (409).
    5. COMMIT.
    6. RepoFS.Move(/data/repos/sh/shithub/old.git, /data/repos/sh/shithub/new.git).
  • FS-failure recovery: a compensating UPDATE reverts repos.name and 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_renamed with {old_name, new_name}.

Transfer

  • Initiate: POST /{owner}/{repo}/settings/transferlifecycle.RequestTransfer. Sender confirms by typing the current owner/repo slug. Inserts a repo_transfer_requests row with expires_at = now() + 7 days, status pending.
  • Accept: POST /transfers/{id}/acceptlifecycle.AcceptTransfer. The recipient is the actor. Atomic transaction:
    1. SELECT … FOR UPDATE on the transfer row to serialize against concurrent decline / cancel.
    2. INSERT redirect from old owner+name.
    3. UPDATE repos.owner_user_id to the recipient (name unchanged).
    4. UPDATE the transfer row to status accepted with timestamp.
    5. COMMIT.
    6. RepoFS.Move from 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 to declined. Repo state unchanged.
  • Cancel: POST /transfers/{id}/cancel. Repo admin (sender) flips status to canceled. Recipient's accept attempts return ErrTransferTerminal (409).
  • Expire: pending transfers past expires_at are flipped to expired by the lifecycle:sweep worker job. Bulk operation; one audit row would be noisy, so the row's terminal status is the audit.

Archive / Unarchive

  • POST /{owner}/{repo}/settings/archiveis_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/visibilitylifecycle.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/deletelifecycle.SoftDelete. Sets deleted_at = now(). Repo disappears from listings, profile pinned slot, search. /{owner}/{repo} 404s for non-owners (auth-aware via S15 policy's DenyRepoDeleted).
  • Restore: owner sees the soft-deleted repo at /settings/repositories. POST to /settings/repositories/restore/{id} clears deleted_at. Past the 7-day grace, restore returns ErrPastGrace (410).
  • Hard delete: lifecycle:sweep worker job (registered in cmd/shithubd/worker.go) runs periodically. The handler:
    1. ListRepoIDsPastSoftDeleteGrace — finds rows past 7 days.
    2. For each, lifecycle.HardDelete runs:
      • OrphanForksOf(repoID) — children's fork_of_repo_id set NULL.
      • DELETE FROM repos WHERE id=$1. FK ON DELETE CASCADE handles push_events, repo_collaborators, repo_redirects (rows pointing at this repo), repo_transfer_requests, and webhook_events_pending.
      • RemoveAll the bare repo on disk.
      • Audit repo_hard_deleted with the row snapshot in meta, since the repo_id no longer resolves.
    3. 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/x and 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:sweep worker 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 take their disk path with them — the bare repo on disk stays for the full grace window. If disk pressure becomes an issue, configure the grace window down (it's a constant in lifecycle.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_events table is the consumer.
  • Periodic enqueue of lifecycle:sweep → tracked in S37 (deploy automation), under shithubd-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. `/{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; S37 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 |
155
156 ## Deferred work (S16 → later sprints)
157
158 Two pieces of S16's spec are intentionally deferred; the receiving
159 sprints have explicit bullets so the work doesn't fall off:
160
161 * **Email notifications** for archive / unarchive / visibility flip /
162 soft-delete / restore / transfer requested / accepted / declined /
163 canceled / expired → tracked in S29 (notifications). The audit rows
164 already exist; S29's `domain_events` table is the consumer.
165 * **Periodic enqueue of `lifecycle:sweep`** → tracked in S37 (deploy
166 automation), under `shithubd-cron.timer`. Today operators kick the
167 job manually with the SQL above.