Skip to content

Commit

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.

syntaqxcommitted Parent230b67bBrowse files
5 files+93−170/5 viewed
+1−1
10421042 | 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. |
10431043 | 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. |
10441044 | 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. |
10461046 | Your username | Held for your account. Nobody else can take it. |
10471047
10481048 Within 30 days, support can restore it: write to support@g1t.sh from one
+1−1
732732 | One of your confirmed addresses, or your noreply address | You |
733733 | An address added to an account but not confirmed | The name in the commit |
734734 | 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` |
736736 | g1t's own (`g1t@users.noreply.g1t.sh`) | `g1t` |
737737
738738 `Co-authored-by` trailers are matched the same way, and their pictures sit
+32−15
982982 id: String,
983983 username: String,
984984 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.
986987 #[serde(default)]
987− purged: u8,
988+ gone: u8,
988989 }
989990 let mut owners = HashMap::new();
990991 let mut plain: Vec<String> = Vec::new();
10071008 let rows = self
10081009 .db
10091010 .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})"
10121014 ))
10131015 .bind(&bind)?
10141016 .all()
10151017 .await?
10161018 .results::<Row>()?;
10171019 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));
10191021 }
10201022 }
10211023 if !by_name.is_empty() {
10251027 let rows = self
10261028 .db
10271029 .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})
10301032 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
10321034 WHERE username IN ({marks})"
10331035 ))
10341036 .bind(&[bind.clone(), bind].concat())?
10391041 if let Some(row) = rows.iter().find(|row| row.username == username)
10401042 && suffix.as_deref().is_none_or(|suffix| id_suffix(&row.id) == suffix)
10411043 {
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));
10491045 }
10501046 }
10511047 }
12371233 pub display: String,
12381234 }
12391235
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+
12401248 #[cfg(test)]
12411249 mod tests {
12421250 use super::*;
12431251
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+
12441261 fn row(id: &str, email: &str, verified: bool, created: &str) -> EmailRow {
12451262 EmailRow {
12461263 id: id.into(),
+10−0
26862686 }
26872687 continue;
26882688 }
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+ }
26892699 if event.kind != "workspace.renamed" {
26902700 continue;
26912701 }
+49−0
1313 use std::collections::{HashMap, HashSet};
1414
1515 use g1t_contracts::about::{ABOUT_CONTRIBUTORS, Contributor, Contributors, Freshness, LanguageShare, Languages, License, WeekCommits};
16+use g1t_contracts::account_deletion::GHOST_USERNAME;
1617 use g1t_contracts::accounts::{EmailOwner, EmailOwnersArgs};
1718 use g1t_contracts::repos::{FileEntry, MAX_LISTED_FILES, Repo};
1819 use g1t_contracts::time::rfc3339;
377378 owners
378379 }
379380
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+
380405 /// Whether the repository is one whose About is worked out: not a pull
381406 /// request's working copy.
382407 pub fn has_about(repo: &Repo) -> bool {
392417 }
393418
394419 #[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]
395444 fn a_security_policy_is_found_where_it_may_be() {
396445 assert_eq!(security_policy(&[file("src/SECURITY.md"), file(".github/SECURITY.md")]).as_deref(), Some(".github/SECURITY.md"));
397446 assert_eq!(security_policy(&[file("docs/security.md"), file("SECURITY.md")]).as_deref(), Some("SECURITY.md"));