Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,18 @@ concurrency:
jobs:
rust:
name: Rust (test + clippy)
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4

# strand-tauri links the full Tauri stack, which needs the webkit/gtk
# dev packages even for `cargo check`.
- name: Install Linux build dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
Expand All @@ -35,6 +40,10 @@ jobs:
build-essential \
git-lfs

- name: Install macOS test dependencies
if: runner.os == 'macOS'
run: brew list git-lfs >/dev/null 2>&1 || brew install git-lfs

- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
Expand Down Expand Up @@ -115,6 +124,15 @@ jobs:
if ($process.ExitCode -ne 0) { throw "WebView2 install failed: $($process.ExitCode)" }
}
# The harness runs the actual Tauri app with an isolated identity/profile.
- name: Test Windows reset paths and process cancellation
shell: pwsh
run: |
cargo test -p strand-core reset::tests
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
cargo test -p strand-core network::bounded_process_tests
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
cargo test -p strand-tauri pull_requests::pages::tests
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Exercise native review, persistence and recovery twice
run: node scripts/test-review-native.mjs --repeat 2 --output target/review-native-ci
- name: Retain native screenshots, feedback and logs
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 6 additions & 5 deletions PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,8 @@ Achieving these is the entire reason for choosing Rust + `gix` + Tauri over the
- **GPG / signing:** never store passphrases. Delegate to the user's `gpg-agent` / SSH agent.
- **Code execution:** hooks run as `git` always has — Strand doesn't sandbox them but warns clearly when a fresh clone has them.
- **Auto-update:** signed update manifests; refusal to apply unsigned updates.
- **Open source:** plan to open-source the app (license TBD, likely AGPL or source-available like Sublime Merge). Decided before launch.
- **Open source:** AGPL-3.0 public source with a dual-license commercial option
(decided 2026-06-12). Contributor terms remain a separate release gate.

---

Expand Down Expand Up @@ -402,7 +403,7 @@ not block stable.
| Risk | Mitigation |
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `@pierre/trees` is still v1.0.0-beta — API may change | Pin versions; contribute upstream; budget for one major-version migration before 1.0. |
| Pierre libraries' licenses not yet confirmed for commercial use | **Open Q1: verify licenses** before committing. Both are described as "open source" but the exact license matters. |
| Pierre library licensing | Cleared for use on 2026-05-25; see TASKS Blockers. |
| `gix` does not yet cover 100% of write operations | Hybrid with `git2` and shell-out is fine and proven (Sublime Merge does it). |
| Interactive rebase UX is hard | Plan a custom sequence-editor protocol with the shelled-out `git rebase -i`. Tower's implementation is the bar. |
| Windows unmanaged MSI/EXE signing requires an EV/Authenticode identity | Preferred distribution uses the Partner Center-signed Store MSIX; obtain a separate identity only if promoting the unmanaged fallback. |
Expand All @@ -411,13 +412,13 @@ not block stable.

### Open questions

1. **Pierre library licensing** — confirm both libraries are usable in a commercial desktop app, or arrange a license.
2. **Open source or source-available?** — affects positioning and contribution model. Decide before 0.5.
1. **Pierre library licensing** — resolved 2026-05-25; both libraries cleared for use.
2. **Open source or source-available?** — resolved: AGPL-3.0 with a commercial dual-license option; contributor terms remain open.
3. **AI features?** — commit message suggestions, conflict resolution hints, PR description drafts. Not in v1, but worth designing the extension point now.
4. **Hosted code review scope.** — Decided 2026-07-13: build a provider-neutral
PR workspace. GitHub and Azure DevOps ship first; GitLab and Bitbucket are
follow-on adapters. The provider remains the source of truth.
5. **Pricing model?** — Tower-style subscription, Sublime Merge-style one-time, or free / OSS. Affects everything downstream.
5. **Pricing model** — resolved: free for individuals, optional one-time commercial support license for companies, without feature gating or nag dialogs.

---

Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ the resolved app appearance automatically.
agent edits, hidden diff panes load patches when opened, and Files reuses
its inventory until paths or ignore rules change. Workspace scans run with
bounded concurrency; Blame highlights code off the UI thread.
- **Safer file operations** — discard and unstage treat selected filenames
literally, dangling symlinks stage as links, and hard reset refuses collisions
with untracked or ignored data. Rename/move protects Git metadata, and Ignore
refuses symlinked `.gitignore` files. Working-tree text previews read a bounded
prefix of large files. Network cancellation stops Git helpers even after
their parent exits, including on Windows.
- **Workbench (⌘1)** — Strand's default workspace combines editable
working-tree file documents and embedded shells in VS Code-style resizable
panes. Drag tabs to reorder them, move them between panes, or drop on a pane
Expand Down Expand Up @@ -423,6 +429,9 @@ Prerequisites:

- **Rust** stable (`rustup default stable`)
- **Node** ≥ 20 and **pnpm** ≥ 9
- **Git LFS** for the Rust integration tests (`brew install git-lfs` on macOS;
`sudo apt-get install git-lfs` on Ubuntu). Tests configure disposable
repositories locally; no global `git lfs install` is needed.
- Platform deps for Tauri 2: see <https://v2.tauri.app/start/prerequisites/>

```sh
Expand All @@ -435,6 +444,11 @@ pnpm tauri:build # installers in target/release/bundle
The frontend detects when it isn't running inside Tauri and disables IPC
calls, so `pnpm dev` is useful for UI work without a Rust build.

Run `cargo test -p strand-core -p strand-tauri` and
`pnpm --filter ./ui test` for the engine/shell and frontend suites. Rust CI
runs on Linux and macOS; optional signing/Git-flow integration tests remain
explicitly ignored unless their tooling is configured.

## Project layout

```
Expand Down
34 changes: 33 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -3087,6 +3087,37 @@ throwing. Fetch/pull/push progress and clone/open `ProgressPopup` are unchanged.

## Cross-cutting tracks (run in parallel with all milestones)

**Main hardening audit kick (2026-09-29):** Audited 1.7.2 at `f5ed9a8` after
pulling main. Real-repository probes reproduced overly broad discard/unstage,
unprotected untracked hard-reset collisions, ignore-file symlink writes,
dangling-link staging and administrative rename destinations. Source review
also found an unbounded content read; macOS tests reproduce missing-worktree
path-alias failure. `docs/main-audit-2026-09-29.md` records seven prioritized
fixes and remaining platform/performance/product work. Frontend tests/build,
Rust check and release-policy checks pass; Rust test failures are explicitly
triaged, not declared green. TASKS carries the open fixes and validation work.
This is an audit/planning milestone; no application fixes shipped in this pass.

**Main audit repairs implemented (2026-09-29):** A01–A07 now have native
fixes and regression coverage: literal special-name batches, untracked/ignored
hard-reset collision guards, symlink-safe Ignore edits, dangling-link staging,
bounded text reads, protected metadata moves and missing-worktree path identity.
Mac test fixtures now use canonical includeIf paths, local signing/LFS setup,
blocking accepted sockets and child-readiness checkpoints. Rust CI adds macOS.
Cancellation also stops Unix helpers after Git exits and complete provider CLI
process trees before joining pipes. Core/integration, Tauri, frontend, typecheck,
Rust check and clippy pass locally; the audit records counts and limitations.

**PR #138 review hardening (2026-09-29):** Provider cleanup retains Unix child
identity until signaling completes. Streaming Git on Windows starts suspended,
joins an owned Job Object, then resumes so helper cleanup survives leader exit.
Reset collision checks refresh the index after external Git changes. Nested
tracked paths, stale-index collisions and dead-leader cleanup have regression
coverage; Windows CI runs the relevant native test subsets.
Linux CI also exposed a terminal exit-notification ordering race; the registry
now removes the completed session before notifying observers.
Native packaged-app and production performance certification remain open.

**Performance audit kick (2026-09-06):** Rechecked `main` at `8e83c8c` on
Windows against the 100k-commit and 10k-file fixtures. Fresh snapshots remain
~36ms and discover+log(5000) ~76ms; the costly paths are 501-file patch
Expand Down Expand Up @@ -3137,7 +3168,8 @@ cross-platform performance certification remain explicit follow-ups.
3. ◐ AI features extension point — `CommitMessageGenerator` trait +
subscription-first commit suggestions (`repo_suggest_commit_message`,
Settings → AI, CommitBar Suggest, ⌘⇧M / palette).
4. ☐ PR review surface — 1.1 candidate.
4. ☑ PR review surface — GitHub/Azure workspace implemented; remaining
provider validation is tracked separately in TASKS.
5. ☑ Pricing — free for all, honor-system paid commercial license.
- **Naming & trademark.** USPTO/EUIPO/WIPO search before 0.5 public launch.

Expand Down
53 changes: 53 additions & 0 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,59 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git-

## strand-core (Rust git engine)

### Main audit follow-ups (2026-09-29)

Evidence and acceptance criteria: [`docs/main-audit-2026-09-29.md`](./docs/main-audit-2026-09-29.md).

- ☑ Audit latest main for correctness, safety, performance and remaining work
(`main-audit-2026-09-29.md`; disposable engine probes, frontend/build checks,
Rust failure triage and live protocol-7 availability check).
- ☑ **A01 / P1 — Literal file targeting.** Prevent discard/unstage from
expanding selected filenames as wildcard pathspecs; regress `[id].tsx`
alongside `i.tsx` through single and bulk actions (`run_literal_paths`,
literal-path regression tests; ordinary batches remain in-process).
- ☑ **A02 / P1 — Hard-reset collision recovery.** Refuse or preserve
untracked/ignored content threatened by the target tree before reset; match
the dialog's recovery promise to actual coverage (`guard_reset_tree`,
`guard_replaced_directory`, `ResetDialog`; normal/sparse/LFS fixtures).
- ☑ **A03 / P1 — Ignore-file write boundary.** Refuse symlink/nonregular
`.gitignore` targets and external-path writes (`Repo::gitignore_add`).
- ☑ **A04 / P2 — Dangling-symlink staging.** Use entry existence rather than
referent existence (`entry_exists`; new/modified single/batch link regressions).
- ☑ **A05 / P2 — Bound file content reads.** Enforce the content cap before
allocation/read (`file_content` bounded prefix; 1 GiB/UTF-8 regression,
18.6 MB peak RSS for the test process).
- ☑ **A06 / P2 — Administrative rename destinations.** Reject moves into/out
of `.git` and its aliases before filesystem mutation (`guard_move_metadata`;
ordinary and linked-worktree regression tests).
- ☑ **A07 / P2 — Missing-worktree path identity.** Make registered missing
targets match macOS path aliases without relaxing recovery guards
(`resolve_missing_path`; existing macOS removal and archive guard tests pass).
- ☑ **Validation follow-up.** Fixed signing/identity/LFS test isolation,
blocking accepted sockets on macOS, and cancellation readiness; added macOS
Rust CI (`ci.yml` matrix, fixture configuration and readiness checkpoints).
Full core/integration and Tauri suites pass; optional integrations remain
explicitly ignored. Detailed evidence is in the audit implementation update.
- ☑ **Cancellation follow-up.** Stop Unix Git helpers after leader exit, and
own provider-command process trees through cancellation, timeout and natural
completion (`kill_git_tree`, `run_command_input_cancellable`; helper regressions).
- ☐ Bound hosted-provider CLI stdout/stderr while reading, with explicit
overflow cancellation (`run_command_input_cancellable` still reads to EOF;
separate from the completed process-tree cancellation repair).
- ☑ **PR #138 review follow-up.** Keep provider leaders unreaped until Unix
group cleanup (`provider_exited` / `waitid(WNOWAIT)`); own Windows streaming
Git helpers in a Job Object assigned before execution (`WindowsJob::spawn`);
refresh the reset index before collision checks after external Git changes
(`Index::read(true)`, stale-index and nested-path regressions). The claimed
Windows separator bug was disproved against git2 0.19's path conversion.
- ☑ **PR #138 Linux CI follow-up.** Remove naturally exited terminal sessions
before publishing Exit/Error, so observers cannot see a dead terminal as
active (`terminal_reader`; synchronous count-at-exit regression).
- ◐ **Planning reconciliation.** Resolved stale PRD licensing/pricing and
ROADMAP PR-review claims; protocol-7 availability is verified. Current native
release/performance certification and historical external publication/Store/SEO
rows still require platform or provider evidence (audit implementation update).

### Git-client feature audit follow-ups (2026-09-06)

- ☑ Audit the current Git-client feature surface against implementation
Expand Down
6 changes: 6 additions & 0 deletions crates/strand-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,9 @@ tempfile = "3"

[target.'cfg(unix)'.dependencies]
libc = "0.2"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = [
"Win32_Foundation", "Win32_Security", "Win32_System_JobObjects",
"Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading",
] }
1 change: 1 addition & 0 deletions crates/strand-core/src/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ mod tests {

git(&dir, &["config", "commit.gpgsign", "true"]);
git(&dir, &["config", "gpg.format", "ssh"]);
git(&dir, &["config", "gpg.ssh.program", "ssh-keygen"]);
let missing = dir.join("no-such-key").to_string_lossy().into_owned();
git(&dir, &["config", "user.signingkey", &missing]);

Expand Down
1 change: 1 addition & 0 deletions crates/strand-core/src/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ mod tests {
let mut cfg = repo.config().unwrap();
cfg.set_str("user.name", "Test").unwrap();
cfg.set_str("user.email", "test@example.com").unwrap();
cfg.set_bool("commit.gpgsign", false).unwrap();
}
let sig = git2::Signature::now("Test", "test@example.com").unwrap();
let tree_oid = repo.index().unwrap().write_tree().unwrap();
Expand Down
46 changes: 44 additions & 2 deletions crates/strand-core/src/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,19 @@ impl Repo {
pub fn file_content(&self, rel_path: &str, rev: Option<&str>) -> Result<FileContent> {
match rev {
None => {
use std::io::Read;
let full = self.safe_workdir_path(rel_path)?;
let bytes = std::fs::read(&full)?;
if !std::fs::metadata(&full)?.is_file() {
return Err(Error::Other(format!("{rel_path} is not a regular file")));
}
let file = std::fs::File::open(&full)?;
if !file.metadata()?.is_file() {
return Err(Error::Other(format!("{rel_path} is not a regular file")));
}
// One extra byte establishes truncation even if the file grows
// after stat. Never allocate the complete file to show a prefix.
let mut bytes = Vec::new();
file.take((MAX_CONTENT_BYTES + 1) as u64).read_to_end(&mut bytes)?;
Ok(build_content(rel_path, &bytes, looks_binary(&bytes)))
}
Some(spec) => {
Expand Down Expand Up @@ -257,7 +268,12 @@ const MARKER: &str = "\u{1e}C\u{1e}";

fn build_content(path: &str, bytes: &[u8], binary: bool) -> FileContent {
let truncated = bytes.len() > MAX_CONTENT_BYTES;
let slice = &bytes[..bytes.len().min(MAX_CONTENT_BYTES)];
let mut slice = &bytes[..bytes.len().min(MAX_CONTENT_BYTES)];
if truncated {
if let Err(error) = std::str::from_utf8(slice) {
if error.error_len().is_none() { slice = &slice[..error.valid_up_to()]; }
}
}
let text = if binary {
String::new()
} else {
Expand Down Expand Up @@ -509,4 +525,30 @@ mod tests {

let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn content_reads_are_bounded_and_preserve_utf8_boundaries() {
use std::io::Write;
let (repo, dir) = scratch();
let mut file = std::fs::File::create(dir.join("large.txt")).unwrap();
file.write_all(&vec![b'x'; MAX_CONTENT_BYTES - 1]).unwrap();
file.write_all("😀tail".as_bytes()).unwrap();
// Sparse large fixture: a complete read would allocate 1 GiB.
file.set_len(1 << 30).unwrap();
let content = repo.file_content("large.txt", None).unwrap();
assert!(content.truncated);
assert!(!content.editable);
assert!(!content.binary);
assert_eq!(content.text.len(), MAX_CONTENT_BYTES - 1);
assert!(!content.text.contains('\u{fffd}'));
std::fs::write(dir.join("exact.txt"), vec![b'a'; MAX_CONTENT_BYTES]).unwrap();
let exact = repo.file_content("exact.txt", None).unwrap();
assert!(exact.editable);
assert!(!exact.truncated);
std::fs::write(dir.join("binary"), [0, 1, 2]).unwrap();
assert!(repo.file_content("binary", None).unwrap().binary);
assert!(repo.file_content(".", None).is_err());
let _ = std::fs::remove_dir_all(dir);
}

}
2 changes: 2 additions & 0 deletions crates/strand-core/src/gitconfig.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,8 @@ mod tests {
let content = "[user]\nname = Conditional\nemail = conditional@example.com\n";
std::fs::write(&included, content).unwrap();
let mut config = repo.git2().unwrap().config().unwrap();
#[cfg(unix)]
let first = first.canonicalize().unwrap();
let condition = format!("includeIf.gitdir:{}/.git.path", first.to_string_lossy().replace('\\', "/"));
config.set_str(&condition, &included.to_string_lossy().replace('\\', "/")).unwrap();
assert_eq!(repo.repository_identity().unwrap().author.identity.as_deref(), Some("Conditional <conditional@example.com>"));
Expand Down
Loading
Loading