GitLab批量清理神器:一键删除

1 阅读10分钟

GitLab 清理工具

  • 📋 文档信息

    • 版本: 1.0.0
    • 最后更新: 2026-03-19
    • 适用场景: GitLab 组和项目的批量清理
    • 核心功能: 全量删除、选择性删除、递归删除、断点续删、试运行预览

    一、工具概述

    1.1 为什么需要这个工具?

    在 GitLab 日常维护中,经常需要清理测试环境、旧项目或废弃的组。手动删除大量项目不仅耗时,还容易出错。本工具提供自动化清理方案:

    • 测试环境重置:一键清理所有测试数据
    • 旧项目归档:选择性删除不再使用的项目
    • 组结构清理:递归删除整个组树
    • 安全预览:先看效果再执行

    1.2 核心功能

    功能说明
    ✅ 全量删除一键删除所有组和项目
    ✅ 选择性删除只删除指定的组或项目
    ✅ 递归删除自动先删项目、再删子组、最后删父组
    ✅ 试运行模式只预览不执行,避免误操作
    ✅ 断点续删记录已删除内容,中断后可继续
    ✅ 日志记录所有操作写入文件,便于追溯

    二、快速开始

    2.1 环境准备

    安装依赖

    pip install python-gitlab

    验证安装

    python -c "import gitlab; print('环境就绪')"


三、完整源码

import gitlab
import time
import logging
import json
import os

# 配置日志(同时输出到控制台和文件)
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    datefmt='%Y-%m-%d %H:%M:%S',
    handlers=[
        logging.StreamHandler(),  # 控制台输出
        logging.FileHandler('./clean.log', encoding='utf-8')  # 文件输出
    ]
)

# ==================== 配置区域 ====================
# GitLab 连接配置(只需修改这里)
target_gitlab_url = "http://192.168.1.110:9080"
target_private_token = "your-admin-token"

# 清理选项配置
CLEAN_OPTIONS = {
    # 清理模式: 'all' 全部删除, 'selected' 只删除指定内容
    'mode': 'all',  # 可选 'all' 或 'selected'
    
    # 当 mode='selected' 时,指定要删除的组(支持递归删除)
    'selected_groups': [
        # "test-group",
        # "old-project-2023",
        # "archive/deprecated",
    ],
    
    # 当 mode='selected' 时,指定要删除的项目
    'selected_projects': [
        # "group1/temp-project",
        # "root-level-project",
    ],
    
    # 试运行模式:True 只预览不删除,False 实际执行
    'dry_run': True,  # 建议先设为 True 预览
    
    # 状态文件:记录已删除内容,用于断点续删
    'state_file': './clean_state.json',
}
# ==================================================


class GitLabCleaner:
    """GitLab 清理工具"""
    
    def __init__(self):
        self.gitlab = None
        self.deleted_projects = set()  # 已删除的项目ID
        self.deleted_groups = set()     # 已删除的组ID
        self.stats = {                  # 统计信息
            'projects': 0,   # 成功删除的项目数
            'groups': 0,     # 成功删除的组数
            'failed': 0      # 失败次数
        }
        
        # 加载之前的状态(用于断点续删)
        self.load_state()
        
        logging.info("=" * 60)
        logging.info("GitLab 清理工具启动")
        logging.info(f"目标地址: {target_gitlab_url}")
        logging.info(f"清理模式: {CLEAN_OPTIONS['mode']}")
        logging.info(f"试运行模式: {CLEAN_OPTIONS['dry_run']}")
        logging.info(f"状态文件: {os.path.abspath(CLEAN_OPTIONS['state_file'])}")
        logging.info("=" * 60)
    
    def load_state(self):
        """加载已删除记录(用于断点续删)"""
        if os.path.exists(CLEAN_OPTIONS['state_file']):
            try:
                with open(CLEAN_OPTIONS['state_file'], 'r', encoding='utf-8') as f:
                    state = json.load(f)
                    self.deleted_projects = set(state.get('projects', []))
                    self.deleted_groups = set(state.get('groups', []))
                    self.stats = state.get('stats', self.stats)
                
                logging.info(f"📂 加载上次进度: 已删除 {len(self.deleted_projects)} 个项目, {len(self.deleted_groups)} 个组")
                logging.info(f"   将继续未完成的清理任务")
            except Exception as e:
                logging.warning(f"加载状态文件失败: {e}")
    
    def save_state(self):
        """保存已删除记录(用于断点续删)"""
        if CLEAN_OPTIONS['dry_run']:
            return  # 试运行模式不保存状态
        
        try:
            with open(CLEAN_OPTIONS['state_file'], 'w', encoding='utf-8') as f:
                json.dump({
                    'projects': list(self.deleted_projects),
                    'groups': list(self.deleted_groups),
                    'stats': self.stats,
                    'last_update': time.strftime('%Y-%m-%d %H:%M:%S')
                }, f, ensure_ascii=False, indent=2)
        except Exception as e:
            logging.warning(f"保存状态失败: {e}")
    
    def connect(self):
        """连接 GitLab"""
        try:
            self.gitlab = gitlab.Gitlab(
                target_gitlab_url,
                private_token=target_private_token,
                ssl_verify=False,
                timeout=30,
                keep_base_url=True  # 防止URL重写
            )
            self.gitlab.auth()
            logging.info(f"✅ 连接成功,当前用户: {self.gitlab.user.username}")
            return True
        except Exception as e:
            logging.error(f"❌ 连接失败: {e}")
            return False
    
    def delete_project(self, project_id, project_path):
        """删除单个项目"""
        # 如果已经删除过,跳过
        if project_id in self.deleted_projects:
            return
        
        if CLEAN_OPTIONS['dry_run']:
            logging.info(f"🔍 [试运行] 将删除项目: {project_path}")
            self.stats['projects'] += 1
            return
        
        try:
            self.gitlab.projects.delete(project_id)
            logging.info(f"✅ 已删除项目: {project_path}")
            self.deleted_projects.add(project_id)
            self.stats['projects'] += 1
            self.save_state()  # 每删除一个就保存状态
            time.sleep(0.3)  # 避免请求过快
        except Exception as e:
            logging.error(f"❌ 删除失败 {project_path}: {e}")
            self.stats['failed'] += 1
    
    def delete_group(self, group_id, group_path, depth=0):
        """
        递归删除组
        
        删除顺序:
        1. 先删除组内的所有项目
        2. 再递归删除所有子组
        3. 最后删除当前组
        """
        # 如果已经删除过,跳过
        if group_id in self.deleted_groups:
            return
        
        indent = "  " * depth  # 用于日志缩进,显示层级
        
        try:
            # 获取完整的组对象
            group = self.gitlab.groups.get(group_id)
            
            # 1. 删除组内的所有项目
            projects = group.projects.list(all=True)
            if projects:
                logging.info(f"{indent}📁 组 {group_path} 包含 {len(projects)} 个项目")
                for project in projects:
                    self.delete_project(project.id, f"{group_path}/{project.path}")
            
            # 2. 递归删除所有子组
            subgroups = group.subgroups.list(all=True)
            if subgroups:
                logging.info(f"{indent}📂 组 {group_path} 包含 {len(subgroups)} 个子组")
                for subgroup in subgroups:
                    self.delete_group(subgroup.id, f"{group_path}/{subgroup.path}", depth + 1)
            
            # 3. 删除当前组
            if CLEAN_OPTIONS['dry_run']:
                logging.info(f"{indent}🔍 [试运行] 将删除组: {group_path}")
                self.stats['groups'] += 1
            else:
                group.delete()
                logging.info(f"{indent}✅ 已删除组: {group_path}")
                self.deleted_groups.add(group_id)
                self.stats['groups'] += 1
                self.save_state()
                time.sleep(0.3)
                
        except Exception as e:
            logging.error(f"{indent}❌ 处理组失败 {group_path}: {e}")
            self.stats['failed'] += 1
    
    def run(self):
        """执行清理任务"""
        # 1. 连接 GitLab
        if not self.connect():
            return
        
        # 2. 如果不是试运行模式,需要用户确认
        if not CLEAN_OPTIONS['dry_run']:
            logging.warning("⚠️  警告:此操作将永久删除 GitLab 上的内容!")
            confirm = input("请输入 'YES' 确认删除: ")
            if confirm != "YES":
                logging.info("操作已取消")
                return
        
        # 3. 根据模式执行清理
        if CLEAN_OPTIONS['mode'] == 'all':
            # 模式1:全量删除
            logging.info("\n" + "=" * 60)
            logging.info("开始全量删除")
            logging.info("=" * 60)
            
            # 先删所有组(会自动删组内项目)
            for group in self.gitlab.groups.list(all=True):
                self.delete_group(group.id, group.path)
            
            # 再删根级别项目(不在任何组中的项目)
            for project in self.gitlab.projects.list(all=True):
                self.delete_project(project.id, project.path_with_namespace)
        
        elif CLEAN_OPTIONS['mode'] == 'selected':
            # 模式2:选择性删除
            logging.info("\n" + "=" * 60)
            logging.info("开始选择性删除")
            logging.info("=" * 60)
            
            # 删除指定的组
            if CLEAN_OPTIONS['selected_groups']:
                logging.info("\n📂 删除指定的组:")
                for group_path in CLEAN_OPTIONS['selected_groups']:
                    try:
                        group = self.gitlab.groups.get(group_path)
                        self.delete_group(group.id, group_path)
                    except Exception as e:
                        logging.error(f"组不存在或无法访问: {group_path}")
            
            # 删除指定的项目
            if CLEAN_OPTIONS['selected_projects']:
                logging.info("\n📁 删除指定的项目:")
                for project_path in CLEAN_OPTIONS['selected_projects']:
                    try:
                        project = self.gitlab.projects.get(project_path)
                        self.delete_project(project.id, project_path)
                    except Exception as e:
                        logging.error(f"项目不存在或无法访问: {project_path}")
        
        else:
            logging.error(f"未知的清理模式: {CLEAN_OPTIONS['mode']}")
            return
        
        # 4. 输出统计信息
        self.print_stats()
    
    def print_stats(self):
        """打印统计信息"""
        logging.info("\n" + "=" * 60)
        if CLEAN_OPTIONS['dry_run']:
            logging.info("试运行统计(实际未删除)")
        else:
            logging.info("清理完成统计")
        logging.info("=" * 60)
        logging.info(f"✅ 成功删除项目: {self.stats['projects']} 个")
        logging.info(f"✅ 成功删除组: {self.stats['groups']} 个")
        logging.info(f"❌ 失败次数: {self.stats['failed']} 个")
        logging.info("=" * 60)
        logging.info(f"📁 状态文件: {os.path.abspath(CLEAN_OPTIONS['state_file'])}")
        logging.info(f"📁 日志文件: {os.path.abspath('./clean.log')}")
        logging.info("=" * 60)


if __name__ == "__main__":
    try:
        cleaner = GitLabCleaner()
        cleaner.run()
    except KeyboardInterrupt:
        logging.info("⏸️  用户中断,当前进度已保存")
    except Exception as e:
        logging.error(f"程序异常: {e}")

四、配置详解

4.1 基础连接配置

# 必须修改的部分
target_gitlab_url = "http://192.168.1.110:9080"  # 你的 GitLab 地址
target_private_token = "glpat-your-token-here"   # 管理员 token

4.2 清理模式配置

模式1:全量删除所有内容
CLEAN_OPTIONS = {
   'mode': 'all',           # 全量模式
  'dry_run': True,         # 建议先试运行
    # 其他选项不需要填写
}
模式2:删除指定的组
CLEAN_OPTIONS = {
  'mode': 'selected',
  'selected_groups': [
  	"test-group",                    # 删除整个测试组
      "old-project-2023",               # 删除旧项目组
      "archive/deprecated",              # 删除深层子组
  ],
  'selected_projects': [],               # 不单独删项目
  'dry_run': True,
}
模式3:删除指定的项目
CLEAN_OPTIONS = {
  'mode': 'selected',
  'selected_groups': [],                  # 不删组
  'selected_projects': [
      "group1/temp-project",               # 组内项目
      "group2/subgroup/test-app",          # 深层项目
    	"root-level-project",                 # 根级别项目
  ],
  'dry_run': True,
}
模式4:混合删除
CLEAN_OPTIONS = {
  'mode': 'selected',
  'selected_groups': [
      "archive-2022",        # 删除整个归档组
  ],
  'selected_projects': [
      "active-group/legacy", # 只删除活动组中的遗留项目
  ],
  'dry_run': True,
}

4.3 试运行模式

'dry_run': True   # 只预览,不实际删除(推荐先使用)
'dry_run': False  # 实际执行删除(需要输入 YES 确认)

五、使用步骤

第一步:试运行预览

  # 1. 设置 dry_run = True
# 2. 运行脚本
  python gitlab_cleaner.py

试运行输出示例:

2026-03-19 15:30:45 - INFO - ============================================================
2026-03-19 15:30:45 - INFO - GitLab 清理工具启动
2026-03-19 15:30:45 - INFO - 目标地址: http://192.168.1.110:9080
2026-03-19 15:30:45 - INFO - 清理模式: selected
2026-03-19 15:30:45 - INFO - 试运行模式: True
2026-03-19 15:30:45 - INFO - ============================================================
2026-03-19 15:30:46 - INFO - 🔍 [试运行] 将删除组: test-group
2026-03-19 15:30:46 - INFO -   🔍 [试运行] 将删除项目: test-group/project1
2026-03-19 15:30:46 - INFO -   🔍 [试运行] 将删除项目: test-group/project2
...
2026-03-19 15:31:00 - INFO - ============================================================
2026-03-19 15:31:00 - INFO - 试运行统计(实际未删除)
2026-03-19 15:31:00 - INFO - ============================================================
2026-03-19 15:31:00 - INFO - ✅ 成功删除项目: 25 个
2026-03-19 15:31:00 - INFO - ✅ 成功删除组: 5 个
2026-03-19 15:31:00 - INFO - ❌ 失败次数: 0 个

第二步:确认后实际执行

# 1. 设置 dry_run = False
# 2. 再次运行
python gitlab_cleaner.py
# 3. 输入 YES 确认

实际执行确认提示:

⚠️  警告:此操作将永久删除 GitLab 上的内容!
请输入 'YES' 确认删除: YES

第三步:中断后继续

# 如果中途中断(Ctrl+C),下次运行会自动继续
python gitlab_cleaner.py
2026-03-19 16:00:00 - INFO - 📂 加载上次进度: 已删除 50 个项目, 10 个组
2026-03-19 16:00:00 - INFO -    将继续未完成的清理任务

六、文件说明

运行后会在当前目录生成:

your-script-directory/
├── gitlab_cleaner.py      # 主程序
├── clean_state.json       # 状态文件(记录已删除内容)
└── clean.log              # 日志文件(所有操作记录)

6.1 状态文件格式

{
  "projects": [123, 456, 789],        // 已删除的项目ID
  "groups": [111, 222, 333],          // 已删除的组ID
  "stats": {                           // 统计信息
    "projects": 50,
    "groups": 10,
    "failed": 0
},
  "last_update": "2026-03-19 15:30:45" // 最后更新时间
}

6.2 文件用途

文件作用能否删除
clean_state.json记录已删除内容,用于断点续删⚠️ 删除后无法继续
clean.log记录所有操作日志✅ 可随时删除

七、使用示例

示例1:清理测试环境

# 配置
target_gitlab_url = "http://test.gitlab.com"
CLEAN_OPTIONS = {
    'mode': 'all',
    'dry_run': True,   # 先预览
}

# 预览确认后改为 False 执行

示例2:删除旧项目归档

CLEAN_OPTIONS = {
    'mode': 'selected',
  'selected_groups': [
        "archive-2022",
      "archive-2023",
    ],
    'dry_run': True,
}

示例3:删除单个项目

CLEAN_OPTIONS = {
    'mode': 'selected',
  'selected_projects': [
        "test-group/bug-repro",
    ],
    'selected_groups': [],
    'dry_run': True,
}

示例4:分批次清理

# 第一批
CLEAN_OPTIONS = {
  'mode': 'selected',
    'selected_groups': ["group1", "group2"],
}

# 第二批(会自动跳过已删除的)
CLEAN_OPTIONS = {
    'mode': 'selected',
    'selected_groups': ["group3", "group4"],
}

八、注意事项

8.1 权限要求

  • 必须使用管理员 token,否则可能无法删除某些内容

    • token 需要 api 权限

    8.2 安全机制

    1. 试运行模式:先预览再执行
    2. 手动确认:实际执行需要输入 YES
    3. 状态记录:已删除的内容不会重复删除
    4. 中断保护:Ctrl+C 会保存当前进度

    8.3 删除顺序(自动处理)

 1.先删项目 → 2. 再删子组 → 3. 最后删父组
 不需要手动关心顺序,工具会自动处理。

8.4 常见问题

Q: 如何从头开始清理?

 # 删除状态文件即可
 rm clean_state.json

Q: 删除过程中断了怎么办?

# 直接重新运行,会自动继续
python gitlab_cleaner.py

Q: 如何只预览不删除?

'dry_run': True

Q: 误删了重要内容?

GitLab 删除操作不可逆,务必先试运行确认!

九、版本历史

版本日期更新内容
1.0.02026-03-19初始版本,支持全量/选择性删除、递归删除、断点续删、试运行

十、总结

这个工具的核心优势:

  • 简单:只有 200 行代码,易于理解和修改
  • 安全:试运行模式 + 手动确认
  • 可靠:断点续删,不怕中断
  • 灵活:支持全量删除和选择性删除
  • 完整:自动处理删除顺序,递归删除所有内容

使用口诀:先试运行预览,确认后再执行,中断不用怕,继续接着删。