跳至内容
GitTree

课程 6 · 日常工作

在判断更改之前先阅读比较

将英雄变成一个可运行的静态项目,然后区分工作、上演和记录的差异。检查单独的确定性图像变体,而不将图像提交添加到持续的过程历史记录中。

进度已保存在此浏览器中。

播放时会加载来自 YouTube 的视频(隐私增强模式)。

英文旁白 · English and Arabic 字幕

打开视频页面在 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

文件摘录 · html
<!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

文件摘录 · css
: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

文件摘录 · javascript
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

文件摘录 · json
{
  "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

文件摘录 · json
{
  "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

文件摘录 · javascript
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

文件摘录 · javascript
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

文件摘录 · javascript
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

文件摘录 · javascript
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 — 忽略构建

文件摘录 · gitignore
dist/

在GitTree

  1. 在原始存储库中,使用 Command Palette… (Command/Ctrl+Shift+P) → Show Working Changes。单击 Unstaged Files 中的index.html。在替换之前,请识别第 5 课中保留的支撑线编辑。
  2. 使用 Show Files(Command/Ctrl+Shift+E);将焦点放在文件树中的index.html 并按Enter 键。将其工作内容替换为 HTML-02,然后替换为 Save(编辑器中的 Command/Ctrl+S)。等待未保存的指示器清除。
  3. 对于上面的每个其他完整文件:调色板 New File… → Path from the project folder,输入确切的根相对路径 → Create,粘贴其自己的完整正文 → Save。嵌套路径创建其缺失的目录。拒绝现有文件意味着检查该文件,而不是覆盖未知的工作。
  4. Back to Graph → 调色板 Show Working Changes。查看未跟踪的文件以及index.html。在上面指定的十个已更改路径上使用 Stage File,而不是生成 dist 输出。保持现有的自述文件不变。
  5. 单击 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。即使在阿拉伯语中,斯普利特的旧的仍然是左,新的右。
  6. 检查 Ignore whitespace / 调色板 Ignore Whitespace in Diffs,然后将其恢复关闭。隐藏的空白并不是没有内容;当隐藏空白更改时,部分选择可能不可用。不要使用显示过滤器作为干净索引的证据。
  7. 在原始文件夹中运行下面的本机检查。然后输入摘要 feat: add styling and local project checks,保持 amend/push/empty/skip-hooks 关闭,然后选择 Commit Changes to 10 Files。 Command/Ctrl+Enter 是替代提交,而不是第二步。验证两个提交并清理跟踪状态。

在航站楼

在 input 检查点,此只读查询捕获尚未提交的第 5 课编辑:

Command · bash
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>

保存所有有效负载后,在暂存之前:

Command · bash
git status --short
输出
 M index.html
?? .gitignore
?? package-lock.json
?? package.json
?? scripts/
?? site.js
?? styles.css
?? tests/

目录条目scripts/和tests/被分组为未跟踪状态,而不是其文件主体被审查的证据。

运行检查;不需要安装依赖项或网络,因为这些脚本仅使用 Node 内置函数:

Command · bash
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 行为仍然需要记录环境检查;这里没有断言服务器采取。

仅当应用程序尚未执行这些突变时,本机阶段/提交替代方案:

Command · bash
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 × 641629a09ba97c890da0d542166ba17882bea8f7bf9b9bd4f5ec068a156f6b7256773
之后64 × 641712ddabb4b9859aa53ff9c433a48cb7db0213e184b425a86fef20d8502eed8974f

before — full PNG Base64 bytes

输出
iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAaUlEQVR4nO3QAQkAAAjAMEtYyr7m0xgi7PACi6yey68LAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4D/AAqNletEZdhfWAAAAAElFTkSuQmCC

after — full PNG Base64 bytes

输出
iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAcklEQVR4nO3QQQ0AIBAEsTOBA9QQ1OANfaCC7INOMgZabeyTPF0BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJ7f14wOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfABwAT8dDllEqFx1AAAAAElFTkSuQmCC

记录操作员可以在隔离副本中一次解码一个有效负载:

Command · bash
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'));"

将尖括号占位符替换为一个完整的有效负载,不带括号。在之前解码,暂存/提交夹具,然后在不暂存的情况下解码。提供的字节计数/摘要标识每个状态;切勿重复使用捏造的图像屏幕截图。

在该变体中,本机:

Command · bash
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/测试/构建是一个真实的信号;保留其输出并仅修复指定的演示输入。不要跳过检查或删除未知文件夹。被阻止/不受支持的图像应保留诚实的二进制/大小通知,直到其实际格式/大小得到协调。取消过时的部分选择请求并重新加载当前差异,而不是强制打补丁。

检查你的理解情况

回答每个问题,然后阅读原因。标记为“必填”的问题将有助于完成本课程。

  1. 第 1 题,共 6 题

    普通的 git diff 比较什么?

    选择一个答案。

  2. 第 2 题,共 6 题

    哪些操作使存储库快照保持不变?

    选择每个适用的答案。

  3. 第 3 题,共 6 题
    必填

    新的 site.js 未被跟踪。普通的 diff 对它来说是空的。是不是缺席了?

    选择一个答案。

  4. 第 4 题,共 6 题
    必填

    如果您暂存该新文件,可以在哪里查看其完整添加内容?

    选择一个答案。

  5. 第 5 题,共 6 题

    Image numstat 打印两个破折号。你能得出什么结论?

    选择一个答案。

  6. 第 6 题,共 6 题
    必填

    验证已完成的检查点。需要同时提供哪些事实?

    选择每个适用的答案。

0 个(共 6 个)正确

正确回答所需的问题以完成课程。

在此浏览器中检查。

练习

在原始版本中,验证计数 2、清空工作/缓存 diff 并忽略 dist。在单独的图像变体中,指出哪个象限发生了变化以及本机 numstat 报告破折号的原因。更改的输入:如果新文件仍未跟踪而不是暂存,则预测普通 diff 仍可以忽略它。

您自己的确认:该网站无法看到您的计算机。

要完成本课程,请正确回答所需的问题并确认实践练习。

回顾

  • 在读取差异之前命名两个端点。
  • 文本、二进制和解码图像视图提供了不同的证据。
  • 检查和精确的分段保护可运行的检查点。

术语表介绍

差异 — ففق;猛男 — كר;索引 — 索引;工作树 — ????????????二进制 — Ыанай;未追踪 — 未追踪。

视频文字记录

视频的旁白,逐条提示。

显示成绩单
  1. 阅读比较 在判断变化之前。
  2. 后期的英雄线依然是 工作内容,而不是新的提交。
  3. 普通差异和阶段差异 比较不同的快照。
  4. 添加完整的可运行输入,保持 现有的支持线编辑。
  5. 新文件可能会从普通文件中丢失 diff 直到它们进入索引。
  6. 暂存声明的输入, 未生成构建输出。
  7. 布局改变了你的 查看,而不是比较的字节。
  8. 按照之前周围的文字 解释添加或删除。
  9. 隐藏空白不是 删除空白更改。
  10. 运行本地格式化 提交前检查。
  11. 所有六个输入验证 测试在此夹具中通过。
  12. 构建输出属于 忽略目录,而不是提交。
  13. 提交已审核的 可在本地运行的项目。
  14. 验证两次提交,没有 剩余跟踪差异。
  15. 使用单独的副本进行图像比较, 不是持续检查点。
  16. 两个确切的 PNG 有效负载 识别图像状态。
  17. 比较指定的边和 他们的实际尺寸。
  18. 移动分隔线,无需 更改任一文件。
  19. 不透明度改变比较, 不是保存的像素。
  20. Git 的二进制摘要不是 更改像素的计数。
  21. 不受支持或过大的文件 必须保留其诚实的通知。
  22. 保留图像固定装置 正典历史之外。
  23. 预测哪个比较 显示新暂存的文件。
  24. 接下来,选择帅哥和线条 而不放弃其他工作。