课程 6 · 日常工作
在判断更改之前先阅读比较
将英雄变成一个可运行的静态项目,然后区分工作、上演和记录的差异。检查单独的确定性图像变体,而不将图像提交添加到持续的过程历史记录中。
- 级别: 初学者
- 阅读 7 分钟
- 5 分钟的视频
- 已更新
- 在这节课之前: 暂存预期的快照并进行第一次提交
进度已保存在此浏览器中。
播放时会加载来自 YouTube 的视频(隐私增强模式)。
你将学到什么
- 命名文本比较的双方并解释添加/删除。
- 解释为什么普通 git diff 中可能不存在未跟踪的文件。
- 直观地比较图像,无需假装 Git 提供文本图像补丁。
- 在提交可运行页面之前添加并验证微小的本地检查。
开始之前
您将从第 5 课结束的地方开始:带有一个英雄提交的原始主线。 HEAD/索引包含 HTML-01;工作index.html 包含HTML-01-WORKING。不存在遥控器。在 HTML-02 中保留支持行编辑;不要丢弃它。
该视频是在 Ubuntu 24.04 上使用 Git 2.52.0、Node 22.22.0、npm 10.9.4 和 GitTree 2.0 录制的:十个文件保存在 GitTree 自己的编辑器中,在应用程序中暂存和提交,终端检查针对应用程序创建的内容运行,或者针对暂存前状态和图像的单独副本运行。节点检查在 Node 22(项目声明的版本)上传递。 macOS 或 Windows 上没有运行任何内容,并且 Ubuntu 是唯一记录的 Linux 系统。 GitTree检查的Git版本是2.39.0。登录到 GitTree 后,本地 Git 将采用免费套餐,无需提供程序或 API 凭据。
概念
diff 回答的是有关 two snapshots 的问题,而不是文件是否“好”的问题。普通git diff比较索引→工作树; git diff --cached 比较 HEAD → 索引; git diff HEAD 比较 HEAD → 工作树。加号标记新侧文本,减号标记旧侧文本,而不是认可或指责。请阅读 Git 的 diff 参考 了解比较语义。
未跟踪的文件没有普通的索引条目。因此,空的普通 diff 并不能证明不存在新文件。还要查看状态和新添加的文件内容。暂存新文件后,可以在缓存的比较中查看其完整添加内容。
更改内联/拆分/块、换行、图像模式或隐藏空白显示会更改 view,而不是文件字节、索引、提交或远程引用。分期确实会改变索引。最终正常提交移动本地main;没有服务器状态改变。
完整的可运行项目输入
保持 README-01 不变。将 index.html 替换为 HTML-02,并使用 UTF-8/LF 和最后一个换行符创建下面的所有剩余文件。最小的 .gitignore 是有意为之的:构建输出不得意外进入第一个可运行的提交。更广泛的忽略政策稍后出台。这些是课程文档有效负载,而不是安装到网络存储库中的应用程序文件。
index.html — HTML-02
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GitTree Features — course demo</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="site-header">
<a href="#main">GitTree Features</a>
</header>
<main id="main">
<section class="hero" aria-labelledby="hero-title">
<p class="eyebrow">GitTree Learn demo project</p>
<h1 id="hero-title">Understand every change.</h1>
<p id="hero-copy">Choose one change, then check what Git saved.</p>
</section>
<section class="features" aria-labelledby="features-title">
<h2 id="features-title">Follow your work</h2>
<div class="cards">
<article class="feature-card">
<h3>History</h3>
<p>Read commits and branches together.</p>
</article>
<article class="feature-card">
<h3>Changes</h3>
<p>Review the exact content selected for a commit.</p>
</article>
<article class="feature-card">
<h3>Recovery</h3>
<p>Inspect the scope of a recovery point before using it.</p>
</article>
</div>
</section>
</main>
<footer><p>Built by a fictional team for GitTree Learn.</p></footer>
<script type="module" src="site.js"></script>
</body>
</html>样式.css — CSS-01
:root {
color-scheme: light;
--page: #f7f8fa;
--surface: #ffffff;
--text: #17212e;
--link: #154ab5;
--line: #bac4d0;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--page);
color: var(--text);
font: 1rem/1.6 system-ui, sans-serif;
}
a {
color: var(--link);
}
.site-header,
main,
footer {
width: min(68rem, 100% - 2rem);
margin-inline: auto;
}
.site-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 1rem;
padding-block: 1rem;
}
.hero {
max-width: 44rem;
padding-block: 3rem;
}
h1 {
font-size: clamp(2rem, 5vw, 3.5rem);
line-height: 1.2;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
gap: 1rem;
}
.feature-card {
padding: 1rem;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--surface);
}
section {
margin-block-end: 2rem;
}
button,
input {
font: inherit;
}
button,
input {
padding: 0.5rem 0.75rem;
}
:focus-visible {
outline: 3px solid var(--link);
outline-offset: 3px;
}
form {
display: grid;
max-width: 28rem;
gap: 0.5rem;
}site.js — JS-01
export function validateEmail(value) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
}
if (typeof document !== 'undefined') {
const form = document.querySelector('#signup-form');
const email = document.querySelector('#demo-email');
const result = document.querySelector('#signup-result');
const toggle = document.querySelector('#theme-toggle');
if (form && email && result) {
form.addEventListener('submit', (event) => {
event.preventDefault();
result.textContent = validateEmail(email.value)
? 'Valid format. This demo sent nothing.'
: 'Enter a complete fictional email address.';
});
}
if (toggle) {
toggle.addEventListener('click', () => {
const enabled = document.documentElement.dataset.theme !== 'dark';
document.documentElement.dataset.theme = enabled ? 'dark' : 'light';
toggle.setAttribute('aria-pressed', String(enabled));
toggle.textContent = enabled ? 'Use light mode' : 'Use dark mode';
});
}
}package.json — PACKAGE-01
{
"name": "gittree-features",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"lint": "node scripts/lint.mjs",
"test": "node --test",
"build": "node scripts/build.mjs",
"start": "node scripts/serve.mjs"
}
}package-lock.json — LOCK-01
{
"name": "gittree-features",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gittree-features",
"version": "0.1.0",
"engines": {
"node": ">=22"
}
}
}
}测试/site.test.mjs — TEST-01
import assert from 'node:assert/strict';
import test from 'node:test';
import { validateEmail } from '../site.js';
test('accepts the fictional team address', () => {
assert.equal(validateEmail('maya@example.com'), true);
});
test('trims surrounding whitespace', () => {
assert.equal(validateEmail(' leo@example.com '), true);
});
test('rejects a missing domain', () => {
assert.equal(validateEmail('omar@'), false);
});
test('rejects a missing local part', () => {
assert.equal(validateEmail('@example.com'), false);
});
test('rejects spaces inside an address', () => {
assert.equal(validateEmail('sara name@example.com'), false);
});
test('rejects input without an at sign', () => {
assert.equal(validateEmail('priya.example.com'), false);
});脚本/lint.mjs — LINT-01
import { readFile } from 'node:fs/promises';
const files = ['index.html', 'styles.css', 'site.js'];
const problems = [];
for (const file of files) {
const source = await readFile(file, 'utf8');
if (!source.endsWith('\n')) problems.push(file + ': missing final newline');
const lines = source.split('\n');
for (const [index, line] of lines.entries()) {
if (/[\t ]+$/.test(line)) {
problems.push(file + ':' + (index + 1) + ': trailing whitespace');
}
}
}
if (problems.length > 0) {
process.stderr.write(problems.join('\n') + '\n');
process.exitCode = 1;
} else {
process.stdout.write('Source formatting checks passed.\n');
}脚本/build.mjs — BUILD-01
import { copyFile, mkdir, readdir, stat } from 'node:fs/promises';
import path from 'node:path';
const output = path.resolve('dist');
const queue = ['index.html', 'styles.css', 'site.js', 'assets', 'vendor/snippets'];
await mkdir(output, { recursive: true });
while (queue.length > 0) {
const source = queue.pop();
let details;
try {
details = await stat(source);
} catch (error) {
if (error.code === 'ENOENT' && (source === 'assets' || source === 'vendor/snippets')) continue;
throw error;
}
if (details.isDirectory()) {
const entries = await readdir(source, { withFileTypes: true });
for (const entry of entries) {
if (entry.name !== '.git') queue.push(path.join(source, entry.name));
}
continue;
}
const target = path.join(output, source);
await mkdir(path.dirname(target), { recursive: true });
await copyFile(source, target);
}
process.stdout.write('Built the static site in dist/.\n');脚本/serve.mjs — SERVE-01
import { readFile, stat } from 'node:fs/promises';
import http from 'node:http';
import path from 'node:path';
const root = path.resolve('dist');
const port = Number(process.env.PORT ?? 8080);
const types = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.bmp': 'image/bmp',
};
await stat(path.join(root, 'index.html'));
const server = http.createServer(async (request, response) => {
try {
const address = new URL(request.url, 'http://localhost');
const pathname = decodeURIComponent(address.pathname);
const target = path.resolve(root, '.' + (pathname === '/' ? '/index.html' : pathname));
const extension = path.extname(target);
if (!target.startsWith(root + path.sep) || !Object.hasOwn(types, extension)) {
response.writeHead(404).end('Not found');
return;
}
const body = await readFile(target);
response.writeHead(200, {
'Content-Type': types[extension],
'X-Content-Type-Options': 'nosniff',
}).end(body);
} catch {
response.writeHead(404).end('Not found');
}
});
server.listen(port, '127.0.0.1', () => {
process.stdout.write('Demo site: http://127.0.0.1:' + port + '\n');
});
process.on('SIGINT', () => server.close());
process.on('SIGTERM', () => server.close());.gitignore — 忽略构建
dist/在GitTree
- 在原始存储库中,使用 Command Palette… (Command/Ctrl+Shift+P) → Show Working Changes。单击 Unstaged Files 中的index.html。在替换之前,请识别第 5 课中保留的支撑线编辑。
- 使用 Show Files(Command/Ctrl+Shift+E);将焦点放在文件树中的index.html 并按Enter 键。将其工作内容替换为 HTML-02,然后替换为 Save(编辑器中的 Command/Ctrl+S)。等待未保存的指示器清除。
- 对于上面的每个其他完整文件:调色板 New File… → Path from the project folder,输入确切的根相对路径 → Create,粘贴其自己的完整正文 → Save。嵌套路径创建其缺失的目录。拒绝现有文件意味着检查该文件,而不是覆盖未知的工作。
- Back to Graph → 调色板 Show Working Changes。查看未跟踪的文件以及index.html。在上面指定的十个已更改路径上使用 Stage File,而不是生成 dist 输出。保持现有的自述文件不变。
- 单击 Staged Files 下的每个路径。使用 Diff view 中提供的 Hunk、Inline 和 Split。调色板等效项为 Show Diff as Hunks、Show Diff Inline、Show Diff Side by Side。 Next change 是 F7; Previous change 是 Shift+F7。即使在阿拉伯语中,斯普利特的旧的仍然是左,新的右。
- 检查 Ignore whitespace / 调色板 Ignore Whitespace in Diffs,然后将其恢复关闭。隐藏的空白并不是没有内容;当隐藏空白更改时,部分选择可能不可用。不要使用显示过滤器作为干净索引的证据。
- 在原始文件夹中运行下面的本机检查。然后输入摘要
feat: add styling and local project checks,保持 amend/push/empty/skip-hooks 关闭,然后选择 Commit Changes to 10 Files。 Command/Ctrl+Enter 是替代提交,而不是第二步。验证两个提交并清理跟踪状态。
在航站楼
在 input 检查点,此只读查询捕获尚未提交的第 5 课编辑:
git diff -- index.html观察到的完整输出:
diff --git a/index.html b/index.html
index 5470374..27f8718 100644
--- a/index.html
+++ b/index.html
@@ -13,7 +13,7 @@
<section class="hero" aria-labelledby="hero-title">
<p class="eyebrow">GitTree Learn demo project</p>
<h1 id="hero-title">Understand every change.</h1>
- <p id="hero-copy">A clear view of Git history.</p>
+ <p id="hero-copy">Choose one change, then check what Git saved.</p>
</section>
</main>
<footer><p>Built by a fictional team for GitTree Learn.</p></footer>保存所有有效负载后,在暂存之前:
git status --short M index.html
?? .gitignore
?? package-lock.json
?? package.json
?? scripts/
?? site.js
?? styles.css
?? tests/目录条目scripts/和tests/被分组为未跟踪状态,而不是其文件主体被审查的证据。
运行检查;不需要安装依赖项或网络,因为这些脚本仅使用 Node 内置函数:
npm run --silent lint
npm test -- --test-reporter=tap
npm run --silent build
git check-ignore dist/index.html观察到的 lint 输出:
Source formatting checks passed.观察到的 test-summary excerpt,不包括每次测试的时间:
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0观察构建和忽略输出,每个命令一个:
Built the static site in dist/.
dist/index.html编写的npm start脚本绑定loopback并服务dist。它是长期运行的;不要将服务器进程与通过的测试混淆。它的实时 HTTP 行为仍然需要记录环境检查;这里没有断言服务器采取。
仅当应用程序尚未执行这些突变时,本机阶段/提交替代方案:
git add -- .gitignore index.html styles.css site.js package.json package-lock.json tests/site.test.mjs scripts/lint.mjs scripts/build.mjs scripts/serve.mjs
git diff --cached --stat
git commit --quiet -m "feat: add styling and local project checks"
git rev-list --count HEAD
git status --short安静的提交悄无声息地成功了; count 打印 2,最终的短状态为空。该统计数据是一种审阅辅助工具,不能替代阅读所有十个文件正文。 dist 被忽略,不被提交;没有添加遥控器。
单独的图像比较变体
准备一个独立的 copy of this same GitTree Features checkpoint,不要准备第二个不相关的示例或连续的原始示例。添加 asset/diff-card.png 与之前的字节,进行明确标记的仅固定装置提交,然后用之后替换其工作字节。这些 Markdown 文件不提供图像文件或最终媒体。
两个 PNG 的左半部分都是蓝色的。之前有一个白色的右半边;之后有一个绿色的右下角。确切的完整字节:
| 版本 | 尺寸 | 字节 | SHA-256 |
|---|---|---|---|
| 之前 | 64 × 64 | 162 | 9a09ba97c890da0d542166ba17882bea8f7bf9b9bd4f5ec068a156f6b7256773 |
| 之后 | 64 × 64 | 171 | 2ddabb4b9859aa53ff9c433a48cb7db0213e184b425a86fef20d8502eed8974f |
before — full PNG Base64 bytes
iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAaUlEQVR4nO3QAQkAAAjAMEtYyr7m0xgi7PACi6yey68LAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4D/AAqNletEZdhfWAAAAAElFTkSuQmCCafter — full PNG Base64 bytes
iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAcklEQVR4nO3QQQ0AIBAEsTOBA9QQ1OANfaCC7INOMgZabeyTPF0BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJ7f14wOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfABwAT8dDllEqFx1AAAAAElFTkSuQmCC记录操作员可以在隔离副本中一次解码一个有效负载:
node --input-type=module -e "import {mkdirSync,writeFileSync} from 'node:fs'; mkdirSync('assets',{recursive:true}); writeFileSync('assets/diff-card.png',Buffer.from('<selected-full-base64>','base64'));"将尖括号占位符替换为一个完整的有效负载,不带括号。在之前解码,暂存/提交夹具,然后在不暂存的情况下解码。提供的字节计数/摘要标识每个状态;切勿重复使用捏造的图像屏幕截图。
在该变体中,本机:
git diff --numstat -- assets/diff-card.png在二元探针中观察到:
- - assets/diff-card.png破折号表示 Git 报告二进制数据,而不是零变化的像素。
在实际应用中:Show Working Changes → 单击 Unstaged Files 中的 asset/diff-card.png → Side by side → Swipe,用箭头键移动 Swipe position → Onion skin,更改 After image opacity。调色板等效项:Show Image Changes Side by Side、Show Image Changes with a Swipe、Show Image Changes as Onion Skin。双方都必须实际解码;缺席/太大/不是图像是一种不同的有效状态,而不是伪造图像的理由。来源可识别 PNG/JPEG/GIF/WebP、not BMP;以后的 LFS BMP 夹具不得允许此图像视图。
回归原点,下一课的检查点。图像变体的额外提交/资产永远不会进入规范历史。
结果
main 上有两次提交;自述文件加上跟踪的十个声明的输入;索引/工作文件与 HEAD 一致;生成的 dist 可能存在但会被忽略。规范树中没有图像固定装置或遥控器。
常见错误
- 清空普通差异,同时未跟踪新文件:检查状态和分阶段添加。
- 读取 --stat 作为正确性:检查实际内容和两个比较端点。
- 将空白隐藏视为删除空白:这只是一个视图。
- 提交 dist 或图像侧设备:部署准确的标准列表。
如果出现问题
失败的 lint/测试/构建是一个真实的信号;保留其输出并仅修复指定的演示输入。不要跳过检查或删除未知文件夹。被阻止/不受支持的图像应保留诚实的二进制/大小通知,直到其实际格式/大小得到协调。取消过时的部分选择请求并重新加载当前差异,而不是强制打补丁。
检查你的理解情况
回答每个问题,然后阅读原因。标记为“必填”的问题将有助于完成本课程。
0 个(共 6 个)正确
正确回答所需的问题以完成课程。
在此浏览器中检查。
练习
在原始版本中,验证计数 2、清空工作/缓存 diff 并忽略 dist。在单独的图像变体中,指出哪个象限发生了变化以及本机 numstat 报告破折号的原因。更改的输入:如果新文件仍未跟踪而不是暂存,则预测普通 diff 仍可以忽略它。
您自己的确认:该网站无法看到您的计算机。
要完成本课程,请正确回答所需的问题并确认实践练习。
回顾
- 在读取差异之前命名两个端点。
- 文本、二进制和解码图像视图提供了不同的证据。
- 检查和精确的分段保护可运行的检查点。
有关的
术语表介绍
差异 — ففق;猛男 — كר;索引 — 索引;工作树 — ????????????二进制 — Ыанай;未追踪 — 未追踪。
视频文字记录
视频的旁白,逐条提示。
显示成绩单
- 阅读比较 在判断变化之前。
- 后期的英雄线依然是 工作内容,而不是新的提交。
- 普通差异和阶段差异 比较不同的快照。
- 添加完整的可运行输入,保持 现有的支持线编辑。
- 新文件可能会从普通文件中丢失 diff 直到它们进入索引。
- 暂存声明的输入, 未生成构建输出。
- 布局改变了你的 查看,而不是比较的字节。
- 按照之前周围的文字 解释添加或删除。
- 隐藏空白不是 删除空白更改。
- 运行本地格式化 提交前检查。
- 所有六个输入验证 测试在此夹具中通过。
- 构建输出属于 忽略目录,而不是提交。
- 提交已审核的 可在本地运行的项目。
- 验证两次提交,没有 剩余跟踪差异。
- 使用单独的副本进行图像比较, 不是持续检查点。
- 两个确切的 PNG 有效负载 识别图像状态。
- 比较指定的边和 他们的实际尺寸。
- 移动分隔线,无需 更改任一文件。
- 不透明度改变比较, 不是保存的像素。
- Git 的二进制摘要不是 更改像素的计数。
- 不受支持或过大的文件 必须保留其诚实的通知。
- 保留图像固定装置 正典历史之外。
- 预测哪个比较 显示新暂存的文件。
- 接下来,选择帅哥和线条 而不放弃其他工作。