From 716f17c76f203c3a121086dc24618fe13fb3573e Mon Sep 17 00:00:00 2001 From: Daniels-Main Date: Sun, 6 Sep 2026 15:43:06 +0200 Subject: [PATCH 1/8] fix: honor Git hooks for every commit and amend --- PRD.md | 2 +- README.md | 3 +- ROADMAP.md | 9 + TASKS.md | 5 +- crates/strand-core/src/commit.rs | 179 +++++++++--------- crates/strand-core/src/git_output.rs | 59 ++++++ crates/strand-core/src/lib.rs | 1 + crates/strand-tauri/src/commands.rs | 6 +- ...-identity-signing-validation-2026-09-06.md | 35 ++++ docs/learnings.md | 8 +- ui/src/demo/dispatch.ts | 2 +- ui/src/lib/types.ts | 1 + ui/src/stores/commitDrafts.ts | 25 +++ ui/src/stores/repo.test.ts | 32 ++++ ui/src/stores/repo.ts | 27 ++- ui/src/styles/features.css | 5 + ui/src/views/LocalChanges.tsx | 26 +-- website/docs/everyday-git.md | 6 + website/docs/settings.md | 2 +- 19 files changed, 318 insertions(+), 115 deletions(-) create mode 100644 crates/strand-core/src/git_output.rs create mode 100644 docs/hooks-identity-signing-validation-2026-09-06.md create mode 100644 ui/src/stores/commitDrafts.ts diff --git a/PRD.md b/PRD.md index 91716aa7..764ac5f2 100644 --- a/PRD.md +++ b/PRD.md @@ -58,7 +58,7 @@ It will be built as a **Tauri 2** application with a **Rust** git backend and a | App shell | **Tauri 2** | True cross-platform, small bundles (~10MB vs Electron's ~100MB+), native webview per OS, signed installers built-in. | | Backend language | **Rust** | Speed, safety, and a great Git ecosystem (`gix`, `git2`). Pairs natively with Tauri. | | Git engine (read) | **`gix` (gitoxide)** | Pure-Rust, modern, dramatically faster than libgit2 for log/diff/status on large repos. | -| Git engine (write) | **`git2` (libgit2)** + shell-out to `git` | Use `git2` for commit/branch/merge/rebase where stable. Shell out to the user's `git` binary for ops that need it: interactive rebase, GPG signing, Git LFS, Git-flow, hooks. This is what Sublime Merge and Tower do — it's the right call. | +| Git engine (write) | **`git2` (libgit2)** + shell-out to `git` | Use `git2` for index/branch writes where stable. Commit/amend always use system Git for hook parity. Shell out to the user's `git` binary for ops that need it: interactive rebase, GPG signing, Git LFS, Git-flow, hooks. This is what Sublime Merge and Tower do — it's the right call. | | Frontend | **React + TypeScript** | Required by `@pierre/diffs` and `@pierre/trees`. | | Diff & code rendering | **`@pierre/diffs`** | Split/stacked diffs, merge conflict UI, line selection, annotations, Shiki themes. Covers most of §6.3. | | File tree | **`@pierre/trees`** | Virtualized (handles 100k+ files), Git status badges built in, drag-and-drop, search, keyboard nav, accessible. Covers §6.5. | diff --git a/README.md b/README.md index 40ac8df7..938aa5af 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,8 @@ the resolved app appearance automatically. (fetch-first for remote bases) and copies gitignored setup files listed in `.worktreeinclude` (`.env`, local settings) so agents can run out of the box. Stale entries whose directories are already gone prune immediately. -- **Everyday Git** — stage, unstage, or recoverably discard whole change +- **Everyday Git** — hook-aware signed/unsigned commit and amend with checkout + session drafts and bounded output; stage, unstage, or recoverably discard whole change blocks or individually selected lines inline in the diff; bulk tree actions include every selected file and every changed file beneath selected folders; initialize a repository with an initial branch, optional diff --git a/ROADMAP.md b/ROADMAP.md index f048b0bf..64995455 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2810,6 +2810,15 @@ GitHub/Azure review, Workbench and performance work retain their own status. --- +**Commit hook parity shipped (2026-09-06, F01):** Signed and unsigned commit/amend +now run system Git’s applicable hooks and honor custom hooksPath, rejection +and rewritten messages. Checkout session drafts survive rejection/navigation; +expandable output retains bounded diagnostics. Native Ctrl+Enter flows and +core/store regressions passed; the loaded-host no-hook cost is recorded in +`docs/hooks-identity-signing-validation-2026-09-06.md`. Index/status paths are +unchanged; the commit-policy exception is explicit in PRD and learnings. + + ## Cross-cutting tracks (run in parallel with all milestones) **Performance audit kick (2026-09-06):** Rechecked `main` at `8e83c8c` on diff --git a/TASKS.md b/TASKS.md index 4c7910bf..4325929d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -89,10 +89,11 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git- (`docs/git-client-feature-audit-2026-09-06.md`: 19 missing/partial feature families, code evidence, priorities, fallbacks, and acceptance criteria). Priorities below are current recommendations, not historical PRD release gates. -- ☐ **F01 / P1 — Hook parity for unsigned commit/amend.** Resolve the recorded +- ☑ **F01 / P1 — Hook parity for unsigned commit/amend.** Resolve the recorded git2 commit-policy versus Git-hook contract tension; honor `core.hooksPath`, rejection and message rewriting, preserve drafts and bounded diagnostics, - and measure the no-hook path (`commit.rs`; signed commits already use Git). + and measure the no-hook path (`Repo::commit`, bounded `git_output`, checkout + `commitDrafts`; evidence in `docs/hooks-identity-signing-validation-2026-09-06.md`). - ☐ **F02 / P1 — Effective repository identity and scoped overrides.** Show the current author/committer identity, set/remove repo-local name/email without changing global/conditional config, and verify linked worktrees. diff --git a/crates/strand-core/src/commit.rs b/crates/strand-core/src/commit.rs index d99081c7..56f63da8 100644 --- a/crates/strand-core/src/commit.rs +++ b/crates/strand-core/src/commit.rs @@ -12,94 +12,32 @@ pub struct CommitOutcome { pub oid: String, /// Whether this commit was an amend of the previous HEAD. pub amended: bool, + /// Bounded stdout and stderr, including successful hook diagnostics. + pub output: String, } -/// Whether the repo's effective config asks for signed commits -/// (`commit.gpgSign = true`). Read through a snapshot for a consistent merged -/// view (system + global + local), like [`gitconfig`](crate::gitconfig); git2 -/// config keys are case-insensitive, so the lowercase lookup matches any -/// spelling. -fn signing_enabled(repo: &git2::Repository) -> bool { - repo.config() - .and_then(|mut c| c.snapshot()) - .and_then(|s| s.get_bool("commit.gpgsign")) - .unwrap_or(false) -} - -/// Write the current index as a new commit on HEAD. -/// -/// Two paths: when `commit.gpgSign` is off (the default) we commit in-process -/// via git2; when it's on we shell out to the user's `git` instead, because -/// git2 never signs — the shell-out picks up the user's gpg/ssh signing -/// config, `gpg.format`, and key lookup for free. +/// Commit/amend always use system Git: it owns hooks (including hooksPath), +/// identity, merge parents, signing and message rewrites. Index edits stay on +/// git2. This deliberately supersedes the old unsigned git2 fast path. impl Repo { pub fn commit(&self, subject: &str, body: Option<&str>, amend: bool) -> Result { - let repo = self.git2()?; - let message = match body.map(str::trim).filter(|b| !b.is_empty()) { Some(b) => format!("{}\n\n{}\n", subject.trim(), b), None => format!("{}\n", subject.trim()), }; - - let oid = if signing_enabled(repo) { - self.commit_via_git(&message, amend)?; - repo.head()?.peel_to_commit()?.id() - } else { - let sig = repo.signature()?; - let mut index = repo.index()?; - let tree_oid = index.write_tree()?; - let tree = repo.find_tree(tree_oid)?; - - if amend { - let head = repo.head()?; - let head_commit = head.peel_to_commit()?; - // Author `None` keeps the original author (git2 reuses the - // existing field), matching real `git commit --amend` and the - // shell-out path; only the committer is the current user. - head_commit.amend( - Some("HEAD"), - None, - Some(&sig), - None, - Some(&message), - Some(&tree), - )? - } else { - // Parent list: HEAD if it exists; empty for the initial commit. - let parents: Vec = match repo.head() { - Ok(h) => vec![h.peel_to_commit()?], - Err(_) => Vec::new(), - }; - let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); - repo.commit(Some("HEAD"), &sig, &sig, &message, &tree, &parent_refs)? - } - }; - - Ok(CommitOutcome { - oid: oid.to_string(), - amended: amend, - }) + let output = self.commit_via_git(&message, amend)?; + let oid = self.git2()?.head()?.peel_to_commit()?.id().to_string(); + Ok(CommitOutcome { oid, amended: amend, output }) } - /// Commit the staged index by shelling out to the user's `git` — the - /// signing path, since git2 cannot sign. The message goes through a temp - /// file (`-F`, with `--cleanup=verbatim`: the file is built by us and - /// already exact, so verbatim keeps `#` lines AND byte parity with the - /// git2 path, which never cleans) to dodge platform quoting. Unlike the - /// git2 path this runs the user's hooks (pre-commit / commit-msg) — that - /// matches plain `git commit` and is the same accepted trust boundary the - /// other shell-out ops have (PRD §10). - fn commit_via_git(&self, message: &str, amend: bool) -> Result<()> { + fn commit_via_git(&self, message: &str, amend: bool) -> Result { let file = temp_message_file(message)?; let file_arg = file.to_string_lossy().into_owned(); let mut args = vec!["commit", "-F", file_arg.as_str(), "--cleanup=verbatim"]; - if amend { - args.push("--amend"); - } + if amend { args.push("--amend"); } let res = run_git(&self.path, &args); - // Best effort, on the error path too — a leak here is only temp litter. let _ = std::fs::remove_file(&file); - res.map(|_| ()) + res } } @@ -141,7 +79,7 @@ fn temp_message_file(message: &str) -> Result { /// (not a `Repo` method) so it doesn't collide with `stash`'s same-named /// helper on the same type. fn run_git(cwd: &Path, args: &[&str]) -> Result { - let out = crate::git_command() + let out = crate::git_output::capture(crate::git_command() .current_dir(cwd) .env("GIT_TERMINAL_PROMPT", "0") // Detach stdin so git can never block reading from a TTY/pipe we don't @@ -149,8 +87,8 @@ fn run_git(cwd: &Path, args: &[&str]) -> Result { .stdin(std::process::Stdio::null()) // Neutralize repo-local config that would run code as a side effect. .args(crate::GIT_SAFE_CONFIG) - .args(args) - .output() + .env("GIT_EDITOR", ":") + .args(args)) .map_err(|e| Error::Other(format!("spawn git failed: {e}")))?; if !out.status.success() { let stdout = String::from_utf8_lossy(&out.stdout); @@ -162,7 +100,7 @@ fn run_git(cwd: &Path, args: &[&str]) -> Result { combined })); } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + Ok(format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)).trim().to_string()) } #[cfg(test)] @@ -185,6 +123,8 @@ mod tests { git(&dir, &["init", "-q", "-b", "main"]); git(&dir, &["config", "user.name", "Test"]); git(&dir, &["config", "user.email", "test@example.com"]); + git(&dir, &["config", "commit.gpgsign", "false"]); + git(&dir, &["config", "core.hooksPath", ".git/hooks"]); (Repo::discover(dir.to_str().unwrap()).unwrap(), dir) } @@ -204,18 +144,85 @@ mod tests { git(dir, &["add", file]); } + fn hook(dir: &Path, name: &str, script: &str) { + std::fs::create_dir_all(dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap(); + #[cfg(unix)] { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + #[test] - fn signing_enabled_defaults_off_and_follows_config() { + fn hooks_reject_rewrite_and_run_after_commit_and_amend() { let (repo, dir) = scratch_repo(); - let g2 = repo.git2().unwrap(); - assert!(!signing_enabled(&g2), "unset ⇒ off"); - - git(&dir, &["config", "commit.gpgsign", "true"]); - assert!(signing_enabled(&repo.git2().unwrap())); + stage(&dir, "a.txt", "a\n"); + let hooks = dir.join("custom hooks"); + git(&dir, &["config", "core.hooksPath", "custom hooks"]); + hook(&hooks, "pre-commit", "echo policy-rejected >&2; exit 1"); + let error = repo.commit("draft", Some("body"), false).unwrap_err().to_string(); + assert!(error.contains("policy-rejected")); + assert!(repo.git2().unwrap().head().is_err()); + assert_eq!(git(&dir, &["diff", "--cached", "--name-only"]), "a.txt"); + hook(&hooks, "pre-commit", "echo pre-commit-ok"); + hook(&hooks, "prepare-commit-msg", "echo prepared >> \"$1\""); + hook(&hooks, "commit-msg", "echo rewritten >> \"$1\""); + hook(&hooks, "post-commit", "echo post-commit-ok >&2"); + hook(&hooks, "post-rewrite", "read old new; echo post-rewrite-$1-$old-$new >&2"); + let outcome = repo.commit("draft", Some("body"), false).unwrap(); + assert!(outcome.output.contains("pre-commit-ok")); + assert!(outcome.output.contains("post-commit-ok")); + assert_eq!(git(&dir, &["log", "-1", "--format=%B"]), "draft\n\nbody\nprepared\nrewritten"); + hook(&hooks, "commit-msg", "echo message-rejected >&2; exit 1"); + assert!(repo.commit("amend draft", None, true).unwrap_err().to_string().contains("message-rejected")); + assert_eq!(git(&dir, &["rev-parse", "HEAD"]), outcome.oid); + hook(&hooks, "commit-msg", "echo amended >> \"$1\""); + let amended = repo.commit("amend draft", None, true).unwrap(); + assert!(amended.output.contains(&format!("post-rewrite-amend-{}-{}", outcome.oid, amended.oid))); + assert_eq!(git(&dir, &["rev-list", "--count", "HEAD"]), "1"); + let _ = std::fs::remove_dir_all(dir); + } - git(&dir, &["config", "commit.gpgsign", "false"]); - assert!(!signing_enabled(&repo.git2().unwrap())); + #[test] + fn hook_output_is_bounded_and_preserves_final_failure() { + let (repo, dir) = scratch_repo(); + stage(&dir, "a.txt", "a\n"); + hook(&dir.join(".git/hooks"), "pre-commit", "i=0; while [ $i -lt 3000 ]; do echo verbose-hook-output; echo verbose-stderr >&2; i=$((i+1)); done; echo final-rejection >&2; exit 1"); + let error = repo.commit("draft", None, false).unwrap_err().to_string(); + assert!(error.len() < 34 * 1024); + assert!(error.contains("output truncated")); + assert!(error.ends_with("final-rejection")); + let _ = std::fs::remove_dir_all(dir); + } + #[test] + #[ignore = "manual no-hook latency measurement"] + fn measure_no_hook_commit_path() { + let (repo, dir) = scratch_repo(); + let mut samples = Vec::new(); + for i in 0..25 { + stage(&dir, "a.txt", &format!("{i}\n")); + let start = std::time::Instant::now(); + repo.commit("measurement", None, false).unwrap(); + samples.push(start.elapsed().as_secs_f64() * 1000.0); + } + let mut previous = Vec::new(); + for i in 0..25 { + stage(&dir, "a.txt", &format!("old-{i}\n")); + let start = std::time::Instant::now(); + let g2 = repo.git2().unwrap(); + let sig = g2.signature().unwrap(); + let tree_oid = g2.index().unwrap().write_tree().unwrap(); + let tree = g2.find_tree(tree_oid).unwrap(); + let parent = g2.head().unwrap().peel_to_commit().unwrap(); + g2.commit(Some("HEAD"), &sig, &sig, "measurement", &tree, &[&parent]).unwrap(); + previous.push(start.elapsed().as_secs_f64() * 1000.0); + } + previous.sort_by(f64::total_cmp); + println!("previous git2 path: median {:.2}ms, p95 {:.2}ms (25 iterations)", previous[12], previous[23]); + samples.sort_by(f64::total_cmp); + println!("no-hook Git commit: median {:.2}ms, p95 {:.2}ms (25 iterations)", samples[12], samples[23]); let _ = std::fs::remove_dir_all(dir); } @@ -273,7 +280,7 @@ mod tests { stage(&dir, "a.txt", "a\n"); repo.commit("original", None, false).unwrap(); - // Same parity check for the git2 path: a different configured user + // A different configured user // amends, the original author survives, the committer updates. git(&dir, &["config", "user.name", "Other"]); git(&dir, &["config", "user.email", "other@example.com"]); diff --git a/crates/strand-core/src/git_output.rs b/crates/strand-core/src/git_output.rs new file mode 100644 index 00000000..5adb7b7c --- /dev/null +++ b/crates/strand-core/src/git_output.rs @@ -0,0 +1,59 @@ +//! Bounded pipe capture for user-triggered Git commands. Drain both pipes to +//! EOF even after the limit, retaining the start and final diagnostics. + +use std::io::Read; +use std::process::{Command, Output, Stdio}; + +const HALF_LIMIT: usize = 8 * 1024; + +fn drain(mut pipe: impl Read) -> std::io::Result> { + let mut head = Vec::new(); + let mut tail = Vec::new(); + let mut total = 0; + let mut buffer = [0; 8192]; + loop { + let n = pipe.read(&mut buffer)?; + if n == 0 { break; } + total += n; + let split = n.min(HALF_LIMIT - head.len()); + head.extend_from_slice(&buffer[..split]); + tail.extend_from_slice(&buffer[split..n]); + if tail.len() > HALF_LIMIT { + tail.drain(..tail.len() - HALF_LIMIT); + } + } + if total > HALF_LIMIT * 2 { + head.extend_from_slice(b"\n[output truncated; final diagnostics follow]\n"); + } + head.extend(tail); + Ok(head) +} + +pub(crate) fn capture(command: &mut Command) -> crate::Result { + let mut child = command.stdin(Stdio::null()) + .stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?; + let stdout = child.stdout.take().expect("piped stdout"); + let stderr = child.stderr.take().expect("piped stderr"); + let reader = std::thread::spawn(move || drain(stdout)); + let stderr = drain(stderr); + let status = child.wait()?; + let stdout = reader.join().map_err(|_| crate::Error::Other("Git output reader failed".into()))??; + Ok(Output { status, stdout, stderr: stderr? }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retains_start_and_end_without_unbounded_allocation() { + let mut bytes = b"start\n".to_vec(); + bytes.extend(vec![b'x'; 1024 * 1024]); + bytes.extend_from_slice(b"\nfinal error"); + let output = drain(bytes.as_slice()).unwrap(); + assert!(output.len() < 17 * 1024); + assert!(output.starts_with(b"start\n")); + assert!(output.ends_with(b"\nfinal error")); + assert!(String::from_utf8(output).unwrap().contains("output truncated")); + } +} diff --git a/crates/strand-core/src/lib.rs b/crates/strand-core/src/lib.rs index e2dc385a..8c48a803 100644 --- a/crates/strand-core/src/lib.rs +++ b/crates/strand-core/src/lib.rs @@ -31,6 +31,7 @@ pub mod maintenance; pub mod conflict; pub mod external; pub mod gitconfig; +mod git_output; pub mod history; pub mod ignore; pub mod stash; diff --git a/crates/strand-tauri/src/commands.rs b/crates/strand-tauri/src/commands.rs index e0ec2642..23fed03a 100644 --- a/crates/strand-tauri/src/commands.rs +++ b/crates/strand-tauri/src/commands.rs @@ -899,13 +899,15 @@ pub fn repo_apply_patch(path: String, patch: String, target: String) -> CmdResul } #[tauri::command(async)] -pub fn repo_commit( +pub async fn repo_commit( path: String, subject: String, body: Option, amend: bool, ) -> CmdResult { - Ok(Repo::discover(&path)?.commit(&subject, body.as_deref(), amend)?) + run_blocking("commit", move || { + Ok(Repo::discover(&path)?.commit(&subject, body.as_deref(), amend)?) + }).await } // Network commands run on a blocking thread (they shell out to `git`, which diff --git a/docs/hooks-identity-signing-validation-2026-09-06.md b/docs/hooks-identity-signing-validation-2026-09-06.md new file mode 100644 index 00000000..50be4ec0 --- /dev/null +++ b/docs/hooks-identity-signing-validation-2026-09-06.md @@ -0,0 +1,35 @@ +# F01–F03 validation — 2026-09-06 + +Base: `263ebe6` (PR #114). Windows, system Git, isolated worktree and fixtures. + +## F01 — commit hooks + +Commit and amend always use system Git. This explicitly supersedes the old +unsigned git2 path documented in learnings; index operations remain on git2. +Git owns hook lookup, `core.hooksPath`, rejection, message rewrites, signing, +merge parents and amend attribution. The command runs on the blocking pool. +Stdout and stderr are drained concurrently, keeping each stream’s first/last +8 KiB with an explicit truncation marker. Drafts are checkout-keyed and survive +failed operations and view/repository changes during the session. + +Evidence: +- Core fixtures: custom hooksPath, rejecting pre-commit and commit-msg, + prepare-commit-msg/commit-msg rewriting, post-commit output, amend post-rewrite + old/new OIDs, preserved index/HEAD on rejection, bounded verbose output, + attribution on amend and missing SSH signing key failure. +- Store/shortcut tests: 12 passed, including rejected-hook index refresh, + completed commit with failed refresh, and repository-switch response handling. +- Frontend TypeScript: passed. +- Isolated native WebView2: Ctrl+Enter rejection kept subject/body; switching + to Commits and back retained the draft; retry ran message rewriting and + exposed successful hook output. Verified the resulting commit message with + Git. Screenshots retained under `target/verify-f010203/f01-*.png` (local only). +- Manual 25-iteration debug measurement, while other tasks were compiling: + system Git median **529.01 ms**, p95 **797.32 ms**; former git2 algorithm median + **24.50 ms**, p95 **97.98 ms**. This is an explicit correctness cost on commit, + not a status/staging hot-path change or an idle performance certification. + Reproduce with `cargo test -p strand-core measure_no_hook_commit_path -- + --ignored --nocapture`. Both measurements exclude staging. + +Git contracts: [hooks](https://git-scm.com/docs/githooks), +[commit](https://git-scm.com/docs/git-commit). diff --git a/docs/learnings.md b/docs/learnings.md index e50db001..ef97422c 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -359,7 +359,13 @@ the existing pattern better than a forced shared abstraction. behaviour matters more than staying pure-git2: **conflicts** (git leaves markers + the in-progress state on disk), **GPG/SSH signing**, and **hooks** — none of which git2's `merge`/`cherrypick`/`revert` do for free, and git2 has no rebase -driver. Index/commit ops still use git2. After any history op, the store refresh +driver. Index operations still use git2; commit/amend always use system Git +(F01, 2026-09-06), including unsigned commits, so Git owns hook discovery, +rejection, message rewriting, merge parents and effective identity. No hook +existence shortcut: conditional/worktree config and installed hooks can change +between operations. Capture bounded stdout/stderr, preserve checkout drafts on +failure, and do not report post-success refresh errors as commit failures. +After any history op, the store refresh tail is meta + local-changes + log + refs (`refreshAfterHistoryOp`), and a paused op is detected via `Repo::operation_in_progress` reading `.git/` markers (`rebase-merge`/`rebase-apply`, `CHERRY_PICK_HEAD`, `REVERT_HEAD`, `MERGE_HEAD`, diff --git a/ui/src/demo/dispatch.ts b/ui/src/demo/dispatch.ts index 4298b70f..4157cbd2 100644 --- a/ui/src/demo/dispatch.ts +++ b/ui/src/demo/dispatch.ts @@ -209,7 +209,7 @@ export const handlers: Record = { repo_apply_patch: (a) => repo.applyPatchTo(wtOf(a), str(a.patch), a.target as 'index' | 'index_reverse' | 'workdir_reverse' | 'workdir'), repo_commit: (a) => { const c = repo.commitIndex(wtOf(a), str(a.subject), a.body == null ? null : str(a.body), Boolean(a.amend)); - return { oid: c.hash, amended: Boolean(a.amend) }; + return { oid: c.hash, amended: Boolean(a.amend), output: 'Demo commit created.' }; }, // ---- branches / tags / remotes ----------------------------------------- diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index c27be6a0..48e49034 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -162,6 +162,7 @@ export interface ReviewNote { export interface CommitOutcome { oid: string; amended: boolean; + output: string; } export interface UpstreamRef { diff --git a/ui/src/stores/commitDrafts.ts b/ui/src/stores/commitDrafts.ts new file mode 100644 index 00000000..85d07793 --- /dev/null +++ b/ui/src/stores/commitDrafts.ts @@ -0,0 +1,25 @@ +import { create } from 'zustand'; + +interface CommitDraft { + subject: string; + body: string; + amend: boolean; + submitting: boolean; + output: string; + error: string | null; +} + +export const emptyCommitDraft: CommitDraft = { + subject: '', body: '', amend: false, submitting: false, output: '', error: null, +}; + +/** Session drafts belong to a checkout, including while a hook is running. */ +export const useCommitDrafts = create<{ + drafts: Record; + patch(path: string, patch: Partial): void; +}>((set) => ({ + drafts: {}, + patch: (path, patch) => set((s) => ({ + drafts: { ...s.drafts, [path]: { ...(s.drafts[path] ?? emptyCommitDraft), ...patch } }, + })), +})); diff --git a/ui/src/stores/repo.test.ts b/ui/src/stores/repo.test.ts index 5d203f72..3a5fdf52 100644 --- a/ui/src/stores/repo.test.ts +++ b/ui/src/stores/repo.test.ts @@ -144,3 +144,35 @@ describe('AI review notes', () => { }]); }); }); + + +describe('commit outcome boundary', () => { + it('propagates a hook rejection and refreshes its index changes', async () => { + const refresh = vi.fn(async () => {}); + const failure = { message: 'commit-msg rejected' }; + vi.spyOn(tauri, 'repoCommit').mockRejectedValue(failure); + useRepo.setState({ activePath: '/repo', refreshLocalChanges: refresh }); + await expect(useRepo.getState().commit('draft', 'body', true)).rejects.toBe(failure); + expect(refresh).toHaveBeenCalledOnce(); + }); + + it('keeps a completed commit successful if refresh fails', async () => { + const outcome = { oid: 'abc', amended: false, output: 'hook accepted' }; + vi.spyOn(tauri, 'repoCommit').mockResolvedValue(outcome); + const refresh = vi.fn(async () => { throw new Error('refresh failed'); }); + useRepo.setState({ activePath: '/repo', refreshLocalChanges: refresh, + refreshLog: refresh, refreshStashes: refresh, refreshMeta: refresh, refreshRefs: refresh }); + await expect(useRepo.getState().commit('draft', null, false)).resolves.toEqual(outcome); + }); + + it('does not refresh a different checkout after a slow hook completes', async () => { + const refresh = vi.fn(async () => {}); + vi.spyOn(tauri, 'repoCommit').mockImplementation(async () => { + useRepo.setState({ activePath: '/other' }); + return { oid: 'abc', amended: false, output: '' }; + }); + useRepo.setState({ activePath: '/repo', refreshLocalChanges: refresh, refreshLog: refresh }); + await useRepo.getState().commit('draft', null, false); + expect(refresh).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/stores/repo.ts b/ui/src/stores/repo.ts index 44ec9ae4..844ad528 100644 --- a/ui/src/stores/repo.ts +++ b/ui/src/stores/repo.ts @@ -23,6 +23,7 @@ import type { BaseBranch, CodeReviewFinding, Commit, + CommitOutcome, BranchPushRequest, CommitSearchMode, FileDiff, @@ -431,7 +432,7 @@ export interface RepoState { loadRepoDiffMode(): Promise; stageAll(): Promise; unstageAll(): Promise; - commit(subject: string, body: string | null, amend: boolean): Promise; + commit(subject: string, body: string | null, amend: boolean): Promise; /** Re-read RepoMeta (branch, ahead/behind) for the active tab. */ refreshMeta(): Promise; @@ -1799,13 +1800,23 @@ export const useRepo = create((set, get) => ({ }, async commit(subject, body, amend) { const path = get().activePath; - if (!path) return; - await tauri.repoCommit(path, subject, body, amend); - await Promise.all([ - get().refreshLocalChanges(), - get().refreshLog(), - get().refreshStashes(), - ]); + if (!path) throw new Error('No repository selected.'); + let outcome: CommitOutcome; + try { + outcome = await tauri.repoCommit(path, subject, body, amend); + } catch (error) { + // Hooks can edit the index/worktree even when they reject the commit. + if (get().activePath === path) await get().refreshLocalChanges().catch(() => {}); + throw error; + } + if (get().activePath === path) { + // A failed refresh cannot turn a completed commit into a retryable failure. + await Promise.allSettled([ + get().refreshLocalChanges(), get().refreshLog(), get().refreshStashes(), + get().refreshMeta(), get().refreshRefs(), + ]); + } + return outcome; }, async refreshMeta() { diff --git a/ui/src/styles/features.css b/ui/src/styles/features.css index 03aa8eaa..52a72821 100644 --- a/ui/src/styles/features.css +++ b/ui/src/styles/features.css @@ -9542,3 +9542,8 @@ select.clone-input { .plugin-heroi-select-thinking, .plugin-heroi-select-permission { display: none; } } + +/* Git hook transcripts stay selectable and bounded in the commit form. */ +.cb-output, .cb-error { max-height: 160px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; } +.cb-output { font-size: var(--type-ui-sm); color: var(--text-2); } +.cb-output pre { white-space: pre-wrap; margin: 6px 0; } diff --git a/ui/src/views/LocalChanges.tsx b/ui/src/views/LocalChanges.tsx index e3d1d269..815fe685 100644 --- a/ui/src/views/LocalChanges.tsx +++ b/ui/src/views/LocalChanges.tsx @@ -1,3 +1,4 @@ +import { emptyCommitDraft, useCommitDrafts } from '../stores/commitDrafts'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { @@ -1667,12 +1668,15 @@ function CommitBar({ canCommit, hasChanges }: { canCommit: boolean; hasChanges: const openaiCli = useSettings((s) => s.openaiCli); const anthropicCli = useSettings((s) => s.anthropicCli); const platform = useSettings((s) => s.platform); - const [subject, setSubject] = useState(''); - const [body, setBody] = useState(''); - const [amend, setAmend] = useState(false); - const [submitting, setSubmitting] = useState(false); + const draftPath = activePath ?? ''; + const draft = useCommitDrafts((s) => s.drafts[draftPath] ?? emptyCommitDraft); + const patchDraft = useCommitDrafts((s) => s.patch); + const { subject, body, amend, submitting, output, error: commitError } = draft; + const setSubject = (subject: string) => patchDraft(draftPath, { subject }); + const setBody = (body: string) => patchDraft(draftPath, { body }); + const setAmend = (amend: boolean) => patchDraft(draftPath, { amend }); + const setCommitError = (error: string | null) => patchDraft(draftPath, { error }); const [suggesting, setSuggesting] = useState(false); - const [commitError, setCommitError] = useState(null); const [sensitivePrompt, setSensitivePrompt] = useState<{ fingerprint: string; files: AiSensitiveFile[]; @@ -1789,18 +1793,15 @@ function CommitBar({ canCommit, hasChanges }: { canCommit: boolean; hasChanges: const trimmed = subject.trim(); if (!trimmed || submitting) return; if (!canCommit && !amend) return; - setSubmitting(true); - setCommitError(null); + patchDraft(draftPath, { submitting: true, error: null, output: '' }); try { - await commit(trimmed, body.trim() || null, amend); - setSubject(''); - setBody(''); - setAmend(false); + const result = await commit(trimmed, body.trim() || null, amend); + patchDraft(draftPath, { subject: '', body: '', amend: false, output: result.output }); } catch (e) { console.error('commit failed', e); setCommitError(`Commit failed: ${gitErrorHint(e)}`); } finally { - setSubmitting(false); + patchDraft(draftPath, { submitting: false }); } } @@ -1904,6 +1905,7 @@ function CommitBar({ canCommit, hasChanges }: { canCommit: boolean; hasChanges: )} + {output &&
Commit output
{output}
} {commitError && (
{commitError} diff --git a/website/docs/everyday-git.md b/website/docs/everyday-git.md index cf6223f9..d49a2a4a 100644 --- a/website/docs/everyday-git.md +++ b/website/docs/everyday-git.md @@ -2,6 +2,12 @@ Strand is a complete daily-driver Git client alongside its review features. This page covers staging and committing in Local Changes, syncing with remotes, the sidebar's branch/tag/stash/remote/submodule sections, history operations like cherry-pick and interactive rebase, and conflict resolution. +Commit and amend run system Git’s hooks, including a configured `core.hooksPath`. +A rejecting hook leaves your subject, body and amend selection in the checkout’s +session draft. Drafts also survive view/repository switches. Failures show Git’s +diagnostics; successful commits offer expandable **Commit output**. Output keeps +the first and last 8 KiB of each stream when a hook is verbose. + ## Local Changes (`Mod+2`) Local Changes is a pure staging workspace: an Unstaged pane and a Staged pane (hierarchical file trees with status badges), a diff pane, and the commit form. diff --git a/website/docs/settings.md b/website/docs/settings.md index c3aa8e84..85a29033 100644 --- a/website/docs/settings.md +++ b/website/docs/settings.md @@ -44,7 +44,7 @@ Below the rebindable list, a **Context shortcuts** card documents the fixed, sur - **Global identity** — Name and Email inputs written to your global git config (`~/.gitconfig`) with an explicit **Save identity** button. This is the author identity for new commits everywhere, not just in Strand. - **Default clone & open folder** — a path with **Choose…** and **Clear** buttons. This is where the clone dialog and the open-repository picker start. -Everything else about git — credentials, SSH keys, commit signing — is inherited from your existing git setup: network operations (push, pull, fetch, clone) go through your system `git`, and when `commit.gpgSign` is on, commits do too — picking up your signing config and running your `pre-commit` / `commit-msg` hooks, just like plain `git commit`. Unsigned commits (the default) are written in-process and do not run commit hooks. There is nothing to configure in Strand for those. +Everything else about git — credentials, SSH keys, commit signing — is inherited from your existing git setup: network operations (push, pull, fetch, clone) go through your system `git`, and every commit/amend does too. Signed and unsigned commits honor hooks (including `core.hooksPath`), rejecting policies and message rewrites. A rejection preserves your checkout’s draft; expandable commit output retains bounded hook diagnostics. Signing continues to use your existing Git config and agents. ## Hosting From 062a482f38fc6e1cf09376accc601eca6456aa58 Mon Sep 17 00:00:00 2001 From: Daniels-Main Date: Sun, 6 Sep 2026 15:58:21 +0200 Subject: [PATCH 2/8] feat: show effective repository identity and local overrides --- README.md | 3 +- ROADMAP.md | 9 + TASKS.md | 5 +- crates/strand-core/src/gitconfig.rs | 194 +++++++++++++++++- crates/strand-tauri/src/commands.rs | 12 +- crates/strand-tauri/src/main.rs | 2 + ...-identity-signing-validation-2026-09-06.md | 17 ++ docs/learnings.md | 12 ++ ui/src/App.tsx | 1 + ui/src/demo/dispatch.ts | 7 + ui/src/lib/tauri.ts | 4 + ui/src/lib/types.ts | 17 ++ ui/src/views/settings/GitSection.tsx | 7 +- ui/src/views/settings/RepositoryIdentity.tsx | 67 ++++++ website/docs/settings.md | 5 + 15 files changed, 351 insertions(+), 11 deletions(-) create mode 100644 ui/src/views/settings/RepositoryIdentity.tsx diff --git a/README.md b/README.md index 938aa5af..8e928827 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,8 @@ the resolved app appearance automatically. `.worktreeinclude` (`.env`, local settings) so agents can run out of the box. Stale entries whose directories are already gone prune immediately. - **Everyday Git** — hook-aware signed/unsigned commit and amend with checkout - session drafts and bounded output; stage, unstage, or recoverably discard whole change + session drafts and bounded output; effective repository author/committer identity + with local overrides in Settings → Git; stage, unstage, or recoverably discard whole change blocks or individually selected lines inline in the diff; bulk tree actions include every selected file and every changed file beneath selected folders; initialize a repository with an initial branch, optional diff --git a/ROADMAP.md b/ROADMAP.md index 64995455..04d14832 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2819,6 +2819,15 @@ core/store regressions passed; the loaded-host no-hook cost is recorded in unchanged; the commit-policy exception is explicit in PRD and learnings. +**Repository identity shipped (2026-09-06, F02):** Settings → Git and its palette +entry show effective author/committer identity with per-field source/scope. +Individual local name/email overrides can be saved or removed without editing +global or included files. Linked worktrees share local values; existing +worktree overrides remain effective. Conditional, two-repository and linked +worktree fixtures passed; native settings save/remove and repository switching +were exercised with isolated fixtures. + + ## Cross-cutting tracks (run in parallel with all milestones) **Performance audit kick (2026-09-06):** Rechecked `main` at `8e83c8c` on diff --git a/TASKS.md b/TASKS.md index 4325929d..efee4dd8 100644 --- a/TASKS.md +++ b/TASKS.md @@ -94,9 +94,10 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git- rejection and message rewriting, preserve drafts and bounded diagnostics, and measure the no-hook path (`Repo::commit`, bounded `git_output`, checkout `commitDrafts`; evidence in `docs/hooks-identity-signing-validation-2026-09-06.md`). -- ☐ **F02 / P1 — Effective repository identity and scoped overrides.** Show +- ☑ **F02 / P1 — Effective repository identity and scoped overrides.** Show the current author/committer identity, set/remove repo-local name/email - without changing global/conditional config, and verify linked worktrees. + without changing global/conditional config, and verify linked worktrees + (`repository_identity` / `repo_set_identity`, Settings → Git source display). - ☐ **F03 / P1 — Signing controls and signed tags.** Keep configured commit signing/verification; add scoped format/key controls and signed-tag creation with agent delegation and visible signing failures. diff --git a/crates/strand-core/src/gitconfig.rs b/crates/strand-core/src/gitconfig.rs index f13b02e0..1388f6c9 100644 --- a/crates/strand-core/src/gitconfig.rs +++ b/crates/strand-core/src/gitconfig.rs @@ -1,13 +1,135 @@ -//! Global git configuration access — the user-level identity (`user.name` / -//! `user.email`) shown and edited in Settings → Git. Reads resolve the same -//! merged view git itself uses (system + global + XDG); writes always target -//! the **global** file, never a repo's `.git/config`. +//! Global defaults and effective repository identity. Repository reads use +//! system Git so conditional/worktree config and environment match commits. +//! Writes target only the explicitly selected global or direct local config. use std::path::PathBuf; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::error::Result; +use crate::{Error, Repo}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScopedValue { + pub value: String, + pub scope: String, + pub origin: String, +} + +#[derive(Debug, Serialize)] +pub struct EffectiveIdentity { + pub identity: Option, + pub error: Option, + pub name_source: ScopedValue, + pub email_source: ScopedValue, +} + +#[derive(Debug, Serialize)] +pub struct RepositoryIdentity { + pub author: EffectiveIdentity, + pub committer: EffectiveIdentity, + pub local: GlobalIdentity, +} + +type ConfigValues = std::collections::BTreeMap; + +fn config_values(repo: &Repo, local: bool, pattern: &str) -> Result { + let mut args = vec!["config", "--null", "--show-scope", "--show-origin"]; + if local { args.extend(["--local", "--no-includes"]); } + else { args.push("--includes"); } + args.extend(["--get-regexp", pattern]); + let out = config_git(repo, &args)?; + if !out.status.success() && out.status.code() != Some(1) { + return Err(config_error(&out)); + } + let text = String::from_utf8_lossy(&out.stdout); + if text.contains("[output truncated;") { + return Err(Error::Other("Git identity/config output exceeded the display limit".into())); + } + let mut fields = text.split_terminator('\0'); + let mut values = ConfigValues::new(); + while let Some(scope) = fields.next() { + let origin = fields.next().ok_or_else(|| Error::Other("Invalid Git config origin".into()))?; + let (key, value) = fields.next().and_then(|entry| entry.split_once('\n')) + .ok_or_else(|| Error::Other("Invalid Git config value".into()))?; + values.insert(key.to_owned(), ScopedValue { + value: value.to_owned(), scope: scope.to_owned(), origin: origin.to_owned(), + }); + } + Ok(values) +} + +fn config_git(repo: &Repo, args: &[&str]) -> Result { + crate::git_output::capture(crate::git_command().current_dir(&repo.path) + .env("GIT_TERMINAL_PROMPT", "0").args(crate::GIT_SAFE_CONFIG).args(args)) +} + +fn config_error(output: &std::process::Output) -> Error { + Error::Other(format!("Git config: {}", String::from_utf8_lossy(&output.stderr).trim())) +} + +fn identity_source(values: &ConfigValues, role: &str, field: &str) -> ScopedValue { + let env_key = format!("GIT_{}_{}", role.to_uppercase(), field.to_uppercase()); + if let Ok(value) = std::env::var(&env_key) { + return ScopedValue { value, scope: "environment".into(), origin: env_key }; + } + values.get(&format!("{role}.{field}")).or_else(|| values.get(&format!("user.{field}"))) + .cloned().unwrap_or_else(|| ScopedValue { + value: String::new(), scope: "fallback".into(), origin: "Git environment/system fallback".into(), + }) +} + +impl Repo { + /// Read using the same Git resolver as commit, including conditional + /// includes, worktree config, author/committer overrides and environment. + /// Only queried on the settings surface, never on status/log refresh. + pub fn repository_identity(&self) -> Result { + let values = config_values(self, false, "^(user|author|committer)\\.(name|email)$")?; + let local = config_values(self, true, "^user\\.(name|email)$")?; + let identity = |role: &str| -> Result { + let variable = format!("GIT_{}_IDENT", role.to_uppercase()); + let out = config_git(self, &["var", &variable])?; + let text = String::from_utf8_lossy(&out.stdout); + Ok(EffectiveIdentity { + identity: out.status.success().then(|| text.rsplit_once('>').map(|(id, _)| format!("{id}>")) + .unwrap_or_else(|| text.trim().to_owned())), + error: (!out.status.success()).then(|| String::from_utf8_lossy(&out.stderr).trim().to_owned()), + name_source: identity_source(&values, role, "name"), + email_source: identity_source(&values, role, "email"), + }) + }; + Ok(RepositoryIdentity { + author: identity("author")?, committer: identity("committer")?, + local: GlobalIdentity { + name: local.get("user.name").map(|v| v.value.clone()), + email: local.get("user.email").map(|v| v.value.clone()), + }, + }) + } + + /// Write only the selected key in the common repository config. Git's + /// --local writes never follow includes back into global/conditional files. + pub fn set_repository_identity(&self, field: &str, value: Option<&str>) -> Result<()> { + let key = match field { + "name" => "user.name", "email" => "user.email", + _ => return Err(Error::Other("Unknown identity field".into())), + }; + self.set_scoped_config("--local", key, value) + } + + fn set_scoped_config(&self, scope: &str, key: &str, value: Option<&str>) -> Result<()> { + if value.is_some_and(|v| v.trim().is_empty() || v.len() > 4096 || v.contains(['\0', '\r', '\n'])) { + return Err(Error::Other("Use a non-empty, single-line config value (up to 4096 bytes), or remove the override".into())); + } + let args = match value { + Some(value) => vec!["config", scope, "--replace-all", key, value], + None => vec!["config", scope, "--unset-all", key], + }; + let out = config_git(self, &args)?; + if out.status.success() || (value.is_none() && out.status.code() == Some(5)) { Ok(()) } + else { Err(config_error(&out)) } + } +} #[derive(Debug, Serialize)] pub struct GlobalIdentity { @@ -56,6 +178,68 @@ fn default_global_path() -> PathBuf { mod tests { use super::*; + #[test] + fn repository_overrides_preserve_conditional_identity_and_other_repositories() { + let dir = std::env::temp_dir().join(format!("strand-identity-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let first = dir.join("one"); + let second = dir.join("two"); + for path in [&first, &second] { + let g2 = git2::Repository::init(path).unwrap(); + let mut config = g2.config().unwrap(); + config.set_str("user.name", "Base").unwrap(); + config.set_str("user.email", "base@example.com").unwrap(); + } + let repo = Repo::discover(&first).unwrap(); + let other = Repo::discover(&second).unwrap(); + let included = dir.join("conditional.gitconfig"); + let content = "[user]\nname = Conditional\nemail = conditional@example.com\n"; + std::fs::write(&included, content).unwrap(); + let mut config = repo.git2().unwrap().config().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 ")); + repo.set_repository_identity("name", Some("Local")).unwrap(); + repo.set_repository_identity("email", Some("local@example.com")).unwrap(); + // The direct local keys occur before the include, so Git correctly + // keeps the later conditional identity effective. UI shows both. + let state = repo.repository_identity().unwrap(); + assert_eq!(state.local.name.as_deref(), Some("Local")); + assert!(state.author.name_source.origin.contains("conditional.gitconfig")); + repo.set_repository_identity("name", None).unwrap(); + repo.set_repository_identity("email", None).unwrap(); + assert_eq!(repo.repository_identity().unwrap().author.identity.as_deref(), Some("Conditional ")); + assert_eq!(std::fs::read_to_string(&included).unwrap(), content); + assert_eq!(other.repository_identity().unwrap().author.identity.as_deref(), Some("Base ")); + assert!(repo.set_repository_identity("signingkey", Some("bad")).is_err()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn local_identity_is_shared_by_linked_worktrees_and_worktree_identity_stays_effective() { + let dir = std::env::temp_dir().join(format!("strand-linked-identity-{}", std::process::id())); + let main = dir.join("main"); + let linked = dir.join("linked"); + std::fs::create_dir_all(&main).unwrap(); + let repo = Repo::discover({ git2::Repository::init(&main).unwrap(); &main }).unwrap(); + repo.set_repository_identity("name", Some("Shared")).unwrap(); + repo.set_repository_identity("email", Some("shared@example.com")).unwrap(); + let out = config_git(&repo, &["-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "base"]).unwrap(); + assert!(out.status.success()); + assert!(config_git(&repo, &["worktree", "add", "-b", "linked", linked.to_str().unwrap()]).unwrap().status.success()); + let worktree = Repo::discover(&linked).unwrap(); + worktree.set_repository_identity("name", Some("Both")).unwrap(); + assert_eq!(repo.repository_identity().unwrap().author.identity.as_deref(), Some("Both ")); + assert!(config_git(&repo, &["config", "extensions.worktreeConfig", "true"]).unwrap().status.success()); + assert!(config_git(&worktree, &["config", "--worktree", "user.name", "Worktree"]).unwrap().status.success()); + worktree.set_repository_identity("name", None).unwrap(); + let identity = worktree.repository_identity().unwrap(); + assert_eq!(identity.author.identity.as_deref(), Some("Worktree ")); + assert_eq!(identity.author.name_source.scope, "worktree"); + assert_eq!(identity.local.name, None); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn identity_round_trips_through_a_config_file() { let dir = std::env::temp_dir().join(format!( diff --git a/crates/strand-tauri/src/commands.rs b/crates/strand-tauri/src/commands.rs index 23fed03a..7ec9623c 100644 --- a/crates/strand-tauri/src/commands.rs +++ b/crates/strand-tauri/src/commands.rs @@ -20,7 +20,7 @@ use strand_core::{ apply::ApplyTarget, blame::BlameLine, branch::CheckoutOutcome, commit::CommitOutcome, commit_metadata::CommitSignature, diff::FileDiff, file::{BlobSource, FileBlob, FileContent, FileHistoryEntry}, - gitconfig::{self, GlobalIdentity}, + gitconfig::{self, GlobalIdentity, RepositoryIdentity}, init::{init_repository, InitOutcome}, maintenance::{MaintenanceOutcome, MaintenanceTask}, history::{MergeMode, RebaseEntry, RebaseStep}, log::{Commit, SearchMode}, @@ -1661,6 +1661,16 @@ pub fn repo_open_in_terminal(path: String, template: String) -> CmdResult<()> { Ok(Repo::discover(&path)?.open_in_terminal(&template)?) } +#[tauri::command(async)] +pub async fn repo_identity(path: String) -> CmdResult { + run_blocking("identity", move || Ok(Repo::discover(&path)?.repository_identity()?)).await +} + +#[tauri::command(async)] +pub async fn repo_set_identity(path: String, field: String, value: Option) -> CmdResult<()> { + run_blocking("identity", move || Ok(Repo::discover(&path)?.set_repository_identity(&field, value.as_deref())?)).await +} + #[tauri::command(async)] pub fn git_global_identity() -> CmdResult { Ok(gitconfig::global_identity()?) diff --git a/crates/strand-tauri/src/main.rs b/crates/strand-tauri/src/main.rs index 0b3fad89..9f89640c 100644 --- a/crates/strand-tauri/src/main.rs +++ b/crates/strand-tauri/src/main.rs @@ -293,6 +293,8 @@ fn main() { commands::repo_open_mergetool, commands::repo_open_in_editor, commands::repo_open_in_terminal, + commands::repo_identity, + commands::repo_set_identity, commands::git_global_identity, commands::git_set_global_identity, commands::workspace_file_read, diff --git a/docs/hooks-identity-signing-validation-2026-09-06.md b/docs/hooks-identity-signing-validation-2026-09-06.md index 50be4ec0..6afc2a1c 100644 --- a/docs/hooks-identity-signing-validation-2026-09-06.md +++ b/docs/hooks-identity-signing-validation-2026-09-06.md @@ -33,3 +33,20 @@ Evidence: Git contracts: [hooks](https://git-scm.com/docs/githooks), [commit](https://git-scm.com/docs/git-commit). + +## F02 — repository identity + +- Core: three gitconfig tests passed, including conditional include contents + left byte-for-byte intact; separate repository values unaffected; common + local config shared across linked worktrees; explicit worktree identity + retained after removal of common local name. +- Native WebView2: Settings → Git displayed effective author/committer and + field provenance; Save name changed the effective identity; Remove name + override restored inheritance; opening a second repository displayed that + repository’s own identity. Local screenshots: `f02-local-identity.png` and + `f02-separate-repository.png` under `target/verify-f010203/`. +- The Git settings entry is available in the command palette; all fields and + actions use native inputs/buttons within the existing Settings tab model. +- Frontend TypeScript and `cargo check -p strand-core -p strand-tauri` passed. + +Git contracts: [config scope and includes](https://git-scm.com/docs/git-config). diff --git a/docs/learnings.md b/docs/learnings.md index ef97422c..0cfb6ba1 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -2500,3 +2500,15 @@ Pierre reads `navigator.userAgent` during module evaluation; Node 22's built-in `navigator` hid a failure on CI's Node 20. Stub browser globals and restore them after the test, while retaining real integration assertions. Reproduce this class of failure locally with `--no-experimental-global-navigator`. + + +## Repository identity must use the commit resolver + +Effective author/committer reads use system Git, including conditional includes, +worktree config and environment overrides. Keep these reads on the explicit +settings surface; do not add subprocesses to snapshot/status paths. Local +identity edits target the direct common repository config, never a file reached +through an include. Show both the saved local values and the effective values: +a later conditional include or a worktree/environment override can still win. +Linked worktrees share local config; `--worktree` writes must never silently +fall back to `--local` when `extensions.worktreeConfig` is disabled. diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ea335854..66c63c90 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1946,6 +1946,7 @@ export function App() { base.push( { id: 'settings', label: 'Settings…', group: 'Actions', shortcut: keyHint('settings'), keywords: 'preferences shortcuts keyboard config options', run: () => openSettingsAt('appearance') }, { id: 'keybindings', label: 'Settings: Keyboard shortcuts', group: 'Actions', keywords: 'keyboard shortcuts keybindings rebind configure customize', run: () => openSettingsAt('keyboard') }, + { id: 'settings-git', label: 'Settings: Repository identity and Git', group: 'Actions', keywords: 'author committer name email local override config', run: () => openSettingsAt('git') }, { id: 'settings-ai', label: 'Settings: AI', group: 'Actions', keywords: 'ai chatgpt codex claude commit message suggest login', run: () => openSettingsAt('ai') }, { id: 'settings-plugins', label: 'Settings: Plugins', group: 'Actions', keywords: 'plugins marketplace extensions workbench surfaces install', run: () => openSettingsAt('plugins') }, { id: 'heroi-new-conversation', label: 'Heroi: New conversation', group: 'Actions', keywords: 'heroi agent chat claude codex cursor', run: () => window.dispatchEvent(new CustomEvent(HEROI_NEW_CONVERSATION_EVENT)) }, diff --git a/ui/src/demo/dispatch.ts b/ui/src/demo/dispatch.ts index 4157cbd2..7707aebf 100644 --- a/ui/src/demo/dispatch.ts +++ b/ui/src/demo/dispatch.ts @@ -69,6 +69,13 @@ export const handlers: Record = { microsoft_store_update_available: () => false, microsoft_store_open_product: () => unavailable('The Microsoft Store'), crash_report_check: () => ({ path: '', len: 0, entry: null }), + repo_identity: () => { + const source = (value: string) => ({ value, scope: 'demo', origin: 'Demo identity' }); + const identity = { identity: `${repo.identity.name} <${repo.identity.email}>`, error: null, + name_source: source(repo.identity.name), email_source: source(repo.identity.email) }; + return { author: identity, committer: identity, local: { name: null, email: null } }; + }, + repo_set_identity: () => unavailable('Repository identity overrides'), git_global_identity: () => ({ name: repo.identity.name, email: repo.identity.email }), git_set_global_identity: ({ name, email }) => { repo.identity = { name: str(name), email: str(email) }; }, workspace_file_read: () => unavailable('Reading .code-workspace files'), diff --git a/ui/src/lib/tauri.ts b/ui/src/lib/tauri.ts index e7356543..8d4dbb13 100644 --- a/ui/src/lib/tauri.ts +++ b/ui/src/lib/tauri.ts @@ -25,6 +25,7 @@ import type { FileHistoryEntry, FileStatus, GlobalIdentity, + RepositoryIdentity, HostingConnectionStatus, HeroiAgentEvent, HeroiAgentOutcome, @@ -640,6 +641,9 @@ export const tauri = { invoke('repo_open_in_editor', { path, file, line, template }), repoOpenInTerminal: (path: string, template: string) => invoke('repo_open_in_terminal', { path, template }), + repoIdentity: (path: string) => invoke('repo_identity', { path }), + repoSetIdentity: (path: string, field: 'name' | 'email', value: string | null) => + invoke('repo_set_identity', { path, field, value }), gitGlobalIdentity: () => invoke('git_global_identity'), gitSetGlobalIdentity: (name: string, email: string) => invoke('git_set_global_identity', { name, email }), diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index 48e49034..ccd49fcd 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -928,3 +928,20 @@ export type AiGenerationOutcome = coverage: AiInputCoverage; provider: AiProvider; }; + +export interface ScopedValue { + value: string; + scope: string; + origin: string; +} +export interface EffectiveIdentity { + identity: string | null; + error: string | null; + name_source: ScopedValue; + email_source: ScopedValue; +} +export interface RepositoryIdentity { + author: EffectiveIdentity; + committer: EffectiveIdentity; + local: GlobalIdentity; +} diff --git a/ui/src/views/settings/GitSection.tsx b/ui/src/views/settings/GitSection.tsx index 5766de05..63e43ae7 100644 --- a/ui/src/views/settings/GitSection.tsx +++ b/ui/src/views/settings/GitSection.tsx @@ -1,3 +1,5 @@ +import { useRepo } from '../../stores/repo'; +import { RepositoryIdentity } from './RepositoryIdentity'; import { useEffect, useState } from 'react'; import { pickDirectory } from '../../lib/dialog'; @@ -11,6 +13,7 @@ import { useSettings } from '../../stores/settings'; * reads, so no half-typed names should land there live. */ export function GitSection() { + const activePath = useRepo((s) => s.activePath); const defaultCloneDir = useSettings((s) => s.defaultCloneDir); const set = useSettings((s) => s.set); @@ -57,11 +60,11 @@ export function GitSection() { return (
+ {activePath && }
Global identity

- Written to your global git config — used as the author of new commits - everywhere, not just in Strand. + Written to your global git config. Repositories can override these defaults.

{value.scope} · {value.origin}; +} + +export function RepositoryIdentity({ path }: { path: string }) { + const [identity, setIdentity] = useState(null); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + function display(value: Identity) { + setIdentity(value); + setName(value.local.name ?? ''); + setEmail(value.local.email ?? ''); + } + useEffect(() => { + let active = true; + void tauri.repoIdentity(path).then((value) => { if (active) display(value); }) + .catch((e) => { if (active) setError(errMessage(e)); }); + return () => { active = false; }; + }, [path]); + + async function save(field: 'name' | 'email', value: string | null) { + if (busy) return; + setBusy(true); + setError(null); + try { + await tauri.repoSetIdentity(path, field, value); + display(await tauri.repoIdentity(path)); + } catch (e) { setError(errMessage(e)); } + finally { setBusy(false); } + } + + return
+ Repository identity +

{path}

+

Effective identity for new commits. Amend keeps the original author. + Local overrides are shared by this repository’s linked worktrees. Higher-priority + worktree, conditional, or environment values remain effective.

+ {identity ? <> + {(['author', 'committer'] as const).map((role) =>
+ {role === 'author' ? 'Author' : 'Committer'}: {identity[role].identity ?? 'Not configured'} +

Name:
+ Email:

+ {identity[role].error &&

{identity[role].error}

} +
)} + {(['name', 'email'] as const).map((field) =>
+ + + +
)} + :

Loading repository identity…

} + {error &&

{error}

} +
; +} diff --git a/website/docs/settings.md b/website/docs/settings.md index 85a29033..557b0835 100644 --- a/website/docs/settings.md +++ b/website/docs/settings.md @@ -41,6 +41,11 @@ Below the rebindable list, a **Context shortcuts** card documents the fixed, sur ## Git +- **Repository identity** — The active checkout’s effective author and committer, + with the scope and source of each name/email. **Save name/email** and + **Remove name/email override** edit only direct local config. Linked worktrees + share these local values; existing worktree, conditional and environment + precedence remains visible. Amend preserves the original author. - **Global identity** — Name and Email inputs written to your global git config (`~/.gitconfig`) with an explicit **Save identity** button. This is the author identity for new commits everywhere, not just in Strand. - **Default clone & open folder** — a path with **Choose…** and **Clear** buttons. This is where the clone dialog and the open-repository picker start. From 86e2699a5dfffd53b257ca3ecc72594e7c9dc7c0 Mon Sep 17 00:00:00 2001 From: Daniels-Main Date: Sun, 6 Sep 2026 16:55:26 +0200 Subject: [PATCH 3/8] Support sparse worktrees and scoped Git clones --- crates/strand-core/src/apply.rs | 8 + crates/strand-core/src/blame.rs | 43 ++++- crates/strand-core/src/branch.rs | 11 ++ crates/strand-core/src/commit.rs | 2 +- crates/strand-core/src/diff.rs | 81 +++++++++ crates/strand-core/src/file.rs | 12 +- crates/strand-core/src/history.rs | 1 + crates/strand-core/src/lib.rs | 1 + crates/strand-core/src/network.rs | 151 +++++++++++++++- crates/strand-core/src/repo.rs | 24 +++ crates/strand-core/src/reset.rs | 13 +- crates/strand-core/src/snapshot.rs | 2 +- crates/strand-core/src/sparse.rs | 159 +++++++++++++++++ crates/strand-core/src/stage.rs | 20 +++ crates/strand-core/src/status.rs | 25 +++ crates/strand-core/src/tree.rs | 26 ++- crates/strand-core/src/watch.rs | 4 +- crates/strand-core/tests/clone_recursive.rs | 38 +++++ crates/strand-core/tests/clone_scope.rs | 140 +++++++++++++++ crates/strand-core/tests/sparse_checkout.rs | 180 ++++++++++++++++++++ crates/strand-tauri/src/commands.rs | 42 ++++- crates/strand-tauri/src/main.rs | 5 + 22 files changed, 954 insertions(+), 34 deletions(-) create mode 100644 crates/strand-core/src/sparse.rs create mode 100644 crates/strand-core/tests/clone_recursive.rs create mode 100644 crates/strand-core/tests/clone_scope.rs create mode 100644 crates/strand-core/tests/sparse_checkout.rs diff --git a/crates/strand-core/src/apply.rs b/crates/strand-core/src/apply.rs index f347a294..fbfd8cee 100644 --- a/crates/strand-core/src/apply.rs +++ b/crates/strand-core/src/apply.rs @@ -28,6 +28,14 @@ impl Repo { /// already have. Reverse targets flip the patch via [`reverse_patch`] /// before applying. pub fn apply_patch(&self, patch: &str, target: ApplyTarget) -> Result<()> { + if self.sparse_enabled() { + let mut args = vec!["apply", "--whitespace=nowarn"]; + if matches!(target, ApplyTarget::Index | ApplyTarget::IndexReverse) { args.push("--cached"); } + if matches!(target, ApplyTarget::IndexReverse | ApplyTarget::WorkdirReverse) { args.push("--reverse"); } + args.push("-"); + self.sparse_git(&args, Some(patch.as_bytes()))?; + return Ok(()); + } let repo = self.git2()?; let (buf, location) = match target { ApplyTarget::Index => (patch.to_owned(), git2::ApplyLocation::Index), diff --git a/crates/strand-core/src/blame.rs b/crates/strand-core/src/blame.rs index 734f70f2..4af41573 100644 --- a/crates/strand-core/src/blame.rs +++ b/crates/strand-core/src/blame.rs @@ -57,9 +57,7 @@ impl Repo { let entry = tree .get_path(Path::new(rel_path)) .map_err(|_| Error::Other(format!("{rel_path} is not tracked at HEAD")))?; - let blob = repo - .find_blob(entry.id()) - .map_err(|_| Error::Other(format!("{rel_path} is not a file")))?; + let blob = self.find_blob(entry.id())?; if blob.is_binary() { return Err(Error::Other(format!("{rel_path} is binary — no blame"))); } @@ -72,6 +70,9 @@ impl Repo { ))); } + if self.is_partial_clone() || repo.is_shallow() { + return self.blame_with_git(rel_path); + } let mut opts = git2::BlameOptions::new(); let blame = repo.blame_file(Path::new(rel_path), Some(&mut opts))?; @@ -123,6 +124,42 @@ impl Repo { } Ok(out) } + + /// Git understands shallow boundaries and can fetch promised blobs while + /// walking history. Keep that work on demand, after the size/binary gate. + fn blame_with_git(&self, path: &str) -> Result> { + let output = crate::git_command().current_dir(&self.path) + .env("GIT_TERMINAL_PROMPT", "0").args(crate::GIT_SAFE_CONFIG) + .args(["blame", "--line-porcelain", "HEAD", "--", path]).output()?; + if !output.status.success() { + return Err(Error::Other(String::from_utf8_lossy(&output.stderr).trim().into())); + } + let mut result = Vec::new(); + let mut current: Option = None; + for line in String::from_utf8_lossy(&output.stdout).lines() { + if let Some(content) = line.strip_prefix('\t') { + if let Some(mut entry) = current.take() { + entry.content = content.to_owned(); + result.push(entry); + } + continue; + } + let fields: Vec<_> = line.split(' ').collect(); + if fields.len() >= 3 && fields[0].len() == 40 && fields[0].bytes().all(|b| b.is_ascii_hexdigit()) { + current = Some(BlameLine { + line_no: fields[2].parse().map_err(|_| Error::Other("Invalid Git blame line".into()))?, + commit: fields[0].into(), short: fields[0][..7].into(), content: String::new(), + author: String::new(), author_email: String::new(), time_unix: 0, summary: String::new(), + }); + } else if let Some(entry) = &mut current { + if let Some(value) = line.strip_prefix("author ") { entry.author = value.into(); } + else if let Some(value) = line.strip_prefix("author-mail ") { entry.author_email = value.trim_start_matches('<').trim_end_matches('>').into(); } + else if let Some(value) = line.strip_prefix("author-time ") { entry.time_unix = value.parse().map_err(|_| Error::Other("Invalid Git blame time".into()))?; } + else if let Some(value) = line.strip_prefix("summary ") { entry.summary = value.into(); } + } + } + Ok(result) + } } #[cfg(test)] diff --git a/crates/strand-core/src/branch.rs b/crates/strand-core/src/branch.rs index 4c4fe7ee..f9a720ea 100644 --- a/crates/strand-core/src/branch.rs +++ b/crates/strand-core/src/branch.rs @@ -30,6 +30,11 @@ impl Repo { /// check goes through the worktree registry; a registry read failure /// falls through (the rollback below still protects the repo). pub fn checkout_branch(&self, name: &str) -> Result { + if self.sparse_enabled() || self.is_partial_clone() { + self.git2()?.find_branch(name, git2::BranchType::Local)?; + crate::network::run_git_streaming(&self.path, &["switch", "--", name], |_| {}, None)?; + return Ok(CheckoutOutcome { branch: name.into() }); + } if let Some(wt) = self .worktrees() .unwrap_or_default() @@ -152,6 +157,12 @@ impl Repo { let repo = self.git2()?; let commit = repo.revparse_single(rev)?.peel_to_commit()?; + if self.sparse_enabled() || self.is_partial_clone() { + let oid = commit.id().to_string(); + crate::network::run_git_streaming(&self.path, &["switch", "--detach", &oid], |_| {}, None)?; + return Ok(CheckoutOutcome { branch: oid[..7].into() }); + } + let tree = commit.tree()?; let mut opts = git2::build::CheckoutBuilder::new(); opts.safe(); diff --git a/crates/strand-core/src/commit.rs b/crates/strand-core/src/commit.rs index d99081c7..cd6f3943 100644 --- a/crates/strand-core/src/commit.rs +++ b/crates/strand-core/src/commit.rs @@ -41,7 +41,7 @@ impl Repo { None => format!("{}\n", subject.trim()), }; - let oid = if signing_enabled(repo) { + let oid = if signing_enabled(repo) || self.sparse_enabled() { self.commit_via_git(&message, amend)?; repo.head()?.peel_to_commit()?.id() } else { diff --git a/crates/strand-core/src/diff.rs b/crates/strand-core/src/diff.rs index 36ef83e1..68b97579 100644 --- a/crates/strand-core/src/diff.rs +++ b/crates/strand-core/src/diff.rs @@ -42,6 +42,22 @@ pub struct DiffPath { impl Repo { pub fn diff_unstaged_paths(&self) -> Result> { + if self.sparse_enabled() { + let bytes = self.sparse_git(&["diff", "--name-status", "-z", "--find-renames", "--no-ext-diff", "--no-textconv", "--"], None)?; + let mut fields = bytes.split(|b| *b == 0).filter(|row| !row.is_empty()); + let mut paths = Vec::new(); + while let Some(status) = fields.next() { + let Some(path) = fields.next() else { break; }; + let path = String::from_utf8_lossy(path).into_owned(); + let (path, old_path) = if status.starts_with(b"R") || status.starts_with(b"C") { + let Some(new) = fields.next() else { break; }; + (String::from_utf8_lossy(new).into_owned(), Some(path)) + } else { (path, None) }; + paths.push(DiffPath { path, old_path }); + } + paths.extend(self.status()?.into_iter().filter(|s| s.kind == crate::status::StatusKind::Untracked).map(|s| DiffPath { path: s.path, old_path: None })); + return Ok(paths); + } let repo = self.git2()?; let mut diff = repo.diff_index_to_workdir(None, Some(&mut diff_options()))?; let mut find = git2::DiffFindOptions::new(); @@ -57,6 +73,7 @@ impl Repo { /// Working tree vs index — the "unstaged" diff shown above the /// commit-form in Local Changes. pub fn diff_unstaged(&self) -> Result> { + if self.sparse_enabled() { return self.sparse_workdir_diff(false, None, 3, None); } let repo = self.git2()?; let mut opts = diff_options(); let diff = repo.diff_index_to_workdir(None, Some(&mut opts))?; @@ -65,6 +82,7 @@ impl Repo { /// Index vs HEAD — what `git diff --cached` would show. pub fn diff_staged(&self) -> Result> { + if self.sparse_enabled() { return self.sparse_workdir_diff(true, None, 3, None); } let repo = self.git2()?; let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok()); let mut opts = diff_options(); @@ -76,6 +94,11 @@ impl Repo { /// and the file view's Compare tab. pub fn diff_between(&self, from: &str, to: &str) -> Result> { let repo = self.git2()?; + if self.is_partial_clone() { + let from = repo.revparse_single(from)?.peel_to_commit()?.id().to_string(); + let to = repo.revparse_single(to)?.peel_to_commit()?.id().to_string(); + return self.git_revision_diff(&[&from, &to, "--"], false); + } let from_tree = repo.revparse_single(from)?.peel_to_commit()?.tree()?; let to_tree = repo.revparse_single(to)?.peel_to_commit()?.tree()?; let mut opts = diff_options(); @@ -89,6 +112,10 @@ impl Repo { /// added. pub fn diff_commit(&self, oid: &str) -> Result> { let repo = self.git2()?; + if self.is_partial_clone() { + let oid = repo.revparse_single(oid)?.peel_to_commit()?.id().to_string(); + return self.git_revision_diff(&[&oid, "--"], true); + } let to_oid = repo.revparse_single(oid)?.id(); let to_commit = repo.find_commit(to_oid)?; let to_tree = to_commit.tree()?; @@ -110,6 +137,10 @@ impl Repo { /// it existed under a different name before a rename). pub fn diff_commit_file(&self, oid: &str, path: &str) -> Result> { let repo = self.git2()?; + if self.is_partial_clone() { + let oid = repo.revparse_single(oid)?.peel_to_commit()?.id().to_string(); + return self.git_revision_diff(&[&oid, "--", path], true); + } let to_commit = repo.revparse_single(oid)?.peel_to_commit()?; let to_tree = to_commit.tree()?; let from_tree = if to_commit.parent_count() == 0 { @@ -129,6 +160,7 @@ impl Repo { /// showing work the agent already staged or committed away from the /// baseline, so the reviewer sees the whole session in one diff. pub fn diff_since(&self, baseline: &str) -> Result> { + if self.sparse_enabled() || self.is_partial_clone() { return self.sparse_workdir_diff(false, Some(baseline), 3, None); } let repo = self.git2()?; let tree = repo.revparse_single(baseline)?.peel_to_commit()?.tree()?; let mut opts = diff_options(); @@ -140,6 +172,7 @@ impl Repo { /// carries the entire file, not just hunks. Powers the Review view, which /// shows an agent's edits in the context of the full file. pub fn diff_unstaged_full(&self) -> Result> { + if self.sparse_enabled() { return self.sparse_workdir_diff(false, None, WHOLE_FILE_CONTEXT, None); } let repo = self.git2()?; let mut opts = diff_options_with(WHOLE_FILE_CONTEXT); let diff = repo.diff_index_to_workdir(None, Some(&mut opts))?; @@ -148,6 +181,7 @@ impl Repo { /// `diff_since` with whole-file context — see `diff_unstaged_full`. pub fn diff_since_full(&self, baseline: &str) -> Result> { + if self.sparse_enabled() || self.is_partial_clone() { return self.sparse_workdir_diff(false, Some(baseline), WHOLE_FILE_CONTEXT, None); } let repo = self.git2()?; let tree = repo.revparse_single(baseline)?.peel_to_commit()?.tree()?; let mut opts = diff_options_with(WHOLE_FILE_CONTEXT); @@ -161,6 +195,9 @@ impl Repo { /// directly to the workdir (ignoring the index), so a half-staged file still /// shows its full on-disk delta; untracked files appear as additions. pub fn diff_workdir_file(&self, path: &str) -> Result> { + if self.sparse_enabled() { + return self.sparse_workdir_diff(false, Some("HEAD"), 3, Some(path)); + } let repo = self.git2()?; let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok()); let mut opts = diff_options(); @@ -168,6 +205,46 @@ impl Repo { let diff = repo.diff_tree_to_workdir(head_tree.as_ref(), Some(&mut opts))?; collect(diff) } + + fn sparse_workdir_diff(&self, staged: bool, baseline: Option<&str>, context: u32, path: Option<&str>) -> Result> { + let context_arg = format!("--unified={context}"); + let oid = baseline.map(|rev| self.git2()?.revparse_single(rev)?.peel_to_commit().map(|c| c.id().to_string()).map_err(crate::Error::from)).transpose()?; + let mut args = vec!["--literal-pathspecs", "diff", "--no-ext-diff", "--no-textconv", "--no-color", "--binary", "--no-relative", "--src-prefix=a/", "--dst-prefix=b/", "--find-renames", &context_arg]; + if staged { args.push("--cached"); } + if let Some(oid) = &oid { args.push(oid); } + args.push("--"); + if let Some(path) = path { args.push(path); } + let bytes = self.sparse_git(&args, None)?; + let mut files = if bytes.is_empty() { Vec::new() } else { collect_ready(git2::Diff::from_buffer(&bytes)?)? }; + if !staged { + let untracked: Vec<_> = self.status()?.into_iter().filter(|s| s.kind == crate::status::StatusKind::Untracked && path.is_none_or(|path| s.path == path)).collect(); + if !untracked.is_empty() { + // One path-limited libgit2 walk for all untracked contents; + // never spawn a Git process per untracked file. + let mut opts = diff_options_with(context); + opts.disable_pathspec_match(true); + for entry in untracked { opts.pathspec(entry.path); } + files.extend(collect(self.git2()?.diff_index_to_workdir(None, Some(&mut opts))?)?); + } + } + Ok(files) + } + + fn git_revision_diff(&self, revisions: &[&str], commit: bool) -> Result> { + let mut command = crate::git_command(); + command.current_dir(&self.path) + .env("GIT_TERMINAL_PROMPT", "0") + .args(crate::GIT_SAFE_CONFIG) + .arg("--literal-pathspecs"); + if commit { command.args(["show", "--format=", "--first-parent"]); } else { command.arg("diff"); } + let output = command.args(["--no-ext-diff", "--no-textconv", "--no-color", "--binary", "--no-relative", "--src-prefix=a/", "--dst-prefix=b/", "--find-renames"]) + .args(revisions).output()?; + if !output.status.success() { + return Err(crate::Error::Other(String::from_utf8_lossy(&output.stderr).trim().into())); + } + if output.stdout.is_empty() { return Ok(Vec::new()); } + collect_ready(git2::Diff::from_buffer(&output.stdout)?) + } } /// "Whole file" context: big enough that one hunk swallows any real file, @@ -199,6 +276,10 @@ fn collect(mut diff: git2::Diff<'_>) -> Result> { let mut find = git2::DiffFindOptions::new(); find.renames(true).copies(true); diff.find_similar(Some(&mut find))?; + collect_ready(diff) +} + +fn collect_ready(diff: git2::Diff<'_>) -> Result> { // Pre-populate one FileDiff per delta so the print callback can index // into us by delta_idx. diff --git a/crates/strand-core/src/file.rs b/crates/strand-core/src/file.rs index 294f275e..6d95ee1c 100644 --- a/crates/strand-core/src/file.rs +++ b/crates/strand-core/src/file.rs @@ -87,9 +87,7 @@ impl Repo { let entry = tree.get_path(Path::new(rel_path)).map_err(|_| { Error::Other(format!("{rel_path} does not exist at {spec}")) })?; - let blob = repo - .find_blob(entry.id()) - .map_err(|_| Error::Other(format!("{rel_path} is not a file at {spec}")))?; + let blob = self.find_blob(entry.id())?; Ok(build_content(rel_path, blob.content(), blob.is_binary())) } } @@ -161,9 +159,7 @@ impl Repo { .index()? .get_path(Path::new(rel_path), 0) .ok_or_else(|| Error::Other(format!("{rel_path} is not in the index")))?; - let blob = repo - .find_blob(entry.id) - .map_err(|_| Error::Other(format!("{rel_path} is not a file in the index")))?; + let blob = self.find_blob(entry.id)?; Ok(build_blob(blob.content())) } BlobSource::Rev(spec) => { @@ -172,9 +168,7 @@ impl Repo { let entry = tree.get_path(Path::new(rel_path)).map_err(|_| { Error::Other(format!("{rel_path} does not exist at {spec}")) })?; - let blob = repo - .find_blob(entry.id()) - .map_err(|_| Error::Other(format!("{rel_path} is not a file at {spec}")))?; + let blob = self.find_blob(entry.id())?; Ok(build_blob(blob.content())) } } diff --git a/crates/strand-core/src/history.rs b/crates/strand-core/src/history.rs index 7b3a02ed..1d0ae780 100644 --- a/crates/strand-core/src/history.rs +++ b/crates/strand-core/src/history.rs @@ -415,6 +415,7 @@ impl Repo { /// Whether the index currently holds unmerged (conflicted) entries. fn has_conflicts(&self) -> Result { + if self.sparse_enabled() { return Ok(!self.sparse_git(&["ls-files", "--unmerged", "-z"], None)?.is_empty()); } Ok(self.git2()?.index()?.has_conflicts()) } diff --git a/crates/strand-core/src/lib.rs b/crates/strand-core/src/lib.rs index e2dc385a..33b101d9 100644 --- a/crates/strand-core/src/lib.rs +++ b/crates/strand-core/src/lib.rs @@ -45,6 +45,7 @@ pub mod reflog; pub mod rename; pub mod reset; pub mod snapshot; +pub mod sparse; pub mod watch; pub use error::{Error, Result}; diff --git a/crates/strand-core/src/network.rs b/crates/strand-core/src/network.rs index dbd5eb8b..e6f4ceba 100644 --- a/crates/strand-core/src/network.rs +++ b/crates/strand-core/src/network.rs @@ -43,8 +43,14 @@ impl CancelHandle { pub fn cancel(&self) { let mut inner = self.0.lock().expect("cancel handle lock"); inner.cancelled = true; - if let Some(child) = inner.child.as_mut() { - let _ = child.kill(); + if inner.child.is_some() { + let handle = self.clone(); + // Recursive clones have child Git/SSH processes holding the pipes. + // 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); } + }); } } @@ -77,6 +83,61 @@ pub struct CloneOutcome { pub output: String, } +fn kill_git_tree(child: &mut std::process::Child) { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + if let Some(root) = std::env::var_os("SystemRoot") { + let _ = std::process::Command::new(Path::new(&root).join("System32/taskkill.exe")) + .creation_flags(0x0800_0000).args(["/PID", &child.id().to_string(), "/T", "/F"]) + .stdout(Stdio::null()).stderr(Stdio::null()).status(); + } + } + #[cfg(unix)] + { + let _ = std::process::Command::new("/bin/kill") + .args(["-TERM", "--", &format!("-{}", child.id())]) + .stdout(Stdio::null()).stderr(Stdio::null()).status(); + } + let _ = child.kill(); +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum CloneFilter { + BlobNone, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct CloneOptions { + pub branch: Option, + pub depth: Option, + pub single_branch: bool, + pub filter: Option, + pub recurse_submodules: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CloneScope { + pub shallow: bool, + pub remotes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CloneRemote { + pub name: String, + pub filter: Option, + pub fetch_refspecs: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum HistoryExpansion { + Deepen { commits: u32 }, + Unshallow, +} + /// One progress update parsed from `git`'s stderr while a network op runs. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Progress { @@ -116,6 +177,42 @@ pub enum PushMode { } impl Repo { + /// On-demand inspection, including repositories cloned outside Strand. + pub fn clone_scope(&self) -> Result { + let repo = self.git2()?; + let config = repo.config()?; + let mut remotes = Vec::new(); + for name in repo.remotes()?.iter().flatten() { + let remote = repo.find_remote(name)?; + remotes.push(CloneRemote { + name: name.to_owned(), + filter: config.get_string(&format!("remote.{name}.partialclonefilter")).ok(), + fetch_refspecs: remote.fetch_refspecs()?.iter().flatten().map(str::to_owned).collect(), + }); + } + Ok(CloneScope { shallow: repo.is_shallow(), remotes }) + } + + /// Fetch more ancestry without changing HEAD, the index or local edits. + pub fn expand_history( + &self, + remote: &str, + expansion: HistoryExpansion, + on_progress: impl FnMut(Progress), + cancel: Option<&CancelHandle>, + ) -> Result { + self.ensure_remote(remote)?; + if !self.git2()?.is_shallow() { + return Err(Error::Other("This repository already has complete history.".into())); + } + let option = match expansion { + HistoryExpansion::Deepen { commits: 0 } => return Err(Error::Other("Depth must be greater than zero.".into())), + HistoryExpansion::Deepen { commits } => format!("--deepen={commits}"), + HistoryExpansion::Unshallow => "--unshallow".into(), + }; + run_git_streaming(&self.path, &["fetch", "--progress", &option, "--", remote], on_progress, cancel) + } + /// Fetch provider-reported branch tips for a read-only comparison without /// updating FETCH_HEAD or any local/remote-tracking ref. Hosted PR views /// use this when a provider exposes commit IDs but not a unified patch. @@ -501,6 +598,16 @@ pub fn clone( dest: &str, on_progress: impl FnMut(Progress), cancel: Option<&CancelHandle>, +) -> Result { + clone_with_options(url, dest, &CloneOptions::default(), on_progress, cancel) +} + +pub fn clone_with_options( + url: &str, + dest: &str, + options: &CloneOptions, + on_progress: impl FnMut(Progress), + cancel: Option<&CancelHandle>, ) -> Result { // The URL is pasted by the user. Make sure git can't read it as an option // (`--upload-pack=…`, `-c …`) or as a command-executing transport @@ -513,7 +620,8 @@ pub fn clone( // Run from the destination's parent so a relative `dest` still lands in // the right place; an absolute `dest` ignores the cwd anyway. let cwd = dest_path.parent().filter(|p| !p.as_os_str().is_empty()); - let args = ["clone", "--progress", "--", url, dest]; + let owned_args = clone_args(url, dest, options)?; + let args = owned_args.iter().map(String::as_str).collect::>(); let outcome = match cwd { Some(parent) => run_git_streaming(parent, &args, on_progress, cancel), None => run_git_streaming(Path::new("."), &args, on_progress, cancel), @@ -524,6 +632,31 @@ pub fn clone( }) } +fn clone_args(url: &str, dest: &str, options: &CloneOptions) -> Result> { + validate_remote_arg(url, "clone URL")?; + let mut args = vec!["clone".into(), "--progress".into()]; + if let Some(branch) = &options.branch { + validate_branch_ref(branch, "clone branch")?; + args.push(format!("--branch={branch}")); + } + if let Some(depth) = options.depth { + if depth == 0 { + return Err(Error::Other("Depth must be greater than zero.".into())); + } + args.push(format!("--depth={depth}")); + } + // Git implies single-branch for --depth. Make the independent UI choice explicit. + args.push(if options.single_branch { "--single-branch" } else { "--no-single-branch" }.into()); + if let Some(CloneFilter::BlobNone) = options.filter { + args.push("--filter=blob:none".into()); + } + if options.recurse_submodules { + args.push("--recurse-submodules".into()); + } + args.extend(["--".into(), url.into(), dest.into()]); + Ok(args) +} + /// Reject a user-supplied remote/URL that git would mis-read as an option or /// a command-executing transport. Paired with an explicit `--` separator at /// the call site, this closes the "paste a malicious clone URL" vector. @@ -593,8 +726,14 @@ pub(crate) fn run_git_streaming_transcript( mut on_progress: impl FnMut(Progress), cancel: Option<&CancelHandle>, ) -> Result { - let mut child = crate::git_command() - .current_dir(cwd) + if cancel.is_some_and(CancelHandle::is_cancelled) { return Err(Error::Cancelled); } + let mut command = crate::git_command(); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + let mut child = 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) @@ -623,7 +762,7 @@ pub(crate) fn run_git_streaming_transcript( { let mut inner = handle.0.lock().expect("cancel handle lock"); if inner.cancelled { - let _ = child.kill(); + kill_git_tree(&mut child); let _ = child.wait(); return Err(Error::Cancelled); } diff --git a/crates/strand-core/src/repo.rs b/crates/strand-core/src/repo.rs index 19085402..a7e1fc0b 100644 --- a/crates/strand-core/src/repo.rs +++ b/crates/strand-core/src/repo.rs @@ -151,6 +151,9 @@ impl Repo { // check-then-set can't race. if self.git2.get().is_none() { let opened = git2::Repository::open(&self.path)?; + if self.sparse_enabled() && opened.index().is_err() { + self.sparse_read_index(&opened)?; + } let _ = self.git2.set(opened); } Ok(self.git2.get().expect("git2 handle set above")) @@ -163,6 +166,27 @@ impl Repo { Ok(git2::Repository::open(&self.path)?) } + pub(crate) fn is_partial_clone(&self) -> bool { + self.gix.config_snapshot().sections_by_name("remote").is_some_and(|mut sections| { + sections.any(|section| section.value("promisor").is_some_and(|value| value.eq_ignore_ascii_case(b"true"))) + }) + } + + /// libgit2 does not fetch promised objects. Ask Git for this exact object + /// only after a missing-object read in a partial clone, then retry locally. + pub(crate) fn find_blob(&self, oid: git2::Oid) -> Result> { + let repo = self.git2()?; + match repo.find_blob(oid) { + Ok(blob) => Ok(blob), + Err(error) if error.code() == git2::ErrorCode::NotFound && self.is_partial_clone() => { + crate::network::run_git_streaming(&self.path, &["cat-file", "-e", &oid.to_string()], |_| {}, None)?; + repo.odb()?.refresh()?; + Ok(repo.find_blob(oid)?) + } + Err(error) => Err(error.into()), + } + } + /// Resolve `rel_path` against the working directory, rejecting absolute /// paths, `..` traversal, and in-tree symlinks that escape the working /// tree. Mirrors the guard in [`conflict`](crate::conflict); used by the diff --git a/crates/strand-core/src/reset.rs b/crates/strand-core/src/reset.rs index 3a56c940..4eb1b846 100644 --- a/crates/strand-core/src/reset.rs +++ b/crates/strand-core/src/reset.rs @@ -64,19 +64,24 @@ impl Repo { // push+apply round-trip that can fail on Windows file locks). let mut snapshot_oid = None; if matches!(mode, ResetMode::Hard) { - let dirty = repo + let dirty = if self.sparse_enabled() { + self.status()?.iter().any(|entry| entry.kind != crate::status::StatusKind::Untracked) + } else { repo .statuses(Some(&mut crate::status::status_options()))? .iter() .any(|e| { !(e.status() & !(git2::Status::WT_NEW | git2::Status::IGNORED)).is_empty() - }); + }) }; if dirty { let msg = format!("Safety: before hard reset to {target_short}"); snapshot_oid = self.stash_snapshot(Some(&msg), false)?.oid; } } - match mode { + if self.sparse_enabled() || self.is_partial_clone() { + let flag = match mode { ResetMode::Soft => "--soft", ResetMode::Mixed => "--mixed", ResetMode::Hard => "--hard" }; + crate::network::run_git_streaming(&self.path, &["reset", flag, &obj.id().to_string(), "--"], |_| {}, None)?; + } else { match mode { ResetMode::Soft => repo.reset(&obj, git2::ResetType::Soft, None)?, ResetMode::Mixed => repo.reset(&obj, git2::ResetType::Mixed, None)?, ResetMode::Hard => { @@ -84,7 +89,7 @@ impl Repo { co.force(); repo.reset(&obj, git2::ResetType::Hard, Some(&mut co))?; } - } + } } Ok(ResetOutcome { target_short, diff --git a/crates/strand-core/src/snapshot.rs b/crates/strand-core/src/snapshot.rs index b82280fe..d22ae4d8 100644 --- a/crates/strand-core/src/snapshot.rs +++ b/crates/strand-core/src/snapshot.rs @@ -35,7 +35,7 @@ impl Repo { pub fn snapshot(&self) -> Result { let repo = self.git2()?; let statuses = repo.statuses(Some(&mut crate::status::status_options()))?; - let status = crate::status::from_statuses(&statuses); + let status = if self.sparse_enabled() { self.status()? } else { crate::status::from_statuses(&statuses) }; let work_tree = crate::tree::from_index_and_statuses(repo, &statuses)?; drop(statuses); diff --git a/crates/strand-core/src/sparse.rs b/crates/strand-core/src/sparse.rs new file mode 100644 index 00000000..14ffb47a --- /dev/null +++ b/crates/strand-core/src/sparse.rs @@ -0,0 +1,159 @@ +//! Cone checkout management and an in-memory read bridge for libgit2 1.8, +//! which cannot read Git's mandatory sparse-directory index extension. +use std::{collections::BTreeSet, io::Write, process::Stdio}; +use serde::{Deserialize, Serialize}; +use crate::{Error, Repo, Result}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SparseCheckout { + pub enabled: bool, + pub cone: bool, + pub sparse_index: bool, + pub directories: Vec, + pub available: Vec, + pub patterns: String, +} + +impl Repo { + pub(crate) fn sparse_enabled(&self) -> bool { + self.gix.config_snapshot().boolean("core.sparseCheckout").unwrap_or(false) + } + + pub fn sparse_checkout(&self) -> Result { + let enabled = self.sparse_enabled(); + let config = self.gix.config_snapshot(); + let cone = config.boolean("core.sparseCheckoutCone").unwrap_or(false); + let sparse_index = config.boolean("index.sparse").unwrap_or(false); + let patterns = if enabled { + std::fs::read_to_string(self.git_dir().join("info/sparse-checkout"))? + } else { String::new() }; + let directories = if enabled && cone { + // Read the literal cone rules rather than Git's quoted display output. + patterns.lines().filter_map(|line| { + if line.starts_with('/') && line.ends_with('/') && line != "/*" { + Some(unescape_cone(&line[1..line.len() - 1])) + } else { None } + }).filter(|dir| !patterns.lines().any(|line| line == format!("!/{dir}/*/", dir = escape_cone(dir)))).collect() + } else { Vec::new() }; + let available = self.sparse_git(&["ls-tree", "-d", "-r", "--name-only", "-z", "HEAD"], None)?; + Ok(SparseCheckout { enabled, cone, sparse_index, directories, available: nul_paths(&available)?, patterns }) + } + + pub fn set_sparse_checkout(&self, directories: &[String], sparse_index: bool) -> Result { + let state = self.sparse_checkout()?; + if state.enabled && !state.cone { + return Err(Error::Other("This checkout uses non-cone patterns. Disable it before selecting cone directories.".into())); + } + let available: BTreeSet<_> = state.available.iter().chain(state.directories.iter()).collect(); + for directory in directories { + if directory.is_empty() || directory.contains(['\\', '\n', '\r', '\0']) + || directory.split('/').any(|part| part.is_empty() || part == "." || part == ".." || part.eq_ignore_ascii_case(".git")) + || !available.contains(directory) + { + return Err(Error::Other(format!("Not a tracked repository directory: {directory}"))); + } + } + self.ensure_sparse_change_clean(Some(directories))?; + // --stdin avoids Windows argv limits and Git option/pathspec interpretation. + let input = directories.iter().map(|dir| format!("\"{}\"\n", dir.replace('"', "\\\""))).collect::(); + let output = self.sparse_git(&["sparse-checkout", "set", "--cone", if sparse_index { "--sparse-index" } else { "--no-sparse-index" }, "--stdin"], Some(input.as_bytes()))?; + Ok(String::from_utf8_lossy(&output).trim().into()) + } + + pub fn disable_sparse_checkout(&self) -> Result { + self.ensure_sparse_change_clean(None)?; + let output = self.sparse_git(&["sparse-checkout", "disable"], None)?; + Ok(String::from_utf8_lossy(&output).trim().into()) + } + + fn ensure_sparse_change_clean(&self, directories: Option<&[String]>) -> Result<()> { + if self.operation_in_progress().is_some() { + return Err(Error::Other("Finish the current Git operation before changing sparse checkout.".into())); + } + let output = self.sparse_git(&["status", "--porcelain=v1", "-z", "--ignored=matching", "--untracked-files=normal"], None)?; + let records = nul_paths(&output)?; + for record in records { + if let Some(path) = record.strip_prefix("!! ") { + let probe = if path.ends_with('/') { format!("{path}__ignored__") } else { path.to_owned() }; + if directories.is_some_and(|dirs| !cone_includes(&probe, dirs)) { + return Err(Error::Other(format!("Ignored files in {path} could be removed by Git. Include that directory or move those files before changing sparse checkout."))); + } + } else { + return Err(Error::Other("Commit or stash local changes and move untracked files before changing sparse checkout. Your files and index have been preserved.".into())); + } + } + // The read-only guard leaves index stat data untouched. Refresh only + // after it passes: otherwise Git can retain a restored clean file as + // "not up to date" when removing its directory from the cone. + self.sparse_git(&["update-index", "--refresh"], None)?; + Ok(()) + } + + pub(crate) fn sparse_git(&self, args: &[&str], input: Option<&[u8]>) -> Result> { + let mut child = crate::git_command().current_dir(&self.path) + .env("GIT_TERMINAL_PROMPT", "0").env("GIT_OPTIONAL_LOCKS", "0") + .args(crate::GIT_SAFE_CONFIG).args(args) + .stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() }) + .stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?; + if let Some(input) = input { + // Write concurrently so an early Git error cannot deadlock a large selection. + let mut stdin = child.stdin.take().expect("piped stdin"); + let input = input.to_vec(); + std::thread::spawn(move || { let _ = stdin.write_all(&input); }); + } + let output = child.wait_with_output()?; + if !output.status.success() { + return Err(Error::Other(String::from_utf8_lossy(&output.stderr).trim().into())); + } + let mut bytes = output.stdout; + // Successful sparse changes can still warn about retained files. + if args.first() == Some(&"sparse-checkout") { bytes.extend_from_slice(&output.stderr); } + Ok(bytes) + } + + /// Attach an expanded *memory-only* index for readers. Never rewrite the + /// user's sparse index merely by opening/refreshing a repository. + pub(crate) fn sparse_read_index(&self, repo: &git2::Repository) -> Result<()> { + let output = self.sparse_git(&["ls-files", "--stage", "-t", "-z"], None)?; + let mut index = git2::Index::new()?; + for record in output.split(|byte| *byte == 0).filter(|row| !row.is_empty()) { + let tab = record.iter().position(|byte| *byte == b'\t').ok_or_else(|| Error::Other("Invalid Git index listing".into()))?; + let header = std::str::from_utf8(&record[..tab]).map_err(|e| Error::Other(e.to_string()))?; + let fields: Vec<_> = header.split(' ').collect(); + if fields.len() != 4 { return Err(Error::Other("Invalid Git index entry".into())); } + let mode = u32::from_str_radix(fields[1], 8).map_err(|e| Error::Other(e.to_string()))?; + let stage = fields[3].parse::().map_err(|e| Error::Other(e.to_string()))?; + index.add(&git2::IndexEntry { + ctime: git2::IndexTime::new(0, 0), mtime: git2::IndexTime::new(0, 0), + dev: 0, ino: 0, mode, uid: 0, gid: 0, file_size: 0, + id: git2::Oid::from_str(fields[2])?, flags: stage << 12, + flags_extended: if fields[0] == "S" { 1 << 14 } else { 0 }, + path: record[tab + 1..].to_vec(), + })?; + } + repo.set_index(&mut index)?; + Ok(()) + } +} + +pub(crate) fn cone_includes(path: &str, directories: &[String]) -> bool { + !path.contains('/') || directories.iter().any(|dir| { + path == dir || path.starts_with(&format!("{dir}/")) + || path.rsplit_once('/').is_some_and(|(parent, _)| dir.starts_with(&format!("{parent}/"))) + }) +} + +fn nul_paths(bytes: &[u8]) -> Result> { + bytes.split(|byte| *byte == 0).filter(|row| !row.is_empty()) + .map(|row| String::from_utf8(row.to_vec()).map_err(|_| Error::Other("Sparse directory names must be UTF-8.".into()))).collect() +} + +fn unescape_cone(value: &str) -> String { + let mut chars = value.chars(); + let mut result = String::new(); + while let Some(c) = chars.next() { result.push(if c == '\\' { chars.next().unwrap_or(c) } else { c }); } + result +} +fn escape_cone(value: &str) -> String { + value.chars().flat_map(|c| if matches!(c, '*' | '?' | '[' | ']' | '\\') { vec!['\\', c] } else { vec![c] }).collect() +} diff --git a/crates/strand-core/src/stage.rs b/crates/strand-core/src/stage.rs index d1c1b7b7..f6383d42 100644 --- a/crates/strand-core/src/stage.rs +++ b/crates/strand-core/src/stage.rs @@ -6,6 +6,7 @@ impl Repo { /// Stage `path` — adds new/modified files, records deletions. Mirrors /// `git add ` for one path at a time. pub fn stage_path(&self, path: &str) -> Result<()> { + if self.sparse_enabled() { return self.stage_paths(&[path.into()]); } let repo = self.git2()?; let mut index = repo.index()?; @@ -30,6 +31,12 @@ impl Repo { if paths.is_empty() { return Ok(()); } + if self.sparse_enabled() { + let mut args = vec!["--literal-pathspecs", "add", "--"]; + args.extend(paths.iter().map(String::as_str)); + self.sparse_git(&args, None)?; + return Ok(()); + } let repo = self.git2()?; let mut index = repo.index()?; let workdir = repo.workdir().map(Path::to_path_buf); @@ -53,6 +60,12 @@ impl Repo { if paths.is_empty() { return Ok(()); } + if self.sparse_enabled() { + let mut args = vec!["--literal-pathspecs", "restore", "--staged", "--"]; + args.extend(paths.iter().map(String::as_str)); + self.sparse_git(&args, None)?; + return Ok(()); + } let repo = self.git2()?; match repo.head().ok().map(|h| h.peel_to_commit()) { None => { @@ -97,6 +110,12 @@ impl Repo { } } if !tracked.is_empty() { + if self.sparse_enabled() { + let mut args = vec!["--literal-pathspecs", "checkout-index", "--force", "--"]; + args.extend(tracked); + self.sparse_git(&args, None)?; + return Ok(()); + } 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 @@ -124,6 +143,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()]); } let repo = self.git2()?; match repo.head().ok().map(|h| h.peel_to_commit()) { // No HEAD yet (unborn branch): just drop the index entry. diff --git a/crates/strand-core/src/status.rs b/crates/strand-core/src/status.rs index dc05a56e..1e5dec01 100644 --- a/crates/strand-core/src/status.rs +++ b/crates/strand-core/src/status.rs @@ -26,6 +26,31 @@ impl Repo { /// Uses `git2` for now because gix's status APIs are still maturing; /// the public type intentionally hides which engine produced it. pub fn status(&self) -> Result> { + if self.sparse_enabled() { + let output = self.sparse_git(&["status", "--porcelain=v1", "-z", "--untracked-files=all"], None)?; + let mut records = output.split(|byte| *byte == 0).filter(|row| !row.is_empty()); + let mut result = Vec::new(); + while let Some(row) = records.next() { + if row.len() < 4 { continue; } + let path = String::from_utf8_lossy(&row[3..]).into_owned(); + let (x, y) = (row[0], row[1]); + if x == b'R' || x == b'C' || y == b'R' || y == b'C' { records.next(); } + if x == b'U' || y == b'U' || (x == b'A' && y == b'A') || (x == b'D' && y == b'D') { + result.push(FileStatus { path, kind: StatusKind::Conflicted, staged: false }); + continue; + } + for (code, staged) in [(x, true), (y, false)] { + let kind = match code { + b'A' | b'C' => StatusKind::Added, b'D' => StatusKind::Deleted, + b'R' => StatusKind::Renamed, b'M' | b'T' => StatusKind::Modified, + b'?' if !staged => StatusKind::Untracked, + _ => continue, + }; + result.push(FileStatus { path: path.clone(), kind, staged }); + } + } + return Ok(result); + } let repo = self.git2()?; let statuses = repo.statuses(Some(&mut status_options()))?; Ok(from_statuses(&statuses)) diff --git a/crates/strand-core/src/tree.rs b/crates/strand-core/src/tree.rs index a2e60422..5c70cd9d 100644 --- a/crates/strand-core/src/tree.rs +++ b/crates/strand-core/src/tree.rs @@ -22,6 +22,9 @@ pub struct WorkTreeEntry { /// because ignored files are not working-tree changes. #[serde(default)] pub ignored: bool, + /// Tracked in Git but intentionally absent from this sparse working tree. + #[serde(default)] + pub excluded: bool, } impl Repo { @@ -81,6 +84,7 @@ impl Repo { path, status: None, ignored: true, + excluded: false, }); } entries.sort_unstable_by(|a, b| a.path.cmp(&b.path)); @@ -105,6 +109,7 @@ impl Repo { path: format!("{root}{name}"), status: None, ignored: false, + excluded: false, }); } } @@ -126,11 +131,11 @@ pub(crate) fn from_index_and_statuses( // Start from the index — the canonical set of tracked paths. A // BTreeMap keeps the output path-sorted and dedupes conflict entries // (which appear once per stage). - let mut map: BTreeMap, bool)> = BTreeMap::new(); + let mut map: BTreeMap, bool, bool)> = BTreeMap::new(); let index = repo.index()?; for entry in index.iter() { if let Ok(p) = std::str::from_utf8(&entry.path) { - map.entry(p.to_string()).or_insert((None, false)); + map.entry(p.to_string()).or_insert((None, false, entry.flags_extended & (1 << 14) != 0)); } } @@ -141,16 +146,20 @@ pub(crate) fn from_index_and_statuses( for e in statuses.iter() { let Some(path) = e.path() else { continue }; let s = e.status(); + // libgit2 reports absent skip-worktree entries as deletions. These + // remain tracked; only a real staged change overrides their identity. + if map.get(path).is_some_and(|(_, _, excluded)| *excluded) + && s == git2::Status::WT_DELETED { continue; } if s.is_ignored() { - map.entry(path.to_string()).or_insert((None, true)); + map.entry(path.to_string()).or_insert((None, true, false)); continue; } - map.insert(path.to_string(), (Some(classify(s)), false)); + map.insert(path.to_string(), (Some(classify(s)), false, false)); } Ok(map .into_iter() - .map(|(path, (status, ignored))| WorkTreeEntry { path, status, ignored }) + .map(|(path, (status, ignored, excluded))| WorkTreeEntry { path, status, ignored, excluded }) .collect()) } @@ -219,7 +228,7 @@ fn ignored_boundaries( ) -> Result> { let mut map = entries .into_iter() - .map(|entry| (entry.path, (entry.status, entry.ignored))) + .map(|entry| (entry.path, (entry.status, entry.ignored, entry.excluded))) .collect::>(); let mut pending = vec![(workdir.to_path_buf(), String::new())]; @@ -254,7 +263,7 @@ fn ignored_boundaries( } else { relative }; - map.insert(path, (None, true)); + map.insert(path, (None, true, false)); continue; } if file_type.is_dir() && !file_type.is_symlink() && !child.path().join(".git").exists() { @@ -265,10 +274,11 @@ fn ignored_boundaries( Ok(map .into_iter() - .map(|(path, (status, ignored))| WorkTreeEntry { + .map(|(path, (status, ignored, excluded))| WorkTreeEntry { path, status, ignored, + excluded, }) .collect()) } diff --git a/crates/strand-core/src/watch.rs b/crates/strand-core/src/watch.rs index e29ed408..97a09a3a 100644 --- a/crates/strand-core/src/watch.rs +++ b/crates/strand-core/src/watch.rs @@ -99,7 +99,8 @@ fn changes_file_inventory(event: ¬ify::Event, git_dir: &Path) -> bool { event.paths.iter().any(|path| { if let Ok(relative) = path.strip_prefix(git_dir) { // Ref/index replacements do not change the Files inventory. - relative == Path::new("info/exclude") || relative == Path::new("config") + relative == Path::new("info/exclude") || relative == Path::new("info/sparse-checkout") + || relative == Path::new("config") || relative == Path::new("config.worktree") } else { structural || path.file_name().is_some_and(|name| name == ".gitignore") } @@ -148,6 +149,7 @@ fn relevant_path(path: &Path, git_dir: &Path) -> bool { | "rebase-apply" | "info" | "config" + | "config.worktree" ) } diff --git a/crates/strand-core/tests/clone_recursive.rs b/crates/strand-core/tests/clone_recursive.rs new file mode 100644 index 00000000..f1bede72 --- /dev/null +++ b/crates/strand-core/tests/clone_recursive.rs @@ -0,0 +1,38 @@ +use std::path::Path; +use strand_core::network::{clone_with_options, CloneOptions}; + +fn git(path: &Path, args: &[&str]) { + let result = std::process::Command::new("git").current_dir(path) + .args(["-c", "protocol.file.allow=always", "-c", "commit.gpgsign=false", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.com"]) + .args(args).output().unwrap(); + assert!(result.status.success(), "{args:?}: {}", String::from_utf8_lossy(&result.stderr)); +} + +#[test] +fn recursive_clone_initializes_nested_modules() { + // This integration-test process owns its environment; other test binaries + // cannot inherit this test-only local transport allowance. + std::env::set_var("GIT_CONFIG_COUNT", "1"); + std::env::set_var("GIT_CONFIG_KEY_0", "protocol.file.allow"); + std::env::set_var("GIT_CONFIG_VALUE_0", "always"); + let base = std::env::temp_dir().join(format!("strand-recursive-clone-{}", std::process::id())); + for name in ["leaf", "module", "source"] { + let path = base.join(name); + std::fs::create_dir_all(&path).unwrap(); + git(&path, &["init", "-b", "main"]); + std::fs::write(path.join("file.txt"), "fixture\n").unwrap(); + git(&path, &["add", "."]); + git(&path, &["commit", "-m", "fixture"]); + } + git(&base.join("module"), &["submodule", "add", "../leaf", "nested"]); + git(&base.join("module"), &["commit", "-am", "add nested"]); + git(&base.join("source"), &["submodule", "add", "../module", "module"]); + git(&base.join("source"), &["commit", "-am", "add module"]); + let dest = base.join("clone"); + clone_with_options(base.join("source").to_str().unwrap(), dest.to_str().unwrap(), &CloneOptions { + recurse_submodules: true, ..Default::default() + }, |_| {}, None).unwrap(); + assert!(dest.join("module/file.txt").exists()); + assert!(dest.join("module/nested/file.txt").exists()); + assert!(strand_core::Repo::discover(dest).unwrap().snapshot().unwrap().status.is_empty()); +} diff --git a/crates/strand-core/tests/clone_scope.rs b/crates/strand-core/tests/clone_scope.rs new file mode 100644 index 00000000..06256d02 --- /dev/null +++ b/crates/strand-core/tests/clone_scope.rs @@ -0,0 +1,140 @@ +use std::path::{Path, PathBuf}; +use strand_core::{network::{clone_with_options, CancelHandle, CloneFilter, CloneOptions, HistoryExpansion}, Repo}; + +fn git(dir: &Path, args: &[&str]) -> String { + let out = std::process::Command::new("git").current_dir(dir) + .args(["-c", "core.autocrlf=false", "-c", "commit.gpgsign=false"]) + .args(args).output().unwrap(); + assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8(out.stdout).unwrap().trim().to_owned() +} + +fn fixture() -> (PathBuf, PathBuf, String) { + let base = std::env::temp_dir().join(format!("strand-clone-scope-{}-{:?}", std::process::id(), std::thread::current().id())); + std::fs::create_dir_all(&base).unwrap(); + let source = base.join("source"); + std::fs::create_dir_all(&source).unwrap(); + git(&source, &["init", "-b", "main"]); + git(&source, &["config", "user.name", "Fixture"]); + git(&source, &["config", "user.email", "fixture@example.com"]); + git(&source, &["config", "uploadpack.allowFilter", "true"]); + for n in 0..5 { + std::fs::write(source.join("file.txt"), format!("version {n}\n")).unwrap(); + git(&source, &["add", "."]); + git(&source, &["commit", "-m", &format!("commit {n}")]); + } + git(&source, &["branch", "topic"]); + let url = format!("file:///{}", source.to_string_lossy().replace('\\', "/").trim_start_matches('/')); + (base, source, url) +} + +#[test] +fn shallow_clone_choices_deepen_and_unshallow_preserve_work() { + let (base, _, url) = fixture(); + let dest = base.join("shallow"); + let mut updates = 0; + clone_with_options(&url, dest.to_str().unwrap(), &CloneOptions { + branch: Some("topic".into()), depth: Some(1), single_branch: true, ..Default::default() + }, |_| updates += 1, None).unwrap(); + assert!(updates > 0); + let repo = Repo::discover(&dest).unwrap(); + assert_eq!(repo.meta().unwrap().branch, "topic"); + assert_eq!(repo.log(20).unwrap().len(), 1); + assert!(repo.diff_commit("HEAD").unwrap()[0].patch.contains("version 4")); + assert!(repo.diff_commit_file("HEAD", "file.txt").unwrap()[0].patch.contains("version 4")); + assert_eq!(repo.blame("file.txt").unwrap()[0].author, "Fixture"); + assert!(repo.snapshot().unwrap().status.is_empty()); + assert!(repo.clone_scope().unwrap().shallow); + assert_eq!(repo.clone_scope().unwrap().remotes[0].fetch_refspecs, ["+refs/heads/topic:refs/remotes/origin/topic"]); + std::fs::write(dest.join("file.txt"), "staged\n").unwrap(); + repo.stage_path("file.txt").unwrap(); + std::fs::write(dest.join("file.txt"), "unstaged\n").unwrap(); + repo.expand_history("origin", HistoryExpansion::Deepen { commits: 2 }, |_| {}, None).unwrap(); + assert_eq!(Repo::discover(&dest).unwrap().log(20).unwrap().len(), 3); + repo.expand_history("origin", HistoryExpansion::Unshallow, |_| {}, None).unwrap(); + let repo = Repo::discover(&dest).unwrap(); + assert!(!repo.clone_scope().unwrap().shallow); + assert_eq!(repo.log(20).unwrap().len(), 5); + assert_eq!(git(&dest, &["show", ":file.txt"]), "staged"); + assert_eq!(std::fs::read_to_string(dest.join("file.txt")).unwrap(), "unstaged\n"); + assert!(repo.expand_history("origin", HistoryExpansion::Unshallow, |_| {}, None).is_err()); +} + +#[test] +fn external_shallow_and_partial_repositories_open_and_inspect() { + let (base, _, url) = fixture(); + let dest = base.join("external"); + git(&base, &["clone", "--depth=1", "--filter=blob:none", "--no-single-branch", &url, dest.to_str().unwrap()]); + let repo = Repo::discover(&dest).unwrap(); + let scope = repo.clone_scope().unwrap(); + assert!(scope.shallow); + assert_eq!(scope.remotes[0].filter.as_deref(), Some("blob:none")); + assert_eq!(scope.remotes[0].fetch_refspecs, ["+refs/heads/*:refs/remotes/origin/*"]); + assert!(repo.snapshot().unwrap().status.is_empty()); + assert!(repo.diff_unstaged().unwrap().is_empty()); + assert_eq!(repo.log(20).unwrap().len(), 1); +} + +#[test] +fn partial_clone_fetches_historical_content_on_demand() { + let (base, source, url) = fixture(); + let dest = base.join("partial"); + clone_with_options(&url, dest.to_str().unwrap(), &CloneOptions { + filter: Some(CloneFilter::BlobNone), ..Default::default() + }, |_| {}, None).unwrap(); + let missing = git(&dest, &["rev-list", "--objects", "--all", "--missing=print"]); + assert!(missing.lines().any(|line| line.starts_with('?')), "fixture must omit real objects"); + let old = git(&source, &["rev-parse", "HEAD~3"]); + let repo = Repo::discover(&dest).unwrap(); + assert!(repo.file_content("file.txt", Some(&old)).unwrap().text.contains("version 1")); + assert!(!repo.diff_commit(&old).unwrap().is_empty()); + assert!(repo.diff_since(&old).unwrap()[0].patch.contains("version 4")); + assert_eq!(repo.blame("file.txt").unwrap()[0].content, "version 4"); +} + +#[test] +fn invalid_options_and_pre_cancel_do_not_create_a_destination() { + let (base, _, url) = fixture(); + let dest = base.join("invalid"); + for options in [ + CloneOptions { depth: Some(0), ..Default::default() }, + CloneOptions { branch: Some("--upload-pack=evil".into()), ..Default::default() }, + CloneOptions { branch: Some("bad\nbranch".into()), ..Default::default() }, + ] { + assert!(clone_with_options(&url, dest.to_str().unwrap(), &options, |_| {}, None).is_err()); + assert!(!dest.exists()); + } + let cancel = CancelHandle::new(); + cancel.cancel(); + assert!(matches!(clone_with_options(&url, dest.to_str().unwrap(), &CloneOptions::default(), |_| {}, Some(&cancel)), Err(strand_core::Error::Cancelled))); + assert!(!dest.exists()); +} + +#[test] +fn cancellation_stops_a_live_http_clone_and_its_transport_child() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/repository", listener.local_addr().unwrap()); + let (accepted_tx, accepted_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + accepted_tx.send(()).unwrap(); + let _ = release_rx.recv(); + drop(stream); + }); + let dest = std::env::temp_dir().join(format!("strand-clone-cancel-{}", std::process::id())); + let cancel = CancelHandle::new(); + let worker_cancel = cancel.clone(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let result = clone_with_options(&url, dest.to_str().unwrap(), &CloneOptions::default(), |_| {}, Some(&worker_cancel)); + done_tx.send(result).unwrap(); + }); + accepted_rx.recv_timeout(std::time::Duration::from_secs(15)).unwrap(); + cancel.cancel(); + let result = done_rx.recv_timeout(std::time::Duration::from_secs(10)); + release_tx.send(()).unwrap(); + server.join().unwrap(); + worker.join().unwrap(); + assert!(matches!(result.unwrap(), Err(strand_core::Error::Cancelled))); +} diff --git a/crates/strand-core/tests/sparse_checkout.rs b/crates/strand-core/tests/sparse_checkout.rs new file mode 100644 index 00000000..12adb226 --- /dev/null +++ b/crates/strand-core/tests/sparse_checkout.rs @@ -0,0 +1,180 @@ +use std::path::{Path, PathBuf}; +use strand_core::Repo; + +fn git(dir: &Path, args: &[&str]) -> String { + let out = std::process::Command::new("git").current_dir(dir) + .args(["-c", "core.autocrlf=false", "-c", "commit.gpgsign=false"]) + .args(args).output().unwrap(); + assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8(out.stdout).unwrap().trim().into() +} +fn fixture() -> PathBuf { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!("strand-sparse-{}-{:?}-{}", std::process::id(), std::thread::current().id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))); + std::fs::create_dir_all(&dir).unwrap(); + git(&dir, &["init", "-b", "main"]); + git(&dir, &["config", "user.name", "Fixture"]); + git(&dir, &["config", "user.email", "fixture@example.com"]); + for name in ["keep/a.txt", "omit/b.txt", "space name/nested/c.txt", "root.txt"] { + std::fs::create_dir_all(dir.join(name).parent().unwrap()).unwrap(); + std::fs::write(dir.join(name), "original\n").unwrap(); + } + git(&dir, &["add", "."]); + git(&dir, &["commit", "-m", "fixture"]); + dir +} + +#[test] +fn external_sparse_indexes_open_without_becoming_deletions_or_rewriting_index() { + for sparse in [false, true] { + let dir = fixture().join(if sparse { "compressed" } else { "expanded" }); + let source = dir.parent().unwrap(); + git(source, &["clone", "--no-local", source.to_str().unwrap(), dir.to_str().unwrap()]); + git(&dir, &["sparse-checkout", "set", "--cone", if sparse { "--sparse-index" } else { "--no-sparse-index" }, "keep"]); + let before = std::fs::read(dir.join(".git/index")).unwrap(); + let repo = Repo::discover(&dir).unwrap(); + assert!(repo.sparse_checkout().unwrap().enabled); + assert_eq!(repo.sparse_checkout().unwrap().directories, ["keep"]); + let snapshot = repo.snapshot().unwrap(); + assert!(snapshot.status.is_empty(), "{:?}", snapshot.status); + assert!(snapshot.work_tree.iter().find(|entry| entry.path == "omit/b.txt").unwrap().excluded); + assert!(repo.diff_unstaged().unwrap().is_empty()); + assert!(repo.diff_staged().unwrap().is_empty()); + assert_eq!(std::fs::read(dir.join(".git/index")).unwrap(), before); + std::fs::remove_file(dir.join("keep/a.txt")).unwrap(); + let status = Repo::discover(&dir).unwrap().status().unwrap(); + assert_eq!(status.len(), 1); + assert_eq!(status[0].path, "keep/a.txt"); + } +} + +#[test] +fn sparse_stage_hunks_commit_checkout_and_discard_preserve_excluded_files() { + let dir = fixture(); + git(&dir, &["branch", "other"]); + git(&dir, &["sparse-checkout", "set", "--cone", "--sparse-index", "keep"]); + std::fs::write(dir.join("keep/a.txt"), "edited\n").unwrap(); + let repo = Repo::discover(&dir).unwrap(); + let patch = repo.diff_unstaged().unwrap().remove(0).patch; + repo.apply_patch(&patch, strand_core::apply::ApplyTarget::Index).unwrap(); + assert!(Repo::discover(&dir).unwrap().diff_staged().unwrap()[0].patch.contains("edited")); + Repo::discover(&dir).unwrap().unstage_path("keep/a.txt").unwrap(); + Repo::discover(&dir).unwrap().stage_paths(&["keep/a.txt".into()]).unwrap(); + git(&dir, &["config", "commit.gpgsign", "false"]); + Repo::discover(&dir).unwrap().commit("sparse edit", None, false).unwrap(); + assert_eq!(git(&dir, &["show", "HEAD:omit/b.txt"]), "original"); + assert!(!dir.join("omit/b.txt").exists()); + assert!(git(&dir, &["ls-files", "--sparse"]).lines().any(|line| line == "omit/")); + Repo::discover(&dir).unwrap().checkout_branch("other").unwrap(); + assert!(!dir.join("omit/b.txt").exists()); + assert_eq!(std::fs::read_to_string(dir.join("keep/a.txt")).unwrap().trim(), "original"); + std::fs::write(dir.join("keep/a.txt"), "discard this\n").unwrap(); + std::fs::write(dir.join("loose.txt"), "new\n").unwrap(); + let repo = Repo::discover(&dir).unwrap(); + assert!(repo.diff_unstaged().unwrap().iter().any(|diff| diff.path == "loose.txt")); + repo.discard_paths(&["keep/a.txt".into(), "loose.txt".into()]).unwrap(); + assert!(Repo::discover(&dir).unwrap().status().unwrap().is_empty()); +} + +#[test] +fn ignored_untracked_staged_and_invalid_selection_refuse_without_mutation() { + let dir = fixture(); + std::fs::write(dir.join(".gitignore"), "omit/cache/\n").unwrap(); + git(&dir, &["add", ".gitignore"]); + git(&dir, &["commit", "-m", "ignore cache"]); + std::fs::create_dir_all(dir.join("omit/cache")).unwrap(); + std::fs::write(dir.join("omit/cache/precious.txt"), "preserve\n").unwrap(); + let repo = Repo::discover(&dir).unwrap(); + let before = std::fs::read(dir.join(".git/index")).unwrap(); + assert!(repo.set_sparse_checkout(&["keep".into()], true).unwrap_err().to_string().contains("Ignored")); + for name in ["../outside", "--cone", "keep\nother", ".git", "unknown"] { + assert!(repo.set_sparse_checkout(&[name.into()], true).is_err()); + } + assert_eq!(std::fs::read(dir.join(".git/index")).unwrap(), before); + assert!(dir.join("omit/cache/precious.txt").exists()); + // Keeping the ignored directory's parent is safe. + repo.set_sparse_checkout(&["omit".into()], true).unwrap(); + std::fs::write(dir.join("root.txt"), "staged\n").unwrap(); + git(&dir, &["add", "root.txt"]); + std::fs::write(dir.join("root.txt"), "unstaged\n").unwrap(); + std::fs::write(dir.join("untracked.txt"), "loose\n").unwrap(); + let before = std::fs::read(dir.join(".git/index")).unwrap(); + assert!(Repo::discover(&dir).unwrap().disable_sparse_checkout().is_err()); + assert_eq!(std::fs::read(dir.join(".git/index")).unwrap(), before); + assert_eq!(git(&dir, &["show", ":root.txt"]), "staged"); + assert_eq!(std::fs::read_to_string(dir.join("root.txt")).unwrap(), "unstaged\n"); + assert!(dir.join("untracked.txt").exists()); +} + +#[test] +fn external_non_cone_inspection_and_linked_worktree_isolation() { + let dir = fixture(); + git(&dir, &["sparse-checkout", "set", "--no-cone", "/keep/"]); + let repo = Repo::discover(&dir).unwrap(); + assert!(!repo.sparse_checkout().unwrap().cone); + assert!(repo.set_sparse_checkout(&["omit".into()], false).is_err()); + repo.disable_sparse_checkout().unwrap(); + let linked = dir.with_extension("linked"); + git(&dir, &["worktree", "add", "-b", "linked", linked.to_str().unwrap()]); + Repo::discover(&linked).unwrap().set_sparse_checkout(&["keep".into()], true).unwrap(); + assert!(!linked.join("omit/b.txt").exists()); + assert!(dir.join("omit/b.txt").exists()); + assert!(!Repo::discover(&dir).unwrap().sparse_checkout().unwrap().enabled); + assert!(Repo::discover(&linked).unwrap().snapshot().unwrap().status.is_empty()); +} + +#[test] +fn sparse_file_diff_limits_work_to_a_literal_tracked_or_untracked_path() { + let dir = fixture(); + std::fs::write(dir.join("keep/[one].txt"), "original\n").unwrap(); + git(&dir, &["add", "."]); + git(&dir, &["commit", "-m", "literal path"]); + Repo::discover(&dir).unwrap().set_sparse_checkout(&["keep".into()], true).unwrap(); + for path in ["root.txt", "keep/[one].txt", "keep/[new].txt"] { + std::fs::write(dir.join(path), "edited\n").unwrap(); + } + for path in ["keep/[one].txt", "keep/[new].txt"] { + let diffs = Repo::discover(&dir).unwrap().diff_workdir_file(path).unwrap(); + assert_eq!(diffs.len(), 1); + assert_eq!(diffs[0].path, path); + assert!(diffs[0].patch.contains("edited")); + } +} + +#[test] +fn restored_clean_file_is_removed_when_its_directory_is_excluded() { + let dir = fixture(); + Repo::discover(&dir).unwrap().set_sparse_checkout(&["keep".into()], true).unwrap(); + let path = dir.join("keep/a.txt"); + let original = std::fs::read(&path).unwrap(); + std::fs::remove_file(&path).unwrap(); + assert!(Repo::discover(&dir).unwrap().set_sparse_checkout(&["omit".into()], true).is_err()); + std::fs::write(&path, original).unwrap(); + std::fs::File::options().write(true).open(&path).unwrap() + .set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(3)).unwrap(); + Repo::discover(&dir).unwrap().set_sparse_checkout(&["omit".into()], true).unwrap(); + assert!(!path.exists(), "Clean files with stale index stat data must be excluded"); + assert!(dir.join("omit/b.txt").exists()); +} + +#[test] +fn cone_selection_round_trip_and_dirty_refusal() { + let dir = fixture(); + let repo = Repo::discover(&dir).unwrap(); + repo.set_sparse_checkout(&["space name/nested".into()], true).unwrap(); + assert!(dir.join("root.txt").exists()); + assert!(dir.join("space name/nested/c.txt").exists()); + assert!(!dir.join("omit/b.txt").exists()); + let repo = Repo::discover(&dir).unwrap(); + assert_eq!(repo.sparse_checkout().unwrap().directories, ["space name/nested"]); + std::fs::write(dir.join("root.txt"), "dirty\n").unwrap(); + let before = std::fs::read(dir.join(".git/index")).unwrap(); + assert!(repo.set_sparse_checkout(&["keep".into()], true).is_err()); + assert!(repo.disable_sparse_checkout().is_err()); + assert_eq!(std::fs::read(dir.join(".git/index")).unwrap(), before); + assert_eq!(std::fs::read_to_string(dir.join("root.txt")).unwrap(), "dirty\n"); + git(&dir, &["restore", "root.txt"]); + Repo::discover(&dir).unwrap().disable_sparse_checkout().unwrap(); + assert!(dir.join("omit/b.txt").exists()); + assert!(Repo::discover(&dir).unwrap().status().unwrap().is_empty()); +} diff --git a/crates/strand-tauri/src/commands.rs b/crates/strand-tauri/src/commands.rs index e0ec2642..49ebca17 100644 --- a/crates/strand-tauri/src/commands.rs +++ b/crates/strand-tauri/src/commands.rs @@ -24,7 +24,7 @@ use strand_core::{ init::{init_repository, InitOutcome}, maintenance::{MaintenanceOutcome, MaintenanceTask}, history::{MergeMode, RebaseEntry, RebaseStep}, log::{Commit, SearchMode}, - network::{clone as core_clone, CancelHandle, CloneOutcome, NetworkOutcome, Progress, PullMode, PushMode}, + network::{clone_with_options as core_clone, CancelHandle, CloneOptions, CloneOutcome, CloneScope, HistoryExpansion, NetworkOutcome, Progress, PullMode, PushMode}, reflog::ReflogEntry, refs::{BaseBranch, Refs}, repo::RepoMeta, reset::{ResetMode, ResetOutcome}, snapshot::Snapshot, stash::{Stash, StashOutcome}, @@ -1094,6 +1094,7 @@ pub async fn repo_branch_pull( pub async fn repo_clone( url: String, dest: String, + options: Option, op_id: Option, on_event: Channel, state: State<'_, AppState>, @@ -1104,6 +1105,7 @@ pub async fn repo_clone( core_clone( &url, &dest, + &options.unwrap_or_default(), |p| { let _ = on_event.send(p); }, @@ -1116,6 +1118,44 @@ pub async fn repo_clone( result } +#[tauri::command(async)] +pub async fn repo_clone_scope(path: String) -> CmdResult { + run_blocking("clone scope", move || Ok(Repo::discover(path)?.clone_scope()?)).await +} + +#[tauri::command(async)] +pub async fn repo_sparse_checkout(path: String) -> CmdResult { + run_blocking("sparse checkout", move || Ok(Repo::discover(path)?.sparse_checkout()?)).await +} + +#[tauri::command(async)] +pub async fn repo_set_sparse_checkout(path: String, directories: Vec, sparse_index: bool) -> CmdResult { + run_blocking("set sparse checkout", move || Ok(Repo::discover(path)?.set_sparse_checkout(&directories, sparse_index)?)).await +} + +#[tauri::command(async)] +pub async fn repo_disable_sparse_checkout(path: String) -> CmdResult { + run_blocking("disable sparse checkout", move || Ok(Repo::discover(path)?.disable_sparse_checkout()?)).await +} + +#[tauri::command(async)] +pub async fn repo_expand_history( + path: String, + remote: String, + expansion: HistoryExpansion, + op_id: Option, + on_event: Channel, + state: State<'_, AppState>, +) -> CmdResult { + let cancel = CancelHandle::new(); + register_op(&state, &op_id, OperationCancelHandle::Network(cancel.clone())); + let result = run_blocking("expand history", move || { + Ok(Repo::discover(path)?.expand_history(&remote, expansion, |p| { let _ = on_event.send(p); }, Some(&cancel))?) + }).await; + deregister_op(&state, &op_id); + result +} + /// Kill the in-flight cancellable op registered under `op_id`. A no-op when /// the op already finished (its handle is gone from the registry). /// Deliberately NOT `(async)`: cancellation is a lock + kill signal and must diff --git a/crates/strand-tauri/src/main.rs b/crates/strand-tauri/src/main.rs index 0b3fad89..47b95cdc 100644 --- a/crates/strand-tauri/src/main.rs +++ b/crates/strand-tauri/src/main.rs @@ -238,6 +238,11 @@ fn main() { commands::repo_branch_fetch, commands::repo_branch_pull, commands::repo_clone, + commands::repo_clone_scope, + commands::repo_sparse_checkout, + commands::repo_set_sparse_checkout, + commands::repo_disable_sparse_checkout, + commands::repo_expand_history, commands::repo_checkout, commands::repo_checkout_commit, commands::repo_tree, From 4ec788b3394d9f1a82e18a954a9555e88c46bf21 Mon Sep 17 00:00:00 2001 From: Daniels-Main Date: Sun, 6 Sep 2026 16:55:40 +0200 Subject: [PATCH 4/8] Expose sparse checkout and clone history controls --- README.md | 6 ++ ROADMAP.md | 10 +++ TASKS.md | 10 ++- docs/learnings.md | 26 +++++++ docs/sparse-clone-verification.md | 40 ++++++++++ ui/src/App.tsx | 45 ++++++++++- ui/src/components/RepositoryFiles.tsx | 7 +- ui/src/components/Topbar.tsx | 8 +- ui/src/demo/dispatch.ts | 5 ++ ui/src/lib/cloneOptions.test.ts | 12 +++ ui/src/lib/cloneOptions.ts | 6 ++ ui/src/lib/tauri.ts | 15 +++- ui/src/lib/types.ts | 25 ++++++ ui/src/lib/workTreeGitStatus.test.ts | 6 ++ ui/src/lib/workTreeGitStatus.ts | 2 + ui/src/plugins/builtins/heroi/HeroiView.tsx | 2 +- ui/src/styles/features.css | 13 ++++ ui/src/views/CloneDialog.tsx | 36 ++++++++- ui/src/views/CloneScopeDialog.tsx | 75 ++++++++++++++++++ ui/src/views/FileView.tsx | 2 +- ui/src/views/SparseCheckoutDialog.tsx | 86 +++++++++++++++++++++ website/docs/keyboard-and-palette.md | 7 ++ website/docs/repositories-and-workspaces.md | 53 +++++++++++++ 23 files changed, 482 insertions(+), 15 deletions(-) create mode 100644 docs/sparse-clone-verification.md create mode 100644 ui/src/lib/cloneOptions.test.ts create mode 100644 ui/src/lib/cloneOptions.ts create mode 100644 ui/src/views/CloneScopeDialog.tsx create mode 100644 ui/src/views/SparseCheckoutDialog.tsx diff --git a/README.md b/README.md index 40ac8df7..7b8c03ed 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,12 @@ the resolved app appearance automatically. ## Features +- **Repository size controls** — clone a chosen branch with optional depth, + single-branch fetching, on-demand file contents (`blob:none`), and recursive + submodules. Inspect clone scope and download more or full history from the + network menu or palette. Sparse checkout selects cone directories, distinguishes + excluded files from deletions, and preserves external sparse indexes on reads. + Selection changes refuse dirty trees and ignored-file removal. - **Responsive refreshes** — repository updates coalesce during bursts of agent edits, hidden diff panes load patches when opened, and Files reuses its inventory until paths or ignore rules change. Workspace scans run with diff --git a/ROADMAP.md b/ROADMAP.md index f048b0bf..934551ad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2808,6 +2808,16 @@ implementation rows while the July audit is labeled historical. This is a planning update, not a claim that these features shipped; existing local Git, GitHub/Azure review, Workbench and performance work retain their own status. +**Sparse checkout and clone controls shipped (2026-09-06, F08/F09):** Clone now +offers branch, independent depth/single-branch choices, blob filtering and +recursive submodules. Repository history controls inspect external clones and +deepen/unshallow with progress and cancellation. Cone sparse selections can be +inspected, changed and disabled, with dirty/ignored-file guards and compatible +reads and mutations for external sparse indexes. Excluded files no longer look +deleted. Windows native UI verification and 13 integration fixtures cover +these workflows; normal repositories retain their existing in-process paths. +See `docs/sparse-clone-verification.md` for validation and supported boundaries. + --- ## Cross-cutting tracks (run in parallel with all milestones) diff --git a/TASKS.md b/TASKS.md index 4c7910bf..d6c7e93c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -109,11 +109,13 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git- - ☐ **F07 / P2 — Patch/mailbox/bundle import and interchange.** Build on exact patch export and hunk apply with preview/validation, explicit targets, mailbox continue/skip/abort and bundle prerequisites/ref summaries. -- ☐ **F08 / P2 — Sparse checkout.** Cone-directory inspect/change/disable and - compatibility fixtures for excluded paths, dirty trees and sparse indexes. -- ☐ **F09 / P2 — Advanced clone options.** Branch, depth/single-branch, +- ☑ **F08 / P2 — Sparse checkout.** Cone-directory inspect/change/disable and + compatibility fixtures for excluded paths, dirty trees and sparse indexes + (`Repo::set_sparse_checkout`, `SparseCheckoutDialog`, `sparse_checkout.rs` fixtures). +- ☑ **F09 / P2 — Advanced clone options.** Branch, depth/single-branch, partial-clone filter and recursive-submodule options; deepen/unshallow, - progress/cancellation, and safe argument construction. + progress/cancellation, and safe argument construction (`clone_with_options`, + `repo_expand_history`, `CloneScopeDialog`; `docs/sparse-clone-verification.md`). - ☐ **F10 / P2 — Guided bisect.** Good/bad/skip, operation progress, external session resume and safe reset to the original checkout; defer test-command execution until the manual workflow is complete. diff --git a/docs/learnings.md b/docs/learnings.md index e50db001..20f046a5 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -1,5 +1,31 @@ # Learnings +## Sparse indexes and promised blobs require Git-aware paths (2026-09-06) + +libgit2 1.8 cannot read the mandatory sparse-directory index extension and +reports absent skip-worktree entries as deletions even with a full index. +For sparse repositories, use Git for status, working diffs and index mutations; +the compatibility index attached to libgit2 is expanded in memory for readers +only. Never rewrite the user's index merely by opening it. Normal repositories +retain their existing in-process paths. This is a scoped exception to the older +index/commit-engine rule, required by F08's sparse-index semantics. + +Sparse selection must go directly through `sparse-checkout set --cone`; an +init-then-set sequence can remove files between steps. Git may delete ignored +files when excluding a directory. Refuse dirty/untracked work and any ignored +boundary the new cone would exclude; do not stash, clean or discard implicitly. +Use worktree-specific Git configuration and preserve external non-cone patterns +until the user explicitly disables them. +After the guard passes, refresh index stat data before changing the cone: +a clean file restored by an editor can otherwise be retained as "not up to +date". Keep Git's successful warnings visible when it retains files. + +Partial-clone fixtures must actually omit objects (`rev-list --missing=print`). +A successful open is insufficient: historical content, diffs and blame need +Git's lazy object fetching; shallow blame needs Git's boundary semantics. +Keep those reads on demand. Recursive clone cancellation must terminate the +transport/submodule child processes too, because they hold the progress pipes. + Things we've learned while building Strand that aren't otherwise obvious from the PRD / ROADMAP / TASKS files. Append here when you discover something that future work (yours or another agent's) needs to respect. diff --git a/docs/sparse-clone-verification.md b/docs/sparse-clone-verification.md new file mode 100644 index 00000000..72fbcae0 --- /dev/null +++ b/docs/sparse-clone-verification.md @@ -0,0 +1,40 @@ +# Sparse checkout and clone controls — F08/F09 + +Verified on Windows / WebView2 on 2026-09-06. + +- `cargo check -j 2 -p strand-core -p strand-tauri` passed. +- `cargo test -j 2 -p strand-core --lib --tests -- --test-threads=2`: + 175 tests passed, including 13 integration tests in `sparse_checkout.rs`, + `clone_scope.rs` and `clone_recursive.rs`. +- `corepack pnpm --filter ./ui exec tsc --noEmit` passed. +- `corepack pnpm --filter ./ui exec vitest run --maxWorkers=2 --minWorkers=1`: + 76 files / 427 tests passed. + +The repository's verify skill was run against an isolated native app instance. +The command palette and keyboard controls cloned a selected branch with depth +one, a single-branch refspec and `blob:none`; system Git confirmed each choice. +The history dialog deepened from one to three commits and downloaded all five. +A real stalled HTTP clone displayed progress and cancelled its transport. +History cancellation in an externally cloned shallow repository preserved the +index and restored keyboard focus. + +Live sparse checks enabled a sparse index, changed to a nested directory with +spaces, refused changes after a real deletion without changing index bytes, and +restored every tracked file on disable. Files omitted excluded paths with a +Manage notice; a real included-file deletion produced exactly one deleted row. +Tab/Shift+Tab wrapping, Escape, initial/post-operation focus and the scrollable +880×650 layout were checked. The native debug instance was isolated from the +user's profile and app identifier; the checked-in Tauri config was unchanged. + +Integration fixtures also cover external full/sparse indexes without rewriting +them on read, linked-worktree isolation, non-cone inspection/disable, staged and +unstaged edits, ignored/untracked-file refusal, sparse staging/hunks/commit/ +checkout/discard, literal file paths, restored files with stale index timestamps, +real omitted historical blobs, shallow blame and recursive nested submodules. + +Cone selection is limited to tracked directories in HEAD. External non-cone +patterns must be disabled before editing cone selections. Sparse changes refuse +dirty work rather than implicitly stashing it; they show busy state and Git's +warnings. Streamed progress and cancellation apply to clone/history downloads. +Partial content reads can require network access. Live execution and transport +cancellation validation was Windows only. diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ea335854..fcf344d0 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -101,6 +101,8 @@ import type { BranchPushRequest, FileDiff, Progress, + CloneOptions, + HistoryExpansion, PullMode, PushMode, RepoMeta, @@ -114,6 +116,8 @@ const waitForPaint = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r()))); const CloneDialog = lazy(() => import('./views/CloneDialog').then((m) => ({ default: m.CloneDialog }))); +const CloneScopeDialog = lazy(() => import('./views/CloneScopeDialog').then((m) => ({ default: m.CloneScopeDialog }))); +const SparseCheckoutDialog = lazy(() => import('./views/SparseCheckoutDialog').then((m) => ({ default: m.SparseCheckoutDialog }))); const InitRepoDialog = lazy(() => import('./views/InitRepoDialog').then((m) => ({ default: m.InitRepoDialog }))); const SettingsDialog = lazy(() => import('./views/SettingsDialog').then((m) => ({ default: m.SettingsDialog }))); const BranchCleanupDialog = lazy(() => import('./views/BranchCleanupDialog').then((m) => ({ default: m.BranchCleanupDialog }))); @@ -335,6 +339,13 @@ export function App() { const [settingsOpen, setSettingsOpen] = useState(false); const [settingsSection, setSettingsSection] = useState('appearance'); const [cloneOpen, setCloneOpen] = useState(false); + const [cloneScopePath, setCloneScopePath] = useState(null); + const [sparsePath, setSparsePath] = useState(null); + useEffect(() => { + const open = () => setSparsePath(useRepo.getState().activePath); + window.addEventListener('strand:open-sparse-checkout', open); + return () => window.removeEventListener('strand:open-sparse-checkout', open); + }, []); const [initRepoOpen, setInitRepoOpen] = useState(false); // null = closed; otherwise the flavour the dialog opens in (snapshot vs stash). const [stashDialog, setStashDialog] = useState<{ snapshot: boolean; keepIndex: boolean } | null>(null); @@ -722,7 +733,7 @@ export function App() { // same popup (one op id) switches in place from "Cloning" to "Opening" — no // flicker. The Clone dialog closes the moment this starts; failures surface // as a toast (there's no dialog to return to). - const runClone = useCallback(async (url: string, dest: string) => { + const runClone = useCallback(async (url: string, dest: string, options: CloneOptions) => { const id = ++opGen.current; const cancelId = nextOpId(); setCloneCancelId(cancelId); @@ -757,7 +768,7 @@ export function App() { } const detail = pct != null ? `${p.phase || 'Working'} ${pct}%` : p.raw || p.phase || 'Cloning…'; setOpProgress((cur) => (cur && cur.id === id && cur.kind === 'clone' ? { ...cur, percent: pct, detail, eta } : cur)); - }, cancelId); + }, cancelId, options); clonedPath = res.path; } catch (e) { setCloneCancelId(null); @@ -861,6 +872,25 @@ export function App() { void setActiveTab(next.path); }, []); + const onExpandHistory = useCallback(async (path: string, remote: string, expansion: HistoryExpansion) => { + if (syncing || pulling || pushing) throw new Error('Another network operation is running.'); + setSyncing(true); + setNetProgress('Downloading history…'); + const opId = nextOpId(); + setNetOpId(opId); + try { + await tauri.repoExpandHistory(path, remote, expansion, (p) => setNetProgress(p.raw), opId); + showToast('History download completed'); + } finally { + setSyncing(false); + setNetProgress(null); + setNetOpId(null); + if (useRepo.getState().activePath === path) { + await Promise.all([useRepo.getState().refreshSnapshot(), useRepo.getState().refreshLog()]); + } + } + }, [syncing, pulling, pushing, nextOpId, showToast]); + const onFetch = useCallback(async (prune?: boolean) => { if (syncing || pulling || pushing) return; setSyncing(true); @@ -1469,6 +1499,7 @@ export function App() { // Files — explicit palette selection opens a pinned Work document. for (const f of workTree) { + if (f.excluded) continue; out.push({ id: `file:${f.path}`, label: f.path, @@ -1875,6 +1906,8 @@ export function App() { ] : []), { id: 'remote-add', label: 'Add remote…', group: 'Actions', keywords: 'remote origin upstream url add', run: () => setRemoteDialog({ kind: 'add' }) }, + { id: 'clone-scope', label: 'Repository history and downloads…', group: 'Actions', keywords: 'clone shallow partial filter deepen unshallow single branch', run: () => setCloneScopePath(meta.path) }, + { id: 'sparse-checkout', label: 'Sparse checkout…', group: 'Actions', keywords: 'cone directories select inspect change disable excluded sparse index', run: () => setSparsePath(meta.path) }, { id: 'repository-maintenance', label: 'Repository maintenance…', group: 'Actions', keywords: 'git gc fsck integrity optimize activity log command output', run: () => { setPaletteOpen(false); setMaintenanceOpen(true); @@ -2175,6 +2208,8 @@ export function App() { onInitRepo={() => setInitRepoOpen(true)} onOpenRecent={openByPath} onClone={() => setCloneOpen(true)} + onCloneScope={() => { if (meta) setCloneScopePath(meta.path); }} + onSparseCheckout={() => { if (meta) setSparsePath(meta.path); }} onCustomize={openIconDialog} onManageWorkspaces={() => setWorkspaceManagerOpen(true)} onWorktreeReview={reviewWorktreeTab} @@ -2387,6 +2422,12 @@ export function App() { setMaintenanceOpen(false)} onToast={showToast} /> )} + {cloneScopePath && { if (netOpId) void tauri.repoCancelOp(netOpId); }} + onClose={() => setCloneScopePath(null)} />} + {sparsePath && setSparsePath(null)} />} + {fileEntryDialog && meta && ( [ - ...displayedTree.map((entry) => entry.path), + ...displayedTree.filter((entry) => !entry.excluded).map((entry) => entry.path), ...(selectedCommit ? [] : emptyDirectories), ], [displayedTree, emptyDirectories, selectedCommit], @@ -480,6 +481,10 @@ export function RepositoryFiles({ /> )} {!selectedCommit && localTree && filePaths.length === 0 && fileCreateToolbar} + {!selectedCommit && displayedTree.some((entry) => entry.excluded) &&
+ Sparse-excluded files are omitted. + +
} {selectedCommit && (
Files at {selectedCommit.slice(0, 7)} diff --git a/ui/src/components/Topbar.tsx b/ui/src/components/Topbar.tsx index 356b4fdf..6c7a68fc 100644 --- a/ui/src/components/Topbar.tsx +++ b/ui/src/components/Topbar.tsx @@ -42,6 +42,8 @@ interface Props { onOpenRecent: (path: string) => void; /** Open the clone dialog (tabs-mode `+` menu). */ onClone: () => void; + onCloneScope: () => void; + onSparseCheckout: () => void; /** Open the icon-customization dialog for a repo tab. */ onCustomize: (path: string) => void; /** Open the workspace manager dialog (tabs-mode switcher). */ @@ -81,6 +83,8 @@ export function Topbar({ onInitRepo, onOpenRecent, onClone, + onCloneScope, + onSparseCheckout, onCustomize, onManageWorkspaces, onWorktreeReview, @@ -166,7 +170,9 @@ export function Topbar({ { label: 'Force with lease…', icon: 'arrow-up', danger: true, onSelect: onForcePush }, ], }, - ], [fetchPrune, networkBusy, onFetch, onForcePush, onPull, onPush, onPushAllTags, onSetFetchPrune, onSetPullAutostash, onSetPullMode, pullAutostash, pullMode, pullModeLabel]); + { label: 'Repository history and downloads…', disabled: networkBusy, onSelect: onCloneScope }, + { label: 'Sparse checkout…', disabled: networkBusy, onSelect: onSparseCheckout }, + ], [fetchPrune, networkBusy, onCloneScope, onSparseCheckout, onFetch, onForcePush, onPull, onPush, onPushAllTags, onSetFetchPrune, onSetPullAutostash, onSetPullMode, pullAutostash, pullMode, pullModeLabel]); const inTauri = isTauri(); // macOS lets the OS draw the traffic lights over our toolbar (`titleBarStyle: diff --git a/ui/src/demo/dispatch.ts b/ui/src/demo/dispatch.ts index 4298b70f..d412ac9d 100644 --- a/ui/src/demo/dispatch.ts +++ b/ui/src/demo/dispatch.ts @@ -107,6 +107,11 @@ export const handlers: Record = { repo_refs: (a) => repo.refs(wtOf(a)), repo_submodules: () => [], repo_submodule_update: () => unavailable('Submodule updates'), + repo_clone_scope: () => ({ shallow: false, remotes: [{ name: 'origin', filter: null, fetch_refspecs: ['+refs/heads/*:refs/remotes/origin/*'] }] }), + repo_expand_history: () => unavailable('History downloads'), + repo_sparse_checkout: () => unavailable('Sparse checkout'), + repo_set_sparse_checkout: () => unavailable('Sparse checkout'), + repo_disable_sparse_checkout: () => unavailable('Sparse checkout'), repo_maintenance: async ({ task }) => { await sleep(600); const command = task === 'garbage-collect' ? 'git gc' : task === 'integrity-check' ? 'git fsck --no-dangling' : 'git maintenance run'; diff --git a/ui/src/lib/cloneOptions.test.ts b/ui/src/lib/cloneOptions.test.ts new file mode 100644 index 00000000..fca82387 --- /dev/null +++ b/ui/src/lib/cloneOptions.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { positiveDepth } from './cloneOptions'; + +describe('positiveDepth', () => { + it('accepts bounded whole numbers and rejects lossy or invalid input', () => { + expect(positiveDepth('1')).toBe(1); + expect(positiveDepth('4294967295')).toBe(4294967295); + for (const value of ['', '0', '-1', '1.5', '2e3', '4294967296', 'NaN', '--all']) { + expect(positiveDepth(value)).toBeNull(); + } + }); +}); diff --git a/ui/src/lib/cloneOptions.ts b/ui/src/lib/cloneOptions.ts new file mode 100644 index 00000000..429d050d --- /dev/null +++ b/ui/src/lib/cloneOptions.ts @@ -0,0 +1,6 @@ +/** Match the unsigned 32-bit depth accepted by the native boundary. */ +export function positiveDepth(value: string): number | null { + if (!/^[0-9]+$/.test(value)) return null; + const depth = Number(value); + return Number.isInteger(depth) && depth > 0 && depth <= 0xffff_ffff ? depth : null; +} diff --git a/ui/src/lib/tauri.ts b/ui/src/lib/tauri.ts index e7356543..de629bb3 100644 --- a/ui/src/lib/tauri.ts +++ b/ui/src/lib/tauri.ts @@ -12,6 +12,10 @@ import type { BranchPushRequest, CheckoutOutcome, CloneOutcome, + CloneOptions, + CloneScope, + SparseCheckout, + HistoryExpansion, Commit, CommitSignature, CommitMessageSuggestion, @@ -440,8 +444,15 @@ export const tauri = { opId, onEvent: progressChannel(onProgress), }), - repoClone: (url: string, dest: string, onProgress?: (p: Progress) => void, opId?: string) => - invoke('repo_clone', { url, dest, opId, onEvent: progressChannel(onProgress) }), + repoClone: (url: string, dest: string, onProgress?: (p: Progress) => void, opId?: string, options?: CloneOptions) => + invoke('repo_clone', { url, dest, options, opId, onEvent: progressChannel(onProgress) }), + repoCloneScope: (path: string) => invoke('repo_clone_scope', { path }), + repoSparseCheckout: (path: string) => invoke('repo_sparse_checkout', { path }), + repoSetSparseCheckout: (path: string, directories: string[], sparseIndex: boolean) => + invoke('repo_set_sparse_checkout', { path, directories, sparseIndex }), + repoDisableSparseCheckout: (path: string) => invoke('repo_disable_sparse_checkout', { path }), + repoExpandHistory: (path: string, remote: string, expansion: HistoryExpansion, onProgress?: (p: Progress) => void, opId?: string) => + invoke('repo_expand_history', { path, remote, expansion, opId, onEvent: progressChannel(onProgress) }), repoCheckout: (path: string, branch: string) => invoke('repo_checkout', { path, branch }), repoCheckoutCommit: (path: string, rev: string) => diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index c27be6a0..0902d6e1 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -539,6 +539,30 @@ export interface CloneOutcome { output: string; } +export interface CloneOptions { + branch: string | null; + depth: number | null; + single_branch: boolean; + filter: 'blob-none' | null; + recurse_submodules: boolean; +} + +export interface CloneScope { + shallow: boolean; + remotes: { name: string; filter: string | null; fetch_refspecs: string[] }[]; +} + +export type HistoryExpansion = { kind: 'deepen'; commits: number } | { kind: 'unshallow' }; + +export interface SparseCheckout { + enabled: boolean; + cone: boolean; + sparse_index: boolean; + directories: string[]; + available: string[]; + patterns: string; +} + /** One file in the working-tree view (Files sidebar tab). */ export interface WorkTreeEntry { path: string; @@ -546,6 +570,7 @@ export interface WorkTreeEntry { status: StatusKind | null; /** Git-ignored local file; intentionally not represented as a change status. */ ignored: boolean; + excluded?: boolean; } /** Shell used by Work's embedded terminal. Commands are tokenized into argv diff --git a/ui/src/lib/workTreeGitStatus.test.ts b/ui/src/lib/workTreeGitStatus.test.ts index 4f3af50b..09d32865 100644 --- a/ui/src/lib/workTreeGitStatus.test.ts +++ b/ui/src/lib/workTreeGitStatus.test.ts @@ -4,6 +4,12 @@ import type { WorkTreeEntry } from './types'; import { workTreeGitStatus } from './workTreeGitStatus'; describe('workTreeGitStatus', () => { + it('does not paint sparse-excluded files as deletions or ignored changes', () => { + expect(workTreeGitStatus([ + { path: 'excluded/file.txt', status: null, ignored: false, excluded: true }, + { path: 'included/deleted.txt', status: 'DELETED', ignored: false }, + ])).toEqual([{ path: 'included/deleted.txt', status: 'deleted' }]); + }); it('collapses a fully ignored tree into one muted directory status', () => { const entries: WorkTreeEntry[] = [ { path: 'node_modules/.bin/tool', status: null, ignored: true }, diff --git a/ui/src/lib/workTreeGitStatus.ts b/ui/src/lib/workTreeGitStatus.ts index 5576ae8c..226d1d0e 100644 --- a/ui/src/lib/workTreeGitStatus.ts +++ b/ui/src/lib/workTreeGitStatus.ts @@ -38,6 +38,7 @@ export function workTreeGitStatus( const directoryCounts = new Map(); for (const entry of entries) { + if (entry.excluded) continue; let separator = entry.path.indexOf('/'); while (separator >= 0) { const directory = entry.path.slice(0, separator); @@ -62,6 +63,7 @@ export function workTreeGitStatus( })); for (const entry of entries) { + if (entry.excluded) continue; if (entry.ignored) { if (!hasAncestor(entry.path, ignoredRoots)) { statuses.push({ path: entry.path, status: 'ignored' }); diff --git a/ui/src/plugins/builtins/heroi/HeroiView.tsx b/ui/src/plugins/builtins/heroi/HeroiView.tsx index ce2a5f80..7da2200e 100644 --- a/ui/src/plugins/builtins/heroi/HeroiView.tsx +++ b/ui/src/plugins/builtins/heroi/HeroiView.tsx @@ -392,7 +392,7 @@ export function HeroiView({ setSkills(discoveredSkills); }).catch(() => { if (!current) return; - setRepoFiles(useRepo.getState().workTree.map((entry) => entry.path)); + setRepoFiles(useRepo.getState().workTree.filter((entry) => !entry.excluded).map((entry) => entry.path)); setSkills([]); }); return () => { current = false; }; diff --git a/ui/src/styles/features.css b/ui/src/styles/features.css index 03aa8eaa..8bf5ab60 100644 --- a/ui/src/styles/features.css +++ b/ui/src/styles/features.css @@ -3447,6 +3447,19 @@ button { } /* ─── Clone dialog ─── */ +.clone-options-dialog { max-height: calc(100vh - 100px); } +.clone-options-dialog > .clone-body { overflow: auto; min-height: 0; overflow-wrap: anywhere; } +.clone-options-dialog > .clone-body > * { flex-shrink: 0; } +.clone-options-dialog > .clone-head, .clone-options-dialog > .clone-foot { flex-shrink: 0; } +.clone-advanced { display: flex; flex-direction: column; } +.clone-advanced > summary { cursor: pointer; font-weight: 600; } +.clone-advanced > :not(summary) { margin-top: 12px; } +.clone-scope-actions { display: flex; gap: 8px; flex-wrap: wrap; } +.sparse-directory-list { display: flex; flex-direction: column; gap: 8px; max-height: 240px; overflow: auto; } +.sparse-directory-list label { display: flex; align-items: center; gap: 8px; } +.sparse-directory-list label span { flex: 1; overflow-wrap: anywhere; } +.sparse-directory-list small { color: var(--text-muted); } +.sparse-patterns { white-space: pre-wrap; overflow-wrap: anywhere; } .clone-dialog { width: 520px; max-width: calc(100vw - 64px); diff --git a/ui/src/views/CloneDialog.tsx b/ui/src/views/CloneDialog.tsx index 5dce1149..462b5b31 100644 --- a/ui/src/views/CloneDialog.tsx +++ b/ui/src/views/CloneDialog.tsx @@ -1,11 +1,14 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Dialog } from '../components/Dialog'; +import { Select } from '../components/Select'; import { Icon } from '../components/Icon'; import { startCloneDialogFocusLifecycle } from '../lib/cloneDialogFocus'; import { pickDirectory } from '../lib/dialog'; import { t } from '../lib/i18n'; import { useSettings } from '../stores/settings'; +import type { CloneOptions } from '../lib/types'; +import { positiveDepth } from '../lib/cloneOptions'; /** * Modal for configuring a clone. The user pastes a URL and picks a destination; @@ -18,13 +21,18 @@ export function CloneDialog({ onStartClone, }: { onClose: () => void; - onStartClone: (url: string, dest: string) => void; + onStartClone: (url: string, dest: string, options: CloneOptions) => void; }) { const [url, setUrl] = useState(''); // Seed the destination with the configured default folder (Settings → Git). const [parent, setParent] = useState(() => useSettings.getState().defaultCloneDir ?? ''); const [name, setName] = useState(''); const [nameEdited, setNameEdited] = useState(false); + const [branch, setBranch] = useState(''); + const [depth, setDepth] = useState(''); + const [singleBranch, setSingleBranch] = useState(false); + const [filter, setFilter] = useState(null); + const [recursive, setRecursive] = useState(false); const urlRef = useRef(null); const openerRef = useRef(null); if (openerRef.current === null && document.activeElement instanceof HTMLElement) { @@ -57,7 +65,7 @@ export function CloneDialog({ () => (parent && nameValid ? joinPath(parent, trimmedName) : ''), [parent, nameValid, trimmedName], ); - const canClone = Boolean(url.trim() && dest); + const canClone = Boolean(url.trim() && dest && (!depth || positiveDepth(depth) !== null)); async function chooseParent() { const dir = await pickDirectory(t('clone.pickerTitle'), parent || undefined); @@ -66,13 +74,14 @@ export function CloneDialog({ function start() { if (!canClone) return; - onStartClone(url.trim(), dest); + onStartClone(url.trim(), dest, { branch: branch.trim() || null, depth: positiveDepth(depth), single_branch: singleBranch, filter, recurse_submodules: recursive }); onClose(); } return ( +
+ Clone options + + + {depth && positiveDepth(depth) === null &&

Enter a positive whole number up to 4294967295.

} + +

Shallow history uses less bandwidth and disk. Older history, blame and merge bases may be unavailable until you deepen or download full history. A single-branch clone only fetches that branch on future fetches.

+ +

On-demand contents require server support. Checkout still downloads current files; reading older contents may need a network connection. A server that ignores filtering can send all objects.

+ +

Submodules clone their own repositories and may require additional downloads and credentials. Depth and filter choices above apply to the parent repository.

+
+ {trimmedName && !nameValid ? (
{t('clone.invalidFolder')}
) : dest ? ( diff --git a/ui/src/views/CloneScopeDialog.tsx b/ui/src/views/CloneScopeDialog.tsx new file mode 100644 index 00000000..35796953 --- /dev/null +++ b/ui/src/views/CloneScopeDialog.tsx @@ -0,0 +1,75 @@ +import { useEffect, useRef, useState } from 'react'; +import { Dialog } from '../components/Dialog'; +import { Select } from '../components/Select'; +import { positiveDepth } from '../lib/cloneOptions'; +import { errMessage, isCancelled, tauri } from '../lib/tauri'; +import type { CloneScope, HistoryExpansion } from '../lib/types'; + +export function CloneScopeDialog({ path, busy, progress, onExpand, onCancel, onClose }: { + path: string; + busy: boolean; + progress: string | null; + onCancel: () => void; + onExpand: (path: string, remote: string, expansion: HistoryExpansion) => Promise; + onClose: () => void; +}) { + const [scope, setScope] = useState(null); + const [remote, setRemote] = useState(''); + const [depth, setDepth] = useState('100'); + const [error, setError] = useState(''); + const first = useRef(null); + useEffect(() => { + let current = true; + void tauri.repoCloneScope(path).then((value) => { + if (!current) return; + setScope(value); + setRemote(value.remotes.find((r) => r.name === 'origin')?.name ?? value.remotes[0]?.name ?? ''); + }).catch((e) => { if (current) setError(errMessage(e)); }); + return () => { current = false; }; + }, [path]); + useEffect(() => { + if (!scope || busy) return; + const frame = requestAnimationFrame(() => first.current?.focus()); + return () => cancelAnimationFrame(frame); + }, [scope, busy]); + async function expand(expansion: HistoryExpansion) { + setError(''); + try { + await onExpand(path, remote, expansion); + setScope(await tauri.repoCloneScope(path)); + } catch (e) { setError(isCancelled(e) ? 'History download cancelled.' : errMessage(e)); } + } + const selected = scope?.remotes.find((r) => r.name === remote); + return Cancel download + : + }> +
+ {error &&

{error}

} + {busy &&

{progress || 'Downloading history…'}

} + {!scope ?

Reading repository configuration…

: <> +

{scope.shallow ? 'Shallow repository — older commits and merge bases may be unavailable.' : 'Full history — no shallow boundary.'}

+ + {!scope.remotes.length &&

No remotes configured. Add a remote before downloading history.

} + {selected && <> +

{selected.filter ? `Partial clone filter: ${selected.filter}. Missing file contents may need a network connection.` : 'No partial-clone filter configured for this remote.'}

+
Fetched branches: {selected.fetch_refspecs.length ? selected.fetch_refspecs.map((ref) =>
{ref}
) : 'No fetch refspec configured'}
+ } + {scope.shallow && <> + +
+ + +
+

Downloads use more bandwidth and disk and preserve your current branch and local edits. Full history may remain shallow if the source is shallow. These actions keep the existing branch refspecs and partial-clone filter.

+ } + } +
+
; +} diff --git a/ui/src/views/FileView.tsx b/ui/src/views/FileView.tsx index d91eeb3e..e1cde328 100644 --- a/ui/src/views/FileView.tsx +++ b/ui/src/views/FileView.tsx @@ -330,7 +330,7 @@ function DirectoryTab({ }, [repoPath, revision]); const source = revision ? revisionTree : workTree; - const entries = useMemo(() => directoryEntries(source ?? [], path), [source, path]); + const entries = useMemo(() => directoryEntries((source ?? []).filter((entry) => !entry.excluded), path), [source, path]); const folderCount = entries.filter((entry) => entry.kind === 'directory').length; const fileCount = entries.length - folderCount; diff --git a/ui/src/views/SparseCheckoutDialog.tsx b/ui/src/views/SparseCheckoutDialog.tsx new file mode 100644 index 00000000..45388824 --- /dev/null +++ b/ui/src/views/SparseCheckoutDialog.tsx @@ -0,0 +1,86 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Dialog } from '../components/Dialog'; +import { errMessage, tauri } from '../lib/tauri'; +import type { SparseCheckout } from '../lib/types'; +import { useRepo } from '../stores/repo'; + +export function SparseCheckoutDialog({ path, onClose }: { path: string; onClose: () => void }) { + const [state, setState] = useState(null); + const [selected, setSelected] = useState([]); + const [sparseIndex, setSparseIndex] = useState(false); + const [query, setQuery] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [message, setMessage] = useState(''); + const search = useRef(null); + function accept(value: SparseCheckout) { + setState(value); setSelected(value.directories); setSparseIndex(value.sparse_index); + } + useEffect(() => { + let current = true; + void tauri.repoSparseCheckout(path).then((value) => { if (current) accept(value); }) + .catch((e) => { if (current) setError(errMessage(e)); }); + return () => { current = false; }; + }, [path]); + useEffect(() => { + if (!state || busy) return; + const frame = requestAnimationFrame(() => search.current?.focus()); + return () => cancelAnimationFrame(frame); + }, [state, busy]); + const directories = useMemo(() => [...new Set([...(state?.available ?? []), ...selected])].sort(), [state?.available, selected]); + const matches = useMemo(() => directories.filter((dir) => dir.toLowerCase().includes(query.toLowerCase())), [directories, query]); + const editable = Boolean(state && (!state.enabled || state.cone)); + async function apply(disable: boolean) { + if (busy) return; + setBusy(true); setError(''); setMessage(''); + try { + const output = disable ? await tauri.repoDisableSparseCheckout(path) : await tauri.repoSetSparseCheckout(path, selected, sparseIndex); + accept(await tauri.repoSparseCheckout(path)); + setMessage(output || (disable ? 'Sparse checkout disabled. All tracked files restored.' : 'Sparse directories updated.')); + } catch (e) { setError(errMessage(e)); } + finally { + try { + if (useRepo.getState().activePath === path) { + useRepo.getState().markFilesTreeChanged(path, { kind: 'refresh' }); + await useRepo.getState().refreshLocalChanges(); + } + } catch (e) { setError(errMessage(e)); } + finally { setBusy(false); } + } + } + return + + {state?.enabled && } + + }> +
+ {error &&

{error}

} + {message &&

{message}

} + {!state ?

Reading tracked directories…

: <> +

{state.enabled ? `Enabled · ${state.cone ? 'cone mode' : 'non-cone patterns'} · ${state.sparse_index ? 'sparse index' : 'full index'}` : 'Disabled — all tracked directories are included.'}

+

Select whole directories to keep locally. Root files and files beside selected directories and their ancestors stay included. Excluded paths remain tracked in Git; they are absent locally and are not deletions.

+ {!editable ? <>

These external non-cone patterns can be inspected or disabled. Disable them before choosing cone directories.

{state.patterns}
: <> + + Selection to apply +
+ {matches.slice(0, 100).map((dir) => { + const inherited = selected.some((parent) => dir.startsWith(`${parent}/`)); + const partial = selected.some((child) => child.startsWith(`${dir}/`)); + return ; + })} + {!matches.length &&

No matching directories.

} +
+ {matches.length > 100 &&

Showing 100 of {matches.length}. Narrow the filter to find another directory.

} +

{selected.length} selected. An empty selection keeps root files only. This reduces populated files, not downloaded history.

+ +

A sparse index can speed up system Git in large repositories; older external tools may not support it. Strand reads it without changing its on-disk format.

+ } +

Commit or stash edits and move untracked files before changing this checkout. Strand refuses a selection that could remove ignored files. Restoring files in a partial clone may require a network connection.

+ } +
+
; +} diff --git a/website/docs/keyboard-and-palette.md b/website/docs/keyboard-and-palette.md index fe72feab..b30827d2 100644 --- a/website/docs/keyboard-and-palette.md +++ b/website/docs/keyboard-and-palette.md @@ -272,3 +272,10 @@ the commit graph when pressed again. See [Worktrees](worktrees.md). On macOS, shortcut chips render as tight glyphs (`⌘⇧P`); on Windows and Linux they render as words (`Ctrl+Shift+P`). Either way the bindings are identical modulo `Mod`. + +**Clone repository…**, **Repository history and downloads…**, and **Sparse +checkout…** are searchable palette actions. Within these dialogs, Tab moves +between fields and buttons, Space toggles a checkbox or expands Clone options, +and Enter activates a focused button. Sparse-directory search bounds the visible +list to 100 matches. Escape closes an idle dialog; a running download exposes +**Cancel download** instead. diff --git a/website/docs/repositories-and-workspaces.md b/website/docs/repositories-and-workspaces.md index 709106de..25e1fbcd 100644 --- a/website/docs/repositories-and-workspaces.md +++ b/website/docs/repositories-and-workspaces.md @@ -27,6 +27,59 @@ trust. Network operations shell out to your system git, so SSH keys, credential helpers, and proxies work exactly as they do on the command line. +### Clone options + +Expand **Clone options** before starting: + +- **Branch** chooses the initial branch; blank uses the remote default. +- **History depth** limits ancestry. Blank downloads all history. Older history, + blame and merge bases may be unavailable in a shallow clone. +- **Fetch only the selected branch** also limits future fetches. Depth and + single-branch fetching are independent choices. +- **File contents on demand (blob:none)** keeps historical file contents out of + the initial transfer when the server supports filtering. Checkout still + downloads current files; older content, diffs and blame can require a network + connection. A server that ignores filtering may send all objects. +- **Initialize submodules recursively** clones nested modules too. Their + downloads and credentials are separate; the parent's depth and filter do not + apply to them. + +Use **Repository history and downloads…** in the topbar network menu or command +palette to inspect shallow state, remote filters and fetch refspecs. In a shallow +repository, choose a remote and **Download more history** or **Download full +history**. These fetch ancestry without switching branches or altering local +edits. They preserve the current branch refspecs and partial-clone filter; a +shallow source may not have the entire history. The dialog shows progress and +offers **Cancel download**. This also works for repositories cloned outside Strand. + +### Sparse checkout + +Choose **Sparse checkout…** from the topbar network menu or command palette. +Filter the tracked directories, tick those to keep, and choose **Enable sparse +checkout** or **Apply selection**. Selection uses directories in HEAD. Root files +and files beside a selected directory or its ancestors remain included; an empty +selection keeps root files only. Nested selections label their ancestors as partly +included. Sparse checkout changes populated files, independently of clone depth +and object filtering. + +Sparse-excluded paths remain tracked in Git. The Files pane omits those absent +paths and shows a **Manage** notice; they do not appear as deleted in Local +Changes. Actual deletions inside included directories retain their normal status. +Historical commit trees still show every file at that revision. + +**Use sparse index** retains Git's compressed index format. Strand can read an +externally created sparse index without rewriting it, and stages, commits and +switches branches through Git when sparse checkout is active. Older external +tools may require turning this option off. + +Selection changes and **Disable sparse checkout** refuse tracked edits or +untracked files. Commit or stash edits and move untracked files first. Strand also +refuses a selection that could remove ignored files: include their directory or +move those files yourself. Disabling restores all tracked files and may download +missing contents in a partial clone. Settings apply to the current worktree. +External non-cone patterns can be inspected and disabled; disable them before +selecting cone directories. Submodule lifecycle controls are separate. + ### Default clone & open folder In [Settings](settings.md) → Git, **Default clone & open folder** sets where the clone dialog and the open-repository picker start. Use Choose… to set it and Clear to remove it. From cf157750a44fc08dc933e8c283929a7413a3e04c Mon Sep 17 00:00:00 2001 From: Daniels-Main Date: Sun, 6 Sep 2026 16:38:43 +0200 Subject: [PATCH 5/8] feat(lfs): preserve pointer filters and add repository management --- .github/workflows/ci.yml | 3 +- Cargo.lock | 1 + README.md | 4 + ROADMAP.md | 12 +- TASKS.md | 10 +- crates/strand-core/Cargo.toml | 3 + crates/strand-core/src/apply.rs | 9 + crates/strand-core/src/branch.rs | 9 + crates/strand-core/src/lfs.rs | 506 +++++++++++++++++++++++++++ crates/strand-core/src/lib.rs | 1 + crates/strand-core/src/network.rs | 99 +++++- crates/strand-core/src/reset.rs | 10 +- crates/strand-core/src/stage.rs | 13 + crates/strand-tauri/src/commands.rs | 18 + crates/strand-tauri/src/main.rs | 1 + docs/learnings.md | 14 + ui/src/App.tsx | 7 + ui/src/components/Sidebar.tsx | 4 +- ui/src/lib/lfs.ts | 15 + ui/src/lib/tauri.ts | 3 + ui/src/lib/types.ts | 8 + ui/src/views/LfsDialog.tsx | 66 ++++ website/docs/everyday-git.md | 31 +- website/docs/keyboard-and-palette.md | 5 + 24 files changed, 833 insertions(+), 19 deletions(-) create mode 100644 crates/strand-core/src/lfs.rs create mode 100644 ui/src/lib/lfs.ts create mode 100644 ui/src/views/LfsDialog.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bb171df..24ebd760 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,8 @@ jobs: libayatana-appindicator3-dev \ librsvg2-dev \ libssl-dev \ - build-essential + build-essential \ + git-lfs - name: Setup Rust uses: dtolnay/rust-toolchain@stable diff --git a/Cargo.lock b/Cargo.lock index 4ea18990..44f14e5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5924,6 +5924,7 @@ version = "1.5.1" dependencies = [ "git2", "gix", + "libc", "notify", "serde", "serde_json", diff --git a/README.md b/README.md index 40ac8df7..fc62b7e7 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,10 @@ the resolved app appearance automatically. ## Features +- **Git LFS** — repository setup, tracking patterns, object/transfer status, + downloads/uploads and server locks from the sidebar and command palette. + Whole-file staging, checkout, discard and hard reset honor LFS filters; + history is never migrated. - **Responsive refreshes** — repository updates coalesce during bursts of agent edits, hidden diff panes load patches when opened, and Files reuses its inventory until paths or ignore rules change. Workspace scans run with diff --git a/ROADMAP.md b/ROADMAP.md index f048b0bf..1aa9eabf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2155,7 +2155,9 @@ and Store certification remain external gates. + task breakdown in TASKS.md. Pre-1.0 guardrails (opaque repo paths, everything through the `commands.rs` seam) are active now. - Git-flow (start/finish feature/release/hotfix; shells out to `git-flow`) -- Git LFS (status badges + progress) +- ☑ Git LFS — filter-correct staging/checkout/discard/hard reset and explicit + setup, patterns, object/transfer status and server locks (`LfsDialog`, + `Repo::lfs_action`); real Git fixtures and native dialog verification pass. - GPG / SSH commit signing UI - Selectable beta updater channel (1.0 remains pinned to the signed stable GitHub Releases channel) @@ -2808,6 +2810,14 @@ implementation rows while the July audit is labeled historical. This is a planning update, not a claim that these features shipped; existing local Git, GitHub/Azure review, Workbench and performance work retain their own status. +**LFS implementation shipped (2026-09-06):** Real fixtures exposed raw asset +blobs from git2 staging. LFS paths now stage in one literal NUL-delimited Git +batch; checkout, discard and hard reset honor required filters, and partial +pointer patches are rejected. The lazy sidebar/palette dialog exposes setup, +patterns, objects, transfers and server locks with bounded output and process +tree cancellation. Exact pointer/commit/network/lock fixtures and native +setup, staging, keyboard, cancellation and recovery checks pass. + --- ## Cross-cutting tracks (run in parallel with all milestones) diff --git a/TASKS.md b/TASKS.md index 4c7910bf..4ca2ec58 100644 --- a/TASKS.md +++ b/TASKS.md @@ -99,10 +99,12 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git- - ☐ **F03 / P1 — Signing controls and signed tags.** Keep configured commit signing/verification; add scoped format/key controls and signed-tag creation with agent delegation and visible signing failures. -- ☐ **F04 / P1 — LFS compatibility and management.** First prove pointer/filter - correctness across single/bulk staging, checkout, commit and network flows; - then add setup/tracking/status/locks/progress. System-Git networking alone - does not establish end-to-end LFS support. +- ☑ **F04 / P1 — LFS compatibility and management.** Filter-aware single/bulk + staging, discard, checkout and hard reset; exact pointer/commit/push/pull and + missing-filter fixtures pass (`lfs.rs`). Local setup, patterns, object/transfer + status, bounded locks and cancellable transfers are exposed in `LfsDialog`. + Real lock-API fixtures and native setup/staging/palette/cancellation/recovery + checks pass; no eager LFS network or status subprocesses. - ☐ **F05 / P1 — Submodule lifecycle.** Extend existing open/status/init/update with add/remove/deinit/sync/URL/nested inspection; verify dirty-state handling, `.gitmodules` and index changes, plus cancellable network operations. diff --git a/crates/strand-core/Cargo.toml b/crates/strand-core/Cargo.toml index ac677e40..fcb0c397 100644 --- a/crates/strand-core/Cargo.toml +++ b/crates/strand-core/Cargo.toml @@ -13,3 +13,6 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true tracing.workspace = true + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/crates/strand-core/src/apply.rs b/crates/strand-core/src/apply.rs index f347a294..5256432a 100644 --- a/crates/strand-core/src/apply.rs +++ b/crates/strand-core/src/apply.rs @@ -36,6 +36,15 @@ impl Repo { ApplyTarget::Workdir => (patch.to_owned(), git2::ApplyLocation::WorkDir), }; let diff = git2::Diff::from_buffer(buf.as_bytes())?; + for delta in diff.deltas() { + for file in [delta.old_file(), delta.new_file()] { + if let Some(path) = file.path() { + if self.is_lfs_path(path)? { + return Err(crate::Error::Other("LFS files must be staged, unstaged or discarded as a whole file; partial patches would corrupt the pointer.".into())); + } + } + } + } repo.apply(&diff, location, None)?; Ok(()) } diff --git a/crates/strand-core/src/branch.rs b/crates/strand-core/src/branch.rs index 4c4fe7ee..28083dfc 100644 --- a/crates/strand-core/src/branch.rs +++ b/crates/strand-core/src/branch.rs @@ -55,6 +55,10 @@ impl Repo { let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok()); let tree = branch.get().peel_to_tree()?; + if self.lfs_checkout_needed(&tree)? { + self.run_lfs_filtered(&["checkout", name, "--"])?; + return Ok(CheckoutOutcome { branch: name.to_string() }); + } let mut opts = git2::build::CheckoutBuilder::new(); opts.safe(); repo.checkout_tree(tree.as_object(), Some(&mut opts))?; @@ -153,6 +157,11 @@ impl Repo { let commit = repo.revparse_single(rev)?.peel_to_commit()?; let tree = commit.tree()?; + if self.lfs_checkout_needed(&tree)? { + let oid = commit.id().to_string(); + self.run_lfs_filtered(&["checkout", "--detach", &oid, "--"])?; + return Ok(CheckoutOutcome { branch: oid[..7].to_string() }); + } let mut opts = git2::build::CheckoutBuilder::new(); opts.safe(); repo.checkout_tree(tree.as_object(), Some(&mut opts))?; diff --git a/crates/strand-core/src/lfs.rs b/crates/strand-core/src/lfs.rs new file mode 100644 index 00000000..c2e2ef09 --- /dev/null +++ b/crates/strand-core/src/lfs.rs @@ -0,0 +1,506 @@ +//! Git LFS operations are explicit, lazy, and delegated to the installed Git LFS. + +use crate::{ + network::{run_git_streaming, CancelHandle, NetworkOutcome, Progress}, + Error, Repo, Result, +}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "action", rename_all = "kebab-case")] +pub enum LfsAction { + Environment, + Install, + Patterns, + Track { pattern: String }, + Untrack { pattern: String }, + Status, + Objects, + Fetch { remote: String }, + Pull { remote: String }, + Push { remote: String }, + Locks { path: String }, + Lock { path: String }, + Unlock { id: String }, +} + +fn argument(value: &str, label: &str) -> Result<()> { + if value.trim().is_empty() || value.starts_with('-') || value.contains(['\0', '\n', '\r']) { + return Err(Error::Other(format!( + "Enter a non-empty {label} that does not start with '-' or contain line breaks." + ))); + } + Ok(()) +} + +impl Repo { + /// No LFS subprocesses run in snapshots or ordinary status refreshes. + pub fn lfs_action( + &self, + action: LfsAction, + progress: impl FnMut(Progress), + cancel: Option<&CancelHandle>, + ) -> Result { + let mut args = vec!["lfs".to_string()]; + match action { + LfsAction::Environment => args.push("env".into()), + LfsAction::Install => args.extend(["install".into(), "--local".into()]), + LfsAction::Patterns => args.push("track".into()), + LfsAction::Track { pattern } => { + argument(&pattern, "tracking pattern")?; + args.extend(["track".into(), "--".into(), pattern]); + } + LfsAction::Untrack { pattern } => { + argument(&pattern, "tracking pattern")?; + args.extend(["untrack".into(), "--".into(), pattern]); + } + LfsAction::Status => args.push("status".into()), + LfsAction::Objects => args.extend(["ls-files".into(), "--size".into()]), + LfsAction::Fetch { remote } => { + argument(&remote, "remote")?; + args.extend(["fetch".into(), remote]); + } + LfsAction::Pull { remote } => { + argument(&remote, "remote")?; + args.extend(["pull".into(), remote]); + } + LfsAction::Push { remote } => { + argument(&remote, "remote")?; + args.extend(["push".into(), remote]); + } + LfsAction::Locks { path } => { + args.extend(["locks".into(), "--limit=100".into()]); + if !path.is_empty() { + argument(&path, "lock path")?; + args.push(format!("--path={path}")); + } + } + LfsAction::Lock { path } => { + argument(&path, "file path")?; + args.extend(["lock".into(), "--".into(), path]); + } + LfsAction::Unlock { id } => { + argument(&id, "lock ID")?; + args.extend(["unlock".into(), format!("--id={id}")]); + } + } + run_git_streaming(&self.path, &args.iter().map(String::as_str).collect::>(), progress, cancel) + .map_err(|error| match error { + Error::Cancelled => Error::Cancelled, + other => Error::Other(format!("{other}\nCheck Git LFS installation, repository setup and remote access, then retry. Completed objects are retained; history is never migrated.")), + }) + } + + pub(crate) fn is_lfs_path(&self, path: &Path) -> Result { + Ok(self + .git2()? + .get_attr(path, "filter", git2::AttrCheckFlags::FILE_THEN_INDEX)? + == Some("lfs")) + } + + fn require_lfs_filter(&self) -> Result<()> { + let config = self.git2()?.config()?; + if !config + .get_string("filter.lfs.process") + .is_ok_and(|v| !v.trim().is_empty()) + && !config + .get_string("filter.lfs.clean") + .is_ok_and(|v| !v.trim().is_empty()) + { + return Err(Error::Other("LFS filters are not configured. Open Git LFS → Set up this repository, then retry.".into())); + } + Ok(()) + } + + pub(crate) fn stage_lfs_paths(&self, paths: &[String]) -> Result<()> { + self.run_lfs_paths( + &[ + "--literal-pathspecs", + "add", + "--pathspec-from-file=-", + "--pathspec-file-nul", + ], + paths.iter().map(String::as_str), + ) + } + + pub(crate) fn discard_lfs_paths(&self, paths: &[&str]) -> Result<()> { + self.run_lfs_paths( + &["checkout-index", "--force", "-z", "--stdin"], + paths.iter().copied(), + ) + } + + fn run_lfs_paths<'a>(&self, args: &[&str], paths: impl Iterator) -> Result<()> { + self.require_lfs_filter()?; + let mut input = Vec::new(); + for path in paths { + if path.contains('\0') { + return Err(Error::Other("Invalid file path".into())); + } + input.extend_from_slice(path.as_bytes()); + input.push(0); + } + let mut filtered = vec!["-c", "filter.lfs.required=true"]; + filtered.extend_from_slice(args); + let transcript = crate::network::run_git_input_transcript( + &self.path, + &filtered, + Some(input), + |_| {}, + None, + )?; + if !transcript.success { + return Err(Error::Other(transcript.output)); + } + self.git2()?.index()?.read(true)?; + Ok(()) + } + + pub(crate) fn lfs_checkout_needed(&self, tree: &git2::Tree<'_>) -> Result { + let repo = self.git2()?; + for entry in repo.index()?.iter() { + if let Ok(path) = std::str::from_utf8(&entry.path) { + if self.is_lfs_path(Path::new(path))? { + return Ok(true); + } + } + } + let mut found = false; + let mut attribute_error = None; + tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { + if entry.kind() == Some(git2::ObjectType::Blob) { + if let Some(name) = entry.name() { + match self.is_lfs_path(Path::new(&format!("{root}{name}"))) { + Ok(lfs) => found |= lfs, + Err(error) => attribute_error = Some(error), + } + } + } + if entry.name() == Some(".gitattributes") { + if let Ok(blob) = repo.find_blob(entry.id()) { + found |= String::from_utf8_lossy(blob.content()).contains("filter=lfs"); + } + } + if found { + git2::TreeWalkResult::Skip + } else { + git2::TreeWalkResult::Ok + } + })?; + if let Some(error) = attribute_error { + return Err(error); + } + Ok(found) + } + + pub(crate) fn run_lfs_filtered(&self, args: &[&str]) -> Result<()> { + self.require_lfs_filter()?; + let mut filtered = vec!["-c", "filter.lfs.required=true"]; + filtered.extend_from_slice(args); + run_git_streaming(&self.path, &filtered, |_| {}, None)?; + // In-process fixtures and chained operations may reuse this handle. + self.git2()?.index()?.read(true)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::LfsAction; + use crate::Repo; + use std::{ + path::{Path, PathBuf}, + process::Command, + }; + + fn git(dir: &Path, args: &[&str]) -> String { + let out = Command::new("git") + .current_dir(dir) + .args(args) + .output() + .unwrap(); + assert!( + out.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).unwrap().trim().to_string() + } + + fn fixture() -> (Repo, PathBuf) { + let dir = std::env::temp_dir().join(format!( + "strand-lfs-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + git(&dir, &["init", "-q", "-b", "main"]); + git(&dir, &["config", "user.name", "Test"]); + git(&dir, &["config", "user.email", "test@example.com"]); + git(&dir, &["config", "commit.gpgsign", "false"]); + git(&dir, &["lfs", "install", "--local"]); + git(&dir, &["lfs", "track", "*.bin"]); + (Repo::discover(&dir).unwrap(), dir) + } + + #[test] + fn single_and_bulk_stage_store_real_lfs_pointers() { + let (repo, dir) = fixture(); + std::fs::write(dir.join("one.bin"), b"large content\0one\n").unwrap(); + repo.stage_path("one.bin").unwrap(); + let expected = git(&dir, &["hash-object", "--path=one.bin", "one.bin"]); + assert_eq!(git(&dir, &["rev-parse", ":one.bin"]), expected); + let pointer = git(&dir, &["show", ":one.bin"]); + assert!( + pointer.starts_with("version https://git-lfs.github.com/spec/v1\noid sha256:"), + "{pointer}" + ); + std::fs::write(dir.join("two.bin"), b"large content\0two\n").unwrap(); + repo.stage_paths(&[".gitattributes".into(), "one.bin".into(), "two.bin".into()]) + .unwrap(); + let two = git(&dir, &["show", ":two.bin"]); + assert!(two.starts_with("version https://git-lfs.github.com/spec/v1\noid sha256:")); + repo.commit("LFS assets", None, false).unwrap(); + assert_eq!(git(&dir, &["show", "HEAD:one.bin"]), pointer); + assert_eq!(git(&dir, &["show", "HEAD:two.bin"]), two); + assert!( + repo.status().unwrap().is_empty(), + "clean LFS files must not appear modified" + ); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn checkout_discard_and_network_round_trip_content_and_pointer_bytes() { + let (repo, dir) = fixture(); + let original = b"large content\0first\n"; + let changed = b"large content\0second\n"; + std::fs::write(dir.join("asset.bin"), original).unwrap(); + repo.stage_paths(&[".gitattributes".into(), "asset.bin".into()]) + .unwrap(); + let first = repo.commit("first", None, false).unwrap().oid; + repo.create_branch("next", None, true).unwrap(); + std::fs::write(dir.join("asset.bin"), changed).unwrap(); + repo.stage_path("asset.bin").unwrap(); + let second = repo.commit("second", None, false).unwrap().oid; + let pointer = git(&dir, &["show", "HEAD:asset.bin"]); + repo.checkout_branch("main").unwrap(); + assert_eq!(std::fs::read(dir.join("asset.bin")).unwrap(), original); + repo.checkout_branch("next").unwrap(); + assert_eq!(std::fs::read(dir.join("asset.bin")).unwrap(), changed); + std::fs::write(dir.join("asset.bin"), b"uncommitted").unwrap(); + assert!(repo.checkout_branch("main").is_err()); + assert_eq!( + std::fs::read(dir.join("asset.bin")).unwrap(), + b"uncommitted" + ); + repo.discard_path("asset.bin").unwrap(); + assert_eq!(std::fs::read(dir.join("asset.bin")).unwrap(), changed); + repo.checkout_commit(&first).unwrap(); + assert_eq!(std::fs::read(dir.join("asset.bin")).unwrap(), original); + repo.checkout_branch("next").unwrap(); + + repo.reset(&first, crate::reset::ResetMode::Hard).unwrap(); + assert_eq!(std::fs::read(dir.join("asset.bin")).unwrap(), original); + repo.reset(&second, crate::reset::ResetMode::Hard).unwrap(); + assert_eq!(std::fs::read(dir.join("asset.bin")).unwrap(), changed); + + let remote = dir.join("upstream.git"); + git(&dir, &["init", "--bare", remote.to_str().unwrap()]); + git(&dir, &["remote", "add", "origin", remote.to_str().unwrap()]); + repo.push_current_to_remote("origin", true, |_| {}, None) + .unwrap(); + let consumer = dir.join("consumer"); + // Git LFS 3.5 installs post-checkout during smudge, which newer Git's + // clone protection refuses. Keep clone configuration in F09's scope: + // acquire objects without checkout, then exercise Strand's checkout. + git( + &dir, + &[ + "clone", + "--no-checkout", + "--branch", + "next", + remote.to_str().unwrap(), + consumer.to_str().unwrap(), + ], + ); + Repo::discover(&consumer) + .unwrap() + .checkout_branch("next") + .unwrap(); + assert_eq!(std::fs::read(consumer.join("asset.bin")).unwrap(), changed); + assert_eq!(git(&consumer, &["show", "HEAD:asset.bin"]), pointer); + std::fs::write(dir.join("asset.bin"), b"third\0version").unwrap(); + repo.stage_path("asset.bin").unwrap(); + repo.commit("third", None, false).unwrap(); + repo.push_current_to_remote("origin", true, |_| {}, None) + .unwrap(); + Repo::discover(&consumer) + .unwrap() + .pull( + crate::network::PullMode::FastForwardOnly, + false, + |_| {}, + None, + ) + .unwrap(); + assert_eq!( + std::fs::read(consumer.join("asset.bin")).unwrap(), + b"third\0version" + ); + assert_eq!( + git(&consumer, &["show", "HEAD:asset.bin"]), + git(&dir, &["show", "HEAD:asset.bin"]) + ); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn missing_filter_fails_without_writing_raw_bytes_or_losing_bulk_index() { + let (repo, dir) = fixture(); + std::fs::write(dir.join("asset.bin"), b"content\0").unwrap(); + std::fs::write(dir.join("keep.txt"), b"keep").unwrap(); + repo.stage_path("keep.txt").unwrap(); + let before = git(&dir, &["write-tree"]); + git( + &dir, + &[ + "config", + "filter.lfs.process", + "strand-missing-git-lfs filter-process", + ], + ); + assert!(repo.stage_path("asset.bin").is_err()); + assert!(repo + .stage_paths(&["keep.txt".into(), "asset.bin".into()]) + .is_err()); + assert_eq!(git(&dir, &["write-tree"]), before); + assert_eq!(std::fs::read(dir.join("asset.bin")).unwrap(), b"content\0"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn management_tracks_without_history_migration_and_reports_server_locks() { + use std::io::{Read, Write}; + let (repo, dir) = fixture(); + repo.stage_path(".gitattributes").unwrap(); + let head = repo.commit("attributes", None, false).unwrap().oid; + repo.lfs_action(LfsAction::Install, |_| {}, None).unwrap(); + repo.lfs_action( + LfsAction::Track { + pattern: "*.psd".into(), + }, + |_| {}, + None, + ) + .unwrap(); + assert!(repo + .lfs_action(LfsAction::Patterns, |_| {}, None) + .unwrap() + .output + .contains("*.psd")); + repo.lfs_action( + LfsAction::Untrack { + pattern: "*.psd".into(), + }, + |_| {}, + None, + ) + .unwrap(); + assert!(!std::fs::read_to_string(dir.join(".gitattributes")) + .unwrap() + .contains("*.psd")); + assert_eq!(git(&dir, &["rev-parse", "HEAD"]), head); + for action in [ + LfsAction::Environment, + LfsAction::Status, + LfsAction::Objects, + ] { + repo.lfs_action(action, |_| {}, None).unwrap(); + } + let server = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + server.set_nonblocking(true).unwrap(); + git( + &dir, + &[ + "config", + "lfs.url", + &format!("http://{}", server.local_addr().unwrap()), + ], + ); + let worker = std::thread::spawn(move || { + let lock = r#"{"id":"1","path":"asset.bin","locked_at":"2026-09-06T10:00:00Z","owner":{"name":"Test"}}"#; + for (request, body) in [ + ("POST /locks", format!("{{\"lock\":{lock}}}")), + ("GET /locks?", format!("{{\"locks\":[{lock}]}}")), + ("POST /locks/1/unlock", format!("{{\"lock\":{lock}}}")), + ] { + let started = std::time::Instant::now(); + let mut stream = loop { + match server.accept() { + Ok((stream, _)) => break stream, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + started.elapsed().as_secs() < 120, + "LFS did not contact lock server" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Err(e) => panic!("{e}"), + } + }; + stream + .set_read_timeout(Some(std::time::Duration::from_secs(30))) + .unwrap(); + let mut received = Vec::new(); + let mut byte = [0]; + while !received.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).unwrap(); + received.push(byte[0]); + assert!(received.len() < 16_384); + } + assert!( + String::from_utf8_lossy(&received).starts_with(request), + "{}", + String::from_utf8_lossy(&received) + ); + let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/vnd.git-lfs+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); + stream.write_all(response.as_bytes()).unwrap(); + } + }); + std::fs::write(dir.join("asset.bin"), b"content").unwrap(); + repo.lfs_action( + LfsAction::Lock { + path: "asset.bin".into(), + }, + |_| {}, + None, + ) + .unwrap(); + assert!(repo + .lfs_action( + LfsAction::Locks { + path: String::new() + }, + |_| {}, + None + ) + .unwrap() + .output + .contains("asset.bin")); + repo.lfs_action(LfsAction::Unlock { id: "1".into() }, |_| {}, None) + .unwrap(); + worker.join().unwrap(); + let cancel = crate::network::CancelHandle::new(); + cancel.cancel(); + assert!(matches!( + repo.lfs_action(LfsAction::Status, |_| {}, Some(&cancel)), + Err(crate::Error::Cancelled) + )); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/strand-core/src/lib.rs b/crates/strand-core/src/lib.rs index e2dc385a..0bce37f5 100644 --- a/crates/strand-core/src/lib.rs +++ b/crates/strand-core/src/lib.rs @@ -28,6 +28,7 @@ pub mod refs; pub mod branch; pub mod remote; pub mod maintenance; +pub mod lfs; pub mod conflict; pub mod external; pub mod gitconfig; diff --git a/crates/strand-core/src/network.rs b/crates/strand-core/src/network.rs index dbd5eb8b..ff5fd2b9 100644 --- a/crates/strand-core/src/network.rs +++ b/crates/strand-core/src/network.rs @@ -13,7 +13,7 @@ //! callback to an IPC `Channel` so the UI can show a live progress bar; the //! core stays UI-agnostic. -use std::io::Read; +use std::io::{Read, Write}; use std::path::Path; use std::process::Stdio; use std::sync::{Arc, Mutex}; @@ -44,7 +44,7 @@ impl CancelHandle { let mut inner = self.0.lock().expect("cancel handle lock"); inner.cancelled = true; if let Some(child) = inner.child.as_mut() { - let _ = child.kill(); + kill_git_tree(child); } } @@ -53,6 +53,23 @@ impl CancelHandle { } } +// Git LFS and submodule helpers inherit the pipes. Killing only git can leave +// those helpers transferring (and the reader waiting for EOF) after Cancel. +fn kill_git_tree(child: &mut std::process::Child) { + if matches!(child.try_wait(), Ok(Some(_))) { return; } + #[cfg(windows)] + { + 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(); + } + #[cfg(unix)] + unsafe { libc::kill(-(child.id() as i32), libc::SIGKILL); } + let _ = child.kill(); +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NetworkOutcome { /// Combined stdout + stderr from `git`, trimmed. Surfaced to the UI so @@ -590,16 +607,31 @@ pub(crate) fn run_git_streaming( pub(crate) fn run_git_streaming_transcript( cwd: &Path, args: &[&str], + on_progress: impl FnMut(Progress), + cancel: Option<&CancelHandle>, +) -> Result { + run_git_input_transcript(cwd, args, None, on_progress, cancel) +} + +pub(crate) fn run_git_input_transcript( + cwd: &Path, + args: &[&str], + input: Option>, mut on_progress: impl FnMut(Progress), cancel: Option<&CancelHandle>, ) -> Result { - let mut child = crate::git_command() + if cancel.is_some_and(CancelHandle::is_cancelled) { return Err(Error::Cancelled); } + let mut command = crate::git_command(); + #[cfg(unix)] + { use std::os::unix::process::CommandExt; command.process_group(0); } + let mut child = 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) // Force progress reporting even though stderr isn't a TTY. .args(args) + .stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() }) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() @@ -607,10 +639,17 @@ pub(crate) fn run_git_streaming_transcript( // Drain stdout on a separate thread so a large stdout can't deadlock us // while we're blocked reading stderr (and vice-versa). + let input_handle = input.zip(child.stdin.take()).map(|(input, mut stdin)| { + std::thread::spawn(move || stdin.write_all(&input)) + }); let stdout_handle = child.stdout.take().map(|mut out| { std::thread::spawn(move || { let mut s = String::new(); - let _ = out.read_to_string(&mut s); + let mut buffer = [0u8; 8192]; + while let Ok(n) = out.read(&mut buffer) { + if n == 0 { break; } + append_output(&mut s, &String::from_utf8_lossy(&buffer[..n])); + } s }) }); @@ -623,7 +662,7 @@ pub(crate) fn run_git_streaming_transcript( { let mut inner = handle.0.lock().expect("cancel handle lock"); if inner.cancelled { - let _ = child.kill(); + kill_git_tree(&mut child); let _ = child.wait(); return Err(Error::Cancelled); } @@ -631,21 +670,24 @@ pub(crate) fn run_git_streaming_transcript( } let mut collected = String::new(); + let mut last_progress = std::time::Instant::now() - std::time::Duration::from_secs(1); if let Some(stderr) = stderr { // `git` delimits progress updates with '\r' and ends phases with // '\n', so we split on either. BufRead::lines would coalesce all the // '\r' updates into one line — we want each fragment. for_each_fragment(stderr, |frag| { - collected.push_str(frag); - collected.push('\n'); + append_output(&mut collected, frag); + append_output(&mut collected, "\n"); let p = parse_progress(frag); - if !p.raw.is_empty() { + if !p.raw.is_empty() && (p.percent == Some(100) || last_progress.elapsed().as_millis() >= 100) { on_progress(p); + last_progress = std::time::Instant::now(); } }); } let stdout_str = stdout_handle.and_then(|h| h.join().ok()).unwrap_or_default(); + if let Some(writer) = input_handle { let _ = writer.join(); } // Stderr hit EOF, so the process is done (or killed) — take the child // back out and reap it. After this point a late `cancel()` is a no-op. let status = { @@ -667,6 +709,43 @@ pub(crate) fn run_git_streaming_transcript( }) } +/// Retain a bounded tail, including an explicit marker when output is partial. +fn append_output(output: &mut String, text: &str) { + const LIMIT: usize = 65_536; + const MARKER: &str = "[Earlier output omitted; showing bounded tail]\n"; + output.push_str(text); + if output.len() > LIMIT { + let mut start = output.len() - (LIMIT - MARKER.len()); + while !output.is_char_boundary(start) { start += 1; } + output.drain(..start); + output.insert_str(0, MARKER); + } +} + +#[cfg(test)] +mod bounded_process_tests { + use super::*; + + #[test] + fn output_tail_stays_bounded_and_marks_partial_unicode_output() { + let mut output = String::new(); + for _ in 0..100 { append_output(&mut output, &"é".repeat(8192)); } + assert!(output.len() <= 65_536); + assert!(output.starts_with("[Earlier output omitted")); + } + + #[test] + fn cancellation_kills_helpers_holding_progress_pipes() { + 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"], + |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"); + } +} + /// Pull the meaningful failure out of a git transcript. git streams progress to /// stderr too, so the full combined output is mostly "Resolving deltas: NN%" /// noise with the actual `fatal:` / `error:` line buried at the very end — @@ -708,6 +787,10 @@ fn for_each_fragment(reader: impl Read, mut sink: impl FnMut(&str)) { } } else { buf.push(b); + if buf.len() == 8192 { + sink(&String::from_utf8_lossy(&buf)); + buf.clear(); + } } } Err(_) => break, diff --git a/crates/strand-core/src/reset.rs b/crates/strand-core/src/reset.rs index 3a56c940..cdea0fd9 100644 --- a/crates/strand-core/src/reset.rs +++ b/crates/strand-core/src/reset.rs @@ -80,9 +80,13 @@ impl Repo { ResetMode::Soft => repo.reset(&obj, git2::ResetType::Soft, None)?, ResetMode::Mixed => repo.reset(&obj, git2::ResetType::Mixed, None)?, ResetMode::Hard => { - let mut co = git2::build::CheckoutBuilder::new(); - co.force(); - repo.reset(&obj, git2::ResetType::Hard, Some(&mut co))?; + if self.lfs_checkout_needed(&obj.peel_to_tree()?)? { + self.run_lfs_filtered(&["reset", "--hard", &obj.id().to_string(), "--"])?; + } else { + let mut co = git2::build::CheckoutBuilder::new(); + co.force(); + repo.reset(&obj, git2::ResetType::Hard, Some(&mut co))?; + } } } diff --git a/crates/strand-core/src/stage.rs b/crates/strand-core/src/stage.rs index d1c1b7b7..631bbc73 100644 --- a/crates/strand-core/src/stage.rs +++ b/crates/strand-core/src/stage.rs @@ -6,6 +6,9 @@ impl Repo { /// Stage `path` — adds new/modified files, records deletions. Mirrors /// `git add ` for one path at a time. pub fn stage_path(&self, path: &str) -> Result<()> { + if self.is_lfs_path(Path::new(path))? { + return self.stage_lfs_paths(&[path.to_owned()]); + } let repo = self.git2()?; let mut index = repo.index()?; @@ -30,6 +33,11 @@ impl Repo { if paths.is_empty() { return Ok(()); } + for path in paths { + if self.is_lfs_path(Path::new(path))? { + return self.stage_lfs_paths(paths); + } + } let repo = self.git2()?; let mut index = repo.index()?; let workdir = repo.workdir().map(Path::to_path_buf); @@ -97,6 +105,11 @@ impl Repo { } } if !tracked.is_empty() { + for path in &tracked { + if self.is_lfs_path(Path::new(path))? { + return self.discard_lfs_paths(&tracked); + } + } 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 diff --git a/crates/strand-tauri/src/commands.rs b/crates/strand-tauri/src/commands.rs index e0ec2642..fd07bdb6 100644 --- a/crates/strand-tauri/src/commands.rs +++ b/crates/strand-tauri/src/commands.rs @@ -23,6 +23,7 @@ use strand_core::{ gitconfig::{self, GlobalIdentity}, init::{init_repository, InitOutcome}, maintenance::{MaintenanceOutcome, MaintenanceTask}, + lfs::LfsAction, history::{MergeMode, RebaseEntry, RebaseStep}, log::{Commit, SearchMode}, network::{clone as core_clone, CancelHandle, CloneOutcome, NetworkOutcome, Progress, PullMode, PushMode}, reflog::ReflogEntry, @@ -1496,6 +1497,23 @@ pub async fn repo_maintenance( result } +#[tauri::command(async)] +pub async fn repo_lfs_action( + path: String, + action: LfsAction, + op_id: Option, + on_event: Channel, + state: State<'_, AppState>, +) -> CmdResult { + let cancel = CancelHandle::new(); + register_op(&state, &op_id, OperationCancelHandle::Network(cancel.clone())); + let result = run_blocking("Git LFS", move || { + Repo::discover(&path)?.lfs_action(action, |p| { let _ = on_event.send(p); }, Some(&cancel)).map_err(CmdError::from) + }).await; + deregister_op(&state, &op_id); + result +} + #[tauri::command(async)] pub fn repo_tag_create( path: String, diff --git a/crates/strand-tauri/src/main.rs b/crates/strand-tauri/src/main.rs index 0b3fad89..7167b6b5 100644 --- a/crates/strand-tauri/src/main.rs +++ b/crates/strand-tauri/src/main.rs @@ -274,6 +274,7 @@ fn main() { commands::repo_remote_set_urls, commands::repo_remote_set_default, commands::repo_maintenance, + commands::repo_lfs_action, commands::repo_tag_create, commands::repo_tag_delete, commands::repo_tag_push, diff --git a/docs/learnings.md b/docs/learnings.md index e50db001..7bfae244 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -6,6 +6,20 @@ that future work (yours or another agent's) needs to respect. --- +## LFS files need Git's external clean/smudge filters (2026-09-06) + +The real Git LFS fixture proved git2 `index.add_path` stored raw asset bytes +instead of the pointer produced by `git hash-object --path`. The historical +index-on-git2 policy has an LFS exception: detect `filter=lfs` attributes and +stage the complete batch with one literal, NUL-delimited Git pathspec input. +Enforce `filter.lfs.required` so missing tooling cannot silently store raw data. +Whole-file checkout/discard and hard reset use Git for LFS; partial patches are refused. +Ordinary status must not start LFS subprocesses. Management reads are explicit, +transcripts bounded, and cancellation must terminate LFS/submodule descendants +that otherwise keep pipes open. Tracking edits attributes, never history. + +--- + ## Closeable tabs follow browser closing conventions **Rule.** Every closeable repository or Work tab supports its visible close diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ea335854..15d8b368 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -32,6 +32,8 @@ import { buildContentReportUrl, buildCrashIssueUrl } from './lib/crashReport'; import { pickCodeWorkspaceFile, pickRepoDirectories } from './lib/dialog'; import { editorTemplate, osType, terminalTemplate } from './lib/integrations'; import { t } from './lib/i18n'; +import { LFS_ACTIONS } from './lib/lfs'; +import type { LfsAction } from './lib/types'; import { plural } from './lib/plural'; import { concatPatches, patchesToMarkdown } from './lib/patchExport'; import { buildReviewFeedback, collectFeedbackFiles } from './lib/reviewExport'; @@ -119,6 +121,7 @@ const SettingsDialog = lazy(() => import('./views/SettingsDialog').then((m) => ( const BranchCleanupDialog = lazy(() => import('./views/BranchCleanupDialog').then((m) => ({ default: m.BranchCleanupDialog }))); const RebaseEditor = lazy(() => import('./views/RebaseEditor').then((m) => ({ default: m.RebaseEditor }))); const MaintenanceDialog = lazy(() => import('./views/MaintenanceDialog').then((m) => ({ default: m.MaintenanceDialog }))); +const LfsDialog = lazy(() => import('./views/LfsDialog').then((m) => ({ default: m.LfsDialog }))); const WorkspaceManagerDialog = lazy(() => import('./views/WorkspaceManagerDialog').then((m) => ({ default: m.WorkspaceManagerDialog }))); const PullRequests = lazy(() => import('./views/PullRequests').then((m) => ({ default: m.PullRequests }))); @@ -351,6 +354,7 @@ export function App() { // null = closed; otherwise which remote-management flavour (add/rename/url). const [remoteDialog, setRemoteDialog] = useState(null); const [maintenanceOpen, setMaintenanceOpen] = useState(false); + const [lfsAction, setLfsAction] = useState<{ repoPath: string; action: LfsAction['action'] } | null>(null); const [fileEntryDialog, setFileEntryDialog] = useState<{ dir: string; directory: boolean } | null>(null); // null = closed; otherwise the branch to rename. const [renameBranchDialog, setRenameBranchDialog] = useState<{ name: string } | null>(null); @@ -1875,6 +1879,7 @@ export function App() { ] : []), { id: 'remote-add', label: 'Add remote…', group: 'Actions', keywords: 'remote origin upstream url add', run: () => setRemoteDialog({ kind: 'add' }) }, + ...LFS_ACTIONS.map(([action, label]) => ({ id: `lfs-${action}`, label: `Git LFS: ${label}…`, group: 'Actions', keywords: 'large file storage lfs objects filters patterns transfers locks', run: () => { setPaletteOpen(false); setLfsAction({ repoPath: meta.path, action }); } } satisfies PaletteAction)), { id: 'repository-maintenance', label: 'Repository maintenance…', group: 'Actions', keywords: 'git gc fsck integrity optimize activity log command output', run: () => { setPaletteOpen(false); setMaintenanceOpen(true); @@ -2221,6 +2226,7 @@ export function App() { onMerge={(source, into) => setMergeDialog({ source, into })} onInteractiveRebase={(base, label) => setRebaseDialog({ base, label })} onManageRemote={(mode) => setRemoteDialog(mode)} + onManageLfs={() => { if (meta) setLfsAction({ repoPath: meta.path, action: 'environment' }); }} onRenameBranch={(name) => setRenameBranchDialog({ name })} onManageBranchNetwork={(mode) => setBranchNetworkDialog(mode)} onPull={onPull} @@ -2386,6 +2392,7 @@ export function App() { {maintenanceOpen && meta && ( setMaintenanceOpen(false)} onToast={showToast} /> )} + {lfsAction && setLfsAction(null)} />} {fileEntryDialog && meta && ( void; onOpenWorkbench: () => void; onOpenWorkSurface: () => void; onOpenRepo: () => void; @@ -176,7 +177,7 @@ function sortTree(node: TreeNode, leafCmp: (a: T, b: T) => number): void { // ─── component ────────────────────────────────────────────────────────── -export function Sidebar({ onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, onCreateBranch, onBranchFromStash, onCreateWorktree, onMerge, onInteractiveRebase, onManageRemote, onRenameBranch, onManageBranchNetwork, onPull, onPush, onForcePush, onFetchBranch, onPullBranch, onOpenFileInEditor, onCreateFileEntry, onToast }: SidebarProps) { +export function Sidebar({ onManageLfs, onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, onCreateBranch, onBranchFromStash, onCreateWorktree, onMerge, onInteractiveRebase, onManageRemote, onRenameBranch, onManageBranchNetwork, onPull, onPush, onForcePush, onFetchBranch, onPullBranch, onOpenFileInEditor, onCreateFileEntry, onToast }: SidebarProps) { const view = useRepo((s) => s.view); const setView = useRepo((s) => s.setView); const selectFile = useRepo((s) => s.selectFile); @@ -1016,6 +1017,7 @@ export function Sidebar({ onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpen /> )}
+ {meta && } invoke('repo_tree_at', { path, rev }), repoSubmodules: (path: string) => invoke('repo_submodules', { path }), + repoLfsAction: (path: string, action: LfsAction, opId: string, onProgress?: (p: Progress) => void) => + invoke('repo_lfs_action', { path, action, opId, onEvent: progressChannel(onProgress) }), repoSubmoduleUpdate: ( path: string, paths: string[], diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index c27be6a0..1bf8c983 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -587,6 +587,14 @@ export type FilesTreeMutation = FilesTreeMutationChange & { /** A submodule's state relative to the superproject's recorded commit. */ export type SubmoduleState = 'uninitialized' | 'up-to-date' | 'out-of-date' | 'modified'; +export type LfsAction = + | { action: 'environment' | 'install' | 'patterns' | 'status' | 'objects' } + | { action: 'track' | 'untrack'; pattern: string } + | { action: 'fetch' | 'pull' | 'push'; remote: string } + | { action: 'locks'; path: string } + | { action: 'lock'; path: string } + | { action: 'unlock'; id: string }; + export interface Submodule { name: string; /** Path within the superproject working tree (forward-slashed). */ diff --git a/ui/src/views/LfsDialog.tsx b/ui/src/views/LfsDialog.tsx new file mode 100644 index 00000000..eb706158 --- /dev/null +++ b/ui/src/views/LfsDialog.tsx @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState } from 'react'; +import { Dialog } from '../components/Dialog'; +import { Select } from '../components/Select'; +import { LFS_ACTIONS } from '../lib/lfs'; +import { errMessage, isCancelled, tauri } from '../lib/tauri'; +import type { LfsAction } from '../lib/types'; +import { useRepo } from '../stores/repo'; + +export function LfsDialog({ path, initialAction = 'environment', onClose }: { + path: string; initialAction?: LfsAction['action']; onClose: () => void; +}) { + const [action, setAction] = useState(initialAction); + const [value, setValue] = useState(''); + const [remote, setRemote] = useState('origin'); + const [running, setRunning] = useState(null); + const busy = useRef(false); + const [output, setOutput] = useState('Choose an action and run it to inspect or manage Git LFS.'); + const [error, setError] = useState(false); + const [progress, setProgress] = useState(''); + const focus = useRef(null); + useEffect(() => { + const frame = requestAnimationFrame(() => focus.current?.focus()); + return () => cancelAnimationFrame(frame); + }, []); + const transfer = action === 'fetch' || action === 'pull' || action === 'push'; + const parameter = action === 'track' || action === 'untrack' ? 'Pattern' : action === 'lock' ? 'Repository-relative file path' : action === 'unlock' ? 'Lock ID' : action === 'locks' ? 'Filter by exact path (optional)' : null; + + async function run() { + if (busy.current) return; + busy.current = true; + const opId = crypto.randomUUID(); + setRunning(opId); setError(false); setProgress('Starting…'); + const request: LfsAction = action === 'track' || action === 'untrack' ? { action, pattern: value } + : action === 'fetch' || action === 'pull' || action === 'push' ? { action, remote } + : action === 'lock' ? { action, path: value } + : action === 'unlock' ? { action, id: value } + : action === 'locks' ? { action, path: value } : { action }; + try { + const result = await tauri.repoLfsAction(path, request, opId, (p) => setProgress(p.raw)); + setOutput(result.output || 'Completed. Git LFS produced no output.'); + } catch (e) { + setError(true); + setOutput(isCancelled(e) ? 'Cancelled. Completed objects are retained. Inspect status, then retry when ready.' : errMessage(e)); + } finally { + setRunning(null); busy.current = false; setProgress(''); + if (['install', 'track', 'untrack', 'pull', 'lock', 'unlock'].includes(action) && useRepo.getState().activePath === path) { + await useRepo.getState().refreshLocalChanges().catch((e) => { setError(true); setOutput(`Refresh failed: ${errMessage(e)}`); }); + } + } + } + + return {running ? + : <>}}> +
+

Setup configures this repository and installs its pre-push hook. Tracking edits .gitattributes; review and stage it with the files you want to track. Existing history is never converted.

+ + {parameter && } + {transfer && } + {action === 'locks' &&

Shows at most 100 locks. Narrow by exact file path for larger repositories. Locking requires support from the remote server.

} + {action === 'objects' &&

An asterisk marks full content in the working tree; a dash marks a pointer. Large listings show a bounded tail.

} +
{progress}
+
{output}
+
+
; +} diff --git a/website/docs/everyday-git.md b/website/docs/everyday-git.md index cf6223f9..f949f1fe 100644 --- a/website/docs/everyday-git.md +++ b/website/docs/everyday-git.md @@ -104,7 +104,36 @@ The first push of a new local branch creates the same-named branch on `origin` a | `Mod+Shift+Y` | Fetch | | `Mod+Shift+S` | Sync (fetch + pull + push) | -Network operations shell out to your system git, so **credential helpers, SSH keys and agents, and proxy settings just work** — Strand never asks for credentials of its own. Content filters configured in your git (such as Git LFS) run as they do on the command line, though Strand has no dedicated LFS UI yet. +Network operations shell out to your system git, so **credential helpers, SSH keys and agents, and proxy settings just work** — Strand never asks for credentials of its own. + +### Git LFS + +Open **Git LFS** in the sidebar or search **Git LFS:** in the command palette. +Select an action, fill its fields, then choose **Run action**. Reads are explicit: +opening the dialog does not start an object scan or contact the remote. + +- **Installation and configuration** shows the installed version and effective + LFS environment. Install Git LFS separately if Git reports it missing. +- **Set up this repository** runs `git lfs install --local`, including the + pre-push hook. Existing conflicting hooks are reported, never overwritten. +- **Tracked patterns**, **Track a pattern**, and **Stop tracking a pattern** + inspect/edit `.gitattributes`. Review and stage that file and the intended + assets in Local Changes. Tracking does not rewrite existing commits. +- **Object and transfer status** shows Git LFS's queued changes; **List objects + and sizes** lists current LFS files (`*` is full content, `-` is a pointer). +- **Download objects**, **Download and check out objects**, and **Upload objects** + use the named Git remote. Downloads can be retried after cancellation; completed + objects remain in the local LFS cache. +- **List locks**, **Lock a file**, and **Unlock by ID** use the server's lock API. + The list is limited to 100; filter by an exact path to inspect other files. + Unsupported locking, authentication failures and offline errors remain visible. + +Whole-file staging (including Stage all), discard, hard reset and branch/revision checkout +run the required LFS filters. Missing filters fail rather than stage raw assets. +LFS files cannot be partially staged or discarded: use the whole-file action. +Operations show bounded output/progress and **Cancel operation**. After an error, +inspect status, correct the installation/configuration or remote access, and retry. +There is no LFS history migration operation in Strand. ## The sidebar Git tab diff --git a/website/docs/keyboard-and-palette.md b/website/docs/keyboard-and-palette.md index fe72feab..1ae4a90f 100644 --- a/website/docs/keyboard-and-palette.md +++ b/website/docs/keyboard-and-palette.md @@ -85,6 +85,11 @@ integrity check, incremental Git maintenance, or guarded garbage collection. Use `Tab` to move between actions and activity entries, `Enter` to run or expand one, and `Escape` to close when no operation is running. +Search **Git LFS:** for each management action. The dialog focuses the action +selector; use arrow keys to choose, `Tab` to reach fields and buttons, and +`Enter` to run. While work runs, **Cancel operation** stops it; `Escape` closes +the dialog once it finishes. + **New file…** and **New folder…** open a focus-trapped path dialog for the active repository. The Files sidebar exposes the same actions from its **+** menu; use the arrow keys and Enter to choose one, or Escape to close it. Focus a From 41e0b7de37200aed71f8db90442a82ec9b3d78e6 Mon Sep 17 00:00:00 2001 From: Daniels-Main Date: Sun, 6 Sep 2026 17:00:56 +0200 Subject: [PATCH 6/8] feat(submodules): add guarded lifecycle management and nested inspection --- README.md | 3 + ROADMAP.md | 11 +- TASKS.md | 13 +- crates/strand-core/src/submodule.rs | 590 ++++++++++++++++++++++- crates/strand-tauri/src/commands.rs | 30 +- crates/strand-tauri/src/main.rs | 2 + docs/git-assets-validation-2026-09-06.md | 73 +++ docs/learnings.md | 15 + ui/src/App.tsx | 13 +- ui/src/components/Sidebar.tsx | 26 +- ui/src/lib/submodules.ts | 11 + ui/src/lib/tauri.ts | 7 + ui/src/lib/types.ts | 10 + ui/src/styles/features.css | 5 + ui/src/views/SubmoduleDialog.tsx | 107 ++++ website/docs/everyday-git.md | 39 +- website/docs/keyboard-and-palette.md | 9 +- 17 files changed, 913 insertions(+), 51 deletions(-) create mode 100644 docs/git-assets-validation-2026-09-06.md create mode 100644 ui/src/lib/submodules.ts create mode 100644 ui/src/views/SubmoduleDialog.tsx diff --git a/README.md b/README.md index fc62b7e7..f296d83d 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,9 @@ the resolved app appearance automatically. downloads/uploads and server locks from the sidebar and command palette. Whole-file staging, checkout, discard and hard reset honor LFS filters; history is never migrated. +- **Submodule lifecycle** — add, remove, deinitialize, synchronize URLs, and + inspect nested modules in pages. Network work is cancellable; dirty module + contents, ignored files and unrecorded commits block removal/deinitialization. - **Responsive refreshes** — repository updates coalesce during bursts of agent edits, hidden diff panes load patches when opened, and Files reuses its inventory until paths or ignore rules change. Workspace scans run with diff --git a/ROADMAP.md b/ROADMAP.md index 1aa9eabf..ef4c9aa6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2166,7 +2166,9 @@ and Store certification remain external gates. - Guided Git bisect - Sparse checkout (cone mode first) - Patch import/mailbox and Git bundle workflows -- Expanded submodule lifecycle (add/remove/deinit/sync/URL/nested status) +- ☑ Expanded submodule lifecycle — guarded add/remove/deinit/sync/URL, paged + nested inspection and cancellable updates (`SubmoduleDialog`); real Git + preservation fixtures and native lifecycle/keyboard checks pass. - Repository/ref/file custom actions with safe argv templates - **CLI companion binary (`strand`)** — `strand ` opens the repo in the app; `strand diff/log/status/review --json` gives AI agents @@ -2818,6 +2820,13 @@ patterns, objects, transfers and server locks with bounded output and process tree cancellation. Exact pointer/commit/network/lock fixtures and native setup, staging, keyboard, cancellation and recovery checks pass. +**Submodule lifecycle shipped (2026-09-06):** Added explicit add/remove/deinit, +URL changes/sync, lazy paged nested inspection, repository opening and +cancellable updates through the sidebar and palette. Removal and deinit retain +Git history and refuse dirty, unrecorded or ignored local data, including nested +modules. Real Git and native UI checks cover registration/index preservation, +network cancellation, URL edits, nesting, confirmations and reinitialization. + --- ## Cross-cutting tracks (run in parallel with all milestones) diff --git a/TASKS.md b/TASKS.md index 4ca2ec58..f9ab7405 100644 --- a/TASKS.md +++ b/TASKS.md @@ -105,9 +105,12 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git- status, bounded locks and cancellable transfers are exposed in `LfsDialog`. Real lock-API fixtures and native setup/staging/palette/cancellation/recovery checks pass; no eager LFS network or status subprocesses. -- ☐ **F05 / P1 — Submodule lifecycle.** Extend existing open/status/init/update - with add/remove/deinit/sync/URL/nested inspection; verify dirty-state handling, - `.gitmodules` and index changes, plus cancellable network operations. +- ☑ **F05 / P1 — Submodule lifecycle.** Add/remove/deinit/sync/URL changes, + paged nested inspection and cancellable updates (`SubmoduleDialog`, + `Repo::{submodule_action,submodule_children}`). Real Git transport, dirty, + ignored and nested files, unrecorded commits and `.gitmodules`/index + preservation fixtures pass. Native lifecycle, keyboard/palette, module + opening, destructive guards and cancellation checks pass. - ☐ **F07 / P2 — Patch/mailbox/bundle import and interchange.** Build on exact patch export and hunk apply with preview/validation, explicit targets, mailbox continue/skip/abort and bundle prerequisites/ref summaries. @@ -115,7 +118,9 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git- compatibility fixtures for excluded paths, dirty trees and sparse indexes. - ☐ **F09 / P2 — Advanced clone options.** Branch, depth/single-branch, partial-clone filter and recursive-submodule options; deepen/unshallow, - progress/cancellation, and safe argument construction. + progress/cancellation, and safe argument construction. Include a real LFS + clone-checkout fixture: Git 2.45.1 / LFS 3.5.1 rejects the hook installed + during checkout (`docs/git-assets-validation-2026-09-06.md`). - ☐ **F10 / P2 — Guided bisect.** Good/bad/skip, operation progress, external session resume and safe reset to the original checkout; defer test-command execution until the manual workflow is complete. diff --git a/crates/strand-core/src/submodule.rs b/crates/strand-core/src/submodule.rs index 2c3673eb..9b99ee18 100644 --- a/crates/strand-core/src/submodule.rs +++ b/crates/strand-core/src/submodule.rs @@ -1,4 +1,4 @@ -//! Submodules — list + status (read) and `update --init` (write). +//! Submodules — status, lazy nested inspection, and guarded Git lifecycle actions. //! //! Reads go through `git2` (`Repository::submodules` + `submodule_status`), //! which gives us the recorded vs checked-out OIDs and a status bitset in one @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; use crate::{ error::{Error, Result}, - network::{NetworkOutcome, Progress}, + network::{run_git_streaming, CancelHandle, NetworkOutcome, Progress}, repo::Repo, }; @@ -50,7 +50,270 @@ pub struct Submodule { pub status: SubmoduleState, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "action", rename_all = "kebab-case")] +pub enum SubmoduleAction { + Add { url: String, path: String }, + Remove { path: String }, + Deinit { path: String }, + Sync { path: String, recursive: bool }, + SetUrl { path: String, url: String }, + Update { path: String, recursive: bool }, + Inspect { path: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubmodulePage { + pub modules: Vec, + pub next_offset: Option, +} + impl Repo { + /// Children are requested a level/page at a time. No recursive dirty walk + /// is added to the repository snapshot or the sidebar refresh. + pub fn submodule_children(&self, parent: &str, offset: usize) -> Result { + let nested; + let owner = if parent.is_empty() { + self + } else { + nested = self.open_nested_submodule(parent)?; + &nested + }; + let repo = owner.git2()?; + let mut modules = repo.submodules()?; + modules.sort_by_key(|sm| sm.path().to_path_buf()); + let total = modules.len(); + let mut out = Vec::new(); + for sm in modules.into_iter().skip(offset).take(100) { + let workdir_id = sm.workdir_id().map(|id| id.to_string()); + let head_id = sm.index_id().map(|id| id.to_string()); + let initialized = workdir_id.is_some(); + out.push(Submodule { + name: sm.name().unwrap_or_default().into(), + path: sm.path().to_string_lossy().replace('\\', "/"), + url: sm.url().map(str::to_owned), + status: if !initialized { + SubmoduleState::Uninitialized + } else if head_id != workdir_id { + SubmoduleState::OutOfDate + } else { + SubmoduleState::UpToDate + }, + head_id, + workdir_id, + initialized, + }); + } + Ok(SubmodulePage { + modules: out, + next_offset: (offset.saturating_add(100) < total).then_some(offset.saturating_add(100)), + }) + } + + fn open_nested_submodule(&self, path: &str) -> Result { + validate_module_path(self, path)?; + // Resolve only registered module edges, never arbitrary nested repos. + let mut owner = Repo::discover(&self.path)?; + let mut remaining = path; + for _ in 0..32 { + let next = owner + .git2()? + .submodules()? + .into_iter() + .find_map(|sm| { + let child = sm.path().to_string_lossy().replace('\\', "/"); + (remaining == child || remaining.starts_with(&format!("{child}/"))) + .then_some(child) + }) + .ok_or_else(|| Error::Other(format!("Not a registered submodule: {path}")))?; + let target = owner.path.join(&next); + let opened = Repo::discover(&target)?; + if opened.path.canonicalize()? != target.canonicalize()? { + return Err(Error::Other(format!( + "Initialize {path} before inspecting its children." + ))); + } + if remaining == next { + return Ok(opened); + } + remaining = &remaining[next.len() + 1..]; + owner = opened; + } + Err(Error::Other( + "Nested submodule depth exceeds 32; open the module as a repository to continue." + .into(), + )) + } + + pub fn submodule_action( + &self, + action: SubmoduleAction, + mut progress: impl FnMut(Progress), + cancel: Option<&CancelHandle>, + ) -> Result { + let (path, changes_modules, needs_clean) = match &action { + SubmoduleAction::Add { path, .. } => (path, true, false), + SubmoduleAction::Remove { path } => (path, true, true), + SubmoduleAction::Deinit { path } => (path, false, true), + SubmoduleAction::SetUrl { path, .. } => (path, true, false), + SubmoduleAction::Update { path, .. } => (path, false, true), + SubmoduleAction::Sync { path, .. } | SubmoduleAction::Inspect { path } => { + (path, false, false) + } + }; + validate_module_path(self, path)?; + let repo = self.git2()?; + let registered = repo + .submodules()? + .into_iter() + .any(|sm| sm.path() == std::path::Path::new(path)); + if !matches!(action, SubmoduleAction::Add { .. }) && !registered { + return Err(Error::Other(format!("Not a registered submodule: {path}"))); + } + let modules_clean = match repo.status_file(std::path::Path::new(".gitmodules")) { + Ok(status) => status.is_empty(), + Err(error) => error.code() == git2::ErrorCode::NotFound, + }; + if changes_modules && !modules_clean { + return Err(Error::Other("Commit or restore .gitmodules before changing submodule registration; its staged and unstaged edits are preserved.".into())); + } + if needs_clean { + self.ensure_submodule_clean( + path, + matches!( + action, + SubmoduleAction::Remove { .. } | SubmoduleAction::Deinit { .. } + ), + &mut progress, + cancel, + )?; + } + if let SubmoduleAction::Inspect { path } = &action { + let child = self.open_nested_submodule(path)?; + return run_git_streaming( + &child.path, + &[ + "status", + "--short", + "--branch", + "--untracked-files=normal", + "--ignore-submodules=none", + ], + progress, + cancel, + ); + } + let mut args = vec!["--literal-pathspecs".to_string()]; + match action { + SubmoduleAction::Add { url, path } => { + crate::network::validate_remote_arg(&url, "submodule URL")?; + if self.path.join(&path).exists() { + return Err(Error::Other( + "Choose a new, empty submodule path; existing directories are preserved." + .into(), + )); + } + args.extend([ + "submodule".into(), + "add".into(), + "--progress".into(), + "--".into(), + url, + path, + ]); + } + SubmoduleAction::Remove { path } => args.extend(["rm".into(), "--".into(), path]), + SubmoduleAction::Deinit { path } => { + args.extend(["submodule".into(), "deinit".into(), "--".into(), path]) + } + SubmoduleAction::SetUrl { path, url } => { + crate::network::validate_remote_arg(&url, "submodule URL")?; + args.extend(["submodule".into(), "set-url".into(), "--".into(), path, url]); + } + SubmoduleAction::Sync { path, recursive } => { + args.extend(["submodule".into(), "sync".into()]); + if recursive { + args.push("--recursive".into()); + } + args.extend(["--".into(), path]); + } + SubmoduleAction::Update { path, recursive } => { + args.extend([ + "submodule".into(), + "update".into(), + "--init".into(), + "--progress".into(), + ]); + if recursive { + args.push("--recursive".into()); + } + args.extend(["--".into(), path]); + } + SubmoduleAction::Inspect { .. } => unreachable!(), + } + run_git_streaming( + &self.path, + &args.iter().map(String::as_str).collect::>(), + progress, + cancel, + ) + } + + fn ensure_submodule_clean( + &self, + path: &str, + require_recorded_commit: bool, + progress: &mut dyn FnMut(Progress), + cancel: Option<&CancelHandle>, + ) -> Result<()> { + let workdir = self.path.join(path); + if !workdir.join(".git").exists() { + if workdir.exists() && std::fs::read_dir(workdir)?.next().is_some() { + return Err(Error::Other(format!("Uninitialized module {path} contains files. Move or preserve them before retrying."))); + } + return Ok(()); + } + let child = self.open_nested_submodule(path)?; + let status = run_git_streaming( + &child.path, + &[ + "status", + "--porcelain", + "--untracked-files=normal", + "--ignore-submodules=none", + if require_recorded_commit { + "--ignored=matching" + } else { + "--ignored=no" + }, + ], + &mut *progress, + cancel, + )?; + if !status.output.is_empty() { + return Err(Error::Other(format!( + "{path} has local or nested changes. Commit or stash them before retrying.\n{}", + status.output + ))); + } + if require_recorded_commit { + let expected = self + .git2()? + .index()? + .get_path(std::path::Path::new(path), 0) + .map(|e| e.id); + if child.git2()?.head()?.target() != expected { + return Err(Error::Other(format!("{path} is checked out at a different commit than the index. Preserve that commit and stage/commit the gitlink before retrying."))); + } + // Git's parent status omits ignored files inside nested modules. + // Before deleting directories, check every initialized child too. + for nested in child.git2()?.submodules()? { + let nested_path = nested.path().to_string_lossy().replace('\\', "/"); + child.ensure_submodule_clean(&nested_path, true, progress, cancel)?; + } + } + Ok(()) + } /// List every submodule with its status. Best-effort per submodule — a /// status lookup that fails (e.g. a malformed `.gitmodules` entry) falls /// back to `Uninitialized` rather than failing the whole listing. @@ -93,16 +356,29 @@ impl Repo { paths: &[String], init: bool, recursive: bool, - on_progress: impl FnMut(Progress), + mut on_progress: impl FnMut(Progress), + cancel: Option<&CancelHandle>, ) -> Result { - for p in paths { - if p.starts_with('-') { - return Err(Error::Other(format!( - "submodule path may not start with '-': {p}" - ))); + let modules = self.git2()?.submodules()?; + let selected: Vec = if paths.is_empty() { + modules + .iter() + .map(|sm| sm.path().to_string_lossy().replace('\\', "/")) + .collect() + } else { + paths.to_vec() + }; + for p in &selected { + validate_module_path(self, p)?; + if !modules + .iter() + .any(|sm| sm.path() == std::path::Path::new(p)) + { + return Err(Error::Other(format!("Not a registered submodule: {p}"))); } + self.ensure_submodule_clean(p, false, &mut on_progress, cancel)?; } - let mut args: Vec<&str> = vec!["submodule", "update", "--progress"]; + let mut args: Vec<&str> = vec!["--literal-pathspecs", "submodule", "update", "--progress"]; if init { args.push("--init"); } @@ -117,10 +393,36 @@ impl Repo { args.push(p.as_str()); } } - crate::network::run_git_streaming(&self.path, &args, on_progress, None) + crate::network::run_git_streaming(&self.path, &args, on_progress, cancel) } } +fn validate_module_path(repo: &Repo, path: &str) -> Result<()> { + if path.is_empty() + || path.starts_with('-') + || path.contains(['\0', '\n', '\r', '\\', ':']) + || path.split('/').any(|part| { + part.is_empty() || part == "." || part == ".." || part.eq_ignore_ascii_case(".git") + }) + { + return Err(Error::Other("Use a repository-relative submodule path with forward slashes and no '.' or '..' components.".into())); + } + let root = repo.path.canonicalize()?; + let mut target = repo.path.clone(); + for part in path.split('/') { + target.push(part); + if let Ok(meta) = std::fs::symlink_metadata(&target) { + if meta.file_type().is_symlink() || !target.canonicalize()?.starts_with(&root) { + return Err(Error::Other( + "Submodule path must stay inside the repository without symlink traversal." + .into(), + )); + } + } + } + Ok(()) +} + /// Reduce git2's `SubmoduleStatus` to a single [`SubmoduleState`] plus an /// `initialized` flag. Order: uninitialized first (no point reporting "modified" /// on a submodule with no working tree), then local working-tree changes, then a @@ -152,7 +454,11 @@ mod tests { use std::process::Command; fn git(dir: &Path, args: &[&str]) -> String { - let out = Command::new("git").current_dir(dir).args(args).output().unwrap(); + let out = Command::new("git") + .current_dir(dir) + .args(args) + .output() + .unwrap(); assert!( out.status.success(), "git {:?} failed: {}", @@ -211,9 +517,269 @@ mod tests { assert_eq!(m.path, "sub"); assert!(m.initialized, "freshly added submodule has a working tree"); assert!(m.head_id.is_some()); - assert_eq!(m.head_id, m.workdir_id, "checked out at the recorded commit"); + assert_eq!( + m.head_id, m.workdir_id, + "checked out at the recorded commit" + ); assert_eq!(m.status, SubmoduleState::UpToDate); + // Real dirty/untracked state is checked lazily at the mutation boundary. + std::fs::write(sup.join("sub/local.txt"), "keep me").unwrap(); + for action in [ + SubmoduleAction::Deinit { path: "sub".into() }, + SubmoduleAction::Remove { path: "sub".into() }, + SubmoduleAction::Update { + path: "sub".into(), + recursive: true, + }, + ] { + assert!(repo + .submodule_action(action, |_| {}, None) + .unwrap_err() + .to_string() + .contains("local or nested changes")); + } + assert_eq!( + std::fs::read_to_string(sup.join("sub/local.txt")).unwrap(), + "keep me" + ); + std::fs::remove_file(sup.join("sub/local.txt")).unwrap(); + let index_before = git(&sup, &["write-tree"]); + let new_url = format!("{sub_url}-new"); + repo.submodule_action( + SubmoduleAction::SetUrl { + path: "sub".into(), + url: new_url.clone(), + }, + |_| {}, + None, + ) + .unwrap(); + assert_eq!( + git(&sup, &["config", "-f", ".gitmodules", "submodule.sub.url"]), + new_url + ); + assert_eq!(git(&sup, &["config", "submodule.sub.url"]), new_url); + assert_eq!( + git(&sup, &["write-tree"]), + index_before, + "URL edit does not silently stage .gitmodules" + ); + assert!( + repo.submodule_action(SubmoduleAction::Remove { path: "sub".into() }, |_| {}, None) + .is_err(), + "pending .gitmodules edits are protected" + ); + git(&sup, &["checkout", "--", ".gitmodules"]); + repo.submodule_action( + SubmoduleAction::Sync { + path: "sub".into(), + recursive: true, + }, + |_| {}, + None, + ) + .unwrap(); + assert_eq!(git(&sup, &["config", "submodule.sub.url"]), sub_url); + + // Add a nested module using the fixture-only local transport override. + let nested_path = sup.join("sub"); + git( + &nested_path, + &[ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + &sub_url, + "nested", + ], + ); + assert_eq!( + repo.submodule_children("sub", 0).unwrap().modules[0].path, + "nested" + ); + assert!(repo + .submodule_children("sub/nested", 0) + .unwrap() + .modules + .is_empty()); + assert!(repo.submodule_children("../sub", 0).is_err()); + assert!(repo.submodule_children("sub/a.txt", 0).is_err()); + assert!(repo.submodule_children("", 100).unwrap().modules.is_empty()); + assert!(repo + .submodule_action(SubmoduleAction::Deinit { path: "sub".into() }, |_| {}, None) + .is_err()); + git( + &nested_path, + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "nested", + ], + ); + assert!(repo + .submodule_action(SubmoduleAction::Deinit { path: "sub".into() }, |_| {}, None) + .unwrap_err() + .to_string() + .contains("different commit")); + repo.stage_path("sub").unwrap(); + repo.commit("record nested", None, false).unwrap(); + std::fs::write(sup.join("sub/nested/untracked.txt"), "nested data").unwrap(); + assert!(repo + .submodule_action(SubmoduleAction::Remove { path: "sub".into() }, |_| {}, None) + .unwrap_err() + .to_string() + .contains("local or nested changes")); + std::fs::remove_file(sup.join("sub/nested/untracked.txt")).unwrap(); + let ignored_rules = base.join("ignored-rules"); + std::fs::write(&ignored_rules, "*.secret\n").unwrap(); + for module in [sup.join("sub"), sup.join("sub/nested")] { + git( + &module, + &[ + "config", + "core.excludesFile", + ignored_rules.to_str().unwrap(), + ], + ); + std::fs::write(module.join("local.secret"), "ignored local data").unwrap(); + assert!(repo + .submodule_action(SubmoduleAction::Deinit { path: "sub".into() }, |_| {}, None) + .unwrap_err() + .to_string() + .contains("local or nested changes")); + assert_eq!( + std::fs::read_to_string(module.join("local.secret")).unwrap(), + "ignored local data" + ); + std::fs::remove_file(module.join("local.secret")).unwrap(); + } + let before_deinit = git(&sup, &["write-tree"]); + repo.submodule_action(SubmoduleAction::Deinit { path: "sub".into() }, |_| {}, None) + .unwrap(); + assert_eq!(git(&sup, &["write-tree"]), before_deinit); + assert!(sup.join(".gitmodules").exists()); + assert!(!sup.join("sub/a.txt").exists()); + // Local module data survives deinit, so reinitialization needs no fetch. + repo.submodule_update(&["sub".into()], true, false, |_| {}, None) + .unwrap(); + assert!(sup.join("sub/a.txt").exists()); + repo.submodule_action(SubmoduleAction::Remove { path: "sub".into() }, |_| {}, None) + .unwrap(); + assert!(git(&sup, &["ls-files", "--stage", "sub"]).is_empty()); + assert!(!std::fs::read_to_string(sup.join(".gitmodules")) + .unwrap() + .contains("submodule")); + assert!(git(&sup, &["diff", "--cached", "--name-only"]).contains(".gitmodules")); + assert!( + sup.join(".git/modules/sub").exists(), + "local module history retained" + ); + let _ = std::fs::remove_dir_all(&base); } + + #[test] + fn adds_submodule_over_git_transport_and_preserves_existing_directories() { + use std::net::{TcpListener, TcpStream}; + let base = + std::env::temp_dir().join(format!("strand-submodule-add-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let source = base.join("source"); + let target = base.join("target"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::create_dir_all(&target).unwrap(); + git(&source, &["init", "-q", "-b", "main"]); + std::fs::write(source.join("asset.txt"), "source").unwrap(); + git(&source, &["add", "asset.txt"]); + git( + &source, + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "initial", + ], + ); + git(&target, &["init", "-q", "-b", "main"]); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let mut daemon = crate::git_command() + .args([ + "daemon", + "--export-all", + "--reuseaddr", + "--listen=127.0.0.1", + &format!("--port={port}"), + &format!("--base-path={}", base.display()), + ]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .unwrap(); + let started = std::time::Instant::now(); + while TcpStream::connect(("127.0.0.1", port)).is_err() { + assert!(started.elapsed().as_secs() < 30); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let repo = Repo::discover(&target).unwrap(); + let url = format!("git://127.0.0.1:{port}/source"); + let result = repo.submodule_action( + SubmoduleAction::Add { + url: url.clone(), + path: "vendor/library".into(), + }, + |_| {}, + None, + ); + let _ = daemon.kill(); + let _ = daemon.wait(); + result.unwrap(); + assert_eq!( + std::fs::read_to_string(target.join("vendor/library/asset.txt")).unwrap(), + "source" + ); + assert!(git(&target, &["ls-files", "--stage", "vendor/library"]).starts_with("160000")); + assert!(git(&target, &["show", ":.gitmodules"]).contains(&url)); + let before = git(&target, &["write-tree"]); + assert!(repo + .submodule_action( + SubmoduleAction::Add { + url, + path: "vendor/library".into() + }, + |_| {}, + None + ) + .is_err()); + assert_eq!(git(&target, &["write-tree"]), before); + let cancel = CancelHandle::new(); + cancel.cancel(); + assert!(matches!( + repo.submodule_action( + SubmoduleAction::Sync { + path: "vendor/library".into(), + recursive: true + }, + |_| {}, + Some(&cancel) + ), + Err(Error::Cancelled) + )); + let _ = std::fs::remove_dir_all(base); + } } diff --git a/crates/strand-tauri/src/commands.rs b/crates/strand-tauri/src/commands.rs index fd07bdb6..87993622 100644 --- a/crates/strand-tauri/src/commands.rs +++ b/crates/strand-tauri/src/commands.rs @@ -29,7 +29,7 @@ use strand_core::{ reflog::ReflogEntry, refs::{BaseBranch, Refs}, repo::RepoMeta, reset::{ResetMode, ResetOutcome}, snapshot::Snapshot, stash::{Stash, StashOutcome}, - status::FileStatus, submodule::Submodule, tree::WorkTreeEntry, + status::FileStatus, submodule::{Submodule, SubmoduleAction, SubmodulePage}, tree::WorkTreeEntry, worktree::{RestoredWorktree, Worktree, WorktreeArchive, WorktreeHealth, WorktreeStats}, Repo, }; use tauri::ipc::Channel; @@ -1186,15 +1186,37 @@ pub async fn repo_submodule_update( init: bool, recursive: bool, on_event: Channel, + op_id: Option, + state: State<'_, AppState>, ) -> CmdResult { - run_blocking("submodule update", move || { + let cancel = CancelHandle::new(); + register_op(&state, &op_id, OperationCancelHandle::Network(cancel.clone())); + let result = run_blocking("submodule update", move || { let repo = Repo::discover(&path)?; repo.submodule_update(&paths, init, recursive, |p| { let _ = on_event.send(p); - }) + }, Some(&cancel)) .map_err(CmdError::from) }) - .await + .await; + deregister_op(&state, &op_id); + result +} + +#[tauri::command(async)] +pub async fn repo_submodule_children(path: String, parent: String, offset: usize) -> CmdResult { + run_blocking("submodule children", move || Ok(Repo::discover(&path)?.submodule_children(&parent, offset)?)).await +} + +#[tauri::command(async)] +pub async fn repo_submodule_action(path: String, action: SubmoduleAction, op_id: Option, on_event: Channel, state: State<'_, AppState>) -> CmdResult { + let cancel = CancelHandle::new(); + register_op(&state, &op_id, OperationCancelHandle::Network(cancel.clone())); + let result = run_blocking("submodule lifecycle", move || { + Repo::discover(&path)?.submodule_action(action, |p| { let _ = on_event.send(p); }, Some(&cancel)).map_err(CmdError::from) + }).await; + deregister_op(&state, &op_id); + result } #[tauri::command(async)] diff --git a/crates/strand-tauri/src/main.rs b/crates/strand-tauri/src/main.rs index 7167b6b5..608a5a30 100644 --- a/crates/strand-tauri/src/main.rs +++ b/crates/strand-tauri/src/main.rs @@ -245,6 +245,8 @@ fn main() { commands::repo_tree_at, commands::repo_submodules, commands::repo_submodule_update, + commands::repo_submodule_children, + commands::repo_submodule_action, commands::repo_worktrees, commands::repo_worktree_add, commands::repo_worktree_remove, diff --git a/docs/git-assets-validation-2026-09-06.md b/docs/git-assets-validation-2026-09-06.md new file mode 100644 index 00000000..3c678f5d --- /dev/null +++ b/docs/git-assets-validation-2026-09-06.md @@ -0,0 +1,73 @@ +# Git LFS and submodule validation — 2026-09-06 + +F04 and F05 are implemented through core operations, typed IPC, sidebar entries +and keyboard-operable dialogs. Verification used Windows, Git 2.45.1 and Git +LFS 3.5.1, with disposable repositories and local Git/HTTP endpoints. + +## Regressions reproduced and fixed + +- **LFS index bytes:** git2 `index.add_path` stored the asset instead of the + canonical pointer produced by `git hash-object --path`. Single-file and bulk + staging now run Git's required LFS clean filter. Fixtures compare the actual + index/commit bytes, then check checkout, discard, hard reset, push and pull. + A missing filter executable fails without changing the index to raw assets. +- **Submodule ignored files:** non-forced `git submodule deinit` removed an + ignored local file. Removal/deinit now check ignored files and recorded + commits in every initialized descendant. The fixture covers both direct + and nested ignored files, dirty/untracked files, and unrecorded commits. +- **Native dialog integration:** corrected shared-select styling and routed + module opening through the workspace store, so workspace reconciliation + retains the new active repository. + +## Automated checks + +Run from the repository root: + +```text +cargo check -p strand-core -p strand-tauri -j 2 +cargo clippy -p strand-core -p strand-tauri -j 2 -- -D warnings +cargo test -p strand-core --lib -j 2 -- --test-threads=2 +pnpm --filter ./ui exec tsc --noEmit +pnpm --filter ./ui test +``` + +All checks passed: 169 core tests and 425 frontend tests across 75 files. + +Core coverage lives in `lfs.rs`, `submodule.rs` and +`network::bounded_process_tests`. Fixtures also verify local setup/tracking +without history changes, server lock/list/unlock, safe argument handling, +submodule transport, `.gitmodules` and gitlink staging, URL/index preservation, +deinit/reinit, lazy nested metadata, bounded output and cancellation of helpers +that keep progress pipes open. Linux CI installs Git LFS for these real fixtures. + +## Native verification + +Followed `.agents/skills/verify/SKILL.md` with an isolated application identifier, +WebView2 profile, Vite port and disposable repositories. The checked-in Tauri +configuration was never edited. The verification instance was stopped by its +recorded PID and the normal dev binary rebuilt without the CDP override. + +- LFS: sidebar setup and tracking, Stage all/commit with canonical pointer + bytes, object listing from the command palette, initial focus, styled fields, + no network on opening, cancellation and subsequent successful environment read. +- Submodules: real add/clone, status, lazy nested navigation, opening the module + as a repository, URL changes/sync, confirmation by keyboard, dirty-file refusal, + deinit/reinit/removal and retained module history. A stalled add cancelled + without changing the index. The rebuilt app also refused deinit when an + ignored local file was present, preserving its exact contents and the index. +- Cancellation of stalled local HTTP requests completed in approximately + 0.44 seconds for LFS and 0.48 seconds for submodule add. These are fixture + observations, not general performance certification. + +## Boundaries + +Lock listing is capped at 100 with an exact-path filter; object transcripts +retain a bounded tail. Submodule metadata is paged at 100 per level; dirty +inspection is explicit and destructive preflight may walk descendants. LFS +history migration and partial-pointer patches are intentionally absent. + +Hosted authentication/locking services and macOS/Linux runtime behavior were +not exercised here. The LFS network fixture clones without checkout and then +uses Strand checkout because this Git/LFS combination rejects a hook installed +during normal clone checkout. Advanced clone compatibility remains tracked +under F09; no system safety override was added. diff --git a/docs/learnings.md b/docs/learnings.md index 7bfae244..78e83d44 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -20,6 +20,21 @@ that otherwise keep pipes open. Tracking edits attributes, never history. --- +## Submodule removal must preserve ignored local data (2026-09-06) + +Git's non-forced submodule deinit can remove ignored files, and a parent status +does not report ignored files inside nested modules. Before remove/deinit, +inspect ignored files and recorded commits in each initialized descendant; +refuse the action when local data remains. Keep this work at the explicit +mutation boundary. Nested browsing reads metadata one level/page at a time, +without adding recursive dirty scans to repository refreshes. + +Opening a module from a dialog must go through `useWorkspaces.openRepoInActive`. +Calling `useRepo.openRepo` directly omits workspace membership, and the workspace +reconciler can immediately clear the active repository. + +--- + ## Closeable tabs follow browser closing conventions **Rule.** Every closeable repository or Work tab supports its visible close diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 15d8b368..a8877745 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -33,6 +33,7 @@ import { pickCodeWorkspaceFile, pickRepoDirectories } from './lib/dialog'; import { editorTemplate, osType, terminalTemplate } from './lib/integrations'; import { t } from './lib/i18n'; import { LFS_ACTIONS } from './lib/lfs'; +import { SUBMODULE_ACTIONS, type SubmoduleDialogAction } from './lib/submodules'; import type { LfsAction } from './lib/types'; import { plural } from './lib/plural'; import { concatPatches, patchesToMarkdown } from './lib/patchExport'; @@ -122,6 +123,7 @@ const BranchCleanupDialog = lazy(() => import('./views/BranchCleanupDialog').the const RebaseEditor = lazy(() => import('./views/RebaseEditor').then((m) => ({ default: m.RebaseEditor }))); const MaintenanceDialog = lazy(() => import('./views/MaintenanceDialog').then((m) => ({ default: m.MaintenanceDialog }))); const LfsDialog = lazy(() => import('./views/LfsDialog').then((m) => ({ default: m.LfsDialog }))); +const SubmoduleDialog = lazy(() => import('./views/SubmoduleDialog').then((m) => ({ default: m.SubmoduleDialog }))); const WorkspaceManagerDialog = lazy(() => import('./views/WorkspaceManagerDialog').then((m) => ({ default: m.WorkspaceManagerDialog }))); const PullRequests = lazy(() => import('./views/PullRequests').then((m) => ({ default: m.PullRequests }))); @@ -294,7 +296,6 @@ export function App() { const submodules = useRepo((s) => s.submodules); const stashApply = useRepo((s) => s.stashApply); const stashPop = useRepo((s) => s.stashPop); - const submoduleUpdate = useRepo((s) => s.submoduleUpdate); const pruneWorktrees = useRepo((s) => s.pruneWorktrees); const baseline = useRepo((s) => s.baseline); const setBaseline = useRepo((s) => s.setBaseline); @@ -355,6 +356,7 @@ export function App() { const [remoteDialog, setRemoteDialog] = useState(null); const [maintenanceOpen, setMaintenanceOpen] = useState(false); const [lfsAction, setLfsAction] = useState<{ repoPath: string; action: LfsAction['action'] } | null>(null); + const [submoduleDialog, setSubmoduleDialog] = useState<{ repoPath: string; path: string; action: SubmoduleDialogAction } | null>(null); const [fileEntryDialog, setFileEntryDialog] = useState<{ dir: string; directory: boolean } | null>(null); // null = closed; otherwise the branch to rename. const [renameBranchDialog, setRenameBranchDialog] = useState<{ name: string } | null>(null); @@ -1544,8 +1546,8 @@ export function App() { keywords: `submodule init update ${sm.path}`, meta: sm.status, run: () => { - void submoduleUpdate([sm.path], true, true).catch((e) => - showToast(`Submodule update failed: ${errMessage(e)}`, 'error')); + setPaletteOpen(false); + setSubmoduleDialog({ repoPath: meta!.path, path: sm.path, action: 'update' }); }, }); } @@ -1553,7 +1555,7 @@ export function App() { return out; }, [paletteOpen, meta, refs, workTree, commits, stashes, submodules, checkout, createBranch, revealInGraph, selectCommit, selectFile, showToast, showWorkbenchWork, - stashApply, stashPop, submoduleUpdate]); + stashApply, stashPop]); const runCustomAction = useCallback(( action: (state: ReturnType) => void, @@ -1879,6 +1881,7 @@ export function App() { ] : []), { id: 'remote-add', label: 'Add remote…', group: 'Actions', keywords: 'remote origin upstream url add', run: () => setRemoteDialog({ kind: 'add' }) }, + ...SUBMODULE_ACTIONS.map(([action, label]) => ({ id: `submodule-${action}`, label: `Submodules: ${label}…`, group: 'Actions', keywords: 'submodule lifecycle url nested status add remove deinit sync', run: () => { setPaletteOpen(false); setSubmoduleDialog({ repoPath: meta.path, path: '', action }); } } satisfies PaletteAction)), ...LFS_ACTIONS.map(([action, label]) => ({ id: `lfs-${action}`, label: `Git LFS: ${label}…`, group: 'Actions', keywords: 'large file storage lfs objects filters patterns transfers locks', run: () => { setPaletteOpen(false); setLfsAction({ repoPath: meta.path, action }); } } satisfies PaletteAction)), { id: 'repository-maintenance', label: 'Repository maintenance…', group: 'Actions', keywords: 'git gc fsck integrity optimize activity log command output', run: () => { setPaletteOpen(false); @@ -2227,6 +2230,7 @@ export function App() { onInteractiveRebase={(base, label) => setRebaseDialog({ base, label })} onManageRemote={(mode) => setRemoteDialog(mode)} onManageLfs={() => { if (meta) setLfsAction({ repoPath: meta.path, action: 'environment' }); }} + onManageSubmodules={(path = '', action = 'inspect') => { if (meta) setSubmoduleDialog({ repoPath: meta.path, path, action }); }} onRenameBranch={(name) => setRenameBranchDialog({ name })} onManageBranchNetwork={(mode) => setBranchNetworkDialog(mode)} onPull={onPull} @@ -2393,6 +2397,7 @@ export function App() { setMaintenanceOpen(false)} onToast={showToast} /> )} {lfsAction && setLfsAction(null)} />} + {submoduleDialog && setSubmoduleDialog(null)} />} {fileEntryDialog && meta && ( void; + onManageSubmodules: (path?: string, action?: import('../lib/submodules').SubmoduleDialogAction) => void; onOpenWorkbench: () => void; onOpenWorkSurface: () => void; onOpenRepo: () => void; @@ -177,7 +178,7 @@ function sortTree(node: TreeNode, leafCmp: (a: T, b: T) => number): void { // ─── component ────────────────────────────────────────────────────────── -export function Sidebar({ onManageLfs, onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, onCreateBranch, onBranchFromStash, onCreateWorktree, onMerge, onInteractiveRebase, onManageRemote, onRenameBranch, onManageBranchNetwork, onPull, onPush, onForcePush, onFetchBranch, onPullBranch, onOpenFileInEditor, onCreateFileEntry, onToast }: SidebarProps) { +export function Sidebar({ onManageSubmodules, onManageLfs, onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, onCreateBranch, onBranchFromStash, onCreateWorktree, onMerge, onInteractiveRebase, onManageRemote, onRenameBranch, onManageBranchNetwork, onPull, onPush, onForcePush, onFetchBranch, onPullBranch, onOpenFileInEditor, onCreateFileEntry, onToast }: SidebarProps) { const view = useRepo((s) => s.view); const setView = useRepo((s) => s.setView); const selectFile = useRepo((s) => s.selectFile); @@ -225,7 +226,6 @@ export function Sidebar({ onManageLfs, onOpenWorkbench, onOpenWorkSurface, onOpe const stashPop = useRepo((s) => s.stashPop); const stashDrop = useRepo((s) => s.stashDrop); const submodules = useRepo((s) => s.submodules); - const submoduleUpdate = useRepo((s) => s.submoduleUpdate); const worktrees = useRepo((s) => s.worktrees); const openWorktree = useRepo((s) => s.openWorktree); const removeWorktree = useRepo((s) => s.removeWorktree); @@ -796,19 +796,6 @@ export function Sidebar({ onManageLfs, onOpenWorkbench, onOpenWorkSurface, onOpe if (!meta || !sub.initialized) return; onOpenRecent(`${meta.path}/${sub.path}`); }; - // `git submodule update` (always --init --recursive) for the given paths - // (empty ⇒ all). Surfaces start + result via a toast. - const runSubmoduleUpdate = (paths: string[], label: string) => { - void (async () => { - onToast(`Updating ${label}…`); - try { - await submoduleUpdate(paths, true, true); - onToast(`Updated ${label}`); - } catch (e) { - onToast(`Submodule update failed: ${errMessage(e)}`, 'error'); - } - })(); - }; const submoduleMenu = (sub: Submodule): MenuItem[] => { const items: MenuItem[] = []; if (sub.initialized) { @@ -817,9 +804,10 @@ export function Sidebar({ onManageLfs, onOpenWorkbench, onOpenWorkSurface, onOpe items.push({ label: sub.initialized ? 'Update' : 'Init & update', icon: 'arrow-down', - onSelect: () => runSubmoduleUpdate([sub.path], leafName(sub.path)), + onSelect: () => onManageSubmodules(sub.path, 'update'), }); items.push({ label: 'Copy path', icon: 'file', onSelect: () => void copyToClipboard(sub.path) }); + items.push({ label: 'Manage / inspect nested modules…', icon: 'submodule', onSelect: () => onManageSubmodules(sub.path) }); return items; }; @@ -1169,11 +1157,7 @@ export function Sidebar({ onManageLfs, onOpenWorkbench, onOpenWorkSurface, onOpe collapsed={!sections.submods} onToggle={() => toggle('submods')} count={filteredSubmodules.length} - action={ - submodules.length > 0 - ? { icon: 'sync', title: 'Update all submodules', onClick: () => runSubmoduleUpdate([], 'all submodules') } - : undefined - } + action={{ icon: 'plus', title: 'Manage submodules', onClick: () => onManageSubmodules() }} /> {sections.submods && filteredSubmodules.length === 0 && (
No submodules.
diff --git a/ui/src/lib/submodules.ts b/ui/src/lib/submodules.ts new file mode 100644 index 00000000..013caa6f --- /dev/null +++ b/ui/src/lib/submodules.ts @@ -0,0 +1,11 @@ +export const SUBMODULE_ACTIONS = [ + ['inspect', 'Inspect working-tree status'], + ['add', 'Add submodule'], + ['update', 'Initialize / update submodule'], + ['update-all', 'Initialize / update all submodules'], + ['sync', 'Sync configured URLs'], + ['set-url', 'Change submodule URL'], + ['deinit', 'Deinitialize submodule'], + ['remove', 'Remove submodule'], +] as const; +export type SubmoduleDialogAction = typeof SUBMODULE_ACTIONS[number][0]; diff --git a/ui/src/lib/tauri.ts b/ui/src/lib/tauri.ts index 28db228b..bc95e45e 100644 --- a/ui/src/lib/tauri.ts +++ b/ui/src/lib/tauri.ts @@ -36,6 +36,8 @@ import type { MaintenanceOutcome, MaintenanceTask, LfsAction, + SubmoduleAction, + SubmodulePage, MergeMode, NetworkOutcome, Progress, @@ -454,6 +456,9 @@ export const tauri = { repoTreeAt: (path: string, rev: string) => invoke('repo_tree_at', { path, rev }), repoSubmodules: (path: string) => invoke('repo_submodules', { path }), + repoSubmoduleChildren: (path: string, parent: string, offset: number) => invoke('repo_submodule_children', { path, parent, offset }), + repoSubmoduleAction: (path: string, action: SubmoduleAction, opId: string, onProgress?: (p: Progress) => void) => + invoke('repo_submodule_action', { path, action, opId, onEvent: progressChannel(onProgress) }), repoLfsAction: (path: string, action: LfsAction, opId: string, onProgress?: (p: Progress) => void) => invoke('repo_lfs_action', { path, action, opId, onEvent: progressChannel(onProgress) }), repoSubmoduleUpdate: ( @@ -462,6 +467,7 @@ export const tauri = { init: boolean, recursive: boolean, onProgress?: (p: Progress) => void, + opId?: string, ) => invoke('repo_submodule_update', { path, @@ -469,6 +475,7 @@ export const tauri = { init, recursive, onEvent: progressChannel(onProgress), + opId, }), repoWorktrees: (path: string) => invoke('repo_worktrees', { path }), // `startPoint` (branch/tag/commit; null = HEAD) and `track` (set upstream to diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index 1bf8c983..2f8f6b7c 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -608,6 +608,16 @@ export interface Submodule { status: SubmoduleState; } +export type SubmoduleAction = + | { action: 'add' | 'set-url'; path: string; url: string } + | { action: 'remove' | 'deinit' | 'inspect'; path: string } + | { action: 'sync' | 'update'; path: string; recursive: boolean }; + +export interface SubmodulePage { + modules: Submodule[]; + next_offset: number | null; +} + /** One entry in the repository's worktree registry (`git worktree list`). */ export interface Worktree { /** Absolute worktree directory, forward-slashed. */ diff --git a/ui/src/styles/features.css b/ui/src/styles/features.css index 03aa8eaa..36abc499 100644 --- a/ui/src/styles/features.css +++ b/ui/src/styles/features.css @@ -4317,6 +4317,11 @@ textarea.clone-input { min-height: 0; overflow-y: auto; } +.submodule-navigation { + display: flex; + flex-wrap: wrap; + gap: 6px; +} .maintenance-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); diff --git a/ui/src/views/SubmoduleDialog.tsx b/ui/src/views/SubmoduleDialog.tsx new file mode 100644 index 00000000..8c7b9243 --- /dev/null +++ b/ui/src/views/SubmoduleDialog.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef, useState } from 'react'; +import { Dialog } from '../components/Dialog'; +import { Select } from '../components/Select'; +import { SUBMODULE_ACTIONS, type SubmoduleDialogAction } from '../lib/submodules'; +import { errMessage, isCancelled, tauri } from '../lib/tauri'; +import type { SubmoduleAction, SubmodulePage } from '../lib/types'; +import { useRepo } from '../stores/repo'; +import { useWorkspaces } from '../stores/workspaces'; + +export function SubmoduleDialog({ path, initialPath = '', initialAction = 'inspect', onClose }: { + path: string; initialPath?: string; initialAction?: SubmoduleDialogAction; onClose: () => void; +}) { + const [parent, setParent] = useState(''); + const [offset, setOffset] = useState(0); + const [page, setPage] = useState({ modules: [], next_offset: null }); + const [selected, setSelected] = useState(initialPath); + const [action, setAction] = useState(initialAction); + const [newPath, setNewPath] = useState(''); + const [url, setUrl] = useState(''); + const [recursive, setRecursive] = useState(true); + const [confirm, setConfirm] = useState(false); + const [running, setRunning] = useState(null); + const [loading, setLoading] = useState(true); + const [reload, setReload] = useState(0); + const [output, setOutput] = useState('Choose a module to inspect its files, or open its nested modules.'); + const [error, setError] = useState(false); + const [progress, setProgress] = useState(''); + const busy = useRef(false); + const focus = useRef(null); + useEffect(() => { + const frame = requestAnimationFrame(() => focus.current?.focus()); + return () => cancelAnimationFrame(frame); + }, []); + const owner = parent ? `${path}/${parent}` : path; + const module = page.modules.find((item) => item.path === selected); + const destructive = action === 'remove' || action === 'deinit'; + + useEffect(() => { + let current = true; + setLoading(true); + setConfirm(false); setUrl(''); + void tauri.repoSubmoduleChildren(path, parent, offset).then((result) => { + if (!current) return; + setPage(result); + setSelected((old) => result.modules.some((item) => item.path === old) ? old : result.modules[0]?.path ?? ''); + }).catch((e) => { if (current) { setError(true); setOutput(errMessage(e)); } }).finally(() => { if (current) setLoading(false); }); + return () => { current = false; }; + }, [path, parent, offset, reload]); + + function navigate(next: string) { + setParent(next); setOffset(0); setSelected(''); setConfirm(false); setPage({ modules: [], next_offset: null }); + } + + async function run() { + if (busy.current || loading) return; + if (destructive && !confirm) { setConfirm(true); return; } + busy.current = true; setConfirm(false); setError(false); + const opId = crypto.randomUUID(); + setRunning(opId); setProgress('Starting…'); + try { + const request: SubmoduleAction | null = action === 'update-all' ? null + : action === 'add' ? { action, path: newPath, url } + : action === 'set-url' ? { action, path: selected, url } + : action === 'update' || action === 'sync' ? { action, path: selected, recursive } + : { action, path: selected }; + const result = request + ? await tauri.repoSubmoduleAction(owner, request, opId, (p) => setProgress(p.raw)) + : await tauri.repoSubmoduleUpdate(owner, [], true, recursive, (p) => setProgress(p.raw), opId); + setOutput(result.output || 'Completed. Review .gitmodules and the gitlink in Local Changes before committing.'); + } catch (e) { + setError(true); + setOutput(isCancelled(e) ? 'Cancelled. Completed clones and local Git data are retained. Refresh and inspect the module before retrying.' : errMessage(e)); + } finally { + setReload((v) => v + 1); + setRunning(null); busy.current = false; setProgress(''); + if (action !== 'inspect' && useRepo.getState().activePath === path) { + await useRepo.getState().refreshLocalChanges().catch((e) => { setError(true); setOutput(`Refresh failed: ${errMessage(e)}`); }); + } + } + } + + return {running ? + : <>}}> +
+

{parent || 'Repository root'} · Children load one level at a time, at most 100 per page. The list compares commits; choose Inspect working-tree status to check local and nested changes.

+ + {action !== 'add' && <> + + {module &&

URL: {module.url ?? 'not set'}
Index: {module.head_id ?? 'none'}
Checked out: {module.workdir_id ?? 'none'}

} +
+ + + + + +
+ } + {action === 'add' && } + {(action === 'add' || action === 'set-url') && } + {(action === 'update' || action === 'update-all' || action === 'sync') && } + {destructive &&

{action === 'remove' ? 'Remove the working directory and stage removal of the gitlink and .gitmodules entry.' : 'Remove the working directory and local registration; keep .gitmodules and the index so it can be initialized again.'} Dirty or ignored files and unrecorded commits, including in nested modules, block this action. Git retains module history under its modules directory.

} +
{progress}
+
{output}
+
+
; +} diff --git a/website/docs/everyday-git.md b/website/docs/everyday-git.md index f949f1fe..54246101 100644 --- a/website/docs/everyday-git.md +++ b/website/docs/everyday-git.md @@ -161,7 +161,44 @@ Single-clicking a stash switches to All Commits, reveals its graph node, and ope ### Submodules -Submodules list with status badges (uninitialized, out of date, modified). Double-click opens the submodule as its own repository tab; the menu offers Open, Update (or Init & update), and Copy path. The section header action runs "Update all" (`--init --recursive`) with streamed progress. +Submodules list with status badges (uninitialized, out of date, modified). +Double-click opens the module as its own repository tab. The menu offers Open, +Update (or Init & update), Copy path, and **Manage / inspect nested modules…**. +The section header's **Manage submodules** control also works in repositories +with no submodules. Every management action is searchable as **Submodules:** +in the command palette. + +Choose an action and a module, then **Run action**: + +- **Add submodule** clones a URL into a new relative path and stages its + `.gitmodules` entry and gitlink. Existing directories are preserved. +- **Initialize / update submodule** and **Initialize / update all submodules** + use Git's configured update behavior. The nested checkbox controls recursion. +- **Change submodule URL** edits `.gitmodules` and synchronizes local URL config. + Review and stage `.gitmodules` in Local Changes before committing. + **Sync configured URLs** reapplies the recorded URLs, optionally recursively. +- **Deinitialize submodule** removes its working directory and local registration + while retaining `.gitmodules` and the index for later initialization. +- **Remove submodule** removes its working directory and stages removal of its + gitlink and `.gitmodules` entry. Git retains module history in its modules directory. + +Removal and deinitialization need a second confirmation. Dirty/untracked or ignored files, +nested changes and checked-out commits that differ from the index block those +actions. Updates also refuse dirty modules. Commit or stash the module's changes, +and stage/commit the intended gitlink before retrying. Add/remove/URL changes +refuse pending `.gitmodules` edits so they cannot stage or overwrite unrelated work. + +The manager loads one level and up to 100 modules per page. Its list compares +recorded and checked-out commits; **Inspect working-tree status** explicitly +checks local and nested files. **Inspect nested modules** descends into an +initialized module; **Repository root** returns to the original repository. +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 +the current state, correct the error and retry. Git's transport restrictions +still apply, including restrictions on local-file submodule URLs. ## Repository maintenance diff --git a/website/docs/keyboard-and-palette.md b/website/docs/keyboard-and-palette.md index 1ae4a90f..af5ae9fd 100644 --- a/website/docs/keyboard-and-palette.md +++ b/website/docs/keyboard-and-palette.md @@ -85,10 +85,11 @@ integrity check, incremental Git maintenance, or guarded garbage collection. Use `Tab` to move between actions and activity entries, `Enter` to run or expand one, and `Escape` to close when no operation is running. -Search **Git LFS:** for each management action. The dialog focuses the action -selector; use arrow keys to choose, `Tab` to reach fields and buttons, and -`Enter` to run. While work runs, **Cancel operation** stops it; `Escape` closes -the dialog once it finishes. +Search **Git LFS:** or **Submodules:** for each management action. These dialogs +focus the action selector; use arrow keys to choose, `Tab` to reach fields and +buttons, and `Enter` to run or confirm. While work runs, **Cancel operation** +stops it; `Escape` closes the dialog once it finishes. Submodule navigation, +pages and **Open repository** are also in the dialog's tab order. **New file…** and **New folder…** open a focus-trapped path dialog for the active repository. The Files sidebar exposes the same actions from its **+** From 340973f5d30226867e3810b9a410f0d399fe8b02 Mon Sep 17 00:00:00 2001 From: Daniels-Main Date: Sun, 6 Sep 2026 17:01:12 +0200 Subject: [PATCH 7/8] feat: add scoped signing controls and signed tags --- README.md | 4 +- ROADMAP.md | 13 +- TASKS.md | 11 +- crates/strand-core/src/commit.rs | 20 +- crates/strand-core/src/gitconfig.rs | 21 +- crates/strand-core/src/lib.rs | 1 + crates/strand-core/src/signing.rs | 270 ++++++++++++++++++ crates/strand-core/src/tag.rs | 116 ++++++-- crates/strand-tauri/src/commands.rs | 28 +- crates/strand-tauri/src/main.rs | 3 + ...-identity-signing-validation-2026-09-06.md | 67 ++++- docs/learnings.md | 18 ++ ui/src/App.tsx | 13 +- ui/src/components/Sidebar.tsx | 4 +- ui/src/components/SigningChoice.tsx | 40 +++ ui/src/demo/dispatch.ts | 10 +- ui/src/lib/tauri.ts | 15 +- ui/src/lib/types.ts | 17 ++ ui/src/stores/commitDrafts.ts | 4 +- ui/src/stores/repo.test.ts | 20 ++ ui/src/stores/repo.ts | 15 +- ui/src/styles/features.css | 7 + ui/src/views/LocalChanges.tsx | 14 +- ui/src/views/TagDialog.tsx | 12 +- ui/src/views/TagVerificationDialog.tsx | 44 +++ ui/src/views/settings/GitSection.tsx | 2 + ui/src/views/settings/SigningSettings.tsx | 82 ++++++ website/docs/everyday-git.md | 22 +- website/docs/settings.md | 22 +- 29 files changed, 847 insertions(+), 68 deletions(-) create mode 100644 crates/strand-core/src/signing.rs create mode 100644 ui/src/components/SigningChoice.tsx create mode 100644 ui/src/views/TagVerificationDialog.tsx create mode 100644 ui/src/views/settings/SigningSettings.tsx diff --git a/README.md b/README.md index 8e928827..6c026f10 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,9 @@ the resolved app appearance automatically. box. Stale entries whose directories are already gone prune immediately. - **Everyday Git** — hook-aware signed/unsigned commit and amend with checkout session drafts and bounded output; effective repository author/committer identity - with local overrides in Settings → Git; stage, unstage, or recoverably discard whole change + with local overrides in Settings → Git; repository/worktree signing settings, + per-operation signing choices, and signed-tag creation/verification; stage, + unstage, or recoverably discard whole change blocks or individually selected lines inline in the diff; bulk tree actions include every selected file and every changed file beneath selected folders; initialize a repository with an initial branch, optional diff --git a/ROADMAP.md b/ROADMAP.md index 04d14832..fbba3156 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2156,7 +2156,8 @@ and Store certification remain external gates. everything through the `commands.rs` seam) are active now. - Git-flow (start/finish feature/release/hotfix; shells out to `git-flow`) - Git LFS (status badges + progress) -- GPG / SSH commit signing UI +- ☑ GPG / SSH commit signing UI (repository/worktree settings and per-operation + choices; signed-tag creation and verification, 2026-09-06). - Selectable beta updater channel (1.0 remains pinned to the signed stable GitHub Releases channel) - Opt-in product telemetry only if a concrete post-1.0 decision, disclosure, @@ -2828,6 +2829,16 @@ worktree fixtures passed; native settings save/remove and repository switching were exercised with isolated fixtures. +**Signing controls and signed tags shipped (2026-09-06, F03):** Repository and +enabled worktree settings show effective signing defaults, format, key and SSH +allowed-signers sources. Commit/amend and tag forms offer inherited, signed or +unsigned operations without changing defaults. Signed tags require an +annotation; lazy verification displays the immutable object and Git trust +diagnostics. Real GPG/SSH fixtures cover hooks, amend, linked worktrees, +unsigned overrides, tampering and failed signers. Native Windows settings, +commit/amend and palette tag flows passed; validation and platform limits are +recorded in `docs/hooks-identity-signing-validation-2026-09-06.md`. + ## Cross-cutting tracks (run in parallel with all milestones) **Performance audit kick (2026-09-06):** Rechecked `main` at `8e83c8c` on diff --git a/TASKS.md b/TASKS.md index efee4dd8..b1c8fc1f 100644 --- a/TASKS.md +++ b/TASKS.md @@ -98,9 +98,11 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git- the current author/committer identity, set/remove repo-local name/email without changing global/conditional config, and verify linked worktrees (`repository_identity` / `repo_set_identity`, Settings → Git source display). -- ☐ **F03 / P1 — Signing controls and signed tags.** Keep configured commit +- ☑ **F03 / P1 — Signing controls and signed tags.** Keep configured commit signing/verification; add scoped format/key controls and signed-tag creation - with agent delegation and visible signing failures. + with agent delegation and visible signing failures (`signing_settings` / + `set_signing_config`, commit/tag `SigningChoice`, `TagVerificationDialog`; + real GPG/SSH and native Windows evidence in the F01–F03 validation note). - ☐ **F04 / P1 — LFS compatibility and management.** First prove pointer/filter correctness across single/bulk staging, checkout, commit and network flows; then add setup/tracking/status/locks/progress. System-Git networking alone @@ -1475,6 +1477,11 @@ community plugins, performance and platform certification from Git feature gaps. and focus-restore to the opener on close (captured pre-`autoFocus`). ### Cross-cutting +- ☐ Investigate Windows watcher burst timing: unchanged + `watch::tests::debounce_collapses_a_burst_into_one_callback` observed two + callbacks instead of one in the 2026-09-06 F01–F03 final full run and isolated + retry, after earlier full-suite passes. Reproduce and distinguish OS event + delivery from debounce/test timing before changing production behavior. - ☑ Resizable panes everywhere (`react-resizable-panels`); sizes persisted per-region via `autoSaveId` (`strand:body`, `strand:lc-main`, `strand:lc-files`) diff --git a/crates/strand-core/src/commit.rs b/crates/strand-core/src/commit.rs index 56f63da8..dcb0ea73 100644 --- a/crates/strand-core/src/commit.rs +++ b/crates/strand-core/src/commit.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::{ error::{Error, Result}, repo::Repo, + signing::SigningMode, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -21,20 +22,29 @@ pub struct CommitOutcome { /// git2. This deliberately supersedes the old unsigned git2 fast path. impl Repo { pub fn commit(&self, subject: &str, body: Option<&str>, amend: bool) -> Result { + self.commit_with_signing(subject, body, amend, SigningMode::Inherit) + } + + pub fn commit_with_signing(&self, subject: &str, body: Option<&str>, amend: bool, signing: SigningMode) -> Result { let message = match body.map(str::trim).filter(|b| !b.is_empty()) { Some(b) => format!("{}\n\n{}\n", subject.trim(), b), None => format!("{}\n", subject.trim()), }; - let output = self.commit_via_git(&message, amend)?; + let output = self.commit_via_git(&message, amend, signing)?; let oid = self.git2()?.head()?.peel_to_commit()?.id().to_string(); Ok(CommitOutcome { oid, amended: amend, output }) } - fn commit_via_git(&self, message: &str, amend: bool) -> Result { + fn commit_via_git(&self, message: &str, amend: bool, signing: SigningMode) -> Result { let file = temp_message_file(message)?; let file_arg = file.to_string_lossy().into_owned(); let mut args = vec!["commit", "-F", file_arg.as_str(), "--cleanup=verbatim"]; if amend { args.push("--amend"); } + match signing { + SigningMode::Inherit => {}, + SigningMode::Sign => args.push("--gpg-sign"), + SigningMode::Unsigned => args.push("--no-gpg-sign"), + } let res = run_git(&self.path, &args); let _ = std::fs::remove_file(&file); res @@ -47,7 +57,7 @@ impl Repo { /// refuses to open a path that already exists — including a pre-planted /// symlink in the shared temp dir (local TOCTOU) — so a collision just bumps /// the counter and retries (bounded). -fn temp_message_file(message: &str) -> Result { +pub(crate) fn temp_message_file(message: &str) -> Result { use std::io::Write; use std::sync::atomic::{AtomicU64, Ordering}; static COUNTER: AtomicU64 = AtomicU64::new(0); @@ -249,7 +259,7 @@ mod tests { git(&dir, &["commit", "-q", "-m", "base"]); stage(&dir, "a.txt", "a\n"); - repo.commit_via_git("subject\n\nbody line\n", false).unwrap(); + repo.commit_via_git("subject\n\nbody line\n", false, SigningMode::Inherit).unwrap(); assert_eq!(git(&dir, &["log", "-1", "--format=%B"]), "subject\n\nbody line"); assert_eq!(git(&dir, &["rev-list", "--count", "HEAD"]), "2"); @@ -257,7 +267,7 @@ mod tests { // author and only updates the committer — assert that parity here. git(&dir, &["config", "user.name", "Other"]); git(&dir, &["config", "user.email", "other@example.com"]); - repo.commit_via_git("amended subject\n", true).unwrap(); + repo.commit_via_git("amended subject\n", true, SigningMode::Inherit).unwrap(); assert_eq!(git(&dir, &["log", "-1", "--format=%B"]), "amended subject"); assert_eq!(git(&dir, &["rev-list", "--count", "HEAD"]), "2", "amend replaces, not adds"); assert_eq!( diff --git a/crates/strand-core/src/gitconfig.rs b/crates/strand-core/src/gitconfig.rs index 1388f6c9..dfa7caf8 100644 --- a/crates/strand-core/src/gitconfig.rs +++ b/crates/strand-core/src/gitconfig.rs @@ -31,11 +31,11 @@ pub struct RepositoryIdentity { pub local: GlobalIdentity, } -type ConfigValues = std::collections::BTreeMap; +pub(crate) type ConfigValues = std::collections::BTreeMap; -fn config_values(repo: &Repo, local: bool, pattern: &str) -> Result { +pub(crate) fn config_values(repo: &Repo, scope: Option<&str>, pattern: &str) -> Result { let mut args = vec!["config", "--null", "--show-scope", "--show-origin"]; - if local { args.extend(["--local", "--no-includes"]); } + if let Some(scope) = scope { args.extend([scope, "--no-includes"]); } else { args.push("--includes"); } args.extend(["--get-regexp", pattern]); let out = config_git(repo, &args)?; @@ -50,8 +50,13 @@ fn config_values(repo: &Repo, local: bool, pattern: &str) -> Result "true", Some("") => "false", Some(value) => value } + } else { value.unwrap_or_default() }; values.insert(key.to_owned(), ScopedValue { value: value.to_owned(), scope: scope.to_owned(), origin: origin.to_owned(), }); @@ -84,8 +89,8 @@ impl Repo { /// includes, worktree config, author/committer overrides and environment. /// Only queried on the settings surface, never on status/log refresh. pub fn repository_identity(&self) -> Result { - let values = config_values(self, false, "^(user|author|committer)\\.(name|email)$")?; - let local = config_values(self, true, "^user\\.(name|email)$")?; + let values = config_values(self, None, "^(user|author|committer)\\.(name|email)$")?; + let local = config_values(self, Some("--local"), "^user\\.(name|email)$")?; let identity = |role: &str| -> Result { let variable = format!("GIT_{}_IDENT", role.to_uppercase()); let out = config_git(self, &["var", &variable])?; @@ -117,7 +122,7 @@ impl Repo { self.set_scoped_config("--local", key, value) } - fn set_scoped_config(&self, scope: &str, key: &str, value: Option<&str>) -> Result<()> { + pub(crate) fn set_scoped_config(&self, scope: &str, key: &str, value: Option<&str>) -> Result<()> { if value.is_some_and(|v| v.trim().is_empty() || v.len() > 4096 || v.contains(['\0', '\r', '\n'])) { return Err(Error::Other("Use a non-empty, single-line config value (up to 4096 bytes), or remove the override".into())); } diff --git a/crates/strand-core/src/lib.rs b/crates/strand-core/src/lib.rs index 8c48a803..bb481b67 100644 --- a/crates/strand-core/src/lib.rs +++ b/crates/strand-core/src/lib.rs @@ -22,6 +22,7 @@ pub mod diff; pub mod stage; pub mod apply; pub mod commit; +pub mod signing; pub mod commit_metadata; pub mod network; pub mod refs; diff --git a/crates/strand-core/src/signing.rs b/crates/strand-core/src/signing.rs new file mode 100644 index 00000000..fe706fdb --- /dev/null +++ b/crates/strand-core/src/signing.rs @@ -0,0 +1,270 @@ +//! Operation-level signing choices. Inherit leaves Git configuration intact. +use serde::{Deserialize, Serialize}; +use crate::{Error, Repo, Result}; +use crate::gitconfig::{ConfigValues, config_values}; + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SigningMode { + #[default] + Inherit, + Sign, + Unsigned, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SigningScope { Local, Worktree } + +#[derive(Debug, Serialize)] +pub struct SigningSettings { + pub effective: ConfigValues, + pub local: ConfigValues, + pub worktree: ConfigValues, + pub worktree_enabled: bool, + pub commit_sign: bool, + pub tag_sign: bool, + pub tag_force_annotated: bool, +} + +const SETTINGS: &str = "^(commit\\.gpgsign|tag\\.(gpgsign|forcesignannotated)|user\\.signingkey|gpg\\.(format|ssh\\.allowedsignersfile))$"; + +pub(crate) fn git_bool(repo: &Repo, key: &str) -> Result { + let out = crate::git_output::capture(crate::git_command().current_dir(&repo.path) + .args(crate::GIT_SAFE_CONFIG).args(["config", "--type=bool", "--get", key]))?; + if out.status.code() == Some(1) { return Ok(false); } + if !out.status.success() { + return Err(Error::Other(String::from_utf8_lossy(&out.stderr).trim().to_owned())); + } + Ok(String::from_utf8_lossy(&out.stdout).trim() == "true") +} + +impl Repo { + /// On-demand settings only: no signing/config subprocesses in snapshots. + pub fn signing_settings(&self) -> Result { + let worktree_enabled = git_bool(self, "extensions.worktreeConfig")?; + let effective = config_values(self, None, SETTINGS)?; + let local = config_values(self, Some("--local"), SETTINGS)?; + // Read the direct worktree file, without following any includes. Do + // not enable worktreeConfig implicitly (that may require migration). + let worktree = if worktree_enabled { + config_values(self, Some("--worktree"), SETTINGS)? + } else { ConfigValues::new() }; + Ok(SigningSettings { + effective, local, worktree, worktree_enabled, + commit_sign: git_bool(self, "commit.gpgsign")?, + tag_sign: git_bool(self, "tag.gpgsign")?, + tag_force_annotated: git_bool(self, "tag.forceSignAnnotated")?, + }) + } + + pub fn set_signing_config(&self, scope: SigningScope, key: &str, value: Option<&str>) -> Result<()> { + match key { + "commit.gpgsign" | "tag.gpgsign" | "tag.forcesignannotated" => { + if value.is_some_and(|v| v != "true" && v != "false") { + return Err(Error::Other("Signing state must be true, false, or inherited".into())); + } + } + "gpg.format" => { + if value.is_some_and(|v| !["openpgp", "ssh", "x509"].contains(&v)) { + return Err(Error::Other("Select OpenPGP, SSH, or X.509 signing".into())); + } + } + "user.signingkey" | "gpg.ssh.allowedsignersfile" => {}, + _ => return Err(Error::Other("Unknown signing setting".into())), + } + if key == "user.signingkey" && value.is_some_and(|v| v.contains("-----BEGIN ") && v.contains("PRIVATE KEY-----")) { + return Err(Error::Other("Enter a key ID, public key, or path; private key material is not stored by Strand".into())); + } + let arg = match scope { + SigningScope::Local => "--local", + SigningScope::Worktree => { + if !git_bool(self, "extensions.worktreeConfig")? { + return Err(Error::Other("Enable extensions.worktreeConfig with Git before writing worktree settings".into())); + } + "--worktree" + } + }; + self.set_scoped_config(arg, key, value) + } +} + + +#[cfg(test)] +mod tests { + use super::*; + use std::{path::{Path, PathBuf}, process::Command}; + use crate::tag::TagVerificationStatus; + use crate::commit_metadata::CommitSignatureStatus; + + fn fixture(kind: &str) -> (Repo, PathBuf) { + let dir = std::env::temp_dir().join(format!("strand-signing-{kind}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let g2 = git2::Repository::init(&dir).unwrap(); + let mut config = g2.config().unwrap(); + for (key, value) in [("user.name", "Signer"), ("user.email", "signer@example.com"), + ("commit.gpgsign", "false"), ("tag.gpgsign", "false"), ("tag.forcesignannotated", "false"), ("core.hooksPath", "hooks")] { + config.set_str(key, value).unwrap(); + } + std::fs::write(dir.join("file.txt"), "signed content\n").unwrap(); + let mut index = g2.index().unwrap(); index.add_path(Path::new("file.txt")).unwrap(); index.write().unwrap(); + (Repo::discover(&dir).unwrap(), dir) + } + fn command(program: &Path, args: &[&str]) -> String { + let out = Command::new(program).args(args).output().unwrap(); + assert!(out.status.success(), "{:?}: {}", program, String::from_utf8_lossy(&out.stderr)); + String::from_utf8_lossy(&out.stdout).into_owned() + } + fn executable(path: &Path, contents: &str) { + std::fs::write(path, contents).unwrap(); + #[cfg(unix)] { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + fn sign_suite(repo: &Repo, dir: &Path) { + std::fs::create_dir_all(dir.join("hooks")).unwrap(); + executable(&dir.join("hooks/commit-msg"), "#!/bin/sh\necho signed-hook-rewrite >> \"$1\"\necho signed-hook-output >&2\n"); + let initial = repo.commit_with_signing("signed subject", None, false, SigningMode::Sign).unwrap(); + assert!(initial.output.contains("signed-hook-output")); + assert!(repo.git2().unwrap().head().unwrap().peel_to_commit().unwrap().message().unwrap().contains("signed-hook-rewrite")); + assert_eq!(repo.commit_signature(&initial.oid).unwrap().status, CommitSignatureStatus::Verified); + repo.set_signing_config(SigningScope::Local, "commit.gpgsign", Some("true")).unwrap(); + let amended = repo.commit("signed amend", None, true).unwrap(); + assert_ne!(initial.oid, amended.oid); + assert_eq!(repo.commit_signature(&amended.oid).unwrap().status, CommitSignatureStatus::Verified); + let unsigned = repo.commit_with_signing("unsigned amend", None, true, SigningMode::Unsigned).unwrap(); + assert_eq!(repo.commit_signature(&unsigned.oid).unwrap().status, CommitSignatureStatus::Unsigned); + assert!(repo.signing_settings().unwrap().commit_sign, "operation override did not change config"); + repo.create_tag_with_signing("signed-tag", None, Some("signed annotation"), false, SigningMode::Sign).unwrap(); + assert!(matches!(repo.verify_tag("signed-tag").unwrap().status, TagVerificationStatus::Verified)); + { + let g2 = repo.git2().unwrap(); + let odb = g2.odb().unwrap(); + let tag_oid = g2.refname_to_id("refs/tags/signed-tag").unwrap(); + let object = odb.read(tag_oid).unwrap(); + let changed = String::from_utf8_lossy(object.data()).replacen("signed annotation", "tampered annotation", 1); + let changed_oid = odb.write(git2::ObjectType::Tag, changed.as_bytes()).unwrap(); + g2.reference("refs/tags/tampered-tag", changed_oid, false, "test tampered signature").unwrap(); + assert!(matches!(repo.verify_tag("tampered-tag").unwrap().status, TagVerificationStatus::Failed)); + } + repo.set_signing_config(SigningScope::Local, "tag.gpgsign", Some("true")).unwrap(); + assert!(repo.create_tag("missing-annotation", None, None, false).is_err()); + repo.create_tag("inherited-tag", None, Some("inherited"), false).unwrap(); + assert!(matches!(repo.verify_tag("inherited-tag").unwrap().status, TagVerificationStatus::Verified)); + repo.set_signing_config(SigningScope::Local, "tag.gpgsign", Some("false")).unwrap(); + repo.set_signing_config(SigningScope::Local, "tag.forcesignannotated", Some("true")).unwrap(); + repo.create_tag("forced-annotation", None, Some("force signed"), false).unwrap(); + assert!(matches!(repo.verify_tag("forced-annotation").unwrap().status, TagVerificationStatus::Verified)); + repo.create_tag("inherited-light", None, None, false).unwrap(); + assert!(matches!(repo.verify_tag("inherited-light").unwrap().status, TagVerificationStatus::Unsigned)); + repo.set_signing_config(SigningScope::Local, "tag.gpgsign", Some("true")).unwrap(); + repo.create_tag_with_signing("unsigned-tag", None, Some("unsigned annotation"), false, SigningMode::Unsigned).unwrap(); + repo.create_tag_with_signing("light-tag", None, None, false, SigningMode::Unsigned).unwrap(); + assert!(matches!(repo.verify_tag("unsigned-tag").unwrap().status, TagVerificationStatus::Unsigned)); + assert!(matches!(repo.verify_tag("light-tag").unwrap().status, TagVerificationStatus::Unsigned)); + let linked = dir.join("linked-checkout"); + command(Path::new("git"), &["-C", dir.to_str().unwrap(), "worktree", "add", "-b", "linked", linked.to_str().unwrap()]); + let worktree = Repo::discover(&linked).unwrap(); + let linked_commit = worktree.commit("linked signed amend", None, true).unwrap(); + assert_eq!(worktree.commit_signature(&linked_commit.oid).unwrap().status, CommitSignatureStatus::Verified); + repo.git2().unwrap().config().unwrap().set_bool("extensions.worktreeConfig", true).unwrap(); + worktree.set_signing_config(SigningScope::Worktree, "commit.gpgsign", Some("false")).unwrap(); + assert!(!worktree.signing_settings().unwrap().commit_sign); + assert!(repo.signing_settings().unwrap().commit_sign); + let linked_unsigned = worktree.commit("linked unsigned amend", None, true).unwrap(); + assert_eq!(worktree.commit_signature(&linked_unsigned.oid).unwrap().status, CommitSignatureStatus::Unsigned); + assert_eq!(repo.git2().unwrap().head().unwrap().target().unwrap().to_string(), unsigned.oid); + executable(&dir.join("hooks/pre-commit"), "#!/bin/sh\necho signed-hook-rejected >&2\nexit 1\n"); + assert!(repo.commit("reject", None, true).unwrap_err().to_string().contains("signed-hook-rejected")); + assert_eq!(repo.git2().unwrap().head().unwrap().target().unwrap().to_string(), unsigned.oid); + std::fs::remove_file(dir.join("hooks/pre-commit")).unwrap(); + repo.set_signing_config(SigningScope::Local, "user.signingkey", Some("strand-no-such-key")).unwrap(); + assert!(repo.commit("bad signer", None, true).is_err()); + assert!(repo.create_tag_with_signing("failed-tag", None, Some("failed"), false, SigningMode::Sign).is_err()); + assert!(repo.git2().unwrap().find_reference("refs/tags/failed-tag").is_err()); + assert_eq!(repo.git2().unwrap().head().unwrap().target().unwrap().to_string(), unsigned.oid); + } + + #[test] + fn signing_settings_distinguish_valueless_and_empty_git_booleans() { + let (repo, dir) = fixture("booleans"); + let config_path = dir.join(".git/config"); + let mut config = std::fs::read_to_string(&config_path).unwrap(); + config.push_str("\n[commit]\ngpgsign\n[tag]\ngpgsign =\nforcesignannotated = yes\n"); + std::fs::write(config_path, config).unwrap(); + let state = repo.signing_settings().unwrap(); + assert!(state.commit_sign); + assert!(!state.tag_sign); + assert!(state.tag_force_annotated); + assert_eq!(state.local["commit.gpgsign"].value, "true"); + assert_eq!(state.local["tag.gpgsign"].value, "false"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn signing_settings_are_scoped_and_do_not_enable_worktree_config_implicitly() { + let (repo, dir) = fixture("scope"); + let (other, other_dir) = fixture("other-scope"); + assert!(repo.set_signing_config(SigningScope::Worktree, "commit.gpgsign", Some("true")).is_err()); + repo.set_signing_config(SigningScope::Local, "commit.gpgsign", Some("true")).unwrap(); + assert!(repo.signing_settings().unwrap().commit_sign); + assert!(!other.signing_settings().unwrap().commit_sign); + let g2 = repo.git2().unwrap(); + g2.config().unwrap().set_bool("extensions.worktreeConfig", true).unwrap(); + repo.set_signing_config(SigningScope::Worktree, "commit.gpgsign", Some("false")).unwrap(); + let state = repo.signing_settings().unwrap(); + assert_eq!(state.local["commit.gpgsign"].value, "true"); + assert_eq!(state.worktree["commit.gpgsign"].value, "false"); + assert_eq!(state.effective["commit.gpgsign"].scope, "worktree"); + assert!(!state.commit_sign); + repo.set_signing_config(SigningScope::Worktree, "commit.gpgsign", None).unwrap(); + assert!(repo.signing_settings().unwrap().commit_sign); + assert!(repo.set_signing_config(SigningScope::Local, "core.hooksPath", Some("unrelated")).is_err()); + assert!(repo.set_signing_config(SigningScope::Local, "user.signingkey", Some("-----BEGIN OPENSSH PRIVATE KEY-----")).is_err()); + let _ = std::fs::remove_dir_all(dir); + let _ = std::fs::remove_dir_all(other_dir); + } + + #[test] + #[ignore = "integration fixture requires ssh-keygen"] + fn ssh_commit_amend_hooks_tags_and_failures() { + let (repo, dir) = fixture("ssh"); + let key = dir.join("signing-key"); + command(Path::new("ssh-keygen"), &["-q", "-t", "ed25519", "-N", "", "-f", key.to_str().unwrap()]); + let allowed = dir.join("allowed-signers"); + let public = std::fs::read_to_string(dir.join("signing-key.pub")).unwrap(); + std::fs::write(&allowed, format!("signer@example.com {public}")).unwrap(); + repo.set_signing_config(SigningScope::Local, "gpg.format", Some("ssh")).unwrap(); + repo.set_signing_config(SigningScope::Local, "user.signingkey", key.to_str()).unwrap(); + repo.set_signing_config(SigningScope::Local, "gpg.ssh.allowedsignersfile", allowed.to_str()).unwrap(); + sign_suite(&repo, &dir); + // Missing allowed signers is visibly different from unsigned. + repo.set_signing_config(SigningScope::Local, "gpg.ssh.allowedsignersfile", Some("missing-signers-file")).unwrap(); + assert!(matches!(repo.verify_tag("signed-tag").unwrap().status, TagVerificationStatus::Failed)); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + #[ignore = "integration fixture requires GPG (Git for Windows includes it)"] + fn gpg_commit_amend_hooks_tags_and_failures() { + let (repo, dir) = fixture("gpg"); + let gpg = if cfg!(windows) { PathBuf::from("C:/Program Files/Git/usr/bin/gpg.exe") } else { PathBuf::from("/usr/bin/gpg") }; + let home = dir.join("gnupg"); std::fs::create_dir_all(&home).unwrap(); + let native_home = home.to_string_lossy().replace('\\', "/"); + // Git for Windows ships MSYS GPG: its Unix socket path cannot contain + // a drive colon. Native GPG elsewhere takes the original absolute path. + let home_arg = if cfg!(windows) { format!("/{}{}", native_home[..1].to_lowercase(), &native_home[2..]) } else { native_home }; + command(&gpg, &["--homedir", &home_arg, "--batch", "--pinentry-mode", "loopback", "--passphrase", "", "--quick-generate-key", "Signer ", "ed25519", "sign", "0"]); + let quote = |path: &Path| format!("'{}'", path.to_string_lossy().replace('\\', "/").replace('\'', "'\"'\"'")); + let wrapper = dir.join("fixture-gpg"); + executable(&wrapper, &format!("#!/bin/sh\nexec {} --homedir {} \"$@\"\n", quote(&gpg), quote(Path::new(&home_arg)))); + repo.git2().unwrap().config().unwrap().set_str("gpg.program", wrapper.to_str().unwrap()).unwrap(); + repo.set_signing_config(SigningScope::Local, "gpg.format", Some("openpgp")).unwrap(); + repo.set_signing_config(SigningScope::Local, "user.signingkey", Some("signer@example.com")).unwrap(); + sign_suite(&repo, &dir); + let gpgconf = gpg.with_file_name(if cfg!(windows) { "gpgconf.exe" } else { "gpgconf" }); + command(&gpgconf, &["--homedir", &home_arg, "--kill", "gpg-agent"]); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/strand-core/src/tag.rs b/crates/strand-core/src/tag.rs index 4a747c66..3d8843b1 100644 --- a/crates/strand-core/src/tag.rs +++ b/crates/strand-core/src/tag.rs @@ -1,11 +1,31 @@ //! Tag writes — create (lightweight + annotated) and delete. //! //! Tag *reads* live in `refs.rs` (`collect_tags`); this is the mutating side. -//! All ops go through `git2`, matching the branch-write policy (stable Rust -//! API, no spawn overhead). Pushing tags to a remote is a separate concern — -//! `git push` doesn't send tags by default — and is tracked as future work. +//! Creation uses system Git for inherited signing and agent/key configuration. -use crate::{error::Result, repo::Repo}; +use serde::{Deserialize, Serialize}; +use crate::{Error, Result, repo::Repo, signing::{SigningMode, git_bool}}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TagVerificationStatus { Unsigned, Verified, Failed } + +#[derive(Debug, Serialize, Deserialize)] +pub struct TagVerification { + pub oid: String, + pub status: TagVerificationStatus, + pub output: String, +} + +fn run_tag_git(repo: &Repo, args: &[&str]) -> Result { + crate::git_output::capture(crate::git_command().current_dir(&repo.path) + .env("GIT_TERMINAL_PROMPT", "0").env("GIT_EDITOR", ":") + .args(crate::GIT_SAFE_CONFIG).args(args)) +} + +fn transcript(output: &std::process::Output) -> String { + format!("{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)).trim().to_owned() +} impl Repo { /// Create a tag pointing at `target` (any revspec git understands — an @@ -13,7 +33,8 @@ impl Repo { /// /// When `message` is `Some` and non-empty, an **annotated** tag is created /// (the tagger is pulled from the repo's git config — `user.name` / - /// `user.email`); otherwise a **lightweight** tag. `force` mirrors + /// `user.email`); otherwise a **lightweight** tag. Inherited signing may + /// require an annotation. `force` mirrors /// `git tag -f`: overwrite an existing tag of the same name instead of /// erroring. pub fn create_tag( @@ -23,24 +44,80 @@ impl Repo { message: Option<&str>, force: bool, ) -> Result<()> { + self.create_tag_with_signing(name, target, message, force, SigningMode::Inherit) + } + + pub fn create_tag_with_signing( + &self, name: &str, target: Option<&str>, message: Option<&str>, force: bool, signing: SigningMode, + ) -> Result<()> { + if name.starts_with('-') || !git2::Reference::is_valid_name(&format!("refs/tags/{name}")) { + return Err(Error::Other("Invalid tag name".into())); + } let repo = self.git2()?; - let rev = target.unwrap_or("HEAD"); - // Peel to a commit so both flavours tag the commit (not, say, the - // tag object of an already-annotated revspec). - let object = repo.revparse_single(rev)?.peel(git2::ObjectType::Commit)?; - - match message { - Some(msg) if !msg.trim().is_empty() => { - let tagger = repo.signature()?; - repo.tag(name, &object, &tagger, msg, force)?; - } - _ => { - repo.tag_lightweight(name, &object, force)?; - } + let object = repo.revparse_single(target.unwrap_or("HEAD"))?.peel(git2::ObjectType::Commit)?; + let oid = object.id().to_string(); + let message = message.filter(|msg| !msg.trim().is_empty()); + let signed = match signing { + SigningMode::Sign => true, + SigningMode::Unsigned => false, + SigningMode::Inherit => git_bool(self, "tag.gpgsign")? + || (message.is_some() && git_bool(self, "tag.forceSignAnnotated")?), + }; + if signed && message.is_none() { + return Err(Error::Other("A signed tag requires an annotation message".into())); + } + // Verbatim cleanup requires a final LF before Git appends the signature. + let file = message.map(|message| crate::commit::temp_message_file(&format!("{}\n", message.trim_end_matches(['\r', '\n'])))).transpose()?; + let file_arg = file.as_ref().map(|path| path.to_string_lossy().into_owned()); + let mut args = vec!["tag"]; + match signing { + SigningMode::Sign => args.push("--sign"), + SigningMode::Unsigned => args.push("--no-sign"), + SigningMode::Inherit => {}, + } + if force { args.push("--force"); } + if let Some(file_arg) = file_arg.as_deref() { + // --file already creates an annotation. Explicit --annotate + // suppresses tag.forceSignAnnotated, so use it only for the + // operation's unsigned override (alongside --no-sign). + if matches!(signing, SigningMode::Unsigned) { args.push("--annotate"); } + args.extend(["--cleanup=verbatim", "--file", file_arg]); + } + args.extend(["--", name, &oid]); + let result = run_tag_git(self, &args); + if let Some(file) = file { let _ = std::fs::remove_file(file); } + let output = result?; + if !output.status.success() { + let text = transcript(&output); + return Err(Error::Other(if text.is_empty() { "Git could not create the tag".into() } else { text })); } Ok(()) } + /// Verify the immutable tag object selected by this exact short tag name. + /// No graph-wide verification or trusting a mutable ref after resolution. + pub fn verify_tag(&self, name: &str) -> Result { + let repo = self.git2()?; + let reference = repo.find_reference(&format!("refs/tags/{name}"))?; + let target = reference.resolve()?.target().ok_or_else(|| Error::Other("Tag has no target".into()))?; + let object = repo.find_object(target, None)?; + let oid = object.id().to_string(); + let tag = object.as_tag(); + let signed = tag.is_some_and(|tag| { + let message = String::from_utf8_lossy(tag.message_bytes().unwrap_or_default()); + ["-----BEGIN PGP SIGNATURE-----", "-----BEGIN SSH SIGNATURE-----", "-----BEGIN SIGNED MESSAGE-----"] + .iter().any(|marker| message.contains(marker)) + }); + if !signed { + return Ok(TagVerification { oid, status: TagVerificationStatus::Unsigned, output: "This tag has no signature.".into() }); + } + let output = run_tag_git(self, &["verify-tag", "--raw", &oid])?; + Ok(TagVerification { + oid, status: if output.status.success() { TagVerificationStatus::Verified } else { TagVerificationStatus::Failed }, + output: transcript(&output), + }) + } + /// Delete a tag by short name (e.g. `v1.0.0`). Local only — a tag already /// pushed to a remote stays there until deleted on the remote too. pub fn delete_tag(&self, name: &str) -> Result<()> { @@ -66,6 +143,9 @@ mod tests { std::fs::create_dir_all(&dir).unwrap(); let repo = git2::Repository::init(&dir).unwrap(); + for (key, value) in [("user.name", "Test"), ("user.email", "test@example.com"), ("tag.gpgsign", "false"), ("tag.forcesignannotated", "false")] { + repo.config().unwrap().set_str(key, value).unwrap(); + } { let sig = git2::Signature::now("Test", "test@example.com").unwrap(); let tree_oid = { diff --git a/crates/strand-tauri/src/commands.rs b/crates/strand-tauri/src/commands.rs index 7ec9623c..b50f51ab 100644 --- a/crates/strand-tauri/src/commands.rs +++ b/crates/strand-tauri/src/commands.rs @@ -19,6 +19,8 @@ use strand_azdo_protocol::ServerProfile; use strand_core::{ apply::ApplyTarget, blame::BlameLine, branch::CheckoutOutcome, commit::CommitOutcome, commit_metadata::CommitSignature, + signing::{SigningMode, SigningScope, SigningSettings}, + tag::TagVerification, diff::FileDiff, file::{BlobSource, FileBlob, FileContent, FileHistoryEntry}, gitconfig::{self, GlobalIdentity, RepositoryIdentity}, init::{init_repository, InitOutcome}, @@ -904,9 +906,10 @@ pub async fn repo_commit( subject: String, body: Option, amend: bool, + signing: Option, ) -> CmdResult { run_blocking("commit", move || { - Ok(Repo::discover(&path)?.commit(&subject, body.as_deref(), amend)?) + Ok(Repo::discover(&path)?.commit_with_signing(&subject, body.as_deref(), amend, signing.unwrap_or_default())?) }).await } @@ -1499,15 +1502,32 @@ pub async fn repo_maintenance( } #[tauri::command(async)] -pub fn repo_tag_create( +pub async fn repo_tag_create( path: String, name: String, target: Option, message: Option, force: bool, + signing: Option, ) -> CmdResult<()> { - Repo::discover(&path)?.create_tag(&name, target.as_deref(), message.as_deref(), force)?; - Ok(()) + run_blocking("tag", move || { + Ok(Repo::discover(&path)?.create_tag_with_signing(&name, target.as_deref(), message.as_deref(), force, signing.unwrap_or_default())?) + }).await +} + +#[tauri::command(async)] +pub async fn repo_tag_verify(path: String, name: String) -> CmdResult { + run_blocking("verify-tag", move || Ok(Repo::discover(&path)?.verify_tag(&name)?)).await +} + +#[tauri::command(async)] +pub async fn repo_signing_settings(path: String) -> CmdResult { + run_blocking("signing-settings", move || Ok(Repo::discover(&path)?.signing_settings()?)).await +} + +#[tauri::command(async)] +pub async fn repo_set_signing_config(path: String, scope: SigningScope, key: String, value: Option) -> CmdResult<()> { + run_blocking("signing-settings", move || Ok(Repo::discover(&path)?.set_signing_config(scope, &key, value.as_deref())?)).await } #[tauri::command(async)] diff --git a/crates/strand-tauri/src/main.rs b/crates/strand-tauri/src/main.rs index 9f89640c..0946b638 100644 --- a/crates/strand-tauri/src/main.rs +++ b/crates/strand-tauri/src/main.rs @@ -293,6 +293,9 @@ fn main() { commands::repo_open_mergetool, commands::repo_open_in_editor, commands::repo_open_in_terminal, + commands::repo_tag_verify, + commands::repo_signing_settings, + commands::repo_set_signing_config, commands::repo_identity, commands::repo_set_identity, commands::git_global_identity, diff --git a/docs/hooks-identity-signing-validation-2026-09-06.md b/docs/hooks-identity-signing-validation-2026-09-06.md index 6afc2a1c..d19a052f 100644 --- a/docs/hooks-identity-signing-validation-2026-09-06.md +++ b/docs/hooks-identity-signing-validation-2026-09-06.md @@ -24,9 +24,11 @@ Evidence: to Commits and back retained the draft; retry ran message rewriting and exposed successful hook output. Verified the resulting commit message with Git. Screenshots retained under `target/verify-f010203/f01-*.png` (local only). -- Manual 25-iteration debug measurement, while other tasks were compiling: - system Git median **529.01 ms**, p95 **797.32 ms**; former git2 algorithm median - **24.50 ms**, p95 **97.98 ms**. This is an explicit correctness cost on commit, +- Manual 25-iteration debug measurement after concurrent builds settled: + system Git median **81.01 ms**, p95 **87.72 ms**; former git2 algorithm median + **7.59 ms**, p95 **8.47 ms**. Under earlier concurrent compilation these were + 529.01/797.32 ms and 24.50/97.98 ms respectively (median/p95). + This is an explicit correctness cost on commit, not a status/staging hot-path change or an idle performance certification. Reproduce with `cargo test -p strand-core measure_no_hook_commit_path -- --ignored --nocapture`. Both measurements exclude staging. @@ -50,3 +52,62 @@ Git contracts: [hooks](https://git-scm.com/docs/githooks), - Frontend TypeScript and `cargo check -p strand-core -p strand-tauri` passed. Git contracts: [config scope and includes](https://git-scm.com/docs/git-config). + +## F03 — signing controls and signed tags + +Commit/amend and tag creation accept inherit/sign/unsigned without writing a +configuration override. Inheritance is resolved by system Git; explicit +unsigned annotated tags suppress both `tag.gpgSign` and `tag.forceSignAnnotated`. +Settings show effective values/provenance and save/remove only direct local or +explicitly enabled worktree keys. Signing uses Git's existing agents, signing +program and key references. Verification resolves an immutable tag object and +returns unsigned/verified/failed plus bounded Git output; validity does not +silently imply signer trust. No graph-wide verification was added. + +Evidence: +- Full core suite passed at 167 tests before the final boolean-parser fixture. + The final run had **167 passed, one failed, three ignored**: unchanged + `watch::tests::debounce_collapses_a_burst_into_one_callback` observed two + callbacks instead of one, and failed again in isolation. The watcher source, + core dependency manifest and lockfile are unchanged from the task base. + This is recorded as a follow-up; the final full core suite is not green. + All hook/identity/signing/tag tests passed, including the added boolean case. + Full frontend suite: 75 files, 430 tests passed, + including refresh failure after successful signing and switching + repositories while a signer runs. +- Explicit real GPG and SSH fixtures both passed. Each covers new signed + commit, inherited signed amend, per-operation unsigned amend without config + mutation, hooks rewriting/rejecting signed commits, explicit/inherited/ + force-annotated signed tags, unsigned annotated/lightweight overrides, + tampered-signature failure, and missing-key commit/tag failure with unchanged + HEAD and no failed tag ref. SSH also covers a missing allowed signers file. +- Each real-signature fixture exercises a linked worktree, inherited signing, + then a worktree-only unsigned default while common local defaults and the + main checkout's HEAD remain intact. A separate scope test rejects worktree + writes when the extension is disabled and confirms removal restores inherited + values without affecting another repository. +- Config parsing distinguishes Git's valueless boolean (`true`) from an + explicitly empty boolean (`false`), with a fixture for both and the `yes` alias. +- Native WebView2: Settings → Git saved SSH format, key reference, allowed + signers path and commit/tag defaults. Ctrl+Enter created a verified signed + commit; amend also verified. The palette opened signed-tag creation and + verification. A missing-key amend preserved HEAD, draft and signing choice. + The final native pass used Enter in the palette, confirmed a failed signed + tag kept name/message/choice and created no ref, retried it unsigned against + both signing defaults, and verified an inherited force-annotated signature. + Worktree-scoped save/remove restored inheritance while main config stayed + unchanged. + Screenshots are local under `target/verify-f010203/f03-*.png`. +- `cargo check -p strand-core -p strand-tauri` and frontend TypeScript passed. + +The fixtures caught two Git tag details: verbatim messages need a final LF +before the appended signature, and explicit `--annotate` suppresses +`tag.forceSignAnnotated`. Inherited tag creation uses `--file` without that +override. See Git's [tag implementation](https://github.com/git/git/blob/v2.45.1/builtin/tag.c) +and [tag configuration](https://git-scm.com/docs/git-config#Documentation/git-config.txt-tagforceSignAnnotated). + +Reproduce the real-key fixtures with `cargo test -p strand-core signing::tests +-- --ignored --nocapture`. They use generated, disposable keys and isolated +GPG homes; they do not change the user's global Git configuration or keyring. +Validation host: Windows, Git 2.45.1.windows.1. macOS/Linux runtime behavior, +X.509, hardware-backed keys and interactive pinentry were not exercised. diff --git a/docs/learnings.md b/docs/learnings.md index 0cfb6ba1..eea38cc3 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -2512,3 +2512,21 @@ through an include. Show both the saved local values and the effective values: a later conditional include or a worktree/environment override can still win. Linked worktrees share local config; `--worktree` writes must never silently fall back to `--local` when `extensions.worktreeConfig` is disabled. + +## Signing policy must remain Git-compatible (2026-09-06) + +Signing settings store only key references and existing-agent configuration. +Operation-level inherit/sign/unsigned choices never rewrite config, and signing +or hook failures retain the draft. Tag creation runs system Git too: `--file` +already creates an annotated tag, while explicit `--annotate` suppresses +`tag.forceSignAnnotated`. Use that override only for an explicitly unsigned +annotation, alongside `--no-sign`. With verbatim cleanup, ensure a final newline +before Git appends the signature or the result cannot be verified. + +Verify tags lazily against their immutable object ID. Display Git's verification +output and distinguish unsigned, valid and failed results; do not turn signature +validity into an unconditional claim of signer trust. Keep config reads and +signature verification out of status/snapshot and graph-wide refresh paths. +In `git config --null --get-regexp` output, a valueless boolean has no newline +separator and means true; a newline followed by an empty value means false. +Preserve that distinction in settings displays and scoped editing. diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 66c63c90..546d94c5 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -55,6 +55,7 @@ import type { SettingsSectionId } from './views/SettingsDialog'; import { StashDialog } from './views/StashDialog'; import { BranchDialog } from './views/BranchDialog'; import { TagDialog } from './views/TagDialog'; +import { TagVerificationDialog } from './views/TagVerificationDialog'; import { MergeDialog } from './views/MergeDialog'; import { RemoteDialog, type RemoteDialogMode } from './views/RemoteDialog'; import { FileEntryDialog } from './views/FileEntryDialog'; @@ -341,6 +342,7 @@ export function App() { const stashDialogRequest = useRepo((s) => s.stashDialogRequest); const clearStashDialogRequest = useRepo((s) => s.clearStashDialogRequest); // null = closed; otherwise the tag target (revspec, null ⇒ HEAD) + its label. + const [tagVerification, setTagVerification] = useState<{ path: string; name: string | null } | null>(null); const [tagDialog, setTagDialog] = useState<{ target: string | null; label: string } | null>(null); const [branchDialog, setBranchDialog] = useState<{ start: string | null; @@ -488,6 +490,11 @@ export function App() { setSettingsSection(section); setSettingsOpen(true); }, []); + useEffect(() => { + const open = () => openSettingsAt('git'); + window.addEventListener('strand:open-git-settings', open); + return () => window.removeEventListener('strand:open-git-settings', open); + }, [openSettingsAt]); useEffect(() => { void restoreWorkbench(workbenchWorkspaceId); @@ -1905,6 +1912,7 @@ export function App() { }, } satisfies PaletteAction] : []), ]), + { id: 'verify-tag', label: 'Verify tag signature…', group: 'Actions', keywords: 'gpg ssh signed tag trust', run: () => { const path = useRepo.getState().activePath; if (path) setTagVerification({ path, name: null }); } }, { id: 'tag', label: 'Create tag…', group: 'Actions', run: () => setTagDialog({ target: null, label: 'HEAD' }) }, { id: 'push-tags', label: 'Push all tags', group: 'Actions', keywords: 'push upload publish tags remote', run: onPushAllTags }, { id: 'fetch', label: 'Fetch', group: 'Actions', shortcut: keyHint('fetch'), keywords: 'fetch remote refs download', run: onFetch }, @@ -1946,7 +1954,7 @@ export function App() { base.push( { id: 'settings', label: 'Settings…', group: 'Actions', shortcut: keyHint('settings'), keywords: 'preferences shortcuts keyboard config options', run: () => openSettingsAt('appearance') }, { id: 'keybindings', label: 'Settings: Keyboard shortcuts', group: 'Actions', keywords: 'keyboard shortcuts keybindings rebind configure customize', run: () => openSettingsAt('keyboard') }, - { id: 'settings-git', label: 'Settings: Repository identity and Git', group: 'Actions', keywords: 'author committer name email local override config', run: () => openSettingsAt('git') }, + { id: 'settings-git', label: 'Settings: Repository identity and signing', group: 'Actions', keywords: 'author committer name email local override config gpg ssh key sign tags', run: () => openSettingsAt('git') }, { id: 'settings-ai', label: 'Settings: AI', group: 'Actions', keywords: 'ai chatgpt codex claude commit message suggest login', run: () => openSettingsAt('ai') }, { id: 'settings-plugins', label: 'Settings: Plugins', group: 'Actions', keywords: 'plugins marketplace extensions workbench surfaces install', run: () => openSettingsAt('plugins') }, { id: 'heroi-new-conversation', label: 'Heroi: New conversation', group: 'Actions', keywords: 'heroi agent chat claude codex cursor', run: () => window.dispatchEvent(new CustomEvent(HEROI_NEW_CONVERSATION_EVENT)) }, @@ -2211,6 +2219,7 @@ export function App() { onOpenRepo={openViaDialog} onOpenRecent={openByPath} onCreateStash={() => setStashDialog({ snapshot: true, keepIndex: false })} + onVerifyTag={(name) => { if (activePath) setTagVerification({ path: activePath, name }); }} onCreateTag={() => setTagDialog({ target: null, label: 'HEAD' })} onCreateBranch={(start, label) => setBranchDialog({ start, label })} onBranchFromStash={(index) => setBranchDialog({ @@ -2356,6 +2365,8 @@ export function App() { /> )} + {tagVerification && setTagVerification(null)} />} {tagDialog && ( void; /** Open the New-tag dialog targeting HEAD. */ onCreateTag: () => void; + onVerifyTag: (name: string) => void; /** Open the New-branch dialog from `start` (`null` ⇒ HEAD); `label` is the * human name shown in the blurb. */ onCreateBranch: (start: string | null, label: string) => void; @@ -176,7 +177,7 @@ function sortTree(node: TreeNode, leafCmp: (a: T, b: T) => number): void { // ─── component ────────────────────────────────────────────────────────── -export function Sidebar({ onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, onCreateBranch, onBranchFromStash, onCreateWorktree, onMerge, onInteractiveRebase, onManageRemote, onRenameBranch, onManageBranchNetwork, onPull, onPush, onForcePush, onFetchBranch, onPullBranch, onOpenFileInEditor, onCreateFileEntry, onToast }: SidebarProps) { +export function Sidebar({ onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, onVerifyTag, onCreateBranch, onBranchFromStash, onCreateWorktree, onMerge, onInteractiveRebase, onManageRemote, onRenameBranch, onManageBranchNetwork, onPull, onPush, onForcePush, onFetchBranch, onPullBranch, onOpenFileInEditor, onCreateFileEntry, onToast }: SidebarProps) { const view = useRepo((s) => s.view); const setView = useRepo((s) => s.setView); const selectFile = useRepo((s) => s.selectFile); @@ -770,6 +771,7 @@ export function Sidebar({ onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpen { label: 'Copy tag name', icon: 'file', onSelect: () => { void copyToClipboard(tg.name); onToast('Tag name copied'); } }, { label: 'Copy commit SHA', icon: 'file', onSelect: () => { void copyToClipboard(tg.target); onToast('Commit SHA copied'); } }, ); + items.push({ label: 'Verify tag signature…', icon: 'tag', onSelect: () => onVerifyTag(tg.name) }); items.push({ label: 'Delete tag', icon: 'trash', danger: true, confirm: true, onSelect: () => void runBranchOp(() => deleteTag(tg.name)) }); return items; }; diff --git a/ui/src/components/SigningChoice.tsx b/ui/src/components/SigningChoice.tsx new file mode 100644 index 00000000..389feb4d --- /dev/null +++ b/ui/src/components/SigningChoice.tsx @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react'; +import { errMessage, tauri } from '../lib/tauri'; +import type { SigningMode, SigningSettings } from '../lib/types'; + +export function SigningChoice({ path, kind, annotated = false, settingsLink = true, value, disabled, onChange }: { + path: string; kind: 'commit' | 'tag'; annotated?: boolean; settingsLink?: boolean; + value: SigningMode; disabled: boolean; onChange: (value: SigningMode) => void; +}) { + const [settings, setSettings] = useState(null); + const [error, setError] = useState(null); + useEffect(() => { + let active = true; + let sequence = 0; + const load = () => { + const request = ++sequence; + void tauri.repoSigningSettings(path).then((result) => { + if (active && request === sequence) { setSettings(result); setError(null); } + }).catch((e) => { if (active && request === sequence) setError(errMessage(e)); }); + }; + load(); + window.addEventListener('strand:git-config-changed', load); + window.addEventListener('focus', load); + return () => { active = false; window.removeEventListener('strand:git-config-changed', load); window.removeEventListener('focus', load); }; + }, [path]); + const inherited = settings && (kind === 'commit' ? settings.commit_sign + : settings.tag_sign || (annotated && settings.tag_force_annotated)); + return
+ + {settingsLink && } + {error && {error}} +
; +} diff --git a/ui/src/demo/dispatch.ts b/ui/src/demo/dispatch.ts index 7707aebf..25baaad2 100644 --- a/ui/src/demo/dispatch.ts +++ b/ui/src/demo/dispatch.ts @@ -69,6 +69,10 @@ export const handlers: Record = { microsoft_store_update_available: () => false, microsoft_store_open_product: () => unavailable('The Microsoft Store'), crash_report_check: () => ({ path: '', len: 0, entry: null }), + repo_signing_settings: () => ({ effective: {}, local: {}, worktree: {}, worktree_enabled: false, + commit_sign: false, tag_sign: false, tag_force_annotated: false }), + repo_set_signing_config: () => unavailable('Signing configuration'), + repo_tag_verify: () => unavailable('Tag signature verification'), repo_identity: () => { const source = (value: string) => ({ value, scope: 'demo', origin: 'Demo identity' }); const identity = { identity: `${repo.identity.name} <${repo.identity.email}>`, error: null, @@ -215,6 +219,7 @@ export const handlers: Record = { repo_discard_many: (a) => { const wt = wtOf(a); for (const f of a.files as string[]) repo.discard(wt, f); }, repo_apply_patch: (a) => repo.applyPatchTo(wtOf(a), str(a.patch), a.target as 'index' | 'index_reverse' | 'workdir_reverse' | 'workdir'), repo_commit: (a) => { + if (a.signing === 'sign') return unavailable('Commit signing'); const c = repo.commitIndex(wtOf(a), str(a.subject), a.body == null ? null : str(a.body), Boolean(a.amend)); return { oid: c.hash, amended: Boolean(a.amend), output: 'Demo commit created.' }; }, @@ -262,7 +267,10 @@ export const handlers: Record = { repo_remote_set_urls: (a) => { const r = repo.remotes.find((x) => x.name === str(a.name)); if (r) { r.url = str(a.url); r.push_url = a.pushUrl == null ? null : str(a.pushUrl); } }, repo_remote_set_default: (a) => { for (const r of repo.remotes) r.is_default = r.name === str(a.name); }, repo_remote_tags: () => repo.tags.map((t) => t.name), - repo_tag_create: (a) => repo.tagCreate(wtOf(a), str(a.name), a.target == null ? null : str(a.target), a.message == null ? null : str(a.message), Boolean(a.force)), + repo_tag_create: (a) => { + if (a.signing === 'sign') return unavailable('Tag signing'); + return repo.tagCreate(wtOf(a), str(a.name), a.target == null ? null : str(a.target), a.message == null ? null : str(a.message), Boolean(a.force)); + }, repo_tag_delete: (a) => repo.tagDelete(str(a.name)), repo_tag_push: async (a) => { await streamProgress(a.onEvent as Channel, ['Writing objects'], 'done'); diff --git a/ui/src/lib/tauri.ts b/ui/src/lib/tauri.ts index 8d4dbb13..009e037a 100644 --- a/ui/src/lib/tauri.ts +++ b/ui/src/lib/tauri.ts @@ -26,6 +26,10 @@ import type { FileStatus, GlobalIdentity, RepositoryIdentity, + SigningMode, + SigningScope, + SigningSettings, + TagVerification, HostingConnectionStatus, HeroiAgentEvent, HeroiAgentOutcome, @@ -361,8 +365,8 @@ export const tauri = { patch: string, target: 'index' | 'index_reverse' | 'workdir_reverse' | 'workdir', ) => invoke('repo_apply_patch', { path, patch, target }), - repoCommit: (path: string, subject: string, body: string | null, amend: boolean) => - invoke('repo_commit', { path, subject, body, amend }), + repoCommit: (path: string, subject: string, body: string | null, amend: boolean, signing: SigningMode = 'inherit') => + invoke('repo_commit', { path, subject, body, amend, signing }), repoFetch: ( path: string, remote: string | null, @@ -576,7 +580,8 @@ export const tauri = { target: string | null, message: string | null, force: boolean, - ) => invoke('repo_tag_create', { path, name, target, message, force }), + signing: SigningMode = 'inherit', + ) => invoke('repo_tag_create', { path, name, target, message, force, signing }), repoTagDelete: (path: string, name: string) => invoke('repo_tag_delete', { path, name }), repoTagPush: ( @@ -641,6 +646,10 @@ export const tauri = { invoke('repo_open_in_editor', { path, file, line, template }), repoOpenInTerminal: (path: string, template: string) => invoke('repo_open_in_terminal', { path, template }), + repoTagVerify: (path: string, name: string) => invoke('repo_tag_verify', { path, name }), + repoSigningSettings: (path: string) => invoke('repo_signing_settings', { path }), + repoSetSigningConfig: (path: string, scope: SigningScope, key: string, value: string | null) => + invoke('repo_set_signing_config', { path, scope, key, value }), repoIdentity: (path: string) => invoke('repo_identity', { path }), repoSetIdentity: (path: string, field: 'name' | 'email', value: string | null) => invoke('repo_set_identity', { path, field, value }), diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index ccd49fcd..026e2ed6 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -945,3 +945,20 @@ export interface RepositoryIdentity { committer: EffectiveIdentity; local: GlobalIdentity; } + +export type SigningMode = 'inherit' | 'sign' | 'unsigned'; +export type SigningScope = 'local' | 'worktree'; +export interface SigningSettings { + effective: Record; + local: Record; + worktree: Record; + worktree_enabled: boolean; + commit_sign: boolean; + tag_sign: boolean; + tag_force_annotated: boolean; +} +export interface TagVerification { + oid: string; + status: 'unsigned' | 'verified' | 'failed'; + output: string; +} diff --git a/ui/src/stores/commitDrafts.ts b/ui/src/stores/commitDrafts.ts index 85d07793..90ddd370 100644 --- a/ui/src/stores/commitDrafts.ts +++ b/ui/src/stores/commitDrafts.ts @@ -1,16 +1,18 @@ +import type { SigningMode } from '../lib/types'; import { create } from 'zustand'; interface CommitDraft { subject: string; body: string; amend: boolean; + signing: SigningMode; submitting: boolean; output: string; error: string | null; } export const emptyCommitDraft: CommitDraft = { - subject: '', body: '', amend: false, submitting: false, output: '', error: null, + subject: '', body: '', amend: false, signing: 'inherit', submitting: false, output: '', error: null, }; /** Session drafts belong to a checkout, including while a hook is running. */ diff --git a/ui/src/stores/repo.test.ts b/ui/src/stores/repo.test.ts index 3a5fdf52..ad6f224b 100644 --- a/ui/src/stores/repo.test.ts +++ b/ui/src/stores/repo.test.ts @@ -176,3 +176,23 @@ describe('commit outcome boundary', () => { expect(refresh).not.toHaveBeenCalled(); }); }); + +describe('tag outcome boundary', () => { + it('keeps a created signed tag successful if refresh fails', async () => { + vi.spyOn(tauri, 'repoTagCreate').mockResolvedValue(undefined); + const refresh = vi.fn(async () => { throw new Error('refresh failed'); }); + useRepo.setState({ activePath: '/repo', refreshRefs: refresh, refreshLog: refresh }); + await expect(useRepo.getState().createTag('release', null, 'annotation', 'sign')).resolves.toBeUndefined(); + expect(tauri.repoTagCreate).toHaveBeenCalledWith('/repo', 'release', null, 'annotation', false, 'sign'); + }); + + it('does not refresh another checkout after the signer completes', async () => { + const refresh = vi.fn(async () => {}); + vi.spyOn(tauri, 'repoTagCreate').mockImplementation(async () => { + useRepo.setState({ activePath: '/other' }); + }); + useRepo.setState({ activePath: '/repo', refreshRefs: refresh, refreshLog: refresh }); + await useRepo.getState().createTag('release', null, 'annotation'); + expect(refresh).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/stores/repo.ts b/ui/src/stores/repo.ts index 844ad528..e44f42bc 100644 --- a/ui/src/stores/repo.ts +++ b/ui/src/stores/repo.ts @@ -24,6 +24,7 @@ import type { CodeReviewFinding, Commit, CommitOutcome, + SigningMode, BranchPushRequest, CommitSearchMode, FileDiff, @@ -432,7 +433,7 @@ export interface RepoState { loadRepoDiffMode(): Promise; stageAll(): Promise; unstageAll(): Promise; - commit(subject: string, body: string | null, amend: boolean): Promise; + commit(subject: string, body: string | null, amend: boolean, signing?: SigningMode): Promise; /** Re-read RepoMeta (branch, ahead/behind) for the active tab. */ refreshMeta(): Promise; @@ -542,7 +543,7 @@ export interface RepoState { * Create a tag at `target` (any revspec; null ⇒ HEAD). A non-empty * `message` makes it an annotated tag, otherwise lightweight. */ - createTag(name: string, target: string | null, message: string | null): Promise; + createTag(name: string, target: string | null, message: string | null, signing?: SigningMode): Promise; /** Delete a tag by short name. */ deleteTag(name: string): Promise; /** @@ -1798,12 +1799,12 @@ export const useRepo = create((set, get) => ({ await tauri.repoUnstageMany(path, files); await get().refreshLocalChanges(); }, - async commit(subject, body, amend) { + async commit(subject, body, amend, signing = 'inherit') { const path = get().activePath; if (!path) throw new Error('No repository selected.'); let outcome: CommitOutcome; try { - outcome = await tauri.repoCommit(path, subject, body, amend); + outcome = await tauri.repoCommit(path, subject, body, amend, signing); } catch (error) { // Hooks can edit the index/worktree even when they reject the commit. if (get().activePath === path) await get().refreshLocalChanges().catch(() => {}); @@ -2119,12 +2120,12 @@ export const useRepo = create((set, get) => ({ await get().refreshLocalChanges(); }, - async createTag(name, target, message) { + async createTag(name, target, message, signing = 'inherit') { const path = get().activePath; if (!path) throw new Error('no repo open'); - await tauri.repoTagCreate(path, name, target, message, false); + await tauri.repoTagCreate(path, name, target, message, false, signing); // Refresh refs (sidebar list) and the log (graph chips read from refs). - await Promise.all([get().refreshRefs(), get().refreshLog()]); + if (get().activePath === path) await Promise.allSettled([get().refreshRefs(), get().refreshLog()]); }, async deleteTag(name) { const path = get().activePath; diff --git a/ui/src/styles/features.css b/ui/src/styles/features.css index 52a72821..fe839853 100644 --- a/ui/src/styles/features.css +++ b/ui/src/styles/features.css @@ -9547,3 +9547,10 @@ select.clone-input { .cb-output, .cb-error { max-height: 160px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; } .cb-output { font-size: var(--type-ui-sm); color: var(--text-2); } .cb-output pre { white-space: pre-wrap; margin: 6px 0; } + +.signing-choice { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; font-size: var(--type-ui-sm); color: var(--text-2); } +.signing-choice label { display: flex; align-items: center; gap: 6px; } +.signing-choice select { width: auto; padding: 3px 6px; font-size: inherit; } +.settings-field .settings-hint { overflow-wrap: anywhere; } + +.tag-verification-output { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 300px; overflow: auto; font-size: var(--type-ui-sm); } diff --git a/ui/src/views/LocalChanges.tsx b/ui/src/views/LocalChanges.tsx index 815fe685..c2682355 100644 --- a/ui/src/views/LocalChanges.tsx +++ b/ui/src/views/LocalChanges.tsx @@ -1,3 +1,4 @@ +import { SigningChoice } from '../components/SigningChoice'; import { emptyCommitDraft, useCommitDrafts } from '../stores/commitDrafts'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; @@ -1671,7 +1672,7 @@ function CommitBar({ canCommit, hasChanges }: { canCommit: boolean; hasChanges: const draftPath = activePath ?? ''; const draft = useCommitDrafts((s) => s.drafts[draftPath] ?? emptyCommitDraft); const patchDraft = useCommitDrafts((s) => s.patch); - const { subject, body, amend, submitting, output, error: commitError } = draft; + const { subject, body, amend, signing, submitting, output, error: commitError } = draft; const setSubject = (subject: string) => patchDraft(draftPath, { subject }); const setBody = (body: string) => patchDraft(draftPath, { body }); const setAmend = (amend: boolean) => patchDraft(draftPath, { amend }); @@ -1791,12 +1792,12 @@ function CommitBar({ canCommit, hasChanges }: { canCommit: boolean; hasChanges: async function submit() { const trimmed = subject.trim(); - if (!trimmed || submitting) return; + if (!trimmed || useCommitDrafts.getState().drafts[draftPath]?.submitting) return; if (!canCommit && !amend) return; patchDraft(draftPath, { submitting: true, error: null, output: '' }); try { - const result = await commit(trimmed, body.trim() || null, amend); - patchDraft(draftPath, { subject: '', body: '', amend: false, output: result.output }); + const result = await commit(trimmed, body.trim() || null, amend, signing); + patchDraft(draftPath, { subject: '', body: '', amend: false, signing: 'inherit', output: result.output }); } catch (e) { console.error('commit failed', e); setCommitError(`Commit failed: ${gitErrorHint(e)}`); @@ -1830,6 +1831,7 @@ function CommitBar({ canCommit, hasChanges }: { canCommit: boolean; hasChanges: className="subject" placeholder="Commit subject" value={subject} + disabled={submitting} onChange={(e) => setSubject(e.target.value)} onKeyDown={submitOnShortcut} /> @@ -1858,6 +1860,7 @@ function CommitBar({ canCommit, hasChanges }: { canCommit: boolean; hasChanges: setAmend(e.target.checked)} />{' '} Amend @@ -1887,6 +1890,7 @@ function CommitBar({ canCommit, hasChanges }: { canCommit: boolean; hasChanges: className="cb-body" placeholder="Description (optional)" value={body} + disabled={submitting} onChange={(e) => setBody(e.target.value)} onKeyDown={submitOnShortcut} /> @@ -1905,6 +1909,8 @@ function CommitBar({ canCommit, hasChanges }: { canCommit: boolean; hasChanges:
)} + {activePath && patchDraft(draftPath, { signing })} />} {output &&
Commit output
{output}
} {commitError && (
diff --git a/ui/src/views/TagDialog.tsx b/ui/src/views/TagDialog.tsx index e5ed0056..51c28401 100644 --- a/ui/src/views/TagDialog.tsx +++ b/ui/src/views/TagDialog.tsx @@ -1,3 +1,5 @@ +import { SigningChoice } from '../components/SigningChoice'; +import type { SigningMode } from '../lib/types'; import { useEffect, useRef, useState } from 'react'; import { Dialog } from '../components/Dialog'; @@ -24,6 +26,8 @@ export function TagDialog({ targetLabel: string; onClose: () => void; }) { + const activePath = useRepo((s) => s.activePath); + const [signing, setSigning] = useState('inherit'); const createTag = useRepo((s) => s.createTag); const [name, setName] = useState(''); @@ -43,7 +47,7 @@ export function TagDialog({ setBusy(true); setError(null); try { - await createTag(tagName, target, message.trim() || null); + await createTag(tagName, target, message.trim() || null, signing); onClose(); } catch (e) { if (mountedRef.current) setError(errMessage(e)); @@ -74,7 +78,7 @@ export function TagDialog({ >

- Tag {targetLabel}. Leave the message empty for a lightweight tag. + Tag {targetLabel}. Signed tags require an annotation. Leave the message empty for an unsigned lightweight tag.

+ {activePath && }
- {annotated ? 'Creates an annotated tag.' : 'Creates a lightweight tag.'} + {annotated ? 'Creates an annotated tag with the selected signing policy.' : 'A message is required if the selected policy signs this tag.'}
{error ?
{error}
: null} diff --git a/ui/src/views/TagVerificationDialog.tsx b/ui/src/views/TagVerificationDialog.tsx new file mode 100644 index 00000000..616e6deb --- /dev/null +++ b/ui/src/views/TagVerificationDialog.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; +import { Dialog } from '../components/Dialog'; +import { errMessage, tauri } from '../lib/tauri'; +import type { TagVerification } from '../lib/types'; +import { useRepo } from '../stores/repo'; + +export function TagVerificationDialog({ path, initialName, onClose }: { + path: string; initialName: string | null; onClose: () => void; +}) { + const [tags] = useState(() => useRepo.getState().refs.tags); + const [name, setName] = useState(initialName ?? tags[0]?.name ?? ''); + const [result, setResult] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + useEffect(() => { + if (!name) return; + let active = true; + setBusy(true); setResult(null); setError(null); + void tauri.repoTagVerify(path, name).then((value) => { if (active) setResult(value); }) + .catch((e) => { if (active) setError(errMessage(e)); }) + .finally(() => { if (active) setBusy(false); }); + return () => { active = false; }; + }, [path, name]); + return Done}> +
+ + {busy &&

Verifying…

} + {result && <> + {result.status === 'verified' ? 'Valid signature — review Git’s trust details below' + : result.status === 'unsigned' ? 'Unsigned tag' : 'Signature verification failed'} +

Tag object: {result.oid}

+
{result.output}
+ } + {error &&

{error}

} +
+
; +} diff --git a/ui/src/views/settings/GitSection.tsx b/ui/src/views/settings/GitSection.tsx index 63e43ae7..ba4f8a5d 100644 --- a/ui/src/views/settings/GitSection.tsx +++ b/ui/src/views/settings/GitSection.tsx @@ -1,3 +1,4 @@ +import { SigningSettings } from './SigningSettings'; import { useRepo } from '../../stores/repo'; import { RepositoryIdentity } from './RepositoryIdentity'; import { useEffect, useState } from 'react'; @@ -61,6 +62,7 @@ export function GitSection() { return (
{activePath && } + {activePath && }
Global identity

diff --git a/ui/src/views/settings/SigningSettings.tsx b/ui/src/views/settings/SigningSettings.tsx new file mode 100644 index 00000000..6a5d3723 --- /dev/null +++ b/ui/src/views/settings/SigningSettings.tsx @@ -0,0 +1,82 @@ +import { useEffect, useState } from 'react'; +import { errMessage, tauri } from '../../lib/tauri'; +import type { ScopedValue, SigningScope, SigningSettings as Settings } from '../../lib/types'; +import { ConfigSource } from './RepositoryIdentity'; + +const fields = [ + { key: 'commit.gpgsign', label: 'Sign commits by default', options: ['true', 'false'] }, + { key: 'tag.gpgsign', label: 'Sign tags by default', options: ['true', 'false'] }, + { key: 'tag.forcesignannotated', label: 'Sign annotated tags', options: ['true', 'false'] }, + { key: 'gpg.format', label: 'Signing format', options: ['openpgp', 'ssh', 'x509'] }, + { key: 'user.signingkey', label: 'Signing key ID or path' }, + { key: 'gpg.ssh.allowedsignersfile', label: 'SSH allowed signers file' }, +]; + +function SettingRow({ field, current, effective, busy, save }: { + field: typeof fields[number]; current?: ScopedValue; effective?: ScopedValue; + busy: boolean; save: (key: string, value: string | null) => void; +}) { + const [value, setValue] = useState(current?.value ?? ''); + useEffect(() => { setValue(current?.value ?? ''); }, [current?.value]); + return

+ +

Effective: {effective?.value ?? (field.key === 'gpg.format' ? 'openpgp' : 'Git default')} + {effective && <> · }

+
+ + +
+
; +} + +export function SigningSettings({ path }: { path: string }) { + const [settings, setSettings] = useState(null); + const [scope, setScope] = useState('local'); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + useEffect(() => { + let active = true; + void tauri.repoSigningSettings(path).then((result) => { if (active) setSettings(result); }) + .catch((e) => { if (active) setError(errMessage(e)); }); + return () => { active = false; }; + }, [path]); + async function save(key: string, value: string | null) { + if (busy) return; + setBusy(true); setError(null); + try { + await tauri.repoSetSigningConfig(path, scope, key, value); + setSettings(await tauri.repoSigningSettings(path)); + window.dispatchEvent(new Event('strand:git-config-changed')); + } catch (e) { setError(errMessage(e)); } + finally { setBusy(false); } + } + return
+ Repository signing +

Git uses your existing GPG/SSH agents and configured signing program. + Strand stores key references, never private keys or passphrases. SSH verification + uses Git’s allowed signers file. Removing an override restores inherited config.

+ + {settings && !settings.worktree_enabled &&

+ Worktree scope is available when Git’s extensions.worktreeConfig is enabled.

} + {settings ? fields.map((field) => void save(key, value)} />) :

Loading signing settings…

} + {error &&

{error}

} +
; +} diff --git a/website/docs/everyday-git.md b/website/docs/everyday-git.md index d49a2a4a..66c38ed3 100644 --- a/website/docs/everyday-git.md +++ b/website/docs/everyday-git.md @@ -82,7 +82,15 @@ height so it does not crowd the diff. `Mod+Enter` in either field commits (the Commit button shows the same chip); plain Enter still inserts a newline in the description. An **amend** checkbox rewrites the previous commit instead. -**Commit signing honors your existing setup**: if `commit.gpgSign=true` is configured, Strand runs your real `git commit`, so GPG or SSH signing happens automatically and pre-commit/commit-msg hooks fire as they would on the command line. With signing off, commits are made by Strand's own engine and hooks are not run. +Every commit and amend runs your real `git commit`, including applicable hooks, +`core.hooksPath`, rejecting policies and message rewrites. The **Signature** +selector defaults to **Inherit Git config** and shows whether Git will sign. +**Sign this commit** and **Do not sign this commit** override that operation +without changing config. **Signing settings…** opens scoped defaults and key +references in Settings → Git. Signing uses your existing GPG/SSH agents; hook +or signing failures retain the subject, description, amend and signing choices +for retry, including when you leave this view and return during the session. +Expandable **Commit output** shows bounded diagnostics after success. ### AI commit message suggestions @@ -130,7 +138,17 @@ Each remote is a tree rooted at its name, showing all remote-tracking branches. ### Tags -Clicking a tag reveals the tagged commit in the graph; double-click (or `Enter`) checks it out (detached). The menu offers Checkout, create a branch or worktree from the tag, Push to a remote, Delete on the remote (grayed out for tags the remote doesn't have), copy the tag name or target SHA, and Delete tag. The section `+` opens the tag dialog — adding a message creates an annotated tag. Tags can also be created from a commit's detail panel ("Tag…") and the palette ("Create tag…", "Push all tags"). +Clicking a tag reveals the tagged commit in the graph; double-click (or `Enter`) checks it out (detached). The menu offers Checkout, create a branch or worktree from the tag, Push to a remote, Delete on the remote (grayed out for tags the remote doesn't have), Verify tag signature, copy the tag name or target SHA, and Delete tag. The section `+` opens the tag dialog — adding a message creates an annotated tag. Tags can also be created from a commit's detail panel ("Tag…") and the palette ("Create tag…", "Push all tags"). + +The tag dialog offers **Inherit Git config**, **Sign this tag**, and **Do not sign +this tag**. Inherited signing honors `tag.gpgSign` and `tag.forceSignAnnotated`. +A signed tag requires an annotation; unsigned tags with no message are +lightweight. A failed signing attempt keeps the form open with your draft and +Git’s error. To inspect a signature, use the tag menu or the palette’s **Verify +tag signature…**. The dialog verifies the selected tag object on demand and +shows its immutable object ID, unsigned/valid/failed status, and Git’s verification +output. Review that output for trust details; a valid signature alone does not +establish that you trust the signer. ### Stashes diff --git a/website/docs/settings.md b/website/docs/settings.md index 557b0835..7fd36a87 100644 --- a/website/docs/settings.md +++ b/website/docs/settings.md @@ -1,6 +1,6 @@ # Settings -Open the Settings dialog with `Mod+,`, the gear button in the status bar, or the command palette ("Settings…"). The dialog has nine sections — Appearance, Diff, Keyboard, Git, Hosting, Integrations, AI, Updates, and Privacy. Most changes apply live; git identity and Azure DevOps Server profiles have explicit save actions. +Open the Settings dialog with `Mod+,`, the gear button in the status bar, or the command palette ("Settings…"). The dialog has nine sections — Appearance, Diff, Keyboard, Git, Hosting, Integrations, AI, Updates, and Privacy. Most changes apply live; Git identity, signing, and Azure DevOps Server profiles have explicit save actions. The sidebar is a keyboard-navigable list: `↑`/`↓` move between sections, `Home`/`End` jump to the first or last, and `Escape` closes the dialog. @@ -46,10 +46,26 @@ Below the rebindable list, a **Context shortcuts** card documents the fixed, sur **Remove name/email override** edit only direct local config. Linked worktrees share these local values; existing worktree, conditional and environment precedence remains visible. Amend preserves the original author. -- **Global identity** — Name and Email inputs written to your global git config (`~/.gitconfig`) with an explicit **Save identity** button. This is the author identity for new commits everywhere, not just in Strand. +- **Repository signing** — Choose the write scope: repository config shared by + linked worktrees, or this worktree when `extensions.worktreeConfig` is already + enabled. Each setting shows its effective value and source. Save or remove + overrides for commit/tag signing defaults, annotated-tag signing, signing + format (OpenPGP, SSH, or X.509), key ID/path, and SSH allowed signers file. + Removing an override restores inheritance; global and included files are + unchanged. The palette action **Settings: Repository identity and signing** + opens this section. +- **Global identity** — Name and Email inputs written to your global git config (`~/.gitconfig`) with an explicit **Save identity** button. These defaults apply to Git outside Strand too; repository, worktree, conditional and environment overrides can take precedence. - **Default clone & open folder** — a path with **Choose…** and **Clear** buttons. This is where the clone dialog and the open-repository picker start. -Everything else about git — credentials, SSH keys, commit signing — is inherited from your existing git setup: network operations (push, pull, fetch, clone) go through your system `git`, and every commit/amend does too. Signed and unsigned commits honor hooks (including `core.hooksPath`), rejecting policies and message rewrites. A rejection preserves your checkout’s draft; expandable commit output retains bounded hook diagnostics. Signing continues to use your existing Git config and agents. +Network operations, commit/amend and tag creation use your system Git. +Credentials, signing programs and GPG/SSH agents come from your existing setup. +Strand stores key references, never private keys or passphrases. SSH verification +uses Git’s allowed signers file. Commit and tag forms offer **Inherit Git config**, +**Sign this commit/tag**, and **Do not sign this commit/tag**; these choices apply +only to that operation. Signed and unsigned commits honor hooks (including +`core.hooksPath`), rejection and message rewrites. A rejection preserves your +checkout’s draft and signing choice; expandable commit output retains bounded +hook diagnostics. ## Hosting From eb6d8ea66b21353aab286d22973a4d6fcfbd35fb Mon Sep 17 00:00:00 2001 From: Daniels-Main Date: Sun, 6 Sep 2026 18:22:54 +0200 Subject: [PATCH 8/8] Correct integration validation note formatting --- docs/learnings.md | 5 ++++- docs/sparse-clone-verification.md | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/learnings.md b/docs/learnings.md index d0935bc2..78105810 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -2585,4 +2585,7 @@ signature verification out of status/snapshot and graph-wide refresh paths. In `git config --null --get-regexp` output, a valueless boolean has no newline separator and means true; a newline followed by an empty value means false. Preserve that distinction in settings displays and scoped editing. -`nLFS guards must run before sparse-index mutation dispatch. Refresh an attached`nmemory-only sparse index through `sparse_read_index`, never `Index::read` from`ndisk; keep one process-tree cancellation helper when composing Git workflows. + +LFS guards must run before sparse-index mutation dispatch. Refresh an attached +memory-only sparse index through `sparse_read_index`, never `Index::read` from +disk; keep one process-tree cancellation helper when composing Git workflows. diff --git a/docs/sparse-clone-verification.md b/docs/sparse-clone-verification.md index ed12e335..979b703b 100644 --- a/docs/sparse-clone-verification.md +++ b/docs/sparse-clone-verification.md @@ -38,4 +38,7 @@ dirty work rather than implicitly stashing it; they show busy state and Git's warnings. Streamed progress and cancellation apply to clone/history downloads. Partial content reads can require network access. Live execution and transport cancellation validation was Windows only. -`nPR integration validation (2026-09-06): the combined LFS/sparse-index fixture`npasses pointer staging, filtered discard, and partial-patch rejection without`nexpanding the on-disk sparse index. Rust checks and TypeScript pass. + +PR integration validation (2026-09-06): the combined LFS/sparse-index fixture +passes pointer staging, filtered discard, and partial-patch rejection without +expanding the on-disk sparse index. Rust checks and TypeScript pass.