告别盲目管理!Git数据驱动:精准量化团队开发效率与代码贡献

目录

一、统计需求与技术选型

1.1 核心统计维度

1.2 技术栈选型

二、核心模块实现:从 Git 数据到有效代码统计

2.1 模块 1:Git 提交数据获取(GitPython 实战)

关键实现说明

2.2 模块 2:过滤规则(排除二进制、资源等)

关键实现说明

2.3 模块 3:代码仓库格式转化及一致性检查,日志初始化(防止拉取代码时出现异常)

关键实现说明

2.4 模块 4:加载代码仓库、访问账户、团队成员等配置(基础配置)

关键实现说明

2.5 模块 5:入口定义,真实名字映射及多仓库统计(统计入口)

关键实现说明

三、整体整合:打造完整统计工具

关键实现说明

四、配置示例:

五、注意事项与扩展建议

5.1 统计结果的 “正确打开方式”

5.2 功能扩展建议

六、总结


 

class 卑微码农:
    def __init__(self):
        self.技能 = ['能读懂十年前祖传代码', '擅长用Ctrl+C/V搭建世界', '信奉"能跑就别动"的玄学']
        self.发量 = 100  # 初始发量
        self.咖啡因耐受度 = '极限'
        
    def 修Bug(self, bug):
        try:
            # 试图用玄学解决问题
            if bug.严重程度 == '离谱':
                print("这一定是环境问题!")
            else:
                print("让我看看是谁又没写注释...哦,是我自己。")
        except Exception as e:
            # 如果try块都救不了,那就...
            print("重启一下试试?")
            self.发量 -= 1  # 每解决一个bug,头发-1
 
 
# 实例化一个我
我 = 卑微码农()

引言

团队开发中,“开发效率” 总是个模糊的话题 —— 有人说 “提交次数多就是效率高”,有人觉得 “改 bug 也算工作量”。但靠主观判断不靠谱,不如从 Git 提交记录入手,用数据说话。Git 作为团队协作的核心工具,每一次 commit 都藏着开发痕迹,通过分析这些记录,我们能客观统计出成员的有效代码行提交、任务分布、迭代节奏,让效率评估有迹可循。

本文将用 Python+GitPython 打造一个轻量统计工具,从 “拉取 Git 提交数据” 到 “计算有效代码行”,再到 “生成可视化报表”,全程带代码示例,新手也能跟着实现。重点解决 “如何区分有效代码和无效提交”“如何兼容不同编程语言注释” 等实际问题,最终给出可落地的团队效率统计方案。

一、统计需求与技术选型

在写代码前,先明确我们要统计什么 —— 避免为了统计而统计,只关注对 “效率评估” 有价值的维度。

1.1 核心统计维度

  • 有效代码行:新增代码行 - 无效代码行(空行、注释),排除格式化、删除冗余代码等无业务价值的修改。
  • 提交频率:指定时间内(如每周 / 每月)的提交次数,反映开发节奏。
  • 文件类型分布:成员提交的代码文件类型(如.py.java.js),判断任务专注度(避免频繁在不同类型文件间切换)。

1.2 技术栈选型

选择轻量、易上手的工具,避免引入复杂框架,确保脚本可跨平台运行:

功能模块选用工具 / 库选择理由
Git 数据获取GitPython封装 Git 命令,可直接通过 Python 接口获取提交记录、文件修改详情,比解析git log输出更可靠
有效代码行计算自定义 Python 函数灵活处理不同语言的注释规则,无需依赖第三方代码分析库

二、核心模块实现:从 Git 数据到有效代码统计

2.1 模块 1:Git 提交数据获取(GitPython 实战)

首先要从 Git 仓库拉取提交记录,包括提交人、提交时间、修改的文件、代码行变化等核心信息。GitPython 让这一步变得简单,无需手动执行git log并解析输出。


# ----------------------------
# 核心统计模块
# ----------------------------
def get_commit_stats(repo_path, start_date, end_date, mode, rules):
    """获取提交统计(含注释行和空白行)

    Args:
        repo_path: Git仓库路径
        start_date: 统计起始日期(YYYY-MM-DD)
        end_date: 统计结束日期(YYYY-MM-DD)

    Returns:
        {
            "repo": "仓库URL",
            "data": {
                "user1": {
                    "additions": 100,    # 总新增行
                    "deletions": 20,     # 总删除行
                    "commits": 3,        # 提交次数
                    "comments_added": 5, # 新增注释行
                    "comments_deleted": 1, # 删除注释行
                    "blanks_added": 3,    # 新增空白行
                    "blanks_deleted": 0  # 删除空白行
                }
            }
        }
    """
    setup_logging()
    logger = logging.getLogger(__name__)
    # ----------------------------
    # 初始化模块
    # ----------------------------
    repo = None  # 提前声明变量
    try:
        if mode == 'remote':
            clone_path = extract_repo_info(repo_path)
            if clone_path:
                current_work_dir = os.path.join(Path(os.getcwd()), "temp")
                if not os.path.exists(current_work_dir):
                    os.makedirs(current_work_dir)
                clone_path = os.path.join(current_work_dir, clone_path)
                # os.environ["GIT_SSH_COMMAND"] = "ssh -i ~/.ssh/id_isa -o StrictHostKeyChecking=no"

                if not os.path.exists(clone_path):
                    logger.info(f"远程仓库克隆开始: {repo_path}")
                    Repo.clone_from(repo_path, clone_path)
                    logger.info(f"远程仓库克隆成功: {repo_path}")
                repo = Repo(clone_path)
                logger.info(f"远程仓库打开成功: {repo_path}")
                # 更新远程引用(不修改工作目录)
                # 可以获取远程仓库当前配置下的全量提交记录,但需满足以下条件:
                # 1、远程仓库本身包含完整历史;
                # 2、本地仓库的origin远程配置指向正确的地址;
                # 3、未使用Repo等工具管理多仓库(否则需额外处理子仓库)。
                repo.remotes.origin.fetch()
                logger.info(f"Code fetch successful: {repo.remotes.origin.url}")
        else:
            repo = Repo(repo_path)
            logger.info(f"本地仓库打开成功: {repo_path}")
            repo.remotes.origin.pull()
            logger.info(f"Code pull successful: {repo.remotes.origin.url}")
    except Exception as e:
        logger.error(f"Code pull/fetch failed: {e}")

    stats = defaultdict(lambda: {
        'additions': 0,
        'deletions': 0,
        'commits': 0,
        'comments_added': 0,
        'comments_deleted': 0,
        'blanks_added': 0,
        'blanks_deleted': 0,
    })

    stime = datetime.strptime(start_date, '%Y-%m-%d')
    etime = datetime.strptime(end_date, '%Y-%m-%d')

    since = datetime(stime.year, stime.month, stime.day, 0, 0, 0)
    until = datetime(etime.year, etime.month, etime.day, 23, 59, 59)

    # 注释匹配规则(支持 Python/Java/C/XML 等)
    comment_re = re.compile(r'^\s*(#|//|/\*|\*|<!--)')
    blank_re = re.compile(r'^\s*$')

    if repo is None:
        logger.error("仓库初始化失败,无法遍历提交记录")
        return None

    # ----------------------------
    # 提交遍历与差异分析
    # ----------------------------
    for commit in repo.iter_commits('--all', since=since.isoformat(), until=until.isoformat(), ):
        if len(commit.parents) >= 2:
            continue  # 跳过合并提交
        author = commit.author.email.split('@')[0].strip()
        parent = commit.parents[0] if commit.parents else git.NULL_TREE
        # 确保对比方向:父提交 → 当前提交
        try:
            diff = parent.diff(commit, create_patch=True)
        except AttributeError:
            logger.info(f"无效的 parent 对象: {type(parent)}")
            continue
        commit_id = commit.hexsha  # 提交的完整哈希 ID(如 "e5a527c880ce05348c51a894")
        commit_time = commit.committed_datetime.strftime("%Y-%m-%d %H:%M:%S")  # 提交时间(datetime 对象)
        # logger.info(f"author: {author} | 提交ID: {commit_id} | 时间: {commit_time}")
        for d in diff:
            if d.diff == b'':  # 跳过二进制文件
                continue

            # 排除.aar文件与同时包含res和drawable关键字的目录
            a_path = d.a_path  # 旧文件路径
            b_path = d.b_path  # 新文件路径
            logger.info(
                f"作者: {author} | "
                f"提交ID: {commit_id} | "
                f"时间: {commit_time} | "
                f"文件: {b_path} | "
                f"代码行: {stats[author]['additions']}"
            )

            if should_exclude(rules, a_path, b_path):
                logger.info(f"should_exclude[{author}]: {a_path}, {b_path}")
                continue  # 跳过该差异项

            # 解码差异文本
            try:
                diff_text = d.diff.decode('utf-8')
            except UnicodeDecodeError:
                diff_text = d.diff.decode('latin-1')

            # if author == 'XXX':
            #     logger.info(f"stats[{author}]['additions']: {stats[author]['additions']}")

            # 逐行分析 Diff 内容
            for line in diff_text.split('\n'):
                if line.startswith(('+++', '---', '@@')):
                    continue  # 跳过 Diff 元信息行

                if line.startswith('+'):
                    content = line[1:].lstrip()
                    if comment_re.match(content):
                        stats[author]['comments_added'] += 1
                    elif blank_re.match(line[1:]):
                        stats[author]['blanks_added'] += 1
                    else:
                        stats[author]['additions'] += 1
                elif line.startswith('-'):
                    content = line[1:].lstrip()
                    if comment_re.match(content):
                        stats[author]['comments_deleted'] += 1
                    elif blank_re.match(line[1:]):
                        stats[author]['blanks_deleted'] += 1
                    else:
                        stats[author]['deletions'] += 1
        stats[author]['commits'] += 1

    return {
        'data': stats,
    }

关键实现说明

  1. 核心功能:统计指定时间范围内 Git 仓库的提交数据,按用户维度区分新增 / 删除的有效代码行、注释行、空白行及提交次数,支持本地 / 远程仓库模式。

  2. 仓库处理逻辑

    • 远程仓库(mode='remote'):克隆远程仓库到本地temp目录(首次克隆,后续直接使用),拉取最新提交记录;
    • 本地仓库:直接打开仓库并拉取(pull)最新代码,确保数据时效性。
  3. 统计维度为每个用户(按邮箱前缀识别)记录:

    • 核心代码行:additions(新增有效代码)、deletions(删除有效代码);
    • 辅助行:comments_added/deleted(新增 / 删除注释行)、blanks_added/deleted(新增 / 删除空白行);
    • 提交次数:commits(排除合并提交)。
  4. 行类型判断

    • 用正则匹配注释行(支持#///*<!--等多语言注释格式);
    • 用正则匹配空白行(仅含空格 / 制表符的行);
    • 其余视为有效代码行。
  5. 差异分析流程

    • 遍历指定时间范围内的非合并提交(跳过父提交数≥2 的提交);
    • 对比当前提交与父提交的差异(diff),排除二进制文件和符合rules规则的文件(如.aar文件、特定目录);
    • 逐行解析diff内容,按+(新增)、-(删除)区分行类型,累加对应统计项。
  6. 关键细节

    • 处理diff文本的编码问题(优先utf-8,失败则用latin-1);
    • 通过--all参数遍历所有分支的提交;
    • defaultdict简化用户统计数据的初始化。

2.2 模块 2:过滤规则(排除二进制、资源等)

Git 统计的是纯代码行,一些图片资源、JAR包文件等,这些属于 “无效代码”,需要过滤掉才能反映真实开发量。

def should_exclude(rules, a_path, b_path):
    """执行排除判断逻辑"""
    return any([
        _check_extensions(rules, a_path, b_path),
        _check_directories(rules, a_path, b_path)
    ])


def _check_extensions(rules, *paths):
    """检查文件扩展名是否在排除规则中[3,6](@ref)

    参数说明:
    rules - 从YAML加载的过滤规则字典
    paths - 需要检测的路径集合(支持多路径同时检测)
    """
    return any(
        p and os.path.splitext(p)[1].lower() in rules.get('extensions', [])
        for p in paths if p
    )


def _check_directories(rules, *paths):
    """动态判断目录排除规则[3,6](@ref)

    参数改进亮点:
    1. 解耦类属性依赖,通过参数显式传递规则
    2. 支持同时检测多个路径的匹配情况
    """
    for path in filter(None, paths):
        normalized_path = path.replace(os.path.sep, '/').lower()

        # 遍历所有目录规则(支持单条件和组合条件)
        for condition in rules.get('directories', []):
            if isinstance(condition, tuple):  # 组合条件
                if all(kw in normalized_path for kw in condition):
                    return True
            elif condition in normalized_path:  # 单条件
                return True
    return False

关键实现说明

  1. 核心功能:按 “扩展名排除” 和 “目录排除” 两类规则,判断文件路径是否需从统计中排除,支持多路径检测、跨平台适配与组合条件判断。

  2. 入口函数 should_exclude聚合两类排除规则,只要_check_extensions(扩展名)或_check_directories(目录)任一返回True,即判定为 “需排除”,逻辑为 “或” 关系。

  3. 扩展名排除 _check_extensions

    • rules['extensions']获取需排除的扩展名列表(如.aar.log);
    • 遍历传入的所有路径(a_path旧路径、b_path新路径),过滤空路径;
    • 提取路径的扩展名(转小写,避免大小写差异),若在排除列表中则返回True
  4. 目录排除 _check_directories

    • 路径归一化:将路径分隔符(如 Windows\、Linux/)统一转为/,并转小写,解决跨平台路径匹配问题;
    • 规则支持
      • 单条件:目录规则为字符串时,只要路径包含该字符串即排除;
      • 组合条件:规则为元组时,需路径包含所有元组元素才排除(“且” 逻辑);
    • 遍历所有路径和规则,满足任一规则即返回True
  5. 关键细节

    • 过滤空路径(避免None或空字符串导致的判断错误);
    • 大小写不敏感(扩展名、目录关键词均转小写匹配);
    • 解耦规则依赖(通过参数rules传递,不硬编码,灵活性高)。

2.3 模块 3:代码仓库格式转化及一致性检查,日志初始化(防止拉取代码时出现异常)

 


def setup_logging():
    current_work_dir = Path(os.getcwd())
    yaml_file = current_work_dir / 'config' / 'logging.yaml'
    with open(yaml_file, 'r', encoding='utf-8') as f:
        config = yaml.safe_load(f)  # 安全加载YAML[5](@ref)
    logging.config.dictConfig(config)  # 应用配置字典[8](@ref)


def enhanced_compare(dict1, dict2):
    setup_logging()
    logger = logging.getLogger(__name__)
    logger.info(f"dict1: {dict1}")
    logger.info(f"dict2: {dict2}")
    # 键集合检查
    keys_diff = dict1.keys() ^ dict2.keys()
    if keys_diff:
        logger.warning(f"键缺失/新增: {keys_diff}")
        return False

    # 值对比(支持嵌套)
    def _compare(a, b, path=""):
        if isinstance(a, dict) and isinstance(b, dict):
            for k in a:
                current_path = f"{path}.{k}" if path else k
                if not _compare(a[k], b[k], current_path):
                    return False
            return True
        else:
            if a != b:
                logger.warning(f"差异路径 {path}: {a} → {b}")
                return False
            return True

    return _compare(dict1, dict2)


def get_remote_ssh_repo(repositories, accounts):
    setup_logging()
    logger = logging.getLogger(__name__)
    # 创建账户映射字典
    account_map = {}
    for host_type, config in accounts.items():
        account_map[host_type] = {
            'hostname': config['HostName'],
            'user': config['User']
        }

    # 转换每个仓库的路径
    converted_repos = []
    for repo in repositories:
        repo_host = repo['RepoHost']
        repo_path = repo['RepoPath']

        # 获取账户配置
        if repo_host not in account_map:
            logger.info(f"警告: 未找到 {repo_host} 的账户配置")
            continue

        account = account_map[repo_host]
        hostname = account['hostname']
        user = account['user']

        # 解析原始路径
        parsed = urlparse(repo_path)
        path = parsed.path

        # 如果路径不以.git结尾,添加.git后缀
        if not path.endswith('.git'):
            path += '.git'

        # 替换主机名
        if hostname not in repo_path:
            logger.info(f"警告: 未找到 {hostname} 的代码仓库主机")
        new_path = f"ssh://{user}@{repo_host}{path}"
        logger.info(f"repositories[] = {new_path}")

        # 创建新的仓库条目
        converted_repos.append(new_path)

    return converted_repos


def check_git_repos(yaml_git_repos, team_git_repos):
    setup_logging()
    logger = logging.getLogger(__name__)
    yaml_repos = defaultdict(list)
    for repo in yaml_git_repos:
        try:
            re_path = git.Repo(repo)
            after = convert_git_url(re_path.remotes.origin.url)
            yaml_repos[after] = after
        except Exception as e:
            logger.error(f"git Repo failed: {e}")

    return enhanced_compare(yaml_repos, team_git_repos)

def extract_repo_info(url: str) -> str:
    """
    从 Git SSH 地址中提取仓库目录信息

    :param url: SSH 格式的 Git 地址,如 ssh://git@10.10.89.209:9922/integration/mots.git
    :return: 下划线拼接的目录名(如 integration_mots),若格式不符返回 None
    """
    # 检查 .git 后缀[1,3](@ref)
    # if not url.endswith(".git"):
    #     return ""

    # 解析 URL 路径[5,6](@ref)
    parsed = urlparse(url)
    path = parsed.path.rstrip('/')  # 去除末尾斜杆

    # 分割路径层级[5,6](@ref)
    clean_path = path[:-4] if path.endswith('.git') else path  # 移除 .git
    parts = [p for p in clean_path.split('/') if p]  # 过滤空层级

    # 验证路径结构[6](@ref)
    if len(parts) != 2:
        return parts[0]

    return '_'.join(parts)


def convert_git_url(url):
    # 使用正则表达式匹配并替换协议、端口和.git后缀
    converted_url = re.sub(
        r'^ssh://git@([^:]+):\d+/(.+)\.git$',
        r'http://\1/\2',
        url
    )
    return converted_url


def convert_http_to_ssh(http_url: str, ssh_port: int) -> str:
    """
    将 HTTP 格式的 Git 仓库地址转换为 SSH 格式

    :param http_url: 原始 HTTP 地址,如 http://10.10.89.209/integration/mots
    :param ssh_port: SSH 端口号,如 9922
    :return: 转换后的 SSH 地址,如 ssh://git@10.10.89.209:9922/integration/mots.git
    """
    # 解析原始 URL 结构
    parsed = urlparse(http_url)

    # 提取路径并标准化(去除末尾斜杠,添加.git后缀)
    path = parsed.path.rstrip('/')
    if not path.endswith('.git'):
        path += '.git'

    # 构建 SSH URL 组件
    ssh_host = f"git@{parsed.hostname}:{ssh_port}"

    # 组合成完整 SSH URL
    ssh_url = f"ssh://{ssh_host}{path}"

    return ssh_url


def convert_https_to_ssh(http_url: str, ssh_port: int) -> str:
    """
    将 HTTP 格式的 Git 仓库地址转换为 SSH 格式,支持特殊路径格式处理

    :param http_url: 原始 HTTP 地址,如 https://review.nts.neusoft.local/plugins/gitiles/app/na_vics
    :param ssh_port: SSH 端口号,如 29418
    :return: 转换后的 SSH 地址,如 ssh://review.nts.neusoft.local:29418/app/na_vics
    """
    # 解析原始 URL 结构
    parsed = urlparse(http_url)

    # 特殊路径处理(针对 /plugins/gitiles/ 前缀)
    path = parsed.path
    if path.startswith("/plugins/gitiles"):
        path = path[len("/plugins/gitiles"):]  # 移除特定前缀

    # 标准化路径(去除末尾斜杠)
    path = path.rstrip('/')

    # 构建 SSH URL 组件(无用户名格式)
    ssh_host = f"{parsed.hostname}:{ssh_port}"

    # 组合成完整 SSH URL
    ssh_url = f"ssh://{ssh_host}{path}"

    return ssh_url


def append_string_if_missing(original_str, target_str):
    # 去除目标字符串首尾空白
    clean_target = target_str.strip()
    if not clean_target:  # 目标为空直接返回原字符串
        return original_str

    # 拆分原字符串并处理空白
    parts = [p.strip() for p in original_str.split(',')]
    parts = [p for p in parts if p]  # 过滤空元素

    # 检查存在性并追加
    if clean_target not in parts:
        parts.append(clean_target)

    # 合并为新字符串
    return ','.join(parts)

关键实现说明

  1. setup_logging:日志配置初始化

    • 核心功能:从当前工作目录下config/logging.yaml加载日志配置,初始化 Python 日志模块。
    • 关键逻辑:用yaml.safe_load安全读取 YAML 配置,通过logging.config.dictConfig应用配置(如日志级别、输出格式、存储路径),确保日志统一管理。
  2. enhanced_compare:增强型字典对比(支持嵌套)

    • 核心功能:对比两个字典是否完全一致(含嵌套结构),输出差异细节。
    • 关键逻辑:
      1. 先检查键集合差异(用异或^找出新增 / 缺失键),有差异则警告并返回False
      2. 递归对比值:若为字典则递归遍历子键,否则直接比较值,记录差异路径(如a.b.c)并返回False
    • 细节:全程日志记录对比过程和差异,支持多层嵌套字典。
  3. get_remote_ssh_repo:仓库路径转换为 SSH 格式

    • 核心功能:根据账户配置,将原始仓库路径转换为ssh://user@host/path.git格式,适配远程 SSH 访问。
    • 关键逻辑:
      1. 构建account_map:映射RepoHost到对应的hostnameuser(从accounts参数提取);
      2. 处理每个仓库:解析原路径,确保以.git结尾,替换为ssh://user@RepoHost+路径格式;
    • 细节:日志警告账户配置缺失或主机名不匹配,过滤无效仓库条目。
  4. check_git_repos:仓库列表一致性检查

    • 核心功能:检查 YAML 配置的仓库列表与团队仓库列表是否一致(基于仓库 URL 转换后的值)。
    • 关键逻辑:
      1. 处理yaml_git_repos:对每个仓库获取其origin远程 URL,调用convert_git_url(未展示)转换后存入字典yaml_repos
      2. 调用enhanced_compare对比yaml_reposteam_git_repos,返回一致性结果;
    • 细节:捕获仓库初始化异常并日志报错,通过 URL 转换统一对比标准(消除格式差异)。

2.4 模块 4:加载代码仓库、访问账户、团队成员等配置(基础配置)


def lower_dict_keys(obj):
    """递归转换字典键名为小写"""
    if isinstance(obj, dict):
        return {k.lower(): lower_dict_keys(v) for k, v in obj.items()}
    elif isinstance(obj, list):
        return [lower_dict_keys(item) for item in obj]
    return obj


def load_git_repos():
    setup_logging()
    logger = logging.getLogger(__name__)
    try:
        current_work_dir = Path(os.getcwd())
        yaml_file = current_work_dir / 'config' / 'git_repos.yaml'
        with open(yaml_file, 'r', encoding='utf-8') as f:
            raw_data = yaml.safe_load(f)
            data = lower_dict_keys(raw_data)

            # 提取所有仓库路径(支持多层级结构)
            repo_paths = []

            # 验证必要字段存在性
            required_keys = ['date', 'mode', 'remote', 'local']
            if not all(key in data for key in required_keys):
                logger.error("YAML 文件缺少必要字段")
            else:
                data_local = data['local']
                if data_local:
                    # 遍历所有顶级字段
                    for section in data_local:
                        # 使用get避免KeyError,兼容字段不存在的情况
                        repos = data_local.get(section, {}).get('repos', [])
                        if repos:
                            repo_paths.extend(repos)

            # 路径有效性过滤(可选增强功能)
            valid_paths = [p for p in repo_paths if Path(str(p)).exists()]
            return {
                'date': data['date'],
                'mode': data['mode'],
                'remote': data['remote'],
                'valid_paths': valid_paths,  # 或返回原始路径 repo_paths
                'exclusion': data['exclusion'],
            }

    except FileNotFoundError:
        logger.error(f"错误:配置文件不存在")
    except yaml.YAMLError as e:
        logger.error(f"YAML 格式错误: {e.problem}")


def load_account_config():
    """
    加载 YAML 配置文件

    :return: 解析后的配置字典
    """
    setup_logging()
    logger = logging.getLogger(__name__)
    try:
        current_work_dir = Path(os.getcwd())
        yaml_file = current_work_dir / 'config' / 'account.yaml'
        with open(yaml_file, 'r', encoding='utf-8') as file:
            config = yaml.safe_load(file)
        return config
    except FileNotFoundError:
        logger.info(f"错误: 配置文件 {file_path} 不存在")
        return None
    except yaml.YAMLError as e:
        logger.info(f"YAML 解析错误: {e}")
        return None


def load_repositories_config():
    """
    加载 YAML 配置文件

    :return: 解析后的配置字典
    """
    setup_logging()
    logger = logging.getLogger(__name__)
    try:
        current_work_dir = Path(os.getcwd())
        yaml_file = current_work_dir / 'config' / 'repositories.yaml'
        with open(yaml_file, 'r', encoding='utf-8') as file:
            config = yaml.safe_load(file)
        return config
    except FileNotFoundError:
        logger.info(f"错误: 配置文件 {file_path} 不存在")
        return None
    except yaml.YAMLError as e:
        logger.info(f"YAML 解析错误: {e}")
        return None

def read_team_from_yaml():
    setup_logging()
    logger = logging.getLogger(__name__)
    current_work_dir = Path(os.getcwd())
    team_file = current_work_dir / 'team' / 'team.yaml'
    logger.info(team_file)
    # 读取 YAML 文件内容
    with open(team_file, "r", encoding="utf-8") as file:
        data = yaml.safe_load(file)  # 安全加载模式[1,4](@ref)

    gitname_to_realname = {}
    team_name = defaultdict(list)
    repo_name = defaultdict(list)
    emails = defaultdict(list)
    other_name = defaultdict(list)
    dep_name = defaultdict(list)
    primary_name = defaultdict(list)
    sub_name = defaultdict(list)

    # 打印解析后的数据结构
    for index, staff in enumerate(data, 1):
        real_name = staff['Email'].split('@')[0].strip()
        gitname_to_realname[real_name] = real_name
        team_name[real_name] = staff['人员'] if staff['人员'] is not None else ""
        dep_name[real_name] = staff['部门'] if staff['部门'] is not None else ""
        primary_name[real_name] = staff['一级团队'] if staff['一级团队'] is not None else ""
        sub_name[real_name] = staff['二级团队'] if staff['二级团队'] is not None else ""
        # 检查别名
        alias_name = staff['别名'] if staff['别名'] is not None else ""
        emails[real_name] = staff['Email'] if staff['Email'] is not None else ""
        other_name[real_name] = alias_name
        if pd.notnull(alias_name) and '@' in str(alias_name):
            gitnames = alias_name.split(',')
            for g_name in gitnames:
                s_name = g_name.split('@')[0].strip()
                gitname_to_realname[s_name] = real_name

        # 检查代码仓库
        rp_name = staff['代码仓库'] if staff['代码仓库'] is not None else ""
        if pd.notnull(rp_name) and ('http:' in str(rp_name) or 'https:' in str(rp_name)):
            # 处理序号格式的代码仓库
            if any(char.isdigit() for char in rp_name):  # 检查是否包含数字
                # 分割序号字符串
                repo_ids = re.split(r'[,\uff0c\n]+|\r\n', rp_name)
                repo_name[real_name] = [rid.strip() for rid in repo_ids if rid.strip()]

        logger.info(f"=== 人员 {index} ===")
        logger.info(f"姓名: {staff['人员']}")
        logger.info(f"邮箱: {staff['Email']}")
        logger.info(f"部门: {staff['部门']}")
        logger.info(f"代码仓库: {', '.join(staff['代码仓库'].split(','))}\n")

    if not gitname_to_realname:
        logger.warning("Error: '别名' or '邮箱' column not found in any sheet of the Team.xlsx file")

    return {
        'team_name': team_name,
        'real_name': gitname_to_realname,
        'repo_name': repo_name,
        'emails': emails,
        'other_name': other_name,
        'dep_name': dep_name,
        'primary_name': primary_name,
        'sub_name': sub_name
    }

关键实现说明

  1. lower_dict_keys:递归统一字典键名大小写

    • 核心功能:将字典(含嵌套字典 / 列表)的所有键名转为小写,其他类型数据保持不变。
    • 关键逻辑:递归遍历,字典则生成新字典(键小写,值递归处理),列表则逐个元素递归处理,确保配置键名大小写不敏感。
  2. load_git_repos:加载 Git 仓库配置(git_repos.yaml

    • 核心功能:读取仓库配置,提取有效本地仓库路径,返回结构化配置信息。
    • 关键逻辑:
      1. 安全加载 YAML,调用lower_dict_keys统一键名;
      2. 验证date/mode/remote/local等必要字段,缺失则日志报错;
      3. 提取local下各模块的repos路径,过滤本地存在的有效路径;
    • 细节:捕获文件不存在、YAML 格式错误,返回含日期、模式、有效路径、排除规则的字典。
  3. load_account_config/load_repositories_config:加载账户 / 仓库配置

    • 核心功能:分别加载account.yaml(账户配置)和repositories.yaml(仓库列表配置)。
    • 关键逻辑:安全读取 YAML 文件,捕获文件不存在、解析错误,返回配置字典或None,全程日志记录状态。
  4. read_team_from_yaml:解析团队成员配置(team.yaml

    • 核心功能:提取团队成员信息,构建 Git 用户名与真实信息的映射关系。
    • 关键逻辑:
      1. 遍历成员数据,从邮箱前缀提取real_name,构建gitname_to_realname(含别名映射,别名邮箱前缀也关联真实名);
      2. 提取成员的团队、部门、一级 / 二级团队、邮箱、代码仓库等信息,分割含数字序号的仓库 ID;
    • 细节:日志打印成员详情,缺失关键字段(如别名、邮箱)时警告,返回多维度成员信息映射字典。

2.5 模块 5:入口定义,真实名字映射及多仓库统计(统计入口)

def map_gitname_to_realname(stats, map_git_name):
    mapped_stats = defaultdict(lambda: {
        'additions': 0,
        'deletions': 0,
        'commits': 0,
        'comments_added': 0,
        'comments_deleted': 0,
        'blanks_added': 0,
        'blanks_deleted': 0,
    })
    for gname, item in stats.items():
        realname = map_git_name.get(gname, gname)
        mapped_stats[realname]['additions'] += item['additions']
        mapped_stats[realname]['deletions'] += item['deletions']
        mapped_stats[realname]['commits'] += item['commits']
        mapped_stats[realname]['comments_added'] += item['comments_added']
        mapped_stats[realname]['comments_deleted'] += item['comments_deleted']
        mapped_stats[realname]['blanks_added'] += item['blanks_added']
        mapped_stats[realname]['blanks_deleted'] += item['blanks_deleted']

    return mapped_stats


def get_commit_stats_for_persons_internal(repo_path, start_date, end_date, map_git_name, mode, rules):
    stats = get_commit_stats(repo_path, start_date, end_date, mode, rules)
    if stats:
        return {
            'data': map_gitname_to_realname(stats['data'], map_git_name),
        }
    return None


def get_commit_stats_for_multiple_repos(repo_paths, start_date, end_date, map_git_name, mode, exclusion):
    cumulative_stats = defaultdict(lambda: {
        'additions': 0,
        'deletions': 0,
        'commits': 0,
        'comments_added': 0,
        'comments_deleted': 0,
        'blanks_added': 0,
        'blanks_deleted': 0,
        'repos': ''
    })
    rules = _normalize_rules(exclusion)

    for repo_path in repo_paths:
        stats = get_commit_stats_for_persons_internal(repo_path, start_date, end_date, map_git_name, mode, rules)
        if stats is None:
            continue
        data = stats['data']
        for realname, stat in data.items():
            cumulative_stats[realname]['additions'] += stat['additions']
            cumulative_stats[realname]['deletions'] += stat['deletions']
            cumulative_stats[realname]['commits'] += stat['commits']
            cumulative_stats[realname]['comments_added'] += stat['comments_added']
            cumulative_stats[realname]['comments_deleted'] += stat['comments_deleted']
            cumulative_stats[realname]['blanks_added'] += stat['blanks_added']
            cumulative_stats[realname]['blanks_deleted'] += stat['blanks_deleted']
            cumulative_stats[realname]['repos'] = append_string_if_missing(cumulative_stats[realname]['repos'], repo_path)

    return {
        'datas': cumulative_stats,
    }


def _normalize_rules(rules):
    """统一路径格式和大小写[3,7](@ref)"""
    return {
        'extensions': [ext.lower() for ext in rules.get('extensions', [])],
        'directories': [
            tuple(map(str.lower, item)) if isinstance(item, list)
            else str(item).lower()
            for item in rules.get('directories', [])
        ]
    }


# 整个功能的入口
def get_commit_stats_for_persons_ex(start_date=None, end_date=None):
    setup_logging()
    logger = logging.getLogger(__name__)
    # 加载yaml文件中的git代码仓库
    yaml_data = load_git_repos()
    account_data = load_account_config()
    repos_data = load_repositories_config()
    repo_paths = yaml_data['valid_paths']

    # 如果参数为空,则使用配置文件中的日期
    if start_date is None:
        start_date = yaml_data['date']['start']
    if end_date is None:
        end_date = yaml_data['date']['end']

    # 转换日期类型为字符串(如果是date对象)
    if isinstance(start_date, date):
        start_date = start_date.isoformat()
    if isinstance(end_date, date):
        end_date = end_date.isoformat()

    logger.info(yaml_data)

    # 加载Team.xlsx文件
    # all_names = read_team_from_excel()

    # 加载Team.yaml文件
    all_names = read_team_from_yaml()
    real_name = all_names['real_name']
    repo_name = all_names['repo_name']
    logger.info(all_names)

    mode = yaml_data['mode']
    checked = False
    if mode == 'remote':
        repo_paths = get_remote_ssh_repo(repos_data, account_data)
        # logger.info(f"remote repo_paths: {repo_paths}")
        if repo_paths:
            checked = True
    else:
        checked = check_git_repos(repo_paths, repo_name)

    # 比较代码yaml和Team的代码仓库是否一致
    if checked:
        cumulative_stats = get_commit_stats_for_multiple_repos(repo_paths, start_date, end_date, real_name, mode, yaml_data['exclusion'])
        datas = cumulative_stats['datas']
        # 确保返回必要的数据
        return datas  # 根据实际需要调整返回值
    return {}  # 如果没有检查通过,返回空或适当处理

关键实现说明

  1. map_gitname_to_realname:Git 用户名映射真实姓名并合并统计

    • 核心功能:根据map_git_name映射关系,将 Git 用户名对应的统计数据(代码行、提交次数等)合并到真实姓名下。
    • 关键逻辑:用defaultdict初始化真实姓名的统计结构,遍历 Git 用户统计,按映射累加各项指标(如新增代码行、注释行),未匹配到映射时保留原 Git 用户名。
  2. get_commit_stats_for_persons_internal:单仓库统计数据处理

    • 核心功能:调用get_commit_stats获取单个仓库的提交统计,通过map_gitname_to_realname转换为真实姓名统计,返回结构化结果。
  3. get_commit_stats_for_multiple_repos:多仓库统计数据累积

    • 核心功能:遍历多个仓库路径,累积所有仓库的用户统计数据,记录用户参与的仓库列表。
    • 关键逻辑:先调用_normalize_rules统一排除规则(大小写、格式);对每个仓库,获取单仓库统计并累加到全局统计;用append_string_if_missing去重追加用户参与的仓库路径。
  4. _normalize_rules:排除规则标准化

    • 核心功能:统一过滤规则的大小写和格式,确保匹配一致性。
    • 关键逻辑:扩展名转小写;目录规则若为列表则转元组并小写,否则直接转小写,适配后续路径匹配。
  5. 入口get_commit_stats_for_persons_ex:多仓库用户统计总入口

    • 核心流程:
      1. 加载配置:Git 仓库(load_git_repos)、账户(load_account_config)、仓库列表(load_repositories_config)及团队成员(read_team_from_yaml)配置;
      2. 日期处理:未传参时使用配置文件中的起始 / 结束日期,转换为字符串格式;
      3. 仓库模式处理:remote模式调用get_remote_ssh_repo转换为 SSH 路径,local模式调用check_git_repos检查仓库一致性;
      4. 统计执行:仓库检查通过后,调用get_commit_stats_for_multiple_repos获取多仓库累积统计,返回用户维度统计结果;未通过则返回空。

 

三、整体整合:打造完整统计工具

将上述模块整合为一个可直接运行的脚本,支持通过命令行参数指定仓库路径、时间范围、分支等,方便团队日常使用。

import re
import os
import git
import yaml
import logging.config
import pandas as pd
from git import Repo
from pathlib import Path
from datetime import date, datetime
from collections import defaultdict
from urllib.parse import urlparse

def execute_cloc_code(all_persons, code_dir, date_list):
    """为每个人生成代码统计文件"""
    start_date = date_list[0]
    end_date = date_list[-1]

    # 获取代码提交统计
    commit_stats = get_commit_stats_for_persons_ex(start_date, end_date)

    for person in all_persons:
        if person.name:
            file_path = os.path.join(code_dir, f"{person.name}.txt")
            with open(file_path, 'w', encoding='utf-8') as file:
                real_name = person.email.split('@')[0].strip()
                if real_name in commit_stats:
                    stats = commit_stats[real_name]

                    # 写入统计信息
                    file.write(f"新增代码: {stats['additions']}行\n")
                    file.write(f"删除代码: {stats['deletions']}行\n")
                    file.write(f"commit: {stats['commits']}次\n")
                    file.write(f"新增注释: {stats['comments_added']}行\n")
                    file.write(f"删除注释: {stats['comments_deleted']}行\n")
                    file.write(f"新增空白: {stats['blanks_added']}次\n")
                    file.write(f"删除空白: {stats['blanks_deleted']}行\n")
                else:
                    file.write("没有代码提交数据\n")
def get_current_week_dates():
    """计算并返回本周的日期列表"""
    now = datetime.now()
    start_of_week = (now - timedelta(days=now.weekday()) - timedelta(days=7*0))  # 本周一
    return [(start_of_week + timedelta(days=i)).strftime('%Y-%m-%d') for i in range(7)]

def get_all_persons():
    file_path = 'User.xlsx'  
    all_persons_dict = read_all_sheets(file_path)
    all_persons = []
    for persons in all_persons_dict.values():
        all_persons.extend(persons)
    return all_persons

def create_directories():
    code_dir = os.path.join(result_dir, 'Code')
    if not os.path.exists(code_dir):
        os.makedirs(code_dir)

    return code_dir

if __name__ == "__main__":
    all_persons = get_all_persons()
    code_dir = create_directories()
    date_list = get_current_week_dates()
    execute_cloc_code(all_persons, code_dir, date_list)

关键实现说明

  1. 核心功能:自动统计本周团队成员的代码提交数据(新增 / 删除代码、提交次数等),并为每人生成独立的文本统计文件(如张三.txt),保存至指定结果目录。

  2. 主要函数

    • execute_cloc_code:核心执行函数。根据date_list(本周日期)调用get_commit_stats_for_persons_ex获取统计数据,遍历all_persons(团队成员),为每人创建 txt 文件,写入新增 / 删除代码行、commit 次数、注释 / 空白行变化等数据,无数据时标注 “没有代码提交数据”。
    • get_current_week_dates:生成本周日期列表。以当前时间为基准计算本周一,返回本周 7 天的日期字符串(格式YYYY-MM-DD)。
    • get_all_persons:读取人员信息。从User.xlsx文件(通过read_all_sheets,未展示实现)读取所有 sheet 的人员数据,整合为成员列表返回。
    • create_directories:创建结果目录。在result_dir(推测为全局定义的结果根目录)下创建Code子目录,用于保存统计文件,目录不存在则自动创建。
  3. 整体流程(入口__main__

    1. 调用get_all_persons获取所有团队成员信息;
    2. 调用create_directories创建Code统计文件目录;
    3. 调用get_current_week_dates获取本周日期范围;
    4. 调用execute_cloc_code执行统计,为每人生成代码提交统计文件。
  4. 关键细节

    • 成员标识:通过成员邮箱前缀提取real_name,关联代码提交统计数据;
    • 统计周期:默认统计 “本周”(7 天),日期范围由get_current_week_dates自动计算;
    • 文件存储:统计文件以成员姓名命名(姓名.txt),统一保存在Code目录下,确保结果结构化。

四、配置示例:

team.yaml,可按照下列格式配置多人:

- Email: yuandashu@xxx.com
  JiraName: Yuan, Da shu
  一级团队: XXX
  二级团队: XXX-TL
  人员: 猿大叔~
  代码仓库: 3,23
  别名: ''
  备注: ''
  序号: ''
  部门: XXX

repositories.yaml,代码仓库配置:

- 'No': 1
  RepoHost: gitlab_1
  RepoPath: http://XXX/xxx/xxx1
- 'No': 2
  RepoHost: gitlab_2
  RepoPath: http://XXX/xxx/xxx2

git_repos.yaml,配置ssh服务:

#统计时间范围
date:
  start: 2025-03-25
  end: 2025-03-26

#模式设定
mode: remote

#Mode1
remote:
  ssh:
    port: xxxx
  jlr:
    addr: https://xxxxxx
    port: xxxx

#Mode2
local:
  team1:
    repos:
      - G:\ALL_PROJECTS\xxx
      -
  team2:
    repos:
      -
  team3:
    repos:
      -

exclusion:
  # 文件扩展名排除
  extensions:
    - .aar
    - .jar
    - .json
    - .gitignore
    - .dockerignore

  # 目录组合排除规则
  directories:
    - [res, drawable]  # 需同时包含两个关键字
    - [res, mipmap]    # 需同时包含两个关键字
    - assets           # 单关键字匹配

account.yaml,配置SSH账号信息:

gitlab_1:
  HostName: xxx.xxx.xxx.xxx
  User: 'git'
gitlab_2:
  HostName: xxx.xxx.xxx.xxx
  User: 'git'

五、注意事项与扩展建议

5.1 统计结果的 “正确打开方式”

  • 不唯代码行数论:有效代码行多不代表效率高(可能写了冗余代码),需结合 “平均每次提交有效行数”“任务复杂度” 综合判断。
  • 排除非开发提交:如格式化代码(blackprettier)、文档更新等提交,可在配置文件中定义过滤规则。
  • 关注趋势而非绝对值:对比团队不同周期的统计结果(如本周 vs 上周),看效率是否有提升,比单次统计的绝对值更有意义。

5.2 功能扩展建议

  • 支持多分支统计:同时统计maindevelopfeature/*等分支,分析分支间的代码流动。
  • 集成项目管理工具:结合 Jira、Trello 等工具,将 Git 提交与任务卡片关联,统计 “每个任务的有效代码行”,评估任务预估与实际开发量的偏差。
  • 代码质量指标:集成flake8(Python)、PMD(Java)等工具,统计代码中的错误、警告数量,将 “效率” 与 “质量” 结合评估。
  • 自动化统计:将脚本部署到 CI/CD 流程(如 Jenkins、GitHub Actions),每周自动生成统计报告并发送到团队邮箱。

六、总结

本文通过 Python+GitPython 实现了基于 Git 提交记录的团队开发效率统计工具,核心是 “从 Git 拉取数据→计算有效代码行→可视化展示” 的全流程。工具轻量易部署,统计维度贴合实际开发场景,避免了 “为统计而统计” 的误区。

使用时需注意:代码行数只是效率评估的一个维度,不能孤立看待,需结合团队分工、任务复杂度、代码质量等因素综合判断。最终目的是通过数据发现问题(如 merge 提交过多、部分成员提交频率过低),而非单纯排名比较。

 

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值