使用自动化将 Assembla 迁移到 Github






4.88/5 (8投票s)
如何将 Assembla 空间迁移到 Github,包括工单、贡献者、文件和源代码
引言
从一个代码管理和工单系统迁移到另一个系统可能非常痛苦。本文将介绍如何使用 3 个 Python 脚本、Assembla 的工单导出和 Github 仓库导入,从 Assembla 账户迁移到 Github 账户。
简而言之,这个过程包括:
- 创建所有工单的备份(不幸的是,它不包含任何图片或附件)。
- 从 Github 的新仓库导入 Assembla 仓库。
- 根据本文的解释定制脚本。
- 运行我们的脚本,它将通过自动化处理其余所有工作。
可选:
- 更新 Github 仓库中的现有问题
- 删除 Github 仓库中的所有问题
Assembla SVN
Assembla 是 Subversion 的代码托管提供商,提供托管和自托管的 SVN。在本文中,我们将把一个拥有 SVN 仓库的 Assembla 空间导出到拥有 Git 仓库的 Github 空间。为了本文的需要,我创建了一个测试 SVN,您可以在 这里找到它。
Assembla 工单
Assembla 有一个工单系统。除了文本之外,每个工单都可以包含我们需要导出的各种元素。其中一些元素当然是可选的,但如果存在,我们也希望将它们导出。
- 提交 - 对修订版(提交)的引用。此类引用通过在文本中插入 [[revision:1]] 来实现。
- 分配人 - 分配给该工单的用户。
- 相关工单 - Assembla 允许将一个工单与另一个工单关联,并定义它们之间的几种关系,例如“子”、“父”或“相关”。虽然我们可以使用对相关工单的引用作为工单正文和/或评论的一部分,并且它们可以导出到 Github 并从 Github 导入,但没有与 Assembla 相似的“相关问题”功能。
- 关注者 - 每个工单都可以有关注者,他们是其他有权访问该空间的用户。默认情况下,工单的创建者和分配人是关注者。
- 附件 - 每个工单都可以附加任何类型的文件。导出到 Github 时,某些文件可以按原样导入,而其他文件(例如:视频文件)必须先压缩为 .zip 文件。图片应作为工单的一部分内联显示。
第 1 步 - 导出 SVN
首先,您需要将 SVN 的地址复制到剪贴板。
Assembla 有时会以错误的格式显示地址。
格式应为:
例如
现在我们进入新的 Github 空间并导入它。
粘贴原始 Assembla SVN 的地址,然后按“**开始导入**”。
在此阶段,如果您的原始 Assembla 空间是私有的,系统会要求您输入 Assembla 的凭据,以便 Github 可以连接到您的旧空间并从中导入 SVN。
现在,导入完成后,您不仅成功传输了源代码,还传输了其所有修订。如果转到以下位置,您可以看到它们:
例如
第 2 步 - 导出工单
下一步是将工单导出到 Assembla 生成的 .bak 文件中。正如我们稍后将解释的,仅凭该文件不足以完成迁移,因为它不包含文件,包括作为工单一部分的图像。
首先转到工单设置。您可以使用以下链接:
或(因为您已经登录)
例如
或(如果您已登录)
按“**设置**”链接。
工单随后以类似 JSON 的自定义格式导出。工单附件未导出,我们需要一个自定义解决方案来导出附件。
首先,您会看到一条消息:“**备份已成功安排。**”,所以您需要返回此页面以收集导出的文件,该文件将以 .bak 结尾。
第 3 步 - 导出文件
您的空间 ID
我们的脚本会自动查找 space_id
标识符。此 ID 不是您为 Assembla 空间指定的名称,而是 Assembla 生成的字符和数字组合。space_id
是 Assembla API 的主要构建块之一。
准备 Python 环境
其余过程的准备工作需要安装 Python 和几个库。
下载并安装 Python
- 使用以下 链接下载。
- 安装后,将安装位置的路径添加到
PATH
环境变量。默认位置将是:
- C:\Users\<您的用户名>\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Python 3.7-32
您可以使用以下命令将此条目添加到
PATH
:set path "%path%;c:\Users\<YOUR USER NAME>\AppData\Local\Programs\Python\Python37-32"
- 打开命令提示符 (CMD) 并输入以下行:
python -m pip install selenium pygithub requests webdriver_manager
您可能会收到以下警告。为确保我们的脚本顺利运行,请添加以下条目:
setx path "%path%;c:\Users\<YOUR USER NAME>\ AppData\Local\Programs\Python\Python37-32\Scripts"
这将安装以下扩展:
- Selenium - 通常用于自动化
- PyGithub - 用于与 Github API 交互
- Requests - 用于 HTTP 通信
- webdriver_manager - Python Webdriver 管理器。用于访问各种 Web 浏览器。
运行 Assembla-Github
Assembla-Github_v5.py 是我们用于整个过程的 Python 脚本。
它连接到我们的 Assembla 账户,并根据我们在导出所有工单时生成的 .bak 文件,下载工单中引用的任何文件。
python Assembla-Github_v5.py -r user/repo --download
当我们的脚本扫描 .bak 文件时,您应该会看到以下响应:
首先,它会找出 space_id
,然后打开一个带有 Assembla 网站的 Chrome 浏览器,以便您可以输入凭据。
在自动输入凭据后,它开始将数据导入 Github。
第 4 步 - 导入到 Github
下一步是在运行下一个 Python 脚本 Assembla-Github.py 之前进行的。
您需要编辑此脚本并添加/编辑以下部分:
添加凭据
在继续之前,请使用您的 Assembla 和 Github 用户名和密码更新 Credentias.py 文件。
class Credentials():
github_user = <your Github user>
github_password = <your Github password>
assembla_user = <You Assembla user>
assembla_password = <Your Assembla password>
添加所有贡献者
在以下代码块中,添加每个贡献者的 ID,用逗号分隔。如果不添加匹配的贡献者,“已创建的”问题”将显示“***”。请注意,问题是在 credentials.py 中使用的账户下生成的,因此它们会将凭据账户持有人视为“问题”的“创建者”,而有关每个原始工单/评论作者的信息将作为问题文本的一部分添加到后面。
val = {
#Place here your contributors, separated with commas. "user":"id"
"securedglobe": "c68pgUDuer4PiDacwqjQWU",
}
运行脚本
我们的脚本会扫描主文件夹内的子文件夹,因此我们可以选择我们想要处理的 Assembla 备份文件(.bak )。
解析工单
首先,我们根据 .bak 文件处理附件,从 Assembla 下载每个工单中提到的实际文件。为此,我们使用以下函数:
print("Parsing tickets...")
tickets_arr = []
find_tickets = re.findall(r"^tickets,\s.*", tickets, re.MULTILINE)
for item in find_tickets:
arr = re.search(r"\[.*\]", item)
fault_replace = str(arr.group(0)).replace(",null", ',"null"')
array = ast.literal_eval(fault_replace)
ticket_info = {
"ticket_id": array[0],
"ticket_number": array[1],
"ticket_reporter_id": array[2],
"ticket_assigned_id": array[3],
"ticket_title": array[5],
"ticket_priority": array[6],
"ticket_description": array[7],
"ticket_created_on": array[8],
"ticket_milestone": array[10],
"ticket_username": None,
"status": "",
"references": set(),
"real_number": None,
"ticket_comments": []
}
#["id","number","reporter_id",
"assigned_to_id","space_id","summary",
"priority","description",
# "created_on","updated_at","milestone_id",
"component_id","notification_list",
# "completed_date","working_hours","is_story",
"importance","story_importance","permission_type",
# "ticket_status_id","state","estimate",
"total_estimate","total_invested_hours","total_working_hours",
# "status_updated_at","due_date","milestone_updated_at"]
# get all comments belonging to specific ticket
find_ticket_comments = re.findall(r"^ticket_comments,\s\[\d+\,
{}.*".format(ticket_info["ticket_id"]), tickets, re.MULTILINE)
for item in find_ticket_comments:
arr = re.search(r"\[.*\]", item)
fault_replace = re.sub(r",null", ',"null"', str(arr.group(0)))
# transform comment array to python array
array = ast.literal_eval(fault_replace)
#ticket_comments:fields, ["id","ticket_id","user_id","created_on",
"updated_at","comment","ticket_changes","rendered"]
comment_info = {
"id": array[0],
"ticket_id": array[1],
"user_id": array[2],
"created_on": array[3],
"updated_at": array[4],
"comment": array[5],
"ticket_changes": array[6],
"rendered": array[7],
"attachments": [],
"username": None
}
ticket_info["ticket_comments"].append(comment_info)
sorted_comments_array = sorted(
ticket_info["ticket_comments"],
key=lambda x: datetime.strptime(x['created_on'], '%Y-%m-%dT%H:%M:%S.000+00:00')
)
ticket_info["ticket_comments"] = sorted_comments_array
tickets_arr.append(ticket_info)
return tickets_arr
该函数返回一个包含所有工单的数组。
处理工单更新
每个 Assembla 工单(以及类似的每个 Github 问题)在很多方面都是一个“对话”,关注者和用户可以发表评论。下一个函数会扫描这些状态和评论,并将它们与原始工单关联起来。
# find statuses and link them to the original ticket
def parseStatus(tickets):
print("Parsing tickets status data...")
find_status = re.findall(r"^ticket_changes,\s.*", tickets, re.MULTILINE)
status_arr = []
for item in find_status:
#ticket_changes:fields, ["id","ticket_comment_id",
"subject","before","after",
"extras","created_at","updated_at"]
arr = re.search(r"\[.*\]", item)
fault_replace = str(arr.group(0)).replace(",null", ',"null"')
array = ast.literal_eval(fault_replace)
ticket_status_info = {
"id": array[0],
"ticket_comment_id": array[1],
"subject": array[2],
"before": array[3],
"after": array[4],
"extras": array[5],
"created_at": array[6],
"updated_at": array[7],
"ticket_comments": []
}
status_arr.append(ticket_status_info)
return status_arr
处理工单附件
Assembla 中的每个工单可能包含一个或多个附件。如前所述,Assembla 对您可以附加的文件类型或大小没有限制,但是 Github 只允许某些文件类型成为“问题”的一部分,因此我们在脚本中定义了这些允许的类型,任何不属于这些类型的都会被打包成 .zip 文件然后上传。
EXTS = ['jpg', 'png', 'jpeg', 'docx', 'log', 'pdf', 'pptx', 'txt', 'zip'] # list of allowed extensions
从工单中获取的字段
以下字段是从每个工单中获取的:
- 工单号 - 我们需要考虑到,如果某个工单已从 Assembla 完全删除,而不仅仅被标记为“
已修复
”或“无效
”,则导入到 Github 时,编号可能会有所不同。通过为每个已删除的 Assembla 工单添加“虚拟”工单来解决此问题,以保留编号。 - 报告人 ID - 此工单创建者的用户 ID
- 分配人 ID - 分配人的用户 ID
- 标题 - 工单的标题
- 优先级 - 工单的优先级
- 描述 - 工单正文的文本
- 创建日期 - 工单创建的日期
- 里程碑 - 如果工单分配了里程碑,则此处会显示
"ticket_id": array[0],
"ticket_number": array[1],
"ticket_reporter_id": array[2],
"ticket_assigned_id": array[3],
"ticket_title": array[5],
"ticket_priority": array[6],
"ticket_description": array[7],
"ticket_created_on": array[8],
"ticket_milestone": array[10],
其他字段通过调用函数填充。
"references": set(),
"ticket_comments": []
为了处理所有附件,我们使用 **parseAttachmentsFromBak
** 函数。
def parseAttachmentsFromBak(sid, tickets):
filelist = []
link = f"https://bigfiles.assembla.com/spaces/{sid}/documents/download/"
# get all attachments from .bak file
# save them in a separate list
if not os.path.isfile('filenames.txt'):
find_files = re.findall(r".*?\[\[(file|image):(.*?)(\|.*?)?\]\].*?", tickets)
for file in find_files:
if file:
filelist.append(rf"{file[1]}")
else:
dirfile = glob.glob(f"{FILES_DIR}\**")
with open("filenames.txt") as file:
for line in file:
filelist.append(line.strip())
for file in dirfile:
file = file.replace(f"{FILES_DIR}\\", "")
file = file[:file.rfind(".")]
for c, fi in enumerate(filelist):
if fi in file:
del filelist[c]
elif os.path.isfile(FILES_DIR+'\\'+fi):
del filelist[c]
chrome_options = webdriver.ChromeOptions()
path = os.path.dirname(os.path.realpath(__file__))
chrome_options.add_experimental_option("prefs", {
"download.default_directory": f"{path}\\temp",
"download.prompt_for_download": False,
"download.directory_upgrade": True,
"safebrowsing.enabled": True
})
chrome_options.add_argument("user-data-dir=selenium")
chrome_options.add_argument("start-maximized")
chrome_options.add_argument("--disable-infobars")
try:
driver = webdriver.Chrome(executable_path=ChromeDriverManager().install(),
options=chrome_options, service_log_path='NUL')
except ValueError:
print("Error opening Chrome. Chrome is not installed?")
exit(1)
FILE_SAVER_MIN_JS_URL =
"https://raw.githubusercontent.com/eligrey/FileSaver.js/master/src/FileSaver.js"
file_saver_min_js = requests.get(FILE_SAVER_MIN_JS_URL).content
driver.get("https://bigfiles.assembla.com/login")
sleep(2)
checklink = driver.execute_script("return document.URL;")
if checklink == "https://bigfiles.assembla.com/login":
login = driver.find_element_by_id("user_login")
login.clear()
login.send_keys(Credentials.assembla_user)
passw = driver.find_element_by_id("user_password")
passw.clear()
passw.send_keys(Credentials.assembla_password)
btn = driver.find_element_by_id("signin_button")
btn.click()
sleep(1)
for file in filelist:
# fetch all files from the filelist
download_script = f"""
return fetch('{file}',
{{
"credentials": "same-origin",
"headers": {{"accept":"*/*;q=0.8",
"accept-language":"en-US,en;q=0.9"}},
"referrerPolicy": "no-referrer-when-downgrade",
"body": null,
"method": "GET",
"mode": "cors"
}}
).then(resp => {{
return resp.blob();
}}).then(blob => {{
saveAs(blob, '{file}');
}});
"""
driver.get(f"{link}{file}")
sleep(1)
try:
# execute FileSaver.js if content == img
loc = driver.find_element_by_tag_name('img')
if loc:
driver.execute_script(file_saver_min_js.decode("ascii"))
driver.execute_script(download_script)
WebDriverWait(driver, 120, 1).until(every_downloads_chrome)
WebDriverWait(driver, 120, 1).until(every_downloads_chrome)
except TimeoutException:
pass
except NoSuchElementException:
pass
except JavascriptException:
pass
sleep(8)
temps = glob.glob(f"temp\**")
try:
for tm in temps:
if tm.endswith('jpg'):
os.rename(tm, f"files\{file}.jpg")
elif tm.endswith('png'):
os.rename(tm, f"files\{file}.png")
elif tm.endswith('zip'):
os.rename(tm, f"files\{file}.zip")
elif tm.endswith('pdf'):
os.rename(tm, f"files\{file}.pdf")
elif tm.endswith('docx'):
os.rename(tm, f"files\{file}.docx")
elif tm.endswith('txt'):
os.rename(tm, f"files\{file}.txt")
else:
os.rename(tm, f"files\{file}")
except FileExistsError:
pass
driver.close()
driver.quit()
重命名附件文件
Assembla 中的工单附件以随机的字母数字名称(例如“c68pgUDuer4PiDacwqjQWU
”)保存,我们需要将这些名称转换回每个工单附件的原始名称和扩展名。这可以通过 renameFiles
函数完成。
def renameFiles(sorted_tickets_array):
print("Renaming files...")
for item in sorted_tickets_array:
for comment in item["ticket_comments"]:
if comment["attachments"]:
for attach in comment["attachments"]:
fname = attach["filename"]
fid = attach["file_id"]
if not fname.endswith('.png') and not fname.endswith('.jpg') \
and not fname.endswith('.PNG') and not fname.endswith('.JPG'):
dot = re.search(r"\..*", fname)
dot = "" if not dot else dot.group(0)
try:
get_file = glob.glob(f"{FILES_DIR}\{fid}.*")
if not get_file:
get_file = glob.glob(f"{FILES_DIR}\{fid}")
get_dot = re.search(r"\..*", get_file[0])
get_dot = "" if not get_dot else get_dot.group(0)
if get_dot and not dot:
dot = get_dot
if get_dot.endswith('.png') or
get_dot.endswith('.jpg') or get_dot.endswith('.PNG') \
or get_dot.endswith('.JPG'):
pass
else:
if os.path.isfile(f"{FILES_DIR}\{fid}{dot}"):
pass
else:
print(f"Renaming: {fid} -> {fid}{dot}")
os.rename(get_file[0], f"{FILES_DIR}\{fid}{dot}")
counter = 0
for ext in EXTS:
if ext not in dot:
counter += 1
else:
pass
if counter == len(EXTS) and not get_file[0].endswith(".htm"):
# not attachable
print(f"Making zip file -> {fid}.zip")
if os.path.isfile(f"{FILES_DIR}\{fid}.zip"):
os.remove(f"{FILES_DIR}\{fid}.zip")
obj = zipfile.ZipFile(f"{FILES_DIR}\{fid}.zip", 'w')
obj.write(f"{FILES_DIR}\{fid}{dot}")
obj.close()
except Exception:
pass # doesn't exist
更新先前运行后的工单
如果您只需要更新现有仓库,并且已经从 Assembla 导出了数据,则可以使用 update
选项。
python Assembla-Github_v5.py -f <Your .bak file> -r <Github user>/<Github repo name> --update
例如
python Assembla-Github_v5.py
-f My-Code-Project-Article-2019-09-27.bak -r haephrati/CodeProjectArticle-Test --update
然后,附加到工单的任何文件都将被下载并放置在名为“files”的文件夹中。无法直接作为“问题”添加到 Github 的文件类型将被压缩成 .zip 存档。
将数据上传到 Github
一切就绪后,我们可以将数据上传到 Github 仓库。我们可以更新现有仓库(其中已包含“问题”),或者从头开始为给定仓库创建所有问题。此类仓库必须已如前所述导入了仓库的源代码。
def uploadToGithub(dirfiles, tickets, working_repo):
filelist = []
ready_files = ""
path = os.path.dirname(os.path.realpath(__file__))
# filter attachments from .bak file to remove attachments not allowed or not existing
find_files = re.findall(r".*?\[\[(file|image):(.*?)(\|.*?)?\]\].*?", tickets)
for file in find_files:
for dr in dirfiles:
di = str(dr.replace(f"{FILES_DIR}\\", ""))
di = di[:di.rfind('.')]
if di in file[1]:
filelist.append(f"{path}\{FILES_DIR}\{dr}")
if os.path.isfile('files.txt'):
print('files.txt exists, parsing existing links...')
ex_files = ""
# check for existing links and remove duplicates
with open('files.txt', 'r') as file:
ex_files = file.read()
file_links = re.findall(r".*?\!\[(.*?)\]\((.*?)\).*?", ex_files)
file_urls = re.findall(r".*?\[(.*?)\]\((.*?)\).*?", ex_files)
get_img = re.findall(r"alt=\"(.*?)\"\ssrc=\"(.*?)\"", ex_files)
file_links.extend(get_img)
file_links.extend(file_urls)
for flink in file_links:
for co, fi in enumerate(filelist):
if flink[0] in fi:
del filelist[co]
if not filelist:
print("uploadToGithub: Nothing to upload.")
return 1
# launch selenium to upload attachments to github camo
chrome_options = Options()
chrome_options.add_argument("user-data-dir=selenium")
chrome_options.add_argument("start-maximized")
chrome_options.add_argument("--disable-infobars")
try:
driver = webdriver.Chrome(executable_path=ChromeDriverManager().install(),
options=chrome_options, service_log_path='NUL')
except ValueError:
print("Error opening Chrome. Chrome is not installed?")
exit(1)
driver.implicitly_wait(1000)
driver.get(f"https://github.com/login")
sleep(2)
link = driver.execute_script("return document.URL;")
if link == "https://github.com/login":
login = driver.find_element_by_id("login_field")
login.clear()
login.send_keys(Credentials.github_user)
passw = driver.find_element_by_id("password")
passw.clear()
passw.send_keys(Credentials.github_password)
btn = driver.find_elements_by_xpath
("//*[@class='btn btn-primary btn-block']")
btn[0].click()
sleep(1)
driver.get(f"https://github.com/{working_repo}/issues/")
sleep(2)
findButton = driver.find_elements_by_xpath("//*[@class='btn btn-primary']")
findButton[0].click()
sleep(2)
# split filelist into chunks of 8 files
chunks = [filelist[i:i + 8] for i in range(0, len(filelist), 8)]
for chunk in chunks:
chk = (' \n ').join(chunk)
findBody = driver.find_element_by_id("issue_body")
findBody.clear()
findButton = driver.find_element_by_id("fc-issue_body")
findButton.clear()
if chk:
findButton.send_keys(chk)
print("Waiting for uploads to finish...")
sleep(5)
while True:
chk = findBody.get_attribute('value')
# [Uploading czo0qWjmmr5PZcdmr6CpXy.zip…]()
if "]()" in chk:
sleep(5)
else:
break
# dump ready links with attachments to a separate file
with open('files.txt', 'a+') as ff:
ff.write(chk)
ready_files += chk
driver.close()
driver.quit()
return ready_files
结果
处理完成后,您应该会在新的 Github 仓库中看到您原始的 Assembla 工单(现在是问题),以及包含所有提交的源代码。然后,您可以看到对其他工单(问题)、附件和内联文件以及提交的引用的参考。
其他辅助函数
为了其他用途或需求,这里添加了一些附加函数到我们的脚本中:
删除仓库中的所有问题
通过使用 delete
参数,您可以删除给定仓库中的所有问题。
python Assembla-Github_v5.py -r user/repo --delete
例如
python Assembla-Github_v5.py -r haephrati/test --delete
以及执行此操作的源代码。
def deleteIssues(working_repo):
chrome_options = Options()
chrome_options.add_argument("user-data-dir=selenium")
chrome_options.add_argument("start-maximized")
chrome_options.add_argument("--disable-infobars")
try:
driver = webdriver.Chrome(executable_path=ChromeDriverManager().install(),
options=chrome_options, service_log_path='NUL')
except ValueError:
print("Error opening Chrome. Chrome is not installed?")
exit(1)
driver.implicitly_wait(1000)
driver.get(f"https://github.com/login")
sleep(2)
link = driver.execute_script("return document.URL;")
if link == "https://github.com/login":
login = driver.find_element_by_id("login_field")
login.clear()
login.send_keys(Credentials.github_user)
passw = driver.find_element_by_id("password")
passw.clear()
passw.send_keys(Credentials.github_password)
btn = driver.find_elements_by_xpath("//*[@class='btn btn-primary btn-block']")
btn[0].click()
sleep(1)
driver.get(f"https://github.com/{working_repo}/issues/")
while True:
get_tab = driver.find_element_by_xpath
("//*[@class='js-selected-navigation-item
selected reponav-item']/child::*[@class='Counter']")
if int(get_tab.text) == 0:
print("No issues left. Exit.")
break
else:
find_issues = driver.find_elements_by_xpath
("//*[@class='link-gray-dark v-align-middle no-underline h4 js-navigation-open']")
link = find_issues[0].get_attribute("href")
driver.get(link)
find_notif = driver.find_elements_by_tag_name("summary")
find_notif[len(find_notif)-1].click()
sleep(1)
find_button = driver.find_element_by_xpath
("//*[@class='btn btn-danger input-block float-none']")
find_button.click()
sleep(1)
driver.get(f"https://github.com/{working_repo}/issues/")
driver.close()
driver.quit()
错误处理
在我们的脚本中,我们尝试处理可能的错误。
for ticket in sorted_tickets_array:
try:
issue_name = f"""{ticket["ticket_title"]}"""
lock = None
for check_issue in repo.get_issues(state='all'):
if check_issue and check_issue.title == issue_name:
print("Issue exists; passing: [", check_issue.title, "]")
lock = 1
if not lock:
issue = createIssue(issue_name, ticket, repo, file_links)
addComments(ticket, issue, file_links, repo)
except RateLimitExceededException as e:
# wait 1 hour for rate limit
print(e, "Waiting 1 hour...")
sleep(60*61)
continue
except Exception as e:
print(e)
pass
处理干扰自动化的安全措施
Github 与其他平台一样,已采取 措施来防止其 API 进行大规模操作,一段时间后,这些措施可能会阻止任何其他操作。我们识别这种情况,并在这种情况下将我们的操作暂停一小时。
这由以下异常处理:
except RateLimitExceededException as e: # wait 1 hour for rate limit print
(e, "Waiting 1 hour...") sleep(60*61) continue
关注点
- 关于 Assembla
- 关于 Github
- Python 自动化(Windows)
- 一篇关于 Git 的 Code Project 文章
历史
- 2019 年 9 月 30 日:初始版本