在这里插入图片描述

一、我们要做什么

在 Git 协作中,分支是代码变更的独立轨道。一个完整的 Git 客户端必须提供完善的分支管理能力,让用户在移动端也能完成日常的 Git 操作。

1.1 核心功能需求

功能模块 具体需求 优先级
分支列表 展示本地分支和远程分支 P0
分支切换 checkout 到指定分支 P0
创建分支 基于某个 commit 创建新分支 P0
删除分支 删除本地或远程分支 P0
合并预览 在合并前查看两个分支的差异 P1
分支对比 查看两个分支的文件差异 P1
默认分支识别 高亮显示默认分支 P2

1.2 预期实现效果

  • ✅ 分支列表清晰展示本地/远程分类
  • ✅ 默认分支(如 main)置顶显示
  • ✅ 切换分支有 Loading 和 Toast 反馈
  • ✅ 删除前有二次确认弹窗
  • ✅ 合并预览可看到冲突文件列表

1.3 面临挑战

挑战 1:本地仓库数据获取

如果 AtomGit 不直接管理本地 Git 仓库(只读 GitHub 远程),需要通过 API 获取分支信息。GitHub 提供完整的 branches API。

挑战 2:跨平台状态同步

切换分支后,相关的文件树、提交历史等需要同步刷新。要用 Pinia 状态管理统一协调。


二、数据模型设计

2.1 分支基础信息

// src/types/branch.ts

export interface Branch {
  name: string                             // 分支名(如 main、feature/login)
  sha: string                              // 分支最新 commit SHA
  protected: boolean                       // 是否受保护分支
  
  // 分支关系
  isDefault: boolean                       // 是否默认分支
  aheadBy: number                          // 领先默认分支的提交数
  behindBy: number                         // 落后默认分支的提交数
  
  // 关联信息
  commit: BranchCommit                      // 最新 commit 摘要
  owner: {                                 // 所属仓库
    login: string
    avatarUrl: string
  }
  
  // 元数据
  type: 'local' | 'remote'                 // 本地或远程
  remoteName?: string                      // 远程仓库名(仅远程分支)
}

2.2 分支提交摘要

// 分支最新 commit 摘要
export interface BranchCommit {
  sha: string                              // 完整 SHA
  shortSha: string                         // 短 SHA(前 7 位)
  message: string                          // 提交信息
  author: {                                // 作者
    name: string
    email: string
    avatarUrl: string
    date: string                           // ISO 时间
  }
  committer: {                             // 提交者
    name: string
    email: string
  }
  url: string                              // GitHub 提交详情 URL
}

2.3 分支操作结果

// 分支切换结果
export interface CheckoutResult {
  success: boolean
  branch: string
  conflicts: ConflictFile[]                // 冲突文件列表
  changedFiles: ChangedFile[]              // 变更文件
  message: string
}

// 冲突文件
export interface ConflictFile {
  path: string                             // 文件路径
  reason: 'both-modified' | 'deleted-by-them' | 'deleted-by-us' | 'both-added'
  ours: string                             // 我们的版本
  theirs: string                           // 他们的版本
}

// 变更文件
export interface ChangedFile {
  path: string
  status: 'added' | 'modified' | 'deleted' | 'renamed' | 'copied'
  additions: number                        // 新增行数
  deletions: number                        // 删除行数
}

2.4 合并预览结果

// 合并预览
export interface MergePreview {
  canMerge: boolean                        // 是否可以合并
  mergeable: 'mergeable' | 'conflicting' | 'unknown'
  aheadBy: number                          // 领先 commit 数
  behindBy: number                         // 落后 commit 数
  totalCommits: number                     // 总 commit 数
  files: ChangedFile[]                     // 变更文件列表
  conflicts: string[]                      // 冲突文件路径
}

三、核心设计决策

3.1 直接 Git 命令 vs API 调用

维度 直接 Git 命令 API 调用
性能 快(本地操作) 慢(网络请求)
实时性 实时 取决于缓存
适用场景 本地 Git 客户端 GitHub 远程只读
离线能力 ✅ 支持 ❌ 依赖网络

选择:AtomGit 主要是 GitHub 远程只读客户端(不克隆到本地),所以主要用 API 调用

但同时保留 localStorage 缓存最近一次的分支信息,离线可看。

3.2 本地分支 vs 远程分支的展示策略

GitHub 上"本地分支"实际上就是默认分支的引用(如 main),"远程分支"是其他分支。在 UI 上需要:

类别 展示方式 数量限制
默认分支 始终置顶,特殊标识 1 个
活跃分支 按 commit 时间倒序 前 20 个
远程分支 按字母排序 前 30 个

3.3 分支切换的状态管理

切换分支是一个异步、有副作用的操作:

  1. UI 切换 Tab → 显示 Loading
  2. 触发 Pinia Action → fetch 仓库详情
  3. 更新 Pinia State → 文件树、提交历史全部联动
  4. UI 响应式更新

需要使用 Pinia 的 actions 统一管理。

3.4 性能优化:分页加载

一个大型项目可能有 1000+ 分支。一次性加载会:

  • 占用内存
  • 渲染卡顿
  • API 响应慢

方案:分页加载 + 搜索过滤。

// 滚动到底部时加载下一页
@scrolltolower="loadMoreBranches"

3.5 错误处理

分支操作可能失败:

错误类型 场景 处理
401 Token 过期 跳登录页
403 无权限(如保护分支) Toast + 重新加载
404 仓库/分支不存在 友好提示
409 冲突(如删除当前分支) 二次确认
422 校验失败(如分支已存在) 表单错误提示

四、完整代码实现

4.1 分支 API 服务层

// src/api/branch.ts
import { request } from '@/utils/request'
import type { Branch, MergePreview } from '@/types/branch'

/**
 * 获取仓库所有分支
 * @param owner 仓库所有者
 * @param repo  仓库名
 * @param page  页码
 * @param perPage 每页数量
 */
export async function listBranches(
  owner: string, 
  repo: string, 
  page = 1, 
  perPage = 30
): Promise<Branch[]> {
  const response = await request({
    url: `/repos/${owner}/${repo}/branches`,
    method: 'GET',
    data: { page, per_page: perPage }
  })
  
  return response.data.map((item: any) => ({
    name: item.name,
    sha: item.commit.sha,
    protected: item.protected,
    isDefault: item.name === 'main' || item.name === 'master',
    aheadBy: 0,
    behindBy: 0,
    commit: {
      sha: item.commit.sha,
      shortSha: item.commit.sha.slice(0, 7),
      message: item.commit.commit.message,
      author: {
        name: item.commit.commit.author.name,
        email: item.commit.commit.author.email,
        avatarUrl: item.commit.author.avatar_url,
        date: item.commit.commit.author.date
      },
      committer: {
        name: item.commit.committer.name,
        email: item.commit.committer.email
      },
      url: item.commit.html_url
    },
    owner: { login: owner, avatarUrl: '' },
    type: 'remote'
  }))
}

/**
 * 获取单个分支详情
 */
export async function getBranch(
  owner: string, 
  repo: string, 
  branch: string
): Promise<Branch> {
  const response = await request({
    url: `/repos/${owner}/${repo}/branches/${branch}`,
    method: 'GET'
  })
  return transformBranch(response.data, owner)
}

/**
 * 创建新分支
 * 需要先用 contents API 创建一个文件,或使用 git refs API
 */
export async function createBranch(
  owner: string, 
  repo: string, 
  branchName: string, 
  fromSha: string
): Promise<Branch> {
  // GitHub 创建分支实际上是通过 git ref API
  await request({
    url: `/repos/${owner}/${repo}/git/refs`,
    method: 'POST',
    data: {
      ref: `refs/heads/${branchName}`,
      sha: fromSha
    }
  })
  
  return getBranch(owner, repo, branchName)
}

/**
 * 删除分支
 */
export async function deleteBranch(
  owner: string, 
  repo: string, 
  branch: string
): Promise<void> {
  await request({
    url: `/repos/${owner}/${repo}/git/refs/heads/${branch}`,
    method: 'DELETE'
  })
}

/**
 * 比较两个分支
 */
export async function compareBranches(
  owner: string, 
  repo: string, 
  base: string, 
  head: string
): Promise<MergePreview> {
  const response = await request({
    url: `/repos/${owner}/${repo}/compare/${base}...${head}`,
    method: 'GET'
  })
  
  return {
    canMerge: response.data.mergeable,
    mergeable: response.data.mergeable_state,
    aheadBy: response.data.ahead_by,
    behindBy: response.data.behind_by,
    totalCommits: response.data.total_commits,
    files: (response.data.files || []).map((f: any) => ({
      path: f.filename,
      status: f.status,
      additions: f.additions,
      deletions: f.deletions
    })),
    conflicts: []
  }
}

function transformBranch(item: any, owner: string): Branch {
  return {
    name: item.name,
    sha: item.commit.sha,
    protected: item.protected,
    isDefault: item.name === 'main' || item.name === 'master',
    aheadBy: 0,
    behindBy: 0,
    commit: {
      sha: item.commit.sha,
      shortSha: item.commit.sha.slice(0, 7),
      message: item.commit.commit.message,
      author: {
        name: item.commit.commit.author.name,
        email: item.commit.commit.author.email,
        avatarUrl: item.commit.author?.avatar_url || '',
        date: item.commit.commit.author.date
      },
      committer: {
        name: item.commit.committer.name,
        email: item.commit.committer.email
      },
      url: item.commit.html_url
    },
    owner: { login: owner, avatarUrl: '' },
    type: 'remote'
  }
}

4.2 分支状态管理(Pinia)

// src/stores/branch.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { 
  listBranches, 
  getBranch, 
  createBranch, 
  deleteBranch, 
  compareBranches 
} from '@/api/branch'
import type { Branch, MergePreview } from '@/types/branch'

export const useBranchStore = defineStore('branch', () => {
  // === State ===
  const branches = ref<Branch[]>([])
  const currentBranch = ref<string>('')
  const defaultBranch = ref<string>('main')
  const loading = ref(false)
  const currentPage = ref(1)
  const hasMore = ref(true)
  
  // === Getters ===
  const localBranches = computed(() => 
    branches.value.filter(b => b.type === 'local')
  )
  
  const remoteBranches = computed(() => 
    branches.value.filter(b => b.type === 'remote')
  )
  
  const sortedBranches = computed(() => {
    // 默认分支置顶
    return [...branches.value].sort((a, b) => {
      if (a.isDefault) return -1
      if (b.isDefault) return 1
      return a.name.localeCompare(b.name)
    })
  })
  
  // === Actions ===
  async function fetchBranches(owner: string, repo: string, reset = true) {
    if (loading.value) return
    loading.value = true
    
    try {
      if (reset) {
        currentPage.value = 1
        hasMore.value = true
        branches.value = []
      }
      
      const list = await listBranches(owner, repo, currentPage.value)
      
      branches.value.push(...list)
      hasMore.value = list.length >= 30
      
      // 设置默认分支
      const main = branches.value.find(b => b.isDefault)
      if (main) defaultBranch.value = main.name
    } finally {
      loading.value = false
    }
  }
  
  async function loadMore(owner: string, repo: string) {
    if (!hasMore.value || loading.value) return
    currentPage.value++
    await fetchBranches(owner, repo, false)
  }
  
  async function switchBranch(owner: string, repo: string, branchName: string) {
    currentBranch.value = branchName
    uni.setStorageSync(`last_branch_${owner}_${repo}`, branchName)
  }
  
  async function createNewBranch(
    owner: string, 
    repo: string, 
    name: string, 
    fromSha: string
  ) {
    uni.showLoading({ title: '创建中...' })
    try {
      const newBranch = await createBranch(owner, repo, name, fromSha)
      branches.value.unshift(newBranch)
      uni.showToast({ title: '创建成功', icon: 'success' })
      return newBranch
    } finally {
      uni.hideLoading()
    }
  }
  
  async function removeBranch(owner: string, repo: string, name: string) {
    await deleteBranch(owner, repo, name)
    branches.value = branches.value.filter(b => b.name !== name)
    uni.showToast({ title: '已删除', icon: 'success' })
  }
  
  async function previewMerge(
    owner: string, 
    repo: string, 
    base: string, 
    head: string
  ): Promise<MergePreview> {
    return compareBranches(owner, repo, base, head)
  }
  
  return {
    branches, currentBranch, defaultBranch, loading,
    localBranches, remoteBranches, sortedBranches,
    fetchBranches, loadMore, switchBranch, 
    createNewBranch, removeBranch, previewMerge
  }
})

4.3 分支列表组件

<!-- src/components/branch/BranchList.vue -->
<template>
  <view class="branch-list">
    <view v-if="loading && branches.length === 0" class="loading-state">
      <text>加载中...</text>
    </view>
    
    <scroll-view
      v-else
      class="list-scroll"
      scroll-y
      @scrolltolower="onScrollLower"
    >
      <!-- 默认分支置顶 -->
      <view v-if="defaultBranchItem" class="default-section">
        <text class="section-label">默认分支</text>
        <BranchItem 
          :branch="defaultBranchItem" 
          :is-current="defaultBranchItem.name === currentBranch"
          @click="onBranchClick(defaultBranchItem)"
        />
      </view>
      
      <!-- 其他分支 -->
      <view v-if="otherBranches.length" class="other-section">
        <text class="section-label">其他分支 ({{ otherBranches.length }})</text>
        <BranchItem
          v-for="b in otherBranches"
          :key="b.name"
          :branch="b"
          :is-current="b.name === currentBranch"
          @click="onBranchClick(b)"
          @longpress="onBranchLongPress(b)"
        />
      </view>
      
      <view v-if="loading" class="loading-more">加载中...</view>
      <view v-else-if="!hasMore" class="no-more">没有更多了</view>
    </scroll-view>
    
    <!-- 新建分支浮动按钮 -->
    <view class="fab" @click="onCreateClick">+</view>
  </view>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import BranchItem from './BranchItem.vue'
import type { Branch } from '@/types/branch'

const props = defineProps<{
  branches: Branch[]
  currentBranch: string
  defaultBranch: string
  loading: boolean
  hasMore: boolean
  owner: string
  repo: string
}>()

const emit = defineEmits<{
  select: [Branch]
  create: []
  delete: [Branch]
  loadMore: []
}>()

const defaultBranchItem = computed(() => 
  props.branches.find(b => b.name === props.defaultBranch)
)

const otherBranches = computed(() => 
  props.branches.filter(b => b.name !== props.defaultBranch)
)

function onBranchClick(b: Branch) {
  emit('select', b)
}

function onBranchLongPress(b: Branch) {
  if (b.protected) {
    uni.showToast({ title: '受保护分支不可删除', icon: 'none' })
    return
  }
  uni.showActionSheet({
    itemList: ['删除分支', '复制分支名'],
    success: (res) => {
      if (res.tapIndex === 0) emit('delete', b)
      else if (res.tapIndex === 1) {
        uni.setClipboardData({ data: b.name })
      }
    }
  })
}

function onCreateClick() {
  emit('create')
}

function onScrollLower() {
  emit('loadMore')
}
</script>

4.4 分支切换确认弹窗

<!-- src/components/branch/SwitchConfirm.vue -->
<template>
  <view class="switch-confirm-mask" @click="$emit('cancel')">
    <view class="switch-confirm" @click.stop>
      <text class="title">切换到 {{ targetBranch }}?</text>
      <text class="desc">
        切换分支后,文件树、提交历史等内容会重新加载。
      </text>
      
      <view v-if="aheadBy > 0 || behindBy > 0" class="diff-info">
        <text class="diff-label">与当前分支差异:</text>
        <text class="diff-line">领先 {{ aheadBy }} 个提交</text>
        <text class="diff-line">落后 {{ behindBy }} 个提交</text>
      </view>
      
      <view class="actions">
        <button class="btn-cancel" @click="$emit('cancel')">取消</button>
        <button class="btn-confirm" @click="$emit('confirm')">确定</button>
      </view>
    </view>
  </view>
</template>

<script setup lang="ts">
defineProps<{
  targetBranch: string
  aheadBy: number
  behindBy: number
}>()

defineEmits<{
  confirm: []
  cancel: []
}>()
</script>

4.5 创建分支表单

<!-- src/components/branch/CreateBranchForm.vue -->
<template>
  <view class="create-form-mask" @click="$emit('close')">
    <view class="create-form" @click.stop>
      <text class="form-title">创建新分支</text>
      
      <view class="form-row">
        <text class="label">分支名 *</text>
        <input
          v-model="form.name"
          class="input"
          placeholder="例如:feature/login"
          :maxlength="50"
        />
        <text v-if="errors.name" class="error-msg">{{ errors.name }}</text>
        <text class="hint">分支名只能包含字母、数字、-、_、/、.</text>
      </view>
      
      <view class="form-row">
        <text class="label">基于 *</text>
        <view class="branch-selector" @click="onBaseBranchClick">
          <text>{{ form.fromBranch || '选择源分支' }}</text>
          <text class="arrow">▼</text>
        </view>
      </view>
      
      <view class="actions">
        <button class="btn-cancel" @click="$emit('close')">取消</button>
        <button class="btn-confirm" :disabled="!isValid" @click="onSubmit">创建</button>
      </view>
    </view>
  </view>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'

const props = defineProps<{
  fromBranch: string
  branches: { name: string }[]
}>()

const emit = defineEmits<{
  submit: [{ name: string; fromBranch: string }]
  close: []
  selectBase: []
}>()

const form = ref({
  name: '',
  fromBranch: props.fromBranch
})

const errors = ref<{ name?: string }>({})

const isValid = computed(() => {
  if (!form.value.name.trim()) return false
  if (props.branches.some(b => b.name === form.value.name)) return false
  return true
})

function onBaseBranchClick() {
  emit('selectBase')
}

function onSubmit() {
  // 校验
  const name = form.value.name.trim()
  if (!name) {
    errors.value.name = '分支名不能为空'
    return
  }
  if (!/^[a-zA-Z0-9._/-]+$/.test(name)) {
    errors.value.name = '分支名包含非法字符'
    return
  }
  if (props.branches.some(b => b.name === name)) {
    errors.value.name = '分支已存在'
    return
  }
  
  emit('submit', { name, fromBranch: form.value.fromBranch })
}
</script>

五、深度技术原理

5.1 Git 分支的本质

Git 分支本质上是一个指向某个 commit 的可移动指针.git/refs/heads/ 目录下每个分支对应一个文件,文件内容就是该分支指向的 commit SHA。

.git/refs/heads/
├── main        # 内容:abc123...
├── develop     # 内容:def456...
└── feature/    # 目录,递归
    └── login   # 内容:789xyz...

分支切换(git checkout)就是让 HEAD 指针指向另一个分支:

main:    A---B---C  (HEAD)
                \
develop:        D---E

切换到 develop 后:

main:    A---B---C
                \
develop:        D---E  (HEAD)

5.2 GitHub API 原理

GitHub 的 branches API 返回的是远程仓库的 ref 信息。本质上是 Git 协议上层的封装:

GET /repos/{owner}/{repo}/branches

返回的 commit 信息是经过 GitHub 数据库预处理后的"扁平化"数据。

5.3 三路合并算法

分支合并的核心是三路合并(Three-way merge):

base:    A---B---C  (共同祖先)
              \   \
feature:      D---E  (我们的修改)
                    \
main:               F---G  (他们的修改)

Git 找到共同祖先 C,diff(C→E) 与 diff(C→G) 合并:

  • 如果两边修改不同文件:自动合并
  • 如果两边修改同一行:冲突,标记为 conflict

GitHub 的 compare API 返回的 mergeable 状态就是基于这个算法。

5.4 Pinia 状态管理

Pinia 是 Vue 3 官方推荐的状态管理库,比 Vuex 更轻量:

// Setup Store 风格(推荐)
export const useStore = defineStore('id', () => {
  // state
  const count = ref(0)
  // getters
  const double = computed(() => count.value * 2)
  // actions
  function increment() { count.value++ }
  return { count, double, increment }
})

在组件中使用:

const store = useStore()
store.count      // 访问 state
store.double     // 访问 getter
store.increment() // 调用 action

六、常见问题解答(FAQ)

Q1:为什么切换分支后页面没有刷新?

原因:没有使用 Pinia 的响应式状态,或者直接在组件中修改本地变量。

解决方案

  1. 把当前分支放在 Pinia store 中
  2. watch 监听 currentBranch 变化,重新拉取文件树、提交历史
watch(() => store.currentBranch, async (newVal) => {
  await store.fetchRepoTree()
  await store.fetchCommits()
})

Q2:受保护分支(protected branch)有什么限制?

GitHub 上 main/master 经常被设置为保护分支,限制:

  • 不能直接 push
  • 必须通过 PR 合并
  • 某些人可能是 admin,可以绕过

AtomGit 处理:长按分支时检测 protected 字段,禁止删除。

Q3:如何获取分支的"领先/落后"数?

方法 1:使用 compare API

GET /repos/owner/repo/compare/main...feature
// 返回 ahead_by 和 behind_by

方法 2:使用 GitHub 状态 API

某些 Webhook 场景用。

Q4:本地分支和远程分支有什么区别?

维度 本地分支 远程分支
存储位置 本地 .git 目录 远程服务器
读写权限 当前用户 仓库协作者
离线可访问
同步机制 git push/pull 自动 fetch

AtomGit 作为 GitHub 远程客户端,所有分支都是远程分支(通过 API 展示)。UI 上可分类展示。

Q5:删除分支失败提示 422 错误?

原因:删除的是默认分支或当前所在分支。

解决:AtomGit 前端校验,禁用默认分支的删除按钮。

Q6:分支名有什么规范?

规范 示例 适用场景
feature/xxx feature/login 新功能
fix/xxx fix/login-bug bug 修复
hotfix/xxx hotfix/security 紧急修复
release/xxx release/v1.0 版本发布
chore/xxx chore/update-deps 杂项

AtomGit 在创建分支时给出命名建议。


七、运行效果

7.1 分支列表

在这里插入图片描述

7.2 创建分支

在这里插入图片描述

7.3 切换分支确认

在这里插入图片描述

7.4 合并预览

在这里插入图片描述

八、扩展方向

  • 分支保护设置:在 UI 上配置保护规则
  • PR 合并触发:从分支页直接发起 PR
  • 分支搜索增强:按 author、date 过滤
  • 批量操作:批量删除已合并分支
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐