A deleted account is ghost on its commits for its 30 days, and itself again when restored
email_owners left out accounts in their deletion window, so their commits showed the raw git name until the purge, when noreply ones turned to ghost. It now reads deleted accounts too and answers ghost for one that is deleted, purged or waiting to be: by a confirmed address or the noreply address, in commit lists, the Files bar, blame, branches and everything else that shows commits through commit-people. A restore clears deleted_at, so the same lookup answers the account again. Contributors are kept per repository (repo_stats), so repos now marks the kept answers that name the account on user.deleting, and those that name ghost on user.restored, to be worked out again on their next view. Tests: identity's shown_owner, repos' shown_differently. Docs: the deletion table and the commit address table say so.
| 1042 | 1042 | | Access tokens, SSH keys and applications | Your personal access tokens (classic and fine-grained), SSH keys, connected applications and sign-ins from a tool stop working and are removed, and so do the deploy keys you added to repositories. A workspace's own tokens are not affected, even ones you made. | | |
| 1043 | 1043 | | Workspaces, teams and repositories | You leave every workspace and team, and lose the roles you were given on single repositories. Repository invitations waiting for you are withdrawn, and invites you made that nobody used are revoked. | | |
| 1044 | 1044 | | Your profile | `g1t.sh/<username>` answers 404, and you drop out of search. Nobody can add you to a workspace, team or repository, and nothing more is emailed to you. | | |
| 1045 | − | | What you wrote | Stays where it is, under your username for now. | | |
| 1045 | + | | What you wrote | Stays where it is, under your username for now. Commits made with your confirmed or noreply addresses show as `ghost`, and as yours again if your account is restored. | | |
| 1046 | 1046 | | Your username | Held for your account. Nobody else can take it. | | |
| 1047 | 1047 | ||
| 1048 | 1048 | Within 30 days, support can restore it: write to support@g1t.sh from one |
| 732 | 732 | | One of your confirmed addresses, or your noreply address | You | | |
| 733 | 733 | | An address added to an account but not confirmed | The name in the commit | | |
| 734 | 734 | | An address no account has | The name in the commit, with a plain picture, no link and no card | | |
| 735 | − | | A deleted account's noreply address | `ghost` | | |
| 735 | + | | A deleted account's noreply address, or any of its confirmed addresses during the 30 days it can be restored | `ghost` | | |
| 736 | 736 | | g1t's own (`g1t@users.noreply.g1t.sh`) | `g1t` | | |
| 737 | 737 | ||
| 738 | 738 | `Co-authored-by` trailers are matched the same way, and their pictures sit |
| 982 | 982 | id: String, | |
| 983 | 983 | username: String, | |
| 984 | 984 | avatar: Option<String>, | |
| 985 | − | /// 1 for a purged account's username (`deleted_users`). | |
| 985 | + | /// 1 for an account that is deleted: purged (its username is | |
| 986 | + | /// in `deleted_users`) or in its window to be restored. | |
| 986 | 987 | #[serde(default)] | |
| 987 | − | purged: u8, | |
| 988 | + | gone: u8, | |
| 988 | 989 | } | |
| 989 | 990 | let mut owners = HashMap::new(); | |
| 990 | 991 | let mut plain: Vec<String> = Vec::new(); | |
| ⋯ | |||
| 1007 | 1008 | let rows = self | |
| 1008 | 1009 | .db | |
| 1009 | 1010 | .prepare(format!( | |
| 1010 | − | "SELECT e.email, u.id, u.username, u.avatar FROM user_emails e JOIN users u ON u.id = e.user_id | |
| 1011 | − | WHERE e.verified_at IS NOT NULL AND u.deleted_at IS NULL AND e.email IN ({marks})" | |
| 1011 | + | "SELECT e.email, u.id, u.username, u.avatar, u.deleted_at IS NOT NULL AS gone | |
| 1012 | + | FROM user_emails e JOIN users u ON u.id = e.user_id | |
| 1013 | + | WHERE e.verified_at IS NOT NULL AND e.email IN ({marks})" | |
| 1012 | 1014 | )) | |
| 1013 | 1015 | .bind(&bind)? | |
| 1014 | 1016 | .all() | |
| 1015 | 1017 | .await? | |
| 1016 | 1018 | .results::<Row>()?; | |
| 1017 | 1019 | for row in rows { | |
| 1018 | − | owners.insert(row.email, EmailOwner { id: row.id, username: row.username, avatar: row.avatar }); | |
| 1020 | + | owners.insert(row.email, shown_owner(row.id, row.username, row.avatar, row.gone != 0)); | |
| 1019 | 1021 | } | |
| 1020 | 1022 | } | |
| 1021 | 1023 | if !by_name.is_empty() { | |
| ⋯ | |||
| 1025 | 1027 | let rows = self | |
| 1026 | 1028 | .db | |
| 1027 | 1029 | .prepare(format!( | |
| 1028 | − | "SELECT username AS email, id, username, avatar, 0 AS purged FROM users | |
| 1029 | − | WHERE username IN ({marks}) AND deleted_at IS NULL | |
| 1030 | + | "SELECT username AS email, id, username, avatar, deleted_at IS NOT NULL AS gone FROM users | |
| 1031 | + | WHERE username IN ({marks}) | |
| 1030 | 1032 | UNION ALL | |
| 1031 | − | SELECT username AS email, user_id AS id, username, NULL AS avatar, 1 AS purged FROM deleted_users | |
| 1033 | + | SELECT username AS email, user_id AS id, username, NULL AS avatar, 1 AS gone FROM deleted_users | |
| 1032 | 1034 | WHERE username IN ({marks})" | |
| 1033 | 1035 | )) | |
| 1034 | 1036 | .bind(&[bind.clone(), bind].concat())? | |
| ⋯ | |||
| 1039 | 1041 | if let Some(row) = rows.iter().find(|row| row.username == username) | |
| 1040 | 1042 | && suffix.as_deref().is_none_or(|suffix| id_suffix(&row.id) == suffix) | |
| 1041 | 1043 | { | |
| 1042 | − | // A purged account's commits are ghost's (account_deletion.rs). | |
| 1043 | − | let owner = if row.purged != 0 { | |
| 1044 | − | EmailOwner { id: GHOST_ID.to_owned(), username: GHOST_USERNAME.to_owned(), avatar: None } | |
| 1045 | − | } else { | |
| 1046 | − | EmailOwner { id: row.id.clone(), username: row.username.clone(), avatar: row.avatar.clone() } | |
| 1047 | − | }; | |
| 1048 | − | owners.insert(email, owner); | |
| 1044 | + | owners.insert(email, shown_owner(row.id.clone(), row.username.clone(), row.avatar.clone(), row.gone != 0)); | |
| 1049 | 1045 | } | |
| 1050 | 1046 | } | |
| 1051 | 1047 | } | |
| ⋯ | |||
| 1237 | 1233 | pub display: String, | |
| 1238 | 1234 | } | |
| 1239 | 1235 | ||
| 1236 | + | /// Who an address's commits are shown as: the account, or ghost for a | |
| 1237 | + | /// deleted one. An account in its window to be restored is ghost already, | |
| 1238 | + | /// as it will be once purged (account_deletion.rs), and is itself again if | |
| 1239 | + | /// staff restore it, since nothing about it is changed here. | |
| 1240 | + | fn shown_owner(id: String, username: String, avatar: Option<String>, gone: bool) -> EmailOwner { | |
| 1241 | + | if gone { | |
| 1242 | + | EmailOwner { id: GHOST_ID.to_owned(), username: GHOST_USERNAME.to_owned(), avatar: None } | |
| 1243 | + | } else { | |
| 1244 | + | EmailOwner { id, username, avatar } | |
| 1245 | + | } | |
| 1246 | + | } | |
| 1247 | + | ||
| 1240 | 1248 | #[cfg(test)] | |
| 1241 | 1249 | mod tests { | |
| 1242 | 1250 | use super::*; | |
| 1243 | 1251 | ||
| 1252 | + | #[test] | |
| 1253 | + | fn a_deleted_account_is_ghost_on_its_commits_until_restored() { | |
| 1254 | + | let live = shown_owner("usr_ana".into(), "ana".into(), Some("abc".into()), false); | |
| 1255 | + | assert_eq!((live.id.as_str(), live.username.as_str(), live.avatar.as_deref()), ("usr_ana", "ana", Some("abc"))); | |
| 1256 | + | // Deleted and restorable, or purged: ghost, with nothing of the account. | |
| 1257 | + | let gone = shown_owner("usr_ana".into(), "ana".into(), Some("abc".into()), true); | |
| 1258 | + | assert_eq!((gone.id.as_str(), gone.username.as_str(), gone.avatar), (GHOST_ID, GHOST_USERNAME, None)); | |
| 1259 | + | } | |
| 1260 | + | ||
| 1244 | 1261 | fn row(id: &str, email: &str, verified: bool, created: &str) -> EmailRow { | |
| 1245 | 1262 | EmailRow { | |
| 1246 | 1263 | id: id.into(), | |
| 2686 | 2686 | } | |
| 2687 | 2687 | continue; | |
| 2688 | 2688 | } | |
| 2689 | + | // An account deleted or restored: kept contributors that name it | |
| 2690 | + | // (or ghost, for a restore) are worked out again, so it shows as | |
| 2691 | + | // ghost for its 30 days, and as itself again if restored. | |
| 2692 | + | if let Some(needle) = stats::shown_differently(&event.kind, &event.data) { | |
| 2693 | + | let db = env.d1("DB")?; | |
| 2694 | + | if let Err(error) = stats::rework_naming(&db, &needle).await { | |
| 2695 | + | worker::console_error!("{} {}: contributors not marked to be counted again: {error}", event.kind, event.id); | |
| 2696 | + | } | |
| 2697 | + | continue; | |
| 2698 | + | } | |
| 2689 | 2699 | if event.kind != "workspace.renamed" { | |
| 2690 | 2700 | continue; | |
| 2691 | 2701 | } |
| 13 | 13 | use std::collections::{HashMap, HashSet}; | |
| 14 | 14 | ||
| 15 | 15 | use g1t_contracts::about::{ABOUT_CONTRIBUTORS, Contributor, Contributors, Freshness, LanguageShare, Languages, License, WeekCommits}; | |
| 16 | + | use g1t_contracts::account_deletion::GHOST_USERNAME; | |
| 16 | 17 | use g1t_contracts::accounts::{EmailOwner, EmailOwnersArgs}; | |
| 17 | 18 | use g1t_contracts::repos::{FileEntry, MAX_LISTED_FILES, Repo}; | |
| 18 | 19 | use g1t_contracts::time::rfc3339; | |
| ⋯ | |||
| 377 | 378 | owners | |
| 378 | 379 | } | |
| 379 | 380 | ||
| 381 | + | /// What a kept contributors answer says when it names an account that | |
| 382 | + | /// `kind` (a `user.deleting` or `user.restored` event with `data`) changes: | |
| 383 | + | /// a deleted account's username, which is ghost from now on, or ghost's, | |
| 384 | + | /// which a restored account may be among. `None` for any other event. | |
| 385 | + | pub fn shown_differently(kind: &str, data: &serde_json::Value) -> Option<String> { | |
| 386 | + | let username = match kind { | |
| 387 | + | "user.deleting" => data["username"].as_str().filter(|name| !name.is_empty())?.to_owned(), | |
| 388 | + | "user.restored" => GHOST_USERNAME.to_owned(), | |
| 389 | + | _ => return None, | |
| 390 | + | }; | |
| 391 | + | Some(format!("\"username\":{}", serde_json::Value::String(username))) | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | /// Marks every repository whose kept contributors contain `needle` (see | |
| 395 | + | /// [`shown_differently`]) to be worked out again on its next view, as an | |
| 396 | + | /// older version's would be. | |
| 397 | + | pub async fn rework_naming(db: &D1Database, needle: &str) -> Result<()> { | |
| 398 | + | db.prepare("UPDATE repo_stats SET version = 0 WHERE instr(contributors, ?1) > 0") | |
| 399 | + | .bind(&[needle.into()])? | |
| 400 | + | .run() | |
| 401 | + | .await?; | |
| 402 | + | Ok(()) | |
| 403 | + | } | |
| 404 | + | ||
| 380 | 405 | /// Whether the repository is one whose About is worked out: not a pull | |
| 381 | 406 | /// request's working copy. | |
| 382 | 407 | pub fn has_about(repo: &Repo) -> bool { | |
| ⋯ | |||
| 392 | 417 | } | |
| 393 | 418 | ||
| 394 | 419 | #[test] | |
| 420 | + | fn deleting_or_restoring_an_account_reworks_the_answers_that_name_it() { | |
| 421 | + | let kept = serde_json::to_string(&Contributor { | |
| 422 | + | kind: g1t_contracts::about::ContributorKind::User, | |
| 423 | + | name: "ana".into(), | |
| 424 | + | username: Some("ana".into()), | |
| 425 | + | avatar: None, | |
| 426 | + | commits: 3, | |
| 427 | + | first_at: "2026-10-01T00:00:00Z".into(), | |
| 428 | + | last_at: "2026-10-02T00:00:00Z".into(), | |
| 429 | + | weeks: Vec::new(), | |
| 430 | + | }) | |
| 431 | + | .unwrap(); | |
| 432 | + | let deleting = shown_differently("user.deleting", &serde_json::json!({ "userId": "usr_ana", "username": "ana" })).unwrap(); | |
| 433 | + | assert!(kept.contains(&deleting)); | |
| 434 | + | // Not a longer name that starts the same. | |
| 435 | + | assert!(!kept.replace("\"ana\"", "\"anabel\"").contains(&deleting)); | |
| 436 | + | // A restored account may be any ghost: those answers are worked out again. | |
| 437 | + | let restored = shown_differently("user.restored", &serde_json::json!({ "userId": "usr_ana", "username": "ana" })).unwrap(); | |
| 438 | + | assert!(kept.replace("\"ana\"", "\"ghost\"").contains(&restored)); | |
| 439 | + | assert_eq!(shown_differently("user.updated", &serde_json::json!({ "username": "ana" })), None); | |
| 440 | + | assert_eq!(shown_differently("user.deleting", &serde_json::json!({})), None); | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | #[test] | |
| 395 | 444 | fn a_security_policy_is_found_where_it_may_be() { | |
| 396 | 445 | assert_eq!(security_policy(&[file("src/SECURITY.md"), file(".github/SECURITY.md")]).as_deref(), Some(".github/SECURITY.md")); | |
| 397 | 446 | assert_eq!(security_policy(&[file("docs/security.md"), file("SECURITY.md")]).as_deref(), Some("SECURITY.md")); | |