Deleting a GitLab project gives it a new name
Contents
22:41:05 UTC. The scrubbed history is ready: one orphan commit, nothing personal in it. The old history is parked on a local branch called old-history-local, just in case.
22:41:09. glab api -X DELETE projects/<group>%2F<repo>. The answer is the shortest thing GitLab will ever say:
{"message":"202 Accepted"}
still exists: <group>/<repo>-deletion_scheduled-<id> marked_for_deletion_on= 2026-10-11
Five seconds later, the second line. That one is a Python one-liner of mine over GET /projects/:id on the original path, and it was supposed to print a 404. Instead the project answered, under a name with a suffix bolted on.
"Accepted" turned out to mean what it means on a parcel slip: received, not delivered.
Why delete a private repo at all#
The repo is private, a personal-data project. Findings that belonged in a gitignored notes file had leaked into the README and docs. The goal was no trace on the host at all.
A force push looks like the obvious fix, and GitLab's own docs explain why it isn't one:
Even with an updated history, old commits can still be accessed by commit SHA, at least until all the automated cleanup of detached commits is performed, or a cleanup is run manually.
So the plan was blunter. Build a fresh single-commit history, delete the project, recreate it under the same name, push. For a young repo, that's cheaper than GitLab's blob-removal tooling.
Delayed deletion: the project goes into a holding pen#
The rename is GitLab's delayed deletion. Since 18.0 it's the default on every tier, personal projects included. The GitLab.com settings page puts the delay at 30 days. A history note says it was seven until 18.0.2. During those 30 days the project is restorable, which is the whole point. For an accidental click, that's a kindness. For a deliberate scrub, it's a month-long copy of exactly what you were trying to remove.
Two details the docs don't prepare you for:
- The rename. None of the pages I fetched mention the
-deletion_scheduled-<id>suffix. And the probe above asked for the original path. GitLab followed it to the renamed project without comment. - The date. The API reference describes
marked_for_deletion_onas the "Date when the project is scheduled for deletion." It held 2026-10-11: the day of the DELETE itself.
The second DELETE, and two docs that disagree#
The user docs say the way out in one line: "Delete a project again to remove it immediately." Over the API, that's permanently_remove=true plus full_path, both pointed at the renamed path, URL-encoded.
22:41:23. Second DELETE. Eight seconds of sleep, then a GET on the renamed path and one on the original:
{"message":"202 Accepted"}
{"message":"404 Project Not Found"}glab: 404 Project Not Found (HTTP 404)
{"message":"404 Project Not Found"}glab: 404 Project Not Found (HTTP 404)
22:41:47. Recreated the project, pushed the single commit. 22:41:58. Asked the new project for three commit SHAs from before the scrub, and for the new one. The command cut each reply at 70 bytes:
92c7521: {"message":"404 Commit Not Found"}glab: 404 Commit Not Found (HTTP 404
925f5e5: {"message":"404 Commit Not Found"}glab: 404 Commit Not Found (HTTP 404
8d4e4da: {"message":"404 Commit Not Found"}glab: 404 Commit Not Found (HTTP 404
f1f3685: {"id":"f1f3685f0fe3654cb09caffed3c30a9a7bf06b85","short_id":"f1f3685f"
Under fifty seconds from the first DELETE (22:41:09) to that check (22:41:58). Done, then.
Except the API reference, on the same endpoint, says permanently_remove is "Disabled on GitLab.com and Dedicated", and adds a warning: "On GitLab.com, after a project is deleted, its data is retained for 30 days, and permanent deletion is not available." One page says delete twice. The other says you can't, and points you at a support ticket.
GitLab.com accepted the parameter and both names went to 404. That is everything I can verify from outside. A 404 is the API's view. What GitLab keeps on its own disks is the API reference's claim to make, and it claims 30 days.
pre-commit can't see commits that already exist#
The guard had gone in 25 seconds before the first DELETE: a pre-commit hook, wired through core.hooksPath, that does two things. It refuses personal-data paths. It also runs every added line through grep -E -f against a gitignored file of regexes, so the patterns themselves never get committed.
It works. It also has nothing to say about the eleven pre-scrub commits still sitting on old-history-local. A hook that runs at commit time can't see commits that already exist. git push --all doesn't ask it.
I rebuilt the setup in a throwaway repo with fake data: a pre-scrub branch carrying home address: 12 Example Street, a clean main, and the same pre-commit hook. The hook refused a new line matching home address. Then git push --all origin, to a local bare remote:
To ../remote.git
* [new branch] main -> main
* [new branch] old-history-local -> old-history-local
push rc=0
git show old-history-local:README.md on the remote printed the address. Nothing panicked. The guard was never asked.
pre-push sees what's leaving#
Git hands the pre-push hook one line per ref on stdin: local ref, local SHA, remote ref, remote SHA. "If this hook exits with a non-zero status, git push will abort without pushing anything." That's the right place for both rules: which refs may leave, and what every outgoing commit contains.
#!/bin/sh
# stdin: <local-ref> <local-sha> <remote-ref> <remote-sha>, one line per ref
z=0000000000000000000000000000000000000000
fail=0
while read -r lref lsha rref rsha; do
[ "$lsha" = "$z" ] && continue # deletions carry no data
if [ "$rref" != "refs/heads/main" ]; then
echo "pre-push: refusing to push $lref -> $rref (only main is allowed)" >&2; fail=1; continue
fi
if [ "$rsha" = "$z" ]; then range="$lsha"; else range="$rsha..$lsha"; fi
for c in $(git rev-list "$range"); do
bad=$(git ls-tree -r --name-only "$c" | grep -E '^data/|\.(sqlite|db|zip)$|\.local\.md$|^\.private-patterns$')
[ -n "$bad" ] && { printf 'pre-push: commit %s has personal-data paths:\n%s\n' "$c" "$bad" >&2; fail=1; }
if [ -f .private-patterns ] && git grep -q -i -E -f .private-patterns "$c" -- 2>/dev/null; then
echo "pre-push: commit $c matches .private-patterns:" >&2
git grep -n -i -E -f .private-patterns "$c" -- | head -5 >&2; fail=1
fi
done
done
exit $fail
It scans the full tree of every outgoing commit, so the branch name stops mattering. Renaming the problem doesn't help. In the demo, git push --dry-run origin old-history-local:main got past the branch rule and stopped at the content scan:
pre-push: commit 2fd41aa692ac358d4ad1fe544d4a9df360765c89 matches .private-patterns:
2fd41aa692ac358d4ad1fe544d4a9df360765c89:README.md:1:home address: 12 Example Street
error: failed to push some refs to '../remote.git'
push rc=1
On the real repo, remote.origin.push is also set to refs/heads/main:refs/heads/main, so a bare git push only offers main in the first place. Then the real git push --all --dry-run:
pre-push: refusing to push refs/heads/old-history-local -> refs/heads/old-history-local (only main is allowed)
error: failed to push some refs to 'gitlab.com:<group>/<repo>.git'
push --all rc=0
Refused. And rc=0, because the command piped git push into tail -3, and $? belonged to tail. The guard held. The check reporting on it said everything was fine, as these checks tend to.
Two limits worth stating. git push --no-verify bypasses the hook "completely", in git-push's own word. And core.hooksPath is local config, so a fresh clone runs no hooks until it's set again. This guards against a tired evening. An adversary runs --no-verify.
Aside: the vendored JS that wasn't there#
One more file was missing from the clean commit, for a dumber reason. The global ~/.config/git/ignore carries a vendor/ line, and it had quietly dropped a vendored JS library from the repo. In the throwaway repo, with vendor/ in core.excludesFile, git status --short --untracked-files=all printed nothing at all. After adding !viewer/static/vendor/ to the repo's own .gitignore:
?? .gitignore
?? viewer/static/vendor/lib.min.js
gitignore precedence puts core.excludesFile last, below .gitignore and info/exclude, so a repo-level negation beats a global rule. The global ignore file never errors. It applies to every repo on the machine, including the ones it was never written for.
The push hook now refuses old-history-local by name, and its contents by pattern. Eleven commits, kept just in case, with nowhere they're allowed to go.
Filed under: gitlab, git, security
Sources#
- GitLab REST API: Projects — Delete a project — 30-day retention on GitLab.com;
permanently_removeandfull_path, "Disabled on GitLab.com and Dedicated";marked_for_deletion_on. - GitLab: Manage projects — Delete a project — pending deletion by default since 18.0; "Delete a project again to remove it immediately."
- GitLab.com settings — Delayed project deletion — 30-day delay, raised from seven days in 18.0.2.
- GitLab: Undo changes — old commits stay reachable by SHA after a history rewrite until cleanup.
- GitLab: Repository size — Remove data from a repository — the documented blob-removal and redaction route.
- git: githooks(5) —
pre-pushstdin format and abort semantics;pre-commitand--no-verify. - git: gitignore(5) — pattern source precedence.
- git: git-push(1) —
--no-verifybypasses thepre-pushhook.