diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 724a9e4..15d0e9e 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -1,5 +1,8 @@ name: Source checks on: [push, pull_request] +concurrency: + group: source-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true permissions: contents: read jobs: @@ -19,8 +22,21 @@ jobs: node-version: 24.19.0 cache: pnpm - uses: dtolnay/rust-toolchain@1.99.0 + - uses: Swatinem/rust-cache@v2 + with: + workspaces: apps/desktop/src-tauri + cache-on-failure: true - run: pnpm install --frozen-lockfile - run: pnpm typecheck + - name: Prepare Windows native write bridge + if: runner.os == 'Windows' + shell: pwsh + run: | + pnpm build + node scripts/build-desktop.mjs --prepare-only + cargo build --locked --manifest-path apps/desktop/src-tauri/Cargo.toml + if ($LASTEXITCODE -ne 0) { throw 'Native write bridge build failed' } + "GIT_VIEW_NATIVE_HELPER=$env:GITHUB_WORKSPACE\apps\desktop\src-tauri\target\debug\git-view-desktop.exe" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: macOS full tests including HTTP transport if: runner.os == 'macOS' run: pnpm test diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 3df6178..095c901 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -34,6 +34,15 @@ jobs: cache-on-failure: true - run: pnpm install --frozen-lockfile - run: pnpm typecheck + - name: Prepare Windows native write bridge + if: runner.os == 'Windows' + shell: pwsh + run: | + pnpm build + node scripts/build-desktop.mjs --prepare-only + cargo build --locked --manifest-path apps/desktop/src-tauri/Cargo.toml + if ($LASTEXITCODE -ne 0) { throw 'Native write bridge build failed' } + "GIT_VIEW_NATIVE_HELPER=$env:GITHUB_WORKSPACE\apps\desktop\src-tauri\target\debug\git-view-desktop.exe" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: macOS full tests including HTTP transport if: runner.os == 'macOS' run: pnpm test diff --git a/README.md b/README.md index 77682ed..0f99d51 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Git View 是一个本地 Git 图形工具,用于查看文件差异、浏览提交历史,以及比较分支和提交。支持整文件暂存、提交和本地分支操作,写入前需要预览确认。 -支持中文和英文界面,通过本机 Git 读取仓库,无需连接远程服务。目前为桌面预览版,提供 macOS Apple silicon 和 Windows x64 安装包;Windows 暂时只开放查看。 +支持中文和英文界面,通过本机 Git 读取仓库,无需连接远程服务。目前为桌面预览版,提供 macOS Apple silicon 和 Windows x64 安装包。 ![提交历史与文件差异,浏览器中的演示仓库](docs/verification/screenshots/history-simplification-browser.png) @@ -25,7 +25,7 @@ Git View 是一个本地 Git 图形工具,用于查看文件差异、浏览提 在 [GitHub Releases](https://github.com/XSY-28/git-view/releases/latest) 下载当前版本的安装包和 `.sha256` 校验文件,无需登录 GitHub。两种包都自带 Node 运行时,使用时只需系统 Git,无需另装 Node、pnpm 或 Rust。 - **macOS Apple silicon**:下载 `macos-arm64.dmg`,打开后先退出 Git View,再双击 **Install Git View.command**。安装位置固定为 `~/Applications/Git View.app`,旧版本保留为 ZIP;完成后弹出磁盘映像。不要再复制一份到 `/Applications`。 -- **Windows x64**:下载 `windows-x64-setup.exe`,运行安装向导,再从开始菜单打开。需要 Git for Windows 与 WebView2;缺少 WebView2 时安装程序会下载运行时。Windows 仅支持查看,不开放暂存、提交和分支写入。 +- **Windows x64**:下载 `windows-x64-setup.exe`,运行安装向导,再从开始菜单打开。需要 Git for Windows 与 WebView2;缺少 WebView2 时安装程序会下载运行时。Windows 桌面应用支持暂存、取消暂存、提交、创建和切换分支。 当前包尚未代码签名或 Apple 公证,系统可能提示无法验证发布者。确认下载来源后,使用系统提供的“仍要运行”或“隐私与安全性 → 仍要打开”入口,不要关闭系统安全机制。完整步骤与校验、卸载说明见[安装包说明](docs/INSTALL.md)。 @@ -86,7 +86,7 @@ open "$HOME/Applications/Git View.app" ## 支持范围 -macOS Apple silicon 已有桌面实机验证。Windows 暂时仅开放查看,已通过 Windows CI 中的实际安装、原生窗口差异与历史走查、正常退出和卸载验证;Windows 10/11 实体电脑尚未复测。详见[安装包验收记录](docs/verification/2026-10-07-desktop-installers.md)。 +macOS Apple silicon 已有桌面实机验证。Windows 通过 CI 验证实际安装、原生窗口及仓库操作;Windows 10/11 实体电脑尚未复测。详见[安装包验收记录](docs/verification/2026-10-07-windows-operations.md)。 - 暂不提供 push、merge、rebase 或历史重写。远程跟踪引用来自本地仓库,不会自动获取远端更新。 - 暂存按整个文件操作,不支持选择差异行或片段。写入限常规仓库和 index;冲突、进行中的 Git 操作、submodule、特殊 index 或外部内容过滤器会被拒绝。暂不支持 `text`、`eol`、`ident`、`working-tree-encoding` 和 autocrlf 转换。 diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 18ce4e8..3ba11cf 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -1281,7 +1281,7 @@ dependencies = [ [[package]] name = "git-view-desktop" -version = "0.2.0" +version = "0.3.0" dependencies = [ "libc", "serde_json", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index a8fb154..25dec64 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "git-view-desktop" -version = "0.2.0" +version = "0.3.0" edition = "2021" description = "Desktop host for local Git viewing and previewed operations" license = "MIT" @@ -20,7 +20,7 @@ uuid = { version = "1", features = ["v4"] } libc = "0.2" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_Security"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp"] } [profile.release] strip = true diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 491fc7d..57fce03 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -4,6 +4,8 @@ )] mod config; mod sidecar; +#[cfg(windows)] +mod windows_platform; use serde_json::{json, Value}; use std::{ @@ -129,6 +131,11 @@ fn main() { libc::umask(0o077); } let args: Vec = std::env::args().skip(1).collect(); + #[cfg(windows)] + if args.as_slice() == ["--windows-platform"] { + windows_platform::main(); + return; + } if args.first().map(String::as_str) == Some("inspect") { // This branch exits before any Tauri builder, webview or dialog is created. let status = config::cli_resources().and_then(|resources| { diff --git a/apps/desktop/src-tauri/src/sidecar.rs b/apps/desktop/src-tauri/src/sidecar.rs index 72fcdaf..2932ed7 100644 --- a/apps/desktop/src-tauri/src/sidecar.rs +++ b/apps/desktop/src-tauri/src/sidecar.rs @@ -68,6 +68,10 @@ impl Sidecar { } #[cfg(windows)] { + command.env( + "GIT_VIEW_NATIVE_HELPER", + std::env::current_exe().map_err(|_| "Cannot locate native helper")?, + ); use std::os::windows::process::CommandExt; // The GUI host has no console; keep its piped background runtime quiet. command.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW); @@ -266,12 +270,12 @@ fn reap(running: Running) { } #[cfg(windows)] -struct WindowsJob(windows_sys::Win32::Foundation::HANDLE); +pub(crate) struct WindowsJob(windows_sys::Win32::Foundation::HANDLE); #[cfg(windows)] unsafe impl Send for WindowsJob {} #[cfg(windows)] impl WindowsJob { - fn assign(child: &Child) -> Result { + pub(crate) fn assign(child: &Child) -> Result { use std::os::windows::io::AsRawHandle; use windows_sys::Win32::{Foundation::CloseHandle, System::JobObjects::*}; unsafe { diff --git a/apps/desktop/src-tauri/src/windows_platform.rs b/apps/desktop/src-tauri/src/windows_platform.rs new file mode 100644 index 0000000..e874048 --- /dev/null +++ b/apps/desktop/src-tauri/src/windows_platform.rs @@ -0,0 +1,502 @@ +//! Private, bounded OS bridge. No web command exposes this entry point. +use serde_json::{json, Value}; +use std::os::windows::{fs::MetadataExt, process::CommandExt}; +use std::{ + fs, + io::{Read, Write}, + path::{Path, PathBuf}, + ptr, thread, + time::{Duration, Instant}, +}; +use windows_sys::Win32::{ + Foundation::*, + Security::{Authorization::*, *}, + Storage::FileSystem::*, + System::{Diagnostics::ToolHelp::*, Threading::*}, +}; + +struct Local(*mut core::ffi::c_void); +impl Drop for Local { + fn drop(&mut self) { + unsafe { + LocalFree(self.0); + } + } +} +fn wide(s: &str) -> Vec { + s.encode_utf16().chain(Some(0)).collect() +} +fn error() -> String { + std::io::Error::last_os_error().to_string() +} +unsafe fn sid_string(sid: PSID) -> Result { + let mut p = ptr::null_mut(); + if ConvertSidToStringSidW(sid, &mut p) == 0 { + return Err(error()); + } + let _memory = Local(p as _); + let mut n = 0; + while *p.add(n) != 0 { + n += 1; + } + Ok(String::from_utf16_lossy(std::slice::from_raw_parts(p, n))) +} +fn user_sid() -> Result { + unsafe { + let mut token = ptr::null_mut(); + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 { + return Err(error()); + } + let result = (|| { + let mut len = 0; + GetTokenInformation(token, TokenUser, ptr::null_mut(), 0, &mut len); + let mut data = vec![0u64; (len as usize + 7) / 8]; + if GetTokenInformation(token, TokenUser, data.as_mut_ptr() as _, len, &mut len) == 0 { + return Err(error()); + } + sid_string((*(data.as_ptr() as *const TOKEN_USER)).User.Sid) + })(); + CloseHandle(token); + result + } +} +fn no_reparse(path: &Path) -> Result<(), String> { + if !path.is_absolute() { + return Err("Absolute paths required".into()); + } + for ancestor in path.ancestors() { + if fs::symlink_metadata(ancestor) + .map_err(|e| e.to_string())? + .file_attributes() + & FILE_ATTRIBUTE_REPARSE_POINT + != 0 + { + return Err( + "Reparse points are not allowed in private state or replacement paths".into(), + ); + } + } + Ok(()) +} +fn private(path: &Path, sid: &str, tighten: bool) -> Result<(), String> { + no_reparse(path)?; + let text = path.to_str().ok_or("Non-Unicode path")?; + let name = wide(text); + unsafe { + let mut owner = ptr::null_mut(); + let mut acl = ptr::null_mut(); + let mut sd = ptr::null_mut(); + let code = GetNamedSecurityInfoW( + name.as_ptr(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &mut owner, + ptr::null_mut(), + &mut acl, + ptr::null_mut(), + &mut sd, + ); + if code != 0 { + return Err(format!("Security descriptor error {code}")); + } + let _original = Local(sd); + let owner = sid_string(owner)?; + if owner != sid && owner != "S-1-5-32-544" { + return Err("Private state belongs to a different account".into()); + } + if tighten { + let sddl = wide(&format!( + "D:P(A;OICI;FA;;;{sid})(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" + )); + let mut secure = ptr::null_mut(); + if ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + 1, + &mut secure, + ptr::null_mut(), + ) == 0 + { + return Err(error()); + } + let _secure = Local(secure); + let mut present = 0; + let mut defaulted = 0; + let mut dacl = ptr::null_mut(); + if GetSecurityDescriptorDacl(secure, &mut present, &mut dacl, &mut defaulted) == 0 + || present == 0 + || dacl.is_null() + { + return Err("Missing protected DACL".into()); + } + let code = SetNamedSecurityInfoW( + name.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + ptr::null_mut(), + ptr::null_mut(), + dacl, + ptr::null_mut(), + ); + if code != 0 { + return Err(format!("Cannot protect private state: {code}")); + } + return Ok(()); + } + if acl.is_null() { + return Err("Unrestricted private state DACL".into()); + } + for i in 0..(*acl).AceCount as u32 { + let mut ace = ptr::null_mut(); + if GetAce(acl, i, &mut ace) == 0 { + return Err(error()); + } + let header = &*(ace as *const ACE_HEADER); + if header.AceType == 1 { + continue; + } // DENY never grants access. + if header.AceType != 0 { + return Err("Unsupported private state access rule".into()); + } + let allowed = &*(ace as *const ACCESS_ALLOWED_ACE); + let principal = sid_string(&allowed.SidStart as *const u32 as PSID)?; + if principal != sid && principal != "S-1-5-18" && principal != "S-1-5-32-544" { + return Err("Private state grants access to another principal".into()); + } + } + } + Ok(()) +} +fn replace(source: &Path, target: &Path) -> Result<(), String> { + if source.parent() != target.parent() { + return Err("Replacement must stay in one directory".into()); + } + no_reparse(source)?; + no_reparse(target.parent().ok_or("Missing parent")?)?; + if target.exists() { + no_reparse(target)?; + } + let source = wide(source.to_str().ok_or("Non-Unicode path")?); + let target = wide(target.to_str().ok_or("Non-Unicode path")?); + for attempt in 0..8 { + unsafe { + if MoveFileExW( + source.as_ptr(), + target.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) != 0 + { + return Ok(()); + } + let code = GetLastError(); + if ![ERROR_SHARING_VIOLATION, ERROR_ACCESS_DENIED].contains(&code) || attempt == 7 { + return Err(format!("File replacement failed: {code}")); + } + } + thread::sleep(Duration::from_millis(25)); + } + unreachable!() +} +fn resume(pid: u32) -> Result<(), String> { + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0); + if snapshot == INVALID_HANDLE_VALUE { + return Err(error()); + } + let result = (|| { + let mut entry: THREADENTRY32 = std::mem::zeroed(); + entry.dwSize = std::mem::size_of_val(&entry) as u32; + let mut next = Thread32First(snapshot, &mut entry); + while next != 0 { + if entry.th32OwnerProcessID == pid { + let handle = OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID); + if handle.is_null() { + return Err(error()); + } + let count = ResumeThread(handle); + CloseHandle(handle); + return if count == u32::MAX { + Err(error()) + } else { + Ok(()) + }; + } + next = Thread32Next(snapshot, &mut entry); + } + Err("Suspended Git thread not found".into()) + })(); + CloseHandle(snapshot); + result + } +} +fn string<'a>(v: &'a Value, key: &str) -> Result<&'a str, String> { + let s = v[key].as_str().ok_or_else(|| format!("Missing {key}"))?; + if s.contains('\0') { + return Err("NUL is not allowed".into()); + } + Ok(s) +} +fn run_git(v: &Value) -> Result { + use std::process::{Command, Stdio}; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + let cwd = PathBuf::from(string(v, "cwd")?); + if !cwd.is_absolute() { + return Err("Absolute cwd required".into()); + } + let marker = string(v, "marker")?; + if !marker.starts_with("git-view:") || marker.len() != 45 { + return Err("Invalid operation marker".into()); + } + let kind = string(v, "kind")?; + let (args, input): (Vec, Option) = match kind { + "commit" => ( + vec![ + "commit".into(), + "--file=-".into(), + "--cleanup=verbatim".into(), + "--no-status".into(), + ], + Some(string(v, "message")?.into()), + ), + "create-branch" => { + let b = string(v, "branch")?; + let oid = string(v, "oid")?; + if ![40, 64].contains(&oid.len()) || !oid.bytes().all(|c| c.is_ascii_hexdigit()) { + return Err("Invalid OID".into()); + } + ( + vec![ + "update-ref".into(), + "--no-deref".into(), + "--create-reflog".into(), + "-m".into(), + marker.into(), + "--stdin".into(), + ], + Some(format!("create refs/heads/{b} {oid}\n")), + ) + } + "switch-branch" => ( + vec![ + "switch".into(), + "--no-guess".into(), + "--no-recurse-submodules".into(), + "--no-overwrite-ignore".into(), + "--".into(), + string(v, "branch")?.into(), + ], + None, + ), + _ => return Err("Unsupported Git write command".into()), + }; + let mut child = Command::new("git") + .args([ + "--no-pager", + "--literal-pathspecs", + "-c", + "core.fsmonitor=false", + "-c", + "core.untrackedCache=false", + "-c", + "submodule.recurse=false", + "-c", + "protocol.allow=never", + "-c", + "core.logAllRefUpdates=true", + "-c", + "gc.auto=0", + "-c", + "maintenance.auto=false", + ]) + .args(args) + .current_dir(cwd) + .creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| e.to_string())?; + let job = match crate::sidecar::WindowsJob::assign(&child) { + Ok(job) => job, + Err(e) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(e); + } + }; + if let Err(e) = resume(child.id()) { + drop(job); + let _ = child.wait(); + return Err(e); + } + let mut job = Some(job); + let overflow = Arc::new(AtomicUsize::new(0)); + fn reader( + mut stream: impl Read + Send + 'static, + overflow: Arc, + ) -> thread::JoinHandle { + thread::spawn(move || { + let mut tail = Vec::new(); + let mut chunk = [0u8; 4096]; + while let Ok(n) = stream.read(&mut chunk) { + if n == 0 { + break; + } + overflow.fetch_add(n, Ordering::SeqCst); + tail.extend_from_slice(&chunk[..n]); + if tail.len() > 8000 { + tail.drain(..tail.len() - 8000); + } + } + String::from_utf8_lossy(&tail).into_owned() + }) + } + let out = reader(child.stdout.take().unwrap(), overflow.clone()); + let err = reader(child.stderr.take().unwrap(), overflow.clone()); + let stdin = child.stdin.take().unwrap(); + let input_writer = thread::spawn(move || { + let mut stdin = stdin; + if let Some(input) = input { + let _ = stdin.write_all(input.as_bytes()); + } + }); + let deadline = Instant::now() + + Duration::from_millis(v["timeoutMs"].as_u64().unwrap_or(60000).clamp(1, 120000)); + let mut interrupted = false; + let status = loop { + if let Some(status) = child.try_wait().map_err(|e| e.to_string())? { + break status; + } + if Instant::now() >= deadline || overflow.load(Ordering::SeqCst) > 1024 * 1024 { + interrupted = true; + drop(job.take()); + break child.wait().map_err(|e| e.to_string())?; + } + thread::sleep(Duration::from_millis(10)); + }; + // Dropping a normal-result job also ends any detached descendants holding pipes. + // The job was moved only on the interrupted path. + drop(job.take()); + let _ = input_writer.join(); + let stdout = out.join().unwrap_or_default(); + let stderr = err.join().unwrap_or_default(); + interrupted |= overflow.load(Ordering::SeqCst) > 1024 * 1024; + let diagnostic = format!( + "{}{}{}", + if interrupted { + "Git 或其 hooks/签名程序超时或输出超限,已终止 Windows 进程树。\n" + } else { + "" + }, + stdout, + stderr + ); + Ok(json!({"code":status.code(),"diagnostic":diagnostic,"interrupted":interrupted})) +} +fn dispatch(v: &Value) -> Result { + let operation = string(v, "operation")?; + if operation == "run-git" { + return run_git(v); + } + let path = PathBuf::from(string(v, "path")?); + match operation { + "private-directory" => { + if !path.is_dir() { + return Err("Private directory missing".into()); + } + private(&path, &user_sid()?, true)?; + private(&path, &user_sid()?, false)?; + } + "check-private" => private(&path, &user_sid()?, false)?, + "replace" => replace(&path, &PathBuf::from(string(v, "target")?))?, + _ => return Err("Unsupported filesystem operation".into()), + } + Ok(json!(true)) +} +pub fn main() { + let mut input = String::new(); + let result = std::io::stdin() + .take(512 * 1024) + .read_to_string(&mut input) + .map_err(|e| e.to_string()) + .and_then(|_| serde_json::from_str::(&input).map_err(|e| e.to_string())) + .and_then(|v| dispatch(&v)); + let response = match result { + Ok(data) => json!({"ok":true,"data":data}), + Err(message) => json!({"ok":false,"error":message}), + }; + println!("{response}"); +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn private_acl_and_write_through_replacement_work_on_unicode_paths() { + let root = + std::env::temp_dir().join(format!("git-view-native-中文-{}", uuid::Uuid::new_v4())); + fs::create_dir(&root).unwrap(); + let result = (|| -> Result<(), String> { + let sid = user_sid()?; + private(&root, &sid, true)?; + private(&root, &sid, false)?; + let old = root.join("回执.json"); + let new = root.join("回执.tmp"); + fs::write(&old, b"old").map_err(|e| e.to_string())?; + fs::write(&new, b"new").map_err(|e| e.to_string())?; + private(&old, &sid, false)?; + replace(&new, &old)?; + assert_eq!(fs::read(&old).unwrap(), b"new"); + assert!(!new.exists()); + private(&old, &sid, false)?; + Ok(()) + })(); + let _ = fs::remove_dir_all(&root); + result.unwrap(); + } + #[test] + fn refuses_replacement_and_private_reads_through_a_directory_junction() { + let root = std::env::temp_dir().join(format!("git-view-junction-{}", uuid::Uuid::new_v4())); + fs::create_dir(&root).unwrap(); + let target = root.join("target"); + fs::create_dir(&target).unwrap(); + fs::write(target.join("source"), b"original").unwrap(); + let link = root.join("link"); + let result = (|| -> Result<(), String> { + let output = std::process::Command::new("cmd.exe") + .args(["/D", "/C", "mklink", "/J"]) + .arg(&link) + .arg(&target) + .output() + .map_err(|e| e.to_string())?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).into_owned()); + } + assert!(private(&link.join("source"), &user_sid()?, false).is_err()); + assert!(replace(&link.join("source"), &link.join("destination")).is_err()); + assert_eq!(fs::read(target.join("source")).unwrap(), b"original"); + Ok(()) + })(); + let _ = fs::remove_dir_all(&root); + result.unwrap(); + } + #[test] + fn rejects_a_private_receipt_with_an_everyone_allow_rule() { + let root = std::env::temp_dir().join(format!("git-view-acl-{}", uuid::Uuid::new_v4())); + fs::create_dir(&root).unwrap(); + let sid = user_sid().unwrap(); + private(&root, &sid, true).unwrap(); + let file = root.join("receipt.json"); + fs::write(&file, b"{}").unwrap(); + let output = std::process::Command::new("icacls") + .arg(&file) + .args(["/grant", "*S-1-1-0:(R)"]) + .output() + .unwrap(); + assert!(output.status.success()); + assert!(private(&file, &sid, false).is_err()); + let _ = fs::remove_dir_all(&root); + } +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 3e3fbac..898d588 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Git View", - "version": "0.2.0", + "version": "0.3.0", "identifier": "local.git-view.desktop", "mainBinaryName": "git-view-desktop", "build": { "frontendDist": "../../../dist/web" }, diff --git a/apps/web/src/i18n/messages.ts b/apps/web/src/i18n/messages.ts index 1e423d2..654f957 100644 --- a/apps/web/src/i18n/messages.ts +++ b/apps/web/src/i18n/messages.ts @@ -565,7 +565,7 @@ Object.assign(englishMessages, { "index 锁的所有权已变化,未安装结果。": "The index lock ownership changed. The result was not installed.", "未选文件的 index 发生变化,已放弃安装。": "Index entries for unselected files changed. Installation was abandoned.", "暂存结果 index 缺失。": "The staging result index is missing.", - "Windows 暂未开放 Git 写入;仓库查看仍可使用。": "Git writes are unavailable on Windows. Repository viewing remains available.", + "Windows 写入需要桌面原生组件,请使用完整安装包。": "Windows writes require the native desktop component. Use the complete installer.", "Git 或其 hooks/签名程序超时或输出超限,已终止进程组。\n": "Git or its hooks/signing program timed out or exceeded the output limit. The process group was terminated.\n", "分支名称无效。": "Invalid branch name.", "分支名称不符合 Git 命名规则。": "The branch name does not follow Git naming rules.", diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 1a32567..76f7aef 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -5,7 +5,7 @@ ## macOS Apple silicon 1. 确认系统 Git 可用,在终端执行 `git --version`。如果系统提示安装 Command Line Tools,按提示完成;使用安装包不需要 Node、pnpm、Rust 或完整 Xcode。 -2. 下载 `Git-View_0.2.0_macos-arm64.dmg`。可在下载目录执行 `shasum -a 256 -c Git-View_0.2.0_macos-arm64.dmg.sha256` 核对文件。 +2. 下载 `Git-View_0.3.0_macos-arm64.dmg`。可在下载目录执行 `shasum -a 256 -c Git-View_0.3.0_macos-arm64.dmg.sha256` 核对文件。 3. 退出正在运行的 Git View,打开 DMG,双击 `Install Git View.command`。安装无需管理员权限,固定安装到 `~/Applications/Git View.app`,完成后自动打开应用。 4. 安装完成后弹出 DMG。不要把另一份 Git View.app 放在 `/Applications`,也不要保留解压的备份应用;这样能避免多个同名搜索入口。 @@ -20,11 +20,11 @@ ## Windows x64 1. 安装 [Git for Windows](https://gitforwindows.org/),确保新开的终端中 `git --version` 能成功。 -2. 下载 `Git-View_0.2.0_windows-x64-setup.exe`。用 PowerShell 执行 `Get-FileHash .\Git-View_0.2.0_windows-x64-setup.exe -Algorithm SHA256`,与同版本 `.sha256` 文件中的哈希比较。 +2. 下载 `Git-View_0.3.0_windows-x64-setup.exe`。用 PowerShell 执行 `Get-FileHash .\Git-View_0.3.0_windows-x64-setup.exe -Algorithm SHA256`,与同版本 `.sha256` 文件中的哈希比较。 3. 运行安装向导。应用安装到当前用户目录,不要求管理员权限;缺少 Microsoft WebView2 时安装程序会下载运行时,因此首次安装可能需要联网。 4. 从开始菜单打开 Git View,点击顶部打开仓库按钮选择本地仓库,或展开“手动输入路径”填入仓库的完整路径。 -Windows 当前只支持查看,包括当前改动、历史、版本比较和历史调查。暂存、取消暂存、提交、创建和切换分支均由后台明确拒绝。浏览器 HTTP 备用入口暂不支持 Windows,请使用桌面应用。 +Windows 桌面应用支持查看和预览确认后的暂存、取消暂存、提交、创建和切换本地分支。写入使用安装包内的原生组件;仅使用 Node CLI 不提供 Windows 写入。Git 内置 CRLF/text/eol 转换受支持;外部过滤器、ident、working-tree-encoding 和未启用符号链接的含符号链接仓库仍会拒绝写入。浏览器 HTTP 备用入口暂不支持 Windows,请使用桌面应用。 当前 EXE 未做代码签名,Windows 可能显示未知发布者或 SmartScreen 提示。仅在核对 GitHub 下载来源和哈希后使用系统提供的“更多信息 → 仍要运行”入口,不要关闭 SmartScreen。若设备的组织策略禁止未知发布者,需要遵循该设备的策略。 @@ -35,6 +35,6 @@ Windows 当前只支持查看,包括当前改动、历史、版本比较和历 包构建工作流分别在 macOS ARM 与 Windows x64 runner 上执行类型检查、适用的真实 Git 测试和 Rust 宿主测试,再构建安装包。 - macOS:验证 DMG 完整性和挂载、执行包内安装程序 dry-run,并对从 DMG 解压出的内容验证内置 Node、中文/空格路径、仓库只读指纹和 stdio 退出。原生 GUI 在本机固定安装路径验收。 -- Windows:运行真正的 NSIS 静默安装到包含中文和空格的目录,对安装结果做包内运行检查;启动安装后的 release 应用,用 WebView2 调试连接驱动真实窗口和 Tauri IPC,检查打开中文路径仓库、暂存/未暂存差异、历史差异、正常关闭与 Node 子进程回收,最后卸载。 +- Windows:运行真正的 NSIS 静默安装到包含中文和空格的目录,对安装结果做包内运行检查;启动安装后的 release 应用,用 WebView2 调试连接驱动真实窗口和 Tauri IPC,检查打开中文路径仓库、暂存/未暂存差异、历史差异、CRLF 文件的暂存与取消暂存、提交、创建与切换分支、正常关闭与 Node 子进程回收,最后卸载。 这些检查的通过与失败以该版本对应的 GitHub Actions 和随包验证报告为准。Windows runner 是 CI 系统,不能替代所有 Windows 10/11 实体电脑上的验收;SmartScreen、Gatekeeper、人工安装向导和原生系统目录选择对话框也需要目标设备复测。当前安装包没有自动更新机制。 diff --git a/docs/verification/2026-10-07-windows-operations.md b/docs/verification/2026-10-07-windows-operations.md new file mode 100644 index 0000000..de456de --- /dev/null +++ b/docs/verification/2026-10-07-windows-operations.md @@ -0,0 +1,21 @@ +# Windows 仓库操作与 0.3.0 安装包验收 + +0.3.0 为 Windows x64 桌面应用增加整文件暂存、取消暂存、普通提交、创建本地分支及切换分支。所有操作仍先预览,再由用户确认;提交和分支操作另须明确允许仓库 hooks 与签名程序。 + +## 实现与验证对象 + +- Windows 回执目录使用当前用户、SYSTEM 和 Administrators 的受限 ACL,拒绝重解析路径和额外的允许访问规则。使用原生文件替换发布回执和 index,不调用 POSIX 目录 fsync。 +- Git 内置 autocrlf/text/eol 转换使用预览时捕获的配置和属性,在隔离的临时 Git 元数据中运行;拒绝外部过滤器、ident 和 working-tree-encoding。 +- Git/hooks/签名程序在启动执行前加入 Windows Job Object;超时、输出超限和宿主退出时回收进程树。 +- 真实仓库测试检查 CRLF 与中文路径、未选中内容保留、重复请求、过期预览、锁冲突、权限拒绝、提交及分支操作,以及进程被强制终止后的结果恢复。 +- 安装验收用 NSIS 实际安装,再通过 WebView2 驱动安装后的 release 原生窗口,完成查看、五种写入操作和正常退出;检查实际 Git 内容与分支状态,最后卸载。 + +## 验收证据 + +最终构建的来源提交、安装包 SHA256 和各检查结果以同版本发布资产中的 `release-verification.json`、平台包报告及原生窗口报告为准。源码和安装包工作流:[Source checks](https://github.com/XSY-28/git-view/actions/workflows/check.yml)、[Desktop packages](https://github.com/XSY-28/git-view/actions/workflows/package.yml)。 + +## 边界 + +Windows CI runner 的真实安装和原生窗口验收不能替代所有 Windows 10/11 实体电脑测试。Windows ARM、Intel Mac 和 Linux 安装包、签名、公证、自动更新、人工系统警告与文件夹选择器验收均不在本次结果中。Windows 浏览器 HTTP 备用入口仍不支持;独立 Node CLI 缺少桌面原生组件时拒绝 Windows 写入。 + +上述恢复测试针对进程中断,不能证明断电下的持久性。合并、变基、挑选、冲突解决、推送与拉取没有加入本批写入功能。此前 0.2.0 的“Windows 仅查看”说明仍属于该旧版本。 diff --git a/packages/git-write/src/filesystem.ts b/packages/git-write/src/filesystem.ts new file mode 100644 index 0000000..602de35 --- /dev/null +++ b/packages/git-write/src/filesystem.ts @@ -0,0 +1,54 @@ +import { execFile } from 'node:child_process'; +import { constants, type Stats } from 'node:fs'; +import { lstat, open, rename } from 'node:fs/promises'; +import path from 'node:path'; +import { QueryError } from '@git-view/contracts'; +export function windowsHelper(): string | undefined { + const helper = process.env.GIT_VIEW_NATIVE_HELPER; + return helper && path.isAbsolute(helper) ? helper : undefined; +} +export function windowsPlatform(request: Record, env = process.env): Promise { + const helper = windowsHelper(); + if (!helper) throw new QueryError('UNSUPPORTED_REPOSITORY', 'Windows 写入需要桌面原生组件,请使用完整安装包。'); + return new Promise((resolve, reject) => { + const child = execFile(helper, ['--windows-platform'], { env, windowsHide: true, timeout: 150_000, maxBuffer: 2 * 1024 * 1024 }, (error, stdout) => { + if (error) return reject(new QueryError('INTERNAL_ERROR', `Windows 原生操作失败:${error.message}`)); + try { + const result = JSON.parse(stdout); + if (result.ok !== true) throw new QueryError('PERMISSION_DENIED', String(result.error)); + resolve(result.data); + } catch (failure) { reject(failure); } + }); + child.stdin?.on('error', () => {}); child.stdin?.end(JSON.stringify(request)); + }); +} +export async function privateDirectoryPermissions(directory: string): Promise { + await windowsPlatform({ operation: 'private-directory', path: directory }); +} +export async function checkPrivate(file: string, stat: Stats, directory: boolean): Promise { + if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile())) throw new QueryError('PERMISSION_DENIED', '操作回执路径不是普通文件或目录。'); + if (process.platform === 'win32') await windowsPlatform({ operation: 'check-private', path: file }); + else if ((process.getuid && stat.uid !== process.getuid()) || (stat.mode & 0o077)) throw new QueryError('PERMISSION_DENIED', '操作回执目录或文件不安全。'); +} +export async function syncDirectory(directory: string): Promise { + // Windows publishes replacement files with MOVEFILE_WRITE_THROUGH. Pending + // marker/lock deletion is conservative: a surviving marker is reconciled from + // the flushed receipt; never use the POSIX directory-fsync API on Windows. + if (process.platform === 'win32') return; + const handle = await open(directory, constants.O_RDONLY | constants.O_NOFOLLOW); + try { await handle.sync(); } finally { await handle.close(); } +} +export async function replaceFile(source: string, target: string): Promise { + if (process.platform === 'win32') await windowsPlatform({ operation: 'replace', path: source, target }); + else await rename(source, target); +} +export async function openRead(file: string) { + const before = await lstat(file, { bigint: true }); + if (!before.isFile() || before.isSymbolicLink()) throw new QueryError('PERMISSION_DENIED', '拒绝读取链接或特殊元数据文件。'); + const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const opened = await handle.stat({ bigint: true }); + if (!opened.isFile() || before.dev !== opened.dev || before.ino !== opened.ino) throw new QueryError('STALE_RESULT', '元数据路径已变化。'); + return handle; + } catch (error) { await handle.close(); throw error; } +} diff --git a/packages/git-write/src/index.ts b/packages/git-write/src/index.ts index ffffffe..7f5ebe3 100644 --- a/packages/git-write/src/index.ts +++ b/packages/git-write/src/index.ts @@ -1,5 +1,5 @@ import { constants } from 'node:fs'; -import { lstat, open, readlink, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'; +import { lstat, open, readlink, mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -8,6 +8,7 @@ import { QueryError, repositorySchema, type ChangeEntry, type RepositoryIdentity import { createGitAdapter } from '../../git-cli/src/index.js'; import { runPlumbing } from './runner.js'; import { requireIndexWrites } from './platform.js'; +import { openRead, replaceFile, syncDirectory } from './filesystem.js'; const MAX_FILE_BYTES = 64 * 1024 * 1024; const MAX_INDEX_BYTES = 32 * 1024 * 1024; @@ -46,7 +47,7 @@ export async function optional(file: string, limit = MAX_INDEX_BYTES): Promise limit) throw new QueryError('OUTPUT_LIMIT', '仓库元数据超过安全上限。'); - const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW); + const handle = await openRead(file); try { const bytes = Buffer.alloc(Math.min(info.size + 1, limit + 1)); let total = 0; @@ -86,7 +87,7 @@ async function fileSnapshot(repository: RepositoryIdentity, name: string): Promi if (before.isSymbolicLink()) { bytes = await readlink(full, { encoding: 'buffer' }); kind = 'symlink'; } else if (before.isFile()) { if (before.size > BigInt(MAX_FILE_BYTES)) throw new QueryError('OUTPUT_LIMIT', '单个暂存文件超过 64 MiB,暂不支持。'); - const handle = await open(full, constants.O_RDONLY | constants.O_NOFOLLOW); + const handle = await openRead(full); try { const opened = await handle.stat({ bigint: true }); if (opened.dev !== before.dev || opened.ino !== before.ino || !opened.isFile()) stale(); @@ -155,8 +156,6 @@ export async function readWriteState(repository: RepositoryIdentity, paths: stri for (const directory of [repository.worktreeRoot, repository.gitDir, repository.commonGitDir]) { const info = await lstat(directory, { bigint: true }); identities.push(`${directory}:${info.dev}:${info.ino}`); } for (const operation of operations) { try { await lstat(path.join(repository.gitDir, operation)); fail('合并、变基、挑选等进行中的操作必须先结束,暂不修改 index。'); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } } const config = await configGuard(repository, signal); - if (kind === 'stage-files' && !falseValue(config.values.get('core.autocrlf') ?? 'false')) fail('首批整文件暂存不支持 core.autocrlf 转换;请使用 Git 完成此类暂存。'); - if (falseValue(config.values.get('core.symlinks') ?? 'true')) fail('暂不支持 core.symlinks=false 的工作区写入。'); const headOid = (await runPlumbing(repository, ['rev-parse', '--verify', '--quiet', 'HEAD'], { signal, allowMissing: true })).toString('ascii').trim() || null; const head = await optional(path.join(repository.gitDir, 'HEAD')); const objectFormat = (await runPlumbing(repository, ['rev-parse', '--show-object-format'], { signal })).toString('ascii').trim(); @@ -164,7 +163,9 @@ export async function readWriteState(repository: RepositoryIdentity, paths: stri inspectIndex(index, objectFormat === 'sha256' ? 32 : 20); const indexEntries = parseEntries(await runPlumbing(repository, ['ls-files', '--stage', '-z'], { signal })); const headEntries = headOid ? parseEntries(await runPlumbing(repository, ['ls-tree', '-r', '-z', '--full-tree', headOid], { signal }), true) : []; + if (falseValue(config.values.get('core.symlinks') ?? 'true') && [...indexEntries, ...headEntries].some(entry => entry.mode === '120000')) fail('core.symlinks=false 的仓库含符号链接条目,暂不支持写入。'); const attrs: string[] = []; + const normalization = new Map(); const allFiles = await runPlumbing(repository, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { signal }); for (const cached of [false, true]) { if (allFiles.length && config.filters.size) { @@ -178,7 +179,11 @@ export async function readWriteState(repository: RepositoryIdentity, paths: stri if (triples.length % 3) fail('无法可靠读取文件属性。'); for (let i = 0; i < triples.length; i += 3) { const attribute = triples[i + 1], value = triples[i + 2]; - if (!['unspecified', 'unset'].includes(value!) && (attribute === 'filter' || kind === 'stage-files')) throw new QueryError('UNSUPPORTED_FILTER', `首批整文件暂存不支持 ${attribute}=${value} 内容转换;请使用 Git 完成此类暂存。`); + if (!cached && (attribute === 'text' || attribute === 'eol')) { + const settings = normalization.get(triples[i]!) ?? { text: 'unspecified', eol: 'unspecified' }; + settings[attribute] = value!; normalization.set(triples[i]!, settings); + } + if (!['unspecified', 'unset'].includes(value!) && (attribute === 'filter' || (kind === 'stage-files' && !['text', 'eol'].includes(attribute!)))) throw new QueryError('UNSUPPORTED_FILTER', `首批整文件暂存不支持 ${attribute}=${value} 内容转换;请使用 Git 完成此类暂存。`); } } const identityGuard = { identities, config: config.hash, head: head?.toString('base64'), headOid }; @@ -186,7 +191,7 @@ export async function readWriteState(repository: RepositoryIdentity, paths: stri // A receipt verifies the resulting index, HEAD and identity. Working-tree attribute // edits may legitimately change cached attributes as part of the installed index. const evidenceGuard = digest(JSON.stringify(identityGuard)); - return { guard, evidenceGuard, indexHash: digest(index ?? 'missing'), index, indexEntries, headEntries, headOid, filemode: !falseValue(config.values.get('core.filemode') ?? 'true') }; + return { guard, evidenceGuard, indexHash: digest(index ?? 'missing'), index, indexEntries, headEntries, headOid, normalization, conversionConfig: { autocrlf: config.values.get('core.autocrlf') ?? 'false', safecrlf: config.values.get('core.safecrlf') ?? 'false', eol: config.values.get('core.eol') ?? 'native' }, symlinks: !falseValue(config.values.get('core.symlinks') ?? 'true'), filemode: !falseValue(config.values.get('core.filemode') ?? 'true') }; } function same(left: unknown, right: unknown) { return JSON.stringify(left) === JSON.stringify(right); } @@ -210,6 +215,7 @@ export function createGitWriteAdapter() { const { snapshot } = await fileSnapshot(repository, name); selectedBytes += snapshot.size; if (selectedBytes > MAX_SELECTED_BYTES) throw new QueryError('OUTPUT_LIMIT', '所选文件总大小超过 128 MiB,请分批暂存。'); + if (snapshot.kind === 'symlink' && (process.platform === 'win32' || !before.symlinks)) fail('core.symlinks=false 时不暂存符号链接。'); files.push(snapshot); } const after = await readWriteState(repository, paths, kind, signal); @@ -224,9 +230,9 @@ export function createGitWriteAdapter() { let lock; try { lock = await open(lockPath, 'wx', 0o600); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new QueryError('REPOSITORY_BUSY', 'index.lock 已存在;未触碰其他 Git 操作的锁。'); throw error; } - const owned = await lock.stat(); + const owned = await lock.stat({ bigint: true }); let temporary: string | undefined, installed = false; - const assertLock = async () => { const current = await lstat(lockPath); if (!current.isFile() || current.dev !== owned.dev || current.ino !== owned.ino) throw new QueryError('REPOSITORY_BUSY', 'index 锁的所有权已变化,未安装结果。'); }; + const assertLock = async () => { const current = await lstat(lockPath, { bigint: true }); if (!current.isFile() || current.dev !== owned.dev || current.ino !== owned.ino) throw new QueryError('REPOSITORY_BUSY', 'index 锁的所有权已变化,未安装结果。'); }; const validate = async (includeBytes = false) => { await assertLock(); const state = await readWriteState(repository, paths, kind); @@ -246,7 +252,17 @@ export function createGitWriteAdapter() { const { state, files } = await validate(true); temporary = await mkdtemp(path.join(tmpdir(), 'git-view-index-')); const alternate = path.join(temporary, 'index'); - if (state.index) await writeFile(alternate, state.index, { mode: 0o600 }); + const canonicalDir = path.join(temporary, 'canonical'); + const canonicalWork = path.join(temporary, 'worktree'); + await mkdir(canonicalWork); + await mkdir(path.join(canonicalDir, 'info'), { recursive: true }); + await mkdir(path.join(canonicalDir, 'objects'), { recursive: true }); + await mkdir(path.join(canonicalDir, 'refs')); + await writeFile(path.join(canonicalDir, 'HEAD'), 'ref: refs/heads/main\n'); + if (state.index) { + await writeFile(alternate, state.index, { mode: 0o600 }); + await writeFile(path.join(canonicalDir, 'index'), state.index, { mode: 0o600 }); + } const oidLength = state.headOid?.length ?? state.indexEntries[0]?.oid.length ?? ((await runPlumbing(repository, ['rev-parse', '--show-object-format'])).toString().trim() === 'sha256' ? 64 : 40); const zero = '0'.repeat(oidLength); const updates: string[] = []; @@ -257,7 +273,30 @@ export function createGitWriteAdapter() { updates.push(head ? `${head.mode} ${head.oid}\t${name}\0` : `0 ${zero}\t${name}\0`); } else if (file.snapshot.kind === 'missing') updates.push(`0 ${zero}\t${name}\0`); else { - const oid = (await runPlumbing(repository, ['hash-object', '-w', '--stdin', '--no-filters'], { input: file.bytes })).toString('ascii').trim(); + // Only built-in conversion is allowed. Use captured attributes/config in + // isolated Git metadata: concurrent .gitattributes or info/attributes + // edits in the real repository can never launch a clean/process filter. + const settings = state.normalization.get(name) ?? { text: 'unspecified', eol: 'unspecified' }; + if (!['unspecified', 'unset', 'set', 'auto'].includes(settings.text) || !['unspecified', 'unset', 'lf', 'crlf'].includes(settings.eol)) fail('不支持此换行属性值。'); + const conversion = state.conversionConfig; + if (!['false', 'true', 'input', '0', '1', 'yes', 'no', 'on', 'off'].includes(conversion.autocrlf.toLowerCase()) || !['false', 'true', 'warn', '0', '1', 'yes', 'no', 'on', 'off'].includes(conversion.safecrlf.toLowerCase()) || !['native', 'lf', 'crlf'].includes(conversion.eol.toLowerCase())) fail('不支持此换行配置值。'); + const objectFormat = oidLength === 64 ? 'sha256' : 'sha1'; + await writeFile(path.join(canonicalDir, 'config'), `[core]\nrepositoryformatversion = ${objectFormat === 'sha256' ? 1 : 0}\nbare = false\nautocrlf = ${conversion.autocrlf}\nsafecrlf = ${conversion.safecrlf}\neol = ${conversion.eol}\n${objectFormat === 'sha256' ? '[extensions]\nobjectformat = sha256\n' : ''}`); + await writeFile(path.join(canonicalDir, 'info', 'attributes'), `* ${settings.text === 'unset' ? '-text' : settings.text === 'set' ? 'text' : settings.text === 'auto' ? 'text=auto' : '!text'} ${['lf', 'crlf'].includes(settings.eol) ? `eol=${settings.eol}` : '!eol'} !filter !ident !working-tree-encoding\n`); + let oid: string; + if (file.snapshot.kind === 'symlink') oid = (await runPlumbing(repository, ['hash-object', '-w', '--stdin', '--no-filters'], { input: file.bytes })).toString('ascii').trim(); + else { + // Ordinary Git add consults the original index before autocrlf/text=auto + // conversion (e.g. legacy CRLF blobs). Keep that behavior in isolation. + const selectedFile = path.join(canonicalWork, name); + await mkdir(path.dirname(selectedFile), { recursive: true }); + await writeFile(selectedFile, file.bytes!); + const normalization = { gitDir: canonicalDir, worktree: canonicalWork }; + await runPlumbing(repository, ['add', '--force', '--', name], { normalization }); + const entry = parseEntries(await runPlumbing(repository, ['ls-files', '--stage', '-z', '--', name], { normalization })).find(entry => entry.path === name); + if (!entry) throw new QueryError('INTERNAL_ERROR', '隔离转换未生成所选文件。'); + oid = entry.oid; + } const prior = state.indexEntries.find((entry) => entry.path === name); const mode = file.snapshot.kind === 'symlink' ? '120000' : !state.filemode ? (prior?.mode.startsWith('100') ? prior.mode : '100644') : file.snapshot.mode & 0o100 ? '100755' : '100644'; updates.push(`${mode} ${oid}\t${name}\0`); @@ -277,13 +316,12 @@ export function createGitWriteAdapter() { await lock.sync(); await validate(); await assertLock(); - await rename(lockPath, path.join(repository.gitDir, 'index')); + await replaceFile(lockPath, path.join(repository.gitDir, 'index')); installed = true; - const directory = await open(repository.gitDir, constants.O_RDONLY); - try { await directory.sync(); } finally { await directory.close(); } + await syncDirectory(repository.gitDir); } finally { await lock.close(); - if (!installed) { try { const current = await lstat(lockPath); if (current.dev === owned.dev && current.ino === owned.ino) await rm(lockPath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } } + if (!installed) { try { const current = await lstat(lockPath, { bigint: true }); if (current.dev === owned.dev && current.ino === owned.ino) await rm(lockPath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } } if (temporary) await rm(temporary, { recursive: true, force: true }); } }, diff --git a/packages/git-write/src/platform.test.ts b/packages/git-write/src/platform.test.ts index b9c2479..569e6eb 100644 --- a/packages/git-write/src/platform.test.ts +++ b/packages/git-write/src/platform.test.ts @@ -9,9 +9,11 @@ import * as platform from './platform'; import { requireIndexWrites, supportsIndexWrites } from './platform'; it('refuses Windows writes until its persistence and index replacement are supported', () => { + vi.stubEnv('GIT_VIEW_NATIVE_HELPER', undefined); expect(supportsIndexWrites('win32')).toBe(false); - expect(() => requireIndexWrites('win32')).toThrow('Windows 暂未开放 Git 写入'); + expect(() => requireIndexWrites('win32')).toThrow('Windows 写入需要桌面原生组件'); expect(() => requireIndexWrites('darwin')).not.toThrow(); + vi.unstubAllEnvs(); }); it('keeps coordinator startup and pending discovery available without creating a Windows write store', async () => { diff --git a/packages/git-write/src/platform.ts b/packages/git-write/src/platform.ts index 87c46ad..d060c2a 100644 --- a/packages/git-write/src/platform.ts +++ b/packages/git-write/src/platform.ts @@ -1,12 +1,13 @@ import { QueryError } from '@git-view/contracts'; +import { windowsHelper } from './filesystem.js'; -// The first writer relies on POSIX file modes, directory fsync and atomic -// replacement. Keep read-only hosts usable until a Windows writer is verified. +// Windows writes require the installed native ACL/replacement/process bridge. +// Plain Node/stdio remains read-only if that component is unavailable. export function supportsIndexWrites(platform: NodeJS.Platform = process.platform) { - return platform !== 'win32'; + return platform !== 'win32' || !!windowsHelper(); } export function indexWritesUnavailable() { - return new QueryError('UNSUPPORTED_REPOSITORY', 'Windows 暂未开放 Git 写入;仓库查看仍可使用。'); + return new QueryError('UNSUPPORTED_REPOSITORY', 'Windows 写入需要桌面原生组件,请使用完整安装包。'); } export function requireIndexWrites(platform: NodeJS.Platform = process.platform): void { if (!supportsIndexWrites(platform)) throw indexWritesUnavailable(); diff --git a/packages/git-write/src/repository-runner.ts b/packages/git-write/src/repository-runner.ts index 9a7ce80..88a7ab2 100644 --- a/packages/git-write/src/repository-runner.ts +++ b/packages/git-write/src/repository-runner.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process'; +import { windowsPlatform } from './filesystem.js'; import { OPERATION_LIMITS, QueryError, type RepositoryIdentity } from '@git-view/contracts'; export type RepositoryCommand = @@ -16,6 +17,7 @@ export function runRepositoryCommand(repository: RepositoryIdentity, command: Re const env: NodeJS.ProcessEnv = {}; for (const [key, value] of Object.entries(process.env)) if (!key.startsWith('GIT_')) env[key] = value; Object.assign(env, { GIT_DIR: repository.gitDir, GIT_WORK_TREE: repository.worktreeRoot, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', GIT_NO_LAZY_FETCH: '1', GIT_EDITOR: 'false', GIT_SEQUENCE_EDITOR: 'false', GIT_REFLOG_ACTION: marker, LC_ALL: 'C', LANG: 'C' }); + if (process.platform === 'win32') return windowsPlatform({ operation: 'run-git', cwd: repository.worktreeRoot, ...command, marker, timeoutMs }, env); return new Promise((resolve, reject) => { // A timed-out hook/signer must not keep writing after its Git parent is killed. const child = spawn('git', ['--no-pager', '--literal-pathspecs', '-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', '-c', 'submodule.recurse=false', '-c', 'protocol.allow=never', '-c', 'core.logAllRefUpdates=true', '-c', 'gc.auto=0', '-c', 'maintenance.auto=false', ...args], { cwd: repository.worktreeRoot, env, shell: false, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] }); diff --git a/packages/git-write/src/runner.ts b/packages/git-write/src/runner.ts index 256140d..a690a5a 100644 --- a/packages/git-write/src/runner.ts +++ b/packages/git-write/src/runner.ts @@ -3,7 +3,9 @@ import { devNull } from 'node:os'; import { QueryError, type RepositoryIdentity } from '@git-view/contracts'; /** Private plumbing boundary. There is deliberately no API accepting user commands. */ -export function runPlumbing(repository: RepositoryIdentity, args: string[], options: { input?: Buffer; index?: string; signal?: AbortSignal; allowMissing?: boolean } = {}): Promise { +export function runPlumbing(repository: RepositoryIdentity, args: string[], options: { input?: Buffer; index?: string; signal?: AbortSignal; allowMissing?: boolean; normalization?: { gitDir: string; worktree: string } } = {}): Promise { + // Git for Windows accepts NUL, while Node's device namespace \\.\nul is not a Git config path. + const gitNull = process.platform === 'win32' ? 'NUL' : devNull; const exact = (expected: string[]) => args.length === expected.length && args.every((value, i) => value === expected[i]); const allowed = exact(['config', '--null', '--list', '--show-origin', '--includes']) || exact(['rev-parse', '--verify', '--quiet', 'HEAD']) || exact(['rev-parse', '--show-object-format']) @@ -11,15 +13,17 @@ export function runPlumbing(repository: RepositoryIdentity, args: string[], opti || (args.length === 5 && exact(['ls-tree', '-r', '-z', '--full-tree', args[4]!]) && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(args[4]!)) || [false, true].some((cached) => ['filter', 'all'].some((selection) => exact(['check-attr', '-z', ...(cached ? ['--cached'] : []), '--stdin', ...(selection === 'filter' ? ['filter'] : ['filter', 'text', 'eol', 'ident', 'working-tree-encoding'])]))) || exact(['hash-object', '-w', '--stdin', '--no-filters']) + || (Boolean(options.normalization) && args.length === 4 && exact(['add', '--force', '--', args[3]!])) + || (Boolean(options.normalization) && args.length === 5 && exact(['ls-files', '--stage', '-z', '--', args[4]!])) || exact(['update-index', '--add', '--remove', '-z', '--index-info']); if (!allowed || (args[0] === 'update-index' && !options.index)) throw new QueryError('INTERNAL_ERROR', '拒绝未授权的 Git 写入命令。'); if (options.signal?.aborted) return Promise.reject(new QueryError('CANCELLED', '操作已取消。')); const env: NodeJS.ProcessEnv = {}; for (const [key, value] of Object.entries(process.env)) if (!key.startsWith('GIT_')) env[key] = value; - Object.assign(env, { GIT_DIR: repository.gitDir, GIT_WORK_TREE: repository.worktreeRoot, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', GIT_NO_LAZY_FETCH: '1', LC_ALL: 'C', LANG: 'C', ...(options.index ? { GIT_INDEX_FILE: options.index } : {}) }); - const fixed = ['--no-pager', '--no-optional-locks', '--literal-pathspecs', '-c', `core.hooksPath=${devNull}`, '-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', '-c', 'core.splitIndex=false', '-c', 'core.pager=cat', '-c', 'diff.external=', '-c', 'submodule.recurse=false', '-c', 'protocol.allow=never', '-c', 'gc.auto=0', '-c', 'maintenance.auto=false']; + Object.assign(env, { GIT_DIR: options.normalization?.gitDir ?? repository.gitDir, GIT_WORK_TREE: options.normalization?.worktree ?? repository.worktreeRoot, ...(options.normalization ? { GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: gitNull, GIT_ATTR_NOSYSTEM: '1', GIT_OBJECT_DIRECTORY: `${repository.commonGitDir}/objects` } : {}), GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', GIT_NO_LAZY_FETCH: '1', LC_ALL: 'C', LANG: 'C', ...(options.index ? { GIT_INDEX_FILE: options.index } : {}) }); + const fixed = ['--no-pager', '--no-optional-locks', '--literal-pathspecs', '-c', `core.hooksPath=${gitNull}`, '-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', '-c', 'core.splitIndex=false', '-c', 'core.pager=cat', '-c', 'diff.external=', '-c', 'submodule.recurse=false', '-c', 'protocol.allow=never', '-c', 'gc.auto=0', '-c', 'maintenance.auto=false']; return new Promise((resolve, reject) => { - const child = spawn('git', [...fixed, ...args], { cwd: repository.worktreeRoot, env, shell: false, stdio: ['pipe', 'pipe', 'pipe'] }); + const child = spawn('git', [...fixed, ...args], { cwd: options.normalization?.worktree ?? repository.worktreeRoot, env, shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); const stdout: Buffer[] = [], stderr: Buffer[] = []; let bytes = 0, failure: QueryError | undefined; const stop = (error: QueryError) => { failure ??= error; child.kill('SIGKILL'); }; diff --git a/packages/operations/src/receipts.ts b/packages/operations/src/receipts.ts index f07fa0d..27e7c67 100644 --- a/packages/operations/src/receipts.ts +++ b/packages/operations/src/receipts.ts @@ -1,6 +1,7 @@ import { constants, type Stats } from 'node:fs'; -import { chmod, link, lstat, mkdir, open, opendir, realpath, rename, unlink } from 'node:fs/promises'; +import { chmod, link, lstat, mkdir, open, opendir, realpath, unlink } from 'node:fs/promises'; import path from 'node:path'; +import { checkPrivate, privateDirectoryPermissions, syncDirectory, replaceFile, openRead } from '../../git-write/src/filesystem.js'; import { createHash, randomUUID } from 'node:crypto'; import { z } from 'zod'; import { QueryError, repositorySchema, operationReceiptSchema, type RepositoryIdentity } from '@git-view/contracts'; @@ -24,25 +25,19 @@ function parseReceipt(data: unknown, operationId?: string): StoredReceipt { return parsed.data; } -function privateStat(stat: Stats, directory: boolean) { - if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile()) || (process.getuid && stat.uid !== process.getuid()) || (stat.mode & 0o077)) throw unsafe(); -} async function privateDirectory(directory: string) { await mkdir(directory, { recursive: true, mode: 0o700 }); const stat = await lstat(directory); if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid())) throw unsafe(); - await chmod(directory, 0o700); -} -async function syncDirectory(directory: string) { - const handle = await open(directory, constants.O_RDONLY | constants.O_NOFOLLOW); - try { await handle.sync(); } finally { await handle.close(); } + if (process.platform === 'win32') await privateDirectoryPermissions(directory); + else await chmod(directory, 0o700); } async function readJson(file: string): Promise { let handle; try { - handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW); + handle = await openRead(file); const stat = await handle.stat(); - privateStat(stat, false); + await checkPrivate(file, stat, false); if (stat.size > MAX_RECORD_BYTES) throw new QueryError('INTERNAL_ERROR', '操作回执过大,无法可靠读取。'); return JSON.parse(await handle.readFile('utf8')); } catch (error) { @@ -52,7 +47,7 @@ async function readJson(file: string): Promise { } finally { await handle?.close(); } } async function assertTarget(file: string) { - try { privateStat(await lstat(file), false); } catch (error) { if (!missing(error)) throw error; } + try { await checkPrivate(file, await lstat(file), false); } catch (error) { if (!missing(error)) throw error; } } async function atomicJson(file: string, data: unknown) { const text = JSON.stringify(data); @@ -66,7 +61,7 @@ async function atomicJson(file: string, data: unknown) { } finally { await handle.close(); } try { await assertTarget(file); - await rename(temporary, file); + await replaceFile(temporary, file); await syncDirectory(path.dirname(file)); } finally { await unlink(temporary).catch(error => { if (!missing(error)) throw error; }); } } @@ -87,8 +82,8 @@ export async function createReceiptStore(directory: string) { const markerPath = (operationId: string) => path.join(pendingRoot, `${hash(operationId)}.json`); const lockPath = (repository: RepositoryIdentity) => path.join(root, `${hash(repository.gitDir)}.lock`); const commonLockPath = (repository: RepositoryIdentity) => path.join(root, `common-${hash(repository.commonGitDir)}.lock`); - const secureRoot = async () => privateStat(await lstat(root), true); - const securePending = async () => { await secureRoot(); privateStat(await lstat(pendingRoot), true); }; + const secureRoot = async () => checkPrivate(root, await lstat(root), true); + const securePending = async () => { await secureRoot(); await checkPrivate(pendingRoot, await lstat(pendingRoot), true); }; async function clearPending(operationId: string) { await securePending(); await assertTarget(markerPath(operationId)); @@ -108,16 +103,21 @@ export async function createReceiptStore(directory: string) { let count = 0, receiptCount = 0, bytes = 0; // Completed history is intentionally not scanned. A marker is durable before // writer invocation and removed only after a terminal receipt is durable. + const names: string[] = []; const directory = await opendir(pendingRoot); for await (const entry of directory) { if (++count > 2000) throw new QueryError('OUTPUT_LIMIT', '待核实索引超过 2000 项,无法完整检查待核实操作。请先整理应用数据目录。'); if (!entry.name.endsWith('.json')) continue; if (++receiptCount > 1000) throw new QueryError('OUTPUT_LIMIT', '待核实操作超过 1000 项,无法完整检查。请先按操作 ID 核对回执。'); if (!/^[a-f0-9]{64}\.json$/.test(entry.name)) throw new QueryError('INTERNAL_ERROR', '待核实索引含无法识别的文件,未忽略该文件。'); - const marker = pendingMarkerSchema.safeParse(await readJson(path.join(pendingRoot, entry.name))); - if (!marker.success || hash(marker.data.operationId) !== entry.name.slice(0, -5)) throw new QueryError('INTERNAL_ERROR', '待核实索引内容无效,无法可靠恢复。'); + names.push(entry.name); + } + // Reject an oversized scan before launching per-file native ACL checks. + for (const name of names) { + const marker = pendingMarkerSchema.safeParse(await readJson(path.join(pendingRoot, name))); + if (!marker.success || hash(marker.data.operationId) !== name.slice(0, -5)) throw new QueryError('INTERNAL_ERROR', '待核实索引内容无效,无法可靠恢复。'); const file = receiptPath(marker.data.operationId); - const stat = await lstat(file); privateStat(stat, false); + const stat = await lstat(file); await checkPrivate(file, stat, false); bytes += stat.size; if (bytes > 16 * 1024 * 1024) throw new QueryError('OUTPUT_LIMIT', '操作回执超过 16 MiB,无法完整检查待核实操作。请先整理应用数据目录。'); const data = await readJson(file); diff --git a/scripts/verify-windows-ui.mjs b/scripts/verify-windows-ui.mjs index 12d0ba2..0b88624 100644 --- a/scripts/verify-windows-ui.mjs +++ b/scripts/verify-windows-ui.mjs @@ -26,12 +26,13 @@ try { await mkdir(fixture); const git = (...args) => exec('git', ['-C', fixture, ...args], { env }); await git('init', '-b', 'main'); - await writeFile(join(fixture, '中文 文件.txt'), 'version one\n'); + for (const [key, value] of Object.entries({ 'user.name': 'Installer Test', 'user.email': 'test@example.invalid', 'commit.gpgsign': 'false', 'core.hooksPath': '.git/hooks', 'core.autocrlf': 'true', 'core.symlinks': 'false', 'core.filemode': 'false' })) await git('config', key, value); + await writeFile(join(fixture, '中文 文件.txt'), 'version one\r\n'); await git('add', '--', '中文 文件.txt'); await git('-c', 'user.name=Installer Test', '-c', 'user.email=test@example.invalid', 'commit', '-m', 'native fixture commit'); - await writeFile(join(fixture, '中文 文件.txt'), 'version two staged\n'); + await writeFile(join(fixture, '中文 文件.txt'), 'version two staged\r\n'); await git('add', '--', '中文 文件.txt'); - await writeFile(join(fixture, '中文 文件.txt'), 'version three working\n'); + await writeFile(join(fixture, '中文 文件.txt'), 'version three working\r\n'); await writeFile(join(fixture, '未跟踪.txt'), 'untracked content\n'); const fingerprint = async () => { const hash = createHash('sha256'); @@ -112,6 +113,51 @@ try { report.checks.noRendererErrors = true; assert.equal(await fingerprint(), before); report.checks.repositoryFingerprintUnchanged = true; + // Read-only evidence above is captured before deliberately exercising writes. + await page.getByRole('navigation', { name: /^(Main view|主视图)$/ }).getByRole('button', { name: /^(Changes|当前改动)/ }).click(); + const confirm = async (consent = false) => { + const dialog = page.locator('.operation-dialog'); + await expect(dialog).toBeVisible({ timeout: 30_000 }); + if (consent) await dialog.locator('.operation-consent input').check(); + await dialog.locator('footer .button.primary').click(); + await expect(dialog).toBeHidden({ timeout: 30_000 }); + await expect(page.locator('.operation-feedback')).toContainText('completed', { timeout: 30_000 }); + }; + await page.locator('.group-staged input[type=checkbox]').check(); + await page.locator('[data-operation-preview]').click(); await confirm(); + assert.equal((await git('show', ':中文 文件.txt')).stdout, 'version one\n'); + assert.equal(await readFile(join(fixture, '中文 文件.txt'), 'utf8'), 'version three working\r\n'); + report.checks.unstagePreservesWorkingFile = true; + // Select both ordinary files so the later branch switch starts clean. + for (const checkbox of await page.locator('.group-unstaged input[type=checkbox], .group-untracked input[type=checkbox]').all()) await checkbox.check(); + await page.locator('[data-operation-preview]').click(); await confirm(); + assert.equal((await git('show', ':中文 文件.txt')).stdout, 'version three working\n'); + report.checks.stageWithCrlfConversion = true; + await page.getByRole('button', { name: 'Commit staged changes', exact: true }).click(); + let form = page.getByRole('dialog', { name: 'Commit staged changes', exact: true }); + await form.getByLabel('Commit message', { exact: true }).fill('native Windows UI commit'); + await form.getByRole('button', { name: 'Preview operation', exact: true }).click(); await confirm(true); + assert.equal((await git('show', 'HEAD:中文 文件.txt')).stdout, 'version three working\n'); + assert.equal((await git('rev-list', '--count', 'HEAD')).stdout.trim(), '2'); + assert.equal((await git('status', '--porcelain')).stdout.trim(), ''); + report.checks.commitFromInstalledWindow = true; + await page.getByRole('button', { name: 'Branch actions', exact: true }).click(); + form = page.getByRole('dialog', { name: 'Branch actions', exact: true }); + await form.getByLabel('New branch name', { exact: true }).fill('topic/windows'); + await form.getByRole('button', { name: 'Preview operation', exact: true }).click(); await confirm(true); + assert.equal((await git('symbolic-ref', '--short', 'HEAD')).stdout.trim(), 'main'); + assert.equal((await git('rev-parse', 'topic/windows')).stdout, (await git('rev-parse', 'HEAD')).stdout); + report.checks.createBranchWithoutSwitching = true; + await page.getByRole('button', { name: 'Branch actions', exact: true }).click(); + form = page.getByRole('dialog', { name: 'Branch actions', exact: true }); + await form.getByRole('button', { name: 'Switch branch', exact: true }).click(); + await form.getByLabel('Target local branch', { exact: true }).selectOption('topic/windows'); + await form.getByRole('button', { name: 'Preview operation', exact: true }).click(); await confirm(true); + assert.equal((await git('symbolic-ref', '--short', 'HEAD')).stdout.trim(), 'topic/windows'); + report.checks.switchBranchFromInstalledWindow = true; + assert.deepEqual(errors, []); + await page.screenshot({ path: join(output, 'windows-native-operations.png') }); + await exec('powershell.exe', ['-NoProfile', '-Command', `$p = Get-Process -Id ${child.pid}; if (-not $p.CloseMainWindow()) { throw 'Native window did not accept close' }`]); const result = await Promise.race([exited, wait(15_000).then(() => { throw new Error('Native host did not exit after window close.'); })]); assert.equal(result.code, 0); diff --git a/tests/integration/commit-branches.test.ts b/tests/integration/commit-branches.test.ts index 6cb8e17..b074c96 100644 --- a/tests/integration/commit-branches.test.ts +++ b/tests/integration/commit-branches.test.ts @@ -32,7 +32,7 @@ async function execute(f: Awaited>, input: Parameters { +describe('ordinary commits and local branches', { timeout: GIT_OPERATION_TEST_TIMEOUT }, () => { it.each([false, true])('commits exactly index V2, retains V3, with correct parents (unborn=%s)', async unborn => { const f = await setup(unborn); const previous = unborn ? null : git(f.root, ['rev-parse', 'HEAD']); const before = fingerprint(f.root); const shown = await f.preview({ kind: 'commit', message: 'ordinary commit\n\nDetails' }); diff --git a/tests/integration/git-write.test.ts b/tests/integration/git-write.test.ts index a0012bb..00c1eb7 100644 --- a/tests/integration/git-write.test.ts +++ b/tests/integration/git-write.test.ts @@ -166,7 +166,7 @@ describe.skipIf(process.platform === 'win32')('isolated whole-file index writes expect(index(root)).toEqual(marker); }); - it('refuses external filters and builtin conversion while never executing hook probes', async () => { + it('refuses external filters, supports built-in conversion and never executes hook probes', async () => { const root = repository(); write(root, 'file', 'base\n'); commit(root); write(root, 'file', 'changed\n'); const identity = await reader.resolveRepository(root), overview = await reader.readOverview(identity); const marker = path.join(root, 'filter-ran'); @@ -176,8 +176,9 @@ describe.skipIf(process.platform === 'win32')('isolated whole-file index writes expect(existsSync(marker)).toBe(false); git(root, ['config', '--unset', 'filter.danger.clean']); write(root, '.gitattributes', 'file text\n'); - await expect(prepare(root, 'stage-files', ['file'])).rejects.toMatchObject({ code: 'UNSUPPORTED_FILTER' }); + await execute(await prepare(root, 'stage-files', ['file'])); rmSync(path.join(root, '.gitattributes')); + write(root, 'file', 'changed again\n'); const hookMarker = path.join(root, 'hook-ran'); write(root, '.git/hooks/post-index-change', `#!/bin/sh\ntouch '${hookMarker}'\n`); chmodSync(path.join(root, '.git/hooks/post-index-change'), 0o755); await execute(await prepare(root, 'stage-files', ['file'])); @@ -209,10 +210,11 @@ describe.skipIf(process.platform === 'win32')('isolated whole-file index writes expect(git(root, ['show', ':file'])).toBe('base'); expect(worktree(root)).toEqual(before); }); - it('bounds file hashing and refuses configured line-ending conversion', async () => { + it('bounds file hashing while supporting built-in line-ending conversion', async () => { const root = repository(); write(root, 'file', 'base\n'); commit(root); write(root, 'file', 'changed\n'); git(root, ['config', 'core.autocrlf', 'input']); - await expect(prepare(root, 'stage-files')).rejects.toMatchObject({ code: 'UNSUPPORTED_REPOSITORY' }); + await execute(await prepare(root, 'stage-files')); + expect(git(root, ['show', ':file'])).toBe('changed'); git(root, ['config', 'core.autocrlf', 'false']); truncateSync(path.join(root, 'file'), 64 * 1024 * 1024 + 1); const before = index(root); diff --git a/tests/integration/operations-crash.test.ts b/tests/integration/operations-crash.test.ts index 3e9ba20..bbf1cff 100644 --- a/tests/integration/operations-crash.test.ts +++ b/tests/integration/operations-crash.test.ts @@ -13,7 +13,6 @@ import { createGitWriteAdapter } from '../../packages/git-write/src/index.js'; import { cleanupFixtures, commit, fixtureGit as git, repository, temporaryDirectory, write } from '../fixtures/git.js'; afterAll(cleanupFixtures); -const posixIt = process.platform === 'win32' ? it.skip : it; const hash = (value: string | Buffer) => createHash('sha256').update(value).digest('hex'); const projectRoot = fileURLToPath(new URL('../../', import.meta.url)); @@ -34,7 +33,7 @@ function runUntilExit(bundle: string, args: string[]): Promise<{ code: number | }); } -posixIt('recovers after actual SIGKILL between index installation and final receipt, without executing twice', async () => { +it('recovers after actual SIGKILL between index installation and final receipt, without executing twice', async () => { // Every Git mutation, the bundled child, and its data directory live in fixture // roots. No process writes to the source checkout or uses the user's repository. const root = repository(); @@ -81,7 +80,7 @@ posixIt('recovers after actual SIGKILL between index installation and final rece }); const terminated = await runUntilExit(bundle, [root, directory, operationId]); - expect(terminated, terminated.stderr).toMatchObject({ code: null, signal: 'SIGKILL' }); + expect(terminated, terminated.stderr).toMatchObject(process.platform === 'win32' ? { code: 1, signal: null, stderr: '' } : { code: null, signal: 'SIGKILL' }); const receiptFile = path.join(directory, 'operations', `${hash(operationId)}.json`); const pendingFile = path.join(directory, 'operations', 'pending', `${hash(operationId)}.json`); const interrupted = storedReceiptSchema.parse(JSON.parse(readFileSync(receiptFile, 'utf8'))); @@ -124,7 +123,7 @@ posixIt('recovers after actual SIGKILL between index installation and final rece expect(readFileSync(appLock, 'utf8')).toBe(originalLock); }); -posixIt('recovers a real committed OID after SIGKILL before the observed result is persisted', async () => { +it('recovers a real committed OID after SIGKILL before the observed result is persisted', async () => { const root = repository(); git(root, ['config', 'user.name', 'Crash Test']); git(root, ['config', 'user.email', 'test@example.invalid']); git(root, ['config', 'commit.gpgsign', 'false']); const hooks = path.join(temporaryDirectory(), 'hooks'); @@ -152,7 +151,7 @@ posixIt('recovers a real committed OID after SIGKILL before the observed result await operations.execute(session, preview.previewId, operationId, true); throw new Error('Expected SIGKILL'); ` }, outfile: bundle, bundle: true, platform: 'node', target: 'node24', format: 'esm', tsconfig: path.join(projectRoot, 'tsconfig.json'), logLevel: 'silent' }); - const exit = await runUntilExit(bundle, [root, directory, operationId]); expect(exit, exit.stderr).toMatchObject({ code: null, signal: 'SIGKILL' }); + const exit = await runUntilExit(bundle, [root, directory, operationId]); expect(exit, exit.stderr).toMatchObject(process.platform === 'win32' ? { code: 1, signal: null, stderr: '' } : { code: null, signal: 'SIGKILL' }); const file = path.join(directory, 'operations', `${hash(operationId)}.json`); const interrupted = storedReceiptSchema.parse(JSON.parse(readFileSync(file, 'utf8'))); expect(interrupted.receipt.status).toBe('running'); diff --git a/tests/integration/operations.test.ts b/tests/integration/operations.test.ts index 99132fe..b24e31d 100644 --- a/tests/integration/operations.test.ts +++ b/tests/integration/operations.test.ts @@ -1,4 +1,5 @@ import { afterAll, describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; import { chmodSync, lstatSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'; import path from 'node:path'; @@ -29,7 +30,7 @@ async function preview(operations: Operations, read: ReturnType entry.path === 'chosen.txt')!.id], fingerprint: overview.fingerprint }); } -describe.skipIf(process.platform === 'win32')('durable file operation coordination (POSIX)', { timeout: GIT_OPERATION_TEST_TIMEOUT }, () => { +describe('durable file operation coordination', { timeout: GIT_OPERATION_TEST_TIMEOUT }, () => { it('stages V3 in full and unstages to V1 while preserving V3 and unselected files', async () => { const { root, read, session, operations, directory } = await setup(); git(root, ['add', '--', 'chosen.txt']); write(root, 'chosen.txt', 'V3\n'); write(root, 'other.txt', 'not selected\n'); @@ -42,8 +43,10 @@ describe.skipIf(process.platform === 'win32')('durable file operation coordinati expect(git(root, ['show', ':chosen.txt'])).toBe('V3'); expect(git(root, ['show', ':other.txt'])).toBe('untouched'); expect(readFileSync(path.join(root, 'chosen.txt'), 'utf8')).toBe('V3\n'); - expect(lstatSync(receiptPath(directory, operationId)).mode & 0o777).toBe(0o600); - expect(lstatSync(path.join(directory, 'operations')).mode & 0o777).toBe(0o700); + if (process.platform !== 'win32') { + expect(lstatSync(receiptPath(directory, operationId)).mode & 0o777).toBe(0o600); + expect(lstatSync(path.join(directory, 'operations')).mode & 0o777).toBe(0o700); + } const unstage = await preview(operations, read, session, 'unstage-files'); expect((await operations.execute(session, unstage.previewId, randomUUID())).status).toBe('succeeded'); expect(git(root, ['show', ':chosen.txt'])).toBe('V1'); @@ -219,9 +222,16 @@ describe.skipIf(process.platform === 'win32')('durable file operation coordinati restorePendingMarker(directory, id); await expect(operations.receipt(session, id)).rejects.toMatchObject({ code: 'INTERNAL_ERROR' }); await expect(operations.pending(session)).rejects.toMatchObject({ code: 'INTERNAL_ERROR' }); - writeFileSync(file, original); chmodSync(file, 0o644); - await expect(operations.receipt(session, id)).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); - chmodSync(file, 0o600); + writeFileSync(file, original); + if (process.platform !== 'win32') { + chmodSync(file, 0o644); + await expect(operations.receipt(session, id)).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + chmodSync(file, 0o600); + } else { + execFileSync('icacls', [file, '/grant', '*S-1-1-0:(R)']); + try { await expect(operations.receipt(session, id)).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); } + finally { execFileSync('icacls', [file, '/remove:g', '*S-1-1-0']); } + } if (process.platform !== 'win32') { const target = path.join(temporaryDirectory(), 'target'); writeFileSync(target, original, { mode: 0o600 }); unlinkSync(file); symlinkSync(target, file); await expect(operations.receipt(session, id)).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); diff --git a/tests/integration/windows-operations.test.ts b/tests/integration/windows-operations.test.ts new file mode 100644 index 0000000..503fcce --- /dev/null +++ b/tests/integration/windows-operations.test.ts @@ -0,0 +1,70 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import path from 'node:path'; +import type { OperationInput } from '@git-view/contracts'; +import { randomUUID } from 'node:crypto'; +import { createGitAdapter } from '../../packages/git-cli/src/index'; +import { createOperations } from '../../packages/operations/src/index'; +import { cleanupFixtures, repository, temporaryDirectory, fixtureGit as git, write, commit, GIT_OPERATION_TEST_TIMEOUT } from '../fixtures/git'; +type WithoutFingerprint = T extends unknown ? Omit : never; +afterAll(cleanupFixtures); +describe('portable repository operations', { timeout: GIT_OPERATION_TEST_TIMEOUT }, () => { + it('stages canonical LF from CRLF without changing the worktree, then unstages, commits and switches branches', async () => { + const root=repository(); git(root,['config','user.name','Portable Test']); git(root,['config','user.email','portable@example.invalid']); git(root,['config','commit.gpgsign','false']); + git(root,['config','core.autocrlf','true']); git(root,['config','core.symlinks','false']); git(root,['config','core.filemode','false']); + write(root,'中文 文件.txt','V1\r\n'); commit(root); + write(root,'中文 文件.txt','V2\r\n'); + const read=createGitAdapter(); const session={sessionId:randomUUID(),generation:0,repository:await read.resolveRepository(root)}; + const ops=await createOperations({directory:temporaryDirectory(),read}); + async function preview(input: WithoutFingerprint) { const overview=await read.readOverview(session.repository); return ops.preview(session,{...input,fingerprint:overview.fingerprint}); } + async function execute(input: WithoutFingerprint) { const p=await preview(input); const id=randomUUID(); const receipt=await ops.execute(session,p.previewId,id,true); expect(receipt, JSON.stringify(receipt)).toMatchObject({status:'succeeded'}); expect(await ops.execute(session,p.previewId,id,true)).toEqual(receipt); return receipt; } + const before=readFileSync(path.join(root,'中文 文件.txt')); + let overview=await read.readOverview(session.repository); await execute({kind:'stage-files',entryIds:overview.changes.unstaged.map(x=>x.id)}); + expect(git(root,['show',':中文 文件.txt'])).toBe('V2'); expect(git(root,['rev-parse',':中文 文件.txt'])).toBe(git(root,['hash-object','--stdin','--no-filters'],'V2\n')); expect(readFileSync(path.join(root,'中文 文件.txt'))).toEqual(before); + overview=await read.readOverview(session.repository); await execute({kind:'unstage-files',entryIds:overview.changes.staged.map(x=>x.id)}); expect(git(root,['show',':中文 文件.txt'])).toBe('V1'); + overview=await read.readOverview(session.repository); await execute({kind:'stage-files',entryIds:overview.changes.unstaged.map(x=>x.id)}); + write(root,'中文 文件.txt','V3\r\n'); await execute({kind:'commit',message:'Portable commit'}); expect(git(root,['show','HEAD:中文 文件.txt'])).toBe('V2'); expect(readFileSync(path.join(root,'中文 文件.txt'),'utf8')).toBe('V3\r\n'); + // Switch requires a clean tree. Discard only this explicitly created fixture's V3. + git(root,['restore','--','中文 文件.txt']); await execute({kind:'create-branch',branch:'中文-test'}); expect(git(root,['branch','--show-current'])).toBe('main'); + await execute({kind:'switch-branch',branch:'中文-test'}); expect(git(root,['branch','--show-current'])).toBe('中文-test'); + expect(existsSync(path.join(root,'.git/index.lock'))).toBe(false); + }); + it('uses captured text/eol attributes while preserving binary bytes and working files', async () => { + const root = repository(); git(root, ['config', 'core.autocrlf', 'false']); + write(root, '.gitattributes', '* text eol=lf\nbinary.dat -text\n'); + write(root, 'unchosen.txt', 'baseline\n'); commit(root); + const text = 'new text\r\n'; const binary = Buffer.from([0, 10, 13, 10, 255]); + write(root, '中文 file.txt', text); write(root, 'binary.dat', binary); + const read = createGitAdapter(); const session = { sessionId: randomUUID(), generation: 0, repository: await read.resolveRepository(root) }; + const ops = await createOperations({ directory: temporaryDirectory(), read }); + const overview = await read.readOverview(session.repository); + const preview = await ops.preview(session, { kind: 'stage-files', entryIds: overview.changes.untracked.map(entry => entry.id), fingerprint: overview.fingerprint }); + const receipt = await ops.execute(session, preview.previewId, randomUUID()); + expect(receipt, JSON.stringify(receipt)).toMatchObject({ status: 'succeeded' }); + expect(git(root, ['rev-parse', ':中文 file.txt'])).toBe(git(root, ['hash-object', '--stdin', '--no-filters'], 'new text\n')); + expect(git(root, ['rev-parse', ':binary.dat'])).toBe(git(root, ['hash-object', '--stdin', '--no-filters'], binary)); + expect(readFileSync(path.join(root, '中文 file.txt'), 'utf8')).toBe(text); + expect(readFileSync(path.join(root, 'binary.dat'))).toEqual(binary); + expect(git(root, ['show', ':unchosen.txt'])).toBe('baseline'); + }); + it('preserves an existing CRLF index when autocrlf is enabled later, matching ordinary git add', async () => { + const root = repository(); git(root, ['config', 'core.autocrlf', 'false']); + write(root, 'legacy.txt', 'old\r\n'); commit(root); + git(root, ['config', 'core.autocrlf', 'true']); write(root, 'legacy.txt', 'new\r\n'); + const read = createGitAdapter(); const session = { sessionId: randomUUID(), generation: 0, repository: await read.resolveRepository(root) }; + const ops = await createOperations({ directory: temporaryDirectory(), read }); const overview = await read.readOverview(session.repository); + const preview = await ops.preview(session, { kind: 'stage-files', entryIds: overview.changes.unstaged.map(entry => entry.id), fingerprint: overview.fingerprint }); + const receipt = await ops.execute(session, preview.previewId, randomUUID()); expect(receipt, JSON.stringify(receipt)).toMatchObject({ status: 'succeeded' }); + expect(git(root, ['rev-parse', ':legacy.txt'])).toBe(git(root, ['hash-object', '--stdin', '--no-filters'], 'new\r\n')); + expect(readFileSync(path.join(root, 'legacy.txt'), 'utf8')).toBe('new\r\n'); + }); + it('rejects changed preview and foreign index locks without losing file content', async()=>{ + const root=repository(); write(root,'file','V1\n'); commit(root); write(root,'file','V2\n'); + const read=createGitAdapter(); const session={sessionId:randomUUID(),generation:0,repository:await read.resolveRepository(root)}; + const ops=await createOperations({directory:temporaryDirectory(),read}); const overview=await read.readOverview(session.repository); + const p=await ops.preview(session,{kind:'stage-files',entryIds:overview.changes.unstaged.map(x=>x.id),fingerprint:overview.fingerprint}); + write(root,'file','V3\n'); const r=await ops.execute(session,p.previewId,randomUUID()); expect(r.status).toBe('failed'); expect(git(root,['show',':file'])).toBe('V1'); + const updated=await read.readOverview(session.repository); const p2=await ops.preview(session,{kind:'stage-files',entryIds:updated.changes.unstaged.map(x=>x.id),fingerprint:updated.fingerprint}); + writeFileSync(path.join(root,'.git/index.lock'),'foreign-lock'); const r2=await ops.execute(session,p2.previewId,randomUUID()); expect(r2.status).toBe('failed'); expect(readFileSync(path.join(root,'.git/index.lock'),'utf8')).toBe('foreign-lock'); expect(git(root,['show',':file'])).toBe('V1'); + }); +});