Uniapp 鸿蒙实战之 AtomGit APP - 分支管理与切换功能

一、我们要做什么
在 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 分支切换的状态管理
切换分支是一个异步、有副作用的操作:
- UI 切换 Tab → 显示 Loading
- 触发 Pinia Action → fetch 仓库详情
- 更新 Pinia State → 文件树、提交历史全部联动
- 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 的响应式状态,或者直接在组件中修改本地变量。
解决方案:
- 把当前分支放在 Pinia store 中
- 在
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 过滤
- 批量操作:批量删除已合并分支
更多推荐



所有评论(0)