diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7270b5a..62bfa62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,13 +17,18 @@ concurrency: jobs: rust: name: Rust (test + clippy) - runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 # strand-tauri links the full Tauri stack, which needs the webkit/gtk # dev packages even for `cargo check`. - name: Install Linux build dependencies + if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y \ @@ -35,6 +40,10 @@ jobs: build-essential \ git-lfs + - name: Install macOS test dependencies + if: runner.os == 'macOS' + run: brew list git-lfs >/dev/null 2>&1 || brew install git-lfs + - name: Setup Rust uses: dtolnay/rust-toolchain@stable with: @@ -115,6 +124,15 @@ jobs: if ($process.ExitCode -ne 0) { throw "WebView2 install failed: $($process.ExitCode)" } } # The harness runs the actual Tauri app with an isolated identity/profile. + - name: Test Windows reset paths and process cancellation + shell: pwsh + run: | + cargo test -p strand-core reset::tests + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo test -p strand-core network::bounded_process_tests + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo test -p strand-tauri pull_requests::pages::tests + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Exercise native review, persistence and recovery twice run: node scripts/test-review-native.mjs --repeat 2 --output target/review-native-ci - name: Retain native screenshots, feedback and logs diff --git a/Cargo.lock b/Cargo.lock index 5eb273a..69f81d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6177,6 +6177,7 @@ dependencies = [ "tempfile", "thiserror 1.0.69", "tracing", + "windows-sys 0.61.2", ] [[package]] diff --git a/PRD.md b/PRD.md index 764ac5f..ea3b4ea 100644 --- a/PRD.md +++ b/PRD.md @@ -368,7 +368,8 @@ Achieving these is the entire reason for choosing Rust + `gix` + Tauri over the - **GPG / signing:** never store passphrases. Delegate to the user's `gpg-agent` / SSH agent. - **Code execution:** hooks run as `git` always has — Strand doesn't sandbox them but warns clearly when a fresh clone has them. - **Auto-update:** signed update manifests; refusal to apply unsigned updates. -- **Open source:** plan to open-source the app (license TBD, likely AGPL or source-available like Sublime Merge). Decided before launch. +- **Open source:** AGPL-3.0 public source with a dual-license commercial option + (decided 2026-06-12). Contributor terms remain a separate release gate. --- @@ -402,7 +403,7 @@ not block stable. | Risk | Mitigation | | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `@pierre/trees` is still v1.0.0-beta — API may change | Pin versions; contribute upstream; budget for one major-version migration before 1.0. | -| Pierre libraries' licenses not yet confirmed for commercial use | **Open Q1: verify licenses** before committing. Both are described as "open source" but the exact license matters. | +| Pierre library licensing | Cleared for use on 2026-05-25; see TASKS Blockers. | | `gix` does not yet cover 100% of write operations | Hybrid with `git2` and shell-out is fine and proven (Sublime Merge does it). | | Interactive rebase UX is hard | Plan a custom sequence-editor protocol with the shelled-out `git rebase -i`. Tower's implementation is the bar. | | Windows unmanaged MSI/EXE signing requires an EV/Authenticode identity | Preferred distribution uses the Partner Center-signed Store MSIX; obtain a separate identity only if promoting the unmanaged fallback. | @@ -411,13 +412,13 @@ not block stable. ### Open questions -1. **Pierre library licensing** — confirm both libraries are usable in a commercial desktop app, or arrange a license. -2. **Open source or source-available?** — affects positioning and contribution model. Decide before 0.5. +1. **Pierre library licensing** — resolved 2026-05-25; both libraries cleared for use. +2. **Open source or source-available?** — resolved: AGPL-3.0 with a commercial dual-license option; contributor terms remain open. 3. **AI features?** — commit message suggestions, conflict resolution hints, PR description drafts. Not in v1, but worth designing the extension point now. 4. **Hosted code review scope.** — Decided 2026-07-13: build a provider-neutral PR workspace. GitHub and Azure DevOps ship first; GitLab and Bitbucket are follow-on adapters. The provider remains the source of truth. -5. **Pricing model?** — Tower-style subscription, Sublime Merge-style one-time, or free / OSS. Affects everything downstream. +5. **Pricing model** — resolved: free for individuals, optional one-time commercial support license for companies, without feature gating or nag dialogs. --- diff --git a/README.md b/README.md index 1c04540..c05ec9d 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,12 @@ the resolved app appearance automatically. agent edits, hidden diff panes load patches when opened, and Files reuses its inventory until paths or ignore rules change. Workspace scans run with bounded concurrency; Blame highlights code off the UI thread. +- **Safer file operations** — discard and unstage treat selected filenames + literally, dangling symlinks stage as links, and hard reset refuses collisions + with untracked or ignored data. Rename/move protects Git metadata, and Ignore + refuses symlinked `.gitignore` files. Working-tree text previews read a bounded + prefix of large files. Network cancellation stops Git helpers even after + their parent exits, including on Windows. - **Workbench (⌘1)** — Strand's default workspace combines editable working-tree file documents and embedded shells in VS Code-style resizable panes. Drag tabs to reorder them, move them between panes, or drop on a pane @@ -423,6 +429,9 @@ Prerequisites: - **Rust** stable (`rustup default stable`) - **Node** ≥ 20 and **pnpm** ≥ 9 +- **Git LFS** for the Rust integration tests (`brew install git-lfs` on macOS; + `sudo apt-get install git-lfs` on Ubuntu). Tests configure disposable + repositories locally; no global `git lfs install` is needed. - Platform deps for Tauri 2: see ```sh @@ -435,6 +444,11 @@ pnpm tauri:build # installers in target/release/bundle The frontend detects when it isn't running inside Tauri and disables IPC calls, so `pnpm dev` is useful for UI work without a Rust build. +Run `cargo test -p strand-core -p strand-tauri` and +`pnpm --filter ./ui test` for the engine/shell and frontend suites. Rust CI +runs on Linux and macOS; optional signing/Git-flow integration tests remain +explicitly ignored unless their tooling is configured. + ## Project layout ``` diff --git a/ROADMAP.md b/ROADMAP.md index 337bfd6..841420a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3087,6 +3087,37 @@ throwing. Fetch/pull/push progress and clone/open `ProgressPopup` are unchanged. ## Cross-cutting tracks (run in parallel with all milestones) +**Main hardening audit kick (2026-09-29):** Audited 1.7.2 at `f5ed9a8` after +pulling main. Real-repository probes reproduced overly broad discard/unstage, +unprotected untracked hard-reset collisions, ignore-file symlink writes, +dangling-link staging and administrative rename destinations. Source review +also found an unbounded content read; macOS tests reproduce missing-worktree +path-alias failure. `docs/main-audit-2026-09-29.md` records seven prioritized +fixes and remaining platform/performance/product work. Frontend tests/build, +Rust check and release-policy checks pass; Rust test failures are explicitly +triaged, not declared green. TASKS carries the open fixes and validation work. +This is an audit/planning milestone; no application fixes shipped in this pass. + +**Main audit repairs implemented (2026-09-29):** A01–A07 now have native +fixes and regression coverage: literal special-name batches, untracked/ignored +hard-reset collision guards, symlink-safe Ignore edits, dangling-link staging, +bounded text reads, protected metadata moves and missing-worktree path identity. +Mac test fixtures now use canonical includeIf paths, local signing/LFS setup, +blocking accepted sockets and child-readiness checkpoints. Rust CI adds macOS. +Cancellation also stops Unix helpers after Git exits and complete provider CLI +process trees before joining pipes. Core/integration, Tauri, frontend, typecheck, +Rust check and clippy pass locally; the audit records counts and limitations. + +**PR #138 review hardening (2026-09-29):** Provider cleanup retains Unix child +identity until signaling completes. Streaming Git on Windows starts suspended, +joins an owned Job Object, then resumes so helper cleanup survives leader exit. +Reset collision checks refresh the index after external Git changes. Nested +tracked paths, stale-index collisions and dead-leader cleanup have regression +coverage; Windows CI runs the relevant native test subsets. +Linux CI also exposed a terminal exit-notification ordering race; the registry +now removes the completed session before notifying observers. +Native packaged-app and production performance certification remain open. + **Performance audit kick (2026-09-06):** Rechecked `main` at `8e83c8c` on Windows against the 100k-commit and 10k-file fixtures. Fresh snapshots remain ~36ms and discover+log(5000) ~76ms; the costly paths are 501-file patch @@ -3137,7 +3168,8 @@ cross-platform performance certification remain explicit follow-ups. 3. ◐ AI features extension point — `CommitMessageGenerator` trait + subscription-first commit suggestions (`repo_suggest_commit_message`, Settings → AI, CommitBar Suggest, ⌘⇧M / palette). - 4. ☐ PR review surface — 1.1 candidate. + 4. ☑ PR review surface — GitHub/Azure workspace implemented; remaining + provider validation is tracked separately in TASKS. 5. ☑ Pricing — free for all, honor-system paid commercial license. - **Naming & trademark.** USPTO/EUIPO/WIPO search before 0.5 public launch. diff --git a/TASKS.md b/TASKS.md index 073d882..653ac9e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -94,6 +94,59 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git- ## strand-core (Rust git engine) +### Main audit follow-ups (2026-09-29) + +Evidence and acceptance criteria: [`docs/main-audit-2026-09-29.md`](./docs/main-audit-2026-09-29.md). + +- ☑ Audit latest main for correctness, safety, performance and remaining work + (`main-audit-2026-09-29.md`; disposable engine probes, frontend/build checks, + Rust failure triage and live protocol-7 availability check). +- ☑ **A01 / P1 — Literal file targeting.** Prevent discard/unstage from + expanding selected filenames as wildcard pathspecs; regress `[id].tsx` + alongside `i.tsx` through single and bulk actions (`run_literal_paths`, + literal-path regression tests; ordinary batches remain in-process). +- ☑ **A02 / P1 — Hard-reset collision recovery.** Refuse or preserve + untracked/ignored content threatened by the target tree before reset; match + the dialog's recovery promise to actual coverage (`guard_reset_tree`, + `guard_replaced_directory`, `ResetDialog`; normal/sparse/LFS fixtures). +- ☑ **A03 / P1 — Ignore-file write boundary.** Refuse symlink/nonregular + `.gitignore` targets and external-path writes (`Repo::gitignore_add`). +- ☑ **A04 / P2 — Dangling-symlink staging.** Use entry existence rather than + referent existence (`entry_exists`; new/modified single/batch link regressions). +- ☑ **A05 / P2 — Bound file content reads.** Enforce the content cap before + allocation/read (`file_content` bounded prefix; 1 GiB/UTF-8 regression, + 18.6 MB peak RSS for the test process). +- ☑ **A06 / P2 — Administrative rename destinations.** Reject moves into/out + of `.git` and its aliases before filesystem mutation (`guard_move_metadata`; + ordinary and linked-worktree regression tests). +- ☑ **A07 / P2 — Missing-worktree path identity.** Make registered missing + targets match macOS path aliases without relaxing recovery guards + (`resolve_missing_path`; existing macOS removal and archive guard tests pass). +- ☑ **Validation follow-up.** Fixed signing/identity/LFS test isolation, + blocking accepted sockets on macOS, and cancellation readiness; added macOS + Rust CI (`ci.yml` matrix, fixture configuration and readiness checkpoints). + Full core/integration and Tauri suites pass; optional integrations remain + explicitly ignored. Detailed evidence is in the audit implementation update. +- ☑ **Cancellation follow-up.** Stop Unix Git helpers after leader exit, and + own provider-command process trees through cancellation, timeout and natural + completion (`kill_git_tree`, `run_command_input_cancellable`; helper regressions). +- ☐ Bound hosted-provider CLI stdout/stderr while reading, with explicit + overflow cancellation (`run_command_input_cancellable` still reads to EOF; + separate from the completed process-tree cancellation repair). +- ☑ **PR #138 review follow-up.** Keep provider leaders unreaped until Unix + group cleanup (`provider_exited` / `waitid(WNOWAIT)`); own Windows streaming + Git helpers in a Job Object assigned before execution (`WindowsJob::spawn`); + refresh the reset index before collision checks after external Git changes + (`Index::read(true)`, stale-index and nested-path regressions). The claimed + Windows separator bug was disproved against git2 0.19's path conversion. +- ☑ **PR #138 Linux CI follow-up.** Remove naturally exited terminal sessions + before publishing Exit/Error, so observers cannot see a dead terminal as + active (`terminal_reader`; synchronous count-at-exit regression). +- ◐ **Planning reconciliation.** Resolved stale PRD licensing/pricing and + ROADMAP PR-review claims; protocol-7 availability is verified. Current native + release/performance certification and historical external publication/Store/SEO + rows still require platform or provider evidence (audit implementation update). + ### Git-client feature audit follow-ups (2026-09-06) - ☑ Audit the current Git-client feature surface against implementation diff --git a/crates/strand-core/Cargo.toml b/crates/strand-core/Cargo.toml index 21ac9e6..c3eb286 100644 --- a/crates/strand-core/Cargo.toml +++ b/crates/strand-core/Cargo.toml @@ -24,3 +24,9 @@ tempfile = "3" [target.'cfg(unix)'.dependencies] libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", + "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading", +] } diff --git a/crates/strand-core/src/commit.rs b/crates/strand-core/src/commit.rs index dcb0ea7..5631328 100644 --- a/crates/strand-core/src/commit.rs +++ b/crates/strand-core/src/commit.rs @@ -322,6 +322,7 @@ mod tests { git(&dir, &["config", "commit.gpgsign", "true"]); git(&dir, &["config", "gpg.format", "ssh"]); + git(&dir, &["config", "gpg.ssh.program", "ssh-keygen"]); let missing = dir.join("no-such-key").to_string_lossy().into_owned(); git(&dir, &["config", "user.signingkey", &missing]); diff --git a/crates/strand-core/src/diff.rs b/crates/strand-core/src/diff.rs index 3bf8e46..439c688 100644 --- a/crates/strand-core/src/diff.rs +++ b/crates/strand-core/src/diff.rs @@ -419,6 +419,7 @@ mod tests { let mut cfg = repo.config().unwrap(); cfg.set_str("user.name", "Test").unwrap(); cfg.set_str("user.email", "test@example.com").unwrap(); + cfg.set_bool("commit.gpgsign", false).unwrap(); } let sig = git2::Signature::now("Test", "test@example.com").unwrap(); let tree_oid = repo.index().unwrap().write_tree().unwrap(); diff --git a/crates/strand-core/src/file.rs b/crates/strand-core/src/file.rs index 10d01ac..bb11d83 100644 --- a/crates/strand-core/src/file.rs +++ b/crates/strand-core/src/file.rs @@ -110,8 +110,19 @@ impl Repo { pub fn file_content(&self, rel_path: &str, rev: Option<&str>) -> Result { match rev { None => { + use std::io::Read; let full = self.safe_workdir_path(rel_path)?; - let bytes = std::fs::read(&full)?; + if !std::fs::metadata(&full)?.is_file() { + return Err(Error::Other(format!("{rel_path} is not a regular file"))); + } + let file = std::fs::File::open(&full)?; + if !file.metadata()?.is_file() { + return Err(Error::Other(format!("{rel_path} is not a regular file"))); + } + // One extra byte establishes truncation even if the file grows + // after stat. Never allocate the complete file to show a prefix. + let mut bytes = Vec::new(); + file.take((MAX_CONTENT_BYTES + 1) as u64).read_to_end(&mut bytes)?; Ok(build_content(rel_path, &bytes, looks_binary(&bytes))) } Some(spec) => { @@ -257,7 +268,12 @@ const MARKER: &str = "\u{1e}C\u{1e}"; fn build_content(path: &str, bytes: &[u8], binary: bool) -> FileContent { let truncated = bytes.len() > MAX_CONTENT_BYTES; - let slice = &bytes[..bytes.len().min(MAX_CONTENT_BYTES)]; + let mut slice = &bytes[..bytes.len().min(MAX_CONTENT_BYTES)]; + if truncated { + if let Err(error) = std::str::from_utf8(slice) { + if error.error_len().is_none() { slice = &slice[..error.valid_up_to()]; } + } + } let text = if binary { String::new() } else { @@ -509,4 +525,30 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn content_reads_are_bounded_and_preserve_utf8_boundaries() { + use std::io::Write; + let (repo, dir) = scratch(); + let mut file = std::fs::File::create(dir.join("large.txt")).unwrap(); + file.write_all(&vec![b'x'; MAX_CONTENT_BYTES - 1]).unwrap(); + file.write_all("😀tail".as_bytes()).unwrap(); + // Sparse large fixture: a complete read would allocate 1 GiB. + file.set_len(1 << 30).unwrap(); + let content = repo.file_content("large.txt", None).unwrap(); + assert!(content.truncated); + assert!(!content.editable); + assert!(!content.binary); + assert_eq!(content.text.len(), MAX_CONTENT_BYTES - 1); + assert!(!content.text.contains('\u{fffd}')); + std::fs::write(dir.join("exact.txt"), vec![b'a'; MAX_CONTENT_BYTES]).unwrap(); + let exact = repo.file_content("exact.txt", None).unwrap(); + assert!(exact.editable); + assert!(!exact.truncated); + std::fs::write(dir.join("binary"), [0, 1, 2]).unwrap(); + assert!(repo.file_content("binary", None).unwrap().binary); + assert!(repo.file_content(".", None).is_err()); + let _ = std::fs::remove_dir_all(dir); + } + } diff --git a/crates/strand-core/src/gitconfig.rs b/crates/strand-core/src/gitconfig.rs index dfa7caf..3508003 100644 --- a/crates/strand-core/src/gitconfig.rs +++ b/crates/strand-core/src/gitconfig.rs @@ -201,6 +201,8 @@ mod tests { let content = "[user]\nname = Conditional\nemail = conditional@example.com\n"; std::fs::write(&included, content).unwrap(); let mut config = repo.git2().unwrap().config().unwrap(); + #[cfg(unix)] + let first = first.canonicalize().unwrap(); let condition = format!("includeIf.gitdir:{}/.git.path", first.to_string_lossy().replace('\\', "/")); config.set_str(&condition, &included.to_string_lossy().replace('\\', "/")).unwrap(); assert_eq!(repo.repository_identity().unwrap().author.identity.as_deref(), Some("Conditional ")); diff --git a/crates/strand-core/src/ignore.rs b/crates/strand-core/src/ignore.rs index dc6724b..30a1d02 100644 --- a/crates/strand-core/src/ignore.rs +++ b/crates/strand-core/src/ignore.rs @@ -5,6 +5,7 @@ //! status walk. use crate::{error::Result, repo::Repo, Error}; +use std::io::{Read, Seek, SeekFrom, Write}; impl Repo { /// Append `pattern` as its own line to the working-tree root `.gitignore`, @@ -17,12 +18,32 @@ impl Repo { return Err(Error::Other("invalid ignore pattern".into())); } - let file = self.path.join(".gitignore"); - let existing = match std::fs::read_to_string(&file) { - Ok(s) => s, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), + let file = self.safe_workdir_path(".gitignore")?; + let mut options = std::fs::OpenOptions::new(); + options.read(true).write(true); + match std::fs::symlink_metadata(&file) { + Ok(meta) if meta.is_file() && !meta.file_type().is_symlink() => {} + Ok(_) => return Err(Error::Other(".gitignore must be a regular file, not a symlink".into())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { options.create_new(true); } Err(e) => return Err(e.into()), - }; + } + // Keep the read and write on one handle, and do not follow a link + // substituted between the metadata check and open. + #[cfg(unix)] { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + #[cfg(windows)] { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(0x00200000); // FILE_FLAG_OPEN_REPARSE_POINT + } + let mut handle = options.open(&file)?; + let meta = handle.metadata()?; + if !meta.is_file() || meta.file_type().is_symlink() { + return Err(Error::Other(".gitignore must be a regular file".into())); + } + let mut existing = String::new(); + handle.read_to_string(&mut existing)?; // `lines()` strips a trailing `\r`, so this also matches CRLF files. if existing.lines().any(|line| line == pattern) { return Ok(()); @@ -34,7 +55,9 @@ impl Repo { } out.push_str(pattern); out.push('\n'); - std::fs::write(&file, out)?; + handle.seek(SeekFrom::Start(0))?; + handle.write_all(out.as_bytes())?; + handle.set_len(out.len() as u64)?; Ok(()) } } @@ -99,4 +122,37 @@ mod tests { assert!(!dir.join(".gitignore").exists(), "rejected patterns write nothing"); let _ = std::fs::remove_dir_all(dir); } + + #[cfg(unix)] + #[test] + fn refuses_existing_and_dangling_symlink_targets() { + let (repo, dir) = scratch_repo(); + let external = tempfile::tempdir().unwrap(); + let target = external.path().join("config"); + for existing in [true, false] { + if existing { std::fs::write(&target, "untouched\n").unwrap(); } + std::os::unix::fs::symlink(&target, dir.join(".gitignore")).unwrap(); + assert!(repo.gitignore_add("/build").is_err()); + if existing { + assert_eq!(std::fs::read_to_string(&target).unwrap(), "untouched\n"); + std::fs::remove_file(&target).unwrap(); + } else { assert!(!target.exists()); } + std::fs::remove_file(dir.join(".gitignore")).unwrap(); + } + // In-tree links are also refused, even though containment passes. + std::fs::write(dir.join("local"), "untouched").unwrap(); + std::os::unix::fs::symlink("local", dir.join(".gitignore")).unwrap(); + assert!(repo.gitignore_add("/build").is_err()); + assert_eq!(std::fs::read_to_string(dir.join("local")).unwrap(), "untouched"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn refuses_directory_ignore_file() { + let (repo, dir) = scratch_repo(); + std::fs::create_dir(dir.join(".gitignore")).unwrap(); + assert!(repo.gitignore_add("/build").is_err()); + let _ = std::fs::remove_dir_all(dir); + } + } diff --git a/crates/strand-core/src/lfs.rs b/crates/strand-core/src/lfs.rs index 6904173..54564e6 100644 --- a/crates/strand-core/src/lfs.rs +++ b/crates/strand-core/src/lfs.rs @@ -362,6 +362,7 @@ mod tests { consumer.to_str().unwrap(), ], ); + git(&consumer, &["lfs", "install", "--local"]); Repo::discover(&consumer) .unwrap() .checkout_branch("next") @@ -487,6 +488,8 @@ mod tests { Err(e) => panic!("{e}"), } }; + // Accepted sockets inherit nonblocking mode on macOS. + stream.set_nonblocking(false).unwrap(); stream .set_read_timeout(Some(std::time::Duration::from_secs(30))) .unwrap(); @@ -537,4 +540,22 @@ mod tests { )); let _ = std::fs::remove_dir_all(dir); } + #[test] + fn hard_reset_refuses_untracked_lfs_collision_before_filtering() { + let (repo, dir) = fixture(); + std::fs::write(dir.join("asset.bin"), "committed asset").unwrap(); + git(&dir, &["add", "."]); + git(&dir, &["commit", "-qm", "asset"]); + git(&dir, &["rm", "asset.bin"]); + git(&dir, &["commit", "-qm", "remove asset"]); + std::fs::write(dir.join("asset.bin"), "local asset").unwrap(); + let head = git(&dir, &["rev-parse", "HEAD"]); + let error = repo.reset("HEAD~1", crate::reset::ResetMode::Hard).unwrap_err(); + assert!(error.to_string().contains("untracked or ignored"), "{error}"); + assert_eq!(std::fs::read_to_string(dir.join("asset.bin")).unwrap(), "local asset"); + assert_eq!(git(&dir, &["rev-parse", "HEAD"]), head); + assert!(repo.stash_list().unwrap().is_empty()); + let _ = std::fs::remove_dir_all(dir); + } + } diff --git a/crates/strand-core/src/lib.rs b/crates/strand-core/src/lib.rs index 71a11ba..b3deec4 100644 --- a/crates/strand-core/src/lib.rs +++ b/crates/strand-core/src/lib.rs @@ -58,6 +58,8 @@ pub mod reset; pub mod snapshot; pub mod sparse; pub mod watch; +#[cfg(windows)] +pub mod windows_job; pub use error::{Error, Result}; pub use repo::Repo; diff --git a/crates/strand-core/src/network.rs b/crates/strand-core/src/network.rs index 1ce0c80..9227b50 100644 --- a/crates/strand-core/src/network.rs +++ b/crates/strand-core/src/network.rs @@ -32,7 +32,13 @@ pub struct CancelHandle(Arc>); #[derive(Default)] struct CancelInner { cancelled: bool, - child: Option, + child: Option, +} + +struct GitChild { + process: std::process::Child, + #[cfg(windows)] + job: crate::windows_job::WindowsJob, } impl CancelHandle { @@ -49,7 +55,7 @@ impl CancelHandle { // Kill their tree off the IPC thread so cancellation stays immediate. std::thread::spawn(move || { let mut inner = handle.0.lock().expect("cancel handle lock"); - if let Some(child) = inner.child.as_mut() { kill_git_tree(child); } + if let Some(child) = inner.child.as_mut() { kill_owned_git_tree(child); } }); } } @@ -59,18 +65,31 @@ impl CancelHandle { } } +// Streaming Git retains a job handle on Windows, so cancellation does not +// depend on whether the wrapper PID is still running. +fn kill_owned_git_tree(child: &mut GitChild) { + #[cfg(windows)] { + child.job.terminate(); + let _ = child.process.kill(); + } + #[cfg(not(windows))] + kill_git_tree(&mut child.process); +} + // Git LFS and submodule helpers inherit the pipes. Killing only git can leave // those helpers transferring (and the reader waiting for EOF) after Cancel. pub(crate) fn kill_git_tree(child: &mut std::process::Child) { - if matches!(child.try_wait(), Ok(Some(_))) { return; } #[cfg(windows)] { + if matches!(child.try_wait(), Ok(Some(_))) { return; } use std::os::windows::process::CommandExt; let system = std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into()); let _ = std::process::Command::new(Path::new(&system).join("System32/taskkill.exe")) .args(["/PID", &child.id().to_string(), "/T", "/F"]) .creation_flags(0x0800_0000).stdout(Stdio::null()).stderr(Stdio::null()).status(); } + // On Unix a helper can retain the pipes after Git has exited. Signal the + // owned group before reaping its leader, including this natural-exit case. #[cfg(unix)] unsafe { libc::kill(-(child.id() as i32), libc::SIGKILL); } let _ = child.kill(); @@ -741,7 +760,7 @@ pub(crate) fn run_git_input_transcript( use std::os::unix::process::CommandExt; command.process_group(0); } - let mut child = command.current_dir(cwd) + command.current_dir(cwd) .env("GIT_TERMINAL_PROMPT", "0") // Neutralize repo-local config that would run code as a side effect. .args(crate::GIT_SAFE_CONFIG) @@ -749,8 +768,11 @@ pub(crate) fn run_git_input_transcript( .args(args) .stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() }) .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() + .stderr(Stdio::piped()); + #[cfg(windows)] + let (mut child, job) = crate::windows_job::WindowsJob::spawn(&mut command).map_err(Error::Other)?; + #[cfg(not(windows))] + let mut child = command.spawn() .map_err(|e| Error::Other(format!("spawn git failed: {e}")))?; // Drain stdout on a separate thread so a large stdout can't deadlock us @@ -770,6 +792,11 @@ pub(crate) fn run_git_input_transcript( }) }); let stderr = child.stderr.take(); + let mut child = GitChild { + process: child, + #[cfg(windows)] + job, + }; // Park the child in the cancel handle (pipes already taken) so a // concurrent `cancel()` can kill it. A cancel that raced the spawn is @@ -778,8 +805,8 @@ pub(crate) fn run_git_input_transcript( { let mut inner = handle.0.lock().expect("cancel handle lock"); if inner.cancelled { - kill_git_tree(&mut child); - let _ = child.wait(); + kill_owned_git_tree(&mut child); + let _ = child.process.wait(); return Err(Error::Cancelled); } inner.child = Some(child); @@ -811,6 +838,7 @@ pub(crate) fn run_git_input_transcript( taken .as_mut() .expect("child parked above") + .process .wait() .map_err(|e| Error::Other(format!("git wait failed: {e}")))? }; @@ -842,6 +870,52 @@ fn append_output(output: &mut String, text: &str) { mod bounded_process_tests { use super::*; + #[cfg(windows)] + #[test] + fn windows_job_fixture() { + let Ok(mode) = std::env::var("STRAND_JOB_FIXTURE") else { return; }; + let ready = std::env::var_os("STRAND_JOB_READY").unwrap(); + if mode == "helper" { + std::fs::write(ready, "ready").unwrap(); + std::thread::sleep(std::time::Duration::from_secs(30)); + } else { + let _helper = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "network::bounded_process_tests::windows_job_fixture", "--nocapture"]) + .env("STRAND_JOB_FIXTURE", "helper") + .stdin(Stdio::null()).stdout(Stdio::inherit()).stderr(Stdio::inherit()) + .spawn().unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !Path::new(&ready).exists() { + assert!(std::time::Instant::now() < deadline); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + } + + #[cfg(windows)] + #[test] + fn windows_cancellation_kills_helpers_after_leader_is_reaped() { + let dir = tempfile::tempdir().unwrap(); + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command.args(["--exact", "network::bounded_process_tests::windows_job_fixture", "--nocapture"]) + .env("STRAND_JOB_FIXTURE", "parent").env("STRAND_JOB_READY", dir.path().join("ready")) + .stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()); + let (mut process, job) = crate::windows_job::WindowsJob::spawn(&mut command).unwrap(); + let mut stdout = process.stdout.take().unwrap(); + let (sent, received) = std::sync::mpsc::channel(); + let reader = std::thread::spawn(move || { + let mut output = Vec::new(); + stdout.read_to_end(&mut output).unwrap(); + let _ = sent.send(output); + }); + assert!(process.wait().unwrap().success()); + assert!(received.try_recv().is_err(), "helper must still hold the pipe"); + let mut child = GitChild { process, job }; + kill_owned_git_tree(&mut child); + assert!(received.recv_timeout(std::time::Duration::from_secs(5)).is_ok(), "helper survived cancellation"); + reader.join().unwrap(); + } + #[test] fn output_tail_stays_bounded_and_marks_partial_unicode_output() { let mut output = String::new(); @@ -855,11 +929,29 @@ mod bounded_process_tests { let cancel = CancelHandle::new(); let mut cancelled_at = None; let result = run_git_streaming(std::env::temp_dir().as_path(), - &["-c", "alias.strand-cancel-test=!echo strand-ready >&2; sleep 60", "strand-cancel-test"], + &["-c", "alias.strand-cancel-test=!sleep 60 & echo strand-ready >&2; wait", "strand-cancel-test"], |p| { if p.raw.contains("strand-ready") { cancelled_at = Some(std::time::Instant::now()); cancel.cancel(); } }, Some(&cancel)); assert!(matches!(result, Err(Error::Cancelled))); assert!(cancelled_at.unwrap().elapsed().as_secs() < 15, "descendants kept pipes open after cancellation"); } + + #[cfg(unix)] + #[test] + fn cancellation_kills_helpers_after_git_exits() { + let cancel = CancelHandle::new(); + let mut cancelled_at = None; + let result = run_git_streaming(std::env::temp_dir().as_path(), + &["-c", "alias.strand-cancel-test=!sleep 60 & echo strand-ready >&2", "strand-cancel-test"], + |p| { + if p.raw.contains("strand-ready") { + std::thread::sleep(std::time::Duration::from_millis(200)); + cancelled_at = Some(std::time::Instant::now()); + cancel.cancel(); + } + }, Some(&cancel)); + assert!(matches!(result, Err(Error::Cancelled))); + assert!(cancelled_at.unwrap().elapsed().as_secs() < 15); + } } /// Pull the meaningful failure out of a git transcript. git streams progress to diff --git a/crates/strand-core/src/rename.rs b/crates/strand-core/src/rename.rs index 4520e7d..9a243a9 100644 --- a/crates/strand-core/src/rename.rs +++ b/crates/strand-core/src/rename.rs @@ -29,6 +29,8 @@ impl Repo { let full_from = self.safe_workdir_path(from)?; let full_to = self.safe_dest_path(to)?; + self.guard_move_metadata(from, &full_from)?; + self.guard_move_metadata(to, &full_to)?; // `exists()` follows symlinks; a dangling in-tree symlink is still a // movable entry, so probe with symlink_metadata. @@ -73,6 +75,27 @@ impl Repo { .any(|e| e.path == exact || e.path.starts_with(&prefix))) } + fn guard_move_metadata(&self, rel: &str, full: &Path) -> Result<()> { + if Path::new(rel).components().any(|part| part.as_os_str().to_str().is_some_and(|part| part.eq_ignore_ascii_case(".git"))) { + return Err(Error::Other("working-tree moves cannot modify .git".into())); + } + // Resolve the nearest existing ancestor for a new destination, and + // protect aliases into both the worktree Git dir and shared metadata. + let existing = full.ancestors().find(|path| path.exists()) + .ok_or_else(|| Error::Other("cannot resolve move path".into()))?; + let resolved = existing.canonicalize()?; + let root = self.path.canonicalize()?; + if resolved == root && full == existing { + return Err(Error::Other("cannot move the working-tree root".into())); + } + for git_dir in [self.git_dir(), self.gix.common_dir()] { + if resolved.starts_with(git_dir.canonicalize()?) { + return Err(Error::Other("working-tree moves cannot modify Git metadata".into())); + } + } + Ok(()) + } + /// [`safe_workdir_path`](Repo::safe_workdir_path) for a destination whose /// parent directories may not exist yet: canonicalize the nearest /// *existing* ancestor instead of the immediate parent, so `new/dir/file` @@ -220,4 +243,51 @@ mod tests { assert!(dir.join("a.txt").exists()); let _ = std::fs::remove_dir_all(dir); } + + #[test] + fn rejects_administrative_moves_before_creating_directories() { + let (repo, dir) = scratch_repo("metadata"); + std::fs::write(dir.join("untracked"), "keep").unwrap(); + let config = std::fs::read(dir.join(".git/config")).unwrap(); + for (from, to) in [("untracked", ".git/new/entry"), (".git/config", "config"), ("untracked", "nested/.git/new"), (".", "moved-root")] { + assert!(repo.move_path(from, to).is_err(), "{from} -> {to}"); + } + assert_eq!(std::fs::read(dir.join(".git/config")).unwrap(), config); + assert!(dir.join("untracked").exists()); + assert!(!dir.join("nested").exists()); + assert!(!dir.join(".git/new").exists()); + let _ = std::fs::remove_dir_all(dir); + } + + #[cfg(unix)] + #[test] + fn rejects_git_directory_aliases() { + let (repo, dir) = scratch_repo("metadata-alias"); + std::fs::write(dir.join("untracked"), "keep").unwrap(); + std::os::unix::fs::symlink(".git", dir.join("alias")).unwrap(); + assert!(repo.move_path("untracked", "alias/new/deep").is_err()); + assert!(repo.move_path("alias/config", "stolen-config").is_err()); + assert!(dir.join("untracked").exists()); + assert!(!dir.join(".git/new").exists()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn linked_worktree_moves_cannot_modify_git_file_or_shared_metadata() { + let (repo, dir) = scratch_repo("linked-metadata"); + std::fs::write(dir.join("tracked"), "base").unwrap(); + commit_all(&dir); + let linked_parent = tempfile::tempdir().unwrap(); + let linked = linked_parent.path().join("linked"); + repo.add_worktree(linked.to_str().unwrap(), "linked", true, None, false).unwrap(); + let linked_repo = Repo::discover(&linked).unwrap(); + std::fs::write(linked.join("untracked"), "keep").unwrap(); + let git_file = std::fs::read(linked.join(".git")).unwrap(); + assert!(linked_repo.move_path(".git", "moved-git-file").is_err()); + assert!(linked_repo.move_path("untracked", ".git/new").is_err()); + assert_eq!(std::fs::read(linked.join(".git")).unwrap(), git_file); + assert!(linked.join("untracked").exists()); + let _ = std::fs::remove_dir_all(dir); + } + } diff --git a/crates/strand-core/src/reset.rs b/crates/strand-core/src/reset.rs index dcb5f41..6e01241 100644 --- a/crates/strand-core/src/reset.rs +++ b/crates/strand-core/src/reset.rs @@ -1,12 +1,9 @@ //! `git reset` — move HEAD (and per mode the index / working tree) to a //! target commit. //! -//! Hard resets of a dirty tree take a safety snapshot first (the same -//! stash-based net `discard_paths` callers use), so "discard all changes" -//! is always recoverable from the stash stack. "Dirty" means tracked -//! changes only — `git reset --hard` never touches untracked files, so an -//! untracked-only tree needs no snapshot and the snapshot itself skips -//! untracked files (the lighter `stash create` path, no working-tree churn). +//! Hard resets snapshot tracked changes and refuse collisions with untracked +//! or ignored data. Unrelated untracked entries remain untouched; snapshots +//! use `stash create` without a working-tree push/apply round trip. use serde::{Deserialize, Serialize}; @@ -57,13 +54,13 @@ impl Repo { .and_then(|b| b.as_str().map(str::to_string)) .unwrap_or_else(|| target.to_string()); - // A hard reset destroys uncommitted *tracked* work — snapshot it onto - // the stash stack first, mirroring discardMany's safety net. Untracked - // files survive a hard reset untouched, so a pure-WT_NEW entry doesn't - // count as dirty and the snapshot skips untracked files (avoiding the - // push+apply round-trip that can fail on Windows file locks). + // Reject untracked/ignored collisions before even taking a snapshot. + // This guard applies to libgit2, sparse, partial-clone and LFS resets. let mut snapshot_oid = None; if matches!(mode, ResetMode::Hard) { + if self.sparse_enabled() { self.sparse_read_index(repo)?; } + else { repo.index()?.read(true)?; } + guard_reset_tree(repo, &repo.index()?, &obj.peel_to_tree()?, self.path(), std::path::Path::new(""))?; let dirty = if self.sparse_enabled() { self.status()?.iter().any(|entry| entry.kind != crate::status::StatusKind::Untracked) } else { repo @@ -102,6 +99,53 @@ impl Repo { } } +fn collision(path: &std::path::Path) -> Error { + Error::Other(format!("Hard reset would overwrite untracked or ignored data at {}. Move or commit it before retrying.", path.display())) +} + +/// Inspect only target paths and directories that would be replaced. In +/// particular, do not enumerate unrelated ignored build/dependency trees. +fn guard_reset_tree(repo: &git2::Repository, index: &git2::Index, tree: &git2::Tree<'_>, root: &std::path::Path, parent: &std::path::Path) -> Result<()> { + for entry in tree.iter() { + #[cfg(unix)] + let name = { + use std::os::unix::ffi::OsStrExt; + std::ffi::OsStr::from_bytes(entry.name_bytes()) + }; + #[cfg(not(unix))] + let name = entry.name().ok_or_else(|| Error::Other("Cannot safely reset a non-UTF-8 target path".into()))?; + let rel = parent.join(name); + let metadata = match std::fs::symlink_metadata(root.join(&rel)) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(error.into()), + }; + if entry.kind() == Some(git2::ObjectType::Tree) && metadata.is_dir() { + guard_reset_tree(repo, index, &repo.find_tree(entry.id())?, root, &rel)?; + } else if metadata.is_dir() && entry.kind() != Some(git2::ObjectType::Commit) { + guard_replaced_directory(index, root, &rel)?; + } else if index.get_path(&rel, 0).is_none() { + return Err(collision(&rel)); + } + // A tracked file/symlink blocking a target directory is itself + // snapshotted; never follow it to inspect external descendants. + } + Ok(()) +} + +fn guard_replaced_directory(index: &git2::Index, root: &std::path::Path, rel: &std::path::Path) -> Result<()> { + for child in std::fs::read_dir(root.join(rel))? { + let child = child?; + let path = rel.join(child.file_name()); + if child.file_type()?.is_dir() { + guard_replaced_directory(index, root, &path)?; + } else if index.get_path(&path, 0).is_none() { + return Err(collision(&path)); + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -143,6 +187,39 @@ mod tests { git(dir, &["rev-parse", "HEAD"]) } + #[test] + fn hard_reset_refreshes_index_before_checking_untracked_collisions() { + let (repo, dir) = scratch_repo(); + let target = write_commit(&dir, "collision", "committed", "target"); + // Prime the cached libgit2 index, then change it through external Git. + repo.reset(&target, ResetMode::Hard).unwrap(); + git(&dir, &["rm", "collision"]); + git(&dir, &["commit", "-qm", "remove collision"]); + std::fs::write(dir.join("collision"), "keep local").unwrap(); + let head = git(&dir, &["rev-parse", "HEAD"]); + assert!(repo.reset(&target, ResetMode::Hard).is_err()); + assert_eq!(std::fs::read_to_string(dir.join("collision")).unwrap(), "keep local"); + assert_eq!(git(&dir, &["rev-parse", "HEAD"]), head); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn hard_reset_accepts_nested_tracked_paths_and_directory_replacements() { + let (repo, dir) = scratch_repo(); + std::fs::create_dir_all(dir.join("nested/deeper")).unwrap(); + let first = write_commit(&dir, "nested/deeper/file", "first", "nested base"); + write_commit(&dir, "nested/deeper/file", "second", "nested update"); + repo.reset(&first, ResetMode::Hard).unwrap(); + assert_eq!(std::fs::read_to_string(dir.join("nested/deeper/file")).unwrap(), "first"); + // Exercise the other guard: tracked descendants may be replaced by a file. + git(&dir, &["rm", "-r", "nested"]); + let file_commit = write_commit(&dir, "nested", "replacement", "replace directory"); + repo.reset(&first, ResetMode::Hard).unwrap(); + repo.reset(&file_commit, ResetMode::Hard).unwrap(); + assert_eq!(std::fs::read_to_string(dir.join("nested")).unwrap(), "replacement"); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn soft_reset_moves_head_and_keeps_changes_staged() { let (repo, dir) = scratch_repo(); @@ -197,8 +274,7 @@ mod tests { let (repo, dir) = scratch_repo(); let first = write_commit(&dir, "a.txt", "one\n", "first"); write_commit(&dir, "a.txt", "two\n", "second"); - // Untracked-only "dirt": `git reset --hard` never touches untracked - // files, so no snapshot is needed (and none should be taken). + // An unrelated untracked file survives; no snapshot is needed. std::fs::write(dir.join("new.txt"), "untracked\n").unwrap(); let outcome = repo.reset("HEAD~1", ResetMode::Hard).unwrap(); @@ -244,4 +320,61 @@ mod tests { ); let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn hard_reset_refuses_untracked_and_ignored_target_collisions() { + // Exercise both the normal and sparse-index dispatches, before any + // reset/filter command can modify HEAD, the index or working bytes. + for sparse in [false, true] { + for ignored in [false, true] { + let (_repo, dir) = scratch_repo(); + std::fs::create_dir_all(dir.join("included")).unwrap(); + std::fs::create_dir_all(dir.join("excluded")).unwrap(); + std::fs::write(dir.join("included/file"), "included").unwrap(); + std::fs::write(dir.join("excluded/file"), "excluded").unwrap(); + git(&dir, &["add", "included", "excluded"]); + write_commit(&dir, "collision", "committed", "target"); + git(&dir, &["rm", "collision"]); + std::fs::write(dir.join("keep"), "keep").unwrap(); + git(&dir, &["add", "keep"]); + git(&dir, &["commit", "-qm", "remove collision"]); + if sparse { + git(&dir, &["sparse-checkout", "set", "--cone", "--sparse-index", "included"]); + assert!(git(&dir, &["ls-files", "--sparse"]).contains("excluded/")); + } + if ignored { std::fs::write(dir.join(".git/info/exclude"), "collision\n").unwrap(); } + std::fs::write(dir.join("collision"), "irreplaceable").unwrap(); + let head = git(&dir, &["rev-parse", "HEAD"]); + let index = std::fs::read(dir.join(".git/index")).unwrap(); + let repo = Repo::discover(&dir).unwrap(); + let error = repo.reset("HEAD~1", ResetMode::Hard).unwrap_err(); + assert!(error.to_string().contains("untracked or ignored"), "{error}"); + assert_eq!(std::fs::read_to_string(dir.join("collision")).unwrap(), "irreplaceable"); + assert_eq!(git(&dir, &["rev-parse", "HEAD"]), head); + assert_eq!(std::fs::read(dir.join(".git/index")).unwrap(), index); + assert!(repo.stash_list().unwrap().is_empty()); + let _ = std::fs::remove_dir_all(dir); + } + } + } + + #[test] + fn hard_reset_refuses_file_directory_collisions_but_keeps_unrelated_data() { + for target_directory in [false, true] { + let (_repo, dir) = scratch_repo(); + if target_directory { std::fs::create_dir(dir.join("target")).unwrap(); } + let path = if target_directory { "target/file" } else { "target" }; + write_commit(&dir, path, "old", "target"); + git(&dir, &["rm", "-r", "target"]); + git(&dir, &["commit", "-qm", "remove target"]); + let untracked = if target_directory { "target" } else { "target/ignored/local" }; + if !target_directory { std::fs::create_dir_all(dir.join("target/ignored")).unwrap(); } + std::fs::write(dir.join(".git/info/exclude"), "target\n").unwrap(); + std::fs::write(dir.join(untracked), "keep").unwrap(); + let repo = Repo::discover(&dir).unwrap(); + assert!(repo.reset("HEAD~1", ResetMode::Hard).is_err()); + assert_eq!(std::fs::read_to_string(dir.join(untracked)).unwrap(), "keep"); + let _ = std::fs::remove_dir_all(dir); + } + } + } diff --git a/crates/strand-core/src/stage.rs b/crates/strand-core/src/stage.rs index db5e4f3..1f49f31 100644 --- a/crates/strand-core/src/stage.rs +++ b/crates/strand-core/src/stage.rs @@ -14,7 +14,7 @@ impl Repo { let mut index = repo.index()?; let on_disk = repo.workdir().map(|w| w.join(path)); - let exists = on_disk.as_deref().map(Path::exists).unwrap_or(false); + let exists = on_disk.as_deref().map(entry_exists).transpose()?.unwrap_or(false); if exists { index.add_path(Path::new(path))?; @@ -50,7 +50,7 @@ impl Repo { let workdir = repo.workdir().map(Path::to_path_buf); for path in paths { let p = Path::new(path); - let exists = workdir.as_deref().map(|w| w.join(path).exists()).unwrap_or(false); + let exists = workdir.as_deref().map(|w| entry_exists(&w.join(path))).transpose()?.unwrap_or(false); if exists { index.add_path(p)?; } else { @@ -68,6 +68,12 @@ impl Repo { if paths.is_empty() { return Ok(()); } + if paths.iter().any(|path| needs_literal_pathspec(path)) { + return self.run_literal_paths( + &["--literal-pathspecs", "reset", "--pathspec-from-file=-", "--pathspec-file-nul"], + paths.iter().map(String::as_str), + ); + } if self.sparse_enabled() { let mut args = vec!["--literal-pathspecs", "restore", "--staged", "--"]; args.extend(paths.iter().map(String::as_str)); @@ -129,6 +135,11 @@ impl Repo { self.sparse_git(&args, None)?; return Ok(()); } + // git2 0.19 does not expose checkout's literal-pathspec flag. + // checkout-index consumes exact filenames; ordinary batches stay in-process. + if tracked.iter().any(|path| needs_literal_pathspec(path)) { + return self.run_literal_paths(&["checkout-index", "--force", "-z", "--stdin"], tracked.into_iter()); + } let mut opts = git2::build::CheckoutBuilder::new(); // This command opened a fresh repository + index above, so there // is nothing stale to refresh. More importantly, libgit2's refresh @@ -156,7 +167,7 @@ impl Repo { /// without touching the working tree. Equivalent to /// `git restore --staged `. pub fn unstage_path(&self, path: &str) -> Result<()> { - if self.sparse_enabled() { return self.unstage_paths(&[path.into()]); } + if self.sparse_enabled() || needs_literal_pathspec(path) { return self.unstage_paths(&[path.into()]); } let repo = self.git2()?; match repo.head().ok().map(|h| h.peel_to_commit()) { // No HEAD yet (unborn branch): just drop the index entry. @@ -173,6 +184,18 @@ impl Repo { Ok(()) } + fn run_literal_paths<'a>(&self, args: &[&str], paths: impl Iterator) -> Result<()> { + let mut input = Vec::new(); + for path in paths { + if path.contains('\0') { return Err(crate::Error::Other("Invalid file path".into())); } + input.extend_from_slice(path.as_bytes()); + input.push(0); + } + let result = crate::network::run_git_input_transcript(&self.path, args, Some(input), |_| {}, None)?; + if !result.success { return Err(crate::Error::Other(result.output)); } + Ok(()) + } + /// Discard working-tree changes for `path` — restore the file from the /// index (mirrors `git checkout -- `), or delete it if untracked. /// @@ -183,6 +206,19 @@ impl Repo { } } +// libgit2 parses glob characters, leading comments/negation and whitespace. +fn needs_literal_pathspec(path: &str) -> bool { + path.bytes().any(|byte| b"*?[]\\!#:".contains(&byte) || byte.is_ascii_whitespace()) +} + +fn entry_exists(path: &Path) -> std::io::Result { + match std::fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} + #[cfg(windows)] fn is_windows_path_too_long(error: &git2::Error) -> bool { error.class() == git2::ErrorClass::Filesystem @@ -227,6 +263,7 @@ mod tests { let mut cfg = repo.config().unwrap(); cfg.set_str("user.name", "Test").unwrap(); cfg.set_str("user.email", "test@example.com").unwrap(); + cfg.set_bool("commit.gpgsign", false).unwrap(); } let sig = git2::Signature::now("Test", "test@example.com").unwrap(); let tree_oid = repo.index().unwrap().write_tree().unwrap(); @@ -358,4 +395,67 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + + #[test] + fn literal_discard_and_unstage_preserve_unselected_files() { + let (repo, dir) = scratch_repo(); + let names = vec!["[id].tsx", "i.tsx", "!bang", "#hash", "space name"]; + #[cfg(unix)] + let names = [names, vec!["a*.txt", "abc.txt", "q?.txt", "qa.txt", ":(glob)*", "back\\slash"]].concat(); + let paths: Vec = names.iter().map(|name| (*name).into()).collect(); + for name in &names { std::fs::write(dir.join(name), "base").unwrap(); } + repo.stage_paths(&paths).unwrap(); + repo.commit("literal base", None, false).unwrap(); + for name in &names { std::fs::write(dir.join(name), "edited").unwrap(); } + repo.discard_path("[id].tsx").unwrap(); + assert_eq!(std::fs::read_to_string(dir.join("[id].tsx")).unwrap(), "base"); + assert_eq!(std::fs::read_to_string(dir.join("i.tsx")).unwrap(), "edited"); + let selected: Vec = paths.iter().filter(|name| needs_literal_pathspec(name)).cloned().collect(); + repo.discard_paths(&selected).unwrap(); + for name in &selected { assert_eq!(std::fs::read_to_string(dir.join(name)).unwrap(), "base"); } + assert_eq!(std::fs::read_to_string(dir.join("i.tsx")).unwrap(), "edited"); + for name in &names { std::fs::write(dir.join(name), "staged").unwrap(); } + repo.stage_paths(&paths).unwrap(); + repo.unstage_path("[id].tsx").unwrap(); + let status = repo.status().unwrap(); + assert!(!staged_paths(&status).contains(&"[id].tsx")); + assert!(staged_paths(&status).contains(&"i.tsx")); + repo.unstage_paths(&selected).unwrap(); + let status = repo.status().unwrap(); + assert!(staged_paths(&status).contains(&"i.tsx")); + for name in &selected { assert!(!staged_paths(&status).contains(&name.as_str())); } + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn literal_unstage_works_before_first_commit() { + let dir = tempfile::tempdir().unwrap(); + git2::Repository::init(dir.path()).unwrap(); + let repo = Repo::discover(dir.path()).unwrap(); + for name in ["[id].tsx", "i.tsx"] { std::fs::write(dir.path().join(name), "new").unwrap(); } + repo.stage_paths(&["[id].tsx".into(), "i.tsx".into()]).unwrap(); + repo.unstage_path("[id].tsx").unwrap(); + assert_eq!(staged_paths(&repo.status().unwrap()), ["i.tsx"]); + } + + #[cfg(unix)] + #[test] + fn stages_new_and_modified_dangling_links_singly_and_in_bulk() { + let (repo, dir) = scratch_repo(); + for bulk in [false, true] { + let name = if bulk { "bulk-link" } else { "single-link" }; + for target in ["missing-first", "missing-second"] { + let _ = std::fs::remove_file(dir.join(name)); + std::os::unix::fs::symlink(target, dir.join(name)).unwrap(); + if bulk { repo.stage_paths(&[name.into()]).unwrap(); } + else { repo.stage_path(name).unwrap(); } + let entry = repo.git2().unwrap().index().unwrap().get_path(Path::new(name), 0).unwrap(); + assert_eq!(entry.mode, 0o120000); + assert_eq!(repo.git2().unwrap().find_blob(entry.id).unwrap().content(), target.as_bytes()); + repo.commit("link", None, false).unwrap(); + } + } + let _ = std::fs::remove_dir_all(dir); + } + } diff --git a/crates/strand-core/src/windows_job.rs b/crates/strand-core/src/windows_job.rs new file mode 100644 index 0000000..108351d --- /dev/null +++ b/crates/strand-core/src/windows_job.rs @@ -0,0 +1,103 @@ +//! Owned Windows process jobs shared by Git and desktop command runners. + +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; +use std::process::{Child, Command}; +use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, + SetInformationJobObject, TerminateJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, +}; + +pub struct WindowsJob(OwnedHandle); + +impl WindowsJob { + pub fn assign(child: &Child) -> Result { + // SAFETY: null arguments request an unnamed job with default security; + // ownership transfers to OwnedHandle only after checking the result. + let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + if handle.is_null() { return Err(last_error("create cancellation job")); } + let job = Self(unsafe { OwnedHandle::from_raw_handle(handle) }); + // Use the child's owned handle rather than reopening a numeric PID. + if unsafe { AssignProcessToJobObject(job.0.as_raw_handle(), child.as_raw_handle()) } == 0 { + return Err(last_error("assign process to cancellation job")); + } + Ok(job) + } + + pub fn kill_on_close(&self) -> Result<(), String> { + // SAFETY: initialized structure, live job handle, and matching size. + unsafe { + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if SetInformationJobObject(self.0.as_raw_handle(), JobObjectExtendedLimitInformation, + (&info as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + std::mem::size_of_val(&info) as u32) == 0 { + return Err(last_error("configure cancellation job cleanup")); + } + } + Ok(()) + } + + pub fn terminate(&self) { + // SAFETY: the owned handle remains valid even after the leader exits. + unsafe { TerminateJobObject(self.0.as_raw_handle(), 1) }; + } + + /// Assign before any user code runs, so even a fast Git wrapper cannot + /// spawn helpers outside the job or exit before job assignment. + pub(crate) fn spawn(command: &mut Command) -> Result<(Child, Self), String> { + use std::os::windows::process::CommandExt; + use windows_sys::Win32::System::Threading::{CREATE_NO_WINDOW, CREATE_SUSPENDED}; + let mut child = command.creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED) + .spawn().map_err(|error| format!("spawn git failed: {error}"))?; + let setup = Self::assign(&child).and_then(|job| { + job.kill_on_close()?; + resume_primary_thread(&child)?; + Ok(job) + }); + match setup { + Ok(job) => Ok((child, job)), + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + Err(error) + } + } + } +} + +fn last_error(action: &str) -> String { + format!("Could not {action}: {}", std::io::Error::last_os_error()) +} + +fn resume_primary_thread(child: &Child) -> Result<(), String> { + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32, + }; + use windows_sys::Win32::System::Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME}; + // Stable std does not expose Child's primary thread handle. The suspended + // process has not executed user code; enumerate its initial thread while + // the owned process handle keeps its identity alive. + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0); + if snapshot == INVALID_HANDLE_VALUE { return Err(last_error("enumerate suspended Git thread")); } + let snapshot = OwnedHandle::from_raw_handle(snapshot); + let mut entry: THREADENTRY32 = std::mem::zeroed(); + entry.dwSize = std::mem::size_of_val(&entry) as u32; + let mut found = Thread32First(snapshot.as_raw_handle(), &mut entry); + while found != 0 { + if entry.th32OwnerProcessID == child.id() { + let thread = OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID); + if thread.is_null() { return Err(last_error("open suspended Git thread")); } + let thread = OwnedHandle::from_raw_handle(thread); + if ResumeThread(thread.as_raw_handle()) == u32::MAX { + return Err(last_error("resume Git thread")); + } + return Ok(()); + } + found = Thread32Next(snapshot.as_raw_handle(), &mut entry); + } + } + Err("Could not find suspended Git thread".into()) +} diff --git a/crates/strand-core/src/worktree.rs b/crates/strand-core/src/worktree.rs index 48d095f..29c194f 100644 --- a/crates/strand-core/src/worktree.rs +++ b/crates/strand-core/src/worktree.rs @@ -15,7 +15,7 @@ //! only owns the worktree *registry* (list + lifecycle). use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; @@ -223,9 +223,11 @@ impl Repo { Err(e) => return Err(Error::Other(format!("cannot inspect worktree before removal: {e}"))), }; let canonical = target.canonicalize().ok(); + let missing_identity = if metadata.is_none() { Some(resolve_missing_path(&target)?) } else { None }; let registered = self.worktrees()?.into_iter().find(|worktree| { let path = Path::new(&worktree.path); path == target || canonical.as_ref().is_some_and(|p| path.canonicalize().ok().as_ref() == Some(p)) + || missing_identity.as_ref().is_some_and(|p| resolve_missing_path(path).ok().as_ref() == Some(p)) }).ok_or_else(|| Error::Other(format!("not a registered worktree: {dest}")))?; if registered.is_main { return Err(Error::Other("the main worktree cannot be removed".into())); @@ -736,6 +738,20 @@ impl Repo { } } +/// Canonicalize the existing ancestor of an absent checkout and retain its +/// missing suffix. This preserves macOS /var → /private/var aliases. +fn resolve_missing_path(path: &Path) -> std::io::Result { + match path.canonicalize() { + Ok(path) => Ok(path), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let parent = path.parent().ok_or(error)?; + let name = path.file_name().ok_or_else(|| std::io::Error::other("invalid missing worktree path"))?; + Ok(resolve_missing_path(parent)?.join(name)) + } + Err(error) => Err(error), + } +} + /// One-pass walk of a working directory: total file bytes + newest mtime, /// skipping anything named `.git` and never following symlinks. Errors are /// swallowed per entry — stats are advisory, not a source of truth. diff --git a/crates/strand-tauri/src/ai/bin.rs b/crates/strand-tauri/src/ai/bin.rs index 70c19ac..407bda5 100644 --- a/crates/strand-tauri/src/ai/bin.rs +++ b/crates/strand-tauri/src/ai/bin.rs @@ -642,69 +642,7 @@ pub(crate) fn kill_process_tree(child: &mut Child, job: &WindowsJob) { } #[cfg(windows)] -pub(crate) struct WindowsJob(windows_sys::Win32::Foundation::HANDLE); - -#[cfg(windows)] -impl WindowsJob { - pub(crate) fn kill_on_close(&self) -> Result<(), String> { - use windows_sys::Win32::System::JobObjects::{ - SetInformationJobObject, JobObjectExtendedLimitInformation, - JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, - }; - // SAFETY: the initialized structure and live job handle match the API. - unsafe { - let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); - info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - if SetInformationJobObject(self.0, JobObjectExtendedLimitInformation, - (&info as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), - std::mem::size_of::() as u32) == 0 { - return Err("Could not configure action cleanup on app exit".into()); - } - } - Ok(()) - } - - pub(crate) fn assign(child: &Child) -> Result { - use windows_sys::Win32::Foundation::CloseHandle; - use windows_sys::Win32::System::JobObjects::{AssignProcessToJobObject, CreateJobObjectW}; - use windows_sys::Win32::System::Threading::{ - OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE, - }; - - // SAFETY: Win32 handles are checked and closed on every failure path. - unsafe { - let job = CreateJobObjectW(std::ptr::null(), std::ptr::null()); - if job.is_null() { - return Err("Could not create a Windows job for the AI provider".into()); - } - let process = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, child.id()); - if process.is_null() { - CloseHandle(job); - return Err("Could not open the AI provider process for cancellation".into()); - } - let assigned = AssignProcessToJobObject(job, process); - CloseHandle(process); - if assigned == 0 { - CloseHandle(job); - return Err("Could not attach the AI provider to its cancellation job".into()); - } - Ok(Self(job)) - } - } - - fn terminate(&self) { - // SAFETY: `self.0` is a live job handle owned by this wrapper. - unsafe { windows_sys::Win32::System::JobObjects::TerminateJobObject(self.0, 1) }; - } -} - -#[cfg(windows)] -impl Drop for WindowsJob { - fn drop(&mut self) { - // SAFETY: this wrapper uniquely owns the job handle. - unsafe { windows_sys::Win32::Foundation::CloseHandle(self.0) }; - } -} +pub(crate) use strand_core::windows_job::WindowsJob; /// Spawn a CLI detached (login flows that open a browser). Keeps default /// console flags: sign-in may need an interactive picker, so unlike @@ -898,23 +836,27 @@ mod tests { ) { let cancel = AiCancelHandle::new(); let worker_cancel = cancel.clone(); - let (started_tx, started_rx) = std::sync::mpsc::sync_channel(0); + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().to_path_buf(); let worker = std::thread::spawn(move || { - started_tx.send(()).unwrap(); run_capture_cancellable( Path::new(program), args, - None, + Some(&cwd), None, SUGGEST_TIMEOUT, Some(&worker_cancel), ) }); - started_rx.recv().unwrap(); - std::thread::sleep(Duration::from_millis(100)); + let deadline = Instant::now() + Duration::from_secs(10); + let ready = dir.path().join("ready"); + while !ready.exists() && !worker.is_finished() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } let cancelled_at = Instant::now(); cancel.cancel(); let err = worker.join().unwrap().unwrap_err(); + assert!(ready.exists(), "child did not reach its cancellation checkpoint: {err}"); assert_eq!(err, "cancelled"); assert!(cancelled_at.elapsed() < Duration::from_secs(3)); } @@ -922,7 +864,7 @@ mod tests { #[cfg(not(windows))] #[test] fn run_capture_cancels_process_group() { - assert_process_group_cancelled_promptly("/bin/sh", &["-c", "sleep 30 & wait"]); + assert_process_group_cancelled_promptly("/bin/sh", &["-c", "sleep 30 & printf ready > ready; wait"]); } #[cfg(windows)] @@ -930,7 +872,7 @@ mod tests { fn run_capture_cancels_process_group() { assert_process_group_cancelled_promptly( "cmd.exe", - &["/C", "ping", "-n", "30", "127.0.0.1"], + &["/C", "echo ready>ready & ping -n 30 127.0.0.1"], ); } } diff --git a/crates/strand-tauri/src/pull_requests.rs b/crates/strand-tauri/src/pull_requests.rs index d229152..c0e28e9 100644 --- a/crates/strand-tauri/src/pull_requests.rs +++ b/crates/strand-tauri/src/pull_requests.rs @@ -3279,6 +3279,22 @@ fn run_command_input( run_command_input_cancellable(cwd, program, args, envs, stdin_data, None) } +// Observe exit without releasing the PID: process-group cleanup must happen +// before Child::wait/try_wait can reap the leader and permit PID reuse. +#[cfg(unix)] +fn provider_exited(child: &std::process::Child) -> std::io::Result { + loop { + // SAFETY: info is initialized and writable; this queries our own child + // without reaping it or waiting for a running process. + let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() }; + let result = unsafe { libc::waitid(libc::P_PID, child.id(), &mut info, + libc::WEXITED | libc::WNOHANG | libc::WNOWAIT) }; + if result == 0 { return Ok(unsafe { info.si_pid() } != 0); } + let error = std::io::Error::last_os_error(); + if error.kind() != std::io::ErrorKind::Interrupted { return Err(error); } + } +} + fn run_command_input_cancellable( cwd: &str, program: &str, @@ -3316,6 +3332,10 @@ fn run_command_input_cancellable( }) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + #[cfg(unix)] { + use std::os::unix::process::CommandExt; + command.process_group(0); + } let mut child = command.spawn().map_err(|e| { let install = if program == "gh" { "Install GitHub CLI and run `gh auth login`" @@ -3324,6 +3344,24 @@ fn run_command_input_cancellable( }; format!("Could not start {program}: {e}. {install}.") })?; + #[cfg(windows)] + let job = match crate::ai::bin::WindowsJob::assign(&child).and_then(|job| { + job.kill_on_close()?; + Ok(job) + }) { + Ok(job) => job, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + } + }; + let stop = |child: &mut std::process::Child| { + #[cfg(unix)] + crate::ai::bin::kill_process_tree(child); + #[cfg(windows)] + crate::ai::bin::kill_process_tree(child, &job); + }; let mut stdin_writer = child.stdin.take().map(|mut stdin| { let data = stdin_data.unwrap_or_default().to_vec(); @@ -3347,11 +3385,20 @@ fn run_command_input_cancellable( let started = Instant::now(); let status = loop { - match child.try_wait() { - Ok(Some(status)) => break status, - Ok(None) => {} + #[cfg(unix)] + let exited = provider_exited(&child); + #[cfg(not(unix))] + let exited = child.try_wait().map(|status| status.is_some()); + match exited { + Ok(true) => { + // Unix retains the unreaped leader until this signal; Windows + // targets the owned job handle, never a recycled numeric PID. + stop(&mut child); + break child.wait().map_err(|error| format!("{program} wait failed: {error}"))?; + } + Ok(false) => {} Err(error) => { - let _ = child.kill(); + stop(&mut child); let _ = child.wait(); let _ = stdout_reader.join(); let _ = stderr_reader.join(); @@ -3362,7 +3409,7 @@ fn run_command_input_cancellable( } } if started.elapsed() >= COMMAND_TIMEOUT || cancelled.is_some_and(|flag| flag.load(std::sync::atomic::Ordering::Relaxed)) { - let _ = child.kill(); + stop(&mut child); let _ = child.wait(); let _ = stdout_reader.join(); let _ = stderr_reader.join(); @@ -3373,6 +3420,7 @@ fn run_command_input_cancellable( } thread::sleep(Duration::from_millis(25)); }; + // The owned tree has stopped, so helpers cannot hold pipes. let stdout = stdout_reader .join() .map_err(|_| format!("{program} output reader failed"))?; diff --git a/crates/strand-tauri/src/pull_requests/pages.rs b/crates/strand-tauri/src/pull_requests/pages.rs index 683d6eb..a4fc861 100644 --- a/crates/strand-tauri/src/pull_requests/pages.rs +++ b/crates/strand-tauri/src/pull_requests/pages.rs @@ -587,27 +587,62 @@ mod tests { #[test] fn cancellation_terminates_an_active_read() { + let dir = tempfile::tempdir().unwrap(); + let ready = dir.path().join("ready"); let cancelled = Arc::new(AtomicBool::new(false)); let signal = cancelled.clone(); let worker = thread::spawn(move || { - thread::sleep(Duration::from_millis(250)); + let deadline = Instant::now() + Duration::from_secs(10); + while !ready.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + let started = ready.exists(); + let cancelled_at = Instant::now(); signal.store(true, Ordering::Relaxed); + assert!(started, "child did not reach its cancellation checkpoint"); + cancelled_at }); - let start = Instant::now(); #[cfg(windows)] let result = run_command_input_cancellable( - ".", + dir.path().to_str().unwrap(), "powershell", - &["-NoProfile", "-Command", "Start-Sleep -Seconds 20"], + &["-NoProfile", "-Command", "Set-Content ready ready; Start-Sleep -Seconds 20"], &[], None, Some(&cancelled), ); #[cfg(not(windows))] let result = - run_command_input_cancellable(".", "sleep", &["20"], &[], None, Some(&cancelled)); - worker.join().unwrap(); + run_command_input_cancellable(dir.path().to_str().unwrap(), "/bin/sh", &["-c", "printf ready > ready; sleep 20"], &[], None, Some(&cancelled)); + let start = worker.join().unwrap(); assert!(result.unwrap_err().contains("cancelled")); assert!(start.elapsed() < Duration::from_secs(5)); } + + #[cfg(unix)] + #[test] + fn provider_exit_observation_retains_leader_until_cleanup() { + let mut child = std::process::Command::new("/bin/sh") + .args(["-c", "exit 7"]).spawn().unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while !super::super::provider_exited(&child).unwrap() { + assert!(Instant::now() < deadline); + thread::sleep(Duration::from_millis(10)); + } + // A second observation must still find a waitable child. If the first + // reaped it, this returns ECHILD and its PID could already be reused. + assert!(super::super::provider_exited(&child).unwrap()); + assert_eq!(child.wait().unwrap().code(), Some(7)); + } + + #[cfg(unix)] + #[test] + fn natural_exit_stops_helpers_before_joining_provider_output() { + let start = Instant::now(); + let output = run_command_input_cancellable( + ".", "/bin/sh", &["-c", "sleep 30 & printf done"], &[], None, None, + ).unwrap(); + assert_eq!(output, b"done"); + assert!(start.elapsed() < Duration::from_secs(15)); + } } diff --git a/crates/strand-tauri/src/terminal.rs b/crates/strand-tauri/src/terminal.rs index 0b1e37f..4364be5 100644 --- a/crates/strand-tauri/src/terminal.rs +++ b/crates/strand-tauri/src/terminal.rs @@ -331,25 +331,21 @@ fn terminal_reader( RuntimeEvent::ReaderDone(error) => reader_done = Some(error), RuntimeEvent::Exit(result) => exit_result = Some(result), } - if let (Some(result), Some(_)) = (&exit_result, &reader_done) { - if !session.closed.load(Ordering::Acquire) { - match result { - Ok(code) => { - let _ = on_event.send(TerminalEvent::Exit { code: *code }); - } - Err(message) => { - let _ = on_event.send(TerminalEvent::Error { - message: message.clone(), - }); - } - } - } + if exit_result.is_some() && reader_done.is_some() { break; } } if let Ok(mut all) = sessions.lock() { all.remove(&id); } + // Observers of Exit must already see this session as stopped. + if !session.closed.load(Ordering::Acquire) { + match exit_result { + Some(Ok(code)) => { let _ = on_event.send(TerminalEvent::Exit { code }); } + Some(Err(message)) => { let _ = on_event.send(TerminalEvent::Error { message }); } + None => {} + } + } } fn terminate(session: &TerminalSession) { @@ -803,16 +799,23 @@ mod tests { ); #[cfg(unix)] let command = "/bin/sh -c 'printf strand-terminal'".to_string(); + let manager = TerminalManager::default(); + let observed_manager = manager.clone(); + let observed_path = dir.path().to_string_lossy().into_owned(); + let count_at_exit = Arc::new(std::sync::atomic::AtomicUsize::new(usize::MAX)); + let observed_count = Arc::clone(&count_at_exit); let (send, receive) = std::sync::mpsc::channel(); let channel = Channel::new(move |body| { if let tauri::ipc::InvokeResponseBody::Json(json) = body { if let Ok(event) = serde_json::from_str::(&json) { + if matches!(event, TerminalEvent::Exit { .. }) { + observed_count.store(observed_manager.count(&observed_path), Ordering::Release); + } let _ = send.send(event); } } Ok(()) }); - let manager = TerminalManager::default(); let handle = manager .create( dir.path().to_string_lossy().into_owned(), @@ -850,6 +853,7 @@ mod tests { ); } assert!(String::from_utf8_lossy(&output).contains("strand-terminal")); + assert_eq!(count_at_exit.load(Ordering::Acquire), 0); assert_eq!(manager.count(&dir.path().to_string_lossy()), 0); manager.close(&handle.id).unwrap(); // natural exit made close idempotent } diff --git a/crates/strand-tauri/src/user_actions.rs b/crates/strand-tauri/src/user_actions.rs index e747361..0dbc69c 100644 --- a/crates/strand-tauri/src/user_actions.rs +++ b/crates/strand-tauri/src/user_actions.rs @@ -234,6 +234,9 @@ mod tests { child.stdout(Stdio::inherit()).stderr(Stdio::inherit()); child.spawn().unwrap(); println!("spawned child"); + use std::io::Write; + std::io::stdout().flush().unwrap(); + std::fs::write(std::env::var("STRAND_ACTION_READY").unwrap(), "ready").unwrap(); std::thread::sleep(Duration::from_secs(60)); } "parent-exit" => { @@ -324,15 +327,23 @@ mod tests { fn cancellation_stops_descendants_and_pre_cancel_never_spawns() { let dir = tempfile::tempdir().unwrap(); let marker = dir.path().join("marker"); + let ready = dir.path().join("ready"); let mut command = child_command("descendant"); command.env("STRAND_ACTION_MARKER", &marker); + command.env("STRAND_ACTION_READY", &ready); let cancel = AiCancelHandle::new(); let trigger = cancel.clone(); - std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(700)); + let watcher = std::thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(10); + while !ready.exists() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + let started = ready.exists(); trigger.cancel(); + started }); - let output = capture_command(command, &cancel, Duration::from_secs(10)).unwrap(); + let output = capture_command(command, &cancel, Duration::from_secs(15)).unwrap(); + assert!(watcher.join().unwrap(), "child did not reach the cancellation checkpoint: {}", output.stderr); assert_eq!(output.status, "cancelled"); assert!(output.stdout.contains("spawned child")); std::thread::sleep(Duration::from_secs(2)); diff --git a/docs/learnings.md b/docs/learnings.md index 750a52f..9d57a31 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -1,5 +1,31 @@ # Learnings +## File actions must preserve literal entries and threatened bytes (2026-09-29) + +Audit probes showed that libgit2 checkout/reset pathspecs expand selected +filenames such as `[id].tsx` to unrelated `i.tsx`. An exact file action needs +literal matching; `--` only separates options and does not by itself disable +Git pathspec magic. Keep bulk operations batched when repairing this. + +A hard reset can overwrite untracked or ignored entries that obstruct its +target tree. A tracked-only dirty check/snapshot cannot promise recovery for +those bytes. Inspect target collisions before destructive dispatch. + +Use directory-entry metadata for symlink staging: a missing referent does not +make the link deleted. Working-tree containment alone also does not exclude +`.git`, and direct joins for special files such as `.gitignore` bypass the +existing symlink boundary. Regression coverage must include these cases. +The git2 0.19 checkout binding does not expose literal-pathspec mode. Keep +ordinary filenames on the existing in-process path; special-name batches use +one NUL-delimited Git operation, with `--literal-pathspecs` for reset and exact +`checkout-index --stdin` filenames for discard. + +macOS tests must canonicalize Unix paths used in `includeIf.gitdir`, explicitly +put accepted test-server sockets into blocking mode, and wait for subprocess +readiness before measuring cancellation. A timer started before spawn measures +OS launch latency as well as cancellation. On Unix, signal an owned process +group even when its leader exited: descendants can still own the pipes. + ## Programmatic clipboard is native so the OS names Strand (2026-09-21) `navigator.clipboard` in the Tauri webview is attributed to the web origin @@ -2925,3 +2951,19 @@ and restore the ordinary build configuration after a CDP test build. Core tests that spawn Git daemons also need process-tree cleanup: killing Git for Windows' parent wrapper alone can leave its daemon alive and prevent the test command from returning after all assertions pass. + +## Keep process identity until cleanup and refresh safety inputs (2026-09-29) + +On Unix, `Child::try_wait` reaps an exited process. Observe provider exit with +`waitid(WNOWAIT)` and signal its owned group before `wait`, so PID reuse cannot +redirect cleanup to an unrelated process. Pipe readiness alone does not hold +that identity. Windows streaming Git needs an owned Job Object: assign while +the process is suspended, then resume, and terminate the job even after the +wrapper exits. Share the job wrapper across core and Tauri rather than reopen +numeric PIDs or duplicate cleanup ownership. + +Hard-reset collision guards must refresh the index before trusting tracked +membership; cached libgit2 indexes can survive external Git writes. Preserve +sparse expansion through its existing reader. git2 0.19 `Index::get_path` +already normalizes Windows separators through `path_to_repo_path`; do not add +lossy string conversion to fix a raw-libgit2 issue the Rust binding handles. diff --git a/docs/main-audit-2026-09-29.md b/docs/main-audit-2026-09-29.md new file mode 100644 index 0000000..a7e4804 --- /dev/null +++ b/docs/main-audit-2026-09-29.md @@ -0,0 +1,339 @@ +# Main audit — 2026-09-29 + +Audited `f5ed9a875adbbb70cbef98987e24b298b85ce2f1` (1.7.2), after a clean +fast-forward of local `main` from `df612d8`. No application fixes, commits or +pushes were made during the audit. The implementation follow-up is recorded +below. This is a targeted correctness, safety, performance and +backlog audit, not exhaustive certification of every feature. + +Environment: macOS Apple Silicon, Rust 1.92.0, Apple Git 2.54.0, Node 25.9.0, +pnpm 9.0.0. Git LFS is absent. Frontend dependencies were synchronized with +`pnpm install --frozen-lockfile`; no manifest or lockfile changed. + +## Prioritized findings + +P1 means fix before the next release; P2 means a concrete follow-up. Six +findings were reproduced against the actual engine or existing tests. A05 is +confirmed by source inspection, without an out-of-memory stress test. + +### A01 / P1 — File discard and unstage expand literal filenames as patterns + +Evidence: `crates/strand-core/src/stage.rs:137–141`, `:88`, `:170`. +`CheckoutBuilder::path` and `reset_default` receive selected filenames as +pathspecs without disabling wildcard matching. The UI calls these paths from +`useRepo.discardMany` / `unstageMany`; bulk discard does not create a safety +stash (`ui/src/stores/repo.ts:1764`). + +Reproduction: commit `[id].tsx` and `i.tsx`, modify both, then call +`Repo::discard_path("[id].tsx")`. Both files revert, including the unrelated +`i.tsx`. Stage new changes to both and call `unstage_path("[id].tsx")`: +`git diff --cached --name-only` becomes empty. This affects valid cross-platform +filenames, including bracketed route filenames; it is not limited to Unix `*`. + +Fix criterion: single and bulk actions affect exactly the selected paths, +including `[]`, `*`, `?` and pathspec-like prefixes. Disable checkout pathspec +matching and use an exact-path index reset strategy. Preserve the existing +batched in-process hot path and narrow Windows fallback. + +### A02 / P1 — Hard reset overwrites colliding untracked content without recovery + +Evidence: `crates/strand-core/src/reset.rs:60–93` and +`ui/src/views/ResetDialog.tsx:108`. +The dirty check explicitly excludes untracked/ignored files and snapshots with +`include_untracked=false`. Its premise that hard reset never touches untracked +files is false when the target tree needs their paths. The dialog promises a +safety snapshot. + +Reproduction: commit `collision.txt`, delete and commit it, recreate it with +unique untracked bytes, then call `reset("HEAD~1", ResetMode::Hard)`. The bytes +become the old committed content and `snapshot_oid` is `None`. + +Fix criterion: preflight collisions against the target tree and either refuse +or preserve the threatened content before resetting. Include file/directory +collisions and ignored data in regression coverage, with native, sparse and +LFS dispatch coverage. Do not claim a recovery snapshot unless it covers the +data actually overwritten; avoid an unconditional stash round trip. + +### A03 / P1 — Add to .gitignore follows symlinks outside the checkout + +Evidence: `crates/strand-core/src/ignore.rs:20–37`. +The quick action joins `.gitignore` directly to the repository path and reads +and rewrites it without the working-tree guard or a no-symlink check. + +Reproduction: point `.gitignore` at a text file in a separate temporary +directory, then call `gitignore_add("/build")`. The operation succeeds and the +external file gains `/build\n`. Only disposable files were used in this probe. + +Fix criterion: reject symlinked/nonregular ignore files and escaped resolved +destinations before reading/writing; test both existing and dangling symlinks. +An ordinary Ignore action must never mutate a repository-controlled external +target. This is a file-boundary bug; no code-execution exploit was attempted. + +### A04 / P2 — Staging a dangling symlink silently stages nothing + +Evidence: `crates/strand-core/src/stage.rs:16–23` and `:50–57`. +`Path::exists` follows the link, so a present link with an absent referent is +classified as a deleted file. Git tracks the link itself. + +Reproduction on macOS: create `link -> missing-target`, call +`stage_path("link")`; it returns `Ok(())`, but `git ls-files --stage link` is +empty. The bulk path repeats the same existence check. For an already tracked +link, this branch can remove its index entry instead of staging the link. + +Fix criterion: inspect directory-entry existence with `symlink_metadata`, +distinguish NotFound from other I/O errors, and test new/modified dangling links +through single and bulk staging. Preserve mode `120000` and the link text. + +### A05 / P2 — The 2 MB content limit does not bound the disk read + +Evidence: `crates/strand-core/src/file.rs:110–115`, `:259–260`. +`file_content` calls `std::fs::read` for the entire working-tree file before +`build_content` truncates the result to 2,000,000 bytes. Selecting a very large +log or generated asset therefore allocates its complete size in the backend, +even though the UI displays only a prefix or a binary notice. + +Fix criterion: open a regular file and perform a bounded prefix read, retaining +correct truncated/binary/editable flags and UTF-8 boundaries. Ensure changing +file size cannot defeat the bound. Measure memory and bytes read on a large +fixture; frontend virtualization does not protect native allocations. Audit +revision/blob reads separately rather than claiming they are already bounded. + +### A06 / P2 — Rename/move allows destinations inside .git + +Evidence: `crates/strand-core/src/rename.rs:30–31`, `:51–61` and +`ui/src/views/RenameFileDialog.tsx:55–63`. +The rename dialog accepts a full relative destination. The native checks +enforce checkout containment but omit the administrative-path rejection used +by file create/delete. + +Reproduction: `move_path("scratch.txt", ".git/audit-moved")` succeeds for an +untracked file. It disappears from the working tree and appears inside Git +metadata. Existing destination files are still protected against overwrite; +this finding does not claim otherwise. + +Fix criterion: reject administrative source/destination components before +creating directories or moving data, including aliases into the Git directory. +Cover untracked files, directories and linked worktrees. Apply the guard at +the native mutation boundary, not only in the dialog. + +### A07 / P2 — Missing-worktree removal fails through a macOS path alias + +Evidence: `crates/strand-core/src/worktree.rs:225–229`. +Registration lookup compares literal paths or canonicalizes the entire target. +Once the target is absent, canonicalization fails; `/var/...` no longer matches +Git's stored `/private/var/...` spelling. + +Reproduction: existing test +`worktree::tests::removal_only_skips_archive_when_the_registered_directory_is_missing` +fails independently on this Mac at line 1411 with `not a registered worktree` +after removing the directory. It also fails with system/global Git config +isolated. The analogous existing-directory identity guard remains important. + +Fix criterion: reconcile missing target identities through their existing +ancestors or authoritative registered identity without weakening +archive-before-remove or different-repository checks. Retest macOS aliases, +ordinary paths and the existing archive-failure cases. + +## Baseline verification and limitations (before fixes) + +| Check | Result | +| --- | --- | +| `pnpm --filter ./ui exec tsc --noEmit` | Pass | +| `pnpm --filter ./ui test` | 99 files, 568 tests pass | +| `pnpm build` | Pass; entry chunk 2,013.31 kB / 573.63 kB gzip; Vite large-chunk warning | +| `cargo check -p strand-core -p strand-tauri` | Pass | +| `pnpm release:check-security` | Pass | +| `pnpm release:test-helper` | 9 tests pass | +| `pnpm release:check-helper` | Live protocol-7 manifest, signature asset and three platform archives available; not a fresh cryptographic verification | +| `cargo test -p strand-core -p strand-tauri` | Core stopped the command: 203 pass, 16 fail, 6 ignored | +| Isolated core run below | 210 pass, 2 fail, 6 ignored, 7 LFS tests filtered | +| Separate `cargo test -p strand-tauri` | 162 pass, 3 fail | +| Serial cancellation subset below | 5 pass, 1 fail | + +Core failure breakdown: seven missing-Git-LFS prerequisites; seven fixtures +inherited personal signing settings and failed through the signing agent; one +conditional-identity expectation; one missing-worktree removal (A07). +No personal Git configuration was changed. This isolated rerun removes the +signing-environment failures, but does not certify LFS: + +```sh +GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 \ + cargo test -p strand-core -- --skip lfs +cargo test -p strand-tauri cancel -- --test-threads=1 +``` + +The conditional-identity test still expects `Conditional` and receives `Base` +(`gitconfig.rs:206`). Its includeIf pattern uses a noncanonical temporary path; +diagnose fixture path semantics against system Git before labeling it a +production identity regression. + +Two Tauri cancellation deadline failures pass in the serial rerun. The user +action descendant test still fails at `user_actions.rs:337`, expecting +`spawned child` in stdout after a fixed 700 ms delay. Investigate child readiness +and macOS capture/startup behavior; the failure alone does not prove a process +escaped cancellation. These are open validation issues, not a green suite. + +The disposable engine probe exercised A01–A04 and A06 directly, with the +following output (A01 used `[id].tsx` and `i.tsx`): + +```text +DISCARD wildcard: selected="old literal", unrelated="old neighbour" +UNSTAGE wildcard: staged files="" +RESET collision: content="old committed", snapshot=None +STAGE dangling symlink: result=Ok(()), index="" +IGNORE symlink: result=Ok(()), external content="outside original\n/build\n" +MOVE into git metadata: result=Ok(()), exists=true +``` + +No native UI walkthrough, packaged installer/updater cycle, live provider write, +full dependency vulnerability scan, or current PRD performance certification +was performed. Passing builds and tests do not establish those claims. + +## What Strand needs next + +1. **Protect local data first.** Fix A01–A03 with real-repository regressions, + then A04–A07. Keep each logical fix separate and preserve batched hot paths. +2. **Make macOS verification trustworthy.** Isolate test Git config, state + prerequisites for Git LFS, resolve the identity/cancellation tests, and add + a macOS Rust test job. CI currently has Linux Rust and Windows-specific + jobs, but no macOS test job. Complete native terminal process-tree, + workspace persistence and Workbench continuity checks on macOS/Linux. +3. **Certify current performance.** Existing TASKS already tracks first-use + grammar/paint, cold launch, idle memory and sustained-edit gaps. Measure the + exact production candidate against PRD §8, separating native reads, IPC, + highlighting and visible paint. The entry bundle is a lead to profile, not + proof of a responsiveness regression. +4. **Close delivery evidence.** Run current macOS/GNOME/KDE install/update + checks and live Azure iteration/suggestion validation. Reconcile helper and + historical release rows: protocol 7 is available now, while protocol-6 + backfill and old Store/SEO tasks require current external evidence. Do not + assume every unchecked historical row is still missing. +5. **Then prioritize product expansion.** Plugin isolation/quotas and remote + install, typed Workbench context/services, SSH artifact/bootstrap and remote + directory browsing, CLI terminal rendering/distribution, and the proposed + per-run review checkpoints remain explicit incomplete families. Small UX + follow-ups include repository-tab reordering, truthful encoding/EOL status + and per-file persistence. Select these by user need after hardening. + +Planning cleanup is also warranted: PRD still contains historical unresolved +licensing/pricing questions already answered in TASKS; ROADMAP's cross-cutting +questions still label PR review as a candidate despite its implementation. +Keep historical audit evidence, but provide one concise current release gate +and distinguish shipped features from pending validation. + + +## Implementation follow-up — 2026-09-29 + +A01–A07 are implemented with regression tests. Historical findings and +failed baseline runs above are retained as +reproduction evidence, not the current implementation status. + +- **A01:** `needs_literal_pathspec` routes special-name batches through one + NUL-delimited Git command. Reset uses `--literal-pathspecs`; checkout-index + takes exact filenames. Ordinary batches retain their libgit2 fast path. + Regressions cover bracketed routes, wildcard/negation/comment characters, + spaces, Unix backslashes, single/bulk operations and unborn HEAD. +- **A02:** `guard_reset_tree` checks target collisions before snapshots or any + reset dispatch. Directory replacement inspects threatened descendants, + including ignored files, without scanning unrelated dependency directories. + It refuses collisions; it does not attempt an implicit untracked stash. + Normal, sparse-index, LFS and file/directory fixtures retain bytes and Git + state on refusal. The dialog and guide now describe the actual coverage. +- **A03:** Ignore checks containment and regular-file metadata, opens without + following symlinks, and reads/writes one handle. Missing files use create-new. + Existing, dangling and in-tree symlinks and directory targets are tested. +- **A04:** `entry_exists` uses symlink metadata and propagates non-NotFound + errors. New and modified dangling links retain mode 120000 and target text + through single and bulk staging. +- **A05:** Working-tree content reads at most 2,000,001 bytes; valid UTF-8 is + not split at the preview boundary. The 1 GiB sparse-file regression ran in + 0.10 seconds with 18,628,608 bytes maximum RSS for the test process on this + Mac (`/usr/bin/time -l`), not a packaged-app memory claim. Historical blob + materialization remains a separate path and is not certified by this test. +- **A06:** `guard_move_metadata` rejects administrative components and resolved + aliases into per-worktree/common Git metadata before creating directories. + Tests include linked worktrees, aliases and working-tree root moves. +- **A07:** `resolve_missing_path` canonicalizes the existing ancestor while + retaining the missing suffix. Existing-directory identity and recovery-archive + guards remain intact and their regressions pass. + +Validation repairs also isolate the affected test fixtures from personal +signing/LFS settings, canonicalize Unix includeIf paths, switch accepted LFS +mock-server sockets to blocking mode on macOS, and replace fixed cancellation +sleeps with child-readiness checkpoints. Git LFS 3.8.0 was installed locally +for verification, without changing global Git configuration. The Rust CI +matrix now includes macOS as well as Linux; its hosted run is still pending. + +Stronger cancellation checks exposed two additional production gaps. Unix Git +cancellation now signals the owned group even after the leader exits. Hosted +provider commands now use Unix process groups / Windows Job Objects and stop +helpers before joining pipes on cancellation, timeout, error and natural +completion. The active-child and exited-parent regressions pass. Provider +output reads remain unbounded; TASKS records that separate follow-up. + +Verification after fixes: + +- Core: 232 unit tests and 13 integration tests pass; six existing optional + signing/Git-flow/measurement tests remain ignored. +- Tauri: 166 tests pass, including provider helper cleanup and readiness-based + cancellation tests. +- Frontend: all 568 tests pass; TypeScript passes. +- `cargo check -p strand-core -p strand-tauri` and clippy with `-D warnings` pass. +- `git diff --check` passes. The audit's earlier production frontend build and + release-policy results remain baseline evidence; no packaged app was rebuilt + or released in this repair pass. + +The repository planning corrections are limited to evidence already available: +PRD licensing/pricing decisions and ROADMAP's implemented PR-review surface. +Native macOS/Linux packaged-app validation, Windows execution of the new native +branches, production PRD performance certification, and external Store/SEO/ +older-helper publication reconciliation remain open. Product-expansion items +in the original audit remain separate future work, not implied fixes in this +hardening change. + +## PR #138 review follow-up — 2026-09-29 + +The eight inline comments repeat four findings. Both Windows separator +findings are false positives: git2 0.19 `Index::get_path` invokes +`path_to_repo_path`, whose Windows branch normalizes backslashes before the +libgit2 lookup. A cross-platform regression exercises nested tracked files and +tracked-directory replacement without adding lossy path conversion. + +Two process findings are valid and repaired: + +- Provider success cleanup previously signaled a numeric Unix group after + `try_wait` reaped its leader. `provider_exited` now uses `waitid(WNOWAIT)`; + cleanup signals the group before `wait` releases the PID. A regression + proves repeated exit observations leave the child waitable, while the + existing helper-held-pipe test verifies cleanup still completes. +- Windows streaming Git previously skipped tree cleanup when its leader had + exited. It now owns a Job Object assigned while Git is suspended, then + resumes the primary thread. Cancellation terminates that job regardless of + leader lifetime. The shared wrapper uses owned handles, including the + existing process handle for assignment, and is reused by Tauri runners. + A Windows regression reaps the leader while its helper retains stdout, + then verifies cancellation closes the pipe promptly. + +The nested reset regression exposed an additional real bug: a cached index +could survive an external Git change. The guard now refreshes the normal index +(and retains sparse expansion), preventing both false collision reports and +missed untracked collisions. Both cases have regression coverage. + +Local follow-up verification: 234 core unit tests, 13 core integration tests, +and 167 Tauri tests pass (414 total; six existing optional tests ignored). +Cargo check, strict Clippy and diff whitespace checks pass. The shared Windows +job module cross-compiles for x86_64-pc-windows-msvc; runtime verification is +delegated to the Windows CI reset, cancellation and provider test subsets. +The preceding PR head passed all five hosted checks; the revised head requires +a fresh run. No frontend behavior or TypeScript code changed in this follow-up. + +The first revised Linux run exposed a pre-existing terminal lifecycle race: +`pty_streams_ordered_output_then_exit` received Exit while the session count +was still one. `terminal_reader` now removes the session before publishing +Exit/Error. The regression observes the count synchronously in the event +callback, so it no longer relies on the receiving thread winning a race. +That deterministic test fails against the original implementation and passes +after the ordering fix. The Windows reset, cancellation and provider regression +subsets passed on `6e97c73`, including the dead-leader helper test. macOS tests +and strict Clippy also passed on that head. The terminal ordering fix triggers +another CI run; packaged-app performance certification remains separate. diff --git a/ui/src/views/ResetDialog.tsx b/ui/src/views/ResetDialog.tsx index da82dc9..9734236 100644 --- a/ui/src/views/ResetDialog.tsx +++ b/ui/src/views/ResetDialog.tsx @@ -105,7 +105,7 @@ export function ResetDialog({
{option('soft', 'Soft', 'keep all changes staged')} {option('mixed', 'Mixed', 'keep changes, unstaged')} - {option('hard', 'Hard', 'discard all changes (a safety snapshot stash is saved first)', true)} + {option('hard', 'Hard', 'discard tracked changes after a safety snapshot; refuse untracked or ignored file collisions', true)}
{error ?
{error}
: null} diff --git a/website/docs/commits-and-history.md b/website/docs/commits-and-history.md index 6e223e5..1147803 100644 --- a/website/docs/commits-and-history.md +++ b/website/docs/commits-and-history.md @@ -110,6 +110,12 @@ Unlike the graph, the reflog includes commits orphaned by a reset, rebase, or am To recover a commit you lost to a bad reset: open the Reflog, find the entry from before the reset, and either **Create branch here…** to keep it or **Reset HEAD here…** to move your branch back. If the commit is orphaned it won't appear in the graph, but the context menu actions work on it directly. +**Hard reset** snapshots tracked changes before discarding them. It refuses to +overwrite untracked or ignored files that collide with the target commit, +including file/folder replacements. Move or commit those files before retrying. +Unrelated untracked files remain in place; a clean reset does not create an +empty recovery stash. + ## Work file documents Open any file from the sidebar's **Files** tab or the command palette to get a diff --git a/website/docs/everyday-git.md b/website/docs/everyday-git.md index b3ff4e3..6b1bd3f 100644 --- a/website/docs/everyday-git.md +++ b/website/docs/everyday-git.md @@ -16,6 +16,9 @@ Right-click a single file in Local Changes or Review and choose **Open in editor - The view opens with a "show all" stacked diff of every changed file. Clicking the Unstaged or Staged column title re-selects that side's full changeset, and selecting a folder row aggregates the diffs beneath it. - Stage or unstage a whole file from its row, or use **Stage all** / **Unstage all** for the whole side. + Filenames such as `[id].tsx` are treated literally; selecting one file does + not select other matching names. A symlink can be staged even when its target + does not exist. - Multi-select files and folders with `Mod`-click or Shift-click. Stage, Unstage, Stash, and Discard act on every selected file plus every changed file beneath each selected folder. - **Block and line staging**: each change block in the diff has inline **Stage** and **Discard** buttons (**Unstage** on the staged side). Drag across changed line numbers to act on a contiguous range, or choose **Lines…** for a keyboard-operable checklist that can select any combination of deleted and added lines. The action labels show the selected-line count. - **Discarding a change block or selected lines is recoverable**: it shows an Undo toast for a few seconds. Whole-file and bulk discards are immediate and permanent — there is no Undo toast and no automatic safety stash — so stash first if you might want the changes back. @@ -53,12 +56,16 @@ Open a row's context menu with right-click, the Menu key, or `Shift+F10`: folder row. - A file can jump directly to **Open file history** or **Open blame**. - **New file here…**, **New folder here…**, and **Rename / move…** act relative - to the selected row. + to the selected row. Rename/move refuses paths inside Git metadata, including + aliases into `.git`. - Copy one or several relative paths, or native absolute paths suitable for the current operating system. - **Delete file/folder** requires a second confirmation click. Tracked entries become ordinary working-tree deletions; the index is not changed. +**Ignore** refuses to edit a `.gitignore` that is a symlink, so the action +cannot change the file that link points to. + The Files tree uses the repository's ignored-inclusive local listing directly; it does not first substitute the Git snapshot while that listing loads. Current Git state is overlaid on those local paths, so added, modified, and deleted @@ -225,7 +232,8 @@ Use **Previous page** / **Next page** for larger lists and **Open repository** to work in a module's own tab. Progress and errors remain visible. **Cancel operation** stops Git and its -helpers. Completed clones and local objects remain available: refresh, inspect +helpers, including helpers left running after the parent exits on Windows. +Completed clones and local objects remain available: refresh, inspect the current state, correct the error and retry. Git's transport restrictions still apply, including restrictions on local-file submodule URLs. diff --git a/website/docs/work.md b/website/docs/work.md index 744fd17..5751334 100644 --- a/website/docs/work.md +++ b/website/docs/work.md @@ -59,6 +59,8 @@ buffer and reload the file from disk without writing it. Historical revisions, binaries, oversized files, and non-UTF-8 text stay read-only. If another tool changes the file while you have unsaved edits, Strand refuses the stale save instead of overwriting the newer disk content. +Large working-tree text files show a read-only preview of their first 2 MB; +Strand reads only that prefix rather than loading the whole file. If a file moves through Strand, its tabs follow the new path. A removed preview closes; a removed pinned file stays visible with a clear missing-file message.