dcd8901246
新增只能打开游戏聊天页的0.1.1独立release构建配置。 内置Runner启动、诊断日志、Windows后台进程与退出收束。 修复自动预览启动、同页版本刷新和结构化验收状态展示。 强化试玩协议、窗口末端采样与固定失败识别,允许正常输赢但拒绝无法推进。 完善旧验收回执自愈和当前场景指纹完成门。 补齐Rust、前端、真实Chrome回归测试及项目文档。
3120 lines
108 KiB
Rust
3120 lines
108 KiB
Rust
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(¤t, 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(¤t) {
|
||
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);
|
||
}
|
||
}
|