Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs
T
kdletters dcd8901246 发布独立游戏聊天0.1.1并强化试玩验收
新增只能打开游戏聊天页的0.1.1独立release构建配置。
内置Runner启动、诊断日志、Windows后台进程与退出收束。
修复自动预览启动、同页版本刷新和结构化验收状态展示。
强化试玩协议、窗口末端采样与固定失败识别,允许正常输赢但拒绝无法推进。
完善旧验收回执自愈和当前场景指纹完成门。
补齐Rust、前端、真实Chrome回归测试及项目文档。
2026-07-31 13:44:13 +08:00

3120 lines
108 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::command_exec::resolve_project_command_spec_at;
use crate::project::{normalize_relative_path, should_skip_project_snapshot_path};
use sha2::{Digest as _, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write as IoWrite};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
const GIT_INSPECT_TIMEOUT: Duration = Duration::from_secs(3);
const GIT_INSPECT_OUTPUT_MAX_BYTES: usize = 256 * 1024;
const GIT_COMMIT_TIMEOUT: Duration = Duration::from_secs(10);
const GIT_COMMIT_MESSAGE_MAX_BYTES: usize = 16 * 1024;
const GIT_COMMIT_PATH_MAX: usize = 12;
const GIT_COMMIT_SNAPSHOT_MAX_PATHS: usize = 10_000;
const GIT_COMMIT_SNAPSHOT_MAX_BYTES: u64 = 512 * 1024 * 1024;
const GIT_COMMIT_STORAGE_MAX_ENTRIES: usize = 200_000;
const GIT_CONTROL_FILE_MAX_BYTES: u64 = 64 * 1024;
const GIT_COMMIT_REFLOG_MESSAGE: &str = "project.git_commit: controlled local commit";
struct GitInspectCommandContext {
executable: PathBuf,
safe_path: std::ffi::OsString,
sandbox: tempfile::TempDir,
}
struct BoundedGitOutput {
raw: Vec<u8>,
text: String,
truncated: bool,
}
struct LocalGitIdentity {
name: String,
email: String,
}
struct CommitSnapshot {
fingerprint: String,
captured_paths: BTreeMap<String, CapturedCommitPath>,
}
enum CapturedCommitPath {
File { snapshot_path: PathBuf, mode: u32 },
Deleted,
}
struct CommitWorktreeState {
head: String,
branch: String,
branch_ref: String,
staged: Vec<String>,
unstaged: Vec<String>,
untracked: Vec<String>,
fingerprint: String,
captured_paths: BTreeMap<String, CapturedCommitPath>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum LocalGitCommitHookPoint {
BeforeUpdateRef,
AfterUpdateRef,
}
#[derive(Clone, Copy)]
enum GitCommandInput<'a> {
None,
Bytes(&'a [u8]),
File(&'a Path),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct LocalGitWorktreeInspect {
pub(crate) head: String,
pub(crate) branch: Option<String>,
pub(crate) commit_snapshot_fingerprint: Option<String>,
pub(crate) staged: Vec<String>,
pub(crate) unstaged: Vec<String>,
pub(crate) untracked: Vec<String>,
pub(crate) file_count: usize,
pub(crate) truncated: bool,
pub(crate) content: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct LocalGitCommitResult {
pub(crate) parent_head: String,
pub(crate) commit_head: String,
pub(crate) branch: String,
pub(crate) paths: Vec<String>,
pub(crate) message_sha256: String,
pub(crate) remaining_changed_count: usize,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct LocalGitCommitError {
message: String,
needs_reconciliation: bool,
}
impl LocalGitCommitError {
fn ordinary(message: impl Into<String>) -> Self {
Self {
message: message.into(),
needs_reconciliation: false,
}
}
fn reconciliation(message: impl Into<String>) -> Self {
Self {
message: message.into(),
needs_reconciliation: true,
}
}
pub(crate) fn message(&self) -> &str {
&self.message
}
pub(crate) fn needs_reconciliation(&self) -> bool {
self.needs_reconciliation
}
}
pub(crate) fn inspect_local_git_worktree_at(
root: &Path,
include_diff: bool,
max_files: usize,
max_chars: usize,
) -> Result<LocalGitWorktreeInspect, String> {
let root = root
.canonicalize()
.map_err(|error| format!("读取项目目录失败:{error}"))?;
let command = build_git_inspect_command_context(&root)?;
ensure_git_top_level(&root, &command)?;
let head = read_git_head(&root, &command);
let branch = read_git_branch(&root, &command);
let status_output = run_git_bounded(
&root,
&command,
&[
"status",
"--porcelain=v1",
"-z",
"--untracked-files=all",
"--ignore-submodules=all",
"--no-renames",
],
)?;
let status = &status_output.text;
let status_truncated = status_output.truncated;
let (mut staged, mut unstaged, mut untracked) = parse_status(&root, status);
staged.sort();
staged.dedup();
unstaged.sort();
unstaged.dedup();
untracked.sort();
untracked.dedup();
let all_paths = staged
.iter()
.chain(&unstaged)
.chain(&untracked)
.cloned()
.collect::<BTreeSet<_>>();
let file_count = all_paths.len();
let selected_paths = all_paths
.into_iter()
.take(max_files)
.collect::<BTreeSet<_>>();
let mut truncated = status_truncated || file_count > selected_paths.len();
let mut content = String::new();
append_path_section(&mut content, "staged files", &staged, &selected_paths);
append_path_section(&mut content, "unstaged files", &unstaged, &selected_paths);
append_path_section(&mut content, "untracked files", &untracked, &selected_paths);
let mut staged_diff = BoundedGitOutput {
raw: Vec::new(),
text: String::new(),
truncated: false,
};
let mut unstaged_diff = BoundedGitOutput {
raw: Vec::new(),
text: String::new(),
truncated: false,
};
if include_diff {
let staged_paths = selected_paths
.iter()
.filter(|path| staged.contains(path))
.cloned()
.collect::<Vec<_>>();
let unstaged_paths = selected_paths
.iter()
.filter(|path| unstaged.contains(path))
.cloned()
.collect::<Vec<_>>();
staged_diff = read_diff(&root, &command, true, &staged_paths)?;
unstaged_diff = read_diff(&root, &command, false, &unstaged_paths)?;
append_diff_section(&mut content, "staged diff", &staged_diff.text);
append_diff_section(&mut content, "unstaged diff", &unstaged_diff.text);
truncated |= staged_diff.truncated || unstaged_diff.truncated;
}
let commit_snapshot_fingerprint = build_inspect_commit_snapshot(
&root,
&command,
&head,
branch.as_deref(),
&status_output,
&staged,
&unstaged,
&untracked,
)?;
let status_after = run_git_bounded(
&root,
&command,
&[
"status",
"--porcelain=v1",
"-z",
"--untracked-files=all",
"--ignore-submodules=all",
"--no-renames",
],
)?;
let staged_diff_after = if include_diff {
let paths = selected_paths
.iter()
.filter(|path| staged.contains(path))
.cloned()
.collect::<Vec<_>>();
read_diff(&root, &command, true, &paths)?
} else {
BoundedGitOutput {
raw: Vec::new(),
text: String::new(),
truncated: false,
}
};
let unstaged_diff_after = if include_diff {
let paths = selected_paths
.iter()
.filter(|path| unstaged.contains(path))
.cloned()
.collect::<Vec<_>>();
read_diff(&root, &command, false, &paths)?
} else {
BoundedGitOutput {
raw: Vec::new(),
text: String::new(),
truncated: false,
}
};
if status_after.raw != status_output.raw
|| status_after.truncated != status_truncated
|| read_git_head(&root, &command) != head
|| read_git_branch(&root, &command) != branch
|| staged_diff_after.raw != staged_diff.raw
|| staged_diff_after.truncated != staged_diff.truncated
|| unstaged_diff_after.raw != unstaged_diff.raw
|| unstaged_diff_after.truncated != unstaged_diff.truncated
{
return Err("Git 工作树在审阅过程中发生变化,请重试".to_string());
}
if content.chars().count() > max_chars {
content = content.chars().take(max_chars).collect();
content.push_str("\n[git inspect output truncated]\n");
truncated = true;
}
Ok(LocalGitWorktreeInspect {
head,
branch,
commit_snapshot_fingerprint,
staged,
unstaged,
untracked,
file_count,
truncated,
content,
})
}
fn build_inspect_commit_snapshot(
root: &Path,
command: &GitInspectCommandContext,
head: &str,
branch: Option<&str>,
status: &BoundedGitOutput,
staged: &[String],
unstaged: &[String],
untracked: &[String],
) -> Result<Option<String>, String> {
let Some(branch) = branch else {
return Ok(None);
};
if head == "(unborn)"
|| status.truncated
|| std::str::from_utf8(&status.raw).is_err()
|| standard_git_dir(root, command).is_err()
{
return Ok(None);
}
let branch_ref = match run_git(root, command, &["symbolic-ref", "--quiet", "HEAD"]) {
Ok(branch_ref) => branch_ref.trim().to_string(),
Err(_) => return Ok(None),
};
if branch_ref.strip_prefix("refs/heads/") != Some(branch) {
return Ok(None);
}
let capture_paths = BTreeSet::new();
build_commit_snapshot(
root,
command,
head,
&branch_ref,
status,
staged,
unstaged,
untracked,
&capture_paths,
None,
Instant::now() + GIT_COMMIT_TIMEOUT,
)
.map(|snapshot| snapshot.map(|snapshot| snapshot.fingerprint))
}
fn build_commit_snapshot(
root: &Path,
command: &GitInspectCommandContext,
head: &str,
branch_ref: &str,
status: &BoundedGitOutput,
staged: &[String],
unstaged: &[String],
untracked: &[String],
capture_paths: &BTreeSet<String>,
capture_dir: Option<&Path>,
deadline: Instant,
) -> Result<Option<CommitSnapshot>, String> {
let staged = normalized_status_paths(staged);
let unstaged = normalized_status_paths(unstaged);
let untracked = normalized_status_paths(untracked);
let changed_paths = staged
.iter()
.chain(&unstaged)
.chain(&untracked)
.cloned()
.collect::<BTreeSet<_>>();
if status.truncated
|| std::str::from_utf8(&status.raw).is_err()
|| changed_paths.len() > GIT_COMMIT_SNAPSHOT_MAX_PATHS
|| !capture_paths.is_subset(&changed_paths)
{
return Ok(None);
}
if !capture_paths.is_empty() && capture_dir.is_none() {
return Err("Git 提交快照捕获目录缺失".to_string());
}
let mut fingerprint = Sha256::new();
update_fingerprint_field(
&mut fingerprint,
b"domain",
b"genarrative.local-git-commit-snapshot.v1",
);
update_fingerprint_field(&mut fingerprint, b"head", head.as_bytes());
update_fingerprint_field(&mut fingerprint, b"branch", branch_ref.as_bytes());
update_fingerprint_status(&mut fingerprint, b"staged", &staged);
update_fingerprint_status(&mut fingerprint, b"unstaged", &unstaged);
update_fingerprint_status(&mut fingerprint, b"untracked", &untracked);
let mut total_bytes = 0_u64;
let mut captured_paths = BTreeMap::new();
for (index, relative_path) in changed_paths.iter().enumerate() {
if Instant::now() >= deadline {
return Ok(None);
}
if !git_worktree_path_is_safe(root, relative_path) {
return Err("Git 工作树在生成提交快照时发生变化,请重试".to_string());
}
update_fingerprint_field(&mut fingerprint, b"path", relative_path.as_bytes());
let path = root.join(relative_path);
match fs::symlink_metadata(&path) {
Ok(metadata) => {
if !metadata.is_file() || metadata_has_multiple_hard_links(&metadata) {
return Err("Git 工作树在生成提交快照时出现不安全路径,请重试".to_string());
}
if total_bytes.saturating_add(metadata.len()) > GIT_COMMIT_SNAPSHOT_MAX_BYTES {
return Ok(None);
}
let capture_path = if capture_paths.contains(relative_path) {
Some(
capture_dir
.expect("capture directory checked above")
.join(format!("{index:05}.blob")),
)
} else {
None
};
let Some((digest, mode, bytes_read)) = hash_stable_worktree_file(
root,
relative_path,
capture_path.as_deref(),
GIT_COMMIT_SNAPSHOT_MAX_BYTES.saturating_sub(total_bytes),
deadline,
)?
else {
return Ok(None);
};
total_bytes = total_bytes.saturating_add(bytes_read);
update_fingerprint_field(&mut fingerprint, b"state", b"file");
update_fingerprint_field(&mut fingerprint, b"mode", &mode.to_be_bytes());
update_fingerprint_field(&mut fingerprint, b"sha256", &digest);
if let Some(snapshot_path) = capture_path {
captured_paths.insert(
relative_path.clone(),
CapturedCommitPath::File {
snapshot_path,
mode,
},
);
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
update_fingerprint_field(&mut fingerprint, b"state", b"deleted");
if capture_paths.contains(relative_path) {
captured_paths.insert(relative_path.clone(), CapturedCommitPath::Deleted);
}
}
Err(error) => {
return Err(format!("读取 Git 工作树提交快照失败:{error}"));
}
}
}
let status_after = read_git_status(root, command)?;
let head_after = run_git(root, command, &["rev-parse", "--verify", "HEAD"])?;
let branch_after = run_git(root, command, &["symbolic-ref", "--quiet", "HEAD"])?;
if status_after.truncated
|| status_after.raw != status.raw
|| head_after.trim() != head
|| branch_after.trim() != branch_ref
{
return Err("Git 工作树在生成提交快照时发生变化,请重试".to_string());
}
Ok(Some(CommitSnapshot {
fingerprint: hex_digest(fingerprint.finalize().as_slice()),
captured_paths,
}))
}
fn update_fingerprint_field(hasher: &mut Sha256, tag: &[u8], value: &[u8]) {
hasher.update((tag.len() as u64).to_be_bytes());
hasher.update(tag);
hasher.update((value.len() as u64).to_be_bytes());
hasher.update(value);
}
fn normalized_status_paths(paths: &[String]) -> Vec<String> {
paths
.iter()
.cloned()
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
fn update_fingerprint_status(hasher: &mut Sha256, category: &[u8], paths: &[String]) {
update_fingerprint_field(hasher, b"status-category", category);
update_fingerprint_field(
hasher,
b"status-path-count",
&(paths.len() as u64).to_be_bytes(),
);
for path in paths {
update_fingerprint_field(hasher, b"status-path", path.as_bytes());
}
}
fn hash_stable_worktree_file(
root: &Path,
relative_path: &str,
capture_path: Option<&Path>,
max_bytes: u64,
deadline: Instant,
) -> Result<Option<([u8; 32], u32, u64)>, String> {
let path = root.join(relative_path);
let Some((first_digest, first_metadata, bytes_read)) =
hash_worktree_file_once(&path, capture_path, max_bytes, deadline)?
else {
return Ok(None);
};
if Instant::now() >= deadline {
return Ok(None);
}
let Some((second_digest, second_metadata, second_bytes_read)) =
hash_worktree_file_once(&path, None, max_bytes, deadline)?
else {
return Ok(None);
};
if first_digest != second_digest
|| bytes_read != second_bytes_read
|| !same_worktree_file_metadata(&first_metadata, &second_metadata)
|| !git_worktree_path_is_safe(root, relative_path)
{
return Err("Git 工作树文件在生成提交快照时发生变化,请重试".to_string());
}
Ok(Some((
first_digest,
git_blob_mode(&second_metadata),
bytes_read,
)))
}
fn hash_worktree_file_once(
path: &Path,
capture_path: Option<&Path>,
max_bytes: u64,
deadline: Instant,
) -> Result<Option<([u8; 32], fs::Metadata, u64)>, String> {
let path_metadata =
fs::symlink_metadata(path).map_err(|error| format!("读取 Git 工作树文件失败:{error}"))?;
if !path_metadata.is_file() || metadata_has_multiple_hard_links(&path_metadata) {
return Err("Git 工作树提交路径必须是安全普通文件".to_string());
}
if path_metadata.len() > max_bytes {
return Ok(None);
}
let mut input =
File::open(path).map_err(|error| format!("打开 Git 工作树文件失败:{error}"))?;
let opened_metadata = input
.metadata()
.map_err(|error| format!("读取 Git 工作树文件元数据失败:{error}"))?;
if !same_worktree_file_metadata(&path_metadata, &opened_metadata)
|| metadata_has_multiple_hard_links(&opened_metadata)
{
return Err("Git 工作树提交路径在打开时发生变化,请重试".to_string());
}
let mut capture = match capture_path {
Some(capture_path) => Some(
OpenOptions::new()
.write(true)
.create_new(true)
.open(capture_path)
.map_err(|error| format!("创建 Git 提交文件快照失败:{error}"))?,
),
None => None,
};
let mut digest = Sha256::new();
let mut bytes_read = 0_u64;
let mut buffer = [0_u8; 64 * 1024];
loop {
if Instant::now() >= deadline {
return Ok(None);
}
let read = input
.read(&mut buffer)
.map_err(|error| format!("读取 Git 工作树文件失败:{error}"))?;
if read == 0 {
break;
}
bytes_read = bytes_read.saturating_add(read as u64);
if bytes_read > max_bytes {
return Ok(None);
}
digest.update(&buffer[..read]);
if let Some(capture) = capture.as_mut() {
capture
.write_all(&buffer[..read])
.map_err(|error| format!("写入 Git 提交文件快照失败:{error}"))?;
}
}
if let Some(capture) = capture.as_mut() {
capture
.flush()
.map_err(|error| format!("刷新 Git 提交文件快照失败:{error}"))?;
}
let final_opened_metadata = input
.metadata()
.map_err(|error| format!("复核 Git 工作树文件元数据失败:{error}"))?;
let final_path_metadata =
fs::symlink_metadata(path).map_err(|error| format!("复核 Git 工作树文件失败:{error}"))?;
if bytes_read != opened_metadata.len()
|| !same_worktree_file_metadata(&opened_metadata, &final_opened_metadata)
|| !same_worktree_file_metadata(&opened_metadata, &final_path_metadata)
|| metadata_has_multiple_hard_links(&final_path_metadata)
{
return Err("Git 工作树文件在读取时发生变化,请重试".to_string());
}
let digest: [u8; 32] = digest.finalize().into();
Ok(Some((digest, final_path_metadata, bytes_read)))
}
fn same_worktree_file_metadata(left: &fs::Metadata, right: &fs::Metadata) -> bool {
left.is_file()
&& right.is_file()
&& left.len() == right.len()
&& left.modified().ok() == right.modified().ok()
&& left.permissions().readonly() == right.permissions().readonly()
&& same_worktree_file_identity(left, right)
}
#[cfg(unix)]
fn same_worktree_file_identity(left: &fs::Metadata, right: &fs::Metadata) -> bool {
use std::os::unix::fs::MetadataExt;
left.dev() == right.dev()
&& left.ino() == right.ino()
&& left.mode() == right.mode()
&& left.nlink() == right.nlink()
}
#[cfg(not(unix))]
fn same_worktree_file_identity(_left: &fs::Metadata, _right: &fs::Metadata) -> bool {
true
}
#[cfg(unix)]
fn git_blob_mode(metadata: &fs::Metadata) -> u32 {
use std::os::unix::fs::PermissionsExt;
if metadata.permissions().mode() & 0o111 == 0 {
0o100644
} else {
0o100755
}
}
#[cfg(not(unix))]
fn git_blob_mode(_metadata: &fs::Metadata) -> u32 {
0o100644
}
fn hex_digest(bytes: &[u8]) -> String {
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(output, "{byte:02x}");
}
output
}
fn read_git_status(
root: &Path,
command: &GitInspectCommandContext,
) -> Result<BoundedGitOutput, String> {
run_git_bounded(
root,
command,
&[
"status",
"--porcelain=v1",
"-z",
"--untracked-files=all",
"--ignore-submodules=all",
"--no-renames",
],
)
}
pub(crate) fn commit_local_git_worktree_at(
root: &Path,
message: &str,
paths: &[String],
expected_head: &str,
expected_snapshot_fingerprint: &str,
) -> Result<LocalGitCommitResult, LocalGitCommitError> {
commit_local_git_worktree_at_with_hook(
root,
message,
paths,
expected_head,
expected_snapshot_fingerprint,
&mut |_| Ok(()),
)
}
fn commit_local_git_worktree_at_with_hook(
root: &Path,
message: &str,
paths: &[String],
expected_head: &str,
expected_snapshot_fingerprint: &str,
hook: &mut dyn FnMut(LocalGitCommitHookPoint) -> Result<(), String>,
) -> Result<LocalGitCommitResult, LocalGitCommitError> {
validate_commit_message(message)?;
let normalized_paths = validate_commit_paths(paths)?;
if expected_head.is_empty() || expected_snapshot_fingerprint.is_empty() {
return Err(LocalGitCommitError::ordinary(
"Git 提交缺少 expected HEAD 或提交快照指纹",
));
}
let root = root
.canonicalize()
.map_err(|error| LocalGitCommitError::ordinary(format!("读取项目目录失败:{error}")))?;
let command =
build_git_inspect_command_context(&root).map_err(LocalGitCommitError::ordinary)?;
ensure_git_top_level(&root, &command).map_err(LocalGitCommitError::ordinary)?;
let git_dir = standard_git_dir(&root, &command).map_err(LocalGitCommitError::ordinary)?;
ensure_safe_git_control_file(&git_dir.join("HEAD"), false)?;
ensure_safe_git_control_file(&git_dir.join("config"), false)?;
ensure_safe_git_control_file(&git_dir.join("index"), true)?;
ensure_local_git_config_has_no_includes(&root, &command)?;
let head_branch_ref = read_attached_branch_ref_from_head(&git_dir)?;
ensure_safe_git_storage_layout(&git_dir, &head_branch_ref)?;
let selected_paths = normalized_paths.iter().cloned().collect::<BTreeSet<_>>();
let preflight = read_commit_worktree_state(
&root,
&command,
&selected_paths,
None,
Instant::now() + GIT_COMMIT_TIMEOUT,
)?;
validate_expected_commit_state(
&preflight,
&selected_paths,
expected_head,
expected_snapshot_fingerprint,
)?;
if preflight.branch_ref != head_branch_ref {
return Err(LocalGitCommitError::ordinary(
"Git HEAD 附着分支在安全检查期间发生变化,请重试",
));
}
let transaction = tempfile::Builder::new()
.prefix("genarrative-git-commit-")
.tempdir_in(command.sandbox.path())
.map_err(|error| {
LocalGitCommitError::ordinary(format!("创建 Git 提交事务目录失败:{error}"))
})?;
let capture_dir = transaction.path().join("worktree");
fs::create_dir(&capture_dir).map_err(|error| {
LocalGitCommitError::ordinary(format!("创建 Git 提交快照目录失败:{error}"))
})?;
let index_lock_path = git_dir.join("index.lock");
let index_path = git_dir.join("index");
let mut index_lock = Some(
OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.open(&index_lock_path)
.map_err(|error| {
LocalGitCommitError::ordinary(format!(
"无法取得真实 Git index.lock,可能有其它 Git 写操作正在执行:{error}"
))
})?,
);
let prepared = (|| -> Result<(PathBuf, String), LocalGitCommitError> {
let locked_state = read_commit_worktree_state(
&root,
&command,
&selected_paths,
Some(&capture_dir),
Instant::now() + GIT_COMMIT_TIMEOUT,
)?;
validate_expected_commit_state(
&locked_state,
&selected_paths,
expected_head,
expected_snapshot_fingerprint,
)?;
let identity = read_local_git_identity(&root, &command, &git_dir)?;
let temporary_index = transaction.path().join("index");
run_git_commit_owned(
&root,
&command,
&["read-tree".to_string(), expected_head.to_string()],
Some(&temporary_index),
None,
GitCommandInput::None,
"初始化临时 Git index",
)?;
for path in &normalized_paths {
let captured = locked_state.captured_paths.get(path).ok_or_else(|| {
LocalGitCommitError::ordinary("Git 提交路径快照不完整,请重新审阅")
})?;
match captured {
CapturedCommitPath::Deleted => {
run_git_commit_owned(
&root,
&command,
&[
"update-index".to_string(),
"--force-remove".to_string(),
"--".to_string(),
path.clone(),
],
Some(&temporary_index),
None,
GitCommandInput::None,
"从临时 Git index 删除路径",
)?;
}
CapturedCommitPath::File {
snapshot_path,
mode,
} => {
let mode =
resolved_commit_blob_mode(&root, &command, expected_head, path, *mode)?;
let object = run_git_commit_owned(
&root,
&command,
&[
"hash-object".to_string(),
"-w".to_string(),
"--no-filters".to_string(),
"--stdin".to_string(),
],
Some(&temporary_index),
None,
GitCommandInput::File(snapshot_path),
"写入 Git blob",
)?;
let object = parse_git_object_id(&object.text, "Git blob")?;
run_git_commit_owned(
&root,
&command,
&[
"update-index".to_string(),
"--add".to_string(),
"--cacheinfo".to_string(),
format!("{mode:o}"),
object,
path.clone(),
],
Some(&temporary_index),
None,
GitCommandInput::None,
"更新临时 Git index",
)?;
}
}
}
let tree = run_git_commit_owned(
&root,
&command,
&["write-tree".to_string()],
Some(&temporary_index),
None,
GitCommandInput::None,
"写入 Git tree",
)?;
let tree = parse_git_object_id(&tree.text, "Git tree")?;
let commit = run_git_commit_owned(
&root,
&command,
&[
"commit-tree".to_string(),
tree,
"-p".to_string(),
expected_head.to_string(),
"-F".to_string(),
"-".to_string(),
],
Some(&temporary_index),
Some(&identity),
GitCommandInput::Bytes(message.as_bytes()),
"创建 Git commit",
)?;
let commit = parse_git_object_id(&commit.text, "Git commit")?;
let final_state = read_commit_worktree_state(
&root,
&command,
&selected_paths,
None,
Instant::now() + GIT_COMMIT_TIMEOUT,
)?;
validate_expected_commit_state(
&final_state,
&selected_paths,
expected_head,
expected_snapshot_fingerprint,
)?;
let mut temporary_index_file = File::open(&temporary_index).map_err(|error| {
LocalGitCommitError::ordinary(format!("打开临时 Git index 失败:{error}"))
})?;
let lock_file = index_lock
.as_mut()
.expect("real index lock remains owned before ref update");
lock_file.set_len(0).map_err(|error| {
LocalGitCommitError::ordinary(format!("准备真实 Git index.lock 失败:{error}"))
})?;
lock_file.seek(SeekFrom::Start(0)).map_err(|error| {
LocalGitCommitError::ordinary(format!("定位真实 Git index.lock 失败:{error}"))
})?;
std::io::copy(&mut temporary_index_file, lock_file).map_err(|error| {
LocalGitCommitError::ordinary(format!("写入真实 Git index.lock 失败:{error}"))
})?;
lock_file.flush().map_err(|error| {
LocalGitCommitError::ordinary(format!("刷新真实 Git index.lock 失败:{error}"))
})?;
lock_file.sync_all().map_err(|error| {
LocalGitCommitError::ordinary(format!("持久化真实 Git index.lock 失败:{error}"))
})?;
Ok((temporary_index, commit))
})();
let (_temporary_index, commit_head) = match prepared {
Ok(prepared) => prepared,
Err(error) => {
return Err(cleanup_pre_ref_index_lock(
&index_lock_path,
&mut index_lock,
error,
));
}
};
if let Err(error) = hook(LocalGitCommitHookPoint::BeforeUpdateRef) {
return Err(cleanup_pre_ref_index_lock(
&index_lock_path,
&mut index_lock,
LocalGitCommitError::ordinary(error),
));
}
ensure_safe_git_storage_layout(&git_dir, &preflight.branch_ref)
.map_err(|error| cleanup_pre_ref_index_lock(&index_lock_path, &mut index_lock, error))?;
ensure_safe_git_control_file(&index_path, true)
.map_err(|error| cleanup_pre_ref_index_lock(&index_lock_path, &mut index_lock, error))?;
let branch_ref_before_update = read_attached_branch_ref_from_head(&git_dir)
.map_err(|error| cleanup_pre_ref_index_lock(&index_lock_path, &mut index_lock, error))?;
if branch_ref_before_update != preflight.branch_ref {
return Err(cleanup_pre_ref_index_lock(
&index_lock_path,
&mut index_lock,
LocalGitCommitError::ordinary("Git HEAD 附着分支在 update-ref 前发生变化,请重新审阅"),
));
}
let update_result = update_git_head_transaction(&root, &command, &commit_head, expected_head);
if let Err(error) = update_result {
if matches!(
update_ref_failure_is_explicit_expected_old_competition(
&root,
&command,
&git_dir,
&preflight.branch_ref,
expected_head,
&commit_head,
error.message(),
),
Ok(true)
) {
return Err(cleanup_pre_ref_index_lock(
&index_lock_path,
&mut index_lock,
error,
));
}
return Err(LocalGitCommitError::reconciliation(format!(
"Git ref/reflog 事务结果无法安全确认,或遗留 ref/reflog lock{}",
error.message()
)));
}
ensure_no_git_ref_transaction_locks(&git_dir, &preflight.branch_ref).map_err(|error| {
LocalGitCommitError::reconciliation(format!(
"Git 分支已前移,但检测到遗留 ref/reflog lock{error}"
))
})?;
if let Err(error) = hook(LocalGitCommitHookPoint::AfterUpdateRef) {
return Err(LocalGitCommitError::reconciliation(format!(
"Git 分支已前移,但后续步骤失败:{error}"
)));
}
ensure_safe_git_control_file(&index_path, true).map_err(|error| {
LocalGitCommitError::reconciliation(format!(
"Git 分支已前移,但真实 index 在安装前变得不安全:{}",
error.message()
))
})?;
drop(index_lock.take());
install_git_index_lock(&index_lock_path, &index_path).map_err(|error| {
LocalGitCommitError::reconciliation(format!("Git 分支已前移,但安装新 index 失败:{error}"))
})?;
verify_git_head_and_reflogs(&root, &command, &preflight.branch_ref, &commit_head).map_err(
|error| {
LocalGitCommitError::reconciliation(format!(
"Git 分支已前移,但 HEAD / branch ref 或双 reflog 复核失败:{error}"
))
},
)?;
let remaining_changed_count =
read_remaining_changed_count(&root, &command).map_err(|error| {
LocalGitCommitError::reconciliation(format!(
"Git 分支已前移,但无法复核剩余变更:{error}"
))
})?;
Ok(LocalGitCommitResult {
parent_head: expected_head.to_string(),
commit_head,
branch: preflight.branch,
paths: normalized_paths,
message_sha256: hex_digest(Sha256::digest(message.as_bytes()).as_slice()),
remaining_changed_count,
})
}
fn validate_commit_message(message: &str) -> Result<(), LocalGitCommitError> {
if message.trim().is_empty() {
return Err(LocalGitCommitError::ordinary("Git 提交信息不能为空"));
}
if message.len() > GIT_COMMIT_MESSAGE_MAX_BYTES {
return Err(LocalGitCommitError::ordinary(format!(
"Git 提交信息不能超过 {GIT_COMMIT_MESSAGE_MAX_BYTES} 字节"
)));
}
if message.as_bytes().contains(&0) {
return Err(LocalGitCommitError::ordinary("Git 提交信息不能包含 NUL"));
}
Ok(())
}
fn validate_commit_paths(paths: &[String]) -> Result<Vec<String>, LocalGitCommitError> {
if paths.is_empty() || paths.len() > GIT_COMMIT_PATH_MAX {
return Err(LocalGitCommitError::ordinary(format!(
"Git 提交路径数量必须在 1 到 {GIT_COMMIT_PATH_MAX} 之间"
)));
}
let mut unique = BTreeSet::new();
let mut normalized_paths = Vec::with_capacity(paths.len());
for path in paths {
let normalized = normalize_relative_path(path)
.map_err(|_| LocalGitCommitError::ordinary("Git 提交路径必须是规范化的项目相对路径"))?;
if normalized != *path
|| should_skip_project_snapshot_path(&normalized)
|| !unique.insert(normalized.clone())
{
return Err(LocalGitCommitError::ordinary(
"Git 提交路径必须已规范化、互不重复且属于安全项目路径",
));
}
normalized_paths.push(normalized);
}
Ok(normalized_paths)
}
fn read_commit_worktree_state(
root: &Path,
command: &GitInspectCommandContext,
capture_paths: &BTreeSet<String>,
capture_dir: Option<&Path>,
deadline: Instant,
) -> Result<CommitWorktreeState, LocalGitCommitError> {
let git_dir = standard_git_dir(root, command).map_err(LocalGitCommitError::ordinary)?;
ensure_safe_git_control_file(&git_dir.join("HEAD"), false)?;
ensure_safe_git_control_file(&git_dir.join("index"), true)?;
let head_branch_ref = read_attached_branch_ref_from_head(&git_dir)?;
ensure_safe_git_storage_layout(&git_dir, &head_branch_ref)?;
let head = run_git(root, command, &["rev-parse", "--verify", "HEAD"])
.map_err(|_| LocalGitCommitError::ordinary("Git 仓库必须已有可提交的 HEAD"))?;
let head = head.trim().to_string();
let branch_ref = run_git(root, command, &["symbolic-ref", "--quiet", "HEAD"])
.map_err(|_| LocalGitCommitError::ordinary("Git 提交只支持附着的本地分支"))?;
let branch_ref = branch_ref.trim().to_string();
if branch_ref != head_branch_ref {
return Err(LocalGitCommitError::ordinary(
"Git HEAD 附着分支在安全检查期间发生变化,请重试",
));
}
let Some(branch) = branch_ref.strip_prefix("refs/heads/") else {
return Err(LocalGitCommitError::ordinary(
"Git 提交只支持 refs/heads 下的本地分支",
));
};
if branch.is_empty() {
return Err(LocalGitCommitError::ordinary("Git 本地分支名称无效"));
}
let branch = branch.to_string();
ensure_empty_staged(root, command)?;
let status = read_git_status(root, command).map_err(LocalGitCommitError::ordinary)?;
if status.truncated || std::str::from_utf8(&status.raw).is_err() {
return Err(LocalGitCommitError::ordinary(
"Git 状态过大或包含非 UTF-8 路径,不能受控提交",
));
}
let (mut staged, mut unstaged, mut untracked) = parse_status(root, &status.text);
staged.sort();
staged.dedup();
unstaged.sort();
unstaged.dedup();
untracked.sort();
untracked.dedup();
if !staged.is_empty() || status_has_staged_entries(&status.raw) {
return Err(LocalGitCommitError::ordinary(
"真实 Git index 已有 staged 内容,请先由用户处理",
));
}
let no_capture_paths = BTreeSet::new();
let snapshot_capture_paths = if capture_dir.is_some() {
capture_paths
} else {
&no_capture_paths
};
let snapshot = build_commit_snapshot(
root,
command,
&head,
&branch_ref,
&status,
&staged,
&unstaged,
&untracked,
snapshot_capture_paths,
capture_dir,
deadline,
)
.map_err(LocalGitCommitError::ordinary)?
.ok_or_else(|| LocalGitCommitError::ordinary("Git 工作树过大或读取超时,未签发可提交快照"))?;
Ok(CommitWorktreeState {
head,
branch,
branch_ref,
staged,
unstaged,
untracked,
fingerprint: snapshot.fingerprint,
captured_paths: snapshot.captured_paths,
})
}
fn validate_expected_commit_state(
state: &CommitWorktreeState,
selected_paths: &BTreeSet<String>,
expected_head: &str,
expected_snapshot_fingerprint: &str,
) -> Result<(), LocalGitCommitError> {
if state.head != expected_head {
return Err(LocalGitCommitError::ordinary(
"Git HEAD 已变化,请重新审阅后提交",
));
}
if state.fingerprint != expected_snapshot_fingerprint {
return Err(LocalGitCommitError::ordinary(
"Git 提交快照已变化,请重新审阅后提交",
));
}
if !state.staged.is_empty() {
return Err(LocalGitCommitError::ordinary(
"真实 Git index 已有 staged 内容,请先由用户处理",
));
}
let eligible = state
.unstaged
.iter()
.chain(&state.untracked)
.cloned()
.collect::<BTreeSet<_>>();
if !selected_paths.is_subset(&eligible) {
return Err(LocalGitCommitError::ordinary(
"Git 提交路径不属于已审阅的安全 unstaged、untracked 或 deleted 变更",
));
}
Ok(())
}
fn ensure_empty_staged(
root: &Path,
command: &GitInspectCommandContext,
) -> Result<(), LocalGitCommitError> {
let output = run_git_commit_owned(
root,
command,
&[
"diff".to_string(),
"--cached".to_string(),
"--ita-visible-in-index".to_string(),
"--name-only".to_string(),
"-z".to_string(),
"--no-renames".to_string(),
"--".to_string(),
],
None,
None,
GitCommandInput::None,
"检查真实 Git index",
)?;
if output.truncated || !output.raw.is_empty() {
return Err(LocalGitCommitError::ordinary(
"真实 Git index 已有 staged 内容,请先由用户处理",
));
}
Ok(())
}
fn status_has_staged_entries(status: &[u8]) -> bool {
status
.split(|byte| *byte == 0)
.filter(|record| !record.is_empty())
.any(|record| {
record.len() < 3 || record[2] != b' ' || (record[0] != b' ' && record[0] != b'?')
})
}
fn read_local_git_identity(
root: &Path,
command: &GitInspectCommandContext,
git_dir: &Path,
) -> Result<LocalGitIdentity, LocalGitCommitError> {
ensure_safe_git_control_file(&git_dir.join("config"), false)?;
ensure_local_git_config_has_no_includes(root, command)?;
let read_value = |key: &str| {
run_git_commit_owned(
root,
command,
&[
"config".to_string(),
"--local".to_string(),
"--no-includes".to_string(),
"--get".to_string(),
key.to_string(),
],
None,
None,
GitCommandInput::None,
"读取仓库本地 Git 身份",
)
.map(|output| output.text.trim_end_matches(['\r', '\n']).to_string())
};
let name = read_value("user.name")
.map_err(|_| LocalGitCommitError::ordinary("缺少仓库本地 Git user.name,不能受控提交"))?;
let email = read_value("user.email")
.map_err(|_| LocalGitCommitError::ordinary("缺少仓库本地 Git user.email,不能受控提交"))?;
if !valid_git_identity_value(&name) || !valid_git_identity_value(&email) {
return Err(LocalGitCommitError::ordinary(
"仓库本地 Git user.name 或 user.email 无效",
));
}
Ok(LocalGitIdentity { name, email })
}
fn ensure_local_git_config_has_no_includes(
root: &Path,
command: &GitInspectCommandContext,
) -> Result<(), LocalGitCommitError> {
let output = run_git_commit_owned(
root,
command,
&[
"config".to_string(),
"--local".to_string(),
"--no-includes".to_string(),
"--name-only".to_string(),
"--list".to_string(),
"-z".to_string(),
],
None,
None,
GitCommandInput::None,
"检查仓库本地 Git 配置",
)?;
if output.truncated || std::str::from_utf8(&output.raw).is_err() {
return Err(LocalGitCommitError::ordinary(
"仓库本地 Git 配置过大或包含非 UTF-8 键名",
));
}
if output
.text
.split('\0')
.map(str::to_ascii_lowercase)
.any(|key| key == "include.path" || key.starts_with("includeif."))
{
return Err(LocalGitCommitError::ordinary(
"受控提交不允许仓库本地 Git 配置引用外部 include",
));
}
Ok(())
}
fn valid_git_identity_value(value: &str) -> bool {
!value.trim().is_empty()
&& value.trim() == value
&& !value.contains(['\0', '\r', '\n', '<', '>'])
}
fn parse_git_object_id(value: &str, kind: &str) -> Result<String, LocalGitCommitError> {
let value = value.trim();
if !matches!(value.len(), 40 | 64) || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(LocalGitCommitError::ordinary(format!(
"{kind} 返回了无效对象 ID"
)));
}
Ok(value.to_ascii_lowercase())
}
#[cfg(unix)]
fn resolved_commit_blob_mode(
_root: &Path,
_command: &GitInspectCommandContext,
_expected_head: &str,
_path: &str,
captured_mode: u32,
) -> Result<u32, LocalGitCommitError> {
Ok(captured_mode)
}
#[cfg(not(unix))]
fn resolved_commit_blob_mode(
root: &Path,
command: &GitInspectCommandContext,
expected_head: &str,
path: &str,
captured_mode: u32,
) -> Result<u32, LocalGitCommitError> {
let output = run_git_commit_owned(
root,
command,
&[
"ls-tree".to_string(),
"-z".to_string(),
expected_head.to_string(),
"--".to_string(),
path.to_string(),
],
None,
None,
GitCommandInput::None,
"读取 Git tree 文件模式",
)?;
if output.raw.is_empty() {
return Ok(captured_mode);
}
let mode = output
.raw
.split(|byte| *byte == b' ')
.next()
.and_then(|mode| std::str::from_utf8(mode).ok())
.and_then(|mode| u32::from_str_radix(mode, 8).ok())
.filter(|mode| matches!(*mode, 0o100644 | 0o100755))
.ok_or_else(|| LocalGitCommitError::ordinary("Git tree 返回了无效文件模式"))?;
Ok(mode)
}
fn update_git_head_transaction(
root: &Path,
command: &GitInspectCommandContext,
commit_head: &str,
expected_head: &str,
) -> Result<(), LocalGitCommitError> {
let input = format!("start\nupdate HEAD {commit_head} {expected_head}\nprepare\ncommit\n");
run_git_commit_owned(
root,
command,
&[
"update-ref".to_string(),
"--create-reflog".to_string(),
"-m".to_string(),
GIT_COMMIT_REFLOG_MESSAGE.to_string(),
"--stdin".to_string(),
],
None,
None,
GitCommandInput::Bytes(input.as_bytes()),
"原子更新 Git ref/reflog 事务",
)
.map(|_| ())
}
fn update_ref_failure_is_explicit_expected_old_competition(
root: &Path,
command: &GitInspectCommandContext,
git_dir: &Path,
branch_ref: &str,
expected_head: &str,
commit_head: &str,
error_message: &str,
) -> Result<bool, String> {
ensure_no_git_ref_transaction_locks(git_dir, branch_ref)?;
let attached_branch =
read_attached_branch_ref_from_head(git_dir).map_err(|error| error.message().to_string())?;
if attached_branch != branch_ref {
return Ok(false);
}
let current_branch = run_git(root, command, &["rev-parse", "--verify", branch_ref])?;
let current_head = run_git(root, command, &["rev-parse", "--verify", "HEAD"])?;
let current_branch = current_branch.trim().to_ascii_lowercase();
let current_head = current_head.trim().to_ascii_lowercase();
let expected_head = expected_head.to_ascii_lowercase();
let commit_head = commit_head.to_ascii_lowercase();
if current_branch != current_head
|| current_branch == expected_head
|| current_branch == commit_head
{
return Ok(false);
}
let detail = error_message.to_ascii_lowercase();
Ok(detail.contains("cannot lock ref")
&& detail.contains(&format!(
"is at {current_branch} but expected {expected_head}"
)))
}
fn verify_git_head_and_reflogs(
root: &Path,
command: &GitInspectCommandContext,
branch_ref: &str,
commit_head: &str,
) -> Result<(), String> {
let attached_branch = run_git(root, command, &["symbolic-ref", "--quiet", "HEAD"])?;
let head = run_git(root, command, &["rev-parse", "--verify", "HEAD"])?;
let branch = run_git(root, command, &["rev-parse", "--verify", branch_ref])?;
if attached_branch.trim() != branch_ref
|| head.trim() != commit_head
|| branch.trim() != commit_head
{
return Err("最终 HEAD、附着分支或 branch ref 不一致".to_string());
}
for reference in ["HEAD", branch_ref] {
let reflog = run_git(
root,
command,
&["reflog", "show", "-1", "--format=%H%x09%gs", reference],
)?;
let reflog = reflog.trim_end_matches(['\r', '\n']);
let Some((reflog_head, reflog_message)) = reflog.split_once('\t') else {
return Err(format!("{reference} reflog 缺少结构化尾项"));
};
if reflog_head != commit_head || reflog_message != GIT_COMMIT_REFLOG_MESSAGE {
return Err(format!("{reference} reflog 未同步到受控提交"));
}
}
Ok(())
}
fn ensure_no_git_ref_transaction_locks(git_dir: &Path, branch_ref: &str) -> Result<(), String> {
for lock_path in git_ref_transaction_lock_paths(git_dir, branch_ref)
.map_err(|error| error.message().to_string())?
{
match fs::symlink_metadata(&lock_path) {
Ok(_) => {
let relative = lock_path.strip_prefix(git_dir).unwrap_or(&lock_path);
return Err(format!("存在 Git 事务锁 {}", relative.display()));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(format!("检查 Git 事务锁失败:{error}")),
}
}
Ok(())
}
fn git_ref_transaction_lock_paths(
git_dir: &Path,
branch_ref: &str,
) -> Result<Vec<PathBuf>, LocalGitCommitError> {
validate_local_branch_ref(branch_ref)?;
let branch_path = git_dir.join(branch_ref);
let branch_reflog = git_dir.join("logs").join(branch_ref);
Ok(vec![
append_lock_suffix(&git_dir.join("HEAD")),
append_lock_suffix(&branch_path),
git_dir.join("packed-refs.lock"),
append_lock_suffix(&git_dir.join("logs/HEAD")),
append_lock_suffix(&branch_reflog),
])
}
fn append_lock_suffix(path: &Path) -> PathBuf {
let mut value = path.as_os_str().to_os_string();
value.push(".lock");
PathBuf::from(value)
}
fn cleanup_pre_ref_index_lock(
lock_path: &Path,
lock_file: &mut Option<File>,
error: LocalGitCommitError,
) -> LocalGitCommitError {
drop(lock_file.take());
match fs::remove_file(lock_path) {
Ok(()) => error,
Err(cleanup_error) => LocalGitCommitError::reconciliation(format!(
"Git ref 尚未更新,但无法安全清理 index.lock:{cleanup_error}"
)),
}
}
#[cfg(unix)]
fn install_git_index_lock(index_lock_path: &Path, index_path: &Path) -> std::io::Result<()> {
fs::rename(index_lock_path, index_path)
}
#[cfg(windows)]
fn install_git_index_lock(index_lock_path: &Path, index_path: &Path) -> std::io::Result<()> {
use std::os::windows::ffi::OsStrExt;
const MOVEFILE_REPLACE_EXISTING: u32 = 0x1;
const MOVEFILE_WRITE_THROUGH: u32 = 0x8;
#[link(name = "kernel32")]
extern "system" {
fn MoveFileExW(
existing_file_name: *const u16,
new_file_name: *const u16,
flags: u32,
) -> i32;
}
let source = index_lock_path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
let destination = index_path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
let moved = unsafe {
MoveFileExW(
source.as_ptr(),
destination.as_ptr(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
)
};
if moved == 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
}
}
#[cfg(not(any(unix, windows)))]
fn install_git_index_lock(index_lock_path: &Path, index_path: &Path) -> std::io::Result<()> {
fs::rename(index_lock_path, index_path)
}
fn read_remaining_changed_count(
root: &Path,
command: &GitInspectCommandContext,
) -> Result<usize, String> {
let status = read_git_status(root, command)?;
if status.truncated || std::str::from_utf8(&status.raw).is_err() {
return Err("Git 状态过大或包含非 UTF-8 路径".to_string());
}
let (staged, unstaged, untracked) = parse_status(root, &status.text);
Ok(staged
.into_iter()
.chain(unstaged)
.chain(untracked)
.collect::<BTreeSet<_>>()
.len())
}
fn build_git_inspect_command_context(root: &Path) -> Result<GitInspectCommandContext, String> {
let probe_args = vec!["status".to_string(), "--short".to_string()];
let spec = resolve_project_command_spec_at(root, "git", &probe_args, ".", 3)
.map_err(|error| format!("解析受信任 Git 失败:{}", error.message()))?;
let sandbox = tempfile::Builder::new()
.prefix("genarrative-git-inspect-")
.tempdir()
.map_err(|error| format!("创建 Git 隔离目录失败:{error}"))?;
Ok(GitInspectCommandContext {
executable: spec.executable,
safe_path: spec.safe_path,
sandbox,
})
}
fn read_git_head(root: &Path, command: &GitInspectCommandContext) -> String {
run_git(root, command, &["rev-parse", "--verify", "HEAD"])
.map(|output| output.trim().to_string())
.unwrap_or_else(|_| "(unborn)".to_string())
}
fn read_git_branch(root: &Path, command: &GitInspectCommandContext) -> Option<String> {
run_git(
root,
command,
&["symbolic-ref", "--quiet", "--short", "HEAD"],
)
.ok()
.map(|output| output.trim().to_string())
.filter(|output| !output.is_empty())
}
fn ensure_git_top_level(root: &Path, command: &GitInspectCommandContext) -> Result<(), String> {
let top_level = run_git(root, command, &["rev-parse", "--show-toplevel"])
.map_err(|_| "项目目录必须是 Git 仓库根目录".to_string())?;
let top_level = Path::new(top_level.trim())
.canonicalize()
.map_err(|error| format!("读取 Git 仓库根目录失败:{error}"))?;
if top_level != root {
return Err("项目目录必须是 Git 仓库根目录".to_string());
}
Ok(())
}
fn standard_git_dir(root: &Path, command: &GitInspectCommandContext) -> Result<PathBuf, String> {
let expected = root.join(".git");
let metadata = fs::symlink_metadata(&expected)
.map_err(|_| "受控提交只支持仓库根目录内的标准 .git 目录".to_string())?;
if !metadata.is_dir()
|| metadata.file_type().is_symlink()
|| metadata_is_reparse_point(&metadata)
{
return Err("受控提交拒绝外置 gitdir、linked worktree 和 submodule".to_string());
}
let expected = expected
.canonicalize()
.map_err(|error| format!("读取标准 .git 目录失败:{error}"))?;
let resolve_git_path = |value: String| -> Result<PathBuf, String> {
let path = PathBuf::from(value.trim());
let path = if path.is_absolute() {
path
} else {
root.join(path)
};
path.canonicalize()
.map_err(|error| format!("读取 Git 元数据目录失败:{error}"))
};
let actual = resolve_git_path(run_git(root, command, &["rev-parse", "--git-dir"])?)?;
let common = resolve_git_path(run_git(root, command, &["rev-parse", "--git-common-dir"])?)?;
if actual != expected || common != expected {
return Err("受控提交只支持仓库根目录内的标准 .git 目录".to_string());
}
let superproject = run_git(
root,
command,
&["rev-parse", "--show-superproject-working-tree"],
)?;
if !superproject.trim().is_empty() {
return Err("受控提交拒绝 submodule 工作树".to_string());
}
let bare = run_git(root, command, &["rev-parse", "--is-bare-repository"])?;
if bare.trim() != "false" {
return Err("受控提交只支持标准非 bare 工作树".to_string());
}
Ok(expected)
}
fn ensure_safe_git_control_file(
path: &Path,
allow_missing: bool,
) -> Result<(), LocalGitCommitError> {
match fs::symlink_metadata(path) {
Ok(metadata) => {
if !metadata.is_file()
|| metadata.file_type().is_symlink()
|| metadata_is_reparse_point(&metadata)
|| git_control_path_has_multiple_hard_links(path, &metadata)?
{
return Err(LocalGitCommitError::ordinary(
"Git 控制文件必须是非 reparse、非符号链接、非硬链接的普通文件",
));
}
Ok(())
}
Err(error) if allow_missing && error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(LocalGitCommitError::ordinary(format!(
"读取 Git 控制文件失败:{error}"
))),
}
}
fn read_attached_branch_ref_from_head(git_dir: &Path) -> Result<String, LocalGitCommitError> {
let head_path = git_dir.join("HEAD");
ensure_safe_git_control_file(&head_path, false)?;
let metadata = fs::symlink_metadata(&head_path).map_err(|error| {
LocalGitCommitError::ordinary(format!("读取 Git HEAD 元数据失败:{error}"))
})?;
if metadata.len() > GIT_CONTROL_FILE_MAX_BYTES {
return Err(LocalGitCommitError::ordinary("Git HEAD 控制文件过大"));
}
let head = fs::read_to_string(&head_path)
.map_err(|error| LocalGitCommitError::ordinary(format!("读取 Git HEAD 失败:{error}")))?;
let branch_ref = head
.strip_suffix("\r\n")
.or_else(|| head.strip_suffix('\n'))
.unwrap_or(&head)
.strip_prefix("ref: ")
.ok_or_else(|| LocalGitCommitError::ordinary("Git 提交只支持附着的本地分支"))?;
validate_local_branch_ref(branch_ref)?;
Ok(branch_ref.to_string())
}
fn validate_local_branch_ref(branch_ref: &str) -> Result<Vec<&str>, LocalGitCommitError> {
let components = branch_ref.split('/').collect::<Vec<_>>();
if components.len() < 3
|| components[0] != "refs"
|| components[1] != "heads"
|| components[2..]
.iter()
.any(|component| !valid_git_ref_component(component))
{
return Err(LocalGitCommitError::ordinary(
"Git 提交只支持安全的 refs/heads 本地分支",
));
}
Ok(components)
}
fn valid_git_ref_component(component: &str) -> bool {
!component.is_empty()
&& component != "."
&& component != ".."
&& !component.starts_with('.')
&& !component.ends_with('.')
&& !component.ends_with(".lock")
&& !component.contains("..")
&& !component.contains("@{")
&& !component.chars().any(|character| {
character.is_control()
|| character.is_whitespace()
|| matches!(character, '~' | '^' | ':' | '?' | '*' | '[' | '\\')
})
}
fn ensure_safe_git_storage_layout(
git_dir: &Path,
branch_ref: &str,
) -> Result<(), LocalGitCommitError> {
let branch_components = validate_local_branch_ref(branch_ref)?;
let objects = git_dir.join("objects");
ensure_safe_git_directory(&objects, false)?;
for alternate in ["alternates", "http-alternates"] {
match fs::symlink_metadata(objects.join("info").join(alternate)) {
Ok(_) => {
return Err(LocalGitCommitError::ordinary(
"受控提交不允许 Git object alternates",
));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(LocalGitCommitError::ordinary(format!(
"检查 Git object alternates 失败:{error}"
)));
}
}
}
ensure_safe_git_storage_tree(&objects)?;
ensure_safe_git_directory_chain(git_dir, &branch_components[..branch_components.len() - 1])?;
let loose_ref = branch_components
.iter()
.fold(git_dir.to_path_buf(), |path, component| {
path.join(component)
});
ensure_safe_git_control_file(&loose_ref, true)?;
ensure_safe_git_control_file(&git_dir.join("packed-refs"), true)?;
ensure_safe_git_directory(&git_dir.join("logs"), true)?;
ensure_safe_git_control_file(&git_dir.join("logs/HEAD"), true)?;
let mut reflog_components = vec!["logs"];
reflog_components.extend(branch_components.iter().copied());
ensure_safe_git_directory_chain(git_dir, &reflog_components[..reflog_components.len() - 1])?;
let branch_reflog = reflog_components
.iter()
.fold(git_dir.to_path_buf(), |path, component| {
path.join(component)
});
ensure_safe_git_control_file(&branch_reflog, true)?;
Ok(())
}
fn ensure_safe_git_storage_tree(root: &Path) -> Result<(), LocalGitCommitError> {
let mut pending = vec![root.to_path_buf()];
let mut entries_seen = 0_usize;
while let Some(directory) = pending.pop() {
ensure_safe_git_directory(&directory, false)?;
let entries = fs::read_dir(&directory).map_err(|error| {
LocalGitCommitError::ordinary(format!("读取 Git object store 失败:{error}"))
})?;
for entry in entries {
let entry = entry.map_err(|error| {
LocalGitCommitError::ordinary(format!("遍历 Git object store 失败:{error}"))
})?;
entries_seen = entries_seen.saturating_add(1);
if entries_seen > GIT_COMMIT_STORAGE_MAX_ENTRIES {
return Err(LocalGitCommitError::ordinary(
"Git object store 条目过多,拒绝受控提交",
));
}
let path = entry.path();
let metadata = fs::symlink_metadata(&path).map_err(|error| {
LocalGitCommitError::ordinary(format!("读取 Git object 路径失败:{error}"))
})?;
if metadata.file_type().is_symlink() || metadata_is_reparse_point(&metadata) {
return Err(LocalGitCommitError::ordinary(
"Git object store 不能包含 symlink、junction 或 reparse 路径",
));
}
if metadata.is_dir() {
pending.push(path);
} else if metadata.is_file() {
if git_control_path_has_multiple_hard_links(&path, &metadata)? {
return Err(LocalGitCommitError::ordinary(
"Git object store 不能包含硬链接文件",
));
}
} else {
return Err(LocalGitCommitError::ordinary(
"Git object store 只能包含普通目录和普通文件",
));
}
}
ensure_safe_git_directory(&directory, false)?;
}
Ok(())
}
fn ensure_safe_git_directory_chain(
git_dir: &Path,
components: &[&str],
) -> Result<(), LocalGitCommitError> {
let mut current = git_dir.to_path_buf();
for component in components {
current.push(component);
if !ensure_safe_git_directory(&current, true)? {
break;
}
}
Ok(())
}
fn ensure_safe_git_directory(
path: &Path,
allow_missing: bool,
) -> Result<bool, LocalGitCommitError> {
match fs::symlink_metadata(path) {
Ok(metadata)
if metadata.is_dir()
&& !metadata.file_type().is_symlink()
&& !metadata_is_reparse_point(&metadata) =>
{
Ok(true)
}
Ok(_) => Err(LocalGitCommitError::ordinary(
"Git 控制目录必须是非 reparse、非符号链接的真实目录",
)),
Err(error) if allow_missing && error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(LocalGitCommitError::ordinary(format!(
"读取 Git 控制目录失败:{error}"
))),
}
}
#[cfg(windows)]
fn metadata_is_reparse_point(metadata: &fs::Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
fn metadata_is_reparse_point(_metadata: &fs::Metadata) -> bool {
false
}
#[cfg(unix)]
fn git_control_path_has_multiple_hard_links(
_path: &Path,
metadata: &fs::Metadata,
) -> Result<bool, LocalGitCommitError> {
Ok(metadata_has_multiple_hard_links(metadata))
}
#[cfg(windows)]
fn git_control_path_has_multiple_hard_links(
path: &Path,
_metadata: &fs::Metadata,
) -> Result<bool, LocalGitCommitError> {
use std::ffi::c_void;
use std::mem::MaybeUninit;
use std::os::windows::io::AsRawHandle;
#[repr(C)]
#[allow(dead_code)]
struct FileTime {
low_date_time: u32,
high_date_time: u32,
}
#[repr(C)]
#[allow(dead_code)]
struct ByHandleFileInformation {
file_attributes: u32,
creation_time: FileTime,
last_access_time: FileTime,
last_write_time: FileTime,
volume_serial_number: u32,
file_size_high: u32,
file_size_low: u32,
number_of_links: u32,
file_index_high: u32,
file_index_low: u32,
}
#[link(name = "kernel32")]
unsafe extern "system" {
fn GetFileInformationByHandle(
file: *mut c_void,
information: *mut ByHandleFileInformation,
) -> i32;
}
let file = File::open(path).map_err(|error| {
LocalGitCommitError::ordinary(format!("打开 Git 控制文件失败:{error}"))
})?;
let mut information = MaybeUninit::<ByHandleFileInformation>::uninit();
let succeeded = unsafe {
GetFileInformationByHandle(file.as_raw_handle().cast(), information.as_mut_ptr())
};
if succeeded == 0 {
return Err(LocalGitCommitError::ordinary(format!(
"读取 Git 控制文件硬链接信息失败:{}",
std::io::Error::last_os_error()
)));
}
let information = unsafe { information.assume_init() };
Ok(information.number_of_links > 1)
}
#[cfg(not(any(unix, windows)))]
fn git_control_path_has_multiple_hard_links(
_path: &Path,
_metadata: &fs::Metadata,
) -> Result<bool, LocalGitCommitError> {
Ok(false)
}
fn parse_status(root: &Path, output: &str) -> (Vec<String>, Vec<String>, Vec<String>) {
let mut staged = Vec::new();
let mut unstaged = Vec::new();
let mut untracked = Vec::new();
for record in output.split('\0').filter(|record| !record.is_empty()) {
let bytes = record.as_bytes();
if bytes.len() < 4 || bytes[2] != b' ' {
continue;
}
let Some(path) = safe_git_path(root, &record[3..]) else {
continue;
};
if bytes[0] == b'?' && bytes[1] == b'?' {
untracked.push(path);
continue;
}
if bytes[0] != b' ' && bytes[0] != b'?' {
staged.push(path.clone());
}
if bytes[1] != b' ' && bytes[1] != b'?' {
unstaged.push(path);
}
}
(staged, unstaged, untracked)
}
fn safe_git_path(root: &Path, path: &str) -> Option<String> {
let normalized = normalize_relative_path(path).ok()?;
if should_skip_project_snapshot_path(&normalized) {
return None;
}
if !git_worktree_path_is_safe(root, &normalized) {
return None;
}
Some(normalized)
}
fn git_worktree_path_is_safe(root: &Path, relative_path: &str) -> bool {
let mut current = root.to_path_buf();
let components = relative_path.split('/').collect::<Vec<_>>();
for (index, component) in components.iter().enumerate() {
current.push(component);
match std::fs::symlink_metadata(&current) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
return false;
}
if index + 1 < components.len() && !metadata.is_dir() {
return false;
}
if index + 1 == components.len()
&& (!metadata.is_file() || metadata_has_multiple_hard_links(&metadata))
{
return false;
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return index + 1 == components.len();
}
Err(_) => return false,
}
}
true
}
#[cfg(unix)]
fn metadata_has_multiple_hard_links(metadata: &std::fs::Metadata) -> bool {
use std::os::unix::fs::MetadataExt;
metadata.nlink() > 1
}
#[cfg(not(unix))]
fn metadata_has_multiple_hard_links(_metadata: &std::fs::Metadata) -> bool {
false
}
fn append_path_section(
output: &mut String,
title: &str,
paths: &[String],
selected_paths: &BTreeSet<String>,
) {
let selected = paths
.iter()
.filter(|path| selected_paths.contains(*path))
.collect::<Vec<_>>();
if selected.is_empty() {
return;
}
let _ = writeln!(output, "\n## {title}");
for path in selected {
let _ = writeln!(output, "- {path}");
}
}
fn read_diff(
root: &Path,
command: &GitInspectCommandContext,
cached: bool,
paths: &[String],
) -> Result<BoundedGitOutput, String> {
if paths.is_empty() {
return Ok(BoundedGitOutput {
raw: Vec::new(),
text: String::new(),
truncated: false,
});
}
let mut args = vec![
"diff".to_string(),
"--no-ext-diff".to_string(),
"--no-color".to_string(),
"--no-renames".to_string(),
"--unified=3".to_string(),
];
if cached {
args.push("--cached".to_string());
}
args.push("--".to_string());
args.extend(paths.iter().cloned());
run_git_owned_bounded(root, command, &args)
}
fn append_diff_section(output: &mut String, title: &str, diff: &str) {
if !diff.is_empty() {
let _ = write!(output, "\n## {title}\n{diff}");
if !diff.ends_with('\n') {
output.push('\n');
}
}
}
fn run_git(
root: &Path,
command: &GitInspectCommandContext,
args: &[&str],
) -> Result<String, String> {
let output = run_git_bounded(root, command, args)?;
if output.truncated {
return Err("Git 元数据输出超过安全上限".to_string());
}
Ok(output.text)
}
fn run_git_bounded(
root: &Path,
context: &GitInspectCommandContext,
args: &[&str],
) -> Result<BoundedGitOutput, String> {
run_git_owned_bounded(
root,
context,
&args
.iter()
.map(|argument| (*argument).to_string())
.collect::<Vec<_>>(),
)
}
fn run_git_owned_bounded(
root: &Path,
context: &GitInspectCommandContext,
args: &[String],
) -> Result<BoundedGitOutput, String> {
let mut command = build_sandboxed_git_command(root, context, args, None, None);
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command
.spawn()
.map_err(|error| format!("启动 Git 检查失败:{error}"))?;
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let stdout_reader = thread::spawn(move || read_bounded(stdout));
let stderr_reader = thread::spawn(move || read_bounded(stderr));
let started = Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if started.elapsed() < GIT_INSPECT_TIMEOUT => {
thread::sleep(Duration::from_millis(10));
}
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Err("Git 检查超时".to_string());
}
Err(error) => {
let _ = child.kill();
let _ = child.wait();
return Err(format!("等待 Git 检查失败:{error}"));
}
}
};
let (stdout, stdout_truncated) = stdout_reader.join().unwrap_or_default();
let (stderr, _) = stderr_reader.join().unwrap_or_default();
if !status.success() {
let detail = String::from_utf8_lossy(&stderr).trim().to_string();
return Err(if detail.is_empty() {
"Git 检查失败".to_string()
} else {
format!("Git 检查失败:{detail}")
});
}
Ok(BoundedGitOutput {
text: String::from_utf8_lossy(&stdout).into_owned(),
raw: stdout,
truncated: stdout_truncated,
})
}
fn run_git_commit_owned(
root: &Path,
context: &GitInspectCommandContext,
args: &[String],
index_path: Option<&Path>,
identity: Option<&LocalGitIdentity>,
input: GitCommandInput<'_>,
operation: &str,
) -> Result<BoundedGitOutput, LocalGitCommitError> {
let mut command = build_sandboxed_git_command(root, context, args, index_path, identity);
match input {
GitCommandInput::None => {
command.stdin(Stdio::null());
}
GitCommandInput::Bytes(_) => {
command.stdin(Stdio::piped());
}
GitCommandInput::File(path) => {
let file = File::open(path).map_err(|error| {
LocalGitCommitError::ordinary(format!("{operation}输入失败:{error}"))
})?;
command.stdin(Stdio::from(file));
}
}
command.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = command.spawn().map_err(|error| {
LocalGitCommitError::ordinary(format!("启动 {operation} 失败:{error}"))
})?;
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let stdout_reader = thread::spawn(move || read_bounded(stdout));
let stderr_reader = thread::spawn(move || read_bounded(stderr));
if let GitCommandInput::Bytes(bytes) = input {
let write_result = child
.stdin
.take()
.ok_or_else(|| LocalGitCommitError::ordinary(format!("{operation} stdin 不可用")))
.and_then(|mut stdin| {
stdin.write_all(bytes).map_err(|error| {
LocalGitCommitError::ordinary(format!("写入 {operation} stdin 失败:{error}"))
})
});
if let Err(error) = write_result {
let _ = child.kill();
let _ = child.wait();
let _ = stdout_reader.join();
let _ = stderr_reader.join();
return Err(error);
}
}
let started = Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if started.elapsed() < GIT_COMMIT_TIMEOUT => {
thread::sleep(Duration::from_millis(10));
}
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
let _ = stdout_reader.join();
let _ = stderr_reader.join();
return Err(LocalGitCommitError::ordinary(format!("{operation} 超时")));
}
Err(error) => {
let _ = child.kill();
let _ = child.wait();
let _ = stdout_reader.join();
let _ = stderr_reader.join();
return Err(LocalGitCommitError::ordinary(format!(
"等待 {operation} 失败:{error}"
)));
}
}
};
let (stdout, stdout_truncated) = stdout_reader.join().unwrap_or_default();
let (stderr, _) = stderr_reader.join().unwrap_or_default();
if !status.success() {
let detail = bounded_git_error_detail(&stderr);
return Err(LocalGitCommitError::ordinary(if detail.is_empty() {
format!("{operation} 失败")
} else {
format!("{operation} 失败:{detail}")
}));
}
if stdout_truncated {
return Err(LocalGitCommitError::ordinary(format!(
"{operation} 输出超过安全上限"
)));
}
Ok(BoundedGitOutput {
text: String::from_utf8_lossy(&stdout).into_owned(),
raw: stdout,
truncated: false,
})
}
fn build_sandboxed_git_command(
root: &Path,
context: &GitInspectCommandContext,
args: &[String],
index_path: Option<&Path>,
identity: Option<&LocalGitIdentity>,
) -> Command {
let null_device = if cfg!(windows) { "NUL" } else { "/dev/null" };
let sandbox = context.sandbox.path();
let mut command = Command::new(&context.executable);
crate::configure_windows_background_std_command(&mut command, false);
command.env_clear();
for key in ["SystemRoot", "WINDIR", "PATHEXT"] {
if let Some(value) = std::env::var_os(key) {
command.env(key, value);
}
}
command
.env("PATH", &context.safe_path)
.env("HOME", sandbox)
.env("USERPROFILE", sandbox)
.env("XDG_CONFIG_HOME", sandbox)
.env("TMPDIR", sandbox)
.env("TEMP", sandbox)
.env("TMP", sandbox)
.env("HTTP_PROXY", "http://127.0.0.1:9")
.env("HTTPS_PROXY", "http://127.0.0.1:9")
.env("ALL_PROXY", "http://127.0.0.1:9")
.env("NO_PROXY", "")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_SYSTEM", null_device)
.env("GIT_CONFIG_GLOBAL", null_device)
.env("GIT_OPTIONAL_LOCKS", "0")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_ASKPASS", null_device)
.env("SSH_ASKPASS", null_device)
.env("GIT_PAGER", "cat")
.env("PAGER", "cat")
.env("TERM", "dumb")
.env("LC_ALL", "C")
.env("LANG", "C")
.env("GIT_NO_REPLACE_OBJECTS", "1")
.env("GIT_ATTR_NOSYSTEM", "1")
.env("GIT_LFS_SKIP_SMUDGE", "1")
.current_dir(root)
.arg("--no-pager")
.arg("--no-optional-locks")
.arg("--literal-pathspecs")
.arg("-c")
.arg("core.fsmonitor=false")
.arg("-c")
.arg(format!("core.hooksPath={null_device}"))
.arg("-c")
.arg("core.pager=cat")
.arg("-c")
.arg("commit.gpgSign=false")
.arg("-c")
.arg("credential.helper=")
.arg("-c")
.arg("protocol.allow=never")
.args(args);
if let Some(parent) = root.parent() {
command.env("GIT_CEILING_DIRECTORIES", parent);
}
if let Some(index_path) = index_path {
command.env("GIT_INDEX_FILE", index_path);
}
if let Some(identity) = identity {
command
.env("GIT_AUTHOR_NAME", &identity.name)
.env("GIT_AUTHOR_EMAIL", &identity.email)
.env("GIT_COMMITTER_NAME", &identity.name)
.env("GIT_COMMITTER_EMAIL", &identity.email);
}
command
}
fn bounded_git_error_detail(stderr: &[u8]) -> String {
String::from_utf8_lossy(stderr)
.trim()
.chars()
.take(512)
.collect()
}
fn read_bounded<R: Read>(stream: Option<R>) -> (Vec<u8>, bool) {
let Some(mut stream) = stream else {
return (Vec::new(), false);
};
let mut collected = Vec::new();
let mut truncated = false;
let mut buffer = [0_u8; 8 * 1024];
while let Ok(read) = stream.read(&mut buffer) {
if read == 0 {
break;
}
let remaining = GIT_INSPECT_OUTPUT_MAX_BYTES.saturating_sub(collected.len());
collected.extend_from_slice(&buffer[..read.min(remaining)]);
truncated |= read > remaining;
}
(collected, truncated)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::io::Cursor;
fn git(root: &Path, args: &[&str]) {
let status = Command::new("git")
.current_dir(root)
.args(args)
.status()
.expect("run git fixture command");
assert!(status.success());
}
fn git_output(root: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.current_dir(root)
.args(args)
.output()
.expect("run git fixture command");
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout)
.expect("git fixture output must be utf-8")
.trim()
.to_string()
}
fn init_git_commit_fixture(root: &Path, local_identity: bool) {
git(root, &["init", "-q", "-b", "main"]);
if local_identity {
git(root, &["config", "--local", "user.name", "Fixture User"]);
git(
root,
&["config", "--local", "user.email", "fixture@example.invalid"],
);
}
fs::write(root.join("game.txt"), "initial\n").expect("write initial fixture file");
fs::write(root.join("delete.txt"), "delete me\n").expect("write deletion fixture file");
fs::write(root.join("remain.txt"), "initial remain\n")
.expect("write remaining fixture file");
git(root, &["add", "game.txt", "delete.txt", "remain.txt"]);
if local_identity {
git(root, &["commit", "-qm", "initial"]);
} else {
git(
root,
&[
"-c",
"user.name=Fixture User",
"-c",
"user.email=fixture@example.invalid",
"commit",
"-qm",
"initial",
],
);
}
}
fn git_commit_snapshot(root: &Path) -> (String, String) {
let inspect =
inspect_local_git_worktree_at(root, false, 100, 24_000).expect("inspect fixture");
(
inspect.head,
inspect
.commit_snapshot_fingerprint
.expect("fixture must issue commit snapshot"),
)
}
#[test]
fn inspects_bounded_changes_and_filters_sensitive_paths() {
let fixture = tempfile::tempdir().expect("create fixture");
let root = fixture.path();
git(root, &["init", "-q"]);
git(root, &["config", "user.name", "Test"]);
git(root, &["config", "user.email", "test@example.invalid"]);
fs::write(root.join("game.txt"), "first\n").expect("write tracked file");
git(root, &["add", "game.txt"]);
git(root, &["commit", "-qm", "initial"]);
fs::write(root.join("game.txt"), "first\nsecond\n").expect("update tracked file");
fs::write(root.join("new.txt"), "new\n").expect("write untracked file");
fs::write(root.join(".env"), "SECRET=hidden\n").expect("write secret file");
fs::create_dir_all(root.join("node_modules/pkg")).expect("create dependency directory");
fs::write(
root.join("node_modules/pkg/index.js"),
"DEPENDENCY_SECRET\n",
)
.expect("write dependency file");
fs::create_dir_all(root.join("data")).expect("create data directory");
fs::write(root.join("data/local.sqlite"), "DATABASE_SECRET\n")
.expect("write database file");
let result =
inspect_local_git_worktree_at(root, true, 20, 24_000).expect("inspect git worktree");
assert_eq!(result.unstaged, vec!["game.txt"]);
assert_eq!(result.untracked, vec!["new.txt"]);
assert!(result.content.contains("+second"));
assert!(!result.content.contains("SECRET"));
assert!(!result.content.contains(".env"));
assert!(!result.content.contains("node_modules"));
assert!(!result.content.contains("local.sqlite"));
assert!(!result.content.contains("DEPENDENCY_SECRET"));
assert!(!result.content.contains("DATABASE_SECRET"));
}
#[test]
fn rejects_a_project_nested_inside_another_repository() {
let fixture = tempfile::tempdir().expect("create fixture");
git(fixture.path(), &["init", "-q"]);
let nested = fixture.path().join("nested");
fs::create_dir(&nested).expect("create nested directory");
let error = inspect_local_git_worktree_at(&nested, false, 20, 24_000)
.expect_err("nested project must not inspect parent repository");
assert!(error.contains("仓库根目录"));
}
#[cfg(unix)]
#[test]
fn omits_hard_linked_and_symlinked_worktree_paths() {
let fixture = tempfile::tempdir().expect("create fixture");
let outside = tempfile::tempdir().expect("create outside fixture");
let root = fixture.path();
git(root, &["init", "-q"]);
fs::write(outside.path().join("shared.txt"), "outside hard link\n")
.expect("write hard link source");
fs::hard_link(
outside.path().join("shared.txt"),
root.join("hard-linked.txt"),
)
.expect("create hard link");
std::os::unix::fs::symlink(
outside.path().join("shared.txt"),
root.join("symlinked.txt"),
)
.expect("create symlink");
let result =
inspect_local_git_worktree_at(root, true, 20, 24_000).expect("inspect worktree");
assert!(!result.untracked.contains(&"hard-linked.txt".to_string()));
assert!(!result.untracked.contains(&"symlinked.txt".to_string()));
assert!(!result.content.contains("outside hard link"));
}
#[test]
fn git_inspect_fingerprint_changes_with_worktree_content() {
let fixture = tempfile::tempdir().expect("create fixture");
let root = fixture.path();
init_git_commit_fixture(root, true);
fs::write(root.join("game.txt"), "first change\n").expect("write first change");
let first = inspect_local_git_worktree_at(root, false, 100, 24_000)
.expect("inspect first worktree")
.commit_snapshot_fingerprint
.expect("first fingerprint");
fs::write(root.join("game.txt"), "second change\n").expect("write second change");
let second = inspect_local_git_worktree_at(root, false, 100, 24_000)
.expect("inspect second worktree")
.commit_snapshot_fingerprint
.expect("second fingerprint");
assert_ne!(first, second);
assert_eq!(first.len(), 64);
assert_eq!(second.len(), 64);
}
#[test]
fn git_commit_fingerprint_ignores_control_plane_changes_between_actions() {
let fixture = tempfile::tempdir().expect("create fixture");
let root = fixture.path();
init_git_commit_fixture(root, true);
fs::create_dir_all(root.join(".agent/runtime")).expect("create control directory");
fs::write(root.join(".agent/runtime/pending.json"), "{\"step\":1}\n")
.expect("write tracked control file");
git(root, &["add", "-f", ".agent/runtime/pending.json"]);
git(root, &["commit", "-qm", "track control plane"]);
fs::write(root.join("game.txt"), "safe source change\n").expect("modify safe source");
let (head, fingerprint) = git_commit_snapshot(root);
fs::write(root.join(".agent/runtime/pending.json"), "{\"step\":2}\n")
.expect("advance control plane");
let after_control_change = inspect_local_git_worktree_at(root, false, 100, 24_000)
.expect("inspect after control-plane change")
.commit_snapshot_fingerprint
.expect("fingerprint after control-plane change");
assert_eq!(after_control_change, fingerprint);
let result = commit_local_git_worktree_at(
root,
"commit safe source",
&["game.txt".to_string()],
&head,
&fingerprint,
)
.expect("control-plane drift must not invalidate safe snapshot");
assert_eq!(result.remaining_changed_count, 0);
assert_eq!(
git_output(root, &["status", "--porcelain"]),
"M .agent/runtime/pending.json"
);
assert_eq!(
git_output(
root,
&["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"]
),
"game.txt"
);
}
#[test]
fn git_commit_fingerprint_still_rejects_safe_source_change_after_control_plane_change() {
let fixture = tempfile::tempdir().expect("create fixture");
let root = fixture.path();
init_git_commit_fixture(root, true);
fs::create_dir_all(root.join(".agent/runtime")).expect("create control directory");
fs::write(root.join(".agent/runtime/pending.json"), "{\"step\":1}\n")
.expect("write tracked control file");
git(root, &["add", "-f", ".agent/runtime/pending.json"]);
git(root, &["commit", "-qm", "track control plane"]);
fs::write(root.join("game.txt"), "reviewed source\n").expect("modify safe source");
let (head, fingerprint) = git_commit_snapshot(root);
fs::write(root.join(".agent/runtime/pending.json"), "{\"step\":2}\n")
.expect("advance control plane");
fs::write(root.join("game.txt"), "source drift after review\n").expect("drift safe source");
let error = commit_local_git_worktree_at(
root,
"must fail",
&["game.txt".to_string()],
&head,
&fingerprint,
)
.expect_err("safe source drift must invalidate snapshot");
assert!(!error.needs_reconciliation());
assert!(error.message().contains("快照"));
assert_eq!(git_output(root, &["rev-parse", "HEAD"]), head);
}
#[test]
fn git_commit_commits_exact_paths_and_preserves_unselected_changes() {
let fixture = tempfile::tempdir().expect("create fixture");
let root = fixture.path();
init_git_commit_fixture(root, true);
fs::write(root.join("game.txt"), "selected change\n").expect("modify selected file");
fs::remove_file(root.join("delete.txt")).expect("delete selected file");
fs::write(root.join("remain.txt"), "unselected change\n").expect("modify unselected file");
let (head, fingerprint) = git_commit_snapshot(root);
let message = "提交已验证修改\n\nprivate body";
let paths = vec!["game.txt".to_string(), "delete.txt".to_string()];
let result = commit_local_git_worktree_at(root, message, &paths, &head, &fingerprint)
.expect("create controlled commit");
assert_eq!(result.parent_head, head);
assert_eq!(result.commit_head, git_output(root, &["rev-parse", "HEAD"]));
assert_eq!(result.branch, "main");
assert_eq!(result.paths, paths);
assert_eq!(
result.message_sha256,
hex_digest(Sha256::digest(message.as_bytes()).as_slice())
);
assert_eq!(result.remaining_changed_count, 1);
assert_eq!(
git_output(
root,
&["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"]
)
.lines()
.collect::<BTreeSet<_>>(),
BTreeSet::from(["delete.txt", "game.txt"])
);
assert!(!root.join("delete.txt").exists());
assert_eq!(git_output(root, &["status", "--porcelain"]), "M remain.txt");
assert_eq!(git_output(root, &["log", "-1", "--format=%B"]), message);
for reference in ["HEAD", "refs/heads/main"] {
assert_eq!(
git_output(root, &["reflog", "show", "-1", "--format=%H", reference]),
result.commit_head
);
let reflog_message =
git_output(root, &["reflog", "show", "-1", "--format=%gs", reference]);
assert_eq!(reflog_message, GIT_COMMIT_REFLOG_MESSAGE);
assert!(!reflog_message.contains("private body"));
}
}
#[test]
fn git_commit_creates_head_and_branch_reflogs_when_missing() {
let fixture = tempfile::tempdir().expect("create fixture");
let root = fixture.path();
init_git_commit_fixture(root, true);
git(
root,
&["config", "--local", "core.logAllRefUpdates", "false"],
);
fs::remove_dir_all(root.join(".git/logs")).expect("remove fixture reflogs");
fs::write(root.join("game.txt"), "change\n").expect("modify file");
let (head, fingerprint) = git_commit_snapshot(root);
let result = commit_local_git_worktree_at(
root,
"create missing reflogs",
&["game.txt".to_string()],
&head,
&fingerprint,
)
.expect("controlled commit must create both reflogs");
for reference in ["HEAD", "refs/heads/main"] {
assert_eq!(
git_output(root, &["reflog", "show", "-1", "--format=%H", reference]),
result.commit_head
);
assert_eq!(
git_output(root, &["reflog", "show", "-1", "--format=%gs", reference]),
GIT_COMMIT_REFLOG_MESSAGE
);
}
}
#[test]
fn git_commit_rejects_any_existing_staged_content() {
let fixture = tempfile::tempdir().expect("create fixture");
let root = fixture.path();
init_git_commit_fixture(root, true);
fs::write(root.join("game.txt"), "unstaged\n").expect("write unstaged file");
fs::write(root.join(".env"), "SECRET=staged\n").expect("write sensitive staged file");
git(root, &["add", "-f", ".env"]);
let (head, fingerprint) = git_commit_snapshot(root);
let error = commit_local_git_worktree_at(
root,
"must fail",
&["game.txt".to_string()],
&head,
&fingerprint,
)
.expect_err("staged content must be rejected");
assert!(!error.needs_reconciliation());
assert!(error.message().contains("staged"));
assert_eq!(git_output(root, &["rev-parse", "HEAD"]), head);
assert!(!root.join(".git/index.lock").exists());
}
#[test]
fn git_commit_rejects_expected_head_drift_without_reconciliation() {
let fixture = tempfile::tempdir().expect("create fixture");
let root = fixture.path();
init_git_commit_fixture(root, true);
fs::write(root.join("game.txt"), "change\n").expect("modify file");
let (head, fingerprint) = git_commit_snapshot(root);
git(root, &["commit", "--allow-empty", "-qm", "concurrent"]);
let error = commit_local_git_worktree_at(
root,
"must fail",
&["game.txt".to_string()],
&head,
&fingerprint,
)
.expect_err("head drift must fail");
assert!(!error.needs_reconciliation());
assert!(error.message().contains("HEAD"));
assert!(!root.join(".git/index.lock").exists());
}
#[test]
fn git_commit_rejects_snapshot_drift_without_reconciliation() {
let fixture = tempfile::tempdir().expect("create fixture");
let root = fixture.path();
init_git_commit_fixture(root, true);
fs::write(root.join("game.txt"), "first change\n").expect("modify file");
let (head, fingerprint) = git_commit_snapshot(root);
fs::write(root.join("game.txt"), "second change\n").expect("drift file");
let error = commit_local_git_worktree_at(
root,
"must fail",
&["game.txt".to_string()],
&head,
&fingerprint,
)
.expect_err("snapshot drift must fail");
assert!(!error.needs_reconciliation());
assert!(error.message().contains("快照"));
assert_eq!(git_output(root, &["rev-parse", "HEAD"]), head);
assert!(!root.join(".git/index.lock").exists());
}
#[test]
fn git_commit_rejects_detached_head_and_linked_worktree() {
let fixture = tempfile::tempdir().expect("create fixture");
let root = fixture.path().join("main");
let linked = fixture.path().join("linked");
fs::create_dir(&root).expect("create main fixture");
init_git_commit_fixture(&root, true);
let linked_path = linked.to_str().expect("utf-8 linked path");
git(
&root,
&["worktree", "add", "-q", "--detach", linked_path, "HEAD"],
);
fs::write(linked.join("game.txt"), "linked change\n").expect("modify linked file");
let linked_inspect = inspect_local_git_worktree_at(&linked, false, 100, 24_000)
.expect("inspect linked worktree");
assert!(linked_inspect.commit_snapshot_fingerprint.is_none());
let linked_error = commit_local_git_worktree_at(
&linked,
"must fail",
&["game.txt".to_string()],
&linked_inspect.head,
"unavailable",
)
.expect_err("linked worktree must fail");
assert!(!linked_error.needs_reconciliation());
assert!(
linked_error.message().contains("gitdir")
|| linked_error.message().contains("worktree")
);
git(&root, &["checkout", "-q", "--detach"]);
fs::write(root.join("game.txt"), "detached change\n").expect("modify detached file");
let detached = inspect_local_git_worktree_at(&root, false, 100, 24_000)
.expect("inspect detached worktree");
assert!(detached.commit_snapshot_fingerprint.is_none());
let detached_error = commit_local_git_worktree_at(
&root,
"must fail",
&["game.txt".to_string()],
&detached.head,
"unavailable",
)
.expect_err("detached head must fail");
assert!(!detached_error.needs_reconciliation());
assert!(detached_error.message().contains("附着"));
}
#[cfg(unix)]
#[test]
fn git_commit_rejects_refs_heads_symlink_outside_git_dir() {
let fixture = tempfile::tempdir().expect("create fixture");
let outside = tempfile::tempdir().expect("create outside refs directory");
let root = fixture.path();
init_git_commit_fixture(root, true);
let original_head = git_output(root, &["rev-parse", "HEAD"]);
fs::write(outside.path().join("main"), format!("{original_head}\n"))
.expect("write outside loose ref");
fs::remove_file(root.join(".git/refs/heads/main")).expect("remove original loose ref");
fs::remove_dir(root.join(".git/refs/heads")).expect("remove original heads directory");
std::os::unix::fs::symlink(outside.path(), root.join(".git/refs/heads"))
.expect("redirect refs heads outside git dir");
fs::write(root.join("game.txt"), "change\n").expect("modify safe source");
let (head, fingerprint) = git_commit_snapshot(root);
let error = commit_local_git_worktree_at(
root,
"must fail",
&["game.txt".to_string()],
&head,
&fingerprint,
)
.expect_err("refs symlink must be rejected");
assert!(!error.needs_reconciliation());
assert!(error.message().contains("控制目录"));
assert_eq!(git_output(root, &["rev-parse", "HEAD"]), original_head);
assert_eq!(
fs::read_to_string(outside.path().join("main")).expect("read outside loose ref"),
format!("{original_head}\n")
);
assert!(!root.join(".git/index.lock").exists());
}
#[cfg(unix)]
#[test]
fn git_commit_rejects_hard_linked_branch_reflog() {
let fixture = tempfile::tempdir().expect("create fixture");
let outside = tempfile::tempdir().expect("create outside reflog directory");
let root = fixture.path();
init_git_commit_fixture(root, true);
fs::hard_link(
root.join(".git/logs/refs/heads/main"),
outside.path().join("shared-reflog"),
)
.expect("hard link branch reflog");
fs::write(root.join("game.txt"), "change\n").expect("modify safe source");
let (head, fingerprint) = git_commit_snapshot(root);
let error = commit_local_git_worktree_at(
root,
"must fail",
&["game.txt".to_string()],
&head,
&fingerprint,
)
.expect_err("hard-linked reflog must be rejected");
assert!(!error.needs_reconciliation());
assert!(error.message().contains("硬链接"));
assert_eq!(git_output(root, &["rev-parse", "HEAD"]), head);
assert!(!root.join(".git/index.lock").exists());
}
#[test]
fn git_commit_requires_repository_local_identity() {
let fixture = tempfile::tempdir().expect("create fixture");
let root = fixture.path();
init_git_commit_fixture(root, false);
fs::write(root.join("game.txt"), "change\n").expect("modify file");
let (head, fingerprint) = git_commit_snapshot(root);
let error = commit_local_git_worktree_at(
root,
"must fail",
&["game.txt".to_string()],
&head,
&fingerprint,
)
.expect_err("missing local identity must fail");
assert!(!error.needs_reconciliation());
assert!(error.message().contains("user.name"));
assert_eq!(git_output(root, &["rev-parse", "HEAD"]), head);
assert!(!root.join(".git/index.lock").exists());
}
#[cfg(unix)]
#[test]
fn git_commit_does_not_execute_pre_commit_hook() {
use std::os::unix::fs::PermissionsExt;
let fixture = tempfile::tempdir().expect("create fixture");
let root = fixture.path();
init_git_commit_fixture(root, true);
fs::write(root.join("game.txt"), "change\n").expect("modify file");
let hook = root.join(".git/hooks/pre-commit");
fs::write(&hook, "#!/bin/sh\nprintf ran > hook-ran\nexit 1\n")
.expect("write rejecting hook");
fs::set_permissions(&hook, fs::Permissions::from_mode(0o755))
.expect("make hook executable");
let (head, fingerprint) = git_commit_snapshot(root);
commit_local_git_worktree_at(
root,
"hook-free commit",
&["game.txt".to_string()],
&head,
&fingerprint,
)
.expect("commit-tree must bypass hooks");
assert!(!root.join("hook-ran").exists());
}
#[test]
fn git_commit_update_ref_competition_is_ordinary_failure() {
let fixture = tempfile::tempdir().expect("create fixture");
let root = fixture.path();
init_git_commit_fixture(root, true);
fs::write(root.join("game.txt"), "change\n").expect("modify file");
let (head, fingerprint) = git_commit_snapshot(root);
let competitor = git_output(
root,
&[
"commit-tree",
"HEAD^{tree}",
"-p",
"HEAD",
"-m",
"competitor",
],
);
let mut hook = |point| {
if point == LocalGitCommitHookPoint::BeforeUpdateRef {
git(root, &["update-ref", "refs/heads/main", &competitor, &head]);
}
Ok(())
};
let error = commit_local_git_worktree_at_with_hook(
root,
"must lose race",
&["game.txt".to_string()],
&head,
&fingerprint,
&mut hook,
)
.expect_err("expected-old competition must fail");
assert!(!error.needs_reconciliation());
assert_eq!(git_output(root, &["rev-parse", "HEAD"]), competitor);
assert!(!root.join(".git/index.lock").exists());
assert_eq!(git_output(root, &["status", "--porcelain"]), "M game.txt");
}
#[test]
fn git_commit_update_ref_error_with_expected_head_needs_reconciliation() {
let fixture = tempfile::tempdir().expect("create fixture");
let root = fixture.path();
init_git_commit_fixture(root, true);
fs::write(root.join("game.txt"), "change\n").expect("modify file");
let (head, fingerprint) = git_commit_snapshot(root);
let mut hook = |point| {
if point == LocalGitCommitHookPoint::BeforeUpdateRef {
fs::write(root.join(".git/refs/heads/main.lock"), "block update-ref\n")
.expect("create competing ref lock");
}
Ok(())
};
let error = commit_local_git_worktree_at_with_hook(
root,
"must reconcile",
&["game.txt".to_string()],
&head,
&fingerprint,
&mut hook,
)
.expect_err("failed update-ref at expected HEAD must reconcile");
assert!(error.needs_reconciliation());
assert_eq!(git_output(root, &["rev-parse", "HEAD"]), head);
assert!(root.join(".git/index.lock").is_file());
}
#[test]
fn git_commit_expected_old_competition_with_leftover_reflog_lock_needs_reconciliation() {
let fixture = tempfile::tempdir().expect("create fixture");
let root = fixture.path();
init_git_commit_fixture(root, true);
fs::write(root.join("game.txt"), "change\n").expect("modify file");
let (head, fingerprint) = git_commit_snapshot(root);
let competitor = git_output(
root,
&[
"commit-tree",
"HEAD^{tree}",
"-p",
"HEAD",
"-m",
"competitor",
],
);
let mut hook = |point| {
if point == LocalGitCommitHookPoint::BeforeUpdateRef {
git(root, &["update-ref", "refs/heads/main", &competitor, &head]);
fs::write(root.join(".git/logs/HEAD.lock"), "leftover reflog lock\n")
.expect("create leftover reflog lock");
}
Ok(())
};
let error = commit_local_git_worktree_at_with_hook(
root,
"must reconcile",
&["game.txt".to_string()],
&head,
&fingerprint,
&mut hook,
)
.expect_err("leftover reflog lock makes update-ref outcome unsafe");
assert!(error.needs_reconciliation());
assert_eq!(git_output(root, &["rev-parse", "HEAD"]), competitor);
assert!(root.join(".git/logs/HEAD.lock").is_file());
assert!(root.join(".git/index.lock").is_file());
}
#[test]
fn git_commit_failure_after_ref_update_needs_reconciliation() {
let fixture = tempfile::tempdir().expect("create fixture");
let root = fixture.path();
init_git_commit_fixture(root, true);
fs::write(root.join("game.txt"), "change\n").expect("modify file");
let (head, fingerprint) = git_commit_snapshot(root);
let mut hook = |point| {
if point == LocalGitCommitHookPoint::AfterUpdateRef {
Err("injected post-ref failure".to_string())
} else {
Ok(())
}
};
let error = commit_local_git_worktree_at_with_hook(
root,
"must reconcile",
&["game.txt".to_string()],
&head,
&fingerprint,
&mut hook,
)
.expect_err("post-ref failure must reconcile");
assert!(error.needs_reconciliation());
assert_ne!(git_output(root, &["rev-parse", "HEAD"]), head);
assert!(root.join(".git/index.lock").is_file());
}
#[test]
fn git_commit_index_install_replaces_existing_index() {
let fixture = tempfile::tempdir().expect("create fixture");
let index = fixture.path().join("index");
let index_lock = fixture.path().join("index.lock");
fs::write(&index, b"old index").expect("write old index");
fs::write(&index_lock, b"new index").expect("write new index lock");
install_git_index_lock(&index_lock, &index).expect("replace existing index");
assert_eq!(
fs::read(&index).expect("read installed index"),
b"new index"
);
assert!(!index_lock.exists());
}
#[cfg(windows)]
#[test]
fn git_commit_windows_index_install_uses_replace_existing_semantics() {
let fixture = tempfile::tempdir().expect("create fixture");
let index = fixture.path().join("index");
let index_lock = fixture.path().join("index.lock");
fs::write(&index, b"existing windows index").expect("write existing index");
fs::write(&index_lock, b"replacement windows index").expect("write replacement index");
install_git_index_lock(&index_lock, &index)
.expect("MoveFileExW must atomically replace an existing index");
assert_eq!(
fs::read(&index).expect("read replaced windows index"),
b"replacement windows index"
);
assert!(!index_lock.exists());
}
#[test]
fn bounded_git_output_reports_discarded_tail_bytes() {
let input = vec![b'x'; GIT_INSPECT_OUTPUT_MAX_BYTES + 17];
let (collected, truncated) = read_bounded(Some(Cursor::new(input)));
assert_eq!(collected.len(), GIT_INSPECT_OUTPUT_MAX_BYTES);
assert!(truncated);
let (collected, truncated) = read_bounded(Some(Cursor::new(b"short".to_vec())));
assert_eq!(collected, b"short");
assert!(!truncated);
}
}